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