157 lines
20 KiB
Markdown
157 lines
20 KiB
Markdown
# 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 N−1 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, maxSessions−liveCount)`; 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 N−1 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 (~3–4 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. |