@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.js CHANGED
@@ -822,6 +822,358 @@ function eventMatches(payload, match) {
822
822
  }
823
823
  __name(eventMatches, "eventMatches");
824
824
 
825
+ // src/handshake/descriptor.ts
826
+ var CURRENT_PROTOCOL_VERSION = 1;
827
+ var LEGACY_V1_PROTOCOL = {
828
+ version: 1,
829
+ range: [
830
+ 1,
831
+ 1
832
+ ]
833
+ };
834
+ var LEGACY_V1_CAPABILITIES = Object.freeze([
835
+ "saga",
836
+ "signals",
837
+ "search-attributes",
838
+ "priority",
839
+ "entities",
840
+ "child-workflows",
841
+ "singleton",
842
+ "schedules",
843
+ "continue-as-new",
844
+ "queries",
845
+ "cancellation"
846
+ ]);
847
+ function isLegacyDescriptor(raw) {
848
+ return raw.protocol === void 0;
849
+ }
850
+ __name(isLegacyDescriptor, "isLegacyDescriptor");
851
+ function normalizeDescriptor(raw) {
852
+ return {
853
+ instanceId: raw.instanceId,
854
+ runtime: raw.runtime,
855
+ sdk: raw.sdk ?? {
856
+ name: "unknown",
857
+ version: "0"
858
+ },
859
+ protocol: raw.protocol ?? {
860
+ ...LEGACY_V1_PROTOCOL
861
+ },
862
+ capabilities: raw.capabilities ?? [
863
+ ...LEGACY_V1_CAPABILITIES
864
+ ],
865
+ workflows: raw.workflows ?? [],
866
+ steps: raw.steps ?? [],
867
+ ...raw.partition !== void 0 ? {
868
+ partition: raw.partition
869
+ } : {},
870
+ ...raw.namespace !== void 0 ? {
871
+ namespace: raw.namespace
872
+ } : {},
873
+ startedAt: raw.startedAt ?? 0
874
+ };
875
+ }
876
+ __name(normalizeDescriptor, "normalizeDescriptor");
877
+ function canonicalizeForHash(d) {
878
+ const set = /* @__PURE__ */ __name((xs) => [
879
+ ...new Set(xs)
880
+ ].sort(), "set");
881
+ return {
882
+ instanceId: d.instanceId,
883
+ runtime: d.runtime,
884
+ sdk: {
885
+ name: d.sdk.name,
886
+ version: d.sdk.version
887
+ },
888
+ protocol: {
889
+ version: d.protocol.version,
890
+ range: [
891
+ d.protocol.range[0],
892
+ d.protocol.range[1]
893
+ ]
894
+ },
895
+ capabilities: set(d.capabilities),
896
+ workflows: set(d.workflows),
897
+ steps: set(d.steps),
898
+ partition: d.partition ?? null,
899
+ namespace: d.namespace ?? null,
900
+ startedAt: d.startedAt
901
+ };
902
+ }
903
+ __name(canonicalizeForHash, "canonicalizeForHash");
904
+ function fnv1a64Hex(input) {
905
+ const OFFSET = 0xcbf29ce484222325n;
906
+ const PRIME = 0x100000001b3n;
907
+ const MASK = 0xffffffffffffffffn;
908
+ let hash = OFFSET;
909
+ for (let i = 0; i < input.length; i += 1) {
910
+ hash ^= BigInt(input.charCodeAt(i) & 255);
911
+ hash ^= BigInt(input.charCodeAt(i) >> 8 & 255);
912
+ hash = hash * PRIME & MASK;
913
+ }
914
+ return hash.toString(16).padStart(16, "0");
915
+ }
916
+ __name(fnv1a64Hex, "fnv1a64Hex");
917
+ function descriptorHash(descriptor) {
918
+ const canonical = canonicalizeForHash(normalizeDescriptor(descriptor));
919
+ return fnv1a64Hex(JSON.stringify(canonical));
920
+ }
921
+ __name(descriptorHash, "descriptorHash");
922
+ function heartbeatStatus(descriptor, opts = {}) {
923
+ return {
924
+ ts: opts.ts ?? Date.now(),
925
+ status: opts.status ?? "up",
926
+ descriptorHash: descriptorHash(descriptor)
927
+ };
928
+ }
929
+ __name(heartbeatStatus, "heartbeatStatus");
930
+
931
+ // src/handshake/negotiate.ts
932
+ var rangesOverlap = /* @__PURE__ */ __name((a, b) => Math.max(a[0], b[0]) <= Math.min(a[1], b[1]), "rangesOverlap");
933
+ var difference = /* @__PURE__ */ __name((a, b) => {
934
+ const bset = new Set(b);
935
+ return [
936
+ ...new Set(a)
937
+ ].filter((x) => !bset.has(x)).sort();
938
+ }, "difference");
939
+ var intersection = /* @__PURE__ */ __name((a, b) => {
940
+ const bset = new Set(b);
941
+ return [
942
+ ...new Set(a)
943
+ ].filter((x) => bset.has(x)).sort();
944
+ }, "intersection");
945
+ function negotiate(localRaw, remoteRaw, opts = {}) {
946
+ const local = normalizeDescriptor(localRaw);
947
+ const remote = normalizeDescriptor(remoteRaw);
948
+ const localRange = local.protocol.range;
949
+ const remoteRange = remote.protocol.range;
950
+ const shared = intersection(local.capabilities, remote.capabilities);
951
+ const missingOnRemote = difference(local.capabilities, remote.capabilities);
952
+ const missingOnLocal = difference(remote.capabilities, local.capabilities);
953
+ const capabilities = {
954
+ shared,
955
+ missingOnRemote,
956
+ missingOnLocal
957
+ };
958
+ if (!rangesOverlap(localRange, remoteRange)) {
959
+ return {
960
+ outcome: "incompatible",
961
+ negotiatedProtocol: null,
962
+ protocol: {
963
+ localRange,
964
+ remoteRange
965
+ },
966
+ capabilities,
967
+ reason: {
968
+ code: "protocol.incompatible",
969
+ message: `no common protocol major: local speaks [${localRange[0]}, ${localRange[1]}], remote speaks [${remoteRange[0]}, ${remoteRange[1]}]`,
970
+ detail: {
971
+ localRange,
972
+ remoteRange
973
+ }
974
+ },
975
+ local,
976
+ remote
977
+ };
978
+ }
979
+ const negotiatedProtocol = Math.min(localRange[1], remoteRange[1]);
980
+ const required = opts.required ?? [];
981
+ const missingRequired = difference(required, remote.capabilities);
982
+ if (missingRequired.length > 0) {
983
+ return {
984
+ outcome: "degraded",
985
+ negotiatedProtocol,
986
+ protocol: {
987
+ localRange,
988
+ remoteRange
989
+ },
990
+ capabilities,
991
+ reason: {
992
+ code: "capability.unavailable",
993
+ message: `remote is missing required capability(ies): ${missingRequired.join(", ")}`,
994
+ detail: {
995
+ missingRequired,
996
+ missingOnRemote,
997
+ missingOnLocal
998
+ }
999
+ },
1000
+ local,
1001
+ remote
1002
+ };
1003
+ }
1004
+ if (missingOnRemote.length > 0 || missingOnLocal.length > 0) {
1005
+ const parts = [];
1006
+ if (missingOnRemote.length > 0) parts.push(`remote lacks ${missingOnRemote.join(", ")}`);
1007
+ if (missingOnLocal.length > 0) parts.push(`local lacks ${missingOnLocal.join(", ")}`);
1008
+ return {
1009
+ outcome: "degraded",
1010
+ negotiatedProtocol,
1011
+ protocol: {
1012
+ localRange,
1013
+ remoteRange
1014
+ },
1015
+ capabilities,
1016
+ reason: {
1017
+ code: "capability.unavailable",
1018
+ message: `capability mismatch: ${parts.join("; ")}`,
1019
+ detail: {
1020
+ missingOnRemote,
1021
+ missingOnLocal
1022
+ }
1023
+ },
1024
+ local,
1025
+ remote
1026
+ };
1027
+ }
1028
+ return {
1029
+ outcome: "compatible",
1030
+ negotiatedProtocol,
1031
+ protocol: {
1032
+ localRange,
1033
+ remoteRange
1034
+ },
1035
+ capabilities,
1036
+ local,
1037
+ remote
1038
+ };
1039
+ }
1040
+ __name(negotiate, "negotiate");
1041
+
1042
+ // src/handshake/routing.ts
1043
+ function requiredCapabilities(handler) {
1044
+ const list = Array.isArray(handler) ? handler : handler.requires ?? [];
1045
+ return [
1046
+ ...new Set(list)
1047
+ ];
1048
+ }
1049
+ __name(requiredCapabilities, "requiredCapabilities");
1050
+ function canRoute(handler, worker) {
1051
+ const required = requiredCapabilities(handler);
1052
+ if (required.length === 0) return true;
1053
+ const advertised = new Set(normalizeDescriptor(worker).capabilities);
1054
+ return required.every((cap) => advertised.has(cap));
1055
+ }
1056
+ __name(canRoute, "canRoute");
1057
+ function missingCapabilities(handler, worker) {
1058
+ const advertised = new Set(normalizeDescriptor(worker).capabilities);
1059
+ return requiredCapabilities(handler).filter((cap) => !advertised.has(cap));
1060
+ }
1061
+ __name(missingCapabilities, "missingCapabilities");
1062
+ function capableWorkers(handler, workers) {
1063
+ return workers.filter((w) => canRoute(handler, w));
1064
+ }
1065
+ __name(capableWorkers, "capableWorkers");
1066
+ function resolveRouting(handler, workers) {
1067
+ const requires = requiredCapabilities(handler);
1068
+ const capable = capableWorkers(handler, workers).map(normalizeDescriptor);
1069
+ if (capable.length > 0) {
1070
+ return {
1071
+ status: "routable",
1072
+ workers: capable,
1073
+ requires
1074
+ };
1075
+ }
1076
+ const suffix = requires.length > 0 ? ` (requires ${requires.join(", ")})` : "";
1077
+ return {
1078
+ status: "blocked",
1079
+ reason: `blocked: no compatible worker${suffix}`,
1080
+ requires
1081
+ };
1082
+ }
1083
+ __name(resolveRouting, "resolveRouting");
1084
+
1085
+ // src/handshake/dispatch-routing.ts
1086
+ function controlPlaneDescriptor(opts) {
1087
+ const version = CURRENT_PROTOCOL_VERSION;
1088
+ return {
1089
+ instanceId: opts.instanceId,
1090
+ runtime: "node",
1091
+ sdk: opts.sdk ?? {
1092
+ name: "@dudousxd/nestjs-durable-core",
1093
+ version: String(version)
1094
+ },
1095
+ protocol: opts.protocol ?? {
1096
+ version,
1097
+ range: [
1098
+ version,
1099
+ version
1100
+ ]
1101
+ },
1102
+ capabilities: opts.capabilities ?? [],
1103
+ workflows: [],
1104
+ steps: [],
1105
+ ...opts.partition !== void 0 ? {
1106
+ partition: opts.partition
1107
+ } : {},
1108
+ ...opts.namespace !== void 0 ? {
1109
+ namespace: opts.namespace
1110
+ } : {},
1111
+ startedAt: opts.startedAt ?? 0
1112
+ };
1113
+ }
1114
+ __name(controlPlaneDescriptor, "controlPlaneDescriptor");
1115
+ var WorkflowBlocked = class extends Error {
1116
+ static {
1117
+ __name(this, "WorkflowBlocked");
1118
+ }
1119
+ plan;
1120
+ constructor(plan) {
1121
+ super(plan.reason), this.plan = plan;
1122
+ this.name = "WorkflowBlocked";
1123
+ }
1124
+ };
1125
+ function planDispatch(requires, liveDescriptors, cp, token) {
1126
+ const required = requiredCapabilities({
1127
+ requires
1128
+ });
1129
+ const workers = liveDescriptors.map(normalizeDescriptor);
1130
+ const capable = workers.filter((w) => canRoute({
1131
+ requires
1132
+ }, w));
1133
+ const compatible = capable.filter((w) => negotiate(cp, w).outcome !== "incompatible");
1134
+ if (compatible.length > 0) return {
1135
+ status: "routable",
1136
+ workers: compatible
1137
+ };
1138
+ if (capable.length > 0) {
1139
+ const workerRanges = capable.map((w) => w.protocol.range);
1140
+ return {
1141
+ status: "blocked",
1142
+ code: "protocol.incompatible",
1143
+ reason: `blocked: no protocol-compatible worker${required.length > 0 ? ` (requires ${required.join(", ")})` : ""}`,
1144
+ requires: required,
1145
+ diagnostics: {
1146
+ code: "protocol.incompatible",
1147
+ token,
1148
+ requires: required,
1149
+ liveWorkers: workers.length,
1150
+ controlPlaneRange: cp.protocol.range,
1151
+ workerRanges,
1152
+ controlPlane: cp,
1153
+ workers
1154
+ }
1155
+ };
1156
+ }
1157
+ const advertised = new Set(workers.flatMap((w) => w.capabilities));
1158
+ const missingCapabilities2 = required.filter((cap) => !advertised.has(cap));
1159
+ return {
1160
+ status: "blocked",
1161
+ code: "capability.unavailable",
1162
+ reason: `blocked: no compatible worker${required.length > 0 ? ` (requires ${required.join(", ")})` : ""}`,
1163
+ requires: required,
1164
+ diagnostics: {
1165
+ code: "capability.unavailable",
1166
+ token,
1167
+ requires: required,
1168
+ liveWorkers: workers.length,
1169
+ missingCapabilities: missingCapabilities2,
1170
+ controlPlane: cp,
1171
+ workers
1172
+ }
1173
+ };
1174
+ }
1175
+ __name(planDispatch, "planDispatch");
1176
+
825
1177
  // src/tenant-group.ts
