Skip to content

Commit 5a7268b

Browse files
committed
refactor: address review — extend dedup to assistants, drop internal identifier refs from comments
Code-review follow-ups on PR #23: - src/dep-dedup.ts: replace `Record<string, unknown>` + `as`-cast with a named `NameablePayload = { name?: unknown; function?: unknown }` shape and `in`-operator narrowing. No casts, no laundered types — the function reads two known paths and narrows them at use. - src/push.ts: scrub "Gap #10" / "Stack J" / "improvements.md #15" identifiers from comments. These were internal stack/log markers that don't help anyone reading the code; rephrased in domain language while keeping the rationale. Also drops redundant `as Record<string, unknown>` casts at call sites and reuses `extractResourceName` for the display-name fallback in dedup warnings. - src/push.ts: extend dedup to assistants. The squad → assistant auto-apply path (`ensureAssistantExists`) had the same bug class as tools / SOs — bootstrap pull stores assistants under `<slug>-<uuid8>` keys, and a squad referencing the original local key would mint a duplicate assistant on every push. Adds `getExistingRemoteAssistants` lazy-fetch + dedup branch with the same orphan-deletion guard and apply-via-PATCH flow already in place for tools / SOs. Documents in the DependencyContext comment why simulations / personalities / scenarios / sim-suites are NOT covered: they're not auto-applied as dependencies anywhere in the engine, so the bug class doesn't fire. - tests/dep-dedup.test.ts: add explicit assistant-payload test (top-level `name`, no nested `function`). Build clean, 115/115 tests pass (was 114, +1 new assistant test).
1 parent 55f77f4 commit 5a7268b

3 files changed

Lines changed: 180 additions & 68 deletions

File tree

src/dep-dedup.ts

Lines changed: 19 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -56,16 +56,30 @@ export function extractBaseSlug(resourceId: string): string {
5656
return match?.[1] ?? resourceId;
5757
}
5858

59+
// Minimal payload shape this module needs. Local resource files are loaded
60+
// as `Record<string, unknown>`, so the only fields we know exist are `name`
61+
// (top-level, used by SOs / assistants / squads) and a nested `function.name`
62+
// (used by tools). Everything else stays opaque — we narrow at use.
63+
export type NameablePayload = { name?: unknown; function?: unknown };
64+
5965
// Pulls the canonical name from a tool / SO / assistant payload.
60-
// For tools: `function.name` is the canonical name (per pull.ts:261-267).
66+
// For tools: `function.name` is the canonical name.
6167
// For SOs / assistants / etc.: top-level `name`. Top-level wins when both
6268
// are present.
6369
export function extractResourceName(
64-
payload: Record<string, unknown>,
70+
payload: NameablePayload,
6571
): string | undefined {
6672
if (typeof payload.name === "string" && payload.name) return payload.name;
67-
const fn = payload.function as Record<string, unknown> | undefined;
68-
if (fn && typeof fn.name === "string" && fn.name) return fn.name;
73+
const fn = payload.function;
74+
if (
75+
fn !== null &&
76+
typeof fn === "object" &&
77+
"name" in fn &&
78+
typeof fn.name === "string" &&
79+
fn.name
80+
) {
81+
return fn.name;
82+
}
6983
return undefined;
7084
}
7185

