plan: dashboard truth + scale implementation
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 file changed,
+1327
−0
docs/superpowers/plans/2026-08-11-dashboard-truth-and-scale.md+1327 −0
diff --git a/docs/superpowers/plans/2026-08-11-dashboard-truth-and-scale.md b/docs/superpowers/plans/2026-08-11-dashboard-truth-and-scale.md| new file mode 100644 |
| index 000000000..027aeaf4c |
| --- /dev/null |
| +++ b/docs/superpowers/plans/2026-08-11-dashboard-truth-and-scale.md |
| @@ -0,0 +1,1327 @@ |
| +# Dashboard Truth + Scale Implementation Plan |
| + |
| +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. |
| + |
| +**Goal:** Make the app dashboard's health reporting truthful (readings for bespoke-unit routes, an honest "No readings" state, a route-granular account-mismatch banner) and scale the page to 100s of instances (flow-diagram rollup, Running-tab search/filter/pagination, API + CLI parity). |
| + |
| +**Architecture:** Box side, the 60s metrics sweep additionally samples the cgroup of any registry route with a stamped `Unit` (the #110 column). Console side, `OwnerAppMetrics` learns that third match key, `healthWord` gains an `expectReadings` distinction, the mismatch banner is computed per-route, and a shared list-query helper (q / health / opaque cursor) drives the Running tab, the instances API, and the CLI. |
| + |
| +**Tech Stack:** Go, html/template server-rendered console, SQLite registry, bucket release store. No JS added. |
| + |
| +**Spec:** `docs/superpowers/specs/2026-08-10-dashboard-truth-and-scale-design.md` — read it first. |
| + |
| +## Global Constraints |
| + |
| +- Branch: `dashboard-truth-and-scale`, based on origin/main `db76025`. Rebase onto origin/main before opening the PR (a parallel session was rolling PR #140 onto main). |
| +- Gate before EVERY commit: `go build ./... && go vet ./... && go test ./... -count=1` all clean, plus `gofmt -l` clean on touched files. Sandbox: `GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off`; go commands may need the sandbox disabled ("read-only file system" is the tell). |
| +- CI also cross-compiles `GOOS=darwin go build ./...` — no Linux-only type assumptions in anything linked into the CLI (`internal/host` is). |
| +- Wire shapes are additive only: readings chunks (`metrics/boxes/<box>/raw/*.json`), the instances API response, and the registry schema all have live producers/consumers. New JSON fields only; never rename, move, or re-type an existing field. When a storage shape changes, the consumer list is everything that PARSES the shape. |
| +- Production channel names are pinned in `internal/release/laddercheck_test.go` — do not touch channel semantics. |
| +- Any test that shells out to the carlos binary builds env through `carlosTestEnv` AND sets `cmd.Dir` to a scratch dir. |
| +- Health-word vocabulary (exact strings, used in templates, filters, and URLs): `Running`, `Serving`, `Setting up`, `Asleep`, `No readings`, `Not responding`, `Failed`. |
| +- Comment style: comments state constraints the code can't show; match the codebase's density (it is high and deliberate). |
| +- Commit messages end with `Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>`. |
| +- Delegation (Paul's rule): suggested executor model is noted per task; name it explicitly when dispatching. Opus reviews Tasks 4, 5, and 9 (authority/production-touching). |
| + |
| +--- |
| + |
| +### Task 1: RouteView carries Unit, Backing, Provisioned |
| + |
| +**Model:** sonnet |
| + |
| +**Files:** |
| +- Modify: `internal/console/view.go` (RouteView struct ~line 112-133; Assemble's RouteView literal ~line 377-383) |
| +- Test: `internal/console/view_test.go` |
| + |
| +**Interfaces:** |
| +- Consumes: `registry.Route.Unit`, `.Backing`, `.Provisioned` (already scanned by `registry.List()`). |
| +- Produces: `RouteView.Unit string`, `RouteView.Backing string`, `RouteView.Provisioned bool` — Tasks 2, 3, 6, 9 read these. |
| + |
| +- [ ] **Step 1: Write the failing test** |
| + |
| +In `internal/console/view_test.go`, add (follow `buildFixtures`' existing registry-seeding pattern in this file — it builds a real SQLite registry; use `registry.Open` + the same route-insert helper it uses, adding a route with `Unit`, `Backing`, and `Provisioned` set — check the exact insert call `buildFixtures` makes and mirror it): |
| + |
| +```go |
| +func TestAssembleCarriesProcessOwnership(t *testing.T) { |
| + // Seed one route with Unit "titogo@demo.service", Backing |
| + // registry.BackingUnit, Provisioned true (via buildFixtures' insert |
| + // path), then Assemble and find its RouteView. |
| + views := assembleFixtureViews(t) // or inline Assemble(...) as sibling tests do |
| + rt := findRoute(t, views, "unit-stamped.example.test") |
| + if rt.Unit != "titogo@demo.service" || rt.Backing != registry.BackingUnit || !rt.Provisioned { |
| + t.Fatalf("RouteView lost process ownership: %+v", rt) |
| + } |
| +} |
| +``` |
| + |
| +(If `view_test.go` has no `findRoute` helper, write the loop inline — sibling tests show the pattern.) |
| + |
| +- [ ] **Step 2: Run it, verify it fails** — `go test ./internal/console/ -run TestAssembleCarriesProcessOwnership -count=1` → FAIL (fields don't exist / are zero). |
| + |
| +- [ ] **Step 3: Implement** — add to `RouteView` (after `Caps`): |
| + |
| +```go |
| + // Unit is the bespoke systemd unit bound to this route |
| + // (registry.Route.Unit, #110): set when an operator-run service owns the |
| + // route's process. The sweep samples stamped units (Task 5), so a stamped |
| + // route's silence is meaningful — see expectsReadings. |
| + Unit string |
| + // Backing mirrors registry.Route.Backing: "" = an exec child the |
| + // activator spawns, registry.BackingUnit = a carlos-app@<host> unit. |
| + Backing string |
| + // Provisioned mirrors registry.Route.Provisioned: minted from a bucket |
| + // instance record, so the activator runs its process by construction. |
| + Provisioned bool |
| +``` |
| + |
| +and to Assemble's `RouteView{...}` literal: `Unit: rt.Unit, Backing: rt.Backing, Provisioned: rt.Provisioned,`. |
| + |
| +- [ ] **Step 4: Run the test, verify PASS**, then the full package: `go test ./internal/console/ -count=1`. |
| + |
| +- [ ] **Step 5: Commit** — `git add internal/console/view.go internal/console/view_test.go && git commit` — `console: RouteView carries Unit/Backing/Provisioned`. |
| + |
| +--- |
| + |
| +### Task 2: healthWord tells "Not responding" from "No readings" |
| + |
| +**Model:** sonnet |
| + |
| +**Files:** |
| +- Modify: `internal/console/overview.go` (`healthWord` ~line 258, `assembleInstanceRows` ~line 246, new `expectsReadings`) |
| +- Modify: `internal/console/flow.go` (`addressTone` ~line 100) |
| +- Modify: `internal/console/templates/app_instances.html` (Health cell, line 14) |
| +- Test: `internal/console/overview_test.go` |
| + |
| +**Interfaces:** |
| +- Consumes: Task 1's `RouteView.Unit/Backing/Provisioned`. |
| +- Produces: `func expectsReadings(provisioned bool, unit, backing string) bool` and `func healthWord(kind, status string, hibernate, reporting, expectReadings bool) string` — Tasks 3, 8, 9 call these. New health word `"No readings"`. |
| + |
| +- [ ] **Step 1: Write the failing tests** — extend the existing healthWord table test in `overview_test.go` (it exists; the a332e7f commit row-scoped it). Add cases: |
| + |
| +```go |
| +// silent + expected to report stays the red word |
| +{kind: "instance", status: "ready", hibernate: false, reporting: false, expect: true, want: "Not responding"}, |
| +// silent + nobody could sample it = honest ignorance, not an outage |
| +{kind: "instance", status: "ready", hibernate: false, reporting: false, expect: false, want: "No readings"}, |
| +// hibernation still wins over both |
| +{kind: "instance", status: "ready", hibernate: true, reporting: false, expect: false, want: "Asleep"}, |
| +``` |
| + |
| +And a direct test of the rule: |
| + |
| +```go |
| +func TestExpectsReadings(t *testing.T) { |
| + cases := []struct { |
| + provisioned bool |
| + unit, backing string |
| + want bool |
| + }{ |
| + {true, "", "", true}, // instances-API-minted: activator-run |
| + {false, "titogo@x.service", "", true}, // stamped: the sweep samples it |
| + {false, "", registry.BackingUnit, true}, // carlos-app@ tenant |
| + {false, "", "", false}, // manual exec-shaped row: can't tell |
| + } |
| + for _, c := range cases { |
| + if got := expectsReadings(c.provisioned, c.unit, c.backing); got != c.want { |
| + t.Fatalf("expectsReadings(%v,%q,%q) = %v", c.provisioned, c.unit, c.backing, got) |
| + } |
| + } |
| +} |
| +``` |
| + |
| +- [ ] **Step 2: Run, verify FAIL** (new parameter doesn't exist). |
| + |
| +- [ ] **Step 3: Implement** in `overview.go`: |
| + |
| +```go |
| +// expectsReadings is the one rule for whether silence from a route's process |
| +// is meaningful: the platform runs the process (a provisioned instance or a |
| +// carlos-app@ tenant) or a stamped unit tells the sweep where to look. |
| +// Everything else — a manually-added route whose process some bespoke, |
| +// unstamped unit owns — is honest ignorance, not an outage, and must not |
| +// render red (the 2026-08-10 tito false alarm: 18 serving routes flagged |
| +// "Not responding" because their titogo@* units matched no sweep key). |
| +func expectsReadings(provisioned bool, unit, backing string) bool { |
| + return provisioned || unit != "" || backing == registry.BackingUnit |
| +} |
| +``` |
| + |
| +(add `"github.com/carlosframework/platform/internal/registry"` to overview.go's imports). Change `healthWord`: |
| + |
| +```go |
| +func healthWord(kind, status string, hibernate, reporting, expectReadings bool) string { |
| + switch status { |
| + case "pending", "creating", "starting", "issuing": |
| + return "Setting up" |
| + case "failed": |
| + return "Failed" |
| + } |
| + if kind != "instance" { |
| + return "Serving" |
| + } |
| + switch { |
| + case reporting: |
| + return "Running" |
| + case hibernate: |
| + return "Asleep" |
| + case expectReadings: |
| + return "Not responding" |
| + default: |
| + return "No readings" |
| + } |
| +} |
| +``` |
| + |
| +In `assembleInstanceRows`, the call becomes: |
| + |
| +```go |
| +HealthWord: healthWord(rt.Kind, status, rt.Hibernate, byHost[rt.Host].Reporting(), |
| + expectsReadings(rt.Provisioned, rt.Unit, rt.Backing)), |
| +``` |
| + |
| +In `flow.go` `addressTone`, add `"No readings"` explicitly to the idle arm (it already falls there via default; naming it documents that it is deliberate): |
| + |
| +```go |
| + case "Asleep", "No readings": |
| + return "idle" |
| +``` |
| + |
| +(Adjust: today `Asleep` is in the default; keep whatever structure is there, just ensure both words yield `"idle"` and the comment covers "No readings".) In `app_instances.html`'s Health cell add after the Asleep hint: |
| + |
| +```html |
| +{{if eq .HealthWord "No readings"}}<span class="small"> · stamp its unit (carlos route -unit) to see health here</span>{{end}} |
| +``` |
| + |
| +- [ ] **Step 4: Run** `go test ./internal/console/ -count=1` → PASS (fix any other healthWord call sites the compiler finds — `grep -rn "healthWord(" internal/console/`). |
| + |
| +- [ ] **Step 5: Commit** — `console: healthWord distinguishes Not responding from No readings`. |
| + |
| +--- |
| + |
| +### Task 3: Overview tiles stop counting the unknowable |
| + |
| +**Model:** sonnet |
| + |
| +**Files:** |
| +- Modify: `internal/console/overview.go` (`OverviewStats`, `assembleOverview`, `healthVerdict`) |
| +- Modify: `internal/console/templates/app_overview.html` (Running tile lines 37-41, Web addresses tile line 50) |
| +- Test: `internal/console/overview_test.go` |
| + |
| +**Interfaces:** |
| +- Consumes: Task 2's `expectsReadings`. |
| +- Produces: `OverviewStats.InstanceNoReadings int`, `OverviewStats.InstanceCounted int` (= Expected − NoReadings). Templates and Task 8's header read these. |
| + |
| +- [ ] **Step 1: Failing test** — in `overview_test.go`, next to existing `assembleOverview` tests (mirror their AppView/metrics construction): |
| + |
| +```go |
| +func TestOverviewSeparatesNoReadings(t *testing.T) { |
| + view := &AppView{Routes: []RouteView{ |
| + {Host: "a.x", Kind: "instance", Account: "acct1", Provisioned: true}, // reporting |
| + {Host: "b.x", Kind: "instance", Account: "acct1", Provisioned: true}, // silent, expected |
| + {Host: "c.x", Kind: "instance", Account: "acct1"}, // silent, bespoke-unstamped |
| + {Host: "d.x", Kind: "instance", Account: "acct1", Hibernate: true}, // asleep |
| + }, Domains: []DomainView{{}, {}, {}, {}}} |
| + metrics := []InstanceMetrics{{Host: "a.x", Box: "box1"}} |
| + st := assembleOverview(view, "acct1", metrics, nil) |
| + if st.InstanceExpected != 4 || st.InstanceReporting != 1 || st.InstanceAsleep != 1 || |
| + st.InstanceNoReadings != 1 || st.InstanceCounted != 3 { |
| + t.Fatalf("counts: %+v", st) |
| + } |
| +} |
| + |
| +func TestVerdictIgnoresNoReadings(t *testing.T) { |
| + // 1 reporting + 1 asleep + 1 no-readings out of 3 counted... construct an |
| + // OverviewStats where Reporting+Asleep == InstanceCounted and assert the |
| + // verdict is NOT "attention" on account of the no-readings row. |
| + st := OverviewStats{HasStable: true, InstanceExpected: 3, InstanceCounted: 2, |
| + InstanceReporting: 1, InstanceAsleep: 1, InstanceNoReadings: 1} |
| + if v, _ := healthVerdict(st); v != "ok" { |
| + t.Fatalf("no-readings rows must not trip the verdict: %s", v) |
| + } |
| +} |
| +``` |
| + |
| +- [ ] **Step 2: Run, verify FAIL.** |
| + |
| +- [ ] **Step 3: Implement** — `OverviewStats` gains (after `InstanceAsleep`): |
| + |
| +```go |
| + // InstanceNoReadings counts instance-kind routes nobody could sample: not |
| + // provisioned, no carlos-app@ backing, no stamped unit. They are honest |
| + // unknowns — the tile names them separately and the verdict must not |
| + // treat them as down (the 2026-08-10 tito false alarm). |
| + InstanceNoReadings int |
| + // InstanceCounted is the Running tile's denominator: InstanceExpected |
| + // minus the unknowable. Templates render facts, never compute them. |
| + InstanceCounted int |
| +``` |
| + |
| +`assembleOverview`'s route loop: |
| + |
| +```go |
| + if rt.Kind == "instance" { |
| + st.InstanceExpected++ |
| + if reporting[rt.Host] { |
| + st.InstanceReporting++ |
| + } else if rt.Hibernate { |
| + st.InstanceAsleep++ |
| + } else if !expectsReadings(rt.Provisioned, rt.Unit, rt.Backing) { |
| + st.InstanceNoReadings++ |
| + } |
| + } |
| +``` |
| + |
| +then before the `healthVerdict` call: `st.InstanceCounted = st.InstanceExpected - st.InstanceNoReadings`. In `healthVerdict`, the shortfall case becomes: |
| + |
| +```go |
| + case st.InstanceReporting+st.InstanceAsleep < st.InstanceCounted: |
| + return "attention", "Needs attention" |
| +``` |
| + |
| +Template — Running tile value/detail lines become: |
| + |
| +```html |
| + <p class="tile-value">{{if eq .InstanceTotal 0}}—{{else if eq .InstanceExpected 0}}Served{{else if .MetricsUnavailable}}?{{else if eq .InstanceCounted 0}}?{{else}}{{.InstanceReporting}} of {{.InstanceCounted}}{{end}}</p> |
| + <p class="tile-detail">{{if eq .InstanceTotal 0}}nothing running yet{{else if eq .InstanceExpected 0}}static — the platform serves it, nothing to run{{else if .MetricsUnavailable}}no data right now{{else if eq .InstanceCounted 0}}no readings for {{.InstanceNoReadings}} copies — stamp their units to see health{{else}}copies reporting{{if .InstanceAsleep}} · {{.InstanceAsleep}} asleep{{end}}{{if .InstanceNoReadings}} · {{.InstanceNoReadings}} no readings{{end}}{{end}}</p> |
| +``` |
| + |
| +Web addresses tile: replace `all working` with `DNS & certs OK`. |
| + |
| +- [ ] **Step 4: Run** `go test ./internal/console/ -count=1` → PASS. Fix any existing test asserting the old copy (`grep -rn "all working\|copies responding" internal/console/`). |
| + |
| +- [ ] **Step 5: Commit** — `console: Running tile separates not-tracked copies; Web addresses says what it measures`. |
| + |
| +--- |
| + |
| +### Task 4: Account-mismatch banner at route granularity |
| + |
| +**Model:** sonnet — **Opus reviews** (this banner gates deploy-confidence messaging). |
| + |
| +**Files:** |
| +- Modify: `internal/console/view.go` (`AppView`, `AccountMismatch`, `Assemble` ~lines 345-425) |
| +- Modify: `internal/console/templates/app_overview.html` (lines 5-15) and `internal/console/templates/app_releases.html` (its twin banner, lines 5-12) |
| +- Test: `internal/console/view_test.go` (assembly), `internal/console/overview_test.go` (rendering) |
| + |
| +**Interfaces:** |
| +- Produces: `type MismatchedRoute struct{ Host, Account string }`, `AppView.Mismatched []MismatchedRoute`, `AppView.AllMismatched bool`. `AppView.RouteAccount` keeps its meaning (first mismatched account, `""` when none) so `AccountMismatch()` semantics survive for any other caller. |
| + |
| +- [ ] **Step 1: Failing tests** — in `view_test.go`, three cases via the fixture registry (seed routes stamped to another account; mirror how buildFixtures stamps accounts — `release.RouteAccount` reads the row's sqid): |
| + |
| +```go |
| +func TestMismatchListsRoutes(t *testing.T) { |
| + // App with 3 non-platform routes, 1 stamped to "otheracct": |
| + // Mismatched == [{host1, otheracct}], AllMismatched == false. |
| +} |
| +func TestMismatchAllRoutes(t *testing.T) { |
| + // All 3 stamped elsewhere: len(Mismatched) == 3, AllMismatched == true. |
| +} |
| +func TestMismatchIgnoresPlatformRoutes(t *testing.T) { |
| + // A platform-minted route stamped elsewhere contributes nothing. |
| +} |
| +``` |
| + |
| +Write real assertions (the sketches above name the expected values); construct via the same fixture path Task 1 used. |
| + |
| +- [ ] **Step 2: Run, verify FAIL.** |
| + |
| +- [ ] **Step 3: Implement** — in `view.go`: |
| + |
| +```go |
| +// MismatchedRoute is one route whose stamped account disagrees with the |
| +// account whose page is being viewed: host plus where it actually reads |
| +// releases from. The banner names these — a warning that says WHICH routes |
| +// is a diagnostic; one that says "this app" is an alarm, and on tito |
| +// (2026-08-10) it was a false one. |
| +type MismatchedRoute struct{ Host, Account string } |
| +``` |
| + |
| +`AppView` gains: |
| + |
| +```go |
| + // Mismatched is every non-platform route stamped to another account; |
| + // AllMismatched is whether that is ALL of them — only then is "this app |
| + // is not deploying" a claim the page can back. |
| + Mismatched []MismatchedRoute |
| + AllMismatched bool |
| +``` |
| + |
| +`AccountMismatch()` becomes `return len(v.Mismatched) > 0`. In `Assemble`, replace the `routeAccount` map with: |
| + |
| +```go |
| + mismatchedByApp := map[string][]MismatchedRoute{} |
| + routableByApp := map[string]int{} |
| +``` |
| + |
| +and in the route loop (replacing the `routeAccount[rt.App]` block): |
| + |
| +```go |
| + if rt.App != "" && !rt.Platform { |
| + routableByApp[rt.App]++ |
| + if a := release.RouteAccount(rt); a != acct { |
| + mismatchedByApp[rt.App] = append(mismatchedByApp[rt.App], MismatchedRoute{Host: rt.Host, Account: a}) |
| + } |
| + } |
| +``` |
| + |
| +In the per-app assembly (replacing the `mismatch :=` block): |
| + |
| +```go |
| + mm := mismatchedByApp[app] |
| + mismatch := "" |
| + if len(mm) > 0 { |
| + mismatch = mm[0].Account |
| + } |
| +``` |
| + |
| +and the `AppView{...}` literal gains `Mismatched: mm, AllMismatched: len(mm) > 0 && len(mm) == routableByApp[app],` (keeping `RouteAccount: mismatch`). Templates — in BOTH `app_overview.html` and `app_releases.html`, replace the banner block with: |
| + |
| +```html |
| +{{if .Detail.AccountMismatch}} |
| +{{if .Detail.AllMismatched}} |
| +<p class="banner error"> |
| + <strong>This app is not deploying.</strong> Every one of its addresses reads |
| + releases from account <code>{{.Detail.RouteAccount}}</code>, but this page |
| + writes them to <code>{{.Account.Sqid}}</code> — so a promote here succeeds |
| + and reaches nothing. Affected: {{range .Detail.Mismatched}}<code>{{.Host}}</code> {{end}} |
| + Finish the move on this app's |
| + <a href="{{.Nav}}/apps/{{.AppName}}/settings">Settings</a> page. |
| + <span class="small">If it is the routes that are in the wrong account, move |
| + those back instead — whichever of the two is right, they have to agree.</span> |
| +</p> |
| +{{else}} |
| +<p class="banner"> |
| + {{len .Detail.Mismatched}} of this app's addresses read releases from another |
| + account and won't follow promotes made here: |
| + {{range .Detail.Mismatched}}<code>{{.Host}}</code> (reads <code>{{.Account}}</code>) {{end}} |
| + — the rest deploy normally. Fix on |
| + <a href="{{.Nav}}/apps/{{.AppName}}/settings">Settings</a>. |
| +</p> |
| +{{end}} |
| +{{end}} |
| +``` |
| + |
| +- [ ] **Step 4: Run** `go test ./internal/console/ -count=1` → PASS (existing banner tests will need the new copy — update assertions to match the new strings, not the other way around). |
| + |
| +- [ ] **Step 5: Commit** — `console: account-mismatch banner names routes, claims "not deploying" only when true`. |
| + |
| +--- |
| + |
| +### Task 5: Box sweep samples stamped units |
| + |
| +**Model:** opus (root-side, fleet-wide blast radius) — **Opus reviews.** |
| + |
| +**Files:** |
| +- Modify: `internal/host/metrics_probes.go` (new `listStampedUnits`) |
| +- Modify: `internal/host/metrics_loop.go` (`realUnitReadings` ~line 639, `realSampleProbes` ~line 621, `metricsLoop` ~line 705 — thread a `stampedUnits func() []string` through) |
| +- Modify: `internal/host/agent.go` (~line 194, the `go metricsLoop(cgm.Root())` call; new `stampedUnitNames`) |
| +- Test: `internal/host/metrics_probes_test.go` (or wherever `listCarlosUnits`' tests live — find with `grep -rn "listCarlosUnits" internal/host/*_test.go`) |
| + |
| +**Interfaces:** |
| +- Consumes: `registry.Route.Unit` via `reg.List()`; `unitCgroup{name, dir}`; `readUnitRaw`. |
| +- Produces: readings for stamped units appear in `metrics.Sample.Units` keyed by the REAL unit name (e.g. `titogo@vicky.service`) — the same additive wire shape; Task 6 matches on it. |
| + |
| +- [ ] **Step 1: Failing test** — temp-dir fixture, same style as the existing `listCarlosUnits` test: |
| + |
| +```go |
| +func TestListStampedUnits(t *testing.T) { |
| + root := t.TempDir() |
| + // flat bespoke unit |
| + mustMkdir(t, filepath.Join(root, "console.service")) |
| + // template instance inside its implicit per-template slice |
| + mustMkdir(t, filepath.Join(root, "system-titogo\\x2dcanary.slice", "titogo-canary@demo.service")) |
| + // present on disk but NOT stamped — must not be returned |
| + mustMkdir(t, filepath.Join(root, "postgres.service")) |
| + |
| + got := listStampedUnits(root, []string{"console.service", "titogo-canary@demo.service", "absent@x.service"}) |
| + want := map[string]bool{"console.service": true, "titogo-canary@demo.service": true} |
| + if len(got) != 2 { |
| + t.Fatalf("got %d units: %+v", len(got), got) |
| + } |
| + for _, u := range got { |
| + if !want[u.name] { |
| + t.Fatalf("unexpected unit %q", u.name) |
| + } |
| + if _, err := os.Stat(u.dir); err != nil { |
| + t.Fatalf("dir %q not the real cgroup dir: %v", u.dir, err) |
| + } |
| + } |
| +} |
| +``` |
| + |
| +(`mustMkdir` = `os.MkdirAll` + `t.Fatal` on error; add if absent. Note the literal directory name contains `\x2d` — systemd's escaping — written in Go as `"system-titogo\\x2dcanary.slice"`.) |
| + |
| +- [ ] **Step 2: Run, verify FAIL** (`listStampedUnits` undefined). |
| + |
| +- [ ] **Step 3: Implement** in `metrics_probes.go`, after `listCarlosUnits`: |
| + |
| +```go |
| +// listStampedUnits finds the cgroup directories of operator-stamped bespoke |
| +// units — registry Route.Unit (#110), the routes the carlos- naming |
| +// convention can't see (tito's titogo@*, an operator's console.service). |
| +// Same two-level walk as listCarlosUnits, for the same reason: flat units |
| +// sit under root, template instances inside their implicit system-*.slice. |
| +// A stamped unit with no cgroup dir simply isn't returned — no reading is |
| +// the honest report for a unit that isn't running, and the console renders |
| +// its absence, never a guessed zero. |
| +func listStampedUnits(root string, stamped []string) []unitCgroup { |
| + if len(stamped) == 0 { |
| + return nil |
| + } |
| + want := make(map[string]bool, len(stamped)) |
| + for _, u := range stamped { |
| + want[u] = true |
| + } |
| + entries, err := os.ReadDir(root) |
| + if err != nil { |
| + return nil |
| + } |
| + var units []unitCgroup |
| + for _, entry := range entries { |
| + if !entry.IsDir() { |
| + continue |
| + } |
| + name := entry.Name() |
| + if want[name] { |
| + units = append(units, unitCgroup{name: name, dir: filepath.Join(root, name)}) |
| + continue |
| + } |
| + if !strings.HasPrefix(name, "system-") || !strings.HasSuffix(name, ".slice") { |
| + continue |
| + } |
| + sliceDir := filepath.Join(root, name) |
| + nested, err := os.ReadDir(sliceDir) |
| + if err != nil { |
| + continue // an unreadable slice loses its units, never the whole scan |
| + } |
| + for _, child := range nested { |
| + if child.IsDir() && want[child.Name()] { |
| + units = append(units, unitCgroup{name: child.Name(), dir: filepath.Join(sliceDir, child.Name())}) |
| + } |
| + } |
| + } |
| + return units |
| +} |
| +``` |
| + |
| +- [ ] **Step 4: Run the new test → PASS.** |
| + |
| +- [ ] **Step 5: Thread it through the loop.** Read `metrics_loop.go`'s `realUnitReadings` (line ~639), `realSampleProbes` (~621), `metricsLoop` (~705) and the `sampler`/`sampleProbes` types they build, then: |
| + - `realUnitReadings(execRoot string)` → `realUnitReadings(execRoot string, stamped []string)`; its unit list becomes `listCarlosUnits(cgroupSliceDir)`, then `append(units, listStampedUnits(cgroupSliceDir, stamped)...)`, then `append(units, listExecCgroups(execRoot)...)` (the `out[u.name] =` map write already dedupes a stamped unit that happens to match the carlos convention). |
| + - `realSampleProbes` and `metricsLoop` gain the parameter: `metricsLoop(execRoot string, stampedUnits func() []string)`, calling `stampedUnits()` once per tick and passing the slice down. A nil func is treated as none (guard: `var stamped []string; if stampedUnits != nil { stamped = stampedUnits() }`). |
| + - Update every test double / call site the compiler flags (`go build ./internal/host/`). |
| + |
| +- [ ] **Step 6: Registry read + agent wiring.** In `agent.go`, next to the other loops: |
| + |
| +```go |
| +// stampedUnitNames is the sweep's one registry read: every distinct bespoke |
| +// unit stamped on a route (Route.Unit). Called once per 60s tick; a read |
| +// failure costs this tick's stamped units, never the sweep or the agent. |
| +func stampedUnitNames(reg *registry.Registry) []string { |
| + routes, err := reg.List() |
| + if err != nil { |
| + return nil |
| + } |
| + seen := map[string]bool{} |
| + var out []string |
| + for _, rt := range routes { |
| + if rt.Unit == "" || seen[rt.Unit] { |
| + continue |
| + } |
| + seen[rt.Unit] = true |
| + out = append(out, rt.Unit) |
| + } |
| + sort.Strings(out) |
| + return out |
| +} |
| +``` |
| + |
| +and change the launch (line ~194): `go metricsLoop(cgm.Root(), func() []string { return stampedUnitNames(reg) })`. Add a small test for `stampedUnitNames` against a fixture registry (dedupe + empty-unit skip) if `internal/host` has registry-fixture tests; if none exist, the `listStampedUnits` + loop-plumbing coverage suffices — note it in the commit message. |
| + |
| +- [ ] **Step 7: Full gate** — `go build ./... && go vet ./... && go test ./... -count=1` and `GOOS=darwin go build ./...` (host package links into the CLI). → all PASS. |
| + |
| +- [ ] **Step 8: Commit** — `host: metrics sweep samples registry-stamped bespoke units`. |
| + |
| +--- |
| + |
| +### Task 6: Console matches stamped units |
| + |
| +**Model:** sonnet |
| + |
| +**Files:** |
| +- Modify: `internal/console/metricsview.go` (`OwnerAppMetrics` ~line 709) |
| +- Modify: `internal/console/server.go` (`appResourceMetrics` line 1475) |
| +- Test: `internal/console/metricsview_test.go` (find `OwnerAppMetrics`' existing tests: `grep -rn "OwnerAppMetrics" internal/console/*_test.go`) |
| + |
| +**Interfaces:** |
| +- Consumes: Task 1's `RouteView.Unit`; sweep entries keyed by real unit names (Task 5). |
| +- Produces: `OwnerAppMetrics(ctx context.Context, hosts []string, unitByHost map[string]string) ([]InstanceMetrics, error)` — Task 9 also calls this. |
| + |
| +- [ ] **Step 1: Failing test** — mirror the existing OwnerAppMetrics test's fixture (it fabricates `BoxMetricsView`/sweep data); add: |
| + |
| +```go |
| +func TestOwnerAppMetricsMatchesStampedUnit(t *testing.T) { |
| + // Sweep contains a unit row named "titogo@vicky.service" on box "b1". |
| + // OwnerAppMetrics(ctx, []string{"vicky.go.tito.io"}, |
| + // map[string]string{"vicky.go.tito.io": "titogo@vicky.service"}) |
| + // must return that host with Box "b1" (Reporting true). |
| + // And WITHOUT the unitByHost entry, the same host must come back |
| + // Reporting false — the third key is opt-in per host, not a wildcard. |
| +} |
| +``` |
| + |
| +Write the real body against the fixture helpers the sibling test uses. |
| + |
| +- [ ] **Step 2: Run, verify FAIL** (signature). |
| + |
| +- [ ] **Step 3: Implement** — `OwnerAppMetrics` gains the param and one block in the allowlist loop: |
| + |
| +```go |
| + want[activator.UnitName(h)] = h |
| + want[activator.ExecName(h)] = h |
| + // The third legal key: an operator-stamped bespoke unit |
| + // (registry Route.Unit, #110). Opt-in per host — the caller maps |
| + // host → stamped unit from the route rows it already holds; an |
| + // empty map costs nothing and matches nothing new. |
| + if u := unitByHost[h]; u != "" { |
| + want[u] = h |
| + } |
| +``` |
| + |
| +(A nil map indexes fine in Go — no guard needed.) `appResourceMetrics` builds it from the fields Task 1 added: |
| + |
| +```go |
| + unitByHost := make(map[string]string, len(view.Routes)) |
| + for _, rt := range view.Routes { |
| + if rt.Account != acct.Sqid || rt.Unit == "" { |
| + continue |
| + } |
| + unitByHost[rt.Host] = rt.Unit |
| + } |
| + am, err := s.OwnerAppMetrics(ctx, hosts, unitByHost) |
| +``` |
| + |
| +Fix every other `OwnerAppMetrics` call site the compiler flags (pass `nil` where no route rows are in hand). |
| + |
| +- [ ] **Step 4: Run** `go test ./internal/console/ -count=1` → PASS. |
| + |
| +- [ ] **Step 5: Commit** — `console: OwnerAppMetrics matches operator-stamped units`. |
| + |
| +--- |
| + |
| +### Task 7: Flow diagram rolls up above 8 addresses |
| + |
| +**Model:** sonnet |
| + |
| +**Files:** |
| +- Modify: `internal/console/flow.go` (`FlowStage`, `assembleFlow` ~line 126) |
| +- Modify: `internal/console/server.go` (`assembleFlow` call in `handleApp`, ~line 1517 — `grep -n "assembleFlow" internal/console/server.go`) |
| +- Modify: `internal/console/templates/flow.html` (`flow_stage` template) |
| +- Test: `internal/console/flow_test.go` |
| + |
| +**Interfaces:** |
| +- Consumes: `InstanceRow.HealthWord` vocabulary (Task 2). |
| +- Produces: `FlowStage.RolledUp bool, Total, Attention, Hidden int, MoreURL string`; `assembleFlow(view *AppView, rows []InstanceRow, latest, latestAt, instancesPath string) FlowView`. |
| + |
| +- [ ] **Step 1: Failing tests** (pure function — exact code, adapt only the AppView/rows construction to sibling tests' style): |
| + |
| +```go |
| +func TestFlowRollsUpBigStages(t *testing.T) { |
| + rows := make([]InstanceRow, 0, 12) |
| + for i := 0; i < 12; i++ { |
| + h := fmt.Sprintf("i%02d.example.test", i) |
| + w := "Running" |
| + if i == 3 || i == 7 { |
| + w = "Not responding" |
| + } |
| + rows = append(rows, InstanceRow{Host: h, Channel: "edge", HealthWord: w}) |
| + } |
| + view := &AppView{Channels: []ChannelView{{Name: "edge", Version: "v1"}}} |
| + f := assembleFlow(view, rows, "v1", "", "/a/x/apps/app/instances") |
| + st := f.Line[0] // edge |
| + if !st.RolledUp || st.Total != 12 || st.Attention != 2 || st.Hidden != 0 { |
| + t.Fatalf("rollup: %+v", st) |
| + } |
| + if len(st.Addresses) != 2 || st.Addresses[0].Health != "Not responding" { |
| + t.Fatalf("exceptions: %+v", st.Addresses) |
| + } |
| + if st.MoreURL != "/a/x/apps/app/instances" { |
| + t.Fatalf("MoreURL: %q", st.MoreURL) |
| + } |
| +} |
| + |
| +func TestFlowSmallStagesUnchanged(t *testing.T) { |
| + // 8 addresses exactly: RolledUp false, all 8 listed. |
| +} |
| + |
| +func TestFlowRollupCapsExceptions(t *testing.T) { |
| + // 20 addresses, 9 "Not responding": Attention 9, len(Addresses) 5, Hidden 4. |
| +} |
| +``` |
| + |
| +- [ ] **Step 2: Run, verify FAIL.** |
| + |
| +- [ ] **Step 3: Implement** — constants + filter in `flow.go`: |
| + |
| +```go |
| +// flowAddrMax is the most addresses a stage lists in full. Below it the |
| +// diagram looks exactly as it always has; above it, a page anticipating |
| +// hundreds of per-customer instances (2026-08-10 design) shows counts and |
| +// problems, never an unbounded list. |
| +const flowAddrMax = 8 |
| + |
| +// flowExceptionMax caps the needs-attention list inside a rolled-up stage; |
| +// the Running tab is where the full story lives. |
| +const flowExceptionMax = 5 |
| + |
| +// needsAttention is the rollup's filter: the words worth naming when there |
| +// are too many addresses to name them all. Asleep and No readings are |
| +// deliberately not here — counted, not listed (hibernation is the system |
| +// working; no-readings is ignorance, not alarm). |
| +func needsAttention(health string) bool { |
| + switch health { |
| + case "Not responding", "Failed", "Setting up": |
| + return true |
| + } |
| + return false |
| +} |
| +``` |
| + |
| +`FlowStage` gains the fields (comment block from Interfaces above). `assembleFlow` gains the `instancesPath string` param; inside `stage()`, after `Addresses: byChannel[channel]`: |
| + |
| +```go |
| + if addrs := s.Addresses; len(addrs) > flowAddrMax { |
| + s.RolledUp = true |
| + s.Total = len(addrs) |
| + s.MoreURL = instancesPath |
| + var exceptions []FlowAddress |
| + for _, a := range addrs { |
| + if !needsAttention(a.Health) { |
| + continue |
| + } |
| + s.Attention++ |
| + if len(exceptions) < flowExceptionMax { |
| + exceptions = append(exceptions, a) |
| + } |
| + } |
| + s.Hidden = s.Attention - len(exceptions) |
| + s.Addresses = exceptions |
| + } |
| +``` |
| + |
| +`handleApp`'s call site passes `p.Nav+"/apps/"+p.AppName+"/instances"` (check the actual field expressions used two lines up in the same handler for Nav/AppName spelling). `flow_stage` template — wrap the addresses block: |
| + |
| +```html |
| + {{if .RolledUp}} |
| + <p class="flow-note">{{.Total}} addresses{{if .Attention}} · <span class="error">{{.Attention}} need{{if eq .Attention 1}}s{{end}} attention</span>{{end}}</p> |
| + {{if .Addresses}} |
| + <ul class="flow-addrs"> |
| + {{range .Addresses}} |
| + <li class="flow-addr flow-tone-{{.Tone}}"> |
| + <a href="https://{{.Host}}">{{.Host}}</a> |
| + <span class="flow-health">{{if eq .Tone "bad"}}{{icon "triangle-alert"}}{{end}}{{.Health}}</span> |
| + </li> |
| + {{end}} |
| + </ul> |
| + {{end}} |
| + <p class="flow-note"><a href="{{.MoreURL}}">{{if .Hidden}}+{{.Hidden}} more · {{end}}all addresses</a></p> |
| + {{else}}{{if .Addresses}} |
| + <ul class="flow-addrs"> |
| + ... (the existing block, unchanged) |
| + </ul> |
| + {{end}}{{end}} |
| +``` |
| + |
| +- [ ] **Step 4: Run** `go test ./internal/console/ -count=1` → PASS (other `assembleFlow` call sites/tests get the new arg — compiler finds them). |
| + |
| +- [ ] **Step 5: Commit** — `console: flow diagram rolls up stages above 8 addresses`. |
| + |
| +--- |
| + |
| +### Task 8: Running tab — search, health filter, cursor pagination |
| + |
| +**Model:** sonnet |
| + |
| +**Files:** |
| +- Create: `internal/console/listquery.go` |
| +- Modify: `internal/console/server.go` (`handleAppInstances` ~line 1599; `page` struct ~line 526) |
| +- Modify: `internal/console/templates/app_instances.html` |
| +- Test: Create `internal/console/listquery_test.go`; extend `internal/console/overview_test.go` (page rendering) |
| + |
| +**Interfaces:** |
| +- Consumes: `InstanceRow` (host-ordered), health vocabulary. |
| +- Produces: `parseInstanceQuery(url.Values) instanceQuery`, `pageInstanceRows(rows []InstanceRow, q instanceQuery, limit int) instancePage`, `encodeInstanceCursor/decodeInstanceCursor`, `healthSlug/healthFromSlug`, `healthWords` — Task 9 reuses the query/cursor/slug pieces. `const instancePageSize = 50`. |
| + |
| +- [ ] **Step 1: Failing tests** in `listquery_test.go` (pure — exact code): |
| + |
| +```go |
| +package console |
| + |
| +import ( |
| + "net/url" |
| + "testing" |
| +) |
| + |
| +func rowsFor(hosts ...string) []InstanceRow { |
| + out := make([]InstanceRow, len(hosts)) |
| + for i, h := range hosts { |
| + out[i] = InstanceRow{Host: h, HealthWord: "Running"} |
| + } |
| + return out |
| +} |
| + |
| +func TestHealthSlugRoundTrips(t *testing.T) { |
| + for _, w := range healthWords { |
| + if got := healthFromSlug(healthSlug(w)); got != w { |
| + t.Fatalf("%q -> %q -> %q", w, healthSlug(w), got) |
| + } |
| + } |
| + if healthFromSlug("nonsense") != "" { |
| + t.Fatal("unknown slug must mean no filter, not an error") |
| + } |
| +} |
| + |
| +func TestPageFiltersAndCounts(t *testing.T) { |
| + rows := rowsFor("a.x", "ab.x", "b.x") |
| + rows[2].HealthWord = "Asleep" |
| + p := pageInstanceRows(rows, instanceQuery{Q: "a"}, 50) |
| + if p.Total != 2 || len(p.Rows) != 2 || p.NextCursor != "" { |
| + t.Fatalf("%+v", p) |
| + } |
| + if p.Counts["Running"] != 2 || p.Counts["Asleep"] != 1 { |
| + t.Fatalf("counts are over ALL rows, filtered or not: %+v", p.Counts) |
| + } |
| + p = pageInstanceRows(rows, instanceQuery{Health: "Asleep"}, 50) |
| + if p.Total != 1 || p.Rows[0].Host != "b.x" { |
| + t.Fatalf("%+v", p) |
| + } |
| +} |
| + |
| +func TestPageCursors(t *testing.T) { |
| + rows := rowsFor("a.x", "b.x", "c.x", "d.x", "e.x") |
| + p1 := pageInstanceRows(rows, instanceQuery{}, 2) |
| + if len(p1.Rows) != 2 || p1.NextCursor == "" || p1.Rows[1].Host != "b.x" { |
| + t.Fatalf("page1: %+v", p1) |
| + } |
| + p2 := pageInstanceRows(rows, instanceQuery{Cursor: p1.NextCursor}, 2) |
| + if len(p2.Rows) != 2 || p2.Rows[0].Host != "c.x" { |
| + t.Fatalf("page2: %+v", p2) |
| + } |
| + p3 := pageInstanceRows(rows, instanceQuery{Cursor: p2.NextCursor}, 2) |
| + if len(p3.Rows) != 1 || p3.Rows[0].Host != "e.x" || p3.NextCursor != "" { |
| + t.Fatalf("page3: %+v", p3) |
| + } |
| +} |
| + |
| +func TestPageGarbageCursorStartsOver(t *testing.T) { |
| + rows := rowsFor("a.x", "b.x") |
| + p := pageInstanceRows(rows, instanceQuery{Cursor: "!!not-base64!!"}, 50) |
| + if len(p.Rows) != 2 { |
| + t.Fatalf("bad cursor must degrade to page one: %+v", p) |
| + } |
| +} |
| + |
| +func TestParseInstanceQuery(t *testing.T) { |
| + q := parseInstanceQuery(url.Values{"q": {" api "}, "health": {"not-responding"}, "cursor": {"abc"}}) |
| + if q.Q != "api" || q.Health != "Not responding" || q.Cursor != "abc" { |
| + t.Fatalf("%+v", q) |
| + } |
| +} |
| +``` |
| + |
| +- [ ] **Step 2: Run, verify FAIL.** |
| + |
| +- [ ] **Step 3: Implement `listquery.go`** (exact): |
| + |
| +```go |
| +// The Running tab's and the instances API's shared list controls: a |
| +// substring match, a health-word filter, and an opaque after-this-host |
| +// cursor. GET-param driven and server-computed on purpose — a filter the |
| +// server applies is one the API and CLI can apply identically (every carlos |
| +// command works for a user with no box access), and a bookmarked filtered |
| +// URL keeps meaning the same thing. |
| +package console |
| + |
| +import ( |
| + "encoding/base64" |
| + "encoding/json" |
| + "net/url" |
| + "strings" |
| +) |
| + |
| +// instancePageSize is the Running tab's page length. |
| +const instancePageSize = 50 |
| + |
| +// healthWords is every word healthWord can emit, in the order the header |
| +// lists them. A new word joins healthWord, this list, and nothing else — |
| +// slugs, filters and links all derive from here. |
| +var healthWords = []string{"Running", "Serving", "Setting up", "Asleep", "No readings", "Not responding", "Failed"} |
| + |
| +// instanceQuery is one request's list controls. Zero value = everything, |
| +// first page. |
| +type instanceQuery struct { |
| + Q string // substring match on host, trimmed |
| + Health string // canonical health word ("" = all) |
| + Cursor string // opaque; list resumes after the host inside it |
| +} |
| + |
| +func parseInstanceQuery(v url.Values) instanceQuery { |
| + return instanceQuery{ |
| + Q: strings.TrimSpace(v.Get("q")), |
| + Health: healthFromSlug(v.Get("health")), |
| + Cursor: v.Get("cursor"), |
| + } |
| +} |
| + |
| +// healthSlug is a health word's URL form: "Not responding" ↔ |
| +// "not-responding". |
| +func healthSlug(word string) string { |
| + return strings.ReplaceAll(strings.ToLower(word), " ", "-") |
| +} |
| + |
| +// healthFromSlug inverts healthSlug. An unknown slug returns "" — no filter: |
| +// a mistyped URL shows everything rather than a mysteriously empty list. |
| +func healthFromSlug(slug string) string { |
| + for _, w := range healthWords { |
| + if healthSlug(w) == slug { |
| + return w |
| + } |
| + } |
| + return "" |
| +} |
| + |
| +type instanceCursor struct { |
| + Host string `json:"host"` |
| +} |
| + |
| +func encodeInstanceCursor(host string) string { |
| + b, _ := json.Marshal(instanceCursor{Host: host}) |
| + return base64.RawURLEncoding.EncodeToString(b) |
| +} |
| + |
| +// decodeInstanceCursor degrades on any malformed input to "no cursor" — |
| +// page one, never an error page (applog.DecodeCursor's rule). |
| +func decodeInstanceCursor(s string) (string, bool) { |
| + b, err := base64.RawURLEncoding.DecodeString(s) |
| + if err != nil { |
| + return "", false |
| + } |
| + var c instanceCursor |
| + if json.Unmarshal(b, &c) != nil || c.Host == "" { |
| + return "", false |
| + } |
| + return c.Host, true |
| +} |
| + |
| +// healthLink is one header chip: a word, its count over ALL the app's rows, |
| +// and the link that filters to it. Precomputed — templates render facts. |
| +type healthLink struct { |
| + Word, Slug string |
| + Count int |
| + Active bool |
| +} |
| + |
| +// instancePage is one screen of the list: the rows to render plus the |
| +// numbers and links the header states. |
| +type instancePage struct { |
| + Rows []InstanceRow |
| + Counts map[string]int // health word -> count over ALL rows |
| + Total int // rows matching Q+Health, across all pages |
| + Links []healthLink // non-zero words, healthWords order |
| + NextCursor string // "" on the last page |
| + NextURL string // the whole next-page href; "" on the last page |
| +} |
| + |
| +// pageInstanceRows applies q to rows (which arrive in host order and stay in |
| +// it) and cuts one page. Counts cover every row regardless of filter — the |
| +// header's chips are the app's whole health story, and double as filters. |
| +func pageInstanceRows(rows []InstanceRow, q instanceQuery, limit int) instancePage { |
| + p := instancePage{Counts: map[string]int{}} |
| + var matched []InstanceRow |
| + for _, r := range rows { |
| + p.Counts[r.HealthWord]++ |
| + if q.Q != "" && !strings.Contains(r.Host, q.Q) { |
| + continue |
| + } |
| + if q.Health != "" && r.HealthWord != q.Health { |
| + continue |
| + } |
| + matched = append(matched, r) |
| + } |
| + p.Total = len(matched) |
| + start := 0 |
| + if after, ok := decodeInstanceCursor(q.Cursor); ok { |
| + for i, r := range matched { |
| + if r.Host > after { |
| + start = i |
| + break |
| + } |
| + start = i + 1 |
| + } |
| + } |
| + end := start + limit |
| + if end < len(matched) { |
| + p.NextCursor = encodeInstanceCursor(matched[end-1].Host) |
| + } else { |
| + end = len(matched) |
| + } |
| + p.Rows = matched[start:end] |
| + for _, w := range healthWords { |
| + if p.Counts[w] == 0 { |
| + continue |
| + } |
| + p.Links = append(p.Links, healthLink{Word: w, Slug: healthSlug(w), Count: p.Counts[w], Active: q.Health == w}) |
| + } |
| + if p.NextCursor != "" { |
| + v := url.Values{} |
| + if q.Q != "" { |
| + v.Set("q", q.Q) |
| + } |
| + if q.Health != "" { |
| + v.Set("health", healthSlug(q.Health)) |
| + } |
| + v.Set("cursor", p.NextCursor) |
| + p.NextURL = "?" + v.Encode() |
| + } |
| + return p |
| +} |
| +``` |
| + |
| +- [ ] **Step 4: Run listquery tests → PASS.** |
| + |
| +- [ ] **Step 5: Wire the handler and template.** `page` struct gains `InstancePage instancePage` and `InstanceQuery instanceQuery`. `handleAppInstances` becomes: |
| + |
| +```go |
| + q := parseInstanceQuery(r.URL.Query()) |
| + rows := assembleInstanceRows(p.Detail, acct, p.AppMetrics) |
| + pg := pageInstanceRows(rows, q, instancePageSize) |
| + p.InstanceRows = pg.Rows |
| + p.InstancePage = pg |
| + p.InstanceQuery = q |
| + if len(rows) == 0 { |
| + p.AttachCmd, p.AttachEnable = s.attachHint(r.Context(), acct.Sqid, p.AppName, "stable") |
| + } |
| +``` |
| + |
| +(NOTE: the empty-state check moves to `len(rows)` — a filter that matches nothing must show "no matches", not the attach onboarding.) Template, between `<h2>Running</h2>` and the table (and only when the app has rows at all): |
| + |
| +```html |
| +{{if or .InstanceRows .InstanceQuery.Q .InstanceQuery.Health}} |
| +<form method="get" class="listctl"> |
| + <input type="search" name="q" value="{{.InstanceQuery.Q}}" placeholder="Filter addresses" aria-label="Filter addresses"> |
| + {{if .InstanceQuery.Health}}<input type="hidden" name="health" value="{{healthSlug .InstanceQuery.Health}}">{{end}} |
| + <button>Filter</button> |
| +</form> |
| +<p class="small"> |
| + <a href="?{{if .InstanceQuery.Q}}q={{.InstanceQuery.Q}}{{end}}"{{if not .InstanceQuery.Health}} aria-current="true"{{end}}>All</a> |
| + {{range .InstancePage.Links}} |
| + · <a href="?health={{.Slug}}{{if $.InstanceQuery.Q}}&q={{$.InstanceQuery.Q}}{{end}}"{{if .Active}} aria-current="true"{{end}}>{{.Word}} {{.Count}}</a> |
| + {{end}} |
| +</p> |
| +{{end}} |
| +``` |
| + |
| +Register `healthSlug` in the template FuncMap (find it: `grep -n "FuncMap" internal/console/*.go`; add `"healthSlug": healthSlug`). After the table, the pager and the filtered-empty state: |
| + |
| +```html |
| +{{if .InstancePage.NextURL}}<p class="small"><a href="{{.InstancePage.NextURL}}">Next {{len .InstanceRows}} shown of {{.InstancePage.Total}} →</a></p>{{end}} |
| +``` |
| + |
| +and change the `{{if .InstanceRows}}` empty branch: when `.InstanceQuery.Q` or `.InstanceQuery.Health` is set render `<p>No addresses match.</p>` instead of the attach hint. |
| + |
| +- [ ] **Step 6: Handler test** — extend `overview_test.go` next to `TestOverviewInstancesPageRendersTable` (same fixture helpers `newConsoleFixture`/`signInPaul`/`get`/`fx.u`): |
| + |
| +```go |
| +func TestInstancesPageFilters(t *testing.T) { |
| + fx := newConsoleFixture(t) |
| + c := signInPaul(t, fx) |
| + status, _, body := get(t, c, fx.u("/apps/kass/instances?q=zzz-no-such-host")) |
| + if status != http.StatusOK || !strings.Contains(body, "No addresses match.") { |
| + t.Fatalf("filtered-empty state missing (%d):\n%s", status, body) |
| + } |
| + status, _, body = get(t, c, fx.u("/apps/kass/instances?health=running")) |
| + if status != http.StatusOK { |
| + t.Fatalf("health filter = %d", status) |
| + } |
| +} |
| +``` |
| + |
| +- [ ] **Step 7: Run** `go test ./internal/console/ -count=1` → PASS. |
| + |
| +- [ ] **Step 8: Commit** — `console: Running tab gains search, health filter and cursor pagination`. |
| + |
| +--- |
| + |
| +### Task 9: Instances API — q/health/limit/cursor + health field |
| + |
| +**Model:** opus (auth-gated API surface) — **Opus reviews.** |
| + |
| +**Files:** |
| +- Modify: `internal/console/apiinstances.go` (`handleAPIInstanceList` line ~227, `instanceListRecord` line ~156, new `instanceHealth`) |
| +- Test: `internal/console/apiinstances_test.go` |
| + |
| +**Interfaces:** |
| +- Consumes: `parseInstanceQuery`, cursor + slug helpers (Task 8); `healthWord`/`expectsReadings` (Task 2); `OwnerAppMetrics` with `unitByHost` (Task 6); `openRegistry(s.cfg.Registry)`, `registry.Route`. |
| +- Produces (wire, ADDITIVE): each record gains `"health"`; the response object gains `"total"`, `"counts"`, `"next_cursor"`. Existing fields untouched; a request with no new params returns every record it did before (default limit stays `instanceListMax`). |
| + |
| +- [ ] **Step 1: Failing tests** in `apiinstances_test.go` (reuse its `instanceFixture` + `rawReq` bearer-request helper; create a few records through the same seeding path existing list tests use): |
| + |
| +```go |
| +func TestAPIInstanceListHealthAndFilters(t *testing.T) { |
| + // Seed 3 records: hosts "a.app.x", "ab.app.x", "b.app.x". |
| + // 1) GET /instances → 3 records, each with a "health" |
| + // string field; body contains "total":3 and a "counts" object. |
| + // 2) GET /instances?q=a → total 2, hosts a.app.x, ab.app.x. |
| + // 3) GET /instances?limit=1 → 1 record + non-empty next_cursor; |
| + // following the cursor returns the next host, no overlap. |
| + // 4) GET /instances?health=setting-up → only records whose health is |
| + // "Setting up" (a record with no registry route yet reads as that). |
| + // 5) No params → response still parses into the OLD client shape |
| + // (instances + truncated) — additive check. |
| +} |
| +``` |
| + |
| +Write the real body: unmarshal into `map[string]any` / small local structs, assert exactly. |
| + |
| +- [ ] **Step 2: Run, verify FAIL.** |
| + |
| +- [ ] **Step 3: Implement.** `instanceListRecord` gains `Health string \`json:"health"\``. New helper in `apiinstances.go`: |
| + |
| +```go |
| +// instanceHealth computes each record host's health word the same way the |
| +// Running tab does — registry row joined with the metrics sweep — so the |
| +// API, the CLI and the page can never disagree about an instance's state. |
| +// A record with no registry row yet is still provisioning: "Setting up". |
| +// Failures degrade to that same word rather than erroring the list: health |
| +// here is an annotation on the record, not the record. |
| +func (s *Server) instanceHealth(ctx context.Context, recs []provision.Record) map[string]string { |
| + out := make(map[string]string, len(recs)) |
| + for _, rec := range recs { |
| + out[rec.Host] = "Setting up" |
| + } |
| + reg, err := openRegistry(s.cfg.Registry) |
| + if err != nil { |
| + return out |
| + } |
| + routes, err := reg.List() |
| + if err != nil { |
| + return out |
| + } |
| + byHost := make(map[string]registry.Route, len(routes)) |
| + for _, rt := range routes { |
| + byHost[rt.Host] = rt |
| + } |
| + var hosts []string |
| + unitByHost := map[string]string{} |
| + for _, rec := range recs { |
| + rt, ok := byHost[rec.Host] |
| + if !ok { |
| + continue |
| + } |
| + hosts = append(hosts, rec.Host) |
| + if rt.Unit != "" { |
| + unitByHost[rec.Host] = rt.Unit |
| + } |
| + } |
| + am, err := s.OwnerAppMetrics(ctx, hosts, unitByHost) |
| + if err != nil { |
| + am = nil |
| + } |
| + rep := make(map[string]bool, len(am)) |
| + for _, m := range am { |
| + if m.Reporting() { |
| + rep[m.Host] = true |
| + } |
| + } |
| + for _, rec := range recs { |
| + rt, ok := byHost[rec.Host] |
| + if !ok { |
| + continue |
| + } |
| + status := rt.Status |
| + if status == "" { |
| + status = "ready" // registry contract: empty status means ready |
| + } |
| + out[rec.Host] = healthWord(rt.Kind, status, rt.Hibernate, rep[rec.Host], |
| + expectsReadings(rt.Provisioned, rt.Unit, rt.Backing)) |
| + } |
| + return out |
| +} |
| +``` |
| + |
| +`handleAPIInstanceList`, after the existing `recs, truncated` + `versions` reads (keep gate, timeout, error paths byte-identical): |
| + |
| +```go |
| + q := parseInstanceQuery(r.URL.Query()) |
| + limit := instanceListMax |
| + if raw := r.URL.Query().Get("limit"); raw != "" { |
| + if n, err := strconv.Atoi(raw); err == nil && n > 0 && n < instanceListMax { |
| + limit = n |
| + } |
| + } |
| + health := s.instanceHealth(ctx, recs) |
| + counts := map[string]int{} |
| + matched := make([]instanceListRecord, 0, len(recs)) |
| + for _, rec := range recs { |
| + h := health[rec.Host] |
| + counts[h]++ |
| + if q.Q != "" && !strings.Contains(rec.Host, q.Q) { |
| + continue |
| + } |
| + if q.Health != "" && h != q.Health { |
| + continue |
| + } |
| + matched = append(matched, instanceListRecord{Record: rec, StagedVersion: versions[rec.Host], Health: h}) |
| + } |
| + total := len(matched) |
| + start := 0 |
| + if after, ok := decodeInstanceCursor(q.Cursor); ok { |
| + for i, rec := range matched { |
| + if rec.Host > after { |
| + start = i |
| + break |
| + } |
| + start = i + 1 |
| + } |
| + } |
| + next := "" |
| + end := start + limit |
| + if end < total { |
| + next = encodeInstanceCursor(matched[end-1].Host) |
| + } else { |
| + end = total |
| + } |
| + writeJSON(w, http.StatusOK, map[string]any{ |
| + "instances": matched[start:end], "truncated": truncated, |
| + "total": total, "counts": counts, "next_cursor": next, |
| + }) |
| +``` |
| + |
| +(`provision.ListRecords` already returns records host-sorted — the cursor leans on that; do not re-sort.) |
| + |
| +- [ ] **Step 4: Run** `go test ./internal/console/ -count=1` → PASS. |
| + |
| +- [ ] **Step 5: Commit** — `console: instances API reports health, filters and paginates (additive)`. |
| + |
| +--- |
| + |
| +### Task 10: CLI — `carlos instances list -match -health` |
| + |
| +**Model:** sonnet |
| + |
| +**Files:** |
| +- Modify: `instances_cli.go` (`runInstancesList` ~line 255) |
| +- Modify: `apimode.go` (`instanceList` ~line 1066, `apiInstanceRecord` ~line 1019, `apiPath` ~line 217) |
| +- Test: extend the CLI's existing tests near `instances` (find: `grep -rn "instanceList\|instances list" *_test.go`); use `carlosTestEnv` + scratch `cmd.Dir` for any subprocess test. |
| + |
| +**Interfaces:** |
| +- Consumes: API params + fields from Task 9. |
| +- Produces: `-match <substr>`, `-health <slug>` flags; a HEALTH column; `instanceList(ctx, a, app, match, health string) (recs []apiInstanceRecord, truncated bool, total int, err error)`. |
| + |
| +- [ ] **Step 1: Failing test** — unit-test the request construction (an `httptest.Server` capturing the query; construct the `apiSession` the way the nearest apimode test does — check `apimode_test.go` / `auth_e2e_test.go` for the pattern): |
| + |
| +```go |
| +func TestInstanceListSendsFilters(t *testing.T) { |
| + // Fake console returns {"instances":[],"truncated":false,"total":0}. |
| + // Call instanceList with match "api", health "not-responding" and assert |
| + // the request URL had q=api and health=not-responding, and that an |
| + // -account-id session (a.Exact) still produces a well-formed URL with |
| + // BOTH exact=sqid and the filters (the "?"+"&" join). |
| +} |
| +``` |
| + |
| +- [ ] **Step 2: Run, verify FAIL.** |
| + |
| +- [ ] **Step 3: Implement.** `apiPath` learns to join (this is the bug the test above pins): |
| + |
| +```go |
| +func apiPath(a apiAcct, app, tail string) string { |
| + p := "/api/cli/apps/" + url.PathEscape(a.Seg) + "/" + url.PathEscape(app) + tail |
| + if a.Exact { |
| + sep := "?" |
| + if strings.Contains(p, "?") { |
| + sep = "&" |
| + } |
| + p += sep + "exact=sqid" |
| + } |
| + return p |
| +} |
| +``` |
| + |
| +`apiInstanceRecord` gains `Health string \`json:"health"\``. `instanceList` gains params and returns total: |
| + |
| +```go |
| +func (s *apiSession) instanceList(ctx context.Context, a apiAcct, app, match, health string) ([]apiInstanceRecord, bool, int, error) { |
| + tail := "/instances" |
| + v := url.Values{} |
| + if match != "" { |
| + v.Set("q", match) |
| + } |
| + if health != "" { |
| + v.Set("health", health) |
| + } |
| + if enc := v.Encode(); enc != "" { |
| + tail += "?" + enc |
| + } |
| + status, body, err := s.getJSON(ctx, apiPath(a, app, tail)) |
| + ... |
| + var res struct { |
| + Instances []apiInstanceRecord `json:"instances"` |
| + Truncated bool `json:"truncated"` |
| + Total int `json:"total"` |
| + } |
| + ... |
| + return res.Instances, res.Truncated, res.Total, nil |
| +} |
| +``` |
| + |
| +(keep the existing error paths verbatim; a pre-Task-9 console returns no `total` → 0, which the printer treats as "unknown" and omits). `runInstancesList` gains: |
| + |
| +```go |
| + match := fs.String("match", "", "only hosts containing this substring") |
| + health := fs.String("health", "", "only this state: running, serving, setting-up, asleep, no-readings, not-responding, failed") |
| +``` |
| + |
| +table header becomes `HOST\tREGION\tCHANNEL\tPOOL\tVERSION\tHEALTH\tCREATED\tBY`, the row printf gains `r.Health` in that position (em-dash `—` when empty — an old console), and after the flush: |
| + |
| +```go |
| + if (*match != "" || *health != "") && total > len(recs) { |
| + mutedf(stdout, "(showing %d of %d matches)", len(recs), total) |
| + } |
| +``` |
| + |
| +Update `instancesRecordsUsage` to document both flags. |
| + |
| +- [ ] **Step 4: Run** `go test ./... -count=1` (CLI tests live at the repo root package) → PASS. |
| + |
| +- [ ] **Step 5: Commit** — `cli: instances list -match/-health, server-side, with HEALTH column`. |
| + |
| +--- |
| + |
| +### Task 11: Whole-branch gate, rebase, PR |
| + |
| +**Model:** run in the main session (Fable) — whole-branch final review per the delegation policy. |
| + |
| +- [ ] **Step 1:** `git fetch origin && git rebase origin/main` (a parallel session merged PR #140 onto main mid-branch — resolve conflicts toward main's shapes). |
| +- [ ] **Step 2:** Full gate: `go build ./... && go vet ./... && go test ./... -count=1`, `GOOS=darwin go build ./...`, `gofmt -l` on every touched file. |
| +- [ ] **Step 3:** Re-read the spec end to end against the diff — every section must map to landed code (the self-review checklist at the bottom of this plan). |
| +- [ ] **Step 4:** Push and open the PR: title `Dashboard truth + scale: honest health, route-granular mismatch banner, instance list controls`, body summarizing the spec's Diagnosis + Design sections, `Closes` nothing automatically (the tito stamping and 404 investigation are operational follow-ups), ending with the standard generation footer. |
| + |
| +--- |
| + |
| +## Rollout runbook (operator / main session — NOT a subagent task) |
| + |
| +Order matters; each step verifies against the thing it changed with the binary it built. |
| + |
| +1. Merge the PR. Deploy **boxes first** (`update-platform-binary.sh` on flagship + `carlos-tito-ie`) — sampling stamped units is harmless while nothing is stamped. Then both consoles (`roll-console.sh`; console = the `cmd/console` build, NOT the root binary — both boxes are arm64). Coordinate on the current deploy lock discipline (another session was rolling PR #140; check open PRs and parallel-session deploys BEFORE deploying). |
| +2. Stamp tito's bespoke-unit routes, as user carlos on `carlos-tito-ie`, one per route (registry `-registry /data/titogo/registry.db` — CHECK which registry the tito console reads; the box has two: `/data/registry.db` platform, `/data/titogo/registry.db` titogo): |
| + `carlos route -registry <path> -host vicky.go.tito.io -unit titogo@vicky.service` (canaries use `titogo-canary@<name>.service` — confirm each unit's real name with `systemctl list-units 'titogo*'` first; a wrong stamp yields no reading, not an error). |
| +3. Verify: the titogo Overview on the tito console shows the stamped routes **Running with real CPU/memory** within ~2 sweep ticks; the Running tile reads `N of N reporting`; `curl -sI --resolve` untouched — this change is console-content only. |
| +4. Confirm the banner: after the roll it must name the actual `bab`-stamped rows (if any remain) instead of the app-wide alarm. Fix those rows (re-stamp/migrate) as a separate operational step. |
| +5. Investigate `titogo.bae.platform.tito.io` (404, no version header) — genuinely broken, pre-existing, not addressed by this branch. |
| +6. Website/tap batch per the standing rule if any member-facing docs mention the instances list. |
| + |
| +## Self-Review (run after writing all code) |
| + |
| +1. **Spec coverage:** §1 readings+vocabulary → Tasks 1,2,5,6; §1 tiles → Task 3; §2 banner → Task 4; §3 rollup → Task 7; §3 Running tab → Task 8; §3 API → Task 9; §3 CLI → Task 10; §4 rollout → runbook; §5 tests → inside each task. |
| +2. **Placeholder scan:** none of "TBD/TODO/similar to Task N"; the only "check the actual X" notes point at named files for signatures this plan could not verify (fixture seeding helpers, apiSession construction) — resolve them by reading the named file, not by inventing. |
| +3. **Type consistency:** `expectsReadings(provisioned bool, unit, backing string)`; `healthWord(kind, status string, hibernate, reporting, expectReadings bool)`; `OwnerAppMetrics(ctx, hosts []string, unitByHost map[string]string)`; `assembleFlow(view, rows, latest, latestAt, instancesPath string)`; `pageInstanceRows(rows []InstanceRow, q instanceQuery, limit int) instancePage`; `instanceList(ctx, a, app, match, health string) ([]apiInstanceRecord, bool, int, error)` — used identically everywhere above. |