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
12 changes: 12 additions & 0 deletions docs/SURFACE.md
Original file line number Diff line number Diff line change
Expand Up @@ -299,6 +299,7 @@ Gate 1 ships three CLI verbs over the journal protocol:
```text
flows check [--json] <flow.yaml|spec.json>
flows run [--json] [--data-dir <dir>] <flow.yaml|spec.json>
flows run [--json] [--data-dir <dir>] <flow.ts> --input <inline-json-or-file>
flows resume [--json] [--data-dir <dir>] <run-id>
```

Expand All @@ -309,6 +310,17 @@ existing run from its journal. The data directory defaults to `.relayflowd`.
Neither verb starts the daemon implicitly. `--json` writes one report-shaped
object to stdout while diagnostics remain on stderr.

A direct `.flow.ts` run requires `--input`. When its argument names an existing
regular file, the CLI parses that file as JSON; otherwise it parses the argument
itself as inline JSON. Direct input is limited to 1,048,576 UTF-8 bytes; file
size is checked before the file is read. Missing, invalid, or oversized input is
refused before the CLI contacts `relayflowd`. After the journal connection is
established, the authored body receives the parsed value as its second argument.
Each awaited `f.run` executes through the journal-backed authored runtime, and
JavaScript control flow observes the output read from `step.completed`.
Unsupported headers, verbs, gates, and completion reasons fail closed rather
than running through a second speculative compiler.

The exit codes are part of the surface contract:

| Exit | Outcome |
Expand Down
329 changes: 329 additions & 0 deletions ops/reviews/20260902-1845-pr140-history.md

Large diffs are not rendered by default.

432 changes: 432 additions & 0 deletions ops/reviews/20260902-1845-pr140-maintainability.md

Large diffs are not rendered by default.

382 changes: 382 additions & 0 deletions ops/reviews/20260902-1845-pr140-structure.md

Large diffs are not rendered by default.