@@ -85,7 +99,7 @@ export function extractResourceName(
8599
// the loser UUIDs in `duplicateUuids` so the caller can warn.
86100
export function findExistingResourceByName(args: {
87101
localResourceId: string;
88-
localPayload: Record<string, unknown>;
102+
localPayload: NameablePayload;
89103
stateSection: Record<string, ResourceState>;
90104
remoteList?: RemoteResource[];
91105
}): DedupMatch | undefined {

src/push.ts

Lines changed: 138 additions & 60 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import { checkDriftForUpdate } from "./drift.ts";
1717
import { writeSnapshot } from "./snapshot.ts";
1818
import { mergeScoped } from "./state-merge.ts";
1919
import {
20+
extractResourceName,
2021
findExistingResourceByName,
2122
type RemoteResource,
2223
} from "./dep-dedup.ts";
@@ -782,11 +783,12 @@ function filterResourcesByPaths<T>(
782783
return resources.filter((r) => matchingIds.has(r.resourceId));
783784
}
784785

785-
// Stack J — track which resourceIds were actually written during this apply.
786-
// On scoped push, the end-of-run save merges only these entries back into
787-
// the on-disk state, leaving untouched entries alone. Without this, a scoped
788-
// push (`npm run push -- <env> assistants/foo.md`) sweeps in any pre-existing
789-
// drift across the entire state file (improvements.md #15).
786+
// Track which resourceIds were actually written during this apply. On
787+
// scoped push, the end-of-run save merges only these entries back into
788+
// the on-disk state, leaving untouched entries alone. Without this, a
789+
// scoped push (`npm run push -- <env> assistants/foo.md`) would sweep
790+
// pre-existing drift across the entire state file into the commit-able
791+
// diff.
790792
interface TouchedSets {
791793
tools: Set<string>;
792794
structuredOutputs: Set<string>;
@@ -831,28 +833,35 @@ interface DependencyContext {
831833
autoApplied: Set<string>;
832834
autoAppliedTools: ResourceFile<Record<string, unknown>>[];
833835
autoAppliedStructuredOutputs: ResourceFile<Record<string, unknown>>[];
834-
// Stack — Gap #10 dedup. Lazy-fetched at most once per push when an
835-
// auto-apply path needs to verify the tool/SO doesn't already exist on
836-
// the dashboard under a different state key. `undefined` = not yet
837-
// fetched; an empty array means "we tried, dashboard returned 0".
836+
// Lazy-fetched dashboard inventories — populated at most once per push,
837+
// only when an auto-apply path needs to verify an existing dashboard
838+
// resource isn't being shadowed by a renamed/state-only key.
839+
// `undefined` = not yet fetched; `[]` = fetched but dashboard returned 0
840+
// (or the fetch failed and we degraded to state-only dedup).
841+
//
842+
// Simulations / personalities / scenarios / simulation suites are not
843+
// listed here because they're not auto-applied as dependencies anywhere
844+
// in the engine (they're top-level resources only). If we ever add a
845+
// dependency-resolution path for them, mirror this pattern.
838846
existingRemoteTools?: RemoteResource[];
839847
existingRemoteStructuredOutputs?: RemoteResource[];
840-
// Touched sets — adoption needs to mark touched so scoped state-merge
841-
// (#15 / Stack J) persists the adopted UUID under the local resourceId.
848+
existingRemoteAssistants?: RemoteResource[];
849+
// Track which resourceIds we mutated so the scoped state-merge on save
850+
// can flush only the touched section, leaving untouched on-disk state
851+
// alone. Required for adoption: without it, a scoped push would lose
852+
// the adopted UUID at end-of-run save.
842853
touched: TouchedSets;
843854
}
844855

845-
// Gap #10 — lazy-fetch the live dashboard tool list once per push. Used by
846-
// `ensureToolExists` to detect bootstrap-renamed entries (e.g. local key
847-
// `b2b-invoice-end-call` vs state key `end-call-67aea057`) and live
848-
// dashboard duplicates from prior bug runs before minting another POST.
856+
// Lazy-fetch the live dashboard inventory for a given resource type. Used by
857+
// the auto-apply path to detect existing dashboard resources before minting
858+
// a duplicate POST. Honors dry-run by skipping the API call entirely
859+
// (empty list → state-only dedup).
849860
async function getExistingRemoteTools(
850861
ctx: DependencyContext,
851862
): Promise<RemoteResource[]> {
852863
if (ctx.existingRemoteTools !== undefined) return ctx.existingRemoteTools;
853864
if (DRY_RUN) {
854-
// Honor dry-run: no API calls. Empty list = "no dashboard match
855-
// possible" → falls through to existing create-skip log.
856865
ctx.existingRemoteTools = [];
857866
return ctx.existingRemoteTools;
858867
}
@@ -893,6 +902,29 @@ async function getExistingRemoteStructuredOutputs(
893902
return ctx.existingRemoteStructuredOutputs;
894903
}
895904

905+
async function getExistingRemoteAssistants(
906+
ctx: DependencyContext,
907+
): Promise<RemoteResource[]> {
908+
if (ctx.existingRemoteAssistants !== undefined)
909+
return ctx.existingRemoteAssistants;
910+
if (DRY_RUN) {
911+
ctx.existingRemoteAssistants = [];
912+
return ctx.existingRemoteAssistants;
913+
}
914+
try {
915+
const remote = await fetchAllResources("assistants");
916+
ctx.existingRemoteAssistants = remote as unknown as RemoteResource[];
917+
} catch (err) {
918+
console.warn(
919+
` ⚠️ Could not fetch dashboard assistants for dedup check: ${
920+
err instanceof Error ? err.message : String(err)
921+
}. Falling back to state-only dedup.`,
922+
);
923+
ctx.existingRemoteAssistants = [];
924+
}
925+
return ctx.existingRemoteAssistants;
926+
}
927+
896928
async function ensureToolExists(
897929
toolId: string,
898930
ctx: DependencyContext,
@@ -907,25 +939,20 @@ async function ensureToolExists(
907939
const tool = ctx.allTools.find((t) => t.resourceId === toolId);
908940
if (!tool) return;
909941

910-
// Gap #10 dedup — before creating, check if a state entry under a
911-
// different key (bootstrap-generated like `end-call-<uuid8>`) or a
912-
// live dashboard tool already represents this same logical tool.
913-
// Adopt instead of mint a duplicate.
942+
// Before creating, check whether an existing state entry (under a
943+
// different key — e.g., bootstrap-generated `end-call-<uuid8>`) or a
944+
// live dashboard tool already represents this same logical tool. Adopt
945+
// instead of minting a duplicate.
914946
const remoteList = await getExistingRemoteTools(ctx);
915947
const match = findExistingResourceByName({
916948
localResourceId: toolId,
917-
localPayload: tool.data as Record<string, unknown>,
949+
localPayload: tool.data,
918950
stateSection: ctx.state.tools,
919951
remoteList,
920952
});
921953
if (match) {
922954
if (match.ambiguous) {
923-
const payload = tool.data as Record<string, unknown>;
924-
const fn = payload.function as Record<string, unknown> | undefined;
925-
const displayName =
926-
(typeof fn?.name === "string" && fn.name) ||
927-
(typeof payload.name === "string" && payload.name) ||
928-
toolId;
955+
const displayName = extractResourceName(tool.data) ?? toolId;
929956
console.warn(
930957
` ⚠️ Multiple dashboard tools share the name "${displayName}" — adopting ${match.uuid} (lex-smallest). Other UUIDs: ${match.duplicateUuids.join(", ")}. Run \`npm run cleanup -- ${VAPI_ENV}\` to prune duplicates.`,
931958
);
@@ -939,23 +966,24 @@ async function ensureToolExists(
939966
// record the post-PATCH hash, exercising the standard drift-check flow.
940967
upsertState(ctx.state.tools, tool.resourceId, { uuid: match.uuid });
941968

942-
// Orphan-deletion guard — drop other state keys pointing at the SAME uuid
943-
// so a subsequent full push doesn't see them as "tracked but no local file"
944-
// and DELETE the dashboard resource we just adopted. Mark them touched
945-
// so mergeScoped (Stack J) flushes the deletion. Do NOT touch entries
946-
// pointing at match.duplicateUuids — those are SEPARATE dashboard duplicates
947-
// and `npm run cleanup` is the right tool for those.
969+
// Orphan-deletion guard — drop other state keys pointing at the SAME
970+
// uuid so a subsequent full push doesn't see them as "tracked but no
971+
// local file" and DELETE the dashboard resource we just adopted. Mark
972+
// them touched so the scoped state-merge on save flushes the deletion.
973+
// Entries pointing at `match.duplicateUuids` are SEPARATE dashboard
974+
// duplicates — leave them alone; `npm run cleanup` handles those.
948975
for (const [staleKey, entry] of Object.entries(ctx.state.tools)) {
949976
if (staleKey !== tool.resourceId && entry.uuid === match.uuid) {
950977
delete ctx.state.tools[staleKey];
951978
ctx.touched.tools.add(staleKey);
952979
}
953980
}
954981

955-
// Now PATCH the dashboard with the local payload. applyTool's
956-
// upsertResourceWithStateRecovery branch picks PATCH because state.tools
957-
// now has existingUuid set. Drift check fires (no-baseline → log + proceed
958-
// when lastPulledHash is undefined; full check when it isn't).
982+
// PATCH the dashboard with the local payload. `applyTool`'s
983+
// `upsertResourceWithStateRecovery` branch picks PATCH because
984+
// `state.tools` now has `existingUuid` set. Drift check fires
985+
// (no-baseline → log + proceed when `lastPulledHash` is undefined;
986+
// full check when it isn't).
959987
try {
960988
const uuid = await applyTool(tool, ctx.state);
961989
ctx.autoApplied.add(`tools:${toolId}`);
@@ -1008,20 +1036,18 @@ async function ensureStructuredOutputExists(
10081036
);
10091037
if (!output) return;
10101038

1011-
// Gap #10 dedup — same as ensureToolExists but against the SO state
1012-
// section + dashboard SO list.
1039+
// Same dedup pattern as `ensureToolExists`, against the SO state section
1040+
// and live dashboard SO list.
10131041
const remoteList = await getExistingRemoteStructuredOutputs(ctx);
10141042
const match = findExistingResourceByName({
10151043
localResourceId: outputId,
1016-
localPayload: output.data as Record<string, unknown>,
1044+
localPayload: output.data,
10171045
stateSection: ctx.state.structuredOutputs,
10181046
remoteList,
10191047
});
10201048
if (match) {
10211049
if (match.ambiguous) {
1022-
const payload = output.data as Record<string, unknown>;
1023-
const displayName =
1024-
(typeof payload.name === "string" && payload.name) || outputId;
1050+
const displayName = extractResourceName(output.data) ?? outputId;
10251051
console.warn(
10261052
` ⚠️ Multiple dashboard structured outputs share the name "${displayName}" — adopting ${match.uuid} (lex-smallest). Other UUIDs: ${match.duplicateUuids.join(", ")}. Run \`npm run cleanup -- ${VAPI_ENV}\` to prune duplicates.`,
10271053
);
@@ -1038,24 +1064,19 @@ async function ensureStructuredOutputExists(
10381064
uuid: match.uuid,
10391065
});
10401066

1041-
// Orphan-deletion guard — drop other state keys pointing at the SAME uuid
1042-
// so a subsequent full push doesn't see them as "tracked but no local file"
1043-
// and DELETE the dashboard resource we just adopted. Mark them touched
1044-
// so mergeScoped (Stack J) flushes the deletion. Do NOT touch entries
1045-
// pointing at match.duplicateUuids — those are SEPARATE dashboard duplicates
1046-
// and `npm run cleanup` is the right tool for those.
1067+
// Orphan-deletion guard — drop other state keys pointing at the SAME
1068+
// uuid so a subsequent full push doesn't see them as "tracked but no
1069+
// local file" and DELETE the dashboard resource we just adopted. Mark
1070+
// them touched so the scoped state-merge on save flushes the deletion.
10471071
for (const [staleKey, entry] of Object.entries(ctx.state.structuredOutputs)) {
10481072
if (staleKey !== output.resourceId && entry.uuid === match.uuid) {
10491073
delete ctx.state.structuredOutputs[staleKey];
10501074
ctx.touched.structuredOutputs.add(staleKey);
10511075
}
10521076
}
10531077

1054-
// Now PATCH the dashboard with the local payload. applyStructuredOutput's
1055-
// upsertResourceWithStateRecovery branch picks PATCH because
1056-
// state.structuredOutputs now has existingUuid set. Drift check fires
1057-
// (no-baseline → log + proceed when lastPulledHash is undefined; full
1058-
// check when it isn't).
1078+
// PATCH via the standard apply path so drift detection fires and any
1079+
// local edits land on the dashboard.
10591080
try {
10601081
const uuid = await applyStructuredOutput(output, ctx.state);
10611082
ctx.autoApplied.add(`structuredOutputs:${outputId}`);
@@ -1150,6 +1171,62 @@ async function ensureAssistantExists(
11501171
const assistant = ctx.allAssistants.find((a) => a.resourceId === assistantId);
11511172
if (!assistant) return;
11521173

1174+
// Same dedup pattern as `ensureToolExists` / `ensureStructuredOutputExists`,
1175+
// against the assistant state section and live dashboard list. Catches the
1176+
// case where bootstrap pull stored the same dashboard assistant under a
1177+
// `<name-slug>-<uuid8>` key and the squad references it by the original
1178+
// local key.
1179+
const remoteList = await getExistingRemoteAssistants(ctx);
1180+
const match = findExistingResourceByName({
1181+
localResourceId: assistantId,
1182+
localPayload: assistant.data,
1183+
stateSection: ctx.state.assistants,
1184+
remoteList,
1185+
});
1186+
if (match) {
1187+
if (match.ambiguous) {
1188+
const displayName = extractResourceName(assistant.data) ?? assistantId;
1189+
console.warn(
1190+
` ⚠️ Multiple dashboard assistants share the name "${displayName}" — adopting ${match.uuid} (lex-smallest). Other UUIDs: ${match.duplicateUuids.join(", ")}. Run \`npm run cleanup -- ${VAPI_ENV}\` to prune duplicates.`,
1191+
);
1192+
}
1193+
console.log(
1194+
` 🔁 Reusing existing assistant: ${assistantId}${match.uuid} (matched via ${match.source})`,
1195+
);
1196+
1197+
upsertState(ctx.state.assistants, assistant.resourceId, {
1198+
uuid: match.uuid,
1199+
});
1200+
1201+
// Orphan-deletion guard — drop other state keys pointing at the SAME
1202+
// uuid so a subsequent full push doesn't see them as "tracked but no
1203+
// local file" and DELETE the dashboard resource we just adopted.
1204+
for (const [staleKey, entry] of Object.entries(ctx.state.assistants)) {
1205+
if (staleKey !== assistant.resourceId && entry.uuid === match.uuid) {
1206+
delete ctx.state.assistants[staleKey];
1207+
ctx.touched.assistants.add(staleKey);
1208+
}
1209+
}
1210+
1211+
// PATCH via the standard apply path so drift detection fires and any
1212+
// local edits land on the dashboard.
1213+
try {
1214+
const uuid = await applyAssistant(assistant, ctx.state);
1215+
ctx.autoApplied.add(`assistants:${assistantId}`);
1216+
if (!uuid) return;
1217+
upsertState(ctx.state.assistants, assistant.resourceId, {
1218+
uuid,
1219+
lastPushedHash: hashPayload(assistant.data),
1220+
});
1221+
ctx.applied.assistants++;
1222+
ctx.touched.assistants.add(assistant.resourceId);
1223+
} catch (error) {
1224+
console.error(formatApiError(assistantId, error));
1225+
throw error;
1226+
}
1227+
return;
1228+
}
1229+
11531230
console.log(` 📦 Auto-applying dependency → assistant: ${assistantId}`);
11541231
try {
11551232
const uuid = await applyAssistant(assistant, ctx.state);
@@ -1163,6 +1240,7 @@ async function ensureAssistantExists(
11631240
});
11641241
ctx.applied.assistants++;
11651242
ctx.autoApplied.add(`assistants:${assistantId}`);
1243+
ctx.touched.assistants.add(assistant.resourceId);
11661244
} catch (error) {
11671245
console.error(formatApiError(assistantId, error));
11681246
throw error;
@@ -1200,8 +1278,8 @@ async function main(): Promise<void> {
12001278
// Load current state (needed for reference resolution even in partial apply)
12011279
let state = loadState();
12021280

1203-
// Stack J — track which resourceIds we actually mutate so the end-of-run
1204-
// save can merge into existing on-disk state instead of rewriting wholesale.
1281+
// Track which resourceIds we actually mutate so the end-of-run save can
1282+
// merge into existing on-disk state instead of rewriting wholesale.
12051283
const touched: TouchedSets = emptyTouchedSets();
12061284

12071285
// Track what was applied for summary
@@ -1703,9 +1781,9 @@ async function main(): Promise<void> {
17031781
);
17041782
} else {
17051783
try {
1706-
// Stack J — for scoped pushes, only persist entries we actually
1707-
// mutated. Re-load disk state and merge our touched entries on top
1708-
// so unrelated drift in untouched entries is left alone. A bare
1784+
// For scoped pushes, only persist entries we actually mutated.
1785+
// Re-load disk state and merge our touched entries on top so
1786+
// unrelated drift in untouched entries is left alone. A bare
17091787
// (non-partial) push falls through to the wholesale save.
17101788
const stateToWrite = partial
17111789
? mergeScoped(loadState(), state, touched)

0 commit comments

Comments
 (0)