@dudousxd/nestjs-durable-core 0.78.0 → 0.80.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
@@ -22,6 +22,7 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
22
22
  // src/index.ts
23
23
  var index_exports = {};
24
24
  __export(index_exports, {
25
+ CHILD_EDGE_PREFIXES: () => CHILD_EDGE_PREFIXES,
25
26
  CONTROL_FLOW_SIGNAL: () => CONTROL_FLOW_SIGNAL,
26
27
  CURRENT_PROTOCOL_VERSION: () => CURRENT_PROTOCOL_VERSION,
27
28
  CodecStateStore: () => CodecStateStore,
@@ -42,6 +43,7 @@ __export(index_exports, {
42
43
  FAN_SUMMARY_FAILURES: () => FAN_SUMMARY_FAILURES,
43
44
  FatalError: () => FatalError,
44
45
  GatherError: () => GatherError,
46
+ HANDOFF_SEQ_BASE: () => HANDOFF_SEQ_BASE,
45
47
  InMemoryAdmissionBackend: () => InMemoryAdmissionBackend,
46
48
  InMemoryStateStore: () => InMemoryStateStore,
47
49
  InMemoryTransport: () => InMemoryTransport,
@@ -76,9 +78,11 @@ __export(index_exports, {
76
78
  WorkflowEngine: () => WorkflowEngine,
77
79
  WorkflowSuspended: () => WorkflowSuspended,
78
80
  aggregateAnnouncements: () => aggregateAnnouncements,
81
+ aggregatesCheckpoints: () => aggregatesCheckpoints,
79
82
  announcementsOf: () => announcementsOf,
80
83
  applyAttributeQuery: () => applyAttributeQuery,
81
84
  assertConcurrency: () => assertConcurrency,
85
+ assertRoutes: () => assertRoutes,
82
86
  attributeColumnFor: () => attributeColumnFor,
83
87
  attributeOperand: () => attributeOperand,
84
88
  attributePredicateOperands: () => attributePredicateOperands,
@@ -88,10 +92,14 @@ __export(index_exports, {
88
92
  buildWorkflowDirectory: () => buildWorkflowDirectory,
89
93
  canRoute: () => canRoute,
90
94
  capableWorkers: () => capableWorkers,
95
+ checkpointNameKind: () => checkpointNameKind,
96
+ checkpointStatsOf: () => checkpointStatsOf,
91
97
  childIdsOf: () => childIdsOf,
92
98
  classifyWaiterToken: () => classifyWaiterToken,
93
99
  clearDurableExecutionWrappers: () => clearDurableExecutionWrappers,
94
100
  clearTerminalSingletonAdmission: () => clearTerminalSingletonAdmission,
101
+ codeAndDetails: () => codeAndDetails,
102
+ codeOf: () => codeOf,
95
103
  collectMetrics: () => collectMetrics,
96
104
  concurrencyTag: () => concurrencyTag,
97
105
  controlPlaneDescriptor: () => controlPlaneDescriptor,
@@ -104,13 +112,20 @@ __export(index_exports, {
104
112
  debug: () => debug,
105
113
  describeWorker: () => describeWorker,
106
114
  descriptorHash: () => descriptorHash,
115
+ detailsOf: () => detailsOf,
107
116
  error: () => error,
117
+ etagMatches: () => etagMatches,
108
118
  eventMatchOf: () => eventMatchOf,
109
119
  eventMatches: () => eventMatches,
110
120
  eventNameOf: () => eventNameOf,
111
121
  eventPrefix: () => eventPrefix,
112
122
  eventToken: () => eventToken,
113
123
  facetOrigin: () => facetOrigin,
124
+ fanPageBounds: () => fanPageBounds,
125
+ fanPageEtag: () => fanPageEtag,
126
+ foldedCount: () => foldedCount,
127
+ handoffHandlerId: () => handoffHandlerId,
128
+ handoffName: () => handoffName,
114
129
  hasDurableExecutionWrappers: () => hasDurableExecutionWrappers,
115
130
  heartbeatStatus: () => heartbeatStatus,
116
131
  indexWaitersByRun: () => indexWaitersByRun,
@@ -122,6 +137,7 @@ __export(index_exports, {
122
137
  isNonEmptyRunScope: () => isNonEmptyRunScope,
123
138
  isWorkflowControlFlowSignal: () => isWorkflowControlFlowSignal,
124
139
  listSignalWaitersOfRuns: () => listSignalWaitersOfRuns,
140
+ liveHandoff: () => liveHandoff,
125
141
  log: () => log,
126
142
  matchesAttributes: () => matchesAttributes,
127
143
  mergeRunFacetRows: () => mergeRunFacetRows,
@@ -132,17 +148,23 @@ __export(index_exports, {
132
148
  normalizeAttributeRows: () => normalizeAttributeRows,
133
149
  normalizeDescriptor: () => normalizeDescriptor,
134
150
  observedAnnouncements: () => observedAnnouncements,
151
+ onErrorRunId: () => onErrorRunId,
135
152
  pageFan: () => pageFan,
153
+ pageFanFromStore: () => pageFanFromStore,
136
154
  parentRunIdOf: () => parentRunIdOf,
137
155
  parseDuration: () => parseDuration,
138
156
  partitionOfToken: () => partitionOfToken,
139
157
  planDispatch: () => planDispatch,
140
158
  prevCronFireMs: () => prevCronFireMs,
159
+ queryCheckpointsOf: () => queryCheckpointsOf,
141
160
  readSearchAttributes: () => readSearchAttributes,
142
161
  requiredCapabilities: () => requiredCapabilities,
143
162
  resolveConcurrency: () => resolveConcurrency,
144
163
  resolveRouting: () => resolveRouting,
145
164
  resolveRunWaiting: () => resolveRunWaiting,
165
+ retiredHandoffName: () => retiredHandoffName,
166
+ routeFor: () => routeFor,
167
+ runDetailEtag: () => runDetailEtag,
146
168
  runDurableExecution: () => runDurableExecution,
147
169
  runInStepLogger: () => runInStepLogger,
148
170
  runInStepScope: () => runInStepScope,
@@ -163,6 +185,7 @@ __export(index_exports, {
163
185
  subEvent: () => subEvent,
164
186
  subProcess: () => subProcess,
165
187
  summarizeFan: () => summarizeFan,
188
+ summarizeRunFromStore: () => summarizeRunFromStore,
166
189
  summarizeTimeline: () => summarizeTimeline,
167
190
  tenantGroup: () => tenantGroup,
168
191
  useDurableExecution: () => useDurableExecution,
@@ -435,10 +458,16 @@ var FatalError = class extends Error {
435
458
  __name(this, "FatalError");
436
459
  }
437
460
  code;
438
- constructor(message, code) {
461
+ /** Structured, JSON-serializable detail (e.g. validation issues). It travels with the failure to
462
+ * the run's error, a parent's `allSettled` item and an onError handler's input. */
463
+ details;
464
+ /** Read by the step result builders, so a dispatched step's retry policy honours it too. */
465
+ retryable = false;
466
+ constructor(message, code, details) {
439
467
  super(message);
440
468
  this.name = "FatalError";
441
469
  this.code = code;
470
+ this.details = details;
442
471
  }
443
472
  };
444
473
  var ConcurrencyLimitError = class extends Error {
@@ -516,6 +545,8 @@ var NonDeterminismError = class extends Error {
516
545
  static {
517
546
  __name(this, "NonDeterminismError");
518
547
  }
548
+ /** Marks the failure as a broken deploy, which an `onError` route never takes. */
549
+ code = "non_determinism";
519
550
  runId;
520
551
  seq;
521
552
  constructor(runId, seq, expected, recorded) {
@@ -927,6 +958,9 @@ async function runStepHandler(task, handler) {
927
958
  ...typeof e?.code === "string" ? {
928
959
  code: e.code
929
960
  } : {},
961
+ ...e?.details !== void 0 ? {
962
+ details: e.details
963
+ } : {},
930
964
  ...typeof e?.retryable === "boolean" ? {
931
965
  retryable: e.retryable
932
966
  } : {}
@@ -1003,11 +1037,36 @@ __name(isFailedCompletion, "isFailedCompletion");
1003
1037
  function unwrapCompletion(payload, label) {
1004
1038
  const c = payload;
1005
1039
  if (c && typeof c === "object" && "ok" in c && c.ok === false) {
1006
- throw new FatalError(`${label} failed: ${c.error}`);
1040
+ throw new FatalError(`${label} failed: ${c.error}`, c.code, c.details);
1007
1041
  }
1008
1042
  return c?.value;
1009
1043
  }
1010
1044
  __name(unwrapCompletion, "unwrapCompletion");
1045
+ function failedCompletion(error2) {
1046
+ return {
1047
+ ok: false,
1048
+ error: error2.message,
1049
+ ...error2.code !== void 0 ? {
1050
+ code: error2.code
1051
+ } : {},
1052
+ ...error2.details !== void 0 ? {
1053
+ details: error2.details
1054
+ } : {}
1055
+ };
1056
+ }
1057
+ __name(failedCompletion, "failedCompletion");
1058
+ function completionError(c) {
1059
+ return {
1060
+ message: c.error,
1061
+ ...c.code !== void 0 ? {
1062
+ code: c.code
1063
+ } : {},
1064
+ ...c.details !== void 0 ? {
1065
+ details: c.details
1066
+ } : {}
1067
+ };
1068
+ }
1069
+ __name(completionError, "completionError");
1011
1070
 
1012
1071
  // src/concurrency.ts
1013
1072
  var DEFAULT_CONCURRENCY_STATUSES = [
@@ -1328,7 +1387,7 @@ function allWaves(size, concurrency) {
1328
1387
  };
1329
1388
  }
1330
1389
  __name(allWaves, "allWaves");
1331
- var ENGINE_CHILD_ID = /^(.+)\.(?:all\.\d+\.(?:w\d+\.)?\d+|child\.\d+)$/;
1390
+ var ENGINE_CHILD_ID = /^(.+)\.(?:all\.\d+\.(?:w\d+\.)?\d+|child\.\d+|onError\.\d+)$/;
1332
1391
  function parentRunIdOf(runId) {
1333
1392
  return ENGINE_CHILD_ID.exec(runId)?.[1];
1334
1393
  }
@@ -1413,7 +1472,11 @@ function canonicalizeRegistrations(regs) {
1413
1472
  requires: r.requires ? [
1414
1473
  ...new Set(r.requires)
1415
1474
  ].sort() : null,
1416
- origin: r.origin ?? null
1475
+ origin: r.origin ?? null,
1476
+ // Conditional, so a registration that states no routes keeps the hash it had before routes existed.
1477
+ ...r.onError !== void 0 ? {
1478
+ onError: canonicalRoutes(r.onError)
1479
+ } : {}
1417
1480
  }));
1418
1481
  const byJson = /* @__PURE__ */ new Map();
1419
1482
  for (const entry of canonical) byJson.set(JSON.stringify(entry), entry);
@@ -1422,6 +1485,20 @@ function canonicalizeRegistrations(regs) {
1422
1485
  ].sort().map((k) => byJson.get(k));
1423
1486
  }
1424
1487
  __name(canonicalizeRegistrations, "canonicalizeRegistrations");
1488
+ function canonicalRoutes(routes) {
1489
+ const byJson = /* @__PURE__ */ new Map();
1490
+ for (const r of routes) {
1491
+ const entry = {
1492
+ code: r.code ?? null,
1493
+ workflow: r.workflow
1494
+ };
1495
+ byJson.set(JSON.stringify(entry), entry);
1496
+ }
1497
+ return [
1498
+ ...byJson.keys()
1499
+ ].sort().map((k) => byJson.get(k));
1500
+ }
1501
+ __name(canonicalRoutes, "canonicalRoutes");
1425
1502
  function canonicalizeForHash(d) {
1426
1503
  const set = /* @__PURE__ */ __name((xs) => [
1427
1504
  ...new Set(xs)
@@ -1456,13 +1533,13 @@ function fnv1a64Hex(input) {
1456
1533
  const OFFSET = 0xcbf29ce484222325n;
1457
1534
  const PRIME = 0x100000001b3n;
1458
1535
  const MASK = 0xffffffffffffffffn;
1459
- let hash = OFFSET;
1536
+ let hash2 = OFFSET;
1460
1537
  for (let i = 0; i < input.length; i += 1) {
1461
- hash ^= BigInt(input.charCodeAt(i) & 255);
1462
- hash ^= BigInt(input.charCodeAt(i) >> 8 & 255);
1463
- hash = hash * PRIME & MASK;
1538
+ hash2 ^= BigInt(input.charCodeAt(i) & 255);
1539
+ hash2 ^= BigInt(input.charCodeAt(i) >> 8 & 255);
1540
+ hash2 = hash2 * PRIME & MASK;
1464
1541
  }
1465
- return hash.toString(16).padStart(16, "0");
1542
+ return hash2.toString(16).padStart(16, "0");
1466
1543
  }
1467
1544
  __name(fnv1a64Hex, "fnv1a64Hex");
1468
1545
  function descriptorHash(descriptor) {
@@ -1578,6 +1655,20 @@ function aggregateAnnouncements(descriptors) {
1578
1655
  const requires = [
1579
1656
  ...requiresByJson.values()
1580
1657
  ].sort((x, y) => x.join(",").localeCompare(y.join(",")));
1658
+ const routesByJson = /* @__PURE__ */ new Map();
1659
+ for (const a of announcements) {
1660
+ if (a.onError === void 0) continue;
1661
+ const canonical = canonicalRoutes(a.onError).map((r) => r.code === null ? {
1662
+ workflow: r.workflow
1663
+ } : {
1664
+ code: r.code,
1665
+ workflow: r.workflow
1666
+ });
1667
+ routesByJson.set(JSON.stringify(canonical), canonical);
1668
+ }
1669
+ const onError = [
1670
+ ...routesByJson.keys()
1671
+ ].sort().map((k) => routesByJson.get(k) ?? []);
1581
1672
  const disagreements = [];
1582
1673
  if (groups.length > 1) disagreements.push({
1583
1674
  axis: "group",
@@ -1593,6 +1684,14 @@ function aggregateAnnouncements(descriptors) {
1593
1684
  values: requires.map((r) => r.join(","))
1594
1685
  });
1595
1686
  }
1687
+ if (onError.length > 1) {
1688
+ disagreements.push({
1689
+ axis: "onError",
1690
+ values: [
1691
+ ...routesByJson.keys()
1692
+ ].sort()
1693
+ });
1694
+ }
1596
1695
  const first = announcements[0];
1597
1696
  if (!first) continue;
1598
1697
  out.push({
@@ -1605,6 +1704,7 @@ function aggregateAnnouncements(descriptors) {
1605
1704
  groups,
1606
1705
  origins,
1607
1706
  requires,
1707
+ onError,
1608
1708
  runtimes: [
1609
1709
  ...new Set(announcements.map((a) => a.runtime))
1610
1710
  ].sort(),
@@ -1638,6 +1738,7 @@ function observedAnnouncements(heartbeats, covered) {
1638
1738
  ],
1639
1739
  origins: [],
1640
1740
  requires: [],
1741
+ onError: [],
1641
1742
  runtimes: [],
1642
1743
  instances: [
1643
1744
  ...instances
@@ -1996,6 +2097,13 @@ function planDispatch(requires, liveDescriptors, cp, token) {
1996
2097
  __name(planDispatch, "planDispatch");
1997
2098
 
1998
2099
  // src/interfaces.ts
2100
+ function checkpointNameKind(name) {
2101
+ if (name.startsWith("signal:child:")) return "child";
2102
+ if (name.startsWith("fan:")) return "folded";
2103
+ if (name.startsWith("spawn:")) return "spawn";
2104
+ return "step";
2105
+ }
2106
+ __name(checkpointNameKind, "checkpointNameKind");
1999
2107
  var TERMINAL_RUN_STATUSES = [
2000
2108
  "completed",
2001
2109
  "failed",
@@ -2008,6 +2116,71 @@ function isNonEmptyRunScope(scope) {
2008
2116
  }
2009
2117
  __name(isNonEmptyRunScope, "isNonEmptyRunScope");
2010
2118
 
2119
+ // src/on-error.ts
2120
+ var UNROUTABLE_CODES = /* @__PURE__ */ new Set([
2121
+ "non_determinism",
2122
+ "nested_workflow_call"
2123
+ ]);
2124
+ var HANDOFF_SEQ_BASE = 1e9;
2125
+ var onErrorRunId = /* @__PURE__ */ __name((runId, attempt) => `${runId}.onError.${attempt}`, "onErrorRunId");
2126
+ var handoffName = /* @__PURE__ */ __name((handlerId) => `signal:child:${handlerId}`, "handoffName");
2127
+ var retiredHandoffName = /* @__PURE__ */ __name((handlerId) => `onError:retried:${handlerId}`, "retiredHandoffName");
2128
+ function handoffHandlerId(runId, cp) {
2129
+ const prefix = `signal:child:${runId}.onError.`;
2130
+ return cp.seq >= HANDOFF_SEQ_BASE && cp.name.startsWith(prefix) ? cp.name.slice("signal:child:".length) : void 0;
2131
+ }
2132
+ __name(handoffHandlerId, "handoffHandlerId");
2133
+ function liveHandoff(runId, checkpoints) {
2134
+ let live;
2135
+ for (const cp of checkpoints) {
2136
+ if (handoffHandlerId(runId, cp) !== void 0 && (!live || cp.seq > live.seq)) live = cp;
2137
+ }
2138
+ return live;
2139
+ }
2140
+ __name(liveHandoff, "liveHandoff");
2141
+ function routeFor(routes, error2) {
2142
+ if (!routes?.length || error2.code && UNROUTABLE_CODES.has(error2.code)) return void 0;
2143
+ return (error2.code ? routes.find((r) => r.code === error2.code) : void 0) ?? routes.find((r) => r.code === void 0);
2144
+ }
2145
+ __name(routeFor, "routeFor");
2146
+ function assertRoutes(workflow, routes) {
2147
+ const seen = /* @__PURE__ */ new Set();
2148
+ for (const r of routes) {
2149
+ if (seen.has(r.code)) {
2150
+ throw new Error(r.code === void 0 ? `workflow ${workflow}: more than one onError route without a code` : `workflow ${workflow}: more than one onError route for code "${r.code}"`);
2151
+ }
2152
+ seen.add(r.code);
2153
+ }
2154
+ }
2155
+ __name(assertRoutes, "assertRoutes");
2156
+ function codeOf(err) {
2157
+ const e = err;
2158
+ return [
2159
+ e?.code,
2160
+ e?.stepError?.code,
2161
+ e?.error?.code
2162
+ ].find((c) => typeof c === "string");
2163
+ }
2164
+ __name(codeOf, "codeOf");
2165
+ function detailsOf(err) {
2166
+ const e = err;
2167
+ return e?.details ?? e?.stepError?.details ?? e?.error?.details;
2168
+ }
2169
+ __name(detailsOf, "detailsOf");
2170
+ function codeAndDetails(err) {
2171
+ const code = codeOf(err);
2172
+ const details = detailsOf(err);
2173
+ return {
2174
+ ...code !== void 0 ? {
2175
+ code
2176
+ } : {},
2177
+ ...details !== void 0 ? {
2178
+ details
2179
+ } : {}
2180
+ };
2181
+ }
2182
+ __name(codeAndDetails, "codeAndDetails");
2183
+
2011
2184
  // src/tenant-group.ts
2012
2185
  function tenantGroup(baseGroup, tenant) {
2013
2186
  return tenant !== void 0 && tenant !== "" && tenant !== "default" ? `${baseGroup}@${tenant}` : baseGroup;
@@ -3067,7 +3240,8 @@ function createWorkflowCtx(host, runId, compensations, workflow = "", searchAttr
3067
3240
  kind: "local",
3068
3241
  error: {
3069
3242
  message: err instanceof Error ? err.message : String(err),
3070
- stack: err instanceof Error ? err.stack : void 0
3243
+ stack: err instanceof Error ? err.stack : void 0,
3244
+ ...codeAndDetails(err)
3071
3245
  },
3072
3246
  events,
3073
3247
  attempts: attempt,
@@ -3323,12 +3497,12 @@ function createWorkflowCtx(host, runId, compensations, workflow = "", searchAttr
3323
3497
  }
3324
3498
  ]));
3325
3499
  }, "child");
3326
- const all = /* @__PURE__ */ __name(async (workflow2, inputs, opts) => {
3500
+ const fan = /* @__PURE__ */ __name(async (workflow2, inputs, opts, settle) => {
3327
3501
  if (inputs.length === 0) return [];
3328
3502
  if (stepNameOf(workflow2) !== void 0) {
3329
- return allSteps(workflow2, inputs, opts);
3503
+ return allSteps(workflow2, inputs, opts, settle);
3330
3504
  }
3331
- const mode = opts?.mode ?? "waitAll";
3505
+ const mode = settle ? "waitAll" : opts?.mode ?? "waitAll";
3332
3506
  const name = workflowName(workflow2);
3333
3507
  const waves = allWaves(inputs.length, opts?.concurrency);
3334
3508
  const positions = inputs.map(() => pos.next());
@@ -3450,24 +3624,45 @@ function createWorkflowCtx(host, runId, compensations, workflow = "", searchAttr
3450
3624
  throw new WorkflowSuspended(void 0, fanJoin(active.map((k) => allWait(runId, firstSeq, waves ? k : void 0))));
3451
3625
  }
3452
3626
  const outputs = [];
3627
+ const results = [];
3453
3628
  const failures = [];
3454
3629
  for (let i = 0; i < inputs.length; i += 1) {
3455
3630
  const c = existing[i]?.output;
3456
3631
  if (c && typeof c === "object" && "ok" in c && c.ok === false) {
3632
+ const { message, ...rest } = completionError({
3633
+ ...c,
3634
+ error: c.error ?? "unknown"
3635
+ });
3636
+ const failure = {
3637
+ id: id(i),
3638
+ error: message,
3639
+ ...rest
3640
+ };
3457
3641
  failures.push({
3458
3642
  index: i,
3459
- id: id(i),
3460
- error: c.error ?? "unknown"
3643
+ ...failure
3644
+ });
3645
+ results.push({
3646
+ status: "rejected",
3647
+ ...failure
3461
3648
  });
3462
3649
  outputs.push(void 0);
3463
3650
  } else {
3464
- outputs.push(c?.value);
3651
+ const value = c?.value;
3652
+ results.push({
3653
+ status: "fulfilled",
3654
+ value
3655
+ });
3656
+ outputs.push(value);
3465
3657
  }
3466
3658
  }
3659
+ if (settle) return results;
3467
3660
  if (failures.length > 0) throw new GatherError(failures, inputs.length);
3468
3661
  return outputs;
3469
- }, "all");
3470
- const allSteps = /* @__PURE__ */ __name(async (ref, inputs, opts) => {
3662
+ }, "fan");
3663
+ const all = /* @__PURE__ */ __name((workflow2, inputs, opts) => fan(workflow2, inputs, opts, false), "all");
3664
+ const allSettled = /* @__PURE__ */ __name((workflow2, inputs, opts) => fan(workflow2, inputs, opts, true), "allSettled");
3665
+ const allSteps = /* @__PURE__ */ __name(async (ref, inputs, opts, settle) => {
3471
3666
  if (opts?.queue !== void 0) {
3472
3667
  throw new Error("ctx.all: a flow-control queue is not supported on a step fan");
3473
3668
  }
@@ -3478,7 +3673,8 @@ function createWorkflowCtx(host, runId, compensations, workflow = "", searchAttr
3478
3673
  };
3479
3674
  const positions = inputs.map(() => pos.next());
3480
3675
  return host.callRemoteAll(runId, positions, def, inputs, {
3481
- mode: opts?.mode ?? "waitAll",
3676
+ mode: settle ? "waitAll" : opts?.mode ?? "waitAll",
3677
+ settle,
3482
3678
  concurrency: opts?.concurrency,
3483
3679
  transport: opts?.transport,
3484
3680
  admission: {
@@ -3796,6 +3992,7 @@ function createWorkflowCtx(host, runId, compensations, workflow = "", searchAttr
3796
3992
  task: guard("task", task),
3797
3993
  child: guard("child", child),
3798
3994
  all: guard("all", all),
3995
+ allSettled: guard("allSettled", allSettled),
3799
3996
  startChild: guard("startChild", startChild),
3800
3997
  breakpoint: guard("breakpoint", breakpoint),
3801
3998
  webhook: guardedWebhook,
@@ -3821,9 +4018,11 @@ function mergeTags(staticTags, runTags) {
3821
4018
  __name(mergeTags, "mergeTags");
3822
4019
  var BREAKPOINT = "breakpoint";
3823
4020
  var isBreakpoint = /* @__PURE__ */ __name((cp) => cp.status === "pending" && cp.name.startsWith(BREAKPOINT), "isBreakpoint");
4021
+ var ANNOUNCED_ROUTES_TTL_MS = 5e3;
3824
4022
  var VERSION_UNDECLARED_TAG = "version:undeclared";
3825
4023
  var versionKey = /* @__PURE__ */ __name((name, version) => `${name}@${version}`, "versionKey");
3826
4024
  var STEP_FAN_CHUNK = 200;
4025
+ var RESULT_BATCH_MAX = 256;
3827
4026
  function shuffle(items) {
3828
4027
  for (let i = items.length - 1; i > 0; i -= 1) {
3829
4028
  const j = Math.floor(Math.random() * (i + 1));
@@ -3853,6 +4052,16 @@ function isNewerVersion(a, b) {
3853
4052
  }) > 0;
3854
4053
  }
3855
4054
  __name(isNewerVersion, "isNewerVersion");
4055
+ var SETTLE_ELIGIBLE_STATUSES = [
4056
+ "pending",
4057
+ "running",
4058
+ "suspended",
4059
+ "blocked",
4060
+ "failed",
4061
+ "cancelling"
4062
+ ];
4063
+ var SUSPEND_ELIGIBLE_STATUSES = SETTLE_ELIGIBLE_STATUSES.filter((status) => status !== "cancelling");
4064
+ var RECOVERY_BATCH = 500;
3856
4065
  var NamespaceMismatch = class NamespaceMismatch2 extends Error {
3857
4066
  static {
3858
4067
  __name(this, "NamespaceMismatch");
@@ -3931,6 +4140,8 @@ var WorkflowEngine = class {
3931
4140
  updateValidators = /* @__PURE__ */ new Map();
3932
4141
  /** Runs being cancelled WITH saga compensation — see `cancel({ compensate: true })`. */
3933
4142
  cancelRequested = /* @__PURE__ */ new Set();
4143
+ /** {@link announcedRoutes} reads, by group + workflow + version. */
4144
+ announcedRoutesCache = /* @__PURE__ */ new Map();
3934
4145
  /**
3935
4146
  * Seqs of the dispatched remote steps a turn suspended on, by run, while that turn executes on
3936
4147
  * THIS instance — so the turn can re-check them once its lease is released (see
@@ -3950,8 +4161,22 @@ var WorkflowEngine = class {
3950
4161
  awaitedStepFans = /* @__PURE__ */ new Map();
3951
4162
  /** Step fans whose join barrier this instance opened, by `runId:firstSeq` — see {@link settledHere}. */
3952
4163
  stepFansFiredHere = /* @__PURE__ */ new Map();
4164
+ /**
4165
+ * The in-process execution gate, by run id: the execution of the run currently in flight in THIS
4166
+ * process (`tail`), and at most one resume queued behind it (`queued`). See {@link resumeRun}.
4167
+ *
4168
+ * The run lease is keyed by engine instance, so it fences OTHER processes only: to the store, a
4169
+ * second execution in this same process holds the very same lease (and {@link execute} skips
4170
+ * `tryLockRun` when its snapshot says this instance holds it). Without this gate, a resume landing
4171
+ * while this process was still executing the run (a signal, a late step result, a child wake, an
4172
+ * explicit `resume()`) went straight past the lease and ran the body concurrently: a
4173
+ * not-yet-checkpointed step executed twice.
4174
+ */
4175
+ runGates = /* @__PURE__ */ new Map();
3953
4176
  /** The tail of each run's queue of results being handled on this instance — see {@link serializedPerRun}. */
3954
4177
  resultChains = /* @__PURE__ */ new Map();
4178
+ /** Each run's results waiting to be settled on this instance — see {@link completeRemoteResult}. */
4179
+ resultQueues = /* @__PURE__ */ new Map();
3955
4180
  /** Flow-control admission backend for remote steps (see {@link registerQueue}). */
3956
4181
  admission;
3957
4182
  /** Runs on THIS instance blocked on admission, by queue — woken early on a freed-slot signal. */
@@ -4158,6 +4383,7 @@ var WorkflowEngine = class {
4158
4383
  name,
4159
4384
  version,
4160
4385
  fn,
4386
+ onError: this.checkedRoutes(name, opts?.onError),
4161
4387
  tags: opts?.tags,
4162
4388
  singleton: opts?.singleton,
4163
4389
  concurrency: opts?.concurrency,
@@ -4227,6 +4453,7 @@ var WorkflowEngine = class {
4227
4453
  executionTimeoutMs: opts.executionTimeout != null ? parseDuration(opts.executionTimeout) : void 0,
4228
4454
  validateInput: opts.validateInput,
4229
4455
  requires: opts.requires,
4456
+ onError: this.checkedRoutes(name, opts.onError),
4230
4457
  remote: {
4231
4458
  group: opts.group,
4232
4459
  executor: opts.executor
@@ -4414,11 +4641,16 @@ var WorkflowEngine = class {
4414
4641
  if (registered.singleton) {
4415
4642
  await this.singletons.assertCapacity(name, registered.singleton, input);
4416
4643
  }
4644
+ const startAtMs = opts?.startAt instanceof Date ? opts.startAt.getTime() : opts?.startAt;
4645
+ const delayed = startAtMs !== void 0 && Number.isFinite(startAtMs) && startAtMs > this.clock();
4417
4646
  const run = {
4418
4647
  id: runId,
4419
4648
  workflow: name,
4420
4649
  workflowVersion: registered.version,
4421
- status: "pending",
4650
+ status: delayed ? "suspended" : "pending",
4651
+ ...delayed ? {
4652
+ wakeAt: startAtMs
4653
+ } : {},
4422
4654
  namespace: opts?.namespace ?? this.namespace,
4423
4655
  // DERIVED from the registration, exactly like `namespace` is derived from this engine — there is
4424
4656
  // deliberately no `opts.origin`, so a caller cannot label its run as another package's work.
@@ -4444,6 +4676,18 @@ var WorkflowEngine = class {
4444
4676
  error: winner.error
4445
4677
  };
4446
4678
  }
4679
+ if (delayed) {
4680
+ this.emit({
4681
+ type: "run.suspended",
4682
+ runId,
4683
+ workflow: name,
4684
+ namespace: run.namespace
4685
+ });
4686
+ return {
4687
+ runId,
4688
+ status: "suspended"
4689
+ };
4690
+ }
4447
4691
  await this.runDispatcher.dispatch(runId);
4448
4692
  if (this.controlPlane) {
4449
4693
  void this.controlPlane.publishControl({
@@ -4481,7 +4725,104 @@ var WorkflowEngine = class {
4481
4725
  resumeImplicit(runId) {
4482
4726
  return this.resumeRun(runId, false);
4483
4727
  }
4484
- async resumeRun(runId, allowFailed) {
4728
+ /**
4729
+ * One execution of a run at a time in this process. A resume that lands while the run executes
4730
+ * here neither joins that execution (the double-run) nor no-ops against its held lease (a lost
4731
+ * wake: its checkpoint may postdate the running turn's snapshot): it is QUEUED, and re-drives the
4732
+ * run once the running execution has settled and released its lease, re-reading the run then
4733
+ * (terminal: echoed, never re-executed). Resumes landing while one is already queued coalesce
4734
+ * into it: it has not started yet, so it will observe every write they made. Their callers get
4735
+ * the queued execution's result, i.e. the run's state after their own wake was applied.
4736
+ *
4737
+ * Across processes, exclusion stays the lease's job: {@link execute} still `tryLockRun`s when it
4738
+ * does not hold the lease.
4739
+ */
4740
+ resumeRun(runId, allowFailed) {
4741
+ const gate = this.runGates.get(runId);
4742
+ if (!gate) {
4743
+ const tail = this.resumeRunNow(runId, allowFailed);
4744
+ const fresh = {
4745
+ tail
4746
+ };
4747
+ this.runGates.set(runId, fresh);
4748
+ this.closeGateAfter(runId, fresh, tail);
4749
+ return tail;
4750
+ }
4751
+ let queued = gate.queued;
4752
+ if (queued) {
4753
+ queued.allowFailed ||= allowFailed;
4754
+ } else {
4755
+ const entry = {
4756
+ allowFailed
4757
+ };
4758
+ const result = gate.tail.catch(() => void 0).then(() => {
4759
+ if (gate.queued === entry) gate.queued = void 0;
4760
+ return this.resumeRunNow(runId, entry.allowFailed);
4761
+ });
4762
+ entry.result = result;
4763
+ queued = entry;
4764
+ gate.queued = queued;
4765
+ gate.tail = result;
4766
+ this.closeGateAfter(runId, gate, result);
4767
+ void this.track(result).catch(() => void 0);
4768
+ }
4769
+ if (currentWorkflowCtx()?.runId === runId) return this.echoCurrentStatus(runId);
4770
+ return queued.result;
4771
+ }
4772
+ /** Drop `gate` once `tail` settles, unless another execution queued behind it meanwhile. */
4773
+ closeGateAfter(runId, gate, tail) {
4774
+ const close = /* @__PURE__ */ __name(() => {
4775
+ if (this.runGates.get(runId) === gate && gate.tail === tail) this.runGates.delete(runId);
4776
+ }, "close");
4777
+ void tail.then(close, close);
4778
+ }
4779
+ /**
4780
+ * Release a run's lease AFTER its outcome is decided (persisted, or about to be returned). Best
4781
+ * effort, and that matters most on the failure path: the thing that failed the run is frequently
4782
+ * the very store this release talks to (a step that violated a DB constraint leaves Postgres
4783
+ * refusing every further statement on that connection), so the release throws too. A throwing
4784
+ * release in a `finally` REPLACES the run's real outcome with a lease write nobody asked about,
4785
+ * skips the post-turn re-drives after it, and, for a turn driven as a background resume, surfaces
4786
+ * as an unhandled rejection instead of a failed run.
4787
+ *
4788
+ * Letting it go is safe: the lease expires on its own and `recoverIncomplete` re-drives whatever
4789
+ * is still held. A lost release costs one lease window of latency; a masked error costs the
4790
+ * diagnosis.
4791
+ */
4792
+ async releaseLeaseBestEffort(runId) {
4793
+ try {
4794
+ await this.store.releaseRunLock(runId);
4795
+ } catch (err) {
4796
+ console.warn(`[nestjs-durable] releasing the lease of run ${runId} failed (it expires on its own and recovery re-drives): ${err instanceof Error ? err.message : String(err)}`);
4797
+ }
4798
+ }
4799
+ /**
4800
+ * The run's persisted state as a {@link RunResult}, without executing anything: the answer to a
4801
+ * resume issued from inside the run's own execution, and to a settle whose compare-and-set lost
4802
+ * (the path that won already emitted and notified). A run that is gone reads as `cancelled`, the
4803
+ * safest terminal reading, never a fabricated success.
4804
+ */
4805
+ async echoCurrentStatus(runId) {
4806
+ const run = await this.store.getRun(runId);
4807
+ return {
4808
+ runId,
4809
+ status: run?.status ?? "cancelled",
4810
+ output: run?.output,
4811
+ error: run?.error
4812
+ };
4813
+ }
4814
+ /**
4815
+ * The engine's stand-in for {@link StateStore.updateRunIf} on a store without it, called with the
4816
+ * store as `this`: read-then-write, which narrows the race to the gap between the read and the
4817
+ * write (the pre-CAS behavior, now applied to every settle) but cannot close it.
4818
+ */
4819
+ async updateRunIfFallback(runId, expected, patch) {
4820
+ const current = await this.getRun(runId);
4821
+ if (!current || !expected.includes(current.status)) return false;
4822
+ await this.updateRun(runId, patch);
4823
+ return true;
4824
+ }
4825
+ async resumeRunNow(runId, allowFailed) {
4485
4826
  const run = await this.store.getRun(runId);
4486
4827
  if (!run) throw new Error(`run ${runId} not found`);
4487
4828
  if (run.status === "cancelled" || run.status === "completed" || run.status === "dead" || run.status === "failed" && !allowFailed) {
@@ -4787,7 +5128,7 @@ var WorkflowEngine = class {
4787
5128
  async recoverIncomplete(nowMs = this.clock()) {
4788
5129
  if (this.draining) return [];
4789
5130
  const results = [];
4790
- for (const listed of await this.store.listIncompleteRuns(this.namespace)) {
5131
+ for (const listed of await this.orphanedRuns(nowMs)) {
4791
5132
  if (this.draining) break;
4792
5133
  const acquired = await this.store.tryLockRun(listed.id, this.instanceId, nowMs + this.leaseMs, nowMs);
4793
5134
  if (!acquired) continue;
@@ -4833,6 +5174,21 @@ var WorkflowEngine = class {
4833
5174
  return results;
4834
5175
  }
4835
5176
  /**
5177
+ * The runs {@link recoverIncomplete} should try to reclaim: incomplete runs whose lease is free or
5178
+ * expired, never the healthy ones a live turn is renewing. Every worker runs recovery on every
5179
+ * timer tick, so listing every running run and lock-probing each (one doomed UPDATE per healthy
5180
+ * run per worker per tick) was pure database load. The store answers it directly when it can
5181
+ * ({@link StateStore.listOrphanedRuns}, bounded by {@link RECOVERY_BATCH}); else the full list is
5182
+ * filtered here, which still spares the probes. Shuffled, like the due-timer pass, so instances
5183
+ * recovering at once spread over the batch instead of contending for the same leases.
5184
+ */
5185
+ async orphanedRuns(nowMs) {
5186
+ if (this.store.listOrphanedRuns) {
5187
+ return shuffle(await this.store.listOrphanedRuns(nowMs, RECOVERY_BATCH, this.namespace));
5188
+ }
5189
+ return shuffle((await this.store.listIncompleteRuns(this.namespace)).filter((run) => run.lockedUntil == null || run.lockedUntil <= nowMs));
5190
+ }
5191
+ /**
4836
5192
  * The remote step this run dispatched and is still awaiting a result for, if any — the signature
4837
5193
  * that tells {@link recoverIncomplete} "this run's work is ON THE TRANSPORT, not in a crashed
4838
5194
  * process". A `pending` remote checkpoint is written BEFORE the dispatch and settled by the
@@ -5004,6 +5360,24 @@ var WorkflowEngine = class {
5004
5360
  if (resurrecting) {
5005
5361
  const isFailureCompletion = /* @__PURE__ */ __name((v) => typeof v === "object" && v !== null && v.ok === false, "isFailureCompletion");
5006
5362
  for (const cp of await this.store.listCheckpoints(runId)) {
5363
+ const handlerId = handoffHandlerId(runId, cp);
5364
+ if (handlerId !== void 0) {
5365
+ await this.store.takeSignalWaiter(`child:${handlerId}`);
5366
+ await this.store.saveCheckpoint({
5367
+ ...cp,
5368
+ name: retiredHandoffName(handlerId),
5369
+ status: "completed"
5370
+ });
5371
+ const tags = run.tags?.filter((t) => !t.startsWith("onError:"));
5372
+ if (tags && tags.length !== run.tags?.length) {
5373
+ await this.store.updateRun(runId, {
5374
+ tags,
5375
+ updatedAt: /* @__PURE__ */ new Date()
5376
+ });
5377
+ run.tags = tags;
5378
+ }
5379
+ continue;
5380
+ }
5007
5381
  if (cp.status === "failed") {
5008
5382
  await this.store.saveCheckpoint({
5009
5383
  ...cp,
@@ -5148,13 +5522,13 @@ var WorkflowEngine = class {
5148
5522
  * `start` when a call site needs the outcome — `await start(...); const r = await waitForRun(id)`.
5149
5523
  */
5150
5524
  waitForRun(runId, opts) {
5151
- const TERMINAL = /* @__PURE__ */ new Set([
5525
+ const TERMINAL2 = /* @__PURE__ */ new Set([
5152
5526
  "completed",
5153
5527
  "failed",
5154
5528
  "cancelled",
5155
5529
  "dead"
5156
5530
  ]);
5157
- const isSettled = /* @__PURE__ */ __name((run) => opts?.until === "terminal" ? TERMINAL.has(run.status) : run.status !== "pending" && run.status !== "running" && run.status !== "cancelling" && !(run.status === "suspended" && run.awaitingDecisionTaskId !== void 0), "isSettled");
5531
+ const isSettled = /* @__PURE__ */ __name((run) => opts?.until === "terminal" ? TERMINAL2.has(run.status) : run.status !== "pending" && run.status !== "running" && run.status !== "cancelling" && !(run.status === "suspended" && run.awaitingDecisionTaskId !== void 0), "isSettled");
5158
5532
  const toResult = /* @__PURE__ */ __name((run) => ({
5159
5533
  runId,
5160
5534
  status: run.status,
@@ -5409,10 +5783,7 @@ var WorkflowEngine = class {
5409
5783
  async redriveSettledStepFans(runId, fans) {
5410
5784
  for (const fan of fans) {
5411
5785
  if (this.stepFansFiredHere.has(`${runId}:${fan.from}`)) continue;
5412
- const pending = this.store.countCheckpoints ? await this.store.countCheckpoints(runId, fan.from, fan.to, [
5413
- "pending"
5414
- ]) : (await this.store.listCheckpoints(runId)).filter((c) => c.seq >= fan.from && c.seq <= fan.to && c.status === "pending").length;
5415
- if (pending > 0) continue;
5786
+ if (await this.stepFanPending(runId, fan.from, fan.to) > 0) continue;
5416
5787
  const nowMs = this.clock();
5417
5788
  if (!await this.store.tryLockRun(runId, this.instanceId, nowMs + this.leaseMs, nowMs)) {
5418
5789
  return;
@@ -6122,6 +6493,209 @@ var WorkflowEngine = class {
6122
6493
  * us back here (a remote step finished, a timer fired) goes through `resume` like any TS workflow.
6123
6494
  */
6124
6495
  /**
6496
+ * The registered onError routes whose handler workflow nothing can run: not registered on this
6497
+ * engine, and no live worker group serves it. Meant for a boot-time warning: a typo in a handler's
6498
+ * name would otherwise surface only when the first run fails.
6499
+ */
6500
+ async unservedOnErrorRoutes() {
6501
+ const unserved = [];
6502
+ let groups;
6503
+ for (const registered of this.workflows.values()) {
6504
+ for (const route of registered.onError ?? []) {
6505
+ if (this.latest.has(route.workflow)) continue;
6506
+ groups ??= await this.pool.listWorkerGroups().catch(() => []);
6507
+ const token = sanitizeQueueToken(route.workflow);
6508
+ if (groups.some((g) => g === token || g.startsWith(`${token}@`))) continue;
6509
+ unserved.push({
6510
+ workflow: registered.name,
6511
+ route
6512
+ });
6513
+ }
6514
+ }
6515
+ return unserved;
6516
+ }
6517
+ /** Validate a workflow's {@link OnErrorRoute}s at registration. */
6518
+ checkedRoutes(name, routes) {
6519
+ if (!routes?.length) return void 0;
6520
+ assertRoutes(name, routes);
6521
+ return routes;
6522
+ }
6523
+ /**
6524
+ * Route a failed run to its {@link OnErrorRoute} handler, read from the workflow's local registration
6525
+ * or, for one served by convention, from what its live workers announce. Starts the handler run and
6526
+ * waits for it the way a parent waits for a `ctx.child`: a `child:<handlerId>` waiter plus a
6527
+ * `running` placeholder checkpoint carrying the handler's name and input, so the handler's terminal
6528
+ * state wakes the run (buffered and reconciled like any child). From then on every execution of the
6529
+ * run only follows the handler ({@link followHandoff}); the body never runs again. Undefined when no
6530
+ * route matches: the run fails as usual.
6531
+ *
6532
+ * The placeholder sits past every seq the body used and is left out of {@link remoteHistory}.
6533
+ */
6534
+ async routeFailure(run, error2) {
6535
+ const registered = this.workflows.get(versionKey(run.workflow, run.workflowVersion));
6536
+ const routes = registered ? registered.onError : await this.announcedRoutes(run, error2);
6537
+ const route = routeFor(routes, error2);
6538
+ if (!route) return void 0;
6539
+ const checkpoints = await this.handoffCheckpoints(run.id);
6540
+ const live = liveHandoff(run.id, checkpoints);
6541
+ if (live) return this.followHandoff(run, live);
6542
+ const attempt = checkpoints.length;
6543
+ const seq = HANDOFF_SEQ_BASE + attempt;
6544
+ const handlerId = onErrorRunId(run.id, attempt);
6545
+ const handler = {
6546
+ workflow: route.workflow,
6547
+ input: {
6548
+ runId: run.id,
6549
+ workflow: run.workflow,
6550
+ input: run.input,
6551
+ error: error2
6552
+ }
6553
+ };
6554
+ await this.store.putSignalWaiter({
6555
+ token: `child:${handlerId}`,
6556
+ runId: run.id,
6557
+ seq
6558
+ });
6559
+ await this.store.saveCheckpoint({
6560
+ ...instantCheckpoint({
6561
+ runId: run.id,
6562
+ seq,
6563
+ name: handoffName(handlerId),
6564
+ kind: "signal",
6565
+ status: "running"
6566
+ }),
6567
+ input: handler
6568
+ });
6569
+ const tag = `onError:${route.code ?? "*"}`;
6570
+ if (!run.tags?.includes(tag)) {
6571
+ const tags = [
6572
+ ...run.tags ?? [],
6573
+ tag
6574
+ ];
6575
+ await this.store.updateRun(run.id, {
6576
+ tags,
6577
+ updatedAt: /* @__PURE__ */ new Date()
6578
+ });
6579
+ run.tags = tags;
6580
+ }
6581
+ this.emit({
6582
+ type: "run.routed",
6583
+ runId: run.id,
6584
+ workflow: run.workflow,
6585
+ namespace: run.namespace,
6586
+ name: route.workflow,
6587
+ error: error2
6588
+ });
6589
+ this.startChildDeferred(handler.workflow, handler.input, handlerId, {
6590
+ namespace: run.namespace
6591
+ });
6592
+ return {
6593
+ kind: "suspended"
6594
+ };
6595
+ }
6596
+ /** A run's handoff checkpoints, live and retired: the only ones at {@link HANDOFF_SEQ_BASE} and up. */
6597
+ async handoffCheckpoints(runId) {
6598
+ const handoffs = await this.store.listCheckpointsByNamePrefix?.(runId, [
6599
+ `signal:child:${runId}.onError.`,
6600
+ `onError:retried:${runId}.onError.`
6601
+ ]);
6602
+ return (handoffs ?? await this.store.listCheckpoints(runId)).filter((cp) => cp.seq >= HANDOFF_SEQ_BASE);
6603
+ }
6604
+ /**
6605
+ * Where a run that handed its failure off stands: the handler's outcome once it settled (from the
6606
+ * wake's checkpoint, or off the handler run when the wake never arrived), else still waiting. Starts
6607
+ * the handler if the turn that handed off crashed before its deferred start ran.
6608
+ */
6609
+ async followHandoff(run, handoff) {
6610
+ if (handoff.status === "completed") {
6611
+ const outcome = handoff.output;
6612
+ return outcome.ok ? {
6613
+ kind: "completed",
6614
+ output: outcome.value
6615
+ } : {
6616
+ kind: "failed",
6617
+ error: completionError(outcome)
6618
+ };
6619
+ }
6620
+ const handlerId = handoffHandlerId(run.id, handoff);
6621
+ const handlerRun = await this.store.getRun(handlerId);
6622
+ if (handlerRun?.status === "completed") return {
6623
+ kind: "completed",
6624
+ output: handlerRun.output
6625
+ };
6626
+ if (handlerRun && TERMINAL_RUN_STATUSES.includes(handlerRun.status)) {
6627
+ return {
6628
+ kind: "failed",
6629
+ error: handlerRun.error ?? {
6630
+ message: `onError handler ${handlerId} ${handlerRun.status}`
6631
+ }
6632
+ };
6633
+ }
6634
+ if (!handlerRun) {
6635
+ const handler = handoff.input;
6636
+ this.startChildDeferred(handler.workflow, handler.input, handlerId, {
6637
+ namespace: run.namespace
6638
+ });
6639
+ }
6640
+ return {
6641
+ kind: "suspended"
6642
+ };
6643
+ }
6644
+ /**
6645
+ * The onError routes the live workers announce for a workflow the engine serves by convention: the
6646
+ * one set they agree on, or none when they state none or cannot be read. Workers that announce
6647
+ * different sets (a rollout in progress) route nothing, and the run's failure is reported as a
6648
+ * `run.unrouted` event. Announcers of another version of the workflow are ignored. The read is
6649
+ * cached for {@link ANNOUNCED_ROUTES_TTL_MS}, so a burst of failures costs one descriptor read.
6650
+ */
6651
+ async announcedRoutes(run, error2) {
6652
+ const group = tenantGroup(sanitizeQueueToken(run.workflow), run.namespace);
6653
+ const key = `${group}
6654
+ ${run.workflow}
6655
+ ${run.workflowVersion}`;
6656
+ const now = this.clock();
6657
+ let cached = this.announcedRoutesCache.get(key);
6658
+ if (!cached || now - cached.at > ANNOUNCED_ROUTES_TTL_MS) {
6659
+ cached = {
6660
+ at: now,
6661
+ sets: await this.readAnnouncedRoutes(run, group)
6662
+ };
6663
+ this.announcedRoutesCache.set(key, cached);
6664
+ }
6665
+ if (cached.sets.length > 1) {
6666
+ this.emit({
6667
+ type: "run.unrouted",
6668
+ runId: run.id,
6669
+ workflow: run.workflow,
6670
+ namespace: run.namespace,
6671
+ error: error2,
6672
+ reason: `live workers announce ${cached.sets.length} different onError route sets`
6673
+ });
6674
+ return void 0;
6675
+ }
6676
+ return cached.sets[0];
6677
+ }
6678
+ /** Every distinct onError route set the live workers of `group` announce for the run's workflow. */
6679
+ async readAnnouncedRoutes(run, group) {
6680
+ const transport = await this.pool.transportWithLiveGroup(group);
6681
+ if (!transport?.readWorkerDescriptors) return [];
6682
+ let descriptors;
6683
+ try {
6684
+ descriptors = await transport.readWorkerDescriptors(group);
6685
+ } catch {
6686
+ return [];
6687
+ }
6688
+ const sets = /* @__PURE__ */ new Map();
6689
+ for (const announced of aggregateAnnouncements(descriptors)) {
6690
+ if (announced.name !== run.workflow) continue;
6691
+ if (announced.version !== void 0 && announced.version !== run.workflowVersion) continue;
6692
+ for (const routes of announced.onError) sets.set(JSON.stringify(routes), routes);
6693
+ }
6694
+ return [
6695
+ ...sets.values()
6696
+ ];
6697
+ }
6698
+ /**
6125
6699
  * Apply a terminal/suspended transition once: persist the new run status, emit the matching
6126
6700
  * lifecycle event, and (on completion/failure) wake a waiting parent. Both the TS and remote
6127
6701
  * executors funnel their completed/failed/suspended outcomes through here so a status update can
@@ -6131,12 +6705,13 @@ var WorkflowEngine = class {
6131
6705
  async settleRun(run, outcome) {
6132
6706
  const updatedAt = /* @__PURE__ */ new Date();
6133
6707
  if (outcome.kind === "completed") {
6134
- await this.store.updateRun(run.id, {
6708
+ const applied2 = await (this.store.updateRunIf ?? this.updateRunIfFallback).call(this.store, run.id, SETTLE_ELIGIBLE_STATUSES, {
6135
6709
  status: "completed",
6136
6710
  output: outcome.output,
6137
6711
  error: void 0,
6138
6712
  updatedAt
6139
6713
  });
6714
+ if (!applied2) return this.echoCurrentStatus(run.id);
6140
6715
  this.emit({
6141
6716
  type: "run.completed",
6142
6717
  runId: run.id,
@@ -6155,42 +6730,36 @@ var WorkflowEngine = class {
6155
6730
  };
6156
6731
  }
6157
6732
  if (outcome.kind === "failed") {
6158
- await this.store.updateRun(run.id, {
6733
+ const handed = await this.routeFailure(run, outcome.error);
6734
+ if (handed && handed.kind !== "failed") return this.settleRun(run, handed);
6735
+ const error2 = handed?.error ?? outcome.error;
6736
+ const applied2 = await (this.store.updateRunIf ?? this.updateRunIfFallback).call(this.store, run.id, SETTLE_ELIGIBLE_STATUSES, {
6159
6737
  status: "failed",
6160
- error: outcome.error,
6738
+ error: error2,
6161
6739
  updatedAt
6162
6740
  });
6741
+ if (!applied2) return this.echoCurrentStatus(run.id);
6163
6742
  this.emit({
6164
6743
  type: "run.failed",
6165
6744
  runId: run.id,
6166
6745
  workflow: run.workflow,
6167
6746
  namespace: run.namespace,
6168
- error: outcome.error
6169
- });
6170
- void this.notifyParent(run.id, {
6171
- ok: false,
6172
- error: outcome.error.message
6747
+ error: error2
6173
6748
  });
6749
+ void this.notifyParent(run.id, failedCompletion(error2));
6174
6750
  return {
6175
6751
  runId: run.id,
6176
6752
  status: "failed",
6177
- error: outcome.error
6753
+ error: error2
6178
6754
  };
6179
6755
  }
6180
6756
  if (outcome.kind === "blocked") return this.parkBlocked(run, outcome.blocked);
6181
- const latest = await this.store.getRun(run.id);
6182
- if (latest?.status === "cancelled") {
6183
- return {
6184
- runId: run.id,
6185
- status: "cancelled",
6186
- error: latest.error
6187
- };
6188
- }
6189
- await this.store.updateRun(run.id, {
6757
+ const applied = await (this.store.updateRunIf ?? this.updateRunIfFallback).call(this.store, run.id, SUSPEND_ELIGIBLE_STATUSES, {
6190
6758
  status: "suspended",
6191
6759
  wakeAt: this.reconcileWakeAt(outcome.wakeAt),
6192
6760
  updatedAt
6193
6761
  });
6762
+ if (!applied) return this.echoCurrentStatus(run.id);
6194
6763
  this.emit({
6195
6764
  type: "run.suspended",
6196
6765
  runId: run.id,
@@ -6237,7 +6806,7 @@ var WorkflowEngine = class {
6237
6806
  from: this.instanceId
6238
6807
  }).catch(() => void 0);
6239
6808
  }
6240
- await this.store.releaseRunLock(run.id);
6809
+ await this.releaseLeaseBestEffort(run.id);
6241
6810
  return {
6242
6811
  runId: run.id,
6243
6812
  status: "cancelled",
@@ -6263,7 +6832,14 @@ var WorkflowEngine = class {
6263
6832
  }
6264
6833
  const wfPlan = await this.planRoute(tenantGroup(sanitizeQueueToken(remote.group), void 0), registered.requires);
6265
6834
  if (wfPlan?.status === "blocked") return this.parkBlocked(run, wfPlan);
6266
- const history = await this.remoteHistory(run.id);
6835
+ const checkpoints = await this.store.listCheckpoints(run.id);
6836
+ const handoff = liveHandoff(run.id, checkpoints);
6837
+ if (handoff) {
6838
+ const result = await this.settleRun(run, await this.followHandoff(run, handoff));
6839
+ await this.releaseLeaseBestEffort(run.id);
6840
+ return result;
6841
+ }
6842
+ const history = await this.remoteHistory(run.id, checkpoints);
6267
6843
  if (remote.executor.dispatch) {
6268
6844
  const taskId = `${run.id}:wf:${globalThis.crypto.randomUUID().slice(0, 8)}`;
6269
6845
  const silenceMs = this.remoteAdvanceSilenceMs;
@@ -6298,7 +6874,7 @@ var WorkflowEngine = class {
6298
6874
  decision = silenceMs != null ? await this.awaitWithLivenessDeadline(run.id, advance, silenceMs, () => new RemoteWorkflowTimeout(run.id, silenceMs)) : await advance;
6299
6875
  } catch (err) {
6300
6876
  if (err instanceof RemoteWorkflowTimeout) {
6301
- await this.store.releaseRunLock(run.id);
6877
+ await this.releaseLeaseBestEffort(run.id);
6302
6878
  return {
6303
6879
  runId: run.id,
6304
6880
  status: "running"
@@ -6587,8 +7163,8 @@ var WorkflowEngine = class {
6587
7163
  }
6588
7164
  }
6589
7165
  /** The run's resolved durable ops as replay inputs: completed/failed steps + elapsed timers. */
6590
- async remoteHistory(runId) {
6591
- const checkpoints = await this.store.listCheckpoints(runId);
7166
+ async remoteHistory(runId, listed) {
7167
+ const checkpoints = listed ?? await this.store.listCheckpoints(runId);
6592
7168
  const kindOf = {
6593
7169
  remote: "call",
6594
7170
  local: "step",
@@ -6598,15 +7174,14 @@ var WorkflowEngine = class {
6598
7174
  const events = [];
6599
7175
  for (const cp of checkpoints) {
6600
7176
  if (cp.status === "completed" || cp.status === "failed") {
7177
+ if (cp.seq >= HANDOFF_SEQ_BASE) continue;
6601
7178
  if (cp.kind === "signal" && cp.name.startsWith("signal:child:")) {
6602
7179
  const completion = cp.output;
6603
7180
  events.push({
6604
7181
  seq: cp.seq,
6605
7182
  kind: "child",
6606
7183
  output: completion?.ok ? completion.value : void 0,
6607
- error: completion && completion.ok === false ? {
6608
- message: completion.error
6609
- } : void 0
7184
+ error: completion && completion.ok === false ? completionError(completion) : void 0
6610
7185
  });
6611
7186
  continue;
6612
7187
  }
@@ -6887,6 +7462,10 @@ var WorkflowEngine = class {
6887
7462
  this.awaitedStepFans.set(run.id, awaitedFans);
6888
7463
  const ctx = createWorkflowCtx(this.ctxHostFor(replay, run.namespace), run.id, compensations, run.workflow, registered?.searchAttributesSchema);
6889
7464
  try {
7465
+ const handoff = liveHandoff(run.id, snapshot);
7466
+ if (handoff && !this.cancelRequested.has(run.id)) {
7467
+ return await this.settleRun(run, await this.followHandoff(run, handoff));
7468
+ }
6890
7469
  const output = await runInWorkflowCtx(ctx, () => fn(ctx, run.input));
6891
7470
  if (this.cancelRequested.has(run.id)) {
6892
7471
  return await this.compensateAndCancel(run, compensations, replay);
@@ -6949,7 +7528,8 @@ var WorkflowEngine = class {
6949
7528
  }
6950
7529
  const error2 = {
6951
7530
  message: err instanceof Error ? err.message : String(err),
6952
- stack: err instanceof Error ? err.stack : void 0
7531
+ stack: err instanceof Error ? err.stack : void 0,
7532
+ ...codeAndDetails(err)
6953
7533
  };
6954
7534
  const pending = await this.unwindCompensations(run, compensations, replay);
6955
7535
  if (pending) return await this.settleRun(run, {
@@ -6965,7 +7545,7 @@ var WorkflowEngine = class {
6965
7545
  this.awaitedRemoteSteps.delete(run.id);
6966
7546
  }
6967
7547
  if (this.awaitedStepFans.get(run.id) === awaitedFans) this.awaitedStepFans.delete(run.id);
6968
- await this.store.releaseRunLock(run.id);
7548
+ await this.releaseLeaseBestEffort(run.id);
6969
7549
  if (suspendedOnFan) {
6970
7550
  void this.redriveSettledChildren(run.id, suspendedOnFan).catch(() => void 0);
6971
7551
  }
@@ -7336,27 +7916,21 @@ var WorkflowEngine = class {
7336
7916
  * own `finally` also releases (the run-execution path) is safe.
7337
7917
  */
7338
7918
  async parkBlocked(run, blocked) {
7339
- const current = await this.store.getRun(run.id);
7340
- if (current && (current.status === "cancelled" || current.status === "completed")) {
7341
- await this.store.releaseRunLock(run.id);
7342
- return {
7343
- runId: run.id,
7344
- status: current.status,
7345
- output: current.output,
7346
- error: current.error
7347
- };
7348
- }
7349
7919
  const error2 = {
7350
7920
  message: blocked.reason,
7351
7921
  code: blocked.code,
7352
7922
  retryable: true
7353
7923
  };
7354
- await this.store.updateRun(run.id, {
7924
+ const applied = await (this.store.updateRunIf ?? this.updateRunIfFallback).call(this.store, run.id, SUSPEND_ELIGIBLE_STATUSES, {
7355
7925
  status: "blocked",
7356
7926
  error: error2,
7357
7927
  wakeAt: this.clock() + this.blockedPollMs,
7358
7928
  updatedAt: /* @__PURE__ */ new Date()
7359
7929
  });
7930
+ if (!applied) {
7931
+ await this.store.releaseRunLock(run.id);
7932
+ return this.echoCurrentStatus(run.id);
7933
+ }
7360
7934
  this.emit({
7361
7935
  type: "run.blocked",
7362
7936
  runId: run.id,
@@ -7585,7 +8159,8 @@ var WorkflowEngine = class {
7585
8159
  const failure = {
7586
8160
  index: i,
7587
8161
  id: stepId(runId, seq),
7588
- error: err.message
8162
+ error: err.message,
8163
+ ...codeAndDetails(err)
7589
8164
  };
7590
8165
  if (opts.mode === "failFast") throw new GatherError([
7591
8166
  failure
@@ -7607,6 +8182,24 @@ var WorkflowEngine = class {
7607
8182
  });
7608
8183
  throw new WorkflowSuspended(wakeAt);
7609
8184
  }
8185
+ if (opts.settle) {
8186
+ const failed = new Map(failures.map((f) => [
8187
+ f.index,
8188
+ f
8189
+ ]));
8190
+ return Array.from(outputs, (value, i) => {
8191
+ const f = failed.get(i);
8192
+ if (!f) return {
8193
+ status: "fulfilled",
8194
+ value
8195
+ };
8196
+ const { index: _index, ...rejected } = f;
8197
+ return {
8198
+ status: "rejected",
8199
+ ...rejected
8200
+ };
8201
+ });
8202
+ }
7610
8203
  if (failures.length > 0) throw new GatherError(failures, inputs.length);
7611
8204
  return outputs;
7612
8205
  }
@@ -7678,6 +8271,25 @@ var WorkflowEngine = class {
7678
8271
  await this.store.saveCheckpoint(checkpoint);
7679
8272
  return true;
7680
8273
  }
8274
+ /**
8275
+ * {@link savePendingCheckpoint} for checkpoints at distinct seqs of one run: one
8276
+ * {@link StateStore.saveCheckpointsIfPending} call when the store has it, else one write each.
8277
+ * Never rejects as a whole: a write that failed is reported as that entry's error, and the batch
8278
+ * call rejecting means it wrote nothing, so the entries are then written one by one.
8279
+ */
8280
+ async savePendingCheckpoints(checkpoints) {
8281
+ if (checkpoints.length > 1 && this.store.saveCheckpointsIfPending) {
8282
+ try {
8283
+ const wrote = await this.store.saveCheckpointsIfPending(checkpoints);
8284
+ return checkpoints.map((_, i) => wrote[i] === true);
8285
+ } catch {
8286
+ }
8287
+ }
8288
+ const settled = await Promise.allSettled(checkpoints.map((cp) => this.savePendingCheckpoint(cp)));
8289
+ return settled.map((s) => s.status === "fulfilled" ? s.value : {
8290
+ error: s.reason
8291
+ });
8292
+ }
7681
8293
  /** Enqueue a remote step task to its worker group via the transport pool. Shared by the initial
7682
8294
  * dispatch, the opt-in self-heal re-dispatch (see `remoteRedispatchMs`), and the explicit
7683
8295
  * {@link redispatchPending}. Carries the current trace/context so a re-dispatch is traceable. */
@@ -7716,57 +8328,161 @@ var WorkflowEngine = class {
7716
8328
  * result (the dispatching one may be gone), so the run is crash/scale-safe. Resolves whether the
7717
8329
  * run should now be resumed: not for a duplicate or late delivery (the checkpoint isn't `pending`),
7718
8330
  * a run already finished, or a step fan whose barrier is still closed.
8331
+ *
8332
+ * Results of one run are settled in batches: the first starts a batch, and the ones that arrive
8333
+ * while it is being written form the next. Under load a wide fan's results are written by one
8334
+ * {@link StateStore.saveCheckpointsIfPending} call per batch, and the fan's barrier is asked once
8335
+ * per batch, not once per result. A result resolves only once its own write has committed, so the
8336
+ * transport acknowledges nothing that is not durable yet.
8337
+ */
8338
+ completeRemoteResult(result) {
8339
+ return new Promise((resolve, reject) => {
8340
+ const entry = {
8341
+ result,
8342
+ resolve,
8343
+ reject
8344
+ };
8345
+ const queued = this.resultQueues.get(result.runId);
8346
+ if (queued) {
8347
+ queued.push(entry);
8348
+ return;
8349
+ }
8350
+ const queue = [
8351
+ entry
8352
+ ];
8353
+ this.resultQueues.set(result.runId, queue);
8354
+ void this.drainResultQueue(result.runId, queue);
8355
+ });
8356
+ }
8357
+ /** Settle `queue` (a run's queued results) batch by batch until it is empty, then retire it. */
8358
+ async drainResultQueue(runId, queue) {
8359
+ await Promise.resolve();
8360
+ while (queue.length > 0) {
8361
+ const batch = queue.splice(0, RESULT_BATCH_MAX);
8362
+ let outcomes;
8363
+ try {
8364
+ outcomes = await this.completeRemoteResults(runId, batch.map((q) => q.result));
8365
+ } catch (err) {
8366
+ outcomes = batch.map(() => ({
8367
+ error: err
8368
+ }));
8369
+ }
8370
+ batch.forEach((q, i) => {
8371
+ const outcome = outcomes[i] ?? false;
8372
+ if (typeof outcome === "boolean") q.resolve(outcome);
8373
+ else q.reject(outcome.error);
8374
+ });
8375
+ }
8376
+ this.resultQueues.delete(runId);
8377
+ }
8378
+ /**
8379
+ * Settle a batch of one run's step results. At most one of them resolves `true` (resume the run):
8380
+ * that resume replays every write of the batch, so a second would only replay it again. A result
8381
+ * whose read or write failed resolves its error, and the transport redelivers just that one.
7719
8382
  */
7720
- async completeRemoteResult(result) {
7721
- const cp = await this.store.getCheckpoint(result.runId, result.seq);
7722
- if (!cp || cp.status !== "pending") return false;
7723
- void this.releaseQueueSlot(cp.stepId).catch(() => void 0);
7724
- const run = await this.store.getRun(result.runId);
7725
- if (run && (run.status === "cancelled" || run.status === "completed")) return false;
8383
+ async completeRemoteResults(runId, results) {
8384
+ const outcomes = results.map(() => false);
8385
+ const seen = /* @__PURE__ */ new Set();
8386
+ const live = results.flatMap((r, i) => {
8387
+ if (seen.has(r.seq)) return [];
8388
+ seen.add(r.seq);
8389
+ return [
8390
+ i
8391
+ ];
8392
+ });
8393
+ const reads = await Promise.allSettled(live.map((i) => this.store.getCheckpoint(runId, results[i].seq)));
8394
+ const pending = [];
8395
+ reads.forEach((read, k) => {
8396
+ const index = live[k];
8397
+ if (read.status === "rejected") outcomes[index] = {
8398
+ error: read.reason
8399
+ };
8400
+ else if (read.value?.status === "pending") pending.push({
8401
+ index,
8402
+ cp: read.value
8403
+ });
8404
+ });
8405
+ if (pending.length === 0) return outcomes;
8406
+ for (const { cp } of pending) void this.releaseQueueSlot(cp.stepId).catch(() => void 0);
8407
+ const run = await this.store.getRun(runId);
8408
+ if (run && (run.status === "cancelled" || run.status === "completed")) return outcomes;
7726
8409
  const finishedAt = /* @__PURE__ */ new Date();
7727
- const startedAt = result.startedAt ? new Date(result.startedAt) : cp.startedAt;
7728
- const claimed = await this.savePendingCheckpoint({
7729
- ...cp,
7730
- status: result.status,
7731
- output: result.status === "completed" ? result.output : cp.output,
7732
- error: result.error,
7733
- events: result.events ?? cp.events,
7734
- startedAt,
7735
- finishedAt
8410
+ const writes = pending.map(({ index, cp }) => {
8411
+ const result = results[index];
8412
+ return {
8413
+ ...cp,
8414
+ status: result.status,
8415
+ output: result.status === "completed" ? result.output : cp.output,
8416
+ error: result.error,
8417
+ events: result.events ?? cp.events,
8418
+ startedAt: result.startedAt ? new Date(result.startedAt) : cp.startedAt,
8419
+ finishedAt
8420
+ };
7736
8421
  });
7737
- if (!claimed) return false;
7738
- this.noteSettledHere(`${result.runId}:${result.seq}`);
7739
- this.emit({
7740
- type: result.status === "completed" ? "step.completed" : "step.failed",
7741
- runId: result.runId,
7742
- seq: result.seq,
7743
- name: cp.name,
7744
- kind: cp.kind,
7745
- output: result.output,
7746
- error: result.error,
7747
- queueMs: startedAt.getTime() - cp.enqueuedAt.getTime(),
7748
- durationMs: finishedAt.getTime() - startedAt.getTime()
8422
+ const claims = await this.savePendingCheckpoints(writes);
8423
+ const settled = [];
8424
+ claims.forEach((claim, k) => {
8425
+ const { index, cp } = pending[k];
8426
+ if (claim === true) settled.push({
8427
+ index,
8428
+ cp,
8429
+ result: results[index]
8430
+ });
8431
+ else if (claim !== false) outcomes[index] = claim;
7749
8432
  });
7750
- return !await this.stepFanStillRunning(cp, result);
8433
+ for (const { cp, result } of settled) {
8434
+ this.noteSettledHere(`${runId}:${result.seq}`);
8435
+ const startedAt = result.startedAt ? new Date(result.startedAt) : cp.startedAt;
8436
+ this.emit({
8437
+ type: result.status === "completed" ? "step.completed" : "step.failed",
8438
+ runId,
8439
+ seq: result.seq,
8440
+ name: cp.name,
8441
+ kind: cp.kind,
8442
+ output: result.output,
8443
+ error: result.error,
8444
+ queueMs: startedAt.getTime() - cp.enqueuedAt.getTime(),
8445
+ durationMs: finishedAt.getTime() - startedAt.getTime()
8446
+ });
8447
+ }
8448
+ const barriers = /* @__PURE__ */ new Map();
8449
+ let carrier;
8450
+ for (const { index, cp, result } of settled) {
8451
+ if (!await this.stepFanStillRunning(cp, result, barriers)) carrier = index;
8452
+ }
8453
+ if (carrier !== void 0) outcomes[carrier] = true;
8454
+ return outcomes;
7751
8455
  }
7752
8456
  /**
7753
8457
  * The join barrier of a step fan: a result for one of its steps resumes the parent only once no
7754
8458
  * step of the fan is still `pending` (a settled wave), or on a failure (failFast surfaces it, and a
7755
8459
  * retry needs the parent to schedule it). Without it every result would replay the whole parent,
7756
- * which makes a fan of N steps cost O(N²) checkpoint reads.
8460
+ * which makes a fan of N steps cost O(N²) checkpoint reads. `barriers` holds the answer per fan
8461
+ * for the rest of a batch. A barrier that cannot be read counts as open: a spare resume replays and
8462
+ * re-suspends, a lost one strands the run.
7757
8463
  */
7758
- async stepFanStillRunning(cp, result) {
8464
+ async stepFanStillRunning(cp, result, barriers) {
7759
8465
  const fan = parseStepFanGroup(cp.parallelGroup);
7760
8466
  if (!fan) return false;
7761
8467
  if (result.status === "failed") return false;
7762
- const to = fan.firstSeq + fan.size - 1;
7763
- const pending = this.store.countCheckpoints ? await this.store.countCheckpoints(result.runId, fan.firstSeq, to, [
7764
- "pending"
7765
- ]) : (await this.store.listCheckpoints(result.runId)).filter((c) => c.seq >= fan.firstSeq && c.seq <= to && c.status === "pending").length;
7766
- if (pending > 0) return true;
8468
+ let closed = barriers.get(fan.firstSeq);
8469
+ if (!closed) {
8470
+ closed = this.stepFanPending(result.runId, fan.firstSeq, fan.firstSeq + fan.size - 1).then((pending) => pending > 0, () => false);
8471
+ barriers.set(fan.firstSeq, closed);
8472
+ }
8473
+ if (await closed) return true;
7767
8474
  this.noteRecent(this.stepFansFiredHere, `${result.runId}:${fan.firstSeq}`);
7768
8475
  return false;
7769
8476
  }
8477
+ /** How many steps of a step fan's seq range are still `pending`. */
8478
+ async stepFanPending(runId, from, to) {
8479
+ if (this.store.countCheckpoints) {
8480
+ return this.store.countCheckpoints(runId, from, to, [
8481
+ "pending"
8482
+ ]);
8483
+ }
8484
+ return (await this.store.listCheckpoints(runId)).filter((c) => c.seq >= from && c.seq <= to && c.status === "pending").length;
8485
+ }
7770
8486
  /**
7771
8487
  * Apply a remote workflow-turn {@link WorkflowDecision} durably — runs on WHATEVER instance receives
7772
8488
  * it over the broker (the dispatching one may be gone, or a point-to-point broker handed it to a
@@ -8213,14 +8929,339 @@ function summarizeTimeline(timeline, over) {
8213
8929
  __name(summarizeTimeline, "summarizeTimeline");
8214
8930
  function pageFan(timeline, group, query) {
8215
8931
  const matching = timeline.filter((cp) => cp.parallelGroup === group && (!query.failedOnly || isFailedMember(cp)));
8216
- const offset = query.offset !== void 0 && Number.isFinite(query.offset) ? Math.max(0, Math.floor(query.offset)) : 0;
8217
- const limit = query.limit !== void 0 && Number.isFinite(query.limit) ? Math.min(FAN_PAGE_MAX, Math.max(1, Math.floor(query.limit))) : 100;
8932
+ const { offset, limit } = fanPageBounds(query);
8218
8933
  return {
8219
8934
  items: matching.slice(offset, offset + limit),
8220
8935
  total: matching.length
8221
8936
  };
8222
8937
  }
8223
8938
  __name(pageFan, "pageFan");
8939
+ function fanPageBounds(query) {
8940
+ const offset = query.offset !== void 0 && Number.isFinite(query.offset) ? Math.max(0, Math.floor(query.offset)) : 0;
8941
+ const limit = query.limit !== void 0 && Number.isFinite(query.limit) ? Math.min(FAN_PAGE_MAX, Math.max(1, Math.floor(query.limit))) : 100;
8942
+ return {
8943
+ offset,
8944
+ limit
8945
+ };
8946
+ }
8947
+ __name(fanPageBounds, "fanPageBounds");
8948
+
8949
+ // src/run-detail-store.ts
8950
+ function aggregatesCheckpoints(store) {
8951
+ return store.checkpointStats !== void 0 && store.queryCheckpoints !== void 0;
8952
+ }
8953
+ __name(aggregatesCheckpoints, "aggregatesCheckpoints");
8954
+ function checkpointStatsOf(checkpoints, opts) {
8955
+ const buckets = /* @__PURE__ */ new Map();
8956
+ for (const cp of checkpoints) {
8957
+ const group = cp.parallelGroup ?? null;
8958
+ if (opts?.group !== void 0 && group !== opts.group) continue;
8959
+ const nameKind = checkpointNameKind(cp.name);
8960
+ const key = JSON.stringify([
8961
+ group,
8962
+ cp.status,
8963
+ nameKind
8964
+ ]);
8965
+ const enqueuedAt = cp.enqueuedAt ?? cp.startedAt;
8966
+ const bucket = buckets.get(key);
8967
+ if (!bucket) {
8968
+ buckets.set(key, {
8969
+ group,
8970
+ status: cp.status,
8971
+ nameKind,
8972
+ count: 1,
8973
+ minSeq: cp.seq,
8974
+ maxSeq: cp.seq,
8975
+ minName: cp.name,
8976
+ maxName: cp.name,
8977
+ minEnqueuedAt: enqueuedAt,
8978
+ minStartedAt: cp.startedAt,
8979
+ maxStartedAt: cp.startedAt,
8980
+ maxFinishedAt: cp.finishedAt,
8981
+ sumAttempts: cp.attempts
8982
+ });
8983
+ continue;
8984
+ }
8985
+ bucket.count += 1;
8986
+ bucket.minSeq = Math.min(bucket.minSeq, cp.seq);
8987
+ bucket.maxSeq = Math.max(bucket.maxSeq, cp.seq);
8988
+ if (cp.name < bucket.minName) bucket.minName = cp.name;
8989
+ if (cp.name > bucket.maxName) bucket.maxName = cp.name;
8990
+ if (enqueuedAt < bucket.minEnqueuedAt) bucket.minEnqueuedAt = enqueuedAt;
8991
+ if (cp.startedAt < bucket.minStartedAt) bucket.minStartedAt = cp.startedAt;
8992
+ if (cp.startedAt > bucket.maxStartedAt) bucket.maxStartedAt = cp.startedAt;
8993
+ if (cp.finishedAt > bucket.maxFinishedAt) bucket.maxFinishedAt = cp.finishedAt;
8994
+ bucket.sumAttempts += cp.attempts;
8995
+ }
8996
+ return [
8997
+ ...buckets.values()
8998
+ ];
8999
+ }
9000
+ __name(checkpointStatsOf, "checkpointStatsOf");
9001
+ function queryCheckpointsOf(checkpoints, query) {
9002
+ const excluded = new Set(query.excludeGroups ?? []);
9003
+ const matching = checkpoints.filter((cp) => (query.group === void 0 || cp.parallelGroup === query.group) && (cp.parallelGroup === void 0 || !excluded.has(cp.parallelGroup)) && (query.statuses === void 0 || query.statuses.includes(cp.status)) && (query.namePrefixes === void 0 || query.namePrefixes.some((prefix) => cp.name.startsWith(prefix)))).sort((a, b) => a.seq - b.seq);
9004
+ const offset = query.offset ?? 0;
9005
+ return matching.slice(offset, query.limit === void 0 ? void 0 : offset + query.limit);
9006
+ }
9007
+ __name(queryCheckpointsOf, "queryCheckpointsOf");
9008
+ var TERMINAL = /* @__PURE__ */ __name((status) => status !== "pending" && status !== "running", "TERMINAL");
9009
+ function isWide(group, rows, over) {
9010
+ return rows > over || (parseFanGroup(group)?.size ?? 0) > over;
9011
+ }
9012
+ __name(isWide, "isWide");
9013
+ function mergeBySeq(a, b) {
9014
+ return [
9015
+ ...a,
9016
+ ...b
9017
+ ].sort((x, y) => x.seq - y.seq);
9018
+ }
9019
+ __name(mergeBySeq, "mergeBySeq");
9020
+ async function failedChildren(store, runId, group, buckets) {
9021
+ if (!buckets.some((b) => b.nameKind === "child" && b.status === "completed")) {
9022
+ return {
9023
+ completed: [],
9024
+ failed: []
9025
+ };
9026
+ }
9027
+ const completed = await store.queryCheckpoints(runId, {
9028
+ group,
9029
+ statuses: [
9030
+ "completed"
9031
+ ],
9032
+ namePrefixes: [
9033
+ "signal:child:"
9034
+ ]
9035
+ });
9036
+ return {
9037
+ completed,
9038
+ failed: completed.filter(isFailedMember)
9039
+ };
9040
+ }
9041
+ __name(failedChildren, "failedChildren");
9042
+ async function summarizeFanFromStats(store, runId, group, buckets, folded) {
9043
+ const counts = {};
9044
+ const add = /* @__PURE__ */ __name((status, n) => {
9045
+ counts[status] = (counts[status] ?? 0) + n;
9046
+ }, "add");
9047
+ let size = 0;
9048
+ let startedAt;
9049
+ let finishedAt;
9050
+ let firstSeq = Number.POSITIVE_INFINITY;
9051
+ let lastSeq = Number.NEGATIVE_INFINITY;
9052
+ let minName;
9053
+ let maxName;
9054
+ let childRuns = false;
9055
+ for (const b of buckets) {
9056
+ firstSeq = Math.min(firstSeq, b.minSeq);
9057
+ lastSeq = Math.max(lastSeq, b.maxSeq);
9058
+ if (minName === void 0 || b.minName < minName) minName = b.minName;
9059
+ if (maxName === void 0 || b.maxName > maxName) maxName = b.maxName;
9060
+ if (b.nameKind === "child" || b.nameKind === "folded") childRuns = true;
9061
+ if (TERMINAL(b.status)) {
9062
+ if (!startedAt || b.minStartedAt < startedAt) startedAt = b.minStartedAt;
9063
+ if (!finishedAt || b.maxFinishedAt > finishedAt) finishedAt = b.maxFinishedAt;
9064
+ } else if (!startedAt || b.minEnqueuedAt < startedAt) {
9065
+ startedAt = b.minEnqueuedAt;
9066
+ }
9067
+ if (b.nameKind === "folded") continue;
9068
+ if (b.nameKind === "child" && b.status === "completed") continue;
9069
+ add(b.status, b.count);
9070
+ size += b.count;
9071
+ }
9072
+ for (const cp of folded) {
9073
+ const n = foldedCount(cp) ?? 1;
9074
+ add(isFailedMember(cp) ? "failed" : cp.status, n);
9075
+ size += n;
9076
+ }
9077
+ const children = await failedChildren(store, runId, group, buckets);
9078
+ const failedChildSet = new Set(children.failed);
9079
+ for (const cp of children.completed) add(failedChildSet.has(cp) ? "failed" : "completed", 1);
9080
+ size += children.completed.length;
9081
+ const failedStatusRows = buckets.some((b) => b.status === "failed") ? await store.queryCheckpoints(runId, {
9082
+ group,
9083
+ statuses: [
9084
+ "failed"
9085
+ ],
9086
+ limit: FAN_SUMMARY_FAILURES
9087
+ }) : [];
9088
+ const failures = mergeBySeq(failedStatusRows, children.failed).slice(0, FAN_SUMMARY_FAILURES);
9089
+ const parsed = parseFanGroup(group);
9090
+ const total = parsed?.size;
9091
+ return {
9092
+ group,
9093
+ label: childRuns ? "child runs" : minName === maxName ? minName ?? "" : "steps",
9094
+ firstSeq,
9095
+ lastSeq,
9096
+ size,
9097
+ total,
9098
+ notStarted: total === void 0 ? void 0 : Math.max(0, total - size),
9099
+ failFast: parsed?.failFast ?? false,
9100
+ counts,
9101
+ startedAt,
9102
+ finishedAt,
9103
+ failures
9104
+ };
9105
+ }
9106
+ __name(summarizeFanFromStats, "summarizeFanFromStats");
9107
+ async function firstChildIds(store, runId) {
9108
+ const ids = [];
9109
+ for (let offset = 0; ids.length < SUMMARY_CHILD_LIMIT; offset += SUMMARY_CHILD_LIMIT) {
9110
+ const page = await store.queryCheckpoints(runId, {
9111
+ namePrefixes: CHILD_EDGE_PREFIXES,
9112
+ offset,
9113
+ limit: SUMMARY_CHILD_LIMIT
9114
+ });
9115
+ ids.push(...childIdsOf(page));
9116
+ if (page.length < SUMMARY_CHILD_LIMIT) break;
9117
+ }
9118
+ return ids.slice(0, SUMMARY_CHILD_LIMIT);
9119
+ }
9120
+ __name(firstChildIds, "firstChildIds");
9121
+ async function summarizeRunFromStore(store, runId, over, stats) {
9122
+ const all = stats ?? await store.checkpointStats(runId);
9123
+ const byGroup = /* @__PURE__ */ new Map();
9124
+ let edgeRows = 0;
9125
+ let hasFolded = false;
9126
+ for (const b of all) {
9127
+ if (b.nameKind === "child" || b.nameKind === "spawn") edgeRows += b.count;
9128
+ if (b.nameKind === "folded") hasFolded = true;
9129
+ if (b.group === null) continue;
9130
+ const buckets = byGroup.get(b.group);
9131
+ if (buckets) buckets.push(b);
9132
+ else byGroup.set(b.group, [
9133
+ b
9134
+ ]);
9135
+ }
9136
+ const wide = [
9137
+ ...byGroup
9138
+ ].filter(([group, buckets]) => isWide(group, buckets.reduce((n, b) => n + b.count, 0), over));
9139
+ const [timeline, folded, children] = await Promise.all([
9140
+ store.queryCheckpoints(runId, {
9141
+ excludeGroups: wide.map(([group]) => group)
9142
+ }),
9143
+ hasFolded ? store.queryCheckpoints(runId, {
9144
+ namePrefixes: [
9145
+ FOLDED_PREFIX
9146
+ ]
9147
+ }) : [],
9148
+ edgeRows > 0 || hasFolded ? firstChildIds(store, runId) : []
9149
+ ]);
9150
+ const fans = await Promise.all(wide.map(([group, buckets]) => summarizeFanFromStats(store, runId, group, buckets, folded.filter((cp) => cp.parallelGroup === group))));
9151
+ const childCount = folded.reduce((n, cp) => n + childIdsOf([
9152
+ cp
9153
+ ]).length, edgeRows);
9154
+ return {
9155
+ timeline,
9156
+ fans: fans.sort((a, b) => a.firstSeq - b.firstSeq),
9157
+ children,
9158
+ childCount
9159
+ };
9160
+ }
9161
+ __name(summarizeRunFromStore, "summarizeRunFromStore");
9162
+ async function pageFanFromStore(store, runId, group, query, stats) {
9163
+ const buckets = stats ?? await store.checkpointStats(runId, {
9164
+ group
9165
+ });
9166
+ const { offset, limit } = fanPageBounds(query);
9167
+ if (!query.failedOnly) {
9168
+ const total2 = buckets.reduce((n, b) => n + b.count, 0);
9169
+ const items = total2 > offset ? await store.queryCheckpoints(runId, {
9170
+ group,
9171
+ offset,
9172
+ limit
9173
+ }) : [];
9174
+ return {
9175
+ items,
9176
+ total: total2
9177
+ };
9178
+ }
9179
+ const failedStatus = buckets.filter((b) => b.status === "failed").reduce((n, b) => n + b.count, 0);
9180
+ const children = await failedChildren(store, runId, group, buckets);
9181
+ const total = failedStatus + children.failed.length;
9182
+ if (total <= offset) return {
9183
+ items: [],
9184
+ total
9185
+ };
9186
+ if (children.failed.length === 0) {
9187
+ return {
9188
+ items: await store.queryCheckpoints(runId, {
9189
+ group,
9190
+ statuses: [
9191
+ "failed"
9192
+ ],
9193
+ offset,
9194
+ limit
9195
+ }),
9196
+ total
9197
+ };
9198
+ }
9199
+ const failedRows = await store.queryCheckpoints(runId, {
9200
+ group,
9201
+ statuses: [
9202
+ "failed"
9203
+ ],
9204
+ limit: offset + limit
9205
+ });
9206
+ return {
9207
+ items: mergeBySeq(failedRows, children.failed).slice(offset, offset + limit),
9208
+ total
9209
+ };
9210
+ }
9211
+ __name(pageFanFromStore, "pageFanFromStore");
9212
+ function hash(text) {
9213
+ let h1 = 3735928559;
9214
+ let h2 = 1103547991;
9215
+ for (let i = 0; i < text.length; i++) {
9216
+ const ch = text.charCodeAt(i);
9217
+ h1 = Math.imul(h1 ^ ch, 2654435761);
9218
+ h2 = Math.imul(h2 ^ ch, 1597334677);
9219
+ }
9220
+ h1 = Math.imul(h1 ^ h1 >>> 16, 2246822507) ^ Math.imul(h2 ^ h2 >>> 13, 3266489909);
9221
+ h2 = Math.imul(h2 ^ h2 >>> 16, 2246822507) ^ Math.imul(h1 ^ h1 >>> 13, 3266489909);
9222
+ return (4294967296 * (2097151 & h2) + (h1 >>> 0)).toString(16);
9223
+ }
9224
+ __name(hash, "hash");
9225
+ function statsFingerprint(stats) {
9226
+ return stats.map((b) => [
9227
+ b.group ?? "",
9228
+ b.status,
9229
+ b.nameKind,
9230
+ b.count,
9231
+ b.minSeq,
9232
+ b.maxSeq,
9233
+ b.minName,
9234
+ b.maxName,
9235
+ b.minEnqueuedAt.getTime(),
9236
+ b.minStartedAt.getTime(),
9237
+ b.maxStartedAt.getTime(),
9238
+ b.maxFinishedAt.getTime(),
9239
+ b.sumAttempts
9240
+ ].join("")).sort().join("");
9241
+ }
9242
+ __name(statsFingerprint, "statsFingerprint");
9243
+ function runDetailEtag(run, stats, over) {
9244
+ return `"rd-${hash(`${over}${JSON.stringify(run)}${statsFingerprint(stats)}`)}"`;
9245
+ }
9246
+ __name(runDetailEtag, "runDetailEtag");
9247
+ function fanPageEtag(group, stats, query) {
9248
+ const { offset, limit } = fanPageBounds(query);
9249
+ const key = [
9250
+ group,
9251
+ offset,
9252
+ limit,
9253
+ query.failedOnly === true
9254
+ ].join("");
9255
+ return `"fp-${hash(`${key}${statsFingerprint(stats)}`)}"`;
9256
+ }
9257
+ __name(fanPageEtag, "fanPageEtag");
9258
+ function etagMatches(ifNoneMatch, etag) {
9259
+ if (!ifNoneMatch) return false;
9260
+ const bare = /* @__PURE__ */ __name((tag) => tag.trim().replace(/^W\//, ""), "bare");
9261
+ const target = bare(etag);
9262
+ return ifNoneMatch.split(",").some((tag) => tag.trim() === "*" || bare(tag) === target);
9263
+ }
9264
+ __name(etagMatches, "etagMatches");
8224
9265
 
8225
9266
  // src/codec-state-store.ts
8226
9267
  var CodecStateStore = class {
@@ -8247,14 +9288,28 @@ var CodecStateStore = class {
8247
9288
  /** Forwarded verbatim (deletes touch no payload). Bound only when the inner store has it, so the
8248
9289
  * engine's per-run fallback still kicks in through the wrapper. */
8249
9290
  deleteRuns;
9291
+ /** Decodes the runs like {@link listIncompleteRuns}. Bound only when the inner store has it, so
9292
+ * the engine's list-and-filter fallback still kicks in through the wrapper. */
9293
+ listOrphanedRuns;
9294
+ /** Encodes the patch like {@link updateRun}. Bound only when the inner store has it, so the
9295
+ * engine's read-then-write fallback still kicks in through the wrapper. */
9296
+ updateRunIf;
8250
9297
  /** Encodes like {@link saveCheckpoint}. Bound only when the inner store has it, so the engine's
8251
9298
  * plain-save fallback still kicks in through the wrapper. */
8252
9299
  saveCheckpointIfPending;
9300
+ /** Encodes each checkpoint like {@link saveCheckpoint}. Bound only when the inner store has it. */
9301
+ saveCheckpointsIfPending;
8253
9302
  /** Forwarded verbatim — a count reads no payload. Bound only when the inner store has it. */
8254
9303
  countCheckpoints;
8255
9304
  /** Forwarded verbatim — retention selects by status/updatedAt/scope, never by payload. Bound only
8256
9305
  * when the inner store prunes, so the retention poller still sees (and warns about) its absence. */
8257
9306
  pruneTerminalRuns;
9307
+ /** Forwarded verbatim — the buckets aggregate names, statuses and timestamps, never a payload.
9308
+ * Bound only when the inner store aggregates, so the run detail's fallback still applies. */
9309
+ checkpointStats;
9310
+ /** Filters on unencoded columns, then decodes the rows like {@link listCheckpoints}. Bound only
9311
+ * when the inner store has it. */
9312
+ queryCheckpoints;
8258
9313
  /** Forwarded verbatim — a count reads no payload. Bound only when the inner store counts, so the
8259
9314
  * engine's facet/listing fallback still applies through the wrapper. */
8260
9315
  countRuns;
@@ -8279,10 +9334,20 @@ var CodecStateStore = class {
8279
9334
  if (inner.tryAdmitSingleton) this.tryAdmitSingleton = inner.tryAdmitSingleton.bind(inner);
8280
9335
  const deleteRuns = inner.deleteRuns;
8281
9336
  if (deleteRuns) this.deleteRuns = (ids) => deleteRuns.call(inner, ids);
9337
+ 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));
9339
+ const updateIf = inner.updateRunIf;
9340
+ if (updateIf) this.updateRunIf = (runId, expected, patch) => updateIf.call(inner, runId, expected, this.encRunPatch(patch));
8282
9341
  const saveIfPending = inner.saveCheckpointIfPending;
8283
9342
  if (saveIfPending) this.saveCheckpointIfPending = (cp) => saveIfPending.call(inner, this.encCp(cp));
9343
+ const saveManyIfPending = inner.saveCheckpointsIfPending;
9344
+ if (saveManyIfPending) this.saveCheckpointsIfPending = (cps) => saveManyIfPending.call(inner, cps.map((cp) => this.encCp(cp)));
8284
9345
  const countCps = inner.countCheckpoints;
8285
9346
  if (countCps) this.countCheckpoints = (runId, from, to, statuses) => countCps.call(inner, runId, from, to, statuses);
9347
+ const stats = inner.checkpointStats;
9348
+ if (stats) this.checkpointStats = (runId, opts) => stats.call(inner, runId, opts);
9349
+ const queryCps = inner.queryCheckpoints;
9350
+ if (queryCps) this.queryCheckpoints = async (runId, query) => (await queryCps.call(inner, runId, query)).map((c) => this.decCp(c));
8286
9351
  const prune = inner.pruneTerminalRuns;
8287
9352
  if (prune) this.pruneTerminalRuns = (policy, now, limit) => prune.call(inner, policy, now, limit);
8288
9353
  const count = inner.countRuns;
@@ -8379,12 +9444,15 @@ var CodecStateStore = class {
8379
9444
  return this.inner.createRun(this.encRun(run));
8380
9445
  }
8381
9446
  updateRun(runId, patch) {
9447
+ return this.inner.updateRun(runId, this.encRunPatch(patch));
9448
+ }
9449
+ encRunPatch(patch) {
8382
9450
  const next = {
8383
9451
  ...patch
8384
9452
  };
8385
9453
  if ("input" in patch) next.input = this.enc(patch.input);
8386
9454
  if ("output" in patch) next.output = this.enc(patch.output);
8387
- return this.inner.updateRun(runId, next);
9455
+ return next;
8388
9456
  }
8389
9457
  async getRun(runId) {
8390
9458
  return this.decRun(await this.inner.getRun(runId));
@@ -8490,8 +9558,11 @@ function collectMetrics(engine) {
8490
9558
  started: 0,
8491
9559
  completed: 0,
8492
9560
  failed: 0,
8493
- suspended: 0
9561
+ suspended: 0,
9562
+ routed: 0,
9563
+ unrouted: 0
8494
9564
  },
9565
+ routedByCode: {},
8495
9566
  steps: {
8496
9567
  started: 0,
8497
9568
  completed: 0,
@@ -8533,6 +9604,15 @@ function collectMetrics(engine) {
8533
9604
  case "run.suspended":
8534
9605
  m.runs.suspended += 1;
8535
9606
  break;
9607
+ case "run.routed": {
9608
+ m.runs.routed += 1;
9609
+ const code = e.error?.code ?? "*";
9610
+ m.routedByCode[code] = (m.routedByCode[code] ?? 0) + 1;
9611
+ break;
9612
+ }
9613
+ case "run.unrouted":
9614
+ m.runs.unrouted += 1;
9615
+ break;
8536
9616
  case "step.started":
8537
9617
  m.steps.started += 1;
8538
9618
  break;
@@ -8559,6 +9639,8 @@ function collectMetrics(engine) {
8559
9639
  for (const [k, v] of Object.entries(m.steps)) lines.push(`durable_steps_total{event="${k}"} ${v}`);
8560
9640
  lines.push("# TYPE durable_runs_by_workflow_total counter");
8561
9641
  for (const [name, v] of Object.entries(m.byWorkflow)) for (const [event, n] of Object.entries(v)) lines.push(`durable_runs_by_workflow_total{workflow="${name}",event="${event}"} ${n}`);
9642
+ lines.push("# TYPE durable_runs_routed_total counter");
9643
+ for (const [code, n] of Object.entries(m.routedByCode)) lines.push(`durable_runs_routed_total{code="${code}"} ${n}`);
8562
9644
  lines.push("# TYPE durable_step_duration_ms_sum counter");
8563
9645
  lines.push(`durable_step_duration_ms_sum ${m.stepDuration.sumMs}`);
8564
9646
  lines.push("# TYPE durable_step_duration_count counter");
@@ -8729,6 +9811,11 @@ var InMemoryStateStore = class {
8729
9811
  * so this lets the benchmark contrast "fetch N, scan in JS" against "fetch the k that match".
8730
9812
  */
8731
9813
  lastCheckpointRowsRead = 0;
9814
+ /**
9815
+ * Test/observability hook: checkpoint rows materialized since construction, across every
9816
+ * checkpoint read that returns rows — what a run-detail benchmark compares between paths.
9817
+ */
9818
+ checkpointRowsRead = 0;
8732
9819
  key(runId, seq) {
8733
9820
  return `${runId}:${seq}`;
8734
9821
  }
@@ -8774,6 +9861,14 @@ var InMemoryStateStore = class {
8774
9861
  this.reindexAttributes(run.id, run.searchAttributes);
8775
9862
  }
8776
9863
  async updateRun(runId, inputPatch) {
9864
+ this.applyRunPatch(runId, inputPatch);
9865
+ }
9866
+ updateRunIf(runId, expectedStatuses, patch) {
9867
+ const existing = this.runs.get(runId);
9868
+ if (!existing || !expectedStatuses.includes(existing.status)) return Promise.resolve(false);
9869
+ return this.updateRun(runId, patch).then(() => true);
9870
+ }
9871
+ applyRunPatch(runId, inputPatch) {
8777
9872
  const patch = inputPatch.status && TERMINAL_RUN_STATUSES.includes(inputPatch.status) ? {
8778
9873
  ...inputPatch,
8779
9874
  tags: ("tags" in inputPatch ? inputPatch.tags : this.runs.get(runId)?.tags)?.filter((tag) => tag !== SINGLETON_ADMITTED_TAG)
@@ -8854,6 +9949,23 @@ var InMemoryStateStore = class {
8854
9949
  });
8855
9950
  return true;
8856
9951
  }
9952
+ async saveCheckpointsIfPending(checkpoints) {
9953
+ return checkpoints.map((checkpoint) => {
9954
+ const key = this.key(checkpoint.runId, checkpoint.seq);
9955
+ if (this.checkpoints.get(key)?.status !== "pending") return false;
9956
+ this.checkpoints.set(key, {
9957
+ ...checkpoint
9958
+ });
9959
+ return true;
9960
+ });
9961
+ }
9962
+ async listOrphanedRuns(nowMs, limit, namespace) {
9963
+ return [
9964
+ ...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) => ({
9966
+ ...r
9967
+ }));
9968
+ }
8857
9969
  async listIncompleteRuns(namespace) {
8858
9970
  return [
8859
9971
  ...this.runs.values()
@@ -9133,6 +10245,23 @@ var InMemoryStateStore = class {
9133
10245
  ...cp
9134
10246
  }));
9135
10247
  this.lastCheckpointRowsRead = rows.length;
10248
+ this.checkpointRowsRead += rows.length;
10249
+ return rows;
10250
+ }
10251
+ runCheckpoints(runId) {
10252
+ return [
10253
+ ...this.checkpoints.values()
10254
+ ].filter((cp) => cp.runId === runId);
10255
+ }
10256
+ async checkpointStats(runId, opts) {
10257
+ return checkpointStatsOf(this.runCheckpoints(runId), opts);
10258
+ }
10259
+ async queryCheckpoints(runId, query) {
10260
+ const rows = queryCheckpointsOf(this.runCheckpoints(runId), query).map((cp) => ({
10261
+ ...cp
10262
+ }));
10263
+ this.lastCheckpointRowsRead = rows.length;
10264
+ this.checkpointRowsRead += rows.length;
9136
10265
  return rows;
9137
10266
  }
9138
10267
  async getLatestCheckpointByName(runId, name) {
@@ -9160,6 +10289,7 @@ var InMemoryStateStore = class {
9160
10289
  ...cp
9161
10290
  }));
9162
10291
  this.lastCheckpointRowsRead = rows.length;
10292
+ this.checkpointRowsRead += rows.length;
9163
10293
  return rows;
9164
10294
  }
9165
10295
  };
@@ -9211,6 +10341,7 @@ var InMemoryTransport = class {
9211
10341
  };
9212
10342
  // Annotate the CommonJS export names for ESM import in node:
9213
10343
  0 && (module.exports = {
10344
+ CHILD_EDGE_PREFIXES,
9214
10345
  CONTROL_FLOW_SIGNAL,
9215
10346
  CURRENT_PROTOCOL_VERSION,
9216
10347
  CodecStateStore,
@@ -9231,6 +10362,7 @@ var InMemoryTransport = class {
9231
10362
  FAN_SUMMARY_FAILURES,
9232
10363
  FatalError,
9233
10364
  GatherError,
10365
+ HANDOFF_SEQ_BASE,
9234
10366
  InMemoryAdmissionBackend,
9235
10367
  InMemoryStateStore,
9236
10368
  InMemoryTransport,
@@ -9265,9 +10397,11 @@ var InMemoryTransport = class {
9265
10397
  WorkflowEngine,
9266
10398
  WorkflowSuspended,
9267
10399
  aggregateAnnouncements,
10400
+ aggregatesCheckpoints,
9268
10401
  announcementsOf,
9269
10402
  applyAttributeQuery,
9270
10403
  assertConcurrency,
10404
+ assertRoutes,
9271
10405
  attributeColumnFor,
9272
10406
  attributeOperand,
9273
10407
  attributePredicateOperands,
@@ -9277,10 +10411,14 @@ var InMemoryTransport = class {
9277
10411
  buildWorkflowDirectory,
9278
10412
  canRoute,
9279
10413
  capableWorkers,
10414
+ checkpointNameKind,
10415
+ checkpointStatsOf,
9280
10416
  childIdsOf,
9281
10417
  classifyWaiterToken,
9282
10418
  clearDurableExecutionWrappers,
9283
10419
  clearTerminalSingletonAdmission,
10420
+ codeAndDetails,
10421
+ codeOf,
9284
10422
  collectMetrics,
9285
10423
  concurrencyTag,
9286
10424
  controlPlaneDescriptor,
@@ -9293,13 +10431,20 @@ var InMemoryTransport = class {
9293
10431
  debug,
9294
10432
  describeWorker,
9295
10433
  descriptorHash,
10434
+ detailsOf,
9296
10435
  error,
10436
+ etagMatches,
9297
10437
  eventMatchOf,
9298
10438
  eventMatches,
9299
10439
  eventNameOf,
9300
10440
  eventPrefix,
9301
10441
  eventToken,
9302
10442
  facetOrigin,
10443
+ fanPageBounds,
10444
+ fanPageEtag,
10445
+ foldedCount,
10446
+ handoffHandlerId,
10447
+ handoffName,
9303
10448
  hasDurableExecutionWrappers,
9304
10449
  heartbeatStatus,
9305
10450
  indexWaitersByRun,
@@ -9311,6 +10456,7 @@ var InMemoryTransport = class {
9311
10456
  isNonEmptyRunScope,
9312
10457
  isWorkflowControlFlowSignal,
9313
10458
  listSignalWaitersOfRuns,
10459
+ liveHandoff,
9314
10460
  log,
9315
10461
  matchesAttributes,
9316
10462
  mergeRunFacetRows,
@@ -9321,17 +10467,23 @@ var InMemoryTransport = class {
9321
10467
  normalizeAttributeRows,
9322
10468
  normalizeDescriptor,
9323
10469
  observedAnnouncements,
10470
+ onErrorRunId,
9324
10471
  pageFan,
10472
+ pageFanFromStore,
9325
10473
  parentRunIdOf,
9326
10474
  parseDuration,
9327
10475
  partitionOfToken,
9328
10476
  planDispatch,
9329
10477
  prevCronFireMs,
10478
+ queryCheckpointsOf,
9330
10479
  readSearchAttributes,
9331
10480
  requiredCapabilities,
9332
10481
  resolveConcurrency,
9333
10482
  resolveRouting,
9334
10483
  resolveRunWaiting,
10484
+ retiredHandoffName,
10485
+ routeFor,
10486
+ runDetailEtag,
9335
10487
  runDurableExecution,
9336
10488
  runInStepLogger,
9337
10489
  runInStepScope,
@@ -9352,6 +10504,7 @@ var InMemoryTransport = class {
9352
10504
  subEvent,
9353
10505
  subProcess,
9354
10506
  summarizeFan,
10507
+ summarizeRunFromStore,
9355
10508
  summarizeTimeline,
9356
10509
  tenantGroup,
9357
10510
  useDurableExecution,