@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 +55 -0
- package/dist/add/add-functions.js +19 -2
- package/dist/add/add-workflow.d.ts +1 -1
- package/dist/add/add-workflow.js +37 -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 +36 -5
- package/src/add/add-workflow.ts +51 -14
- package/src/add/expect-eventually-scenario-only.test.ts +108 -0
- package/src/add/function-body-span.test.ts +96 -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,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
|
|
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(...)
|
|
@@ -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
|
-
*
|
|
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,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 (
|
|
312
|
-
// personas view and the
|
|
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
|
-
|
|
356
|
+
scenario: wrapperType === 'scenario' ? true : undefined,
|
|
331
357
|
actors: actorNames.length > 0 ? actorNames : undefined,
|
|
332
358
|
};
|
|
333
|
-
//
|
|
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 === '
|
|
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.
|
|
342
|
-
`
|
|
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;
|
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.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.
|
|
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",
|
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) {
|
|
@@ -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
|
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,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 (
|
|
372
|
-
// personas view and the
|
|
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
|
-
|
|
430
|
+
scenario: wrapperType === 'scenario' ? true : undefined,
|
|
394
431
|
actors: actorNames.length > 0 ? actorNames : undefined,
|
|
395
432
|
}
|
|
396
433
|
|
|
397
|
-
//
|
|
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 === '
|
|
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.
|
|
409
|
-
`
|
|
410
|
-
`
|
|
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
|
+
})
|