/*
evidence.sync command handler — uploads evidence artifacts from missions/{mission}/evidence/axiom/ to R2 (RFC-0651).
- Does not integrate with mission.close or leitstand.dev-deploy — that is RFC-0652.
- Does not implement content-addressed deduplication — rejected for simplicity (RFC-0650). The RFC-1202 upload manifest is a per-prefix completion marker, not a dedup index: keys stay timestamped and same-key re-uploads remain byte-identical overwrites.
- Does not support partial sync or resumable uploads.
- RFC-1149: migrate ambient filesystem and process IO to the WorkspaceIO port across engine and shared
- RFC-1190: restore mission context + evidence archival in release-mode deploys
RFC-1190: precedence-ordered resolveMissionContext (--mission → release build-identity → currentMission), MISSION_CONTEXT_UNRESOLVED/MISMATCH blocking, fail-closed EVIDENCE_SYNC_FAILED with --skip-evidence-sync as sole escape hatch, journaled evidence-sync-* ship steps after each certify-*, evidence.sync --site/--system flag.
2026-10-04 handoff fixes: shared 25min mission.check timeout (inner 5min race + 300s orchestrator raced cold ~7-11min captures), sweep excludeOpIds so mission.close no longer self-abandons the live ship journal (broke --resume), name-keyed journal resume state so closed-mission plan shifts can't skip unexecuted steps, wrangler dev documented as the required --base-url preview server.
- RFC-1237: resolve dry-run via context.dryRun fallback — consumeCommonFlags strips --dry-run into context.dryRun before dispatch, so a flags-only read was permanently false under werkstatt run (enforced by KERNEL-FLAG-10).
- RFC-1249: wire werkstatt.commands.validate into packages.check + retire CMD-OUTPUT debt
- RFC-1324: cold-run defect — resolveSystemId split on bare "-m" cut `e2e-valid-minimal-m000001` to `e2e-valid`, then SYSTEM_MISMATCH blocked evidence.sync after a closed mission (currentMission scan misses post-close). Shared resolveSystemIdForMission now reads mission.yaml first.
ADR-0025, RFC-0651, RFC-1097, RFC-1190, RFC-1202
*/
import path from "node:path";
import type {
KernelCommandInput,
KernelCommandResult,
KernelRuntimeContext,
} from "@warpgogol/werkstatt-engine/kernel";
import { resolveMissionDir } from "@warpgogol/werkstatt-engine/kernel";
import { collectFiles } from "@warpgogol/werkstatt-shared/node/fs";
import { resolveSystemIdForMission } from "./resolve-system-id.ts";
import { createR2Client, resolveR2ConfigFromEnv, MissingEnvError } from "./r2-client.ts";
import type { WorkspaceIO } from "@warpgogol/werkstatt-shared/kernel/workspace-io";
export interface EvidenceSyncResult {
missionId: string;
systemId: string;
runTimestamp: string;
r2KeyPrefix: string;
uploadedFiles: string[];
skippedFiles: string[];
deduped: boolean;
totalBytes: number;
durationMs: number;
}
// RFC-1202: completion marker written strictly after the last successful
// upload. Sibling of the run prefix ({ts}.upload-manifest.json), never inside
// it — evidence.fetch downloads every key under runPrefix, so an in-prefix
// marker would pollute downloadedFiles and file counts.
export interface EvidenceUploadManifest {
schemaVersion: 1;
missionId: string;
runTimestamp: string;
fileCount: number;
totalBytes: number;
files: string[]; // relative paths, sorted
completedAt: string;
}
const UPLOAD_MANIFEST_SUFFIX = ".upload-manifest.json";
function uploadManifestKey(r2KeyPrefix: string): string {
return `${r2KeyPrefix.replace(/\/$/, "")}${UPLOAD_MANIFEST_SUFFIX}`;
}
type R2Client = ReturnType;
// Deliberately broad catch — NoSuchKey, network errors, and corrupt JSON all
// mean "no usable manifest": the upload proceeds. Evidence integrity is never
// traded for the optimization.
async function readUploadManifest(
client: R2Client,
key: string,
): Promise {
try {
const result = await client.getObject(key);
const parsed = JSON.parse(new TextDecoder().decode(result.body)) as EvidenceUploadManifest;
if (!parsed || !Array.isArray(parsed.files) || parsed.schemaVersion !== 1) return null;
return parsed;
} catch {
return null;
}
}
function manifestMatches(manifest: EvidenceUploadManifest, relativeFiles: string[]): boolean {
const local = [...relativeFiles].sort();
const stored = [...manifest.files].sort();
return local.length === stored.length && local.every((f, i) => f === stored[i]);
}
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";
}
async function readEvidenceMetadata(
evidenceDir: string,
missionId: string,
io: WorkspaceIO,
): Promise {
const metaPath = path.join(evidenceDir, "evidence-metadata.json");
if (!(await io.exists(metaPath))) {
throw new Error(
`[evidence.sync] INVALID_EVIDENCE: evidence-metadata.json not found for mission '${missionId}' — run mission.check first`,
);
}
let raw: string;
try {
raw = await io.readFile(metaPath);
} catch (err) {
throw new Error(
`[evidence.sync] INVALID_EVIDENCE: cannot read evidence-metadata.json for mission '${missionId}': ${err}`,
);
}
let parsed: EvidenceMetadata;
try {
parsed = JSON.parse(raw) as EvidenceMetadata;
} catch {
throw new Error(
`[evidence.sync] INVALID_EVIDENCE: evidence-metadata.json is not valid JSON for mission '${missionId}'`,
);
}
if (!parsed.runTimestamp) {
throw new Error(
`[evidence.sync] INVALID_EVIDENCE: evidence-metadata.json missing runTimestamp field for mission '${missionId}' — run mission.check first`,
);
}
return parsed;
}
export async function runEvidenceSync(
input: KernelCommandInput,
context: KernelRuntimeContext,
): Promise> {
const { workspaceRoot } = context;
const startTime = Date.now();
const missionId = flagString(input, "mission");
if (!missionId) {
throw new Error("evidence.sync requires --mission ");
}
const dryRun = context.dryRun === true || flagBool(input, "dry-run");
const explicitRunTimestamp = flagString(input, "run-timestamp");
const missionDir = await resolveMissionDir(workspaceRoot, missionId, context.io);
const evidenceDir = path.join(missionDir, "evidence", "axiom");
if (!(await context.io.exists(evidenceDir))) {
throw new Error(
`[evidence.sync] NOT_FOUND: evidence/axiom/ directory not found for mission '${missionId}'`,
);
}
const metadata = await readEvidenceMetadata(evidenceDir, missionId, context.io);
const runTimestamp = explicitRunTimestamp ?? metadata.runTimestamp!;
// RFC-1190: --site makes the target Sternsystem explicit (ship passes it).
// When absent, fall back to the currentMission scan / missionId prefix. A
// flag that contradicts the resolved system is a hard error, not a silent
// override — an R2 prefix under the wrong system corrupts the archive layout.
const siteFlag = flagString(input, "site") ?? flagString(input, "system");
const resolvedSystemId = await resolveSystemIdForMission(workspaceRoot, missionId, context.io);
if (siteFlag && siteFlag !== resolvedSystemId) {
throw new Error(
`[evidence.sync] SYSTEM_MISMATCH: --site '${siteFlag}' contradicts resolved systemId '${resolvedSystemId}' for mission '${missionId}'`,
);
}
const systemId = siteFlag ?? resolvedSystemId;
const r2KeyPrefix = `${systemId}/${missionId}/${runTimestamp}/`;
const allFiles = await collectFiles(evidenceDir);
const relativeFiles = allFiles.map((f) => path.relative(evidenceDir, f).replace(/\\/g, "/"));
const uploadedFiles: string[] = [];
const skippedFiles: string[] = [];
let totalBytes = 0;
const manifestKey = uploadManifestKey(r2KeyPrefix);
const dedupedResult = (dry: boolean): KernelCommandResult => {
const skipped = [...relativeFiles].sort();
return {
data: {
missionId,
systemId,
runTimestamp,
r2KeyPrefix,
uploadedFiles: [],
skippedFiles: skipped,
deduped: true,
totalBytes: 0,
durationMs: Date.now() - startTime,
},
exitCode: 0,
summary: dry
? `[evidence.sync] ${missionId}: dry-run — already archived, ${skipped.length} files would be skipped (manifest match at ${r2KeyPrefix})`
: `[evidence.sync] ${missionId}: already archived — ${skipped.length} files skipped (manifest match at ${r2KeyPrefix})`,
};
};
if (!dryRun) {
let r2Config;
try {
r2Config = resolveR2ConfigFromEnv("axiom-evidence", "R2_AXIOM");
} catch (err) {
if (err instanceof MissingEnvError) {
return {
exitCode: 1,
summary: `[evidence.sync] MISSING_ENV: ${err.message}`,
nextSteps: [
{ action: `[evidence.sync] MISSING_ENV: ${err.message}`, kind: "required" as const },
],
data: {
missionId,
systemId,
runTimestamp,
r2KeyPrefix,
uploadedFiles: [],
skippedFiles: [],
deduped: false,
totalBytes: 0,
durationMs: Date.now() - startTime,
},
};
}
throw err;
}
const client = createR2Client(r2Config);
// RFC-1202: a matching completion manifest means the prefix is already
// archived — one small read replaces the whole upload.
const manifest = await readUploadManifest(client, manifestKey);
if (manifest && manifestMatches(manifest, relativeFiles)) {
return dedupedResult(false);
}
context.logger.info(
`[evidence.sync] uploading ${relativeFiles.length} files to R2 prefix ${r2KeyPrefix}`,
);
// ADR-0025: progress heartbeat — log every 30s during silent upload loops.
let lastProgressIndex = 0;
const heartbeat = setInterval(() => {
const elapsed = ((Date.now() - startTime) / 1000).toFixed(0);
context.logger.info(
`[evidence.sync] still uploading — ${uploadedFiles.length}/${relativeFiles.length} files done (${elapsed}s elapsed)`,
);
lastProgressIndex = uploadedFiles.length;
}, 30_000);
const CONCURRENCY = 10;
let uploadFailed = false;
let firstError: { relPath: string; err: unknown } | null = null;
try {
for (let i = 0; i < relativeFiles.length && !uploadFailed; i += CONCURRENCY) {
const batch = relativeFiles.slice(i, i + CONCURRENCY);
const results = await Promise.allSettled(
batch.map(async (relPath) => {
const fullPath = path.join(evidenceDir, relPath);
const body = await context.io.readFileBytes(fullPath);
totalBytes += body.byteLength;
await client.putObject({
key: r2KeyPrefix + relPath,
body: new Uint8Array(body),
});
return relPath;
}),
);
for (let j = 0; j < results.length; j++) {
const result = results[j];
if (result.status === "fulfilled") {
uploadedFiles.push(result.value);
} else {
uploadFailed = true;
if (!firstError) {
firstError = { relPath: batch[j], err: result.reason };
}
}
}
}
} finally {
clearInterval(heartbeat);
if (!uploadFailed && uploadedFiles.length > lastProgressIndex) {
context.logger.info(
`[evidence.sync] upload loop complete — ${uploadedFiles.length}/${relativeFiles.length} files`,
);
}
}
if (uploadFailed && firstError) {
return {
exitCode: 1,
summary: `[evidence.sync] R2_UPLOAD_ERROR: failed to upload '${firstError.relPath}': ${firstError.err}`,
nextSteps: [
{
action: `[evidence.sync] R2_UPLOAD_ERROR: failed to upload '${firstError.relPath}': ${firstError.err}`,
kind: "required" as const,
},
],
data: {
missionId,
systemId,
runTimestamp,
r2KeyPrefix,
uploadedFiles,
skippedFiles,
deduped: false,
totalBytes,
durationMs: Date.now() - startTime,
},
};
}
// RFC-1202: manifest written strictly after the loop — a prefix is complete
// only when this object exists. A failed write degrades to one extra
// upload next run; it never falsifies archival state.
const manifestPayload: EvidenceUploadManifest = {
schemaVersion: 1,
missionId,
runTimestamp,
fileCount: relativeFiles.length,
totalBytes,
files: [...relativeFiles].sort(),
completedAt: new Date().toISOString(),
};
try {
await client.putObject({
key: manifestKey,
body: new TextEncoder().encode(JSON.stringify(manifestPayload, null, 2) + "\n"),
contentType: "application/json",
});
} catch (err) {
context.logger.warn(
`[evidence.sync] upload manifest write failed (next run re-uploads): ${err instanceof Error ? err.message : String(err)}`,
);
}
} else {
// RFC-1202: dry-run performs the manifest check when R2 env resolves;
// MissingEnvError degrades to the plain listing (today's behavior).
try {
const dryR2Config = resolveR2ConfigFromEnv("axiom-evidence", "R2_AXIOM");
const dryClient = createR2Client(dryR2Config);
const manifest = await readUploadManifest(dryClient, manifestKey);
if (manifest && manifestMatches(manifest, relativeFiles)) {
return dedupedResult(true);
}
} catch (err) {
if (!(err instanceof MissingEnvError)) throw err;
}
for (const relPath of relativeFiles) {
const fullPath = path.join(evidenceDir, relPath);
const stat = await context.io.stat(fullPath);
totalBytes += stat.size;
uploadedFiles.push(relPath);
}
}
const durationMs = Date.now() - startTime;
const sizeMb = (totalBytes / (1024 * 1024)).toFixed(1);
const summary = dryRun
? `[evidence.sync] ${missionId}: dry-run — ${uploadedFiles.length} files (${sizeMb} MB) would be uploaded to ${r2KeyPrefix}`
: `[evidence.sync] ${missionId}: uploaded ${uploadedFiles.length} files (${sizeMb} MB) to R2 in ${(durationMs / 1000).toFixed(1)}s`;
return {
data: {
missionId,
systemId,
runTimestamp,
r2KeyPrefix,
uploadedFiles,
skippedFiles,
deduped: false,
totalBytes,
durationMs,
},
exitCode: 0,
summary,
nextSteps: dryRun
? [
{
action: `Upload for real: pnpm exec werkstatt run evidence.sync --mission ${missionId}`,
kind: "optional",
},
]
: undefined,
};
}