8 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
21 changed files with 925 additions and 53 deletions
+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"
+33 -11
View File
@@ -142,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
@@ -167,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 \
+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=
+8 -4
View File
@@ -29,17 +29,21 @@
{"t":"Admission controller","plain_t":"Tamper-proof seal","plain":"The work order is cryptographically signed when created, so any later tampering is detectable and the eventual change can be checked against it.","d":"Ed25519-sign issue body at creation (#36). Verify sig + PR alignment at infra boundary.","tags":["chain of custody"]},
{"t":"⚖️ var-go Oath","plain_t":"Definition of done","plain":"A machine-checkable \"definition of done\" is embedded in the work order — exactly one, or the order is rejected — later used to prove the result actually meets the spec.","cls":"oath","pill":"var(--gold)","d":"Acceptance contract embedded in the issue as a var fenced block. Exactly one — zero/multiple fail closed. Prose → typed steps; failures anchored to byte spans.","tags":["swedsl · var-go","defined here → enforced @06"]}
]},
{"no":"STAGE 04","short":"Human go","cls":"gate","title":"Human dispatch gate","plain_title":"Human says go","plain":"A person reviews the work order and its risk and decides whether to release it — the one and only checkpoint where work does not move on its own.","path":"the only checkpoint",
{"no":"STAGE 04","short":"Human go","cls":"gate","title":"Human dispatch gate","plain_title":"Human says go","plain":"A person reviews the work order and its risk and decides whether to release it — the one and only checkpoint where work does not move on its own.","path":"the only checkpoint","generate":"stuck-badges",
"trans_label":"A human said go","trans":"The hard stop. Nothing crosses automatically — a person must review the plan and risk and explicitly release it, and the repo must be on the allow-list, before any agent starts. This is the single human checkpoint in the whole pipeline.","nodes":[
{"t":"Human triggers execution","plain_t":"The go button","plain":"A person confirms the plan and its risk level, then releases the work — nothing runs until they do.","cls":"gateway","pill":"var(--amber)","d":"Ratify proposed-plan + risk tier, then dispatch.","gate":true},
{"t":"Session-Dispatch bridge","plain_t":"Hand-off to agents","plain":"The approval flips a switch that hands the signed work order over to the agents to start execution.","cls":"bridge","pill":"var(--blue)","d":"claude.ai MCP → gitea:workflow_run_trigger → cad-dispatch.yml → agentsquad. The final design→execution bridge.","tags":["workflow_dispatch"]}
]},
{"no":"STAGE 05","short":"Build","cls":"exec","title":"Execute · agentsquad","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","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":[
@@ -49,7 +53,7 @@
"trans_label":"It's live","trans":"Once the new version is actually running on the server, the deployed outcome becomes the input to scoring. Advancing means shipped and observable, not just merged.","nodes":[
{"t":"Deploy on green","plain_t":"Auto-deploy when green","plain":"When all checks pass, the release system rolls the new version onto the live server automatically — but only once the release is pointed at that version (merging code alone doesn't ship it).","pill":"var(--green)","d":"Flux reconciles image → k3s pod on koala. Push ≠ deploy: bump tag in mathias/infra.","tags":["ntfy on deploy"]}
]},
{"no":"STAGE 08","short":"Learn","cls":"telos","title":"Loop back","plain_title":"Did it work?","plain":"The result is scored against the goal that started it and fed back into the mission board, so the next round of planning learns from what shipped.","path":"→ TELOS (feedback bus)",
{"no":"STAGE 08","short":"Learn","cls":"telos","title":"Loop back","plain_title":"Did it work?","plain":"The result is scored against the goal that started it and fed back into the mission board, so the next round of planning learns from what shipped.","path":"→ TELOS (feedback bus)","generate":"stuck-badges",
"trans_label":"What did we learn?","trans":"The scored outcome flows back into the mission board so goals, problems, and priorities update — the loop that makes the pipeline a cycle rather than a line. Partly manual today; an explicit improvement target.","nodes":[
{"t":"Close the loop","plain_t":"Score & feed back","plain":"The outcome is scored against the goal that started it and written back to the mission board, so future planning learns from what actually shipped.","pill":"var(--violet)","d":"session_log + attestation → brain. Score deploy outcome vs originating goal. (arc partly manual — improvement target.)","tags":["continuous"]}
]}
+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)
}
}
}
}
+18 -12
View File
@@ -32,17 +32,23 @@ 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"`
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 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.
@@ -65,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")
}
}
+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)
}
}
+14
View File
@@ -40,6 +40,20 @@ func MyIssues() ([]byte, error) {
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) {
+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))
}
}
+79 -9
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,19 +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)
}
if ld.issues != nil {
for i := range a.Stages {
if a.Stages[i].Generate == "gitea-issues" {
a.Stages[i].Nodes = append(ld.issues, a.Stages[i].Nodes...)
}
}
}
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 {
@@ -73,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...)
}
}
@@ -93,6 +99,31 @@ type liveOverlayData struct {
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
@@ -137,6 +168,11 @@ func liveOverlay() liveOverlayData {
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
@@ -147,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)
}
}
}
+21 -1
View File
@@ -120,6 +120,17 @@
border:1px solid var(--line);color:var(--dim);margin:6px 6px 0 0}
.pill{width:8px;height:8px;border-radius:50%;flex:0 0 8px}
/* cad-atlas#11: stuck-state overlay. Two visual classes, deliberately
distinct — "stuck" needs intervention, "awaiting-human" is a control
working as designed and must never look like a problem. */
.node.stuck{border-color:rgba(255,122,92,.55);background:linear-gradient(180deg,rgba(255,122,92,.07),transparent)}
.node.awaiting-human{border-color:rgba(74,208,122,.5);background:linear-gradient(180deg,rgba(74,208,122,.06),transparent)}
.stagehead{display:flex;align-items:baseline;gap:8px;flex-wrap:wrap}
.stagehead .badges{display:flex;gap:6px}
.badge{font-size:10.5px;padding:2px 8px;border-radius:20px;font-weight:600;white-space:nowrap}
.badge.stuck{color:var(--coral);border:1px solid rgba(255,122,92,.5)}
.badge.awaiting{color:var(--green);border:1px solid rgba(74,208,122,.5)}
.stage.telos h2{color:var(--violet)}
.stage.gate h2{color:var(--amber)}
.stage.exec h2{color:var(--coral)}
@@ -234,7 +245,16 @@ function renderAtlas(){
? `<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||'');