@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.
Files changed (48) hide show
  1. package/CHANGELOG.md +88 -0
  2. package/LICENSE +21 -0
  3. package/dist/services/in-memory-queue-service.d.ts +6 -0
  4. package/dist/services/in-memory-queue-service.js +8 -1
  5. package/dist/services/in-memory-workflow-service.d.ts +3 -5
  6. package/dist/services/in-memory-workflow-service.js +10 -19
  7. package/dist/services/workflow-service.d.ts +7 -5
  8. package/dist/types/core.types.d.ts +7 -0
  9. package/dist/wirings/ai-agent/ai-agent-agui.js +0 -8
  10. package/dist/wirings/ai-agent/ai-agent-prepare.js +1 -2
  11. package/dist/wirings/ai-agent/ai-agent.types.d.ts +0 -6
  12. package/dist/wirings/workflow/graph/graph-runner.js +3 -2
  13. package/dist/wirings/workflow/graph/graph-validation.d.ts +0 -2
  14. package/dist/wirings/workflow/graph/graph-validation.js +0 -142
  15. package/dist/wirings/workflow/graph/index.d.ts +1 -1
  16. package/dist/wirings/workflow/graph/index.js +1 -1
  17. package/dist/wirings/workflow/index.d.ts +0 -1
  18. package/dist/wirings/workflow/index.js +0 -2
  19. package/dist/wirings/workflow/pikku-workflow-service.d.ts +69 -15
  20. package/dist/wirings/workflow/pikku-workflow-service.js +260 -164
  21. package/dist/wirings/workflow/workflow.types.d.ts +1 -6
  22. package/package.json +1 -1
  23. package/src/services/in-memory-queue-service.test.ts +66 -1
  24. package/src/services/in-memory-queue-service.ts +13 -2
  25. package/src/services/in-memory-workflow-service.ts +12 -25
  26. package/src/services/workflow-service.ts +7 -4
  27. package/src/types/core.types.ts +7 -0
  28. package/src/wirings/ai-agent/ai-agent-agui.test.ts +0 -16
  29. package/src/wirings/ai-agent/ai-agent-agui.ts +0 -9
  30. package/src/wirings/ai-agent/ai-agent-prepare.ts +1 -2
  31. package/src/wirings/ai-agent/ai-agent.types.ts +0 -7
  32. package/src/wirings/workflow/graph/graph-runner.ts +3 -2
  33. package/src/wirings/workflow/graph/graph-validation.test.ts +1 -144
  34. package/src/wirings/workflow/graph/graph-validation.ts +0 -196
  35. package/src/wirings/workflow/graph/index.ts +1 -5
  36. package/src/wirings/workflow/index.ts +0 -6
  37. package/src/wirings/workflow/pikku-workflow-service.ts +377 -212
  38. package/src/wirings/workflow/scenario-expectations.test.ts +153 -0
  39. package/src/wirings/workflow/scenario-step.test.ts +1 -1
  40. package/src/wirings/workflow/workflow-dispatch-durability.test.ts +1 -1
  41. package/src/wirings/workflow/workflow-dispatch-payload.test.ts +59 -0
  42. package/src/wirings/workflow/workflow-mirror.test.ts +178 -0
  43. package/src/wirings/workflow/workflow-replay-snapshot.test.ts +139 -0
  44. package/src/wirings/workflow/workflow-run-context.test.ts +177 -0
  45. package/src/wirings/workflow/workflow-run-polling.test.ts +132 -0
  46. package/src/wirings/workflow/workflow-step-ordinal.test.ts +4 -4
  47. package/src/wirings/workflow/workflow.types.ts +1 -4
  48. package/tsconfig.tsbuildinfo +1 -1
@@ -171,13 +171,45 @@ const WORKFLOW_END_STATES = new Set([
171
171
  'suspended',
172
172
  ]);
173
173
  /**
174
- * Abstract workflow state service
175
- * Implementations provide pluggable storage backends (SQLite, PostgreSQL, etc.)
176
- * Combines orchestration and step execution
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
- async safeMirror(fn) {
199
- if (!this.mirror)
200
- return;
201
- try {
202
- await fn();
203
- }
204
- catch (err) {
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.logger?.warn?.(`[pikku] WorkflowRunMirror write failed: ${err?.message ?? err}`);
245
+ await mirror(this.mirror, written);
207
246
  }
208
- catch {
209
- // logger unavailable (e.g. singleton services not initialized) — swallow
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.inlineRuns.has(runId);
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.inlineRuns.add(runId);
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.inlineRuns.delete(runId);
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
- const runId = await this.createRunImpl(workflowName, input, inline, graphHash, wire, options);
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
- await this.safeMirror(() => this.mirror.updateRunStatus(id, status, output, error));
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
- const step = await this.insertStepStateImpl(runId, stepName, rpcName, data, stepOptions, fromStepName);
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
- const serialized = {
478
- message: error.message,
479
- stack: error.stack,
480
- code: error.code,
481
- expected: isExpectedError(error),
482
- };
483
- await this.safeMirror(() => this.mirror.setStepError(stepId, serialized));
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
- const newStep = await this.createRetryAttemptImpl(failedStepId, status);
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), JSON.parse(JSON.stringify({ runId, stepName, rpcName, data, fromStepName })), {
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), JSON.parse(JSON.stringify({ runId, stepName, rpcName, data, fromStepName })), {
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), then dynamic DB
742
+ // Resolve workflow from static meta (root or addon namespace)
702
743
  const resolved = resolveWorkflowMeta(name);
703
- let workflowMeta = resolved?.meta;
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.inlineRuns.add(runId);
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.inlineRuns.delete(runId);
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
- if (run.status === 'failed') {
786
- throw new WorkflowRunFailedError(run.error?.message);
787
- }
788
- if (run.status === 'cancelled') {
789
- throw new WorkflowRunCancelledError();
790
- }
791
- return run.output;
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
- // Per-run, per-replay ordinal counters (runId → stepName → count).
797
- stepOrdinals = new Map();
798
- // Previous step key reached in the current DSL walk (runId stepName), so a
799
- // new step records where it came from. Rebuilt each replay alongside ordinals.
800
- stepLineage = new Map();
801
- resetStepOrdinals(runId) {
802
- this.stepOrdinals.set(runId, new Map());
803
- this.stepLineage.delete(runId);
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.stepLineage.get(runId);
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
- let perRun = this.stepOrdinals.get(runId);
817
- if (!perRun) {
818
- perRun = new Map();
819
- this.stepOrdinals.set(runId, perRun);
820
- }
821
- const ordinal = perRun.get(logicalStepName) ?? 0;
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
- this.stepLineage.set(runId, stepName);
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
- this.resetStepOrdinals(runId);
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.stepOrdinals.delete(runId);
973
+ this.endReplay(runId);
835
974
  }
836
975
  }
837
976
  async runWorkflowJobInner(runId, rpcService) {
838
- const run = await this.getRun(runId);
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.getRun(runId);
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
- // Check if step already exists
1245
- let stepState;
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
- while (true) {
1316
- const childRun = await this.getRun(childRunId);
1317
- if (!childRun) {
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
- // Check if step already exists
1361
- let stepState;
1362
- try {
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
- // Check if step already exists
1411
- let stepState;
1412
- try {
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;