Files
stack/packages/forge/src/pipeline-runner.ts
T

477 lines
14 KiB
TypeScript

import fs from 'node:fs';
import path from 'node:path';
import { STAGE_SEQUENCE, STAGE_SPECS } from './constants.js';
import { determineBriefClass, stagesForClass } from './brief-classifier.js';
import { ForgeCapabilityError, providerErrorCode } from './errors.js';
import {
blockedGateResults,
evaluateStageGates,
isCapabilityGate,
simulatedGateResults,
waitingGateResults,
} from './outcomes.js';
import { mapStageToTask } from './stage-adapter.js';
import { createSimulatedExecutor } from './simulated-executor.js';
import type {
ForgeTask,
ForgeTaskResult,
PipelineOptions,
PipelineResult,
RunManifest,
RunMode,
StageStatus,
TaskExecutor,
} from './types.js';
/** Reason stamped on stages that complete under explicit simulation. */
const SIMULATED_STAGE_REASON =
'simulated execution (--simulate): stage was not executed by a real executor';
/**
* Generate a timestamp-based run ID.
*/
export function generateRunId(): string {
const now = new Date();
const pad = (n: number, w = 2) => String(n).padStart(w, '0');
return [
now.getUTCFullYear(),
pad(now.getUTCMonth() + 1),
pad(now.getUTCDate()),
'-',
pad(now.getUTCHours()),
pad(now.getUTCMinutes()),
pad(now.getUTCSeconds()),
].join('');
}
/**
* Get the ISO timestamp for now.
*/
function nowISO(): string {
return new Date().toISOString();
}
/**
* Create and persist a run manifest.
*/
function createManifest(opts: {
runId: string;
briefPath: string;
codebase: string;
briefClass: RunManifest['briefClass'];
classSource: RunManifest['classSource'];
forceBoard: boolean;
mode: RunMode;
runDir: string;
}): RunManifest {
const ts = nowISO();
const manifest: RunManifest = {
runId: opts.runId,
brief: opts.briefPath,
codebase: opts.codebase,
briefClass: opts.briefClass,
classSource: opts.classSource,
forceBoard: opts.forceBoard,
mode: opts.mode,
createdAt: ts,
updatedAt: ts,
currentStage: '',
status: 'in_progress',
stages: {},
};
saveManifest(opts.runDir, manifest);
return manifest;
}
/**
* Save a manifest to disk.
*/
export function saveManifest(runDir: string, manifest: RunManifest): void {
manifest.updatedAt = nowISO();
const manifestPath = path.join(runDir, 'manifest.json');
fs.mkdirSync(path.dirname(manifestPath), { recursive: true });
fs.writeFileSync(manifestPath, JSON.stringify(manifest, null, 2) + '\n', 'utf-8');
}
/**
* Load a manifest from disk.
*/
export function loadManifest(runDir: string): RunManifest {
const manifestPath = path.join(runDir, 'manifest.json');
if (!fs.existsSync(manifestPath)) {
throw new Error(`manifest.json not found: ${manifestPath}`);
}
return JSON.parse(fs.readFileSync(manifestPath, 'utf-8')) as RunManifest;
}
/**
* Select and validate stages, optionally skipping to a specific stage.
*/
export function selectStages(stages?: string[], skipTo?: string): string[] {
const selected = stages ?? [...STAGE_SEQUENCE];
const unknown = selected.filter((s) => !STAGE_SEQUENCE.includes(s));
if (unknown.length > 0) {
throw new Error(`Unknown Forge stages requested: ${unknown.join(', ')}`);
}
if (!skipTo) return selected;
if (!selected.includes(skipTo)) {
throw new Error(`skip_to stage '${skipTo}' is not present in the selected stage list`);
}
const skipIndex = selected.indexOf(skipTo);
return selected.slice(skipIndex);
}
/**
* Fail closed when the required executor capability is missing (SDLC-D-035).
*/
function requireExecutor(executor: TaskExecutor | undefined, simulate: boolean): TaskExecutor {
if (executor) return executor;
if (simulate) return createSimulatedExecutor({ log: false });
throw new ForgeCapabilityError(
'FORGE_NO_EXECUTOR',
'task-executor',
'no task executor is wired; refusing to run the pipeline with a stub executor (fail closed). ' +
'Pass --simulate to opt into explicitly simulated execution.',
);
}
/**
* Pre-flight a stage's gates in normal mode (fail closed, SDLC-D-035).
*
* - authority gates: record a typed `waiting-for-authority` stage result and
* raise FORGE_AUTHORITY_REQUIRED — approval-based gates never pass vacuously.
* - provider gates: record a typed `blocked` stage result and raise the typed
* capability error for the missing provider.
*
* Returns the stage status to record when the pre-flight blocks, or undefined
* when the stage may proceed.
*/
function preflightStageGates(
stageName: string,
manifest: RunManifest,
): { status: StageStatus; error: ForgeCapabilityError } | undefined {
const spec = STAGE_SPECS[stageName];
if (!spec) throw new Error(`Unknown Forge stage: ${stageName}`);
for (const gate of spec.qualityGates) {
if (!isCapabilityGate(gate)) continue;
const startedAt = manifest.stages[stageName]?.startedAt;
const completedAt = nowISO();
if (gate.kind === 'authority') {
const reason = `gate '${gate.capability}' requires authority sign-off; no mechanical implementation exists (${gate.reason})`;
return {
status: {
status: 'waiting-for-authority',
reason,
startedAt,
completedAt,
gateResults: waitingGateResults(spec.qualityGates, reason),
},
error: new ForgeCapabilityError(
'FORGE_AUTHORITY_REQUIRED',
gate.capability,
`stage '${stageName}' is blocked on authority gate '${gate.capability}': ${gate.reason}. ` +
'The pipeline fails closed instead of passing vacuously. Record the approval out-of-band ' +
'or run with --simulate for explicitly simulated execution.',
),
};
}
const reason = `gate '${gate.capability}' requires provider '${gate.capability}' and none is wired (${gate.reason})`;
return {
status: {
status: 'blocked',
reason,
startedAt,
completedAt,
gateResults: blockedGateResults(spec.qualityGates, reason),
},
error: new ForgeCapabilityError(
providerErrorCode(gate.capability),
gate.capability,
`stage '${stageName}' requires provider '${gate.capability}' which is not wired: ${gate.reason}. ` +
'The pipeline fails closed instead of passing vacuously.',
),
};
}
return undefined;
}
/**
* Execute the given stage tasks sequentially, updating the manifest.
*
* Normal mode requires a real executor and evaluates every declared command
* gate through the typed outcome model; any non-verified result fails closed.
* Simulate mode types every stage and gate result as `simulated`.
*/
async function executeStages(opts: {
manifest: RunManifest;
runDir: string;
tasks: ForgeTask[];
stageNames: string[];
executor: TaskExecutor;
simulate: boolean;
}): Promise<void> {
const { manifest, runDir, tasks, stageNames, executor, simulate } = opts;
for (let i = 0; i < tasks.length; i++) {
const task = tasks[i]!;
const stageName = stageNames[i]!;
const spec = STAGE_SPECS[stageName];
if (!spec) throw new Error(`Unknown Forge stage: ${stageName}`);
// Update manifest: stage in progress
manifest.currentStage = stageName;
manifest.stages[stageName] = {
status: 'in_progress',
startedAt: nowISO(),
};
saveManifest(runDir, manifest);
// Fail-closed pre-flight (normal mode only): authority/provider gates have
// no mechanical implementation and must never pass vacuously.
if (!simulate) {
const blocked = preflightStageGates(stageName, manifest);
if (blocked) {
manifest.stages[stageName] = blocked.status;
manifest.status =
blocked.status.status === 'waiting-for-authority' ? 'waiting-for-authority' : 'failed';
saveManifest(runDir, manifest);
throw blocked.error;
}
}
let result: ForgeTaskResult;
try {
await executor.submitTask(task);
result = await executor.waitForCompletion(task.id, task.timeoutSeconds * 1000);
} catch (error) {
// Process errors (including timeouts) map to the fail-closed `error` outcome.
const reason = error instanceof Error ? error.message : String(error);
manifest.stages[stageName] = {
status: 'error',
reason: `executor error: ${reason}`,
startedAt: manifest.stages[stageName]?.startedAt,
completedAt: nowISO(),
gateResults: [],
};
manifest.status = 'failed';
saveManifest(runDir, manifest);
throw error instanceof Error ? error : new Error(reason);
}
if (simulate) {
manifest.stages[stageName] = {
status: 'simulated',
reason: SIMULATED_STAGE_REASON,
startedAt: manifest.stages[stageName]?.startedAt,
completedAt: nowISO(),
gateResults: simulatedGateResults(spec.qualityGates),
};
saveManifest(runDir, manifest);
continue;
}
const evaluation = evaluateStageGates(stageName, spec.qualityGates, result);
manifest.stages[stageName] = {
status: evaluation.outcome,
reason: evaluation.reason,
startedAt: manifest.stages[stageName]?.startedAt,
completedAt: nowISO(),
gateResults: evaluation.gateResults,
};
if (evaluation.outcome !== 'passed') {
manifest.status =
evaluation.outcome === 'waiting-for-authority' ? 'waiting-for-authority' : 'failed';
saveManifest(runDir, manifest);
throw new Error(`Stage ${stageName} ${evaluation.outcome}: ${evaluation.reason}`);
}
saveManifest(runDir, manifest);
}
}
/**
* Run the Forge pipeline.
*
* 1. Fail closed unless a real executor is wired or simulation is explicit
* 2. Classify the brief
* 3. Generate a run ID and create run directory
* 4. Map stages to tasks and submit to TaskExecutor
* 5. Track manifest with typed stage outcomes
* 6. Return pipeline result
*/
export async function runPipeline(
briefPath: string,
projectRoot: string,
options: PipelineOptions,
): Promise<PipelineResult> {
const simulate = options.simulate ?? false;
const executor = requireExecutor(options.executor, simulate);
const mode: RunMode = simulate ? 'simulated' : 'normal';
const resolvedRoot = path.resolve(projectRoot);
const resolvedBrief = path.resolve(briefPath);
const briefContent = fs.readFileSync(resolvedBrief, 'utf-8');
// Classify brief
const { briefClass, classSource } = determineBriefClass(briefContent, options.briefClass);
// Determine stages
const classStages = options.stages ?? stagesForClass(briefClass, options.forceBoard);
const selectedStages = selectStages(classStages, options.skipTo);
// Create run directory
const runId = generateRunId();
const runDir = path.join(resolvedRoot, '.forge', 'runs', runId);
fs.mkdirSync(runDir, { recursive: true });
// Create manifest
const manifest = createManifest({
runId,
briefPath: resolvedBrief,
codebase: options.codebase ?? '',
briefClass,
classSource,
forceBoard: options.forceBoard ?? false,
mode,
runDir,
});
// Map stages to tasks
const tasks: ForgeTask[] = [];
for (let i = 0; i < selectedStages.length; i++) {
const stageName = selectedStages[i]!;
const task = mapStageToTask({
stageName,
briefContent,
projectRoot: resolvedRoot,
runId,
runDir,
});
// Override dependency chain for selected (possibly filtered) stages
if (i > 0) {
task.dependsOn = [tasks[i - 1]!.id];
} else {
delete task.dependsOn;
}
tasks.push(task);
}
// Execute stages
await executeStages({ manifest, runDir, tasks, stageNames: selectedStages, executor, simulate });
// All stages reached a terminal state for this mode
manifest.status = simulate ? 'simulated' : 'completed';
saveManifest(runDir, manifest);
return {
runId,
briefPath: resolvedBrief,
projectRoot: resolvedRoot,
runDir,
taskIds: tasks.map((t) => t.id),
stages: selectedStages,
manifest,
};
}
/**
* Resume a pipeline from the last non-passed stage.
*/
export async function resumePipeline(
runDir: string,
executor?: TaskExecutor,
options?: { simulate?: boolean },
): Promise<PipelineResult> {
const simulate = options?.simulate ?? false;
const wiredExecutor = requireExecutor(executor, simulate);
const mode: RunMode = simulate ? 'simulated' : 'normal';
const manifest = loadManifest(runDir);
const resolvedRoot = path.dirname(path.dirname(path.dirname(runDir))); // .forge/runs/{id} → project root
const briefContent = fs.readFileSync(manifest.brief, 'utf-8');
const allStages = stagesForClass(manifest.briefClass, manifest.forceBoard);
manifest.mode = mode;
// Find first non-satisfying stage (only a verified `passed` counts as done;
// simulated and waiting-for-authority stages are re-run).
const resumeFrom = allStages.find((s) => manifest.stages[s]?.status !== 'passed');
if (!resumeFrom) {
manifest.status = mode === 'simulated' ? 'simulated' : 'completed';
saveManifest(runDir, manifest);
return {
runId: manifest.runId,
briefPath: manifest.brief,
projectRoot: resolvedRoot,
runDir,
taskIds: [],
stages: allStages,
manifest,
};
}
const remainingStages = selectStages(allStages, resumeFrom);
manifest.status = 'in_progress';
const tasks: ForgeTask[] = [];
for (let i = 0; i < remainingStages.length; i++) {
const stageName = remainingStages[i]!;
const task = mapStageToTask({
stageName,
briefContent,
projectRoot: resolvedRoot,
runId: manifest.runId,
runDir,
});
if (i > 0) {
task.dependsOn = [tasks[i - 1]!.id];
} else {
delete task.dependsOn;
}
tasks.push(task);
}
await executeStages({
manifest,
runDir,
tasks,
stageNames: remainingStages,
executor: wiredExecutor,
simulate,
});
manifest.status = simulate ? 'simulated' : 'completed';
saveManifest(runDir, manifest);
return {
runId: manifest.runId,
briefPath: manifest.brief,
projectRoot: resolvedRoot,
runDir,
taskIds: tasks.map((t) => t.id),
stages: remainingStages,
manifest,
};
}
/**
* Get the status of a pipeline run.
*/
export function getPipelineStatus(runDir: string): RunManifest {
return loadManifest(runDir);
}