From d4ac9d68d251166a6f9b211ef437cf6fe87be55d Mon Sep 17 00:00:00 2001 From: Dhruva Reddy Date: Fri, 1 May 2026 12:45:35 -0700 Subject: [PATCH] refactor: state schema with per-resource content hashes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## ELI5 **Problem.** The state file (`.vapi-state..json`) used to map *name → UUID* and nothing else. So when push went to update a resource, the engine had no way to tell whether someone had edited the resource on the dashboard since you last pulled — there was nothing to compare to. This is the root cause of "drift detection isn't possible," "real rollback isn't possible," and "scoped pushes can't be precise about what they touched": the engine has no per-resource memory of *what was there before*. **What this fix does.** Widens each state entry from a bare `string` (the UUID) to a `ResourceState` object carrying: - `uuid` — the platform UUID (unchanged semantics) - `lastPulledHash` — sha256 of the platform payload at last pull - `lastPulledAt` — ISO timestamp - `lastPushedHash` — sha256 of the last pushed payload - `platformVersionId` — Stack I, populated when platform exposes one Every state-reading and state-writing call site is updated. **No new external behavior ships in this PR alone** — strictly plumbing. Backwards compatible: legacy state files (the old `string` shape) load fine, just without hashes until the next pull/push populates them. The on-disk file isn't rewritten until the next `saveState`, so a "deploy and immediately rollback" doesn't corrupt state. **Outcome you'll notice.** This PR alone changes nothing visible. It's the architectural foundation that **drift detection (Stack G), snapshot rollback (Stack H), and scoped state writes (Stack J)** all depend on. After it lands, your next pull populates `lastPulledHash` for every resource, and the next three PRs unlock real safety guarantees. --- Architectural pivot. State sections move from Record (name → UUID) to Record carrying: - uuid: string (the platform UUID, unchanged semantics) - lastPulledHash?: string (sha256 of canonicalized platform payload) - lastPulledAt?: string (ISO timestamp) - lastPushedHash?: string (sha256 of last pushed payload) - platformVersionId?: string (Stack I — populated when platform exposes one) This is the architectural prerequisite for drift detection (Stack G), snapshot rollback (Stack H), optimistic concurrency (Stack I), and scoped state writes (Stack J). Every state-reading call site is updated, but NO new external behavior ships in this PR — strictly plumbing. Backwards compatibility: - src/state.ts:loadState wraps any legacy bare-string value as { uuid: } at load time. Existing customer state files keep working until their next pull populates hashes. No flag-day migration. - The on-disk file is NOT rewritten until the next saveState, so a "deploy and immediately rollback" scenario does NOT corrupt state. Files: - src/types.ts: ResourceState type, StateFile sections retyped. - src/state-serialize.ts: hashPayload (canonicalize + sha256), asResourceState (legacy migration), upsertState (preserves un-touched fields when patching). - src/state.ts: stateUuid helper for the common case; loadState wraps legacy string entries via migrateSection; re-exports the helpers for ergonomics. - src/pull.ts: each pull populates lastPulledHash + lastPulledAt; credential entries preserve prior metadata when slug+uuid are stable. - src/push.ts: each PATCH/POST populates lastPushedHash via upsertState. All `state.X[id]` reads → `?.uuid`. State assignments → upsertState. - src/cleanup.ts, src/credentials.ts, src/delete.ts, src/eval.ts, src/resolver.ts, src/call.ts: mechanical updates for the new shape. Verified by tsc — no leaks where a bare string is still expected. - tests/state-migration.test.ts: legacy string entries load and round-trip; mixed legacy + new entries; canonicalize stability; hashPayload determinism; upsertState preservation semantics. Closes improvements.md #4 (architectural prerequisite). G/H/I/J unblocked. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --- improvements.md | 2 +- src/call.ts | 7 +-- src/cleanup.ts | 20 ++++--- src/credentials.ts | 8 +-- src/delete.ts | 7 ++- src/eval.ts | 23 +++++--- src/pull.ts | 46 +++++++++------ src/push.ts | 94 ++++++++++++++++++++++--------- src/resolver.ts | 27 +++++---- src/state-serialize.ts | 43 ++++++++++++++ src/state.ts | 76 ++++++++++++++++++++----- src/types.ts | 56 ++++++++++++++---- tests/state-migration.test.ts | 103 ++++++++++++++++++++++++++++++++++ 13 files changed, 404 insertions(+), 108 deletions(-) create mode 100644 tests/state-migration.test.ts 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"] }); +});