Files
open-design/apps/daemon/tests/critique-run-registry.test.ts
6de802ba70 feat(daemon): add critique interrupt endpoint + project-keyed run registry (Task 6.1) (#819)
Phase 6.1 of the Critique Theater rollout: a single new endpoint and the
in-process registry that backs it.

POST /api/projects/:projectId/critique/:runId/interrupt cascades an
AbortController to the orchestrator that owns the spawned CLI so the
parser can flush best-so-far state and emit critique.interrupted before
the process exits. Backed by a new in-process run registry that the
orchestrator wiring registers each run into before runOrchestrator is
invoked, and unregisters in a finally block.

The registry is keyed by (projectId, runId), not just runId. A request
to interrupt project p1's runId cannot find or abort a registry handle
that belongs to project p2 even if their ids ever collide. The HTTP
handler also performs its own DB-row projectId check before calling the
registry, so cross-project leakage is blocked at two layers.

The endpoint is idempotent on already-interrupted rows: a client that
lost the first response and retries observes 202 with prevStatus
"interrupted" rather than a 409 conflict. Other terminal statuses
(shipped, failed, timed_out, degraded, below_threshold, legacy) still
return 409 because those runs reached their real terminal state on
their own and an interrupt is no longer meaningful.

Recovery path for stale running rows: when registry.interrupt returns
false (the in-process registry has no AbortController for this
projectId/runId pair) but the DB still says 'running', the endpoint
marks the row 'interrupted' directly with recoveryReason='no_live_handle'
and returns 202 with recovered=true. This window opens after a daemon
restart in the gap before reconcileStaleRuns sees the row old enough.
Without the recovery branch the endpoint would lie: 202 accepted, no
child signaled, no critique.interrupted event, row stuck running. The
new persistence helper markRunInterruptedRecovery mirrors the per-row
write reconcileStaleRuns already does, gated on status='running' so a
row that just transitioned terminal is not overwritten.

Task 6.2 (rerun endpoint) is intentionally not in this PR. The earlier
draft conflated row insertion between the handler and runOrchestrator
(primary key collision) and did not actually start a new agent spawn.
Rerun needs a real chat-run path with prior-art context, an artifact-id
validator, and SQL LIKE escaping that the row lookup path is missing
today; it is cleaner shipped as a follow-up than wedged into this PR.

Tests:
- critique-run-registry: 17 cases covering register, get, interrupt,
  unregister, list, plus the new (projectId, runId) composite key
  invariants (cross-project register, cross-project get/interrupt
  isolation, unregister keying).
- critique-interrupt-endpoint: 17 cases covering 202 happy path, 404 on
  unknown run, 404 on cross-project run, 404 cross-project leak guard at
  the registry layer, 409 on terminal statuses, 202 idempotent retry on
  already-interrupted, stale-handle defense, 202 + recovered on a stale
  running row with no live handle, 400 on bad params.

Incidental: apps/web/src/i18n/locales/id.ts was missing 18 fileViewer
deploy/Cloudflare keys after upstream landed PR #805 (R2 release
publishing). Without those keys the workspace web typecheck fails on
the i18n Dict equality check, blocking CI on every PR. Added Indonesian
translations for the missing keys to unblock.

Co-authored-by: Nagendhra <nagendhra405@gmail.com>
2026-05-08 11:29:37 +08:00

175 lines
6.0 KiB
TypeScript

