Skip to content

Commit 1efe5ec

Browse files
committed
refactor: state schema with per-resource content hashes
## ELI5 **Problem.** The state file (`.vapi-state.<env>.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<string, string> (name → UUID) to Record<string, ResourceState> 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: <string> } 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)
1 parent 346fbf7 commit 1efe5ec

13 files changed

Lines changed: 404 additions & 108 deletions

improvements.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,7 @@ you which stack PR closes the row.**
5555
| 1 | `push` drift detection | Prevent silent overwrites of dashboard edits | #4 | Open (Stack G planned) |
5656
| 2 | `apply` same-file conflict | `apply` drops concurrent same-file dashboard edits | #4 | Open (Stack G planned) |
5757
| 3 | Rollback | Current undo can clobber newer live changes | #4, #5 | Open (Stack H planned) |
58-
| 4 | State schema content hashes | Architectural unlock for #1, #2, #3, #6, #7 | None | Open (Stack F planned) |
58+
| 4 | State schema content hashes | Architectural unlock for #1, #2, #3, #6, #7 | None | RESOLVED 2026-04-30 (Stack F) |
5959
| 5 | `push --dry-run` | Cheapest operator-safety win | None | RESOLVED 2026-04-30 (Stack C) |
6060
| 6 | API-level optimistic concurrency | Server-side conflict rejection | Platform | Deferred (Stack I, gated) |
6161
| 7 | Voice edits drop pronunciation-dictionary attachments | Silent regression on Cartesia + 11labs voice edits | #4 | Open (Stack G planned) |

