16 Commits
Author SHA1 Message Date
mathiasandClaude Opus 5 097d8747c6 feat(atlas): surface the user's own Gitea repos on stage 05 (#10)
CD / Deploy via GitOps (push) Successful in 0s
CD / Detect unsubstituted template (push) Successful in 1s
CD / Lint / Test / Vet (push) Successful in 6s
CD / var-go/oath (push) Skipped
CD / Build & Import (push) Successful in 5s
Adds the repos panel from #4's "Not yet shipped" list, following the shipped
issues slice (b2761a4) structurally rather than inventing a second approach:

- internal/atlas/repos.go — RepoNodes(), a pure parser over an already-decoded
  /user/repos response. Archived repos are dropped: the atlas shows where work
  happens, and an archived repo is by definition somewhere it does not.
- internal/gitea/client.go — MyRepos(), same PAT and same contract as MyIssues:
  errors when GITEA_TOKEN is unset so the caller skips the overlay rather than
  issuing an unauthenticated request. That unset path is the one running in
  prod today (#4), so it is tested, not assumed.
- internal/web/handler.go — wired into liveOverlay().

Stage 05 is the right home — repos are what the execution stage acts on — but
#11 had just claimed its `generate` key for stuck-badges, and a stage could
only name one source. So `generate` now accepts either spelling:

    "generate": "ci-jobs"
    "generate": ["stuck-badges", "gitea-repos"]

The bare string stays valid and marshals back as a bare string, so no authored
stage is rewritten and the frontend contract is unchanged. Stages 04 and 08
already share stuck-badges between them; this is the same growth one level in.

The overlay merge also moves out of the handler into atlas.PrependGenerated,
which is unit-testable where the inline loop was not — it sat behind a network
call. It covers the issues path too, and fixes a latent aliasing bug there: the
old `append(liveNodes, authored...)` wrote into the live slice's backing array,
so two stages sharing a key would corrupt each other. That shape is no longer
hypothetical now that stuck-badges spans three stages.

Verified end-to-end both ways: without GITEA_TOKEN the atlas returns 200 with
stage 05's authored nodes intact; with a real token stage 05 serves both
overlays at once — stuck badges, then eight live repos, then the authored
nodes — and a repo with no language emits one tag rather than an empty pill.

task check green (golangci-lint 0 issues, vet, -race).

Closes #10.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 22:01:48 +02:00
mathiasandClaude Sonnet 5 62955c998d fix(ci): authenticate go mod download against the private cad module
CD / Deploy via GitOps (push) Successful in 1s
CD / Detect unsubstituted template (push) Successful in 1s
CD / Lint / Test / Vet (push) Successful in 6s
CD / var-go/oath (push) Skipped
CD / Build & Import (push) Successful in 7s
Build & Import (run #68) failed: mathias/cad is PRIVATE (unlike
mcp-chassis, the only other internal module a cad-* Dockerfile has fetched
so far, which is public) — 'go mod download' inside the Docker build had
no credential and died on 'terminal prompts disabled'.

Fix mirrors this repo's own existing GIT_ASKPASS pattern (cd.yml's
oathcandidate step, for the private swedsl dependency): a BuildKit secret
mount feeds a GIT_ASKPASS helper for the one RUN that needs it. The token
is never written to a persisted layer — the askpass script and its
reference to /run/secrets/gitea_token are created and removed within the
same RUN instruction. cd.yml writes the token to a file only for
build-image.sh's subprocess and passes it via BUILD_SECRETS (the script's
existing mechanism — 'the caller writes the secret to PATH itself').

