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:
@@ -53,6 +53,12 @@ const CFG: Config = {
|
||||
previewBytes: 24 * 1024,
|
||||
useTmux: false,
|
||||
allowedOrigins: [],
|
||||
// v0.6 project manager fields
|
||||
projectRoots: ['/home/tester'],
|
||||
projectScanDepth: 4,
|
||||
projectScanTtlMs: 10_000,
|
||||
projectDirtyCheck: true,
|
||||
editorCmd: 'code',
|
||||
};
|
||||
|
||||
const DIMS: Dims = { cols: 80, rows: 24 };
|
||||
@@ -407,3 +413,129 @@ describe('kill', () => {
|
||||
expect((s.pty as MockIPty).killed).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ── spawn env (T-spawn-env / B2) ──────────────────────────────────────────────
|
||||
describe('spawn env', () => {
|
||||
it('injects WEBTERM_STATUSLINE_URL pointing to /hook/status (B2)', () => {
|
||||
newSession();
|
||||
const opts = spawnCalls[0]!.options as { env?: Record<string, string> };
|
||||
expect(opts.env?.['WEBTERM_STATUSLINE_URL']).toBe(
|
||||
`http://127.0.0.1:${CFG.port}/hook/status`,
|
||||
);
|
||||
});
|
||||
|
||||
it('still injects WEBTERM_SESSION and WEBTERM_HOOK_URL (existing env vars)', () => {
|
||||
const s = newSession();
|
||||
const opts = spawnCalls[0]!.options as { env?: Record<string, string> };
|
||||
expect(opts.env?.['WEBTERM_SESSION']).toBe(s.meta.id);
|
||||
expect(opts.env?.['WEBTERM_HOOK_URL']).toBe(`http://127.0.0.1:${CFG.port}/hook`);
|
||||
});
|
||||
|
||||
it('uses port from cfg (not hardcoded) in all URL env vars', () => {
|
||||
nextPty = createMockPty();
|
||||
const altCfg: Config = { ...CFG, port: 4321 };
|
||||
createSession(altCfg, DIMS, 1_000, () => {});
|
||||
const opts = spawnCalls[0]!.options as { env?: Record<string, string> };
|
||||
expect(opts.env?.['WEBTERM_STATUSLINE_URL']).toContain('4321');
|
||||
expect(opts.env?.['WEBTERM_HOOK_URL']).toContain('4321');
|
||||
});
|
||||
});
|
||||
|
||||
// ── session initialization — v0.7 fields (T-spawn-env) ───────────────────────
|
||||
describe('session v0.7 field initialization', () => {
|
||||
it('initializes timeline as an empty frozen array', () => {
|
||||
const s = newSession();
|
||||
expect(s.timeline).toEqual([]);
|
||||
expect(Object.isFrozen(s.timeline)).toBe(true);
|
||||
});
|
||||
|
||||
it('initializes stuckNotified as false', () => {
|
||||
const s = newSession();
|
||||
expect(s.stuckNotified).toBe(false);
|
||||
});
|
||||
|
||||
it('initializes telemetry as null', () => {
|
||||
const s = newSession();
|
||||
expect(s.telemetry).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// ── onData stuck re-arming (A5 / §3.4) ───────────────────────────────────────
|
||||
describe('onData stuck re-arming', () => {
|
||||
it('resets stuckNotified to false on any output', () => {
|
||||
const s = newSession();
|
||||
s.stuckNotified = true; // simulate a stuck alert having fired this round
|
||||
(s.pty as MockIPty).emitData('new output');
|
||||
expect(s.stuckNotified).toBe(false);
|
||||
});
|
||||
|
||||
it('resets claudeStatus from stuck→working and broadcasts when output arrives', () => {
|
||||
const s = newSession();
|
||||
const ws = createMockWs();
|
||||
attachWs(s, ws);
|
||||
ws.sent.length = 0;
|
||||
|
||||
s.claudeStatus = 'stuck';
|
||||
(s.pty as MockIPty).emitData('recovered output');
|
||||
|
||||
expect(s.claudeStatus).toBe('working');
|
||||
const msgs = received(ws);
|
||||
const statusMsg = msgs.find((m) => m.type === 'status');
|
||||
expect(statusMsg).toEqual({ type: 'status', status: 'working' });
|
||||
});
|
||||
|
||||
it('does NOT emit a status message when claudeStatus is not stuck', () => {
|
||||
const s = newSession();
|
||||
const ws = createMockWs();
|
||||
attachWs(s, ws);
|
||||
ws.sent.length = 0;
|
||||
|
||||
s.claudeStatus = 'working'; // not stuck
|
||||
(s.pty as MockIPty).emitData('normal chunk');
|
||||
|
||||
const msgs = received(ws);
|
||||
const statusMsgs = msgs.filter((m) => m.type === 'status');
|
||||
expect(statusMsgs).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('does NOT emit a status message for idle/unknown/waiting (only stuck → working)', () => {
|
||||
for (const status of ['idle', 'unknown', 'waiting'] as const) {
|
||||
nextPty = createMockPty();
|
||||
const s = createSession(CFG, DIMS, 1_000, () => {});
|
||||
const ws = createMockWs();
|
||||
attachWs(s, ws);
|
||||
ws.sent.length = 0;
|
||||
|
||||
s.claudeStatus = status;
|
||||
(s.pty as MockIPty).emitData('chunk');
|
||||
|
||||
const msgs = received(ws);
|
||||
const statusMsgs = msgs.filter((m) => m.type === 'status');
|
||||
expect(statusMsgs).toHaveLength(0);
|
||||
}
|
||||
});
|
||||
|
||||
it('broadcasts stuck reset even with no clients attached (no-op, does not throw)', () => {
|
||||
const s = newSession();
|
||||
// no clients attached
|
||||
s.claudeStatus = 'stuck';
|
||||
expect(() => (s.pty as MockIPty).emitData('silent recovery')).not.toThrow();
|
||||
expect(s.claudeStatus).toBe('working');
|
||||
expect(s.stuckNotified).toBe(false);
|
||||
});
|
||||
|
||||
it('output message is still broadcast after stuck re-arm', () => {
|
||||
const s = newSession();
|
||||
const ws = createMockWs();
|
||||
attachWs(s, ws);
|
||||
ws.sent.length = 0;
|
||||
|
||||
s.claudeStatus = 'stuck';
|
||||
(s.pty as MockIPty).emitData('hello after stuck');
|
||||
|
||||
const msgs = received(ws);
|
||||
const outputMsgs = msgs.filter((m) => m.type === 'output');
|
||||
expect(outputMsgs).toHaveLength(1);
|
||||
expect(outputMsgs[0]).toEqual({ type: 'output', data: 'hello after stuck' });
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user