@dudousxd/nestjs-durable-core 0.81.0 → 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,6 +30,7 @@ __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,
@@ -53,6 +54,7 @@ __export(index_exports, {
53
54
  NestedWorkflowCallError: () => NestedWorkflowCallError,
54
55
  NonDeterminismError: () => NonDeterminismError,
55
56
  QueueController: () => QueueController,
57
+ RUN_HISTORY_FIXTURE_VERSION: () => RUN_HISTORY_FIXTURE_VERSION,
56
58
  RUN_VALUE_FACET_LIMIT: () => RUN_VALUE_FACET_LIMIT,
57
59
  RUN_VALUE_FACET_SCAN: () => RUN_VALUE_FACET_SCAN,
58
60
  RemoteStepError: () => RemoteStepError,
@@ -126,6 +128,7 @@ __export(index_exports, {
126
128
  facetOrigin: () => facetOrigin,
127
129
  fanPageBounds: () => fanPageBounds,
128
130
  fanPageEtag: () => fanPageEtag,
131
+ findStalledRuns: () => findStalledRuns,
129
132
  foldedCount: () => foldedCount,
130
133
  handoffHandlerId: () => handoffHandlerId,
131
134
  handoffName: () => handoffName,
@@ -3017,6 +3020,66 @@ var SingletonGate = class {
3017
3020
  }
3018
3021
  };
3019
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
+
3020
3083
  // src/transport-pool.ts
3021
3084
  var TransportPool = class {
3022
3085
  static {
@@ -4374,6 +4437,18 @@ var WorkflowEngine = class {
4374
4437
  stepQueue = /* @__PURE__ */ new Map();
4375
4438
  /** Executions currently in flight, so a graceful shutdown can wait for them to settle. */
4376
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;
4377
4452
  draining = false;
4378
4453
  /** Wait already spent on this shutdown before `drain()` (see `beginDrain`). */
4379
4454
  drainSpentMs = 0;
@@ -4398,6 +4473,8 @@ var WorkflowEngine = class {
4398
4473
  this.maxRecoveryAttempts = deps.maxRecoveryAttempts;
4399
4474
  this.remoteAdvanceSilenceMs = deps.remoteAdvanceSilenceMs;
4400
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;
4401
4478
  const batch = deps.timerBatchSize;
4402
4479
  this.timerBatchSize = batch !== void 0 && Number.isFinite(batch) && batch >= 1 ? Math.floor(batch) : 500;
4403
4480
  this.eventBufferTtlMs = deps.eventBufferTtlMs || void 0;
@@ -4741,6 +4818,64 @@ var WorkflowEngine = class {
4741
4818
  this.deadListeners.add(listener);
4742
4819
  return () => this.deadListeners.delete(listener);
4743
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
+ }
4744
4879
  notifyDead(run) {
4745
4880
  for (const fn of this.deadListeners) {
4746
4881
  try {
@@ -4899,6 +5034,11 @@ var WorkflowEngine = class {
4899
5034
  getRun(runId) {
4900
5035
  return this.store.getRun(runId);
4901
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
+ }
4902
5042
  /**
4903
5043
  * Re-execute a run's body (replaying its checkpoints). This is the EXPLICIT entry point: besides
4904
5044
  * driving a live run, it re-drives a `failed` run — the manual retry (prefer {@link requeue}, which
@@ -4984,6 +5124,8 @@ var WorkflowEngine = class {
4984
5124
  * diagnosis.
4985
5125
  */
4986
5126
  async releaseLeaseBestEffort(runId) {
5127
+ if (this.isFenced(runId)) return;
5128
+ this.handOffLease(runId);
4987
5129
  try {
4988
5130
  await this.store.releaseRunLock(runId);
4989
5131
  } catch (err) {
@@ -5233,17 +5375,47 @@ var WorkflowEngine = class {
5233
5375
  rearm();
5234
5376
  });
5235
5377
  this.drainRearms.add(rearm);
5378
+ let settled = false;
5236
5379
  try {
5237
5380
  await Promise.race([
5238
5381
  Promise.allSettled([
5239
5382
  ...this.inflight
5240
- ]),
5383
+ ]).then(() => {
5384
+ settled = true;
5385
+ }),
5241
5386
  timer
5242
5387
  ]);
5243
5388
  } finally {
5244
5389
  this.drainRearms.delete(rearm);
5245
5390
  clearTimeout(timeout);
5246
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
+ }
5247
5419
  }
5248
5420
  /**
5249
5421
  * Cancel in-flight runs that have outlived their workflow's `executionTimeout`. Call it from the
@@ -6733,7 +6905,10 @@ var WorkflowEngine = class {
6733
6905
  }
6734
6906
  async singletonWaitResult(run) {
6735
6907
  const current = await this.store.getRun(run.id);
6736
- 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
+ }
6737
6912
  if (!current) throw new Error(`run ${run.id} was deleted during singleton admission`);
6738
6913
  if (current.status === "suspended") {
6739
6914
  this.emit({
@@ -6761,10 +6936,7 @@ var WorkflowEngine = class {
6761
6936
  };
6762
6937
  }
6763
6938
  }
6764
- const renew = setInterval(() => {
6765
- void this.store.renewRunLock(run.id, this.instanceId, this.clock() + this.leaseMs).catch(() => void 0);
6766
- }, Math.max(50, Math.floor(this.leaseMs / 2)));
6767
- renew.unref?.();
6939
+ const held = this.holdExecutionLease(run.id);
6768
6940
  try {
6769
6941
  const result = registered?.remote ? await this.runRemoteExecution(run, registered) : await this.runExecution(run, fn, registered);
6770
6942
  if (registered?.singleton && (result.status === "completed" || result.status === "failed" || result.status === "cancelled" || result.status === "dead")) {
@@ -6772,10 +6944,59 @@ var WorkflowEngine = class {
6772
6944
  }
6773
6945
  return result;
6774
6946
  } finally {
6775
- clearInterval(renew);
6947
+ held.stop();
6776
6948
  }
6777
6949
  }
6778
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
+ /**
6779
7000
  * Advance a remote (cross-SDK) workflow one turn: hand its history to the executor (which dispatches
6780
7001
  * a workflow task to the worker and awaits its replay) and apply the decision. Mirrors
6781
7002
  * {@link runExecution}'s settle/suspend; the lease is held by {@link execute}. The result that lands
@@ -6992,6 +7213,7 @@ ${run.workflowVersion}`;
6992
7213
  * keeps its own lease handling (the TS executor's `finally`, the remote path's caller).
6993
7214
  */
6994
7215
  async settleRun(run, outcome) {
7216
+ if (this.isFenced(run.id)) return this.echoCurrentStatus(run.id);
6995
7217
  const updatedAt = /* @__PURE__ */ new Date();
6996
7218
  if (outcome.kind === "completed") {
6997
7219
  const applied2 = await (this.store.updateRunIf ?? this.updateRunIfFallback).call(this.store, run.id, SETTLE_ELIGIBLE_STATUSES, {
@@ -7072,6 +7294,7 @@ ${run.workflowVersion}`;
7072
7294
  }
7073
7295
  async runRemoteExecution(run, registered) {
7074
7296
  if (run.status === "cancelling") {
7297
+ if (this.isFenced(run.id)) return this.echoCurrentStatus(run.id);
7075
7298
  const error2 = {
7076
7299
  message: "cancelled"
7077
7300
  };
@@ -7080,6 +7303,7 @@ ${run.workflowVersion}`;
7080
7303
  error: error2,
7081
7304
  updatedAt: /* @__PURE__ */ new Date()
7082
7305
  });
7306
+ if (this.isFenced(run.id)) return this.echoCurrentStatus(run.id);
7083
7307
  this.emit({
7084
7308
  type: "run.failed",
7085
7309
  runId: run.id,
@@ -7126,6 +7350,7 @@ ${run.workflowVersion}`;
7126
7350
  }
7127
7351
  const history = await this.remoteHistory(run.id, checkpoints);
7128
7352
  if (remote.executor.dispatch) {
7353
+ if (this.isFenced(run.id)) return this.echoCurrentStatus(run.id);
7129
7354
  const taskId = `${run.id}:wf:${globalThis.crypto.randomUUID().slice(0, 8)}`;
7130
7355
  const silenceMs = this.remoteAdvanceSilenceMs;
7131
7356
  const wakeAt = silenceMs != null ? this.clock() + silenceMs : void 0;
@@ -7141,6 +7366,7 @@ ${run.workflowVersion}`;
7141
7366
  workflow: run.workflow,
7142
7367
  namespace: run.namespace
7143
7368
  });
7369
+ this.handOffLease(run.id);
7144
7370
  await this.store.releaseRunLock(run.id);
7145
7371
  await remote.executor.dispatch(run, history, taskId);
7146
7372
  return {
@@ -7169,6 +7395,7 @@ ${run.workflowVersion}`;
7169
7395
  message: err instanceof Error ? err.message : String(err),
7170
7396
  stack: err instanceof Error ? err.stack : void 0
7171
7397
  };
7398
+ if (this.isFenced(run.id)) return this.echoCurrentStatus(run.id);
7172
7399
  await this.store.updateRun(run.id, {
7173
7400
  status: "failed",
7174
7401
  error: error2,
@@ -7197,6 +7424,7 @@ ${run.workflowVersion}`;
7197
7424
  * — the caller has already ensured this is the decision for the currently-awaited turn.
7198
7425
  */
7199
7426
  async applyDecision(run, decision) {
7427
+ if (this.isFenced(run.id)) return this.echoCurrentStatus(run.id);
7200
7428
  if (decision.status === "completed") {
7201
7429
  await this.applyCommands(run, decision.commands);
7202
7430
  return this.settleRun(run, {
@@ -7666,6 +7894,7 @@ ${run.workflowVersion}`;
7666
7894
  * cancel flag stays too, re-derived from the status on any later re-drive regardless.
7667
7895
  */
7668
7896
  async compensateAndCancel(run, compensations, replay) {
7897
+ if (this.isFenced(run.id)) return this.echoCurrentStatus(run.id);
7669
7898
  const pending = await this.unwindCompensations(run, compensations, replay);
7670
7899
  if (pending) return {
7671
7900
  runId: run.id,
@@ -7757,6 +7986,7 @@ ${run.workflowVersion}`;
7757
7986
  });
7758
7987
  } catch (err) {
7759
7988
  if (err instanceof ContinueAsNew) {
7989
+ if (this.isFenced(run.id)) return await this.echoCurrentStatus(run.id);
7760
7990
  if (this.cancelRequested.has(run.id)) {
7761
7991
  return await this.compensateAndCancel(run, compensations, replay);
7762
7992
  }
@@ -8208,6 +8438,7 @@ ${run.workflowVersion}`;
8208
8438
  * own `finally` also releases (the run-execution path) is safe.
8209
8439
  */
8210
8440
  async parkBlocked(run, blocked) {
8441
+ if (this.isFenced(run.id)) return this.echoCurrentStatus(run.id);
8211
8442
  const error2 = {
8212
8443
  message: blocked.reason,
8213
8444
  code: blocked.code,
@@ -8794,10 +9025,7 @@ ${run.workflowVersion}`;
8794
9025
  if (!await this.store.tryLockRun(decision.runId, this.instanceId, nowMs + this.leaseMs, nowMs)) {
8795
9026
  return;
8796
9027
  }
8797
- const renew = setInterval(() => {
8798
- void this.store.renewRunLock(decision.runId, this.instanceId, this.clock() + this.leaseMs).catch(() => void 0);
8799
- }, Math.max(50, Math.floor(this.leaseMs / 2)));
8800
- renew.unref?.();
9028
+ const held = this.holdExecutionLease(decision.runId);
8801
9029
  try {
8802
9030
  const fresh = await this.store.getRun(decision.runId);
8803
9031
  if (!fresh || fresh.awaitingDecisionTaskId !== decision.taskId) return;
@@ -8812,8 +9040,9 @@ ${run.workflowVersion}`;
8812
9040
  fresh.awaitingDecisionTaskId = void 0;
8813
9041
  await this.applyDecision(fresh, decision);
8814
9042
  } finally {
8815
- clearInterval(renew);
8816
- await this.store.releaseRunLock(decision.runId);
9043
+ const fenced = held.lease.lost;
9044
+ held.stop();
9045
+ if (!fenced) await this.store.releaseRunLock(decision.runId);
8817
9046
  }
8818
9047
  }
8819
9048
  /** Earliest clock time a suspended turn's deadline is worth LOOKING at again (`runId` → clock ms) —
@@ -9009,6 +9238,9 @@ function childIdsOf(checkpoints) {
9009
9238
  }
9010
9239
  __name(childIdsOf, "childIdsOf");
9011
9240
 
9241
+ // src/run-history.ts
9242
+ var RUN_HISTORY_FIXTURE_VERSION = 1;
9243
+
9012
9244
  // src/run-facets.ts
9013
9245
  function facetOrigin(origin) {
9014
9246
  return origin?.trim() ? origin : null;
@@ -9562,13 +9794,14 @@ var CodecStateStore = class {
9562
9794
  }
9563
9795
  inner;
9564
9796
  codec;
9797
+ extended;
9565
9798
  /** Atomic claims touch only searchable metadata and bypass payload encoding. */
9566
9799
  tryAdmitSingleton;
9567
9800
  /**
9568
9801
  * Forwarded verbatim — a facet cell counts `status`/`origin`, neither of which the codec touches
9569
- * (it encodes payloads: input/output/error). Bound as a PROPERTY, and only when the inner store
9570
- * actually aggregates, so `store.runFacets?.()` still reports absence through the wrapper instead
9571
- * 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.
9572
9805
  */
9573
9806
  runFacets;
9574
9807
  /**
@@ -9620,9 +9853,10 @@ var CodecStateStore = class {
9620
9853
  updateSchedule;
9621
9854
  deleteSchedule;
9622
9855
  listSchedules;
9623
- constructor(inner, codec) {
9856
+ constructor(inner, codec, options = {}) {
9624
9857
  this.inner = inner;
9625
9858
  this.codec = codec;
9859
+ this.extended = options.coverage === "extended";
9626
9860
  if (inner.tryAdmitSingleton) this.tryAdmitSingleton = inner.tryAdmitSingleton.bind(inner);
9627
9861
  const deleteRuns = inner.deleteRuns;
9628
9862
  if (deleteRuns) this.deleteRuns = (ids) => deleteRuns.call(inner, ids);
@@ -9680,32 +9914,52 @@ var CodecStateStore = class {
9680
9914
  return v === void 0 ? void 0 : this.codec.decode(v);
9681
9915
  }
9682
9916
  encRun(run) {
9683
- return {
9917
+ const next = {
9684
9918
  ...run,
9685
9919
  input: this.enc(run.input),
9686
9920
  output: this.enc(run.output)
9687
9921
  };
9922
+ if (this.extended && run.error !== void 0) {
9923
+ next.error = this.enc(run.error);
9924
+ }
9925
+ return next;
9688
9926
  }
9689
9927
  decRun(run) {
9690
- return run && {
9928
+ if (!run) return run;
9929
+ const next = {
9691
9930
  ...run,
9692
9931
  input: this.dec(run.input),
9693
9932
  output: this.dec(run.output)
9694
9933
  };
9934
+ if (this.extended && run.error !== void 0) {
9935
+ next.error = this.dec(run.error);
9936
+ }
9937
+ return next;
9695
9938
  }
9696
9939
  encCp(cp) {
9697
- return {
9940
+ const next = {
9698
9941
  ...cp,
9699
9942
  input: this.enc(cp.input),
9700
9943
  output: this.enc(cp.output)
9701
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;
9702
9950
  }
9703
9951
  decCp(cp) {
9704
- return cp && {
9952
+ if (!cp) return cp;
9953
+ const next = {
9705
9954
  ...cp,
9706
9955
  input: this.dec(cp.input),
9707
9956
  output: this.dec(cp.output)
9708
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;
9709
9963
  }
9710
9964
  encSpec(spec) {
9711
9965
  return "input" in spec ? {
@@ -9744,6 +9998,9 @@ var CodecStateStore = class {
9744
9998
  };
9745
9999
  if ("input" in patch) next.input = this.enc(patch.input);
9746
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
+ }
9747
10004
  return next;
9748
10005
  }
9749
10006
  async getRun(runId) {
@@ -9779,8 +10036,8 @@ var CodecStateStore = class {
9779
10036
  tryLockRun(runId, owner, leaseUntilMs, nowMs) {
9780
10037
  return this.inner.tryLockRun(runId, owner, leaseUntilMs, nowMs);
9781
10038
  }
9782
- releaseRunLock(runId) {
9783
- return this.inner.releaseRunLock(runId);
10039
+ releaseRunLock(runId, owner) {
10040
+ return owner === void 0 ? this.inner.releaseRunLock(runId) : this.inner.releaseRunLock(runId, owner);
9784
10041
  }
9785
10042
  renewRunLock(runId, owner, leaseUntilMs) {
9786
10043
  return this.inner.renewRunLock(runId, owner, leaseUntilMs);
@@ -10288,9 +10545,9 @@ var InMemoryStateStore = class {
10288
10545
  run.lockedUntil = leaseUntilMs;
10289
10546
  return true;
10290
10547
  }
10291
- async releaseRunLock(runId) {
10548
+ async releaseRunLock(runId, owner) {
10292
10549
  const run = this.runs.get(runId);
10293
- if (run) {
10550
+ if (run && (owner === void 0 || run.lockedBy === owner)) {
10294
10551
  run.lockedBy = void 0;
10295
10552
  run.lockedUntil = void 0;
10296
10553
  }
@@ -10641,6 +10898,7 @@ var InMemoryTransport = class {
10641
10898
  ContinueAsNew,
10642
10899
  DEFAULT_CONCURRENCY_STATUSES,
10643
10900
  DEFAULT_PARTITION,
10901
+ DEFAULT_STALLED_AFTER_MS,
10644
10902
  DURABLE_OPTIONS,
10645
10903
  DURABLE_OPTIONS_CANONICAL,
10646
10904
  DURABLE_STEP_CONFIG,
@@ -10664,6 +10922,7 @@ var InMemoryTransport = class {
10664
10922
  NestedWorkflowCallError,
10665
10923
  NonDeterminismError,
10666
10924
  QueueController,
10925
+ RUN_HISTORY_FIXTURE_VERSION,
10667
10926
  RUN_VALUE_FACET_LIMIT,
10668
10927
  RUN_VALUE_FACET_SCAN,
10669
10928
  RemoteStepError,
@@ -10737,6 +10996,7 @@ var InMemoryTransport = class {
10737
10996
  facetOrigin,
10738
10997
  fanPageBounds,
10739
10998
  fanPageEtag,
10999
+ findStalledRuns,
10740
11000
  foldedCount,
10741
11001
  handoffHandlerId,
10742
11002
  handoffName,