Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions improvements.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
7 changes: 6 additions & 1 deletion src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,7 @@ function parseFlags(): {
bootstrapSync: boolean;
dryRun: boolean;
strictValidation: boolean;
overwriteDrift: boolean;
applyFilter: ApplyFilter;
} {
const args = process.argv.slice(3);
Expand All @@ -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: {},
};

Expand All @@ -115,7 +118,8 @@ function parseFlags(): {
arg === "--force" ||
arg === "--bootstrap" ||
arg === "--dry-run" ||
arg === "--strict"
arg === "--strict" ||
arg === "--overwrite"
)
continue;

Expand Down Expand Up @@ -252,6 +256,7 @@ export const {
bootstrapSync: BOOTSTRAP_SYNC,
dryRun: DRY_RUN,
strictValidation: STRICT_VALIDATION,
overwriteDrift: OVERWRITE_DRIFT,
applyFilter: APPLY_FILTER,
} = parseFlags();

Expand Down
134 changes: 134 additions & 0 deletions src/drift.ts
Original file line number Diff line number Diff line change
@@ -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<unknown | null> {
// 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<string, unknown> = {};
for (const [k, v] of Object.entries(payload as Record<string, unknown>)) {
if (!SERVER_FIELDS.has(k)) out[k] = v;
}
return out;
}

export async function checkDriftForUpdate(options: {
endpoint: string; // e.g. "/assistant/<uuid>"
resourceLabel: string; // for log lines
resourceId: string; // local resource id
state: ResourceState;
overwrite: boolean;
}): Promise<DriftCheckResult> {
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";
34 changes: 34 additions & 0 deletions src/push.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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;
Expand Down
65 changes: 65 additions & 0 deletions src/state-serialize.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Loading