@adonis-agora/durable 0.6.0 → 0.7.0

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 (68) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/dist/providers/durable_provider.d.ts.map +1 -1
  3. package/dist/providers/durable_provider.js +41 -4
  4. package/dist/providers/durable_provider.js.map +1 -1
  5. package/dist/src/dashboard/index.d.ts +1 -1
  6. package/dist/src/dashboard/index.js +1 -1
  7. package/dist/src/define_config.d.ts +8 -0
  8. package/dist/src/define_config.d.ts.map +1 -1
  9. package/dist/src/define_config.js.map +1 -1
  10. package/dist/src/engine.d.ts +40 -2
  11. package/dist/src/engine.d.ts.map +1 -1
  12. package/dist/src/engine.js +129 -28
  13. package/dist/src/engine.js.map +1 -1
  14. package/dist/src/entities.js +2 -2
  15. package/dist/src/entities.js.map +1 -1
  16. package/dist/src/hooks/steps.d.ts +43 -0
  17. package/dist/src/hooks/steps.d.ts.map +1 -0
  18. package/dist/src/hooks/steps.js +46 -0
  19. package/dist/src/hooks/steps.js.map +1 -0
  20. package/dist/src/index.d.ts +5 -2
  21. package/dist/src/index.d.ts.map +1 -1
  22. package/dist/src/index.js +5 -2
  23. package/dist/src/index.js.map +1 -1
  24. package/dist/src/interfaces.d.ts +116 -42
  25. package/dist/src/interfaces.d.ts.map +1 -1
  26. package/dist/src/step-discovery.d.ts +42 -0
  27. package/dist/src/step-discovery.d.ts.map +1 -0
  28. package/dist/src/step-discovery.js +127 -0
  29. package/dist/src/step-discovery.js.map +1 -0
  30. package/dist/src/step-name-symbol.d.ts +63 -0
  31. package/dist/src/step-name-symbol.d.ts.map +1 -0
  32. package/dist/src/step-name-symbol.js +42 -0
  33. package/dist/src/step-name-symbol.js.map +1 -0
  34. package/dist/src/step-ref.d.ts +70 -0
  35. package/dist/src/step-ref.d.ts.map +1 -0
  36. package/dist/src/step-ref.js +93 -0
  37. package/dist/src/step-ref.js.map +1 -0
  38. package/dist/src/tenant-group.d.ts +18 -0
  39. package/dist/src/tenant-group.d.ts.map +1 -0
  40. package/dist/src/tenant-group.js +24 -0
  41. package/dist/src/tenant-group.js.map +1 -0
  42. package/dist/src/testing-kit/state-store-conformance.js +2 -2
  43. package/dist/src/testing-kit/state-store-conformance.js.map +1 -1
  44. package/dist/src/testing-kit/transport-conformance.d.ts.map +1 -1
  45. package/dist/src/testing-kit/transport-conformance.js +7 -12
  46. package/dist/src/testing-kit/transport-conformance.js.map +1 -1
  47. package/dist/src/transports/db-schema.d.ts.map +1 -1
  48. package/dist/src/transports/db-schema.js +24 -4
  49. package/dist/src/transports/db-schema.js.map +1 -1
  50. package/dist/src/transports/db.d.ts +34 -5
  51. package/dist/src/transports/db.d.ts.map +1 -1
  52. package/dist/src/transports/db.js +58 -15
  53. package/dist/src/transports/db.js.map +1 -1
  54. package/dist/src/transports/queue.d.ts +15 -5
  55. package/dist/src/transports/queue.d.ts.map +1 -1
  56. package/dist/src/transports/queue.js +19 -17
  57. package/dist/src/transports/queue.js.map +1 -1
  58. package/dist/src/workflow-ctx.d.ts +27 -8
  59. package/dist/src/workflow-ctx.d.ts.map +1 -1
  60. package/dist/src/workflow-ctx.js +101 -16
  61. package/dist/src/workflow-ctx.js.map +1 -1
  62. package/dist/stubs/database/migrations/create_durable_transport_tables.stub +8 -4
  63. package/dist/stubs/make/workflow/main.stub +1 -1
  64. package/package.json +6 -1
  65. package/dist/src/remote-step-factory.d.ts +0 -15
  66. package/dist/src/remote-step-factory.d.ts.map +0 -1
  67. package/dist/src/remote-step-factory.js +0 -12
  68. package/dist/src/remote-step-factory.js.map +0 -1
