@dudousxd/nestjs-durable-core 0.58.0 → 0.60.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,12 +40,15 @@ __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,
45
48
  RemoteStepTimeout: () => RemoteStepTimeout,
46
49
  RemoteWorkflowExecutor: () => RemoteWorkflowExecutor,
47
50
  RemoteWorkflowTimeout: () => RemoteWorkflowTimeout,
51
+ RunGateway: () => RunGateway,
48
52
  STATE_STORE: () => STATE_STORE,
49
53
  STATE_STORE_CANONICAL: () => STATE_STORE_CANONICAL,
50
54
  SignalTimeoutError: () => SignalTimeoutError,
@@ -53,6 +57,7 @@ __export(index_exports, {
53
57
  TRANSPORT: () => TRANSPORT,
54
58
  TRANSPORT_CANONICAL: () => TRANSPORT_CANONICAL,
55
59
  WORKFLOW_NAME_KEY: () => WORKFLOW_NAME_KEY,
60
+ WorkflowBlocked: () => WorkflowBlocked,
56
61
  WorkflowEngine: () => WorkflowEngine,
57
62
  WorkflowSuspended: () => WorkflowSuspended,
58
63
  applyAttributeQuery: () => applyAttributeQuery,
@@ -60,23 +65,35 @@ __export(index_exports, {
60
65
  attributeOperand: () => attributeOperand,
61
66
  bindWorkflowClass: () => bindWorkflowClass,
62
67
  breakpointToken: () => breakpointToken,
68
+ canRoute: () => canRoute,
69
+ capableWorkers: () => capableWorkers,
63
70
  classifyWaiterToken: () => classifyWaiterToken,
64
71
  collectMetrics: () => collectMetrics,
72
+ controlPlaneDescriptor: () => controlPlaneDescriptor,
65
73
  createStepLogger: () => createStepLogger,
66
74
  currentWorkflowCtx: () => currentWorkflowCtx,
75
+ descriptorHash: () => descriptorHash,
67
76
  eventMatchOf: () => eventMatchOf,
68
77
  eventMatches: () => eventMatches,
69
78
  eventNameOf: () => eventNameOf,
70
79
  eventPrefix: () => eventPrefix,
71
80
  eventToken: () => eventToken,
81
+ heartbeatStatus: () => heartbeatStatus,
72
82
  indexWaitersByRun: () => indexWaitersByRun,
73
83
  isEventToken: () => isEventToken,
84
+ isLegacyDescriptor: () => isLegacyDescriptor,
74
85
  isWorkflowControlFlowSignal: () => isWorkflowControlFlowSignal,
75
86
  matchesAttributes: () => matchesAttributes,
87
+ missingCapabilities: () => missingCapabilities,
88
+ negotiate: () => negotiate,
76
89
  normalizeAttributeRows: () => normalizeAttributeRows,
90
+ normalizeDescriptor: () => normalizeDescriptor,
77
91
  parseDuration: () => parseDuration,
92
+ planDispatch: () => planDispatch,
78
93
  prevCronFireMs: () => prevCronFireMs,
79
94
  readSearchAttributes: () => readSearchAttributes,
95
+ requiredCapabilities: () => requiredCapabilities,
96
+ resolveRouting: () => resolveRouting,
80
97
  resolveRunWaiting: () => resolveRunWaiting,
81
98
  runInWorkflowCtx: () => runInWorkflowCtx,
82
99
  runSchedules: () => runSchedules,
@@ -913,6 +930,358 @@ function eventMatches(payload, match) {
913
930
  }
914
931
  __name(eventMatches, "eventMatches");
915
932
 
933
+ // src/handshake/descriptor.ts
934
+ var CURRENT_PROTOCOL_VERSION = 1;
935
+ var LEGACY_V1_PROTOCOL = {
936
+ version: 1,
937
+ range: [
938
+ 1,
939
+ 1
940
+ ]
941
+ };
942
+ var LEGACY_V1_CAPABILITIES = Object.freeze([
943
+ "saga",
944
+ "signals",
945
+ "search-attributes",
946
+ "priority",
947
+ "entities",
948
+ "child-workflows",
949
+ "singleton",
950
+ "schedules",
951
+ "continue-as-new",
952
+ "queries",
953
+ "cancellation"
954
+ ]);
955
+ function isLegacyDescriptor(raw) {
956
+ return raw.protocol === void 0;
957
+ }
958
+ __name(isLegacyDescriptor, "isLegacyDescriptor");
959
+ function normalizeDescriptor(raw) {
960
+ return {
961
+ instanceId: raw.instanceId,
962
+ runtime: raw.runtime,
963
+ sdk: raw.sdk ?? {
964
+ name: "unknown",
965
+ version: "0"
966
+ },
967
+ protocol: raw.protocol ?? {
968
+ ...LEGACY_V1_PROTOCOL
969
+ },
970
+ capabilities: raw.capabilities ?? [
971
+ ...LEGACY_V1_CAPABILITIES
972
+ ],
973
+ workflows: raw.workflows ?? [],
974
+ steps: raw.steps ?? [],
975
+ ...raw.partition !== void 0 ? {
976
+ partition: raw.partition
977
+ } : {},
978
+ ...raw.namespace !== void 0 ? {
979
+ namespace: raw.namespace
980
+ } : {},
981
+ startedAt: raw.startedAt ?? 0
982
+ };
983
+ }
984
+ __name(normalizeDescriptor, "normalizeDescriptor");
985
+ function canonicalizeForHash(d) {
986
+ const set = /* @__PURE__ */ __name((xs) => [
987
+ ...new Set(xs)
988
+ ].sort(), "set");
989
+ return {
990
+ instanceId: d.instanceId,
991
+ runtime: d.runtime,
992
+ sdk: {
993
+ name: d.sdk.name,
994
+ version: d.sdk.version
995
+ },
996
+ protocol: {
997
+ version: d.protocol.version,
998
+ range: [
999
+ d.protocol.range[0],
1000
+ d.protocol.range[1]
1001
+ ]
1002
+ },
1003
+ capabilities: set(d.capabilities),
1004
+ workflows: set(d.workflows),
1005
+ steps: set(d.steps),
1006
+ partition: d.partition ?? null,
1007
+ namespace: d.namespace ?? null,
1008
+ startedAt: d.startedAt
1009
+ };
1010
+ }
1011
+ __name(canonicalizeForHash, "canonicalizeForHash");
1012
+ function fnv1a64Hex(input) {
1013
+ const OFFSET = 0xcbf29ce484222325n;
1014
+ const PRIME = 0x100000001b3n;
1015
+ const MASK = 0xffffffffffffffffn;
1016
+ let hash = OFFSET;
1017
+ for (let i = 0; i < input.length; i += 1) {
1018
+ hash ^= BigInt(input.charCodeAt(i) & 255);
1019
+ hash ^= BigInt(input.charCodeAt(i) >> 8 & 255);
1020
+ hash = hash * PRIME & MASK;
1021
+ }
1022
+ return hash.toString(16).padStart(16, "0");
1023
+ }
1024
+ __name(fnv1a64Hex, "fnv1a64Hex");
1025
+ function descriptorHash(descriptor) {
1026
+ const canonical = canonicalizeForHash(normalizeDescriptor(descriptor));
1027
+ return fnv1a64Hex(JSON.stringify(canonical));
1028
+ }
1029
+ __name(descriptorHash, "descriptorHash");
1030
+ function heartbeatStatus(descriptor, opts = {}) {
1031
+ return {
1032
+ ts: opts.ts ?? Date.now(),
1033
+ status: opts.status ?? "up",
1034
+ descriptorHash: descriptorHash(descriptor)
1035
+ };
1036
+ }
1037
+ __name(heartbeatStatus, "heartbeatStatus");
1038
+
1039
+ // src/handshake/negotiate.ts
1040
+ var rangesOverlap = /* @__PURE__ */ __name((a, b) => Math.max(a[0], b[0]) <= Math.min(a[1], b[1]), "rangesOverlap");
1041
+ var difference = /* @__PURE__ */ __name((a, b) => {
1042
+ const bset = new Set(b);
1043
+ return [
1044
+ ...new Set(a)
1045
+ ].filter((x) => !bset.has(x)).sort();
1046
+ }, "difference");
1047
+ var intersection = /* @__PURE__ */ __name((a, b) => {
1048
+ const bset = new Set(b);
1049
+ return [
1050
+ ...new Set(a)
1051
+ ].filter((x) => bset.has(x)).sort();
1052
+ }, "intersection");
1053
+ function negotiate(localRaw, remoteRaw, opts = {}) {
1054
+ const local = normalizeDescriptor(localRaw);
1055
+ const remote = normalizeDescriptor(remoteRaw);
1056
+ const localRange = local.protocol.range;
1057
+ const remoteRange = remote.protocol.range;
1058
+ const shared = intersection(local.capabilities, remote.capabilities);
1059
+ const missingOnRemote = difference(local.capabilities, remote.capabilities);
1060
+ const missingOnLocal = difference(remote.capabilities, local.capabilities);
1061
+ const capabilities = {
1062
+ shared,
1063
+ missingOnRemote,
1064
+ missingOnLocal
1065
+ };
1066
+ if (!rangesOverlap(localRange, remoteRange)) {
1067
+ return {
1068
+ outcome: "incompatible",
1069
+ negotiatedProtocol: null,
1070
+ protocol: {
1071
+ localRange,
1072
+ remoteRange
1073
+ },
1074
+ capabilities,
1075
+ reason: {
1076
+ code: "protocol.incompatible",
1077
+ message: `no common protocol major: local speaks [${localRange[0]}, ${localRange[1]}], remote speaks [${remoteRange[0]}, ${remoteRange[1]}]`,
1078
+ detail: {
1079
+ localRange,
1080
+ remoteRange
1081
+ }
1082
+ },
1083
+ local,
1084
+ remote
1085
+ };
1086
+ }
1087
+ const negotiatedProtocol = Math.min(localRange[1], remoteRange[1]);
1088
+ const required = opts.required ?? [];
1089
+ const missingRequired = difference(required, remote.capabilities);
1090
+ if (missingRequired.length > 0) {
1091
+ return {
1092
+ outcome: "degraded",
1093
+ negotiatedProtocol,
1094
+ protocol: {
1095
+ localRange,
1096
+ remoteRange
1097
+ },
1098
+ capabilities,
1099
+ reason: {
1100
+ code: "capability.unavailable",
1101
+ message: `remote is missing required capability(ies): ${missingRequired.join(", ")}`,
1102
+ detail: {
1103
+ missingRequired,
1104
+ missingOnRemote,
1105
+ missingOnLocal
1106
+ }
1107
+ },
1108
+ local,
1109
+ remote
1110
+ };
1111
+ }
1112
+ if (missingOnRemote.length > 0 || missingOnLocal.length > 0) {
1113
+ const parts = [];
1114
+ if (missingOnRemote.length > 0) parts.push(`remote lacks ${missingOnRemote.join(", ")}`);
1115
+ if (missingOnLocal.length > 0) parts.push(`local lacks ${missingOnLocal.join(", ")}`);
1116
+ return {
1117
+ outcome: "degraded",
1118
+ negotiatedProtocol,
1119
+ protocol: {
1120
+ localRange,
1121
+ remoteRange
1122
+ },
1123
+ capabilities,
1124
+ reason: {
1125
+ code: "capability.unavailable",
1126
+ message: `capability mismatch: ${parts.join("; ")}`,
1127
+ detail: {
1128
+ missingOnRemote,
1129
+ missingOnLocal
1130
+ }
1131
+ },
1132
+ local,
1133
+ remote
1134
+ };
1135
+ }
1136
+ return {
1137
+ outcome: "compatible",
1138
+ negotiatedProtocol,
1139
+ protocol: {
1140
+ localRange,
1141
+ remoteRange
1142
+ },
1143
+ capabilities,
1144
+ local,
1145
+ remote
1146
+ };
1147
+ }
1148
+ __name(negotiate, "negotiate");
1149
+
1150
+ // src/handshake/routing.ts
1151
+ function requiredCapabilities(handler) {
1152
+ const list = Array.isArray(handler) ? handler : handler.requires ?? [];
1153
+ return [
1154
+ ...new Set(list)
1155
+ ];
1156
+ }
1157
+ __name(requiredCapabilities, "requiredCapabilities");
1158
+ function canRoute(handler, worker) {
1159
+ const required = requiredCapabilities(handler);
1160
+ if (required.length === 0) return true;
1161
+ const advertised = new Set(normalizeDescriptor(worker).capabilities);
1162
+ return required.every((cap) => advertised.has(cap));
1163
+ }
1164
+ __name(canRoute, "canRoute");
1165
+ function missingCapabilities(handler, worker) {
1166
+ const advertised = new Set(normalizeDescriptor(worker).capabilities);
1167
+ return requiredCapabilities(handler).filter((cap) => !advertised.has(cap));
1168
+ }
1169
+ __name(missingCapabilities, "missingCapabilities");
1170
+ function capableWorkers(handler, workers) {
1171
+ return workers.filter((w) => canRoute(handler, w));
1172
+ }
1173
+ __name(capableWorkers, "capableWorkers");
1174
+ function resolveRouting(handler, workers) {
1175
+ const requires = requiredCapabilities(handler);
1176
+ const capable = capableWorkers(handler, workers).map(normalizeDescriptor);
1177
+ if (capable.length > 0) {
1178
+ return {
1179
+ status: "routable",
1180
+ workers: capable,
1181
+ requires
1182
+ };
1183
+ }
1184
+ const suffix = requires.length > 0 ? ` (requires ${requires.join(", ")})` : "";
1185
+ return {
1186
+ status: "blocked",
1187
+ reason: `blocked: no compatible worker${suffix}`,
1188
+ requires
1189
+ };
1190
+ }
1191
+ __name(resolveRouting, "resolveRouting");
1192
+
1193
+ // src/handshake/dispatch-routing.ts
1194
+ function controlPlaneDescriptor(opts) {
1195
+ const version = CURRENT_PROTOCOL_VERSION;
1196
+ return {
1197
+ instanceId: opts.instanceId,
1198
+ runtime: "node",
1199
+ sdk: opts.sdk ?? {
1200
+ name: "@dudousxd/nestjs-durable-core",
1201
+ version: String(version)
1202
+ },
1203
+ protocol: opts.protocol ?? {
1204
+ version,
1205
+ range: [
1206
+ version,
1207
+ version
1208
+ ]
1209
+ },
1210
+ capabilities: opts.capabilities ?? [],
1211
+ workflows: [],
1212
+ steps: [],
1213
+ ...opts.partition !== void 0 ? {
1214
+ partition: opts.partition
1215
+ } : {},
1216
+ ...opts.namespace !== void 0 ? {
1217
+ namespace: opts.namespace
1218
+ } : {},
1219
+ startedAt: opts.startedAt ?? 0
1220
+ };
1221
+ }
1222
+ __name(controlPlaneDescriptor, "controlPlaneDescriptor");
1223
+ var WorkflowBlocked = class extends Error {
1224
+ static {
1225
+ __name(this, "WorkflowBlocked");
1226
+ }
1227
+ plan;
1228
+ constructor(plan) {
1229
+ super(plan.reason), this.plan = plan;
1230
+ this.name = "WorkflowBlocked";
1231
+ }
1232
+ };
1233
+ function planDispatch(requires, liveDescriptors, cp, token) {
1234
+ const required = requiredCapabilities({
1235
+ requires
1236
+ });
1237
+ const workers = liveDescriptors.map(normalizeDescriptor);
1238
+ const capable = workers.filter((w) => canRoute({
1239
+ requires
1240
+ }, w));
1241
+ const compatible = capable.filter((w) => negotiate(cp, w).outcome !== "incompatible");
1242
+ if (compatible.length > 0) return {
1243
+ status: "routable",
1244
+ workers: compatible
1245
+ };
1246
+ if (capable.length > 0) {
1247
+ const workerRanges = capable.map((w) => w.protocol.range);
1248
+ return {
1249
+ status: "blocked",
1250
+ code: "protocol.incompatible",
1251
+ reason: `blocked: no protocol-compatible worker${required.length > 0 ? ` (requires ${required.join(", ")})` : ""}`,
1252
+ requires: required,
1253
+ diagnostics: {
1254
+ code: "protocol.incompatible",
1255
+ token,
1256
+ requires: required,
1257
+ liveWorkers: workers.length,
1258
+ controlPlaneRange: cp.protocol.range,
1259
+ workerRanges,
1260
+ controlPlane: cp,
1261
+ workers
1262
+ }
1263
+ };
1264
+ }
1265
+ const advertised = new Set(workers.flatMap((w) => w.capabilities));
1266
+ const missingCapabilities2 = required.filter((cap) => !advertised.has(cap));
1267
+ return {
1268
+ status: "blocked",
1269
+ code: "capability.unavailable",
1270
+ reason: `blocked: no compatible worker${required.length > 0 ? ` (requires ${required.join(", ")})` : ""}`,
1271
+ requires: required,
1272
+ diagnostics: {
1273
+ code: "capability.unavailable",
1274
+ token,
1275
+ requires: required,
1276
+ liveWorkers: workers.length,
1277
+ missingCapabilities: missingCapabilities2,
1278
+ controlPlane: cp,
1279
+ workers
1280
+ }
1281
+ };
1282
+ }
1283
+ __name(planDispatch, "planDispatch");
1284
+
916
1285
  // src/tenant-group.ts
917
1286
  function tenantGroup(baseGroup, tenant) {
918
1287
  return tenant !== void 0 && tenant !== "" && tenant !== "default" ? `${baseGroup}@${tenant}` : baseGroup;
@@ -1177,6 +1546,22 @@ var TransportPool = class {
1177
1546
  transport.onStartRun?.(handler);
1178
1547
  }
1179
1548
  }
1549
+ /**
1550
+ * The live handshake descriptors advertised for `group` (design §7.2), merged across every transport
1551
+ * that can report them. Returns `[]` when NO transport implements
1552
+ * {@link Transport.readWorkerDescriptors} (a pure in-process pool) OR when descriptors are supported
1553
+ * but none is published — both are the "guard disengaged / legacy assume-compatible" case, so the
1554
+ * engine's routing guard treats an empty result identically (design §7.7).
1555
+ */
1556
+ async readWorkerDescriptors(group) {
1557
+ const descriptors = [];
1558
+ for (const { transport } of this.transports) {
1559
+ if (transport.readWorkerDescriptors) {
1560
+ descriptors.push(...await transport.readWorkerDescriptors(group));
1561
+ }
1562
+ }
1563
+ return descriptors;
1564
+ }
1180
1565
  /** Distinct worker groups with a live heartbeat, merged across every transport that can report it. */
1181
1566
  async listWorkerGroups() {
1182
1567
  const groups = /* @__PURE__ */ new Set();
@@ -1272,6 +1657,8 @@ function resolveStepPolicy(config, opts) {
1272
1657
  if (jitter !== void 0) policy.jitter = jitter;
1273
1658
  const timeoutMs = opts?.timeoutMs ?? config?.timeoutMs;
1274
1659
  if (timeoutMs !== void 0) policy.timeoutMs = timeoutMs;
1660
+ const requires = opts?.requires ?? config?.requires;
1661
+ if (requires !== void 0) policy.requires = requires;
1275
1662
  return policy;
1276
1663
  }
1277
1664
  __name(resolveStepPolicy, "resolveStepPolicy");
@@ -1977,6 +2364,10 @@ var WorkflowEngine = class {
1977
2364
  eventBufferTtlMs;
1978
2365
  remoteRedispatchMs;
1979
2366
  remoteRedispatchMax;
2367
+ /** How long a `blocked` run waits before the recovery poll re-checks the live fleet (design §7.5). */
2368
+ blockedPollMs;
2369
+ /** This engine's control-plane handshake descriptor — negotiated against each worker (design §7.3). */
2370
+ cpDescriptor;
1980
2371
  webhookUrl;
1981
2372
  traceparent;
1982
2373
  context;
@@ -2048,6 +2439,13 @@ var WorkflowEngine = class {
2048
2439
  this.eventBufferTtlMs = deps.eventBufferTtlMs || void 0;
2049
2440
  this.remoteRedispatchMs = deps.remoteRedispatchMs || void 0;
2050
2441
  this.remoteRedispatchMax = Math.max(1, deps.remoteRedispatchMax ?? 10);
2442
+ this.blockedPollMs = deps.blockedPollMs ?? 5e3;
2443
+ this.cpDescriptor = controlPlaneDescriptor({
2444
+ instanceId: `cp-${this.instanceId}`,
2445
+ ...this.namespace !== void 0 ? {
2446
+ namespace: this.namespace
2447
+ } : {}
2448
+ });
2051
2449
  this.webhookUrl = deps.webhookUrl;
2052
2450
  this.traceparent = deps.traceparent;
2053
2451
  this.context = deps.context;
@@ -2201,6 +2599,7 @@ var WorkflowEngine = class {
2201
2599
  searchAttributesSchema: opts?.searchAttributesSchema,
2202
2600
  onEvent: opts?.onEvent,
2203
2601
  eventBatch: opts?.eventBatch,
2602
+ requires: opts?.requires,
2204
2603
  // A real, retained in-process body — so `workflowBody` can hand it to an in-app worker, and so
2205
2604
  // it is NOT confused with the throwing stub `registerRemote` installs for a body-less remote.
2206
2605
  hasBody: true
@@ -2249,6 +2648,7 @@ var WorkflowEngine = class {
2249
2648
  singleton: opts.singleton,
2250
2649
  executionTimeoutMs: opts.executionTimeout != null ? parseDuration(opts.executionTimeout) : void 0,
2251
2650
  validateInput: opts.validateInput,
2651
+ requires: opts.requires,
2252
2652
  remote: {
2253
2653
  group: opts.group,
2254
2654
  executor: opts.executor
@@ -2732,9 +3132,13 @@ var WorkflowEngine = class {
2732
3132
  */
2733
3133
  async resumeDueTimers(nowMs = this.clock()) {
2734
3134
  const due = await this.store.listDueTimers(nowMs, this.namespace);
2735
- if (due.length === 0) return [];
3135
+ const blocked = await this.dueBlockedRuns(nowMs);
3136
+ if (due.length === 0 && blocked.length === 0) return [];
2736
3137
  const waiterByRun = indexWaitersByRun(await this.store.listSignalWaiters(""));
2737
- return this.resumeLeased(due, nowMs, (run) => this.reconcileSignalWaiter(run, waiterByRun.get(run.id)));
3138
+ return this.resumeLeased([
3139
+ ...due,
3140
+ ...blocked
3141
+ ], nowMs, (run) => this.reconcileSignalWaiter(run, waiterByRun.get(run.id)));
2738
3142
  }
2739
3143
  /**
2740
3144
  * The reconcile safety net's per-run check (see {@link resumeDueTimers}): if this suspended run has
@@ -3643,6 +4047,7 @@ var WorkflowEngine = class {
3643
4047
  error: outcome.error
3644
4048
  };
3645
4049
  }
4050
+ if (outcome.kind === "blocked") return this.parkBlocked(run, outcome.blocked);
3646
4051
  const latest = await this.store.getRun(run.id);
3647
4052
  if (latest?.status === "cancelled") {
3648
4053
  return {
@@ -3742,6 +4147,8 @@ var WorkflowEngine = class {
3742
4147
  status: "suspended"
3743
4148
  };
3744
4149
  }
4150
+ const wfPlan = await this.planRoute(tenantGroup(sanitizeQueueToken(remote.group), void 0), registered.requires);
4151
+ if (wfPlan?.status === "blocked") return this.parkBlocked(run, wfPlan);
3745
4152
  const history = await this.remoteHistory(run.id);
3746
4153
  if (remote.executor.dispatch) {
3747
4154
  const taskId = `${run.id}:wf:${globalThis.crypto.randomUUID().slice(0, 8)}`;
@@ -4165,6 +4572,15 @@ var WorkflowEngine = class {
4165
4572
  status: "completed"
4166
4573
  };
4167
4574
  }
4575
+ if (err instanceof WorkflowBlocked) {
4576
+ if (this.cancelRequested.has(run.id)) {
4577
+ return this.compensateAndCancel(run, compensations, replay);
4578
+ }
4579
+ return this.settleRun(run, {
4580
+ kind: "blocked",
4581
+ blocked: err.plan
4582
+ });
4583
+ }
4168
4584
  if (err instanceof WorkflowSuspended) {
4169
4585
  if (this.cancelRequested.has(run.id)) {
4170
4586
  return this.compensateAndCancel(run, compensations, replay);
@@ -4511,6 +4927,85 @@ var WorkflowEngine = class {
4511
4927
  run: result
4512
4928
  };
4513
4929
  }
4930
+ /**
4931
+ * Plan a dispatch to `token` for a handler requiring `requires` (handshake design §7.5). Returns:
4932
+ * - `undefined` — NO live descriptors published on this token: the guard does not engage, so a
4933
+ * pre-handshake fleet (or an in-process transport that advertises none) keeps flowing exactly as
4934
+ * before (design §7.7);
4935
+ * - a `routable` plan — ≥1 live worker is capability-capable AND protocol-compatible (dispatch);
4936
+ * - a `blocked` plan — descriptors exist but none can run it (park the run, never dispatch into it).
4937
+ */
4938
+ async planRoute(token, requires) {
4939
+ const descriptors = await this.pool.readWorkerDescriptors(token);
4940
+ if (descriptors.length === 0) return void 0;
4941
+ return planDispatch(requires ?? [], descriptors, this.cpDescriptor, token);
4942
+ }
4943
+ /** Throw {@link WorkflowBlocked} when no live worker can run a dispatch to `token` — so the caller's
4944
+ * run-execution catch parks the run `blocked` (with the plan) instead of persisting a `pending`
4945
+ * checkpoint + dispatching into a queue nobody consumes (design §7.5). A no-op when the guard does
4946
+ * not engage (no descriptors) or a capable+compatible worker exists. */
4947
+ async ensureRoutable(token, requires) {
4948
+ const plan = await this.planRoute(token, requires);
4949
+ if (plan?.status === "blocked") throw new WorkflowBlocked(plan);
4950
+ }
4951
+ /**
4952
+ * Park a run `blocked` (handshake design §7.5/§7.6): no live worker on its next-dispatch group is
4953
+ * capability-capable + protocol-compatible. Persists the `blocked` status with a human reason + the
4954
+ * machine `code` + a `wakeAt` so the blocked-recovery poll re-drives it, emits the LOUD structured
4955
+ * `capability.unavailable`/`protocol.incompatible` diagnostics (both descriptors + the precise delta),
4956
+ * and releases the run lease so any instance can re-drive it. Re-checks the persisted status first so
4957
+ * a concurrent cancel/complete is never clobbered. `releaseRunLock` is idempotent, so a caller whose
4958
+ * own `finally` also releases (the run-execution path) is safe.
4959
+ */
4960
+ async parkBlocked(run, blocked) {
4961
+ const current = await this.store.getRun(run.id);
4962
+ if (current && (current.status === "cancelled" || current.status === "completed")) {
4963
+ await this.store.releaseRunLock(run.id);
4964
+ return {
4965
+ runId: run.id,
4966
+ status: current.status,
4967
+ output: current.output,
4968
+ error: current.error
4969
+ };
4970
+ }
4971
+ const error = {
4972
+ message: blocked.reason,
4973
+ code: blocked.code,
4974
+ retryable: true
4975
+ };
4976
+ await this.store.updateRun(run.id, {
4977
+ status: "blocked",
4978
+ error,
4979
+ wakeAt: this.clock() + this.blockedPollMs,
4980
+ updatedAt: /* @__PURE__ */ new Date()
4981
+ });
4982
+ this.emit({
4983
+ type: "run.blocked",
4984
+ runId: run.id,
4985
+ workflow: run.workflow,
4986
+ namespace: run.namespace,
4987
+ error,
4988
+ requires: blocked.requires,
4989
+ diagnostics: blocked.diagnostics
4990
+ });
4991
+ await this.store.releaseRunLock(run.id);
4992
+ return {
4993
+ runId: run.id,
4994
+ status: "blocked",
4995
+ error
4996
+ };
4997
+ }
4998
+ /** Runs parked `blocked` whose recovery poll is due (`wakeAt <= nowMs`), namespace-scoped like
4999
+ * {@link resumeDueTimers}. Re-driving one re-checks the live fleet: it dispatches if a
5000
+ * capable+compatible worker has appeared, else re-parks with a fresh `wakeAt` (design §7.5). */
5001
+ async dueBlockedRuns(nowMs) {
5002
+ const blocked = await this.store.listRuns({
5003
+ statuses: [
5004
+ "blocked"
5005
+ ]
5006
+ });
5007
+ return blocked.filter((r) => (this.namespace === void 0 || r.namespace === void 0 || r.namespace === this.namespace) && r.wakeAt !== void 0 && r.wakeAt <= nowMs);
5008
+ }
4514
5009
  async callRemote(runId, seq, step, input, queue, transport, replay, admission) {
4515
5010
  const existing = replay?.get(seq) ?? await this.store.getCheckpoint(runId, seq);
4516
5011
  if (existing && existing.name !== step.name) {
@@ -4576,6 +5071,7 @@ var WorkflowEngine = class {
4576
5071
  if (this.clock() < existing.wakeAt) throw new WorkflowSuspended(existing.wakeAt);
4577
5072
  attempt = existing.attempts + 1;
4578
5073
  }
5074
+ await this.ensureRoutable(tenantGroup(sanitizeQueueToken(step.name), step.partition), step.requires);
4579
5075
  const id = stepId(runId, seq);
4580
5076
  if (queue && this.admission.handles(queue)) {
4581
5077
  const decision = await this.admission.tryAdmit(queue, {
@@ -4762,6 +5258,7 @@ var WorkflowEngine = class {
4762
5258
  /** In-memory await path for a remote step with a liveness `timeoutMs` (re-dispatch on timeout). */
4763
5259
  async callRemoteInMemory(runId, seq, step, input, transport) {
4764
5260
  if (this.pool.size === 0) throw new Error("remote steps require a Transport");
5261
+ await this.ensureRoutable(tenantGroup(sanitizeQueueToken(step.name), step.partition), step.requires);
4765
5262
  const validInput = step.input ? step.input.parse(input) : input;
4766
5263
  const id = stepId(runId, seq);
4767
5264
  const enqueuedAt = /* @__PURE__ */ new Date();
@@ -4880,6 +5377,13 @@ var TERMINAL_RUN_STATUSES = [
4880
5377
  "dead"
4881
5378
  ];
4882
5379
 
5380
+ // src/run-gateway.ts
5381
+ var RunGateway = class {
5382
+ static {
5383
+ __name(this, "RunGateway");
5384
+ }
5385
+ };
5386
+
4883
5387
  // src/codec-state-store.ts
4884
5388
  var CodecStateStore = class {
4885
5389
  static {
@@ -5661,6 +6165,7 @@ var InMemoryTransport = class {
5661
6165
  // Annotate the CommonJS export names for ESM import in node:
5662
6166
  0 && (module.exports = {
5663
6167
  CONTROL_FLOW_SIGNAL,
6168
+ CURRENT_PROTOCOL_VERSION,
5664
6169
  CodecStateStore,
5665
6170
  ContinueAsNew,
5666
6171
  DURABLE_OPTIONS,
@@ -5677,12 +6182,15 @@ var InMemoryTransport = class {
5677
6182
  InMemoryAdmissionBackend,
5678
6183
  InMemoryStateStore,
5679
6184
  InMemoryTransport,
6185
+ LEGACY_V1_CAPABILITIES,
6186
+ LEGACY_V1_PROTOCOL,
5680
6187
  NonDeterminismError,
5681
6188
  QueueController,
5682
6189
  RemoteStepError,
5683
6190
  RemoteStepTimeout,
5684
6191
  RemoteWorkflowExecutor,
5685
6192
  RemoteWorkflowTimeout,
6193
+ RunGateway,
5686
6194
  STATE_STORE,
5687
6195
  STATE_STORE_CANONICAL,
5688
6196
  SignalTimeoutError,
@@ -5691,6 +6199,7 @@ var InMemoryTransport = class {
5691
6199
  TRANSPORT,
5692
6200
  TRANSPORT_CANONICAL,
5693
6201
  WORKFLOW_NAME_KEY,
6202
+ WorkflowBlocked,
5694
6203
  WorkflowEngine,
5695
6204
  WorkflowSuspended,
5696
6205
  applyAttributeQuery,
@@ -5698,23 +6207,35 @@ var InMemoryTransport = class {
5698
6207
  attributeOperand,
5699
6208
  bindWorkflowClass,
5700
6209
  breakpointToken,
6210
+ canRoute,
6211
+ capableWorkers,
5701
6212
  classifyWaiterToken,
5702
6213
  collectMetrics,
6214
+ controlPlaneDescriptor,
5703
6215
  createStepLogger,
5704
6216
  currentWorkflowCtx,
6217
+ descriptorHash,
5705
6218
  eventMatchOf,
5706
6219
  eventMatches,
5707
6220
  eventNameOf,
5708
6221
  eventPrefix,
5709
6222
  eventToken,
6223
+ heartbeatStatus,
5710
6224
  indexWaitersByRun,
5711
6225
  isEventToken,
6226
+ isLegacyDescriptor,
5712
6227
  isWorkflowControlFlowSignal,
5713
6228
  matchesAttributes,
6229
+ missingCapabilities,
6230
+ negotiate,
5714
6231
  normalizeAttributeRows,
6232
+ normalizeDescriptor,
5715
6233
  parseDuration,
6234
+ planDispatch,
5716
6235
  prevCronFireMs,
5717
6236
  readSearchAttributes,
6237
+ requiredCapabilities,
6238
+ resolveRouting,
5718
6239
  resolveRunWaiting,
5719
6240
  runInWorkflowCtx,
5720
6241
  runSchedules,