@dudousxd/nestjs-durable-core 0.58.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
@@ -3643,6 +4046,7 @@ var WorkflowEngine = class {
3643
4046
  error: outcome.error
3644
4047
  };
3645
4048
  }
4049
+ if (outcome.kind === "blocked") return this.parkBlocked(run, outcome.blocked);
3646
4050
  const latest = await this.store.getRun(run.id);
3647
4051
  if (latest?.status === "cancelled") {
3648
4052
  return {
@@ -3742,6 +4146,8 @@ var WorkflowEngine = class {
3742
4146
  status: "suspended"
3743
4147
  };
3744
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);
3745
4151
  const history = await this.remoteHistory(run.id);
3746
4152
  if (remote.executor.dispatch) {
3747
4153
  const taskId = `${run.id}:wf:${globalThis.crypto.randomUUID().slice(0, 8)}`;
@@ -4165,6 +4571,15 @@ var WorkflowEngine = class {
4165
4571
  status: "completed"
4166
4572
  };
4167
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
+ }
4168
4583
  if (err instanceof WorkflowSuspended) {
4169
4584
  if (this.cancelRequested.has(run.id)) {
4170
4585
  return this.compensateAndCancel(run, compensations, replay);
@@ -4511,6 +4926,85 @@ var WorkflowEngine = class {
4511
4926
  run: result
4512
4927
  };
4513
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
+ }
4514
5008
  async callRemote(runId, seq, step, input, queue, transport, replay, admission) {
4515
5009
  const existing = replay?.get(seq) ?? await this.store.getCheckpoint(runId, seq);
4516
5010
  if (existing && existing.name !== step.name) {
@@ -4576,6 +5070,7 @@ var WorkflowEngine = class {
4576
5070
  if (this.clock() < existing.wakeAt) throw new WorkflowSuspended(existing.wakeAt);
4577
5071
  attempt = existing.attempts + 1;
4578
5072
  }
5073
+ await this.ensureRoutable(tenantGroup(sanitizeQueueToken(step.name), step.partition), step.requires);
4579
5074
  const id = stepId(runId, seq);
4580
5075
  if (queue && this.admission.handles(queue)) {
4581
5076
  const decision = await this.admission.tryAdmit(queue, {
@@ -4762,6 +5257,7 @@ var WorkflowEngine = class {
4762
5257
  /** In-memory await path for a remote step with a liveness `timeoutMs` (re-dispatch on timeout). */
4763
5258
  async callRemoteInMemory(runId, seq, step, input, transport) {
4764
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);
4765
5261
  const validInput = step.input ? step.input.parse(input) : input;
4766
5262
  const id = stepId(runId, seq);
4767
5263
  const enqueuedAt = /* @__PURE__ */ new Date();
@@ -5661,6 +6157,7 @@ var InMemoryTransport = class {
5661
6157
  // Annotate the CommonJS export names for ESM import in node:
5662
6158
  0 && (module.exports = {
5663
6159
  CONTROL_FLOW_SIGNAL,
6160
+ CURRENT_PROTOCOL_VERSION,
5664
6161
  CodecStateStore,
5665
6162
  ContinueAsNew,
5666
6163
  DURABLE_OPTIONS,
@@ -5677,6 +6174,8 @@ var InMemoryTransport = class {
5677
6174
  InMemoryAdmissionBackend,
5678
6175
  InMemoryStateStore,
5679
6176
  InMemoryTransport,
6177
+ LEGACY_V1_CAPABILITIES,
6178
+ LEGACY_V1_PROTOCOL,
5680
6179
  NonDeterminismError,
5681
6180
  QueueController,
5682
6181
  RemoteStepError,
@@ -5691,6 +6190,7 @@ var InMemoryTransport = class {
5691
6190
  TRANSPORT,
5692
6191
  TRANSPORT_CANONICAL,
5693
6192
  WORKFLOW_NAME_KEY,
6193
+ WorkflowBlocked,
5694
6194
  WorkflowEngine,
5695
6195
  WorkflowSuspended,
5696
6196
  applyAttributeQuery,
@@ -5698,23 +6198,35 @@ var InMemoryTransport = class {
5698
6198
  attributeOperand,
5699
6199
  bindWorkflowClass,
5700
6200
  breakpointToken,
6201
+ canRoute,
6202
+ capableWorkers,
5701
6203
  classifyWaiterToken,
5702
6204
  collectMetrics,
6205
+ controlPlaneDescriptor,
5703
6206
  createStepLogger,
5704
6207
  currentWorkflowCtx,
6208
+ descriptorHash,
5705
6209
  eventMatchOf,
5706
6210
  eventMatches,
5707
6211
  eventNameOf,
5708
6212
  eventPrefix,
5709
6213
  eventToken,
6214
+ heartbeatStatus,
5710
6215
  indexWaitersByRun,
5711
6216
  isEventToken,
6217
+ isLegacyDescriptor,
5712
6218
  isWorkflowControlFlowSignal,
5713
6219
  matchesAttributes,
6220
+ missingCapabilities,
6221
+ negotiate,
5714
6222
  normalizeAttributeRows,
6223
+ normalizeDescriptor,
5715
6224
  parseDuration,
6225
+ planDispatch,
5716
6226
  prevCronFireMs,
5717
6227
  readSearchAttributes,
6228
+ requiredCapabilities,
6229
+ resolveRouting,
5718
6230
  resolveRunWaiting,
5719
6231
  runInWorkflowCtx,
5720
6232
  runSchedules,