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:
55
packages/ion/src/cli/commands/abandon.ts
Normal file
55
packages/ion/src/cli/commands/abandon.ts
Normal 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}`);
|
||||
}
|
||||
}
|
||||
60
packages/ion/src/cli/commands/approve.ts
Normal file
60
packages/ion/src/cli/commands/approve.ts
Normal 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}`);
|
||||
}
|
||||
}
|
||||
74
packages/ion/src/cli/commands/cleanup.ts
Normal file
74
packages/ion/src/cli/commands/cleanup.ts
Normal 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]}`;
|
||||
}
|
||||
62
packages/ion/src/cli/commands/convert.ts
Normal file
62
packages/ion/src/cli/commands/convert.ts
Normal 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}`);
|
||||
}
|
||||
59
packages/ion/src/cli/commands/list.ts
Normal file
59
packages/ion/src/cli/commands/list.ts
Normal 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 },
|
||||
],
|
||||
);
|
||||
}
|
||||
62
packages/ion/src/cli/commands/reject.ts
Normal file
62
packages/ion/src/cli/commands/reject.ts
Normal 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}`);
|
||||
}
|
||||
}
|
||||
55
packages/ion/src/cli/commands/resume.ts
Normal file
55
packages/ion/src/cli/commands/resume.ts
Normal 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}`);
|
||||
}
|
||||
}
|
||||
94
packages/ion/src/cli/commands/run.ts
Normal file
94
packages/ion/src/cli/commands/run.ts
Normal 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}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
91
packages/ion/src/cli/commands/runs.ts
Normal file
91
packages/ion/src/cli/commands/runs.ts
Normal 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 },
|
||||
],
|
||||
);
|
||||
}
|
||||
67
packages/ion/src/cli/commands/status.ts
Normal file
67
packages/ion/src/cli/commands/status.ts
Normal 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 },
|
||||
],
|
||||
);
|
||||
}
|
||||
66
packages/ion/src/cli/commands/validate.ts
Normal file
66
packages/ion/src/cli/commands/validate.ts
Normal 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}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
207
packages/ion/src/cli/index.ts
Normal file
207
packages/ion/src/cli/index.ts
Normal 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);
|
||||
});
|
||||
}
|
||||
239
packages/ion/src/cli/utils.ts
Normal file
239
packages/ion/src/cli/utils.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
70
packages/ion/src/engine/__tests__/command-validation.test.ts
Normal file
70
packages/ion/src/engine/__tests__/command-validation.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
149
packages/ion/src/engine/__tests__/condition-evaluator.test.ts
Normal file
149
packages/ion/src/engine/__tests__/condition-evaluator.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
132
packages/ion/src/engine/__tests__/output-ref.test.ts
Normal file
132
packages/ion/src/engine/__tests__/output-ref.test.ts
Normal 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());
|
||||
});
|
||||
});
|
||||
27
packages/ion/src/engine/command-validation.ts
Normal file
27
packages/ion/src/engine/command-validation.ts
Normal 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);
|
||||
}
|
||||
427
packages/ion/src/engine/condition-evaluator.ts
Normal file
427
packages/ion/src/engine/condition-evaluator.ts
Normal 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)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
1149
packages/ion/src/engine/dag-executor.ts
Normal file
1149
packages/ion/src/engine/dag-executor.ts
Normal file
File diff suppressed because it is too large
Load Diff
199
packages/ion/src/engine/deps.ts
Normal file
199
packages/ion/src/engine/deps.ts
Normal 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;
|
||||
}
|
||||
214
packages/ion/src/engine/event-emitter.ts
Normal file
214
packages/ion/src/engine/event-emitter.ts
Normal 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;
|
||||
}
|
||||
251
packages/ion/src/engine/executor-shared.ts
Normal file
251
packages/ion/src/engine/executor-shared.ts
Normal 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';
|
||||
}
|
||||
373
packages/ion/src/engine/executor.ts
Normal file
373
packages/ion/src/engine/executor.ts
Normal 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(),
|
||||
};
|
||||
}
|
||||
63
packages/ion/src/engine/index.ts
Normal file
63
packages/ion/src/engine/index.ts
Normal 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';
|
||||
228
packages/ion/src/engine/model-validation.ts
Normal file
228
packages/ion/src/engine/model-validation.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
122
packages/ion/src/engine/output-ref.ts
Normal file
122
packages/ion/src/engine/output-ref.ts
Normal 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)'}`,
|
||||
);
|
||||
}
|
||||
372
packages/ion/src/engine/utils.ts
Normal file
372
packages/ion/src/engine/utils.ts
Normal 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;
|
||||
}
|
||||
22
packages/ion/src/format/index.ts
Normal file
22
packages/ion/src/format/index.ts
Normal 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';
|
||||
78
packages/ion/src/format/sop-discovery.ts
Normal file
78
packages/ion/src/format/sop-discovery.ts
Normal 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;
|
||||
}
|
||||
203
packages/ion/src/format/sop-parser.ts
Normal file
203
packages/ion/src/format/sop-parser.ts
Normal 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 } : {}),
|
||||
};
|
||||
}
|
||||
102
packages/ion/src/format/sop-to-yaml.ts
Normal file
102
packages/ion/src/format/sop-to-yaml.ts
Normal 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
152
packages/ion/src/index.ts
Normal 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';
|
||||
377
packages/ion/src/schema/__tests__/dag-node.test.ts
Normal file
377
packages/ion/src/schema/__tests__/dag-node.test.ts
Normal 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([]);
|
||||
});
|
||||
});
|
||||
273
packages/ion/src/schema/dag-node.ts
Normal file
273
packages/ion/src/schema/dag-node.ts
Normal 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';
|
||||
}
|
||||
111
packages/ion/src/schema/index.ts
Normal file
111
packages/ion/src/schema/index.ts
Normal 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';
|
||||
46
packages/ion/src/schema/loop.ts
Normal file
46
packages/ion/src/schema/loop.ts
Normal 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>;
|
||||
46
packages/ion/src/schema/node-output.ts
Normal file
46
packages/ion/src/schema/node-output.ts
Normal 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;
|
||||
}
|
||||
31
packages/ion/src/schema/retry.ts
Normal file
31
packages/ion/src/schema/retry.ts
Normal 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 (1–5 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 (1000–60000). */
|
||||
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>;
|
||||
26
packages/ion/src/schema/trigger-rule.ts
Normal file
26
packages/ion/src/schema/trigger-rule.ts
Normal 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';
|
||||
109
packages/ion/src/schema/workflow-run.ts
Normal file
109
packages/ion/src/schema/workflow-run.ts
Normal 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>;
|
||||
246
packages/ion/src/schema/workflow.ts
Normal file
246
packages/ion/src/schema/workflow.ts
Normal 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>;
|
||||
249
packages/ion/src/store/fs-store.ts
Normal file
249
packages/ion/src/store/fs-store.ts
Normal 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;
|
||||
}
|
||||
22
packages/ion/src/store/index.ts
Normal file
22
packages/ion/src/store/index.ts
Normal 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';
|
||||
46
packages/ion/src/store/optional-deps.d.ts
vendored
Normal file
46
packages/ion/src/store/optional-deps.d.ts
vendored
Normal 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;
|
||||
}
|
||||
309
packages/ion/src/store/pg-store.ts
Normal file
309
packages/ion/src/store/pg-store.ts
Normal 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;
|
||||
}
|
||||
336
packages/ion/src/store/sqlite-store.ts
Normal file
336
packages/ion/src/store/sqlite-store.ts
Normal 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;
|
||||
}
|
||||
14
packages/ion/src/store/types.ts
Normal file
14
packages/ion/src/store/types.ts
Normal 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';
|
||||
Reference in New Issue
Block a user