From a9c72d6ca82a84382b28b6c4393fba8279644e77 Mon Sep 17 00:00:00 2001 From: Mathias Date: Mon, 20 Jul 2026 00:34:06 +0200 Subject: [PATCH] =?UTF-8?q?feat(atlas):=20Phase=20B=20=E2=80=94=20data-dri?= =?UTF-8?q?ven=20/api/atlas.json,=20CI=20stage=20generated=20from=20cd.yml?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The atlas no longer hand-maintains its content. Authored data lives in one place (internal/atlas/atlas.json); the Go layer overlays sourced facts and serves the result at /api/atlas.json; the frontend fetches + renders (inline arrays kept only as an offline fallback). First generated source: the CI/CD stage's nodes are parsed from the repo's own .gitea/workflows/cd.yml — so the viz shows the pipeline that actually runs (guard/check/build/deploy), dropping the aspirational var-go/oath-gate node that isn't wired yet. That's the point: it can't drift from the real pipeline. New internal/atlas package (JobsFromWorkflow, Build) built test-first. Adds gopkg.in/yaml.v3 (justified: parsing the workflow YAML; stdlib has no YAML). Verified: go build/vet/lint(0)/test green; /api/atlas.json → 9 stages, CI = real jobs; frontend renders from the fetch (screenshot). Co-Authored-By: Claude Opus 4.8 (1M context) --- embed.go | 12 ++++++ go.mod | 2 + go.sum | 4 ++ internal/atlas/atlas.json | 46 +++++++++++++++++++++ internal/atlas/build_test.go | 48 ++++++++++++++++++++++ internal/atlas/embed.go | 15 +++++++ internal/atlas/model.go | 66 ++++++++++++++++++++++++++++++ internal/atlas/workflow.go | 48 ++++++++++++++++++++++ internal/atlas/workflow_test.go | 45 ++++++++++++++++++++ internal/web/handler.go | 24 +++++++---- internal/web/handler_test.go | 40 ++++++++++++++++++ internal/web/static/cad-atlas.html | 49 ++++++++++++++-------- 12 files changed, 375 insertions(+), 24 deletions(-) create mode 100644 embed.go create mode 100644 internal/atlas/atlas.json create mode 100644 internal/atlas/build_test.go create mode 100644 internal/atlas/embed.go create mode 100644 internal/atlas/model.go create mode 100644 internal/atlas/workflow.go create mode 100644 internal/atlas/workflow_test.go diff --git a/embed.go b/embed.go new file mode 100644 index 0000000..372cffb --- /dev/null +++ b/embed.go @@ -0,0 +1,12 @@ +// Package cadatlas embeds module-root build artifacts that live outside any +// subpackage directory. go:embed cannot reach parent directories, so the CI +// workflow — which the atlas parses to generate its CI/CD stage from the real +// pipeline — is embedded here at the module root. +package cadatlas + +import _ "embed" + +// CDWorkflow is the repo's own Gitea Actions workflow, embedded at build time. +// +//go:embed .gitea/workflows/cd.yml +var CDWorkflow []byte diff --git a/go.mod b/go.mod index fa7f9bc..bddcdf0 100644 --- a/go.mod +++ b/go.mod @@ -3,3 +3,5 @@ module git.d-ma.be/mathias/cad-atlas go 1.26 require github.com/a-h/templ v0.3.1020 + +require gopkg.in/yaml.v3 v3.0.1 diff --git a/go.sum b/go.sum index 0857b6f..23cbb51 100644 --- a/go.sum +++ b/go.sum @@ -2,3 +2,7 @@ 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= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/atlas/atlas.json b/internal/atlas/atlas.json new file mode 100644 index 0000000..6c2def8 --- /dev/null +++ b/internal/atlas/atlas.json @@ -0,0 +1,46 @@ +{ + "substrate": [ + {"n":"koala","k":"RTX 5070 · k3s control-plane · Gitea · LiteLLM :30401 · llama-swap :31234 · searxng"}, + {"n":"iguana","k":"M2 Ultra · Ollama / mlx"}, + {"n":"flamingo","k":"daily driver · ~/dev"}, + {"n":"piguard","k":"NGINX reverse-proxy · ntfy"} + ], + "ns": "Tailscale mesh · ns: ai-stack · supervisor(→brain) · gitea-mcp · infra-mcp · council", + "stages": [ + {"no":"STAGE 00","title":"Signals","path":"→ mathias/signals","nodes":[ + {"t":"Applied AI Radar","d":"Daily Tier-1 + weekly Tier-2 deep pass. Verified-primary bar (paper/benchmark/code/named-lab).","tags":["cron · daily/weekly","→ signals #1–26+"]}, + {"t":"Manual capture","d":"claude.ai strategic drop · brain capture tool.","tags":["ad-hoc"]}, + {"t":"Aspirational surfaces","pill":"var(--dim)","d":"Telegram / voice / URL → inbox. NOT built.","tags":["gap"]} + ]}, + {"no":"STAGE 01","cls":"telos","title":"TELOS","path":"wiki/telos/","nodes":[ + {"t":"Intention substrate","pill":"var(--violet)","d":"Mission · goals · problems · strategies · status. Every downstream item traces to a goal.","tags":["brain_query wing=telos"]} + ]}, + {"no":"STAGE 02","title":"Strategic session","path":"claude.ai frontier + brain MCP","nodes":[ + {"t":"Design · ADRs · specs","d":"Human + frontier model. ISC acceptance criteria written here.","tags":["Define / converge"]}, + {"t":"🏛️ LLM Council","cls":"council","pill":"var(--violet)","d":"fan-out → anonymous cross-review → chairman synth. glm-4.7-flash · qwen36-35b · gemma4-31b (chair).","tags":["hard strategic Q","chat.d-ma.be"]}, + {"t":"Autoresearch Council","cls":"council","pill":"var(--violet)","d":"Sibling pipe — ratifies research before the gate.","tags":["proposed: → standalone svc"]} + ]}, + {"no":"STAGE 03","title":"Spec → Gitea issue","path":"agent-ready contract","nodes":[ + {"t":"Contract enforced","d":"Binary ISC · declared risk tier · reg-risk assessment · no open human deps.","tags":["LOW / MED / HIGH"]}, + {"t":"Admission controller","d":"Ed25519-sign issue body at creation (#36). Verify sig + PR alignment at infra boundary.","tags":["chain of custody"]}, + {"t":"⚖️ var-go Oath","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","cls":"gate","title":"Human dispatch gate","path":"the only checkpoint","nodes":[ + {"t":"Human triggers execution","cls":"gateway","pill":"var(--amber)","d":"Ratify proposed-plan + risk tier, then dispatch.","gate":true}, + {"t":"Session-Dispatch bridge","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","cls":"exec","title":"Execute · agentsquad","path":"koala · cmd/agentsquad-serve","nodes":[ + {"t":"Task API","pill":"var(--coral)","d":"POST /tasks → job id · GET /tasks/{id}. taskqueue + serve (v0.12+).","tags":["single agentsquad.yaml"]}, + {"t":"Executor + reviewer loop","cls":"win","pill":"var(--coral)","d":"ADK Go + LiteLLM. Frontier models (local qwen spirals). Reviewer on distinct tier — echo-chamber prevention.","risk":true}, + {"t":"dma-cli · routing + scope","cls":"bridge","pill":"var(--blue)","d":"Harness-config arm: routes agents to the right LLM backend. Three-layer scope policy + confirmation gate = CAD guardrail.","tags":["backend routing","scope guardrail"]}, + {"t":"assessor-loop ledger","d":"Attestation ledger (audit trail) + brain session_log on completion.","tags":["audit package"]} + ]}, + {"no":"STAGE 06","title":"PR → CI","path":"Gitea Actions · cd.yml (live)","generate":"ci-jobs","nodes":[]}, + {"no":"STAGE 07","cls":"cd","title":"CD → pod","path":"Flux GitOps → k3s","nodes":[ + {"t":"Deploy on green","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","cls":"telos","title":"Loop back","path":"→ TELOS (feedback bus)","nodes":[ + {"t":"Close the loop","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/build_test.go b/internal/atlas/build_test.go new file mode 100644 index 0000000..e4a1d17 --- /dev/null +++ b/internal/atlas/build_test.go @@ -0,0 +1,48 @@ +package atlas_test + +import ( + "reflect" + "testing" + + "git.d-ma.be/mathias/cad-atlas/internal/atlas" +) + +func TestBuild_OverlaysCIStageNodesFromWorkflow(t *testing.T) { + atlasJSON := []byte(`{ + "substrate": [{"n":"koala","k":"gpu"}], + "stages": [ + {"no":"STAGE 06","title":"PR → CI","generate":"ci-jobs","nodes":[]}, + {"no":"STAGE 01","title":"TELOS","nodes":[{"t":"Intention substrate"}]} + ]}`) + workflow := []byte("jobs:\n check:\n a: 1\n build:\n b: 2\n deploy:\n c: 3\n") + + a, err := atlas.Build(atlasJSON, workflow) + if err != nil { + t.Fatalf("Build: %v", err) + } + + // CI stage nodes generated from the workflow's jobs, in order. + var titles []string + for _, n := range a.Stages[0].Nodes { + titles = append(titles, n.Title) + } + if want := []string{"check", "build", "deploy"}; !reflect.DeepEqual(titles, want) { + t.Fatalf("CI stage nodes = %v, want %v", titles, want) + } + + // Non-generated stage is untouched. + if a.Stages[1].Nodes[0].Title != "Intention substrate" { + t.Fatalf("authored stage was altered: %+v", a.Stages[1]) + } + + // Substrate preserved. + if len(a.Substrate) != 1 || a.Substrate[0].Name != "koala" { + t.Fatalf("substrate not preserved: %+v", a.Substrate) + } +} + +func TestBuild_ErrorsOnBadAtlasJSON(t *testing.T) { + if _, err := atlas.Build([]byte("{not json"), []byte("jobs:\n x:\n a: 1\n")); err == nil { + t.Fatal("expected error on bad atlas JSON, got nil") + } +} diff --git a/internal/atlas/embed.go b/internal/atlas/embed.go new file mode 100644 index 0000000..c3f5354 --- /dev/null +++ b/internal/atlas/embed.go @@ -0,0 +1,15 @@ +package atlas + +import _ "embed" + +// DataJSON is the authored atlas content (substrate + stage narrative). +// Sourced/generated facts are overlaid onto it at Build time. +// +//go:embed atlas.json +var DataJSON []byte + +// Default builds the atlas from the embedded authored data, overlaying the +// given CI workflow (for the stage marked generate:ci-jobs). +func Default(workflow []byte) (Atlas, error) { + return Build(DataJSON, workflow) +} diff --git a/internal/atlas/model.go b/internal/atlas/model.go new file mode 100644 index 0000000..37a1f7e --- /dev/null +++ b/internal/atlas/model.go @@ -0,0 +1,66 @@ +package atlas + +import ( + "encoding/json" + "fmt" +) + +// Host is a substrate machine/entry (koala, iguana, …). +type Host struct { + Name string `json:"n"` + Spec string `json:"k"` +} + +// Node is a card within a stage. +type Node struct { + Title string `json:"t"` + Desc string `json:"d,omitempty"` + Pill string `json:"pill,omitempty"` + Cls string `json:"cls,omitempty"` + Tags []string `json:"tags,omitempty"` + Risk bool `json:"risk,omitempty"` + Gate bool `json:"gate,omitempty"` +} + +// Stage is one column of the pipeline. When Generate is set, its Nodes are +// derived from a source at Build time rather than taken from the authored data. +type Stage struct { + No string `json:"no"` + Title string `json:"title"` + Path string `json:"path,omitempty"` + Cls string `json:"cls,omitempty"` + Generate string `json:"generate,omitempty"` + Nodes []Node `json:"nodes"` +} + +// Atlas is the full data model the frontend renders. +type Atlas struct { + Substrate []Host `json:"substrate"` + NS string `json:"ns,omitempty"` + Stages []Stage `json:"stages"` +} + +// Build unmarshals the authored atlas JSON and overlays generated facts from +// real sources, so sourced parts can't drift. Currently: any stage marked +// `"generate":"ci-jobs"` gets its Nodes replaced by the workflow's job list. +func Build(atlasJSON, workflow []byte) (Atlas, error) { + var a Atlas + if err := json.Unmarshal(atlasJSON, &a); err != nil { + return Atlas{}, fmt.Errorf("parse atlas data: %w", err) + } + for i := range a.Stages { + if a.Stages[i].Generate != "ci-jobs" { + continue + } + jobs, err := JobsFromWorkflow(workflow) + if err != nil { + return Atlas{}, fmt.Errorf("stage %s: %w", a.Stages[i].No, err) + } + nodes := make([]Node, 0, len(jobs)) + for _, j := range jobs { + nodes = append(nodes, Node{Title: j}) + } + a.Stages[i].Nodes = nodes + } + return a, nil +} diff --git a/internal/atlas/workflow.go b/internal/atlas/workflow.go new file mode 100644 index 0000000..9343f67 --- /dev/null +++ b/internal/atlas/workflow.go @@ -0,0 +1,48 @@ +// Package atlas builds the CAD Atlas data model, deriving parts from real +// sources (the repo's own CI workflow, infra manifests, the live cluster) +// so the visualization can't drift from reality. +package atlas + +import ( + "fmt" + + "gopkg.in/yaml.v3" +) + +// JobsFromWorkflow extracts the job names, in document order, from a Gitea +// Actions / GitHub Actions workflow YAML. Used to generate the CI/CD stage of +// the atlas from the pipeline that actually runs, rather than hand-authoring it. +func JobsFromWorkflow(workflow []byte) ([]string, error) { + var doc yaml.Node + if err := yaml.Unmarshal(workflow, &doc); err != nil { + return nil, fmt.Errorf("parse workflow: %w", err) + } + if len(doc.Content) == 0 || doc.Content[0].Kind != yaml.MappingNode { + return nil, fmt.Errorf("workflow: expected a top-level mapping") + } + jobs := mappingValue(doc.Content[0], "jobs") + if jobs == nil { + return nil, fmt.Errorf("workflow: no jobs block") + } + if jobs.Kind != yaml.MappingNode { + return nil, fmt.Errorf("workflow: jobs is not a mapping") + } + + // A mapping node stores keys and values as alternating Content entries; + // keys are the even indices, in document order. + names := make([]string, 0, len(jobs.Content)/2) + for i := 0; i+1 < len(jobs.Content); i += 2 { + names = append(names, jobs.Content[i].Value) + } + return names, nil +} + +// mappingValue returns the value node for key in a YAML mapping node, or nil. +func mappingValue(m *yaml.Node, key string) *yaml.Node { + for i := 0; i+1 < len(m.Content); i += 2 { + if m.Content[i].Value == key { + return m.Content[i+1] + } + } + return nil +} diff --git a/internal/atlas/workflow_test.go b/internal/atlas/workflow_test.go new file mode 100644 index 0000000..6d3147d --- /dev/null +++ b/internal/atlas/workflow_test.go @@ -0,0 +1,45 @@ +package atlas_test + +import ( + "reflect" + "testing" + + "git.d-ma.be/mathias/cad-atlas/internal/atlas" +) + +func TestJobsFromWorkflow_ReturnsJobNamesInDocumentOrder(t *testing.T) { + workflow := []byte(` +name: CD +"on": + push: + branches: [main] +jobs: + guard: + runs-on: self-hosted + check: + needs: guard + runs-on: self-hosted + build: + needs: [guard, check] + runs-on: self-hosted + deploy: + needs: [guard, build] + runs-on: self-hosted +`) + + jobs, err := atlas.JobsFromWorkflow(workflow) + if err != nil { + t.Fatalf("JobsFromWorkflow: %v", err) + } + + want := []string{"guard", "check", "build", "deploy"} + if !reflect.DeepEqual(jobs, want) { + t.Fatalf("jobs = %v, want %v", jobs, want) + } +} + +func TestJobsFromWorkflow_ErrorsOnMalformedYAML(t *testing.T) { + if _, err := atlas.JobsFromWorkflow([]byte("jobs: [this is: not valid")); err == nil { + t.Fatal("expected error on malformed YAML, got nil") + } +} diff --git a/internal/web/handler.go b/internal/web/handler.go index adb6c19..3c76c5d 100644 --- a/internal/web/handler.go +++ b/internal/web/handler.go @@ -1,20 +1,22 @@ package web import ( - "context" _ "embed" + "encoding/json" "net/http" + + cadatlas "git.d-ma.be/mathias/cad-atlas" + "git.d-ma.be/mathias/cad-atlas/internal/atlas" ) -// atlasHTML is the Phase-A static hero visualization. Phase C replaces this -// self-contained file with a Templ view hydrated from live CAD trace data -// (assessor-loop ledger, session_log, Gitea run API, Flux events). +// atlasHTML is the Phase-A/B static shell. It fetches /api/atlas.json at load +// and renders from that data (no inline arrays), so the content is sourced. // //go:embed static/cad-atlas.html var atlasHTML []byte -// NewHandler serves the CAD Atlas. Root ("/") returns the static atlas; -// /api/hello is a leftover template probe kept until Phase C wires real endpoints. +// NewHandler serves the CAD Atlas: the shell at "/", and the sourced data at +// "/api/atlas.json" (authored data + CI stage generated from the real cd.yml). func NewHandler() http.Handler { mux := http.NewServeMux() mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { @@ -25,8 +27,14 @@ func NewHandler() http.Handler { w.Header().Set("Content-Type", "text/html; charset=utf-8") _, _ = w.Write(atlasHTML) }) - mux.HandleFunc("/api/hello", func(w http.ResponseWriter, r *http.Request) { - _ = Hello("world").Render(context.Background(), w) + mux.HandleFunc("/api/atlas.json", func(w http.ResponseWriter, r *http.Request) { + a, err := atlas.Default(cadatlas.CDWorkflow) + if err != nil { + http.Error(w, "atlas build failed", http.StatusInternalServerError) + return + } + w.Header().Set("Content-Type", "application/json; charset=utf-8") + _ = json.NewEncoder(w).Encode(a) }) return mux } diff --git a/internal/web/handler_test.go b/internal/web/handler_test.go index e5bbe0e..39c4f0b 100644 --- a/internal/web/handler_test.go +++ b/internal/web/handler_test.go @@ -1,10 +1,13 @@ package web import ( + "encoding/json" "net/http" "net/http/httptest" "strings" "testing" + + "git.d-ma.be/mathias/cad-atlas/internal/atlas" ) func TestRootServesAtlas(t *testing.T) { @@ -27,6 +30,43 @@ func TestRootServesAtlas(t *testing.T) { } } +func TestAtlasJSON_ServesAtlasWithGeneratedCIStage(t *testing.T) { + srv := httptest.NewServer(NewHandler()) + defer srv.Close() + + resp, err := http.Get(srv.URL + "/api/atlas.json") + if err != nil { + t.Fatalf("GET /api/atlas.json: %v", err) + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusOK { + t.Fatalf("status = %d, want 200", resp.StatusCode) + } + var a atlas.Atlas + if err := json.NewDecoder(resp.Body).Decode(&a); err != nil { + t.Fatalf("decode atlas: %v", err) + } + if len(a.Stages) == 0 { + t.Fatal("atlas has no stages") + } + + // The generate:ci-jobs stage must be populated from the real cd.yml jobs. + got := map[string]bool{} + for _, s := range a.Stages { + if s.No == "STAGE 06" { + for _, n := range s.Nodes { + got[n.Title] = true + } + } + } + for _, want := range []string{"guard", "check", "build", "deploy"} { + if !got[want] { + t.Fatalf("CI stage missing generated job %q (got %v)", want, got) + } + } +} + func TestUnknownPath404(t *testing.T) { srv := httptest.NewServer(NewHandler()) defer srv.Close() diff --git a/internal/web/static/cad-atlas.html b/internal/web/static/cad-atlas.html index 3b70b89..4596e08 100644 --- a/internal/web/static/cad-atlas.html +++ b/internal/web/static/cad-atlas.html @@ -114,7 +114,7 @@

CAD Atlas · From Signal to Pod

- one human gate · everything up- and downstream is agents · v0.3 static snapshot (→ live in Phase C) + one human gate · everything up- and downstream is agents · v0.4 · data-driven (CI stage generated from cd.yml)
@@ -145,20 +145,22 @@
CAD → CI → CD · intent→specify→dispatch · build→test→validate · deploy→ship. Dashed violet = feedback bus (stage 08 → TELOS: deploy outcome scored vs originating goal). - Data: static inventory from brain (2026-07-19). Phase C swaps these arrays for live reads of - assessor-loop ledger · session_log · Gitea run API · Flux events. + Data served from /api/atlas.json (authored atlas.json + CI stage generated from the live cd.yml). + Phase C plugs in live reads of assessor-loop ledger · session_log · Gitea run API · Flux events.