@pikku/core 0.12.69 → 0.12.70
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 +334 -0
- package/README.md +34 -2
- package/dist/function/functions.types.d.ts +27 -0
- package/dist/index.d.ts +1 -1
- package/dist/internal.d.ts +1 -1
- package/dist/internal.js +1 -1
- package/dist/pikku-state.js +1 -0
- package/dist/services/http-scenario-actors.d.ts +12 -4
- package/dist/services/http-scenario-actors.js +47 -45
- package/dist/services/index.d.ts +2 -1
- package/dist/services/index.js +1 -0
- package/dist/services/meta-service.d.ts +5 -1
- package/dist/services/meta-service.js +44 -18
- package/dist/services/scenario-actors-service.d.ts +108 -2
- package/dist/services/scenario-actors-service.js +40 -1
- package/dist/types/core.types.d.ts +21 -3
- package/dist/types/state.types.d.ts +3 -1
- package/dist/wirings/actor-flow/actor-flow.types.d.ts +1 -1
- package/dist/wirings/actor-flow/index.d.ts +1 -1
- package/dist/wirings/actor-flow/run-conversation.d.ts +10 -10
- package/dist/wirings/actor-flow/run-conversation.js +27 -27
- package/dist/wirings/cli/command-parser.js +11 -1
- package/dist/wirings/rpc/rpc-runner.js +1 -1
- package/dist/wirings/workflow/dsl/workflow-dsl.types.d.ts +52 -3
- package/dist/wirings/workflow/feature.d.ts +28 -0
- package/dist/wirings/workflow/feature.js +57 -0
- package/dist/wirings/workflow/index.d.ts +13 -2
- package/dist/wirings/workflow/index.js +15 -0
- package/dist/wirings/workflow/pikku-scenario-service.d.ts +121 -0
- package/dist/wirings/workflow/pikku-scenario-service.js +419 -0
- package/dist/wirings/workflow/pikku-workflow-service.d.ts +103 -10
- package/dist/wirings/workflow/pikku-workflow-service.js +80 -135
- package/dist/wirings/workflow/scenario-cookie-jar.d.ts +29 -0
- package/dist/wirings/workflow/scenario-cookie-jar.js +51 -0
- package/dist/wirings/workflow/scenario-poll.d.ts +20 -0
- package/dist/wirings/workflow/scenario-poll.js +25 -0
- package/dist/wirings/workflow/scenario-prose.d.ts +38 -0
- package/dist/wirings/workflow/scenario-prose.js +45 -0
- package/dist/wirings/workflow/scenario-step-guards.d.ts +16 -0
- package/dist/wirings/workflow/scenario-step-guards.js +29 -0
- package/dist/wirings/workflow/scenario-step.types.d.ts +148 -0
- package/dist/wirings/workflow/scenario-step.types.js +1 -0
- package/dist/wirings/workflow/workflow.types.d.ts +81 -2
- package/package.json +3 -1
- package/src/function/functions.types.ts +32 -0
- package/src/index.ts +1 -0
- package/src/internal.ts +5 -1
- package/src/pikku-state.ts +1 -0
- package/src/services/http-scenario-actors.test.ts +85 -1
- package/src/services/http-scenario-actors.ts +65 -51
- package/src/services/index.ts +5 -0
- package/src/services/meta-service.test.ts +79 -0
- package/src/services/meta-service.ts +61 -26
- package/src/services/scenario-actors-service.ts +157 -2
- package/src/types/core.types.ts +27 -2
- package/src/types/state.types.ts +3 -0
- package/src/wirings/actor-flow/actor-flow.types.ts +1 -1
- package/src/wirings/actor-flow/index.ts +1 -1
- package/src/wirings/actor-flow/run-conversation.test.ts +12 -6
- package/src/wirings/actor-flow/run-conversation.ts +36 -41
- package/src/wirings/cli/command-parser.test.ts +60 -0
- package/src/wirings/cli/command-parser.ts +12 -1
- package/src/wirings/rpc/rpc-runner.test.ts +28 -5
- package/src/wirings/rpc/rpc-runner.ts +1 -1
- package/src/wirings/workflow/dsl/workflow-dsl.types.ts +86 -2
- package/src/wirings/workflow/feature.test.ts +131 -0
- package/src/wirings/workflow/feature.ts +78 -0
- package/src/wirings/workflow/index.ts +73 -0
- package/src/wirings/workflow/pikku-scenario-service.ts +682 -0
- package/src/wirings/workflow/pikku-workflow-service.test.ts +55 -0
- package/src/wirings/workflow/pikku-workflow-service.ts +196 -208
- package/src/wirings/workflow/scenario-cookie-jar.test.ts +108 -0
- package/src/wirings/workflow/scenario-cookie-jar.ts +65 -0
- package/src/wirings/workflow/scenario-hooks.test.ts +212 -0
- package/src/wirings/workflow/scenario-poll.test.ts +66 -0
- package/src/wirings/workflow/scenario-poll.ts +36 -0
- package/src/wirings/workflow/scenario-prose.test.ts +152 -0
- package/src/wirings/workflow/scenario-prose.ts +79 -0
- package/src/wirings/workflow/scenario-service.test.ts +155 -0
- package/src/wirings/workflow/scenario-step-guards.ts +43 -0
- package/src/wirings/workflow/scenario-step.test.ts +441 -8
- package/src/wirings/workflow/scenario-step.types.ts +157 -0
- package/src/wirings/workflow/workflow.types.ts +98 -1
- package/tsconfig.tsbuildinfo +1 -1
|
@@ -0,0 +1,419 @@
|
|
|
1
|
+
import { runPikkuFunc } from '../../function/function-runner.js';
|
|
2
|
+
import { getSingletonServices, getCreateWireServices, pikkuState, } from '../../pikku-state.js';
|
|
3
|
+
import { getDurationInMilliseconds } from '../../time-utils.js';
|
|
4
|
+
import { closeWireServices } from '../../utils.js';
|
|
5
|
+
import { PikkuError, addError } from '../../errors/error-handler.js';
|
|
6
|
+
import { InMemoryWorkflowService } from '../../services/in-memory-workflow-service.js';
|
|
7
|
+
import { runScheduledTask } from '../scheduler/scheduler-runner.js';
|
|
8
|
+
import { WorkflowStepNameNotString, } from './pikku-workflow-service.js';
|
|
9
|
+
/**
|
|
10
|
+
* A workflow service with the scenario capability attached — the two lines
|
|
11
|
+
* `pikku scenario run` needs, in one call so no caller has to remember that the
|
|
12
|
+
* capability is installed rather than inherited.
|
|
13
|
+
*
|
|
14
|
+
* The in-memory service is the right engine because a scenario run is a single
|
|
15
|
+
* external process driving a deployed app over its real transport: there is
|
|
16
|
+
* nothing to persist and no second worker to resume it.
|
|
17
|
+
*/
|
|
18
|
+
export const createScenarioRunner = (options = {}) => {
|
|
19
|
+
const workflowService = new InMemoryWorkflowService(options);
|
|
20
|
+
const scenarioService = workflowService.setRunExtension((engine) => new PikkuScenarioService(engine));
|
|
21
|
+
return { workflowService, scenarioService };
|
|
22
|
+
};
|
|
23
|
+
/**
|
|
24
|
+
* A scenario's `before` or `after` hook threw. The original error is kept as
|
|
25
|
+
* the `cause` so the failure that actually happened is never lost behind the
|
|
26
|
+
* label saying which phase it happened in.
|
|
27
|
+
*/
|
|
28
|
+
export class ScenarioHookError extends PikkuError {
|
|
29
|
+
scenarioName;
|
|
30
|
+
phase;
|
|
31
|
+
constructor(scenarioName, phase, cause) {
|
|
32
|
+
super(`Scenario '${scenarioName}' ${phase} hook failed: ${cause instanceof Error ? cause.message : String(cause)}`);
|
|
33
|
+
this.scenarioName = scenarioName;
|
|
34
|
+
this.phase = phase;
|
|
35
|
+
this.cause = cause;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
addError(ScenarioHookError, {
|
|
39
|
+
status: 500,
|
|
40
|
+
message: 'A scenario lifecycle hook failed.',
|
|
41
|
+
});
|
|
42
|
+
/**
|
|
43
|
+
* The scenario capability, layered onto a workflow service rather than being
|
|
44
|
+
* one.
|
|
45
|
+
*
|
|
46
|
+
* Every scenario affordance — steps, actors, lifecycle hooks, the browser
|
|
47
|
+
* provider, the assertion wire members — lives here rather than on
|
|
48
|
+
* `PikkuWorkflowService`, because a bundler drops an unused *module* but never
|
|
49
|
+
* an unused class member: anything declared on the workflow service ships in
|
|
50
|
+
* every server built on Pikku, along with everything it imports. Scenarios only
|
|
51
|
+
* ever run from `pikku scenario run`, so the whole surface stays behind an
|
|
52
|
+
* import only that runner makes.
|
|
53
|
+
*
|
|
54
|
+
* It is not a workflow service because a scenario is not a different kind of
|
|
55
|
+
* run — it is the same durable run with a step vocabulary on top. What it
|
|
56
|
+
* needs from the engine it gets through the narrow `WorkflowRunEngine` handle,
|
|
57
|
+
* which is why recording a step never became public API.
|
|
58
|
+
*
|
|
59
|
+
* ```ts
|
|
60
|
+
* const workflowService = new InMemoryWorkflowService()
|
|
61
|
+
* const scenarioService = workflowService.setRunExtension(
|
|
62
|
+
* (engine) => new PikkuScenarioService(engine)
|
|
63
|
+
* )
|
|
64
|
+
* ```
|
|
65
|
+
*/
|
|
66
|
+
export class PikkuScenarioService {
|
|
67
|
+
engine;
|
|
68
|
+
// Scenario actors per run: live authenticated clients (cookie jars) are
|
|
69
|
+
// process-local by nature, so they ride this map, never the persisted wire.
|
|
70
|
+
runActors = new Map();
|
|
71
|
+
scenarioBrowserProvider;
|
|
72
|
+
scenarioEnvironment;
|
|
73
|
+
constructor(engine) {
|
|
74
|
+
this.engine = engine;
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* Registered by `@pikku/playwright` (or any other driver) before a scenario
|
|
78
|
+
* runs. Absent means browser steps cannot run, which the CLI checks up front
|
|
79
|
+
* so a run fails fast rather than mid-flow.
|
|
80
|
+
*/
|
|
81
|
+
setScenarioBrowserProvider(provider) {
|
|
82
|
+
this.scenarioBrowserProvider = provider;
|
|
83
|
+
}
|
|
84
|
+
getScenarioBrowserProvider() {
|
|
85
|
+
return this.scenarioBrowserProvider;
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* The environment scenario steps run against, set once by the runner. It is
|
|
89
|
+
* per-service rather than per-run because a runner process targets exactly
|
|
90
|
+
* one environment for every scenario it executes.
|
|
91
|
+
*/
|
|
92
|
+
setScenarioEnvironment(env) {
|
|
93
|
+
this.scenarioEnvironment = env;
|
|
94
|
+
}
|
|
95
|
+
getScenarioEnvironment() {
|
|
96
|
+
return this.scenarioEnvironment;
|
|
97
|
+
}
|
|
98
|
+
async attachRunContext(runId, workflowMeta, options) {
|
|
99
|
+
const actors = options?.actors ??
|
|
100
|
+
(workflowMeta.source === 'scenario'
|
|
101
|
+
? await this.resolveScenarioActors()
|
|
102
|
+
: undefined);
|
|
103
|
+
if (actors) {
|
|
104
|
+
this.runActors.set(runId, actors);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
detachRunContext(runId) {
|
|
108
|
+
this.runActors.delete(runId);
|
|
109
|
+
}
|
|
110
|
+
decorateRunWire(wire, context) {
|
|
111
|
+
wire.scenario =
|
|
112
|
+
context.workflowMeta?.source === 'scenario'
|
|
113
|
+
? context.workflowWire
|
|
114
|
+
: undefined;
|
|
115
|
+
wire.actors = this.runActors.get(context.runId);
|
|
116
|
+
}
|
|
117
|
+
async onBeforeRunFunc(context) {
|
|
118
|
+
const hooks = this.scenarioHooks(context);
|
|
119
|
+
if (!hooks?.before)
|
|
120
|
+
return;
|
|
121
|
+
await this.runScenarioHook('before', context.workflowMeta.name, hooks.before, context.wire, context.run.input, context.packageName);
|
|
122
|
+
}
|
|
123
|
+
async onAfterRunFunc(context, outcome, failure) {
|
|
124
|
+
if (outcome === 'interrupted')
|
|
125
|
+
return;
|
|
126
|
+
const hooks = this.scenarioHooks(context);
|
|
127
|
+
if (!hooks?.after)
|
|
128
|
+
return;
|
|
129
|
+
const { runId, run, workflowMeta } = context;
|
|
130
|
+
try {
|
|
131
|
+
await this.runScenarioHook('after', workflowMeta.name, hooks.after, context.wire, run.input, context.packageName);
|
|
132
|
+
}
|
|
133
|
+
catch (hookError) {
|
|
134
|
+
if (outcome === 'failed') {
|
|
135
|
+
// The scenario already failed for its own reason; a teardown failure is
|
|
136
|
+
// diagnostic context, never the headline.
|
|
137
|
+
if (failure instanceof Error && failure.cause === undefined) {
|
|
138
|
+
failure.cause = hookError;
|
|
139
|
+
}
|
|
140
|
+
getSingletonServices()?.logger.error(`Scenario ${workflowMeta.name} (run ${runId}) failed, and its after hook also failed:`, hookError);
|
|
141
|
+
}
|
|
142
|
+
else {
|
|
143
|
+
await this.engine.updateRunStatus(runId, 'failed', undefined, {
|
|
144
|
+
message: hookError.message,
|
|
145
|
+
stack: hookError.stack,
|
|
146
|
+
code: hookError.code,
|
|
147
|
+
});
|
|
148
|
+
await this.engine.onChildWorkflowFailed(run, hookError);
|
|
149
|
+
throw hookError;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
/**
|
|
154
|
+
* Hooks are a scenario affordance only: a plain workflow is durable and
|
|
155
|
+
* resumable, so a callback that reruns on every replay has no honest meaning
|
|
156
|
+
* there.
|
|
157
|
+
*/
|
|
158
|
+
scenarioHooks(context) {
|
|
159
|
+
return context.workflowMeta.source === 'scenario'
|
|
160
|
+
? context.workflow.func
|
|
161
|
+
: undefined;
|
|
162
|
+
}
|
|
163
|
+
/**
|
|
164
|
+
* Run a scenario `before`/`after` hook.
|
|
165
|
+
*
|
|
166
|
+
* A hook is not a pikku function: it has no id, no meta and no schema, so it
|
|
167
|
+
* cannot go through `runPikkuFunc` and the runner records nothing for it. It
|
|
168
|
+
* gets exactly what the scenario body gets — the same wire (so `actors` is
|
|
169
|
+
* how it reaches the app), and singleton services composed with this
|
|
170
|
+
* invocation's wire services — and nothing else.
|
|
171
|
+
*/
|
|
172
|
+
async runScenarioHook(phase, scenarioName, hook, wire, data, packageName) {
|
|
173
|
+
const singletonServices = getSingletonServices();
|
|
174
|
+
let createWireServices = getCreateWireServices();
|
|
175
|
+
if (packageName) {
|
|
176
|
+
const factories = pikkuState(packageName, 'package', 'factories');
|
|
177
|
+
if (factories?.createWireServices) {
|
|
178
|
+
createWireServices = factories.createWireServices;
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
let wireServices;
|
|
182
|
+
try {
|
|
183
|
+
wireServices = (await createWireServices?.(singletonServices, wire));
|
|
184
|
+
const services = wireServices && Object.keys(wireServices).length > 0
|
|
185
|
+
? { ...singletonServices, ...wireServices }
|
|
186
|
+
: singletonServices;
|
|
187
|
+
await hook(services, data, wire);
|
|
188
|
+
}
|
|
189
|
+
catch (error) {
|
|
190
|
+
throw new ScenarioHookError(scenarioName, phase, error);
|
|
191
|
+
}
|
|
192
|
+
finally {
|
|
193
|
+
if (wireServices && Object.keys(wireServices).length > 0) {
|
|
194
|
+
await closeWireServices(singletonServices.logger, wireServices);
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
/**
|
|
199
|
+
* Build HTTP scenario actors for a run started without them; undefined when
|
|
200
|
+
* SCENARIO_ACTOR_SECRET or the API URL is missing.
|
|
201
|
+
*
|
|
202
|
+
* The actor client is imported lazily so that even a runner bundle only pays
|
|
203
|
+
* for the AI persona conversation loop it pulls in when a scenario actually
|
|
204
|
+
* signs an actor in.
|
|
205
|
+
*/
|
|
206
|
+
async resolveScenarioActors() {
|
|
207
|
+
const services = getSingletonServices();
|
|
208
|
+
const variables = services?.variables;
|
|
209
|
+
const metaService = services?.metaService;
|
|
210
|
+
if (!variables || !metaService) {
|
|
211
|
+
return undefined;
|
|
212
|
+
}
|
|
213
|
+
const secret = await variables.get('SCENARIO_ACTOR_SECRET');
|
|
214
|
+
const apiUrl = await variables.get('API_URL');
|
|
215
|
+
if (!secret || !apiUrl) {
|
|
216
|
+
services?.logger?.warn('A scenario was started without actors but SCENARIO_ACTOR_SECRET / API_URL is not configured — running without actors.');
|
|
217
|
+
return undefined;
|
|
218
|
+
}
|
|
219
|
+
const actorsConfig = await metaService.getScenarioActorsMeta();
|
|
220
|
+
if (!actorsConfig || Object.keys(actorsConfig).length === 0) {
|
|
221
|
+
return undefined;
|
|
222
|
+
}
|
|
223
|
+
const signInPath = (await variables.get('SCENARIO_SIGN_IN_PATH')) ??
|
|
224
|
+
'/api/auth/sign-in/actor';
|
|
225
|
+
const rpcPath = (await variables.get('SCENARIO_RPC_PATH')) ?? '/rpc';
|
|
226
|
+
// A run started outside the CLI still targets an environment — its own.
|
|
227
|
+
this.scenarioEnvironment ??= {
|
|
228
|
+
apiUrl,
|
|
229
|
+
appUrl: (await variables.get('APP_URL')) ?? undefined,
|
|
230
|
+
};
|
|
231
|
+
const { createHttpScenarioActors } = await import('../../services/http-scenario-actors.js');
|
|
232
|
+
return createHttpScenarioActors({
|
|
233
|
+
apiUrl,
|
|
234
|
+
secret,
|
|
235
|
+
actors: actorsConfig,
|
|
236
|
+
signInPath,
|
|
237
|
+
rpcPath,
|
|
238
|
+
});
|
|
239
|
+
}
|
|
240
|
+
decorateWorkflowWire(wire, context) {
|
|
241
|
+
const { name, runId, rpcService, addonNamespace } = context;
|
|
242
|
+
const workflowWire = wire;
|
|
243
|
+
const scenarioStepContext = () => ({
|
|
244
|
+
runId,
|
|
245
|
+
workflowName: name,
|
|
246
|
+
addonNamespace,
|
|
247
|
+
workflowWire,
|
|
248
|
+
rpcService,
|
|
249
|
+
});
|
|
250
|
+
Object.assign(workflowWire, {
|
|
251
|
+
// Durable polling step: invoke an RPC (as an actor when options.as is
|
|
252
|
+
// set) until the predicate passes or `within` elapses. The whole poll is
|
|
253
|
+
// ONE recorded step, so replay returns the cached outcome.
|
|
254
|
+
expectEventually: async (stepName, rpcName, data, predicate, options) => {
|
|
255
|
+
this.engine.verifyStepName(stepName);
|
|
256
|
+
const resolvedRpcName = addonNamespace && !rpcName.includes(':')
|
|
257
|
+
? `${addonNamespace}:${rpcName}`
|
|
258
|
+
: rpcName;
|
|
259
|
+
const within = getDurationInMilliseconds(options?.within ?? '30s');
|
|
260
|
+
const interval = getDurationInMilliseconds(options?.interval ?? '1s');
|
|
261
|
+
return await this.engine.inlineStep(runId, stepName, async () => {
|
|
262
|
+
const deadline = Date.now() + within;
|
|
263
|
+
let last;
|
|
264
|
+
while (true) {
|
|
265
|
+
last = options?.actor
|
|
266
|
+
? await options.actor.invoke(resolvedRpcName, data)
|
|
267
|
+
: await rpcService.rpcWithWire(resolvedRpcName, data, {});
|
|
268
|
+
if (predicate(last))
|
|
269
|
+
return last;
|
|
270
|
+
if (Date.now() + interval > deadline) {
|
|
271
|
+
throw new Error(`[workflow] expectEventually '${stepName}' ('${resolvedRpcName}'` +
|
|
272
|
+
`${options?.actor ? ` as '${options.actor.name}'` : ''}) did not pass within ${within}ms; ` +
|
|
273
|
+
`last result: ${JSON.stringify(last)?.slice(0, 300)}`);
|
|
274
|
+
}
|
|
275
|
+
await new Promise((resolve) => setTimeout(resolve, interval));
|
|
276
|
+
}
|
|
277
|
+
}, options);
|
|
278
|
+
},
|
|
279
|
+
expectError: async (stepName, rpcName, data, options) => {
|
|
280
|
+
this.engine.verifyStepName(stepName);
|
|
281
|
+
const resolvedRpcName = addonNamespace && !rpcName.includes(':')
|
|
282
|
+
? `${addonNamespace}:${rpcName}`
|
|
283
|
+
: rpcName;
|
|
284
|
+
return await this.engine.inlineStep(runId, stepName, async () => {
|
|
285
|
+
let result;
|
|
286
|
+
try {
|
|
287
|
+
result = options?.actor
|
|
288
|
+
? await options.actor.invoke(resolvedRpcName, data)
|
|
289
|
+
: await rpcService.rpcWithWire(resolvedRpcName, data, {});
|
|
290
|
+
}
|
|
291
|
+
catch (e) {
|
|
292
|
+
const message = e?.message ?? String(e);
|
|
293
|
+
if (options?.matches) {
|
|
294
|
+
const matched = typeof options.matches === 'string'
|
|
295
|
+
? message.includes(options.matches)
|
|
296
|
+
: options.matches.test(message);
|
|
297
|
+
if (!matched) {
|
|
298
|
+
throw new Error(`[workflow] expectError '${stepName}' ('${resolvedRpcName}') threw, but the message did not match ${options.matches}: ${message}`);
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
return message;
|
|
302
|
+
}
|
|
303
|
+
throw new Error(`[workflow] expectError '${stepName}' ('${resolvedRpcName}') expected an error but the call succeeded: ${JSON.stringify(result)?.slice(0, 300)}`);
|
|
304
|
+
}, options);
|
|
305
|
+
},
|
|
306
|
+
expectService: async (stepName, serviceMethod, options) => {
|
|
307
|
+
this.engine.verifyStepName(stepName);
|
|
308
|
+
const [service, method] = serviceMethod.split('.');
|
|
309
|
+
if (!service || !method) {
|
|
310
|
+
throw new Error(`[workflow] expectService '${stepName}' needs 'service.method', got '${serviceMethod}'`);
|
|
311
|
+
}
|
|
312
|
+
await this.engine.inlineStep(runId, stepName, async () => {
|
|
313
|
+
const rpcName = 'pikkuScenarioGetStubCalls';
|
|
314
|
+
const calls = options?.actor
|
|
315
|
+
? await options.actor.invoke(rpcName, { service })
|
|
316
|
+
: await rpcService.rpcWithWire(rpcName, { service }, {});
|
|
317
|
+
const matching = (calls ?? []).filter((c) => c.service === service &&
|
|
318
|
+
c.method === method &&
|
|
319
|
+
(options?.calledWith === undefined ||
|
|
320
|
+
JSON.stringify(c.args?.[0]) ===
|
|
321
|
+
JSON.stringify(options.calledWith)));
|
|
322
|
+
const expected = options?.times;
|
|
323
|
+
const ok = expected === undefined
|
|
324
|
+
? matching.length > 0
|
|
325
|
+
: matching.length === expected;
|
|
326
|
+
if (!ok) {
|
|
327
|
+
const seen = (calls ?? [])
|
|
328
|
+
.map((c) => `${c.service}.${c.method}(${JSON.stringify(c.args?.[0])?.slice(0, 120) ?? ''})`)
|
|
329
|
+
.join('\n ') || '(none)';
|
|
330
|
+
throw new Error(`[workflow] expectService '${stepName}' expected ${expected ?? 'at least one'} call(s) to '${serviceMethod}'` +
|
|
331
|
+
`${options?.calledWith !== undefined ? ` with ${JSON.stringify(options.calledWith)}` : ''}, found ${matching.length}. Recorded:\n ${seen}`);
|
|
332
|
+
}
|
|
333
|
+
}, options);
|
|
334
|
+
},
|
|
335
|
+
// Scenario steps: a named `pikkuScenarioStep` run as one durable step.
|
|
336
|
+
// `given`/`when`/`then` are pure sugar over `step` — the phase only
|
|
337
|
+
// changes the prose a reporter renders.
|
|
338
|
+
step: (stepName, stepFunc, data, options) => this.scenarioStep('step', scenarioStepContext(), stepName, stepFunc, data, options),
|
|
339
|
+
given: (stepName, stepFunc, data, options) => this.scenarioStep('given', scenarioStepContext(), stepName, stepFunc, data, options),
|
|
340
|
+
when: (stepName, stepFunc, data, options) => this.scenarioStep('when', scenarioStepContext(), stepName, stepFunc, data, options),
|
|
341
|
+
then: (stepName, stepFunc, data, options) => this.scenarioStep('then', scenarioStepContext(), stepName, stepFunc, data, options),
|
|
342
|
+
runScheduledTask: async (taskName) => {
|
|
343
|
+
await runScheduledTask({ name: taskName });
|
|
344
|
+
},
|
|
345
|
+
});
|
|
346
|
+
}
|
|
347
|
+
async scenarioStep(phase, context, stepName, stepFunc, data, options) {
|
|
348
|
+
const { runId, workflowName, addonNamespace, workflowWire, rpcService } = context;
|
|
349
|
+
// Also the guard for `then` being a wire member: an accidental
|
|
350
|
+
// `await scenario` calls it with a resolve function, which lands here as a
|
|
351
|
+
// loud, named error instead of a silent hang.
|
|
352
|
+
this.engine.verifyStepName(stepName);
|
|
353
|
+
if (typeof stepFunc !== 'string') {
|
|
354
|
+
throw new WorkflowStepNameNotString(stepFunc);
|
|
355
|
+
}
|
|
356
|
+
const packageName = addonNamespace && !stepFunc.includes(':') ? addonNamespace : null;
|
|
357
|
+
const resolvedStepFunc = addonNamespace && !stepFunc.includes(':')
|
|
358
|
+
? `${addonNamespace}:${stepFunc}`
|
|
359
|
+
: stepFunc;
|
|
360
|
+
const actor = options?.actor;
|
|
361
|
+
const description = options?.description ??
|
|
362
|
+
this.scenarioStepDescription(packageName, resolvedStepFunc) ??
|
|
363
|
+
stepName;
|
|
364
|
+
return await this.engine.inlineStep(runId, stepName, async () => {
|
|
365
|
+
const wire = {
|
|
366
|
+
workflow: workflowWire,
|
|
367
|
+
scenario: workflowWire,
|
|
368
|
+
rpc: rpcService?.wire?.rpc,
|
|
369
|
+
session: rpcService?.wire?.session,
|
|
370
|
+
pikkuUserId: workflowWire.pikkuUserId,
|
|
371
|
+
actors: this.runActors.get(runId),
|
|
372
|
+
scenarioStep: {
|
|
373
|
+
name: resolvedStepFunc,
|
|
374
|
+
stepName,
|
|
375
|
+
runId,
|
|
376
|
+
phase,
|
|
377
|
+
actor,
|
|
378
|
+
env: this.scenarioEnvironment,
|
|
379
|
+
},
|
|
380
|
+
};
|
|
381
|
+
if (this.requiresBrowser(packageName, resolvedStepFunc)) {
|
|
382
|
+
if (!this.scenarioBrowserProvider) {
|
|
383
|
+
throw new Error(`[scenario] step '${resolvedStepFunc}' declares 'browser: true' but no browser provider is registered. ` +
|
|
384
|
+
`Install @pikku/playwright and register its provider, or run with --no-browser to skip browser steps.`);
|
|
385
|
+
}
|
|
386
|
+
if (!actor) {
|
|
387
|
+
throw new Error(`[scenario] step '${resolvedStepFunc}' declares 'browser: true' but was called without an actor. ` +
|
|
388
|
+
`Pass { actor: actors.<name> } so the browser signs in as that persona.`);
|
|
389
|
+
}
|
|
390
|
+
wire.browser = await this.scenarioBrowserProvider.sessionFor(actor.name);
|
|
391
|
+
}
|
|
392
|
+
return await runPikkuFunc('workflow', workflowName, resolvedStepFunc, {
|
|
393
|
+
singletonServices: getSingletonServices(),
|
|
394
|
+
createWireServices: getCreateWireServices(),
|
|
395
|
+
data: () => data,
|
|
396
|
+
wire,
|
|
397
|
+
packageName: packageName ?? undefined,
|
|
398
|
+
});
|
|
399
|
+
}, {
|
|
400
|
+
description,
|
|
401
|
+
// Retrying a failed assertion is the wrong behaviour for a test
|
|
402
|
+
// primitive, so steps opt out of the workflow-wide retry default.
|
|
403
|
+
retries: options?.retries ?? 0,
|
|
404
|
+
retryDelay: options?.retryDelay,
|
|
405
|
+
}, data, resolvedStepFunc);
|
|
406
|
+
}
|
|
407
|
+
scenarioStepConfig(packageName, stepFunc) {
|
|
408
|
+
const localName = packageName && stepFunc.startsWith(`${packageName}:`)
|
|
409
|
+
? stepFunc.slice(packageName.length + 1)
|
|
410
|
+
: stepFunc;
|
|
411
|
+
return pikkuState(packageName, 'function', 'functions').get(localName);
|
|
412
|
+
}
|
|
413
|
+
scenarioStepDescription(packageName, stepFunc) {
|
|
414
|
+
return this.scenarioStepConfig(packageName, stepFunc)?.description;
|
|
415
|
+
}
|
|
416
|
+
requiresBrowser(packageName, stepFunc) {
|
|
417
|
+
return this.scenarioStepConfig(packageName, stepFunc)?.browser === true;
|
|
418
|
+
}
|
|
419
|
+
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import type { SerializedError } from '../../types/core.types.js';
|
|
2
|
-
import type { ApprovalOutcome,
|
|
1
|
+
import type { PikkuWire, SerializedError } from '../../types/core.types.js';
|
|
2
|
+
import type { ApprovalOutcome, CoreWorkflow, PikkuWorkflowWire, StepState, StepStatus, WorkflowPlannedStep, WorkflowRun, WorkflowRunMirror, WorkflowRunStatus, WorkflowRunWire, WorkflowStatus, WorkflowVersionStatus, WorkflowQueueOptions, WorkflowStepOptions } from './workflow.types.js';
|
|
3
3
|
import type { WorkflowService } from '../../services/workflow-service.js';
|
|
4
4
|
import type { ScenarioActors } from '../../services/scenario-actors-service.js';
|
|
5
5
|
import { PikkuError } from '../../errors/error-handler.js';
|
|
@@ -88,6 +88,72 @@ export declare class WorkflowServiceNotInitialized extends Error {
|
|
|
88
88
|
export declare class WorkflowStepNameNotString extends Error {
|
|
89
89
|
constructor(stepName: any);
|
|
90
90
|
}
|
|
91
|
+
/**
|
|
92
|
+
* Everything an extension needs to run something of its own around a run's
|
|
93
|
+
* function: the registration it was resolved from, the run itself (so `input`
|
|
94
|
+
* is the same object the function is called with), and the wire it is given.
|
|
95
|
+
*/
|
|
96
|
+
export interface RunLifecycleContext {
|
|
97
|
+
runId: string;
|
|
98
|
+
run: WorkflowRun;
|
|
99
|
+
workflowMeta: any;
|
|
100
|
+
workflow: CoreWorkflow;
|
|
101
|
+
wire: PikkuWire;
|
|
102
|
+
packageName: string | null;
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* The slice of the run engine an extension is allowed to drive, handed to it at
|
|
106
|
+
* construction. It exists so that recording a durable step stays available to
|
|
107
|
+
* an extension without `inlineStep` and friends becoming public API on every
|
|
108
|
+
* workflow service a production app instantiates.
|
|
109
|
+
*/
|
|
110
|
+
export interface WorkflowRunEngine {
|
|
111
|
+
inlineStep(runId: string, logicalStepName: string, fn: Function, stepOptions?: WorkflowStepOptions, data?: any, funcName?: string): Promise<any>;
|
|
112
|
+
updateRunStatus(runId: string, status: WorkflowStatus, output?: any, error?: SerializedError): Promise<void>;
|
|
113
|
+
onChildWorkflowFailed(run: WorkflowRun, error: unknown): Promise<void>;
|
|
114
|
+
verifyStepName(stepName: unknown): void;
|
|
115
|
+
}
|
|
116
|
+
/**
|
|
117
|
+
* A capability layered onto a run without being a workflow service itself.
|
|
118
|
+
*
|
|
119
|
+
* The engine names nothing about what an extension is for: a bundler drops an
|
|
120
|
+
* unused *module* but never an unused class member, so the alternative — a
|
|
121
|
+
* subclass carrying the capability — puts it in every server built on Pikku
|
|
122
|
+
* whether or not the app ever uses it. Scenarios are the one implementation
|
|
123
|
+
* today (`PikkuScenarioService` in `@pikku/core/scenario`).
|
|
124
|
+
*/
|
|
125
|
+
export interface WorkflowRunExtension {
|
|
126
|
+
/** Per-run state resolved once the run has an id. */
|
|
127
|
+
attachRunContext(runId: string, workflowMeta: any, options?: Record<string, any>): Promise<void>;
|
|
128
|
+
/** Release whatever `attachRunContext` stored. */
|
|
129
|
+
detachRunContext(runId: string): void;
|
|
130
|
+
/** Contribute extra members to the wire a run's function is invoked with. */
|
|
131
|
+
decorateRunWire(wire: PikkuWire, context: {
|
|
132
|
+
runId: string;
|
|
133
|
+
workflowMeta: any;
|
|
134
|
+
workflowWire: PikkuWorkflowWire;
|
|
135
|
+
}): void;
|
|
136
|
+
/** Contribute extra members to the workflow wire itself, in place. */
|
|
137
|
+
decorateWorkflowWire(workflowWire: PikkuWorkflowWire, context: {
|
|
138
|
+
name: string;
|
|
139
|
+
runId: string;
|
|
140
|
+
rpcService: any;
|
|
141
|
+
addonNamespace?: string | null;
|
|
142
|
+
}): void;
|
|
143
|
+
/**
|
|
144
|
+
* Called immediately before the run's function, inside the run lock. Throwing
|
|
145
|
+
* skips the function and fails the run.
|
|
146
|
+
*/
|
|
147
|
+
onBeforeRunFunc(context: RunLifecycleContext): Promise<void>;
|
|
148
|
+
/**
|
|
149
|
+
* Called once the run reaches a terminal state, in a `finally`, so it runs
|
|
150
|
+
* whether the function passed or threw. `interrupted` means the run suspended
|
|
151
|
+
* or went async and is still mid-flight. Throwing here propagates, replacing
|
|
152
|
+
* a pending failure, so an extension that must not mask one handles that
|
|
153
|
+
* itself.
|
|
154
|
+
*/
|
|
155
|
+
onAfterRunFunc(context: RunLifecycleContext, outcome: 'completed' | 'failed' | 'interrupted', failure: unknown): Promise<void>;
|
|
156
|
+
}
|
|
91
157
|
/**
|
|
92
158
|
* Abstract workflow state service
|
|
93
159
|
* Implementations provide pluggable storage backends (SQLite, PostgreSQL, etc.)
|
|
@@ -95,7 +161,7 @@ export declare class WorkflowStepNameNotString extends Error {
|
|
|
95
161
|
*/
|
|
96
162
|
export declare abstract class PikkuWorkflowService implements WorkflowService {
|
|
97
163
|
private inlineRuns;
|
|
98
|
-
private
|
|
164
|
+
private runExtension?;
|
|
99
165
|
protected get logger(): import("../../services/logger.js").Logger;
|
|
100
166
|
protected mirror?: WorkflowRunMirror;
|
|
101
167
|
protected readonly queueStrategy: 'per-workflow' | 'shared-groups';
|
|
@@ -179,7 +245,10 @@ export declare abstract class PikkuWorkflowService implements WorkflowService {
|
|
|
179
245
|
* Creates pending step in both workflow_step and workflow_step_history
|
|
180
246
|
* @param runId - Run ID
|
|
181
247
|
* @param stepName - Step cache key
|
|
182
|
-
* @param rpcName - RPC
|
|
248
|
+
* @param rpcName - The name this step was dispatched by: an RPC for a
|
|
249
|
+
* `workflow.do` step, a step function for a scenario step, null for a
|
|
250
|
+
* closure. Nothing dispatches off this value — it is recorded so a reader
|
|
251
|
+
* can join a step back to the function that ran it.
|
|
183
252
|
* @param data - Step input data
|
|
184
253
|
* @param stepOptions - Step options (retries, retryDelay)
|
|
185
254
|
* @returns Step state with generated stepId
|
|
@@ -382,18 +451,28 @@ export declare abstract class PikkuWorkflowService implements WorkflowService {
|
|
|
382
451
|
* false to fall through to inline `setTimeout` behavior.
|
|
383
452
|
*/
|
|
384
453
|
protected scheduleSleep(runId: string, stepId: string, duration: number | string): Promise<boolean>;
|
|
385
|
-
/**
|
|
386
|
-
|
|
454
|
+
/**
|
|
455
|
+
* Install the one extension a run may have, built from a handle onto the run
|
|
456
|
+
* engine so that `inlineStep` and friends stay protected rather than becoming
|
|
457
|
+
* public API. Returns the extension, so the caller keeps a typed reference to
|
|
458
|
+
* whatever it just built.
|
|
459
|
+
*/
|
|
460
|
+
setRunExtension<T extends WorkflowRunExtension>(create: (engine: WorkflowRunEngine) => T): T;
|
|
461
|
+
getRunExtension(): WorkflowRunExtension | undefined;
|
|
387
462
|
/**
|
|
388
463
|
* Start a new workflow run
|
|
389
464
|
* Automatically detects workflow type (DSL or graph) from meta and executes accordingly
|
|
390
465
|
* @param options.inline - If true, execute workflow directly without queue service
|
|
391
466
|
* @param options.startNode - Starting node ID for graph workflows (from wire config)
|
|
467
|
+
* @param options.onRunCreated - Called with the run id the moment the run exists.
|
|
468
|
+
* An inline run that fails throws instead of returning, so this is the only
|
|
469
|
+
* way a caller can still read that run back — its steps, and which one failed.
|
|
392
470
|
*/
|
|
393
471
|
startWorkflow<I>(name: string, input: I, wire: WorkflowRunWire, rpcService: any, options?: {
|
|
394
472
|
inline?: boolean;
|
|
395
473
|
startNode?: string;
|
|
396
474
|
actors?: ScenarioActors;
|
|
475
|
+
onRunCreated?: (runId: string) => void;
|
|
397
476
|
}): Promise<{
|
|
398
477
|
runId: string;
|
|
399
478
|
}>;
|
|
@@ -416,7 +495,7 @@ export declare abstract class PikkuWorkflowService implements WorkflowService {
|
|
|
416
495
|
runWorkflowJob(runId: string, rpcService: any): Promise<void>;
|
|
417
496
|
private runWorkflowJobInner;
|
|
418
497
|
private onChildWorkflowCompleted;
|
|
419
|
-
|
|
498
|
+
protected onChildWorkflowFailed(childRun: WorkflowRun, error: Error): Promise<void>;
|
|
420
499
|
private runVersionMismatchFallback;
|
|
421
500
|
/**
|
|
422
501
|
* Execute a single workflow step (called by worker)
|
|
@@ -455,7 +534,21 @@ export declare abstract class PikkuWorkflowService implements WorkflowService {
|
|
|
455
534
|
*/
|
|
456
535
|
private runStepCompensation;
|
|
457
536
|
private rpcStep;
|
|
458
|
-
|
|
537
|
+
protected inlineStep(runId: string, logicalStepName: string, fn: Function, stepOptions?: WorkflowStepOptions,
|
|
538
|
+
/**
|
|
539
|
+
* The input this step was called with, recorded on the run so a reporter can
|
|
540
|
+
* name the values under test. A closure step has none; a scenario step does.
|
|
541
|
+
*/
|
|
542
|
+
data?: any,
|
|
543
|
+
/**
|
|
544
|
+
* The name this step was dispatched by, for the kinds of inline step that
|
|
545
|
+
* have one. A closure step has no name; a scenario step is a step RPC, so
|
|
546
|
+
* it records the step function that ran — which is the only way to join a
|
|
547
|
+
* step back to its declaration when its durable name was built at runtime
|
|
548
|
+
* (a step called in a loop reaches the run as `sees @pikku/addon-todos`,
|
|
549
|
+
* declared as `sees ${packageName}`).
|
|
550
|
+
*/
|
|
551
|
+
rpcName?: string | null): Promise<any>;
|
|
459
552
|
private sleepStep;
|
|
460
553
|
/**
|
|
461
554
|
* Derive the durable step name for a suspend point from its `reason`, so each
|
|
@@ -507,8 +600,8 @@ export declare abstract class PikkuWorkflowService implements WorkflowService {
|
|
|
507
600
|
*/
|
|
508
601
|
approveStep(runId: string, reason: string, decision: unknown): Promise<void>;
|
|
509
602
|
private approvalStep;
|
|
510
|
-
createWorkflowWire(name: string, runId: string, rpcService: any, addonNamespace?: string | null):
|
|
511
|
-
|
|
603
|
+
createWorkflowWire(name: string, runId: string, rpcService: any, addonNamespace?: string | null): PikkuWorkflowWire;
|
|
604
|
+
protected verifyStepName(stepName: string): void;
|
|
512
605
|
private getConfig;
|
|
513
606
|
/**
|
|
514
607
|
* Get the orchestrator queue name for a specific workflow.
|