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.
328 lines
12 KiB
TypeScript
328 lines
12 KiB
TypeScript
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
|
import type {
|
|
Config,
|
|
NotifyClass,
|
|
PushPayload,
|
|
PushSubscriptionRecord,
|
|
Session,
|
|
} from '../../src/types.js'
|
|
import type { SubscriptionStore } from '../../src/push/subscription-store.js'
|
|
import {
|
|
createPushService,
|
|
type PushSendOptions,
|
|
type PushSender,
|
|
} from '../../src/push/push-service.js'
|
|
|
|
/* ── fixtures ─────────────────────────────────────────────────────── */
|
|
|
|
const BASE_CFG: Config = {
|
|
port: 3000,
|
|
bindHost: '0.0.0.0',
|
|
shellPath: '/bin/zsh',
|
|
homeDir: '/home/tester',
|
|
idleTtlMs: 10_000,
|
|
scrollbackBytes: 2 * 1024 * 1024,
|
|
maxPayloadBytes: 1024 * 1024,
|
|
wsPath: '/term',
|
|
maxSessions: 50,
|
|
maxMsgsPerSec: 2000,
|
|
permTimeoutMs: 300_000,
|
|
reapIntervalMs: 60_000,
|
|
previewBytes: 24 * 1024,
|
|
useTmux: false,
|
|
allowedOrigins: [],
|
|
projectRoots: ['/home/tester'],
|
|
projectScanDepth: 4,
|
|
projectScanTtlMs: 10_000,
|
|
projectDirtyCheck: true,
|
|
editorCmd: 'code',
|
|
vapidPublicKey: 'PUBKEY',
|
|
vapidPrivateKey: 'PRIVKEY',
|
|
vapidSubject: 'mailto:admin@example.com',
|
|
pushStorePath: '/tmp/push-subs.json',
|
|
pushMaxSubs: 50,
|
|
notifyDone: true,
|
|
notifyDnd: false,
|
|
decisionTokenTtlMs: 300_000,
|
|
timelineMax: 200,
|
|
timelineEnabled: true,
|
|
stuckTtlMs: 600_000,
|
|
stuckAlert: true,
|
|
diffTimeoutMs: 2000,
|
|
diffMaxBytes: 2 * 1024 * 1024,
|
|
diffMaxFiles: 300,
|
|
statuslineTtlMs: 30_000,
|
|
worktreeEnabled: true,
|
|
worktreeRoot: undefined,
|
|
worktreeTimeoutMs: 10_000,
|
|
maxFanoutLanes: 6,
|
|
defaultPermissionMode: 'default',
|
|
allowAutoMode: false,
|
|
}
|
|
|
|
function cfg(overrides: Partial<Config> = {}): Config {
|
|
return { ...BASE_CFG, ...overrides }
|
|
}
|
|
|
|
const SID = 'f47ac10b-58cc-4372-a567-0e02b2c3d479'
|
|
|
|
function fakeSession(id = SID, cwd: string | null = '/home/tester/my-repo'): Session {
|
|
return { meta: { id, createdAt: 0, shellPath: '/bin/zsh' }, cwd } as unknown as Session
|
|
}
|
|
|
|
function rec(endpoint: string): PushSubscriptionRecord {
|
|
return { endpoint, keys: { p256dh: 'p-' + endpoint, auth: 'a-' + endpoint }, createdAt: 1 }
|
|
}
|
|
|
|
interface SentCall {
|
|
record: PushSubscriptionRecord
|
|
payload: PushPayload
|
|
options: PushSendOptions
|
|
}
|
|
|
|
/** A fake sender that records calls and can be told to throw per-endpoint. */
|
|
function makeSender(failures: Record<string, number> = {}): PushSender & {
|
|
sent: SentCall[]
|
|
vapid: { subject: string; publicKey: string; privateKey: string } | null
|
|
} {
|
|
const sent: SentCall[] = []
|
|
let vapid: { subject: string; publicKey: string; privateKey: string } | null = null
|
|
return {
|
|
sent,
|
|
get vapid() {
|
|
return vapid
|
|
},
|
|
setVapid(subject, publicKey, privateKey) {
|
|
vapid = { subject, publicKey, privateKey }
|
|
},
|
|
async send(record, payload, options) {
|
|
const code = failures[record.endpoint]
|
|
if (code) {
|
|
const e = new Error('push failed') as Error & { statusCode: number }
|
|
e.statusCode = code
|
|
throw e
|
|
}
|
|
sent.push({ record, payload: JSON.parse(payload) as PushPayload, options })
|
|
},
|
|
}
|
|
}
|
|
|
|
/** A fake store backed by an in-memory array. */
|
|
function makeStore(initial: PushSubscriptionRecord[] = []): SubscriptionStore & {
|
|
pruned: string[][]
|
|
persistCount: number
|
|
} {
|
|
let records = [...initial]
|
|
const pruned: string[][] = []
|
|
let persistCount = 0
|
|
return {
|
|
pruned,
|
|
get persistCount() {
|
|
return persistCount
|
|
},
|
|
list: () => Object.freeze([...records]),
|
|
add(r) {
|
|
records = [...records.filter((x) => x.endpoint !== r.endpoint), r]
|
|
},
|
|
remove(endpoint) {
|
|
records = records.filter((x) => x.endpoint !== endpoint)
|
|
},
|
|
prune(dead) {
|
|
pruned.push([...dead])
|
|
records = records.filter((x) => !dead.includes(x.endpoint))
|
|
},
|
|
async persist() {
|
|
persistCount++
|
|
},
|
|
}
|
|
}
|
|
|
|
beforeEach(() => {
|
|
vi.restoreAllMocks()
|
|
})
|
|
|
|
/* ── isEnabled truth table ────────────────────────────────────────── */
|
|
|
|
describe('createPushService — isEnabled', () => {
|
|
it('is true only when both VAPID keys are configured', () => {
|
|
expect(createPushService(cfg(), makeStore(), { sender: makeSender() }).isEnabled()).toBe(true)
|
|
expect(
|
|
createPushService(cfg({ vapidPublicKey: undefined }), makeStore(), { sender: makeSender() }).isEnabled(),
|
|
).toBe(false)
|
|
expect(
|
|
createPushService(cfg({ vapidPrivateKey: undefined }), makeStore(), { sender: makeSender() }).isEnabled(),
|
|
).toBe(false)
|
|
expect(
|
|
createPushService(cfg({ vapidPublicKey: undefined, vapidPrivateKey: undefined }), makeStore(), {
|
|
sender: makeSender(),
|
|
}).isEnabled(),
|
|
).toBe(false)
|
|
})
|
|
|
|
it('registers VAPID details with the sender when enabled', () => {
|
|
const sender = makeSender()
|
|
createPushService(cfg(), makeStore(), { sender })
|
|
expect(sender.vapid).toEqual({
|
|
subject: 'mailto:admin@example.com',
|
|
publicKey: 'PUBKEY',
|
|
privateKey: 'PRIVKEY',
|
|
})
|
|
})
|
|
|
|
it('does not register VAPID details when disabled', () => {
|
|
const sender = makeSender()
|
|
createPushService(cfg({ vapidPrivateKey: undefined }), makeStore(), { sender })
|
|
expect(sender.vapid).toBeNull()
|
|
})
|
|
|
|
it('falls back to a default subject when VAPID_SUBJECT is unset', () => {
|
|
const sender = makeSender()
|
|
createPushService(cfg({ vapidSubject: undefined }), makeStore(), { sender })
|
|
expect(sender.vapid?.subject).toMatch(/^mailto:/)
|
|
})
|
|
})
|
|
|
|
/* ── notify: short-circuits ───────────────────────────────────────── */
|
|
|
|
describe('notify — short-circuits', () => {
|
|
it('is a no-op when push is disabled', async () => {
|
|
const sender = makeSender()
|
|
const svc = createPushService(cfg({ vapidPublicKey: undefined }), makeStore([rec('e1')]), { sender })
|
|
await svc.notify(fakeSession(), 'needs-input', 'tok')
|
|
expect(sender.sent).toHaveLength(0)
|
|
})
|
|
|
|
it('is a no-op when NOTIFY_DND is on', async () => {
|
|
const sender = makeSender()
|
|
const svc = createPushService(cfg({ notifyDnd: true }), makeStore([rec('e1')]), { sender })
|
|
await svc.notify(fakeSession(), 'needs-input', 'tok')
|
|
expect(sender.sent).toHaveLength(0)
|
|
})
|
|
|
|
it('drops DONE notifications when NOTIFY_DONE is off, but still sends needs-input/stuck', async () => {
|
|
const sender = makeSender()
|
|
const svc = createPushService(cfg({ notifyDone: false }), makeStore([rec('e1')]), { sender })
|
|
await svc.notify(fakeSession(), 'done')
|
|
expect(sender.sent).toHaveLength(0)
|
|
await svc.notify(fakeSession(), 'needs-input', 'tok')
|
|
await svc.notify(fakeSession(), 'stuck')
|
|
expect(sender.sent.map((c) => c.payload.cls)).toEqual(['needs-input', 'stuck'])
|
|
})
|
|
|
|
it('is a no-op when there are no subscriptions', async () => {
|
|
const sender = makeSender()
|
|
const svc = createPushService(cfg(), makeStore([]), { sender })
|
|
await svc.notify(fakeSession(), 'needs-input', 'tok')
|
|
expect(sender.sent).toHaveLength(0)
|
|
})
|
|
})
|
|
|
|
/* ── notify: payload shape ────────────────────────────────────────── */
|
|
|
|
describe('notify — payload (§3.3 PushPayload)', () => {
|
|
it('sends a minimal needs-input payload with the capability token to every subscription', async () => {
|
|
const sender = makeSender()
|
|
const svc = createPushService(cfg(), makeStore([rec('e1'), rec('e2')]), { sender })
|
|
await svc.notify(fakeSession(), 'needs-input', 'cap-tok')
|
|
|
|
expect(sender.sent).toHaveLength(2)
|
|
const { payload } = sender.sent[0]!
|
|
expect(payload.sessionId).toBe(SID)
|
|
expect(payload.cls).toBe('needs-input')
|
|
expect(payload.token).toBe('cap-tok')
|
|
// minimal: only known keys, no raw output / secrets
|
|
expect(Object.keys(payload).sort()).toEqual(['cls', 'detail', 'sessionId', 'token'])
|
|
expect(payload.detail).toBe('my-repo')
|
|
})
|
|
|
|
it('omits the token for non-needs-input classes even when one is passed', async () => {
|
|
const sender = makeSender()
|
|
const svc = createPushService(cfg(), makeStore([rec('e1')]), { sender })
|
|
await svc.notify(fakeSession(), 'done', 'leaked-token')
|
|
expect(sender.sent[0]!.payload.token).toBeUndefined()
|
|
})
|
|
|
|
it('omits detail when the session has no cwd', async () => {
|
|
const sender = makeSender()
|
|
const svc = createPushService(cfg(), makeStore([rec('e1')]), { sender })
|
|
await svc.notify(fakeSession(SID, null), 'stuck')
|
|
expect(sender.sent[0]!.payload.detail).toBeUndefined()
|
|
})
|
|
})
|
|
|
|
/* ── notify: web-push options ─────────────────────────────────────── */
|
|
|
|
describe('notify — send options', () => {
|
|
it('uses high urgency for needs-input/stuck and low for done', async () => {
|
|
const sender = makeSender()
|
|
const svc = createPushService(cfg(), makeStore([rec('e1')]), { sender })
|
|
await svc.notify(fakeSession(), 'needs-input', 'tok')
|
|
await svc.notify(fakeSession(), 'stuck')
|
|
await svc.notify(fakeSession(), 'done')
|
|
expect(sender.sent.map((c) => c.options.urgency)).toEqual(['high', 'high', 'low'])
|
|
})
|
|
|
|
it('sets a per-session collapse topic (valid Topic header, no dashes, <=32 chars)', async () => {
|
|
const sender = makeSender()
|
|
const svc = createPushService(cfg(), makeStore([rec('e1')]), { sender })
|
|
await svc.notify(fakeSession(), 'needs-input', 'tok')
|
|
const topic = sender.sent[0]!.options.topic!
|
|
expect(topic).toBe(SID.replace(/-/g, ''))
|
|
expect(topic.length).toBeLessThanOrEqual(32)
|
|
expect(topic).toMatch(/^[A-Za-z0-9_-]+$/)
|
|
})
|
|
})
|
|
|
|
/* ── notify: pruning dead subscriptions ───────────────────────────── */
|
|
|
|
describe('notify — pruning', () => {
|
|
for (const code of [404, 410] as const) {
|
|
it(`prunes + persists a subscription that returns ${code}`, async () => {
|
|
const sender = makeSender({ e2: code })
|
|
const store = makeStore([rec('e1'), rec('e2'), rec('e3')])
|
|
const svc = createPushService(cfg(), store, { sender })
|
|
await svc.notify(fakeSession(), 'needs-input', 'tok')
|
|
|
|
expect(store.pruned).toEqual([['e2']])
|
|
expect(store.persistCount).toBe(1)
|
|
// the live subscriptions still received the push
|
|
expect(sender.sent.map((c) => c.record.endpoint).sort()).toEqual(['e1', 'e3'])
|
|
})
|
|
}
|
|
|
|
it('does NOT prune on transient errors (e.g. 500); logs and keeps the subscription', async () => {
|
|
const err = vi.spyOn(console, 'error').mockImplementation(() => {})
|
|
const sender = makeSender({ e1: 500 })
|
|
const store = makeStore([rec('e1'), rec('e2')])
|
|
const svc = createPushService(cfg(), store, { sender })
|
|
await svc.notify(fakeSession(), 'done')
|
|
|
|
expect(store.pruned).toEqual([])
|
|
expect(store.persistCount).toBe(0)
|
|
expect(err).toHaveBeenCalled()
|
|
expect(sender.sent.map((c) => c.record.endpoint)).toEqual(['e2'])
|
|
})
|
|
|
|
it('does not persist when no subscriptions are dead', async () => {
|
|
const sender = makeSender()
|
|
const store = makeStore([rec('e1')])
|
|
const svc = createPushService(cfg(), store, { sender })
|
|
await svc.notify(fakeSession(), 'needs-input', 'tok')
|
|
expect(store.persistCount).toBe(0)
|
|
})
|
|
})
|
|
|
|
/* ── default sender (web-push) is wired without crashing ──────────── */
|
|
|
|
describe('createPushService — default sender', () => {
|
|
it('constructs with the real web-push sender when none is injected', () => {
|
|
// disabled config → setVapidDetails not called, so no real keys needed
|
|
const svc = createPushService(cfg({ vapidPublicKey: undefined, vapidPrivateKey: undefined }), makeStore())
|
|
expect(svc.isEnabled()).toBe(false)
|
|
})
|
|
})
|
|
|
|
/* exhaustiveness guard: every NotifyClass is exercised above */
|
|
const _classes: NotifyClass[] = ['needs-input', 'done', 'stuck']
|
|
void _classes
|