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:
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(),
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user