diff --git a/public/fanout.ts b/public/fanout.ts new file mode 100644 index 0000000..0a1aa6f --- /dev/null +++ b/public/fanout.ts @@ -0,0 +1,74 @@ +/** + * public/fanout.ts (W5 fan-out board) — PURE launch-command + branch-slug helpers. + * + * No DOM, no fetch, no side effects — every export is unit-testable in isolation. + * The load-bearing invariant is the prompt shell-quoting (shellSingleQuote): the + * prompt is typed as raw bytes into a lane's shell BEFORE `claude` parses it, so + * it must be wrapped as a single literal argv element — no `$(...)`, backtick, + * `;`, `&&`, or redirection may execute. NEVER String-concat the prompt into the + * command unquoted (see the plan's Security section). + */ + +import type { PermissionMode } from '../src/types.js' + +/** Max prompt length typed into a lane (bounded DoS + keeps the command sane). */ +export const FANOUT_PROMPT_MAX = 4000 + +/** Default/max fan-out lanes (mirrors the server MAX_FANOUT_LANES default and the + * grid-6 board capacity). The FE stepper max and the launch clamp both use it. */ +export const FANOUT_MAX_LANES = 6 + +/** + * POSIX single-quote a string so the shell passes it verbatim as ONE argv element. + * Wrap in single quotes and replace every embedded `'` with the classic + * `'\''` sequence (close-quote, escaped-quote, re-open-quote). Pure. + */ +export function shellSingleQuote(s: string): string { + return `'${s.replace(/'/g, `'\\''`)}'` +} + +/** + * Normalize a prompt for typing into a PTY: collapse every line break (`\r`, `\n`, + * `\r\n`) to a single space — a bare newline keystroke would submit a partial line + * before `claude` starts — then trim and cap at FANOUT_PROMPT_MAX. Pure. + */ +export function sanitizePrompt(s: string): string { + return s.replace(/[\r\n]+/g, ' ').trim().slice(0, FANOUT_PROMPT_MAX) +} + +/** + * Build the `claude` launch command for one lane. Single-quotes the sanitized + * prompt (injection-safe), prefixes `--permission-mode ` for non-default modes, + * and ends with `\r` so the shell auto-executes it. 'auto' is downgraded to + * 'default' when the server forbids it (SEC-M5 parity with resolveMode). Pure. + */ +export function buildFanoutCmd( + prompt: string, + mode: PermissionMode, + allowAutoMode: boolean, +): string { + const effective: PermissionMode = mode === 'auto' && !allowAutoMode ? 'default' : mode + const flag = effective === 'default' ? '' : `--permission-mode ${effective} ` + return `claude ${flag}${shellSingleQuote(sanitizePrompt(prompt))}\r` +} + +/** Lane branch name: `${base}-lane-${i}` (i is 1-based). Pure. */ +export function laneBranch(base: string, i: number): string { + return `${base}-lane-${i}` +} + +/** + * Derive a safe git-branch-base slug from a free-text prompt: lowercase, non- + * alphanumeric runs → single `-`, strip leading/trailing dashes, cap at 40 chars. + * Falls back to 'fanout' when nothing survives. The result is a valid branch base + * (passes validateBranchNameClient). Pure. + */ +export function slugify(prompt: string): string { + const slug = prompt + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, '') + .slice(0, 40) + .replace(/-+$/g, '') + return slug || 'fanout' +} diff --git a/public/projects.ts b/public/projects.ts index 6e17f6d..5cc21d8 100644 Binary files a/public/projects.ts and b/public/projects.ts differ diff --git a/public/style.css b/public/style.css index 695cc6d..8c2fa04 100644 --- a/public/style.css +++ b/public/style.css @@ -456,6 +456,20 @@ body { color: var(--text); background: var(--surface-3); } +.cell-keep { + border: 0; + background: transparent; + color: var(--text-faint); + cursor: pointer; + font-size: 13px; + line-height: 1; + padding: 2px 4px; + border-radius: 5px; +} +.cell-keep:hover { + color: var(--text); + background: var(--surface-3); +} .cell-monitor-btn { border: 0; background: transparent; @@ -1944,6 +1958,60 @@ body { text-transform: uppercase; color: var(--text-faint); } +/* W5 fan-out form — sibling of the New Worktree form. */ +.proj-fanout-form { + display: flex; + flex-direction: column; + gap: 8px; +} +.proj-fanout-prompt { + width: 100%; + box-sizing: border-box; + resize: vertical; + min-height: 44px; + font: inherit; +} +.proj-fanout-row { + display: flex; + gap: 8px; + align-items: center; + flex-wrap: wrap; +} +.proj-fanout-lanes { + width: 72px; +} +.proj-fanout-branch { + flex: 1 1 160px; + min-width: 120px; +} +.proj-fanout-error { + color: var(--danger, #e5534b); + font-size: 12px; +} +.proj-fanout-submit { + align-self: flex-start; +} +.proj-fanout-submit:disabled { + opacity: 0.5; + cursor: not-allowed; +} +/* W5 fan-out status/error banner — fixed, dismissible, textContent only. */ +.fanout-banner { + position: fixed; + left: 50%; + bottom: 16px; + transform: translateX(-50%); + max-width: min(680px, 92vw); + z-index: 60; + padding: 10px 14px; + border-radius: 8px; + background: var(--surface-3, #2a2c33); + color: var(--text, #e7e8ec); + border: 1px solid var(--border, #3a3d46); + box-shadow: 0 6px 24px rgba(0, 0, 0, 0.35); + font-size: 13px; + cursor: pointer; +} /* Section header with an inline action (CLAUDE.md generate/update). */ .proj-section-row { display: flex; diff --git a/public/tabs.ts b/public/tabs.ts index 2c0031e..25a3180 100644 --- a/public/tabs.ts +++ b/public/tabs.ts @@ -25,8 +25,21 @@ import { TerminalSession } from './terminal-session.js' import { ICON_TIMELINE } from './icons.js' import { THEMES, DEFAULT_SETTINGS, type Settings } from './settings.js' import { mountLauncher, type Launcher } from './launcher.js' -import { mountProjects, type ProjectsPanel } from './projects.js' -import type { ApprovalPreview, ClaudeStatus, PermissionMode, UiConfig } from '../src/types.js' +import { + mountProjects, + createWorktreeReq, + removeWorktreeReq, + validateBranchNameClient, + type ProjectsPanel, +} from './projects.js' +import { buildFanoutCmd, laneBranch, FANOUT_MAX_LANES } from './fanout.js' +import type { + ApprovalPreview, + ClaudeStatus, + FanoutLaunchOpts, + PermissionMode, + UiConfig, +} from '../src/types.js' import { renderDiffFile } from './diff.js' import { renderTelemetryGauge } from './preview-grid.js' import { mountPushToggle } from './push.js' @@ -72,12 +85,35 @@ const ALL_PERMISSION_MODES: readonly PermissionMode[] = ['default', 'acceptEdits type HomeView = 'sessions' | 'projects' +/** W5: one lane of a fan-out group — its worktree + branch (the session that runs + * in it is tracked via the tagged TabEntry, matched by TabEntry.fanoutGroupId). */ +interface FanoutLane { + branch: string + worktreePath: string +} + +/** W5: an in-memory fan-out group — one prompt fanned across N lanes of one repo. + * Lives only for the session (v1): reload does not reconstruct the board (matches + * v0.5 "no auto-restore"); keepFanoutWinner operates on the currently-open lanes. */ +interface FanoutGroup { + id: string + repoPath: string + repoName: string + prompt: string + lanes: FanoutLane[] +} + interface TabEntry { session: TerminalSession customTitle: string | null // user-set; null = use auto/fallback (persisted) autoTitle: string | null // current folder from the terminal title hasActivity: boolean // inactive tab got output since last viewed el: HTMLDivElement | null // the .tab element (updated in place) + // W5: when set, this tab is a lane of a fan-out group (id) with its own worktree + // + branch; the cell gets a 🏆 Keep button that resolves the race for this lane. + fanoutGroupId?: string + worktreePath?: string + branch?: string // Split-grid: the .term-cell wrapper around session.el (header + terminal + // optional inline-approve footer). One per tab; the grid lays these out. cell: HTMLDivElement | null @@ -144,6 +180,12 @@ export class TabApp { // W3(b): the server COST_BUDGET_USD (from /config/ui); 0 = disabled. The per-tab // gauge warn-styles the cost chip once costUsd >= this budget. private costBudgetUsd = 0 + // W5 fan-out board: the server MAX_FANOUT_LANES (from /config/ui) — the hard cap + // launchFanout clamps to (alongside grid-6 capacity + the server DoS cap). + private maxFanoutLanes = FANOUT_MAX_LANES + // W5: in-memory fan-out group model (session-scoped; not persisted in v1). + private readonly fanoutGroups = new Map() + private fanoutBanner: HTMLElement | null = null private pushHost!: HTMLElement // A1: 🔔 host, mounted once, re-parented per rebuild private timelinePanel!: HTMLElement // A4: shared timeline panel (one mounted at a time) private timelineOpen = false @@ -180,6 +222,7 @@ export class TabApp { this.projects = mountProjects(this.paneHost, { onOpenProject: (repoPath, repoName, cmd) => this.openProject(repoPath, repoName, cmd), onEnterSession: (id) => this.openSession(id), + onFanout: (repoPath, repoName, opts) => void this.launchFanout(repoPath, repoName, opts), }) this.segControl = this.buildSegControl() @@ -195,6 +238,7 @@ export class TabApp { this.setupQuickReply() // A3 chips above the key bar this.setupTimelinePanel() // A4 activity timeline this.setupVoiceOverlay() // A2 interim-transcript overlay + this.setupFanoutBanner() // W5 fan-out status/error banner void this.loadUiConfig() // B4 allowAutoMode gate (best-effort) // v0.5: do NOT auto-create or auto-restore tabs. Land on the home screen; @@ -264,6 +308,35 @@ export class TabApp { this.voiceConfirmOverlay = confirmOverlay } + /** W5: a dismissible banner for fan-out status/errors (partial-launch, keep- + * winner failures). textContent only (SEC-L3/H6); tap or auto-hide to clear. */ + private setupFanoutBanner(): void { + const banner = document.createElement('div') + banner.id = 'fanout-banner' + banner.className = 'fanout-banner' + banner.style.display = 'none' + banner.addEventListener('click', () => { + banner.style.display = 'none' + }) + document.body.appendChild(banner) + this.fanoutBanner = banner + } + + private fanoutBannerTimer: ReturnType | null = null + + /** Show a fan-out banner message (textContent only). Auto-hides after 8s. */ + private showFanoutBanner(msg: string): void { + const banner = this.fanoutBanner + if (!banner) return + banner.textContent = msg // SEC-L3/H6: textContent, never innerHTML + banner.style.display = 'block' + if (this.fanoutBannerTimer !== null) clearTimeout(this.fanoutBannerTimer) + this.fanoutBannerTimer = setTimeout(() => { + banner.style.display = 'none' + this.fanoutBannerTimer = null + }, 8000) + } + /** B4: fetch the server UI config (allowAutoMode). Best-effort, never throws. */ private async loadUiConfig(): Promise { try { @@ -283,6 +356,11 @@ export class TabApp { if (typeof budget === 'number' && Number.isFinite(budget) && budget > 0) { this.costBudgetUsd = budget } + // W5: read the fan-out lane cap (optional; older servers omit → keep default). + const lanes = (data as Record)?.['maxFanoutLanes'] + if (typeof lanes === 'number' && Number.isFinite(lanes) && lanes >= 2) { + this.maxFanoutLanes = Math.floor(lanes) + } } catch { // best-effort — leave allowAutoMode false (auto hidden) on any failure } @@ -798,7 +876,23 @@ export class TabApp { e.stopPropagation() this.toggleMonitor(this.tabs.indexOf(entry)) }) - head.append(monBtn, maxBtn) + // W5: 🏆 keep-winner — only shown (via renderCell) when this tab is a fan-out + // lane. Resolves the race: keep this lane, discard the others' worktrees. + const keepBtn = document.createElement('button') + keepBtn.type = 'button' + keepBtn.className = 'cell-keep' + keepBtn.textContent = '🏆' + keepBtn.title = 'Keep this lane, discard the others' + keepBtn.setAttribute('aria-label', 'Keep this fan-out lane as the winner') + keepBtn.style.display = 'none' + keepBtn.addEventListener('pointerdown', (e) => e.stopPropagation()) + keepBtn.addEventListener('click', (e) => { + e.stopPropagation() + if (entry.fanoutGroupId !== undefined) { + void this.keepFanoutWinner(entry.fanoutGroupId, entry.session.id ?? '') + } + }) + head.append(monBtn, keepBtn, maxBtn) // v2: drag a tab from the tab bar onto this quadrant to assign it here. this.wireCellDropTarget(cell, () => this.tabs.indexOf(entry)) cell.append(head, session.el) @@ -852,6 +946,134 @@ export class TabApp { ).length } + /* ── W5 fan-out board ────────────────────────────────────────────────── */ + + /** + * Fan ONE prompt across N lanes of `repoPath`: create N worktrees SEQUENTIALLY + * (git's `worktree add` takes a repo lock — parallel adds race), then open one + * Claude session per created lane pre-injected with the same shell-quoted prompt, + * and lay them out on the split-grid board. Composition of shipped parts — + * createWorktreeReq + addEntry (openProject shape) + setGridLayout. Partial + * success is surfaced in the banner (no auto-rollback in v1); never throws. + */ + async launchFanout(repoPath: string, repoName: string, opts: FanoutLaunchOpts): Promise { + const base = opts.branchBase.trim() + const branchErr = validateBranchNameClient(base) + if (branchErr !== null) { + this.showFanoutBanner(`Cannot fan out — ${branchErr}.`) + return + } + // Effective N: min(requested, server cap, grid-6 capacity). The remaining + // maxSessions − liveCount bound is enforced server-side (assertUnderSessionCap + // throws → that lane shows exit(-1); reported as "started K of N" below). + const n = Math.min( + Math.max(2, Math.floor(opts.lanes)), + this.maxFanoutLanes, + FANOUT_MAX_LANES, + ) + // Build the launch command ONCE — every lane runs the identical prompt. + const cmd = buildFanoutCmd(opts.prompt, this.resolveMode(opts.mode ?? 'default'), this.allowAutoMode) + + const groupId = `fanout-${Date.now()}-${Math.random().toString(36).slice(2, 8)}` + const lanes: FanoutLane[] = [] + const failures: string[] = [] + for (let i = 1; i <= n; i++) { + const branch = laneBranch(base, i) + const r = await createWorktreeReq(repoPath, branch) // sequential (repo lock) + if (r.ok && typeof r.path === 'string') { + lanes.push({ branch, worktreePath: r.path }) + } else { + failures.push(`${branch}: ${r.error ?? 'failed'}`) + } + } + + if (lanes.length === 0) { + this.showFanoutBanner(`Fan-out failed — no lanes created. ${failures.join('; ')}`) + return + } + + const group: FanoutGroup = { id: groupId, repoPath, repoName, prompt: opts.prompt, lanes } + this.fanoutGroups.set(groupId, group) + + const created: TabEntry[] = [] + for (const lane of lanes) { + const entry = this.addEntry(null, `${repoName}·${lane.branch}`, lane.worktreePath || undefined, cmd) + entry.fanoutGroupId = groupId + entry.worktreePath = lane.worktreePath + entry.branch = lane.branch + created.push(entry) + } + this.persist() + this.rebuild() + // Watch board: 2×2 up to 4 lanes, else 2×3 (grid-6 holds up to 6). + this.setGridLayout(lanes.length <= 4 ? 'grid-4' : 'grid-6') + const first = created[0] + if (first) this.activate(this.tabs.indexOf(first)) + + if (failures.length > 0) { + this.showFanoutBanner(`Started ${lanes.length} of ${n} lanes. Failed: ${failures.join('; ')}`) + } + } + + /** + * Keep the winning lane and discard the others: a single batch confirm, then + * each losing lane's tab is closed (detach — its PTY is reclaimed by IDLE_TTL) + * and its worktree removed (auto-forcing on a 409 dirty tree, inside the one + * confirm — no per-lane prompt). The winner's tab + worktree stay so the user + * runs the merge inside it (one-click merge is out of v1 scope). Never throws. + */ + async keepFanoutWinner(groupId: string, winnerSessionId: string): Promise { + const group = this.fanoutGroups.get(groupId) + if (!group) return + const laneTabs = this.tabs.filter((t) => t.fanoutGroupId === groupId) + const winner = laneTabs.find((t) => t.session.id === winnerSessionId) + if (!winner) { + // No lane matches — refuse rather than treat every lane (incl. the intended + // winner) as a loser and delete them all. Happens if the winner hasn't + // attached yet (session.id still null). + this.showFanoutBanner('Cannot keep this lane yet — it has not connected.') + return + } + const losers = laneTabs.filter((t) => t !== winner) + const winnerBranch = winner.branch ?? winnerSessionId + if ( + !window.confirm( + `Keep ${winnerBranch} and discard the other ${losers.length} lane(s) (delete their worktrees)?`, + ) + ) { + return + } + + const failures: string[] = [] + for (const loser of losers) { + const idx = this.tabs.indexOf(loser) + if (idx >= 0) this.closeTab(idx) // detach; PTY reaped by IDLE_TTL + const wt = loser.worktreePath + if (wt !== undefined && wt !== '') { + let r = await removeWorktreeReq(group.repoPath, wt, false) + if (!r.ok && r.status === 409) { + r = await removeWorktreeReq(group.repoPath, wt, true) // dirty → force (batch-confirmed) + } + if (!r.ok) failures.push(`${loser.branch ?? wt}: ${r.error ?? 'failed'}`) + } + } + + this.fanoutGroups.delete(groupId) + winner.fanoutGroupId = undefined // drop the 🏆 button — the race is resolved + + if (losers.length > 0) { + this.setGridLayout('single') + const wi = this.tabs.indexOf(winner) + if (wi >= 0) this.activate(wi) + } + this.persist() + this.rebuild() + + if (failures.length > 0) { + this.showFanoutBanner(`Kept ${winnerBranch}. Some worktrees could not be removed: ${failures.join('; ')}`) + } + } + activate(i: number): void { if (i < 0 || i >= this.tabs.length) return // Board-aware focus: in a split grid the focused pane must be ON the board, @@ -1344,6 +1566,9 @@ export class TabApp { if (maxBtn) maxBtn.textContent = maximized ? '⤡' : '⛶' const monBtn = cell.querySelector('.cell-monitor-btn') if (monBtn) monBtn.classList.toggle('active', grid && entry.monitor) + // W5: the 🏆 keep button appears only on a fan-out lane's cell (any layout). + const keepBtn = cell.querySelector('.cell-keep') + if (keepBtn) keepBtn.style.display = entry.fanoutGroupId !== undefined ? '' : 'none' const nameEl = cell.querySelector('.cell-name') if (nameEl) nameEl.textContent = this.displayTitle(entry, idx) diff --git a/src/config.ts b/src/config.ts index 6d2b4a6..156b73c 100644 --- a/src/config.ts +++ b/src/config.ts @@ -65,6 +65,8 @@ const DEFAULT_GH_TIMEOUT_MS = 8_000 const DEFAULT_STATUSLINE_TTL_MS = 30_000 // B3 worktrees const DEFAULT_WORKTREE_TIMEOUT_MS = 10_000 +// W5 fan-out board — max lanes one task may be fanned across (matches grid-6 capacity) +const DEFAULT_MAX_FANOUT_LANES = 6 // W4 git write (stage / commit / push) const DEFAULT_GIT_OPS_TIMEOUT_MS = 10_000 // stage/commit exec bound (local, like worktree) const DEFAULT_GIT_PUSH_TIMEOUT_MS = 120_000 // push is network-bound → longer bound @@ -410,6 +412,14 @@ export function loadConfig(env: EnvLike): Config { DEFAULT_WORKTREE_TIMEOUT_MS, ) + // W5 fan-out board — cap on lanes one task may be fanned across (DoS guard, + // like MAX_SESSIONS). 0 is allowed (disables fan-out) via parseNonNegativeInt. + const maxFanoutLanes = parseNonNegativeInt( + env['MAX_FANOUT_LANES'], + 'MAX_FANOUT_LANES', + DEFAULT_MAX_FANOUT_LANES, + ) + // W4 git write (stage / commit / push) — the highest-risk write channel const gitOpsEnabled = parseBool(env['GIT_OPS_ENABLED'], true) // master kill-switch const gitOpsTimeoutMs = parseNonNegativeInt( @@ -497,6 +507,8 @@ export function loadConfig(env: EnvLike): Config { worktreeEnabled, worktreeRoot, worktreeTimeoutMs, + // W5 fan-out board + maxFanoutLanes, // W4 git write (stage / commit / push) gitOpsEnabled, gitOpsTimeoutMs, diff --git a/src/http/session-groups.ts b/src/http/session-groups.ts new file mode 100644 index 0000000..751999c --- /dev/null +++ b/src/http/session-groups.ts @@ -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 `-worktrees/` (createWorktree's base, + * worktrees.ts computeWorktreeDir), so a session whose cwd is + * `-worktrees/` 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 + * `-worktrees` folder, the repo root is `/` (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() + 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)!) +} diff --git a/src/server.ts b/src/server.ts index ae8ef5e..138a315 100644 --- a/src/server.ts +++ b/src/server.ts @@ -45,6 +45,7 @@ import { buildDigest, clampSince } from './http/digest.js' import { getPrStatus } from './http/gh.js' import { parseStatusLine } from './http/statusline.js' import { createWorktree, removeWorktree, pruneWorktrees } from './http/worktrees.js' +import { groupSessionsByRepo } from './http/session-groups.js' import { stageFiles, commit as gitCommit, push as gitPush } from './http/git-ops.js' import { createSessionManager } from './session/manager.js' import { detachWs, writeInput, setClientDims } from './session/session.js' @@ -333,6 +334,14 @@ export function startServer(cfg: Config): { close(): Promise } { res.json(manager.list()) }) + // ── W5 fan-out board: running sessions clustered by repo (read-only) ────── + // Pure string grouping over manager.list() (no git/FS work); same threat model + // as /live-sessions and /digest → no Origin guard. Registered BEFORE the + // /live-sessions/:id routes so "grouped" is never captured as an :id. + app.get('/live-sessions/grouped', (_req, res) => { + res.json(groupSessionsByRepo(manager.list())) + }) + // ── W3 quick-wins (c): "while you were away" reconnect digest (read-only) ── // A pure read-side aggregate over manager.list() + in-memory telemetry/status; // no Origin guard (same threat model as /live-sessions). `?since=` is @@ -1102,6 +1111,8 @@ export function startServer(cfg: Config): { close(): Promise } { // W3(b): expose the cost budget only when set (>0) so the FE can derive // cost-overage warn styling; a non-secret number, safe over /config/ui. ...(cfg.costBudgetUsd > 0 ? { costBudgetUsd: cfg.costBudgetUsd } : {}), + // W5 fan-out board: let the FE stepper max mirror MAX_FANOUT_LANES. + maxFanoutLanes: cfg.maxFanoutLanes, } res.json(uiConfig) }) diff --git a/src/types.ts b/src/types.ts index f0dd6a0..c806b7d 100644 --- a/src/types.ts +++ b/src/types.ts @@ -72,6 +72,8 @@ export interface Config { readonly worktreeEnabled: boolean; // WORKTREE_ENABLED, default true readonly worktreeRoot: string | undefined; // WORKTREE_ROOT (undefined → computed) readonly worktreeTimeoutMs: number; // WORKTREE_TIMEOUT_MS, default 10000 + // W5 fan-out board — cap on lanes one task may be fanned across (grid-6 capacity) + readonly maxFanoutLanes: number; // MAX_FANOUT_LANES, default 6 // W4 git write (stage / commit / push) — the highest-risk write channel readonly gitOpsEnabled: boolean; // GIT_OPS_ENABLED, default true (kill-switch → all 3 routes 403) readonly gitOpsTimeoutMs: number; // GIT_OPS_TIMEOUT_MS, default 10000 (stage/commit exec bound) @@ -311,6 +313,27 @@ export interface LiveSessionInfo { readonly queueLength?: number; } +/* ───────────────── W5 fan-out board (parallel agent lanes) ───────────────── */ + +/** A group of running sessions sharing a repo/worktree-root (fan-out discovery). + * Derived STRING-ONLY from each session's cwd (no git exec): fan-out worktrees + * live under `-worktrees/`, so sessions whose cwd shares that parent + * cluster into one group. impl: src/http/session-groups.ts groupSessionsByRepo. */ +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 so the form and the launcher agree on ONE shape). */ +export interface FanoutLaunchOpts { + prompt: string; + lanes: number; // 2..maxFanoutLanes (clamped to grid-6 capacity + the DoS cap) + branchBase: string; // slug; lanes get `${branchBase}-lane-${i}` + mode?: PermissionMode; // reuse PermissionMode +} + /* ───────────────── project manager (v0.6, §4.3 FEATURE doc) ──────────────── */ /** One running session belonging to a project (live-sessions归并 by cwd). @@ -658,6 +681,9 @@ export interface UiConfig { /** W3 quick-wins (b): the cost-budget threshold (USD). Present when > 0 so the * FE can derive cost-overage warn styling client-side; omitted when disabled. */ costBudgetUsd?: number; + /** W5 fan-out board: server-controlled cap on fan-out lanes so the FE stepper + * max mirrors MAX_FANOUT_LANES. Additive OPTIONAL (older clients default to 6). */ + maxFanoutLanes?: number; } /* ── W3 quick-wins (c) reconnect digest (GET /digest) ── */ diff --git a/test/config.test.ts b/test/config.test.ts index 7951433..4b9de49 100644 --- a/test/config.test.ts +++ b/test/config.test.ts @@ -299,6 +299,18 @@ describe('loadConfig — session/rate/timer constants', () => { expect(() => loadConfig({ MAX_SESSIONS: 'lots' })).toThrow() }) + it('defaults maxFanoutLanes to 6 (W5)', () => { + expect(loadConfig({}).maxFanoutLanes).toBe(6) + }) + + it('reads MAX_FANOUT_LANES from env', () => { + expect(loadConfig({ MAX_FANOUT_LANES: '3' }).maxFanoutLanes).toBe(3) + }) + + it('throws for a negative MAX_FANOUT_LANES (fail-fast, like MAX_SESSIONS)', () => { + expect(() => loadConfig({ MAX_FANOUT_LANES: '-1' })).toThrow() + }) + it('defaults maxMsgsPerSec to 2000 and reads MAX_MSGS_PER_SEC', () => { expect(loadConfig({}).maxMsgsPerSec).toBe(2000) expect(loadConfig({ MAX_MSGS_PER_SEC: '500' }).maxMsgsPerSec).toBe(500) diff --git a/test/fanout.test.ts b/test/fanout.test.ts new file mode 100644 index 0000000..f3eb2d0 --- /dev/null +++ b/test/fanout.test.ts @@ -0,0 +1,128 @@ +/** + * test/fanout.test.ts (W5) — pure launch-command + branch-slug builders. + * + * The load-bearing case is shellSingleQuote: the prompt is typed as raw bytes into + * a lane's shell before `claude` parses it, so it must become one literal argv + * element — no shell metacharacter may execute. + */ + +import { describe, it, expect } from 'vitest' + +import { + shellSingleQuote, + sanitizePrompt, + buildFanoutCmd, + laneBranch, + slugify, + FANOUT_PROMPT_MAX, +} from '../public/fanout.js' + +// Local mirror of validateBranchNameClient's rules (projects.ts) — inlined so this +// stays a pure node test (importing projects.js would drag in @xterm/xterm). +function isValidBranch(branch: string): boolean { + if (!branch || branch.length > 250) return false + if (/[\x00-\x20\x7f]/.test(branch)) return false + if (branch.startsWith('-') || branch.includes('..') || branch.endsWith('.lock')) return false + if (/[~^:?*[\\]/.test(branch)) return false + if (branch.includes('@{')) return false + if (branch.startsWith('/') || branch.endsWith('/') || branch.includes('//')) return false + return true +} + +describe('shellSingleQuote', () => { + it('single-quotes a plain string', () => { + expect(shellSingleQuote('fix bug')).toBe(`'fix bug'`) + }) + + it("escapes an embedded single quote with the '\\'' sequence", () => { + expect(shellSingleQuote("it's ok")).toBe(`'it'\\''s ok'`) + }) + + it('neutralizes shell metacharacters as literal text', () => { + // $(...), backticks, ;, && all sit inside single quotes → passed verbatim. + const dangerous = 'a; rm -rf / && echo $(whoami) `id`' + const quoted = shellSingleQuote(dangerous) + expect(quoted.startsWith("'")).toBe(true) + expect(quoted.endsWith("'")).toBe(true) + // No unescaped single quote can break out of the quoting. + expect(quoted.slice(1, -1).includes("'")).toBe(false) + }) +}) + +describe('sanitizePrompt', () => { + it('collapses newlines (\\n, \\r\\n) to single spaces', () => { + expect(sanitizePrompt('a\nb\r\nc')).toBe('a b c') + }) + + it('collapses a bare carriage return too (would submit a partial line)', () => { + expect(sanitizePrompt('a\rb')).toBe('a b') + }) + + it('trims surrounding whitespace', () => { + expect(sanitizePrompt(' hello ')).toBe('hello') + }) + + it('caps the length at FANOUT_PROMPT_MAX', () => { + const long = 'x'.repeat(FANOUT_PROMPT_MAX + 500) + expect(sanitizePrompt(long)).toHaveLength(FANOUT_PROMPT_MAX) + }) +}) + +describe('buildFanoutCmd', () => { + it('adds --permission-mode for a non-default mode and ends with \\r', () => { + expect(buildFanoutCmd('fix bug', 'plan', true)).toBe(`claude --permission-mode plan 'fix bug'\r`) + }) + + it('omits the flag for the default mode', () => { + expect(buildFanoutCmd('fix bug', 'default', true)).toBe(`claude 'fix bug'\r`) + }) + + it('downgrades auto to default when the server forbids it (SEC-M5)', () => { + expect(buildFanoutCmd('fix bug', 'auto', false)).toBe(`claude 'fix bug'\r`) + }) + + it('honors auto when the server allows it', () => { + expect(buildFanoutCmd('fix bug', 'auto', true)).toBe(`claude --permission-mode auto 'fix bug'\r`) + }) + + it('shell-quotes an injection-style prompt into one argv element', () => { + const cmd = buildFanoutCmd('rm -rf $(pwd); echo hi', 'default', false) + expect(cmd).toBe(`claude 'rm -rf $(pwd); echo hi'\r`) + }) + + it('collapses a multi-line prompt before quoting', () => { + expect(buildFanoutCmd('line1\nline2', 'default', false)).toBe(`claude 'line1 line2'\r`) + }) +}) + +describe('laneBranch', () => { + it('builds `${base}-lane-${i}`', () => { + expect(laneBranch('feat-x', 3)).toBe('feat-x-lane-3') + }) + + it('produces a valid git branch name', () => { + expect(isValidBranch(laneBranch('feat-x', 3))).toBe(true) + expect(isValidBranch(laneBranch(slugify('Add dark mode!'), 1))).toBe(true) + }) +}) + +describe('slugify', () => { + it('lowercases and dash-collapses non-alphanumeric runs', () => { + expect(slugify('Add Dark Mode!')).toBe('add-dark-mode') + }) + + it('strips leading/trailing dashes', () => { + expect(slugify(' ...hello... ')).toBe('hello') + }) + + it('falls back to "fanout" for an empty/symbol-only prompt', () => { + expect(slugify('!!!')).toBe('fanout') + expect(slugify('')).toBe('fanout') + }) + + it('caps the slug length and stays a valid branch base', () => { + const slug = slugify('a'.repeat(100)) + expect(slug.length).toBeLessThanOrEqual(40) + expect(isValidBranch(slug)).toBe(true) + }) +}) diff --git a/test/http/session-groups.test.ts b/test/http/session-groups.test.ts new file mode 100644 index 0000000..cb67f16 --- /dev/null +++ b/test/http/session-groups.test.ts @@ -0,0 +1,119 @@ +/** + * test/http/session-groups.test.ts (W5) — pure grouping helpers, no I/O. + * + * deriveRepoRoot strips a `-worktrees/` parent to `<...>/`; + * groupSessionsByRepo clusters a repo + its worktree lanes into one SessionGroup, + * skips cwd:null, and preserves manager.list() (newest-first) order. + */ + +import { describe, it, expect } from 'vitest' + +import { deriveRepoRoot, groupSessionsByRepo } from '../../src/http/session-groups.js' +import type { LiveSessionInfo } from '../../src/types.js' + +function live(id: string, cwd: string | null): LiveSessionInfo { + return { + id, + createdAt: 0, + clientCount: 0, + status: 'unknown', + exited: false, + cwd, + cols: 80, + rows: 24, + } +} + +describe('deriveRepoRoot', () => { + it('strips a *-worktrees parent to the repo root', () => { + expect(deriveRepoRoot('/a/proj-worktrees/lane-1')).toBe('/a/proj') + }) + + it('returns the cwd unchanged when the parent is not a *-worktrees dir', () => { + expect(deriveRepoRoot('/a/proj')).toBe('/a/proj') + }) + + it('returns null for a null cwd', () => { + expect(deriveRepoRoot(null)).toBe(null) + }) + + it('returns null for an empty cwd', () => { + expect(deriveRepoRoot('')).toBe(null) + }) + + it('handles a nested repo path with a *-worktrees parent', () => { + expect(deriveRepoRoot('/home/me/code/web-terminal-worktrees/feat-x-lane-2')).toBe( + '/home/me/code/web-terminal', + ) + }) + + it('does not treat a bare "-worktrees" segment (no repo name) specially', () => { + // parentName === '-worktrees' has no repo name to keep → falls through to cwd. + expect(deriveRepoRoot('/a/-worktrees/x')).toBe('/a/-worktrees/x') + }) +}) + +describe('groupSessionsByRepo', () => { + it('returns [] for no sessions', () => { + expect(groupSessionsByRepo([])).toEqual([]) + }) + + it('clusters a repo and its worktree lanes into ONE group', () => { + const sessions = [ + live('s1', '/a/proj-worktrees/lane-1'), + live('s2', '/a/proj-worktrees/lane-2'), + ] + const groups = groupSessionsByRepo(sessions) + expect(groups).toHaveLength(1) + expect(groups[0]!.repoRoot).toBe('/a/proj') + expect(groups[0]!.label).toBe('proj') + expect(groups[0]!.sessions.map((s) => s.id)).toEqual(['s1', 's2']) + }) + + it('groups the main repo cwd together with its worktree lanes', () => { + const sessions = [ + live('main', '/a/proj'), + live('lane', '/a/proj-worktrees/feat-lane-1'), + ] + const groups = groupSessionsByRepo(sessions) + expect(groups).toHaveLength(1) + expect(groups[0]!.repoRoot).toBe('/a/proj') + expect(groups[0]!.sessions.map((s) => s.id)).toEqual(['main', 'lane']) + }) + + it('puts an unrelated cwd in its own group', () => { + const groups = groupSessionsByRepo([ + live('a', '/a/proj-worktrees/lane-1'), + live('b', '/b/other'), + ]) + expect(groups).toHaveLength(2) + expect(groups[0]!.repoRoot).toBe('/a/proj') + expect(groups[1]!.repoRoot).toBe('/b/other') + expect(groups[1]!.label).toBe('other') + }) + + it('skips sessions with a null cwd (not attributable to a repo)', () => { + const groups = groupSessionsByRepo([ + live('nocwd', null), + live('real', '/a/proj'), + ]) + expect(groups).toHaveLength(1) + expect(groups[0]!.repoRoot).toBe('/a/proj') + expect(groups[0]!.sessions.map((s) => s.id)).toEqual(['real']) + }) + + it('preserves the input (newest-first) order across and within groups', () => { + const groups = groupSessionsByRepo([ + live('newest', '/a/proj-worktrees/lane-2'), + live('older', '/a/proj-worktrees/lane-1'), + ]) + expect(groups[0]!.sessions.map((s) => s.id)).toEqual(['newest', 'older']) + }) + + it('does not mutate the input array', () => { + const input = [live('a', '/a/proj')] + const copy = [...input] + groupSessionsByRepo(input) + expect(input).toEqual(copy) + }) +}) diff --git a/test/integration/grouped-endpoint.test.ts b/test/integration/grouped-endpoint.test.ts new file mode 100644 index 0000000..4c4217f --- /dev/null +++ b/test/integration/grouped-endpoint.test.ts @@ -0,0 +1,172 @@ +/** + * Integration test for GET /live-sessions/grouped (W5 fan-out board) and the + * /config/ui maxFanoutLanes field. + * + * Starts a real HTTP server. The grouped route is read-only (no Origin guard) and + * derives groups purely from cwd — the empty-manager path needs no PTY. The + * real-PTY case (two sessions under one *-worktrees dir clustering into one group) + * is itPty-gated: posix_spawn is blocked in the sandbox, so it auto-skips there + * and runs in CI. + */ + +import net from 'node:net' +import fs from 'node:fs/promises' +import os from 'node:os' +import path from 'node:path' + +import { afterAll, afterEach, beforeAll, describe, expect, it } from 'vitest' +import WebSocket from 'ws' +import * as nodePty from 'node-pty' + +import { loadConfig } from '../../src/config.js' +import { startServer } from '../../src/server.js' +import type { SessionGroup, UiConfig } from '../../src/types.js' + +const PTY_AVAILABLE = (() => { + try { + const p = nodePty.spawn(process.env['SHELL'] ?? '/bin/sh', [], { cols: 80, rows: 24 }) + p.kill() + return true + } catch { + return false + } +})() +const itPty = PTY_AVAILABLE ? it : it.skip + +function getFreePort(): Promise { + return new Promise((resolve, reject) => { + const srv = net.createServer() + srv.listen(0, '127.0.0.1', () => { + const addr = srv.address() + if (addr === null || typeof addr === 'string') { + srv.close() + reject(new Error('unexpected address type')) + return + } + const port = addr.port + srv.close(() => resolve(port)) + }) + srv.on('error', reject) + }) +} + +function waitForOpen(ws: WebSocket, timeoutMs = 3_000): Promise { + return new Promise((resolve, reject) => { + const timer = setTimeout(() => reject(new Error('WS open timeout')), timeoutMs) + ws.once('open', () => { + clearTimeout(timer) + resolve() + }) + ws.once('error', (err) => { + clearTimeout(timer) + reject(err) + }) + }) +} + +/** Resolve once an 'attached' frame arrives (output frames may interleave). */ +function waitForAttached(ws: WebSocket, timeoutMs = 4_000): Promise { + return new Promise((resolve, reject) => { + const timer = setTimeout(() => reject(new Error('attached timeout')), timeoutMs) + const onMsg = (raw: WebSocket.RawData): void => { + let msg: { type?: string } + try { + msg = JSON.parse(raw.toString('utf8')) + } catch { + return + } + if (msg.type === 'attached') { + clearTimeout(timer) + ws.off('message', onMsg) + resolve() + } + } + ws.on('message', onMsg) + }) +} + +describe('GET /live-sessions/grouped + /config/ui maxFanoutLanes — integration', () => { + let port: number + let serverHandle: { close(): Promise } + const origin = (): string => `http://127.0.0.1:${port}` + + beforeAll(async () => { + port = await getFreePort() + const cfg = loadConfig({ + PORT: String(port), + BIND_HOST: '127.0.0.1', + SHELL_PATH: process.env['SHELL'] ?? '/bin/zsh', + ALLOWED_ORIGINS: `http://127.0.0.1:${port}`, + USE_TMUX: '0', + IDLE_TTL: '86400', + MAX_FANOUT_LANES: '5', + }) + serverHandle = startServer(cfg) + await new Promise((r) => setTimeout(r, 100)) + }) + + afterAll(async () => { + await serverHandle.close() + }) + + it('returns 200 [] on an empty manager, WITHOUT requiring an Origin header', async () => { + const res = await fetch(`${origin()}/live-sessions/grouped`) + expect(res.status).toBe(200) // read-only: not 403 even with no Origin + const body = (await res.json()) as SessionGroup[] + expect(Array.isArray(body)).toBe(true) + expect(body).toHaveLength(0) + }) + + it('does not shadow "grouped" as a session :id (returns an array, not an error)', async () => { + const res = await fetch(`${origin()}/live-sessions/grouped`) + const body = await res.json() + expect(Array.isArray(body)).toBe(true) + }) + + it('exposes maxFanoutLanes on /config/ui', async () => { + const res = await fetch(`${origin()}/config/ui`) + expect(res.status).toBe(200) + const body = (await res.json()) as UiConfig + expect(body.maxFanoutLanes).toBe(5) + }) + + // ── real-PTY: two sessions under one *-worktrees dir cluster into one group ── + let tmpRepo: string | null = null + const sockets: WebSocket[] = [] + + afterEach(() => { + for (const ws of sockets.splice(0)) { + try { + ws.close() + } catch { + // ignore + } + } + }) + + itPty('clusters two worktree-lane sessions into a single group', async () => { + tmpRepo = await fs.mkdtemp(path.join(os.tmpdir(), 'fanout-')) + const laneDir = path.join(tmpRepo, 'proj-worktrees') + const lane1 = path.join(laneDir, 'lane-1') + const lane2 = path.join(laneDir, 'lane-2') + await fs.mkdir(lane1, { recursive: true }) + await fs.mkdir(lane2, { recursive: true }) + + for (const cwd of [lane1, lane2]) { + const ws = new WebSocket(`ws://127.0.0.1:${port}/term`, { origin: origin() }) + sockets.push(ws) + await waitForOpen(ws) + ws.send(JSON.stringify({ type: 'attach', sessionId: null, cwd })) + await waitForAttached(ws) + } + // Give the sessions a beat to register their cwd. + await new Promise((r) => setTimeout(r, 100)) + + const res = await fetch(`${origin()}/live-sessions/grouped`) + const groups = (await res.json()) as SessionGroup[] + const proj = groups.find((g) => g.repoRoot === path.join(tmpRepo!, 'proj')) + expect(proj).toBeDefined() + expect(proj!.label).toBe('proj') + expect(proj!.sessions.length).toBe(2) + }) +}) diff --git a/test/integration/timeline-events.test.ts b/test/integration/timeline-events.test.ts index d78e831..e2c6a04 100644 --- a/test/integration/timeline-events.test.ts +++ b/test/integration/timeline-events.test.ts @@ -206,13 +206,13 @@ describe('GET /config/ui', () => { const { port } = await spawnServer() const res = await fetch(`http://127.0.0.1:${port}/config/ui`) expect(res.status).toBe(200) - expect(await res.json()).toEqual({ allowAutoMode: false }) + expect(await res.json()).toEqual({ allowAutoMode: false, maxFanoutLanes: 6 }) }) it('reports allowAutoMode:true when ALLOW_AUTO_MODE=1', async () => { const { port } = await spawnServer({ ALLOW_AUTO_MODE: '1' }) const res = await fetch(`http://127.0.0.1:${port}/config/ui`) - expect(await res.json()).toEqual({ allowAutoMode: true }) + expect(await res.json()).toEqual({ allowAutoMode: true, maxFanoutLanes: 6 }) }) it('omits costBudgetUsd when the budget is unset (W3 b)', async () => { @@ -225,6 +225,6 @@ describe('GET /config/ui', () => { it('reports costBudgetUsd when COST_BUDGET_USD is set (W3 b)', async () => { const { port } = await spawnServer({ COST_BUDGET_USD: '7.5' }) const res = await fetch(`http://127.0.0.1:${port}/config/ui`) - expect(await res.json()).toEqual({ allowAutoMode: false, costBudgetUsd: 7.5 }) + expect(await res.json()).toEqual({ allowAutoMode: false, costBudgetUsd: 7.5, maxFanoutLanes: 6 }) }) }) diff --git a/test/manager.test.ts b/test/manager.test.ts index 94534cd..5dbc0ac 100644 --- a/test/manager.test.ts +++ b/test/manager.test.ts @@ -86,6 +86,7 @@ const CFG: Config = { worktreeEnabled: true, worktreeRoot: undefined, worktreeTimeoutMs: 10_000, + maxFanoutLanes: 6, defaultPermissionMode: 'default', allowAutoMode: false, // W2 inject queue diff --git a/test/push-apns.test.ts b/test/push-apns.test.ts index f94bcae..fc47f86 100644 --- a/test/push-apns.test.ts +++ b/test/push-apns.test.ts @@ -114,6 +114,7 @@ const BASE_CFG: Config = { worktreeEnabled: true, worktreeRoot: undefined, worktreeTimeoutMs: 10_000, + maxFanoutLanes: 6, defaultPermissionMode: 'default', allowAutoMode: false, } diff --git a/test/push-fcm.test.ts b/test/push-fcm.test.ts index 50d4142..8c1309d 100644 --- a/test/push-fcm.test.ts +++ b/test/push-fcm.test.ts @@ -133,6 +133,7 @@ const BASE_CFG: Config = { worktreeEnabled: true, worktreeRoot: undefined, worktreeTimeoutMs: 10_000, + maxFanoutLanes: 6, defaultPermissionMode: 'default', allowAutoMode: false, } diff --git a/test/push/push-service.test.ts b/test/push/push-service.test.ts index 21c6964..9d4beba 100644 --- a/test/push/push-service.test.ts +++ b/test/push/push-service.test.ts @@ -55,6 +55,7 @@ const BASE_CFG: Config = { worktreeEnabled: true, worktreeRoot: undefined, worktreeTimeoutMs: 10_000, + maxFanoutLanes: 6, defaultPermissionMode: 'default', allowAutoMode: false, } diff --git a/test/tabs.test.ts b/test/tabs.test.ts index ddd2946..f6158fc 100644 --- a/test/tabs.test.ts +++ b/test/tabs.test.ts @@ -156,7 +156,26 @@ const mountProjects = vi.fn(() => ({ }, refresh: vi.fn(), })) -vi.mock('../public/projects.js', () => ({ mountProjects })) +// W5: TabApp imports these worktree helpers from projects.js for launchFanout / +// keepFanoutWinner. Mocked so we drive them without real fetch and assert routing. +const createWorktreeReqMock = vi.fn( + async (_repoPath: string, branch: string) => ({ ok: true, path: `/wt/${branch}` }) as { + ok: boolean + path?: string + status?: number + error?: string + }, +) +const removeWorktreeReqMock = vi.fn( + async () => ({ ok: true }) as { ok: boolean; status?: number; error?: string }, +) +const validateBranchNameClientMock = vi.fn((_b: string): string | null => null) +vi.mock('../public/projects.js', () => ({ + mountProjects, + createWorktreeReq: (...a: unknown[]) => createWorktreeReqMock(...(a as [string, string])), + removeWorktreeReq: (...a: unknown[]) => removeWorktreeReqMock(...(a as [])), + validateBranchNameClient: (...a: unknown[]) => validateBranchNameClientMock(...(a as [string])), +})) // settings.js is light but imports nothing heavy; let it load for real. @@ -200,6 +219,16 @@ beforeEach(() => { quickReply.onSend = undefined quickReply.onQueue = undefined enqueueFollowupMock.mockClear() + // W5 fan-out mocks: reset to permissive defaults each test. + createWorktreeReqMock.mockReset() + createWorktreeReqMock.mockImplementation(async (_repoPath: string, branch: string) => ({ + ok: true, + path: `/wt/${branch}`, + })) + removeWorktreeReqMock.mockReset() + removeWorktreeReqMock.mockResolvedValue({ ok: true }) + validateBranchNameClientMock.mockReset() + validateBranchNameClientMock.mockReturnValue(null) voice.onTranscript = undefined voice.onInterim = undefined // Default: server forbids 'auto' (loadUiConfig resolves to allowAutoMode=false). @@ -208,6 +237,7 @@ beforeEach(() => { afterEach(() => { vi.unstubAllGlobals() + vi.restoreAllMocks() // restore window.confirm etc. spied via vi.spyOn (W5 tests) }) describe('TabApp — v0.5 launcher chooser', () => { @@ -1718,3 +1748,169 @@ describe('TabApp — W2 inject queue', () => { expect(enqueueFollowupMock).not.toHaveBeenCalled() }) }) + +// ── W5: fan-out board (launchFanout + keepFanoutWinner) ────────────────────── +describe('TabApp — W5 fan-out board', () => { + const flush = (): Promise => new Promise((r) => setTimeout(r, 0)) + + /** Fan out N lanes and stamp server ids on the resulting sessions. */ + async function launched( + app: InstanceType, + lanes: number, + branchBase = 'feat', + ): Promise { + await app.launchFanout('/repo', 'repo', { prompt: 'add feature', lanes, branchBase, mode: 'default' }) + FakeTerminalSession.instances.forEach((inst, i) => (inst.id = `sess-${i}`)) + } + + const keepBtnOf = (i: number): HTMLButtonElement => + FakeTerminalSession.instances[i]!.el.closest('.term-cell')!.querySelector('.cell-keep')! + + it('launchFanout creates N worktrees SEQUENTIALLY, one tab per lane, on a grid-4 board', async () => { + const { paneHost, tabBar } = makeHosts() + const app = new TabApp(paneHost, tabBar) + + await app.launchFanout('/repo', 'repo', { + prompt: 'add feature', + lanes: 3, + branchBase: 'feat', + mode: 'default', + }) + + expect(app.snapshot()).toHaveLength(3) + expect(FakeTerminalSession.instances).toHaveLength(3) + // Sequential, in lane order. + expect(createWorktreeReqMock).toHaveBeenCalledTimes(3) + expect(createWorktreeReqMock.mock.calls.map((c) => c[1])).toEqual([ + 'feat-lane-1', + 'feat-lane-2', + 'feat-lane-3', + ]) + // Same shell-quoted launch command injected into EVERY lane. + for (const inst of FakeTerminalSession.instances) { + expect(inst.initialInput).toBe("claude 'add feature'\r") + } + // Each lane spawns in its own worktree cwd. + const cwds = FakeTerminalSession.instances.map( + (i) => (i.cbs as Record)['cwd'], + ) + expect(cwds).toEqual(['/wt/feat-lane-1', '/wt/feat-lane-2', '/wt/feat-lane-3']) + expect(app.getGridLayout()).toBe('grid-4') + }) + + it('launchFanout uses grid-6 for more than four lanes', async () => { + const { paneHost, tabBar } = makeHosts() + const app = new TabApp(paneHost, tabBar) + await launched(app, 5) + expect(app.snapshot()).toHaveLength(5) + expect(app.getGridLayout()).toBe('grid-6') + }) + + it('launchFanout refuses an invalid branch base (no worktrees, banner shown)', async () => { + validateBranchNameClientMock.mockReturnValue('bad branch') + const { paneHost, tabBar } = makeHosts() + const app = new TabApp(paneHost, tabBar) + await app.launchFanout('/repo', 'repo', { prompt: 'x', lanes: 3, branchBase: '-bad', mode: 'default' }) + expect(createWorktreeReqMock).not.toHaveBeenCalled() + expect(app.snapshot()).toHaveLength(0) + expect(document.getElementById('fanout-banner')!.style.display).toBe('block') + }) + + it('launchFanout tolerates a partial failure — records it in the banner, never throws', async () => { + createWorktreeReqMock.mockImplementation(async (_repo: string, branch: string) => + branch.endsWith('-2') + ? { ok: false, status: 500, error: 'boom' } + : { ok: true, path: `/wt/${branch}` }, + ) + const { paneHost, tabBar } = makeHosts() + const app = new TabApp(paneHost, tabBar) + await expect( + app.launchFanout('/repo', 'repo', { prompt: 'x', lanes: 3, branchBase: 'feat', mode: 'default' }), + ).resolves.toBeUndefined() + expect(app.snapshot()).toHaveLength(2) // lanes 1 and 3 created + const banner = document.getElementById('fanout-banner')! + expect(banner.style.display).toBe('block') + expect(banner.textContent).toContain('Started 2 of 3') + }) + + it('every created lane cell shows a 🏆 Keep button (fan-out lanes only)', async () => { + const { paneHost, tabBar } = makeHosts() + const app = new TabApp(paneHost, tabBar) + await launched(app, 3) + for (let i = 0; i < 3; i++) expect(keepBtnOf(i).style.display).not.toBe('none') + }) + + it('a plain (non-fan-out) tab has no visible Keep button', () => { + const { paneHost, tabBar } = makeHosts() + const app = new TabApp(paneHost, tabBar) + app.newTab() + const cell = FakeTerminalSession.instances[0]!.el.closest('.term-cell')! + expect((cell.querySelector('.cell-keep') as HTMLElement).style.display).toBe('none') + }) + + it('keepFanoutWinner keeps the winner, closes losers, removes their worktrees, layout → single', async () => { + const { paneHost, tabBar } = makeHosts() + const app = new TabApp(paneHost, tabBar) + await launched(app, 3) + vi.spyOn(window, 'confirm').mockReturnValue(true) + + keepBtnOf(0).click() // keep sess-0 (feat-lane-1) + await flush() + + expect(app.snapshot()).toHaveLength(1) // only the winner remains + expect(app.activeSessionId()).toBe('sess-0') + // One DELETE per loser, force=false, with the loser's worktree path. + expect(removeWorktreeReqMock).toHaveBeenCalledTimes(2) + const paths = removeWorktreeReqMock.mock.calls.map((c) => (c as unknown[])[1]).sort() + expect(paths).toEqual(['/wt/feat-lane-2', '/wt/feat-lane-3']) + expect(removeWorktreeReqMock.mock.calls.every((c) => (c as unknown[])[2] === false)).toBe(true) + expect(app.getGridLayout()).toBe('single') + }) + + it('keepFanoutWinner force-removes a dirty (409) loser without a second confirm', async () => { + const { paneHost, tabBar } = makeHosts() + const app = new TabApp(paneHost, tabBar) + await launched(app, 2) + removeWorktreeReqMock + .mockResolvedValueOnce({ ok: false, status: 409, error: 'dirty' }) + .mockResolvedValueOnce({ ok: true }) + const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(true) + + keepBtnOf(0).click() // keep sess-0; one loser (sess-1) + await flush() + + expect(removeWorktreeReqMock).toHaveBeenCalledTimes(2) + expect((removeWorktreeReqMock.mock.calls[0] as unknown[])[2]).toBe(false) // first try + expect((removeWorktreeReqMock.mock.calls[1] as unknown[])[2]).toBe(true) // forced retry + expect(confirmSpy).toHaveBeenCalledTimes(1) // only the batch confirm + }) + + it('keepFanoutWinner cancelled → no tabs closed, no worktree removed', async () => { + const { paneHost, tabBar } = makeHosts() + const app = new TabApp(paneHost, tabBar) + await launched(app, 3) + vi.spyOn(window, 'confirm').mockReturnValue(false) + + keepBtnOf(0).click() + await flush() + + expect(app.snapshot()).toHaveLength(3) // nothing closed + expect(removeWorktreeReqMock).not.toHaveBeenCalled() + }) + + it('keepFanoutWinner refuses when the winner has not attached (null id) — deletes nothing', async () => { + const { paneHost, tabBar } = makeHosts() + const app = new TabApp(paneHost, tabBar) + // Fan out but do NOT stamp ids (sessions still null). + await app.launchFanout('/repo', 'repo', { prompt: 'x', lanes: 3, branchBase: 'feat', mode: 'default' }) + const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(true) + + keepBtnOf(0).click() // entry.session.id is null → winnerSessionId '' + await flush() + + expect(confirmSpy).not.toHaveBeenCalled() // refused before the confirm + expect(removeWorktreeReqMock).not.toHaveBeenCalled() + expect(app.snapshot()).toHaveLength(3) + expect(document.getElementById('fanout-banner')!.style.display).toBe('block') + }) +}) diff --git a/test/worktree-form.test.ts b/test/worktree-form.test.ts index c521793..9c5abcd 100644 --- a/test/worktree-form.test.ts +++ b/test/worktree-form.test.ts @@ -44,6 +44,7 @@ vi.mock('../public/gh-chip.js', () => ({ mountPrChip: mockMountPrChip })) const { validateBranchNameClient, renderNewWorktreeForm, + renderFanoutForm, renderProjectDetail, makeWorktreeRow, confirmAndRemoveWorktree, @@ -53,7 +54,7 @@ const { /* ── Helpers ───────────────────────────────────────────────────────────────── */ function makeHooks() { - return { onOpenProject: vi.fn(), onEnterSession: vi.fn() } + return { onOpenProject: vi.fn(), onEnterSession: vi.fn(), onFanout: vi.fn() } } function makeCbs() { @@ -286,6 +287,7 @@ describe('renderNewWorktreeForm', () => { // Flush async microtasks await Promise.resolve() await Promise.resolve() + await new Promise((r) => setTimeout(r, 0)) expect(hooks.onOpenProject).toHaveBeenCalledWith( '/home/user/my-repo-worktrees/feat', @@ -309,6 +311,7 @@ describe('renderNewWorktreeForm', () => { await Promise.resolve() await Promise.resolve() + await new Promise((r) => setTimeout(r, 0)) expect(hooks.onOpenProject).toHaveBeenCalledWith('/repo', 'feat', 'claude\r') }) @@ -327,6 +330,7 @@ describe('renderNewWorktreeForm', () => { await Promise.resolve() await Promise.resolve() + await new Promise((r) => setTimeout(r, 0)) const errorEl = form.querySelector('.proj-wt-error') as HTMLElement expect(errorEl.style.display).not.toBe('none') @@ -345,6 +349,7 @@ describe('renderNewWorktreeForm', () => { await Promise.resolve() await Promise.resolve() + await new Promise((r) => setTimeout(r, 0)) const errorEl = form.querySelector('.proj-wt-error') as HTMLElement expect(errorEl.textContent).toBeTruthy() @@ -365,6 +370,7 @@ describe('renderNewWorktreeForm', () => { await Promise.resolve() await Promise.resolve() + await new Promise((r) => setTimeout(r, 0)) const errorEl = form.querySelector('.proj-wt-error') as HTMLElement // No