import { createHash } from "node:crypto"; import { canonicalJson, snapshotHash } from "./snapshot.ts"; import type { AppliedPatchRecord, Arc, Evidence, ExecutionToken, Place, Reservation, TransitionContract, TransitionStatus, TransitionType, WorkflowState, } from "./types.ts"; type LegacyEvidence = Omit; interface LegacyTransition { id: string; type: TransitionType; intent: string; status: TransitionStatus; contract: Omit; evidenceIds: string[]; supersededBy?: string; } export interface LegacyWorkflowStateV1 { schemaVersion: 1; revision: number; rootIntent?: string; places: Record; transitions: Record; arcs: Record; token: ExecutionToken; reservation?: Reservation; evidence: Record; appliedPatches: Record; } function isRecord(value: unknown): value is Record { return !!value && typeof value === "object" && !Array.isArray(value); } export function isWorkflowStateV2(value: unknown): value is WorkflowState { if (!isRecord(value)) return false; return ( value.schemaVersion === 2 && typeof value.revision === "number" && isRecord(value.places) && isRecord(value.transitions) && isRecord(value.arcs) && isRecord(value.refinements) && isRecord(value.evidence) && isRecord(value.appliedPatches) && isRecord(value.token) && value.token.id === "tok_main" && typeof value.token.placeId === "string" ); } export function isWorkflowStateV1(value: unknown): value is LegacyWorkflowStateV1 { if (!isRecord(value)) return false; return ( value.schemaVersion === 1 && typeof value.revision === "number" && isRecord(value.places) && isRecord(value.transitions) && isRecord(value.arcs) && isRecord(value.evidence) && isRecord(value.appliedPatches) && isRecord(value.token) && value.token.id === "tok_main" && typeof value.token.placeId === "string" ); } function sortRecord(record: Record): Record { return Object.fromEntries(Object.entries(record).sort(([a], [b]) => a.localeCompare(b))); } function sortedUnique(values: string[]): string[] { return [...new Set(values)].sort((a, b) => a.localeCompare(b)); } function canonicalLegacyState(state: LegacyWorkflowStateV1): LegacyWorkflowStateV1 { return { ...state, places: Object.fromEntries( Object.entries(sortRecord(state.places)).map(([id, place]) => [ id, { ...place, evidenceIds: sortedUnique(place.evidenceIds) }, ]), ), transitions: Object.fromEntries( Object.entries(sortRecord(state.transitions)).map(([id, transition]) => [ id, { ...transition, evidenceIds: sortedUnique(transition.evidenceIds), contract: { ...transition.contract, expectedEvidence: sortedUnique(transition.contract.expectedEvidence), evidenceSelectors: sortedUnique(transition.contract.evidenceSelectors), allowedEpisodeKinds: sortedUnique(transition.contract.allowedEpisodeKinds), }, }, ]), ), arcs: sortRecord(state.arcs), evidence: sortRecord(state.evidence), appliedPatches: Object.fromEntries( Object.entries(sortRecord(state.appliedPatches)).map(([key, value]) => [ key, { ...value, snapshotHash: "" }, ]), ), }; } export function snapshotHashV1(state: LegacyWorkflowStateV1): string { return `sha256:${createHash("sha256").update(canonicalJson(canonicalLegacyState(state))).digest("hex")}`; } function migratedCommitment(transition: LegacyTransition): "committed" | "provisional" | "directional" { if (transition.status === "active" || transition.status === "completed" || transition.status === "failed") { return "committed"; } const hasOperationalDetail = !!transition.contract.whyNow || transition.contract.expectedEvidence.length > 0 || !!transition.contract.exitCondition || !!transition.contract.failureCondition; return hasOperationalDetail ? "provisional" : "directional"; } export function migrateWorkflowStateV1(state: LegacyWorkflowStateV1): WorkflowState { const migrated: WorkflowState = { schemaVersion: 2, revision: state.revision, ...(state.rootIntent ? { rootIntent: state.rootIntent } : {}), places: structuredClone(state.places), transitions: Object.fromEntries( Object.entries(state.transitions).map(([id, transition]) => [ id, { ...structuredClone(transition), planning: { commitment: migratedCommitment(transition), dependsOn: [], requiresRefinement: migratedCommitment(transition) === "directional", ...(migratedCommitment(transition) === "provisional" ? { reconsiderWhen: "Migration-inferred plan; confirm before activation" } : {}), source: "migration_default" as const, }, contract: { ...structuredClone(transition.contract), }, }, ]), ), arcs: structuredClone(state.arcs), refinements: {}, token: structuredClone(state.token), ...(state.reservation ? { reservation: structuredClone(state.reservation) } : {}), evidence: Object.fromEntries( Object.entries(state.evidence).map(([id, evidence]) => [ id, { ...structuredClone(evidence), selectors: [] }, ]), ), appliedPatches: structuredClone(state.appliedPatches), }; // Force canonical validation now so corrupt legacy values fail during reconstruction. snapshotHash(migrated); return migrated; } export interface MigratedStateResult { state: WorkflowState; migratedFromSchemaVersion?: 1; } export function migrateCheckpointToLatest(value: unknown): MigratedStateResult | undefined { if (isWorkflowStateV2(value)) return { state: structuredClone(value) }; if (isWorkflowStateV1(value)) { return { state: migrateWorkflowStateV1(value), migratedFromSchemaVersion: 1 }; } return undefined; }