@pikku/inspector 0.12.35 → 0.12.37

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,58 @@
1
+ ## 0.12.37
2
+
3
+ ### Patch Changes
4
+
5
+ - efb0406: Add in-process V8 precise coverage (`pikku dev --coverage` / `pikku serve --coverage`) with per-scenario attribution.
6
+ - `@pikku/core`: new `V8CoverageService` (node:inspector precise coverage with snapshot + reset), exposed as the optional `coverageService` singleton service.
7
+ - `@pikku/inspector`: function meta now records `bodyStart`/`bodyEnd` body spans (verbose meta only) so coverage can be mapped without a runtime TypeScript dependency.
8
+ - `@pikku/cli`: `--coverage` on `pikku dev` and `pikku serve` starts the collector in-process; `pikku scenario run --coverage` resets/snapshots the server between flows and writes `.pikku/coverage/scenario-coverage.json` with per-scenario function coverage.
9
+ - `@pikku/addon-console`: new exposed `takeLiveCoverage` / `resetLiveCoverage` RPCs; V8 ranges are mapped through inline source maps to original TypeScript lines (offset-based, so esbuild/tsx single-line output keeps full resolution).
10
+
11
+ - Updated dependencies [efb0406]
12
+ - Updated dependencies [fe4f5ca]
13
+ - @pikku/core@0.12.53
14
+
15
+ ## 0.12.36
16
+
17
+ ### Patch Changes
18
+
19
+ - 61c9ce9: Add `actor.converse(...)` — actor agents for user journeys (#850)
20
+
21
+ An actor can now hold a dynamic, LLM-driven conversation with a target Pikku AI
22
+ agent in its own persona:
23
+
24
+ ```ts
25
+ const verdict = await actors.pm.converse({
26
+ agent: 'todoBot',
27
+ task: 'Get a todo created for the launch',
28
+ evaluate: 'A todo about the launch now exists',
29
+ })
30
+ // verdict: { passed, reasoning, transcript }
31
+ // then assert deterministically as the same actor:
32
+ const todos = await actors.pm.invoke('listTodos', {})
33
+ ```
34
+
35
+ The actor drives the target over the real transport (the agent's own
36
+ `agentRun` / `agentApprove` HTTP routes, signed in as the actor), plays the
37
+ persona from its `pikku.config.json` config, answers the agent's tool-approval
38
+ requests in-persona (`approvals: 'in-persona' | 'always' | 'never'`), and
39
+ returns its verdict on whether the task was met. Deterministic checks stay the
40
+ caller's job — they already hold the actor.
41
+
42
+ The conversation engine is transport-agnostic (persona LLM + injected target
43
+ driver); the persona's own turns run in-process via the configured
44
+ `aiAgentRunner` (`model` from the call or the actors-service default).
45
+
46
+ `agent` is typed against the generated agent-name union (`keyof AgentMap`), so
47
+ it's author-time checked and autocompleted in a typed project.
48
+
49
+ - 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.
50
+ - Updated dependencies [61c9ce9]
51
+ - Updated dependencies [f1f39f8]
52
+ - Updated dependencies [c45e98d]
53
+ - Updated dependencies [472a349]
54
+ - @pikku/core@0.12.52
55
+
1
56
  ## 0.12.35
2
57
 
3
58
  ### 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(...)
@@ -727,6 +727,21 @@ export const addFunctions = (logger, node, checker, state, options) => {
727
727
  objectNode,
728
728
  isDirectFunction,
729
729
  });
