Work packages completed: - WP1: packages/forge — pipeline runner, stage adapter, board tasks, brief classifier, persona loader with project-level overrides. 89 tests, 95.62% coverage. - WP2: packages/macp — credential resolver, gate runner, event emitter, protocol types. 65 tests, 96.24% coverage. Full Python-to-TS port preserving all behavior. - WP3: plugins/mosaic-framework — OC rails injection plugin (before_agent_start + subagent_spawning hooks for Mosaic contract enforcement). - WP4: profiles/ (domains, tech-stacks, workflows), guides/ (17 docs), skills/ (5 universal skills), forge pipeline assets (48 markdown files). Board deliberation: docs/reviews/consolidation-board-memo.md Brief: briefs/monorepo-consolidation.md Consolidates mosaic/stack (forge, MACP, bootstrap framework) into mosaic/mosaic-stack. 154 new tests total. Zero Python — all TypeScript/ESM.
332 lines
9.6 KiB
TypeScript
332 lines
9.6 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, 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<TaskResult> {
|
|
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');
|
|
});
|
|
});
|