@dudousxd/nestjs-durable-core 0.77.0 → 0.79.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
@@ -265,10 +265,16 @@ var FatalError = class extends Error {
265
265
  __name(this, "FatalError");
266
266
  }
267
267
  code;
268
- constructor(message, code) {
268
+ /** Structured, JSON-serializable detail (e.g. validation issues). It travels with the failure to
269
+ * the run's error, a parent's `allSettled` item and an onError handler's input. */
270
+ details;
271
+ /** Read by the step result builders, so a dispatched step's retry policy honours it too. */
272
+ retryable = false;
273
+ constructor(message, code, details) {
269
274
  super(message);
270
275
  this.name = "FatalError";
271
276
  this.code = code;
277
+ this.details = details;
272
278
  }
273
279
  };
274
280
  var ConcurrencyLimitError = class extends Error {
@@ -346,6 +352,8 @@ var NonDeterminismError = class extends Error {
346
352
  static {
347
353
  __name(this, "NonDeterminismError");
348
354
  }
355
+ /** Marks the failure as a broken deploy, which an `onError` route never takes. */
356
+ code = "non_determinism";
349
357
  runId;
350
358
  seq;
351
359
  constructor(runId, seq, expected, recorded) {
@@ -757,6 +765,9 @@ async function runStepHandler(task, handler) {
757
765
  ...typeof e?.code === "string" ? {
758
766
  code: e.code
759
767
  } : {},
768
+ ...e?.details !== void 0 ? {
769
+ details: e.details
770
+ } : {},
760
771
  ...typeof e?.retryable === "boolean" ? {
761
772
  retryable: e.retryable
762
773
  } : {}
@@ -833,11 +844,36 @@ __name(isFailedCompletion, "isFailedCompletion");
833
844
  function unwrapCompletion(payload, label) {
834
845
  const c = payload;
835
846
  if (c && typeof c === "object" && "ok" in c && c.ok === false) {
836
- throw new FatalError(`${label} failed: ${c.error}`);
847
+ throw new FatalError(`${label} failed: ${c.error}`, c.code, c.details);
837
848
  }
838
849
  return c?.value;
839
850
  }
840
851
  __name(unwrapCompletion, "unwrapCompletion");
852
+ function failedCompletion(error2) {
853
+ return {
854
+ ok: false,
855
+ error: error2.message,
856
+ ...error2.code !== void 0 ? {
857
+ code: error2.code
858
+ } : {},
859
+ ...error2.details !== void 0 ? {
860
+ details: error2.details
861
+ } : {}
862
+ };
863
+ }
864
+ __name(failedCompletion, "failedCompletion");
865
+ function completionError(c) {
866
+ return {
867
+ message: c.error,
868
+ ...c.code !== void 0 ? {
869
+ code: c.code
870
+ } : {},
871
+ ...c.details !== void 0 ? {
872
+ details: c.details
873
+ } : {}
874
+ };
875
+ }
876
+ __name(completionError, "completionError");
841
877
 
842
878
  // src/concurrency.ts
843
879
  var DEFAULT_CONCURRENCY_STATUSES = [
@@ -1083,8 +1119,9 @@ function allChildId(runId, firstSeq, index, wave) {
1083
1119
  return `${allBase(runId, firstSeq, wave)}${index}`;
1084
1120
  }
1085
1121
  __name(allChildId, "allChildId");
1086
- function allGroup(firstSeq, mode) {
1087
- return mode === "failFast" ? `all:${firstSeq}:failFast` : `all:${firstSeq}`;
1122
+ function allGroup(firstSeq, size, mode) {
1123
+ const group = `all:${firstSeq}:n${size}`;
1124
+ return mode === "failFast" ? `${group}:failFast` : group;
1088
1125
  }
1089
1126
  __name(allGroup, "allGroup");
1090
1127
  function stepFanGroup(firstSeq, size, mode) {
@@ -1093,14 +1130,26 @@ function stepFanGroup(firstSeq, size, mode) {
1093
1130
  }
1094
1131
  __name(stepFanGroup, "stepFanGroup");
1095
1132
  function parseStepFanGroup(group) {
1096
- const m = group ? /^steps:(\d+):(\d+)(?::failFast)?$/.exec(group) : null;
1097
- if (!m) return void 0;
1133
+ const fan = parseFanGroup(group);
1134
+ if (fan?.kind !== "steps" || fan.size === void 0) return void 0;
1098
1135
  return {
1099
- firstSeq: Number(m[1]),
1100
- size: Number(m[2])
1136
+ firstSeq: fan.firstSeq,
1137
+ size: fan.size
1101
1138
  };
1102
1139
  }
1103
1140
  __name(parseStepFanGroup, "parseStepFanGroup");
1141
+ function parseFanGroup(group) {
1142
+ const m = group ? /^(all|steps):(\d+)(?::n?(\d+))?(:failFast)?$/.exec(group) : null;
1143
+ if (!m) return void 0;
1144
+ if (m[1] === "steps" && m[3] === void 0) return void 0;
1145
+ return {
1146
+ kind: m[1],
1147
+ firstSeq: Number(m[2]),
1148
+ size: m[3] === void 0 ? void 0 : Number(m[3]),
1149
+ failFast: m[4] !== void 0
1150
+ };
1151
+ }
1152
+ __name(parseFanGroup, "parseFanGroup");
1104
1153
  function allWait(runId, firstSeq, wave) {
1105
1154
  const base = allBase(runId, firstSeq, wave);
1106
1155
  return {
@@ -1145,12 +1194,12 @@ function allWaves(size, concurrency) {
1145
1194
  };
1146
1195
  }
1147
1196
  __name(allWaves, "allWaves");
1148
- var ENGINE_CHILD_ID = /^(.+)\.(?:all\.\d+\.(?:w\d+\.)?\d+|child\.\d+)$/;
1197
+ var ENGINE_CHILD_ID = /^(.+)\.(?:all\.\d+\.(?:w\d+\.)?\d+|child\.\d+|onError\.\d+)$/;
1149
1198
  function parentRunIdOf(runId) {
1150
1199
  return ENGINE_CHILD_ID.exec(runId)?.[1];
1151
1200
  }
1152
1201
  __name(parentRunIdOf, "parentRunIdOf");
1153
- var GROUP = /^all:(\d+)(:failFast)?$/;
1202
+ var GROUP = /^all:(\d+)(?::n\d+)?(:failFast)?$/;
1154
1203
  var REST = /^(?:w(\d+)\.)?\d+$/;
1155
1204
  function fanOfWaiter(waiter) {
1156
1205
  const group = waiter.parallelGroup ? GROUP.exec(waiter.parallelGroup) : null;
@@ -1230,7 +1279,11 @@ function canonicalizeRegistrations(regs) {
1230
1279
  requires: r.requires ? [
1231
1280
  ...new Set(r.requires)
1232
1281
  ].sort() : null,
1233
- origin: r.origin ?? null
1282
+ origin: r.origin ?? null,
1283
+ // Conditional, so a registration that states no routes keeps the hash it had before routes existed.
1284
+ ...r.onError !== void 0 ? {
1285
+ onError: canonicalRoutes(r.onError)
1286
+ } : {}
1234
1287
  }));
1235
1288
  const byJson = /* @__PURE__ */ new Map();
1236
1289
  for (const entry of canonical) byJson.set(JSON.stringify(entry), entry);
@@ -1239,6 +1292,20 @@ function canonicalizeRegistrations(regs) {
1239
1292
  ].sort().map((k) => byJson.get(k));
1240
1293
  }
1241
1294
  __name(canonicalizeRegistrations, "canonicalizeRegistrations");
1295
+ function canonicalRoutes(routes) {
1296
+ const byJson = /* @__PURE__ */ new Map();
1297
+ for (const r of routes) {
1298
+ const entry = {
1299
+ code: r.code ?? null,
1300
+ workflow: r.workflow
1301
+ };
1302
+ byJson.set(JSON.stringify(entry), entry);
1303
+ }
1304
+ return [
1305
+ ...byJson.keys()
1306
+ ].sort().map((k) => byJson.get(k));
1307
+ }
1308
+ __name(canonicalRoutes, "canonicalRoutes");
1242
1309
  function canonicalizeForHash(d) {
1243
1310
  const set = /* @__PURE__ */ __name((xs) => [
1244
1311
  ...new Set(xs)
@@ -1273,13 +1340,13 @@ function fnv1a64Hex(input) {
1273
1340
  const OFFSET = 0xcbf29ce484222325n;
1274
1341
  const PRIME = 0x100000001b3n;
1275
1342
  const MASK = 0xffffffffffffffffn;
1276
- let hash = OFFSET;
1343
+ let hash2 = OFFSET;
1277
1344
  for (let i = 0; i < input.length; i += 1) {
1278
- hash ^= BigInt(input.charCodeAt(i) & 255);
1279
- hash ^= BigInt(input.charCodeAt(i) >> 8 & 255);
1280
- hash = hash * PRIME & MASK;
1345
+ hash2 ^= BigInt(input.charCodeAt(i) & 255);
1346
+ hash2 ^= BigInt(input.charCodeAt(i) >> 8 & 255);
1347
+ hash2 = hash2 * PRIME & MASK;
1281
1348
  }
1282
- return hash.toString(16).padStart(16, "0");
1349
+ return hash2.toString(16).padStart(16, "0");
1283
1350
  }
1284
1351
  __name(fnv1a64Hex, "fnv1a64Hex");
1285
1352
  function descriptorHash(descriptor) {
@@ -1395,6 +1462,20 @@ function aggregateAnnouncements(descriptors) {
1395
1462
  const requires = [
1396
1463
  ...requiresByJson.values()
1397
1464
  ].sort((x, y) => x.join(",").localeCompare(y.join(",")));
1465
+ const routesByJson = /* @__PURE__ */ new Map();
1466
+ for (const a of announcements) {
1467
+ if (a.onError === void 0) continue;
1468
+ const canonical = canonicalRoutes(a.onError).map((r) => r.code === null ? {
1469
+ workflow: r.workflow
1470
+ } : {
1471
+ code: r.code,
1472
+ workflow: r.workflow
1473
+ });
1474
+ routesByJson.set(JSON.stringify(canonical), canonical);
1475
+ }
1476
+ const onError = [
1477
+ ...routesByJson.keys()
1478
+ ].sort().map((k) => routesByJson.get(k) ?? []);
1398
1479
  const disagreements = [];
1399
1480
  if (groups.length > 1) disagreements.push({
1400
1481
  axis: "group",
@@ -1410,6 +1491,14 @@ function aggregateAnnouncements(descriptors) {
1410
1491
  values: requires.map((r) => r.join(","))
1411
1492
  });
1412
1493
  }
1494
+ if (onError.length > 1) {
1495
+ disagreements.push({
1496
+ axis: "onError",
1497
+ values: [
1498
+ ...routesByJson.keys()
1499
+ ].sort()
1500
+ });
1501
+ }
1413
1502
  const first = announcements[0];
1414
1503
  if (!first) continue;
1415
1504
  out.push({
@@ -1422,6 +1511,7 @@ function aggregateAnnouncements(descriptors) {
1422
1511
  groups,
1423
1512
  origins,
1424
1513
  requires,
1514
+ onError,
1425
1515
  runtimes: [
1426
1516
  ...new Set(announcements.map((a) => a.runtime))
1427
1517
  ].sort(),
@@ -1455,6 +1545,7 @@ function observedAnnouncements(heartbeats, covered) {
1455
1545
  ],
1456
1546
  origins: [],
1457
1547
  requires: [],
1548
+ onError: [],
1458
1549
  runtimes: [],
1459
1550
  instances: [
1460
1551
  ...instances
@@ -1813,6 +1904,13 @@ function planDispatch(requires, liveDescriptors, cp, token) {
1813
1904
  __name(planDispatch, "planDispatch");
1814
1905
 
1815
1906
  // src/interfaces.ts
1907
+ function checkpointNameKind(name) {
1908
+ if (name.startsWith("signal:child:")) return "child";
1909
+ if (name.startsWith("fan:")) return "folded";
1910
+ if (name.startsWith("spawn:")) return "spawn";
1911
+ return "step";
1912
+ }
1913
+ __name(checkpointNameKind, "checkpointNameKind");
1816
1914
  var TERMINAL_RUN_STATUSES = [
1817
1915
  "completed",
1818
1916
  "failed",
@@ -1825,6 +1923,71 @@ function isNonEmptyRunScope(scope) {
1825
1923
  }
1826
1924
  __name(isNonEmptyRunScope, "isNonEmptyRunScope");
1827
1925
 
1926
+ // src/on-error.ts
1927
+ var UNROUTABLE_CODES = /* @__PURE__ */ new Set([
1928
+ "non_determinism",
1929
+ "nested_workflow_call"
1930
+ ]);
1931
+ var HANDOFF_SEQ_BASE = 1e9;
1932
+ var onErrorRunId = /* @__PURE__ */ __name((runId, attempt) => `${runId}.onError.${attempt}`, "onErrorRunId");
1933
+ var handoffName = /* @__PURE__ */ __name((handlerId) => `signal:child:${handlerId}`, "handoffName");
1934
+ var retiredHandoffName = /* @__PURE__ */ __name((handlerId) => `onError:retried:${handlerId}`, "retiredHandoffName");
1935
+ function handoffHandlerId(runId, cp) {
1936
+ const prefix = `signal:child:${runId}.onError.`;
1937
+ return cp.seq >= HANDOFF_SEQ_BASE && cp.name.startsWith(prefix) ? cp.name.slice("signal:child:".length) : void 0;
1938
+ }
1939
+ __name(handoffHandlerId, "handoffHandlerId");
1940
+ function liveHandoff(runId, checkpoints) {
1941
+ let live;
1942
+ for (const cp of checkpoints) {
1943
+ if (handoffHandlerId(runId, cp) !== void 0 && (!live || cp.seq > live.seq)) live = cp;
1944
+ }
1945
+ return live;
1946
+ }
1947
+ __name(liveHandoff, "liveHandoff");
1948
+ function routeFor(routes, error2) {
1949
+ if (!routes?.length || error2.code && UNROUTABLE_CODES.has(error2.code)) return void 0;
1950
+ return (error2.code ? routes.find((r) => r.code === error2.code) : void 0) ?? routes.find((r) => r.code === void 0);
1951
+ }
1952
+ __name(routeFor, "routeFor");
1953
+ function assertRoutes(workflow, routes) {
1954
+ const seen = /* @__PURE__ */ new Set();
1955
+ for (const r of routes) {
1956
+ if (seen.has(r.code)) {
1957
+ 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}"`);
1958
+ }
1959
+ seen.add(r.code);
1960
+ }
1961
+ }
1962
+ __name(assertRoutes, "assertRoutes");
1963
+ function codeOf(err) {
1964
+ const e = err;
1965
+ return [
1966
+ e?.code,
1967
+ e?.stepError?.code,
1968
+ e?.error?.code
1969
+ ].find((c) => typeof c === "string");
1970
+ }
1971
+ __name(codeOf, "codeOf");
1972
+ function detailsOf(err) {
1973
+ const e = err;
1974
+ return e?.details ?? e?.stepError?.details ?? e?.error?.details;
1975
+ }
1976
+ __name(detailsOf, "detailsOf");
1977
+ function codeAndDetails(err) {
1978
+ const code = codeOf(err);
1979
+ const details = detailsOf(err);
1980
+ return {
1981
+ ...code !== void 0 ? {
1982
+ code
1983
+ } : {},
1984
+ ...details !== void 0 ? {
1985
+ details
1986
+ } : {}
1987
+ };
1988
+ }
1989
+ __name(codeAndDetails, "codeAndDetails");
1990
+
1828
1991
  // src/tenant-group.ts
1829
1992
  function tenantGroup(baseGroup, tenant) {
1830
1993
  return tenant !== void 0 && tenant !== "" && tenant !== "default" ? `${baseGroup}@${tenant}` : baseGroup;
@@ -2884,7 +3047,8 @@ function createWorkflowCtx(host, runId, compensations, workflow = "", searchAttr
2884
3047
  kind: "local",
2885
3048
  error: {
2886
3049
  message: err instanceof Error ? err.message : String(err),
2887
- stack: err instanceof Error ? err.stack : void 0
3050
+ stack: err instanceof Error ? err.stack : void 0,
3051
+ ...codeAndDetails(err)
2888
3052
  },
2889
3053
  events,
2890
3054
  attempts: attempt,
@@ -3140,17 +3304,17 @@ function createWorkflowCtx(host, runId, compensations, workflow = "", searchAttr
3140
3304
  }
3141
3305
  ]));
3142
3306
  }, "child");
3143
- const all = /* @__PURE__ */ __name(async (workflow2, inputs, opts) => {
3307
+ const fan = /* @__PURE__ */ __name(async (workflow2, inputs, opts, settle) => {
3144
3308
  if (inputs.length === 0) return [];
3145
3309
  if (stepNameOf(workflow2) !== void 0) {
3146
- return allSteps(workflow2, inputs, opts);
3310
+ return allSteps(workflow2, inputs, opts, settle);
3147
3311
  }
3148
- const mode = opts?.mode ?? "waitAll";
3312
+ const mode = settle ? "waitAll" : opts?.mode ?? "waitAll";
3149
3313
  const name = workflowName(workflow2);
3150
3314
  const waves = allWaves(inputs.length, opts?.concurrency);
3151
3315
  const positions = inputs.map(() => pos.next());
3152
3316
  const firstSeq = positions[0];
3153
- const group = allGroup(firstSeq, mode);
3317
+ const group = allGroup(firstSeq, inputs.length, mode);
3154
3318
  const waveOf = /* @__PURE__ */ __name((i) => waves ? Math.floor(i / waves.waveSize) : void 0, "waveOf");
3155
3319
  const id = /* @__PURE__ */ __name((i) => allChildId(runId, firstSeq, i, waveOf(i)), "id");
3156
3320
  const { checkpoints: existing, folded } = await readFan(positions, [
@@ -3267,24 +3431,45 @@ function createWorkflowCtx(host, runId, compensations, workflow = "", searchAttr
3267
3431
  throw new WorkflowSuspended(void 0, fanJoin(active.map((k) => allWait(runId, firstSeq, waves ? k : void 0))));
3268
3432
  }
3269
3433
  const outputs = [];
3434
+ const results = [];
3270
3435
  const failures = [];
3271
3436
  for (let i = 0; i < inputs.length; i += 1) {
3272
3437
  const c = existing[i]?.output;
3273
3438
  if (c && typeof c === "object" && "ok" in c && c.ok === false) {
3439
+ const { message, ...rest } = completionError({
3440
+ ...c,
3441
+ error: c.error ?? "unknown"
3442
+ });
3443
+ const failure = {
3444
+ id: id(i),
3445
+ error: message,
3446
+ ...rest
3447
+ };
3274
3448
  failures.push({
3275
3449
  index: i,
3276
- id: id(i),
3277
- error: c.error ?? "unknown"
3450
+ ...failure
3451
+ });
3452
+ results.push({
3453
+ status: "rejected",
3454
+ ...failure
3278
3455
  });
3279
3456
  outputs.push(void 0);
3280
3457
  } else {
3281
- outputs.push(c?.value);
3458
+ const value = c?.value;
3459
+ results.push({
3460
+ status: "fulfilled",
3461
+ value
3462
+ });
3463
+ outputs.push(value);
3282
3464
  }
3283
3465
  }
3466
+ if (settle) return results;
3284
3467
  if (failures.length > 0) throw new GatherError(failures, inputs.length);
3285
3468
  return outputs;
3286
- }, "all");
3287
- const allSteps = /* @__PURE__ */ __name(async (ref, inputs, opts) => {
3469
+ }, "fan");
3470
+ const all = /* @__PURE__ */ __name((workflow2, inputs, opts) => fan(workflow2, inputs, opts, false), "all");
3471
+ const allSettled = /* @__PURE__ */ __name((workflow2, inputs, opts) => fan(workflow2, inputs, opts, true), "allSettled");
3472
+ const allSteps = /* @__PURE__ */ __name(async (ref, inputs, opts, settle) => {
3288
3473
  if (opts?.queue !== void 0) {
3289
3474
  throw new Error("ctx.all: a flow-control queue is not supported on a step fan");
3290
3475
  }
@@ -3295,7 +3480,8 @@ function createWorkflowCtx(host, runId, compensations, workflow = "", searchAttr
3295
3480
  };
3296
3481
  const positions = inputs.map(() => pos.next());
3297
3482
  return host.callRemoteAll(runId, positions, def, inputs, {
3298
- mode: opts?.mode ?? "waitAll",
3483
+ mode: settle ? "waitAll" : opts?.mode ?? "waitAll",
3484
+ settle,
3299
3485
  concurrency: opts?.concurrency,
3300
3486
  transport: opts?.transport,
3301
3487
  admission: {
@@ -3613,6 +3799,7 @@ function createWorkflowCtx(host, runId, compensations, workflow = "", searchAttr
3613
3799
  task: guard("task", task),
3614
3800
  child: guard("child", child),
3615
3801
  all: guard("all", all),
3802
+ allSettled: guard("allSettled", allSettled),
3616
3803
  startChild: guard("startChild", startChild),
3617
3804
  breakpoint: guard("breakpoint", breakpoint),
3618
3805
  webhook: guardedWebhook,
@@ -3638,9 +3825,11 @@ function mergeTags(staticTags, runTags) {
3638
3825
  __name(mergeTags, "mergeTags");
3639
3826
  var BREAKPOINT = "breakpoint";
3640
3827
  var isBreakpoint = /* @__PURE__ */ __name((cp) => cp.status === "pending" && cp.name.startsWith(BREAKPOINT), "isBreakpoint");
3828
+ var ANNOUNCED_ROUTES_TTL_MS = 5e3;
3641
3829
  var VERSION_UNDECLARED_TAG = "version:undeclared";
3642
3830
  var versionKey = /* @__PURE__ */ __name((name, version) => `${name}@${version}`, "versionKey");
3643
3831
  var STEP_FAN_CHUNK = 200;
3832
+ var RESULT_BATCH_MAX = 256;
3644
3833
  function shuffle(items) {
3645
3834
  for (let i = items.length - 1; i > 0; i -= 1) {
3646
3835
  const j = Math.floor(Math.random() * (i + 1));
@@ -3748,6 +3937,8 @@ var WorkflowEngine = class {
3748
3937
  updateValidators = /* @__PURE__ */ new Map();
3749
3938
  /** Runs being cancelled WITH saga compensation — see `cancel({ compensate: true })`. */
3750
3939
  cancelRequested = /* @__PURE__ */ new Set();
3940
+ /** {@link announcedRoutes} reads, by group + workflow + version. */
3941
+ announcedRoutesCache = /* @__PURE__ */ new Map();
3751
3942
  /**
3752
3943
  * Seqs of the dispatched remote steps a turn suspended on, by run, while that turn executes on
3753
3944
  * THIS instance — so the turn can re-check them once its lease is released (see
@@ -3769,6 +3960,8 @@ var WorkflowEngine = class {
3769
3960
  stepFansFiredHere = /* @__PURE__ */ new Map();
3770
3961
  /** The tail of each run's queue of results being handled on this instance — see {@link serializedPerRun}. */
3771
3962
  resultChains = /* @__PURE__ */ new Map();
3963
+ /** Each run's results waiting to be settled on this instance — see {@link completeRemoteResult}. */
3964
+ resultQueues = /* @__PURE__ */ new Map();
3772
3965
  /** Flow-control admission backend for remote steps (see {@link registerQueue}). */
3773
3966
  admission;
3774
3967
  /** Runs on THIS instance blocked on admission, by queue — woken early on a freed-slot signal. */
@@ -3975,6 +4168,7 @@ var WorkflowEngine = class {
3975
4168
  name,
3976
4169
  version,
3977
4170
  fn,
4171
+ onError: this.checkedRoutes(name, opts?.onError),
3978
4172
  tags: opts?.tags,
3979
4173
  singleton: opts?.singleton,
3980
4174
  concurrency: opts?.concurrency,
@@ -4044,6 +4238,7 @@ var WorkflowEngine = class {
4044
4238
  executionTimeoutMs: opts.executionTimeout != null ? parseDuration(opts.executionTimeout) : void 0,
4045
4239
  validateInput: opts.validateInput,
4046
4240
  requires: opts.requires,
4241
+ onError: this.checkedRoutes(name, opts.onError),
4047
4242
  remote: {
4048
4243
  group: opts.group,
4049
4244
  executor: opts.executor
@@ -4821,6 +5016,24 @@ var WorkflowEngine = class {
4821
5016
  if (resurrecting) {
4822
5017
  const isFailureCompletion = /* @__PURE__ */ __name((v) => typeof v === "object" && v !== null && v.ok === false, "isFailureCompletion");
4823
5018
  for (const cp of await this.store.listCheckpoints(runId)) {
5019
+ const handlerId = handoffHandlerId(runId, cp);
5020
+ if (handlerId !== void 0) {
5021
+ await this.store.takeSignalWaiter(`child:${handlerId}`);
5022
+ await this.store.saveCheckpoint({
5023
+ ...cp,
5024
+ name: retiredHandoffName(handlerId),
5025
+ status: "completed"
5026
+ });
5027
+ const tags = run.tags?.filter((t) => !t.startsWith("onError:"));
5028
+ if (tags && tags.length !== run.tags?.length) {
5029
+ await this.store.updateRun(runId, {
5030
+ tags,
5031
+ updatedAt: /* @__PURE__ */ new Date()
5032
+ });
5033
+ run.tags = tags;
5034
+ }
5035
+ continue;
5036
+ }
4824
5037
  if (cp.status === "failed") {
4825
5038
  await this.store.saveCheckpoint({
4826
5039
  ...cp,
@@ -4965,13 +5178,13 @@ var WorkflowEngine = class {
4965
5178
  * `start` when a call site needs the outcome — `await start(...); const r = await waitForRun(id)`.
4966
5179
  */
4967
5180
  waitForRun(runId, opts) {
4968
- const TERMINAL = /* @__PURE__ */ new Set([
5181
+ const TERMINAL2 = /* @__PURE__ */ new Set([
4969
5182
  "completed",
4970
5183
  "failed",
4971
5184
  "cancelled",
4972
5185
  "dead"
4973
5186
  ]);
4974
- 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");
5187
+ 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");
4975
5188
  const toResult = /* @__PURE__ */ __name((run) => ({
4976
5189
  runId,
4977
5190
  status: run.status,
@@ -5226,10 +5439,7 @@ var WorkflowEngine = class {
5226
5439
  async redriveSettledStepFans(runId, fans) {
5227
5440
  for (const fan of fans) {
5228
5441
  if (this.stepFansFiredHere.has(`${runId}:${fan.from}`)) continue;
5229
- const pending = this.store.countCheckpoints ? await this.store.countCheckpoints(runId, fan.from, fan.to, [
5230
- "pending"
5231
- ]) : (await this.store.listCheckpoints(runId)).filter((c) => c.seq >= fan.from && c.seq <= fan.to && c.status === "pending").length;
5232
- if (pending > 0) continue;
5442
+ if (await this.stepFanPending(runId, fan.from, fan.to) > 0) continue;
5233
5443
  const nowMs = this.clock();
5234
5444
  if (!await this.store.tryLockRun(runId, this.instanceId, nowMs + this.leaseMs, nowMs)) {
5235
5445
  return;
@@ -5939,6 +6149,209 @@ var WorkflowEngine = class {
5939
6149
  * us back here (a remote step finished, a timer fired) goes through `resume` like any TS workflow.
5940
6150
  */
5941
6151
  /**
6152
+ * The registered onError routes whose handler workflow nothing can run: not registered on this
6153
+ * engine, and no live worker group serves it. Meant for a boot-time warning: a typo in a handler's
6154
+ * name would otherwise surface only when the first run fails.
6155
+ */
6156
+ async unservedOnErrorRoutes() {
6157
+ const unserved = [];
6158
+ let groups;
6159
+ for (const registered of this.workflows.values()) {
6160
+ for (const route of registered.onError ?? []) {
6161
+ if (this.latest.has(route.workflow)) continue;
6162
+ groups ??= await this.pool.listWorkerGroups().catch(() => []);
6163
+ const token = sanitizeQueueToken(route.workflow);
6164
+ if (groups.some((g) => g === token || g.startsWith(`${token}@`))) continue;
6165
+ unserved.push({
6166
+ workflow: registered.name,
6167
+ route
6168
+ });
6169
+ }
6170
+ }
6171
+ return unserved;
6172
+ }
6173
+ /** Validate a workflow's {@link OnErrorRoute}s at registration. */
6174
+ checkedRoutes(name, routes) {
6175
+ if (!routes?.length) return void 0;
6176
+ assertRoutes(name, routes);
6177
+ return routes;
6178
+ }
6179
+ /**
6180
+ * Route a failed run to its {@link OnErrorRoute} handler, read from the workflow's local registration
6181
+ * or, for one served by convention, from what its live workers announce. Starts the handler run and
6182
+ * waits for it the way a parent waits for a `ctx.child`: a `child:<handlerId>` waiter plus a
6183
+ * `running` placeholder checkpoint carrying the handler's name and input, so the handler's terminal
6184
+ * state wakes the run (buffered and reconciled like any child). From then on every execution of the
6185
+ * run only follows the handler ({@link followHandoff}); the body never runs again. Undefined when no
6186
+ * route matches: the run fails as usual.
6187
+ *
6188
+ * The placeholder sits past every seq the body used and is left out of {@link remoteHistory}.
6189
+ */
6190
+ async routeFailure(run, error2) {
6191
+ const registered = this.workflows.get(versionKey(run.workflow, run.workflowVersion));
6192
+ const routes = registered ? registered.onError : await this.announcedRoutes(run, error2);
6193
+ const route = routeFor(routes, error2);
6194
+ if (!route) return void 0;
6195
+ const checkpoints = await this.handoffCheckpoints(run.id);
6196
+ const live = liveHandoff(run.id, checkpoints);
6197
+ if (live) return this.followHandoff(run, live);
6198
+ const attempt = checkpoints.length;
6199
+ const seq = HANDOFF_SEQ_BASE + attempt;
6200
+ const handlerId = onErrorRunId(run.id, attempt);
6201
+ const handler = {
6202
+ workflow: route.workflow,
6203
+ input: {
6204
+ runId: run.id,
6205
+ workflow: run.workflow,
6206
+ input: run.input,
6207
+ error: error2
6208
+ }
6209
+ };
6210
+ await this.store.putSignalWaiter({
6211
+ token: `child:${handlerId}`,
6212
+ runId: run.id,
6213
+ seq
6214
+ });
6215
+ await this.store.saveCheckpoint({
6216
+ ...instantCheckpoint({
6217
+ runId: run.id,
6218
+ seq,
6219
+ name: handoffName(handlerId),
6220
+ kind: "signal",
6221
+ status: "running"
6222
+ }),
6223
+ input: handler
6224
+ });
6225
+ const tag = `onError:${route.code ?? "*"}`;
6226
+ if (!run.tags?.includes(tag)) {
6227
+ const tags = [
6228
+ ...run.tags ?? [],
6229
+ tag
6230
+ ];
6231
+ await this.store.updateRun(run.id, {
6232
+ tags,
6233
+ updatedAt: /* @__PURE__ */ new Date()
6234
+ });
6235
+ run.tags = tags;
6236
+ }
6237
+ this.emit({
6238
+ type: "run.routed",
6239
+ runId: run.id,
6240
+ workflow: run.workflow,
6241
+ namespace: run.namespace,
6242
+ name: route.workflow,
6243
+ error: error2
6244
+ });
6245
+ this.startChildDeferred(handler.workflow, handler.input, handlerId, {
6246
+ namespace: run.namespace
6247
+ });
6248
+ return {
6249
+ kind: "suspended"
6250
+ };
6251
+ }
6252
+ /** A run's handoff checkpoints, live and retired: the only ones at {@link HANDOFF_SEQ_BASE} and up. */
6253
+ async handoffCheckpoints(runId) {
6254
+ const handoffs = await this.store.listCheckpointsByNamePrefix?.(runId, [
6255
+ `signal:child:${runId}.onError.`,
6256
+ `onError:retried:${runId}.onError.`
6257
+ ]);
6258
+ return (handoffs ?? await this.store.listCheckpoints(runId)).filter((cp) => cp.seq >= HANDOFF_SEQ_BASE);
6259
+ }
6260
+ /**
6261
+ * Where a run that handed its failure off stands: the handler's outcome once it settled (from the
6262
+ * wake's checkpoint, or off the handler run when the wake never arrived), else still waiting. Starts
6263
+ * the handler if the turn that handed off crashed before its deferred start ran.
6264
+ */
6265
+ async followHandoff(run, handoff) {
6266
+ if (handoff.status === "completed") {
6267
+ const outcome = handoff.output;
6268
+ return outcome.ok ? {
6269
+ kind: "completed",
6270
+ output: outcome.value
6271
+ } : {
6272
+ kind: "failed",
6273
+ error: completionError(outcome)
6274
+ };
6275
+ }
6276
+ const handlerId = handoffHandlerId(run.id, handoff);
6277
+ const handlerRun = await this.store.getRun(handlerId);
6278
+ if (handlerRun?.status === "completed") return {
6279
+ kind: "completed",
6280
+ output: handlerRun.output
6281
+ };
6282
+ if (handlerRun && TERMINAL_RUN_STATUSES.includes(handlerRun.status)) {
6283
+ return {
6284
+ kind: "failed",
6285
+ error: handlerRun.error ?? {
6286
+ message: `onError handler ${handlerId} ${handlerRun.status}`
6287
+ }
6288
+ };
6289
+ }
6290
+ if (!handlerRun) {
6291
+ const handler = handoff.input;
6292
+ this.startChildDeferred(handler.workflow, handler.input, handlerId, {
6293
+ namespace: run.namespace
6294
+ });
6295
+ }
6296
+ return {
6297
+ kind: "suspended"
6298
+ };
6299
+ }
6300
+ /**
6301
+ * The onError routes the live workers announce for a workflow the engine serves by convention: the
6302
+ * one set they agree on, or none when they state none or cannot be read. Workers that announce
6303
+ * different sets (a rollout in progress) route nothing, and the run's failure is reported as a
6304
+ * `run.unrouted` event. Announcers of another version of the workflow are ignored. The read is
6305
+ * cached for {@link ANNOUNCED_ROUTES_TTL_MS}, so a burst of failures costs one descriptor read.
6306
+ */
6307
+ async announcedRoutes(run, error2) {
6308
+ const group = tenantGroup(sanitizeQueueToken(run.workflow), run.namespace);
6309
+ const key = `${group}
6310
+ ${run.workflow}
6311
+ ${run.workflowVersion}`;
6312
+ const now = this.clock();
6313
+ let cached = this.announcedRoutesCache.get(key);
6314
+ if (!cached || now - cached.at > ANNOUNCED_ROUTES_TTL_MS) {
6315
+ cached = {
6316
+ at: now,
6317
+ sets: await this.readAnnouncedRoutes(run, group)
6318
+ };
6319
+ this.announcedRoutesCache.set(key, cached);
6320
+ }
6321
+ if (cached.sets.length > 1) {
6322
+ this.emit({
6323
+ type: "run.unrouted",
6324
+ runId: run.id,
6325
+ workflow: run.workflow,
6326
+ namespace: run.namespace,
6327
+ error: error2,
6328
+ reason: `live workers announce ${cached.sets.length} different onError route sets`
6329
+ });
6330
+ return void 0;
6331
+ }
6332
+ return cached.sets[0];
6333
+ }
6334
+ /** Every distinct onError route set the live workers of `group` announce for the run's workflow. */
6335
+ async readAnnouncedRoutes(run, group) {
6336
+ const transport = await this.pool.transportWithLiveGroup(group);
6337
+ if (!transport?.readWorkerDescriptors) return [];
6338
+ let descriptors;
6339
+ try {
6340
+ descriptors = await transport.readWorkerDescriptors(group);
6341
+ } catch {
6342
+ return [];
6343
+ }
6344
+ const sets = /* @__PURE__ */ new Map();
6345
+ for (const announced of aggregateAnnouncements(descriptors)) {
6346
+ if (announced.name !== run.workflow) continue;
6347
+ if (announced.version !== void 0 && announced.version !== run.workflowVersion) continue;
6348
+ for (const routes of announced.onError) sets.set(JSON.stringify(routes), routes);
6349
+ }
6350
+ return [
6351
+ ...sets.values()
6352
+ ];
6353
+ }
6354
+ /**
5942
6355
  * Apply a terminal/suspended transition once: persist the new run status, emit the matching
5943
6356
  * lifecycle event, and (on completion/failure) wake a waiting parent. Both the TS and remote
5944
6357
  * executors funnel their completed/failed/suspended outcomes through here so a status update can
@@ -5972,9 +6385,12 @@ var WorkflowEngine = class {
5972
6385
  };
5973
6386
  }
5974
6387
  if (outcome.kind === "failed") {
6388
+ const handed = await this.routeFailure(run, outcome.error);
6389
+ if (handed && handed.kind !== "failed") return this.settleRun(run, handed);
6390
+ const error2 = handed?.error ?? outcome.error;
5975
6391
  await this.store.updateRun(run.id, {
5976
6392
  status: "failed",
5977
- error: outcome.error,
6393
+ error: error2,
5978
6394
  updatedAt
5979
6395
  });
5980
6396
  this.emit({
@@ -5982,16 +6398,13 @@ var WorkflowEngine = class {
5982
6398
  runId: run.id,
5983
6399
  workflow: run.workflow,
5984
6400
  namespace: run.namespace,
5985
- error: outcome.error
5986
- });
5987
- void this.notifyParent(run.id, {
5988
- ok: false,
5989
- error: outcome.error.message
6401
+ error: error2
5990
6402
  });
6403
+ void this.notifyParent(run.id, failedCompletion(error2));
5991
6404
  return {
5992
6405
  runId: run.id,
5993
6406
  status: "failed",
5994
- error: outcome.error
6407
+ error: error2
5995
6408
  };
5996
6409
  }
5997
6410
  if (outcome.kind === "blocked") return this.parkBlocked(run, outcome.blocked);
@@ -6080,7 +6493,14 @@ var WorkflowEngine = class {
6080
6493
  }
6081
6494
  const wfPlan = await this.planRoute(tenantGroup(sanitizeQueueToken(remote.group), void 0), registered.requires);
6082
6495
  if (wfPlan?.status === "blocked") return this.parkBlocked(run, wfPlan);
6083
- const history = await this.remoteHistory(run.id);
6496
+ const checkpoints = await this.store.listCheckpoints(run.id);
6497
+ const handoff = liveHandoff(run.id, checkpoints);
6498
+ if (handoff) {
6499
+ const result = await this.settleRun(run, await this.followHandoff(run, handoff));
6500
+ await this.store.releaseRunLock(run.id);
6501
+ return result;
6502
+ }
6503
+ const history = await this.remoteHistory(run.id, checkpoints);
6084
6504
  if (remote.executor.dispatch) {
6085
6505
  const taskId = `${run.id}:wf:${globalThis.crypto.randomUUID().slice(0, 8)}`;
6086
6506
  const silenceMs = this.remoteAdvanceSilenceMs;
@@ -6404,8 +6824,8 @@ var WorkflowEngine = class {
6404
6824
  }
6405
6825
  }
6406
6826
  /** The run's resolved durable ops as replay inputs: completed/failed steps + elapsed timers. */
6407
- async remoteHistory(runId) {
6408
- const checkpoints = await this.store.listCheckpoints(runId);
6827
+ async remoteHistory(runId, listed) {
6828
+ const checkpoints = listed ?? await this.store.listCheckpoints(runId);
6409
6829
  const kindOf = {
6410
6830
  remote: "call",
6411
6831
  local: "step",
@@ -6415,15 +6835,14 @@ var WorkflowEngine = class {
6415
6835
  const events = [];
6416
6836
  for (const cp of checkpoints) {
6417
6837
  if (cp.status === "completed" || cp.status === "failed") {
6838
+ if (cp.seq >= HANDOFF_SEQ_BASE) continue;
6418
6839
  if (cp.kind === "signal" && cp.name.startsWith("signal:child:")) {
6419
6840
  const completion = cp.output;
6420
6841
  events.push({
6421
6842
  seq: cp.seq,
6422
6843
  kind: "child",
6423
6844
  output: completion?.ok ? completion.value : void 0,
6424
- error: completion && completion.ok === false ? {
6425
- message: completion.error
6426
- } : void 0
6845
+ error: completion && completion.ok === false ? completionError(completion) : void 0
6427
6846
  });
6428
6847
  continue;
6429
6848
  }
@@ -6704,6 +7123,10 @@ var WorkflowEngine = class {
6704
7123
  this.awaitedStepFans.set(run.id, awaitedFans);
6705
7124
  const ctx = createWorkflowCtx(this.ctxHostFor(replay, run.namespace), run.id, compensations, run.workflow, registered?.searchAttributesSchema);
6706
7125
  try {
7126
+ const handoff = liveHandoff(run.id, snapshot);
7127
+ if (handoff && !this.cancelRequested.has(run.id)) {
7128
+ return await this.settleRun(run, await this.followHandoff(run, handoff));
7129
+ }
6707
7130
  const output = await runInWorkflowCtx(ctx, () => fn(ctx, run.input));
6708
7131
  if (this.cancelRequested.has(run.id)) {
6709
7132
  return await this.compensateAndCancel(run, compensations, replay);
@@ -6766,7 +7189,8 @@ var WorkflowEngine = class {
6766
7189
  }
6767
7190
  const error2 = {
6768
7191
  message: err instanceof Error ? err.message : String(err),
6769
- stack: err instanceof Error ? err.stack : void 0
7192
+ stack: err instanceof Error ? err.stack : void 0,
7193
+ ...codeAndDetails(err)
6770
7194
  };
6771
7195
  const pending = await this.unwindCompensations(run, compensations, replay);
6772
7196
  if (pending) return await this.settleRun(run, {
@@ -7402,7 +7826,8 @@ var WorkflowEngine = class {
7402
7826
  const failure = {
7403
7827
  index: i,
7404
7828
  id: stepId(runId, seq),
7405
- error: err.message
7829
+ error: err.message,
7830
+ ...codeAndDetails(err)
7406
7831
  };
7407
7832
  if (opts.mode === "failFast") throw new GatherError([
7408
7833
  failure
@@ -7424,6 +7849,24 @@ var WorkflowEngine = class {
7424
7849
  });
7425
7850
  throw new WorkflowSuspended(wakeAt);
7426
7851
  }
7852
+ if (opts.settle) {
7853
+ const failed = new Map(failures.map((f) => [
7854
+ f.index,
7855
+ f
7856
+ ]));
7857
+ return Array.from(outputs, (value, i) => {
7858
+ const f = failed.get(i);
7859
+ if (!f) return {
7860
+ status: "fulfilled",
7861
+ value
7862
+ };
7863
+ const { index: _index, ...rejected } = f;
7864
+ return {
7865
+ status: "rejected",
7866
+ ...rejected
7867
+ };
7868
+ });
7869
+ }
7427
7870
  if (failures.length > 0) throw new GatherError(failures, inputs.length);
7428
7871
  return outputs;
7429
7872
  }
@@ -7495,6 +7938,25 @@ var WorkflowEngine = class {
7495
7938
  await this.store.saveCheckpoint(checkpoint);
7496
7939
  return true;
7497
7940
  }
7941
+ /**
7942
+ * {@link savePendingCheckpoint} for checkpoints at distinct seqs of one run: one
7943
+ * {@link StateStore.saveCheckpointsIfPending} call when the store has it, else one write each.
7944
+ * Never rejects as a whole: a write that failed is reported as that entry's error, and the batch
7945
+ * call rejecting means it wrote nothing, so the entries are then written one by one.
7946
+ */
7947
+ async savePendingCheckpoints(checkpoints) {
7948
+ if (checkpoints.length > 1 && this.store.saveCheckpointsIfPending) {
7949
+ try {
7950
+ const wrote = await this.store.saveCheckpointsIfPending(checkpoints);
7951
+ return checkpoints.map((_, i) => wrote[i] === true);
7952
+ } catch {
7953
+ }
7954
+ }
7955
+ const settled = await Promise.allSettled(checkpoints.map((cp) => this.savePendingCheckpoint(cp)));
7956
+ return settled.map((s) => s.status === "fulfilled" ? s.value : {
7957
+ error: s.reason
7958
+ });
7959
+ }
7498
7960
  /** Enqueue a remote step task to its worker group via the transport pool. Shared by the initial
7499
7961
  * dispatch, the opt-in self-heal re-dispatch (see `remoteRedispatchMs`), and the explicit
7500
7962
  * {@link redispatchPending}. Carries the current trace/context so a re-dispatch is traceable. */
@@ -7533,57 +7995,161 @@ var WorkflowEngine = class {
7533
7995
  * result (the dispatching one may be gone), so the run is crash/scale-safe. Resolves whether the
7534
7996
  * run should now be resumed: not for a duplicate or late delivery (the checkpoint isn't `pending`),
7535
7997
  * a run already finished, or a step fan whose barrier is still closed.
7998
+ *
7999
+ * Results of one run are settled in batches: the first starts a batch, and the ones that arrive
8000
+ * while it is being written form the next. Under load a wide fan's results are written by one
8001
+ * {@link StateStore.saveCheckpointsIfPending} call per batch, and the fan's barrier is asked once
8002
+ * per batch, not once per result. A result resolves only once its own write has committed, so the
8003
+ * transport acknowledges nothing that is not durable yet.
7536
8004
  */
7537
- async completeRemoteResult(result) {
7538
- const cp = await this.store.getCheckpoint(result.runId, result.seq);
7539
- if (!cp || cp.status !== "pending") return false;
7540
- void this.releaseQueueSlot(cp.stepId).catch(() => void 0);
7541
- const run = await this.store.getRun(result.runId);
7542
- if (run && (run.status === "cancelled" || run.status === "completed")) return false;
8005
+ completeRemoteResult(result) {
8006
+ return new Promise((resolve, reject) => {
8007
+ const entry = {
8008
+ result,
8009
+ resolve,
8010
+ reject
8011
+ };
8012
+ const queued = this.resultQueues.get(result.runId);
8013
+ if (queued) {
8014
+ queued.push(entry);
8015
+ return;
8016
+ }
8017
+ const queue = [
8018
+ entry
8019
+ ];
8020
+ this.resultQueues.set(result.runId, queue);
8021
+ void this.drainResultQueue(result.runId, queue);
8022
+ });
8023
+ }
8024
+ /** Settle `queue` (a run's queued results) batch by batch until it is empty, then retire it. */
8025
+ async drainResultQueue(runId, queue) {
8026
+ await Promise.resolve();
8027
+ while (queue.length > 0) {
8028
+ const batch = queue.splice(0, RESULT_BATCH_MAX);
8029
+ let outcomes;
8030
+ try {
8031
+ outcomes = await this.completeRemoteResults(runId, batch.map((q) => q.result));
8032
+ } catch (err) {
8033
+ outcomes = batch.map(() => ({
8034
+ error: err
8035
+ }));
8036
+ }
8037
+ batch.forEach((q, i) => {
8038
+ const outcome = outcomes[i] ?? false;
8039
+ if (typeof outcome === "boolean") q.resolve(outcome);
8040
+ else q.reject(outcome.error);
8041
+ });
8042
+ }
8043
+ this.resultQueues.delete(runId);
8044
+ }
8045
+ /**
8046
+ * Settle a batch of one run's step results. At most one of them resolves `true` (resume the run):
8047
+ * that resume replays every write of the batch, so a second would only replay it again. A result
8048
+ * whose read or write failed resolves its error, and the transport redelivers just that one.
8049
+ */
8050
+ async completeRemoteResults(runId, results) {
8051
+ const outcomes = results.map(() => false);
8052
+ const seen = /* @__PURE__ */ new Set();
8053
+ const live = results.flatMap((r, i) => {
8054
+ if (seen.has(r.seq)) return [];
8055
+ seen.add(r.seq);
8056
+ return [
8057
+ i
8058
+ ];
8059
+ });
8060
+ const reads = await Promise.allSettled(live.map((i) => this.store.getCheckpoint(runId, results[i].seq)));
8061
+ const pending = [];
8062
+ reads.forEach((read, k) => {
8063
+ const index = live[k];
8064
+ if (read.status === "rejected") outcomes[index] = {
8065
+ error: read.reason
8066
+ };
8067
+ else if (read.value?.status === "pending") pending.push({
8068
+ index,
8069
+ cp: read.value
8070
+ });
8071
+ });
8072
+ if (pending.length === 0) return outcomes;
8073
+ for (const { cp } of pending) void this.releaseQueueSlot(cp.stepId).catch(() => void 0);
8074
+ const run = await this.store.getRun(runId);
8075
+ if (run && (run.status === "cancelled" || run.status === "completed")) return outcomes;
7543
8076
  const finishedAt = /* @__PURE__ */ new Date();
7544
- const startedAt = result.startedAt ? new Date(result.startedAt) : cp.startedAt;
7545
- const claimed = await this.savePendingCheckpoint({
7546
- ...cp,
7547
- status: result.status,
7548
- output: result.status === "completed" ? result.output : cp.output,
7549
- error: result.error,
7550
- events: result.events ?? cp.events,
7551
- startedAt,
7552
- finishedAt
8077
+ const writes = pending.map(({ index, cp }) => {
8078
+ const result = results[index];
8079
+ return {
8080
+ ...cp,
8081
+ status: result.status,
8082
+ output: result.status === "completed" ? result.output : cp.output,
8083
+ error: result.error,
8084
+ events: result.events ?? cp.events,
8085
+ startedAt: result.startedAt ? new Date(result.startedAt) : cp.startedAt,
8086
+ finishedAt
8087
+ };
7553
8088
  });
7554
- if (!claimed) return false;
7555
- this.noteSettledHere(`${result.runId}:${result.seq}`);
7556
- this.emit({
7557
- type: result.status === "completed" ? "step.completed" : "step.failed",
7558
- runId: result.runId,
7559
- seq: result.seq,
7560
- name: cp.name,
7561
- kind: cp.kind,
7562
- output: result.output,
7563
- error: result.error,
7564
- queueMs: startedAt.getTime() - cp.enqueuedAt.getTime(),
7565
- durationMs: finishedAt.getTime() - startedAt.getTime()
8089
+ const claims = await this.savePendingCheckpoints(writes);
8090
+ const settled = [];
8091
+ claims.forEach((claim, k) => {
8092
+ const { index, cp } = pending[k];
8093
+ if (claim === true) settled.push({
8094
+ index,
8095
+ cp,
8096
+ result: results[index]
8097
+ });
8098
+ else if (claim !== false) outcomes[index] = claim;
7566
8099
  });
7567
- return !await this.stepFanStillRunning(cp, result);
8100
+ for (const { cp, result } of settled) {
8101
+ this.noteSettledHere(`${runId}:${result.seq}`);
8102
+ const startedAt = result.startedAt ? new Date(result.startedAt) : cp.startedAt;
8103
+ this.emit({
8104
+ type: result.status === "completed" ? "step.completed" : "step.failed",
8105
+ runId,
8106
+ seq: result.seq,
8107
+ name: cp.name,
8108
+ kind: cp.kind,
8109
+ output: result.output,
8110
+ error: result.error,
8111
+ queueMs: startedAt.getTime() - cp.enqueuedAt.getTime(),
8112
+ durationMs: finishedAt.getTime() - startedAt.getTime()
8113
+ });
8114
+ }
8115
+ const barriers = /* @__PURE__ */ new Map();
8116
+ let carrier;
8117
+ for (const { index, cp, result } of settled) {
8118
+ if (!await this.stepFanStillRunning(cp, result, barriers)) carrier = index;
8119
+ }
8120
+ if (carrier !== void 0) outcomes[carrier] = true;
8121
+ return outcomes;
7568
8122
  }
7569
8123
  /**
7570
8124
  * The join barrier of a step fan: a result for one of its steps resumes the parent only once no
7571
8125
  * step of the fan is still `pending` (a settled wave), or on a failure (failFast surfaces it, and a
7572
8126
  * retry needs the parent to schedule it). Without it every result would replay the whole parent,
7573
- * which makes a fan of N steps cost O(N²) checkpoint reads.
8127
+ * which makes a fan of N steps cost O(N²) checkpoint reads. `barriers` holds the answer per fan
8128
+ * for the rest of a batch. A barrier that cannot be read counts as open: a spare resume replays and
8129
+ * re-suspends, a lost one strands the run.
7574
8130
  */
7575
- async stepFanStillRunning(cp, result) {
8131
+ async stepFanStillRunning(cp, result, barriers) {
7576
8132
  const fan = parseStepFanGroup(cp.parallelGroup);
7577
8133
  if (!fan) return false;
7578
8134
  if (result.status === "failed") return false;
7579
- const to = fan.firstSeq + fan.size - 1;
7580
- const pending = this.store.countCheckpoints ? await this.store.countCheckpoints(result.runId, fan.firstSeq, to, [
7581
- "pending"
7582
- ]) : (await this.store.listCheckpoints(result.runId)).filter((c) => c.seq >= fan.firstSeq && c.seq <= to && c.status === "pending").length;
7583
- if (pending > 0) return true;
8135
+ let closed = barriers.get(fan.firstSeq);
8136
+ if (!closed) {
8137
+ closed = this.stepFanPending(result.runId, fan.firstSeq, fan.firstSeq + fan.size - 1).then((pending) => pending > 0, () => false);
8138
+ barriers.set(fan.firstSeq, closed);
8139
+ }
8140
+ if (await closed) return true;
7584
8141
  this.noteRecent(this.stepFansFiredHere, `${result.runId}:${fan.firstSeq}`);
7585
8142
  return false;
7586
8143
  }
8144
+ /** How many steps of a step fan's seq range are still `pending`. */
8145
+ async stepFanPending(runId, from, to) {
8146
+ if (this.store.countCheckpoints) {
8147
+ return this.store.countCheckpoints(runId, from, to, [
8148
+ "pending"
8149
+ ]);
8150
+ }
8151
+ return (await this.store.listCheckpoints(runId)).filter((c) => c.seq >= from && c.seq <= to && c.status === "pending").length;
8152
+ }
7587
8153
  /**
7588
8154
  * Apply a remote workflow-turn {@link WorkflowDecision} durably — runs on WHATEVER instance receives
7589
8155
  * it over the broker (the dispatching one may be gone, or a point-to-point broker handed it to a
@@ -7935,6 +8501,435 @@ var RunGateway = class {
7935
8501
  }
7936
8502
  };
7937
8503
 
8504
+ // src/run-detail-summary.ts
8505
+ var FAN_SUMMARY_FAILURES = 20;
8506
+ var SUMMARY_CHILD_LIMIT = 200;
8507
+ var FAN_PAGE_MAX = 500;
8508
+ function foldedCount(cp) {
8509
+ if (!cp.name.startsWith(FOLDED_PREFIX)) return void 0;
8510
+ const values = cp.output?.values;
8511
+ return Array.isArray(values) ? values.length : 0;
8512
+ }
8513
+ __name(foldedCount, "foldedCount");
8514
+ function isFailedMember(cp) {
8515
+ if (cp.status === "failed") return true;
8516
+ if (!cp.name.startsWith("signal:child:") || cp.status !== "completed") return false;
8517
+ return cp.output?.ok === false;
8518
+ }
8519
+ __name(isFailedMember, "isFailedMember");
8520
+ function fanLabel(members) {
8521
+ if (members.some((cp) => cp.name.startsWith("signal:child:") || foldedCount(cp) !== void 0)) {
8522
+ return "child runs";
8523
+ }
8524
+ const first = members[0]?.name ?? "";
8525
+ return members.every((cp) => cp.name === first) ? first : "steps";
8526
+ }
8527
+ __name(fanLabel, "fanLabel");
8528
+ function summarizeFan(group, members) {
8529
+ const counts = {};
8530
+ const failures = [];
8531
+ let size = 0;
8532
+ let startedAt;
8533
+ let finishedAt;
8534
+ let firstSeq = Number.POSITIVE_INFINITY;
8535
+ let lastSeq = Number.NEGATIVE_INFINITY;
8536
+ for (const cp of members) {
8537
+ firstSeq = Math.min(firstSeq, cp.seq);
8538
+ lastSeq = Math.max(lastSeq, cp.seq);
8539
+ const folded = foldedCount(cp);
8540
+ const n = folded ?? 1;
8541
+ size += n;
8542
+ const status = isFailedMember(cp) ? "failed" : cp.status;
8543
+ counts[status] = (counts[status] ?? 0) + n;
8544
+ if (status === "failed" && failures.length < FAN_SUMMARY_FAILURES) failures.push(cp);
8545
+ if (cp.status !== "pending" && cp.status !== "running") {
8546
+ if (!startedAt || cp.startedAt < startedAt) startedAt = cp.startedAt;
8547
+ if (!finishedAt || cp.finishedAt > finishedAt) finishedAt = cp.finishedAt;
8548
+ } else if (!startedAt || cp.enqueuedAt < startedAt) {
8549
+ startedAt = cp.enqueuedAt;
8550
+ }
8551
+ }
8552
+ const parsed = parseFanGroup(group);
8553
+ const total = parsed?.size;
8554
+ return {
8555
+ group,
8556
+ label: fanLabel(members),
8557
+ firstSeq,
8558
+ lastSeq,
8559
+ size,
8560
+ total,
8561
+ notStarted: total === void 0 ? void 0 : Math.max(0, total - size),
8562
+ failFast: parsed?.failFast ?? false,
8563
+ counts,
8564
+ startedAt,
8565
+ finishedAt,
8566
+ failures
8567
+ };
8568
+ }
8569
+ __name(summarizeFan, "summarizeFan");
8570
+ function summarizeTimeline(timeline, over) {
8571
+ const byGroup = /* @__PURE__ */ new Map();
8572
+ for (const cp of timeline) {
8573
+ if (cp.parallelGroup === void 0) continue;
8574
+ const members = byGroup.get(cp.parallelGroup);
8575
+ if (members) members.push(cp);
8576
+ else byGroup.set(cp.parallelGroup, [
8577
+ cp
8578
+ ]);
8579
+ }
8580
+ const summarized = /* @__PURE__ */ new Set();
8581
+ const fans = [];
8582
+ for (const [group, members] of byGroup) {
8583
+ if (members.length <= over && (parseFanGroup(group)?.size ?? 0) <= over) continue;
8584
+ summarized.add(group);
8585
+ fans.push(summarizeFan(group, members));
8586
+ }
8587
+ if (fans.length === 0) return {
8588
+ timeline,
8589
+ fans
8590
+ };
8591
+ return {
8592
+ timeline: timeline.filter((cp) => cp.parallelGroup === void 0 || !summarized.has(cp.parallelGroup)),
8593
+ fans: fans.sort((a, b) => a.firstSeq - b.firstSeq)
8594
+ };
8595
+ }
8596
+ __name(summarizeTimeline, "summarizeTimeline");
8597
+ function pageFan(timeline, group, query) {
8598
+ const matching = timeline.filter((cp) => cp.parallelGroup === group && (!query.failedOnly || isFailedMember(cp)));
8599
+ const { offset, limit } = fanPageBounds(query);
8600
+ return {
8601
+ items: matching.slice(offset, offset + limit),
8602
+ total: matching.length
8603
+ };
8604
+ }
8605
+ __name(pageFan, "pageFan");
8606
+ function fanPageBounds(query) {
8607
+ const offset = query.offset !== void 0 && Number.isFinite(query.offset) ? Math.max(0, Math.floor(query.offset)) : 0;
8608
+ const limit = query.limit !== void 0 && Number.isFinite(query.limit) ? Math.min(FAN_PAGE_MAX, Math.max(1, Math.floor(query.limit))) : 100;
8609
+ return {
8610
+ offset,
8611
+ limit
8612
+ };
8613
+ }
8614
+ __name(fanPageBounds, "fanPageBounds");
8615
+
8616
+ // src/run-detail-store.ts
8617
+ function aggregatesCheckpoints(store) {
8618
+ return store.checkpointStats !== void 0 && store.queryCheckpoints !== void 0;
8619
+ }
8620
+ __name(aggregatesCheckpoints, "aggregatesCheckpoints");
8621
+ function checkpointStatsOf(checkpoints, opts) {
8622
+ const buckets = /* @__PURE__ */ new Map();
8623
+ for (const cp of checkpoints) {
8624
+ const group = cp.parallelGroup ?? null;
8625
+ if (opts?.group !== void 0 && group !== opts.group) continue;
8626
+ const nameKind = checkpointNameKind(cp.name);
8627
+ const key = JSON.stringify([
8628
+ group,
8629
+ cp.status,
8630
+ nameKind
8631
+ ]);
8632
+ const enqueuedAt = cp.enqueuedAt ?? cp.startedAt;
8633
+ const bucket = buckets.get(key);
8634
+ if (!bucket) {
8635
+ buckets.set(key, {
8636
+ group,
8637
+ status: cp.status,
8638
+ nameKind,
8639
+ count: 1,
8640
+ minSeq: cp.seq,
8641
+ maxSeq: cp.seq,
8642
+ minName: cp.name,
8643
+ maxName: cp.name,
8644
+ minEnqueuedAt: enqueuedAt,
8645
+ minStartedAt: cp.startedAt,
8646
+ maxStartedAt: cp.startedAt,
8647
+ maxFinishedAt: cp.finishedAt,
8648
+ sumAttempts: cp.attempts
8649
+ });
8650
+ continue;
8651
+ }
8652
+ bucket.count += 1;
8653
+ bucket.minSeq = Math.min(bucket.minSeq, cp.seq);
8654
+ bucket.maxSeq = Math.max(bucket.maxSeq, cp.seq);
8655
+ if (cp.name < bucket.minName) bucket.minName = cp.name;
8656
+ if (cp.name > bucket.maxName) bucket.maxName = cp.name;
8657
+ if (enqueuedAt < bucket.minEnqueuedAt) bucket.minEnqueuedAt = enqueuedAt;
8658
+ if (cp.startedAt < bucket.minStartedAt) bucket.minStartedAt = cp.startedAt;
8659
+ if (cp.startedAt > bucket.maxStartedAt) bucket.maxStartedAt = cp.startedAt;
8660
+ if (cp.finishedAt > bucket.maxFinishedAt) bucket.maxFinishedAt = cp.finishedAt;
8661
+ bucket.sumAttempts += cp.attempts;
8662
+ }
8663
+ return [
8664
+ ...buckets.values()
8665
+ ];
8666
+ }
8667
+ __name(checkpointStatsOf, "checkpointStatsOf");
8668
+ function queryCheckpointsOf(checkpoints, query) {
8669
+ const excluded = new Set(query.excludeGroups ?? []);
8670
+ 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);
8671
+ const offset = query.offset ?? 0;
8672
+ return matching.slice(offset, query.limit === void 0 ? void 0 : offset + query.limit);
8673
+ }
8674
+ __name(queryCheckpointsOf, "queryCheckpointsOf");
8675
+ var TERMINAL = /* @__PURE__ */ __name((status) => status !== "pending" && status !== "running", "TERMINAL");
8676
+ function isWide(group, rows, over) {
8677
+ return rows > over || (parseFanGroup(group)?.size ?? 0) > over;
8678
+ }
8679
+ __name(isWide, "isWide");
8680
+ function mergeBySeq(a, b) {
8681
+ return [
8682
+ ...a,
8683
+ ...b
8684
+ ].sort((x, y) => x.seq - y.seq);
8685
+ }
8686
+ __name(mergeBySeq, "mergeBySeq");
8687
+ async function failedChildren(store, runId, group, buckets) {
8688
+ if (!buckets.some((b) => b.nameKind === "child" && b.status === "completed")) {
8689
+ return {
8690
+ completed: [],
8691
+ failed: []
8692
+ };
8693
+ }
8694
+ const completed = await store.queryCheckpoints(runId, {
8695
+ group,
8696
+ statuses: [
8697
+ "completed"
8698
+ ],
8699
+ namePrefixes: [
8700
+ "signal:child:"
8701
+ ]
8702
+ });
8703
+ return {
8704
+ completed,
8705
+ failed: completed.filter(isFailedMember)
8706
+ };
8707
+ }
8708
+ __name(failedChildren, "failedChildren");
8709
+ async function summarizeFanFromStats(store, runId, group, buckets, folded) {
8710
+ const counts = {};
8711
+ const add = /* @__PURE__ */ __name((status, n) => {
8712
+ counts[status] = (counts[status] ?? 0) + n;
8713
+ }, "add");
8714
+ let size = 0;
8715
+ let startedAt;
8716
+ let finishedAt;
8717
+ let firstSeq = Number.POSITIVE_INFINITY;
8718
+ let lastSeq = Number.NEGATIVE_INFINITY;
8719
+ let minName;
8720
+ let maxName;
8721
+ let childRuns = false;
8722
+ for (const b of buckets) {
8723
+ firstSeq = Math.min(firstSeq, b.minSeq);
8724
+ lastSeq = Math.max(lastSeq, b.maxSeq);
8725
+ if (minName === void 0 || b.minName < minName) minName = b.minName;
8726
+ if (maxName === void 0 || b.maxName > maxName) maxName = b.maxName;
8727
+ if (b.nameKind === "child" || b.nameKind === "folded") childRuns = true;
8728
+ if (TERMINAL(b.status)) {
8729
+ if (!startedAt || b.minStartedAt < startedAt) startedAt = b.minStartedAt;
8730
+ if (!finishedAt || b.maxFinishedAt > finishedAt) finishedAt = b.maxFinishedAt;
8731
+ } else if (!startedAt || b.minEnqueuedAt < startedAt) {
8732
+ startedAt = b.minEnqueuedAt;
8733
+ }
8734
+ if (b.nameKind === "folded") continue;
8735
+ if (b.nameKind === "child" && b.status === "completed") continue;
8736
+ add(b.status, b.count);
8737
+ size += b.count;
8738
+ }
8739
+ for (const cp of folded) {
8740
+ const n = foldedCount(cp) ?? 1;
8741
+ add(isFailedMember(cp) ? "failed" : cp.status, n);
8742
+ size += n;
8743
+ }
8744
+ const children = await failedChildren(store, runId, group, buckets);
8745
+ const failedChildSet = new Set(children.failed);
8746
+ for (const cp of children.completed) add(failedChildSet.has(cp) ? "failed" : "completed", 1);
8747
+ size += children.completed.length;
8748
+ const failedStatusRows = buckets.some((b) => b.status === "failed") ? await store.queryCheckpoints(runId, {
8749
+ group,
8750
+ statuses: [
8751
+ "failed"
8752
+ ],
8753
+ limit: FAN_SUMMARY_FAILURES
8754
+ }) : [];
8755
+ const failures = mergeBySeq(failedStatusRows, children.failed).slice(0, FAN_SUMMARY_FAILURES);
8756
+ const parsed = parseFanGroup(group);
8757
+ const total = parsed?.size;
8758
+ return {
8759
+ group,
8760
+ label: childRuns ? "child runs" : minName === maxName ? minName ?? "" : "steps",
8761
+ firstSeq,
8762
+ lastSeq,
8763
+ size,
8764
+ total,
8765
+ notStarted: total === void 0 ? void 0 : Math.max(0, total - size),
8766
+ failFast: parsed?.failFast ?? false,
8767
+ counts,
8768
+ startedAt,
8769
+ finishedAt,
8770
+ failures
8771
+ };
8772
+ }
8773
+ __name(summarizeFanFromStats, "summarizeFanFromStats");
8774
+ async function firstChildIds(store, runId) {
8775
+ const ids = [];
8776
+ for (let offset = 0; ids.length < SUMMARY_CHILD_LIMIT; offset += SUMMARY_CHILD_LIMIT) {
8777
+ const page = await store.queryCheckpoints(runId, {
8778
+ namePrefixes: CHILD_EDGE_PREFIXES,
8779
+ offset,
8780
+ limit: SUMMARY_CHILD_LIMIT
8781
+ });
8782
+ ids.push(...childIdsOf(page));
8783
+ if (page.length < SUMMARY_CHILD_LIMIT) break;
8784
+ }
8785
+ return ids.slice(0, SUMMARY_CHILD_LIMIT);
8786
+ }
8787
+ __name(firstChildIds, "firstChildIds");
8788
+ async function summarizeRunFromStore(store, runId, over, stats) {
8789
+ const all = stats ?? await store.checkpointStats(runId);
8790
+ const byGroup = /* @__PURE__ */ new Map();
8791
+ let edgeRows = 0;
8792
+ let hasFolded = false;
8793
+ for (const b of all) {
8794
+ if (b.nameKind === "child" || b.nameKind === "spawn") edgeRows += b.count;
8795
+ if (b.nameKind === "folded") hasFolded = true;
8796
+ if (b.group === null) continue;
8797
+ const buckets = byGroup.get(b.group);
8798
+ if (buckets) buckets.push(b);
8799
+ else byGroup.set(b.group, [
8800
+ b
8801
+ ]);
8802
+ }
8803
+ const wide = [
8804
+ ...byGroup
8805
+ ].filter(([group, buckets]) => isWide(group, buckets.reduce((n, b) => n + b.count, 0), over));
8806
+ const [timeline, folded, children] = await Promise.all([
8807
+ store.queryCheckpoints(runId, {
8808
+ excludeGroups: wide.map(([group]) => group)
8809
+ }),
8810
+ hasFolded ? store.queryCheckpoints(runId, {
8811
+ namePrefixes: [
8812
+ FOLDED_PREFIX
8813
+ ]
8814
+ }) : [],
8815
+ edgeRows > 0 || hasFolded ? firstChildIds(store, runId) : []
8816
+ ]);
8817
+ const fans = await Promise.all(wide.map(([group, buckets]) => summarizeFanFromStats(store, runId, group, buckets, folded.filter((cp) => cp.parallelGroup === group))));
8818
+ const childCount = folded.reduce((n, cp) => n + childIdsOf([
8819
+ cp
8820
+ ]).length, edgeRows);
8821
+ return {
8822
+ timeline,
8823
+ fans: fans.sort((a, b) => a.firstSeq - b.firstSeq),
8824
+ children,
8825
+ childCount
8826
+ };
8827
+ }
8828
+ __name(summarizeRunFromStore, "summarizeRunFromStore");
8829
+ async function pageFanFromStore(store, runId, group, query, stats) {
8830
+ const buckets = stats ?? await store.checkpointStats(runId, {
8831
+ group
8832
+ });
8833
+ const { offset, limit } = fanPageBounds(query);
8834
+ if (!query.failedOnly) {
8835
+ const total2 = buckets.reduce((n, b) => n + b.count, 0);
8836
+ const items = total2 > offset ? await store.queryCheckpoints(runId, {
8837
+ group,
8838
+ offset,
8839
+ limit
8840
+ }) : [];
8841
+ return {
8842
+ items,
8843
+ total: total2
8844
+ };
8845
+ }
8846
+ const failedStatus = buckets.filter((b) => b.status === "failed").reduce((n, b) => n + b.count, 0);
8847
+ const children = await failedChildren(store, runId, group, buckets);
8848
+ const total = failedStatus + children.failed.length;
8849
+ if (total <= offset) return {
8850
+ items: [],
8851
+ total
8852
+ };
8853
+ if (children.failed.length === 0) {
8854
+ return {
8855
+ items: await store.queryCheckpoints(runId, {
8856
+ group,
8857
+ statuses: [
8858
+ "failed"
8859
+ ],
8860
+ offset,
8861
+ limit
8862
+ }),
8863
+ total
8864
+ };
8865
+ }
8866
+ const failedRows = await store.queryCheckpoints(runId, {
8867
+ group,
8868
+ statuses: [
8869
+ "failed"
8870
+ ],
8871
+ limit: offset + limit
8872
+ });
8873
+ return {
8874
+ items: mergeBySeq(failedRows, children.failed).slice(offset, offset + limit),
8875
+ total
8876
+ };
8877
+ }
8878
+ __name(pageFanFromStore, "pageFanFromStore");
8879
+ function hash(text) {
8880
+ let h1 = 3735928559;
8881
+ let h2 = 1103547991;
8882
+ for (let i = 0; i < text.length; i++) {
8883
+ const ch = text.charCodeAt(i);
8884
+ h1 = Math.imul(h1 ^ ch, 2654435761);
8885
+ h2 = Math.imul(h2 ^ ch, 1597334677);
8886
+ }
8887
+ h1 = Math.imul(h1 ^ h1 >>> 16, 2246822507) ^ Math.imul(h2 ^ h2 >>> 13, 3266489909);
8888
+ h2 = Math.imul(h2 ^ h2 >>> 16, 2246822507) ^ Math.imul(h1 ^ h1 >>> 13, 3266489909);
8889
+ return (4294967296 * (2097151 & h2) + (h1 >>> 0)).toString(16);
8890
+ }
8891
+ __name(hash, "hash");
8892
+ function statsFingerprint(stats) {
8893
+ return stats.map((b) => [
8894
+ b.group ?? "",
8895
+ b.status,
8896
+ b.nameKind,
8897
+ b.count,
8898
+ b.minSeq,
8899
+ b.maxSeq,
8900
+ b.minName,
8901
+ b.maxName,
8902
+ b.minEnqueuedAt.getTime(),
8903
+ b.minStartedAt.getTime(),
8904
+ b.maxStartedAt.getTime(),
8905
+ b.maxFinishedAt.getTime(),
8906
+ b.sumAttempts
8907
+ ].join("")).sort().join("");
8908
+ }
8909
+ __name(statsFingerprint, "statsFingerprint");
8910
+ function runDetailEtag(run, stats, over) {
8911
+ return `"rd-${hash(`${over}${JSON.stringify(run)}${statsFingerprint(stats)}`)}"`;
8912
+ }
8913
+ __name(runDetailEtag, "runDetailEtag");
8914
+ function fanPageEtag(group, stats, query) {
8915
+ const { offset, limit } = fanPageBounds(query);
8916
+ const key = [
8917
+ group,
8918
+ offset,
8919
+ limit,
8920
+ query.failedOnly === true
8921
+ ].join("");
8922
+ return `"fp-${hash(`${key}${statsFingerprint(stats)}`)}"`;
8923
+ }
8924
+ __name(fanPageEtag, "fanPageEtag");
8925
+ function etagMatches(ifNoneMatch, etag) {
8926
+ if (!ifNoneMatch) return false;
8927
+ const bare = /* @__PURE__ */ __name((tag) => tag.trim().replace(/^W\//, ""), "bare");
8928
+ const target = bare(etag);
8929
+ return ifNoneMatch.split(",").some((tag) => tag.trim() === "*" || bare(tag) === target);
8930
+ }
8931
+ __name(etagMatches, "etagMatches");
8932
+
7938
8933
  // src/codec-state-store.ts
7939
8934
  var CodecStateStore = class {
7940
8935
  static {
@@ -7963,11 +8958,19 @@ var CodecStateStore = class {
7963
8958
  /** Encodes like {@link saveCheckpoint}. Bound only when the inner store has it, so the engine's
7964
8959
  * plain-save fallback still kicks in through the wrapper. */
7965
8960
  saveCheckpointIfPending;
8961
+ /** Encodes each checkpoint like {@link saveCheckpoint}. Bound only when the inner store has it. */
8962
+ saveCheckpointsIfPending;
7966
8963
  /** Forwarded verbatim — a count reads no payload. Bound only when the inner store has it. */
7967
8964
  countCheckpoints;
7968
8965
  /** Forwarded verbatim — retention selects by status/updatedAt/scope, never by payload. Bound only
7969
8966
  * when the inner store prunes, so the retention poller still sees (and warns about) its absence. */
7970
8967
  pruneTerminalRuns;
8968
+ /** Forwarded verbatim — the buckets aggregate names, statuses and timestamps, never a payload.
8969
+ * Bound only when the inner store aggregates, so the run detail's fallback still applies. */
8970
+ checkpointStats;
8971
+ /** Filters on unencoded columns, then decodes the rows like {@link listCheckpoints}. Bound only
8972
+ * when the inner store has it. */
8973
+ queryCheckpoints;
7971
8974
  /** Forwarded verbatim — a count reads no payload. Bound only when the inner store counts, so the
7972
8975
  * engine's facet/listing fallback still applies through the wrapper. */
7973
8976
  countRuns;
@@ -7994,8 +8997,14 @@ var CodecStateStore = class {
7994
8997
  if (deleteRuns) this.deleteRuns = (ids) => deleteRuns.call(inner, ids);
7995
8998
  const saveIfPending = inner.saveCheckpointIfPending;
7996
8999
  if (saveIfPending) this.saveCheckpointIfPending = (cp) => saveIfPending.call(inner, this.encCp(cp));
9000
+ const saveManyIfPending = inner.saveCheckpointsIfPending;
9001
+ if (saveManyIfPending) this.saveCheckpointsIfPending = (cps) => saveManyIfPending.call(inner, cps.map((cp) => this.encCp(cp)));
7997
9002
  const countCps = inner.countCheckpoints;
7998
9003
  if (countCps) this.countCheckpoints = (runId, from, to, statuses) => countCps.call(inner, runId, from, to, statuses);
9004
+ const stats = inner.checkpointStats;
9005
+ if (stats) this.checkpointStats = (runId, opts) => stats.call(inner, runId, opts);
9006
+ const queryCps = inner.queryCheckpoints;
9007
+ if (queryCps) this.queryCheckpoints = async (runId, query) => (await queryCps.call(inner, runId, query)).map((c) => this.decCp(c));
7999
9008
  const prune = inner.pruneTerminalRuns;
8000
9009
  if (prune) this.pruneTerminalRuns = (policy, now, limit) => prune.call(inner, policy, now, limit);
8001
9010
  const count = inner.countRuns;
@@ -8203,8 +9212,11 @@ function collectMetrics(engine) {
8203
9212
  started: 0,
8204
9213
  completed: 0,
8205
9214
  failed: 0,
8206
- suspended: 0
9215
+ suspended: 0,
9216
+ routed: 0,
9217
+ unrouted: 0
8207
9218
  },
9219
+ routedByCode: {},
8208
9220
  steps: {
8209
9221
  started: 0,
8210
9222
  completed: 0,
@@ -8246,6 +9258,15 @@ function collectMetrics(engine) {
8246
9258
  case "run.suspended":
8247
9259
  m.runs.suspended += 1;
8248
9260
  break;
9261
+ case "run.routed": {
9262
+ m.runs.routed += 1;
9263
+ const code = e.error?.code ?? "*";
9264
+ m.routedByCode[code] = (m.routedByCode[code] ?? 0) + 1;
9265
+ break;
9266
+ }
9267
+ case "run.unrouted":
9268
+ m.runs.unrouted += 1;
9269
+ break;
8249
9270
  case "step.started":
8250
9271
  m.steps.started += 1;
8251
9272
  break;
@@ -8272,6 +9293,8 @@ function collectMetrics(engine) {
8272
9293
  for (const [k, v] of Object.entries(m.steps)) lines.push(`durable_steps_total{event="${k}"} ${v}`);
8273
9294
  lines.push("# TYPE durable_runs_by_workflow_total counter");
8274
9295
  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}`);
9296
+ lines.push("# TYPE durable_runs_routed_total counter");
9297
+ for (const [code, n] of Object.entries(m.routedByCode)) lines.push(`durable_runs_routed_total{code="${code}"} ${n}`);
8275
9298
  lines.push("# TYPE durable_step_duration_ms_sum counter");
8276
9299
  lines.push(`durable_step_duration_ms_sum ${m.stepDuration.sumMs}`);
8277
9300
  lines.push("# TYPE durable_step_duration_count counter");
@@ -8442,6 +9465,11 @@ var InMemoryStateStore = class {
8442
9465
  * so this lets the benchmark contrast "fetch N, scan in JS" against "fetch the k that match".
8443
9466
  */
8444
9467
  lastCheckpointRowsRead = 0;
9468
+ /**
9469
+ * Test/observability hook: checkpoint rows materialized since construction, across every
9470
+ * checkpoint read that returns rows — what a run-detail benchmark compares between paths.
9471
+ */
9472
+ checkpointRowsRead = 0;
8445
9473
  key(runId, seq) {
8446
9474
  return `${runId}:${seq}`;
8447
9475
  }
@@ -8567,6 +9595,16 @@ var InMemoryStateStore = class {
8567
9595
  });
8568
9596
  return true;
8569
9597
  }
9598
+ async saveCheckpointsIfPending(checkpoints) {
9599
+ return checkpoints.map((checkpoint) => {
9600
+ const key = this.key(checkpoint.runId, checkpoint.seq);
9601
+ if (this.checkpoints.get(key)?.status !== "pending") return false;
9602
+ this.checkpoints.set(key, {
9603
+ ...checkpoint
9604
+ });
9605
+ return true;
9606
+ });
9607
+ }
8570
9608
  async listIncompleteRuns(namespace) {
8571
9609
  return [
8572
9610
  ...this.runs.values()
@@ -8846,6 +9884,23 @@ var InMemoryStateStore = class {
8846
9884
  ...cp
8847
9885
  }));
8848
9886
  this.lastCheckpointRowsRead = rows.length;
9887
+ this.checkpointRowsRead += rows.length;
9888
+ return rows;
9889
+ }
9890
+ runCheckpoints(runId) {
9891
+ return [
9892
+ ...this.checkpoints.values()
9893
+ ].filter((cp) => cp.runId === runId);
9894
+ }
9895
+ async checkpointStats(runId, opts) {
9896
+ return checkpointStatsOf(this.runCheckpoints(runId), opts);
9897
+ }
9898
+ async queryCheckpoints(runId, query) {
9899
+ const rows = queryCheckpointsOf(this.runCheckpoints(runId), query).map((cp) => ({
9900
+ ...cp
9901
+ }));
9902
+ this.lastCheckpointRowsRead = rows.length;
9903
+ this.checkpointRowsRead += rows.length;
8849
9904
  return rows;
8850
9905
  }
8851
9906
  async getLatestCheckpointByName(runId, name) {
@@ -8873,6 +9928,7 @@ var InMemoryStateStore = class {
8873
9928
  ...cp
8874
9929
  }));
8875
9930
  this.lastCheckpointRowsRead = rows.length;
9931
+ this.checkpointRowsRead += rows.length;
8876
9932
  return rows;
8877
9933
  }
8878
9934
  };
@@ -8923,6 +9979,7 @@ var InMemoryTransport = class {
8923
9979
  }
8924
9980
  };
8925
9981
  export {
9982
+ CHILD_EDGE_PREFIXES,
8926
9983
  CONTROL_FLOW_SIGNAL,
8927
9984
  CURRENT_PROTOCOL_VERSION,
8928
9985
  CodecStateStore,
@@ -8940,8 +9997,10 @@ export {
8940
9997
  EVENT_TOKEN_PREFIX,
8941
9998
  Entities,
8942
9999
  EventAccumulators,
10000
+ FAN_SUMMARY_FAILURES,
8943
10001
  FatalError,
8944
10002
  GatherError,
10003
+ HANDOFF_SEQ_BASE,
8945
10004
  InMemoryAdmissionBackend,
8946
10005
  InMemoryStateStore,
8947
10006
  InMemoryTransport,
@@ -8961,6 +10020,7 @@ export {
8961
10020
  SINGLETON_ADMITTED_TAG,
8962
10021
  STATE_STORE,
8963
10022
  STATE_STORE_CANONICAL,
10023
+ SUMMARY_CHILD_LIMIT,
8964
10024
  ScheduleAlreadyExistsError,
8965
10025
  ScheduleClient,
8966
10026
  ScheduleNotFoundError,
@@ -8975,9 +10035,11 @@ export {
8975
10035
  WorkflowEngine,
8976
10036
  WorkflowSuspended,
8977
10037
  aggregateAnnouncements,
10038
+ aggregatesCheckpoints,
8978
10039
  announcementsOf,
8979
10040
  applyAttributeQuery,
8980
10041
  assertConcurrency,
10042
+ assertRoutes,
8981
10043
  attributeColumnFor,
8982
10044
  attributeOperand,
8983
10045
  attributePredicateOperands,
@@ -8987,9 +10049,14 @@ export {
8987
10049
  buildWorkflowDirectory,
8988
10050
  canRoute,
8989
10051
  capableWorkers,
10052
+ checkpointNameKind,
10053
+ checkpointStatsOf,
10054
+ childIdsOf,
8990
10055
  classifyWaiterToken,
8991
10056
  clearDurableExecutionWrappers,
8992
10057
  clearTerminalSingletonAdmission,
10058
+ codeAndDetails,
10059
+ codeOf,
8993
10060
  collectMetrics,
8994
10061
  concurrencyTag,
8995
10062
  controlPlaneDescriptor,
@@ -9002,23 +10069,32 @@ export {
9002
10069
  debug,
9003
10070
  describeWorker,
9004
10071
  descriptorHash,
10072
+ detailsOf,
9005
10073
  error,
10074
+ etagMatches,
9006
10075
  eventMatchOf,
9007
10076
  eventMatches,
9008
10077
  eventNameOf,
9009
10078
  eventPrefix,
9010
10079
  eventToken,
9011
10080
  facetOrigin,
10081
+ fanPageBounds,
10082
+ fanPageEtag,
10083
+ foldedCount,
10084
+ handoffHandlerId,
10085
+ handoffName,
9012
10086
  hasDurableExecutionWrappers,
9013
10087
  heartbeatStatus,
9014
10088
  indexWaitersByRun,
9015
10089
  info,
9016
10090
  isEngineMintedTag,
9017
10091
  isEventToken,
10092
+ isFailedMember,
9018
10093
  isLegacyDescriptor,
9019
10094
  isNonEmptyRunScope,
9020
10095
  isWorkflowControlFlowSignal,
9021
10096
  listSignalWaitersOfRuns,
10097
+ liveHandoff,
9022
10098
  log,
9023
10099
  matchesAttributes,
9024
10100
  mergeRunFacetRows,
@@ -9029,16 +10105,23 @@ export {
9029
10105
  normalizeAttributeRows,
9030
10106
  normalizeDescriptor,
9031
10107
  observedAnnouncements,
10108
+ onErrorRunId,
10109
+ pageFan,
10110
+ pageFanFromStore,
9032
10111
  parentRunIdOf,
9033
10112
  parseDuration,
9034
10113
  partitionOfToken,
9035
10114
  planDispatch,
9036
10115
  prevCronFireMs,
10116
+ queryCheckpointsOf,
9037
10117
  readSearchAttributes,
9038
10118
  requiredCapabilities,
9039
10119
  resolveConcurrency,
9040
10120
  resolveRouting,
9041
10121
  resolveRunWaiting,
10122
+ retiredHandoffName,
10123
+ routeFor,
10124
+ runDetailEtag,
9042
10125
  runDurableExecution,
9043
10126
  runInStepLogger,
9044
10127
  runInStepScope,
@@ -9058,6 +10141,9 @@ export {
9058
10141
  sub,
9059
10142
  subEvent,
9060
10143
  subProcess,
10144
+ summarizeFan,
10145
+ summarizeRunFromStore,
10146
+ summarizeTimeline,
9061
10147
  tenantGroup,
9062
10148
  useDurableExecution,
9063
10149
  warn,