/*
RFC-0566 deploy.artifact.gc — garbage-collect old platform artifacts not referenced by symlinks.
- Do not delete release artifacts — those are managed by artifact.store.gc.
- RFC-0566: initial deploy.artifact.gc handler.
- RFC-1097: step 6 — compass.migrate codemod run
Mechanical v1 to v2 header migration across the workspace: 942 files rewritten — CHANGE_SUMMARY windows collapsed into history, forbidden v1 blocks stripped, KEY_DECISIONS seeded from @ai-invariant comments (5 files) or TODO placeholders (103 files), blocks reordered to canonical order.
- RFC-1097: sweep — werkstatt-engine clean
Sweep batch 4: 73 Compass headers on headerless engine files (certification, component-runtime, isolation, evolution, testing), real KEY_DECISIONS on 75 files (kernel, cache, dht, swim, gitmesh, runtime), ~80 purpose expansions (CONTRACT-02/PURPOSE-02), non-goals on 13 CONTRACT-03 files, CS-07 history literal fix repo-wide (253 files). Policy: .template.ts/.template.astro excludedPaths. werkstatt-engine now 0 diagnostics.
- RFC-1249: wire werkstatt.commands.validate into packages.check + retire CMD-OUTPUT debt
*/
import type {
KernelCommandInput,
KernelCommandResult,
KernelRuntimeContext,
} from "@warpgogol/werkstatt-engine/kernel";
import type { ArtifactGcResult } from "./types.ts";
import {
artifactDir,
currentSymlinkPath,
listArtifactHashes,
previousSymlinkPath,
readSymlinkBasename,
} from "./deploy-utils.ts";
function flagBool(input: KernelCommandInput, key: string): boolean {
const v = input.flags[key];
return v === true || v === "true";
}
function flagNumber(input: KernelCommandInput, key: string, defaultValue: number): number {
const v = input.flags[key];
if (typeof v === "string") {
const n = parseInt(v, 10);
return isNaN(n) ? defaultValue : n;
}
if (typeof v === "number") return v;
return defaultValue;
}
const DEFAULT_RETENTION = 5;
export async function runDeployArtifactGc(
input: KernelCommandInput,
context: KernelRuntimeContext,
): Promise> {
const { workspaceRoot, logger } = context;
const dryRun = flagBool(input, "dry-run");
const retention = flagNumber(input, "retain", DEFAULT_RETENTION);
const currentHash = await readSymlinkBasename(currentSymlinkPath(workspaceRoot), context.io);
const previousHash = await readSymlinkBasename(previousSymlinkPath(workspaceRoot), context.io);
const referenced = new Set();
if (currentHash) referenced.add(currentHash);
if (previousHash) referenced.add(previousHash);
const allHashes = await listArtifactHashes(workspaceRoot);
if (allHashes.length === 0) {
return {
data: { dryRun, examined: 0, deleted: 0, retained: 0, candidates: [] },
exitCode: 0,
summary: "[deploy.artifact.gc] no artifacts found",
};
}
const sortedByTime: Array<{ hash: string; mtime: number }> = [];
for (const hash of allHashes) {
const dir = artifactDir(workspaceRoot, hash);
try {
const stat = await context.io.stat(dir);
sortedByTime.push({ hash, mtime: stat.mtimeMs });
} catch {
// skip
}
}
sortedByTime.sort((a, b) => b.mtime - a.mtime);
const candidates: Array<{ hash: string; reason: string }> = [];
let retained = 0;
let deleted = 0;
for (let i = 0; i < sortedByTime.length; i++) {
const { hash } = sortedByTime[i];
if (referenced.has(hash)) {
retained++;
continue;
}
if (i < retention) {
retained++;
continue;
}
candidates.push({ hash, reason: "unreferenced-beyond-retention" });
}
if (!dryRun) {
for (const candidate of candidates) {
const dir = artifactDir(workspaceRoot, candidate.hash);
try {
await context.io.rm(dir, { recursive: true });
deleted++;
} catch (err) {
logger.error(
`[deploy.artifact.gc] failed to delete ${candidate.hash}: ${(err as Error).message}`,
);
}
}
}
logger.info(
`[deploy.artifact.gc] examined ${allHashes.length}, retained ${retained}, deleted ${deleted}, candidates ${candidates.length}`,
);
return {
data: {
dryRun,
examined: allHashes.length,
deleted,
retained,
candidates,
},
exitCode: 0,
summary: `[deploy.artifact.gc] examined ${allHashes.length}, retained ${retained}, deleted ${deleted}`,
};
}