@@ -16,6 +16,10 @@ import { summarizeFindings, validateResources } from "./validate.ts";
1616import { checkDriftForUpdate } from "./drift.ts" ;
1717import { writeSnapshot } from "./snapshot.ts" ;
1818import { mergeScoped } from "./state-merge.ts" ;
19+ import {
20+ findExistingResourceByName ,
21+ type RemoteResource ,
22+ } from "./dep-dedup.ts" ;
1923
2024// Map a resource label to its state-file key. Used for snapshotting (Stack H)
2125// — snapshot directories are keyed by the same names the state file uses.
@@ -827,6 +831,66 @@ interface DependencyContext {
827831 autoApplied : Set < string > ;
828832 autoAppliedTools : ResourceFile < Record < string , unknown > > [ ] ;
829833 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".
838+ existingRemoteTools ?: RemoteResource [ ] ;
839+ 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.
842+ touched : TouchedSets ;
843+ }
844+
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.
849+ async function getExistingRemoteTools (
850+ ctx : DependencyContext ,
851+ ) : Promise < RemoteResource [ ] > {
852+ if ( ctx . existingRemoteTools !== undefined ) return ctx . existingRemoteTools ;
853+ if ( DRY_RUN ) {
854+ // Honor dry-run: no API calls. Empty list = "no dashboard match
855+ // possible" → falls through to existing create-skip log.
856+ ctx . existingRemoteTools = [ ] ;
857+ return ctx . existingRemoteTools ;
858+ }
859+ try {
860+ const remote = await fetchAllResources ( "tools" ) ;
861+ ctx . existingRemoteTools = remote as unknown as RemoteResource [ ] ;
862+ } catch ( err ) {
863+ console . warn (
864+ ` ⚠️ Could not fetch dashboard tools for dedup check: ${
865+ err instanceof Error ? err . message : String ( err )
866+ } . Falling back to state-only dedup.`,
867+ ) ;
868+ ctx . existingRemoteTools = [ ] ;
869+ }
870+ return ctx . existingRemoteTools ;
871+ }
872+
873+ async function getExistingRemoteStructuredOutputs (
874+ ctx : DependencyContext ,
875+ ) : Promise < RemoteResource [ ] > {
876+ if ( ctx . existingRemoteStructuredOutputs !== undefined )
877+ return ctx . existingRemoteStructuredOutputs ;
878+ if ( DRY_RUN ) {
879+ ctx . existingRemoteStructuredOutputs = [ ] ;
880+ return ctx . existingRemoteStructuredOutputs ;
881+ }
882+ try {
883+ const remote = await fetchAllResources ( "structuredOutputs" ) ;
884+ ctx . existingRemoteStructuredOutputs = remote as unknown as RemoteResource [ ] ;
885+ } catch ( err ) {
886+ console . warn (
887+ ` ⚠️ Could not fetch dashboard structured outputs for dedup check: ${
888+ err instanceof Error ? err . message : String ( err )
889+ } . Falling back to state-only dedup.`,
890+ ) ;
891+ ctx . existingRemoteStructuredOutputs = [ ] ;
892+ }
893+ return ctx . existingRemoteStructuredOutputs ;
830894}
831895
832896async function ensureToolExists (
@@ -843,6 +907,73 @@ async function ensureToolExists(
843907 const tool = ctx . allTools . find ( ( t ) => t . resourceId === toolId ) ;
844908 if ( ! tool ) return ;
845909
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.
914+ const remoteList = await getExistingRemoteTools ( ctx ) ;
915+ const match = findExistingResourceByName ( {
916+ localResourceId : toolId ,
917+ localPayload : tool . data as Record < string , unknown > ,
918+ stateSection : ctx . state . tools ,
919+ remoteList,
920+ } ) ;
921+ if ( match ) {
922+ 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 ;
929+ console . warn (
930+ ` ⚠️ 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.` ,
931+ ) ;
932+ }
933+ console . log (
934+ ` 🔁 Reusing existing tool: ${ toolId } → ${ match . uuid } (matched via ${ match . source } )` ,
935+ ) ;
936+
937+ // Re-key state to point at the adopted UUID under the local resourceId.
938+ // No hash yet — applyTool below will PATCH with the local payload and
939+ // record the post-PATCH hash, exercising the standard drift-check flow.
940+ upsertState ( ctx . state . tools , tool . resourceId , { uuid : match . uuid } ) ;
941+
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.
948+ for ( const [ staleKey , entry ] of Object . entries ( ctx . state . tools ) ) {
949+ if ( staleKey !== tool . resourceId && entry . uuid === match . uuid ) {
950+ delete ctx . state . tools [ staleKey ] ;
951+ ctx . touched . tools . add ( staleKey ) ;
952+ }
953+ }
954+
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).
959+ try {
960+ const uuid = await applyTool ( tool , ctx . state ) ;
961+ ctx . autoApplied . add ( `tools:${ toolId } ` ) ;
962+ if ( ! uuid ) return ;
963+ upsertState ( ctx . state . tools , tool . resourceId , {
964+ uuid,
965+ lastPushedHash : hashPayload ( tool . data ) ,
966+ } ) ;
967+ ctx . applied . tools ++ ;
968+ ctx . autoAppliedTools . push ( tool ) ;
969+ ctx . touched . tools . add ( tool . resourceId ) ;
970+ } catch ( error ) {
971+ console . error ( formatApiError ( toolId , error ) ) ;
972+ throw error ;
973+ }
974+ return ;
975+ }
976+
846977 console . log ( ` 📦 Auto-applying dependency → tool: ${ toolId } ` ) ;
847978 try {
848979 const uuid = await applyTool ( tool , ctx . state ) ;
@@ -854,6 +985,7 @@ async function ensureToolExists(
854985 } ) ;
855986 ctx . applied . tools ++ ;
856987 ctx . autoAppliedTools . push ( tool ) ;
988+ ctx . touched . tools . add ( tool . resourceId ) ;
857989 } catch ( error ) {
858990 console . error ( formatApiError ( toolId , error ) ) ;
859991 throw error ;
@@ -876,6 +1008,72 @@ async function ensureStructuredOutputExists(
8761008 ) ;
8771009 if ( ! output ) return ;
8781010
1011+ // Gap #10 dedup — same as ensureToolExists but against the SO state
1012+ // section + dashboard SO list.
1013+ const remoteList = await getExistingRemoteStructuredOutputs ( ctx ) ;
1014+ const match = findExistingResourceByName ( {
1015+ localResourceId : outputId ,
1016+ localPayload : output . data as Record < string , unknown > ,
1017+ stateSection : ctx . state . structuredOutputs ,
1018+ remoteList,
1019+ } ) ;
1020+ if ( match ) {
1021+ if ( match . ambiguous ) {
1022+ const payload = output . data as Record < string , unknown > ;
1023+ const displayName =
1024+ ( typeof payload . name === "string" && payload . name ) || outputId ;
1025+ console . warn (
1026+ ` ⚠️ 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.` ,
1027+ ) ;
1028+ }
1029+ console . log (
1030+ ` 🔁 Reusing existing structured output: ${ outputId } → ${ match . uuid } (matched via ${ match . source } )` ,
1031+ ) ;
1032+
1033+ // Re-key state to point at the adopted UUID under the local resourceId.
1034+ // No hash yet — applyStructuredOutput below will PATCH with the local
1035+ // payload and record the post-PATCH hash, exercising the standard
1036+ // drift-check flow.
1037+ upsertState ( ctx . state . structuredOutputs , output . resourceId , {
1038+ uuid : match . uuid ,
1039+ } ) ;
1040+
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.
1047+ for ( const [ staleKey , entry ] of Object . entries ( ctx . state . structuredOutputs ) ) {
1048+ if ( staleKey !== output . resourceId && entry . uuid === match . uuid ) {
1049+ delete ctx . state . structuredOutputs [ staleKey ] ;
1050+ ctx . touched . structuredOutputs . add ( staleKey ) ;
1051+ }
1052+ }
1053+
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).
1059+ try {
1060+ const uuid = await applyStructuredOutput ( output , ctx . state ) ;
1061+ ctx . autoApplied . add ( `structuredOutputs:${ outputId } ` ) ;
1062+ if ( ! uuid ) return ;
1063+ upsertState ( ctx . state . structuredOutputs , output . resourceId , {
1064+ uuid,
1065+ lastPushedHash : hashPayload ( output . data ) ,
1066+ } ) ;
1067+ ctx . applied . structuredOutputs ++ ;
1068+ ctx . autoAppliedStructuredOutputs . push ( output ) ;
1069+ ctx . touched . structuredOutputs . add ( output . resourceId ) ;
1070+ } catch ( error ) {
1071+ console . error ( formatApiError ( outputId , error ) ) ;
1072+ throw error ;
1073+ }
1074+ return ;
1075+ }
1076+
8791077 console . log ( ` 📦 Auto-applying dependency → structured output: ${ outputId } ` ) ;
8801078 try {
8811079 const uuid = await applyStructuredOutput ( output , ctx . state ) ;
@@ -887,6 +1085,7 @@ async function ensureStructuredOutputExists(
8871085 } ) ;
8881086 ctx . applied . structuredOutputs ++ ;
8891087 ctx . autoAppliedStructuredOutputs . push ( output ) ;
1088+ ctx . touched . structuredOutputs . add ( output . resourceId ) ;
8901089 } catch ( error ) {
8911090 console . error ( formatApiError ( outputId , error ) ) ;
8921091 throw error ;
@@ -1178,6 +1377,7 @@ async function main(): Promise<void> {
11781377 autoApplied,
11791378 autoAppliedTools,
11801379 autoAppliedStructuredOutputs,
1380+ touched,
11811381 } ;
11821382
11831383 // Determine which types to check for orphaned deletions
0 commit comments