recipe-manager/src/backend/services/SequentialOrchestrator.ts

193 lines
7.4 KiB
TypeScript

import { promises as fs } from 'fs';
import path from 'path';
import { logPhaseProgress } from './PhaseProgressLogger';
import { WorkflowStatusManager, WorkflowStatus } from './WorkflowStatusManager';
import { appendPhaseUpdate } from './PhaseUpdateQueue';
export type OrchestratorPhase<TInput, TOutput> = {
name: string;
run: (input: TInput) => Promise<TOutput>;
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;
};
const DEFAULT_CHECKPOINT_FILE = path.join(process.cwd(), 'data/orchestrator-checkpoint.json');
const DEFAULT_STATUS_FILE = path.join(process.cwd(), 'status/workflow-status.json');
export class SequentialOrchestrator<TInput = any, TOutput = any> {
private phases: OrchestratorPhase<TInput, TOutput>[];
private checkpointPath: string;
private input: TInput;
private maxAttemptsPerPhasePerRun: number;
private statusManager: WorkflowStatusManager;
constructor(options: {
phases: OrchestratorPhase<TInput, TOutput>[];
checkpointPath?: string;
input: TInput;
maxAttemptsPerPhasePerRun?: number;
statusFilePath?: string;
}) {
this.phases = options.phases;
this.checkpointPath = options.checkpointPath || DEFAULT_CHECKPOINT_FILE;
this.input = options.input;
this.maxAttemptsPerPhasePerRun = options.maxAttemptsPerPhasePerRun ?? Infinity;
this.statusManager = new WorkflowStatusManager(options.statusFilePath || DEFAULT_STATUS_FILE);
}
private async writeStatus(
state: Partial<WorkflowStatus>,
extra: { currentPhaseIdx?: number; phaseResult?: PhaseAttemptResult } = {}
) {
const checkpoint = await this.loadCheckpoint();
let completedPhases: string[] = [];
if (checkpoint) {
completedPhases = this.phases
.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' && this.phases[extra.currentPhaseIdx]?.name
? this.phases[extra.currentPhaseIdx].name
: null;
let overallStatus = state.overallStatus || 'idle';
if (extra.phaseResult && extra.phaseResult.status === 'failure') {
overallStatus = 'failed';
} else if (checkpoint && checkpoint.currentPhase === this.phases.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<Checkpoint | null> {
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');
}
public async run(): Promise<void> {
let checkpoint = await this.loadCheckpoint();
if (!checkpoint) {
checkpoint = { currentPhase: 0, phaseResults: [] };
await this.saveCheckpoint(checkpoint);
await this.writeStatus({ overallStatus: 'idle' }, { currentPhaseIdx: 0 });
}
while (checkpoint.currentPhase < this.phases.length) {
const phase = this.phases[checkpoint.currentPhase];
let success = false;
let attempt = 1;
const maxRetries = (typeof phase.retry === 'number') ? phase.retry : 1;
const maxAttemptsThisRun = Number.isFinite(this.maxAttemptsPerPhasePerRun)
? Math.min(maxRetries, this.maxAttemptsPerPhasePerRun)
: maxRetries;
await appendPhaseUpdate({
eventType: 'phase_started',
phase: phase.name,
summary: `Phase '${phase.name}' started`,
details: { attempt },
});
for (; attempt <= maxAttemptsThisRun && !success; attempt++) {
try {
await phase.run(this.input);
const res: PhaseAttemptResult = {
phase: phase.name,
status: 'success',
attempt,
timestamp: new Date().toISOString(),
nextAction: 'proceed',
};
checkpoint.phaseResults.push(res);
checkpoint.currentPhase += 1;
await this.saveCheckpoint(checkpoint);
await logPhaseProgress({
phase: res.phase,
status: res.status,
attempt: res.attempt,
timestamp: res.timestamp,
nextAction: res.nextAction,
});
await appendPhaseUpdate({
eventType: 'phase_succeeded',
phase: phase.name,
summary: `Phase '${phase.name}' succeeded`,
details: { attempt },
});
success = true;
await this.writeStatus({ overallStatus: 'running', nextAction: phase.nextAction || '' }, { currentPhaseIdx: checkpoint.currentPhase, phaseResult: res });
} catch (err: any) {
const isLastAttempt = attempt === maxRetries;
// Always log every failed attempt
const failRes: PhaseAttemptResult = {
phase: phase.name,
status: 'failure',
attempt,
timestamp: new Date().toISOString(),
error: err && err.message ? err.message : String(err),
failureReason: err && err.message ? err.message : String(err),
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,
});
if (isLastAttempt) {
await appendPhaseUpdate({
eventType: 'phase_failed',
phase: phase.name,
summary: `Phase '${phase.name}' failed`,
details: { attempt, error: err && err.message ? err.message : String(err) },
});
await this.saveCheckpoint(checkpoint);
await this.writeStatus({ overallStatus: 'failed', lastFailureReason: failRes.failureReason, nextAction: failRes.nextAction }, { currentPhaseIdx: checkpoint.currentPhase, phaseResult: failRes });
return;
}
}
}
}
if (checkpoint.currentPhase === this.phases.length) {
await this.saveCheckpoint(checkpoint);
await appendPhaseUpdate({
eventType: 'workflow_completed',
phase: null,
summary: `Workflow completed successfully`,
details: { totalPhases: this.phases.length },
});
await this.writeStatus({ overallStatus: 'completed', nextAction: 'done' }, { currentPhaseIdx: null });
}
}
}