/*
RFC-0480: mission.cleanup — explicit workpiece cleanup with age-based option. RFC-0652: age-based Axiom evidence cleanup. RFC-0985: --stale-dirs mode to archive aborted missions and remove stub directories.
- Does not remove non-Axiom evidence bundles (close-report.json, workpiece.git-bundle) — those are permanent audit artifacts.
- Does not abort missions — use mission.abort for that.
- RFC-0652: replace unconditional evidence preservation with age-based Axiom evidence cleanup; add --evidence-retention-days flag.
- RFC-0985: add --stale-dirs mode to archive aborted missions and remove stub directories.
- RFC-1149: migrate ambient filesystem and process IO to the WorkspaceIO port across engine and shared
- RFC-1249: wire werkstatt.commands.validate into packages.check + retire CMD-OUTPUT debt
- RFC-1254: drop unused io-migration leftovers
RFC-0480
*/
import path from "node:path";
import type {
KernelCommandInput,
KernelCommandResult,
KernelRuntimeContext,
} from "@warpgogol/werkstatt-engine/kernel";
import { readMissionManifest, resolveMissionDir } from "./mission-io.ts";
export interface MissionCleanupData {
missionId: string;
removedPaths: string[];
skipped: string[];
evidenceCleaned: boolean;
evidenceRetentionDays: number;
archived?: string[];
warnings?: string[];
}
function flagString(input: KernelCommandInput, key: string): string | undefined {
const v = input.flags[key];
return typeof v === "string" ? v : undefined;
}
function flagNumber(input: KernelCommandInput, key: string, defaultValue: number): number {
const v = input.flags[key];
if (typeof v === "number") return v;
if (typeof v === "string") {
const n = parseInt(v, 10);
if (!Number.isNaN(n) && n >= 0) return n;
}
return defaultValue;
}
function flagBoolean(input: KernelCommandInput, key: string): boolean {
const v = input.flags[key];
return v === true || v === "true";
}
function parseOlderThan(value: string): number | null {
const match = value.match(/^(\d+)d$/);
if (!match) return null;
return parseInt(match[1], 10) * 24 * 60 * 60 * 1000;
}
export async function runMissionCleanup(
input: KernelCommandInput,
context: KernelRuntimeContext,
): Promise> {
const { workspaceRoot, logger } = context;
const missionId = flagString(input, "mission");
const olderThan = flagString(input, "older-than");
const staleDirs = flagBoolean(input, "stale-dirs");
const evidenceRetentionDays = flagNumber(input, "evidence-retention-days", 30);
if (!missionId && !olderThan && !staleDirs) {
throw new Error(
"[mission.cleanup] either --mission, --older-than, or --stale-dirs is required",
);
}
const removedPaths: string[] = [];
const skipped: string[] = [];
let evidenceCleaned = false;
// --stale-dirs mode: RFC-0985 Measure 6 — archive aborted missions, remove stub dirs
if (staleDirs) {
const missionsDir = path.join(workspaceRoot, "missions");
if (!(await context.io.exists(missionsDir))) {
return {
data: {
missionId: "*",
removedPaths,
skipped,
evidenceCleaned,
evidenceRetentionDays,
archived: [],
warnings: [],
},
exitCode: 0,
summary: `[mission.cleanup] no missions directory found`,
nextSteps: [],
};
}
const archived: string[] = [];
const warnings: string[] = [];
const entries = await context.io.readdir(missionsDir);
for (const entry of entries) {
if (entry.name === "archive") continue;
const missionDir = path.join(missionsDir, entry.name);
if ((await context.io.exec("test", ["-L", missionDir])).exitCode === 0) continue;
if (!entry.isDirectory) continue;
const missionFile = path.join(missionDir, "mission.yaml");
if (!(await context.io.exists(missionFile))) {
// Stub directory from failed mission.open — remove it
await context.io.rm(missionDir, { recursive: true });
removedPaths.push(`${entry.name} (stub — no mission.yaml)`);
logger.info(` Removed stub directory '${entry.name}'`);
continue;
}
try {
const manifest = await readMissionManifest(workspaceRoot, entry.name);
if (manifest.state === "aborted") {
// Move to archive/aborted/
const archiveDir = path.join(missionsDir, "archive", "aborted", entry.name);
await context.io.mkdir(path.dirname(archiveDir));
try {
await context.io.rename(missionDir, archiveDir);
} catch {
const cpResult = await context.io.exec("cp", ["-r", "-f", missionDir, archiveDir]);
if (cpResult.exitCode !== 0) {
throw new Error(`cp failed for '${entry.name}': ${cpResult.stderr.slice(0, 200)}`);
}
await context.io.rm(missionDir, { recursive: true });
}
archived.push(entry.name);
logger.info(` Archived aborted mission '${entry.name}'`);
} else if (manifest.state === "open") {
warnings.push(`${entry.name} is open — skipped`);
}
} catch {
warnings.push(`${entry.name} — failed to read mission.yaml`);
}
}
return {
data: {
missionId: "*",
removedPaths,
skipped,
evidenceCleaned,
evidenceRetentionDays,
archived,
warnings,
},
exitCode: 0,
summary: `[mission.cleanup] stale-dirs: archived ${archived.length}, removed ${removedPaths.length}, warnings ${warnings.length}`,
nextSteps: [],
};
}
if (missionId) {
const manifest = await readMissionManifest(workspaceRoot, missionId);
if (manifest.state === "open") {
throw new Error(`[mission.cleanup] mission '${missionId}' is open — abort or close it first`);
}
const missionDir = await resolveMissionDir(workspaceRoot, missionId, context.io);
const workpieceDir = path.join(missionDir, "workpiece");
const distributionDir = path.join(missionDir, "distribution");
if (await context.io.exists(workpieceDir)) {
await context.io.rm(workpieceDir, { recursive: true });
removedPaths.push("workpiece");
logger.info(` Removed workpiece for mission '${missionId}'`);
}
if (await context.io.exists(distributionDir)) {
await context.io.rm(distributionDir, { recursive: true });
removedPaths.push("distribution");
logger.info(` Removed distribution for mission '${missionId}'`);
}
// RFC-0652: Age-based Axiom evidence cleanup.
// Non-Axiom evidence (close-report.json, workpiece.git-bundle) is always preserved.
// Only evidence/axiom/ is eligible for cleanup, and only if evidence-metadata.json
// exists with a runTimestamp older than the retention period.
const axiomEvidenceDir = path.join(missionDir, "evidence", "axiom");
if (evidenceRetentionDays === 0) {
skipped.push("evidence/axiom (preserved — retention=0)");
} else if (await context.io.exists(axiomEvidenceDir)) {
const metadataPath = path.join(axiomEvidenceDir, "evidence-metadata.json");
if (!(await context.io.exists(metadataPath))) {
skipped.push("evidence/axiom (no metadata — preserved)");
} else {
try {
const raw = await context.io.readFile(metadataPath);
const meta = JSON.parse(raw) as { runTimestamp?: string };
if (!meta.runTimestamp) {
skipped.push("evidence/axiom (no runTimestamp — preserved)");
} else {
const runTime = new Date(meta.runTimestamp).getTime();
const cutoff = Date.now() - evidenceRetentionDays * 24 * 60 * 60 * 1000;
if (runTime < cutoff) {
await context.io.rm(axiomEvidenceDir, { recursive: true });
removedPaths.push("evidence/axiom");
evidenceCleaned = true;
logger.info(
` Removed evidence/axiom for mission '${missionId}' (older than ${evidenceRetentionDays}d)`,
);
} else {
skipped.push("evidence/axiom (within retention period)");
}
}
} catch (err) {
skipped.push("evidence/axiom (metadata read error — preserved)");
logger.warn(
` Warning: failed to read evidence-metadata.json for mission '${missionId}': ${err instanceof Error ? err.message : String(err)}`,
);
}
}
} else {
skipped.push("evidence/axiom (not present)");
}
return {
data: { missionId, removedPaths, skipped, evidenceCleaned, evidenceRetentionDays },
exitCode: 0,
summary: `[mission.cleanup] ${missionId} removed: ${removedPaths.join(", ") || "nothing"}`,
nextSteps: [
{
action: `Close the mission: pnpm exec werkstatt run mission.close --mission ${missionId}`,
kind: "optional",
},
],
};
}
// --older-than mode: clean workpieces for closed/aborted missions older than threshold
if (olderThan) {
const thresholdMs = parseOlderThan(olderThan);
if (thresholdMs === null) {
throw new Error(`[mission.cleanup] --older-than must be in format 'd' (e.g. '30d')`);
}
const missionsDir = path.join(workspaceRoot, "missions");
if (!(await context.io.exists(missionsDir))) {
return {
data: { missionId: "*", removedPaths, skipped, evidenceCleaned, evidenceRetentionDays },
exitCode: 0,
summary: `[mission.cleanup] no missions directory found`,
nextSteps: [
{
action: `List missions: pnpm exec werkstatt run mission.list`,
kind: "optional",
},
],
};
}
const entries = await context.io.readdir(missionsDir);
const now = Date.now();
for (const entry of entries) {
if (entry.name === "archive") continue;
const missionDir = path.join(missionsDir, entry.name);
if ((await context.io.exec("test", ["-L", missionDir])).exitCode === 0) continue;
if (!entry.isDirectory) continue;
try {
const manifest = await readMissionManifest(workspaceRoot, entry.name);
if (manifest.state === "open") {
skipped.push(`${entry.name} (open)`);
continue;
}
const closedAt = manifest.closedAt;
if (!closedAt) {
skipped.push(`${entry.name} (no closedAt)`);
continue;
}
const ageMs = now - new Date(closedAt).getTime();
if (ageMs < thresholdMs) {
skipped.push(`${entry.name} (age: ${Math.floor(ageMs / (24 * 60 * 60 * 1000))}d)`);
continue;
}
const workpieceDir = path.join(missionDir, "workpiece");
const distributionDir = path.join(missionDir, "distribution");
if (await context.io.exists(workpieceDir)) {
await context.io.rm(workpieceDir, { recursive: true });
removedPaths.push(`${entry.name}/workpiece`);
}
if (await context.io.exists(distributionDir)) {
await context.io.rm(distributionDir, { recursive: true });
removedPaths.push(`${entry.name}/distribution`);
}
// RFC-0652: Age-based Axiom evidence cleanup in --older-than mode
const axiomEvidenceDir = path.join(missionDir, "evidence", "axiom");
if (evidenceRetentionDays > 0 && (await context.io.exists(axiomEvidenceDir))) {
const metadataPath = path.join(axiomEvidenceDir, "evidence-metadata.json");
if (await context.io.exists(metadataPath)) {
try {
const raw = await context.io.readFile(metadataPath);
const meta = JSON.parse(raw) as { runTimestamp?: string };
if (meta.runTimestamp) {
const runTime = new Date(meta.runTimestamp).getTime();
const cutoff = Date.now() - evidenceRetentionDays * 24 * 60 * 60 * 1000;
if (runTime < cutoff) {
await context.io.rm(axiomEvidenceDir, { recursive: true });
removedPaths.push(`${entry.name}/evidence/axiom`);
evidenceCleaned = true;
}
}
} catch {
// metadata read error — preserve evidence
}
}
}
} catch {
skipped.push(`${entry.name} (no manifest)`);
}
}
logger.info(` Cleaned ${removedPaths.length} path(s), skipped ${skipped.length}`);
}
return {
data: { missionId: "*", removedPaths, skipped, evidenceCleaned, evidenceRetentionDays },
exitCode: 0,
summary: `[mission.cleanup] removed: ${removedPaths.length}, skipped: ${skipped.length}`,
nextSteps: [
{
action: `List remaining missions: pnpm exec werkstatt run mission.list`,
kind: "optional",
},
],
};
}