@pikku/inspector 0.12.34 → 0.12.35

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -234,6 +234,7 @@ function createMockInspectorState(): Omit<InspectorState, 'typesLookup'> {
234
234
  exposedMeta: {},
235
235
  exposedFiles: new Map(),
236
236
  invokedFunctions: new Set(),
237
+ invokedFunctionsByFile: new Map(),
237
238
  },
238
239
  mcpEndpoints: {
239
240
  toolsMeta: {
@@ -1657,3 +1658,233 @@ describe('addonServerlessIncompatible serialization roundtrip', () => {
1657
1658
  assert.strictEqual(restored.addonServerlessIncompatible.size, 0)
1658
1659
  })
1659
1660
  })
1661
+
1662
+ describe('addon bootstrap tree-shake', () => {
1663
+ const withAddon = (
1664
+ mutate?: (state: Omit<InspectorState, 'typesLookup'>) => void
1665
+ ) => {
1666
+ const state = createMockInspectorState()
1667
+ state.rpc.wireAddonDeclarations = new Map([
1668
+ ['console', { package: '@pikku/addon-console' }],
1669
+ ])
1670
+ state.rpc.usedAddons = new Set(['console'])
1671
+ state.rpc.invokedFunctions = new Set(['console:streamFunctionTests'])
1672
+ mutate?.(state)
1673
+ return state
1674
+ }
1675
+
1676
+ test('drops an addon nothing kept references', () => {
1677
+ const state = withAddon()
1678
+ const filtered = filterInspectorState(
1679
+ state,
1680
+ { names: ['getUsers'] },
1681
+ mockLogger
1682
+ )
1683
+ assert.strictEqual(filtered.rpc.wireAddonDeclarations.size, 0)
1684
+ assert.strictEqual(filtered.rpc.usedAddons.size, 0)
1685
+ })
1686
+
1687
+ test('keeps an addon when a kept wiring targets one of its functions', () => {
1688
+ const state = withAddon((s) => {
1689
+ s.http.meta.get['/console/stream'] = {
1690
+ pikkuFuncId: 'console:streamFunctionTests',
1691
+ route: '/console/stream',
1692
+ method: 'GET',
1693
+ tags: [],
1694
+ middleware: [],
1695
+ permissions: [],
1696
+ } as any
1697
+ })
1698
+ const filtered = filterInspectorState(
1699
+ state,
1700
+ { names: ['console:streamFunctionTests'] },
1701
+ mockLogger
1702
+ )
1703
+ assert.strictEqual(filtered.rpc.wireAddonDeclarations.size, 1)
1704
+ assert.ok(filtered.rpc.wireAddonDeclarations.has('console'))
1705
+ })
1706
+
1707
+ test('keeps an addon when a kept route ref()-targets one of its functions', () => {
1708
+ const state = withAddon((s) => {
1709
+ s.http.meta.get['/function-tests/stream'] = {
1710
+ pikkuFuncId: 'http:get:/function-tests/stream',
1711
+ refTarget: 'console:streamFunctionTests',
1712
+ route: '/function-tests/stream',
1713
+ method: 'GET',
1714
+ tags: [],
1715
+ middleware: [],
1716
+ permissions: [],
1717
+ } as any
1718
+ s.functions.meta['http:get:/function-tests/stream'] = {
1719
+ services: { optimized: false, services: [] },
1720
+ } as any
1721
+ })
1722
+ const filtered = filterInspectorState(
1723
+ state,
1724
+ { names: ['http:get:/function-tests/stream'] },
1725
+ mockLogger
1726
+ )
1727
+ assert.strictEqual(filtered.rpc.wireAddonDeclarations.size, 1)
1728
+ assert.ok(filtered.rpc.wireAddonDeclarations.has('console'))
1729
+ assert.ok(
1730
+ filtered.serviceAggregation.usedFunctions.has(
1731
+ 'console:streamFunctionTests'
1732
+ )
1733
+ )
1734
+ })
1735
+
1736
+ test('drops the addon when the ref()-wired route is filtered out', () => {
1737
+ const state = withAddon((s) => {
1738
+ s.http.meta.get['/function-tests/stream'] = {
1739
+ pikkuFuncId: 'http:get:/function-tests/stream',
1740
+ refTarget: 'console:streamFunctionTests',
1741
+ route: '/function-tests/stream',
1742
+ method: 'GET',
1743
+ tags: [],
1744
+ middleware: [],
1745
+ permissions: [],
1746
+ } as any
1747
+ s.functions.meta['http:get:/function-tests/stream'] = {
1748
+ services: { optimized: false, services: [] },
1749
+ } as any
1750
+ })
1751
+ const filtered = filterInspectorState(
1752
+ state,
1753
+ { names: ['getUsers'] },
1754
+ mockLogger
1755
+ )
1756
+ assert.strictEqual(filtered.rpc.wireAddonDeclarations.size, 0)
1757
+ assert.ok(
1758
+ !filtered.serviceAggregation.usedFunctions.has(
1759
+ 'console:streamFunctionTests'
1760
+ )
1761
+ )
1762
+ })
1763
+
1764
+ test('keeps an addon when a kept MCP tool targets one of its functions', () => {
1765
+ const state = withAddon((s) => {
1766
+ s.mcpEndpoints.toolsMeta['console:getSchema'] = {
1767
+ name: 'console:getSchema',
1768
+ description: 'addon tool',
1769
+ pikkuFuncId: 'console:getSchema',
1770
+ tags: [],
1771
+ middleware: [],
1772
+ permissions: [],
1773
+ } as any
1774
+ })
1775
+ const filtered = filterInspectorState(
1776
+ state,
1777
+ { names: ['console:getSchema'] },
1778
+ mockLogger
1779
+ )
1780
+ assert.strictEqual(filtered.rpc.wireAddonDeclarations.size, 1)
1781
+ })
1782
+
1783
+ test('keeps an addon when a kept agent lists one of its functions as a tool', () => {
1784
+ const state = withAddon((s) => {
1785
+ s.agents = s.agents ?? ({ agentsMeta: {} } as any)
1786
+ s.agents.agentsMeta['support-agent'] = {
1787
+ name: 'support-agent',
1788
+ pikkuFuncId: 'supportAgent',
1789
+ tools: ['console:getSchema'],
1790
+ tags: [],
1791
+ } as any
1792
+ })
1793
+ const filtered = filterInspectorState(
1794
+ state,
1795
+ { names: ['support-agent'] },
1796
+ mockLogger
1797
+ )
1798
+ assert.strictEqual(filtered.rpc.wireAddonDeclarations.size, 1)
1799
+ })
1800
+
1801
+ test('keeps an addon when a kept function body-invokes it', () => {
1802
+ const state = withAddon((s) => {
1803
+ s.rpc.invokedFunctionsByFile = new Map([
1804
+ ['/test/project/src/api/users.ts', new Set(['console:getSchema'])],
1805
+ ])
1806
+ })
1807
+ const filtered = filterInspectorState(
1808
+ state,
1809
+ { names: ['getUsers'] },
1810
+ mockLogger
1811
+ )
1812
+ assert.strictEqual(filtered.rpc.wireAddonDeclarations.size, 1)
1813
+ })
1814
+
1815
+ test('a kept-file body invoke joins the filtered usedFunctions', () => {
1816
+ const state = withAddon((s) => {
1817
+ s.rpc.invokedFunctionsByFile = new Map([
1818
+ ['/test/project/src/api/users.ts', new Set(['console:getSchema'])],
1819
+ ])
1820
+ })
1821
+ const filtered = filterInspectorState(
1822
+ state,
1823
+ { names: ['getUsers'] },
1824
+ mockLogger
1825
+ )
1826
+ assert.ok(
1827
+ filtered.serviceAggregation.usedFunctions.has('console:getSchema')
1828
+ )
1829
+ })
1830
+
1831
+ test('drops an addon body-invoked only from filtered-out files', () => {
1832
+ const state = withAddon((s) => {
1833
+ s.rpc.invokedFunctionsByFile = new Map([
1834
+ ['/test/project/src/admin/settings.ts', new Set(['console:getSchema'])],
1835
+ ])
1836
+ })
1837
+ const filtered = filterInspectorState(
1838
+ state,
1839
+ { names: ['getUsers'] },
1840
+ mockLogger
1841
+ )
1842
+ assert.strictEqual(filtered.rpc.wireAddonDeclarations.size, 0)
1843
+ })
1844
+
1845
+ test('unfiltered state keeps all addons', () => {
1846
+ const state = withAddon()
1847
+ const filtered = filterInspectorState(state, {}, mockLogger)
1848
+ assert.strictEqual(filtered.rpc.wireAddonDeclarations.size, 1)
1849
+ })
1850
+ })
1851
+
1852
+ describe('invokedFunctionsByFile serialization', () => {
1853
+ test('round-trips through serialize/deserialize', () => {
1854
+ const state = getInitialInspectorState('/test/project')
1855
+ state.rpc.invokedFunctionsByFile = new Map([
1856
+ ['/test/project/src/api/users.ts', new Set(['console:getSchema'])],
1857
+ [
1858
+ '/test/project/src/api/tasks.ts',
1859
+ new Set(['listTasks', 'console:runAgent']),
1860
+ ],
1861
+ ])
1862
+ const restored = deserializeInspectorState(
1863
+ JSON.parse(JSON.stringify(serializeInspectorState(state as any)))
1864
+ )
1865
+ assert.ok(restored.rpc.invokedFunctionsByFile instanceof Map)
1866
+ assert.strictEqual(restored.rpc.invokedFunctionsByFile.size, 2)
1867
+ assert.deepStrictEqual(
1868
+ [
1869
+ ...restored.rpc.invokedFunctionsByFile.get(
1870
+ '/test/project/src/api/tasks.ts'
1871
+ )!,
1872
+ ].sort(),
1873
+ ['console:runAgent', 'listTasks']
1874
+ )
1875
+ })
1876
+
1877
+ test('deserializing legacy state without the field yields an empty Map', () => {
1878
+ const serialized = JSON.parse(
1879
+ JSON.stringify(
1880
+ serializeInspectorState(
1881
+ getInitialInspectorState('/test/project') as any
1882
+ )
1883
+ )
1884
+ )
1885
+ delete serialized.rpc.invokedFunctionsByFile
1886
+ const restored = deserializeInspectorState(serialized)
1887
+ assert.ok(restored.rpc.invokedFunctionsByFile instanceof Map)
1888
+ assert.strictEqual(restored.rpc.invokedFunctionsByFile.size, 0)
1889
+ })
1890
+ })
@@ -468,6 +468,11 @@ export function filterInspectorState(
468
468
  filteredState.serviceAggregation.usedFunctions.add(
469
469
  routeMeta.pikkuFuncId
470
470
  )
471
+ if (routeMeta.refTarget) {
472
+ filteredState.serviceAggregation.usedFunctions.add(
473
+ routeMeta.refTarget
474
+ )
475
+ }
471
476
  // For workflow/agent routes, also add the base name
472
477
  // so the workflow/agent definition survives pruning
473
478
  const colonIdx = routeMeta.pikkuFuncId.indexOf(':')
@@ -1105,6 +1110,57 @@ export function filterInspectorState(
1105
1110
  filteredState.serviceAggregation.requiredServices.add('queueService')
1106
1111
  }
1107
1112
 
1113
+ if ((filteredState.rpc.wireAddonDeclarations?.size ?? 0) > 0) {
1114
+ const referencedIds = new Set<string>(
1115
+ filteredState.serviceAggregation.usedFunctions
1116
+ )
1117
+ const keptFiles = new Set<string>()
1118
+ for (const funcId of filteredState.serviceAggregation.usedFunctions) {
1119
+ const file = filteredState.functions.files.get(funcId)
1120
+ if (file?.path) keptFiles.add(file.path)
1121
+ }
1122
+ for (const [file, invoked] of state.rpc.invokedFunctionsByFile ??
1123
+ new Map<string, Set<string>>()) {
1124
+ if (!keptFiles.has(file)) continue
1125
+ for (const id of invoked) {
1126
+ referencedIds.add(id)
1127
+ if (id.includes(':')) {
1128
+ filteredState.serviceAggregation.usedFunctions.add(id)
1129
+ }
1130
+ }
1131
+ }
1132
+ for (const toolMeta of Object.values(
1133
+ filteredState.mcpEndpoints?.toolsMeta ?? {}
1134
+ ) as Array<{ pikkuFuncId?: string }>) {
1135
+ if (toolMeta.pikkuFuncId) referencedIds.add(toolMeta.pikkuFuncId)
1136
+ }
1137
+ for (const agentMeta of Object.values(
1138
+ filteredState.agents?.agentsMeta ?? {}
1139
+ ) as Array<{ tools?: string[] }>) {
1140
+ for (const tool of agentMeta.tools ?? []) referencedIds.add(tool)
1141
+ }
1142
+ const keptNamespaces = new Set<string>()
1143
+ for (const namespace of filteredState.rpc.wireAddonDeclarations.keys()) {
1144
+ const prefix = `${namespace}:`
1145
+ for (const id of referencedIds) {
1146
+ if (id.startsWith(prefix)) {
1147
+ keptNamespaces.add(namespace)
1148
+ break
1149
+ }
1150
+ }
1151
+ }
1152
+ filteredState.rpc.wireAddonDeclarations = new Map(
1153
+ [...filteredState.rpc.wireAddonDeclarations].filter(([namespace]) =>
1154
+ keptNamespaces.has(namespace)
1155
+ )
1156
+ )
1157
+ filteredState.rpc.usedAddons = new Set(
1158
+ [...filteredState.rpc.usedAddons].filter((namespace) =>
1159
+ keptNamespaces.has(namespace)
1160
+ )
1161
+ )
1162
+ }
1163
+
1108
1164
  // Recalculate requiredServices based on filtered functions/middleware/permissions
1109
1165
  // Need to cast to InspectorState temporarily for aggregateRequiredServices
1110
1166
  const stateForAggregation = filteredState as InspectorState
@@ -200,6 +200,7 @@ export async function loadAddonFunctionsMeta(
200
200
  // No variables meta — that's fine
201
201
  }
202
202
  // Load addon serverlessIncompatible service names from pikku-addon-meta.gen.json
203
+ let loadedParentServices = false
203
204
  try {
204
205
  const addonMetaPath = require.resolve(
205
206
  `${decl.package}/.pikku/console/pikku-addon-meta.gen.json`
@@ -218,29 +219,38 @@ export async function loadAddonFunctionsMeta(
218
219
  `Addon '${namespace}' marks [${addonMeta.serverlessIncompatible.join(', ')}] as serverless-incompatible`
219
220
  )
220
221
  }
221
- } catch {
222
- // No addon meta or no serverlessIncompatible declared — that's fine
223
- }
224
-
225
- // Load addon required parent services from pikku-services.gen
226
- try {
227
- const servicesGenPath = require.resolve(
228
- `${decl.package}/.pikku/pikku-services.gen.js`
229
- )
230
- const servicesModule = await import(servicesGenPath)
231
222
  if (
232
- servicesModule.requiredParentServices &&
233
- Array.isArray(servicesModule.requiredParentServices)
223
+ Array.isArray(addonMeta.requiredParentServices) &&
224
+ addonMeta.requiredParentServices.length > 0
234
225
  ) {
235
- for (const service of servicesModule.requiredParentServices) {
226
+ for (const service of addonMeta.requiredParentServices) {
236
227
  state.addonRequiredParentServices.push(service)
237
228
  }
229
+ loadedParentServices = true
238
230
  logger.debug(
239
- `Loaded ${servicesModule.requiredParentServices.length} required parent services for '${namespace}' from ${decl.package}`
231
+ `Loaded ${addonMeta.requiredParentServices.length} required parent services for '${namespace}' from addon meta`
240
232
  )
241
233
  }
242
- } catch {
243
- // No services gen — addon may not have requiredParentServices
234
+ } catch {}
235
+
236
+ if (!loadedParentServices) {
237
+ try {
238
+ const servicesGenPath = require.resolve(
239
+ `${decl.package}/.pikku/pikku-services.gen.js`
240
+ )
241
+ const servicesModule = await import(servicesGenPath)
242
+ if (
243
+ servicesModule.requiredParentServices &&
244
+ Array.isArray(servicesModule.requiredParentServices)
245
+ ) {
246
+ for (const service of servicesModule.requiredParentServices) {
247
+ state.addonRequiredParentServices.push(service)
248
+ }
249
+ logger.debug(
250
+ `Loaded ${servicesModule.requiredParentServices.length} required parent services for '${namespace}' from ${decl.package}`
251
+ )
252
+ }
253
+ } catch {}
244
254
  }
245
255
 
246
256
  try {
@@ -0,0 +1,254 @@
1
+ import { strict as assert } from 'assert'
2
+ import { describe, test } from 'node:test'
3
+ import { aggregateRequiredServices } from './post-process.js'
4
+ import type { InspectorState } from '../types.js'
5
+
6
+ function makeState(
7
+ overrides: {
8
+ usedFunctions?: string[]
9
+ functionsMeta?: Record<string, any>
10
+ addonFunctions?: Record<string, Record<string, any>>
11
+ addonRequiredParentServices?: string[]
12
+ } = {}
13
+ ): Omit<InspectorState, 'typesLookup'> {
14
+ return {
15
+ serviceAggregation: {
16
+ requiredServices: new Set<string>(),
17
+ usedFunctions: new Set(overrides.usedFunctions ?? []),
18
+ usedMiddleware: new Set<string>(),
19
+ usedPermissions: new Set<string>(),
20
+ allSingletonServices: [],
21
+ allWireServices: [],
22
+ },
23
+ functions: { meta: overrides.functionsMeta ?? {} },
24
+ middleware: { definitions: {}, tagMiddleware: new Map() },
25
+ permissions: { definitions: {}, tagPermissions: new Map() },
26
+ http: {
27
+ meta: {
28
+ get: {},
29
+ post: {},
30
+ put: {},
31
+ patch: {},
32
+ delete: {},
33
+ head: {},
34
+ options: {},
35
+ },
36
+ routeMiddleware: new Map(),
37
+ routePermissions: new Map(),
38
+ },
39
+ channels: { meta: {} },
40
+ queueWorkers: { meta: {} },
41
+ scheduledTasks: { meta: {} },
42
+ mcpEndpoints: { toolsMeta: {}, promptsMeta: {}, resourcesMeta: {} },
43
+ agents: { agentsMeta: {} },
44
+ workflows: { meta: {}, graphMeta: {} },
45
+ wireServicesMeta: new Map(),
46
+ rpc: { internalMeta: {}, exposedMeta: {} },
47
+ addonFunctions: overrides.addonFunctions ?? {},
48
+ addonRequiredParentServices: overrides.addonRequiredParentServices ?? [],
49
+ } as unknown as Omit<InspectorState, 'typesLookup'>
50
+ }
51
+
52
+ const CONSOLE_PARENT_SERVICES = [
53
+ 'metaService',
54
+ 'aiAgentRunner',
55
+ 'deploymentService',
56
+ 'credentialService',
57
+ ]
58
+
59
+ describe('aggregateRequiredServices — per-function addon services', () => {
60
+ test('a used addon function adds only its own parent services', () => {
61
+ const state = makeState({
62
+ usedFunctions: ['console:getSchema'],
63
+ addonFunctions: {
64
+ console: {
65
+ getSchema: {
66
+ services: { optimized: true, services: ['metaService'] },
67
+ },
68
+ runAgent: {
69
+ services: {
70
+ optimized: true,
71
+ services: ['aiAgentRunner', 'deploymentService'],
72
+ },
73
+ },
74
+ },
75
+ },
76
+ addonRequiredParentServices: CONSOLE_PARENT_SERVICES,
77
+ })
78
+ aggregateRequiredServices(state)
79
+ const required = state.serviceAggregation.requiredServices
80
+ assert.ok(required.has('metaService'))
81
+ assert.ok(!required.has('aiAgentRunner'))
82
+ assert.ok(!required.has('deploymentService'))
83
+ assert.ok(!required.has('credentialService'))
84
+ })
85
+
86
+ test('an addon-created service falls back to the full parent blanket', () => {
87
+ const state = makeState({
88
+ usedFunctions: ['console:editCode'],
89
+ addonFunctions: {
90
+ console: {
91
+ editCode: {
92
+ services: {
93
+ optimized: true,
94
+ services: ['codeEditService', 'metaService'],
95
+ },
96
+ },
97
+ },
98
+ },
99
+ addonRequiredParentServices: CONSOLE_PARENT_SERVICES,
100
+ })
101
+ aggregateRequiredServices(state)
102
+ const required = state.serviceAggregation.requiredServices
103
+ for (const service of CONSOLE_PARENT_SERVICES) {
104
+ assert.ok(required.has(service), `expected blanket to add ${service}`)
105
+ }
106
+ })
107
+
108
+ test('missing services meta (old addon build) falls back to the blanket', () => {
109
+ const state = makeState({
110
+ usedFunctions: ['console:getSchema'],
111
+ addonFunctions: { console: { getSchema: {} } },
112
+ addonRequiredParentServices: CONSOLE_PARENT_SERVICES,
113
+ })
114
+ aggregateRequiredServices(state)
115
+ const required = state.serviceAggregation.requiredServices
116
+ for (const service of CONSOLE_PARENT_SERVICES) {
117
+ assert.ok(required.has(service), `expected blanket to add ${service}`)
118
+ }
119
+ })
120
+
121
+ test('a bare project function colliding with an addon function name adds nothing', () => {
122
+ const state = makeState({
123
+ usedFunctions: ['getAgentThreads'],
124
+ functionsMeta: {
125
+ getAgentThreads: {
126
+ services: { optimized: true, services: ['kysely'] },
127
+ },
128
+ },
129
+ addonFunctions: {
130
+ console: {
131
+ getAgentThreads: {
132
+ services: { optimized: true, services: ['metaService'] },
133
+ },
134
+ },
135
+ },
136
+ addonRequiredParentServices: CONSOLE_PARENT_SERVICES,
137
+ })
138
+ aggregateRequiredServices(state)
139
+ const required = state.serviceAggregation.requiredServices
140
+ assert.ok(required.has('kysely'))
141
+ assert.ok(!required.has('metaService'))
142
+ assert.ok(!required.has('aiAgentRunner'))
143
+ })
144
+
145
+ test('no used addon functions adds no parent services', () => {
146
+ const state = makeState({
147
+ usedFunctions: ['listTasks'],
148
+ functionsMeta: {
149
+ listTasks: { services: { optimized: true, services: ['kysely'] } },
150
+ },
151
+ addonFunctions: {
152
+ console: {
153
+ getSchema: {
154
+ services: { optimized: true, services: ['metaService'] },
155
+ },
156
+ },
157
+ },
158
+ addonRequiredParentServices: CONSOLE_PARENT_SERVICES,
159
+ })
160
+ aggregateRequiredServices(state)
161
+ const required = state.serviceAggregation.requiredServices
162
+ assert.ok(!required.has('metaService'))
163
+ assert.ok(!required.has('aiAgentRunner'))
164
+ })
165
+
166
+ test('internal services in addon function meta never force the blanket', () => {
167
+ const state = makeState({
168
+ usedFunctions: ['console:getSchema'],
169
+ addonFunctions: {
170
+ console: {
171
+ getSchema: {
172
+ services: {
173
+ optimized: true,
174
+ services: ['metaService', 'rpc', 'channel'],
175
+ },
176
+ },
177
+ },
178
+ },
179
+ addonRequiredParentServices: CONSOLE_PARENT_SERVICES,
180
+ })
181
+ aggregateRequiredServices(state)
182
+ const required = state.serviceAggregation.requiredServices
183
+ assert.ok(required.has('metaService'))
184
+ assert.ok(!required.has('aiAgentRunner'))
185
+ assert.ok(!required.has('rpc'))
186
+ })
187
+
188
+ test('default framework services in addon function meta never force the blanket', () => {
189
+ const state = makeState({
190
+ usedFunctions: ['ext:goodbye'],
191
+ addonFunctions: {
192
+ ext: {
193
+ goodbye: {
194
+ services: { optimized: true, services: ['logger', 'config'] },
195
+ },
196
+ },
197
+ },
198
+ addonRequiredParentServices: ['greetingStore', 'auditSink'],
199
+ })
200
+ aggregateRequiredServices(state)
201
+ const required = state.serviceAggregation.requiredServices
202
+ assert.ok(!required.has('greetingStore'))
203
+ assert.ok(!required.has('auditSink'))
204
+ })
205
+
206
+ test('a ref()-wired route (inline id + namespaced target) aggregates the target services', () => {
207
+ const state = makeState({
208
+ usedFunctions: [
209
+ 'http:get:/function-tests/stream',
210
+ 'console:streamFunctionTests',
211
+ ],
212
+ functionsMeta: {
213
+ 'http:get:/function-tests/stream': {
214
+ services: { optimized: false, services: [] },
215
+ },
216
+ },
217
+ addonFunctions: {
218
+ console: {
219
+ streamFunctionTests: {
220
+ services: { optimized: true, services: ['metaService'] },
221
+ },
222
+ },
223
+ },
224
+ addonRequiredParentServices: CONSOLE_PARENT_SERVICES,
225
+ })
226
+ aggregateRequiredServices(state)
227
+ const required = state.serviceAggregation.requiredServices
228
+ assert.ok(required.has('metaService'))
229
+ assert.ok(!required.has('aiAgentRunner'))
230
+ })
231
+
232
+ test('multiple used addon functions union their parent services', () => {
233
+ const state = makeState({
234
+ usedFunctions: ['console:getSchema', 'console:runAgent'],
235
+ addonFunctions: {
236
+ console: {
237
+ getSchema: {
238
+ services: { optimized: true, services: ['metaService'] },
239
+ },
240
+ runAgent: {
241
+ services: { optimized: true, services: ['aiAgentRunner'] },
242
+ },
243
+ },
244
+ },
245
+ addonRequiredParentServices: CONSOLE_PARENT_SERVICES,
246
+ })
247
+ aggregateRequiredServices(state)
248
+ const required = state.serviceAggregation.requiredServices
249
+ assert.ok(required.has('metaService'))
250
+ assert.ok(required.has('aiAgentRunner'))
251
+ assert.ok(!required.has('deploymentService'))
252
+ assert.ok(!required.has('credentialService'))
253
+ })
254
+ })
@@ -325,20 +325,47 @@ export function aggregateRequiredServices(
325
325
  }
326
326
 
327
327
  // 7. Services that consumed addons need from the parent project.
328
- // These are required ONLY by units that actually deploy an addon function;
329
- // a unit that merely calls the addon over RPC (or never touches it) must not
330
- // carry them, or every per-unit bundle would over-include the addon's
331
- // parent-service dependencies (e.g. aiAgentRunner, deploymentService) and
332
- // defeat per-unit tree-shaking.
333
- const addonFuncIds = new Set<string>()
334
- for (const fns of Object.values(state.addonFunctions ?? {})) {
335
- for (const id of Object.keys(fns)) addonFuncIds.add(id)
336
- }
337
- const unitDeploysAddonFn = [...usedFunctions].some((fn) =>
338
- addonFuncIds.has(fn)
339
- )
340
- if (unitDeploysAddonFn) {
341
- for (const service of state.addonRequiredParentServices ?? []) {
328
+ const addonFnServices = new Map<string, string[] | undefined>()
329
+ for (const [namespace, fns] of Object.entries(state.addonFunctions ?? {})) {
330
+ for (const [id, meta] of Object.entries(fns)) {
331
+ addonFnServices.set(
332
+ `${namespace}:${id}`,
333
+ (meta as { services?: FunctionServicesMeta })?.services?.services
334
+ )
335
+ }
336
+ }
337
+ const parentDeclared = state.addonRequiredParentServices ?? []
338
+ const parentDeclaredSet = new Set(parentDeclared)
339
+ const defaultServices = new Set([
340
+ 'config',
341
+ 'logger',
342
+ 'variables',
343
+ 'schema',
344
+ 'secrets',
345
+ ])
346
+ let usesAddonFn = false
347
+ let addonFactoryNeeded = false
348
+ for (const funcId of usedFunctions) {
349
+ if (!addonFnServices.has(funcId)) continue
350
+ usesAddonFn = true
351
+ const services = addonFnServices.get(funcId)
352
+ if (!services) {
353
+ addonFactoryNeeded = true
354
+ continue
355
+ }
356
+ for (const service of services) {
357
+ if (parentDeclaredSet.has(service)) {
358
+ requiredServices.add(service)
359
+ } else if (
360
+ !internalServices.has(service) &&
361
+ !defaultServices.has(service)
362
+ ) {
363
+ addonFactoryNeeded = true
364
+ }
365
+ }
366
+ }
367
+ if (usesAddonFn && addonFactoryNeeded) {
368
+ for (const service of parentDeclared) {
342
369
  requiredServices.add(service)
343
370
  }
344
371
  }