Files
web-terminal/docs/plans/w5-fanout-board.md

157 lines
20 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Worktree fan-out board (parallel agent lanes)
**Feature id:** `w5-fanout-board` · **Branch base:** `develop` · **Effort: L** · Depends on Wave 2 (PTY-inject / `initialInput`) + Wave 4 (worktree create + remove).
Fan **one task** across **N** branch/agent lanes of one repo: create N worktrees, spawn N Claude sessions each pre-injected with the same prompt, watch them race side-by-side in the existing split-grid board, approve/kill per lane, then **keep the winner** (its tab + worktree stay) while **losers get worktree-remove**. This is **≈90% composition of shipped parts**; the only genuinely new code is a small pure launch-command builder, an in-memory (localStorage-persisted) lane-group model, and one thin read-only grouping endpoint.
> **Byte-shuttle + no-clobber preserved:** the server never learns "fan-out" semantics on the terminal stream. Each lane is its **own worktree → own PTY** (`createWorktree` at `src/http/worktrees.ts:224` gives a fresh dir; `attach(null)` spawns a fresh PTY there), so two Claudes editing the same repo never touch the same working tree.
## New vs reused
| Concern | Reused (no change) | New (thin) |
|---|---|---|
| Spawn N worktrees | `createWorktree` (`worktrees.ts:224`) via `POST /projects/worktree` (`server.ts:908`) | orchestrator loops the route N× (sequential) |
| Spawn N sessions w/ prompt | `openProject``addEntry``initialInput` (`tabs.ts:836,704,717`) → typed after `attached` (`terminal-session.ts:374`, `INITIAL_INPUT_DELAY_MS=700` `:30`); `claude "<prompt>"` pattern proven at `projects.ts:958` | `buildFanoutCmd(prompt,mode)` — pure, shell-quotes the prompt |
| Watch board | `setGridLayout('grid-4'\|'grid-6')` (`tabs.ts:1020`), per-quadrant approve/maximize/monitor (`renderInlineApprove`/`toggleMaximize`/`toggleMonitor` `tabs.ts:1364,1081,1088`), statusLine gauges (`renderCell` `:1328`, `tab-gauge` `:1451`) | per-cell "🏆 Keep" button (same pattern as `cell-max` wiring `tabs.ts:776-801`) |
| Discard losers | `removeWorktreeReq`/`confirmAndRemoveWorktree` (`projects.ts:299,349`) → `DELETE /projects/worktree` (`server.ts:937`) | batch-confirm loop in `keepFanoutWinner` |
| Grouped discovery | `manager.list()`/`GET /live-sessions` (`manager.ts:185`, `server.ts:332`); `LiveSessionInfo.cwd` (`types.ts:299`) | `GET /live-sessions/grouped` + pure `groupSessionsByRepo` |
**Merge of the winner:** the winner's session/worktree **stays open** so the user runs the merge inside it (the ROADMAP + task design collapse "keep winner" to "winner session stays"). A one-click merge is **out of scope for v1** (needs conflict handling); an optional thin `merge()` in `git-ops.ts` is sketched under Effort as a follow-up.
---
## Contract
### New HTTP route (`src/server.ts`) — the one thin server piece
| Method / path | Guard | Response |
|---|---|---|
| `GET /live-sessions/grouped` | none (read-only aggregate, same threat model as `/live-sessions` `:332` and `/digest` `:340`) | `200 SessionGroup[]` |
Pure read over `manager.list()`; **no** Origin guard, **no** new write path. Grouping is **string-only** (no `git` exec): fan-out worktrees always live under `<repo>-worktrees/` (createWorktree base, `worktrees.ts:151-152`), so sessions whose `cwd` shares that parent cluster into one group. Registered beside `/live-sessions` (`server.ts:332`).
### `src/types.ts` (coordination edit — the frozen shared contract)
```ts
// group of running sessions sharing a repo/worktree-root (fan-out discovery)
export interface SessionGroup {
repoRoot: string; // derived repo dir (parent of the *-worktrees folder, or the cwd)
label: string; // basename(repoRoot)
sessions: LiveSessionInfo[]; // members, newest-first (already the manager.list order)
}
// launch options the FE passes to TabApp.launchFanout (FE-internal, but typed shared)
export interface FanoutLaunchOpts {
prompt: string;
lanes: number; // 2..maxFanoutLanes
branchBase: string; // slug; lanes get `${branchBase}-lane-${i}`
mode?: PermissionMode; // reuse types.ts:445
}
```
Add `maxFanoutLanes?: number` to **`UiConfig`** (`types.ts:656`) so the FE stepper max is server-controlled.
### `src/config.ts` env var (additive, optional)
| Env | Field (add to `Config` `types.ts:26` block) | Default | Parser |
|---|---|---|---|
| `MAX_FANOUT_LANES` | `maxFanoutLanes: number` | `6` (matches `grid-6` capacity, `grid-layout.ts:30`) | `parseNonNegativeInt` (`config.ts:83`, as `maxSessions` `:297`) |
Effective N is `min(opts.lanes, cfg.maxFanoutLanes, 6, maxSessions liveCount)` — bounded by grid capacity and the DoS cap (`assertUnderSessionCap`, `manager.ts:106`).
### Client-side contract
- **No new `ClientMessage`/`ServerMessage`.** The stream stays a byte-shuttle. Launch = N existing `POST /projects/worktree` + N existing WS attaches (via `openProject`). Discard = existing `DELETE /projects/worktree`.
- **New `ProjectsHooks` method** (`projects.ts:39`): `onFanout: (repoPath: string, repoName: string, opts: FanoutLaunchOpts) => void` — mirrors the existing `onOpenProject` hook exactly; wired in `tabs.ts` constructor (`:180`) to `this.launchFanout(...)`.
- **New pure launch-cmd builder** `buildFanoutCmd(prompt, mode, allowAutoMode): string``claude [--permission-mode <m>] '<shell-quoted, newline-collapsed prompt>'\r`. Single-quote wrap with `'``'\''` escaping; collapse `\r?\n`→space; cap length (`FANOUT_PROMPT_MAX = 4000`). Reuses `resolveMode`/`buildClaudeCmd` shape (`tabs.ts:659,664`).
---
## Files to change
| Path | Concrete change |
|---|---|
| `src/types.ts` | **Coordination edit.** Add `SessionGroup`, `FanoutLaunchOpts`; add `maxFanoutLanes?: number` to `UiConfig` (`:656`); add `maxFanoutLanes: number` to `Config` (`:26` block). |
| `src/config.ts` | Parse `MAX_FANOUT_LANES` in `loadConfig` (helper exists, `parseNonNegativeInt` `:83`); include in returned `Config`. |
| `src/http/session-groups.ts` **(new)** | Pure `deriveRepoRoot(cwd: string \| null): string \| null` (strip a trailing `/<name>` when the parent basename ends with `-worktrees`, else return cwd) + `groupSessionsByRepo(sessions: LiveSessionInfo[]): SessionGroup[]`. No I/O — fully node-unit-testable. High-cohesion small file (per coding-style). |
| `src/server.ts` | Register `GET /live-sessions/grouped``res.json(groupSessionsByRepo(manager.list()))` beside `:332`; import `groupSessionsByRepo`; add `maxFanoutLanes` to the `UiConfig` object at `/config/ui` (`:1099`). |
| `public/fanout.ts` **(new)** | `buildFanoutCmd(prompt, mode, allowAutoMode)`, `shellSingleQuote(s)`, `sanitizePrompt(s)` (collapse newlines, trim, cap), `laneBranch(base, i)`, `slugify(prompt)` — all pure/exported for jsdom unit tests. Plus `createWorktreeReq(repoPath, branch)` thin POST helper if not reusing the inline one in `projects.ts:725` (extract it to reuse — see below). |
| `public/projects.ts` | Add `onFanout` to `ProjectsHooks` (`:39`); add `renderFanoutForm(detail, hooks)` (sibling of `renderNewWorktreeForm` `:692`: prompt `<textarea>`, N `<input type=number min=2 max=maxFanoutLanes>`, branch-base `<input>` prefilled `slugify(prompt)`, permission-mode `<select>`, "⑃ Fan out N lanes" submit) rendered in `renderProjectDetail` (`:822`) just after the New Worktree form (`:930`); **extract** the inline `POST /projects/worktree` body (`:725-737`) into an exported `createWorktreeReq(repoPath, branch)` so fan-out and the form share it (DRY). |
| `public/tabs.ts` | Add `launchFanout(repoPath, repoName, opts)` + `keepFanoutWinner(groupId, winnerSessionId)`; a `fanoutGroups: Map<string, FanoutGroup>` field (persisted via a new `FANOUT_KEY`); extend `TabEntry` with `fanoutGroupId?: string`, `worktreePath?: string`, `branch?: string`; append a per-cell **🏆 Keep** button in `addEntry` (`:776-801`) shown only when `entry.fanoutGroupId` is set (toggled in `renderCell` `:1328`); wire `onFanout` in the constructor (`:180`). Reuse `setGridLayout` (`:1020`), `addEntry` (`:704`), `countOpenWithTitlePrefix` (`:849`), `removeWorktreeReq` (`projects.ts:299`). |
| `public/styles.css` (or the FE CSS entry) | `.proj-fanout-form`, `.cell-keep` (mirror `.cell-max` `tabs.ts:779`), `.fanout-banner`. No new layout — reuses `lay-grid-4/6` + `term-cell` chrome. |
`launchFanout` algorithm (FE orchestration, **sequential**`git worktree add` takes a repo lock, so parallel adds race):
1. Clamp `N = min(opts.lanes, maxFanoutLanes, 6)`; validate `branchBase` via `validateBranchNameClient` (`projects.ts:672`); build `cmd = buildFanoutCmd(prompt, mode, allowAutoMode)` once.
2. `for i in 1..N`: `const r = await createWorktreeReq(repoPath, laneBranch(base,i))`; on `r.ok` push `{branch, worktreePath:r.path}` to the group; on failure record the error, **continue** (partial-success, surfaced in the banner — no auto-rollback in v1).
3. If ≥1 lane created: create a `FanoutGroup {id, repoPath, repoName, prompt, lanes[]}`; for each created lane `addEntry(null, `${repoName}·${branch}`, worktreePath, cmd)` (reuse `openProject`'s exact addEntry call shape `:842`), tag the returned `TabEntry` with `fanoutGroupId/worktreePath/branch`; persist.
4. `setGridLayout(N <= 4 ? 'grid-4' : 'grid-6')` and `activate` the first lane. The board's existing per-quadrant approve/maximize/monitor + gauges now cover every lane for free.
`keepFanoutWinner(groupId, winnerSessionId)`:
1. Resolve the group; single confirm: `"Keep <winner branch> and discard the other N1 lanes (delete their worktrees)?"`.
2. For each losing lane: `closeTab(idx)` (detach — PTY reaped by IDLE_TTL) then `removeWorktreeReq(repoPath, lane.worktreePath, false)`; on `409` dirty → `removeWorktreeReq(..., true)` (already inside the batch confirm — no per-lane prompt). Collect failures into the banner.
3. Winner tab **stays**; drop the group from `fanoutGroups`; if all losers gone, `setGridLayout('single')` and activate the winner.
---
## TDD steps (ordered — RED→GREEN, matching repo style)
**Backend pure — `test/http/session-groups.test.ts`** (new, node env, no I/O; mirror the pure-helper style of `test/http/worktrees*.test.ts`):
1. `deriveRepoRoot('/a/proj-worktrees/lane-1')``/a/proj` (parent basename ends `-worktrees`). `deriveRepoRoot('/a/proj')``/a/proj`. `deriveRepoRoot(null)``null`. → implement.
2. `groupSessionsByRepo([...])`: two sessions with cwds `/a/proj-worktrees/lane-1` and `/lane-2`**one** `SessionGroup` (`repoRoot:/a/proj`, 2 members); an unrelated `/b/other` cwd → its own group; `cwd:null` → skipped or an "ungrouped" bucket (decide + assert). Members preserve `manager.list` newest-first order.
**Backend config — `test/config.test.ts`** (extend): assert `maxFanoutLanes` default `6`, `MAX_FANOUT_LANES=3` override parses, `-1` throws (fail-fast, like the `MAX_SESSIONS` test). Update **every** all-fields `CFG` fixture (`grep -rl "maxSessions" test/`) to add `maxFanoutLanes` (compile gate).
**Backend route — `test/integration/*.test.ts`** (extend an existing live-sessions integration file; reuse `startServer` + `fetch`, `itPty` where a real session is needed):
3. `GET /live-sessions/grouped` on an empty manager → `200 []`. No Origin header required (read-only) — assert it does **not** 403.
4. `itPty`: attach two sessions with cwds under one `*-worktrees` dir → `grouped` returns one group with both ids. `GET /config/ui` includes `maxFanoutLanes`.
**FE pure — `test/fanout.test.ts`** (new, jsdom or node; pure functions):
5. `shellSingleQuote("it's ok")``'it'\''s ok'`. `sanitizePrompt("a\nb\r\nc")``"a b c"`; over-length → truncated to `FANOUT_PROMPT_MAX`.
6. `buildFanoutCmd("fix bug","plan",true)``claude --permission-mode plan 'fix bug'\r`; `mode:'default'``claude 'fix bug'\r`; `mode:'auto', allowAutoMode:false` → downgraded to no `--permission-mode` (SEC-M5 parity with `resolveMode` `tabs.ts:659`).
7. `laneBranch('feat-x',3)``feat-x-lane-3`; result passes `validateBranchNameClient` (`projects.ts:672`).
**FE component — `test/projects.test.ts`** (extend; reuse `makeDetail`/`makeHooks`, stubbed `fetch`):
8. `renderFanoutForm` renders a prompt textarea, N stepper (max = `maxFanoutLanes`), branch-base input, mode select, submit. Empty prompt → submit disabled / inline error via `textContent` (SEC-L3/H6, like `renderNewWorktreeForm` `:708`).
9. Submitting calls `hooks.onFanout(detail.path, detail.name, {prompt, lanes, branchBase, mode})`.
**FE orchestration — `test/tabs.test.ts`** (extend; the file already stubs `TerminalSession`/`fetch`):
10. `launchFanout(repo,'repo',{lanes:3,...})` with `fetch` stubbed to return `{ok:true,path:'/wt/lane-i'}`**3** `addEntry` calls (assert 3 tabs), each with the **same** `initialInput` (`buildFanoutCmd` output) and its lane cwd; grid becomes `grid-4`; worktree POSTs happen **sequentially** (assert call order / that the (k+1)th starts after the kth resolves).
11. Partial failure: 2nd `createWorktreeReq` rejects → 2 lanes created, banner shows the failure, no throw (never-throw discipline, `projects.ts:299`).
12. `keepFanoutWinner(id, winnerId)`: `confirm=()=>true`, `fetch` `{ok:true}` → loser tabs closed (assert `tabs.length` drops to 1), `DELETE /projects/worktree` called once per loser with the loser's `worktreePath`; winner tab remains; layout → `single`.
13. Dirty loser: `DELETE` first `{ok:false,status:409}` then `{ok:true}` → second call body has `force:true`; **no** second `window.confirm` (batch already confirmed).
14. `confirm=()=>false` → no `closeTab`, no `DELETE` (no accidental deletion).
Run `npm test`; the ~80% gate holds — grouping/config/route are deterministic node tests, and every FE branch (build-cmd modes, sequential launch, partial failure, keep-winner happy/dirty/cancel) is jsdom-exercised. The only PTY-real assertion (grouped over live sessions) is `itPty`-gated (auto-skips in sandbox, runs in CI).
---
## Edge cases
- **Prompt with quotes / `$` / backticks / `;`** — single-quote wrapping + `'\''` escaping means the shell passes it verbatim to `claude` as one argv element; no command injection into the shell (see Security).
- **Multi-line prompt** — a raw newline typed into the PTY submits early; `sanitizePrompt` collapses `\r?\n`→space (single-line task descriptions are the use case, like an issue title). Documented in the form's helper text.
- **N exceeds caps** — clamped to `min(maxFanoutLanes, 6, maxSessionsliveCount)`; if the DoS cap is hit mid-launch, `addEntry`→attach throws server-side via `assertUnderSessionCap` (`manager.ts:106`, the M4 path) and that lane shows an `exit(-1)`; the banner reports "started K of N".
- **Branch already checked out / dir exists** — `createWorktree` returns `409` (`worktrees.ts:200-208`); that lane is skipped with a banner note; other lanes proceed (each branch is `-lane-i`, so collisions only on a re-run — suggest a fresh `branchBase`).
- **Sequential lock contention** — awaiting each `createWorktree` before the next avoids git's `worktree add` lock race; total launch is O(N) git adds (bounded, N≤6).
- **Keep-winner on a dirty loser** — `DELETE` 409 → auto-retry with `force:true` *inside the single batch confirm* (the user already accepted "discard the other lanes"); still never force-removes the **main** worktree (server rejects `isMain` 400, `worktrees.ts:327`) — losers are never main.
- **Winner == current worktree you're viewing** — fine; only losers are removed. Removing a loser whose tab is elsewhere just detaches then deletes its dir.
- **Reload mid-race** — `fanoutGroups` persisted to `FANOUT_KEY` (like `TABS_KEY` `tabs.ts:57`, written in `onSessionId` once ids resolve); on boot, reconstruct the board from persisted groups. Fallback discovery: `GET /live-sessions/grouped` re-clusters live sessions by repo even if localStorage was cleared.
- **Session exits (Claude finished) before you pick a winner** — the exited lane keeps its last screen (L1 replay, `manager.ts:149`) and its gauge greys stale (`STATUSLINE_TTL_MS`, `tabs.ts:64`); still selectable as winner (its worktree persists until you keep someone).
- **`worktreeEnabled=0`** — create/remove routes 403 (`server.ts:910,939`); `launchFanout` surfaces the 403 in the banner and creates nothing (the form can hide itself when `/config/ui` signals disabled — optional).
---
## Security
- **No new trust boundary.** Launch reuses `POST /projects/worktree` and each lane's WS attach; discard reuses `DELETE /projects/worktree` — all already `requireAllowedOrigin`-guarded (`server.ts:480,909,938`). `GET /live-sessions/grouped` is read-only (session ids, cwds, statuses — same data already in `/live-sessions`), so no Origin guard, consistent with `/live-sessions` (`:332`) and `/digest` (`:340`).
- **Prompt → shell injection (the one new risk).** The prompt is typed as raw bytes into the lane's shell before `claude` parses it. `shellSingleQuote` wraps it in single quotes with `'``'\''` escaping so the shell treats it as a single literal argv element — no `$(...)`, backtick, `;`, `&&`, or redirection can execute. `sanitizePrompt` additionally strips newlines (which would submit a partial line) and caps length. **This is the load-bearing quoting invariant — unit-test it (steps 5-6) and never `String`-concat the prompt into the command unquoted.** (The threat is limited anyway: the caller already has full shell access via the terminal — this just avoids a *surprising* early-execution when a prompt contains shell metacharacters.)
- **Branch names** — validated client-side (`validateBranchNameClient` `:672`) **and** server-side (`validateBranchName` `worktrees.ts:95`, `-b <branch>` after `--`); flag-injection (`-`-leading) and traversal already rejected; the worktree dir is containment-checked (`computeWorktreeDir` M2, `:146`).
- **Destructive keep-winner** — a single explicit confirm before deleting N1 worktrees; `removeWorktree`'s realpath-must-match-a-registered-worktree spine (`worktrees.ts:313-323`) means only genuine linked worktrees can be deleted, never an arbitrary path, and never `main`. Errors render via `textContent` (SEC-L3/H6), never `innerHTML`.
- **DoS bounds** — N capped by `maxFanoutLanes`/grid-6/`maxSessions`; each lane is one bounded PTY; grouping endpoint does zero `git`/FS work (pure string grouping over the in-memory list).
- **Audit** — each `createWorktree`/`removeWorktree` already logs via `sanitizeForLog` (`server.ts:923,952`); no new logging path, prompt bytes are never logged.
---
## Effort & dependencies
- **Rough effort: L (~34 dev-days).** Backend is genuinely thin: config field + one pure grouping module + one read-only route (~0.5 d incl. tests). FE carries the weight: `public/fanout.ts` pure helpers (~0.5 d), `renderFanoutForm` + `onFanout` wiring (~0.5 d), `launchFanout`/`keepFanoutWinner`/lane-group model + persistence + the 🏆 cell button (~1.5 d), tests (~0.75 d).
- **Depends on (all shipped):** W2 `initialInput` (`terminal-session.ts:127,374`) + `openProject`/`addEntry` (`tabs.ts:836,704`); W4 `createWorktree` (`worktrees.ts:224`) + `removeWorktree` (`:301`) + `removeWorktreeReq`/`confirmAndRemoveWorktree` (`projects.ts:299,349`); the split-grid board (`grid-layout.ts`, `tabs.ts` `applyLayout`/`renderInlineApprove`/`renderCell`). **No** node-pty/protocol change, **no** new WS frame, **no** DB/migration, **no** new npm dep.
- **Coordination:** the `src/types.ts` edit (`SessionGroup`, `FanoutLaunchOpts`, `UiConfig.maxFanoutLanes`, `Config.maxFanoutLanes`) is the only cross-cutting change — freeze it first and update every all-fields `CFG` test fixture in the same commit (it touches `Config`).
- **Optional follow-ups (explicitly out of v1 scope):** a thin `merge(repoPath, branch, opts)` in `src/http/git-ops.ts` (sibling of `commit` `:218`/`push` `:312`, reusing `classifyGitError` `:81`) behind `POST /projects/git/merge` for one-click "land the winner"; server-side `git --git-common-dir` grouping for exact (non-heuristic) clustering; auto-rollback of partially-created worktrees on launch failure. Each is additive and can ship after the board proves out.