@pikku/core 0.12.69 → 0.12.71
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 +422 -0
- package/LICENSE +21 -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/in-memory-queue-service.d.ts +6 -0
- package/dist/services/in-memory-queue-service.js +8 -1
- package/dist/services/in-memory-workflow-service.d.ts +3 -5
- package/dist/services/in-memory-workflow-service.js +10 -19
- 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/services/workflow-service.d.ts +7 -5
- package/dist/types/core.types.d.ts +28 -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/ai-agent/ai-agent-agui.js +0 -8
- package/dist/wirings/ai-agent/ai-agent-prepare.js +1 -2
- package/dist/wirings/ai-agent/ai-agent.types.d.ts +0 -6
- 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/graph/graph-runner.js +3 -2
- package/dist/wirings/workflow/graph/graph-validation.d.ts +0 -2
- package/dist/wirings/workflow/graph/graph-validation.js +0 -142
- package/dist/wirings/workflow/graph/index.d.ts +1 -1
- package/dist/wirings/workflow/graph/index.js +1 -1
- package/dist/wirings/workflow/index.d.ts +13 -3
- package/dist/wirings/workflow/index.js +15 -2
- 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 +170 -23
- package/dist/wirings/workflow/pikku-workflow-service.js +338 -297
- 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 +82 -8
- 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/in-memory-queue-service.test.ts +66 -1
- package/src/services/in-memory-queue-service.ts +13 -2
- package/src/services/in-memory-workflow-service.ts +12 -25
- 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/services/workflow-service.ts +7 -4
- package/src/types/core.types.ts +34 -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/ai-agent/ai-agent-agui.test.ts +0 -16
- package/src/wirings/ai-agent/ai-agent-agui.ts +0 -9
- package/src/wirings/ai-agent/ai-agent-prepare.ts +1 -2
- package/src/wirings/ai-agent/ai-agent.types.ts +0 -7
- 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/graph/graph-runner.ts +3 -2
- package/src/wirings/workflow/graph/graph-validation.test.ts +1 -144
- package/src/wirings/workflow/graph/graph-validation.ts +0 -196
- package/src/wirings/workflow/graph/index.ts +1 -5
- package/src/wirings/workflow/index.ts +73 -6
- 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 +572 -419
- 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-expectations.test.ts +153 -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 +442 -9
- package/src/wirings/workflow/scenario-step.types.ts +157 -0
- package/src/wirings/workflow/workflow-dispatch-durability.test.ts +1 -1
- package/src/wirings/workflow/workflow-dispatch-payload.test.ts +59 -0
- package/src/wirings/workflow/workflow-mirror.test.ts +178 -0
- package/src/wirings/workflow/workflow-replay-snapshot.test.ts +139 -0
- package/src/wirings/workflow/workflow-run-context.test.ts +177 -0
- package/src/wirings/workflow/workflow-run-polling.test.ts +132 -0
- package/src/wirings/workflow/workflow-step-ordinal.test.ts +4 -4
- package/src/wirings/workflow/workflow.types.ts +99 -5
- package/tsconfig.tsbuildinfo +1 -1
|
@@ -3,7 +3,6 @@ import { pikkuWorkflowWorkerFunc, pikkuWorkflowOrchestratorFunc, pikkuWorkflowSl
|
|
|
3
3
|
import { wireQueueWorker } from '../queue/queue-runner.js';
|
|
4
4
|
import { getSingletonServices, getCreateWireServices, pikkuState, } from '../../pikku-state.js';
|
|
5
5
|
import { getDurationInMilliseconds } from '../../time-utils.js';
|
|
6
|
-
import { createHttpScenarioActors } from '../../services/http-scenario-actors.js';
|
|
7
6
|
const resolveWorkflowMeta = (name) => {
|
|
8
7
|
const rootMeta = pikkuState(null, 'workflows', 'meta');
|
|
9
8
|
if (rootMeta[name]) {
|
|
@@ -29,7 +28,6 @@ const resolveWorkflowMeta = (name) => {
|
|
|
29
28
|
return null;
|
|
30
29
|
};
|
|
31
30
|
const toKebab = (s) => s.replace(/([a-z0-9])([A-Z])/g, '$1-$2').toLowerCase();
|
|
32
|
-
import { runScheduledTask } from '../scheduler/scheduler-runner.js';
|
|
33
31
|
import { continueGraph, executeGraphStep, runWorkflowGraph, runFromMeta, } from './graph/graph-runner.js';
|
|
34
32
|
import { PikkuError, addError, isExpectedError, } from '../../errors/error-handler.js';
|
|
35
33
|
import { RPCNotFoundError } from '../rpc/rpc-runner.js';
|
|
@@ -173,15 +171,45 @@ const WORKFLOW_END_STATES = new Set([
|
|
|
173
171
|
'suspended',
|
|
174
172
|
]);
|
|
175
173
|
/**
|
|
176
|
-
*
|
|
177
|
-
*
|
|
178
|
-
*
|
|
174
|
+
* States a run never leaves. `suspended` is deliberately absent: a suspended
|
|
175
|
+
* run stops a poll loop but can still be resumed, so anything the process holds
|
|
176
|
+
* for it has to survive.
|
|
179
177
|
*/
|
|
178
|
+
const WORKFLOW_TERMINAL_STATES = new Set([
|
|
179
|
+
'completed',
|
|
180
|
+
'failed',
|
|
181
|
+
'cancelled',
|
|
182
|
+
]);
|
|
183
|
+
/** First wait when polling a run, before the backoff starts widening it. */
|
|
184
|
+
const WORKFLOW_POLL_MIN_MS = 10;
|
|
185
|
+
/** How much each successive wait grows, up to the caller's ceiling. */
|
|
186
|
+
const WORKFLOW_POLL_FACTOR = 1.6;
|
|
187
|
+
/**
|
|
188
|
+
* Ceiling for the wait on an inline sub-workflow. Lower than a top-level run's
|
|
189
|
+
* default, because the parent step is blocked on it and every wait here is
|
|
190
|
+
* added latency in the middle of a workflow rather than at its edge.
|
|
191
|
+
*/
|
|
192
|
+
const WORKFLOW_CHILD_POLL_MAX_MS = 500;
|
|
180
193
|
export class PikkuWorkflowService {
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
194
|
+
runExtension;
|
|
195
|
+
runContexts = new Map();
|
|
196
|
+
contextFor(runId) {
|
|
197
|
+
let context = this.runContexts.get(runId);
|
|
198
|
+
if (!context) {
|
|
199
|
+
context = { inline: false };
|
|
200
|
+
this.runContexts.set(runId, context);
|
|
201
|
+
}
|
|
202
|
+
return context;
|
|
203
|
+
}
|
|
204
|
+
/** Drop a run's context once nothing is holding it open. */
|
|
205
|
+
releaseContext(runId) {
|
|
206
|
+
const context = this.runContexts.get(runId);
|
|
207
|
+
if (!context)
|
|
208
|
+
return;
|
|
209
|
+
if (context.inline || context.replay)
|
|
210
|
+
return;
|
|
211
|
+
this.runContexts.delete(runId);
|
|
212
|
+
}
|
|
185
213
|
get logger() {
|
|
186
214
|
return getSingletonServices()?.logger;
|
|
187
215
|
}
|
|
@@ -199,20 +227,33 @@ export class PikkuWorkflowService {
|
|
|
199
227
|
this.wireQueueWorkers();
|
|
200
228
|
}
|
|
201
229
|
}
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
230
|
+
/**
|
|
231
|
+
* Perform a state write, then shadow it to the mirror.
|
|
232
|
+
*
|
|
233
|
+
* The mirror is an observability sink, never a second source of truth, and
|
|
234
|
+
* both halves of that follow from this one shape: it is only ever told about
|
|
235
|
+
* a write that already landed, and a mirror that is down or throwing cannot
|
|
236
|
+
* fail — or even be seen by — the workflow it is watching.
|
|
237
|
+
*
|
|
238
|
+
* @param write - the authoritative write; its result is what the caller gets
|
|
239
|
+
* @param mirror - shadows the write, given the live mirror and what was written
|
|
240
|
+
*/
|
|
241
|
+
async mirrored(write, mirror) {
|
|
242
|
+
const written = await write();
|
|
243
|
+
if (this.mirror) {
|
|
209
244
|
try {
|
|
210
|
-
this.
|
|
245
|
+
await mirror(this.mirror, written);
|
|
211
246
|
}
|
|
212
|
-
catch {
|
|
213
|
-
|
|
247
|
+
catch (err) {
|
|
248
|
+
try {
|
|
249
|
+
this.logger?.warn?.(`[pikku] WorkflowRunMirror write failed: ${err?.message ?? err}`);
|
|
250
|
+
}
|
|
251
|
+
catch {
|
|
252
|
+
// logger unavailable (e.g. singleton services not initialized) — swallow
|
|
253
|
+
}
|
|
214
254
|
}
|
|
215
255
|
}
|
|
256
|
+
return written;
|
|
216
257
|
}
|
|
217
258
|
/**
|
|
218
259
|
* Wire the queue-based orchestrator/step/sleeper workers.
|
|
@@ -310,19 +351,23 @@ export class PikkuWorkflowService {
|
|
|
310
351
|
* Check if a run is executing inline (without queues)
|
|
311
352
|
*/
|
|
312
353
|
isInline(runId) {
|
|
313
|
-
return this.
|
|
354
|
+
return this.runContexts.get(runId)?.inline === true;
|
|
314
355
|
}
|
|
315
356
|
/**
|
|
316
357
|
* Register a run as inline (for graph-runner to use)
|
|
317
358
|
*/
|
|
318
359
|
registerInlineRun(runId) {
|
|
319
|
-
this.
|
|
360
|
+
this.contextFor(runId).inline = true;
|
|
320
361
|
}
|
|
321
362
|
/**
|
|
322
363
|
* Unregister a run from inline tracking
|
|
323
364
|
*/
|
|
324
365
|
unregisterInlineRun(runId) {
|
|
325
|
-
this.
|
|
366
|
+
const context = this.runContexts.get(runId);
|
|
367
|
+
if (!context)
|
|
368
|
+
return;
|
|
369
|
+
context.inline = false;
|
|
370
|
+
this.releaseContext(runId);
|
|
326
371
|
}
|
|
327
372
|
async registerWorkflowVersions() {
|
|
328
373
|
const allMeta = pikkuState(null, 'workflows', 'meta');
|
|
@@ -333,9 +378,7 @@ export class PikkuWorkflowService {
|
|
|
333
378
|
}
|
|
334
379
|
}
|
|
335
380
|
async createRun(workflowName, input, inline, graphHash, wire, options) {
|
|
336
|
-
|
|
337
|
-
await this.safeMirror(() => this.mirror.createRun(runId, workflowName, input, inline, graphHash, wire, options));
|
|
338
|
-
return runId;
|
|
381
|
+
return this.mirrored(() => this.createRunImpl(workflowName, input, inline, graphHash, wire, options), (mirror, runId) => mirror.createRun(runId, workflowName, input, inline, graphHash, wire, options));
|
|
339
382
|
}
|
|
340
383
|
/**
|
|
341
384
|
* Get minimal workflow run status with step summaries.
|
|
@@ -412,23 +455,30 @@ export class PikkuWorkflowService {
|
|
|
412
455
|
* @param status - New status
|
|
413
456
|
*/
|
|
414
457
|
async updateRunStatus(id, status, output, error) {
|
|
415
|
-
await this.updateRunStatusImpl(id, status, output, error);
|
|
416
|
-
|
|
458
|
+
await this.mirrored(() => this.updateRunStatusImpl(id, status, output, error), (mirror) => mirror.updateRunStatus(id, status, output, error));
|
|
459
|
+
if (WORKFLOW_TERMINAL_STATES.has(status)) {
|
|
460
|
+
// The run is over: release whatever this process opened for it. Queued
|
|
461
|
+
// runs never pass through the inline path that does this, so their
|
|
462
|
+
// context was held for the life of the process.
|
|
463
|
+
this.runExtension?.detachRunContext(id);
|
|
464
|
+
this.releaseContext(id);
|
|
465
|
+
}
|
|
417
466
|
}
|
|
418
467
|
/**
|
|
419
468
|
* Insert initial step state (called by orchestrator)
|
|
420
469
|
* Creates pending step in both workflow_step and workflow_step_history
|
|
421
470
|
* @param runId - Run ID
|
|
422
471
|
* @param stepName - Step cache key
|
|
423
|
-
* @param rpcName - RPC
|
|
472
|
+
* @param rpcName - The name this step was dispatched by: an RPC for a
|
|
473
|
+
* `workflow.do` step, a step function for a scenario step, null for a
|
|
474
|
+
* closure. Nothing dispatches off this value — it is recorded so a reader
|
|
475
|
+
* can join a step back to the function that ran it.
|
|
424
476
|
* @param data - Step input data
|
|
425
477
|
* @param stepOptions - Step options (retries, retryDelay)
|
|
426
478
|
* @returns Step state with generated stepId
|
|
427
479
|
*/
|
|
428
480
|
async insertStepState(runId, stepName, rpcName, data, stepOptions, fromStepName) {
|
|
429
|
-
|
|
430
|
-
await this.safeMirror(() => this.mirror.insertStepState(runId, { ...step, stepName, rpcName, data }));
|
|
431
|
-
return step;
|
|
481
|
+
return this.mirrored(() => this.insertStepStateImpl(runId, stepName, rpcName, data, stepOptions, fromStepName), (mirror, step) => mirror.insertStepState(runId, { ...step, stepName, rpcName, data }));
|
|
432
482
|
}
|
|
433
483
|
/**
|
|
434
484
|
* Mark step as running
|
|
@@ -436,8 +486,7 @@ export class PikkuWorkflowService {
|
|
|
436
486
|
* @param stepId - Step ID
|
|
437
487
|
*/
|
|
438
488
|
async setStepRunning(stepId) {
|
|
439
|
-
await this.setStepRunningImpl(stepId);
|
|
440
|
-
await this.safeMirror(() => this.mirror.setStepRunning(stepId));
|
|
489
|
+
await this.mirrored(() => this.setStepRunningImpl(stepId), (mirror) => mirror.setStepRunning(stepId));
|
|
441
490
|
}
|
|
442
491
|
/**
|
|
443
492
|
* Mark step as scheduled (queued for execution)
|
|
@@ -445,8 +494,7 @@ export class PikkuWorkflowService {
|
|
|
445
494
|
* @param stepId - Step ID
|
|
446
495
|
*/
|
|
447
496
|
async setStepScheduled(stepId) {
|
|
448
|
-
await this.setStepScheduledImpl(stepId);
|
|
449
|
-
await this.safeMirror(() => this.mirror.setStepScheduled(stepId));
|
|
497
|
+
await this.mirrored(() => this.setStepScheduledImpl(stepId), (mirror) => mirror.setStepScheduled(stepId));
|
|
450
498
|
}
|
|
451
499
|
/**
|
|
452
500
|
* Store step result and mark as succeeded
|
|
@@ -455,8 +503,7 @@ export class PikkuWorkflowService {
|
|
|
455
503
|
* @param result - Step result
|
|
456
504
|
*/
|
|
457
505
|
async setStepResult(stepId, result) {
|
|
458
|
-
await this.setStepResultImpl(stepId, result);
|
|
459
|
-
await this.safeMirror(() => this.mirror.setStepResult(stepId, result));
|
|
506
|
+
await this.mirrored(() => this.setStepResultImpl(stepId, result), (mirror) => mirror.setStepResult(stepId, result));
|
|
460
507
|
}
|
|
461
508
|
/**
|
|
462
509
|
* Set the child workflow run ID on a step
|
|
@@ -464,8 +511,7 @@ export class PikkuWorkflowService {
|
|
|
464
511
|
* @param childRunId - Child workflow run ID
|
|
465
512
|
*/
|
|
466
513
|
async setStepChildRunId(stepId, childRunId) {
|
|
467
|
-
await this.setStepChildRunIdImpl(stepId, childRunId);
|
|
468
|
-
await this.safeMirror(() => this.mirror.setStepChildRunId(stepId, childRunId));
|
|
514
|
+
await this.mirrored(() => this.setStepChildRunIdImpl(stepId, childRunId), (mirror) => mirror.setStepChildRunId(stepId, childRunId));
|
|
469
515
|
}
|
|
470
516
|
/**
|
|
471
517
|
* Store step error and mark as failed
|
|
@@ -474,14 +520,15 @@ export class PikkuWorkflowService {
|
|
|
474
520
|
* @param error - Error object
|
|
475
521
|
*/
|
|
476
522
|
async setStepError(stepId, error) {
|
|
477
|
-
await this.setStepErrorImpl(stepId, error)
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
523
|
+
await this.mirrored(() => this.setStepErrorImpl(stepId, error), (mirror) => {
|
|
524
|
+
const serialized = {
|
|
525
|
+
message: error.message,
|
|
526
|
+
stack: error.stack,
|
|
527
|
+
code: error.code,
|
|
528
|
+
expected: isExpectedError(error),
|
|
529
|
+
};
|
|
530
|
+
return mirror.setStepError(stepId, serialized);
|
|
531
|
+
});
|
|
485
532
|
}
|
|
486
533
|
/**
|
|
487
534
|
* Create a new retry attempt for a failed step
|
|
@@ -492,13 +539,10 @@ export class PikkuWorkflowService {
|
|
|
492
539
|
* @returns New step state for the retry attempt
|
|
493
540
|
*/
|
|
494
541
|
async createRetryAttempt(failedStepId, status) {
|
|
495
|
-
|
|
496
|
-
const stepName = newStep.stepName ?? '';
|
|
497
|
-
await this.safeMirror(() => this.mirror.createRetryAttempt(failedStepId, {
|
|
542
|
+
return this.mirrored(() => this.createRetryAttemptImpl(failedStepId, status), (mirror, newStep) => mirror.createRetryAttempt(failedStepId, {
|
|
498
543
|
...newStep,
|
|
499
|
-
stepName,
|
|
544
|
+
stepName: newStep.stepName ?? '',
|
|
500
545
|
}));
|
|
501
|
-
return newStep;
|
|
502
546
|
}
|
|
503
547
|
/**
|
|
504
548
|
* Set the branch key for a graph node step
|
|
@@ -506,8 +550,7 @@ export class PikkuWorkflowService {
|
|
|
506
550
|
* @param branchKey - Branch key selected by graph.branch()
|
|
507
551
|
*/
|
|
508
552
|
async setBranchTaken(stepId, branchKey) {
|
|
509
|
-
await this.setBranchTakenImpl(stepId, branchKey);
|
|
510
|
-
await this.safeMirror(() => this.mirror.setBranchTaken(stepId, branchKey));
|
|
553
|
+
await this.mirrored(() => this.setBranchTakenImpl(stepId, branchKey), (mirror) => mirror.setBranchTaken(stepId, branchKey));
|
|
511
554
|
}
|
|
512
555
|
/**
|
|
513
556
|
* Update a state variable in the workflow run's state
|
|
@@ -516,16 +559,13 @@ export class PikkuWorkflowService {
|
|
|
516
559
|
* @param value - Value to store
|
|
517
560
|
*/
|
|
518
561
|
async updateRunState(runId, name, value) {
|
|
519
|
-
await this.updateRunStateImpl(runId, name, value);
|
|
520
|
-
await this.safeMirror(() => this.mirror.updateRunState(runId, name, value));
|
|
562
|
+
await this.mirrored(() => this.updateRunStateImpl(runId, name, value), (mirror) => mirror.updateRunState(runId, name, value));
|
|
521
563
|
}
|
|
522
564
|
async upsertWorkflowVersion(name, graphHash, graph, source, status) {
|
|
523
|
-
await this.upsertWorkflowVersionImpl(name, graphHash, graph, source, status);
|
|
524
|
-
await this.safeMirror(() => this.mirror.upsertWorkflowVersion(name, graphHash, graph, source, status));
|
|
565
|
+
await this.mirrored(() => this.upsertWorkflowVersionImpl(name, graphHash, graph, source, status), (mirror) => mirror.upsertWorkflowVersion(name, graphHash, graph, source, status));
|
|
525
566
|
}
|
|
526
567
|
async updateWorkflowVersionStatus(name, graphHash, status) {
|
|
527
|
-
await this.updateWorkflowVersionStatusImpl(name, graphHash, status);
|
|
528
|
-
await this.safeMirror(() => this.mirror.updateWorkflowVersionStatus(name, graphHash, status));
|
|
568
|
+
await this.mirrored(() => this.updateWorkflowVersionStatusImpl(name, graphHash, status), (mirror) => mirror.updateWorkflowVersionStatus(name, graphHash, status));
|
|
529
569
|
}
|
|
530
570
|
// ============================================================================
|
|
531
571
|
// Workflow Lifecycle Methods
|
|
@@ -574,7 +614,7 @@ export class PikkuWorkflowService {
|
|
|
574
614
|
}
|
|
575
615
|
async queueStepWorker(runId, stepName, rpcName, data, stepOptions, fromStepName) {
|
|
576
616
|
const queueService = this.verifyQueueService();
|
|
577
|
-
await queueService.add(this.getStepWorkerQueueName(rpcName),
|
|
617
|
+
await queueService.add(this.getStepWorkerQueueName(rpcName), { runId, stepName, rpcName, data, fromStepName }, {
|
|
578
618
|
...this.resolveStepJobOptions(stepOptions),
|
|
579
619
|
// Group by step function, mirroring how per-step queues split them —
|
|
580
620
|
// one slow step function can't monopolise the shared step worker.
|
|
@@ -638,7 +678,7 @@ export class PikkuWorkflowService {
|
|
|
638
678
|
throw new Error(`Workflow step '${stepName}' (function '${rpcName}') is marked 'workflowQueued: true' but no queue service is configured.`);
|
|
639
679
|
}
|
|
640
680
|
try {
|
|
641
|
-
await getSingletonServices().queueService.add(this.getStepWorkerQueueName(rpcName),
|
|
681
|
+
await getSingletonServices().queueService.add(this.getStepWorkerQueueName(rpcName), { runId, stepName, rpcName, data, fromStepName }, {
|
|
642
682
|
...this.resolveStepJobOptions(stepOptions),
|
|
643
683
|
group: this.getJobGroup(rpcName),
|
|
644
684
|
});
|
|
@@ -669,58 +709,44 @@ export class PikkuWorkflowService {
|
|
|
669
709
|
await getSingletonServices().schedulerService.scheduleRPC(duration, this.getConfig().sleeperRPCName, { runId, stepId });
|
|
670
710
|
return true;
|
|
671
711
|
}
|
|
672
|
-
/**
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
'/api/auth/sign-in/actor';
|
|
692
|
-
const rpcPath = (await variables.get('SCENARIO_RPC_PATH')) ?? '/rpc';
|
|
693
|
-
return createHttpScenarioActors({
|
|
694
|
-
apiUrl,
|
|
695
|
-
secret,
|
|
696
|
-
actors: actorsConfig,
|
|
697
|
-
signInPath,
|
|
698
|
-
rpcPath,
|
|
699
|
-
});
|
|
712
|
+
/**
|
|
713
|
+
* Install the one extension a run may have, built from a handle onto the run
|
|
714
|
+
* engine so that `inlineStep` and friends stay protected rather than becoming
|
|
715
|
+
* public API. Returns the extension, so the caller keeps a typed reference to
|
|
716
|
+
* whatever it just built.
|
|
717
|
+
*/
|
|
718
|
+
setRunExtension(create) {
|
|
719
|
+
const engine = {
|
|
720
|
+
inlineStep: this.inlineStep.bind(this),
|
|
721
|
+
updateRunStatus: this.updateRunStatus.bind(this),
|
|
722
|
+
onChildWorkflowFailed: this.onChildWorkflowFailed.bind(this),
|
|
723
|
+
verifyStepName: this.verifyStepName.bind(this),
|
|
724
|
+
};
|
|
725
|
+
const extension = create(engine);
|
|
726
|
+
this.runExtension = extension;
|
|
727
|
+
return extension;
|
|
728
|
+
}
|
|
729
|
+
getRunExtension() {
|
|
730
|
+
return this.runExtension;
|
|
700
731
|
}
|
|
701
732
|
/**
|
|
702
733
|
* Start a new workflow run
|
|
703
734
|
* Automatically detects workflow type (DSL or graph) from meta and executes accordingly
|
|
704
735
|
* @param options.inline - If true, execute workflow directly without queue service
|
|
705
736
|
* @param options.startNode - Starting node ID for graph workflows (from wire config)
|
|
737
|
+
* @param options.onRunCreated - Called with the run id the moment the run exists.
|
|
738
|
+
* An inline run that fails throws instead of returning, so this is the only
|
|
739
|
+
* way a caller can still read that run back — its steps, and which one failed.
|
|
706
740
|
*/
|
|
707
741
|
async startWorkflow(name, input, wire, rpcService, options) {
|
|
708
|
-
// Resolve workflow from static meta (root or addon namespace)
|
|
742
|
+
// Resolve workflow from static meta (root or addon namespace)
|
|
709
743
|
const resolved = resolveWorkflowMeta(name);
|
|
710
|
-
|
|
744
|
+
const workflowMeta = resolved?.meta;
|
|
711
745
|
const packageName = resolved?.packageName ?? null;
|
|
712
|
-
if (!workflowMeta) {
|
|
713
|
-
const dynamicWorkflows = await this.getAIGeneratedWorkflows();
|
|
714
|
-
const match = dynamicWorkflows.find((w) => w.workflowName === name);
|
|
715
|
-
if (match?.graph) {
|
|
716
|
-
workflowMeta = match.graph;
|
|
717
|
-
}
|
|
718
|
-
}
|
|
719
746
|
if (!workflowMeta) {
|
|
720
747
|
throw new WorkflowNotFoundError(name);
|
|
721
748
|
}
|
|
722
|
-
if (workflowMeta.source === 'graph'
|
|
723
|
-
workflowMeta.source === 'dynamic-workflow') {
|
|
749
|
+
if (workflowMeta.source === 'graph') {
|
|
724
750
|
const shouldInline = options?.inline || !getSingletonServices()?.queueService;
|
|
725
751
|
return runWorkflowGraph(this, name, input, rpcService, shouldInline, options?.startNode, wire, workflowMeta);
|
|
726
752
|
}
|
|
@@ -738,15 +764,10 @@ export class PikkuWorkflowService {
|
|
|
738
764
|
deterministic: workflowMeta.deterministic,
|
|
739
765
|
plannedSteps: workflowMeta.plannedSteps,
|
|
740
766
|
});
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
? await this.resolveScenarioActors()
|
|
744
|
-
: undefined);
|
|
745
|
-
if (actors) {
|
|
746
|
-
this.runActors.set(runId, actors);
|
|
747
|
-
}
|
|
767
|
+
options?.onRunCreated?.(runId);
|
|
768
|
+
await this.runExtension?.attachRunContext(runId, workflowMeta, options);
|
|
748
769
|
if (shouldInline) {
|
|
749
|
-
this.
|
|
770
|
+
this.registerInlineRun(runId);
|
|
750
771
|
try {
|
|
751
772
|
await this.runWorkflowJob(runId, rpcService);
|
|
752
773
|
}
|
|
@@ -776,8 +797,8 @@ export class PikkuWorkflowService {
|
|
|
776
797
|
}
|
|
777
798
|
}
|
|
778
799
|
finally {
|
|
779
|
-
this.
|
|
780
|
-
this.
|
|
800
|
+
this.unregisterInlineRun(runId);
|
|
801
|
+
this.runExtension?.detachRunContext(runId);
|
|
781
802
|
}
|
|
782
803
|
}
|
|
783
804
|
else {
|
|
@@ -786,37 +807,143 @@ export class PikkuWorkflowService {
|
|
|
786
807
|
return { runId };
|
|
787
808
|
}
|
|
788
809
|
async runToCompletion(name, input, rpcService, options) {
|
|
789
|
-
const pollInterval = options?.pollIntervalMs ?? 1000;
|
|
790
810
|
const { runId } = await this.startWorkflow(name, input, options?.wire ?? { type: 'internal' }, rpcService, { inline: true });
|
|
811
|
+
const run = await this.awaitRunEnd(runId, options?.pollIntervalMs ?? 1000);
|
|
812
|
+
if (run.status === 'failed') {
|
|
813
|
+
throw new WorkflowRunFailedError(run.error?.message);
|
|
814
|
+
}
|
|
815
|
+
if (run.status === 'cancelled') {
|
|
816
|
+
throw new WorkflowRunCancelledError();
|
|
817
|
+
}
|
|
818
|
+
return run.output;
|
|
819
|
+
}
|
|
820
|
+
/**
|
|
821
|
+
* Read a run until it reaches an end state, backing off as it drags on.
|
|
822
|
+
*
|
|
823
|
+
* A fixed interval is wrong at both ends: it makes a workflow that finished
|
|
824
|
+
* in milliseconds wait out the whole interval anyway, and it keeps reading a
|
|
825
|
+
* long-running one at full rate for as long as it lasts. Starting short and
|
|
826
|
+
* growing to `maxIntervalMs` returns quick runs promptly while a slow run's
|
|
827
|
+
* read cost grows logarithmically rather than linearly with its duration.
|
|
828
|
+
*/
|
|
829
|
+
async awaitRunEnd(runId, maxIntervalMs) {
|
|
830
|
+
let interval = Math.min(WORKFLOW_POLL_MIN_MS, maxIntervalMs);
|
|
791
831
|
while (true) {
|
|
792
832
|
const run = await this.getRun(runId);
|
|
793
833
|
if (!run) {
|
|
794
834
|
throw new WorkflowRunNotFoundError(runId);
|
|
795
835
|
}
|
|
796
836
|
if (WORKFLOW_END_STATES.has(run.status)) {
|
|
797
|
-
|
|
798
|
-
throw new WorkflowRunFailedError(run.error?.message);
|
|
799
|
-
}
|
|
800
|
-
if (run.status === 'cancelled') {
|
|
801
|
-
throw new WorkflowRunCancelledError();
|
|
802
|
-
}
|
|
803
|
-
return run.output;
|
|
837
|
+
return run;
|
|
804
838
|
}
|
|
805
|
-
await
|
|
839
|
+
await this.waitBeforeNextRead(interval);
|
|
840
|
+
interval = Math.min(interval * WORKFLOW_POLL_FACTOR, maxIntervalMs);
|
|
806
841
|
}
|
|
807
842
|
}
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
843
|
+
/**
|
|
844
|
+
* Wait between two reads of a run.
|
|
845
|
+
*
|
|
846
|
+
* Its own method so the backoff schedule can be asserted on directly. Timing
|
|
847
|
+
* a poll loop by the clock measures the host's scheduler as much as the
|
|
848
|
+
* policy — `setTimeout(40)` routinely returns late on a loaded runner — which
|
|
849
|
+
* makes the obvious test both slow and flaky.
|
|
850
|
+
*/
|
|
851
|
+
async waitBeforeNextRead(ms) {
|
|
852
|
+
await new Promise((resolve) => setTimeout(resolve, ms));
|
|
853
|
+
}
|
|
854
|
+
/**
|
|
855
|
+
* Every step of a run in one read, or `null` if this backend has no bulk read.
|
|
856
|
+
*
|
|
857
|
+
* A replay walks the DSL body from the top, and each step it passes asks for
|
|
858
|
+
* its own row — so a run of N steps costs N reads per replay and O(N^2) over
|
|
859
|
+
* its lifetime. Backends that can answer this in a single query collapse that
|
|
860
|
+
* to one read per replay.
|
|
861
|
+
*/
|
|
862
|
+
async listStepStates(_runId) {
|
|
863
|
+
return null;
|
|
864
|
+
}
|
|
865
|
+
/**
|
|
866
|
+
* Begin a replay pass: fresh ordinal counters, and one read of the steps the
|
|
867
|
+
* run has already taken so the walk back to where it left off is served from
|
|
868
|
+
* memory. Safe because a pass reaches each step key at most once, and the
|
|
869
|
+
* steps it replays past are `succeeded` and therefore immutable.
|
|
870
|
+
*/
|
|
871
|
+
async beginReplay(runId) {
|
|
872
|
+
const context = this.contextFor(runId);
|
|
873
|
+
context.replay = { ordinals: new Map() };
|
|
874
|
+
const steps = await this.listStepStates(runId);
|
|
875
|
+
if (steps) {
|
|
876
|
+
context.replay.steps = new Map(steps.map((step) => [step.stepName, step]));
|
|
877
|
+
}
|
|
878
|
+
}
|
|
879
|
+
endReplay(runId) {
|
|
880
|
+
const context = this.runContexts.get(runId);
|
|
881
|
+
if (!context)
|
|
882
|
+
return;
|
|
883
|
+
context.replay = undefined;
|
|
884
|
+
this.releaseContext(runId);
|
|
885
|
+
}
|
|
886
|
+
/**
|
|
887
|
+
* The step row for `stepName`, creating it if the run has not reached it
|
|
888
|
+
* before. Served from the replay snapshot when one is loaded.
|
|
889
|
+
*/
|
|
890
|
+
async loadOrCreateStep(runId, stepName, create) {
|
|
891
|
+
const snapshot = this.runContexts.get(runId)?.replay?.steps;
|
|
892
|
+
if (snapshot) {
|
|
893
|
+
const cached = snapshot.get(stepName);
|
|
894
|
+
if (cached) {
|
|
895
|
+
return cached;
|
|
896
|
+
}
|
|
897
|
+
}
|
|
898
|
+
else {
|
|
899
|
+
try {
|
|
900
|
+
return await this.getStepState(runId, stepName);
|
|
901
|
+
}
|
|
902
|
+
catch {
|
|
903
|
+
// No row yet — fall through and create it.
|
|
904
|
+
}
|
|
905
|
+
}
|
|
906
|
+
let step;
|
|
907
|
+
try {
|
|
908
|
+
step = await create();
|
|
909
|
+
}
|
|
910
|
+
catch (error) {
|
|
911
|
+
// A concurrent replay of this run created the row after the snapshot was
|
|
912
|
+
// taken. Its state is the truth; if it isn't really there, the insert
|
|
913
|
+
// failed for its own reasons and that error is the one worth seeing.
|
|
914
|
+
try {
|
|
915
|
+
step = await this.getStepState(runId, stepName);
|
|
916
|
+
}
|
|
917
|
+
catch {
|
|
918
|
+
throw error;
|
|
919
|
+
}
|
|
920
|
+
}
|
|
921
|
+
snapshot?.set(stepName, step);
|
|
922
|
+
return step;
|
|
923
|
+
}
|
|
924
|
+
/**
|
|
925
|
+
* The run's immutable half — which workflow it is, the wire it was started
|
|
926
|
+
* on, its input. `getRun` is otherwise called several times per step for
|
|
927
|
+
* answers that were all fixed at creation, so a replay reads it once and
|
|
928
|
+
* hands the same object to everyone who only needs that half.
|
|
929
|
+
*
|
|
930
|
+
* Anyone who needs `status`, `output`, `error` or `state` must call `getRun`:
|
|
931
|
+
* those move while the run executes, and a cached copy would be a lie.
|
|
932
|
+
*/
|
|
933
|
+
async getRunIdentity(runId) {
|
|
934
|
+
const replay = this.runContexts.get(runId)?.replay;
|
|
935
|
+
if (replay?.run) {
|
|
936
|
+
return replay.run;
|
|
937
|
+
}
|
|
938
|
+
const run = await this.getRun(runId);
|
|
939
|
+
if (run && replay) {
|
|
940
|
+
replay.run = run;
|
|
941
|
+
}
|
|
942
|
+
return run;
|
|
816
943
|
}
|
|
817
944
|
/** The step the DSL walk last reached (the predecessor for the next step). */
|
|
818
945
|
lastStepName(runId) {
|
|
819
|
-
return this.
|
|
946
|
+
return this.runContexts.get(runId)?.replay?.lastStep;
|
|
820
947
|
}
|
|
821
948
|
/**
|
|
822
949
|
* Physical, replay-stable key for the Nth reach of `logicalStepName` in a run:
|
|
@@ -825,29 +952,31 @@ export class PikkuWorkflowService {
|
|
|
825
952
|
* the rows clobbering. Deterministic given a deterministic DSL body.
|
|
826
953
|
*/
|
|
827
954
|
nextStepKey(runId, logicalStepName) {
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
perRun.set(logicalStepName, ordinal + 1);
|
|
955
|
+
const context = this.contextFor(runId);
|
|
956
|
+
const replay = (context.replay ??= {
|
|
957
|
+
ordinals: new Map(),
|
|
958
|
+
});
|
|
959
|
+
const ordinal = replay.ordinals.get(logicalStepName) ?? 0;
|
|
960
|
+
replay.ordinals.set(logicalStepName, ordinal + 1);
|
|
835
961
|
const stepName = ordinal === 0 ? logicalStepName : `${logicalStepName}#${ordinal}`;
|
|
836
|
-
|
|
962
|
+
replay.lastStep = stepName;
|
|
837
963
|
return stepName;
|
|
838
964
|
}
|
|
839
965
|
async runWorkflowJob(runId, rpcService) {
|
|
840
|
-
// Fresh ordinal counters per replay so step keys are deterministic
|
|
841
|
-
|
|
966
|
+
// Fresh ordinal counters per replay so step keys are deterministic, and one
|
|
967
|
+
// read of the steps the run has already taken.
|
|
968
|
+
await this.beginReplay(runId);
|
|
842
969
|
try {
|
|
843
970
|
await this.runWorkflowJobInner(runId, rpcService);
|
|
844
971
|
}
|
|
845
972
|
finally {
|
|
846
|
-
this.
|
|
973
|
+
this.endReplay(runId);
|
|
847
974
|
}
|
|
848
975
|
}
|
|
849
976
|
async runWorkflowJobInner(runId, rpcService) {
|
|
850
|
-
|
|
977
|
+
// Caches the run for the rest of this replay, so the steps it walks don't
|
|
978
|
+
// each re-read the workflow name and wire it already has.
|
|
979
|
+
const run = await this.getRunIdentity(runId);
|
|
851
980
|
if (!run) {
|
|
852
981
|
throw new WorkflowRunNotFoundError(runId);
|
|
853
982
|
}
|
|
@@ -860,8 +989,7 @@ export class PikkuWorkflowService {
|
|
|
860
989
|
await this.runVersionMismatchFallback(run, workflowMeta, rpcService);
|
|
861
990
|
return;
|
|
862
991
|
}
|
|
863
|
-
if (workflowMeta?.source === 'graph'
|
|
864
|
-
workflowMeta?.source === 'dynamic-workflow') {
|
|
992
|
+
if (workflowMeta?.source === 'graph') {
|
|
865
993
|
await continueGraph(this, runId, run.workflow);
|
|
866
994
|
const updatedRun = await this.getRun(runId);
|
|
867
995
|
if (updatedRun?.status === 'completed') {
|
|
@@ -873,22 +1001,6 @@ export class PikkuWorkflowService {
|
|
|
873
1001
|
}
|
|
874
1002
|
return;
|
|
875
1003
|
}
|
|
876
|
-
if (!workflowMeta) {
|
|
877
|
-
const dynamicWorkflows = await this.getAIGeneratedWorkflows();
|
|
878
|
-
const match = dynamicWorkflows.find((w) => w.workflowName === run.workflow);
|
|
879
|
-
if (match?.graph) {
|
|
880
|
-
await continueGraph(this, runId, run.workflow, match.graph);
|
|
881
|
-
const updatedRun = await this.getRun(runId);
|
|
882
|
-
if (updatedRun?.status === 'completed') {
|
|
883
|
-
await this.onChildWorkflowCompleted(updatedRun, updatedRun.output);
|
|
884
|
-
}
|
|
885
|
-
else if (updatedRun?.status === 'failed' ||
|
|
886
|
-
updatedRun?.status === 'cancelled') {
|
|
887
|
-
await this.onChildWorkflowFailed(updatedRun, new Error(updatedRun.error?.message || 'Child workflow failed'));
|
|
888
|
-
}
|
|
889
|
-
return;
|
|
890
|
-
}
|
|
891
|
-
}
|
|
892
1004
|
const registrations = pikkuState(pkgName, 'workflows', 'registrations');
|
|
893
1005
|
const workflow = registrations.get(resolved?.resolvedName ?? run.workflow);
|
|
894
1006
|
if (!workflow) {
|
|
@@ -902,14 +1014,30 @@ export class PikkuWorkflowService {
|
|
|
902
1014
|
workflowWire.pikkuUserId = run.wire?.pikkuUserId;
|
|
903
1015
|
const wire = {
|
|
904
1016
|
workflow: workflowWire,
|
|
905
|
-
scenario: workflowMeta?.source === 'scenario' ? workflowWire : undefined,
|
|
906
1017
|
pikkuUserId: run.wire?.pikkuUserId,
|
|
907
1018
|
session: rpcService?.wire?.session,
|
|
908
1019
|
rpc: rpcService?.wire?.rpc,
|
|
909
|
-
// User-flow actors registered for this run (see startWorkflow options)
|
|
910
|
-
actors: this.runActors.get(runId),
|
|
911
1020
|
};
|
|
1021
|
+
this.runExtension?.decorateRunWire(wire, {
|
|
1022
|
+
runId,
|
|
1023
|
+
workflowMeta,
|
|
1024
|
+
workflowWire,
|
|
1025
|
+
});
|
|
1026
|
+
const lifecycle = {
|
|
1027
|
+
runId,
|
|
1028
|
+
run,
|
|
1029
|
+
workflowMeta,
|
|
1030
|
+
workflow,
|
|
1031
|
+
wire,
|
|
1032
|
+
packageName: pkgName,
|
|
1033
|
+
};
|
|
1034
|
+
// `interrupted` means the run has not reached a terminal state — it is
|
|
1035
|
+
// suspended or waiting — so teardown would run while the run is still
|
|
1036
|
+
// mid-flight.
|
|
1037
|
+
let outcome = 'completed';
|
|
1038
|
+
let failure;
|
|
912
1039
|
try {
|
|
1040
|
+
await this.runExtension?.onBeforeRunFunc(lifecycle);
|
|
913
1041
|
const result = await runPikkuFunc('workflow', workflowMeta.name, workflowMeta.pikkuFuncId, {
|
|
914
1042
|
singletonServices: getSingletonServices(),
|
|
915
1043
|
wire,
|
|
@@ -921,10 +1049,13 @@ export class PikkuWorkflowService {
|
|
|
921
1049
|
await this.onChildWorkflowCompleted(run, result);
|
|
922
1050
|
}
|
|
923
1051
|
catch (error) {
|
|
1052
|
+
failure = error;
|
|
924
1053
|
if (error instanceof WorkflowAsyncException) {
|
|
1054
|
+
outcome = 'interrupted';
|
|
925
1055
|
throw error;
|
|
926
1056
|
}
|
|
927
1057
|
if (error instanceof WorkflowCancelledException) {
|
|
1058
|
+
outcome = 'failed';
|
|
928
1059
|
await this.updateRunStatus(runId, 'cancelled', undefined, {
|
|
929
1060
|
message: error.message || 'Workflow cancelled',
|
|
930
1061
|
stack: '',
|
|
@@ -934,6 +1065,7 @@ export class PikkuWorkflowService {
|
|
|
934
1065
|
throw error;
|
|
935
1066
|
}
|
|
936
1067
|
if (error instanceof WorkflowSuspendedException) {
|
|
1068
|
+
outcome = 'interrupted';
|
|
937
1069
|
await this.updateRunStatus(runId, 'suspended', undefined, {
|
|
938
1070
|
message: error.message || 'Workflow suspended',
|
|
939
1071
|
stack: '',
|
|
@@ -941,6 +1073,7 @@ export class PikkuWorkflowService {
|
|
|
941
1073
|
});
|
|
942
1074
|
throw error;
|
|
943
1075
|
}
|
|
1076
|
+
outcome = 'failed';
|
|
944
1077
|
await this.updateRunStatus(runId, 'failed', undefined, {
|
|
945
1078
|
message: error.message,
|
|
946
1079
|
stack: error.stack,
|
|
@@ -949,6 +1082,9 @@ export class PikkuWorkflowService {
|
|
|
949
1082
|
await this.onChildWorkflowFailed(run, error);
|
|
950
1083
|
throw error;
|
|
951
1084
|
}
|
|
1085
|
+
finally {
|
|
1086
|
+
await this.runExtension?.onAfterRunFunc(lifecycle, outcome, failure);
|
|
1087
|
+
}
|
|
952
1088
|
});
|
|
953
1089
|
}
|
|
954
1090
|
async onChildWorkflowCompleted(childRun, result) {
|
|
@@ -1027,8 +1163,7 @@ export class PikkuWorkflowService {
|
|
|
1027
1163
|
}
|
|
1028
1164
|
const meta = pikkuState(null, 'workflows', 'meta');
|
|
1029
1165
|
const workflowMeta = meta[run.workflow];
|
|
1030
|
-
const isGraphWorkflow = workflowMeta?.source === 'graph'
|
|
1031
|
-
workflowMeta?.source === 'dynamic-workflow';
|
|
1166
|
+
const isGraphWorkflow = workflowMeta?.source === 'graph';
|
|
1032
1167
|
// Map the physical step key back to its logical node: a revisit instance
|
|
1033
1168
|
// is `node#N` (ordinal), which isn't a literal key in `nodes`.
|
|
1034
1169
|
let graphNodeId;
|
|
@@ -1075,7 +1210,7 @@ export class PikkuWorkflowService {
|
|
|
1075
1210
|
}
|
|
1076
1211
|
}
|
|
1077
1212
|
else {
|
|
1078
|
-
result = await this.invokeStepRpc(runId, stepName, stepState, rpcName, data, rpcService);
|
|
1213
|
+
result = await this.invokeStepRpc(runId, stepName, stepState, rpcName, data, rpcService, run);
|
|
1079
1214
|
}
|
|
1080
1215
|
}
|
|
1081
1216
|
// Store result and mark succeeded
|
|
@@ -1149,10 +1284,10 @@ export class PikkuWorkflowService {
|
|
|
1149
1284
|
* Identical for the queue executor and the inline executor — the only thing
|
|
1150
1285
|
* that differs between transports is who calls it, not the call itself.
|
|
1151
1286
|
*/
|
|
1152
|
-
async invokeStepRpc(runId, stepName, stepState, rpcName, data, rpcService) {
|
|
1287
|
+
async invokeStepRpc(runId, stepName, stepState, rpcName, data, rpcService, knownRun) {
|
|
1153
1288
|
// Carry the run's pikkuUserId onto the step wire so authed steps rehydrate their
|
|
1154
1289
|
// session on the queued path too (the bare job wire lacks it; inline already has it).
|
|
1155
|
-
const run = await this.
|
|
1290
|
+
const run = knownRun ?? (await this.getRunIdentity(runId));
|
|
1156
1291
|
return rpcService.rpcWithWire(rpcName, data, {
|
|
1157
1292
|
...(run?.wire?.pikkuUserId ? { pikkuUserId: run.wire.pikkuUserId } : {}),
|
|
1158
1293
|
workflowStep: {
|
|
@@ -1229,15 +1364,8 @@ export class PikkuWorkflowService {
|
|
|
1229
1364
|
actor: stepOptions?.actor,
|
|
1230
1365
|
onError: stepOptions?.onError,
|
|
1231
1366
|
};
|
|
1232
|
-
//
|
|
1233
|
-
|
|
1234
|
-
try {
|
|
1235
|
-
stepState = await this.getStepState(runId, stepName);
|
|
1236
|
-
}
|
|
1237
|
-
catch {
|
|
1238
|
-
// Step doesn't exist - create it
|
|
1239
|
-
stepState = await this.insertStepState(runId, stepName, rpcName, data, resolvedStepOptions, fromStepName);
|
|
1240
|
-
}
|
|
1367
|
+
// Reuse the step if the run already reached it, otherwise create it.
|
|
1368
|
+
const stepState = await this.loadOrCreateStep(runId, stepName, () => this.insertStepState(runId, stepName, rpcName, data, resolvedStepOptions, fromStepName));
|
|
1241
1369
|
if (stepState.status === 'succeeded') {
|
|
1242
1370
|
// Return cached result
|
|
1243
1371
|
return stepState.result;
|
|
@@ -1300,22 +1428,14 @@ export class PikkuWorkflowService {
|
|
|
1300
1428
|
const { runId: childRunId } = await this.startWorkflow(rpcName, data, childWire, rpcService, { inline: true });
|
|
1301
1429
|
await this.setStepChildRunId(currentStepState.stepId, childRunId);
|
|
1302
1430
|
// Poll until child workflow completes
|
|
1303
|
-
|
|
1304
|
-
|
|
1305
|
-
|
|
1306
|
-
|
|
1307
|
-
|
|
1308
|
-
|
|
1309
|
-
if (childRun.status === 'failed') {
|
|
1310
|
-
throw new Error(childRun.error?.message || 'Sub-workflow failed');
|
|
1311
|
-
}
|
|
1312
|
-
if (childRun.status === 'cancelled') {
|
|
1313
|
-
throw new Error('Sub-workflow was cancelled');
|
|
1314
|
-
}
|
|
1315
|
-
return childRun.output;
|
|
1316
|
-
}
|
|
1317
|
-
await new Promise((resolve) => setTimeout(resolve, 500));
|
|
1431
|
+
const childRun = await this.awaitRunEnd(childRunId, WORKFLOW_CHILD_POLL_MAX_MS);
|
|
1432
|
+
if (childRun.status === 'failed') {
|
|
1433
|
+
throw new Error(childRun.error?.message || 'Sub-workflow failed');
|
|
1434
|
+
}
|
|
1435
|
+
if (childRun.status === 'cancelled') {
|
|
1436
|
+
throw new Error('Sub-workflow was cancelled');
|
|
1318
1437
|
}
|
|
1438
|
+
return childRun.output;
|
|
1319
1439
|
}
|
|
1320
1440
|
return this.invokeStepRpc(runId, stepName, currentStepState, rpcName, data, rpcService);
|
|
1321
1441
|
}, async (error) => {
|
|
@@ -1328,18 +1448,26 @@ export class PikkuWorkflowService {
|
|
|
1328
1448
|
}
|
|
1329
1449
|
});
|
|
1330
1450
|
}
|
|
1331
|
-
async inlineStep(runId, logicalStepName, fn, stepOptions
|
|
1451
|
+
async inlineStep(runId, logicalStepName, fn, stepOptions,
|
|
1452
|
+
/**
|
|
1453
|
+
* The input this step was called with, recorded on the run so a reporter can
|
|
1454
|
+
* name the values under test. A closure step has none; a scenario step does.
|
|
1455
|
+
*/
|
|
1456
|
+
data = null,
|
|
1457
|
+
/**
|
|
1458
|
+
* The name this step was dispatched by, for the kinds of inline step that
|
|
1459
|
+
* have one. A closure step has no name; a scenario step is a step RPC, so
|
|
1460
|
+
* it records the step function that ran — which is the only way to join a
|
|
1461
|
+
* step back to its declaration when its durable name was built at runtime
|
|
1462
|
+
* (a step called in a loop reaches the run as `sees @pikku/addon-todos`,
|
|
1463
|
+
* declared as `sees ${packageName}`).
|
|
1464
|
+
*/
|
|
1465
|
+
rpcName = null) {
|
|
1332
1466
|
const fromStepName = this.lastStepName(runId);
|
|
1333
1467
|
const stepName = this.nextStepKey(runId, logicalStepName);
|
|
1334
|
-
//
|
|
1335
|
-
|
|
1336
|
-
|
|
1337
|
-
stepState = await this.getStepState(runId, stepName);
|
|
1338
|
-
}
|
|
1339
|
-
catch {
|
|
1340
|
-
// Step doesn't exist - create it (inline, no RPC)
|
|
1341
|
-
stepState = await this.insertStepState(runId, stepName, null, null, stepOptions, fromStepName);
|
|
1342
|
-
}
|
|
1468
|
+
// Reuse the step if the run already reached it, otherwise create it
|
|
1469
|
+
// (inline, so never dispatched).
|
|
1470
|
+
const stepState = await this.loadOrCreateStep(runId, stepName, () => this.insertStepState(runId, stepName, rpcName, data, stepOptions, fromStepName));
|
|
1343
1471
|
if (stepState.status === 'succeeded') {
|
|
1344
1472
|
// Return cached result
|
|
1345
1473
|
return stepState.result;
|
|
@@ -1381,15 +1509,9 @@ export class PikkuWorkflowService {
|
|
|
1381
1509
|
async sleepStep(runId, logicalStepName, duration) {
|
|
1382
1510
|
const fromStepName = this.lastStepName(runId);
|
|
1383
1511
|
const stepName = this.nextStepKey(runId, logicalStepName);
|
|
1384
|
-
//
|
|
1385
|
-
|
|
1386
|
-
|
|
1387
|
-
stepState = await this.getStepState(runId, stepName);
|
|
1388
|
-
}
|
|
1389
|
-
catch {
|
|
1390
|
-
// Step doesn't exist - create it (sleep step, no RPC)
|
|
1391
|
-
stepState = await this.insertStepState(runId, stepName, null, { duration }, undefined, fromStepName);
|
|
1392
|
-
}
|
|
1512
|
+
// Reuse the step if the run already reached it, otherwise create it
|
|
1513
|
+
// (sleep step, no RPC).
|
|
1514
|
+
const stepState = await this.loadOrCreateStep(runId, stepName, () => this.insertStepState(runId, stepName, null, { duration }, undefined, fromStepName));
|
|
1393
1515
|
if (stepState.status === 'succeeded') {
|
|
1394
1516
|
// Sleep already completed, return immediately
|
|
1395
1517
|
return;
|
|
@@ -1635,90 +1757,6 @@ export class PikkuWorkflowService {
|
|
|
1635
1757
|
return await this.inlineStep(runId, stepName, rpcNameOrFn, dataOrOptions);
|
|
1636
1758
|
}
|
|
1637
1759
|
},
|
|
1638
|
-
// Durable polling step: invoke an RPC (as an actor when options.as is
|
|
1639
|
-
// set) until the predicate passes or `within` elapses. The whole poll is
|
|
1640
|
-
// ONE recorded step, so replay returns the cached outcome.
|
|
1641
|
-
expectEventually: async (stepName, rpcName, data, predicate, options) => {
|
|
1642
|
-
this.verifyStepName(stepName);
|
|
1643
|
-
const resolvedRpcName = addonNamespace && !rpcName.includes(':')
|
|
1644
|
-
? `${addonNamespace}:${rpcName}`
|
|
1645
|
-
: rpcName;
|
|
1646
|
-
const within = getDurationInMilliseconds(options?.within ?? '30s');
|
|
1647
|
-
const interval = getDurationInMilliseconds(options?.interval ?? '1s');
|
|
1648
|
-
return await this.inlineStep(runId, stepName, async () => {
|
|
1649
|
-
const deadline = Date.now() + within;
|
|
1650
|
-
let last;
|
|
1651
|
-
while (true) {
|
|
1652
|
-
last = options?.actor
|
|
1653
|
-
? await options.actor.invoke(resolvedRpcName, data)
|
|
1654
|
-
: await rpcService.rpcWithWire(resolvedRpcName, data, {});
|
|
1655
|
-
if (predicate(last))
|
|
1656
|
-
return last;
|
|
1657
|
-
if (Date.now() + interval > deadline) {
|
|
1658
|
-
throw new Error(`[workflow] expectEventually '${stepName}' ('${resolvedRpcName}'` +
|
|
1659
|
-
`${options?.actor ? ` as '${options.actor.name}'` : ''}) did not pass within ${within}ms; ` +
|
|
1660
|
-
`last result: ${JSON.stringify(last)?.slice(0, 300)}`);
|
|
1661
|
-
}
|
|
1662
|
-
await new Promise((resolve) => setTimeout(resolve, interval));
|
|
1663
|
-
}
|
|
1664
|
-
}, options);
|
|
1665
|
-
},
|
|
1666
|
-
expectError: async (stepName, rpcName, data, options) => {
|
|
1667
|
-
this.verifyStepName(stepName);
|
|
1668
|
-
const resolvedRpcName = addonNamespace && !rpcName.includes(':')
|
|
1669
|
-
? `${addonNamespace}:${rpcName}`
|
|
1670
|
-
: rpcName;
|
|
1671
|
-
return await this.inlineStep(runId, stepName, async () => {
|
|
1672
|
-
let result;
|
|
1673
|
-
try {
|
|
1674
|
-
result = options?.actor
|
|
1675
|
-
? await options.actor.invoke(resolvedRpcName, data)
|
|
1676
|
-
: await rpcService.rpcWithWire(resolvedRpcName, data, {});
|
|
1677
|
-
}
|
|
1678
|
-
catch (e) {
|
|
1679
|
-
const message = e?.message ?? String(e);
|
|
1680
|
-
if (options?.matches) {
|
|
1681
|
-
const matched = typeof options.matches === 'string'
|
|
1682
|
-
? message.includes(options.matches)
|
|
1683
|
-
: options.matches.test(message);
|
|
1684
|
-
if (!matched) {
|
|
1685
|
-
throw new Error(`[workflow] expectError '${stepName}' ('${resolvedRpcName}') threw, but the message did not match ${options.matches}: ${message}`);
|
|
1686
|
-
}
|
|
1687
|
-
}
|
|
1688
|
-
return message;
|
|
1689
|
-
}
|
|
1690
|
-
throw new Error(`[workflow] expectError '${stepName}' ('${resolvedRpcName}') expected an error but the call succeeded: ${JSON.stringify(result)?.slice(0, 300)}`);
|
|
1691
|
-
}, options);
|
|
1692
|
-
},
|
|
1693
|
-
expectService: async (stepName, serviceMethod, options) => {
|
|
1694
|
-
this.verifyStepName(stepName);
|
|
1695
|
-
const [service, method] = serviceMethod.split('.');
|
|
1696
|
-
if (!service || !method) {
|
|
1697
|
-
throw new Error(`[workflow] expectService '${stepName}' needs 'service.method', got '${serviceMethod}'`);
|
|
1698
|
-
}
|
|
1699
|
-
await this.inlineStep(runId, stepName, async () => {
|
|
1700
|
-
const rpcName = 'pikkuScenarioGetStubCalls';
|
|
1701
|
-
const calls = options?.actor
|
|
1702
|
-
? await options.actor.invoke(rpcName, { service })
|
|
1703
|
-
: await rpcService.rpcWithWire(rpcName, { service }, {});
|
|
1704
|
-
const matching = (calls ?? []).filter((c) => c.service === service &&
|
|
1705
|
-
c.method === method &&
|
|
1706
|
-
(options?.calledWith === undefined ||
|
|
1707
|
-
JSON.stringify(c.args?.[0]) ===
|
|
1708
|
-
JSON.stringify(options.calledWith)));
|
|
1709
|
-
const expected = options?.times;
|
|
1710
|
-
const ok = expected === undefined
|
|
1711
|
-
? matching.length > 0
|
|
1712
|
-
: matching.length === expected;
|
|
1713
|
-
if (!ok) {
|
|
1714
|
-
const seen = (calls ?? [])
|
|
1715
|
-
.map((c) => `${c.service}.${c.method}(${JSON.stringify(c.args?.[0])?.slice(0, 120) ?? ''})`)
|
|
1716
|
-
.join('\n ') || '(none)';
|
|
1717
|
-
throw new Error(`[workflow] expectService '${stepName}' expected ${expected ?? 'at least one'} call(s) to '${serviceMethod}'` +
|
|
1718
|
-
`${options?.calledWith !== undefined ? ` with ${JSON.stringify(options.calledWith)}` : ''}, found ${matching.length}. Recorded:\n ${seen}`);
|
|
1719
|
-
}
|
|
1720
|
-
}, options);
|
|
1721
|
-
},
|
|
1722
1760
|
// Implement workflow.sleep()
|
|
1723
1761
|
sleep: async (stepName, duration) => {
|
|
1724
1762
|
this.verifyStepName(stepName);
|
|
@@ -1732,10 +1770,13 @@ export class PikkuWorkflowService {
|
|
|
1732
1770
|
this.verifyStepName(reason);
|
|
1733
1771
|
return await this.approvalStep(runId, reason, options);
|
|
1734
1772
|
}),
|
|
1735
|
-
runScheduledTask: async (taskName) => {
|
|
1736
|
-
await runScheduledTask({ name: taskName });
|
|
1737
|
-
},
|
|
1738
1773
|
};
|
|
1774
|
+
this.runExtension?.decorateWorkflowWire(workflowWire, {
|
|
1775
|
+
name,
|
|
1776
|
+
runId,
|
|
1777
|
+
rpcService,
|
|
1778
|
+
addonNamespace,
|
|
1779
|
+
});
|
|
1739
1780
|
return workflowWire;
|
|
1740
1781
|
}
|
|
1741
1782
|
verifyStepName(stepName) {
|