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,349 @@
import { describe, it, expect } from 'vitest';
import {
sanitizeField,
deriveClass,
deriveLabel,
makeTimelineEvent,
appendEvent,
} from '../../src/session/timeline.js';
import type { TimelineEvent } from '../../src/types.js';
/**
* N-timeline tests — src/session/timeline.ts
*
* Covers:
* - sanitizeField: control-char strip + truncation (SEC-H6)
* - deriveClass: hook name → semantic TimelineClass (§3.1)
* - deriveLabel: human-language label per event type
* - makeTimelineEvent: whitelist gate + sanitize + compose
* - appendEvent: immutability + bounded eviction (SEC-M8)
*/
// ── helpers ──────────────────────────────────────────────────────────────────
function makeEvent(overrides: Partial<TimelineEvent> = {}): TimelineEvent {
return {
at: 1000,
class: 'tool',
label: 'ran Bash',
...overrides,
};
}
// ── sanitizeField ─────────────────────────────────────────────────────────────
describe('sanitizeField', () => {
it('returns the string unchanged when it has no control chars and is under max', () => {
expect(sanitizeField('Bash')).toBe('Bash');
});
it('strips all control characters (0x000x1f)', () => {
// NUL, LF, CR, ESC, tab should all be removed
const input = '\x00Bash\x09script\x0a\x0d\x1b[1m';
expect(sanitizeField(input)).toBe('Bashscript[1m');
});
it('strips null bytes embedded in the string', () => {
expect(sanitizeField('he\x00llo')).toBe('hello');
});
it('truncates to the default max of 200 characters', () => {
const long = 'a'.repeat(300);
const result = sanitizeField(long);
expect(result.length).toBe(200);
});
it('truncates to a custom max', () => {
const result = sanitizeField('abcdef', 3);
expect(result).toBe('abc');
});
it('handles an empty string', () => {
expect(sanitizeField('')).toBe('');
});
it('does not truncate strings exactly at the max', () => {
const exact = 'x'.repeat(200);
expect(sanitizeField(exact).length).toBe(200);
});
it('strips control chars before truncating (strip-then-truncate)', () => {
// 10 control chars + 5 regular chars; after strip only 5 remain → under max
const withControls = '\x01'.repeat(10) + 'hello';
expect(sanitizeField(withControls, 8)).toBe('hello');
});
});
// ── deriveClass ───────────────────────────────────────────────────────────────
describe('deriveClass', () => {
it('maps PreToolUse → "tool"', () => {
expect(deriveClass('PreToolUse')).toBe('tool');
});
it('maps PostToolUse → "tool"', () => {
expect(deriveClass('PostToolUse')).toBe('tool');
});
it('maps PermissionRequest → "waiting"', () => {
expect(deriveClass('PermissionRequest')).toBe('waiting');
});
it('maps Notification → "waiting"', () => {
expect(deriveClass('Notification')).toBe('waiting');
});
it('maps Stop → "done"', () => {
expect(deriveClass('Stop')).toBe('done');
});
it('maps SessionEnd → "done"', () => {
expect(deriveClass('SessionEnd')).toBe('done');
});
it('maps UserPromptSubmit → "user"', () => {
expect(deriveClass('UserPromptSubmit')).toBe('user');
});
it('returns "tool" as a safe fallback for unknown event names', () => {
expect(deriveClass('SomeUnknownEvent')).toBe('tool');
});
});
// ── deriveLabel ───────────────────────────────────────────────────────────────
describe('deriveLabel', () => {
describe('PreToolUse', () => {
it('returns a label that includes the tool name when provided', () => {
const label = deriveLabel('PreToolUse', 'Bash');
expect(label).toContain('Bash');
});
it('returns a generic label when toolName is absent', () => {
const label = deriveLabel('PreToolUse', undefined);
expect(label.length).toBeGreaterThan(0);
});
});
describe('PostToolUse', () => {
it('returns a "ran" label for execution tools (Bash)', () => {
const label = deriveLabel('PostToolUse', 'Bash');
expect(label.toLowerCase()).toContain('bash');
});
it('returns an "edit" flavour label for Edit tool', () => {
const label = deriveLabel('PostToolUse', 'Edit');
// Should be something like "edited file" or "ran Edit" — contract just says it's human
expect(label.length).toBeGreaterThan(0);
});
it('returns a generic label when toolName is absent', () => {
const label = deriveLabel('PostToolUse', undefined);
expect(label.length).toBeGreaterThan(0);
});
});
describe('waiting events', () => {
it('returns an approval-flavoured label for PermissionRequest', () => {
const label = deriveLabel('PermissionRequest', undefined);
expect(label.length).toBeGreaterThan(0);
expect(label.toLowerCase()).toMatch(/wait|approv|permiss/);
});
it('returns an approval-flavoured label for Notification', () => {
const label = deriveLabel('Notification', undefined);
expect(label.toLowerCase()).toMatch(/wait|approv|permiss/);
});
});
describe('done events', () => {
it('returns a "done" label for Stop', () => {
expect(deriveLabel('Stop', undefined)).toMatch(/done|stop|end/i);
});
it('returns a "done" label for SessionEnd', () => {
expect(deriveLabel('SessionEnd', undefined)).toMatch(/done|stop|end/i);
});
});
describe('user input', () => {
it('returns a user-input label for UserPromptSubmit', () => {
const label = deriveLabel('UserPromptSubmit', undefined);
expect(label.toLowerCase()).toMatch(/user|input|prompt/);
});
});
});
// ── makeTimelineEvent ─────────────────────────────────────────────────────────
describe('makeTimelineEvent', () => {
it('returns null for an unknown event name (whitelist guard)', () => {
expect(makeTimelineEvent('UnknownHookType', undefined, 1000)).toBeNull();
});
it('returns null for an empty string event name', () => {
expect(makeTimelineEvent('', undefined, 1000)).toBeNull();
});
it('returns a TimelineEvent for PreToolUse', () => {
const ev = makeTimelineEvent('PreToolUse', 'Bash', 1234);
expect(ev).not.toBeNull();
expect(ev!.at).toBe(1234);
expect(ev!.class).toBe('tool');
expect(ev!.toolName).toBe('Bash');
expect(ev!.label.length).toBeGreaterThan(0);
});
it('returns a TimelineEvent for PostToolUse', () => {
const ev = makeTimelineEvent('PostToolUse', 'Bash', 9999);
expect(ev).not.toBeNull();
expect(ev!.class).toBe('tool');
expect(ev!.toolName).toBe('Bash');
});
it('returns a TimelineEvent for PermissionRequest', () => {
const ev = makeTimelineEvent('PermissionRequest', undefined, 5000);
expect(ev).not.toBeNull();
expect(ev!.class).toBe('waiting');
expect(ev!.toolName).toBeUndefined();
});
it('returns a TimelineEvent for Stop', () => {
const ev = makeTimelineEvent('Stop', undefined, 7000);
expect(ev).not.toBeNull();
expect(ev!.class).toBe('done');
});
it('returns a TimelineEvent for SessionEnd', () => {
const ev = makeTimelineEvent('SessionEnd', undefined, 8000);
expect(ev).not.toBeNull();
expect(ev!.class).toBe('done');
});
it('returns a TimelineEvent for UserPromptSubmit', () => {
const ev = makeTimelineEvent('UserPromptSubmit', undefined, 6000);
expect(ev).not.toBeNull();
expect(ev!.class).toBe('user');
});
it('returns a TimelineEvent for Notification', () => {
const ev = makeTimelineEvent('Notification', undefined, 2000);
expect(ev).not.toBeNull();
expect(ev!.class).toBe('waiting');
});
it('sanitizes control characters in toolName (SEC-H6)', () => {
const ev = makeTimelineEvent('PostToolUse', 'Ba\x01sh\x1bscript', 1000);
expect(ev).not.toBeNull();
expect(ev!.toolName).toBe('Bashscript');
});
it('truncates toolName to 200 characters (SEC-H6)', () => {
const longName = 'a'.repeat(250);
const ev = makeTimelineEvent('PostToolUse', longName, 1000);
expect(ev).not.toBeNull();
expect(ev!.toolName!.length).toBe(200);
});
it('omits toolName from the result when toolName is undefined', () => {
const ev = makeTimelineEvent('Stop', undefined, 1000);
expect(ev).not.toBeNull();
expect(ev!.toolName).toBeUndefined();
});
});
// ── appendEvent ───────────────────────────────────────────────────────────────
describe('appendEvent', () => {
it('appends to an empty array', () => {
const ev = makeEvent({ at: 100 });
const result = appendEvent([], ev, 10);
expect(result).toHaveLength(1);
expect(result[0]).toBe(ev);
});
it('appends to a non-empty array', () => {
const ev1 = makeEvent({ at: 100 });
const ev2 = makeEvent({ at: 200 });
const result = appendEvent([ev1], ev2, 10);
expect(result).toHaveLength(2);
expect(result[1]).toBe(ev2);
});
it('does not mutate the input array', () => {
const original: readonly TimelineEvent[] = [makeEvent({ at: 1 })];
const ev = makeEvent({ at: 2 });
appendEvent(original, ev, 10);
expect(original).toHaveLength(1);
});
it('returns a new array reference (immutability)', () => {
const original: readonly TimelineEvent[] = [makeEvent({ at: 1 })];
const ev = makeEvent({ at: 2 });
const result = appendEvent(original, ev, 10);
expect(result).not.toBe(original);
});
it('evicts the oldest events when length exceeds maxLen', () => {
const events: readonly TimelineEvent[] = [
makeEvent({ at: 1 }),
makeEvent({ at: 2 }),
makeEvent({ at: 3 }),
];
const ev = makeEvent({ at: 4 });
// maxLen = 3, so appending a 4th must evict the oldest (at=1)
const result = appendEvent(events, ev, 3);
expect(result).toHaveLength(3);
expect(result[0]!.at).toBe(2);
expect(result[2]!.at).toBe(4);
});
it('enforces the ring cap: append MAX+10 events → length ≤ MAX (SEC-M8)', () => {
const MAX = 5;
let events: readonly TimelineEvent[] = [];
for (let i = 0; i < MAX + 10; i++) {
events = appendEvent(events, makeEvent({ at: i }), MAX);
}
expect(events.length).toBeLessThanOrEqual(MAX);
});
it('keeps only the newest events when trimming (oldest are evicted first)', () => {
const MAX = 3;
let events: readonly TimelineEvent[] = [];
for (let i = 0; i < 10; i++) {
events = appendEvent(events, makeEvent({ at: i }), MAX);
}
// Should have the last 3 events: at = 7, 8, 9
expect(events.map((e) => e.at)).toEqual([7, 8, 9]);
});
it('handles maxLen of 1 (only the newest event survives)', () => {
const ev1 = makeEvent({ at: 1 });
const ev2 = makeEvent({ at: 2 });
const result = appendEvent([ev1], ev2, 1);
expect(result).toHaveLength(1);
expect(result[0]!.at).toBe(2);
});
});
// ── integration: each TimelineClass has at least one example ─────────────────
describe('TimelineClass coverage (AC-A4.4)', () => {
const WHITELISTED_EVENTS: Array<{ event: string; toolName?: string; expectedClass: string }> = [
{ event: 'PreToolUse', toolName: 'Bash', expectedClass: 'tool' },
{ event: 'PostToolUse', toolName: 'Read', expectedClass: 'tool' },
{ event: 'PermissionRequest', expectedClass: 'waiting' },
{ event: 'Notification', expectedClass: 'waiting' },
{ event: 'Stop', expectedClass: 'done' },
{ event: 'SessionEnd', expectedClass: 'done' },
{ event: 'UserPromptSubmit', expectedClass: 'user' },
];
for (const { event, toolName, expectedClass } of WHITELISTED_EVENTS) {
it(`${event} → class '${expectedClass}'`, () => {
const ev = makeTimelineEvent(event, toolName, Date.now());
expect(ev).not.toBeNull();
expect(ev!.class).toBe(expectedClass);
});
}
});