@dudousxd/nestjs-durable-core 0.57.0 → 0.59.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
@@ -23,6 +23,7 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
23
23
  var index_exports = {};
24
24
  __export(index_exports, {
25
25
  CONTROL_FLOW_SIGNAL: () => CONTROL_FLOW_SIGNAL,
26
+ CURRENT_PROTOCOL_VERSION: () => CURRENT_PROTOCOL_VERSION,
26
27
  CodecStateStore: () => CodecStateStore,
27
28
  ContinueAsNew: () => ContinueAsNew,
28
29
  DURABLE_OPTIONS: () => DURABLE_OPTIONS,
@@ -39,6 +40,8 @@ __export(index_exports, {
39
40
  InMemoryAdmissionBackend: () => InMemoryAdmissionBackend,
40
41
  InMemoryStateStore: () => InMemoryStateStore,
41
42
  InMemoryTransport: () => InMemoryTransport,
43
+ LEGACY_V1_CAPABILITIES: () => LEGACY_V1_CAPABILITIES,
44
+ LEGACY_V1_PROTOCOL: () => LEGACY_V1_PROTOCOL,
42
45
  NonDeterminismError: () => NonDeterminismError,
43
46
  QueueController: () => QueueController,
44
47
  RemoteStepError: () => RemoteStepError,
@@ -53,6 +56,7 @@ __export(index_exports, {
53
56
  TRANSPORT: () => TRANSPORT,
54
57
  TRANSPORT_CANONICAL: () => TRANSPORT_CANONICAL,
55
58
  WORKFLOW_NAME_KEY: () => WORKFLOW_NAME_KEY,
59
+ WorkflowBlocked: () => WorkflowBlocked,
56
60
  WorkflowEngine: () => WorkflowEngine,
57
61
  WorkflowSuspended: () => WorkflowSuspended,
58
62
  applyAttributeQuery: () => applyAttributeQuery,
@@ -60,23 +64,35 @@ __export(index_exports, {
60
64
  attributeOperand: () => attributeOperand,
61
65
  bindWorkflowClass: () => bindWorkflowClass,
62
66
  breakpointToken: () => breakpointToken,
67
+ canRoute: () => canRoute,
68
+ capableWorkers: () => capableWorkers,
63
69
  classifyWaiterToken: () => classifyWaiterToken,
64
70
  collectMetrics: () => collectMetrics,
71
+ controlPlaneDescriptor: () => controlPlaneDescriptor,
65
72
  createStepLogger: () => createStepLogger,
66
73
  currentWorkflowCtx: () => currentWorkflowCtx,
74
+ descriptorHash: () => descriptorHash,
67
75
  eventMatchOf: () => eventMatchOf,
68
76
  eventMatches: () => eventMatches,
69
77
  eventNameOf: () => eventNameOf,
70
78
  eventPrefix: () => eventPrefix,
71
79
  eventToken: () => eventToken,
80
+ heartbeatStatus: () => heartbeatStatus,
72
81
  indexWaitersByRun: () => indexWaitersByRun,
73
82
  isEventToken: () => isEventToken,
83
+ isLegacyDescriptor: () => isLegacyDescriptor,
74
84
  isWorkflowControlFlowSignal: () => isWorkflowControlFlowSignal,
75
85
  matchesAttributes: () => matchesAttributes,
86
+ missingCapabilities: () => missingCapabilities,
87
+ negotiate: () => negotiate,
76
88
  normalizeAttributeRows: () => normalizeAttributeRows,
89
+ normalizeDescriptor: () => normalizeDescriptor,
77
90
  parseDuration: () => parseDuration,
91
+ planDispatch: () => planDispatch,
78
92
  prevCronFireMs: () => prevCronFireMs,
79
93
  readSearchAttributes: () => readSearchAttributes,
94
+ requiredCapabilities: () => requiredCapabilities,
95
+ resolveRouting: () => resolveRouting,
80
96
  resolveRunWaiting: () => resolveRunWaiting,
81
97
  runInWorkflowCtx: () => runInWorkflowCtx,
82
98
  runSchedules: () => runSchedules,
@@ -913,6 +929,358 @@ function eventMatches(payload, match) {
913
929
  }
914
930
  __name(eventMatches, "eventMatches");
915
931
 
932
+ // src/handshake/descriptor.ts
933
+ var CURRENT_PROTOCOL_VERSION = 1;
934
+ var LEGACY_V1_PROTOCOL = {
935
+ version: 1,
936
+ range: [
937
+ 1,
938
+ 1
939
+ ]
940
+ };
941
+ var LEGACY_V1_CAPABILITIES = Object.freeze([
942
+ "saga",
943
+ "signals",
944
+ "search-attributes",
945
+ "priority",
946
+ "entities",
947
+ "child-workflows",
948
+ "singleton",
949
+ "schedules",
950
+ "continue-as-new",
951
+ "queries",
952
+ "cancellation"
953
+ ]);
954
+ function isLegacyDescriptor(raw) {
955
+ return raw.protocol === void 0;
956
+ }
957
+ __name(isLegacyDescriptor, "isLegacyDescriptor");
958
+ function normalizeDescriptor(raw) {
959
+ return {
960
+ instanceId: raw.instanceId,
961
+ runtime: raw.runtime,
962
+ sdk: raw.sdk ?? {
963
+ name: "unknown",
964
+ version: "0"
965
+ },
966
+ protocol: raw.protocol ?? {
967
+ ...LEGACY_V1_PROTOCOL
968
+ },
969
+ capabilities: raw.capabilities ?? [
970
+ ...LEGACY_V1_CAPABILITIES
971
+ ],
972
+ workflows: raw.workflows ?? [],
973
+ steps: raw.steps ?? [],
974
+ ...raw.partition !== void 0 ? {
975
+ partition: raw.partition
976
+ } : {},
977
+ ...raw.namespace !== void 0 ? {
978
+ namespace: raw.namespace
979
+ } : {},
980
+ startedAt: raw.startedAt ?? 0
981
+ };
982
+ }
983
+ __name(normalizeDescriptor, "normalizeDescriptor");
984
+ function canonicalizeForHash(d) {
985
+ const set = /* @__PURE__ */ __name((xs) => [
986
+ ...new Set(xs)
987
+ ].sort(), "set");
988
+ return {
989
+ instanceId: d.instanceId,
990
+ runtime: d.runtime,
991
+ sdk: {
992
+ name: d.sdk.name,
993
+ version: d.sdk.version
994
+ },
995
+ protocol: {
996
+ version: d.protocol.version,
997
+ range: [
998
+ d.protocol.range[0],
999
+ d.protocol.range[1]
1000
+ ]
1001
+ },
1002
+ capabilities: set(d.capabilities),
1003
+ workflows: set(d.workflows),
1004
+ steps: set(d.steps),
1005
+ partition: d.partition ?? null,
1006
+ namespace: d.namespace ?? null,
1007
+ startedAt: d.startedAt
1008
+ };
1009
+ }
1010
+ __name(canonicalizeForHash, "canonicalizeForHash");
1011
+ function fnv1a64Hex(input) {
1012
+ const OFFSET = 0xcbf29ce484222325n;
1013
+ const PRIME = 0x100000001b3n;
1014
+ const MASK = 0xffffffffffffffffn;
1015
+ let hash = OFFSET;
1016
+ for (let i = 0; i < input.length; i += 1) {
1017
+ hash ^= BigInt(input.charCodeAt(i) & 255);
1018
+ hash ^= BigInt(input.charCodeAt(i) >> 8 & 255);
1019
+ hash = hash * PRIME & MASK;
1020
+ }
1021
+ return hash.toString(16).padStart(16, "0");
1022
+ }
1023
+ __name(fnv1a64Hex, "fnv1a64Hex");
1024
+ function descriptorHash(descriptor) {
1025
+ const canonical = canonicalizeForHash(normalizeDescriptor(descriptor));
1026
+ return fnv1a64Hex(JSON.stringify(canonical));
1027
+ }
1028
+ __name(descriptorHash, "descriptorHash");
1029
+ function heartbeatStatus(descriptor, opts = {}) {
1030
+ return {
1031
+ ts: opts.ts ?? Date.now(),
1032
+ status: opts.status ?? "up",
1033
+ descriptorHash: descriptorHash(descriptor)
1034
+ };
1035
+ }
1036
+ __name(heartbeatStatus, "heartbeatStatus");
1037
+
1038
+ // src/handshake/negotiate.ts
1039
+ var rangesOverlap = /* @__PURE__ */ __name((a, b) => Math.max(a[0], b[0]) <= Math.min(a[1], b[1]), "rangesOverlap");
1040
+ var difference = /* @__PURE__ */ __name((a, b) => {
1041
+ const bset = new Set(b);
1042
+ return [
1043
+ ...new Set(a)
1044
+ ].filter((x) => !bset.has(x)).sort();
1045
+ }, "difference");
1046
+ var intersection = /* @__PURE__ */ __name((a, b) => {
1047
+ const bset = new Set(b);
1048
+ return [
1049
+ ...new Set(a)
1050
+ ].filter((x) => bset.has(x)).sort();
1051
+ }, "intersection");
1052
+ function negotiate(localRaw, remoteRaw, opts = {}) {
1053
+ const local = normalizeDescriptor(localRaw);
1054
+ const remote = normalizeDescriptor(remoteRaw);
1055
+ const localRange = local.protocol.range;
1056
+ const remoteRange = remote.protocol.range;
1057
+ const shared = intersection(local.capabilities, remote.capabilities);
1058
+ const missingOnRemote = difference(local.capabilities, remote.capabilities);
1059
+ const missingOnLocal = difference(remote.capabilities, local.capabilities);
1060
+ const capabilities = {
1061
+ shared,
1062
+ missingOnRemote,
1063
+ missingOnLocal
1064
+ };
1065
+ if (!rangesOverlap(localRange, remoteRange)) {
1066
+ return {
1067
+ outcome: "incompatible",
1068
+ negotiatedProtocol: null,
1069
+ protocol: {
1070
+ localRange,
1071
+ remoteRange
1072
+ },
1073
+ capabilities,
1074
+ reason: {
1075
+ code: "protocol.incompatible",
1076
+ message: `no common protocol major: local speaks [${localRange[0]}, ${localRange[1]}], remote speaks [${remoteRange[0]}, ${remoteRange[1]}]`,
1077
+ detail: {
1078
+ localRange,
1079
+ remoteRange
1080
+ }
1081
+ },
1082
+ local,
1083
+ remote
1084
+ };
1085
+ }
1086
+ const negotiatedProtocol = Math.min(localRange[1], remoteRange[1]);
1087
+ const required = opts.required ?? [];
1088
+ const missingRequired = difference(required, remote.capabilities);
1089
+ if (missingRequired.length > 0) {
1090
+ return {
1091
+ outcome: "degraded",
1092
+ negotiatedProtocol,
1093
+ protocol: {
1094
+ localRange,
1095
+ remoteRange
1096
+ },
1097
+ capabilities,
1098
+ reason: {
1099
+ code: "capability.unavailable",
1100
+ message: `remote is missing required capability(ies): ${missingRequired.join(", ")}`,
1101
+ detail: {
1102
+ missingRequired,
1103
+ missingOnRemote,
1104
+ missingOnLocal
1105
+ }
1106
+ },
1107
+ local,
1108
+ remote
1109
+ };
1110
+ }
1111
+ if (missingOnRemote.length > 0 || missingOnLocal.length > 0) {
1112
+ const parts = [];
1113
+ if (missingOnRemote.length > 0) parts.push(`remote lacks ${missingOnRemote.join(", ")}`);
1114
+ if (missingOnLocal.length > 0) parts.push(`local lacks ${missingOnLocal.join(", ")}`);
1115
+ return {
1116
+ outcome: "degraded",
1117
+ negotiatedProtocol,
1118
+ protocol: {
1119
+ localRange,
1120
+ remoteRange
1121
+ },
1122
+ capabilities,
1123
+ reason: {
1124
+ code: "capability.unavailable",
1125
+ message: `capability mismatch: ${parts.join("; ")}`,
1126
+ detail: {
1127
+ missingOnRemote,
1128
+ missingOnLocal
1129
+ }
1130
+ },
1131
+ local,
1132
+ remote
1133
+ };
1134
+ }
1135
+ return {
1136
+ outcome: "compatible",
1137
+ negotiatedProtocol,
1138
+ protocol: {
1139
+ localRange,
1140
+ remoteRange
1141
+ },
1142
+ capabilities,
1143
+ local,
1144
+ remote
1145
+ };
1146
+ }
1147
+ __name(negotiate, "negotiate");
1148
+
1149
+ // src/handshake/routing.ts
1150
+ function requiredCapabilities(handler) {
1151
+ const list = Array.isArray(handler) ? handler : handler.requires ?? [];
1152
+ return [
1153
+ ...new Set(list)
1154
+ ];
1155
+ }
1156
+ __name(requiredCapabilities, "requiredCapabilities");
1157
+ function canRoute(handler, worker) {
1158
+ const required = requiredCapabilities(handler);
1159
+ if (required.length === 0) return true;
1160
+ const advertised = new Set(normalizeDescriptor(worker).capabilities);
1161
+ return required.every((cap) => advertised.has(cap));
1162
+ }
1163
+ __name(canRoute, "canRoute");
1164
+ function missingCapabilities(handler, worker) {
1165
+ const advertised = new Set(normalizeDescriptor(worker).capabilities);
1166
+ return requiredCapabilities(handler).filter((cap) => !advertised.has(cap));
1167
+ }
1168
+ __name(missingCapabilities, "missingCapabilities");
1169
+ function capableWorkers(handler, workers) {
1170
+ return workers.filter((w) => canRoute(handler, w));
1171
+ }
1172
+ __name(capableWorkers, "capableWorkers");
1173
+ function resolveRouting(handler, workers) {
1174
+ const requires = requiredCapabilities(handler);
1175
+ const capable = capableWorkers(handler, workers).map(normalizeDescriptor);
1176
+ if (capable.length > 0) {
1177
+ return {
1178
+ status: "routable",
1179
+ workers: capable,
1180
+ requires
1181
+ };
1182
+ }
1183
+ const suffix = requires.length > 0 ? ` (requires ${requires.join(", ")})` : "";
1184
+ return {
1185
+ status: "blocked",
1186
+ reason: `blocked: no compatible worker${suffix}`,
1187
+ requires
1188
+ };
1189
+ }
1190
+ __name(resolveRouting, "resolveRouting");
1191
+
1192
+ // src/handshake/dispatch-routing.ts
1193
+ function controlPlaneDescriptor(opts) {
1194
+ const version = CURRENT_PROTOCOL_VERSION;
1195
+ return {
1196
+ instanceId: opts.instanceId,
1197
+ runtime: "node",
1198
+ sdk: opts.sdk ?? {
1199
+ name: "@dudousxd/nestjs-durable-core",
1200
+ version: String(version)
1201
+ },
1202
+ protocol: opts.protocol ?? {
1203
+ version,
1204
+ range: [
1205
+ version,
1206
+ version
1207
+ ]
1208
+ },
1209
+ capabilities: opts.capabilities ?? [],
1210
+ workflows: [],
1211
+ steps: [],
1212
+ ...opts.partition !== void 0 ? {
1213
+ partition: opts.partition
1214
+ } : {},
1215
+ ...opts.namespace !== void 0 ? {
1216
+ namespace: opts.namespace
1217
+ } : {},
1218
+ startedAt: opts.startedAt ?? 0
1219
+ };
1220
+ }
1221
+ __name(controlPlaneDescriptor, "controlPlaneDescriptor");
1222
+ var WorkflowBlocked = class extends Error {
1223
+ static {
1224
+ __name(this, "WorkflowBlocked");
1225
+ }
1226
+ plan;
1227
+ constructor(plan) {
1228
+ super(plan.reason), this.plan = plan;
1229
+ this.name = "WorkflowBlocked";
1230
+ }
1231
+ };
1232
+ function planDispatch(requires, liveDescriptors, cp, token) {
1233
+ const required = requiredCapabilities({
1234
+ requires
1235
+ });
1236
+ const workers = liveDescriptors.map(normalizeDescriptor);
1237
+ const capable = workers.filter((w) => canRoute({
1238
+ requires
1239
+ }, w));
1240
+ const compatible = capable.filter((w) => negotiate(cp, w).outcome !== "incompatible");
1241
+ if (compatible.length > 0) return {
1242
+ status: "routable",
1243
+ workers: compatible
1244
+ };
1245
+ if (capable.length > 0) {
1246
+ const workerRanges = capable.map((w) => w.protocol.range);
1247
+ return {
1248
+ status: "blocked",
1249
+ code: "protocol.incompatible",
1250
+ reason: `blocked: no protocol-compatible worker${required.length > 0 ? ` (requires ${required.join(", ")})` : ""}`,
1251
+ requires: required,
1252
+ diagnostics: {
1253
+ code: "protocol.incompatible",
1254
+ token,
1255
+ requires: required,
1256
+ liveWorkers: workers.length,
1257
+ controlPlaneRange: cp.protocol.range,
1258
+ workerRanges,
1259
+ controlPlane: cp,
1260
+ workers
1261
+ }
1262
+ };
1263
+ }
1264
+ const advertised = new Set(workers.flatMap((w) => w.capabilities));
1265
+ const missingCapabilities2 = required.filter((cap) => !advertised.has(cap));
1266
+ return {
1267
+ status: "blocked",
1268
+ code: "capability.unavailable",
1269
+ reason: `blocked: no compatible worker${required.length > 0 ? ` (requires ${required.join(", ")})` : ""}`,
1270
+ requires: required,
1271
+ diagnostics: {
1272
+ code: "capability.unavailable",
1273
+ token,
1274
+ requires: required,
1275
+ liveWorkers: workers.length,
1276
+ missingCapabilities: missingCapabilities2,
1277
+ controlPlane: cp,
1278
+ workers
1279
+ }
1280
+ };
1281
+ }
1282
+ __name(planDispatch, "planDispatch");
1283
+
916
1284
  // src/tenant-group.ts
917
1285
  function tenantGroup(baseGroup, tenant) {
918
1286
  return tenant !== void 0 && tenant !== "" && tenant !== "default" ? `${baseGroup}@${tenant}` : baseGroup;
@@ -1177,6 +1545,22 @@ var TransportPool = class {
1177
1545
  transport.onStartRun?.(handler);
1178
1546
  }
1179
1547
  }
1548
+ /**
1549
+ * The live handshake descriptors advertised for `group` (design §7.2), merged across every transport
1550
+ * that can report them. Returns `[]` when NO transport implements
1551
+ * {@link Transport.readWorkerDescriptors} (a pure in-process pool) OR when descriptors are supported
1552
+ * but none is published — both are the "guard disengaged / legacy assume-compatible" case, so the
1553
+ * engine's routing guard treats an empty result identically (design §7.7).
1554
+ */
1555
+ async readWorkerDescriptors(group) {
1556
+ const descriptors = [];
1557
+ for (const { transport } of this.transports) {
1558
+ if (transport.readWorkerDescriptors) {
1559
+ descriptors.push(...await transport.readWorkerDescriptors(group));
1560
+ }
1561
+ }
1562
+ return descriptors;
1563
+ }
1180
1564
  /** Distinct worker groups with a live heartbeat, merged across every transport that can report it. */
1181
1565
  async listWorkerGroups() {
1182
1566
  const groups = /* @__PURE__ */ new Set();
@@ -1272,6 +1656,8 @@ function resolveStepPolicy(config, opts) {
1272
1656
  if (jitter !== void 0) policy.jitter = jitter;
1273
1657
  const timeoutMs = opts?.timeoutMs ?? config?.timeoutMs;
1274
1658
  if (timeoutMs !== void 0) policy.timeoutMs = timeoutMs;
1659
+ const requires = opts?.requires ?? config?.requires;
1660
+ if (requires !== void 0) policy.requires = requires;
1275
1661
  return policy;
1276
1662
  }
1277
1663
  __name(resolveStepPolicy, "resolveStepPolicy");
@@ -1977,6 +2363,10 @@ var WorkflowEngine = class {
1977
2363
  eventBufferTtlMs;
1978
2364
  remoteRedispatchMs;
1979
2365
  remoteRedispatchMax;
2366
+ /** How long a `blocked` run waits before the recovery poll re-checks the live fleet (design §7.5). */
2367
+ blockedPollMs;
2368
+ /** This engine's control-plane handshake descriptor — negotiated against each worker (design §7.3). */
2369
+ cpDescriptor;
1980
2370
  webhookUrl;
1981
2371
  traceparent;
1982
2372
  context;
@@ -2048,6 +2438,13 @@ var WorkflowEngine = class {
2048
2438
  this.eventBufferTtlMs = deps.eventBufferTtlMs || void 0;
2049
2439
  this.remoteRedispatchMs = deps.remoteRedispatchMs || void 0;
2050
2440
  this.remoteRedispatchMax = Math.max(1, deps.remoteRedispatchMax ?? 10);
2441
+ this.blockedPollMs = deps.blockedPollMs ?? 5e3;
2442
+ this.cpDescriptor = controlPlaneDescriptor({
2443
+ instanceId: `cp-${this.instanceId}`,
2444
+ ...this.namespace !== void 0 ? {
2445
+ namespace: this.namespace
2446
+ } : {}
2447
+ });
2051
2448
  this.webhookUrl = deps.webhookUrl;
2052
2449
  this.traceparent = deps.traceparent;
2053
2450
  this.context = deps.context;
@@ -2201,6 +2598,7 @@ var WorkflowEngine = class {
2201
2598
  searchAttributesSchema: opts?.searchAttributesSchema,
2202
2599
  onEvent: opts?.onEvent,
2203
2600
  eventBatch: opts?.eventBatch,
2601
+ requires: opts?.requires,
2204
2602
  // A real, retained in-process body — so `workflowBody` can hand it to an in-app worker, and so
2205
2603
  // it is NOT confused with the throwing stub `registerRemote` installs for a body-less remote.
2206
2604
  hasBody: true
@@ -2249,6 +2647,7 @@ var WorkflowEngine = class {
2249
2647
  singleton: opts.singleton,
2250
2648
  executionTimeoutMs: opts.executionTimeout != null ? parseDuration(opts.executionTimeout) : void 0,
2251
2649
  validateInput: opts.validateInput,
2650
+ requires: opts.requires,
2252
2651
  remote: {
2253
2652
  group: opts.group,
2254
2653
  executor: opts.executor
@@ -2732,9 +3131,13 @@ var WorkflowEngine = class {
2732
3131
  */
2733
3132
  async resumeDueTimers(nowMs = this.clock()) {
2734
3133
  const due = await this.store.listDueTimers(nowMs, this.namespace);
2735
- if (due.length === 0) return [];
3134
+ const blocked = await this.dueBlockedRuns(nowMs);
3135
+ if (due.length === 0 && blocked.length === 0) return [];
2736
3136
  const waiterByRun = indexWaitersByRun(await this.store.listSignalWaiters(""));
2737
- return this.resumeLeased(due, nowMs, (run) => this.reconcileSignalWaiter(run, waiterByRun.get(run.id)));
3137
+ return this.resumeLeased([
3138
+ ...due,
3139
+ ...blocked
3140
+ ], nowMs, (run) => this.reconcileSignalWaiter(run, waiterByRun.get(run.id)));
2738
3141
  }
2739
3142
  /**
2740
3143
  * The reconcile safety net's per-run check (see {@link resumeDueTimers}): if this suspended run has
@@ -2817,7 +3220,10 @@ var WorkflowEngine = class {
2817
3220
  * inline — or null if the run is unknown. The dashboard "retry" goes through here so it can't block
2818
3221
  * the HTTP request on workflow execution.
2819
3222
  */
2820
- async requeue(runId) {
3223
+ async requeue(runId, _seen) {
3224
+ const seen = _seen ?? /* @__PURE__ */ new Set();
3225
+ if (seen.has(runId)) return null;
3226
+ seen.add(runId);
2821
3227
  const run = await this.store.getRun(runId);
2822
3228
  if (!run) return null;
2823
3229
  if (run.status === "failed" || run.status === "dead") {
@@ -2841,12 +3247,28 @@ var WorkflowEngine = class {
2841
3247
  status: "running",
2842
3248
  output: void 0
2843
3249
  });
3250
+ const childId = cp.name.slice("signal:child:".length);
3251
+ const parked = [];
3252
+ for (; ; ) {
3253
+ const buffered = await this.store.takeBufferedSignal(`child:${childId}`);
3254
+ if (!buffered) break;
3255
+ parked.push(buffered.payload);
3256
+ }
3257
+ for (const payload of parked) await this.store.bufferSignal(`child:${childId}`, payload);
3258
+ const bufferedSuccess = parked.some((payload) => typeof payload === "object" && payload !== null && payload.ok === true);
3259
+ if (!bufferedSuccess) {
3260
+ const child = await this.store.getRun(childId);
3261
+ if (child && (child.status === "failed" || child.status === "dead")) {
3262
+ await this.requeue(childId, seen);
3263
+ }
3264
+ }
2844
3265
  }
2845
3266
  }
2846
3267
  }
2847
3268
  await this.store.releaseRunLock(runId);
2848
3269
  await this.store.updateRun(runId, {
2849
3270
  status: "pending",
3271
+ error: void 0,
2850
3272
  updatedAt: /* @__PURE__ */ new Date()
2851
3273
  });
2852
3274
  await this.runDispatcher.dispatch(runId);
@@ -3166,6 +3588,10 @@ var WorkflowEngine = class {
3166
3588
  */
3167
3589
  notifyParent(runId, completion) {
3168
3590
  void this.signal(`child:${runId}`, completion).catch(() => void 0);
3591
+ const at = runId.lastIndexOf("~retry~");
3592
+ if (at !== -1 && completion.ok) {
3593
+ void this.signal(`child:${runId.slice(0, at)}`, completion).catch(() => void 0);
3594
+ }
3169
3595
  }
3170
3596
  /**
3171
3597
  * Deferred child start shared by the in-process ctx host and the remote `startChild` command.
@@ -3620,6 +4046,7 @@ var WorkflowEngine = class {
3620
4046
  error: outcome.error
3621
4047
  };
3622
4048
  }
4049
+ if (outcome.kind === "blocked") return this.parkBlocked(run, outcome.blocked);
3623
4050
  const latest = await this.store.getRun(run.id);
3624
4051
  if (latest?.status === "cancelled") {
3625
4052
  return {
@@ -3719,6 +4146,8 @@ var WorkflowEngine = class {
3719
4146
  status: "suspended"
3720
4147
  };
3721
4148
  }
4149
+ const wfPlan = await this.planRoute(tenantGroup(sanitizeQueueToken(remote.group), void 0), registered.requires);
4150
+ if (wfPlan?.status === "blocked") return this.parkBlocked(run, wfPlan);
3722
4151
  const history = await this.remoteHistory(run.id);
3723
4152
  if (remote.executor.dispatch) {
3724
4153
  const taskId = `${run.id}:wf:${globalThis.crypto.randomUUID().slice(0, 8)}`;
@@ -4142,6 +4571,15 @@ var WorkflowEngine = class {
4142
4571
  status: "completed"
4143
4572
  };
4144
4573
  }
4574
+ if (err instanceof WorkflowBlocked) {
4575
+ if (this.cancelRequested.has(run.id)) {
4576
+ return this.compensateAndCancel(run, compensations, replay);
4577
+ }
4578
+ return this.settleRun(run, {
4579
+ kind: "blocked",
4580
+ blocked: err.plan
4581
+ });
4582
+ }
4145
4583
  if (err instanceof WorkflowSuspended) {
4146
4584
  if (this.cancelRequested.has(run.id)) {
4147
4585
  return this.compensateAndCancel(run, compensations, replay);
@@ -4488,6 +4926,85 @@ var WorkflowEngine = class {
4488
4926
  run: result
4489
4927
  };
4490
4928
  }
4929
+ /**
4930
+ * Plan a dispatch to `token` for a handler requiring `requires` (handshake design §7.5). Returns:
4931
+ * - `undefined` — NO live descriptors published on this token: the guard does not engage, so a
4932
+ * pre-handshake fleet (or an in-process transport that advertises none) keeps flowing exactly as
4933
+ * before (design §7.7);
4934
+ * - a `routable` plan — ≥1 live worker is capability-capable AND protocol-compatible (dispatch);
4935
+ * - a `blocked` plan — descriptors exist but none can run it (park the run, never dispatch into it).
4936
+ */
4937
+ async planRoute(token, requires) {
4938
+ const descriptors = await this.pool.readWorkerDescriptors(token);
4939
+ if (descriptors.length === 0) return void 0;
4940
+ return planDispatch(requires ?? [], descriptors, this.cpDescriptor, token);
4941
+ }
4942
+ /** Throw {@link WorkflowBlocked} when no live worker can run a dispatch to `token` — so the caller's
4943
+ * run-execution catch parks the run `blocked` (with the plan) instead of persisting a `pending`
4944
+ * checkpoint + dispatching into a queue nobody consumes (design §7.5). A no-op when the guard does
4945
+ * not engage (no descriptors) or a capable+compatible worker exists. */
4946
+ async ensureRoutable(token, requires) {
4947
+ const plan = await this.planRoute(token, requires);
4948
+ if (plan?.status === "blocked") throw new WorkflowBlocked(plan);
4949
+ }
4950
+ /**
4951
+ * Park a run `blocked` (handshake design §7.5/§7.6): no live worker on its next-dispatch group is
4952
+ * capability-capable + protocol-compatible. Persists the `blocked` status with a human reason + the
4953
+ * machine `code` + a `wakeAt` so the blocked-recovery poll re-drives it, emits the LOUD structured
4954
+ * `capability.unavailable`/`protocol.incompatible` diagnostics (both descriptors + the precise delta),
4955
+ * and releases the run lease so any instance can re-drive it. Re-checks the persisted status first so
4956
+ * a concurrent cancel/complete is never clobbered. `releaseRunLock` is idempotent, so a caller whose
4957
+ * own `finally` also releases (the run-execution path) is safe.
4958
+ */
4959
+ async parkBlocked(run, blocked) {
4960
+ const current = await this.store.getRun(run.id);
4961
+ if (current && (current.status === "cancelled" || current.status === "completed")) {
4962
+ await this.store.releaseRunLock(run.id);
4963
+ return {
4964
+ runId: run.id,
4965
+ status: current.status,
4966
+ output: current.output,
4967
+ error: current.error
4968
+ };
4969
+ }
4970
+ const error = {
4971
+ message: blocked.reason,
4972
+ code: blocked.code,
4973
+ retryable: true
4974
+ };
4975
+ await this.store.updateRun(run.id, {
4976
+ status: "blocked",
4977
+ error,
4978
+ wakeAt: this.clock() + this.blockedPollMs,
4979
+ updatedAt: /* @__PURE__ */ new Date()
4980
+ });
4981
+ this.emit({
4982
+ type: "run.blocked",
4983
+ runId: run.id,
4984
+ workflow: run.workflow,
4985
+ namespace: run.namespace,
4986
+ error,
4987
+ requires: blocked.requires,
4988
+ diagnostics: blocked.diagnostics
4989
+ });
4990
+ await this.store.releaseRunLock(run.id);
4991
+ return {
4992
+ runId: run.id,
4993
+ status: "blocked",
4994
+ error
4995
+ };
4996
+ }
4997
+ /** Runs parked `blocked` whose recovery poll is due (`wakeAt <= nowMs`), namespace-scoped like
4998
+ * {@link resumeDueTimers}. Re-driving one re-checks the live fleet: it dispatches if a
4999
+ * capable+compatible worker has appeared, else re-parks with a fresh `wakeAt` (design §7.5). */
5000
+ async dueBlockedRuns(nowMs) {
5001
+ const blocked = await this.store.listRuns({
5002
+ statuses: [
5003
+ "blocked"
5004
+ ]
5005
+ });
5006
+ return blocked.filter((r) => (this.namespace === void 0 || r.namespace === void 0 || r.namespace === this.namespace) && r.wakeAt !== void 0 && r.wakeAt <= nowMs);
5007
+ }
4491
5008
  async callRemote(runId, seq, step, input, queue, transport, replay, admission) {
4492
5009
  const existing = replay?.get(seq) ?? await this.store.getCheckpoint(runId, seq);
4493
5010
  if (existing && existing.name !== step.name) {
@@ -4553,6 +5070,7 @@ var WorkflowEngine = class {
4553
5070
  if (this.clock() < existing.wakeAt) throw new WorkflowSuspended(existing.wakeAt);
4554
5071
  attempt = existing.attempts + 1;
4555
5072
  }
5073
+ await this.ensureRoutable(tenantGroup(sanitizeQueueToken(step.name), step.partition), step.requires);
4556
5074
  const id = stepId(runId, seq);
4557
5075
  if (queue && this.admission.handles(queue)) {
4558
5076
  const decision = await this.admission.tryAdmit(queue, {
@@ -4739,6 +5257,7 @@ var WorkflowEngine = class {
4739
5257
  /** In-memory await path for a remote step with a liveness `timeoutMs` (re-dispatch on timeout). */
4740
5258
  async callRemoteInMemory(runId, seq, step, input, transport) {
4741
5259
  if (this.pool.size === 0) throw new Error("remote steps require a Transport");
5260
+ await this.ensureRoutable(tenantGroup(sanitizeQueueToken(step.name), step.partition), step.requires);
4742
5261
  const validInput = step.input ? step.input.parse(input) : input;
4743
5262
  const id = stepId(runId, seq);
4744
5263
  const enqueuedAt = /* @__PURE__ */ new Date();
@@ -5638,6 +6157,7 @@ var InMemoryTransport = class {
5638
6157
  // Annotate the CommonJS export names for ESM import in node:
5639
6158
  0 && (module.exports = {
5640
6159
  CONTROL_FLOW_SIGNAL,
6160
+ CURRENT_PROTOCOL_VERSION,
5641
6161
  CodecStateStore,
5642
6162
  ContinueAsNew,
5643
6163
  DURABLE_OPTIONS,
@@ -5654,6 +6174,8 @@ var InMemoryTransport = class {
5654
6174
  InMemoryAdmissionBackend,
5655
6175
  InMemoryStateStore,
5656
6176
  InMemoryTransport,
6177
+ LEGACY_V1_CAPABILITIES,
6178
+ LEGACY_V1_PROTOCOL,
5657
6179
  NonDeterminismError,
5658
6180
  QueueController,
5659
6181
  RemoteStepError,
@@ -5668,6 +6190,7 @@ var InMemoryTransport = class {
5668
6190
  TRANSPORT,
5669
6191
  TRANSPORT_CANONICAL,
5670
6192
  WORKFLOW_NAME_KEY,
6193
+ WorkflowBlocked,
5671
6194
  WorkflowEngine,
5672
6195
  WorkflowSuspended,
5673
6196
  applyAttributeQuery,
@@ -5675,23 +6198,35 @@ var InMemoryTransport = class {
5675
6198
  attributeOperand,
5676
6199
  bindWorkflowClass,
5677
6200
  breakpointToken,
6201
+ canRoute,
6202
+ capableWorkers,
5678
6203
  classifyWaiterToken,
5679
6204
  collectMetrics,
6205
+ controlPlaneDescriptor,
5680
6206
  createStepLogger,
5681
6207
  currentWorkflowCtx,
6208
+ descriptorHash,
5682
6209
  eventMatchOf,
5683
6210
  eventMatches,
5684
6211
  eventNameOf,
5685
6212
  eventPrefix,
5686
6213
  eventToken,
6214
+ heartbeatStatus,
5687
6215
  indexWaitersByRun,
5688
6216
  isEventToken,
6217
+ isLegacyDescriptor,
5689
6218
  isWorkflowControlFlowSignal,
5690
6219
  matchesAttributes,
6220
+ missingCapabilities,
6221
+ negotiate,
5691
6222
  normalizeAttributeRows,
6223
+ normalizeDescriptor,
5692
6224
  parseDuration,
6225
+ planDispatch,
5693
6226
  prevCronFireMs,
5694
6227
  readSearchAttributes,
6228
+ requiredCapabilities,
6229
+ resolveRouting,
5695
6230
  resolveRunWaiting,
5696
6231
  runInWorkflowCtx,
5697
6232
  runSchedules,