Compare commits

...
Sign in to create a new pull request.

80 commits

Author SHA1 Message Date
Snider
48d385279b Merge branch 'feat/ml-integration' into dev
Some checks are pending
Security Scan / Go Vulnerability Check (push) Waiting to run
Security Scan / Secret Detection (push) Waiting to run
Security Scan / Dependency & Config Scan (push) Waiting to run
# Conflicts:
#	.gh-actions/ISSUE_TEMPLATE/config.yml
#	.gh-actions/workflows/alpha-release-manual.yml
#	.gh-actions/workflows/alpha-release-push.yml
#	.gh-actions/workflows/alpha-release.yml
#	.gh-actions/workflows/bugseti-release.yml
#	.gh-actions/workflows/ci-manual.yml
#	.gh-actions/workflows/ci-pull-request.yml
#	.gh-actions/workflows/ci-push.yml
#	.gh-actions/workflows/ci.yml
#	.gh-actions/workflows/coverage-manual.yml
#	.gh-actions/workflows/coverage-pull-request.yml
#	.gh-actions/workflows/coverage-push.yml
#	.gh-actions/workflows/coverage.yml
#	.gh-actions/workflows/release.yml
#	cmd/bugseti/go.mod
#	cmd/bugseti/workspace.go
#	go.sum
#	internal/bugseti/submit.go
#	internal/bugseti/updater/go.mod
#	internal/cmd/ml/cmd_ml.go
#	internal/core-ide/go.mod
#	internal/variants/full.go
#	pkg/ml/db.go
2026-02-16 06:13:40 +00:00
Snider
4dcd168cd4 feat: update import paths to use new forge.lthn.ai domain 2026-02-16 06:04:53 +00:00
Claude
1b23082e25 feat(ethics-ab): LEK-1 ethics kernel A/B testing and LoRA POC
Five-phase ethics kernel testing across 4 local models (Gemma 3 12B,
Mistral 7B, DeepSeek V2 16B, Qwen 2.5 7B) proving that Google's
alignment training creates persistent ethical reasoning pathways in
Gemma that survive distillation.

- Phase 1: LEK-1 signed vs unsigned (Gemma 8.8/10 differential)
- Phase 2: Three-way test (unsigned vs LEK-1 vs Axioms of Life)
- Phase 3: Double-signed/sandwich signing mode comparison
- Phase 4: Multilingual filter mapping (EN/RU/CN bypass vectors)
- Phase 5: Hypnos POC training data + MLX LoRA on M3 Ultra

Key findings: sandwich signing optimal for training, DeepSeek CCP
alignment is weight-level (no prompt override), Russian language
bypasses DeepSeek content filters. LoRA POC mechanism confirmed
with 40 examples — needs 200+ for stable generalisation.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 06:04:53 +00:00
Claude
da81534897
feat: integrate lab dashboard as core lab serve
Some checks are pending
Security Scan / Go Vulnerability Check (push) Waiting to run
Security Scan / Secret Detection (push) Waiting to run
Security Scan / Dependency & Config Scan (push) Waiting to run
Port the standalone lab dashboard (lab.lthn.io) into the core CLI as
pkg/lab/ with collectors, handlers, and HTML templates. The dashboard
monitors machines, Docker containers, Forgejo, HuggingFace models,
training runs, and InfluxDB metrics with SSE live updates.

New command: core lab serve --bind :8080

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 04:34:29 +00:00
Claude
a290ab31e9
feat: port 11 LEM data management commands into core ml
Ports all remaining LEM pipeline commands from pkg/lem into core ml,
eliminating the standalone LEM CLI dependency. Each command is split
into reusable business logic (pkg/ml/) and a thin cobra wrapper
(internal/cmd/ml/).

New commands: query, inventory, metrics, ingest, normalize, seed-influx,
consolidate, import-all, approve, publish, coverage.

Adds Path(), Exec(), QueryRowScan() convenience methods to DB type.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 04:02:28 +00:00
Claude
d2bb19c6bd
feat: add Metal memory budget monitoring after each request
Tracks model size at load time and checks Metal active memory after
each generation. If usage exceeds 3× model size, forces double GC
and cache clear as a safety net.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 02:52:26 +00:00
Claude
298c8d9cd8
fix: remove Go-side array ref tracking, rely on MLX-C refcounting
The Go wrapper was tracking inter-array references via desc.inputs,
creating chains that kept all intermediate arrays alive across requests.
After 3-4 requests, Metal memory grew to 170GB+ and macOS killed the
process.

Fix: remove desc.inputs/numRefs entirely. MLX-C has its own internal
reference counting — when Go GC finalizes an Array wrapper, it calls
mlx_array_free which decrements the C-side refcount. If the C-side
count reaches 0, Metal memory is freed. Go GC + MLX-C refcounting
together handle all lifecycle management correctly.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 02:49:35 +00:00
Claude
a27a31faad
fix: add GC-based memory management for MLX array handles
Go GC cannot see Metal/C memory pressure, so intermediate arrays from
each forward pass accumulated without bound, causing OOM kills after
3-4 requests. Fix: runtime.SetFinalizer on every Array releases C
handles when GC collects them, and runtime.GC() is forced every 4
tokens during generation. Also adds SetMemoryLimit(24GB) as a hard
Metal ceiling.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 02:42:28 +00:00
Claude
478bbdd44c
fix: add Metal cache management to prevent memory growth
- Add ClearCache() wrapping mlx_clear_cache
- Clear Metal allocator cache every 8 tokens during generation
- Set 16GB cache limit on backend init
- Prevents GPU memory from growing unbounded during inference

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 02:27:10 +00:00
Claude
b6fbb88bfb
fix: correct SDPA mask mode and slice logits to last position 2026-02-16 02:25:23 +00:00
Claude
70c32135d0
fix: use affine quantization mode and infer head_dim from weights 2026-02-16 02:19:33 +00:00
Claude
ef22946f35
debug: add shape logging and stderr error handler for inference debugging 2026-02-16 02:16:30 +00:00
Claude
c8e66918c3
feat: support quantized inference (4-bit) for Gemma 3
- Add QuantizedLinear with QuantizedMatmul for packed uint32 weights
- Add quantized Embedding with Dequantize before lookup
- Parse quantization config (group_size, bits) from config.json
- Detect .scales/.biases weight tensors and auto-select quantized path
- Add Dequantize op wrapping mlx_dequantize
- Add safety guard to KVCache.Update for malformed shapes
- Handle tied embeddings with quantization (AsLinear helper)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 02:12:31 +00:00
Claude
1ce4f6b251
fix: handle both string and array merge formats in tokenizer
Gemma 3 tokenizer.json uses [["a","b"],...] format for merges
instead of the ["a b",...] format. Support both.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 02:02:55 +00:00
Claude
f8d8bd6556
feat: use native MLX backend when --model-path is set on Apple Silicon
Build-tagged backend selection: MLX on darwin/arm64/mlx, HTTP elsewhere.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 02:01:52 +00:00
Claude
6f2d9f8de4
feat: handle nested text_config and language_model weight prefix
Supports both multimodal (Gemma3ForConditionalGeneration) and
text-only configs. Resolves weights with language_model. prefix
fallback. Computes head_dim from hidden_size when missing.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 02:00:40 +00:00
Claude
c8ec0f9e49
chore: target macOS 26.0, fix duplicate -lstdc++ linker warning
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 01:55:49 +00:00
Claude
290e3416ce
fix: remove unused vars in TopP sampler placeholder
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 01:54:29 +00:00
Claude
f89e80732a
fix: resolve CGo type conflict in error handler
Use pure C callback instead of //export to avoid const char* vs
GoString type mismatch in cgo-generated headers.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 01:53:36 +00:00
Claude
065b42a0be
fix: correct 20 mlx-c API mismatches for v0.4.1
- Use _axis/_axes variants for softmax, argmax, topk, sum, mean, squeeze,
  concatenate, argpartition
- Fix size_t vs int for count parameters throughout
- Fix int64_t strides in as_strided
- Add mlx_optional_int + mode param to quantized_matmul
- Use mlx_array_new() for null arrays (freqs, key, mask, sinks)
- Fix expand_dims to single-axis signature
- Fix compile callback signature (size_t index)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 01:52:29 +00:00
Claude
004c5c9eb9
fix: correct mlx_closure_new_func_payload signature for mlx-c v0.4.1
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 01:41:07 +00:00
Claude
9d664c055a
feat: add native MLX backend for Apple Silicon inference (pkg/mlx)
CGo wrapper for mlx-c providing zero-Python Metal GPU inference.
Includes Gemma 3 model architecture, BPE tokenizer, KV cache,
composable sampling, and OpenAI-compatible serve command.

Build-tagged (darwin && arm64 && mlx) with stubs for cross-platform.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 01:19:04 +00:00
Claude
ca8c155d85
feat: add ML inference, scoring, and training pipeline (pkg/ml)
Port LEM scoring/training pipeline into CoreGo as pkg/ml with:
- Inference abstraction with HTTP, llama-server, and Ollama backends
- 3-tier scoring engine (heuristic, exact, LLM judge)
- Capability and content probes for model evaluation
- GGUF/safetensors format converters, MLX to PEFT adapter conversion
- DuckDB integration for training data pipeline
- InfluxDB metrics for lab dashboard
- Training data export (JSONL + Parquet)
- Expansion generation pipeline with distributed workers
- 10 CLI commands under 'core ml' (score, probe, export, expand, status, gguf, convert, agent, worker)
- 5 MCP tools (ml_generate, ml_score, ml_probe, ml_status, ml_backends)

