Skip to content

Commit b1b4f0c

Browse files
committed
fix: prevent pull collisions from similarly named resources
Stop pull from reusing resource IDs by prefix match and make push treat UUID-to-file mappings with mismatched current names as invalid before apply.
1 parent 10fd265 commit b1b4f0c

2 files changed

Lines changed: 107 additions & 24 deletions

File tree

src/pull.ts

Lines changed: 71 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ import type { StateFile, ResourceType } from "./types.ts";
2121
// Types
2222
// ─────────────────────────────────────────────────────────────────────────────
2323

24-
interface VapiResource {
24+
export interface VapiResource {
2525
id: string;
2626
name?: string;
2727
[key: string]: unknown;
@@ -245,28 +245,77 @@ function generateResourceId(resource: VapiResource): string {
245245
return name ? `${slugify(name)}-${shortId}` : `resource-${shortId}`;
246246
}
247247

248+
export function extractBaseSlug(resourceId: string): string {
249+
const match = resourceId.match(/^(.*)-([a-f0-9]{8})$/i);
250+
return match?.[1] ?? resourceId;
251+
}
252+
253+
export function resourceIdMatchesName(
254+
resourceId: string,
255+
resource: VapiResource,
256+
): boolean {
257+
const name = extractName(resource);
258+
if (!name) return true;
259+
return extractBaseSlug(resourceId) === slugify(name);
260+
}
261+
262+
function listExistingResourceIds(resourceType: ResourceType): string[] {
263+
const dir = join(RESOURCES_DIR, FOLDER_MAP[resourceType]);
264+
if (!existsSync(dir)) return [];
265+
266+
const walk = (currentDir: string, ids: string[]): string[] => {
267+
const entries = readdirSync(currentDir, { withFileTypes: true });
268+
for (const entry of entries) {
269+
const fullPath = join(currentDir, entry.name);
270+
if (entry.isDirectory()) {
271+
walk(fullPath, ids);
272+
continue;
273+
}
274+
275+
if (!/\.(yml|yaml|md)$/.test(entry.name)) {
276+
continue;
277+
}
278+
279+
const relativePath = relative(dir, fullPath);
280+
ids.push(relativePath.replace(/\.(yml|yaml|md)$/, ""));
281+
}
282+
283+
return ids;
284+
};
285+
286+
return walk(dir, []);
287+
}
288+
248289
// When pulling a new environment, a resource may already exist on disk under a
249290
// different UUID suffix (e.g., `end-call-tool-8102e715` from dev). Match by
250291
// name-slug so we reuse the existing file instead of creating a duplicate.
251292
function findExistingResourceId(
252-
resourceType: ResourceType,
293+
existingResourceIds: string[],
253294
resource: VapiResource,
254295
): string | undefined {
255296
const name = extractName(resource);
256297
if (!name) return undefined;
257298

258299
const nameSlug = slugify(name);
259-
const dir = join(RESOURCES_DIR, FOLDER_MAP[resourceType]);
260-
if (!existsSync(dir)) return undefined;
261-
262-
const matches = readdirSync(dir)
263-
.filter((f) => /\.(yml|yaml|md)$/.test(f))
264-
.map((f) => f.replace(/\.(yml|yaml|md)$/, ""))
265-
.filter((id) => id === nameSlug || id.startsWith(nameSlug + "-"));
300+
const matches = existingResourceIds.filter(
301+
(id) => extractBaseSlug(id) === nameSlug,
302+
);
266303

267304
return matches.length === 1 ? matches[0] : undefined;
268305
}
269306

307+
function removeUuidMappings(
308+
stateSection: Record<string, string>,
309+
uuid: string,
310+
keepResourceId?: string,
311+
): void {
312+
for (const [resourceId, mappedUuid] of Object.entries(stateSection)) {
313+
if (mappedUuid === uuid && resourceId !== keepResourceId) {
314+
delete stateSection[resourceId];
315+
}
316+
}
317+
}
318+
270319
// ─────────────────────────────────────────────────────────────────────────────
271320
// Resource Processing
272321
// ─────────────────────────────────────────────────────────────────────────────
@@ -583,6 +632,9 @@ export async function pullResourceType(
583632
const newStateSection: Record<string, string> = resourceIds?.length
584633
? { ...state[resourceType] }
585634
: {};
635+
const existingResourceIds = bootstrap
636+
? []
637+
: listExistingResourceIds(resourceType);
586638

587639
let created = 0;
588640
let updated = 0;
@@ -591,15 +643,23 @@ export async function pullResourceType(
591643
for (const resource of resources) {
592644
// Check if we already have this resource in state (by UUID)
593645
let resourceId = reverseMap.get(resource.id);
594-
const isNew = !resourceId;
646+
if (resourceId && !resourceIdMatchesName(resourceId, resource)) {
647+
delete newStateSection[resourceId];
648+
resourceId = undefined;
649+
}
595650

596651
if (!resourceId) {
597652
// Reuse an existing file's resourceId if the name matches (cross-env pull)
598653
resourceId = bootstrap
599654
? generateResourceId(resource)
600-
: (findExistingResourceId(resourceType, resource) ??
655+
: (findExistingResourceId(existingResourceIds, resource) ??
601656
generateResourceId(resource));
602657
}
658+
const isNew =
659+
!reverseMap.get(resource.id) ||
660+
!resourceIdMatchesName(resourceId, resource);
661+
662+
removeUuidMappings(newStateSection, resource.id, resourceId);
603663

604664
// Skip files that have been locally modified (git detection)
605665
if (!bootstrap && changedFiles) {

src/push.ts

Lines changed: 36 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import {
88
} from "./config.ts";
99
import { loadState, saveState } from "./state.ts";
1010
import { loadResources, loadSingleResource, FOLDER_MAP } from "./resources.ts";
11-
import { fetchAllResources, runPull } from "./pull.ts";
11+
import { fetchAllResources, resourceIdMatchesName, runPull } from "./pull.ts";
1212
import {
1313
resolveReferences,
1414
resolveAssistantIds,
@@ -156,14 +156,22 @@ function getMissingCredentialNames(
156156
return [...names].filter((name) => !credentialMap.has(name));
157157
}
158158

159-
async function getStaleStateMappings(
159+
async function getInvalidStateMappings(
160160
resources: LoadedResources,
161161
state: StateFile,
162-
): Promise<Array<{ type: ResourceType; resourceId: string; uuid: string }>> {
163-
const staleMappings: Array<{
162+
): Promise<
163+
Array<{
164164
type: ResourceType;
165165
resourceId: string;
166166
uuid: string;
167+
reason: "missing_remote" | "name_mismatch";
168+
}>
169+
> {
170+
const invalidMappings: Array<{
171+
type: ResourceType;
172+
resourceId: string;
173+
uuid: string;
174+
reason: "missing_remote" | "name_mismatch";
167175
}> = [];
168176

169177
for (const type of getTargetedResourceTypes(resources)) {
@@ -185,18 +193,33 @@ async function getStaleStateMappings(
185193
continue;
186194
}
187195

188-
const remoteIds = new Set(
189-
(await fetchAllResources(type)).map((resource) => resource.id),
196+
const remoteResources = await fetchAllResources(type);
197+
const remoteResourcesById = new Map(
198+
remoteResources.map((resource) => [resource.id, resource]),
190199
);
191200

192201
for (const trackedResource of trackedResources) {
193-
if (!remoteIds.has(trackedResource.uuid)) {
194-
staleMappings.push({ type, ...trackedResource });
202+
const remoteResource = remoteResourcesById.get(trackedResource.uuid);
203+
if (!remoteResource) {
204+
invalidMappings.push({
205+
type,
206+
...trackedResource,
207+
reason: "missing_remote",
208+
});
209+
continue;
210+
}
211+
212+
if (!resourceIdMatchesName(trackedResource.resourceId, remoteResource)) {
213+
invalidMappings.push({
214+
type,
215+
...trackedResource,
216+
reason: "name_mismatch",
217+
});
195218
}
196219
}
197220
}
198221

199-
return staleMappings;
222+
return invalidMappings;
200223
}
201224

202225
async function maybeBootstrapState(
@@ -212,12 +235,12 @@ async function maybeBootstrapState(
212235
const stateUninitialized =
213236
Object.keys(state.credentials).length === 0 ||
214237
targetedTypes.every((type) => Object.keys(state[type]).length === 0);
215-
const staleMappings = await getStaleStateMappings(resources, state);
238+
const invalidMappings = await getInvalidStateMappings(resources, state);
216239

217240
if (
218241
!stateUninitialized &&
219242
missingCredentialNames.length === 0 &&
220-
staleMappings.length === 0
243+
invalidMappings.length === 0
221244
) {
222245
return state;
223246
}
@@ -233,9 +256,9 @@ async function maybeBootstrapState(
233256
` - Missing credential mappings: ${missingCredentialNames.join(", ")}`,
234257
);
235258
}
236-
for (const mapping of staleMappings) {
259+
for (const mapping of invalidMappings) {
237260
console.log(
238-
` - Stale ${mapping.type} mapping: ${mapping.resourceId} -> ${mapping.uuid}`,
261+
` - Invalid ${mapping.type} mapping (${mapping.reason}): ${mapping.resourceId} -> ${mapping.uuid}`,
239262
);
240263
}
241264

0 commit comments

Comments
 (0)