@pikku/core 0.12.67 → 0.12.70

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 (96) hide show
  1. package/CHANGELOG.md +397 -0
  2. package/README.md +34 -2
  3. package/dist/function/functions.types.d.ts +27 -0
  4. package/dist/index.d.ts +1 -1
  5. package/dist/internal.d.ts +1 -1
  6. package/dist/internal.js +1 -1
  7. package/dist/pikku-state.js +1 -0
  8. package/dist/services/http-scenario-actors.d.ts +12 -4
  9. package/dist/services/http-scenario-actors.js +47 -45
  10. package/dist/services/in-memory-workflow-service.d.ts +2 -2
  11. package/dist/services/in-memory-workflow-service.js +2 -2
  12. package/dist/services/index.d.ts +2 -1
  13. package/dist/services/index.js +1 -0
  14. package/dist/services/meta-service.d.ts +5 -1
  15. package/dist/services/meta-service.js +44 -18
  16. package/dist/services/scenario-actors-service.d.ts +108 -2
  17. package/dist/services/scenario-actors-service.js +40 -1
  18. package/dist/types/core.types.d.ts +21 -3
  19. package/dist/types/state.types.d.ts +3 -1
  20. package/dist/wirings/actor-flow/actor-flow.types.d.ts +1 -1
  21. package/dist/wirings/actor-flow/index.d.ts +1 -1
  22. package/dist/wirings/actor-flow/run-conversation.d.ts +10 -10
  23. package/dist/wirings/actor-flow/run-conversation.js +27 -27
  24. package/dist/wirings/ai-agent/ai-agent-prepare.d.ts +18 -1
  25. package/dist/wirings/ai-agent/ai-agent-prepare.js +26 -4
  26. package/dist/wirings/cli/command-parser.js +11 -1
  27. package/dist/wirings/queue/index.d.ts +1 -1
  28. package/dist/wirings/queue/queue.types.d.ts +30 -0
  29. package/dist/wirings/rpc/rpc-runner.js +1 -1
  30. package/dist/wirings/workflow/dsl/workflow-dsl.types.d.ts +52 -3
  31. package/dist/wirings/workflow/feature.d.ts +28 -0
  32. package/dist/wirings/workflow/feature.js +57 -0
  33. package/dist/wirings/workflow/index.d.ts +13 -2
  34. package/dist/wirings/workflow/index.js +15 -0
  35. package/dist/wirings/workflow/pikku-scenario-service.d.ts +121 -0
  36. package/dist/wirings/workflow/pikku-scenario-service.js +419 -0
  37. package/dist/wirings/workflow/pikku-workflow-service.d.ts +118 -12
  38. package/dist/wirings/workflow/pikku-workflow-service.js +166 -153
  39. package/dist/wirings/workflow/scenario-cookie-jar.d.ts +29 -0
  40. package/dist/wirings/workflow/scenario-cookie-jar.js +51 -0
  41. package/dist/wirings/workflow/scenario-poll.d.ts +20 -0
  42. package/dist/wirings/workflow/scenario-poll.js +25 -0
  43. package/dist/wirings/workflow/scenario-prose.d.ts +38 -0
  44. package/dist/wirings/workflow/scenario-prose.js +45 -0
  45. package/dist/wirings/workflow/scenario-step-guards.d.ts +16 -0
  46. package/dist/wirings/workflow/scenario-step-guards.js +29 -0
  47. package/dist/wirings/workflow/scenario-step.types.d.ts +148 -0
  48. package/dist/wirings/workflow/scenario-step.types.js +1 -0
  49. package/dist/wirings/workflow/workflow.types.d.ts +119 -2
  50. package/package.json +3 -1
  51. package/src/function/functions.types.ts +32 -0
  52. package/src/index.ts +1 -0
  53. package/src/internal.ts +5 -1
  54. package/src/pikku-state.ts +1 -0
  55. package/src/services/http-scenario-actors.test.ts +85 -1
  56. package/src/services/http-scenario-actors.ts +65 -51
  57. package/src/services/in-memory-workflow-service.test.ts +50 -1
  58. package/src/services/in-memory-workflow-service.ts +3 -2
  59. package/src/services/index.ts +5 -0
  60. package/src/services/meta-service.test.ts +79 -0
  61. package/src/services/meta-service.ts +61 -26
  62. package/src/services/scenario-actors-service.ts +157 -2
  63. package/src/types/core.types.ts +27 -2
  64. package/src/types/state.types.ts +3 -0
  65. package/src/wirings/actor-flow/actor-flow.types.ts +1 -1
  66. package/src/wirings/actor-flow/index.ts +1 -1
  67. package/src/wirings/actor-flow/run-conversation.test.ts +12 -6
  68. package/src/wirings/actor-flow/run-conversation.ts +36 -41
  69. package/src/wirings/ai-agent/ai-agent-prepare.test.ts +29 -0
  70. package/src/wirings/ai-agent/ai-agent-prepare.ts +38 -4
  71. package/src/wirings/cli/command-parser.test.ts +60 -0
  72. package/src/wirings/cli/command-parser.ts +12 -1
  73. package/src/wirings/queue/index.ts +2 -0
  74. package/src/wirings/queue/queue.types.ts +32 -0
  75. package/src/wirings/rpc/rpc-runner.test.ts +28 -5
  76. package/src/wirings/rpc/rpc-runner.ts +1 -1
  77. package/src/wirings/workflow/dsl/workflow-dsl.types.ts +86 -2
  78. package/src/wirings/workflow/feature.test.ts +131 -0
  79. package/src/wirings/workflow/feature.ts +78 -0
  80. package/src/wirings/workflow/index.ts +74 -0
  81. package/src/wirings/workflow/pikku-scenario-service.ts +682 -0
  82. package/src/wirings/workflow/pikku-workflow-service.test.ts +126 -0
  83. package/src/wirings/workflow/pikku-workflow-service.ts +306 -228
  84. package/src/wirings/workflow/scenario-cookie-jar.test.ts +108 -0
  85. package/src/wirings/workflow/scenario-cookie-jar.ts +65 -0
  86. package/src/wirings/workflow/scenario-hooks.test.ts +212 -0
  87. package/src/wirings/workflow/scenario-poll.test.ts +66 -0
  88. package/src/wirings/workflow/scenario-poll.ts +36 -0
  89. package/src/wirings/workflow/scenario-prose.test.ts +152 -0
  90. package/src/wirings/workflow/scenario-prose.ts +79 -0
  91. package/src/wirings/workflow/scenario-service.test.ts +155 -0
  92. package/src/wirings/workflow/scenario-step-guards.ts +43 -0
  93. package/src/wirings/workflow/scenario-step.test.ts +441 -8
  94. package/src/wirings/workflow/scenario-step.types.ts +157 -0
  95. package/src/wirings/workflow/workflow.types.ts +137 -1
  96. 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,
