@pikku/inspector 0.12.35 → 0.12.36

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,44 @@
1
+ ## 0.12.36
2
+
3
+ ### Patch Changes
4
+
5
+ - 61c9ce9: Add `actor.converse(...)` — actor agents for user journeys (#850)
6
+
7
+ An actor can now hold a dynamic, LLM-driven conversation with a target Pikku AI
8
+ agent in its own persona:
9
+
10
+ ```ts
11
+ const verdict = await actors.pm.converse({
12
+ agent: 'todoBot',
13
+ task: 'Get a todo created for the launch',
14
+ evaluate: 'A todo about the launch now exists',
15
+ })
16
+ // verdict: { passed, reasoning, transcript }
17
+ // then assert deterministically as the same actor:
18
+ const todos = await actors.pm.invoke('listTodos', {})
19
+ ```
20
+
21
+ The actor drives the target over the real transport (the agent's own
22
+ `agentRun` / `agentApprove` HTTP routes, signed in as the actor), plays the
23
+ persona from its `pikku.config.json` config, answers the agent's tool-approval
24
+ requests in-persona (`approvals: 'in-persona' | 'always' | 'never'`), and
25
+ returns its verdict on whether the task was met. Deterministic checks stay the
26
+ caller's job — they already hold the actor.
27
+
28
+ The conversation engine is transport-agnostic (persona LLM + injected target
29
+ driver); the persona's own turns run in-process via the configured
30
+ `aiAgentRunner` (`model` from the call or the actors-service default).
31
+
32
+ `agent` is typed against the generated agent-name union (`keyof AgentMap`), so
33
+ it's author-time checked and autocompleted in a typed project.
34
+
35
+ - 472a349: Rename the userflow concept to scenario (#862). `pikkuUserFlow` becomes `pikkuScenario`, `pikku userflow run/list` becomes `pikku scenario run/list`, the workflow meta flag `userFlow` becomes `scenario`, actor types are now `ScenarioActor`/`ScenarioActors`/`ScenarioActorConfig` (`createHttpScenarioActors`), pikku.config.json's `userFlows` key becomes `scenarios`, the generated actors file is `pikku-scenario-actors.gen.ts` (`createScenarioActors`), the actor sign-in secret env var is `SCENARIO_ACTOR_SECRET`, and the console's User Flows view is now Scenarios.
36
+ - Updated dependencies [61c9ce9]
37
+ - Updated dependencies [f1f39f8]
38
+ - Updated dependencies [c45e98d]
39
+ - Updated dependencies [472a349]
40
+ - @pikku/core@0.12.52
41
+
1
42
  ## 0.12.35
2
43
 
3
44
  ### Patch Changes
@@ -251,10 +251,10 @@ export const addFunctions = (logger, node, checker, state, options) => {
251
251
  return;
252
252
  }
253
253
  // Match identifiers that contain both "pikku" and "func" (case insensitive),
254
- // plus pikkuUserFlow (a workflow wrapper without "func" in its name)
254
+ // plus pikkuScenario (a workflow wrapper without "func" in its name)
255
255
  const pikkuFuncPattern = /pikku.*func/i;
256
256
  if (!pikkuFuncPattern.test(expression.text) &&
257
- expression.text !== 'pikkuUserFlow') {
257
+ expression.text !== 'pikkuScenario') {
258
258
  return;
259
259
  }
260
260
  // only handle calls like pikkuFunc(...)
@@ -6,6 +6,6 @@ import type { WorkflowStepMeta } from '@pikku/core/workflow';
6
6
  export declare function collectInvokedRPCs(steps: WorkflowStepMeta[], rpcs: Set<string>): void;
7
7
  /**
8
8
  * Inspector for pikkuWorkflowFunc(), pikkuWorkflowComplexFunc() and
9
- * pikkuUserFlow() calls. Detects workflow registration and extracts metadata.
9
+ * pikkuScenario() calls. Detects workflow registration and extracts metadata.
10
10
  */
11
11
  export declare const addWorkflow: AddWiring;
@@ -19,6 +19,25 @@ function extractStepName(node, checker) {
19
19
  return getSourceText(node);
20
20
  }
21
21
  }
22
+ /**
23
+ * Walk a function body for any `X.expectEventually(...)` call. Used to enforce
24
+ * that the durable-polling assertion is a scenario-only primitive — regular
25
+ * workflows must not carry test/assertion semantics.
26
+ */
27
+ function containsExpectEventuallyCall(node) {
28
+ if (ts.isCallExpression(node) &&
29
+ ts.isPropertyAccessExpression(node.expression) &&
30
+ node.expression.name.text === 'expectEventually') {
31
+ return true;
32
+ }
33
+ let found = false;
34
+ ts.forEachChild(node, (child) => {
35
+ if (!found && !ts.isFunctionDeclaration(child)) {
36
+ found = containsExpectEventuallyCall(child);
37
+ }
38
+ });
39
+ return found;
40
+ }
22
41
  /**
23
42
  * Recursively check if any step has inline type (non-serializable)
24
43
  */
@@ -177,7 +196,7 @@ function getWorkflowInvocations(node, checker, state, workflowName, steps) {
177
196
  }
178
197
  /**
179
198
  * Inspector for pikkuWorkflowFunc(), pikkuWorkflowComplexFunc() and
180
- * pikkuUserFlow() calls. Detects workflow registration and extracts metadata.
199
+ * pikkuScenario() calls. Detects workflow registration and extracts metadata.
181
200
  */
182
201
  export const addWorkflow = (logger, node, checker, state) => {
183
202
  if (!ts.isCallExpression(node)) {
@@ -196,10 +215,10 @@ export const addWorkflow = (logger, node, checker, state) => {
196
215
  else if (expression.text === 'pikkuWorkflowComplexFunc') {
197
216
  wrapperType = 'complex';
198
217
  }
199
- else if (expression.text === 'pikkuUserFlow') {
200
- // A user flow is a complex workflow whose steps run as actors over the
218
+ else if (expression.text === 'pikkuScenario') {
219
+ // A scenario is a complex workflow whose steps run as actors over the
201
220
  // real transport — same extraction rules as complex, distinct meta.
202
- wrapperType = 'user-flow';
221
+ wrapperType = 'scenario';
203
222
  }
204
223
  else {
205
224
  return;
@@ -244,6 +263,15 @@ export const addWorkflow = (logger, node, checker, state) => {
244
263
  logger.critical(ErrorCode.MISSING_FUNC, `Could not resolve workflow function for '${workflowName}'.`);
245
264
  return;
246
265
  }
266
+ // expectEventually is a scenario-only assertion primitive — regular
267
+ // workflows must stay free of test/eval semantics.
268
+ if (wrapperType !== 'scenario' &&
269
+ containsExpectEventuallyCall(resolvedFunc)) {
270
+ logger.critical(ErrorCode.EXPECT_EVENTUALLY_SCENARIO_ONLY, `Workflow '${workflowName}' calls workflow.expectEventually(), which is only ` +
271
+ `available in scenarios (pikkuScenario). Move it into a scenario, or drive ` +
272
+ `the assertion outside the workflow.`);
273
+ return;
274
+ }
247
275
  // Track workflow file for wiring generation
248
276
  if (exportedName) {
249
277
  state.workflows.files.set(pikkuFuncId, {
@@ -308,8 +336,8 @@ export const addWorkflow = (logger, node, checker, state) => {
308
336
  const sink = steps.length > 0 ? [] : steps;
309
337
  getWorkflowInvocations(resolvedFunc, checker, state, workflowName, sink);
310
338
  }
311
- // Actor names the flow's steps run as (user flows) — powers the console
312
- // personas view and the User Flow graph badges.
339
+ // Actor names the flow's steps run as (scenarios) — powers the console
340
+ // personas view and the Scenario graph badges.
313
341
  const actorNames = [
314
342
  ...new Set(steps
315
343
  .map((s) => ('actor' in s ? s.actor : undefined))
@@ -327,19 +355,19 @@ export const addWorkflow = (logger, node, checker, state) => {
327
355
  errors,
328
356
  tags,
329
357
  expose,
330
- userFlow: wrapperType === 'user-flow' ? true : undefined,
358
+ scenario: wrapperType === 'scenario' ? true : undefined,
331
359
  actors: actorNames.length > 0 ? actorNames : undefined,
332
360
  };
333
- // User flows are pure stories of remote RPCs (same rule as client-side CLI
361
+ // Scenarios are pure stories of remote RPCs (same rule as client-side CLI
334
362
  // renderers): the func may only destructure logger/config — everything else
335
363
  // must go through actor steps so the flow runs against the TARGET
336
364
  // environment, never local services.
337
365
  const funcMeta = state.functions.meta[pikkuFuncId];
338
- if (wrapperType === 'user-flow' && funcMeta?.services) {
366
+ if (wrapperType === 'scenario' && funcMeta?.services) {
339
367
  const disallowed = funcMeta.services.services.filter((svc) => svc !== 'logger' && svc !== 'config');
340
368
  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 ` +
369
+ logger.critical(ErrorCode.SCENARIO_HAS_SERVICES, `Scenario '${workflowName}' destructures services: ${disallowed.join(', ')}. ` +
370
+ `Scenarios may only use 'logger'/'config' — drive everything else through ` +
343
371
  `actor steps (workflow.do(step, rpc, data, { actor: actors.x })) so the flow ` +
344
372
  `runs against the target environment.`);
345
373
  return;
@@ -19,7 +19,8 @@ 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
+ SCENARIO_HAS_SERVICES = "PKU673",
23
+ EXPECT_EVENTUALLY_SCENARIO_ONLY = "PKU675",
23
24
  DYNAMIC_STEP_NAME = "PKU529",
24
25
  WORKFLOW_ORCHESTRATOR_NOT_CONFIGURED = "PKU600",
25
26
  INVALID_DSL_WORKFLOW = "PKU641",
@@ -21,7 +21,8 @@ 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
+ ErrorCode["SCENARIO_HAS_SERVICES"] = "PKU673";
25
+ ErrorCode["EXPECT_EVENTUALLY_SCENARIO_ONLY"] = "PKU675";
25
26
  ErrorCode["DYNAMIC_STEP_NAME"] = "PKU529";
26
27
  ErrorCode["WORKFLOW_ORCHESTRATOR_NOT_CONFIGURED"] = "PKU600";
27
28
  ErrorCode["INVALID_DSL_WORKFLOW"] = "PKU641";
@@ -4,7 +4,7 @@ import * as ts from 'typescript';
4
4
  */
5
5
  /**
6
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
+ * (both are RPC steps; expectEventually is the polling variant used by scenarios)
8
8
  */
9
9
  export declare function isWorkflowDoCall(node: ts.CallExpression, checker: ts.TypeChecker): boolean;
10
10
  /**
@@ -4,7 +4,7 @@ import * as ts from 'typescript';
4
4
  */
5
5
  /**
6
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
+ * (both are RPC steps; expectEventually is the polling variant used by scenarios)
8
8
  */
9
9
  export function isWorkflowDoCall(node, checker) {
10
10
  if (!ts.isPropertyAccessExpression(node.expression)) {
@@ -298,11 +298,11 @@ export function convertDslToGraph(workflowName, meta) {
298
298
  }
299
299
  const entryNodeIds = nodes.length > 0 ? [nodes[0].nodeId] : [];
300
300
  // Determine source type:
301
- // - userFlow: complex workflow whose steps run as actors (user flow)
301
+ // - scenario: complex workflow whose steps run as actors (scenario)
302
302
  // - dsl === true: pure DSL workflow, can be serialized
303
303
  // - dsl === false: complex workflow with inline steps, not serializable
304
- const source = meta.userFlow
305
- ? 'user-flow'
304
+ const source = meta.scenario
305
+ ? 'scenario'
306
306
  : meta.dsl === false
307
307
  ? 'complex'
308
308
  : 'dsl';
@@ -96,9 +96,9 @@ 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}) */
99
+ /** Scenario actor name this step runs as (workflow.do {actor: actors.x}) */
100
100
  actor?: string;
101
- /** True for workflow.expectEventually polling steps (user flows) */
101
+ /** True for workflow.expectEventually polling steps (scenarios) */
102
102
  expectEventually?: boolean;
103
103
  }
104
104
  /**
@@ -127,9 +127,9 @@ export declare const isFlowNode: (node: SerializedGraphNode) => node is FlowNode
127
127
  * - 'dsl': Pure DSL workflow (pikkuWorkflowFunc) - can be round-tripped to code
128
128
  * - 'complex': Complex workflow (pikkuWorkflowComplexFunc) - contains inline steps, not serializable
129
129
  * - 'graph': Graph-based workflow (pikkuWorkflowGraph)
130
- * - 'user-flow': User flow (pikkuUserFlow) - complex workflow whose steps run as actors
130
+ * - 'scenario': Scenario (pikkuScenario) - complex workflow whose steps run as actors
131
131
  */
132
- export type WorkflowSourceType = 'dsl' | 'complex' | 'graph' | 'user-flow';
132
+ export type WorkflowSourceType = 'dsl' | 'complex' | 'graph' | 'scenario';
133
133
  /**
134
134
  * Serialized workflow graph - the canonical JSON format
135
135
  */
@@ -146,7 +146,7 @@ export interface SerializedWorkflowGraph {
146
146
  description?: string;
147
147
  /** Tags for organization */
148
148
  tags?: string[];
149
- /** Actor names a user flow's steps run as */
149
+ /** Actor names a scenario's steps run as */
150
150
  actors?: string[];
151
151
  /** If true, workflow always executes inline without queues */
152
152
  inline?: boolean;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pikku/inspector",
3
- "version": "0.12.35",
3
+ "version": "0.12.36",
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.51",
38
+ "@pikku/core": "^0.12.52",
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",
@@ -358,11 +358,11 @@ export const addFunctions: AddWiring = (
358
358
  }
359
359
 
360
360
  // Match identifiers that contain both "pikku" and "func" (case insensitive),
361
- // plus pikkuUserFlow (a workflow wrapper without "func" in its name)
361
+ // plus pikkuScenario (a workflow wrapper without "func" in its name)
362
362
  const pikkuFuncPattern = /pikku.*func/i
363
363
  if (
364
364
  !pikkuFuncPattern.test(expression.text) &&
365
- expression.text !== 'pikkuUserFlow'
365
+ expression.text !== 'pikkuScenario'
366
366
  ) {
367
367
  return
368
368
  }
@@ -494,9 +494,15 @@ export const addFunctions: AddWiring = (
494
494
  approvalRequired = getPropertyValue(firstArg, 'approvalRequired') as
495
495
  | boolean
496
496
  | undefined
497
- workflowQueued = getPropertyValue(firstArg, 'workflowQueued') as boolean | undefined
498
- workflowRetries = getPropertyValue(firstArg, 'workflowRetries') as number | undefined
499
- workflowTimeout = getPropertyValue(firstArg, 'workflowTimeout') as string | undefined
497
+ workflowQueued = getPropertyValue(firstArg, 'workflowQueued') as
498
+ | boolean
499
+ | undefined
500
+ workflowRetries = getPropertyValue(firstArg, 'workflowRetries') as
501
+ | number
502
+ | undefined
503
+ workflowTimeout = getPropertyValue(firstArg, 'workflowTimeout') as
504
+ | string
505
+ | undefined
500
506
 
501
507
  // Extract approvalDescription identifier reference
502
508
  for (const prop of firstArg.properties) {
@@ -34,6 +34,28 @@ function extractStepName(node: ts.Node, checker: ts.TypeChecker): string {
34
34
  }
35
35
  }
36
36
 
37
+ /**
38
+ * Walk a function body for any `X.expectEventually(...)` call. Used to enforce
39
+ * that the durable-polling assertion is a scenario-only primitive — regular
40
+ * workflows must not carry test/assertion semantics.
41
+ */
42
+ function containsExpectEventuallyCall(node: ts.Node): boolean {
43
+ if (
44
+ ts.isCallExpression(node) &&
45
+ ts.isPropertyAccessExpression(node.expression) &&
46
+ node.expression.name.text === 'expectEventually'
47
+ ) {
48
+ return true
49
+ }
50
+ let found = false
51
+ ts.forEachChild(node, (child) => {
52
+ if (!found && !ts.isFunctionDeclaration(child)) {
53
+ found = containsExpectEventuallyCall(child)
54
+ }
55
+ })
56
+ return found
57
+ }
58
+
37
59
  /**
38
60
  * Recursively check if any step has inline type (non-serializable)
39
61
  */
@@ -155,7 +177,9 @@ function getWorkflowInvocations(
155
177
  stepName,
156
178
  rpcName,
157
179
  expectEventually: true,
158
- actor: extractActorFromOptions(args.length >= 5 ? args[4] : undefined),
180
+ actor: extractActorFromOptions(
181
+ args.length >= 5 ? args[4] : undefined
182
+ ),
159
183
  })
160
184
  state.rpc.invokedFunctions.add(rpcName)
161
185
  }
@@ -194,7 +218,7 @@ function getWorkflowInvocations(
194
218
 
195
219
  /**
196
220
  * Inspector for pikkuWorkflowFunc(), pikkuWorkflowComplexFunc() and
197
- * pikkuUserFlow() calls. Detects workflow registration and extracts metadata.
221
+ * pikkuScenario() calls. Detects workflow registration and extracts metadata.
198
222
  */
199
223
  export const addWorkflow: AddWiring = (logger, node, checker, state) => {
200
224
  if (!ts.isCallExpression(node)) {
@@ -209,15 +233,15 @@ export const addWorkflow: AddWiring = (logger, node, checker, state) => {
209
233
  return
210
234
  }
211
235
 
212
- let wrapperType: 'dsl' | 'complex' | 'user-flow' | null = null
236
+ let wrapperType: 'dsl' | 'complex' | 'scenario' | null = null
213
237
  if (expression.text === 'pikkuWorkflowFunc') {
214
238
  wrapperType = 'dsl'
215
239
  } else if (expression.text === 'pikkuWorkflowComplexFunc') {
216
240
  wrapperType = 'complex'
217
- } else if (expression.text === 'pikkuUserFlow') {
218
- // A user flow is a complex workflow whose steps run as actors over the
241
+ } else if (expression.text === 'pikkuScenario') {
242
+ // A scenario is a complex workflow whose steps run as actors over the
219
243
  // real transport — same extraction rules as complex, distinct meta.
220
- wrapperType = 'user-flow'
244
+ wrapperType = 'scenario'
221
245
  } else {
222
246
  return
223
247
  }
@@ -292,6 +316,21 @@ export const addWorkflow: AddWiring = (logger, node, checker, state) => {
292
316
  return
293
317
  }
294
318
 
319
+ // expectEventually is a scenario-only assertion primitive — regular
320
+ // workflows must stay free of test/eval semantics.
321
+ if (
322
+ wrapperType !== 'scenario' &&
323
+ containsExpectEventuallyCall(resolvedFunc)
324
+ ) {
325
+ logger.critical(
326
+ ErrorCode.EXPECT_EVENTUALLY_SCENARIO_ONLY,
327
+ `Workflow '${workflowName}' calls workflow.expectEventually(), which is only ` +
328
+ `available in scenarios (pikkuScenario). Move it into a scenario, or drive ` +
329
+ `the assertion outside the workflow.`
330
+ )
331
+ return
332
+ }
333
+
295
334
  // Track workflow file for wiring generation
296
335
  if (exportedName) {
297
336
  state.workflows.files.set(pikkuFuncId, {
@@ -368,8 +407,8 @@ export const addWorkflow: AddWiring = (logger, node, checker, state) => {
368
407
  getWorkflowInvocations(resolvedFunc, checker, state, workflowName, sink)
369
408
  }
370
409
 
371
- // Actor names the flow's steps run as (user flows) — powers the console
372
- // personas view and the User Flow graph badges.
410
+ // Actor names the flow's steps run as (scenarios) — powers the console
411
+ // personas view and the Scenario graph badges.
373
412
  const actorNames = [
374
413
  ...new Set(
375
414
  steps
@@ -390,24 +429,24 @@ export const addWorkflow: AddWiring = (logger, node, checker, state) => {
390
429
  errors,
391
430
  tags,
392
431
  expose,
393
- userFlow: wrapperType === 'user-flow' ? true : undefined,
432
+ scenario: wrapperType === 'scenario' ? true : undefined,
394
433
  actors: actorNames.length > 0 ? actorNames : undefined,
395
434
  }
396
435
 
397
- // User flows are pure stories of remote RPCs (same rule as client-side CLI
436
+ // Scenarios are pure stories of remote RPCs (same rule as client-side CLI
398
437
  // renderers): the func may only destructure logger/config — everything else
399
438
  // must go through actor steps so the flow runs against the TARGET
400
439
  // environment, never local services.
401
440
  const funcMeta = state.functions.meta[pikkuFuncId]
402
- if (wrapperType === 'user-flow' && funcMeta?.services) {
441
+ if (wrapperType === 'scenario' && funcMeta?.services) {
403
442
  const disallowed = funcMeta.services.services.filter(
404
443
  (svc) => svc !== 'logger' && svc !== 'config'
405
444
  )
406
445
  if (disallowed.length > 0) {
407
446
  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 ` +
447
+ ErrorCode.SCENARIO_HAS_SERVICES,
448
+ `Scenario '${workflowName}' destructures services: ${disallowed.join(', ')}. ` +
449
+ `Scenarios may only use 'logger'/'config' — drive everything else through ` +
411
450
  `actor steps (workflow.do(step, rpc, data, { actor: actors.x })) so the flow ` +
412
451
  `runs against the target environment.`
413
452
  )
@@ -0,0 +1,108 @@
1
+ import { strict as assert } from 'assert'
2
+ import { describe, test } from 'node:test'
3
+ import { mkdtemp, rm, writeFile } from 'node:fs/promises'
4
+ import { tmpdir } from 'node:os'
5
+ import { join } from 'node:path'
6
+ import { inspect } from '../inspector.js'
7
+ import type { InspectorLogger } from '../types.js'
8
+
9
+ function makeLogger(
10
+ criticals: Array<{ code: string; message: string }>
11
+ ): InspectorLogger {
12
+ return {
13
+ debug: () => {},
14
+ info: () => {},
15
+ warn: () => {},
16
+ error: () => {},
17
+ diagnostic: ({ code, message }) => {
18
+ criticals.push({ code, message })
19
+ },
20
+ critical: (code: any, message: string) => {
21
+ criticals.push({ code, message })
22
+ },
23
+ hasCriticalErrors: () => criticals.length > 0,
24
+ }
25
+ }
26
+
27
+ const stepFile = (dir: string) => join(dir, 'my.steps.ts')
28
+ const stepSource = [
29
+ "import { pikkuSessionlessFunc } from '@pikku/core'",
30
+ 'export const getTodos = pikkuSessionlessFunc({',
31
+ ' func: async ({ logger }) => [{ done: true }],',
32
+ '})',
33
+ ].join('\n')
34
+
35
+ describe('expectEventually is scenario-only', () => {
36
+ test('pikkuWorkflowFunc calling expectEventually is a critical error pointing at pikkuScenario', async () => {
37
+ const rootDir = await mkdtemp(join(tmpdir(), 'pikku-ee-wf-'))
38
+ const wfFile = join(rootDir, 'my.workflow.ts')
39
+ await writeFile(stepFile(rootDir), stepSource)
40
+ await writeFile(
41
+ wfFile,
42
+ [
43
+ "import { pikkuWorkflowFunc } from '@pikku/core/workflow'",
44
+ 'export const badWorkflow = pikkuWorkflowFunc(async (_, _input, { workflow }) => {',
45
+ " await workflow.expectEventually('Wait', 'getTodos', {}, (todos: any) => todos.length > 0)",
46
+ ' return { ok: true }',
47
+ '})',
48
+ ].join('\n')
49
+ )
50
+
51
+ const criticals: Array<{ code: string; message: string }> = []
52
+ try {
53
+ await inspect(makeLogger(criticals), [stepFile(rootDir), wfFile], {
54
+ rootDir,
55
+ })
56
+ const pku675 = criticals.find((c) => c.code === 'PKU675')
57
+ assert.ok(
58
+ pku675,
59
+ `expected a PKU675 critical, got: ${JSON.stringify(criticals)}`
60
+ )
61
+ assert.ok(
62
+ pku675.message.includes('pikkuScenario'),
63
+ `PKU675 should point users at pikkuScenario, got: ${pku675.message}`
64
+ )
65
+ } finally {
66
+ await rm(rootDir, { recursive: true, force: true })
67
+ }
68
+ })
69
+
70
+ test('pikkuScenario calling expectEventually is allowed and flagged as a scenario', async () => {
71
+ const rootDir = await mkdtemp(join(tmpdir(), 'pikku-ee-sc-'))
72
+ const wfFile = join(rootDir, 'my.scenario.ts')
73
+ await writeFile(stepFile(rootDir), stepSource)
74
+ await writeFile(
75
+ wfFile,
76
+ [
77
+ "import { pikkuScenario } from '@pikku/core/workflow'",
78
+ 'declare const actors: Record<string, any>',
79
+ 'export const goodFlow = pikkuScenario(async (_, _input, { workflow }) => {',
80
+ " await workflow.expectEventually('Wait', 'getTodos', {}, (todos: any) => todos.length > 0, { actor: actors.pm })",
81
+ ' return { ok: true }',
82
+ '})',
83
+ ].join('\n')
84
+ )
85
+
86
+ const criticals: Array<{ code: string; message: string }> = []
87
+ try {
88
+ const state = await inspect(
89
+ makeLogger(criticals),
90
+ [stepFile(rootDir), wfFile],
91
+ { rootDir }
92
+ )
93
+ assert.ok(
94
+ !criticals.some((c) => c.code === 'PKU675'),
95
+ `scenarios may use expectEventually; got: ${JSON.stringify(criticals)}`
96
+ )
97
+ const meta = (state.workflows.meta as any).goodFlow
98
+ assert.ok(meta, 'pikkuScenario export should register a workflow')
99
+ assert.equal(
100
+ meta.scenario,
101
+ true,
102
+ `scenario meta flag should be true, got: ${JSON.stringify(meta)}`
103
+ )
104
+ } finally {
105
+ await rm(rootDir, { recursive: true, force: true })
106
+ }
107
+ })
108
+ })
@@ -21,7 +21,8 @@ export enum ErrorCode {
21
21
  MISSING_QUEUE_NAME = 'PKU384',
22
22
  MISSING_CHANNEL_NAME = 'PKU400',
23
23
  CLI_CLIENTSIDE_RENDERER_HAS_SERVICES = 'PKU672',
24
- USER_FLOW_HAS_SERVICES = 'PKU673',
24
+ SCENARIO_HAS_SERVICES = 'PKU673',
25
+ EXPECT_EVENTUALLY_SCENARIO_ONLY = 'PKU675',
25
26
  DYNAMIC_STEP_NAME = 'PKU529',
26
27
  WORKFLOW_ORCHESTRATOR_NOT_CONFIGURED = 'PKU600',
27
28
  INVALID_DSL_WORKFLOW = 'PKU641',
@@ -6,7 +6,7 @@ import * as ts from 'typescript'
6
6
 
7
7
  /**
8
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
+ * (both are RPC steps; expectEventually is the polling variant used by scenarios)
10
10
  */
11
11
  export function isWorkflowDoCall(
12
12
  node: ts.CallExpression,
@@ -396,11 +396,11 @@ export function convertDslToGraph(
396
396
  const entryNodeIds = nodes.length > 0 ? [nodes[0].nodeId] : []
397
397
 
398
398
  // Determine source type:
399
- // - userFlow: complex workflow whose steps run as actors (user flow)
399
+ // - scenario: complex workflow whose steps run as actors (scenario)
400
400
  // - dsl === true: pure DSL workflow, can be serialized
401
401
  // - dsl === false: complex workflow with inline steps, not serializable
402
- const source = meta.userFlow
403
- ? 'user-flow'
402
+ const source = meta.scenario
403
+ ? 'scenario'
404
404
  : meta.dsl === false
405
405
  ? 'complex'
406
406
  : 'dsl'
@@ -142,9 +142,9 @@ 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}) */
145
+ /** Scenario actor name this step runs as (workflow.do {actor: actors.x}) */
146
146
  actor?: string
147
- /** True for workflow.expectEventually polling steps (user flows) */
147
+ /** True for workflow.expectEventually polling steps (scenarios) */
148
148
  expectEventually?: boolean
149
149
  }
150
150
 
@@ -181,9 +181,9 @@ export const isFlowNode = (node: SerializedGraphNode): node is FlowNode =>
181
181
  * - 'dsl': Pure DSL workflow (pikkuWorkflowFunc) - can be round-tripped to code
182
182
  * - 'complex': Complex workflow (pikkuWorkflowComplexFunc) - contains inline steps, not serializable
183
183
  * - 'graph': Graph-based workflow (pikkuWorkflowGraph)
184
- * - 'user-flow': User flow (pikkuUserFlow) - complex workflow whose steps run as actors
184
+ * - 'scenario': Scenario (pikkuScenario) - complex workflow whose steps run as actors
185
185
  */
186
- export type WorkflowSourceType = 'dsl' | 'complex' | 'graph' | 'user-flow'
186
+ export type WorkflowSourceType = 'dsl' | 'complex' | 'graph' | 'scenario'
187
187
 
188
188
  /**
189
189
  * Serialized workflow graph - the canonical JSON format
@@ -201,7 +201,7 @@ export interface SerializedWorkflowGraph {
201
201
  description?: string
202
202
  /** Tags for organization */
203
203
  tags?: string[]
204
- /** Actor names a user flow's steps run as */
204
+ /** Actor names a scenario's steps run as */
205
205
  actors?: string[]
206
206
  /** If true, workflow always executes inline without queues */
207
207
  inline?: boolean