@pikku/inspector 0.12.33 → 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.
Files changed (35) hide show
  1. package/CHANGELOG.md +31 -0
  2. package/dist/add/add-http-route.js +4 -1
  3. package/dist/add/add-rpc-invocations.js +7 -0
  4. package/dist/add/add-workflow.js +35 -4
  5. package/dist/inspector.js +1 -0
  6. package/dist/types.d.ts +1 -0
  7. package/dist/utils/filter-inspector-state.js +43 -0
  8. package/dist/utils/load-addon-functions-meta.js +20 -14
  9. package/dist/utils/post-process.js +38 -13
  10. package/dist/utils/serialize-inspector-state.d.ts +1 -0
  11. package/dist/utils/serialize-inspector-state.js +2 -0
  12. package/dist/utils/workflow/dsl/extract-dsl-workflow.js +9 -4
  13. package/dist/utils/workflow/dsl/patterns.d.ts +12 -1
  14. package/dist/utils/workflow/dsl/patterns.js +37 -2
  15. package/dist/utils/workflow/graph/convert-dsl-to-graph.js +8 -0
  16. package/dist/utils/workflow/graph/workflow-graph.types.d.ts +8 -0
  17. package/package.json +2 -2
  18. package/run-tests.sh +4 -4
  19. package/src/add/add-http-route.ts +4 -1
  20. package/src/add/add-rpc-invocations.test.ts +113 -0
  21. package/src/add/add-rpc-invocations.ts +8 -0
  22. package/src/add/add-workflow.ts +42 -4
  23. package/src/inspector.ts +1 -0
  24. package/src/types.ts +1 -0
  25. package/src/utils/filter-inspector-state.test.ts +231 -0
  26. package/src/utils/filter-inspector-state.ts +56 -0
  27. package/src/utils/load-addon-functions-meta.ts +26 -16
  28. package/src/utils/post-process.test.ts +254 -0
  29. package/src/utils/post-process.ts +41 -14
  30. package/src/utils/serialize-inspector-state.ts +11 -0
  31. package/src/utils/workflow/dsl/extract-dsl-workflow.ts +12 -3
  32. package/src/utils/workflow/dsl/patterns.ts +48 -2
  33. package/src/utils/workflow/graph/convert-dsl-to-graph.ts +8 -0
  34. package/src/utils/workflow/graph/workflow-graph.types.ts +8 -0
  35. package/tsconfig.tsbuildinfo +1 -1
