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