From e425555bd51111b1f9588f2ce0256d54be1fed2c Mon Sep 17 00:00:00 2001 From: jitokim Date: Fri, 31 Jul 2026 09:13:04 +0900 Subject: [PATCH] feat(ports): surface listening ports per session in the fleet view MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The captain runs many worktrees with make local PORT=xxxx e2e servers and loses track of which port belongs to which session. Surface the observed listening TCP ports on each loop, per pivot.md ยง11: - claude.ListeningPortsByCwd: two bounded lsof probes (system-wide TCP listeners, then those pids' cwds), same procTimeout discipline and p/n record parsing as LiveClaudeCwds - claude.applyPorts: enrich after applyLiveness, attaching ports only to CwdVerified loops by exact real-path match โ€” probe failure attaches nothing, ambiguity attaches nothing, State/Stall never touched - probe is gated: skipped entirely when no loop has a verified cwd - FLEET row: optional dim ๐ŸŒ:3000 tag column (accountTag pattern, shown only when >=1 visible loop has ports; drops before ACCOUNT in the width cascade); DETAIL panel: PORTS row under CWD with every port - demo fleet: flaky-tests carries an observed port so --demo shows it Pure observation, display-only: no actuation, no health claims โ€” a port present means exactly "a listening socket was observed in this loop's verified cwd", nothing more. Co-Authored-By: Claude Fable 5 Signed-off-by: jitokim --- internal/claude/procs.go | 151 +++++++++++++++++++++++ internal/claude/procs_test.go | 100 +++++++++++++++ internal/claude/scan.go | 61 +++++++++ internal/claude/scan_test.go | 86 +++++++++++++ internal/domain/loop.go | 19 ++- internal/tui/model.go | 126 +++++++++++++++++-- internal/tui/model_test.go | 226 ++++++++++++++++++++++++++++++++-- 7 files changed, 742 insertions(+), 27 deletions(-) diff --git a/internal/claude/procs.go b/internal/claude/procs.go index a344af2..1cefb10 100644 --- a/internal/claude/procs.go +++ b/internal/claude/procs.go @@ -4,6 +4,8 @@ import ( "context" "os/exec" "path/filepath" + "slices" + "sort" "strconv" "strings" "time" @@ -101,6 +103,155 @@ func matchesClaudeComm(comm string) bool { return name == "claude" } +// ListeningPortsByCwd returns real (unencoded) cwd โ†’ sorted distinct TCP +// ports with a listening socket held by a process working there โ€” the +// captain's `make local PORT=xxxx` e2e server running as a sibling shell in +// a worktree, found the same way LiveClaudeCwds finds claude processes: two +// bounded lsof probes (system-wide listeners, then those pids' cwds), each +// under procTimeout so a wedged process table never hangs the TUI's refresh. +// +// ok=false means a probe itself failed โ€” the caller MUST NOT treat that as +// "no servers": a racing lsof is not evidence a server died, so on failure +// nothing may be attached OR cleared based on this result. (lsof exits +// non-zero both on real failure and when zero sockets match the filter; +// they're indistinguishable here, and both correctly attach nothing.) +func ListeningPortsByCwd() (map[string][]int, bool) { + ctx, cancel := context.WithTimeout(context.Background(), procTimeout) + defer cancel() + out, err := exec.CommandContext(ctx, "lsof", "-nP", "-iTCP", "-sTCP:LISTEN", "-Fpn").Output() + if err != nil { + return map[string][]int{}, false + } + portsByPid := parseLsofListenPorts(string(out)) + if len(portsByPid) == 0 { + return map[string][]int{}, true // probe succeeded; genuinely zero listeners + } + pidStrs := make([]string, 0, len(portsByPid)) + for pid := range portsByPid { + pidStrs = append(pidStrs, strconv.Itoa(pid)) + } + ctx2, cancel2 := context.WithTimeout(context.Background(), procTimeout) + defer cancel2() + cwdOut, err := exec.CommandContext(ctx2, "lsof", "-a", "-p", strings.Join(pidStrs, ","), "-d", "cwd", "-Fn").Output() + if err != nil { + return map[string][]int{}, false + } + return joinPortsByCwd(portsByPid, parseLsofPidCwds(string(cwdOut))), true +} + +// parseLsofListenPorts parses `lsof -nP -iTCP -sTCP:LISTEN -Fpn` output โ€” +// interleaved "p"/"f"/"n" lines, one "n" per listening +// socket โ€” into pid โ†’ sorted distinct ports. One server commonly listens +// twice per port (IPv4 "*:3000" + IPv6 "[::]:3000"), so ports dedup per +// pid. An addr whose port doesn't parse (e.g. "*:*") attaches nothing โ€” +// never a guessed port โ€” and an unparseable "p" line orphans the "n" lines +// under it rather than crediting them to the previous pid. +func parseLsofListenPorts(out string) map[int][]int { + ports := make(map[int][]int) + pid := -1 + for _, line := range strings.Split(out, "\n") { + if len(line) < 2 { + continue + } + switch line[0] { + case 'p': + id, err := strconv.Atoi(line[1:]) + if err != nil { + pid = -1 + continue + } + pid = id + case 'n': + if pid < 0 { + continue + } + port, ok := listenAddrPort(line[1:]) + if !ok { + continue + } + if !slices.Contains(ports[pid], port) { + ports[pid] = append(ports[pid], port) + } + } + } + for _, ps := range ports { + sort.Ints(ps) + } + return ports +} + +// listenAddrPort extracts the port from an lsof listen-socket name field โ€” +// "*:3000", "127.0.0.1:3000", "[::1]:3000" โ€” the text after the LAST colon +// (IPv6 addrs contain colons of their own). ok=false for anything that +// isn't a valid port number. +func listenAddrPort(addr string) (int, bool) { + idx := strings.LastIndexByte(addr, ':') + if idx < 0 { + return 0, false + } + port, err := strconv.Atoi(addr[idx+1:]) + if err != nil || port < 1 || port > 65535 { + return 0, false + } + return port, true +} + +// parseLsofPidCwds parses `lsof -a -p -d cwd -Fn` output into +// pid โ†’ cwd path โ€” the same record shape parseLsofCwds reads, but keyed by +// pid instead of counted, because the ports join needs to know WHICH +// process's cwd each listener has. An unparseable "p" line orphans the "n" +// under it (never credited to the previous pid), same as +// parseLsofListenPorts. +func parseLsofPidCwds(out string) map[int]string { + cwds := make(map[int]string) + pid := -1 + for _, line := range strings.Split(out, "\n") { + if len(line) < 2 { + continue + } + switch line[0] { + case 'p': + id, err := strconv.Atoi(line[1:]) + if err != nil { + pid = -1 + continue + } + pid = id + case 'n': + if pid < 0 { + continue + } + cwds[pid] = line[1:] + } + } + return cwds +} + +// joinPortsByCwd joins the two probes: listener pids' ports, keyed by those +// pids' real cwd paths. A listener whose pid has no cwd record (process +// exited between the two probes, or lsof couldn't resolve it) contributes +// nothing โ€” an unattributable port is never attached anywhere. Ports dedup +// per cwd (two servers in one dir can share a port across restarts' TIME_WAIT +// races, and a dir can host several pids listening on the same port family). +func joinPortsByCwd(portsByPid map[int][]int, cwdByPid map[int]string) map[string][]int { + byCwd := make(map[string][]int) + for pid, ports := range portsByPid { + cwd, ok := cwdByPid[pid] + if !ok || cwd == "" { + continue + } + for _, p := range ports { + if !slices.Contains(byCwd[cwd], p) { + byCwd[cwd] = append(byCwd[cwd], p) + } + } + } + for _, ps := range byCwd { + sort.Ints(ps) + } + return byCwd +} + // parseLsofCwds parses `lsof -a -p -d cwd -Fn` output: interleaved // "p"/"f"/"n" lines, one "n" per live process (the // path of its "cwd" fd) โ€” counting how many processes share each cwd. diff --git a/internal/claude/procs_test.go b/internal/claude/procs_test.go index bd49417..75330ad 100644 --- a/internal/claude/procs_test.go +++ b/internal/claude/procs_test.go @@ -106,3 +106,103 @@ func TestParseLsofCwds_IgnoresNonNLines(t *testing.T) { t.Errorf("got %+v, want {/tmp/x: 1}", counts) } } + +func TestParseLsofListenPorts(t *testing.T) { + // real captured shape of `lsof -nP -iTCP -sTCP:LISTEN -Fpn`: p/f/n + // records, one n per listening socket. pid 100 listens on 3000 twice + // (IPv4 "*:3000" + IPv6 "[::]:3000" โ€” the common dual-stack server, + // must dedup to ONE 3000) plus 8080; pid 200 on a loopback addr. + out := "p100\nf23\nn*:3000\nf24\nn[::]:3000\nf25\nn*:8080\n" + + "p200\nf10\nn127.0.0.1:5173\n" + + ports := parseLsofListenPorts(out) + if len(ports) != 2 { + t.Fatalf("got %d pids, want 2: %+v", len(ports), ports) + } + if len(ports[100]) != 2 || ports[100][0] != 3000 || ports[100][1] != 8080 { + t.Errorf("ports[100] = %v, want [3000 8080] (dual-stack 3000 deduped, sorted)", ports[100]) + } + if len(ports[200]) != 1 || ports[200][0] != 5173 { + t.Errorf("ports[200] = %v, want [5173]", ports[200]) + } +} + +func TestParseLsofListenPorts_UnparseableAddrAttachesNothing(t *testing.T) { + // an addr with no valid port ("*:*", garbage) must attach nothing โ€” + // never a guessed port. Honesty rule: ambiguous โ†’ nothing. + out := "p100\nf23\nn*:*\nf24\nngarbage\n" + if ports := parseLsofListenPorts(out); len(ports) != 0 { + t.Errorf("got %+v, want empty โ€” unparseable addrs must not attach", ports) + } +} + +func TestParseLsofListenPorts_BadPidLineOrphansItsRecords(t *testing.T) { + // an unparseable "p" line must orphan the n-lines under it, NOT credit + // them to the previous pid. + out := "p100\nf23\nn*:3000\npNOPE\nf24\nn*:9999\n" + ports := parseLsofListenPorts(out) + if len(ports) != 1 || len(ports[100]) != 1 || ports[100][0] != 3000 { + t.Errorf("got %+v, want {100: [3000]} โ€” 9999 belongs to an unparseable pid", ports) + } +} + +func TestParseLsofListenPorts_Empty(t *testing.T) { + if ports := parseLsofListenPorts(""); len(ports) != 0 { + t.Errorf("got %+v, want empty", ports) + } +} + +func TestListenAddrPort(t *testing.T) { + cases := []struct { + addr string + port int + ok bool + }{ + {"*:3000", 3000, true}, + {"127.0.0.1:8080", 8080, true}, + {"[::1]:5173", 5173, true}, // port is after the LAST colon โ€” IPv6 addrs contain their own + {"[::]:80", 80, true}, + {"*:*", 0, false}, + {"no-colon", 0, false}, + {"*:0", 0, false}, // 0 is not a real listening port + {"*:99999", 0, false}, // out of range + {"", 0, false}, + } + for _, c := range cases { + port, ok := listenAddrPort(c.addr) + if port != c.port || ok != c.ok { + t.Errorf("listenAddrPort(%q) = (%d, %v), want (%d, %v)", c.addr, port, ok, c.port, c.ok) + } + } +} + +func TestParseLsofPidCwds(t *testing.T) { + // same record shape parseLsofCwds reads, but keyed by pid for the + // ports join. + out := "p100\nfcwd\nn/x/worktree-a\np200\nfcwd\nn/x/worktree-b\n" + cwds := parseLsofPidCwds(out) + if len(cwds) != 2 || cwds[100] != "/x/worktree-a" || cwds[200] != "/x/worktree-b" { + t.Errorf("got %+v, want {100: /x/worktree-a, 200: /x/worktree-b}", cwds) + } +} + +func TestJoinPortsByCwd(t *testing.T) { + portsByPid := map[int][]int{ + 100: {3000}, + 200: {8080}, + 300: {9999}, // no cwd record (exited between probes) โ€” must attach nowhere + } + cwdByPid := map[int]string{ + 100: "/x/worktree-a", + 200: "/x/worktree-a", // second server in the same dir โ€” ports merge + } + + byCwd := joinPortsByCwd(portsByPid, cwdByPid) + if len(byCwd) != 1 { + t.Fatalf("got %d cwds, want 1: %+v", len(byCwd), byCwd) + } + got := byCwd["/x/worktree-a"] + if len(got) != 2 || got[0] != 3000 || got[1] != 8080 { + t.Errorf("got %v, want [3000 8080] (merged across pids, sorted)", got) + } +} diff --git a/internal/claude/scan.go b/internal/claude/scan.go index 51d306c..b2ff3fd 100644 --- a/internal/claude/scan.go +++ b/internal/claude/scan.go @@ -166,6 +166,16 @@ func DiscoverLoops(now time.Time, within time.Duration) ([]domain.Loop, error) { live, liveOK := LiveClaudeCwds() loops = applyLiveness(loops, live, liveOK, historyDir, now, within) + // PORTS surfacing (pivot ยง11): attach observed listening TCP ports to + // loops whose cwd applyLiveness just verified live. Gated โ€” the probe + // (2 more lsof calls) only runs when at least one loop is CwdVerified, + // since applyPorts can attach to no one else anyway; a fleet with no + // live process pays nothing new. + if anyCwdVerified(loops) { + ports, portsOK := ListeningPortsByCwd() + loops = applyPorts(loops, ports, portsOK) + } + // Keep metricsCache bounded to sessions actually present in this scan โ€” // otherwise it grows forever as old sessions age out of the window or // get deleted, over a long-running fleetops process. @@ -612,6 +622,57 @@ func applyLiveness(loops []domain.Loop, live map[string]int, ok bool, historyDir return out } +// anyCwdVerified reports whether at least one loop has a live-process- +// verified cwd โ€” the gate for the ports probe in DiscoverLoops: applyPorts +// attaches to CwdVerified loops only, so with none the 2 extra lsof calls +// would be pure cost for a guaranteed no-op. +func anyCwdVerified(loops []domain.Loop) bool { + for _, l := range loops { + if l.CwdVerified { + return true + } + } + return false +} + +// applyPorts attaches observed listening TCP ports (ListeningPortsByCwd) to +// each loop whose cwd hosts a listener โ€” the PORTS surfacing enrichment +// (pivot ยง11), run right after applyLiveness because it needs the healed +// real cwd. Display-only metadata: State/Stall are never touched here, and +// a port's disappearance is never a StallKind. +// +// Honesty rules, mirroring applyLiveness's own discipline: +// - ok=false (a probe failed) attaches nothing โ€” a racing lsof is not +// evidence about any server, and Ports stays nil ("nothing observed"), +// which renders as nothing rather than as a claim. +// - only CwdVerified loops can match, and the match is EXACT string +// equality between the listener's real lsof cwd and the loop's healed +// real Cwd (both come from the same lsof -d cwd probe shape, so equal +// directories yield equal strings). Deliberately NOT the encodeCwd +// round-trip applyLiveness uses for its own matching: encodeCwd is +// many-to-one ("/x/foo-bar" vs "/x/foo.bar"), and matching through it +// could attach one dir's server to the other dir's loop โ€” exactly the +// ambiguity CwdVerified's collision guard exists to refuse. Verified +// loops have the real path, so the lossless comparison is available and +// exact-only (a server in a SUBdirectory of a loop's cwd attaches +// nowhere โ€” v1, per ยง11: `make local` runs at the worktree root). +// - a collided ProjectDir needs no second guard here: applyLiveness +// already leaves its loops CwdVerified=false. +func applyPorts(loops []domain.Loop, portsByCwd map[string][]int, ok bool) []domain.Loop { + if !ok { + return loops + } + for i := range loops { + if !loops[i].CwdVerified { + continue + } + if ports := portsByCwd[loops[i].Cwd]; len(ports) > 0 { + loops[i].Ports = ports + } + } + return loops +} + // mostRecentActuationIsKill reports whether sessionID's most recent // actor=human actuation event within `within` of now was a kill that was // CONFIRMED DISPATCHED. diff --git a/internal/claude/scan_test.go b/internal/claude/scan_test.go index 8e9c948..ffa849b 100644 --- a/internal/claude/scan_test.go +++ b/internal/claude/scan_test.go @@ -2913,3 +2913,89 @@ func TestOutstandingBackgroundWork_ForegroundOutstanding_StillFalse(t *testing.T t.Error("a foreground launch must not count as outstanding BACKGROUND work") } } + +// โ”€โ”€ PORTS surfacing (pivot ยง11): applyPorts / anyCwdVerified โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +func TestApplyPorts_VerifiedExactMatch_Attaches(t *testing.T) { + loops := []domain.Loop{ + {SessionID: "s1", ProjectDir: "-x-web", Cwd: "/x/web", CwdVerified: true, State: domain.StateRunning}, + } + + out := applyPorts(loops, map[string][]int{"/x/web": {3000, 8080}}, true) + + if len(out[0].Ports) != 2 || out[0].Ports[0] != 3000 || out[0].Ports[1] != 8080 { + t.Errorf("Ports = %v, want [3000 8080]", out[0].Ports) + } +} + +func TestApplyPorts_ProbeFailed_AttachesNothing(t *testing.T) { + // honesty rule 1: a failed probe is not evidence โ€” nothing may be + // attached (or claimed) on no data, even for a loop that WOULD match. + loops := []domain.Loop{ + {SessionID: "s1", Cwd: "/x/web", CwdVerified: true, State: domain.StateRunning}, + } + + out := applyPorts(loops, map[string][]int{"/x/web": {3000}}, false) + + if out[0].Ports != nil { + t.Errorf("Ports = %v, want nil โ€” probe failure must attach nothing", out[0].Ports) + } +} + +func TestApplyPorts_UnverifiedCwd_NeverAttaches(t *testing.T) { + // honesty rule 4: only a live-process-verified cwd may match โ€” an + // unverified Cwd is a LOSSY decode, and attaching on it could pin + // another directory's server to this loop. + loops := []domain.Loop{ + {SessionID: "s1", Cwd: "/x/web", CwdVerified: false, State: domain.StateIdle}, + } + + out := applyPorts(loops, map[string][]int{"/x/web": {3000}}, true) + + if out[0].Ports != nil { + t.Errorf("Ports = %v, want nil โ€” unverified cwd must never attach", out[0].Ports) + } +} + +func TestApplyPorts_SubdirectoryListener_DoesNotAttach(t *testing.T) { + // exact match only (v1): a server in a SUBdirectory of the loop's cwd + // attaches nowhere โ€” prefix matching is deliberately not done. + loops := []domain.Loop{ + {SessionID: "s1", Cwd: "/x/web", CwdVerified: true, State: domain.StateRunning}, + } + + out := applyPorts(loops, map[string][]int{"/x/web/e2e": {4000}}, true) + + if out[0].Ports != nil { + t.Errorf("Ports = %v, want nil โ€” subdirectory listener must not attach to the parent", out[0].Ports) + } +} + +func TestApplyPorts_StateNeverTouched(t *testing.T) { + // display-only metadata: ports enrichment must never reclassify a loop + // โ€” a port's presence (or disappearance) is not a StallKind. + loops := []domain.Loop{ + {SessionID: "s1", Cwd: "/x/web", CwdVerified: true, State: domain.StateStalled, Stall: domain.StallNoOutput}, + } + + out := applyPorts(loops, map[string][]int{"/x/web": {3000}}, true) + + if out[0].State != domain.StateStalled || out[0].Stall != domain.StallNoOutput { + t.Errorf("State/Stall = %v/%v, want untouched StateStalled/StallNoOutput", out[0].State, out[0].Stall) + } + if len(out[0].Ports) != 1 || out[0].Ports[0] != 3000 { + t.Errorf("Ports = %v, want [3000]", out[0].Ports) + } +} + +func TestAnyCwdVerified(t *testing.T) { + if anyCwdVerified([]domain.Loop{{CwdVerified: false}, {CwdVerified: false}}) { + t.Error("got true, want false โ€” no loop is verified") + } + if !anyCwdVerified([]domain.Loop{{CwdVerified: false}, {CwdVerified: true}}) { + t.Error("got false, want true โ€” one verified loop is enough") + } + if anyCwdVerified(nil) { + t.Error("got true, want false for an empty fleet") + } +} diff --git a/internal/domain/loop.go b/internal/domain/loop.go index 69a27cd..b910ca2 100644 --- a/internal/domain/loop.go +++ b/internal/domain/loop.go @@ -106,11 +106,20 @@ type Loop struct { // prompt, deliberately: its choices are always some form of yes/no, so the // information a human (or an agent) actually needs there is WHAT is being // asked, which GatePrompt carries. Display-only; nothing selects from this. - GateOptions []string - Project string // decoded project label (e.g. "myproject") - ProjectDir string // raw encoded project dir name, e.g. "-home-user-myproject" - Cwd string // best-effort decoded absolute cwd, for display only โ€” see CwdVerified - CwdVerified bool // true once Cwd was confirmed against a live process's real lsof path (not a lossy decode); gates the spawn wizard's explicit [s] use-this-loop's-dir choice (see tui's wizardWhere) + GateOptions []string + Project string // decoded project label (e.g. "myproject") + ProjectDir string // raw encoded project dir name, e.g. "-home-user-myproject" + Cwd string // best-effort decoded absolute cwd, for display only โ€” see CwdVerified + CwdVerified bool // true once Cwd was confirmed against a live process's real lsof path (not a lossy decode); gates the spawn wizard's explicit [s] use-this-loop's-dir choice (see tui's wizardWhere) + // Ports are the listening TCP ports observed (one bounded lsof pass, see + // claude.ListeningPortsByCwd/applyPorts) for processes whose cwd is this + // loop's VERIFIED Cwd โ€” the captain's `make local PORT=xxxx` e2e server + // running as a sibling shell in the same worktree. Display-only metadata, + // only ever attached when CwdVerified is true. nil means "no port + // OBSERVED" (probe skipped/failed, or genuinely nothing listening) โ€” + // never "the server is down"; and a present port claims exactly "a + // listening socket was observed there", never "healthy"/"e2e ready". + Ports []int SessionID string // Claude Code session id Path string // path to the session JSONL LastActivity time.Time // last log write diff --git a/internal/tui/model.go b/internal/tui/model.go index 1977ff6..e846344 100644 --- a/internal/tui/model.go +++ b/internal/tui/model.go @@ -789,7 +789,9 @@ func NewDemo() Model { // // The fixture deliberately covers every renderable shape this file knows // about, in one small fleet: a GATE (permission prompt), a plain RUNNING -// loop, a RUNNING loop DELEGATING to a live subagent (v0.9.0 subagent +// loop (flaky-tests โ€” also the loop with an observed listening port, so the +// FLEET PORT column and DETAIL PORTS row render, feat/ports-surfacing), a +// RUNNING loop DELEGATING to a live subagent (v0.9.0 subagent // visibility โ€” flock-audit, its TAIL row carrying the exact "delegating: // โ€” " string applySubagentDelegation produces), a // DRIFT (oracle-rejected) loop, two unbound/observed loops (no goal at all @@ -828,15 +830,23 @@ func demoFleet() (loops []domain.Loop, detailCache map[string]detailCacheEntry, TokensSpent: 640000, LastActivity: now.Add(-40 * time.Second), } + // feat/ports-surfacing: the one loop with an observed listening port โ€” + // a dev server running in its worktree โ€” so the FLEET PORT column and + // the DETAIL PORTS row have something real to show. CwdVerified + // accompanies it because the real pipeline only ever attaches ports to + // a verified cwd (see claude.applyPorts) โ€” a demo loop with ports but + // an unverified cwd would be a state the product cannot produce. flakyTests := domain.Loop{ Project: "flaky-tests", SessionID: "demo-flaky-tests", ProjectDir: "-home-user-web", Cwd: "/home/user/web", + CwdVerified: true, Path: "/home/user/.claude/projects/-home-user-web/demo-flaky-tests.jsonl", State: domain.StateRunning, Cycle: 4, Goal: domain.Goal{Text: "fix flaky tests", MaxCycles: 12}, + Ports: []int{3000}, LastActivity: now.Add(-3 * time.Second), } // v0.9.0 subagent visibility: a RUNNING loop delegating to a live child. @@ -5016,6 +5026,18 @@ const ( // the fixed-width panel. See listRowWidths for where it drops in the // width cascade. wAccount = 12 + // wPort (feat/ports-surfacing): the FLEET row's optional listening-port + // column, shown ONLY when >=1 visible loop has observed ports (see + // fleetHasPorts) โ€” a fleet with no live e2e server never requests it, + // keeping those rows byte-identical to before this column existed. The + // tag renders as "๐ŸŒ:3000" ("+N" appended for extra ports when it fits + // โ€” see portTag); 10 fits the widest single port "๐ŸŒ:65535" (8 cols, ๐ŸŒ + // is 2) with a column gap, and is deliberately this lean so the column + // SURVIVES renderWide's capped FLEET panel (wideLeftCap 72 โ†’ inner 70) + // next to the ACCOUNT tag: at wPort 12 the readability cascade had to + // shed it there, making the feature invisible in exactly the layout the + // captain runs. See listRowWidths for where it drops in the cascade. + wPort = 10 ) // nameFloorWidth/nameCapWidth bound the FLEET panel's NAME column: below the @@ -5352,8 +5374,17 @@ func fleetOracleCountsCmd(loops []domain.Loop) tea.Cmd { // column). When wantAccount is false showAccount starts false and never // enters the width math, so a single-/zero-account fleet's widths โ€” and the // three lines below โ€” are byte-identical to before this column existed. -func listRowWidths(innerWidth int, wantAccount bool) (wName int, showAccount, showCycle, showOracle, showLast bool) { - showAccount, showCycle, showOracle, showLast = wantAccount, true, true, true +// +// feat/ports-surfacing: the PORT tag column follows the ACCOUNT pattern +// exactly โ€” requested by the caller (wantPort โ€” true only when >=1 visible +// loop has observed ports, see fleetHasPorts), never by width alone, and +// showPort never enters the width math when unrequested (a no-server fleet +// keeps byte-identical rows). In the cascade it drops BEFORE ACCOUNT: +// port is a convenience glance ("which port is this worktree's server on"), +// while the account tag disambiguates whose loop a row even is โ€” identity +// beats convenience when width runs out. +func listRowWidths(innerWidth int, wantAccount, wantPort bool) (wName int, showAccount, showPort, showCycle, showOracle, showLast bool) { + showAccount, showPort, showCycle, showOracle, showLast = wantAccount, wantPort, true, true, true name := func() int { fixed := wMarker + wState if showLast { @@ -5368,17 +5399,23 @@ func listRowWidths(innerWidth int, wantAccount bool) (wName int, showAccount, sh if showAccount { fixed += wAccount } + if showPort { + fixed += wPort + } return innerWidth - fixed } - // drop right-to-priority: ORACLE first, then CYCLE, then ACCOUNT (all - // readability-floor gated), then LAST (physical floor) โ€” see this - // function's own doc for why this specific order. + // drop right-to-priority: ORACLE first, then CYCLE, then PORT, then + // ACCOUNT (all readability-floor gated), then LAST (physical floor) โ€” + // see this function's own doc for why this specific order. if name() < nameGoodWidth { showOracle = false } if name() < nameGoodWidth { showCycle = false } + if name() < nameGoodWidth { + showPort = false + } if name() < nameGoodWidth { showAccount = false } @@ -5392,7 +5429,7 @@ func listRowWidths(innerWidth int, wantAccount bool) (wName int, showAccount, sh if wName > nameCapWidth { wName = nameCapWidth } - return wName, showAccount, showCycle, showOracle, showLast + return wName, showAccount, showPort, showCycle, showOracle, showLast } // renderListRow renders one FLEET panel row: marker+NAME+STATE[+CYCLE] @@ -5402,7 +5439,7 @@ func listRowWidths(innerWidth int, wantAccount bool) (wName int, showAccount, sh // (Model.fleetOracleCounts, looked up by the caller โ€” this function does // no I/O of its own, same discipline as everything else in this file's // render path). -func renderListRow(l domain.Loop, sel, dup bool, wName int, showAccount, showCycle, showOracle, showLast bool, oracleCount int, totalWidth int) string { +func renderListRow(l domain.Loop, sel, dup bool, wName int, showAccount, showPort, showCycle, showOracle, showLast bool, oracleCount int, totalWidth int) string { // feat/engine-provenance: wMarker is 2 cols wide, but the cursor glyph // below only ever occupies 1 of them โ€” the second was always blank // padding. Rather than widen the row for a Driven marker, that @@ -5438,6 +5475,14 @@ func renderListRow(l domain.Loop, sel, dup bool, wName int, showAccount, showCyc if showAccount { cells = append(cells, stDim.Width(wAccount).Render(accountTag(l.Account.Label()))) } + // feat/ports-surfacing: observed listening ports, dim like the other + // metadata columns โ€” a pure observation ("a listening socket exists in + // this loop's verified cwd"), not a health claim. A loop with no + // observed ports renders a blank cell of width wPort, aligned, so the + // columns to its right stay lined up across rows. + if showPort { + cells = append(cells, stDim.Width(wPort).Render(portTag(l.Ports))) + } if showCycle { cells = append(cells, stDim.Width(wCycle).Render(cycleLabel(l))) } @@ -5516,6 +5561,59 @@ func multiAccountFleet(loops []domain.Loop) bool { return false } +// portTag is the FLEET panel's PORT column value: the loop's first observed +// listening port as "๐ŸŒ:3000", with a "+N" suffix when more ports were +// observed in the same cwd (the full list lives in the DETAIL panel's PORTS +// row), or "" for a loop with no observed ports, which renders as a blank +// cell of width wPort so the columns to its right stay aligned across rows. +// When "+N" would overflow wPort-1 (a 5-digit port with extras) the SUFFIX +// is dropped, never trunc'd: an ellipsis mid-number would display a port +// that doesn't exist, and a wrong port is worse than an omitted "more" +// hint. The bare port always fits by construction (๐ŸŒ 2 + ":65535" 6 = +// 8 <= wPort-1). Pure observation โ€” a listening socket seen in the loop's +// verified cwd, no health/readiness claim (see domain.Loop.Ports). The +// >=1-loop-has-ports gate that decides whether this column shows at all +// lives in the caller (fleetHasPorts). +func portTag(ports []int) string { + if len(ports) == 0 { + return "" + } + tag := "๐ŸŒ:" + strconv.Itoa(ports[0]) + if len(ports) > 1 { + if withMore := tag + "+" + strconv.Itoa(len(ports)-1); narrowAmbiguous.StringWidth(withMore) <= wPort-1 { + tag = withMore + } + } + return tag +} + +// portsDetailValue is the DETAIL panel's PORTS row value: every observed +// listening port, ":3000 :8080" โ€” the full list portTag's "+N" summarizes. +// Callers only render the row when ports exist (presence/absence, like the +// DRIVE row), so this never needs an empty placeholder. +func portsDetailValue(ports []int) string { + parts := make([]string, len(ports)) + for i, p := range ports { + parts[i] = ":" + strconv.Itoa(p) + } + return strings.Join(parts, " ") +} + +// fleetHasPorts reports whether at least one (visible) loop has observed +// listening ports โ€” the only condition under which the FLEET panel shows +// its PORT column. Below it (the common no-e2e-server fleet) the column +// would be a blank cell on every row, so it must add nothing at all, +// keeping today's byte-identical rows โ€” the same presence gate +// multiAccountFleet applies to the ACCOUNT column. +func fleetHasPorts(loops []domain.Loop) bool { + for _, l := range loops { + if len(l.Ports) > 0 { + return true + } + } + return false +} + // fleetPanelLines builds the FLEET panel's content lines, scrolled (see // visibleWindow) so the cursor row stays visible within innerHeight rows. // Callers pad/clip the result to exactly innerHeight via padLines. @@ -5535,13 +5633,13 @@ func (m Model) fleetPanelLines(innerWidth, innerHeight int) []string { case len(visible) == 0: return []string{stFaint.Render(fmt.Sprintf("no loops match filter %q.", m.filterQuery))} } - wName, showAccount, showCycle, showOracle, showLast := listRowWidths(innerWidth, multiAccountFleet(visible)) + wName, showAccount, showPort, showCycle, showOracle, showLast := listRowWidths(innerWidth, multiAccountFleet(visible), fleetHasPorts(visible)) dupLabels := duplicateLabels(visible) start, end := visibleWindow(len(visible), m.cursor, innerHeight) rows := make([]string, 0, end-start) for i := start; i < end; i++ { l := visible[i] - rows = append(rows, renderListRow(l, i == m.cursor, dupLabels[l.DisplayLabel()], wName, showAccount, showCycle, showOracle, showLast, m.fleetOracleCounts[l.SessionID], innerWidth)) + rows = append(rows, renderListRow(l, i == m.cursor, dupLabels[l.DisplayLabel()], wName, showAccount, showPort, showCycle, showOracle, showLast, m.fleetOracleCounts[l.SessionID], innerWidth)) } return rows } @@ -6158,6 +6256,14 @@ func renderDetail(l domain.Loop, width, height int, data detailData) string { } d.WriteString(detailRow("LAST", stInk.Render(rel(time.Since(l.LastActivity))+" ("+l.LastActivity.Format("15:04:05")+")"))) d.WriteString(detailRow("CWD", stDim.Render(trunc(l.Cwd, valueWidth)))) + // feat/ports-surfacing: every observed listening port in this loop's + // verified cwd, directly under CWD so the directory and its e2e server + // read together ("which port is THIS worktree's `make local` on"). Same + // presence/absence discipline as the DRIVE/CLAUDE rows โ€” omitted + // entirely when nothing was observed, never a claim the server is down. + if len(l.Ports) > 0 { + d.WriteString(detailRow("PORTS", stDim.Render(trunc(portsDetailValue(l.Ports), valueWidth)))) + } // multi-account Phase B: omitted ENTIRELY (not shown blank) when // l.Account.Label() is "" โ€” the default account (the common // zero-config case) renders no row at all here, matching the DRIVE diff --git a/internal/tui/model_test.go b/internal/tui/model_test.go index c17522e..8418afd 100644 --- a/internal/tui/model_test.go +++ b/internal/tui/model_test.go @@ -249,7 +249,7 @@ func TestLayoutModeFor(t *testing.T) { // cols" caveat for the old columnWidths). func TestListRowWidths_NeverOverflows(t *testing.T) { for innerWidth := wMarker + wState; innerWidth <= 200; innerWidth++ { - wName, _, showCycle, showOracle, showLast := listRowWidths(innerWidth, false) + wName, _, _, showCycle, showOracle, showLast := listRowWidths(innerWidth, false, false) sum := wMarker + wName + wState if showCycle { sum += wCycle @@ -278,14 +278,14 @@ func TestListRowWidths_NeverOverflows(t *testing.T) { // readable label"; LAST alone keeps the physical threshold. func TestListRowWidths_DropOrder_OracleThenCycleThenLast(t *testing.T) { full := wMarker + wState + wCycle + wOracle + wLast + nameGoodWidth - _, _, showCycle, showOracle, showLast := listRowWidths(full, false) + _, _, _, showCycle, showOracle, showLast := listRowWidths(full, false, false) if !showCycle || !showOracle || !showLast { t.Fatalf("precondition failed: at full width want all three shown, got cycle=%v oracle=%v last=%v", showCycle, showOracle, showLast) } // one step narrower than "all three fit" โ€” ORACLE (the least // essential) must be the one to go, CYCLE and LAST both survive. - _, _, showCycle, showOracle, showLast = listRowWidths(full-1, false) + _, _, _, showCycle, showOracle, showLast = listRowWidths(full-1, false, false) if showOracle { t.Error("showOracle = true with insufficient room, want false (ORACLE drops first)") } @@ -296,7 +296,7 @@ func TestListRowWidths_DropOrder_OracleThenCycleThenLast(t *testing.T) { // narrow enough that CYCLE can't keep the label readable either โ€” // CYCLE goes next, LAST still survives alone. tight := wMarker + wState + wLast + listNameFloor - _, _, showCycle, showOracle, showLast = listRowWidths(tight, false) + _, _, _, showCycle, showOracle, showLast = listRowWidths(tight, false, false) if showOracle || showCycle { t.Errorf("got cycle=%v oracle=%v, want both dropped at this width", showCycle, showOracle) } @@ -305,7 +305,7 @@ func TestListRowWidths_DropOrder_OracleThenCycleThenLast(t *testing.T) { } // narrower still โ€” even LAST alone doesn't fit. - _, _, showCycle, showOracle, showLast = listRowWidths(wMarker+wState+listNameFloor-1, false) + _, _, _, showCycle, showOracle, showLast = listRowWidths(wMarker+wState+listNameFloor-1, false, false) if showCycle || showOracle || showLast { t.Errorf("got cycle=%v oracle=%v last=%v, want all three dropped at this width", showCycle, showOracle, showLast) } @@ -317,7 +317,7 @@ func TestListRowWidths_DropOrder_OracleThenCycleThenLast(t *testing.T) { // NAME instead (the 100-col-terminal case: innerWidth 48 used to give // NAME 7). func TestListRowWidths_ReadabilityFloor_ProtectsLabelOverOracleCycle(t *testing.T) { - wName, _, showCycle, showOracle, showLast := listRowWidths(48, false) + wName, _, _, showCycle, showOracle, showLast := listRowWidths(48, false, false) if showOracle || showCycle { t.Errorf("got cycle=%v oracle=%v, want both dropped in favor of a readable label", showCycle, showOracle) } @@ -335,7 +335,7 @@ func TestListRowWidths_ReadabilityFloor_ProtectsLabelOverOracleCycle(t *testing. // doc on the structural floor this row format needs). func TestListRowWidths_NameWithinBounds(t *testing.T) { for _, innerWidth := range []int{wMarker + wState + listNameFloor, 40, 100, 300} { - wName, _, _, _, _ := listRowWidths(innerWidth, false) + wName, _, _, _, _, _ := listRowWidths(innerWidth, false, false) if wName < listNameFloor { t.Errorf("innerWidth=%d: wName=%d, want >= listNameFloor (%d)", innerWidth, wName, listNameFloor) } @@ -9113,7 +9113,7 @@ func TestListRowWidths_AccountNotRequested_NeverShown(t *testing.T) { // wantAccount=false โ‡’ showAccount=false at EVERY width โ€” the zero-config // path never even enters the account width math. for innerWidth := wMarker + wState; innerWidth <= 200; innerWidth++ { - if _, showAccount, _, _, _ := listRowWidths(innerWidth, false); showAccount { + if _, showAccount, _, _, _, _ := listRowWidths(innerWidth, false, false); showAccount { t.Fatalf("innerWidth=%d: showAccount=true with wantAccount=false", innerWidth) } } @@ -9121,7 +9121,7 @@ func TestListRowWidths_AccountNotRequested_NeverShown(t *testing.T) { func TestListRowWidths_AccountRequested_ShowsAtGenerousWidth(t *testing.T) { full := wMarker + wState + wAccount + wCycle + wOracle + wLast + nameGoodWidth - _, showAccount, showCycle, showOracle, showLast := listRowWidths(full, true) + _, showAccount, _, showCycle, showOracle, showLast := listRowWidths(full, true, false) if !showAccount || !showCycle || !showOracle || !showLast { t.Fatalf("at full width want every column incl account, got account=%v cycle=%v oracle=%v last=%v", showAccount, showCycle, showOracle, showLast) @@ -9133,7 +9133,7 @@ func TestListRowWidths_Account_OutlivesOracleAndCycle(t *testing.T) { // oracle/cycle on top. Oracle and cycle drop FIRST; account (more worth // protecting when two accounts are on screen) survives. w := wMarker + wState + wAccount + wLast + nameGoodWidth - _, showAccount, showCycle, showOracle, showLast := listRowWidths(w, true) + _, showAccount, _, showCycle, showOracle, showLast := listRowWidths(w, true, false) if !showAccount { t.Errorf("showAccount=false at innerWidth=%d, want true (account outlives oracle/cycle)", w) } @@ -9150,7 +9150,7 @@ func TestListRowWidths_Account_DropsBeforeLast(t *testing.T) { // AND account are all shed, LAST alone survives (account yields to LAST, // the most established column). tight := wMarker + wState + wLast + listNameFloor - _, showAccount, showCycle, showOracle, showLast := listRowWidths(tight, true) + _, showAccount, _, showCycle, showOracle, showLast := listRowWidths(tight, true, false) if showAccount || showCycle || showOracle { t.Errorf("got account=%v cycle=%v oracle=%v, want all three dropped at tight width", showAccount, showCycle, showOracle) @@ -9164,7 +9164,7 @@ func TestListRowWidths_AccountRequested_NeverOverflows(t *testing.T) { // The account column must obey the same "never return a layout that // doesn't fit" invariant as the rest of the cascade. for innerWidth := wMarker + wState; innerWidth <= 200; innerWidth++ { - wName, showAccount, showCycle, showOracle, showLast := listRowWidths(innerWidth, true) + wName, showAccount, _, showCycle, showOracle, showLast := listRowWidths(innerWidth, true, false) sum := wMarker + wName + wState if showAccount { sum += wAccount @@ -10937,3 +10937,205 @@ func TestUpdate_Esc_FilterTakesPrecedenceOverBannerDismiss(t *testing.T) { t.Error("esc cleared the filter but must NOT also dismiss the banner in the same press") } } + +// โ”€โ”€ feat/ports-surfacing: the FLEET PORT column and DETAIL PORTS row โ”€โ”€โ”€โ”€โ”€ + +func TestPortTag(t *testing.T) { + if got := portTag(nil); got != "" { + t.Errorf("portTag(nil) = %q, want \"\" โ€” no observed ports renders a blank cell, never a claim", got) + } + if got := portTag([]int{3000}); got != "๐ŸŒ:3000" { + t.Errorf("portTag([3000]) = %q, want ๐ŸŒ:3000", got) + } + if got := portTag([]int{3000, 8080, 5173}); got != "๐ŸŒ:3000+2" { + t.Errorf("portTag([3000 8080 5173]) = %q, want ๐ŸŒ:3000+2 โ€” extras summarized, full list is DETAIL's", got) + } +} + +func TestPortTag_NeverExceedsColumnWidth_DropsSuffixNotDigits(t *testing.T) { + // the tag must fit inside wPort with the column gap โ€” same + // fit-inside-the-fixed-column contract accountTag pins. But unlike a + // label, a port number may never be sheared mid-digits (an ellipsis'd + // port is a port that doesn't exist): when "+N" would overflow, the + // SUFFIX goes, the port stays whole. + wide := portTag([]int{65535, 1, 2, 3, 4, 5, 6, 7, 8, 9}) + if w := narrowAmbiguous.StringWidth(wide); w > wPort-1 { + t.Errorf("portTag width = %d (%q), want <= wPort-1 (%d)", w, wide, wPort-1) + } + if wide != "๐ŸŒ:65535" { + t.Errorf("portTag = %q, want ๐ŸŒ:65535 โ€” the +N suffix drops, the digits never do", wide) + } +} + +func TestPortsDetailValue(t *testing.T) { + if got := portsDetailValue([]int{3000}); got != ":3000" { + t.Errorf("portsDetailValue([3000]) = %q, want :3000", got) + } + if got := portsDetailValue([]int{3000, 8080}); got != ":3000 :8080" { + t.Errorf("portsDetailValue([3000 8080]) = %q, want ':3000 :8080'", got) + } +} + +func TestFleetHasPorts(t *testing.T) { + if fleetHasPorts([]domain.Loop{{}, {}}) { + t.Error("got true, want false โ€” no loop has observed ports") + } + if !fleetHasPorts([]domain.Loop{{}, {Ports: []int{3000}}}) { + t.Error("got false, want true โ€” one loop with ports is enough") + } + if fleetHasPorts(nil) { + t.Error("got true, want false for an empty fleet") + } +} + +// listRowWidths' port branch: requested vs not, and its place in the drop +// cascade (drops after ORACLE/CYCLE but BEFORE ACCOUNT โ€” see its doc). + +func TestListRowWidths_PortNotRequested_NeverShown(t *testing.T) { + // wantPort=false โ‡’ showPort=false at EVERY width โ€” the no-server fleet + // never even enters the port width math. + for innerWidth := wMarker + wState; innerWidth <= 200; innerWidth++ { + if _, _, showPort, _, _, _ := listRowWidths(innerWidth, false, false); showPort { + t.Fatalf("innerWidth=%d: showPort=true with wantPort=false", innerWidth) + } + } +} + +func TestListRowWidths_PortRequested_ShowsAtGenerousWidth(t *testing.T) { + full := wMarker + wState + wPort + wCycle + wOracle + wLast + nameGoodWidth + _, _, showPort, showCycle, showOracle, showLast := listRowWidths(full, false, true) + if !showPort || !showCycle || !showOracle || !showLast { + t.Fatalf("at full width want every column incl port, got port=%v cycle=%v oracle=%v last=%v", + showPort, showCycle, showOracle, showLast) + } +} + +func TestListRowWidths_Port_OutlivesOracleAndCycle(t *testing.T) { + // Mid-width band: room for a readable NAME + port + LAST, but not for + // oracle/cycle on top. Oracle and cycle drop FIRST; port survives. + w := wMarker + wState + wPort + wLast + nameGoodWidth + _, _, showPort, showCycle, showOracle, showLast := listRowWidths(w, false, true) + if !showPort { + t.Errorf("showPort=false at innerWidth=%d, want true (port outlives oracle/cycle)", w) + } + if showOracle || showCycle { + t.Errorf("got oracle=%v cycle=%v, want both dropped before port", showOracle, showCycle) + } + if !showLast { + t.Error("showLast=false, want true") + } +} + +func TestListRowWidths_Port_DropsBeforeAccount(t *testing.T) { + // Both requested, room for only one of them beside a readable NAME (the + // band fits either column alone, PORT even more easily at its narrower + // width โ€” so surviving is purely about ORDER: the cascade must shed + // PORT and keep ACCOUNT, identity beats convenience). + w := wMarker + wState + wAccount + wLast + nameGoodWidth + _, showAccount, showPort, _, _, _ := listRowWidths(w, true, true) + if showPort { + t.Error("showPort=true, want false โ€” PORT yields before ACCOUNT") + } + if !showAccount { + t.Error("showAccount=false, want true โ€” ACCOUNT survives PORT") + } +} + +func TestListRowWidths_PortRequested_NeverOverflows(t *testing.T) { + // The port column must obey the same "never return a layout that + // doesn't fit" invariant as the rest of the cascade. + for innerWidth := wMarker + wState; innerWidth <= 200; innerWidth++ { + wName, showAccount, showPort, showCycle, showOracle, showLast := listRowWidths(innerWidth, true, true) + sum := wMarker + wName + wState + if showAccount { + sum += wAccount + } + if showPort { + sum += wPort + } + if showCycle { + sum += wCycle + } + if showOracle { + sum += wOracle + } + if showLast { + sum += wLast + } + if sum > innerWidth { + t.Errorf("innerWidth=%d: sum=%d (wName=%d account=%v port=%v cycle=%v oracle=%v last=%v), want <= %d", + innerWidth, sum, wName, showAccount, showPort, showCycle, showOracle, showLast, innerWidth) + } + } +} + +func TestFleetPanelLines_LoopWithPorts_RendersTag(t *testing.T) { + m := New() + m.loops = []domain.Loop{ + {Project: "web", SessionID: "s1", State: domain.StateRunning, Cycle: 2, + Goal: domain.Goal{Text: "run the e2e suite", MaxCycles: 12}, + Ports: []int{3000}}, + {Project: "api", SessionID: "s2", State: domain.StateIdle, Cycle: 1, + Goal: domain.Goal{Text: "idle loop", MaxCycles: 12}}, + } + m.cursor = 0 + joined := strings.Join(m.fleetPanelLines(120, 10), "\n") + if !strings.Contains(joined, "๐ŸŒ:3000") { + t.Errorf("expected the ๐ŸŒ:3000 tag, got:\n%s", joined) + } +} + +func TestFleetPanelLines_NoPorts_NoTagColumn(t *testing.T) { + // the common no-e2e-server fleet must not gain a blank column โ€” no ๐ŸŒ + // anywhere in the panel. + m := New() + m.loops = []domain.Loop{ + {Project: "web", SessionID: "s1", State: domain.StateRunning, Cycle: 2, + Goal: domain.Goal{Text: "some goal", MaxCycles: 12}}, + } + m.cursor = 0 + joined := strings.Join(m.fleetPanelLines(120, 10), "\n") + if strings.Contains(joined, "๐ŸŒ") { + t.Errorf("expected no port glyph in a fleet with no observed ports, got:\n%s", joined) + } +} + +func TestRenderDetail_WithPorts_ShowsPortsRow(t *testing.T) { + l := domain.Loop{Project: "web", SessionID: "s1", State: domain.StateRunning, + Cwd: "/x/web", CwdVerified: true, Path: "/x/s1.jsonl", Ports: []int{3000, 8080}} + out := renderDetail(l, 80, 40, detailData{now: time.Now()}) + if !strings.Contains(out, "PORTS") || !strings.Contains(out, ":3000 :8080") { + t.Errorf("detail pane should show a PORTS row with every observed port:\n%s", out) + } +} + +func TestRenderDetail_NoPorts_OmitsPortsRow(t *testing.T) { + // presence/absence like the DRIVE row: nothing observed renders NO row + // โ€” never a "no server" claim. + l := domain.Loop{Project: "web", SessionID: "s1", State: domain.StateIdle, + Cwd: "/x/web", Path: "/x/s1.jsonl"} + out := renderDetail(l, 80, 40, detailData{now: time.Now()}) + if strings.Contains(out, "PORTS") { + t.Errorf("detail pane should have NO PORTS row when no port was observed:\n%s", out) + } +} + +func TestDemoFleet_FlakyTests_CarriesObservedPort(t *testing.T) { + // the demo is what a new reader believes the product looks like โ€” one + // loop must exercise the PORT column/PORTS row, in the only shape the + // real pipeline can produce: ports on a VERIFIED cwd (see applyPorts). + loops, _, _, _ := demoFleet() + for _, l := range loops { + if l.Project != "flaky-tests" { + continue + } + if len(l.Ports) != 1 || l.Ports[0] != 3000 { + t.Errorf("flaky-tests.Ports = %v, want [3000]", l.Ports) + } + if !l.CwdVerified { + t.Error("flaky-tests.CwdVerified = false โ€” demo ports must ride a verified cwd, the only state the product produces") + } + return + } + t.Fatal("expected a flaky-tests loop in the demo fleet") +}