plan: fleets console v2 — 8 tasks, subagent-sized, model tiers per CLAUDE.md
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 file changed,
+489
−0
docs/superpowers/plans/2026-08-10-fleets-console.md+489 −0
diff --git a/docs/superpowers/plans/2026-08-10-fleets-console.md b/docs/superpowers/plans/2026-08-10-fleets-console.md| new file mode 100644 |
| index 000000000..0b75318de |
| --- /dev/null |
| +++ b/docs/superpowers/plans/2026-08-10-fleets-console.md |
| @@ -0,0 +1,489 @@ |
| +# Fleets Console v2 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:** The account-level Fleet tab (shared-fleet card, fleets index, BYO instructions), fleet-box tags, console placement moves, and the fleet-channel fast-ping — implementing `docs/superpowers/specs/2026-08-10-fleets-console-design.md` (D1–D8) on branch `worktree-fleets-v2`, stacked on PR #63. |
| + |
| +**Architecture:** Everything stacks on #63's fleets machinery. New console surface follows the redesign conventions (accountPage/appPage, `{{.Nav}}` links, `{{.Base}}` forms, gate order session → CSRF → owner-404). One additive registry column (`fleet_boxes.tags`). The edge change is confined to dynamic (fleet) channels' ping cadence. |
| + |
| +**Tech Stack:** Go 1.x, modernc sqlite, html/template, x/net/http2. No new dependencies. |
| + |
| +## Global Constraints |
| + |
| +- Branch `worktree-fleets-v2`; base is #63's tip (`6629c52`). Never touch `main`. |
| +- Gate before every commit batch: `go build ./... && go vet ./... && go test ./... -count=1` (all 20 packages), `gofmt -l` clean on touched files. Sandbox env: `GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off`; go commands may need sandbox disabled. |
| +- Wire/storage shapes are **additive only**: pre-change registry rows and JSON responses must parse unchanged. |
| +- Console security gate order: session → CSRF → owner (byte-identical 404) — `requireFreshAuth` last. Unknown fleet and other-account fleet answer identical `http.NotFound`. |
| +- Tag grammar (spec): comma-separated, each tag `^[a-z0-9-]{1,24}$`, max 8 tags, empty string = untagged. Reject, don't sanitize. |
| +- Fleet ping cadence (spec D7): dynamic channels ping every 5s, timeout 5s; static channels keep 15s/10s. |
| +- Match surrounding comment density and idiom; icons are vendored Lucide, emoji never chrome. |
| + |
| +--- |
| + |
| +### Task 1: Registry — `fleet_boxes.tags` (additive column + accessors) |
| + |
| +**Files:** |
| +- Modify: `internal/registry/fleets.go` (migrateFleets ~L74, FleetBox struct ~L40, ListFleetBoxes ~L344; add ValidBoxTags + SetFleetBoxTags beside SetFleetBoxToken ~L289) |
| +- Test: `internal/registry/fleets_test.go` |
| + |
| +**Interfaces:** |
| +- Produces: `FleetBox.Tags string` (comma form, storage-identical); `func ValidBoxTags(tags string) bool`; `func (r *Registry) SetFleetBoxTags(fleet, label, tags string) error` (bumps `rev`; refuses invalid grammar and unknown (fleet,label)). |
| +- Consumes: existing `migrateFleets`, `freshFleetsConn`, `ValidTunnelName` patterns in the same file. |
| + |
| +- [ ] **Step 1: Write the failing tests** in `internal/registry/fleets_test.go` (same package/test style as the existing fleet tests there): |
| + |
| +```go |
| +func TestFleetBoxTags(t *testing.T) { |
| + r := openTestRegistry(t) // reuse this file's existing constructor helper (whatever name it uses) |
| + mustCreateFleet(t, r, "pi", "acct1", "bkt") |
| + mustAddBox(t, r, "pi", "box-a") // reuse/add tiny helpers mirroring existing tests |
| + // grammar |
| + for _, bad := range []string{"Office", "a b", "-lead", "toolongtoolongtoolongtoolo", "a,,b", |
| + "a,b,c,d,e,f,g,h,i"} { |
| + if ValidBoxTags(bad) { |
| + t.Errorf("ValidBoxTags(%q) = true, want false", bad) |
| + } |
| + } |
| + for _, good := range []string{"", "office", "office,free", "a-1"} { |
| + if !ValidBoxTags(good) { |
| + t.Errorf("ValidBoxTags(%q) = false, want true", good) |
| + } |
| + } |
| + if err := r.SetFleetBoxTags("pi", "box-a", "Office"); err == nil { |
| + t.Fatal("invalid grammar accepted") |
| + } |
| + if err := r.SetFleetBoxTags("pi", "nope", "office"); err == nil { |
| + t.Fatal("unknown box accepted") |
| + } |
| + if err := r.SetFleetBoxTags("pi", "box-a", "office,free"); err != nil { |
| + t.Fatal(err) |
| + } |
| + boxes, err := r.ListFleetBoxes("pi") |
| + if err != nil || len(boxes) != 1 || boxes[0].Tags != "office,free" { |
| + t.Fatalf("tags round-trip: %+v err=%v", boxes, err) |
| + } |
| + // clearing |
| + if err := r.SetFleetBoxTags("pi", "box-a", ""); err != nil { |
| + t.Fatal(err) |
| + } |
| +} |
| + |
| +func TestFleetBoxTagsFingerprintMoves(t *testing.T) { |
| + // mirror the existing fingerprint test for token writes in this file: |
| + // snapshot Fingerprint(), SetFleetBoxTags, assert Fingerprint() changed. |
| +} |
| + |
| +func TestFleetBoxesPreTagsRowsParse(t *testing.T) { |
| + // additive-shape: build a registry, then simulate a pre-change row by |
| + // creating the table WITHOUT tags in a scratch db file, running |
| + // migrate via NewRegistry over it, and asserting ListFleetBoxes returns |
| + // Tags == "" with no error. Mirror however the existing routes |
| + // pool/region additive tests do this (see registry_test.go). |
| +} |
| +``` |
| + |
| +- [ ] **Step 2: Run to verify failure** — `go test ./internal/registry/ -run TestFleetBoxTags -count=1` → compile error (`ValidBoxTags` undefined). |
| + |
| +- [ ] **Step 3: Implement.** In `migrateFleets`, after the two CREATEs, add the additive column exactly the way `registry.go` adds `routes.pool`/`routes.region` (grep `ALTER TABLE routes ADD COLUMN` and copy that ignore-duplicate-error idiom): |
| + |
| +```go |
| +// tags: additive (2026-08-10-fleets-console-design.md D3). Pre-change rows |
| +// read as '' — untagged. |
| +r.sql.Exec(`ALTER TABLE fleet_boxes ADD COLUMN tags TEXT NOT NULL DEFAULT ''`) |
| +``` |
| + |
| +Add to `FleetBox`: `Tags string // comma-separated, ValidBoxTags grammar; "" = untagged`. Extend `ListFleetBoxes`'s SELECT + scan with `tags`. Add: |
| + |
| +```go |
| +var boxTagRe = regexp.MustCompile(`^[a-z0-9-]{1,24}$`) |
| + |
| +// ValidBoxTags reports whether tags is a well-formed comma-separated tag |
| +// list: each tag boxTagRe, at most 8, no empty segments. "" is valid and |
| +// means untagged. |
| +func ValidBoxTags(tags string) bool { |
| + if tags == "" { |
| + return true |
| + } |
| + parts := strings.Split(tags, ",") |
| + if len(parts) > 8 { |
| + return false |
| + } |
| + for _, p := range parts { |
| + if !boxTagRe.MatchString(p) { |
| + return false |
| + } |
| + } |
| + return true |
| +} |
| + |
| +// SetFleetBoxTags mirrors SetFleetBoxToken: same rev bump, same |
| +// RowsAffected guard for an unknown (fleet, label). |
| +func (r *Registry) SetFleetBoxTags(fleet, label, tags string) error { ... } |
| +``` |
| + |
| +`SetFleetBoxTags` body: copy `SetFleetBoxToken`'s shape verbatim (UPDATE … SET tags=?, rev=rev+1 WHERE fleet=? AND label=?; RowsAffected==0 → error), swapping the validation to `ValidBoxTags`. |
| + |
| +- [ ] **Step 4: Run** `go test ./internal/registry/ -count=1` → PASS. |
| +- [ ] **Step 5: Commit** — `git add internal/registry && git commit -m "registry: fleet_boxes.tags — additive column, grammar, rev-bumped writer"` |
| + |
| +--- |
| + |
| +### Task 2: Fleet API + CLI — tags surface (`carlos fleets tag-box`, ls column) |
| + |
| +**Files:** |
| +- Modify: `internal/console/fleets.go` (fleetBoxView + handleAPIFleetList ~L211; new handleAPIFleetTagBox beside handleAPIFleetRotateBox ~L312), `internal/console/authcli.go` (register the door beside the rotate/detach registrations), `fleets.go` (repo root — RunFleets dispatch ~L85, new runFleetsTagBox, runFleetsList ~L278) |
| +- Test: `internal/console/fleets_test.go` (or wherever the existing fleet API door tests live — find `handleAPIFleetRotateBox` tests and sit beside them), `fleets_cli_test.go` if CLI-level tests exist for fleets verbs (check `cli_test.go` for the fleets pattern; if the existing verbs have no CLI-level tests, API-level tests suffice — do not invent a new harness). |
| + |
| +**Interfaces:** |
| +- Consumes: Task 1's `ValidBoxTags`, `SetFleetBoxTags`, `FleetBox.Tags`. |
| +- Produces: `POST /api/cli/fleets/{account}/{fleet}/boxes/{label}/tags` accepting `{"tags":"office,free"}` (owner bearer auth like rotate; 400 on grammar, 404 on unknown box — same shapes as rotate); `fleetBoxView.Tags string` (`json:"tags,omitempty"` — additive); CLI verb `carlos fleets tag-box <fleet> <label> -tags office,free` (full replace, `-tags ""` clears) and a TAGS column in `fleets ls`. |
| + |
| +- [ ] **Step 1: Failing API test**, modeled exactly on the existing rotate-box door test: |
| + |
| +```go |
| +func TestAPIFleetTagBox(t *testing.T) { |
| + // arrange: server + account + fleet "pi" + box "box-a" (copy the rotate test's arrange) |
| + // act: POST /api/cli/fleets/{acct}/pi/boxes/box-a/tags body {"tags":"office,free"} |
| + // assert: 200; ListFleetBoxes shows Tags "office,free"; |
| + // GET /api/cli/fleets/{acct} body contains "tags":"office,free" |
| + // bad grammar {"tags":"Office"} → 400 |
| + // unknown label → 404, byte-identical to rotate's unknown-label 404 |
| + // other account's fleet → 404 (apiFleet already gives this — assert anyway) |
| +} |
| +``` |
| + |
| +- [ ] **Step 2: Verify failure** — `go test ./internal/console/ -run TestAPIFleetTagBox -count=1` → 404 (route unregistered). |
| +- [ ] **Step 3: Implement.** Handler is a trimmed copy of `handleAPIFleetRotateBox` (same `apiFleet` gate + `findFleetBox`), ending in `SetFleetBoxTags` + `s.audit(acct, "", addr, "fleet", "tagged "+fleet.Name+"/"+label+" ["+tags+"] (cli)")` + `writeJSON(w, http.StatusOK, map[string]any{})`. Validate `registry.ValidBoxTags` before touching the registry → `jsonError(w, http.StatusBadRequest, "invalid tags — comma-separated [a-z0-9-], max 8")`. Extend `fleetBoxView` with `Tags string \`json:"tags,omitempty"\`` and populate in `handleAPIFleetList`. |
| +- [ ] **Step 4: CLI.** In root `fleets.go`: add `case "tag-box":` to `RunFleets`; `runFleetsTagBox` copies `runFleetsRotateToken`'s flag/arg shape plus `-tags` string flag (NOTE: distinguish "flag absent" from `-tags ""` is unnecessary — the verb requires `-tags`, and `""` clears). `runFleetsList`: add TAGS to the header and row printf (empty prints as `-`, matching the file's existing column style if it has one; otherwise print raw). |
| +- [ ] **Step 5: Gate + commit** — full `go build ./... && go test ./... -count=1`; `git commit -m "fleets: tag-box — API door, CLI verb, ls column"` |
| + |
| +--- |
| + |
| +### Task 3: Fleet detail page — tags chips + owner edit |
| + |
| +**Files:** |
| +- Modify: `internal/console/templates/fleetdetail.html` (Boxes table), `internal/console/fleets.go` (`fleetPageBox` ~L626, `loadFleetPage` ~L721; new `handleFleetTagBoxPage` beside `handleFleetRotateBoxPage` ~L837), `internal/console/server.go` (route registration beside L341-342) |
| +- Test: beside the existing fleet-page tests (find `handleFleetPage`/rotate-page tests in `internal/console`) |
| + |
| +**Interfaces:** |
| +- Consumes: Task 1's accessors; existing `ownerFleet` gate + flash pattern from `handleFleetRotateBoxPage`. |
| +- Produces: `POST /a/{sqid}/fleets/{fleet}/tags` (form fields `csrf`, `label`, `tags`), redirect back with flash; `fleetPageBox.Tags []string` (split for chips). |
| + |
| +- [ ] **Step 1: Failing tests** — extend the fleet-page render test: box with tags shows each tag; POST tags as owner updates + redirects with flash; POST as plain member 403/404s exactly like rotate does; bad grammar → flash error, nothing written. |
| +- [ ] **Step 2: Verify failure.** |
| +- [ ] **Step 3: Implement.** `fleetPageBox` gains `Tags []string`; `loadFleetPage` fills it (`strings.Split` on non-empty). Handler copies `handleFleetRotateBoxPage`'s gate/flash/redirect skeleton, calling `SetFleetBoxTags`. Template: in the Boxes table add a `Tags` column — |
| + |
| +```html |
| +<td>{{range .Tags}}<span class="chip">{{.}}</span> {{end}}{{if not .Tags}}—{{end}}</td> |
| +``` |
| + |
| +and, in the owner cell, a third inline form: |
| + |
| +```html |
| +<form method="post" action="{{$.Base}}/fleets/{{$.FleetDetail.Name}}/tags"> |
| + <input type="hidden" name="csrf" value="{{$.CSRF}}"> |
| + <input type="hidden" name="label" value="{{.Label}}"> |
| + <input type="text" name="tags" value="{{join .Tags ","}}" size="14" placeholder="office,free"> |
| + <button type="submit" class="linkish">Tag</button> |
| +</form> |
| +``` |
| + |
| +(If the template FuncMap has no `join`, add it in server.go's template setup beside the existing helpers — `"join": strings.Join`.) Reuse the existing `.chip` CSS class; add nothing new to layout.html unless `.chip` doesn't exist (it does — `chip chip-down` is used in this very template). |
| +- [ ] **Step 4: Gate.** — full test run. |
| +- [ ] **Step 5: Commit** — `git commit -m "console: fleet page tags — chips + owner edit form"` |
| + |
| +--- |
| + |
| +### Task 4: The Fleet tab — nav, page, shared-fleet card, fleets index |
| + |
| +**Files:** |
| +- Create: `internal/console/accountfleet.go`, `internal/console/accountfleet_test.go`, `internal/console/templates/account_fleet.html` |
| +- Modify: `internal/console/server.go` (route beside L313; `page` struct fields near the fleet fields ~L528; ActiveNav comment L443), `internal/console/templates/layout.html` (account nav L316-318) |
| + |
| +**Interfaces:** |
| +- Consumes: `FleetView(ctx, s.cfg.Releases)` (view.go:597, reads `status/fleet.json` → `*status.Fleet` with `.Boxes[].Region`), `reg.ListFleets()`/`ListFleetBoxes`, `loadFleetPage` (fleets.go:721) for per-fleet heartbeat freshness, `registry.FleetPlacement`, `s.accountPage` chrome (server.go), `fleetPageURL` (fleets.go:826). |
| +- Produces: `GET /a/{sqid}/fleet` (`ActiveNav: "fleet"`), template `account_fleet`, view types `sharedFleetCard{Title string; Active bool; Regions []string; Boxes int; PlatformApps int}` and `accountFleetCard{Name string; Boxes, Reachable, Apps int}`; page fields `SharedFleet *sharedFleetCard`, `AccountFleets []accountFleetCard`. Task 5 hangs its BYO link off this page. |
| + |
| +- [ ] **Step 1: Failing tests** in `accountfleet_test.go` (copy the arrange style of the fleet-page tests; the console test harness has fixtures for registry + fake release store): |
| + |
| +```go |
| +// renders for owner AND plain member; non-member gets the account gate's usual answer |
| +// shared card: with a fixture status/fleet.json of two boxes (regions "EU · Ireland", |
| +// "UK · London") → Regions == both, Boxes == 2 |
| +// Active flips: account route with Region "" → Active; all routes fleet-placed → not Active |
| +// fleets index: two fleets owned + one other-account fleet → only the two, each with |
| +// box counts; page links to /a/<seg>/fleets/<name> |
| +// degraded: no fleet.json in store → page still 200, shared card renders "no boxes yet" |
| +``` |
| + |
| +- [ ] **Step 2: Verify failure.** |
| +- [ ] **Step 3: Implement `assembleAccountFleet`** in `accountfleet.go` — the `overview.go` posture: every read soft-fails to an empty section, never a 500. |
| + |
| +```go |
| +// assembleAccountFleet builds the Fleet tab's two halves: the shared |
| +// platform fleet as a PRESENTATION of status/fleet.json (D2 — deliberately |
| +// not a registry object), and the account's own fleets from the registry. |
| +func (s *Server) assembleAccountFleet(ctx context.Context, acct *Account) (*sharedFleetCard, []accountFleetCard, string) { |
| + card := &sharedFleetCard{Title: "Shared fleet"} |
| + if s.cfg.Releases != nil { |
| + if f, err := FleetView(ctx, s.cfg.Releases); err == nil && f != nil { |
| + card.Boxes = len(f.Boxes) |
| + seen := map[string]bool{} |
| + for _, b := range f.Boxes { |
| + if b.Region != "" && !seen[b.Region] { |
| + seen[b.Region] = true |
| + card.Regions = append(card.Regions, b.Region) |
| + } |
| + } |
| + sort.Strings(card.Regions) |
| + } |
| + } |
| + // placement split from this account's routes |
| + reg, err := openRegistry(s.cfg.Registry) |
| + ... |
| + routes, _ := reg.List() |
| + platformApps := map[string]bool{} |
| + fleetApps := map[string]map[string]bool{} // fleet -> app set |
| + for _, rt := range routes { |
| + if rt.Account != acct.Sqid { continue } |
| + if f, ok := registry.FleetPlacement(rt.Region); ok { |
| + if fleetApps[f] == nil { fleetApps[f] = map[string]bool{} } |
| + fleetApps[f][rt.App] = true |
| + } else { |
| + platformApps[rt.App] = true |
| + } |
| + } |
| + card.PlatformApps = len(platformApps) |
| + card.Active = card.PlatformApps > 0 |
| + // account fleets |
| + fleets, _ := reg.ListFleets() |
| + var cards []accountFleetCard |
| + for _, f := range fleets { |
| + if f.Account != acct.Sqid { continue } |
| + boxes, apps, _ := s.loadFleetPage(ctx, reg, acct, &f) |
| + c := accountFleetCard{Name: f.Name, Boxes: len(boxes), Apps: len(apps)} |
| + for _, b := range boxes { if b.Reachable { c.Reachable++ } } |
| + cards = append(cards, c) |
| + } |
| + return card, cards, warn |
| +} |
| +``` |
| + |
| +(Exact soft-fail/warn wiring: mirror `loadFleetPage`'s `warn` convention. `_ = fleetApps` is NOT dead — render per-fleet placed counts if `accountFleetCard.Apps` from `loadFleetPage` disagrees; prefer `loadFleetPage`'s apps since it's the page users click through to.) |
| + |
| +Handler in `accountfleet.go`: |
| + |
| +```go |
| +// handleAccountFleetTab is the account-level Fleet tab (D1) — a different |
| +// thing from handleFleet (the operator's global /fleet status page) and |
| +// from handleFleetPage (one fleet's detail). |
| +func (s *Server) handleAccountFleetTab(w http.ResponseWriter, r *http.Request) { |
| + _, _, acct, role, ok := s.account(w, r) |
| + if !ok { return } |
| + shared, own, warn := s.assembleAccountFleet(r.Context(), acct) |
| + p := s.accountPage(r, acct, role) |
| + p.ActiveNav = "fleet" |
| + p.SharedFleet, p.AccountFleets, p.FlashErr = shared, own, warn |
| + s.render(w, r, "account_fleet", p) |
| +} |
| +``` |
| + |
| +**NOTE to implementer:** `s.account`, `s.accountPage`, `s.render` signatures — copy from `handleTeam` (server.go:1329-ish) exactly; the sketch above compresses. Register `s.mux.HandleFunc("GET /a/{sqid}/fleet", s.handleAccountFleetTab)` beside `/a/{sqid}/team`. Extend the ActiveNav comment to `"apps" | "fleet" | "team" | "settings"`. |
| + |
| +Nav (layout.html, after the Apps line): |
| + |
| +```html |
| +<a href="{{.Nav}}/fleet"{{if eq .ActiveNav "fleet"}} aria-current="page"{{end}}>Fleet</a> |
| +``` |
| + |
| +Template `account_fleet.html` (pagehead + three sections; `.chip` for regions, `card` layout — copy structural CSS class usage from `apps.html`): |
| + |
| +```html |
| +{{define "account_fleet"}}{{template "top" .}} |
| +{{if .FlashErr}}<p class="banner error">{{.FlashErr}}</p>{{end}} |
| +<div class="pagehead"> |
| + <p class="eyebrow">{{.Account.Name}}</p> |
| + <h1>Fleet</h1> |
| + <p class="lede">Where this account's apps run — the shared fleet, and boxes you bring.</p> |
| +</div> |
| + |
| +<h2>{{.SharedFleet.Title}} {{if .SharedFleet.Active}}<span class="chip">Active</span>{{else}}<span class="chip">Not used</span>{{end}}</h2> |
| +{{if .SharedFleet.Boxes}} |
| +<p>{{.SharedFleet.Boxes}} box{{if ne .SharedFleet.Boxes 1}}es{{end}} in |
| +{{range .SharedFleet.Regions}}<span class="chip">{{.}}</span> {{end}}</p> |
| +<p class="small">{{if .SharedFleet.Active}}{{.SharedFleet.PlatformApps}} of your apps run here. Apps run on the shared fleet unless you place them on a fleet of your own.{{else}}None of your apps run on the shared fleet — they are all placed on your own fleets.{{end}}</p> |
| +{{else}}<p class="small">No shared boxes are reporting right now.</p>{{end}} |
| + |
| +<h2>Your fleets</h2> |
| +{{if .AccountFleets}} |
| +<div class="tablewrap"><table> |
| + <tr><th>Fleet</th><th>Boxes</th><th>Reachable</th><th>Placed apps</th></tr> |
| + {{range .AccountFleets}} |
| + <tr> |
| + <td><a href="{{$.Nav}}/fleets/{{.Name}}">{{.Name}}</a></td> |
| + <td>{{.Boxes}}</td><td>{{.Reachable}}</td><td>{{.Apps}}</td> |
| + </tr> |
| + {{end}} |
| +</table></div> |
| +{{else}}<p class="small">No fleets yet.</p>{{end}} |
| + |
| +<h2>Bring your own fleet</h2> |
| +<p>Run your apps on hardware you own — an office box, a Raspberry Pi, your |
| +own cloud account. Your boxes dial out to the platform; nothing connects in.</p> |
| +<p><a class="btn" href="{{.Nav}}/fleet/byo">How to add and harden a box</a></p> |
| +{{template "bottom" .}}{{end}} |
| +``` |
| + |
| +- [ ] **Step 4: Gate** — full run; check the nav renders on every account page (existing server tests exercising layout will catch template errors). |
| +- [ ] **Step 5: Commit** — `git commit -m "console: the Fleet tab — shared-fleet card, fleets index, nav (D1/D2)"` |
| + |
| +--- |
| + |
| +### Task 5: BYO instructions page |
| + |
| +**Files:** |
| +- Create: `internal/console/templates/fleet_byo.html` |
| +- Modify: `internal/console/server.go` (route `GET /a/{sqid}/fleet/byo`), `internal/console/accountfleet.go` (handler) |
| +- Test: extend `accountfleet_test.go` |
| + |
| +**Interfaces:** |
| +- Consumes: Task 4's nav/ActiveNav; the **authoritative env contract** in `infra/dev/region-pi.md` and `internal/host/agent.go`'s package doc (copy variable names from there — do not invent). |
| +- Produces: `GET /a/{sqid}/fleet/byo`, member-readable, `ActiveNav: "fleet"`. |
| + |
| +- [ ] **Step 1: Failing test** — page 200s for a member, contains `carlos fleets create`, `CARLOS_TUNNEL_NAME`, and a "Hardening" heading. |
| +- [ ] **Step 2: Verify failure.** |
| +- [ ] **Step 3: Implement.** Handler mirrors Task 4's minus assembly. Template content, in order (pagehead: eyebrow "Fleet · {{.Account.Name}}", h1 "Bring your own fleet"): |
| + 1. **What you need** — a Linux box (arm64 or amd64) that can reach the internet outbound; an object-storage bucket you own for the fleet's data; nothing listens inbound, ever. |
| + 2. **Create the fleet** — fenced code block: `carlos fleets create -bucket <your-bucket> <name>` then `carlos fleets add-box <name> <label>` with the shown-once token warning (reuse fleetdetail.html's exact rotation-is-not-revocation phrasing for consistency). |
| + 3. **Configure the box** — the env contract block copied verbatim from `infra/dev/region-pi.md` (implementer: read that file on this branch and transcribe the fleet env vars — `CARLOS_FLEET`, `CARLOS_FLEET_BOX`, `CARLOS_REGION=fleet/<name>`, `CARLOS_STATUS_PREFIX`, `CARLOS_TUNNEL_NAME=<fleet>/<label>`, `CARLOS_TUNNEL_TOKEN`, `CARLOS_HIBERNATE_BUCKET`, plus whichever tunnel-address/bucket-credential vars that doc lists), with `<name>`/`<label>` placeholders. |
| + 4. **Harden it** — checklist (`<ul>`): dedicated non-root user for the agent; the env file root-owned mode 600 (it holds the token); default-deny inbound firewall (the box only dials out); unattended security upgrades on; clock kept sane (chrony/systemd-timesyncd — leases compare timestamps); systemd unit with `ProtectSystem=strict`/`NoNewPrivileges` (point at `internal/host/units/` as the reference shapes); rotate the token if it may have leaked, **Detach** (not rotate) to cut a compromised box off. |
| + 5. **Then** — the box appears on your fleet page within minutes (heartbeat cadence); place an app with `carlos apps place` or from the app's Instances tab. |
| +- [ ] **Step 4: Gate.** |
| +- [ ] **Step 5: Commit** — `git commit -m "console: BYO fleet instructions page (D8)"` |
| + |
| +--- |
| + |
| +### Task 6: Console placement moves — app Instances tab + fleet page form |
| + |
| +**Files:** |
| +- Modify: `internal/console/fleets.go` (extract `placeAppTarget` core from `handleAPIPlace` ~L479-601; new browser handler `handleAppPlace`), `internal/console/server.go` (route `POST /a/{sqid}/apps/{app}/place`; app-instances assembly additions), `internal/console/templates/app_instances.html` (placement section), `internal/console/templates/fleetdetail.html` ("Place an app here" form + handler route `POST /a/{sqid}/fleets/{fleet}/place`) |
| +- Test: `internal/console/fleets_test.go` additions + existing `handleAPIPlace` tests must stay green **unchanged** (they pin the refactor) |
| + |
| +**Interfaces:** |
| +- Consumes: `handleAPIPlace`'s existing body — its store-first, routes-before-records ORDER MATTERS discipline moves verbatim into the core; `appPlacement` (fleets.go:412) for current placement; `s.accountApp`/`s.owner` gates; `confirm`? No — plain form + warning copy (D6). |
| +- Produces: `func (s *Server) placeAppTarget(ctx context.Context, acct *Account, app, target string) (status int, errMsg string)` — returns (0, "") on success; both handlers translate. Template data: `AppPlacement string`, `OwnedFleetNames []string` on the instances-tab view; `FleetPlaceableApps []string` on the fleet page. |
| + |
| +- [ ] **Step 1: Failing tests.** |
| + |
| +```go |
| +// browser place: owner POST /a/{sqid}/apps/{app}/place form target=fleet/pi → |
| +// 303 back to instances tab, flash contains "placed"; registry route Region |
| +// updated; instance record region updated (fake release store fixture) |
| +// member (non-owner) POST → the owner gate's usual refusal |
| +// bad csrf → the standard csrf refusal |
| +// target naming another account's fleet → 404 byte-identical to unknown |
| +// clearing: target "" → routes cleared, flash "back on the shared fleet" |
| +// fleet-page form: POST /a/{sqid}/fleets/{fleet}/place app=<name> → same effects, |
| +// redirect back to fleet page |
| +// API regression: the existing handleAPIPlace tests all still pass UNTOUCHED |
| +``` |
| + |
| +- [ ] **Step 2: Verify failure.** |
| +- [ ] **Step 3: Refactor + implement.** Mechanical extraction: everything in `handleAPIPlace` from the `s.cfg.Releases == nil` check through the record-stamping loop becomes `placeAppTarget`; the API handler keeps auth, rate-limit, JSON decode, audit(`"(cli)"`), and translates `(status, errMsg)` to `jsonError`. **Move the comments with the code** — the ORDER MATTERS block guards the core, not the handler. Browser handlers: |
| + - `handleAppPlace`: gate like the other app POSTs (`s.owner` + `s.accountApp` + CSRF — copy `handleAppRename`'s skeleton), read `target` form value, call core, audit detail suffix `"(console)"`, redirect `{{.Nav}}/apps/{app}/instances` with flash. |
| + - `handleFleetPlacePage`: `ownerFleet` gate, `app` form value, target = `registry.PlacementTarget(fleet.Name)`, same core, redirect to fleet page. |
| +- App Instances tab (find `handleAppInstances`'s assembly in server.go): add `AppPlacement` (via `appPlacement`) and `OwnedFleetNames` (ListFleets filtered by account). Template section (owner-only forms, member sees state): |
| + |
| +```html |
| +<h2>Placement</h2> |
| +<p>{{if .AppPlacement}}This app runs on your fleet <a href="{{.Nav}}/fleets/{{fleetName .AppPlacement}}"><code>{{fleetName .AppPlacement}}</code></a>.{{else}}This app runs on the shared fleet.{{end}}</p> |
| +{{if and .IsOwner .OwnedFleetNames}} |
| +<form method="post" action="{{.Base}}/apps/{{.AppName}}/place"> |
| + <input type="hidden" name="csrf" value="{{.CSRF}}"> |
| + <select name="target"> |
| + <option value=""{{if not .AppPlacement}} selected{{end}}>Shared fleet</option> |
| + {{range .OwnedFleetNames}}<option value="fleet/{{.}}"{{if eq (printf "fleet/%s" .) $.AppPlacement}} selected{{end}}>fleet/{{.}}</option>{{end}} |
| + </select> |
| + <button type="submit" class="btn-sm">Move</button> |
| +</form> |
| +<p class="small">Moving changes where this app <em>serves</em>. Data does not move: |
| +hibernated instance state stays in the old placement's bucket.</p> |
| +{{end}} |
| +``` |
| + |
| +(`fleetName` FuncMap helper = `func(target string) string { f, _ := registry.FleetPlacement(target); return f }` — add beside `join`.) Fleet page form (below Placed apps): select over `FleetPlaceableApps` (account's apps not already on this fleet — derive in `loadFleetPage`'s caller from the same route walk) + Move button + the same data-does-not-move line. |
| +- [ ] **Step 4: Gate** — the pre-existing API place tests passing unmodified is the refactor's proof. |
| +- [ ] **Step 5: Commit** — `git commit -m "console: placement moves from the app Instances tab and fleet page (D4/D5/D6)"` |
| + |
| +--- |
| + |
| +### Task 7: Edge — fleet channels ping fast (D7) |
| + |
| +**Files:** |
| +- Modify: `internal/edge/tunnel.go` (constants ~L46; `tunnelConn` ~L53; `ServeHTTP` auth branch ~L129; `pingLoop` ~L201) |
| +- Test: `internal/edge/` beside the existing tunnel tests (grep `pingLoop`/`SetDynamicAuth` tests) |
| + |
| +**Interfaces:** |
| +- Consumes: nothing from other tasks (independent; can run in parallel with 2-6). |
| +- Produces: `fleetPingEvery = 5s`, `fleetPingTimeout = 5s`; dynamic-auth'd conns ping at fleet cadence; static conns provably unchanged. |
| + |
| +- [ ] **Step 1: Failing tests.** |
| + |
| +```go |
| +func TestPingCadence(t *testing.T) { |
| + if e, to := pingCadence(false); e != tunnelPingEvery || to != tunnelPingTimeout { |
| + t.Fatal("static cadence changed") |
| + } |
| + if e, to := pingCadence(true); e != fleetPingEvery || to != fleetPingTimeout { |
| + t.Fatal("fleet cadence wrong") |
| + } |
| +} |
| + |
| +func TestDynamicChannelMarkedDynamic(t *testing.T) { |
| + // register via the dynamic-auth path (copy the SetDynamicAuth round-trip |
| + // test's arrange: hub + SetDynamicAuth{"pi/box-a": sha256hex(tok)} + a |
| + // real handshake through httptest — the existing rotate/detach tests |
| + // show how); then assert h.conns["pi/box-a"].dynamic == true, and a |
| + // static-auth'd channel's conn has dynamic == false. |
| +} |
| +``` |
| + |
| +- [ ] **Step 2: Verify failure** (`pingCadence` undefined). |
| +- [ ] **Step 3: Implement.** |
| + |
| +```go |
| +// Fleet (dynamic) channels ping tighter (fleets-console spec D7): a customer |
| +// fleet's failover is "requests fall back to a surviving box once the dead |
| +// channel is reaped", so the reap bound IS the user-visible outage window — |
| +// ≤10s (one 5s sleep + one 5s deadline) instead of the static channels' |
| +// ~25s. Static platform channels (tito hq) keep the wider cadence: their |
| +// routes have no fallback box, so a tighter ping only adds churn risk. |
| +fleetPingEvery = 5 * time.Second |
| +fleetPingTimeout = 5 * time.Second |
| + |
| +func pingCadence(dynamic bool) (every, timeout time.Duration) { ... } |
| +``` |
| + |
| +`tunnelConn` gains `dynamic bool`. In `ServeHTTP`, hoist a `viaDynamic bool`, set it `true` inside the dynamic-auth fallback branch when its compare sets `authOK`; store it on the conn (`h.conns[name] = &tunnelConn{cc: cc, raw: conn, dynamic: viaDynamic}`) and pass to `go h.pingLoop(name, cc, conn, viaDynamic)`; `pingLoop` uses `pingCadence(dynamic)` for both the sleep and the context deadline. |
| +- [ ] **Step 4: Gate** — `go test ./internal/edge/ -count=1 -race` (race mode: the edge suite runs it in CI) then the full suite. |
| +- [ ] **Step 5: Commit** — `git commit -m "edge: fleet channels ping at 5s/5s — reap bound is the failover window (D7)"` |
| + |
| +--- |
| + |
| +### Task 8: Whole-branch gate, docs touch-ups, PR |
| + |
| +**Files:** |
| +- Modify: `infra/dev/region-pi.md` (add a pointer: tags + console Fleet tab exist; BYO page is the canonical box-add instructions now), spec status line (Draft → notes what shipped) |
| +- No code. |
| + |
| +- [ ] **Step 1:** Full gate: `go build ./... && go vet ./... && go test ./... -count=1` all packages; `gofmt -l` on every touched file; `GOOS=darwin go build ./...` (CI runs it; `internal/host` links into the CLI). |
| +- [ ] **Step 2:** Docs touch-ups above; verify no template references a helper that isn't registered (server tests cover, but `grep -n "join\|fleetName" internal/console/server.go templates` as a belt). |
| +- [ ] **Step 3:** Commit docs; push branch; open PR **against `worktree-customer-fleets`** (stacked — the diff must show only this plan's work) titled "Fleets console v2: the Fleet tab, tags, placement moves, fleet fast-ping". Body: spec link, D1-D8 summary, "merges after #63; do not deploy" note. |
| + |
| +--- |
| + |
| +## Self-review notes (run before execution) |
| + |
| +- Spec coverage: D1/D2 → Task 4; D3 → Tasks 1-3 (+ pools recipe lands in Task 5's step 4? NO — pools recipe is ops docs, folded into Task 5's BYO page? Also no — spec says ops docs; it lives in the Task 8 region-pi.md touch-up and the spec itself. Covered, no code owed); D4/D5/D6 → Task 6; D7 → Task 7; D8 → Task 5. Shared-card site-name variant: deliberately NOT implemented (PR #72 unmerged) — spec says "when present"; today's title is the constant. |
| +- Type consistency: `sharedFleetCard`/`accountFleetCard` defined Task 4, consumed only there + template; `placeAppTarget` defined and consumed in Task 6; `ValidBoxTags`/`SetFleetBoxTags`/`FleetBox.Tags` defined Task 1, consumed Tasks 2/3. |
| +- Parallelism: Task 7 is independent; Tasks 2 and 3 depend on 1; Task 5 depends on 4; Task 6 is independent of 1-5 (template merge conflicts with 4 are trivial — different sections). |