/* Overlay resolution for desired-state composition (RFC-1038). Applies layered patches (DesiredStateOverlay) to a base DesiredState, detecting conflicts when two overlays with the same priority modify the same componentId. Does not implement reconciliation — see reconciler.ts. Does not persist overlays — overlay storage is managed by the composition commands. RFC-1038: initial implementation — overlay resolution with conflict detection (COMPOSITION-01). */ import type { DesiredState, DesiredStateOverlay } from "@warpgogol/werkstatt-shared/kernel"; /** * Overlay conflict error — thrown when two overlays with the same priority * have addOrReplace entries for the same componentId (COMPOSITION-01). */ export class OverlayConflictError extends Error { readonly code = "COMPOSITION-01"; constructor( readonly overlay1Id: string, readonly overlay2Id: string, readonly componentId: string, readonly priority: number, ) { super( `COMPOSITION-01: Overlay conflict — overlays "${overlay1Id}" and "${overlay2Id}" both modify component "${componentId}" at priority ${priority}`, ); this.name = "OverlayConflictError"; } } /** * Scope priority order (innermost first, highest priority). * per-command > per-mission > per-session > per-workshop > per-fleet */ const SCOPE_ORDER: Record = { "per-command": 0, "per-mission": 1, "per-session": 2, "per-workshop": 3, "per-fleet": 4, }; /** * Resolve overlays against a base desired state. * * Overlays are applied in scope order (innermost first), then by priority * (higher priority overrides lower within the same scope). The final desired * state is the merge of all applicable overlays. * * @throws {OverlayConflictError} when two overlays with the same priority * have addOrReplace entries for the same componentId (COMPOSITION-01). */ export function resolveOverlays(base: DesiredState, overlays: DesiredStateOverlay[]): DesiredState { if (overlays.length === 0) return base; const sorted = [...overlays].sort((a, b) => { const scopeDiff = SCOPE_ORDER[a.scope]! - SCOPE_ORDER[b.scope]!; if (scopeDiff !== 0) return scopeDiff; return b.priority - a.priority; }); const merged = new Map(base.components); const seenPriorities = new Map>(); for (const overlay of sorted) { for (const [componentId, declaration] of overlay.addOrReplace) { const priorityMap = seenPriorities.get(overlay.priority); if (priorityMap) { const existingOverlayId = priorityMap.get(componentId); if (existingOverlayId && existingOverlayId !== overlay.id) { throw new OverlayConflictError( existingOverlayId, overlay.id, componentId, overlay.priority, ); } } else { seenPriorities.set(overlay.priority, new Map()); } seenPriorities.get(overlay.priority)!.set(componentId, overlay.id); merged.set(componentId, declaration); } for (const componentId of overlay.remove) { merged.delete(componentId); } } return { ...base, components: merged, }; } /** * Inspect overlays — returns a summary of each overlay's effects. */ export function inspectOverlays(overlays: DesiredStateOverlay[]): Array<{ id: string; scope: string; priority: number; addCount: number; removeCount: number; }> { return overlays.map((overlay) => ({ id: overlay.id, scope: overlay.scope, priority: overlay.priority, addCount: overlay.addOrReplace.size, removeCount: overlay.remove.length, })); }