5 Commits
Author SHA1 Message Date
mathias 6967d12d1d feat(atlas): weight replay pacing on CI/CD stages by real job durations (#5)
CD / Detect unsubstituted template (push) Successful in 1s
CD / Lint / Test / Vet (push) Successful in 6s
CD / var-go/oath (push) Has been skipped
CD / Build & Import (push) Successful in 18s
CD / Deploy via GitOps (push) Has been skipped
TDD: Job.Seconds() + StageSeconds() derive real CI/CD dwell time from the
latest run's per-job created_at/updated_at (last job in pipeline order =
deploy/stage 07, everything before it = CI/stage 06).

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

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

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

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

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

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

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

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-20 14:33:17 +02:00
16 changed files with 552 additions and 62 deletions
+10 -10
View File
@@ -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>`)
+48 -25
View File
@@ -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
+13 -8
View File
@@ -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 -1
View File
@@ -23,7 +23,7 @@
{"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"]},
+32
View File
@@ -0,0 +1,32 @@
package atlas
import (
"encoding/json"
"fmt"
)
// IssueNodes parses a Gitea `/repos/issues/search` response (the
// authenticated user's own open issues, newest first) into one node per
// issue, linking out to the issue.
func IssueNodes(searchJSON []byte) ([]Node, error) {
var issues []struct {
Number int `json:"number"`
Title string `json:"title"`
HTMLURL string `json:"html_url"`
Repository struct {
FullName string `json:"full_name"`
} `json:"repository"`
}
if err := json.Unmarshal(searchJSON, &issues); err != nil {
return nil, fmt.Errorf("parse issues: %w", err)
}
nodes := make([]Node, 0, len(issues))
for _, i := range issues {
nodes = append(nodes, Node{
Title: fmt.Sprintf("#%d %s", i.Number, i.Title),
Tags: []string{"live · Gitea", i.Repository.FullName},
URL: i.HTMLURL,
})
}
return nodes, nil
}
+41
View File
@@ -0,0 +1,41 @@
package atlas_test
import (
"testing"
"git.d-ma.be/mathias/cad-atlas/internal/atlas"
)
func TestIssueNodes_OneNodePerIssueWithRepoTagAndURL(t *testing.T) {
search := []byte(`[
{"number":212,"title":"segment-embedder: add smoke test","html_url":"https://git.d-ma.be/mathias/infra/issues/212","repository":{"full_name":"mathias/infra"}},
{"number":8,"title":"Write cad-atlas's own vargo-gate candidate","html_url":"https://git.d-ma.be/mathias/cad-atlas/issues/8","repository":{"full_name":"mathias/cad-atlas"}}
]`)
nodes, err := atlas.IssueNodes(search)
if err != nil {
t.Fatalf("IssueNodes: %v", err)
}
if len(nodes) != 2 {
t.Fatalf("want 2 nodes, got %d", len(nodes))
}
if nodes[0].Title != "#212 segment-embedder: add smoke test" {
t.Fatalf("title = %q", nodes[0].Title)
}
if nodes[0].URL != "https://git.d-ma.be/mathias/infra/issues/212" {
t.Fatalf("url = %q", nodes[0].URL)
}
if len(nodes[0].Tags) != 2 || nodes[0].Tags[0] != "live · Gitea" || nodes[0].Tags[1] != "mathias/infra" {
t.Fatalf("tags = %v", nodes[0].Tags)
}
}
func TestIssueNodes_EmptyListReturnsEmptyNotNil(t *testing.T) {
nodes, err := atlas.IssueNodes([]byte(`[]`))
if err != nil {
t.Fatalf("IssueNodes: %v", err)
}
if nodes == nil || len(nodes) != 0 {
t.Fatalf("nodes = %+v, want empty non-nil slice", nodes)
}
}
+3
View File
@@ -23,6 +23,7 @@ type Node struct {
Tags []string `json:"tags,omitempty"`
Risk bool `json:"risk,omitempty"`
Gate bool `json:"gate,omitempty"`
URL string `json:"url,omitempty"`
}
// Stage is one column of the pipeline. PlainTitle/Plain are the plain-language
@@ -50,6 +51,8 @@ type Atlas struct {
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"`
}
+37 -2
View File
@@ -3,13 +3,18 @@ package atlas
import (
"encoding/json"
"fmt"
"time"
)
// Job is one job within a workflow run (a Gitea Actions "task").
// Job is one job within a workflow run (a Gitea Actions "task"). Started/
// Finished are best-effort (zero value if the job hasn't completed or the
// timestamp failed to parse).
type Job struct {
Name string
Status string
Conclusion string
Started time.Time
Finished time.Time
}
// State is the effective outcome: conclusion if set, else status.
@@ -20,6 +25,14 @@ func (j Job) State() string {
return j.Status
}
// Seconds is how long the job ran, or 0 if either timestamp is missing/invalid.
func (j Job) Seconds() float64 {
if j.Started.IsZero() || j.Finished.Before(j.Started) {
return 0
}
return j.Finished.Sub(j.Started).Seconds()
}
// RunSummary is the newest workflow run and its per-job outcomes.
type RunSummary struct {
Number int
@@ -100,6 +113,8 @@ func LatestRunJobs(tasksJSON []byte) (RunSummary, error) {
Conclusion string `json:"conclusion"`
SHA string `json:"head_sha"`
Title string `json:"display_title"`
Created string `json:"created_at"`
Updated string `json:"updated_at"`
} `json:"workflow_runs"`
}
if err := json.Unmarshal(tasksJSON, &resp); err != nil {
@@ -113,7 +128,12 @@ func LatestRunJobs(tasksJSON []byte) (RunSummary, error) {
s := RunSummary{Number: latest.RunNumber, SHA: latest.SHA, Title: latest.Title}
for _, t := range resp.Tasks {
if t.RunNumber == latest.RunNumber {
s.Jobs = append(s.Jobs, Job{Name: t.Name, Status: t.Status, Conclusion: t.Conclusion})
started, _ := time.Parse(time.RFC3339, t.Created)
finished, _ := time.Parse(time.RFC3339, t.Updated)
s.Jobs = append(s.Jobs, Job{
Name: t.Name, Status: t.Status, Conclusion: t.Conclusion,
Started: started, Finished: finished,
})
}
}
// Gitea lists newest (last-finished) first; reverse to pipeline order.
@@ -142,6 +162,21 @@ func RunNodes(s RunSummary) []Node {
return nodes
}
// StageSeconds splits a run's real job durations across the two live-timed
// stages: the last job in pipeline order is the deploy (stage 07), everything
// before it is CI (stage 06). Returns 0, 0 if there are no jobs.
func StageSeconds(s RunSummary) (ci, cd float64) {
if len(s.Jobs) == 0 {
return 0, 0
}
last := len(s.Jobs) - 1
for _, j := range s.Jobs[:last] {
ci += j.Seconds()
}
cd = s.Jobs[last].Seconds()
return ci, cd
}
func statePill(state string) string {
switch state {
case "success":
+50
View File
@@ -2,6 +2,7 @@ package atlas_test
import (
"testing"
"time"
"git.d-ma.be/mathias/cad-atlas/internal/atlas"
)
@@ -104,3 +105,52 @@ func TestLatestRunJobs_ErrorsWhenEmpty(t *testing.T) {
t.Fatal("expected error on empty, got nil")
}
}
func TestLatestRunJobs_ParsesPerJobTimestampsIntoSeconds(t *testing.T) {
tasks := []byte(`{"workflow_runs":[
{"run_number":28,"name":"Deploy via GitOps","status":"success","created_at":"2026-07-20T21:05:44Z","updated_at":"2026-07-20T21:05:50Z"},
{"run_number":28,"name":"Lint / Test / Vet","status":"success","created_at":"2026-07-20T21:05:39Z","updated_at":"2026-07-20T21:05:44Z"}
]}`)
s, err := atlas.LatestRunJobs(tasks)
if err != nil {
t.Fatalf("LatestRunJobs: %v", err)
}
// pipeline order: Lint first, Deploy last
if got := s.Jobs[0].Seconds(); got != 5 {
t.Fatalf("Lint job seconds = %v, want 5", got)
}
if got := s.Jobs[1].Seconds(); got != 6 {
t.Fatalf("Deploy job seconds = %v, want 6", got)
}
}
func TestStageSeconds_LastJobIsDeploySumOfRestIsCI(t *testing.T) {
s := atlas.RunSummary{Jobs: []atlas.Job{
{Name: "Lint", Started: mustParse("2026-07-20T21:00:00Z"), Finished: mustParse("2026-07-20T21:00:10Z")}, // 10s
{Name: "Build", Started: mustParse("2026-07-20T21:00:10Z"), Finished: mustParse("2026-07-20T21:00:25Z")}, // 15s
{Name: "Deploy", Started: mustParse("2026-07-20T21:00:25Z"), Finished: mustParse("2026-07-20T21:00:33Z")}, // 8s
}}
ci, cd := atlas.StageSeconds(s)
if ci != 25 {
t.Fatalf("ci = %v, want 25", ci)
}
if cd != 8 {
t.Fatalf("cd = %v, want 8", cd)
}
}
func TestStageSeconds_NoJobsReturnsZero(t *testing.T) {
ci, cd := atlas.StageSeconds(atlas.RunSummary{})
if ci != 0 || cd != 0 {
t.Fatalf("ci=%v cd=%v, want 0,0", ci, cd)
}
}
func mustParse(s string) time.Time {
t, err := time.Parse(time.RFC3339, s)
if err != nil {
panic(err)
}
return t
}
+33 -4
View File
@@ -4,6 +4,7 @@
package gitea
import (
"context"
"fmt"
"io"
"net/http"
@@ -21,9 +22,37 @@ 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)
}
// 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 +62,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
}
+14
View File
@@ -55,6 +55,14 @@ func NewHandler() http.Handler {
a.Stages[i].Nodes = nodes
}
}
a.CIDurationS, a.CDDurationS = atlas.StageSeconds(*ld.run)
}
if ld.issues != nil {
for i := range a.Stages {
if a.Stages[i].Generate == "gitea-issues" {
a.Stages[i].Nodes = append(ld.issues, a.Stages[i].Nodes...)
}
}
}
if ld.deploy != nil || ld.flux != nil {
var live []atlas.Node
@@ -84,6 +92,7 @@ type liveOverlayData struct {
deploy *atlas.Deploy
flux *atlas.Flux
timeline []atlas.RunDot
issues []atlas.Node
}
// live cache: query the cluster at most once per TTL; fall back to the authored
@@ -123,6 +132,11 @@ 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 := cluster.Deployment(); err == nil {
if dep, err := atlas.DeployState(raw); err == nil {
d.deploy = &dep
+44 -7
View File
@@ -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}
@@ -214,7 +214,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');
@@ -246,7 +246,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 +330,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 +403,8 @@ async function init(){
if(typeof data.ns==='string') NS=data.ns;
if(Array.isArray(data.stages)) STAGES=data.stages;
if(Array.isArray(data.timeline)) TIMELINE=data.timeline;
if(typeof data.ci_duration_s==='number') CI_DUR=data.ci_duration_s;
if(typeof data.cd_duration_s==='number') CD_DUR=data.cd_duration_s;
if(data.version) document.getElementById('ver').textContent=data.version;
}catch(e){
console.error('atlas: failed to load /api/atlas.json —',e);
+108
View File
@@ -0,0 +1,108 @@
// Package oathcandidate supplies cad-atlas's own real var-go candidate (cad-atlas#8):
// steps that gate its own CI-workflow oath by actually parsing the committed
// .gitea/workflows/cd.yml, not a stub that hardcodes an unrelated toy vocabulary.
// var-go injects and owns the gate across the subprocess boundary (SubprocessGate,
// ADR-0003), so this package supplies only the prose->behaviour binding and never a
// verdict — it cannot self-certify.
package oathcandidate
import (
"os"
"path/filepath"
"strings"
oath "git.d-ma.be/mathias/swedsl/oath"
"gopkg.in/yaml.v3"
)
// workflowState is the candidate's domain: the job names and concatenated step-run
// scripts parsed out of one Gitea Actions workflow file.
type workflowState struct {
jobNames map[string]bool
jobRuns map[string]string // job name -> every step's `run:` script, concatenated
}
type workflowFile struct {
Jobs map[string]struct {
Steps []struct {
Run string `yaml:"run"`
} `yaml:"steps"`
} `yaml:"jobs"`
}
// Build returns cad-atlas's candidate registry. cmd/vargo-gate runs the generated
// harness with cwd = this module's own directory (SubprocessGate's
// cmd.Dir = candidateModuleDir contract) — one level under the cad-atlas repo root
// in cad-atlas's real layout — so a workflow path in the oath text like
// ".gitea/workflows/cd.yml" is read relative to "..".
func Build() *oath.Registry[workflowState] {
reg := oath.NewRegistry[workflowState]()
if err := reg.Stimulus(`the CI workflow file {string} is parsed`,
func(_ workflowState, path string) workflowState {
return parseWorkflow(path)
}); err != nil {
panic(err)
}
if err := reg.Sensor(`it defines a job named {string}`,
func(s workflowState, name string) string {
if s.jobNames[name] {
return name
}
return "<no such job>"
}); err != nil {
panic(err)
}
// Checks what the workflow file can actually attest to: the job's run script
// invokes the gate binary. The "var-go/oath" commit-status context string
// itself lives in vargo-gate's Go code, not the YAML — not something this
// file-level check can see, so it isn't what's asserted here.
if err := reg.Sensor(`the job named {string} invokes {string}`,
func(s workflowState, job, cmd string) (string, string) {
run, ok := s.jobRuns[job]
foundJob := "<no such job>"
if ok {
foundJob = job
}
foundCmd := cmd
if !ok || !strings.Contains(run, cmd) {
foundCmd = "<not invoked>"
}
return foundJob, foundCmd
}); err != nil {
panic(err)
}
return reg
}
// parseWorkflow reads and parses a Gitea Actions workflow file relative to the
// repo root (see Build's doc comment for the cwd contract). A read or parse
// failure returns an empty state — every sensor then observes "not found",
// which fails the gate closed rather than silently skipping the check.
func parseWorkflow(repoRelativePath string) workflowState {
state := workflowState{jobNames: map[string]bool{}, jobRuns: map[string]string{}}
data, err := os.ReadFile(filepath.Join("..", repoRelativePath))
if err != nil {
return state
}
var wf workflowFile
if err := yaml.Unmarshal(data, &wf); err != nil {
return state
}
for name, job := range wf.Jobs {
state.jobNames[name] = true
var runs strings.Builder
for _, step := range job.Steps {
runs.WriteString(step.Run)
runs.WriteString("\n")
}
state.jobRuns[name] = runs.String()
}
return state
}
+79
View File
@@ -0,0 +1,79 @@
package oathcandidate
import (
"os"
"path/filepath"
"testing"
oath "git.d-ma.be/mathias/swedsl/oath"
)
// realOath is cad-atlas#8's actual oath text — the same var block committed to
// that issue. Gating it against the REAL checked-out .gitea/workflows/cd.yml
// proves the candidate reads real CI config, not a fixture standing in for it.
//
// Format note (discovered writing this test): var-go's parser requires a
// SINGLE-LINE paragraph — sentences are split by "." within that line, not by
// newline — and does NOT strip Given/When/Then/And keywords before matching a
// step. cad-atlas's older oaths (e.g. issue #1) use a multi-line, keyword-prefixed
// style that was never actually exercised against this parser (every prior gate
// run errored before reaching real sentence matching). Plain declarative
// sentences, period-separated, one line — see swedsl's own gate_test.go fixtures.
const realOath = "```var\n" +
`the CI workflow file ".gitea/workflows/cd.yml" is parsed. it defines a job named "oath". the job named "oath" invokes "cmd/vargo-gate".` +
"\n```\n"
// TestBuild_GatesRealWorkflow is named before Build existed (TDD): it fails to
// compile until Build() and the workflow-parsing steps exist, and fails to pass
// until they parse the REAL committed cd.yml correctly — this is the file that
// must go from red to green, not a mock.
func TestBuild_GatesRealWorkflow(t *testing.T) {
// go test's cwd is already this package's dir (oathcandidate/), matching
// SubprocessGate's cmd.Dir = candidateModuleDir contract exactly — no chdir
// needed to reproduce it here.
verdict, err := oath.Gate([]byte(realOath), Build())
if err != nil {
t.Fatalf("Gate returned error: %v", err)
}
if !verdict.Pass {
if verdict.Failure != nil {
t.Fatalf("Gate did not pass: failure=%+v", *verdict.Failure)
}
t.Fatalf("Gate did not pass against the real committed cd.yml: %+v", verdict)
}
}
// TestBuild_FailsClosedOnMissingJob proves the candidate is a REAL check, not a
// rubber stamp: gating a workflow file that has no "oath" job must fail.
func TestBuild_FailsClosedOnMissingJob(t *testing.T) {
dir := t.TempDir()
workflowsDir := filepath.Join(dir, ".gitea", "workflows")
if err := os.MkdirAll(workflowsDir, 0o755); err != nil {
t.Fatal(err)
}
noOathJob := "jobs:\n check:\n steps:\n - run: go test ./...\n"
if err := os.WriteFile(filepath.Join(workflowsDir, "cd.yml"), []byte(noOathJob), 0o644); err != nil {
t.Fatal(err)
}
cwd, err := os.Getwd()
if err != nil {
t.Fatal(err)
}
// SubprocessGate always runs the candidate with cmd.Dir = candidateModuleDir,
// one level under the repo root (cad-atlas's real layout) — reproduce that by
// chdir-ing into a sibling "candidate/" dir under the fixture root.
candDir := filepath.Join(dir, "candidate")
if err := os.MkdirAll(candDir, 0o755); err != nil {
t.Fatal(err)
}
if err := os.Chdir(candDir); err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = os.Chdir(cwd) })
verdict, err := oath.Gate([]byte(realOath), Build())
if err == nil && verdict.Pass {
t.Fatalf("expected the gate to fail closed on a workflow with no oath job, got Pass=true")
}
}
+18
View File
@@ -0,0 +1,18 @@
// Package oathcandidate is cad-atlas's committed real candidate: the STEPS that
// gate its own CI-workflow oath (cad-atlas#8). Deliberately a separate module (not
// part of the main cad-atlas module) so var-go's transitive deps (cucumber-expressions,
// goldmark) never link into the deployed atlas binary mirrors swedsl's own
// oath/testdata/selfcandidate pattern.
module oathcandidate
go 1.26.4
require (
git.d-ma.be/mathias/swedsl/oath v0.28.0
gopkg.in/yaml.v3 v3.0.1
)
require (
github.com/cucumber/cucumber-expressions/go/v18 v18.1.0 // indirect
github.com/yuin/goldmark v1.8.2 // indirect
)
+16
View File
@@ -0,0 +1,16 @@
git.d-ma.be/mathias/swedsl/oath v0.28.0 h1:q4WXlGtMlDymhmuw9Pdc025OTLs1wl8THsrz/raeMxs=
git.d-ma.be/mathias/swedsl/oath v0.28.0/go.mod h1:kEOX7Wubf3g/HTKzuoHD4fm6zNSl55cSV/qgy+ezMoI=
github.com/cucumber/cucumber-expressions/go/v18 v18.1.0 h1:zvZFnbmtQxwHq6ru5gHxpfBloLq9wmjoKbdwOzt/XNA=
github.com/cucumber/cucumber-expressions/go/v18 v18.1.0/go.mod h1:+Qe2kvmilsdGRFJ+zlkjXp84rPEf6O/idcoOsvnIORY=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/yuin/goldmark v1.8.2 h1:kEGpgqJXdgbkhcOgBxkC0X0PmoPG1ZyoZ117rDVp4zE=
github.com/yuin/goldmark v1.8.2/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=