generated from mathias/template-go-web
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
62955c998d | ||
|
|
97d0f56383 | ||
|
|
4ab65a3b1b | ||
|
|
c35c9699d4 | ||
|
|
1fdd7d2e3d | ||
|
|
99fb969d27 | ||
|
|
294d9e2fc5 | ||
|
|
6967d12d1d |
Executable
+121
@@ -0,0 +1,121 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Build an OCI image with buildctl and push it to the local registry.
|
||||||
|
#
|
||||||
|
# CANONICAL COPY: mathias/infra ci-templates/build-image.sh (infra#309).
|
||||||
|
# Every repo carries a byte-identical copy at .gitea/build-image.sh, written by
|
||||||
|
# `task ci:sync` and held in place by the drift gate. DO NOT EDIT THE COPIES --
|
||||||
|
# edit here, re-sync, and the gate will tell you which repos still disagree.
|
||||||
|
#
|
||||||
|
# Why this exists: this step was hand-copied into a dozen repos, so migrating it
|
||||||
|
# off rootless buildah (infra#241) meant eleven near-identical commits, eleven
|
||||||
|
# verifications, and three separate rediscoveries of the same fix in the two
|
||||||
|
# repo templates and the gitea-ci skill. The differences between the copies were
|
||||||
|
# incidental -- a label here, a build-arg there -- never intentional. Those are
|
||||||
|
# inputs now, not forks of the implementation.
|
||||||
|
#
|
||||||
|
# Why buildctl and not buildah: the gitea-runner pod runs with
|
||||||
|
# `capabilities: drop [ALL]` and `allowPrivilegeEscalation: false`, which
|
||||||
|
# unconditionally blocks the setuid-root exec that rootless buildah's uid/gid
|
||||||
|
# remapping needs, and the pod has no OCI runtime at all. buildctl is a thin
|
||||||
|
# client -- it hands the build to the host's buildkitd over a socket, so the
|
||||||
|
# privileged layer work happens on the host and this pod's securityContext is
|
||||||
|
# not in the picture. buildah is absent from the runner image as of baked-v14
|
||||||
|
# and its absence is asserted at pod start (infra#308).
|
||||||
|
#
|
||||||
|
# Inputs, all via environment:
|
||||||
|
#
|
||||||
|
# IMAGE (required) image name, e.g. cobalt-dingo
|
||||||
|
# REGISTRY default localhost:5000
|
||||||
|
# SHA_TAG default `git rev-parse --short HEAD`
|
||||||
|
# VERSION_TAG optional extra tag, e.g. v1.2.3 on a tag build
|
||||||
|
# PUSH_LATEST default 1; set 0 to skip the :latest tag
|
||||||
|
# CONTEXT default . -- build context directory
|
||||||
|
# DOCKERFILE_DIR default $CONTEXT -- directory holding the Dockerfile
|
||||||
|
# DOCKERFILE_NAME optional, when the file is not named `Dockerfile`
|
||||||
|
# TARGET optional, multi-stage build target (--opt target=)
|
||||||
|
# BUILD_ARGS optional, space-separated K=V pairs
|
||||||
|
# BUILD_SECRETS optional, space-separated buildkit secret specs, each
|
||||||
|
# `id=NAME,src=PATH`. Pass the SPEC only -- the caller
|
||||||
|
# writes the secret to PATH itself. A secret VALUE must
|
||||||
|
# never be passed here: it would land in this script's
|
||||||
|
# argv and therefore in the CI log.
|
||||||
|
# EXTRA_LABELS optional, space-separated K=V pairs
|
||||||
|
# GIT_SHA optional, stamped as org.opencontainers.image.revision
|
||||||
|
# IMAGE_SOURCE optional, stamped as org.opencontainers.image.source
|
||||||
|
#
|
||||||
|
# Note on shell style below: every conditional is a full `if` block, never
|
||||||
|
# `[ -n "$X" ] && cmd`. Under `set -e` the && form exits the script whenever the
|
||||||
|
# test is false, which is a real bug this estate has already shipped once.
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
if [ -z "${IMAGE:-}" ]; then
|
||||||
|
echo "build-image: IMAGE is required" >&2
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
|
||||||
|
REGISTRY="${REGISTRY:-localhost:5000}"
|
||||||
|
BUILDKIT_HOST="${BUILDKIT_HOST:-unix:///run/buildkit/buildkitd.sock}"
|
||||||
|
CONTEXT="${CONTEXT:-.}"
|
||||||
|
DOCKERFILE_DIR="${DOCKERFILE_DIR:-$CONTEXT}"
|
||||||
|
PUSH_LATEST="${PUSH_LATEST:-1}"
|
||||||
|
VERSION_TAG="${VERSION_TAG:-}"
|
||||||
|
|
||||||
|
if [ -z "${SHA_TAG:-}" ]; then
|
||||||
|
SHA_TAG="$(git rev-parse --short HEAD)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
REF="${REGISTRY}/${IMAGE}:${SHA_TAG}"
|
||||||
|
|
||||||
|
TAR_FILE="$(mktemp)"
|
||||||
|
trap 'rm -f "$TAR_FILE"' EXIT
|
||||||
|
|
||||||
|
args=(
|
||||||
|
--frontend dockerfile.v0
|
||||||
|
--local "context=${CONTEXT}"
|
||||||
|
--local "dockerfile=${DOCKERFILE_DIR}"
|
||||||
|
)
|
||||||
|
|
||||||
|
if [ -n "${DOCKERFILE_NAME:-}" ]; then
|
||||||
|
args+=(--opt "filename=${DOCKERFILE_NAME}")
|
||||||
|
fi
|
||||||
|
if [ -n "${TARGET:-}" ]; then
|
||||||
|
args+=(--opt "target=${TARGET}")
|
||||||
|
fi
|
||||||
|
for spec in ${BUILD_SECRETS:-}; do
|
||||||
|
# Opaque id=NAME,src=PATH -- buildkit reads the file, nothing is echoed.
|
||||||
|
args+=(--secret "${spec}")
|
||||||
|
done
|
||||||
|
if [ -n "${GIT_SHA:-}" ]; then
|
||||||
|
args+=(--opt "label:org.opencontainers.image.revision=${GIT_SHA}")
|
||||||
|
fi
|
||||||
|
if [ -n "${IMAGE_SOURCE:-}" ]; then
|
||||||
|
args+=(--opt "label:org.opencontainers.image.source=${IMAGE_SOURCE}")
|
||||||
|
fi
|
||||||
|
for kv in ${BUILD_ARGS:-}; do
|
||||||
|
args+=(--opt "build-arg:${kv}")
|
||||||
|
done
|
||||||
|
for kv in ${EXTRA_LABELS:-}; do
|
||||||
|
args+=(--opt "label:${kv}")
|
||||||
|
done
|
||||||
|
|
||||||
|
echo "build-image: building ${REF}"
|
||||||
|
buildctl --addr "$BUILDKIT_HOST" build "${args[@]}" --output "type=oci,dest=${TAR_FILE}"
|
||||||
|
|
||||||
|
# buildctl has no registry-push equivalent to `buildah push`, so skopeo copies
|
||||||
|
# the OCI archive up -- once per tag, from the same archive, so every tag points
|
||||||
|
# at bit-identical content.
|
||||||
|
push_tag() {
|
||||||
|
echo "build-image: pushing ${1}"
|
||||||
|
skopeo copy --dest-tls-verify=false "oci-archive:${TAR_FILE}" "docker://${1}"
|
||||||
|
}
|
||||||
|
|
||||||
|
push_tag "$REF"
|
||||||
|
if [ "$PUSH_LATEST" = "1" ]; then
|
||||||
|
push_tag "${REGISTRY}/${IMAGE}:latest"
|
||||||
|
fi
|
||||||
|
if [ -n "$VERSION_TAG" ]; then
|
||||||
|
push_tag "${REGISTRY}/${IMAGE}:${VERSION_TAG}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "✓ build-image: ${REF} pushed"
|
||||||
+33
-11
@@ -142,18 +142,33 @@ jobs:
|
|||||||
echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
|
echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
- name: Build and push to local registry
|
- name: Build and push to local registry
|
||||||
|
# infra#309: implementation lives in .gitea/build-image.sh, a
|
||||||
|
# byte-identical copy of mathias/infra's ci-templates/build-image.sh.
|
||||||
|
# Do not edit it here -- edit the canonical file, run `task ci:sync`,
|
||||||
|
# and infra's drift gate names any repo still carrying an old copy.
|
||||||
|
#
|
||||||
|
# cad-atlas#11: the build now fetches git.d-ma.be/mathias/cad, a
|
||||||
|
# PRIVATE module (unlike mcp-chassis, the only other internal module
|
||||||
|
# any cad-* repo's Dockerfile has fetched so far, which is public) --
|
||||||
|
# `go mod download` inside the Dockerfile needs to authenticate. The
|
||||||
|
# token is written to a file ONLY for the build-image.sh subprocess and
|
||||||
|
# passed as a BuildKit secret (build-image.sh's existing BUILD_SECRETS
|
||||||
|
# mechanism: "the caller writes the secret to PATH itself... a secret
|
||||||
|
# VALUE must never be passed here"), never as a build-arg or env var
|
||||||
|
# that would land in the image or this log.
|
||||||
|
env:
|
||||||
|
SHA_TAG: ${{ steps.meta.outputs.sha-tag }}
|
||||||
|
GIT_SHA: ${{ github.sha }}
|
||||||
|
BUILD_ARGS: VERSION=${{ steps.meta.outputs.version }}
|
||||||
|
DMABE_GITEA_API_TOKEN: ${{ secrets.DMABE_GITEA_API_TOKEN }}
|
||||||
run: |
|
run: |
|
||||||
REGISTRY="localhost:5000"
|
set -euo pipefail
|
||||||
REF="${REGISTRY}/${{ env.IMAGE }}:${{ steps.meta.outputs.sha-tag }}"
|
TOKEN_FILE=$(mktemp)
|
||||||
buildah build \
|
printf '%s' "$DMABE_GITEA_API_TOKEN" > "$TOKEN_FILE"
|
||||||
--build-arg VERSION="${{ steps.meta.outputs.version }}" \
|
chmod 600 "$TOKEN_FILE"
|
||||||
--label "org.opencontainers.image.revision=${{ github.sha }}" \
|
export BUILD_SECRETS="id=gitea_token,src=${TOKEN_FILE}"
|
||||||
-t ${REF} \
|
bash .gitea/build-image.sh
|
||||||
-t ${REGISTRY}/${{ env.IMAGE }}:latest \
|
shred -u "$TOKEN_FILE"
|
||||||
.
|
|
||||||
buildah push --tls-verify=false ${REF}
|
|
||||||
buildah push --tls-verify=false ${REGISTRY}/${{ env.IMAGE }}:latest
|
|
||||||
echo "✓ Image pushed to ${REF}"
|
|
||||||
|
|
||||||
deploy:
|
deploy:
|
||||||
name: Deploy via GitOps
|
name: Deploy via GitOps
|
||||||
@@ -167,6 +182,13 @@ jobs:
|
|||||||
DEPLOY_KEY: ${{ secrets.INFRA_DEPLOY_KEY }}
|
DEPLOY_KEY: ${{ secrets.INFRA_DEPLOY_KEY }}
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
# infra#311: fail on an empty tag BEFORE patching anything. The
|
||||||
|
# grep verification further down CANNOT catch it -- with IMAGE_TAG
|
||||||
|
# empty its pattern matches the very corruption it exists to reject.
|
||||||
|
# The result is `image: <repo>:` in the manifest, which is invalid
|
||||||
|
# YAML, so Flux fails the ENTIRE apps Kustomization and every app
|
||||||
|
# stops reconciling, not just this one. That happened on 2026-08-25.
|
||||||
|
[ -n "${IMAGE_TAG:-}" ] || { echo "IMAGE_TAG is empty - refusing to patch"; exit 1; }
|
||||||
mkdir -p ~/.ssh
|
mkdir -p ~/.ssh
|
||||||
echo "$DEPLOY_KEY" > ~/.ssh/id_infra
|
echo "$DEPLOY_KEY" > ~/.ssh/id_infra
|
||||||
chmod 600 ~/.ssh/id_infra
|
chmod 600 ~/.ssh/id_infra
|
||||||
|
|||||||
+29
-3
@@ -1,9 +1,35 @@
|
|||||||
FROM golang:1.26-alpine AS build
|
FROM golang:1.26-alpine AS build
|
||||||
WORKDIR /src
|
WORKDIR /src
|
||||||
RUN apk add --no-cache git
|
# Fetch internal git-hosted Go modules (cad-atlas now depends on
|
||||||
|
# git.d-ma.be/mathias/cad — cad-atlas#11, reusing cad#14's cad-stuck query
|
||||||
|
# logic rather than duplicating the taxonomy) without going through
|
||||||
|
# proxy.golang.org. Gitea returns http:// in its go-import meta tag, so
|
||||||
|
# rewrite to https and bypass the module proxy + sumdb (mirrors mathias/cad's
|
||||||
|
# own Dockerfile — same trick, same reason).
|
||||||
|
#
|
||||||
|
# UNLIKE mcp-chassis (public, what cad's own Dockerfile fetches this way),
|
||||||
|
# mathias/cad is a PRIVATE repo, so the plain insteadOf rewrite above is not
|
||||||
|
# enough — `go mod download` needs to authenticate. Use a GIT_ASKPASS helper
|
||||||
|
# reading a BuildKit secret mount (mirrors the GIT_ASKPASS pattern this
|
||||||
|
# repo's own cd.yml already uses for the oathcandidate module's private
|
||||||
|
# swedsl dependency): the token is never written to a persisted layer — the
|
||||||
|
# askpass script and its reference to /run/secrets/gitea_token are created
|
||||||
|
# and removed within the same RUN instruction, and the secret mount itself
|
||||||
|
# is never part of any layer diff. Caller passes it via
|
||||||
|
# `BUILD_SECRETS="id=gitea_token,src=<path>"` (build-image.sh's existing
|
||||||
|
# mechanism) — see cd.yml.
|
||||||
|
RUN apk add --no-cache git && \
|
||||||
|
git config --global url."https://git.d-ma.be/".insteadOf "http://git.d-ma.be/"
|
||||||
|
ENV GOPRIVATE=git.d-ma.be
|
||||||
|
ENV GOPROXY=direct
|
||||||
|
ENV GOSUMDB=off
|
||||||
RUN go install github.com/a-h/templ/cmd/templ@latest
|
RUN go install github.com/a-h/templ/cmd/templ@latest
|
||||||
COPY go.mod ./
|
COPY go.mod go.sum ./
|
||||||
RUN go mod download
|
RUN --mount=type=secret,id=gitea_token \
|
||||||
|
printf '#!/bin/sh\ncase "$1" in *sername*) echo oauth2 ;; *) cat /run/secrets/gitea_token ;; esac\n' > /tmp/askpass.sh && \
|
||||||
|
chmod 700 /tmp/askpass.sh && \
|
||||||
|
GIT_ASKPASS=/tmp/askpass.sh GIT_TERMINAL_PROMPT=0 go mod download && \
|
||||||
|
rm -f /tmp/askpass.sh
|
||||||
COPY . .
|
COPY . .
|
||||||
ARG VERSION=dev
|
ARG VERSION=dev
|
||||||
RUN templ generate && CGO_ENABLED=0 go build -trimpath \
|
RUN templ generate && CGO_ENABLED=0 go build -trimpath \
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
module git.d-ma.be/mathias/cad-atlas
|
module git.d-ma.be/mathias/cad-atlas
|
||||||
|
|
||||||
go 1.26
|
go 1.26.4
|
||||||
|
|
||||||
require github.com/a-h/templ v0.3.1020
|
require github.com/a-h/templ v0.3.1020
|
||||||
|
|
||||||
require gopkg.in/yaml.v3 v3.0.1
|
require gopkg.in/yaml.v3 v3.0.1
|
||||||
|
|
||||||
|
require git.d-ma.be/mathias/cad v0.10.0
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
git.d-ma.be/mathias/cad v0.10.0 h1:eK94XBO5oLAsUL0tQQaRzlw8bDNy8COSMLqQZcscuA0=
|
||||||
|
git.d-ma.be/mathias/cad v0.10.0/go.mod h1:24ESmBwu/8kpVAo2F+G+JCm2JeQn2FKawoW4TfUEAg0=
|
||||||
github.com/a-h/templ v0.3.1020 h1:ypAT/L5ySWEnZ6Zft/5yfoWXYYkhFNvEFOeeqecg4tw=
|
github.com/a-h/templ v0.3.1020 h1:ypAT/L5ySWEnZ6Zft/5yfoWXYYkhFNvEFOeeqecg4tw=
|
||||||
github.com/a-h/templ v0.3.1020/go.mod h1:A2DlK61v+K+NRoGnhmYbNYVmtYHcFO5/AisMvBdDxTM=
|
github.com/a-h/templ v0.3.1020/go.mod h1:A2DlK61v+K+NRoGnhmYbNYVmtYHcFO5/AisMvBdDxTM=
|
||||||
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
|
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
|
||||||
|
|||||||
@@ -29,17 +29,21 @@
|
|||||||
{"t":"Admission controller","plain_t":"Tamper-proof seal","plain":"The work order is cryptographically signed when created, so any later tampering is detectable and the eventual change can be checked against it.","d":"Ed25519-sign issue body at creation (#36). Verify sig + PR alignment at infra boundary.","tags":["chain of custody"]},
|
{"t":"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"]}
|
{"t":"⚖️ var-go Oath","plain_t":"Definition of done","plain":"A machine-checkable \"definition of done\" is embedded in the work order — exactly one, or the order is rejected — later used to prove the result actually meets the spec.","cls":"oath","pill":"var(--gold)","d":"Acceptance contract embedded in the issue as a var fenced block. Exactly one — zero/multiple fail closed. Prose → typed steps; failures anchored to byte spans.","tags":["swedsl · var-go","defined here → enforced @06"]}
|
||||||
]},
|
]},
|
||||||
{"no":"STAGE 04","short":"Human go","cls":"gate","title":"Human dispatch gate","plain_title":"Human says go","plain":"A person reviews the work order and its risk and decides whether to release it — the one and only checkpoint where work does not move on its own.","path":"the only checkpoint",
|
{"no":"STAGE 04","short":"Human go","cls":"gate","title":"Human dispatch gate","plain_title":"Human says go","plain":"A person reviews the work order and its risk and decides whether to release it — the one and only checkpoint where work does not move on its own.","path":"the only checkpoint","generate":"stuck-badges",
|
||||||
"trans_label":"A human said go","trans":"The hard stop. Nothing crosses automatically — a person must review the plan and risk and explicitly release it, and the repo must be on the allow-list, before any agent starts. This is the single human checkpoint in the whole pipeline.","nodes":[
|
"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":"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"]}
|
{"t":"Session-Dispatch bridge","plain_t":"Hand-off to agents","plain":"The approval flips a switch that hands the signed work order over to the agents to start execution.","cls":"bridge","pill":"var(--blue)","d":"claude.ai MCP → gitea:workflow_run_trigger → cad-dispatch.yml → agentsquad. The final design→execution bridge.","tags":["workflow_dispatch"]}
|
||||||
]},
|
]},
|
||||||
{"no":"STAGE 05","short":"Build","cls":"exec","title":"Execute · agentsquad","plain_title":"Agents do the work","plain":"AI agents actually build the thing — one writes, a second independent one reviews it to avoid marking its own homework — and every step is logged for the audit trail.","path":"koala · cmd/agentsquad-serve",
|
{"no":"STAGE 05","short":"Build","cls":"exec","title":"Execute · agentsquad + dispatch","plain_title":"Agents do the work","plain":"AI agents actually build the thing — one writes, a second independent one reviews it to avoid marking its own homework — and every step is logged for the audit trail. Two execution paths run today: agentsquad for orchestrated multi-agent jobs, and dispatch for headless single-issue work.","path":"koala · agentsquad-serve + dispatch CronJob","generate":"stuck-badges",
|
||||||
"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":[
|
"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":"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":"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":"dma-cli · routing + scope","plain_t":"Router & guardrails","plain":"A router sends each agent to the right AI backend and enforces what it is and isn't allowed to touch, with a confirmation gate as a guardrail.","cls":"bridge","pill":"var(--blue)","d":"Harness-config arm: routes agents to the right LLM backend. Three-layer scope policy + confirmation gate = CAD guardrail.","tags":["backend routing","scope guardrail"]},
|
||||||
{"t":"assessor-loop ledger","plain_t":"Audit log","plain":"Every step is recorded in a tamper-evident log so the whole run can be audited afterwards.","d":"Attestation ledger (audit trail) + brain session_log on completion.","tags":["audit package"]}
|
{"t":"assessor-loop ledger","plain_t":"Audit log","plain":"Every step is recorded in a tamper-evident log so the whole run can be audited afterwards.","d":"Attestation ledger (audit trail) + brain session_log on completion.","tags":["audit package"]},
|
||||||
|
{"t":"dispatch · headless issue runs","plain_t":"Picking up work by itself","plain":"A watcher checks the tracker every minute for issues marked ready, and runs one agent per issue on its own — no human starts it. Whether the work is really done is decided by the automated checks, never by the agent saying so.","pill":"var(--coral)","d":"CronJob polls status:dispatch in allow-listed repos (.dispatch-allow marker, fail-closed). Claims, runs, reports; CI-green on the pushed commit is the only done-signal.","tags":["1-min poll","CI is ground truth"]},
|
||||||
|
{"t":"Two independent executors","plain_t":"Two different AI providers","plain":"The same job can be run by either of two different AI providers, chosen per issue. When one is unavailable — an empty account stopped everything for six weeks — the other still works.","cls":"win","pill":"var(--blue)","d":"executor:<name> label routes to claude (Anthropic SDK runner) or kimi (berget/Kimi-K2.6 via LiteLLM, EU-metered, Go-native loop). Provider independence after a 6-week single-provider outage.","tags":["executor: label","EU option"],"risk":true},
|
||||||
|
{"t":"Fail-closed tool policy","plain_t":"What an agent may touch","plain":"Each agent may only use an approved short list of tools and commands; anything else is refused outright rather than asked about, since no human is watching. Its file access cannot leave its own copy of the repo, and each run has a hard limit on both steps and money spent.","cls":"gateway","pill":"var(--amber)","d":"One executor.Policy for both executors: tool + bash-prefix allowlist, closed env-prefix list, exact MCP tool names. Writes confined to the checkout (symlink-resolved, O_NOFOLLOW). Per-run turn and USD ceilings; spend ledgered against a rolling cap.","tags":["deny by default","per-run ceilings"]},
|
||||||
|
{"t":"Read-only PR review","plain_t":"A second agent reads the change","plain":"A pull request can be handed to an agent that may only read and comment, never edit. Its review is thrown away unless it points at a file the change actually touched — which catches a reviewer that skimmed the title instead of the code. New commits re-open the review by themselves.","pill":"var(--coral)","d":"status:review → read-only tools (no write, no shell). Published only if grounded: must cite a file in the PR's changed-files list. Handling anchored to the head sha, so a push re-opens it; attempts capped per revision.","tags":["grounded or dropped","re-opens on push"]}
|
||||||
]},
|
]},
|
||||||
{"no":"STAGE 06","short":"Check","title":"PR → CI","plain_title":"Automatic quality checks","plain":"The proposed change is run through automated tests and safety checks — including a check that it actually satisfies the work order's definition of done — and only a clean pass lets it continue.","path":"Gitea Actions · cd.yml (live)","generate":"ci-jobs",
|
{"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":[
|
"trans_label":"All checks green","trans":"Advances only if every automated check passes — tests, linters, security scan, and the Oath check proving it meets the original work order. Any red gate stops it here; a passing reviewer is not enough to override a failed Oath.","nodes":[
|
||||||
@@ -49,7 +53,7 @@
|
|||||||
"trans_label":"It's live","trans":"Once the new version is actually running on the server, the deployed outcome becomes the input to scoring. Advancing means shipped and observable, not just merged.","nodes":[
|
"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"]}
|
{"t":"Deploy on green","plain_t":"Auto-deploy when green","plain":"When all checks pass, the release system rolls the new version onto the live server automatically — but only once the release is pointed at that version (merging code alone doesn't ship it).","pill":"var(--green)","d":"Flux reconciles image → k3s pod on koala. Push ≠ deploy: bump tag in mathias/infra.","tags":["ntfy on deploy"]}
|
||||||
]},
|
]},
|
||||||
{"no":"STAGE 08","short":"Learn","cls":"telos","title":"Loop back","plain_title":"Did it work?","plain":"The result is scored against the goal that started it and fed back into the mission board, so the next round of planning learns from what shipped.","path":"→ TELOS (feedback bus)",
|
{"no":"STAGE 08","short":"Learn","cls":"telos","title":"Loop back","plain_title":"Did it work?","plain":"The result is scored against the goal that started it and fed back into the mission board, so the next round of planning learns from what shipped.","path":"→ TELOS (feedback bus)","generate":"stuck-badges",
|
||||||
"trans_label":"What did we learn?","trans":"The scored outcome flows back into the mission board so goals, problems, and priorities update — the loop that makes the pipeline a cycle rather than a line. Partly manual today; an explicit improvement target.","nodes":[
|
"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"]}
|
{"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"]}
|
||||||
]}
|
]}
|
||||||
|
|||||||
+14
-6
@@ -42,16 +42,24 @@ type Stage struct {
|
|||||||
Generate string `json:"generate,omitempty"`
|
Generate string `json:"generate,omitempty"`
|
||||||
TransLabel string `json:"trans_label,omitempty"`
|
TransLabel string `json:"trans_label,omitempty"`
|
||||||
Trans string `json:"trans,omitempty"`
|
Trans string `json:"trans,omitempty"`
|
||||||
Nodes []Node `json:"nodes"`
|
// StuckCount/AwaitingCount are set by ApplyStuckOverlay (cad-atlas#11) on
|
||||||
|
// stages marked `"generate":"stuck-badges"` — the two visual classes the
|
||||||
|
// issue asks for kept as separate counts so a functioning control
|
||||||
|
// (awaiting human) can never be rendered as a bottleneck (stuck).
|
||||||
|
StuckCount int `json:"stuck_count,omitempty"`
|
||||||
|
AwaitingCount int `json:"awaiting_count,omitempty"`
|
||||||
|
Nodes []Node `json:"nodes"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Atlas is the full data model the frontend renders.
|
// Atlas is the full data model the frontend renders.
|
||||||
type Atlas struct {
|
type Atlas struct {
|
||||||
Version string `json:"version,omitempty"`
|
Version string `json:"version,omitempty"`
|
||||||
Substrate []Host `json:"substrate"`
|
Substrate []Host `json:"substrate"`
|
||||||
NS string `json:"ns,omitempty"`
|
NS string `json:"ns,omitempty"`
|
||||||
Timeline []RunDot `json:"timeline,omitempty"`
|
Timeline []RunDot `json:"timeline,omitempty"`
|
||||||
Stages []Stage `json:"stages"`
|
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
|
// Build unmarshals the authored atlas JSON and overlays generated facts from
|
||||||
|
|||||||
+37
-2
@@ -3,13 +3,18 @@ package atlas
|
|||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"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 {
|
type Job struct {
|
||||||
Name string
|
Name string
|
||||||
Status string
|
Status string
|
||||||
Conclusion string
|
Conclusion string
|
||||||
|
Started time.Time
|
||||||
|
Finished time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
// State is the effective outcome: conclusion if set, else status.
|
// State is the effective outcome: conclusion if set, else status.
|
||||||
@@ -20,6 +25,14 @@ func (j Job) State() string {
|
|||||||
return j.Status
|
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.
|
// RunSummary is the newest workflow run and its per-job outcomes.
|
||||||
type RunSummary struct {
|
type RunSummary struct {
|
||||||
Number int
|
Number int
|
||||||
@@ -100,6 +113,8 @@ func LatestRunJobs(tasksJSON []byte) (RunSummary, error) {
|
|||||||
Conclusion string `json:"conclusion"`
|
Conclusion string `json:"conclusion"`
|
||||||
SHA string `json:"head_sha"`
|
SHA string `json:"head_sha"`
|
||||||
Title string `json:"display_title"`
|
Title string `json:"display_title"`
|
||||||
|
Created string `json:"created_at"`
|
||||||
|
Updated string `json:"updated_at"`
|
||||||
} `json:"workflow_runs"`
|
} `json:"workflow_runs"`
|
||||||
}
|
}
|
||||||
if err := json.Unmarshal(tasksJSON, &resp); err != nil {
|
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}
|
s := RunSummary{Number: latest.RunNumber, SHA: latest.SHA, Title: latest.Title}
|
||||||
for _, t := range resp.Tasks {
|
for _, t := range resp.Tasks {
|
||||||
if t.RunNumber == latest.RunNumber {
|
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.
|
// Gitea lists newest (last-finished) first; reverse to pipeline order.
|
||||||
@@ -142,6 +162,21 @@ func RunNodes(s RunSummary) []Node {
|
|||||||
return nodes
|
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 {
|
func statePill(state string) string {
|
||||||
switch state {
|
switch state {
|
||||||
case "success":
|
case "success":
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package atlas_test
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
"git.d-ma.be/mathias/cad-atlas/internal/atlas"
|
"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")
|
t.Fatal("expected error on empty, got nil")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestLatestRunJobs_ParsesPerJobTimestampsIntoSeconds(t *testing.T) {
|
||||||
|
tasks := []byte(`{"workflow_runs":[
|
||||||
|
{"run_number":28,"name":"Deploy via GitOps","status":"success","created_at":"2026-07-20T21:05:44Z","updated_at":"2026-07-20T21:05:50Z"},
|
||||||
|
{"run_number":28,"name":"Lint / Test / Vet","status":"success","created_at":"2026-07-20T21:05:39Z","updated_at":"2026-07-20T21:05:44Z"}
|
||||||
|
]}`)
|
||||||
|
|
||||||
|
s, err := atlas.LatestRunJobs(tasks)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("LatestRunJobs: %v", err)
|
||||||
|
}
|
||||||
|
// pipeline order: Lint first, Deploy last
|
||||||
|
if got := s.Jobs[0].Seconds(); got != 5 {
|
||||||
|
t.Fatalf("Lint job seconds = %v, want 5", got)
|
||||||
|
}
|
||||||
|
if got := s.Jobs[1].Seconds(); got != 6 {
|
||||||
|
t.Fatalf("Deploy job seconds = %v, want 6", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStageSeconds_LastJobIsDeploySumOfRestIsCI(t *testing.T) {
|
||||||
|
s := atlas.RunSummary{Jobs: []atlas.Job{
|
||||||
|
{Name: "Lint", Started: mustParse("2026-07-20T21:00:00Z"), Finished: mustParse("2026-07-20T21:00:10Z")}, // 10s
|
||||||
|
{Name: "Build", Started: mustParse("2026-07-20T21:00:10Z"), Finished: mustParse("2026-07-20T21:00:25Z")}, // 15s
|
||||||
|
{Name: "Deploy", Started: mustParse("2026-07-20T21:00:25Z"), Finished: mustParse("2026-07-20T21:00:33Z")}, // 8s
|
||||||
|
}}
|
||||||
|
ci, cd := atlas.StageSeconds(s)
|
||||||
|
if ci != 25 {
|
||||||
|
t.Fatalf("ci = %v, want 25", ci)
|
||||||
|
}
|
||||||
|
if cd != 8 {
|
||||||
|
t.Fatalf("cd = %v, want 8", cd)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStageSeconds_NoJobsReturnsZero(t *testing.T) {
|
||||||
|
ci, cd := atlas.StageSeconds(atlas.RunSummary{})
|
||||||
|
if ci != 0 || cd != 0 {
|
||||||
|
t.Fatalf("ci=%v cd=%v, want 0,0", ci, cd)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func mustParse(s string) time.Time {
|
||||||
|
t, err := time.Parse(time.RFC3339, s)
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
return t
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,100 @@
|
|||||||
|
// stuck.go overlays cad-atlas#11's stuck-state badges onto the pipeline: a
|
||||||
|
// count badge per stage (Node entries + Stage.StuckCount/AwaitingCount),
|
||||||
|
// sourced from mathias/cad#14's `cad stuck` query rather than re-implemented
|
||||||
|
// here. cad's app/domain packages are public Go modules (not internal/) —
|
||||||
|
// this reuses the exact tested classification logic instead of duplicating
|
||||||
|
// the taxonomy, per ADR-0002 ("a small coupling surface, not a monolith").
|
||||||
|
package atlas
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
caddomain "git.d-ma.be/mathias/cad/domain"
|
||||||
|
)
|
||||||
|
|
||||||
|
// stageForCategory maps a cad-stuck taxonomy row onto the existing 9-stage
|
||||||
|
// pipeline (atlas.json), following the grouping cad-atlas#11 itself proposes:
|
||||||
|
// Trigger→armed-but-never-fired sits at the human dispatch gate (the label
|
||||||
|
// that authorizes dispatch is on, nothing has claimed it yet); Claim/Execute
|
||||||
|
// collapse into the one Execute stage this pipeline already has (there is no
|
||||||
|
// separate Claim stage); Close maps to the loop-back stage, where a human
|
||||||
|
// closing the issue is the action that actually completes the loop.
|
||||||
|
//
|
||||||
|
// CategoryDoneNotClosed is deliberately absent from the "stuck" class — see
|
||||||
|
// ApplyStuckOverlay, which reads Class rather than Category to decide the
|
||||||
|
// visual treatment. It IS present here for stage placement.
|
||||||
|
var stageForCategory = map[caddomain.StuckCategory]string{
|
||||||
|
caddomain.CategoryArmedNeverFired: "STAGE 04",
|
||||||
|
caddomain.CategoryClaimedNeverRan: "STAGE 05",
|
||||||
|
caddomain.CategoryCeilingHit: "STAGE 05",
|
||||||
|
caddomain.CategoryReFireLoop: "STAGE 05",
|
||||||
|
caddomain.CategoryNearAttemptCap: "STAGE 05",
|
||||||
|
caddomain.CategoryDoneNotClosed: "STAGE 08",
|
||||||
|
}
|
||||||
|
|
||||||
|
// stuckNodeCls / awaitingNodeCls are the CSS classes the static shell colours
|
||||||
|
// (cad-atlas.html: .node.stuck, .node.awaiting-human) — the two visual
|
||||||
|
// classes cad-atlas#11 asks for, kept visually distinct so "awaiting human"
|
||||||
|
// (a functioning control) never reads as "stuck" (needs intervention).
|
||||||
|
const (
|
||||||
|
stuckNodeCls = "stuck"
|
||||||
|
awaitingNodeCls = "awaiting-human"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ApplyStuckOverlay assigns each finding to the stage stageForCategory names,
|
||||||
|
// and for every stage in stages marked `"generate":"stuck-badges"`, sets its
|
||||||
|
// StuckCount/AwaitingCount and prepends one Node per finding assigned there
|
||||||
|
// (title, class, repo tag, and a URL to the finding's first locator — this
|
||||||
|
// overlay never adds a control, only a link out to Gitea). Findings whose
|
||||||
|
// category has no stage mapping are silently omitted from the pipeline view
|
||||||
|
// rather than misplaced — cad-atlas#11 is a read of the query, not a second
|
||||||
|
// copy of its taxonomy, so an unmapped category is a gap to notice in
|
||||||
|
// stageForCategory, not something to guess a stage for.
|
||||||
|
//
|
||||||
|
// stages is returned unmodified aside from the stuck-badges stages; this is a
|
||||||
|
// pure function, no I/O, so it is fixture-testable without a live query.
|
||||||
|
func ApplyStuckOverlay(stages []Stage, findings []caddomain.StuckFinding) []Stage {
|
||||||
|
byStage := make(map[string][]caddomain.StuckFinding)
|
||||||
|
for _, f := range findings {
|
||||||
|
stageNo, ok := stageForCategory[f.Category]
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
byStage[stageNo] = append(byStage[stageNo], f)
|
||||||
|
}
|
||||||
|
|
||||||
|
out := make([]Stage, len(stages))
|
||||||
|
copy(out, stages)
|
||||||
|
for i := range out {
|
||||||
|
if out[i].Generate != "stuck-badges" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
assigned := byStage[out[i].No]
|
||||||
|
var stuckN, awaitingN int
|
||||||
|
nodes := make([]Node, 0, len(assigned))
|
||||||
|
for _, f := range assigned {
|
||||||
|
cls := stuckNodeCls
|
||||||
|
if f.Class == caddomain.ClassAwaitingHuman {
|
||||||
|
cls = awaitingNodeCls
|
||||||
|
awaitingN++
|
||||||
|
} else {
|
||||||
|
stuckN++
|
||||||
|
}
|
||||||
|
var url string
|
||||||
|
if len(f.Locators) > 0 {
|
||||||
|
url = f.Locators[0]
|
||||||
|
}
|
||||||
|
nodes = append(nodes, Node{
|
||||||
|
Title: fmt.Sprintf("#%d %s", f.Issue, f.Category),
|
||||||
|
Desc: f.Detail,
|
||||||
|
Cls: cls,
|
||||||
|
Tags: []string{"live · cad-stuck", f.Repo},
|
||||||
|
URL: url,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
out[i].StuckCount = stuckN
|
||||||
|
out[i].AwaitingCount = awaitingN
|
||||||
|
out[i].Nodes = append(nodes, out[i].Nodes...)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
package atlas_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"git.d-ma.be/mathias/cad-atlas/internal/atlas"
|
||||||
|
caddomain "git.d-ma.be/mathias/cad/domain"
|
||||||
|
)
|
||||||
|
|
||||||
|
func stage(no, generate string) atlas.Stage {
|
||||||
|
return atlas.Stage{No: no, Title: "example", Generate: generate}
|
||||||
|
}
|
||||||
|
|
||||||
|
func finding(repo string, issue int, cat caddomain.StuckCategory, class caddomain.StuckClass) caddomain.StuckFinding {
|
||||||
|
return caddomain.StuckFinding{
|
||||||
|
Repo: repo, Issue: issue, Title: "example", Category: cat, Class: class,
|
||||||
|
Detail: "detail",
|
||||||
|
Locators: []string{"https://git.d-ma.be/" + repo + "/issues/1"},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestApplyStuckOverlay_BadgesOnlyOnGeneratedStages(t *testing.T) {
|
||||||
|
stages := []atlas.Stage{
|
||||||
|
stage("STAGE 04", ""), // no generate marker — must be untouched
|
||||||
|
stage("STAGE 04b", "stuck-badges"), // marked but no findings map here
|
||||||
|
}
|
||||||
|
out := atlas.ApplyStuckOverlay(stages, nil)
|
||||||
|
if len(out[0].Nodes) != 0 || out[0].StuckCount != 0 {
|
||||||
|
t.Fatalf("un-marked stage was touched: %+v", out[0])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestApplyStuckOverlay_ArmedNeverFiredGoesToStage04(t *testing.T) {
|
||||||
|
stages := []atlas.Stage{stage("STAGE 04", "stuck-badges")}
|
||||||
|
findings := []caddomain.StuckFinding{
|
||||||
|
finding("mathias/brain-gardener", 190, caddomain.CategoryArmedNeverFired, caddomain.ClassStuck),
|
||||||
|
}
|
||||||
|
out := atlas.ApplyStuckOverlay(stages, findings)
|
||||||
|
if out[0].StuckCount != 1 || out[0].AwaitingCount != 0 {
|
||||||
|
t.Fatalf("counts = stuck=%d awaiting=%d, want stuck=1 awaiting=0", out[0].StuckCount, out[0].AwaitingCount)
|
||||||
|
}
|
||||||
|
if len(out[0].Nodes) != 1 {
|
||||||
|
t.Fatalf("want 1 node, got %d", len(out[0].Nodes))
|
||||||
|
}
|
||||||
|
n := out[0].Nodes[0]
|
||||||
|
if n.Cls != "stuck" {
|
||||||
|
t.Errorf("Cls = %q, want %q", n.Cls, "stuck")
|
||||||
|
}
|
||||||
|
if n.URL == "" {
|
||||||
|
t.Error("node has no URL locator")
|
||||||
|
}
|
||||||
|
if n.Title != "#190 armed_never_fired" {
|
||||||
|
t.Errorf("Title = %q", n.Title)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestApplyStuckOverlay_DoneNotClosedIsAwaitingHumanAtStage08(t *testing.T) {
|
||||||
|
stages := []atlas.Stage{stage("STAGE 08", "stuck-badges")}
|
||||||
|
findings := []caddomain.StuckFinding{
|
||||||
|
finding("mathias/cobalt-dingo", 19, caddomain.CategoryDoneNotClosed, caddomain.ClassAwaitingHuman),
|
||||||
|
finding("mathias/cobalt-dingo", 16, caddomain.CategoryDoneNotClosed, caddomain.ClassAwaitingHuman),
|
||||||
|
}
|
||||||
|
out := atlas.ApplyStuckOverlay(stages, findings)
|
||||||
|
if out[0].StuckCount != 0 || out[0].AwaitingCount != 2 {
|
||||||
|
t.Fatalf("counts = stuck=%d awaiting=%d, want stuck=0 awaiting=2 — done_not_closed must never render as stuck", out[0].StuckCount, out[0].AwaitingCount)
|
||||||
|
}
|
||||||
|
for _, n := range out[0].Nodes {
|
||||||
|
if n.Cls != "awaiting-human" {
|
||||||
|
t.Errorf("node Cls = %q, want %q", n.Cls, "awaiting-human")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestApplyStuckOverlay_ExecutionCategoriesCollapseToStage05(t *testing.T) {
|
||||||
|
stages := []atlas.Stage{stage("STAGE 05", "stuck-badges")}
|
||||||
|
findings := []caddomain.StuckFinding{
|
||||||
|
finding("mathias/cobalt-dingo", 30, caddomain.CategoryCeilingHit, caddomain.ClassStuck),
|
||||||
|
finding("mathias/cad-atlas", 10, caddomain.CategoryNearAttemptCap, caddomain.ClassStuck),
|
||||||
|
finding("mathias/cobalt-dingo", 16, caddomain.CategoryReFireLoop, caddomain.ClassStuck),
|
||||||
|
finding("mathias/cobalt-dingo", 41, caddomain.CategoryClaimedNeverRan, caddomain.ClassStuck),
|
||||||
|
}
|
||||||
|
out := atlas.ApplyStuckOverlay(stages, findings)
|
||||||
|
if out[0].StuckCount != 4 {
|
||||||
|
t.Fatalf("StuckCount = %d, want 4", out[0].StuckCount)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestApplyStuckOverlay_NodesPrependedNotReplacingAuthoredContent(t *testing.T) {
|
||||||
|
s := stage("STAGE 04", "stuck-badges")
|
||||||
|
s.Nodes = []atlas.Node{{Title: "authored node"}}
|
||||||
|
out := atlas.ApplyStuckOverlay([]atlas.Stage{s}, []caddomain.StuckFinding{
|
||||||
|
finding("mathias/brain-gardener", 190, caddomain.CategoryArmedNeverFired, caddomain.ClassStuck),
|
||||||
|
})
|
||||||
|
if len(out[0].Nodes) != 2 {
|
||||||
|
t.Fatalf("want 2 nodes (1 live + 1 authored), got %d", len(out[0].Nodes))
|
||||||
|
}
|
||||||
|
if out[0].Nodes[len(out[0].Nodes)-1].Title != "authored node" {
|
||||||
|
t.Errorf("authored node was not preserved: %+v", out[0].Nodes)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestApplyStuckOverlay_DoesNotMutateInput(t *testing.T) {
|
||||||
|
stages := []atlas.Stage{stage("STAGE 04", "stuck-badges")}
|
||||||
|
_ = atlas.ApplyStuckOverlay(stages, []caddomain.StuckFinding{
|
||||||
|
finding("mathias/brain-gardener", 190, caddomain.CategoryArmedNeverFired, caddomain.ClassStuck),
|
||||||
|
})
|
||||||
|
if len(stages[0].Nodes) != 0 {
|
||||||
|
t.Fatalf("input slice was mutated: %+v", stages[0].Nodes)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,9 +1,12 @@
|
|||||||
package web
|
package web
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
_ "embed"
|
_ "embed"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -12,6 +15,10 @@ import (
|
|||||||
"git.d-ma.be/mathias/cad-atlas/internal/cluster"
|
"git.d-ma.be/mathias/cad-atlas/internal/cluster"
|
||||||
"git.d-ma.be/mathias/cad-atlas/internal/gitea"
|
"git.d-ma.be/mathias/cad-atlas/internal/gitea"
|
||||||
"git.d-ma.be/mathias/cad-atlas/internal/version"
|
"git.d-ma.be/mathias/cad-atlas/internal/version"
|
||||||
|
|
||||||
|
"git.d-ma.be/mathias/cad/adapters/gitearead"
|
||||||
|
cadapp "git.d-ma.be/mathias/cad/app"
|
||||||
|
caddomain "git.d-ma.be/mathias/cad/domain"
|
||||||
)
|
)
|
||||||
|
|
||||||
// atlasHTML is the Phase-A/B static shell. It fetches /api/atlas.json at load
|
// atlasHTML is the Phase-A/B static shell. It fetches /api/atlas.json at load
|
||||||
@@ -55,6 +62,7 @@ func NewHandler() http.Handler {
|
|||||||
a.Stages[i].Nodes = nodes
|
a.Stages[i].Nodes = nodes
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
a.CIDurationS, a.CDDurationS = atlas.StageSeconds(*ld.run)
|
||||||
}
|
}
|
||||||
if ld.issues != nil {
|
if ld.issues != nil {
|
||||||
for i := range a.Stages {
|
for i := range a.Stages {
|
||||||
@@ -63,6 +71,10 @@ func NewHandler() http.Handler {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// cad-atlas#11: overlay mathias/cad#14's cad-stuck findings as
|
||||||
|
// per-stage badges. Read-only — ApplyStuckOverlay only adds Nodes
|
||||||
|
// with a link out; it never writes anything back to Gitea.
|
||||||
|
a.Stages = atlas.ApplyStuckOverlay(a.Stages, ld.stuck)
|
||||||
if ld.deploy != nil || ld.flux != nil {
|
if ld.deploy != nil || ld.flux != nil {
|
||||||
var live []atlas.Node
|
var live []atlas.Node
|
||||||
if ld.deploy != nil {
|
if ld.deploy != nil {
|
||||||
@@ -92,6 +104,30 @@ type liveOverlayData struct {
|
|||||||
flux *atlas.Flux
|
flux *atlas.Flux
|
||||||
timeline []atlas.RunDot
|
timeline []atlas.RunDot
|
||||||
issues []atlas.Node
|
issues []atlas.Node
|
||||||
|
stuck []caddomain.StuckFinding
|
||||||
|
}
|
||||||
|
|
||||||
|
// stuckRepos is the estate cad-stuck queries — mirrors mathias/dispatch's
|
||||||
|
// dispatch-repos.txt scope. Override with CAD_ATLAS_STUCK_REPOS (comma
|
||||||
|
// separated "owner/name" entries) so this doesn't need a redeploy every time
|
||||||
|
// the estate's repo list changes; cad-atlas has no filesystem access to the
|
||||||
|
// canonical dispatch-repos.txt at runtime.
|
||||||
|
var defaultStuckRepos = []string{
|
||||||
|
"mathias/dispatch-sandbox", "mathias/cobalt-dingo", "mathias/parallax",
|
||||||
|
"mathias/cad-atlas", "mathias/brain-gardener", "mathias/hyperguild", "mathias/brain",
|
||||||
|
}
|
||||||
|
|
||||||
|
func stuckRepos() []string {
|
||||||
|
if v := os.Getenv("CAD_ATLAS_STUCK_REPOS"); v != "" {
|
||||||
|
var repos []string
|
||||||
|
for _, r := range strings.Split(v, ",") {
|
||||||
|
if r = strings.TrimSpace(r); r != "" {
|
||||||
|
repos = append(repos, r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return repos
|
||||||
|
}
|
||||||
|
return defaultStuckRepos
|
||||||
}
|
}
|
||||||
|
|
||||||
// live cache: query the cluster at most once per TTL; fall back to the authored
|
// live cache: query the cluster at most once per TTL; fall back to the authored
|
||||||
@@ -146,6 +182,40 @@ func liveOverlay() liveOverlayData {
|
|||||||
d.flux = &f
|
d.flux = &f
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
d.stuck = fetchStuck()
|
||||||
liveCache = d
|
liveCache = d
|
||||||
return d
|
return d
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// fetchStuck re-derives cad#14's stuck worklist fresh on every liveOverlay
|
||||||
|
// call (no cache beyond the shared liveTTL) — the same "re-derive, never
|
||||||
|
// recall" discipline cad-stuck and cad-babysit both hold to. Read-only:
|
||||||
|
// gitearead.Client has no write method in its package. Degrades to nil
|
||||||
|
// (no badges rendered) on any error or when GITEA_TOKEN is unset, exactly
|
||||||
|
// like every other liveOverlay source — a forge outage costs a missing
|
||||||
|
// overlay, never a broken page.
|
||||||
|
func fetchStuck() []caddomain.StuckFinding {
|
||||||
|
token := os.Getenv("GITEA_TOKEN")
|
||||||
|
if token == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
base := os.Getenv("GITEA_BASE")
|
||||||
|
if base == "" || strings.Contains(base, ".svc.cluster.local") {
|
||||||
|
// The in-cluster Gitea service (internal/gitea's default) has no
|
||||||
|
// public HTTPS endpoint for gitearead's client to call with a
|
||||||
|
// bearer token in the same way; cad-stuck's own default (the public
|
||||||
|
// git.d-ma.be) is the correct base here regardless of what
|
||||||
|
// GITEA_BASE is set to for the read-only in-cluster fetchers above.
|
||||||
|
base = "https://git.d-ma.be"
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
client := gitearead.New(base, token)
|
||||||
|
cfg := cadapp.DefaultStuckConfig()
|
||||||
|
cfg.BaseURL = base
|
||||||
|
findings, _, err := cadapp.RunStuckQuery(ctx, client, stuckRepos(), cfg)
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return findings
|
||||||
|
}
|
||||||
|
|||||||
@@ -30,7 +30,20 @@ func TestRootServesAtlas(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestAtlasJSON_ServesAtlasWithGeneratedCIStage(t *testing.T) {
|
// This asserts the SHAPE the endpoint serves, not the exact CI job names.
|
||||||
|
//
|
||||||
|
// STAGE 06 has two sources: the embedded workflow (deterministic) and, when the
|
||||||
|
// forge is reachable, the live latest run — which overwrites it with real job
|
||||||
|
// names and a "▶ run #N" node. The old assertion demanded the workflow-derived
|
||||||
|
// names, so it passed on a laptop with no route to Gitea and failed inside CI,
|
||||||
|
// where the overlay works exactly as designed. It went red on 2026-08-19 for a
|
||||||
|
// commit that only touched atlas.json.
|
||||||
|
//
|
||||||
|
// The deterministic assertion lives in the atlas package
|
||||||
|
// (TestBuild_OverlaysCIStageNodesFromWorkflow), which feeds Build a fixed
|
||||||
|
// workflow and no network. Here we check the endpoint serves a usable atlas
|
||||||
|
// whichever source won.
|
||||||
|
func TestAtlasJSON_ServesAtlasWithPopulatedCIStage(t *testing.T) {
|
||||||
srv := httptest.NewServer(NewHandler())
|
srv := httptest.NewServer(NewHandler())
|
||||||
defer srv.Close()
|
defer srv.Close()
|
||||||
|
|
||||||
@@ -51,18 +64,21 @@ func TestAtlasJSON_ServesAtlasWithGeneratedCIStage(t *testing.T) {
|
|||||||
t.Fatal("atlas has no stages")
|
t.Fatal("atlas has no stages")
|
||||||
}
|
}
|
||||||
|
|
||||||
// The generate:ci-jobs stage must be populated from the real cd.yml jobs.
|
var ci *atlas.Stage
|
||||||
got := map[string]bool{}
|
for i := range a.Stages {
|
||||||
for _, s := range a.Stages {
|
if a.Stages[i].No == "STAGE 06" {
|
||||||
if s.No == "STAGE 06" {
|
ci = &a.Stages[i]
|
||||||
for _, n := range s.Nodes {
|
|
||||||
got[n.Title] = true
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for _, want := range []string{"guard", "check", "build", "deploy"} {
|
if ci == nil {
|
||||||
if !got[want] {
|
t.Fatal("STAGE 06 missing from the served atlas")
|
||||||
t.Fatalf("CI stage missing generated job %q (got %v)", want, got)
|
}
|
||||||
|
if len(ci.Nodes) == 0 {
|
||||||
|
t.Fatal("STAGE 06 has no nodes — neither the embedded workflow nor the live run populated it")
|
||||||
|
}
|
||||||
|
for _, n := range ci.Nodes {
|
||||||
|
if n.Title == "" {
|
||||||
|
t.Fatalf("STAGE 06 node with no title: %+v", n)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -120,6 +120,17 @@
|
|||||||
border:1px solid var(--line);color:var(--dim);margin:6px 6px 0 0}
|
border:1px solid var(--line);color:var(--dim);margin:6px 6px 0 0}
|
||||||
.pill{width:8px;height:8px;border-radius:50%;flex:0 0 8px}
|
.pill{width:8px;height:8px;border-radius:50%;flex:0 0 8px}
|
||||||
|
|
||||||
|
/* cad-atlas#11: stuck-state overlay. Two visual classes, deliberately
|
||||||
|
distinct — "stuck" needs intervention, "awaiting-human" is a control
|
||||||
|
working as designed and must never look like a problem. */
|
||||||
|
.node.stuck{border-color:rgba(255,122,92,.55);background:linear-gradient(180deg,rgba(255,122,92,.07),transparent)}
|
||||||
|
.node.awaiting-human{border-color:rgba(74,208,122,.5);background:linear-gradient(180deg,rgba(74,208,122,.06),transparent)}
|
||||||
|
.stagehead{display:flex;align-items:baseline;gap:8px;flex-wrap:wrap}
|
||||||
|
.stagehead .badges{display:flex;gap:6px}
|
||||||
|
.badge{font-size:10.5px;padding:2px 8px;border-radius:20px;font-weight:600;white-space:nowrap}
|
||||||
|
.badge.stuck{color:var(--coral);border:1px solid rgba(255,122,92,.5)}
|
||||||
|
.badge.awaiting{color:var(--green);border:1px solid rgba(74,208,122,.5)}
|
||||||
|
|
||||||
.stage.telos h2{color:var(--violet)}
|
.stage.telos h2{color:var(--violet)}
|
||||||
.stage.gate h2{color:var(--amber)}
|
.stage.gate h2{color:var(--amber)}
|
||||||
.stage.exec h2{color:var(--coral)}
|
.stage.exec h2{color:var(--coral)}
|
||||||
@@ -214,7 +225,7 @@
|
|||||||
// stage from the live cd.yml + substrate from the live cluster). These start
|
// 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
|
// empty and are filled by init()'s fetch; on failure the page shows an error
|
||||||
// banner rather than stale inline data.
|
// 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'
|
let MODE = localStorage.getItem('atlas-mode') || 'plain'; // 'plain' | 'technical'
|
||||||
|
|
||||||
const track=document.getElementById('track');
|
const track=document.getElementById('track');
|
||||||
@@ -234,7 +245,16 @@ function renderAtlas(){
|
|||||||
? `<div class="no mono">${s.no}</div><h2>${s.plain_title||s.title}</h2>`+
|
? `<div class="no mono">${s.no}</div><h2>${s.plain_title||s.title}</h2>`+
|
||||||
`<div class="tech-sub">${s.title}</div>`+(s.plain?`<div class="plain-what">${s.plain}</div>`:'')
|
`<div class="tech-sub">${s.title}</div>`+(s.plain?`<div class="plain-what">${s.plain}</div>`:'')
|
||||||
: `<div class="no mono">${s.no}</div><h2>${s.title}</h2><div class="path mono">${s.path||''}</div>`;
|
: `<div class="no mono">${s.no}</div><h2>${s.title}</h2><div class="path mono">${s.path||''}</div>`;
|
||||||
let h = `<div class="stagehead">${head}</div>`;
|
// cad-atlas#11: a count badge per stage from cad-stuck (mathias/cad#14),
|
||||||
|
// read-only — no controls, just a count and (via the nodes below) a link
|
||||||
|
// out to the underlying issues. Two classes, kept visually separate: a
|
||||||
|
// "stuck" count needs intervention, an "awaiting" count is a control
|
||||||
|
// working as designed and must never read as a problem.
|
||||||
|
let badges='';
|
||||||
|
if(s.stuck_count)badges+=`<span class="badge stuck">${s.stuck_count} stuck</span>`;
|
||||||
|
if(s.awaiting_count)badges+=`<span class="badge awaiting">${s.awaiting_count} awaiting</span>`;
|
||||||
|
if(badges)badges=`<span class="badges">${badges}</span>`;
|
||||||
|
let h = `<div class="stagehead">${head}${badges}</div>`;
|
||||||
(s.nodes||[]).forEach(n=>{
|
(s.nodes||[]).forEach(n=>{
|
||||||
const pill=n.pill?`<span class="pill" style="background:${n.pill}"></span>`:'';
|
const pill=n.pill?`<span class="pill" style="background:${n.pill}"></span>`:'';
|
||||||
const body = plain ? (n.plain||n.d||'') : (n.d||'');
|
const body = plain ? (n.plain||n.d||'') : (n.d||'');
|
||||||
@@ -330,14 +350,47 @@ function build(){
|
|||||||
}
|
}
|
||||||
spineLen=spinePath.getTotalLength();loopLen=loopPath.getTotalLength();
|
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){
|
function run(ts){
|
||||||
if(mobile)return;
|
if(mobile)return;
|
||||||
if(!t0)t0=ts;
|
if(!t0)t0=ts;
|
||||||
const dur=slow?16000:7000;
|
const dur=slow?16000:7000;
|
||||||
const p=Math.min((ts-t0)/dur,1);
|
const p=Math.min((ts-t0)/dur,1);
|
||||||
const total=spineLen+loopLen, dist=total*p;
|
const spineFrac=spineLen/(spineLen+loopLen);
|
||||||
let pt,onLoop=dist>spineLen;
|
let dist,onLoop;
|
||||||
pt=onLoop?loopPath.getPointAtLength(dist-spineLen):spinePath.getPointAtLength(dist);
|
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.left=pt.x+'px';pulse.style.top=pt.y+'px';
|
||||||
pulse.style.background=onLoop?'var(--violet)':'var(--amber)';
|
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)';
|
pulse.style.boxShadow=onLoop?'0 0 15px 4px rgba(155,140,255,.75)':'0 0 15px 4px rgba(245,185,66,.75)';
|
||||||
@@ -370,6 +423,8 @@ async function init(){
|
|||||||
if(typeof data.ns==='string') NS=data.ns;
|
if(typeof data.ns==='string') NS=data.ns;
|
||||||
if(Array.isArray(data.stages)) STAGES=data.stages;
|
if(Array.isArray(data.stages)) STAGES=data.stages;
|
||||||
if(Array.isArray(data.timeline)) TIMELINE=data.timeline;
|
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;
|
if(data.version) document.getElementById('ver').textContent=data.version;
|
||||||
}catch(e){
|
}catch(e){
|
||||||
console.error('atlas: failed to load /api/atlas.json —',e);
|
console.error('atlas: failed to load /api/atlas.json —',e);
|
||||||
|
|||||||
Reference in New Issue
Block a user