/*
RFC-1038 AC-13: Persistence for desired state. Writes `.werkstatt/desired-state.json`
atomically so that on process restart after a crash, the reconciler can load the
last persisted desired state, compare it with actual state, and correct drift.
- Does not implement reconciliation logic — see reconciler.ts.
- Does not implement overlay resolution — see overlay.ts.
- RFC-1038: initial implementation — desired-state persistence for crash recovery (AC-13).
*/
import { join } from "node:path";
import { writeFileAtomic } from "@warpgogol/werkstatt-shared/kernel";
import type { Sha256Digest } from "@warpgogol/werkstatt-shared/fingerprint";
import type { DesiredState, ComponentDeclaration } from "@warpgogol/werkstatt-shared/kernel";
import { createDefaultIO, type WorkspaceIO } from "@warpgogol/werkstatt-shared/kernel/workspace-io";
/**
* On-disk schema for persisted desired state.
* Maps are serialized as arrays of pairs for JSON compatibility.
*/
interface PersistedDesiredState {
schemaVersion: "werkstatt/desired-state@1";
components: Array<[string, ComponentDeclaration]>;
requiredCapabilities: string[];
availableArtifacts: Array<[string, string]>;
admittedGrants: Array<{ scope: string; resource: string }>;
profileId: string;
}
/**
* Persist desired state to `.werkstatt/desired-state.json`.
* Uses atomic write (temp file + rename) to prevent corruption on crash.
*/
export async function persistDesiredState(
desired: DesiredState,
workspaceRoot: string,
io: WorkspaceIO = createDefaultIO().io,
): Promise {
const dir = join(workspaceRoot, ".werkstatt");
const filePath = join(dir, "desired-state.json");
const persisted: PersistedDesiredState = {
schemaVersion: "werkstatt/desired-state@1",
components: Array.from(desired.components.entries()),
requiredCapabilities: desired.requiredCapabilities,
availableArtifacts: Array.from(desired.availableArtifacts.entries()),
admittedGrants: [...desired.admittedGrants],
profileId: desired.profileId,
};
await io.mkdir(dir);
await writeFileAtomic(filePath, JSON.stringify(persisted, null, 2) + "\n");
}
/**
* Load persisted desired state from `.werkstatt/desired-state.json`.
* Returns null if the file does not exist or is corrupt (logs warning).
*/
export async function loadPersistedDesiredState(
workspaceRoot: string,
io: WorkspaceIO = createDefaultIO().io,
): Promise {
const filePath = join(workspaceRoot, ".werkstatt", "desired-state.json");
let raw: string;
try {
raw = await io.readFile(filePath);
} catch {
return null;
}
try {
const parsed = JSON.parse(raw) as PersistedDesiredState;
if (
!parsed ||
typeof parsed !== "object" ||
parsed.schemaVersion !== "werkstatt/desired-state@1"
) {
process.stderr.write(
`[desired-state-persistence] WARN: corrupt or incompatible schema in ${filePath} — ignoring.\n`,
);
return null;
}
return {
components: new Map(parsed.components),
requiredCapabilities: parsed.requiredCapabilities ?? [],
availableArtifacts: new Map(
(parsed.availableArtifacts ?? []).map(
([k, v]) => [k, v as Sha256Digest] as [string, Sha256Digest],
),
),
admittedGrants: parsed.admittedGrants ?? [],
profileId: parsed.profileId ?? "",
};
} catch (e) {
process.stderr.write(
`[desired-state-persistence] WARN: failed to parse ${filePath}: ${e instanceof Error ? e.message : String(e)} — ignoring.\n`,
);
return null;
}
}