@pikku/core 0.12.70 → 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 +88 -0
- package/LICENSE +21 -0
- 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/workflow-service.d.ts +7 -5
- package/dist/types/core.types.d.ts +7 -0
- 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/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 +0 -1
- package/dist/wirings/workflow/index.js +0 -2
- package/dist/wirings/workflow/pikku-workflow-service.d.ts +69 -15
- package/dist/wirings/workflow/pikku-workflow-service.js +260 -164
- package/dist/wirings/workflow/workflow.types.d.ts +1 -6
- package/package.json +1 -1
- 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/workflow-service.ts +7 -4
- package/src/types/core.types.ts +7 -0
- 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/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 +0 -6
- package/src/wirings/workflow/pikku-workflow-service.ts +377 -212
- package/src/wirings/workflow/scenario-expectations.test.ts +153 -0
- package/src/wirings/workflow/scenario-step.test.ts +1 -1
- 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 +1 -4
- package/tsconfig.tsbuildinfo +1 -1
|
@@ -171,13 +171,45 @@ const WORKFLOW_END_STATES = new Set([
|
|
|
171
171
|
'suspended',
|
|
172
172
|
]);
|
|
173
173
|
/**
|
|
174
|
-
*
|
|
175
|
-
*
|
|
176
|
-
*
|
|
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.
|
|
177
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;
|
|
178
193
|
export class PikkuWorkflowService {
|
|
179
|
-
inlineRuns = new Set();
|
|
180
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
|
+
}
|
|
181
213
|
get logger() {
|
|
182
214
|
return getSingletonServices()?.logger;
|
|
183
215
|
}
|
|
@@ -195,20 +227,33 @@ export class PikkuWorkflowService {
|
|
|
195
227
|
this.wireQueueWorkers();
|
|
196
228
|
}
|
|
197
229
|
}
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
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) {
|
|
205
244
|
try {
|
|
206
|
-
this.
|
|
245
|
+
await mirror(this.mirror, written);
|
|
207
246
|
}
|
|
208
|
-
catch {
|
|
209
|
-
|
|
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
|
+
}
|
|
210
254
|
}
|
|
211
255
|
}
|
|
256
|
+
return written;
|
|
212
257
|
}
|
|
213
258
|
/**
|
|
214
259
|
* Wire the queue-based orchestrator/step/sleeper workers.
|
|
@@ -306,19 +351,23 @@ export class PikkuWorkflowService {
|
|
|
306
351
|
* Check if a run is executing inline (without queues)
|
|
307
352
|
*/
|
|
308
353
|
isInline(runId) {
|
|
309
|
-
return this.
|
|
354
|
+
return this.runContexts.get(runId)?.inline === true;
|
|
310
355
|
}
|
|
311
356
|
/**
|
|
312
357
|
* Register a run as inline (for graph-runner to use)
|
|
313
358
|
*/
|
|
314
359
|
registerInlineRun(runId) {
|
|
315
|
-
this.
|
|
360
|
+
this.contextFor(runId).inline = true;
|
|
316
361
|
}
|
|
317
362
|
/**
|
|
318
363
|
* Unregister a run from inline tracking
|
|
319
364
|
*/
|
|
320
365
|
unregisterInlineRun(runId) {
|
|
321
|
-
this.
|
|
366
|
+
const context = this.runContexts.get(runId);
|
|
367
|
+
if (!context)
|
|
368
|
+
return;
|
|
369
|
+
context.inline = false;
|
|
370
|
+
this.releaseContext(runId);
|
|
322
371
|
}
|
|
323
372
|
async registerWorkflowVersions() {
|
|
324
373
|
const allMeta = pikkuState(null, 'workflows', 'meta');
|
|
@@ -329,9 +378,7 @@ export class PikkuWorkflowService {
|
|
|
329
378
|
}
|
|
330
379
|
}
|
|
331
380
|
async createRun(workflowName, input, inline, graphHash, wire, options) {
|
|
332
|
-
|
|
333
|
-
await this.safeMirror(() => this.mirror.createRun(runId, workflowName, input, inline, graphHash, wire, options));
|
|
334
|
-
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));
|
|
335
382
|
}
|
|
336
383
|
/**
|
|
337
384
|
* Get minimal workflow run status with step summaries.
|
|
@@ -408,8 +455,14 @@ export class PikkuWorkflowService {
|
|
|
408
455
|
* @param status - New status
|
|
409
456
|
*/
|
|
410
457
|
async updateRunStatus(id, status, output, error) {
|
|
411
|
-
await this.updateRunStatusImpl(id, status, output, error);
|
|
412
|
-
|
|
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
|
+
}
|
|
413
466
|
}
|
|
414
467
|
/**
|
|
415
468
|
* Insert initial step state (called by orchestrator)
|
|
@@ -425,9 +478,7 @@ export class PikkuWorkflowService {
|
|
|
425
478
|
* @returns Step state with generated stepId
|
|
426
479
|
*/
|
|
427
480
|
async insertStepState(runId, stepName, rpcName, data, stepOptions, fromStepName) {
|
|
428
|
-
|
|
429
|
-
await this.safeMirror(() => this.mirror.insertStepState(runId, { ...step, stepName, rpcName, data }));
|
|
430
|
-
return step;
|
|
481
|
+
return this.mirrored(() => this.insertStepStateImpl(runId, stepName, rpcName, data, stepOptions, fromStepName), (mirror, step) => mirror.insertStepState(runId, { ...step, stepName, rpcName, data }));
|
|
431
482
|
}
|
|
432
483
|
/**
|
|
433
484
|
* Mark step as running
|
|
@@ -435,8 +486,7 @@ export class PikkuWorkflowService {
|
|
|
435
486
|
* @param stepId - Step ID
|
|
436
487
|
*/
|
|
437
488
|
async setStepRunning(stepId) {
|
|
438
|
-
await this.setStepRunningImpl(stepId);
|
|
439
|
-
await this.safeMirror(() => this.mirror.setStepRunning(stepId));
|
|
489
|
+
await this.mirrored(() => this.setStepRunningImpl(stepId), (mirror) => mirror.setStepRunning(stepId));
|
|
440
490
|
}
|
|
441
491
|
/**
|
|
442
492
|
* Mark step as scheduled (queued for execution)
|
|
@@ -444,8 +494,7 @@ export class PikkuWorkflowService {
|
|
|
444
494
|
* @param stepId - Step ID
|
|
445
495
|
*/
|
|
446
496
|
async setStepScheduled(stepId) {
|
|
447
|
-
await this.setStepScheduledImpl(stepId);
|
|
448
|
-
await this.safeMirror(() => this.mirror.setStepScheduled(stepId));
|
|
497
|
+
await this.mirrored(() => this.setStepScheduledImpl(stepId), (mirror) => mirror.setStepScheduled(stepId));
|
|
449
498
|
}
|
|
450
499
|
/**
|
|
451
500
|
* Store step result and mark as succeeded
|
|
@@ -454,8 +503,7 @@ export class PikkuWorkflowService {
|
|
|
454
503
|
* @param result - Step result
|
|
455
504
|
*/
|
|
456
505
|
async setStepResult(stepId, result) {
|
|
457
|
-
await this.setStepResultImpl(stepId, result);
|
|
458
|
-
await this.safeMirror(() => this.mirror.setStepResult(stepId, result));
|
|
506
|
+
await this.mirrored(() => this.setStepResultImpl(stepId, result), (mirror) => mirror.setStepResult(stepId, result));
|
|
459
507
|
}
|
|
460
508
|
/**
|
|
461
509
|
* Set the child workflow run ID on a step
|
|
@@ -463,8 +511,7 @@ export class PikkuWorkflowService {
|
|
|
463
511
|
* @param childRunId - Child workflow run ID
|
|
464
512
|
*/
|
|
465
513
|
async setStepChildRunId(stepId, childRunId) {
|
|
466
|
-
await this.setStepChildRunIdImpl(stepId, childRunId);
|
|
467
|
-
await this.safeMirror(() => this.mirror.setStepChildRunId(stepId, childRunId));
|
|
514
|
+
await this.mirrored(() => this.setStepChildRunIdImpl(stepId, childRunId), (mirror) => mirror.setStepChildRunId(stepId, childRunId));
|
|
468
515
|
}
|
|
469
516
|
/**
|
|
470
517
|
* Store step error and mark as failed
|
|
@@ -473,14 +520,15 @@ export class PikkuWorkflowService {
|
|
|
473
520
|
* @param error - Error object
|
|
474
521
|
*/
|
|
475
522
|
async setStepError(stepId, error) {
|
|
476
|
-
await this.setStepErrorImpl(stepId, error)
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
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
|
+
});
|
|
484
532
|
}
|
|
485
533
|
/**
|
|
486
534
|
* Create a new retry attempt for a failed step
|
|
@@ -491,13 +539,10 @@ export class PikkuWorkflowService {
|
|
|
491
539
|
* @returns New step state for the retry attempt
|
|
492
540
|
*/
|
|
493
541
|
async createRetryAttempt(failedStepId, status) {
|
|
494
|
-
|
|
495
|
-
const stepName = newStep.stepName ?? '';
|
|
496
|
-
await this.safeMirror(() => this.mirror.createRetryAttempt(failedStepId, {
|
|
542
|
+
return this.mirrored(() => this.createRetryAttemptImpl(failedStepId, status), (mirror, newStep) => mirror.createRetryAttempt(failedStepId, {
|
|
497
543
|
...newStep,
|
|
498
|
-
stepName,
|
|
544
|
+
stepName: newStep.stepName ?? '',
|
|
499
545
|
}));
|
|
500
|
-
return newStep;
|
|
501
546
|
}
|
|
502
547
|
/**
|
|
503
548
|
* Set the branch key for a graph node step
|
|
@@ -505,8 +550,7 @@ export class PikkuWorkflowService {
|
|
|
505
550
|
* @param branchKey - Branch key selected by graph.branch()
|
|
506
551
|
*/
|
|
507
552
|
async setBranchTaken(stepId, branchKey) {
|
|
508
|
-
await this.setBranchTakenImpl(stepId, branchKey);
|
|
509
|
-
await this.safeMirror(() => this.mirror.setBranchTaken(stepId, branchKey));
|
|
553
|
+
await this.mirrored(() => this.setBranchTakenImpl(stepId, branchKey), (mirror) => mirror.setBranchTaken(stepId, branchKey));
|
|
510
554
|
}
|
|
511
555
|
/**
|
|
512
556
|
* Update a state variable in the workflow run's state
|
|
@@ -515,16 +559,13 @@ export class PikkuWorkflowService {
|
|
|
515
559
|
* @param value - Value to store
|
|
516
560
|
*/
|
|
517
561
|
async updateRunState(runId, name, value) {
|
|
518
|
-
await this.updateRunStateImpl(runId, name, value);
|
|
519
|
-
await this.safeMirror(() => this.mirror.updateRunState(runId, name, value));
|
|
562
|
+
await this.mirrored(() => this.updateRunStateImpl(runId, name, value), (mirror) => mirror.updateRunState(runId, name, value));
|
|
520
563
|
}
|
|
521
564
|
async upsertWorkflowVersion(name, graphHash, graph, source, status) {
|
|
522
|
-
await this.upsertWorkflowVersionImpl(name, graphHash, graph, source, status);
|
|
523
|
-
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));
|
|
524
566
|
}
|
|
525
567
|
async updateWorkflowVersionStatus(name, graphHash, status) {
|
|
526
|
-
await this.updateWorkflowVersionStatusImpl(name, graphHash, status);
|
|
527
|
-
await this.safeMirror(() => this.mirror.updateWorkflowVersionStatus(name, graphHash, status));
|
|
568
|
+
await this.mirrored(() => this.updateWorkflowVersionStatusImpl(name, graphHash, status), (mirror) => mirror.updateWorkflowVersionStatus(name, graphHash, status));
|
|
528
569
|
}
|
|
529
570
|
// ============================================================================
|
|
530
571
|
// Workflow Lifecycle Methods
|
|
@@ -573,7 +614,7 @@ export class PikkuWorkflowService {
|
|
|
573
614
|
}
|
|
574
615
|
async queueStepWorker(runId, stepName, rpcName, data, stepOptions, fromStepName) {
|
|
575
616
|
const queueService = this.verifyQueueService();
|
|
576
|
-
await queueService.add(this.getStepWorkerQueueName(rpcName),
|
|
617
|
+
await queueService.add(this.getStepWorkerQueueName(rpcName), { runId, stepName, rpcName, data, fromStepName }, {
|
|
577
618
|
...this.resolveStepJobOptions(stepOptions),
|
|
578
619
|
// Group by step function, mirroring how per-step queues split them —
|
|
579
620
|
// one slow step function can't monopolise the shared step worker.
|
|
@@ -637,7 +678,7 @@ export class PikkuWorkflowService {
|
|
|
637
678
|
throw new Error(`Workflow step '${stepName}' (function '${rpcName}') is marked 'workflowQueued: true' but no queue service is configured.`);
|
|
638
679
|
}
|
|
639
680
|
try {
|
|
640
|
-
await getSingletonServices().queueService.add(this.getStepWorkerQueueName(rpcName),
|
|
681
|
+
await getSingletonServices().queueService.add(this.getStepWorkerQueueName(rpcName), { runId, stepName, rpcName, data, fromStepName }, {
|
|
641
682
|
...this.resolveStepJobOptions(stepOptions),
|
|
642
683
|
group: this.getJobGroup(rpcName),
|
|
643
684
|
});
|
|
@@ -698,22 +739,14 @@ export class PikkuWorkflowService {
|
|
|
698
739
|
* way a caller can still read that run back — its steps, and which one failed.
|
|
699
740
|
*/
|
|
700
741
|
async startWorkflow(name, input, wire, rpcService, options) {
|
|
701
|
-
// Resolve workflow from static meta (root or addon namespace)
|
|
742
|
+
// Resolve workflow from static meta (root or addon namespace)
|
|
702
743
|
const resolved = resolveWorkflowMeta(name);
|
|
703
|
-
|
|
744
|
+
const workflowMeta = resolved?.meta;
|
|
704
745
|
const packageName = resolved?.packageName ?? null;
|
|
705
|
-
if (!workflowMeta) {
|
|
706
|
-
const dynamicWorkflows = await this.getAIGeneratedWorkflows();
|
|
707
|
-
const match = dynamicWorkflows.find((w) => w.workflowName === name);
|
|
708
|
-
if (match?.graph) {
|
|
709
|
-
workflowMeta = match.graph;
|
|
710
|
-
}
|
|
711
|
-
}
|
|
712
746
|
if (!workflowMeta) {
|
|
713
747
|
throw new WorkflowNotFoundError(name);
|
|
714
748
|
}
|
|
715
|
-
if (workflowMeta.source === 'graph'
|
|
716
|
-
workflowMeta.source === 'dynamic-workflow') {
|
|
749
|
+
if (workflowMeta.source === 'graph') {
|
|
717
750
|
const shouldInline = options?.inline || !getSingletonServices()?.queueService;
|
|
718
751
|
return runWorkflowGraph(this, name, input, rpcService, shouldInline, options?.startNode, wire, workflowMeta);
|
|
719
752
|
}
|
|
@@ -734,7 +767,7 @@ export class PikkuWorkflowService {
|
|
|
734
767
|
options?.onRunCreated?.(runId);
|
|
735
768
|
await this.runExtension?.attachRunContext(runId, workflowMeta, options);
|
|
736
769
|
if (shouldInline) {
|
|
737
|
-
this.
|
|
770
|
+
this.registerInlineRun(runId);
|
|
738
771
|
try {
|
|
739
772
|
await this.runWorkflowJob(runId, rpcService);
|
|
740
773
|
}
|
|
@@ -764,7 +797,7 @@ export class PikkuWorkflowService {
|
|
|
764
797
|
}
|
|
765
798
|
}
|
|
766
799
|
finally {
|
|
767
|
-
this.
|
|
800
|
+
this.unregisterInlineRun(runId);
|
|
768
801
|
this.runExtension?.detachRunContext(runId);
|
|
769
802
|
}
|
|
770
803
|
}
|
|
@@ -774,37 +807,143 @@ export class PikkuWorkflowService {
|
|
|
774
807
|
return { runId };
|
|
775
808
|
}
|
|
776
809
|
async runToCompletion(name, input, rpcService, options) {
|
|
777
|
-
const pollInterval = options?.pollIntervalMs ?? 1000;
|
|
778
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);
|
|
779
831
|
while (true) {
|
|
780
832
|
const run = await this.getRun(runId);
|
|
781
833
|
if (!run) {
|
|
782
834
|
throw new WorkflowRunNotFoundError(runId);
|
|
783
835
|
}
|
|
784
836
|
if (WORKFLOW_END_STATES.has(run.status)) {
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
837
|
+
return run;
|
|
838
|
+
}
|
|
839
|
+
await this.waitBeforeNextRead(interval);
|
|
840
|
+
interval = Math.min(interval * WORKFLOW_POLL_FACTOR, maxIntervalMs);
|
|
841
|
+
}
|
|
842
|
+
}
|
|
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;
|
|
792
919
|
}
|
|
793
|
-
await new Promise((resolve) => setTimeout(resolve, pollInterval));
|
|
794
920
|
}
|
|
921
|
+
snapshot?.set(stepName, step);
|
|
922
|
+
return step;
|
|
795
923
|
}
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
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;
|
|
804
943
|
}
|
|
805
944
|
/** The step the DSL walk last reached (the predecessor for the next step). */
|
|
806
945
|
lastStepName(runId) {
|
|
807
|
-
return this.
|
|
946
|
+
return this.runContexts.get(runId)?.replay?.lastStep;
|
|
808
947
|
}
|
|
809
948
|
/**
|
|
810
949
|
* Physical, replay-stable key for the Nth reach of `logicalStepName` in a run:
|
|
@@ -813,29 +952,31 @@ export class PikkuWorkflowService {
|
|
|
813
952
|
* the rows clobbering. Deterministic given a deterministic DSL body.
|
|
814
953
|
*/
|
|
815
954
|
nextStepKey(runId, logicalStepName) {
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
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);
|
|
823
961
|
const stepName = ordinal === 0 ? logicalStepName : `${logicalStepName}#${ordinal}`;
|
|
824
|
-
|
|
962
|
+
replay.lastStep = stepName;
|
|
825
963
|
return stepName;
|
|
826
964
|
}
|
|
827
965
|
async runWorkflowJob(runId, rpcService) {
|
|
828
|
-
// Fresh ordinal counters per replay so step keys are deterministic
|
|
829
|
-
|
|
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);
|
|
830
969
|
try {
|
|
831
970
|
await this.runWorkflowJobInner(runId, rpcService);
|
|
832
971
|
}
|
|
833
972
|
finally {
|
|
834
|
-
this.
|
|
973
|
+
this.endReplay(runId);
|
|
835
974
|
}
|
|
836
975
|
}
|
|
837
976
|
async runWorkflowJobInner(runId, rpcService) {
|
|
838
|
-
|
|
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);
|
|
839
980
|
if (!run) {
|
|
840
981
|
throw new WorkflowRunNotFoundError(runId);
|
|
841
982
|
}
|
|
@@ -848,8 +989,7 @@ export class PikkuWorkflowService {
|
|
|
848
989
|
await this.runVersionMismatchFallback(run, workflowMeta, rpcService);
|
|
849
990
|
return;
|
|
850
991
|
}
|
|
851
|
-
if (workflowMeta?.source === 'graph'
|
|
852
|
-
workflowMeta?.source === 'dynamic-workflow') {
|
|
992
|
+
if (workflowMeta?.source === 'graph') {
|
|
853
993
|
await continueGraph(this, runId, run.workflow);
|
|
854
994
|
const updatedRun = await this.getRun(runId);
|
|
855
995
|
if (updatedRun?.status === 'completed') {
|
|
@@ -861,22 +1001,6 @@ export class PikkuWorkflowService {
|
|
|
861
1001
|
}
|
|
862
1002
|
return;
|
|
863
1003
|
}
|
|
864
|
-
if (!workflowMeta) {
|
|
865
|
-
const dynamicWorkflows = await this.getAIGeneratedWorkflows();
|
|
866
|
-
const match = dynamicWorkflows.find((w) => w.workflowName === run.workflow);
|
|
867
|
-
if (match?.graph) {
|
|
868
|
-
await continueGraph(this, runId, run.workflow, match.graph);
|
|
869
|
-
const updatedRun = await this.getRun(runId);
|
|
870
|
-
if (updatedRun?.status === 'completed') {
|
|
871
|
-
await this.onChildWorkflowCompleted(updatedRun, updatedRun.output);
|
|
872
|
-
}
|
|
873
|
-
else if (updatedRun?.status === 'failed' ||
|
|
874
|
-
updatedRun?.status === 'cancelled') {
|
|
875
|
-
await this.onChildWorkflowFailed(updatedRun, new Error(updatedRun.error?.message || 'Child workflow failed'));
|
|
876
|
-
}
|
|
877
|
-
return;
|
|
878
|
-
}
|
|
879
|
-
}
|
|
880
1004
|
const registrations = pikkuState(pkgName, 'workflows', 'registrations');
|
|
881
1005
|
const workflow = registrations.get(resolved?.resolvedName ?? run.workflow);
|
|
882
1006
|
if (!workflow) {
|
|
@@ -1039,8 +1163,7 @@ export class PikkuWorkflowService {
|
|
|
1039
1163
|
}
|
|
1040
1164
|
const meta = pikkuState(null, 'workflows', 'meta');
|
|
1041
1165
|
const workflowMeta = meta[run.workflow];
|
|
1042
|
-
const isGraphWorkflow = workflowMeta?.source === 'graph'
|
|
1043
|
-
workflowMeta?.source === 'dynamic-workflow';
|
|
1166
|
+
const isGraphWorkflow = workflowMeta?.source === 'graph';
|
|
1044
1167
|
// Map the physical step key back to its logical node: a revisit instance
|
|
1045
1168
|
// is `node#N` (ordinal), which isn't a literal key in `nodes`.
|
|
1046
1169
|
let graphNodeId;
|
|
@@ -1087,7 +1210,7 @@ export class PikkuWorkflowService {
|
|
|
1087
1210
|
}
|
|
1088
1211
|
}
|
|
1089
1212
|
else {
|
|
1090
|
-
result = await this.invokeStepRpc(runId, stepName, stepState, rpcName, data, rpcService);
|
|
1213
|
+
result = await this.invokeStepRpc(runId, stepName, stepState, rpcName, data, rpcService, run);
|
|
1091
1214
|
}
|
|
1092
1215
|
}
|
|
1093
1216
|
// Store result and mark succeeded
|
|
@@ -1161,10 +1284,10 @@ export class PikkuWorkflowService {
|
|
|
1161
1284
|
* Identical for the queue executor and the inline executor — the only thing
|
|
1162
1285
|
* that differs between transports is who calls it, not the call itself.
|
|
1163
1286
|
*/
|
|
1164
|
-
async invokeStepRpc(runId, stepName, stepState, rpcName, data, rpcService) {
|
|
1287
|
+
async invokeStepRpc(runId, stepName, stepState, rpcName, data, rpcService, knownRun) {
|
|
1165
1288
|
// Carry the run's pikkuUserId onto the step wire so authed steps rehydrate their
|
|
1166
1289
|
// session on the queued path too (the bare job wire lacks it; inline already has it).
|
|
1167
|
-
const run = await this.
|
|
1290
|
+
const run = knownRun ?? (await this.getRunIdentity(runId));
|
|
1168
1291
|
return rpcService.rpcWithWire(rpcName, data, {
|
|
1169
1292
|
...(run?.wire?.pikkuUserId ? { pikkuUserId: run.wire.pikkuUserId } : {}),
|
|
1170
1293
|
workflowStep: {
|
|
@@ -1241,15 +1364,8 @@ export class PikkuWorkflowService {
|
|
|
1241
1364
|
actor: stepOptions?.actor,
|
|
1242
1365
|
onError: stepOptions?.onError,
|
|
1243
1366
|
};
|
|
1244
|
-
//
|
|
1245
|
-
|
|
1246
|
-
try {
|
|
1247
|
-
stepState = await this.getStepState(runId, stepName);
|
|
1248
|
-
}
|
|
1249
|
-
catch {
|
|
1250
|
-
// Step doesn't exist - create it
|
|
1251
|
-
stepState = await this.insertStepState(runId, stepName, rpcName, data, resolvedStepOptions, fromStepName);
|
|
1252
|
-
}
|
|
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));
|
|
1253
1369
|
if (stepState.status === 'succeeded') {
|
|
1254
1370
|
// Return cached result
|
|
1255
1371
|
return stepState.result;
|
|
@@ -1312,22 +1428,14 @@ export class PikkuWorkflowService {
|
|
|
1312
1428
|
const { runId: childRunId } = await this.startWorkflow(rpcName, data, childWire, rpcService, { inline: true });
|
|
1313
1429
|
await this.setStepChildRunId(currentStepState.stepId, childRunId);
|
|
1314
1430
|
// Poll until child workflow completes
|
|
1315
|
-
|
|
1316
|
-
|
|
1317
|
-
|
|
1318
|
-
throw new WorkflowRunNotFoundError(childRunId);
|
|
1319
|
-
}
|
|
1320
|
-
if (WORKFLOW_END_STATES.has(childRun.status)) {
|
|
1321
|
-
if (childRun.status === 'failed') {
|
|
1322
|
-
throw new Error(childRun.error?.message || 'Sub-workflow failed');
|
|
1323
|
-
}
|
|
1324
|
-
if (childRun.status === 'cancelled') {
|
|
1325
|
-
throw new Error('Sub-workflow was cancelled');
|
|
1326
|
-
}
|
|
1327
|
-
return childRun.output;
|
|
1328
|
-
}
|
|
1329
|
-
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');
|
|
1330
1434
|
}
|
|
1435
|
+
if (childRun.status === 'cancelled') {
|
|
1436
|
+
throw new Error('Sub-workflow was cancelled');
|
|
1437
|
+
}
|
|
1438
|
+
return childRun.output;
|
|
1331
1439
|
}
|
|
1332
1440
|
return this.invokeStepRpc(runId, stepName, currentStepState, rpcName, data, rpcService);
|
|
1333
1441
|
}, async (error) => {
|
|
@@ -1357,15 +1465,9 @@ export class PikkuWorkflowService {
|
|
|
1357
1465
|
rpcName = null) {
|
|
1358
1466
|
const fromStepName = this.lastStepName(runId);
|
|
1359
1467
|
const stepName = this.nextStepKey(runId, logicalStepName);
|
|
1360
|
-
//
|
|
1361
|
-
|
|
1362
|
-
|
|
1363
|
-
stepState = await this.getStepState(runId, stepName);
|
|
1364
|
-
}
|
|
1365
|
-
catch {
|
|
1366
|
-
// Step doesn't exist - create it (inline, so never dispatched)
|
|
1367
|
-
stepState = await this.insertStepState(runId, stepName, rpcName, data, stepOptions, fromStepName);
|
|
1368
|
-
}
|
|
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));
|
|
1369
1471
|
if (stepState.status === 'succeeded') {
|
|
1370
1472
|
// Return cached result
|
|
1371
1473
|
return stepState.result;
|
|
@@ -1407,15 +1509,9 @@ export class PikkuWorkflowService {
|
|
|
1407
1509
|
async sleepStep(runId, logicalStepName, duration) {
|
|
1408
1510
|
const fromStepName = this.lastStepName(runId);
|
|
1409
1511
|
const stepName = this.nextStepKey(runId, logicalStepName);
|
|
1410
|
-
//
|
|
1411
|
-
|
|
1412
|
-
|
|
1413
|
-
stepState = await this.getStepState(runId, stepName);
|
|
1414
|
-
}
|
|
1415
|
-
catch {
|
|
1416
|
-
// Step doesn't exist - create it (sleep step, no RPC)
|
|
1417
|
-
stepState = await this.insertStepState(runId, stepName, null, { duration }, undefined, fromStepName);
|
|
1418
|
-
}
|
|
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));
|
|
1419
1515
|
if (stepState.status === 'succeeded') {
|
|
1420
1516
|
// Sleep already completed, return immediately
|
|
1421
1517
|
return;
|