@@ -17,6 +17,7 @@ import { checkDriftForUpdate } from "./drift.ts";
1717import { writeSnapshot } from "./snapshot.ts" ;
1818import { mergeScoped } from "./state-merge.ts" ;
1919import {
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.
790792interface 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) .
849860async 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+
896928async 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