@dudousxd/nestjs-durable-core 0.80.1 → 0.82.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.cjs CHANGED
@@ -30,12 +30,14 @@ __export(index_exports, {
30
30
  ContinueAsNew: () => ContinueAsNew,
31
31
  DEFAULT_CONCURRENCY_STATUSES: () => DEFAULT_CONCURRENCY_STATUSES,
32
32
  DEFAULT_PARTITION: () => DEFAULT_PARTITION,
33
+ DEFAULT_STALLED_AFTER_MS: () => DEFAULT_STALLED_AFTER_MS,
33
34
  DURABLE_OPTIONS: () => DURABLE_OPTIONS,
34
35
  DURABLE_OPTIONS_CANONICAL: () => DURABLE_OPTIONS_CANONICAL,
35
36
  DURABLE_STEP_CONFIG: () => DURABLE_STEP_CONFIG,
36
37
  DURABLE_STEP_NAME: () => DURABLE_STEP_NAME,
37
38
  DurableWorkflow: () => DurableWorkflow,
38
39
  ENGINE_MINTED_TAG_PREFIXES: () => ENGINE_MINTED_TAG_PREFIXES,
40
+ ENTITY_WORKFLOW: () => ENTITY_WORKFLOW,
39
41
  EVENT_BUFFER_SCAN_LIMIT: () => EVENT_BUFFER_SCAN_LIMIT,
40
42
  EVENT_TOKEN_PREFIX: () => EVENT_TOKEN_PREFIX,
41
43
  Entities: () => Entities,
@@ -52,6 +54,7 @@ __export(index_exports, {
52
54
  NestedWorkflowCallError: () => NestedWorkflowCallError,
53
55
  NonDeterminismError: () => NonDeterminismError,
54
56
  QueueController: () => QueueController,
57
+ RUN_HISTORY_FIXTURE_VERSION: () => RUN_HISTORY_FIXTURE_VERSION,
55
58
  RUN_VALUE_FACET_LIMIT: () => RUN_VALUE_FACET_LIMIT,
56
59
  RUN_VALUE_FACET_SCAN: () => RUN_VALUE_FACET_SCAN,
57
60
  RemoteStepError: () => RemoteStepError,
@@ -61,6 +64,8 @@ __export(index_exports, {
61
64
  RunGateway: () => RunGateway,
62
65
  SINGLETON_ACTIVE_STATUSES: () => SINGLETON_ACTIVE_STATUSES,
63
66
  SINGLETON_ADMITTED_TAG: () => SINGLETON_ADMITTED_TAG,
67
+ SINGLETON_RESERVED_TAGS: () => SINGLETON_RESERVED_TAGS,
68
+ SINGLETON_TRACKED_TAG: () => SINGLETON_TRACKED_TAG,
64
69
  STATE_STORE: () => STATE_STORE,
65
70
  STATE_STORE_CANONICAL: () => STATE_STORE_CANONICAL,
66
71
  SUMMARY_CHILD_LIMIT: () => SUMMARY_CHILD_LIMIT,
@@ -123,6 +128,7 @@ __export(index_exports, {
123
128
  facetOrigin: () => facetOrigin,
124
129
  fanPageBounds: () => fanPageBounds,
125
130
  fanPageEtag: () => fanPageEtag,
131
+ findStalledRuns: () => findStalledRuns,
126
132
  foldedCount: () => foldedCount,
127
133
  handoffHandlerId: () => handoffHandlerId,
128
134
  handoffName: () => handoffName,
@@ -136,6 +142,7 @@ __export(index_exports, {
136
142
  isLegacyDescriptor: () => isLegacyDescriptor,
137
143
  isNonEmptyRunScope: () => isNonEmptyRunScope,
138
144
  isWorkflowControlFlowSignal: () => isWorkflowControlFlowSignal,
145
+ legacySingletonHolders: () => legacySingletonHolders,
139
146
  listSignalWaitersOfRuns: () => listSignalWaitersOfRuns,
140
147
  liveHandoff: () => liveHandoff,
141
148
  log: () => log,
@@ -153,8 +160,10 @@ __export(index_exports, {
153
160
  pageFanFromStore: () => pageFanFromStore,
154
161
  parentRunIdOf: () => parentRunIdOf,
155
162
  parseDuration: () => parseDuration,
163
+ parseWebhookToken: () => parseWebhookToken,
156
164
  partitionOfToken: () => partitionOfToken,
157
165
  planDispatch: () => planDispatch,
166
+ poolNamespace: () => poolNamespace,
158
167
  prevCronFireMs: () => prevCronFireMs,
159
168
  queryCheckpointsOf: () => queryCheckpointsOf,
160
169
  readSearchAttributes: () => readSearchAttributes,
@@ -190,6 +199,8 @@ __export(index_exports, {
190
199
  tenantGroup: () => tenantGroup,
191
200
  useDurableExecution: () => useDurableExecution,
192
201
  warn: () => warn,
202
+ withSingletonTracked: () => withSingletonTracked,
203
+ withoutSingletonMarkers: () => withoutSingletonMarkers,
193
204
  workflowName: () => workflowName
194
205
  });
195
206
  module.exports = __toCommonJS(index_exports);
@@ -1121,6 +1132,9 @@ async function assertConcurrency(store, workflow, quota) {
1121
1132
  __name(assertConcurrency, "assertConcurrency");
1122
1133
 
1123
1134
  // src/entities.ts
1135
+ var NUDGE = {
1136
+ nudge: true
1137
+ };
1124
1138
  var ENTITY_WORKFLOW = "__entity";
1125
1139
  var entityRunId = /* @__PURE__ */ __name((name, key) => `entity:${name}:${key}`, "entityRunId");
1126
1140
  var Entities = class {
@@ -1128,9 +1142,11 @@ var Entities = class {
1128
1142
  __name(this, "Entities");
1129
1143
  }
1130
1144
  host;
1145
+ workflowHost;
1131
1146
  configs = /* @__PURE__ */ new Map();
1132
- constructor(host) {
1147
+ constructor(host, workflowHost) {
1133
1148
  this.host = host;
1149
+ this.workflowHost = workflowHost;
1134
1150
  host.register(ENTITY_WORKFLOW, "1", (ctx, input) => this.run(ctx, input));
1135
1151
  }
1136
1152
  register(name, config) {
@@ -1152,6 +1168,52 @@ var Entities = class {
1152
1168
  }
1153
1169
  });
1154
1170
  }
1171
+ /**
1172
+ * The durable half of a send from INSIDE a workflow (`ctx.callEntity` / `ctx.signalEntity`): write
1173
+ * the op to the entity's signal buffer and return once it is stored, so the caller can checkpoint
1174
+ * after it. The op carries `id`, the caller's stable `<runId>:<seq>`, so if the caller dies between
1175
+ * this write and its checkpoint and sends again on replay, the entity skips the second copy. The
1176
+ * entity's run is also ensured here, awaited (`start` only enqueues, it never runs the body
1177
+ * inline), so an op sent on first contact can't be stranded in a buffer no run will ever read if
1178
+ * the process dies right after. The wake-up ({@link kick}) is separate and may be lost: a busy
1179
+ * entity reads the buffer at its next wait, and a parked one is paired with it by the engine's
1180
+ * `reconcileMs` sweep. Returns `false` on a host without a workflow host.
1181
+ */
1182
+ async enqueue(name, key, op, arg, id, reply) {
1183
+ if (!this.workflowHost) return false;
1184
+ const message = {
1185
+ op,
1186
+ arg,
1187
+ id,
1188
+ ...reply ? {
1189
+ reply
1190
+ } : {}
1191
+ };
1192
+ const runId = entityRunId(name, key);
1193
+ await this.workflowHost.bufferSignal(runId, message);
1194
+ await this.workflowHost.ensureStarted(ENTITY_WORKFLOW, {
1195
+ name,
1196
+ key
1197
+ }, runId);
1198
+ return true;
1199
+ }
1200
+ /**
1201
+ * Make the entity act on what {@link enqueue} buffered: wake its run if it is parked on its wait
1202
+ * (the run itself was ensured by `enqueue`; ensured again here, idempotently, for a caller replay
1203
+ * that only re-kicks). A run that is busy needs nothing: its next wait reads the buffer before
1204
+ * suspending. The wake carries a no-op `nudge`, never the op itself, so the op stays in
1205
+ * the buffer in arrival order and a lost or repeated kick costs at most one empty iteration.
1206
+ */
1207
+ async kick(name, key) {
1208
+ const host = this.workflowHost;
1209
+ if (!host) return;
1210
+ const runId = entityRunId(name, key);
1211
+ await host.ensureStarted(ENTITY_WORKFLOW, {
1212
+ name,
1213
+ key
1214
+ }, runId);
1215
+ await host.wakeWaiter(runId, NUDGE);
1216
+ }
1155
1217
  async signal(name, key, op, arg) {
1156
1218
  await this.dispatch(name, key, op, arg);
1157
1219
  }
@@ -1165,11 +1227,18 @@ var Entities = class {
1165
1227
  if (!config) throw new FatalError(`entity "${name}" is not registered`);
1166
1228
  const token = entityRunId(name, key);
1167
1229
  let state = config.initialState();
1230
+ const seen = /* @__PURE__ */ new Set();
1168
1231
  for (let i = 0; ; i += 1) {
1169
1232
  const msg = await ctx.waitForSignal(token);
1233
+ if (msg.nudge) continue;
1234
+ if (msg.id !== void 0) {
1235
+ if (seen.has(msg.id)) continue;
1236
+ seen.add(msg.id);
1237
+ }
1238
+ const op = msg.op;
1170
1239
  const out = await ctx.localStep(`op:${i}`, async () => {
1171
- const handler = config.handlers[msg.op];
1172
- if (!handler) throw new FatalError(`entity "${name}" has no handler for op "${msg.op}"`);
1240
+ const handler = config.handlers[op];
1241
+ if (!handler) throw new FatalError(`entity "${name}" has no handler for op "${op}"`);
1173
1242
  const result = await handler(state, msg.arg);
1174
1243
  return {
1175
1244
  result,
@@ -1179,8 +1248,9 @@ var Entities = class {
1179
1248
  state = out.state;
1180
1249
  await ctx.setEvent("state", state);
1181
1250
  if (msg.reply) {
1251
+ const reply = msg.reply;
1182
1252
  await ctx.localStep(`reply:${i}`, async () => {
1183
- await this.host.signal(msg.reply, out.result);
1253
+ await this.host.signal(reply, out.result);
1184
1254
  });
1185
1255
  }
1186
1256
  }
@@ -2104,6 +2174,10 @@ function checkpointNameKind(name) {
2104
2174
  return "step";
2105
2175
  }
2106
2176
  __name(checkpointNameKind, "checkpointNameKind");
2177
+ function poolNamespace(namespace) {
2178
+ return namespace === "" ? "default" : namespace;
2179
+ }
2180
+ __name(poolNamespace, "poolNamespace");
2107
2181
  var TERMINAL_RUN_STATUSES = [
2108
2182
  "completed",
2109
2183
  "failed",
@@ -2235,6 +2309,19 @@ var WEBHOOK_PREFIX = "wh:";
2235
2309
  var CHILD_PREFIX = "child:";
2236
2310
  var BREAKPOINT_PREFIX = "bp:";
2237
2311
  var EVENT_PREFIX = EVENT_TOKEN_PREFIX;
2312
+ function parseWebhookToken(token) {
2313
+ if (!token.startsWith(WEBHOOK_PREFIX)) return null;
2314
+ const rest = token.slice(WEBHOOK_PREFIX.length);
2315
+ const at = rest.lastIndexOf(":");
2316
+ if (at <= 0) return null;
2317
+ const seqText = rest.slice(at + 1);
2318
+ if (!/^\d+$/.test(seqText)) return null;
2319
+ return {
2320
+ runId: rest.slice(0, at),
2321
+ seq: Number(seqText)
2322
+ };
2323
+ }
2324
+ __name(parseWebhookToken, "parseWebhookToken");
2238
2325
  function classifyWaiterToken(token) {
2239
2326
  if (token.startsWith(WEBHOOK_PREFIX)) return {
2240
2327
  on: "webhook",
@@ -2772,6 +2859,11 @@ __name(errorMessage, "errorMessage");
2772
2859
 
2773
2860
  // src/singleton-admission.ts
2774
2861
  var SINGLETON_ADMITTED_TAG = "durable:singleton:admitted";
2862
+ var SINGLETON_TRACKED_TAG = "durable:singleton:tracked";
2863
+ var SINGLETON_RESERVED_TAGS = [
2864
+ SINGLETON_ADMITTED_TAG,
2865
+ SINGLETON_TRACKED_TAG
2866
+ ];
2775
2867
  var SINGLETON_ACTIVE_STATUSES = [
2776
2868
  "pending",
2777
2869
  "running",
@@ -2779,6 +2871,33 @@ var SINGLETON_ACTIVE_STATUSES = [
2779
2871
  "blocked",
2780
2872
  "cancelling"
2781
2873
  ];
2874
+ var LEGACY_SCAN_STATUSES = [
2875
+ "running",
2876
+ "suspended",
2877
+ "cancelling"
2878
+ ];
2879
+ var byAdmissionOrder = /* @__PURE__ */ __name((a, b) => a.createdAt.getTime() - b.createdAt.getTime() || a.id.localeCompare(b.id), "byAdmissionOrder");
2880
+ function withoutSingletonMarkers(tags) {
2881
+ return tags?.filter((tag) => !SINGLETON_RESERVED_TAGS.includes(tag));
2882
+ }
2883
+ __name(withoutSingletonMarkers, "withoutSingletonMarkers");
2884
+ function withSingletonTracked(tags) {
2885
+ const current = tags ?? [];
2886
+ if (current.includes(SINGLETON_ADMITTED_TAG) || current.includes(SINGLETON_TRACKED_TAG)) {
2887
+ return current;
2888
+ }
2889
+ return [
2890
+ ...current,
2891
+ SINGLETON_TRACKED_TAG
2892
+ ];
2893
+ }
2894
+ __name(withSingletonTracked, "withSingletonTracked");
2895
+ function legacySingletonHolders(active, runId, limit) {
2896
+ const scanned = active.filter((run) => LEGACY_SCAN_STATUSES.includes(run.status)).sort(byAdmissionOrder);
2897
+ const window = new Set(scanned.slice(0, limit).map((run) => run.id));
2898
+ return scanned.filter((run) => run.id !== runId && !run.tags?.includes(SINGLETON_ADMITTED_TAG) && (!run.tags?.includes(SINGLETON_TRACKED_TAG) || window.has(run.id)));
2899
+ }
2900
+ __name(legacySingletonHolders, "legacySingletonHolders");
2782
2901
  function singletonAdmissionTags(runs, runId, tag, workflow, limit) {
2783
2902
  if (!Number.isInteger(limit) || limit < 1) throw new Error("singleton limit must be a positive integer");
2784
2903
  const active = runs.filter((run) => run.workflow === workflow && SINGLETON_ACTIVE_STATUSES.includes(run.status) && run.tags?.includes(tag));
@@ -2787,12 +2906,13 @@ function singletonAdmissionTags(runs, runId, tag, workflow, limit) {
2787
2906
  if (candidate.tags?.includes(SINGLETON_ADMITTED_TAG)) return candidate.tags;
2788
2907
  if (candidate.status === "cancelling") return null;
2789
2908
  const holders = active.filter((run) => run.tags?.includes(SINGLETON_ADMITTED_TAG));
2790
- const remaining = limit - holders.length;
2909
+ const legacy = new Set(legacySingletonHolders(active, runId, limit).map((run) => run.id));
2910
+ const remaining = limit - holders.length - legacy.size;
2791
2911
  if (remaining <= 0) return null;
2792
- 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));
2912
+ const waiters = active.filter((run) => !run.tags?.includes(SINGLETON_ADMITTED_TAG) && !legacy.has(run.id)).sort(byAdmissionOrder);
2793
2913
  if (!waiters.slice(0, remaining).some((run) => run.id === runId)) return null;
2794
2914
  return [
2795
- ...candidate.tags ?? [],
2915
+ ...(candidate.tags ?? []).filter((t) => t !== SINGLETON_TRACKED_TAG),
2796
2916
  SINGLETON_ADMITTED_TAG
2797
2917
  ];
2798
2918
  }
@@ -2802,7 +2922,7 @@ async function clearTerminalSingletonAdmission(runId, patch, getRun) {
2802
2922
  const tags = "tags" in patch ? patch.tags : (await getRun(runId))?.tags;
2803
2923
  return {
2804
2924
  ...patch,
2805
- tags: tags?.filter((tag) => tag !== SINGLETON_ADMITTED_TAG)
2925
+ tags: withoutSingletonMarkers(tags)
2806
2926
  };
2807
2927
  }
2808
2928
  __name(clearTerminalSingletonAdmission, "clearTerminalSingletonAdmission");
@@ -2813,6 +2933,7 @@ function singletonAdmissionDenialPatch(runs, runId, tag, workflow, retryWakeAt)
2813
2933
  return {
2814
2934
  status: "suspended",
2815
2935
  wakeAt: retryWakeAt,
2936
+ tags: withSingletonTracked(candidate.tags),
2816
2937
  updatedAt: /* @__PURE__ */ new Date()
2817
2938
  };
2818
2939
  }
@@ -2865,7 +2986,7 @@ var SingletonGate = class {
2865
2986
  const admitted = await this.deps.store.tryAdmitSingleton(run.id, this.tag(cfg, run.input), run.workflow, cfg.limit ?? 1, this.retryWakeAt());
2866
2987
  if (admitted && !run.tags?.includes(SINGLETON_ADMITTED_TAG)) {
2867
2988
  run.tags = [
2868
- ...run.tags ?? [],
2989
+ ...(run.tags ?? []).filter((tag) => tag !== SINGLETON_TRACKED_TAG),
2869
2990
  SINGLETON_ADMITTED_TAG
2870
2991
  ];
2871
2992
  }
@@ -2899,6 +3020,66 @@ var SingletonGate = class {
2899
3020
  }
2900
3021
  };
2901
3022
 
3023
+ // src/stalled-runs.ts
3024
+ var DEFAULT_STALLED_AFTER_MS = 15 * 6e4;
3025
+ async function findStalledRuns(store, options = {}) {
3026
+ const now = options.now ?? Date.now();
3027
+ const thresholdMs = options.thresholdMs ?? DEFAULT_STALLED_AFTER_MS;
3028
+ const scan = Math.max(1, options.scan ?? 1e3);
3029
+ const pageSize = Math.min(200, scan);
3030
+ const stalled = [];
3031
+ for (let offset = 0; offset < scan; offset += pageSize) {
3032
+ const limit = Math.min(pageSize, scan - offset);
3033
+ const page = await store.listRuns({
3034
+ statuses: [
3035
+ "running",
3036
+ "suspended"
3037
+ ],
3038
+ ...options.namespace !== void 0 ? {
3039
+ namespace: options.namespace
3040
+ } : {},
3041
+ limit,
3042
+ offset
3043
+ });
3044
+ for (const run of page) {
3045
+ const ageMs = now - run.updatedAt.getTime();
3046
+ if (ageMs < thresholdMs) continue;
3047
+ const info2 = await stalledInfo(store, run, now, thresholdMs, ageMs);
3048
+ if (info2) stalled.push(info2);
3049
+ }
3050
+ if (page.length < limit) break;
3051
+ }
3052
+ return stalled;
3053
+ }
3054
+ __name(findStalledRuns, "findStalledRuns");
3055
+ async function stalledInfo(store, run, now, thresholdMs, ageMs) {
3056
+ let stalePending = null;
3057
+ for (const cp of await store.listCheckpoints(run.id)) {
3058
+ if (cp.kind !== "remote" || cp.status !== "pending") continue;
3059
+ const stepAgeMs = now - cp.enqueuedAt.getTime();
3060
+ if (stepAgeMs < thresholdMs) continue;
3061
+ if (cp.wakeAt != null && cp.wakeAt > now) continue;
3062
+ if (!stalePending || stepAgeMs > stalePending.ageMs) {
3063
+ stalePending = {
3064
+ seq: cp.seq,
3065
+ name: cp.name,
3066
+ attempts: cp.attempts,
3067
+ ageMs: stepAgeMs,
3068
+ leaseUntil: cp.wakeAt ?? null
3069
+ };
3070
+ }
3071
+ }
3072
+ const wakeForever = run.status === "suspended" && run.wakeAt == null;
3073
+ if (!wakeForever && !stalePending) return null;
3074
+ return {
3075
+ run,
3076
+ ageMs,
3077
+ wakeForever,
3078
+ stalePending
3079
+ };
3080
+ }
3081
+ __name(stalledInfo, "stalledInfo");
3082
+
2902
3083
  // src/transport-pool.ts
2903
3084
  var TransportPool = class {
2904
3085
  static {
@@ -3760,30 +3941,82 @@ function createWorkflowCtx(host, runId, compensations, workflow = "", searchAttr
3760
3941
  }));
3761
3942
  return id;
3762
3943
  }, "startChild");
3944
+ const sendEntityOp = /* @__PURE__ */ __name(async (name, key, op, arg, seq, reply) => await host.enqueueEntityOp?.(name, key, op, arg, `${runId}:${seq}`, reply) ?? false, "sendEntityOp");
3763
3945
  const callEntity = /* @__PURE__ */ __name(async (name, key, op, arg) => {
3764
3946
  const current = pos.next();
3765
- const existing = await readCheckpoint(current);
3947
+ let existing = await readCheckpoint(current);
3766
3948
  if (existing && existing.status === "completed") return existing.output;
3767
3949
  const reply = `entityreply:${runId}:${current}`;
3950
+ const intentName = `signal:${reply}`;
3951
+ if (existing && existing.name !== intentName) {
3952
+ throw new NonDeterminismError(runId, current, intentName, existing.name);
3953
+ }
3954
+ if (existing && replay?.has(current)) {
3955
+ existing = await store.getCheckpoint(runId, current) ?? existing;
3956
+ if (existing.status === "completed") return existing.output;
3957
+ }
3958
+ const markSent = /* @__PURE__ */ __name(() => store.saveCheckpointIfPending?.(instantCheckpoint({
3959
+ runId,
3960
+ seq: current,
3961
+ name: intentName,
3962
+ kind: "signal",
3963
+ status: "pending",
3964
+ output: {
3965
+ sent: true
3966
+ }
3967
+ })) ?? Promise.resolve(false), "markSent");
3968
+ if (existing) {
3969
+ const buffered = await consumeBuffered(reply, current);
3970
+ if (buffered) {
3971
+ await store.removeSignalWaiter({
3972
+ token: reply,
3973
+ runId,
3974
+ seq: current
3975
+ });
3976
+ return buffered.value;
3977
+ }
3978
+ if (existing.output?.sent !== true) {
3979
+ if (await sendEntityOp(name, key, op, arg, current, reply)) await markSent();
3980
+ }
3981
+ host.kickEntity?.(name, key);
3982
+ throw new WorkflowSuspended();
3983
+ }
3768
3984
  await store.putSignalWaiter({
3769
3985
  token: reply,
3770
3986
  runId,
3771
3987
  seq: current
3772
3988
  });
3773
- host.signalEntity?.(name, key, op, arg, reply);
3989
+ if (!host.enqueueEntityOp) {
3990
+ host.signalEntity?.(name, key, op, arg, reply);
3991
+ throw new WorkflowSuspended();
3992
+ }
3993
+ await writeCheckpoint(instantCheckpoint({
3994
+ runId,
3995
+ seq: current,
3996
+ name: intentName,
3997
+ kind: "signal",
3998
+ status: "pending",
3999
+ output: {
4000
+ sent: false
4001
+ }
4002
+ }));
4003
+ if (await sendEntityOp(name, key, op, arg, current, reply)) await markSent();
4004
+ host.kickEntity?.(name, key);
3774
4005
  throw new WorkflowSuspended();
3775
4006
  }, "callEntity");
3776
4007
  const signalEntity = /* @__PURE__ */ __name(async (name, key, op, arg) => {
3777
4008
  const current = pos.next();
3778
4009
  const existing = await readCheckpoint(current);
3779
4010
  if (existing && existing.status === "completed") return;
3780
- host.signalEntity?.(name, key, op, arg);
4011
+ const durable = await sendEntityOp(name, key, op, arg, current);
4012
+ if (!durable) host.signalEntity?.(name, key, op, arg);
3781
4013
  await writeCheckpoint(instantCheckpoint({
3782
4014
  runId,
3783
4015
  seq: current,
3784
4016
  name: `entitysig:${name}:${key}`,
3785
4017
  kind: "local"
3786
4018
  }));
4019
+ if (durable) host.kickEntity?.(name, key);
3787
4020
  }, "signalEntity");
3788
4021
  const breakpoint = /* @__PURE__ */ __name(async (label) => {
3789
4022
  const current = pos.next();
@@ -3851,17 +4084,15 @@ function createWorkflowCtx(host, runId, compensations, workflow = "", searchAttr
3851
4084
  const deadlineSeq = timeoutMs == null ? -1 : pos.next();
3852
4085
  const existing = await readCheckpoint(current);
3853
4086
  if (existing && existing.status === "completed") return existing.output;
3854
- if (timeoutMs == null) {
3855
- await store.putSignalWaiter({
4087
+ const buffered = await consumeBuffered(token, current);
4088
+ if (buffered) return buffered.value;
4089
+ const deadline = timeoutMs == null ? void 0 : await stampDeadline(deadlineSeq, token, timeoutMs);
4090
+ if (deadline !== void 0 && host.clock() >= deadline) {
4091
+ await store.removeSignalWaiter({
3856
4092
  token,
3857
4093
  runId,
3858
4094
  seq: current
3859
- });
3860
- throw new WorkflowSuspended();
3861
- }
3862
- const deadline = await stampDeadline(deadlineSeq, token, timeoutMs);
3863
- if (host.clock() >= deadline) {
3864
- await store.takeSignalWaiter(token).catch(() => void 0);
4095
+ }).catch(() => void 0);
3865
4096
  throw new SignalTimeoutError(token, timeoutMs);
3866
4097
  }
3867
4098
  await store.putSignalWaiter({
@@ -3869,6 +4100,15 @@ function createWorkflowCtx(host, runId, compensations, workflow = "", searchAttr
3869
4100
  runId,
3870
4101
  seq: current
3871
4102
  });
4103
+ const lateBuffered = await consumeBuffered(token, current);
4104
+ if (lateBuffered) {
4105
+ await store.removeSignalWaiter({
4106
+ token,
4107
+ runId,
4108
+ seq: current
4109
+ });
4110
+ return lateBuffered.value;
4111
+ }
3872
4112
  throw new WorkflowSuspended(deadline);
3873
4113
  }, "wait");
3874
4114
  return {
@@ -4060,6 +4300,12 @@ var SETTLE_ELIGIBLE_STATUSES = [
4060
4300
  "failed",
4061
4301
  "cancelling"
4062
4302
  ];
4303
+ var TIMEOUT_SWEPT_STATUSES = [
4304
+ "pending",
4305
+ "running",
4306
+ "suspended",
4307
+ "blocked"
4308
+ ];
4063
4309
  var SUSPEND_ELIGIBLE_STATUSES = SETTLE_ELIGIBLE_STATUSES.filter((status) => status !== "cancelling");
4064
4310
  var RECOVERY_BATCH = 500;
4065
4311
  var NamespaceMismatch = class NamespaceMismatch2 extends Error {
@@ -4082,6 +4328,12 @@ var WorkflowEngine = class {
4082
4328
  instanceId;
4083
4329
  namespace;
4084
4330
  leaseMs;
4331
+ /**
4332
+ * Leases held by this `instanceId` that expire strictly before this epoch ms were taken by a
4333
+ * PREVIOUS process with the same id (every lease this one takes or renews expires at
4334
+ * `clock() + leaseMs`, so at or after it). See {@link orphanedRuns}.
4335
+ */
4336
+ ownLeaseCutoffMs;
4085
4337
  maxRecoveryAttempts;
4086
4338
  remoteAdvanceSilenceMs;
4087
4339
  reconcileMs;
@@ -4185,6 +4437,18 @@ var WorkflowEngine = class {
4185
4437
  stepQueue = /* @__PURE__ */ new Map();
4186
4438
  /** Executions currently in flight, so a graceful shutdown can wait for them to settle. */
4187
4439
  inflight = /* @__PURE__ */ new Set();
4440
+ /**
4441
+ * The run lease of each execution in flight on THIS instance, by run id — renewed while it runs
4442
+ * and checked by every settle (the zombie fence, see {@link ExecutionLease}). Also what `drain()`
4443
+ * releases when its budget runs out.
4444
+ */
4445
+ executionLeases = /* @__PURE__ */ new Map();
4446
+ /** Pager hooks for stranded runs (see {@link onStalled}). */
4447
+ stalledListeners = /* @__PURE__ */ new Set();
4448
+ /** Runs already paged about, by id → the `updatedAt` of the paged episode (see {@link sweepStalled}). */
4449
+ notifiedStalled = /* @__PURE__ */ new Map();
4450
+ lastStalledSweepAt = Number.NEGATIVE_INFINITY;
4451
+ stalledAfterMs;
4188
4452
  draining = false;
4189
4453
  /** Wait already spent on this shutdown before `drain()` (see `beginDrain`). */
4190
4454
  drainSpentMs = 0;
@@ -4203,11 +4467,14 @@ var WorkflowEngine = class {
4203
4467
  this.admission = deps.admission ?? new InMemoryAdmissionBackend(this.clock);
4204
4468
  this.admission.onFreed?.((queue) => this.wakeQueueWaiters(queue));
4205
4469
  this.instanceId = deps.instanceId ?? globalThis.crypto.randomUUID();
4206
- this.namespace = deps.namespace;
4470
+ this.namespace = poolNamespace(deps.namespace);
4207
4471
  this.leaseMs = deps.leaseMs ?? 3e4;
4472
+ this.ownLeaseCutoffMs = this.clock() + this.leaseMs;
4208
4473
  this.maxRecoveryAttempts = deps.maxRecoveryAttempts;
4209
4474
  this.remoteAdvanceSilenceMs = deps.remoteAdvanceSilenceMs;
4210
4475
  this.reconcileMs = deps.reconcileMs === void 0 ? 3e5 : deps.reconcileMs || void 0;
4476
+ const stalledAfter = deps.stalledAfterMs;
4477
+ this.stalledAfterMs = stalledAfter !== void 0 && Number.isFinite(stalledAfter) ? Math.max(6e4, stalledAfter) : DEFAULT_STALLED_AFTER_MS;
4211
4478
  const batch = deps.timerBatchSize;
4212
4479
  this.timerBatchSize = batch !== void 0 && Number.isFinite(batch) && batch >= 1 ? Math.floor(batch) : 500;
4213
4480
  this.eventBufferTtlMs = deps.eventBufferTtlMs || void 0;
@@ -4324,7 +4591,11 @@ var WorkflowEngine = class {
4324
4591
  }
4325
4592
  });
4326
4593
  this.accumulators = new EventAccumulators(this);
4327
- this.entities = new Entities(this);
4594
+ this.entities = new Entities(this, {
4595
+ bufferSignal: /* @__PURE__ */ __name((token, payload) => this.store.bufferSignal(token, payload), "bufferSignal"),
4596
+ wakeWaiter: /* @__PURE__ */ __name((token, payload) => this.wakeSignalWaiter(token, payload), "wakeWaiter"),
4597
+ ensureStarted: /* @__PURE__ */ __name((workflow, input, runId) => this.start(workflow, input, runId), "ensureStarted")
4598
+ });
4328
4599
  }
4329
4600
  /**
4330
4601
  * Register a **durable entity** (a virtual object): a keyed actor whose `handlers` run **serialized
@@ -4547,6 +4818,64 @@ var WorkflowEngine = class {
4547
4818
  this.deadListeners.add(listener);
4548
4819
  return () => this.deadListeners.delete(listener);
4549
4820
  }
4821
+ /**
4822
+ * Register a pager hook for STRANDED runs: an in-flight run untouched past `stalledAfterMs` that is
4823
+ * either suspended with NO wake timer (wake-forever: nothing will ever re-drive it) or parked on an
4824
+ * old pending remote step with no live lease (dispatched and, most likely, lost). The timer poller
4825
+ * sweeps for these ({@link sweepStalled}, throttled to one pass a minute) and notifies each episode
4826
+ * ONCE per instance: a run is paged again only after it makes progress and strands again. Wire your
4827
+ * alerting here like {@link onDead}. A listener that throws never breaks the sweep. Returns an
4828
+ * unsubscribe function.
4829
+ */
4830
+ onStalled(listener) {
4831
+ this.stalledListeners.add(listener);
4832
+ return () => this.stalledListeners.delete(listener);
4833
+ }
4834
+ /**
4835
+ * Detect stranded in-flight runs and notify the {@link onStalled} listeners. Self-throttled to one
4836
+ * pass a minute, skipped while draining, and a no-op without listeners — call it every tick (the
4837
+ * NestJS timer poller does). Scoped to this engine's `namespace` (an operator engine with no
4838
+ * namespace sweeps every namespace). Returns the runs notified THIS pass.
4839
+ */
4840
+ async sweepStalled(now = this.clock()) {
4841
+ if (this.stalledListeners.size === 0 || this.draining) return [];
4842
+ if (now - this.lastStalledSweepAt < 6e4) return [];
4843
+ this.lastStalledSweepAt = now;
4844
+ const notified = [];
4845
+ for (const info2 of await this.listStalledRuns({
4846
+ now
4847
+ })) {
4848
+ const episode = info2.run.updatedAt.getTime();
4849
+ if (this.notifiedStalled.get(info2.run.id) === episode) continue;
4850
+ this.notifiedStalled.set(info2.run.id, episode);
4851
+ if (this.notifiedStalled.size > 1e3) this.notifiedStalled.clear();
4852
+ for (const listener of this.stalledListeners) {
4853
+ try {
4854
+ listener(info2);
4855
+ } catch {
4856
+ }
4857
+ }
4858
+ notified.push(info2);
4859
+ }
4860
+ return notified;
4861
+ }
4862
+ /**
4863
+ * The stranded runs right now, without notifying anyone: the query behind {@link sweepStalled} and
4864
+ * the CLI's `durable inspect --stale`. A `running`/`suspended` run qualifies when it has been
4865
+ * untouched for `thresholdMs` (default `stalledAfterMs`) AND either it is suspended with no wake
4866
+ * timer, or its oldest pending remote step is older than the threshold with no lease in the future
4867
+ * (a step with a `timeoutMs`, a heartbeat or `remoteRedispatchMs` keeps a lease; a long
4868
+ * `ctx.sleep` has a future `wakeAt`; neither is stranded). Scans up to `scan` candidates (default
4869
+ * 1000), newest first, in this engine's namespace.
4870
+ */
4871
+ async listStalledRuns(options = {}) {
4872
+ return findStalledRuns(this.store, {
4873
+ now: options.now ?? this.clock(),
4874
+ thresholdMs: options.thresholdMs ?? this.stalledAfterMs,
4875
+ scan: options.scan,
4876
+ namespace: this.namespace
4877
+ });
4878
+ }
4550
4879
  notifyDead(run) {
4551
4880
  for (const fn of this.deadListeners) {
4552
4881
  try {
@@ -4600,7 +4929,7 @@ var WorkflowEngine = class {
4600
4929
  // tenant-suffixed group the run will dispatch to — an operator starting a tenant's run via the
4601
4930
  // `onStartRun` wire (opts.namespace = tenant) must find the live `<workflow>@<tenant>` group,
4602
4931
  // not the bare group. Without this the start-run entry path can't reach a suffixed-only worker.
4603
- namespace: opts?.namespace ?? this.namespace,
4932
+ namespace: poolNamespace(opts?.namespace) ?? this.namespace,
4604
4933
  input,
4605
4934
  createdAt: /* @__PURE__ */ new Date(),
4606
4935
  updatedAt: /* @__PURE__ */ new Date()
@@ -4637,7 +4966,7 @@ var WorkflowEngine = class {
4637
4966
  VERSION_UNDECLARED_TAG
4638
4967
  ] : []
4639
4968
  ];
4640
- const tags = mergeTags(registered.tags, stampedTags)?.filter((tag) => tag !== SINGLETON_ADMITTED_TAG);
4969
+ const tags = mergeTags(registered.tags, stampedTags)?.filter((tag) => !SINGLETON_RESERVED_TAGS.includes(tag));
4641
4970
  if (registered.singleton) {
4642
4971
  await this.singletons.assertCapacity(name, registered.singleton, input);
4643
4972
  }
@@ -4651,7 +4980,7 @@ var WorkflowEngine = class {
4651
4980
  ...delayed ? {
4652
4981
  wakeAt: startAtMs
4653
4982
  } : {},
4654
- namespace: opts?.namespace ?? this.namespace,
4983
+ namespace: poolNamespace(opts?.namespace) ?? this.namespace,
4655
4984
  // DERIVED from the registration, exactly like `namespace` is derived from this engine — there is
4656
4985
  // deliberately no `opts.origin`, so a caller cannot label its run as another package's work.
4657
4986
  // `undefined` when the registration carried none (remote/convention/synthesized child, or a
@@ -4705,6 +5034,11 @@ var WorkflowEngine = class {
4705
5034
  getRun(runId) {
4706
5035
  return this.store.getRun(runId);
4707
5036
  }
5037
+ /** A run's full checkpoint timeline, ordered by seq. A thin pass-through to the store (it is what
5038
+ * the testing kit's `captureHistory` reads, next to {@link getRun}). */
5039
+ listCheckpoints(runId) {
5040
+ return this.store.listCheckpoints(runId);
5041
+ }
4708
5042
  /**
4709
5043
  * Re-execute a run's body (replaying its checkpoints). This is the EXPLICIT entry point: besides
4710
5044
  * driving a live run, it re-drives a `failed` run — the manual retry (prefer {@link requeue}, which
@@ -4790,6 +5124,8 @@ var WorkflowEngine = class {
4790
5124
  * diagnosis.
4791
5125
  */
4792
5126
  async releaseLeaseBestEffort(runId) {
5127
+ if (this.isFenced(runId)) return;
5128
+ this.handOffLease(runId);
4793
5129
  try {
4794
5130
  await this.store.releaseRunLock(runId);
4795
5131
  } catch (err) {
@@ -4833,12 +5169,12 @@ var WorkflowEngine = class {
4833
5169
  error: run.error
4834
5170
  };
4835
5171
  }
4836
- if (this.namespace !== void 0 && run.namespace !== void 0 && run.namespace !== this.namespace) {
5172
+ if (this.namespace !== void 0 && run.namespace !== void 0 && poolNamespace(run.namespace) !== this.namespace) {
4837
5173
  await this.store.releaseRunLock(runId);
4838
5174
  throw new NamespaceMismatch();
4839
5175
  }
4840
- if (run.status === "failed" && run.tags?.includes(SINGLETON_ADMITTED_TAG)) {
4841
- run.tags = run.tags.filter((tag) => tag !== SINGLETON_ADMITTED_TAG);
5176
+ if (run.status === "failed" && (run.tags?.includes(SINGLETON_ADMITTED_TAG) || run.tags?.includes(SINGLETON_TRACKED_TAG))) {
5177
+ run.tags = withoutSingletonMarkers(run.tags);
4842
5178
  await this.store.updateRun(run.id, {
4843
5179
  tags: run.tags
4844
5180
  });
@@ -5039,17 +5375,47 @@ var WorkflowEngine = class {
5039
5375
  rearm();
5040
5376
  });
5041
5377
  this.drainRearms.add(rearm);
5378
+ let settled = false;
5042
5379
  try {
5043
5380
  await Promise.race([
5044
5381
  Promise.allSettled([
5045
5382
  ...this.inflight
5046
- ]),
5383
+ ]).then(() => {
5384
+ settled = true;
5385
+ }),
5047
5386
  timer
5048
5387
  ]);
5049
5388
  } finally {
5050
5389
  this.drainRearms.delete(rearm);
5051
5390
  clearTimeout(timeout);
5052
5391
  }
5392
+ if (!settled) await this.releaseInflightLeases();
5393
+ }
5394
+ /**
5395
+ * The drain budget ran out with executions still in flight: release their recovery leases so
5396
+ * another instance re-drives those runs on its next recovery pass, instead of waiting out
5397
+ * `leaseMs` after this process exits. Each still-running turn is FENCED first, so whatever it
5398
+ * computes after this point is never written over the new owner's work (see settleRun). Its
5399
+ * completed steps are checkpointed, so the new owner replays them; a step still executing here
5400
+ * may run again there, exactly as after a crash. The release is owner-scoped where the store
5401
+ * supports it (only a lease this instance still holds is cleared). Best effort: never throws.
5402
+ */
5403
+ async releaseInflightLeases() {
5404
+ const released = [];
5405
+ for (const [runId, lease] of [
5406
+ ...this.executionLeases
5407
+ ]) {
5408
+ if (lease.lost || lease.handedOff || lease.done) continue;
5409
+ lease.lost = true;
5410
+ try {
5411
+ await this.store.releaseRunLock(runId, this.instanceId);
5412
+ released.push(runId);
5413
+ } catch {
5414
+ }
5415
+ }
5416
+ if (released.length > 0) {
5417
+ console.warn(`[nestjs-durable] drain timed out with ${released.length} run(s) still executing; released their leases so another instance recovers them now: ${released.join(", ")}`);
5418
+ }
5053
5419
  }
5054
5420
  /**
5055
5421
  * Cancel in-flight runs that have outlived their workflow's `executionTimeout`. Call it from the
@@ -5057,6 +5423,15 @@ var WorkflowEngine = class {
5057
5423
  * `execution_timeout` error (terminal, so a late step result can't resurrect it), AND its children
5058
5424
  * are cascaded exactly as an explicit {@link cancel} cascades them.
5059
5425
  *
5426
+ * WHICH RUNS. Every run not yet finished, counted from `createdAt`: `pending` and `blocked` as well
5427
+ * as `running` and `suspended`. A run nobody picks up (no worker on its queue) or nobody can run (no
5428
+ * worker with its capabilities) is as stuck as one that hangs mid-body, and the cap is the backstop
5429
+ * for exactly that — leaving them out meant a run that never started lived forever while one that
5430
+ * started and hung was cut off. Cancelling one a worker is picking up at that moment is safe: the
5431
+ * worker's settle only writes over a live status ({@link SETTLE_ELIGIBLE_STATUSES}), so it can't
5432
+ * bring the run back. `cancelling` is left alone on purpose — a compensating cancel's saga undo is
5433
+ * running, it ends `cancelled` by itself, and cutting it short would skip the undo.
5434
+ *
5060
5435
  * WHY THE TERMINAL WRITE IS DIRECT, not a call to {@link cancel}. Two reasons, both still standing:
5061
5436
  * the run must land with the `execution_timeout` error `cancel` knows nothing about (`cancel` writes
5062
5437
  * a plain `cancelled`, and the code is what a dashboard/alert distinguishes a timeout by), and this
@@ -5069,8 +5444,8 @@ var WorkflowEngine = class {
5069
5444
  * children kept running with the parent gone and nothing pointing at them — a silent orphan you
5070
5445
  * could only find by reading the runs table by hand. {@link cancelChildren} is the SAME recursive
5071
5446
  * walk `cancel` uses, so children of children go too, and it inherits that path's two guards:
5072
- * `cancel` returns early on a run that is already `completed`/`cancelled`/`dead` (a child that beat
5073
- * the sweep to the finish is never clobbered, and a re-cancel is a no-op), and it writes the
5447
+ * `cancel` returns early on a run that is already terminal (a child that beat the sweep to the
5448
+ * finish is never clobbered, and a re-cancel is a no-op), and it writes the
5074
5449
  * parent's terminal status BEFORE recursing, so a cyclic parent→child graph terminates on the
5075
5450
  * second visit instead of looping. Two pollers sweeping the same run concurrently therefore write
5076
5451
  * the same terminal state twice and cascade into already-cancelled children — idempotent, not
@@ -5086,18 +5461,11 @@ var WorkflowEngine = class {
5086
5461
  if (this.draining) return;
5087
5462
  if (reg.executionTimeoutMs == null) continue;
5088
5463
  const deadline = now - reg.executionTimeoutMs;
5089
- const inflight = [
5090
- ...await this.store.listRuns({
5091
- workflow: reg.name,
5092
- status: "running",
5093
- namespace: this.namespace
5094
- }),
5095
- ...await this.store.listRuns({
5096
- workflow: reg.name,
5097
- status: "suspended",
5098
- namespace: this.namespace
5099
- })
5100
- ];
5464
+ const inflight = await this.store.listRuns({
5465
+ workflow: reg.name,
5466
+ statuses: TIMEOUT_SWEPT_STATUSES,
5467
+ namespace: this.namespace
5468
+ });
5101
5469
  for (const run of inflight) {
5102
5470
  if (this.draining) return;
5103
5471
  if (run.createdAt.getTime() > deadline) continue;
@@ -5130,48 +5498,103 @@ var WorkflowEngine = class {
5130
5498
  const results = [];
5131
5499
  for (const listed of await this.orphanedRuns(nowMs)) {
5132
5500
  if (this.draining) break;
5133
- const acquired = await this.store.tryLockRun(listed.id, this.instanceId, nowMs + this.leaseMs, nowMs);
5134
- if (!acquired) continue;
5135
- const run = await this.store.getRun(listed.id);
5136
- if (!run || run.status !== "running" && run.status !== "cancelling") {
5137
- await this.store.releaseRunLock(listed.id);
5138
- continue;
5139
- }
5140
- if (run.status === "running") {
5141
- const dispatched = await this.inFlightRemoteStep(run.id);
5142
- if (dispatched) {
5143
- await this.store.updateRun(run.id, {
5144
- status: "suspended",
5145
- wakeAt: this.reconcileWakeAt(dispatched.wakeAt ?? void 0),
5146
- updatedAt: /* @__PURE__ */ new Date()
5147
- });
5148
- await this.store.releaseRunLock(run.id);
5149
- results.push({
5150
- runId: run.id,
5151
- status: "suspended"
5152
- });
5153
- continue;
5154
- }
5155
- }
5156
- const settled = await this.countRecovery(run);
5157
- if (settled) {
5158
- results.push(settled);
5159
- continue;
5160
- }
5161
- await this.store.releaseRunLock(run.id);
5162
- if (run.status !== "cancelling") {
5501
+ const outcome = this.isOwnLiveLease(listed, nowMs) ? await this.reclaimOwnLease(listed, nowMs) : await this.recoverOrphan(listed, nowMs);
5502
+ if (!outcome) continue;
5503
+ if (outcome.dispatch) await this.runDispatcher.dispatch(outcome.result.runId);
5504
+ results.push(outcome.result);
5505
+ }
5506
+ return results;
5507
+ }
5508
+ /** A run whose still-live lease is held by this very `instanceId` (see {@link reclaimOwnLease}). */
5509
+ isOwnLiveLease(run, nowMs) {
5510
+ return run.lockedBy === this.instanceId && (run.lockedUntil ?? 0) > nowMs;
5511
+ }
5512
+ /**
5513
+ * Recover a run whose lease is still live but held by THIS `instanceId` and predates this process
5514
+ * (listed by {@link orphanedRuns}): the previous process with this stable id died without a
5515
+ * graceful shutdown (OOM, SIGKILL), so nothing is renewing that lease and waiting for it to expire
5516
+ * only delays the run. The lease is taken back with `renewRunLock` (a compare-and-set on the
5517
+ * owner; `tryLockRun` refuses a live lease), so the lease alone cannot fence a live execution in
5518
+ * THIS process from its own recovery: the in-process run gate does. A run with a
5519
+ * gate is executing (or about to) here and is skipped; otherwise a gate is held while the run is
5520
+ * recovered, so a resume landing meanwhile queues behind the recovery instead of running beside it.
5521
+ * The gate is released before the re-dispatch, which may resume the run on this instance.
5522
+ */
5523
+ async reclaimOwnLease(listed, nowMs) {
5524
+ if (this.runGates.has(listed.id)) return void 0;
5525
+ let release;
5526
+ const tail = new Promise((resolve) => {
5527
+ release = resolve;
5528
+ });
5529
+ const gate = {
5530
+ tail
5531
+ };
5532
+ this.runGates.set(listed.id, gate);
5533
+ this.closeGateAfter(listed.id, gate, tail);
5534
+ let outcome;
5535
+ try {
5536
+ const fresh = await this.store.getRun(listed.id);
5537
+ const liveHere = fresh?.lockedBy === this.instanceId && (fresh.lockedUntil ?? 0) > nowMs && (fresh.lockedUntil ?? 0) >= this.ownLeaseCutoffMs;
5538
+ if (fresh && !liveHere) outcome = await this.recoverOrphan(listed, nowMs, true);
5539
+ } finally {
5540
+ release(outcome?.result ?? {
5541
+ runId: listed.id,
5542
+ status: listed.status
5543
+ });
5544
+ }
5545
+ return outcome;
5546
+ }
5547
+ /**
5548
+ * One run of a {@link recoverIncomplete} pass: take its lease and decide on it. Resolves the
5549
+ * pass's result for it and whether it must be re-dispatched, or `undefined` when it was not ours.
5550
+ * `ownLease`: the run's live lease is this instance id's own, left by a previous process (see
5551
+ * {@link reclaimOwnLease}), so it is taken back by owner rather than acquired as a free one.
5552
+ */
5553
+ async recoverOrphan(listed, nowMs, ownLease = false) {
5554
+ const acquired = ownLease ? await this.store.renewRunLock(listed.id, this.instanceId, nowMs + this.leaseMs) : await this.store.tryLockRun(listed.id, this.instanceId, nowMs + this.leaseMs, nowMs);
5555
+ if (!acquired) return void 0;
5556
+ const run = await this.store.getRun(listed.id);
5557
+ if (!run || run.status !== "running" && run.status !== "cancelling") {
5558
+ await this.store.releaseRunLock(listed.id);
5559
+ return void 0;
5560
+ }
5561
+ if (run.status === "running") {
5562
+ const dispatched = await this.inFlightRemoteStep(run.id);
5563
+ if (dispatched) {
5163
5564
  await this.store.updateRun(run.id, {
5164
- status: "pending",
5565
+ status: "suspended",
5566
+ wakeAt: this.reconcileWakeAt(dispatched.wakeAt ?? void 0),
5165
5567
  updatedAt: /* @__PURE__ */ new Date()
5166
5568
  });
5569
+ await this.store.releaseRunLock(run.id);
5570
+ return {
5571
+ result: {
5572
+ runId: run.id,
5573
+ status: "suspended"
5574
+ },
5575
+ dispatch: false
5576
+ };
5167
5577
  }
5168
- await this.runDispatcher.dispatch(run.id);
5169
- results.push({
5170
- runId: run.id,
5171
- status: run.status === "cancelling" ? "cancelling" : "pending"
5578
+ }
5579
+ const settled = await this.countRecovery(run);
5580
+ if (settled) return {
5581
+ result: settled,
5582
+ dispatch: false
5583
+ };
5584
+ await this.store.releaseRunLock(run.id);
5585
+ if (run.status !== "cancelling") {
5586
+ await this.store.updateRun(run.id, {
5587
+ status: "pending",
5588
+ updatedAt: /* @__PURE__ */ new Date()
5172
5589
  });
5173
5590
  }
5174
- return results;
5591
+ return {
5592
+ result: {
5593
+ runId: run.id,
5594
+ status: run.status === "cancelling" ? "cancelling" : "pending"
5595
+ },
5596
+ dispatch: true
5597
+ };
5175
5598
  }
5176
5599
  /**
5177
5600
  * The runs {@link recoverIncomplete} should try to reclaim: incomplete runs whose lease is free or
@@ -5181,12 +5604,20 @@ var WorkflowEngine = class {
5181
5604
  * ({@link StateStore.listOrphanedRuns}, bounded by {@link RECOVERY_BATCH}); else the full list is
5182
5605
  * filtered here, which still spares the probes. Shuffled, like the due-timer pass, so instances
5183
5606
  * recovering at once spread over the batch instead of contending for the same leases.
5607
+ *
5608
+ * Also listed: runs still leased by THIS `instanceId` with a lease taken before this process
5609
+ * booted ({@link ownLeaseCutoffMs}). With a stable `instanceId` (pinned to the pod identity), that
5610
+ * is the previous process, killed without a graceful shutdown; see {@link reclaimOwnLease}.
5184
5611
  */
5185
5612
  async orphanedRuns(nowMs) {
5613
+ const ownedBy = {
5614
+ owner: this.instanceId,
5615
+ lockedBefore: this.ownLeaseCutoffMs
5616
+ };
5186
5617
  if (this.store.listOrphanedRuns) {
5187
- return shuffle(await this.store.listOrphanedRuns(nowMs, RECOVERY_BATCH, this.namespace));
5618
+ return shuffle(await this.store.listOrphanedRuns(nowMs, RECOVERY_BATCH, this.namespace, ownedBy));
5188
5619
  }
5189
- return shuffle((await this.store.listIncompleteRuns(this.namespace)).filter((run) => run.lockedUntil == null || run.lockedUntil <= nowMs).sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime()).slice(0, RECOVERY_BATCH));
5620
+ return shuffle((await this.store.listIncompleteRuns(this.namespace)).filter((run) => run.lockedUntil == null || run.lockedUntil <= nowMs || run.lockedBy === ownedBy.owner && run.lockedUntil < ownedBy.lockedBefore).sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime()).slice(0, RECOVERY_BATCH));
5190
5621
  }
5191
5622
  /**
5192
5623
  * The remote step this run dispatched and is still awaiting a result for, if any — the signature
@@ -5229,6 +5660,7 @@ var WorkflowEngine = class {
5229
5660
  error: error2,
5230
5661
  recoveryAttempts: attempts
5231
5662
  });
5663
+ this.notifyParent(run.id, failedCompletion(error2));
5232
5664
  return {
5233
5665
  runId: run.id,
5234
5666
  status: "dead",
@@ -5435,7 +5867,7 @@ var WorkflowEngine = class {
5435
5867
  error: void 0,
5436
5868
  ...resurrecting ? {
5437
5869
  recoveryAttempts: 0,
5438
- tags: run.tags?.filter((tag) => tag !== SINGLETON_ADMITTED_TAG)
5870
+ tags: withoutSingletonMarkers(run.tags)
5439
5871
  } : {},
5440
5872
  updatedAt: /* @__PURE__ */ new Date()
5441
5873
  });
@@ -5847,6 +6279,16 @@ var WorkflowEngine = class {
5847
6279
  return this.deliverSignal(lateWaiter, reclaimed.payload);
5848
6280
  }
5849
6281
  /**
6282
+ * Wake the run parked on `token` with `payload`, if there is one; otherwise do nothing — unlike
6283
+ * {@link signal}, it never buffers. For a wake-up whose real payload is ALREADY buffered on the
6284
+ * token (an entity op, see `Entities.kick`): a run that isn't parked reads that buffer at its next
6285
+ * wait, so there is nothing to keep.
6286
+ */
6287
+ async wakeSignalWaiter(token, payload) {
6288
+ const waiter = await this.store.takeSignalWaiter(token);
6289
+ return waiter ? this.deliverSignal(waiter, payload) : null;
6290
+ }
6291
+ /**
5850
6292
  * Ensure a run exists for `runId`, then deliver a signal to it — atomically race-free thanks to
5851
6293
  * signal buffering: if the run is new (or busy / not yet waiting), the signal is buffered and
5852
6294
  * consumed when it reaches `waitForSignal(token)`. The canonical **durable-entity / accumulator**
@@ -6442,9 +6884,31 @@ var WorkflowEngine = class {
6442
6884
  });
6443
6885
  }
6444
6886
  /** Admission persists the wait atomically; never overwrite a cancellation after it returns. */
6887
+ /**
6888
+ * A run's first execution: `pending` -> `running`. A singleton run not yet admitted is stamped
6889
+ * {@link SINGLETON_TRACKED_TAG} in the same write, BEFORE it competes for a slot, so a run this
6890
+ * replica is about to admit or deny is never mistaken for a holder admitted by a replica older
6891
+ * than 0.73 (see `legacySingletonHolders`).
6892
+ */
6893
+ async markStarted(run, singleton) {
6894
+ const tags = singleton ? withSingletonTracked(run.tags) : run.tags;
6895
+ const retag = tags !== run.tags;
6896
+ await this.store.updateRun(run.id, {
6897
+ status: "running",
6898
+ ...retag ? {
6899
+ tags
6900
+ } : {},
6901
+ updatedAt: /* @__PURE__ */ new Date()
6902
+ });
6903
+ run.status = "running";
6904
+ if (retag) run.tags = tags;
6905
+ }
6445
6906
  async singletonWaitResult(run) {
6446
6907
  const current = await this.store.getRun(run.id);
6447
- await this.store.releaseRunLock(run.id);
6908
+ if (!this.isFenced(run.id)) {
6909
+ this.handOffLease(run.id);
6910
+ await this.store.releaseRunLock(run.id);
6911
+ }
6448
6912
  if (!current) throw new Error(`run ${run.id} was deleted during singleton admission`);
6449
6913
  if (current.status === "suspended") {
6450
6914
  this.emit({
@@ -6472,10 +6936,7 @@ var WorkflowEngine = class {
6472
6936
  };
6473
6937
  }
6474
6938
  }
6475
- const renew = setInterval(() => {
6476
- void this.store.renewRunLock(run.id, this.instanceId, this.clock() + this.leaseMs).catch(() => void 0);
6477
- }, Math.max(50, Math.floor(this.leaseMs / 2)));
6478
- renew.unref?.();
6939
+ const held = this.holdExecutionLease(run.id);
6479
6940
  try {
6480
6941
  const result = registered?.remote ? await this.runRemoteExecution(run, registered) : await this.runExecution(run, fn, registered);
6481
6942
  if (registered?.singleton && (result.status === "completed" || result.status === "failed" || result.status === "cancelled" || result.status === "dead")) {
@@ -6483,10 +6944,59 @@ var WorkflowEngine = class {
6483
6944
  }
6484
6945
  return result;
6485
6946
  } finally {
6486
- clearInterval(renew);
6947
+ held.stop();
6487
6948
  }
6488
6949
  }
6489
6950
  /**
6951
+ * Renew `runId`'s lease for as long as this execution holds it, and register it in
6952
+ * {@link executionLeases} so settles can check the zombie fence and `drain()` can release it.
6953
+ *
6954
+ * A renewal that returns `false` means the lease is gone: this turn stalled past `leaseMs` and
6955
+ * another instance reclaimed the run, so this executor is now a zombie. It is FENCED (`lost`): its
6956
+ * settles degrade to an echo of the store's state instead of clobbering the new owner's writes, and
6957
+ * its own lease release is skipped (it would free the new owner's lease). A renewal that THROWS (a
6958
+ * transient store error) is not evidence of a takeover, so it stays swallowed and the next
6959
+ * interval retries. A lease this execution released on purpose (`handedOff`) is not renewed, and a
6960
+ * renewal resolving after the execution ended (`done`) fences nothing.
6961
+ */
6962
+ holdExecutionLease(runId) {
6963
+ const lease = {
6964
+ lost: false,
6965
+ handedOff: false,
6966
+ done: false
6967
+ };
6968
+ this.executionLeases.set(runId, lease);
6969
+ const renew = setInterval(() => {
6970
+ if (lease.handedOff || lease.lost || lease.done) return;
6971
+ void this.store.renewRunLock(runId, this.instanceId, this.clock() + this.leaseMs).then((renewed) => {
6972
+ if (renewed || lease.handedOff || lease.lost || lease.done) return;
6973
+ lease.lost = true;
6974
+ console.warn(`[nestjs-durable] lost the lease of run ${runId} while executing it (another instance took it over after it expired); this instance will not write the run's outcome`);
6975
+ }).catch(() => void 0);
6976
+ }, Math.max(50, Math.floor(this.leaseMs / 2)));
6977
+ renew.unref?.();
6978
+ return {
6979
+ lease,
6980
+ stop: /* @__PURE__ */ __name(() => {
6981
+ clearInterval(renew);
6982
+ lease.done = true;
6983
+ if (this.executionLeases.get(runId) === lease) this.executionLeases.delete(runId);
6984
+ }, "stop")
6985
+ };
6986
+ }
6987
+ /** Whether `runId`'s execution on this instance lost its lease (see {@link holdExecutionLease}). */
6988
+ isFenced(runId) {
6989
+ return this.executionLeases.get(runId)?.lost === true;
6990
+ }
6991
+ /**
6992
+ * Mark `runId`'s lease as given away on purpose by its own execution (released mid-turn), so a
6993
+ * renewal that now fails is not read as a takeover.
6994
+ */
6995
+ handOffLease(runId) {
6996
+ const lease = this.executionLeases.get(runId);
6997
+ if (lease) lease.handedOff = true;
6998
+ }
6999
+ /**
6490
7000
  * Advance a remote (cross-SDK) workflow one turn: hand its history to the executor (which dispatches
6491
7001
  * a workflow task to the worker and awaits its replay) and apply the decision. Mirrors
6492
7002
  * {@link runExecution}'s settle/suspend; the lease is held by {@link execute}. The result that lands
@@ -6703,6 +7213,7 @@ ${run.workflowVersion}`;
6703
7213
  * keeps its own lease handling (the TS executor's `finally`, the remote path's caller).
6704
7214
  */
6705
7215
  async settleRun(run, outcome) {
7216
+ if (this.isFenced(run.id)) return this.echoCurrentStatus(run.id);
6706
7217
  const updatedAt = /* @__PURE__ */ new Date();
6707
7218
  if (outcome.kind === "completed") {
6708
7219
  const applied2 = await (this.store.updateRunIf ?? this.updateRunIfFallback).call(this.store, run.id, SETTLE_ELIGIBLE_STATUSES, {
@@ -6783,6 +7294,7 @@ ${run.workflowVersion}`;
6783
7294
  }
6784
7295
  async runRemoteExecution(run, registered) {
6785
7296
  if (run.status === "cancelling") {
7297
+ if (this.isFenced(run.id)) return this.echoCurrentStatus(run.id);
6786
7298
  const error2 = {
6787
7299
  message: "cancelled"
6788
7300
  };
@@ -6791,6 +7303,7 @@ ${run.workflowVersion}`;
6791
7303
  error: error2,
6792
7304
  updatedAt: /* @__PURE__ */ new Date()
6793
7305
  });
7306
+ if (this.isFenced(run.id)) return this.echoCurrentStatus(run.id);
6794
7307
  this.emit({
6795
7308
  type: "run.failed",
6796
7309
  runId: run.id,
@@ -6815,11 +7328,7 @@ ${run.workflowVersion}`;
6815
7328
  }
6816
7329
  const remote = registered.remote;
6817
7330
  if (run.status === "pending") {
6818
- await this.store.updateRun(run.id, {
6819
- status: "running",
6820
- updatedAt: /* @__PURE__ */ new Date()
6821
- });
6822
- run.status = "running";
7331
+ await this.markStarted(run, registered?.singleton !== void 0);
6823
7332
  this.emit({
6824
7333
  type: "run.started",
6825
7334
  runId: run.id,
@@ -6841,6 +7350,7 @@ ${run.workflowVersion}`;
6841
7350
  }
6842
7351
  const history = await this.remoteHistory(run.id, checkpoints);
6843
7352
  if (remote.executor.dispatch) {
7353
+ if (this.isFenced(run.id)) return this.echoCurrentStatus(run.id);
6844
7354
  const taskId = `${run.id}:wf:${globalThis.crypto.randomUUID().slice(0, 8)}`;
6845
7355
  const silenceMs = this.remoteAdvanceSilenceMs;
6846
7356
  const wakeAt = silenceMs != null ? this.clock() + silenceMs : void 0;
@@ -6856,6 +7366,7 @@ ${run.workflowVersion}`;
6856
7366
  workflow: run.workflow,
6857
7367
  namespace: run.namespace
6858
7368
  });
7369
+ this.handOffLease(run.id);
6859
7370
  await this.store.releaseRunLock(run.id);
6860
7371
  await remote.executor.dispatch(run, history, taskId);
6861
7372
  return {
@@ -6884,6 +7395,7 @@ ${run.workflowVersion}`;
6884
7395
  message: err instanceof Error ? err.message : String(err),
6885
7396
  stack: err instanceof Error ? err.stack : void 0
6886
7397
  };
7398
+ if (this.isFenced(run.id)) return this.echoCurrentStatus(run.id);
6887
7399
  await this.store.updateRun(run.id, {
6888
7400
  status: "failed",
6889
7401
  error: error2,
@@ -6912,6 +7424,7 @@ ${run.workflowVersion}`;
6912
7424
  * — the caller has already ensured this is the decision for the currently-awaited turn.
6913
7425
  */
6914
7426
  async applyDecision(run, decision) {
7427
+ if (this.isFenced(run.id)) return this.echoCurrentStatus(run.id);
6915
7428
  if (decision.status === "completed") {
6916
7429
  await this.applyCommands(run, decision.commands);
6917
7430
  return this.settleRun(run, {
@@ -7381,6 +7894,7 @@ ${run.workflowVersion}`;
7381
7894
  * cancel flag stays too, re-derived from the status on any later re-drive regardless.
7382
7895
  */
7383
7896
  async compensateAndCancel(run, compensations, replay) {
7897
+ if (this.isFenced(run.id)) return this.echoCurrentStatus(run.id);
7384
7898
  const pending = await this.unwindCompensations(run, compensations, replay);
7385
7899
  if (pending) return {
7386
7900
  runId: run.id,
@@ -7435,11 +7949,7 @@ ${run.workflowVersion}`;
7435
7949
  async runExecutionTurn(run, fn, registered) {
7436
7950
  if (run.status === "cancelling") this.cancelRequested.add(run.id);
7437
7951
  if (run.status === "pending") {
7438
- await this.store.updateRun(run.id, {
7439
- status: "running",
7440
- updatedAt: /* @__PURE__ */ new Date()
7441
- });
7442
- run.status = "running";
7952
+ await this.markStarted(run, registered?.singleton !== void 0);
7443
7953
  this.emit({
7444
7954
  type: "run.started",
7445
7955
  runId: run.id,
@@ -7476,6 +7986,7 @@ ${run.workflowVersion}`;
7476
7986
  });
7477
7987
  } catch (err) {
7478
7988
  if (err instanceof ContinueAsNew) {
7989
+ if (this.isFenced(run.id)) return await this.echoCurrentStatus(run.id);
7479
7990
  if (this.cancelRequested.has(run.id)) {
7480
7991
  return await this.compensateAndCancel(run, compensations, replay);
7481
7992
  }
@@ -7815,6 +8326,17 @@ ${run.workflowVersion}`;
7815
8326
  signalEntity: /* @__PURE__ */ __name((name, key, op, arg, reply) => {
7816
8327
  queueMicrotask(() => void this.entities.dispatch(name, key, op, arg, reply).catch(() => void 0));
7817
8328
  }, "signalEntity"),
8329
+ // The durable half of a workflow-side entity send: awaited inside the turn (a store write
8330
+ // only, never an inline entity turn), so the ctx can checkpoint after it. See Entities.enqueue.
8331
+ enqueueEntityOp: /* @__PURE__ */ __name((name, key, op, arg, id, reply) => this.entities.enqueue(name, key, op, arg, id, reply), "enqueueEntityOp"),
8332
+ // Start/wake the entity for what enqueueEntityOp buffered — deferred like signalEntity above,
8333
+ // so it never re-enters a still-executing turn. A failed wake loses nothing: the op stays
8334
+ // buffered for the entity's next wait (and recovery's buffered-entity wake).
8335
+ kickEntity: /* @__PURE__ */ __name((name, key) => {
8336
+ queueMicrotask(() => void this.entities.kick(name, key).catch((err) => {
8337
+ console.warn(`[nestjs-durable] waking entity "${name}" key "${key}" failed (its op stays buffered for the next wake):`, err);
8338
+ }));
8339
+ }, "kickEntity"),
7818
8340
  interceptStep: /* @__PURE__ */ __name((invocation, body) => this.interceptStep(invocation, body), "interceptStep")
7819
8341
  };
7820
8342
  }
@@ -7916,6 +8438,7 @@ ${run.workflowVersion}`;
7916
8438
  * own `finally` also releases (the run-execution path) is safe.
7917
8439
  */
7918
8440
  async parkBlocked(run, blocked) {
8441
+ if (this.isFenced(run.id)) return this.echoCurrentStatus(run.id);
7919
8442
  const error2 = {
7920
8443
  message: blocked.reason,
7921
8444
  code: blocked.code,
@@ -7956,7 +8479,7 @@ ${run.workflowVersion}`;
7956
8479
  "blocked"
7957
8480
  ]
7958
8481
  });
7959
- return blocked.filter((r) => (this.namespace === void 0 || r.namespace === void 0 || r.namespace === this.namespace) && r.wakeAt !== void 0 && r.wakeAt <= nowMs);
8482
+ return blocked.filter((r) => (this.namespace === void 0 || r.namespace === void 0 || poolNamespace(r.namespace) === this.namespace) && r.wakeAt !== void 0 && r.wakeAt <= nowMs);
7960
8483
  }
7961
8484
  async callRemote(runId, seq, step, input, queue, transport, replay, admission, routing) {
7962
8485
  const group = stepGroup(step, routing?.namespace);
@@ -8495,17 +9018,14 @@ ${run.workflowVersion}`;
8495
9018
  const run = await this.store.getRun(decision.runId);
8496
9019
  if (!run || run.awaitingDecisionTaskId !== decision.taskId) return;
8497
9020
  if (run.status === "cancelled" || run.status === "completed" || run.status === "dead") return;
8498
- if (this.namespace !== void 0 && run.namespace !== void 0 && run.namespace !== this.namespace) {
9021
+ if (this.namespace !== void 0 && run.namespace !== void 0 && poolNamespace(run.namespace) !== this.namespace) {
8499
9022
  return;
8500
9023
  }
8501
9024
  const nowMs = this.clock();
8502
9025
  if (!await this.store.tryLockRun(decision.runId, this.instanceId, nowMs + this.leaseMs, nowMs)) {
8503
9026
  return;
8504
9027
  }
8505
- const renew = setInterval(() => {
8506
- void this.store.renewRunLock(decision.runId, this.instanceId, this.clock() + this.leaseMs).catch(() => void 0);
8507
- }, Math.max(50, Math.floor(this.leaseMs / 2)));
8508
- renew.unref?.();
9028
+ const held = this.holdExecutionLease(decision.runId);
8509
9029
  try {
8510
9030
  const fresh = await this.store.getRun(decision.runId);
8511
9031
  if (!fresh || fresh.awaitingDecisionTaskId !== decision.taskId) return;
@@ -8520,8 +9040,9 @@ ${run.workflowVersion}`;
8520
9040
  fresh.awaitingDecisionTaskId = void 0;
8521
9041
  await this.applyDecision(fresh, decision);
8522
9042
  } finally {
8523
- clearInterval(renew);
8524
- await this.store.releaseRunLock(decision.runId);
9043
+ const fenced = held.lease.lost;
9044
+ held.stop();
9045
+ if (!fenced) await this.store.releaseRunLock(decision.runId);
8525
9046
  }
8526
9047
  }
8527
9048
  /** Earliest clock time a suspended turn's deadline is worth LOOKING at again (`runId` → clock ms) —
@@ -8717,6 +9238,9 @@ function childIdsOf(checkpoints) {
8717
9238
  }
8718
9239
  __name(childIdsOf, "childIdsOf");
8719
9240
 
9241
+ // src/run-history.ts
9242
+ var RUN_HISTORY_FIXTURE_VERSION = 1;
9243
+
8720
9244
  // src/run-facets.ts
8721
9245
  function facetOrigin(origin) {
8722
9246
  return origin?.trim() ? origin : null;
@@ -9270,13 +9794,14 @@ var CodecStateStore = class {
9270
9794
  }
9271
9795
  inner;
9272
9796
  codec;
9797
+ extended;
9273
9798
  /** Atomic claims touch only searchable metadata and bypass payload encoding. */
9274
9799
  tryAdmitSingleton;
9275
9800
  /**
9276
9801
  * Forwarded verbatim — a facet cell counts `status`/`origin`, neither of which the codec touches
9277
- * (it encodes payloads: input/output/error). Bound as a PROPERTY, and only when the inner store
9278
- * actually aggregates, so `store.runFacets?.()` still reports absence through the wrapper instead
9279
- * of resolving to a silently wrong empty result.
9802
+ * (it encodes payloads: input/output, plus events/error under `extended` coverage). Bound as a
9803
+ * PROPERTY, and only when the inner store actually aggregates, so `store.runFacets?.()` still
9804
+ * reports absence through the wrapper instead of resolving to a silently wrong empty result.
9280
9805
  */
9281
9806
  runFacets;
9282
9807
  /**
@@ -9328,14 +9853,15 @@ var CodecStateStore = class {
9328
9853
  updateSchedule;
9329
9854
  deleteSchedule;
9330
9855
  listSchedules;
9331
- constructor(inner, codec) {
9856
+ constructor(inner, codec, options = {}) {
9332
9857
  this.inner = inner;
9333
9858
  this.codec = codec;
9859
+ this.extended = options.coverage === "extended";
9334
9860
  if (inner.tryAdmitSingleton) this.tryAdmitSingleton = inner.tryAdmitSingleton.bind(inner);
9335
9861
  const deleteRuns = inner.deleteRuns;
9336
9862
  if (deleteRuns) this.deleteRuns = (ids) => deleteRuns.call(inner, ids);
9337
9863
  const orphans = inner.listOrphanedRuns;
9338
- if (orphans) this.listOrphanedRuns = async (nowMs, limit, namespace) => (await orphans.call(inner, nowMs, limit, namespace)).map((r) => this.decRun(r));
9864
+ if (orphans) this.listOrphanedRuns = async (nowMs, limit, namespace, ownedBy) => (await orphans.call(inner, nowMs, limit, namespace, ownedBy)).map((r) => this.decRun(r));
9339
9865
  const updateIf = inner.updateRunIf;
9340
9866
  if (updateIf) this.updateRunIf = (runId, expected, patch) => updateIf.call(inner, runId, expected, this.encRunPatch(patch));
9341
9867
  const saveIfPending = inner.saveCheckpointIfPending;
@@ -9388,32 +9914,52 @@ var CodecStateStore = class {
9388
9914
  return v === void 0 ? void 0 : this.codec.decode(v);
9389
9915
  }
9390
9916
  encRun(run) {
9391
- return {
9917
+ const next = {
9392
9918
  ...run,
9393
9919
  input: this.enc(run.input),
9394
9920
  output: this.enc(run.output)
9395
9921
  };
9922
+ if (this.extended && run.error !== void 0) {
9923
+ next.error = this.enc(run.error);
9924
+ }
9925
+ return next;
9396
9926
  }
9397
9927
  decRun(run) {
9398
- return run && {
9928
+ if (!run) return run;
9929
+ const next = {
9399
9930
  ...run,
9400
9931
  input: this.dec(run.input),
9401
9932
  output: this.dec(run.output)
9402
9933
  };
9934
+ if (this.extended && run.error !== void 0) {
9935
+ next.error = this.dec(run.error);
9936
+ }
9937
+ return next;
9403
9938
  }
9404
9939
  encCp(cp) {
9405
- return {
9940
+ const next = {
9406
9941
  ...cp,
9407
9942
  input: this.enc(cp.input),
9408
9943
  output: this.enc(cp.output)
9409
9944
  };
9945
+ if (this.extended) {
9946
+ if (cp.events !== void 0) next.events = this.enc(cp.events);
9947
+ if (cp.error !== void 0) next.error = this.enc(cp.error);
9948
+ }
9949
+ return next;
9410
9950
  }
9411
9951
  decCp(cp) {
9412
- return cp && {
9952
+ if (!cp) return cp;
9953
+ const next = {
9413
9954
  ...cp,
9414
9955
  input: this.dec(cp.input),
9415
9956
  output: this.dec(cp.output)
9416
9957
  };
9958
+ if (this.extended) {
9959
+ if (cp.events !== void 0) next.events = this.dec(cp.events);
9960
+ if (cp.error !== void 0) next.error = this.dec(cp.error);
9961
+ }
9962
+ return next;
9417
9963
  }
9418
9964
  encSpec(spec) {
9419
9965
  return "input" in spec ? {
@@ -9452,6 +9998,9 @@ var CodecStateStore = class {
9452
9998
  };
9453
9999
  if ("input" in patch) next.input = this.enc(patch.input);
9454
10000
  if ("output" in patch) next.output = this.enc(patch.output);
10001
+ if (this.extended && patch.error !== void 0) {
10002
+ next.error = this.enc(patch.error);
10003
+ }
9455
10004
  return next;
9456
10005
  }
9457
10006
  async getRun(runId) {
@@ -9487,8 +10036,8 @@ var CodecStateStore = class {
9487
10036
  tryLockRun(runId, owner, leaseUntilMs, nowMs) {
9488
10037
  return this.inner.tryLockRun(runId, owner, leaseUntilMs, nowMs);
9489
10038
  }
9490
- releaseRunLock(runId) {
9491
- return this.inner.releaseRunLock(runId);
10039
+ releaseRunLock(runId, owner) {
10040
+ return owner === void 0 ? this.inner.releaseRunLock(runId) : this.inner.releaseRunLock(runId, owner);
9492
10041
  }
9493
10042
  renewRunLock(runId, owner, leaseUntilMs) {
9494
10043
  return this.inner.renewRunLock(runId, owner, leaseUntilMs);
@@ -9856,7 +10405,7 @@ var InMemoryStateStore = class {
9856
10405
  async createRun(run) {
9857
10406
  this.runs.set(run.id, {
9858
10407
  ...run,
9859
- namespace: run.namespace ?? "default"
10408
+ namespace: run.namespace || "default"
9860
10409
  });
9861
10410
  this.reindexAttributes(run.id, run.searchAttributes);
9862
10411
  }
@@ -9871,7 +10420,7 @@ var InMemoryStateStore = class {
9871
10420
  applyRunPatch(runId, inputPatch) {
9872
10421
  const patch = inputPatch.status && TERMINAL_RUN_STATUSES.includes(inputPatch.status) ? {
9873
10422
  ...inputPatch,
9874
- tags: ("tags" in inputPatch ? inputPatch.tags : this.runs.get(runId)?.tags)?.filter((tag) => tag !== SINGLETON_ADMITTED_TAG)
10423
+ tags: withoutSingletonMarkers("tags" in inputPatch ? inputPatch.tags : this.runs.get(runId)?.tags)
9875
10424
  } : inputPatch;
9876
10425
  const existing = this.runs.get(runId);
9877
10426
  if (!existing) throw new Error(`run ${runId} not found`);
@@ -9959,10 +10508,10 @@ var InMemoryStateStore = class {
9959
10508
  return true;
9960
10509
  });
9961
10510
  }
9962
- async listOrphanedRuns(nowMs, limit, namespace) {
10511
+ async listOrphanedRuns(nowMs, limit, namespace, ownedBy) {
9963
10512
  return [
9964
10513
  ...this.runs.values()
9965
- ].filter((r) => (r.status === "running" || r.status === "cancelling") && (r.lockedUntil == null || r.lockedUntil <= nowMs) && (namespace === void 0 || r.namespace === namespace)).sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime()).slice(0, Math.max(0, limit)).map((r) => ({
10514
+ ].filter((r) => (r.status === "running" || r.status === "cancelling") && (r.lockedUntil == null || r.lockedUntil <= nowMs || ownedBy !== void 0 && r.lockedBy === ownedBy.owner && r.lockedUntil < ownedBy.lockedBefore) && (namespace === void 0 || r.namespace === namespace)).sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime()).slice(0, Math.max(0, limit)).map((r) => ({
9966
10515
  ...r
9967
10516
  }));
9968
10517
  }
@@ -9996,9 +10545,9 @@ var InMemoryStateStore = class {
9996
10545
  run.lockedUntil = leaseUntilMs;
9997
10546
  return true;
9998
10547
  }
9999
- async releaseRunLock(runId) {
10548
+ async releaseRunLock(runId, owner) {
10000
10549
  const run = this.runs.get(runId);
10001
- if (run) {
10550
+ if (run && (owner === void 0 || run.lockedBy === owner)) {
10002
10551
  run.lockedBy = void 0;
10003
10552
  run.lockedUntil = void 0;
10004
10553
  }
@@ -10349,12 +10898,14 @@ var InMemoryTransport = class {
10349
10898
  ContinueAsNew,
10350
10899
  DEFAULT_CONCURRENCY_STATUSES,
10351
10900
  DEFAULT_PARTITION,
10901
+ DEFAULT_STALLED_AFTER_MS,
10352
10902
  DURABLE_OPTIONS,
10353
10903
  DURABLE_OPTIONS_CANONICAL,
10354
10904
  DURABLE_STEP_CONFIG,
10355
10905
  DURABLE_STEP_NAME,
10356
10906
  DurableWorkflow,
10357
10907
  ENGINE_MINTED_TAG_PREFIXES,
10908
+ ENTITY_WORKFLOW,
10358
10909
  EVENT_BUFFER_SCAN_LIMIT,
10359
10910
  EVENT_TOKEN_PREFIX,
10360
10911
  Entities,
@@ -10371,6 +10922,7 @@ var InMemoryTransport = class {
10371
10922
  NestedWorkflowCallError,
10372
10923
  NonDeterminismError,
10373
10924
  QueueController,
10925
+ RUN_HISTORY_FIXTURE_VERSION,
10374
10926
  RUN_VALUE_FACET_LIMIT,
10375
10927
  RUN_VALUE_FACET_SCAN,
10376
10928
  RemoteStepError,
@@ -10380,6 +10932,8 @@ var InMemoryTransport = class {
10380
10932
  RunGateway,
10381
10933
  SINGLETON_ACTIVE_STATUSES,
10382
10934
  SINGLETON_ADMITTED_TAG,
10935
+ SINGLETON_RESERVED_TAGS,
10936
+ SINGLETON_TRACKED_TAG,
10383
10937
  STATE_STORE,
10384
10938
  STATE_STORE_CANONICAL,
10385
10939
  SUMMARY_CHILD_LIMIT,
@@ -10442,6 +10996,7 @@ var InMemoryTransport = class {
10442
10996
  facetOrigin,
10443
10997
  fanPageBounds,
10444
10998
  fanPageEtag,
10999
+ findStalledRuns,
10445
11000
  foldedCount,
10446
11001
  handoffHandlerId,
10447
11002
  handoffName,
@@ -10455,6 +11010,7 @@ var InMemoryTransport = class {
10455
11010
  isLegacyDescriptor,
10456
11011
  isNonEmptyRunScope,
10457
11012
  isWorkflowControlFlowSignal,
11013
+ legacySingletonHolders,
10458
11014
  listSignalWaitersOfRuns,
10459
11015
  liveHandoff,
10460
11016
  log,
@@ -10472,8 +11028,10 @@ var InMemoryTransport = class {
10472
11028
  pageFanFromStore,
10473
11029
  parentRunIdOf,
10474
11030
  parseDuration,
11031
+ parseWebhookToken,
10475
11032
  partitionOfToken,
10476
11033
  planDispatch,
11034
+ poolNamespace,
10477
11035
  prevCronFireMs,
10478
11036
  queryCheckpointsOf,
10479
11037
  readSearchAttributes,
@@ -10509,6 +11067,8 @@ var InMemoryTransport = class {
10509
11067
  tenantGroup,
10510
11068
  useDurableExecution,
10511
11069
  warn,
11070
+ withSingletonTracked,
11071
+ withoutSingletonMarkers,
10512
11072
  workflowName
10513
11073
  });
10514
11074
  //# sourceMappingURL=index.cjs.map