@@ -0,0 +1,113 @@
1
+ import { strict as assert } from 'assert'
2
+ import { describe, test } from 'node:test'
3
+ import { mkdtemp, rm, writeFile } from 'node:fs/promises'
4
+ import { tmpdir } from 'node:os'
5
+ import { join } from 'node:path'
6
+ import { inspect } from '../inspector.js'
7
+ import type { InspectorLogger, InspectorState } from '../types.js'
8
+
9
+ function makeLogger(): InspectorLogger {
10
+ return {
11
+ debug: () => {},
12
+ info: () => {},
13
+ warn: () => {},
14
+ error: () => {},
15
+ diagnostic: () => {},
16
+ critical: () => {},
17
+ hasCriticalErrors: () => false,
18
+ }
19
+ }
20
+
21
+ async function inspectFiles(
22
+ files: Record<string, string>
23
+ ): Promise<{ state: InspectorState; dir: string }> {
24
+ const dir = await mkdtemp(join(tmpdir(), 'pikku-rpc-invocations-test-'))
25
+ const paths: string[] = []
26
+ for (const [name, source] of Object.entries(files)) {
27
+ const path = join(dir, name)
28
+ await writeFile(path, source)
29
+ paths.push(path)
30
+ }
31
+ const state = await inspect(makeLogger(), paths, { rootDir: dir })
32
+ return { state, dir }
33
+ }
34
+
35
+ describe('add-rpc-invocations — invokedFunctionsByFile', () => {
36
+ test('body-level rpc.invoke() is attributed to its source file', async () => {
37
+ const { state, dir } = await inspectFiles({
38
+ 'caller.ts': `
39
+ declare const rpc: { invoke: (name: string, data?: unknown) => Promise<unknown> }
40
+ export async function doWork() {
41
+ return rpc.invoke('console:getSchema')
42
+ }
43
+ `,
44
+ 'other.ts': `
45
+ export const unrelated = () => 'no invocations here'
46
+ `,
47
+ })
48
+ try {
49
+ assert.ok(state.rpc.invokedFunctions.has('console:getSchema'))
50
+ const byFile = state.rpc.invokedFunctionsByFile
51
+ assert.strictEqual(byFile.size, 1)
52
+ const [file, invoked] = [...byFile.entries()][0]!
53
+ assert.ok(file.endsWith('caller.ts'))
54
+ assert.deepStrictEqual([...invoked], ['console:getSchema'])
55
+ } finally {
56
+ await rm(dir, { recursive: true, force: true })
57
+ }
58
+ })
59
+
60
+ test('namespaced body invoke joins serviceAggregation.usedFunctions', async () => {
61
+ const { state, dir } = await inspectFiles({
62
+ 'caller.ts': `
63
+ declare const rpc: { invoke: (name: string, data?: unknown) => Promise<unknown> }
64
+ export async function doWork() {
65
+ await rpc.invoke('ext:goodbye')
66
+ return rpc.invoke('localHelper')
67
+ }
68
+ `,
69
+ })
70
+ try {
71
+ assert.ok(state.serviceAggregation.usedFunctions.has('ext:goodbye'))
72
+ assert.ok(!state.serviceAggregation.usedFunctions.has('localHelper'))
73
+ } finally {
74
+ await rm(dir, { recursive: true, force: true })
75
+ }
76
+ })
77
+
78
+ test('multiple invocations in one file accumulate under that file', async () => {
79
+ const { state, dir } = await inspectFiles({
80
+ 'caller.ts': `
81
+ declare const rpc: { invoke: (name: string, data?: unknown) => Promise<unknown> }
82
+ export async function doWork() {
83
+ await rpc.invoke('console:getSchema')
84
+ return rpc.invoke('listTasks')
85
+ }
86
+ `,
87
+ })
88
+ try {
89
+ const invoked = [...state.rpc.invokedFunctionsByFile.values()][0]!
90
+ assert.deepStrictEqual([...invoked].sort(), [
91
+ 'console:getSchema',
92
+ 'listTasks',
93
+ ])
94
+ } finally {
95
+ await rm(dir, { recursive: true, force: true })
96
+ }
97
+ })
98
+
99
+ test('wiring-level ref() lands in invokedFunctions but NOT in the by-file map', async () => {
100
+ const { state, dir } = await inspectFiles({
101
+ 'wiring.ts': `
102
+ declare const ref: (name: string) => unknown
103
+ export const routes = { stream: { func: ref('console:streamWorkflowRun') } }
104
+ `,
105
+ })
106
+ try {
107
+ assert.ok(state.rpc.invokedFunctions.has('console:streamWorkflowRun'))
108
+ assert.strictEqual(state.rpc.invokedFunctionsByFile.size, 0)
109
+ } finally {
110
+ await rm(dir, { recursive: true, force: true })
111
+ }
112
+ })
113
+ })
@@ -115,10 +115,18 @@ export function addRPCInvocations(
115
115
  logger.debug(`• Found RPC invocation: ${functionRef}`)
116
116
  state.rpc.invokedFunctions.add(functionRef)
117
117
 
118
+ let byFile = state.rpc.invokedFunctionsByFile.get(sourceFileName)
119
+ if (!byFile) {
120
+ byFile = new Set()
121
+ state.rpc.invokedFunctionsByFile.set(sourceFileName, byFile)
122
+ }
123
+ byFile.add(functionRef)
124
+
118
125
  const namespace = extractNamespace(functionRef)
119
126
  if (namespace) {
120
127
  logger.debug(` → Addon detected: ${namespace}`)
121
128
  state.rpc.usedAddons.add(namespace)
129
+ state.serviceAggregation.usedFunctions.add(functionRef)
122
130
  }
123
131
  }
124
132
  // Handle template literals like `function-${name}`
@@ -16,7 +16,10 @@ import {
16
16
  getPropertyValue,
17
17
  } from '../utils/get-property-value.js'
18
18
  import { extractDSLWorkflow } from '../utils/workflow/dsl/extract-dsl-workflow.js'
19
- import { getSourceText } from '../utils/workflow/dsl/patterns.js'
19
+ import {
20
+ getSourceText,
21
+ extractActorFromOptions,
22
+ } from '../utils/workflow/dsl/patterns.js'
20
23
 
21
24
  /**
22
25
  * Extract a workflow step's display name without letting a non-static name
@@ -86,7 +89,8 @@ export function collectInvokedRPCs(
86
89
  }
87
90
 
88
91
  /**
89
- * Scan for workflow.do(), workflow.sleep(), and workflow.cancel() calls to extract workflow steps
92
+ * Scan for workflow.do(), workflow.expectEventually(), workflow.sleep(), and
93
+ * workflow.cancel() calls to extract workflow steps
90
94
  */
