diff --git a/docs/guides/how-it-works.md b/docs/guides/how-it-works.md index 0d08adf..4f52ea3 100644 --- a/docs/guides/how-it-works.md +++ b/docs/guides/how-it-works.md @@ -5,7 +5,7 @@ Resources are scoped by organization, with names you choose (not fixed `dev`/`stg`/`prod`). Each org gets: - `.env.` — private API key and base URL -- `.vapi-state..json` — resource name ↔ UUID mappings (nothing else — committed) +- `.vapi-state..json` - committed resource name-to-UUID mappings and optional observed Vapi versions - `.vapi-state-hash//` — last-seen platform content hash per resource, used for drift detection (per-developer, gitignored) - `resources//` — all resource files @@ -136,7 +136,7 @@ Tracks resource ID ↔ Vapi UUID mappings per org: ```json { - "assistants": { "my-assistant": { "uuid": "9c0f3f42-…" } }, + "assistants": { "my-assistant": { "uuid": "9c0f3f42-...", "latestVersion": "v8" } }, "credentials": { "my-cred": { "uuid": "2f6db611-…" } }, "squads": { "my-squad": { "uuid": "51a9e1c7-…" } }, "tools": { "my-tool": { "uuid": "d4b8a2e0-…" } } @@ -145,6 +145,11 @@ Tracks resource ID ↔ Vapi UUID mappings per org: Every resource type has a section. Keys are sorted, so diffs stay readable. +`latestVersion` is optional Vapi metadata recorded in the state file, not desired resource configuration. +Pull and successful writes update the observed version. The CLI excludes it from resource files and content hashes, +and from update payloads for versioned resources, so a version-only change does not create drift. For example, +v6 and v8 can have identical configuration even when v7 contained an intervening change. + ## What Vapi sees Every API request uses the org's private key and identifies the tool with a @@ -161,7 +166,7 @@ requests themselves; there is no separate telemetry. | Path | What it is | | --- | --- | | `resources//` | Your resources, one folder per org | -| `.vapi-state..json` | Name → UUID mappings per org (committed) | +| `.vapi-state..json` | Name → UUID mappings and optional Vapi version metadata per org (committed) | | `.env.` | API key and generated binding IDs (gitignored) | | `promotion.yml`, `vapi-checks.yml` | Promotion pipelines and PR checks (copy from the `*.example.yml` files) | | `resources//.vapi-ignore` | Platform resources this repo shouldn't manage (see `resources/.vapi-ignore.example`) | diff --git a/docs/learnings/sync-behavior.md b/docs/learnings/sync-behavior.md index bbb3eed..5a7b105 100644 --- a/docs/learnings/sync-behavior.md +++ b/docs/learnings/sync-behavior.md @@ -15,7 +15,7 @@ function of which of them exist and whether their contents agree. | Artifact | Where | Committed? | Meaning | |---|---|---|---| | **Local file** (`L`) | `resources///.md\|yml` | yes | The declarative source of truth you edit | -| **State entry** (`S`) | `.vapi-state..json` → `{ "": { "uuid": … } }` | yes | The ONLY thing state stores: which platform UUID a local name is bound to | +| **State entry** (`S`) | `.vapi-state..json` -> `{ "": { "uuid": "...", "latestVersion": "vN" } }` | yes | UUID binding and optional observed Vapi version; version metadata is excluded from content hashes | | **Baseline** (`B`) | `.vapi-state-hash//` | **no** (per-developer, gitignored) | sha256 of the last platform content *you* saw (last pull or push) | | **Dashboard resource** (`D`) | Vapi platform | n/a | What's live | @@ -59,6 +59,14 @@ UUIDs → names): --- +## Version metadata and drift + +`latestVersion` records the most recent Vapi version observed by pull or a +successful write. It is stored in the committed state mapping, not in resource +configuration or the per-developer content baseline. If the assistant config +at v6 is later restored and published as v8 after a different v7, identical v6 +and v8 configs hash the same and do not create a conflict. + ## Content scenarios (resource exists everywhere: L + S + B + D) ### 1. Nothing changed (`clean`) @@ -202,8 +210,10 @@ sweep, which consume the resources today without consuming the verdict. `pull`, `push`, and `apply` all **hard-refuse** with `Run \`npm run migrate\` first.` The migration (no org argument, no token -needed) slims every `.vapi-state..json` to `name → { uuid }` and seeds -each org's `.vapi-state-hash/` from the legacy hashes. Idempotent. +needed) normalizes each `.vapi-state..json` mapping to +`{ uuid, latestVersion? }` and seeds each org's `.vapi-state-hash/` from +the legacy hashes. Existing version metadata is retained and does not +participate in content drift. Idempotent. ### G. Local file renamed diff --git a/improvements.md b/improvements.md index 43be71a..7e5639a 100644 --- a/improvements.md +++ b/improvements.md @@ -90,6 +90,7 @@ you which stack PR closes the row.** | 36 | `cleanup` deletes resources excluded by `.vapi-ignore` | A destructive cleanup can delete resources another team owns | None | RESOLVED 2026-10-03 | | 37 | Resource validation ran only at deploy time, after merge | A config `apply` refuses could merge and block deploys and promotion | #32 | RESOLVED 2026-10-03 (#76) | | 38 | Promotion can't carry a simulation that uses a stock personality by UUID | Gated orgs, and any promoted tests, need local personality files | None | Open | +| 39 | `latestVersion` metadata can cause false content drift | Matching configs can appear drifted across published versions | None | Open | **Active backlog after cleanup:** `#2`, `#6`, `#8`, `#12`, `#20`, `#24–#26`, `#31`, and the open remainder of `#27` (wiring the listing-completeness verdict into push/delete/audit, and moving `cleanup.ts` onto the shared pager). Resolved entries stay in this file as historical incident notes per the maintenance directive; stale superseded backlog rows are not duplicated. @@ -2061,12 +2062,56 @@ dependency check, as `check-payload.ts` already does. --- +## 39. `latestVersion` metadata can cause false content drift + +**Discovered:** 2026-10-10, while tracing version-only drift after a push. + +### Problem + +Vapi can advance a resource version even when its effective configuration later +returns to an earlier value. For example, an assistant can be published as v6, +changed and published as v7, then reverted and published as v8. The v6 and v8 +configurations are identical, but a local v6 resource can conflict with the +platform v8 resource if the version number is treated as configuration. + +### Current behavior (upstream/main before this change) + +The state mapping records the resource UUID but not its observed Vapi version. +`latestVersion` is included in resource data and content hashes, while a +successful POST or PATCH refreshes the hash baseline without recording the +returned version in state. A version-only difference can therefore look like +content drift. The affected paths are `src/types.ts`, `src/canonical.ts`, +`src/resources.ts`, `src/pull.ts`, and `src/push.ts`. + +### Risk + +A version-only difference can trigger an unnecessary conflict even when the +local and dashboard configurations are identical. + +### Current mitigation + +There is no version-aware CLI workaround on the base branch. Compare the +resource configuration manually before resolving a reported conflict. + +### Possible fix + +Store the observed Vapi version as state metadata, exclude it from resource +files and content hashes, and persist the version returned by successful API +writes. The implementation in this PR follows that approach; see +`src/types.ts:20-23`, `src/pull.ts:65-71`, `src/push.ts:149-162`, and +`src/resources.ts:42-48`. + +### Status + +**Open.** + +--- + ## Out of scope (intentionally not improvements) -- **State file is identity-only and not git-ignored.** It's intentionally - committed so all collaborators share the same local→UUID mapping. - The proposal in #4 is *additive* — keep identity mappings, add - content hashes. +- **State file is committed; content hashes are not.** It shares resource + UUID mappings and observed Vapi version metadata across collaborators. + Content baselines remain per-developer in the gitignored hash store. - **`push -- ` does not require an interactive confirmation prompt.** That's a UX choice — adding a prompt would break automation. The right place to add friction is `--dry-run` (#5). diff --git a/src/canonical.ts b/src/canonical.ts index 5c49b62..b8b23bf 100644 --- a/src/canonical.ts +++ b/src/canonical.ts @@ -29,6 +29,7 @@ export const EXCLUDED_FIELDS = [ "orgId", "createdAt", "updatedAt", + "latestVersion", // Vapi version metadata is tracked in the state file "analyticsMetadata", "isDeleted", // Computed/derived fields that shouldn't be synced back diff --git a/src/config.ts b/src/config.ts index bcd45c2..d2d60aa 100644 --- a/src/config.ts +++ b/src/config.ts @@ -302,10 +302,10 @@ export const STATE_FILE_PATH = join(BASE_DIR, `.vapi-state.${VAPI_ENV}.json`); // ───────────────────────────────────────────────────────────────────────────── export const UPDATE_EXCLUDED_KEYS: Record = { - tools: ["type"], - assistants: [], + tools: ["type", "latestVersion"], + assistants: ["latestVersion"], structuredOutputs: ["type"], - squads: [], + squads: ["latestVersion"], personalities: [], scenarios: [], simulations: [], diff --git a/src/migrate-hash-store.ts b/src/migrate-hash-store.ts index a8cf1bc..8f02c30 100644 --- a/src/migrate-hash-store.ts +++ b/src/migrate-hash-store.ts @@ -1,5 +1,6 @@ // One-time migration from the legacy fat state file to the slim -// `name → { uuid }` state file + `.vapi-state-hash//` baseline store. +// `name → { uuid, latestVersion? }` state file + +// `.vapi-state-hash//` baseline store. // // This whole module is a REMOVABLE SEAM. When the migration is no longer // needed, delete this file + `migrate-cmd.ts`, remove the `assertStateMigrated` @@ -33,14 +34,17 @@ export interface MigrationResult { orgs: OrgMigrationResult[]; } -// A legacy entry is anything that isn't already exactly `{ uuid: string }`: -// a bare string (oldest form) or an object carrying extra fields -// (lastPulledHash / lastPulledAt / lastPushedHash / platformVersionId). +// A legacy entry is a bare string (oldest form) or an object carrying fields +// other than the current state metadata (`uuid`, `latestVersion`). function isLegacyEntry(value: unknown): boolean { if (typeof value === "string") return true; if (value && typeof value === "object" && !Array.isArray(value)) { const keys = Object.keys(value as Record); - return keys.some((k) => k !== "uuid"); + const record = value as Record; + return ( + keys.some((k) => k !== "uuid" && k !== "latestVersion") || + ("latestVersion" in record && typeof record.latestVersion !== "string") + ); } return false; } @@ -65,8 +69,9 @@ function uuidOf(value: unknown): string | undefined { } // Treat a top-level value as a "section" if it's a plain object whose values -// look like state entries (string or { uuid }). This avoids hard-coding the -// section list, so the migration keeps working if a section is added later. +// look like state entries (string or `{ uuid, latestVersion? }`). This avoids +// hard-coding the section list, so migration keeps working if a section is +// added later. function isSection(value: unknown): value is Record { return !!value && typeof value === "object" && !Array.isArray(value); } @@ -93,7 +98,10 @@ async function migrateOne( let seeded = 0; let skipped = 0; let needsSlim = false; - const slim: Record> = {}; + const slim: Record< + string, + Record + > = {}; for (const [sectionKey, section] of Object.entries(raw)) { if (!isSection(section)) { @@ -102,7 +110,10 @@ async function migrateOne( (slim as Record)[sectionKey] = section; continue; } - const slimSection: Record = {}; + const slimSection: Record< + string, + { uuid: string; latestVersion?: string } + > = {}; for (const [resourceId, value] of Object.entries(section)) { if (isLegacyEntry(value)) needsSlim = true; const uuid = uuidOf(value); @@ -118,7 +129,14 @@ async function migrateOne( skipped++; } } - slimSection[resourceId] = { uuid }; + const latestVersion = + value && typeof value === "object" + ? (value as { latestVersion?: unknown }).latestVersion + : undefined; + slimSection[resourceId] = { + uuid, + ...(typeof latestVersion === "string" ? { latestVersion } : {}), + }; } slim[sectionKey] = slimSection; } @@ -151,7 +169,7 @@ export async function migrateAll( } // Guard called at the entry of pull/push/apply. Throws if the given state file -// is still in the legacy format (any entry that isn't exactly `{ uuid }`). A +// is still in the legacy format (hash/timestamp fields or bare-string entries). A // missing or empty file is new-style by definition (fresh repos / --bootstrap // must not be blocked). Shape-only check — never inspects the hash store. // diff --git a/src/pull.ts b/src/pull.ts index 2eca30a..b7493b5 100644 --- a/src/pull.ts +++ b/src/pull.ts @@ -62,6 +62,15 @@ const ENDPOINT_MAP: Record = { evals: "/eval", }; +function stateMappingForResource(resource: VapiResource): ResourceState { + return { + uuid: resource.id, + ...(typeof resource.latestVersion === "string" + ? { latestVersion: resource.latestVersion } + : {}), + }; +} + // ───────────────────────────────────────────────────────────────────────────── // Git Helpers (detect locally changed files to skip during pull) // ───────────────────────────────────────────────────────────────────────────── @@ -918,9 +927,9 @@ export async function pullResourceType( }); if (driftCounts) driftCounts[direction]++; - // The drift baseline now lives in the hash store, NOT in the state - // section that gets wholesale-replaced at end-of-loop. So the - // preserve branches just write the `{ uuid }` mapping and leave the + // The content baseline now lives in the hash store, NOT in the state + // section that gets wholesale-replaced at end-of-loop. Preserve + // branches refresh version metadata while leaving the // `.vapi-state-hash//` file untouched — the baseline // survives by construction, no carry-forward needed. if (direction === "both-diverged") { @@ -932,14 +941,15 @@ export async function pullResourceType( platformHash, lastPulledHash: baseline, }); - // Preserve the `{ uuid }` mapping. `resolveBothDivergedResources` + // Preserve the resource mapping. `resolveBothDivergedResources` // will rewrite the baseline to the resolve-mode-appropriate hash // after the per-type loop; if the operator doesn't pass --resolve, // the baseline file is left intact so the next pull still sees drift. - const existing = state[resourceType][resourceId]; - if (existing) { - newStateSection[resourceId] = existing; - } + upsertState( + newStateSection, + resourceId, + stateMappingForResource(resource), + ); skipped++; continue; } @@ -964,7 +974,11 @@ export async function pullResourceType( console.log( ` ⬆️ ${resourceId} (local ahead of dashboard) ${formatDriftLabel(direction)}`, ); - upsertState(newStateSection, resourceId, { uuid: resource.id }); + upsertState( + newStateSection, + resourceId, + stateMappingForResource(resource), + ); skipped++; continue; } @@ -1002,7 +1016,11 @@ export async function pullResourceType( changedFiles.has(yamlPath) ) { console.log(` ✏️ ${resourceId} (locally modified, preserving)`); - upsertState(newStateSection, resourceId, { uuid: resource.id }); + upsertState( + newStateSection, + resourceId, + stateMappingForResource(resource), + ); skipped++; continue; } @@ -1029,7 +1047,11 @@ export async function pullResourceType( const stateMtime = statSync(stateFilePath).mtimeMs; if (localMtime > stateMtime) { console.log(` ✏️ ${resourceId} (locally modified, preserving)`); - upsertState(newStateSection, resourceId, { uuid: resource.id }); + upsertState( + newStateSection, + resourceId, + stateMappingForResource(resource), + ); skipped++; continue; } @@ -1051,7 +1073,11 @@ export async function pullResourceType( console.log( ` 🗑️ ${resourceId} (deleted locally, intent in state — add to .vapi-ignore to stop tracking)`, ); - upsertState(newStateSection, resourceId, { uuid: resource.id }); + upsertState( + newStateSection, + resourceId, + stateMappingForResource(resource), + ); skipped++; continue; } @@ -1113,7 +1139,11 @@ export async function pullResourceType( ` ⚠️ ${resourceType}/${resourceId}: failed to hash post-write disk form; falling back to in-memory hash (may produce phantom drift on next pull)`, ); } - upsertState(newStateSection, resourceId, { uuid: resource.id }); + upsertState( + newStateSection, + resourceId, + stateMappingForResource(resource), + ); await writeBaseline( VAPI_ENV, resource.id, @@ -1209,7 +1239,11 @@ async function resolveBothDivergedResources(options: { // No file write — local is preserved. Set the baseline to the platform // state at resolve time so the next classifyDrift correctly reports // `local-ahead` instead of re-flagging `both-diverged`. - upsertState(section, entry.resourceId, { uuid: entry.resource.id }); + upsertState( + section, + entry.resourceId, + stateMappingForResource(entry.resource), + ); await writeBaseline(VAPI_ENV, entry.resource.id, entry.platformHash); continue; } @@ -1235,7 +1269,11 @@ async function resolveBothDivergedResources(options: { ` ⚠️ ${entry.resourceType}/${entry.resourceId}: failed to hash post-write disk form; falling back to in-memory hash (may produce phantom drift on next pull)`, ); } - upsertState(section, entry.resourceId, { uuid: entry.resource.id }); + upsertState( + section, + entry.resourceId, + stateMappingForResource(entry.resource), + ); await writeBaseline( VAPI_ENV, entry.resource.id, diff --git a/src/push.ts b/src/push.ts index ea80668..9ece1db 100644 --- a/src/push.ts +++ b/src/push.ts @@ -150,9 +150,16 @@ async function writeBaselineFromResponse( uuid: string, response: unknown, state: StateFile, + stateSection: Record, + resourceId: string, ): Promise { if (DRY_RUN) return; if (!response || typeof response !== "object") return; + const latestVersion = (response as Record).latestVersion; + upsertState(stateSection, resourceId, { + uuid, + ...(typeof latestVersion === "string" ? { latestVersion } : {}), + }); try { const credReverse = credentialReverseMap(state); const hash = hashPayload( @@ -198,7 +205,13 @@ async function upsertResourceWithStateRecovery(options: { if (!existingUuid) { console.log(` ✨ Creating ${resourceLabel}: ${resourceId}`); const result = await vapiRequest("POST", createEndpoint, createPayload); - await writeBaselineFromResponse(result.id, result, fullState); + await writeBaselineFromResponse( + result.id, + result, + fullState, + stateSection, + resourceId, + ); return result.id; } @@ -343,7 +356,13 @@ async function upsertResourceWithStateRecovery(options: { // The PATCH response is the full resource as the platform now stores it — // the freshest possible "last known platform state." Hash it as the new // drift baseline so the next push of a further local edit is clean. - await writeBaselineFromResponse(existingUuid, result, fullState); + await writeBaselineFromResponse( + existingUuid, + result, + fullState, + stateSection, + resourceId, + ); return existingUuid; } catch (error) { if (!(error instanceof VapiApiError) || error.statusCode !== 404) { @@ -959,12 +978,24 @@ export async function applyEval( `/eval/${existingUuid}`, updatePayload, ); - await writeBaselineFromResponse(existingUuid, result, state); + await writeBaselineFromResponse( + existingUuid, + result, + state, + state.evals, + resourceId, + ); return existingUuid; } else { console.log(` ✨ Creating eval: ${resourceId}`); const result = await vapiRequest("POST", "/eval", payload); - await writeBaselineFromResponse(result.id, result, state); + await writeBaselineFromResponse( + result.id, + result, + state, + state.evals, + resourceId, + ); return result.id; } } @@ -1019,7 +1050,13 @@ export async function updateToolAssistantRefs( // This PATCH mutates the platform AFTER the main upsert wrote its // baseline — refresh it from the linking response, or the next push would // see drift we caused ourselves. - await writeBaselineFromResponse(uuid, result, state); + await writeBaselineFromResponse( + uuid, + result, + state, + state.tools, + resourceId, + ); } } @@ -1092,7 +1129,13 @@ export async function updateStructuredOutputAssistantRefs( }); // Same post-upsert mutation as the tool-linking PATCH above — refresh // the baseline from the response to avoid self-inflicted drift. - await writeBaselineFromResponse(uuid, result, state); + await writeBaselineFromResponse( + uuid, + result, + state, + state.structuredOutputs, + resourceId, + ); } } } diff --git a/src/resources.ts b/src/resources.ts index 6ac5213..48e4b6d 100644 --- a/src/resources.ts +++ b/src/resources.ts @@ -42,7 +42,9 @@ export function hashLocalResource( const filePath = findLocalResourceFile(type, resourceId); if (!filePath) return null; try { - return hashPayload(parseResourceDataFromFile(filePath)); + const data = parseResourceDataFromFile(filePath); + delete data.latestVersion; + return hashPayload(data); } catch { return null; } diff --git a/src/state-serialize.ts b/src/state-serialize.ts index 9320fff..a760ca8 100644 --- a/src/state-serialize.ts +++ b/src/state-serialize.ts @@ -58,13 +58,12 @@ export function hashPayload(payload: unknown): string { return createHash("sha256").update(JSON.stringify(canonical)).digest("hex"); } -// Normalize a state value to the slim `{ uuid }` shape. Accepts a bare string -// (oldest legacy form) or an object with a `uuid` field, and discards any other -// fields a legacy file may still carry (lastPulledHash, lastPulledAt, -// lastPushedHash). Returns undefined if no uuid can be recovered — at load time -// that means a corrupt state file. Note: the `assertStateMigrated` guard -// normally blocks legacy-shaped files before they reach here; this stripping is -// a belt-and-suspenders so `saveState` can never re-emit a legacy field. +// Normalize a state value to `{ uuid, latestVersion? }`. Accepts a bare string +// (oldest legacy form) or an object with a `uuid` field, and discards legacy +// hash/timestamp fields. Returns undefined if no uuid can be recovered — at +// load time that means a corrupt state file. Note: the `assertStateMigrated` +// guard normally blocks legacy-shaped files before they reach here; this +// stripping is a belt-and-suspenders so `saveState` cannot re-emit them. export function asResourceState(value: unknown): ResourceState | undefined { if (typeof value === "string") return { uuid: value }; if ( @@ -72,20 +71,34 @@ export function asResourceState(value: unknown): ResourceState | undefined { typeof value === "object" && typeof (value as { uuid?: unknown }).uuid === "string" ) { - return { uuid: (value as { uuid: string }).uuid }; + const entry = value as { uuid: string; latestVersion?: unknown }; + return { + uuid: entry.uuid, + ...(typeof entry.latestVersion === "string" + ? { latestVersion: entry.latestVersion } + : {}), + }; } return undefined; } -// Set the `{ uuid }` mapping for a resource. The state file no longer carries -// any per-resource field beyond the UUID, so this is a plain assignment — the -// drift baseline now lives in the hash store, not here. +// Set a resource mapping while retaining its observed Vapi version when the +// caller has no newer version to record. A changed UUID starts a fresh mapping. export function upsertState( section: Record, resourceId: string, patch: ResourceState, ): void { - section[resourceId] = { uuid: patch.uuid }; + const existing = section[resourceId]; + section[resourceId] = { + uuid: patch.uuid, + ...(typeof patch.latestVersion === "string" + ? { latestVersion: patch.latestVersion } + : existing?.uuid === patch.uuid && + typeof existing.latestVersion === "string" + ? { latestVersion: existing.latestVersion } + : {}), + }; } // Pronunciation-dictionary drop check. Detects when a dictionary attachment diff --git a/src/state.ts b/src/state.ts index 502e096..81cf721 100644 --- a/src/state.ts +++ b/src/state.ts @@ -29,7 +29,7 @@ export function stateUuid( return entry?.uuid; } -// Migrate one section: wrap any legacy string values as { uuid: string }. +// Migrate one section: normalize each mapping to { uuid, latestVersion? }. // Mutates in place — safe because the parent `loadState()` clones first via // the empty-state spread. function migrateSection( diff --git a/src/types.ts b/src/types.ts index f0c8ed4..21af88c 100644 --- a/src/types.ts +++ b/src/types.ts @@ -2,16 +2,15 @@ // Types // ───────────────────────────────────────────────────────────────────────────── -// Per-resource state metadata. The state file is a pure `name → { uuid }` -// map: the only thing it records is which platform UUID a local resource -// file is bound to. Nothing else. +// Per-resource state metadata. `latestVersion` records the latest version +// observed from Vapi; it is metadata, not part of the desired resource config. // // The "last known platform state" content hash that drives drift detection // lives OUTSIDE this file, in the gitignored per-developer hash store at // `.vapi-state-hash//` (see `hash-store.ts`). Keeping hashes out -// of the committed state file means the state diff is purely semantic -// (added/removed/re-bound resources) and the drift baseline is local to each -// developer's working copy. +// of the committed state file means state carries identity and observed Vapi +// version metadata, while content hashes remain local to each developer's +// working copy. // // Legacy state files (bare string values, or objects carrying lastPulledHash / // lastPushedHash / lastPulledAt) are NOT loaded by the sync commands — the @@ -20,6 +19,7 @@ // store from the old hashes. export interface ResourceState { uuid: string; + latestVersion?: string; } // `StateFile` is the on-disk shape of `.vapi-state..json`. Each section diff --git a/tests/clean-resource.test.ts b/tests/clean-resource.test.ts index effe9a2..9b42ca3 100644 --- a/tests/clean-resource.test.ts +++ b/tests/clean-resource.test.ts @@ -1,15 +1,21 @@ import assert from "node:assert/strict"; +import { mkdir, rm, rmdir, writeFile } from "node:fs/promises"; +import { randomUUID } from "node:crypto"; +import { join } from "node:path"; import test from "node:test"; +import { hashPayload } from "../src/state-serialize.ts"; // Regression tests for P0-3. // // pull.ts depends on config.ts which calls process.exit(1) at module load // time if VAPI_TOKEN is not set or if argv[2] is not a valid slug. Set both // before dynamic-importing the module under test. -process.argv = ["node", "test", "test-fixture-org"]; +const testOrg = `test-version-${randomUUID().slice(0, 8)}`; +process.argv = ["node", "test", testOrg]; process.env.VAPI_TOKEN = process.env.VAPI_TOKEN || "test-token-not-used"; const { cleanResource } = await import("../src/canonical.ts"); +const { hashLocalResource } = await import("../src/resources.ts"); test("cleanResource strips the EXCLUDED_FIELDS (id, orgId, createdAt, etc.)", () => { const out = cleanResource({ @@ -17,6 +23,7 @@ test("cleanResource strips the EXCLUDED_FIELDS (id, orgId, createdAt, etc.)", () orgId: "org-1", createdAt: "2026-01-01", updatedAt: "2026-01-02", + latestVersion: "v6", isDeleted: false, name: "support-bot", }); @@ -24,10 +31,38 @@ test("cleanResource strips the EXCLUDED_FIELDS (id, orgId, createdAt, etc.)", () assert.equal(out.orgId, undefined); assert.equal(out.createdAt, undefined); assert.equal(out.updatedAt, undefined); + assert.equal(out.latestVersion, undefined); assert.equal(out.isDeleted, undefined); assert.equal(out.name, "support-bot"); }); +test("cleanResource hashes the same config identically across Vapi versions", () => { + const v6 = cleanResource({ id: "u", latestVersion: "v6", name: "agent" }); + const v8 = cleanResource({ id: "u", latestVersion: "v8", name: "agent" }); + assert.equal(hashPayload(v6), hashPayload(v8)); +}); + +test("hashLocalResource ignores latestVersion in an existing resource file", async () => { + const toolsDir = join(process.cwd(), "resources", testOrg, "tools"); + const resourceId = "version-hash-fixture"; + const filePath = join(toolsDir, `${resourceId}.yml`); + await mkdir(toolsDir, { recursive: true }); + try { + await writeFile(filePath, "name: same-config\nlatestVersion: v6\n"); + const v6Hash = hashLocalResource("tools", resourceId); + await writeFile(filePath, "name: same-config\nlatestVersion: v8\n"); + const v8Hash = hashLocalResource("tools", resourceId); + assert.ok(v6Hash); + assert.equal(v6Hash, v8Hash); + } finally { + await rm(filePath, { force: true }); + await rmdir(toolsDir).catch(() => undefined); + await rmdir(join(process.cwd(), "resources", testOrg)).catch( + () => undefined, + ); + } +}); + test("cleanResource strips undefined values", () => { const out = cleanResource({ id: "uuid-1234", diff --git a/tests/config-update-exclusions.test.ts b/tests/config-update-exclusions.test.ts new file mode 100644 index 0000000..37ac8bc --- /dev/null +++ b/tests/config-update-exclusions.test.ts @@ -0,0 +1,58 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +// config.ts parses the command-line environment at import time. +const originalArgv = process.argv; +const originalPrivateKey = process.env.VAPI_PRIVATE_API_KEY; +const originalLegacyToken = process.env.VAPI_TOKEN; +process.argv = [...originalArgv.slice(0, 2), "test-org"]; +process.env.VAPI_PRIVATE_API_KEY = "test-only"; +const { removeExcludedKeys } = await import("../src/config.ts"); +process.argv = originalArgv; +if (originalPrivateKey === undefined) delete process.env.VAPI_PRIVATE_API_KEY; +else process.env.VAPI_PRIVATE_API_KEY = originalPrivateKey; +if (originalLegacyToken === undefined) delete process.env.VAPI_TOKEN; +else process.env.VAPI_TOKEN = originalLegacyToken; + +test("tool update payloads exclude latestVersion", () => { + const payload = { + id: "tool-id", + type: "apiRequest", + latestVersion: "v1", + name: "lookup_customer", + }; + + assert.deepEqual(removeExcludedKeys(payload, "tools"), { + id: "tool-id", + name: "lookup_customer", + }); + assert.equal(payload.latestVersion, "v1"); +}); + +test("assistant update payloads exclude latestVersion", () => { + const payload = { + id: "assistant-id", + latestVersion: "v1", + name: "Support Assistant", + }; + + assert.deepEqual(removeExcludedKeys(payload, "assistants"), { + id: "assistant-id", + name: "Support Assistant", + }); + assert.equal(payload.latestVersion, "v1"); +}); + +test("squad update payloads exclude latestVersion", () => { + const payload = { + id: "squad-id", + latestVersion: "v1", + name: "Support Squad", + }; + + assert.deepEqual(removeExcludedKeys(payload, "squads"), { + id: "squad-id", + name: "Support Squad", + }); + assert.equal(payload.latestVersion, "v1"); +}); diff --git a/tests/pull-rename-preserves-filename.test.ts b/tests/pull-rename-preserves-filename.test.ts index 7405380..224aece 100644 --- a/tests/pull-rename-preserves-filename.test.ts +++ b/tests/pull-rename-preserves-filename.test.ts @@ -96,6 +96,7 @@ function renamedDashboardBody() { return { id: UUID_X, orgId: "org-test", + latestVersion: "v8", name: "Call Transfer Test 1", model: { provider: "openai", @@ -119,7 +120,7 @@ voice: marker:original-local `; -test("pull: dashboard rename of tracked resource preserves the local filename (no duplicate)", async () => { +test("pull: tracked assistant preserves its filename and stores latestVersion in state", async () => { const dir = mkdtempSync(join(tmpdir(), "vapi-pull-rename-")); cpSync(join(REPO_ROOT, "src"), join(dir, "src"), { recursive: true }); @@ -210,6 +211,11 @@ test("pull: dashboard rename of tracked resource preserves the local filename (n `${TRACKED_SLUG}.md must still exist; dir: ${readdirSync(assistantsDir).join(", ")}`, ); const content = readFileSync(trackedPath, "utf-8"); + assert.doesNotMatch( + content, + /latestVersion/, + "version metadata stays out of the resource file", + ); assert.match( content, /marker:renamed-on-dashboard/, @@ -237,6 +243,12 @@ test("pull: dashboard rename of tracked resource preserves the local filename (n UUID_X, `state[${TRACKED_SLUG}] must still map to X; got ${JSON.stringify(finalState.assistants)}`, ); + assert.equal( + finalState.assistants[TRACKED_SLUG]?.latestVersion, + "v8", + "pull records the dashboard latestVersion in state metadata", + ); + assert.equal( finalState.assistants[NAME_DERIVED_SLUG], undefined, diff --git a/tests/push-stale-baseline-noop.test.ts b/tests/push-stale-baseline-noop.test.ts index 882ce3d..def85ed 100644 --- a/tests/push-stale-baseline-noop.test.ts +++ b/tests/push-stale-baseline-noop.test.ts @@ -4,6 +4,7 @@ import { cpSync, mkdirSync, mkdtempSync, + readFileSync, rmSync, symlinkSync, writeFileSync, @@ -37,10 +38,11 @@ const SLUG = "stale-baseline-bot-c95f4c6b"; // Dashboard payload. cleanResource strips id/orgId; the remaining shape must // canonicalize to exactly what the local .md parses to (see LOCAL_MD). -function dashboardBody() { +function dashboardBody(latestVersion = "v8") { return { id: UUID, orgId: "org-test", + latestVersion, name: "Stale Baseline Bot", model: { provider: "openai", @@ -114,7 +116,7 @@ function startStub( }); } -test("push: stale lastPulledHash does not block when local and dashboard agree", async () => { +test("push: stale baseline does not block and PATCH latestVersion is saved to state", async () => { const dir = mkdtempSync(join(tmpdir(), "vapi-push-stale-")); cpSync(join(REPO_ROOT, "src"), join(dir, "src"), { recursive: true }); @@ -161,7 +163,7 @@ test("push: stale lastPulledHash does not block when local and dashboard agree", // Route order matters: the by-id GET must match before the list GET. const { worker, port } = await startStub([ { method: "GET", pathStartsWith: `/assistant/${UUID}`, body: dashboardBody() }, - { method: "PATCH", pathStartsWith: `/assistant/${UUID}`, body: dashboardBody() }, + { method: "PATCH", pathStartsWith: `/assistant/${UUID}`, body: dashboardBody("v9") }, { method: "GET", pathStartsWith: "/assistant", body: [dashboardBody()] }, ]); @@ -215,6 +217,14 @@ test("push: stale lastPulledHash does not block when local and dashboard agree", /Applied 1 resource/, `push must apply the resource (PATCH no-op)\n${out}`, ); + + const savedState = JSON.parse( + readFileSync(join(dir, `.vapi-state.${ENV}.json`), "utf-8"), + ); + assert.deepEqual(savedState.assistants[SLUG], { + uuid: UUID, + latestVersion: "v9", + }); } finally { worker.postMessage({ type: "shutdown" }); await new Promise((resolveShutdown) => { diff --git a/tests/state-migration.test.ts b/tests/state-migration.test.ts index 069bcef..78ce6d0 100644 --- a/tests/state-migration.test.ts +++ b/tests/state-migration.test.ts @@ -1,5 +1,11 @@ import assert from "node:assert/strict"; import test from "node:test"; +import { mkdtemp, readFile, rm, rmdir, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { + assertStateMigrated, + migrateAll, +} from "../src/migrate-hash-store.ts"; import { asResourceState, canonicalize, @@ -10,8 +16,8 @@ import type { ResourceState } from "../src/types.ts"; // Stack F — state schema migration coverage. // -// Each state value is a ResourceState — a pure `{ uuid }`. Drift baselines -// moved to the per-developer hash store (.vapi-state-hash/), so the helpers +// Each state value is a ResourceState — `{ uuid, latestVersion? }`. Drift +// baselines moved to the per-developer hash store (.vapi-state-hash/), so helpers // must strip any legacy hash/timestamp field rather than carry it forward: // saveState must never re-emit one. These specs pin the behavior of the public // helpers without importing the full state.ts module (which loads config.ts @@ -22,10 +28,17 @@ test("asResourceState: wraps a bare string UUID as { uuid }", () => { assert.deepEqual(result, { uuid: "uuid-abc-123" }); }); -test("asResourceState: keeps only the uuid of an object entry", () => { +test("asResourceState: keeps the uuid of an object entry", () => { assert.deepEqual(asResourceState({ uuid: "u" }), { uuid: "u" }); }); +test("asResourceState: preserves latestVersion metadata", () => { + assert.deepEqual(asResourceState({ uuid: "u", latestVersion: "v8" }), { + uuid: "u", + latestVersion: "v8", + }); +}); + test("asResourceState: strips legacy hash/timestamp fields", () => { const legacy = { uuid: "u", @@ -44,19 +57,68 @@ test("asResourceState: rejects non-string-non-object values", () => { assert.equal(asResourceState({ uuid: 42 }), undefined); }); +test("state migration accepts and preserves latestVersion metadata", async () => { + const dir = await mkdtemp(join(process.cwd(), ".test-state-version-")); + const statePath = join(dir, ".vapi-state.fixture.json"); + try { + await writeFile( + statePath, + JSON.stringify({ + assistants: { agent: { uuid: "u1", latestVersion: "v8" } }, + }), + ); + assert.doesNotThrow(() => assertStateMigrated(statePath)); + + await writeFile( + statePath, + JSON.stringify({ + assistants: { + agent: { + uuid: "u1", + latestVersion: "v8", + lastPulledAt: "legacy", + }, + }, + }), + ); + await migrateAll(dir); + const migrated = JSON.parse(await readFile(statePath, "utf-8")); + assert.deepEqual(migrated.assistants.agent, { + uuid: "u1", + latestVersion: "v8", + }); + } finally { + await rm(statePath, { force: true }); + await rm(`${statePath}.tmp`, { force: true }); + await rmdir(dir).catch(() => 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: writes only the uuid, even when the patch carries legacy fields", () => { +test("upsertState: ignores legacy fields and accepts latestVersion metadata", () => { // A caller still holding an old-shaped object must not smuggle a hash back // into the state file; baselines belong to the hash store. const section: Record = {}; - const legacyPatch = { uuid: "u1", lastPushedHash: "new-push-hash" }; + const legacyPatch = { + uuid: "u1", + latestVersion: "v3", + lastPushedHash: "new-push-hash", + }; upsertState(section, "agent-a", legacyPatch); - assert.deepEqual(section["agent-a"], { uuid: "u1" }); + assert.deepEqual(section["agent-a"], { uuid: "u1", latestVersion: "v3" }); +}); + +test("upsertState: an update without a version preserves the observed version", () => { + const section: Record = { + "agent-a": { uuid: "u1", latestVersion: "v8" }, + }; + upsertState(section, "agent-a", { uuid: "u1" }); + assert.deepEqual(section["agent-a"], { uuid: "u1", latestVersion: "v8" }); }); test("upsertState: overwrites uuid if it changes", () => { @@ -64,7 +126,7 @@ test("upsertState: overwrites uuid if it changes", () => { "agent-a": { uuid: "u-old" }, }; upsertState(section, "agent-a", { uuid: "u-new" }); - assert.equal(section["agent-a"]!.uuid, "u-new"); + assert.deepEqual(section["agent-a"], { uuid: "u-new" }); }); test("hashPayload: produces stable hash regardless of insertion order", () => {