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:
@@ -13,7 +13,14 @@
|
||||
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
|
||||
import type { Config, Dims, Session, WebSocketLike } from '../src/types.js';
|
||||
import type {
|
||||
Config,
|
||||
Dims,
|
||||
NotifyService,
|
||||
Session,
|
||||
StatusTelemetry,
|
||||
WebSocketLike,
|
||||
} from '../src/types.js';
|
||||
import { WS_OPEN } from '../src/types.js';
|
||||
import { createMockPty, type MockIPty } from './helpers/mock-pty.js';
|
||||
|
||||
@@ -53,10 +60,53 @@ const CFG: Config = {
|
||||
previewBytes: 24 * 1024,
|
||||
useTmux: false,
|
||||
allowedOrigins: [],
|
||||
// v0.6 project manager
|
||||
projectRoots: ['/home/tester'],
|
||||
projectScanDepth: 4,
|
||||
projectScanTtlMs: 10_000,
|
||||
projectDirtyCheck: true,
|
||||
editorCmd: 'code',
|
||||
// v0.7 Walk-away Workbench
|
||||
vapidPublicKey: undefined,
|
||||
vapidPrivateKey: undefined,
|
||||
vapidSubject: undefined,
|
||||
pushStorePath: '/home/tester/.web-terminal-push-subs.json',
|
||||
pushMaxSubs: 50,
|
||||
notifyDone: true,
|
||||
notifyDnd: false,
|
||||
decisionTokenTtlMs: 300_000,
|
||||
timelineMax: 200,
|
||||
timelineEnabled: true,
|
||||
stuckTtlMs: 10_000, // 10 s for easy arithmetic in tests
|
||||
stuckAlert: true,
|
||||
diffTimeoutMs: 2000,
|
||||
diffMaxBytes: 2 * 1024 * 1024,
|
||||
diffMaxFiles: 300,
|
||||
statuslineTtlMs: 30_000,
|
||||
worktreeEnabled: true,
|
||||
worktreeRoot: undefined,
|
||||
worktreeTimeoutMs: 10_000,
|
||||
defaultPermissionMode: 'default',
|
||||
allowAutoMode: false,
|
||||
};
|
||||
|
||||
const DIMS: Dims = { cols: 80, rows: 24 };
|
||||
|
||||
/** A telemetry sample for B2 tests. */
|
||||
function makeTelemetry(at = 1_000): StatusTelemetry {
|
||||
return { contextUsedPct: 42, costUsd: 1.23, model: 'sonnet', at };
|
||||
}
|
||||
|
||||
/** A mock NotifyService (DI) recording every notify(session, cls, token?) call. */
|
||||
function createMockNotify() {
|
||||
const notify = vi.fn(async (_session: Session, _cls: string, _token?: string) => {});
|
||||
const service: NotifyService = {
|
||||
isEnabled: () => true,
|
||||
notify,
|
||||
};
|
||||
return { service, notify };
|
||||
}
|
||||
|
||||
interface MockWs extends WebSocketLike {
|
||||
readyState: number;
|
||||
sent: string[];
|
||||
@@ -646,3 +696,356 @@ describe('onExit removes session from table when ws attached at exit time (L2)',
|
||||
expect(session.exitedAt).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// ── handleHookEvent — A4 timeline + B4 gate (T-manager) ───────────────────────
|
||||
describe('handleHookEvent — timeline append (A4)', () => {
|
||||
it('appends a derived timeline event when eventClass is supplied + timeline enabled', () => {
|
||||
const mgr = createSessionManager(CFG);
|
||||
const ws = createMockWs();
|
||||
const s = mgr.handleAttach(ws, null, DIMS, 1_000);
|
||||
|
||||
mgr.handleHookEvent(s.meta.id, 'working', 'Bash', false, undefined, 'PreToolUse', 'Bash');
|
||||
|
||||
expect(s.timeline).toHaveLength(1);
|
||||
expect(s.timeline[0]).toMatchObject({ class: 'tool', toolName: 'Bash', label: 'using Bash' });
|
||||
expect(typeof s.timeline[0]?.at).toBe('number');
|
||||
});
|
||||
|
||||
it('replaces the timeline array immutably (never mutates the previous one)', () => {
|
||||
const mgr = createSessionManager(CFG);
|
||||
const ws = createMockWs();
|
||||
const s = mgr.handleAttach(ws, null, DIMS, 1_000);
|
||||
const before = s.timeline;
|
||||
|
||||
mgr.handleHookEvent(s.meta.id, 'working', undefined, false, undefined, 'PreToolUse', 'Bash');
|
||||
|
||||
expect(s.timeline).not.toBe(before);
|
||||
expect(before).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('does NOT append when timeline is disabled', () => {
|
||||
const mgr = createSessionManager({ ...CFG, timelineEnabled: false });
|
||||
const ws = createMockWs();
|
||||
const s = mgr.handleAttach(ws, null, DIMS, 1_000);
|
||||
|
||||
mgr.handleHookEvent(s.meta.id, 'working', undefined, false, undefined, 'PreToolUse', 'Bash');
|
||||
|
||||
expect(s.timeline).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('does NOT append when eventClass is omitted', () => {
|
||||
const mgr = createSessionManager(CFG);
|
||||
const ws = createMockWs();
|
||||
const s = mgr.handleAttach(ws, null, DIMS, 1_000);
|
||||
|
||||
mgr.handleHookEvent(s.meta.id, 'waiting', 'Bash', true);
|
||||
|
||||
expect(s.timeline).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('drops non-whitelisted event classes (makeTimelineEvent → null)', () => {
|
||||
const mgr = createSessionManager(CFG);
|
||||
const ws = createMockWs();
|
||||
const s = mgr.handleAttach(ws, null, DIMS, 1_000);
|
||||
|
||||
mgr.handleHookEvent(s.meta.id, 'working', undefined, false, undefined, 'BogusEvent', 'X');
|
||||
|
||||
expect(s.timeline).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('evicts oldest events past cfg.timelineMax', () => {
|
||||
const mgr = createSessionManager({ ...CFG, timelineMax: 3 });
|
||||
const ws = createMockWs();
|
||||
const s = mgr.handleAttach(ws, null, DIMS, 1_000);
|
||||
|
||||
for (let i = 0; i < 5; i += 1) {
|
||||
mgr.handleHookEvent(s.meta.id, 'working', undefined, false, undefined, 'PreToolUse', `Tool${i}`);
|
||||
}
|
||||
|
||||
expect(s.timeline).toHaveLength(3);
|
||||
expect(s.timeline.map((e) => e.toolName)).toEqual(['Tool2', 'Tool3', 'Tool4']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('handleHookEvent — gate broadcast (B4)', () => {
|
||||
it('broadcasts the status frame with gate when supplied', () => {
|
||||
const mgr = createSessionManager(CFG);
|
||||
const ws = createMockWs();
|
||||
const s = mgr.handleAttach(ws, null, DIMS, 1_000);
|
||||
ws.sent.length = 0;
|
||||
|
||||
mgr.handleHookEvent(s.meta.id, 'waiting', 'ExitPlanMode', true, 'plan', 'PermissionRequest', 'ExitPlanMode');
|
||||
|
||||
const status = parseSent(ws).find((m) => m.type === 'status');
|
||||
expect(status).toMatchObject({
|
||||
type: 'status',
|
||||
status: 'waiting',
|
||||
detail: 'ExitPlanMode',
|
||||
pending: true,
|
||||
gate: 'plan',
|
||||
});
|
||||
});
|
||||
|
||||
it('omits gate when not supplied', () => {
|
||||
const mgr = createSessionManager(CFG);
|
||||
const ws = createMockWs();
|
||||
const s = mgr.handleAttach(ws, null, DIMS, 1_000);
|
||||
ws.sent.length = 0;
|
||||
|
||||
mgr.handleHookEvent(s.meta.id, 'waiting', 'Bash', true);
|
||||
|
||||
const status = parseSent(ws).find((m) => m.type === 'status');
|
||||
expect(status).not.toHaveProperty('gate');
|
||||
});
|
||||
});
|
||||
|
||||
// ── handleStatusLine (B2 telemetry) ───────────────────────────────────────────
|
||||
describe('handleStatusLine', () => {
|
||||
it('stores telemetry on the session and broadcasts it to all clients', () => {
|
||||
const mgr = createSessionManager(CFG);
|
||||
const a = createMockWs();
|
||||
const b = createMockWs();
|
||||
const s = mgr.handleAttach(a, null, DIMS, 1_000);
|
||||
mgr.handleAttach(b, s.meta.id, DIMS, 2_000);
|
||||
a.sent.length = 0;
|
||||
b.sent.length = 0;
|
||||
|
||||
const telemetry = makeTelemetry();
|
||||
mgr.handleStatusLine(s.meta.id, telemetry);
|
||||
|
||||
expect(s.telemetry).toBe(telemetry);
|
||||
for (const ws of [a, b]) {
|
||||
const msg = parseSent(ws).find((m) => m.type === 'telemetry');
|
||||
expect(msg).toMatchObject({ type: 'telemetry', telemetry: { costUsd: 1.23, model: 'sonnet' } });
|
||||
}
|
||||
});
|
||||
|
||||
it('is a no-op for an unknown session id', () => {
|
||||
const mgr = createSessionManager(CFG);
|
||||
expect(() => mgr.handleStatusLine('00000000-0000-4000-8000-0000000000cc', makeTelemetry())).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
// ── handleAttach Case 2 — late-join telemetry/status replay (M3 / AC-B2.3) ─────
|
||||
describe('handleAttach — late-join replay (M3)', () => {
|
||||
it('sends the current telemetry to a device joining a live session', () => {
|
||||
const mgr = createSessionManager(CFG);
|
||||
const a = createMockWs();
|
||||
const s = mgr.handleAttach(a, null, DIMS, 1_000);
|
||||
mgr.handleStatusLine(s.meta.id, makeTelemetry());
|
||||
|
||||
const b = createMockWs();
|
||||
mgr.handleAttach(b, s.meta.id, DIMS, 2_000);
|
||||
|
||||
const msg = parseSent(b).find((m) => m.type === 'telemetry');
|
||||
expect(msg).toMatchObject({ type: 'telemetry', telemetry: { model: 'sonnet' } });
|
||||
});
|
||||
|
||||
it('sends the current status to a late-joining device', () => {
|
||||
const mgr = createSessionManager(CFG);
|
||||
const a = createMockWs();
|
||||
const s = mgr.handleAttach(a, null, DIMS, 1_000);
|
||||
mgr.handleHookEvent(s.meta.id, 'working');
|
||||
|
||||
const b = createMockWs();
|
||||
mgr.handleAttach(b, s.meta.id, DIMS, 2_000);
|
||||
|
||||
const status = parseSent(b).find((m) => m.type === 'status');
|
||||
expect(status).toMatchObject({ type: 'status', status: 'working' });
|
||||
});
|
||||
|
||||
it('does NOT send a telemetry frame when the session has no telemetry yet', () => {
|
||||
const mgr = createSessionManager(CFG);
|
||||
const a = createMockWs();
|
||||
const s = mgr.handleAttach(a, null, DIMS, 1_000);
|
||||
|
||||
const b = createMockWs();
|
||||
mgr.handleAttach(b, s.meta.id, DIMS, 2_000);
|
||||
|
||||
expect(parseSent(b).some((m) => m.type === 'telemetry')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ── sweepStuck (A5) ───────────────────────────────────────────────────────────
|
||||
describe('sweepStuck (A5)', () => {
|
||||
it('marks a silent live session stuck, broadcasts, and notifies once', () => {
|
||||
const { service, notify } = createMockNotify();
|
||||
const mgr = createSessionManager(CFG, service);
|
||||
const ws = createMockWs();
|
||||
const s = mgr.handleAttach(ws, null, DIMS, 1_000); // lastOutputAt = 1000
|
||||
ws.sent.length = 0;
|
||||
|
||||
mgr.sweepStuck(1_000 + CFG.stuckTtlMs + 1);
|
||||
|
||||
expect(s.claudeStatus).toBe('stuck');
|
||||
expect(s.stuckNotified).toBe(true);
|
||||
const status = parseSent(ws).find((m) => m.type === 'status');
|
||||
expect(status).toMatchObject({ type: 'status', status: 'stuck' });
|
||||
expect(notify).toHaveBeenCalledTimes(1);
|
||||
expect(notify).toHaveBeenCalledWith(s, 'stuck');
|
||||
});
|
||||
|
||||
it('covers ATTACHED sessions, not only detached ones (AC-A5.3)', () => {
|
||||
const { service, notify } = createMockNotify();
|
||||
const mgr = createSessionManager(CFG, service);
|
||||
const ws = createMockWs();
|
||||
const s = mgr.handleAttach(ws, null, DIMS, 1_000);
|
||||
// still attached: clients.size === 1, detachedAt === null
|
||||
|
||||
mgr.sweepStuck(1_000 + CFG.stuckTtlMs + 1);
|
||||
|
||||
expect(s.detachedAt).toBeNull();
|
||||
expect(s.claudeStatus).toBe('stuck');
|
||||
expect(notify).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('alerts at most once per silent round (AC-A5.1)', () => {
|
||||
const { service, notify } = createMockNotify();
|
||||
const mgr = createSessionManager(CFG, service);
|
||||
const ws = createMockWs();
|
||||
const s = mgr.handleAttach(ws, null, DIMS, 1_000);
|
||||
|
||||
mgr.sweepStuck(1_000 + CFG.stuckTtlMs + 1);
|
||||
mgr.sweepStuck(1_000 + CFG.stuckTtlMs + 9_999);
|
||||
|
||||
expect(notify).toHaveBeenCalledTimes(1);
|
||||
expect(s.claudeStatus).toBe('stuck');
|
||||
});
|
||||
|
||||
it('re-arms after output recovery so a later silence alerts again (AC-A5.2)', () => {
|
||||
const { service, notify } = createMockNotify();
|
||||
const mgr = createSessionManager(CFG, service);
|
||||
const ws = createMockWs();
|
||||
const s = mgr.handleAttach(ws, null, DIMS, 1_000);
|
||||
|
||||
mgr.sweepStuck(1_000 + CFG.stuckTtlMs + 1);
|
||||
expect(notify).toHaveBeenCalledTimes(1);
|
||||
|
||||
// Output recovery (session.ts onData) re-arms the flag + status.
|
||||
(s.pty as MockIPty).emitData('back to work');
|
||||
expect(s.stuckNotified).toBe(false);
|
||||
expect(s.claudeStatus).toBe('working');
|
||||
s.lastOutputAt = 100_000; // pin to a known cursor
|
||||
|
||||
mgr.sweepStuck(100_000 + CFG.stuckTtlMs + 1);
|
||||
expect(notify).toHaveBeenCalledTimes(2);
|
||||
expect(s.claudeStatus).toBe('stuck');
|
||||
});
|
||||
|
||||
it('skips idle sessions', () => {
|
||||
const { service, notify } = createMockNotify();
|
||||
const mgr = createSessionManager(CFG, service);
|
||||
const ws = createMockWs();
|
||||
const s = mgr.handleAttach(ws, null, DIMS, 1_000);
|
||||
s.claudeStatus = 'idle';
|
||||
|
||||
mgr.sweepStuck(1_000 + CFG.stuckTtlMs + 1);
|
||||
|
||||
expect(s.claudeStatus).toBe('idle');
|
||||
expect(notify).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('skips already-exited sessions (kept in table after detach-then-exit)', () => {
|
||||
const { service, notify } = createMockNotify();
|
||||
const mgr = createSessionManager(CFG, service);
|
||||
const ws = createMockWs();
|
||||
const s = mgr.handleAttach(ws, null, DIMS, 1_000);
|
||||
s.clients.clear();
|
||||
s.detachedAt = 2_000;
|
||||
(s.pty as MockIPty).emitExit(0); // detached-then-exit → stays in table (L1)
|
||||
|
||||
mgr.sweepStuck(1_000 + CFG.stuckTtlMs + 1);
|
||||
|
||||
expect(s.claudeStatus).not.toBe('stuck');
|
||||
expect(notify).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not mark a session whose output is within the TTL window', () => {
|
||||
const { service, notify } = createMockNotify();
|
||||
const mgr = createSessionManager(CFG, service);
|
||||
const ws = createMockWs();
|
||||
const s = mgr.handleAttach(ws, null, DIMS, 1_000);
|
||||
|
||||
mgr.sweepStuck(1_000 + CFG.stuckTtlMs - 1);
|
||||
|
||||
expect(s.claudeStatus).not.toBe('stuck');
|
||||
expect(notify).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('is disabled when stuckAlert is false', () => {
|
||||
const { service, notify } = createMockNotify();
|
||||
const mgr = createSessionManager({ ...CFG, stuckAlert: false }, service);
|
||||
const ws = createMockWs();
|
||||
const s = mgr.handleAttach(ws, null, DIMS, 1_000);
|
||||
|
||||
mgr.sweepStuck(1_000 + CFG.stuckTtlMs + 1);
|
||||
|
||||
expect(s.claudeStatus).not.toBe('stuck');
|
||||
expect(notify).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('is disabled when stuckTtlMs is 0', () => {
|
||||
const { service, notify } = createMockNotify();
|
||||
const mgr = createSessionManager({ ...CFG, stuckTtlMs: 0 }, service);
|
||||
const ws = createMockWs();
|
||||
const s = mgr.handleAttach(ws, null, DIMS, 1_000);
|
||||
|
||||
mgr.sweepStuck(99_999_999);
|
||||
|
||||
expect(s.claudeStatus).not.toBe('stuck');
|
||||
expect(notify).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('swallows a notifyService rejection (logs via console.error, never throws)', async () => {
|
||||
const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
const notify = vi.fn(async () => {
|
||||
throw new Error('push transport down');
|
||||
});
|
||||
const service: NotifyService = { isEnabled: () => true, notify };
|
||||
const mgr = createSessionManager(CFG, service);
|
||||
const ws = createMockWs();
|
||||
const s = mgr.handleAttach(ws, null, DIMS, 1_000);
|
||||
|
||||
expect(() => mgr.sweepStuck(1_000 + CFG.stuckTtlMs + 1)).not.toThrow();
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
|
||||
expect(s.claudeStatus).toBe('stuck');
|
||||
expect(errSpy).toHaveBeenCalled();
|
||||
errSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('works without an injected notifyService (broadcasts, no throw)', () => {
|
||||
const mgr = createSessionManager(CFG); // no notifyService
|
||||
const ws = createMockWs();
|
||||
const s = mgr.handleAttach(ws, null, DIMS, 1_000);
|
||||
ws.sent.length = 0;
|
||||
|
||||
expect(() => mgr.sweepStuck(1_000 + CFG.stuckTtlMs + 1)).not.toThrow();
|
||||
expect(s.claudeStatus).toBe('stuck');
|
||||
expect(parseSent(ws).some((m) => m.type === 'status' && m.status === 'stuck')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ── list() carries telemetry (B2 thumbnail wall) ──────────────────────────────
|
||||
describe('list — telemetry field', () => {
|
||||
it('includes the latest telemetry for each session', () => {
|
||||
const mgr = createSessionManager(CFG);
|
||||
const ws = createMockWs();
|
||||
const s = mgr.handleAttach(ws, null, DIMS, 1_000);
|
||||
const telemetry = makeTelemetry();
|
||||
mgr.handleStatusLine(s.meta.id, telemetry);
|
||||
|
||||
const entry = mgr.list().find((e) => e.id === s.meta.id);
|
||||
expect(entry?.telemetry).toBe(telemetry);
|
||||
});
|
||||
|
||||
it('reports null telemetry before the first statusLine report', () => {
|
||||
const mgr = createSessionManager(CFG);
|
||||
const ws = createMockWs();
|
||||
const s = mgr.handleAttach(ws, null, DIMS, 1_000);
|
||||
|
||||
const entry = mgr.list().find((e) => e.id === s.meta.id);
|
||||
expect(entry?.telemetry).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user