feat(fanout): worktree fan-out board — race N agent lanes of one repo (W5)
Fan ONE prompt across N branch/agent lanes: N worktrees, N Claude sessions on the
same prompt, watched side-by-side in the split-grid board, approve/kill per lane,
🏆 keep the winner (losers' worktrees removed). ~90% composition of shipped parts.
- src/http/session-groups.ts (new, pure, no git exec): deriveRepoRoot /
groupSessionsByRepo (cluster sessions by their <repo>-worktrees parent).
- GET /live-sessions/grouped (read-only; registered BEFORE /live-sessions/:id so
"grouped" isn't captured as an id). MAX_FANOUT_LANES env (default 6, = grid-6 cap).
- public/fanout.ts (new, pure): buildFanoutCmd shell-quotes the prompt (single-quote
wrap with '\''-escaping so $(...)/backticks/;/&& can't execute) + collapses newlines
+ caps 4000 chars; laneBranch/slugify. Effective N = min(lanes, maxFanoutLanes, 6),
≥2; the maxSessions cap is enforced server-side ("Started K of N" banner).
- public/tabs.ts launchFanout (N× createWorktree → openProject w/ prompt pre-injected
via the existing initialInput) + keepFanoutWinner; public/projects.ts renderFanoutForm
+ extracted shared createWorktreeReq (DRY). Byte-shuttle preserved (lane = own PTY).
One-click merge deferred (winner session stays open for a manual merge).
Reused unchanged: createWorktree/removeWorktree, addEntry/initialInput, split-grid +
per-quadrant approve/maximize/monitor + gauges. Verified: typecheck + build:web clean,
2063 pass at --test-timeout=30000 (only the known tmux/PTY flake red). Fixed 3
/config/ui exact-shape tests to include the new maxFanoutLanes field.
This commit is contained in:
@@ -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,
|
||||
|
||||
57
src/http/session-groups.ts
Normal file
57
src/http/session-groups.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
/**
|
||||
* src/http/session-groups.ts (W5 fan-out board) — cluster running sessions by
|
||||
* the repo they belong to, for the fan-out discovery endpoint.
|
||||
*
|
||||
* PURE + STRING-ONLY (no `git` exec, no filesystem I/O) — fully node-unit-testable.
|
||||
* Fan-out worktrees always live under `<repo>-worktrees/` (createWorktree's base,
|
||||
* worktrees.ts computeWorktreeDir), so a session whose cwd is
|
||||
* `<repo>-worktrees/<lane>` shares a group with the repo and its sibling lanes.
|
||||
* A session that is NOT under a `*-worktrees` parent groups under its own cwd.
|
||||
*/
|
||||
|
||||
import path from 'node:path'
|
||||
import type { LiveSessionInfo, SessionGroup } from '../types.js'
|
||||
|
||||
/** The suffix createWorktree appends to derive the worktree base dir name. */
|
||||
const WORKTREES_SUFFIX = '-worktrees'
|
||||
|
||||
/**
|
||||
* Derive the repo root for a session cwd. If the cwd's PARENT directory is a
|
||||
* `<name>-worktrees` folder, the repo root is `<dirname-of-parent>/<name>` (strip
|
||||
* the `-worktrees` suffix). Otherwise the cwd is its own repo root. `null` in →
|
||||
* `null` out (a session with no known cwd has no repo to group under). Pure.
|
||||
*/
|
||||
export function deriveRepoRoot(cwd: string | null): string | null {
|
||||
if (cwd === null || cwd === '') return null
|
||||
const parent = path.dirname(cwd)
|
||||
const parentName = path.basename(parent)
|
||||
if (parentName.length > WORKTREES_SUFFIX.length && parentName.endsWith(WORKTREES_SUFFIX)) {
|
||||
const repoName = parentName.slice(0, -WORKTREES_SUFFIX.length)
|
||||
return path.join(path.dirname(parent), repoName)
|
||||
}
|
||||
return cwd
|
||||
}
|
||||
|
||||
/**
|
||||
* Group live sessions by derived repo root. Sessions whose cwd resolves to the
|
||||
* same repo (a repo + its `*-worktrees/*` lanes) land in one SessionGroup.
|
||||
* Sessions with `cwd === null` are SKIPPED (no repo to attribute them to).
|
||||
* Group order and per-group member order both follow the input order (which
|
||||
* manager.list() already yields newest-first). Never mutates the input. Pure.
|
||||
*/
|
||||
export function groupSessionsByRepo(sessions: readonly LiveSessionInfo[]): SessionGroup[] {
|
||||
const byRoot = new Map<string, SessionGroup>()
|
||||
const order: string[] = []
|
||||
for (const session of sessions) {
|
||||
const repoRoot = deriveRepoRoot(session.cwd)
|
||||
if (repoRoot === null) continue // no cwd → not attributable to a repo
|
||||
let group = byRoot.get(repoRoot)
|
||||
if (group === undefined) {
|
||||
group = { repoRoot, label: path.basename(repoRoot), sessions: [] }
|
||||
byRoot.set(repoRoot, group)
|
||||
order.push(repoRoot)
|
||||
}
|
||||
group.sessions.push(session)
|
||||
}
|
||||
return order.map((root) => byRoot.get(root)!)
|
||||
}
|
||||
@@ -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<void> } {
|
||||
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=<epochMs>` is
|
||||
@@ -1102,6 +1111,8 @@ export function startServer(cfg: Config): { close(): Promise<void> } {
|
||||
// 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)
|
||||
})
|
||||
|
||||
26
src/types.ts
26
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 `<repo>-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) ── */
|
||||
|
||||
Reference in New Issue
Block a user