@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,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
  }
@@ -155,6 +155,7 @@ export interface SerializableInspectorState {
155
155
  exposedMeta: InspectorState['rpc']['exposedMeta']
156
156
  exposedFiles: Array<[string, { path: string; exportedName: string }]>
157
157
  invokedFunctions: string[]
158
+ invokedFunctionsByFile?: Array<[string, string[]]>
158
159
  usedAddons: string[]
159
160
  wireAddonDeclarations: Array<
160
161
  [
@@ -379,6 +380,11 @@ export function serializeInspectorState(
379
380
  exposedMeta: state.rpc.exposedMeta,
380
381
  exposedFiles: Array.from(state.rpc.exposedFiles.entries()),
381
382
  invokedFunctions: Array.from(state.rpc.invokedFunctions),
383
+ invokedFunctionsByFile: Array.from(
384
+ (
385
+ state.rpc.invokedFunctionsByFile ?? new Map<string, Set<string>>()
386
+ ).entries()
387
+ ).map(([file, fns]): [string, string[]] => [file, Array.from(fns)]),
382
388
  usedAddons: Array.from(state.rpc.usedAddons),
383
389
  wireAddonDeclarations: Array.from(
384
390
  state.rpc.wireAddonDeclarations.entries()
@@ -565,6 +571,11 @@ export function deserializeInspectorState(
565
571
  exposedMeta: data.rpc.exposedMeta,
566
572
  exposedFiles: new Map(data.rpc.exposedFiles),
567
573
  invokedFunctions: new Set(data.rpc.invokedFunctions),
574
+ invokedFunctionsByFile: new Map(
575
+ (data.rpc.invokedFunctionsByFile || []).map(
576
+ ([file, fns]): [string, Set<string>] => [file, new Set(fns)]
577
+ )
578
+ ),
568
579
  usedAddons: new Set(data.rpc.usedAddons || []),
569
580
  wireAddonDeclarations: new Map(data.rpc.wireAddonDeclarations || []),
570
581
  wireAddonFiles: new Set(data.rpc.wireAddonFiles || []),
@@ -21,6 +21,8 @@ import type {
21
21
  } from '@pikku/core/workflow'
22
22
  import {
23
23
  isWorkflowDoCall,
24
+ isWorkflowExpectEventuallyCall,
25
+ extractActorFromOptions,
24
26
  isWorkflowSleepCall,
25
27
  isWorkflowSuspendCall,
26
28
  isThrowCancelException,
@@ -553,10 +555,15 @@ function extractRpcStep(
553
555
  const inputs =
554
556
  args.length >= 3 ? extractInputSources(args[2], context) : undefined
555
557
 
556
- // Extract options from fourth argument
558
+ // do(step, rpc, data, options?) vs expectEventually(step, rpc, data, predicate, options?)
559
+ const expectEventually = isWorkflowExpectEventuallyCall(call)
560
+ const optionsIndex = expectEventually ? 4 : 3
561
+ const optionsArg =
562
+ args.length > optionsIndex ? args[optionsIndex] : undefined
563
+
557
564
  const options =
558
- args.length >= 4 && ts.isObjectLiteralExpression(args[3])
559
- ? extractStepOptions(args[3], context)
565
+ optionsArg && ts.isObjectLiteralExpression(optionsArg)
566
+ ? extractStepOptions(optionsArg, context)
560
567
  : undefined
561
568
 
562
569
  return {
@@ -566,6 +573,8 @@ function extractRpcStep(
566
573
  outputVar,
567
574
  inputs,
568
575
  options,
576
+ actor: extractActorFromOptions(optionsArg),
577
+ expectEventually: expectEventually || undefined,
569
578
  }
570
579
  } catch (error) {
571
580
  context.errors.push({
@@ -5,7 +5,8 @@ import * as ts from 'typescript'
5
5
  */
6
6
 
7
7
  /**
8
- * Check if a call expression is workflow.do()
8
+ * Check if a call expression is workflow.do() or workflow.expectEventually()
9
+ * (both are RPC steps; expectEventually is the polling variant used by user flows)
9
10
  */
10
11
  export function isWorkflowDoCall(
11
12
  node: ts.CallExpression,
@@ -17,12 +18,57 @@ export function isWorkflowDoCall(
17
18
 
18
19
  const propAccess = node.expression
19
20
  return (
20
- propAccess.name.text === 'do' &&
21
+ (propAccess.name.text === 'do' ||
22
+ propAccess.name.text === 'expectEventually') &&
21
23
  ts.isIdentifier(propAccess.expression) &&
22
24
  propAccess.expression.text === 'workflow'
23
25
  )
24
26
  }
25
27
 
28
+ /**
29
+ * Check if a call expression is workflow.expectEventually()
30
+ */
31
+ export function isWorkflowExpectEventuallyCall(
32
+ node: ts.CallExpression
33
+ ): boolean {
34
+ return (
35
+ ts.isPropertyAccessExpression(node.expression) &&
36
+ node.expression.name.text === 'expectEventually' &&
37
+ ts.isIdentifier(node.expression.expression) &&
38
+ node.expression.expression.text === 'workflow'
39
+ )
40
+ }
41
+
42
+ /**
43
+ * Extract the actor NAME from a step options object literal:
44
+ * `{ actor: actors.x }` → 'x' (also `actors['x']`). Undefined when absent
45
+ * or not statically resolvable.
46
+ */
47
+ export function extractActorFromOptions(
48
+ optionsArg: ts.Expression | undefined
49
+ ): string | undefined {
50
+ if (!optionsArg || !ts.isObjectLiteralExpression(optionsArg)) return
51
+ for (const prop of optionsArg.properties) {
52
+ if (
53
+ ts.isPropertyAssignment(prop) &&
54
+ ts.isIdentifier(prop.name) &&
55
+ prop.name.text === 'actor'
56
+ ) {
57
+ const init = prop.initializer
58
+ if (ts.isPropertyAccessExpression(init)) {
59
+ return init.name.text
60
+ }
61
+ if (
62
+ ts.isElementAccessExpression(init) &&
63
+ ts.isStringLiteral(init.argumentExpression)
64
+ ) {
65
+ return init.argumentExpression.text
66
+ }
67
+ }
68
+ }
69
+ return
70
+ }
71
+
26
72
  /**
27
73
  * Check if a call expression is workflow.sleep()
28
74
  */
@@ -93,6 +93,12 @@ function convertStepToNode(
93
93
  rpcName: step.rpcName,
94
94
  next: nextNodeId,
95
95
  }
96
+ if (step.actor) {
97
+ node.actor = step.actor
98
+ }
99
+ if (step.expectEventually) {
100
+ node.expectEventually = true
101
+ }
96
102
  if (step.inputs) {
97
103
  if (step.inputs === 'passthrough') {
98
104
  // Entire data is passed through - store as reference to trigger
@@ -403,8 +409,10 @@ export function convertDslToGraph(
403
409
  name: workflowName,
404
410
  pikkuFuncId: meta.pikkuFuncId,
405
411
  source,
412
+ title: meta.title,
406
413
  description: meta.description,
407
414
  tags: meta.tags,
415
+ actors: meta.actors,
408
416
  context: meta.context,
409
417
  nodes: nodesRecord,
410
418
  entryNodeIds,
@@ -142,6 +142,10 @@ export interface FunctionNode extends BaseNode {
142
142
  outputVar?: string
143
143
  /** Hash of nodeId + RPC input/output schemas for version detection */
144
144
  stepHash?: string
145
+ /** User-flow actor name this step runs as (workflow.do {actor: actors.x}) */
146
+ actor?: string
147
+ /** True for workflow.expectEventually polling steps (user flows) */
148
+ expectEventually?: boolean
145
149
  }
146
150
 
147
151
  /**
@@ -191,10 +195,14 @@ export interface SerializedWorkflowGraph {
191
195
  pikkuFuncId: string
192
196
  /** Source type: 'dsl' for pikkuWorkflowFunc, 'graph' for pikkuWorkflowGraph */
193
197
  source: WorkflowSourceType
198
+ /** Optional short display name */
199
+ title?: string
194
200
  /** Optional description */
195
201
  description?: string
196
202
  /** Tags for organization */
197
203
  tags?: string[]
204
+ /** Actor names a user flow's steps run as */
205
+ actors?: string[]
198
206
  /** If true, workflow always executes inline without queues */
199
207
  inline?: boolean
200
208
  /** Workflow context/state variables (from Zod schema) */