generated from mathias/template-go-web
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6967d12d1d | ||
|
|
b2761a4747 | ||
|
|
68ae01cb75 | ||
|
|
2f5fca8513 | ||
|
|
805b76d7c3 | ||
|
|
346037c5c8 | ||
|
|
f7a0281ca2 | ||
|
|
60cc8894a2 |
+10
-3
@@ -54,9 +54,16 @@ assessor-loop ledger) → `06 PR → CI` (go test/vet/lint/govulncheck + **var-g
|
||||
|
||||
This repo is built *through* the workflow it depicts. It is `dispatch-allow`-enabled, and its
|
||||
own build increments are governed by a **var-go Oath** embedded in their spec issues (see the
|
||||
Stage-03 tracking issue). Bootstrapping honesty (per swedsl honest-stub discipline): the Oath is
|
||||
**defined** but `cmd/vargo-gate` is **not yet wired** into this repo's CI — until it is, the Oath
|
||||
is advisory here. Wiring it is a first tracked task; disclosed in code, this doc, and CI config.
|
||||
Stage-03 tracking issue). `cmd/vargo-gate` is wired into `.gitea/workflows/cd.yml`'s `oath` job —
|
||||
on every pull_request it fetches the linked issue's oath and gates cad-atlas's **own real
|
||||
candidate** (`oathcandidate/`, #8): it parses the committed `.gitea/workflows/cd.yml` and checks
|
||||
the `oath` job actually exists and invokes `cmd/vargo-gate`, then posts the `var-go/oath` commit
|
||||
status. This is a real check (TDD'd: passes on the real file, fails closed on a fixture missing
|
||||
the job), not swedsl's toy self-test stub — swedsl#35 (import path) and swedsl#38 (real-candidate
|
||||
subprocess gating) unblocked it. **Required by branch protection on `main`** (#8, closed
|
||||
2026-07-20), confirmed holding on a real PR. Direct pushes remain allowlisted for `mathias` per
|
||||
this repo's TBD convention. Disclosed in the CI config comment, this doc, and
|
||||
`docs/INCEPTION-OATH.md`.
|
||||
|
||||
## Brain references (source of truth — `brain_get <path>`)
|
||||
|
||||
|
||||
@@ -53,6 +53,74 @@ jobs:
|
||||
- name: Run checks
|
||||
run: task check
|
||||
|
||||
- name: oathcandidate module — vet + test (private dep, short-lived askpass)
|
||||
working-directory: oathcandidate
|
||||
run: |
|
||||
set -euo pipefail
|
||||
export DMABE_GITEA_API_TOKEN='${{ secrets.DMABE_GITEA_API_TOKEN }}'
|
||||
ASKPASS=$(mktemp)
|
||||
{ echo '#!/bin/sh'
|
||||
echo 'case "$1" in'
|
||||
echo ' *Username*) echo oauth2 ;;'
|
||||
echo ' *) echo "$DMABE_GITEA_API_TOKEN" ;;'
|
||||
echo 'esac'
|
||||
} > "$ASKPASS"
|
||||
chmod 700 "$ASKPASS"
|
||||
export GIT_ASKPASS="$ASKPASS" GIT_TERMINAL_PROMPT=0 GOPRIVATE=git.d-ma.be
|
||||
go vet ./...
|
||||
go test ./...
|
||||
rm -f "$ASKPASS"
|
||||
|
||||
oath:
|
||||
name: var-go/oath
|
||||
needs: guard
|
||||
# cad-atlas's own real candidate (#8): oathcandidate/ parses the committed
|
||||
# .gitea/workflows/cd.yml and gates it against cad-atlas#8's oath — replacing the
|
||||
# earlier wiring-only proof (#1) that always gated swedsl's toy self-test fixture
|
||||
# and always failed closed. cmd/vargo-gate (swedsl#35/#37/#38) now go-installs
|
||||
# cleanly from its real module path and runs the candidate module in a sandboxed
|
||||
# subprocess (SubprocessGate, ADR-0003) — a green status here means "the committed
|
||||
# CI config satisfies its oath", not merely "the wiring ran". Still NOT required by
|
||||
# branch protection (#8) until proven green on a real PR.
|
||||
if: needs.guard.outputs.is_template != 'true' && github.event_name == 'pull_request'
|
||||
runs-on: self-hosted
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version-file: oathcandidate/go.mod
|
||||
cache: false
|
||||
|
||||
- name: Run vargo-gate (fetch linked oath -> sandboxed-gate the real candidate -> post status)
|
||||
env:
|
||||
VARGO_GITEA_BASEURL: ${{ github.server_url }}
|
||||
VARGO_GITEA_OWNER: ${{ github.repository_owner }}
|
||||
VARGO_GITEA_REPO: cad-atlas
|
||||
# Oath issue resolution (swedsl#38): a "Closes #NN" reference in the PR body
|
||||
# picks the linked oath issue; VARGO_GITEA_ISSUE is the fallback (PR's own
|
||||
# number, correct only for a PR filed directly against its oath issue).
|
||||
VARGO_PR_BODY: ${{ github.event.pull_request.body }}
|
||||
VARGO_GITEA_ISSUE: ${{ github.event.pull_request.number }}
|
||||
VARGO_GITEA_SHA: ${{ github.event.pull_request.head.sha }}
|
||||
VARGO_CANDIDATE_DIR: oathcandidate
|
||||
# Sandbox is ON by default (untrusted PR code runs in a fresh user+net
|
||||
# namespace, swedsl#37); no need to set VARGO_SANDBOX here.
|
||||
run: |
|
||||
set -euo pipefail
|
||||
export DMABE_GITEA_API_TOKEN='${{ secrets.DMABE_GITEA_API_TOKEN }}'
|
||||
ASKPASS=$(mktemp)
|
||||
{ echo '#!/bin/sh'
|
||||
echo 'case "$1" in'
|
||||
echo ' *Username*) echo oauth2 ;;'
|
||||
echo ' *) echo "$DMABE_GITEA_API_TOKEN" ;;'
|
||||
echo 'esac'
|
||||
} > "$ASKPASS"
|
||||
chmod 700 "$ASKPASS"
|
||||
export GIT_ASKPASS="$ASKPASS" GIT_TERMINAL_PROMPT=0 GOPRIVATE=git.d-ma.be
|
||||
go run git.d-ma.be/mathias/swedsl/oath/cmd/vargo-gate@oath/v0.28.0
|
||||
rm -f "$ASKPASS"
|
||||
|
||||
build:
|
||||
name: Build & Import
|
||||
needs: [guard, check]
|
||||
|
||||
+13
-7
@@ -3,8 +3,10 @@
|
||||
The acceptance contract for standing up cad-atlas. The sprint is finalized only when this
|
||||
Oath holds. Methodology: brain `wiki/homelab/decisions/inception-sprint-and-oath.md`.
|
||||
|
||||
> **Status of enforcement:** this Oath is currently **advisory** (human-verified). Machine
|
||||
> enforcement via `var-go/oath` is deferred — see the honesty rule below and issue #1.
|
||||
> **Status of enforcement:** `var-go/oath` gates a real candidate (`oathcandidate/`, #8 — parses
|
||||
> the committed CI workflow, TDD'd pass/fail-closed) and is now **required by branch protection**
|
||||
> on `main` (verified green on a real PR). Direct pushes remain allowlisted for `mathias` per this
|
||||
> repo's TBD convention.
|
||||
|
||||
## General clauses (any inception sprint)
|
||||
|
||||
@@ -24,7 +26,7 @@ Oath holds. Methodology: brain `wiki/homelab/decisions/inception-sprint-and-oath
|
||||
|---|--------|--------|----------|
|
||||
| S1 | Atlas served at `/`, renders all 9 stages signal→pod | ✅ | `internal/web/handler.go` + `static/cad-atlas.html` |
|
||||
| S2 | Oath covered in the viz (stages 03 + 06) | ✅ | var-go Oath nodes in the atlas |
|
||||
| S3 | `var-go/oath` enforces cad-atlas's own PRs | ⏸ **deferred → #1** | var-go v1 candidate is a toy self-test; module not cross-repo consumable. See honesty rule. |
|
||||
| S3 | `var-go/oath` enforces cad-atlas's own PRs | ✅ | `oathcandidate/` gates the real `.gitea/workflows/cd.yml` (TDD green: passes real file, fails closed on a fixture missing the job) via swedsl's sandboxed `SubprocessGate` (swedsl#35/#38). Branch protection on `main` now requires `var-go/oath`, confirmed holding on a real PR (#8). |
|
||||
|
||||
## Deployment
|
||||
|
||||
@@ -36,10 +38,14 @@ namespace `cad-atlas`, 1 replica, `cad-atlas:80 → :8080` (manifests in `mathia
|
||||
## The honesty rule
|
||||
|
||||
A clause blocked by an external dependency is **descoped and tracked, never marked satisfied** —
|
||||
a self-lying Oath is a rubber stamp, the exact failure the Oath exists to prevent. S3's real
|
||||
enforcement depends on a var-go Executor (swedsl#27) + a published `oath` module; it is tracked as
|
||||
a fast-follow on **#1**, not claimed here. The `DMABE_GITEA_API_TOKEN` Actions secret is
|
||||
pre-provisioned so #1 can land without a secret-write.
|
||||
a self-lying Oath is a rubber stamp, the exact failure the Oath exists to prevent. S3 is now fully
|
||||
enforced: real candidate wired and branch-protection-required (#8), confirmed on a real PR. The
|
||||
`DMABE_GITEA_API_TOKEN` Actions secret is pre-provisioned so #1 and #8 both landed without a
|
||||
secret-write.
|
||||
|
||||
Also surfaced by #8: this file's own "Oath (advisory form)" below predates the discovery that
|
||||
var-go's parser requires single-line, period-separated sentences with no `Given`/`Then`/`And`
|
||||
keyword stripping — it has never been machine-gated and would need reformatting first if it ever is.
|
||||
|
||||
## The Oath (advisory form)
|
||||
|
||||
|
||||
@@ -7,49 +7,49 @@
|
||||
],
|
||||
"ns": "Tailscale mesh · ns: ai-stack · supervisor(→brain) · gitea-mcp · infra-mcp · council",
|
||||
"stages": [
|
||||
{"no":"STAGE 00","title":"Signals","plain_title":"Notice what's happening","plain":"New ideas and developments worth reacting to are collected — mostly an automated daily/weekly scan of AI news, plus things saved by hand.","path":"→ mathias/signals",
|
||||
{"no":"STAGE 00","short":"Notice","title":"Signals","plain_title":"Notice what's happening","plain":"New ideas and developments worth reacting to are collected — mostly an automated daily/weekly scan of AI news, plus things saved by hand.","path":"→ mathias/signals",
|
||||
"trans_label":"Does it matter to us?","trans":"A raw signal only advances if it connects to something we actually care about. Most captured signals stop here; the few that touch the mission get pulled up against a goal.","nodes":[
|
||||
{"t":"Applied AI Radar","plain_t":"Automated news scan","plain":"An automated scan reads AI news every day (and deeper every week) and keeps only claims backed by a real paper, benchmark, code, or named lab.","d":"Daily Tier-1 + weekly Tier-2 deep pass. Verified-primary bar (paper/benchmark/code/named-lab).","tags":["cron · daily/weekly","→ signals #1–26+"]},
|
||||
{"t":"Manual capture","plain_t":"Saved by hand","plain":"Anything interesting spotted by hand gets saved into the same inbox.","d":"claude.ai strategic drop · brain capture tool.","tags":["ad-hoc"]},
|
||||
{"t":"Aspirational surfaces","plain_t":"Not built yet","plain":"Planned but not built yet: sending ideas in by Telegram, voice, or a URL.","pill":"var(--dim)","d":"Telegram / voice / URL → inbox. NOT built.","tags":["gap"]}
|
||||
]},
|
||||
{"no":"STAGE 01","cls":"telos","title":"TELOS","plain_title":"Why we're here","plain":"The mission, goals, and problems we're actually trying to solve live here — every piece of work downstream has to trace back to one of these goals.","path":"wiki/telos/",
|
||||
{"no":"STAGE 01","short":"Why","cls":"telos","title":"TELOS","plain_title":"Why we're here","plain":"The mission, goals, and problems we're actually trying to solve live here — every piece of work downstream has to trace back to one of these goals.","path":"wiki/telos/",
|
||||
"trans_label":"Worth a session?","trans":"A goal or problem on the board becomes the seed for a design session when it's decided worth working on now. The goal is the input the session must trace back to.","nodes":[
|
||||
{"t":"Intention substrate","plain_t":"The goal board","plain":"The master list of mission, goals, problems, and current status — the yardstick everything downstream is measured against.","pill":"var(--violet)","d":"Mission · goals · problems · strategies · status. Every downstream item traces to a goal.","tags":["brain_query wing=telos"]}
|
||||
]},
|
||||
{"no":"STAGE 02","title":"Strategic session","plain_title":"Think it through","plain":"A human and AI models work out what to do and why, debating hard calls and writing down the decision and what \"done\" will mean.","path":"claude.ai frontier + brain MCP",
|
||||
{"no":"STAGE 02","short":"Think","title":"Strategic session","plain_title":"Think it through","plain":"A human and AI models work out what to do and why, debating hard calls and writing down the decision and what \"done\" will mean.","path":"claude.ai frontier + brain MCP",
|
||||
"trans_label":"Decision reached","trans":"It advances only when the thinking converges on a decision and is written down as a concrete, testable specification — not while the answer is still open.","nodes":[
|
||||
{"t":"Design · ADRs · specs","plain_t":"Decide the approach","plain":"A human and a top-tier AI model figure out the approach and write down the decision plus what a finished result must prove.","d":"Human + frontier model. ISC acceptance criteria written here.","tags":["Define / converge"]},
|
||||
{"t":"🏛️ LLM Council","plain_t":"AI review panel","plain":"For hard calls, several AI models answer independently, anonymously critique each other, and a \"chair\" model synthesises one verdict — reducing any single model's bias.","cls":"council","pill":"var(--violet)","d":"fan-out → anonymous cross-review → chairman synth. glm-4.7-flash · qwen36-35b · gemma4-31b (chair).","tags":["hard strategic Q","chat.d-ma.be"]},
|
||||
{"t":"Autoresearch Council","plain_t":"Research review panel","plain":"A parallel version of the same review that vets research findings before they're allowed through.","cls":"council","pill":"var(--violet)","d":"Sibling pipe — ratifies research before the gate.","tags":["proposed: → standalone svc"]}
|
||||
]},
|
||||
{"no":"STAGE 03","title":"Spec → Gitea issue","plain_title":"Write the work order","plain":"The decision is turned into a precise, self-contained work order an AI agent can execute unsupervised — with a pass/fail definition of done, a risk rating, and a tamper-proof seal.","path":"agent-ready contract",
|
||||
{"no":"STAGE 03","short":"Write order","title":"Spec → Gitea issue","plain_title":"Write the work order","plain":"The decision is turned into a precise, self-contained work order an AI agent can execute unsupervised — with a pass/fail definition of done, a risk rating, and a tamper-proof seal.","path":"agent-ready contract","generate":"gitea-issues",
|
||||
"trans_label":"Sealed & agent-ready","trans":"Advances to the gate only when the spec is a complete contract: a pass/fail test, a risk tier, a regulatory note, no open human dependencies, one embedded Oath, and a valid cryptographic signature. A malformed or unsigned order fails closed and never reaches the gate.","nodes":[
|
||||
{"t":"Contract enforced","plain_t":"The work-order rules","plain":"The work order must have a clear pass/fail test, a risk rating, a regulatory-risk note, and no unfinished human dependencies before it counts as agent-ready.","d":"Binary ISC · declared risk tier · reg-risk assessment · no open human deps.","tags":["LOW / MED / HIGH"]},
|
||||
{"t":"Admission controller","plain_t":"Tamper-proof seal","plain":"The work order is cryptographically signed when created, so any later tampering is detectable and the eventual change can be checked against it.","d":"Ed25519-sign issue body at creation (#36). Verify sig + PR alignment at infra boundary.","tags":["chain of custody"]},
|
||||
{"t":"⚖️ var-go Oath","plain_t":"Definition of done","plain":"A machine-checkable \"definition of done\" is embedded in the work order — exactly one, or the order is rejected — later used to prove the result actually meets the spec.","cls":"oath","pill":"var(--gold)","d":"Acceptance contract embedded in the issue as a var fenced block. Exactly one — zero/multiple fail closed. Prose → typed steps; failures anchored to byte spans.","tags":["swedsl · var-go","defined here → enforced @06"]}
|
||||
]},
|
||||
{"no":"STAGE 04","cls":"gate","title":"Human dispatch gate","plain_title":"Human says go","plain":"A person reviews the work order and its risk and decides whether to release it — the one and only checkpoint where work does not move on its own.","path":"the only checkpoint",
|
||||
{"no":"STAGE 04","short":"Human go","cls":"gate","title":"Human dispatch gate","plain_title":"Human says go","plain":"A person reviews the work order and its risk and decides whether to release it — the one and only checkpoint where work does not move on its own.","path":"the only checkpoint",
|
||||
"trans_label":"A human said go","trans":"The hard stop. Nothing crosses automatically — a person must review the plan and risk and explicitly release it, and the repo must be on the allow-list, before any agent starts. This is the single human checkpoint in the whole pipeline.","nodes":[
|
||||
{"t":"Human triggers execution","plain_t":"The go button","plain":"A person confirms the plan and its risk level, then releases the work — nothing runs until they do.","cls":"gateway","pill":"var(--amber)","d":"Ratify proposed-plan + risk tier, then dispatch.","gate":true},
|
||||
{"t":"Session-Dispatch bridge","plain_t":"Hand-off to agents","plain":"The approval flips a switch that hands the signed work order over to the agents to start execution.","cls":"bridge","pill":"var(--blue)","d":"claude.ai MCP → gitea:workflow_run_trigger → cad-dispatch.yml → agentsquad. The final design→execution bridge.","tags":["workflow_dispatch"]}
|
||||
]},
|
||||
{"no":"STAGE 05","cls":"exec","title":"Execute · agentsquad","plain_title":"Agents do the work","plain":"AI agents actually build the thing — one writes, a second independent one reviews it to avoid marking its own homework — and every step is logged for the audit trail.","path":"koala · cmd/agentsquad-serve",
|
||||
{"no":"STAGE 05","short":"Build","cls":"exec","title":"Execute · agentsquad","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",
|
||||
"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"]}
|
||||
]},
|
||||
{"no":"STAGE 06","title":"PR → CI","plain_title":"Automatic quality checks","plain":"The proposed change is run through automated tests and safety checks — including a check that it actually satisfies the work order's definition of done — and only a clean pass lets it continue.","path":"Gitea Actions · cd.yml (live)","generate":"ci-jobs",
|
||||
{"no":"STAGE 06","short":"Check","title":"PR → CI","plain_title":"Automatic quality checks","plain":"The proposed change is run through automated tests and safety checks — including a check that it actually satisfies the work order's definition of done — and only a clean pass lets it continue.","path":"Gitea Actions · cd.yml (live)","generate":"ci-jobs",
|
||||
"trans_label":"All checks green","trans":"Advances only if every automated check passes — tests, linters, security scan, and the Oath check proving it meets the original work order. Any red gate stops it here; a passing reviewer is not enough to override a failed Oath.","nodes":[
|
||||
{"t":"Automated checks","plain_t":"Quality checks","plain":"Tests, linters, a security scan, plus a check that the change actually meets the work order — all must pass to continue.","d":"go test · vet · lint · govulncheck + var-go/oath gate.","tags":["green = proceed"]}
|
||||
]},
|
||||
{"no":"STAGE 07","cls":"cd","title":"CD → pod","plain_title":"Ship it","plain":"Once everything is green, the change is deployed automatically to the live server — with the rule that merging code alone doesn't ship it; the release has to be pointed at the new version.","path":"Flux GitOps → k3s","generate":"deploy-state",
|
||||
{"no":"STAGE 07","short":"Ship","cls":"cd","title":"CD → pod","plain_title":"Ship it","plain":"Once everything is green, the change is deployed automatically to the live server — with the rule that merging code alone doesn't ship it; the release has to be pointed at the new version.","path":"Flux GitOps → k3s","generate":"deploy-state",
|
||||
"trans_label":"It's live","trans":"Once the new version is actually running on the server, the deployed outcome becomes the input to scoring. Advancing means shipped and observable, not just merged.","nodes":[
|
||||
{"t":"Deploy on green","plain_t":"Auto-deploy when green","plain":"When all checks pass, the release system rolls the new version onto the live server automatically — but only once the release is pointed at that version (merging code alone doesn't ship it).","pill":"var(--green)","d":"Flux reconciles image → k3s pod on koala. Push ≠ deploy: bump tag in mathias/infra.","tags":["ntfy on deploy"]}
|
||||
]},
|
||||
{"no":"STAGE 08","cls":"telos","title":"Loop back","plain_title":"Did it work?","plain":"The result is scored against the goal that started it and fed back into the mission board, so the next round of planning learns from what shipped.","path":"→ TELOS (feedback bus)",
|
||||
{"no":"STAGE 08","short":"Learn","cls":"telos","title":"Loop back","plain_title":"Did it work?","plain":"The result is scored against the goal that started it and fed back into the mission board, so the next round of planning learns from what shipped.","path":"→ TELOS (feedback bus)",
|
||||
"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"]}
|
||||
]}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -32,6 +33,7 @@ type Node struct {
|
||||
// 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"`
|
||||
@@ -45,11 +47,13 @@ type Stage struct {
|
||||
|
||||
// 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
|
||||
|
||||
+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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -86,6 +86,19 @@
|
||||
.legend .lg{display:inline-flex;align-items:center;gap:6px}
|
||||
.legend .lg i{width:11px;height:11px;border-radius:3px;display:inline-block;border:1px solid rgba(0,0,0,.35)}
|
||||
.legend .dash{width:18px;border-top:2px dashed var(--violet);display:inline-block}
|
||||
/* overview rail — see the whole 9-stage shape + jump to any stage */
|
||||
.rail{display:flex;gap:4px;flex-wrap:wrap;align-items:center;
|
||||
padding:8px 22px;border-bottom:1px solid var(--line);background:var(--panel)}
|
||||
.rail .lbl{color:var(--dim);letter-spacing:1.5px;margin-right:4px;font-size:11px}
|
||||
.railchip{font:inherit;font-size:11px;cursor:pointer;color:var(--ink);
|
||||
background:var(--panel2);border:1px solid var(--line);border-radius:6px;padding:4px 9px}
|
||||
.railchip:hover{border-color:var(--blue)}
|
||||
.railchip b{color:var(--dim);font-weight:600;margin-right:3px}
|
||||
.railchip.gate{border-color:rgba(245,185,66,.5)} .railchip.telos{border-color:rgba(155,140,255,.5)}
|
||||
.railchip.exec{border-color:rgba(255,122,92,.5)} .railchip.cd{border-color:rgba(74,208,122,.5)}
|
||||
.railarr{color:var(--dim);font-size:10px}
|
||||
/* Plain view: drop decorative node pills so colour is reserved for live status (keyed in the legend) */
|
||||
body.plain .node .pill{display:none}
|
||||
|
||||
.scroll{overflow-x:auto;padding:24px 22px 20px}
|
||||
.track{position:relative;display:flex;align-items:flex-start;min-width:max-content}
|
||||
@@ -98,8 +111,8 @@
|
||||
.stage .no{color:var(--dim);font-size:11px;letter-spacing:2px}
|
||||
.stage h2{font-size:17px;margin:6px 0 2px}
|
||||
.stage .path{color:var(--mono);font-size:11.5px;margin-bottom:6px;min-height:16px}
|
||||
.node{border:1px solid var(--line);border-radius:11px;background:var(--panel);
|
||||
padding:12px 13px;margin-top:12px;position:relative;
|
||||
.node{display:block;border:1px solid var(--line);border-radius:11px;background:var(--panel);
|
||||
padding:12px 13px;margin-top:12px;position:relative;color:inherit;text-decoration:none;
|
||||
transition:border-color .25s,box-shadow .25s,transform .25s}
|
||||
.node .t{font-weight:600;margin-bottom:3px;display:flex;align-items:center;gap:7px}
|
||||
.node .d{color:var(--dim);font-size:12px}
|
||||
@@ -167,6 +180,7 @@
|
||||
<span class="lg"><i style="background:var(--gold)"></i>governance gate — must pass to advance</span>
|
||||
<span class="lg"><span class="dash"></span> feedback loop · partly manual</span>
|
||||
</div>
|
||||
<div class="rail mono" id="rail"></div>
|
||||
|
||||
<div class="scroll">
|
||||
<div class="track" id="track">
|
||||
@@ -200,7 +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');
|
||||
@@ -232,17 +246,33 @@ function renderAtlas(){
|
||||
if(n.risk)inner+=`<div class="risk mono"><span class="lo">LOW · auto</span><span class="md">MED · ntfy gate</span><span class="hi">HIGH · blocked</span></div>`;
|
||||
}
|
||||
if(n.gate)inner+=`<div class="gatebtns mono"><div class="g ok">✓ approve</div><div class="g no">✕ reject</div></div>`;
|
||||
h+=`<div class="node ${n.cls||''}">${inner}</div>`;
|
||||
const tag = n.url ? 'a' : 'div';
|
||||
const link = n.url ? ` href="${n.url}" target="_blank" rel="noopener"` : '';
|
||||
h+=`<${tag} class="node ${n.cls||''}"${link}>${inner}</${tag}>`;
|
||||
});
|
||||
st.innerHTML=h;track.appendChild(st);stageEls.push(st);
|
||||
// stacked-layout transition row (shown on mobile where the SVG spine is hidden)
|
||||
if(s.trans_label){
|
||||
const tr=document.createElement('div');
|
||||
tr.className='trans-row'+((idx===4||idx===6)?' trans-row-gate':'');
|
||||
tr.innerHTML=`<span class="tr-lbl">${s.trans_label}</span>${s.trans?' '+s.trans:''}`;
|
||||
tr.innerHTML=`<span class="tr-lbl">${(idx===4||idx===6)?'🔒 ':''}${s.trans_label}</span>${s.trans?' '+s.trans:''}`;
|
||||
track.appendChild(tr);
|
||||
}
|
||||
});
|
||||
renderRail();
|
||||
}
|
||||
|
||||
function renderRail(){
|
||||
const rail=document.getElementById('rail');
|
||||
rail.innerHTML='<span class="lbl">PIPELINE</span>';
|
||||
STAGES.forEach((s,i)=>{
|
||||
const c=document.createElement('button');
|
||||
c.className='railchip'+(s.cls?' '+s.cls:'');
|
||||
c.innerHTML=`<b>${(s.no||'').replace('STAGE ','')}</b>${s.short||s.plain_title||s.title}`;
|
||||
c.onclick=()=>{ if(stageEls[i]) stageEls[i].scrollIntoView({behavior:'smooth',inline:'center',block:'nearest'}); };
|
||||
rail.appendChild(c);
|
||||
if(i<STAGES.length-1){const a=document.createElement('span');a.className='railarr';a.textContent='→';rail.appendChild(a);}
|
||||
});
|
||||
}
|
||||
|
||||
function renderTimeline(){
|
||||
@@ -293,21 +323,54 @@ function build(){
|
||||
t.setAttribute('x',(cs[i]+cs[i+1])/2);t.setAttribute('y',RAILY-9);
|
||||
t.setAttribute('text-anchor','middle');
|
||||
t.setAttribute('class',gate?'translabel translabel-gate':'translabel');
|
||||
t.textContent=(gate?'▸ ':'')+s.trans_label;
|
||||
t.textContent=(gate?'🔒 ':'')+s.trans_label;
|
||||
const ttl=document.createElementNS('http://www.w3.org/2000/svg','title');
|
||||
ttl.textContent=s.trans||''; t.appendChild(ttl);
|
||||
tg.appendChild(t);
|
||||
}
|
||||
spineLen=spinePath.getTotalLength();loopLen=loopPath.getTotalLength();
|
||||
}
|
||||
// weightedSpineDist maps f (0..1 time-progress across the spine) to an arc-length
|
||||
// distance. Default weight per stage-to-stage segment is its own pixel length —
|
||||
// reduces to the old constant-pixel-speed sweep. When a real run's CI/CD durations
|
||||
// are known, the pixel-time-budget already held by the 06(CI)/07(CD) segments is
|
||||
// re-split by their real relative duration instead of by raw pixel width — so the
|
||||
// reel visits stage 06 vs 07 at speeds proportional to how long they actually took.
|
||||
function weightedSpineDist(f){
|
||||
const n=cs.length-1;
|
||||
if(n<=0)return 0;
|
||||
const segLens=[];for(let i=0;i<n;i++)segLens.push(cs[i+1]-cs[i]);
|
||||
const weights=segLens.slice();
|
||||
if(CI_DUR>0&&CD_DUR>0&&n>=7){
|
||||
const budget=weights[5]+weights[6], tot=CI_DUR+CD_DUR;
|
||||
weights[5]=budget*CI_DUR/tot; weights[6]=budget*CD_DUR/tot;
|
||||
}
|
||||
const totalW=weights.reduce((a,b)=>a+b,0);
|
||||
const target=f*totalW;
|
||||
let acc=0;
|
||||
for(let i=0;i<n;i++){
|
||||
if(target<=acc+weights[i]||i===n-1){
|
||||
const local=weights[i]>0?(target-acc)/weights[i]:0;
|
||||
const segStart=cs[i]-cs[0];
|
||||
return Math.min(spineLen,Math.max(0,segStart+segLens[i]*Math.min(1,Math.max(0,local))));
|
||||
}
|
||||
acc+=weights[i];
|
||||
}
|
||||
return spineLen;
|
||||
}
|
||||
function run(ts){
|
||||
if(mobile)return;
|
||||
if(!t0)t0=ts;
|
||||
const dur=slow?16000:7000;
|
||||
const p=Math.min((ts-t0)/dur,1);
|
||||
const total=spineLen+loopLen, dist=total*p;
|
||||
let pt,onLoop=dist>spineLen;
|
||||
pt=onLoop?loopPath.getPointAtLength(dist-spineLen):spinePath.getPointAtLength(dist);
|
||||
const spineFrac=spineLen/(spineLen+loopLen);
|
||||
let dist,onLoop;
|
||||
if(p<spineFrac){
|
||||
dist=weightedSpineDist(p/spineFrac); onLoop=false;
|
||||
}else{
|
||||
dist=spineLen+loopLen*((p-spineFrac)/(1-spineFrac)); onLoop=true;
|
||||
}
|
||||
let pt=onLoop?loopPath.getPointAtLength(dist-spineLen):spinePath.getPointAtLength(dist);
|
||||
pulse.style.left=pt.x+'px';pulse.style.top=pt.y+'px';
|
||||
pulse.style.background=onLoop?'var(--violet)':'var(--amber)';
|
||||
pulse.style.boxShadow=onLoop?'0 0 15px 4px rgba(155,140,255,.75)':'0 0 15px 4px rgba(245,185,66,.75)';
|
||||
@@ -340,6 +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);
|
||||
|
||||
@@ -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