import { promises as fs } from 'fs'; import path from 'path'; import { logPhaseProgress } from './PhaseProgressLogger.js'; import { WorkflowStatusManager } from './WorkflowStatusManager.js'; import type { WorkflowStatus } from './WorkflowStatusManager.js'; import { appendPhaseUpdate } from './PhaseUpdateQueue.js'; export type WorkflowContext = { runId: string; startedAt: string; metadata?: Record; [key: string]: unknown; }; export type WorkflowStageResult = { summary?: string; metadata?: Record; nextAction?: string; }; export type WorkflowStage = { id: string; name: string; execute: (context: WorkflowContext) => Promise; retry?: number; backoffMs?: number; }; export type OrchestratorPhase = { name: string; run: (input: TInput) => Promise; retry?: number; backoffMs?: number; nextAction?: string; }; export type PhaseAttemptResult = { phase: string; status: 'success' | 'failure'; attempt: number; timestamp: string; error?: string; failureReason?: string; nextAction: string; }; export type Checkpoint = { currentPhase: number; phaseResults: PhaseAttemptResult[]; inProgress?: boolean; context?: WorkflowContext; }; const DEFAULT_CHECKPOINT_FILE = path.join(process.cwd(), 'data/orchestrator-checkpoint.json'); const DEFAULT_STATUS_FILE = path.join(process.cwd(), 'status/workflow-status.json'); const DEFAULT_PROGRESS_LOG = path.join(process.cwd(), 'status/phase-progress.jsonl'); export class SequentialOrchestrator { private phases?: OrchestratorPhase[]; private input?: TInput; private checkpointPath: string; private maxAttemptsPerPhasePerRun: number; private maxRetriesPerPhase?: number; private statusManager: WorkflowStatusManager; private progressLogPath: string; constructor(options: { phases?: OrchestratorPhase[]; checkpointPath?: string; input?: TInput; maxAttemptsPerPhasePerRun?: number; maxRetriesPerPhase?: number; statusManager?: WorkflowStatusManager; statusFilePath?: string; progressLogPath?: string; } = {}) { this.phases = options.phases; this.input = options.input; this.checkpointPath = options.checkpointPath || DEFAULT_CHECKPOINT_FILE; this.maxAttemptsPerPhasePerRun = options.maxAttemptsPerPhasePerRun ?? Infinity; this.maxRetriesPerPhase = options.maxRetriesPerPhase; this.statusManager = options.statusManager ?? new WorkflowStatusManager(options.statusFilePath || DEFAULT_STATUS_FILE); this.progressLogPath = options.progressLogPath ?? DEFAULT_PROGRESS_LOG; } private async writeStatus( stages: WorkflowStage[], state: Partial, checkpoint: Checkpoint, extra: { currentPhaseIdx?: number | null; phaseResult?: PhaseAttemptResult } = {} ) { const completedPhases = stages .slice(0, checkpoint.currentPhase) .map((p) => p.name) .filter((phaseName) => checkpoint.phaseResults.some((r) => r.phase === phaseName && r.status === 'success')); const currentP = typeof extra.currentPhaseIdx === 'number' && stages[extra.currentPhaseIdx]?.name ? stages[extra.currentPhaseIdx].name : null; let overallStatus = state.overallStatus || 'idle'; if (extra.phaseResult && extra.phaseResult.status === 'failure') { overallStatus = 'failed'; } else if (checkpoint.currentPhase === stages.length) { overallStatus = 'completed'; } const lastFailureReason = (state.lastFailureReason === undefined && extra.phaseResult?.failureReason) ? extra.phaseResult.failureReason || null : (state.lastFailureReason !== undefined ? state.lastFailureReason : null); const workflowStatus: WorkflowStatus = { currentPhase: currentP, overallStatus, lastUpdated: new Date().toISOString(), lastFailureReason, nextAction: state.nextAction || extra.phaseResult?.nextAction || '', completedPhases, }; await this.statusManager.update(workflowStatus); } private async loadCheckpoint(): Promise { try { const txt = await fs.readFile(this.checkpointPath, 'utf8'); return JSON.parse(txt) as Checkpoint; } catch { return null; } } private async saveCheckpoint(checkpoint: Checkpoint) { await fs.mkdir(path.dirname(this.checkpointPath), { recursive: true }); await fs.writeFile(this.checkpointPath, JSON.stringify(checkpoint, null, 2), 'utf8'); } private toStagesFromLegacyPhases(phases: OrchestratorPhase[], input: TInput): WorkflowStage[] { return phases.map((phase, idx) => ({ id: `legacy-${idx + 1}`, name: phase.name, retry: phase.retry, backoffMs: phase.backoffMs, execute: async () => { await phase.run(input); return { nextAction: phase.nextAction ?? 'proceed' }; }, })); } private async executeStages(stages: WorkflowStage[], context: WorkflowContext, resetCheckpoint: boolean): Promise { let checkpoint = await this.loadCheckpoint(); if (!checkpoint || resetCheckpoint) { checkpoint = { currentPhase: 0, phaseResults: [], inProgress: true, context }; await this.saveCheckpoint(checkpoint); await this.writeStatus(stages, { overallStatus: 'idle' }, checkpoint, { currentPhaseIdx: 0 }); } else if (!checkpoint.context) { checkpoint.context = context; await this.saveCheckpoint(checkpoint); } while (checkpoint.currentPhase < stages.length) { const stage = stages[checkpoint.currentPhase]; let success = false; let attempt = 1; const stageRetries = typeof stage.retry === 'number' ? stage.retry : (this.maxRetriesPerPhase ?? 1); const maxAttemptsThisRun = Number.isFinite(this.maxAttemptsPerPhasePerRun) ? Math.min(stageRetries, this.maxAttemptsPerPhasePerRun) : stageRetries; await appendPhaseUpdate({ eventType: 'phase_started', phase: stage.name, summary: `Phase '${stage.name}' started`, details: { attempt }, }); for (; attempt <= maxAttemptsThisRun && !success; attempt += 1) { try { const result = await stage.execute(checkpoint.context ?? context); const nextAction = result?.nextAction ?? 'proceed'; const res: PhaseAttemptResult = { phase: stage.name, status: 'success', attempt, timestamp: new Date().toISOString(), nextAction, }; checkpoint.phaseResults.push(res); checkpoint.currentPhase += 1; checkpoint.inProgress = checkpoint.currentPhase < stages.length; await this.saveCheckpoint(checkpoint); await logPhaseProgress({ phase: res.phase, status: res.status, attempt: res.attempt, timestamp: res.timestamp, nextAction: res.nextAction, }, this.progressLogPath); await appendPhaseUpdate({ eventType: 'phase_succeeded', phase: stage.name, summary: `Phase '${stage.name}' succeeded`, details: { attempt }, }); success = true; await this.writeStatus( stages, { overallStatus: checkpoint.currentPhase < stages.length ? 'running' : 'completed', nextAction }, checkpoint, { currentPhaseIdx: checkpoint.currentPhase, phaseResult: res } ); } catch (err: any) { const isLastAttempt = attempt === stageRetries; const message = err && err.message ? err.message : String(err); const failRes: PhaseAttemptResult = { phase: stage.name, status: 'failure', attempt, timestamp: new Date().toISOString(), error: message, failureReason: message, nextAction: !isLastAttempt ? 'retry' : 'manual intervention', }; checkpoint.phaseResults.push(failRes); await logPhaseProgress({ phase: failRes.phase, status: failRes.status, attempt: failRes.attempt, timestamp: failRes.timestamp, failureReason: failRes.failureReason, nextAction: failRes.nextAction, }, this.progressLogPath); if (!isLastAttempt && stage.backoffMs && stage.backoffMs > 0) { await new Promise((resolve) => setTimeout(resolve, stage.backoffMs)); } if (isLastAttempt) { checkpoint.inProgress = false; await appendPhaseUpdate({ eventType: 'phase_failed', phase: stage.name, summary: `Phase '${stage.name}' failed`, details: { attempt, error: message }, }); await this.saveCheckpoint(checkpoint); await this.writeStatus( stages, { overallStatus: 'failed', lastFailureReason: failRes.failureReason, nextAction: failRes.nextAction }, checkpoint, { currentPhaseIdx: checkpoint.currentPhase, phaseResult: failRes } ); return; } } } } checkpoint.inProgress = false; await this.saveCheckpoint(checkpoint); await appendPhaseUpdate({ eventType: 'workflow_completed', phase: null, summary: 'Workflow completed successfully', details: { totalPhases: stages.length }, }); await this.writeStatus(stages, { overallStatus: 'completed', nextAction: 'done' }, checkpoint, { currentPhaseIdx: null }); } public async start(stages: WorkflowStage[], context: WorkflowContext): Promise { await this.executeStages(stages, context, true); } public async resume(stages: WorkflowStage[], context: WorkflowContext): Promise { await this.executeStages(stages, context, false); } // Backward-compatible API used by existing tests. public async run(): Promise { const phases = this.phases ?? []; const input = this.input as TInput; const stages = this.toStagesFromLegacyPhases(phases, input); const context: WorkflowContext = { runId: `legacy-${new Date().toISOString()}`, startedAt: new Date().toISOString(), metadata: { source: 'SequentialOrchestrator.run' }, }; await this.resume(stages, context); } }