@dudousxd/nestjs-durable-core 0.81.0 → 0.83.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -277,6 +277,31 @@ var FatalError = class extends Error {
277
277
  this.details = details;
278
278
  }
279
279
  };
280
+ var SchemaValidationError = class extends FatalError {
281
+ static {
282
+ __name(this, "SchemaValidationError");
283
+ }
284
+ static code = "schema_validation";
285
+ issues;
286
+ /**
287
+ * @param label What was validated, e.g. `workflow "checkout" input`; the message is
288
+ * `invalid <label>: <path>: <message>; …`.
289
+ * @param summary Overrides the `<path>: <message>; …` part, for issues whose messages already
290
+ * name their field (class-validator's `orderId must be a string`).
291
+ */
292
+ constructor(label, issues, code = "schema_validation", summary) {
293
+ const normalized = issues.map((issue) => ({
294
+ message: issue.message,
295
+ path: (issue.path ?? []).map((segment) => String(typeof segment === "object" && segment !== null ? segment.key : segment))
296
+ }));
297
+ const text = summary ?? normalized.map((issue) => issue.path.length > 0 ? `${issue.path.join(".")}: ${issue.message}` : issue.message).join("; ");
298
+ super(`invalid ${label}: ${text}`, code, {
299
+ issues: normalized
300
+ });
301
+ this.name = "SchemaValidationError";
302
+ this.issues = normalized;
303
+ }
304
+ };
280
305
  var ConcurrencyLimitError = class extends Error {
281
306
  static {
282
307
  __name(this, "ConcurrencyLimitError");
@@ -2008,9 +2033,14 @@ function liveHandoff(runId, checkpoints) {
2008
2033
  return live;
2009
2034
  }
2010
2035
  __name(liveHandoff, "liveHandoff");
2036
+ var SCHEMA_VALIDATION_CODES = /* @__PURE__ */ new Set([
2037
+ "invalid_input",
2038
+ "invalid_output"
2039
+ ]);
2011
2040
  function routeFor(routes, error2) {
2012
2041
  if (!routes?.length || error2.code && UNROUTABLE_CODES.has(error2.code)) return void 0;
2013
- return (error2.code ? routes.find((r) => r.code === error2.code) : void 0) ?? routes.find((r) => r.code === void 0);
2042
+ const { code } = error2;
2043
+ return (code ? routes.find((r) => r.code === code) : void 0) ?? (code && SCHEMA_VALIDATION_CODES.has(code) ? routes.find((r) => r.code === "schema_validation") : void 0) ?? routes.find((r) => r.code === void 0);
2014
2044
  }
2015
2045
  __name(routeFor, "routeFor");
2016
2046
  function assertRoutes(workflow, routes) {
@@ -2282,9 +2312,13 @@ __name(scheduleRunIdsAt, "scheduleRunIdsAt");
2282
2312
  async function runSchedules(engine, schedules, nowMs, opts) {
2283
2313
  const random = opts?.random ?? Math.random;
2284
2314
  const sleep = opts?.sleep ?? defaultSleep;
2315
+ const settled = settledIdsFor(engine);
2285
2316
  const ids = [];
2286
2317
  for (const s of schedules) {
2318
+ if (engine.isDraining?.()) break;
2287
2319
  if (s.paused) continue;
2320
+ const candidates = scheduleRunIdsAt(s, nowMs).filter((id) => !settled.get(s.key)?.has(id));
2321
+ if (candidates.length === 0) continue;
2288
2322
  if (s.overlap === "skip" && s.everyMs) {
2289
2323
  const prevBucket = Math.floor(nowMs / s.everyMs) - 1;
2290
2324
  if (prevBucket >= 0) {
@@ -2293,14 +2327,44 @@ async function runSchedules(engine, schedules, nowMs, opts) {
2293
2327
  }
2294
2328
  }
2295
2329
  if (s.jitter && s.jitter > 0) await sleep(Math.floor(random() * s.jitter));
2296
- for (const runId of scheduleRunIdsAt(s, nowMs)) {
2297
- await engine.start(s.workflow, s.input, runId);
2330
+ for (const runId of candidates) {
2331
+ if (engine.isDraining?.()) break;
2332
+ const existing = await engine.getRun(runId);
2333
+ remember(settled, s.key, runId);
2334
+ if (existing) continue;
2335
+ await engine.start(workflowName(s.workflow), s.input, runId, {
2336
+ namespace: s.namespace
2337
+ });
2298
2338
  ids.push(runId);
2299
2339
  }
2300
2340
  }
2301
2341
  return ids;
2302
2342
  }
2303
2343
  __name(runSchedules, "runSchedules");
2344
+ var settledRunIds = /* @__PURE__ */ new WeakMap();
2345
+ function settledIdsFor(engine) {
2346
+ let memo = settledRunIds.get(engine);
2347
+ if (memo === void 0) {
2348
+ memo = /* @__PURE__ */ new Map();
2349
+ settledRunIds.set(engine, memo);
2350
+ }
2351
+ return memo;
2352
+ }
2353
+ __name(settledIdsFor, "settledIdsFor");
2354
+ function remember(memo, key, runId) {
2355
+ const seen = memo.get(key);
2356
+ if (seen === void 0) {
2357
+ memo.set(key, /* @__PURE__ */ new Set([
2358
+ runId
2359
+ ]));
2360
+ return;
2361
+ }
2362
+ seen.add(runId);
2363
+ if (seen.size > 64) memo.set(key, /* @__PURE__ */ new Set([
2364
+ runId
2365
+ ]));
2366
+ }
2367
+ __name(remember, "remember");
2304
2368
 
2305
2369
  // src/schedules.ts
2306
2370
  var ScheduleAlreadyExistsError = class extends Error {
@@ -2816,6 +2880,66 @@ var SingletonGate = class {
2816
2880
  }
2817
2881
  };
2818
2882
 
2883
+ // src/stalled-runs.ts
2884
+ var DEFAULT_STALLED_AFTER_MS = 15 * 6e4;
2885
+ async function findStalledRuns(store, options = {}) {
2886
+ const now = options.now ?? Date.now();
2887
+ const thresholdMs = options.thresholdMs ?? DEFAULT_STALLED_AFTER_MS;
2888
+ const scan = Math.max(1, options.scan ?? 1e3);
2889
+ const pageSize = Math.min(200, scan);
2890
+ const stalled = [];
2891
+ for (let offset = 0; offset < scan; offset += pageSize) {
2892
+ const limit = Math.min(pageSize, scan - offset);
2893
+ const page = await store.listRuns({
2894
+ statuses: [
2895
+ "running",
2896
+ "suspended"
2897
+ ],
2898
+ ...options.namespace !== void 0 ? {
2899
+ namespace: options.namespace
2900
+ } : {},
2901
+ limit,
2902
+ offset
2903
+ });
2904
+ for (const run of page) {
2905
+ const ageMs = now - run.updatedAt.getTime();
2906
+ if (ageMs < thresholdMs) continue;
2907
+ const info2 = await stalledInfo(store, run, now, thresholdMs, ageMs);
2908
+ if (info2) stalled.push(info2);
2909
+ }
2910
+ if (page.length < limit) break;
2911
+ }
2912
+ return stalled;
2913
+ }
2914
+ __name(findStalledRuns, "findStalledRuns");
2915
+ async function stalledInfo(store, run, now, thresholdMs, ageMs) {
2916
+ let stalePending = null;
2917
+ for (const cp of await store.listCheckpoints(run.id)) {
2918
+ if (cp.kind !== "remote" || cp.status !== "pending") continue;
2919
+ const stepAgeMs = now - cp.enqueuedAt.getTime();
2920
+ if (stepAgeMs < thresholdMs) continue;
2921
+ if (cp.wakeAt != null && cp.wakeAt > now) continue;
2922
+ if (!stalePending || stepAgeMs > stalePending.ageMs) {
2923
+ stalePending = {
2924
+ seq: cp.seq,
2925
+ name: cp.name,
2926
+ attempts: cp.attempts,
2927
+ ageMs: stepAgeMs,
2928
+ leaseUntil: cp.wakeAt ?? null
2929
+ };
2930
+ }
2931
+ }
2932
+ const wakeForever = run.status === "suspended" && run.wakeAt == null;
2933
+ if (!wakeForever && !stalePending) return null;
2934
+ return {
2935
+ run,
2936
+ ageMs,
2937
+ wakeForever,
2938
+ stalePending
2939
+ };
2940
+ }
2941
+ __name(stalledInfo, "stalledInfo");
2942
+
2819
2943
  // src/transport-pool.ts
2820
2944
  var TransportPool = class {
2821
2945
  static {
@@ -3039,11 +3163,6 @@ function normalizeChildOptions(options) {
3039
3163
  } : options ?? {};
3040
3164
  }
3041
3165
  __name(normalizeChildOptions, "normalizeChildOptions");
3042
- function formatIssuePath(path) {
3043
- if (!path || path.length === 0) return "(root)";
3044
- return path.map((segment) => String(typeof segment === "object" ? segment.key : segment)).join(".");
3045
- }
3046
- __name(formatIssuePath, "formatIssuePath");
3047
3166
  function resolveStepPolicy(config, opts) {
3048
3167
  const policy = {};
3049
3168
  const retries = opts?.retries ?? config?.retries;
@@ -3875,11 +3994,7 @@ function createWorkflowCtx(host, runId, compensations, workflow = "", searchAttr
3875
3994
  throw new Error(`workflow "${workflow}": ctx.upsertSearchAttributes schema validation must be synchronous (the searchAttributes schema's \`~standard.validate\` returned a Promise) \u2014 replay-determinism requires a sync validator; drop any async refinement from the schema passed to @Workflow({ searchAttributes }).`);
3876
3995
  }
3877
3996
  if (result.issues) {
3878
- const offendingKeys = [
3879
- ...new Set(result.issues.map((issue) => formatIssuePath(issue.path)))
3880
- ];
3881
- const details = result.issues.map((issue) => `${formatIssuePath(issue.path)}: ${issue.message}`).join("; ");
3882
- throw new Error(`workflow "${workflow}": ctx.upsertSearchAttributes rejected by its searchAttributes schema \u2014 offending key(s) ${offendingKeys.join(", ")} \u2014 ${details}`);
3997
+ throw new SchemaValidationError(`workflow "${workflow}" ctx.upsertSearchAttributes`, result.issues);
3883
3998
  }
3884
3999
  }
3885
4000
  await host.upsertSearchAttributes(runId, attrs);
@@ -4173,6 +4288,18 @@ var WorkflowEngine = class {
4173
4288
  stepQueue = /* @__PURE__ */ new Map();
4174
4289
  /** Executions currently in flight, so a graceful shutdown can wait for them to settle. */
4175
4290
  inflight = /* @__PURE__ */ new Set();
4291
+ /**
4292
+ * The run lease of each execution in flight on THIS instance, by run id — renewed while it runs
4293
+ * and checked by every settle (the zombie fence, see {@link ExecutionLease}). Also what `drain()`
4294
+ * releases when its budget runs out.
4295
+ */
4296
+ executionLeases = /* @__PURE__ */ new Map();
4297
+ /** Pager hooks for stranded runs (see {@link onStalled}). */
4298
+ stalledListeners = /* @__PURE__ */ new Set();
4299
+ /** Runs already paged about, by id → the `updatedAt` of the paged episode (see {@link sweepStalled}). */
4300
+ notifiedStalled = /* @__PURE__ */ new Map();
4301
+ lastStalledSweepAt = Number.NEGATIVE_INFINITY;
4302
+ stalledAfterMs;
4176
4303
  draining = false;
4177
4304
  /** Wait already spent on this shutdown before `drain()` (see `beginDrain`). */
4178
4305
  drainSpentMs = 0;
@@ -4197,6 +4324,8 @@ var WorkflowEngine = class {
4197
4324
  this.maxRecoveryAttempts = deps.maxRecoveryAttempts;
4198
4325
  this.remoteAdvanceSilenceMs = deps.remoteAdvanceSilenceMs;
4199
4326
  this.reconcileMs = deps.reconcileMs === void 0 ? 3e5 : deps.reconcileMs || void 0;
4327
+ const stalledAfter = deps.stalledAfterMs;
4328
+ this.stalledAfterMs = stalledAfter !== void 0 && Number.isFinite(stalledAfter) ? Math.max(6e4, stalledAfter) : DEFAULT_STALLED_AFTER_MS;
4200
4329
  const batch = deps.timerBatchSize;
4201
4330
  this.timerBatchSize = batch !== void 0 && Number.isFinite(batch) && batch >= 1 ? Math.floor(batch) : 500;
4202
4331
  this.eventBufferTtlMs = deps.eventBufferTtlMs || void 0;
@@ -4540,6 +4669,64 @@ var WorkflowEngine = class {
4540
4669
  this.deadListeners.add(listener);
4541
4670
  return () => this.deadListeners.delete(listener);
4542
4671
  }
4672
+ /**
4673
+ * Register a pager hook for STRANDED runs: an in-flight run untouched past `stalledAfterMs` that is
4674
+ * either suspended with NO wake timer (wake-forever: nothing will ever re-drive it) or parked on an
4675
+ * old pending remote step with no live lease (dispatched and, most likely, lost). The timer poller
4676
+ * sweeps for these ({@link sweepStalled}, throttled to one pass a minute) and notifies each episode
4677
+ * ONCE per instance: a run is paged again only after it makes progress and strands again. Wire your
4678
+ * alerting here like {@link onDead}. A listener that throws never breaks the sweep. Returns an
4679
+ * unsubscribe function.
4680
+ */
4681
+ onStalled(listener) {
4682
+ this.stalledListeners.add(listener);
4683
+ return () => this.stalledListeners.delete(listener);
4684
+ }
4685
+ /**
4686
+ * Detect stranded in-flight runs and notify the {@link onStalled} listeners. Self-throttled to one
4687
+ * pass a minute, skipped while draining, and a no-op without listeners — call it every tick (the
4688
+ * NestJS timer poller does). Scoped to this engine's `namespace` (an operator engine with no
4689
+ * namespace sweeps every namespace). Returns the runs notified THIS pass.
4690
+ */
4691
+ async sweepStalled(now = this.clock()) {
4692
+ if (this.stalledListeners.size === 0 || this.draining) return [];
4693
+ if (now - this.lastStalledSweepAt < 6e4) return [];
4694
+ this.lastStalledSweepAt = now;
4695
+ const notified = [];
4696
+ for (const info2 of await this.listStalledRuns({
4697
+ now
4698
+ })) {
4699
+ const episode = info2.run.updatedAt.getTime();
4700
+ if (this.notifiedStalled.get(info2.run.id) === episode) continue;
4701
+ this.notifiedStalled.set(info2.run.id, episode);
4702
+ if (this.notifiedStalled.size > 1e3) this.notifiedStalled.clear();
4703
+ for (const listener of this.stalledListeners) {
4704
+ try {
4705
+ listener(info2);
4706
+ } catch {
4707
+ }
4708
+ }
4709
+ notified.push(info2);
4710
+ }
4711
+ return notified;
4712
+ }
4713
+ /**
4714
+ * The stranded runs right now, without notifying anyone: the query behind {@link sweepStalled} and
4715
+ * the CLI's `durable inspect --stale`. A `running`/`suspended` run qualifies when it has been
4716
+ * untouched for `thresholdMs` (default `stalledAfterMs`) AND either it is suspended with no wake
4717
+ * timer, or its oldest pending remote step is older than the threshold with no lease in the future
4718
+ * (a step with a `timeoutMs`, a heartbeat or `remoteRedispatchMs` keeps a lease; a long
4719
+ * `ctx.sleep` has a future `wakeAt`; neither is stranded). Scans up to `scan` candidates (default
4720
+ * 1000), newest first, in this engine's namespace.
4721
+ */
4722
+ async listStalledRuns(options = {}) {
4723
+ return findStalledRuns(this.store, {
4724
+ now: options.now ?? this.clock(),
4725
+ thresholdMs: options.thresholdMs ?? this.stalledAfterMs,
4726
+ scan: options.scan,
4727
+ namespace: this.namespace
4728
+ });
4729
+ }
4543
4730
  notifyDead(run) {
4544
4731
  for (const fn of this.deadListeners) {
4545
4732
  try {
@@ -4698,6 +4885,11 @@ var WorkflowEngine = class {
4698
4885
  getRun(runId) {
4699
4886
  return this.store.getRun(runId);
4700
4887
  }
4888
+ /** A run's full checkpoint timeline, ordered by seq. A thin pass-through to the store (it is what
4889
+ * the testing kit's `captureHistory` reads, next to {@link getRun}). */
4890
+ listCheckpoints(runId) {
4891
+ return this.store.listCheckpoints(runId);
4892
+ }
4701
4893
  /**
4702
4894
  * Re-execute a run's body (replaying its checkpoints). This is the EXPLICIT entry point: besides
4703
4895
  * driving a live run, it re-drives a `failed` run — the manual retry (prefer {@link requeue}, which
@@ -4783,6 +4975,8 @@ var WorkflowEngine = class {
4783
4975
  * diagnosis.
4784
4976
  */
4785
4977
  async releaseLeaseBestEffort(runId) {
4978
+ if (this.isFenced(runId)) return;
4979
+ this.handOffLease(runId);
4786
4980
  try {
4787
4981
  await this.store.releaseRunLock(runId);
4788
4982
  } catch (err) {
@@ -5006,6 +5200,10 @@ var WorkflowEngine = class {
5006
5200
  for (const rearm of this.drainRearms) rearm();
5007
5201
  }
5008
5202
  }
5203
+ /** True once {@link beginDrain} (or {@link drain}) has been called. */
5204
+ isDraining() {
5205
+ return this.draining;
5206
+ }
5009
5207
  /**
5010
5208
  * Graceful shutdown: stop picking up new runs (recovery/timer become no-ops) and wait for
5011
5209
  * in-flight executions to settle, up to `timeoutMs`. Call from your app's shutdown hook so a
@@ -5032,17 +5230,47 @@ var WorkflowEngine = class {
5032
5230
  rearm();
5033
5231
  });
5034
5232
  this.drainRearms.add(rearm);
5233
+ let settled = false;
5035
5234
  try {
5036
5235
  await Promise.race([
5037
5236
  Promise.allSettled([
5038
5237
  ...this.inflight
5039
- ]),
5238
+ ]).then(() => {
5239
+ settled = true;
5240
+ }),
5040
5241
  timer
5041
5242
  ]);
5042
5243
  } finally {
5043
5244
  this.drainRearms.delete(rearm);
5044
5245
  clearTimeout(timeout);
5045
5246
  }
5247
+ if (!settled) await this.releaseInflightLeases();
5248
+ }
5249
+ /**
5250
+ * The drain budget ran out with executions still in flight: release their recovery leases so
5251
+ * another instance re-drives those runs on its next recovery pass, instead of waiting out
5252
+ * `leaseMs` after this process exits. Each still-running turn is FENCED first, so whatever it
5253
+ * computes after this point is never written over the new owner's work (see settleRun). Its
5254
+ * completed steps are checkpointed, so the new owner replays them; a step still executing here
5255
+ * may run again there, exactly as after a crash. The release is owner-scoped where the store
5256
+ * supports it (only a lease this instance still holds is cleared). Best effort: never throws.
5257
+ */
5258
+ async releaseInflightLeases() {
5259
+ const released = [];
5260
+ for (const [runId, lease] of [
5261
+ ...this.executionLeases
5262
+ ]) {
5263
+ if (lease.lost || lease.handedOff || lease.done) continue;
5264
+ lease.lost = true;
5265
+ try {
5266
+ await this.store.releaseRunLock(runId, this.instanceId);
5267
+ released.push(runId);
5268
+ } catch {
5269
+ }
5270
+ }
5271
+ if (released.length > 0) {
5272
+ 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(", ")}`);
5273
+ }
5046
5274
  }
5047
5275
  /**
5048
5276
  * Cancel in-flight runs that have outlived their workflow's `executionTimeout`. Call it from the
@@ -6532,7 +6760,10 @@ var WorkflowEngine = class {
6532
6760
  }
6533
6761
  async singletonWaitResult(run) {
6534
6762
  const current = await this.store.getRun(run.id);
6535
- await this.store.releaseRunLock(run.id);
6763
+ if (!this.isFenced(run.id)) {
6764
+ this.handOffLease(run.id);
6765
+ await this.store.releaseRunLock(run.id);
6766
+ }
6536
6767
  if (!current) throw new Error(`run ${run.id} was deleted during singleton admission`);
6537
6768
  if (current.status === "suspended") {
6538
6769
  this.emit({
@@ -6560,10 +6791,7 @@ var WorkflowEngine = class {
6560
6791
  };
6561
6792
  }
6562
6793
  }
6563
- const renew = setInterval(() => {
6564
- void this.store.renewRunLock(run.id, this.instanceId, this.clock() + this.leaseMs).catch(() => void 0);
6565
- }, Math.max(50, Math.floor(this.leaseMs / 2)));
6566
- renew.unref?.();
6794
+ const held = this.holdExecutionLease(run.id);
6567
6795
  try {
6568
6796
  const result = registered?.remote ? await this.runRemoteExecution(run, registered) : await this.runExecution(run, fn, registered);
6569
6797
  if (registered?.singleton && (result.status === "completed" || result.status === "failed" || result.status === "cancelled" || result.status === "dead")) {
@@ -6571,10 +6799,59 @@ var WorkflowEngine = class {
6571
6799
  }
6572
6800
  return result;
6573
6801
  } finally {
6574
- clearInterval(renew);
6802
+ held.stop();
6575
6803
  }
6576
6804
  }
6577
6805
  /**
6806
+ * Renew `runId`'s lease for as long as this execution holds it, and register it in
6807
+ * {@link executionLeases} so settles can check the zombie fence and `drain()` can release it.
6808
+ *
6809
+ * A renewal that returns `false` means the lease is gone: this turn stalled past `leaseMs` and
6810
+ * another instance reclaimed the run, so this executor is now a zombie. It is FENCED (`lost`): its
6811
+ * settles degrade to an echo of the store's state instead of clobbering the new owner's writes, and
6812
+ * its own lease release is skipped (it would free the new owner's lease). A renewal that THROWS (a
6813
+ * transient store error) is not evidence of a takeover, so it stays swallowed and the next
6814
+ * interval retries. A lease this execution released on purpose (`handedOff`) is not renewed, and a
6815
+ * renewal resolving after the execution ended (`done`) fences nothing.
6816
+ */
6817
+ holdExecutionLease(runId) {
6818
+ const lease = {
6819
+ lost: false,
6820
+ handedOff: false,
6821
+ done: false
6822
+ };
6823
+ this.executionLeases.set(runId, lease);
6824
+ const renew = setInterval(() => {
6825
+ if (lease.handedOff || lease.lost || lease.done) return;
6826
+ void this.store.renewRunLock(runId, this.instanceId, this.clock() + this.leaseMs).then((renewed) => {
6827
+ if (renewed || lease.handedOff || lease.lost || lease.done) return;
6828
+ lease.lost = true;
6829
+ 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`);
6830
+ }).catch(() => void 0);
6831
+ }, Math.max(50, Math.floor(this.leaseMs / 2)));
6832
+ renew.unref?.();
6833
+ return {
6834
+ lease,
6835
+ stop: /* @__PURE__ */ __name(() => {
6836
+ clearInterval(renew);
6837
+ lease.done = true;
6838
+ if (this.executionLeases.get(runId) === lease) this.executionLeases.delete(runId);
6839
+ }, "stop")
6840
+ };
6841
+ }
6842
+ /** Whether `runId`'s execution on this instance lost its lease (see {@link holdExecutionLease}). */
6843
+ isFenced(runId) {
6844
+ return this.executionLeases.get(runId)?.lost === true;
6845
+ }
6846
+ /**
6847
+ * Mark `runId`'s lease as given away on purpose by its own execution (released mid-turn), so a
6848
+ * renewal that now fails is not read as a takeover.
6849
+ */
6850
+ handOffLease(runId) {
6851
+ const lease = this.executionLeases.get(runId);
6852
+ if (lease) lease.handedOff = true;
6853
+ }
6854
+ /**
6578
6855
  * Advance a remote (cross-SDK) workflow one turn: hand its history to the executor (which dispatches
6579
6856
  * a workflow task to the worker and awaits its replay) and apply the decision. Mirrors
6580
6857
  * {@link runExecution}'s settle/suspend; the lease is held by {@link execute}. The result that lands
@@ -6791,6 +7068,7 @@ ${run.workflowVersion}`;
6791
7068
  * keeps its own lease handling (the TS executor's `finally`, the remote path's caller).
6792
7069
  */
6793
7070
  async settleRun(run, outcome) {
7071
+ if (this.isFenced(run.id)) return this.echoCurrentStatus(run.id);
6794
7072
  const updatedAt = /* @__PURE__ */ new Date();
6795
7073
  if (outcome.kind === "completed") {
6796
7074
  const applied2 = await (this.store.updateRunIf ?? this.updateRunIfFallback).call(this.store, run.id, SETTLE_ELIGIBLE_STATUSES, {
@@ -6871,6 +7149,7 @@ ${run.workflowVersion}`;
6871
7149
  }
6872
7150
  async runRemoteExecution(run, registered) {
6873
7151
  if (run.status === "cancelling") {
7152
+ if (this.isFenced(run.id)) return this.echoCurrentStatus(run.id);
6874
7153
  const error2 = {
6875
7154
  message: "cancelled"
6876
7155
  };
@@ -6879,6 +7158,7 @@ ${run.workflowVersion}`;
6879
7158
  error: error2,
6880
7159
  updatedAt: /* @__PURE__ */ new Date()
6881
7160
  });
7161
+ if (this.isFenced(run.id)) return this.echoCurrentStatus(run.id);
6882
7162
  this.emit({
6883
7163
  type: "run.failed",
6884
7164
  runId: run.id,
@@ -6925,6 +7205,7 @@ ${run.workflowVersion}`;
6925
7205
  }
6926
7206
  const history = await this.remoteHistory(run.id, checkpoints);
6927
7207
  if (remote.executor.dispatch) {
7208
+ if (this.isFenced(run.id)) return this.echoCurrentStatus(run.id);
6928
7209
  const taskId = `${run.id}:wf:${globalThis.crypto.randomUUID().slice(0, 8)}`;
6929
7210
  const silenceMs = this.remoteAdvanceSilenceMs;
6930
7211
  const wakeAt = silenceMs != null ? this.clock() + silenceMs : void 0;
@@ -6940,6 +7221,7 @@ ${run.workflowVersion}`;
6940
7221
  workflow: run.workflow,
6941
7222
  namespace: run.namespace
6942
7223
  });
7224
+ this.handOffLease(run.id);
6943
7225
  await this.store.releaseRunLock(run.id);
6944
7226
  await remote.executor.dispatch(run, history, taskId);
6945
7227
  return {
@@ -6968,6 +7250,7 @@ ${run.workflowVersion}`;
6968
7250
  message: err instanceof Error ? err.message : String(err),
6969
7251
  stack: err instanceof Error ? err.stack : void 0
6970
7252
  };
7253
+ if (this.isFenced(run.id)) return this.echoCurrentStatus(run.id);
6971
7254
  await this.store.updateRun(run.id, {
6972
7255
  status: "failed",
6973
7256
  error: error2,
@@ -6996,6 +7279,7 @@ ${run.workflowVersion}`;
6996
7279
  * — the caller has already ensured this is the decision for the currently-awaited turn.
6997
7280
  */
6998
7281
  async applyDecision(run, decision) {
7282
+ if (this.isFenced(run.id)) return this.echoCurrentStatus(run.id);
6999
7283
  if (decision.status === "completed") {
7000
7284
  await this.applyCommands(run, decision.commands);
7001
7285
  return this.settleRun(run, {
@@ -7465,6 +7749,7 @@ ${run.workflowVersion}`;
7465
7749
  * cancel flag stays too, re-derived from the status on any later re-drive regardless.
7466
7750
  */
7467
7751
  async compensateAndCancel(run, compensations, replay) {
7752
+ if (this.isFenced(run.id)) return this.echoCurrentStatus(run.id);
7468
7753
  const pending = await this.unwindCompensations(run, compensations, replay);
7469
7754
  if (pending) return {
7470
7755
  runId: run.id,
@@ -7556,6 +7841,7 @@ ${run.workflowVersion}`;
7556
7841
  });
7557
7842
  } catch (err) {
7558
7843
  if (err instanceof ContinueAsNew) {
7844
+ if (this.isFenced(run.id)) return await this.echoCurrentStatus(run.id);
7559
7845
  if (this.cancelRequested.has(run.id)) {
7560
7846
  return await this.compensateAndCancel(run, compensations, replay);
7561
7847
  }
@@ -8007,6 +8293,7 @@ ${run.workflowVersion}`;
8007
8293
  * own `finally` also releases (the run-execution path) is safe.
8008
8294
  */
8009
8295
  async parkBlocked(run, blocked) {
8296
+ if (this.isFenced(run.id)) return this.echoCurrentStatus(run.id);
8010
8297
  const error2 = {
8011
8298
  message: blocked.reason,
8012
8299
  code: blocked.code,
@@ -8593,10 +8880,7 @@ ${run.workflowVersion}`;
8593
8880
  if (!await this.store.tryLockRun(decision.runId, this.instanceId, nowMs + this.leaseMs, nowMs)) {
8594
8881
  return;
8595
8882
  }
8596
- const renew = setInterval(() => {
8597
- void this.store.renewRunLock(decision.runId, this.instanceId, this.clock() + this.leaseMs).catch(() => void 0);
8598
- }, Math.max(50, Math.floor(this.leaseMs / 2)));
8599
- renew.unref?.();
8883
+ const held = this.holdExecutionLease(decision.runId);
8600
8884
  try {
8601
8885
  const fresh = await this.store.getRun(decision.runId);
8602
8886
  if (!fresh || fresh.awaitingDecisionTaskId !== decision.taskId) return;
@@ -8611,8 +8895,9 @@ ${run.workflowVersion}`;
8611
8895
  fresh.awaitingDecisionTaskId = void 0;
8612
8896
  await this.applyDecision(fresh, decision);
8613
8897
  } finally {
8614
- clearInterval(renew);
8615
- await this.store.releaseRunLock(decision.runId);
8898
+ const fenced = held.lease.lost;
8899
+ held.stop();
8900
+ if (!fenced) await this.store.releaseRunLock(decision.runId);
8616
8901
  }
8617
8902
  }
8618
8903
  /** Earliest clock time a suspended turn's deadline is worth LOOKING at again (`runId` → clock ms) —
@@ -8808,6 +9093,9 @@ function childIdsOf(checkpoints) {
8808
9093
  }
8809
9094
  __name(childIdsOf, "childIdsOf");
8810
9095
 
9096
+ // src/run-history.ts
9097
+ var RUN_HISTORY_FIXTURE_VERSION = 1;
9098
+
8811
9099
  // src/run-facets.ts
8812
9100
  function facetOrigin(origin) {
8813
9101
  return origin?.trim() ? origin : null;
@@ -9361,13 +9649,14 @@ var CodecStateStore = class {
9361
9649
  }
9362
9650
  inner;
9363
9651
  codec;
9652
+ extended;
9364
9653
  /** Atomic claims touch only searchable metadata and bypass payload encoding. */
9365
9654
  tryAdmitSingleton;
9366
9655
  /**
9367
9656
  * Forwarded verbatim — a facet cell counts `status`/`origin`, neither of which the codec touches
9368
- * (it encodes payloads: input/output/error). Bound as a PROPERTY, and only when the inner store
9369
- * actually aggregates, so `store.runFacets?.()` still reports absence through the wrapper instead
9370
- * of resolving to a silently wrong empty result.
9657
+ * (it encodes payloads: input/output, plus events/error under `extended` coverage). Bound as a
9658
+ * PROPERTY, and only when the inner store actually aggregates, so `store.runFacets?.()` still
9659
+ * reports absence through the wrapper instead of resolving to a silently wrong empty result.
9371
9660
  */
9372
9661
  runFacets;
9373
9662
  /**
@@ -9419,9 +9708,10 @@ var CodecStateStore = class {
9419
9708
  updateSchedule;
9420
9709
  deleteSchedule;
9421
9710
  listSchedules;
9422
- constructor(inner, codec) {
9711
+ constructor(inner, codec, options = {}) {
9423
9712
  this.inner = inner;
9424
9713
  this.codec = codec;
9714
+ this.extended = options.coverage === "extended";
9425
9715
  if (inner.tryAdmitSingleton) this.tryAdmitSingleton = inner.tryAdmitSingleton.bind(inner);
9426
9716
  const deleteRuns = inner.deleteRuns;
9427
9717
  if (deleteRuns) this.deleteRuns = (ids) => deleteRuns.call(inner, ids);
@@ -9479,32 +9769,52 @@ var CodecStateStore = class {
9479
9769
  return v === void 0 ? void 0 : this.codec.decode(v);
9480
9770
  }
9481
9771
  encRun(run) {
9482
- return {
9772
+ const next = {
9483
9773
  ...run,
9484
9774
  input: this.enc(run.input),
9485
9775
  output: this.enc(run.output)
9486
9776
  };
9777
+ if (this.extended && run.error !== void 0) {
9778
+ next.error = this.enc(run.error);
9779
+ }
9780
+ return next;
9487
9781
  }
9488
9782
  decRun(run) {
9489
- return run && {
9783
+ if (!run) return run;
9784
+ const next = {
9490
9785
  ...run,
9491
9786
  input: this.dec(run.input),
9492
9787
  output: this.dec(run.output)
9493
9788
  };
9789
+ if (this.extended && run.error !== void 0) {
9790
+ next.error = this.dec(run.error);
9791
+ }
9792
+ return next;
9494
9793
  }
9495
9794
  encCp(cp) {
9496
- return {
9795
+ const next = {
9497
9796
  ...cp,
9498
9797
  input: this.enc(cp.input),
9499
9798
  output: this.enc(cp.output)
9500
9799
  };
9800
+ if (this.extended) {
9801
+ if (cp.events !== void 0) next.events = this.enc(cp.events);
9802
+ if (cp.error !== void 0) next.error = this.enc(cp.error);
9803
+ }
9804
+ return next;
9501
9805
  }
9502
9806
  decCp(cp) {
9503
- return cp && {
9807
+ if (!cp) return cp;
9808
+ const next = {
9504
9809
  ...cp,
9505
9810
  input: this.dec(cp.input),
9506
9811
  output: this.dec(cp.output)
9507
9812
  };
9813
+ if (this.extended) {
9814
+ if (cp.events !== void 0) next.events = this.dec(cp.events);
9815
+ if (cp.error !== void 0) next.error = this.dec(cp.error);
9816
+ }
9817
+ return next;
9508
9818
  }
9509
9819
  encSpec(spec) {
9510
9820
  return "input" in spec ? {
@@ -9543,6 +9853,9 @@ var CodecStateStore = class {
9543
9853
  };
9544
9854
  if ("input" in patch) next.input = this.enc(patch.input);
9545
9855
  if ("output" in patch) next.output = this.enc(patch.output);
9856
+ if (this.extended && patch.error !== void 0) {
9857
+ next.error = this.enc(patch.error);
9858
+ }
9546
9859
  return next;
9547
9860
  }
9548
9861
  async getRun(runId) {
@@ -9578,8 +9891,8 @@ var CodecStateStore = class {
9578
9891
  tryLockRun(runId, owner, leaseUntilMs, nowMs) {
9579
9892
  return this.inner.tryLockRun(runId, owner, leaseUntilMs, nowMs);
9580
9893
  }
9581
- releaseRunLock(runId) {
9582
- return this.inner.releaseRunLock(runId);
9894
+ releaseRunLock(runId, owner) {
9895
+ return owner === void 0 ? this.inner.releaseRunLock(runId) : this.inner.releaseRunLock(runId, owner);
9583
9896
  }
9584
9897
  renewRunLock(runId, owner, leaseUntilMs) {
9585
9898
  return this.inner.renewRunLock(runId, owner, leaseUntilMs);
@@ -10087,9 +10400,9 @@ var InMemoryStateStore = class {
10087
10400
  run.lockedUntil = leaseUntilMs;
10088
10401
  return true;
10089
10402
  }
10090
- async releaseRunLock(runId) {
10403
+ async releaseRunLock(runId, owner) {
10091
10404
  const run = this.runs.get(runId);
10092
- if (run) {
10405
+ if (run && (owner === void 0 || run.lockedBy === owner)) {
10093
10406
  run.lockedBy = void 0;
10094
10407
  run.lockedUntil = void 0;
10095
10408
  }
@@ -10439,6 +10752,7 @@ export {
10439
10752
  ContinueAsNew,
10440
10753
  DEFAULT_CONCURRENCY_STATUSES,
10441
10754
  DEFAULT_PARTITION,
10755
+ DEFAULT_STALLED_AFTER_MS,
10442
10756
  DURABLE_OPTIONS,
10443
10757
  DURABLE_OPTIONS_CANONICAL,
10444
10758
  DURABLE_STEP_CONFIG,
@@ -10462,6 +10776,7 @@ export {
10462
10776
  NestedWorkflowCallError,
10463
10777
  NonDeterminismError,
10464
10778
  QueueController,
10779
+ RUN_HISTORY_FIXTURE_VERSION,
10465
10780
  RUN_VALUE_FACET_LIMIT,
10466
10781
  RUN_VALUE_FACET_SCAN,
10467
10782
  RemoteStepError,
@@ -10479,6 +10794,7 @@ export {
10479
10794
  ScheduleAlreadyExistsError,
10480
10795
  ScheduleClient,
10481
10796
  ScheduleNotFoundError,
10797
+ SchemaValidationError,
10482
10798
  SignalTimeoutError,
10483
10799
  SingletonQueueFullError,
10484
10800
  TERMINAL_RUN_STATUSES,
@@ -10535,6 +10851,7 @@ export {
10535
10851
  facetOrigin,
10536
10852
  fanPageBounds,
10537
10853
  fanPageEtag,
10854
+ findStalledRuns,
10538
10855
  foldedCount,
10539
10856
  handoffHandlerId,
10540
10857
  handoffName,
@@ -10589,6 +10906,7 @@ export {
10589
10906
  runStepHandler,
10590
10907
  runValueFacetsFromRuns,
10591
10908
  sanitizeQueueToken,
10909
+ scheduleRunIdAt,
10592
10910
  scheduledRunId,
10593
10911
  singletonAdmissionDenialPatch,
10594
10912
  singletonAdmissionTags,