diff --git a/improvements.md b/improvements.md index 220bb24..2df5c8e 100644 --- a/improvements.md +++ b/improvements.md @@ -52,13 +52,13 @@ you which stack PR closes the row.** | # | Title | Why it matters | Depends on | Status | | --- | -------------------------------------------------------- | -------------------------------------------------- | ---------- | --------------------------------- | -| 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) | +| 1 | `push` drift detection | Prevent silent overwrites of dashboard edits | #4 | RESOLVED 2026-04-30 (Stack G) | +| 2 | `apply` same-file conflict | `apply` drops concurrent same-file dashboard edits | #4 | Partial — Stack G GET on push | | 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 | 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) | +| 7 | Voice edits drop pronunciation-dictionary attachments | Silent regression on Cartesia + 11labs voice edits | #4 | RESOLVED 2026-04-30 (Stack G) | | 8 | Dashboard prompt edits can in-place duplicate the prompt | Two stacked prompt versions = stitched output | None | Partial — Stack D heuristic | | 9 | Provider-specific voice schema mismatch (push 400) | `voice.speed` vs `voice.generationConfig.speed` | None | RESOLVED 2026-04-30 (Stack D + A) | | 10 | Targeted assistant push mints duplicate tools | Re-pushing assistant duplicates `end-call-*` tools | #4 | Partial | diff --git a/src/config.ts b/src/config.ts index 6288c4a..b20eddd 100644 --- a/src/config.ts +++ b/src/config.ts @@ -87,6 +87,7 @@ function parseFlags(): { bootstrapSync: boolean; dryRun: boolean; strictValidation: boolean; + overwriteDrift: boolean; applyFilter: ApplyFilter; } { const args = process.argv.slice(3); @@ -95,12 +96,14 @@ function parseFlags(): { bootstrapSync: boolean; dryRun: boolean; strictValidation: boolean; + overwriteDrift: boolean; applyFilter: ApplyFilter; } = { forceDelete: args.includes("--force"), bootstrapSync: args.includes("--bootstrap"), dryRun: args.includes("--dry-run"), strictValidation: args.includes("--strict"), + overwriteDrift: args.includes("--overwrite"), applyFilter: {}, }; @@ -115,7 +118,8 @@ function parseFlags(): { arg === "--force" || arg === "--bootstrap" || arg === "--dry-run" || - arg === "--strict" + arg === "--strict" || + arg === "--overwrite" ) continue; @@ -252,6 +256,7 @@ export const { bootstrapSync: BOOTSTRAP_SYNC, dryRun: DRY_RUN, strictValidation: STRICT_VALIDATION, + overwriteDrift: OVERWRITE_DRIFT, applyFilter: APPLY_FILTER, } = parseFlags(); diff --git a/src/drift.ts b/src/drift.ts new file mode 100644 index 0000000..53f88e5 --- /dev/null +++ b/src/drift.ts @@ -0,0 +1,134 @@ +// ───────────────────────────────────────────────────────────────────────────── +// Drift detection — Stack G +// +// Before each PATCH, GET the current platform payload, hash it, and compare +// to the `lastPulledHash` recorded in state. If the hashes differ, the +// dashboard has drifted away from the version we last pulled — refuse to +// push without `--overwrite` (improvements.md #1, #2, #7). +// +// Behavior matrix: +// - No `lastPulledHash` (e.g., legacy state, first push after Stack F): +// log "drift unknown — proceeding" and continue. Don't block. +// - Hashes match: continue silently. +// - Hashes differ + no --overwrite: refuse the push, return false. +// - Hashes differ + --overwrite: log "overwriting drift" and continue. +// +// The check fires GET against the same endpoint the apply function would +// PATCH. We don't centralize it inside `vapiRequest` because POST (create) +// has nothing to compare against — only PATCH (update) is drift-sensitive. +// ───────────────────────────────────────────────────────────────────────────── + +import { hashPayload } from "./state-serialize.ts"; +import { VAPI_BASE_URL, VAPI_TOKEN } from "./config.ts"; +import type { ResourceState } from "./types.ts"; + +export interface DriftCheckResult { + ok: boolean; + reason: "no-baseline" | "match" | "drift-overwritten" | "drift-blocked"; + message?: string; + // Hash of the *current* platform payload — caller may want to update + // state's `lastPulledHash` after a successful push so subsequent pushes + // start from the platform's current state, not the stale pre-overwrite hash. + platformHash?: string; +} + +async function fetchPlatformPayload( + endpoint: string, +): Promise { + // GET against the same path the PATCH would target. 404 means the resource + // was deleted on the dashboard — let the upsert path handle it (the existing + // 404 → "stale mapping, drop and skip" recovery in + // upsertResourceWithStateRecovery covers this case). + const response = await fetch(`${VAPI_BASE_URL}${endpoint}`, { + method: "GET", + headers: { Authorization: `Bearer ${VAPI_TOKEN}` }, + }); + if (response.status === 404) return null; + if (!response.ok) { + const text = await response.text(); + throw new Error(`Drift GET ${endpoint} → ${response.status}: ${text}`); + } + return response.json(); +} + +// Strip server-managed fields before hashing so the platform's payload hash +// matches the last-pulled-hash basis (which excluded them via cleanResource). +const SERVER_FIELDS = new Set([ + "id", + "orgId", + "createdAt", + "updatedAt", + "analyticsMetadata", + "isDeleted", + "isServerUrlSecretSet", + "workflowIds", +]); + +function stripServerFields(payload: unknown): unknown { + if (!payload || typeof payload !== "object" || Array.isArray(payload)) { + return payload; + } + const out: Record = {}; + for (const [k, v] of Object.entries(payload as Record)) { + if (!SERVER_FIELDS.has(k)) out[k] = v; + } + return out; +} + +export async function checkDriftForUpdate(options: { + endpoint: string; // e.g. "/assistant/" + resourceLabel: string; // for log lines + resourceId: string; // local resource id + state: ResourceState; + overwrite: boolean; +}): Promise { + const { endpoint, resourceLabel, resourceId, state, overwrite } = options; + + if (!state.lastPulledHash) { + return { + ok: true, + reason: "no-baseline", + message: + ` ⚠️ drift check skipped for ${resourceLabel} ${resourceId}: ` + + `no lastPulledHash in state. Run \`npm run pull\` to establish a baseline.`, + }; + } + + const remote = await fetchPlatformPayload(endpoint); + if (remote === null) { + // Resource was deleted on the dashboard — defer to the upsert recovery + // path. Drift is not the right framing here. + return { ok: true, reason: "no-baseline" }; + } + + const platformHash = hashPayload(stripServerFields(remote)); + if (platformHash === state.lastPulledHash) { + return { ok: true, reason: "match", platformHash }; + } + + if (overwrite) { + return { + ok: true, + reason: "drift-overwritten", + platformHash, + message: + ` ⚠️ drift on ${resourceLabel} ${resourceId}: platform changed since last pull, ` + + `overwriting (--overwrite).`, + }; + } + + return { + ok: false, + reason: "drift-blocked", + platformHash, + message: + ` ❌ drift detected on ${resourceLabel} ${resourceId}: ` + + `platform hash (${platformHash.slice(0, 8)}...) differs from last-pulled ` + + `(${state.lastPulledHash.slice(0, 8)}...). ` + + `Re-run pull, resolve locally, or push with --overwrite to take ownership.`, + }; +} + +// Re-export the pure helper from state-serialize so call sites can import +// from drift.ts but tests can import the pure version directly. +export { checkPronunciationDictDrop } from "./state-serialize.ts"; diff --git a/src/push.ts b/src/push.ts index 61e413e..5269345 100644 --- a/src/push.ts +++ b/src/push.ts @@ -7,11 +7,13 @@ import { FORCE_DELETE, DRY_RUN, STRICT_VALIDATION, + OVERWRITE_DRIFT, APPLY_FILTER, BASE_DIR, removeExcludedKeys, } from "./config.ts"; import { summarizeFindings, validateResources } from "./validate.ts"; +import { checkDriftForUpdate } from "./drift.ts"; import { hashPayload, loadState, @@ -82,6 +84,38 @@ async function upsertResourceWithStateRecovery(options: { ` 🔄 Updating ${resourceLabel}: ${resourceId} (${existingUuid})`, ); + // Stack G — drift detection. Before PATCH, GET the current platform + // payload, hash it, and compare to lastPulledHash. Refuse to overwrite + // without --overwrite. Skipped in dry-run because the operator just + // wants to see what would happen, and skipped if no baseline hash. + if (!DRY_RUN) { + const stateEntry = stateSection[resourceId]; + if (stateEntry) { + try { + const drift = await checkDriftForUpdate({ + endpoint: updateEndpoint, + resourceLabel, + resourceId, + state: stateEntry, + overwrite: OVERWRITE_DRIFT, + }); + if (drift.message) { + if (drift.ok) console.log(drift.message); + else console.error(drift.message); + } + if (!drift.ok) return null; + } catch (driftErr) { + // A drift check failure should NOT block the push — the existing + // PATCH path will surface the real error. Log and move on. + console.warn( + ` ⚠️ drift check failed for ${resourceLabel} ${resourceId}: ` + + (driftErr instanceof Error ? driftErr.message : String(driftErr)) + + ". Continuing.", + ); + } + } + } + try { await vapiRequest("PATCH", updateEndpoint, updatePayload); return existingUuid; diff --git a/src/state-serialize.ts b/src/state-serialize.ts index 57dbb33..a262289 100644 --- a/src/state-serialize.ts +++ b/src/state-serialize.ts @@ -89,3 +89,68 @@ export function upsertState( ...patch, }; } + +// Pronunciation-dictionary drop check (improvements.md #7). Detects when a +// dictionary attachment disappears from the platform between pulls. Two +// shapes are supported because Vapi exposes a different field per provider: +// +// - 11labs (documented at +// https://docs.vapi.ai/assistants/pronunciation-dictionaries): +// `voice.pronunciationDictionaryLocators` — array of +// { pronunciationDictionaryId, versionId }. Dashboard edits that +// change the voice can drop entries from this array. +// +// - Cartesia (passthrough; not in Vapi docs but observed in real customer +// payloads): `voice.pronunciationDictId` — single string id. The +// Cartesia voice-picker silently drops the field on voice change. +// +// Pure-data (no network) so safe to import in tests. +type VoiceLike = { + voice?: { + pronunciationDictId?: unknown; + pronunciationDictionaryLocators?: unknown; + }; +}; + +function locatorsArray(value: unknown): unknown[] { + return Array.isArray(value) ? value : []; +} + +export function checkPronunciationDictDrop( + resourceId: string, + priorPayload: unknown, + newPayload: unknown, +): string | null { + const priorVoice = (priorPayload as VoiceLike | undefined)?.voice; + const newVoice = (newPayload as VoiceLike | undefined)?.voice; + + // Cartesia single-id form. Drops 1 → 0. + if ( + priorVoice?.pronunciationDictId && + typeof priorVoice.pronunciationDictId === "string" && + !newVoice?.pronunciationDictId + ) { + return ( + ` ⚠️ ${resourceId}: voice.pronunciationDictId was "${priorVoice.pronunciationDictId}" ` + + `at last pull but is missing on platform now. ` + + `Cartesia voice picker drops this silently — re-attach if needed.` + ); + } + + // 11labs locator-array form. Catches array clears (N → 0) and shrinks + // (N → M, M < N). A drop from 0 → 0 (or undefined → undefined) is a + // no-op and rightly returns null. + const priorLocators = locatorsArray(priorVoice?.pronunciationDictionaryLocators); + if (priorLocators.length > 0) { + const newLocators = locatorsArray(newVoice?.pronunciationDictionaryLocators); + if (newLocators.length < priorLocators.length) { + return ( + ` ⚠️ ${resourceId}: voice.pronunciationDictionaryLocators dropped from ` + + `${priorLocators.length} entry/entries at last pull to ${newLocators.length} on platform now. ` + + `11labs dashboard voice edits can drop these silently — re-attach if needed.` + ); + } + } + + return null; +} diff --git a/tests/drift.test.ts b/tests/drift.test.ts new file mode 100644 index 0000000..c2df6fa --- /dev/null +++ b/tests/drift.test.ts @@ -0,0 +1,142 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { checkPronunciationDictDrop } from "../src/state-serialize.ts"; + +// Stack G — drift unit tests. +// `checkDriftForUpdate` itself fires GET against the Vapi platform; a unit +// test for that path requires either a fake fetch or live API access. Manual +// integration coverage is the right place. Here we cover the +// pronunciation-dict-drop detector, which is pure-data. + +test("checkPronunciationDictDrop: warns when prior had ID and new lost it", () => { + const prior = { voice: { provider: "cartesia", pronunciationDictId: "pdict_X" } }; + const current = { voice: { provider: "cartesia" } }; + const msg = checkPronunciationDictDrop("agent-foo", prior, current); + assert.ok(msg, "expected a warning message"); + assert.match(msg!, /pdict_X/); + assert.match(msg!, /agent-foo/); +}); + +test("checkPronunciationDictDrop: silent when both have it", () => { + const prior = { voice: { pronunciationDictId: "pdict_X" } }; + const current = { voice: { pronunciationDictId: "pdict_X" } }; + assert.equal(checkPronunciationDictDrop("agent-foo", prior, current), null); +}); + +test("checkPronunciationDictDrop: silent when neither has it", () => { + const prior = { voice: {} }; + const current = { voice: {} }; + assert.equal(checkPronunciationDictDrop("agent-foo", prior, current), null); +}); + +test("checkPronunciationDictDrop: silent when prior didn't have it (additive change)", () => { + const prior = { voice: {} }; + const current = { voice: { pronunciationDictId: "pdict_X" } }; + assert.equal(checkPronunciationDictDrop("agent-foo", prior, current), null); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// 11labs `pronunciationDictionaryLocators` array shape (Vapi-documented). +// https://docs.vapi.ai/assistants/pronunciation-dictionaries +// ───────────────────────────────────────────────────────────────────────────── + +test("checkPronunciationDictDrop: warns when 11labs locator array clears (1 → 0)", () => { + const prior = { + voice: { + provider: "11labs", + pronunciationDictionaryLocators: [ + { pronunciationDictionaryId: "rjshI10OgN6KxqtJBqO4", versionId: "xJl0ImZzi3cYp61T0UQG" }, + ], + }, + }; + const current = { voice: { provider: "11labs", pronunciationDictionaryLocators: [] } }; + const msg = checkPronunciationDictDrop("eleven-agent", prior, current); + assert.ok(msg, "expected a warning message"); + assert.match(msg!, /pronunciationDictionaryLocators/); + assert.match(msg!, /1 entry\/entries .* to 0/); + assert.match(msg!, /eleven-agent/); +}); + +test("checkPronunciationDictDrop: warns when 11labs locator array shrinks (2 → 1)", () => { + const prior = { + voice: { + provider: "11labs", + pronunciationDictionaryLocators: [ + { pronunciationDictionaryId: "id_a", versionId: "v_a" }, + { pronunciationDictionaryId: "id_b", versionId: "v_b" }, + ], + }, + }; + const current = { + voice: { + provider: "11labs", + pronunciationDictionaryLocators: [ + { pronunciationDictionaryId: "id_a", versionId: "v_a" }, + ], + }, + }; + const msg = checkPronunciationDictDrop("eleven-agent", prior, current); + assert.ok(msg, "expected a warning message for partial drop"); + assert.match(msg!, /2 entry\/entries .* to 1/); +}); + +test("checkPronunciationDictDrop: warns when 11labs locator array goes missing entirely", () => { + const prior = { + voice: { + provider: "11labs", + pronunciationDictionaryLocators: [ + { pronunciationDictionaryId: "id_a", versionId: "v_a" }, + ], + }, + }; + const current = { voice: { provider: "11labs" } }; + const msg = checkPronunciationDictDrop("eleven-agent", prior, current); + assert.ok(msg, "expected a warning message when array missing"); + assert.match(msg!, /1 entry\/entries .* to 0/); +}); + +test("checkPronunciationDictDrop: silent when 11labs locator array is unchanged", () => { + const locators = [{ pronunciationDictionaryId: "id_a", versionId: "v_a" }]; + const prior = { voice: { pronunciationDictionaryLocators: locators } }; + const current = { voice: { pronunciationDictionaryLocators: [...locators] } }; + assert.equal(checkPronunciationDictDrop("eleven-agent", prior, current), null); +}); + +test("checkPronunciationDictDrop: silent when 11labs locator array grows (additive)", () => { + const prior = { + voice: { + pronunciationDictionaryLocators: [{ pronunciationDictionaryId: "id_a", versionId: "v_a" }], + }, + }; + const current = { + voice: { + pronunciationDictionaryLocators: [ + { pronunciationDictionaryId: "id_a", versionId: "v_a" }, + { pronunciationDictionaryId: "id_b", versionId: "v_b" }, + ], + }, + }; + assert.equal(checkPronunciationDictDrop("eleven-agent", prior, current), null); +}); + +test("checkPronunciationDictDrop: detects either shape when prior has both somehow (Cartesia wins; 11labs check still runs)", () => { + // Defensive — a payload that happens to carry both shapes (shouldn't + // happen in practice but the function should not crash). The Cartesia + // single-id check runs first; if it fires we return that message. If it + // doesn't (because new still has Cartesia id), the 11labs check runs. + const prior = { + voice: { + pronunciationDictId: "pdict_X", + pronunciationDictionaryLocators: [{ pronunciationDictionaryId: "id_a", versionId: "v_a" }], + }, + }; + const current = { + voice: { + pronunciationDictId: "pdict_X", + pronunciationDictionaryLocators: [], + }, + }; + const msg = checkPronunciationDictDrop("hybrid-agent", prior, current); + assert.ok(msg, "expected a warning when locators dropped"); + assert.match(msg!, /pronunciationDictionaryLocators/); +});