91
95
  function getWorkflowInvocations(
92
96
  node: ts.Node,
@@ -100,7 +104,12 @@ function getWorkflowInvocations(
100
104
  const { name } = node
101
105
 
102
106
  // Check if this is accessing 'do' or 'sleep' property
103
- if (name.text === 'do' || name.text === 'sleep' || name.text === 'cancel') {
107
+ if (
108
+ name.text === 'do' ||
109
+ name.text === 'sleep' ||
110
+ name.text === 'cancel' ||
111
+ name.text === 'expectEventually'
112
+ ) {
104
113
  // Check if the parent is a call expression
105
114
  const parent = node.parent
106
115
  if (ts.isCallExpression(parent) && parent.expression === node) {
@@ -125,6 +134,7 @@ function getWorkflowInvocations(
125
134
  type: 'rpc',
126
135
  stepName,
127
136
  rpcName,
137
+ actor: extractActorFromOptions(optionsArg),
128
138
  })
129
139
  state.rpc.invokedFunctions.add(rpcName)
130
140
  } else if (isFunctionLike(secondArg)) {
@@ -135,6 +145,20 @@ function getWorkflowInvocations(
135
145
  description: description || '<dynamic>',
136
146
  })
137
147
  }
148
+ } else if (name.text === 'expectEventually' && args.length >= 4) {
149
+ // workflow.expectEventually(stepName, rpcName, data, predicate, options?)
150
+ const stepName = extractStepName(args[0], checker)
151
+ if (isStringLike(args[1], checker)) {
152
+ const rpcName = extractStringLiteral(args[1], checker)
153
+ steps.push({
154
+ type: 'rpc',
155
+ stepName,
156
+ rpcName,
157
+ expectEventually: true,
158
+ actor: extractActorFromOptions(args.length >= 5 ? args[4] : undefined),
159
+ })
160
+ state.rpc.invokedFunctions.add(rpcName)
161
+ }
138
162
  } else if (name.text === 'sleep' && args.length >= 2) {
139
163
  // workflow.sleep(stepName, duration)
140
164
  const stepNameArg = args[0]
@@ -337,10 +361,23 @@ export const addWorkflow: AddWiring = (logger, node, checker, state) => {
337
361
  // For pikkuWorkflowComplexFunc, also run basic extraction so RPCs in
338
362
  // patterns the DSL extractor doesn't handle (array+push, nested Promise.all
339
363
  // with identifier args, etc.) are still registered as invoked functions.
364
+ // When DSL extraction already produced steps this pass is registration-only:
365
+ // appending to `steps` would duplicate nodes and clobber the graph.
340
366
  if (wrapperType !== 'dsl') {
341
- getWorkflowInvocations(resolvedFunc, checker, state, workflowName, steps)
367
+ const sink: WorkflowStepMeta[] = steps.length > 0 ? [] : steps
368
+ getWorkflowInvocations(resolvedFunc, checker, state, workflowName, sink)
342
369
  }
343
370
 
371
+ // Actor names the flow's steps run as (user flows) — powers the console
372
+ // personas view and the User Flow graph badges.
373
+ const actorNames = [
374
+ ...new Set(
375
+ steps
376
+ .map((s) => ('actor' in s ? s.actor : undefined))
377
+ .filter((a): a is string => typeof a === 'string')
378
+ ),
379
+ ]
380
+
344
381
  state.workflows.meta[workflowName] = {
345
382
  pikkuFuncId,
346
383
  name: workflowName,
@@ -354,6 +391,7 @@ export const addWorkflow: AddWiring = (logger, node, checker, state) => {
354
391
  tags,
355
392
  expose,
356
393
  userFlow: wrapperType === 'user-flow' ? true : undefined,
394
+ actors: actorNames.length > 0 ? actorNames : undefined,
357
395
  }
358
396
 
359
397
  // User flows are pure stories of remote RPCs (same rule as client-side CLI
package/src/inspector.ts CHANGED
@@ -124,6 +124,7 @@ export function getInitialInspectorState(rootDir: string): InspectorState {
124
124
  exposedMeta: {},
125
125
  exposedFiles: new Map(),
126
126
  invokedFunctions: new Set(),
127
+ invokedFunctionsByFile: new Map(),
127
128
  usedAddons: new Set(),
128
129
  wireAddonDeclarations: new Map(),
129
130
  wireAddonFiles: new Set(),
package/src/types.ts CHANGED
@@ -482,6 +482,7 @@ export interface InspectorState {
482
482
  exposedMeta: Record<string, string>
483
483
  exposedFiles: Map<string, { path: string; exportedName: string }>
484
484
  invokedFunctions: Set<string>
485
+ invokedFunctionsByFile: Map<string, Set<string>>
485
486
  usedAddons: Set<string>
486
487
  wireAddonDeclarations: Map<
487
488
  string,
@@ -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 {