chore: add ion package, codesight wiki, work plans, ascli config

New @boocode/ion package (v0.0.1) for inference optimization network.
.codesight/ wiki artifacts for codebase documentation.
.omo/ work plans for openspec cleanup and enhanced file panel.
This commit is contained in:
2026-06-07 22:16:45 +00:00
parent ec48066a80
commit 02063072ab
63 changed files with 14025 additions and 0 deletions

View File

@@ -0,0 +1,55 @@
/**
* `workflow abandon` — Cancel a non-terminal workflow run.
*
* Marks the run as cancelled. Only works on runs that are not
* already in a terminal state (completed, failed, cancelled).
*
* @example
* workflow abandon abc123
* workflow abandon abc123 --json
*/
import type { CliOptions } from '../utils.js';
import { printJson } from '../utils.js';
// ---------------------------------------------------------------------------
// Stub: engine integration (not implemented yet)
// ---------------------------------------------------------------------------
interface AbandonResult {
runId: string;
abandoned: boolean;
message: string;
}
async function abandonWorkflowRun(_runId: string): Promise<AbandonResult> {
throw new Error('not implemented yet: abandonWorkflowRun');
}
// ---------------------------------------------------------------------------
// Command handler
// ---------------------------------------------------------------------------
export async function abandonCommand(
args: string[],
options: CliOptions,
): Promise<void> {
if (args.length === 0) {
throw new Error('Missing required argument: <run-id>\n\nUsage: workflow abandon <run-id> [--json]');
}
const runId = args[0]!;
const result = await abandonWorkflowRun(runId);
if (options.json) {
printJson(result);
return;
}
if (result.abandoned) {
console.log(`⊘ Run ${result.runId} abandoned (cancelled).`);
} else {
console.log(`Failed to abandon run ${result.runId}: ${result.message}`);
}
}

View File

@@ -0,0 +1,60 @@
/**
* `workflow approve` — Approve a paused workflow run.
*
* @example
* workflow approve abc123
* workflow approve abc123 "Looks good" --json
*/
import type { CliOptions } from '../utils.js';
import { printJson } from '../utils.js';
// ---------------------------------------------------------------------------
// Stub: engine integration (not implemented yet)
// ---------------------------------------------------------------------------
interface ApproveResult {
runId: string;
approved: boolean;
comment?: string;
message: string;
}
async function approveWorkflowRun(
_runId: string,
_comment?: string,
): Promise<ApproveResult> {
throw new Error('not implemented yet: approveWorkflowRun');
}
// ---------------------------------------------------------------------------
// Command handler
// ---------------------------------------------------------------------------
export async function approveCommand(
args: string[],
options: CliOptions,
): Promise<void> {
if (args.length === 0) {
throw new Error('Missing required argument: <run-id>\n\nUsage: workflow approve <run-id> [comment] [--json]');
}
const runId = args[0]!;
const comment = args.length > 1 ? args.slice(1).join(' ') : undefined;
const result = await approveWorkflowRun(runId, comment);
if (options.json) {
printJson(result);
return;
}
if (result.approved) {
console.log(`✓ Run ${result.runId} approved.`);
if (result.comment) {
console.log(` Comment: ${result.comment}`);
}
} else {
console.log(`✗ Failed to approve run ${result.runId}: ${result.message}`);
}
}

View File

