feat(fanout): worktree fan-out board — race N agent lanes of one repo (W5)

Fan ONE prompt across N branch/agent lanes: N worktrees, N Claude sessions on the
same prompt, watched side-by-side in the split-grid board, approve/kill per lane,
🏆 keep the winner (losers' worktrees removed). ~90% composition of shipped parts.

- src/http/session-groups.ts (new, pure, no git exec): deriveRepoRoot /
  groupSessionsByRepo (cluster sessions by their <repo>-worktrees parent).
- GET /live-sessions/grouped (read-only; registered BEFORE /live-sessions/:id so
  "grouped" isn't captured as an id). MAX_FANOUT_LANES env (default 6, = grid-6 cap).
- public/fanout.ts (new, pure): buildFanoutCmd shell-quotes the prompt (single-quote
  wrap with '\''-escaping so $(...)/backticks/;/&& can't execute) + collapses newlines
  + caps 4000 chars; laneBranch/slugify. Effective N = min(lanes, maxFanoutLanes, 6),
  ≥2; the maxSessions cap is enforced server-side ("Started K of N" banner).
- public/tabs.ts launchFanout (N× createWorktree → openProject w/ prompt pre-injected
  via the existing initialInput) + keepFanoutWinner; public/projects.ts renderFanoutForm
  + extracted shared createWorktreeReq (DRY). Byte-shuttle preserved (lane = own PTY).
  One-click merge deferred (winner session stays open for a manual merge).

Reused unchanged: createWorktree/removeWorktree, addEntry/initialInput, split-grid +
per-quadrant approve/maximize/monitor + gauges. Verified: typecheck + build:web clean,
2063 pass at --test-timeout=30000 (only the known tmux/PTY flake red). Fixed 3
/config/ui exact-shape tests to include the new maxFanoutLanes field.
This commit is contained in:
Yaojia Wang
2026-07-13 05:09:19 +02:00
parent c81821b890
commit 9683a16f4f
20 changed files with 1225 additions and 8 deletions

View File

@@ -0,0 +1,57 @@
/**
* src/http/session-groups.ts (W5 fan-out board) — cluster running sessions by
* the repo they belong to, for the fan-out discovery endpoint.
*
* PURE + STRING-ONLY (no `git` exec, no filesystem I/O) — fully node-unit-testable.
* Fan-out worktrees always live under `<repo>-worktrees/` (createWorktree's base,
* worktrees.ts computeWorktreeDir), so a session whose cwd is
* `<repo>-worktrees/<lane>` shares a group with the repo and its sibling lanes.
* A session that is NOT under a `*-worktrees` parent groups under its own cwd.
*/
import path from 'node:path'
import type { LiveSessionInfo, SessionGroup } from '../types.js'
/** The suffix createWorktree appends to derive the worktree base dir name. */
const WORKTREES_SUFFIX = '-worktrees'
/**
* Derive the repo root for a session cwd. If the cwd's PARENT directory is a
* `<name>-worktrees` folder, the repo root is `<dirname-of-parent>/<name>` (strip
* the `-worktrees` suffix). Otherwise the cwd is its own repo root. `null` in →
* `null` out (a session with no known cwd has no repo to group under). Pure.
*/
export function deriveRepoRoot(cwd: string | null): string | null {
if (cwd === null || cwd === '') return null
const parent = path.dirname(cwd)
const parentName = path.basename(parent)
if (parentName.length > WORKTREES_SUFFIX.length && parentName.endsWith(WORKTREES_SUFFIX)) {
const repoName = parentName.slice(0, -WORKTREES_SUFFIX.length)
return path.join(path.dirname(parent), repoName)
}
return cwd
}
/**
* Group live sessions by derived repo root. Sessions whose cwd resolves to the
* same repo (a repo + its `*-worktrees/*` lanes) land in one SessionGroup.
* Sessions with `cwd === null` are SKIPPED (no repo to attribute them to).
* Group order and per-group member order both follow the input order (which
* manager.list() already yields newest-first). Never mutates the input. Pure.
*/
export function groupSessionsByRepo(sessions: readonly LiveSessionInfo[]): SessionGroup[] {
const byRoot = new Map<string, SessionGroup>()
const order: string[] = []
for (const session of sessions) {
const repoRoot = deriveRepoRoot(session.cwd)
if (repoRoot === null) continue // no cwd → not attributable to a repo
let group = byRoot.get(repoRoot)
if (group === undefined) {
group = { repoRoot, label: path.basename(repoRoot), sessions: [] }
byRoot.set(repoRoot, group)
order.push(repoRoot)
}
group.sessions.push(session)
}
return order.map((root) => byRoot.get(root)!)
}