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, RunManifest, TaskExecutor } from '../src/types.js'; import type { TaskResult } from '@mosaic/macp'; /** Mock TaskExecutor that records submitted tasks and returns success. */ 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, status: 'failed', completed_at: new Date().toISOString(), exit_code: 1, gate_results: [], }; } return { task_id: taskId, status: 'completed', completed_at: new Date().toISOString(), exit_code: 0, gate_results: [], }; }, 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', '00b-discovery'], }); expect(result.runId).toMatch(/^\d{8}-\d{6}$/); expect(result.stages).toEqual(['00-intake', '00b-discovery']); expect(result.manifest.status).toBe('completed'); 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', '00b-discovery'], }); const manifest = loadManifest(result.runDir); expect(manifest.stages['00-intake']?.status).toBe('passed'); expect(manifest.stages['00b-discovery']?.status).toBe('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', '00b-discovery', '02-planning-1'], }); 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: '00b-discovery' }); await expect( runPipeline(briefPath, tmpDir, { executor, stages: ['00-intake', '00b-discovery'], }), ).rejects.toThrow('Stage 00b-discovery 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', async () => { // First run fails on discovery const executor1 = createMockExecutor({ failStage: '00b-discovery' }); let runDir: string; try { await runPipeline(briefPath, tmpDir, { executor: executor1, stages: ['00-intake', '00b-discovery', '02-planning-1'], }); } catch { // expected } const runsDir = path.join(tmpDir, '.forge', 'runs'); runDir = path.join(runsDir, fs.readdirSync(runsDir)[0]!); // Resume should pick up from 00b-discovery const executor2 = createMockExecutor(); const result = await resumePipeline(runDir, executor2); expect(result.manifest.status).toBe('completed'); // Should have re-run from 00b-discovery onward expect(result.stages[0]).toBe('00b-discovery'); }); }); 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'); }); });