All 37 ML tests passing. Binary builds at 138MB with all commands.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 00:34:53 +00:00
Claude
01d9aa1b73
refactor: rename module from github.com/host-uk/core to forge.lthn.ai/core/cli
Move module identity to our own Forgejo instance. All import paths
updated across 434 Go files, sub-module go.mod files, and go.work.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 00:30:41 +00:00
b8b144bec0 Merge pull request 'Merge new branch into dev — 382 commits of platform work' (#161) from merge/new-into-dev into dev
Reviewed-on: https://forge.lthn.io/host-uk/core/pulls/161
Reviewed-by: Snider <snider@lethean.io>
2026-02-16 00:26:16 +00:00
Claude
f2272e4f6f
fix: restore CLI entry point and register all commands
The main.go was removed when Wails3 apps were added to cmd/, breaking
`go build .` for the core CLI. Restore it and update variants/full.go
to include daemon, forge, mcpcmd, prod, and session commands. Drop gitea
(superseded by forge) and unifi (unused).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 00:24:00 +00:00
Claude
5fd7705580
feat(mcp): add ML tools subsystem and fix MCP service extension points
Some checks are pending
Security Scan / Go Vulnerability Check (push) Waiting to run
Security Scan / Secret Detection (push) Waiting to run
Security Scan / Dependency & Config Scan (push) Waiting to run
Add 5 ML MCP tools (ml_generate, ml_score, ml_probe, ml_status,
ml_backends) as a Subsystem. Fix pre-existing gaps: add Subsystems(),
Shutdown(), WithProcessService, WithWSHub, WSHub(), ProcessService()
methods, and subsystem registration loop in New().

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 23:58:16 +00:00
Claude
3dbb5988a8
feat(ml): add CoreGo service wrapper and CLI commands (Tasks 6-7)
Some checks are pending
Security Scan / Go Vulnerability Check (push) Waiting to run
Security Scan / Secret Detection (push) Waiting to run
Security Scan / Dependency & Config Scan (push) Waiting to run
Service registration with DI lifecycle, typed options, and backend
management. Ten CLI subcommands under `core ml` for scoring, probing,
export, expansion, status, GGUF/PEFT conversion, agent, and worker.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 23:52:46 +00:00
Claude
fcd1758b7d
feat(ml): add format converters, data pipeline, and scoring agent
Some checks are pending
Security Scan / Go Vulnerability Check (push) Waiting to run
Security Scan / Secret Detection (push) Waiting to run
Security Scan / Dependency & Config Scan (push) Waiting to run
Port remaining lem-repo components into pkg/ml/:
- convert.go: safetensors reader/writer, MLX→PEFT converter
- gguf.go: GGUF v3 writer, MLX→GGUF LoRA converter
- export.go: training data JSONL export with split/filter
- parquet.go: Parquet export with snappy compression
- db.go: DuckDB wrapper for golden set and expansion prompts
- influx.go: InfluxDB v3 client for metrics/status
- ollama.go: Ollama model management (create/delete with adapters)
- status.go: training and generation status display
- expand.go: expansion generation pipeline (Backend interface)
- agent.go: scoring agent with probe running and InfluxDB push
- worker.go: distributed worker for LEM API task processing

Adds parquet-go and go-duckdb dependencies.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 23:46:24 +00:00
Claude
3fdc3f3086
refactor: rename module from github.com/host-uk/core to forge.lthn.ai/core/cli
Some checks are pending
Security Scan / Go Vulnerability Check (push) Waiting to run
Security Scan / Secret Detection (push) Waiting to run
Security Scan / Dependency & Config Scan (push) Waiting to run
Move Go module path to production Forgejo instance.
Updates all imports, go.mod, go.sum, docs, and CI configs.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 23:35:00 +00:00
Claude
6f52e4e3ae
feat(ml): add ML inference and scoring engine from lem-repo
Port LEM scoring pipeline into CoreGo pkg/ml/:
- Backend interface abstracting HTTP, llama-server, and future backends
- HTTPBackend for OpenAI-compatible APIs with retry logic
- LlamaBackend managing llama-server via pkg/process
- Scoring engine with heuristic, semantic, content, and exact suites
- Judge for LLM-based multi-dimensional scoring
- 23 capability probes (math, logic, reasoning, code)
- 6 sovereignty content probes
- GGUF/PEFT format helpers, safetensors reader
- 37 tests passing

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 23:34:54 +00:00
Virgil
1f43073f57 Merge pull request 'feat(bugseti): add HubService for portal coordination' (#160) from feat/bugseti-hub-service into new 2026-02-13 21:45:01 +00:00
Virgil
1facdd602f Merge pull request 'feat(bugseti): migrate from GitHub gh CLI to Forgejo SDK' (#159) from feat/bugseti-forgejo-migration into new 2026-02-13 21:44:43 +00:00
Snider
c72f35bd3f feat(bugseti): wire HubService into main.go with auto-registration
Add HubService to the Wails service list and attempt hub registration
at startup when hubUrl is configured. Drains any pending operations
queued from previous sessions.

Co-Authored-By: Virgil <virgil@lethean.io>
2026-02-13 21:36:59 +00:00
Snider
2a8b5c207f feat(bugseti): implement pending operations queue with disk persistence
Replace no-op stubs with real implementations for queueOp, drainPendingOps,
savePendingOps, and loadPendingOps. Operations are persisted to hub_pending.json
and replayed on next hub connection — 5xx/transport errors are retried, 4xx
responses are dropped as stale. Adds PendingCount() for queue inspection.

Co-Authored-By: Virgil <virgil@lethean.io>
2026-02-13 21:36:08 +00:00
Snider
5d0b6c3a71 feat(bugseti): add hub read operations
Add IsIssueClaimed, ListClaims, GetLeaderboard, and GetGlobalStats
methods. IsIssueClaimed returns (nil, nil) on 404 for unclaimed
issues. GetLeaderboard returns entries and total participant count.

Co-Authored-By: Virgil <virgil@lethean.io>
2026-02-13 21:33:11 +00:00
Snider
d583a074f7 feat(bugseti): add hub write operations
Add Register, Heartbeat, ClaimIssue, UpdateStatus, ReleaseClaim,
and SyncStats methods for hub coordination. ClaimIssue returns
ConflictError on 409 and calls drainPendingOps before mutating.

Co-Authored-By: Virgil <virgil@lethean.io>
2026-02-13 21:32:57 +00:00
Snider
f963a45d9f feat(bugseti): add AutoRegister via Forge token exchange
Exchange a Forge API token for a hub API key by POSTing to
/api/bugseti/auth/forge. Skips if hub token already cached.
Adds drainPendingOps() stub for future Task 7 use.

Co-Authored-By: Virgil <virgil@lethean.io>
2026-02-13 21:31:23 +00:00
Snider
74bb62fda8 feat(bugseti): add HubService HTTP request helpers
Add doRequest() and doJSON() methods for hub API communication. doRequest
builds full URLs, sets bearer auth and JSON headers, tracks connected
state. doJSON handles status codes: 401 unauthorised, 409 ConflictError,
404 NotFoundError, and generic errors for other 4xx/5xx responses.

Co-Authored-By: Virgil <virgil@lethean.io>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-13 21:25:28 +00:00
Snider
f85bba5332 feat(bugseti): add HubService types and constructor
Introduce HubService struct with types for hub coordination: PendingOp,
HubClaim, LeaderboardEntry, GlobalStats, ConflictError, NotFoundError.
Constructor generates a crypto/rand client ID when none exists. Includes
no-op loadPendingOps/savePendingOps stubs for future persistence.

Co-Authored-By: Virgil <virgil@lethean.io>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-13 21:24:38 +00:00
Snider
0af6407666 feat(bugseti): add hub coordination config fields and accessors
Add HubURL, HubToken, ClientID, and ClientName fields to Config struct
for agentic portal integration. Include getter/setter methods following
the existing pattern (SetForgeURL, SetForgeToken also added).

Co-Authored-By: Virgil <virgil@lethean.io>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-13 21:23:02 +00:00
Snider
1f3e6ba4ab docs: add BugSETI HubService implementation plan
10 tasks covering Go client + Laravel auth endpoint.
TDD approach with httptest mocks.

Co-Authored-By: Virgil <virgil@lethean.io>
2026-02-13 21:17:59 +00:00
Snider
39d6dccbf8 docs: add BugSETI HubService design doc
Thin HTTP client for portal coordination API — issue claiming,
stats sync, leaderboard, auto-register via forge token.

Co-Authored-By: Virgil <virgil@lethean.io>
2026-02-13 21:12:53 +00:00
Snider
2979816d83 feat(bugseti): migrate from GitHub gh CLI to Forgejo SDK
Replace all exec.Command("gh", ...) calls with the existing pkg/forge
wrapper around the Forgejo Go SDK. BugSETI no longer requires the gh
CLI to be installed.

Changes:
- fetcher: use forge.ListIssues/GetIssue instead of gh issue list/view
- submit: use forge.ForkRepo/CreatePullRequest instead of gh pr create
- seeder: use git clone with forge URL + token auth instead of gh clone
- ghcheck: CheckForge() returns *forge.Client via forge.NewFromConfig()
- config: add ForgeURL/ForgeToken fields (GitHubToken kept for migration)
- pkg/forge: add Token(), GetCurrentUser(), ForkRepo(), CreatePullRequest(),
  ListIssueComments(), and label filtering to ListIssuesOpts

Co-Authored-By: Virgil <virgil@lethean.io>
2026-02-13 20:55:39 +00:00
Virgil
f0595f6858 Merge pull request 'chore: migrate forge.lthn.ai → forge.lthn.io' (#158) from chore/forge-domain-migration into new 2026-02-13 19:22:41 +00:00
Snider
bdbfc5e59e chore: migrate forge.lthn.ai → forge.lthn.io
Update Forgejo domain references in CI pipeline, vanity import
tool, and core-app codex prompt.

Co-Authored-By: Virgil <virgil@lethean.io>
2026-02-13 19:13:40 +00:00
Charon (snider-linux)
b779c5ece0 Merge pull request 'fix(bugseti): workspace TTL sweeper and configurable limits (#54)' (#157) from fix/54-workspace-ttl-cleanup into new 2026-02-12 20:35:24 +00:00
Charon (snider-linux)
1e0bff0a2e Merge pull request 'fix(security): path traversal in journal logging (#46)' (#156) from fix/issue-46-path-traversal into new 2026-02-12 20:35:22 +00:00
Charon (snider-linux)
e9df62b04e Merge pull request 'fix(bugseti): race condition in QueueService.load() - missing mutex during init' (#155) from fix/51-queue-load-race-condition into new 2026-02-12 20:35:15 +00:00
Charon (snider-linux)
03d7a7dc4e Merge pull request 'fix(security): move Gemini API key from URL to header (#47)' (#154) from fix/gemini-api-key-in-url-47 into new 2026-02-12 20:35:02 +00:00
Claude
6abe90c8cb
fix(bugseti): add background TTL sweeper and configurable workspace limits
The workspace map previously only cleaned up during Capture() calls,
meaning stale entries would accumulate indefinitely if no new captures
occurred. This adds:

- Background sweeper goroutine (Start/Stop lifecycle) that runs every 5
  minutes to evict expired workspaces
- Configurable MaxWorkspaces and WorkspaceTTLMinutes in Config (defaults:
  100 entries, 24h TTL) replacing hardcoded constants
- cleanup() now returns eviction count for observability logging
- Nil-config fallback to safe defaults

Fixes #54

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-12 20:31:25 +00:00
Claude
b0ef3fb215
fix(security): sanitize path components in journal logging (#46)
Prevent path traversal in Journal.Append() by validating RepoOwner and
RepoName before using them in file paths. Malicious values like
"../../etc/cron.d" could previously write outside the journal baseDir.

Defence layers:
- Reject inputs containing path separators (/ or \)
- Reject ".." and "." traversal components
- Validate against safe character regex ^[a-zA-Z0-9][a-zA-Z0-9._-]*$
- Verify resolved absolute path stays within baseDir

Closes #46
CVSS 6.3 — OWASP A01:2021-Broken Access Control

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-12 20:30:47 +00:00
Claude
bcb559630e
fix(bugseti): hold mutex during entire QueueService initialization
Move shared state initialization (issues, seen) and the load() call
inside the mutex scope in NewQueueService() to eliminate the race
window where concurrent callers could observe partially initialized
state. Remove the redundant heap.Init before the lock since load()
already calls heap.Init when restoring from disk.

Add documentation to save() and load() noting they must be called
with q.mu held.

Fixes #51

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-12 20:30:22 +00:00
Claude
bde00e40f4
fix(security): move Gemini API key from URL query params to header (#47)
Pass the API key via x-goog-api-key HTTP header instead of the URL
query parameter to prevent credential leakage in proxy logs, web
server access logs, and monitoring systems.

Resolves: #47 (CVSS 5.3, OWASP A09:2021)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-12 20:29:09 +00:00
Charon (snider-linux)
45d8b5b7d4 Merge pull request 'feat(agentic): agent allowance system — respect model quotas and budgets' (#151) from feat/agentic-allowance-system into new 2026-02-12 20:07:11 +00:00
Charon (snider-linux)
6702d56edb Merge pull request 'feat(agentic): Forgejo integration bridge — PHP service linking platform to forges' (#150) from feat/agentic-forgejo-bridge into new 2026-02-12 20:06:58 +00:00
Charon (snider-linux)
4bc43939a6 Merge pull request 'feat(agentic): agent trust model — security wall between non-aligned agents' (#149) from feat/agentic-trust-model into new 2026-02-12 20:06:50 +00:00
Charon (snider-linux)
d2dd23697f Merge pull request 'feat(agentic): real-time dashboard — live agent activity view' (#148) from feat/agentic-dashboard into new 2026-02-12 20:03:51 +00:00
Athena
65c138e126 feat(agentic): add agent allowance system for model quotas and budgets
Implements quota enforcement for agents including daily token limits,
daily job limits, concurrent job caps, model allowlists, and global
per-model budgets. Quota recovery returns 50% for failed jobs and
100% for cancelled jobs.

Go: AllowanceService with MemoryStore, AllowanceStore interface, and
25 tests covering all enforcement paths.

Laravel: migration for 5 tables (agent_allowances, quota_usage,
model_quotas, usage_reports, repo_limits), Eloquent models,
AllowanceService, QuotaMiddleware, and REST API routes.

Closes #99

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-10 20:32:41 +00:00
Athena
740cf115b2 feat(agentic): add Forgejo integration bridge for PHP platform
Add ForgejoClient and ForgejoService to the Laravel app, providing a
clean service layer for all Forgejo REST API operations the orchestrator
needs. Supports multiple instances (forge, dev, qa) with config-driven
auto-routing, token auth, retry with circuit breaker, and pagination.

Covers issues, PRs, repos, branches, user/token management, and orgs.

Closes #98

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-10 20:16:15 +00:00
Athena
3bfaf37ab1 feat(agentic): add agent trust model with tiered access control
Implements the security wall between non-aligned agents (issue #97).

Adds pkg/trust with:
- Three trust tiers: Full (Tier 3), Verified (Tier 2), Untrusted (Tier 1)
- Agent registry with mutex-protected concurrent access
- Policy engine with capability-based access control
- Repo-scoped permissions for Tier 2 agents
- Default policies matching the spec (rate limits, approval gates, denials)
- 49 tests covering all tiers, capabilities, edge cases, and helpers

Closes #97

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-10 20:04:35 +00:00
Athena
72529a8281 feat(agentic): add real-time dashboard with Livewire components (#96)
Add a live agent activity dashboard to the Core App Laravel frontend.
Provides real-time visibility into agent fleet status, job queue,
activity feed, metrics, and human-in-the-loop actions — replacing
SSH + tail -f as the operator interface.

Dashboard panels:
- Agent Fleet: grid of agent cards with heartbeat, status, model info
- Job Queue: filterable table with cancel/retry actions
- Live Activity Feed: real-time stream with agent/type filters
- Metrics: stat cards, budget gauge, cost breakdown, throughput chart
- Human Actions: inline question answering, review gate approval

Tech: Laravel Blade + Livewire 4 + Tailwind CSS + Alpine.js + ApexCharts

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-10 19:56:17 +00:00
7ce8ca717c Merge pull request 'fix(bugseti): add test coverage for SubmitService PR workflow' (#76) from Athena/core:fix/bugseti-submit-tests into new 2026-02-10 15:43:41 +00:00
37b04695d1 Merge pull request 'fix(bugseti): sanitize shell metacharacters in seeder env vars' (#71) from Athena/core:fix/bugseti-sanitize-shell-metacharacters into new 2026-02-10 15:43:37 +00:00
9fe4d5f063 Merge pull request 'fix(bugseti): update config file permissions to 0600' (#57) from fix/bugseti-config-perms into new 2026-02-10 15:43:19 +00:00
16a5ba70ef Merge pull request 'fix(bugseti): add mutex protection to seeder concurrent access' (#75) from Athena/core:fix/issue-63-seeder-mutex into new 2026-02-10 15:42:58 +00:00
88e5560086 Merge pull request 'fix(bugseti): handle silent git fetch failure in submit.go' (#74) from Athena/core:fix/bugseti-git-fetch-error-62 into new 2026-02-10 15:42:54 +00:00
b57e30ea06 Merge pull request 'fix(bugseti): add gh CLI availability check with helpful error' (#73) from Athena/core:fix/bugseti-gh-cli-check into new 2026-02-10 15:42:51 +00:00
8d3f9a73ee Merge pull request 'fix(bugseti): add comprehensive tests for FetcherService' (#72) from Athena/core:fix/bugseti-fetcher-tests into new 2026-02-10 15:42:48 +00:00
Athena
c4d59f9850 fix(bugseti): add test coverage for SubmitService PR workflow (#64)
Extract buildForkURL helper for testable fork URL construction and add
19 tests covering Submit validation, HTTPS/SSH fork URLs, PR body
generation, and ensureFork error handling.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-10 15:42:45 +00:00
9f9e8cc044 Merge pull request 'fix(bugseti): add TTL cleanup and max size cap to workspace map' (#58) from fix/bugseti-workspace-ttl-55 into new 2026-02-10 15:42:44 +00:00
74e2614e41 Merge pull request 'fix(bugseti): acquire mutex in NewQueueService before load()' (#56) from fix/bugseti-queue-race-52 into new 2026-02-10 15:42:40 +00:00
Athena
149dc3de14 fix(bugseti): add mutex protection to seeder concurrent access
Add sync.Mutex to SeederService to protect shared state during
concurrent SeedIssue, GetWorkspaceDir, and CleanupWorkspace calls.
Extract getWorkspaceDir as lock-free helper to avoid double-locking.

Closes #63

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-10 15:37:11 +00:00
Athena
9319015219 fix(bugseti): handle silent git fetch failure in submit.go
Capture and log the error from `git fetch origin` in createBranch()
instead of silently ignoring it. Warns the user they may be proceeding
with stale data if the fetch fails.

Fixes #62

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-10 15:30:50 +00:00
Athena
25985af53c fix(bugseti): add gh CLI availability check with helpful error
Adds a startup check that verifies gh is in PATH and authenticated
before initializing services. Provides clear install/auth instructions
on failure instead of cryptic exec errors at runtime.

Closes #61

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-10 15:27:53 +00:00
Athena
796ec563ed fix(bugseti): add comprehensive tests for FetcherService (#60)
Add fetcher_test.go covering: service creation, start/pause lifecycle,
calculatePriority scoring for all label types, label query construction
with custom and default labels, gh CLI JSON parsing for both list and
single-issue endpoints, channel backpressure when issuesCh is full,
fetchAll with no repos configured, and missing binary error handling.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-10 15:23:35 +00:00
Athena
e83e416854 fix(bugseti): sanitize shell metacharacters in seeder env vars
SanitizeEnv() only removed control characters but not shell
metacharacters. A malicious repo name could execute arbitrary commands
via environment variable injection (e.g. backticks, $(), semicolons).

Add stripShellMeta() to strip backticks, dollar signs, semicolons,
pipes, ampersands, and other shell-significant characters from values
passed to the bash seed script environment.

Fixes #59

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-10 15:18:36 +00:00
Claude (M3 Studio)
3fc04f809b fix(bugseti): add TTL cleanup and max size cap to workspace map (#55)
The workspaces map in WorkspaceService grew unboundedly. Add cleanup()
that evicts entries older than 24h and enforces a 100-entry cap by
removing oldest entries first. Called on each Capture().

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-10 11:25:00 +00:00
Claude (M3 Studio)
169428a945 fix(bugseti): update config file permissions to 0600
This commit updates the file permissions for the BugSETI configuration file from 0644 to 0600, ensuring owner-only access. This addresses the security concern where the GitHub token stored in the config file was world-readable.

Fixes #53
2026-02-10 11:15:52 +00:00
Claude (M3 Studio)
440086b83a fix(bugseti): acquire mutex in NewQueueService before load()
q.load() accesses shared state (issues, seen, current) without holding
the mutex, creating a race condition. Wrap the call with q.mu.Lock().

Fixes #52

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-10 11:12:46 +00:00
684 changed files with 28691 additions and 5828 deletions

View file

@ -30,7 +30,7 @@ core/
package domain
import (
"github.com/host-uk/core/pkg/cli"
"forge.lthn.ai/core/cli/pkg/cli"
"github.com/spf13/cobra"
)
@ -53,7 +53,7 @@ func NewNameCmd() *cobra.Command {
## CLI Output Helpers
```go
import "github.com/host-uk/core/pkg/cli"
import "forge.lthn.ai/core/cli/pkg/cli"
cli.Success("Operation completed") // Green check
cli.Warning("Something to note") // Yellow warning

View file

@ -1,7 +1,7 @@
# Issue 258: Smart Test Detection
## Original Issue
<https://github.com/host-uk/core/issues/258>
<https://forge.lthn.ai/core/cli/issues/258>
## Summary
Make `core test` smart — detect changed Go files and run only relevant tests.

View file

@ -1,58 +0,0 @@
name: Bug Report
description: Report a problem with the core CLI
title: "[Bug]: "
labels: ["bug", "triage"]
body:
- type: markdown
attributes:
value: |
Thanks for reporting! Please fill out the details below.
- type: dropdown
id: os
attributes:
label: Operating System
options:
- macOS
- Windows
- Linux (Ubuntu/Debian)
- Linux (Other)
validations:
required: true
- type: input
id: command
attributes:
label: Command
description: Which command failed?
placeholder: "e.g., core dev work, core php test"
validations:
required: true
- type: input
id: version
attributes:
label: Version
description: Output of `core version`
placeholder: "e.g., core v0.1.0"
- type: textarea
id: description
attributes:
label: What happened?
description: Describe the issue
validations:
required: true
- type: textarea
id: expected
attributes:
label: Expected behaviour
description: What should have happened?
- type: textarea
id: logs
attributes:
label: Error output
description: Paste any error messages
render: shell

View file

@ -1,8 +0,0 @@
blank_issues_enabled: true
contact_links:
- name: Host UK Documentation
url: https://github.com/host-uk/core-devops
about: Setup guides and workspace documentation
- name: Discussions
url: https://github.com/orgs/host-uk/discussions
about: Ask questions and share ideas

View file

@ -1,58 +0,0 @@
name: Feature Request
description: Suggest a new feature or enhancement
title: "[Feature]: "
labels: ["enhancement"]
body:
- type: markdown
attributes:
value: |
Thanks for the suggestion! Please describe your idea below.
- type: dropdown
id: area
attributes:
label: Area
options:
- dev commands (work, commit, push, pull)
- php commands (test, lint, stan)
- GitHub integration (issues, reviews, ci)
- New command
- Documentation
- Other
validations:
required: true
- type: textarea
id: problem
attributes:
label: Problem or use case
description: What problem does this solve?
validations:
required: true
- type: textarea
id: solution
attributes:
label: Proposed solution
description: How would you like it to work?
validations:
required: true
- type: textarea
id: alternatives
attributes:
label: Alternatives considered
description: Any other approaches you've thought about?
- type: dropdown
id: complexity
attributes:
label: Estimated complexity
description: How much work do you think this requires?
options:
- "Small - Quick fix, single file, < 1 hour"
- "Medium - Multiple files, few hours to a day"
- "Large - Significant changes, multiple days"
- "Unknown - Not sure"
validations:
required: false

View file

@ -1,24 +0,0 @@
version: 2
updates:
- package-ecosystem: "gomod"
directory: "/"
schedule:
interval: "weekly"
day: "monday"
labels:
- "type:dependencies"
- "priority:low"
commit-message:
prefix: "deps(go):"
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "weekly"
day: "monday"
labels:
- "type:dependencies"
- "priority:low"
commit-message:
prefix: "deps(actions):"

View file

@ -1,10 +0,0 @@
name: Agent Verification
on:
issues:
types: [labeled]
jobs:
verify:
uses: host-uk/.github/.github/workflows/agent-verify.yml@main
secrets: inherit

View file

@ -1,92 +0,0 @@
# https://docs.github.com/en/actions/using-workflows/events-that-trigger-workflows#workflow_dispatch
name: "Alpha Release: Manual"
on:
workflow_dispatch:
permissions:
contents: write
id-token: write
attestations: write
env:
NEXT_VERSION: "0.0.4"
jobs:
build:
strategy:
matrix:
include:
- os: ubuntu-latest
platform: linux/amd64
- os: ubuntu-latest
platform: linux/arm64
- os: macos-latest
platform: darwin/universal
- os: windows-latest
platform: windows/amd64
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v6
- name: Build
uses: host-uk/build@v3
with:
build-name: core
build-platform: ${{ matrix.platform }}
build: true
package: true
sign: false
release:
needs: build
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- name: Download artifacts
uses: actions/download-artifact@v7
with:
path: dist
merge-multiple: true
- name: Prepare release files
run: |
mkdir -p release
cp dist/* release/ 2>/dev/null || true
ls -la release/
- name: Create alpha release
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
VERSION="v${{ env.NEXT_VERSION }}-alpha.${{ github.run_number }}"
gh release create "$VERSION" \
--title "Alpha: $VERSION" \
--notes "Canary build from dev branch.
**Version:** $VERSION
**Commit:** ${{ github.sha }}
**Built:** $(date -u +'%Y-%m-%d %H:%M:%S UTC')
**Run:** ${{ github.run_id }}
## Channel: Alpha (Canary)
This is an automated pre-release for early testing.
- Systems and early adopters can test breaking changes
- Quality scoring determines promotion to beta
- Use stable releases for production
## Installation
\`\`\`bash
# macOS/Linux
curl -fsSL https://github.com/host-uk/core/releases/download/$VERSION/core-linux-amd64 -o core
chmod +x core && sudo mv core /usr/local/bin/
\`\`\`
" \
--prerelease \
--target dev \
release/*

View file

@ -1,93 +0,0 @@
# https://docs.github.com/en/actions/using-workflows/events-that-trigger-workflows#push
name: "Alpha Release: Push"
on:
push:
branches: [dev]
permissions:
contents: write
id-token: write
attestations: write
env:
NEXT_VERSION: "0.0.4"
jobs:
build:
strategy:
matrix:
include:
- os: ubuntu-latest
platform: linux/amd64
- os: ubuntu-latest
platform: linux/arm64
- os: macos-latest
platform: darwin/universal
- os: windows-latest
platform: windows/amd64
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v6
- name: Build
uses: host-uk/build@v3
with:
build-name: core
build-platform: ${{ matrix.platform }}
build: true
package: true
sign: false
release:
needs: build
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- name: Download artifacts
uses: actions/download-artifact@v7
with:
path: dist
merge-multiple: true
- name: Prepare release files
run: |
mkdir -p release
cp dist/* release/ 2>/dev/null || true
ls -la release/
- name: Create alpha release
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
VERSION="v${{ env.NEXT_VERSION }}-alpha.${{ github.run_number }}"
gh release create "$VERSION" \
--title "Alpha: $VERSION" \
--notes "Canary build from dev branch.
**Version:** $VERSION
**Commit:** ${{ github.sha }}
**Built:** $(date -u +'%Y-%m-%d %H:%M:%S UTC')
**Run:** ${{ github.run_id }}
## Channel: Alpha (Canary)
This is an automated pre-release for early testing.
- Systems and early adopters can test breaking changes
- Quality scoring determines promotion to beta
- Use stable releases for production
## Installation
\`\`\`bash
# macOS/Linux
curl -fsSL https://github.com/host-uk/core/releases/download/$VERSION/core-linux-amd64 -o core
chmod +x core && sudo mv core /usr/local/bin/
\`\`\`
" \
--prerelease \
--target dev \
release/*

View file

@ -1,500 +0,0 @@
name: Alpha Release
on:
push:
branches: [dev]
workflow_dispatch:
permissions:
contents: write
id-token: write
attestations: write
env:
# Next version - update when releasing
NEXT_VERSION: "0.0.4"
jobs:
build:
strategy:
matrix:
include:
- os: ubuntu-latest
goos: linux
goarch: amd64
- os: ubuntu-latest
goos: linux
goarch: arm64
- os: macos-latest
goos: darwin
goarch: arm64
- os: windows-latest
goos: windows
goarch: amd64
runs-on: ${{ matrix.os }}
env:
GOOS: ${{ matrix.goos }}
GOARCH: ${{ matrix.goarch }}
steps:
- uses: actions/checkout@v6
# GUI build disabled until build action supports Wails v3
# - name: Wails Build Action
# uses: host-uk/build@v4.0.0
# with:
# build-name: core
# build-platform: ${{ matrix.goos }}/${{ matrix.goarch }}
# build: true
# package: true
# sign: false
- name: Setup Go
uses: host-uk/build/actions/setup/go@v4.0.0
with:
go-version: "1.25"
- name: Build CLI
shell: bash
run: |
EXT=""
if [ "$GOOS" = "windows" ]; then EXT=".exe"; fi
BINARY="core${EXT}"
ARCHIVE_PREFIX="core-${GOOS}-${GOARCH}"
APP_VERSION="${{ env.NEXT_VERSION }}-alpha.${{ github.run_number }}"
go build -ldflags "-s -w -X github.com/host-uk/core/pkg/cli.AppVersion=${APP_VERSION}" -o "./bin/${BINARY}" .
# Create tar.gz for Homebrew (non-Windows)
if [ "$GOOS" != "windows" ]; then
tar czf "./bin/${ARCHIVE_PREFIX}.tar.gz" -C ./bin "${BINARY}"
fi
# Create zip for Scoop (Windows)
if [ "$GOOS" = "windows" ]; then
cd ./bin && zip "${ARCHIVE_PREFIX}.zip" "${BINARY}" && cd ..
fi
# Rename raw binary to platform-specific name for release
mv "./bin/${BINARY}" "./bin/${ARCHIVE_PREFIX}${EXT}"
- name: Upload artifact
uses: actions/upload-artifact@v4
with:
name: core-${{ matrix.goos }}-${{ matrix.goarch }}
path: ./bin/core-*
build-ide:
strategy:
matrix:
include:
- os: macos-latest
goos: darwin
goarch: arm64
- os: ubuntu-latest
goos: linux
goarch: amd64
- os: windows-latest
goos: windows
goarch: amd64
runs-on: ${{ matrix.os }}
env:
GOOS: ${{ matrix.goos }}
GOARCH: ${{ matrix.goarch }}
defaults:
run:
working-directory: internal/core-ide
steps:
- uses: actions/checkout@v6
- name: Setup Go
uses: host-uk/build/actions/setup/go@v4.0.0
with:
go-version: "1.25"
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: "20"
- name: Install Wails CLI
run: go install github.com/wailsapp/wails/v3/cmd/wails3@latest
- name: Install frontend dependencies
working-directory: internal/core-ide/frontend
run: npm ci
- name: Generate bindings
run: wails3 generate bindings -f '-tags production' -clean=false -ts -i
- name: Build frontend
working-directory: internal/core-ide/frontend
run: npm run build
- name: Install Linux dependencies
if: matrix.goos == 'linux'
run: |
sudo apt-get update
sudo apt-get install -y libgtk-3-dev libwebkit2gtk-4.0-dev
- name: Build IDE
shell: bash
run: |
EXT=""
if [ "$GOOS" = "windows" ]; then EXT=".exe"; fi
BINARY="core-ide${EXT}"
ARCHIVE_PREFIX="core-ide-${GOOS}-${GOARCH}"
BUILD_FLAGS="-tags production -trimpath -buildvcs=false"
if [ "$GOOS" = "windows" ]; then
# Windows: no CGO, use windowsgui linker flag
export CGO_ENABLED=0
LDFLAGS="-w -s -H windowsgui"
# Generate Windows syso resource
cd build
wails3 generate syso -arch ${GOARCH} -icon windows/icon.ico -manifest windows/wails.exe.manifest -info windows/info.json -out ../wails_windows_${GOARCH}.syso
cd ..
elif [ "$GOOS" = "darwin" ]; then
export CGO_ENABLED=1
export CGO_CFLAGS="-mmacosx-version-min=10.15"
export CGO_LDFLAGS="-mmacosx-version-min=10.15"
export MACOSX_DEPLOYMENT_TARGET="10.15"
LDFLAGS="-w -s"
else
export CGO_ENABLED=1
LDFLAGS="-w -s"
fi
go build ${BUILD_FLAGS} -ldflags="${LDFLAGS}" -o "./bin/${BINARY}"
# Clean up syso files
rm -f *.syso
# Package
if [ "$GOOS" = "darwin" ]; then
# Create .app bundle
mkdir -p "./bin/Core IDE.app/Contents/"{MacOS,Resources}
cp build/darwin/icons.icns "./bin/Core IDE.app/Contents/Resources/"
cp "./bin/${BINARY}" "./bin/Core IDE.app/Contents/MacOS/"
cp build/darwin/Info.plist "./bin/Core IDE.app/Contents/"
codesign --force --deep --sign - "./bin/Core IDE.app"
tar czf "./bin/${ARCHIVE_PREFIX}.tar.gz" -C ./bin "Core IDE.app"
elif [ "$GOOS" = "windows" ]; then
cd ./bin && zip "${ARCHIVE_PREFIX}.zip" "${BINARY}" && cd ..
else
tar czf "./bin/${ARCHIVE_PREFIX}.tar.gz" -C ./bin "${BINARY}"
fi
# Rename raw binary
mv "./bin/${BINARY}" "./bin/${ARCHIVE_PREFIX}${EXT}"
- name: Upload artifact
uses: actions/upload-artifact@v4
with:
name: core-ide-${{ matrix.goos }}-${{ matrix.goarch }}
path: internal/core-ide/bin/core-ide-*
release:
needs: [build, build-ide]
runs-on: ubuntu-latest
outputs:
version: ${{ steps.version.outputs.version }}
steps:
- uses: actions/checkout@v6
- name: Set version
id: version
run: echo "version=v${{ env.NEXT_VERSION }}-alpha.${{ github.run_number }}" >> "$GITHUB_OUTPUT"
- name: Download artifacts
uses: actions/download-artifact@v7
with:
path: dist
merge-multiple: true
- name: Prepare release files
run: |
mkdir -p release
cp dist/* release/ 2>/dev/null || true
ls -la release/
- name: Create alpha release
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
VERSION: ${{ steps.version.outputs.version }}
run: |
gh release create "$VERSION" \
--title "Alpha: $VERSION" \
--notes "Canary build from dev branch.
**Version:** $VERSION
**Commit:** ${{ github.sha }}
**Built:** $(date -u +'%Y-%m-%d %H:%M:%S UTC')
**Run:** ${{ github.run_id }}
## Channel: Alpha (Canary)
This is an automated pre-release for early testing.
- Systems and early adopters can test breaking changes
- Quality scoring determines promotion to beta
- Use stable releases for production
## Installation
\`\`\`bash
# Homebrew (macOS/Linux)
brew install host-uk/tap/core
# Scoop (Windows)
scoop bucket add host-uk https://github.com/host-uk/scoop-bucket
scoop install core
# Direct download (example: Linux amd64)
curl -fsSL https://github.com/host-uk/core/releases/download/$VERSION/core-linux-amd64 -o core
chmod +x core && sudo mv core /usr/local/bin/
\`\`\`
" \
--prerelease \
--target dev \
release/*
update-tap:
needs: release
runs-on: ubuntu-latest
steps:
- name: Download artifacts
uses: actions/download-artifact@v7
with:
path: dist
merge-multiple: true
- name: Generate checksums
run: |
cd dist
for f in *.tar.gz; do
sha256sum "$f" | awk '{print $1}' > "${f}.sha256"
done
echo "=== Checksums ==="
cat *.sha256
- name: Update Homebrew formula
env:
GH_TOKEN: ${{ secrets.HOMEBREW_TAP_TOKEN }}
VERSION: ${{ needs.release.outputs.version }}
run: |
# Strip leading 'v' for formula version
FORMULA_VERSION="${VERSION#v}"
# Read checksums
DARWIN_ARM64=$(cat dist/core-darwin-arm64.tar.gz.sha256)
LINUX_AMD64=$(cat dist/core-linux-amd64.tar.gz.sha256)
LINUX_ARM64=$(cat dist/core-linux-arm64.tar.gz.sha256)
# Clone tap repo (configure auth for push)
gh repo clone host-uk/homebrew-tap /tmp/tap -- --depth=1
cd /tmp/tap
git remote set-url origin "https://x-access-token:${GH_TOKEN}@github.com/host-uk/homebrew-tap.git"
cd -
mkdir -p /tmp/tap/Formula
# Write formula
cat > /tmp/tap/Formula/core.rb << FORMULA
# typed: false
# frozen_string_literal: true
class Core < Formula
desc "Host UK development CLI"
homepage "https://github.com/host-uk/core"
version "${FORMULA_VERSION}"
license "EUPL-1.2"
on_macos do
url "https://github.com/host-uk/core/releases/download/${VERSION}/core-darwin-arm64.tar.gz"
sha256 "${DARWIN_ARM64}"
end
on_linux do
if Hardware::CPU.arm?
url "https://github.com/host-uk/core/releases/download/${VERSION}/core-linux-arm64.tar.gz"
sha256 "${LINUX_ARM64}"
else
url "https://github.com/host-uk/core/releases/download/${VERSION}/core-linux-amd64.tar.gz"
sha256 "${LINUX_AMD64}"
end
end
def install
bin.install "core"
end
test do
system "\#{bin}/core", "--version"
end
end
FORMULA
# Remove leading whitespace from heredoc
sed -i 's/^ //' /tmp/tap/Formula/core.rb
# Read IDE checksums (may not exist if build-ide failed)
IDE_DARWIN_ARM64=$(cat dist/core-ide-darwin-arm64.tar.gz.sha256 2>/dev/null || echo "")
IDE_LINUX_AMD64=$(cat dist/core-ide-linux-amd64.tar.gz.sha256 2>/dev/null || echo "")
# Write core-ide Formula (Linux binary)
if [ -n "${IDE_LINUX_AMD64}" ]; then
cat > /tmp/tap/Formula/core-ide.rb << FORMULA
# typed: false
# frozen_string_literal: true
class CoreIde < Formula
desc "Host UK desktop development environment"
homepage "https://github.com/host-uk/core"
version "${FORMULA_VERSION}"
license "EUPL-1.2"
on_linux do
url "https://github.com/host-uk/core/releases/download/${VERSION}/core-ide-linux-amd64.tar.gz"
sha256 "${IDE_LINUX_AMD64}"
end
def install
bin.install "core-ide"
end
end
FORMULA
sed -i 's/^ //' /tmp/tap/Formula/core-ide.rb
fi
# Write core-ide Cask (macOS .app bundle)
if [ -n "${IDE_DARWIN_ARM64}" ]; then
mkdir -p /tmp/tap/Casks
cat > /tmp/tap/Casks/core-ide.rb << CASK
cask "core-ide" do
version "${FORMULA_VERSION}"
sha256 "${IDE_DARWIN_ARM64}"
url "https://github.com/host-uk/core/releases/download/${VERSION}/core-ide-darwin-arm64.tar.gz"
name "Core IDE"
desc "Host UK desktop development environment"
homepage "https://github.com/host-uk/core"
app "Core IDE.app"
end
CASK
sed -i 's/^ //' /tmp/tap/Casks/core-ide.rb
fi
cd /tmp/tap
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git add .
git diff --cached --quiet && echo "No changes to tap" && exit 0
git commit -m "Update core to ${FORMULA_VERSION}"
git push
update-scoop:
needs: release
runs-on: ubuntu-latest
steps:
- name: Download artifacts
uses: actions/download-artifact@v7
with:
path: dist
merge-multiple: true
- name: Generate checksums
run: |
cd dist
for f in *.zip; do
[ -f "$f" ] || continue
sha256sum "$f" | awk '{print $1}' > "${f}.sha256"
done
echo "=== Checksums ==="
cat *.sha256 2>/dev/null || echo "No zip checksums"
- name: Update Scoop manifests
env:
GH_TOKEN: ${{ secrets.HOMEBREW_TAP_TOKEN }}
VERSION: ${{ needs.release.outputs.version }}
run: |
# Strip leading 'v' for manifest version
MANIFEST_VERSION="${VERSION#v}"
# Read checksums
WIN_AMD64=$(cat dist/core-windows-amd64.zip.sha256 2>/dev/null || echo "")
IDE_WIN_AMD64=$(cat dist/core-ide-windows-amd64.zip.sha256 2>/dev/null || echo "")
# Clone scoop bucket
gh repo clone host-uk/scoop-bucket /tmp/scoop -- --depth=1
cd /tmp/scoop
git remote set-url origin "https://x-access-token:${GH_TOKEN}@github.com/host-uk/scoop-bucket.git"
# Write core.json manifest
cat > core.json << 'MANIFEST'
{
"version": "VERSION_PLACEHOLDER",
"description": "Host UK development CLI",
"homepage": "https://github.com/host-uk/core",
"license": "EUPL-1.2",
"architecture": {
"64bit": {
"url": "URL_PLACEHOLDER",
"hash": "HASH_PLACEHOLDER",
"bin": "core.exe"
}
},
"checkver": "github",
"autoupdate": {
"architecture": {
"64bit": {
"url": "https://github.com/host-uk/core/releases/download/v$version/core-windows-amd64.zip"
}
}
}
}
MANIFEST
sed -i "s|VERSION_PLACEHOLDER|${MANIFEST_VERSION}|g" core.json
sed -i "s|URL_PLACEHOLDER|https://github.com/host-uk/core/releases/download/${VERSION}/core-windows-amd64.zip|g" core.json
sed -i "s|HASH_PLACEHOLDER|${WIN_AMD64}|g" core.json
sed -i 's/^ //' core.json
# Write core-ide.json manifest
if [ -n "${IDE_WIN_AMD64}" ]; then
cat > core-ide.json << 'MANIFEST'
{
"version": "VERSION_PLACEHOLDER",
"description": "Host UK desktop development environment",
"homepage": "https://github.com/host-uk/core",
"license": "EUPL-1.2",
"architecture": {
"64bit": {
"url": "URL_PLACEHOLDER",
"hash": "HASH_PLACEHOLDER",
"bin": "core-ide.exe"
}
},
"checkver": "github",
"autoupdate": {
"architecture": {
"64bit": {
"url": "https://github.com/host-uk/core/releases/download/v$version/core-ide-windows-amd64.zip"
}
}
}
}
MANIFEST
sed -i "s|VERSION_PLACEHOLDER|${MANIFEST_VERSION}|g" core-ide.json
sed -i "s|URL_PLACEHOLDER|https://github.com/host-uk/core/releases/download/${VERSION}/core-ide-windows-amd64.zip|g" core-ide.json
sed -i "s|HASH_PLACEHOLDER|${IDE_WIN_AMD64}|g" core-ide.json
sed -i 's/^ //' core-ide.json
fi
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git add .
git diff --cached --quiet && echo "No changes to scoop bucket" && exit 0
git commit -m "Update core to ${MANIFEST_VERSION}"
git push

View file

@ -1,113 +0,0 @@
# https://docs.github.com/en/actions/using-workflows/events-that-trigger-workflows#issues
name: "Auto Label: Issue Created/Edited"
on:
issues:
types: [opened, edited]
permissions:
issues: write
jobs:
label:
runs-on: ubuntu-latest
steps:
- name: Auto-label based on content
uses: actions/github-script@v8
with:
script: |
const issue = context.payload.issue;
const title = issue.title.toLowerCase();
const body = (issue.body || '').toLowerCase();
const content = title + ' ' + body;
const labelsToAdd = [];
// Type labels based on title prefix
if (title.includes('[bug]')) {
labelsToAdd.push('bug');
} else if (title.includes('[feature]') || title.includes('feat(') || title.includes('feat:')) {
labelsToAdd.push('enhancement');
} else if (title.includes('[docs]') || title.includes('docs(') || title.includes('docs:')) {
labelsToAdd.push('documentation');
}
// Project labels based on content
if (content.includes('core dev') || content.includes('core work') || content.includes('core commit') || content.includes('core push')) {
labelsToAdd.push('project:core-cli');
}
if (content.includes('core php') || content.includes('composer') || content.includes('pest') || content.includes('phpstan')) {
labelsToAdd.push('project:core-php');
}
// Language labels
if (content.includes('.go') || content.includes('golang') || content.includes('go mod')) {
labelsToAdd.push('go');
}
// Priority detection
if (content.includes('critical') || content.includes('urgent') || content.includes('breaking')) {
labelsToAdd.push('priority:high');
}
// Agent labels
if (content.includes('agent') || content.includes('ai ') || content.includes('claude') || content.includes('agentic')) {
labelsToAdd.push('agentic');
}
// Complexity - from template dropdown or heuristics
if (body.includes('small - quick fix')) {
labelsToAdd.push('complexity:small');
labelsToAdd.push('good first issue');
} else if (body.includes('medium - multiple files')) {
labelsToAdd.push('complexity:medium');
} else if (body.includes('large - significant')) {
labelsToAdd.push('complexity:large');
} else if (!body.includes('unknown - not sure')) {
// Heuristic complexity detection
const checklistCount = (body.match(/- \[ \]/g) || []).length;
const codeBlocks = (body.match(/```/g) || []).length / 2;
const sections = (body.match(/^##/gm) || []).length;
const fileRefs = (body.match(/\.(go|php|js|ts|yml|yaml|json|md)\b/g) || []).length;
const complexKeywords = ['refactor', 'rewrite', 'migration', 'breaking change', 'across repos', 'architecture'];
const simpleKeywords = ['simple', 'quick fix', 'typo', 'minor', 'trivial'];
const hasComplexKeyword = complexKeywords.some(k => content.includes(k));
const hasSimpleKeyword = simpleKeywords.some(k => content.includes(k));
let score = checklistCount * 2 + codeBlocks + sections + fileRefs;
score += hasComplexKeyword ? 5 : 0;
score -= hasSimpleKeyword ? 3 : 0;
if (hasSimpleKeyword || score <= 2) {
labelsToAdd.push('complexity:small');
labelsToAdd.push('good first issue');
} else if (score <= 6) {
labelsToAdd.push('complexity:medium');
} else {
labelsToAdd.push('complexity:large');
}
}
// Apply labels if any detected
if (labelsToAdd.length > 0) {
// Filter to only existing labels
const existingLabels = await github.rest.issues.listLabelsForRepo({
owner: context.repo.owner,
repo: context.repo.repo,
per_page: 100
});
const validLabels = existingLabels.data.map(l => l.name);
const filteredLabels = labelsToAdd.filter(l => validLabels.includes(l));
if (filteredLabels.length > 0) {
await github.rest.issues.addLabels({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: issue.number,
labels: filteredLabels
});
console.log(`Added labels: ${filteredLabels.join(', ')}`);
}
}

View file

@ -1,54 +0,0 @@
name: Auto Merge
on:
pull_request:
types: [opened, reopened, ready_for_review]
permissions:
contents: write
pull-requests: write
env:
GH_REPO: ${{ github.repository }}
jobs:
merge:
runs-on: ubuntu-latest
if: github.event.pull_request.draft == false
steps:
- name: Checkout
uses: actions/checkout@v6
- name: Enable auto-merge
uses: actions/github-script@v7
env:
PR_NUMBER: ${{ github.event.pull_request.number }}
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
script: |
const author = context.payload.pull_request.user.login;
const association = context.payload.pull_request.author_association;
// Trusted bot accounts (act as org members)
const trustedBots = ['google-labs-jules[bot]'];
const isTrustedBot = trustedBots.includes(author);
// Check author association from webhook payload
const trusted = ['MEMBER', 'OWNER', 'COLLABORATOR'];
if (!isTrustedBot && !trusted.includes(association)) {
core.info(`${author} is ${association} — skipping auto-merge`);
return;
}
try {
await exec.exec('gh', [
'pr', 'merge', process.env.PR_NUMBER,
'--auto',
'--merge',
'-R', `${context.repo.owner}/${context.repo.repo}`
]);
core.info(`Auto-merge enabled for #${process.env.PR_NUMBER}`);
} catch (error) {
core.error(`Failed to enable auto-merge: ${error.message}`);
throw error;
}

View file

@ -1,10 +0,0 @@
name: Auto Project
on:
issues:
types: [opened, labeled]
jobs:
project:
uses: host-uk/.github/.github/workflows/auto-project.yml@main
secrets: inherit

View file

@ -1,309 +0,0 @@
# BugSETI Release Workflow
# Builds for all platforms and creates GitHub releases
name: "BugSETI Release"
on:
push:
tags:
- 'bugseti-v*.*.*' # Stable: bugseti-v1.0.0
- 'bugseti-v*.*.*-beta.*' # Beta: bugseti-v1.0.0-beta.1
- 'bugseti-nightly-*' # Nightly: bugseti-nightly-20260205
permissions:
contents: write
env:
APP_NAME: bugseti
WAILS_VERSION: "3"
jobs:
# Determine release channel from tag
prepare:
runs-on: ubuntu-latest
outputs:
version: ${{ steps.version.outputs.version }}
channel: ${{ steps.version.outputs.channel }}
prerelease: ${{ steps.version.outputs.prerelease }}
steps:
- name: Determine version and channel
id: version
env:
TAG: ${{ github.ref_name }}
run: |
if [[ "$TAG" == bugseti-nightly-* ]]; then
VERSION="${TAG#bugseti-}"
CHANNEL="nightly"
PRERELEASE="true"
elif [[ "$TAG" == *-beta.* ]]; then
VERSION="${TAG#bugseti-v}"
CHANNEL="beta"
PRERELEASE="true"
else
VERSION="${TAG#bugseti-v}"
CHANNEL="stable"
PRERELEASE="false"
fi
echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
echo "channel=${CHANNEL}" >> "$GITHUB_OUTPUT"
echo "prerelease=${PRERELEASE}" >> "$GITHUB_OUTPUT"
echo "Tag: $TAG"
echo "Version: $VERSION"
echo "Channel: $CHANNEL"
echo "Prerelease: $PRERELEASE"
build:
needs: prepare
strategy:
fail-fast: false
matrix:
include:
# macOS ARM64 (Apple Silicon)
- os: macos-latest
goos: darwin
goarch: arm64
ext: ""
archive: tar.gz
# macOS AMD64 (Intel)
- os: macos-13
goos: darwin
goarch: amd64
ext: ""
archive: tar.gz
# Linux AMD64
- os: ubuntu-latest
goos: linux
goarch: amd64
ext: ""
archive: tar.gz
# Linux ARM64
- os: ubuntu-24.04-arm
goos: linux
goarch: arm64
ext: ""
archive: tar.gz
# Windows AMD64
- os: windows-latest
goos: windows
goarch: amd64
ext: ".exe"
archive: zip
runs-on: ${{ matrix.os }}
env:
GOOS: ${{ matrix.goos }}
GOARCH: ${{ matrix.goarch }}
VERSION: ${{ needs.prepare.outputs.version }}
CHANNEL: ${{ needs.prepare.outputs.channel }}
defaults:
run:
working-directory: cmd/bugseti
steps:
- uses: actions/checkout@v6
- name: Setup Go
uses: host-uk/build/actions/setup/go@v4.0.0
with:
go-version: "1.25"
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: "20"
- name: Install Wails CLI
run: go install github.com/wailsapp/wails/v3/cmd/wails3@latest
- name: Install frontend dependencies
working-directory: cmd/bugseti/frontend
run: npm ci
- name: Generate bindings
run: wails3 generate bindings -f '-tags production' -clean=false -ts -i
- name: Build frontend
working-directory: cmd/bugseti/frontend
run: npm run build
- name: Install Linux dependencies
if: matrix.goos == 'linux'
run: |
sudo apt-get update
sudo apt-get install -y libgtk-3-dev libwebkit2gtk-4.1-dev libayatana-appindicator3-dev
- name: Build BugSETI
shell: bash
env:
EXT: ${{ matrix.ext }}
ARCHIVE: ${{ matrix.archive }}
COMMIT_SHA: ${{ github.sha }}
run: |
BINARY="${APP_NAME}${EXT}"
ARCHIVE_PREFIX="${APP_NAME}-${GOOS}-${GOARCH}"
BUILD_FLAGS="-tags production -trimpath -buildvcs=false"
# Version injection via ldflags
LDFLAGS="-s -w"
LDFLAGS="${LDFLAGS} -X github.com/host-uk/core/internal/bugseti.Version=${VERSION}"
LDFLAGS="${LDFLAGS} -X github.com/host-uk/core/internal/bugseti.Channel=${CHANNEL}"
LDFLAGS="${LDFLAGS} -X github.com/host-uk/core/internal/bugseti.Commit=${COMMIT_SHA}"
LDFLAGS="${LDFLAGS} -X github.com/host-uk/core/internal/bugseti.BuildTime=$(date -u +%Y-%m-%dT%H:%M:%SZ)"
if [ "$GOOS" = "windows" ]; then
export CGO_ENABLED=0
LDFLAGS="${LDFLAGS} -H windowsgui"
# Generate Windows syso resource
cd build
wails3 generate syso -arch ${GOARCH} -icon windows/icon.ico -manifest windows/wails.exe.manifest -info windows/info.json -out ../wails_windows_${GOARCH}.syso 2>/dev/null || true
cd ..
elif [ "$GOOS" = "darwin" ]; then
export CGO_ENABLED=1
export CGO_CFLAGS="-mmacosx-version-min=10.15"
export CGO_LDFLAGS="-mmacosx-version-min=10.15"
export MACOSX_DEPLOYMENT_TARGET="10.15"
else
export CGO_ENABLED=1
fi
mkdir -p bin
go build ${BUILD_FLAGS} -ldflags="${LDFLAGS}" -o "./bin/${BINARY}"
# Clean up syso files
rm -f *.syso
# Package based on platform
if [ "$GOOS" = "darwin" ]; then
# Create .app bundle
mkdir -p "./bin/BugSETI.app/Contents/"{MacOS,Resources}
cp build/darwin/icons.icns "./bin/BugSETI.app/Contents/Resources/" 2>/dev/null || true
cp "./bin/${BINARY}" "./bin/BugSETI.app/Contents/MacOS/"
cp build/darwin/Info.plist "./bin/BugSETI.app/Contents/"
codesign --force --deep --sign - "./bin/BugSETI.app" 2>/dev/null || true
tar czf "./bin/${ARCHIVE_PREFIX}.tar.gz" -C ./bin "BugSETI.app"
elif [ "$GOOS" = "windows" ]; then
cd ./bin && zip "${ARCHIVE_PREFIX}.zip" "${BINARY}" && cd ..
else
tar czf "./bin/${ARCHIVE_PREFIX}.tar.gz" -C ./bin "${BINARY}"
fi
# Rename raw binary for individual download
mv "./bin/${BINARY}" "./bin/${ARCHIVE_PREFIX}${EXT}"
# Generate checksum
cd ./bin
sha256sum "${ARCHIVE_PREFIX}.${ARCHIVE}" > "${ARCHIVE_PREFIX}.${ARCHIVE}.sha256"
sha256sum "${ARCHIVE_PREFIX}${EXT}" > "${ARCHIVE_PREFIX}${EXT}.sha256"
- name: Upload artifact
uses: actions/upload-artifact@v4
with:
name: bugseti-${{ matrix.goos }}-${{ matrix.goarch }}
path: |
cmd/bugseti/bin/bugseti-*
retention-days: 7
release:
needs: [prepare, build]
runs-on: ubuntu-latest
env:
TAG_NAME: ${{ github.ref_name }}
VERSION: ${{ needs.prepare.outputs.version }}
CHANNEL: ${{ needs.prepare.outputs.channel }}
PRERELEASE: ${{ needs.prepare.outputs.prerelease }}
REPO: ${{ github.repository }}
steps:
- uses: actions/checkout@v6
- name: Download all artifacts
uses: actions/download-artifact@v7
with:
path: dist
merge-multiple: true
- name: List release files
run: |
echo "=== Release files ==="
ls -la dist/
echo "=== Checksums ==="
cat dist/*.sha256
- name: Create release
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
# Determine release title
if [ "$CHANNEL" = "nightly" ]; then
TITLE="BugSETI Nightly (${VERSION})"
elif [ "$CHANNEL" = "beta" ]; then
TITLE="BugSETI v${VERSION} (Beta)"
else
TITLE="BugSETI v${VERSION}"
fi
# Create release notes
cat > release-notes.md << EOF
## BugSETI ${VERSION}
**Channel:** ${CHANNEL}
### Downloads
| Platform | Architecture | Binary | Archive |
|----------|-------------|--------|---------|
| macOS | ARM64 (Apple Silicon) | [bugseti-darwin-arm64](https://github.com/${REPO}/releases/download/${TAG_NAME}/bugseti-darwin-arm64) | [tar.gz](https://github.com/${REPO}/releases/download/${TAG_NAME}/bugseti-darwin-arm64.tar.gz) |
| macOS | AMD64 (Intel) | [bugseti-darwin-amd64](https://github.com/${REPO}/releases/download/${TAG_NAME}/bugseti-darwin-amd64) | [tar.gz](https://github.com/${REPO}/releases/download/${TAG_NAME}/bugseti-darwin-amd64.tar.gz) |
| Linux | AMD64 | [bugseti-linux-amd64](https://github.com/${REPO}/releases/download/${TAG_NAME}/bugseti-linux-amd64) | [tar.gz](https://github.com/${REPO}/releases/download/${TAG_NAME}/bugseti-linux-amd64.tar.gz) |
| Linux | ARM64 | [bugseti-linux-arm64](https://github.com/${REPO}/releases/download/${TAG_NAME}/bugseti-linux-arm64) | [tar.gz](https://github.com/${REPO}/releases/download/${TAG_NAME}/bugseti-linux-arm64.tar.gz) |
| Windows | AMD64 | [bugseti-windows-amd64.exe](https://github.com/${REPO}/releases/download/${TAG_NAME}/bugseti-windows-amd64.exe) | [zip](https://github.com/${REPO}/releases/download/${TAG_NAME}/bugseti-windows-amd64.zip) |
### Checksums (SHA256)
\`\`\`
$(cat dist/*.sha256)
\`\`\`
---
*BugSETI - Distributed Bug Fixing, like SETI@home but for code*
EOF
# Build release command
RELEASE_ARGS=(
--title "$TITLE"
--notes-file release-notes.md
)
if [ "$PRERELEASE" = "true" ]; then
RELEASE_ARGS+=(--prerelease)
fi
# Create the release
gh release create "$TAG_NAME" \
"${RELEASE_ARGS[@]}" \
dist/*
# Scheduled nightly builds
nightly:
if: github.event_name == 'schedule'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- name: Create nightly tag
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
DATE=$(date -u +%Y%m%d)
TAG="bugseti-nightly-${DATE}"
# Delete existing nightly tag for today if it exists
gh release delete "$TAG" --yes 2>/dev/null || true
git push origin ":refs/tags/$TAG" 2>/dev/null || true
# Create new tag
git tag "$TAG"
git push origin "$TAG"

View file

@ -1,41 +0,0 @@
# https://docs.github.com/en/actions/using-workflows/events-that-trigger-workflows#workflow_dispatch
name: "CI: Manual"
on:
workflow_dispatch:
env:
CORE_VERSION: dev
jobs:
qa:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- name: Set up Go
uses: actions/setup-go@v6
with:
go-version-file: 'go.mod'
- name: Install system dependencies
run: |
sudo apt-get update
sudo apt-get install -y libgtk-3-dev libwebkit2gtk-4.1-dev
- name: Build core CLI
run: |
go build -ldflags "-X github.com/host-uk/core/pkg/cli.AppVersion=${{ env.CORE_VERSION }}" -o /usr/local/bin/core .
core --version
- name: Generate code
run: go generate ./internal/cmd/updater/...
- name: Run QA
# Skip lint until golangci-lint supports Go 1.25
run: core go qa --skip=lint
- name: Verify build
run: |
core build --targets=linux/amd64 --ci
dist/linux_amd64/core --version

View file

@ -1,42 +0,0 @@
# https://docs.github.com/en/actions/using-workflows/events-that-trigger-workflows#pull_request
name: "CI: Pull Request"
on:
pull_request:
branches: [dev, main]
env:
CORE_VERSION: dev
jobs:
qa:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- name: Set up Go
uses: actions/setup-go@v6
with:
go-version-file: 'go.mod'
- name: Install system dependencies
run: |
sudo apt-get update
sudo apt-get install -y libgtk-3-dev libwebkit2gtk-4.1-dev
- name: Build core CLI
run: |
go build -ldflags "-X github.com/host-uk/core/pkg/cli.AppVersion=${{ env.CORE_VERSION }}" -o /usr/local/bin/core .
core --version
- name: Generate code
run: go generate ./internal/cmd/updater/...
- name: Run QA
# Skip lint until golangci-lint supports Go 1.25
run: core go qa --skip=lint
- name: Verify build
run: |
core build --targets=linux/amd64 --ci
dist/linux_amd64/core --version

View file

@ -1,42 +0,0 @@
# https://docs.github.com/en/actions/using-workflows/events-that-trigger-workflows#push
name: "CI: Push"
on:
push:
branches: [dev, main]
env:
CORE_VERSION: dev
jobs:
qa:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- name: Set up Go
uses: actions/setup-go@v6
with:
go-version-file: 'go.mod'
- name: Install system dependencies
run: |
sudo apt-get update
sudo apt-get install -y libgtk-3-dev libwebkit2gtk-4.1-dev
- name: Build core CLI
run: |
go build -ldflags "-X github.com/host-uk/core/pkg/cli.AppVersion=${{ env.CORE_VERSION }}" -o /usr/local/bin/core .
core --version
- name: Generate code
run: go generate ./internal/cmd/updater/...
- name: Run QA
# Skip lint until golangci-lint supports Go 1.25
run: core go qa --skip=lint
- name: Verify build
run: |
core build --targets=linux/amd64 --ci
dist/linux_amd64/core --version

View file

@ -1,49 +0,0 @@
name: CI
on:
push:
branches: [dev, main]
pull_request:
branches: [dev, main]
workflow_dispatch:
permissions:
contents: read
env:
CORE_VERSION: dev
jobs:
qa:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- name: Set up Go
uses: actions/setup-go@v6
with:
go-version-file: 'go.mod'
- name: Install system dependencies
run: |
sudo apt-get update
# Try 4.1 first (Ubuntu 22.04+), fall back to 4.0 (Ubuntu 20.04)
sudo apt-get install -y libgtk-3-dev libwebkit2gtk-4.1-dev || \
sudo apt-get install -y libgtk-3-dev libwebkit2gtk-4.0-dev
- name: Build core CLI
run: |
go build -ldflags "-X github.com/host-uk/core/pkg/cli.AppVersion=${{ env.CORE_VERSION }}" -o /usr/local/bin/core .
core --version
- name: Generate code
run: go generate ./internal/cmd/updater/...
- name: Run QA
# Skip lint until golangci-lint supports Go 1.25
run: core go qa --skip=lint
- name: Verify build
run: |
core build --targets=linux/amd64 --ci
dist/linux_amd64/core --version

View file

@ -1,32 +0,0 @@
# https://docs.github.com/en/actions/using-workflows/events-that-trigger-workflows#pull_request
name: "CodeQL: Pull Request"
on:
pull_request:
branches: [dev, main]
jobs:
analyze:
name: Analyze
runs-on: ubuntu-latest
permissions:
actions: read
contents: read
security-events: write
steps:
- name: Checkout
uses: actions/checkout@v6
- name: Initialize CodeQL
uses: github/codeql-action/init@v4
with:
languages: go
- name: Autobuild
uses: github/codeql-action/autobuild@v4
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v4
with:
category: "/language:go"

View file

@ -1,32 +0,0 @@
# https://docs.github.com/en/actions/using-workflows/events-that-trigger-workflows#push
name: "CodeQL: Push"
on:
push:
branches: [dev, main]
jobs:
analyze:
name: Analyze
runs-on: ubuntu-latest
permissions:
actions: read
contents: read
security-events: write
steps:
- name: Checkout
uses: actions/checkout@v6
- name: Initialize CodeQL
uses: github/codeql-action/init@v4
with:
languages: go
- name: Autobuild
uses: github/codeql-action/autobuild@v4
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v4
with:
category: "/language:go"

View file

@ -1,32 +0,0 @@
# https://docs.github.com/en/actions/using-workflows/events-that-trigger-workflows#schedule
name: "CodeQL: Schedule"
on:
schedule:
- cron: "0 6 * * 1"
jobs:
analyze:
name: Analyze
runs-on: ubuntu-latest
permissions:
actions: read
contents: read
security-events: write
steps:
- name: Checkout
uses: actions/checkout@v6
- name: Initialize CodeQL
uses: github/codeql-action/init@v4
with:
languages: go
- name: Autobuild
uses: github/codeql-action/autobuild@v4
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v4
with:
category: "/language:go"

View file

@ -1,30 +0,0 @@
# https://docs.github.com/en/actions/using-workflows/events-that-trigger-workflows#pull_request
name: "Code Scanning: Pull Request"
on:
pull_request:
branches: ["dev"]
jobs:
CodeQL:
runs-on: ubuntu-latest
permissions:
security-events: write
actions: read
contents: read
steps:
- name: "Checkout Repository"
uses: actions/checkout@v6
- name: "Initialize CodeQL"
uses: github/codeql-action/init@v4
with:
languages: go,javascript,typescript
- name: "Autobuild"
uses: github/codeql-action/autobuild@v4
- name: "Perform CodeQL Analysis"
uses: github/codeql-action/analyze@v4

View file

@ -1,30 +0,0 @@
# https://docs.github.com/en/actions/using-workflows/events-that-trigger-workflows#push
name: "Code Scanning: Push"
on:
push:
branches: ["dev"]
jobs:
CodeQL:
runs-on: ubuntu-latest
permissions:
security-events: write
actions: read
contents: read
steps:
- name: "Checkout Repository"
uses: actions/checkout@v6
- name: "Initialize CodeQL"
uses: github/codeql-action/init@v4
with:
languages: go,javascript,typescript
- name: "Autobuild"
uses: github/codeql-action/autobuild@v4
- name: "Perform CodeQL Analysis"
uses: github/codeql-action/analyze@v4

View file

@ -1,30 +0,0 @@
# https://docs.github.com/en/actions/using-workflows/events-that-trigger-workflows#schedule
name: "Code Scanning: Schedule"
on:
schedule:
- cron: "0 2 * * 1-5"
jobs:
CodeQL:
runs-on: ubuntu-latest
permissions:
security-events: write
actions: read
contents: read
steps:
- name: "Checkout Repository"
uses: actions/checkout@v6
- name: "Initialize CodeQL"
uses: github/codeql-action/init@v4
with:
languages: go,javascript,typescript
- name: "Autobuild"
uses: github/codeql-action/autobuild@v4
- name: "Perform CodeQL Analysis"
uses: github/codeql-action/analyze@v4

View file

@ -1,46 +0,0 @@
# https://docs.github.com/en/actions/using-workflows/events-that-trigger-workflows#workflow_dispatch
name: "Coverage: Manual"
on:
workflow_dispatch:
env:
CORE_VERSION: dev
jobs:
coverage:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- name: Set up Go
uses: actions/setup-go@v6
with:
go-version-file: 'go.mod'
- name: Install system dependencies
run: |
sudo apt-get update
sudo apt-get install -y libgtk-3-dev libwebkit2gtk-4.1-dev
- name: Build core CLI
run: |
go build -ldflags "-X github.com/host-uk/core/pkg/cli.AppVersion=${{ env.CORE_VERSION }}" -o /usr/local/bin/core .
core --version
- name: Generate code
run: go generate ./internal/cmd/updater/...
- name: Run coverage
run: core go cov
- name: Upload coverage reports to Codecov
uses: codecov/codecov-action@v5
with:
token: ${{ secrets.CODECOV_TOKEN }}
- name: Upload coverage report
uses: actions/upload-artifact@v6
with:
name: coverage-report
path: coverage.txt

View file

@ -1,47 +0,0 @@
# https://docs.github.com/en/actions/using-workflows/events-that-trigger-workflows#pull_request
name: "Coverage: Pull Request"
on:
pull_request:
branches: [dev, main]
env:
CORE_VERSION: dev
jobs:
coverage:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- name: Set up Go
uses: actions/setup-go@v6
with:
go-version-file: 'go.mod'
- name: Install system dependencies
run: |
sudo apt-get update
sudo apt-get install -y libgtk-3-dev libwebkit2gtk-4.1-dev
- name: Build core CLI
run: |
go build -ldflags "-X github.com/host-uk/core/pkg/cli.AppVersion=${{ env.CORE_VERSION }}" -o /usr/local/bin/core .
core --version
- name: Generate code
run: go generate ./internal/cmd/updater/...
- name: Run coverage
run: core go cov
- name: Upload coverage reports to Codecov
uses: codecov/codecov-action@v5
with:
token: ${{ secrets.CODECOV_TOKEN }}
- name: Upload coverage report
uses: actions/upload-artifact@v6
with:
name: coverage-report
path: coverage.txt

View file

@ -1,47 +0,0 @@
# https://docs.github.com/en/actions/using-workflows/events-that-trigger-workflows#push
name: "Coverage: Push"
on:
push:
branches: [dev, main]
env:
CORE_VERSION: dev
jobs:
coverage:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- name: Set up Go
uses: actions/setup-go@v6
with:
go-version-file: 'go.mod'
- name: Install system dependencies
run: |
sudo apt-get update
sudo apt-get install -y libgtk-3-dev libwebkit2gtk-4.1-dev
- name: Build core CLI
run: |
go build -ldflags "-X github.com/host-uk/core/pkg/cli.AppVersion=${{ env.CORE_VERSION }}" -o /usr/local/bin/core .
core --version
- name: Generate code
run: go generate ./internal/cmd/updater/...
- name: Run coverage
run: core go cov
- name: Upload coverage reports to Codecov
uses: codecov/codecov-action@v5
with:
token: ${{ secrets.CODECOV_TOKEN }}
- name: Upload coverage report
uses: actions/upload-artifact@v6
with:
name: coverage-report
path: coverage.txt

View file

@ -1,54 +0,0 @@
name: Coverage
on:
push:
branches: [dev, main]
pull_request:
branches: [dev, main]
workflow_dispatch:
permissions:
contents: read
env:
CORE_VERSION: dev
jobs:
coverage:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- name: Set up Go
uses: actions/setup-go@v6
with:
go-version-file: 'go.mod'
- name: Install system dependencies
run: |
sudo apt-get update
# Try 4.1 first (Ubuntu 22.04+), fall back to 4.0 (Ubuntu 20.04)
sudo apt-get install -y libgtk-3-dev libwebkit2gtk-4.1-dev || \
sudo apt-get install -y libgtk-3-dev libwebkit2gtk-4.0-dev
- name: Build core CLI
run: |
go build -ldflags "-X github.com/host-uk/core/pkg/cli.AppVersion=${{ env.CORE_VERSION }}" -o /usr/local/bin/core .
core --version
- name: Generate code
run: go generate ./internal/cmd/updater/...
- name: Run coverage
run: core go cov --output coverage.txt --threshold 40 --branch-threshold 35
- name: Upload coverage reports to Codecov
uses: codecov/codecov-action@v5
with:
token: ${{ secrets.CODECOV_TOKEN }}
- name: Upload coverage report
uses: actions/upload-artifact@v6
with:
name: coverage-report
path: coverage.txt

View file

@ -1,89 +0,0 @@
# https://docs.github.com/en/actions/using-workflows/events-that-trigger-workflows#workflow_dispatch
name: "PR Build: Manual"
on:
workflow_dispatch:
inputs:
pr_number:
description: 'PR number to build'
required: true
type: number
permissions:
contents: write
pull-requests: read
env:
NEXT_VERSION: "0.0.4"
jobs:
build:
strategy:
matrix:
include:
- os: ubuntu-latest
platform: linux/amd64
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v6
- name: Build
uses: host-uk/build@v3
with:
build-name: core
build-platform: ${{ matrix.platform }}
build: true
package: true
sign: false
draft-release:
needs: build
runs-on: ubuntu-latest
env:
PR_NUM: ${{ inputs.pr_number }}
PR_SHA: ${{ github.sha }}
steps:
- uses: actions/checkout@v6
- name: Download artifacts
uses: actions/download-artifact@v7
with:
path: dist
merge-multiple: true
- name: Prepare release files
run: |
mkdir -p release
cp dist/* release/ 2>/dev/null || true
ls -la release/
- name: Create draft release
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
TAG="v${{ env.NEXT_VERSION }}.pr.${PR_NUM}.bid.${{ github.run_id }}"
# Delete existing draft for this PR if it exists
gh release delete "$TAG" -y 2>/dev/null || true
git push origin ":refs/tags/$TAG" 2>/dev/null || true
gh release create "$TAG" \
--title "Draft: PR #${PR_NUM}" \
--notes "Draft build for PR #${PR_NUM}.
**Version:** $TAG
**PR:** #${PR_NUM}
**Commit:** ${PR_SHA}
**Built:** $(date -u +'%Y-%m-%d %H:%M:%S UTC')
**Run:** ${{ github.run_id }}
## Channel: Draft
This is a draft build for testing PR changes before merge.
Not intended for production use.
Build artifacts available for download and testing.
" \
--draft \
--prerelease \
release/*

View file

@ -1,89 +0,0 @@
# https://docs.github.com/en/actions/using-workflows/events-that-trigger-workflows#pull_request
name: "PR Build: Pull Request"
on:
pull_request:
types: [opened, synchronize, reopened]
permissions:
contents: write
pull-requests: read
env:
NEXT_VERSION: "0.0.4"
jobs:
build:
# Only build if PR is from the same repo (not forks)
if: github.event.pull_request.head.repo.full_name == github.repository
strategy:
matrix:
include:
- os: ubuntu-latest
platform: linux/amd64
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v6
with:
ref: ${{ github.event.pull_request.head.sha }}
- name: Build
uses: host-uk/build@v3
with:
build-name: core
build-platform: ${{ matrix.platform }}
build: true
package: true
sign: false
draft-release:
needs: build
runs-on: ubuntu-latest
env:
PR_NUM: ${{ github.event.pull_request.number }}
PR_SHA: ${{ github.event.pull_request.head.sha }}
steps:
- uses: actions/checkout@v6
- name: Download artifacts
uses: actions/download-artifact@v7
with:
path: dist
merge-multiple: true
- name: Prepare release files
run: |
mkdir -p release
cp dist/* release/ 2>/dev/null || true
ls -la release/
- name: Create draft release
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
TAG="v${{ env.NEXT_VERSION }}.pr.${PR_NUM}.bid.${{ github.run_id }}"
# Delete existing draft for this PR if it exists
gh release delete "$TAG" -y 2>/dev/null || true
git push origin ":refs/tags/$TAG" 2>/dev/null || true
gh release create "$TAG" \
--title "Draft: PR #${PR_NUM}" \
--notes "Draft build for PR #${PR_NUM}.
**Version:** $TAG
**PR:** #${PR_NUM}
**Commit:** ${PR_SHA}
**Built:** $(date -u +'%Y-%m-%d %H:%M:%S UTC')
**Run:** ${{ github.run_id }}
## Channel: Draft
This is a draft build for testing PR changes before merge.
Not intended for production use.
Build artifacts available for download and testing.
" \
--draft \
--prerelease \
release/*

View file

@ -1,113 +0,0 @@
name: PR Build
on:
pull_request:
types: [opened, synchronize, reopened]
workflow_dispatch:
inputs:
pr_number:
description: 'PR number to build'
required: true
type: number
permissions:
contents: write
pull-requests: read
env:
# Next version - update when releasing
NEXT_VERSION: "0.0.4"
jobs:
build:
# Only build if PR is from the same repo (not forks) or manually triggered
if: github.event.pull_request.head.repo.full_name == github.repository || github.event_name == 'workflow_dispatch'
strategy:
matrix:
include:
- os: ubuntu-latest
goos: linux
goarch: amd64
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
with:
ref: ${{ github.event.pull_request.head.sha || github.sha }}
# GUI build disabled until build action supports Wails v3
# - name: Wails Build Action
# uses: host-uk/build@v4.0.0
# with:
# build-name: core
# build-platform: ${{ matrix.goos }}/${{ matrix.goarch }}
# build: true
# package: true
# sign: false
- name: Setup Go
uses: host-uk/build/actions/setup/go@v4.0.0
with:
go-version: "1.25"
- name: Build CLI
run: go build -o ./bin/core .
- name: Upload artifact
uses: actions/upload-artifact@v4
with:
name: core-${{ matrix.goos }}-${{ matrix.goarch }}
path: ./bin/core
draft-release:
needs: build
runs-on: ubuntu-latest
env:
# Safe: PR number is numeric, not user-controlled string
PR_NUM: ${{ github.event.pull_request.number || inputs.pr_number }}
PR_SHA: ${{ github.event.pull_request.head.sha || github.sha }}
steps:
- uses: actions/checkout@v6
- name: Download artifacts
uses: actions/download-artifact@v7
with:
path: dist
merge-multiple: true
- name: Prepare release files
run: |
mkdir -p release
cp dist/* release/ 2>/dev/null || true
ls -la release/
- name: Create draft release
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
# Use dots for build metadata (semver v1 compatible)
TAG="v${{ env.NEXT_VERSION }}.pr.${PR_NUM}.bid.${{ github.run_id }}"
# Delete existing draft for this PR if it exists
gh release delete "$TAG" -y 2>/dev/null || true
git push origin ":refs/tags/$TAG" 2>/dev/null || true
gh release create "$TAG" \
--title "Draft: PR #${PR_NUM}" \
--notes "Draft build for PR #${PR_NUM}.
**Version:** $TAG
**PR:** #${PR_NUM}
**Commit:** ${PR_SHA}
**Built:** $(date -u +'%Y-%m-%d %H:%M:%S UTC')
**Run:** ${{ github.run_id }}
## Channel: Draft
This is a draft build for testing PR changes before merge.
Not intended for production use.
Build artifacts available for download and testing.
" \
--draft \
--prerelease \
release/*

View file

@ -1,45 +0,0 @@
name: PR Gate
on:
pull_request_target:
types: [opened, synchronize, reopened, labeled]
permissions:
contents: read
pull-requests: read
jobs:
org-gate:
runs-on: ubuntu-latest
steps:
- name: Check org membership or approval label
uses: actions/github-script@v7
with:
script: |
const author = context.payload.pull_request.user.login;
const association = context.payload.pull_request.author_association;
// Trusted accounts
const trustedAuthors = ['google-labs-jules[bot]', 'Snider'];
if (trustedAuthors.includes(author)) {
core.info(`${author} is trusted — gate passed`);
return;
}
// Check author association
const trustedAssociations = ['MEMBER', 'OWNER', 'COLLABORATOR'];
if (trustedAssociations.includes(association)) {
core.info(`${author} is ${association} — gate passed`);
return;
}
// Check for external-approved label
const labels = context.payload.pull_request.labels.map(l => l.name);
if (labels.includes('external-approved')) {
core.info('external-approved label present — gate passed');
return;
}
core.setFailed(
`External PR from ${author} requires an org member to add the "external-approved" label before merge.`
);

View file

@ -1,454 +0,0 @@
# https://docs.github.com/en/actions/using-workflows/events-that-trigger-workflows#push
name: "Release: Tag Push"
on:
push:
tags:
- 'v*.*.*'
permissions:
contents: write
jobs:
build:
strategy:
matrix:
include:
- os: ubuntu-latest
goos: linux
goarch: amd64
- os: ubuntu-latest
goos: linux
goarch: arm64
- os: macos-latest
goos: darwin
goarch: arm64
- os: windows-latest
goos: windows
goarch: amd64
runs-on: ${{ matrix.os }}
env:
GOOS: ${{ matrix.goos }}
GOARCH: ${{ matrix.goarch }}
steps:
- uses: actions/checkout@v6
- name: Setup Go
uses: host-uk/build/actions/setup/go@v4.0.0
with:
go-version: "1.25"
- name: Build CLI
shell: bash
run: |
EXT=""
if [ "$GOOS" = "windows" ]; then EXT=".exe"; fi
BINARY="core${EXT}"
ARCHIVE_PREFIX="core-${GOOS}-${GOARCH}"
APP_VERSION="${GITHUB_REF_NAME#v}"
go build -ldflags "-s -w -X github.com/host-uk/core/pkg/cli.AppVersion=${APP_VERSION}" -o "./bin/${BINARY}" .
# Create tar.gz for Homebrew (non-Windows)
if [ "$GOOS" != "windows" ]; then
tar czf "./bin/${ARCHIVE_PREFIX}.tar.gz" -C ./bin "${BINARY}"
fi
# Create zip for Scoop (Windows)
if [ "$GOOS" = "windows" ]; then
cd ./bin && zip "${ARCHIVE_PREFIX}.zip" "${BINARY}" && cd ..
fi
# Rename raw binary to platform-specific name for release
mv "./bin/${BINARY}" "./bin/${ARCHIVE_PREFIX}${EXT}"
- name: Upload artifact
uses: actions/upload-artifact@v4
with:
name: core-${{ matrix.goos }}-${{ matrix.goarch }}
path: ./bin/core-*
build-ide:
strategy:
matrix:
include:
- os: macos-latest
goos: darwin
goarch: arm64
- os: ubuntu-latest
goos: linux
goarch: amd64
- os: windows-latest
goos: windows
goarch: amd64
runs-on: ${{ matrix.os }}
env:
GOOS: ${{ matrix.goos }}
GOARCH: ${{ matrix.goarch }}
defaults:
run:
working-directory: internal/core-ide
steps:
- uses: actions/checkout@v6
- name: Setup Go
uses: host-uk/build/actions/setup/go@v4.0.0
with:
go-version: "1.25"
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: "20"
- name: Install Wails CLI
run: go install github.com/wailsapp/wails/v3/cmd/wails3@latest
- name: Install frontend dependencies
working-directory: internal/core-ide/frontend
run: npm ci
- name: Generate bindings
run: wails3 generate bindings -f '-tags production' -clean=false -ts -i
- name: Build frontend
working-directory: internal/core-ide/frontend
run: npm run build
- name: Install Linux dependencies
if: matrix.goos == 'linux'
run: |
sudo apt-get update
sudo apt-get install -y libgtk-3-dev libwebkit2gtk-4.0-dev
- name: Build IDE
shell: bash
run: |
EXT=""
if [ "$GOOS" = "windows" ]; then EXT=".exe"; fi
BINARY="core-ide${EXT}"
ARCHIVE_PREFIX="core-ide-${GOOS}-${GOARCH}"
BUILD_FLAGS="-tags production -trimpath -buildvcs=false"
if [ "$GOOS" = "windows" ]; then
# Windows: no CGO, use windowsgui linker flag
export CGO_ENABLED=0
LDFLAGS="-w -s -H windowsgui"
# Generate Windows syso resource
cd build
wails3 generate syso -arch ${GOARCH} -icon windows/icon.ico -manifest windows/wails.exe.manifest -info windows/info.json -out ../wails_windows_${GOARCH}.syso
cd ..
elif [ "$GOOS" = "darwin" ]; then
export CGO_ENABLED=1
export CGO_CFLAGS="-mmacosx-version-min=10.15"
export CGO_LDFLAGS="-mmacosx-version-min=10.15"
export MACOSX_DEPLOYMENT_TARGET="10.15"
LDFLAGS="-w -s"
else
export CGO_ENABLED=1
LDFLAGS="-w -s"
fi
go build ${BUILD_FLAGS} -ldflags="${LDFLAGS}" -o "./bin/${BINARY}"
# Clean up syso files
rm -f *.syso
# Package
if [ "$GOOS" = "darwin" ]; then
# Create .app bundle
mkdir -p "./bin/Core IDE.app/Contents/"{MacOS,Resources}
cp build/darwin/icons.icns "./bin/Core IDE.app/Contents/Resources/"
cp "./bin/${BINARY}" "./bin/Core IDE.app/Contents/MacOS/"
cp build/darwin/Info.plist "./bin/Core IDE.app/Contents/"
codesign --force --deep --sign - "./bin/Core IDE.app"
tar czf "./bin/${ARCHIVE_PREFIX}.tar.gz" -C ./bin "Core IDE.app"
elif [ "$GOOS" = "windows" ]; then
cd ./bin && zip "${ARCHIVE_PREFIX}.zip" "${BINARY}" && cd ..
else
tar czf "./bin/${ARCHIVE_PREFIX}.tar.gz" -C ./bin "${BINARY}"
fi
# Rename raw binary
mv "./bin/${BINARY}" "./bin/${ARCHIVE_PREFIX}${EXT}"
- name: Upload artifact
uses: actions/upload-artifact@v4
with:
name: core-ide-${{ matrix.goos }}-${{ matrix.goarch }}
path: internal/core-ide/bin/core-ide-*
release:
needs: [build, build-ide]
runs-on: ubuntu-latest
outputs:
version: ${{ steps.version.outputs.version }}
steps:
- uses: actions/checkout@v6
- name: Set version
id: version
run: echo "version=${{ github.ref_name }}" >> "$GITHUB_OUTPUT"
- name: Download artifacts
uses: actions/download-artifact@v7
with:
path: dist
merge-multiple: true
- name: Prepare release files
run: |
mkdir -p release
cp dist/* release/ 2>/dev/null || true
ls -la release/
- name: Create release
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAG_NAME: ${{ github.ref_name }}
run: |
gh release create "$TAG_NAME" \
--title "Release $TAG_NAME" \
--generate-notes \
release/*
update-tap:
needs: release
runs-on: ubuntu-latest
steps:
- name: Download artifacts
uses: actions/download-artifact@v7
with:
path: dist
merge-multiple: true
- name: Generate checksums
run: |
cd dist
for f in *.tar.gz; do
sha256sum "$f" | awk '{print $1}' > "${f}.sha256"
done
echo "=== Checksums ==="
cat *.sha256
- name: Update Homebrew formula
env:
GH_TOKEN: ${{ secrets.HOMEBREW_TAP_TOKEN }}
VERSION: ${{ needs.release.outputs.version }}
run: |
# Strip leading 'v' for formula version
FORMULA_VERSION="${VERSION#v}"
# Read checksums
DARWIN_ARM64=$(cat dist/core-darwin-arm64.tar.gz.sha256)
LINUX_AMD64=$(cat dist/core-linux-amd64.tar.gz.sha256)
LINUX_ARM64=$(cat dist/core-linux-arm64.tar.gz.sha256)
# Clone tap repo (configure auth for push)
gh repo clone host-uk/homebrew-tap /tmp/tap -- --depth=1
cd /tmp/tap
git remote set-url origin "https://x-access-token:${GH_TOKEN}@github.com/host-uk/homebrew-tap.git"
cd -
mkdir -p /tmp/tap/Formula
# Write formula
cat > /tmp/tap/Formula/core.rb << FORMULA
# typed: false
# frozen_string_literal: true
class Core < Formula
desc "Host UK development CLI"
homepage "https://github.com/host-uk/core"
version "${FORMULA_VERSION}"
license "EUPL-1.2"
on_macos do
url "https://github.com/host-uk/core/releases/download/${VERSION}/core-darwin-arm64.tar.gz"
sha256 "${DARWIN_ARM64}"
end
on_linux do
if Hardware::CPU.arm?
url "https://github.com/host-uk/core/releases/download/${VERSION}/core-linux-arm64.tar.gz"
sha256 "${LINUX_ARM64}"
else
url "https://github.com/host-uk/core/releases/download/${VERSION}/core-linux-amd64.tar.gz"
sha256 "${LINUX_AMD64}"
end
end
def install
bin.install "core"
end
test do
system "\#{bin}/core", "--version"
end
end
FORMULA
# Remove leading whitespace from heredoc
sed -i 's/^ //' /tmp/tap/Formula/core.rb
# Read IDE checksums (may not exist if build-ide failed)
IDE_DARWIN_ARM64=$(cat dist/core-ide-darwin-arm64.tar.gz.sha256 2>/dev/null || echo "")
IDE_LINUX_AMD64=$(cat dist/core-ide-linux-amd64.tar.gz.sha256 2>/dev/null || echo "")
# Write core-ide Formula (Linux binary)
if [ -n "${IDE_LINUX_AMD64}" ]; then
cat > /tmp/tap/Formula/core-ide.rb << FORMULA
# typed: false
# frozen_string_literal: true
class CoreIde < Formula
desc "Host UK desktop development environment"
homepage "https://github.com/host-uk/core"
version "${FORMULA_VERSION}"
license "EUPL-1.2"
on_linux do
url "https://github.com/host-uk/core/releases/download/${VERSION}/core-ide-linux-amd64.tar.gz"
sha256 "${IDE_LINUX_AMD64}"
end
def install
bin.install "core-ide"
end
end
FORMULA
sed -i 's/^ //' /tmp/tap/Formula/core-ide.rb
fi
# Write core-ide Cask (macOS .app bundle)
if [ -n "${IDE_DARWIN_ARM64}" ]; then
mkdir -p /tmp/tap/Casks
cat > /tmp/tap/Casks/core-ide.rb << CASK
cask "core-ide" do
version "${FORMULA_VERSION}"
sha256 "${IDE_DARWIN_ARM64}"
url "https://github.com/host-uk/core/releases/download/${VERSION}/core-ide-darwin-arm64.tar.gz"
name "Core IDE"
desc "Host UK desktop development environment"
homepage "https://github.com/host-uk/core"
app "Core IDE.app"
end
CASK
sed -i 's/^ //' /tmp/tap/Casks/core-ide.rb
fi
cd /tmp/tap
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git add .
git diff --cached --quiet && echo "No changes to tap" && exit 0
git commit -m "Update core to ${FORMULA_VERSION}"
git push
update-scoop:
needs: release
runs-on: ubuntu-latest
steps:
- name: Download artifacts
uses: actions/download-artifact@v7
with:
path: dist
merge-multiple: true
- name: Generate checksums
run: |
cd dist
for f in *.zip; do
[ -f "$f" ] || continue
sha256sum "$f" | awk '{print $1}' > "${f}.sha256"
done
echo "=== Checksums ==="
cat *.sha256 2>/dev/null || echo "No zip checksums"
- name: Update Scoop manifests
env:
GH_TOKEN: ${{ secrets.HOMEBREW_TAP_TOKEN }}
VERSION: ${{ needs.release.outputs.version }}
run: |
# Strip leading 'v' for manifest version
MANIFEST_VERSION="${VERSION#v}"
# Read checksums
WIN_AMD64=$(cat dist/core-windows-amd64.zip.sha256 2>/dev/null || echo "")
IDE_WIN_AMD64=$(cat dist/core-ide-windows-amd64.zip.sha256 2>/dev/null || echo "")
# Clone scoop bucket
gh repo clone host-uk/scoop-bucket /tmp/scoop -- --depth=1
cd /tmp/scoop
git remote set-url origin "https://x-access-token:${GH_TOKEN}@github.com/host-uk/scoop-bucket.git"
# Write core.json manifest
cat > core.json << 'MANIFEST'
{
"version": "VERSION_PLACEHOLDER",
"description": "Host UK development CLI",
"homepage": "https://github.com/host-uk/core",
"license": "EUPL-1.2",
"architecture": {
"64bit": {
"url": "URL_PLACEHOLDER",
"hash": "HASH_PLACEHOLDER",
"bin": "core.exe"
}
},
"checkver": "github",
"autoupdate": {
"architecture": {
"64bit": {
"url": "https://github.com/host-uk/core/releases/download/v$version/core-windows-amd64.zip"
}
}
}
}
MANIFEST
sed -i "s|VERSION_PLACEHOLDER|${MANIFEST_VERSION}|g" core.json
sed -i "s|URL_PLACEHOLDER|https://github.com/host-uk/core/releases/download/${VERSION}/core-windows-amd64.zip|g" core.json
sed -i "s|HASH_PLACEHOLDER|${WIN_AMD64}|g" core.json
sed -i 's/^ //' core.json
# Write core-ide.json manifest
if [ -n "${IDE_WIN_AMD64}" ]; then
cat > core-ide.json << 'MANIFEST'
{
"version": "VERSION_PLACEHOLDER",
"description": "Host UK desktop development environment",
"homepage": "https://github.com/host-uk/core",
"license": "EUPL-1.2",
"architecture": {
"64bit": {
"url": "URL_PLACEHOLDER",
"hash": "HASH_PLACEHOLDER",
"bin": "core-ide.exe"
}
},
"checkver": "github",
"autoupdate": {
"architecture": {
"64bit": {
"url": "https://github.com/host-uk/core/releases/download/v$version/core-ide-windows-amd64.zip"
}
}
}
}
MANIFEST
sed -i "s|VERSION_PLACEHOLDER|${MANIFEST_VERSION}|g" core-ide.json
sed -i "s|URL_PLACEHOLDER|https://github.com/host-uk/core/releases/download/${VERSION}/core-ide-windows-amd64.zip|g" core-ide.json
sed -i "s|HASH_PLACEHOLDER|${IDE_WIN_AMD64}|g" core-ide.json
sed -i 's/^ //' core-ide.json
fi
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git add .
git diff --cached --quiet && echo "No changes to scoop bucket" && exit 0
git commit -m "Update core to ${MANIFEST_VERSION}"
git push

View file

@ -41,7 +41,7 @@ steps:
settings:
api_key:
from_secret: forgejo_token
base_url: https://forge.lthn.ai
base_url: https://forge.lthn.io
files:
- bin/bugseti-linux-amd64.tar.gz
- bin/bugseti-linux-amd64.tar.gz.sha256

View file

@ -9,9 +9,9 @@ steps:
- go mod download
- >-
go build
-ldflags "-X github.com/host-uk/core/pkg/cli.AppVersion=ci
-X github.com/host-uk/core/pkg/cli.BuildCommit=${CI_COMMIT_SHA:0:7}
-X github.com/host-uk/core/pkg/cli.BuildDate=$(date -u +%Y%m%d)"
-ldflags "-X forge.lthn.ai/core/cli/pkg/cli.AppVersion=ci
-X forge.lthn.ai/core/cli/pkg/cli.BuildCommit=${CI_COMMIT_SHA:0:7}
-X forge.lthn.ai/core/cli/pkg/cli.BuildDate=$(date -u +%Y%m%d)"
-o ./bin/core .
- ./bin/core --version

View file

@ -1,14 +1,14 @@
# Core
[![codecov](https://codecov.io/gh/host-uk/core/branch/dev/graph/badge.svg)](https://codecov.io/gh/host-uk/core)
[![Go Test Coverage](https://github.com/host-uk/core/actions/workflows/coverage.yml/badge.svg)](https://github.com/host-uk/core/actions/workflows/coverage.yml)
[![Code Scanning](https://github.com/host-uk/core/actions/workflows/codescan.yml/badge.svg)](https://github.com/host-uk/core/actions/workflows/codescan.yml)
[![Go Test Coverage](https://forge.lthn.ai/core/cli/actions/workflows/coverage.yml/badge.svg)](https://forge.lthn.ai/core/cli/actions/workflows/coverage.yml)
[![Code Scanning](https://forge.lthn.ai/core/cli/actions/workflows/codescan.yml/badge.svg)](https://forge.lthn.ai/core/cli/actions/workflows/codescan.yml)
[![Go Version](https://img.shields.io/github/go-mod/go-version/host-uk/core)](https://go.dev/)
[![License](https://img.shields.io/badge/License-EUPL--1.2-blue.svg)](https://opensource.org/licenses/EUPL-1.2)
Core is a Web3 Framework, written in Go using Wails.io to replace Electron and the bloat of browsers that, at their core, still live in their mum's basement.
- Repo: https://github.com/host-uk/core
- Repo: https://forge.lthn.ai/core/cli
## Vision
@ -26,7 +26,7 @@ Core is an **opinionated Web3 desktop application framework** providing:
```bash
# 1. Install Core
go install github.com/host-uk/core/cmd/core@latest
go install forge.lthn.ai/core/cli/cmd/core@latest
# 2. Verify environment
core doctor
@ -44,7 +44,7 @@ For more details, see the [User Guide](docs/user-guide.md).
## Framework Quick Start (Go)
```go
import core "github.com/host-uk/core/pkg/framework/core"
import core "forge.lthn.ai/core/cli/pkg/framework/core"
app, err := core.New(
core.WithServiceLock(),
@ -210,7 +210,7 @@ app.RegisterService(application.NewService(coreService)) // Only Core is regist
**Currently exposed** (see `cmd/core-gui/public/bindings/`):
```typescript
// From frontend:
import { ACTION, Config, Service } from './bindings/github.com/host-uk/core/pkg/core'
import { ACTION, Config, Service } from './bindings/forge.lthn.ai/core/cli/pkg/core'
ACTION(msg) // Broadcast IPC message
Config() // Get config service reference
@ -259,7 +259,7 @@ Sub-services are accessed via Core's **IPC/ACTION system**, not direct Wails bin
```typescript
// Frontend calls Core.ACTION() with typed messages
import { ACTION } from './bindings/github.com/host-uk/core/pkg/core'
import { ACTION } from './bindings/forge.lthn.ai/core/cli/pkg/core'
// Open a window
ACTION({ action: "display.open_window", name: "settings", options: { Title: "Settings", Width: 800 } })

View file

@ -15,7 +15,7 @@ vars:
SEMVER_PRERELEASE:
sh: '[ "{{.SEMVER_COMMITS}}" = "0" ] && echo "" || echo "dev.{{.SEMVER_COMMITS}}"'
# ldflags
PKG: "github.com/host-uk/core/pkg/cli"
PKG: "forge.lthn.ai/core/cli/pkg/cli"
LDFLAGS_BASE: >-
-X {{.PKG}}.AppVersion={{.SEMVER_VERSION}}
-X {{.PKG}}.BuildCommit={{.SEMVER_COMMIT}}

View file

@ -19,7 +19,7 @@ BugSETI is a system tray application that helps developers contribute to open so
```bash
# Clone the repository
git clone https://github.com/host-uk/core.git
git clone https://forge.lthn.ai/core/cli.git
cd core
# Build BugSETI

View file

@ -12,7 +12,7 @@ description: |
it pulls OSS issues from GitHub, AI prepares context,
you fix bugs, and it auto-submits PRs.
vendor: "Lethean"
homepage: "https://github.com/host-uk/core"
homepage: "https://forge.lthn.ai/core/cli"
license: "MIT"
contents:

View file

@ -1,40 +1,53 @@
module github.com/host-uk/core/cmd/bugseti
module forge.lthn.ai/core/cli/cmd/bugseti
go 1.25.5
require (
forge.lthn.ai/core/cli v0.0.0
forge.lthn.ai/core/cli/internal/bugseti v0.0.0
forge.lthn.ai/core/cli/internal/bugseti/updater v0.0.0
github.com/Snider/Borg v0.2.0
github.com/host-uk/core v0.0.0
github.com/host-uk/core/internal/bugseti v0.0.0
github.com/host-uk/core/internal/bugseti/updater v0.0.0
forge.lthn.ai/core/cli v0.0.0
forge.lthn.ai/core/cli/internal/bugseti v0.0.0
forge.lthn.ai/core/cli/internal/bugseti/updater v0.0.0
github.com/wailsapp/wails/v3 v3.0.0-alpha.64
)
replace github.com/host-uk/core => ../..
replace forge.lthn.ai/core/cli => ../..
replace github.com/host-uk/core/internal/bugseti => ../../internal/bugseti
replace forge.lthn.ai/core/cli/internal/bugseti => ../../internal/bugseti
replace github.com/host-uk/core/internal/bugseti/updater => ../../internal/bugseti/updater
replace forge.lthn.ai/core/cli/internal/bugseti/updater => ../../internal/bugseti/updater
require (
codeberg.org/mvdkleijn/forgejo-sdk/forgejo/v2 v2.2.0 // indirect
dario.cat/mergo v1.0.2 // indirect
github.com/42wim/httpsig v1.2.3 // indirect
github.com/Microsoft/go-winio v0.6.2 // indirect
github.com/ProtonMail/go-crypto v1.3.0 // indirect
github.com/Snider/Enchantrix v0.0.2 // indirect
github.com/adrg/xdg v0.5.3 // indirect
github.com/bahlo/generic-list-go v0.2.0 // indirect
github.com/bep/debounce v1.2.1 // indirect
github.com/buger/jsonparser v1.1.1 // indirect
github.com/cloudflare/circl v1.6.3 // indirect
github.com/coder/websocket v1.8.14 // indirect
github.com/cyphar/filepath-securejoin v0.6.1 // indirect
github.com/davidmz/go-pageant v1.0.2 // indirect
github.com/ebitengine/purego v0.9.1 // indirect
github.com/emirpasic/gods v1.18.1 // indirect
github.com/fsnotify/fsnotify v1.9.0 // indirect
github.com/go-fed/httpsig v1.1.0 // indirect
github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect
github.com/go-git/go-billy/v5 v5.7.0 // indirect
github.com/go-git/go-git/v5 v5.16.4 // indirect
github.com/go-ole/go-ole v1.3.0 // indirect
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
github.com/godbus/dbus/v5 v5.2.2 // indirect
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/hashicorp/go-version v1.7.0 // indirect
github.com/invopop/jsonschema v0.13.0 // indirect
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect
github.com/jchv/go-winloader v0.0.0-20250406163304-c1995be93bd1 // indirect
github.com/kevinburke/ssh_config v1.4.0 // indirect
@ -42,20 +55,34 @@ require (
github.com/leaanthony/go-ansi-parser v1.6.1 // indirect
github.com/leaanthony/u v1.1.1 // indirect
github.com/lmittmann/tint v1.1.2 // indirect
github.com/mailru/easyjson v0.9.1 // indirect
github.com/mark3labs/mcp-go v0.43.2 // indirect
github.com/mattn/go-colorable v0.1.14 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
github.com/pjbgf/sha1cd v0.5.0 // indirect
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/sagikazarmark/locafero v0.11.0 // indirect
github.com/samber/lo v1.52.0 // indirect
github.com/sergi/go-diff v1.4.0 // indirect
github.com/skeema/knownhosts v1.3.2 // indirect
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect
github.com/spf13/afero v1.15.0 // indirect
github.com/spf13/cast v1.10.0 // indirect
github.com/spf13/pflag v1.0.10 // indirect
github.com/spf13/viper v1.21.0 // indirect
github.com/subosito/gotenv v1.6.0 // indirect
github.com/wailsapp/go-webview2 v1.0.23 // indirect
github.com/wk8/go-ordered-map/v2 v2.1.8 // indirect
github.com/xanzy/ssh-agent v0.3.3 // indirect
github.com/yosida95/uritemplate/v3 v3.0.2 // indirect
go.yaml.in/yaml/v3 v3.0.4 // indirect
golang.org/x/crypto v0.47.0 // indirect
golang.org/x/mod v0.32.0 // indirect
golang.org/x/net v0.49.0 // indirect
golang.org/x/sys v0.40.0 // indirect
golang.org/x/text v0.33.0 // indirect
gopkg.in/warnings.v0 v0.1.2 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)

View file

@ -1,5 +1,7 @@
codeberg.org/mvdkleijn/forgejo-sdk/forgejo/v2 v2.2.0 h1:HTCWpzyWQOHDWt3LzI6/d2jvUDsw/vgGRWm/8BTvcqI=
dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8=
dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA=
github.com/42wim/httpsig v1.2.3 h1:xb0YyWhkYj57SPtfSttIobJUPJZB9as1nsfo7KWVcEs=
github.com/Microsoft/go-winio v0.5.2/go.mod h1:WpS1mjBmmwHBEWmogvA2mj8546UReBk4v8QkMxJ6pZY=
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
@ -15,8 +17,10 @@ github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFI
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4=
github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio=
github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs=
github.com/bahlo/generic-list-go v0.2.0 h1:5sz/EEAK+ls5wF+NeqDpk5+iNdMDXrh3z3nPnH1Wvgk=
github.com/bep/debounce v1.2.1 h1:v67fRdBA9UQu2NhLFXrSg0Brw7CexQekrBwDMM8bzeY=
github.com/bep/debounce v1.2.1/go.mod h1:H8yggRPQKLUhUoqrJC1bO2xNya7vanpDl7xR3ISbCJ0=
github.com/buger/jsonparser v1.1.1 h1:2PnMjfWD7wBILjqQbt530v576A/cAbQvEW9gGIpYMUs=
github.com/cloudflare/circl v1.6.3 h1:9GPOhQGF9MCYUeXyMYlqTR6a5gTrgR/fBLXvUgtVcg8=
github.com/cloudflare/circl v1.6.3/go.mod h1:2eXP6Qfat4O/Yhh8BznvKnJ+uzEoTQ6jVKJRn81BiS4=
github.com/coder/websocket v1.8.14 h1:9L0p0iKiNOibykf283eHkKUHHrpG7f65OE3BhhO7v9g=
@ -27,14 +31,18 @@ github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSs
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davidmz/go-pageant v1.0.2 h1:bPblRCh5jGU+Uptpz6LgMZGD5hJoOt7otgT454WvHn0=
github.com/ebitengine/purego v0.9.1 h1:a/k2f2HQU3Pi399RPW1MOaZyhKJL9w/xFpKAg4q1s0A=
github.com/ebitengine/purego v0.9.1/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
github.com/elazarl/goproxy v1.7.2 h1:Y2o6urb7Eule09PjlhQRGNsqRfPmYI3KKQLFpCAV3+o=
github.com/elazarl/goproxy v1.7.2/go.mod h1:82vkLNir0ALaW14Rc399OTTjyNREgmdL2cVoIbS6XaE=
github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc=
github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ=
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
github.com/gliderlabs/ssh v0.3.8 h1:a4YXD1V7xMF9g5nTkdfnja3Sxy1PVDCj1Zg4Wb8vY6c=
github.com/gliderlabs/ssh v0.3.8/go.mod h1:xYoytBv1sV0aL3CavoDuJIQNURXkkfPA/wxQ1pL1fAU=
github.com/go-fed/httpsig v1.1.0 h1:9M+hb0jkEICD8/cAiNqEB66R87tTINszBRTjwjQzWcI=
github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 h1:+zs/tPmkDkHx3U66DAb0lQFJrpS6731Oaa12ikc+DiI=
github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376/go.mod h1:an3vInlBmSxCcxctByoQdvwPiA7DTK7jaaFDBTtu0ic=
github.com/go-git/go-billy/v5 v5.7.0 h1:83lBUJhGWhYp0ngzCMSgllhUSuoHP1iEWYjsPl9nwqM=
@ -47,6 +55,7 @@ github.com/go-json-experiment/json v0.0.0-20251027170946-4849db3c2f7e h1:Lf/gRko
github.com/go-json-experiment/json v0.0.0-20251027170946-4849db3c2f7e/go.mod h1:uNVvRXArCGbZ508SxYYTC5v1JWoz2voff5pm25jU1Ok=
github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE=
github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78=
github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs=
github.com/godbus/dbus/v5 v5.2.2 h1:TUR3TgtSVDmjiXOgAAyaZbYmIeP3DPkld3jgKGV8mXQ=
github.com/godbus/dbus/v5 v5.2.2/go.mod h1:3AAv2+hPq5rdnr5txxxRwiGjPXamgoIHgz9FPBfOp3c=
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 h1:f+oWsMOmNPc8JmEHVZIycC7hBoQxHH9pNKQORJNozsQ=
@ -55,6 +64,8 @@ github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/hashicorp/go-version v1.7.0 h1:5tqGy27NaOTB8yJKUZELlFAS/LTKJkrmONwQKeRZfjY=
github.com/invopop/jsonschema v0.13.0 h1:KvpoAJWEjR3uD9Kbm2HWJmqsEaHt8lBUpd0qHcIi21E=
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 h1:BQSFePA1RWJOlocH6Fxy8MmwDt+yVQYULKfN0RoTN8A=
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99/go.mod h1:1lJo3i6rXxKeerYnT8Nvf0QmHCRC1n8sfWVwXF2Frvo=
github.com/jchv/go-winloader v0.0.0-20250406163304-c1995be93bd1 h1:njuLRcjAuMKr7kI3D85AXWkw6/+v9PwtV6M6o11sWHQ=
@ -76,6 +87,8 @@ github.com/leaanthony/u v1.1.1 h1:TUFjwDGlNX+WuwVEzDqQwC2lOv0P4uhTQw7CMFdiK7M=
github.com/leaanthony/u v1.1.1/go.mod h1:9+o6hejoRljvZ3BzdYlVL0JYCwtnAsVuN9pVTQcaRfI=
github.com/lmittmann/tint v1.1.2 h1:2CQzrL6rslrsyjqLDwD11bZ5OpLBPU+g3G/r5LSfS8w=
github.com/lmittmann/tint v1.1.2/go.mod h1:HIS3gSy7qNwGCj+5oRjAutErFBl4BzdQP6cJZ0NfMwE=
github.com/mailru/easyjson v0.9.1 h1:LbtsOm5WAswyWbvTEOqhypdPeZzHavpZx96/n553mR8=
github.com/mark3labs/mcp-go v0.43.2 h1:21PUSlWWiSbUPQwXIJ5WKlETixpFpq+WBpbMGDSVy/I=
github.com/matryer/is v1.4.0/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU=
github.com/matryer/is v1.4.1 h1:55ehd8zaGABKLXQUe2awZ99BD/PTc2ls+KV/dXphgEQ=
github.com/matryer/is v1.4.1/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU=
@ -85,6 +98,7 @@ github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWE
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/onsi/gomega v1.34.1 h1:EUMJIKUjM8sKjYbtxQI9A4z2o+rruxnzNvpknOXie6k=
github.com/onsi/gomega v1.34.1/go.mod h1:kU1QgUvBDLXBJq618Xvm2LUX6rSAfRaFRTcdOeDLwwY=
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
github.com/pjbgf/sha1cd v0.5.0 h1:a+UkboSi1znleCDUNT3M5YxjOnN1fz2FhN48FlwCxs0=
github.com/pjbgf/sha1cd v0.5.0/go.mod h1:lhpGlyHLpQZoxMv8HcgXvZEhcGs0PG/vsZnEJ7H0iCM=
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ=
@ -99,6 +113,7 @@ github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
github.com/sagikazarmark/locafero v0.11.0 h1:1iurJgmM9G3PA/I+wWYIOw/5SyBtxapeHDcg+AAIFXc=
github.com/samber/lo v1.52.0 h1:Rvi+3BFHES3A8meP33VPAxiBZX/Aws5RxrschYGjomw=
github.com/samber/lo v1.52.0/go.mod h1:4+MXEGsJzbKGaUEQFKBq2xtfuznW9oz/WrgyzMzRoM0=
github.com/sergi/go-diff v1.4.0 h1:n/SP9D5ad1fORl+llWyN+D6qoUETXNZARKjyY2/KVCw=
@ -106,17 +121,26 @@ github.com/sergi/go-diff v1.4.0/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepq
github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0=
github.com/skeema/knownhosts v1.3.2 h1:EDL9mgf4NzwMXCTfaxSD/o/a5fxDw/xL9nkU28JjdBg=
github.com/skeema/knownhosts v1.3.2/go.mod h1:bEg3iQAuw+jyiw+484wwFJoKSLwcfd7fqRy+N0QTiow=
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 h1:+jumHNA0Wrelhe64i8F6HNlS8pkoyMv5sreGx2Ry5Rw=
github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I=
github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY=
github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
github.com/wailsapp/go-webview2 v1.0.23 h1:jmv8qhz1lHibCc79bMM/a/FqOnnzOGEisLav+a0b9P0=
github.com/wailsapp/go-webview2 v1.0.23/go.mod h1:qJmWAmAmaniuKGZPWwne+uor3AHMB5PFhqiK0Bbj8kc=
github.com/wailsapp/wails/v3 v3.0.0-alpha.64 h1:xAhLFVfdbg7XdZQ5mMQmBv2BglWu8hMqe50Z+3UJvBs=
github.com/wailsapp/wails/v3 v3.0.0-alpha.64/go.mod h1:zvgNL/mlFcX8aRGu6KOz9AHrMmTBD+4hJRQIONqF/Yw=
github.com/wk8/go-ordered-map/v2 v2.1.8 h1:5h/BUHu93oj4gIdvHHHGsScSTMijfx5PeYkE/fJgbpc=
github.com/xanzy/ssh-agent v0.3.3 h1:+/15pJfg/RsTxqYcX6fHqOXZwwMP+2VyYWJeWM2qQFM=
github.com/xanzy/ssh-agent v0.3.3/go.mod h1:6dzNDKs0J9rVPHPhaGCukekBHKqfl+L3KghI1Bc68Uw=
github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4=
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8=
golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A=

View file

@ -2,7 +2,7 @@
// BugSETI - "Distributed Bug Fixing like SETI@home but for code"
//
// The application runs as a system tray app that:
// - Pulls OSS issues from GitHub
// - Pulls OSS issues from Forgejo
// - Uses AI to prepare context for each issue
// - Presents issues to users for fixing
// - Automates PR submission
@ -16,9 +16,9 @@ import (
"runtime"
"strings"
"github.com/host-uk/core/cmd/bugseti/icons"
"github.com/host-uk/core/internal/bugseti"
"github.com/host-uk/core/internal/bugseti/updater"
"forge.lthn.ai/core/cli/cmd/bugseti/icons"
"forge.lthn.ai/core/cli/internal/bugseti"
"forge.lthn.ai/core/cli/internal/bugseti/updater"
"github.com/wailsapp/wails/v3/pkg/application"
"github.com/wailsapp/wails/v3/pkg/events"
)
@ -39,13 +39,20 @@ func main() {
log.Printf("Warning: Could not load config: %v", err)
}
// Check Forgejo API availability
forgeClient, err := bugseti.CheckForge()
if err != nil {
log.Fatalf("Forgejo check failed: %v\n\nConfigure with: core forge config --url URL --token TOKEN", err)
}
// Initialize core services
notifyService := bugseti.NewNotifyService(configService)
statsService := bugseti.NewStatsService(configService)
fetcherService := bugseti.NewFetcherService(configService, notifyService)
fetcherService := bugseti.NewFetcherService(configService, notifyService, forgeClient)
queueService := bugseti.NewQueueService(configService)
seederService := bugseti.NewSeederService(configService)
submitService := bugseti.NewSubmitService(configService, notifyService, statsService)
seederService := bugseti.NewSeederService(configService, forgeClient.URL(), forgeClient.Token())
submitService := bugseti.NewSubmitService(configService, notifyService, statsService, forgeClient)
hubService := bugseti.NewHubService(configService)
versionService := bugseti.NewVersionService()
workspaceService := NewWorkspaceService(configService)
@ -69,6 +76,7 @@ func main() {
application.NewService(submitService),
application.NewService(versionService),
application.NewService(workspaceService),
application.NewService(hubService),
application.NewService(trayService),
}
@ -107,6 +115,19 @@ func main() {
log.Println(" - Waiting for issues...")
log.Printf(" - Version: %s (%s)", bugseti.GetVersion(), bugseti.GetChannel())
// Attempt hub registration (non-blocking)
if hubURL := configService.GetHubURL(); hubURL != "" {
if err := hubService.AutoRegister(); err != nil {
log.Printf(" - Hub: auto-register skipped: %v", err)
} else if err := hubService.Register(); err != nil {
log.Printf(" - Hub: registration failed: %v", err)
} else {
log.Println(" - Hub: registered with portal")
}
} else {
log.Println(" - Hub: not configured (set hubUrl in config)")
}
if err := app.Run(); err != nil {
log.Fatal(err)
}

View file

@ -5,7 +5,7 @@ import (
"context"
"log"
"github.com/host-uk/core/internal/bugseti"
"forge.lthn.ai/core/cli/internal/bugseti"
"github.com/wailsapp/wails/v3/pkg/application"
)

View file

@ -7,12 +7,22 @@ import (
"log"
"os"
"path/filepath"
"sort"
"sync"
"time"
"github.com/Snider/Borg/pkg/tim"
"github.com/host-uk/core/internal/bugseti"
"github.com/host-uk/core/pkg/io/datanode"
"forge.lthn.ai/core/cli/internal/bugseti"
"forge.lthn.ai/core/cli/pkg/io/datanode"
)
const (
// defaultMaxWorkspaces is the fallback upper bound when config is unavailable.
defaultMaxWorkspaces = 100
// defaultWorkspaceTTL is the fallback TTL when config is unavailable.
defaultWorkspaceTTL = 24 * time.Hour
// sweepInterval is how often the background sweeper runs.
sweepInterval = 5 * time.Minute
)
// WorkspaceService manages DataNode-backed workspaces for issues.
@ -20,8 +30,10 @@ import (
// snapshotted, packaged as a TIM container, or shipped as a crash report.
type WorkspaceService struct {
config *bugseti.ConfigService
workspaces map[string]*Workspace // issue ID workspace
workspaces map[string]*Workspace // issue ID -> workspace
mu sync.RWMutex
done chan struct{} // signals the background sweeper to stop
stopped chan struct{} // closed when the sweeper goroutine exits
}
// Workspace tracks a DataNode-backed workspace for an issue.
@ -47,10 +59,13 @@ type CrashReport struct {
}
// NewWorkspaceService creates a new WorkspaceService.
// Call Start() to begin the background TTL sweeper.
func NewWorkspaceService(config *bugseti.ConfigService) *WorkspaceService {
return &WorkspaceService{
config: config,
workspaces: make(map[string]*Workspace),
done: make(chan struct{}),
stopped: make(chan struct{}),
}
}
@ -59,6 +74,56 @@ func (w *WorkspaceService) ServiceName() string {
return "WorkspaceService"
}
// Start launches the background sweeper goroutine that periodically
// evicts expired workspaces. This prevents unbounded map growth even
// when no new Capture calls arrive.
func (w *WorkspaceService) Start() {
go func() {
defer close(w.stopped)
ticker := time.NewTicker(sweepInterval)
defer ticker.Stop()
for {
select {
case <-ticker.C:
w.mu.Lock()
evicted := w.cleanup()
w.mu.Unlock()
if evicted > 0 {
log.Printf("Workspace sweeper: evicted %d stale entries, %d remaining", evicted, w.ActiveWorkspaces())
}
case <-w.done:
return
}
}
}()
log.Printf("Workspace sweeper started (interval=%s, ttl=%s, max=%d)",
sweepInterval, w.ttl(), w.maxCap())
}
// Stop signals the background sweeper to exit and waits for it to finish.
func (w *WorkspaceService) Stop() {
close(w.done)
<-w.stopped
log.Printf("Workspace sweeper stopped")
}
// ttl returns the configured workspace TTL, falling back to the default.
func (w *WorkspaceService) ttl() time.Duration {
if w.config != nil {
return w.config.GetWorkspaceTTL()
}
return defaultWorkspaceTTL
}
// maxCap returns the configured max workspace count, falling back to the default.
func (w *WorkspaceService) maxCap() int {
if w.config != nil {
return w.config.GetMaxWorkspaces()
}
return defaultMaxWorkspaces
}
// Capture loads a filesystem workspace into a DataNode Medium.
// Call this after git clone to create the in-memory snapshot.
func (w *WorkspaceService) Capture(issue *bugseti.Issue, diskPath string) error {
@ -109,6 +174,7 @@ func (w *WorkspaceService) Capture(issue *bugseti.Issue, diskPath string) error
}
w.mu.Lock()
w.cleanup()
w.workspaces[issue.ID] = &Workspace{
Issue: issue,
Medium: m,
@ -240,6 +306,46 @@ func (w *WorkspaceService) SaveCrashReport(report *CrashReport) (string, error)
return path, nil
}
// cleanup evicts expired workspaces and enforces the max size cap.
// Must be called with w.mu held for writing.
// Returns the number of evicted entries.
func (w *WorkspaceService) cleanup() int {
now := time.Now()
ttl := w.ttl()
cap := w.maxCap()
evicted := 0
// First pass: evict entries older than TTL.
for id, ws := range w.workspaces {
if now.Sub(ws.CreatedAt) > ttl {
delete(w.workspaces, id)
evicted++
}
}
// Second pass: if still over cap, evict oldest entries.
if len(w.workspaces) > cap {
type entry struct {
id string
createdAt time.Time
}
entries := make([]entry, 0, len(w.workspaces))
for id, ws := range w.workspaces {
entries = append(entries, entry{id, ws.CreatedAt})
}
sort.Slice(entries, func(i, j int) bool {
return entries[i].createdAt.Before(entries[j].createdAt)
})
toEvict := len(w.workspaces) - cap
for i := 0; i < toEvict; i++ {
delete(w.workspaces, entries[i].id)
evicted++
}
}
return evicted
}
// Release removes a workspace from memory.
func (w *WorkspaceService) Release(issueID string) {
w.mu.Lock()

View file

@ -0,0 +1,151 @@
package main
import (
"fmt"
"testing"
"time"
"forge.lthn.ai/core/cli/internal/bugseti"
)
func TestCleanup_TTL(t *testing.T) {
svc := NewWorkspaceService(bugseti.NewConfigService())
// Seed with entries that are older than TTL.
svc.mu.Lock()
for i := 0; i < 5; i++ {
svc.workspaces[fmt.Sprintf("old-%d", i)] = &Workspace{
CreatedAt: time.Now().Add(-25 * time.Hour),
}
}
// Add one fresh entry.
svc.workspaces["fresh"] = &Workspace{
CreatedAt: time.Now(),
}
svc.cleanup()
svc.mu.Unlock()
if got := svc.ActiveWorkspaces(); got != 1 {
t.Errorf("expected 1 workspace after TTL cleanup, got %d", got)
}
}
func TestCleanup_MaxSize(t *testing.T) {
svc := NewWorkspaceService(bugseti.NewConfigService())
maxCap := svc.maxCap()
// Fill beyond the cap with fresh entries.
svc.mu.Lock()
for i := 0; i < maxCap+20; i++ {
svc.workspaces[fmt.Sprintf("ws-%d", i)] = &Workspace{
CreatedAt: time.Now().Add(-time.Duration(i) * time.Minute),
}
}
svc.cleanup()
svc.mu.Unlock()
if got := svc.ActiveWorkspaces(); got != maxCap {
t.Errorf("expected %d workspaces after cap cleanup, got %d", maxCap, got)
}
}
func TestCleanup_EvictsOldestWhenOverCap(t *testing.T) {
svc := NewWorkspaceService(bugseti.NewConfigService())
maxCap := svc.maxCap()
// Create maxCap+1 entries; the newest should survive.
svc.mu.Lock()
for i := 0; i <= maxCap; i++ {
svc.workspaces[fmt.Sprintf("ws-%d", i)] = &Workspace{
CreatedAt: time.Now().Add(-time.Duration(maxCap-i) * time.Minute),
}
}
svc.cleanup()
svc.mu.Unlock()
// The newest entry (ws-<maxCap>) should still exist.
newest := fmt.Sprintf("ws-%d", maxCap)
svc.mu.RLock()
_, exists := svc.workspaces[newest]
svc.mu.RUnlock()
if !exists {
t.Error("expected newest workspace to survive eviction")
}
// The oldest entry (ws-0) should have been evicted.
svc.mu.RLock()
_, exists = svc.workspaces["ws-0"]
svc.mu.RUnlock()
if exists {
t.Error("expected oldest workspace to be evicted")
}
}
func TestCleanup_ReturnsEvictedCount(t *testing.T) {
svc := NewWorkspaceService(bugseti.NewConfigService())
svc.mu.Lock()
for i := 0; i < 3; i++ {
svc.workspaces[fmt.Sprintf("old-%d", i)] = &Workspace{
CreatedAt: time.Now().Add(-25 * time.Hour),
}
}
svc.workspaces["fresh"] = &Workspace{
CreatedAt: time.Now(),
}
evicted := svc.cleanup()
svc.mu.Unlock()
if evicted != 3 {
t.Errorf("expected 3 evicted entries, got %d", evicted)
}
}
func TestStartStop(t *testing.T) {
svc := NewWorkspaceService(bugseti.NewConfigService())
svc.Start()
// Add a stale entry while the sweeper is running.
svc.mu.Lock()
svc.workspaces["stale"] = &Workspace{
CreatedAt: time.Now().Add(-25 * time.Hour),
}
svc.mu.Unlock()
// Stop should return without hanging.
svc.Stop()
}
func TestConfigurableTTL(t *testing.T) {
cfg := bugseti.NewConfigService()
svc := NewWorkspaceService(cfg)
// Default TTL should be 24h (1440 minutes).
if got := svc.ttl(); got != 24*time.Hour {
t.Errorf("expected default TTL of 24h, got %s", got)
}
// Default max cap should be 100.
if got := svc.maxCap(); got != 100 {
t.Errorf("expected default max cap of 100, got %d", got)
}
}
func TestNilConfigFallback(t *testing.T) {
svc := &WorkspaceService{
config: nil,
workspaces: make(map[string]*Workspace),
done: make(chan struct{}),
stopped: make(chan struct{}),
}
if got := svc.ttl(); got != defaultWorkspaceTTL {
t.Errorf("expected fallback TTL %s, got %s", defaultWorkspaceTTL, got)
}
if got := svc.maxCap(); got != defaultMaxWorkspaces {
t.Errorf("expected fallback max cap %d, got %d", defaultMaxWorkspaces, got)
}
}

View file

@ -186,7 +186,7 @@
<div class="flex items-center gap-6 text-sm">
<a href="#how-it-works" class="text-lethean-400 hover:text-lethean-200 transition-colors link-underline">How it works</a>
<a href="#ecosystem" class="text-lethean-400 hover:text-lethean-200 transition-colors link-underline">Ecosystem</a>
<a href="https://github.com/host-uk/core" target="_blank" rel="noopener" class="text-lethean-400 hover:text-lethean-200 transition-colors link-underline">GitHub</a>
<a href="https://forge.lthn.ai/core/cli" target="_blank" rel="noopener" class="text-lethean-400 hover:text-lethean-200 transition-colors link-underline">GitHub</a>
<a href="#join" class="inline-flex items-center gap-1.5 px-4 py-1.5 rounded-md bg-cyan-400/10 text-cyan-400 border border-cyan-400/20 hover:bg-cyan-400/20 hover:border-cyan-400/30 transition-all text-sm font-medium">
Get BugSETI
</a>
@ -249,7 +249,7 @@
Download BugSETI
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 7l5 5m0 0l-5 5m5-5H6"/></svg>
</a>
<a href="https://github.com/host-uk/core" target="_blank" rel="noopener" class="inline-flex items-center gap-2 px-6 py-3 rounded-lg border border-lethean-600/50 text-lethean-300 font-medium text-sm hover:bg-lethean-800/50 hover:border-lethean-500/50 transition-all">
<a href="https://forge.lthn.ai/core/cli" target="_blank" rel="noopener" class="inline-flex items-center gap-2 px-6 py-3 rounded-lg border border-lethean-600/50 text-lethean-300 font-medium text-sm hover:bg-lethean-800/50 hover:border-lethean-500/50 transition-all">
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 24 24"><path d="M12 0C5.37 0 0 5.37 0 12c0 5.31 3.435 9.795 8.205 11.385.6.105.825-.255.825-.57 0-.285-.015-1.23-.015-2.235-3.015.555-3.795-.735-4.035-1.41-.135-.345-.72-1.41-1.23-1.695-.42-.225-1.02-.78-.015-.795.945-.015 1.62.87 1.845 1.23 1.08 1.815 2.805 1.305 3.495.99.105-.78.42-1.305.765-1.605-2.67-.3-5.46-1.335-5.46-5.925 0-1.305.465-2.385 1.23-3.225-.12-.3-.54-1.53.12-3.18 0 0 1.005-.315 3.3 1.23.96-.27 1.98-.405 3-.405s2.04.135 3 .405c2.295-1.56 3.3-1.23 3.3-1.23.66 1.65.24 2.88.12 3.18.765.84 1.23 1.905 1.23 3.225 0 4.605-2.805 5.625-5.475 5.925.435.375.81 1.095.81 2.22 0 1.605-.015 2.895-.015 3.3 0 .315.225.69.825.57A12.02 12.02 0 0024 12c0-6.63-5.37-12-12-12z"/></svg>
View Source
</a>
@ -518,13 +518,13 @@
<!-- Download buttons -->
<div class="flex flex-col sm:flex-row items-center justify-center gap-3 mb-12">
<a href="https://github.com/host-uk/core/releases" target="_blank" rel="noopener" class="w-full sm:w-auto inline-flex items-center justify-center gap-2 px-6 py-3.5 rounded-lg bg-lethean-800 border border-lethean-600/40 text-lethean-200 font-medium text-sm hover:bg-lethean-700 hover:border-lethean-500/50 transition-all">
<a href="https://forge.lthn.ai/core/cli/releases" target="_blank" rel="noopener" class="w-full sm:w-auto inline-flex items-center justify-center gap-2 px-6 py-3.5 rounded-lg bg-lethean-800 border border-lethean-600/40 text-lethean-200 font-medium text-sm hover:bg-lethean-700 hover:border-lethean-500/50 transition-all">
<span class="text-lg">🐧</span> Linux
</a>
<a href="https://github.com/host-uk/core/releases" target="_blank" rel="noopener" class="w-full sm:w-auto inline-flex items-center justify-center gap-2 px-6 py-3.5 rounded-lg bg-lethean-800 border border-lethean-600/40 text-lethean-200 font-medium text-sm hover:bg-lethean-700 hover:border-lethean-500/50 transition-all">
<a href="https://forge.lthn.ai/core/cli/releases" target="_blank" rel="noopener" class="w-full sm:w-auto inline-flex items-center justify-center gap-2 px-6 py-3.5 rounded-lg bg-lethean-800 border border-lethean-600/40 text-lethean-200 font-medium text-sm hover:bg-lethean-700 hover:border-lethean-500/50 transition-all">
<span class="text-lg">🍎</span> macOS
</a>
<a href="https://github.com/host-uk/core/releases" target="_blank" rel="noopener" class="w-full sm:w-auto inline-flex items-center justify-center gap-2 px-6 py-3.5 rounded-lg bg-lethean-800 border border-lethean-600/40 text-lethean-200 font-medium text-sm hover:bg-lethean-700 hover:border-lethean-500/50 transition-all">
<a href="https://forge.lthn.ai/core/cli/releases" target="_blank" rel="noopener" class="w-full sm:w-auto inline-flex items-center justify-center gap-2 px-6 py-3.5 rounded-lg bg-lethean-800 border border-lethean-600/40 text-lethean-200 font-medium text-sm hover:bg-lethean-700 hover:border-lethean-500/50 transition-all">
<span class="text-lg">🪟</span> Windows
</a>
</div>
@ -533,7 +533,7 @@
<div class="gradient-border rounded-lg overflow-hidden max-w-md mx-auto">
<div class="bg-lethean-900 rounded-lg px-5 py-3 font-mono text-sm text-left">
<span class="text-lethean-500"># or build from source</span><br>
<span class="text-cyan-400">$</span> <span class="text-lethean-300">git clone https://github.com/host-uk/core</span><br>
<span class="text-cyan-400">$</span> <span class="text-lethean-300">git clone https://forge.lthn.ai/core/cli</span><br>
<span class="text-cyan-400">$</span> <span class="text-lethean-300">cd core && go build ./cmd/bugseti</span>
</div>
</div>

View file

@ -94,7 +94,7 @@ go build -tags nowatcher -o ../../bin/core-app .
## CRITICAL WARNINGS
- **DO NOT push to GitHub** — GitHub remotes have been removed deliberately. The host-uk org is flagged.
- **DO NOT add GitHub as a remote** — Forge (forge.lthn.ai / git.lthn.ai) is the source of truth.
- **DO NOT add GitHub as a remote** — Forge (forge.lthn.io / git.lthn.ai) is the source of truth.
- **DO NOT modify files outside `cmd/core-app/`** — This is a workspace module, keep changes scoped.
- **DO NOT remove the `-tags nowatcher` build flag** — It will fail without libwatcher-c.
- **DO NOT change the PHP-ZTS path** — It must be the ZTS variant, not the default Homebrew PHP.

View file

@ -1,4 +1,4 @@
module github.com/host-uk/core/cmd/core-app
module forge.lthn.ai/core/cli/cmd/core-app
go 1.25.5
@ -64,4 +64,4 @@ require (
gopkg.in/warnings.v0 v0.1.2 // indirect
)
replace github.com/host-uk/core => ../..
replace forge.lthn.ai/core/cli => ../..

View file

@ -0,0 +1,46 @@
<?php
declare(strict_types=1);
namespace App\Http\Middleware;
use App\Services\AllowanceService;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
class QuotaMiddleware
{
public function __construct(
private readonly AllowanceService $allowanceService,
) {}
public function handle(Request $request, Closure $next): Response
{
$agentId = $request->header('X-Agent-ID', $request->input('agent_id', ''));
$model = $request->input('model', '');
if ($agentId === '') {
return response()->json([
'error' => 'agent_id is required',
], 400);
}
$result = $this->allowanceService->check($agentId, $model);
if (! $result['allowed']) {
return response()->json([
'error' => 'quota_exceeded',
'status' => $result['status'],
'reason' => $result['reason'],
'remaining_tokens' => $result['remaining_tokens'],
'remaining_jobs' => $result['remaining_jobs'],
], 429);
}
// Attach quota info to request for downstream use
$request->merge(['_quota' => $result]);
return $next($request);
}
}

View file

@ -0,0 +1,111 @@
<?php
declare(strict_types=1);
namespace App\Livewire\Dashboard;
use Livewire\Component;
class ActivityFeed extends Component
{
public array $entries = [];
public string $agentFilter = 'all';
public string $typeFilter = 'all';
public bool $showOnlyQuestions = false;
public function mount(): void
{
$this->loadEntries();
}
public function loadEntries(): void
{
// Placeholder data — will be replaced with real-time WebSocket feed
$this->entries = [
[
'id' => 'act-001',
'agent' => 'Athena',
'type' => 'code_write',
'message' => 'Created AgentFleet Livewire component',
'job' => '#96',
'timestamp' => now()->subMinutes(2)->toIso8601String(),
'is_question' => false,
],
[
'id' => 'act-002',
'agent' => 'Athena',
'type' => 'tool_call',
'message' => 'Read file: cmd/core-app/laravel/composer.json',
'job' => '#96',
'timestamp' => now()->subMinutes(5)->toIso8601String(),
'is_question' => false,
],
[
'id' => 'act-003',
'agent' => 'Clotho',
'type' => 'question',
'message' => 'Should I apply the fix to both the TCP and Unix socket transports, or just TCP?',
'job' => '#84',
'timestamp' => now()->subMinutes(8)->toIso8601String(),
'is_question' => true,
],
[
'id' => 'act-004',
'agent' => 'Virgil',
'type' => 'pr_created',
'message' => 'Opened PR #89: fix WebSocket reconnection logic',
'job' => '#89',
'timestamp' => now()->subMinutes(15)->toIso8601String(),
'is_question' => false,
],
[
'id' => 'act-005',
'agent' => 'Virgil',
'type' => 'test_run',
'message' => 'All 47 tests passed (0.8s)',
'job' => '#89',
'timestamp' => now()->subMinutes(18)->toIso8601String(),
'is_question' => false,
],
[
'id' => 'act-006',
'agent' => 'Athena',
'type' => 'git_push',
'message' => 'Pushed branch feat/agentic-dashboard',
'job' => '#96',
'timestamp' => now()->subMinutes(22)->toIso8601String(),
'is_question' => false,
],
[
'id' => 'act-007',
'agent' => 'Clotho',
'type' => 'code_write',
'message' => 'Added input validation for MCP file_write paths',
'job' => '#84',
'timestamp' => now()->subMinutes(30)->toIso8601String(),
'is_question' => false,
],
];
}
public function getFilteredEntriesProperty(): array
{
return array_filter($this->entries, function ($entry) {
if ($this->showOnlyQuestions && !$entry['is_question']) {
return false;
}
if ($this->agentFilter !== 'all' && $entry['agent'] !== $this->agentFilter) {
return false;
}
if ($this->typeFilter !== 'all' && $entry['type'] !== $this->typeFilter) {
return false;
}
return true;
});
}
public function render()
{
return view('livewire.dashboard.activity-feed');
}
}

View file

@ -0,0 +1,85 @@
<?php
declare(strict_types=1);
namespace App\Livewire\Dashboard;
use Livewire\Component;
class AgentFleet extends Component
{
/** @var array<int, array{name: string, host: string, model: string, status: string, job: string, heartbeat: string, uptime: string}> */
public array $agents = [];
public ?string $selectedAgent = null;
public function mount(): void
{
$this->loadAgents();
}
public function loadAgents(): void
{
// Placeholder data — will be replaced with real API calls to Go backend
$this->agents = [
[
'id' => 'athena',
'name' => 'Athena',
'host' => 'studio.snider.dev',
'model' => 'claude-opus-4-6',
'status' => 'working',
'job' => '#96 agentic dashboard',
'heartbeat' => 'green',
'uptime' => '4h 23m',
'tokens_today' => 142_580,
'jobs_completed' => 3,
],
[
'id' => 'virgil',
'name' => 'Virgil',
'host' => 'studio.snider.dev',
'model' => 'claude-opus-4-6',
'status' => 'idle',
'job' => '',
'heartbeat' => 'green',
'uptime' => '12h 07m',
'tokens_today' => 89_230,
'jobs_completed' => 5,
],
[
'id' => 'clotho',
'name' => 'Clotho',
'host' => 'darwin-au',
'model' => 'claude-sonnet-4-5',
'status' => 'working',
'job' => '#84 security audit',
'heartbeat' => 'yellow',
'uptime' => '1h 45m',
'tokens_today' => 34_100,
'jobs_completed' => 1,
],
[
'id' => 'charon',
'name' => 'Charon',
'host' => 'linux.snider.dev',
'model' => 'claude-haiku-4-5',
'status' => 'unhealthy',
'job' => '',
'heartbeat' => 'red',
'uptime' => '0m',
'tokens_today' => 0,
'jobs_completed' => 0,
],
];
}
public function selectAgent(string $agentId): void
{
$this->selectedAgent = $this->selectedAgent === $agentId ? null : $agentId;
}
public function render()
{
return view('livewire.dashboard.agent-fleet');
}
}

View file

@ -0,0 +1,93 @@
<?php
declare(strict_types=1);
namespace App\Livewire\Dashboard;
use Livewire\Component;
class HumanActions extends Component
{
public array $pendingQuestions = [];
public array $reviewGates = [];
public string $answerText = '';
public ?string $answeringId = null;
public function mount(): void
{
$this->loadPending();
}
public function loadPending(): void
{
// Placeholder data — will be replaced with real data from Go backend
$this->pendingQuestions = [
[
'id' => 'q-001',
'agent' => 'Clotho',
'job' => '#84',
'question' => 'Should I apply the fix to both the TCP and Unix socket transports, or just TCP?',
'asked_at' => now()->subMinutes(8)->toIso8601String(),
'context' => 'Working on security audit — found unvalidated input in transport layer.',
],
];
$this->reviewGates = [
[
'id' => 'rg-001',
'agent' => 'Virgil',
'job' => '#89',
'type' => 'pr_review',
'title' => 'PR #89: fix WebSocket reconnection logic',
'description' => 'Adds exponential backoff and connection state tracking.',
'submitted_at' => now()->subMinutes(15)->toIso8601String(),
],
];
}
public function startAnswer(string $questionId): void
{
$this->answeringId = $questionId;
$this->answerText = '';
}
public function submitAnswer(): void
{
if (! $this->answeringId || trim($this->answerText) === '') {
return;
}
// Remove answered question from list
$this->pendingQuestions = array_values(
array_filter($this->pendingQuestions, fn ($q) => $q['id'] !== $this->answeringId)
);
$this->answeringId = null;
$this->answerText = '';
}
public function cancelAnswer(): void
{
$this->answeringId = null;
$this->answerText = '';
}
public function approveGate(string $gateId): void
{
$this->reviewGates = array_values(
array_filter($this->reviewGates, fn ($g) => $g['id'] !== $gateId)
);
}
public function rejectGate(string $gateId): void
{
$this->reviewGates = array_values(
array_filter($this->reviewGates, fn ($g) => $g['id'] !== $gateId)
);
}
public function render()
{
return view('livewire.dashboard.human-actions');
}
}

View file

@ -0,0 +1,125 @@
<?php
declare(strict_types=1);
namespace App\Livewire\Dashboard;
use Livewire\Component;
class JobQueue extends Component
{
public array $jobs = [];
public string $statusFilter = 'all';
public string $agentFilter = 'all';
public function mount(): void
{
$this->loadJobs();
}
public function loadJobs(): void
{
// Placeholder data — will be replaced with real API calls to Go backend
$this->jobs = [
[
'id' => 'job-001',
'issue' => '#96',
'repo' => 'host-uk/core',
'title' => 'feat(agentic): real-time dashboard',
'agent' => 'Athena',
'status' => 'in_progress',
'priority' => 1,
'queued_at' => now()->subMinutes(45)->toIso8601String(),
'started_at' => now()->subMinutes(30)->toIso8601String(),
],
[
'id' => 'job-002',
'issue' => '#84',
'repo' => 'host-uk/core',
'title' => 'fix: security audit findings',
'agent' => 'Clotho',
'status' => 'in_progress',
'priority' => 2,
'queued_at' => now()->subHours(2)->toIso8601String(),
'started_at' => now()->subHours(1)->toIso8601String(),
],
[
'id' => 'job-003',
'issue' => '#102',
'repo' => 'host-uk/core',
'title' => 'feat: add rate limiting to MCP',
'agent' => null,
'status' => 'queued',
'priority' => 3,
'queued_at' => now()->subMinutes(10)->toIso8601String(),
'started_at' => null,
],
[
'id' => 'job-004',
'issue' => '#89',
'repo' => 'host-uk/core',
'title' => 'fix: WebSocket reconnection',
'agent' => 'Virgil',
'status' => 'review',
'priority' => 2,
'queued_at' => now()->subHours(4)->toIso8601String(),
'started_at' => now()->subHours(3)->toIso8601String(),
],
[
'id' => 'job-005',
'issue' => '#78',
'repo' => 'host-uk/core',
'title' => 'docs: update CLAUDE.md',
'agent' => 'Virgil',
'status' => 'completed',
'priority' => 4,
'queued_at' => now()->subHours(6)->toIso8601String(),
'started_at' => now()->subHours(5)->toIso8601String(),
],
];
}
public function updatedStatusFilter(): void
{
// Livewire auto-updates the view
}
public function cancelJob(string $jobId): void
{
$this->jobs = array_map(function ($job) use ($jobId) {
if ($job['id'] === $jobId && in_array($job['status'], ['queued', 'in_progress'])) {
$job['status'] = 'cancelled';
}
return $job;
}, $this->jobs);
}
public function retryJob(string $jobId): void
{
$this->jobs = array_map(function ($job) use ($jobId) {
if ($job['id'] === $jobId && in_array($job['status'], ['failed', 'cancelled'])) {
$job['status'] = 'queued';
$job['agent'] = null;
}
return $job;
}, $this->jobs);
}
public function getFilteredJobsProperty(): array
{
return array_filter($this->jobs, function ($job) {
if ($this->statusFilter !== 'all' && $job['status'] !== $this->statusFilter) {
return false;
}
if ($this->agentFilter !== 'all' && ($job['agent'] ?? '') !== $this->agentFilter) {
return false;
}
return true;
});
}
public function render()
{
return view('livewire.dashboard.job-queue');
}
}

View file

@ -0,0 +1,60 @@
<?php
declare(strict_types=1);
namespace App\Livewire\Dashboard;
use Livewire\Component;
class Metrics extends Component
{
public array $stats = [];
public array $throughputData = [];
public array $costBreakdown = [];
public float $budgetUsed = 0;
public float $budgetLimit = 0;
public function mount(): void
{
$this->loadMetrics();
}
public function loadMetrics(): void
{
// Placeholder data — will be replaced with real metrics from Go backend
$this->stats = [
'jobs_completed' => 12,
'prs_merged' => 8,
'tokens_used' => 1_245_800,
'cost_today' => 18.42,
'active_agents' => 3,
'queue_depth' => 4,
];
$this->budgetUsed = 18.42;
$this->budgetLimit = 50.00;
// Hourly throughput for chart
$this->throughputData = [
['hour' => '00:00', 'jobs' => 0, 'tokens' => 0],
['hour' => '02:00', 'jobs' => 0, 'tokens' => 0],
['hour' => '04:00', 'jobs' => 1, 'tokens' => 45_000],
['hour' => '06:00', 'jobs' => 2, 'tokens' => 120_000],
['hour' => '08:00', 'jobs' => 3, 'tokens' => 195_000],
['hour' => '10:00', 'jobs' => 2, 'tokens' => 280_000],
['hour' => '12:00', 'jobs' => 1, 'tokens' => 340_000],
['hour' => '14:00', 'jobs' => 3, 'tokens' => 450_000],
];
$this->costBreakdown = [
['model' => 'claude-opus-4-6', 'cost' => 12.80, 'tokens' => 856_000],
['model' => 'claude-sonnet-4-5', 'cost' => 4.20, 'tokens' => 312_000],
['model' => 'claude-haiku-4-5', 'cost' => 1.42, 'tokens' => 77_800],
];
}
public function render()
{
return view('livewire.dashboard.metrics');
}
}

View file

@ -0,0 +1,43 @@
<?php
declare(strict_types=1);
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
class AgentAllowance extends Model
{
protected $fillable = [
'agent_id',
'daily_token_limit',
'daily_job_limit',
'concurrent_jobs',
'max_job_duration_minutes',
'model_allowlist',
];
protected function casts(): array
{
return [
'daily_token_limit' => 'integer',
'daily_job_limit' => 'integer',
'concurrent_jobs' => 'integer',
'max_job_duration_minutes' => 'integer',
'model_allowlist' => 'array',
];
}
public function usageRecords(): HasMany
{
return $this->hasMany(QuotaUsage::class, 'agent_id', 'agent_id');
}
public function todayUsage(): ?QuotaUsage
{
return $this->usageRecords()
->where('period_date', now()->toDateString())
->first();
}
}

View file

@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class ModelQuota extends Model
{
protected $fillable = [
'model',
'daily_token_budget',
'hourly_rate_limit',
'cost_ceiling',
];
protected function casts(): array
{
return [
'daily_token_budget' => 'integer',
'hourly_rate_limit' => 'integer',
'cost_ceiling' => 'integer',
];
}
}

View file

@ -0,0 +1,36 @@
<?php
declare(strict_types=1);
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class QuotaUsage extends Model
{
protected $table = 'quota_usage';
protected $fillable = [
'agent_id',
'tokens_used',
'jobs_started',
'active_jobs',
'period_date',
];
protected function casts(): array
{
return [
'tokens_used' => 'integer',
'jobs_started' => 'integer',
'active_jobs' => 'integer',
'period_date' => 'date',
];
}
public function allowance(): BelongsTo
{
return $this->belongsTo(AgentAllowance::class, 'agent_id', 'agent_id');
}
}

View file

@ -0,0 +1,29 @@
<?php
declare(strict_types=1);
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class UsageReport extends Model
{
protected $fillable = [
'agent_id',
'job_id',
'model',
'tokens_in',
'tokens_out',
'event',
'reported_at',
];
protected function casts(): array
{
return [
'tokens_in' => 'integer',
'tokens_out' => 'integer',
'reported_at' => 'datetime',
];
}
}

View file

@ -4,12 +4,29 @@ declare(strict_types=1);
namespace App\Providers;
use App\Services\Forgejo\ForgejoService;
use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\ServiceProvider;
use Throwable;
class AppServiceProvider extends ServiceProvider
{
public function register(): void
{
$this->app->singleton(ForgejoService::class, function ($app): ForgejoService {
/** @var array<string, mixed> $config */
$config = $app['config']->get('forgejo', []);
return new ForgejoService(
instances: $config['instances'] ?? [],
defaultInstance: $config['default'] ?? 'forge',
timeout: $config['timeout'] ?? 30,
retryTimes: $config['retry_times'] ?? 3,
retrySleep: $config['retry_sleep'] ?? 500,
);
});
}
public function boot(): void
{
// Auto-migrate on first boot. Single-user desktop app with

View file

@ -0,0 +1,183 @@
<?php
declare(strict_types=1);
namespace App\Services;
use App\Models\AgentAllowance;
use App\Models\ModelQuota;
use App\Models\QuotaUsage;
use App\Models\UsageReport;
class AllowanceService
{
/**
* Pre-dispatch check: verify agent has remaining allowance.
*
* @return array{allowed: bool, status: string, remaining_tokens: int, remaining_jobs: int, reason: ?string}
*/
public function check(string $agentId, string $model = ''): array
{
$allowance = AgentAllowance::where('agent_id', $agentId)->first();
if (! $allowance) {
return [
'allowed' => false,
'status' => 'exceeded',
'remaining_tokens' => 0,
'remaining_jobs' => 0,
'reason' => 'no allowance configured for agent',
];
}
$usage = QuotaUsage::firstOrCreate(
['agent_id' => $agentId, 'period_date' => now()->toDateString()],
['tokens_used' => 0, 'jobs_started' => 0, 'active_jobs' => 0],
);
$result = [
'allowed' => true,
'status' => 'ok',
'remaining_tokens' => -1,
'remaining_jobs' => -1,
'reason' => null,
];
// Check model allowlist
if ($model !== '' && ! empty($allowance->model_allowlist)) {
if (! in_array($model, $allowance->model_allowlist, true)) {
return array_merge($result, [
'allowed' => false,
'status' => 'exceeded',
'reason' => "model not in allowlist: {$model}",
]);
}
}
// Check daily token limit
if ($allowance->daily_token_limit > 0) {
$remaining = $allowance->daily_token_limit - $usage->tokens_used;
$result['remaining_tokens'] = $remaining;
if ($remaining <= 0) {
return array_merge($result, [
'allowed' => false,
'status' => 'exceeded',
'reason' => 'daily token limit exceeded',
]);
}
$ratio = $usage->tokens_used / $allowance->daily_token_limit;
if ($ratio >= 0.8) {
$result['status'] = 'warning';
}
}
// Check daily job limit
if ($allowance->daily_job_limit > 0) {
$remaining = $allowance->daily_job_limit - $usage->jobs_started;
$result['remaining_jobs'] = $remaining;
if ($remaining <= 0) {
return array_merge($result, [
'allowed' => false,
'status' => 'exceeded',
'reason' => 'daily job limit exceeded',
]);
}
}
// Check concurrent jobs
if ($allowance->concurrent_jobs > 0 && $usage->active_jobs >= $allowance->concurrent_jobs) {
return array_merge($result, [
'allowed' => false,
'status' => 'exceeded',
'reason' => 'concurrent job limit reached',
]);
}
// Check global model quota
if ($model !== '') {
$modelQuota = ModelQuota::where('model', $model)->first();
if ($modelQuota && $modelQuota->daily_token_budget > 0) {
$modelUsage = UsageReport::where('model', $model)
->whereDate('reported_at', now()->toDateString())
->sum(\DB::raw('tokens_in + tokens_out'));
if ($modelUsage >= $modelQuota->daily_token_budget) {
return array_merge($result, [
'allowed' => false,
'status' => 'exceeded',
'reason' => "global model token budget exceeded for: {$model}",
]);
}
}
}
return $result;
}
/**
* Record usage from an agent runner report.
*/
public function recordUsage(array $report): void
{
$agentId = $report['agent_id'];
$totalTokens = ($report['tokens_in'] ?? 0) + ($report['tokens_out'] ?? 0);
$usage = QuotaUsage::firstOrCreate(
['agent_id' => $agentId, 'period_date' => now()->toDateString()],
['tokens_used' => 0, 'jobs_started' => 0, 'active_jobs' => 0],
);
// Persist the raw report
UsageReport::create([
'agent_id' => $report['agent_id'],
'job_id' => $report['job_id'],
'model' => $report['model'] ?? null,
'tokens_in' => $report['tokens_in'] ?? 0,
'tokens_out' => $report['tokens_out'] ?? 0,
'event' => $report['event'],
'reported_at' => $report['timestamp'] ?? now(),
]);
match ($report['event']) {
'job_started' => $usage->increment('jobs_started') || $usage->increment('active_jobs'),
'job_completed' => $this->handleCompleted($usage, $totalTokens),
'job_failed' => $this->handleFailed($usage, $totalTokens),
'job_cancelled' => $this->handleCancelled($usage, $totalTokens),
default => null,
};
}
/**
* Reset daily usage counters for an agent.
*/
public function resetAgent(string $agentId): void
{
QuotaUsage::updateOrCreate(
['agent_id' => $agentId, 'period_date' => now()->toDateString()],
['tokens_used' => 0, 'jobs_started' => 0, 'active_jobs' => 0],
);
}
private function handleCompleted(QuotaUsage $usage, int $totalTokens): void
{
$usage->increment('tokens_used', $totalTokens);
$usage->decrement('active_jobs');
}
private function handleFailed(QuotaUsage $usage, int $totalTokens): void
{
$returnAmount = intdiv($totalTokens, 2);
$usage->increment('tokens_used', $totalTokens - $returnAmount);
$usage->decrement('active_jobs');
}
private function handleCancelled(QuotaUsage $usage, int $totalTokens): void
{
$usage->decrement('active_jobs');
// 100% returned — no token charge
}
}

View file

@ -0,0 +1,155 @@
<?php
declare(strict_types=1);
namespace App\Services\Forgejo;
use Illuminate\Http\Client\PendingRequest;
use Illuminate\Http\Client\Response;
use Illuminate\Support\Facades\Http;
use RuntimeException;
/**
* Low-level HTTP client for a single Forgejo instance.
*
* Wraps the Laravel HTTP client with token auth, retry, and
* base-URL scoping so callers never deal with raw HTTP details.
*/
class ForgejoClient
{
private PendingRequest $http;
public function __construct(
private readonly string $baseUrl,
private readonly string $token,
int $timeout = 30,
int $retryTimes = 3,
int $retrySleep = 500,
) {
if ($this->token === '') {
throw new RuntimeException("Forgejo API token is required for {$this->baseUrl}");
}
$this->http = Http::baseUrl(rtrim($this->baseUrl, '/') . '/api/v1')
->withHeaders([
'Authorization' => "token {$this->token}",
'Accept' => 'application/json',
'Content-Type' => 'application/json',
])
->timeout($timeout)
->retry($retryTimes, $retrySleep, fn (?\Throwable $e, PendingRequest $req): bool =>
$e instanceof \Illuminate\Http\Client\ConnectionException
);
}
public function baseUrl(): string
{
return $this->baseUrl;
}
// ----- Generic verbs -----
/** @return array<string, mixed> */
public function get(string $path, array $query = []): array
{
return $this->decodeOrFail($this->http->get($path, $query));
}
/** @return array<string, mixed> */
public function post(string $path, array $data = []): array
{
return $this->decodeOrFail($this->http->post($path, $data));
}
/** @return array<string, mixed> */
public function patch(string $path, array $data = []): array
{
return $this->decodeOrFail($this->http->patch($path, $data));
}
/** @return array<string, mixed> */
public function put(string $path, array $data = []): array
{
return $this->decodeOrFail($this->http->put($path, $data));
}
public function delete(string $path): void
{
$response = $this->http->delete($path);
if ($response->failed()) {
throw new RuntimeException(
"Forgejo DELETE {$path} failed [{$response->status()}]: {$response->body()}"
);
}
}
/**
* GET a path and return the raw response body as a string.
* Useful for endpoints that return non-JSON content (e.g. diffs).
*/
public function getRaw(string $path, array $query = []): string
{
$response = $this->http->get($path, $query);
if ($response->failed()) {
throw new RuntimeException(
"Forgejo GET {$path} failed [{$response->status()}]: {$response->body()}"
);
}
return $response->body();
}
/**
* Paginate through all pages of a list endpoint.
*
* @return list<array<string, mixed>>
*/
public function paginate(string $path, array $query = [], int $limit = 50): array
{
$all = [];
$page = 1;
do {
$response = $this->http->get($path, array_merge($query, [
'page' => $page,
'limit' => $limit,
]));
if ($response->failed()) {
throw new RuntimeException(
"Forgejo GET {$path} page {$page} failed [{$response->status()}]: {$response->body()}"
);
}
$items = $response->json();
if (!is_array($items) || $items === []) {
break;
}
array_push($all, ...$items);
// Forgejo returns total count in x-total-count header.
$total = (int) $response->header('x-total-count');
$page++;
} while (count($all) < $total);
return $all;
}
// ----- Internals -----
/** @return array<string, mixed> */
private function decodeOrFail(Response $response): array
{
if ($response->failed()) {
throw new RuntimeException(
"Forgejo API error [{$response->status()}]: {$response->body()}"
);
}
return $response->json() ?? [];
}
}

View file

@ -0,0 +1,302 @@
<?php
declare(strict_types=1);
namespace App\Services\Forgejo;
use RuntimeException;
/**
* Business-logic layer for Forgejo operations.
*
* Manages multiple Forgejo instances (forge, dev, qa) and provides
* a unified API for issues, pull requests, repositories, and user
* management. Mirrors the Go pkg/forge API surface.
*/
class ForgejoService
{
/** @var array<string, ForgejoClient> */
private array $clients = [];
private string $defaultInstance;
/**
* @param array<string, array{url: string, token: string}> $instances
*/
public function __construct(
array $instances,
string $defaultInstance = 'forge',
private readonly int $timeout = 30,
private readonly int $retryTimes = 3,
private readonly int $retrySleep = 500,
) {
$this->defaultInstance = $defaultInstance;
foreach ($instances as $name => $cfg) {
if (($cfg['token'] ?? '') === '') {
continue; // skip unconfigured instances
}
$this->clients[$name] = new ForgejoClient(
baseUrl: $cfg['url'],
token: $cfg['token'],
timeout: $this->timeout,
retryTimes: $this->retryTimes,
retrySleep: $this->retrySleep,
);
}
}
// ----------------------------------------------------------------
// Instance resolution
// ----------------------------------------------------------------
public function client(?string $instance = null): ForgejoClient
{
$name = $instance ?? $this->defaultInstance;
return $this->clients[$name]
?? throw new RuntimeException("Forgejo instance '{$name}' is not configured or has no token");
}
/** @return list<string> */
public function instances(): array
{
return array_keys($this->clients);
}
// ----------------------------------------------------------------
// Issue Operations
// ----------------------------------------------------------------
/** @return array<string, mixed> */
public function createIssue(
string $owner,
string $repo,
string $title,
string $body = '',
array $labels = [],
string $assignee = '',
?string $instance = null,
): array {
$data = ['title' => $title, 'body' => $body];
if ($labels !== []) {
$data['labels'] = $labels;
}
if ($assignee !== '') {
$data['assignees'] = [$assignee];
}
return $this->client($instance)->post("/repos/{$owner}/{$repo}/issues", $data);
}
/** @return array<string, mixed> */
public function updateIssue(
string $owner,
string $repo,
int $number,
array $fields,
?string $instance = null,
): array {
return $this->client($instance)->patch("/repos/{$owner}/{$repo}/issues/{$number}", $fields);
}
public function closeIssue(string $owner, string $repo, int $number, ?string $instance = null): array
{
return $this->updateIssue($owner, $repo, $number, ['state' => 'closed'], $instance);
}
/** @return array<string, mixed> */
public function addComment(
string $owner,
string $repo,
int $number,
string $body,
?string $instance = null,
): array {
return $this->client($instance)->post(
"/repos/{$owner}/{$repo}/issues/{$number}/comments",
['body' => $body],
);
}
/**
* @return list<array<string, mixed>>
*/
public function listIssues(
string $owner,
string $repo,
string $state = 'open',
int $page = 1,
int $limit = 50,
?string $instance = null,
): array {
return $this->client($instance)->get("/repos/{$owner}/{$repo}/issues", [
'state' => $state,
'type' => 'issues',
'page' => $page,
'limit' => $limit,
]);
}
// ----------------------------------------------------------------
// Pull Request Operations
// ----------------------------------------------------------------
/** @return array<string, mixed> */
public function createPR(
string $owner,
string $repo,
string $head,
string $base,
string $title,
string $body = '',
?string $instance = null,
): array {
return $this->client($instance)->post("/repos/{$owner}/{$repo}/pulls", [
'head' => $head,
'base' => $base,
'title' => $title,
'body' => $body,
]);
}
public function mergePR(
string $owner,
string $repo,
int $number,
string $strategy = 'merge',
?string $instance = null,
): void {
$this->client($instance)->post("/repos/{$owner}/{$repo}/pulls/{$number}/merge", [
'Do' => $strategy,
'delete_branch_after_merge' => true,
]);
}
/**
* @return list<array<string, mixed>>
*/
public function listPRs(
string $owner,
string $repo,
string $state = 'open',
?string $instance = null,
): array {
return $this->client($instance)->paginate("/repos/{$owner}/{$repo}/pulls", [
'state' => $state,
]);
}
public function getPRDiff(string $owner, string $repo, int $number, ?string $instance = null): string
{
return $this->client($instance)->getRaw("/repos/{$owner}/{$repo}/pulls/{$number}.diff");
}
// ----------------------------------------------------------------
// Repository Operations
// ----------------------------------------------------------------
/**
* @return list<array<string, mixed>>
*/
public function listRepos(string $org, ?string $instance = null): array
{
return $this->client($instance)->paginate("/orgs/{$org}/repos");
}
/** @return array<string, mixed> */
public function getRepo(string $owner, string $name, ?string $instance = null): array
{
return $this->client($instance)->get("/repos/{$owner}/{$name}");
}
/** @return array<string, mixed> */
public function createBranch(
string $owner,
string $repo,
string $name,
string $from = '',
?string $instance = null,
): array {
$data = ['new_branch_name' => $name];
if ($from !== '') {
$data['old_branch_name'] = $from;
}
return $this->client($instance)->post("/repos/{$owner}/{$repo}/branches", $data);
}
public function deleteBranch(
string $owner,
string $repo,
string $name,
?string $instance = null,
): void {
$this->client($instance)->delete("/repos/{$owner}/{$repo}/branches/{$name}");
}
// ----------------------------------------------------------------
// User / Token Management
// ----------------------------------------------------------------
/** @return array<string, mixed> */
public function createUser(
string $username,
string $email,
string $password,
?string $instance = null,
): array {
return $this->client($instance)->post('/admin/users', [
'username' => $username,
'email' => $email,
'password' => $password,
'must_change_password' => false,
]);
}
/** @return array<string, mixed> */
public function createToken(
string $username,
string $name,
array $scopes = [],
?string $instance = null,
): array {
$data = ['name' => $name];
if ($scopes !== []) {
$data['scopes'] = $scopes;
}
return $this->client($instance)->post("/users/{$username}/tokens", $data);
}
public function revokeToken(string $username, int $tokenId, ?string $instance = null): void
{
$this->client($instance)->delete("/users/{$username}/tokens/{$tokenId}");
}
/** @return array<string, mixed> */
public function addToOrg(
string $username,
string $org,
int $teamId,
?string $instance = null,
): array {
return $this->client($instance)->put("/teams/{$teamId}/members/{$username}");
}
// ----------------------------------------------------------------
// Org Operations
// ----------------------------------------------------------------
/**
* @return list<array<string, mixed>>
*/
public function listOrgs(?string $instance = null): array
{
return $this->client($instance)->paginate('/user/orgs');
}
}

View file

@ -9,6 +9,7 @@ use Illuminate\Foundation\Configuration\Middleware;
return Application::configure(basePath: dirname(__DIR__))
->withRouting(
web: __DIR__.'/../routes/web.php',
api: __DIR__.'/../routes/api.php',
commands: __DIR__.'/../routes/console.php',
)
->withMiddleware(function (Middleware $middleware) {

View file

@ -0,0 +1,51 @@
<?php
declare(strict_types=1);
return [
/*
|--------------------------------------------------------------------------
| Default Forgejo Instance
|--------------------------------------------------------------------------
|
| The instance name to use when no explicit instance is specified.
|
*/
'default' => env('FORGEJO_DEFAULT', 'forge'),
/*
|--------------------------------------------------------------------------
| Forgejo Instances
|--------------------------------------------------------------------------
|
| Each entry defines a Forgejo instance the platform can talk to.
| The service auto-routes by matching the configured URL.
|
| url Base URL of the Forgejo instance (no trailing slash)
| token Admin API token for the instance
|
*/
'instances' => [
'forge' => [
'url' => env('FORGEJO_FORGE_URL', 'https://forge.lthn.ai'),
'token' => env('FORGEJO_FORGE_TOKEN', ''),
],
'dev' => [
'url' => env('FORGEJO_DEV_URL', 'https://dev.lthn.ai'),
'token' => env('FORGEJO_DEV_TOKEN', ''),
],
'qa' => [
'url' => env('FORGEJO_QA_URL', 'https://qa.lthn.ai'),
'token' => env('FORGEJO_QA_TOKEN', ''),
],
],
/*
|--------------------------------------------------------------------------
| HTTP Client Settings
|--------------------------------------------------------------------------
*/
'timeout' => (int) env('FORGEJO_TIMEOUT', 30),
'retry_times' => (int) env('FORGEJO_RETRY_TIMES', 3),
'retry_sleep' => (int) env('FORGEJO_RETRY_SLEEP', 500),
];

View file

@ -0,0 +1,75 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('agent_allowances', function (Blueprint $table) {
$table->id();
$table->string('agent_id')->unique();
$table->bigInteger('daily_token_limit')->default(0);
$table->integer('daily_job_limit')->default(0);
$table->integer('concurrent_jobs')->default(1);
$table->integer('max_job_duration_minutes')->default(0);
$table->json('model_allowlist')->nullable();
$table->timestamps();
});
Schema::create('quota_usage', function (Blueprint $table) {
$table->id();
$table->string('agent_id')->index();
$table->bigInteger('tokens_used')->default(0);
$table->integer('jobs_started')->default(0);
$table->integer('active_jobs')->default(0);
$table->date('period_date')->index();
$table->timestamps();
$table->unique(['agent_id', 'period_date']);
});
Schema::create('model_quotas', function (Blueprint $table) {
$table->id();
$table->string('model')->unique();
$table->bigInteger('daily_token_budget')->default(0);
$table->integer('hourly_rate_limit')->default(0);
$table->bigInteger('cost_ceiling')->default(0);
$table->timestamps();
});
Schema::create('usage_reports', function (Blueprint $table) {
$table->id();
$table->string('agent_id')->index();
$table->string('job_id')->index();
$table->string('model')->nullable();
$table->bigInteger('tokens_in')->default(0);
$table->bigInteger('tokens_out')->default(0);
$table->string('event');
$table->timestamp('reported_at');
$table->timestamps();
});
Schema::create('repo_limits', function (Blueprint $table) {
$table->id();
$table->string('repo')->unique();
$table->integer('max_daily_prs')->default(0);
$table->integer('max_daily_issues')->default(0);
$table->integer('cooldown_after_failure_minutes')->default(0);
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('repo_limits');
Schema::dropIfExists('usage_reports');
Schema::dropIfExists('model_quotas');
Schema::dropIfExists('quota_usage');
Schema::dropIfExists('agent_allowances');
}
};

View file

@ -0,0 +1,105 @@
<!DOCTYPE html>
<html lang="en" class="h-full">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{ $title ?? 'Agentic Dashboard' }} Core</title>
<script src="https://cdn.tailwindcss.com"></script>
<script>
tailwind.config = {
darkMode: 'class',
theme: {
extend: {
colors: {
surface: { DEFAULT: '#0d1117', raised: '#161b22', overlay: '#21262d' },
border: { DEFAULT: '#30363d', subtle: '#21262d' },
accent: { DEFAULT: '#39d0d8', dim: '#1b6b6f' },
success: '#238636',
warning: '#d29922',
danger: '#da3633',
muted: '#8b949e',
},
},
},
}
</script>
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/apexcharts"></script>
<style>
[x-cloak] { display: none !important; }
@keyframes pulse-dot { 0%, 100% { opacity: 1; } 50% { opacity: .4; } }
.heartbeat { animation: pulse-dot 2s ease-in-out infinite; }
.scrollbar-thin::-webkit-scrollbar { width: 6px; }
.scrollbar-thin::-webkit-scrollbar-track { background: transparent; }
.scrollbar-thin::-webkit-scrollbar-thumb { background: #30363d; border-radius: 3px; }
</style>
@livewireStyles
</head>
<body class="h-full bg-surface text-gray-200 antialiased">
<div class="flex h-full" x-data="{ sidebarOpen: true }">
{{-- Sidebar --}}
<aside class="flex flex-col w-56 border-r border-border bg-surface-raised shrink-0 transition-all"
:class="sidebarOpen ? 'w-56' : 'w-16'">
<div class="flex items-center gap-2 px-4 h-14 border-b border-border">
<svg class="w-6 h-6 text-accent shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z"/>
</svg>
<span class="font-semibold text-sm tracking-wide" x-show="sidebarOpen" x-cloak>Agentic</span>
</div>
<nav class="flex-1 py-2 space-y-0.5 px-2">
<a href="{{ route('dashboard') }}"
class="flex items-center gap-3 px-3 py-2 text-sm rounded-md {{ request()->routeIs('dashboard') ? 'bg-surface-overlay text-white' : 'text-muted hover:bg-surface-overlay hover:text-white' }} transition">
<svg class="w-4 h-4 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2V6zm10 0a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2V6zM4 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2v-2zm10 0a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2v-2z"/></svg>
<span x-show="sidebarOpen">Dashboard</span>
</a>
<a href="{{ route('dashboard.agents') }}"
class="flex items-center gap-3 px-3 py-2 text-sm rounded-md {{ request()->routeIs('dashboard.agents') ? 'bg-surface-overlay text-white' : 'text-muted hover:bg-surface-overlay hover:text-white' }} transition">
<svg class="w-4 h-4 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0z"/></svg>
<span x-show="sidebarOpen">Agent Fleet</span>
</a>
<a href="{{ route('dashboard.jobs') }}"
class="flex items-center gap-3 px-3 py-2 text-sm rounded-md {{ request()->routeIs('dashboard.jobs') ? 'bg-surface-overlay text-white' : 'text-muted hover:bg-surface-overlay hover:text-white' }} transition">
<svg class="w-4 h-4 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5H7a2 2 0 00-2 2v10a2 2 0 002 2h8a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2"/></svg>
<span x-show="sidebarOpen">Job Queue</span>
</a>
<a href="{{ route('dashboard.activity') }}"
class="flex items-center gap-3 px-3 py-2 text-sm rounded-md {{ request()->routeIs('dashboard.activity') ? 'bg-surface-overlay text-white' : 'text-muted hover:bg-surface-overlay hover:text-white' }} transition">
<svg class="w-4 h-4 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 7h8m0 0v8m0-8l-8 8-4-4-6 6"/></svg>
<span x-show="sidebarOpen">Activity</span>
</a>
</nav>
<div class="border-t border-border p-2">
<button @click="sidebarOpen = !sidebarOpen"
class="flex items-center justify-center w-full px-3 py-2 text-muted hover:text-white rounded-md hover:bg-surface-overlay transition">
<svg class="w-4 h-4 transition-transform" :class="sidebarOpen ? '' : 'rotate-180'" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 19l-7-7 7-7m8 14l-7-7 7-7"/></svg>
</button>
</div>
</aside>
{{-- Main content --}}
<main class="flex-1 overflow-auto">
<header class="sticky top-0 z-10 flex items-center justify-between h-14 px-6 border-b border-border bg-surface/80 backdrop-blur">
<h1 class="text-sm font-semibold">{{ $title ?? 'Dashboard' }}</h1>
<div class="flex items-center gap-4">
<div class="flex items-center gap-2 text-xs text-muted"
x-data="{ connected: true }"
x-init="
setInterval(() => {
connected = navigator.onLine;
}, 3000)
">
<span class="w-2 h-2 rounded-full heartbeat"
:class="connected ? 'bg-green-500' : 'bg-red-500'"></span>
<span x-text="connected ? 'Connected' : 'Disconnected'"></span>
</div>
<span class="text-xs text-muted font-mono">{{ now()->format('H:i') }}</span>
</div>
</header>
<div class="p-6">
{{ $slot }}
</div>
</main>
</div>
@livewireScripts
</body>
</html>

View file

@ -0,0 +1,3 @@
<x-dashboard-layout title="Live Activity">
<livewire:dashboard.activity-feed />
</x-dashboard-layout>

View file

@ -0,0 +1,3 @@
<x-dashboard-layout title="Agent Fleet">
<livewire:dashboard.agent-fleet />
</x-dashboard-layout>

View file

@ -0,0 +1,34 @@
<x-dashboard-layout title="Dashboard">
{{-- Metrics overview at top --}}
<section class="mb-8">
<livewire:dashboard.metrics />
</section>
<div class="grid grid-cols-1 xl:grid-cols-3 gap-6">
{{-- Left column: Agent fleet + Human actions --}}
<div class="xl:col-span-2 space-y-6">
<section>
<h2 class="text-sm font-semibold text-muted uppercase tracking-wider mb-3">Agent Fleet</h2>
<livewire:dashboard.agent-fleet />
</section>
<section>
<h2 class="text-sm font-semibold text-muted uppercase tracking-wider mb-3">Job Queue</h2>
<livewire:dashboard.job-queue />
</section>
</div>
{{-- Right column: Actions + Activity --}}
<div class="space-y-6">
<section>
<h2 class="text-sm font-semibold text-muted uppercase tracking-wider mb-3">Human Actions</h2>
<livewire:dashboard.human-actions />
</section>
<section>
<h2 class="text-sm font-semibold text-muted uppercase tracking-wider mb-3">Live Activity</h2>
<livewire:dashboard.activity-feed />
</section>
</div>
</div>
</x-dashboard-layout>

View file

@ -0,0 +1,3 @@
<x-dashboard-layout title="Job Queue">
<livewire:dashboard.job-queue />
</x-dashboard-layout>

View file

@ -0,0 +1,72 @@
<div wire:poll.3s="loadEntries">
{{-- Filters --}}
<div class="flex flex-wrap items-center gap-3 mb-4">
<select wire:model.live="agentFilter"
class="bg-surface-overlay border border-border rounded-md px-3 py-1.5 text-xs text-gray-300 focus:border-accent focus:outline-none">
<option value="all">All agents</option>
<option value="Athena">Athena</option>
<option value="Virgil">Virgil</option>
<option value="Clotho">Clotho</option>
<option value="Charon">Charon</option>
</select>
<select wire:model.live="typeFilter"
class="bg-surface-overlay border border-border rounded-md px-3 py-1.5 text-xs text-gray-300 focus:border-accent focus:outline-none">
<option value="all">All types</option>
<option value="code_write">Code write</option>
<option value="tool_call">Tool call</option>
<option value="test_run">Test run</option>
<option value="pr_created">PR created</option>
<option value="git_push">Git push</option>
<option value="question">Question</option>
</select>
<label class="flex items-center gap-2 text-xs text-muted cursor-pointer">
<input type="checkbox" wire:model.live="showOnlyQuestions"
class="rounded border-border bg-surface-overlay text-accent focus:ring-accent">
Waiting for answer only
</label>
</div>
{{-- Feed --}}
<div class="space-y-2 max-h-[600px] overflow-y-auto scrollbar-thin">
@forelse ($this->filteredEntries as $entry)
<div class="bg-surface-raised border rounded-lg px-4 py-3 transition
{{ $entry['is_question'] ? 'border-yellow-500/50 bg-yellow-500/5' : 'border-border' }}">
<div class="flex items-start gap-3">
{{-- Type icon --}}
@php
$typeIcons = [
'code_write' => '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 20l4-16m4 4l4 4-4 4M6 16l-4-4 4-4"/>',
'tool_call' => '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.066 2.573c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.573 1.066c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.066-2.573c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"/><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/>',
'test_run' => '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/>',
'pr_created' => '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7h12m0 0l-4-4m4 4l-4 4m0 6H4m0 0l4 4m-4-4l4-4"/>',
'git_push' => '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 16V4m0 0L3 8m4-4l4 4m6 0v12m0 0l4-4m-4 4l-4-4"/>',
'question' => '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8.228 9c.549-1.165 2.03-2 3.772-2 2.21 0 4 1.343 4 3 0 1.4-1.278 2.575-3.006 2.907-.542.104-.994.54-.994 1.093m0 3h.01"/>',
];
$iconPath = $typeIcons[$entry['type']] ?? $typeIcons['tool_call'];
$iconColor = $entry['is_question'] ? 'text-yellow-400' : 'text-muted';
@endphp
<svg class="w-4 h-4 mt-0.5 shrink-0 {{ $iconColor }}" fill="none" viewBox="0 0 24 24" stroke="currentColor">{!! $iconPath !!}</svg>
{{-- Content --}}
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2 mb-0.5">
<span class="text-xs font-semibold text-gray-300">{{ $entry['agent'] }}</span>
<span class="text-[10px] text-muted font-mono">{{ $entry['job'] }}</span>
@if ($entry['is_question'])
<span class="text-[10px] px-1.5 py-0.5 rounded bg-yellow-500/20 text-yellow-400 font-medium">NEEDS ANSWER</span>
@endif
</div>
<p class="text-xs text-gray-400 leading-relaxed">{{ $entry['message'] }}</p>
</div>
{{-- Timestamp --}}
<span class="text-[11px] text-muted shrink-0">
{{ \Carbon\Carbon::parse($entry['timestamp'])->diffForHumans(short: true) }}
</span>
</div>
</div>
@empty
<div class="text-center py-8 text-muted text-sm">No activity matching filters.</div>
@endforelse
</div>
</div>

View file

@ -0,0 +1,58 @@
<div wire:poll.5s="loadAgents">
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
@foreach ($agents as $agent)
<div wire:click="selectAgent('{{ $agent['id'] }}')"
class="bg-surface-raised border rounded-lg p-4 cursor-pointer transition hover:border-accent
{{ $selectedAgent === $agent['id'] ? 'border-accent' : 'border-border' }}">
{{-- Header --}}
<div class="flex items-center justify-between mb-3">
<div class="flex items-center gap-2">
<span class="w-2.5 h-2.5 rounded-full heartbeat
{{ $agent['heartbeat'] === 'green' ? 'bg-green-500' : ($agent['heartbeat'] === 'yellow' ? 'bg-yellow-500' : 'bg-red-500') }}"></span>
<span class="font-semibold text-sm">{{ $agent['name'] }}</span>
</div>
<span class="text-[10px] px-2 py-0.5 rounded-full font-medium uppercase tracking-wider
{{ $agent['status'] === 'working' ? 'bg-blue-500/20 text-blue-400' : ($agent['status'] === 'idle' ? 'bg-green-500/20 text-green-400' : 'bg-red-500/20 text-red-400') }}">
{{ $agent['status'] }}
</span>
</div>
{{-- Info --}}
<div class="space-y-1.5 text-xs text-muted">
<div class="flex justify-between">
<span>Host</span>
<span class="text-gray-300 font-mono">{{ $agent['host'] }}</span>
</div>
<div class="flex justify-between">
<span>Model</span>
<span class="text-gray-300 font-mono text-[11px]">{{ $agent['model'] }}</span>
</div>
<div class="flex justify-between">
<span>Uptime</span>
<span class="text-gray-300">{{ $agent['uptime'] }}</span>
</div>
@if ($agent['job'])
<div class="flex justify-between">
<span>Job</span>
<span class="text-accent text-[11px]">{{ $agent['job'] }}</span>
</div>
@endif
</div>
{{-- Expanded detail --}}
@if ($selectedAgent === $agent['id'])
<div class="mt-3 pt-3 border-t border-border space-y-1.5 text-xs text-muted">
<div class="flex justify-between">
<span>Tokens today</span>
<span class="text-gray-300">{{ number_format($agent['tokens_today']) }}</span>
</div>
<div class="flex justify-between">
<span>Jobs completed</span>
<span class="text-gray-300">{{ $agent['jobs_completed'] }}</span>
</div>
</div>
@endif
</div>
@endforeach
</div>
</div>

View file

@ -0,0 +1,92 @@
<div wire:poll.3s="loadPending">
{{-- Pending questions --}}
@if (count($pendingQuestions) > 0)
<div class="mb-6">
<h3 class="text-sm font-semibold mb-3 flex items-center gap-2">
<span class="w-2 h-2 rounded-full bg-yellow-500 heartbeat"></span>
Agent Questions ({{ count($pendingQuestions) }})
</h3>
<div class="space-y-3">
@foreach ($pendingQuestions as $q)
<div class="bg-yellow-500/5 border border-yellow-500/30 rounded-lg p-4">
<div class="flex items-center gap-2 mb-2">
<span class="text-xs font-semibold text-yellow-400">{{ $q['agent'] }}</span>
<span class="text-[10px] text-muted font-mono">{{ $q['job'] }}</span>
<span class="text-[10px] text-muted">{{ \Carbon\Carbon::parse($q['asked_at'])->diffForHumans(short: true) }}</span>
</div>
<p class="text-sm text-gray-300 mb-2">{{ $q['question'] }}</p>
@if (!empty($q['context']))
<p class="text-xs text-muted mb-3">{{ $q['context'] }}</p>
@endif
@if ($answeringId === $q['id'])
<div class="mt-3">
<textarea wire:model="answerText"
rows="3"
placeholder="Type your answer..."
class="w-full bg-surface-overlay border border-border rounded-md px-3 py-2 text-sm text-gray-300 placeholder-muted focus:border-accent focus:outline-none resize-none"></textarea>
<div class="flex gap-2 mt-2">
<button wire:click="submitAnswer"
class="px-3 py-1.5 text-xs font-medium rounded bg-accent text-surface hover:opacity-90 transition">
Send Answer
</button>
<button wire:click="cancelAnswer"
class="px-3 py-1.5 text-xs font-medium rounded bg-surface-overlay text-muted hover:text-white border border-border transition">
Cancel
</button>
</div>
</div>
@else
<button wire:click="startAnswer('{{ $q['id'] }}')"
class="px-3 py-1.5 text-xs font-medium rounded bg-yellow-500/20 text-yellow-400 hover:bg-yellow-500/30 transition">
Answer
</button>
@endif
</div>
@endforeach
</div>
</div>
@endif
{{-- Review gates --}}
@if (count($reviewGates) > 0)
<div>
<h3 class="text-sm font-semibold mb-3 flex items-center gap-2">
<span class="w-2 h-2 rounded-full bg-purple-500 heartbeat"></span>
Review Gates ({{ count($reviewGates) }})
</h3>
<div class="space-y-3">
@foreach ($reviewGates as $gate)
<div class="bg-surface-raised border border-purple-500/30 rounded-lg p-4">
<div class="flex items-center gap-2 mb-2">
<span class="text-xs font-semibold text-purple-400">{{ $gate['agent'] }}</span>
<span class="text-[10px] text-muted font-mono">{{ $gate['job'] }}</span>
<span class="text-[10px] px-1.5 py-0.5 rounded bg-purple-500/20 text-purple-400 font-medium uppercase">{{ str_replace('_', ' ', $gate['type']) }}</span>
</div>
<p class="text-sm font-medium text-gray-300 mb-1">{{ $gate['title'] }}</p>
<p class="text-xs text-muted mb-3">{{ $gate['description'] }}</p>
<div class="flex gap-2">
<button wire:click="approveGate('{{ $gate['id'] }}')"
class="px-3 py-1.5 text-xs font-medium rounded bg-green-500/20 text-green-400 hover:bg-green-500/30 transition">
Approve
</button>
<button wire:click="rejectGate('{{ $gate['id'] }}')"
class="px-3 py-1.5 text-xs font-medium rounded bg-red-500/20 text-red-400 hover:bg-red-500/30 transition">
Reject
</button>
</div>
</div>
@endforeach
</div>
</div>
@endif
@if (count($pendingQuestions) === 0 && count($reviewGates) === 0)
<div class="text-center py-12 text-muted">
<svg class="w-8 h-8 mx-auto mb-3 opacity-50" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg>
<p class="text-sm">No pending actions. All agents are autonomous.</p>
</div>
@endif
</div>

View file

@ -0,0 +1,98 @@
<div wire:poll.5s="loadJobs">
{{-- Filters --}}
<div class="flex flex-wrap gap-3 mb-4">
<select wire:model.live="statusFilter"
class="bg-surface-overlay border border-border rounded-md px-3 py-1.5 text-xs text-gray-300 focus:border-accent focus:outline-none">
<option value="all">All statuses</option>
<option value="queued">Queued</option>
<option value="in_progress">In Progress</option>
<option value="review">Review</option>
<option value="completed">Completed</option>
<option value="failed">Failed</option>
<option value="cancelled">Cancelled</option>
</select>
<select wire:model.live="agentFilter"
class="bg-surface-overlay border border-border rounded-md px-3 py-1.5 text-xs text-gray-300 focus:border-accent focus:outline-none">
<option value="all">All agents</option>
<option value="Athena">Athena</option>
<option value="Virgil">Virgil</option>
<option value="Clotho">Clotho</option>
<option value="Charon">Charon</option>
</select>
</div>
{{-- Table --}}
<div class="bg-surface-raised border border-border rounded-lg overflow-hidden">
<table class="w-full text-sm">
<thead>
<tr class="border-b border-border text-xs text-muted uppercase tracking-wider">
<th class="text-left px-4 py-3 font-medium">Job</th>
<th class="text-left px-4 py-3 font-medium">Issue</th>
<th class="text-left px-4 py-3 font-medium">Agent</th>
<th class="text-left px-4 py-3 font-medium">Status</th>
<th class="text-left px-4 py-3 font-medium">Priority</th>
<th class="text-left px-4 py-3 font-medium">Queued</th>
<th class="text-right px-4 py-3 font-medium">Actions</th>
</tr>
</thead>
<tbody class="divide-y divide-border">
@forelse ($this->filteredJobs as $job)
<tr class="hover:bg-surface-overlay/50 transition">
<td class="px-4 py-3">
<div class="font-mono text-xs text-muted">{{ $job['id'] }}</div>
<div class="text-xs text-gray-300 mt-0.5 truncate max-w-[200px]">{{ $job['title'] }}</div>
</td>
<td class="px-4 py-3">
<span class="text-accent font-mono text-xs">{{ $job['issue'] }}</span>
<div class="text-[11px] text-muted">{{ $job['repo'] }}</div>
</td>
<td class="px-4 py-3 text-xs">
{{ $job['agent'] ?? '—' }}
</td>
<td class="px-4 py-3">
@php
$statusColors = [
'queued' => 'bg-yellow-500/20 text-yellow-400',
'in_progress' => 'bg-blue-500/20 text-blue-400',
'review' => 'bg-purple-500/20 text-purple-400',
'completed' => 'bg-green-500/20 text-green-400',
'failed' => 'bg-red-500/20 text-red-400',
'cancelled' => 'bg-gray-500/20 text-gray-400',
];
@endphp
<span class="text-[10px] px-2 py-0.5 rounded-full font-medium uppercase tracking-wider {{ $statusColors[$job['status']] ?? '' }}">
{{ str_replace('_', ' ', $job['status']) }}
</span>
</td>
<td class="px-4 py-3">
<span class="text-xs font-mono text-muted">P{{ $job['priority'] }}</span>
</td>
<td class="px-4 py-3 text-xs text-muted">
{{ \Carbon\Carbon::parse($job['queued_at'])->diffForHumans(short: true) }}
</td>
<td class="px-4 py-3 text-right">
<div class="flex items-center justify-end gap-1">
@if (in_array($job['status'], ['queued', 'in_progress']))
<button wire:click="cancelJob('{{ $job['id'] }}')"
class="text-[11px] px-2 py-1 rounded bg-red-500/10 text-red-400 hover:bg-red-500/20 transition">
Cancel
</button>
@endif
@if (in_array($job['status'], ['failed', 'cancelled']))
<button wire:click="retryJob('{{ $job['id'] }}')"
class="text-[11px] px-2 py-1 rounded bg-blue-500/10 text-blue-400 hover:bg-blue-500/20 transition">
Retry
</button>
@endif
</div>
</td>
</tr>
@empty
<tr>
<td colspan="7" class="px-4 py-8 text-center text-muted text-sm">No jobs match the selected filters.</td>
</tr>
@endforelse
</tbody>
</table>
</div>
</div>

View file

@ -0,0 +1,113 @@
<div wire:poll.10s="loadMetrics">
{{-- Stat cards --}}
<div class="grid grid-cols-2 lg:grid-cols-3 xl:grid-cols-6 gap-4 mb-6">
@php
$statCards = [
['label' => 'Jobs Completed', 'value' => $stats['jobs_completed'], 'icon' => 'M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z', 'color' => 'text-green-400'],
['label' => 'PRs Merged', 'value' => $stats['prs_merged'], 'icon' => 'M8 7h12m0 0l-4-4m4 4l-4 4m0 6H4m0 0l4 4m-4-4l4-4', 'color' => 'text-purple-400'],
['label' => 'Tokens Used', 'value' => number_format($stats['tokens_used']), 'icon' => 'M7 8h10M7 12h4m1 8l-4-4H5a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v8a2 2 0 01-2 2h-3l-4 4z', 'color' => 'text-blue-400'],
['label' => 'Cost Today', 'value' => '$' . number_format($stats['cost_today'], 2), 'icon' => 'M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1M21 12a9 9 0 11-18 0 9 9 0 0118 0z', 'color' => 'text-yellow-400'],
['label' => 'Active Agents', 'value' => $stats['active_agents'], 'icon' => 'M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0z', 'color' => 'text-accent'],
['label' => 'Queue Depth', 'value' => $stats['queue_depth'], 'icon' => 'M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10', 'color' => 'text-orange-400'],
];
@endphp
@foreach ($statCards as $card)
<div class="bg-surface-raised border border-border rounded-lg p-4">
<div class="flex items-center gap-2 mb-2">
<svg class="w-4 h-4 {{ $card['color'] }}" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="{{ $card['icon'] }}"/>
</svg>
<span class="text-[11px] text-muted uppercase tracking-wider">{{ $card['label'] }}</span>
</div>
<div class="text-xl font-bold font-mono {{ $card['color'] }}">{{ $card['value'] }}</div>
</div>
@endforeach
</div>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
{{-- Budget gauge --}}
<div class="bg-surface-raised border border-border rounded-lg p-5">
<h3 class="text-sm font-semibold mb-4">Budget</h3>
<div class="flex items-end gap-3 mb-3">
<span class="text-3xl font-bold font-mono text-accent">${{ number_format($budgetUsed, 2) }}</span>
<span class="text-sm text-muted mb-1">/ ${{ number_format($budgetLimit, 2) }}</span>
</div>
@php
$pct = $budgetLimit > 0 ? min(100, ($budgetUsed / $budgetLimit) * 100) : 0;
$barColor = $pct > 80 ? 'bg-red-500' : ($pct > 60 ? 'bg-yellow-500' : 'bg-accent');
@endphp
<div class="w-full h-3 bg-surface-overlay rounded-full overflow-hidden">
<div class="{{ $barColor }} h-full rounded-full transition-all duration-500" style="width: {{ $pct }}%"></div>
</div>
<div class="text-xs text-muted mt-2">{{ number_format($pct, 0) }}% of daily budget used</div>
</div>
{{-- Cost breakdown by model --}}
<div class="bg-surface-raised border border-border rounded-lg p-5">
<h3 class="text-sm font-semibold mb-4">Cost by Model</h3>
<div class="space-y-3">
@foreach ($costBreakdown as $model)
@php
$modelPct = $budgetUsed > 0 ? ($model['cost'] / $budgetUsed) * 100 : 0;
$modelColors = [
'claude-opus-4-6' => 'bg-purple-500',
'claude-sonnet-4-5' => 'bg-blue-500',
'claude-haiku-4-5' => 'bg-green-500',
];
$barCol = $modelColors[$model['model']] ?? 'bg-gray-500';
@endphp
<div>
<div class="flex items-center justify-between text-xs mb-1">
<span class="font-mono text-gray-300">{{ $model['model'] }}</span>
<span class="text-muted">${{ number_format($model['cost'], 2) }} ({{ number_format($model['tokens']) }} tokens)</span>
</div>
<div class="w-full h-2 bg-surface-overlay rounded-full overflow-hidden">
<div class="{{ $barCol }} h-full rounded-full transition-all duration-500" style="width: {{ $modelPct }}%"></div>
</div>
</div>
@endforeach
</div>
</div>
</div>
{{-- Throughput chart --}}
<div class="bg-surface-raised border border-border rounded-lg p-5 mt-6"
x-data="{
chart: null,
init() {
this.chart = new ApexCharts(this.$refs.chart, {
chart: {
type: 'area',
height: 240,
background: 'transparent',
toolbar: { show: false },
zoom: { enabled: false },
},
theme: { mode: 'dark' },
colors: ['#39d0d8', '#8b5cf6'],
series: [
{ name: 'Jobs', data: {{ json_encode(array_column($throughputData, 'jobs')) }} },
{ name: 'Tokens (k)', data: {{ json_encode(array_map(fn($t) => round($t / 1000, 1), array_column($throughputData, 'tokens'))) }} },
],
xaxis: {
categories: {{ json_encode(array_column($throughputData, 'hour')) }},
labels: { style: { colors: '#8b949e', fontSize: '11px' } },
},
yaxis: [
{ labels: { style: { colors: '#39d0d8' } }, title: { text: 'Jobs', style: { color: '#39d0d8' } } },
{ opposite: true, labels: { style: { colors: '#8b5cf6' } }, title: { text: 'Tokens (k)', style: { color: '#8b5cf6' } } },
],
grid: { borderColor: '#21262d', strokeDashArray: 3 },
stroke: { curve: 'smooth', width: 2 },
fill: { type: 'gradient', gradient: { opacityFrom: 0.3, opacityTo: 0.05 } },
dataLabels: { enabled: false },
legend: { labels: { colors: '#8b949e' } },
tooltip: { theme: 'dark' },
});
this.chart.render();
}
}">
<h3 class="text-sm font-semibold mb-4">Throughput</h3>
<div x-ref="chart"></div>
</div>
</div>

View file

@ -0,0 +1,146 @@
<?php
declare(strict_types=1);
use App\Models\AgentAllowance;
use App\Models\ModelQuota;
use App\Models\RepoLimit;
use App\Services\AllowanceService;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Route;
/*
|--------------------------------------------------------------------------
| Allowance API Routes
|--------------------------------------------------------------------------
|
| Endpoints for managing agent quotas, checking allowances, and recording
| usage. Protected endpoints use QuotaMiddleware for enforcement.
|
*/
// Health check for quota service
Route::get('/allowances/health', fn () => response()->json(['status' => 'ok']));
// Agent allowance CRUD
Route::prefix('allowances/agents')->group(function () {
Route::get('/', function () {
return AgentAllowance::all();
});
Route::get('/{agentId}', function (string $agentId) {
$allowance = AgentAllowance::where('agent_id', $agentId)->first();
if (! $allowance) {
return response()->json(['error' => 'not found'], 404);
}
return $allowance;
});
Route::post('/', function (Request $request) {
$validated = $request->validate([
'agent_id' => 'required|string|unique:agent_allowances,agent_id',
'daily_token_limit' => 'integer|min:0',
'daily_job_limit' => 'integer|min:0',
'concurrent_jobs' => 'integer|min:0',
'max_job_duration_minutes' => 'integer|min:0',
'model_allowlist' => 'array',
'model_allowlist.*' => 'string',
]);
return AgentAllowance::create($validated);
});
Route::put('/{agentId}', function (Request $request, string $agentId) {
$allowance = AgentAllowance::where('agent_id', $agentId)->first();
if (! $allowance) {
return response()->json(['error' => 'not found'], 404);
}
$validated = $request->validate([
'daily_token_limit' => 'integer|min:0',
'daily_job_limit' => 'integer|min:0',
'concurrent_jobs' => 'integer|min:0',
'max_job_duration_minutes' => 'integer|min:0',
'model_allowlist' => 'array',
'model_allowlist.*' => 'string',
]);
$allowance->update($validated);
return $allowance;
});
Route::delete('/{agentId}', function (string $agentId) {
AgentAllowance::where('agent_id', $agentId)->delete();
return response()->json(['status' => 'deleted']);
});
});
// Quota check endpoint
Route::get('/allowances/check/{agentId}', function (Request $request, string $agentId, AllowanceService $svc) {
$model = $request->query('model', '');
return response()->json($svc->check($agentId, $model));
});
// Usage reporting endpoint
Route::post('/allowances/usage', function (Request $request, AllowanceService $svc) {
$validated = $request->validate([
'agent_id' => 'required|string',
'job_id' => 'required|string',
'model' => 'nullable|string',
'tokens_in' => 'integer|min:0',
'tokens_out' => 'integer|min:0',
'event' => 'required|in:job_started,job_completed,job_failed,job_cancelled',
'timestamp' => 'nullable|date',
]);
$svc->recordUsage($validated);
return response()->json(['status' => 'recorded']);
});
// Daily reset endpoint
Route::post('/allowances/reset/{agentId}', function (string $agentId, AllowanceService $svc) {
$svc->resetAgent($agentId);
return response()->json(['status' => 'reset']);
});
// Model quota management
Route::prefix('allowances/models')->group(function () {
Route::get('/', fn () => ModelQuota::all());
Route::post('/', function (Request $request) {
$validated = $request->validate([
'model' => 'required|string|unique:model_quotas,model',
'daily_token_budget' => 'integer|min:0',
'hourly_rate_limit' => 'integer|min:0',
'cost_ceiling' => 'integer|min:0',
]);
return ModelQuota::create($validated);
});
Route::put('/{model}', function (Request $request, string $model) {
$quota = ModelQuota::where('model', $model)->first();
if (! $quota) {
return response()->json(['error' => 'not found'], 404);
}
$validated = $request->validate([
'daily_token_budget' => 'integer|min:0',
'hourly_rate_limit' => 'integer|min:0',
'cost_ceiling' => 'integer|min:0',
]);
$quota->update($validated);
return $quota;
});
});

View file

@ -7,3 +7,9 @@ use Illuminate\Support\Facades\Route;
Route::get('/', function () {
return view('welcome');
});
// Agentic Dashboard
Route::get('/dashboard', fn () => view('dashboard.index'))->name('dashboard');
Route::get('/dashboard/agents', fn () => view('dashboard.agents'))->name('dashboard.agents');
Route::get('/dashboard/jobs', fn () => view('dashboard.jobs'))->name('dashboard.jobs');
Route::get('/dashboard/activity', fn () => view('dashboard.activity'))->name('dashboard.activity');

View file

@ -0,0 +1,206 @@
<?php
declare(strict_types=1);
namespace Tests\Unit\Services\Forgejo;
use App\Services\Forgejo\ForgejoClient;
use Illuminate\Support\Facades\Http;
use Orchestra\Testbench\TestCase;
use RuntimeException;
class ForgejoClientTest extends TestCase
{
private const BASE_URL = 'https://forge.test';
private const TOKEN = 'test-token-abc123';
// ---- Construction ----
public function test_constructor_good(): void
{
Http::fake();
$client = new ForgejoClient(self::BASE_URL, self::TOKEN);
$this->assertSame(self::BASE_URL, $client->baseUrl());
}
public function test_constructor_bad_empty_token(): void
{
$this->expectException(RuntimeException::class);
$this->expectExceptionMessage('API token is required');
new ForgejoClient(self::BASE_URL, '');
}
// ---- GET ----
public function test_get_good(): void
{
Http::fake([
'forge.test/api/v1/repos/owner/repo' => Http::response(['id' => 1, 'name' => 'repo'], 200),
]);
$client = new ForgejoClient(self::BASE_URL, self::TOKEN, retryTimes: 0);
$result = $client->get('/repos/owner/repo');
$this->assertSame(1, $result['id']);
$this->assertSame('repo', $result['name']);
}
public function test_get_bad_server_error(): void
{
Http::fake([
'forge.test/api/v1/repos/owner/repo' => Http::response('Internal Server Error', 500),
]);
$client = new ForgejoClient(self::BASE_URL, self::TOKEN, retryTimes: 0);
$this->expectException(RuntimeException::class);
$this->expectExceptionMessage('Forgejo API error [500]');
$client->get('/repos/owner/repo');
}
// ---- POST ----
public function test_post_good(): void
{
Http::fake([
'forge.test/api/v1/repos/owner/repo/issues' => Http::response(['number' => 42], 201),
]);
$client = new ForgejoClient(self::BASE_URL, self::TOKEN, retryTimes: 0);
$result = $client->post('/repos/owner/repo/issues', ['title' => 'Bug']);
$this->assertSame(42, $result['number']);
}
// ---- PATCH ----
public function test_patch_good(): void
{
Http::fake([
'forge.test/api/v1/repos/owner/repo/issues/1' => Http::response(['state' => 'closed'], 200),
]);
$client = new ForgejoClient(self::BASE_URL, self::TOKEN, retryTimes: 0);
$result = $client->patch('/repos/owner/repo/issues/1', ['state' => 'closed']);
$this->assertSame('closed', $result['state']);
}
// ---- PUT ----
public function test_put_good(): void
{
Http::fake([
'forge.test/api/v1/teams/5/members/alice' => Http::response([], 204),
]);
$client = new ForgejoClient(self::BASE_URL, self::TOKEN, retryTimes: 0);
$result = $client->put('/teams/5/members/alice');
$this->assertIsArray($result);
}
// ---- DELETE ----
public function test_delete_good(): void
{
Http::fake([
'forge.test/api/v1/repos/owner/repo/branches/old' => Http::response('', 204),
]);
$client = new ForgejoClient(self::BASE_URL, self::TOKEN, retryTimes: 0);
// Should not throw
$client->delete('/repos/owner/repo/branches/old');
$this->assertTrue(true);
}
public function test_delete_bad_not_found(): void
{
Http::fake([
'forge.test/api/v1/repos/owner/repo/branches/gone' => Http::response('Not Found', 404),
]);
$client = new ForgejoClient(self::BASE_URL, self::TOKEN, retryTimes: 0);
$this->expectException(RuntimeException::class);
$this->expectExceptionMessage('failed [404]');
$client->delete('/repos/owner/repo/branches/gone');
}
// ---- getRaw ----
public function test_getRaw_good(): void
{
Http::fake([
'forge.test/api/v1/repos/owner/repo/pulls/1.diff' => Http::response(
"diff --git a/file.txt b/file.txt\n",
200,
['Content-Type' => 'text/plain'],
),
]);
$client = new ForgejoClient(self::BASE_URL, self::TOKEN, retryTimes: 0);
$diff = $client->getRaw('/repos/owner/repo/pulls/1.diff');
$this->assertStringContainsString('diff --git', $diff);
}
// ---- Pagination ----
public function test_paginate_good(): void
{
Http::fake([
'forge.test/api/v1/orgs/myorg/repos?page=1&limit=2' => Http::response(
[['id' => 1], ['id' => 2]],
200,
['x-total-count' => '3'],
),
'forge.test/api/v1/orgs/myorg/repos?page=2&limit=2' => Http::response(
[['id' => 3]],
200,
['x-total-count' => '3'],
),
]);
$client = new ForgejoClient(self::BASE_URL, self::TOKEN, retryTimes: 0);
$repos = $client->paginate('/orgs/myorg/repos', [], 2);
$this->assertCount(3, $repos);
$this->assertSame(1, $repos[0]['id']);
$this->assertSame(3, $repos[2]['id']);
}
public function test_paginate_good_empty(): void
{
Http::fake([
'forge.test/api/v1/orgs/empty/repos?page=1&limit=50' => Http::response([], 200),
]);
$client = new ForgejoClient(self::BASE_URL, self::TOKEN, retryTimes: 0);
$repos = $client->paginate('/orgs/empty/repos');
$this->assertSame([], $repos);
}
// ---- Auth header ----
public function test_auth_header_sent(): void
{
Http::fake([
'forge.test/api/v1/user' => Http::response(['login' => 'bot'], 200),
]);
$client = new ForgejoClient(self::BASE_URL, self::TOKEN, retryTimes: 0);
$client->get('/user');
Http::assertSent(function ($request) {
return $request->hasHeader('Authorization', 'token ' . self::TOKEN);
});
}
}

View file

@ -0,0 +1,256 @@
<?php
declare(strict_types=1);
namespace Tests\Unit\Services\Forgejo;
use App\Services\Forgejo\ForgejoService;
use Illuminate\Support\Facades\Http;
use Orchestra\Testbench\TestCase;
use RuntimeException;
class ForgejoServiceTest extends TestCase
{
private const INSTANCES = [
'forge' => ['url' => 'https://forge.test', 'token' => 'tok-forge'],
'dev' => ['url' => 'https://dev.test', 'token' => 'tok-dev'],
];
private function service(): ForgejoService
{
return new ForgejoService(
instances: self::INSTANCES,
defaultInstance: 'forge',
timeout: 5,
retryTimes: 0,
retrySleep: 0,
);
}
// ---- Instance management ----
public function test_instances_good(): void
{
$svc = $this->service();
$this->assertSame(['forge', 'dev'], $svc->instances());
}
public function test_instances_skips_empty_token(): void
{
$svc = new ForgejoService(
instances: [
'forge' => ['url' => 'https://forge.test', 'token' => 'tok'],
'qa' => ['url' => 'https://qa.test', 'token' => ''],
],
);
$this->assertSame(['forge'], $svc->instances());
}
public function test_client_bad_unknown_instance(): void
{
$this->expectException(RuntimeException::class);
$this->expectExceptionMessage("instance 'nope' is not configured");
$this->service()->client('nope');
}
// ---- Issues ----
public function test_createIssue_good(): void
{
Http::fake([
'forge.test/api/v1/repos/org/repo/issues' => Http::response([
'number' => 99,
'title' => 'New bug',
], 201),
]);
$result = $this->service()->createIssue('org', 'repo', 'New bug', 'Description');
$this->assertSame(99, $result['number']);
Http::assertSent(fn ($r) => $r['title'] === 'New bug' && $r['body'] === 'Description');
}
public function test_createIssue_good_with_labels_and_assignee(): void
{
Http::fake([
'forge.test/api/v1/repos/org/repo/issues' => Http::response(['number' => 1], 201),
]);
$this->service()->createIssue('org', 'repo', 'Task', assignee: 'alice', labels: [1, 2]);
Http::assertSent(fn ($r) => $r['assignees'] === ['alice'] && $r['labels'] === [1, 2]);
}
public function test_closeIssue_good(): void
{
Http::fake([
'forge.test/api/v1/repos/org/repo/issues/5' => Http::response(['state' => 'closed'], 200),
]);
$result = $this->service()->closeIssue('org', 'repo', 5);
$this->assertSame('closed', $result['state']);
}
public function test_addComment_good(): void
{
Http::fake([
'forge.test/api/v1/repos/org/repo/issues/5/comments' => Http::response(['id' => 100], 201),
]);
$result = $this->service()->addComment('org', 'repo', 5, 'LGTM');
$this->assertSame(100, $result['id']);
}
public function test_listIssues_good(): void
{
Http::fake([
'forge.test/api/v1/repos/org/repo/issues*' => Http::response([
['number' => 1],
['number' => 2],
], 200),
]);
$issues = $this->service()->listIssues('org', 'repo');
$this->assertCount(2, $issues);
}
// ---- Pull Requests ----
public function test_createPR_good(): void
{
Http::fake([
'forge.test/api/v1/repos/org/repo/pulls' => Http::response([
'number' => 10,
'title' => 'Feature X',
], 201),
]);
$result = $this->service()->createPR('org', 'repo', 'feat/x', 'main', 'Feature X');
$this->assertSame(10, $result['number']);
}
public function test_mergePR_good(): void
{
Http::fake([
'forge.test/api/v1/repos/org/repo/pulls/10/merge' => Http::response([], 200),
]);
// Should not throw
$this->service()->mergePR('org', 'repo', 10, 'squash');
$this->assertTrue(true);
}
public function test_getPRDiff_good(): void
{
Http::fake([
'forge.test/api/v1/repos/org/repo/pulls/10.diff' => Http::response(
"diff --git a/f.go b/f.go\n+new line\n",
200,
),
]);
$diff = $this->service()->getPRDiff('org', 'repo', 10);
$this->assertStringContainsString('diff --git', $diff);
}
// ---- Repositories ----
public function test_getRepo_good(): void
{
Http::fake([
'forge.test/api/v1/repos/org/core' => Http::response(['full_name' => 'org/core'], 200),
]);
$result = $this->service()->getRepo('org', 'core');
$this->assertSame('org/core', $result['full_name']);
}
public function test_createBranch_good(): void
{
Http::fake([
'forge.test/api/v1/repos/org/repo/branches' => Http::response(['name' => 'feat/y'], 201),
]);
$result = $this->service()->createBranch('org', 'repo', 'feat/y', 'main');
$this->assertSame('feat/y', $result['name']);
Http::assertSent(fn ($r) =>
$r['new_branch_name'] === 'feat/y' && $r['old_branch_name'] === 'main'
);
}
public function test_deleteBranch_good(): void
{
Http::fake([
'forge.test/api/v1/repos/org/repo/branches/old' => Http::response('', 204),
]);
$this->service()->deleteBranch('org', 'repo', 'old');
$this->assertTrue(true);
}
// ---- User / Token Management ----
public function test_createUser_good(): void
{
Http::fake([
'forge.test/api/v1/admin/users' => Http::response(['login' => 'bot'], 201),
]);
$result = $this->service()->createUser('bot', 'bot@test.io', 's3cret');
$this->assertSame('bot', $result['login']);
Http::assertSent(fn ($r) =>
$r['username'] === 'bot'
&& $r['must_change_password'] === false
);
}
public function test_createToken_good(): void
{
Http::fake([
'forge.test/api/v1/users/bot/tokens' => Http::response(['sha1' => 'abc123'], 201),
]);
$result = $this->service()->createToken('bot', 'ci-token', ['repo', 'user']);
$this->assertSame('abc123', $result['sha1']);
}
public function test_revokeToken_good(): void
{
Http::fake([
'forge.test/api/v1/users/bot/tokens/42' => Http::response('', 204),
]);
$this->service()->revokeToken('bot', 42);
$this->assertTrue(true);
}
// ---- Multi-instance routing ----
public function test_explicit_instance_routing(): void
{
Http::fake([
'dev.test/api/v1/repos/org/repo' => Http::response(['full_name' => 'org/repo'], 200),
]);
$result = $this->service()->getRepo('org', 'repo', instance: 'dev');
$this->assertSame('org/repo', $result['full_name']);
Http::assertSent(fn ($r) => str_contains($r->url(), 'dev.test'));
}
}

View file

@ -11,7 +11,7 @@ import (
"log"
"runtime"
"github.com/host-uk/core/cmd/core-app/icons"
"forge.lthn.ai/core/cli/cmd/core-app/icons"
"github.com/wailsapp/wails/v3/pkg/application"
)

View file

@ -5,7 +5,7 @@ import (
"log"
"time"
"github.com/host-uk/core/pkg/mcp/ide"
"forge.lthn.ai/core/cli/pkg/mcp/ide"
"github.com/wailsapp/wails/v3/pkg/application"
)

View file

@ -5,7 +5,7 @@ import (
"log"
"time"
"github.com/host-uk/core/pkg/mcp/ide"
"forge.lthn.ai/core/cli/pkg/mcp/ide"
"github.com/wailsapp/wails/v3/pkg/application"
)

View file

@ -1,10 +1,10 @@
module github.com/host-uk/core/cmd/core-ide
module forge.lthn.ai/core/cli/cmd/core-ide
go 1.25.5
require (
github.com/gorilla/websocket v1.5.3
github.com/host-uk/core v0.0.0
forge.lthn.ai/core/cli v0.0.0
github.com/wailsapp/wails/v3 v3.0.0-alpha.64
)
@ -54,4 +54,4 @@ require (
gopkg.in/warnings.v0 v0.1.2 // indirect
)
replace github.com/host-uk/core => ../..
replace forge.lthn.ai/core/cli => ../..

View file

@ -4,8 +4,8 @@ import (
"context"
"log"
"github.com/host-uk/core/pkg/mcp/ide"
"github.com/host-uk/core/pkg/ws"
"forge.lthn.ai/core/cli/pkg/mcp/ide"
"forge.lthn.ai/core/cli/pkg/ws"
"github.com/wailsapp/wails/v3/pkg/application"
)

View file

@ -13,9 +13,9 @@ import (
"runtime"
"strings"
"github.com/host-uk/core/cmd/core-ide/icons"
"github.com/host-uk/core/pkg/mcp/ide"
"github.com/host-uk/core/pkg/ws"
"forge.lthn.ai/core/cli/cmd/core-ide/icons"
"forge.lthn.ai/core/cli/pkg/mcp/ide"
"forge.lthn.ai/core/cli/pkg/ws"
"github.com/wailsapp/wails/v3/pkg/application"
)

View file

@ -8,7 +8,7 @@ import (
"net/http"
"sync"
"github.com/host-uk/core/pkg/ws"
"forge.lthn.ai/core/cli/pkg/ws"
"github.com/wailsapp/wails/v3/pkg/application"
)

View file

@ -2,7 +2,7 @@
//
// When a Go tool requests ?go-get=1, this server responds with HTML
// containing <meta name="go-import"> tags that map dappco.re module
// paths to their Git repositories on forge.lthn.ai.
// paths to their Git repositories on forge.lthn.io.
//
// For browser requests (no ?go-get=1), it redirects to the Forgejo
// repository web UI.
@ -22,7 +22,7 @@ var modules = map[string]string{
}
const (
forgeBase = "https://forge.lthn.ai"
forgeBase = "https://forge.lthn.io"
vanityHost = "dappco.re"
defaultAddr = ":8080"
)

BIN
core-ide Executable file

Binary file not shown.

View file

@ -26,8 +26,8 @@ core dev work --status
repos:
- name: core
path: ./core
url: https://github.com/host-uk/core
url: https://forge.lthn.ai/core/cli
- name: core-php
path: ./core-php
url: https://github.com/host-uk/core-php
url: https://forge.lthn.ai/core/cli-php
```

View file

@ -32,7 +32,7 @@ core go mod graph | dot -Tpng -o deps.png
## Output
```
github.com/host-uk/core github.com/stretchr/testify@v1.11.1
forge.lthn.ai/core/cli github.com/stretchr/testify@v1.11.1
github.com/stretchr/testify@v1.11.1 github.com/davecgh/go-spew@v1.1.2
github.com/stretchr/testify@v1.11.1 github.com/pmezard/go-difflib@v1.0.1
...

View file

@ -23,7 +23,7 @@ Unified interface for Go/PHP development, multi-repo management, and deployment.
## Installation
```bash
go install github.com/host-uk/core/cmd/core@latest
go install forge.lthn.ai/core/cli/cmd/core@latest
```
Verify: `core doctor`

View file

@ -21,7 +21,7 @@ It is both. The Core Framework (`pkg/core`) is a library for building Go desktop
The recommended way is via Go:
```bash
go install github.com/host-uk/core/cmd/core@latest
go install forge.lthn.ai/core/cli/cmd/core@latest
```
Ensure your Go bin directory is in your PATH. See [Getting Started](getting-started.md) for more options.

View file

@ -25,7 +25,7 @@ Optional (for specific features):
```bash
# Install latest release
go install github.com/host-uk/core/cmd/core@latest
go install forge.lthn.ai/core/cli/cmd/core@latest
# Verify installation
core doctor
@ -39,21 +39,21 @@ export PATH="$PATH:$(go env GOPATH)/bin"
### Option 2: Download Binary
Download pre-built binaries from [GitHub Releases](https://github.com/host-uk/core/releases):
Download pre-built binaries from [GitHub Releases](https://forge.lthn.ai/core/cli/releases):
```bash
# macOS (Apple Silicon)
curl -Lo core https://github.com/host-uk/core/releases/latest/download/core-darwin-arm64
curl -Lo core https://forge.lthn.ai/core/cli/releases/latest/download/core-darwin-arm64
chmod +x core
sudo mv core /usr/local/bin/
# macOS (Intel)
curl -Lo core https://github.com/host-uk/core/releases/latest/download/core-darwin-amd64
curl -Lo core https://forge.lthn.ai/core/cli/releases/latest/download/core-darwin-amd64
chmod +x core
sudo mv core /usr/local/bin/
# Linux (x86_64)
curl -Lo core https://github.com/host-uk/core/releases/latest/download/core-linux-amd64
curl -Lo core https://forge.lthn.ai/core/cli/releases/latest/download/core-linux-amd64
chmod +x core
sudo mv core /usr/local/bin/
```
@ -62,7 +62,7 @@ sudo mv core /usr/local/bin/
```bash
# Clone repository
git clone https://github.com/host-uk/core.git
git clone https://forge.lthn.ai/core/cli.git
cd core
# Build with Task (recommended)
@ -181,7 +181,7 @@ core doctor
core <command> --help
# Full documentation
https://github.com/host-uk/core/tree/main/docs
https://forge.lthn.ai/core/cli/tree/main/docs
```
## See Also

View file

@ -6,10 +6,10 @@ Core is a unified CLI for the host-uk ecosystem - build, release, and deploy Go,
```bash
# Via Go (recommended)
go install github.com/host-uk/core/cmd/core@latest
go install forge.lthn.ai/core/cli/cmd/core@latest
# Or download binary from releases
curl -Lo core https://github.com/host-uk/core/releases/latest/download/core-$(go env GOOS)-$(go env GOARCH)
curl -Lo core https://forge.lthn.ai/core/cli/releases/latest/download/core-$(go env GOOS)-$(go env GOARCH)
chmod +x core && sudo mv core /usr/local/bin/
# Verify

View file

@ -364,7 +364,7 @@ import (
"log"
"time"
"github.com/host-uk/core/pkg/webview"
"forge.lthn.ai/core/cli/pkg/webview"
)
func main() {
@ -424,7 +424,7 @@ import (
"log"
"time"
"github.com/host-uk/core/pkg/webview"
"forge.lthn.ai/core/cli/pkg/webview"
)
func main() {

View file

@ -47,8 +47,8 @@ Here is the technical documentation for the Core framework packages.
* **Framework Integration**: The `Service` struct embeds `framework.ServiceRuntime`, utilizing the Actor pattern (Queries and Tasks) to allow dynamic log level adjustment at runtime without restarting the application.
### 4. Dependencies
* `github.com/host-uk/core/pkg/io`: Used by `rotation.go` to handle file operations (renaming, deleting, writing) abstractly.
* `github.com/host-uk/core/pkg/framework`: Used by `service.go` to hook into the application lifecycle and message bus.
* `forge.lthn.ai/core/cli/pkg/io`: Used by `rotation.go` to handle file operations (renaming, deleting, writing) abstractly.
* `forge.lthn.ai/core/cli/pkg/framework`: Used by `service.go` to hook into the application lifecycle and message bus.
* Standard Lib: `errors`, `fmt`, `os`, `sync`, `time`.
### 5. Test Coverage Notes
@ -88,8 +88,8 @@ Here is the technical documentation for the Core framework packages.
### 4. Dependencies
* `github.com/spf13/viper`: Core logic for map merging and unmarshalling.
* `gopkg.in/yaml.v3`: For marshalling data when saving.
* `github.com/host-uk/core/pkg/io`: For reading/writing config files.
* `github.com/host-uk/core/pkg/framework/core`: For service integration and error handling.
* `forge.lthn.ai/core/cli/pkg/io`: For reading/writing config files.
* `forge.lthn.ai/core/cli/pkg/framework/core`: For service integration and error handling.
### 5. Test Coverage Notes
* **Precedence**: Verify that Environment variables override File values.
@ -122,7 +122,7 @@ Here is the technical documentation for the Core framework packages.
### 4. Dependencies
* Standard Lib: `io`, `io/fs`, `os`, `path/filepath`, `strings`, `time`.
* `github.com/host-uk/core/pkg/io/local`: (Implied) The concrete implementation for OS disk access.
* `forge.lthn.ai/core/cli/pkg/io/local`: (Implied) The concrete implementation for OS disk access.
### 5. Test Coverage Notes
* **Mock fidelity**: The `MockMedium` must behave exactly like the OS. E.g., `Rename` should fail if the source doesn't exist; `Delete` should fail if a directory is not empty.
@ -198,10 +198,10 @@ Here is the technical documentation for the Core framework packages.
4. Server validates signature against User Public Key.
### 4. Dependencies
* `github.com/host-uk/core/pkg/io`: For user database storage.
* `github.com/host-uk/core/pkg/crypt/lthn`: (Implied) Specific password hashing.
* `github.com/host-uk/core/pkg/crypt/pgp`: (Implied) OpenPGP operations.
* `github.com/host-uk/core/pkg/framework/core`: Error handling.
* `forge.lthn.ai/core/cli/pkg/io`: For user database storage.
* `forge.lthn.ai/core/cli/pkg/crypt/lthn`: (Implied) Specific password hashing.
* `forge.lthn.ai/core/cli/pkg/crypt/pgp`: (Implied) OpenPGP operations.
* `forge.lthn.ai/core/cli/pkg/framework/core`: Error handling.
### 5. Test Coverage Notes
* **Flow Verification**: Full integration test simulating a client: Register -> Get Challenge -> Decrypt/Sign (Mock Client) -> Validate -> Get Token.

View file

@ -60,9 +60,9 @@ The `cli` package is a comprehensive application runtime and UI framework design
### 4. Dependencies
- `github.com/spf13/cobra`: The underlying command routing engine.
- `github.com/host-uk/core/pkg/framework`: The dependency injection and service lifecycle container.
- `github.com/host-uk/core/pkg/i18n`: For translation and semantic grammar generation.
- `github.com/host-uk/core/pkg/log`: For structured logging.
- `forge.lthn.ai/core/cli/pkg/framework`: The dependency injection and service lifecycle container.
- `forge.lthn.ai/core/cli/pkg/i18n`: For translation and semantic grammar generation.
- `forge.lthn.ai/core/cli/pkg/log`: For structured logging.
- `golang.org/x/term`: For TTY detection.
### 5. Test Coverage Notes
@ -162,8 +162,8 @@ The `workspace` package implements the `core.Workspace` interface, providing iso
- **Key Management**: Delegates actual key generation to the core's `Crypt()` service but manages the storage of the resulting keys within the workspace layout.
### 4. Dependencies
- `github.com/host-uk/core/pkg/framework/core`: Interfaces.
- `github.com/host-uk/core/pkg/io`: File system abstraction (`io.Medium`).
- `forge.lthn.ai/core/cli/pkg/framework/core`: Interfaces.
- `forge.lthn.ai/core/cli/pkg/io`: File system abstraction (`io.Medium`).
- `crypt` service (Runtime dependency): Required for `CreateWorkspace`.
### 5. Test Coverage Notes

View file

@ -87,8 +87,8 @@ type Builder interface {
### 4. Dependencies
* `archive/tar`, `archive/zip`, `compress/gzip`: Standard library for archiving.
* `github.com/Snider/Borg/pkg/compress`: External dependency for XZ compression support.
* `github.com/host-uk/core/pkg/io`: Internal interface for filesystem abstraction.
* `github.com/host-uk/core/pkg/config`: Internal centralized configuration loading.
* `forge.lthn.ai/core/cli/pkg/io`: Internal interface for filesystem abstraction.
* `forge.lthn.ai/core/cli/pkg/config`: Internal centralized configuration loading.
### 5. Test Coverage Notes
* **Mocking IO**: Tests must implement a mock `io.Medium` to simulate file existence (`Detect`) and write operations (`Archive`) without touching the disk.
@ -158,7 +158,7 @@ type RunOptions struct {
### 4. Dependencies
* `os/exec`: Essential for spawning the hypervisor processes.
* `embed`: For built-in templates.
* `github.com/host-uk/core/pkg/io`: Filesystem access for state and logs.
* `forge.lthn.ai/core/cli/pkg/io`: Filesystem access for state and logs.
### 5. Test Coverage Notes
* **Process Management**: Difficult to test `Run` in standard CI. Mocking `exec.Command` or the `Hypervisor` interface is required.
@ -224,7 +224,7 @@ func (r *Runner) RunParallel(ctx context.Context, specs []RunSpec) (*RunAllResul
### 4. Dependencies
* `os/exec`: The underlying execution engine.
* `github.com/host-uk/core/pkg/framework`: Creates the `ServiceRuntime` and provides the IPC/Action bus.
* `forge.lthn.ai/core/cli/pkg/framework`: Creates the `ServiceRuntime` and provides the IPC/Action bus.
### 5. Test Coverage Notes
* **Concurrency**: The `Runner` needs tests for race conditions during parallel execution.
@ -286,7 +286,7 @@ type JobHandler interface {
* **Journaling**: Writes `jsonl` (JSON Lines) files partitioned by repository and date (`baseDir/owner/repo/YYYY-MM-DD.jsonl`), ensuring an append-only audit trail.
### 4. Dependencies
* `github.com/host-uk/core/pkg/log`: Internal logging.
* `forge.lthn.ai/core/cli/pkg/log`: Internal logging.
* `encoding/json`: For journal serialization.
### 5. Test Coverage Notes

View file

@ -72,7 +72,7 @@ func NewService(opts ServiceOptions) func(*framework.Core) (any, error)
### Dependencies
* `os/exec`: For invoking git commands.
* `github.com/host-uk/core/pkg/framework`: For service registration and message passing types.
* `forge.lthn.ai/core/cli/pkg/framework`: For service registration and message passing types.
### Test Coverage Notes
* **Mocking**: Testing requires abstracting `exec.Command`. Since this package calls `exec.CommandContext` directly, tests likely require overriding a package-level variable or using a "fake exec" pattern during test initialization.
@ -135,7 +135,7 @@ func (repo *Repo) IsGitRepo() bool
### Dependencies
* `gopkg.in/yaml.v3`: For parsing `repos.yaml`.
* `github.com/host-uk/core/pkg/io`: For filesystem abstraction (`io.Medium`).
* `forge.lthn.ai/core/cli/pkg/io`: For filesystem abstraction (`io.Medium`).
### Test Coverage Notes
* **Circular Dependencies**: Critical test cases must define a registry with `A->B->A` dependencies to ensure `TopologicalOrder` returns a clear error and doesn't stack overflow.
@ -197,7 +197,7 @@ func (c *Client) ListUserRepos(...)
### Dependencies
* `code.gitea.io/sdk/gitea` (for `pkg/gitea`)
* `codeberg.org/mvdkleijn/forgejo-sdk` (for `pkg/forge`)
* `github.com/host-uk/core/pkg/config`: For persistent auth storage.
* `forge.lthn.ai/core/cli/pkg/config`: For persistent auth storage.
### Test Coverage Notes
* **Draft Status**: The raw HTTP patch in `pkg/forge` needs integration testing against a real instance or a high-fidelity HTTP mock to ensure payload format matches Forgejo's API expectation.
@ -250,8 +250,8 @@ func IncrementVersion(current string) string
* **SDK Generation**: Includes a specialized sub-pipeline (`RunSDK`) that handles OpenAPI diffing and client generation.
### Dependencies
* `github.com/host-uk/core/pkg/build`: For compiling artifacts.
* `github.com/host-uk/core/pkg/release/publishers`: Interface definitions for publishing targets.
* `forge.lthn.ai/core/cli/pkg/build`: For compiling artifacts.
* `forge.lthn.ai/core/cli/pkg/release/publishers`: Interface definitions for publishing targets.
* `golang.org/x/text`: For title casing in changelogs.
### Test Coverage Notes

View file

@ -51,8 +51,8 @@ func ListAgents(cfg *config.Config) (map[string]AgentConfig, error)
* **Defaults Handling**: `LoadAgents` applies specific logic defaults (e.g., default queue directories, default models like "sonnet") to ensure the system works with minimal configuration.
### 4. Dependencies
* `github.com/host-uk/core/pkg/config`: For reading/writing the persistent configuration state.
* `github.com/host-uk/core/pkg/jobrunner/handlers`: To map local config structs to the runtime types used by the job dispatch system.
* `forge.lthn.ai/core/cli/pkg/config`: For reading/writing the persistent configuration state.
* `forge.lthn.ai/core/cli/pkg/jobrunner/handlers`: To map local config structs to the runtime types used by the job dispatch system.
### 5. Test Coverage Notes
* **Configuration Persistence**: Tests should verify that `SaveAgent` correctly updates the underlying config file and that `LoadAgents` retrieves it accurately.

Some files were not shown because too many files have changed in this diff Show more