Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 4 additions & 3 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,9 +49,10 @@ entry point (multi-turn sessions, persistence, export-to-workflow) are shipped.
resolution (2.B) landed (PR #40), `relavium run` is wired to the engine
(2.D, the M3 keystone — PR #41), the `--json` CI machine-output contract
landed (2.F — PR #42, ADR-0049), the engine regression harness (2.K — PR #43)
completes M3, and durable run history landed (2.H — PR #44, ADR-0050); and the provider/key
commands with OS-keychain storage landed (2.C — PR #45, behind ADR-0019 + ADR-0006). The next
pickup is 2.E (ink TUI). For live status, per-PR history,
completes M3, and durable run history landed (2.H — PR #44, ADR-0050); the provider/key
commands with OS-keychain storage landed (2.C — PR #45, behind ADR-0019 + ADR-0006); and the `ink`
streaming TUI landed (2.E — PR #46, behind ADR-0047). The next pickup is 2.G (interactive human-gate
prompt + `relavium gate` resume). For live status, per-PR history,
milestone dates, and open obligations, see the canonical home
[docs/roadmap/current.md](docs/roadmap/current.md); [README.md](README.md) is the
public overview.
Expand Down
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -93,8 +93,8 @@ One engine, three modes behind the one `LLMProvider` seam:
local-first BYOK — workflow parsing, DAG execution, live streaming, checkpoint/resume,
multi-provider failover, cost governance, and multimodal media I/O. **Phase 2 (the CLI) is
underway** — the CLI skeleton, config resolution, `relavium run` (wired to the engine), its
`--json` CI machine-output contract, the engine regression harness, durable local run history, and the
provider/key commands (API keys in the OS keychain) have landed (milestone **M3** reached). For live status and the full roadmap, see
`--json` CI machine-output contract, the engine regression harness, durable local run history, the
provider/key commands (API keys in the OS keychain), and the live `ink` streaming TUI have landed (milestone **M3** reached). For live status and the full roadmap, see
[docs/roadmap/current.md](docs/roadmap/current.md) and the
[roadmap](docs/roadmap/README.md).

Expand Down
1 change: 1 addition & 0 deletions apps/cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
"test": "vitest run"
},
"dependencies": {
"@clack/prompts": "catalog:",
"@napi-rs/keyring": "catalog:",
"@relavium/core": "workspace:*",
"@relavium/db": "workspace:*",
Expand Down
222 changes: 222 additions & 0 deletions apps/cli/src/commands/drive.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,222 @@
import {
EngineStateError,
createInMemoryHost,
parseWorkflow,
type WorkflowEngine,
} from '@relavium/core';
import type { GateDecision, RunEvent, RunPausedEvent } from '@relavium/shared';
import { describe, expect, it, vi } from 'vitest';

import { buildEngine } from '../engine/build-engine.js';
import type { GatePrompter } from '../gate/prompter.js';
import type { RunRenderer } from '../render/renderer.js';
import { captureIo } from '../test-support.js';
import { driveRun, shouldBreakOnPause } from './drive.js';

// gate → out: a single approval gate, then completes. The in-memory host pauses at the fail-closed gate.
const GATED = `schema_version: '1.0'
workflow:
id: drive-gated
nodes:
- { id: start, type: input }
- { id: g, type: human_gate, gate_type: approval }
- { id: out, type: output }
edges:
- { from: start, to: g }
- { from: g, to: out }
`;

async function startGatedRun(): Promise<{
engine: WorkflowEngine;
handle: ReturnType<WorkflowEngine['start']>;
}> {
const engine = await buildEngine({ host: createInMemoryHost() });
const def = parseWorkflow(GATED, { source: 'drive.test' });
const handle = engine.start({ workflow: def, inputs: {} });
return { engine, handle };
}

/** A renderer that records event types and spies on the suspend/resume/finalize lifecycle. */
function recordingRenderer(): { renderer: RunRenderer; events: RunEvent[] } {
const events: RunEvent[] = [];
return {
events,
renderer: {
onEvent: (e) => {
events.push(e);
},
suspend: vi.fn(),
resume: vi.fn(),
finalize: vi.fn(),
},
};
}

const prompterReturning = (value: GateDecision | null): GatePrompter => ({
prompt: vi.fn(() => Promise.resolve(value)),
});

describe('driveRun — interactive gate', () => {
it('resolves a gate inline (approve) — suspends the TUI, prompts, resumes the engine, drives to completion', async () => {
const { engine, handle } = await startGatedRun();
const { renderer, events } = recordingRenderer();
const prompter = prompterReturning({ decision: 'approved', decidedBy: 'cli' });
const { io } = captureIo();

const outcome = await driveRun({
engine,
handle,
makeRenderer: () => renderer,
gatePrompter: prompter,
io,
});

expect(outcome).toBe('completed');
expect(prompter.prompt).toHaveBeenCalledTimes(1);
expect(renderer.suspend).toHaveBeenCalledTimes(1); // released the terminal for the prompt...
expect(renderer.resume).toHaveBeenCalledTimes(1); // ...then re-mounted
const types = events.map((e) => e.type);
expect(types).toContain('human_gate:resumed'); // the engine applied the decision
expect(types.at(-1)).toBe('run:completed'); // and continued past the (ignored) run:paused to the terminal
});

it('passes the built decision through to the engine (the resumed event carries it)', async () => {
const { engine, handle } = await startGatedRun();
const { renderer, events } = recordingRenderer();
const { io } = captureIo();

await driveRun({
engine,
handle,
makeRenderer: () => renderer,
gatePrompter: prompterReturning({ decision: 'rejected', decidedBy: 'cli', comment: 'no' }),
io,
});

const resumed = events.find((e) => e.type === 'human_gate:resumed');
expect(resumed).toMatchObject({ decision: 'rejected', decidedBy: 'cli' });
});

it('a null decision (the user aborted the prompt) cooperatively cancels the run', async () => {
const { engine, handle } = await startGatedRun();
const { renderer, events } = recordingRenderer();
const { io } = captureIo();

const outcome = await driveRun({
engine,
handle,
makeRenderer: () => renderer,
gatePrompter: prompterReturning(null),
io,
});

expect(outcome).toBe('cancelled');
expect(events.at(-1)?.type).toBe('run:cancelled');
});

it('re-mounts the TUI even if the prompt throws (finally), cancels the live run, then the error unwinds', async () => {
const { engine, handle } = await startGatedRun();
const { renderer } = recordingRenderer();
const prompter: GatePrompter = {
prompt: () => Promise.reject(new Error('prompt boom')),
};
const { io } = captureIo();
const cancelSpy = vi.spyOn(handle, 'cancel');

await expect(
driveRun({ engine, handle, makeRenderer: () => renderer, gatePrompter: prompter, io }),
).rejects.toThrow('prompt boom');
expect(renderer.resume).toHaveBeenCalledTimes(1); // the suspend/prompt/resume finally restored the view
expect(renderer.finalize).toHaveBeenCalledTimes(1); // driveRun's finally still finalized
expect(cancelSpy).toHaveBeenCalledTimes(1); // ...and cancelled the still-live run (abnormal-unwind guard)
});

it('a renderer.resume() throw does NOT mask the prompt error (logs the resume failure to stderr)', async () => {
const { engine, handle } = await startGatedRun();
const renderer: RunRenderer = {
onEvent: () => {},
suspend: vi.fn(),
resume: vi.fn(() => {
throw new Error('resume boom');
}),
finalize: vi.fn(),
};
const prompter: GatePrompter = {
prompt: () => Promise.reject(new Error('prompt boom')),
};
const { io, err } = captureIo();

await expect(
driveRun({ engine, handle, makeRenderer: () => renderer, gatePrompter: prompter, io }),
).rejects.toThrow('prompt boom'); // the PROMPT error survives — the resume throw did not replace it
expect(err()).toContain('failed to restore the live view'); // the resume failure went to stderr instead
});

it('without a prompter, a gate pause stops the loop (→ paused, exit 3)', async () => {
const { engine, handle } = await startGatedRun();
const { renderer } = recordingRenderer();
const { io } = captureIo();
const outcome = await driveRun({ engine, handle, makeRenderer: () => renderer, io });
expect(outcome).toBe('paused');
});

it('swallows a run_already_terminal from a cancel-during-prompt race, draining to cancelled (no opaque error)', async () => {
const { engine, handle } = await startGatedRun();
const { renderer } = recordingRenderer();
const { io } = captureIo();
// The SIGINT-during-prompt race: the prompt cooperatively cancels the run (→ run:cancelled), then returns
// a decision the engine now refuses with run_already_terminal. driveRun must swallow that and drain to the
// cancelled terminal — never surface it as a generic "unexpected internal error".
const resumeSpy = vi
.spyOn(engine, 'resume')
.mockRejectedValue(
new EngineStateError('run_already_terminal', 'the run has already terminated'),
);
const prompter: GatePrompter = {
prompt: () => {
handle.cancel(); // cooperative cancel → the run drains to run:cancelled
return Promise.resolve({ decision: 'approved', decidedBy: 'cli' });
},
};

const outcome = await driveRun({
engine,
handle,
makeRenderer: () => renderer,
gatePrompter: prompter,
io,
});

expect(resumeSpy).toHaveBeenCalledTimes(1); // the race path WAS taken...
expect(outcome).toBe('cancelled'); // ...and resolved cleanly to the cancelled terminal, no throw
});
});

describe('shouldBreakOnPause', () => {
const pause = (over: Partial<RunPausedEvent> = {}): RunPausedEvent => ({
type: 'run:paused',
runId: 'r',
timestamp: '2026-06-24T10:00:00.000Z',
sequenceNumber: 5,
pendingGateCount: 1,
gateIds: ['g1'],
...over,
});

it('always breaks with no prompter (CI / --json / no-TTY → exit 3)', () => {
expect(shouldBreakOnPause(pause(), false, new Set())).toBe(true);
});

it('ignores a pause whose every gate was handled inline (no media park)', () => {
expect(shouldBreakOnPause(pause({ gateIds: ['g1'] }), true, new Set(['g1']))).toBe(false);
});

it('breaks on a gate it never handled (cannot resolve inline)', () => {
expect(shouldBreakOnPause(pause({ gateIds: ['g1', 'g2'] }), true, new Set(['g1']))).toBe(true);
});

it('breaks on a media-job park even when every gate was handled', () => {
const event = pause({ pendingGateCount: 0, gateIds: [], pendingMediaJobNodeIds: ['m'] });
expect(shouldBreakOnPause(event, true, new Set())).toBe(true);
});
});
Loading
Loading