@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
@@ -337,15 +337,80 @@ export interface WorkflowRunExtension {
337
337
  ): Promise<void>
338
338
  }
339
339
 
340
+ /**
341
+ * States a run never leaves. `suspended` is deliberately absent: a suspended
342
+ * run stops a poll loop but can still be resumed, so anything the process holds
343
+ * for it has to survive.
344
+ */
345
+ const WORKFLOW_TERMINAL_STATES: ReadonlySet<string> = new Set([
346
+ 'completed',
347
+ 'failed',
348
+ 'cancelled',
349
+ ])
350
+
351
+ /** First wait when polling a run, before the backoff starts widening it. */
352
+ const WORKFLOW_POLL_MIN_MS = 10
353
+
354
+ /** How much each successive wait grows, up to the caller's ceiling. */
355
+ const WORKFLOW_POLL_FACTOR = 1.6
356
+
357
+ /**
358
+ * Ceiling for the wait on an inline sub-workflow. Lower than a top-level run's
359
+ * default, because the parent step is blocked on it and every wait here is
360
+ * added latency in the middle of a workflow rather than at its edge.
361
+ */
362
+ const WORKFLOW_CHILD_POLL_MAX_MS = 500
363
+
340
364
  /**
341
365
  * Abstract workflow state service
342
366
  * Implementations provide pluggable storage backends (SQLite, PostgreSQL, etc.)
343
367
  * Combines orchestration and step execution
344
368
  */
369
+ /**
370
+ * Everything the engine holds in memory for a run that is executing in this
371
+ * process. One entry, one lifetime: created when the run starts executing here
372
+ * and dropped when nothing is holding it open any more.
373
+ *
374
+ * The `replay` half is rebuilt from scratch on every orchestrator tick; the
375
+ * rest outlives individual ticks and belongs to whoever started the run.
376
+ */
377
+ type RunContext = {
378
+ /** Executing straight through in-process, without a queue. */
379
+ inline: boolean
380
+ replay?: {
381
+ /** How many times this walk has reached each logical step name. */
382
+ ordinals: Map<string, number>
383
+ /** The step key the walk last reached — the next step's predecessor. */
384
+ lastStep?: string
385
+ /** Every step of the run as this replay found it, keyed by step name. */
386
+ steps?: Map<string, StepState>
387
+ /** The run as this replay found it. Only its immutable half is reused. */
388
+ run?: WorkflowRun
389
+ }
390
+ }
391
+
345
392
  export abstract class PikkuWorkflowService implements WorkflowService {
346
- private inlineRuns = new Set<string>()
347
393
  private runExtension?: WorkflowRunExtension
348
394
 
395
+ private runContexts = new Map<string, RunContext>()
396
+
397
+ private contextFor(runId: string): RunContext {
398
+ let context = this.runContexts.get(runId)
399
+ if (!context) {
400
+ context = { inline: false }
401
+ this.runContexts.set(runId, context)
402
+ }
403
+ return context
404
+ }
405
+
406
+ /** Drop a run's context once nothing is holding it open. */
407
+ private releaseContext(runId: string): void {
408
+ const context = this.runContexts.get(runId)
409
+ if (!context) return
410
+ if (context.inline || context.replay) return
411
+ this.runContexts.delete(runId)
412
+ }
413
+
349
414
  protected get logger() {
350
415
  return getSingletonServices()?.logger
351
416
  }
@@ -372,19 +437,36 @@ export abstract class PikkuWorkflowService implements WorkflowService {
372
437
  }
373
438
  }
374
439
 
