feat(v0.7): Walk-away Workbench (Band A + B) — multi-agent parallel build
Implements docs/PLAN_WALKAWAY_WORKBENCH.md (27 tasks, waves R0→W0→W1×14→W2→W3→W4)
via module-builder agents. 23 tasks built, 0 blocked.
Band A (finish the walk-away loop): A1 Web Push + lock-screen approve/deny
(web-push dep), A2 voice dictation, A3 quick-reply chips + saved-prompt palette,
A4 activity timeline, A5 stuck/idle alert.
Band B (workbench above the terminal): B1 read-only git diff viewer, B2 statusLine
telemetry → per-tab cost/context/PR gauges, B3 create git worktrees from the UI,
B4 plan-mode / permission-mode relay.
New: src/push/* (subscription store + VAPID push), src/http/{diff,statusline}.ts,
src/session/timeline.ts, public/{diff,timeline,quickreply,push-ui,...}.ts, sw-push,
statusLine script; extends hook intake, manager, server routes (Origin/CSRF guards
+ per-IP rate limits on state-changing ones; loopback-only ingest), terminal-session,
tabs, projects detail, service worker, setup-hooks (statusLine + ntfy bridge).
Orchestrator reconciled a W0 contract gap: added the 21 v0.7 Config fields to the
Config interface in types.ts (T-types had left them only in config.ts's return).
Verified: both tsc clean, full vitest + coverage 91.4/84.1/92.2/93.4 (≥80×4),
build:web OK. W4 review: no CRITICAL/HIGH; all security checks pass. Follow-ups
(non-blocking): move approve.mode validation into parseClientMessage, drop CSP
ws:/wss: wildcard, validate worktree base ref, +2 targeted tests.
This commit is contained in:
@@ -456,3 +456,158 @@ describe('show()/refit() re-fit when visible', () => {
|
||||
expect(ws.sent).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
|
||||
// ── B2 telemetry frame ────────────────────────────────────────────────────────
|
||||
|
||||
describe('telemetry frame (B2)', () => {
|
||||
beforeEach(() => setLocation('http:', 'lan:3000'))
|
||||
|
||||
function connected(opts: { onTelemetry?: (t: unknown) => void } = {}) {
|
||||
const s = new TerminalSession({
|
||||
sessionId: null,
|
||||
onSessionId: vi.fn(),
|
||||
onTelemetry: opts.onTelemetry as Parameters<typeof TerminalSession>[0]['onTelemetry'],
|
||||
})
|
||||
s.connect()
|
||||
const ws = MockWebSocket.last()
|
||||
ws.openIt()
|
||||
return { s, ws }
|
||||
}
|
||||
|
||||
it('telemetry getter starts as null before any telemetry frame', () => {
|
||||
const s = new TerminalSession({ sessionId: null, onSessionId: vi.fn() })
|
||||
expect(s.telemetry).toBeNull()
|
||||
})
|
||||
|
||||
it('fires onTelemetry callback and updates the telemetry getter', () => {
|
||||
const onTelemetry = vi.fn()
|
||||
const { s, ws } = connected({ onTelemetry })
|
||||
const tel = { at: 1000, contextUsedPct: 42, costUsd: 0.5, model: 'claude-opus-4' }
|
||||
ws.message(JSON.stringify({ type: 'telemetry', telemetry: tel }))
|
||||
expect(onTelemetry).toHaveBeenCalledOnce()
|
||||
expect(onTelemetry).toHaveBeenCalledWith(tel)
|
||||
expect(s.telemetry).toEqual(tel)
|
||||
})
|
||||
|
||||
it('works without onTelemetry (callback is optional — no throw)', () => {
|
||||
const { ws } = connected() // no onTelemetry
|
||||
const tel = { at: 2000, costUsd: 0.1 }
|
||||
expect(() => ws.message(JSON.stringify({ type: 'telemetry', telemetry: tel }))).not.toThrow()
|
||||
})
|
||||
|
||||
it('updates telemetry on subsequent frames (latest wins)', () => {
|
||||
const { s, ws } = connected()
|
||||
ws.message(JSON.stringify({ type: 'telemetry', telemetry: { at: 1000, costUsd: 0.1 } }))
|
||||
ws.message(JSON.stringify({ type: 'telemetry', telemetry: { at: 2000, costUsd: 0.9 } }))
|
||||
expect(s.telemetry).toMatchObject({ at: 2000, costUsd: 0.9 })
|
||||
})
|
||||
|
||||
it('onTelemetry is called once per frame (not debounced)', () => {
|
||||
const onTelemetry = vi.fn()
|
||||
const { ws } = connected({ onTelemetry })
|
||||
ws.message(JSON.stringify({ type: 'telemetry', telemetry: { at: 1 } }))
|
||||
ws.message(JSON.stringify({ type: 'telemetry', telemetry: { at: 2 } }))
|
||||
ws.message(JSON.stringify({ type: 'telemetry', telemetry: { at: 3 } }))
|
||||
expect(onTelemetry).toHaveBeenCalledTimes(3)
|
||||
})
|
||||
})
|
||||
|
||||
// ── B4 pendingGate from status frame ─────────────────────────────────────────
|
||||
|
||||
describe('pendingGate from status frame (B4)', () => {
|
||||
beforeEach(() => setLocation('http:', 'lan:3000'))
|
||||
|
||||
function connected() {
|
||||
const s = new TerminalSession({ sessionId: null, onSessionId: vi.fn() })
|
||||
s.connect()
|
||||
const ws = MockWebSocket.last()
|
||||
ws.openIt()
|
||||
return { s, ws }
|
||||
}
|
||||
|
||||
it('pendingGate starts as null', () => {
|
||||
const s = new TerminalSession({ sessionId: null, onSessionId: vi.fn() })
|
||||
expect(s.pendingGate).toBeNull()
|
||||
})
|
||||
|
||||
it('records pendingGate="plan" from a status message with gate:"plan"', () => {
|
||||
const { s, ws } = connected()
|
||||
ws.message(JSON.stringify({ type: 'status', status: 'waiting', pending: true, gate: 'plan' }))
|
||||
expect(s.pendingGate).toBe('plan')
|
||||
})
|
||||
|
||||
it('records pendingGate="tool" from a status message with gate:"tool"', () => {
|
||||
const { s, ws } = connected()
|
||||
ws.message(JSON.stringify({ type: 'status', status: 'waiting', pending: true, gate: 'tool' }))
|
||||
expect(s.pendingGate).toBe('tool')
|
||||
})
|
||||
|
||||
it('sets pendingGate to null when gate is absent from status', () => {
|
||||
const { s, ws } = connected()
|
||||
ws.message(JSON.stringify({ type: 'status', status: 'waiting', pending: true }))
|
||||
expect(s.pendingGate).toBeNull()
|
||||
})
|
||||
|
||||
it('clears pendingGate to null on a subsequent status without gate', () => {
|
||||
const { s, ws } = connected()
|
||||
ws.message(JSON.stringify({ type: 'status', status: 'waiting', pending: true, gate: 'plan' }))
|
||||
expect(s.pendingGate).toBe('plan')
|
||||
ws.message(JSON.stringify({ type: 'status', status: 'working' }))
|
||||
expect(s.pendingGate).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
// ── B4 approve(mode?) with PermissionMode ────────────────────────────────────
|
||||
|
||||
describe('approve(mode?) — B4 permission-mode relay', () => {
|
||||
beforeEach(() => setLocation('http:', 'lan:3000'))
|
||||
|
||||
function connected() {
|
||||
const s = new TerminalSession({ sessionId: null, onSessionId: vi.fn() })
|
||||
s.connect()
|
||||
const ws = MockWebSocket.last()
|
||||
ws.openIt()
|
||||
ws.message(JSON.stringify({ type: 'status', status: 'waiting', pending: true, gate: 'plan' }))
|
||||
ws.sent.length = 0
|
||||
return { s, ws }
|
||||
}
|
||||
|
||||
it('approve() without mode sends {type:"approve"} with NO mode field', () => {
|
||||
const { s, ws } = connected()
|
||||
s.approve()
|
||||
const msg = JSON.parse(ws.sent[0]!)
|
||||
expect(msg).toEqual({ type: 'approve' })
|
||||
expect('mode' in msg).toBe(false)
|
||||
})
|
||||
|
||||
it('approve("acceptEdits") sends {type:"approve", mode:"acceptEdits"}', () => {
|
||||
const { s, ws } = connected()
|
||||
s.approve('acceptEdits')
|
||||
expect(JSON.parse(ws.sent[0]!)).toEqual({ type: 'approve', mode: 'acceptEdits' })
|
||||
})
|
||||
|
||||
it('approve("plan") sends {type:"approve", mode:"plan"}', () => {
|
||||
const { s, ws } = connected()
|
||||
s.approve('plan')
|
||||
expect(JSON.parse(ws.sent[0]!)).toEqual({ type: 'approve', mode: 'plan' })
|
||||
})
|
||||
|
||||
it('approve("auto") sends {type:"approve", mode:"auto"}', () => {
|
||||
const { s, ws } = connected()
|
||||
s.approve('auto')
|
||||
expect(JSON.parse(ws.sent[0]!)).toEqual({ type: 'approve', mode: 'auto' })
|
||||
})
|
||||
|
||||
it('approve("default") sends {type:"approve", mode:"default"}', () => {
|
||||
const { s, ws } = connected()
|
||||
s.approve('default')
|
||||
expect(JSON.parse(ws.sent[0]!)).toEqual({ type: 'approve', mode: 'default' })
|
||||
})
|
||||
|
||||
it('approve() clears pendingApproval regardless of mode', () => {
|
||||
const { s } = connected()
|
||||
expect(s.pendingApproval).toBe(true)
|
||||
s.approve('acceptEdits')
|
||||
expect(s.pendingApproval).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user