@dudousxd/nestjs-durable-core 0.72.0 → 0.73.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.
package/dist/index.js CHANGED
@@ -172,6 +172,27 @@ function currentWorkflowCtx() {
172
172
  return storage.getStore();
173
173
  }
174
174
  __name(currentWorkflowCtx, "currentWorkflowCtx");
175
+ var STEP_SCOPE_KEY = Symbol.for("nestjs-durable:ambient-step-scope");
176
+ var stepScopeRef = globalThis;
177
+ if (!stepScopeRef[STEP_SCOPE_KEY]) {
178
+ stepScopeRef[STEP_SCOPE_KEY] = new AsyncLocalStorage();
179
+ }
180
+ var stepScopeStorage = stepScopeRef[STEP_SCOPE_KEY];
181
+ function runInStepScope(owner, step, fn) {
182
+ return stepScopeStorage.run({
183
+ owner,
184
+ step
185
+ }, fn);
186
+ }
187
+ __name(runInStepScope, "runInStepScope");
188
+ function currentStepScope() {
189
+ return stepScopeStorage.getStore();
190
+ }
191
+ __name(currentStepScope, "currentStepScope");
192
+ function runOutsideWorkflowCtx(fn) {
193
+ return storage.exit(() => stepScopeStorage.exit(fn));
194
+ }
195
+ __name(runOutsideWorkflowCtx, "runOutsideWorkflowCtx");
175
196
 
176
197
  // src/ambient-step.ts
177
198
  import { AsyncLocalStorage as AsyncLocalStorage2 } from "node:async_hooks";
@@ -334,6 +355,21 @@ var NonDeterminismError = class extends Error {
334
355
  this.seq = seq;
335
356
  }
336
357
  };