826
1178
  function tenantGroup(baseGroup, tenant) {
827
1179
  return tenant !== void 0 && tenant !== "" && tenant !== "default" ? `${baseGroup}@${tenant}` : baseGroup;
@@ -1086,6 +1438,22 @@ var TransportPool = class {
1086
1438
  transport.onStartRun?.(handler);
1087
1439
  }
1088
1440
  }
1441
+ /**
1442
+ * The live handshake descriptors advertised for `group` (design §7.2), merged across every transport
1443
+ * that can report them. Returns `[]` when NO transport implements
1444
+ * {@link Transport.readWorkerDescriptors} (a pure in-process pool) OR when descriptors are supported
1445
+ * but none is published — both are the "guard disengaged / legacy assume-compatible" case, so the
1446
+ * engine's routing guard treats an empty result identically (design §7.7).
1447
+ */
1448
+ async readWorkerDescriptors(group) {
1449
+ const descriptors = [];
1450
+ for (const { transport } of this.transports) {
1451
+ if (transport.readWorkerDescriptors) {
1452
+ descriptors.push(...await transport.readWorkerDescriptors(group));
1453
+ }
1454
+ }
1455
+ return descriptors;
1456
+ }
1089
1457
  /** Distinct worker groups with a live heartbeat, merged across every transport that can report it. */
