@pikku/inspector 0.12.31 → 0.12.34

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.
package/CHANGELOG.md CHANGED
@@ -1,3 +1,55 @@
1
+ ## 0.12.34
2
+
3
+ ### Patch Changes
4
+
5
+ - 92bd643: User flows in the console: workflow graph extraction now captures
6
+ `workflow.expectEventually` steps and per-step actor names (`{ actor:
7
+ actors.x }`), workflow meta carries `actors`/`title` into the serialized
8
+ graph, the CLI emits `user-flow-actors.gen.json` for the new
9
+ `MetaService.getUserFlowActorsMeta()`, and the console Workflows page gains a
10
+ Workflows / User Flows / Personas toggle. Also fixes complex-workflow graphs
11
+ being clobbered by a duplicate basic-extraction pass after successful DSL
12
+ extraction.
13
+ - Updated dependencies [35a9bab]
14
+ - Updated dependencies [92bd643]
15
+ - @pikku/core@0.12.50
16
+
17
+ ## 0.12.33
18
+
19
+ ### Patch Changes
20
+
21
+ - 4c17f7e: user flows: actors move onto the workflow wire + `pikku userflow` command
22
+ - Actors are no longer a singleton service: `startWorkflow(..., { actors })`
23
+ registers them per run and they arrive on the wire —
24
+ `func: async ({ logger }, input, { workflow, actors })`.
25
+ - Inspector enforces user flows are pure remote stories (PKU673): a
26
+ pikkuUserFlow func may only destructure `logger`/`config` from services.
27
+ - New `pikku userflow run <environment> [--flows a,b] [--tags x,y]` runs flows
28
+ against `userFlows.environments` from pikku.config.json (secret from
29
+ USER_FLOW_ACTOR_SECRET env), refusing internal (non-actor) steps so runs
30
+ against staging/production never touch local services; non-zero exit on
31
+ failure. `pikku userflow list` prints names, descriptions and tags.
32
+ - Workflow meta now carries `title` (parity with HTTP routes/functions).
33
+
34
+ - Updated dependencies [4c17f7e]
35
+ - @pikku/core@0.12.49
36
+
37
+ ## 0.12.32
38
+
39
+ ### Patch Changes
40
+
41
+ - 8dfddc3: pikkuUserFlow: user flows as workflows. A complex workflow whose steps can run
42
+ as actors over the real transport — `workflow.do(step, rpc, data, { actor:
43
+ actors.yasser })` — plus `workflow.expectEventually(...)` for polling async
44
+ effects. Actor steps never queue and never dispatch internally, so auth
45
+ middleware/permissions are exercised end-to-end; flows double as e2e tests and
46
+ staged/production health checks. Ships UserFlowActor types +
47
+ createHttpUserFlowActors (lazy sign-in via `/auth/sign-in/actor` with a
48
+ server-held secret), inspector source `'user-flow'`, and a console badge.
49
+ - Updated dependencies [5f2c566]
50
+ - Updated dependencies [8dfddc3]
51
+ - @pikku/core@0.12.48
52
+
1
53
  ## 0.12.31
2
54
 
3
55
  ### Patch Changes
@@ -250,9 +250,11 @@ export const addFunctions = (logger, node, checker, state, options) => {
250
250
  if (!ts.isIdentifier(expression)) {
251
251
  return;
252
252
  }
253
- // Match identifiers that contain both "pikku" and "func" (case insensitive)
253
+ // Match identifiers that contain both "pikku" and "func" (case insensitive),
254
+ // plus pikkuUserFlow (a workflow wrapper without "func" in its name)
254
255
  const pikkuFuncPattern = /pikku.*func/i;
255
- if (!pikkuFuncPattern.test(expression.text)) {
256
+ if (!pikkuFuncPattern.test(expression.text) &&
257
+ expression.text !== 'pikkuUserFlow') {
256
258
  return;
257
259
  }
258
260
  // only handle calls like pikkuFunc(...)
@@ -5,7 +5,7 @@ import type { WorkflowStepMeta } from '@pikku/core/workflow';
5
5
  */
6
6
  export declare function collectInvokedRPCs(steps: WorkflowStepMeta[], rpcs: Set<string>): void;
7
7
  /**
8
- * Inspector for pikkuWorkflowFunc() and pikkuWorkflowComplexFunc() calls
9
- * Detects workflow registration and extracts metadata
8
+ * Inspector for pikkuWorkflowFunc(), pikkuWorkflowComplexFunc() and
9
+ * pikkuUserFlow() calls. Detects workflow registration and extracts metadata.
10
10
  */
11
11
  export declare const addWorkflow: AddWiring;
@@ -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];
@@ -156,8 +176,8 @@ function getWorkflowInvocations(node, checker, state, workflowName, steps) {
156
176
  });
