@pikku/core 0.12.69 → 0.12.71

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (118) hide show
  1. package/CHANGELOG.md +422 -0
  2. package/LICENSE +21 -0
  3. package/README.md +34 -2
  4. package/dist/function/functions.types.d.ts +27 -0
  5. package/dist/index.d.ts +1 -1
  6. package/dist/internal.d.ts +1 -1
  7. package/dist/internal.js +1 -1
  8. package/dist/pikku-state.js +1 -0
  9. package/dist/services/http-scenario-actors.d.ts +12 -4
  10. package/dist/services/http-scenario-actors.js +47 -45
  11. package/dist/services/in-memory-queue-service.d.ts +6 -0
  12. package/dist/services/in-memory-queue-service.js +8 -1
  13. package/dist/services/in-memory-workflow-service.d.ts +3 -5
  14. package/dist/services/in-memory-workflow-service.js +10 -19
  15. package/dist/services/index.d.ts +2 -1
  16. package/dist/services/index.js +1 -0
  17. package/dist/services/meta-service.d.ts +5 -1
  18. package/dist/services/meta-service.js +44 -18
  19. package/dist/services/scenario-actors-service.d.ts +108 -2
  20. package/dist/services/scenario-actors-service.js +40 -1
  21. package/dist/services/workflow-service.d.ts +7 -5
  22. package/dist/types/core.types.d.ts +28 -3
  23. package/dist/types/state.types.d.ts +3 -1
  24. package/dist/wirings/actor-flow/actor-flow.types.d.ts +1 -1
  25. package/dist/wirings/actor-flow/index.d.ts +1 -1
  26. package/dist/wirings/actor-flow/run-conversation.d.ts +10 -10
  27. package/dist/wirings/actor-flow/run-conversation.js +27 -27
  28. package/dist/wirings/ai-agent/ai-agent-agui.js +0 -8
  29. package/dist/wirings/ai-agent/ai-agent-prepare.js +1 -2
  30. package/dist/wirings/ai-agent/ai-agent.types.d.ts +0 -6
  31. package/dist/wirings/cli/command-parser.js +11 -1
  32. package/dist/wirings/rpc/rpc-runner.js +1 -1
  33. package/dist/wirings/workflow/dsl/workflow-dsl.types.d.ts +52 -3
  34. package/dist/wirings/workflow/feature.d.ts +28 -0
  35. package/dist/wirings/workflow/feature.js +57 -0
  36. package/dist/wirings/workflow/graph/graph-runner.js +3 -2
  37. package/dist/wirings/workflow/graph/graph-validation.d.ts +0 -2
  38. package/dist/wirings/workflow/graph/graph-validation.js +0 -142
  39. package/dist/wirings/workflow/graph/index.d.ts +1 -1
  40. package/dist/wirings/workflow/graph/index.js +1 -1
  41. package/dist/wirings/workflow/index.d.ts +13 -3
  42. package/dist/wirings/workflow/index.js +15 -2
  43. package/dist/wirings/workflow/pikku-scenario-service.d.ts +121 -0
  44. package/dist/wirings/workflow/pikku-scenario-service.js +419 -0
  45. package/dist/wirings/workflow/pikku-workflow-service.d.ts +170 -23
  46. package/dist/wirings/workflow/pikku-workflow-service.js +338 -297
  47. package/dist/wirings/workflow/scenario-cookie-jar.d.ts +29 -0
  48. package/dist/wirings/workflow/scenario-cookie-jar.js +51 -0
  49. package/dist/wirings/workflow/scenario-poll.d.ts +20 -0
  50. package/dist/wirings/workflow/scenario-poll.js +25 -0
  51. package/dist/wirings/workflow/scenario-prose.d.ts +38 -0
  52. package/dist/wirings/workflow/scenario-prose.js +45 -0
  53. package/dist/wirings/workflow/scenario-step-guards.d.ts +16 -0
  54. package/dist/wirings/workflow/scenario-step-guards.js +29 -0
  55. package/dist/wirings/workflow/scenario-step.types.d.ts +148 -0
  56. package/dist/wirings/workflow/scenario-step.types.js +1 -0
  57. package/dist/wirings/workflow/workflow.types.d.ts +82 -8
  58. package/package.json +3 -1
  59. package/src/function/functions.types.ts +32 -0
  60. package/src/index.ts +1 -0
  61. package/src/internal.ts +5 -1
  62. package/src/pikku-state.ts +1 -0
  63. package/src/services/http-scenario-actors.test.ts +85 -1
  64. package/src/services/http-scenario-actors.ts +65 -51
  65. package/src/services/in-memory-queue-service.test.ts +66 -1
  66. package/src/services/in-memory-queue-service.ts +13 -2
  67. package/src/services/in-memory-workflow-service.ts +12 -25
  68. package/src/services/index.ts +5 -0
  69. package/src/services/meta-service.test.ts +79 -0
  70. package/src/services/meta-service.ts +61 -26
  71. package/src/services/scenario-actors-service.ts +157 -2
  72. package/src/services/workflow-service.ts +7 -4
  73. package/src/types/core.types.ts +34 -2
  74. package/src/types/state.types.ts +3 -0
  75. package/src/wirings/actor-flow/actor-flow.types.ts +1 -1
  76. package/src/wirings/actor-flow/index.ts +1 -1
  77. package/src/wirings/actor-flow/run-conversation.test.ts +12 -6
  78. package/src/wirings/actor-flow/run-conversation.ts +36 -41
  79. package/src/wirings/ai-agent/ai-agent-agui.test.ts +0 -16
  80. package/src/wirings/ai-agent/ai-agent-agui.ts +0 -9
  81. package/src/wirings/ai-agent/ai-agent-prepare.ts +1 -2
  82. package/src/wirings/ai-agent/ai-agent.types.ts +0 -7
  83. package/src/wirings/cli/command-parser.test.ts +60 -0
  84. package/src/wirings/cli/command-parser.ts +12 -1
  85. package/src/wirings/rpc/rpc-runner.test.ts +28 -5
  86. package/src/wirings/rpc/rpc-runner.ts +1 -1
  87. package/src/wirings/workflow/dsl/workflow-dsl.types.ts +86 -2
  88. package/src/wirings/workflow/feature.test.ts +131 -0
  89. package/src/wirings/workflow/feature.ts +78 -0
  90. package/src/wirings/workflow/graph/graph-runner.ts +3 -2
  91. package/src/wirings/workflow/graph/graph-validation.test.ts +1 -144
  92. package/src/wirings/workflow/graph/graph-validation.ts +0 -196
  93. package/src/wirings/workflow/graph/index.ts +1 -5
  94. package/src/wirings/workflow/index.ts +73 -6
  95. package/src/wirings/workflow/pikku-scenario-service.ts +682 -0
  96. package/src/wirings/workflow/pikku-workflow-service.test.ts +55 -0
  97. package/src/wirings/workflow/pikku-workflow-service.ts +572 -419
  98. package/src/wirings/workflow/scenario-cookie-jar.test.ts +108 -0
  99. package/src/wirings/workflow/scenario-cookie-jar.ts +65 -0
  100. package/src/wirings/workflow/scenario-expectations.test.ts +153 -0
  101. package/src/wirings/workflow/scenario-hooks.test.ts +212 -0
  102. package/src/wirings/workflow/scenario-poll.test.ts +66 -0
  103. package/src/wirings/workflow/scenario-poll.ts +36 -0
  104. package/src/wirings/workflow/scenario-prose.test.ts +152 -0
  105. package/src/wirings/workflow/scenario-prose.ts +79 -0
  106. package/src/wirings/workflow/scenario-service.test.ts +155 -0
  107. package/src/wirings/workflow/scenario-step-guards.ts +43 -0
  108. package/src/wirings/workflow/scenario-step.test.ts +442 -9
  109. package/src/wirings/workflow/scenario-step.types.ts +157 -0
  110. package/src/wirings/workflow/workflow-dispatch-durability.test.ts +1 -1
  111. package/src/wirings/workflow/workflow-dispatch-payload.test.ts +59 -0
  112. package/src/wirings/workflow/workflow-mirror.test.ts +178 -0
  113. package/src/wirings/workflow/workflow-replay-snapshot.test.ts +139 -0
  114. package/src/wirings/workflow/workflow-run-context.test.ts +177 -0
  115. package/src/wirings/workflow/workflow-run-polling.test.ts +132 -0
  116. package/src/wirings/workflow/workflow-step-ordinal.test.ts +4 -4
  117. package/src/wirings/workflow/workflow.types.ts +99 -5
  118. package/tsconfig.tsbuildinfo +1 -1
