fix(ri-050): forge fails closed without providers; explicit typed simulation (#1275)
ci/woodpecker/pr/ci Pipeline failed
ci/woodpecker/pr/ci Pipeline failed
This commit is contained in:
@@ -1,18 +1,33 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
import { STAGE_SEQUENCE } from './constants.js';
|
||||
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.
|
||||
*/
|
||||
@@ -47,6 +62,7 @@ function createManifest(opts: {
|
||||
briefClass: RunManifest['briefClass'];
|
||||
classSource: RunManifest['classSource'];
|
||||
forceBoard: boolean;
|
||||
mode: RunMode;
|
||||
runDir: string;
|
||||
}): RunManifest {
|
||||
const ts = nowISO();
|
||||
@@ -57,6 +73,7 @@ function createManifest(opts: {
|
||||
briefClass: opts.briefClass,
|
||||
classSource: opts.classSource,
|
||||
forceBoard: opts.forceBoard,
|
||||
mode: opts.mode,
|
||||
createdAt: ts,
|
||||
updatedAt: ts,
|
||||
currentStage: '',
|
||||
@@ -108,20 +125,199 @@ export function selectStages(stages?: string[], skipTo?: string): string[] {
|
||||
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. Classify the brief
|
||||
* 2. Generate a run ID and create run directory
|
||||
* 3. Map stages to tasks and submit to TaskExecutor
|
||||
* 4. Track manifest with stage statuses
|
||||
* 5. Return pipeline result
|
||||
* 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');
|
||||
@@ -146,6 +342,7 @@ export async function runPipeline(
|
||||
briefClass,
|
||||
classSource,
|
||||
forceBoard: options.forceBoard ?? false,
|
||||
mode,
|
||||
runDir,
|
||||
});
|
||||
|
||||
@@ -172,54 +369,10 @@ export async function runPipeline(
|
||||
}
|
||||
|
||||
// Execute stages
|
||||
const { executor } = options;
|
||||
for (let i = 0; i < tasks.length; i++) {
|
||||
const task = tasks[i]!;
|
||||
const stageName = selectedStages[i]!;
|
||||
await executeStages({ manifest, runDir, tasks, stageNames: selectedStages, executor, simulate });
|
||||
|
||||
// Update manifest: stage in progress
|
||||
manifest.currentStage = stageName;
|
||||
manifest.stages[stageName] = {
|
||||
status: 'in_progress',
|
||||
startedAt: nowISO(),
|
||||
};
|
||||
saveManifest(runDir, manifest);
|
||||
|
||||
try {
|
||||
await executor.submitTask(task);
|
||||
const result = await executor.waitForCompletion(task.id, task.timeoutSeconds * 1000);
|
||||
|
||||
// Update manifest: stage completed or failed
|
||||
const stageStatus: StageStatus = {
|
||||
status: result.status === 'completed' ? 'passed' : 'failed',
|
||||
startedAt: manifest.stages[stageName]!.startedAt,
|
||||
completedAt: nowISO(),
|
||||
};
|
||||
manifest.stages[stageName] = stageStatus;
|
||||
|
||||
if (result.status !== 'completed') {
|
||||
manifest.status = 'failed';
|
||||
saveManifest(runDir, manifest);
|
||||
throw new Error(`Stage ${stageName} failed with status: ${result.status}`);
|
||||
}
|
||||
|
||||
saveManifest(runDir, manifest);
|
||||
} catch (error) {
|
||||
if (!manifest.stages[stageName]?.completedAt) {
|
||||
manifest.stages[stageName] = {
|
||||
status: 'failed',
|
||||
startedAt: manifest.stages[stageName]?.startedAt,
|
||||
completedAt: nowISO(),
|
||||
};
|
||||
}
|
||||
manifest.status = 'failed';
|
||||
saveManifest(runDir, manifest);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// All stages passed
|
||||
manifest.status = 'completed';
|
||||
// All stages reached a terminal state for this mode
|
||||
manifest.status = simulate ? 'simulated' : 'completed';
|
||||
saveManifest(runDir, manifest);
|
||||
|
||||
return {
|
||||
@@ -234,22 +387,30 @@ export async function runPipeline(
|
||||
}
|
||||
|
||||
/**
|
||||
* Resume a pipeline from the last incomplete stage.
|
||||
* Resume a pipeline from the last non-passed stage.
|
||||
*/
|
||||
export async function resumePipeline(
|
||||
runDir: string,
|
||||
executor: TaskExecutor,
|
||||
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);
|
||||
|
||||
// Find first non-passed stage
|
||||
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 = 'completed';
|
||||
manifest.status = mode === 'simulated' ? 'simulated' : 'completed';
|
||||
saveManifest(runDir, manifest);
|
||||
return {
|
||||
runId: manifest.runId,
|
||||
@@ -284,49 +445,16 @@ export async function resumePipeline(
|
||||
tasks.push(task);
|
||||
}
|
||||
|
||||
for (let i = 0; i < tasks.length; i++) {
|
||||
const task = tasks[i]!;
|
||||
const stageName = remainingStages[i]!;
|
||||
await executeStages({
|
||||
manifest,
|
||||
runDir,
|
||||
tasks,
|
||||
stageNames: remainingStages,
|
||||
executor: wiredExecutor,
|
||||
simulate,
|
||||
});
|
||||
|
||||
manifest.currentStage = stageName;
|
||||
manifest.stages[stageName] = {
|
||||
status: 'in_progress',
|
||||
startedAt: nowISO(),
|
||||
};
|
||||
saveManifest(runDir, manifest);
|
||||
|
||||
try {
|
||||
await executor.submitTask(task);
|
||||
const result = await executor.waitForCompletion(task.id, task.timeoutSeconds * 1000);
|
||||
|
||||
manifest.stages[stageName] = {
|
||||
status: result.status === 'completed' ? 'passed' : 'failed',
|
||||
startedAt: manifest.stages[stageName]!.startedAt,
|
||||
completedAt: nowISO(),
|
||||
};
|
||||
|
||||
if (result.status !== 'completed') {
|
||||
manifest.status = 'failed';
|
||||
saveManifest(runDir, manifest);
|
||||
throw new Error(`Stage ${stageName} failed with status: ${result.status}`);
|
||||
}
|
||||
|
||||
saveManifest(runDir, manifest);
|
||||
} catch (error) {
|
||||
if (!manifest.stages[stageName]?.completedAt) {
|
||||
manifest.stages[stageName] = {
|
||||
status: 'failed',
|
||||
startedAt: manifest.stages[stageName]?.startedAt,
|
||||
completedAt: nowISO(),
|
||||
};
|
||||
}
|
||||
manifest.status = 'failed';
|
||||
saveManifest(runDir, manifest);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
manifest.status = 'completed';
|
||||
manifest.status = simulate ? 'simulated' : 'completed';
|
||||
saveManifest(runDir, manifest);
|
||||
|
||||
return {
|
||||
|
||||
Reference in New Issue
Block a user