/*
evidence.fetch command handler — downloads historical evidence runs from R2 and lists available runs via ListObjectsV2 (RFC-0651).
- Does not implement Iceberg REST catalog listing — ListObjectsV2 is the primary mechanism.
- Does not support partial fetch or resumable downloads.
- RFC-0651: initial evidence.fetch command 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
- RFC-1324: share resolveSystemIdForMission — drops the duplicated `split("-m")[0]` derivation that broke on mid-slug "-m" system ids.
*/
import path from "node:path";
import type {
KernelCommandInput,
KernelCommandResult,
KernelRuntimeContext,
} from "@warpgogol/werkstatt-engine/kernel";
import { resolveSystemIdForMission } from "./resolve-system-id.ts";
import { createR2Client, resolveR2ConfigFromEnv, MissingEnvError } from "./r2-client.ts";
export interface EvidenceFetchResult {
missionId: string;
runTimestamp: string;
r2KeyPrefix: string;
downloadedFiles: string[];
totalBytes: number;
outputDir: string;
}
export interface EvidenceListResult {
missionId: string;
runs: Array<{
runTimestamp: string;
commitSha: string | null;
r2KeyPrefix: string;
}>;
}
interface EvidenceMetadata {
auditId?: string;
runTimestamp?: string;
commitSha?: string;
}
function flagString(input: KernelCommandInput, key: string): string | undefined {
const v = input.flags[key];
return typeof v === "string" ? v : undefined;
}
function flagBool(input: KernelCommandInput, key: string): boolean {
const v = input.flags[key];
return v === true || v === "true";
}
function parseRunTimestampFromKey(key: string, prefix: string): string | null {
const afterPrefix = key.slice(prefix.length);
const slashIdx = afterPrefix.indexOf("/");
if (slashIdx === -1) return null;
return afterPrefix.slice(0, slashIdx);
}
export async function runEvidenceFetch(
input: KernelCommandInput,
context: KernelRuntimeContext,
): Promise> {
const { workspaceRoot } = context;
const missionId = flagString(input, "mission");
if (!missionId) {
throw new Error("evidence.fetch requires --mission ");
}
const listMode = flagBool(input, "list");
const noRaw = flagBool(input, "no-raw");
const runTimestamp = flagString(input, "run-timestamp");
const outputDir = flagString(input, "output-dir");
let r2Config;
try {
r2Config = resolveR2ConfigFromEnv("axiom-evidence", "R2_AXIOM");
} catch (err) {
if (err instanceof MissingEnvError) {
return {
exitCode: 1,
summary: `[evidence.fetch] MISSING_ENV: ${err.message}`,
nextSteps: [
{ action: `[evidence.fetch] MISSING_ENV: ${err.message}`, kind: "required" as const },
],
data: { missionId, runs: [] },
};
}
throw err;
}
const systemId = await resolveSystemIdForMission(workspaceRoot, missionId, context.io);
const client = createR2Client(r2Config);
if (listMode) {
const listPrefix = `${systemId}/${missionId}/`;
let objects: Array<{ key: string; size: number }>;
try {
objects = await client.listObjectsV2(listPrefix);
} catch (err) {
return {
exitCode: 1,
summary: `[evidence.fetch] R2_LIST_ERROR: failed to list objects with prefix '${listPrefix}': ${err}`,
nextSteps: [
{
action: `[evidence.fetch] R2_LIST_ERROR: failed to list objects with prefix '${listPrefix}': ${err}`,
kind: "required" as const,
},
],
data: { missionId, runs: [] },
};
}
const runTimestamps = new Set();
for (const obj of objects) {
const ts = parseRunTimestampFromKey(obj.key, listPrefix);
if (ts) runTimestamps.add(ts);
}
const runs: EvidenceListResult["runs"] = [];
for (const ts of runTimestamps) {
const metaKey = `${listPrefix}${ts}/evidence-metadata.json`;
let commitSha: string | null = null;
try {
const obj = await client.getObject(metaKey);
const text = new TextDecoder().decode(obj.body);
const meta = JSON.parse(text) as EvidenceMetadata;
commitSha = meta.commitSha ?? null;
} catch {
// If evidence-metadata.json is missing or invalid, commitSha stays null
}
runs.push({
runTimestamp: ts,
commitSha,
r2KeyPrefix: `${listPrefix}${ts}/`,
});
}
runs.sort((a, b) => b.runTimestamp.localeCompare(a.runTimestamp));
return {
data: { missionId, runs },
exitCode: 0,
summary: `[evidence.fetch] ${missionId}: ${runs.length} run${runs.length === 1 ? "" : "s"} available`,
nextSteps:
runs.length > 0
? [
{
action: `Download a run: pnpm exec werkstatt run evidence.fetch --mission ${missionId} --run `,
kind: "optional",
},
]
: undefined,
};
}
if (!runTimestamp) {
throw new Error(
"evidence.fetch requires --run-timestamp (or --list to list available runs)",
);
}
if (!outputDir) {
throw new Error("evidence.fetch requires --output-dir when fetching a run");
}
const runPrefix = `${systemId}/${missionId}/${runTimestamp}/`;
let objects: Array<{ key: string; size: number }>;
try {
objects = await client.listObjectsV2(runPrefix);
} catch (err) {
return {
exitCode: 1,
summary: `[evidence.fetch] R2_LIST_ERROR: failed to list objects with prefix '${runPrefix}': ${err}`,
nextSteps: [
{
action: `[evidence.fetch] R2_LIST_ERROR: failed to list objects with prefix '${runPrefix}': ${err}`,
kind: "required" as const,
},
],
data: {
missionId,
runTimestamp,
r2KeyPrefix: runPrefix,
downloadedFiles: [],
totalBytes: 0,
outputDir,
},
};
}
if (objects.length === 0) {
return {
exitCode: 1,
summary: `[evidence.fetch] NOT_FOUND: no evidence found for mission '${missionId}' run '${runTimestamp}'`,
nextSteps: [
{
action: `[evidence.fetch] NOT_FOUND: no evidence found for mission '${missionId}' run '${runTimestamp}'`,
kind: "required" as const,
},
],
data: {
missionId,
runTimestamp,
r2KeyPrefix: runPrefix,
downloadedFiles: [],
totalBytes: 0,
outputDir,
},
};
}
const filteredObjects = noRaw
? objects.filter((obj) => !obj.key.slice(runPrefix.length).startsWith("raw/"))
: objects;
await context.io.mkdir(outputDir);
const downloadedFiles: string[] = [];
let totalBytes = 0;
for (const obj of filteredObjects) {
const relPath = obj.key.slice(runPrefix.length);
if (!relPath) continue;
const destPath = path.join(outputDir, relPath);
await context.io.mkdir(path.dirname(destPath));
try {
const result = await client.getObject(obj.key);
await context.io.writeFile(destPath, result.body);
downloadedFiles.push(relPath);
totalBytes += result.body.byteLength;
} catch (err) {
return {
exitCode: 1,
summary: `[evidence.fetch] R2_DOWNLOAD_ERROR: failed to download '${relPath}': ${err}`,
nextSteps: [
{
action: `[evidence.fetch] R2_DOWNLOAD_ERROR: failed to download '${relPath}': ${err}`,
kind: "required" as const,
},
],
data: {
missionId,
runTimestamp,
r2KeyPrefix: runPrefix,
downloadedFiles,
totalBytes,
outputDir,
},
};
}
}
const sizeMb = (totalBytes / (1024 * 1024)).toFixed(1);
return {
data: {
missionId,
runTimestamp,
r2KeyPrefix: runPrefix,
downloadedFiles,
totalBytes,
outputDir,
},
exitCode: 0,
summary: `[evidence.fetch] ${missionId}: downloaded ${downloadedFiles.length} files (${sizeMb} MB) from R2 to ${outputDir}`,
};
}