recipe-manager/scripts/run-workflow.ts

191 lines
5.9 KiB
TypeScript

#!/usr/bin/env ts-node
import * as path from 'path';
import { promises as fs } from 'fs';
import { SequentialOrchestrator } from '../src/backend/services/SequentialOrchestrator.ts';
import type { WorkflowStage, WorkflowContext } from '../src/backend/services/SequentialOrchestrator.ts';
import { WorkflowStatusManager } from '../src/backend/services/WorkflowStatusManager.ts';
type RunWorkflowOptions = {
mode?: 'start' | 'resume' | 'restart';
checkpointPath?: string;
statusPath?: string;
progressLogPath?: string;
maxRetriesPerPhase?: number;
now?: Date;
};
type CliArgs = {
mode: 'start' | 'resume' | 'restart';
checkpointPath?: string;
statusPath?: string;
progressLogPath?: string;
maxRetriesPerPhase?: number;
};
const DEFAULT_CHECKPOINT_PATH = path.join(process.cwd(), 'status', 'workflow-checkpoint.json');
const DEFAULT_STATUS_PATH = path.join(process.cwd(), 'status', 'workflow-status.json');
const DEFAULT_PROGRESS_LOG_PATH = path.join(process.cwd(), 'status', 'workflow-progress.jsonl');
function parseCliArgs(argv: string[]): CliArgs {
const out: CliArgs = { mode: 'resume' };
for (let i = 0; i < argv.length; i += 1) {
const arg = argv[i];
const next = argv[i + 1];
if (arg === '--mode' && next) {
if (next === 'start' || next === 'resume' || next === 'restart') {
out.mode = next;
i += 1;
}
continue;
}
if (arg === '--checkpoint' && next) {
out.checkpointPath = next;
i += 1;
continue;
}
if (arg === '--status' && next) {
out.statusPath = next;
i += 1;
continue;
}
if (arg === '--progress-log' && next) {
out.progressLogPath = next;
i += 1;
continue;
}
if (arg === '--max-retries' && next) {
const parsed = Number.parseInt(next, 10);
if (Number.isFinite(parsed) && parsed >= 0) {
out.maxRetriesPerPhase = parsed;
}
i += 1;
continue;
}
}
return out;
}
function createTimestampedSummary(label: string, now: Date): string {
return `${label} @ ${now.toISOString()}`;
}
function createStages(now: Date): WorkflowStage[] {
return [
{
id: 'review-plan',
name: 'Review TODO and roadmap',
execute: async (_context: WorkflowContext) => {
const todoPath = path.join(process.cwd(), 'TODO.md');
const roadmapPath = path.join(process.cwd(), 'ROADMAP.md');
const todo = await fs.readFile(todoPath, 'utf8');
const roadmap = await fs.readFile(roadmapPath, 'utf8');
const todoLines = todo.split(/\r?\n/).length;
const roadmapLines = roadmap.split(/\r?\n/).length;
return {
summary: createTimestampedSummary(`Loaded planning docs (TODO: ${todoLines} lines, ROADMAP: ${roadmapLines} lines)`, now),
metadata: { todoLines, roadmapLines },
nextAction: 'Proceed to implementation stage.',
};
},
},
{
id: 'verify-build',
name: 'Verify TypeScript build',
execute: async (_context: WorkflowContext) => {
// Lightweight validation step to keep workflow deterministic in tests.
const packageJsonPath = path.join(process.cwd(), 'package.json');
const pkgRaw = await fs.readFile(packageJsonPath, 'utf8');
const pkg = JSON.parse(pkgRaw) as { scripts?: Record<string, string> };
const hasBuildScript = Boolean(pkg.scripts?.build);
if (!hasBuildScript) {
throw new Error('Missing required npm script: build');
}
return {
summary: createTimestampedSummary('Validated project scripts and build entrypoint', now),
metadata: { hasBuildScript },
nextAction: 'Proceed to tests stage.',
};
},
},
{
id: 'verify-tests',
name: 'Verify test command availability',
execute: async (_context: WorkflowContext) => {
const packageJsonPath = path.join(process.cwd(), 'package.json');
const pkgRaw = await fs.readFile(packageJsonPath, 'utf8');
const pkg = JSON.parse(pkgRaw) as { scripts?: Record<string, string> };
const hasTestScript = Boolean(pkg.scripts?.test);
if (!hasTestScript) {
throw new Error('Missing required npm script: test');
}
return {
summary: createTimestampedSummary('Validated test script presence', now),
metadata: { hasTestScript },
nextAction: 'Workflow checks complete.',
};
},
},
];
}
export async function runWorkflow(options: RunWorkflowOptions = {}): Promise<void> {
const checkpointPath = options.checkpointPath ?? DEFAULT_CHECKPOINT_PATH;
const statusPath = options.statusPath ?? DEFAULT_STATUS_PATH;
const progressLogPath = options.progressLogPath ?? DEFAULT_PROGRESS_LOG_PATH;
const mode = options.mode ?? 'resume';
const maxRetriesPerPhase = options.maxRetriesPerPhase ?? 1;
const now = options.now ?? new Date();
const workflow = new SequentialOrchestrator({
checkpointPath,
maxRetriesPerPhase,
statusManager: new WorkflowStatusManager(statusPath),
progressLogPath,
});
const initialContext: WorkflowContext = {
runId: `workflow-${now.toISOString()}`,
startedAt: now.toISOString(),
metadata: {
initiatedBy: 'scripts/run-workflow.ts',
mode,
},
};
const stages = createStages(now);
if (mode === 'restart' || mode === 'start') {
await workflow.start(stages, initialContext);
return;
}
await workflow.resume(stages, initialContext);
}
if (import.meta.url === `file://${process.argv[1]}`) {
const args = parseCliArgs(process.argv.slice(2));
runWorkflow({
mode: args.mode,
checkpointPath: args.checkpointPath,
statusPath: args.statusPath,
progressLogPath: args.progressLogPath,
maxRetriesPerPhase: args.maxRetriesPerPhase,
}).catch((error) => {
const message = error instanceof Error ? error.message : String(error);
console.error(`[workflow:run] failed: ${message}`);
process.exitCode = 1;
});
}