730
+ const handlerSourceFile = handler.getSourceFile();
731
+ const bodySourceFile = handlerSourceFile.fileName !== sourceFile
732
+ ? handlerSourceFile.fileName
733
+ : undefined;
734
+ const lineOf = (pos) => handlerSourceFile.getLineAndCharacterOfPosition(pos).line + 1;
735
+ const handlerBody = handler.body;
736
+ const bodySpan = ts.isBlock(handlerBody) && handlerBody.statements.length > 0
737
+ ? {
738
+ bodyStart: lineOf(handlerBody.statements[0].getStart(handlerSourceFile)),
739
+ bodyEnd: lineOf(handlerBody.statements[handlerBody.statements.length - 1].getEnd()),
740
+ }
741
+ : {
742
+ bodyStart: lineOf(handlerBody.getStart(handlerSourceFile)),
743
+ bodyEnd: lineOf(handlerBody.getEnd()),
744
+ };
730
745
  state.functions.meta[pikkuFuncId] = {
731
746
  pikkuFuncId,
732
747
  functionType: 'user',
@@ -760,7 +775,9 @@ export const addFunctions = (logger, node, checker, state, options) => {
760
775
  permissions,
761
776
  isDirectFunction,
762
777
  sourceFile,
778
+ bodySourceFile,
763
779
  exportedName: exportedName || undefined,
780
+ ...bodySpan,
764
781
  };
765
782
  // Populate node metadata if node config is present
766
783
  if (nodeDisplayName && nodeCategory && nodeType) {
@@ -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,13 @@ 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
+ if (wrapperType !== 'scenario' &&
267
+ containsExpectEventuallyCall(resolvedFunc)) {
268
+ logger.critical(ErrorCode.EXPECT_EVENTUALLY_SCENARIO_ONLY, `Workflow '${workflowName}' calls workflow.expectEventually(), which is only ` +
269
+ `available in scenarios (pikkuScenario). Move it into a scenario, or drive ` +
270
+ `the assertion outside the workflow.`);
271
+ return;
272
+ }
247
273
  // Track workflow file for wiring generation
248
274
  if (exportedName) {
249
275
  state.workflows.files.set(pikkuFuncId, {
@@ -308,8 +334,8 @@ export const addWorkflow = (logger, node, checker, state) => {
308
334
  const sink = steps.length > 0 ? [] : steps;
309
335
  getWorkflowInvocations(resolvedFunc, checker, state, workflowName, sink);
310
336
  }
311
- // Actor names the flow's steps run as (user flows) — powers the console
312
- // personas view and the User Flow graph badges.
337
+ // Actor names the flow's steps run as (scenarios) — powers the console
338
+ // personas view and the Scenario graph badges.
313
339
  const actorNames = [
314
340
  ...new Set(steps
315
341
  .map((s) => ('actor' in s ? s.actor : undefined))
@@ -327,19 +353,19 @@ export const addWorkflow = (logger, node, checker, state) => {
327
353
  errors,
328
354
  tags,
329
355
  expose,
330
- userFlow: wrapperType === 'user-flow' ? true : undefined,
356
+ scenario: wrapperType === 'scenario' ? true : undefined,
331
357
  actors: actorNames.length > 0 ? actorNames : undefined,
332
358
  };
333
- // User flows are pure stories of remote RPCs (same rule as client-side CLI
359
+ // Scenarios are pure stories of remote RPCs (same rule as client-side CLI
334
360
  // renderers): the func may only destructure logger/config — everything else
335
361
  // must go through actor steps so the flow runs against the TARGET
336
362
  // environment, never local services.
337
363
  const funcMeta = state.functions.meta[pikkuFuncId];
338
- if (wrapperType === 'user-flow' && funcMeta?.services) {
364
+ if (wrapperType === 'scenario' && funcMeta?.services) {
339
365
  const disallowed = funcMeta.services.services.filter((svc) => svc !== 'logger' && svc !== 'config');
340
366
  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 ` +
367
+ logger.critical(ErrorCode.SCENARIO_HAS_SERVICES, `Scenario '${workflowName}' destructures services: ${disallowed.join(', ')}. ` +
368
+ `Scenarios may only use 'logger'/'config' — drive everything else through ` +
343
369
  `actor steps (workflow.do(step, rpc, data, { actor: actors.x })) so the flow ` +
344
370
  `runs against the target environment.`);
345
371
  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.37",
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.53",
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) {
@@ -1016,6 +1022,29 @@ export const addFunctions: AddWiring = (
1016
1022
  isDirectFunction,
1017
1023
  })
1018
1024
 
1025
+ const handlerSourceFile = handler.getSourceFile()
1026
+ const bodySourceFile =
1027
+ handlerSourceFile.fileName !== sourceFile
1028
+ ? handlerSourceFile.fileName
1029
+ : undefined
1030
+ const lineOf = (pos: number) =>
1031
+ handlerSourceFile.getLineAndCharacterOfPosition(pos).line + 1
1032
+ const handlerBody = handler.body
1033
+ const bodySpan =
1034
+ ts.isBlock(handlerBody) && handlerBody.statements.length > 0
1035
+ ? {
1036
+ bodyStart: lineOf(
1037
+ handlerBody.statements[0].getStart(handlerSourceFile)
1038
+ ),
1039
+ bodyEnd: lineOf(
1040
+ handlerBody.statements[handlerBody.statements.length - 1].getEnd()
1041
+ ),
1042
+ }
1043
+ : {
1044
+ bodyStart: lineOf(handlerBody.getStart(handlerSourceFile)),
1045
+ bodyEnd: lineOf(handlerBody.getEnd()),
1046
+ }
1047
+
1019
1048
  state.functions.meta[pikkuFuncId] = {
1020
1049
  pikkuFuncId,
1021
1050
  functionType: 'user',
@@ -1049,7 +1078,9 @@ export const addFunctions: AddWiring = (
1049
1078
  permissions,
1050
1079
  isDirectFunction,
1051
1080
  sourceFile,
1081
+ bodySourceFile,
1052
1082
  exportedName: exportedName || undefined,
1083
+ ...bodySpan,
1053
1084
  }
1054
1085
 
1055
1086
  // Populate node metadata if node config is present
@@ -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,19 @@ export const addWorkflow: AddWiring = (logger, node, checker, state) => {
292
316
  return
293
317
  }
294
318
 
319
+ if (
320
+ wrapperType !== 'scenario' &&
321
+ containsExpectEventuallyCall(resolvedFunc)
322
+ ) {
323
+ logger.critical(
324
+ ErrorCode.EXPECT_EVENTUALLY_SCENARIO_ONLY,
325
+ `Workflow '${workflowName}' calls workflow.expectEventually(), which is only ` +
326
+ `available in scenarios (pikkuScenario). Move it into a scenario, or drive ` +
327
+ `the assertion outside the workflow.`
328
+ )
329
+ return
330
+ }
331
+
295
332
  // Track workflow file for wiring generation
296
333
  if (exportedName) {
297
334
  state.workflows.files.set(pikkuFuncId, {
@@ -368,8 +405,8 @@ export const addWorkflow: AddWiring = (logger, node, checker, state) => {
368
405
  getWorkflowInvocations(resolvedFunc, checker, state, workflowName, sink)
369
406
  }
370
407
 
371
- // Actor names the flow's steps run as (user flows) — powers the console
372
- // personas view and the User Flow graph badges.
408
+ // Actor names the flow's steps run as (scenarios) — powers the console
409
+ // personas view and the Scenario graph badges.
373
410
  const actorNames = [
374
411
  ...new Set(
375
412
  steps
@@ -390,24 +427,24 @@ export const addWorkflow: AddWiring = (logger, node, checker, state) => {
390
427
  errors,
391
428
  tags,
392
429
  expose,
393
- userFlow: wrapperType === 'user-flow' ? true : undefined,
430
+ scenario: wrapperType === 'scenario' ? true : undefined,
394
431
  actors: actorNames.length > 0 ? actorNames : undefined,
395
432
  }
396
433
 
397
- // User flows are pure stories of remote RPCs (same rule as client-side CLI
434
+ // Scenarios are pure stories of remote RPCs (same rule as client-side CLI
398
435
  // renderers): the func may only destructure logger/config — everything else
399
436
  // must go through actor steps so the flow runs against the TARGET
400
437
  // environment, never local services.
401
438
  const funcMeta = state.functions.meta[pikkuFuncId]
402
- if (wrapperType === 'user-flow' && funcMeta?.services) {
439
+ if (wrapperType === 'scenario' && funcMeta?.services) {
403
440
  const disallowed = funcMeta.services.services.filter(
404
441
  (svc) => svc !== 'logger' && svc !== 'config'
405
442
  )
406
443
  if (disallowed.length > 0) {
407
444
  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 ` +
445
+ ErrorCode.SCENARIO_HAS_SERVICES,
446
+ `Scenario '${workflowName}' destructures services: ${disallowed.join(', ')}. ` +
447
+ `Scenarios may only use 'logger'/'config' — drive everything else through ` +
411
448
  `actor steps (workflow.do(step, rpc, data, { actor: actors.x })) so the flow ` +
412
449
  `runs against the target environment.`
413
450
  )
@@ -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
+ })