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

View File

@@ -0,0 +1,373 @@
/**
* test/http/statusline.test.ts — TDD tests for parseStatusLine (N-statusline-be).
*
* Acceptance: AC-B2.6 — full/missing/dirty/empty bodies; never throw; ≥80% coverage.
* Security: SEC-M7 — unknown + narrowing, string length limits.
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { parseStatusLine } from '../../src/http/statusline.js'
import type { StatusTelemetry } from '../../src/types.js'
// Representative full Claude Code statusLine JSON body.
// Field mapping (R0 → StatusTelemetry): context_window.used_percentage→contextUsedPct,
// cost.total_cost_usd→costUsd, cost.total_lines_added/removed→linesAdded/Removed,
// model.display_name→model, effort.level→effort, pr→pr, rate_limits→rate.
const FULL_BODY = {
context_window: { used_percentage: 42.5 },
cost: { total_cost_usd: 0.05, total_lines_added: 100, total_lines_removed: 50 },
model: { display_name: 'Claude 3.5 Sonnet' },
effort: { level: 'normal' },
pr: { number: 42, url: 'https://github.com/foo/bar/pull/42', reviewState: 'pending' },
rate_limits: { fiveHourPct: 10, sevenDayPct: 20 },
}
const FIXED_NOW = 1_700_000_000_000
describe('parseStatusLine — null for non-objects', () => {
it('returns null for null', () => {
expect(parseStatusLine(null)).toBeNull()
})
it('returns null for undefined', () => {
expect(parseStatusLine(undefined)).toBeNull()
})
it('returns null for a string', () => {
expect(parseStatusLine('hello')).toBeNull()
})
it('returns null for a number', () => {
expect(parseStatusLine(42)).toBeNull()
})
it('returns null for a boolean', () => {
expect(parseStatusLine(true)).toBeNull()
})
it('returns null for an array (typeof is "object" but is not a plain object)', () => {
expect(parseStatusLine([])).toBeNull()
expect(parseStatusLine([1, 2, 3])).toBeNull()
})
})
describe('parseStatusLine — full valid body', () => {
beforeEach(() => {
vi.spyOn(Date, 'now').mockReturnValue(FIXED_NOW)
})
afterEach(() => {
vi.restoreAllMocks()
})
it('maps all fields correctly from a fully-populated body', () => {
const result = parseStatusLine(FULL_BODY)
expect(result).not.toBeNull()
const t = result as StatusTelemetry
expect(t.contextUsedPct).toBe(42.5)
expect(t.costUsd).toBe(0.05)
expect(t.linesAdded).toBe(100)
expect(t.linesRemoved).toBe(50)
expect(t.model).toBe('Claude 3.5 Sonnet')
expect(t.effort).toBe('normal')
expect(t.pr).toEqual({ number: 42, url: 'https://github.com/foo/bar/pull/42', reviewState: 'pending' })
expect(t.rate).toEqual({ fiveHourPct: 10, sevenDayPct: 20 })
expect(t.at).toBe(FIXED_NOW)
})
})
describe('parseStatusLine — empty/minimal body', () => {
beforeEach(() => {
vi.spyOn(Date, 'now').mockReturnValue(FIXED_NOW)
})
afterEach(() => {
vi.restoreAllMocks()
})
it('returns a StatusTelemetry with only at for an empty object', () => {
const result = parseStatusLine({})
expect(result).not.toBeNull()
expect(result?.at).toBe(FIXED_NOW)
expect(result?.contextUsedPct).toBeUndefined()
expect(result?.costUsd).toBeUndefined()
expect(result?.linesAdded).toBeUndefined()
expect(result?.linesRemoved).toBeUndefined()
expect(result?.model).toBeUndefined()
expect(result?.effort).toBeUndefined()
expect(result?.pr).toBeUndefined()
expect(result?.rate).toBeUndefined()
})
})
describe('parseStatusLine — at timestamp', () => {
afterEach(() => {
vi.restoreAllMocks()
})
it('sets at to Date.now() on each call', () => {
const t1 = 1000
const t2 = 2000
vi.spyOn(Date, 'now').mockReturnValueOnce(t1).mockReturnValueOnce(t2)
expect(parseStatusLine({})?.at).toBe(t1)
expect(parseStatusLine({})?.at).toBe(t2)
})
})
describe('parseStatusLine — dirty number fields', () => {
it('omits contextUsedPct when used_percentage is NaN', () => {
expect(
parseStatusLine({ context_window: { used_percentage: NaN } })?.contextUsedPct,
).toBeUndefined()
})
it('omits contextUsedPct when used_percentage is Infinity', () => {
expect(
parseStatusLine({ context_window: { used_percentage: Infinity } })?.contextUsedPct,
).toBeUndefined()
})
it('omits contextUsedPct when used_percentage is a string', () => {
expect(
parseStatusLine({ context_window: { used_percentage: '42' } })?.contextUsedPct,
).toBeUndefined()
})
it('omits costUsd when total_cost_usd is Infinity', () => {
expect(parseStatusLine({ cost: { total_cost_usd: Infinity } })?.costUsd).toBeUndefined()
})
it('omits costUsd when total_cost_usd is NaN', () => {
expect(parseStatusLine({ cost: { total_cost_usd: NaN } })?.costUsd).toBeUndefined()
})
it('omits linesAdded when total_lines_added is a string', () => {
expect(parseStatusLine({ cost: { total_lines_added: 'bad' } })?.linesAdded).toBeUndefined()
})
it('omits linesRemoved when total_lines_removed is null', () => {
expect(parseStatusLine({ cost: { total_lines_removed: null } })?.linesRemoved).toBeUndefined()
})
it('parses partial cost fields independently', () => {
const result = parseStatusLine({ cost: { total_cost_usd: 1.5 } })
expect(result?.costUsd).toBe(1.5)
expect(result?.linesAdded).toBeUndefined()
expect(result?.linesRemoved).toBeUndefined()
})
})
describe('parseStatusLine — context_window not an object', () => {
it('omits contextUsedPct when context_window is a string', () => {
expect(parseStatusLine({ context_window: 'bad' })?.contextUsedPct).toBeUndefined()
})
it('omits contextUsedPct when context_window is null', () => {
expect(parseStatusLine({ context_window: null })?.contextUsedPct).toBeUndefined()
})
it('omits contextUsedPct when context_window is a number', () => {
expect(parseStatusLine({ context_window: 42 })?.contextUsedPct).toBeUndefined()
})
})
describe('parseStatusLine — cost not an object', () => {
it('omits all cost fields when cost is a string', () => {
const result = parseStatusLine({ cost: 'bad' })
expect(result?.costUsd).toBeUndefined()
expect(result?.linesAdded).toBeUndefined()
expect(result?.linesRemoved).toBeUndefined()
})
it('omits all cost fields when cost is null', () => {
const result = parseStatusLine({ cost: null })
expect(result?.costUsd).toBeUndefined()
expect(result?.linesAdded).toBeUndefined()
expect(result?.linesRemoved).toBeUndefined()
})
})
describe('parseStatusLine — dirty string fields (SEC-M7 length caps)', () => {
it('omits model when display_name exceeds 200 characters', () => {
expect(
parseStatusLine({ model: { display_name: 'a'.repeat(201) } })?.model,
).toBeUndefined()
})
it('accepts model at exactly 200 characters', () => {
const model200 = 'a'.repeat(200)
expect(parseStatusLine({ model: { display_name: model200 } })?.model).toBe(model200)
})
it('omits model when model field is not an object', () => {
expect(parseStatusLine({ model: 'bad' })?.model).toBeUndefined()
expect(parseStatusLine({ model: null })?.model).toBeUndefined()
expect(parseStatusLine({ model: 42 })?.model).toBeUndefined()
})
it('omits effort when level is a number', () => {
expect(parseStatusLine({ effort: { level: 42 } })?.effort).toBeUndefined()
})
it('omits effort when level exceeds 100 characters', () => {
expect(
parseStatusLine({ effort: { level: 'x'.repeat(101) } })?.effort,
).toBeUndefined()
})
it('accepts effort at exactly 100 characters', () => {
const effort100 = 'e'.repeat(100)
expect(parseStatusLine({ effort: { level: effort100 } })?.effort).toBe(effort100)
})
it('omits effort when effort field is not an object', () => {
expect(parseStatusLine({ effort: 42 })?.effort).toBeUndefined()
expect(parseStatusLine({ effort: null })?.effort).toBeUndefined()
expect(parseStatusLine({ effort: [] })?.effort).toBeUndefined()
})
})
describe('parseStatusLine — pr parsing', () => {
it('parses pr with all fields including optional reviewState', () => {
const result = parseStatusLine({
pr: { number: 42, url: 'https://github.com/foo/bar/pull/42', reviewState: 'pending' },
})
expect(result?.pr).toEqual({
number: 42,
url: 'https://github.com/foo/bar/pull/42',
reviewState: 'pending',
})
})
it('parses pr without optional reviewState', () => {
const result = parseStatusLine({ pr: { number: 5, url: 'https://github.com/pr/5' } })
expect(result?.pr).toBeDefined()
expect(result?.pr?.number).toBe(5)
expect(result?.pr?.url).toBe('https://github.com/pr/5')
expect(result?.pr?.reviewState).toBeUndefined()
})
it('omits pr when pr.number is missing', () => {
expect(parseStatusLine({ pr: { url: 'https://example.com' } })?.pr).toBeUndefined()
})
it('omits pr when pr.url is missing', () => {
expect(parseStatusLine({ pr: { number: 1 } })?.pr).toBeUndefined()
})
it('omits pr when both required fields are missing', () => {
expect(parseStatusLine({ pr: {} })?.pr).toBeUndefined()
})
it('omits pr when pr is not an object', () => {
expect(parseStatusLine({ pr: 'not-an-object' })?.pr).toBeUndefined()
expect(parseStatusLine({ pr: null })?.pr).toBeUndefined()
expect(parseStatusLine({ pr: 42 })?.pr).toBeUndefined()
})
it('omits pr when pr.url exceeds 2000 characters', () => {
const longUrl = 'https://example.com/' + 'x'.repeat(1990)
expect(parseStatusLine({ pr: { number: 1, url: longUrl } })?.pr).toBeUndefined()
})
it('omits pr.reviewState but keeps pr when reviewState exceeds 100 characters', () => {
const result = parseStatusLine({
pr: {
number: 1,
url: 'https://github.com/pr/1',
reviewState: 'x'.repeat(101),
},
})
expect(result?.pr).toBeDefined()
expect(result?.pr?.number).toBe(1)
expect(result?.pr?.url).toBe('https://github.com/pr/1')
expect(result?.pr?.reviewState).toBeUndefined()
})
it('omits pr when pr.number is NaN', () => {
expect(parseStatusLine({ pr: { number: NaN, url: 'https://github.com' } })?.pr).toBeUndefined()
})
})
describe('parseStatusLine — rate_limits parsing', () => {
it('parses rate_limits with both fields', () => {
const result = parseStatusLine({ rate_limits: { fiveHourPct: 25, sevenDayPct: 50 } })
expect(result?.rate).toEqual({ fiveHourPct: 25, sevenDayPct: 50 })
})
it('parses rate_limits with only fiveHourPct', () => {
const result = parseStatusLine({ rate_limits: { fiveHourPct: 25 } })
expect(result?.rate?.fiveHourPct).toBe(25)
expect(result?.rate?.sevenDayPct).toBeUndefined()
})
it('parses rate_limits with only sevenDayPct', () => {
const result = parseStatusLine({ rate_limits: { sevenDayPct: 75 } })
expect(result?.rate?.fiveHourPct).toBeUndefined()
expect(result?.rate?.sevenDayPct).toBe(75)
})
it('omits rate when rate_limits is not an object', () => {
expect(parseStatusLine({ rate_limits: 'bad' })?.rate).toBeUndefined()
expect(parseStatusLine({ rate_limits: null })?.rate).toBeUndefined()
expect(parseStatusLine({ rate_limits: 42 })?.rate).toBeUndefined()
})
it('returns rate object with undefined sub-fields when rate_limits values are invalid', () => {
const result = parseStatusLine({ rate_limits: { fiveHourPct: NaN, sevenDayPct: Infinity } })
// rate_limits IS a valid object, so rate is returned; but both sub-fields are invalid
expect(result?.rate).toBeDefined()
expect(result?.rate?.fiveHourPct).toBeUndefined()
expect(result?.rate?.sevenDayPct).toBeUndefined()
})
it('omits invalid sub-fields and keeps valid ones', () => {
const result = parseStatusLine({
rate_limits: { fiveHourPct: 'not-a-number', sevenDayPct: 80 },
})
expect(result?.rate?.fiveHourPct).toBeUndefined()
expect(result?.rate?.sevenDayPct).toBe(80)
})
})
describe('parseStatusLine — never throw (SEC-M7)', () => {
it('never throws for wildly malformed bodies', () => {
const malformed: unknown[] = [
null,
undefined,
42,
'string',
true,
[],
{ context_window: 'not-an-object' },
{ context_window: { used_percentage: {} } },
{ cost: null },
{ model: 42 },
{ effort: [] },
{ pr: 'not-an-object' },
{ pr: { number: 'bad', url: {} } },
{ rate_limits: 'bad' },
{ rate_limits: { fiveHourPct: Infinity, sevenDayPct: -Infinity } },
{ pr: { number: NaN, url: '' } },
{ context_window: { used_percentage: null } },
{ cost: { total_cost_usd: {}, total_lines_added: [], total_lines_removed: undefined } },
]
for (const body of malformed) {
expect(() => parseStatusLine(body)).not.toThrow()
}
})
})