375
- private async safeMirror(fn: () => Promise<void>): Promise<void> {
376
- if (!this.mirror) return
377
- try {
378
- await fn()
379
- } catch (err: any) {
440
+ /**
441
+ * Perform a state write, then shadow it to the mirror.
442
+ *
443
+ * The mirror is an observability sink, never a second source of truth, and
444
+ * both halves of that follow from this one shape: it is only ever told about
445
+ * a write that already landed, and a mirror that is down or throwing cannot
446
+ * fail — or even be seen by — the workflow it is watching.
447
+ *
448
+ * @param write - the authoritative write; its result is what the caller gets
449
+ * @param mirror - shadows the write, given the live mirror and what was written
450
+ */
451
+ private async mirrored<T>(
452
+ write: () => Promise<T>,
453
+ mirror: (mirror: WorkflowRunMirror, written: T) => Promise<void>
454
+ ): Promise<T> {
455
+ const written = await write()
456
+ if (this.mirror) {
380
457
  try {
381
- this.logger?.warn?.(
382
- `[pikku] WorkflowRunMirror write failed: ${err?.message ?? err}`
383
- )
384
- } catch {
385
- // logger unavailable (e.g. singleton services not initialized) — swallow
458
+ await mirror(this.mirror, written)
459
+ } catch (err: any) {
460
+ try {
461
+ this.logger?.warn?.(
462
+ `[pikku] WorkflowRunMirror write failed: ${err?.message ?? err}`
463
+ )
464
+ } catch {
465
+ // logger unavailable (e.g. singleton services not initialized) — swallow
466
+ }
386
467
  }
387
468
  }
469
+ return written
388
470
  }
389
471
 
390
472
  /**
@@ -520,21 +602,24 @@ export abstract class PikkuWorkflowService implements WorkflowService {
520
602
  * Check if a run is executing inline (without queues)
521
603
  */
522
604
  protected isInline(runId: string): boolean {
523
- return this.inlineRuns.has(runId)
605
+ return this.runContexts.get(runId)?.inline === true
524
606
  }
525
607
 
526
608
  /**
527
609
  * Register a run as inline (for graph-runner to use)
528
610
  */
529
611
  public registerInlineRun(runId: string): void {
530
- this.inlineRuns.add(runId)
612
+ this.contextFor(runId).inline = true
531
613
  }
532
614
 
533
615
  /**
534
616
  * Unregister a run from inline tracking
535
617
  */
536
618
  public unregisterInlineRun(runId: string): void {
537
- this.inlineRuns.delete(runId)
619
+ const context = this.runContexts.get(runId)
620
+ if (!context) return
621
+ context.inline = false
622
+ this.releaseContext(runId)
538
623
  }
539
624
 
540
625
  public async registerWorkflowVersions(): Promise<void> {
@@ -556,26 +641,27 @@ export abstract class PikkuWorkflowService implements WorkflowService {
556
641
  plannedSteps?: WorkflowPlannedStep[]
557
642
  }
558
643
  ): Promise<string> {
559
- const runId = await this.createRunImpl(
560
- workflowName,
561
- input,
562
- inline,
563
- graphHash,
564
- wire,
565
- options
566
- )
567
- await this.safeMirror(() =>
568
- this.mirror!.createRun(
569
- runId,
570
- workflowName,
571
- input,
572
- inline,
573
- graphHash,
574
- wire,
575
- options
576
- )
644
+ return this.mirrored(
645
+ () =>
646
+ this.createRunImpl(
647
+ workflowName,
648
+ input,
649
+ inline,
650
+ graphHash,
651
+ wire,
652
+ options
653
+ ),
654
+ (mirror, runId) =>
655
+ mirror.createRun(
656
+ runId,
657
+ workflowName,
658
+ input,
659
+ inline,
660
+ graphHash,
661
+ wire,
662
+ options
663
+ )
577
664
  )
578
- return runId
579
665
  }
580
666
 
581
667
  protected abstract createRunImpl(
@@ -702,10 +788,17 @@ export abstract class PikkuWorkflowService implements WorkflowService {
702
788
  output?: any,
703
789
  error?: SerializedError
704
790
  ): Promise<void> {
705
- await this.updateRunStatusImpl(id, status, output, error)
706
- await this.safeMirror(() =>
707
- this.mirror!.updateRunStatus(id, status, output, error)
791
+ await this.mirrored(
792
+ () => this.updateRunStatusImpl(id, status, output, error),
793
+ (mirror) => mirror.updateRunStatus(id, status, output, error)
708
794
  )
795
+ if (WORKFLOW_TERMINAL_STATES.has(status)) {
796
+ // The run is over: release whatever this process opened for it. Queued
797
+ // runs never pass through the inline path that does this, so their
798
+ // context was held for the life of the process.
799
+ this.runExtension?.detachRunContext(id)
800
+ this.releaseContext(id)
801
+ }
709
802
  }
710
803
 
711
804
  protected abstract updateRunStatusImpl(
@@ -736,18 +829,19 @@ export abstract class PikkuWorkflowService implements WorkflowService {
736
829
  stepOptions?: WorkflowStepOptions,
737
830
  fromStepName?: string
738
831
  ): Promise<StepState> {
739
- const step = await this.insertStepStateImpl(
740
- runId,
741
- stepName,
742
- rpcName,
743
- data,
744
- stepOptions,
745
- fromStepName
746
- )
747
- await this.safeMirror(() =>
748
- this.mirror!.insertStepState(runId, { ...step, stepName, rpcName, data })
832
+ return this.mirrored(
833
+ () =>
834
+ this.insertStepStateImpl(
835
+ runId,
836
+ stepName,
837
+ rpcName,
838
+ data,
839
+ stepOptions,
840
+ fromStepName
841
+ ),
842
+ (mirror, step) =>
843
+ mirror.insertStepState(runId, { ...step, stepName, rpcName, data })
749
844
  )
750
- return step
751
845
  }
752
846
 
753
847
  protected abstract insertStepStateImpl(
@@ -773,8 +867,10 @@ export abstract class PikkuWorkflowService implements WorkflowService {
773
867
  * @param stepId - Step ID
774
868
  */
775
869
  public async setStepRunning(stepId: string): Promise<void> {
776
- await this.setStepRunningImpl(stepId)
777
- await this.safeMirror(() => this.mirror!.setStepRunning(stepId))
870
+ await this.mirrored(
871
+ () => this.setStepRunningImpl(stepId),
872
+ (mirror) => mirror.setStepRunning(stepId)
873
+ )
778
874
  }
779
875
 
780
876
  protected abstract setStepRunningImpl(stepId: string): Promise<void>
@@ -785,8 +881,10 @@ export abstract class PikkuWorkflowService implements WorkflowService {
785
881
  * @param stepId - Step ID
786
882
  */
787
883
  public async setStepScheduled(stepId: string): Promise<void> {
788
- await this.setStepScheduledImpl(stepId)
789
- await this.safeMirror(() => this.mirror!.setStepScheduled(stepId))
884
+ await this.mirrored(
885
+ () => this.setStepScheduledImpl(stepId),
886
+ (mirror) => mirror.setStepScheduled(stepId)
887
+ )
790
888
  }
791
889
 
792
890
  protected abstract setStepScheduledImpl(stepId: string): Promise<void>
@@ -798,8 +896,10 @@ export abstract class PikkuWorkflowService implements WorkflowService {
798
896
  * @param result - Step result
799
897
  */
800
898
  public async setStepResult(stepId: string, result: any): Promise<void> {
801
- await this.setStepResultImpl(stepId, result)
802
- await this.safeMirror(() => this.mirror!.setStepResult(stepId, result))
899
+ await this.mirrored(
900
+ () => this.setStepResultImpl(stepId, result),
901
+ (mirror) => mirror.setStepResult(stepId, result)
902
+ )
803
903
  }
804
904
 
805
905
  protected abstract setStepResultImpl(
@@ -816,9 +916,9 @@ export abstract class PikkuWorkflowService implements WorkflowService {
816
916
  stepId: string,
817
917
  childRunId: string
818
918
  ): Promise<void> {
819
- await this.setStepChildRunIdImpl(stepId, childRunId)
820
- await this.safeMirror(() =>
821
- this.mirror!.setStepChildRunId(stepId, childRunId)
919
+ await this.mirrored(
920
+ () => this.setStepChildRunIdImpl(stepId, childRunId),
921
+ (mirror) => mirror.setStepChildRunId(stepId, childRunId)
822
922
  )
823
923
  }
824
924
 
@@ -834,14 +934,18 @@ export abstract class PikkuWorkflowService implements WorkflowService {
834
934
  * @param error - Error object
835
935
  */
836
936
  public async setStepError(stepId: string, error: Error): Promise<void> {
837
- await this.setStepErrorImpl(stepId, error)
838
- const serialized: SerializedError = {
839
- message: error.message,
840
- stack: error.stack,
841
- code: (error as any).code,
842
- expected: isExpectedError(error),
843
- }
844
- await this.safeMirror(() => this.mirror!.setStepError(stepId, serialized))
937
+ await this.mirrored(
938
+ () => this.setStepErrorImpl(stepId, error),
939
+ (mirror) => {
940
+ const serialized: SerializedError = {
941
+ message: error.message,
942
+ stack: error.stack,
943
+ code: (error as any).code,
944
+ expected: isExpectedError(error),
945
+ }
946
+ return mirror.setStepError(stepId, serialized)
947
+ }
948
+ )
845
949
  }
846
950
 
847
951
  protected abstract setStepErrorImpl(
@@ -861,15 +965,14 @@ export abstract class PikkuWorkflowService implements WorkflowService {
861
965
  failedStepId: string,
862
966
  status: 'pending' | 'running'
863
967
  ): Promise<StepState> {
864
- const newStep = await this.createRetryAttemptImpl(failedStepId, status)
865
- const stepName = (newStep as any).stepName ?? ''
866
- await this.safeMirror(() =>
867
- this.mirror!.createRetryAttempt(failedStepId, {
868
- ...newStep,
869
- stepName,
870
- })
968
+ return this.mirrored(
969
+ () => this.createRetryAttemptImpl(failedStepId, status),
970
+ (mirror, newStep) =>
971
+ mirror.createRetryAttempt(failedStepId, {
972
+ ...newStep,
973
+ stepName: (newStep as any).stepName ?? '',
974
+ })
871
975
  )
872
- return newStep
873
976
  }
874
977
 
875
978
  protected abstract createRetryAttemptImpl(
@@ -963,8 +1066,10 @@ export abstract class PikkuWorkflowService implements WorkflowService {
963
1066
  stepId: string,
964
1067
  branchKey: string
965
1068
  ): Promise<void> {
966
- await this.setBranchTakenImpl(stepId, branchKey)
967
- await this.safeMirror(() => this.mirror!.setBranchTaken(stepId, branchKey))
1069
+ await this.mirrored(
1070
+ () => this.setBranchTakenImpl(stepId, branchKey),
1071
+ (mirror) => mirror.setBranchTaken(stepId, branchKey)
1072
+ )
968
1073
  }
969
1074
 
970
1075
  protected abstract setBranchTakenImpl(
@@ -983,8 +1088,10 @@ export abstract class PikkuWorkflowService implements WorkflowService {
983
1088
  name: string,
984
1089
  value: unknown
985
1090
  ): Promise<void> {
986
- await this.updateRunStateImpl(runId, name, value)
987
- await this.safeMirror(() => this.mirror!.updateRunState(runId, name, value))
1091
+ await this.mirrored(
1092
+ () => this.updateRunStateImpl(runId, name, value),
1093
+ (mirror) => mirror.updateRunState(runId, name, value)
1094
+ )
988
1095
  }
989
1096
 
990
1097
  protected abstract updateRunStateImpl(
@@ -1007,9 +1114,11 @@ export abstract class PikkuWorkflowService implements WorkflowService {
1007
1114
  source: string,
1008
1115
  status?: WorkflowVersionStatus
1009
1116
  ): Promise<void> {
1010
- await this.upsertWorkflowVersionImpl(name, graphHash, graph, source, status)
1011
- await this.safeMirror(() =>
1012
- this.mirror!.upsertWorkflowVersion(name, graphHash, graph, source, status)
1117
+ await this.mirrored(
1118
+ () =>
1119
+ this.upsertWorkflowVersionImpl(name, graphHash, graph, source, status),
1120
+ (mirror) =>
1121
+ mirror.upsertWorkflowVersion(name, graphHash, graph, source, status)
1013
1122
  )
1014
1123
  }
1015
1124
 
@@ -1026,9 +1135,9 @@ export abstract class PikkuWorkflowService implements WorkflowService {
1026
1135
  graphHash: string,
1027
1136
  status: WorkflowVersionStatus
1028
1137
  ): Promise<void> {
1029
- await this.updateWorkflowVersionStatusImpl(name, graphHash, status)
1030
- await this.safeMirror(() =>
1031
- this.mirror!.updateWorkflowVersionStatus(name, graphHash, status)
1138
+ await this.mirrored(
1139
+ () => this.updateWorkflowVersionStatusImpl(name, graphHash, status),
1140
+ (mirror) => mirror.updateWorkflowVersionStatus(name, graphHash, status)
1032
1141
  )
1033
1142
  }
1034
1143
 
@@ -1043,10 +1152,6 @@ export abstract class PikkuWorkflowService implements WorkflowService {
1043
1152
  graphHash: string
1044
1153
  ): Promise<{ graph: any; source: string } | null>
1045
1154
 
1046
- abstract getAIGeneratedWorkflows(
1047
- agentName?: string
1048
- ): Promise<Array<{ workflowName: string; graphHash: string; graph: any }>>
1049
-
1050
1155
  // ============================================================================
1051
1156
  // Workflow Lifecycle Methods
1052
1157
  // ============================================================================
@@ -1116,9 +1221,7 @@ export abstract class PikkuWorkflowService implements WorkflowService {
1116
1221
  const queueService = this.verifyQueueService()
1117
1222
  await queueService.add(
1118
1223
  this.getStepWorkerQueueName(rpcName),
1119
- JSON.parse(
1120
- JSON.stringify({ runId, stepName, rpcName, data, fromStepName })
1121
- ),
1224
+ { runId, stepName, rpcName, data, fromStepName },
1122
1225
  {
1123
1226
  ...this.resolveStepJobOptions(stepOptions),
1124
1227
  // Group by step function, mirroring how per-step queues split them —
@@ -1210,9 +1313,7 @@ export abstract class PikkuWorkflowService implements WorkflowService {
1210
1313
  try {
1211
1314
  await getSingletonServices()!.queueService!.add(
1212
1315
  this.getStepWorkerQueueName(rpcName),
1213
- JSON.parse(
1214
- JSON.stringify({ runId, stepName, rpcName, data, fromStepName })
1215
- ),
1316
+ { runId, stepName, rpcName, data, fromStepName },
1216
1317
  {
1217
1318
  ...this.resolveStepJobOptions(stepOptions),
1218
1319
  group: this.getJobGroup(rpcName),
@@ -1299,27 +1400,16 @@ export abstract class PikkuWorkflowService implements WorkflowService {
1299
1400
  onRunCreated?: (runId: string) => void
1300
1401
  }
1301
1402
  ): Promise<{ runId: string }> {
1302
- // Resolve workflow from static meta (root or addon namespace), then dynamic DB
1403
+ // Resolve workflow from static meta (root or addon namespace)
1303
1404
  const resolved = resolveWorkflowMeta(name)
1304
- let workflowMeta = resolved?.meta
1405
+ const workflowMeta = resolved?.meta
1305
1406
  const packageName = resolved?.packageName ?? null
1306
1407
 
1307
- if (!workflowMeta) {
1308
- const dynamicWorkflows = await this.getAIGeneratedWorkflows()
1309
- const match = dynamicWorkflows.find((w) => w.workflowName === name)
1310
- if (match?.graph) {
1311
- workflowMeta = match.graph
1312
- }
1313
- }
1314
-
1315
1408
  if (!workflowMeta) {
1316
1409
  throw new WorkflowNotFoundError(name)
1317
1410
  }
1318
1411
 
1319
- if (
1320
- workflowMeta.source === 'graph' ||
1321
- workflowMeta.source === 'dynamic-workflow'
1322
- ) {
1412
+ if (workflowMeta.source === 'graph') {
1323
1413
  const shouldInline =
1324
1414
  options?.inline || !getSingletonServices()?.queueService
1325
1415
  return runWorkflowGraph(
@@ -1366,7 +1456,7 @@ export abstract class PikkuWorkflowService implements WorkflowService {
1366
1456
  await this.runExtension?.attachRunContext(runId, workflowMeta, options)
1367
1457
 
1368
1458
  if (shouldInline) {
1369
- this.inlineRuns.add(runId)
1459
+ this.registerInlineRun(runId)
1370
1460
  try {
1371
1461
  await this.runWorkflowJob(runId, rpcService)
1372
1462
  } catch (error: any) {
@@ -1399,7 +1489,7 @@ export abstract class PikkuWorkflowService implements WorkflowService {
1399
1489
  throw error
1400
1490
  }
1401
1491
  } finally {
1402
- this.inlineRuns.delete(runId)
1492
+ this.unregisterInlineRun(runId)
1403
1493
  this.runExtension?.detachRunContext(runId)
1404
1494
  }
1405
1495
  } else {
@@ -1415,7 +1505,6 @@ export abstract class PikkuWorkflowService implements WorkflowService {
1415
1505
  rpcService: any,
1416
1506
  options?: { pollIntervalMs?: number; wire?: WorkflowRunWire }
1417
1507
  ): Promise<any> {
1418
- const pollInterval = options?.pollIntervalMs ?? 1000
1419
1508
  const { runId } = await this.startWorkflow(
1420
1509
  name,
1421
1510
  input,
@@ -1423,38 +1512,155 @@ export abstract class PikkuWorkflowService implements WorkflowService {
1423
1512
  rpcService,
1424
1513
  { inline: true }
1425
1514
  )
1515
+ const run = await this.awaitRunEnd(runId, options?.pollIntervalMs ?? 1000)
1516
+ if (run.status === 'failed') {
1517
+ throw new WorkflowRunFailedError(run.error?.message)
1518
+ }
1519
+ if (run.status === 'cancelled') {
1520
+ throw new WorkflowRunCancelledError()
1521
+ }
1522
+ return run.output
1523
+ }
1524
+
1525
+ /**
1526
+ * Read a run until it reaches an end state, backing off as it drags on.
1527
+ *
1528
+ * A fixed interval is wrong at both ends: it makes a workflow that finished
1529
+ * in milliseconds wait out the whole interval anyway, and it keeps reading a
1530
+ * long-running one at full rate for as long as it lasts. Starting short and
1531
+ * growing to `maxIntervalMs` returns quick runs promptly while a slow run's
1532
+ * read cost grows logarithmically rather than linearly with its duration.
1533
+ */
1534
+ protected async awaitRunEnd(
1535
+ runId: string,
1536
+ maxIntervalMs: number
1537
+ ): Promise<WorkflowRun> {
1538
+ let interval = Math.min(WORKFLOW_POLL_MIN_MS, maxIntervalMs)
1426
1539
  while (true) {
1427
1540
  const run = await this.getRun(runId)
1428
1541
  if (!run) {
1429
1542
  throw new WorkflowRunNotFoundError(runId)
1430
1543
  }
1431
1544
  if (WORKFLOW_END_STATES.has(run.status)) {
1432
- if (run.status === 'failed') {
1433
- throw new WorkflowRunFailedError(run.error?.message)
1434
- }
1435
- if (run.status === 'cancelled') {
1436
- throw new WorkflowRunCancelledError()
1437
- }
1438
- return run.output
1545
+ return run
1439
1546
  }
1440
- await new Promise((resolve) => setTimeout(resolve, pollInterval))
1547
+ await this.waitBeforeNextRead(interval)
1548
+ interval = Math.min(interval * WORKFLOW_POLL_FACTOR, maxIntervalMs)
1441
1549
  }
1442
1550
  }
1443
1551
 
1444
- // Per-run, per-replay ordinal counters (runId → stepName → count).
1445
- private stepOrdinals = new Map<string, Map<string, number>>()
1446
- // Previous step key reached in the current DSL walk (runId → stepName), so a
1447
- // new step records where it came from. Rebuilt each replay alongside ordinals.
1448
- private stepLineage = new Map<string, string>()
1552
+ /**
1553
+ * Wait between two reads of a run.
1554
+ *
1555
+ * Its own method so the backoff schedule can be asserted on directly. Timing
1556
+ * a poll loop by the clock measures the host's scheduler as much as the
1557
+ * policy — `setTimeout(40)` routinely returns late on a loaded runner — which
1558
+ * makes the obvious test both slow and flaky.
1559
+ */
1560
+ protected async waitBeforeNextRead(ms: number): Promise<void> {
1561
+ await new Promise((resolve) => setTimeout(resolve, ms))
1562
+ }
1563
+
1564
+ /**
1565
+ * Every step of a run in one read, or `null` if this backend has no bulk read.
1566
+ *
1567
+ * A replay walks the DSL body from the top, and each step it passes asks for
1568
+ * its own row — so a run of N steps costs N reads per replay and O(N^2) over
1569
+ * its lifetime. Backends that can answer this in a single query collapse that
1570
+ * to one read per replay.
1571
+ */
1572
+ protected async listStepStates(
1573
+ _runId: string
1574
+ ): Promise<Array<StepState & { stepName: string }> | null> {
1575
+ return null
1576
+ }
1577
+
1578
+ /**
1579
+ * Begin a replay pass: fresh ordinal counters, and one read of the steps the
1580
+ * run has already taken so the walk back to where it left off is served from
1581
+ * memory. Safe because a pass reaches each step key at most once, and the
1582
+ * steps it replays past are `succeeded` and therefore immutable.
1583
+ */
1584
+ private async beginReplay(runId: string): Promise<void> {
1585
+ const context = this.contextFor(runId)
1586
+ context.replay = { ordinals: new Map() }
1587
+ const steps = await this.listStepStates(runId)
1588
+ if (steps) {
1589
+ context.replay.steps = new Map(steps.map((step) => [step.stepName, step]))
1590
+ }
1591
+ }
1592
+
1593
+ private endReplay(runId: string): void {
1594
+ const context = this.runContexts.get(runId)
1595
+ if (!context) return
1596
+ context.replay = undefined
1597
+ this.releaseContext(runId)
1598
+ }
1599
+
1600
+ /**
1601
+ * The step row for `stepName`, creating it if the run has not reached it
1602
+ * before. Served from the replay snapshot when one is loaded.
1603
+ */
1604
+ private async loadOrCreateStep(
1605
+ runId: string,
1606
+ stepName: string,
1607
+ create: () => Promise<StepState>
1608
+ ): Promise<StepState> {
1609
+ const snapshot = this.runContexts.get(runId)?.replay?.steps
1610
+ if (snapshot) {
1611
+ const cached = snapshot.get(stepName)
1612
+ if (cached) {
1613
+ return cached
1614
+ }
1615
+ } else {
1616
+ try {
1617
+ return await this.getStepState(runId, stepName)
1618
+ } catch {
1619
+ // No row yet — fall through and create it.
1620
+ }
1621
+ }
1449
1622
 
1450
- private resetStepOrdinals(runId: string): void {
1451
- this.stepOrdinals.set(runId, new Map())
1452
- this.stepLineage.delete(runId)
1623
+ let step: StepState
1624
+ try {
1625
+ step = await create()
1626
+ } catch (error) {
1627
+ // A concurrent replay of this run created the row after the snapshot was
1628
+ // taken. Its state is the truth; if it isn't really there, the insert
1629
+ // failed for its own reasons and that error is the one worth seeing.
1630
+ try {
1631
+ step = await this.getStepState(runId, stepName)
1632
+ } catch {
1633
+ throw error
1634
+ }
1635
+ }
1636
+ snapshot?.set(stepName, step)
1637
+ return step
1638
+ }
1639
+
1640
+ /**
1641
+ * The run's immutable half — which workflow it is, the wire it was started
1642
+ * on, its input. `getRun` is otherwise called several times per step for
1643
+ * answers that were all fixed at creation, so a replay reads it once and
1644
+ * hands the same object to everyone who only needs that half.
1645
+ *
1646
+ * Anyone who needs `status`, `output`, `error` or `state` must call `getRun`:
1647
+ * those move while the run executes, and a cached copy would be a lie.
1648
+ */
1649
+ private async getRunIdentity(runId: string): Promise<WorkflowRun | null> {
1650
+ const replay = this.runContexts.get(runId)?.replay
1651
+ if (replay?.run) {
1652
+ return replay.run
1653
+ }
1654
+ const run = await this.getRun(runId)
1655
+ if (run && replay) {
1656
+ replay.run = run
1657
+ }
1658
+ return run
1453
1659
  }
1454
1660
 
1455
1661
  /** The step the DSL walk last reached (the predecessor for the next step). */
1456
1662
  private lastStepName(runId: string): string | undefined {
1457
- return this.stepLineage.get(runId)
1663
+ return this.runContexts.get(runId)?.replay?.lastStep
1458
1664
  }
1459
1665
 
1460
1666
  /**
@@ -1464,26 +1670,26 @@ export abstract class PikkuWorkflowService implements WorkflowService {
1464
1670
  * the rows clobbering. Deterministic given a deterministic DSL body.
1465
1671
  */
1466
1672
  private nextStepKey(runId: string, logicalStepName: string): string {
1467
- let perRun = this.stepOrdinals.get(runId)
1468
- if (!perRun) {
1469
- perRun = new Map()
1470
- this.stepOrdinals.set(runId, perRun)
1471
- }
1472
- const ordinal = perRun.get(logicalStepName) ?? 0
1473
- perRun.set(logicalStepName, ordinal + 1)
1673
+ const context = this.contextFor(runId)
1674
+ const replay: NonNullable<RunContext['replay']> = (context.replay ??= {
1675
+ ordinals: new Map(),
1676
+ })
1677
+ const ordinal = replay.ordinals.get(logicalStepName) ?? 0
1678
+ replay.ordinals.set(logicalStepName, ordinal + 1)
1474
1679
  const stepName =
1475
1680
  ordinal === 0 ? logicalStepName : `${logicalStepName}#${ordinal}`
1476
- this.stepLineage.set(runId, stepName)
1681
+ replay.lastStep = stepName
1477
1682
  return stepName
1478
1683
  }
1479
1684
 
1480
1685
  public async runWorkflowJob(runId: string, rpcService: any): Promise<void> {
1481
- // Fresh ordinal counters per replay so step keys are deterministic.
1482
- this.resetStepOrdinals(runId)
1686
+ // Fresh ordinal counters per replay so step keys are deterministic, and one
1687
+ // read of the steps the run has already taken.
1688
+ await this.beginReplay(runId)
1483
1689
  try {
1484
1690
  await this.runWorkflowJobInner(runId, rpcService)
1485
1691
  } finally {
1486
- this.stepOrdinals.delete(runId)
1692
+ this.endReplay(runId)
1487
1693
  }
1488
1694
  }
1489
1695
 
@@ -1491,7 +1697,9 @@ export abstract class PikkuWorkflowService implements WorkflowService {
1491
1697
  runId: string,
1492
1698
  rpcService: any
1493
1699
  ): Promise<void> {
1494
- const run = await this.getRun(runId)
1700
+ // Caches the run for the rest of this replay, so the steps it walks don't
1701
+ // each re-read the workflow name and wire it already has.
1702
+ const run = await this.getRunIdentity(runId)
1495
1703
  if (!run) {
1496
1704
  throw new WorkflowRunNotFoundError(runId)
1497
1705
  }
@@ -1509,10 +1717,7 @@ export abstract class PikkuWorkflowService implements WorkflowService {
1509
1717
  return
1510
1718
  }
1511
1719
 
1512
- if (
1513
- workflowMeta?.source === 'graph' ||
1514
- workflowMeta?.source === 'dynamic-workflow'
1515
- ) {
1720
+ if (workflowMeta?.source === 'graph') {
1516
1721
  await continueGraph(this, runId, run.workflow)
1517
1722
  const updatedRun = await this.getRun(runId)
1518
1723
  if (updatedRun?.status === 'completed') {
@@ -1529,29 +1734,6 @@ export abstract class PikkuWorkflowService implements WorkflowService {
1529
1734
  return
1530
1735
  }
1531
1736
 
1532
- if (!workflowMeta) {
1533
- const dynamicWorkflows = await this.getAIGeneratedWorkflows()
1534
- const match = dynamicWorkflows.find(
1535
- (w) => w.workflowName === run.workflow
1536
- )
1537
- if (match?.graph) {
1538
- await continueGraph(this, runId, run.workflow, match.graph)
1539
- const updatedRun = await this.getRun(runId)
1540
- if (updatedRun?.status === 'completed') {
1541
- await this.onChildWorkflowCompleted(updatedRun, updatedRun.output)
1542
- } else if (
1543
- updatedRun?.status === 'failed' ||
1544
- updatedRun?.status === 'cancelled'
1545
- ) {
1546
- await this.onChildWorkflowFailed(
1547
- updatedRun,
1548
- new Error(updatedRun.error?.message || 'Child workflow failed')
1549
- )
1550
- }
1551
- return
1552
- }
1553
- }
1554
-
1555
1737
  const registrations = pikkuState(pkgName, 'workflows', 'registrations')
1556
1738
  const workflow = registrations.get(resolved?.resolvedName ?? run.workflow)
1557
1739
  if (!workflow) {
@@ -1764,9 +1946,7 @@ export abstract class PikkuWorkflowService implements WorkflowService {
1764
1946
  const meta = pikkuState(null, 'workflows', 'meta')
1765
1947
  const workflowMeta = meta[run.workflow]
1766
1948
 
1767
- const isGraphWorkflow =
1768
- workflowMeta?.source === 'graph' ||
1769
- workflowMeta?.source === 'dynamic-workflow'
1949
+ const isGraphWorkflow = workflowMeta?.source === 'graph'
1770
1950
  // Map the physical step key back to its logical node: a revisit instance
1771
1951
  // is `node#N` (ordinal), which isn't a literal key in `nodes`.
1772
1952
  let graphNodeId: string | undefined
@@ -1833,7 +2013,8 @@ export abstract class PikkuWorkflowService implements WorkflowService {
1833
2013
  stepState,
1834
2014
  rpcName,
1835
2015
  data,
1836
- rpcService
2016
+ rpcService,
2017
+ run
1837
2018
  )
1838
2019
  }
1839
2020
  }
@@ -1938,11 +2119,12 @@ export abstract class PikkuWorkflowService implements WorkflowService {
1938
2119
  stepState: StepState,
1939
2120
  rpcName: string,
1940
2121
  data: any,
1941
- rpcService: any
2122
+ rpcService: any,
2123
+ knownRun?: WorkflowRun | null
1942
2124
  ): Promise<any> {
1943
2125
  // Carry the run's pikkuUserId onto the step wire so authed steps rehydrate their
1944
2126
  // session on the queued path too (the bare job wire lacks it; inline already has it).
1945
- const run = await this.getRun(runId)
2127
+ const run = knownRun ?? (await this.getRunIdentity(runId))
1946
2128
  return rpcService.rpcWithWire(rpcName, data, {
1947
2129
  ...(run?.wire?.pikkuUserId ? { pikkuUserId: run.wire.pikkuUserId } : {}),
1948
2130
  workflowStep: {
@@ -2052,13 +2234,9 @@ export abstract class PikkuWorkflowService implements WorkflowService {
2052
2234
  actor: stepOptions?.actor,
2053
2235
  onError: stepOptions?.onError,
2054
2236
  }
2055
- // Check if step already exists
2056
- let stepState: StepState
2057
- try {
2058
- stepState = await this.getStepState(runId, stepName)
2059
- } catch {
2060
- // Step doesn't exist - create it
2061
- stepState = await this.insertStepState(
2237
+ // Reuse the step if the run already reached it, otherwise create it.
2238
+ const stepState = await this.loadOrCreateStep(runId, stepName, () =>
2239
+ this.insertStepState(
2062
2240
  runId,
2063
2241
  stepName,
2064
2242
  rpcName,
@@ -2066,7 +2244,7 @@ export abstract class PikkuWorkflowService implements WorkflowService {
2066
2244
  resolvedStepOptions,
2067
2245
  fromStepName
2068
2246
  )
2069
- }
2247
+ )
2070
2248
 
2071
2249
  if (stepState.status === 'succeeded') {
2072
2250
  // Return cached result
@@ -2160,24 +2338,17 @@ export abstract class PikkuWorkflowService implements WorkflowService {
2160
2338
  )
2161
2339
  await this.setStepChildRunId(currentStepState.stepId, childRunId)
2162
2340
  // Poll until child workflow completes
2163
- while (true) {
2164
- const childRun = await this.getRun(childRunId)
2165
- if (!childRun) {
2166
- throw new WorkflowRunNotFoundError(childRunId)
2167
- }
2168
- if (WORKFLOW_END_STATES.has(childRun.status)) {
2169
- if (childRun.status === 'failed') {
2170
- throw new Error(
2171
- childRun.error?.message || 'Sub-workflow failed'
2172
- )
2173
- }
2174
- if (childRun.status === 'cancelled') {
2175
- throw new Error('Sub-workflow was cancelled')
2176
- }
2177
- return childRun.output
2178
- }
2179
- await new Promise((resolve) => setTimeout(resolve, 500))
2341
+ const childRun = await this.awaitRunEnd(
2342
+ childRunId,
2343
+ WORKFLOW_CHILD_POLL_MAX_MS
2344
+ )
2345
+ if (childRun.status === 'failed') {
2346
+ throw new Error(childRun.error?.message || 'Sub-workflow failed')
2347
+ }
2348
+ if (childRun.status === 'cancelled') {
2349
+ throw new Error('Sub-workflow was cancelled')
2180
2350
  }
2351
+ return childRun.output
2181
2352
  }
2182
2353
  return this.invokeStepRpc(
2183
2354
  runId,
@@ -2222,13 +2393,10 @@ export abstract class PikkuWorkflowService implements WorkflowService {
2222
2393
  ): Promise<any> {
2223
2394
  const fromStepName = this.lastStepName(runId)
2224
2395
  const stepName = this.nextStepKey(runId, logicalStepName)
2225
- // Check if step already exists
2226
- let stepState: StepState
2227
- try {
2228
- stepState = await this.getStepState(runId, stepName)
2229
- } catch {
2230
- // Step doesn't exist - create it (inline, so never dispatched)
2231
- stepState = await this.insertStepState(
2396
+ // Reuse the step if the run already reached it, otherwise create it
2397
+ // (inline, so never dispatched).
2398
+ const stepState = await this.loadOrCreateStep(runId, stepName, () =>
2399
+ this.insertStepState(
2232
2400
  runId,
2233
2401
  stepName,
2234
2402
  rpcName,
@@ -2236,7 +2404,7 @@ export abstract class PikkuWorkflowService implements WorkflowService {
2236
2404
  stepOptions,
2237
2405
  fromStepName
2238
2406
  )
2239
- }
2407
+ )
2240
2408
 
2241
2409
  if (stepState.status === 'succeeded') {
2242
2410
  // Return cached result
@@ -2288,13 +2456,10 @@ export abstract class PikkuWorkflowService implements WorkflowService {
2288
2456
  ) {
2289
2457
  const fromStepName = this.lastStepName(runId)
2290
2458
  const stepName = this.nextStepKey(runId, logicalStepName)
2291
- // Check if step already exists
2292
- let stepState: StepState
2293
- try {
2294
- stepState = await this.getStepState(runId, stepName)
2295
- } catch {
2296
- // Step doesn't exist - create it (sleep step, no RPC)
2297
- stepState = await this.insertStepState(
2459
+ // Reuse the step if the run already reached it, otherwise create it
2460
+ // (sleep step, no RPC).
2461
+ const stepState = await this.loadOrCreateStep(runId, stepName, () =>
2462
+ this.insertStepState(
2298
2463
  runId,
2299
2464
  stepName,
2300
2465
  null,
@@ -2302,7 +2467,7 @@ export abstract class PikkuWorkflowService implements WorkflowService {
2302
2467
  undefined,
2303
2468
  fromStepName
2304
2469
  )
2305
- }
2470
+ )
2306
2471
 
2307
2472
  if (stepState.status === 'succeeded') {
2308
2473
  // Sleep already completed, return immediately