358
+ var NestedWorkflowCallError = class extends FatalError {
359
+ static {
360
+ __name(this, "NestedWorkflowCallError");
361
+ }
362
+ runId;
363
+ primitive;
364
+ step;
365
+ constructor(runId, primitive, step) {
366
+ super(`ctx.${primitive}() was called inside the body of step "${step}" (run ${runId}). Workflow primitives (child/startChild/step/sleep/signals/sideEffect/\u2026, and MyWorkflow.start()/.execute() via the ambient ctx) must be called from the workflow body, never from inside a step body: the step's checkpoint replays without re-running it, so the nested call would shift every later journal position and corrupt the run on replay. Move the call out of the step.`, "nested_workflow_call");
367
+ this.name = "NestedWorkflowCallError";
368
+ this.runId = runId;
369
+ this.primitive = primitive;
370
+ this.step = step;
371
+ }
372
+ };
337
373
  var GatherError = class extends Error {
338
374
  static {
339
375
  __name(this, "GatherError");
@@ -690,14 +726,19 @@ async function runStepHandler(task, handler) {
690
726
  } : result, "withEvents");
691
727
  try {
692
728
  const logger = createStepLogger(events, Date.now);
693
- const output = await runDurableExecution({
694
- unit: "step",
695
- runId: task.runId,
696
- workflow: void 0,
697
- name: task.name,
698
- seq: task.seq,
699
- attempt: task.attempt
700
- }, async () => runInStepLogger(logger, () => handler(task.input, logger)));
729
+ const output = await runDurableExecution(
730
+ {
731
+ unit: "step",
732
+ runId: task.runId,
733
+ workflow: void 0,
734
+ name: task.name,
735
+ seq: task.seq,
736
+ attempt: task.attempt
737
+ },
738
+ // Outside any ambient workflow ctx: an in-process transport runs the handler on the parent
739
+ // body's async path, but a handler is not the body (see `runOutsideWorkflowCtx`).
740
+ async () => runOutsideWorkflowCtx(() => runInStepLogger(logger, () => handler(task.input, logger)))
741
+ );
701
742
  return withEvents({
702
743
  ...base,
703
744
  status: "completed",
@@ -2247,6 +2288,54 @@ function errorMessage(error2) {
2247
2288
  }
2248
2289
  __name(errorMessage, "errorMessage");
2249
2290
 
2291
+ // src/singleton-admission.ts
2292
+ var SINGLETON_ADMITTED_TAG = "durable:singleton:admitted";
2293
+ var SINGLETON_ACTIVE_STATUSES = [
2294
+ "pending",
2295
+ "running",
2296
+ "suspended",
2297
+ "blocked",
2298
+ "cancelling"
2299
+ ];
2300
+ function singletonAdmissionTags(runs, runId, tag, workflow, limit) {
2301
+ if (!Number.isInteger(limit) || limit < 1) throw new Error("singleton limit must be a positive integer");
2302
+ const active = runs.filter((run) => run.workflow === workflow && SINGLETON_ACTIVE_STATUSES.includes(run.status) && run.tags?.includes(tag));
2303
+ const candidate = active.find((run) => run.id === runId);
2304
+ if (!candidate) return null;
2305
+ if (candidate.tags?.includes(SINGLETON_ADMITTED_TAG)) return candidate.tags;
2306
+ if (candidate.status === "cancelling") return null;
2307
+ const holders = active.filter((run) => run.tags?.includes(SINGLETON_ADMITTED_TAG));
2308
+ const remaining = limit - holders.length;
2309
+ if (remaining <= 0) return null;
2310
+ const waiters = active.filter((run) => !run.tags?.includes(SINGLETON_ADMITTED_TAG)).sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime() || a.id.localeCompare(b.id));
2311
+ if (!waiters.slice(0, remaining).some((run) => run.id === runId)) return null;
2312
+ return [
2313
+ ...candidate.tags ?? [],
2314
+ SINGLETON_ADMITTED_TAG
2315
+ ];
2316
+ }
2317
+ __name(singletonAdmissionTags, "singletonAdmissionTags");
2318
+ async function clearTerminalSingletonAdmission(runId, patch, getRun) {
2319
+ if (!patch.status || !TERMINAL_RUN_STATUSES.includes(patch.status)) return patch;
2320
+ const tags = "tags" in patch ? patch.tags : (await getRun(runId))?.tags;
2321
+ return {
2322
+ ...patch,
2323
+ tags: tags?.filter((tag) => tag !== SINGLETON_ADMITTED_TAG)
2324
+ };
2325
+ }
2326
+ __name(clearTerminalSingletonAdmission, "clearTerminalSingletonAdmission");
2327
+ function singletonAdmissionDenialPatch(runs, runId, tag, workflow, retryWakeAt) {
2328
+ if (retryWakeAt === void 0) return void 0;
2329
+ const candidate = runs.find((run) => run.id === runId && run.workflow === workflow && run.tags?.includes(tag) && SINGLETON_ACTIVE_STATUSES.includes(run.status));
2330
+ if (!candidate || candidate.status === "cancelling") return void 0;
2331
+ return {
2332
+ status: "suspended",
2333
+ wakeAt: retryWakeAt,
2334
+ updatedAt: /* @__PURE__ */ new Date()
2335
+ };
2336
+ }
2337
+ __name(singletonAdmissionDenialPatch, "singletonAdmissionDenialPatch");
2338
+
2250
2339
  // src/singleton-gate.ts
2251
2340
  var SINGLETON_RETRY_MS = 1e3;
2252
2341
  var SINGLETON_RETRY_JITTER_MS = 250;
@@ -2260,6 +2349,9 @@ var SingletonGate = class {
2260
2349
  }
2261
2350
  /** The tag a singleton run carries, so the gate can find others sharing its key. */
2262
2351
  tag(cfg, input) {
2352
+ if (!this.deps.store.tryAdmitSingleton) {
2353
+ throw new Error("Singleton workflows require StateStore.tryAdmitSingleton for atomic durable admission");
2354
+ }
2263
2355
  return `singleton:${cfg.key(input)}`;
2264
2356
  }
2265
2357
  /** Next wake time for a gated run: the base retry delay jittered to avoid a wakeup stampede. */
@@ -2268,7 +2360,7 @@ var SingletonGate = class {
2268
2360
  }
2269
2361
  /**
2270
2362
  * Reject a start that would grow the same-key backlog past `limit + maxQueueDepth` (counting
2271
- * `pending`/`running`/`suspended` runs sharing the key in one scan). No-op when no `maxQueueDepth`
2363
+ * `pending`/`running`/`suspended`/`blocked`/`cancelling` runs sharing the key in one scan). No-op when no `maxQueueDepth`
2272
2364
  * is configured.
2273
2365
  */
2274
2366
  async assertCapacity(workflow, cfg, input) {
@@ -2277,34 +2369,25 @@ var SingletonGate = class {
2277
2369
  const queued = await this.deps.store.listRuns({
2278
2370
  tag: this.tag(cfg, input),
2279
2371
  workflow,
2280
- statuses: [
2281
- "pending",
2282
- "running",
2283
- "suspended",
2284
- "cancelling"
2285
- ]
2372
+ statuses: SINGLETON_ACTIVE_STATUSES
2286
2373
  });
2287
2374
  if (queued.length >= cap) {
2288
2375
  throw new SingletonQueueFullError(workflow, cfg.key(input), cfg.maxQueueDepth);
2289
2376
  }
2290
2377
  }
2291
- /**
2292
- * Whether `run` may run now under its key: it's among the `limit` oldest in-flight (running or
2293
- * suspended) runs sharing the key, by `(createdAt, id)` order. A consistent store gives every
2294
- * instance the same ordering, so admission is race-free + FIFO.
2295
- */
2378
+ /** Preserve durable holders and atomically claim one of the remaining slots. */
2296
2379
  async admit(run, cfg) {
2297
- const inflight = (await this.deps.store.listRuns({
2298
- tag: this.tag(cfg, run.input),
2299
- workflow: run.workflow,
2300
- statuses: [
2301
- "running",
2302
- "suspended",
2303
- "cancelling"
2304
- ]
2305
- })).sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime() || a.id.localeCompare(b.id));
2306
- const idx = inflight.findIndex((r) => r.id === run.id);
2307
- return idx >= 0 && idx < (cfg.limit ?? 1);
2380
+ if (!this.deps.store.tryAdmitSingleton) {
2381
+ throw new Error("Singleton workflows require StateStore.tryAdmitSingleton for atomic durable admission");
2382
+ }
2383
+ const admitted = await this.deps.store.tryAdmitSingleton(run.id, this.tag(cfg, run.input), run.workflow, cfg.limit ?? 1, this.retryWakeAt());
2384
+ if (admitted && !run.tags?.includes(SINGLETON_ADMITTED_TAG)) {
2385
+ run.tags = [
2386
+ ...run.tags ?? [],
2387
+ SINGLETON_ADMITTED_TAG
2388
+ ];
2389
+ }
2390
+ return admitted;
2308
2391
  }
2309
2392
  /**
2310
2393
  * Notify-on-release: a singleton run settled (freeing a slot), so dispatch the oldest gated
@@ -2601,10 +2684,15 @@ var Position = class Position2 {
2601
2684
  rewind() {
2602
2685
  this.seq -= 1;
2603
2686
  }
2687
+ /** The position the NEXT primitive would claim, without claiming it. */
2688
+ peek() {
2689
+ return this.seq + 1;
2690
+ }
2604
2691
  };
2605
2692
  function createWorkflowCtx(host, runId, compensations, workflow = "", searchAttributesSchema) {
2606
2693
  const { store, replay } = host;
2607
2694
  const pos = new Position();
2695
+ const scopeOwner = {};
2608
2696
  const readCheckpoint = /* @__PURE__ */ __name((seq) => {
2609
2697
  const hit = replay?.get(seq);
2610
2698
  if (hit !== void 0) return hit;
@@ -2650,7 +2738,7 @@ function createWorkflowCtx(host, runId, compensations, workflow = "", searchAttr
2650
2738
  };
2651
2739
  const body = /* @__PURE__ */ __name(() => {
2652
2740
  const logger = createStepLogger(events, host.clock);
2653
- return runInStepLogger(logger, () => fn(logger));
2741
+ return runInStepScope(scopeOwner, name, () => runInStepLogger(logger, () => fn(logger)));
2654
2742
  }, "body");
2655
2743
  const output = host.interceptStep ? await host.interceptStep(invocation, body) : await body();
2656
2744
  await host.completeStep({
@@ -2703,7 +2791,7 @@ function createWorkflowCtx(host, runId, compensations, workflow = "", searchAttr
2703
2791
  }
2704
2792
  if (existing && existing.status === "completed") return existing.output;
2705
2793
  return store.transaction(async (tx) => {
2706
- const output = await fn(tx.raw);
2794
+ const output = await runInStepScope(scopeOwner, name, () => fn(tx.raw));
2707
2795
  const cp = instantCheckpoint({
2708
2796
  runId,
2709
2797
  seq: current,
@@ -3212,30 +3300,49 @@ function createWorkflowCtx(host, runId, compensations, workflow = "", searchAttr
3212
3300
  });
3213
3301
  }
3214
3302
  __name(step, "step");
3303
+ const nestedStep = /* @__PURE__ */ __name(() => {
3304
+ const scope = currentStepScope();
3305
+ return scope !== void 0 && scope.owner === scopeOwner ? scope.step : void 0;
3306
+ }, "nestedStep");
3307
+ const guard = /* @__PURE__ */ __name((primitive, fn) => (...args) => {
3308
+ const stepName = nestedStep();
3309
+ if (stepName === void 0) return fn(...args);
3310
+ return (async () => {
3311
+ if (await readCheckpoint(pos.peek())) return fn(...args);
3312
+ throw new NestedWorkflowCallError(runId, primitive, stepName);
3313
+ })();
3314
+ }, "guard");
3315
+ const guardedWebhook = /* @__PURE__ */ __name(() => {
3316
+ const stepName = nestedStep();
3317
+ if (stepName !== void 0 && !replay?.get(pos.peek())) {
3318
+ throw new NestedWorkflowCallError(runId, "webhook", stepName);
3319
+ }
3320
+ return webhook();
3321
+ }, "guardedWebhook");
3215
3322
  return {
3216
3323
  runId,
3217
- step,
3218
- localStep,
3219
- upsertSearchAttributes,
3220
- transaction,
3221
- callEntity,
3222
- signalEntity,
3223
- sleep,
3224
- sleepUntil,
3324
+ step: guard("step", step),
3325
+ localStep: guard("localStep", localStep),
3326
+ upsertSearchAttributes: guard("upsertSearchAttributes", upsertSearchAttributes),
3327
+ transaction: guard("transaction", transaction),
3328
+ callEntity: guard("callEntity", callEntity),
3329
+ signalEntity: guard("signalEntity", signalEntity),
3330
+ sleep: guard("sleep", sleep),
3331
+ sleepUntil: guard("sleepUntil", sleepUntil),
3225
3332
  continueAsNew,
3226
- waitForSignal,
3227
- waitForEvent,
3228
- task,
3229
- child,
3230
- all,
3231
- startChild,
3232
- breakpoint,
3233
- webhook,
3234
- setEvent,
3235
- onUpdate,
3236
- patched,
3237
- now,
3238
- sideEffect
3333
+ waitForSignal: guard("waitForSignal", waitForSignal),
3334
+ waitForEvent: guard("waitForEvent", waitForEvent),
3335
+ task: guard("task", task),
3336
+ child: guard("child", child),
3337
+ all: guard("all", all),
3338
+ startChild: guard("startChild", startChild),
3339
+ breakpoint: guard("breakpoint", breakpoint),
3340
+ webhook: guardedWebhook,
3341
+ setEvent: guard("setEvent", setEvent),
3342
+ onUpdate: guard("onUpdate", onUpdate),
3343
+ patched: guard("patched", patched),
3344
+ now: guard("now", now),
3345
+ sideEffect: guard("sideEffect", sideEffect)
3239
3346
  };
3240
3347
  }
3241
3348
  __name(createWorkflowCtx, "createWorkflowCtx");
@@ -3797,7 +3904,7 @@ var WorkflowEngine = class {
3797
3904
  VERSION_UNDECLARED_TAG
3798
3905
  ] : []
3799
3906
  ];
3800
- const tags = mergeTags(registered.tags, stampedTags);
3907
+ const tags = mergeTags(registered.tags, stampedTags)?.filter((tag) => tag !== SINGLETON_ADMITTED_TAG);
3801
3908
  if (registered.singleton) {
3802
3909
  await this.singletons.assertCapacity(name, registered.singleton, input);
3803
3910
  }
@@ -3837,10 +3944,30 @@ var WorkflowEngine = class {
3837
3944
  getRun(runId) {
3838
3945
  return this.store.getRun(runId);
3839
3946
  }
3840
- async resume(runId) {
3947
+ /**
3948
+ * Re-execute a run's body (replaying its checkpoints). This is the EXPLICIT entry point: besides
3949
+ * driving a live run, it re-drives a `failed` run — the manual retry (prefer {@link requeue}, which
3950
+ * also resets the failure state so replay re-attempts the failed parts). Completed / cancelled /
3951
+ * dead runs are never re-executed. The engine's own implicit wake-ups (signals, step results,
3952
+ * timers, redelivered dispatches) go through {@link resumeImplicit}, which treats `failed` as
3953
+ * terminal too.
3954
+ */
3955
+ resume(runId) {
3956
+ return this.resumeRun(runId, true);
3957
+ }
3958
+ /**
3959
+ * {@link resume} for every IMPLICIT wake-up — a delivered signal/child completion, a late step
3960
+ * result, a due timer, a redelivered dispatch, a queue-slot wake. A `failed` run is terminal to
3961
+ * these: only an explicit retry ({@link requeue} / {@link resume}) may re-drive it. Otherwise a
3962
+ * stale waiter or a late result left behind by a failed run silently re-executes its body.
3963
+ */
3964
+ resumeImplicit(runId) {
3965
+ return this.resumeRun(runId, false);
3966
+ }
3967
+ async resumeRun(runId, allowFailed) {
3841
3968
  const run = await this.store.getRun(runId);
3842
3969
  if (!run) throw new Error(`run ${runId} not found`);
3843
- if (run.status === "cancelled" || run.status === "completed" || run.status === "dead") {
3970
+ if (run.status === "cancelled" || run.status === "completed" || run.status === "dead" || run.status === "failed" && !allowFailed) {
3844
3971
  return {
3845
3972
  runId,
3846
3973
  status: run.status,
@@ -3852,6 +3979,12 @@ var WorkflowEngine = class {
3852
3979
  await this.store.releaseRunLock(runId);
3853
3980
  throw new NamespaceMismatch();
3854
3981
  }
3982
+ if (run.status === "failed" && run.tags?.includes(SINGLETON_ADMITTED_TAG)) {
3983
+ run.tags = run.tags.filter((tag) => tag !== SINGLETON_ADMITTED_TAG);
3984
+ await this.store.updateRun(run.id, {
3985
+ tags: run.tags
3986
+ });
3987
+ }
3855
3988
  const registered = this.workflows.get(versionKey(run.workflow, run.workflowVersion)) ?? await this.findInheritedRegistration(run) ?? await this.resolveRemoteByConvention(run);
3856
3989
  if (!registered) {
3857
3990
  throw new Error(`workflow ${run.workflow}@${run.workflowVersion} is not registered \u2014 keep the prior version deployed so in-flight runs can drain (skew protection)`);
@@ -4001,7 +4134,10 @@ var WorkflowEngine = class {
4001
4134
  /** Track an in-flight execution so {@link drain} can wait for it. */
4002
4135
  track(p) {
4003
4136
  this.inflight.add(p);
4004
- void p.finally(() => this.inflight.delete(p));
4137
+ const done = /* @__PURE__ */ __name(() => {
4138
+ this.inflight.delete(p);
4139
+ }, "done");
4140
+ void p.then(done, done);
4005
4141
  return p;
4006
4142
  }
4007
4143
  /**
@@ -4289,7 +4425,7 @@ var WorkflowEngine = class {
4289
4425
  const nowMs = this.clock();
4290
4426
  const acquired = await this.store.tryLockRun(runId, this.instanceId, nowMs + this.leaseMs, nowMs);
4291
4427
  if (!acquired) return null;
4292
- return this.resume(runId).catch((err) => {
4428
+ return this.resumeImplicit(runId).catch((err) => {
4293
4429
  if (err instanceof NamespaceMismatch) return null;
4294
4430
  throw err;
4295
4431
  });
@@ -4352,7 +4488,8 @@ var WorkflowEngine = class {
4352
4488
  status: "pending",
4353
4489
  error: void 0,
4354
4490
  ...resurrecting ? {
4355
- recoveryAttempts: 0
4491
+ recoveryAttempts: 0,
4492
+ tags: run.tags?.filter((tag) => tag !== SINGLETON_ADMITTED_TAG)
4356
4493
  } : {},
4357
4494
  updatedAt: /* @__PURE__ */ new Date()
4358
4495
  });
@@ -4471,10 +4608,19 @@ var WorkflowEngine = class {
4471
4608
  off();
4472
4609
  resolve(toResult(run));
4473
4610
  }, "finish");
4611
+ const fail = /* @__PURE__ */ __name((error2) => {
4612
+ if (done) return;
4613
+ done = true;
4614
+ if (timer) clearTimeout(timer);
4615
+ if (poller) clearInterval(poller);
4616
+ off();
4617
+ reject(error2);
4618
+ }, "fail");
4474
4619
  const check = /* @__PURE__ */ __name(() => {
4620
+ if (done) return;
4475
4621
  void this.store.getRun(runId).then((run) => {
4476
4622
  if (run && isSettled(run)) finish(run);
4477
- });
4623
+ }, fail);
4478
4624
  }, "check");
4479
4625
  const poller = opts?.until === "terminal" ? (() => {
4480
4626
  const interval = setInterval(check, 250);
@@ -4510,7 +4656,7 @@ var WorkflowEngine = class {
4510
4656
  if (!acquired) continue;
4511
4657
  try {
4512
4658
  const settled = onLocked ? await onLocked(run) : void 0;
4513
- results.push(settled ?? await this.resume(run.id));
4659
+ results.push(settled ?? await this.resumeImplicit(run.id));
4514
4660
  } catch (error2) {
4515
4661
  console.error(`[nestjs-durable] could not resume run ${run.id} (${run.workflow}@${run.workflowVersion}); leaving it for another instance:`, error2);
4516
4662
  }
@@ -4607,7 +4753,7 @@ var WorkflowEngine = class {
4607
4753
  // for an ordinary (non-fan) signal/child await.
4608
4754
  parallelGroup: waiter.parallelGroup
4609
4755
  }));
4610
- return this.resume(waiter.runId).catch((err) => {
4756
+ return this.resumeImplicit(waiter.runId).catch((err) => {
4611
4757
  if (err instanceof NamespaceMismatch) return null;
4612
4758
  throw err;
4613
4759
  });
@@ -4723,7 +4869,7 @@ var WorkflowEngine = class {
4723
4869
  async cancel(runId, opts) {
4724
4870
  const run = await this.store.getRun(runId);
4725
4871
  if (!run) return null;
4726
- if (run.status === "completed" || run.status === "cancelled" || run.status === "dead") {
4872
+ if (TERMINAL_RUN_STATUSES.includes(run.status)) {
4727
4873
  return {
4728
4874
  runId,
4729
4875
  status: run.status,
@@ -4752,7 +4898,7 @@ var WorkflowEngine = class {
4752
4898
  }).catch(() => void 0);
4753
4899
  }
4754
4900
  queueMicrotask(() => {
4755
- void this.resume(runId).then(() => this.notifyCancelled(runId)).catch(() => void 0);
4901
+ void this.resumeImplicit(runId).then(() => this.notifyCancelled(runId)).catch(() => void 0);
4756
4902
  });
4757
4903
  await this.cancelChildren(runId, opts);
4758
4904
  return {
@@ -5257,6 +5403,26 @@ var WorkflowEngine = class {
5257
5403
  durationMs: Date.now() - step.startedAt.getTime()
5258
5404
  });
5259
5405
  }
5406
+ /** Admission persists the wait atomically; never overwrite a cancellation after it returns. */
5407
+ async singletonWaitResult(run) {
5408
+ const current = await this.store.getRun(run.id);
5409
+ await this.store.releaseRunLock(run.id);
5410
+ if (!current) throw new Error(`run ${run.id} was deleted during singleton admission`);
5411
+ if (current.status === "suspended") {
5412
+ this.emit({
5413
+ type: "run.suspended",
5414
+ runId: current.id,
5415
+ workflow: current.workflow,
5416
+ namespace: current.namespace
5417
+ });
5418
+ }
5419
+ return {
5420
+ runId: current.id,
5421
+ status: current.status,
5422
+ output: current.output,
5423
+ error: current.error
5424
+ };
5425
+ }
5260
5426
  async execute(run, fn) {
5261
5427
  const registered = this.workflows.get(versionKey(run.workflow, run.workflowVersion)) ?? await this.findInheritedRegistration(run) ?? await this.resolveRemoteByConvention(run);
5262
5428
  const lockNow = this.clock();
@@ -5426,23 +5592,7 @@ var WorkflowEngine = class {
5426
5592
  });
5427
5593
  }
5428
5594
  if (registered.singleton && !await this.singletons.admit(run, registered.singleton)) {
5429
- const wakeAt = this.singletons.retryWakeAt();
5430
- await this.store.updateRun(run.id, {
5431
- status: "suspended",
5432
- wakeAt,
5433
- updatedAt: /* @__PURE__ */ new Date()
5434
- });
5435
- this.emit({
5436
- type: "run.suspended",
5437
- runId: run.id,
5438
- workflow: run.workflow,
5439
- namespace: run.namespace
5440
- });
5441
- await this.store.releaseRunLock(run.id);
5442
- return {
5443
- runId: run.id,
5444
- status: "suspended"
5445
- };
5595
+ return this.singletonWaitResult(run);
5446
5596
  }
5447
5597
  const wfPlan = await this.planRoute(tenantGroup(sanitizeQueueToken(remote.group), void 0), registered.requires);
5448
5598
  if (wfPlan?.status === "blocked") return this.parkBlocked(run, wfPlan);
@@ -5587,7 +5737,7 @@ var WorkflowEngine = class {
5587
5737
  const checkpoint = await this.store.getCheckpoint(runId, cmd.seq);
5588
5738
  if (checkpoint && checkpoint.status !== "pending") {
5589
5739
  setTimeout(() => {
5590
- void this.resume(runId).catch(() => void 0);
5740
+ void this.resumeImplicit(runId).catch(() => void 0);
5591
5741
  }, 0).unref?.();
5592
5742
  return;
5593
5743
  }
@@ -5929,7 +6079,7 @@ var WorkflowEngine = class {
5929
6079
  kind: "signal",
5930
6080
  output: payload
5931
6081
  }));
5932
- setTimeout(() => void this.resume(run.id).catch(() => void 0), 0);
6082
+ setTimeout(() => void this.resumeImplicit(run.id).catch(() => void 0), 0);
5933
6083
  }, "deliverBuffered");
5934
6084
  const buffered = await this.store.takeBufferedSignal(cmd.signal);
5935
6085
  if (buffered) {
@@ -6056,23 +6206,7 @@ var WorkflowEngine = class {
6056
6206
  });
6057
6207
  }
6058
6208
  if (run.status !== "cancelling" && registered?.singleton && !await this.singletons.admit(run, registered.singleton)) {
6059
- const wakeAt = this.singletons.retryWakeAt();
6060
- await this.store.updateRun(run.id, {
6061
- status: "suspended",
6062
- wakeAt,
6063
- updatedAt: /* @__PURE__ */ new Date()
6064
- });
6065
- this.emit({
6066
- type: "run.suspended",
6067
- runId: run.id,
6068
- workflow: run.workflow,
6069
- namespace: run.namespace
6070
- });
6071
- await this.store.releaseRunLock(run.id);
6072
- return {
6073
- runId: run.id,
6074
- status: "suspended"
6075
- };
6209
+ return this.singletonWaitResult(run);
6076
6210
  }
6077
6211
  const compensations = [];
6078
6212
  const snapshot = await this.store.listCheckpoints(run.id);
@@ -6743,7 +6877,7 @@ var WorkflowEngine = class {
6743
6877
  queueMs: startedAt.getTime() - cp.enqueuedAt.getTime(),
6744
6878
  durationMs: finishedAt.getTime() - startedAt.getTime()
6745
6879
  });
6746
- await this.resume(result.runId).catch((err) => {
6880
+ await this.resumeImplicit(result.runId).catch((err) => {
6747
6881
  if (err instanceof NamespaceMismatch) return null;
6748
6882
  throw err;
6749
6883
  });
@@ -6844,7 +6978,7 @@ var WorkflowEngine = class {
6844
6978
  ...waiters
6845
6979
  ];
6846
6980
  waiters.clear();
6847
- for (const runId of runIds) void this.resume(runId).catch(() => void 0);
6981
+ for (const runId of runIds) void this.resumeImplicit(runId).catch(() => void 0);
6848
6982
  }
6849
6983
  /** Release the flow-control slot a dispatched step held (if any), by its stepId. */
6850
6984
  async releaseQueueSlot(id) {
@@ -7091,6 +7225,8 @@ var CodecStateStore = class {
7091
7225
  }
7092
7226
  inner;
7093
7227
  codec;
7228
+ /** Atomic claims touch only searchable metadata and bypass payload encoding. */
7229
+ tryAdmitSingleton;
7094
7230
  /**
7095
7231
  * Forwarded verbatim — a facet cell counts `status`/`origin`, neither of which the codec touches
7096
7232
  * (it encodes payloads: input/output/error). Bound as a PROPERTY, and only when the inner store
@@ -7123,6 +7259,7 @@ var CodecStateStore = class {
7123
7259
  constructor(inner, codec) {
7124
7260
  this.inner = inner;
7125
7261
  this.codec = codec;
7262
+ if (inner.tryAdmitSingleton) this.tryAdmitSingleton = inner.tryAdmitSingleton.bind(inner);
7126
7263
  const deleteRuns = inner.deleteRuns;
7127
7264
  if (deleteRuns) this.deleteRuns = (ids) => deleteRuns.call(inner, ids);
7128
7265
  const prune = inner.pruneTerminalRuns;
@@ -7575,6 +7712,28 @@ var InMemoryStateStore = class {
7575
7712
  byRun.set(runId, row);
7576
7713
  }
7577
7714
  }
7715
+ async tryAdmitSingleton(runId, tag, workflow, limit, retryWakeAt) {
7716
+ const runs = [
7717
+ ...this.runs.values()
7718
+ ];
7719
+ const tags = singletonAdmissionTags(runs, runId, tag, workflow, limit);
7720
+ if (!tags) {
7721
+ const patch = singletonAdmissionDenialPatch(runs, runId, tag, workflow, retryWakeAt);
7722
+ const candidate = this.runs.get(runId);
7723
+ if (patch && candidate) this.runs.set(runId, {
7724
+ ...candidate,
7725
+ ...patch
7726
+ });
7727
+ return false;
7728
+ }
7729
+ const run = this.runs.get(runId);
7730
+ if (!run) return false;
7731
+ this.runs.set(runId, {
7732
+ ...run,
7733
+ tags
7734
+ });
7735
+ return true;
7736
+ }
7578
7737
  async createRun(run) {
7579
7738
  this.runs.set(run.id, {
7580
7739
  ...run,
@@ -7582,7 +7741,11 @@ var InMemoryStateStore = class {
7582
7741
  });
7583
7742
  this.reindexAttributes(run.id, run.searchAttributes);
7584
7743
  }
7585
- async updateRun(runId, patch) {
7744
+ async updateRun(runId, inputPatch) {
7745
+ const patch = inputPatch.status && TERMINAL_RUN_STATUSES.includes(inputPatch.status) ? {
7746
+ ...inputPatch,
7747
+ tags: ("tags" in inputPatch ? inputPatch.tags : this.runs.get(runId)?.tags)?.filter((tag) => tag !== SINGLETON_ADMITTED_TAG)
7748
+ } : inputPatch;
7586
7749
  const existing = this.runs.get(runId);
7587
7750
  if (!existing) throw new Error(`run ${runId} not found`);
7588
7751
  const next = {
@@ -7996,6 +8159,7 @@ export {
7996
8159
  InMemoryTransport,
7997
8160
  LEGACY_V1_CAPABILITIES,
7998
8161
  LEGACY_V1_PROTOCOL,
8162
+ NestedWorkflowCallError,
7999
8163
  NonDeterminismError,
8000
8164
  QueueController,
8001
8165
  RUN_VALUE_FACET_LIMIT,
@@ -8005,6 +8169,8 @@ export {
8005
8169
  RemoteWorkflowExecutor,
8006
8170
  RemoteWorkflowTimeout,
8007
8171
  RunGateway,
8172
+ SINGLETON_ACTIVE_STATUSES,
8173
+ SINGLETON_ADMITTED_TAG,
8008
8174
  STATE_STORE,
8009
8175
  STATE_STORE_CANONICAL,
8010
8176
  ScheduleAlreadyExistsError,
@@ -8035,6 +8201,7 @@ export {
8035
8201
  capableWorkers,
8036
8202
  classifyWaiterToken,
8037
8203
  clearDurableExecutionWrappers,
8204
+ clearTerminalSingletonAdmission,
8038
8205
  collectMetrics,
8039
8206
  concurrencyTag,
8040
8207
  controlPlaneDescriptor,
@@ -8042,6 +8209,7 @@ export {
8042
8209
  coveredTokens,
8043
8210
  createStepLogger,
8044
8211
  currentStep,
8212
+ currentStepScope,
8045
8213
  currentWorkflowCtx,
8046
8214
  debug,
8047
8215
  describeWorker,
@@ -8083,12 +8251,16 @@ export {
8083
8251
  resolveRunWaiting,
8084
8252
  runDurableExecution,
8085
8253
  runInStepLogger,
8254
+ runInStepScope,
8086
8255
  runInWorkflowCtx,
8256
+ runOutsideWorkflowCtx,
8087
8257
  runSchedules,
8088
8258
  runStepHandler,
8089
8259
  runValueFacetsFromRuns,
8090
8260
  sanitizeQueueToken,
8091
8261
  scheduledRunId,
8262
+ singletonAdmissionDenialPatch,
8263
+ singletonAdmissionTags,
8092
8264
  sqlComparator,
8093
8265
  stepConfigOf,
8094
8266
  stepId,