1090
1458
  async listWorkerGroups() {
1091
1459
  const groups = /* @__PURE__ */ new Set();
@@ -1181,6 +1549,8 @@ function resolveStepPolicy(config, opts) {
1181
1549
  if (jitter !== void 0) policy.jitter = jitter;
1182
1550
  const timeoutMs = opts?.timeoutMs ?? config?.timeoutMs;
1183
1551
  if (timeoutMs !== void 0) policy.timeoutMs = timeoutMs;
1552
+ const requires = opts?.requires ?? config?.requires;
1553
+ if (requires !== void 0) policy.requires = requires;
1184
1554
  return policy;
1185
1555
  }
1186
1556
  __name(resolveStepPolicy, "resolveStepPolicy");
@@ -1886,6 +2256,10 @@ var WorkflowEngine = class {
1886
2256
  eventBufferTtlMs;
1887
2257
  remoteRedispatchMs;
1888
2258
  remoteRedispatchMax;
2259
+ /** How long a `blocked` run waits before the recovery poll re-checks the live fleet (design §7.5). */
2260
+ blockedPollMs;
2261
+ /** This engine's control-plane handshake descriptor — negotiated against each worker (design §7.3). */
2262
+ cpDescriptor;
1889
2263
  webhookUrl;
1890
2264
  traceparent;
1891
2265
  context;
@@ -1957,6 +2331,13 @@ var WorkflowEngine = class {
1957
2331
  this.eventBufferTtlMs = deps.eventBufferTtlMs || void 0;
1958
2332
  this.remoteRedispatchMs = deps.remoteRedispatchMs || void 0;
1959
2333
  this.remoteRedispatchMax = Math.max(1, deps.remoteRedispatchMax ?? 10);
2334
+ this.blockedPollMs = deps.blockedPollMs ?? 5e3;
2335
+ this.cpDescriptor = controlPlaneDescriptor({
2336
+ instanceId: `cp-${this.instanceId}`,
2337
+ ...this.namespace !== void 0 ? {
2338
+ namespace: this.namespace
2339
+ } : {}
2340
+ });
1960
2341
  this.webhookUrl = deps.webhookUrl;
1961
2342
  this.traceparent = deps.traceparent;
1962
2343
  this.context = deps.context;
@@ -2110,6 +2491,7 @@ var WorkflowEngine = class {
2110
2491
  searchAttributesSchema: opts?.searchAttributesSchema,
2111
2492
  onEvent: opts?.onEvent,
2112
2493
  eventBatch: opts?.eventBatch,
2494
+ requires: opts?.requires,
2113
2495
  // A real, retained in-process body — so `workflowBody` can hand it to an in-app worker, and so
2114
2496
  // it is NOT confused with the throwing stub `registerRemote` installs for a body-less remote.
2115
2497
  hasBody: true
@@ -2158,6 +2540,7 @@ var WorkflowEngine = class {
2158
2540
  singleton: opts.singleton,
2159
2541
  executionTimeoutMs: opts.executionTimeout != null ? parseDuration(opts.executionTimeout) : void 0,
2160
2542
  validateInput: opts.validateInput,
2543
+ requires: opts.requires,
2161
2544
  remote: {
2162
2545
  group: opts.group,
2163
2546
  executor: opts.executor
@@ -2641,9 +3024,13 @@ var WorkflowEngine = class {
2641
3024
  */
2642
3025
  async resumeDueTimers(nowMs = this.clock()) {
2643
3026
  const due = await this.store.listDueTimers(nowMs, this.namespace);
2644
- if (due.length === 0) return [];
3027
+ const blocked = await this.dueBlockedRuns(nowMs);
3028
+ if (due.length === 0 && blocked.length === 0) return [];
2645
3029
  const waiterByRun = indexWaitersByRun(await this.store.listSignalWaiters(""));
2646
- return this.resumeLeased(due, nowMs, (run) => this.reconcileSignalWaiter(run, waiterByRun.get(run.id)));
3030
+ return this.resumeLeased([
3031
+ ...due,
3032
+ ...blocked
3033
+ ], nowMs, (run) => this.reconcileSignalWaiter(run, waiterByRun.get(run.id)));
2647
3034
  }
2648
3035
  /**
2649
3036
  * The reconcile safety net's per-run check (see {@link resumeDueTimers}): if this suspended run has
@@ -2726,7 +3113,10 @@ var WorkflowEngine = class {
2726
3113
  * inline — or null if the run is unknown. The dashboard "retry" goes through here so it can't block
2727
3114
  * the HTTP request on workflow execution.
2728
3115
  */
2729
- async requeue(runId) {
3116
+ async requeue(runId, _seen) {
3117
+ const seen = _seen ?? /* @__PURE__ */ new Set();
3118
+ if (seen.has(runId)) return null;
3119
+ seen.add(runId);
2730
3120
  const run = await this.store.getRun(runId);
2731
3121
  if (!run) return null;
2732
3122
  if (run.status === "failed" || run.status === "dead") {
@@ -2750,12 +3140,28 @@ var WorkflowEngine = class {
2750
3140
  status: "running",
2751
3141
  output: void 0
2752
3142
  });
3143
+ const childId = cp.name.slice("signal:child:".length);
3144
+ const parked = [];
3145
+ for (; ; ) {
3146
+ const buffered = await this.store.takeBufferedSignal(`child:${childId}`);
3147
+ if (!buffered) break;
3148
+ parked.push(buffered.payload);
3149
+ }
3150
+ for (const payload of parked) await this.store.bufferSignal(`child:${childId}`, payload);
3151
+ const bufferedSuccess = parked.some((payload) => typeof payload === "object" && payload !== null && payload.ok === true);
3152
+ if (!bufferedSuccess) {
3153
+ const child = await this.store.getRun(childId);
3154
+ if (child && (child.status === "failed" || child.status === "dead")) {
3155
+ await this.requeue(childId, seen);
3156
+ }
3157
+ }
2753
3158
  }
2754
3159
  }
2755
3160
  }
2756
3161
  await this.store.releaseRunLock(runId);
2757
3162
  await this.store.updateRun(runId, {
2758
3163
  status: "pending",
3164
+ error: void 0,
2759
3165
  updatedAt: /* @__PURE__ */ new Date()
2760
3166
  });
2761
3167
  await this.runDispatcher.dispatch(runId);
@@ -3075,6 +3481,10 @@ var WorkflowEngine = class {
3075
3481
  */
3076
3482
  notifyParent(runId, completion) {
3077
3483
  void this.signal(`child:${runId}`, completion).catch(() => void 0);
3484
+ const at = runId.lastIndexOf("~retry~");
3485
+ if (at !== -1 && completion.ok) {
3486
+ void this.signal(`child:${runId.slice(0, at)}`, completion).catch(() => void 0);
3487
+ }
3078
3488
  }
3079
3489
  /**
3080
3490
  * Deferred child start shared by the in-process ctx host and the remote `startChild` command.
@@ -3529,6 +3939,7 @@ var WorkflowEngine = class {
3529
3939
  error: outcome.error
3530
3940
  };
3531
3941
  }
3942
+ if (outcome.kind === "blocked") return this.parkBlocked(run, outcome.blocked);
3532
3943
  const latest = await this.store.getRun(run.id);
3533
3944
  if (latest?.status === "cancelled") {
3534
3945
  return {
@@ -3628,6 +4039,8 @@ var WorkflowEngine = class {
3628
4039
  status: "suspended"
3629
4040
  };
3630
4041
  }
4042
+ const wfPlan = await this.planRoute(tenantGroup(sanitizeQueueToken(remote.group), void 0), registered.requires);
4043
+ if (wfPlan?.status === "blocked") return this.parkBlocked(run, wfPlan);
3631
4044
  const history = await this.remoteHistory(run.id);
3632
4045
  if (remote.executor.dispatch) {
3633
4046
  const taskId = `${run.id}:wf:${globalThis.crypto.randomUUID().slice(0, 8)}`;
@@ -4051,6 +4464,15 @@ var WorkflowEngine = class {
4051
4464
  status: "completed"
4052
4465
  };
4053
4466
  }
4467
+ if (err instanceof WorkflowBlocked) {
4468
+ if (this.cancelRequested.has(run.id)) {
4469
+ return this.compensateAndCancel(run, compensations, replay);
4470
+ }
4471
+ return this.settleRun(run, {
4472
+ kind: "blocked",
4473
+ blocked: err.plan
4474
+ });
4475
+ }
4054
4476
  if (err instanceof WorkflowSuspended) {
4055
4477
  if (this.cancelRequested.has(run.id)) {
4056
4478
  return this.compensateAndCancel(run, compensations, replay);
@@ -4397,6 +4819,85 @@ var WorkflowEngine = class {
4397
4819
  run: result
4398
4820
  };
4399
4821
  }
4822
+ /**
4823
+ * Plan a dispatch to `token` for a handler requiring `requires` (handshake design §7.5). Returns:
4824
+ * - `undefined` — NO live descriptors published on this token: the guard does not engage, so a
4825
+ * pre-handshake fleet (or an in-process transport that advertises none) keeps flowing exactly as
4826
+ * before (design §7.7);
4827
+ * - a `routable` plan — ≥1 live worker is capability-capable AND protocol-compatible (dispatch);
4828
+ * - a `blocked` plan — descriptors exist but none can run it (park the run, never dispatch into it).
4829
+ */
4830
+ async planRoute(token, requires) {
4831
+ const descriptors = await this.pool.readWorkerDescriptors(token);
4832
+ if (descriptors.length === 0) return void 0;
4833
+ return planDispatch(requires ?? [], descriptors, this.cpDescriptor, token);
4834
+ }
4835
+ /** Throw {@link WorkflowBlocked} when no live worker can run a dispatch to `token` — so the caller's
4836
+ * run-execution catch parks the run `blocked` (with the plan) instead of persisting a `pending`
4837
+ * checkpoint + dispatching into a queue nobody consumes (design §7.5). A no-op when the guard does
4838
+ * not engage (no descriptors) or a capable+compatible worker exists. */
4839
+ async ensureRoutable(token, requires) {
4840
+ const plan = await this.planRoute(token, requires);
4841
+ if (plan?.status === "blocked") throw new WorkflowBlocked(plan);
4842
+ }
4843
+ /**
4844
+ * Park a run `blocked` (handshake design §7.5/§7.6): no live worker on its next-dispatch group is
4845
+ * capability-capable + protocol-compatible. Persists the `blocked` status with a human reason + the
4846
+ * machine `code` + a `wakeAt` so the blocked-recovery poll re-drives it, emits the LOUD structured
4847
+ * `capability.unavailable`/`protocol.incompatible` diagnostics (both descriptors + the precise delta),
4848
+ * and releases the run lease so any instance can re-drive it. Re-checks the persisted status first so
4849
+ * a concurrent cancel/complete is never clobbered. `releaseRunLock` is idempotent, so a caller whose
4850
+ * own `finally` also releases (the run-execution path) is safe.
4851
+ */
4852
+ async parkBlocked(run, blocked) {
4853
+ const current = await this.store.getRun(run.id);
4854
+ if (current && (current.status === "cancelled" || current.status === "completed")) {
4855
+ await this.store.releaseRunLock(run.id);
4856
+ return {
4857
+ runId: run.id,
4858
+ status: current.status,
4859
+ output: current.output,
4860
+ error: current.error
4861
+ };
4862
+ }
4863
+ const error = {
4864
+ message: blocked.reason,
4865
+ code: blocked.code,
4866
+ retryable: true
4867
+ };
4868
+ await this.store.updateRun(run.id, {
4869
+ status: "blocked",
4870
+ error,
4871
+ wakeAt: this.clock() + this.blockedPollMs,
4872
+ updatedAt: /* @__PURE__ */ new Date()
4873
+ });
4874
+ this.emit({
4875
+ type: "run.blocked",
4876
+ runId: run.id,
4877
+ workflow: run.workflow,
4878
+ namespace: run.namespace,
4879
+ error,
4880
+ requires: blocked.requires,
4881
+ diagnostics: blocked.diagnostics
4882
+ });
4883
+ await this.store.releaseRunLock(run.id);
4884
+ return {
4885
+ runId: run.id,
4886
+ status: "blocked",
4887
+ error
4888
+ };
4889
+ }
4890
+ /** Runs parked `blocked` whose recovery poll is due (`wakeAt <= nowMs`), namespace-scoped like
4891
+ * {@link resumeDueTimers}. Re-driving one re-checks the live fleet: it dispatches if a
4892
+ * capable+compatible worker has appeared, else re-parks with a fresh `wakeAt` (design §7.5). */
4893
+ async dueBlockedRuns(nowMs) {
4894
+ const blocked = await this.store.listRuns({
4895
+ statuses: [
4896
+ "blocked"
4897
+ ]
4898
+ });
4899
+ return blocked.filter((r) => (this.namespace === void 0 || r.namespace === void 0 || r.namespace === this.namespace) && r.wakeAt !== void 0 && r.wakeAt <= nowMs);
4900
+ }
4400
4901
  async callRemote(runId, seq, step, input, queue, transport, replay, admission) {
4401
4902
  const existing = replay?.get(seq) ?? await this.store.getCheckpoint(runId, seq);
4402
4903
  if (existing && existing.name !== step.name) {
@@ -4462,6 +4963,7 @@ var WorkflowEngine = class {
4462
4963
  if (this.clock() < existing.wakeAt) throw new WorkflowSuspended(existing.wakeAt);
4463
4964
  attempt = existing.attempts + 1;
4464
4965
  }
4966
+ await this.ensureRoutable(tenantGroup(sanitizeQueueToken(step.name), step.partition), step.requires);
4465
4967
  const id = stepId(runId, seq);
4466
4968
  if (queue && this.admission.handles(queue)) {
4467
4969
  const decision = await this.admission.tryAdmit(queue, {
@@ -4648,6 +5150,7 @@ var WorkflowEngine = class {
4648
5150
  /** In-memory await path for a remote step with a liveness `timeoutMs` (re-dispatch on timeout). */
4649
5151
  async callRemoteInMemory(runId, seq, step, input, transport) {
4650
5152
  if (this.pool.size === 0) throw new Error("remote steps require a Transport");
5153
+ await this.ensureRoutable(tenantGroup(sanitizeQueueToken(step.name), step.partition), step.requires);
4651
5154
  const validInput = step.input ? step.input.parse(input) : input;
4652
5155
  const id = stepId(runId, seq);
4653
5156
  const enqueuedAt = /* @__PURE__ */ new Date();
@@ -5546,6 +6049,7 @@ var InMemoryTransport = class {
5546
6049
  };
5547
6050
  export {
5548
6051
  CONTROL_FLOW_SIGNAL,
6052
+ CURRENT_PROTOCOL_VERSION,
5549
6053
  CodecStateStore,
5550
6054
  ContinueAsNew,
5551
6055
  DURABLE_OPTIONS,
@@ -5562,6 +6066,8 @@ export {
5562
6066
  InMemoryAdmissionBackend,
5563
6067
  InMemoryStateStore,
5564
6068
  InMemoryTransport,
6069
+ LEGACY_V1_CAPABILITIES,
6070
+ LEGACY_V1_PROTOCOL,
5565
6071
  NonDeterminismError,
5566
6072
  QueueController,
5567
6073
  RemoteStepError,
@@ -5576,6 +6082,7 @@ export {
5576
6082
  TRANSPORT,
5577
6083
  TRANSPORT_CANONICAL,
5578
6084
  WORKFLOW_NAME_KEY,
6085
+ WorkflowBlocked,
5579
6086
  WorkflowEngine,
5580
6087
  WorkflowSuspended,
5581
6088
  applyAttributeQuery,
@@ -5583,23 +6090,35 @@ export {
5583
6090
  attributeOperand,
5584
6091
  bindWorkflowClass,
5585
6092
  breakpointToken,
6093
+ canRoute,
6094
+ capableWorkers,
5586
6095
  classifyWaiterToken,
5587
6096
  collectMetrics,
6097
+ controlPlaneDescriptor,
5588
6098
  createStepLogger,
5589
6099
  currentWorkflowCtx,
6100
+ descriptorHash,
5590
6101
  eventMatchOf,
5591
6102
  eventMatches,
5592
6103
  eventNameOf,
5593
6104
  eventPrefix,
5594
6105
  eventToken,
6106
+ heartbeatStatus,
5595
6107
  indexWaitersByRun,
5596
6108
  isEventToken,
6109
+ isLegacyDescriptor,
5597
6110
  isWorkflowControlFlowSignal,
5598
6111
  matchesAttributes,
6112
+ missingCapabilities,
6113
+ negotiate,
5599
6114
  normalizeAttributeRows,
6115
+ normalizeDescriptor,
5600
6116
  parseDuration,
6117
+ planDispatch,
5601
6118
  prevCronFireMs,
5602
6119
  readSearchAttributes,
6120
+ requiredCapabilities,
6121
+ resolveRouting,
5603
6122
  resolveRunWaiting,
5604
6123
  runInWorkflowCtx,
5605
6124
  runSchedules,