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:
172
test/integration/grouped-endpoint.test.ts
Normal file
172
test/integration/grouped-endpoint.test.ts
Normal file
@@ -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<number> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> }
|
||||
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<void>((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<void>((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)
|
||||
})
|
||||
})
|
||||
@@ -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 })
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user