/** * Artifacts storage for workflow runs. * * Stores progress, usage, and output artifacts at: * /workflows// * * Each run directory contains: * progress.json — WorkflowProgress snapshot * output.md — Final assembled output * steps/.json — Per-step progress and output */ import { mkdirSync, writeFileSync, readFileSync, existsSync, readdirSync, rmSync } from "node:fs"; import { join } from "node:path"; import { getAgentDir } from "../../shared/paths.ts"; import { WORKFLOWS_DIR_NAME, type WorkflowProgress, type StepProgress, } from "./types.ts"; // ── Paths ─────────────────────────────────────────────────────────────────────── /** Root directory for all workflow artifact storage. */ export function workflowsDir(): string { return join(getAgentDir(), WORKFLOWS_DIR_NAME); } /** Directory for a specific run's artifacts. */ export function runDir(runId: string): string { return join(workflowsDir(), runId); } /** Path to the progress snapshot for a run. */ function progressPath(runId: string): string { return join(runDir(runId), "progress.json"); } /** Directory for per-step outputs. */ function stepsDir(runId: string): string { return join(runDir(runId), "steps"); } /** Path to a specific step's output file. */ function stepPath(runId: string, stepId: string): string { return join(stepsDir(runId), `${stepId.replace(/[^a-zA-Z0-9_-]/g, "_")}.json`); } /** Path to the final output file. */ function outputPath(runId: string): string { return join(runDir(runId), "output.md"); } // ── Initialization ────────────────────────────────────────────────────────────── /** * Ensure the artifacts directory structure exists for a run. */ export function ensureRunDir(runId: string): void { const dir = runDir(runId); if (!existsSync(dir)) { mkdirSync(dir, { recursive: true }); } const sdir = stepsDir(runId); if (!existsSync(sdir)) { mkdirSync(sdir, { recursive: true }); } } // ── Progress Persistence ──────────────────────────────────────────────────────── /** * Save the current WorkflowProgress snapshot. */ export function saveProgress(runId: string, progress: WorkflowProgress): void { ensureRunDir(runId); writeFileSync(progressPath(runId), JSON.stringify(progress, null, 2), "utf-8"); } /** * Load a WorkflowProgress snapshot, or null if not found. */ export function loadProgress(runId: string): WorkflowProgress | null { const pp = progressPath(runId); if (!existsSync(pp)) return null; try { return JSON.parse(readFileSync(pp, "utf-8")) as WorkflowProgress; } catch { return null; } } /** * Save per-step progress (including output). */ export function saveStepProgress(runId: string, step: StepProgress): void { ensureRunDir(runId); writeFileSync(stepPath(runId, step.stepId), JSON.stringify(step, null, 2), "utf-8"); } /** * Load per-step progress, or null if not found. */ export function loadStepProgress(runId: string, stepId: string): StepProgress | null { const sp = stepPath(runId, stepId); if (!existsSync(sp)) return null; try { return JSON.parse(readFileSync(sp, "utf-8")) as StepProgress; } catch { return null; } } // ── Output Persistence ────────────────────────────────────────────────────────── /** * Save the final assembled workflow output. */ export function saveOutput(runId: string, output: string): void { ensureRunDir(runId); writeFileSync(outputPath(runId), output, "utf-8"); } /** * Load the final assembled output, or null if not found. */ export function loadOutput(runId: string): string | null { const op = outputPath(runId); if (!existsSync(op)) return null; try { return readFileSync(op, "utf-8"); } catch { return null; } } // ── Run Listing ───────────────────────────────────────────────────────────────── /** * List all workflow run IDs sorted by most recent first. */ export function listRuns(): string[] { const wd = workflowsDir(); if (!existsSync(wd)) return []; try { return readdirSync(wd, { withFileTypes: true }) .filter((entry) => entry.isDirectory()) .map((entry) => entry.name) .sort((a, b) => b.localeCompare(a)); } catch { return []; } } /** * List runs with their progress statuses. */ export function listRunsWithStatus(): { runId: string; progress: WorkflowProgress | null }[] { return listRuns().map((runId) => ({ runId, progress: loadProgress(runId), })); } // ── Cleanup ───────────────────────────────────────────────────────────────────── /** * Delete all artifacts for a specific run. */ export function deleteRun(runId: string): void { const dir = runDir(runId); if (existsSync(dir)) { try { rmSync(dir, { recursive: true, force: true }); } catch { // Best effort } } } /** * Delete all workflow artifacts (used on full cleanup). */ export function deleteAllRuns(): void { const wd = workflowsDir(); if (existsSync(wd)) { try { rmSync(wd, { recursive: true, force: true }); } catch { // Best effort } } } /** * Delete completed/failed/cancelled runs older than maxAgeMs. * Returns the number of runs deleted. */ export function cleanupOldRuns(maxAgeMs: number = 24 * 60 * 60 * 1000): number { const now = Date.now(); let deleted = 0; for (const runId of listRuns()) { const progress = loadProgress(runId); if (!progress) { // No progress file — stale, clean up deleteRun(runId); deleted++; continue; } if ( (progress.status === "completed" || progress.status === "failed" || progress.status === "cancelled") && progress.finishedAt !== null && now - progress.finishedAt > maxAgeMs ) { deleteRun(runId); deleted++; } } return deleted; }