@@ -7,7 +7,9 @@ import { ContinueAsNew, NonDeterminismError, RemoteStepTimeout, WorkflowSuspende
7
7
  import { EventAccumulators } from './event-accumulators.js';
8
8
  import { eventMatchOf, eventMatches, eventPrefix } from './events.js';
9
9
  import { breakpointToken, stepId } from './protocol.js';
10
+ import { RemoteWorkflowExecutor } from './remote-workflow-executor.js';
10
11
  import { SingletonGate } from './singleton-gate.js';
12
+ import { sanitizeQueueToken, tenantGroup } from './tenant-group.js';
11
13
  import { TransportPool } from './transport-pool.js';
12
14
  import { createWorkflowCtx, } from './workflow-ctx.js';
13
15
  import { workflowName, } from './workflow-ref.js';
@@ -52,6 +54,11 @@ export class WorkflowEngine {
52
54
  store;
53
55
  /** Ordered transport pool (dispatch + failover). Empty = no remote steps. */
54
56
  pool;
57
+ /** The primary task transport (first of the pool), used to build an on-the-fly remote-workflow
58
+ * executor for {@link WorkflowEngineDeps.remoteByConvention}. Undefined when no transport is wired. */
59
+ primaryTransport;
60
+ /** Convention-dispatch opt-in — see {@link WorkflowEngineDeps.remoteByConvention}. */
61
+ remoteByConvention;
55
62
  controlPlane;
56
63
  clock;
57
64
  instanceId;
@@ -110,6 +117,8 @@ export class WorkflowEngine {
110
117
  constructor(deps) {
111
118
  this.store = deps.store;
112
119
  this.pool = new TransportPool(deps.transports ?? (deps.transport ? [{ id: 'default', transport: deps.transport }] : []));
120
+ this.primaryTransport = deps.transports?.[0]?.transport ?? deps.transport;
121
+ this.remoteByConvention = deps.remoteByConvention ?? false;
113
122
  this.controlPlane = deps.controlPlane;
114
123
  this.clock = deps.clock ?? Date.now;
115
124
  this.admission = deps.admission ?? new InMemoryAdmissionBackend(this.clock);
@@ -294,7 +303,28 @@ export class WorkflowEngine {
294
303
  this.latest.set(name, registered);
295
304
  }
296
305
  /**
297
- * Register a flow-control queue referenced by `ctx.call(step, input, { queue })`. Caps concurrent
306
+ * Convention-dispatch resolution (opt-in via {@link WorkflowEngineDeps.remoteByConvention}): when
307
+ * `name` isn't locally registered but a LIVE worker group of the same name exists, auto-register a
308
+ * remote workflow routed to that group (bare group, no partition) so its runs advance over the
309
+ * broker like any `registerRemote`'d one. Returns whether `name` is registered afterwards. A no-op
310
+ * (returns `false`) when the opt-in is off, no transport is wired, or no matching group is live.
311
+ */
312
+ async ensureConventionWorkflow(name) {
313
+ if (!this.remoteByConvention || !this.primaryTransport)
314
+ return false;
315
+ if (this.latest.get(name))
316
+ return true;
317
+ const groups = await this.pool.listWorkerGroups();
318
+ if (!groups.includes(name))
319
+ return false;
320
+ this.registerRemote(name, '1', {
321
+ group: name,
322
+ executor: new RemoteWorkflowExecutor(this.primaryTransport, name),
323
+ });
324
+ return true;
325
+ }
326
+ /**
327
+ * Register a flow-control queue referenced by `ctx.step(name, input, { queue })`. Caps concurrent
298
328
  * in-flight steps and/or the admission rate; blocked calls re-suspend and retry, so the limit is
299
329
  * durable. Per engine instance (see {@link QueueConfig}). Registering the same name replaces it.
300
330
  */
@@ -394,7 +424,12 @@ export class WorkflowEngine {
394
424
  }
395
425
  async start(workflow, input, runId, opts) {
396
426
  const name = workflowName(workflow);
397
- const registered = this.latest.get(name);
427
+ let registered = this.latest.get(name);
428
+ // remoteByConvention: an unregistered workflow whose name matches a LIVE worker group is routed to
429
+ // it as a remote workflow (bare group). Off by default → the original "not registered" throw.
430
+ if (!registered && (await this.ensureConventionWorkflow(name))) {
431
+ registered = this.latest.get(name);
432
+ }
398
433
  if (!registered)
399
434
  throw new Error(`workflow ${name} is not registered`);
400
435
  // Validate the input up front — a bad payload is rejected before any run is created.
@@ -421,7 +456,7 @@ export class WorkflowEngine {
421
456
  workflow: name,
422
457
  workflowVersion: registered.version,
423
458
  status: 'pending',
424
- namespace: this.namespace,
459
+ namespace: opts?.namespace ?? this.namespace,
425
460
  input,
426
461
  tags,
427
462
  searchAttributes: opts?.searchAttributes,
@@ -496,7 +531,14 @@ export class WorkflowEngine {
496
531
  }
497
532
  // Pin to the version the run STARTED on — replay is positional, so running a changed
498
533
  // workflow body against old checkpoints would corrupt the run.
499
- const registered = this.workflows.get(versionKey(run.workflow, run.workflowVersion));
534
+ let registered = this.workflows.get(versionKey(run.workflow, run.workflowVersion));
535
+ // remoteByConvention: a run recovered on an instance that never registered this workflow (e.g. a
536
+ // crash-recovery pickup) is re-routed to a live worker group of the same name, if one exists.
537
+ if (!registered && (await this.ensureConventionWorkflow(run.workflow))) {
538
+ registered =
539
+ this.workflows.get(versionKey(run.workflow, run.workflowVersion)) ??
540
+ this.latest.get(run.workflow);
541
+ }
500
542
  if (!registered) {
501
543
  throw new Error(`workflow ${run.workflow}@${run.workflowVersion} is not registered — keep the prior version deployed so in-flight runs can drain (skew protection)`);
502
544
  }
@@ -550,7 +592,7 @@ export class WorkflowEngine {
550
592
  continue;
551
593
  const error = { message: 'execution timeout', code: 'execution_timeout' };
552
594
  await this.store.updateRun(run.id, { status: 'cancelled', error, updatedAt: new Date() });
553
- this.emit({ type: 'run.failed', runId: run.id, workflow: run.workflow, error });
595
+ this.emit({ type: 'run.failed', runId: run.id, workflow: run.workflow, namespace: run.namespace, error });
554
596
  }
555
597
  }
556
598
  }
@@ -597,7 +639,7 @@ export class WorkflowEngine {
597
639
  };
598
640
  await this.store.updateRun(run.id, { status: 'dead', error, updatedAt: new Date() });
599
641
  await this.store.releaseRunLock(run.id);
600
- this.emit({ type: 'run.failed', runId: run.id, workflow: run.workflow, error });
642
+ this.emit({ type: 'run.failed', runId: run.id, workflow: run.workflow, namespace: run.namespace, error });
601
643
  this.notifyDead({ ...run, status: 'dead', error, recoveryAttempts: attempts });
602
644
  return { runId: run.id, status: 'dead', error };
603
645
  }
@@ -659,7 +701,9 @@ export class WorkflowEngine {
659
701
  if (!run)
660
702
  return null;
661
703
  const id = newRunId ?? `${runId}~retry~${globalThis.crypto.randomUUID().slice(0, 8)}`;
662
- await this.start(run.workflow, input, id, { tags: run.tags });
704
+ // Inherit the original run's namespace so a retry lands in the SAME worker-pool partition (an
705
+ // operator-routed run keeps its tenant on retry, rather than falling back to the engine's own).
706
+ await this.start(run.workflow, input, id, { tags: run.tags, namespace: run.namespace });
663
707
  return { runId: id };
664
708
  }
665
709
  /**
@@ -885,7 +929,7 @@ export class WorkflowEngine {
885
929
  }
886
930
  const error = { message: 'cancelled' };
887
931
  await this.store.updateRun(runId, { status: 'cancelled', error, updatedAt: new Date() });
888
- this.emit({ type: 'run.failed', runId, workflow: run.workflow, error });
932
+ this.emit({ type: 'run.failed', runId, workflow: run.workflow, namespace: run.namespace, error });
889
933
  await this.cancelChildren(runId, opts);
890
934
  // Notify local cancel listeners now (a worker on this pod), and broadcast so the instance/worker
891
935
  // actually running this run learns of it and can abort cooperatively (the store already records
@@ -1204,6 +1248,7 @@ export class WorkflowEngine {
1204
1248
  type: 'run.completed',
1205
1249
  runId: run.id,
1206
1250
  workflow: run.workflow,
1251
+ namespace: run.namespace,
1207
1252
  output: outcome.output,
1208
1253
  });
1209
1254
  void this.notifyParent(run.id, { ok: true, value: outcome.output });
@@ -1215,13 +1260,14 @@ export class WorkflowEngine {
1215
1260
  type: 'run.failed',
1216
1261
  runId: run.id,
1217
1262
  workflow: run.workflow,
1263
+ namespace: run.namespace,
1218
1264
  error: outcome.error,
1219
1265
  });
1220
1266
  void this.notifyParent(run.id, { ok: false, error: outcome.error.message });
1221
1267
  return { runId: run.id, status: 'failed', error: outcome.error };
1222
1268
  }
1223
1269
  await this.store.updateRun(run.id, { status: 'suspended', wakeAt: outcome.wakeAt, updatedAt });
1224
- this.emit({ type: 'run.suspended', runId: run.id, workflow: run.workflow });
1270
+ this.emit({ type: 'run.suspended', runId: run.id, workflow: run.workflow, namespace: run.namespace });
1225
1271
  return { runId: run.id, status: 'suspended' };
1226
1272
  }
1227
1273
  async runRemoteExecution(run, registered) {
@@ -1229,12 +1275,12 @@ export class WorkflowEngine {
1229
1275
  if (run.status === 'pending') {
1230
1276
  await this.store.updateRun(run.id, { status: 'running', updatedAt: new Date() });
1231
1277
  run.status = 'running';
1232
- this.emit({ type: 'run.started', runId: run.id, workflow: run.workflow });
1278
+ this.emit({ type: 'run.started', runId: run.id, workflow: run.workflow, namespace: run.namespace });
1233
1279
  }
1234
1280
  if (registered.singleton && !(await this.singletons.admit(run, registered.singleton))) {
1235
1281
  const wakeAt = this.singletons.retryWakeAt();
1236
1282
  await this.store.updateRun(run.id, { status: 'suspended', wakeAt, updatedAt: new Date() });
1237
- this.emit({ type: 'run.suspended', runId: run.id, workflow: run.workflow });
1283
+ this.emit({ type: 'run.suspended', runId: run.id, workflow: run.workflow, namespace: run.namespace });
1238
1284
  await this.store.releaseRunLock(run.id);
1239
1285
  return { runId: run.id, status: 'suspended' };
1240
1286
  }
@@ -1249,7 +1295,7 @@ export class WorkflowEngine {
1249
1295
  stack: err instanceof Error ? err.stack : undefined,
1250
1296
  };
1251
1297
  await this.store.updateRun(run.id, { status: 'failed', error, updatedAt: new Date() });
1252
- this.emit({ type: 'run.failed', runId: run.id, workflow: run.workflow, error });
1298
+ this.emit({ type: 'run.failed', runId: run.id, workflow: run.workflow, namespace: run.namespace, error });
1253
1299
  return { runId: run.id, status: 'failed', error };
1254
1300
  }
1255
1301
  if (decision.status === 'completed') {
@@ -1375,6 +1421,10 @@ export class WorkflowEngine {
1375
1421
  // Mirrors the `startChild` `getRun(childId)` guard below.
1376
1422
  if (await this.store.getCheckpoint(run.id, cmd.seq))
1377
1423
  continue;
1424
+ // Route this cross-SDK-worker `call` by the SAME name-based token a native `ctx.step`
1425
+ // dispatches with (and a worker subscribes to per handler name), so a decision-driven remote
1426
+ // step and an in-process one land on the identical queue. The command carries no partition.
1427
+ const callToken = tenantGroup(sanitizeQueueToken(cmd.name), undefined);
1378
1428
  await this.store.saveCheckpoint(stepCheckpoint({
1379
1429
  runId: run.id,
1380
1430
  seq: cmd.seq,
@@ -1383,13 +1433,13 @@ export class WorkflowEngine {
1383
1433
  status: 'pending',
1384
1434
  input: cmd.input,
1385
1435
  attempts: 1,
1386
- workerGroup: cmd.group,
1436
+ workerGroup: callToken,
1387
1437
  enqueuedAt: at,
1388
1438
  startedAt: at,
1389
1439
  finishedAt: at,
1390
1440
  // A `ctx.gather_calls([...])` fan-out stamps every dispatched `call` in the fan with the
1391
1441
  // same group, so the dashboard renders the remote steps as one parallel fan (parity with
1392
- // the gathered `recordStep`/`startChild` tags). Undefined for a lone sequential `ctx.call`.
1442
+ // the gathered `recordStep`/`startChild` tags). Undefined for a lone sequential `ctx.step`.
1393
1443
  parallelGroup: cmd.parallelGroup,
1394
1444
  }));
1395
1445
  await this.pool.dispatch({
@@ -1397,7 +1447,7 @@ export class WorkflowEngine {
1397
1447
  seq: cmd.seq,
1398
1448
  name: cmd.name,
1399
1449
  stepId: id,
1400
- group: cmd.group,
1450
+ group: callToken,
1401
1451
  input: cmd.input,
1402
1452
  traceparent: this.traceparent?.(),
1403
1453
  context: this.context?.(),
@@ -1483,14 +1533,14 @@ export class WorkflowEngine {
1483
1533
  if (run.status === 'pending') {
1484
1534
  await this.store.updateRun(run.id, { status: 'running', updatedAt: new Date() });
1485
1535
  run.status = 'running';
1486
- this.emit({ type: 'run.started', runId: run.id, workflow: run.workflow });
1536
+ this.emit({ type: 'run.started', runId: run.id, workflow: run.workflow, namespace: run.namespace });
1487
1537
  }
1488
1538
  // Singleton admission gate: if this run shares its key with `limit` older in-flight runs, wait
1489
1539
  // (suspend on a short timer) until a slot frees instead of running now. Re-checked on each resume.
1490
1540
  if (registered?.singleton && !(await this.singletons.admit(run, registered.singleton))) {
1491
1541
  const wakeAt = this.singletons.retryWakeAt();
1492
1542
  await this.store.updateRun(run.id, { status: 'suspended', wakeAt, updatedAt: new Date() });
1493
- this.emit({ type: 'run.suspended', runId: run.id, workflow: run.workflow });
1543
+ this.emit({ type: 'run.suspended', runId: run.id, workflow: run.workflow, namespace: run.namespace });
1494
1544
  await this.store.releaseRunLock(run.id);
1495
1545
  return { runId: run.id, status: 'suspended' };
1496
1546
  }
@@ -1521,7 +1571,7 @@ export class WorkflowEngine {
1521
1571
  error: undefined,
1522
1572
  updatedAt: new Date(),
1523
1573
  });
1524
- this.emit({ type: 'run.completed', runId: run.id, workflow: run.workflow });
1574
+ this.emit({ type: 'run.completed', runId: run.id, workflow: run.workflow, namespace: run.namespace });
1525
1575
  void this.notifyParent(run.id, { ok: true, value: undefined });
1526
1576
  const nextId = nextContinuationId(run.id);
1527
1577
  queueMicrotask(() => void this.start(run.workflow, err.input, nextId).catch(() => undefined));
@@ -1539,7 +1589,7 @@ export class WorkflowEngine {
1539
1589
  }
1540
1590
  const error = { message: 'cancelled' };
1541
1591
  await this.store.updateRun(run.id, { status: 'cancelled', error, updatedAt: new Date() });
1542
- this.emit({ type: 'run.failed', runId: run.id, workflow: run.workflow, error });
1592
+ this.emit({ type: 'run.failed', runId: run.id, workflow: run.workflow, namespace: run.namespace, error });
1543
1593
  return { runId: run.id, status: 'cancelled', error };
1544
1594
  }
1545
1595
  return this.settleRun(run, { kind: 'suspended', wakeAt: err.wakeAt });
@@ -1569,14 +1619,21 @@ export class WorkflowEngine {
1569
1619
  /**
1570
1620
  * Run one saga compensation, retried up to `compensationRetries`, emitting a `compensate:<step>`
1571
1621
  * step event for its outcome so a stranded undo is visible. Never throws — a permanently-failing
1572
- * compensation is skipped so it can't mask the original failure.
1622
+ * compensation is skipped so it can't mask the original failure. Handles both compensation shapes:
1623
+ * a LOCAL `fn` (run in-process) and a DISPATCHED undo (an ordinary step def sent to a worker and
1624
+ * awaited inline — the worker serving its name runs it with the {@link StepUndo} envelope).
1573
1625
  */
1574
1626
  async runCompensation(run, comp) {
1575
1627
  const name = `compensate:${comp.name}`;
1576
1628
  for (let attempt = 1; attempt <= this.compensationRetries; attempt += 1) {
1577
1629
  const startedAt = Date.now();
1578
1630
  try {
1579
- await comp.fn();
1631
+ if ('fn' in comp) {
1632
+ await comp.fn();
1633
+ }
1634
+ else {
1635
+ await this.dispatchCompensation(run, comp.dispatch.def, comp.dispatch.args, attempt);
1636
+ }
1580
1637
  this.emit({
1581
1638
  type: 'step.completed',
1582
1639
  runId: run.id,
@@ -1602,6 +1659,43 @@ export class WorkflowEngine {
1602
1659
  }
1603
1660
  }
1604
1661
  }
1662
+ /**
1663
+ * Dispatch a saga undo step to a worker and await its result INLINE (the saga unwind already runs
1664
+ * outside the positional replay, so this is not checkpointed — it's a one-shot dispatch + await).
1665
+ * Routes by the undo def's own name/partition token, applies its own optional input schema, and
1666
+ * honours its own liveness `timeoutMs` via the heartbeat window. Throws on a worker-reported failure
1667
+ * or timeout so `runCompensation`'s retry/skip logic applies uniformly to both compensation shapes.
1668
+ */
1669
+ async dispatchCompensation(run, def, args, attempt) {
1670
+ if (this.pool.size === 0)
1671
+ throw new Error('dispatched compensation requires a Transport');
1672
+ const token = tenantGroup(sanitizeQueueToken(def.name), def.partition);
1673
+ const validInput = def.input ? def.input.parse(args) : args;
1674
+ const id = `${run.id}:compensate:${def.name}:${attempt}`;
1675
+ const resultPromise = new Promise((resolve, reject) => {
1676
+ this.pending.set(id, { resolve, reject });
1677
+ });
1678
+ try {
1679
+ await this.pool.dispatch({
1680
+ runId: run.id,
1681
+ seq: -1,
1682
+ name: def.name,
1683
+ stepId: id,
1684
+ group: token,
1685
+ input: validInput,
1686
+ traceparent: this.traceparent?.(),
1687
+ context: this.context?.(),
1688
+ attempt: 1,
1689
+ });
1690
+ await (def.timeoutMs
1691
+ ? this.awaitWithHeartbeat(id, resultPromise, def.timeoutMs)
1692
+ : resultPromise);
1693
+ }
1694
+ catch (err) {
1695
+ this.pending.delete(id);
1696
+ throw err;
1697
+ }
1698
+ }
1605
1699
  /** The seam handed to {@link createWorkflowCtx}: the authoring API reaches durability + lifecycle
1606
1700
  * (checkpointing, dispatch, child start) through this, so the ctx primitives live in their own
1607
1701
  * module and the engine stays the orchestrator. */
@@ -1760,7 +1854,13 @@ export class WorkflowEngine {
1760
1854
  this.queueWaiters.get(queue)?.delete(runId);
1761
1855
  this.stepQueue.set(id, queue);
1762
1856
  }
1763
- const validInput = step.input.parse(input);
1857
+ // Optional runtime schema: validate only when the def carries one (a bare `@Step()` / string-name
1858
+ // call carries none — the engine passes the input through untouched then).
1859
+ const validInput = step.input ? step.input.parse(input) : input;
1860
+ // Routing token: BY NAME (sanitized for brokers), optionally partition-suffixed. Computed once and
1861
+ // used at BOTH the checkpoint's workerGroup and the dispatched task's routing `group`, so the same
1862
+ // token the worker subscribes to per handler name serves this step.
1863
+ const token = tenantGroup(sanitizeQueueToken(step.name), step.partition);
1764
1864
  const enqueuedAt = new Date();
1765
1865
  // Persist the pending checkpoint BEFORE dispatching, so a fast result always finds it to complete.
1766
1866
  await this.store.saveCheckpoint({
@@ -1772,7 +1872,7 @@ export class WorkflowEngine {
1772
1872
  status: 'pending',
1773
1873
  input: validInput,
1774
1874
  attempts: attempt,
1775
- workerGroup: step.group,
1875
+ workerGroup: token,
1776
1876
  enqueuedAt,
1777
1877
  startedAt: enqueuedAt, // placeholders until the worker result lands
1778
1878
  finishedAt: enqueuedAt,
@@ -1782,7 +1882,7 @@ export class WorkflowEngine {
1782
1882
  seq,
1783
1883
  name: step.name,
1784
1884
  stepId: id,
1785
- group: step.group,
1885
+ group: token,
1786
1886
  input: validInput,
1787
1887
  traceparent: this.traceparent?.(),
1788
1888
  context: this.context?.(),
@@ -1859,7 +1959,8 @@ export class WorkflowEngine {
1859
1959
  async callRemoteInMemory(runId, seq, step, input, transport) {
1860
1960
  if (this.pool.size === 0)
1861
1961
  throw new Error('remote steps require a Transport');
1862
- const validInput = step.input.parse(input);
1962
+ const validInput = step.input ? step.input.parse(input) : input;
1963
+ const token = tenantGroup(sanitizeQueueToken(step.name), step.partition);
1863
1964
  const id = stepId(runId, seq);
1864
1965
  const enqueuedAt = new Date();
1865
1966
  this.emit({ type: 'step.started', runId, seq, name: step.name, kind: 'remote' });
@@ -1878,7 +1979,7 @@ export class WorkflowEngine {
1878
1979
  seq,
1879
1980
  name: step.name,
1880
1981
  stepId: id,
1881
- group: step.group,
1982
+ group: token,
1882
1983
  input: validInput,
1883
1984
  traceparent: this.traceparent?.(),
1884
1985
  context: this.context?.(),
@@ -1888,7 +1989,7 @@ export class WorkflowEngine {
1888
1989
  const resolution = step.timeoutMs
1889
1990
  ? await this.awaitWithHeartbeat(id, resultPromise, step.timeoutMs)
1890
1991
  : await resultPromise;
1891
- const output = step.output.parse(resolution.output);
1992
+ const output = (step.output ? step.output.parse(resolution.output) : resolution.output);
1892
1993
  // The worker reports when it actually picked the task up; fall back to dispatch time if a
1893
1994
  // transport doesn't carry it (queue-wait then reads as zero rather than going negative).
1894
1995
  const startedAt = resolution.startedAt ? new Date(resolution.startedAt) : enqueuedAt;
@@ -1901,7 +2002,7 @@ export class WorkflowEngine {
1901
2002
  output,
1902
2003
  events: resolution.events,
1903
2004
  attempts: attempt,
1904
- workerGroup: step.group,
2005
+ workerGroup: token,
1905
2006
  enqueuedAt,
1906
2007
  startedAt,
1907
2008
  });