Files
stack/packages/forge/__tests__/pipeline-runner.test.ts
T

459 lines
14 KiB
TypeScript

import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import {
generateRunId,
selectStages,
saveManifest,
loadManifest,
runPipeline,
resumePipeline,
getPipelineStatus,
} from '../src/pipeline-runner.js';
import type { ForgeTask, ForgeTaskResult, RunManifest, TaskExecutor } from '../src/types.js';
import { gateLabel, isCommandGate } from '../src/outcomes.js';
/** Mock TaskExecutor that records submitted tasks and returns typed results. */
function createMockExecutor(options?: {
failStage?: string;
}): TaskExecutor & { submittedTasks: ForgeTask[] } {
const submittedTasks: ForgeTask[] = [];
return {
submittedTasks,
async submitTask(task: ForgeTask) {
submittedTasks.push(task);
},
async waitForCompletion(taskId: string): Promise<ForgeTaskResult> {
const failStage = options?.failStage;
const task = submittedTasks.find((t) => t.id === taskId);
const stageName = task?.metadata?.['stageName'] as string | undefined;
if (failStage && stageName === failStage) {
return {
task_id: taskId,
outcome: 'failed',
reason: 'mock task failure',
completed_at: new Date().toISOString(),
exit_code: 1,
gate_results: [],
};
}
return {
task_id: taskId,
outcome: 'passed',
reason: 'mock verified',
completed_at: new Date().toISOString(),
exit_code: 0,
gate_results: (task?.qualityGates ?? [])
.filter((gate) => isCommandGate(gate))
.map((gate) => ({
gate: gateLabel(gate),
outcome: 'passed' as const,
reason: 'mock verified',
})),
};
},
async getTaskStatus() {
return 'completed' as const;
},
};
}
describe('generateRunId', () => {
it('returns a timestamp string', () => {
const id = generateRunId();
expect(id).toMatch(/^\d{8}-\d{6}$/);
});
it('returns unique IDs', () => {
const ids = new Set(Array.from({ length: 10 }, generateRunId));
// Given they run in the same second, they should at least be consistent format
expect(ids.size).toBeGreaterThanOrEqual(1);
});
});
describe('selectStages', () => {
it('returns full sequence when no args', () => {
const stages = selectStages();
expect(stages.length).toBeGreaterThan(0);
expect(stages[0]).toBe('00-intake');
});
it('returns provided stages', () => {
const stages = selectStages(['00-intake', '05-coding']);
expect(stages).toEqual(['00-intake', '05-coding']);
});
it('throws for unknown stages', () => {
expect(() => selectStages(['unknown'])).toThrow('Unknown Forge stages');
});
it('skips to specified stage', () => {
const stages = selectStages(undefined, '05-coding');
expect(stages[0]).toBe('05-coding');
expect(stages).not.toContain('00-intake');
});
it('throws if skipTo not in selected stages', () => {
expect(() => selectStages(['00-intake'], '05-coding')).toThrow(
"skip_to stage '05-coding' is not present",
);
});
});
describe('manifest operations', () => {
let tmpDir: string;
beforeEach(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'forge-manifest-'));
});
afterEach(() => {
fs.rmSync(tmpDir, { recursive: true, force: true });
});
it('saveManifest and loadManifest roundtrip', () => {
const manifest: RunManifest = {
runId: 'test-123',
brief: '/path/to/brief.md',
codebase: '/project',
briefClass: 'strategic',
classSource: 'auto',
forceBoard: false,
createdAt: '2026-01-01T00:00:00Z',
updatedAt: '2026-01-01T00:00:00Z',
currentStage: '00-intake',
status: 'in_progress',
stages: {
'00-intake': { status: 'passed', startedAt: '2026-01-01T00:00:00Z' },
},
};
saveManifest(tmpDir, manifest);
const loaded = loadManifest(tmpDir);
expect(loaded.runId).toBe('test-123');
expect(loaded.briefClass).toBe('strategic');
expect(loaded.stages['00-intake']?.status).toBe('passed');
});
it('loadManifest throws for missing file', () => {
expect(() => loadManifest('/nonexistent')).toThrow('manifest.json not found');
});
});
describe('runPipeline', () => {
let tmpDir: string;
let briefPath: string;
beforeEach(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'forge-pipeline-'));
briefPath = path.join(tmpDir, 'test-brief.md');
fs.writeFileSync(
briefPath,
'---\nclass: hotfix\n---\n\n# Fix CSS bug\n\nFix the bugfix for lint cleanup.',
);
});
afterEach(() => {
fs.rmSync(tmpDir, { recursive: true, force: true });
});
it('runs pipeline to completion with mock executor', async () => {
const executor = createMockExecutor();
const result = await runPipeline(briefPath, tmpDir, {
executor,
stages: ['00-intake', '05-coding'],
});
expect(result.runId).toMatch(/^\d{8}-\d{6}$/);
expect(result.stages).toEqual(['00-intake', '05-coding']);
expect(result.manifest.status).toBe('completed');
expect(result.manifest.mode).toBe('normal');
expect(executor.submittedTasks).toHaveLength(2);
});
it('creates run directory under .forge/runs/', async () => {
const executor = createMockExecutor();
const result = await runPipeline(briefPath, tmpDir, {
executor,
stages: ['00-intake'],
});
expect(result.runDir).toContain(path.join('.forge', 'runs'));
expect(fs.existsSync(result.runDir)).toBe(true);
});
it('writes manifest with stage statuses', async () => {
const executor = createMockExecutor();
const result = await runPipeline(briefPath, tmpDir, {
executor,
stages: ['00-intake', '05-coding'],
});
const manifest = loadManifest(result.runDir);
expect(manifest.stages['00-intake']?.status).toBe('passed');
expect(manifest.stages['05-coding']?.status).toBe('passed');
expect(manifest.stages['05-coding']?.gateResults?.map((g) => g.outcome)).toEqual([
'passed',
'passed',
'passed',
]);
});
it('respects CLI class override', async () => {
const executor = createMockExecutor();
const result = await runPipeline(briefPath, tmpDir, {
executor,
briefClass: 'strategic',
stages: ['00-intake'],
});
expect(result.manifest.briefClass).toBe('strategic');
expect(result.manifest.classSource).toBe('cli');
});
it('uses frontmatter class', async () => {
const executor = createMockExecutor();
const result = await runPipeline(briefPath, tmpDir, {
executor,
stages: ['00-intake'],
});
expect(result.manifest.briefClass).toBe('hotfix');
expect(result.manifest.classSource).toBe('frontmatter');
});
it('builds dependency chain between tasks', async () => {
const executor = createMockExecutor();
await runPipeline(briefPath, tmpDir, {
executor,
stages: ['00-intake', '05-coding', '08-test'],
});
expect(executor.submittedTasks[0]!.dependsOn).toBeUndefined();
expect(executor.submittedTasks[1]!.dependsOn).toEqual([executor.submittedTasks[0]!.id]);
expect(executor.submittedTasks[2]!.dependsOn).toEqual([executor.submittedTasks[1]!.id]);
});
it('handles stage failure', async () => {
const executor = createMockExecutor({ failStage: '05-coding' });
await expect(
runPipeline(briefPath, tmpDir, {
executor,
stages: ['00-intake', '05-coding'],
}),
).rejects.toThrow('Stage 05-coding failed');
});
it('marks manifest as failed on stage failure', async () => {
const executor = createMockExecutor({ failStage: '00-intake' });
try {
await runPipeline(briefPath, tmpDir, {
executor,
stages: ['00-intake'],
});
} catch {
// expected
}
// Find the run dir (we don't have it from the failed result)
const runsDir = path.join(tmpDir, '.forge', 'runs');
const runDirs = fs.readdirSync(runsDir);
expect(runDirs).toHaveLength(1);
const manifest = loadManifest(path.join(runsDir, runDirs[0]!));
expect(manifest.status).toBe('failed');
expect(manifest.stages['00-intake']?.status).toBe('failed');
});
});
describe('resumePipeline', () => {
let tmpDir: string;
let briefPath: string;
beforeEach(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'forge-resume-'));
briefPath = path.join(tmpDir, 'brief.md');
fs.writeFileSync(briefPath, '---\nclass: hotfix\n---\n\n# Fix bug');
});
afterEach(() => {
fs.rmSync(tmpDir, { recursive: true, force: true });
});
it('resumes from first incomplete stage and fails closed at the next provider gate', async () => {
// Simulate a run whose authority stages were approved out-of-band
// (recorded as passed) and whose coding stage failed mechanically.
const runId = '20260101-000000';
const runDir = path.join(tmpDir, '.forge', 'runs', runId);
fs.mkdirSync(runDir, { recursive: true });
const passed = { status: 'passed' as const, startedAt: '2026-01-01T00:00:00Z' };
saveManifest(runDir, {
runId,
brief: briefPath,
codebase: tmpDir,
briefClass: 'hotfix',
classSource: 'frontmatter',
forceBoard: false,
mode: 'normal',
createdAt: '2026-01-01T00:00:00Z',
updatedAt: '2026-01-01T00:00:00Z',
currentStage: '05-coding',
status: 'failed',
stages: {
'00-intake': passed,
'00b-discovery': passed,
'02-planning-1': passed,
'03-planning-2': passed,
'04-planning-3': passed,
'05-coding': { status: 'failed', reason: 'gate failed' },
},
});
// Resume re-runs 05-coding (the first non-passed stage), then fails
// closed at 06-review because no reviewer provider is wired.
const executor = createMockExecutor();
await expect(resumePipeline(runDir, executor)).rejects.toMatchObject({
name: 'ForgeCapabilityError',
code: 'FORGE_NO_REVIEWER',
});
const manifest = loadManifest(runDir);
expect(manifest.stages['05-coding']?.status).toBe('passed');
expect(manifest.stages['06-review']?.status).toBe('blocked');
expect(manifest.status).toBe('failed');
});
it('resumes to completion as simulated under explicit simulate', async () => {
const runId = '20260101-000003';
const runDir = path.join(tmpDir, '.forge', 'runs', runId);
fs.mkdirSync(runDir, { recursive: true });
const passed = { status: 'passed' as const, startedAt: '2026-01-01T00:00:00Z' };
saveManifest(runDir, {
runId,
brief: briefPath,
codebase: tmpDir,
briefClass: 'hotfix',
classSource: 'frontmatter',
forceBoard: false,
mode: 'normal',
createdAt: '2026-01-01T00:00:00Z',
updatedAt: '2026-01-01T00:00:00Z',
currentStage: '05-coding',
status: 'failed',
stages: {
'00-intake': passed,
'00b-discovery': passed,
'02-planning-1': passed,
'03-planning-2': passed,
'04-planning-3': passed,
'05-coding': { status: 'failed', reason: 'gate failed' },
},
});
const result = await resumePipeline(runDir, undefined, { simulate: true });
expect(result.manifest.status).toBe('simulated');
expect(result.manifest.mode).toBe('simulated');
expect(result.stages[0]).toBe('05-coding');
for (const stage of result.stages) {
expect(result.manifest.stages[stage]?.status).toBe('simulated');
}
});
it('fails closed on resume when the next stage needs authority sign-off', async () => {
const runId = '20260101-000001';
const runDir = path.join(tmpDir, '.forge', 'runs', runId);
fs.mkdirSync(runDir, { recursive: true });
saveManifest(runDir, {
runId,
brief: briefPath,
codebase: tmpDir,
briefClass: 'hotfix',
classSource: 'frontmatter',
forceBoard: false,
mode: 'normal',
createdAt: '2026-01-01T00:00:00Z',
updatedAt: '2026-01-01T00:00:00Z',
currentStage: '00-intake',
status: 'in_progress',
stages: {
'00-intake': { status: 'passed' },
},
});
const executor = createMockExecutor();
await expect(resumePipeline(runDir, executor)).rejects.toMatchObject({
name: 'ForgeCapabilityError',
code: 'FORGE_AUTHORITY_REQUIRED',
});
const manifest = loadManifest(runDir);
expect(manifest.stages['00b-discovery']?.status).toBe('waiting-for-authority');
expect(manifest.status).toBe('waiting-for-authority');
});
it('fails closed on resume without an executor or --simulate', async () => {
const runId = '20260101-000002';
const runDir = path.join(tmpDir, '.forge', 'runs', runId);
fs.mkdirSync(runDir, { recursive: true });
saveManifest(runDir, {
runId,
brief: briefPath,
codebase: tmpDir,
briefClass: 'hotfix',
classSource: 'frontmatter',
forceBoard: false,
mode: 'normal',
createdAt: '2026-01-01T00:00:00Z',
updatedAt: '2026-01-01T00:00:00Z',
currentStage: '00-intake',
status: 'in_progress',
stages: {
'00-intake': { status: 'passed' },
},
});
await expect(resumePipeline(runDir)).rejects.toMatchObject({
name: 'ForgeCapabilityError',
code: 'FORGE_NO_EXECUTOR',
});
});
});
describe('getPipelineStatus', () => {
let tmpDir: string;
beforeEach(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'forge-status-'));
});
afterEach(() => {
fs.rmSync(tmpDir, { recursive: true, force: true });
});
it('returns manifest', () => {
const manifest: RunManifest = {
runId: 'test',
brief: '/brief.md',
codebase: '',
briefClass: 'strategic',
classSource: 'auto',
forceBoard: false,
createdAt: '2026-01-01T00:00:00Z',
updatedAt: '2026-01-01T00:00:00Z',
currentStage: '00-intake',
status: 'in_progress',
stages: {},
};
saveManifest(tmpDir, manifest);
const status = getPipelineStatus(tmpDir);
expect(status.runId).toBe('test');
expect(status.status).toBe('in_progress');
});
});