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:
119
test/http/session-groups.test.ts
Normal file
119
test/http/session-groups.test.ts
Normal file
@@ -0,0 +1,119 @@
|
||||
/**
|
||||
* test/http/session-groups.test.ts (W5) — pure grouping helpers, no I/O.
|
||||
*
|
||||
* deriveRepoRoot strips a `<name>-worktrees/<lane>` parent to `<...>/<name>`;
|
||||
* 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)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user