import { PatchValidationError } from "./errors.ts"; import type { TransitionRefinement, WorkflowState } from "./types.ts"; export function liveRefinements(state: WorkflowState): TransitionRefinement[] { return Object.values(state.refinements).filter((refinement) => refinement.status !== "superseded"); } export function refinementForParent( state: WorkflowState, parentTransitionId: string, ): TransitionRefinement | undefined { const matches = liveRefinements(state).filter((refinement) => refinement.parentTransitionId === parentTransitionId); if (matches.length > 1) { throw new PatchValidationError(`Transition ${parentTransitionId} has multiple live refinements`); } return matches[0]; } export function refinementContainingNode(state: WorkflowState, nodeId: string): TransitionRefinement | undefined { const matches = liveRefinements(state).filter((refinement) => refinement.nodeIds.includes(nodeId)); if (matches.length > 1) throw new PatchValidationError(`Node ${nodeId} belongs to multiple live refinements`); return matches[0]; } export function refinementChainForNode(state: WorkflowState, nodeId: string): TransitionRefinement[] { const innerToOuter: TransitionRefinement[] = []; const seen = new Set(); let currentNodeId: string | undefined = nodeId; while (currentNodeId) { const refinement = refinementContainingNode(state, currentNodeId); if (!refinement) break; if (seen.has(refinement.id)) throw new PatchValidationError(`Refinement cycle detected at ${refinement.id}`); seen.add(refinement.id); innerToOuter.push(refinement); currentNodeId = refinement.parentTransitionId; } return innerToOuter.reverse(); } export function activeShellTransitionIds(state: WorkflowState): string[] { const active = liveRefinements(state) .filter((refinement) => refinement.status === "active") .map((refinement) => refinement.parentTransitionId); return active.sort((a, b) => refinementChainForNode(state, a).length - refinementChainForNode(state, b).length); } export function isNodeInsideRefinement(state: WorkflowState, nodeId: string, refinementId: string): boolean { let current: string | undefined = nodeId; const seen = new Set(); while (current) { const containing = refinementContainingNode(state, current); if (!containing) return false; if (containing.id === refinementId) return true; if (seen.has(containing.id)) return false; seen.add(containing.id); current = containing.parentTransitionId; } return false; } export function validateRefinements(state: WorkflowState): void { const nodeOwners = new Map(); const arcOwners = new Map(); const parentOwners = new Map(); for (const refinement of liveRefinements(state)) { if (!state.transitions[refinement.parentTransitionId]) { throw new PatchValidationError( `Refinement ${refinement.id} parent transition is missing: ${refinement.parentTransitionId}`, ); } if (parentOwners.has(refinement.parentTransitionId)) { throw new PatchValidationError(`Transition ${refinement.parentTransitionId} has multiple live refinements`); } parentOwners.set(refinement.parentTransitionId, refinement.id); const uniqueNodes = new Set(refinement.nodeIds); const uniqueArcs = new Set(refinement.arcIds); if (uniqueNodes.size !== refinement.nodeIds.length) { throw new PatchValidationError(`Refinement ${refinement.id} contains duplicate node IDs`); } if (uniqueArcs.size !== refinement.arcIds.length) { throw new PatchValidationError(`Refinement ${refinement.id} contains duplicate arc IDs`); } if (uniqueNodes.has(refinement.parentTransitionId)) { throw new PatchValidationError(`Refinement ${refinement.id} cannot contain its parent transition`); } for (const nodeId of refinement.nodeIds) { if (!state.places[nodeId] && !state.transitions[nodeId]) { throw new PatchValidationError(`Refinement ${refinement.id} references missing node ${nodeId}`); } const owner = nodeOwners.get(nodeId); if (owner) throw new PatchValidationError(`Node ${nodeId} belongs to refinements ${owner} and ${refinement.id}`); nodeOwners.set(nodeId, refinement.id); } for (const placeId of [ refinement.entryPlaceId, refinement.successExitPlaceId, ...(refinement.failureExitPlaceId ? [refinement.failureExitPlaceId] : []), ]) { if (!state.places[placeId] || !uniqueNodes.has(placeId)) { throw new PatchValidationError(`Refinement ${refinement.id} exit/entry Place is outside its scope: ${placeId}`); } } const scopedArcs: Array<{ fromId: string; toId: string }> = []; for (const arcId of refinement.arcIds) { const arc = state.arcs[arcId]; if (!arc) throw new PatchValidationError(`Refinement ${refinement.id} references missing arc ${arcId}`); if (!uniqueNodes.has(arc.fromId) || !uniqueNodes.has(arc.toId)) { throw new PatchValidationError(`Refinement ${refinement.id} arc ${arcId} leaves its direct scope`); } const owner = arcOwners.get(arcId); if (owner) throw new PatchValidationError(`Arc ${arcId} belongs to refinements ${owner} and ${refinement.id}`); arcOwners.set(arcId, refinement.id); scopedArcs.push(arc); } if (!refinement.nodeIds.some((nodeId) => !!state.transitions[nodeId])) { throw new PatchValidationError(`Refinement ${refinement.id} must contain at least one Transition`); } if (refinement.entryPlaceId === refinement.successExitPlaceId) { throw new PatchValidationError(`Refinement ${refinement.id} entry and success exit must be distinct`); } const reachable = new Set([refinement.entryPlaceId]); const queue = [refinement.entryPlaceId]; while (queue.length > 0) { const current = queue.shift()!; for (const arc of scopedArcs.filter((candidate) => candidate.fromId === current)) { if (reachable.has(arc.toId)) continue; reachable.add(arc.toId); queue.push(arc.toId); } } if (!reachable.has(refinement.successExitPlaceId)) { throw new PatchValidationError(`Refinement ${refinement.id} has no entry-to-success path`); } if (refinement.failureExitPlaceId && !reachable.has(refinement.failureExitPlaceId)) { throw new PatchValidationError(`Refinement ${refinement.id} failure exit is unreachable`); } // Walking the parent chain validates nested refinements and detects cycles. refinementChainForNode(state, refinement.parentTransitionId); } for (const arc of Object.values(state.arcs)) { const fromOwner = refinementContainingNode(state, arc.fromId); const toOwner = refinementContainingNode(state, arc.toId); if (fromOwner?.id !== toOwner?.id) { throw new PatchValidationError(`Arc ${arc.id} crosses refinement scope boundaries`); } if (fromOwner && !fromOwner.arcIds.includes(arc.id)) { throw new PatchValidationError(`Arc ${arc.id} is missing from refinement ${fromOwner.id}`); } } const activeRefinements = liveRefinements(state).filter((refinement) => refinement.status === "active"); for (const refinement of activeRefinements) { const parent = state.transitions[refinement.parentTransitionId]; if (parent?.status !== "active") { throw new PatchValidationError(`Active refinement ${refinement.id} requires active parent ${refinement.parentTransitionId}`); } if (parent.planning.commitment !== "committed") { throw new PatchValidationError(`Active shell ${parent.id} must be committed`); } if (!isNodeInsideRefinement(state, state.token.placeId, refinement.id)) { throw new PatchValidationError(`Token is outside active refinement ${refinement.id}`); } } const activeShells = activeShellTransitionIds(state); for (let index = 1; index < activeShells.length; index += 1) { const child = activeShells[index]!; const parent = activeShells[index - 1]!; const chain = refinementChainForNode(state, child).map((item) => item.parentTransitionId); if (!chain.includes(parent)) { throw new PatchValidationError("Active shell transitions must form one linear refinement chain"); } } }