diff --git a/Dockerfile b/Dockerfile index 79ab445..525d9e6 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,8 +1,18 @@ FROM golang:1.26-alpine AS build WORKDIR /src -RUN apk add --no-cache git +# Fetch internal git-hosted Go modules (cad-atlas now depends on +# git.d-ma.be/mathias/cad — cad-atlas#11, reusing cad#14's cad-stuck query +# logic rather than duplicating the taxonomy) without going through +# proxy.golang.org. Gitea returns http:// in its go-import meta tag, so +# rewrite to https and bypass the module proxy + sumdb (mirrors mathias/cad's +# own Dockerfile — same trick, same reason). +RUN apk add --no-cache git && \ + git config --global url."https://git.d-ma.be/".insteadOf "http://git.d-ma.be/" +ENV GOPRIVATE=git.d-ma.be +ENV GOPROXY=direct +ENV GOSUMDB=off RUN go install github.com/a-h/templ/cmd/templ@latest -COPY go.mod ./ +COPY go.mod go.sum ./ RUN go mod download COPY . . ARG VERSION=dev diff --git a/go.mod b/go.mod index bddcdf0..275fb1f 100644 --- a/go.mod +++ b/go.mod @@ -1,7 +1,9 @@ module git.d-ma.be/mathias/cad-atlas -go 1.26 +go 1.26.4 require github.com/a-h/templ v0.3.1020 require gopkg.in/yaml.v3 v3.0.1 + +require git.d-ma.be/mathias/cad v0.10.0 diff --git a/go.sum b/go.sum index 23cbb51..b6e5b23 100644 --- a/go.sum +++ b/go.sum @@ -1,3 +1,5 @@ +git.d-ma.be/mathias/cad v0.10.0 h1:eK94XBO5oLAsUL0tQQaRzlw8bDNy8COSMLqQZcscuA0= +git.d-ma.be/mathias/cad v0.10.0/go.mod h1:24ESmBwu/8kpVAo2F+G+JCm2JeQn2FKawoW4TfUEAg0= github.com/a-h/templ v0.3.1020 h1:ypAT/L5ySWEnZ6Zft/5yfoWXYYkhFNvEFOeeqecg4tw= github.com/a-h/templ v0.3.1020/go.mod h1:A2DlK61v+K+NRoGnhmYbNYVmtYHcFO5/AisMvBdDxTM= github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= diff --git a/internal/atlas/atlas.json b/internal/atlas/atlas.json index f012d2a..1e3e40e 100644 --- a/internal/atlas/atlas.json +++ b/internal/atlas/atlas.json @@ -29,12 +29,12 @@ {"t":"Admission controller","plain_t":"Tamper-proof seal","plain":"The work order is cryptographically signed when created, so any later tampering is detectable and the eventual change can be checked against it.","d":"Ed25519-sign issue body at creation (#36). Verify sig + PR alignment at infra boundary.","tags":["chain of custody"]}, {"t":"⚖️ var-go Oath","plain_t":"Definition of done","plain":"A machine-checkable \"definition of done\" is embedded in the work order — exactly one, or the order is rejected — later used to prove the result actually meets the spec.","cls":"oath","pill":"var(--gold)","d":"Acceptance contract embedded in the issue as a var fenced block. Exactly one — zero/multiple fail closed. Prose → typed steps; failures anchored to byte spans.","tags":["swedsl · var-go","defined here → enforced @06"]} ]}, - {"no":"STAGE 04","short":"Human go","cls":"gate","title":"Human dispatch gate","plain_title":"Human says go","plain":"A person reviews the work order and its risk and decides whether to release it — the one and only checkpoint where work does not move on its own.","path":"the only checkpoint", + {"no":"STAGE 04","short":"Human go","cls":"gate","title":"Human dispatch gate","plain_title":"Human says go","plain":"A person reviews the work order and its risk and decides whether to release it — the one and only checkpoint where work does not move on its own.","path":"the only checkpoint","generate":"stuck-badges", "trans_label":"A human said go","trans":"The hard stop. Nothing crosses automatically — a person must review the plan and risk and explicitly release it, and the repo must be on the allow-list, before any agent starts. This is the single human checkpoint in the whole pipeline.","nodes":[ {"t":"Human triggers execution","plain_t":"The go button","plain":"A person confirms the plan and its risk level, then releases the work — nothing runs until they do.","cls":"gateway","pill":"var(--amber)","d":"Ratify proposed-plan + risk tier, then dispatch.","gate":true}, {"t":"Session-Dispatch bridge","plain_t":"Hand-off to agents","plain":"The approval flips a switch that hands the signed work order over to the agents to start execution.","cls":"bridge","pill":"var(--blue)","d":"claude.ai MCP → gitea:workflow_run_trigger → cad-dispatch.yml → agentsquad. The final design→execution bridge.","tags":["workflow_dispatch"]} ]}, - {"no":"STAGE 05","short":"Build","cls":"exec","title":"Execute · agentsquad + dispatch","plain_title":"Agents do the work","plain":"AI agents actually build the thing — one writes, a second independent one reviews it to avoid marking its own homework — and every step is logged for the audit trail. Two execution paths run today: agentsquad for orchestrated multi-agent jobs, and dispatch for headless single-issue work.","path":"koala · agentsquad-serve + dispatch CronJob", + {"no":"STAGE 05","short":"Build","cls":"exec","title":"Execute · agentsquad + dispatch","plain_title":"Agents do the work","plain":"AI agents actually build the thing — one writes, a second independent one reviews it to avoid marking its own homework — and every step is logged for the audit trail. Two execution paths run today: agentsquad for orchestrated multi-agent jobs, and dispatch for headless single-issue work.","path":"koala · agentsquad-serve + dispatch CronJob","generate":"stuck-badges", "trans_label":"Change proposed","trans":"Advances when the agents finish and open a proposed change (a PR) with its audit log attached. Until there's a concrete change to test, nothing moves.","nodes":[ {"t":"Task API","plain_t":"Start a job","plain":"A request kicks off a job and hands back an id you can poll for progress.","pill":"var(--coral)","d":"POST /tasks → job id · GET /tasks/{id}. taskqueue + serve (v0.12+).","tags":["single agentsquad.yaml"]}, {"t":"Executor + reviewer loop","plain_t":"Build + independent review","plain":"One agent does the work; a second, independent agent on a different model reviews it — so nothing marks its own homework.","cls":"win","pill":"var(--coral)","d":"ADK Go + LiteLLM. Frontier models (local qwen spirals). Reviewer on distinct tier — echo-chamber prevention.","risk":true}, @@ -53,7 +53,7 @@ "trans_label":"It's live","trans":"Once the new version is actually running on the server, the deployed outcome becomes the input to scoring. Advancing means shipped and observable, not just merged.","nodes":[ {"t":"Deploy on green","plain_t":"Auto-deploy when green","plain":"When all checks pass, the release system rolls the new version onto the live server automatically — but only once the release is pointed at that version (merging code alone doesn't ship it).","pill":"var(--green)","d":"Flux reconciles image → k3s pod on koala. Push ≠ deploy: bump tag in mathias/infra.","tags":["ntfy on deploy"]} ]}, - {"no":"STAGE 08","short":"Learn","cls":"telos","title":"Loop back","plain_title":"Did it work?","plain":"The result is scored against the goal that started it and fed back into the mission board, so the next round of planning learns from what shipped.","path":"→ TELOS (feedback bus)", + {"no":"STAGE 08","short":"Learn","cls":"telos","title":"Loop back","plain_title":"Did it work?","plain":"The result is scored against the goal that started it and fed back into the mission board, so the next round of planning learns from what shipped.","path":"→ TELOS (feedback bus)","generate":"stuck-badges", "trans_label":"What did we learn?","trans":"The scored outcome flows back into the mission board so goals, problems, and priorities update — the loop that makes the pipeline a cycle rather than a line. Partly manual today; an explicit improvement target.","nodes":[ {"t":"Close the loop","plain_t":"Score & feed back","plain":"The outcome is scored against the goal that started it and written back to the mission board, so future planning learns from what actually shipped.","pill":"var(--violet)","d":"session_log + attestation → brain. Score deploy outcome vs originating goal. (arc partly manual — improvement target.)","tags":["continuous"]} ]} diff --git a/internal/atlas/model.go b/internal/atlas/model.go index 3b3e447..108b1d5 100644 --- a/internal/atlas/model.go +++ b/internal/atlas/model.go @@ -42,7 +42,13 @@ type Stage struct { Generate string `json:"generate,omitempty"` TransLabel string `json:"trans_label,omitempty"` Trans string `json:"trans,omitempty"` - Nodes []Node `json:"nodes"` + // StuckCount/AwaitingCount are set by ApplyStuckOverlay (cad-atlas#11) on + // stages marked `"generate":"stuck-badges"` — the two visual classes the + // issue asks for kept as separate counts so a functioning control + // (awaiting human) can never be rendered as a bottleneck (stuck). + StuckCount int `json:"stuck_count,omitempty"` + AwaitingCount int `json:"awaiting_count,omitempty"` + Nodes []Node `json:"nodes"` } // Atlas is the full data model the frontend renders. diff --git a/internal/atlas/stuck.go b/internal/atlas/stuck.go new file mode 100644 index 0000000..5cd6fe0 --- /dev/null +++ b/internal/atlas/stuck.go @@ -0,0 +1,100 @@ +// stuck.go overlays cad-atlas#11's stuck-state badges onto the pipeline: a +// count badge per stage (Node entries + Stage.StuckCount/AwaitingCount), +// sourced from mathias/cad#14's `cad stuck` query rather than re-implemented +// here. cad's app/domain packages are public Go modules (not internal/) — +// this reuses the exact tested classification logic instead of duplicating +// the taxonomy, per ADR-0002 ("a small coupling surface, not a monolith"). +package atlas + +import ( + "fmt" + + caddomain "git.d-ma.be/mathias/cad/domain" +) + +// stageForCategory maps a cad-stuck taxonomy row onto the existing 9-stage +// pipeline (atlas.json), following the grouping cad-atlas#11 itself proposes: +// Trigger→armed-but-never-fired sits at the human dispatch gate (the label +// that authorizes dispatch is on, nothing has claimed it yet); Claim/Execute +// collapse into the one Execute stage this pipeline already has (there is no +// separate Claim stage); Close maps to the loop-back stage, where a human +// closing the issue is the action that actually completes the loop. +// +// CategoryDoneNotClosed is deliberately absent from the "stuck" class — see +// ApplyStuckOverlay, which reads Class rather than Category to decide the +// visual treatment. It IS present here for stage placement. +var stageForCategory = map[caddomain.StuckCategory]string{ + caddomain.CategoryArmedNeverFired: "STAGE 04", + caddomain.CategoryClaimedNeverRan: "STAGE 05", + caddomain.CategoryCeilingHit: "STAGE 05", + caddomain.CategoryReFireLoop: "STAGE 05", + caddomain.CategoryNearAttemptCap: "STAGE 05", + caddomain.CategoryDoneNotClosed: "STAGE 08", +} + +// stuckNodeCls / awaitingNodeCls are the CSS classes the static shell colours +// (cad-atlas.html: .node.stuck, .node.awaiting-human) — the two visual +// classes cad-atlas#11 asks for, kept visually distinct so "awaiting human" +// (a functioning control) never reads as "stuck" (needs intervention). +const ( + stuckNodeCls = "stuck" + awaitingNodeCls = "awaiting-human" +) + +// ApplyStuckOverlay assigns each finding to the stage stageForCategory names, +// and for every stage in stages marked `"generate":"stuck-badges"`, sets its +// StuckCount/AwaitingCount and prepends one Node per finding assigned there +// (title, class, repo tag, and a URL to the finding's first locator — this +// overlay never adds a control, only a link out to Gitea). Findings whose +// category has no stage mapping are silently omitted from the pipeline view +// rather than misplaced — cad-atlas#11 is a read of the query, not a second +// copy of its taxonomy, so an unmapped category is a gap to notice in +// stageForCategory, not something to guess a stage for. +// +// stages is returned unmodified aside from the stuck-badges stages; this is a +// pure function, no I/O, so it is fixture-testable without a live query. +func ApplyStuckOverlay(stages []Stage, findings []caddomain.StuckFinding) []Stage { + byStage := make(map[string][]caddomain.StuckFinding) + for _, f := range findings { + stageNo, ok := stageForCategory[f.Category] + if !ok { + continue + } + byStage[stageNo] = append(byStage[stageNo], f) + } + + out := make([]Stage, len(stages)) + copy(out, stages) + for i := range out { + if out[i].Generate != "stuck-badges" { + continue + } + assigned := byStage[out[i].No] + var stuckN, awaitingN int + nodes := make([]Node, 0, len(assigned)) + for _, f := range assigned { + cls := stuckNodeCls + if f.Class == caddomain.ClassAwaitingHuman { + cls = awaitingNodeCls + awaitingN++ + } else { + stuckN++ + } + var url string + if len(f.Locators) > 0 { + url = f.Locators[0] + } + nodes = append(nodes, Node{ + Title: fmt.Sprintf("#%d %s", f.Issue, f.Category), + Desc: f.Detail, + Cls: cls, + Tags: []string{"live · cad-stuck", f.Repo}, + URL: url, + }) + } + out[i].StuckCount = stuckN + out[i].AwaitingCount = awaitingN + out[i].Nodes = append(nodes, out[i].Nodes...) + } + return out +} diff --git a/internal/atlas/stuck_test.go b/internal/atlas/stuck_test.go new file mode 100644 index 0000000..aa11a5a --- /dev/null +++ b/internal/atlas/stuck_test.go @@ -0,0 +1,110 @@ +package atlas_test + +import ( + "testing" + + "git.d-ma.be/mathias/cad-atlas/internal/atlas" + caddomain "git.d-ma.be/mathias/cad/domain" +) + +func stage(no, generate string) atlas.Stage { + return atlas.Stage{No: no, Title: "example", Generate: generate} +} + +func finding(repo string, issue int, cat caddomain.StuckCategory, class caddomain.StuckClass) caddomain.StuckFinding { + return caddomain.StuckFinding{ + Repo: repo, Issue: issue, Title: "example", Category: cat, Class: class, + Detail: "detail", + Locators: []string{"https://git.d-ma.be/" + repo + "/issues/1"}, + } +} + +func TestApplyStuckOverlay_BadgesOnlyOnGeneratedStages(t *testing.T) { + stages := []atlas.Stage{ + stage("STAGE 04", ""), // no generate marker — must be untouched + stage("STAGE 04b", "stuck-badges"), // marked but no findings map here + } + out := atlas.ApplyStuckOverlay(stages, nil) + if len(out[0].Nodes) != 0 || out[0].StuckCount != 0 { + t.Fatalf("un-marked stage was touched: %+v", out[0]) + } +} + +func TestApplyStuckOverlay_ArmedNeverFiredGoesToStage04(t *testing.T) { + stages := []atlas.Stage{stage("STAGE 04", "stuck-badges")} + findings := []caddomain.StuckFinding{ + finding("mathias/brain-gardener", 190, caddomain.CategoryArmedNeverFired, caddomain.ClassStuck), + } + out := atlas.ApplyStuckOverlay(stages, findings) + if out[0].StuckCount != 1 || out[0].AwaitingCount != 0 { + t.Fatalf("counts = stuck=%d awaiting=%d, want stuck=1 awaiting=0", out[0].StuckCount, out[0].AwaitingCount) + } + if len(out[0].Nodes) != 1 { + t.Fatalf("want 1 node, got %d", len(out[0].Nodes)) + } + n := out[0].Nodes[0] + if n.Cls != "stuck" { + t.Errorf("Cls = %q, want %q", n.Cls, "stuck") + } + if n.URL == "" { + t.Error("node has no URL locator") + } + if n.Title != "#190 armed_never_fired" { + t.Errorf("Title = %q", n.Title) + } +} + +func TestApplyStuckOverlay_DoneNotClosedIsAwaitingHumanAtStage08(t *testing.T) { + stages := []atlas.Stage{stage("STAGE 08", "stuck-badges")} + findings := []caddomain.StuckFinding{ + finding("mathias/cobalt-dingo", 19, caddomain.CategoryDoneNotClosed, caddomain.ClassAwaitingHuman), + finding("mathias/cobalt-dingo", 16, caddomain.CategoryDoneNotClosed, caddomain.ClassAwaitingHuman), + } + out := atlas.ApplyStuckOverlay(stages, findings) + if out[0].StuckCount != 0 || out[0].AwaitingCount != 2 { + t.Fatalf("counts = stuck=%d awaiting=%d, want stuck=0 awaiting=2 — done_not_closed must never render as stuck", out[0].StuckCount, out[0].AwaitingCount) + } + for _, n := range out[0].Nodes { + if n.Cls != "awaiting-human" { + t.Errorf("node Cls = %q, want %q", n.Cls, "awaiting-human") + } + } +} + +func TestApplyStuckOverlay_ExecutionCategoriesCollapseToStage05(t *testing.T) { + stages := []atlas.Stage{stage("STAGE 05", "stuck-badges")} + findings := []caddomain.StuckFinding{ + finding("mathias/cobalt-dingo", 30, caddomain.CategoryCeilingHit, caddomain.ClassStuck), + finding("mathias/cad-atlas", 10, caddomain.CategoryNearAttemptCap, caddomain.ClassStuck), + finding("mathias/cobalt-dingo", 16, caddomain.CategoryReFireLoop, caddomain.ClassStuck), + finding("mathias/cobalt-dingo", 41, caddomain.CategoryClaimedNeverRan, caddomain.ClassStuck), + } + out := atlas.ApplyStuckOverlay(stages, findings) + if out[0].StuckCount != 4 { + t.Fatalf("StuckCount = %d, want 4", out[0].StuckCount) + } +} + +func TestApplyStuckOverlay_NodesPrependedNotReplacingAuthoredContent(t *testing.T) { + s := stage("STAGE 04", "stuck-badges") + s.Nodes = []atlas.Node{{Title: "authored node"}} + out := atlas.ApplyStuckOverlay([]atlas.Stage{s}, []caddomain.StuckFinding{ + finding("mathias/brain-gardener", 190, caddomain.CategoryArmedNeverFired, caddomain.ClassStuck), + }) + if len(out[0].Nodes) != 2 { + t.Fatalf("want 2 nodes (1 live + 1 authored), got %d", len(out[0].Nodes)) + } + if out[0].Nodes[len(out[0].Nodes)-1].Title != "authored node" { + t.Errorf("authored node was not preserved: %+v", out[0].Nodes) + } +} + +func TestApplyStuckOverlay_DoesNotMutateInput(t *testing.T) { + stages := []atlas.Stage{stage("STAGE 04", "stuck-badges")} + _ = atlas.ApplyStuckOverlay(stages, []caddomain.StuckFinding{ + finding("mathias/brain-gardener", 190, caddomain.CategoryArmedNeverFired, caddomain.ClassStuck), + }) + if len(stages[0].Nodes) != 0 { + t.Fatalf("input slice was mutated: %+v", stages[0].Nodes) + } +} diff --git a/internal/web/handler.go b/internal/web/handler.go index ae7c8a8..bfb2e52 100644 --- a/internal/web/handler.go +++ b/internal/web/handler.go @@ -1,9 +1,12 @@ package web import ( + "context" _ "embed" "encoding/json" "net/http" + "os" + "strings" "sync" "time" @@ -12,6 +15,10 @@ import ( "git.d-ma.be/mathias/cad-atlas/internal/cluster" "git.d-ma.be/mathias/cad-atlas/internal/gitea" "git.d-ma.be/mathias/cad-atlas/internal/version" + + "git.d-ma.be/mathias/cad/adapters/gitearead" + cadapp "git.d-ma.be/mathias/cad/app" + caddomain "git.d-ma.be/mathias/cad/domain" ) // atlasHTML is the Phase-A/B static shell. It fetches /api/atlas.json at load @@ -64,6 +71,10 @@ func NewHandler() http.Handler { } } } + // cad-atlas#11: overlay mathias/cad#14's cad-stuck findings as + // per-stage badges. Read-only — ApplyStuckOverlay only adds Nodes + // with a link out; it never writes anything back to Gitea. + a.Stages = atlas.ApplyStuckOverlay(a.Stages, ld.stuck) if ld.deploy != nil || ld.flux != nil { var live []atlas.Node if ld.deploy != nil { @@ -93,6 +104,30 @@ type liveOverlayData struct { flux *atlas.Flux timeline []atlas.RunDot issues []atlas.Node + stuck []caddomain.StuckFinding +} + +// stuckRepos is the estate cad-stuck queries — mirrors mathias/dispatch's +// dispatch-repos.txt scope. Override with CAD_ATLAS_STUCK_REPOS (comma +// separated "owner/name" entries) so this doesn't need a redeploy every time +// the estate's repo list changes; cad-atlas has no filesystem access to the +// canonical dispatch-repos.txt at runtime. +var defaultStuckRepos = []string{ + "mathias/dispatch-sandbox", "mathias/cobalt-dingo", "mathias/parallax", + "mathias/cad-atlas", "mathias/brain-gardener", "mathias/hyperguild", "mathias/brain", +} + +func stuckRepos() []string { + if v := os.Getenv("CAD_ATLAS_STUCK_REPOS"); v != "" { + var repos []string + for _, r := range strings.Split(v, ",") { + if r = strings.TrimSpace(r); r != "" { + repos = append(repos, r) + } + } + return repos + } + return defaultStuckRepos } // live cache: query the cluster at most once per TTL; fall back to the authored @@ -147,6 +182,40 @@ func liveOverlay() liveOverlayData { d.flux = &f } } + d.stuck = fetchStuck() liveCache = d return d } + +// fetchStuck re-derives cad#14's stuck worklist fresh on every liveOverlay +// call (no cache beyond the shared liveTTL) — the same "re-derive, never +// recall" discipline cad-stuck and cad-babysit both hold to. Read-only: +// gitearead.Client has no write method in its package. Degrades to nil +// (no badges rendered) on any error or when GITEA_TOKEN is unset, exactly +// like every other liveOverlay source — a forge outage costs a missing +// overlay, never a broken page. +func fetchStuck() []caddomain.StuckFinding { + token := os.Getenv("GITEA_TOKEN") + if token == "" { + return nil + } + base := os.Getenv("GITEA_BASE") + if base == "" || strings.Contains(base, ".svc.cluster.local") { + // The in-cluster Gitea service (internal/gitea's default) has no + // public HTTPS endpoint for gitearead's client to call with a + // bearer token in the same way; cad-stuck's own default (the public + // git.d-ma.be) is the correct base here regardless of what + // GITEA_BASE is set to for the read-only in-cluster fetchers above. + base = "https://git.d-ma.be" + } + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + client := gitearead.New(base, token) + cfg := cadapp.DefaultStuckConfig() + cfg.BaseURL = base + findings, _, err := cadapp.RunStuckQuery(ctx, client, stuckRepos(), cfg) + if err != nil { + return nil + } + return findings +} diff --git a/internal/web/static/cad-atlas.html b/internal/web/static/cad-atlas.html index 879a955..f94380a 100644 --- a/internal/web/static/cad-atlas.html +++ b/internal/web/static/cad-atlas.html @@ -120,6 +120,17 @@ border:1px solid var(--line);color:var(--dim);margin:6px 6px 0 0} .pill{width:8px;height:8px;border-radius:50%;flex:0 0 8px} + /* cad-atlas#11: stuck-state overlay. Two visual classes, deliberately + distinct — "stuck" needs intervention, "awaiting-human" is a control + working as designed and must never look like a problem. */ + .node.stuck{border-color:rgba(255,122,92,.55);background:linear-gradient(180deg,rgba(255,122,92,.07),transparent)} + .node.awaiting-human{border-color:rgba(74,208,122,.5);background:linear-gradient(180deg,rgba(74,208,122,.06),transparent)} + .stagehead{display:flex;align-items:baseline;gap:8px;flex-wrap:wrap} + .stagehead .badges{display:flex;gap:6px} + .badge{font-size:10.5px;padding:2px 8px;border-radius:20px;font-weight:600;white-space:nowrap} + .badge.stuck{color:var(--coral);border:1px solid rgba(255,122,92,.5)} + .badge.awaiting{color:var(--green);border:1px solid rgba(74,208,122,.5)} + .stage.telos h2{color:var(--violet)} .stage.gate h2{color:var(--amber)} .stage.exec h2{color:var(--coral)} @@ -234,7 +245,16 @@ function renderAtlas(){ ? `