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
9 changes: 5 additions & 4 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,10 +45,11 @@ non-agent node handlers) with live streaming, per-node-boundary checkpoint/resum
multimodal media I/O (input, inline output, and the async generation job loop). All
three adapters (Anthropic, OpenAI/DeepSeek, Gemini) and the agent-first `AgentSession`
entry point (multi-turn sessions, persistence, export-to-workflow) are shipped.
**Phase 2 (CLI, milestone M3) is in progress** — the CLI skeleton (2.A) and config
resolution (2.B) have landed (PR #40), `relavium run` is wired to the engine
(2.D, the M3 keystone — PR #41), and the `--json` CI machine-output contract has
landed (2.F — PR #42, ADR-0049). For live status, per-PR history,
**Phase 2 (CLI) is underway and milestone M3 is reached** — the CLI skeleton (2.A) and config
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), and the engine regression harness (2.K — PR #43)
completes M3. The next pickup is 2.H (durable run history). 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
5 changes: 3 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -92,8 +92,9 @@ One engine, three modes behind the one `LLMProvider` seam:
**Phase 1 — Engine and LLM is complete** (2026-06-21): the engine runs end-to-end on
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), and its
`--json` CI machine-output contract have landed. For live status and the full roadmap, see
underway** — the CLI skeleton, config resolution, `relavium run` (wired to the engine), its
`--json` CI machine-output contract, and the engine regression harness 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 @@ -18,6 +18,7 @@
},
"dependencies": {
"@relavium/core": "workspace:*",
"@relavium/db": "workspace:*",
"@relavium/llm": "workspace:*",
"@relavium/shared": "workspace:*",
"commander": "catalog:",
Expand Down
97 changes: 64 additions & 33 deletions apps/cli/src/commands/run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,11 +13,13 @@ import {
buildEngine as defaultBuildEngine,
type BuildEngineOptions,
} from '../engine/build-engine.js';
import { createCliHost } from '../engine/host.js';
import {
createProviderResolver,
neededProviderIds,
type ProviderResolver,
} from '../engine/providers.js';
import type { OpenedHistory } from '../history/open.js';
import { CliError } from '../process/errors.js';
import { EXIT_CODES, type ExitCode } from '../process/exit-codes.js';
import type { CliIo } from '../process/io.js';
Expand All @@ -38,6 +40,11 @@ export interface RunCommandDeps {
readonly buildEngine?: (options?: BuildEngineOptions) => Promise<WorkflowEngine>;
/** Injectable provider seam (key pre-flight + the engine's resolver). Defaults to the env resolver. */
readonly providers?: ProviderResolver;
/**
* Production wires the durable SQLite run-history store (2.H) here, per workflow; the unit tests and the
* 2.K harness omit it, keeping the in-memory `RunStore` so they never open `~/.relavium/history.db`.
*/
readonly openRunStore?: (workflow: WorkflowDefinition, homeDir: string) => OpenedHistory;
}

type RunOutcome = 'completed' | 'failed' | 'cancelled' | 'paused';
Expand All @@ -55,8 +62,9 @@ export async function runCommand(args: RunCommandArgs, deps: RunCommandDeps): Pr
// One resolver shared by the key pre-flight and the engine, reading the CLI's env seam (io.env).
const providers = deps.providers ?? createProviderResolver(deps.io.env);

// Config (2.B) — a malformed layer surfaces as exit 2; the project dir powers id/slug discovery.
const { projectConfigDir } = loadResolvedConfig({
// Config (2.B) — a malformed layer surfaces as exit 2; the project dir powers id/slug discovery,
// homeDir locates `~/.relavium/history.db` (2.H).
const { projectConfigDir, homeDir } = loadResolvedConfig({
cwd: deps.global.cwd,
configPath: deps.global.configPath,
});
Expand Down Expand Up @@ -85,41 +93,64 @@ export async function runCommand(args: RunCommandArgs, deps: RunCommandDeps): Pr
providers.keyFor(id);
}

const engine = await build({ providers });
const handle = engine.start({ workflow: def, inputs });

const renderer = deps.global.json ? createJsonRenderer(deps.io) : createPlainRenderer(deps.io);
let outcome: RunOutcome | undefined;
// Register the cancel handler immediately before the consume loop — no statement between it and the
// `try` whose `finally` removes it, so the listener can never leak on an intervening throw.
const onSigint = (): void => {
handle.cancel(); // cooperative cancel → run:cancelled (idempotent, safe post-terminal)
};
process.once('SIGINT', onSigint);
// Durable history (2.H): open `~/.relavium/history.db` and run THIS workflow on a host backed by the
// SQLite `RunStore`, so every node-boundary/terminal event is persisted before delivery (ADR-0036). Tests
// and the 2.K harness omit `openRunStore` → the in-memory default host, no DB touched. `close()` releases
// the connection at run end. A persist failure rejects out of the engine (ADR-0050 fatal posture).
let opened: OpenedHistory | undefined;
try {
for await (const event of handle.events) {
renderer.onEvent(event);
outcome = nextOutcome(outcome, event);
if (event.type === 'run:paused') {
// A human gate — the only `run:paused` source in 2.D (no media-job host is wired; build-engine.ts).
// The interactive prompt + `relavium gate` resume are 2.G (and need 2.H persistence); for 2.D the
// run exits with the gate-paused code. When media host-wiring lands (2.S) a media-only park is also
// a `run:paused`, so revisit whether exit 3 should distinguish it then (deferred-tasks).
break;
opened = deps.openRunStore?.(def, homeDir);
} catch (err) {
// A pre-run history fault (cannot create / open / migrate ~/.relavium/history.db) is an INVOCATION
// fault (exit 2), not a workflow failure (exit 1) — surface it as such, before the engine starts, so a
// `--json`/CI consumer can tell "the history db couldn't open" from "a node failed mid-run".
throw new CliError(
'invalid_invocation',
`could not open the run history database: ${err instanceof Error ? err.message : String(err)}`,
{ cause: err },
);
}
try {
const engine = await build(
opened === undefined ? { providers } : { providers, host: createCliHost(opened.store) },
);
const handle = engine.start({ workflow: def, inputs });

const renderer = deps.global.json ? createJsonRenderer(deps.io) : createPlainRenderer(deps.io);
let outcome: RunOutcome | undefined;
// Register the cancel handler immediately before the consume loop — no statement between it and the
// `try` whose `finally` removes it, so the listener can never leak on an intervening throw.
const onSigint = (): void => {
handle.cancel(); // cooperative cancel → run:cancelled (idempotent, safe post-terminal)
};
process.once('SIGINT', onSigint);
try {
for await (const event of handle.events) {
renderer.onEvent(event);
outcome = nextOutcome(outcome, event);
if (event.type === 'run:paused') {
// A human gate — the only `run:paused` source in 2.D (no media-job host is wired; build-engine.ts).
// The interactive prompt + `relavium gate` resume are 2.G (the persisted gate state is now durable,
// 2.H); for now the run exits with the gate-paused code. When media host-wiring lands (2.S) a
// media-only park is also a `run:paused`, so revisit whether exit 3 should distinguish it then.
break;
}
}
} finally {
process.removeListener('SIGINT', onSigint);
}
} finally {
process.removeListener('SIGINT', onSigint);
}

switch (outcome) {
case 'completed':
return EXIT_CODES.success;
case 'paused':
return EXIT_CODES.gatePaused;
default:
// failed / cancelled / (an unreachable no-terminal) → non-zero workflow failure.
return EXIT_CODES.workflowFailed;
switch (outcome) {
case 'completed':
return EXIT_CODES.success;
case 'paused':
return EXIT_CODES.gatePaused;
default:
// failed / cancelled / (an unreachable no-terminal) → non-zero workflow failure.
return EXIT_CODES.workflowFailed;
}
} finally {
opened?.close();
}
}

Expand Down
4 changes: 3 additions & 1 deletion apps/cli/src/commands/specs.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type { Command } from 'commander';

import { openHistoryStore } from '../history/open.js';
import { CliError } from '../process/errors.js';
import type { ExitCode } from '../process/exit-codes.js';
import type { CliIo } from '../process/io.js';
Expand Down Expand Up @@ -109,7 +110,8 @@ function registerRun(program: Command, ctx?: CommandContext): void {
run.action(async (workflow: string, opts: { input?: readonly string[] }) => {
ctx.result.exitCode = await runCommand(
{ workflow, input: opts.input ?? [] },
{ io: ctx.io, global: ctx.global },
// Production wires durable run history (2.H) — the real CLI persists to ~/.relavium/history.db.
{ io: ctx.io, global: ctx.global, openRunStore: openHistoryStore },
);
});
}
Expand Down
4 changes: 3 additions & 1 deletion apps/cli/src/config/load.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,8 @@ export interface LoadedConfig {
readonly config: ResolvedConfig;
/** The discovered project `.relavium/` directory, or `undefined` when outside a project. */
readonly projectConfigDir: string | undefined;
/** The resolved home directory — the root of `~/.relavium/` (where `history.db` lives, 2.H/ADR-0050). */
readonly homeDir: string;
}

/**
Expand All @@ -102,7 +104,7 @@ export function loadResolvedConfig(options: LoadConfigOptions): LoadedConfig {
);
}

return { config: resolveConfig({ global, workspace, project }), projectConfigDir };
return { config: resolveConfig({ global, workspace, project }), projectConfigDir, homeDir: home };
}

function errnoCode(err: unknown): string | undefined {
Expand Down
13 changes: 11 additions & 2 deletions apps/cli/src/config/paths.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { mkdirSync, statSync } from 'node:fs';
import { chmodSync, mkdirSync, statSync } from 'node:fs';
import { homedir } from 'node:os';
import { dirname, join, resolve } from 'node:path';

Expand All @@ -13,10 +13,19 @@ export function globalConfigDir(home: string = homedir()): string {
return join(home, '.relavium');
}

/** Lazily create `~/.relavium/` (and its `tmp/`) on first run. Idempotent. */
/**
* Lazily create `~/.relavium/` (and its `tmp/`) on first run, owner-only (`0700`). Idempotent.
*
* The `0700` is the at-rest control for the unencrypted CLI `history.db` it will hold
* ([ADR-0050](../../../../docs/decisions/0050-cli-history-db-at-rest-posture.md)). The explicit
* `chmod` is deliberate: `mkdir`'s mode is umask-masked AND would not re-permission an already-existing
* directory, so a dir created earlier (e.g. by 2.B) is corrected here. On Windows POSIX mode bits do not
* apply (`chmod` is effectively a no-op) — protection there falls to the `%USERPROFILE%` NTFS ACL.
*/
export function ensureGlobalConfigDir(home: string = homedir()): string {
const dir = globalConfigDir(home);
mkdirSync(join(dir, 'tmp'), { recursive: true });
chmodSync(dir, 0o700);
return dir;
}

Expand Down
108 changes: 108 additions & 0 deletions apps/cli/src/history/open.e2e.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
import { randomUUID } from 'node:crypto';
import { existsSync, mkdtempSync, rmSync, statSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { fileURLToPath } from 'node:url';

import { reconstructCheckpointState } from '@relavium/core';
import { createClient, createRunHistoryStore, type RunHistoryStore } from '@relavium/db';
import type { RunEvent } from '@relavium/shared';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';

import { runCommand } from '../commands/run.js';
import { EXIT_CODES } from '../process/exit-codes.js';
import type { GlobalOptions } from '../process/options.js';
import { captureIo } from '../test-support.js';
import { openHistoryStore } from './open.js';

/**
* 2.H end-to-end: a real `relavium run` (the production engine + SQLite `RunStore`) persists to a
* `history.db` under a TEMP home, so the test never touches the user's `~/.relavium/`. The injected
* `openRunStore` redirects the store's home to the temp dir (it ignores the runCommand-derived homeDir),
* which is the seam tests use to avoid the real home. Asserts the durable rows, the ADR-0050 `0600`/`0700`
* at-rest permissions, and that the persisted events reconstruct a checkpoint in a fresh connection
* (the cross-process resume substrate 2.G consumes).
*/

const FIXTURES = fileURLToPath(new URL('../harness/fixtures/', import.meta.url));

function globalOptions(): GlobalOptions {
return { json: true, color: false, cwd: FIXTURES, configPath: undefined, verbosity: 'normal' };
}

/** Re-open the temp history.db read-only-ish for assertions (a fresh connection — the run's was closed). */
function reopen(home: string): { store: RunHistoryStore; close: () => void } {
const client = createClient(join(home, '.relavium', 'history.db'));
const store = createRunHistoryStore(client.db, {
uuid: () => randomUUID(),
now: () => Date.now(),
workflow: { slug: 'reader', name: 'reader', definitionJson: '{}' },
});
return { store, close: () => client.sqlite.close() };
}

describe('2.H durable run history — real run → history.db (temp home)', () => {
let home: string;

beforeEach(() => {
home = mkdtempSync(join(tmpdir(), 'relavium-2h-'));
});
afterEach(() => {
rmSync(home, { recursive: true, force: true });
});

it('persists a completed run (migrate-on-first-use) with owner-only perms', async () => {
const { io } = captureIo();
const code = await runCommand(
{ workflow: join(FIXTURES, 'sequential.relavium.yaml'), input: ['n=3'] },
{ io, global: globalOptions(), openRunStore: (wf) => openHistoryStore(wf, home) },
);
expect(code).toBe(EXIT_CODES.success);

const dbPath = join(home, '.relavium', 'history.db');
expect(existsSync(dbPath)).toBe(true);
if (process.platform !== 'win32') {
// ADR-0050: history.db is unencrypted, guarded by 0600 (file) / 0700 (dir) OS permissions.
expect(statSync(dbPath).mode & 0o777).toBe(0o600);
expect(statSync(join(home, '.relavium')).mode & 0o777).toBe(0o700);
}

const { store, close } = reopen(home);
try {
const runs = store.listRuns();
expect(runs).toHaveLength(1);
expect(runs[0]?.status).toBe('completed');
const events = store.loadRunEvents(runs[0]?.id ?? '');
expect(events[0]?.type).toBe('run:started');
expect(events.at(-1)?.type).toBe('run:completed');
expect(events.map((e) => e.sequenceNumber)).toEqual(events.map((_, i) => i)); // gap-free
} finally {
close();
}
});

it('persists a gate-paused run sufficient to reconstruct a checkpoint in a fresh connection (2.G substrate)', async () => {
const { io } = captureIo();
const code = await runCommand(
{ workflow: join(FIXTURES, 'human-gate.relavium.yaml'), input: [] },
{ io, global: globalOptions(), openRunStore: (wf) => openHistoryStore(wf, home) },
);
expect(code).toBe(EXIT_CODES.gatePaused);

const { store, close } = reopen(home);
try {
const run = store.listRuns()[0];
expect(run?.status).toBe('paused');
const interrupted = await store.listInterruptedRuns();
expect(interrupted.find((r) => r.runId === run?.id)?.resumable).toBe(true);

const events: RunEvent[] = store.loadRunEvents(run?.id ?? '');
expect(events.some((e) => e.type === 'human_gate:paused')).toBe(true);
// The durable log is sufficient for a fresh process to rebuild run state (the 2.G resume path).
expect(() => reconstructCheckpointState(events)).not.toThrow();
expect(reconstructCheckpointState(events)).toBeDefined();
} finally {
close();
}
});
});
Loading
Loading