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:
Yaojia Wang
2026-06-30 17:42:18 +02:00
parent 4f1d3ebc6b
commit d6809c65c4
54 changed files with 13171 additions and 200 deletions

190
test/sw-push.test.ts Normal file
View File

@@ -0,0 +1,190 @@
/**
* test/sw-push.test.ts — Unit tests for public/sw-push.js pure helpers.
*
* Covers T-sw W1: buildPushNotification (all NotifyClass values) and
* resolveNotificationClick (allow / deny / body-click routing).
*
* Security: SEC-C1 (decision body carries token), SEC-M6 (tag=sessionId dedup).
* No DOM or SW globals needed — sw-push.js is intentionally pure.
*/
import { describe, it, expect } from 'vitest'
import type { PushPayload } from '../src/types.js'
// sw-push.js is a plain ES-module JS file; vitest imports it as ESM.
// We assert the expected return shapes inline.
const { buildPushNotification, resolveNotificationClick } = await import(
'../public/sw-push.js'
)
// ─────────────── helpers ───────────────────────────────────────────────────
/** Minimal notification mock matching the shape sw-push.js reads. */
function fakeNotification(data: Record<string, unknown>): Notification {
return { data } as unknown as Notification
}
// ─────────────── buildPushNotification ────────────────────────────────────
describe('buildPushNotification', () => {
it('needs-input: requireInteraction=true, Allow+Deny actions, tag=sessionId', () => {
const payload: PushPayload = {
sessionId: 'sess-001',
toolName: 'Bash',
detail: 'rm -rf /tmp/cache',
token: 'tok-aaa',
cls: 'needs-input',
}
const { title, options } = buildPushNotification(payload)
expect(title).toBeTruthy()
expect(typeof title).toBe('string')
expect(options.tag).toBe('sess-001') // SEC-M6
expect(options.requireInteraction).toBe(true)
const actions: Array<{ action: string; title: string }> = options.actions ?? []
expect(actions.length).toBeGreaterThanOrEqual(2)
expect(actions.some((a) => a.action === 'allow')).toBe(true)
expect(actions.some((a) => a.action === 'deny')).toBe(true)
expect(options.data).toMatchObject({
sessionId: 'sess-001',
token: 'tok-aaa',
cls: 'needs-input',
})
})
it('needs-input without detail falls back to toolName in body', () => {
const payload: PushPayload = {
sessionId: 's',
toolName: 'Edit',
token: 'tok-bbb',
cls: 'needs-input',
}
const { options } = buildPushNotification(payload)
expect(options.body ?? '').toContain('Edit')
})
it('done: no requireInteraction, no actions, tag=sessionId', () => {
const payload: PushPayload = { sessionId: 'sess-002', cls: 'done' }
const { title, options } = buildPushNotification(payload)
expect(title).toBeTruthy()
expect(options.tag).toBe('sess-002') // SEC-M6
expect(options.requireInteraction).toBeFalsy()
expect((options.actions ?? []).length).toBe(0)
expect(options.data).toMatchObject({ sessionId: 'sess-002', cls: 'done' })
})
it('done: body includes detail when provided', () => {
const payload: PushPayload = {
sessionId: 'sess-003',
detail: 'Build succeeded',
cls: 'done',
}
const { options } = buildPushNotification(payload)
expect(options.body).toContain('Build succeeded')
})
it('stuck: no requireInteraction, no actions, tag=sessionId', () => {
const payload: PushPayload = { sessionId: 'sess-004', cls: 'stuck' }
const { title, options } = buildPushNotification(payload)
expect(title).toBeTruthy()
expect(options.tag).toBe('sess-004') // SEC-M6
expect(options.requireInteraction).toBeFalsy()
expect((options.actions ?? []).length).toBe(0)
expect(options.data).toMatchObject({ sessionId: 'sess-004', cls: 'stuck' })
})
it('no detail and no toolName → body is undefined or empty', () => {
const payload: PushPayload = { sessionId: 'sess-005', cls: 'done' }
const { options } = buildPushNotification(payload)
// body should be undefined or an empty string — not a thrown error
expect(() => buildPushNotification(payload)).not.toThrow()
const body = options.body
expect(body === undefined || body === '').toBe(true)
})
})
// ─────────────── resolveNotificationClick ─────────────────────────────────
describe('resolveNotificationClick', () => {
it('allow action → decision with decision=allow and capability token (SEC-C1)', () => {
const notification = fakeNotification({
sessionId: 'sess-10',
token: 'tok-xyz',
cls: 'needs-input',
})
const result = resolveNotificationClick(notification, 'allow')
expect(result.kind).toBe('decision')
const body = JSON.parse(result.body) as Record<string, unknown>
expect(body.sessionId).toBe('sess-10')
expect(body.decision).toBe('allow')
expect(body.token).toBe('tok-xyz') // SEC-C1: token forwarded
})
it('deny action → decision with decision=deny', () => {
const notification = fakeNotification({
sessionId: 'sess-11',
token: 'tok-abc',
cls: 'needs-input',
})
const result = resolveNotificationClick(notification, 'deny')
expect(result.kind).toBe('decision')
const body = JSON.parse(result.body) as Record<string, unknown>
expect(body.sessionId).toBe('sess-11')
expect(body.decision).toBe('deny')
expect(body.token).toBe('tok-abc')
})
it('empty action string (body click) → focus with session URL', () => {
const notification = fakeNotification({ sessionId: 'sess-12' })
const result = resolveNotificationClick(notification, '')
expect(result.kind).toBe('focus')
expect(result.url).toBe('/?session=sess-12')
})
it('undefined action (body click) → focus with session URL', () => {
const notification = fakeNotification({ sessionId: 'sess-13' })
const result = resolveNotificationClick(notification, undefined)
expect(result.kind).toBe('focus')
expect(result.url).toBe('/?session=sess-13')
})
it('null action → focus (treated as default body click)', () => {
const notification = fakeNotification({ sessionId: 'sess-14' })
const result = resolveNotificationClick(notification, null)
expect(result.kind).toBe('focus')
expect(result.url).toContain('sess-14')
})
it('token is undefined in body when not in notification data (done notification)', () => {
// done/stuck notifications have no token in data; test edge: action='allow' still
// sends a decision body but token field is absent (server should reject gracefully)
const notification = fakeNotification({ sessionId: 'sess-15', cls: 'done' })
const result = resolveNotificationClick(notification, 'allow')
expect(result.kind).toBe('decision')
const body = JSON.parse(result.body) as Record<string, unknown>
expect(body.token).toBeUndefined()
})
it('sessionId is URL-encoded in focus URL (special characters safe)', () => {
const notification = fakeNotification({ sessionId: 'sess a+b' })
const result = resolveNotificationClick(notification, '')
expect(result.kind).toBe('focus')
// encodeURIComponent encodes spaces and + signs
expect(result.url).not.toContain(' ')
})
it('missing data on notification → focus URL with empty session gracefully', () => {
// notification.data is null/undefined
const notification = { data: null } as unknown as Notification
expect(() => resolveNotificationClick(notification, '')).not.toThrow()
})
})