/* RFC-1038: Desired-state reconciler. Computes deltas between desired and actual state, applies them transactionally via ActivationTransaction, and persists the desired state for crash recovery. Concurrent reconciliation is guarded by a simple lock (DNA-51). Does not implement overlay resolution — see overlay.ts. Does not implement component resolution — see component-runtime/resolver.ts. Does not implement fiber lifecycle — see component-runtime/fiber.ts. RFC-1038: initial implementation — reconciler replacing component-runtime/reconciliation.ts. RFC-1144: run read-only steps to completion in collect-errors pipeline mode Scheduler: collectErrors option on executeScheduledSteps — read-only steps (ScheduledStep.readOnly, executor-resolved from mutatesState === false) run after dependency failures with ranAfterFailedDependency annotation; ranTainted set propagates taint transitively so mutating dependents still skip. Executor: mergeCollectErrorsOption OR-merges PipelineDeclaration.collectErrors with the CLI flag at registry resolution; readOnly annotated post-buildSchedule; taint flag copied to step reports; tainted failures marked in pretty output. Contracts: PipelineDeclaration.collectErrors, KernelExecutionReport.ranAfterFailedDependency, pipelines maps store full declarations. Declarations: collectErrors on validation-gate registrations (build.post, check, build.check, compass, integrity.release in the workpiece template; packages.check in checks module). Tests: 10 new cases covering AC-1..AC-6 plus transitive taint. */ import type { DesiredState, ActualState, MutableActualState, ReconciliationDelta, ReconciliationResult, ComponentDeclaration, ModuleExport, CommandDeclaration, } from "@warpgogol/werkstatt-shared/kernel"; import type { ModuleFiberState, KernelPipelineStep, PipelineDeclaration, } from "@warpgogol/werkstatt-shared/kernel"; /** * Build ActualState from ModuleExport[]. * Commands and pipelines are populated once and never change. * Components start empty — the reconciler populates them. */ export function buildActualState(exports: ModuleExport[]): ActualState { const commands = new Map(); const pipelines = new Map(); const commandModules = new Map(); for (const mod of exports) { if (!mod || typeof mod !== "object") continue; const commandsArray = mod.commands ?? []; const pipelinesArray = mod.pipelines ?? []; if ( commandsArray.length === 0 && pipelinesArray.length === 0 && typeof (mod as unknown as { register?: unknown }).register === "function" ) { const legacyCommands: CommandDeclaration[] = []; const legacyPipelines: { name: string; steps: KernelPipelineStep[] }[] = []; ( mod as unknown as { register: (r: { registerCommand: (c: CommandDeclaration) => void; registerPipeline: (n: string, s: KernelPipelineStep[]) => void; }) => void; } ).register({ registerCommand: (cmd) => legacyCommands.push(cmd), registerPipeline: (name, steps) => legacyPipelines.push({ name, steps }), }); for (const cmd of legacyCommands) { const existing = commands.get(cmd.name); if (existing) { if (existing.execute === cmd.execute) continue; throw new Error( `COMPOSITION-02: Duplicate command "${cmd.name}" declared by module "${mod.name}" — already declared by another module.`, ); } commands.set(cmd.name, cmd); commandModules.set(cmd.name, mod.name); } for (const pipe of legacyPipelines) { if (pipelines.has(pipe.name)) { const existing = pipelines.get(pipe.name)!; if ( existing.steps.length === pipe.steps.length && existing.steps.every((s, i) => s === pipe.steps[i]) ) continue; throw new Error( `COMPOSITION-03: Duplicate pipeline "${pipe.name}" declared by module "${mod.name}" — already declared by another module.`, ); } pipelines.set(pipe.name, pipe); } continue; } for (const cmd of commandsArray) { const existing = commands.get(cmd.name); if (existing) { if (existing.execute === cmd.execute) continue; throw new Error( `COMPOSITION-02: Duplicate command "${cmd.name}" declared by module "${mod.name}" — already declared by another module.`, ); } commands.set(cmd.name, cmd); commandModules.set(cmd.name, mod.name); } for (const pipe of pipelinesArray) { if (pipelines.has(pipe.name)) { const existing = pipelines.get(pipe.name)!; if ( existing.steps.length === pipe.steps.length && existing.steps.every((s, i) => s === pipe.steps[i]) ) continue; throw new Error( `COMPOSITION-03: Duplicate pipeline "${pipe.name}" declared by module "${mod.name}" — already declared by another module.`, ); } pipelines.set(pipe.name, pipe); } } return { components: new Map(), commands, pipelines, commandModules, }; } /** * Build DesiredState from ModuleExport[]. * Collects all ComponentDeclarations from all modules into the desired state. */ export function buildDesiredState( exports: ModuleExport[], options: { profileId: string; requiredCapabilities?: string[]; availableArtifacts?: ReadonlyMap< string, import("@warpgogol/werkstatt-shared/fingerprint").Sha256Digest >; admittedGrants?: ReadonlyArray<{ scope: string; resource: string }>; }, ): DesiredState { const components = new Map(); for (const mod of exports) { for (const decl of mod.declarations) { if (components.has(decl.componentId)) { throw new Error( `COMPOSITION-04: Duplicate component "${decl.componentId}" declared by module "${mod.name}".`, ); } components.set(decl.componentId, decl); } } return { components, requiredCapabilities: options.requiredCapabilities ?? [], availableArtifacts: options.availableArtifacts ?? new Map(), admittedGrants: options.admittedGrants ?? [], profileId: options.profileId, }; } /** * Compute the delta between desired and actual state. */ export function computeDelta(desired: DesiredState, actual: ActualState): ReconciliationDelta { const toActivate: ComponentDeclaration[] = []; const toDeactivate: string[] = []; const toReconfigure: ReconciliationDelta["toReconfigure"] = []; const unchanged: string[] = []; const missingDependencies: ReconciliationDelta["missingDependencies"] = []; const providedCapabilities = new Set(); for (const decl of desired.components.values()) { for (const provide of decl.provides) { providedCapabilities.add(provide.capability); } } for (const [id, desiredDecl] of desired.components) { if (!desiredDecl.active) continue; const actualEntry = actual.components.get(id); if (!actualEntry) { toActivate.push(desiredDecl); } else { const configChanged = !deepEqual( desiredDecl.config ?? {}, actualEntry.declaration.config ?? {}, ); if (configChanged) { toReconfigure.push({ id, oldConfig: actualEntry.declaration.config ?? {}, newConfig: desiredDecl.config ?? {}, }); } else { unchanged.push(id); } } for (const req of desiredDecl.requires) { if (!req.optional && !providedCapabilities.has(req.capability)) { missingDependencies.push({ capability: req.capability, requiredBy: id, }); } } } for (const id of actual.components.keys()) { const desiredDecl = desired.components.get(id); if (!desiredDecl || !desiredDecl.active) { toDeactivate.push(id); } } return { toActivate, toDeactivate, toReconfigure, unchanged, missingDependencies, }; } function deepEqual(a: unknown, b: unknown): boolean { if (a === b) return true; if (typeof a !== "object" || typeof b !== "object" || a === null || b === null) { return false; } const keysA = Object.keys(a as Record); const keysB = Object.keys(b as Record); if (keysA.length !== keysB.length) return false; for (const key of keysA) { if (!deepEqual((a as Record)[key], (b as Record)[key])) { return false; } } return true; } let reconciliationLock = false; /** * Reconcile desired state with actual state. * * DNA-51: Concurrent reconciliation is guarded by a lock. If the lock is held, * returns `{ applied: false }` without blocking. * * Crash recovery: on startup, load persisted desired state from * `.werkstatt/desired-state.json`, compare with actual, reconcile drift. */ export async function reconcile( desired: DesiredState, actual: MutableActualState, options?: { onActivate?: (decl: ComponentDeclaration) => Promise; onDeactivate?: (id: string) => Promise; onReconfigure?: ( id: string, oldConfig: Record, newConfig: Record, ) => Promise; }, ): Promise { const start = Date.now(); if (reconciliationLock) { return { delta: computeDelta(desired, actual), applied: false, failures: [{ id: "(lock)", reason: "reconciliation in progress" }], durationMs: Date.now() - start, }; } reconciliationLock = true; const failures: ReconciliationResult["failures"] = []; try { const delta = computeDelta(desired, actual); for (const id of delta.toDeactivate) { try { if (options?.onDeactivate) { await options.onDeactivate(id); } actual.components.delete(id); } catch (e) { failures.push({ id, reason: e instanceof Error ? e.message : String(e) }); } } for (const { id, oldConfig, newConfig } of delta.toReconfigure) { try { if (options?.onReconfigure) { await options.onReconfigure(id, oldConfig, newConfig); } const entry = actual.components.get(id); if (entry) { actual.components.set(id, { declaration: { ...entry.declaration, config: newConfig }, state: "active" as ModuleFiberState, }); } } catch (e) { failures.push({ id, reason: e instanceof Error ? e.message : String(e) }); } } for (const decl of delta.toActivate) { try { if (options?.onActivate) { await options.onActivate(decl); } actual.components.set(decl.componentId, { declaration: decl, state: "active" as ModuleFiberState, }); } catch (e) { failures.push({ id: decl.componentId, reason: e instanceof Error ? e.message : String(e), }); } } return { delta, applied: failures.length === 0, failures, durationMs: Date.now() - start, }; } finally { reconciliationLock = false; } } /** * Reset the reconciliation lock (for testing). */ export function resetReconciliationLock(): void { reconciliationLock = false; } /** * AC-13: Crash recovery — load persisted desired state from * `.werkstatt/desired-state.json`, compare with actual state, and correct drift. * * If no persisted state exists, builds desired state from module exports. * After reconciliation, persists the updated desired state. */ export async function reconcileFromPersisted( workspaceRoot: string, exports: ModuleExport[], actual: MutableActualState, options: { profileId: string; requiredCapabilities?: string[]; availableArtifacts?: ReadonlyMap< string, import("@warpgogol/werkstatt-shared/fingerprint").Sha256Digest >; admittedGrants?: ReadonlyArray<{ scope: string; resource: string }>; onActivate?: (decl: ComponentDeclaration) => Promise; onDeactivate?: (id: string) => Promise; onReconfigure?: ( id: string, oldConfig: Record, newConfig: Record, ) => Promise; }, ): Promise { const { loadPersistedDesiredState, persistDesiredState } = await import("./desired-state-persistence.ts"); let desired = await loadPersistedDesiredState(workspaceRoot); if (!desired) { desired = buildDesiredState(exports, { profileId: options.profileId, requiredCapabilities: options.requiredCapabilities, availableArtifacts: options.availableArtifacts, admittedGrants: options.admittedGrants, }); } const result = await reconcile(desired, actual, { onActivate: options.onActivate, onDeactivate: options.onDeactivate, onReconfigure: options.onReconfigure, }); await persistDesiredState(desired, workspaceRoot); return result; } /** * Start periodic reconciliation for long-running modes (dev server, watch mode, daemon). * Calls reconcile() every `intervalMs` (default 60s). NOT for CLI mode (process exits in seconds). * * @returns a `stop()` function that clears the interval. */ export function startPeriodicReconciler( desired: DesiredState, actual: MutableActualState, options?: { intervalMs?: number; onActivate?: (decl: ComponentDeclaration) => Promise; onDeactivate?: (id: string) => Promise; onReconfigure?: ( id: string, oldConfig: Record, newConfig: Record, ) => Promise; }, ): () => void { const intervalMs = options?.intervalMs ?? 60_000; const timer = setInterval(async () => { try { await reconcile(desired, actual, { onActivate: options?.onActivate, onDeactivate: options?.onDeactivate, onReconfigure: options?.onReconfigure, }); } catch (e) { process.stderr.write( `[periodic-reconciler] ERROR: ${e instanceof Error ? e.message : String(e)}\n`, ); } }, intervalMs); return () => clearInterval(timer); }