import { describe, it, expect, beforeEach } from 'vitest';
import {
createRunRegistry,
type RunHandle,
type RunRegistry,
} from '../src/critique/run-registry.js';
function makeHandle(runId: string, projectId = 'p1'): RunHandle {
return {
runId,
projectId,
abort: new AbortController(),
startedAt: Date.now(),
};
}
describe('RunRegistry', () => {
let registry: RunRegistry;
beforeEach(() => {
registry = createRunRegistry();
});
// ---------------------------------------------------------------------------
// register / get
// ---------------------------------------------------------------------------
it('returns null for an unregistered (projectId, runId)', () => {
expect(registry.get('p1', 'unknown')).toBeNull();
});
it('register + get round-trips the handle', () => {
const h = makeHandle('crun_01');
registry.register(h);
expect(registry.get('p1', 'crun_01')).toBe(h);
});
it('register throws on duplicate (projectId, runId)', () => {
const h = makeHandle('crun_dup');
registry.register(h);
expect(() => registry.register(makeHandle('crun_dup'))).toThrow(
/duplicate \(projectId="p1", runId="crun_dup"\)/,
);
});
it('register accepts the same runId across different projects', () => {
// The composite key is (projectId, runId), so two different projects
// legitimately can carry runs that share an id without colliding.
const a = makeHandle('crun_shared', 'p1');
const b = makeHandle('crun_shared', 'p2');
expect(() => {
registry.register(a);
registry.register(b);
}).not.toThrow();
expect(registry.get('p1', 'crun_shared')).toBe(a);
expect(registry.get('p2', 'crun_shared')).toBe(b);
});
it('get does not return a handle from a different project even when the runId matches', () => {
// Cross-project leak guard: looking up a runId that exists in project p2
// while authenticated as p1 must return null, not p2's handle.
const inP2 = makeHandle('crun_only_in_p2', 'p2');
registry.register(inP2);
expect(registry.get('p1', 'crun_only_in_p2')).toBeNull();
expect(registry.get('p2', 'crun_only_in_p2')).toBe(inP2);
});
// ---------------------------------------------------------------------------
// interrupt
// ---------------------------------------------------------------------------
it('interrupt returns false for an unknown (projectId, runId)', () => {
expect(registry.interrupt('p1', 'not-there')).toBe(false);
});
it('interrupt fires the AbortController and returns true', () => {
const h = makeHandle('crun_02');
registry.register(h);
const aborted = registry.interrupt('p1', 'crun_02', 'user_requested');
expect(aborted).toBe(true);
expect(h.abort.signal.aborted).toBe(true);
});
it('interrupt passes the reason string to the abort signal', () => {
const h = makeHandle('crun_03');
registry.register(h);
registry.interrupt('p1', 'crun_03', 'test_reason');
expect(h.abort.signal.reason).toBe('test_reason');
});
it('interrupt with no reason still aborts the signal', () => {
const h = makeHandle('crun_04');
registry.register(h);
registry.interrupt('p1', 'crun_04');
expect(h.abort.signal.aborted).toBe(true);
});
it('interrupt does not abort a same-runId handle that belongs to a different project', () => {
// Cross-project leak guard for interrupt: a request to abort project p1's
// runId must NOT abort project p2's handle if their ids ever collide.
const sharedRunId = 'crun_shared_id';
const inP1 = makeHandle(sharedRunId, 'p1');
const inP2 = makeHandle(sharedRunId, 'p2');
registry.register(inP1);
registry.register(inP2);
const aborted = registry.interrupt('p1', sharedRunId);
expect(aborted).toBe(true);
expect(inP1.abort.signal.aborted).toBe(true);
expect(inP2.abort.signal.aborted).toBe(false);
});
// ---------------------------------------------------------------------------
// unregister
// ---------------------------------------------------------------------------
it('unregister removes the entry so get returns null', () => {
const h = makeHandle('crun_05');
registry.register(h);
registry.unregister('p1', 'crun_05');
expect(registry.get('p1', 'crun_05')).toBeNull();
});
it('unregister is a no-op for an unknown (projectId, runId)', () => {
expect(() => registry.unregister('p1', 'ghost')).not.toThrow();
});
it('unregister keys by project too: removing p1\'s runId does not remove p2\'s same-id handle', () => {
const sharedRunId = 'crun_shared_for_unregister';
const inP1 = makeHandle(sharedRunId, 'p1');
const inP2 = makeHandle(sharedRunId, 'p2');
registry.register(inP1);
registry.register(inP2);
registry.unregister('p1', sharedRunId);
expect(registry.get('p1', sharedRunId)).toBeNull();
expect(registry.get('p2', sharedRunId)).toBe(inP2);
});
// ---------------------------------------------------------------------------
// list
// ---------------------------------------------------------------------------
it('list returns empty array when no runs are registered', () => {
expect(registry.list()).toEqual([]);
});
it('list returns a snapshot of all registered handles across projects', () => {
const h1 = makeHandle('crun_10', 'p1');
const h2 = makeHandle('crun_11', 'p2');
registry.register(h1);
registry.register(h2);
const snap = registry.list();
expect(snap).toHaveLength(2);
expect(snap).toContain(h1);
expect(snap).toContain(h2);
});
it('list returns a defensive copy (mutations do not affect registry)', () => {
registry.register(makeHandle('crun_12'));
const snap = registry.list();
snap.splice(0);
expect(registry.list()).toHaveLength(1);
});
it('list excludes unregistered handles', () => {
registry.register(makeHandle('crun_13'));
registry.register(makeHandle('crun_14'));
registry.unregister('p1', 'crun_13');
const ids = registry.list().map((h) => h.runId);
expect(ids).not.toContain('crun_13');
expect(ids).toContain('crun_14');
});
});