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