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 { 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'); }); });