/*
RFC-0583: bordbuch.repair — detect orphan-mission-close and unmatched-mission-open violations, insert missing events, recompute hash chain and event-id sequence, write atomically.
- Do not repair duplicate-mission-id, sensitive-payload, hash-mismatch, or hash-chain-gap violations — these are unrepairable by this command.
- Do not add bordbuch.repair to any pipeline — it is an on-demand operator command.
- RFC-0583: initial bordbuch.repair 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-1249: wire werkstatt.commands.validate into packages.check + retire CMD-OUTPUT debt
*/
import path from "node:path";
import type {
KernelCommandInput,
KernelCommandResult,
KernelRuntimeContext,
} from "@warpgogol/werkstatt-engine/kernel";
import type { BordbuchEntry } from "@warpgogol/werkstatt-engine/schemas";
import {
readBordbuch,
validateBordbuch,
computeEntryHash,
resolveBordbuchPath,
commitAndPushBordbuch,
} from "./bordbuch-io.ts";
import { resolveCacheClonePath } from "../sternsystem/registry-io.ts";
import { acquireLock, releaseLock, generateOperationId } from "../werkstatt/index.ts";
import { atomicWriteFile } from "../werkstatt/atomic.ts";
export interface BordbuchRepairOrphan {
missionId: string;
closeEventId: string;
closeEventKind: "mission-close" | "mission-abort";
proposedOpen: Omit;
metadataSource: "auto-derived" | "operator-supplied";
}
export interface BordbuchRepairUnmatched {
missionId: string;
openEventId: string;
proposedClose: Omit;
metadataSource: "auto-derived" | "operator-supplied";
}
export interface BordbuchRepairResult {
systemId: string;
insertedEvents: number;
recomputedHashes: number;
repairedFilePath: string;
dryRun: boolean;
orphans?: BordbuchRepairOrphan[];
}
function flagString(input: KernelCommandInput, key: string): string | undefined {
const v = input.flags[key];
return typeof v === "string" ? v : undefined;
}
function flagBoolean(input: KernelCommandInput, key: string): boolean {
const v = input.flags[key];
return v === true || v === "true";
}
const UNREPAIRABLE_RULES = new Set([
"duplicate-mission-id",
"sensitive-payload",
"hash-mismatch",
"hash-chain-gap",
"event-id-gap",
]);
function entriesToNdjson(entries: BordbuchEntry[]): string {
return entries.map((e) => JSON.stringify(e)).join("\n") + "\n";
}
export async function runBordbuchRepair(
input: KernelCommandInput,
context: KernelRuntimeContext,
): Promise> {
const { workspaceRoot, logger } = context;
const systemId = flagString(input, "system") ?? context.site?.name;
const dryRun = context.dryRun === true || flagBoolean(input, "dry-run") === true;
const missionFilter = flagString(input, "mission");
const metadataStr = flagString(input, "metadata");
if (!systemId) throw new Error("[bordbuch.repair] --system is required");
let operatorMetadata: { occurredAt?: string; summary?: string; actor?: string } | undefined;
if (metadataStr) {
try {
operatorMetadata = JSON.parse(metadataStr);
} catch {
throw new Error("[bordbuch.repair] --metadata must be valid JSON");
}
}
const operationId = generateOperationId();
await acquireLock(workspaceRoot, `system:${systemId}`, operationId, "bordbuch.repair", "agent");
await acquireLock(workspaceRoot, `bordbuch:${systemId}`, operationId, "bordbuch.repair", "agent");
try {
const entries = await readBordbuch(workspaceRoot, systemId);
const { violations } = await validateBordbuch(workspaceRoot, systemId);
const filePath = await resolveBordbuchPath(workspaceRoot, systemId);
if (violations.length === 0) {
logger.info(`[bordbuch.repair] ${systemId}: no repairs needed`);
return {
data: {
systemId,
insertedEvents: 0,
recomputedHashes: 0,
repairedFilePath: filePath,
dryRun,
},
exitCode: 0,
summary: `[bordbuch.repair] ${systemId}: no repairs needed`,
};
}
const orphanViolations = violations.filter((v) => v.rule === "orphan-mission-close");
const unmatchedViolations = violations.filter((v) => v.rule === "unmatched-mission-open");
const unrepairable = violations.filter((v) => UNREPAIRABLE_RULES.has(v.rule));
if (unrepairable.length > 0) {
const messages = unrepairable.map((v) => `[${v.rule}] ${v.message}`).join("; ");
throw new Error(
`[bordbuch.repair] unrepairable violations found: ${messages}. These require manual intervention.`,
);
}
if (orphanViolations.length === 0 && unmatchedViolations.length === 0) {
throw new Error(
`[bordbuch.repair] no repairable violations found. Other violations require manual intervention.`,
);
}
const orphans: BordbuchRepairOrphan[] = [];
const unmatched: BordbuchRepairUnmatched[] = [];
const insertions: Array<{ index: number; entry: BordbuchEntry }> = [];
const appends: BordbuchEntry[] = [];
for (const violation of orphanViolations) {
const closeEntry = entries.find((e) => e.id === violation.eventId);
if (!closeEntry || !closeEntry.missionId) continue;
if (missionFilter && closeEntry.missionId !== missionFilter) continue;
const occurredAt = operatorMetadata?.occurredAt ?? closeEntry.occurredAt;
const summary = operatorMetadata?.summary ?? "Mission opened (auto-repaired)";
const actor = operatorMetadata?.actor ?? "agent";
const proposedOpen: Omit = {
schemaVersion: "1.0.0",
systemId,
occurredAt,
kind: "mission-open",
status: "done",
missionId: closeEntry.missionId,
releaseId: null,
actor,
summary,
metadata: { autoRepaired: true },
};
orphans.push({
missionId: closeEntry.missionId,
closeEventId: closeEntry.id,
closeEventKind: closeEntry.kind as "mission-close" | "mission-abort",
proposedOpen,
metadataSource: operatorMetadata ? "operator-supplied" : "auto-derived",
});
const closeIndex = entries.indexOf(closeEntry);
const openEntry: BordbuchEntry = {
...proposedOpen,
id: "event-000000",
previousHash: null,
hash: "sha256:placeholder",
};
insertions.push({ index: closeIndex, entry: openEntry });
}
if (missionFilter && orphans.length === 0 && unmatched.length === 0) {
throw new Error(
`[bordbuch.repair] no repairable violations found for mission '${missionFilter}'.`,
);
}
// Process unmatched-mission-open: append mission-close events for orphaned opens.
for (const violation of unmatchedViolations) {
const missionId = violation.message.match(/'([^']+)'/)?.[1] ?? "";
if (!missionId) continue;
if (missionFilter && missionId !== missionFilter) continue;
const openEntry = entries.find((e) => e.kind === "mission-open" && e.missionId === missionId);
const occurredAt = operatorMetadata?.occurredAt ?? new Date().toISOString();
const summary =
operatorMetadata?.summary ?? "Mission closed (auto-repaired — unmatched open)";
const actor = operatorMetadata?.actor ?? "agent";
const proposedClose: Omit = {
schemaVersion: "1.0.0",
systemId,
occurredAt,
kind: "mission-close",
status: "done",
missionId,
releaseId: null,
actor,
summary,
metadata: { autoRepaired: true },
};
unmatched.push({
missionId,
openEventId: openEntry?.id ?? "unknown",
proposedClose,
metadataSource: operatorMetadata ? "operator-supplied" : "auto-derived",
});
appends.push({
...proposedClose,
id: "event-000000",
previousHash: null,
hash: "sha256:placeholder",
});
}
const repairedEntries: BordbuchEntry[] = [];
let insertIdx = 0;
for (let i = 0; i < entries.length; i++) {
while (insertIdx < insertions.length && insertions[insertIdx].index === i) {
repairedEntries.push(insertions[insertIdx].entry);
insertIdx++;
}
repairedEntries.push(entries[i]);
}
while (insertIdx < insertions.length) {
repairedEntries.push(insertions[insertIdx].entry);
insertIdx++;
}
// Append mission-close events for unmatched-mission-open violations
for (const entry of appends) {
repairedEntries.push(entry);
}
let prevHash: string | null = null;
for (let i = 0; i < repairedEntries.length; i++) {
const id = `event-${String(i + 1).padStart(6, "0")}`;
const { hash: _oldHash, id: _oldId, previousHash: _oldPrev, ...rest } = repairedEntries[i];
const entryWithoutHash: Omit = {
...rest,
id,
previousHash: prevHash,
};
const hash = computeEntryHash(entryWithoutHash);
repairedEntries[i] = { ...entryWithoutHash, hash };
prevHash = hash;
}
const recomputedHashes = repairedEntries.length;
const originalContent = (await context.io.exists(filePath))
? await context.io.readFile(filePath)
: "";
const repairedNdjson = entriesToNdjson(repairedEntries);
await atomicWriteFile(filePath, repairedNdjson);
const { violations: postViolations } = await validateBordbuch(workspaceRoot, systemId);
if (postViolations.length > 0) {
await atomicWriteFile(filePath, originalContent);
const messages = postViolations.map((v) => `[${v.rule}] ${v.message}`).join("; ");
throw new Error(
`[bordbuch.repair] repaired bordbuch still invalid: ${messages}. File not written.`,
);
}
if (dryRun) {
await atomicWriteFile(filePath, originalContent);
const totalRepairs = orphans.length + unmatched.length;
logger.info(`[bordbuch.repair] ${systemId}: dry-run — ${totalRepairs} repair(s) planned`);
return {
data: {
systemId,
insertedEvents: 0,
recomputedHashes: 0,
repairedFilePath: filePath,
dryRun: true,
orphans,
},
exitCode: 0,
summary: `[bordbuch.repair] ${systemId}: dry-run — ${totalRepairs} repair(s) planned`,
nextSteps: [
{
action: `Apply repairs: pnpm exec werkstatt run bordbuch.repair --site ${systemId}`,
kind: "optional",
},
],
};
}
const cacheCloneDir = await resolveCacheClonePath(workspaceRoot, systemId);
// Clear stale currentMission in system-state.yaml for repaired unmatched missions.
// This prevents "ghost mission" errors where system-state.yaml references a mission
// that was closed via bordbuch.repair but the state file was not updated.
if (unmatched.length > 0) {
try {
const { readSystemState, writeSystemState } = await import("../sternsystem/registry-io.ts");
const state = await readSystemState(workspaceRoot, systemId);
const repairedMissionIds = new Set(unmatched.map((u) => u.missionId));
if (state.currentMission && repairedMissionIds.has(state.currentMission)) {
state.currentMission = "";
await writeSystemState(workspaceRoot, systemId, state);
logger.info(
`[bordbuch.repair] ${systemId}: cleared stale currentMission in system-state.yaml`,
);
}
} catch {
// System state not available — non-fatal, bordbuch repair still succeeds
}
}
if (await context.io.exists(path.join(cacheCloneDir, ".git"))) {
const totalRepairs = orphans.length + unmatched.length;
const commitResult = await commitAndPushBordbuch(
cacheCloneDir,
`bordbuch.repair: ${systemId} — auto-repair ${totalRepairs} violation(s) (${orphans.length} orphan-close, ${unmatched.length} unmatched-open)`,
);
if (!commitResult.commitSha || !commitResult.pushed) {
throw new Error(
`[bordbuch.repair] ${systemId}: bordbuch file written but ${!commitResult.commitSha ? "commit" : "push"} failed — ` +
`cache clone may have rebase conflicts. Error: ${commitResult.error ?? "unknown"}. ` +
`Check git state in the cache clone and re-run bordbuch.repair.`,
);
}
}
const totalInserted = orphans.length + unmatched.length;
logger.success(
`[bordbuch.repair] ${systemId}: inserted ${orphans.length} mission-open + ${unmatched.length} mission-close event(s), recomputed ${recomputedHashes} hashes`,
);
return {
data: {
systemId,
insertedEvents: totalInserted,
recomputedHashes,
repairedFilePath: filePath,
dryRun: false,
},
exitCode: 0,
summary: `[bordbuch.repair] ${systemId}: inserted ${orphans.length} mission-open + ${unmatched.length} mission-close event(s), recomputed ${recomputedHashes} hashes`,
nextSteps: [],
};
} finally {
await releaseLock(workspaceRoot, `bordbuch:${systemId}`);
await releaseLock(workspaceRoot, `system:${systemId}`);
}
}