diff --git a/improvements.md b/improvements.md index 8d3a1b5..220bb24 100644 --- a/improvements.md +++ b/improvements.md @@ -55,7 +55,7 @@ you which stack PR closes the row.** | 1 | `push` drift detection | Prevent silent overwrites of dashboard edits | #4 | Open (Stack G planned) | | 2 | `apply` same-file conflict | `apply` drops concurrent same-file dashboard edits | #4 | Open (Stack G planned) | | 3 | Rollback | Current undo can clobber newer live changes | #4, #5 | Open (Stack H planned) | -| 4 | State schema content hashes | Architectural unlock for #1, #2, #3, #6, #7 | None | Open (Stack F planned) | +| 4 | State schema content hashes | Architectural unlock for #1, #2, #3, #6, #7 | None | RESOLVED 2026-04-30 (Stack F) | | 5 | `push --dry-run` | Cheapest operator-safety win | None | RESOLVED 2026-04-30 (Stack C) | | 6 | API-level optimistic concurrency | Server-side conflict rejection | Platform | Deferred (Stack I, gated) | | 7 | Voice edits drop pronunciation-dictionary attachments | Silent regression on Cartesia + 11labs voice edits | #4 | Open (Stack G planned) | diff --git a/src/call.ts b/src/call.ts index d93d89e..4b40a07 100644 --- a/src/call.ts +++ b/src/call.ts @@ -292,9 +292,8 @@ function resolveTarget( resourceType: ResourceType, ): string { if (resourceType === "squad") { - const squads = - (state as StateFile & { squads?: Record }).squads || {}; - const uuid = squads[target]; + const squads = state.squads || {}; + const uuid = squads[target]?.uuid; if (!uuid) { console.error(`❌ Squad not found: ${target}`); console.error(" Available squads:"); @@ -308,7 +307,7 @@ function resolveTarget( } return uuid; } else { - const uuid = state.assistants[target]; + const uuid = state.assistants[target]?.uuid; if (!uuid) { console.error(`❌ Assistant not found: ${target}`); console.error(" Available assistants:"); diff --git a/src/cleanup.ts b/src/cleanup.ts index 0722714..5b2d5ae 100644 --- a/src/cleanup.ts +++ b/src/cleanup.ts @@ -111,16 +111,18 @@ async function main(): Promise { } const state = loadState(); + // Stack F: state values are ResourceState objects, not bare UUIDs. Extract + // each .uuid for the orphan-detection set. const stateIds = new Set([ - ...Object.values(state.assistants), - ...Object.values(state.tools), - ...Object.values(state.structuredOutputs), - ...Object.values(state.squads), - ...Object.values(state.personalities), - ...Object.values(state.scenarios), - ...Object.values(state.simulations), - ...Object.values(state.simulationSuites), - ...Object.values(state.evals), + ...Object.values(state.assistants).map((e) => e.uuid), + ...Object.values(state.tools).map((e) => e.uuid), + ...Object.values(state.structuredOutputs).map((e) => e.uuid), + ...Object.values(state.squads).map((e) => e.uuid), + ...Object.values(state.personalities).map((e) => e.uuid), + ...Object.values(state.scenarios).map((e) => e.uuid), + ...Object.values(state.simulations).map((e) => e.uuid), + ...Object.values(state.simulationSuites).map((e) => e.uuid), + ...Object.values(state.evals).map((e) => e.uuid), ]); // A state file with zero tracked resources is almost always a fresh clone, diff --git a/src/credentials.ts b/src/credentials.ts index 8b86ebc..c352deb 100644 --- a/src/credentials.ts +++ b/src/credentials.ts @@ -16,16 +16,16 @@ import type { StateFile } from "./types.ts"; export function credentialReverseMap(state: StateFile): Map { const map = new Map(); - for (const [name, uuid] of Object.entries(state.credentials)) { - map.set(uuid, name); + for (const [name, entry] of Object.entries(state.credentials)) { + map.set(entry.uuid, name); } return map; } export function credentialForwardMap(state: StateFile): Map { const map = new Map(); - for (const [name, uuid] of Object.entries(state.credentials)) { - map.set(name, uuid); + for (const [name, entry] of Object.entries(state.credentials)) { + map.set(name, entry.uuid); } return map; } diff --git a/src/delete.ts b/src/delete.ts index 938fbf5..10f70c4 100644 --- a/src/delete.ts +++ b/src/delete.ts @@ -3,6 +3,7 @@ import { FORCE_DELETE } from "./config.ts"; import { extractReferencedIds } from "./resolver.ts"; import type { ResourceFile, + ResourceState, StateFile, LoadedResources, OrphanedResource, @@ -15,13 +16,13 @@ import type { export function findOrphanedResources( loadedResourceIds: string[], - stateResourceIds: Record + stateResourceIds: Record ): OrphanedResource[] { const orphaned: OrphanedResource[] = []; - for (const [resourceId, uuid] of Object.entries(stateResourceIds)) { + for (const [resourceId, entry] of Object.entries(stateResourceIds)) { if (!loadedResourceIds.includes(resourceId)) { - orphaned.push({ resourceId, uuid }); + orphaned.push({ resourceId, uuid: entry.uuid }); } } diff --git a/src/eval.ts b/src/eval.ts index 2eac403..790115a 100644 --- a/src/eval.ts +++ b/src/eval.ts @@ -250,10 +250,13 @@ function loadVariables(config: EvalConfig): Record | undefined const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; -function resolveId(id: string, stateSection: Record): string { +function resolveId( + id: string, + stateSection: Record, +): string { const clean = id.split("##")[0]?.trim() ?? ""; if (UUID_RE.test(clean)) return clean; - return stateSection[clean] ?? clean; + return stateSection[clean]?.uuid ?? clean; } function resolveAssistantConfig(config: Record, state: StateFile): Record { @@ -278,7 +281,9 @@ function resolveAssistantConfig(config: Record, state: StateFil } } // Resolve credentials - const credMap = new Map(Object.entries(state.credentials)); + const credMap = new Map( + Object.entries(state.credentials).map(([slug, rs]) => [slug, rs.uuid]), + ); if (credMap.size > 0) return deepReplace(resolved, credMap) as Record; return resolved; } @@ -314,7 +319,9 @@ async function resolveSquadConfig(config: Record, state: StateF } } } - const credMap = new Map(Object.entries(state.credentials)); + const credMap = new Map( + Object.entries(state.credentials).map(([slug, rs]) => [slug, rs.uuid]), + ); if (credMap.size > 0) return deepReplace(resolved, credMap) as Record; return resolved; } @@ -344,9 +351,9 @@ function loadEvals(state: StateFile, filter?: string): EvalDefinition[] { const evalState = state.evals ?? {}; const evals: EvalDefinition[] = []; - for (const [resourceId, uuid] of Object.entries(evalState)) { + for (const [resourceId, entry] of Object.entries(evalState)) { if (filter && !resourceId.toLowerCase().includes(filter.toLowerCase())) continue; - evals.push({ resourceId, evalId: uuid, name: resourceId }); + evals.push({ resourceId, evalId: entry.uuid, name: resourceId }); } return evals; @@ -443,7 +450,7 @@ async function main(): Promise { if (config.squadName) { if (config.useStored) { - const squadId = state.squads[config.squadName]; + const squadId = state.squads[config.squadName]?.uuid; if (!squadId) { console.error(`❌ Squad not found in state: ${config.squadName}`); console.error(" Available: " + Object.keys(state.squads).join(", ")); @@ -466,7 +473,7 @@ async function main(): Promise { } } else { if (config.useStored) { - const assistantId = state.assistants[config.assistantName!]; + const assistantId = state.assistants[config.assistantName!]?.uuid; if (!assistantId) { console.error(`❌ Assistant not found in state: ${config.assistantName}`); process.exit(1); diff --git a/src/pull.ts b/src/pull.ts index 033feab..6af7c5c 100644 --- a/src/pull.ts +++ b/src/pull.ts @@ -15,9 +15,9 @@ import { loadIgnorePatterns, matchesIgnore, } from "./config.ts"; -import { loadState, saveState } from "./state.ts"; +import { hashPayload, loadState, saveState, upsertState } from "./state.ts"; import { credentialReverseMap, replaceCredentialRefs } from "./credentials.ts"; -import type { StateFile, ResourceType } from "./types.ts"; +import type { ResourceState, StateFile, ResourceType } from "./types.ts"; // ───────────────────────────────────────────────────────────────────────────── // Types @@ -221,11 +221,11 @@ async function pullCredentials(state: StateFile): Promise { const credentials = await fetchCredentials(); console.log(` Found ${credentials.length} credentials in Vapi`); - const newSection: Record = {}; + const newSection: Record = {}; // Build reverse map from existing state to preserve slug stability const existingReverse = new Map(); - for (const [slug, uuid] of Object.entries(state.credentials)) { - existingReverse.set(uuid, slug); + for (const [slug, entry] of Object.entries(state.credentials)) { + existingReverse.set(entry.uuid, slug); } for (const cred of credentials) { @@ -234,7 +234,12 @@ async function pullCredentials(state: StateFile): Promise { if (!slug) { slug = credentialSlug(cred); } - newSection[slug] = cred.id; + // Preserve existing hash metadata if the slug+uuid pair survives. + const prior = state.credentials[slug]; + newSection[slug] = + prior && prior.uuid === cred.id + ? prior + : { uuid: cred.id, lastPulledAt: new Date().toISOString() }; console.log(` 🔑 ${slug} -> ${cred.id}`); } @@ -327,12 +332,12 @@ function findExistingResourceId( } function removeUuidMappings( - stateSection: Record, + stateSection: Record, uuid: string, keepResourceId?: string, ): void { - for (const [resourceId, mappedUuid] of Object.entries(stateSection)) { - if (mappedUuid === uuid && resourceId !== keepResourceId) { + for (const [resourceId, entry] of Object.entries(stateSection)) { + if (entry.uuid === uuid && resourceId !== keepResourceId) { delete stateSection[resourceId]; } } @@ -368,8 +373,8 @@ function buildReverseMap( const map = new Map(); const stateSection = state[resourceType]; - for (const [resourceId, uuid] of Object.entries(stateSection)) { - map.set(uuid, resourceId); + for (const [resourceId, entry] of Object.entries(stateSection)) { + map.set(entry.uuid, resourceId); } return map; @@ -653,7 +658,7 @@ export async function pullResourceType( const reverseMap = buildReverseMap(state, resourceType); const credReverse = credentialReverseMap(state); - const newStateSection: Record = resourceIds?.length + const newStateSection: Record = resourceIds?.length ? { ...state[resourceType] } : {}; const existingResourceIds = bootstrap @@ -728,7 +733,7 @@ export async function pullResourceType( changedFiles.has(yamlPath) ) { console.log(` ✏️ ${resourceId} (locally modified, preserving)`); - newStateSection[resourceId] = resource.id; + upsertState(newStateSection, resourceId, { uuid: resource.id }); skipped++; continue; } @@ -761,7 +766,7 @@ export async function pullResourceType( const stateMtime = statSync(stateFilePath).mtimeMs; if (localMtime > stateMtime) { console.log(` ✏️ ${resourceId} (locally modified, preserving)`); - newStateSection[resourceId] = resource.id; + upsertState(newStateSection, resourceId, { uuid: resource.id }); skipped++; continue; } @@ -783,7 +788,7 @@ export async function pullResourceType( console.log( ` 🗑️ ${resourceId} (deleted locally, intent in state — add to .vapi-ignore to stop tracking)`, ); - newStateSection[resourceId] = resource.id; + upsertState(newStateSection, resourceId, { uuid: resource.id }); skipped++; continue; } @@ -825,8 +830,15 @@ export async function pullResourceType( if (isNew) created++; else updated++; - // Update state - newStateSection[resourceId] = resource.id; + // Update state with new content hash + timestamp (Stack F). + // Hashing the resolved-with-credentials payload (the form we will save + // to disk) keeps `lastPulledHash` aligned with the source-of-truth diff + // basis used by drift detection in Stack G. + upsertState(newStateSection, resourceId, { + uuid: resource.id, + lastPulledHash: hashPayload(withCredNames), + lastPulledAt: new Date().toISOString(), + }); } // Update state with new mappings diff --git a/src/push.ts b/src/push.ts index d051664..61e413e 100644 --- a/src/push.ts +++ b/src/push.ts @@ -12,7 +12,12 @@ import { removeExcludedKeys, } from "./config.ts"; import { summarizeFindings, validateResources } from "./validate.ts"; -import { loadState, saveState } from "./state.ts"; +import { + hashPayload, + loadState, + saveState, + upsertState, +} from "./state.ts"; import { loadResources, loadSingleResource, FOLDER_MAP } from "./resources.ts"; import { fetchAllResources, resourceIdMatchesName, runPull } from "./pull.ts"; import { @@ -26,6 +31,7 @@ import type { ResourceFile, StateFile, ResourceType, + ResourceState, LoadedResources, } from "./types.ts"; @@ -49,7 +55,7 @@ async function upsertResourceWithStateRecovery(options: { resourceLabel: string; resourceId: string; existingUuid?: string; - stateSection: Record; + stateSection: Record; updateEndpoint: string; updatePayload: Record; createEndpoint: string; @@ -189,7 +195,7 @@ async function getInvalidStateMappings( const trackedResources = resources[type] .map((resource) => ({ resourceId: resource.resourceId, - uuid: state[type][resource.resourceId], + uuid: state[type][resource.resourceId]?.uuid, })) .filter( ( @@ -307,7 +313,7 @@ export async function applyTool( state: StateFile, ): Promise { const { resourceId, data } = resource; - const existingUuid = state.tools[resourceId]; + const existingUuid = state.tools[resourceId]?.uuid; // Resolve references (but assistants may not exist yet on first pass) const payload = resolveReferences(data as Record, state); @@ -361,7 +367,7 @@ export async function applyStructuredOutput( state: StateFile, ): Promise { const { resourceId, data } = resource; - const existingUuid = state.structuredOutputs[resourceId]; + const existingUuid = state.structuredOutputs[resourceId]?.uuid; // Resolve references to assistants (but assistants might not exist yet in first pass) const payload = resolveReferences(data as Record, state); @@ -386,7 +392,7 @@ export async function applyAssistant( state: StateFile, ): Promise { const { resourceId, data } = resource; - const existingUuid = state.assistants[resourceId]; + const existingUuid = state.assistants[resourceId]?.uuid; // Resolve tool and structured output references const payload = resolveReferences(data as Record, state); @@ -408,7 +414,7 @@ export async function applySquad( state: StateFile, ): Promise { const { resourceId, data } = resource; - const existingUuid = state.squads[resourceId]; + const existingUuid = state.squads[resourceId]?.uuid; // Resolve assistant references in members const payload = resolveReferences(data as Record, state); @@ -430,7 +436,7 @@ export async function applyPersonality( state: StateFile, ): Promise { const { resourceId, data } = resource; - const existingUuid = state.personalities[resourceId]; + const existingUuid = state.personalities[resourceId]?.uuid; // Personalities contain inline assistant config, no external references to resolve const payload = data as Record; @@ -452,7 +458,7 @@ export async function applyScenario( state: StateFile, ): Promise { const { resourceId, data } = resource; - const existingUuid = state.scenarios[resourceId]; + const existingUuid = state.scenarios[resourceId]?.uuid; // Resolve structuredOutputId references in evaluations const payload = resolveReferences(data as Record, state); @@ -474,7 +480,7 @@ export async function applySimulation( state: StateFile, ): Promise { const { resourceId, data } = resource; - const existingUuid = state.simulations[resourceId]; + const existingUuid = state.simulations[resourceId]?.uuid; // Resolve personality and scenario references const payload = resolveReferences(data as Record, state); @@ -496,7 +502,7 @@ export async function applySimulationSuite( state: StateFile, ): Promise { const { resourceId, data } = resource; - const existingUuid = state.simulationSuites[resourceId]; + const existingUuid = state.simulationSuites[resourceId]?.uuid; // Resolve simulation references const payload = resolveReferences(data as Record, state); @@ -518,7 +524,7 @@ export async function applyEval( state: StateFile, ): Promise { const { resourceId, data } = resource; - const existingUuid = state.evals[resourceId]; + const existingUuid = state.evals[resourceId]?.uuid; const payload = data as Record; @@ -557,7 +563,7 @@ export async function updateToolAssistantRefs( if (!hasAssistantRefs) continue; - const uuid = state.tools[resourceId]; + const uuid = state.tools[resourceId]?.uuid; if (!uuid) continue; // Resolve destinations now that all assistants exist @@ -590,7 +596,7 @@ export async function updateStructuredOutputAssistantRefs( continue; } - const uuid = state.structuredOutputs[resourceId]; + const uuid = state.structuredOutputs[resourceId]?.uuid; if (!uuid) continue; // Resolve assistant IDs now that all assistants exist @@ -716,7 +722,10 @@ async function ensureToolExists( const uuid = await applyTool(tool, ctx.state); ctx.autoApplied.add(`tools:${toolId}`); if (!uuid) return; - ctx.state.tools[tool.resourceId] = uuid; + upsertState(ctx.state.tools, tool.resourceId, { + uuid, + lastPushedHash: hashPayload(tool.data), + }); ctx.applied.tools++; ctx.autoAppliedTools.push(tool); } catch (error) { @@ -746,7 +755,10 @@ async function ensureStructuredOutputExists( const uuid = await applyStructuredOutput(output, ctx.state); ctx.autoApplied.add(`structuredOutputs:${outputId}`); if (!uuid) return; - ctx.state.structuredOutputs[output.resourceId] = uuid; + upsertState(ctx.state.structuredOutputs, output.resourceId, { + uuid, + lastPushedHash: hashPayload(output.data), + }); ctx.applied.structuredOutputs++; ctx.autoAppliedStructuredOutputs.push(output); } catch (error) { @@ -820,7 +832,10 @@ async function ensureAssistantExists( ctx.autoApplied.add(`assistants:${assistantId}`); return; } - ctx.state.assistants[assistant.resourceId] = uuid; + upsertState(ctx.state.assistants, assistant.resourceId, { + uuid, + lastPushedHash: hashPayload(assistant.data), + }); ctx.applied.assistants++; ctx.autoApplied.add(`assistants:${assistantId}`); } catch (error) { @@ -1093,7 +1108,10 @@ async function main(): Promise { try { const uuid = await applyTool(tool, state); if (!uuid) continue; - state.tools[tool.resourceId] = uuid; + upsertState(state.tools, tool.resourceId, { + uuid, + lastPushedHash: hashPayload(tool.data), + }); applied.tools++; } catch (error) { console.error(formatApiError(tool.resourceId, error)); @@ -1108,7 +1126,10 @@ async function main(): Promise { try { const uuid = await applyStructuredOutput(output, state); if (!uuid) continue; - state.structuredOutputs[output.resourceId] = uuid; + upsertState(state.structuredOutputs, output.resourceId, { + uuid, + lastPushedHash: hashPayload(output.data), + }); applied.structuredOutputs++; } catch (error) { console.error(formatApiError(output.resourceId, error)); @@ -1136,7 +1157,10 @@ async function main(): Promise { try { const uuid = await applyAssistant(assistant, state); if (!uuid) continue; - state.assistants[assistant.resourceId] = uuid; + upsertState(state.assistants, assistant.resourceId, { + uuid, + lastPushedHash: hashPayload(assistant.data), + }); applied.assistants++; } catch (error) { console.error(formatApiError(assistant.resourceId, error)); @@ -1158,7 +1182,10 @@ async function main(): Promise { try { const uuid = await applySquad(squad, state); if (!uuid) continue; - state.squads[squad.resourceId] = uuid; + upsertState(state.squads, squad.resourceId, { + uuid, + lastPushedHash: hashPayload(squad.data), + }); applied.squads++; } catch (error) { console.error(formatApiError(squad.resourceId, error)); @@ -1173,7 +1200,10 @@ async function main(): Promise { try { const uuid = await applyPersonality(personality, state); if (!uuid) continue; - state.personalities[personality.resourceId] = uuid; + upsertState(state.personalities, personality.resourceId, { + uuid, + lastPushedHash: hashPayload(personality.data), + }); applied.personalities++; } catch (error) { console.error(formatApiError(personality.resourceId, error)); @@ -1188,7 +1218,10 @@ async function main(): Promise { try { const uuid = await applyScenario(scenario, state); if (!uuid) continue; - state.scenarios[scenario.resourceId] = uuid; + upsertState(state.scenarios, scenario.resourceId, { + uuid, + lastPushedHash: hashPayload(scenario.data), + }); applied.scenarios++; } catch (error) { console.error(formatApiError(scenario.resourceId, error)); @@ -1203,7 +1236,10 @@ async function main(): Promise { try { const uuid = await applySimulation(simulation, state); if (!uuid) continue; - state.simulations[simulation.resourceId] = uuid; + upsertState(state.simulations, simulation.resourceId, { + uuid, + lastPushedHash: hashPayload(simulation.data), + }); applied.simulations++; } catch (error) { console.error(formatApiError(simulation.resourceId, error)); @@ -1218,7 +1254,10 @@ async function main(): Promise { try { const uuid = await applySimulationSuite(suite, state); if (!uuid) continue; - state.simulationSuites[suite.resourceId] = uuid; + upsertState(state.simulationSuites, suite.resourceId, { + uuid, + lastPushedHash: hashPayload(suite.data), + }); applied.simulationSuites++; } catch (error) { console.error(formatApiError(suite.resourceId, error)); @@ -1232,7 +1271,10 @@ async function main(): Promise { for (const evalResource of evals) { try { const uuid = await applyEval(evalResource, state); - state.evals[evalResource.resourceId] = uuid; + upsertState(state.evals, evalResource.resourceId, { + uuid, + lastPushedHash: hashPayload(evalResource.data), + }); applied.evals++; } catch (error) { console.error(formatApiError(evalResource.resourceId, error)); diff --git a/src/resolver.ts b/src/resolver.ts index 7f0a1a1..358dd57 100644 --- a/src/resolver.ts +++ b/src/resolver.ts @@ -1,4 +1,4 @@ -import type { StateFile } from "./types.ts"; +import type { ResourceState, StateFile } from "./types.ts"; // ───────────────────────────────────────────────────────────────────────────── // ID Resolution - Convert resource IDs to Vapi UUIDs @@ -11,9 +11,14 @@ function isUUID(value: string): boolean { return UUID_REGEX.test(value); } -// Check if a UUID is tracked in a state section (reverse lookup) -function isKnownUUID(uuid: string, stateSection: Record): boolean { - return Object.values(stateSection).includes(uuid); +// Check if a UUID is tracked in a state section (reverse lookup). After +// Stack F, sections store ResourceState entries — extract `.uuid` for the +// reverse-lookup membership check. +function isKnownUUID(uuid: string, stateSection: Record): boolean { + for (const entry of Object.values(stateSection)) { + if (entry.uuid === uuid) return true; + } + return false; } export function resolveToolId( @@ -29,7 +34,7 @@ export function resolveToolId( return cleanId; } - const uuid = state.tools[cleanId]; + const uuid = state.tools[cleanId]?.uuid; if (!uuid) { console.warn(` ⚠️ Tool reference not found: ${cleanId}`); return null; @@ -58,7 +63,7 @@ export function resolveStructuredOutputIds( return cleanId; } - const uuid = state.structuredOutputs[cleanId]; + const uuid = state.structuredOutputs[cleanId]?.uuid; if (!uuid) { console.warn(` ⚠️ Structured output reference not found: ${cleanId}`); return null; @@ -82,7 +87,7 @@ export function resolveAssistantId( return cleanId; } - const uuid = state.assistants[cleanId]; + const uuid = state.assistants[cleanId]?.uuid; if (!uuid) { console.warn(` ⚠️ Assistant reference not found: ${cleanId}`); return null; @@ -110,7 +115,7 @@ export function resolvePersonalityId( return cleanId; } - const uuid = state.personalities[cleanId]; + const uuid = state.personalities[cleanId]?.uuid; if (!uuid) { console.warn(` ⚠️ Personality reference not found: ${cleanId}`); return null; @@ -129,7 +134,7 @@ export function resolveScenarioId( return cleanId; } - const uuid = state.scenarios[cleanId]; + const uuid = state.scenarios[cleanId]?.uuid; if (!uuid) { console.warn(` ⚠️ Scenario reference not found: ${cleanId}`); return null; @@ -148,7 +153,7 @@ export function resolveSimulationId( return cleanId; } - const uuid = state.simulations[cleanId]; + const uuid = state.simulations[cleanId]?.uuid; if (!uuid) { console.warn(` ⚠️ Simulation reference not found: ${cleanId}`); return null; @@ -299,7 +304,7 @@ export function resolveReferences( if (isUUID(cleanId)) { evaluation.structuredOutputId = cleanId; } else { - const uuid = state.structuredOutputs[cleanId]; + const uuid = state.structuredOutputs[cleanId]?.uuid; if (uuid) { evaluation.structuredOutputId = uuid; } else { diff --git a/src/state-serialize.ts b/src/state-serialize.ts index 67975d4..57dbb33 100644 --- a/src/state-serialize.ts +++ b/src/state-serialize.ts @@ -3,6 +3,9 @@ // Kept config-free so tests can import without triggering the CLI argument // parser in `config.ts` (which `process.exit(1)`s when no env is supplied). +import { createHash } from "crypto"; +import type { ResourceState } from "./types.ts"; + // JSON.stringify replacer that emits object keys in alphabetical order at // every nesting level. Without this, the state file diff includes pure // reorderings every time a resource map gets rebuilt from multiple sources @@ -46,3 +49,43 @@ export function canonicalize(value: unknown): unknown { } return sorted; } + +// Stable sha256 of a payload after canonicalization. Used for content drift +// detection (this stack populates the hashes; Stack G consumes them). +export function hashPayload(payload: unknown): string { + const canonical = canonicalize(payload); + return createHash("sha256") + .update(JSON.stringify(canonical)) + .digest("hex"); +} + +// Wrap a legacy state value (bare string UUID) as a ResourceState. Returns +// undefined if the value isn't recognized — `loadState()` migrates the +// shape, so an unrecognized value at load time means a corrupt state file. +export function asResourceState(value: unknown): ResourceState | undefined { + if (typeof value === "string") return { uuid: value }; + if ( + value && + typeof value === "object" && + typeof (value as { uuid?: unknown }).uuid === "string" + ) { + return value as ResourceState; + } + return undefined; +} + +// Update or create the ResourceState entry for a resource with new content +// hashes. Preserves whichever fields aren't being updated (e.g. setting +// lastPushedHash leaves lastPulledHash intact). Critical for drift +// detection — push must not stomp the lastPulledHash that pull populated. +export function upsertState( + section: Record, + resourceId: string, + patch: Partial & { uuid: string }, +): void { + const existing = section[resourceId]; + section[resourceId] = { + ...(existing ?? {}), + ...patch, + }; +} diff --git a/src/state.ts b/src/state.ts index f822e92..aa0f5bc 100644 --- a/src/state.ts +++ b/src/state.ts @@ -1,13 +1,48 @@ import { existsSync, readFileSync } from "fs"; import { rename, writeFile } from "fs/promises"; import { STATE_FILE_PATH, VAPI_ENV } from "./config.ts"; -import { sortedKeysReplacer } from "./state-serialize.ts"; -import type { StateFile } from "./types.ts"; +import { + asResourceState, + hashPayload, + sortedKeysReplacer, + upsertState, +} from "./state-serialize.ts"; +import type { ResourceState, StateFile } from "./types.ts"; -// Re-export the pure helper so callers can pull it from `state.ts` (same -// import line as loadState/saveState) without forcing the config-laden -// module on test code that just wants the serializer. -export { sortedKeysReplacer } from "./state-serialize.ts"; +// Re-export pure helpers so callers can import them from the same file as +// loadState / saveState (less import churn) but the helpers themselves stay +// config-free for testability. +export { + asResourceState, + hashPayload, + sortedKeysReplacer, + upsertState, +} from "./state-serialize.ts"; + +// Returns just the UUID for the most common call site (resolver, push, +// pull). Returns undefined if the value isn't a recognized shape. +export function stateUuid( + section: Record, + resourceId: string, +): string | undefined { + const entry = section[resourceId]; + return entry?.uuid; +} + +// Migrate one section: wrap any legacy string values as { uuid: string }. +// Mutates in place — safe because the parent `loadState()` clones first via +// the empty-state spread. +function migrateSection( + raw: Record | undefined, +): Record { + const out: Record = {}; + if (!raw) return out; + for (const [key, value] of Object.entries(raw)) { + const rs = asResourceState(value); + if (rs) out[key] = rs; + } + return out; +} // ───────────────────────────────────────────────────────────────────────────── // State Management @@ -51,11 +86,27 @@ export function loadState(): StateFile { } console.log(`📄 Loaded state file for environment: ${VAPI_ENV}`); - // Merge with empty state to ensure all keys exist (for backwards compatibility) - return { + // Merge with empty state to ensure all keys exist, then run the legacy + // migration so old state files (Record) become + // Record without rewriting on disk until the next + // saveState(). A "deploy and immediately rollback" scenario therefore does + // NOT corrupt state — the read path is purely additive. + const merged = { ...createEmptyState(), - ...(content as Partial), - } as StateFile; + ...(content as Partial>), + }; + return { + credentials: migrateSection(merged.credentials as Record), + assistants: migrateSection(merged.assistants as Record), + structuredOutputs: migrateSection(merged.structuredOutputs as Record), + tools: migrateSection(merged.tools as Record), + squads: migrateSection(merged.squads as Record), + personalities: migrateSection(merged.personalities as Record), + scenarios: migrateSection(merged.scenarios as Record), + simulations: migrateSection(merged.simulations as Record), + simulationSuites: migrateSection(merged.simulationSuites as Record), + evals: migrateSection(merged.evals as Record), + }; } export async function saveState(state: StateFile): Promise { @@ -63,11 +114,6 @@ export async function saveState(state: StateFile): Promise { // A crash or SIGINT mid-write leaves the original state intact rather than // truncating it. A truncated state file would silently wipe all UUID // mappings on the next load. - // sortedKeysReplacer enforces deterministic key ordering across every - // nested object so two semantically-equal state objects (with different - // insertion orders from push/pull/bootstrap merges) always serialize - // byte-identically. Without this, ~half of the state-file diff is pure - // reordering, which trains reviewers to skim past it. const tmpPath = `${STATE_FILE_PATH}.tmp`; await writeFile( tmpPath, diff --git a/src/types.ts b/src/types.ts index fd51eaf..c41ffc4 100644 --- a/src/types.ts +++ b/src/types.ts @@ -2,17 +2,53 @@ // Types // ───────────────────────────────────────────────────────────────────────────── +// Per-resource state metadata. Stack F architectural pivot: state values +// changed from bare `string` (UUID) to a structured ResourceState carrying +// content hashes, timestamps, and (Stack I) optional platform version IDs. +// +// Backwards compatibility: legacy state files loaded with bare string values +// are migrated at load time in `loadState()` — each string becomes +// { uuid: } with no other fields. The first push or pull after +// migration populates the hash fields. Until then, drift detection (Stack G) +// short-circuits cleanly because `lastPulledHash` is undefined. +// +// Why preserve backwards-compat instead of doing a flag-day migration: +// - Customer state files are committed to git. A breaking schema change +// would require a coordinated merge across every customer fork. +// - The fields are all optional except `uuid`, so existing loaders that +// only need the UUID work unchanged after going through the helpers +// in this module. +export interface ResourceState { + uuid: string; + // sha256 of the canonicalized platform payload at last pull. Set by + // `pull.ts` after `cleanResource()` + canonical sort. Used by Stack G + // drift detection. + lastPulledHash?: string; + // ISO-8601 timestamp of the last pull. Useful for triage when investigating + // "when did this drift?". + lastPulledAt?: string; + // sha256 of the canonicalized payload that was last sent on PATCH/POST. + // Distinct from `lastPulledHash` because we may push without pulling. + lastPushedHash?: string; + // Platform-provided ETag / version identifier (Stack I). Engine populates + // it from response headers when the platform exposes one. + platformVersionId?: string; +} + +// `StateFile` is the on-disk shape of `.vapi-state..json`. After +// Stack F it carries `Record` per section instead of +// bare strings. `loadState()` migrates legacy data automatically. export interface StateFile { - credentials: Record; - assistants: Record; - structuredOutputs: Record; - tools: Record; - squads: Record; - personalities: Record; - scenarios: Record; - simulations: Record; - simulationSuites: Record; - evals: Record; + credentials: Record; + assistants: Record; + structuredOutputs: Record; + tools: Record; + squads: Record; + personalities: Record; + scenarios: Record; + simulations: Record; + simulationSuites: Record; + evals: Record; } export interface ResourceFile> { diff --git a/tests/state-migration.test.ts b/tests/state-migration.test.ts new file mode 100644 index 0000000..d1964d4 --- /dev/null +++ b/tests/state-migration.test.ts @@ -0,0 +1,103 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { + asResourceState, + canonicalize, + hashPayload, + upsertState, +} from "../src/state-serialize.ts"; +import type { ResourceState } from "../src/types.ts"; + +// Stack F — state schema migration coverage. +// +// The architectural pivot wraps each state value as a ResourceState. Legacy +// state files (Record) must keep loading cleanly so the +// rollout is a no-op for customers until their first pull populates the +// hash fields. These specs pin the behavior of the public helpers without +// importing the full state.ts module (which loads config.ts and exits). + +test("asResourceState: wraps a bare string UUID as { uuid }", () => { + const result = asResourceState("uuid-abc-123"); + assert.deepEqual(result, { uuid: "uuid-abc-123" }); +}); + +test("asResourceState: passes through a ResourceState object", () => { + const input: ResourceState = { + uuid: "u", + lastPulledHash: "h", + lastPulledAt: "2026-04-30T12:00:00Z", + }; + assert.equal(asResourceState(input), input); +}); + +test("asResourceState: rejects non-string-non-object values", () => { + assert.equal(asResourceState(null), undefined); + assert.equal(asResourceState(42), undefined); + assert.equal(asResourceState(undefined), undefined); + assert.equal(asResourceState({}), undefined); + assert.equal(asResourceState({ uuid: 42 }), undefined); +}); + +test("upsertState: creates a new entry when none exists", () => { + const section: Record = {}; + upsertState(section, "agent-a", { uuid: "u1" }); + assert.deepEqual(section["agent-a"], { uuid: "u1" }); +}); + +test("upsertState: preserves prior fields not being patched", () => { + const section: Record = { + "agent-a": { + uuid: "u1", + lastPulledHash: "old-hash", + lastPulledAt: "2026-04-29T00:00:00Z", + }, + }; + upsertState(section, "agent-a", { uuid: "u1", lastPushedHash: "new-push-hash" }); + assert.deepEqual(section["agent-a"], { + uuid: "u1", + lastPulledHash: "old-hash", + lastPulledAt: "2026-04-29T00:00:00Z", + lastPushedHash: "new-push-hash", + }); +}); + +test("upsertState: overwrites uuid if it changes", () => { + const section: Record = { + "agent-a": { uuid: "u-old" }, + }; + upsertState(section, "agent-a", { uuid: "u-new" }); + assert.equal(section["agent-a"]!.uuid, "u-new"); +}); + +test("hashPayload: produces stable hash regardless of insertion order", () => { + const a = { z: 1, a: { y: 2, x: 3 } }; + const b = { a: { x: 3, y: 2 }, z: 1 }; + assert.equal(hashPayload(a), hashPayload(b)); +}); + +test("hashPayload: produces different hash for different content", () => { + assert.notEqual(hashPayload({ a: 1 }), hashPayload({ a: 2 })); +}); + +test("hashPayload: drops null/undefined leaves so transient nullish doesn't churn", () => { + // The Vapi API sometimes echoes back fields as `null` and sometimes drops + // them entirely. We don't want this to register as drift. + const a = { name: "X", voicemail: null }; + const b = { name: "X" }; + assert.equal(hashPayload(a), hashPayload(b)); +}); + +test("canonicalize: sorts keys and drops nullish leaves", () => { + const result = canonicalize({ + z: 1, + a: undefined, + b: { y: null, x: "v" }, + }); + // Sorted: { b: { x: "v" }, z: 1 } — `a` dropped, `b.y` dropped + assert.deepEqual(result, { b: { x: "v" }, z: 1 }); +}); + +test("canonicalize: preserves array order", () => { + const result = canonicalize({ ids: ["c", "a", "b"] }); + assert.deepEqual(result, { ids: ["c", "a", "b"] }); +});