generated from mathias/template-go-web
feat(atlas): Phase B tail — live namespaces + trim inline fallback
NamespaceSummary (test-first) renders the substrate ns line from live cluster namespaces (system-filtered, sorted, capped). Handler now overlays both node specs and the ns line via a single cached liveOverlay(). Removed the inline SUBSTRATE/STAGES/NS data — /api/atlas.json is the single source; the page shows an error banner on fetch failure instead of stale data. GPU model naming intentionally NOT done: koala's node has no GPU product label (only nvidia.com/gpu count), so "1× GPU" is the API's truth — naming the RTX 5070 would need GPU-feature-discovery, out of scope. Verified: build/vet/lint(0)/test green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -4,6 +4,7 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"math"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
@@ -52,6 +53,47 @@ func HostsFromNodes(nodesJSON []byte) ([]Host, error) {
|
||||
return hosts, nil
|
||||
}
|
||||
|
||||
// NamespaceSummary parses a Kubernetes `/api/v1/namespaces` list into a compact
|
||||
// "ns: a · b · c" line for the substrate, dropping system namespaces, sorting,
|
||||
// and capping the count (with "+N more" when it overflows).
|
||||
func NamespaceSummary(nsJSON []byte) (string, error) {
|
||||
var list struct {
|
||||
Items []struct {
|
||||
Metadata struct {
|
||||
Name string `json:"name"`
|
||||
} `json:"metadata"`
|
||||
} `json:"items"`
|
||||
}
|
||||
if err := json.Unmarshal(nsJSON, &list); err != nil {
|
||||
return "", fmt.Errorf("parse namespaces: %w", err)
|
||||
}
|
||||
|
||||
var names []string
|
||||
for _, it := range list.Items {
|
||||
n := it.Metadata.Name
|
||||
if strings.HasPrefix(n, "kube-") || n == "default" || n == "flux-system" {
|
||||
continue
|
||||
}
|
||||
names = append(names, n)
|
||||
}
|
||||
sort.Strings(names)
|
||||
|
||||
const maxShown = 12
|
||||
more := 0
|
||||
if len(names) > maxShown {
|
||||
more = len(names) - maxShown
|
||||
names = names[:maxShown]
|
||||
}
|
||||
if len(names) == 0 {
|
||||
return "ns: (none)", nil
|
||||
}
|
||||
s := "ns: " + strings.Join(names, " · ")
|
||||
if more > 0 {
|
||||
s += " · +" + strconv.Itoa(more) + " more"
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// MergeSubstrate overlays live node specs onto the authored substrate: an
|
||||
// authored host is replaced by the live node of the same name (fresh specs),
|
||||
// authored-only machines (non-cluster: iguana/flamingo/piguard) are kept, and
|
||||
|
||||
@@ -2,6 +2,7 @@ package atlas_test
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.d-ma.be/mathias/cad-atlas/internal/atlas"
|
||||
@@ -62,6 +63,41 @@ func TestMergeSubstrate_NoLiveReturnsAuthored(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestNamespaceSummary_FiltersSystemSortsAndJoins(t *testing.T) {
|
||||
ns := []byte(`{"items":[
|
||||
{"metadata":{"name":"gitea"}},
|
||||
{"metadata":{"name":"kube-system"}},
|
||||
{"metadata":{"name":"ai-stack"}},
|
||||
{"metadata":{"name":"default"}},
|
||||
{"metadata":{"name":"flux-system"}},
|
||||
{"metadata":{"name":"brain"}}
|
||||
]}`)
|
||||
|
||||
got, err := atlas.NamespaceSummary(ns)
|
||||
if err != nil {
|
||||
t.Fatalf("NamespaceSummary: %v", err)
|
||||
}
|
||||
if want := "ns: ai-stack · brain · gitea"; got != want {
|
||||
t.Fatalf("summary = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNamespaceSummary_CapsWithMore(t *testing.T) {
|
||||
var items []string
|
||||
for i := 0; i < 15; i++ {
|
||||
items = append(items, `{"metadata":{"name":"app`+string(rune('a'+i))+`"}}`)
|
||||
}
|
||||
ns := []byte(`{"items":[` + strings.Join(items, ",") + `]}`)
|
||||
|
||||
got, err := atlas.NamespaceSummary(ns)
|
||||
if err != nil {
|
||||
t.Fatalf("NamespaceSummary: %v", err)
|
||||
}
|
||||
if !strings.HasSuffix(got, "· +3 more") {
|
||||
t.Fatalf("expected cap suffix, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHostsFromNodes_ErrorsOnBadJSON(t *testing.T) {
|
||||
if _, err := atlas.HostsFromNodes([]byte("{not json")); err == nil {
|
||||
t.Fatal("expected error on bad JSON, got nil")
|
||||
|
||||
@@ -21,6 +21,9 @@ const (
|
||||
// Nodes returns the raw /api/v1/nodes JSON from the in-cluster API server.
|
||||
func Nodes() ([]byte, error) { return get("/api/v1/nodes") }
|
||||
|
||||
// Namespaces returns the raw /api/v1/namespaces JSON from the in-cluster API server.
|
||||
func Namespaces() ([]byte, error) { return get("/api/v1/namespaces") }
|
||||
|
||||
func get(path string) ([]byte, error) {
|
||||
host, port := os.Getenv("KUBERNETES_SERVICE_HOST"), os.Getenv("KUBERNETES_SERVICE_PORT")
|
||||
if host == "" || port == "" {
|
||||
|
||||
+36
-24
@@ -37,8 +37,12 @@ func NewHandler() http.Handler {
|
||||
http.Error(w, "atlas build failed", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if live := liveSubstrate(); len(live) > 0 {
|
||||
a.Substrate = atlas.MergeSubstrate(a.Substrate, live)
|
||||
ld := liveOverlay()
|
||||
if len(ld.hosts) > 0 {
|
||||
a.Substrate = atlas.MergeSubstrate(a.Substrate, ld.hosts)
|
||||
}
|
||||
if ld.ns != "" {
|
||||
a.NS = "Tailscale mesh · " + ld.ns
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
_ = json.NewEncoder(w).Encode(a)
|
||||
@@ -46,33 +50,41 @@ func NewHandler() http.Handler {
|
||||
return mux
|
||||
}
|
||||
|
||||
// substrate cache: query the cluster at most once per TTL; fall back to the
|
||||
// authored substrate (returns nil) whenever the cluster is unreachable.
|
||||
// liveOverlay holds cluster-sourced substrate facts (node specs + namespace line).
|
||||
type liveOverlayData struct {
|
||||
hosts []atlas.Host
|
||||
ns string
|
||||
}
|
||||
|
||||
// live cache: query the cluster at most once per TTL; fall back to the authored
|
||||
// substrate/ns (zero values) whenever the cluster is unreachable.
|
||||
var (
|
||||
subMu sync.Mutex
|
||||
subCache []atlas.Host
|
||||
subAt time.Time
|
||||
liveMu sync.Mutex
|
||||
liveCache liveOverlayData
|
||||
liveAt time.Time
|
||||
)
|
||||
|
||||
const substrateTTL = 30 * time.Second
|
||||
const liveTTL = 30 * time.Second
|
||||
|
||||
func liveSubstrate() []atlas.Host {
|
||||
subMu.Lock()
|
||||
defer subMu.Unlock()
|
||||
if !subAt.IsZero() && time.Since(subAt) < substrateTTL {
|
||||
return subCache
|
||||
func liveOverlay() liveOverlayData {
|
||||
liveMu.Lock()
|
||||
defer liveMu.Unlock()
|
||||
if !liveAt.IsZero() && time.Since(liveAt) < liveTTL {
|
||||
return liveCache
|
||||
}
|
||||
subAt = time.Now()
|
||||
raw, err := cluster.Nodes()
|
||||
if err != nil {
|
||||
subCache = nil
|
||||
return nil
|
||||
liveAt = time.Now()
|
||||
|
||||
var d liveOverlayData
|
||||
if raw, err := cluster.Nodes(); err == nil {
|
||||
if hosts, err := atlas.HostsFromNodes(raw); err == nil {
|
||||
d.hosts = hosts
|
||||
}
|
||||
}
|
||||
hosts, err := atlas.HostsFromNodes(raw)
|
||||
if err != nil {
|
||||
subCache = nil
|
||||
return nil
|
||||
if raw, err := cluster.Namespaces(); err == nil {
|
||||
if s, err := atlas.NamespaceSummary(raw); err == nil {
|
||||
d.ns = s
|
||||
}
|
||||
}
|
||||
subCache = hosts
|
||||
return hosts
|
||||
liveCache = d
|
||||
return d
|
||||
}
|
||||
|
||||
@@ -114,7 +114,7 @@
|
||||
<body>
|
||||
<header>
|
||||
<h1><b>CAD</b> Atlas · From Signal to Pod</h1>
|
||||
<span class="sub mono">one human gate · everything up- and downstream is agents · <em>v0.5 · data-driven (CI + substrate from the live cluster)</em></span>
|
||||
<span class="sub mono">one human gate · everything up- and downstream is agents · <em>v0.6 · live cluster (nodes + namespaces) · single-source /api/atlas.json</em></span>
|
||||
<div class="controls">
|
||||
<button id="replay"><span class="dot"></span> Replay</button>
|
||||
<button id="slowmo">Slow-mo · <span id="slowState">off</span></button>
|
||||
@@ -150,57 +150,11 @@
|
||||
</footer>
|
||||
|
||||
<script>
|
||||
// Inline data is an offline FALLBACK. Authoritative data is fetched from
|
||||
// /api/atlas.json (authored atlas.json + CI stage generated from the real cd.yml).
|
||||
let SUBSTRATE=[
|
||||
{n:"koala", k:"RTX 5070 · k3s control-plane · Gitea · LiteLLM :30401 · llama-swap :31234 · searxng"},
|
||||
{n:"iguana", k:"M2 Ultra · Ollama / mlx"},
|
||||
{n:"flamingo",k:"daily driver · ~/dev"},
|
||||
{n:"piguard",k:"NGINX reverse-proxy · ntfy"},
|
||||
];
|
||||
let NS="Tailscale mesh · ns: ai-stack · supervisor(→brain) · gitea-mcp · infra-mcp · council";
|
||||
|
||||
let STAGES=[
|
||||
{no:"STAGE 00",cls:"",title:"Signals",path:"→ mathias/signals",nodes:[
|
||||
{t:"Applied AI Radar",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",d:"claude.ai strategic drop · brain capture tool.",tags:["ad-hoc"]},
|
||||
{t:"Aspirational surfaces",pill:"var(--dim)",d:"Telegram / voice / URL → inbox. NOT built.",tags:["gap"]},
|
||||
]},
|
||||
{no:"STAGE 01",cls:"telos",title:"TELOS",path:"wiki/telos/",nodes:[
|
||||
{t:"Intention substrate",pill:"var(--violet)",d:"Mission · goals · problems · strategies · status. Every downstream item traces to a goal.",tags:["brain_query wing=telos"]},
|
||||
]},
|
||||
{no:"STAGE 02",cls:"",title:"Strategic session",path:"claude.ai frontier + brain MCP",nodes:[
|
||||
{t:"Design · ADRs · specs",d:"Human + frontier model. ISC acceptance criteria written here.",tags:["Define / converge"]},
|
||||
{t:"🏛️ LLM Council",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",cls:"council",pill:"var(--violet)",d:"Sibling pipe — ratifies research before the gate.",tags:["proposed: → standalone svc"]},
|
||||
]},
|
||||
{no:"STAGE 03",cls:"",title:"Spec → Gitea issue",path:"agent-ready contract",nodes:[
|
||||
{t:"Contract enforced",d:"Binary ISC · declared risk tier · reg-risk assessment · no open human deps.",tags:["LOW / MED / HIGH"]},
|
||||
{t:"Admission controller",d:"Ed25519-sign issue body at creation (#36). Verify sig + PR alignment at infra boundary.",tags:["chain of custody"]},
|
||||
{t:"⚖️ var-go Oath",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",path:"the only checkpoint",nodes:[
|
||||
{t:"Human triggers execution",cls:"gateway",pill:"var(--amber)",d:"Ratify proposed-plan + risk tier, then dispatch.",gate:true},
|
||||
{t:"Session-Dispatch bridge",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",path:"koala · cmd/agentsquad-serve",nodes:[
|
||||
{t:"Task API",pill:"var(--coral)",d:"POST /tasks → job id · GET /tasks/{id}. taskqueue + serve (v0.12+).",tags:["single agentsquad.yaml"]},
|
||||
{t:"Executor + reviewer loop",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",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",d:"Attestation ledger (audit trail) + brain session_log on completion.",tags:["audit package"]},
|
||||
]},
|
||||
{no:"STAGE 06",cls:"",title:"PR → CI",path:"Gitea Actions",nodes:[
|
||||
{t:"PR + label",d:"Gitea PR · agent-done / agent-blocked label.",tags:[]},
|
||||
{t:"Mechanical gate",d:"go test · vet · lint · govulncheck. ISC verified mechanically.",tags:["green = proceed"]},
|
||||
{t:"⚖️ var-go/oath gate",cls:"oath",pill:"var(--gold)",d:"cmd/vargo-gate runs in CI → posts commit status context=var-go/oath. Authoritative FLOOR: failed Oath blocks regardless of reviewer approval (#55 anti-rubber-stamp).",tags:["branch-protection req","enforces @03 Oath"]},
|
||||
]},
|
||||
{no:"STAGE 07",cls:"cd",title:"CD → pod",path:"Flux GitOps → k3s",nodes:[
|
||||
{t:"Deploy on green",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",path:"→ TELOS (feedback bus)",nodes:[
|
||||
{t:"Close the loop",pill:"var(--violet)",d:"session_log + attestation → brain. Score deploy outcome vs originating goal. (arc partly manual — improvement target.)",tags:["continuous"]},
|
||||
]},
|
||||
];
|
||||
// Single source of truth: /api/atlas.json (Go app — authored atlas.json + CI
|
||||
// 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=[];
|
||||
|
||||
const track=document.getElementById('track');
|
||||
let stageEls=[];
|
||||
@@ -279,7 +233,14 @@ async function init(){
|
||||
if(Array.isArray(data.substrate)) SUBSTRATE=data.substrate;
|
||||
if(typeof data.ns==='string') NS=data.ns;
|
||||
if(Array.isArray(data.stages)) STAGES=data.stages;
|
||||
}catch(e){ console.warn('atlas: using inline fallback —',e); }
|
||||
}catch(e){
|
||||
console.error('atlas: failed to load /api/atlas.json —',e);
|
||||
document.getElementById('track').insertAdjacentHTML('beforeend',
|
||||
'<div class="stage"><div class="no mono">ERROR</div>'+
|
||||
'<h2 style="color:var(--coral)">Data unavailable</h2>'+
|
||||
'<div class="path mono">/api/atlas.json failed to load</div></div>');
|
||||
return;
|
||||
}
|
||||
renderAtlas();
|
||||
replay();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user