import fs from 'node:fs'; import path from 'node:path'; import { STAGE_SEQUENCE } from './constants.js'; import { determineBriefClass, stagesForClass } from './brief-classifier.js'; import { mapStageToTask } from './stage-adapter.js'; import type { ForgeTask, PipelineOptions, PipelineResult, RunManifest, StageStatus, TaskExecutor, } from './types.js'; /** * Generate a timestamp-based run ID. */ export function generateRunId(): string { const now = new Date(); const pad = (n: number, w = 2) => String(n).padStart(w, '0'); return [ now.getUTCFullYear(), pad(now.getUTCMonth() + 1), pad(now.getUTCDate()), '-', pad(now.getUTCHours()), pad(now.getUTCMinutes()), pad(now.getUTCSeconds()), ].join(''); } /** * Get the ISO timestamp for now. */ function nowISO(): string { return new Date().toISOString(); } /** * Create and persist a run manifest. */ function createManifest(opts: { runId: string; briefPath: string; codebase: string; briefClass: RunManifest['briefClass']; classSource: RunManifest['classSource']; forceBoard: boolean; runDir: string; }): RunManifest { const ts = nowISO(); const manifest: RunManifest = { runId: opts.runId, brief: opts.briefPath, codebase: opts.codebase, briefClass: opts.briefClass, classSource: opts.classSource, forceBoard: opts.forceBoard, createdAt: ts, updatedAt: ts, currentStage: '', status: 'in_progress', stages: {}, }; saveManifest(opts.runDir, manifest); return manifest; } /** * Save a manifest to disk. */ export function saveManifest(runDir: string, manifest: RunManifest): void { manifest.updatedAt = nowISO(); const manifestPath = path.join(runDir, 'manifest.json'); fs.mkdirSync(path.dirname(manifestPath), { recursive: true }); fs.writeFileSync(manifestPath, JSON.stringify(manifest, null, 2) + '\n', 'utf-8'); } /** * Load a manifest from disk. */ export function loadManifest(runDir: string): RunManifest { const manifestPath = path.join(runDir, 'manifest.json'); if (!fs.existsSync(manifestPath)) { throw new Error(`manifest.json not found: ${manifestPath}`); } return JSON.parse(fs.readFileSync(manifestPath, 'utf-8')) as RunManifest; } /** * Select and validate stages, optionally skipping to a specific stage. */ export function selectStages(stages?: string[], skipTo?: string): string[] { const selected = stages ?? [...STAGE_SEQUENCE]; const unknown = selected.filter((s) => !STAGE_SEQUENCE.includes(s)); if (unknown.length > 0) { throw new Error(`Unknown Forge stages requested: ${unknown.join(', ')}`); } if (!skipTo) return selected; if (!selected.includes(skipTo)) { throw new Error(`skip_to stage '${skipTo}' is not present in the selected stage list`); } const skipIndex = selected.indexOf(skipTo); return selected.slice(skipIndex); } /** * Run the Forge pipeline. * * 1. Classify the brief * 2. Generate a run ID and create run directory * 3. Map stages to tasks and submit to TaskExecutor * 4. Track manifest with stage statuses * 5. Return pipeline result */ export async function runPipeline( briefPath: string, projectRoot: string, options: PipelineOptions, ): Promise { const resolvedRoot = path.resolve(projectRoot); const resolvedBrief = path.resolve(briefPath); const briefContent = fs.readFileSync(resolvedBrief, 'utf-8'); // Classify brief const { briefClass, classSource } = determineBriefClass(briefContent, options.briefClass); // Determine stages const classStages = options.stages ?? stagesForClass(briefClass, options.forceBoard); const selectedStages = selectStages(classStages, options.skipTo); // Create run directory const runId = generateRunId(); const runDir = path.join(resolvedRoot, '.forge', 'runs', runId); fs.mkdirSync(runDir, { recursive: true }); // Create manifest const manifest = createManifest({ runId, briefPath: resolvedBrief, codebase: options.codebase ?? '', briefClass, classSource, forceBoard: options.forceBoard ?? false, runDir, }); // Map stages to tasks const tasks: ForgeTask[] = []; for (let i = 0; i < selectedStages.length; i++) { const stageName = selectedStages[i]!; const task = mapStageToTask({ stageName, briefContent, projectRoot: resolvedRoot, runId, runDir, }); // Override dependency chain for selected (possibly filtered) stages if (i > 0) { task.dependsOn = [tasks[i - 1]!.id]; } else { delete task.dependsOn; } tasks.push(task); } // Execute stages const { executor } = options; for (let i = 0; i < tasks.length; i++) { const task = tasks[i]!; const stageName = selectedStages[i]!; // Update manifest: stage in progress manifest.currentStage = stageName; manifest.stages[stageName] = { status: 'in_progress', startedAt: nowISO(), }; saveManifest(runDir, manifest); try { await executor.submitTask(task); const result = await executor.waitForCompletion(task.id, task.timeoutSeconds * 1000); // Update manifest: stage completed or failed const stageStatus: StageStatus = { status: result.status === 'completed' ? 'passed' : 'failed', startedAt: manifest.stages[stageName]!.startedAt, completedAt: nowISO(), }; manifest.stages[stageName] = stageStatus; if (result.status !== 'completed') { manifest.status = 'failed'; saveManifest(runDir, manifest); throw new Error(`Stage ${stageName} failed with status: ${result.status}`); } saveManifest(runDir, manifest); } catch (error) { if (!manifest.stages[stageName]?.completedAt) { manifest.stages[stageName] = { status: 'failed', startedAt: manifest.stages[stageName]?.startedAt, completedAt: nowISO(), }; } manifest.status = 'failed'; saveManifest(runDir, manifest); throw error; } } // All stages passed manifest.status = 'completed'; saveManifest(runDir, manifest); return { runId, briefPath: resolvedBrief, projectRoot: resolvedRoot, runDir, taskIds: tasks.map((t) => t.id), stages: selectedStages, manifest, }; } /** * Resume a pipeline from the last incomplete stage. */ export async function resumePipeline( runDir: string, executor: TaskExecutor, ): Promise { const manifest = loadManifest(runDir); const resolvedRoot = path.dirname(path.dirname(path.dirname(runDir))); // .forge/runs/{id} → project root const briefContent = fs.readFileSync(manifest.brief, 'utf-8'); const allStages = stagesForClass(manifest.briefClass, manifest.forceBoard); // Find first non-passed stage const resumeFrom = allStages.find((s) => manifest.stages[s]?.status !== 'passed'); if (!resumeFrom) { manifest.status = 'completed'; saveManifest(runDir, manifest); return { runId: manifest.runId, briefPath: manifest.brief, projectRoot: resolvedRoot, runDir, taskIds: [], stages: allStages, manifest, }; } const remainingStages = selectStages(allStages, resumeFrom); manifest.status = 'in_progress'; const tasks: ForgeTask[] = []; for (let i = 0; i < remainingStages.length; i++) { const stageName = remainingStages[i]!; const task = mapStageToTask({ stageName, briefContent, projectRoot: resolvedRoot, runId: manifest.runId, runDir, }); if (i > 0) { task.dependsOn = [tasks[i - 1]!.id]; } else { delete task.dependsOn; } tasks.push(task); } for (let i = 0; i < tasks.length; i++) { const task = tasks[i]!; const stageName = remainingStages[i]!; manifest.currentStage = stageName; manifest.stages[stageName] = { status: 'in_progress', startedAt: nowISO(), }; saveManifest(runDir, manifest); try { await executor.submitTask(task); const result = await executor.waitForCompletion(task.id, task.timeoutSeconds * 1000); manifest.stages[stageName] = { status: result.status === 'completed' ? 'passed' : 'failed', startedAt: manifest.stages[stageName]!.startedAt, completedAt: nowISO(), }; if (result.status !== 'completed') { manifest.status = 'failed'; saveManifest(runDir, manifest); throw new Error(`Stage ${stageName} failed with status: ${result.status}`); } saveManifest(runDir, manifest); } catch (error) { if (!manifest.stages[stageName]?.completedAt) { manifest.stages[stageName] = { status: 'failed', startedAt: manifest.stages[stageName]?.startedAt, completedAt: nowISO(), }; } manifest.status = 'failed'; saveManifest(runDir, manifest); throw error; } } manifest.status = 'completed'; saveManifest(runDir, manifest); return { runId: manifest.runId, briefPath: manifest.brief, projectRoot: resolvedRoot, runDir, taskIds: tasks.map((t) => t.id), stages: remainingStages, manifest, }; } /** * Get the status of a pipeline run. */ export function getPipelineStatus(runDir: string): RunManifest { return loadManifest(runDir); }