@pikku/inspector 0.12.7 → 0.12.9

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 (51) hide show
  1. package/CHANGELOG.md +29 -0
  2. package/dist/add/add-ai-agent.js +24 -7
  3. package/dist/add/add-channel.js +2 -2
  4. package/dist/add/add-cli.js +13 -10
  5. package/dist/add/add-file-with-factory.js +22 -5
  6. package/dist/add/add-functions.js +4 -3
  7. package/dist/add/add-http-route.js +1 -0
  8. package/dist/add/add-mcp-prompt.js +4 -0
  9. package/dist/add/add-mcp-resource.js +4 -0
  10. package/dist/add/add-rpc-invocations.js +2 -2
  11. package/dist/add/add-workflow.d.ts +5 -0
  12. package/dist/add/add-workflow.js +20 -2
  13. package/dist/inspector.js +1 -0
  14. package/dist/types.d.ts +1 -0
  15. package/dist/utils/extract-function-name.d.ts +1 -0
  16. package/dist/utils/extract-function-name.js +27 -32
  17. package/dist/utils/extract-node-value.js +6 -1
  18. package/dist/utils/filter-inspector-state.js +211 -8
  19. package/dist/utils/load-addon-functions-meta.js +47 -0
  20. package/dist/utils/post-process.js +63 -0
  21. package/dist/utils/resolve-versions.js +30 -0
  22. package/dist/utils/schema-generator.js +124 -33
  23. package/dist/utils/serialize-inspector-state.d.ts +1 -0
  24. package/dist/utils/serialize-inspector-state.js +2 -0
  25. package/dist/visit.js +1 -1
  26. package/package.json +2 -2
  27. package/src/add/add-ai-agent.ts +25 -10
  28. package/src/add/add-channel.ts +2 -2
  29. package/src/add/add-cli.ts +17 -16
  30. package/src/add/add-file-with-factory.ts +26 -7
  31. package/src/add/add-functions.ts +4 -4
  32. package/src/add/add-http-route.ts +6 -1
  33. package/src/add/add-mcp-prompt.ts +5 -0
  34. package/src/add/add-mcp-resource.ts +5 -0
  35. package/src/add/add-queue-worker.ts +5 -1
  36. package/src/add/add-rpc-invocations.ts +2 -2
  37. package/src/add/add-workflow.ts +22 -2
  38. package/src/inspector.ts +1 -0
  39. package/src/types.ts +1 -0
  40. package/src/utils/extract-function-name.ts +36 -37
  41. package/src/utils/extract-node-value.test.ts +67 -0
  42. package/src/utils/extract-node-value.ts +5 -1
  43. package/src/utils/filter-inspector-state.ts +246 -11
  44. package/src/utils/load-addon-functions-meta.ts +59 -0
  45. package/src/utils/post-process.ts +74 -0
  46. package/src/utils/resolve-versions.test.ts +141 -0
  47. package/src/utils/resolve-versions.ts +37 -0
  48. package/src/utils/schema-generator.ts +191 -41
  49. package/src/utils/serialize-inspector-state.ts +3 -0
  50. package/src/visit.ts +2 -1
  51. package/tsconfig.tsbuildinfo +1 -1
@@ -1,5 +1,6 @@
1
1
  import * as ts from 'typescript'
2
2
  import { randomUUID } from 'crypto'
3
+ import { formatVersionedId } from '@pikku/core'
3
4
 