@@ -60,11 +59,9 @@ import type {
60
59
  WorkflowRunWire,
61
60
  WorkflowStatus,
62
61
  WorkflowVersionStatus,
62
+ WorkflowQueueOptions,
63
63
  WorkflowServiceConfig,
64
64
  WorkflowStepOptions,
65
- WorkflowExpectEventuallyOptions,
66
- WorkflowExpectErrorOptions,
67
- WorkflowExpectServiceOptions,
68
65
  } from './workflow.types.js'
69
66
  import {
70
67
  continueGraph,
@@ -88,7 +85,12 @@ import {
88
85
  type RunTimeline,
89
86
  type ReconstructedRunState,
90
87
  } from './run-timeline.js'
91
- import type { JobOptions } from '../queue/queue.types.js'
88
+ import type {
89
+ GroupConcurrencyConfig,
90
+ JobGroup,
91
+ JobOptions,
92
+ PikkuWorkerConfig,
93
+ } from '../queue/queue.types.js'
92
94
 
93
95
  /**
94
96
  * Default number of retries for a workflow step when none is specified. The
@@ -240,6 +242,101 @@ const WORKFLOW_END_STATES: ReadonlySet<string> = new Set([
240
242
  'suspended',
241
243
  ])
242
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
+
243
340
  /**
244
341
  * Abstract workflow state service
245
342
  * Implementations provide pluggable storage backends (SQLite, PostgreSQL, etc.)
@@ -247,9 +344,7 @@ const WORKFLOW_END_STATES: ReadonlySet<string> = new Set([
247
344
  */
248
345
  export abstract class PikkuWorkflowService implements WorkflowService {
249
346
  private inlineRuns = new Set<string>()
250
- // User-flow actors per run: live authenticated clients (cookie jars) are
251
- // process-local by nature, so they ride this map, never the persisted wire.
252
- private runActors = new Map<string, ScenarioActors>()
347
+ private runExtension?: WorkflowRunExtension
253
348
 
254
349
  protected get logger() {
255
350
  return getSingletonServices()?.logger
@@ -257,11 +352,21 @@ export abstract class PikkuWorkflowService implements WorkflowService {
257
352
 
258
353
  protected mirror?: WorkflowRunMirror
259
354
 
355
+ protected readonly queueStrategy: 'per-workflow' | 'shared-groups'
356
+ protected readonly queueConcurrency: number
357
+ protected readonly queueGroupConcurrency: number | GroupConcurrencyConfig
358
+
260
359
  constructor(
261
- options: { wireQueues?: boolean; mirror?: WorkflowRunMirror } = {}
360
+ options: {
361
+ wireQueues?: boolean
362
+ mirror?: WorkflowRunMirror
363
+ } & WorkflowQueueOptions = {}
262
364
  ) {
263
365
  const wireQueues = options.wireQueues ?? true
264
366
  this.mirror = options.mirror
367
+ this.queueStrategy = options.queueStrategy ?? 'per-workflow'
368
+ this.queueConcurrency = options.queueConcurrency ?? 20
369
+ this.queueGroupConcurrency = options.queueGroupConcurrency ?? 2
265
370
  if (wireQueues) {
266
371
  this.wireQueueWorkers()
267
372
  }
@@ -306,29 +411,44 @@ export abstract class PikkuWorkflowService implements WorkflowService {
306
411
  const registerWorkflowFunc = (
307
412
  funcId: string,
308
413
  func: { func: unknown },
309
- queueName: string
414
+ queueName: string,
415
+ config?: PikkuWorkerConfig
310
416
  ) => {
311
417
  if (functions.has(funcId)) return
312
418
  addFunction(funcId, func as never)
313
419
  if (!queueMeta[queueName]) {
314
420
  queueMeta[queueName] = { pikkuFuncId: funcId, name: queueName }
315
421
  }
316
- wireQueueWorker({ name: queueName, func } as never)
422
+ wireQueueWorker({ name: queueName, func, config } as never)
317
423
  if (!functionsMeta[funcId]) {
318
424
  functionsMeta[funcId] = mkMeta(funcId)
319
425
  }
320
426
  }
321
427
 
428
+ // Under 'shared-groups' every workflow runs through these two queues and is
429
+ // kept from hogging them by the per-group cap, so the per-workflow queues
430
+ // below are left unconsumed — one set of pollers for the whole system
431
+ // instead of one per workflow.
432
+ const sharedGroups = this.queueStrategy === 'shared-groups'
433
+ const sharedQueueConfig: PikkuWorkerConfig | undefined = sharedGroups
434
+ ? {
435
+ batchSize: this.queueConcurrency,
436
+ groupConcurrency: this.queueGroupConcurrency,
437
+ }
438
+ : undefined
439
+
322
440
  // Register shared queue workers for monolith deployments
323
441
  registerWorkflowFunc(
324
442
  'pikkuWorkflowOrchestrator',
325
443
  { func: pikkuWorkflowOrchestratorFunc },
326
- 'pikku-workflow-orchestrator'
444
+ 'pikku-workflow-orchestrator',
445
+ sharedQueueConfig
327
446
  )
328
447
  registerWorkflowFunc(
329
448
  'pikkuWorkflowStepWorker',
330
449
  { func: pikkuWorkflowWorkerFunc },
331
- 'pikku-workflow-step-worker'
450
+ 'pikku-workflow-step-worker',
451
+ sharedQueueConfig
332
452
  )
333
453
 
334
454
  // Register per-workflow queue workers (root + addon packages)
@@ -351,18 +471,41 @@ export abstract class PikkuWorkflowService implements WorkflowService {
351
471
  }
352
472
  }
353
473
 
354
- registerQueueWorkers(pikkuState(null, 'queue', 'meta'))
474
+ if (!sharedGroups) {
475
+ registerQueueWorkers(pikkuState(null, 'queue', 'meta'))
355
476
 
356
- const addons = pikkuState(null, 'addons', 'packages')
357
- if (addons) {
358
- for (const [, addon] of addons) {
359
- const addonQueueMeta = pikkuState(addon.package, 'queue', 'meta')
360
- if (addonQueueMeta) {
361
- registerQueueWorkers(addonQueueMeta)
477
+ const addons = pikkuState(null, 'addons', 'packages')
478
+ if (addons) {
479
+ for (const [, addon] of addons) {
480
+ const addonQueueMeta = pikkuState(addon.package, 'queue', 'meta')
481
+ if (addonQueueMeta) {
482
+ registerQueueWorkers(addonQueueMeta)
483
+ }
362
484
  }
363
485
  }
364
486
  }
365
487
 
488
+ // Workflows exist but no per-workflow orchestrator queue was registered:
489
+ // the generated queue meta never reached the runtime (most often the
490
+ // bootstrap doesn't import the queue-workers meta, so `queue.meta` is
491
+ // empty). Everything still "works" — dispatch silently falls back to the
492
+ // single shared orchestrator queue — but the isolation is gone: one slow
493
+ // workflow step head-of-line-blocks every other workflow behind it. That
494
+ // is invisible until a queue starves, so say so loudly at wiring time.
495
+ const workflowCount = Object.keys(
496
+ pikkuState(null, 'workflows', 'meta') ?? {}
497
+ ).length
498
+ const perWorkflowQueues = Object.keys(queueMeta).filter((name) =>
499
+ name.startsWith('wf-orchestrator-')
500
+ ).length
501
+ if (workflowCount > 0 && perWorkflowQueues === 0) {
502
+ this.logger?.warn?.(
503
+ `[pikku] ${workflowCount} workflow(s) registered but no per-workflow orchestrator queues were found in queue meta. ` +
504
+ `All workflows will share a single orchestrator queue, where one slow step blocks every other workflow behind it. ` +
505
+ `Check that the generated bootstrap imports the queue-workers meta (pikku-queue-workers-wirings-meta.gen.js).`
506
+ )
507
+ }
508
+
366
509
  if (!functions.has('pikkuWorkflowSleeper')) {
367
510
  addFunction('pikkuWorkflowSleeper', {
368
511
  func: pikkuWorkflowSleeperFunc,
@@ -577,7 +720,10 @@ export abstract class PikkuWorkflowService implements WorkflowService {
577
720
  * Creates pending step in both workflow_step and workflow_step_history
578
721
  * @param runId - Run ID
579
722
  * @param stepName - Step cache key
580
- * @param rpcName - RPC function name
723
+ * @param rpcName - The name this step was dispatched by: an RPC for a
724
+ * `workflow.do` step, a step function for a scenario step, null for a
725
+ * closure. Nothing dispatches off this value — it is recorded so a reader
726
+ * can join a step back to the function that ran it.
581
727
  * @param data - Step input data
582
728
  * @param stepOptions - Step options (retries, retryDelay)
583
729
  * @returns Step state with generated stepId
@@ -927,7 +1073,10 @@ export abstract class PikkuWorkflowService implements WorkflowService {
927
1073
  await queueService.add(
928
1074
  this.getOrchestratorQueueName(workflowName),
929
1075
  { runId },
930
- this.resolveStepJobOptions()
1076
+ {
1077
+ ...this.resolveStepJobOptions(),
1078
+ group: this.getJobGroup(workflowName),
1079
+ }
931
1080
  )
932
1081
  }
933
1082
 
@@ -970,7 +1119,12 @@ export abstract class PikkuWorkflowService implements WorkflowService {
970
1119
  JSON.parse(
971
1120
  JSON.stringify({ runId, stepName, rpcName, data, fromStepName })
972
1121
  ),
973
- this.resolveStepJobOptions(stepOptions)
1122
+ {
1123
+ ...this.resolveStepJobOptions(stepOptions),
1124
+ // Group by step function, mirroring how per-step queues split them —
1125
+ // one slow step function can't monopolise the shared step worker.
1126
+ group: this.getJobGroup(rpcName),
1127
+ }
974
1128
  )
975
1129
  }
976
1130
 
@@ -1005,7 +1159,12 @@ export abstract class PikkuWorkflowService implements WorkflowService {
1005
1159
  await queueService.add(
1006
1160
  this.getOrchestratorQueueName(workflowName),
1007
1161
  { runId },
1008
- retryDelay ? { delay: getDurationInMilliseconds(retryDelay) } : undefined
1162
+ {
1163
+ ...(retryDelay
1164
+ ? { delay: getDurationInMilliseconds(retryDelay) }
1165
+ : undefined),
1166
+ group: this.getJobGroup(workflowName),
1167
+ }
1009
1168
  )
1010
1169
  }
1011
1170
 
@@ -1054,7 +1213,10 @@ export abstract class PikkuWorkflowService implements WorkflowService {
1054
1213
  JSON.parse(
1055
1214
  JSON.stringify({ runId, stepName, rpcName, data, fromStepName })
1056
1215
  ),
1057
- this.resolveStepJobOptions(stepOptions)
1216
+ {
1217
+ ...this.resolveStepJobOptions(stepOptions),
1218
+ group: this.getJobGroup(rpcName),
1219
+ }
1058
1220
  )
1059
1221
  } catch (cause) {
1060
1222
  // The queue is down/unreachable — NOT a step failure. Surface it as a
@@ -1092,37 +1254,28 @@ export abstract class PikkuWorkflowService implements WorkflowService {
1092
1254
  return true
1093
1255
  }
1094
1256
 
1095
- /** Build HTTP scenario actors for a run started without them; undefined when SCENARIO_ACTOR_SECRET or the API URL is missing */
1096
- private async resolveScenarioActors(): Promise<ScenarioActors | undefined> {
1097
- const services = getSingletonServices()
1098
- const variables = services?.variables
1099
- const metaService = services?.metaService
1100
- if (!variables || !metaService) {
1101
- return undefined
1102
- }
1103
- const secret = await variables.get('SCENARIO_ACTOR_SECRET')
1104
- const apiUrl = await variables.get('API_URL')
1105
- if (!secret || !apiUrl) {
1106
- services?.logger?.warn(
1107
- 'A scenario was started without actors but SCENARIO_ACTOR_SECRET / API_URL is not configured — running without actors.'
1108
- )
1109
- return undefined
1110
- }
1111
- const actorsConfig = await metaService.getScenarioActorsMeta()
1112
- if (!actorsConfig || Object.keys(actorsConfig).length === 0) {
1113
- return undefined
1257
+ /**
1258
+ * Install the one extension a run may have, built from a handle onto the run
1259
+ * engine so that `inlineStep` and friends stay protected rather than becoming
1260
+ * public API. Returns the extension, so the caller keeps a typed reference to
1261
+ * whatever it just built.
1262
+ */
1263
+ public setRunExtension<T extends WorkflowRunExtension>(
1264
+ create: (engine: WorkflowRunEngine) => T
1265
+ ): T {
1266
+ const engine: WorkflowRunEngine = {
1267
+ inlineStep: this.inlineStep.bind(this),
1268
+ updateRunStatus: this.updateRunStatus.bind(this),
1269
+ onChildWorkflowFailed: this.onChildWorkflowFailed.bind(this),
1270
+ verifyStepName: this.verifyStepName.bind(this),
1114
1271
  }
1115
- const signInPath =
1116
- (await variables.get('SCENARIO_SIGN_IN_PATH')) ??
1117
- '/api/auth/sign-in/actor'
1118
- const rpcPath = (await variables.get('SCENARIO_RPC_PATH')) ?? '/rpc'
1119
- return createHttpScenarioActors({
1120
- apiUrl,
1121
- secret,
1122
- actors: actorsConfig,
1123
- signInPath,
1124
- rpcPath,
1125
- })
1272
+ const extension = create(engine)
1273
+ this.runExtension = extension
1274
+ return extension
1275
+ }
1276
+
1277
+ public getRunExtension(): WorkflowRunExtension | undefined {
1278
+ return this.runExtension
1126
1279
  }
1127
1280
 
1128
1281
  /**
@@ -1130,13 +1283,21 @@ export abstract class PikkuWorkflowService implements WorkflowService {
1130
1283
  * Automatically detects workflow type (DSL or graph) from meta and executes accordingly
1131
1284
  * @param options.inline - If true, execute workflow directly without queue service
1132
1285
  * @param options.startNode - Starting node ID for graph workflows (from wire config)
1286
+ * @param options.onRunCreated - Called with the run id the moment the run exists.
1287
+ * An inline run that fails throws instead of returning, so this is the only
1288
+ * way a caller can still read that run back — its steps, and which one failed.
1133
1289
  */
1134
1290
  public async startWorkflow<I>(
1135
1291
  name: string,
1136
1292
  input: I,
1137
1293
  wire: WorkflowRunWire,
1138
1294
  rpcService: any,
1139
- options?: { inline?: boolean; startNode?: string; actors?: ScenarioActors }
1295
+ options?: {
1296
+ inline?: boolean
1297
+ startNode?: string
1298
+ actors?: ScenarioActors
1299
+ onRunCreated?: (runId: string) => void
1300
+ }
1140
1301
  ): Promise<{ runId: string }> {
1141
1302
  // Resolve workflow from static meta (root or addon namespace), then dynamic DB
1142
1303
  const resolved = resolveWorkflowMeta(name)
@@ -1200,14 +1361,9 @@ export abstract class PikkuWorkflowService implements WorkflowService {
1200
1361
  }
1201
1362
  )
1202
1363
 
1203
- const actors =
1204
- options?.actors ??
1205
- (workflowMeta.source === 'scenario'
1206
- ? await this.resolveScenarioActors()
1207
- : undefined)
1208
- if (actors) {
1209
- this.runActors.set(runId, actors)
1210
- }
1364
+ options?.onRunCreated?.(runId)
1365
+
1366
+ await this.runExtension?.attachRunContext(runId, workflowMeta, options)
1211
1367
 
1212
1368
  if (shouldInline) {
1213
1369
  this.inlineRuns.add(runId)
@@ -1244,7 +1400,7 @@ export abstract class PikkuWorkflowService implements WorkflowService {
1244
1400
  }
1245
1401
  } finally {
1246
1402
  this.inlineRuns.delete(runId)
1247
- this.runActors.delete(runId)
1403
+ this.runExtension?.detachRunContext(runId)
1248
1404
  }
1249
1405
  } else {
1250
1406
  await this.resumeWorkflow(runId)
@@ -1415,15 +1571,33 @@ export abstract class PikkuWorkflowService implements WorkflowService {
1415
1571
  workflowWire.pikkuUserId = run.wire?.pikkuUserId
1416
1572
  const wire: PikkuWire = {
1417
1573
  workflow: workflowWire,
1418
- scenario:
1419
- workflowMeta?.source === 'scenario' ? workflowWire : undefined,
1420
1574
  pikkuUserId: run.wire?.pikkuUserId,
1421
1575
  session: rpcService?.wire?.session,
1422
1576
  rpc: rpcService?.wire?.rpc,
1423
- // User-flow actors registered for this run (see startWorkflow options)
1424
- actors: this.runActors.get(runId),
1425
1577
  }
1578
+ this.runExtension?.decorateRunWire(wire, {
1579
+ runId,
1580
+ workflowMeta,
1581
+ workflowWire,
1582
+ })
1583
+
1584
+ const lifecycle: RunLifecycleContext = {
1585
+ runId,
1586
+ run,
1587
+ workflowMeta,
1588
+ workflow,
1589
+ wire,
1590
+ packageName: pkgName,
1591
+ }
1592
+
1593
+ // `interrupted` means the run has not reached a terminal state — it is
1594
+ // suspended or waiting — so teardown would run while the run is still
1595
+ // mid-flight.
1596
+ let outcome: 'completed' | 'failed' | 'interrupted' = 'completed'
1597
+ let failure: any
1426
1598
  try {
1599
+ await this.runExtension?.onBeforeRunFunc(lifecycle)
1600
+
1427
1601
  const result = await runPikkuFunc(
1428
1602
  'workflow',
1429
1603
  workflowMeta.name,
@@ -1440,11 +1614,15 @@ export abstract class PikkuWorkflowService implements WorkflowService {
1440
1614
  await this.updateRunStatus(runId, 'completed', result)
1441
1615
  await this.onChildWorkflowCompleted(run, result)
1442
1616
  } catch (error: any) {
1617
+ failure = error
1618
+
1443
1619
  if (error instanceof WorkflowAsyncException) {
1620
+ outcome = 'interrupted'
1444
1621
  throw error
1445
1622
  }
1446
1623
 
1447
1624
  if (error instanceof WorkflowCancelledException) {
1625
+ outcome = 'failed'
1448
1626
  await this.updateRunStatus(runId, 'cancelled', undefined, {
1449
1627
  message: error.message || 'Workflow cancelled',
1450
1628
  stack: '',
@@ -1455,6 +1633,7 @@ export abstract class PikkuWorkflowService implements WorkflowService {
1455
1633
  }
1456
1634
 
1457
1635
  if (error instanceof WorkflowSuspendedException) {
1636
+ outcome = 'interrupted'
1458
1637
  await this.updateRunStatus(runId, 'suspended', undefined, {
1459
1638
  message: error.message || 'Workflow suspended',
1460
1639
  stack: '',
@@ -1463,6 +1642,7 @@ export abstract class PikkuWorkflowService implements WorkflowService {
1463
1642
  throw error
1464
1643
  }
1465
1644
 
1645
+ outcome = 'failed'
1466
1646
  await this.updateRunStatus(runId, 'failed', undefined, {
1467
1647
  message: error.message,
1468
1648
  stack: error.stack,
@@ -1471,6 +1651,8 @@ export abstract class PikkuWorkflowService implements WorkflowService {
1471
1651
  await this.onChildWorkflowFailed(run, error)
1472
1652
 
1473
1653
  throw error
1654
+ } finally {
1655
+ await this.runExtension?.onAfterRunFunc(lifecycle, outcome, failure)
1474
1656
  }
1475
1657
  })
1476
1658
  }
@@ -1489,7 +1671,7 @@ export abstract class PikkuWorkflowService implements WorkflowService {
1489
1671
  await this.resumeWorkflow(parentRunId)
1490
1672
  }
1491
1673
 
1492
- private async onChildWorkflowFailed(
1674
+ protected async onChildWorkflowFailed(
1493
1675
  childRun: WorkflowRun,
1494
1676
  error: Error
1495
1677
  ): Promise<void> {
@@ -2018,11 +2200,25 @@ export abstract class PikkuWorkflowService implements WorkflowService {
2018
2200
  )
2019
2201
  }
2020
2202
 
2021
- private async inlineStep(
2203
+ protected async inlineStep(
2022
2204
  runId: string,
2023
2205
  logicalStepName: string,
2024
2206
  fn: Function,
2025
- stepOptions?: WorkflowStepOptions
2207
+ stepOptions?: WorkflowStepOptions,
2208
+ /**
2209
+ * The input this step was called with, recorded on the run so a reporter can
2210
+ * name the values under test. A closure step has none; a scenario step does.
2211
+ */
2212
+ data: any = null,
2213
+ /**
2214
+ * The name this step was dispatched by, for the kinds of inline step that
2215
+ * have one. A closure step has no name; a scenario step is a step RPC, so
2216
+ * it records the step function that ran — which is the only way to join a
2217
+ * step back to its declaration when its durable name was built at runtime
2218
+ * (a step called in a loop reaches the run as `sees @pikku/addon-todos`,
2219
+ * declared as `sees ${packageName}`).
2220
+ */
2221
+ rpcName: string | null = null
2026
2222
  ): Promise<any> {
2027
2223
  const fromStepName = this.lastStepName(runId)
2028
2224
  const stepName = this.nextStepKey(runId, logicalStepName)
@@ -2031,12 +2227,12 @@ export abstract class PikkuWorkflowService implements WorkflowService {
2031
2227
  try {
2032
2228
  stepState = await this.getStepState(runId, stepName)
2033
2229
  } catch {
2034
- // Step doesn't exist - create it (inline, no RPC)
2230
+ // Step doesn't exist - create it (inline, so never dispatched)
2035
2231
  stepState = await this.insertStepState(
2036
2232
  runId,
2037
2233
  stepName,
2038
- null,
2039
- null,
2234
+ rpcName,
2235
+ data,
2040
2236
  stepOptions,
2041
2237
  fromStepName
2042
2238
  )
@@ -2225,7 +2421,11 @@ export abstract class PikkuWorkflowService implements WorkflowService {
2225
2421
  await queueService.add(
2226
2422
  this.getOrchestratorQueueName(run.workflow),
2227
2423
  { runId },
2228
- { ...this.resolveStepJobOptions(), delay }
2424
+ {
2425
+ ...this.resolveStepJobOptions(),
2426
+ delay,
2427
+ group: this.getJobGroup(run.workflow),
2428
+ }
2229
2429
  )
2230
2430
  } catch (error) {
2231
2431
  this.logger?.warn(
@@ -2420,8 +2620,8 @@ export abstract class PikkuWorkflowService implements WorkflowService {
2420
2620
  runId: string,
2421
2621
  rpcService: any,
2422
2622
  addonNamespace?: string | null
2423
- ): PikkuScenarioWire {
2424
- const workflowWire: PikkuScenarioWire = {
2623
+ ): PikkuWorkflowWire {
2624
+ const workflowWire: PikkuWorkflowWire = {
2425
2625
  name,
2426
2626
  runId,
2427
2627
  getRun: async () => (await this.getRun(runId)) as WorkflowRun,
@@ -2457,146 +2657,6 @@ export abstract class PikkuWorkflowService implements WorkflowService {
2457
2657
  }
2458
2658
  },
2459
2659
 
2460
- // Durable polling step: invoke an RPC (as an actor when options.as is
2461
- // set) until the predicate passes or `within` elapses. The whole poll is
2462
- // ONE recorded step, so replay returns the cached outcome.
2463
- expectEventually: async (
2464
- stepName: string,
2465
- rpcName: string,
2466
- data: any,
2467
- predicate: (output: any) => boolean,
2468
- options?: WorkflowExpectEventuallyOptions
2469
- ) => {
2470
- this.verifyStepName(stepName)
2471
- const resolvedRpcName =
2472
- addonNamespace && !rpcName.includes(':')
2473
- ? `${addonNamespace}:${rpcName}`
2474
- : rpcName
2475
- const within = getDurationInMilliseconds(options?.within ?? '30s')
2476
- const interval = getDurationInMilliseconds(options?.interval ?? '1s')
2477
- return await this.inlineStep(
2478
- runId,
2479
- stepName,
2480
- async () => {
2481
- const deadline = Date.now() + within
2482
- let last: any
2483
- while (true) {
2484
- last = options?.actor
2485
- ? await options.actor.invoke(resolvedRpcName, data)
2486
- : await rpcService.rpcWithWire(resolvedRpcName, data, {})
2487
- if (predicate(last)) return last
2488
- if (Date.now() + interval > deadline) {
2489
- throw new Error(
2490
- `[workflow] expectEventually '${stepName}' ('${resolvedRpcName}'` +
2491
- `${options?.actor ? ` as '${options.actor.name}'` : ''}) did not pass within ${within}ms; ` +
2492
- `last result: ${JSON.stringify(last)?.slice(0, 300)}`
2493
- )
2494
- }
2495
- await new Promise((resolve) => setTimeout(resolve, interval))
2496
- }
2497
- },
2498
- options
2499
- )
2500
- },
2501
-
2502
- expectError: async (
2503
- stepName: string,
2504
- rpcName: string,
2505
- data: any,
2506
- options?: WorkflowExpectErrorOptions
2507
- ) => {
2508
- this.verifyStepName(stepName)
2509
- const resolvedRpcName =
2510
- addonNamespace && !rpcName.includes(':')
2511
- ? `${addonNamespace}:${rpcName}`
2512
- : rpcName
2513
- return await this.inlineStep(
2514
- runId,
2515
- stepName,
2516
- async () => {
2517
- let result: any
2518
- try {
2519
- result = options?.actor
2520
- ? await options.actor.invoke(resolvedRpcName, data)
2521
- : await rpcService.rpcWithWire(resolvedRpcName, data, {})
2522
- } catch (e: any) {
2523
- const message = e?.message ?? String(e)
2524
- if (options?.matches) {
2525
- const matched =
2526
- typeof options.matches === 'string'
2527
- ? message.includes(options.matches)
2528
- : options.matches.test(message)
2529
- if (!matched) {
2530
- throw new Error(
2531
- `[workflow] expectError '${stepName}' ('${resolvedRpcName}') threw, but the message did not match ${options.matches}: ${message}`
2532
- )
2533
- }
2534
- }
2535
- return message
2536
- }
2537
- throw new Error(
2538
- `[workflow] expectError '${stepName}' ('${resolvedRpcName}') expected an error but the call succeeded: ${JSON.stringify(result)?.slice(0, 300)}`
2539
- )
2540
- },
2541
- options
2542
- )
2543
- },
2544
-
2545
- expectService: async (
2546
- stepName: string,
2547
- serviceMethod: string,
2548
- options?: WorkflowExpectServiceOptions
2549
- ) => {
2550
- this.verifyStepName(stepName)
2551
- const [service, method] = serviceMethod.split('.')
2552
- if (!service || !method) {
2553
- throw new Error(
2554
- `[workflow] expectService '${stepName}' needs 'service.method', got '${serviceMethod}'`
2555
- )
2556
- }
2557
- await this.inlineStep(
2558
- runId,
2559
- stepName,
2560
- async () => {
2561
- const rpcName = 'pikkuScenarioGetStubCalls'
2562
- const calls: Array<{
2563
- service: string
2564
- method: string
2565
- args: unknown[]
2566
- }> = options?.actor
2567
- ? await options.actor.invoke(rpcName, { service })
2568
- : await rpcService.rpcWithWire(rpcName, { service }, {})
2569
- const matching = (calls ?? []).filter(
2570
- (c) =>
2571
- c.service === service &&
2572
- c.method === method &&
2573
- (options?.calledWith === undefined ||
2574
- JSON.stringify(c.args?.[0]) ===
2575
- JSON.stringify(options.calledWith))
2576
- )
2577
- const expected = options?.times
2578
- const ok =
2579
- expected === undefined
2580
- ? matching.length > 0
2581
- : matching.length === expected
2582
- if (!ok) {
2583
- const seen =
2584
- (calls ?? [])
2585
- .map(
2586
- (c) =>
2587
- `${c.service}.${c.method}(${JSON.stringify(c.args?.[0])?.slice(0, 120) ?? ''})`
2588
- )
2589
- .join('\n ') || '(none)'
2590
- throw new Error(
2591
- `[workflow] expectService '${stepName}' expected ${expected ?? 'at least one'} call(s) to '${serviceMethod}'` +
2592
- `${options?.calledWith !== undefined ? ` with ${JSON.stringify(options.calledWith)}` : ''}, found ${matching.length}. Recorded:\n ${seen}`
2593
- )
2594
- }
2595
- },
2596
- options
2597
- )
2598
- },
2599
-
2600
2660
  // Implement workflow.sleep()
2601
2661
  sleep: async (stepName: string, duration: string | number) => {
2602
2662
  this.verifyStepName(stepName)
@@ -2615,16 +2675,18 @@ export abstract class PikkuWorkflowService implements WorkflowService {
2615
2675
  approval: (async (reason: string, options: WorkflowApprovalOptions) => {
2616
2676
  this.verifyStepName(reason)
2617
2677
  return await this.approvalStep(runId, reason, options)
2618
- }) as PikkuScenarioWire['approval'],
2619
-
2620
- runScheduledTask: async (taskName: string) => {
2621
- await runScheduledTask({ name: taskName })
2622
- },
2678
+ }) as PikkuWorkflowWire['approval'],
2623
2679
  }
2680
+ this.runExtension?.decorateWorkflowWire(workflowWire, {
2681
+ name,
2682
+ runId,
2683
+ rpcService,
2684
+ addonNamespace,
2685
+ })
2624
2686
  return workflowWire
2625
2687
  }
2626
2688
 
2627
- private verifyStepName(stepName: string) {
2689
+ protected verifyStepName(stepName: string) {
2628
2690
  if (typeof stepName !== 'string') {
2629
2691
  throw new WorkflowStepNameNotString(stepName)
2630
2692
  }
@@ -2655,7 +2717,7 @@ export abstract class PikkuWorkflowService implements WorkflowService {
2655
2717
  * queues — but it produces to them — so registrations would miss them.
2656
2718
  */
2657
2719
  protected getOrchestratorQueueName(workflowName?: string): string {
2658
- if (workflowName) {
2720
+ if (workflowName && this.queueStrategy !== 'shared-groups') {
2659
2721
  const perWorkflow = `wf-orchestrator-${toKebab(workflowName)}`
2660
2722
  const meta = pikkuState(null, 'queue', 'meta')
2661
2723
  if (meta[perWorkflow]) {
@@ -2666,7 +2728,7 @@ export abstract class PikkuWorkflowService implements WorkflowService {
2666
2728
  }
2667
2729
 
2668
2730
  protected getStepWorkerQueueName(rpcName?: string): string {
2669
- if (rpcName) {
2731
+ if (rpcName && this.queueStrategy !== 'shared-groups') {
2670
2732
  const perStep = `wf-step-${toKebab(rpcName)}`
2671
2733
  const meta = pikkuState(null, 'queue', 'meta')
2672
2734
  if (meta[perStep]) {
@@ -2675,4 +2737,20 @@ export abstract class PikkuWorkflowService implements WorkflowService {
2675
2737
  }
2676
2738
  return this.getConfig().stepWorkerQueueName
2677
2739
  }
2740
+
2741
+ /**
2742
+ * Fairness key for a job on a shared queue. Under `'per-workflow'` the queue
2743
+ * name already isolates workflows, so no group is needed — returning one
2744
+ * anyway would cap a workflow inside its own dedicated queue.
2745
+ *
2746
+ * The tier repeats the id so a workflow can be given its own limit purely
2747
+ * from config, with no per-workflow wiring; an unmatched tier falls back to
2748
+ * the default limit.
2749
+ */
2750
+ protected getJobGroup(id?: string): JobGroup | undefined {
2751
+ if (!id || this.queueStrategy !== 'shared-groups') {
2752
+ return undefined
2753
+ }
2754
+ return { id, tier: id }
2755
+ }
2678
2756
  }