@@ -11,7 +11,6 @@ import {
11
11
  pikkuState,
12
12
  } from '../../pikku-state.js'
13
13
  import { getDurationInMilliseconds } from '../../time-utils.js'
14
- import { createHttpScenarioActors } from '../../services/http-scenario-actors.js'
15
14
 
16
15
  const resolveWorkflowMeta = (
17
16
  name: string
@@ -46,10 +45,10 @@ const toKebab = (s: string) =>
46
45
  s.replace(/([a-z0-9])([A-Z])/g, '$1-$2').toLowerCase()
47
46
  import type { PikkuWire, SerializedError } from '../../types/core.types.js'
48
47
  import type { QueueService } from '../queue/queue.types.js'
49
- import { runScheduledTask } from '../scheduler/scheduler-runner.js'
50
48
  import type {
51
49
  ApprovalOutcome,
52
- PikkuScenarioWire,
50
+ CoreWorkflow,
51
+ PikkuWorkflowWire,
53
52
  StepState,
54
53
  StepStatus,
55
54
  WorkflowApprovalOptions,
@@ -63,9 +62,6 @@ import type {
63
62
  WorkflowQueueOptions,
64
63
  WorkflowServiceConfig,
65
64
  WorkflowStepOptions,
66
- WorkflowExpectEventuallyOptions,
67
- WorkflowExpectErrorOptions,
68
- WorkflowExpectServiceOptions,
69
65
  } from './workflow.types.js'
70
66
  import {
71
67
  continueGraph,
@@ -246,16 +242,174 @@ const WORKFLOW_END_STATES: ReadonlySet<string> = new Set([
246
242
  'suspended',
247
243
  ])
248
244
 
245
+ /**
246
+ * Everything an extension needs to run something of its own around a run's
247
+ * function: the registration it was resolved from, the run itself (so `input`
248
+ * is the same object the function is called with), and the wire it is given.
249
+ */
250
+ export interface RunLifecycleContext {
251
+ runId: string
252
+ run: WorkflowRun
253
+ workflowMeta: any
254
+ workflow: CoreWorkflow
255
+ wire: PikkuWire
256
+ packageName: string | null
257
+ }
258
+
259
+ /**
260
+ * The slice of the run engine an extension is allowed to drive, handed to it at
261
+ * construction. It exists so that recording a durable step stays available to
262
+ * an extension without `inlineStep` and friends becoming public API on every
263
+ * workflow service a production app instantiates.
264
+ */
265
+ export interface WorkflowRunEngine {
266
+ inlineStep(
267
+ runId: string,
268
+ logicalStepName: string,
269
+ fn: Function,
270
+ stepOptions?: WorkflowStepOptions,
271
+ data?: any,
272
+ funcName?: string
273
+ ): Promise<any>
274
+ updateRunStatus(
275
+ runId: string,
276
+ status: WorkflowStatus,
277
+ output?: any,
278
+ error?: SerializedError
279
+ ): Promise<void>
280
+ onChildWorkflowFailed(run: WorkflowRun, error: unknown): Promise<void>
281
+ verifyStepName(stepName: unknown): void
282
+ }
283
+
284
+ /**
285
+ * A capability layered onto a run without being a workflow service itself.
286
+ *
287
+ * The engine names nothing about what an extension is for: a bundler drops an
288
+ * unused *module* but never an unused class member, so the alternative — a
289
+ * subclass carrying the capability — puts it in every server built on Pikku
290
+ * whether or not the app ever uses it. Scenarios are the one implementation
291
+ * today (`PikkuScenarioService` in `@pikku/core/scenario`).
292
+ */
293
+ export interface WorkflowRunExtension {
294
+ /** Per-run state resolved once the run has an id. */
295
+ attachRunContext(
296
+ runId: string,
297
+ workflowMeta: any,
298
+ options?: Record<string, any>
299
+ ): Promise<void>
300
+ /** Release whatever `attachRunContext` stored. */
301
+ detachRunContext(runId: string): void
302
+ /** Contribute extra members to the wire a run's function is invoked with. */
303
+ decorateRunWire(
304
+ wire: PikkuWire,
305
+ context: {
306
+ runId: string
307
+ workflowMeta: any
308
+ workflowWire: PikkuWorkflowWire
309
+ }
310
+ ): void
311
+ /** Contribute extra members to the workflow wire itself, in place. */
312
+ decorateWorkflowWire(
313
+ workflowWire: PikkuWorkflowWire,
314
+ context: {
315
+ name: string
316
+ runId: string
317
+ rpcService: any
318
+ addonNamespace?: string | null
319
+ }
320
+ ): void
321
+ /**
322
+ * Called immediately before the run's function, inside the run lock. Throwing
323
+ * skips the function and fails the run.
324
+ */
325
+ onBeforeRunFunc(context: RunLifecycleContext): Promise<void>
326
+ /**
327
+ * Called once the run reaches a terminal state, in a `finally`, so it runs
328
+ * whether the function passed or threw. `interrupted` means the run suspended
329
+ * or went async and is still mid-flight. Throwing here propagates, replacing
330
+ * a pending failure, so an extension that must not mask one handles that
331
+ * itself.
332
+ */
333
+ onAfterRunFunc(
334
+ context: RunLifecycleContext,
335
+ outcome: 'completed' | 'failed' | 'interrupted',
336
+ failure: unknown
337
+ ): Promise<void>
338
+ }
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
+
249
364
  /**
250
365
  * Abstract workflow state service
251
366
  * Implementations provide pluggable storage backends (SQLite, PostgreSQL, etc.)
252
367
  * Combines orchestration and step execution
253
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
+
254
392
  export abstract class PikkuWorkflowService implements WorkflowService {
255
- private inlineRuns = new Set<string>()
256
- // User-flow actors per run: live authenticated clients (cookie jars) are
257
- // process-local by nature, so they ride this map, never the persisted wire.
258
- private runActors = new Map<string, ScenarioActors>()
393
+ private runExtension?: WorkflowRunExtension
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
+ }
259
413
 
260
414
  protected get logger() {
261
415
  return getSingletonServices()?.logger
@@ -283,19 +437,36 @@ export abstract class PikkuWorkflowService implements WorkflowService {
283
437
  }
284
438
  }
285
439
 
286
- private async safeMirror(fn: () => Promise<void>): Promise<void> {
287
- if (!this.mirror) return
288
- try {
289
- await fn()
290
- } 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) {
291
457
  try {
292
- this.logger?.warn?.(
293
- `[pikku] WorkflowRunMirror write failed: ${err?.message ?? err}`
294
- )
295
- } catch {
296
- // 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
+ }
297
467
  }
298
468
  }
469
+ return written
299
470
  }
300
471
 
301
472
  /**
@@ -431,21 +602,24 @@ export abstract class PikkuWorkflowService implements WorkflowService {
431
602
  * Check if a run is executing inline (without queues)
432
603
  */
433
604
  protected isInline(runId: string): boolean {
434
- return this.inlineRuns.has(runId)
605
+ return this.runContexts.get(runId)?.inline === true
435
606
  }
436
607
 
437
608
  /**
438
609
  * Register a run as inline (for graph-runner to use)
439
610
  */
440
611
  public registerInlineRun(runId: string): void {
441
- this.inlineRuns.add(runId)
612
+ this.contextFor(runId).inline = true
442
613
  }
443
614
 
444
615
  /**
445
616
  * Unregister a run from inline tracking
446
617
  */
447
618
  public unregisterInlineRun(runId: string): void {
448
- this.inlineRuns.delete(runId)
619
+ const context = this.runContexts.get(runId)
620
+ if (!context) return
621
+ context.inline = false
622
+ this.releaseContext(runId)
449
623
  }
450
624
 
451
625
  public async registerWorkflowVersions(): Promise<void> {
@@ -467,26 +641,27 @@ export abstract class PikkuWorkflowService implements WorkflowService {
467
641
  plannedSteps?: WorkflowPlannedStep[]
468
642
  }
469
643
  ): Promise<string> {
470
- const runId = await this.createRunImpl(
471
- workflowName,
472
- input,
473
- inline,
474
- graphHash,
475
- wire,
476
- options
477
- )
478
- await this.safeMirror(() =>
479
- this.mirror!.createRun(
480
- runId,
481
- workflowName,
482
- input,
483
- inline,
484
- graphHash,
485
- wire,
486
- options
487
- )
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
+ )
488
664
  )
489
- return runId
490
665
  }
491
666
 
492
667
  protected abstract createRunImpl(
@@ -613,10 +788,17 @@ export abstract class PikkuWorkflowService implements WorkflowService {
613
788
  output?: any,
614
789
  error?: SerializedError
615
790
  ): Promise<void> {
616
- await this.updateRunStatusImpl(id, status, output, error)
617
- await this.safeMirror(() =>
618
- 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)
619
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
+ }
620
802
  }
621
803
 
622
804
  protected abstract updateRunStatusImpl(
@@ -631,7 +813,10 @@ export abstract class PikkuWorkflowService implements WorkflowService {
631
813
  * Creates pending step in both workflow_step and workflow_step_history
632
814
  * @param runId - Run ID
633
815
  * @param stepName - Step cache key
634
- * @param rpcName - RPC function name
816
+ * @param rpcName - The name this step was dispatched by: an RPC for a
817
+ * `workflow.do` step, a step function for a scenario step, null for a
818
+ * closure. Nothing dispatches off this value — it is recorded so a reader
819
+ * can join a step back to the function that ran it.
635
820
  * @param data - Step input data
636
821
  * @param stepOptions - Step options (retries, retryDelay)
637
822
  * @returns Step state with generated stepId
@@ -644,18 +829,19 @@ export abstract class PikkuWorkflowService implements WorkflowService {
644
829
  stepOptions?: WorkflowStepOptions,
645
830
  fromStepName?: string
646
831
  ): Promise<StepState> {
647
- const step = await this.insertStepStateImpl(
648
- runId,
649
- stepName,
650
- rpcName,
651
- data,
652
- stepOptions,
653
- fromStepName
654
- )
655
- await this.safeMirror(() =>
656
- 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 })
657
844
  )
658
- return step
659
845
  }
660
846
 
661
847
  protected abstract insertStepStateImpl(
@@ -681,8 +867,10 @@ export abstract class PikkuWorkflowService implements WorkflowService {
681
867
  * @param stepId - Step ID
682
868
  */
683
869
  public async setStepRunning(stepId: string): Promise<void> {
684
- await this.setStepRunningImpl(stepId)
685
- await this.safeMirror(() => this.mirror!.setStepRunning(stepId))
870
+ await this.mirrored(
871
+ () => this.setStepRunningImpl(stepId),
872
+ (mirror) => mirror.setStepRunning(stepId)
873
+ )
686
874
  }
687
875
 
688
876
  protected abstract setStepRunningImpl(stepId: string): Promise<void>
@@ -693,8 +881,10 @@ export abstract class PikkuWorkflowService implements WorkflowService {
693
881
  * @param stepId - Step ID
694
882
  */
695
883
  public async setStepScheduled(stepId: string): Promise<void> {
696
- await this.setStepScheduledImpl(stepId)
697
- await this.safeMirror(() => this.mirror!.setStepScheduled(stepId))
884
+ await this.mirrored(
885
+ () => this.setStepScheduledImpl(stepId),
886
+ (mirror) => mirror.setStepScheduled(stepId)
887
+ )
698
888
  }
699
889
 
700
890
  protected abstract setStepScheduledImpl(stepId: string): Promise<void>
@@ -706,8 +896,10 @@ export abstract class PikkuWorkflowService implements WorkflowService {
706
896
  * @param result - Step result
707
897
  */
708
898
  public async setStepResult(stepId: string, result: any): Promise<void> {
709
- await this.setStepResultImpl(stepId, result)
710
- await this.safeMirror(() => this.mirror!.setStepResult(stepId, result))
899
+ await this.mirrored(
900
+ () => this.setStepResultImpl(stepId, result),
901
+ (mirror) => mirror.setStepResult(stepId, result)
902
+ )
711
903
  }
712
904
 
713
905
  protected abstract setStepResultImpl(
@@ -724,9 +916,9 @@ export abstract class PikkuWorkflowService implements WorkflowService {
724
916
  stepId: string,
725
917
  childRunId: string
726
918
  ): Promise<void> {
727
- await this.setStepChildRunIdImpl(stepId, childRunId)
728
- await this.safeMirror(() =>
729
- this.mirror!.setStepChildRunId(stepId, childRunId)
919
+ await this.mirrored(
920
+ () => this.setStepChildRunIdImpl(stepId, childRunId),
921
+ (mirror) => mirror.setStepChildRunId(stepId, childRunId)
730
922
  )
731
923
  }
732
924
 
@@ -742,14 +934,18 @@ export abstract class PikkuWorkflowService implements WorkflowService {
742
934
  * @param error - Error object
743
935
  */
744
936
  public async setStepError(stepId: string, error: Error): Promise<void> {
745
- await this.setStepErrorImpl(stepId, error)
746
- const serialized: SerializedError = {
747
- message: error.message,
748
- stack: error.stack,
749
- code: (error as any).code,
750
- expected: isExpectedError(error),
751
- }
752
- 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
+ )
753
949
  }
754
950
 
755
951
  protected abstract setStepErrorImpl(
@@ -769,15 +965,14 @@ export abstract class PikkuWorkflowService implements WorkflowService {
769
965
  failedStepId: string,
770
966
  status: 'pending' | 'running'
771
967
  ): Promise<StepState> {
772
- const newStep = await this.createRetryAttemptImpl(failedStepId, status)
773
- const stepName = (newStep as any).stepName ?? ''
774
- await this.safeMirror(() =>
775
- this.mirror!.createRetryAttempt(failedStepId, {
776
- ...newStep,
777
- stepName,
778
- })
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
+ })
779
975
  )
780
- return newStep
781
976
  }
782
977
 
783
978
  protected abstract createRetryAttemptImpl(
@@ -871,8 +1066,10 @@ export abstract class PikkuWorkflowService implements WorkflowService {
871
1066
  stepId: string,
872
1067
  branchKey: string
873
1068
  ): Promise<void> {
874
- await this.setBranchTakenImpl(stepId, branchKey)
875
- await this.safeMirror(() => this.mirror!.setBranchTaken(stepId, branchKey))
1069
+ await this.mirrored(
1070
+ () => this.setBranchTakenImpl(stepId, branchKey),
1071
+ (mirror) => mirror.setBranchTaken(stepId, branchKey)
1072
+ )
876
1073
  }
877
1074
 
878
1075
  protected abstract setBranchTakenImpl(
@@ -891,8 +1088,10 @@ export abstract class PikkuWorkflowService implements WorkflowService {
891
1088
  name: string,
892
1089
  value: unknown
893
1090
  ): Promise<void> {
894
- await this.updateRunStateImpl(runId, name, value)
895
- 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
+ )
896
1095
  }
897
1096
 
898
1097
  protected abstract updateRunStateImpl(
@@ -915,9 +1114,11 @@ export abstract class PikkuWorkflowService implements WorkflowService {
915
1114
  source: string,
916
1115
  status?: WorkflowVersionStatus
917
1116
  ): Promise<void> {
918
- await this.upsertWorkflowVersionImpl(name, graphHash, graph, source, status)
919
- await this.safeMirror(() =>
920
- 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)
921
1122
  )
922
1123
  }
923
1124
 
@@ -934,9 +1135,9 @@ export abstract class PikkuWorkflowService implements WorkflowService {
934
1135
  graphHash: string,
935
1136
  status: WorkflowVersionStatus
936
1137
  ): Promise<void> {
937
- await this.updateWorkflowVersionStatusImpl(name, graphHash, status)
938
- await this.safeMirror(() =>
939
- this.mirror!.updateWorkflowVersionStatus(name, graphHash, status)
1138
+ await this.mirrored(
1139
+ () => this.updateWorkflowVersionStatusImpl(name, graphHash, status),
1140
+ (mirror) => mirror.updateWorkflowVersionStatus(name, graphHash, status)
940
1141
  )
941
1142
  }
942
1143
 
@@ -951,10 +1152,6 @@ export abstract class PikkuWorkflowService implements WorkflowService {
951
1152
  graphHash: string
952
1153
  ): Promise<{ graph: any; source: string } | null>
953
1154
 
954
- abstract getAIGeneratedWorkflows(
955
- agentName?: string
956
- ): Promise<Array<{ workflowName: string; graphHash: string; graph: any }>>
957
-
958
1155
  // ============================================================================
959
1156
  // Workflow Lifecycle Methods
960
1157
  // ============================================================================
@@ -1024,9 +1221,7 @@ export abstract class PikkuWorkflowService implements WorkflowService {
1024
1221
  const queueService = this.verifyQueueService()
1025
1222
  await queueService.add(
1026
1223
  this.getStepWorkerQueueName(rpcName),
1027
- JSON.parse(
1028
- JSON.stringify({ runId, stepName, rpcName, data, fromStepName })
1029
- ),
1224
+ { runId, stepName, rpcName, data, fromStepName },
1030
1225
  {
1031
1226
  ...this.resolveStepJobOptions(stepOptions),
1032
1227
  // Group by step function, mirroring how per-step queues split them —
@@ -1118,9 +1313,7 @@ export abstract class PikkuWorkflowService implements WorkflowService {
1118
1313
  try {
1119
1314
  await getSingletonServices()!.queueService!.add(
1120
1315
  this.getStepWorkerQueueName(rpcName),
1121
- JSON.parse(
1122
- JSON.stringify({ runId, stepName, rpcName, data, fromStepName })
1123
- ),
1316
+ { runId, stepName, rpcName, data, fromStepName },
1124
1317
  {
1125
1318
  ...this.resolveStepJobOptions(stepOptions),
1126
1319
  group: this.getJobGroup(rpcName),
@@ -1162,37 +1355,28 @@ export abstract class PikkuWorkflowService implements WorkflowService {
1162
1355
  return true
1163
1356
  }
1164
1357
 
1165
- /** Build HTTP scenario actors for a run started without them; undefined when SCENARIO_ACTOR_SECRET or the API URL is missing */
1166
- private async resolveScenarioActors(): Promise<ScenarioActors | undefined> {
1167
- const services = getSingletonServices()
1168
- const variables = services?.variables
1169
- const metaService = services?.metaService
1170
- if (!variables || !metaService) {
1171
- return undefined
1172
- }
1173
- const secret = await variables.get('SCENARIO_ACTOR_SECRET')
1174
- const apiUrl = await variables.get('API_URL')
1175
- if (!secret || !apiUrl) {
1176
- services?.logger?.warn(
1177
- 'A scenario was started without actors but SCENARIO_ACTOR_SECRET / API_URL is not configured — running without actors.'
1178
- )
1179
- return undefined
1180
- }
1181
- const actorsConfig = await metaService.getScenarioActorsMeta()
1182
- if (!actorsConfig || Object.keys(actorsConfig).length === 0) {
1183
- return undefined
1358
+ /**
1359
+ * Install the one extension a run may have, built from a handle onto the run
1360
+ * engine so that `inlineStep` and friends stay protected rather than becoming
1361
+ * public API. Returns the extension, so the caller keeps a typed reference to
1362
+ * whatever it just built.
1363
+ */
1364
+ public setRunExtension<T extends WorkflowRunExtension>(
1365
+ create: (engine: WorkflowRunEngine) => T
1366
+ ): T {
1367
+ const engine: WorkflowRunEngine = {
1368
+ inlineStep: this.inlineStep.bind(this),
1369
+ updateRunStatus: this.updateRunStatus.bind(this),
1370
+ onChildWorkflowFailed: this.onChildWorkflowFailed.bind(this),
1371
+ verifyStepName: this.verifyStepName.bind(this),
1184
1372
  }
1185
- const signInPath =
1186
- (await variables.get('SCENARIO_SIGN_IN_PATH')) ??
1187
- '/api/auth/sign-in/actor'
1188
- const rpcPath = (await variables.get('SCENARIO_RPC_PATH')) ?? '/rpc'
1189
- return createHttpScenarioActors({
1190
- apiUrl,
1191
- secret,
1192
- actors: actorsConfig,
1193
- signInPath,
1194
- rpcPath,
1195
- })
1373
+ const extension = create(engine)
1374
+ this.runExtension = extension
1375
+ return extension
1376
+ }
1377
+
1378
+ public getRunExtension(): WorkflowRunExtension | undefined {
1379
+ return this.runExtension
1196
1380
  }
1197
1381
 
1198
1382
  /**
@@ -1200,35 +1384,32 @@ export abstract class PikkuWorkflowService implements WorkflowService {
1200
1384
  * Automatically detects workflow type (DSL or graph) from meta and executes accordingly
1201
1385
  * @param options.inline - If true, execute workflow directly without queue service
1202
1386
  * @param options.startNode - Starting node ID for graph workflows (from wire config)
1387
+ * @param options.onRunCreated - Called with the run id the moment the run exists.
1388
+ * An inline run that fails throws instead of returning, so this is the only
1389
+ * way a caller can still read that run back — its steps, and which one failed.
1203
1390
  */
1204
1391
  public async startWorkflow<I>(
1205
1392
  name: string,
1206
1393
  input: I,
1207
1394
  wire: WorkflowRunWire,
1208
1395
  rpcService: any,
1209
- options?: { inline?: boolean; startNode?: string; actors?: ScenarioActors }
1396
+ options?: {
1397
+ inline?: boolean
1398
+ startNode?: string
1399
+ actors?: ScenarioActors
1400
+ onRunCreated?: (runId: string) => void
1401
+ }
1210
1402
  ): Promise<{ runId: string }> {
1211
- // Resolve workflow from static meta (root or addon namespace), then dynamic DB
1403
+ // Resolve workflow from static meta (root or addon namespace)
1212
1404
  const resolved = resolveWorkflowMeta(name)
1213
- let workflowMeta = resolved?.meta
1405
+ const workflowMeta = resolved?.meta
1214
1406
  const packageName = resolved?.packageName ?? null
1215
1407
 
1216
- if (!workflowMeta) {
1217
- const dynamicWorkflows = await this.getAIGeneratedWorkflows()
1218
- const match = dynamicWorkflows.find((w) => w.workflowName === name)
1219
- if (match?.graph) {
1220
- workflowMeta = match.graph
1221
- }
1222
- }
1223
-
1224
1408
  if (!workflowMeta) {
1225
1409
  throw new WorkflowNotFoundError(name)
1226
1410
  }
1227
1411
 
1228
- if (
1229
- workflowMeta.source === 'graph' ||
1230
- workflowMeta.source === 'dynamic-workflow'
1231
- ) {
1412
+ if (workflowMeta.source === 'graph') {
1232
1413
  const shouldInline =
1233
1414
  options?.inline || !getSingletonServices()?.queueService
1234
1415
  return runWorkflowGraph(
@@ -1270,17 +1451,12 @@ export abstract class PikkuWorkflowService implements WorkflowService {
1270
1451
  }
1271
1452
  )
1272
1453
 
1273
- const actors =
1274
- options?.actors ??
1275
- (workflowMeta.source === 'scenario'
1276
- ? await this.resolveScenarioActors()
1277
- : undefined)
1278
- if (actors) {
1279
- this.runActors.set(runId, actors)
1280
- }
1454
+ options?.onRunCreated?.(runId)
1455
+
1456
+ await this.runExtension?.attachRunContext(runId, workflowMeta, options)
1281
1457
 
1282
1458
  if (shouldInline) {
1283
- this.inlineRuns.add(runId)
1459
+ this.registerInlineRun(runId)
1284
1460
  try {
1285
1461
  await this.runWorkflowJob(runId, rpcService)
1286
1462
  } catch (error: any) {
@@ -1313,8 +1489,8 @@ export abstract class PikkuWorkflowService implements WorkflowService {
1313
1489
  throw error
1314
1490
  }
1315
1491
  } finally {
1316
- this.inlineRuns.delete(runId)
1317
- this.runActors.delete(runId)
1492
+ this.unregisterInlineRun(runId)
1493
+ this.runExtension?.detachRunContext(runId)
1318
1494
  }
1319
1495
  } else {
1320
1496
  await this.resumeWorkflow(runId)
@@ -1329,7 +1505,6 @@ export abstract class PikkuWorkflowService implements WorkflowService {
1329
1505
  rpcService: any,
1330
1506
  options?: { pollIntervalMs?: number; wire?: WorkflowRunWire }
1331
1507
  ): Promise<any> {
1332
- const pollInterval = options?.pollIntervalMs ?? 1000
1333
1508
  const { runId } = await this.startWorkflow(
1334
1509
  name,
1335
1510
  input,
@@ -1337,38 +1512,155 @@ export abstract class PikkuWorkflowService implements WorkflowService {
1337
1512
  rpcService,
1338
1513
  { inline: true }
1339
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)
1340
1539
  while (true) {
1341
1540
  const run = await this.getRun(runId)
1342
1541
  if (!run) {
1343
1542
  throw new WorkflowRunNotFoundError(runId)
1344
1543
  }
1345
1544
  if (WORKFLOW_END_STATES.has(run.status)) {
1346
- if (run.status === 'failed') {
1347
- throw new WorkflowRunFailedError(run.error?.message)
1348
- }
1349
- if (run.status === 'cancelled') {
1350
- throw new WorkflowRunCancelledError()
1351
- }
1352
- return run.output
1545
+ return run
1353
1546
  }
1354
- await new Promise((resolve) => setTimeout(resolve, pollInterval))
1547
+ await this.waitBeforeNextRead(interval)
1548
+ interval = Math.min(interval * WORKFLOW_POLL_FACTOR, maxIntervalMs)
1355
1549
  }
1356
1550
  }
1357
1551
 
1358
- // Per-run, per-replay ordinal counters (runId → stepName → count).
1359
- private stepOrdinals = new Map<string, Map<string, number>>()
1360
- // Previous step key reached in the current DSL walk (runId → stepName), so a
1361
- // new step records where it came from. Rebuilt each replay alongside ordinals.
1362
- 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
+ }
1363
1563
 
1364
- private resetStepOrdinals(runId: string): void {
1365
- this.stepOrdinals.set(runId, new Map())
1366
- this.stepLineage.delete(runId)
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
+ }
1622
+
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
1367
1659
  }
1368
1660
 
1369
1661
  /** The step the DSL walk last reached (the predecessor for the next step). */
1370
1662
  private lastStepName(runId: string): string | undefined {
1371
- return this.stepLineage.get(runId)
1663
+ return this.runContexts.get(runId)?.replay?.lastStep
1372
1664
  }
1373
1665
 
1374
1666
  /**
@@ -1378,26 +1670,26 @@ export abstract class PikkuWorkflowService implements WorkflowService {
1378
1670
  * the rows clobbering. Deterministic given a deterministic DSL body.
1379
1671
  */
1380
1672
  private nextStepKey(runId: string, logicalStepName: string): string {
1381
- let perRun = this.stepOrdinals.get(runId)
1382
- if (!perRun) {
1383
- perRun = new Map()
1384
- this.stepOrdinals.set(runId, perRun)
1385
- }
1386
- const ordinal = perRun.get(logicalStepName) ?? 0
1387
- 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)
1388
1679
  const stepName =
1389
1680
  ordinal === 0 ? logicalStepName : `${logicalStepName}#${ordinal}`
1390
- this.stepLineage.set(runId, stepName)
1681
+ replay.lastStep = stepName
1391
1682
  return stepName
1392
1683
  }
1393
1684
 
1394
1685
  public async runWorkflowJob(runId: string, rpcService: any): Promise<void> {
1395
- // Fresh ordinal counters per replay so step keys are deterministic.
1396
- 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)
1397
1689
  try {
1398
1690
  await this.runWorkflowJobInner(runId, rpcService)
1399
1691
  } finally {
1400
- this.stepOrdinals.delete(runId)
1692
+ this.endReplay(runId)
1401
1693
  }
1402
1694
  }
1403
1695
 
@@ -1405,7 +1697,9 @@ export abstract class PikkuWorkflowService implements WorkflowService {
1405
1697
  runId: string,
1406
1698
  rpcService: any
1407
1699
  ): Promise<void> {
1408
- 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)
1409
1703
  if (!run) {
1410
1704
  throw new WorkflowRunNotFoundError(runId)
1411
1705
  }
@@ -1423,10 +1717,7 @@ export abstract class PikkuWorkflowService implements WorkflowService {
1423
1717
  return
1424
1718
  }
1425
1719
 
1426
- if (
1427
- workflowMeta?.source === 'graph' ||
1428
- workflowMeta?.source === 'dynamic-workflow'
1429
- ) {
1720
+ if (workflowMeta?.source === 'graph') {
1430
1721
  await continueGraph(this, runId, run.workflow)
1431
1722
  const updatedRun = await this.getRun(runId)
1432
1723
  if (updatedRun?.status === 'completed') {
@@ -1443,29 +1734,6 @@ export abstract class PikkuWorkflowService implements WorkflowService {
1443
1734
  return
1444
1735
  }
1445
1736
 
1446
- if (!workflowMeta) {
1447
- const dynamicWorkflows = await this.getAIGeneratedWorkflows()
1448
- const match = dynamicWorkflows.find(
1449
- (w) => w.workflowName === run.workflow
1450
- )
1451
- if (match?.graph) {
1452
- await continueGraph(this, runId, run.workflow, match.graph)
1453
- const updatedRun = await this.getRun(runId)
1454
- if (updatedRun?.status === 'completed') {
1455
- await this.onChildWorkflowCompleted(updatedRun, updatedRun.output)
1456
- } else if (
1457
- updatedRun?.status === 'failed' ||
1458
- updatedRun?.status === 'cancelled'
1459
- ) {
1460
- await this.onChildWorkflowFailed(
1461
- updatedRun,
1462
- new Error(updatedRun.error?.message || 'Child workflow failed')
1463
- )
1464
- }
1465
- return
1466
- }
1467
- }
1468
-
1469
1737
  const registrations = pikkuState(pkgName, 'workflows', 'registrations')
1470
1738
  const workflow = registrations.get(resolved?.resolvedName ?? run.workflow)
1471
1739
  if (!workflow) {
@@ -1485,15 +1753,33 @@ export abstract class PikkuWorkflowService implements WorkflowService {
1485
1753
  workflowWire.pikkuUserId = run.wire?.pikkuUserId
1486
1754
  const wire: PikkuWire = {
1487
1755
  workflow: workflowWire,
1488
- scenario:
1489
- workflowMeta?.source === 'scenario' ? workflowWire : undefined,
1490
1756
  pikkuUserId: run.wire?.pikkuUserId,
1491
1757
  session: rpcService?.wire?.session,
1492
1758
  rpc: rpcService?.wire?.rpc,
1493
- // User-flow actors registered for this run (see startWorkflow options)
1494
- actors: this.runActors.get(runId),
1495
1759
  }
1760
+ this.runExtension?.decorateRunWire(wire, {
1761
+ runId,
1762
+ workflowMeta,
1763
+ workflowWire,
1764
+ })
1765
+
1766
+ const lifecycle: RunLifecycleContext = {
1767
+ runId,
1768
+ run,
1769
+ workflowMeta,
1770
+ workflow,
1771
+ wire,
1772
+ packageName: pkgName,
1773
+ }
1774
+
1775
+ // `interrupted` means the run has not reached a terminal state — it is
1776
+ // suspended or waiting — so teardown would run while the run is still
1777
+ // mid-flight.
1778
+ let outcome: 'completed' | 'failed' | 'interrupted' = 'completed'
1779
+ let failure: any
1496
1780
  try {
1781
+ await this.runExtension?.onBeforeRunFunc(lifecycle)
1782
+
1497
1783
  const result = await runPikkuFunc(
1498
1784
  'workflow',
1499
1785
  workflowMeta.name,
@@ -1510,11 +1796,15 @@ export abstract class PikkuWorkflowService implements WorkflowService {
1510
1796
  await this.updateRunStatus(runId, 'completed', result)
1511
1797
  await this.onChildWorkflowCompleted(run, result)
1512
1798
  } catch (error: any) {
1799
+ failure = error
1800
+
1513
1801
  if (error instanceof WorkflowAsyncException) {
1802
+ outcome = 'interrupted'
1514
1803
  throw error
1515
1804
  }
1516
1805
 
1517
1806
  if (error instanceof WorkflowCancelledException) {
1807
+ outcome = 'failed'
1518
1808
  await this.updateRunStatus(runId, 'cancelled', undefined, {
1519
1809
  message: error.message || 'Workflow cancelled',
1520
1810
  stack: '',
@@ -1525,6 +1815,7 @@ export abstract class PikkuWorkflowService implements WorkflowService {
1525
1815
  }
1526
1816
 
1527
1817
  if (error instanceof WorkflowSuspendedException) {
1818
+ outcome = 'interrupted'
1528
1819
  await this.updateRunStatus(runId, 'suspended', undefined, {
1529
1820
  message: error.message || 'Workflow suspended',
1530
1821
  stack: '',
@@ -1533,6 +1824,7 @@ export abstract class PikkuWorkflowService implements WorkflowService {
1533
1824
  throw error
1534
1825
  }
1535
1826
 
1827
+ outcome = 'failed'
1536
1828
  await this.updateRunStatus(runId, 'failed', undefined, {
1537
1829
  message: error.message,
1538
1830
  stack: error.stack,
@@ -1541,6 +1833,8 @@ export abstract class PikkuWorkflowService implements WorkflowService {
1541
1833
  await this.onChildWorkflowFailed(run, error)
1542
1834
 
1543
1835
  throw error
1836
+ } finally {
1837
+ await this.runExtension?.onAfterRunFunc(lifecycle, outcome, failure)
1544
1838
  }
1545
1839
  })
1546
1840
  }
@@ -1559,7 +1853,7 @@ export abstract class PikkuWorkflowService implements WorkflowService {
1559
1853
  await this.resumeWorkflow(parentRunId)
1560
1854
  }
1561
1855
 
1562
- private async onChildWorkflowFailed(
1856
+ protected async onChildWorkflowFailed(
1563
1857
  childRun: WorkflowRun,
1564
1858
  error: Error
1565
1859
  ): Promise<void> {
@@ -1652,9 +1946,7 @@ export abstract class PikkuWorkflowService implements WorkflowService {
1652
1946
  const meta = pikkuState(null, 'workflows', 'meta')
1653
1947
  const workflowMeta = meta[run.workflow]
1654
1948
 
1655
- const isGraphWorkflow =
1656
- workflowMeta?.source === 'graph' ||
1657
- workflowMeta?.source === 'dynamic-workflow'
1949
+ const isGraphWorkflow = workflowMeta?.source === 'graph'
1658
1950
  // Map the physical step key back to its logical node: a revisit instance
1659
1951
  // is `node#N` (ordinal), which isn't a literal key in `nodes`.
1660
1952
  let graphNodeId: string | undefined
@@ -1721,7 +2013,8 @@ export abstract class PikkuWorkflowService implements WorkflowService {
1721
2013
  stepState,
1722
2014
  rpcName,
1723
2015
  data,
1724
- rpcService
2016
+ rpcService,
2017
+ run
1725
2018
  )
1726
2019
  }
1727
2020
  }
@@ -1826,11 +2119,12 @@ export abstract class PikkuWorkflowService implements WorkflowService {
1826
2119
  stepState: StepState,
1827
2120
  rpcName: string,
1828
2121
  data: any,
1829
- rpcService: any
2122
+ rpcService: any,
2123
+ knownRun?: WorkflowRun | null
1830
2124
  ): Promise<any> {
1831
2125
  // Carry the run's pikkuUserId onto the step wire so authed steps rehydrate their
1832
2126
  // session on the queued path too (the bare job wire lacks it; inline already has it).
1833
- const run = await this.getRun(runId)
2127
+ const run = knownRun ?? (await this.getRunIdentity(runId))
1834
2128
  return rpcService.rpcWithWire(rpcName, data, {
1835
2129
  ...(run?.wire?.pikkuUserId ? { pikkuUserId: run.wire.pikkuUserId } : {}),
1836
2130
  workflowStep: {
@@ -1940,13 +2234,9 @@ export abstract class PikkuWorkflowService implements WorkflowService {
1940
2234
  actor: stepOptions?.actor,
1941
2235
  onError: stepOptions?.onError,
1942
2236
  }
1943
- // Check if step already exists
1944
- let stepState: StepState
1945
- try {
1946
- stepState = await this.getStepState(runId, stepName)
1947
- } catch {
1948
- // Step doesn't exist - create it
1949
- 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(
1950
2240
  runId,
1951
2241
  stepName,
1952
2242
  rpcName,
@@ -1954,7 +2244,7 @@ export abstract class PikkuWorkflowService implements WorkflowService {
1954
2244
  resolvedStepOptions,
1955
2245
  fromStepName
1956
2246
  )
1957
- }
2247
+ )
1958
2248
 
1959
2249
  if (stepState.status === 'succeeded') {
1960
2250
  // Return cached result
@@ -2048,24 +2338,17 @@ export abstract class PikkuWorkflowService implements WorkflowService {
2048
2338
  )
2049
2339
  await this.setStepChildRunId(currentStepState.stepId, childRunId)
2050
2340
  // Poll until child workflow completes
2051
- while (true) {
2052
- const childRun = await this.getRun(childRunId)
2053
- if (!childRun) {
2054
- throw new WorkflowRunNotFoundError(childRunId)
2055
- }
2056
- if (WORKFLOW_END_STATES.has(childRun.status)) {
2057
- if (childRun.status === 'failed') {
2058
- throw new Error(
2059
- childRun.error?.message || 'Sub-workflow failed'
2060
- )
2061
- }
2062
- if (childRun.status === 'cancelled') {
2063
- throw new Error('Sub-workflow was cancelled')
2064
- }
2065
- return childRun.output
2066
- }
2067
- 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')
2068
2347
  }
2348
+ if (childRun.status === 'cancelled') {
2349
+ throw new Error('Sub-workflow was cancelled')
2350
+ }
2351
+ return childRun.output
2069
2352
  }
2070
2353
  return this.invokeStepRpc(
2071
2354
  runId,
@@ -2088,29 +2371,40 @@ export abstract class PikkuWorkflowService implements WorkflowService {
2088
2371
  )
2089
2372
  }
2090
2373
 
2091
- private async inlineStep(
2374
+ protected async inlineStep(
2092
2375
  runId: string,
2093
2376
  logicalStepName: string,
2094
2377
  fn: Function,
2095
- stepOptions?: WorkflowStepOptions
2378
+ stepOptions?: WorkflowStepOptions,
2379
+ /**
2380
+ * The input this step was called with, recorded on the run so a reporter can
2381
+ * name the values under test. A closure step has none; a scenario step does.
2382
+ */
2383
+ data: any = null,
2384
+ /**
2385
+ * The name this step was dispatched by, for the kinds of inline step that
2386
+ * have one. A closure step has no name; a scenario step is a step RPC, so
2387
+ * it records the step function that ran — which is the only way to join a
2388
+ * step back to its declaration when its durable name was built at runtime
2389
+ * (a step called in a loop reaches the run as `sees @pikku/addon-todos`,
2390
+ * declared as `sees ${packageName}`).
2391
+ */
2392
+ rpcName: string | null = null
2096
2393
  ): Promise<any> {
2097
2394
  const fromStepName = this.lastStepName(runId)
2098
2395
  const stepName = this.nextStepKey(runId, logicalStepName)
2099
- // Check if step already exists
2100
- let stepState: StepState
2101
- try {
2102
- stepState = await this.getStepState(runId, stepName)
2103
- } catch {
2104
- // Step doesn't exist - create it (inline, no RPC)
2105
- 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(
2106
2400
  runId,
2107
2401
  stepName,
2108
- null,
2109
- null,
2402
+ rpcName,
2403
+ data,
2110
2404
  stepOptions,
2111
2405
  fromStepName
2112
2406
  )
2113
- }
2407
+ )
2114
2408
 
2115
2409
  if (stepState.status === 'succeeded') {
2116
2410
  // Return cached result
@@ -2162,13 +2456,10 @@ export abstract class PikkuWorkflowService implements WorkflowService {
2162
2456
  ) {
2163
2457
  const fromStepName = this.lastStepName(runId)
2164
2458
  const stepName = this.nextStepKey(runId, logicalStepName)
2165
- // Check if step already exists
2166
- let stepState: StepState
2167
- try {
2168
- stepState = await this.getStepState(runId, stepName)
2169
- } catch {
2170
- // Step doesn't exist - create it (sleep step, no RPC)
2171
- 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(
2172
2463
  runId,
2173
2464
  stepName,
2174
2465
  null,
@@ -2176,7 +2467,7 @@ export abstract class PikkuWorkflowService implements WorkflowService {
2176
2467
  undefined,
2177
2468
  fromStepName
2178
2469
  )
2179
- }
2470
+ )
2180
2471
 
2181
2472
  if (stepState.status === 'succeeded') {
2182
2473
  // Sleep already completed, return immediately
@@ -2494,8 +2785,8 @@ export abstract class PikkuWorkflowService implements WorkflowService {
2494
2785
  runId: string,
2495
2786
  rpcService: any,
2496
2787
  addonNamespace?: string | null
2497
- ): PikkuScenarioWire {
2498
- const workflowWire: PikkuScenarioWire = {
2788
+ ): PikkuWorkflowWire {
2789
+ const workflowWire: PikkuWorkflowWire = {
2499
2790
  name,
2500
2791
  runId,
2501
2792
  getRun: async () => (await this.getRun(runId)) as WorkflowRun,
@@ -2531,146 +2822,6 @@ export abstract class PikkuWorkflowService implements WorkflowService {
2531
2822
  }
2532
2823
  },
2533
2824
 
2534
- // Durable polling step: invoke an RPC (as an actor when options.as is
2535
- // set) until the predicate passes or `within` elapses. The whole poll is
2536
- // ONE recorded step, so replay returns the cached outcome.
2537
- expectEventually: async (
2538
- stepName: string,
2539
- rpcName: string,
2540
- data: any,
2541
- predicate: (output: any) => boolean,
2542
- options?: WorkflowExpectEventuallyOptions
2543
- ) => {
2544
- this.verifyStepName(stepName)
2545
- const resolvedRpcName =
2546
- addonNamespace && !rpcName.includes(':')
2547
- ? `${addonNamespace}:${rpcName}`
2548
- : rpcName
2549
- const within = getDurationInMilliseconds(options?.within ?? '30s')
2550
- const interval = getDurationInMilliseconds(options?.interval ?? '1s')
2551
- return await this.inlineStep(
2552
- runId,
2553
- stepName,
2554
- async () => {
2555
- const deadline = Date.now() + within
2556
- let last: any
2557
- while (true) {
2558
- last = options?.actor
2559
- ? await options.actor.invoke(resolvedRpcName, data)
2560
- : await rpcService.rpcWithWire(resolvedRpcName, data, {})
2561
- if (predicate(last)) return last
2562
- if (Date.now() + interval > deadline) {
2563
- throw new Error(
2564
- `[workflow] expectEventually '${stepName}' ('${resolvedRpcName}'` +
2565
- `${options?.actor ? ` as '${options.actor.name}'` : ''}) did not pass within ${within}ms; ` +
2566
- `last result: ${JSON.stringify(last)?.slice(0, 300)}`
2567
- )
2568
- }
2569
- await new Promise((resolve) => setTimeout(resolve, interval))
2570
- }
2571
- },
2572
- options
2573
- )
2574
- },
2575
-
2576
- expectError: async (
2577
- stepName: string,
2578
- rpcName: string,
2579
- data: any,
2580
- options?: WorkflowExpectErrorOptions
2581
- ) => {
2582
- this.verifyStepName(stepName)
2583
- const resolvedRpcName =
2584
- addonNamespace && !rpcName.includes(':')
2585
- ? `${addonNamespace}:${rpcName}`
2586
- : rpcName
2587
- return await this.inlineStep(
2588
- runId,
2589
- stepName,
2590
- async () => {
2591
- let result: any
2592
- try {
2593
- result = options?.actor
2594
- ? await options.actor.invoke(resolvedRpcName, data)
2595
- : await rpcService.rpcWithWire(resolvedRpcName, data, {})
2596
- } catch (e: any) {
2597
- const message = e?.message ?? String(e)
2598
- if (options?.matches) {
2599
- const matched =
2600
- typeof options.matches === 'string'
2601
- ? message.includes(options.matches)
2602
- : options.matches.test(message)
2603
- if (!matched) {
2604
- throw new Error(
2605
- `[workflow] expectError '${stepName}' ('${resolvedRpcName}') threw, but the message did not match ${options.matches}: ${message}`
2606
- )
2607
- }
2608
- }
2609
- return message
2610
- }
2611
- throw new Error(
2612
- `[workflow] expectError '${stepName}' ('${resolvedRpcName}') expected an error but the call succeeded: ${JSON.stringify(result)?.slice(0, 300)}`
2613
- )
2614
- },
2615
- options
2616
- )
2617
- },
2618
-
2619
- expectService: async (
2620
- stepName: string,
2621
- serviceMethod: string,
2622
- options?: WorkflowExpectServiceOptions
2623
- ) => {
2624
- this.verifyStepName(stepName)
2625
- const [service, method] = serviceMethod.split('.')
2626
- if (!service || !method) {
2627
- throw new Error(
2628
- `[workflow] expectService '${stepName}' needs 'service.method', got '${serviceMethod}'`
2629
- )
2630
- }
2631
- await this.inlineStep(
2632
- runId,
2633
- stepName,
2634
- async () => {
2635
- const rpcName = 'pikkuScenarioGetStubCalls'
2636
- const calls: Array<{
2637
- service: string
2638
- method: string
2639
- args: unknown[]
2640
- }> = options?.actor
2641
- ? await options.actor.invoke(rpcName, { service })
2642
- : await rpcService.rpcWithWire(rpcName, { service }, {})
2643
- const matching = (calls ?? []).filter(
2644
- (c) =>
2645
- c.service === service &&
2646
- c.method === method &&
2647
- (options?.calledWith === undefined ||
2648
- JSON.stringify(c.args?.[0]) ===
2649
- JSON.stringify(options.calledWith))
2650
- )
2651
- const expected = options?.times
2652
- const ok =
2653
- expected === undefined
2654
- ? matching.length > 0
2655
- : matching.length === expected
2656
- if (!ok) {
2657
- const seen =
2658
- (calls ?? [])
2659
- .map(
2660
- (c) =>
2661
- `${c.service}.${c.method}(${JSON.stringify(c.args?.[0])?.slice(0, 120) ?? ''})`
2662
- )
2663
- .join('\n ') || '(none)'
2664
- throw new Error(
2665
- `[workflow] expectService '${stepName}' expected ${expected ?? 'at least one'} call(s) to '${serviceMethod}'` +
2666
- `${options?.calledWith !== undefined ? ` with ${JSON.stringify(options.calledWith)}` : ''}, found ${matching.length}. Recorded:\n ${seen}`
2667
- )
2668
- }
2669
- },
2670
- options
2671
- )
2672
- },
2673
-
2674
2825
  // Implement workflow.sleep()
2675
2826
  sleep: async (stepName: string, duration: string | number) => {
2676
2827
  this.verifyStepName(stepName)
@@ -2689,16 +2840,18 @@ export abstract class PikkuWorkflowService implements WorkflowService {
2689
2840
  approval: (async (reason: string, options: WorkflowApprovalOptions) => {
2690
2841
  this.verifyStepName(reason)
2691
2842
  return await this.approvalStep(runId, reason, options)
2692
- }) as PikkuScenarioWire['approval'],
2693
-
2694
- runScheduledTask: async (taskName: string) => {
2695
- await runScheduledTask({ name: taskName })
2696
- },
2843
+ }) as PikkuWorkflowWire['approval'],
2697
2844
  }
2845
+ this.runExtension?.decorateWorkflowWire(workflowWire, {
2846
+ name,
2847
+ runId,
2848
+ rpcService,
2849
+ addonNamespace,
2850
+ })
2698
2851
  return workflowWire
2699
2852
  }
2700
2853
 
2701
- private verifyStepName(stepName: string) {
2854
+ protected verifyStepName(stepName: string) {
2702
2855
  if (typeof stepName !== 'string') {
2703
2856
  throw new WorkflowStepNameNotString(stepName)
2704
2857
  }