Skip to content

Commit 7e051fa

Browse files
authored
fix(pull): state-aware findExistingResourceId — prevent same-name clobber (#29)
* fix(pull): make findExistingResourceId state-aware to prevent clobber When two dashboard resources share a name (e.g. via Duplicate Assistant or any platform auto-seed) and one is already tracked in state, the prior pull behavior was to silently reuse the existing file's slug for the new UUID — clobbering the original's content and orphaning its state mapping. Fix: before adopting a matching file, check whether it's already claimed in state by a different UUID. If so, refuse adoption and let the caller fall through to `generateResourceId(resource)`, which produces a deterministic `<name>-<uuid8>.md` slug per UUID. Cross-env adoption (file exists, state empty for it) and re-pull (file exists, state claims it for THIS UUID) paths are unchanged. Promotes `findExistingResourceId` from `function` to `export function` so tests can pin behavior directly. Closes customer issue surfaced in gitops-mudflap working session 2026-05-13 (PRISM-737-adjacent). * fix(pull): merge state[resourceType] into adoption guard (code-review H1) Addresses the BLOCKING finding from the code review of d7c718f. The first fix passed only `newStateSection` (in-flight pull state) to `findExistingResourceId`. That made the fix iteration-order-dependent: if the dashboard's /assistant list returned the new same-name twin BEFORE the tracked one, the new twin saw `newStateSection` empty (the tracked one hadn't been processed yet), and clobbered the existing file anyway. Fix: pass a merged view `{...state[resourceType], ...newStateSection}` to the adoption guard. The prior-pull state carries claims loaded from disk; the in-flight section carries claims made earlier in this same loop. Spreading `newStateSection` last preserves intra-pull precedence when both have the same slug (e.g. an earlier iteration re-keyed it). Adds a B-first integration test that fails under d7c718f and passes under this fix. The original A-first test is preserved by extracting both into a shared `runClobberScenario(testName, order)` helper. Also tightens the same-UUID branch comment in `findExistingResourceId` to clarify that it's defensive — the caller's `reverseMap.get` short- circuits this case in production. Comment-only change. Suite: 167/167 pass.
1 parent 4712085 commit 7e051fa

3 files changed

Lines changed: 519 additions & 4 deletions

File tree

src/pull.ts

Lines changed: 42 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -316,9 +316,19 @@ export function listExistingResourceIds(resourceType: ResourceType): string[] {
316316
// When pulling a new environment, a resource may already exist on disk under a
317317
// different UUID suffix (e.g., `end-call-tool-8102e715` from dev). Match by
318318
// name-slug so we reuse the existing file instead of creating a duplicate.
319-
function findExistingResourceId(
319+
//
320+
// State-awareness guard: if a name-matching file is already claimed in state
321+
// by a *different* UUID, refuse adoption. Without this guard, two dashboard
322+
// resources sharing a name (e.g. a Duplicate-Assistant click, or any
323+
// platform auto-seed of a same-named twin) collapse onto the same file —
324+
// the second pull silently overwrites the first's content and reassigns
325+
// the slug's state mapping to the new UUID, orphaning the original.
326+
// Falling through to `generateResourceId` (caller) produces a deterministic
327+
// `<name>-<uuid8>` slug per UUID, so the second resource gets its own file.
328+
export function findExistingResourceId(
320329
existingResourceIds: string[],
321330
resource: VapiResource,
331+
stateSection: Record<string, ResourceState>,
322332
): string | undefined {
323333
const name = extractName(resource);
324334
if (!name) return undefined;
@@ -327,8 +337,23 @@ function findExistingResourceId(
327337
const matches = existingResourceIds.filter(
328338
(id) => extractBaseSlug(id) === nameSlug,
329339
);
340+
if (matches.length === 0) return undefined;
341+
342+
// A file is adoptable when it is either unclaimed in state (cross-env
343+
// pull: file shipped from dev, this env has no prior state for it) or
344+
// already claimed by THIS resource's UUID. The same-UUID branch is
345+
// defensive — in the production code path the caller's
346+
// `reverseMap.get(resource.id)` short-circuits this case, so we only
347+
// reach this function when the UUID is unknown to state. Keep the
348+
// branch anyway so the helper is correct in isolation.
349+
// A file claimed by a *different* UUID is not adoptable — adopting it
350+
// would clobber the existing resource's content and state mapping.
351+
const adoptable = matches.filter((id) => {
352+
const claim = stateSection[id]?.uuid;
353+
return claim === undefined || claim === resource.id;
354+
});
330355

331-
return matches.length === 1 ? matches[0] : undefined;
356+
return adoptable.length === 1 ? adoptable[0] : undefined;
332357
}
333358

334359
function removeUuidMappings(
@@ -676,10 +701,23 @@ export async function pullResourceType(
676701
}
677702

678703
if (!resourceId) {
679-
// Reuse an existing file's resourceId if the name matches (cross-env pull)
704+
// Reuse an existing file's resourceId if the name matches (cross-env
705+
// pull). The adoption guard needs both prior-pull claims AND
706+
// intra-pull claims so the fix is iteration-order-independent:
707+
// - `state[resourceType]` carries prior-pull claims loaded from
708+
// disk. Without this, if the dashboard returns the new same-name
709+
// twin BEFORE the tracked one, the new twin sees `newStateSection`
710+
// empty and clobbers the tracked file. The customer's mudflap-prod
711+
// 5-Rileys investigation surfaced this ordering dependency.
712+
// - `newStateSection` carries intra-pull claims from earlier
713+
// iterations. Handles the converse (tracked-then-twin order).
714+
// Spread `newStateSection` last so it wins when both have the same
715+
// slug — the in-flight value is the more authoritative claim during
716+
// this pull (e.g. an earlier iteration rekeyed the slug to a new UUID).
717+
const claimView = { ...state[resourceType], ...newStateSection };
680718
resourceId = bootstrap
681719
? generateResourceId(resource)
682-
: (findExistingResourceId(existingResourceIds, resource) ??
720+
: (findExistingResourceId(existingResourceIds, resource, claimView) ??
683721
generateResourceId(resource));
684722
}
685723
const isNew =
Lines changed: 172 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,172 @@
1+
import assert from "node:assert/strict";
2+
import test from "node:test";
3+
import type { ResourceState } from "../src/types.ts";
4+
5+
// pull.ts depends on config.ts which calls process.exit(1) at module load
6+
// time if VAPI_TOKEN is not set or if argv[2] is not a valid slug. Set both
7+
// before dynamic-importing the module under test. Same pattern as
8+
// `clean-resource.test.ts`.
9+
process.argv = ["node", "test", "test-fixture-org"];
10+
process.env.VAPI_TOKEN = process.env.VAPI_TOKEN || "test-token-not-used";
11+
12+
const { findExistingResourceId } = await import("../src/pull.ts");
13+
14+
// Helper: build a minimal resource shape with the id+name fields the function
15+
// reads. Other fields are irrelevant for adoption logic.
16+
function resource(id: string, name: string) {
17+
return { id, name };
18+
}
19+
20+
function stateEntry(uuid: string): ResourceState {
21+
return { uuid };
22+
}
23+
24+
// ─────────────────────────────────────────────────────────────────────────────
25+
// Adoption: positive cases (the function reuses an on-disk file's slug)
26+
// ─────────────────────────────────────────────────────────────────────────────
27+
28+
test("adoption: state empty, one file matches name → adopt (cross-env pull)", () => {
29+
// Classic cross-env pull: dev shipped a file `riley-8102e715.md`, prod is
30+
// a fresh clone with no state entry for it. Pull should reuse the file
31+
// even though the UUID-suffix on disk differs from prod's UUID.
32+
const onDisk = ["riley-8102e715"];
33+
const newState: Record<string, ResourceState> = {};
34+
const result = findExistingResourceId(
35+
onDisk,
36+
resource("uuid-prod-aaaa", "Riley"),
37+
newState,
38+
);
39+
assert.equal(result, "riley-8102e715");
40+
});
41+
42+
test("adoption: file claimed in state by the SAME UUID → adopt (re-pull is idempotent)", () => {
43+
// Second pull of an already-tracked resource. State maps the slug to
44+
// THIS resource's UUID, so reusing the slug is correct.
45+
const onDisk = ["riley"];
46+
const newState: Record<string, ResourceState> = {
47+
riley: stateEntry("uuid-aaaa"),
48+
};
49+
const result = findExistingResourceId(
50+
onDisk,
51+
resource("uuid-aaaa", "Riley"),
52+
newState,
53+
);
54+
assert.equal(result, "riley");
55+
});
56+
57+
test("adoption: two matches but only one is adoptable (the unclaimed one) → adopt it", () => {
58+
// The dashboard has 2 same-named resources. The first was already
59+
// processed earlier in this same pull loop and adopted `riley.md`.
60+
// Now a different file `riley-deadbeef.md` (e.g. from cross-env pull
61+
// history) is on disk, unclaimed in state, and the current resource
62+
// can adopt it without conflict.
63+
const onDisk = ["riley", "riley-deadbeef"];
64+
const newState: Record<string, ResourceState> = {
65+
riley: stateEntry("uuid-aaaa"),
66+
};
67+
const result = findExistingResourceId(
68+
onDisk,
69+
resource("uuid-bbbb", "Riley"),
70+
newState,
71+
);
72+
assert.equal(result, "riley-deadbeef");
73+
});
74+
75+
// ─────────────────────────────────────────────────────────────────────────────
76+
// No adoption: the fix's core case + the existing 0-or-2+ behavior
77+
// ─────────────────────────────────────────────────────────────────────────────
78+
79+
test("no adoption: file claimed by DIFFERENT UUID → undefined (the fix's main case)", () => {
80+
// This is the clobber scenario the fix prevents: state already maps
81+
// `riley` to UUID A; a new resource with the same name but a NEW UUID
82+
// (B) must NOT adopt `riley.md` — doing so would overwrite A's content.
83+
const onDisk = ["riley"];
84+
const newState: Record<string, ResourceState> = {
85+
riley: stateEntry("uuid-aaaa"),
86+
};
87+
const result = findExistingResourceId(
88+
onDisk,
89+
resource("uuid-bbbb", "Riley"),
90+
newState,
91+
);
92+
assert.equal(result, undefined);
93+
});
94+
95+
test("no adoption: N+ matches with mixed claims → undefined", () => {
96+
// Two on-disk files both name-match. One is unclaimed (adoptable),
97+
// one is claimed by a different UUID (NOT adoptable). But ALSO a
98+
// third match exists, claimed by yet another different UUID. With
99+
// multiple adoptable candidates the 1:1 ambiguity guard still kicks
100+
// in. Here we test a related shape: 2 adoptable matches → ambiguous.
101+
const onDisk = ["riley", "riley-aaaa1111", "riley-bbbb2222"];
102+
const newState: Record<string, ResourceState> = {
103+
riley: stateEntry("uuid-other"),
104+
// riley-aaaa1111 and riley-bbbb2222 are both unclaimed → 2 adoptable
105+
};
106+
const result = findExistingResourceId(
107+
onDisk,
108+
resource("uuid-cccc", "Riley"),
109+
newState,
110+
);
111+
assert.equal(result, undefined);
112+
});
113+
114+
test("no adoption: N+ matches but all claimed by other UUIDs → undefined", () => {
115+
// Every name-matching file is claimed by some other UUID. No file is
116+
// adoptable for the current resource; fall through to
117+
// generateResourceId in the caller.
118+
const onDisk = ["riley", "riley-aaaa1111"];
119+
const newState: Record<string, ResourceState> = {
120+
riley: stateEntry("uuid-aaaa"),
121+
"riley-aaaa1111": stateEntry("uuid-bbbb"),
122+
};
123+
const result = findExistingResourceId(
124+
onDisk,
125+
resource("uuid-cccc", "Riley"),
126+
newState,
127+
);
128+
assert.equal(result, undefined);
129+
});
130+
131+
test("no adoption: no name-matching files on disk → undefined", () => {
132+
const onDisk = ["alex", "morgan-1234abcd"];
133+
const newState: Record<string, ResourceState> = {};
134+
const result = findExistingResourceId(
135+
onDisk,
136+
resource("uuid-aaaa", "Riley"),
137+
newState,
138+
);
139+
assert.equal(result, undefined);
140+
});
141+
142+
// ─────────────────────────────────────────────────────────────────────────────
143+
// Edge cases (regression guards, unchanged behavior)
144+
// ─────────────────────────────────────────────────────────────────────────────
145+
146+
test("regression: resource without a name → undefined (unchanged)", () => {
147+
// Tools store their name under function.name (see extractName). A
148+
// resource with neither a top-level name nor a function.name is
149+
// un-adoptable by design — no slug to compute.
150+
const onDisk = ["riley"];
151+
const newState: Record<string, ResourceState> = {};
152+
const result = findExistingResourceId(
153+
onDisk,
154+
{ id: "uuid-aaaa" }, // no name
155+
newState,
156+
);
157+
assert.equal(result, undefined);
158+
});
159+
160+
test("regression: two same-name files, state empty → undefined (unchanged ambiguity)", () => {
161+
// Pre-fix behavior: 2+ matches without a state discriminator → ambiguous,
162+
// refuse adoption. Fix should preserve this — both files are adoptable
163+
// (unclaimed), so `adoptable.length === 2` and the 1:1 guard fires.
164+
const onDisk = ["riley", "riley-deadbeef"];
165+
const newState: Record<string, ResourceState> = {};
166+
const result = findExistingResourceId(
167+
onDisk,
168+
resource("uuid-aaaa", "Riley"),
169+
newState,
170+
);
171+
assert.equal(result, undefined);
172+
});

0 commit comments

Comments
 (0)