7 changes: 4 additions & 3 deletions sdk/src/authored-flow-executor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,11 +79,12 @@ type JournalStepUsesStepCompletionReason = Assert<
* `JournalClient`; values are read back from `step.completed` journal entries.
* Unsupported headers, verbs, gates, or completion lowering fail closed.
*/
export async function executeAuthoredFlow(
export async function executeAuthoredFlow<Input = undefined>(
handle: FlowHandle,
journal: JournalClient,
input?: Input,
): Promise<AuthoredFlowExecutionResult> {
const definition = getAuthoredFlowDefinition(handle);
const definition = getAuthoredFlowDefinition<Input>(handle);
const headerFields = Object.keys(definition.header);
if (headerFields.length > 0) {
throw new AuthoredFlowExecutionError(
Expand Down Expand Up @@ -182,7 +183,7 @@ export async function executeAuthoredFlow(
let bodyFailed = false;
let bodyFailure: unknown;
try {
const bodyPromise = lifecycle.runBody(() => definition.body(context));
const bodyPromise = lifecycle.runBody(() => definition.body(context, input as Input));
await bodyPromise;
} catch (error) {
bodyFailed = true;
Expand Down
44 changes: 44 additions & 0 deletions sdk/src/authored-flow-loader.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { accessSync, constants } from 'node:fs';
import { resolve } from 'node:path';
import { pathToFileURL } from 'node:url';
import { getAuthoredFlowDefinition, type FlowHandle } from './authored-flow.js';

export class AuthoredFlowLoadError extends Error {
constructor(message: string) {
super(message);
this.name = 'AuthoredFlowLoadError';
}
}

/** Import and validate a direct-run module without executing its authored body. */
export async function loadAuthoredFlow(path: string): Promise<FlowHandle> {
const absolutePath = resolve(path);
try {
accessSync(absolutePath, constants.R_OK);
} catch {
throw new AuthoredFlowLoadError(`Flow "${path}" is not readable.`);
}

let authoredModule: Record<string, unknown>;
try {
authoredModule = await import(pathToFileURL(absolutePath).href) as Record<string, unknown>;
} catch (error) {
throw new AuthoredFlowLoadError(
`Flow "${path}" could not be imported: ${errorMessage(error)}`,
);
}

const handle = authoredModule['default'] as FlowHandle;
try {
getAuthoredFlowDefinition(handle);
} catch (error) {
throw new AuthoredFlowLoadError(
`Flow "${path}" must default-export flow(...): ${errorMessage(error)}`,
);
}
return handle;
}

function errorMessage(error: unknown): string {
return error instanceof Error ? error.message : 'unknown authored-flow error';
}
6 changes: 3 additions & 3 deletions sdk/src/authored-flow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,10 @@ import {
* progress has no durable root journal. Internal lowering tests recover the
* definition here without making that seam a supported runner.
*/
export function getAuthoredFlowDefinition(
export function getAuthoredFlowDefinition<Input = unknown>(
handle: FlowHandle,
): AuthoredFlowDefinition {
return getFlowDefinition(handle);
): AuthoredFlowDefinition<Input> {
return getFlowDefinition<Input>(handle);
}

export type { AuthoredFlowDefinition, FlowHandle };
30 changes: 27 additions & 3 deletions sdk/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ import {
type RunExecution,
type RunReport,
} from './cli/run.js';
import { runDirectFlow } from './cli/direct-run.js';
import { isAuthoredFlowPath } from './direct-input.js';
import { runHnMonitor } from './cli/hn-monitor.js';
import { runTickRunner } from './cli/tick-runner.js';

Expand All @@ -26,7 +28,8 @@ export interface CliIo {
type CliExitCode = 0 | 1 | 2 | 3;
type ParsedArgs =
| { command: 'check'; json: boolean; value: string }
| { command: 'run' | 'resume'; dataDir: string; json: boolean; value: string }
| { command: 'run'; dataDir: string; input: string | undefined; json: boolean; value: string }
| { command: 'resume'; dataDir: string; json: boolean; value: string }
| { command: 'hn-monitor'; sub: 'start'; dataDir: string; specPath: string; pollIntervalMs: number | undefined }
| { command: 'tick'; sub: 'start'; dataDir: string; specPath: string; scheduleId: string;
intervalMs: number; epochMs: number | undefined; maxCatchUp: number | undefined;
Expand All @@ -37,6 +40,7 @@ const USAGE = [
'Usage:',
'flows check [--json] <flow.yaml|spec.json>',
'flows run [--json] [--data-dir <dir>] <flow.yaml|spec.json>',
'flows run [--json] [--data-dir <dir>] <flow.ts> --input <inline-json-or-file>',
'flows tick start --schedule-id <id> --interval-ms <ms> [--epoch-ms <ms>] [--max-catch-up <n>] [--poll-interval-ms <ms>] [--data-dir <dir>] <spec.json>',
'flows resume [--json] [--data-dir <dir>] <run-id>',
'flows hn-monitor start [--data-dir <dir>] [--poll-interval-ms <n>] <spec.json>',
Expand Down Expand Up @@ -107,7 +111,14 @@ export async function runCli(
}

const execution = parsed.command === 'run'
? await runFlow(parsed.value, parsed.dataDir, { onWait: (progress) => emitWait(progress, io) })
? isAuthoredFlowPath(parsed.value)
? await runDirectFlow(
parsed.value,
parsed.input,
parsed.dataDir,
{ onWait: (progress) => emitWait(progress, io) },
)
: await runFlow(parsed.value, parsed.dataDir, { onWait: (progress) => emitWait(progress, io) })
: await resumeFlow(parsed.value, parsed.dataDir, { onWait: (progress) => emitWait(progress, io) });
emitRunReport(execution, parsed.json, io);
return execution.exitCode;
Expand All @@ -132,6 +143,8 @@ function parseArgs(args: readonly string[]): ParsedArgs | undefined {
let json = false;
let dataDir = DEFAULT_DATA_DIR;
let sawDataDir = false;
let input: string | undefined;
let sawInput = false;
const positionals: string[] = [];
for (let index = 1; index < args.length; index += 1) {
const argument = args[index]!;
Expand All @@ -148,14 +161,25 @@ function parseArgs(args: readonly string[]): ParsedArgs | undefined {
index += 1;
continue;
}
if (argument === '--input') {
const value = args[index + 1];
if (command !== 'run' || sawInput || value === undefined || value.startsWith('--')) return undefined;
input = value;
sawInput = true;
index += 1;
continue;
}
if (argument.startsWith('-')) return undefined;
positionals.push(argument);
}
if (positionals.length !== 1) return undefined;

if (command === 'run' && input !== undefined && !isAuthoredFlowPath(positionals[0]!)) return undefined;
return command === 'check'
? { command, json, value: positionals[0]! }
: { command, dataDir, json, value: positionals[0]! };
: command === 'run'
? { command, dataDir, input, json, value: positionals[0]! }
: { command, dataDir, json, value: positionals[0]! };
}

function parseHnMonitorArgs(rest: readonly string[]): ParsedArgs | undefined {
Expand Down
15 changes: 14 additions & 1 deletion sdk/src/cli/check.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,20 @@ class CheckFailure extends Error {
export function checkFlow(path: string): CheckExecution {
const absolutePath = resolve(path);
try {
const authoring = readFlow(absolutePath);
const flow = readFlow(absolutePath);
return checkAuthoredFlow(flow, path);
} catch (error) {
const failure = error instanceof CheckFailure
? error
: new CheckFailure('invalid_spec', `Flow "${path}" could not be checked as a Relayflow spec.`);
return { report: inputFailureReport(failure, path) };
}
}

/** Preflight a validated authored flow through the same path as YAML/JSON. */
export function checkAuthoredFlow(authoring: FlowSpec, path: string): CheckExecution {
const absolutePath = resolve(path);
try {
const config = readProjectConfig(dirname(absolutePath));
const probes = systemProbes(dirname(absolutePath), config);
const result = preflight(authoring, {
Expand Down
86 changes: 86 additions & 0 deletions sdk/src/cli/direct-run.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import {
AuthoredFlowExecutionError,
executeAuthoredFlow,
} from '../authored-flow-executor.js';
import { AuthoredFlowLoadError, loadAuthoredFlow } from '../authored-flow-loader.js';
import { DirectInputError, parseDirectInput } from '../direct-input.js';
import { JournalClient } from '../journal-client.js';
import { inputFailureReport } from './check.js';
import {
connect,
emptyReport,
fromCheckReport,
protocolFailure,
socketFor,
type RunExecution,
type RunLifecycleOptions,
type RunReport,
} from './run.js';

export async function runDirectFlow(
path: string,
inputArgument: string | undefined,
dataDir: string,
_options: RunLifecycleOptions = {},
): Promise<RunExecution> {
let input: unknown;
try {
input = parseDirectInput(inputArgument);
} catch (error) {
const failure = error instanceof DirectInputError ? error : {
kind: 'input_invalid' as const,
message: 'Direct input could not be parsed.',
};
return {
exitCode: 2,
report: fromCheckReport('run', inputFailureReport(failure, path)),
};
}

const socketPath = socketFor(dataDir);
const base: RunReport = { ...emptyReport('run'), path };
const client = new JournalClient(socketPath);
const connected = await connect(client, 'run', dataDir, base);
if (connected !== undefined) return connected;

try {
const handle = await loadAuthoredFlow(path);
const result = await executeAuthoredFlow(handle, client, input);
const terminal = result.journalSteps.at(-1);
if (terminal === undefined) {
return protocolFailure('run', base, socketPath, new Error(
`authored flow "${result.name}" completed without a journal step`,
));
}
return {
exitCode: 0,
report: {
...base,
ok: true,
runId: terminal.runId,
socketPath,
status: 'completed',
completionReason: result.completionReason,
completedSteps: result.journalSteps.length,
},
};
} catch (error) {
if (error instanceof AuthoredFlowLoadError
|| (error instanceof AuthoredFlowExecutionError && error.code === 'unsupported_header')) {
return {
exitCode: 2,
report: {
...fromCheckReport('run', inputFailureReport({
kind: 'invalid_spec',
message: error.message,
}, path)),
socketPath,
},
};
}
const runId = error instanceof AuthoredFlowExecutionError ? error.runId : undefined;
return protocolFailure('run', base, socketPath, error, runId);
} finally {
client.close();
}
}
20 changes: 14 additions & 6 deletions sdk/src/cli/run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,13 +71,21 @@ export async function runFlow(
return { exitCode: 2, report: fromCheckReport('run', checked.report) };
}

return executeCheckedFlow(checked, dataDir, options);
}

async function executeCheckedFlow(
checked: ReturnType<typeof checkFlow>,
dataDir: string,
options: RunLifecycleOptions,
): Promise<RunExecution> {
const socketPath = socketFor(dataDir);
const client = new JournalClient(socketPath);
const connected = await connect(client, 'run', dataDir, checked.report);
if (connected !== undefined) return connected;

try {
const spec = toKernelSpec(checked.flow);
const spec = toKernelSpec(checked.flow!);
const outcome = await client.runStart(spec);
return await classifyOutcome(client, 'run', outcome, checked.report, socketPath, options);
} catch (error) {
Expand Down Expand Up @@ -123,7 +131,7 @@ export async function resumeFlow(
}
}

async function connect(
export async function connect(
client: JournalClient,
command: RunCommand,
dataDir: string,
Expand Down Expand Up @@ -318,7 +326,7 @@ async function waitForRunningStep(
}
}

function protocolFailure(
export function protocolFailure(
command: RunCommand,
base: CheckReport | RunReport,
socketPath: string,
Expand All @@ -340,7 +348,7 @@ function protocolFailure(
};
}

function fromCheckReport(command: RunCommand, report: CheckReport): RunReport {
export function fromCheckReport(command: RunCommand, report: CheckReport): RunReport {
return {
ok: false,
command,
Expand All @@ -351,15 +359,15 @@ function fromCheckReport(command: RunCommand, report: CheckReport): RunReport {
};
}

function emptyReport(command: RunCommand): RunReport {
export function emptyReport(command: RunCommand): RunReport {
return { ok: false, command, resolutions: [], diagnostics: [] };
}

function fromBase(command: RunCommand, base: CheckReport | RunReport): RunReport {
return 'command' in base ? base : fromCheckReport(command, base);
}

function socketFor(dataDir: string): string {
export function socketFor(dataDir: string): string {
return join(resolve(dataDir), 'relayflowd.sock');
}

Expand Down
Loading
Loading