generated from mathias/template-go-web
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
097d8747c6 | ||
|
|
62955c998d | ||
|
|
97d0f56383 | ||
|
|
4ab65a3b1b | ||
|
|
c35c9699d4 | ||
|
|
1fdd7d2e3d | ||
|
|
99fb969d27 | ||
|
|
294d9e2fc5 | ||
|
|
6967d12d1d | ||
|
|
b2761a4747 | ||
|
|
68ae01cb75 | ||
|
|
2f5fca8513 | ||
|
|
805b76d7c3 |
+10
-10
@@ -54,16 +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): `cmd/vargo-gate`
|
||||
is wired into `.gitea/workflows/cd.yml`'s `oath` job (issue #1, verified with a real PR) — it runs
|
||||
on every pull_request, fetches the linked issue's oath, and posts a `var-go/oath` commit status.
|
||||
But its candidate is still swedsl's hardcoded toy self-test registry, not cad-atlas's own — it
|
||||
fails closed against any real oath. var-go's Executor/Reviewer path is **not** the blocker (that
|
||||
was explicitly killed by swedsl's ADR-0002, swedsl#27 — var-go is gate-only by design, each
|
||||
consumer supplies its own candidate). The real blocker is `swedsl/oath`'s import path
|
||||
(`swedsl#35`); once fixed, cad-atlas writes its own candidate (`#8`). The status is **not**
|
||||
required by branch protection, so it can't block merges yet. Disclosed in the CI config comment,
|
||||
this doc, and `docs/INCEPTION-OATH.md`.
|
||||
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>`)
|
||||
|
||||
|
||||
Executable
+121
@@ -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"
|
||||
+81
-36
@@ -53,50 +53,73 @@ 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
|
||||
# Only a real pull_request event carries a linked-issue oath to gate (mirrors
|
||||
# swedsl's own oath job, .gitea/workflows/ci.yml). v1 simplification (swedsl#30):
|
||||
# the oath issue number is the PR's OWN number.
|
||||
#
|
||||
# DISCLOSED LIMITATION (honest-stub discipline, see docs/INCEPTION-OATH.md S3 and
|
||||
# knowledge/swedsl-vargo-sprint1-enforcement-teeth-verdict.md): cmd/vargo-gate's
|
||||
# candidate is a hardcoded toy self-test registry (swedsl's own #9 fixture
|
||||
# vocabulary), not a real PR-diff checker. It will fail closed against any oath
|
||||
# that isn't that toy vocabulary — which is every real oath, including this repo's
|
||||
# own #1. A red or green "var-go/oath" status here currently proves the WIRING
|
||||
# (fetch issue -> gate -> post commit status) runs end-to-end on a real PR, not
|
||||
# that the PR satisfies its linked issue's oath. Deliberately NOT required by
|
||||
# branch protection until cad-atlas has its own candidate matching its real oath
|
||||
# vocabulary (#8, blocked on swedsl/oath's import path, swedsl#35) — making it
|
||||
# required now would permanently block every cad-atlas PR.
|
||||
# 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:
|
||||
- name: Checkout swedsl (var-go source — not go-installable, module path isn't a real import path)
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
repository: mathias/swedsl
|
||||
path: swedsl
|
||||
token: ${{ secrets.DMABE_GITEA_API_TOKEN }}
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version-file: swedsl/oath/go.mod
|
||||
go-version-file: oathcandidate/go.mod
|
||||
cache: false
|
||||
|
||||
- name: Run vargo-gate (fetch -> gate -> post status against this PR)
|
||||
working-directory: swedsl/oath
|
||||
- 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 }}'
|
||||
go run ./cmd/vargo-gate
|
||||
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
|
||||
@@ -119,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
|
||||
@@ -144,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
@@ -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
-8
@@ -3,9 +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). `var-go/oath`
|
||||
> is wired (issue #1) and runs on every PR, but its candidate is a toy self-test — it fails closed
|
||||
> against any real oath and is not required by branch protection. See the honesty rule below.
|
||||
> **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)
|
||||
|
||||
@@ -25,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 | ⏸ **wired, not enforcing → #8** | `oath` job runs + posts status (#1, verified with a real PR). Not branch-protection-required: candidate is still swedsl's toy self-test, fails closed on every real oath. Blocked on `swedsl/oath` import path (swedsl#35) → cad-atlas writing its own candidate (#8). Not blocked on an Executor — that path was killed by swedsl's ADR-0002. 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
|
||||
|
||||
@@ -37,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 `swedsl/oath` becoming importable (swedsl#35) and cad-atlas writing its own
|
||||
candidate (#8); it is tracked there, not claimed here. The `DMABE_GITEA_API_TOKEN` Actions secret
|
||||
is pre-provisioned so #1 landed 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)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -23,23 +23,27 @@
|
||||
{"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","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",
|
||||
{"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","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:<name> 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"]}
|
||||
]}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
-17
@@ -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,26 +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"`
|
||||
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.
|
||||
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
|
||||
@@ -62,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)
|
||||
|
||||
@@ -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...)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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":
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -111,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}
|
||||
@@ -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)}
|
||||
@@ -214,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');
|
||||
@@ -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||'');
|
||||
@@ -246,7 +266,9 @@ 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)
|
||||
@@ -328,14 +350,47 @@ function build(){
|
||||
}
|
||||
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)';
|
||||
@@ -368,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);
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
@@ -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=
|
||||
Reference in New Issue
Block a user