Skip to content

Commit a1e3228

Browse files
dhruva-vapiclaude
andcommitted
fix(gitops): scope credential walker, harden state and retries
Ports the bug fixes landed in gitops-mudflap to this repo. The push pipeline had a class of bugs that surfaced as cryptic POST validation errors and silent state corruption. src/credentials.ts Rewrite credential resolution to walk only `credentialId` and `credentialIds` keys instead of every string in the payload. The old walker swapped any string matching a credential-name slug with its UUID, which collided with provider enum values like `openai`, `langfuse`, `11labs`, `cartesia` — those appear in `model.provider`, `voice.provider`, `voice.fallbackPlan.voices[].provider`, and `observabilityPlan.provider`, all of which the API enforces as enums on POST. PATCH silently tolerated the UUID-replaced fields, which hid the bug for resources that already existed. Also add a plain-object guard (Date/Map/Set/Buffer pass through) and a WeakSet to short-circuit cycles. src/state.ts Atomic write via tmp file + rename so a crash mid-write leaves the prior state intact instead of truncating it. Loud throw on JSON parse error: silently falling back to an empty state would cause the next push to recreate every remote resource as new, duplicating the entire org. src/push.ts Wrap the apply body in try/finally so saveState always runs. If a 5xx kills the run after a few resources got UUIDs from the API, we still record them locally — otherwise the next run creates duplicates. src/cleanup.ts Double-gate destructive cleanup: --force now also requires `--confirm <env>` matching VAPI_ENV. Refuse to run if the state file has zero tracked resources (almost always a fresh clone or bootstrap-not-run, where deletion would wipe the org). src/pull.ts Preserve `null` values in cleanResource — `null` is the API's way to express "this field is intentionally cleared", which is different from "field absent". Stripping it caused round-trip drift. Switch git status parsing to `--porcelain -z` so filenames with spaces, quotes, or newlines parse correctly. src/api.ts Extend the retry loop to cover 5xx responses (transient gateway errors, deploys in progress) in addition to 429. A single 502 shouldn't force the operator to re-run the whole push. src/resources.ts Sort directory entries before iteration so push order is stable across filesystems (APFS sorts, ext4 doesn't). Non-deterministic ordering makes duplicate-resourceId bugs hard to reproduce. .gitignore Add .claude/ (local agent lock state). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1 parent 8e8a4e9 commit a1e3228

8 files changed

Lines changed: 179 additions & 52 deletions

File tree

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,3 +23,6 @@ Thumbs.db
2323

2424
# Logs
2525
*.log
26+
27+
# Local agent state
28+
.claude/

src/api.ts

Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,13 @@ async function throttle(): Promise<void> {
4646
lastRequestTime = Date.now();
4747
}
4848

49+
// 429 = rate limit. 5xx = transient server error (gateway timeout, upstream
50+
// hiccup, deploy in progress). Both are worth retrying with backoff; surfacing
51+
// a 502 as a hard failure forces the operator to re-run the entire push.
52+
function shouldRetry(status: number): boolean {
53+
return status === 429 || (status >= 500 && status < 600);
54+
}
55+
4956
export async function vapiRequest<T = VapiResponse>(
5057
method: "POST" | "PATCH",
5158
endpoint: string,
@@ -68,10 +75,10 @@ export async function vapiRequest<T = VapiResponse>(
6875
return response.json() as Promise<T>;
6976
}
7077

71-
// Handle rate limit with retry
72-
if (response.status === 429 && attempt < MAX_RETRIES) {
78+
if (shouldRetry(response.status) && attempt < MAX_RETRIES) {
7379
const delay = INITIAL_DELAY_MS * Math.pow(2, attempt);
74-
console.log(` ⏳ Rate limited, retrying in ${delay / 1000}s (attempt ${attempt + 1}/${MAX_RETRIES})...`);
80+
const reason = response.status === 429 ? "Rate limited" : `Server error ${response.status}`;
81+
console.log(` ⏳ ${reason}, retrying in ${delay / 1000}s (attempt ${attempt + 1}/${MAX_RETRIES})...`);
7582
await sleep(delay);
7683
continue;
7784
}
@@ -99,10 +106,10 @@ export async function vapiDelete(endpoint: string): Promise<void> {
99106
return;
100107
}
101108

102-
// Handle rate limit with retry
103-
if (response.status === 429 && attempt < MAX_RETRIES) {
109+
if (shouldRetry(response.status) && attempt < MAX_RETRIES) {
104110
const delay = INITIAL_DELAY_MS * Math.pow(2, attempt);
105-
console.log(` ⏳ Rate limited, retrying in ${delay / 1000}s (attempt ${attempt + 1}/${MAX_RETRIES})...`);
111+
const reason = response.status === 429 ? "Rate limited" : `Server error ${response.status}`;
112+
console.log(` ⏳ ${reason}, retrying in ${delay / 1000}s (attempt ${attempt + 1}/${MAX_RETRIES})...`);
106113
await sleep(delay);
107114
continue;
108115
}

src/cleanup.ts

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,8 +68,19 @@ interface VapiResource {
6868
name?: string;
6969
}
7070

71+
function readConfirmToken(argv: string[]): string | undefined {
72+
// Accept either `--confirm <env>` or `--confirm=<env>`.
73+
for (let i = 0; i < argv.length; i++) {
74+
const arg = argv[i];
75+
if (arg === "--confirm") return argv[i + 1];
76+
if (arg?.startsWith("--confirm=")) return arg.slice("--confirm=".length);
77+
}
78+
return undefined;
79+
}
80+
7181
async function main(): Promise<void> {
7282
const dryRun = !process.argv.includes("--force");
83+
const confirmToken = readConfirmToken(process.argv);
7384

7485
console.log(
7586
"═══════════════════════════════════════════════════════════════",
@@ -83,6 +94,20 @@ async function main(): Promise<void> {
8394
"═══════════════════════════════════════════════════════════════\n",
8495
);
8596

97+
// Destructive cleanup must be double-gated. `--force` alone is not enough
98+
// because it is easy to set habitually or copy from another command where
99+
// it has a different meaning. Require `--confirm <env>` so the caller has
100+
// to name the environment they intend to wipe.
101+
if (!dryRun && confirmToken !== VAPI_ENV) {
102+
console.error(
103+
`❌ Refusing to run destructive cleanup without explicit confirmation.`,
104+
);
105+
console.error(
106+
` Re-run with: npm run cleanup:${VAPI_ENV} -- --force --confirm ${VAPI_ENV}`,
107+
);
108+
process.exit(1);
109+
}
110+
86111
const state = loadState();
87112
const stateIds = new Set([
88113
...Object.values(state.assistants),
@@ -95,6 +120,20 @@ async function main(): Promise<void> {
95120
...Object.values(state.simulationSuites),
96121
]);
97122

123+
// A state file with zero tracked resources is almost always a fresh clone,
124+
// a corrupted state, or a bootstrap that has not written yet. Deleting from
125+
// that baseline would wipe the org. Block it explicitly.
126+
if (!dryRun && stateIds.size === 0) {
127+
console.error(
128+
`❌ Refusing to run destructive cleanup: state file has 0 tracked resources.`,
129+
);
130+
console.error(
131+
` This usually means the state was never bootstrapped. Run ` +
132+
`\`npm run pull:${VAPI_ENV}:bootstrap\` first, then retry.`,
133+
);
134+
process.exit(1);
135+
}
136+
98137
console.log(`📄 State file has ${stateIds.size} resource IDs to keep\n`);
99138

100139
const toDelete: {

src/credentials.ts

Lines changed: 44 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,12 @@ import type { StateFile } from "./types.ts";
66
// Credentials are pulled from the API and stored in state (name-slug → UUID).
77
// Resource files store credential NAMES (e.g., "roofr-server-credential").
88
// Push resolves names → UUIDs. Pull resolves UUIDs → names.
9+
//
10+
// Replacement is scoped to `credentialId` / `credentialIds` fields only.
11+
// Credential slugs like `openai`, `langfuse`, `11labs` collide with enum
12+
// values for fields such as `model.provider`, `voice.provider`,
13+
// `observabilityPlan.provider`. A generic string-level walk would swap those
14+
// enum values with UUIDs and break POST validation.
915
// ─────────────────────────────────────────────────────────────────────────────
1016

1117
// Build UUID → name reverse map from state.credentials
@@ -27,28 +33,54 @@ export function credentialForwardMap(state: StateFile): Map<string, string> {
2733
}
2834

2935
// ─────────────────────────────────────────────────────────────────────────────
30-
// Deep walk: replace string values matching the map keys
31-
// Works at any depth in any object/array structure
36+
// Scoped walker: replace values only at `credentialId` / `credentialIds` keys.
37+
// Works at any depth in any object/array structure.
3238
// ─────────────────────────────────────────────────────────────────────────────
3339

34-
export function deepReplaceValues<T>(obj: T, replacements: Map<string, string>): T {
40+
export function replaceCredentialRefs<T>(
41+
obj: T,
42+
replacements: Map<string, string>,
43+
): T {
3544
if (replacements.size === 0) return obj;
36-
return walk(obj, replacements) as T;
45+
return walk(obj, replacements, new WeakSet()) as T;
3746
}
3847

39-
function walk(value: unknown, replacements: Map<string, string>): unknown {
40-
if (typeof value === "string") {
41-
return replacements.get(value) ?? value;
42-
}
48+
function isPlainObject(value: unknown): value is Record<string, unknown> {
49+
if (value === null || typeof value !== "object") return false;
50+
const proto = Object.getPrototypeOf(value);
51+
// Only walk into objects produced by JSON parsing or object literals.
52+
// Date/Map/Set/Buffer/etc. should pass through unchanged — recursing into
53+
// them via Object.entries silently drops their prototype methods.
54+
return proto === Object.prototype || proto === null;
55+
}
4356

57+
function walk(
58+
value: unknown,
59+
replacements: Map<string, string>,
60+
seen: WeakSet<object>,
61+
): unknown {
4462
if (Array.isArray(value)) {
45-
return value.map((item) => walk(item, replacements));
63+
if (seen.has(value)) return value;
64+
seen.add(value);
65+
return value.map((item) => walk(item, replacements, seen));
4666
}
4767

48-
if (value !== null && typeof value === "object") {
68+
if (isPlainObject(value)) {
69+
if (seen.has(value)) return value;
70+
seen.add(value);
4971
const result: Record<string, unknown> = {};
50-
for (const [key, val] of Object.entries(value as Record<string, unknown>)) {
51-
result[key] = walk(val, replacements);
72+
for (const [key, val] of Object.entries(value)) {
73+
if (key === "credentialId" && typeof val === "string") {
74+
result[key] = replacements.get(val) ?? val;
75+
} else if (key === "credentialIds" && Array.isArray(val)) {
76+
result[key] = val.map((item) =>
77+
typeof item === "string"
78+
? (replacements.get(item) ?? item)
79+
: walk(item, replacements, seen),
80+
);
81+
} else {
82+
result[key] = walk(val, replacements, seen);
83+
}
5284
}
5385
return result;
5486
}

src/pull.ts

Lines changed: 27 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ import {
1414
BOOTSTRAP_SYNC,
1515
} from "./config.ts";
1616
import { loadState, saveState } from "./state.ts";
17-
import { credentialReverseMap, deepReplaceValues } from "./credentials.ts";
17+
import { credentialReverseMap, replaceCredentialRefs } from "./credentials.ts";
1818
import type { StateFile, ResourceType } from "./types.ts";
1919

2020
// ─────────────────────────────────────────────────────────────────────────────
@@ -94,20 +94,28 @@ function gitHasCommits(): boolean {
9494
}
9595
}
9696

97-
// Returns relative paths of all locally modified, deleted, or untracked files
97+
// Returns relative paths of all locally modified, deleted, or untracked files.
98+
// Uses `-z` (null-terminated) format so filenames containing spaces, newlines,
99+
// or quotes parse correctly without the ad-hoc quote/arrow stripping that the
100+
// plain porcelain format requires. With `-z`, renames emit two separate
101+
// null-terminated records: `XY new\0old\0` — we want `new`, so we consume
102+
// the record after any `R`/`C` status.
98103
function getLocallyChangedFiles(): Set<string> {
99-
const status = gitCmd("status --porcelain");
104+
const status = gitCmd("status --porcelain -z");
100105
const files = new Set<string>();
101-
for (const line of status.split("\n")) {
102-
if (!line.trim()) continue;
103-
// format: XY filename (or XY "filename" for special chars)
104-
let filePath = line.slice(3);
105-
// Handle renames: "old -> new"
106-
const arrowIdx = filePath.indexOf(" -> ");
107-
if (arrowIdx !== -1) filePath = filePath.slice(arrowIdx + 4);
108-
// Strip quotes if present
109-
filePath = filePath.replace(/^"|"$/g, "").trim();
110-
files.add(filePath);
106+
const records = status.split("\0");
107+
for (let i = 0; i < records.length; i++) {
108+
const record = records[i];
109+
if (!record) continue;
110+
// Each record is `XY <path>`. For R/C statuses, the NEXT record is the
111+
// original path, which we skip.
112+
const x = record[0];
113+
const filePath = record.slice(3);
114+
if (filePath) files.add(filePath);
115+
if (x === "R" || x === "C") {
116+
// Skip the following "from" path record
117+
i++;
118+
}
111119
}
112120
return files;
113121
}
@@ -323,12 +331,12 @@ function removeUuidMappings(
323331
function cleanResource(resource: VapiResource): Record<string, unknown> {
324332
const cleaned: Record<string, unknown> = {};
325333

334+
// Preserve `null` values: the API uses `null` to represent an intentionally
335+
// cleared field (e.g. `voicemailMessage: null`), which is semantically
336+
// different from an absent field. Stripping it on pull would cause the next
337+
// push to drop the clear and re-apply any prior value still on the server.
326338
for (const [key, value] of Object.entries(resource)) {
327-
if (
328-
!EXCLUDED_FIELDS.includes(key) &&
329-
value !== null &&
330-
value !== undefined
331-
) {
339+
if (!EXCLUDED_FIELDS.includes(key) && value !== undefined) {
332340
cleaned[key] = value;
333341
}
334342
}
@@ -708,7 +716,7 @@ export async function pullResourceType(
708716
// Clean, resolve resource references, and replace credential UUIDs with names
709717
const cleaned = cleanResource(resource);
710718
const resolved = resolveReferencesToResourceIds(cleaned, state);
711-
const withCredNames = deepReplaceValues(resolved, credReverse);
719+
const withCredNames = replaceCredentialRefs(resolved, credReverse);
712720

713721
// Mark platform defaults so apply skips them
714722
if (isPlatformDefault) {

src/push.ts

Lines changed: 20 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ import {
1414
resolveAssistantIds,
1515
extractReferencedIds,
1616
} from "./resolver.ts";
17-
import { credentialForwardMap, deepReplaceValues } from "./credentials.ts";
17+
import { credentialForwardMap, replaceCredentialRefs } from "./credentials.ts";
1818
import { deleteOrphanedResources } from "./delete.ts";
1919
import type {
2020
ResourceFile,
@@ -816,6 +816,11 @@ async function main(): Promise<void> {
816816
simulationSuites: 0,
817817
};
818818

819+
// From here on, any path out of the function (success OR thrown error) must
820+
// flush the state file. If an early 5xx kills the apply after a few resources
821+
// have been created on the remote, we still need their UUIDs recorded locally
822+
// — otherwise the next run creates duplicates.
823+
try {
819824
// Load all resources (we need them for reference resolution and filtering)
820825
console.log("\n📂 Loading resources...\n");
821826
const allToolsRaw = await loadResources<Record<string, unknown>>("tools");
@@ -860,7 +865,7 @@ async function main(): Promise<void> {
860865
resources: ResourceFile<T>[],
861866
): ResourceFile<T>[] =>
862867
resources.map((r) => {
863-
const resolved = deepReplaceValues(r.data, credMap);
868+
const resolved = replaceCredentialRefs(r.data, credMap);
864869
warnUnresolvedCredentials(
865870
r.resourceId,
866871
resolved as Record<string, unknown>,
@@ -1147,9 +1152,6 @@ async function main(): Promise<void> {
11471152
await updateStructuredOutputAssistantRefs(allAppliedOutputs, state);
11481153
}
11491154

1150-
// Save updated state
1151-
await saveState(state);
1152-
11531155
console.log(
11541156
"\n═══════════════════════════════════════════════════════════════",
11551157
);
@@ -1192,6 +1194,19 @@ async function main(): Promise<void> {
11921194
` Simulation Suites: ${Object.keys(state.simulationSuites).length}`,
11931195
);
11941196
}
1197+
} finally {
1198+
// Always flush state, even on partial failure — resources that already
1199+
// received UUIDs from the API must be recorded so the next run does not
1200+
// re-create them.
1201+
try {
1202+
await saveState(state);
1203+
} catch (saveError) {
1204+
console.error(
1205+
"\n⚠️ Failed to persist state file after apply:",
1206+
saveError instanceof Error ? saveError.message : saveError,
1207+
);
1208+
}
1209+
}
11951210
}
11961211

11971212
// Run the apply engine

src/resources.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,12 @@ function parseFrontmatter(content: string): {
6767
* Warns about unsupported files found in resource directories
6868
*/
6969
async function scanDirectory(dir: string, baseDir: string): Promise<string[]> {
70-
const entries = await readdir(dir);
70+
// Sort entries so iteration order is identical across filesystems/CI runners.
71+
// readdir() returns entries in OS-dependent order (APFS sorts, ext4 doesn't),
72+
// and downstream push order affects which resource is created first when
73+
// multiple files declare the same resourceId — non-determinism makes that
74+
// bug class hard to reproduce.
75+
const entries = (await readdir(dir)).slice().sort();
7176
const files: string[] = [];
7277

7378
for (const entry of entries) {

src/state.ts

Lines changed: 27 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { existsSync, readFileSync } from "fs";
2-
import { writeFile } from "fs/promises";
2+
import { rename, writeFile } from "fs/promises";
33
import { STATE_FILE_PATH, VAPI_ENV } from "./config.ts";
44
import type { StateFile } from "./types.ts";
55

@@ -27,19 +27,37 @@ export function loadState(): StateFile {
2727
return createEmptyState();
2828
}
2929

30+
let content: unknown;
3031
try {
31-
const content = JSON.parse(readFileSync(STATE_FILE_PATH, "utf-8"));
32-
console.log(`📄 Loaded state file for environment: ${VAPI_ENV}`);
33-
// Merge with empty state to ensure all keys exist (for backwards compatibility)
34-
return { ...createEmptyState(), ...content } as StateFile;
32+
content = JSON.parse(readFileSync(STATE_FILE_PATH, "utf-8"));
3533
} catch (error) {
36-
console.error(`⚠️ Failed to parse state file, creating new: ${error}`);
37-
return createEmptyState();
34+
// Failing loudly here is deliberate. If we silently fall back to an empty
35+
// state on a corrupted file, a subsequent push would treat every remote
36+
// resource as new and create duplicates across the entire org.
37+
const msg = error instanceof Error ? error.message : String(error);
38+
throw new Error(
39+
`Failed to parse state file at ${STATE_FILE_PATH}: ${msg}\n` +
40+
`Refusing to continue — falling back to empty state would cause duplicate ` +
41+
`resources on next push. Restore the file from git, or delete it manually ` +
42+
`to start fresh after confirming the environment has no tracked resources.`,
43+
);
3844
}
45+
46+
console.log(`📄 Loaded state file for environment: ${VAPI_ENV}`);
47+
// Merge with empty state to ensure all keys exist (for backwards compatibility)
48+
return {
49+
...createEmptyState(),
50+
...(content as Partial<StateFile>),
51+
} as StateFile;
3952
}
4053

4154
export async function saveState(state: StateFile): Promise<void> {
42-
await writeFile(STATE_FILE_PATH, JSON.stringify(state, null, 2) + "\n");
55+
// Atomic write: emit to a sibling temp file, then rename over the target.
56+
// A crash or SIGINT mid-write leaves the original state intact rather than
57+
// truncating it. A truncated state file would silently wipe all UUID
58+
// mappings on the next load.
59+
const tmpPath = `${STATE_FILE_PATH}.tmp`;
60+
await writeFile(tmpPath, JSON.stringify(state, null, 2) + "\n");
61+
await rename(tmpPath, STATE_FILE_PATH);
4362
console.log(`💾 Saved state file: ${STATE_FILE_PATH}`);
4463
}
45-

0 commit comments

Comments
 (0)