/* 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; } }