4
5
  export type ExtractedFunctionName = {
5
6
  pikkuFuncId: string
@@ -8,6 +9,7 @@ export type ExtractedFunctionName = {
8
9
  exportedName: string | null
9
10
  propertyName: string | null
10
11
  isHelper: boolean
12
+ version: number | null
11
13
  }
12
14
 
13
15
  export function makeContextBasedId(
@@ -40,6 +42,7 @@ export function extractFunctionName(
40
42
  propertyName: null,
41
43
  explicitName: null,
42
44
  isHelper: false,
45
+ version: null,
43
46
  }
44
47
 
45
48
  const workflowHelpers = new Set([
@@ -143,18 +146,7 @@ export function extractFunctionName(
143
146
  // Check for object with 'name' property in first argument
144
147
  const firstArg = args[0]
145
148
  if (firstArg && ts.isObjectLiteralExpression(firstArg)) {
146
- for (const prop of firstArg.properties) {
147
- if (
148
- ts.isPropertyAssignment(prop) &&
149
- ts.isIdentifier(prop.name) &&
150
- prop.name.text === 'override' &&
151
- ts.isStringLiteral(prop.initializer)
152
- ) {
153
- // Priority 1: Object with override property
154
- result.explicitName = prop.initializer.text
155
- break
156
- }
157
- }
149
+ extractOverrideAndVersion(firstArg, result)
158
150
  }
159
151
 
160
152
  // Special handling for pikkuSessionlessFunc pattern - use the arrow function directly
@@ -367,21 +359,9 @@ export function extractFunctionName(
367
359
  ts.isIdentifier(decl.initializer.expression) &&
368
360
  decl.initializer.expression.text.startsWith('pikku')
369
361
  ) {
370
- // Check for object with 'override' property in first argument
371
362
  const firstArg = decl.initializer.arguments[0]
372
363
  if (firstArg && ts.isObjectLiteralExpression(firstArg)) {
373
- for (const prop of firstArg.properties) {
374
- if (
375
- ts.isPropertyAssignment(prop) &&
376
- ts.isIdentifier(prop.name) &&
377
- prop.name.text === 'override' &&
378
- ts.isStringLiteral(prop.initializer)
379
- ) {
380
- // Priority 1: Object with override property
381
- result.explicitName = prop.initializer.text
382
- break
383
- }
384
- }
364
+ extractOverrideAndVersion(firstArg, result)
385
365
  }
386
366
 
387
367
  if (decl.initializer.expression.text.startsWith('pikku')) {
@@ -448,18 +428,7 @@ export function extractFunctionName(
448
428
  else if (ts.isCallExpression(callExpr)) {
449
429
  const firstArg = callExpr.arguments[0]
450
430
  if (firstArg && ts.isObjectLiteralExpression(firstArg)) {
451
- for (const prop of firstArg.properties) {
452
- if (
453
- ts.isPropertyAssignment(prop) &&
454
- ts.isIdentifier(prop.name) &&
455
- prop.name.text === 'override' &&
456
- ts.isStringLiteral(prop.initializer) &&
457
- !result.explicitName // Only set if not already set
458
- ) {
459
- result.explicitName = prop.initializer.text
460
- break
461
- }
462
- }
431
+ extractOverrideAndVersion(firstArg, result)
463
432
  }
464
433
  }
465
434
 
@@ -474,6 +443,10 @@ export function extractFunctionName(
474
443
  result.pikkuFuncId = `__temp_${randomUUID()}`
475
444
  }
476
445
 
446
+ if (result.version !== null) {
447
+ result.pikkuFuncId = formatVersionedId(result.pikkuFuncId, result.version)
448
+ }
449
+
477
450
  return result
478
451
  }
479
452
 
@@ -539,3 +512,29 @@ export function isNamedExport(
539
512
 
540
513
  return false
541
514
  }
515
+
516
+ function extractOverrideAndVersion(
517
+ objLiteral: ts.ObjectLiteralExpression,
518
+ result: ExtractedFunctionName
519
+ ): void {
520
+ for (const prop of objLiteral.properties) {
521
+ if (ts.isPropertyAssignment(prop) && ts.isIdentifier(prop.name)) {
522
+ if (
523
+ prop.name.text === 'override' &&
524
+ ts.isStringLiteral(prop.initializer) &&
525
+ !result.explicitName
526
+ ) {
527
+ result.explicitName = prop.initializer.text
528
+ } else if (
529
+ prop.name.text === 'version' &&
530
+ ts.isNumericLiteral(prop.initializer) &&
531
+ result.version === null
532
+ ) {
533
+ const parsed = Number(prop.initializer.text)
534
+ if (Number.isInteger(parsed) && parsed >= 1) {
535
+ result.version = parsed
536
+ }
537
+ }
538
+ }
539
+ }
540
+ }
@@ -0,0 +1,67 @@
1
+ import { test, describe } from 'node:test'
2
+ import { strict as assert } from 'node:assert'
3
+ import * as ts from 'typescript'
4
+ import { extractDescription } from './extract-node-value'
5
+
6
+ const createChecker = (source: string) => {
7
+ const sourceFile = ts.createSourceFile(
8
+ 'test.ts',
9
+ source,
10
+ ts.ScriptTarget.Latest,
11
+ true,
12
+ ts.ScriptKind.TS
13
+ )
14
+ const host = ts.createCompilerHost({})
15
+ const originalGetSourceFile = host.getSourceFile
16
+ host.getSourceFile = (fileName, target) => {
17
+ if (fileName === 'test.ts') return sourceFile
18
+ return originalGetSourceFile.call(host, fileName, target)
19
+ }
20
+ const program = ts.createProgram(['test.ts'], {}, host)
21
+ return { checker: program.getTypeChecker(), sourceFile }
22
+ }
23
+
24
+ const findObjectLiteral = (
25
+ node: ts.Node
26
+ ): ts.ObjectLiteralExpression | undefined => {
27
+ if (ts.isObjectLiteralExpression(node)) return node
28
+ let result: ts.ObjectLiteralExpression | undefined
29
+ ts.forEachChild(node, (child) => {
30
+ if (!result) result = findObjectLiteral(child)
31
+ })
32
+ return result
33
+ }
34
+
35
+ describe('extractDescription', () => {
36
+ test('returns null when node is undefined', () => {
37
+ const { checker } = createChecker('')
38
+ assert.equal(extractDescription(undefined, checker), null)
39
+ })
40
+
41
+ test('extracts string literal description', () => {
42
+ const { checker, sourceFile } = createChecker(
43
+ `const opts = { description: 'my step' }`
44
+ )
45
+ const obj = findObjectLiteral(sourceFile)!
46
+ assert.equal(extractDescription(obj, checker), 'my step')
47
+ })
48
+
49
+ test('returns null for non-literal description value without crashing', () => {
50
+ const { checker, sourceFile } = createChecker(
51
+ `const name = 'test'; const data = { description: name + ' addon' }`
52
+ )
53
+ const objs: ts.ObjectLiteralExpression[] = []
54
+ const visit = (node: ts.Node) => {
55
+ if (ts.isObjectLiteralExpression(node)) objs.push(node)
56
+ ts.forEachChild(node, visit)
57
+ }
58
+ ts.forEachChild(sourceFile, visit)
59
+ const dataObj = objs[objs.length - 1]!
60
+ assert.equal(extractDescription(dataObj, checker), null)
61
+ })
62
+
63
+ test('returns null for non-object node', () => {
64
+ const { checker, sourceFile } = createChecker(`const x = 42`)
65
+ assert.equal(extractDescription(sourceFile, checker), null)
66
+ })
67
+ })
@@ -110,7 +110,11 @@ export function extractDescription(
110
110
  if (!optionsNode || !ts.isObjectLiteralExpression(optionsNode)) {
111
111
  return null
112
112
  }
113
- return extractPropertyString(optionsNode, 'description', checker)
113
+ try {
114
+ return extractPropertyString(optionsNode, 'description', checker)
115
+ } catch {
116
+ return null
117
+ }
114
118
  }
115
119
 
116
120
  /**
@@ -191,6 +191,11 @@ export function filterInspectorState(
191
191
  return state
192
192
  }
193
193
 
194
+ // Snapshot the original workflow graph meta before filtering prunes it
195
+ const originalGraphMeta = {
196
+ ...((state as InspectorState).workflows?.graphMeta ?? {}),
197
+ }
198
+
194
199
  // Create a shallow copy with new Maps/Sets to avoid mutating the original
195
200
  const filteredState = {
196
201
  ...state,
@@ -201,11 +206,21 @@ export function filterInspectorState(
201
206
  usedMiddleware: new Set<string>(),
202
207
  usedPermissions: new Set<string>(),
203
208
  },
209
+ functions: {
210
+ ...state.functions,
211
+ meta: JSON.parse(JSON.stringify(state.functions.meta)), // Deep clone to avoid mutating original
212
+ files: new Map(state.functions.files),
213
+ },
204
214
  http: {
205
215
  ...state.http,
206
216
  meta: JSON.parse(JSON.stringify(state.http.meta)), // Deep clone metadata
207
217
  files: new Set<string>(), // Will be repopulated with filtered files
208
218
  },
219
+ workflows: {
220
+ ...state.workflows,
221
+ graphMeta: JSON.parse(JSON.stringify(state.workflows?.graphMeta ?? {})),
222
+ meta: JSON.parse(JSON.stringify(state.workflows?.meta ?? {})),
223
+ },
209
224
  channels: {
210
225
  ...state.channels,
211
226
  meta: JSON.parse(JSON.stringify(state.channels.meta)),
@@ -240,6 +255,14 @@ export function filterInspectorState(
240
255
  agentsMeta: JSON.parse(JSON.stringify(state.agents?.agentsMeta ?? {})),
241
256
  files: new Map(),
242
257
  },
258
+ rpc: {
259
+ ...state.rpc,
260
+ internalMeta: { ...state.rpc.internalMeta }, // Clone to avoid mutating original
261
+ internalFiles: new Map(state.rpc.internalFiles),
262
+ exposedMeta: { ...state.rpc.exposedMeta },
263
+ exposedFiles: new Map(state.rpc.exposedFiles),
264
+ invokedFunctions: new Set(state.rpc.invokedFunctions),
265
+ },
243
266
  cli: {
244
267
  ...state.cli,
245
268
  meta: JSON.parse(JSON.stringify(state.cli.meta)),
@@ -280,6 +303,14 @@ export function filterInspectorState(
280
303
  filteredState.serviceAggregation.usedFunctions.add(
281
304
  routeMeta.pikkuFuncId
282
305
  )
306
+ // For workflow/agent routes, also add the base name
307
+ // so the workflow/agent definition survives pruning
308
+ const colonIdx = routeMeta.pikkuFuncId.indexOf(':')
309
+ if (colonIdx !== -1) {
310
+ filteredState.serviceAggregation.usedFunctions.add(
311
+ routeMeta.pikkuFuncId.slice(colonIdx + 1)
312
+ )
313
+ }
283
314
  }
284
315
  extractWireNames(routeMeta.middleware).forEach((name: string) =>
285
316
  filteredState.serviceAggregation.usedMiddleware.add(name)
@@ -291,12 +322,28 @@ export function filterInspectorState(
291
322
  }
292
323
  }
293
324
 
294
- // Repopulate http.files if any routes remain
295
- const hasHttpRoutes = Object.values(
296
- filteredState.http.meta as Record<string, any>
297
- ).some((routes) => Object.keys(routes).length > 0)
298
- if (hasHttpRoutes) {
299
- filteredState.http.files = new Set(state.http.files)
325
+ // Repopulate http.files with only files that have surviving routes
326
+ for (const method of Object.keys(filteredState.http.meta)) {
327
+ const routes = (
328
+ filteredState.http.meta as Record<
329
+ string,
330
+ Record<string, { sourceFile?: string }>
331
+ >
332
+ )[method]
333
+ for (const routeMeta of Object.values(routes)) {
334
+ if (routeMeta.sourceFile) {
335
+ filteredState.http.files.add(routeMeta.sourceFile)
336
+ }
337
+ }
338
+ }
339
+ // Fallback: if no sourceFile info available but routes exist, include all files
340
+ if (filteredState.http.files.size === 0) {
341
+ const hasHttpRoutes = Object.values(
342
+ filteredState.http.meta as Record<string, Record<string, unknown>>
343
+ ).some((routes) => Object.keys(routes).length > 0)
344
+ if (hasHttpRoutes) {
345
+ filteredState.http.files = new Set(state.http.files)
346
+ }
300
347
  }
301
348
 
302
349
  // Filter channels
@@ -315,11 +362,40 @@ export function filterInspectorState(
315
362
  if (!matches) {
316
363
  delete filteredState.channels.meta[name]
317
364
  } else {
318
- if (channelMeta.pikkuFuncId) {
365
+ // Add all functions referenced by this channel
366
+ if ('pikkuFuncId' in channelMeta && channelMeta.pikkuFuncId) {
367
+ filteredState.serviceAggregation.usedFunctions.add(
368
+ channelMeta.pikkuFuncId as string
369
+ )
370
+ }
371
+ if (channelMeta.connect?.pikkuFuncId) {
372
+ filteredState.serviceAggregation.usedFunctions.add(
373
+ channelMeta.connect.pikkuFuncId
374
+ )
375
+ }
376
+ if (channelMeta.disconnect?.pikkuFuncId) {
319
377
  filteredState.serviceAggregation.usedFunctions.add(
320
- channelMeta.pikkuFuncId
378
+ channelMeta.disconnect.pikkuFuncId
321
379
  )
322
380
  }
381
+ if (channelMeta.message?.pikkuFuncId) {
382
+ filteredState.serviceAggregation.usedFunctions.add(
383
+ channelMeta.message.pikkuFuncId
384
+ )
385
+ }
386
+ if (channelMeta.messageWirings) {
387
+ for (const groupKey of Object.keys(channelMeta.messageWirings)) {
388
+ const commands = channelMeta.messageWirings[groupKey]
389
+ for (const cmdKey of Object.keys(commands)) {
390
+ const wiring = commands[cmdKey]
391
+ if (wiring.pikkuFuncId) {
392
+ filteredState.serviceAggregation.usedFunctions.add(
393
+ wiring.pikkuFuncId
394
+ )
395
+ }
396
+ }
397
+ }
398
+ }
323
399
  extractWireNames(channelMeta.middleware).forEach((name: string) =>
324
400
  filteredState.serviceAggregation.usedMiddleware.add(name)
325
401
  )
@@ -413,6 +489,12 @@ export function filterInspectorState(
413
489
  filteredState.serviceAggregation.usedFunctions.add(
414
490
  workerMeta.pikkuFuncId
415
491
  )
492
+ const colonIdx = workerMeta.pikkuFuncId.indexOf(':')
493
+ if (colonIdx !== -1) {
494
+ filteredState.serviceAggregation.usedFunctions.add(
495
+ workerMeta.pikkuFuncId.slice(colonIdx + 1)
496
+ )
497
+ }
416
498
  }
417
499
  extractWireNames(workerMeta.middleware).forEach((name: string) =>
418
500
  filteredState.serviceAggregation.usedMiddleware.add(name)
@@ -616,11 +698,47 @@ export function filterInspectorState(
616
698
  filteredState.cli.files = new Set(state.cli.files)
617
699
  }
618
700
 
619
- // Post-filter version expansion: include all versions of matched functions
701
+ // Direct function filtering: functions that match the names/tags/directories
702
+ // filters should be included even if no wiring (HTTP, scheduler, etc.) references them.
703
+ // This ensures standalone RPC-callable functions survive filtering.
704
+ // Only run when function-level filters are active — httpRoutes/httpMethods work
705
+ // through the HTTP wiring pass which already adds the right functions.
706
+ const hasFunctionLevelFilters =
707
+ (filters.names && filters.names.length > 0) ||
708
+ (filters.tags && filters.tags.length > 0) ||
709
+ (filters.directories && filters.directories.length > 0)
710
+
711
+ for (const funcId of Object.keys(filteredState.functions.meta)) {
712
+ if (!hasFunctionLevelFilters) break
713
+ const funcMeta = filteredState.functions.meta[funcId]
714
+ const funcFile = filteredState.functions.files.get(funcId)
715
+ const filePath = funcFile?.path
716
+
717
+ const matches = matchesFilters(
718
+ filters,
719
+ {
720
+ type: 'rpc' as PikkuWiringTypes,
721
+ name: funcId,
722
+ tags: funcMeta.tags,
723
+ filePath,
724
+ },
725
+ logger
726
+ )
727
+
728
+ if (matches) {
729
+ filteredState.serviceAggregation.usedFunctions.add(funcId)
730
+ }
731
+ }
732
+
733
+ // Post-filter version expansion: when an unversioned base name is matched,
734
+ // include all its versions. Specific version matches (e.g. analyzeData@v1)
735
+ // do NOT expand to include other versions.
620
736
  const includedBaseNames = new Set<string>()
621
737
  for (const funcId of filteredState.serviceAggregation.usedFunctions) {
622
- const { baseName } = parseVersionedId(funcId)
623
- includedBaseNames.add(baseName)
738
+ const { baseName, version } = parseVersionedId(funcId)
739
+ if (version === null) {
740
+ includedBaseNames.add(baseName)
741
+ }
624
742
  }
625
743
  if (includedBaseNames.size > 0) {
626
744
  for (const funcId of Object.keys(state.functions.meta)) {
@@ -631,6 +749,123 @@ export function filterInspectorState(
631
749
  }
632
750
  }
633
751
 
752
+ // Prune functions.meta and functions.files to only include used functions
753
+ if (filteredState.serviceAggregation.usedFunctions.size > 0) {
754
+ for (const funcId of Object.keys(filteredState.functions.meta)) {
755
+ if (!filteredState.serviceAggregation.usedFunctions.has(funcId)) {
756
+ delete filteredState.functions.meta[funcId]
757
+ filteredState.functions.files.delete(funcId)
758
+ }
759
+ }
760
+
761
+ // Prune channels whose functions were filtered out
762
+ for (const name of Object.keys(filteredState.channels.meta)) {
763
+ const channelMeta = filteredState.channels.meta[name]
764
+ // Check if any of the channel's functions are in the used set
765
+ const channelFuncIds: string[] = []
766
+ if (channelMeta.connect?.pikkuFuncId)
767
+ channelFuncIds.push(channelMeta.connect.pikkuFuncId)
768
+ if (channelMeta.disconnect?.pikkuFuncId)
769
+ channelFuncIds.push(channelMeta.disconnect.pikkuFuncId)
770
+ if (channelMeta.message?.pikkuFuncId)
771
+ channelFuncIds.push(channelMeta.message.pikkuFuncId)
772
+ if (channelMeta.messageWirings) {
773
+ for (const groupKey of Object.keys(channelMeta.messageWirings)) {
774
+ const commands = channelMeta.messageWirings[groupKey]
775
+ for (const cmdKey of Object.keys(commands)) {
776
+ const wiring = commands[cmdKey]
777
+ if (wiring.pikkuFuncId) channelFuncIds.push(wiring.pikkuFuncId)
778
+ }
779
+ }
780
+ }
781
+ const hasUsedFunc = channelFuncIds.some((id) =>
782
+ filteredState.serviceAggregation.usedFunctions.has(id)
783
+ )
784
+ if (channelFuncIds.length > 0 && !hasUsedFunc) {
785
+ delete filteredState.channels.meta[name]
786
+ }
787
+ }
788
+
789
+ // Prune workflow graphs whose function was filtered out
790
+ const workflowKeys = new Set([
791
+ ...Object.keys(filteredState.workflows.graphMeta),
792
+ ...Object.keys(filteredState.workflows.meta),
793
+ ])
794
+ for (const name of workflowKeys) {
795
+ const graphMeta = filteredState.workflows.graphMeta[name]
796
+ const workflowMeta = filteredState.workflows.meta[name]
797
+ // Check both graphMeta.pikkuFuncId and meta.pikkuFuncId
798
+ const pikkuFuncId = graphMeta?.pikkuFuncId ?? workflowMeta?.pikkuFuncId
799
+ if (
800
+ pikkuFuncId &&
801
+ !filteredState.serviceAggregation.usedFunctions.has(pikkuFuncId)
802
+ ) {
803
+ delete filteredState.workflows.graphMeta[name]
804
+ delete filteredState.workflows.meta[name]
805
+ } else if (!pikkuFuncId) {
806
+ // No function ID found — prune it
807
+ delete filteredState.workflows.graphMeta[name]
808
+ delete filteredState.workflows.meta[name]
809
+ }
810
+ }
811
+
812
+ // Prune RPC meta to only include entries whose target function survived
813
+ const survivingFuncIds = new Set(Object.keys(filteredState.functions.meta))
814
+ for (const key of Object.keys(filteredState.rpc.internalMeta)) {
815
+ const targetFuncId = filteredState.rpc.internalMeta[key]
816
+ if (!survivingFuncIds.has(targetFuncId) && !survivingFuncIds.has(key)) {
817
+ delete filteredState.rpc.internalMeta[key]
818
+ filteredState.rpc.internalFiles.delete(key)
819
+ }
820
+ }
821
+ for (const key of Object.keys(filteredState.rpc.exposedMeta)) {
822
+ const targetFuncId = filteredState.rpc.exposedMeta[key]
823
+ if (!survivingFuncIds.has(targetFuncId) && !survivingFuncIds.has(key)) {
824
+ delete filteredState.rpc.exposedMeta[key]
825
+ filteredState.rpc.exposedFiles.delete(key)
826
+ }
827
+ }
828
+ // Prune invokedFunctions to match surviving functions
829
+ for (const funcId of filteredState.rpc.invokedFunctions) {
830
+ if (!survivingFuncIds.has(funcId)) {
831
+ filteredState.rpc.invokedFunctions.delete(funcId)
832
+ }
833
+ }
834
+ }
835
+
836
+ // Recompute requiredSchemas based on pruned functions.meta
837
+ if (filteredState.serviceAggregation.usedFunctions.size > 0) {
838
+ const prunedSchemas = new Set<string>()
839
+ for (const funcMeta of Object.values(
840
+ filteredState.functions.meta
841
+ ) as Array<{ inputs?: string[]; outputs?: string[] }>) {
842
+ if (funcMeta.inputs?.[0]) prunedSchemas.add(funcMeta.inputs[0])
843
+ if (funcMeta.outputs?.[0]) prunedSchemas.add(funcMeta.outputs[0])
844
+ }
845
+ filteredState.requiredSchemas = prunedSchemas
846
+ }
847
+
848
+ // If any surviving function is a non-inline workflow step, the unit needs
849
+ // workflowService + queueService even though the function doesn't use them.
850
+ // Check the ORIGINAL graph meta (before filtering pruned it).
851
+ const survivingFuncIds = new Set(Object.keys(filteredState.functions.meta))
852
+ // Use the snapshot taken before filtering
853
+ for (const graph of Object.values(originalGraphMeta)) {
854
+ if (!graph.nodes) continue
855
+ for (const node of Object.values(graph.nodes)) {
856
+ if (!('rpcName' in node) || !node.rpcName) continue
857
+ const rpcName = node.rpcName as string
858
+ if (!survivingFuncIds.has(rpcName)) continue
859
+ const isInline =
860
+ (node as { options?: { async?: boolean } }).options?.async !== true &&
861
+ graph.inline === true
862
+ if (!isInline) {
863
+ filteredState.serviceAggregation.requiredServices.add('workflowService')
864
+ filteredState.serviceAggregation.requiredServices.add('queueService')
865
+ }
866
+ }
867
+ }
868
+
634
869
  // Recalculate requiredServices based on filtered functions/middleware/permissions
635
870
  // Need to cast to InspectorState temporarily for aggregateRequiredServices
636
871
  const stateForAggregation = filteredState as InspectorState
@@ -45,6 +45,65 @@ export async function loadAddonFunctionsMeta(
45
45
  }
46
46
  }
47
47
  }
48
+ // Load addon secrets meta
49
+ try {
50
+ const secretsMetaPath = require.resolve(
51
+ `${decl.package}/.pikku/secrets/pikku-secrets-meta.gen.json`
52
+ )
53
+ const secretsRaw = await readFile(secretsMetaPath, 'utf-8')
54
+ const secretsMeta = JSON.parse(secretsRaw)
55
+ for (const [key, def] of Object.entries<any>(secretsMeta)) {
56
+ const existing = state.secrets.definitions.find(
57
+ (d: any) => d.name === key
58
+ )
59
+ if (!existing) {
60
+ state.secrets.definitions.push(def)
61
+ logger.debug(`Loaded addon secret '${key}' from ${decl.package}`)
62
+ }
63
+ }
64
+ } catch {
65
+ // No secrets meta — that's fine
66
+ }
67
+
68
+ // Load addon variables meta
69
+ try {
70
+ const variablesMetaPath = require.resolve(
71
+ `${decl.package}/.pikku/variables/pikku-variables-meta.gen.json`
72
+ )
73
+ const variablesRaw = await readFile(variablesMetaPath, 'utf-8')
74
+ const variablesMeta = JSON.parse(variablesRaw)
75
+ for (const [key, def] of Object.entries<any>(variablesMeta)) {
76
+ const existing = state.variables.definitions.find(
77
+ (d: any) => d.name === key
78
+ )
79
+ if (!existing) {
80
+ state.variables.definitions.push(def)
81
+ logger.debug(`Loaded addon variable '${key}' from ${decl.package}`)
82
+ }
83
+ }
84
+ } catch {
85
+ // No variables meta — that's fine
86
+ }
87
+ // Load addon required parent services from pikku-services.gen
88
+ try {
89
+ const servicesGenPath = require.resolve(
90
+ `${decl.package}/.pikku/pikku-services.gen.js`
91
+ )
92
+ const servicesModule = await import(servicesGenPath)
93
+ if (
94
+ servicesModule.requiredParentServices &&
95
+ Array.isArray(servicesModule.requiredParentServices)
96
+ ) {
97
+ for (const service of servicesModule.requiredParentServices) {
98
+ state.addonRequiredParentServices.push(service)
99
+ }
100
+ logger.debug(
101
+ `Loaded ${servicesModule.requiredParentServices.length} required parent services for '${namespace}' from ${decl.package}`
102
+ )
103
+ }
104
+ } catch {
105
+ // No services gen — addon may not have requiredParentServices
106
+ }
48
107
  } catch (error: any) {
49
108
  logger.warn(
50
109
  `Failed to load addon function metadata for '${namespace}' (${decl.package}): ${error.message}`
@@ -221,6 +221,80 @@ export function aggregateRequiredServices(
221
221
  }
222
222
  })
223
223
  }
224
+
225
+ // 6. Implicit platform services required by wiring types
226
+ // Workflows need workflowService + workflowRunService + schedulerService + queueService.
227
+ // Check workflow definitions, graph meta, AND helper functions (workflowStart:*, etc.)
228
+ // that wrap workflow operations but don't destructure the services.
229
+ const hasWorkflows =
230
+ Object.keys(state.workflows.graphMeta).length > 0 ||
231
+ Object.keys(state.workflows.meta).length > 0 ||
232
+ Object.keys(state.functions.meta).some(
233
+ (id) =>
234
+ id.startsWith('workflowStart:') ||
235
+ id.startsWith('workflowStatus:') ||
236
+ id.startsWith('workflow:')
237
+ )
238
+ if (hasWorkflows) {
239
+ requiredServices.add('workflowService')
240
+ requiredServices.add('workflowRunService')
241
+ requiredServices.add('schedulerService')
242
+ requiredServices.add('queueService')
243
+ }
244
+
245
+ // 6b. Inject synthetic queue workers for workflow graph steps.
246
+ // Each workflow gets an orchestrator queue and per-step queues.
247
+ // Without these, the PikkuWorkflowService constructor can't find
248
+ // per-workflow queue entries and falls back to shared queue names.
249
+ for (const [, graph] of Object.entries(state.workflows.graphMeta)) {
250
+ if (!graph.nodes || !graph.name) continue
251
+
252
+ const toKebab = (s: string) =>
253
+ s
254
+ .replace(/([a-z0-9])([A-Z])/g, '$1-$2')
255
+ .replace(/([A-Z])([A-Z][a-z])/g, '$1-$2')
256
+ .toLowerCase()
257
+
258
+ // Orchestrator queue
259
+ const orchQueueName = `wf-orchestrator-${toKebab(graph.name)}`
260
+ if (!state.queueWorkers.meta[orchQueueName]) {
261
+ state.queueWorkers.meta[orchQueueName] = {
262
+ name: orchQueueName,
263
+ pikkuFuncId: `pikkuWorkflowOrchestrator:${graph.name}`,
264
+ }
265
+ }
266
+
267
+ // Per-step queues
268
+ for (const node of Object.values(graph.nodes)) {
269
+ if (!('rpcName' in node) || !node.rpcName) continue
270
+ const rpcName = node.rpcName as string
271
+ const stepQueueName = `wf-step-${toKebab(rpcName)}`
272
+ if (!state.queueWorkers.meta[stepQueueName]) {
273
+ state.queueWorkers.meta[stepQueueName] = {
274
+ name: stepQueueName,
275
+ pikkuFuncId: `pikkuWorkflowWorker:${rpcName}`,
276
+ }
277
+ }
278
+ }
279
+ }
280
+
281
+ // AI agents need aiStorage + aiRunState + agentRunService + aiAgentRunner
282
+ if (Object.keys(state.agents.agentsMeta).length > 0) {
283
+ requiredServices.add('aiStorage')
284
+ requiredServices.add('aiRunState')
285
+ requiredServices.add('agentRunService')
286
+ requiredServices.add('aiAgentRunner')
287
+ }
288
+
289
+ // Channels need eventHub for pub/sub
290
+ if (Object.keys(state.channels.meta).length > 0) {
291
+ requiredServices.add('eventHub')
292
+ }
293
+
294
+ // 7. Services that addons need from the parent project
295
+ for (const service of state.addonRequiredParentServices ?? []) {
296
+ requiredServices.add(service)
297
+ }
224
298
  }
225
299
 
226
300
  export function validateSecretOverrides(