/*
RFC-0890: nachweis.screenshot.ingest command handler — ingests a raw full-page screenshot to R2 private storage and cache clone local directory.
- Does not crop, resize, or convert the raw screenshot — that belongs to RFC-0891.
- Does not upload a public display variant — use nachweis.screenshot.upload for that.
- RFC-0890: initial nachweis.screenshot.ingest 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-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-1254: drop unused io-migration leftovers
*/
import path from "node:path";
import type {
KernelCommandInput,
KernelCommandResult,
KernelRuntimeContext,
} from "@warpgogol/werkstatt-engine/kernel";
import {
parseMarkdownFrontmatter,
stringifyMarkdownFrontmatter,
} from "@warpgogol/werkstatt-shared/content";
import { appendAndCommitBordbuch } from "../bordbuch/bordbuch-commit-helper.ts";
import { acquireLock, releaseLock, generateOperationId } from "../werkstatt/index.ts";
import {
isNachweisEntitled,
makeSkipResult,
resolveNachweisCachePath,
resolvePbpEntityDir,
resolveDefaultLang,
resolveNachweisRawScreenshotR2Path,
resolveNachweisRawScreenshotLocalPath,
computeSourceSha256,
uploadToR2,
detectImageMetadata,
parseCaptureXFilename,
type NachweisScreenshotIngestResult,
} from "./nachweis-io.ts";
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";
}
export async function runNachweisScreenshotIngest(
input: KernelCommandInput,
context: KernelRuntimeContext,
): Promise> {
const { workspaceRoot, logger } = context;
const systemId = flagString(input, "system") ?? context.site?.name;
const slug = flagString(input, "slug");
const filePath = flagString(input, "file");
const dryRun = context.dryRun === true || flagBool(input, "dry-run");
const capturedAtOverride = flagString(input, "captured-at");
if (!systemId) throw new Error("[nachweis.screenshot.ingest] --system is required");
if (!slug) throw new Error("[nachweis.screenshot.ingest] --slug is required");
if (!filePath) throw new Error("[nachweis.screenshot.ingest] --file is required");
const entitled = await isNachweisEntitled(workspaceRoot, systemId);
if (!entitled) {
return makeSkipResult(
"nachweis.screenshot.ingest",
systemId,
) as unknown as KernelCommandResult;
}
const cachePath = await resolveNachweisCachePath(workspaceRoot, systemId);
const lang = await resolveDefaultLang(cachePath);
const evidenceDir = resolvePbpEntityDir(cachePath, lang, "evidence-source");
const evidenceFile = path.join(evidenceDir, `${slug}.md`);
if (!(await context.io.exists(evidenceFile))) {
throw new Error(
`[nachweis.screenshot.ingest] NOT_FOUND: evidence-source '${slug}' not found at ${evidenceFile}`,
);
}
if (!(await context.io.exists(filePath))) {
throw new Error(`[nachweis.screenshot.ingest] NOT_FOUND: file '${filePath}' not found`);
}
const originalFilename = path.basename(filePath);
const sha256 = await computeSourceSha256(filePath);
const rawEvidence = await context.io.readFile(evidenceFile);
const { data: evidenceData } = parseMarkdownFrontmatter(rawEvidence);
const existingScreenshot = evidenceData.websiteScreenshot as
| {
rawArtifact?: {
sha256?: string;
mediaType?: string;
originalFilename?: string;
width?: number;
height?: number;
r2Key?: string;
localPath?: string;
capturedAt?: string;
};
}
| undefined;
const existingRawSha = existingScreenshot?.rawArtifact?.sha256;
if (existingRawSha === sha256) {
logger.info(
`[nachweis.screenshot.ingest] '${slug}' already ingested with same SHA-256 — skipping`,
);
const existingRaw = existingScreenshot!.rawArtifact!;
return {
data: {
slug,
systemId,
sha256,
mediaType: existingRaw.mediaType as string,
originalFilename: existingRaw.originalFilename as string,
width: existingRaw.width as number,
height: existingRaw.height as number,
capturedAt: (existingRaw.capturedAt as string | undefined) ?? null,
r2Key: (existingRaw.r2Key as string | undefined) ?? "",
localPath: (existingRaw.localPath as string | undefined) ?? "",
bordbuchEventId: "",
alreadyIngested: true,
},
exitCode: 0,
summary: `[nachweis.screenshot.ingest] ${systemId}: '${slug}' already ingested (SHA-256 match, skipped)`,
};
}
const { mediaType, width, height } = await detectImageMetadata(filePath);
let capturedAt: string | null = null;
if (capturedAtOverride) {
capturedAt = capturedAtOverride;
} else {
const parsed = parseCaptureXFilename(originalFilename);
if (parsed) capturedAt = parsed.capturedAt;
}
const r2Key = resolveNachweisRawScreenshotR2Path(systemId, slug, originalFilename);
const localPath = resolveNachweisRawScreenshotLocalPath(cachePath, slug, originalFilename);
if (dryRun) {
return {
data: {
slug,
systemId,
sha256,
mediaType,
originalFilename,
width,
height,
capturedAt,
r2Key,
localPath,
bordbuchEventId: "",
alreadyIngested: false,
},
exitCode: 0,
summary: `[nachweis.screenshot.ingest] ${systemId}: DRY RUN — would ingest raw screenshot for '${slug}' to ${r2Key}`,
};
}
const fileBuffer = await context.io.readFileBytes(filePath);
await uploadToR2(fileBuffer, r2Key, mediaType);
await context.io.mkdir(path.dirname(localPath));
await context.io.copyFile(filePath, localPath);
const { data: currentEvidence, content: evidenceContent } = parseMarkdownFrontmatter(
await context.io.readFile(evidenceFile),
);
const currentScreenshot = (currentEvidence.websiteScreenshot ?? {}) as Record;
currentEvidence.websiteScreenshot = {
...currentScreenshot,
rawArtifact: {
sha256,
mediaType,
originalFilename,
width,
height,
r2Key,
localPath,
...(capturedAt ? { capturedAt } : {}),
},
};
const updatedContent = stringifyMarkdownFrontmatter(evidenceContent, currentEvidence);
await context.io.writeFile(evidenceFile, updatedContent);
logger.info(`[nachweis.screenshot.ingest] ingested raw screenshot for '${slug}' to ${r2Key}`);
const operationId = generateOperationId();
await acquireLock(
workspaceRoot,
`system:${systemId}`,
operationId,
"nachweis.screenshot.ingest",
"agent",
);
await acquireLock(
workspaceRoot,
`bordbuch:${systemId}`,
operationId,
"nachweis.screenshot.ingest",
"agent",
);
let bordbuchEventId: string;
try {
const { entry } = await appendAndCommitBordbuch(
workspaceRoot,
systemId,
"nachweis-record",
`Raw screenshot ingested for '${slug}'`,
"agent",
{
writerRole: "nachweis",
metadata: {
slug,
rawScreenshotSha256: sha256,
mediaType,
originalFilename,
width,
height,
},
},
`Bordbuch: nachweis-record ${systemId} ${slug} raw-screenshot`,
);
bordbuchEventId = entry.id;
} finally {
await releaseLock(workspaceRoot, `bordbuch:${systemId}`);
await releaseLock(workspaceRoot, `system:${systemId}`);
}
return {
data: {
slug,
systemId,
sha256,
mediaType,
originalFilename,
width,
height,
capturedAt,
r2Key,
localPath,
bordbuchEventId,
alreadyIngested: false,
},
exitCode: 0,
summary: `[nachweis.screenshot.ingest] ${systemId}: raw screenshot for '${slug}' ingested (bordbuch: ${bordbuchEventId})`,
nextSteps: [
{
action: `Upload the screenshot: pnpm exec werkstatt run nachweis.screenshot.upload --site ${systemId} --slug ${slug}`,
kind: "optional",
},
],
};
}