459 lines
14 KiB
TypeScript
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');
|
|
});
|
|
});
|