/* RFC-0707: nachweis.ingest command handler — hashes a PDF, uploads to R2, appends Bordbuch entry. Does not create PBP entities — that is a manual content authoring step. Does not implement R2 download or cleanup. 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-1249: wire werkstatt.commands.validate into packages.check + retire CMD-OUTPUT debt RFC-1254: drop unused io-migration leftovers RFC-0707 */ import type { KernelCommandInput, KernelCommandResult, KernelRuntimeContext, } from "@warpgogol/werkstatt-engine/kernel"; import { appendAndCommitBordbuch } from "../bordbuch/bordbuch-commit-helper.ts"; import { acquireLock, releaseLock, generateOperationId } from "../werkstatt/index.ts"; import { computeSourceSha256, generateRecordId, resolveNachweisR2Path, uploadToR2, isMissingEnvError, isNachweisEntitled, makeSkipResult, type NachweisIngestResult, } from "./nachweis-io.ts"; import { failResult } from "@warpgogol/werkstatt-shared/checks/result-helpers"; 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 runNachweisIngest( input: KernelCommandInput, context: KernelRuntimeContext, ): Promise> { const { workspaceRoot, logger } = context; const systemId = flagString(input, "system") ?? context.site?.name; const filePath = flagString(input, "file"); const recordType = flagString(input, "record-type"); const slug = flagString(input, "slug"); const titleDe = flagString(input, "title-de"); const titleUk = flagString(input, "title-uk"); const titleEn = flagString(input, "title-en"); const qualityStatus = flagString(input, "quality-status") ?? "unverified"; const dryRun = context.dryRun === true || flagBool(input, "dry-run"); if (!systemId) throw new Error("[nachweis.ingest] --system is required"); if (!filePath) throw new Error("[nachweis.ingest] --file is required"); if (!recordType) throw new Error("[nachweis.ingest] --record-type is required"); if (!slug) throw new Error("[nachweis.ingest] --slug is required"); if (!titleDe) throw new Error("[nachweis.ingest] --title-de is required"); if (!titleUk) throw new Error("[nachweis.ingest] --title-uk is required"); const entitled = await isNachweisEntitled(workspaceRoot, systemId); if (!entitled) { return makeSkipResult( "nachweis.ingest", systemId, ) as unknown as KernelCommandResult; } if (!(await context.io.exists(filePath))) { throw new Error(`[nachweis.ingest] NOT_FOUND: file '${filePath}' does not exist`); } if (!filePath.toLowerCase().endsWith(".pdf")) { throw new Error(`[nachweis.ingest] INVALID_FILE: file must be a PDF, got '${filePath}'`); } const sourceSha256 = await computeSourceSha256(filePath); const recordId = generateRecordId(slug); const version = 1; const r2Path = resolveNachweisR2Path(systemId, recordId, version); if (dryRun) { logger.info(`[nachweis.ingest] dry-run — would upload ${filePath} to ${r2Path}`); return { data: { recordId, systemId, sourceSha256, r2Path, version, dryRun: true, bordbuchEventId: null, }, exitCode: 0, summary: `[nachweis.ingest] ${systemId}: dry-run — record ${recordId} would be ingested (sha256: ${sourceSha256.slice(0, 20)}...)`, nextSteps: [ { action: `Ingest for real: pnpm exec werkstatt run nachweis.ingest --site ${systemId} --slug ${slug}`, kind: "optional", }, ], }; } const fileBuffer = await context.io.readFileBytes(filePath); const uploadBuffer = fileBuffer; // ADR-0025: heartbeat for potentially long R2 upload const startTime = Date.now(); const heartbeat = setInterval(() => { const elapsed = ((Date.now() - startTime) / 1000).toFixed(0); logger.info(`[nachweis.ingest] uploading to R2 — ${elapsed}s elapsed`); }, 30_000); try { try { await uploadToR2(uploadBuffer, r2Path); } catch (err) { if (isMissingEnvError(err)) { return { exitCode: 1, summary: `[nachweis.ingest] MISSING_ENV: ${err.message}`, nextSteps: [ { action: `[nachweis.ingest] MISSING_ENV: ${err.message}`, kind: "required" as const }, ], data: { recordId, systemId, sourceSha256, r2Path, version, dryRun: false, bordbuchEventId: null, }, }; } return { ...failResult("nachweis.ingest", [ `[nachweis.ingest] R2_UPLOAD_ERROR: failed to upload '${filePath}': ${err}`, ]), data: { recordId, systemId, sourceSha256, r2Path, version, dryRun: false, bordbuchEventId: null, }, }; } } finally { clearInterval(heartbeat); } logger.info(`[nachweis.ingest] uploaded to R2 — appending Bordbuch entry`); const operationId = generateOperationId(); await acquireLock(workspaceRoot, `system:${systemId}`, operationId, "nachweis.ingest", "agent"); await acquireLock(workspaceRoot, `bordbuch:${systemId}`, operationId, "nachweis.ingest", "agent"); let bordbuchEventId: string | null = null; try { const { entry } = await appendAndCommitBordbuch( workspaceRoot, systemId, "nachweis-record", `Ingested nachweis record (type: ${recordType}, slug: ${slug})`, "agent", { writerRole: "nachweis", metadata: { recordId, slug, recordType, sourceSha256, r2Path, version, qualityStatus, titleDe, titleUk, ...(titleEn ? { titleEn } : {}), }, }, `Bordbuch: nachweis-record ${systemId} ${slug}`, ); bordbuchEventId = entry.id; } catch (err) { logger.warn( `[nachweis.ingest] Bordbuch append failed — R2 object at ${r2Path} needs manual cleanup: ${err}`, ); return { ...failResult("nachweis.ingest", [ `[nachweis.ingest] BORDBUCH_ERROR: R2 upload succeeded but Bordbuch append failed — manual cleanup needed for ${r2Path}`, ]), data: { recordId, systemId, sourceSha256, r2Path, version, dryRun: false, bordbuchEventId: null, }, }; } finally { await releaseLock(workspaceRoot, `bordbuch:${systemId}`); await releaseLock(workspaceRoot, `system:${systemId}`); } return { data: { recordId, systemId, sourceSha256, r2Path, version, dryRun: false, bordbuchEventId, }, exitCode: 0, summary: `[nachweis.ingest] ${systemId}: ingested ${recordId} (sha256: ${sourceSha256.slice(0, 20)}..., bordbuch: ${bordbuchEventId})`, nextSteps: [ { action: `Sign the nachweis: pnpm exec werkstatt run nachweis.sign --site ${systemId} --slug ${slug}`, kind: "optional", }, ], }; }