Verified by running build-image.sh's actual buildctl invocation locally
against the real koala buildkitd (same daemon CI uses) with a real token —
'go mod download' completes in 1.9s, full build succeeds. Reproduced the
CI failure log before writing this fix, per house discipline.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-28 13:31:59 +02:00
mathiasandClaude Sonnet 5 97d0f56383 feat(stuck): stuck-state pipeline overlay (cad-atlas#11)
CD / Build & Import (push) Failing after 3s
CD / Detect unsubstituted template (push) Successful in 1s
CD / Lint / Test / Vet (push) Successful in 5s
CD / var-go/oath (push) Skipped
CD / Deploy via GitOps (push) Skipped
Consumes mathias/cad#14's cad-stuck query directly — cad's app/domain
packages are public Go modules, so this reuses the exact tested
classification logic (import, not re-implementation) rather than
duplicating the taxonomy across two repos.

ApplyStuckOverlay (internal/atlas/stuck.go, fixture-tested) maps each
finding's category onto the existing 9-stage pipeline: armed_never_fired to
the human dispatch gate, claimed_never_ran/ceiling_hit/refire_loop/
near_attempt_cap to the one Execute stage (there is no separate Claim
stage), done_not_closed to the loop-back stage. Adds a StuckCount/
AwaitingCount badge per stage plus one Node per finding, linking out to the
underlying issue/comment — read-only, no controls, matching cad#14's own
constraint.

Two visual classes, kept deliberately distinct per the issue's own framing:
a coral 'N stuck' badge (needs intervention) vs a green 'N awaiting' badge
(a functioning control waiting on a human) — done_not_closed can never
render as a bottleneck.

Verified against the live estate: STAGE 05 shows 2 stuck (cobalt-dingo#30
ceiling_hit, cad-atlas#10 near_attempt_cap), STAGE 08 shows 5 awaiting
(cobalt-dingo #3/#7/#12/#16/#19 done_not_closed) — screenshotted via a
koala k3s Playwright job against a live-token server, matching cad#14's own
proven query output exactly.

go.mod: added git.d-ma.be/mathias/cad v0.10.0 (app/domain/adapters-gitearead
only — no mcp-chassis pulled in, that's only imported by cad's own cmd/cad).
Dockerfile: added the same GOPRIVATE/insteadOf fetch config mathias/cad's
own Dockerfile uses, now needed for this new private-module dependency.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-28 13:27:02 +02:00
mathiasandClaude Opus 5 4ab65a3b1b ci(deploy): refuse to patch a manifest with an empty IMAGE_TAG (infra#311)
CD / Detect unsubstituted template (push) Successful in 0s
CD / var-go/oath (push) Skipped
CD / Lint / Test / Vet (push) Successful in 6s
CD / Build & Import (push) Successful in 12s
CD / Deploy via GitOps (push) Successful in 1s
On 2026-08-25 a deploy job ran with an empty IMAGE_TAG, wrote
`image: localhost:5000/gitea-mcp:` into the infra repo and pushed it.
That is invalid YAML, so Flux failed the ENTIRE apps Kustomization --
every app stopped reconciling for about an hour, including three
unrelated deploys queued behind it.

This job's existing check could not catch it. It greps for
`image: <repo>:${IMAGE_TAG}` after the sed, and with IMAGE_TAG empty that
pattern matches the corrupted line itself, so the verification passes
because of the bug rather than despite it.

Asserting the input up front is the fix: an empty tag now fails the job
before anything is written, let alone committed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011PL5XcG68SvQqz3vGVbWbf
2026-08-25 17:25:24 +02:00
mathiasandClaude Opus 5 c35c9699d4 ci: call the shared build-image script instead of an inline copy (infra#309)
CD / Detect unsubstituted template (push) Successful in 1s
CD / var-go/oath (push) Skipped
CD / Lint / Test / Vet (push) Successful in 6s
CD / Build & Import (push) Successful in 6s
CD / Deploy via GitOps (push) Successful in 0s
The buildctl/skopeo step here was one of nineteen near-identical copies
across the estate. That duplication is why moving them off rootless
buildah (infra#241) took eleven commits and then had to be rediscovered
in both repo templates and the gitea-ci skill.

The implementation now lives in .gitea/build-image.sh, byte-identical to
mathias/infra's ci-templates/build-image.sh and held there by a drift
gate. What used to be textual differences between copies -- labels, tags,
build-args, build target -- are environment inputs.

Behaviour is unchanged: same registry, same tags, same labels, same
buildctl-against-host-buildkitd path.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011PL5XcG68SvQqz3vGVbWbf
2026-08-25 11:29:53 +02:00
mathiasandClaude Opus 5 1fdd7d2e3d ci: build via buildctl instead of rootless buildah (infra#241)
CD / Detect unsubstituted template (push) Successful in 0s
CD / var-go/oath (push) Skipped
CD / Lint / Test / Vet (push) Successful in 5s
CD / Build & Import (push) Successful in 5s
CD / Deploy via GitOps (push) Successful in 0s
cad-atlas is the eighth repo to hit this wall, and it only surfaced now because
the build job had been unreachable behind a failing test job:

  error running container: from  creating container for
  [/bin/sh -c apk add --no-cache git]: : exec: no command

Root cause is the gitea-runner pod's securityContext, not this repo: it drops
ALL capabilities and sets allowPrivilegeEscalation:false, so newuidmap and
newgidmap cannot remap uids and rootless buildah gets a single non-remapped
mapping — not enough to set up a RUN step's user namespace.

buildctl talks to koala's host buildkitd over the socket the runner pod already
mounts, so the privileged layer work happens on the host and the pod keeps its
minimal posture. Same migration dispatch, tapir, brain-ingest-ntfy, parallax,
macro-research, cobalt-dingo, agentsquad (infra#233) and segment-embedder
(infra#243) have already made.

Note --opt build-arg:VERSION replaces buildah's --build-arg; the version label
this repo stamps into its binary depends on it, and dropping it silently would
have produced an image reporting the wrong version.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011PL5XcG68SvQqz3vGVbWbf
2026-08-19 13:38:54 +02:00
mathiasandClaude Opus 5 99fb969d27 fix(test): stop asserting CI job names that the live overlay legitimately replaces
CD / Detect unsubstituted template (push) Successful in 0s
CD / var-go/oath (push) Skipped
CD / Lint / Test / Vet (push) Successful in 8s
CD / Build & Import (push) Failing after 2s
CD / Deploy via GitOps (push) Skipped
TestAtlasJSON_ServesAtlasWithGeneratedCIStage demanded STAGE 06 contain the
job names parsed from the embedded cd.yml (guard/check/build/deploy). But when
the forge is reachable, the handler deliberately REPLACES those nodes with the
latest real run — live per-job status, which is the whole point of that overlay.

So the test passed on a laptop with no route to Gitea and failed inside CI,
where the overlay works as designed:

  CI stage missing generated job "guard"
  (got map[Detect unsubstituted template:true Lint / Test / Vet:true
           ▶ run #62 · running:true])

It went red on a commit that touched only atlas.json — a test that depends on
whether the network is reachable will eventually fail on someone innocent, and
did.

The endpoint test now asserts the shape it serves — STAGE 06 exists, is
populated by whichever source won, and every node has a title. The
deterministic assertion already exists where it belongs, in
TestBuild_OverlaysCIStageNodesFromWorkflow, which feeds Build a fixed workflow
and never touches the network.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011PL5XcG68SvQqz3vGVbWbf
2026-08-19 13:26:28 +02:00
mathiasandClaude Opus 5 294d9e2fc5 feat(atlas): stage 05 reflects the dispatch execution path
CD / Detect unsubstituted template (push) Successful in 1s
CD / var-go/oath (push) Skipped
CD / Lint / Test / Vet (push) Failing after 6s
CD / Build & Import (push) Skipped
CD / Deploy via GitOps (push) Skipped
The atlas described stage 05 as agentsquad only, while headless single-issue
execution has run through mathias/dispatch for months and gained substantial
machinery in the 2026-08-17→19 session. Four nodes added, and the stage now
names both paths rather than one.

- dispatch · headless issue runs — the 1-minute poll over allow-listed repos,
  and the rule that CI-green on the pushed commit is the only done-signal.
- Two independent executors — claude or kimi (berget/Kimi-K2.6 via LiteLLM,
  EU-metered), routed per issue. Marked as a win, and as risk-bearing: it exists
  because a single provider's empty account stopped all CAD for six weeks.
- Fail-closed tool policy — one policy shared by both executors, writes confined
  to the checkout (symlink-resolved after an escape was found live), per-run
  turn and USD ceilings.
- Read-only PR review — status:review, no write tools, and the grounding rule
  that a review citing no changed file is dropped rather than published.

Deliberately NOT added: the risk-tiered agentic review with ntfy escalation.
That is designed (dispatch#48) and not built, and the atlas is a map of what
runs, not of what is planned.

Every node carries the plain-language layer the build test enforces.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011PL5XcG68SvQqz3vGVbWbf
2026-08-19 12:00:17 +02:00
mathias 6967d12d1d feat(atlas): weight replay pacing on CI/CD stages by real job durations (#5)
CD / Detect unsubstituted template (push) Successful in 1s
CD / Lint / Test / Vet (push) Successful in 6s
CD / var-go/oath (push) Has been skipped
CD / Build & Import (push) Successful in 18s
CD / Deploy via GitOps (push) Has been skipped
TDD: Job.Seconds() + StageSeconds() derive real CI/CD dwell time from the
latest run's per-job created_at/updated_at (last job in pipeline order =
deploy/stage 07, everything before it = CI/stage 06).

Frontend: weightedSpineDist() redistributes the pixel-time-budget the
06/07 segments already had, splitting it by real CI:CD duration ratio
instead of raw pixel width. Falls back to the exact prior constant-speed
sweep when no run data is available (weights default to segment pixel
length) or when a job is skipped (0 duration) — no behavior change for
stages 00-05/08, which still have no live timing source (same gap as #5's
ledger item).

Verified: real duration values flow through /api/atlas.json (ci_duration_s:
18 observed against a real run), full page screenshot confirms no visual
regression.
2026-07-20 23:22:53 +02:00
mathias b2761a4747 feat(gitea): surface mathias's own open Gitea issues on stage 03 (#4)
CD / Detect unsubstituted template (push) Successful in 1s
CD / Lint / Test / Vet (push) Successful in 5s
CD / var-go/oath (push) Has been skipped
CD / Build & Import (push) Successful in 12s
CD / Deploy via GitOps (push) Has been skipped
TDD: IssueNodes parses /repos/issues/search into stage nodes (title, repo
tag, clickable html_url). gitea.MyIssues() reads GITEA_TOKEN and skips
gracefully when unset, mirroring the existing liveOverlay fallback pattern.

Single-operator homelab, not per-visitor OAuth: a static read-only PAT
gates on "cleared Authentik forward-auth", not per-user token exchange —
see #4 discussion. GITEA_TOKEN provisioning in infra (ExternalSecret) is
a separate follow-up; without it the overlay is inert (no crash, just no
live nodes), so this ships safely ahead of that wiring.

Nodes with a url now render as clickable <a class="node"> instead of
<div class="node">.
2026-07-20 23:04:44 +02:00
mathias 68ae01cb75 docs(project): drop stale branch-protection caveat, closed by #8
CD / Detect unsubstituted template (push) Successful in 1s
CD / Lint / Test / Vet (push) Successful in 6s
CD / var-go/oath (push) Has been skipped
CD / Build & Import (push) Successful in 14s
CD / Deploy via GitOps (push) Successful in 1s
2026-07-20 20:53:18 +00:00
mathias 2f5fca8513 docs(oath): mark S3 enforced now that branch protection requires var-go/oath (closes #8)
CD / Detect unsubstituted template (push) Successful in 1s
CD / Lint / Test / Vet (push) Successful in 6s
CD / var-go/oath (push) Has been skipped
CD / Build & Import (push) Successful in 14s
CD / Deploy via GitOps (push) Successful in 1s
2026-07-20 20:51:35 +00:00
mathiasandClaude Sonnet 5 805b76d7c3 feat(oath): gate cad-atlas's own real candidate, not swedsl's toy stub (#8)
CD / Detect unsubstituted template (push) Successful in 0s
CD / Lint / Test / Vet (push) Successful in 5s
CD / var-go/oath (push) Has been skipped
CD / Build & Import (push) Successful in 14s
CD / Deploy via GitOps (push) Successful in 1s
oathcandidate/ is a separate Go module (mirrors swedsl's own
oath/testdata/selfcandidate pattern, keeping var-go's transitive deps
out of the deployed atlas binary) whose Build() parses the committed
.gitea/workflows/cd.yml and checks the "oath" job exists and invokes
cmd/vargo-gate. TDD: passes against the real file, fails closed on a
fixture missing the job.

Rewires the oath CI job to go-run vargo-gate from its real module path
(git.d-ma.be/mathias/swedsl/oath/cmd/vargo-gate@oath/v0.28.0, unblocked
by swedsl#35/#38) against VARGO_CANDIDATE_DIR=oathcandidate, instead of
checking out swedsl and gating its hardcoded toy fixture. Private-module
auth via a short-lived GIT_ASKPASS script (token never in argv, never
written to git config, matches act_runner's env:-block-with-secrets
gotcha).

Discovered along the way: var-go's parser needs single-line,
period-separated oath sentences with no Given/When/Then/And keyword
stripping — this repo's older oaths (incl. #1) used an unverified
multi-line keyword-prefixed style. #8's oath uses the proven format.

Still not required by branch protection pending a real-PR confirmation.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-20 14:33:17 +02:00
mathiasandClaude Sonnet 5 346037c5c8 docs(oath): correct stale Executor framing after swedsl#27 closed
CD / Detect unsubstituted template (push) Successful in 1s
CD / Lint / Test / Vet (push) Successful in 4s
CD / var-go/oath (push) Has been skipped
CD / Build & Import (push) Successful in 13s
CD / Deploy via GitOps (push) Has been skipped
swedsl#27 (var-go strategic-fit ADR) closed 2026-07-18 and killed the
Executor/Reviewer path entirely — var-go is gate-only by design, each
consuming repo supplies its own candidate. The real blocker for
enforcement here is swedsl/oath's non-importable module path
(swedsl#35), not a nonexistent Executor. Corrects PROJECT.md,
INCEPTION-OATH.md, and the cd.yml comment to match.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-20 12:16:53 +02:00
mathiasandClaude Sonnet 5 f7a0281ca2 feat(ci): wire var-go/oath gate into CI (#1)
CD / Detect unsubstituted template (push) Successful in 0s
CD / Lint / Test / Vet (push) Successful in 5s
CD / var-go/oath (push) Has been skipped
CD / Build & Import (push) Successful in 13s
CD / Deploy via GitOps (push) Has been skipped
Adds an oath job to cd.yml: on pull_request, checks out swedsl (the
vargo-gate source — its oath submodule isn't go-installable, module
path isn't a real import path) and runs cmd/vargo-gate against this
repo's linked issue, posting a var-go/oath commit status.

Deliberately NOT required by branch protection: vargo-gate's candidate
is still a hardcoded toy self-test registry (swedsl's own #9 fixture),
not a real PR-diff checker, so it fails closed against any real oath
until swedsl ships an Executor (swedsl#27). Requiring it now would
permanently block every cad-atlas PR. Disclosed in the CI config
comment, PROJECT.md, and docs/INCEPTION-OATH.md (honest-stub
discipline).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-20 12:10:25 +02:00
mathiasandClaude Opus 4.8 60cc8894a2 feat(atlas): UX — overview rail + gate glyphs + pill reconcile (#3,#4,#5)
CD / Detect unsubstituted template (push) Successful in 1s
CD / Lint / Test / Vet (push) Successful in 4s
CD / Build & Import (push) Successful in 12s
CD / Deploy via GitOps (push) Has been skipped
Closes the UX sprint's authored layer.
#3 Horizontal-scroll blindness → an always-visible "PIPELINE" overview rail:
9 colour-coded chips (00 Notice → 08 Learn) showing the whole shape at a glance,
each click-to-scroll to its stage. First-timers now see the arc + that more exists.
#4 Real gate glyph — 🔒 on the two checkpoint transitions (human @04, CI @06),
desktop spine + mobile rows.
#5 Reconcile legend with Plain: decorative node pills hidden in Plain, so colour
is reserved for live status (green/amber/coral) — exactly what the legend keys.
(#6 attached chips/grammar: the question-vs-statement label mix is meaningful —
decisions vs milestones — kept; SVG chip background skipped as low-value.)

New Stage.short (guarded by JSON validity via existing tests). build/vet/lint(0)/
test green; desktop Plain screenshot-verified.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-20 10:46:08 +02:00
31 changed files with 1525 additions and 81 deletions
+10 -3
View File
@@ -54,9 +54,16 @@ assessor-loop ledger) → `06 PR → CI` (go test/vet/lint/govulncheck + **var-g
This repo is built *through* the workflow it depicts. It is `dispatch-allow`-enabled, and its
own build increments are governed by a **var-go Oath** embedded in their spec issues (see the
Stage-03 tracking issue). Bootstrapping honesty (per swedsl honest-stub discipline): the Oath is
**defined** but `cmd/vargo-gate` is **not yet wired** into this repo's CI — until it is, the Oath
is advisory here. Wiring it is a first tracked task; disclosed in code, this doc, and CI config.
Stage-03 tracking issue). `cmd/vargo-gate` is wired into `.gitea/workflows/cd.yml`'s `oath` job —
on every pull_request it fetches the linked issue's oath and gates cad-atlas's **own real
candidate** (`oathcandidate/`, #8): it parses the committed `.gitea/workflows/cd.yml` and checks
the `oath` job actually exists and invokes `cmd/vargo-gate`, then posts the `var-go/oath` commit
status. This is a real check (TDD'd: passes on the real file, fails closed on a fixture missing
the job), not swedsl's toy self-test stub — swedsl#35 (import path) and swedsl#38 (real-candidate
subprocess gating) unblocked it. **Required by branch protection on `main`** (#8, closed
2026-07-20), confirmed holding on a real PR. Direct pushes remain allowlisted for `mathias` per
this repo's TBD convention. Disclosed in the CI config comment, this doc, and
`docs/INCEPTION-OATH.md`.
## Brain references (source of truth — `brain_get <path>`)
+121
View File
@@ -0,0 +1,121 @@
#!/usr/bin/env bash
# Build an OCI image with buildctl and push it to the local registry.
#
# CANONICAL COPY: mathias/infra ci-templates/build-image.sh (infra#309).
# Every repo carries a byte-identical copy at .gitea/build-image.sh, written by
# `task ci:sync` and held in place by the drift gate. DO NOT EDIT THE COPIES --
# edit here, re-sync, and the gate will tell you which repos still disagree.
#
# Why this exists: this step was hand-copied into a dozen repos, so migrating it
# off rootless buildah (infra#241) meant eleven near-identical commits, eleven
# verifications, and three separate rediscoveries of the same fix in the two
# repo templates and the gitea-ci skill. The differences between the copies were
# incidental -- a label here, a build-arg there -- never intentional. Those are
# inputs now, not forks of the implementation.
#
# Why buildctl and not buildah: the gitea-runner pod runs with
# `capabilities: drop [ALL]` and `allowPrivilegeEscalation: false`, which
# unconditionally blocks the setuid-root exec that rootless buildah's uid/gid
# remapping needs, and the pod has no OCI runtime at all. buildctl is a thin
# client -- it hands the build to the host's buildkitd over a socket, so the
# privileged layer work happens on the host and this pod's securityContext is
# not in the picture. buildah is absent from the runner image as of baked-v14
# and its absence is asserted at pod start (infra#308).
#
# Inputs, all via environment:
#
# IMAGE (required) image name, e.g. cobalt-dingo
# REGISTRY default localhost:5000
# SHA_TAG default `git rev-parse --short HEAD`
# VERSION_TAG optional extra tag, e.g. v1.2.3 on a tag build
# PUSH_LATEST default 1; set 0 to skip the :latest tag
# CONTEXT default . -- build context directory
# DOCKERFILE_DIR default $CONTEXT -- directory holding the Dockerfile
# DOCKERFILE_NAME optional, when the file is not named `Dockerfile`
# TARGET optional, multi-stage build target (--opt target=)
# BUILD_ARGS optional, space-separated K=V pairs
# BUILD_SECRETS optional, space-separated buildkit secret specs, each
# `id=NAME,src=PATH`. Pass the SPEC only -- the caller
# writes the secret to PATH itself. A secret VALUE must
# never be passed here: it would land in this script's
# argv and therefore in the CI log.
# EXTRA_LABELS optional, space-separated K=V pairs
# GIT_SHA optional, stamped as org.opencontainers.image.revision
# IMAGE_SOURCE optional, stamped as org.opencontainers.image.source
#
# Note on shell style below: every conditional is a full `if` block, never
# `[ -n "$X" ] && cmd`. Under `set -e` the && form exits the script whenever the
# test is false, which is a real bug this estate has already shipped once.
set -euo pipefail
if [ -z "${IMAGE:-}" ]; then
echo "build-image: IMAGE is required" >&2
exit 2
fi
REGISTRY="${REGISTRY:-localhost:5000}"
BUILDKIT_HOST="${BUILDKIT_HOST:-unix:///run/buildkit/buildkitd.sock}"
CONTEXT="${CONTEXT:-.}"
DOCKERFILE_DIR="${DOCKERFILE_DIR:-$CONTEXT}"
PUSH_LATEST="${PUSH_LATEST:-1}"
VERSION_TAG="${VERSION_TAG:-}"
if [ -z "${SHA_TAG:-}" ]; then
SHA_TAG="$(git rev-parse --short HEAD)"
fi
REF="${REGISTRY}/${IMAGE}:${SHA_TAG}"
TAR_FILE="$(mktemp)"
trap 'rm -f "$TAR_FILE"' EXIT
args=(
--frontend dockerfile.v0
--local "context=${CONTEXT}"
--local "dockerfile=${DOCKERFILE_DIR}"
)
if [ -n "${DOCKERFILE_NAME:-}" ]; then
args+=(--opt "filename=${DOCKERFILE_NAME}")
fi
if [ -n "${TARGET:-}" ]; then
args+=(--opt "target=${TARGET}")
fi
for spec in ${BUILD_SECRETS:-}; do
# Opaque id=NAME,src=PATH -- buildkit reads the file, nothing is echoed.
args+=(--secret "${spec}")
done
if [ -n "${GIT_SHA:-}" ]; then
args+=(--opt "label:org.opencontainers.image.revision=${GIT_SHA}")
fi
if [ -n "${IMAGE_SOURCE:-}" ]; then
args+=(--opt "label:org.opencontainers.image.source=${IMAGE_SOURCE}")
fi
for kv in ${BUILD_ARGS:-}; do
args+=(--opt "build-arg:${kv}")
done
for kv in ${EXTRA_LABELS:-}; do
args+=(--opt "label:${kv}")
done
echo "build-image: building ${REF}"
buildctl --addr "$BUILDKIT_HOST" build "${args[@]}" --output "type=oci,dest=${TAR_FILE}"
# buildctl has no registry-push equivalent to `buildah push`, so skopeo copies
# the OCI archive up -- once per tag, from the same archive, so every tag points
# at bit-identical content.
push_tag() {
echo "build-image: pushing ${1}"
skopeo copy --dest-tls-verify=false "oci-archive:${TAR_FILE}" "docker://${1}"
}
push_tag "$REF"
if [ "$PUSH_LATEST" = "1" ]; then
push_tag "${REGISTRY}/${IMAGE}:latest"
fi
if [ -n "$VERSION_TAG" ]; then
push_tag "${REGISTRY}/${IMAGE}:${VERSION_TAG}"
fi
echo "✓ build-image: ${REF} pushed"
+101 -11
View File
@@ -53,6 +53,74 @@ jobs:
- name: Run checks
run: task check
- name: oathcandidate module — vet + test (private dep, short-lived askpass)
working-directory: oathcandidate
run: |
set -euo pipefail
export DMABE_GITEA_API_TOKEN='${{ secrets.DMABE_GITEA_API_TOKEN }}'
ASKPASS=$(mktemp)
{ echo '#!/bin/sh'
echo 'case "$1" in'
echo ' *Username*) echo oauth2 ;;'
echo ' *) echo "$DMABE_GITEA_API_TOKEN" ;;'
echo 'esac'
} > "$ASKPASS"
chmod 700 "$ASKPASS"
export GIT_ASKPASS="$ASKPASS" GIT_TERMINAL_PROMPT=0 GOPRIVATE=git.d-ma.be
go vet ./...
go test ./...
rm -f "$ASKPASS"
oath:
name: var-go/oath
needs: guard
# cad-atlas's own real candidate (#8): oathcandidate/ parses the committed
# .gitea/workflows/cd.yml and gates it against cad-atlas#8's oath — replacing the
# earlier wiring-only proof (#1) that always gated swedsl's toy self-test fixture
# and always failed closed. cmd/vargo-gate (swedsl#35/#37/#38) now go-installs
# cleanly from its real module path and runs the candidate module in a sandboxed
# subprocess (SubprocessGate, ADR-0003) — a green status here means "the committed
# CI config satisfies its oath", not merely "the wiring ran". Still NOT required by
# branch protection (#8) until proven green on a real PR.
if: needs.guard.outputs.is_template != 'true' && github.event_name == 'pull_request'
runs-on: self-hosted
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version-file: oathcandidate/go.mod
cache: false
- name: Run vargo-gate (fetch linked oath -> sandboxed-gate the real candidate -> post status)
env:
VARGO_GITEA_BASEURL: ${{ github.server_url }}
VARGO_GITEA_OWNER: ${{ github.repository_owner }}
VARGO_GITEA_REPO: cad-atlas
# Oath issue resolution (swedsl#38): a "Closes #NN" reference in the PR body
# picks the linked oath issue; VARGO_GITEA_ISSUE is the fallback (PR's own
# number, correct only for a PR filed directly against its oath issue).
VARGO_PR_BODY: ${{ github.event.pull_request.body }}
VARGO_GITEA_ISSUE: ${{ github.event.pull_request.number }}
VARGO_GITEA_SHA: ${{ github.event.pull_request.head.sha }}
VARGO_CANDIDATE_DIR: oathcandidate
# Sandbox is ON by default (untrusted PR code runs in a fresh user+net
# namespace, swedsl#37); no need to set VARGO_SANDBOX here.
run: |
set -euo pipefail
export DMABE_GITEA_API_TOKEN='${{ secrets.DMABE_GITEA_API_TOKEN }}'
ASKPASS=$(mktemp)
{ echo '#!/bin/sh'
echo 'case "$1" in'
echo ' *Username*) echo oauth2 ;;'
echo ' *) echo "$DMABE_GITEA_API_TOKEN" ;;'
echo 'esac'
} > "$ASKPASS"
chmod 700 "$ASKPASS"
export GIT_ASKPASS="$ASKPASS" GIT_TERMINAL_PROMPT=0 GOPRIVATE=git.d-ma.be
go run git.d-ma.be/mathias/swedsl/oath/cmd/vargo-gate@oath/v0.28.0
rm -f "$ASKPASS"
build:
name: Build & Import
needs: [guard, check]
@@ -74,18 +142,33 @@ jobs:
echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
- name: Build and push to local registry
# infra#309: implementation lives in .gitea/build-image.sh, a
# byte-identical copy of mathias/infra's ci-templates/build-image.sh.
# Do not edit it here -- edit the canonical file, run `task ci:sync`,
# and infra's drift gate names any repo still carrying an old copy.
#
# cad-atlas#11: the build now fetches git.d-ma.be/mathias/cad, a
# PRIVATE module (unlike mcp-chassis, the only other internal module
# any cad-* repo's Dockerfile has fetched so far, which is public) --
# `go mod download` inside the Dockerfile needs to authenticate. The
# token is written to a file ONLY for the build-image.sh subprocess and
# passed as a BuildKit secret (build-image.sh's existing BUILD_SECRETS
# mechanism: "the caller writes the secret to PATH itself... a secret
# VALUE must never be passed here"), never as a build-arg or env var
# that would land in the image or this log.
env:
SHA_TAG: ${{ steps.meta.outputs.sha-tag }}
GIT_SHA: ${{ github.sha }}
BUILD_ARGS: VERSION=${{ steps.meta.outputs.version }}
DMABE_GITEA_API_TOKEN: ${{ secrets.DMABE_GITEA_API_TOKEN }}
run: |
REGISTRY="localhost:5000"
REF="${REGISTRY}/${{ env.IMAGE }}:${{ steps.meta.outputs.sha-tag }}"
buildah build \
--build-arg VERSION="${{ steps.meta.outputs.version }}" \
--label "org.opencontainers.image.revision=${{ github.sha }}" \
-t ${REF} \
-t ${REGISTRY}/${{ env.IMAGE }}:latest \
.
buildah push --tls-verify=false ${REF}
buildah push --tls-verify=false ${REGISTRY}/${{ env.IMAGE }}:latest
echo "✓ Image pushed to ${REF}"
set -euo pipefail
TOKEN_FILE=$(mktemp)
printf '%s' "$DMABE_GITEA_API_TOKEN" > "$TOKEN_FILE"
chmod 600 "$TOKEN_FILE"
export BUILD_SECRETS="id=gitea_token,src=${TOKEN_FILE}"
bash .gitea/build-image.sh
shred -u "$TOKEN_FILE"
deploy:
name: Deploy via GitOps
@@ -99,6 +182,13 @@ jobs:
DEPLOY_KEY: ${{ secrets.INFRA_DEPLOY_KEY }}
run: |
set -euo pipefail
# infra#311: fail on an empty tag BEFORE patching anything. The
# grep verification further down CANNOT catch it -- with IMAGE_TAG
# empty its pattern matches the very corruption it exists to reject.
# The result is `image: <repo>:` in the manifest, which is invalid
# YAML, so Flux fails the ENTIRE apps Kustomization and every app
# stops reconciling, not just this one. That happened on 2026-08-25.
[ -n "${IMAGE_TAG:-}" ] || { echo "IMAGE_TAG is empty - refusing to patch"; exit 1; }
mkdir -p ~/.ssh
echo "$DEPLOY_KEY" > ~/.ssh/id_infra
chmod 600 ~/.ssh/id_infra
+29 -3
View File
@@ -1,9 +1,35 @@
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).
#
# UNLIKE mcp-chassis (public, what cad's own Dockerfile fetches this way),
# mathias/cad is a PRIVATE repo, so the plain insteadOf rewrite above is not
# enough — `go mod download` needs to authenticate. Use a GIT_ASKPASS helper
# reading a BuildKit secret mount (mirrors the GIT_ASKPASS pattern this
# repo's own cd.yml already uses for the oathcandidate module's private
# swedsl dependency): the token is never written to a persisted layer — the
# askpass script and its reference to /run/secrets/gitea_token are created
# and removed within the same RUN instruction, and the secret mount itself
# is never part of any layer diff. Caller passes it via
# `BUILD_SECRETS="id=gitea_token,src=<path>"` (build-image.sh's existing
# mechanism) — see cd.yml.
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 ./
RUN go mod download
COPY go.mod go.sum ./
RUN --mount=type=secret,id=gitea_token \
printf '#!/bin/sh\ncase "$1" in *sername*) echo oauth2 ;; *) cat /run/secrets/gitea_token ;; esac\n' > /tmp/askpass.sh && \
chmod 700 /tmp/askpass.sh && \
GIT_ASKPASS=/tmp/askpass.sh GIT_TERMINAL_PROMPT=0 go mod download && \
rm -f /tmp/askpass.sh
COPY . .
ARG VERSION=dev
RUN templ generate && CGO_ENABLED=0 go build -trimpath \
+13 -7
View File
@@ -3,8 +3,10 @@
The acceptance contract for standing up cad-atlas. The sprint is finalized only when this
Oath holds. Methodology: brain `wiki/homelab/decisions/inception-sprint-and-oath.md`.
> **Status of enforcement:** this Oath is currently **advisory** (human-verified). Machine
> enforcement via `var-go/oath` is deferred — see the honesty rule below and issue #1.
> **Status of enforcement:** `var-go/oath` gates a real candidate (`oathcandidate/`, #8 — parses
> the committed CI workflow, TDD'd pass/fail-closed) and is now **required by branch protection**
> on `main` (verified green on a real PR). Direct pushes remain allowlisted for `mathias` per this
> repo's TBD convention.
## General clauses (any inception sprint)
@@ -24,7 +26,7 @@ Oath holds. Methodology: brain `wiki/homelab/decisions/inception-sprint-and-oath
|---|--------|--------|----------|
| S1 | Atlas served at `/`, renders all 9 stages signal→pod | ✅ | `internal/web/handler.go` + `static/cad-atlas.html` |
| S2 | Oath covered in the viz (stages 03 + 06) | ✅ | var-go Oath nodes in the atlas |
| S3 | `var-go/oath` enforces cad-atlas's own PRs | ⏸ **deferred → #1** | var-go v1 candidate is a toy self-test; module not cross-repo consumable. See honesty rule. |
| S3 | `var-go/oath` enforces cad-atlas's own PRs | ✅ | `oathcandidate/` gates the real `.gitea/workflows/cd.yml` (TDD green: passes real file, fails closed on a fixture missing the job) via swedsl's sandboxed `SubprocessGate` (swedsl#35/#38). Branch protection on `main` now requires `var-go/oath`, confirmed holding on a real PR (#8). |
## Deployment
@@ -36,10 +38,14 @@ namespace `cad-atlas`, 1 replica, `cad-atlas:80 → :8080` (manifests in `mathia
## The honesty rule
A clause blocked by an external dependency is **descoped and tracked, never marked satisfied** —
a self-lying Oath is a rubber stamp, the exact failure the Oath exists to prevent. S3's real
enforcement depends on a var-go Executor (swedsl#27) + a published `oath` module; it is tracked as
a fast-follow on **#1**, not claimed here. The `DMABE_GITEA_API_TOKEN` Actions secret is
pre-provisioned so #1 can land without a secret-write.
a self-lying Oath is a rubber stamp, the exact failure the Oath exists to prevent. S3 is now fully
enforced: real candidate wired and branch-protection-required (#8), confirmed on a real PR. The
`DMABE_GITEA_API_TOKEN` Actions secret is pre-provisioned so #1 and #8 both landed without a
secret-write.
Also surfaced by #8: this file's own "Oath (advisory form)" below predates the discovery that
var-go's parser requires single-line, period-separated sentences with no `Given`/`Then`/`And`
keyword stripping — it has never been machine-gated and would need reformatting first if it ever is.
## The Oath (advisory form)
+3 -1
View File
@@ -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
+2
View File
@@ -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=
+14 -10
View File
@@ -7,49 +7,53 @@
],
"ns": "Tailscale mesh · ns: ai-stack · supervisor(→brain) · gitea-mcp · infra-mcp · council",
"stages": [
{"no":"STAGE 00","title":"Signals","plain_title":"Notice what's happening","plain":"New ideas and developments worth reacting to are collected — mostly an automated daily/weekly scan of AI news, plus things saved by hand.","path":"→ mathias/signals",
{"no":"STAGE 00","short":"Notice","title":"Signals","plain_title":"Notice what's happening","plain":"New ideas and developments worth reacting to are collected — mostly an automated daily/weekly scan of AI news, plus things saved by hand.","path":"→ mathias/signals",
"trans_label":"Does it matter to us?","trans":"A raw signal only advances if it connects to something we actually care about. Most captured signals stop here; the few that touch the mission get pulled up against a goal.","nodes":[
{"t":"Applied AI Radar","plain_t":"Automated news scan","plain":"An automated scan reads AI news every day (and deeper every week) and keeps only claims backed by a real paper, benchmark, code, or named lab.","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","plain_t":"Saved by hand","plain":"Anything interesting spotted by hand gets saved into the same inbox.","d":"claude.ai strategic drop · brain capture tool.","tags":["ad-hoc"]},
{"t":"Aspirational surfaces","plain_t":"Not built yet","plain":"Planned but not built yet: sending ideas in by Telegram, voice, or a URL.","pill":"var(--dim)","d":"Telegram / voice / URL → inbox. NOT built.","tags":["gap"]}
]},
{"no":"STAGE 01","cls":"telos","title":"TELOS","plain_title":"Why we're here","plain":"The mission, goals, and problems we're actually trying to solve live here — every piece of work downstream has to trace back to one of these goals.","path":"wiki/telos/",
{"no":"STAGE 01","short":"Why","cls":"telos","title":"TELOS","plain_title":"Why we're here","plain":"The mission, goals, and problems we're actually trying to solve live here — every piece of work downstream has to trace back to one of these goals.","path":"wiki/telos/",
"trans_label":"Worth a session?","trans":"A goal or problem on the board becomes the seed for a design session when it's decided worth working on now. The goal is the input the session must trace back to.","nodes":[
{"t":"Intention substrate","plain_t":"The goal board","plain":"The master list of mission, goals, problems, and current status — the yardstick everything downstream is measured against.","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","plain_title":"Think it through","plain":"A human and AI models work out what to do and why, debating hard calls and writing down the decision and what \"done\" will mean.","path":"claude.ai frontier + brain MCP",
{"no":"STAGE 02","short":"Think","title":"Strategic session","plain_title":"Think it through","plain":"A human and AI models work out what to do and why, debating hard calls and writing down the decision and what \"done\" will mean.","path":"claude.ai frontier + brain MCP",
"trans_label":"Decision reached","trans":"It advances only when the thinking converges on a decision and is written down as a concrete, testable specification — not while the answer is still open.","nodes":[
{"t":"Design · ADRs · specs","plain_t":"Decide the approach","plain":"A human and a top-tier AI model figure out the approach and write down the decision plus what a finished result must prove.","d":"Human + frontier model. ISC acceptance criteria written here.","tags":["Define / converge"]},
{"t":"🏛️ LLM Council","plain_t":"AI review panel","plain":"For hard calls, several AI models answer independently, anonymously critique each other, and a \"chair\" model synthesises one verdict — reducing any single model's bias.","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","plain_t":"Research review panel","plain":"A parallel version of the same review that vets research findings before they're allowed through.","cls":"council","pill":"var(--violet)","d":"Sibling pipe — ratifies research before the gate.","tags":["proposed: → standalone svc"]}
]},
{"no":"STAGE 03","title":"Spec → Gitea issue","plain_title":"Write the work order","plain":"The decision is turned into a precise, self-contained work order an AI agent can execute unsupervised — with a pass/fail definition of done, a risk rating, and a tamper-proof seal.","path":"agent-ready contract",
{"no":"STAGE 03","short":"Write order","title":"Spec → Gitea issue","plain_title":"Write the work order","plain":"The decision is turned into a precise, self-contained work order an AI agent can execute unsupervised — with a pass/fail definition of done, a risk rating, and a tamper-proof seal.","path":"agent-ready contract","generate":"gitea-issues",
"trans_label":"Sealed & agent-ready","trans":"Advances to the gate only when the spec is a complete contract: a pass/fail test, a risk tier, a regulatory note, no open human dependencies, one embedded Oath, and a valid cryptographic signature. A malformed or unsigned order fails closed and never reaches the gate.","nodes":[
{"t":"Contract enforced","plain_t":"The work-order rules","plain":"The work order must have a clear pass/fail test, a risk rating, a regulatory-risk note, and no unfinished human dependencies before it counts as agent-ready.","d":"Binary ISC · declared risk tier · reg-risk assessment · no open human deps.","tags":["LOW / MED / HIGH"]},
{"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","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","cls":"exec","title":"Execute · agentsquad","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.","path":"koala · cmd/agentsquad-serve",
{"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","gitea-repos"],
"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},
{"t":"dma-cli · routing + scope","plain_t":"Router & guardrails","plain":"A router sends each agent to the right AI backend and enforces what it is and isn't allowed to touch, with a confirmation gate as a guardrail.","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","plain_t":"Audit log","plain":"Every step is recorded in a tamper-evident log so the whole run can be audited afterwards.","d":"Attestation ledger (audit trail) + brain session_log on completion.","tags":["audit package"]}
{"t":"assessor-loop ledger","plain_t":"Audit log","plain":"Every step is recorded in a tamper-evident log so the whole run can be audited afterwards.","d":"Attestation ledger (audit trail) + brain session_log on completion.","tags":["audit package"]},
{"t":"dispatch · headless issue runs","plain_t":"Picking up work by itself","plain":"A watcher checks the tracker every minute for issues marked ready, and runs one agent per issue on its own — no human starts it. Whether the work is really done is decided by the automated checks, never by the agent saying so.","pill":"var(--coral)","d":"CronJob polls status:dispatch in allow-listed repos (.dispatch-allow marker, fail-closed). Claims, runs, reports; CI-green on the pushed commit is the only done-signal.","tags":["1-min poll","CI is ground truth"]},
{"t":"Two independent executors","plain_t":"Two different AI providers","plain":"The same job can be run by either of two different AI providers, chosen per issue. When one is unavailable — an empty account stopped everything for six weeks — the other still works.","cls":"win","pill":"var(--blue)","d":"executor:&lt;name&gt; label routes to claude (Anthropic SDK runner) or kimi (berget/Kimi-K2.6 via LiteLLM, EU-metered, Go-native loop). Provider independence after a 6-week single-provider outage.","tags":["executor: label","EU option"],"risk":true},
{"t":"Fail-closed tool policy","plain_t":"What an agent may touch","plain":"Each agent may only use an approved short list of tools and commands; anything else is refused outright rather than asked about, since no human is watching. Its file access cannot leave its own copy of the repo, and each run has a hard limit on both steps and money spent.","cls":"gateway","pill":"var(--amber)","d":"One executor.Policy for both executors: tool + bash-prefix allowlist, closed env-prefix list, exact MCP tool names. Writes confined to the checkout (symlink-resolved, O_NOFOLLOW). Per-run turn and USD ceilings; spend ledgered against a rolling cap.","tags":["deny by default","per-run ceilings"]},
{"t":"Read-only PR review","plain_t":"A second agent reads the change","plain":"A pull request can be handed to an agent that may only read and comment, never edit. Its review is thrown away unless it points at a file the change actually touched — which catches a reviewer that skimmed the title instead of the code. New commits re-open the review by themselves.","pill":"var(--coral)","d":"status:review → read-only tools (no write, no shell). Published only if grounded: must cite a file in the PR's changed-files list. Handling anchored to the head sha, so a push re-opens it; attempts capped per revision.","tags":["grounded or dropped","re-opens on push"]}
]},
{"no":"STAGE 06","title":"PR → CI","plain_title":"Automatic quality checks","plain":"The proposed change is run through automated tests and safety checks — including a check that it actually satisfies the work order's definition of done — and only a clean pass lets it continue.","path":"Gitea Actions · cd.yml (live)","generate":"ci-jobs",
{"no":"STAGE 06","short":"Check","title":"PR → CI","plain_title":"Automatic quality checks","plain":"The proposed change is run through automated tests and safety checks — including a check that it actually satisfies the work order's definition of done — and only a clean pass lets it continue.","path":"Gitea Actions · cd.yml (live)","generate":"ci-jobs",
"trans_label":"All checks green","trans":"Advances only if every automated check passes — tests, linters, security scan, and the Oath check proving it meets the original work order. Any red gate stops it here; a passing reviewer is not enough to override a failed Oath.","nodes":[
{"t":"Automated checks","plain_t":"Quality checks","plain":"Tests, linters, a security scan, plus a check that the change actually meets the work order — all must pass to continue.","d":"go test · vet · lint · govulncheck + var-go/oath gate.","tags":["green = proceed"]}
]},
{"no":"STAGE 07","cls":"cd","title":"CD → pod","plain_title":"Ship it","plain":"Once everything is green, the change is deployed automatically to the live server — with the rule that merging code alone doesn't ship it; the release has to be pointed at the new version.","path":"Flux GitOps → k3s","generate":"deploy-state",
{"no":"STAGE 07","short":"Ship","cls":"cd","title":"CD → pod","plain_title":"Ship it","plain":"Once everything is green, the change is deployed automatically to the live server — with the rule that merging code alone doesn't ship it; the release has to be pointed at the new version.","path":"Flux GitOps → k3s","generate":"deploy-state",
"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","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"]}
]}
+1 -1
View File
@@ -53,7 +53,7 @@ func TestDefault_HasPlainLayerAndTransitions(t *testing.T) {
if s.TransLabel == "" {
t.Fatalf("stage %s missing trans_label", s.No)
}
if s.Generate == "ci-jobs" {
if s.Generate.Has("ci-jobs") {
continue // nodes are generated live, no authored plain
}
for _, n := range s.Nodes {
+63
View File
@@ -0,0 +1,63 @@
package atlas
import (
"bytes"
"encoding/json"
"fmt"
)
// GenerateKeys names the live sources a stage draws nodes from. A stage grew
// past one source once stuck-badges landed alongside the existing overlays, so
// this accepts either spelling in atlas.json:
//
// "generate": "ci-jobs"
// "generate": ["stuck-badges", "gitea-repos"]
//
// The bare string is kept rather than migrated: most stages have exactly one
// source, and a one-element list reads worse for no benefit.
type GenerateKeys []string
// Has reports whether the stage draws from the named source. The empty key
// never matches, so a stage with no `generate` is inert.
func (g GenerateKeys) Has(key string) bool {
if key == "" {
return false
}
for _, k := range g {
if k == key {
return true
}
}
return false
}
func (g *GenerateKeys) UnmarshalJSON(b []byte) error {
b = bytes.TrimSpace(b)
if len(b) == 0 || string(b) == "null" {
*g = nil
return nil
}
if b[0] == '"' {
var one string
if err := json.Unmarshal(b, &one); err != nil {
return fmt.Errorf("parse generate: %w", err)
}
*g = GenerateKeys{one}
return nil
}
var many []string
if err := json.Unmarshal(b, &many); err != nil {
return fmt.Errorf("parse generate: %w", err)
}
*g = many
return nil
}
// MarshalJSON writes a lone key back as a bare string, so a round-trip through
// the API does not rewrite every authored stage into list form.
func (g GenerateKeys) MarshalJSON() ([]byte, error) {
if len(g) == 1 {
return json.Marshal(g[0])
}
return json.Marshal([]string(g))
}
+66
View File
@@ -0,0 +1,66 @@
package atlas_test
import (
"encoding/json"
"testing"
"git.d-ma.be/mathias/cad-atlas/internal/atlas"
)
// Every stage authored before this type existed spells `generate` as a bare
// string. That spelling stays valid — this is additive, not a migration.
func TestGenerateKeys_UnmarshalsBareString(t *testing.T) {
var s atlas.Stage
if err := json.Unmarshal([]byte(`{"no":"06","generate":"ci-jobs"}`), &s); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if !s.Generate.Has("ci-jobs") {
t.Fatalf("Has(ci-jobs) false for %+v", s.Generate)
}
if s.Generate.Has("gitea-repos") {
t.Fatal("Has matched a key that is not present")
}
}
func TestGenerateKeys_UnmarshalsList(t *testing.T) {
var s atlas.Stage
if err := json.Unmarshal([]byte(`{"no":"05","generate":["stuck-badges","gitea-repos"]}`), &s); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if !s.Generate.Has("stuck-badges") || !s.Generate.Has("gitea-repos") {
t.Fatalf("both keys should match, got %+v", s.Generate)
}
}
func TestGenerateKeys_AbsentMatchesNothing(t *testing.T) {
var s atlas.Stage
if err := json.Unmarshal([]byte(`{"no":"00"}`), &s); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if s.Generate.Has("") || s.Generate.Has("ci-jobs") {
t.Fatalf("absent generate matched something: %+v", s.Generate)
}
}
// The frontend reads this JSON, so the round-trip must stay stable.
func TestGenerateKeys_RoundTrips(t *testing.T) {
for _, raw := range []string{`{"generate":"ci-jobs"}`, `{"generate":["a","b"]}`} {
var s atlas.Stage
if err := json.Unmarshal([]byte(raw), &s); err != nil {
t.Fatalf("unmarshal %s: %v", raw, err)
}
out, err := json.Marshal(s)
if err != nil {
t.Fatalf("marshal: %v", err)
}
var back atlas.Stage
if err := json.Unmarshal(out, &back); err != nil {
t.Fatalf("re-unmarshal %s: %v", out, err)
}
for _, k := range []string{"ci-jobs", "a", "b"} {
if s.Generate.Has(k) != back.Generate.Has(k) {
t.Fatalf("round-trip lost %q: %s -> %s", k, raw, out)
}
}
}
}
+32
View File
@@ -0,0 +1,32 @@
package atlas
import (
"encoding/json"
"fmt"
)
// IssueNodes parses a Gitea `/repos/issues/search` response (the
// authenticated user's own open issues, newest first) into one node per
// issue, linking out to the issue.
func IssueNodes(searchJSON []byte) ([]Node, error) {
var issues []struct {
Number int `json:"number"`
Title string `json:"title"`
HTMLURL string `json:"html_url"`
Repository struct {
FullName string `json:"full_name"`
} `json:"repository"`
}
if err := json.Unmarshal(searchJSON, &issues); err != nil {
return nil, fmt.Errorf("parse issues: %w", err)
}
nodes := make([]Node, 0, len(issues))
for _, i := range issues {
nodes = append(nodes, Node{
Title: fmt.Sprintf("#%d %s", i.Number, i.Title),
Tags: []string{"live · Gitea", i.Repository.FullName},
URL: i.HTMLURL,
})
}
return nodes, nil
}
+41
View File
@@ -0,0 +1,41 @@
package atlas_test
import (
"testing"
"git.d-ma.be/mathias/cad-atlas/internal/atlas"
)
func TestIssueNodes_OneNodePerIssueWithRepoTagAndURL(t *testing.T) {
search := []byte(`[
{"number":212,"title":"segment-embedder: add smoke test","html_url":"https://git.d-ma.be/mathias/infra/issues/212","repository":{"full_name":"mathias/infra"}},
{"number":8,"title":"Write cad-atlas's own vargo-gate candidate","html_url":"https://git.d-ma.be/mathias/cad-atlas/issues/8","repository":{"full_name":"mathias/cad-atlas"}}
]`)
nodes, err := atlas.IssueNodes(search)
if err != nil {
t.Fatalf("IssueNodes: %v", err)
}
if len(nodes) != 2 {
t.Fatalf("want 2 nodes, got %d", len(nodes))
}
if nodes[0].Title != "#212 segment-embedder: add smoke test" {
t.Fatalf("title = %q", nodes[0].Title)
}
if nodes[0].URL != "https://git.d-ma.be/mathias/infra/issues/212" {
t.Fatalf("url = %q", nodes[0].URL)
}
if len(nodes[0].Tags) != 2 || nodes[0].Tags[0] != "live · Gitea" || nodes[0].Tags[1] != "mathias/infra" {
t.Fatalf("tags = %v", nodes[0].Tags)
}
}
func TestIssueNodes_EmptyListReturnsEmptyNotNil(t *testing.T) {
nodes, err := atlas.IssueNodes([]byte(`[]`))
if err != nil {
t.Fatalf("IssueNodes: %v", err)
}
if nodes == nil || len(nodes) != 0 {
t.Fatalf("nodes = %+v, want empty non-nil slice", nodes)
}
}
+26 -16
View File
@@ -23,6 +23,7 @@ type Node struct {
Tags []string `json:"tags,omitempty"`
Risk bool `json:"risk,omitempty"`
Gate bool `json:"gate,omitempty"`
URL string `json:"url,omitempty"`
}
// Stage is one column of the pipeline. PlainTitle/Plain are the plain-language
@@ -31,25 +32,34 @@ type Node struct {
// moves work forward and what must be true to advance. When Generate is set,
// Nodes are derived from a live source at Build time.
type Stage struct {
No string `json:"no"`
Title string `json:"title"`
PlainTitle string `json:"plain_title,omitempty"`
Plain string `json:"plain,omitempty"`
Path string `json:"path,omitempty"`
Cls string `json:"cls,omitempty"`
Generate string `json:"generate,omitempty"`
TransLabel string `json:"trans_label,omitempty"`
Trans string `json:"trans,omitempty"`
Nodes []Node `json:"nodes"`
No string `json:"no"`
Short string `json:"short,omitempty"`
Title string `json:"title"`
PlainTitle string `json:"plain_title,omitempty"`
Plain string `json:"plain,omitempty"`
Path string `json:"path,omitempty"`
Cls string `json:"cls,omitempty"`
Generate GenerateKeys `json:"generate,omitempty"`
TransLabel string `json:"trans_label,omitempty"`
Trans string `json:"trans,omitempty"`
// 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.
type Atlas struct {
Version string `json:"version,omitempty"`
Substrate []Host `json:"substrate"`
NS string `json:"ns,omitempty"`
Timeline []RunDot `json:"timeline,omitempty"`
Stages []Stage `json:"stages"`
Version string `json:"version,omitempty"`
Substrate []Host `json:"substrate"`
NS string `json:"ns,omitempty"`
Timeline []RunDot `json:"timeline,omitempty"`
CIDurationS float64 `json:"ci_duration_s,omitempty"`
CDDurationS float64 `json:"cd_duration_s,omitempty"`
Stages []Stage `json:"stages"`
}
// Build unmarshals the authored atlas JSON and overlays generated facts from
@@ -61,7 +71,7 @@ func Build(atlasJSON, workflow []byte) (Atlas, error) {
return Atlas{}, fmt.Errorf("parse atlas data: %w", err)
}
for i := range a.Stages {
if a.Stages[i].Generate != "ci-jobs" {
if !a.Stages[i].Generate.Has("ci-jobs") {
continue
}
jobs, err := JobsFromWorkflow(workflow)
+17
View File
@@ -0,0 +1,17 @@
package atlas
// PrependGenerated puts live nodes ahead of the authored ones on every stage
// carrying the given `generate` key. Authored nodes are kept, never replaced:
// they are the graceful-degradation path, and a live source that is
// unavailable (no token, no network) must leave the atlas rendering exactly as
// authored. Passing no nodes is therefore a no-op, not an erasure.
func PrependGenerated(a *Atlas, generate string, nodes []Node) {
if a == nil || len(nodes) == 0 {
return
}
for i := range a.Stages {
if a.Stages[i].Generate.Has(generate) {
a.Stages[i].Nodes = append(append([]Node{}, nodes...), a.Stages[i].Nodes...)
}
}
}
+66
View File
@@ -0,0 +1,66 @@
package atlas_test
import (
"testing"
"git.d-ma.be/mathias/cad-atlas/internal/atlas"
)
func stagedAtlas() *atlas.Atlas {
return &atlas.Atlas{Stages: []atlas.Stage{
{No: "STAGE 03", Generate: atlas.GenerateKeys{"gitea-issues"}, Nodes: []atlas.Node{{Title: "authored issue node"}}},
{No: "STAGE 05", Generate: atlas.GenerateKeys{"gitea-repos"}, Nodes: []atlas.Node{{Title: "authored repo node"}}},
{No: "STAGE 08", Nodes: []atlas.Node{{Title: "untouched"}}},
}}
}
func TestPrependGenerated_LiveNodesComeFirstAndAuthoredSurvive(t *testing.T) {
a := stagedAtlas()
atlas.PrependGenerated(a, "gitea-repos", []atlas.Node{{Title: "live repo"}})
got := a.Stages[1].Nodes
if len(got) != 2 {
t.Fatalf("want 2 nodes, got %d: %+v", len(got), got)
}
if got[0].Title != "live repo" || got[1].Title != "authored repo node" {
t.Fatalf("order wrong: %+v", got)
}
}
func TestPrependGenerated_OnlyTouchesMatchingStage(t *testing.T) {
a := stagedAtlas()
atlas.PrependGenerated(a, "gitea-repos", []atlas.Node{{Title: "live repo"}})
if len(a.Stages[0].Nodes) != 1 || a.Stages[0].Nodes[0].Title != "authored issue node" {
t.Fatalf("stage 03 disturbed: %+v", a.Stages[0].Nodes)
}
if len(a.Stages[2].Nodes) != 1 || a.Stages[2].Nodes[0].Title != "untouched" {
t.Fatalf("stage 08 disturbed: %+v", a.Stages[2].Nodes)
}
}
// The graceful-skip contract: with GITEA_TOKEN unset the overlay yields no
// nodes, and the atlas must still render its authored placeholders unchanged.
func TestPrependGenerated_NoLiveNodesLeavesAuthoredIntact(t *testing.T) {
a := stagedAtlas()
atlas.PrependGenerated(a, "gitea-repos", nil)
if len(a.Stages[1].Nodes) != 1 || a.Stages[1].Nodes[0].Title != "authored repo node" {
t.Fatalf("authored nodes lost on empty overlay: %+v", a.Stages[1].Nodes)
}
}
func TestPrependGenerated_UnknownKeyIsANoop(t *testing.T) {
a := stagedAtlas()
atlas.PrependGenerated(a, "no-such-key", []atlas.Node{{Title: "live"}})
for i, s := range a.Stages {
if len(s.Nodes) != 1 {
t.Fatalf("stage %d changed: %+v", i, s.Nodes)
}
}
}
+40
View File
@@ -0,0 +1,40 @@
package atlas
import (
"encoding/json"
"fmt"
)
// RepoNodes parses a Gitea `/user/repos` response (the token owner's own
// repositories) into one node per repo, linking out to the repo. Archived
// repos are dropped: the atlas shows where work happens, and an archived repo
// is by definition somewhere it does not.
func RepoNodes(listJSON []byte) ([]Node, error) {
var repos []struct {
Name string `json:"name"`
Description string `json:"description"`
HTMLURL string `json:"html_url"`
Archived bool `json:"archived"`
Language string `json:"language"`
}
if err := json.Unmarshal(listJSON, &repos); err != nil {
return nil, fmt.Errorf("parse repos: %w", err)
}
nodes := make([]Node, 0, len(repos))
for _, r := range repos {
if r.Archived {
continue
}
tags := []string{"live · Gitea"}
if r.Language != "" {
tags = append(tags, r.Language)
}
nodes = append(nodes, Node{
Title: r.Name,
Desc: r.Description,
Tags: tags,
URL: r.HTMLURL,
})
}
return nodes, nil
}
+80
View File
@@ -0,0 +1,80 @@
package atlas_test
import (
"testing"
"git.d-ma.be/mathias/cad-atlas/internal/atlas"
)
// A Gitea /user/repos response, trimmed to the fields RepoNodes reads.
const repoList = `[
{"name":"cad-atlas","full_name":"mathias/cad-atlas","description":"Visualising the CAD workflow","html_url":"https://git.d-ma.be/mathias/cad-atlas","archived":false,"language":"Go"},
{"name":"dispatch","full_name":"mathias/dispatch","description":"","html_url":"https://git.d-ma.be/mathias/dispatch","archived":false,"language":""},
{"name":"old-thing","full_name":"mathias/old-thing","description":"retired","html_url":"https://git.d-ma.be/mathias/old-thing","archived":true,"language":"Go"}
]`
func TestRepoNodes_OneNodePerRepoWithDescriptionAndURL(t *testing.T) {
nodes, err := atlas.RepoNodes([]byte(repoList))
if err != nil {
t.Fatalf("RepoNodes: %v", err)
}
if len(nodes) != 2 {
t.Fatalf("want 2 nodes (archived excluded), got %d", len(nodes))
}
if nodes[0].Title != "cad-atlas" {
t.Fatalf("title = %q, want %q", nodes[0].Title, "cad-atlas")
}
if nodes[0].URL != "https://git.d-ma.be/mathias/cad-atlas" {
t.Fatalf("url = %q", nodes[0].URL)
}
if nodes[0].Desc != "Visualising the CAD workflow" {
t.Fatalf("desc = %q", nodes[0].Desc)
}
}
// Mirrors IssueNodes' tagging so the two live slices read identically on stage.
func TestRepoNodes_TagsCarryLiveMarkerAndLanguage(t *testing.T) {
nodes, err := atlas.RepoNodes([]byte(repoList))
if err != nil {
t.Fatalf("RepoNodes: %v", err)
}
if len(nodes) < 2 {
t.Fatalf("want at least 2 nodes, got %d", len(nodes))
}
if len(nodes[0].Tags) != 2 || nodes[0].Tags[0] != "live · Gitea" || nodes[0].Tags[1] != "Go" {
t.Fatalf("tags = %v, want [live · Gitea Go]", nodes[0].Tags)
}
// A repo with no language must not emit an empty tag pill.
if len(nodes[1].Tags) != 1 || nodes[1].Tags[0] != "live · Gitea" {
t.Fatalf("tags = %v, want [live · Gitea]", nodes[1].Tags)
}
}
// Load-bearing: archived repos are explicitly out of scope per the issue.
func TestRepoNodes_ExcludesArchived(t *testing.T) {
nodes, err := atlas.RepoNodes([]byte(repoList))
if err != nil {
t.Fatalf("RepoNodes: %v", err)
}
for _, n := range nodes {
if n.Title == "old-thing" {
t.Fatalf("archived repo surfaced: %+v", n)
}
}
}
func TestRepoNodes_EmptyListReturnsEmptyNotNil(t *testing.T) {
nodes, err := atlas.RepoNodes([]byte(`[]`))
if err != nil {
t.Fatalf("RepoNodes: %v", err)
}
if nodes == nil || len(nodes) != 0 {
t.Fatalf("nodes = %+v, want empty non-nil slice", nodes)
}
}
func TestRepoNodes_MalformedJSONErrors(t *testing.T) {
if _, err := atlas.RepoNodes([]byte(`{`)); err == nil {
t.Fatal("want an error for malformed JSON, got nil")
}
}
+37 -2
View File
@@ -3,13 +3,18 @@ package atlas
import (
"encoding/json"
"fmt"
"time"
)
// Job is one job within a workflow run (a Gitea Actions "task").
// Job is one job within a workflow run (a Gitea Actions "task"). Started/
// Finished are best-effort (zero value if the job hasn't completed or the
// timestamp failed to parse).
type Job struct {
Name string
Status string
Conclusion string
Started time.Time
Finished time.Time
}
// State is the effective outcome: conclusion if set, else status.
@@ -20,6 +25,14 @@ func (j Job) State() string {
return j.Status
}
// Seconds is how long the job ran, or 0 if either timestamp is missing/invalid.
func (j Job) Seconds() float64 {
if j.Started.IsZero() || j.Finished.Before(j.Started) {
return 0
}
return j.Finished.Sub(j.Started).Seconds()
}
// RunSummary is the newest workflow run and its per-job outcomes.
type RunSummary struct {
Number int
@@ -100,6 +113,8 @@ func LatestRunJobs(tasksJSON []byte) (RunSummary, error) {
Conclusion string `json:"conclusion"`
SHA string `json:"head_sha"`
Title string `json:"display_title"`
Created string `json:"created_at"`
Updated string `json:"updated_at"`
} `json:"workflow_runs"`
}
if err := json.Unmarshal(tasksJSON, &resp); err != nil {
@@ -113,7 +128,12 @@ func LatestRunJobs(tasksJSON []byte) (RunSummary, error) {
s := RunSummary{Number: latest.RunNumber, SHA: latest.SHA, Title: latest.Title}
for _, t := range resp.Tasks {
if t.RunNumber == latest.RunNumber {
s.Jobs = append(s.Jobs, Job{Name: t.Name, Status: t.Status, Conclusion: t.Conclusion})
started, _ := time.Parse(time.RFC3339, t.Created)
finished, _ := time.Parse(time.RFC3339, t.Updated)
s.Jobs = append(s.Jobs, Job{
Name: t.Name, Status: t.Status, Conclusion: t.Conclusion,
Started: started, Finished: finished,
})
}
}
// Gitea lists newest (last-finished) first; reverse to pipeline order.
@@ -142,6 +162,21 @@ func RunNodes(s RunSummary) []Node {
return nodes
}
// StageSeconds splits a run's real job durations across the two live-timed
// stages: the last job in pipeline order is the deploy (stage 07), everything
// before it is CI (stage 06). Returns 0, 0 if there are no jobs.
func StageSeconds(s RunSummary) (ci, cd float64) {
if len(s.Jobs) == 0 {
return 0, 0
}
last := len(s.Jobs) - 1
for _, j := range s.Jobs[:last] {
ci += j.Seconds()
}
cd = s.Jobs[last].Seconds()
return ci, cd
}
func statePill(state string) string {
switch state {
case "success":
+50
View File
@@ -2,6 +2,7 @@ package atlas_test
import (
"testing"
"time"
"git.d-ma.be/mathias/cad-atlas/internal/atlas"
)
@@ -104,3 +105,52 @@ func TestLatestRunJobs_ErrorsWhenEmpty(t *testing.T) {
t.Fatal("expected error on empty, got nil")
}
}
func TestLatestRunJobs_ParsesPerJobTimestampsIntoSeconds(t *testing.T) {
tasks := []byte(`{"workflow_runs":[
{"run_number":28,"name":"Deploy via GitOps","status":"success","created_at":"2026-07-20T21:05:44Z","updated_at":"2026-07-20T21:05:50Z"},
{"run_number":28,"name":"Lint / Test / Vet","status":"success","created_at":"2026-07-20T21:05:39Z","updated_at":"2026-07-20T21:05:44Z"}
]}`)
s, err := atlas.LatestRunJobs(tasks)
if err != nil {
t.Fatalf("LatestRunJobs: %v", err)
}
// pipeline order: Lint first, Deploy last
if got := s.Jobs[0].Seconds(); got != 5 {
t.Fatalf("Lint job seconds = %v, want 5", got)
}
if got := s.Jobs[1].Seconds(); got != 6 {
t.Fatalf("Deploy job seconds = %v, want 6", got)
}
}
func TestStageSeconds_LastJobIsDeploySumOfRestIsCI(t *testing.T) {
s := atlas.RunSummary{Jobs: []atlas.Job{
{Name: "Lint", Started: mustParse("2026-07-20T21:00:00Z"), Finished: mustParse("2026-07-20T21:00:10Z")}, // 10s
{Name: "Build", Started: mustParse("2026-07-20T21:00:10Z"), Finished: mustParse("2026-07-20T21:00:25Z")}, // 15s
{Name: "Deploy", Started: mustParse("2026-07-20T21:00:25Z"), Finished: mustParse("2026-07-20T21:00:33Z")}, // 8s
}}
ci, cd := atlas.StageSeconds(s)
if ci != 25 {
t.Fatalf("ci = %v, want 25", ci)
}
if cd != 8 {
t.Fatalf("cd = %v, want 8", cd)
}
}
func TestStageSeconds_NoJobsReturnsZero(t *testing.T) {
ci, cd := atlas.StageSeconds(atlas.RunSummary{})
if ci != 0 || cd != 0 {
t.Fatalf("ci=%v cd=%v, want 0,0", ci, cd)
}
}
func mustParse(s string) time.Time {
t, err := time.Parse(time.RFC3339, s)
if err != nil {
panic(err)
}
return t
}
+100
View File
@@ -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.Has("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
}
+114
View File
@@ -0,0 +1,114 @@
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 {
s := atlas.Stage{No: no, Title: "example"}
if generate != "" {
s.Generate = atlas.GenerateKeys{generate}
}
return s
}
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)
}
}
+47 -4
View File
@@ -4,6 +4,7 @@
package gitea
import (
"context"
"fmt"
"io"
"net/http"
@@ -21,9 +22,51 @@ func base() string {
// Runs returns the raw /actions/tasks JSON for mathias/cad-atlas (newest first).
func Runs() ([]byte, error) {
url := base() + "/api/v1/repos/mathias/cad-atlas/actions/tasks?limit=50"
client := &http.Client{Timeout: 5 * time.Second}
resp, err := client.Get(url) //nolint:noctx // short-lived, timeout on the client
return get(base()+"/api/v1/repos/mathias/cad-atlas/actions/tasks?limit=50", "")
}
// MyIssues returns the raw /repos/issues/search JSON for the token owner's
// own open issues across every repo they can see. Requires GITEA_TOKEN — a
// read-only PAT for the mathias account (this is a single-operator homelab,
// not per-visitor OAuth: anyone who clears Authentik forward-auth sees
// Mathias's own data). Returns an error if GITEA_TOKEN is unset, so callers
// can skip the overlay gracefully.
func MyIssues() ([]byte, error) {
token := os.Getenv("GITEA_TOKEN")
if token == "" {
return nil, fmt.Errorf("GITEA_TOKEN not set")
}
url := base() + "/api/v1/repos/issues/search?state=open&created=true&type=issues&limit=8"
return get(url, token)
}
// MyRepos returns the raw /user/repos JSON for the token owner's own
// repositories. Same PAT and same contract as MyIssues: returns an error if
// GITEA_TOKEN is unset so callers can skip the overlay gracefully rather than
// issue an unauthenticated request. Gitea's own visibility rules scope the
// result, so no additional filtering is applied here.
func MyRepos() ([]byte, error) {
token := os.Getenv("GITEA_TOKEN")
if token == "" {
return nil, fmt.Errorf("GITEA_TOKEN not set")
}
url := base() + "/api/v1/user/repos?limit=8"
return get(url, token)
}
// get performs a short-lived GET, optionally with a bearer token, and returns
// the response body.
func get(url, token string) ([]byte, error) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return nil, err
}
if token != "" {
req.Header.Set("Authorization", "token "+token)
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
return nil, err
}
@@ -33,7 +76,7 @@ func Runs() ([]byte, error) {
return nil, err
}
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("gitea runs: %s", resp.Status)
return nil, fmt.Errorf("gitea: %s", resp.Status)
}
return body, nil
}
+23
View File
@@ -0,0 +1,23 @@
package gitea_test
import (
"testing"
"git.d-ma.be/mathias/cad-atlas/internal/gitea"
)
// The unset path is the one that runs live right now: GITEA_TOKEN is still not
// provisioned in prod (#4), so MyRepos must fail cleanly rather than issue an
// unauthenticated request, letting liveOverlay skip and keep the authored
// placeholder nodes.
func TestMyRepos_ErrorsWhenTokenUnset(t *testing.T) {
t.Setenv("GITEA_TOKEN", "")
body, err := gitea.MyRepos()
if err == nil {
t.Fatal("want an error when GITEA_TOKEN is unset, got nil")
}
if body != nil {
t.Fatalf("want nil body on error, got %d bytes", len(body))
}
}
+86 -2
View File
@@ -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
@@ -51,11 +58,18 @@ func NewHandler() http.Handler {
if ld.run != nil {
nodes := atlas.RunNodes(*ld.run)
for i := range a.Stages {
if a.Stages[i].Generate == "ci-jobs" {
if a.Stages[i].Generate.Has("ci-jobs") {
a.Stages[i].Nodes = nodes
}
}
a.CIDurationS, a.CDDurationS = atlas.StageSeconds(*ld.run)
}
atlas.PrependGenerated(&a, "gitea-issues", ld.issues)
atlas.PrependGenerated(&a, "gitea-repos", ld.repos)
// 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 {
@@ -65,7 +79,7 @@ func NewHandler() http.Handler {
live = append(live, atlas.FluxNode(*ld.flux))
}
for i := range a.Stages {
if a.Stages[i].Generate == "deploy-state" {
if a.Stages[i].Generate.Has("deploy-state") {
a.Stages[i].Nodes = append(live, a.Stages[i].Nodes...)
}
}
@@ -84,6 +98,32 @@ type liveOverlayData struct {
deploy *atlas.Deploy
flux *atlas.Flux
timeline []atlas.RunDot
issues []atlas.Node
repos []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
@@ -123,6 +163,16 @@ func liveOverlay() liveOverlayData {
d.timeline = dots
}
}
if raw, err := gitea.MyIssues(); err == nil {
if nodes, err := atlas.IssueNodes(raw); err == nil {
d.issues = nodes
}
}
if raw, err := gitea.MyRepos(); err == nil {
if nodes, err := atlas.RepoNodes(raw); err == nil {
d.repos = nodes
}
}
if raw, err := cluster.Deployment(); err == nil {
if dep, err := atlas.DeployState(raw); err == nil {
d.deploy = &dep
@@ -133,6 +183,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
}
+27 -11
View File
@@ -30,7 +30,20 @@ func TestRootServesAtlas(t *testing.T) {
}
}
func TestAtlasJSON_ServesAtlasWithGeneratedCIStage(t *testing.T) {
// This asserts the SHAPE the endpoint serves, not the exact CI job names.
//
// STAGE 06 has two sources: the embedded workflow (deterministic) and, when the
// forge is reachable, the live latest run — which overwrites it with real job
// names and a "▶ run #N" node. The old assertion demanded the workflow-derived
// names, so it passed on a laptop with no route to Gitea and failed inside CI,
// where the overlay works exactly as designed. It went red on 2026-08-19 for a
// commit that only touched atlas.json.
//
// The deterministic assertion lives in the atlas package
// (TestBuild_OverlaysCIStageNodesFromWorkflow), which feeds Build a fixed
// workflow and no network. Here we check the endpoint serves a usable atlas
// whichever source won.
func TestAtlasJSON_ServesAtlasWithPopulatedCIStage(t *testing.T) {
srv := httptest.NewServer(NewHandler())
defer srv.Close()
@@ -51,18 +64,21 @@ func TestAtlasJSON_ServesAtlasWithGeneratedCIStage(t *testing.T) {
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
}
var ci *atlas.Stage
for i := range a.Stages {
if a.Stages[i].No == "STAGE 06" {
ci = &a.Stages[i]
}
}
for _, want := range []string{"guard", "check", "build", "deploy"} {
if !got[want] {
t.Fatalf("CI stage missing generated job %q (got %v)", want, got)
if ci == nil {
t.Fatal("STAGE 06 missing from the served atlas")
}
if len(ci.Nodes) == 0 {
t.Fatal("STAGE 06 has no nodes — neither the embedded workflow nor the live run populated it")
}
for _, n := range ci.Nodes {
if n.Title == "" {
t.Fatalf("STAGE 06 node with no title: %+v", n)
}
}
}
+95 -10
View File
@@ -86,6 +86,19 @@
.legend .lg{display:inline-flex;align-items:center;gap:6px}
.legend .lg i{width:11px;height:11px;border-radius:3px;display:inline-block;border:1px solid rgba(0,0,0,.35)}
.legend .dash{width:18px;border-top:2px dashed var(--violet);display:inline-block}
/* overview rail — see the whole 9-stage shape + jump to any stage */
.rail{display:flex;gap:4px;flex-wrap:wrap;align-items:center;
padding:8px 22px;border-bottom:1px solid var(--line);background:var(--panel)}
.rail .lbl{color:var(--dim);letter-spacing:1.5px;margin-right:4px;font-size:11px}
.railchip{font:inherit;font-size:11px;cursor:pointer;color:var(--ink);
background:var(--panel2);border:1px solid var(--line);border-radius:6px;padding:4px 9px}
.railchip:hover{border-color:var(--blue)}
.railchip b{color:var(--dim);font-weight:600;margin-right:3px}
.railchip.gate{border-color:rgba(245,185,66,.5)} .railchip.telos{border-color:rgba(155,140,255,.5)}
.railchip.exec{border-color:rgba(255,122,92,.5)} .railchip.cd{border-color:rgba(74,208,122,.5)}
.railarr{color:var(--dim);font-size:10px}
/* Plain view: drop decorative node pills so colour is reserved for live status (keyed in the legend) */
body.plain .node .pill{display:none}
.scroll{overflow-x:auto;padding:24px 22px 20px}
.track{position:relative;display:flex;align-items:flex-start;min-width:max-content}
@@ -98,8 +111,8 @@
.stage .no{color:var(--dim);font-size:11px;letter-spacing:2px}
.stage h2{font-size:17px;margin:6px 0 2px}
.stage .path{color:var(--mono);font-size:11.5px;margin-bottom:6px;min-height:16px}
.node{border:1px solid var(--line);border-radius:11px;background:var(--panel);
padding:12px 13px;margin-top:12px;position:relative;
.node{display:block;border:1px solid var(--line);border-radius:11px;background:var(--panel);
padding:12px 13px;margin-top:12px;position:relative;color:inherit;text-decoration:none;
transition:border-color .25s,box-shadow .25s,transform .25s}
.node .t{font-weight:600;margin-bottom:3px;display:flex;align-items:center;gap:7px}
.node .d{color:var(--dim);font-size:12px}
@@ -107,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)}
@@ -167,6 +191,7 @@
<span class="lg"><i style="background:var(--gold)"></i>governance gate — must pass to advance</span>
<span class="lg"><span class="dash"></span> feedback loop · partly manual</span>
</div>
<div class="rail mono" id="rail"></div>
<div class="scroll">
<div class="track" id="track">
@@ -200,7 +225,7 @@
// stage from the live cd.yml + substrate from the live cluster). These start
// empty and are filled by init()'s fetch; on failure the page shows an error
// banner rather than stale inline data.
let SUBSTRATE=[], NS="", STAGES=[], TIMELINE=[];
let SUBSTRATE=[], NS="", STAGES=[], TIMELINE=[], CI_DUR=0, CD_DUR=0;
let MODE = localStorage.getItem('atlas-mode') || 'plain'; // 'plain' | 'technical'
const track=document.getElementById('track');
@@ -220,7 +245,16 @@ function renderAtlas(){
? `<div class="no mono">${s.no}</div><h2>${s.plain_title||s.title}</h2>`+
`<div class="tech-sub">${s.title}</div>`+(s.plain?`<div class="plain-what">${s.plain}</div>`:'')
: `<div class="no mono">${s.no}</div><h2>${s.title}</h2><div class="path mono">${s.path||''}</div>`;
let h = `<div class="stagehead">${head}</div>`;
// cad-atlas#11: a count badge per stage from cad-stuck (mathias/cad#14),
// read-only — no controls, just a count and (via the nodes below) a link
// out to the underlying issues. Two classes, kept visually separate: a
// "stuck" count needs intervention, an "awaiting" count is a control
// working as designed and must never read as a problem.
let badges='';
if(s.stuck_count)badges+=`<span class="badge stuck">${s.stuck_count} stuck</span>`;
if(s.awaiting_count)badges+=`<span class="badge awaiting">${s.awaiting_count} awaiting</span>`;
if(badges)badges=`<span class="badges">${badges}</span>`;
let h = `<div class="stagehead">${head}${badges}</div>`;
(s.nodes||[]).forEach(n=>{
const pill=n.pill?`<span class="pill" style="background:${n.pill}"></span>`:'';
const body = plain ? (n.plain||n.d||'') : (n.d||'');
@@ -232,17 +266,33 @@ function renderAtlas(){
if(n.risk)inner+=`<div class="risk mono"><span class="lo">LOW · auto</span><span class="md">MED · ntfy gate</span><span class="hi">HIGH · blocked</span></div>`;
}
if(n.gate)inner+=`<div class="gatebtns mono"><div class="g ok">✓ approve</div><div class="g no">✕ reject</div></div>`;
h+=`<div class="node ${n.cls||''}">${inner}</div>`;
const tag = n.url ? 'a' : 'div';
const link = n.url ? ` href="${n.url}" target="_blank" rel="noopener"` : '';
h+=`<${tag} class="node ${n.cls||''}"${link}>${inner}</${tag}>`;
});
st.innerHTML=h;track.appendChild(st);stageEls.push(st);
// stacked-layout transition row (shown on mobile where the SVG spine is hidden)
if(s.trans_label){
const tr=document.createElement('div');
tr.className='trans-row'+((idx===4||idx===6)?' trans-row-gate':'');
tr.innerHTML=`<span class="tr-lbl">${s.trans_label}</span>${s.trans?' '+s.trans:''}`;
tr.innerHTML=`<span class="tr-lbl">${(idx===4||idx===6)?'🔒 ':''}${s.trans_label}</span>${s.trans?' '+s.trans:''}`;
track.appendChild(tr);
}
});
renderRail();
}
function renderRail(){
const rail=document.getElementById('rail');
rail.innerHTML='<span class="lbl">PIPELINE</span>';
STAGES.forEach((s,i)=>{
const c=document.createElement('button');
c.className='railchip'+(s.cls?' '+s.cls:'');
c.innerHTML=`<b>${(s.no||'').replace('STAGE ','')}</b>${s.short||s.plain_title||s.title}`;
c.onclick=()=>{ if(stageEls[i]) stageEls[i].scrollIntoView({behavior:'smooth',inline:'center',block:'nearest'}); };
rail.appendChild(c);
if(i<STAGES.length-1){const a=document.createElement('span');a.className='railarr';a.textContent='→';rail.appendChild(a);}
});
}
function renderTimeline(){
@@ -293,21 +343,54 @@ function build(){
t.setAttribute('x',(cs[i]+cs[i+1])/2);t.setAttribute('y',RAILY-9);
t.setAttribute('text-anchor','middle');
t.setAttribute('class',gate?'translabel translabel-gate':'translabel');
t.textContent=(gate?'▸ ':'')+s.trans_label;
t.textContent=(gate?'🔒 ':'')+s.trans_label;
const ttl=document.createElementNS('http://www.w3.org/2000/svg','title');
ttl.textContent=s.trans||''; t.appendChild(ttl);
tg.appendChild(t);
}
spineLen=spinePath.getTotalLength();loopLen=loopPath.getTotalLength();
}
// weightedSpineDist maps f (0..1 time-progress across the spine) to an arc-length
// distance. Default weight per stage-to-stage segment is its own pixel length —
// reduces to the old constant-pixel-speed sweep. When a real run's CI/CD durations
// are known, the pixel-time-budget already held by the 06(CI)/07(CD) segments is
// re-split by their real relative duration instead of by raw pixel width — so the
// reel visits stage 06 vs 07 at speeds proportional to how long they actually took.
function weightedSpineDist(f){
const n=cs.length-1;
if(n<=0)return 0;
const segLens=[];for(let i=0;i<n;i++)segLens.push(cs[i+1]-cs[i]);
const weights=segLens.slice();
if(CI_DUR>0&&CD_DUR>0&&n>=7){
const budget=weights[5]+weights[6], tot=CI_DUR+CD_DUR;
weights[5]=budget*CI_DUR/tot; weights[6]=budget*CD_DUR/tot;
}
const totalW=weights.reduce((a,b)=>a+b,0);
const target=f*totalW;
let acc=0;
for(let i=0;i<n;i++){
if(target<=acc+weights[i]||i===n-1){
const local=weights[i]>0?(target-acc)/weights[i]:0;
const segStart=cs[i]-cs[0];
return Math.min(spineLen,Math.max(0,segStart+segLens[i]*Math.min(1,Math.max(0,local))));
}
acc+=weights[i];
}
return spineLen;
}
function run(ts){
if(mobile)return;
if(!t0)t0=ts;
const dur=slow?16000:7000;
const p=Math.min((ts-t0)/dur,1);
const total=spineLen+loopLen, dist=total*p;
let pt,onLoop=dist>spineLen;
pt=onLoop?loopPath.getPointAtLength(dist-spineLen):spinePath.getPointAtLength(dist);
const spineFrac=spineLen/(spineLen+loopLen);
let dist,onLoop;
if(p<spineFrac){
dist=weightedSpineDist(p/spineFrac); onLoop=false;
}else{
dist=spineLen+loopLen*((p-spineFrac)/(1-spineFrac)); onLoop=true;
}
let pt=onLoop?loopPath.getPointAtLength(dist-spineLen):spinePath.getPointAtLength(dist);
pulse.style.left=pt.x+'px';pulse.style.top=pt.y+'px';
pulse.style.background=onLoop?'var(--violet)':'var(--amber)';
pulse.style.boxShadow=onLoop?'0 0 15px 4px rgba(155,140,255,.75)':'0 0 15px 4px rgba(245,185,66,.75)';
@@ -340,6 +423,8 @@ async function init(){
if(typeof data.ns==='string') NS=data.ns;
if(Array.isArray(data.stages)) STAGES=data.stages;
if(Array.isArray(data.timeline)) TIMELINE=data.timeline;
if(typeof data.ci_duration_s==='number') CI_DUR=data.ci_duration_s;
if(typeof data.cd_duration_s==='number') CD_DUR=data.cd_duration_s;
if(data.version) document.getElementById('ver').textContent=data.version;
}catch(e){
console.error('atlas: failed to load /api/atlas.json —',e);
+108
View File
@@ -0,0 +1,108 @@
// Package oathcandidate supplies cad-atlas's own real var-go candidate (cad-atlas#8):
// steps that gate its own CI-workflow oath by actually parsing the committed
// .gitea/workflows/cd.yml, not a stub that hardcodes an unrelated toy vocabulary.
// var-go injects and owns the gate across the subprocess boundary (SubprocessGate,
// ADR-0003), so this package supplies only the prose->behaviour binding and never a
// verdict — it cannot self-certify.
package oathcandidate
import (
"os"
"path/filepath"
"strings"
oath "git.d-ma.be/mathias/swedsl/oath"
"gopkg.in/yaml.v3"
)
// workflowState is the candidate's domain: the job names and concatenated step-run
// scripts parsed out of one Gitea Actions workflow file.
type workflowState struct {
jobNames map[string]bool
jobRuns map[string]string // job name -> every step's `run:` script, concatenated
}
type workflowFile struct {
Jobs map[string]struct {
Steps []struct {
Run string `yaml:"run"`
} `yaml:"steps"`
} `yaml:"jobs"`
}
// Build returns cad-atlas's candidate registry. cmd/vargo-gate runs the generated
// harness with cwd = this module's own directory (SubprocessGate's
// cmd.Dir = candidateModuleDir contract) — one level under the cad-atlas repo root
// in cad-atlas's real layout — so a workflow path in the oath text like
// ".gitea/workflows/cd.yml" is read relative to "..".
func Build() *oath.Registry[workflowState] {
reg := oath.NewRegistry[workflowState]()
if err := reg.Stimulus(`the CI workflow file {string} is parsed`,
func(_ workflowState, path string) workflowState {
return parseWorkflow(path)
}); err != nil {
panic(err)
}
if err := reg.Sensor(`it defines a job named {string}`,
func(s workflowState, name string) string {
if s.jobNames[name] {
return name
}
return "<no such job>"
}); err != nil {
panic(err)
}
// Checks what the workflow file can actually attest to: the job's run script
// invokes the gate binary. The "var-go/oath" commit-status context string
// itself lives in vargo-gate's Go code, not the YAML — not something this
// file-level check can see, so it isn't what's asserted here.
if err := reg.Sensor(`the job named {string} invokes {string}`,
func(s workflowState, job, cmd string) (string, string) {
run, ok := s.jobRuns[job]
foundJob := "<no such job>"
if ok {
foundJob = job
}
foundCmd := cmd
if !ok || !strings.Contains(run, cmd) {
foundCmd = "<not invoked>"
}
return foundJob, foundCmd
}); err != nil {
panic(err)
}
return reg
}
// parseWorkflow reads and parses a Gitea Actions workflow file relative to the
// repo root (see Build's doc comment for the cwd contract). A read or parse
// failure returns an empty state — every sensor then observes "not found",
// which fails the gate closed rather than silently skipping the check.
func parseWorkflow(repoRelativePath string) workflowState {
state := workflowState{jobNames: map[string]bool{}, jobRuns: map[string]string{}}
data, err := os.ReadFile(filepath.Join("..", repoRelativePath))
if err != nil {
return state
}
var wf workflowFile
if err := yaml.Unmarshal(data, &wf); err != nil {
return state
}
for name, job := range wf.Jobs {
state.jobNames[name] = true
var runs strings.Builder
for _, step := range job.Steps {
runs.WriteString(step.Run)
runs.WriteString("\n")
}
state.jobRuns[name] = runs.String()
}
return state
}
+79
View File
@@ -0,0 +1,79 @@
package oathcandidate
import (
"os"
"path/filepath"
"testing"
oath "git.d-ma.be/mathias/swedsl/oath"
)
// realOath is cad-atlas#8's actual oath text — the same var block committed to
// that issue. Gating it against the REAL checked-out .gitea/workflows/cd.yml
// proves the candidate reads real CI config, not a fixture standing in for it.
//
// Format note (discovered writing this test): var-go's parser requires a
// SINGLE-LINE paragraph — sentences are split by "." within that line, not by
// newline — and does NOT strip Given/When/Then/And keywords before matching a
// step. cad-atlas's older oaths (e.g. issue #1) use a multi-line, keyword-prefixed
// style that was never actually exercised against this parser (every prior gate
// run errored before reaching real sentence matching). Plain declarative
// sentences, period-separated, one line — see swedsl's own gate_test.go fixtures.
const realOath = "```var\n" +
`the CI workflow file ".gitea/workflows/cd.yml" is parsed. it defines a job named "oath". the job named "oath" invokes "cmd/vargo-gate".` +
"\n```\n"
// TestBuild_GatesRealWorkflow is named before Build existed (TDD): it fails to
// compile until Build() and the workflow-parsing steps exist, and fails to pass
// until they parse the REAL committed cd.yml correctly — this is the file that
// must go from red to green, not a mock.
func TestBuild_GatesRealWorkflow(t *testing.T) {
// go test's cwd is already this package's dir (oathcandidate/), matching
// SubprocessGate's cmd.Dir = candidateModuleDir contract exactly — no chdir
// needed to reproduce it here.
verdict, err := oath.Gate([]byte(realOath), Build())
if err != nil {
t.Fatalf("Gate returned error: %v", err)
}
if !verdict.Pass {
if verdict.Failure != nil {
t.Fatalf("Gate did not pass: failure=%+v", *verdict.Failure)
}
t.Fatalf("Gate did not pass against the real committed cd.yml: %+v", verdict)
}
}
// TestBuild_FailsClosedOnMissingJob proves the candidate is a REAL check, not a
// rubber stamp: gating a workflow file that has no "oath" job must fail.
func TestBuild_FailsClosedOnMissingJob(t *testing.T) {
dir := t.TempDir()
workflowsDir := filepath.Join(dir, ".gitea", "workflows")
if err := os.MkdirAll(workflowsDir, 0o755); err != nil {
t.Fatal(err)
}
noOathJob := "jobs:\n check:\n steps:\n - run: go test ./...\n"
if err := os.WriteFile(filepath.Join(workflowsDir, "cd.yml"), []byte(noOathJob), 0o644); err != nil {
t.Fatal(err)
}
cwd, err := os.Getwd()
if err != nil {
t.Fatal(err)
}
// SubprocessGate always runs the candidate with cmd.Dir = candidateModuleDir,
// one level under the repo root (cad-atlas's real layout) — reproduce that by
// chdir-ing into a sibling "candidate/" dir under the fixture root.
candDir := filepath.Join(dir, "candidate")
if err := os.MkdirAll(candDir, 0o755); err != nil {
t.Fatal(err)
}
if err := os.Chdir(candDir); err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = os.Chdir(cwd) })
verdict, err := oath.Gate([]byte(realOath), Build())
if err == nil && verdict.Pass {
t.Fatalf("expected the gate to fail closed on a workflow with no oath job, got Pass=true")
}
}
+18
View File
@@ -0,0 +1,18 @@
// Package oathcandidate is cad-atlas's committed real candidate: the STEPS that
// gate its own CI-workflow oath (cad-atlas#8). Deliberately a separate module (not
// part of the main cad-atlas module) so var-go's transitive deps (cucumber-expressions,
// goldmark) never link into the deployed atlas binary — mirrors swedsl's own
// oath/testdata/selfcandidate pattern.
module oathcandidate
go 1.26.4
require (
git.d-ma.be/mathias/swedsl/oath v0.28.0
gopkg.in/yaml.v3 v3.0.1
)
require (
github.com/cucumber/cucumber-expressions/go/v18 v18.1.0 // indirect
github.com/yuin/goldmark v1.8.2 // indirect
)
+16
View File
@@ -0,0 +1,16 @@
git.d-ma.be/mathias/swedsl/oath v0.28.0 h1:q4WXlGtMlDymhmuw9Pdc025OTLs1wl8THsrz/raeMxs=
git.d-ma.be/mathias/swedsl/oath v0.28.0/go.mod h1:kEOX7Wubf3g/HTKzuoHD4fm6zNSl55cSV/qgy+ezMoI=
github.com/cucumber/cucumber-expressions/go/v18 v18.1.0 h1:zvZFnbmtQxwHq6ru5gHxpfBloLq9wmjoKbdwOzt/XNA=
github.com/cucumber/cucumber-expressions/go/v18 v18.1.0/go.mod h1:+Qe2kvmilsdGRFJ+zlkjXp84rPEf6O/idcoOsvnIORY=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
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/yuin/goldmark v1.8.2 h1:kEGpgqJXdgbkhcOgBxkC0X0PmoPG1ZyoZ117rDVp4zE=
github.com/yuin/goldmark v1.8.2/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
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=