@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
package/CHANGELOG.md CHANGED
@@ -1,3 +1,34 @@
1
+ ## 0.12.35
2
+
3
+ ### Patch Changes
4
+
5
+ - 7ebea62: Tree-shake addon registrations in filtered inspector states (per-unit deploy codegen).
6
+ - `filterInspectorState` drops an addon's `wireAddonDeclarations`/`usedAddons` unless something kept actually references it (kept wiring targeting `namespace:*`, kept agent/MCP tool, or a body-level `rpc.invoke('namespace:*')` from a file that still contains a kept function). The generated per-unit bootstrap no longer imports unused addon package bootstraps — previously every deploy unit registered every addon's entire function surface, which pulled dev-only code (e.g. `@pikku/addon-console`'s static `node:fs` imports) into Cloudflare Worker bundles and failed upload with `No such module "node:fs"`.
7
+ - Body-level `rpc.invoke()` targets are now tracked per source file (`rpc.invokedFunctionsByFile`) so wiring-level `ref()` targets no longer pin an addon into every unit.
8
+ - `aggregateRequiredServices` computes addon parent services per used addon function (from the addon's shipped per-function `services` meta) instead of blanket-adding `addonRequiredParentServices` — and matches namespaced ids only, so bare project function names colliding with addon function names no longer force the blanket.
9
+ - Addon builds keep per-function `services` in the shipped `pikku-functions-meta.gen.json` so parent projects can do the above; addons built before this fall back to the blanket.
10
+ - HTTP route meta records `refTarget` for `ref('namespace:fn')`-wired routes, so per-unit filtering keeps the addon registration (and only that function's services) when the route deploys.
11
+
12
+ - Updated dependencies [7ebea62]
13
+ - Updated dependencies [e57dd65]
14
+ - @pikku/core@0.12.51
15
+
16
+ ## 0.12.34
17
+
18
+ ### Patch Changes
19
+
20
+ - 92bd643: User flows in the console: workflow graph extraction now captures
21
+ `workflow.expectEventually` steps and per-step actor names (`{ actor:
22
+ actors.x }`), workflow meta carries `actors`/`title` into the serialized
23
+ graph, the CLI emits `user-flow-actors.gen.json` for the new
24
+ `MetaService.getUserFlowActorsMeta()`, and the console Workflows page gains a
25
+ Workflows / User Flows / Personas toggle. Also fixes complex-workflow graphs
26
+ being clobbered by a duplicate basic-extraction pass after successful DSL
27
+ extraction.
28
+ - Updated dependencies [35a9bab]
29
+ - Updated dependencies [92bd643]
30
+ - @pikku/core@0.12.50
31
+
1
32
  ## 0.12.33
2
33
 
3
34
  ### Patch Changes
@@ -220,8 +220,10 @@ export function registerHTTPRoute({ obj, state, checker, logger, sourceFile, bas
220
220
  const middleware = resolveHTTPMiddlewareFromObject(state, fullRoute, obj, tags, checker);
221
221
  // Resolve permissions
222
222
  const permissions = resolveHTTPPermissionsFromObject(state, fullRoute, obj, tags, checker);
223
- // Track used functions/middleware/permissions for service aggregation
224
223
  state.serviceAggregation.usedFunctions.add(funcName);
224
+ if (refAddonTarget) {
225
+ state.serviceAggregation.usedFunctions.add(refAddonTarget);
226
+ }
225
227
  extractWireNames(middleware).forEach((name) => state.serviceAggregation.usedMiddleware.add(name));
226
228
  extractWireNames(permissions).forEach((name) => state.serviceAggregation.usedPermissions.add(name));
227
229
  // Check for SSE
@@ -232,6 +234,7 @@ export function registerHTTPRoute({ obj, state, checker, logger, sourceFile, bas
232
234
  state.http.files.add(sourceFile.fileName);
233
235
  state.http.meta[method][fullRoute] = {
234
236
  pikkuFuncId: funcName,
237
+ ...(refAddonTarget && { refTarget: refAddonTarget }),
235
238
  ...(packageName && { packageName }),
236
239
  route: fullRoute,
237
240
  sourceFile: sourceFile.fileName,
@@ -85,10 +85,17 @@ export function addRPCInvocations(node, state, logger) {
85
85
  const functionRef = firstArg.text;
86
86
  logger.debug(`• Found RPC invocation: ${functionRef}`);
87
87
  state.rpc.invokedFunctions.add(functionRef);
88
+ let byFile = state.rpc.invokedFunctionsByFile.get(sourceFileName);
89
+ if (!byFile) {
90
+ byFile = new Set();
91
+ state.rpc.invokedFunctionsByFile.set(sourceFileName, byFile);
92
+ }
93
+ byFile.add(functionRef);
88
94
  const namespace = extractNamespace(functionRef);
89
95
  if (namespace) {
90
96
  logger.debug(` → Addon detected: ${namespace}`);
91
97
  state.rpc.usedAddons.add(namespace);
98
+ state.serviceAggregation.usedFunctions.add(functionRef);
92
99
  }
93
100
  }
94
101
  // Handle template literals like `function-${name}`
@@ -5,7 +5,7 @@ import { ErrorCode } from '../error-codes.js';
5
5
  import { extractStringLiteral, isStringLike, isFunctionLike, extractDescription, extractDuration, } from '../utils/extract-node-value.js';
6
6
  import { getCommonWireMetaData, getPropertyValue, } from '../utils/get-property-value.js';
7
7
  import { extractDSLWorkflow } from '../utils/workflow/dsl/extract-dsl-workflow.js';
8
- import { getSourceText } from '../utils/workflow/dsl/patterns.js';
8
+ import { getSourceText, extractActorFromOptions, } from '../utils/workflow/dsl/patterns.js';
9
9
  /**
10
10
  * Extract a workflow step's display name without letting a non-static name
11
11
  * (e.g. a function call) abort the scan. The step name is cosmetic, so a
@@ -86,14 +86,18 @@ export function collectInvokedRPCs(steps, rpcs) {
86
86
  }
87
87
  }
88
88
  /**
89
- * Scan for workflow.do(), workflow.sleep(), and workflow.cancel() calls to extract workflow steps
89
+ * Scan for workflow.do(), workflow.expectEventually(), workflow.sleep(), and
90
+ * workflow.cancel() calls to extract workflow steps
90
91
  */
91
92
  function getWorkflowInvocations(node, checker, state, workflowName, steps) {
92
93
  // Look for property access expressions: workflow.do or workflow.sleep
93
94
  if (ts.isPropertyAccessExpression(node)) {
94
95
  const { name } = node;
95
96
  // Check if this is accessing 'do' or 'sleep' property
96
- if (name.text === 'do' || name.text === 'sleep' || name.text === 'cancel') {
97
+ if (name.text === 'do' ||
98
+ name.text === 'sleep' ||
99
+ name.text === 'cancel' ||
100
+ name.text === 'expectEventually') {
97
101
  // Check if the parent is a call expression
98
102
  const parent = node.parent;
99
103
  if (ts.isCallExpression(parent) && parent.expression === node) {
@@ -113,6 +117,7 @@ function getWorkflowInvocations(node, checker, state, workflowName, steps) {
113
117
  type: 'rpc',
114
118
  stepName,
115
119
  rpcName,
120
+ actor: extractActorFromOptions(optionsArg),
116
121
  });
117
122
  state.rpc.invokedFunctions.add(rpcName);
118
123
  }
@@ -125,6 +130,21 @@ function getWorkflowInvocations(node, checker, state, workflowName, steps) {
125
130
  });
126
131
  }
127
132
  }
133
+ else if (name.text === 'expectEventually' && args.length >= 4) {
134
+ // workflow.expectEventually(stepName, rpcName, data, predicate, options?)
135
+ const stepName = extractStepName(args[0], checker);
136
+ if (isStringLike(args[1], checker)) {
137
+ const rpcName = extractStringLiteral(args[1], checker);
138
+ steps.push({
139
+ type: 'rpc',
140
+ stepName,
141
+ rpcName,
142
+ expectEventually: true,
143
+ actor: extractActorFromOptions(args.length >= 5 ? args[4] : undefined),
144
+ });
145
+ state.rpc.invokedFunctions.add(rpcName);
146
+ }
147
+ }
128
148
  else if (name.text === 'sleep' && args.length >= 2) {
129
149
  // workflow.sleep(stepName, duration)
130
150
  const stepNameArg = args[0];
@@ -282,9 +302,19 @@ export const addWorkflow = (logger, node, checker, state) => {
282
302
  // For pikkuWorkflowComplexFunc, also run basic extraction so RPCs in
283
303
  // patterns the DSL extractor doesn't handle (array+push, nested Promise.all
284
304
  // with identifier args, etc.) are still registered as invoked functions.
305
+ // When DSL extraction already produced steps this pass is registration-only:
306
+ // appending to `steps` would duplicate nodes and clobber the graph.
285
307
  if (wrapperType !== 'dsl') {
286
- getWorkflowInvocations(resolvedFunc, checker, state, workflowName, steps);
308
+ const sink = steps.length > 0 ? [] : steps;
309
+ getWorkflowInvocations(resolvedFunc, checker, state, workflowName, sink);
287
310
  }
311
+ // Actor names the flow's steps run as (user flows) — powers the console
312
+ // personas view and the User Flow graph badges.
313
+ const actorNames = [
314
+ ...new Set(steps
315
+ .map((s) => ('actor' in s ? s.actor : undefined))
316
+ .filter((a) => typeof a === 'string')),
317
+ ];
288
318
  state.workflows.meta[workflowName] = {
289
319
  pikkuFuncId,
290
320
  name: workflowName,
@@ -298,6 +328,7 @@ export const addWorkflow = (logger, node, checker, state) => {
298
328
  tags,
299
329
  expose,
300
330
  userFlow: wrapperType === 'user-flow' ? true : undefined,
331
+ actors: actorNames.length > 0 ? actorNames : undefined,
301
332
  };
302
333
  // User flows are pure stories of remote RPCs (same rule as client-side CLI
303
334
  // renderers): the func may only destructure logger/config — everything else
package/dist/inspector.js CHANGED
@@ -93,6 +93,7 @@ export function getInitialInspectorState(rootDir) {
93
93
  exposedMeta: {},
94
94
  exposedFiles: new Map(),
95
95
  invokedFunctions: new Set(),
96
+ invokedFunctionsByFile: new Map(),
96
97
  usedAddons: new Set(),
97
98
  wireAddonDeclarations: new Map(),
98
99
  wireAddonFiles: new Set(),
package/dist/types.d.ts CHANGED
@@ -413,6 +413,7 @@ export interface InspectorState {
413
413
  exportedName: string;
414
414
  }>;
415
415
  invokedFunctions: Set<string>;
416
+ invokedFunctionsByFile: Map<string, Set<string>>;
416
417
  usedAddons: Set<string>;
417
418
  wireAddonDeclarations: Map<string, {
418
419
  package: string;
@@ -366,6 +366,9 @@ export function filterInspectorState(state, filters, logger) {
366
366
  // Track used functions/middleware/permissions
367
367
  if (routeMeta.pikkuFuncId) {
368
368
  filteredState.serviceAggregation.usedFunctions.add(routeMeta.pikkuFuncId);
369
+ if (routeMeta.refTarget) {
370
+ filteredState.serviceAggregation.usedFunctions.add(routeMeta.refTarget);
371
+ }
369
372
  // For workflow/agent routes, also add the base name
370
373
  // so the workflow/agent definition survives pruning
371
374
  const colonIdx = routeMeta.pikkuFuncId.indexOf(':');
@@ -796,6 +799,46 @@ export function filterInspectorState(state, filters, logger) {
796
799
  if (filteredState.serviceAggregation.requiredServices.has('workflowService')) {
797
800
  filteredState.serviceAggregation.requiredServices.add('queueService');
798
801
  }
802
+ if ((filteredState.rpc.wireAddonDeclarations?.size ?? 0) > 0) {
803
+ const referencedIds = new Set(filteredState.serviceAggregation.usedFunctions);
804
+ const keptFiles = new Set();
805
+ for (const funcId of filteredState.serviceAggregation.usedFunctions) {
806
+ const file = filteredState.functions.files.get(funcId);
807
+ if (file?.path)
808
+ keptFiles.add(file.path);
809
+ }
810
+ for (const [file, invoked] of state.rpc.invokedFunctionsByFile ??
811
+ new Map()) {
812
+ if (!keptFiles.has(file))
813
+ continue;
814
+ for (const id of invoked) {
815
+ referencedIds.add(id);
816
+ if (id.includes(':')) {
817
+ filteredState.serviceAggregation.usedFunctions.add(id);
818
+ }
819
+ }
820
+ }
821
+ for (const toolMeta of Object.values(filteredState.mcpEndpoints?.toolsMeta ?? {})) {
822
+ if (toolMeta.pikkuFuncId)
823
+ referencedIds.add(toolMeta.pikkuFuncId);
824
+ }
825
+ for (const agentMeta of Object.values(filteredState.agents?.agentsMeta ?? {})) {
826
+ for (const tool of agentMeta.tools ?? [])
827
+ referencedIds.add(tool);
828
+ }
829
+ const keptNamespaces = new Set();
830
+ for (const namespace of filteredState.rpc.wireAddonDeclarations.keys()) {
831
+ const prefix = `${namespace}:`;
832
+ for (const id of referencedIds) {
833
+ if (id.startsWith(prefix)) {
834
+ keptNamespaces.add(namespace);
835
+ break;
836
+ }
837
+ }
838
+ }
839
+ filteredState.rpc.wireAddonDeclarations = new Map([...filteredState.rpc.wireAddonDeclarations].filter(([namespace]) => keptNamespaces.has(namespace)));
840
+ filteredState.rpc.usedAddons = new Set([...filteredState.rpc.usedAddons].filter((namespace) => keptNamespaces.has(namespace)));
841
+ }
799
842
  // Recalculate requiredServices based on filtered functions/middleware/permissions
800
843
  // Need to cast to InspectorState temporarily for aggregateRequiredServices
801
844
  const stateForAggregation = filteredState;
@@ -135,6 +135,7 @@ export async function loadAddonFunctionsMeta(logger, state) {
135
135
  // No variables meta — that's fine
136
136
  }
137
137
  // Load addon serverlessIncompatible service names from pikku-addon-meta.gen.json
138
+ let loadedParentServices = false;
138
139
  try {
139
140
  const addonMetaPath = require.resolve(`${decl.package}/.pikku/console/pikku-addon-meta.gen.json`);
140
141
  const addonMetaRaw = await readFile(addonMetaPath, 'utf-8');
@@ -144,24 +145,29 @@ export async function loadAddonFunctionsMeta(logger, state) {
144
145
  state.addonServerlessIncompatible.set(namespace, addonMeta.serverlessIncompatible);
145
146
  logger.debug(`Addon '${namespace}' marks [${addonMeta.serverlessIncompatible.join(', ')}] as serverless-incompatible`);
146
147
  }
147
- }
148
- catch {
149
- // No addon meta or no serverlessIncompatible declared — that's fine
150
- }
151
- // Load addon required parent services from pikku-services.gen
152
- try {
153
- const servicesGenPath = require.resolve(`${decl.package}/.pikku/pikku-services.gen.js`);
154
- const servicesModule = await import(servicesGenPath);
155
- if (servicesModule.requiredParentServices &&
156
- Array.isArray(servicesModule.requiredParentServices)) {
157
- for (const service of servicesModule.requiredParentServices) {
148
+ if (Array.isArray(addonMeta.requiredParentServices) &&
149
+ addonMeta.requiredParentServices.length > 0) {
150
+ for (const service of addonMeta.requiredParentServices) {
158
151
  state.addonRequiredParentServices.push(service);
159
152
  }
160
- logger.debug(`Loaded ${servicesModule.requiredParentServices.length} required parent services for '${namespace}' from ${decl.package}`);
153
+ loadedParentServices = true;
154
+ logger.debug(`Loaded ${addonMeta.requiredParentServices.length} required parent services for '${namespace}' from addon meta`);
161
155
  }
162
156
  }
163
- catch {
164
- // No services gen — addon may not have requiredParentServices
157
+ catch { }
158
+ if (!loadedParentServices) {
159
+ try {
160
+ const servicesGenPath = require.resolve(`${decl.package}/.pikku/pikku-services.gen.js`);
161
+ const servicesModule = await import(servicesGenPath);
162
+ if (servicesModule.requiredParentServices &&
163
+ Array.isArray(servicesModule.requiredParentServices)) {
164
+ for (const service of servicesModule.requiredParentServices) {
165
+ state.addonRequiredParentServices.push(service);
166
+ }
167
+ logger.debug(`Loaded ${servicesModule.requiredParentServices.length} required parent services for '${namespace}' from ${decl.package}`);
168
+ }
169
+ }
170
+ catch { }
165
171
  }
166
172
  try {
167
173
  const httpContractsPath = require.resolve(`${decl.package}/.pikku/http/pikku-http-contracts-meta.gen.json`);
@@ -253,19 +253,44 @@ export function aggregateRequiredServices(state) {
253
253
  requiredServices.add('eventHub');
254
254
  }
255
255
  // 7. Services that consumed addons need from the parent project.
256
- // These are required ONLY by units that actually deploy an addon function;
257
- // a unit that merely calls the addon over RPC (or never touches it) must not
258
- // carry them, or every per-unit bundle would over-include the addon's
259
- // parent-service dependencies (e.g. aiAgentRunner, deploymentService) and
260
- // defeat per-unit tree-shaking.
261
- const addonFuncIds = new Set();
262
- for (const fns of Object.values(state.addonFunctions ?? {})) {
263
- for (const id of Object.keys(fns))
264
- addonFuncIds.add(id);
265
- }
266
- const unitDeploysAddonFn = [...usedFunctions].some((fn) => addonFuncIds.has(fn));
267
- if (unitDeploysAddonFn) {
268
- for (const service of state.addonRequiredParentServices ?? []) {
256
+ const addonFnServices = new Map();
257
+ for (const [namespace, fns] of Object.entries(state.addonFunctions ?? {})) {
258
+ for (const [id, meta] of Object.entries(fns)) {
259
+ addonFnServices.set(`${namespace}:${id}`, meta?.services?.services);
260
+ }
261
+ }
262
+ const parentDeclared = state.addonRequiredParentServices ?? [];
263
+ const parentDeclaredSet = new Set(parentDeclared);
264
+ const defaultServices = new Set([
265
+ 'config',
266
+ 'logger',
267
+ 'variables',
268
+ 'schema',
269
+ 'secrets',
270
+ ]);
271
+ let usesAddonFn = false;
272
+ let addonFactoryNeeded = false;
273
+ for (const funcId of usedFunctions) {
274
+ if (!addonFnServices.has(funcId))
275
+ continue;
276
+ usesAddonFn = true;
277
+ const services = addonFnServices.get(funcId);
278
+ if (!services) {
279
+ addonFactoryNeeded = true;
280
+ continue;
281
+ }
282
+ for (const service of services) {
283
+ if (parentDeclaredSet.has(service)) {
284
+ requiredServices.add(service);
285
+ }
286
+ else if (!internalServices.has(service) &&
287
+ !defaultServices.has(service)) {
288
+ addonFactoryNeeded = true;
289
+ }
290
+ }
291
+ }
292
+ if (usesAddonFn && addonFactoryNeeded) {
293
+ for (const service of parentDeclared) {
269
294
  requiredServices.add(service);
270
295
  }
271
296
  }
@@ -178,6 +178,7 @@ export interface SerializableInspectorState {
178
178
  exportedName: string;
179
179
  }]>;
180
180
  invokedFunctions: string[];
181
+ invokedFunctionsByFile?: Array<[string, string[]]>;
181
182
  usedAddons: string[];
182
183
  wireAddonDeclarations: Array<[
183
184
  string,
@@ -78,6 +78,7 @@ export function serializeInspectorState(state) {
78
78
  exposedMeta: state.rpc.exposedMeta,
79
79
  exposedFiles: Array.from(state.rpc.exposedFiles.entries()),
80
80
  invokedFunctions: Array.from(state.rpc.invokedFunctions),
81
+ invokedFunctionsByFile: Array.from((state.rpc.invokedFunctionsByFile ?? new Map()).entries()).map(([file, fns]) => [file, Array.from(fns)]),
81
82
  usedAddons: Array.from(state.rpc.usedAddons),
82
83
  wireAddonDeclarations: Array.from(state.rpc.wireAddonDeclarations.entries()),
83
84
  wireAddonFiles: Array.from(state.rpc.wireAddonFiles),
@@ -235,6 +236,7 @@ export function deserializeInspectorState(data) {
235
236
  exposedMeta: data.rpc.exposedMeta,
236
237
  exposedFiles: new Map(data.rpc.exposedFiles),
237
238
  invokedFunctions: new Set(data.rpc.invokedFunctions),
239
+ invokedFunctionsByFile: new Map((data.rpc.invokedFunctionsByFile || []).map(([file, fns]) => [file, new Set(fns)])),
238
240
  usedAddons: new Set(data.rpc.usedAddons || []),
239
241
  wireAddonDeclarations: new Map(data.rpc.wireAddonDeclarations || []),
240
242
  wireAddonFiles: new Set(data.rpc.wireAddonFiles || []),
@@ -1,5 +1,5 @@
1
1
  import * as ts from 'typescript';
2
- import { isWorkflowDoCall, isWorkflowSleepCall, isWorkflowSuspendCall, isThrowCancelException, extractCancelReason, isParallelFanout, isParallelGroup, isSequentialFanout, isArrayFilter, isArraySome, isArrayEvery, extractForOfVariable, isArrayType, getSourceText, extractSourcePath, } from './patterns.js';
2
+ import { isWorkflowDoCall, isWorkflowExpectEventuallyCall, extractActorFromOptions, isWorkflowSleepCall, isWorkflowSuspendCall, isThrowCancelException, extractCancelReason, isParallelFanout, isParallelGroup, isSequentialFanout, isArrayFilter, isArraySome, isArrayEvery, extractForOfVariable, isArrayType, getSourceText, extractSourcePath, } from './patterns.js';
3
3
  import { validateNoDisallowedPatterns, validateAwaitedCalls, formatValidationErrors, } from './validation.js';
4
4
  import { extractStringLiteral, extractNumberLiteral, } from '../../extract-node-value.js';
5
5
  /**
@@ -386,9 +386,12 @@ function extractRpcStep(call, context, outputVar) {
386
386
  const rpcName = extractStringLiteral(args[1], context.checker);
387
387
  // Extract inputs from third argument
388
388
  const inputs = args.length >= 3 ? extractInputSources(args[2], context) : undefined;
389
- // Extract options from fourth argument
390
- const options = args.length >= 4 && ts.isObjectLiteralExpression(args[3])
391
- ? extractStepOptions(args[3], context)
389
+ // do(step, rpc, data, options?) vs expectEventually(step, rpc, data, predicate, options?)
390
+ const expectEventually = isWorkflowExpectEventuallyCall(call);
391
+ const optionsIndex = expectEventually ? 4 : 3;
392
+ const optionsArg = args.length > optionsIndex ? args[optionsIndex] : undefined;
393
+ const options = optionsArg && ts.isObjectLiteralExpression(optionsArg)
394
+ ? extractStepOptions(optionsArg, context)
392
395
  : undefined;
393
396
  return {
394
397
  type: 'rpc',
@@ -397,6 +400,8 @@ function extractRpcStep(call, context, outputVar) {
397
400
  outputVar,
398
401
  inputs,
399
402
  options,
403
+ actor: extractActorFromOptions(optionsArg),
404
+ expectEventually: expectEventually || undefined,
400
405
  };
401
406
  }
402
407
  catch (error) {
@@ -3,9 +3,20 @@ import * as ts from 'typescript';
3
3
  * Pattern detection helpers for DSL workflow extraction
4
4
  */
5
5
  /**
6
- * Check if a call expression is workflow.do()
6
+ * Check if a call expression is workflow.do() or workflow.expectEventually()
7
+ * (both are RPC steps; expectEventually is the polling variant used by user flows)
7
8
  */
8
9
  export declare function isWorkflowDoCall(node: ts.CallExpression, checker: ts.TypeChecker): boolean;
10
+ /**
11
+ * Check if a call expression is workflow.expectEventually()
12
+ */
13
+ export declare function isWorkflowExpectEventuallyCall(node: ts.CallExpression): boolean;
14
+ /**
15
+ * Extract the actor NAME from a step options object literal:
16
+ * `{ actor: actors.x }` → 'x' (also `actors['x']`). Undefined when absent
17
+ * or not statically resolvable.
18
+ */
19
+ export declare function extractActorFromOptions(optionsArg: ts.Expression | undefined): string | undefined;
9
20
  /**
10
21
  * Check if a call expression is workflow.sleep()
11
22
  */
@@ -3,17 +3,52 @@ import * as ts from 'typescript';
3
3
  * Pattern detection helpers for DSL workflow extraction
4
4
  */
5
5
  /**
6
- * Check if a call expression is workflow.do()
6
+ * Check if a call expression is workflow.do() or workflow.expectEventually()
7
+ * (both are RPC steps; expectEventually is the polling variant used by user flows)
7
8
  */
8
9
  export function isWorkflowDoCall(node, checker) {
9
10
  if (!ts.isPropertyAccessExpression(node.expression)) {
10
11
  return false;
11
12
  }
12
13
  const propAccess = node.expression;
13
- return (propAccess.name.text === 'do' &&
14
+ return ((propAccess.name.text === 'do' ||
15
+ propAccess.name.text === 'expectEventually') &&
14
16
  ts.isIdentifier(propAccess.expression) &&
15
17
  propAccess.expression.text === 'workflow');
16
18
  }
19
+ /**
20
+ * Check if a call expression is workflow.expectEventually()
21
+ */
22
+ export function isWorkflowExpectEventuallyCall(node) {
23
+ return (ts.isPropertyAccessExpression(node.expression) &&
24
+ node.expression.name.text === 'expectEventually' &&
25
+ ts.isIdentifier(node.expression.expression) &&
26
+ node.expression.expression.text === 'workflow');
27
+ }
28
+ /**
29
+ * Extract the actor NAME from a step options object literal:
30
+ * `{ actor: actors.x }` → 'x' (also `actors['x']`). Undefined when absent
31
+ * or not statically resolvable.
32
+ */
33
+ export function extractActorFromOptions(optionsArg) {
34
+ if (!optionsArg || !ts.isObjectLiteralExpression(optionsArg))
35
+ return;
36
+ for (const prop of optionsArg.properties) {
37
+ if (ts.isPropertyAssignment(prop) &&
38
+ ts.isIdentifier(prop.name) &&
39
+ prop.name.text === 'actor') {
40
+ const init = prop.initializer;
41
+ if (ts.isPropertyAccessExpression(init)) {
42
+ return init.name.text;
43
+ }
44
+ if (ts.isElementAccessExpression(init) &&
45
+ ts.isStringLiteral(init.argumentExpression)) {
46
+ return init.argumentExpression.text;
47
+ }
48
+ }
49
+ }
50
+ return;
51
+ }
17
52
  /**
18
53
  * Check if a call expression is workflow.sleep()
19
54
  */
@@ -58,6 +58,12 @@ function convertStepToNode(step, index, steps, nodeIdPrefix = 'step') {
58
58
  rpcName: step.rpcName,
59
59
  next: nextNodeId,
60
60
  };
61
+ if (step.actor) {
62
+ node.actor = step.actor;
63
+ }
64
+ if (step.expectEventually) {
65
+ node.expectEventually = true;
66
+ }
61
67
  if (step.inputs) {
62
68
  if (step.inputs === 'passthrough') {
63
69
  // Entire data is passed through - store as reference to trigger
@@ -304,8 +310,10 @@ export function convertDslToGraph(workflowName, meta) {
304
310
  name: workflowName,
305
311
  pikkuFuncId: meta.pikkuFuncId,
306
312
  source,
313
+ title: meta.title,
307
314
  description: meta.description,
308
315
  tags: meta.tags,
316
+ actors: meta.actors,
309
317
  context: meta.context,
310
318
  nodes: nodesRecord,
311
319
  entryNodeIds,
@@ -96,6 +96,10 @@ export interface FunctionNode extends BaseNode {
96
96
  outputVar?: string;
97
97
  /** Hash of nodeId + RPC input/output schemas for version detection */
98
98
  stepHash?: string;
99
+ /** User-flow actor name this step runs as (workflow.do {actor: actors.x}) */
100
+ actor?: string;
101
+ /** True for workflow.expectEventually polling steps (user flows) */
102
+ expectEventually?: boolean;
99
103
  }
100
104
  /**
101
105
  * Flow node - control flow only, no RPC call
@@ -136,10 +140,14 @@ export interface SerializedWorkflowGraph {
136
140
  pikkuFuncId: string;
137
141
  /** Source type: 'dsl' for pikkuWorkflowFunc, 'graph' for pikkuWorkflowGraph */
138
142
  source: WorkflowSourceType;
143
+ /** Optional short display name */
144
+ title?: string;
139
145
  /** Optional description */
140
146
  description?: string;
141
147
  /** Tags for organization */
142
148
  tags?: string[];
149
+ /** Actor names a user flow's steps run as */
150
+ actors?: string[];
143
151
  /** If true, workflow always executes inline without queues */
144
152
  inline?: boolean;
145
153
  /** Workflow context/state variables (from Zod schema) */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pikku/inspector",
3
- "version": "0.12.33",
3
+ "version": "0.12.35",
4
4
  "author": "yasser.fadl@gmail.com",
5
5
  "license": "BUSL-1.1",
6
6
  "type": "module",
@@ -35,7 +35,7 @@
35
35
  },
36
36
  "dependencies": {
37
37
  "@openapi-contrib/json-schema-to-openapi-schema": "^4.3.1",
38
- "@pikku/core": "^0.12.49",
38
+ "@pikku/core": "^0.12.51",
39
39
  "openapi-types": "^12.1.3",
40
40
  "path-to-regexp": "^8.3.0",
41
41
  "ts-json-schema-generator": "2.9.1-next.16",
package/run-tests.sh CHANGED
@@ -38,16 +38,16 @@ if [ ${#files[@]} -eq 0 ]; then
38
38
  fi
39
39
 
40
40
  # Construct the node command
41
- node_cmd="node --import tsx --test"
41
+ node_cmd=(node --import tsx --test)
42
42
 
43
43
  # Append options based on flags
44
44
  if [ "$watch_mode" = true ]; then
45
- node_cmd="$node_cmd --watch"
45
+ node_cmd+=(--watch)
46
46
  fi
47
47
 
48
48
  if [ "$coverage_mode" = true ]; then
49
- node_cmd="$node_cmd --test-coverage-include=\"src/**/*.{ts,js}\" --test-coverage-exclude=\"**/dist/**\" --experimental-test-coverage --test-reporter=lcov --test-reporter-destination=lcov.info"
49
+ node_cmd+=(--test-coverage-include="src/**/*.{ts,js}" --test-coverage-exclude="**/dist/**" --experimental-test-coverage --test-reporter=lcov --test-reporter-destination=lcov.info)
50
50
  fi
51
51
 
52
52
  # Execute the node command with the expanded list of files
53
- $node_cmd "${files[@]}"
53
+ "${node_cmd[@]}" "${files[@]}"
@@ -389,8 +389,10 @@ export function registerHTTPRoute({
389
389
  checker
390
390
  )
391
391
 
392
- // Track used functions/middleware/permissions for service aggregation
393
392
  state.serviceAggregation.usedFunctions.add(funcName)
393
+ if (refAddonTarget) {
394
+ state.serviceAggregation.usedFunctions.add(refAddonTarget)
395
+ }
394
396
  extractWireNames(middleware).forEach((name) =>
395
397
  state.serviceAggregation.usedMiddleware.add(name)
396
398
  )
@@ -415,6 +417,7 @@ export function registerHTTPRoute({
415
417
  state.http.files.add(sourceFile.fileName)
416
418
  state.http.meta[method][fullRoute] = {
417
419
  pikkuFuncId: funcName,
420
+ ...(refAddonTarget && { refTarget: refAddonTarget }),
418
421
  ...(packageName && { packageName }),
419
422
  route: fullRoute,
420
423
  sourceFile: sourceFile.fileName,