@@ -0,0 +1,74 @@
/**
* `workflow cleanup` — Remove old workflow run artifacts.
*
* Default retention: 7 days. Removes run data older than the specified
* number of days.
*
* @example
* workflow cleanup
* workflow cleanup 30 --json
*/
import type { CliOptions } from '../utils.js';
import { printJson } from '../utils.js';
// ---------------------------------------------------------------------------
// Stub: engine integration (not implemented yet)
// ---------------------------------------------------------------------------
interface CleanupResult {
removedRuns: number;
removedEvents: number;
freedBytes: number;
retentionDays: number;
}
async function cleanupWorkflowRuns(
_days: number,
_cwd?: string,
): Promise<CleanupResult> {
throw new Error('not implemented yet: cleanupWorkflowRuns');
}
// ---------------------------------------------------------------------------
// Command handler
// ---------------------------------------------------------------------------
export async function cleanupCommand(
args: string[],
options: CliOptions,
): Promise<void> {
// First positional arg is the number of days (default 7).
const days = args.length > 0 ? parseInt(args[0]!, 10) : 7;
if (isNaN(days) || days < 1) {
throw new Error(`Invalid retention days: ${args[0]}. Must be a positive integer.`);
}
const result = await cleanupWorkflowRuns(days, options.cwd);
if (options.json) {
printJson(result);
return;
}
console.log(`Cleanup complete (retention: ${result.retentionDays} days).`);
console.log(` Runs removed: ${result.removedRuns}`);
console.log(` Events removed: ${result.removedEvents}`);
console.log(` Space freed: ${formatBytes(result.freedBytes)}`);
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function formatBytes(bytes: number): string {
if (bytes === 0) return '0 B';
const units = ['B', 'KB', 'MB', 'GB'];
const i = Math.min(
Math.floor(Math.log(bytes) / Math.log(1024)),
units.length - 1,
);
const value = bytes / Math.pow(1024, i);
return `${value.toFixed(i === 0 ? 0 : 1)} ${units[i]}`;
}

View File

@@ -0,0 +1,62 @@
/**
* `workflow convert` — Convert a .sop.md file to a YAML workflow definition.
*
* Reads the SOP markdown file, parses its structure, and outputs
* a corresponding YAML workflow definition.
*
* @example
* workflow convert deploy.sop.md
* workflow convert deploy.sop.md --output workflows/deploy.yaml
*/
import type { CliOptions } from '../utils.js';
import { printJson } from '../utils.js';
// ---------------------------------------------------------------------------
// Stub: engine integration (not implemented yet)
// ---------------------------------------------------------------------------
interface ConvertResult {
inputFile: string;
outputFile: string;
workflowName: string;
nodeCount: number;
}
async function convertSopToYaml(
_inputPath: string,
_outputPath?: string,
): Promise<ConvertResult> {
throw new Error('not implemented yet: convertSopToYaml');
}
// ---------------------------------------------------------------------------
// Command handler
// ---------------------------------------------------------------------------
export async function convertCommand(
args: string[],
options: CliOptions,
): Promise<void> {
if (args.length === 0) {
throw new Error('Missing required argument: <file.sop.md>\n\nUsage: workflow convert <file.sop.md> [--output <path>]');
}
const inputPath = args[0]!;
if (!inputPath.endsWith('.sop.md')) {
throw new Error(`Input file must end with .sop.md, got: ${inputPath}`);
}
const result = await convertSopToYaml(inputPath, options.output);
if (options.json) {
printJson(result);
return;
}
console.log(`Converted: ${result.inputFile}`);
console.log(` Output: ${result.outputFile}`);
console.log(` Workflow: ${result.workflowName}`);
console.log(` Nodes: ${result.nodeCount}`);
}

View File

@@ -0,0 +1,59 @@
/**
* `workflow list` — List all available workflows.
*
* Discovers workflows from both bundled and project sources and displays
* them in a formatted table (or JSON with --json).
*
* @example
* workflow list
* workflow list --json
*/
import type { CliOptions } from '../utils.js';
import { printTable, printJson } from '../utils.js';
// ---------------------------------------------------------------------------
// Stub: engine integration (not implemented yet)
// ---------------------------------------------------------------------------
interface WorkflowEntry {
name: string;
description: string;
source: 'bundled' | 'project';
}
async function discoverWorkflows(_cwd?: string): Promise<WorkflowEntry[]> {
throw new Error('not implemented yet: discoverWorkflows');
}
// ---------------------------------------------------------------------------
// Command handler
// ---------------------------------------------------------------------------
export async function listCommand(
_args: string[],
options: CliOptions,
): Promise<void> {
const workflows = await discoverWorkflows(options.cwd);
if (options.json) {
printJson(workflows);
return;
}
console.log('Available workflows:');
console.log('');
printTable(
workflows.map((w) => ({
name: w.name,
description: w.description,
source: w.source,
})),
[
{ header: 'Name', field: 'name', minWidth: 20 },
{ header: 'Description', field: 'description', minWidth: 30 },
{ header: 'Source', field: 'source', minWidth: 10 },
],
);
}

View File

@@ -0,0 +1,62 @@
/**
* `workflow reject` — Reject a paused workflow run.
*
* Sets $REJECTION_REASON with the provided reason string.
*
* @example
* workflow reject abc123
* workflow reject abc123 "Not compliant" --json
*/
import type { CliOptions } from '../utils.js';
import { printJson } from '../utils.js';
// ---------------------------------------------------------------------------
// Stub: engine integration (not implemented yet)
// ---------------------------------------------------------------------------
interface RejectResult {
runId: string;
rejected: boolean;
reason?: string;
message: string;
}
async function rejectWorkflowRun(
_runId: string,
_reason?: string,
): Promise<RejectResult> {
throw new Error('not implemented yet: rejectWorkflowRun');
}
// ---------------------------------------------------------------------------
// Command handler
// ---------------------------------------------------------------------------
export async function rejectCommand(
args: string[],
options: CliOptions,
): Promise<void> {
if (args.length === 0) {
throw new Error('Missing required argument: <run-id>\n\nUsage: workflow reject <run-id> [reason] [--json]');
}
const runId = args[0]!;
const reason = args.length > 1 ? args.slice(1).join(' ') : undefined;
const result = await rejectWorkflowRun(runId, reason);
if (options.json) {
printJson(result);
return;
}
if (result.rejected) {
console.log(`✗ Run ${result.runId} rejected.`);
if (result.reason) {
console.log(` Reason: ${result.reason}`);
}
} else {
console.log(`Failed to reject run ${result.runId}: ${result.message}`);
}
}

View File

@@ -0,0 +1,55 @@
/**
* `workflow resume` — Resume a failed workflow run.
*
* Skips completed nodes and re-executes from the failure point.
*
* @example
* workflow resume abc123
* workflow resume abc123 --json
*/
import type { CliOptions } from '../utils.js';
import { printJson } from '../utils.js';
// ---------------------------------------------------------------------------
// Stub: engine integration (not implemented yet)
// ---------------------------------------------------------------------------
interface ResumeResult {
runId: string;
resumed: boolean;
message: string;
}
async function resumeWorkflowRun(_runId: string): Promise<ResumeResult> {
throw new Error('not implemented yet: resumeWorkflowRun');
}
// ---------------------------------------------------------------------------
// Command handler
// ---------------------------------------------------------------------------
export async function resumeCommand(
args: string[],
options: CliOptions,
): Promise<void> {
if (args.length === 0) {
throw new Error('Missing required argument: <run-id>\n\nUsage: workflow resume <run-id> [--json]');
}
const runId = args[0]!;
const result = await resumeWorkflowRun(runId);
if (options.json) {
printJson(result);
return;
}
if (result.resumed) {
console.log(`↻ Run ${result.runId} resumed.`);
console.log(` ${result.message}`);
} else {
console.log(`Failed to resume run ${result.runId}: ${result.message}`);
}
}

View File

@@ -0,0 +1,94 @@
/**
* `workflow run` — Execute a workflow by name.
*
* Resolves the workflow, passes message args, and shows real-time progress.
* With --detach, runs in background and returns the run ID immediately.
*
* @example
* workflow run deploy
* workflow run deploy --cwd /tmp/project --json
* workflow run deploy --detach
*/
import type { CliOptions } from '../utils.js';
import { printJson } from '../utils.js';
// ---------------------------------------------------------------------------
// Stub: engine integration (not implemented yet)
// ---------------------------------------------------------------------------
interface WorkflowRunResult {
id: string;
workflowName: string;
status: string;
output?: Record<string, unknown>;
error?: string;
startedAt: string;
completedAt?: string;
}
async function resolveWorkflow(
_name: string,
_cwd?: string,
): Promise<unknown> {
throw new Error('not implemented yet: resolveWorkflow');
}
async function executeWorkflow(
_workflow: unknown,
_messageArgs: string[],
_options: { cwd?: string; detach?: boolean },
): Promise<WorkflowRunResult> {
throw new Error('not implemented yet: executeWorkflow');
}
// ---------------------------------------------------------------------------
// Command handler
// ---------------------------------------------------------------------------
export async function runCommand(
args: string[],
options: CliOptions,
): Promise<void> {
if (args.length === 0) {
throw new Error('Missing required argument: <name>\n\nUsage: workflow run <name> [args...] [--cwd <path>] [--detach] [--json]');
}
const workflowName = args[0]!;
const messageArgs = args.slice(1);
const detach = options.json ? false : false; // --detach is a flag, not in CliOptions yet
// Parse --detach from raw args (it's a boolean flag).
// This is handled by the arg parser in the main entry point,
// but since CliOptions doesn't have detach, we check process.argv.
const isDetach = process.argv.includes('--detach');
const workflow = await resolveWorkflow(workflowName, options.cwd);
const result = await executeWorkflow(workflow, messageArgs, {
cwd: options.cwd,
detach: isDetach,
});
if (options.json) {
printJson(result);
return;
}
if (isDetach) {
console.log(`Workflow started in background.`);
console.log(`Run ID: ${result.id}`);
console.log(`Workflow: ${result.workflowName}`);
console.log(`Status: ${result.status}`);
} else {
console.log(`Workflow run completed.`);
console.log(` Run ID: ${result.id}`);
console.log(` Workflow: ${result.workflowName}`);
console.log(` Status: ${result.status}`);
if (result.output) {
console.log(` Output: ${JSON.stringify(result.output)}`);
}
if (result.error) {
console.log(` Error: ${result.error}`);
}
}
}

View File

@@ -0,0 +1,91 @@
/**
* `workflow runs` — List recent workflow runs with filters.
*
* @example
* workflow runs
* workflow runs --status failed --limit 10 --json
* workflow runs --all
*/
import type { CliOptions } from '../utils.js';
import { printTable, printJson, formatTimestamp, formatDuration } from '../utils.js';
// ---------------------------------------------------------------------------
// Stub: engine integration (not implemented yet)
// ---------------------------------------------------------------------------
interface RunRecord {
id: string;
workflowName: string;
status: string;
startedAt: string;
duration?: number; // ms, absent if still running
currentNode?: string;
}
async function listWorkflowRuns(_filters: {
status?: string;
limit?: number;
all?: boolean;
cwd?: string;
}): Promise<RunRecord[]> {
throw new Error('not implemented yet: listWorkflowRuns');
}
// ---------------------------------------------------------------------------
// Command handler
// ---------------------------------------------------------------------------
export async function runsCommand(
args: string[],
options: CliOptions,
): Promise<void> {
// Parse --status, --limit, --all from args/options.
// These are already extracted by parseArgs into options.
const status = typeof (options as Record<string, unknown>).status === 'string'
? (options as Record<string, unknown>).status as string
: undefined;
const limit = typeof (options as Record<string, unknown>).limit === 'string'
? parseInt((options as Record<string, unknown>).limit as string, 10)
: 50;
const all = (options as Record<string, unknown>).all === true;
const runs = await listWorkflowRuns({
status,
limit,
all,
cwd: options.cwd,
});
if (options.json) {
printJson(runs);
return;
}
if (runs.length === 0) {
console.log('No workflow runs found.');
return;
}
console.log(`Showing ${runs.length} run(s):`);
console.log('');
printTable(
runs.map((r) => ({
id: r.id,
workflow: r.workflowName,
status: r.status,
started: formatTimestamp(new Date(r.startedAt)),
duration: r.duration != null ? formatDuration(r.duration) : '-',
currentNode: r.currentNode ?? '-',
})),
[
{ header: 'ID', field: 'id', minWidth: 26 },
{ header: 'Workflow', field: 'workflow', minWidth: 20 },
{ header: 'Status', field: 'status', minWidth: 10 },
{ header: 'Started', field: 'started', minWidth: 19 },
{ header: 'Duration', field: 'duration', minWidth: 10 },
{ header: 'Node', field: 'currentNode', minWidth: 15 },
],
);
}

View File

@@ -0,0 +1,67 @@
/**
* `workflow status` — Show active (running + paused) workflow runs.
*
* @example
* workflow status
* workflow status --json
*/
import type { CliOptions } from '../utils.js';
import { printTable, printJson, formatDuration } from '../utils.js';
// ---------------------------------------------------------------------------
// Stub: engine integration (not implemented yet)
// ---------------------------------------------------------------------------
interface ActiveRun {
id: string;
workflowName: string;
status: string;
duration: number; // ms
currentNode?: string;
}
async function getActiveRuns(_cwd?: string): Promise<ActiveRun[]> {
throw new Error('not implemented yet: getActiveRuns');
}
// ---------------------------------------------------------------------------
// Command handler
// ---------------------------------------------------------------------------
export async function statusCommand(
_args: string[],
options: CliOptions,
): Promise<void> {
const runs = await getActiveRuns(options.cwd);
if (options.json) {
printJson(runs);
return;
}
if (runs.length === 0) {
console.log('No active workflow runs.');
return;
}
console.log('Active workflow runs:');
console.log('');
printTable(
runs.map((r) => ({
id: r.id,
workflow: r.workflowName,
status: r.status,
duration: formatDuration(r.duration),
currentNode: r.currentNode ?? '-',
})),
[
{ header: 'ID', field: 'id', minWidth: 26 },
{ header: 'Workflow', field: 'workflow', minWidth: 20 },
{ header: 'Status', field: 'status', minWidth: 10 },
{ header: 'Duration', field: 'duration', minWidth: 10 },
{ header: 'Current Node', field: 'currentNode', minWidth: 15 },
],
);
}

View File

@@ -0,0 +1,66 @@
/**
* `workflow validate` — Validate a workflow definition without executing.
*
* Loads the workflow, runs schema validation, and reports any errors.
*
* @example
* workflow validate deploy
* workflow validate deploy --json
*/
import type { CliOptions } from '../utils.js';
import { printJson } from '../utils.js';
// ---------------------------------------------------------------------------
// Stub: engine integration (not implemented yet)
// ---------------------------------------------------------------------------
interface ValidationError {
path: string;
message: string;
}
interface ValidateResult {
valid: boolean;
errors: ValidationError[];
workflowName: string;
}
async function validateWorkflow(
_name: string,
_cwd?: string,
): Promise<ValidateResult> {
throw new Error('not implemented yet: validateWorkflow');
}
// ---------------------------------------------------------------------------
// Command handler
// ---------------------------------------------------------------------------
export async function validateCommand(
args: string[],
options: CliOptions,
): Promise<void> {
if (args.length === 0) {
throw new Error('Missing required argument: <name>\n\nUsage: workflow validate <name> [--json]');
}
const workflowName = args[0]!;
const result = await validateWorkflow(workflowName, options.cwd);
if (options.json) {
printJson(result);
return;
}
if (result.valid) {
console.log(`✓ Workflow "${result.workflowName}" is valid.`);
} else {
console.log(`✗ Workflow "${result.workflowName}" has ${result.errors.length} error(s):`);
console.log('');
for (const err of result.errors) {
console.log(` ${err.path}: ${err.message}`);
}
}
}

View File

@@ -0,0 +1,207 @@
/**
* Ion workflow engine CLI entry point.
*
* Pure Node.js CLI using process.argv parsing — no external argparse library.
* Routes subcommands to their respective handler modules.
*
* @example
* node dist/cli/index.js workflow list --json
* node dist/cli/index.js workflow run deploy --cwd /tmp/project
*/
import { parseArgs, buildCliOptions, printJson } from './utils.js';
import type { CliOptions } from './utils.js';
import { listCommand } from './commands/list.js';
import { runCommand } from './commands/run.js';
import { statusCommand } from './commands/status.js';
import { runsCommand } from './commands/runs.js';
import { approveCommand } from './commands/approve.js';
import { rejectCommand } from './commands/reject.js';
import { resumeCommand } from './commands/resume.js';
import { abandonCommand } from './commands/abandon.js';
import { cleanupCommand } from './commands/cleanup.js';
import { validateCommand } from './commands/validate.js';
import { convertCommand } from './commands/convert.js';
// ---------------------------------------------------------------------------
// Command registry
// ---------------------------------------------------------------------------
interface CommandEntry {
name: string;
description: string;
usage: string;
handler: (args: string[], options: CliOptions) => Promise<void>;
}
const COMMANDS: CommandEntry[] = [
{
name: 'list',
description: 'List all available workflows',
usage: 'workflow list [--json]',
handler: listCommand,
},
{
name: 'run',
description: 'Execute a workflow by name',
usage: 'workflow run <name> [args...] [--cwd <path>] [--detach] [--json]',
handler: runCommand,
},
{
name: 'status',
description: 'Show active (running + paused) workflow runs',
usage: 'workflow status [--json]',
handler: statusCommand,
},
{
name: 'runs',
description: 'List recent workflow runs with filters',
usage: 'workflow runs [--status <status>] [--limit N] [--all] [--json]',
handler: runsCommand,
},
{
name: 'approve',
description: 'Approve a paused workflow run',
usage: 'workflow approve <run-id> [comment] [--json]',
handler: approveCommand,
},
{
name: 'reject',
description: 'Reject a paused workflow run',
usage: 'workflow reject <run-id> [reason] [--json]',
handler: rejectCommand,
},
{
name: 'resume',
description: 'Resume a failed workflow run',
usage: 'workflow resume <run-id> [--json]',
handler: resumeCommand,
},
{
name: 'abandon',
description: 'Cancel a non-terminal workflow run',
usage: 'workflow abandon <run-id> [--json]',
handler: abandonCommand,
},
{
name: 'cleanup',
description: 'Remove old workflow run artifacts',
usage: 'workflow cleanup [days] [--json]',
handler: cleanupCommand,
},
{
name: 'validate',
description: 'Validate a workflow definition without executing',
usage: 'workflow validate <name> [--json]',
handler: validateCommand,
},
{
name: 'convert',
description: 'Convert a .sop.md file to a YAML workflow definition',
usage: 'workflow convert <file.sop.md> [--output <path>]',
handler: convertCommand,
},
];
// ---------------------------------------------------------------------------
// Help output
// ---------------------------------------------------------------------------
function printHelp(): void {
console.log('');
console.log('Ion — Workflow Engine CLI');
console.log('');
console.log('Usage:');
console.log(' workflow <command> [options]');
console.log('');
console.log('Commands:');
const maxNameLen = Math.max(...COMMANDS.map((c) => c.name.length));
for (const cmd of COMMANDS) {
const padded = cmd.name.padEnd(maxNameLen + 2);
console.log(` ${padded}${cmd.description}`);
}
console.log('');
console.log('Global options:');
console.log(' --json Output as JSON (suppresses all other output)');
console.log(' --cwd <path> Set working directory');
console.log(' --store <path> Path to workflow store');
console.log(' --db-path <p> Path to database file');
console.log('');
console.log('Run "workflow <command> --help" for command-specific usage.');
console.log('');
}
function printCommandHelp(cmd: CommandEntry): void {
console.log('');
console.log(`workflow ${cmd.name}`);
console.log('');
console.log(` ${cmd.description}`);
console.log('');
console.log('Usage:');
console.log(` ${cmd.usage}`);
console.log('');
}
// ---------------------------------------------------------------------------
// Main entry
// ---------------------------------------------------------------------------
export async function main(argv: string[] = process.argv.slice(2)): Promise<void> {
const { args, options } = parseArgs(argv);
const cliOptions = buildCliOptions(options);
// --help with no command → general help
if (args.length === 0 || options.help === true) {
printHelp();
process.exit(0);
}
const commandName = args[0];
const commandArgs = args.slice(1);
// --help after a command name → command-specific help
if (options.help) {
const cmd = COMMANDS.find((c) => c.name === commandName);
if (cmd) {
printCommandHelp(cmd);
} else {
console.error(`Unknown command: ${commandName}`);
printHelp();
}
process.exit(0);
}
const command = COMMANDS.find((c) => c.name === commandName);
if (!command) {
console.error(`Unknown command: ${commandName}`);
printHelp();
process.exit(1);
return; // unreachable, but satisfies TS control flow
}
try {
await command.handler(commandArgs, cliOptions);
} catch (err: unknown) {
const message = err instanceof Error ? err.message : String(err);
if (cliOptions.json) {
printJson({ error: message });
} else {
console.error(`Error: ${message}`);
}
process.exit(1);
}
}
// Run when executed directly (not imported).
// In ESM, check import.meta.url to detect direct execution.
const _directRun = typeof import.meta !== 'undefined' && import.meta.url;
if (_directRun) {
main().catch((err: unknown) => {
console.error(err);
process.exit(1);
});
}

View File

@@ -0,0 +1,239 @@
/**
* CLI utility functions for the Ion workflow engine.
*
* Provides formatting, table rendering, and JSON output helpers
* used across all CLI commands.
*/
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
export interface CliOptions {
/** Working directory override. */
cwd?: string;
/** Output as JSON (suppresses all other output). */
json?: boolean;
/** Path to the workflow store database. */
store?: string;
/** Path to the database file. */
dbPath?: string;
/** Output file path (for convert command). */
output?: string;
}
// ---------------------------------------------------------------------------
// Duration formatting
// ---------------------------------------------------------------------------
/**
* Format a duration in milliseconds into a human-readable string.
*
* @example
* formatDuration(90500) // "1m 30s"
* formatDuration(3661000) // "1h 1m"
* formatDuration(500) // "0s"
*/
export function formatDuration(ms: number): string {
if (ms < 0) ms = 0;
const seconds = Math.floor(ms / 1000) % 60;
const minutes = Math.floor(ms / 60000) % 60;
const hours = Math.floor(ms / 3600000);
if (hours > 0) {
return minutes > 0 ? `${hours}h ${minutes}m` : `${hours}h`;
}
if (minutes > 0) {
return seconds > 0 ? `${minutes}m ${seconds}s` : `${minutes}m`;
}
return `${seconds}s`;
}
// ---------------------------------------------------------------------------
// Timestamp formatting
// ---------------------------------------------------------------------------
/**
* Format a Date into an ISO-like timestamp suitable for CLI display.
*
* @example
* formatTimestamp(new Date('2025-06-07T14:30:00Z'))
* // "2025-06-07 14:30:00"
*/
export function formatTimestamp(date: Date): string {
const y = date.getFullYear();
const mo = String(date.getMonth() + 1).padStart(2, '0');
const d = String(date.getDate()).padStart(2, '0');
const h = String(date.getHours()).padStart(2, '0');
const mi = String(date.getMinutes()).padStart(2, '0');
const s = String(date.getSeconds()).padStart(2, '0');
return `${y}-${mo}-${d} ${h}:${mi}:${s}`;
}
// ---------------------------------------------------------------------------
// String truncation
// ---------------------------------------------------------------------------
/**
* Truncate a string to `max` characters, appending an ellipsis if truncated.
*
* @example
* truncate('hello world', 8) // "hello..."
* truncate('hi', 8) // "hi"
*/
export function truncate(str: string, max: number): string {
if (str.length <= max) return str;
if (max <= 3) return str.slice(0, max);
return str.slice(0, max - 3) + '...';
}
// ---------------------------------------------------------------------------
// Table rendering
// ---------------------------------------------------------------------------
export interface TableColumn {
/** Column header label. */
header: string;
/** Minimum column width. */
minWidth?: number;
/** Field name to extract from each row object. */
field: string;
}
/**
* Print a formatted table to stdout.
*
* @param rows - Array of row objects.
* @param columns - Column definitions with header labels and field names.
*
* @example
* printTable(
* [{ name: 'deploy', desc: 'Deploy app' }],
* [{ header: 'Name', field: 'name' }, { header: 'Description', field: 'desc' }],
* )
*/
export function printTable(
rows: Record<string, unknown>[],
columns: TableColumn[],
): void {
if (rows.length === 0) {
console.log('(no results)');
return;
}
// Compute column widths.
const widths: number[] = columns.map((col) => {
const headerLen = col.header.length;
const dataLen = Math.max(
...rows.map((row) => {
const val = row[col.field];
const str = val === undefined || val === null ? '' : String(val);
return str.length;
}),
0,
);
const min = col.minWidth ?? 0;
return Math.max(headerLen, dataLen, min);
});
// Header row.
const headerLine = columns
.map((col, i) => col.header.padEnd(widths[i]!))
.join(' ');
console.log(headerLine);
// Separator.
const sepLine = widths.map((w) => '-'.repeat(w)).join(' ');
console.log(sepLine);
// Data rows.
for (const row of rows) {
const line = columns
.map((col, i) => {
const val = row[col.field];
const str = val === undefined || val === null ? '' : String(val);
return str.padEnd(widths[i]!);
})
.join(' ');
console.log(line);
}
}
// ---------------------------------------------------------------------------
// JSON output
// ---------------------------------------------------------------------------
/**
* Print a data structure as formatted JSON to stdout.
* Uses 2-space indentation.
*/
export function printJson(data: unknown): void {
console.log(JSON.stringify(data, null, 2));
}
// ---------------------------------------------------------------------------
// Argument parsing
// ---------------------------------------------------------------------------
/**
* Parse CLI arguments into positional args and named options.
*
* Supports `--flag` (boolean) and `--key value` (string) formats.
* Everything after `--` is treated as positional.
*
* @example
* parseArgs(['run', 'deploy', '--json', '--cwd', '/tmp'])
* // { args: ['run', 'deploy'], options: { json: true, cwd: '/tmp' } }
*/
export function parseArgs(argv: string[]): {
args: string[];
options: Record<string, string | boolean>;
} {
const args: string[] = [];
const options: Record<string, string | boolean> = {};
let i = 0;
while (i < argv.length) {
const token = argv[i]!;
if (token === '--') {
args.push(...argv.slice(i + 1));
break;
}
if (token.startsWith('--')) {
const key = token.slice(2);
const next = argv[i + 1];
if (next && !next.startsWith('--')) {
options[key] = next;
i += 2;
} else {
options[key] = true;
i += 1;
}
} else {
args.push(token);
i += 1;
}
}
return { args, options };
}
/**
* Build a CliOptions object from parsed options.
* Extracts known CLI flags into their typed fields.
*/
export function buildCliOptions(
options: Record<string, string | boolean>,
): CliOptions {
return {
cwd: typeof options.cwd === 'string' ? options.cwd : undefined,
json: options.json === true,
store: typeof options.store === 'string' ? options.store : undefined,
dbPath: typeof options['db-path'] === 'string' ? options['db-path'] : undefined,
output: typeof options.output === 'string' ? options.output : undefined,
};
}

View File

@@ -0,0 +1,70 @@
import { describe, it, expect } from 'vitest';
import { isValidCommandName } from '../command-validation.js';
describe('isValidCommandName', () => {
describe('valid command names', () => {
it('accepts simple lowercase names', () => {
expect(isValidCommandName('assist')).toBe(true);
});
it('accepts kebab-case names', () => {
expect(isValidCommandName('code-review')).toBe(true);
});
it('accepts names with numbers', () => {
expect(isValidCommandName('deploy-v2')).toBe(true);
});
it('accepts single character names', () => {
expect(isValidCommandName('a')).toBe(true);
});
it('accepts names with only numbers', () => {
expect(isValidCommandName('123')).toBe(true);
});
it('accepts names with mixed alphanumeric and hyphens', () => {
expect(isValidCommandName('a1-b2-c3')).toBe(true);
});
it('accepts names starting with numbers', () => {
expect(isValidCommandName('2fa-verify')).toBe(true);
});
});
describe('invalid command names', () => {
it('rejects uppercase letters', () => {
expect(isValidCommandName('Assist')).toBe(false);
expect(isValidCommandName('CODE-REVIEW')).toBe(false);
});
it('rejects leading hyphens', () => {
expect(isValidCommandName('-assist')).toBe(false);
});
it('rejects trailing hyphens', () => {
expect(isValidCommandName('assist-')).toBe(false);
});
it('rejects double hyphens', () => {
expect(isValidCommandName('code--review')).toBe(false);
});
it('rejects empty strings', () => {
expect(isValidCommandName('')).toBe(false);
});
it('rejects underscores', () => {
expect(isValidCommandName('code_review')).toBe(false);
});
it('rejects spaces', () => {
expect(isValidCommandName('code review')).toBe(false);
});
it('rejects special characters', () => {
expect(isValidCommandName('code.review')).toBe(false);
expect(isValidCommandName('code@review')).toBe(false);
});
});
});

View File

@@ -0,0 +1,149 @@
import { describe, it, expect } from 'vitest';
import { evaluateCondition, ConditionError } from '../condition-evaluator.js';
describe('evaluateCondition', () => {
describe('simple boolean conditions', () => {
it('evaluates boolean true in a comparison', () => {
expect(evaluateCondition('true == true', {})).toBe(true);
});
it('evaluates boolean false in a comparison', () => {
expect(evaluateCondition('false == false', {})).toBe(true);
});
it('evaluates true != false', () => {
expect(evaluateCondition('true != false', {})).toBe(true);
});
it('evaluates boolean via node reference', () => {
const outputs = { flag: { output: true } };
expect(evaluateCondition('$flag.output == true', outputs)).toBe(true);
});
});
describe('string equality with node references', () => {
it('evaluates $nodeId.output == "value" as true when matching', () => {
const outputs = { analysis: { output: 'done' } };
expect(evaluateCondition('$analysis.output == "done"', outputs)).toBe(true);
});
it('evaluates $nodeId.output == "value" as false when not matching', () => {
const outputs = { analysis: { output: 'pending' } };
expect(evaluateCondition('$analysis.output == "done"', outputs)).toBe(false);
});
it('evaluates $nodeId.output != "value" correctly', () => {
const outputs = { analysis: { output: 'pending' } };
expect(evaluateCondition('$analysis.output != "done"', outputs)).toBe(true);
});
});
describe('numeric comparisons', () => {
it('evaluates $score.output > 5 as true', () => {
const outputs = { score: { output: 10 } };
expect(evaluateCondition('$score.output > 5', outputs)).toBe(true);
});
it('evaluates $score.output > 5 as false when score is lower', () => {
const outputs = { score: { output: 3 } };
expect(evaluateCondition('$score.output > 5', outputs)).toBe(false);
});
it('evaluates >= comparison', () => {
const outputs = { score: { output: 5 } };
expect(evaluateCondition('$score.output >= 5', outputs)).toBe(true);
});
it('evaluates < comparison', () => {
const outputs = { score: { output: 3 } };
expect(evaluateCondition('$score.output < 5', outputs)).toBe(true);
});
it('evaluates <= comparison', () => {
const outputs = { score: { output: 5 } };
expect(evaluateCondition('$score.output <= 5', outputs)).toBe(true);
});
});
describe('AND/OR compounds', () => {
it('evaluates AND compound: both true', () => {
const outputs = { a: { output: 'x' }, b: { output: 'y' } };
expect(evaluateCondition('$a.output == "x" AND $b.output == "y"', outputs)).toBe(true);
});
it('evaluates AND compound: one false', () => {
const outputs = { a: { output: 'x' }, b: { output: 'z' } };
expect(evaluateCondition('$a.output == "x" AND $b.output == "y"', outputs)).toBe(false);
});
it('evaluates OR compound: one true', () => {
const outputs = { a: { output: 'x' }, b: { output: 'z' } };
expect(evaluateCondition('$a.output == "x" OR $b.output == "y"', outputs)).toBe(true);
});
it('evaluates OR compound: both false', () => {
const outputs = { a: { output: 'z' }, b: { output: 'z' } };
expect(evaluateCondition('$a.output == "x" OR $b.output == "y"', outputs)).toBe(false);
});
it('evaluates mixed AND/OR with correct precedence', () => {
const outputs = { a: { output: 'x' }, b: { output: 'y' }, c: { output: 'z' } };
// false AND true OR true => false OR true => true (AND binds tighter)
expect(evaluateCondition('$a.output == "wrong" AND $b.output == "y" OR $c.output == "z"', outputs)).toBe(true);
});
});
describe('parenthesized expressions', () => {
it('evaluates parenthesized expressions', () => {
const outputs = { a: { output: 'x' }, b: { output: 'y' } };
expect(evaluateCondition('($a.output == "x" OR $a.output == "z") AND $b.output == "y"', outputs)).toBe(true);
});
});
describe('error handling', () => {
it('throws ConditionError on invalid expressions', () => {
expect(() => evaluateCondition('!!!invalid', {})).toThrow(ConditionError);
});
it('throws ConditionError on missing node reference', () => {
expect(() => evaluateCondition('$missing.output == "x"', {})).toThrow(ConditionError);
});
it('throws ConditionError on node reference without field', () => {
expect(() => evaluateCondition('$analysis == "x"', {})).toThrow(ConditionError);
});
it('throws ConditionError on unterminated string', () => {
expect(() => evaluateCondition('"unterminated', {})).toThrow(ConditionError);
});
});
describe('whitespace handling', () => {
it('handles extra whitespace around operators', () => {
const outputs = { a: { output: 'x' } };
expect(evaluateCondition(' $a.output == "x" ', outputs)).toBe(true);
});
});
describe('quoted strings with special characters', () => {
it('handles double-quoted strings with spaces', () => {
const outputs = { msg: { output: 'hello world' } };
expect(evaluateCondition('$msg.output == "hello world"', outputs)).toBe(true);
});
it('handles single-quoted strings', () => {
const outputs = { msg: { output: 'hello' } };
expect(evaluateCondition("$msg.output == 'hello'", outputs)).toBe(true);
});
});
describe('empty condition', () => {
it('returns true for empty string', () => {
expect(evaluateCondition('', {})).toBe(true);
});
it('returns true for whitespace-only string', () => {
expect(evaluateCondition(' ', {})).toBe(true);
});
});
});

View File

@@ -0,0 +1,132 @@
import { describe, it, expect } from 'vitest';
import {
resolveNodeOutputField,
declaredFieldsFromSchema,
OutputRefError,
} from '../output-ref.js';
describe('resolveNodeOutputField', () => {
describe('with declared schema match', () => {
it('returns value when field exists in output and schema', () => {
const declaredFields = new Set(['name', 'status']);
const output = { name: 'test-result', status: 'completed' };
const result = resolveNodeOutputField(output, 'node-1', 'name', declaredFields);
expect(result).toEqual({ kind: 'value', value: 'test-result' });
});
it('returns JSON-serialized value for non-string fields', () => {
const declaredFields = new Set(['count']);
const output = { count: 42 };
const result = resolveNodeOutputField(output, 'node-1', 'count', declaredFields);
expect(result).toEqual({ kind: 'value', value: '42' });
});
it('returns JSON-serialized value for object fields', () => {
const declaredFields = new Set(['data']);
const output = { data: { key: 'val' } };
const result = resolveNodeOutputField(output, 'node-1', 'data', declaredFields);
expect(result).toEqual({ kind: 'value', value: '{"key":"val"}' });
});
});
describe('with schemaless JSON output', () => {
it('returns value when field exists in output without schema', () => {
const output = { dynamic_field: 'hello' };
const result = resolveNodeOutputField(output, 'node-1', 'dynamic_field');
expect(result).toEqual({ kind: 'value', value: 'hello' });
});
it('returns JSON-serialized number without schema', () => {
const output = { score: 99 };
const result = resolveNodeOutputField(output, 'node-1', 'score');
expect(result).toEqual({ kind: 'value', value: '99' });
});
});
describe('missing optional field', () => {
it('returns empty when field is declared in schema but missing from output', () => {
const declaredFields = new Set(['name', 'optional_field']);
const output = { name: 'test' };
const result = resolveNodeOutputField(output, 'node-1', 'optional_field', declaredFields);
expect(result).toEqual({ kind: 'empty', value: '' });
});
it('returns empty when field exists but value is null', () => {
const declaredFields = new Set(['name']);
const output = { name: null };
const result = resolveNodeOutputField(output, 'node-1', 'name', declaredFields);
expect(result).toEqual({ kind: 'empty', value: '' });
});
it('returns empty when field exists but value is undefined', () => {
const declaredFields = new Set(['name']);
const output = { name: undefined };
const result = resolveNodeOutputField(output, 'node-1', 'name', declaredFields);
expect(result).toEqual({ kind: 'empty', value: '' });
});
});
describe('missing required field', () => {
it('throws OutputRefError when field is not declared and not in output', () => {
const output = { name: 'test' };
expect(() => resolveNodeOutputField(output, 'node-1', 'nonexistent')).toThrow(OutputRefError);
});
it('throws OutputRefError with nodeId and field info', () => {
const output = { name: 'test' };
try {
resolveNodeOutputField(output, 'my-node', 'missing_field');
expect.unreachable('Should have thrown');
} catch (err) {
expect(err).toBeInstanceOf(OutputRefError);
if (err instanceof OutputRefError) {
expect(err.nodeId).toBe('my-node');
expect(err.field).toBe('missing_field');
}
}
});
it('includes available fields in error message', () => {
const output = { name: 'test', status: 'ok' };
try {
resolveNodeOutputField(output, 'node-1', 'nonexistent');
expect.unreachable('Should have thrown');
} catch (err) {
expect(err).toBeInstanceOf(OutputRefError);
if (err instanceof OutputRefError) {
expect(err.message).toContain('name');
expect(err.message).toContain('status');
}
}
});
});
});
describe('declaredFieldsFromSchema', () => {
it('extracts fields from a valid JSON Schema object', () => {
const schema = {
type: 'object',
properties: {
name: { type: 'string' },
count: { type: 'number' },
},
};
const fields = declaredFieldsFromSchema(schema);
expect(fields).toEqual(new Set(['name', 'count']));
});
it('returns empty set for undefined schema', () => {
const fields = declaredFieldsFromSchema(undefined);
expect(fields).toEqual(new Set());
});
it('returns empty set for string schema', () => {
const fields = declaredFieldsFromSchema('just a string description');
expect(fields).toEqual(new Set());
});
it('returns empty set for schema without properties', () => {
const fields = declaredFieldsFromSchema({ type: 'object' });
expect(fields).toEqual(new Set());
});
});

View File

@@ -0,0 +1,27 @@
/**
* Command name validation for the Ion workflow engine.
*
* Command names must be lowercase kebab-case: lowercase alphanumeric
* segments separated by single hyphens.
*/
/** Pattern for valid command names: lowercase kebab-case. */
const COMMAND_NAME_PATTERN = /^[a-z0-9]+(-[a-z0-9]+)*$/;
/**
* Validate a command name.
*
* Valid names match the pattern: `^[a-z0-9]+(-[a-z0-9]+)*$`
* - Lowercase alphanumeric segments
* - Segments separated by single hyphens
* - No leading or trailing hyphens
* - No consecutive hyphens
*
* @returns `true` if the name is valid, `false` otherwise.
*/
export function isValidCommandName(name: string): boolean {
if (name.length === 0) {
return false;
}
return COMMAND_NAME_PATTERN.test(name);
}

View File

@@ -0,0 +1,427 @@
/**
* Condition evaluator for the Ion workflow engine.
*
* Parses and evaluates `when:` conditions that reference node outputs.
* Supports comparison operators, AND/OR compounds, and literal values.
*
* Grammar (informal):
* condition = orExpr
* orExpr = andExpr ( "OR" andExpr )*
* andExpr = comparison ( "AND" comparison )*
* comparison = value operator value
* value = nodeRef | literal
* nodeRef = "$" nodeId "." field
* literal = number | boolean | quotedString
* operator = "==" | "!=" | "<" | ">" | "<=" | ">="
*/
import { resolveNodeOutputField, OutputRefError } from './output-ref.js';
// ---------------------------------------------------------------------------
// Error type
// ---------------------------------------------------------------------------
export class ConditionError extends Error {
public readonly expression: string;
constructor(expression: string, message: string) {
super(`Condition evaluation error in "${expression}": ${message}`);
this.name = 'ConditionError';
this.expression = expression;
}
}
// ---------------------------------------------------------------------------
// Token types
// ---------------------------------------------------------------------------
type TokenType =
| 'NODE_REF' // $nodeId.field
| 'NUMBER' // 42, 3.14
| 'BOOLEAN' // true, false
| 'STRING' // "hello" or 'hello'
| 'OPERATOR' // ==, !=, <, >, <=, >=
| 'AND' // AND keyword
| 'OR' // OR keyword
| 'LPAREN' // (
| 'RPAREN' // )
| 'EOF';
interface Token {
type: TokenType;
value: string;
}
// ---------------------------------------------------------------------------
// Tokenizer
// ---------------------------------------------------------------------------
const OPERATORS = new Set(['==', '!=', '<=', '>=', '<', '>']);
function tokenize(expression: string): Token[] {
const tokens: Token[] = [];
let pos = 0;
while (pos < expression.length) {
// Skip whitespace.
if (/\s/.test(expression[pos]!)) {
pos++;
continue;
}
// Parentheses.
if (expression[pos] === '(') {
tokens.push({ type: 'LPAREN', value: '(' });
pos++;
continue;
}
if (expression[pos] === ')') {
tokens.push({ type: 'RPAREN', value: ')' });
pos++;
continue;
}
// Node reference: $nodeId.field
if (expression[pos] === '$') {
const start = pos;
pos++; // skip $
let field = '';
// Read the nodeId (alphanumeric, underscores, hyphens).
while (pos < expression.length && /[\w-]/.test(expression[pos]!)) {
pos++;
}
const nodeId = expression.slice(start + 1, pos);
if (nodeId.length === 0) {
throw new ConditionError(expression, `Expected node identifier after $ at position ${start}`);
}
// Expect a dot then field name.
if (expression[pos] !== '.') {
throw new ConditionError(expression, `Expected "." after node reference $${nodeId} at position ${pos}`);
}
pos++; // skip dot
const fieldStart = pos;
while (pos < expression.length && /[\w-]/.test(expression[pos]!)) {
pos++;
}
field = expression.slice(fieldStart, pos);
if (field.length === 0) {
throw new ConditionError(expression, `Expected field name after $${nodeId}. at position ${fieldStart}`);
}
tokens.push({ type: 'NODE_REF', value: `${nodeId}.${field}` });
continue;
}
// Quoted string.
if (expression[pos] === '"' || expression[pos] === "'") {
const quote = expression[pos]!;
const start = pos;
pos++;
let str = '';
while (pos < expression.length && expression[pos] !== quote) {
if (expression[pos] === '\\' && pos + 1 < expression.length) {
pos++; // skip escape
str += expression[pos]!;
} else {
str += expression[pos]!;
}
pos++;
}
if (pos >= expression.length) {
throw new ConditionError(expression, `Unterminated string starting at position ${start}`);
}
pos++; // skip closing quote
tokens.push({ type: 'STRING', value: str });
continue;
}
// Two-character operators.
if (pos + 1 < expression.length) {
const twoChar = expression.slice(pos, pos + 2);
if (OPERATORS.has(twoChar)) {
tokens.push({ type: 'OPERATOR', value: twoChar });
pos += 2;
continue;
}
}
// Single-character operators.
const oneChar = expression[pos]!;
if (OPERATORS.has(oneChar)) {
tokens.push({ type: 'OPERATOR', value: oneChar });
pos++;
continue;
}
// AND / OR keywords.
const remaining = expression.slice(pos);
const andMatch = remaining.match(/^AND(?=\s|\(|$)/i);
if (andMatch) {
tokens.push({ type: 'AND', value: 'AND' });
pos += 3;
continue;
}
const orMatch = remaining.match(/^OR(?=\s|\(|$)/i);
if (orMatch) {
tokens.push({ type: 'OR', value: 'OR' });
pos += 2;
continue;
}
// Boolean literals.
const trueMatch = remaining.match(/^true(?=\s|\)|$)/i);
if (trueMatch) {
tokens.push({ type: 'BOOLEAN', value: 'true' });
pos += 4;
continue;
}
const falseMatch = remaining.match(/^false(?=\s|\)|$)/i);
if (falseMatch) {
tokens.push({ type: 'BOOLEAN', value: 'false' });
pos += 5;
continue;
}
// Number literal.
const numMatch = remaining.match(/^(-?\d+\.?\d*)/);
if (numMatch && numMatch[1] !== undefined) {
tokens.push({ type: 'NUMBER', value: numMatch[1] });
pos += numMatch[1].length;
continue;
}
throw new ConditionError(
expression,
`Unexpected character "${expression[pos]}" at position ${pos}`,
);
}
tokens.push({ type: 'EOF', value: '' });
return tokens;
}
// ---------------------------------------------------------------------------
// Parser (recursive descent)
// ---------------------------------------------------------------------------
class ConditionParser {
private pos = 0;
constructor(
private tokens: Token[],
private expression: string,
private nodeOutputs: Record<string, Record<string, unknown>>,
) {}
parse(): boolean {
const result = this.parseOr();
if (this.tokens[this.pos]!.type !== 'EOF') {
throw new ConditionError(
this.expression,
`Unexpected token "${this.tokens[this.pos]!.value}" after expression`,
);
}
return result;
}
// orExpr = andExpr ( "OR" andExpr )*
private parseOr(): boolean {
let result = this.parseAnd();
while (this.tokens[this.pos]!.type === 'OR') {
this.pos++; // consume OR
const right = this.parseAnd();
result = result || right;
}
return result;
}
// andExpr = comparison ( "AND" comparison )*
private parseAnd(): boolean {
let result = this.parseComparison();
while (this.tokens[this.pos]!.type === 'AND') {
this.pos++; // consume AND
const right = this.parseComparison();
result = result && right;
}
return result;
}
// comparison = value operator value | "(" orExpr ")"
private parseComparison(): boolean {
// Parenthesized expression.
if (this.tokens[this.pos]!.type === 'LPAREN') {
this.pos++; // consume (
const result = this.parseOr();
if (this.tokens[this.pos]!.type !== 'RPAREN') {
throw new ConditionError(this.expression, 'Expected closing ")"');
}
this.pos++; // consume )
return result;
}
// value operator value
const left = this.resolveValue();
const opToken = this.tokens[this.pos]!;
if (opToken.type !== 'OPERATOR') {
throw new ConditionError(
this.expression,
`Expected comparison operator, got "${opToken.value}" (${opToken.type})`,
);
}
this.pos++; // consume operator
const right = this.resolveValue();
return this.compare(left, opToken.value, right);
}
private resolveValue(): string | number | boolean {
const token = this.tokens[this.pos]!;
switch (token.type) {
case 'NODE_REF': {
this.pos++;
const dotIndex = token.value.indexOf('.');
if (dotIndex === -1) {
throw new ConditionError(
this.expression,
`Invalid node reference: ${token.value}`,
);
}
const nodeId = token.value.slice(0, dotIndex);
const field = token.value.slice(dotIndex + 1);
const output = this.nodeOutputs[nodeId];
if (!output) {
throw new ConditionError(
this.expression,
`Node "${nodeId}" has no output available. Available nodes: ${Object.keys(this.nodeOutputs).join(', ') || '(none)'}`,
);
}
try {
const result = resolveNodeOutputField(output, nodeId, field);
// For comparison, we need the raw value, not the stringified version.
const rawValue = output[field];
if (typeof rawValue === 'number') return rawValue;
if (typeof rawValue === 'boolean') return rawValue;
return result.value;
} catch (err) {
if (err instanceof OutputRefError) {
throw new ConditionError(this.expression, err.message);
}
throw err;
}
}
case 'NUMBER': {
this.pos++;
const num = Number(token.value);
if (Number.isNaN(num)) {
throw new ConditionError(
this.expression,
`Invalid number literal: ${token.value}`,
);
}
return num;
}
case 'BOOLEAN': {
this.pos++;
return token.value.toLowerCase() === 'true';
}
case 'STRING': {
this.pos++;
return token.value;
}
default:
throw new ConditionError(
this.expression,
`Expected value (node reference, number, boolean, or string), got "${token.value}" (${token.type})`,
);
}
}
private compare(
left: string | number | boolean,
op: string,
right: string | number | boolean,
): boolean {
// Coerce types for comparison.
const leftNum = typeof left === 'number' ? left : Number(left);
const rightNum = typeof right === 'number' ? right : Number(right);
switch (op) {
case '==':
return left === right;
case '!=':
return left !== right;
case '<':
if (!Number.isNaN(leftNum) && !Number.isNaN(rightNum)) {
return leftNum < rightNum;
}
return String(left) < String(right);
case '>':
if (!Number.isNaN(leftNum) && !Number.isNaN(rightNum)) {
return leftNum > rightNum;
}
return String(left) > String(right);
case '<=':
if (!Number.isNaN(leftNum) && !Number.isNaN(rightNum)) {
return leftNum <= rightNum;
}
return String(left) <= String(right);
case '>=':
if (!Number.isNaN(leftNum) && !Number.isNaN(rightNum)) {
return leftNum >= rightNum;
}
return String(left) >= String(right);
default:
throw new ConditionError(this.expression, `Unknown operator: ${op}`);
}
}
}
// ---------------------------------------------------------------------------
// Public API
// ---------------------------------------------------------------------------
/**
* Evaluate a `when:` condition expression against node outputs.
*
* Supports:
* - Node output references: `$nodeId.field`
* - Comparison operators: `==`, `!=`, `<`, `>`, `<=`, `>=`
* - Logical compounds: `AND`, `OR`
* - Parenthesized sub-expressions
* - Literal values: numbers, booleans, quoted strings
*
* Returns `true` or `false`. Throws `ConditionError` on parse failure (fail-closed).
*/
export function evaluateCondition(
expression: string,
nodeOutputs: Record<string, Record<string, unknown>>,
): boolean {
if (!expression || expression.trim().length === 0) {
// Empty condition is always true (no guard = proceed).
return true;
}
const trimmed = expression.trim();
const tokens = tokenize(trimmed);
const parser = new ConditionParser(tokens, trimmed, nodeOutputs);
try {
return parser.parse();
} catch (err) {
if (err instanceof ConditionError) {
throw err;
}
throw new ConditionError(
trimmed,
`Unexpected error: ${err instanceof Error ? err.message : String(err)}`,
);
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,199 @@
/**
* Dependency injection interfaces for the Ion workflow engine.
*
* These interfaces define the contracts that the engine requires from its
* runtime environment. Concrete implementations are provided by the
* platform layer (CLI, server, etc.).
*/
// ---------------------------------------------------------------------------
// Workflow platform — messaging back to the conversation channel
// ---------------------------------------------------------------------------
export interface IWorkflowPlatform {
/** Send a text message to the conversation channel. */
sendMessage(
conversationId: string,
message: string,
metadata?: Record<string, unknown>,
): Promise<void>;
/** Whether the platform supports streaming responses. */
getStreamingMode(): boolean;
/** Optional structured event emission (e.g. approval requests). */
sendStructuredEvent?(
conversationId: string,
event: Record<string, unknown>,
): Promise<void>;
}
// ---------------------------------------------------------------------------
// Workflow configuration — per-workflow settings
// ---------------------------------------------------------------------------
/** Configuration for a single AI provider. */
export interface ProviderConfig {
/** Provider identifier (e.g. "openai", "anthropic"). */
provider: string;
/** Model name (e.g. "gpt-4o", "claude-sonnet-4-20250514"). */
model?: string;
/** Additional provider-specific options. */
options?: Record<string, unknown>;
}
/** Folder-level command configuration. */
export interface CommandFolderConfig {
/** Path to the commands folder. */
path: string;
/** Whether commands are enabled by default. */
enabled?: boolean;
}
export interface WorkflowConfig {
/** Default assistant identifier. */
assistant: string;
/** Named provider configurations keyed by provider id. */
assistants: Record<string, ProviderConfig>;
/** Environment variables available to the workflow. */
envVars: Record<string, string>;
/** Command folder configuration. */
commands: CommandFolderConfig;
/** Base git branch for the workflow (optional). */
baseBranch?: string;
/** Path to documentation directory (optional). */
docsPath?: string;
}
// ---------------------------------------------------------------------------
// Workflow store — persistence interface (will move to store/ later)
// ---------------------------------------------------------------------------
/** Minimal data required to create a workflow run. */
export interface CreateWorkflowRunData {
workflowPath: string;
workflowName: string;
trigger: string;
input: Record<string, unknown>;
}
/** Status of a workflow run. */
export type WorkflowRunStatus =
| 'pending'
| 'running'
| 'completed'
| 'failed'
| 'cancelled';
/** A persisted workflow run record. */
export interface WorkflowRun {
id: string;
workflowPath: string;
workflowName: string;
status: WorkflowRunStatus;
trigger: string;
input: Record<string, unknown>;
output?: Record<string, unknown>;
error?: string;
createdAt: Date;
updatedAt: Date;
}
/** A single event within a workflow run (node start/complete/etc.). */
export interface WorkflowEvent {
id: string;
runId: string;
nodeId?: string;
type: string;
data: Record<string, unknown>;
createdAt: Date;
}
export interface IWorkflowStore {
// -- Run lifecycle -------------------------------------------------------
/** Create a new workflow run. */
createWorkflowRun(data: CreateWorkflowRunData): Promise<WorkflowRun>;
/** Retrieve a workflow run by id. */
getWorkflowRun(id: string): Promise<WorkflowRun | null>;
/** Update a workflow run. */
updateWorkflowRun(
id: string,
data: Partial<WorkflowRun>,
): Promise<WorkflowRun>;
/** Mark a workflow run as failed. */
failWorkflowRun(id: string, error: string): Promise<WorkflowRun>;
/** Get the current status of a workflow run. */
getWorkflowRunStatus(id: string): Promise<WorkflowRunStatus | null>;
// -- Events --------------------------------------------------------------
/** Record a workflow event. */
createWorkflowEvent(event: Omit<WorkflowEvent, 'id' | 'createdAt'>): Promise<WorkflowEvent>;
/** Get completed DAG node outputs for a run. */
getCompletedDagNodeOutputs(
runId: string,
): Promise<Record<string, Record<string, unknown>>>;
// -- Active runs ---------------------------------------------------------
/** Find an active (non-terminal) run for a given workflow path. */
getActiveWorkflowRunByPath(
path: string,
opts?: { excludeId?: string },
): Promise<WorkflowRun | null>;
// -- Codebase ------------------------------------------------------------
/** Get a codebase record by id. */
getCodebase(id: string): Promise<Record<string, unknown> | null>;
/** Get environment variables for a codebase. */
getCodebaseEnvVars(id: string): Promise<Record<string, string>>;
// -- Resumption ----------------------------------------------------------
/** Resume a paused workflow run. */
resumeWorkflowRun(id: string): Promise<WorkflowRun>;
}
// ---------------------------------------------------------------------------
// Agent provider — creates AI agent instances
// ---------------------------------------------------------------------------
export interface IAgentProvider {
/** Provider identifier. */
readonly providerId: string;
/** Send a prompt and return the response. */
sendPrompt(prompt: string, options?: Record<string, unknown>): Promise<string>;
}
// ---------------------------------------------------------------------------
// Workflow dependencies — the full DI container
// ---------------------------------------------------------------------------
export interface WorkflowDeps {
/** Persistence store. */
store: IWorkflowStore;
/** Load workflow config from a working directory. */
loadConfig(cwd: string): Promise<WorkflowConfig>;
/** Get an agent provider by its provider id. */
getAgentProvider(providerId: string): IAgentProvider;
/** Resolve a bot-level GitHub token (optional). */
resolveBotGitHubToken?(): Promise<string | undefined>;
/** Get the user-level GitHub token (optional). */
getUserGithubToken?(): Promise<string | undefined>;
/** Whether per-user GitHub token resolution is enabled (optional). */
isPerUserGitHubEnabled?(): boolean;
}

View File

@@ -0,0 +1,214 @@
/**
* Typed event emitter for the Ion workflow engine.
*
* Provides a singleton event bus for workflow lifecycle events.
* Supports both global and run-scoped subscriptions.
*/
// ---------------------------------------------------------------------------
// Event types
// ---------------------------------------------------------------------------
export type WorkflowEventType =
| 'workflow_started'
| 'workflow_completed'
| 'workflow_failed'
| 'workflow_cancelled'
| 'node_started'
| 'node_completed'
| 'node_failed'
| 'node_skipped'
| 'loop_iteration_started'
| 'loop_iteration_completed'
| 'approval_pending';
// ---------------------------------------------------------------------------
// Event shapes
// ---------------------------------------------------------------------------
export interface WorkflowEventBase {
/** Discriminator for the event type. */
type: WorkflowEventType;
/** The workflow run id. */
runId: string;
/** The node id (when applicable). */
nodeId?: string;
/** The workflow name. */
workflowName?: string;
/** Error message (for failure events). */
error?: string;
/** Human-readable step name. */
stepName?: string;
/** Arbitrary metadata attached to the event. */
metadata?: Record<string, unknown>;
}
/** Specific shapes for strongly-typed event handling. */
export interface WorkflowStartedEvent extends WorkflowEventBase {
type: 'workflow_started';
workflowName: string;
}
export interface WorkflowCompletedEvent extends WorkflowEventBase {
type: 'workflow_completed';
workflowName: string;
}
export interface WorkflowFailedEvent extends WorkflowEventBase {
type: 'workflow_failed';
error: string;
}
export interface WorkflowCancelledEvent extends WorkflowEventBase {
type: 'workflow_cancelled';
}
export interface NodeStartedEvent extends WorkflowEventBase {
type: 'node_started';
nodeId: string;
stepName?: string;
}
export interface NodeCompletedEvent extends WorkflowEventBase {
type: 'node_completed';
nodeId: string;
stepName?: string;
}
export interface NodeFailedEvent extends WorkflowEventBase {
type: 'node_failed';
nodeId: string;
error: string;
}
export interface NodeSkippedEvent extends WorkflowEventBase {
type: 'node_skipped';
nodeId: string;
stepName?: string;
}
export interface LoopIterationStartedEvent extends WorkflowEventBase {
type: 'loop_iteration_started';
nodeId: string;
metadata?: { iteration: number };
}
export interface LoopIterationCompletedEvent extends WorkflowEventBase {
type: 'loop_iteration_completed';
nodeId: string;
metadata?: { iteration: number };
}
export interface ApprovalPendingEvent extends WorkflowEventBase {
type: 'approval_pending';
nodeId: string;
metadata?: { approver?: string; reason?: string };
}
export type WorkflowEvent =
| WorkflowStartedEvent
| WorkflowCompletedEvent
| WorkflowFailedEvent
| WorkflowCancelledEvent
| NodeStartedEvent
| NodeCompletedEvent
| NodeFailedEvent
| NodeSkippedEvent
| LoopIterationStartedEvent
| LoopIterationCompletedEvent
| ApprovalPendingEvent;
// ---------------------------------------------------------------------------
// Event handler type
// ---------------------------------------------------------------------------
export type WorkflowEventHandler = (event: WorkflowEvent) => void;
// ---------------------------------------------------------------------------
// WorkflowEventEmitter — singleton event bus
// ---------------------------------------------------------------------------
export class WorkflowEventEmitter {
private listeners: Set<WorkflowEventHandler> = new Set();
private runListeners: Map<string, Set<WorkflowEventHandler>> = new Map();
/** Subscribe to all workflow events. */
subscribe(handler: WorkflowEventHandler): () => void {
this.listeners.add(handler);
return () => {
this.listeners.delete(handler);
};
}
/** Unsubscribe a handler from all events. */
unsubscribe(handler: WorkflowEventHandler): void {
this.listeners.delete(handler);
}
/** Emit an event to all subscribers (global + run-scoped). */
emit(event: WorkflowEvent): void {
for (const handler of this.listeners) {
try {
handler(event);
} catch {
// Swallow handler errors to prevent cascading failures.
}
}
// Also notify run-scoped listeners.
const runHandlers = this.runListeners.get(event.runId);
if (runHandlers) {
for (const handler of runHandlers) {
try {
handler(event);
} catch {
// Swallow handler errors to prevent cascading failures.
}
}
}
}
/** Remove all global listeners and run-scoped listeners. */
clear(): void {
this.listeners.clear();
this.runListeners.clear();
}
// -- Run-scoped subscriptions ------------------------------------------------
/** Register a run-scoped event listener. */
registerRun(runId: string, handler: WorkflowEventHandler): () => void {
let handlers = this.runListeners.get(runId);
if (!handlers) {
handlers = new Set();
this.runListeners.set(runId, handlers);
}
handlers.add(handler);
return () => {
handlers!.delete(handler);
if (handlers!.size === 0) {
this.runListeners.delete(runId);
}
};
}
/** Unregister all listeners for a specific run. */
unregisterRun(runId: string): void {
this.runListeners.delete(runId);
}
}
// ---------------------------------------------------------------------------
// Singleton factory
// ---------------------------------------------------------------------------
let instance: WorkflowEventEmitter | undefined;
/** Get the singleton WorkflowEventEmitter instance. */
export function getWorkflowEventEmitter(): WorkflowEventEmitter {
if (!instance) {
instance = new WorkflowEventEmitter();
}
return instance;
}

View File

@@ -0,0 +1,251 @@
/**
* Shared executor utilities for the Ion workflow engine.
*
* Pure functions for variable substitution, error classification,
* message helpers, and completion signal detection.
*/
import type { IWorkflowPlatform } from './deps.js';
// ---------------------------------------------------------------------------
// Variable substitution
// ---------------------------------------------------------------------------
/** Well-known workflow variable names. */
const WORKFLOW_VARIABLES = [
'$WORKFLOW_ID',
'$USER_MESSAGE',
'$ARGUMENTS',
'$ARTIFACTS_DIR',
'$BASE_BRANCH',
'$DOCS_DIR',
'$CONTEXT',
'$EXTERNAL_CONTEXT',
'$LOOP_USER_INPUT',
'$REJECTION_REASON',
] as const;
export type WorkflowVariableName = (typeof WORKFLOW_VARIABLES)[number];
/** Context object providing values for workflow variable substitution. */
export interface VariableContext {
WORKFLOW_ID?: string;
USER_MESSAGE?: string;
ARGUMENTS?: string;
ARTIFACTS_DIR?: string;
BASE_BRANCH?: string;
DOCS_DIR?: string;
CONTEXT?: string;
EXTERNAL_CONTEXT?: string;
LOOP_USER_INPUT?: string;
REJECTION_REASON?: string;
}
/**
* Substitute workflow variables in a string.
*
* Replaces known `$VARIABLE` tokens with values from the context.
* Unknown `$VARIABLE` tokens are left as-is (not removed).
*/
export function substituteWorkflowVariables(
template: string,
context: VariableContext,
): string {
let result = template;
// Map variable names to their context keys.
const mapping: Record<string, string | undefined> = {
$WORKFLOW_ID: context.WORKFLOW_ID,
$USER_MESSAGE: context.USER_MESSAGE,
$ARGUMENTS: context.ARGUMENTS,
$ARTIFACTS_DIR: context.ARTIFACTS_DIR,
$BASE_BRANCH: context.BASE_BRANCH,
$DOCS_DIR: context.DOCS_DIR,
$CONTEXT: context.CONTEXT,
$EXTERNAL_CONTEXT: context.EXTERNAL_CONTEXT,
$LOOP_USER_INPUT: context.LOOP_USER_INPUT,
$REJECTION_REASON: context.REJECTION_REASON,
};
for (const [variable, value] of Object.entries(mapping)) {
if (value !== undefined) {
// Replace all occurrences of the variable.
result = result.split(variable).join(value);
}
}
return result;
}
/**
* Build a prompt by substituting workflow variables and appending
* issue context if provided.
*/
export function buildPromptWithContext(
template: string,
context: VariableContext,
issueContext?: string,
): string {
let prompt = substituteWorkflowVariables(template, context);
if (issueContext && issueContext.length > 0) {
prompt += `\n\n---\nIssue Context:\n${issueContext}`;
}
return prompt;
}
// ---------------------------------------------------------------------------
// Error classification
// ---------------------------------------------------------------------------
export type ErrorClassification = 'FATAL' | 'TRANSIENT' | 'UNKNOWN';
/** Patterns that indicate a fatal (non-retryable) error. */
const FATAL_PATTERNS: readonly RegExp[] = [
/auth(?:entication|orization)?\s*(?:fail|error|denied|invalid)/i,
/permission\s*(?:denied|error|fail)/i,
/insufficient\s*(?:credit|quota|permission)/i,
/api\s*key\s*(?:invalid|expired|missing)/i,
/forbidden/i,
/unauthorized/i,
/account\s*(?:suspended|disabled|deactivated)/i,
];
/** Patterns that indicate a transient (retryable) error. */
const TRANSIENT_PATTERNS: readonly RegExp[] = [
/timeout/i,
/timed?\s*out/i,
/network\s*(?:error|fail|unreachable)/i,
/connection\s*(?:reset|refused|dropped|lost)/i,
/econnreset/i,
/econnrefused/i,
/rate\s*limit/i,
/too\s*many\s*requests/i,
/service\s*(?:unavailable|temporarily\s*unavailable)/i,
/503/i,
/502/i,
/retry/i,
];
/**
* Classify an error as FATAL, TRANSIENT, or UNKNOWN based on its message.
*
* FATAL errors should not be retried (auth, permission, credit issues).
* TRANSIENT errors may succeed on retry (network, timeout, rate limit).
* UNKNOWN errors have no recognized pattern.
*/
export function classifyError(error: Error | string): ErrorClassification {
const message = typeof error === 'string' ? error : error.message;
for (const pattern of FATAL_PATTERNS) {
if (pattern.test(message)) {
return 'FATAL';
}
}
for (const pattern of TRANSIENT_PATTERNS) {
if (pattern.test(message)) {
return 'TRANSIENT';
}
}
return 'UNKNOWN';
}
// ---------------------------------------------------------------------------
// Platform message helpers
// ---------------------------------------------------------------------------
/**
* Safely send a message via the platform interface.
*
* Returns `true` if the message was sent successfully, `false` otherwise.
* Logs failures but does not throw.
*/
export async function safeSendMessage(
platform: IWorkflowPlatform,
conversationId: string,
message: string,
metadata?: Record<string, unknown>,
): Promise<boolean> {
try {
await platform.sendMessage(conversationId, message, metadata);
return true;
} catch {
return false;
}
}
// ---------------------------------------------------------------------------
// Completion signal detection
// ---------------------------------------------------------------------------
/**
* Detect whether an output contains the expected completion signal.
*
* The `until` string is the marker that signals the node has finished
* producing its output. Returns `true` if the marker is found.
*/
export function detectCompletionSignal(
output: string,
until: string,
): boolean {
if (!until || until.length === 0) {
// No completion marker configured — always consider complete.
return true;
}
return output.includes(until);
}
/**
* Strip the completion marker from the output.
*
* Removes the `until` string from the output if present.
* Does not trim or modify whitespace beyond removing the marker.
*/
export function stripCompletionTags(output: string, until: string): string {
if (!until || until.length === 0) {
return output;
}
return output.split(until).join('');
}
// ---------------------------------------------------------------------------
// Subprocess failure formatting
// ---------------------------------------------------------------------------
export interface SubprocessFailure {
exitCode: number | null;
stderr: string;
command?: string;
}
/**
* Format a subprocess failure into a human-readable error string.
*
* Includes the command (if provided), exit code, and stderr output.
*/
export function formatSubprocessFailure(failure: SubprocessFailure): string {
const parts: string[] = [];
if (failure.command) {
parts.push(`Command: ${failure.command}`);
}
if (failure.exitCode !== null) {
parts.push(`Exit code: ${failure.exitCode}`);
}
if (failure.stderr && failure.stderr.length > 0) {
// Truncate very long stderr to keep error messages manageable.
const maxStderr = 2048;
const stderr =
failure.stderr.length > maxStderr
? failure.stderr.slice(0, maxStderr) + '... (truncated)'
: failure.stderr;
parts.push(`Stderr:\n${stderr}`);
}
return parts.length > 0 ? parts.join('\n') : 'Subprocess failed with no details';
}

View File

@@ -0,0 +1,373 @@
/**
* Top-level workflow executor for the Ion engine.
*
* Orchestrates the full lifecycle of a workflow run:
* - Load configuration and resolve provider/model
* - Create or resume a WorkflowRun
* - Path-lock guard (prevent concurrent runs on the same working path)
* - Pre-create artifacts directory
* - Delegate to executeDagWorkflow for DAG traversal
* - Update run status on completion/failure
* - Emit events and notify the user
*/
import { mkdir } from 'node:fs/promises';
import { join, resolve } from 'node:path';
import type { WorkflowDefinition } from '../schema/index.js';
import type {
IWorkflowPlatform,
IWorkflowStore,
WorkflowDeps,
WorkflowConfig,
WorkflowRun,
CreateWorkflowRunData,
} from './deps.js';
import { executeDagWorkflow, type DagWorkflowResult } from './dag-executor.js';
import { safeSendMessage } from './utils.js';
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
/** Options for workflow execution. */
export interface WorkflowExecutionOptions {
/** Whether to resume a previously failed/paused run. */
resume?: boolean;
/** Additional input variables for the workflow. */
input?: Record<string, unknown>;
/** Override the provider id. */
provider?: string;
/** Override the model. */
model?: string;
/** Codebase id for scoped paths. */
codebaseId?: string;
}
/** Result of a workflow execution. */
export interface WorkflowExecutionResult {
/** The workflow run record. */
run: WorkflowRun;
/** The DAG execution result. */
dagResult?: DagWorkflowResult;
/** Whether the execution was successful. */
success: boolean;
/** Error message if execution failed. */
error?: string;
}
/** Hydrated resumable run data. */
export interface HydratedResumableRun {
/** The pre-created or resumed workflow run. */
preCreatedRun: WorkflowRun;
/** Prior completed node outputs from the previous run. */
priorCompletedNodes: Record<string, Record<string, unknown>>;
}
/** Resolved project paths for a workflow run. */
export interface ProjectPaths {
/** Directory for workflow artifacts. */
artifactsDir: string;
/** Directory for workflow logs. */
logDir: string;
}
// ---------------------------------------------------------------------------
// Main executor
// ---------------------------------------------------------------------------
/**
* Execute a workflow from start to finish.
*
* This is the primary entry point for running a workflow. It handles:
* 1. Loading configuration
* 2. Resolving provider and model
* 3. Creating or resuming a WorkflowRun
* 4. Path-lock guard
* 5. Pre-creating the artifacts directory
* 6. Delegating to executeDagWorkflow
* 7. Updating run status on completion/failure
*
* @param deps - Dependency injection container.
* @param platform - Platform interface for messaging.
* @param conversationId - Conversation channel id.
* @param cwd - Working directory for the workflow.
* @param workflow - The workflow definition.
* @param userMessage - The triggering user message (stored as input).
* @param opts - Execution options.
* @returns WorkflowExecutionResult with run, dag result, and success status.
*/
export async function executeWorkflow(
deps: WorkflowDeps,
platform: IWorkflowPlatform,
conversationId: string,
cwd: string,
workflow: WorkflowDefinition,
userMessage: string,
opts: WorkflowExecutionOptions = {},
): Promise<WorkflowExecutionResult> {
// 1. Load configuration
let config: WorkflowConfig;
try {
config = await deps.loadConfig(cwd);
} catch (err) {
return {
run: createFailedRun(workflow, err),
success: false,
error: `Failed to load configuration: ${err instanceof Error ? err.message : String(err)}`,
};
}
// 2. Resolve provider and model
const providerId = opts.provider ?? workflow.provider ?? config.assistant;
const model = opts.model ?? workflow.model ?? config.assistants[providerId]?.model;
// 3. Create or resume a workflow run
let workflowRun: WorkflowRun;
let priorCompletedNodes: Record<string, Record<string, unknown>> | undefined;
if (opts.resume) {
// Try to find an existing run to resume
const existingRun = await deps.store.getActiveWorkflowRunByPath(workflow.name);
if (existingRun) {
const hydrated = await hydrateResumableRun(deps, existingRun);
workflowRun = hydrated.preCreatedRun;
priorCompletedNodes = hydrated.priorCompletedNodes;
} else {
// No existing run — create a new one
workflowRun = await createNewRun(deps, workflow, userMessage, opts);
}
} else {
// 4. Path-lock guard: check no other run is active
const activeRun = await deps.store.getActiveWorkflowRunByPath(workflow.name);
if (activeRun) {
const errorMsg = `Workflow "${workflow.name}" already has an active run (${activeRun.id}). Wait for it to complete or cancel it first.`;
await safeSendMessage(platform, conversationId, `${errorMsg}`);
return {
run: createFailedRun(workflow, new Error(errorMsg)),
success: false,
error: errorMsg,
};
}
workflowRun = await createNewRun(deps, workflow, userMessage, opts);
}
// 5. Pre-create artifacts directory
const paths = resolveProjectPaths(deps, cwd, workflowRun.id, opts.codebaseId);
try {
await mkdir(paths.artifactsDir, { recursive: true });
await mkdir(paths.logDir, { recursive: true });
} catch (err) {
// Artifacts dir creation is best-effort
}
// 6. Set status to running
try {
workflowRun = await deps.store.updateWorkflowRun(workflowRun.id, {
status: 'running',
});
} catch (err) {
return {
run: workflowRun,
success: false,
error: `Failed to set workflow run status to running: ${err instanceof Error ? err.message : String(err)}`,
};
}
// 7. Notify user
await safeSendMessage(
platform,
conversationId,
`🚀 Starting workflow "${workflow.name}" (run ${workflowRun.id})`,
);
// 8. Execute the DAG
let dagResult: DagWorkflowResult | undefined;
try {
dagResult = await executeDagWorkflow(
deps,
platform,
conversationId,
cwd,
workflow,
workflowRun,
priorCompletedNodes,
);
// 9. Update run status on completion
if (dagResult.success) {
workflowRun = await deps.store.updateWorkflowRun(workflowRun.id, {
status: 'completed',
output: Object.fromEntries(dagResult.nodeOutputs),
});
await safeSendMessage(
platform,
conversationId,
`✅ Workflow "${workflow.name}" completed successfully\n${dagResult.summary}`,
);
} else {
workflowRun = await deps.store.failWorkflowRun(
workflowRun.id,
dagResult.error ?? 'Workflow failed',
);
await safeSendMessage(
platform,
conversationId,
`❌ Workflow "${workflow.name}" failed: ${dagResult.error}`,
);
}
return {
run: workflowRun,
dagResult,
success: dagResult.success,
error: dagResult.error,
};
} catch (err) {
// Unhandled error — update DB and notify
const errorMsg = err instanceof Error ? err.message : String(err);
try {
workflowRun = await deps.store.failWorkflowRun(workflowRun.id, errorMsg);
} catch {
// Best-effort DB update
}
await safeSendMessage(
platform,
conversationId,
`❌ Workflow "${workflow.name}" failed with error: ${errorMsg}`,
);
// Emit error event
try {
await deps.store.createWorkflowEvent({
runId: workflowRun.id,
type: 'workflow_failed',
data: { error: errorMsg },
});
} catch {
// Best-effort event emission
}
return {
run: workflowRun,
success: false,
error: errorMsg,
};
}
}
// ---------------------------------------------------------------------------
// Resume support
// ---------------------------------------------------------------------------
/**
* Hydrate a resumable workflow run.
*
* Loads completed node outputs from the previous run and sets the
* run status back to 'running' so execution can continue.
*
* @param deps - Dependency injection container.
* @param candidate - The existing workflow run to resume.
* @returns Hydrated run with prior completed nodes.
*/
export async function hydrateResumableRun(
deps: WorkflowDeps,
candidate: WorkflowRun,
): Promise<HydratedResumableRun> {
// Load completed node outputs from the previous run
const priorCompletedNodes = await deps.store.getCompletedDagNodeOutputs(candidate.id);
// Resume the workflow run (set status back to 'running')
const preCreatedRun = await deps.store.resumeWorkflowRun(candidate.id);
return {
preCreatedRun,
priorCompletedNodes,
};
}
// ---------------------------------------------------------------------------
// Project paths
// ---------------------------------------------------------------------------
/**
* Resolve project paths for a workflow run.
*
* Uses codebase-scoped paths if a codebaseId is provided,
* otherwise falls back to cwd-based paths.
*
* @param deps - Dependency injection container.
* @param cwd - Working directory.
* @param workflowRunId - The workflow run id.
* @param codebaseId - Optional codebase id for scoped paths.
* @returns Resolved artifacts and log directories.
*/
export function resolveProjectPaths(
_deps: WorkflowDeps,
cwd: string,
workflowRunId: string,
codebaseId?: string,
): ProjectPaths {
if (codebaseId) {
// Codebase-scoped paths
return {
artifactsDir: resolve(cwd, '.ion', 'codebases', codebaseId, 'artifacts', workflowRunId),
logDir: resolve(cwd, '.ion', 'codebases', codebaseId, 'logs', workflowRunId),
};
}
// Cwd-based paths (default)
return {
artifactsDir: resolve(cwd, '.ion', 'artifacts', workflowRunId),
logDir: resolve(cwd, '.ion', 'logs', workflowRunId),
};
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
/**
* Create a new workflow run in the store.
*/
async function createNewRun(
deps: WorkflowDeps,
workflow: WorkflowDefinition,
userMessage: string,
opts: WorkflowExecutionOptions,
): Promise<WorkflowRun> {
const data: CreateWorkflowRunData = {
workflowPath: workflow.name,
workflowName: workflow.name,
trigger: 'manual',
input: {
message: userMessage,
...(opts.input ?? {}),
},
};
return deps.store.createWorkflowRun(data);
}
/**
* Create a minimal failed run object for error cases where
* the store is not available.
*/
function createFailedRun(workflow: WorkflowDefinition, error: unknown): WorkflowRun {
return {
id: 'error',
workflowPath: workflow.name,
workflowName: workflow.name,
status: 'failed',
trigger: 'manual',
input: {},
error: error instanceof Error ? error.message : String(error),
createdAt: new Date(),
updatedAt: new Date(),
};
}

View File

@@ -0,0 +1,63 @@
/**
* Engine barrel exports.
*
* Re-exports everything from the engine submodules.
*/
// Dependencies and types
export type {
IWorkflowPlatform,
IWorkflowStore,
IAgentProvider,
WorkflowDeps,
WorkflowConfig,
ProviderConfig,
CommandFolderConfig,
CreateWorkflowRunData,
WorkflowEvent,
} from './deps.js';
// DAG executor
export {
buildTopologicalLayers,
checkTriggerRule,
executeNodeInternal,
executeScriptNode,
handleApprovalNode,
handleLoopNode,
executeDagWorkflow,
} from './dag-executor.js';
export type { DagWorkflowResult } from './dag-executor.js';
// Top-level executor
export {
executeWorkflow,
hydrateResumableRun,
resolveProjectPaths,
} from './executor.js';
export type {
WorkflowExecutionOptions,
WorkflowExecutionResult,
HydratedResumableRun,
ProjectPaths,
} from './executor.js';
// Utilities
export {
substituteWorkflowVariables,
substituteNodeOutputRefs,
buildPromptWithContext,
evaluateCondition,
classifyError,
safeSendMessage,
formatSubprocessFailure,
sleep,
retryWithBackoff,
resolveNodeOutputField,
OutputRefError,
DagCycleError,
NodeTimeoutError,
ApprovalRejectedError,
LoopMaxIterationsError,
} from './utils.js';
export type { ErrorCategory } from './utils.js';

View File

@@ -0,0 +1,228 @@
/**
* Provider/model resolution for the Ion workflow engine.
*
* Maps model references (aliases, tier presets, or literal specs)
* to concrete AI model configurations.
*/
import type { ProviderConfig } from './deps.js';
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
/** A concrete model specification with all fields resolved. */
export interface LiteralModelSpec {
/** The AI provider (e.g. "openai", "anthropic"). */
provider: string;
/** The model identifier (e.g. "gpt-4o", "claude-sonnet-4-20250514"). */
model: string;
/** Optional effort level (e.g. "low", "medium", "high"). */
effort?: string;
/** Optional thinking/reasoning configuration. */
thinking?: {
type: string;
budgetTokens?: number;
};
}
/** A preset that maps an alias to a concrete model configuration. */
export interface ModelAliasPreset {
/** The provider for this preset. */
provider: string;
/** The model identifier. */
model: string;
/** Optional effort level. */
effort?: string;
/** Optional thinking configuration. */
thinking?: {
type: string;
budgetTokens?: number;
};
}
/** Tier definitions for an AI profile. */
export interface AiProfileTiers {
/** Fast/cheap tier. */
fast?: ModelAliasPreset;
/** Balanced tier. */
balanced?: ModelAliasPreset;
/** Powerful/expensive tier. */
powerful?: ModelAliasPreset;
}
/** An AI profile with tiers and named aliases. */
export interface AiProfile {
/** The default provider. */
defaultProvider: string;
/** Named provider configurations. */
providers: Record<string, ProviderConfig>;
/** Tier presets. */
tiers: AiProfileTiers;
/** Named aliases mapping to presets. */
aliases: Record<string, ModelAliasPreset>;
}
/** Options for building an AI profile. */
export interface BuildAiProfileOptions {
/** The default assistant/provider id. */
assistant: string;
/** Named provider configurations. */
assistants: Record<string, ProviderConfig>;
/** Optional model overrides from workflow config. */
modelOverrides?: Record<string, ModelAliasPreset>;
}
// ---------------------------------------------------------------------------
// Type guards
// ---------------------------------------------------------------------------
/**
* Check if a model spec is a literal (fully resolved) spec.
*
* A literal spec has a `provider` and `model` field directly.
*/
export function isLiteralSpec(
spec: unknown,
): spec is LiteralModelSpec {
if (typeof spec !== 'object' || spec === null) {
return false;
}
const obj = spec as Record<string, unknown>;
return typeof obj['provider'] === 'string' && typeof obj['model'] === 'string';
}
// ---------------------------------------------------------------------------
// Profile builder
// ---------------------------------------------------------------------------
/** Default tier presets for common providers. */
const DEFAULT_TIERS: Record<string, AiProfileTiers> = {
openai: {
fast: { provider: 'openai', model: 'gpt-4o-mini' },
balanced: { provider: 'openai', model: 'gpt-4o' },
powerful: { provider: 'openai', model: 'o1' },
},
anthropic: {
fast: { provider: 'anthropic', model: 'claude-haiku-4-20250414' },
balanced: { provider: 'anthropic', model: 'claude-sonnet-4-20250514' },
powerful: {
provider: 'anthropic',
model: 'claude-opus-4-20250514',
thinking: { type: 'enabled', budgetTokens: 10000 },
},
},
};
/**
* Build an AI profile from workflow configuration.
*
* Merges default tier presets with any model overrides from the config.
*/
export function buildAiProfile(
opts: BuildAiProfileOptions,
): AiProfile {
const providers = { ...opts.assistants };
// Determine the default provider from the assistant config.
const defaultProviderConfig = providers[opts.assistant];
const defaultProvider = defaultProviderConfig?.provider ?? opts.assistant;
// Start with default tiers for the default provider.
const baseTiers = DEFAULT_TIERS[defaultProvider] ?? {};
// Apply model overrides if provided.
const tiers: AiProfileTiers = { ...baseTiers };
if (opts.modelOverrides) {
for (const [key, preset] of Object.entries(opts.modelOverrides)) {
if (key === 'fast' || key === 'balanced' || key === 'powerful') {
tiers[key] = preset;
}
}
}
// Build aliases from overrides and tiers.
const aliases: Record<string, ModelAliasPreset> = {};
// Tier-based aliases.
if (tiers.fast) aliases['fast'] = tiers.fast;
if (tiers.balanced) aliases['balanced'] = tiers.balanced;
if (tiers.powerful) aliases['powerful'] = tiers.powerful;
// Custom overrides as aliases.
if (opts.modelOverrides) {
for (const [key, preset] of Object.entries(opts.modelOverrides)) {
if (key !== 'fast' && key !== 'balanced' && key !== 'powerful') {
aliases[key] = preset;
}
}
}
return {
defaultProvider,
providers,
tiers,
aliases,
};
}
// ---------------------------------------------------------------------------
// Model resolution
// ---------------------------------------------------------------------------
/**
* Resolve a model reference to a literal model spec.
*
* A model reference can be:
* - A literal spec (has `provider` and `model` fields) → returned as-is
* - A tier name ("fast", "balanced", "powerful") → resolved from profile tiers
* - A named alias → resolved from profile aliases
* - A provider-prefixed reference ("openai/gpt-4o") → parsed into a spec
* - A bare model name → resolved using the default provider
*
* Throws if the reference cannot be resolved.
*/
export function resolveModelSpec(
profile: AiProfile,
modelRef: string | LiteralModelSpec,
): LiteralModelSpec {
// Already a literal spec.
if (typeof modelRef !== 'string') {
if (isLiteralSpec(modelRef)) {
return modelRef;
}
throw new Error(`Invalid model spec: ${JSON.stringify(modelRef)}`);
}
// Check aliases first (includes tier aliases).
const alias = profile.aliases[modelRef];
if (alias) {
return {
provider: alias.provider,
model: alias.model,
effort: alias.effort,
thinking: alias.thinking,
};
}
// Provider-prefixed reference: "provider/model"
if (modelRef.includes('/')) {
const slashIndex = modelRef.indexOf('/');
const provider = modelRef.slice(0, slashIndex)!;
const model = modelRef.slice(slashIndex + 1);
if (!provider || !model) {
throw new Error(
`Invalid provider-prefixed model reference: "${modelRef}". Expected format "provider/model".`,
);
}
return { provider, model };
}
// Bare model name — use default provider.
return {
provider: profile.defaultProvider,
model: modelRef,
};
}

View File

@@ -0,0 +1,122 @@
/**
* Node output reference resolution for the Ion workflow engine.
*
* Resolves `$nodeId.field` references in workflow conditions and prompts,
* with strict schema-aware validation and descriptive errors.
*/
// ---------------------------------------------------------------------------
// Output reference result
// ---------------------------------------------------------------------------
export type OutputRefKind = 'value' | 'empty';
export interface OutputRefResult {
/** Whether the field had a value or was empty. */
kind: OutputRefKind;
/** The resolved value (empty string for missing optional fields). */
value: string;
}
// ---------------------------------------------------------------------------
// OutputRefError
// ---------------------------------------------------------------------------
export class OutputRefError extends Error {
public readonly nodeId: string;
public readonly field: string;
constructor(nodeId: string, field: string, message: string) {
super(`Output reference error for node "${nodeId}".${field}: ${message}`);
this.name = 'OutputRefError';
this.nodeId = nodeId;
this.field = field;
}
}
// ---------------------------------------------------------------------------
// Schema helpers
// ---------------------------------------------------------------------------
/**
* Extract declared field names from an output_format schema.
*
* The output_format can be:
* - A JSON Schema object with `properties` (standard)
* - A string describing the format (treated as having no declared fields)
* - Undefined (no schema)
*
* Returns a Set of field names that are declared in the schema.
*/
export function declaredFieldsFromSchema(
outputFormat: Record<string, unknown> | string | undefined,
): Set<string> {
if (!outputFormat || typeof outputFormat === 'string') {
return new Set();
}
const properties = outputFormat['properties'];
if (properties && typeof properties === 'object' && properties !== null) {
return new Set(Object.keys(properties as Record<string, unknown>));
}
return new Set();
}
// ---------------------------------------------------------------------------
// Node output resolution
// ---------------------------------------------------------------------------
/**
* Resolve a specific field from a node's output.
*
* Behavior:
* - If the field is present in the output, returns `{ kind: 'value', value }`.
* - If the field is declared in the schema but not present in the output,
* returns `{ kind: 'empty', value: '' }` (optional field not set).
* - If the field is NOT declared in the schema AND not in the output,
* throws `OutputRefError` (undeclared reference).
* - If the field is NOT declared in the schema but IS in the output,
* returns `{ kind: 'value', value }` (dynamic output).
*
* The `declaredFields` parameter should come from `declaredFieldsFromSchema()`.
*/
export function resolveNodeOutputField(
nodeOutput: Record<string, unknown>,
nodeId: string,
field: string,
declaredFields?: Set<string>,
): OutputRefResult {
// Check if the field exists in the output.
if (field in nodeOutput) {
const rawValue = nodeOutput[field];
// Convert the value to a string.
if (rawValue === null || rawValue === undefined) {
// Field key exists but value is nullish — treat as empty.
return { kind: 'empty', value: '' };
}
if (typeof rawValue === 'string') {
return { kind: 'value', value: rawValue };
}
// Non-string values are JSON-serialized.
return { kind: 'value', value: JSON.stringify(rawValue) };
}
// Field is not in the output. Check if it was declared in the schema.
const isDeclared = declaredFields?.has(field) ?? false;
if (isDeclared) {
// Declared but not present — optional field not set.
return { kind: 'empty', value: '' };
}
// Not declared and not present — this is an error.
throw new OutputRefError(
nodeId,
field,
`Field "${field}" is not declared in the output schema and is not present in the node output. Available fields: ${Object.keys(nodeOutput).join(', ') || '(none)'}`,
);
}

View File

@@ -0,0 +1,372 @@
/**
* Utility functions for the Ion workflow engine.
*
* Provides variable substitution, condition evaluation, error classification,
* and safe messaging helpers used by the DAG executor and top-level executor.
*/
import type { NodeOutput } from '../schema/index.js';
// ---------------------------------------------------------------------------
// Variable substitution
// ---------------------------------------------------------------------------
/**
* Substitute workflow-level variables in a string.
*
* Replaces `${VAR_NAME}` patterns with values from the provided variables map.
* Supports nested dot-notation access (e.g. `${env.API_KEY}`).
*/
export function substituteWorkflowVariables(
template: string,
variables: Record<string, unknown>,
): string {
return template.replace(/\$\{([^}]+)\}/g, (_match, path: string) => {
const parts = path.split('.');
let current: unknown = variables;
for (const part of parts) {
if (current == null || typeof current !== 'object') return '';
current = (current as Record<string, unknown>)[part];
}
return current != null ? String(current) : '';
});
}
/**
* Regex pattern for node output references: `$nodeId.output` or `$nodeId.output.field`.
*/
const NODE_OUTPUT_REF_REGEX =
/\$([a-zA-Z_][\w-]*)\.output(?:\.([a-zA-Z_][\w]*))?/g;
/**
* Substitute node output references in a prompt string.
*
* Resolves `$nodeId.output` → full text output, and
* `$nodeId.output.field` → specific structured field.
*
* @param prompt - The prompt template containing references.
* @param nodeOutputs - Map of node id → NodeOutput.
* @param escapedForBash - If true, escapes special bash characters in the output.
*/
export function substituteNodeOutputRefs(
prompt: string,
nodeOutputs: Map<string, NodeOutput>,
escapedForBash = false,
): string {
return prompt.replace(NODE_OUTPUT_REF_REGEX, (_match, nodeId: string, field?: string) => {
const output = nodeOutputs.get(nodeId);
if (!output) {
throw new OutputRefError(
`Node output reference $${nodeId}.output not found. ` +
`Available nodes: ${[...nodeOutputs.keys()].join(', ')}`,
);
}
let value: string;
if (field) {
value = resolveNodeOutputField(output, field);
} else {
value = output.text ?? '';
}
if (escapedForBash) {
value = value.replace(/(["'$`\\!])/g, '\\$1');
}
return value;
});
}
/**
* Resolve a specific field from a node's structured output.
*
* @throws OutputRefError if the field doesn't exist or output has no fields.
*/
export function resolveNodeOutputField(output: NodeOutput, field: string): string {
if (!output.fields || !(field in output.fields)) {
throw new OutputRefError(
`Node ${output.nodeId} output does not have field "${field}". ` +
`Available fields: ${output.fields ? Object.keys(output.fields).join(', ') : '(none)'}`,
);
}
const value = output.fields[field];
return value != null ? String(value) : '';
}
/**
* Build a complete prompt string with context injection.
*
* Applies workflow variable substitution and node output reference
* substitution to produce the final prompt sent to the AI provider.
*/
export function buildPromptWithContext(
prompt: string,
variables: Record<string, unknown>,
nodeOutputs: Map<string, NodeOutput>,
escapedForBash = false,
): string {
let result = substituteWorkflowVariables(prompt, variables);
result = substituteNodeOutputRefs(result, nodeOutputs, escapedForBash);
return result;
}
// ---------------------------------------------------------------------------
// Condition evaluation
// ---------------------------------------------------------------------------
/**
* Evaluate a condition expression against the current workflow context.
*
* Supports simple expressions:
* - Truthy/falsy string check (empty string = false)
* - Comparison: `==`, `!=`, `>`, `<`, `>=`, `<=`
* - Boolean literals: `true`, `false`
* - Variable references: `${var}` resolved before evaluation
*
* @param condition - The condition string to evaluate.
* @param variables - Workflow variables for substitution.
* @returns Whether the condition is truthy.
*/
export function evaluateCondition(
condition: string | undefined,
variables: Record<string, unknown>,
): boolean {
if (condition === undefined || condition === '') return true;
// Substitute variables first
const resolved = substituteWorkflowVariables(condition, variables).trim();
// Boolean literals
if (resolved === 'true') return true;
if (resolved === 'false') return false;
// Empty after substitution = falsy
if (resolved === '') return false;
// Comparison operators
const comparisonMatch = resolved.match(/^(.+?)\s*(==|!=|>=|<=|>|<)\s*(.+)$/);
if (comparisonMatch) {
const [, left, op, right] = comparisonMatch;
const leftVal = left!.trim();
const rightVal = right!.trim();
switch (op) {
case '==':
return leftVal === rightVal;
case '!=':
return leftVal !== rightVal;
case '>=':
return parseFloat(leftVal) >= parseFloat(rightVal);
case '<=':
return parseFloat(leftVal) <= parseFloat(rightVal);
case '>':
return parseFloat(leftVal) > parseFloat(rightVal);
case '<':
return parseFloat(leftVal) < parseFloat(rightVal);
}
}
// Default: non-empty string is truthy
return resolved.length > 0;
}
// ---------------------------------------------------------------------------
// Error classification
// ---------------------------------------------------------------------------
/** Error categories for classification. */
export type ErrorCategory = 'transient' | 'permanent' | 'timeout' | 'rate_limit' | 'unknown';
/**
* Classify an error into a category for retry decisions.
*/
export function classifyError(error: unknown): ErrorCategory {
if (error instanceof Error) {
const msg = error.message.toLowerCase();
// Timeout errors
if (
msg.includes('timeout') ||
msg.includes('timed out') ||
msg.includes('aborted') ||
error.name === 'AbortError' ||
error.name === 'TimeoutError'
) {
return 'timeout';
}
// Rate limiting
if (
msg.includes('rate limit') ||
msg.includes('429') ||
msg.includes('too many requests')
) {
return 'rate_limit';
}
// Permanent errors
if (
msg.includes('authentication') ||
msg.includes('unauthorized') ||
msg.includes('forbidden') ||
msg.includes('401') ||
msg.includes('403') ||
msg.includes('invalid api key') ||
msg.includes('permission denied')
) {
return 'permanent';
}
// Transient errors
if (
msg.includes('network') ||
msg.includes('econnreset') ||
msg.includes('econnrefused') ||
msg.includes('enotfound') ||
msg.includes('socket hang up') ||
msg.includes('500') ||
msg.includes('502') ||
msg.includes('503') ||
msg.includes('504') ||
msg.includes('internal server error') ||
msg.includes('bad gateway') ||
msg.includes('service unavailable') ||
msg.includes('gateway timeout')
) {
return 'transient';
}
}
return 'unknown';
}
// ---------------------------------------------------------------------------
// Safe messaging
// ---------------------------------------------------------------------------
/**
* Safely send a message to the platform, swallowing errors.
*
* Used for non-critical notifications where failure should not
* abort the workflow.
*/
export async function safeSendMessage(
platform: { sendMessage: (convId: string, msg: string, meta?: Record<string, unknown>) => Promise<void> },
conversationId: string,
message: string,
metadata?: Record<string, unknown>,
): Promise<void> {
try {
await platform.sendMessage(conversationId, message, metadata);
} catch {
// Swallow — this is a best-effort notification
}
}
// ---------------------------------------------------------------------------
// Custom errors
// ---------------------------------------------------------------------------
/** Thrown when a node output reference cannot be resolved. */
export class OutputRefError extends Error {
constructor(message: string) {
super(message);
this.name = 'OutputRefError';
}
}
/** Thrown when a cycle is detected in the DAG. */
export class DagCycleError extends Error {
constructor(nodeCount: number, layerSum: number) {
super(
`Cycle detected in DAG: ${nodeCount} nodes but only ${layerSum} reachable via topological sort`,
);
this.name = 'DagCycleError';
}
}
/** Thrown when a node execution times out. */
export class NodeTimeoutError extends Error {
constructor(nodeId: string, timeoutMs: number) {
super(`Node "${nodeId}" timed out after ${timeoutMs}ms`);
this.name = 'NodeTimeoutError';
}
}
/** Thrown when an approval is rejected. */
export class ApprovalRejectedError extends Error {
constructor(nodeId: string, reason?: string) {
super(`Approval rejected for node "${nodeId}"${reason ? `: ${reason}` : ''}`);
this.name = 'ApprovalRejectedError';
}
}
/** Thrown when a loop exceeds its maximum iterations. */
export class LoopMaxIterationsError extends Error {
constructor(nodeId: string, iterations: number) {
super(`Loop node "${nodeId}" exceeded max iterations (${iterations})`);
this.name = 'LoopMaxIterationsError';
}
}
// ---------------------------------------------------------------------------
// Subprocess formatting
// ---------------------------------------------------------------------------
/**
* Format a subprocess failure into a human-readable error message.
*/
export function formatSubprocessFailure(
command: string,
exitCode: number | null,
stdout: string,
stderr: string,
): string {
const parts: string[] = [];
parts.push(`Command failed: ${command}`);
if (exitCode != null) parts.push(`Exit code: ${exitCode}`);
if (stderr.trim()) parts.push(`stderr: ${stderr.trim()}`);
if (stdout.trim()) parts.push(`stdout: ${stdout.trim()}`);
return parts.join('\n');
}
// ---------------------------------------------------------------------------
// Misc helpers
// ---------------------------------------------------------------------------
/**
* Sleep for a given number of milliseconds.
*/
export function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
/**
* Retry a function with exponential backoff.
*
* @param fn - The function to retry.
* @param maxAttempts - Maximum number of attempts.
* @param baseDelayMs - Base delay between retries in ms.
* @param shouldRetry - Optional predicate to decide if a retry is warranted.
*/
export async function retryWithBackoff<T>(
fn: () => Promise<T>,
maxAttempts: number,
baseDelayMs = 1000,
shouldRetry?: (error: unknown) => boolean,
): Promise<T> {
let lastError: unknown;
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
try {
return await fn();
} catch (error) {
lastError = error;
if (shouldRetry && !shouldRetry(error)) throw error;
if (attempt < maxAttempts) {
const delay = baseDelayMs * Math.pow(2, attempt - 1);
await sleep(delay);
}
}
}
throw lastError;
}

View File

@@ -0,0 +1,22 @@
/**
* Ion workflow engine — format module.
*
* Re-exports the SOP markdown parser, YAML converter, and file discovery
* utilities so consumers can import from a single entry point:
*
* ```ts
* import { parseSopContent, convertSopToWorkflowYaml, discoverSopFiles } from '@boocode/ion/format';
* ```
*/
export {
parseSopContent,
type SopDocument,
type SopParameter,
type SopStep,
} from './sop-parser.js';
export { convertSopToWorkflowYaml } from './sop-to-yaml.js';
export { discoverSopFiles } from './sop-discovery.js';
export type { GlobFn } from './sop-discovery.js';

View File

@@ -0,0 +1,78 @@
/**
* SOP file discovery for the Ion workflow engine.
*
* Locates `.sop.md` files by delegating file-system traversal to a
* caller-provided glob function. This keeps the module pure (no Node
* dependency) and easily testable.
*/
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
/**
* A function that resolves a glob pattern to an array of absolute paths.
*
* The caller typically provides an implementation backed by `node:fs/promises`
* or a test double.
*/
export type GlobFn = (pattern: string) => Promise<string[]>;
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
/** Default search directories (in priority order, relative to cwd). */
const SEARCH_DIRS = ['.archon/workflows', '.'];
/** Glob pattern for SOP markdown files. */
const SOP_GLOB = '**/*.sop.md';
// ---------------------------------------------------------------------------
// Public API
// ---------------------------------------------------------------------------
/**
* Discover all `.sop.md` files in the given working directory.
*
* Searches `.archon/workflows/` first, then the project root, and returns
* absolute paths to every matching file. Duplicate paths are de-duplicated.
*
* @param cwd - The working directory to search from.
* @param globFn - A function that resolves a glob pattern to file paths.
* Typically backed by `glob` from `node:fs/promises` or a
* similar library.
* @returns An array of absolute file paths to discovered `.sop.md` files,
* sorted deterministically.
*/
export async function discoverSopFiles(
cwd: string,
globFn: GlobFn,
): Promise<string[]> {
const seen = new Set<string>();
const results: string[] = [];
for (const dir of SEARCH_DIRS) {
const pattern =
dir === '.' ? `${cwd}/${SOP_GLOB}` : `${cwd}/${dir}/${SOP_GLOB}`;
let paths: string[];
try {
paths = await globFn(pattern);
} catch {
// Glob errors (e.g. directory doesn't exist) are non-fatal.
continue;
}
// Sort for deterministic output and de-duplicate
paths.sort();
for (const p of paths) {
if (!seen.has(p)) {
seen.add(p);
results.push(p);
}
}
}
return results;
}

View File

@@ -0,0 +1,203 @@
/**
* SOP Markdown parser for the Ion workflow engine.
*
* Parses `.sop.md` files (Agent SOP format) into structured `SopDocument`
* objects that can be converted to YAML workflow definitions.
*/
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
/** A single parameter declared in the SOP's Parameters section. */
export interface SopParameter {
/** Parameter name (camelCase by convention). */
name: string;
/** Whether the parameter is required or optional. */
type: 'required' | 'optional';
/** Default value (only present when type is 'optional'). */
default?: string;
/** Human-readable description of the parameter. */
description: string;
}
/** A single step declared in the SOP's Steps section. */
export interface SopStep {
/** Step number (1-based). */
number: number;
/** Short human-readable step name. */
name: string;
/** Full body text of the step (may be multi-line). */
body: string;
/** Constraints text extracted from the step, if any. */
constraints?: string;
}
/** The fully-parsed SOP document. */
export interface SopDocument {
/** Title extracted from the first `# heading`. */
title: string;
/** Overview section content. */
overview: string;
/** Parsed parameters (empty array if section absent). */
parameters: SopParameter[];
/** Parsed steps (empty array if section absent). */
steps: SopStep[];
/** Optional examples section content. */
examples?: string;
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
/**
* Extract a section body from markdown text.
*
* A section starts with `## <Title>` and ends at the next `## ` or `# `
* heading (or end of string).
*/
function extractSection(markdown: string, heading: string): string | null {
const pattern = new RegExp(
`^##\\s+${escapeRegex(heading)}\\s*\\n([\\s\\S]*?)(?=\\n##|\\n#|$)`,
'm',
);
const match = markdown.match(pattern);
return match?.[1]?.trim() ?? null;
}
/** Escape special regex characters in a literal string. */
function escapeRegex(str: string): string {
return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
// ---------------------------------------------------------------------------
// Section parsers
// ---------------------------------------------------------------------------
/** Parse the Parameters section into structured `SopParameter` objects. */
function parseParameters(raw: string): SopParameter[] {
const parameters: SopParameter[] = [];
// Match lines like: - **paramName** (required): Description here
// - **paramName** (optional, default: value): Description here
const paramRegex =
/^-\s+\*\*(\w+)\*\*\s+\((required|optional)(?:,\s*default:\s*([^)]+))?\):\s+(.+)$/gm;
let match: RegExpExecArray | null;
while ((match = paramRegex.exec(raw)) !== null) {
const name = match[1]!;
const type = match[2]! as 'required' | 'optional';
const defaultVal = match[3]; // may be undefined (optional group)
const description = match[4]!;
const param: SopParameter = {
name,
type,
description,
};
if (defaultVal !== undefined) {
param.default = defaultVal.trim();
}
parameters.push(param);
}
return parameters;
}
/** Parse the Steps section into structured `SopStep` objects. */
function parseSteps(raw: string): SopStep[] {
const steps: SopStep[] = [];
// Find all ### sub-headings like "### 1. Step Name"
const stepHeadingRegex = /^###\s+(\d+)\.\s+(.+)$/gm;
// Collect heading positions: [startIndex, endIndex, number, name]
const headings: { number: number; name: string; start: number; end: number }[] = [];
let match: RegExpExecArray | null;
while ((match = stepHeadingRegex.exec(raw)) !== null) {
headings.push({
number: parseInt(match[1]!, 10),
name: match[2]!.trim(),
start: match.index,
end: -1, // filled in below
});
}
// Set end positions: each heading ends where the next one starts, or at EOF
for (let i = 0; i < headings.length; i++) {
const heading = headings[i]!;
heading.end =
i + 1 < headings.length ? headings[i + 1]!.start : raw.length;
}
for (const heading of headings) {
// The body starts after the heading line itself
const headingLineEnd = raw.indexOf('\n', heading.start);
const bodyStart = headingLineEnd === -1 ? raw.length : headingLineEnd + 1;
const sectionText = raw.slice(bodyStart, heading.end).trim();
// Extract constraints if present
const constraintsMatch = sectionText.match(
/\*\*Constraints:\*\*\s*\n([\s\S]*?)(?=\n###|\n##|$)/,
);
const constraints = constraintsMatch?.[1]?.trim();
// Body is everything before the Constraints heading (or the whole text)
let body: string;
if (constraintsMatch?.index !== undefined) {
body = sectionText.slice(0, constraintsMatch.index).trim();
} else {
body = sectionText;
}
steps.push({
number: heading.number,
name: heading.name,
body,
...(constraints ? { constraints } : {}),
});
}
return steps;
}
// ---------------------------------------------------------------------------
// Public API
// ---------------------------------------------------------------------------
/**
* Parse a `.sop.md` markdown string into a structured `SopDocument`.
*
* @param markdown - The raw markdown content of a `.sop.md` file.
* @returns A parsed `SopDocument` with title, overview, parameters, steps,
* and optional examples.
*/
export function parseSopContent(markdown: string): SopDocument {
// --- Title (first h1) ---
const titleMatch = markdown.match(/^#\s+(.+)$/m);
const title = titleMatch?.[1]?.trim() ?? 'Untitled SOP';
// --- Overview ---
const overviewRaw = extractSection(markdown, 'Overview');
const overview = overviewRaw ?? '';
// --- Parameters ---
const parametersRaw = extractSection(markdown, 'Parameters');
const parameters = parametersRaw ? parseParameters(parametersRaw) : [];
// --- Steps ---
const stepsRaw = extractSection(markdown, 'Steps');
const steps = stepsRaw ? parseSteps(stepsRaw) : [];
// --- Examples (optional) ---
const examplesRaw = extractSection(markdown, 'Examples');
return {
title,
overview,
parameters,
steps,
...(examplesRaw !== null ? { examples: examplesRaw } : {}),
};
}

View File

@@ -0,0 +1,102 @@
/**
* SOP-to-YAML converter for the Ion workflow engine.
*
* Converts a parsed `SopDocument` into a YAML workflow definition string
* that can be fed back into the Ion YAML loader.
*/
import type { SopDocument } from './sop-parser.js';
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
/** Convert a title string to kebab-case for use as a YAML identifier. */
function toKebabCase(title: string): string {
return title
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-') // non-alphanumeric → hyphen
.replace(/^-+|-+$/g, ''); // strip leading/trailing hyphens
}
/**
* Indent every line of a multi-line string by the given number of spaces.
* Empty lines are preserved without extra indentation.
*/
function indentBlock(text: string, spaces: number): string {
const prefix = ' '.repeat(spaces);
return text
.split('\n')
.map((line) => (line.length > 0 ? prefix + line : line))
.join('\n');
}
// ---------------------------------------------------------------------------
// Public API
// ---------------------------------------------------------------------------
/**
* Convert a parsed `SopDocument` into a YAML workflow definition string.
*
* The output is valid YAML that can be loaded by the Ion YAML loader.
* Steps become sequential prompt nodes with `depends_on` linking.
* Constraints are appended to the prompt body as plain text.
* Only `prompt:` nodes are emitted — SOP is conversation-only.
*
* @param sop - The parsed SOP document.
* @returns A YAML string representing the workflow.
*/
export function convertSopToWorkflowYaml(sop: SopDocument): string {
const name = toKebabCase(sop.title);
const lines: string[] = [];
// --- Header comment with parameter info ---
if (sop.parameters.length > 0) {
lines.push('# Parameters:');
for (const param of sop.parameters) {
const tag = param.type === 'required' ? 'required' : 'optional';
const defaultPart = param.default ? `, default: ${param.default}` : '';
lines.push(`# ${param.name} (${tag}${defaultPart}): ${param.description}`);
}
lines.push('');
}
// --- Top-level fields ---
lines.push(`name: ${name}`);
lines.push(`description: |`);
lines.push(indentBlock(sop.overview || 'No description provided.', 2));
lines.push('');
// --- Nodes ---
lines.push('nodes:');
for (let i = 0; i < sop.steps.length; i++) {
const step = sop.steps[i]!;
const stepId = `step_${step.number}`;
const isFirst = i === 0;
// Build the prompt body: step body + constraints (if any)
let promptBody = step.body;
if (step.constraints) {
promptBody += `\n\nConstraints:\n${step.constraints}`;
}
lines.push(` - id: ${stepId}`);
lines.push(` prompt: |`);
lines.push(indentBlock(promptBody, 6));
if (isFirst) {
lines.push(` depends_on: []`);
} else {
const prevStep = sop.steps[i - 1]!;
lines.push(` depends_on: [step_${prevStep.number}]`);
}
// Blank line between nodes (but not after the last one)
if (i < sop.steps.length - 1) {
lines.push('');
}
}
return lines.join('\n') + '\n';
}

152
packages/ion/src/index.ts Normal file
View File

@@ -0,0 +1,152 @@
// Schema layer — types and validation
export type {
StepRetryConfig,
LoopNodeConfig,
TriggerRule,
EffortLevel,
ThinkingConfig,
ApprovalOnReject,
DagNodeBase,
CommandNode,
PromptNode,
BashNode,
ScriptNode,
LoopNode,
ApprovalNode,
CancelNode,
DagNode,
ModelReasoningEffort,
WebSearchMode,
WorkflowRequirement,
WorkflowWorktreePolicy,
SandboxConfig,
ProviderOverrides,
WorkflowBase,
WorkflowDefinition,
WorkflowSource,
WorkflowExecutionResult as SchemaWorkflowExecutionResult,
WorkflowWithSource,
WorkflowLoadError,
WorkflowLoadResult,
LoadCommandResult,
WorkflowRunStatus,
NodeState,
NodeOutput,
ApprovalContext,
WorkflowRun,
NodeExecutionResult,
} from './schema/index.js';
export {
stepRetryConfigSchema,
loopNodeConfigSchema,
triggerRuleSchema,
TRIGGER_RULES,
DEFAULT_TRIGGER_RULE,
effortLevelSchema,
thinkingConfigSchema,
approvalOnRejectSchema,
dagNodeBaseSchema,
commandNodeSchema,
promptNodeSchema,
bashNodeSchema,
scriptNodeSchema,
loopNodeSchema,
approvalNodeSchema,
cancelNodeSchema,
dagNodeSchema,
isBashNode,
isLoopNode,
isApprovalNode,
isCancelNode,
isScriptNode,
isPromptNode,
isCommandNode,
modelReasoningEffortSchema,
webSearchModeSchema,
workflowRequirementSchema,
workflowWorktreePolicySchema,
sandboxConfigSchema,
providerOverridesSchema,
workflowBaseSchema,
workflowDefinitionSchema,
WorkflowSourceSchema,
workflowExecutionResultSchema,
workflowWithSourceSchema,
workflowLoadErrorSchema,
workflowLoadResultSchema,
loadCommandResultSchema,
WorkflowRunStatusSchema,
TERMINAL_WORKFLOW_STATUSES,
RESUMABLE_WORKFLOW_STATUSES,
NodeStateSchema,
ApprovalContextSchema,
WorkflowRunSchema,
nodeOutputSchema,
} from './schema/index.js';
// Engine — core execution logic
export type {
IWorkflowPlatform,
IWorkflowStore,
IAgentProvider,
WorkflowDeps,
WorkflowConfig,
ProviderConfig,
CommandFolderConfig,
CreateWorkflowRunData,
WorkflowEvent,
DagWorkflowResult,
WorkflowExecutionOptions,
HydratedResumableRun,
ProjectPaths,
ErrorCategory,
} from './engine/index.js';
export {
buildTopologicalLayers,
checkTriggerRule,
executeNodeInternal,
executeScriptNode,
handleApprovalNode,
handleLoopNode,
executeDagWorkflow,
executeWorkflow,
hydrateResumableRun,
resolveProjectPaths,
substituteWorkflowVariables,
substituteNodeOutputRefs,
buildPromptWithContext,
evaluateCondition,
classifyError,
safeSendMessage,
formatSubprocessFailure,
resolveNodeOutputField,
OutputRefError,
DagCycleError,
NodeTimeoutError,
ApprovalRejectedError,
LoopMaxIterationsError,
} from './engine/index.js';
// Storage backends
export {
createFsStore,
createSqliteStore,
createPostgresStore,
} from './store/index.js';
export type {
IWorkflowStore as StoreIWorkflowStore,
} from './store/index.js';
// Format — markdown transpiler
export {
parseSopContent,
convertSopToWorkflowYaml,
discoverSopFiles,
} from './format/index.js';
export type {
SopDocument,
SopParameter,
SopStep,
} from './format/index.js';

View File

@@ -0,0 +1,377 @@
import { describe, it, expect } from 'vitest';
import {
dagNodeSchema,
promptNodeSchema,
commandNodeSchema,
bashNodeSchema,
scriptNodeSchema,
loopNodeSchema,
approvalNodeSchema,
cancelNodeSchema,
loopNodeConfigSchema,
stepRetryConfigSchema,
triggerRuleSchema,
isBashNode,
isLoopNode,
isApprovalNode,
isCancelNode,
isScriptNode,
isPromptNode,
isCommandNode,
effortLevelSchema,
thinkingConfigSchema,
approvalOnRejectSchema,
dagNodeBaseSchema,
} from '../index.js';
import type {
DagNode,
PromptNode,
CommandNode,
BashNode,
ScriptNode,
LoopNode,
ApprovalNode,
CancelNode,
} from '../index.js';
const validBase = {
id: 'test-node',
depends_on: [],
};
describe('dagNodeSchema', () => {
it('validates a prompt node', () => {
const node = { ...validBase, kind: 'prompt' as const, prompt: 'Do the thing' };
const result = dagNodeSchema.safeParse(node);
expect(result.success).toBe(true);
});
it('validates a command node', () => {
const node = { ...validBase, kind: 'command' as const, command: 'echo hello' };
const result = dagNodeSchema.safeParse(node);
expect(result.success).toBe(true);
});
it('validates a bash node', () => {
const node = { ...validBase, kind: 'bash' as const, bash: 'echo hello' };
const result = dagNodeSchema.safeParse(node);
expect(result.success).toBe(true);
});
it('validates a script node', () => {
const node = { ...validBase, kind: 'script' as const, script: 'print(1)', runtime: 'bun' as const };
const result = dagNodeSchema.safeParse(node);
expect(result.success).toBe(true);
});
it('validates a loop node', () => {
const node = {
...validBase,
kind: 'loop' as const,
config: { prompt: 'iterate', until: 'done', max_iterations: 5 },
};
const result = dagNodeSchema.safeParse(node);
expect(result.success).toBe(true);
});
it('validates an approval node', () => {
const node = { ...validBase, kind: 'approval' as const, message: 'Approve this?' };
const result = dagNodeSchema.safeParse(node);
expect(result.success).toBe(true);
});
it('validates a cancel node', () => {
const node = { ...validBase, kind: 'cancel' as const };
const result = dagNodeSchema.safeParse(node);
expect(result.success).toBe(true);
});
it('rejects a node with no kind field', () => {
const result = dagNodeSchema.safeParse({ id: 'x', prompt: 'y' });
expect(result.success).toBe(false);
});
it('rejects a node with an invalid kind', () => {
const result = dagNodeSchema.safeParse({ ...validBase, kind: 'unknown' });
expect(result.success).toBe(false);
});
});
describe('node type unique fields', () => {
it('prompt node requires prompt or command_file', () => {
const result = promptNodeSchema.safeParse({ ...validBase, kind: 'prompt' });
expect(result.success).toBe(true);
});
it('command node requires command', () => {
const result = commandNodeSchema.safeParse({ ...validBase, kind: 'command' });
expect(result.success).toBe(false);
});
it('bash node requires bash', () => {
const result = bashNodeSchema.safeParse({ ...validBase, kind: 'bash' });
expect(result.success).toBe(false);
});
it('script node requires script and runtime', () => {
const noScript = scriptNodeSchema.safeParse({ ...validBase, kind: 'script', runtime: 'bun' });
expect(noScript.success).toBe(false);
const noRuntime = scriptNodeSchema.safeParse({ ...validBase, kind: 'script', script: 'print(1)' });
expect(noRuntime.success).toBe(false);
const valid = scriptNodeSchema.safeParse({
...validBase,
kind: 'script',
script: 'print(1)',
runtime: 'bun',
});
expect(valid.success).toBe(true);
});
it('loop node requires config', () => {
const result = loopNodeSchema.safeParse({ ...validBase, kind: 'loop' });
expect(result.success).toBe(false);
});
it('approval node requires message', () => {
const result = approvalNodeSchema.safeParse({ ...validBase, kind: 'approval' });
expect(result.success).toBe(false);
});
it('cancel node does not require reason', () => {
const result = cancelNodeSchema.safeParse({ ...validBase, kind: 'cancel' });
expect(result.success).toBe(true);
});
});
describe('loopNodeConfigSchema', () => {
it('validates a minimal config', () => {
const result = loopNodeConfigSchema.safeParse({
prompt: 'iterate',
until: 'done',
max_iterations: 5,
});
expect(result.success).toBe(true);
});
it('rejects empty prompt', () => {
const result = loopNodeConfigSchema.safeParse({
prompt: '',
until: 'done',
max_iterations: 5,
});
expect(result.success).toBe(false);
});
it('rejects empty until', () => {
const result = loopNodeConfigSchema.safeParse({
prompt: 'iterate',
until: '',
max_iterations: 5,
});
expect(result.success).toBe(false);
});
it('rejects non-positive max_iterations', () => {
const zeroResult = loopNodeConfigSchema.safeParse({
prompt: 'iterate',
until: 'done',
max_iterations: 0,
});
expect(zeroResult.success).toBe(false);
const negResult = loopNodeConfigSchema.safeParse({
prompt: 'iterate',
until: 'done',
max_iterations: -1,
});
expect(negResult.success).toBe(false);
});
it('requires gate_message when interactive is true', () => {
const noGate = loopNodeConfigSchema.safeParse({
prompt: 'iterate',
until: 'done',
max_iterations: 5,
interactive: true,
});
expect(noGate.success).toBe(false);
const withGate = loopNodeConfigSchema.safeParse({
prompt: 'iterate',
until: 'done',
max_iterations: 5,
interactive: true,
gate_message: 'Approve this iteration?',
});
expect(withGate.success).toBe(true);
});
it('allows interactive=false without gate_message', () => {
const result = loopNodeConfigSchema.safeParse({
prompt: 'iterate',
until: 'done',
max_iterations: 5,
interactive: false,
});
expect(result.success).toBe(true);
});
});
describe('stepRetryConfigSchema', () => {
it('validates a minimal retry config', () => {
const result = stepRetryConfigSchema.safeParse({ max_attempts: 3 });
expect(result.success).toBe(true);
});
it('rejects max_attempts below 1', () => {
const result = stepRetryConfigSchema.safeParse({ max_attempts: 0 });
expect(result.success).toBe(false);
});
it('rejects max_attempts above 5', () => {
const result = stepRetryConfigSchema.safeParse({ max_attempts: 6 });
expect(result.success).toBe(false);
});
it('accepts max_attempts at boundaries (1 and 5)', () => {
expect(stepRetryConfigSchema.safeParse({ max_attempts: 1 }).success).toBe(true);
expect(stepRetryConfigSchema.safeParse({ max_attempts: 5 }).success).toBe(true);
});
it('rejects delay_ms below 1000', () => {
const result = stepRetryConfigSchema.safeParse({ max_attempts: 2, delay_ms: 500 });
expect(result.success).toBe(false);
});
it('rejects delay_ms above 60000', () => {
const result = stepRetryConfigSchema.safeParse({ max_attempts: 2, delay_ms: 70000 });
expect(result.success).toBe(false);
});
it('accepts delay_ms at boundaries (1000 and 60000)', () => {
expect(stepRetryConfigSchema.safeParse({ max_attempts: 2, delay_ms: 1000 }).success).toBe(true);
expect(stepRetryConfigSchema.safeParse({ max_attempts: 2, delay_ms: 60000 }).success).toBe(true);
});
it('defaults on_error to transient', () => {
const result = stepRetryConfigSchema.parse({ max_attempts: 2 });
expect(result.on_error).toBe('transient');
});
});
describe('triggerRuleSchema', () => {
it('validates all trigger rules', () => {
const rules = ['all_success', 'one_success', 'all_done', 'none_failed_min_one_success'];
for (const rule of rules) {
expect(triggerRuleSchema.safeParse(rule).success).toBe(true);
}
});
it('rejects invalid trigger rules', () => {
const result = triggerRuleSchema.safeParse('invalid_rule');
expect(result.success).toBe(false);
});
});
describe('type guards', () => {
const nodes: DagNode[] = [
{ ...validBase, kind: 'prompt', prompt: 'test' } as PromptNode,
{ ...validBase, kind: 'command', command: 'echo' } as CommandNode,
{ ...validBase, kind: 'bash', bash: 'echo' } as BashNode,
{ ...validBase, kind: 'script', script: 'print(1)', runtime: 'bun' } as ScriptNode,
{ ...validBase, kind: 'loop', config: { prompt: 'x', until: 'y', max_iterations: 3 } } as LoopNode,
{ ...validBase, kind: 'approval', message: 'ok?' } as ApprovalNode,
{ ...validBase, kind: 'cancel' } as CancelNode,
];
it('isPromptNode identifies prompt nodes', () => {
expect(isPromptNode(nodes[0])).toBe(true);
expect(isPromptNode(nodes[1])).toBe(false);
});
it('isCommandNode identifies command nodes', () => {
expect(isCommandNode(nodes[1])).toBe(true);
expect(isCommandNode(nodes[0])).toBe(false);
});
it('isBashNode identifies bash nodes', () => {
expect(isBashNode(nodes[2])).toBe(true);
expect(isBashNode(nodes[0])).toBe(false);
});
it('isScriptNode identifies script nodes', () => {
expect(isScriptNode(nodes[3])).toBe(true);
expect(isScriptNode(nodes[0])).toBe(false);
});
it('isLoopNode identifies loop nodes', () => {
expect(isLoopNode(nodes[4])).toBe(true);
expect(isLoopNode(nodes[0])).toBe(false);
});
it('isApprovalNode identifies approval nodes', () => {
expect(isApprovalNode(nodes[5])).toBe(true);
expect(isApprovalNode(nodes[0])).toBe(false);
});
it('isCancelNode identifies cancel nodes', () => {
expect(isCancelNode(nodes[6])).toBe(true);
expect(isCancelNode(nodes[0])).toBe(false);
});
});
describe('effortLevelSchema', () => {
it('validates all effort levels', () => {
expect(effortLevelSchema.safeParse('low').success).toBe(true);
expect(effortLevelSchema.safeParse('medium').success).toBe(true);
expect(effortLevelSchema.safeParse('high').success).toBe(true);
});
it('rejects invalid effort levels', () => {
expect(effortLevelSchema.safeParse('extreme').success).toBe(false);
});
});
describe('thinkingConfigSchema', () => {
it('validates a minimal thinking config', () => {
const result = thinkingConfigSchema.safeParse({});
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.enabled).toBe(false);
}
});
it('validates a full thinking config', () => {
const result = thinkingConfigSchema.safeParse({ enabled: true, max_tokens: 4096 });
expect(result.success).toBe(true);
});
});
describe('approvalOnRejectSchema', () => {
it('validates all on-reject actions', () => {
expect(approvalOnRejectSchema.safeParse('retry').success).toBe(true);
expect(approvalOnRejectSchema.safeParse('fail').success).toBe(true);
expect(approvalOnRejectSchema.safeParse('skip').success).toBe(true);
});
it('rejects invalid on-reject actions', () => {
expect(approvalOnRejectSchema.safeParse('restart').success).toBe(false);
});
});
describe('dagNodeBaseSchema', () => {
it('validates a minimal base node', () => {
const result = dagNodeBaseSchema.safeParse({
id: 'node-1',
kind: 'prompt',
});
expect(result.success).toBe(true);
});
it('defaults depends_on to empty array', () => {
const result = dagNodeBaseSchema.parse({ id: 'node-1', kind: 'prompt' });
expect(result.depends_on).toEqual([]);
});
});

View File

@@ -0,0 +1,273 @@
import { z } from 'zod';
import { stepRetryConfigSchema } from './retry.js';
import { loopNodeConfigSchema } from './loop.js';
import { triggerRuleSchema } from './trigger-rule.js';
// ---------------------------------------------------------------------------
// Effort level
// ---------------------------------------------------------------------------
/** Effort level for AI model calls. */
export const effortLevelSchema = z.enum(['low', 'medium', 'high']);
export type EffortLevel = z.infer<typeof effortLevelSchema>;
// ---------------------------------------------------------------------------
// Thinking configuration
// ---------------------------------------------------------------------------
/** Configuration for extended thinking / chain-of-thought. */
export const thinkingConfigSchema = z.object({
/** Whether thinking is enabled. */
enabled: z.boolean().default(false),
/** Maximum thinking tokens. */
max_tokens: z.number().int().positive().optional(),
});
export type ThinkingConfig = z.infer<typeof thinkingConfigSchema>;
// ---------------------------------------------------------------------------
// Approval on-reject action
// ---------------------------------------------------------------------------
/** What to do when an approval node is rejected. */
export const approvalOnRejectSchema = z.enum(['retry', 'fail', 'skip']);
export type ApprovalOnReject = z.infer<typeof approvalOnRejectSchema>;
// ---------------------------------------------------------------------------
// Base DAG node
// ---------------------------------------------------------------------------
/** The kind of a DAG node determines how it executes. */
export const dagNodeKindSchema = z.enum([
'prompt',
'command',
'bash',
'script',
'approval',
'loop',
'cancel',
]);
/** Base fields shared by all DAG node types. */
export const dagNodeBaseSchema = z.object({
id: z.string(),
kind: dagNodeKindSchema,
name: z.string().optional(),
when: z.string().optional(),
depends_on: z.array(z.string()).default([]),
trigger_rule: triggerRuleSchema.optional(),
retry: stepRetryConfigSchema.optional(),
env: z.record(z.string()).optional(),
});
export type DagNodeBase = z.infer<typeof dagNodeBaseSchema>;
export type DagNodeKind = z.infer<typeof dagNodeKindSchema>;
// ---------------------------------------------------------------------------
// Prompt node — sends a prompt to an AI provider
// ---------------------------------------------------------------------------
export const promptNodeSchema = z.object({
id: z.string(),
kind: z.literal('prompt'),
/** Human-readable name for display. */
name: z.string().optional(),
/** The prompt text (inline). Mutually exclusive with command_file. */
prompt: z.string().optional(),
/** Path to a command file containing the prompt. */
command_file: z.string().optional(),
/** Provider id to use (overrides workflow default). */
provider: z.string().optional(),
/** Model override for the provider. */
model: z.string().optional(),
/** Structured output format definition. */
output_format: z
.record(z.unknown())
.optional(),
/** Condition expression; node runs only when truthy. */
when: z.string().optional(),
/** Node ids this node depends on. */
depends_on: z.array(z.string()).default([]),
/** Trigger rule for evaluating dependency states. */
trigger_rule: triggerRuleSchema.optional(),
/** Retry configuration. */
retry: stepRetryConfigSchema.optional(),
/** Idle timeout in milliseconds. */
idle_timeout_ms: z.number().positive().optional(),
/** Environment variable overrides for this node. */
env: z.record(z.string()).optional(),
});
// ---------------------------------------------------------------------------
// Command node — runs a shell command
// ---------------------------------------------------------------------------
export const commandNodeSchema = z.object({
id: z.string(),
kind: z.literal('command'),
name: z.string().optional(),
/** The command string to execute. */
command: z.string(),
/** Working directory override. */
cwd: z.string().optional(),
when: z.string().optional(),
depends_on: z.array(z.string()).default([]),
trigger_rule: triggerRuleSchema.optional(),
retry: stepRetryConfigSchema.optional(),
env: z.record(z.string()).optional(),
});
// ---------------------------------------------------------------------------
// Bash node — runs a bash script
// ---------------------------------------------------------------------------
export const bashNodeSchema = z.object({
id: z.string(),
kind: z.literal('bash'),
name: z.string().optional(),
/** Bash script content to execute. */
bash: z.string(),
/** Timeout in milliseconds. */
timeout_ms: z.number().positive().optional(),
when: z.string().optional(),
depends_on: z.array(z.string()).default([]),
trigger_rule: triggerRuleSchema.optional(),
retry: stepRetryConfigSchema.optional(),
env: z.record(z.string()).optional(),
});
// ---------------------------------------------------------------------------
// Script node — runs a script with a specific runtime
// ---------------------------------------------------------------------------
export const scriptNodeSchema = z.object({
id: z.string(),
kind: z.literal('script'),
name: z.string().optional(),
/** Script content to execute. */
script: z.string(),
/** Runtime: 'bun' or 'uv'. */
runtime: z.enum(['bun', 'uv']),
/** Dependencies to install before running. */
deps: z.array(z.string()).default([]),
/** Timeout in milliseconds. */
timeout_ms: z.number().positive().optional(),
when: z.string().optional(),
depends_on: z.array(z.string()).default([]),
trigger_rule: triggerRuleSchema.optional(),
retry: stepRetryConfigSchema.optional(),
env: z.record(z.string()).optional(),
});
// ---------------------------------------------------------------------------
// Approval node — pauses for human approval
// ---------------------------------------------------------------------------
export const approvalNodeSchema = z.object({
id: z.string(),
kind: z.literal('approval'),
name: z.string().optional(),
/** Message shown to the approver. */
message: z.string(),
/** Prompt to execute if the approval is rejected. */
on_reject: z.string().optional(),
when: z.string().optional(),
depends_on: z.array(z.string()).default([]),
trigger_rule: triggerRuleSchema.optional(),
env: z.record(z.string()).optional(),
});
// ---------------------------------------------------------------------------
// Loop node — iterates until a condition is met
// ---------------------------------------------------------------------------
export const loopNodeSchema = z.object({
id: z.string(),
kind: z.literal('loop'),
name: z.string().optional(),
/** Loop configuration (prompt, until, max_iterations, etc.). */
config: loopNodeConfigSchema,
/** Provider id to use (overrides workflow default). */
provider: z.string().optional(),
/** Model override for the provider. */
model: z.string().optional(),
when: z.string().optional(),
depends_on: z.array(z.string()).default([]),
trigger_rule: triggerRuleSchema.optional(),
retry: stepRetryConfigSchema.optional(),
env: z.record(z.string()).optional(),
});
// ---------------------------------------------------------------------------
// Cancel node — cancels the workflow
// ---------------------------------------------------------------------------
export const cancelNodeSchema = z.object({
id: z.string(),
kind: z.literal('cancel'),
name: z.string().optional(),
/** Reason for cancellation. */
reason: z.string().optional(),
when: z.string().optional(),
depends_on: z.array(z.string()).default([]),
trigger_rule: triggerRuleSchema.optional(),
env: z.record(z.string()).optional(),
});
// ---------------------------------------------------------------------------
// Union type — any DAG node
// ---------------------------------------------------------------------------
export const dagNodeSchema = z.discriminatedUnion('kind', [
promptNodeSchema,
commandNodeSchema,
bashNodeSchema,
scriptNodeSchema,
approvalNodeSchema,
loopNodeSchema,
cancelNodeSchema,
]);
export type DagNode = z.infer<typeof dagNodeSchema>;
export type PromptNode = z.infer<typeof promptNodeSchema>;
export type CommandNode = z.infer<typeof commandNodeSchema>;
export type BashNode = z.infer<typeof bashNodeSchema>;
export type ScriptNode = z.infer<typeof scriptNodeSchema>;
export type ApprovalNode = z.infer<typeof approvalNodeSchema>;
export type LoopNode = z.infer<typeof loopNodeSchema>;
export type CancelNode = z.infer<typeof cancelNodeSchema>;
// ---------------------------------------------------------------------------
// Type guards
// ---------------------------------------------------------------------------
export function isBashNode(node: DagNode): node is BashNode {
return node.kind === 'bash';
}
export function isScriptNode(node: DagNode): node is ScriptNode {
return node.kind === 'script';
}
export function isLoopNode(node: DagNode): node is LoopNode {
return node.kind === 'loop';
}
export function isApprovalNode(node: DagNode): node is ApprovalNode {
return node.kind === 'approval';
}
export function isCancelNode(node: DagNode): node is CancelNode {
return node.kind === 'cancel';
}
export function isPromptNode(node: DagNode): node is PromptNode {
return node.kind === 'prompt';
}
export function isCommandNode(node: DagNode): node is CommandNode {
return node.kind === 'command';
}

View File

@@ -0,0 +1,111 @@
// ---------------------------------------------------------------------------
// Ion Schema Layer — Public API
// ---------------------------------------------------------------------------
// retry.ts
export {
stepRetryConfigSchema,
type StepRetryConfig,
} from './retry.js';
// loop.ts
export {
loopNodeConfigSchema,
type LoopNodeConfig,
} from './loop.js';
// trigger-rule.ts
export {
triggerRuleSchema,
TRIGGER_RULES,
DEFAULT_TRIGGER_RULE,
type TriggerRule,
} from './trigger-rule.js';
// dag-node.ts
export {
effortLevelSchema,
type EffortLevel,
thinkingConfigSchema,
type ThinkingConfig,
approvalOnRejectSchema,
type ApprovalOnReject,
dagNodeBaseSchema,
type DagNodeBase,
commandNodeSchema,
promptNodeSchema,
bashNodeSchema,
scriptNodeSchema,
loopNodeSchema,
approvalNodeSchema,
cancelNodeSchema,
type CommandNode,
type PromptNode,
type BashNode,
type ScriptNode,
type LoopNode,
type ApprovalNode,
type CancelNode,
dagNodeSchema,
type DagNode,
isBashNode,
isLoopNode,
isApprovalNode,
isCancelNode,
isScriptNode,
isPromptNode,
isCommandNode,
} from './dag-node.js';
// workflow.ts
export {
modelReasoningEffortSchema,
type ModelReasoningEffort,
webSearchModeSchema,
type WebSearchMode,
workflowRequirementSchema,
type WorkflowRequirement,
workflowWorktreePolicySchema,
type WorkflowWorktreePolicy,
sandboxConfigSchema,
type SandboxConfig,
providerOverridesSchema,
type ProviderOverrides,
workflowBaseSchema,
type WorkflowBase,
workflowDefinitionSchema,
type WorkflowDefinition,
WorkflowSourceSchema,
type WorkflowSource,
workflowExecutionResultSchema,
type WorkflowExecutionResult,
workflowWithSourceSchema,
type WorkflowWithSource,
workflowLoadErrorSchema,
type WorkflowLoadError,
workflowLoadResultSchema,
type WorkflowLoadResult,
loadCommandResultSchema,
type LoadCommandResult,
} from './workflow.js';
// workflow-run.ts
export {
WorkflowRunStatusSchema,
type WorkflowRunStatus,
TERMINAL_WORKFLOW_STATUSES,
RESUMABLE_WORKFLOW_STATUSES,
NodeStateSchema,
type NodeState,
ApprovalContextSchema,
type ApprovalContext,
WorkflowRunSchema,
type WorkflowRun,
} from './workflow-run.js';
// node-output.ts
export {
nodeOutputSchema,
type NodeOutput,
type NodeExecutionResult,
} from './node-output.js';

View File

@@ -0,0 +1,46 @@
import { z } from 'zod';
/**
* Configuration for a loop-type DAG node.
*
* The loop repeatedly invokes the model with the given prompt until the
* `until` condition is satisfied or `max_iterations` is reached.
* When `interactive` is true, a human gate must approve each iteration.
*/
export const loopNodeConfigSchema = z
.object({
/** The prompt sent to the model on each iteration. */
prompt: z.string().min(1, 'loop prompt must not be empty'),
/** Natural-language condition that must be true for the loop to exit. */
until: z.string().min(1, 'loop until condition must not be empty'),
/** Maximum iterations before the loop is force-stopped. */
max_iterations: z
.number()
.int()
.positive('max_iterations must be a positive integer'),
/** Whether each iteration starts with a fresh context window. */
fresh_context: z.boolean().default(false),
/** Optional bash command whose exit code is used as the until-check. */
until_bash: z.string().optional(),
/** When true, a human must approve each iteration before it proceeds. */
interactive: z.boolean().optional(),
/** Message shown to the human gate when interactive is true. */
gate_message: z.string().optional(),
})
.superRefine((config, ctx) => {
if (config.interactive && !config.gate_message) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'gate_message is required when interactive is true',
path: ['gate_message'],
});
}
});
export type LoopNodeConfig = z.infer<typeof loopNodeConfigSchema>;

View File

@@ -0,0 +1,46 @@
import { z } from 'zod';
/**
* Output produced by a single DAG node after execution.
*
* Captures the result text, structured fields, and execution metadata
* so downstream nodes can reference outputs via `$nodeId.output` syntax.
*/
export const nodeOutputSchema = z.object({
/** The node id that produced this output. */
nodeId: z.string(),
/** Current state of the node execution. */
state: z.enum(['pending', 'running', 'completed', 'failed', 'skipped']),
/** The raw text output from the node (alias for text for backward compat). */
output: z.string().default(''),
/** The raw text output from the node. */
text: z.string().optional(),
/** Structured output fields (when output_format is defined). */
fields: z.record(z.unknown()).optional(),
/** Error message if the node failed. */
error: z.string().optional(),
/** Token usage or cost metadata. */
costUsd: z.number().optional(),
});
export type NodeOutput = z.infer<typeof nodeOutputSchema>;
/**
* Result of executing a single node within the DAG.
*
* Internal to the engine — not persisted directly but used to build
* the nodeOutputs map that flows between layers.
*/
export interface NodeExecutionResult {
state: 'completed' | 'failed' | 'skipped';
output?: string;
fields?: Record<string, unknown>;
error?: string;
costUsd?: number;
}

View File

@@ -0,0 +1,31 @@
import { z } from 'zod';
/**
* Retry configuration for a DAG node step.
*
* Controls how many times a step can be re-attempted on failure,
* the delay between attempts, and which classes of errors trigger a retry.
*/
export const stepRetryConfigSchema = z.object({
/** Maximum number of retry attempts (15 inclusive). */
max_attempts: z
.number()
.int()
.min(1, 'max_attempts must be at least 1')
.max(5, 'max_attempts must be at most 5'),
/** Milliseconds to wait between retry attempts (100060000). */
delay_ms: z
.number()
.int()
.min(1000, 'delay_ms must be at least 1000')
.max(60000, 'delay_ms must be at most 60000')
.optional(),
/** Which errors trigger a retry. Defaults to 'transient'. */
on_error: z
.enum(['transient', 'all'])
.default('transient'),
});
export type StepRetryConfig = z.infer<typeof stepRetryConfigSchema>;

View File

@@ -0,0 +1,26 @@
import { z } from 'zod';
/**
* Trigger rule for a DAG node.
*
* Determines when a node should run based on the completion status of its
* dependencies. Mirrors Airflow's trigger rules.
*/
export const triggerRuleSchema = z.enum([
/** All dependencies must have completed successfully. */
'all_success',
/** At least one dependency must have completed successfully. */
'one_success',
/** All dependencies must have finished (any status). */
'all_done',
/** No dependency failed AND at least one succeeded. */
'none_failed_min_one_success',
]);
export type TriggerRule = z.infer<typeof triggerRuleSchema>;
/** All valid trigger rule values. */
export const TRIGGER_RULES: TriggerRule[] = triggerRuleSchema.options;
/** Default trigger rule used when none is specified. */
export const DEFAULT_TRIGGER_RULE: TriggerRule = 'all_success';

View File

@@ -0,0 +1,109 @@
import { z } from 'zod';
import { approvalOnRejectSchema } from './dag-node.js';
// ---------------------------------------------------------------------------
// Workflow run status
// ---------------------------------------------------------------------------
export const WorkflowRunStatusSchema = z.enum([
'pending',
'running',
'paused',
'completed',
'failed',
'cancelled',
]);
export type WorkflowRunStatus = z.infer<typeof WorkflowRunStatusSchema>;
/** Statuses that indicate the run has finished (no further transitions). */
export const TERMINAL_WORKFLOW_STATUSES = WorkflowRunStatusSchema.options.filter(
(s): s is 'completed' | 'failed' | 'cancelled' =>
s === 'completed' || s === 'failed' || s === 'cancelled',
);
/** Statuses from which a run can be resumed. */
export const RESUMABLE_WORKFLOW_STATUSES = WorkflowRunStatusSchema.options.filter(
(s): s is 'paused' | 'failed' =>
s === 'paused' || s === 'failed',
);
// ---------------------------------------------------------------------------
// Node state
// ---------------------------------------------------------------------------
export const NodeStateSchema = z.enum([
'pending',
'running',
'completed',
'failed',
'skipped',
]);
export type NodeState = z.infer<typeof NodeStateSchema>;
// NOTE: NodeOutput type is in node-output.ts — re-exported via schema/index.ts
// ---------------------------------------------------------------------------
// Approval context
// ---------------------------------------------------------------------------
export const ApprovalContextSchema = z.object({
/** Discriminator for the approval type. */
type: z.literal('approval'),
/** The node that is waiting for approval. */
nodeId: z.string().min(1, 'nodeId must not be empty'),
/** Message shown to the approver. */
message: z.string().min(1, 'approval message must not be empty'),
/** Whether the approver's response should be captured. */
capture_response: z.boolean().default(false),
/** What to do when the approval is rejected. */
on_reject: approvalOnRejectSchema.optional(),
});
export type ApprovalContext = z.infer<typeof ApprovalContextSchema>;
// ---------------------------------------------------------------------------
// Workflow run
// ---------------------------------------------------------------------------
export const WorkflowRunSchema = z.object({
/** Unique run identifier. */
id: z.string().min(1, 'run id must not be empty'),
/** Name of the workflow being run. */
workflow_name: z.string().min(1, 'workflow_name must not be empty'),
/** Current status of the run. */
status: WorkflowRunStatusSchema,
/** Conversation this run belongs to. */
conversation_id: z.string().min(1, 'conversation_id must not be empty'),
/** Codebase this run operates on. */
codebase_id: z.string().min(1, 'codebase_id must not be empty'),
/** The user message that triggered this run. */
user_message: z.string().optional(),
/** Working directory path for the run. */
working_path: z.string().optional(),
/** Timestamp when the run was created. */
created_at: z.string().datetime(),
/** Timestamp when the run started executing. */
started_at: z.string().datetime().optional(),
/** Arbitrary metadata attached to the run. */
metadata: z.record(z.string(), z.unknown()).default({}),
/** Parent conversation ID for nested runs. */
parent_conversation_id: z.string().optional(),
});
export type WorkflowRun = z.infer<typeof WorkflowRunSchema>;

View File

@@ -0,0 +1,246 @@
import { z } from 'zod';
import { dagNodeSchema, effortLevelSchema, thinkingConfigSchema } from './dag-node.js';
// ---------------------------------------------------------------------------
// Model reasoning effort
// ---------------------------------------------------------------------------
export const modelReasoningEffortSchema = z.enum(['low', 'medium', 'high']);
export type ModelReasoningEffort = z.infer<typeof modelReasoningEffortSchema>;
// ---------------------------------------------------------------------------
// Web search mode
// ---------------------------------------------------------------------------
export const webSearchModeSchema = z.enum(['off', 'auto', 'on']);
export type WebSearchMode = z.infer<typeof webSearchModeSchema>;
// ---------------------------------------------------------------------------
// Workflow requirement
// ---------------------------------------------------------------------------
export const workflowRequirementSchema = z.object({
/** Human-readable name of the requirement. */
name: z.string().min(1, 'requirement name must not be empty'),
/** Short description of what the requirement enforces. */
description: z.string().optional(),
});
export type WorkflowRequirement = z.infer<typeof workflowRequirementSchema>;
// ---------------------------------------------------------------------------
// Worktree policy
// ---------------------------------------------------------------------------
export const workflowWorktreePolicySchema = z.object({
/** Whether worktree isolation is enabled for this workflow. */
enabled: z.boolean().optional(),
});
export type WorkflowWorktreePolicy = z.infer<typeof workflowWorktreePolicySchema>;
// ---------------------------------------------------------------------------
// Sandbox config
// ---------------------------------------------------------------------------
export const sandboxConfigSchema = z.object({
/** Whether sandboxing is enabled. */
enabled: z.boolean().default(false),
/** Docker image to use for sandboxing. */
image: z.string().optional(),
/** Memory limit in megabytes. */
memory_mb: z.number().int().positive().optional(),
/** CPU limit in cores. */
cpu_cores: z.number().positive().optional(),
/** Network access inside the sandbox. */
network: z.boolean().default(false),
});
export type SandboxConfig = z.infer<typeof sandboxConfigSchema>;
// ---------------------------------------------------------------------------
// Provider overrides
// ---------------------------------------------------------------------------
export const providerOverridesSchema = z.record(
z.string(),
z.object({
model: z.string().optional(),
base_url: z.string().optional(),
api_key_env: z.string().optional(),
}),
);
export type ProviderOverrides = z.infer<typeof providerOverridesSchema>;
// ---------------------------------------------------------------------------
// Workflow base schema (shared between definition and metadata)
// ---------------------------------------------------------------------------
export const workflowBaseSchema = z.object({
/** Human-readable workflow name. */
name: z.string().min(1, 'workflow name must not be empty'),
/** Short description of what the workflow does. */
description: z.string().optional(),
/** Default provider for all nodes. */
provider: z.string().optional(),
/** Default model for all nodes. */
model: z.string().optional(),
/** Reasoning effort level for the model. */
modelReasoningEffort: modelReasoningEffortSchema.optional(),
/** Web search mode for the model. */
webSearchMode: webSearchModeSchema.optional(),
/** Whether the workflow can pause for human input. */
interactive: z.boolean().default(false),
/** Default effort level for all nodes. */
effort: effortLevelSchema.optional(),
/** Default thinking configuration for all nodes. */
thinking: thinkingConfigSchema.optional(),
/** Fallback model when the primary model is unavailable. */
fallbackModel: z.string().optional(),
/** Tags for categorisation and filtering. */
tags: z.array(z.string()).default([]),
/** Requirements that must be met before the workflow can run. */
requires: z.array(workflowRequirementSchema).default([]),
/** Whether this workflow modifies the git checkout. */
mutates_checkout: z.boolean().default(false),
/** Whether to persist conversation sessions between runs. */
persist_sessions: z.boolean().default(false),
/** Per-provider configuration overrides. */
provider_overrides: providerOverridesSchema.optional(),
/** Sandbox configuration for the workflow. */
sandbox: sandboxConfigSchema.optional(),
/** Worktree policy for the workflow. */
worktree: workflowWorktreePolicySchema.optional(),
});
export type WorkflowBase = z.infer<typeof workflowBaseSchema>;
// ---------------------------------------------------------------------------
// Full workflow definition (base + nodes)
// ---------------------------------------------------------------------------
export const workflowDefinitionSchema = workflowBaseSchema.extend({
/** The DAG nodes that make up this workflow. */
nodes: z.array(dagNodeSchema),
});
export type WorkflowDefinition = z.infer<typeof workflowDefinitionSchema>;
// ---------------------------------------------------------------------------
// Workflow source
// ---------------------------------------------------------------------------
export const WorkflowSourceSchema = z.enum(['bundled', 'global', 'project']);
export type WorkflowSource = z.infer<typeof WorkflowSourceSchema>;
// ---------------------------------------------------------------------------
// Workflow execution result
// ---------------------------------------------------------------------------
export const workflowExecutionResultSchema = z.discriminatedUnion('status', [
z.object({
status: z.literal('success'),
output: z.string(),
metadata: z.record(z.string(), z.unknown()).optional(),
}),
z.object({
status: z.literal('fail'),
error: z.string(),
node_id: z.string().optional(),
metadata: z.record(z.string(), z.unknown()).optional(),
}),
z.object({
status: z.literal('paused'),
node_id: z.string(),
reason: z.string(),
metadata: z.record(z.string(), z.unknown()).optional(),
}),
]);
export type WorkflowExecutionResult = z.infer<typeof workflowExecutionResultSchema>;
// ---------------------------------------------------------------------------
// Workflow with source metadata
// ---------------------------------------------------------------------------
export const workflowWithSourceSchema = z.object({
definition: workflowDefinitionSchema,
source: WorkflowSourceSchema,
path: z.string().optional(),
});
export type WorkflowWithSource = z.infer<typeof workflowWithSourceSchema>;
// ---------------------------------------------------------------------------
// Workflow load error
// ---------------------------------------------------------------------------
export const workflowLoadErrorSchema = z.object({
message: z.string(),
path: z.string().optional(),
cause: z.unknown().optional(),
});
export type WorkflowLoadError = z.infer<typeof workflowLoadErrorSchema>;
// ---------------------------------------------------------------------------
// Workflow load result (success or error)
// ---------------------------------------------------------------------------
export const workflowLoadResultSchema = z.union([
workflowWithSourceSchema,
workflowLoadErrorSchema,
]);
export type WorkflowLoadResult = z.infer<typeof workflowLoadResultSchema>;
// ---------------------------------------------------------------------------
// Load command result
// ---------------------------------------------------------------------------
export const loadCommandResultSchema = z.discriminatedUnion('status', [
z.object({
status: z.literal('success'),
definition: workflowDefinitionSchema,
source: WorkflowSourceSchema,
path: z.string().optional(),
}),
z.object({
status: z.literal('fail'),
reason: z.enum([
'not_found',
'parse_error',
'validation_error',
'permission_denied',
]),
message: z.string(),
path: z.string().optional(),
}),
]);
export type LoadCommandResult = z.infer<typeof loadCommandResultSchema>;

View File

@@ -0,0 +1,249 @@
/**
* Filesystem-backed workflow store.
*
* Stores each run as `{basePath}/{runId}/run.json` and
* `{basePath}/{runId}/events.jsonl`. Thread-safe writes use atomic
* rename (write to temp file, then rename).
*/
import { mkdir, writeFile, readFile, readdir, rename, unlink } from 'node:fs/promises';
import { existsSync } from 'node:fs';
import { join } from 'node:path';
import { nanoid } from 'nanoid';
import type {
IWorkflowStore,
WorkflowRun,
WorkflowEvent,
WorkflowRunStatus,
CreateWorkflowRunData,
} from '../engine/deps.js';
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
const ACTIVE_STATUSES: WorkflowRunStatus[] = ['pending', 'running'];
function parseRun(raw: string): WorkflowRun {
const obj = JSON.parse(raw);
return {
...obj,
createdAt: new Date(obj.createdAt),
updatedAt: new Date(obj.updatedAt),
};
}
function serializeRun(run: WorkflowRun): string {
return JSON.stringify(
{
...run,
createdAt: run.createdAt.toISOString(),
updatedAt: run.updatedAt.toISOString(),
},
null,
2,
);
}
function parseEvent(line: string): WorkflowEvent {
const obj = JSON.parse(line);
return {
...obj,
createdAt: new Date(obj.createdAt),
};
}
function serializeEvent(event: WorkflowEvent): string {
return JSON.stringify({
...event,
createdAt: event.createdAt.toISOString(),
});
}
// ---------------------------------------------------------------------------
// Atomic write helper
// ---------------------------------------------------------------------------
async function atomicWrite(filePath: string, data: string): Promise<void> {
const tmp = `${filePath}.${nanoid(8)}.tmp`;
await writeFile(tmp, data, 'utf-8');
await rename(tmp, filePath);
}
// ---------------------------------------------------------------------------
// Factory
// ---------------------------------------------------------------------------
export function createFsStore(basePath: string): IWorkflowStore {
// Ensure base directory exists on first write — no side effects at import.
const store: IWorkflowStore = {
// -- Run lifecycle -------------------------------------------------------
async createWorkflowRun(data: CreateWorkflowRunData): Promise<WorkflowRun> {
const id = nanoid();
const now = new Date();
const run: WorkflowRun = {
id,
workflowPath: data.workflowPath,
workflowName: data.workflowName,
status: 'pending',
trigger: data.trigger,
input: data.input,
createdAt: now,
updatedAt: now,
};
const runDir = join(basePath, id);
await mkdir(runDir, { recursive: true });
await atomicWrite(join(runDir, 'run.json'), serializeRun(run));
// Create empty events file
await atomicWrite(join(runDir, 'events.jsonl'), '');
return run;
},
async getWorkflowRun(id: string): Promise<WorkflowRun | null> {
const filePath = join(basePath, id, 'run.json');
if (!existsSync(filePath)) return null;
const raw = await readFile(filePath, 'utf-8');
return parseRun(raw);
},
async updateWorkflowRun(
id: string,
data: Partial<WorkflowRun>,
): Promise<WorkflowRun> {
const existing = await store.getWorkflowRun(id);
if (!existing) throw new Error(`WorkflowRun not found: ${id}`);
const updated: WorkflowRun = {
...existing,
...data,
id: existing.id, // id is immutable
updatedAt: new Date(),
};
await atomicWrite(
join(basePath, id, 'run.json'),
serializeRun(updated),
);
return updated;
},
async failWorkflowRun(id: string, error: string): Promise<WorkflowRun> {
return store.updateWorkflowRun(id, {
status: 'failed',
error,
} as Partial<WorkflowRun>);
},
async getWorkflowRunStatus(
id: string,
): Promise<WorkflowRunStatus | null> {
const run = await store.getWorkflowRun(id);
return run?.status ?? null;
},
// -- Events --------------------------------------------------------------
async createWorkflowEvent(
event: Omit<WorkflowEvent, 'id' | 'createdAt'>,
): Promise<WorkflowEvent> {
const full: WorkflowEvent = {
...event,
id: nanoid(),
createdAt: new Date(),
};
const eventsPath = join(basePath, event.runId, 'events.jsonl');
// Ensure directory exists
if (!existsSync(join(basePath, event.runId))) {
await mkdir(join(basePath, event.runId), { recursive: true });
}
const line = serializeEvent(full) + '\n';
// Append — not atomic for appends, but each line is self-contained
const existing = existsSync(eventsPath)
? await readFile(eventsPath, 'utf-8').catch(() => '')
: '';
await atomicWrite(eventsPath, existing + line);
return full;
},
async getCompletedDagNodeOutputs(
runId: string,
): Promise<Record<string, Record<string, unknown>>> {
const eventsPath = join(basePath, runId, 'events.jsonl');
if (!existsSync(eventsPath)) return {};
const raw = await readFile(eventsPath, 'utf-8');
const lines = raw.split('\n').filter(Boolean);
const outputs: Record<string, Record<string, unknown>> = {};
for (const line of lines) {
const event = parseEvent(line);
if (event.type === 'node_complete' && event.nodeId && event.data?.output) {
outputs[event.nodeId] = event.data.output as Record<string, unknown>;
}
}
return outputs;
},
// -- Active runs ---------------------------------------------------------
async getActiveWorkflowRunByPath(
path: string,
opts?: { excludeId?: string },
): Promise<WorkflowRun | null> {
if (!existsSync(basePath)) return null;
const entries = await readdir(basePath, { withFileTypes: true });
for (const entry of entries) {
if (!entry.isDirectory()) continue;
const runPath = join(basePath, entry.name, 'run.json');
if (!existsSync(runPath)) continue;
const raw = await readFile(runPath, 'utf-8');
const run = parseRun(raw);
if (run.workflowPath !== path) continue;
if (!ACTIVE_STATUSES.includes(run.status)) continue;
if (opts?.excludeId && run.id === opts.excludeId) continue;
return run;
}
return null;
},
// -- Codebase ------------------------------------------------------------
async getCodebase(
_id: string,
): Promise<Record<string, unknown> | null> {
// Filesystem store does not persist codebase records
return null;
},
async getCodebaseEnvVars(
_id: string,
): Promise<Record<string, string>> {
// Filesystem store does not persist codebase env vars
return {};
},
// -- Resumption ----------------------------------------------------------
async resumeWorkflowRun(id: string): Promise<WorkflowRun> {
return store.updateWorkflowRun(id, {
status: 'running',
} as Partial<WorkflowRun>);
},
};
return store;
}

View File

@@ -0,0 +1,22 @@
/**
* Store module — persistence backends for the Ion workflow engine.
*
* Provides three store implementations:
* - `createFsStore` — filesystem-backed (JSON files + JSONL events)
* - `createSqliteStore` — SQLite-backed (better-sqlite3, optional dep)
* - `createPostgresStore` — Postgres-backed (postgres.js, optional dep)
*
* All implement the `IWorkflowStore` interface from `../engine/deps.js`.
*/
export { createFsStore } from './fs-store.js';
export { createSqliteStore } from './sqlite-store.js';
export { createPostgresStore } from './pg-store.js';
export type {
IWorkflowStore,
WorkflowRun,
WorkflowEvent,
WorkflowRunStatus,
CreateWorkflowRunData,
} from './types.js';

View File

@@ -0,0 +1,46 @@
/**
* Type declarations for optional store dependencies.
*
* These modules are optional — they may not be installed.
* We declare just enough types here for the store implementations to compile
* without requiring the actual packages.
*/
declare module 'better-sqlite3' {
interface RunResult {
changes: number;
lastInsertRowid: number | bigint;
}
interface Statement {
run(...params: unknown[]): RunResult;
get(...params: unknown[]): unknown;
all(...params: unknown[]): unknown[];
}
class Database {
constructor(filename: string, options?: unknown);
prepare(sql: string): Statement;
exec(sql: string): Database;
pragma(pragma: string): unknown;
close(): void;
}
export default Database;
}
declare module 'postgres' {
interface Sql {
<T = unknown>(
strings: TemplateStringsArray,
...values: unknown[]
): Promise<T[]>;
unsafe(sql: string, params?: unknown[]): Promise<unknown[]>;
json(data: unknown): unknown;
end(): Promise<void>;
}
function postgres(connectionString: string): Sql;
export default postgres;
}

View File

@@ -0,0 +1,309 @@
/**
* Postgres-backed workflow store.
*
* Uses postgres.js for async, connection-pooled persistence.
* The dependency is optional — a helpful error is thrown if not installed.
*/
import { nanoid } from 'nanoid';
import type {
IWorkflowStore,
WorkflowRun,
WorkflowEvent,
WorkflowRunStatus,
CreateWorkflowRunData,
} from '../engine/deps.js';
// ---------------------------------------------------------------------------
// Optional dependency loading
// ---------------------------------------------------------------------------
async function loadPostgres(): Promise<typeof import('postgres')> {
try {
return await import('postgres');
} catch {
throw new Error(
'postgres is not installed. Install it with: npm install postgres',
);
}
}
// ---------------------------------------------------------------------------
// Schema
// ---------------------------------------------------------------------------
const SCHEMA_SQL = `
CREATE TABLE IF NOT EXISTS workflow_runs (
id TEXT PRIMARY KEY,
workflow_path TEXT NOT NULL,
workflow_name TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'pending',
trigger TEXT NOT NULL,
input JSONB NOT NULL DEFAULT '{}',
output JSONB,
error TEXT,
created_at TIMESTAMPTZ NOT NULL,
updated_at TIMESTAMPTZ NOT NULL
);
CREATE TABLE IF NOT EXISTS workflow_events (
id TEXT PRIMARY KEY,
run_id TEXT NOT NULL REFERENCES workflow_runs(id),
node_id TEXT,
type TEXT NOT NULL,
data JSONB NOT NULL DEFAULT '{}',
created_at TIMESTAMPTZ NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_workflow_runs_path_status
ON workflow_runs(workflow_path, status);
CREATE INDEX IF NOT EXISTS idx_workflow_events_run_id
ON workflow_events(run_id);
`;
// ---------------------------------------------------------------------------
// Row mappers
// ---------------------------------------------------------------------------
interface RunRow {
id: string;
workflow_path: string;
workflow_name: string;
status: string;
trigger: string;
input: unknown;
output: unknown;
error: string | null;
created_at: Date | string;
updated_at: Date | string;
}
interface EventRow {
id: string;
run_id: string;
node_id: string | null;
type: string;
data: unknown;
created_at: Date | string;
}
function rowToRun(row: RunRow): WorkflowRun {
return {
id: row.id,
workflowPath: row.workflow_path,
workflowName: row.workflow_name,
status: row.status as WorkflowRunStatus,
trigger: row.trigger,
input: (row.input ?? {}) as Record<string, unknown>,
output: row.output
? (row.output as Record<string, unknown>)
: undefined,
error: row.error ?? undefined,
createdAt: new Date(row.created_at),
updatedAt: new Date(row.updated_at),
};
}
function rowToEvent(row: EventRow): WorkflowEvent {
return {
id: row.id,
runId: row.run_id,
nodeId: row.node_id ?? undefined,
type: row.type,
data: (row.data ?? {}) as Record<string, unknown>,
createdAt: new Date(row.created_at),
};
}
// ---------------------------------------------------------------------------
// Factory
// ---------------------------------------------------------------------------
export async function createPostgresStore(
connectionString: string,
): Promise<IWorkflowStore> {
const mod = await loadPostgres();
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const sql: any = mod.default
? mod.default(connectionString)
: (mod as any)(connectionString);
// Initialize schema
await sql.unsafe(SCHEMA_SQL);
const ACTIVE_STATUSES: WorkflowRunStatus[] = ['pending', 'running'];
const store: IWorkflowStore = {
// -- Run lifecycle -------------------------------------------------------
async createWorkflowRun(data: CreateWorkflowRunData): Promise<WorkflowRun> {
const id = nanoid();
const now = new Date();
const rows = await sql`
INSERT INTO workflow_runs (id, workflow_path, workflow_name, status, trigger, input, created_at, updated_at)
VALUES (${id}, ${data.workflowPath}, ${data.workflowName}, 'pending', ${data.trigger}, ${sql.json(data.input)}, ${now.toISOString()}, ${now.toISOString()})
RETURNING *
`;
return rowToRun(rows[0] as RunRow);
},
async getWorkflowRun(id: string): Promise<WorkflowRun | null> {
const rows = await sql`
SELECT * FROM workflow_runs WHERE id = ${id}
`;
if (rows.length === 0) return null;
return rowToRun(rows[0] as RunRow);
},
async updateWorkflowRun(
id: string,
data: Partial<WorkflowRun>,
): Promise<WorkflowRun> {
const existing = await store.getWorkflowRun(id);
if (!existing) throw new Error(`WorkflowRun not found: ${id}`);
const now = new Date().toISOString();
const sets: string[] = ['updated_at = $1'];
const values: unknown[] = [now];
let paramIdx = 2;
if (data.status !== undefined) {
sets.push(`status = $${paramIdx++}`);
values.push(data.status);
}
if (data.output !== undefined) {
sets.push(`output = $${paramIdx++}`);
values.push(JSON.stringify(data.output));
}
if (data.error !== undefined) {
sets.push(`error = $${paramIdx++}`);
values.push(data.error);
}
if (data.workflowPath !== undefined) {
sets.push(`workflow_path = $${paramIdx++}`);
values.push(data.workflowPath);
}
if (data.workflowName !== undefined) {
sets.push(`workflow_name = $${paramIdx++}`);
values.push(data.workflowName);
}
if (data.trigger !== undefined) {
sets.push(`trigger = $${paramIdx++}`);
values.push(data.trigger);
}
if (data.input !== undefined) {
sets.push(`input = $${paramIdx++}`);
values.push(JSON.stringify(data.input));
}
values.push(id);
const query = `UPDATE workflow_runs SET ${sets.join(', ')} WHERE id = $${paramIdx}`;
await sql.unsafe(query, values);
const updated = await store.getWorkflowRun(id);
return updated!;
},
async failWorkflowRun(id: string, error: string): Promise<WorkflowRun> {
return store.updateWorkflowRun(id, {
status: 'failed',
error,
} as Partial<WorkflowRun>);
},
async getWorkflowRunStatus(
id: string,
): Promise<WorkflowRunStatus | null> {
const rows = await sql`
SELECT status FROM workflow_runs WHERE id = ${id}
`;
if (rows.length === 0) return null;
return (rows[0] as { status: string }).status as WorkflowRunStatus;
},
// -- Events --------------------------------------------------------------
async createWorkflowEvent(
event: Omit<WorkflowEvent, 'id' | 'createdAt'>,
): Promise<WorkflowEvent> {
const id = nanoid();
const now = new Date();
const rows = await sql`
INSERT INTO workflow_events (id, run_id, node_id, type, data, created_at)
VALUES (${id}, ${event.runId}, ${event.nodeId ?? null}, ${event.type}, ${sql.json(event.data)}, ${now.toISOString()})
RETURNING *
`;
return rowToEvent(rows[0] as EventRow);
},
async getCompletedDagNodeOutputs(
runId: string,
): Promise<Record<string, Record<string, unknown>>> {
const rows = await sql`
SELECT node_id, data FROM workflow_events
WHERE run_id = ${runId} AND type = 'node_complete' AND node_id IS NOT NULL
`;
const outputs: Record<string, Record<string, unknown>> = {};
for (const row of rows) {
const r = row as { node_id: string; data: unknown };
const parsed = (r.data ?? {}) as Record<string, unknown>;
if (parsed.output) {
outputs[r.node_id] = parsed.output as Record<string, unknown>;
}
}
return outputs;
},
// -- Active runs ---------------------------------------------------------
async getActiveWorkflowRunByPath(
path: string,
opts?: { excludeId?: string },
): Promise<WorkflowRun | null> {
const excludeId = opts?.excludeId;
const rows = await sql`
SELECT * FROM workflow_runs
WHERE workflow_path = ${path}
AND status IN ${sql(ACTIVE_STATUSES)}
${excludeId ? sql`AND id != ${excludeId}` : sql``}
LIMIT 1
`;
if (rows.length === 0) return null;
return rowToRun(rows[0] as RunRow);
},
// -- Codebase ------------------------------------------------------------
async getCodebase(
_id: string,
): Promise<Record<string, unknown> | null> {
return null;
},
async getCodebaseEnvVars(
_id: string,
): Promise<Record<string, string>> {
return {};
},
// -- Resumption ----------------------------------------------------------
async resumeWorkflowRun(id: string): Promise<WorkflowRun> {
return store.updateWorkflowRun(id, {
status: 'running',
} as Partial<WorkflowRun>);
},
};
return store;
}

View File

@@ -0,0 +1,336 @@
/**
* SQLite-backed workflow store.
*
* Uses better-sqlite3 for synchronous, file-based persistence.
* The dependency is optional — a helpful error is thrown if not installed.
*/
import { nanoid } from 'nanoid';
import type {
IWorkflowStore,
WorkflowRun,
WorkflowEvent,
WorkflowRunStatus,
CreateWorkflowRunData,
} from '../engine/deps.js';
// ---------------------------------------------------------------------------
// Optional dependency loading
// ---------------------------------------------------------------------------
async function loadBetterSqlite3(): Promise<typeof import('better-sqlite3')> {
try {
return await import('better-sqlite3');
} catch {
throw new Error(
'better-sqlite3 is not installed. Install it with: npm install better-sqlite3',
);
}
}
// ---------------------------------------------------------------------------
// Schema
// ---------------------------------------------------------------------------
const SCHEMA_SQL = `
CREATE TABLE IF NOT EXISTS workflow_runs (
id TEXT PRIMARY KEY,
workflow_path TEXT NOT NULL,
workflow_name TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'pending',
trigger TEXT NOT NULL,
input TEXT NOT NULL DEFAULT '{}',
output TEXT,
error TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS workflow_events (
id TEXT PRIMARY KEY,
run_id TEXT NOT NULL,
node_id TEXT,
type TEXT NOT NULL,
data TEXT NOT NULL DEFAULT '{}',
created_at TEXT NOT NULL,
FOREIGN KEY (run_id) REFERENCES workflow_runs(id)
);
CREATE INDEX IF NOT EXISTS idx_workflow_runs_path_status
ON workflow_runs(workflow_path, status);
CREATE INDEX IF NOT EXISTS idx_workflow_events_run_id
ON workflow_events(run_id);
`;
// ---------------------------------------------------------------------------
// Row mappers
// ---------------------------------------------------------------------------
interface RunRow {
id: string;
workflow_path: string;
workflow_name: string;
status: string;
trigger: string;
input: string;
output: string | null;
error: string | null;
created_at: string;
updated_at: string;
}
interface EventRow {
id: string;
run_id: string;
node_id: string | null;
type: string;
data: string;
created_at: string;
}
function rowToRun(row: RunRow): WorkflowRun {
return {
id: row.id,
workflowPath: row.workflow_path,
workflowName: row.workflow_name,
status: row.status as WorkflowRunStatus,
trigger: row.trigger,
input: JSON.parse(row.input),
output: row.output ? JSON.parse(row.output) : undefined,
error: row.error ?? undefined,
createdAt: new Date(row.created_at),
updatedAt: new Date(row.updated_at),
};
}
function rowToEvent(row: EventRow): WorkflowEvent {
return {
id: row.id,
runId: row.run_id,
nodeId: row.node_id ?? undefined,
type: row.type,
data: JSON.parse(row.data),
createdAt: new Date(row.created_at),
};
}
// ---------------------------------------------------------------------------
// Factory
// ---------------------------------------------------------------------------
export async function createSqliteStore(
dbPath: string,
): Promise<IWorkflowStore> {
const mod = await loadBetterSqlite3();
const DatabaseCtor = mod.default ?? mod;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const db: any = new DatabaseCtor(dbPath);
// Enable WAL mode for better concurrent read performance
db.pragma('journal_mode = WAL');
// Initialize schema
db.exec(SCHEMA_SQL);
const ACTIVE_STATUSES: WorkflowRunStatus[] = ['pending', 'running'];
const store: IWorkflowStore = {
// -- Run lifecycle -------------------------------------------------------
createWorkflowRun(data: CreateWorkflowRunData): Promise<WorkflowRun> {
const id = nanoid();
const now = new Date().toISOString();
const inputJson = JSON.stringify(data.input);
db.prepare(
`INSERT INTO workflow_runs (id, workflow_path, workflow_name, status, trigger, input, created_at, updated_at)
VALUES (?, ?, ?, 'pending', ?, ?, ?, ?)`,
).run(
id,
data.workflowPath,
data.workflowName,
data.trigger,
inputJson,
now,
now,
);
return Promise.resolve({
id,
workflowPath: data.workflowPath,
workflowName: data.workflowName,
status: 'pending',
trigger: data.trigger,
input: data.input,
createdAt: new Date(now),
updatedAt: new Date(now),
});
},
getWorkflowRun(id: string): Promise<WorkflowRun | null> {
const row = db
.prepare('SELECT * FROM workflow_runs WHERE id = ?')
.get(id) as RunRow | undefined;
if (!row) return Promise.resolve(null);
return Promise.resolve(rowToRun(row));
},
updateWorkflowRun(
id: string,
data: Partial<WorkflowRun>,
): Promise<WorkflowRun> {
const existing = db
.prepare('SELECT * FROM workflow_runs WHERE id = ?')
.get(id) as RunRow | undefined;
if (!existing) throw new Error(`WorkflowRun not found: ${id}`);
const now = new Date().toISOString();
const sets: string[] = ['updated_at = ?'];
const values: unknown[] = [now];
if (data.status !== undefined) {
sets.push('status = ?');
values.push(data.status);
}
if (data.output !== undefined) {
sets.push('output = ?');
values.push(JSON.stringify(data.output));
}
if (data.error !== undefined) {
sets.push('error = ?');
values.push(data.error);
}
if (data.workflowPath !== undefined) {
sets.push('workflow_path = ?');
values.push(data.workflowPath);
}
if (data.workflowName !== undefined) {
sets.push('workflow_name = ?');
values.push(data.workflowName);
}
if (data.trigger !== undefined) {
sets.push('trigger = ?');
values.push(data.trigger);
}
if (data.input !== undefined) {
sets.push('input = ?');
values.push(JSON.stringify(data.input));
}
values.push(id);
db.prepare(
`UPDATE workflow_runs SET ${sets.join(', ')} WHERE id = ?`,
).run(...values);
const updated = db
.prepare('SELECT * FROM workflow_runs WHERE id = ?')
.get(id) as RunRow;
return Promise.resolve(rowToRun(updated));
},
failWorkflowRun(id: string, error: string): Promise<WorkflowRun> {
return store.updateWorkflowRun(id, {
status: 'failed',
error,
} as Partial<WorkflowRun>);
},
getWorkflowRunStatus(id: string): Promise<WorkflowRunStatus | null> {
const row = db
.prepare('SELECT status FROM workflow_runs WHERE id = ?')
.get(id) as { status: string } | undefined;
if (!row) return Promise.resolve(null);
return Promise.resolve(row.status as WorkflowRunStatus);
},
// -- Events --------------------------------------------------------------
createWorkflowEvent(
event: Omit<WorkflowEvent, 'id' | 'createdAt'>,
): Promise<WorkflowEvent> {
const id = nanoid();
const now = new Date().toISOString();
const dataJson = JSON.stringify(event.data);
db.prepare(
`INSERT INTO workflow_events (id, run_id, node_id, type, data, created_at)
VALUES (?, ?, ?, ?, ?, ?)`,
).run(id, event.runId, event.nodeId ?? null, event.type, dataJson, now);
return Promise.resolve({
id,
runId: event.runId,
nodeId: event.nodeId,
type: event.type,
data: event.data,
createdAt: new Date(now),
});
},
getCompletedDagNodeOutputs(
runId: string,
): Promise<Record<string, Record<string, unknown>>> {
const rows = db
.prepare(
`SELECT node_id, data FROM workflow_events
WHERE run_id = ? AND type = 'node_complete' AND node_id IS NOT NULL`,
)
.all(runId) as { node_id: string; data: string }[];
const outputs: Record<string, Record<string, unknown>> = {};
for (const row of rows) {
const parsed = JSON.parse(row.data);
if (parsed.output) {
outputs[row.node_id] = parsed.output as Record<string, unknown>;
}
}
return Promise.resolve(outputs);
},
// -- Active runs ---------------------------------------------------------
getActiveWorkflowRunByPath(
path: string,
opts?: { excludeId?: string },
): Promise<WorkflowRun | null> {
const statuses = ACTIVE_STATUSES;
const placeholders = statuses.map(() => '?').join(', ');
let query = `SELECT * FROM workflow_runs WHERE workflow_path = ? AND status IN (${placeholders})`;
const params: unknown[] = [path, ...statuses];
if (opts?.excludeId) {
query += ' AND id != ?';
params.push(opts.excludeId);
}
query += ' LIMIT 1';
const row = db.prepare(query).get(...params) as RunRow | undefined;
if (!row) return Promise.resolve(null);
return Promise.resolve(rowToRun(row));
},
// -- Codebase ------------------------------------------------------------
getCodebase(_id: string): Promise<Record<string, unknown> | null> {
return Promise.resolve(null);
},
getCodebaseEnvVars(_id: string): Promise<Record<string, string>> {
return Promise.resolve({});
},
// -- Resumption ----------------------------------------------------------
resumeWorkflowRun(id: string): Promise<WorkflowRun> {
return store.updateWorkflowRun(id, {
status: 'running',
} as Partial<WorkflowRun>);
},
};
return store;
}

View File

@@ -0,0 +1,14 @@
/**
* Store type re-exports.
*
* Re-exports the workflow store types from the engine dependency interfaces
* so consumers can import them from the store module directly.
*/
export type {
IWorkflowStore,
WorkflowRun,
WorkflowEvent,
WorkflowRunStatus,
CreateWorkflowRunData,
} from '../engine/deps.js';