src/call.ts

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -292,9 +292,8 @@ function resolveTarget(
292292
resourceType: ResourceType,
293293
): string {
294294
if (resourceType === "squad") {
295-
const squads =
296-
(state as StateFile & { squads?: Record<string, string> }).squads || {};
297-
const uuid = squads[target];
295+
const squads = state.squads || {};
296+
const uuid = squads[target]?.uuid;
298297
if (!uuid) {
299298
console.error(`❌ Squad not found: ${target}`);
300299
console.error(" Available squads:");
@@ -308,7 +307,7 @@ function resolveTarget(
308307
}
309308
return uuid;
310309
} else {
311-
const uuid = state.assistants[target];
310+
const uuid = state.assistants[target]?.uuid;
312311
if (!uuid) {
313312
console.error(`❌ Assistant not found: ${target}`);
314313
console.error(" Available assistants:");

src/cleanup.ts

Lines changed: 11 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -111,16 +111,18 @@ async function main(): Promise<void> {
111111
}
112112

113113
const state = loadState();
114+
// Stack F: state values are ResourceState objects, not bare UUIDs. Extract
115+
// each .uuid for the orphan-detection set.
114116
const stateIds = new Set([
115-
...Object.values(state.assistants),
116-
...Object.values(state.tools),
117-
...Object.values(state.structuredOutputs),
118-
...Object.values(state.squads),
119-
...Object.values(state.personalities),
120-
...Object.values(state.scenarios),
121-
...Object.values(state.simulations),
122-
...Object.values(state.simulationSuites),
123-
...Object.values(state.evals),
117+
...Object.values(state.assistants).map((e) => e.uuid),
118+
...Object.values(state.tools).map((e) => e.uuid),
119+
...Object.values(state.structuredOutputs).map((e) => e.uuid),
120+
...Object.values(state.squads).map((e) => e.uuid),
121+
...Object.values(state.personalities).map((e) => e.uuid),
122+
...Object.values(state.scenarios).map((e) => e.uuid),
123+
...Object.values(state.simulations).map((e) => e.uuid),
124+
...Object.values(state.simulationSuites).map((e) => e.uuid),
125+
...Object.values(state.evals).map((e) => e.uuid),
124126
]);
125127

126128
// A state file with zero tracked resources is almost always a fresh clone,

src/credentials.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -16,16 +16,16 @@ import type { StateFile } from "./types.ts";
1616

1717
export function credentialReverseMap(state: StateFile): Map<string, string> {
1818
const map = new Map<string, string>();
19-
for (const [name, uuid] of Object.entries(state.credentials)) {
20-
map.set(uuid, name);
19+
for (const [name, entry] of Object.entries(state.credentials)) {
20+
map.set(entry.uuid, name);
2121
}
2222
return map;
2323
}
2424

2525
export function credentialForwardMap(state: StateFile): Map<string, string> {
2626
const map = new Map<string, string>();
27-
for (const [name, uuid] of Object.entries(state.credentials)) {
28-
map.set(name, uuid);
27+
for (const [name, entry] of Object.entries(state.credentials)) {
28+
map.set(name, entry.uuid);
2929
}
3030
return map;
3131
}

src/delete.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { FORCE_DELETE } from "./config.ts";
33
import { extractReferencedIds } from "./resolver.ts";
44
import type {
55
ResourceFile,
6+
ResourceState,
67
StateFile,
78
LoadedResources,
89
OrphanedResource,
@@ -15,13 +16,13 @@ import type {
1516

1617
export function findOrphanedResources(
1718
loadedResourceIds: string[],
18-
stateResourceIds: Record<string, string>
19+
stateResourceIds: Record<string, ResourceState>
1920
): OrphanedResource[] {
2021
const orphaned: OrphanedResource[] = [];
2122

22-
for (const [resourceId, uuid] of Object.entries(stateResourceIds)) {
23+
for (const [resourceId, entry] of Object.entries(stateResourceIds)) {
2324
if (!loadedResourceIds.includes(resourceId)) {
24-
orphaned.push({ resourceId, uuid });
25+
orphaned.push({ resourceId, uuid: entry.uuid });
2526
}
2627
}
2728

src/eval.ts

Lines changed: 15 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -250,10 +250,13 @@ function loadVariables(config: EvalConfig): Record<string, unknown> | undefined
250250

251251
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
252252

253-
function resolveId(id: string, stateSection: Record<string, string>): string {
253+
function resolveId(
254+
id: string,
255+
stateSection: Record<string, { uuid: string } | undefined>,
256+
): string {
254257
const clean = id.split("##")[0]?.trim() ?? "";
255258
if (UUID_RE.test(clean)) return clean;
256-
return stateSection[clean] ?? clean;
259+
return stateSection[clean]?.uuid ?? clean;
257260
}
258261

259262
function resolveAssistantConfig(config: Record<string, unknown>, state: StateFile): Record<string, unknown> {
@@ -278,7 +281,9 @@ function resolveAssistantConfig(config: Record<string, unknown>, state: StateFil
278281
}
279282
}
280283
// Resolve credentials
281-
const credMap = new Map(Object.entries(state.credentials));
284+
const credMap = new Map(
285+
Object.entries(state.credentials).map(([slug, rs]) => [slug, rs.uuid]),
286+
);
282287
if (credMap.size > 0) return deepReplace(resolved, credMap) as Record<string, unknown>;
283288
return resolved;
284289
}
@@ -314,7 +319,9 @@ async function resolveSquadConfig(config: Record<string, unknown>, state: StateF
314319
}
315320
}
316321
}
317-
const credMap = new Map(Object.entries(state.credentials));
322+
const credMap = new Map(
323+
Object.entries(state.credentials).map(([slug, rs]) => [slug, rs.uuid]),
324+
);
318325
if (credMap.size > 0) return deepReplace(resolved, credMap) as Record<string, unknown>;
319326
return resolved;
320327
}
@@ -344,9 +351,9 @@ function loadEvals(state: StateFile, filter?: string): EvalDefinition[] {
344351
const evalState = state.evals ?? {};
345352
const evals: EvalDefinition[] = [];
346353

347-
for (const [resourceId, uuid] of Object.entries(evalState)) {
354+
for (const [resourceId, entry] of Object.entries(evalState)) {
348355
if (filter && !resourceId.toLowerCase().includes(filter.toLowerCase())) continue;
349-
evals.push({ resourceId, evalId: uuid, name: resourceId });
356+
evals.push({ resourceId, evalId: entry.uuid, name: resourceId });
350357
}
351358

352359
return evals;
@@ -443,7 +450,7 @@ async function main(): Promise<void> {
443450

444451
if (config.squadName) {
445452
if (config.useStored) {
446-
const squadId = state.squads[config.squadName];
453+
const squadId = state.squads[config.squadName]?.uuid;
447454
if (!squadId) {
448455
console.error(`❌ Squad not found in state: ${config.squadName}`);
449456
console.error(" Available: " + Object.keys(state.squads).join(", "));
@@ -466,7 +473,7 @@ async function main(): Promise<void> {
466473
}
467474
} else {
468475
if (config.useStored) {
469-
const assistantId = state.assistants[config.assistantName!];
476+
const assistantId = state.assistants[config.assistantName!]?.uuid;
470477
if (!assistantId) {
471478
console.error(`❌ Assistant not found in state: ${config.assistantName}`);
472479
process.exit(1);

src/pull.ts

Lines changed: 29 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -15,9 +15,9 @@ import {
1515
loadIgnorePatterns,
1616
matchesIgnore,
1717
} from "./config.ts";
18-
import { loadState, saveState } from "./state.ts";
18+
import { hashPayload, loadState, saveState, upsertState } from "./state.ts";
1919
import { credentialReverseMap, replaceCredentialRefs } from "./credentials.ts";
20-
import type { StateFile, ResourceType } from "./types.ts";
20+
import type { ResourceState, StateFile, ResourceType } from "./types.ts";
2121

2222
// ─────────────────────────────────────────────────────────────────────────────
2323
// Types
@@ -221,11 +221,11 @@ async function pullCredentials(state: StateFile): Promise<void> {
221221
const credentials = await fetchCredentials();
222222
console.log(` Found ${credentials.length} credentials in Vapi`);
223223

224-
const newSection: Record<string, string> = {};
224+
const newSection: Record<string, ResourceState> = {};
225225
// Build reverse map from existing state to preserve slug stability
226226
const existingReverse = new Map<string, string>();
227-
for (const [slug, uuid] of Object.entries(state.credentials)) {
228-
existingReverse.set(uuid, slug);
227+
for (const [slug, entry] of Object.entries(state.credentials)) {
228+
existingReverse.set(entry.uuid, slug);
229229
}
230230

231231
for (const cred of credentials) {
@@ -234,7 +234,12 @@ async function pullCredentials(state: StateFile): Promise<void> {
234234
if (!slug) {
235235
slug = credentialSlug(cred);
236236
}
237-
newSection[slug] = cred.id;
237+
// Preserve existing hash metadata if the slug+uuid pair survives.
238+
const prior = state.credentials[slug];
239+
newSection[slug] =
240+
prior && prior.uuid === cred.id
241+
? prior
242+
: { uuid: cred.id, lastPulledAt: new Date().toISOString() };
238243
console.log(` 🔑 ${slug} -> ${cred.id}`);
239244
}
240245

@@ -327,12 +332,12 @@ function findExistingResourceId(
327332
}
328333

329334
function removeUuidMappings(
330-
stateSection: Record<string, string>,
335+
stateSection: Record<string, ResourceState>,
331336
uuid: string,
332337
keepResourceId?: string,
333338
): void {
334-
for (const [resourceId, mappedUuid] of Object.entries(stateSection)) {
335-
if (mappedUuid === uuid && resourceId !== keepResourceId) {
339+
for (const [resourceId, entry] of Object.entries(stateSection)) {
340+
if (entry.uuid === uuid && resourceId !== keepResourceId) {
336341
delete stateSection[resourceId];
337342
}
338343
}
@@ -368,8 +373,8 @@ function buildReverseMap(
368373
const map = new Map<string, string>();
369374
const stateSection = state[resourceType];
370375

371-
for (const [resourceId, uuid] of Object.entries(stateSection)) {
372-
map.set(uuid, resourceId);
376+
for (const [resourceId, entry] of Object.entries(stateSection)) {
377+
map.set(entry.uuid, resourceId);
373378
}
374379

375380
return map;
@@ -653,7 +658,7 @@ export async function pullResourceType(
653658

654659
const reverseMap = buildReverseMap(state, resourceType);
655660
const credReverse = credentialReverseMap(state);
656-
const newStateSection: Record<string, string> = resourceIds?.length
661+
const newStateSection: Record<string, ResourceState> = resourceIds?.length
657662
? { ...state[resourceType] }
658663
: {};
659664
const existingResourceIds = bootstrap
@@ -728,7 +733,7 @@ export async function pullResourceType(
728733
changedFiles.has(yamlPath)
729734
) {
730735
console.log(` ✏️ ${resourceId} (locally modified, preserving)`);
731-
newStateSection[resourceId] = resource.id;
736+
upsertState(newStateSection, resourceId, { uuid: resource.id });
732737
skipped++;
733738
continue;
734739
}
@@ -761,7 +766,7 @@ export async function pullResourceType(
761766
const stateMtime = statSync(stateFilePath).mtimeMs;
762767
if (localMtime > stateMtime) {
763768
console.log(` ✏️ ${resourceId} (locally modified, preserving)`);
764-
newStateSection[resourceId] = resource.id;
769+
upsertState(newStateSection, resourceId, { uuid: resource.id });
765770
skipped++;
766771
continue;
767772
}
@@ -783,7 +788,7 @@ export async function pullResourceType(
783788
console.log(
784789
` 🗑️ ${resourceId} (deleted locally, intent in state — add to .vapi-ignore to stop tracking)`,
785790
);
786-
newStateSection[resourceId] = resource.id;
791+
upsertState(newStateSection, resourceId, { uuid: resource.id });
787792
skipped++;
788793
continue;
789794
}
@@ -825,8 +830,15 @@ export async function pullResourceType(
825830
if (isNew) created++;
826831
else updated++;
827832

828-
// Update state
829-
newStateSection[resourceId] = resource.id;
833+
// Update state with new content hash + timestamp (Stack F).
834+
// Hashing the resolved-with-credentials payload (the form we will save
835+
// to disk) keeps `lastPulledHash` aligned with the source-of-truth diff
836+
// basis used by drift detection in Stack G.
837+
upsertState(newStateSection, resourceId, {
838+
uuid: resource.id,
839+
lastPulledHash: hashPayload(withCredNames),
840+
lastPulledAt: new Date().toISOString(),
841+
});
830842
}
831843

832844
// Update state with new mappings

0 commit comments

Comments
 (0)