/* 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}`, }; }