@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 +41 -0
- package/dist/add/add-functions.js +2 -2
- package/dist/add/add-workflow.d.ts +1 -1
- package/dist/add/add-workflow.js +39 -11
- package/dist/error-codes.d.ts +2 -1
- package/dist/error-codes.js +2 -1
- package/dist/utils/workflow/dsl/patterns.d.ts +1 -1
- package/dist/utils/workflow/dsl/patterns.js +1 -1
- package/dist/utils/workflow/graph/convert-dsl-to-graph.js +3 -3
- package/dist/utils/workflow/graph/workflow-graph.types.d.ts +5 -5
- package/package.json +2 -2
- package/src/add/add-functions.ts +11 -5
- package/src/add/add-workflow.ts +53 -14
- package/src/add/expect-eventually-scenario-only.test.ts +108 -0
- package/src/error-codes.ts +2 -1
- package/src/utils/workflow/dsl/patterns.ts +1 -1
- package/src/utils/workflow/graph/convert-dsl-to-graph.ts +3 -3
- package/src/utils/workflow/graph/workflow-graph.types.ts +5 -5
- package/tsconfig.tsbuildinfo +1 -1
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
|
|
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 !== '
|
|
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
|
-
*
|
|
9
|
+
* pikkuScenario() calls. Detects workflow registration and extracts metadata.
|
|
10
10
|
*/
|
|
11
11
|
export declare const addWorkflow: AddWiring;
|
package/dist/add/add-workflow.js
CHANGED
|
@@ -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
|
-
*
|
|
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 === '
|
|
200
|
-
// A
|
|
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 = '
|
|
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 (
|
|
312
|
-
// personas view and the
|
|
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
|
-
|
|
358
|
+
scenario: wrapperType === 'scenario' ? true : undefined,
|
|
331
359
|
actors: actorNames.length > 0 ? actorNames : undefined,
|
|
332
360
|
};
|
|
333
|
-
//
|
|
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 === '
|
|
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.
|
|
342
|
-
`
|
|
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;
|
package/dist/error-codes.d.ts
CHANGED
|
@@ -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
|
-
|
|
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",
|
package/dist/error-codes.js
CHANGED
|
@@ -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["
|
|
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
|
|
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
|
|
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
|
-
// -
|
|
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.
|
|
305
|
-
? '
|
|
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
|
-
/**
|
|
99
|
+
/** Scenario actor name this step runs as (workflow.do {actor: actors.x}) */
|
|
100
100
|
actor?: string;
|
|
101
|
-
/** True for workflow.expectEventually polling steps (
|
|
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
|
-
* - '
|
|
130
|
+
* - 'scenario': Scenario (pikkuScenario) - complex workflow whose steps run as actors
|
|
131
131
|
*/
|
|
132
|
-
export type WorkflowSourceType = 'dsl' | 'complex' | 'graph' | '
|
|
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
|
|
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.
|
|
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.
|
|
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",
|
package/src/add/add-functions.ts
CHANGED
|
@@ -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
|
|
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 !== '
|
|
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
|
|
498
|
-
|
|
499
|
-
|
|
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) {
|
package/src/add/add-workflow.ts
CHANGED
|
@@ -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(
|
|
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
|
-
*
|
|
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' | '
|
|
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 === '
|
|
218
|
-
// A
|
|
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 = '
|
|
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 (
|
|
372
|
-
// personas view and the
|
|
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
|
-
|
|
432
|
+
scenario: wrapperType === 'scenario' ? true : undefined,
|
|
394
433
|
actors: actorNames.length > 0 ? actorNames : undefined,
|
|
395
434
|
}
|
|
396
435
|
|
|
397
|
-
//
|
|
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 === '
|
|
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.
|
|
409
|
-
`
|
|
410
|
-
`
|
|
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
|
+
})
|
package/src/error-codes.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
-
// -
|
|
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.
|
|
403
|
-
? '
|
|
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
|
-
/**
|
|
145
|
+
/** Scenario actor name this step runs as (workflow.do {actor: actors.x}) */
|
|
146
146
|
actor?: string
|
|
147
|
-
/** True for workflow.expectEventually polling steps (
|
|
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
|
-
* - '
|
|
184
|
+
* - 'scenario': Scenario (pikkuScenario) - complex workflow whose steps run as actors
|
|
185
185
|
*/
|
|
186
|
-
export type WorkflowSourceType = 'dsl' | 'complex' | 'graph' | '
|
|
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
|
|
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
|