157
177
  }
158
178
  /**
159
- * Inspector for pikkuWorkflowFunc() and pikkuWorkflowComplexFunc() calls
160
- * Detects workflow registration and extracts metadata
179
+ * Inspector for pikkuWorkflowFunc(), pikkuWorkflowComplexFunc() and
180
+ * pikkuUserFlow() calls. Detects workflow registration and extracts metadata.
161
181
  */
162
182
  export const addWorkflow = (logger, node, checker, state) => {
163
183
  if (!ts.isCallExpression(node)) {
@@ -176,6 +196,11 @@ export const addWorkflow = (logger, node, checker, state) => {
176
196
  else if (expression.text === 'pikkuWorkflowComplexFunc') {
177
197
  wrapperType = 'complex';
178
198
  }
199
+ else if (expression.text === 'pikkuUserFlow') {
200
+ // A user flow is a complex workflow whose steps run as actors over the
201
+ // real transport — same extraction rules as complex, distinct meta.
202
+ wrapperType = 'user-flow';
203
+ }
179
204
  else {
180
205
  return;
181
206
  }
@@ -193,6 +218,7 @@ export const addWorkflow = (logger, node, checker, state) => {
193
218
  const { funcNode, resolvedFunc } = extractFunctionNode(firstArg, checker);
194
219
  // Extract metadata if using object form
195
220
  let tags;
221
+ let title;
196
222
  let summary;
197
223
  let description;
198
224
  let errors;
@@ -202,6 +228,7 @@ export const addWorkflow = (logger, node, checker, state) => {
202
228
  if (metadata.disabled)
203
229
  return;
204
230
  tags = metadata.tags;
231
+ title = metadata.title;
205
232
  summary = metadata.summary;
206
233
  description = metadata.description;
207
234
  errors = metadata.errors;
@@ -230,7 +257,7 @@ export const addWorkflow = (logger, node, checker, state) => {
230
257
  // Try DSL workflow extraction first
231
258
  // Pass the whole CallExpression node so findWorkflowFunction can find the arrow function
232
259
  const result = extractDSLWorkflow(node, checker, {
233
- allowInline: wrapperType === 'complex',
260
+ allowInline: wrapperType !== 'dsl',
234
261
  });
235
262
  if (result.status === 'ok' && result.steps) {
236
263
  // Extraction succeeded
@@ -275,24 +302,51 @@ export const addWorkflow = (logger, node, checker, state) => {
275
302
  // For pikkuWorkflowComplexFunc, also run basic extraction so RPCs in
276
303
  // patterns the DSL extractor doesn't handle (array+push, nested Promise.all
277
304
  // with identifier args, etc.) are still registered as invoked functions.
278
- if (wrapperType === 'complex') {
279
- getWorkflowInvocations(resolvedFunc, checker, state, workflowName, steps);
305
+ // When DSL extraction already produced steps this pass is registration-only:
306
+ // appending to `steps` would duplicate nodes and clobber the graph.
307
+ if (wrapperType !== 'dsl') {
308
+ const sink = steps.length > 0 ? [] : steps;
309
+ getWorkflowInvocations(resolvedFunc, checker, state, workflowName, sink);
280
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
+ ];
281
318
  state.workflows.meta[workflowName] = {
282
319
  pikkuFuncId,
283
320
  name: workflowName,
284
321
  steps,
285
322
  context,
286
323
  dsl,
324
+ title,
287
325
  summary,
288
326
  description,
289
327
  errors,
290
328
  tags,
291
329
  expose,
330
+ userFlow: wrapperType === 'user-flow' ? true : undefined,
331
+ actors: actorNames.length > 0 ? actorNames : undefined,
292
332
  };
333
+ // User flows are pure stories of remote RPCs (same rule as client-side CLI
334
+ // renderers): the func may only destructure logger/config — everything else
335
+ // must go through actor steps so the flow runs against the TARGET
336
+ // environment, never local services.
337
+ const funcMeta = state.functions.meta[pikkuFuncId];
338
+ if (wrapperType === 'user-flow' && funcMeta?.services) {
339
+ const disallowed = funcMeta.services.services.filter((svc) => svc !== 'logger' && svc !== 'config');
340
+ if (disallowed.length > 0) {
341
+ logger.critical(ErrorCode.USER_FLOW_HAS_SERVICES, `User flow '${workflowName}' destructures services: ${disallowed.join(', ')}. ` +
342
+ `User flows may only use 'logger'/'config' — drive everything else through ` +
343
+ `actor steps (workflow.do(step, rpc, data, { actor: actors.x })) so the flow ` +
344
+ `runs against the target environment.`);
345
+ return;
346
+ }
347
+ }
293
348
  // Workflow functions require platform services that aren't visible
294
349
  // through parameter destructuring (they're accessed via workflow.do/sleep)
295
- const funcMeta = state.functions.meta[pikkuFuncId];
296
350
  if (funcMeta?.services) {
297
351
  for (const svc of [
298
352
  'workflowService',
@@ -19,6 +19,7 @@ export declare enum ErrorCode {
19
19
  MISSING_QUEUE_NAME = "PKU384",
20
20
  MISSING_CHANNEL_NAME = "PKU400",
21
21
  CLI_CLIENTSIDE_RENDERER_HAS_SERVICES = "PKU672",
22
+ USER_FLOW_HAS_SERVICES = "PKU673",
22
23
  DYNAMIC_STEP_NAME = "PKU529",
23
24
  WORKFLOW_ORCHESTRATOR_NOT_CONFIGURED = "PKU600",
24
25
  INVALID_DSL_WORKFLOW = "PKU641",
@@ -21,6 +21,7 @@ export var ErrorCode;
21
21
  ErrorCode["MISSING_QUEUE_NAME"] = "PKU384";
22
22
  ErrorCode["MISSING_CHANNEL_NAME"] = "PKU400";
23
23
  ErrorCode["CLI_CLIENTSIDE_RENDERER_HAS_SERVICES"] = "PKU672";
24
+ ErrorCode["USER_FLOW_HAS_SERVICES"] = "PKU673";
24
25
  ErrorCode["DYNAMIC_STEP_NAME"] = "PKU529";
25
26
  ErrorCode["WORKFLOW_ORCHESTRATOR_NOT_CONFIGURED"] = "PKU600";
26
27
  ErrorCode["INVALID_DSL_WORKFLOW"] = "PKU641";
@@ -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
@@ -291,16 +297,23 @@ export function convertDslToGraph(workflowName, meta) {
291
297
  nodesRecord[node.nodeId] = node;
292
298
  }
293
299
  const entryNodeIds = nodes.length > 0 ? [nodes[0].nodeId] : [];
294
- // Determine source type based on dsl flag:
300
+ // Determine source type:
301
+ // - userFlow: complex workflow whose steps run as actors (user flow)
295
302
  // - dsl === true: pure DSL workflow, can be serialized
296
303
  // - dsl === false: complex workflow with inline steps, not serializable
297
- const source = meta.dsl === false ? 'complex' : 'dsl';
304
+ const source = meta.userFlow
305
+ ? 'user-flow'
306
+ : meta.dsl === false
307
+ ? 'complex'
308
+ : 'dsl';
298
309
  return {
299
310
  name: workflowName,
300
311
  pikkuFuncId: meta.pikkuFuncId,
301
312
  source,
313
+ title: meta.title,
302
314
  description: meta.description,
303
315
  tags: meta.tags,
316
+ actors: meta.actors,
304
317
  context: meta.context,
305
318
  nodes: nodesRecord,
306
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
@@ -123,8 +127,9 @@ export declare const isFlowNode: (node: SerializedGraphNode) => node is FlowNode
123
127
  * - 'dsl': Pure DSL workflow (pikkuWorkflowFunc) - can be round-tripped to code
124
128
  * - 'complex': Complex workflow (pikkuWorkflowComplexFunc) - contains inline steps, not serializable
125
129
  * - 'graph': Graph-based workflow (pikkuWorkflowGraph)
130
+ * - 'user-flow': User flow (pikkuUserFlow) - complex workflow whose steps run as actors
126
131
  */
127
- export type WorkflowSourceType = 'dsl' | 'complex' | 'graph';
132
+ export type WorkflowSourceType = 'dsl' | 'complex' | 'graph' | 'user-flow';
128
133
  /**
129
134
  * Serialized workflow graph - the canonical JSON format
130
135
  */
@@ -135,10 +140,14 @@ export interface SerializedWorkflowGraph {
135
140
  pikkuFuncId: string;
136
141
  /** Source type: 'dsl' for pikkuWorkflowFunc, 'graph' for pikkuWorkflowGraph */
137
142
  source: WorkflowSourceType;
143
+ /** Optional short display name */
144
+ title?: string;
138
145
  /** Optional description */
139
146
  description?: string;
140
147
  /** Tags for organization */
141
148
  tags?: string[];
149
+ /** Actor names a user flow's steps run as */
150
+ actors?: string[];
142
151
  /** If true, workflow always executes inline without queues */
143
152
  inline?: boolean;
144
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.31",
3
+ "version": "0.12.34",
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.45",
38
+ "@pikku/core": "^0.12.50",
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",
@@ -357,9 +357,13 @@ export const addFunctions: AddWiring = (
357
357
  return
358
358
  }
359
359
 
360
- // Match identifiers that contain both "pikku" and "func" (case insensitive)
360
+ // Match identifiers that contain both "pikku" and "func" (case insensitive),
361
+ // plus pikkuUserFlow (a workflow wrapper without "func" in its name)
361
362
  const pikkuFuncPattern = /pikku.*func/i
362
- if (!pikkuFuncPattern.test(expression.text)) {
363
+ if (
364
+ !pikkuFuncPattern.test(expression.text) &&
365
+ expression.text !== 'pikkuUserFlow'
366
+ ) {
363
367
  return
364
368
  }
365
369
 
@@ -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]
@@ -169,8 +193,8 @@ function getWorkflowInvocations(
169
193
  }
170
194
 
171
195
  /**
172
- * Inspector for pikkuWorkflowFunc() and pikkuWorkflowComplexFunc() calls
173
- * Detects workflow registration and extracts metadata
196
+ * Inspector for pikkuWorkflowFunc(), pikkuWorkflowComplexFunc() and
197
+ * pikkuUserFlow() calls. Detects workflow registration and extracts metadata.
174
198
  */
175
199
  export const addWorkflow: AddWiring = (logger, node, checker, state) => {
176
200
  if (!ts.isCallExpression(node)) {
@@ -185,11 +209,15 @@ export const addWorkflow: AddWiring = (logger, node, checker, state) => {
185
209
  return
186
210
  }
187
211
 
188
- let wrapperType: 'dsl' | 'complex' | null = null
212
+ let wrapperType: 'dsl' | 'complex' | 'user-flow' | null = null
189
213
  if (expression.text === 'pikkuWorkflowFunc') {
190
214
  wrapperType = 'dsl'
191
215
  } else if (expression.text === 'pikkuWorkflowComplexFunc') {
192
216
  wrapperType = 'complex'
217
+ } else if (expression.text === 'pikkuUserFlow') {
218
+ // A user flow is a complex workflow whose steps run as actors over the
219
+ // real transport — same extraction rules as complex, distinct meta.
220
+ wrapperType = 'user-flow'
193
221
  } else {
194
222
  return
195
223
  }
@@ -220,6 +248,7 @@ export const addWorkflow: AddWiring = (logger, node, checker, state) => {
220
248
 
221
249
  // Extract metadata if using object form
222
250
  let tags: string[] | undefined
251
+ let title: string | undefined
223
252
  let summary: string | undefined
224
253
  let description: string | undefined
225
254
  let errors: string[] | undefined
@@ -235,6 +264,7 @@ export const addWorkflow: AddWiring = (logger, node, checker, state) => {
235
264
  )
236
265
  if (metadata.disabled) return
237
266
  tags = metadata.tags
267
+ title = metadata.title
238
268
  summary = metadata.summary
239
269
  description = metadata.description
240
270
  errors = metadata.errors
@@ -277,7 +307,7 @@ export const addWorkflow: AddWiring = (logger, node, checker, state) => {
277
307
  // Try DSL workflow extraction first
278
308
  // Pass the whole CallExpression node so findWorkflowFunction can find the arrow function
279
309
  const result = extractDSLWorkflow(node, checker, {
280
- allowInline: wrapperType === 'complex',
310
+ allowInline: wrapperType !== 'dsl',
281
311
  })
282
312
 
283
313
  if (result.status === 'ok' && result.steps) {
@@ -331,26 +361,62 @@ export const addWorkflow: AddWiring = (logger, node, checker, state) => {
331
361
  // For pikkuWorkflowComplexFunc, also run basic extraction so RPCs in
332
362
  // patterns the DSL extractor doesn't handle (array+push, nested Promise.all
333
363
  // with identifier args, etc.) are still registered as invoked functions.
334
- if (wrapperType === 'complex') {
335
- getWorkflowInvocations(resolvedFunc, checker, state, workflowName, steps)
364
+ // When DSL extraction already produced steps this pass is registration-only:
365
+ // appending to `steps` would duplicate nodes and clobber the graph.
366
+ if (wrapperType !== 'dsl') {
367
+ const sink: WorkflowStepMeta[] = steps.length > 0 ? [] : steps
368
+ getWorkflowInvocations(resolvedFunc, checker, state, workflowName, sink)
336
369
  }
337
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
+
338
381
  state.workflows.meta[workflowName] = {
339
382
  pikkuFuncId,
340
383
  name: workflowName,
341
384
  steps,
342
385
  context,
343
386
  dsl,
387
+ title,
344
388
  summary,
345
389
  description,
346
390
  errors,
347
391
  tags,
348
392
  expose,
393
+ userFlow: wrapperType === 'user-flow' ? true : undefined,
394
+ actors: actorNames.length > 0 ? actorNames : undefined,
395
+ }
396
+
397
+ // User flows are pure stories of remote RPCs (same rule as client-side CLI
398
+ // renderers): the func may only destructure logger/config — everything else
399
+ // must go through actor steps so the flow runs against the TARGET
400
+ // environment, never local services.
401
+ const funcMeta = state.functions.meta[pikkuFuncId]
402
+ if (wrapperType === 'user-flow' && funcMeta?.services) {
403
+ const disallowed = funcMeta.services.services.filter(
404
+ (svc) => svc !== 'logger' && svc !== 'config'
405
+ )
406
+ if (disallowed.length > 0) {
407
+ logger.critical(
408
+ ErrorCode.USER_FLOW_HAS_SERVICES,
409
+ `User flow '${workflowName}' destructures services: ${disallowed.join(', ')}. ` +
410
+ `User flows may only use 'logger'/'config' — drive everything else through ` +
411
+ `actor steps (workflow.do(step, rpc, data, { actor: actors.x })) so the flow ` +
412
+ `runs against the target environment.`
413
+ )
414
+ return
415
+ }
349
416
  }
350
417
 
351
418
  // Workflow functions require platform services that aren't visible
352
419
  // through parameter destructuring (they're accessed via workflow.do/sleep)
353
- const funcMeta = state.functions.meta[pikkuFuncId]
354
420
  if (funcMeta?.services) {
355
421
  for (const svc of [
356
422
  'workflowService',