@dudousxd/nestjs-durable-core 0.64.0 → 0.66.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
@@ -981,6 +981,9 @@ function normalizeDescriptor(raw) {
981
981
  ],
982
982
  workflows: raw.workflows ?? [],
983
983
  steps: raw.steps ?? [],
984
+ ...raw.registrations !== void 0 ? {
985
+ registrations: raw.registrations
986
+ } : {},
984
987
  ...raw.partition !== void 0 ? {
985
988
  partition: raw.partition
986
989
  } : {},
@@ -991,6 +994,23 @@ function normalizeDescriptor(raw) {
991
994
  };
992
995
  }
993
996
  __name(normalizeDescriptor, "normalizeDescriptor");
997
+ function canonicalizeRegistrations(regs) {
998
+ const canonical = regs.map((r) => ({
999
+ name: r.name,
1000
+ version: r.version ?? null,
1001
+ group: r.group ?? null,
1002
+ requires: r.requires ? [
1003
+ ...new Set(r.requires)
1004
+ ].sort() : null,
1005
+ origin: r.origin ?? null
1006
+ }));
1007
+ const byJson = /* @__PURE__ */ new Map();
1008
+ for (const entry of canonical) byJson.set(JSON.stringify(entry), entry);
1009
+ return [
1010
+ ...byJson.keys()
1011
+ ].sort().map((k) => byJson.get(k));
1012
+ }
1013
+ __name(canonicalizeRegistrations, "canonicalizeRegistrations");
994
1014
  function canonicalizeForHash(d) {
995
1015
  const set = /* @__PURE__ */ __name((xs) => [
996
1016
  ...new Set(xs)
@@ -1012,6 +1032,9 @@ function canonicalizeForHash(d) {
1012
1032
  capabilities: set(d.capabilities),
1013
1033
  workflows: set(d.workflows),
1014
1034
  steps: set(d.steps),
1035
+ ...d.registrations !== void 0 ? {
1036
+ registrations: canonicalizeRegistrations(d.registrations)
1037
+ } : {},
1015
1038
  partition: d.partition ?? null,
1016
1039
  namespace: d.namespace ?? null,
1017
1040
  startedAt: d.startedAt
@@ -1044,6 +1067,276 @@ function heartbeatStatus(descriptor, opts = {}) {
1044
1067
  };
1045
1068
  }
1046
1069
  __name(heartbeatStatus, "heartbeatStatus");
1070
+ function describeWorker(opts) {
1071
+ const version = CURRENT_PROTOCOL_VERSION;
1072
+ const registrations = [
1073
+ ...opts.registrations ?? []
1074
+ ].sort((a, b) => `${a.name}@${a.version ?? ""}`.localeCompare(`${b.name}@${b.version ?? ""}`));
1075
+ return {
1076
+ instanceId: opts.instanceId,
1077
+ runtime: opts.runtime,
1078
+ sdk: {
1079
+ name: opts.sdk.name,
1080
+ version: opts.sdk.version
1081
+ },
1082
+ protocol: {
1083
+ version,
1084
+ range: [
1085
+ version,
1086
+ version
1087
+ ]
1088
+ },
1089
+ capabilities: [
1090
+ ...LEGACY_V1_CAPABILITIES
1091
+ ],
1092
+ workflows: [
1093
+ ...opts.workflows ?? []
1094
+ ].sort(),
1095
+ steps: [
1096
+ ...opts.steps
1097
+ ].sort(),
1098
+ ...registrations.length > 0 ? {
1099
+ registrations
1100
+ } : {},
1101
+ ...opts.partition ? {
1102
+ partition: opts.partition
1103
+ } : {},
1104
+ ...opts.namespace && opts.namespace !== "default" ? {
1105
+ namespace: opts.namespace
1106
+ } : {},
1107
+ startedAt: opts.startedAt
1108
+ };
1109
+ }
1110
+ __name(describeWorker, "describeWorker");
1111
+
1112
+ // src/handshake/announced.ts
1113
+ function announcementsOf(raw) {
1114
+ const d = normalizeDescriptor(raw);
1115
+ const identity = {
1116
+ instanceId: d.instanceId,
1117
+ runtime: d.runtime
1118
+ };
1119
+ const announcements = (d.registrations ?? []).map((reg) => ({
1120
+ ...reg,
1121
+ ...identity
1122
+ }));
1123
+ const covered = new Set(announcements.map((a) => a.name));
1124
+ for (const name of d.workflows) {
1125
+ if (!covered.has(name)) announcements.push({
1126
+ name,
1127
+ ...identity
1128
+ });
1129
+ }
1130
+ return announcements;
1131
+ }
1132
+ __name(announcementsOf, "announcementsOf");
1133
+ function announcementKey(a) {
1134
+ return a.version === void 0 ? a.name : `${a.name}@${a.version}`;
1135
+ }
1136
+ __name(announcementKey, "announcementKey");
1137
+ function stated(values) {
1138
+ return [
1139
+ ...new Set(values.filter((v) => v !== void 0))
1140
+ ].sort();
1141
+ }
1142
+ __name(stated, "stated");
1143
+ function aggregateAnnouncements(descriptors) {
1144
+ const byKey = /* @__PURE__ */ new Map();
1145
+ for (const descriptor of descriptors) {
1146
+ for (const announcement of announcementsOf(descriptor)) {
1147
+ const key = announcementKey(announcement);
1148
+ const bucket = byKey.get(key);
1149
+ if (bucket) bucket.push(announcement);
1150
+ else byKey.set(key, [
1151
+ announcement
1152
+ ]);
1153
+ }
1154
+ }
1155
+ const out = [];
1156
+ for (const [key, announcements] of byKey) {
1157
+ const groups = stated(announcements.map((a) => a.group));
1158
+ const origins = stated(announcements.map((a) => a.origin));
1159
+ const requiresByJson = /* @__PURE__ */ new Map();
1160
+ for (const a of announcements) {
1161
+ if (a.requires === void 0) continue;
1162
+ const canonical = [
1163
+ ...new Set(a.requires)
1164
+ ].sort();
1165
+ requiresByJson.set(JSON.stringify(canonical), canonical);
1166
+ }
1167
+ const requires = [
1168
+ ...requiresByJson.values()
1169
+ ].sort((x, y) => x.join(",").localeCompare(y.join(",")));
1170
+ const disagreements = [];
1171
+ if (groups.length > 1) disagreements.push({
1172
+ axis: "group",
1173
+ values: groups
1174
+ });
1175
+ if (origins.length > 1) disagreements.push({
1176
+ axis: "origin",
1177
+ values: origins
1178
+ });
1179
+ if (requires.length > 1) {
1180
+ disagreements.push({
1181
+ axis: "requires",
1182
+ values: requires.map((r) => r.join(","))
1183
+ });
1184
+ }
1185
+ const first = announcements[0];
1186
+ if (!first) continue;
1187
+ out.push({
1188
+ evidence: "declared",
1189
+ key,
1190
+ name: first.name,
1191
+ ...first.version !== void 0 ? {
1192
+ version: first.version
1193
+ } : {},
1194
+ groups,
1195
+ origins,
1196
+ requires,
1197
+ runtimes: [
1198
+ ...new Set(announcements.map((a) => a.runtime))
1199
+ ].sort(),
1200
+ instances: [
1201
+ ...new Set(announcements.map((a) => a.instanceId))
1202
+ ].sort(),
1203
+ disagreements
1204
+ });
1205
+ }
1206
+ return out.sort((a, b) => a.key.localeCompare(b.key));
1207
+ }
1208
+ __name(aggregateAnnouncements, "aggregateAnnouncements");
1209
+ function observedAnnouncements(heartbeats, covered) {
1210
+ const byToken = /* @__PURE__ */ new Map();
1211
+ for (const beat of heartbeats) {
1212
+ if (!beat.group || covered.has(beat.group)) continue;
1213
+ const instances = byToken.get(beat.group);
1214
+ if (instances) instances.add(beat.instanceId);
1215
+ else byToken.set(beat.group, /* @__PURE__ */ new Set([
1216
+ beat.instanceId
1217
+ ]));
1218
+ }
1219
+ return [
1220
+ ...byToken.entries()
1221
+ ].map(([group, instances]) => ({
1222
+ evidence: "observed",
1223
+ key: group,
1224
+ name: group,
1225
+ groups: [
1226
+ group
1227
+ ],
1228
+ origins: [],
1229
+ requires: [],
1230
+ runtimes: [],
1231
+ instances: [
1232
+ ...instances
1233
+ ].sort(),
1234
+ disagreements: []
1235
+ })).sort((a, b) => a.key.localeCompare(b.key));
1236
+ }
1237
+ __name(observedAnnouncements, "observedAnnouncements");
1238
+ function coveredTokens(announced, descriptors, sanitize) {
1239
+ const tokens = /* @__PURE__ */ new Set();
1240
+ for (const entry of announced) {
1241
+ tokens.add(sanitize(entry.name));
1242
+ for (const group of entry.groups) tokens.add(group);
1243
+ }
1244
+ for (const raw of descriptors) {
1245
+ const d = normalizeDescriptor(raw);
1246
+ for (const step of d.steps) {
1247
+ const token = sanitize(step);
1248
+ tokens.add(token);
1249
+ if (d.partition) tokens.add(`${token}@${d.partition}`);
1250
+ }
1251
+ }
1252
+ return tokens;
1253
+ }
1254
+ __name(coveredTokens, "coveredTokens");
1255
+
1256
+ // src/handshake/directory.ts
1257
+ var DEFAULT_PARTITION = "default";
1258
+ function partitionOfToken(token) {
1259
+ const at = token.lastIndexOf("@");
1260
+ return at === -1 ? DEFAULT_PARTITION : token.slice(at + 1);
1261
+ }
1262
+ __name(partitionOfToken, "partitionOfToken");
1263
+ function buildWorkflowDirectory(input) {
1264
+ const { descriptors, heartbeats, namespace, supported, sanitize } = input;
1265
+ if (!supported) {
1266
+ return {
1267
+ supported: false,
1268
+ workflows: [],
1269
+ otherPartitions: [],
1270
+ detail: "No transport in this process can read what the fleet advertises, so nothing here was asked and this list is empty for that reason alone. Workers may well be running. A workflow can still be called by name."
1271
+ };
1272
+ }
1273
+ const mine = namespace === void 0 ? void 0 : namespace;
1274
+ const ownDescriptors = mine === void 0 ? descriptors : descriptors.filter((d) => (d.namespace ?? DEFAULT_PARTITION) === mine);
1275
+ const ownBeats = [];
1276
+ const foreign = /* @__PURE__ */ new Map();
1277
+ for (const beat of heartbeats) {
1278
+ if (!beat.group) continue;
1279
+ const partition = partitionOfToken(beat.group);
1280
+ if (mine === void 0 || partition === mine) {
1281
+ ownBeats.push(beat);
1282
+ continue;
1283
+ }
1284
+ const seen = foreign.get(partition);
1285
+ if (seen) {
1286
+ seen.groups.add(beat.group);
1287
+ seen.instances.add(beat.instanceId);
1288
+ } else {
1289
+ foreign.set(partition, {
1290
+ groups: /* @__PURE__ */ new Set([
1291
+ beat.group
1292
+ ]),
1293
+ instances: /* @__PURE__ */ new Set([
1294
+ beat.instanceId
1295
+ ])
1296
+ });
1297
+ }
1298
+ }
1299
+ const declared = aggregateAnnouncements(ownDescriptors);
1300
+ const observed = observedAnnouncements(ownBeats, coveredTokens(declared, ownDescriptors, sanitize));
1301
+ const workflows = [
1302
+ ...declared,
1303
+ ...observed
1304
+ ].sort((a, b) => a.key.localeCompare(b.key));
1305
+ const otherPartitions = [
1306
+ ...foreign.entries()
1307
+ ].map(([partition, seen]) => ({
1308
+ partition,
1309
+ groups: [
1310
+ ...seen.groups
1311
+ ].sort(),
1312
+ instances: [
1313
+ ...seen.instances
1314
+ ].sort()
1315
+ })).sort((a, b) => a.partition.localeCompare(b.partition));
1316
+ return {
1317
+ supported: true,
1318
+ workflows,
1319
+ otherPartitions,
1320
+ detail: describeDirectory(workflows, otherPartitions, mine)
1321
+ };
1322
+ }
1323
+ __name(buildWorkflowDirectory, "buildWorkflowDirectory");
1324
+ function summarize(names, limit = 3) {
1325
+ if (names.length <= limit) return names.join(", ");
1326
+ return `${names.slice(0, limit).join(", ")} and ${names.length - limit} more`;
1327
+ }
1328
+ __name(summarize, "summarize");
1329
+ function describeDirectory(workflows, otherPartitions, namespace) {
1330
+ const here = namespace === void 0 ? "this deployment" : `partition "${namespace}"`;
1331
+ const elsewhere = otherPartitions.length === 0 ? "" : ` Live workers were seen on ${otherPartitions.map((p) => `"${p.partition}" (${summarize(p.groups)})`).join(", ")}, which ${here} does not route to \u2014 a worker on the wrong partition looks exactly like no worker at all, so it is named here rather than left out.`;
1332
+ if (workflows.length === 0) {
1333
+ return `The fleet was asked just now and nothing is live in ${here}, so this list is empty because the deployment is empty \u2014 not because the question could not be put.${elsewhere}`;
1334
+ }
1335
+ const observed = workflows.filter((w) => w.evidence === "observed").length;
1336
+ const declaredNote = observed === 0 ? "" : ` ${observed} of them ${observed === 1 ? "is" : "are"} known only from a live queue: the worker serving ${observed === 1 ? "it" : "them"} publishes no description, so ${observed === 1 ? "its" : "their"} version, origin and runtime are genuinely unknown and a version pinned against ${observed === 1 ? "it" : "them"} cannot be checked.`;
1337
+ return `Every workflow a live worker in ${here} can execute, read just now. A worker that stops beating drops off this list within about half a minute.${declaredNote}${elsewhere}`;
1338
+ }
1339
+ __name(describeDirectory, "describeDirectory");
1047
1340
 
1048
1341
  // src/handshake/negotiate.ts
1049
1342
  var rangesOverlap = /* @__PURE__ */ __name((a, b) => Math.max(a[0], b[0]) <= Math.min(a[1], b[1]), "rangesOverlap");
@@ -1571,6 +1864,60 @@ var TransportPool = class {
1571
1864
  }
1572
1865
  return descriptors;
1573
1866
  }
1867
+ /**
1868
+ * Every live descriptor in the deployment, merged across every transport that can report them and
1869
+ * de-duplicated by `instanceId` — one worker publishes the same descriptor under each token it
1870
+ * consumes, and it must count once. Returns `[]` when no transport implements
1871
+ * {@link Transport.readAllWorkerDescriptors} (a pure in-process pool advertises nothing).
1872
+ *
1873
+ * First writer wins on a duplicate id: the copies are the same bytes by construction (one process,
1874
+ * one descriptor, published N times), so there is nothing to choose between. Two DIFFERENT
1875
+ * processes sharing an `instanceId` would collapse into one here — that is a misconfiguration the
1876
+ * fleet already suffers from (they also share heartbeat and descriptor keys, so they overwrite each
1877
+ * other's advertisement), not something this read can repair.
1878
+ */
1879
+ async readAllWorkerDescriptors() {
1880
+ const byInstance = /* @__PURE__ */ new Map();
1881
+ for (const { transport } of this.transports) {
1882
+ if (!transport.readAllWorkerDescriptors) continue;
1883
+ for (const descriptor of await transport.readAllWorkerDescriptors()) {
1884
+ if (!byInstance.has(descriptor.instanceId)) byInstance.set(descriptor.instanceId, descriptor);
1885
+ }
1886
+ }
1887
+ return [
1888
+ ...byInstance.values()
1889
+ ];
1890
+ }
1891
+ /**
1892
+ * Every live worker heartbeat in the deployment, merged across every transport that can report
1893
+ * them and de-duplicated by `(group, instanceId)` — the same worker is one beat per token, and two
1894
+ * transports scanning the same keyspace must not double-count it.
1895
+ *
1896
+ * Returns `[]` when no transport implements {@link Transport.readAllWorkerHeartbeats}, which is
1897
+ * indistinguishable from "nothing is beating" on its own — {@link introspectsFleet} is what tells
1898
+ * those apart, and the directory reads it rather than guessing from an empty array.
1899
+ */
1900
+ async readAllWorkerHeartbeats() {
1901
+ const seen = /* @__PURE__ */ new Map();
1902
+ for (const { transport } of this.transports) {
1903
+ if (!transport.readAllWorkerHeartbeats) continue;
1904
+ for (const beat of await transport.readAllWorkerHeartbeats()) {
1905
+ const key = `${beat.group}\0${beat.instanceId}`;
1906
+ if (!seen.has(key)) seen.set(key, beat);
1907
+ }
1908
+ }
1909
+ return [
1910
+ ...seen.values()
1911
+ ];
1912
+ }
1913
+ /**
1914
+ * Can ANY transport here introspect the fleet — descriptors or heartbeats? This is the difference
1915
+ * between "the fleet is empty" and "nobody asked", and it is a property of the POOL rather than of
1916
+ * a read's result, so it stays answerable when both reads legitimately come back empty.
1917
+ */
1918
+ get introspectsFleet() {
1919
+ return this.transports.some(({ transport }) => transport.readAllWorkerDescriptors || transport.readAllWorkerHeartbeats);
1920
+ }
1574
1921
  /** Distinct worker groups with a live heartbeat, merged across every transport that can report it. */
1575
1922
  async listWorkerGroups() {
1576
1923
  const groups = /* @__PURE__ */ new Set();
@@ -1971,7 +2318,7 @@ function createWorkflowCtx(host, runId, compensations, workflow = "", searchAttr
1971
2318
  return unwrapCompletion(await waitForSignal(`task:${runId}:${name}`), `task "${name}"`);
1972
2319
  }, "task");
1973
2320
  const child = /* @__PURE__ */ __name(async (workflow2, input, options) => {
1974
- const { childId, priority } = normalizeChildOptions(options);
2321
+ const { childId, priority, version } = normalizeChildOptions(options);
1975
2322
  const current = pos.next();
1976
2323
  const id = childId ?? `${runId}.child.${current}`;
1977
2324
  const existing = await readCheckpoint(current);
@@ -1999,7 +2346,7 @@ function createWorkflowCtx(host, runId, compensations, workflow = "", searchAttr
1999
2346
  }));
2000
2347
  return unwrapCompletion(buffered.payload, `child "${id}"`);
2001
2348
  }
2002
- if (!await store.getRun(id)) host.startChild(workflowName(workflow2), input, id, priority);
2349
+ if (!await store.getRun(id)) host.startChild(workflowName(workflow2), input, id, priority, version);
2003
2350
  if (!existing) {
2004
2351
  await writeCheckpoint(instantCheckpoint({
2005
2352
  runId,
@@ -2082,12 +2429,12 @@ function createWorkflowCtx(host, runId, compensations, workflow = "", searchAttr
2082
2429
  return outputs;
2083
2430
  }, "all");
2084
2431
  const startChild = /* @__PURE__ */ __name(async (workflow2, input, options) => {
2085
- const { childId, priority } = normalizeChildOptions(options);
2432
+ const { childId, priority, version } = normalizeChildOptions(options);
2086
2433
  const current = pos.next();
2087
2434
  const id = childId ?? `${runId}.child.${current}`;
2088
2435
  const existing = await readCheckpoint(current);
2089
2436
  if (existing && existing.status === "completed") return existing.output;
2090
- if (!await store.getRun(id)) host.startChild(workflowName(workflow2), input, id, priority);
2437
+ if (!await store.getRun(id)) host.startChild(workflowName(workflow2), input, id, priority, version);
2091
2438
  await writeCheckpoint(instantCheckpoint({
2092
2439
  runId,
2093
2440
  seq: current,
@@ -2335,6 +2682,7 @@ function mergeTags(staticTags, runTags) {
2335
2682
  __name(mergeTags, "mergeTags");
2336
2683
  var BREAKPOINT = "breakpoint";
2337
2684
  var isBreakpoint = /* @__PURE__ */ __name((cp) => cp.status === "pending" && cp.name.startsWith(BREAKPOINT), "isBreakpoint");
2685
+ var VERSION_UNDECLARED_TAG = "version:undeclared";
2338
2686
  var versionKey = /* @__PURE__ */ __name((name, version) => `${name}@${version}`, "versionKey");
2339
2687
  function stepGroup(step, runNamespace) {
2340
2688
  return tenantGroup(sanitizeQueueToken(step.name), step.partition ?? runNamespace);
@@ -2647,6 +2995,15 @@ var WorkflowEngine = class {
2647
2995
  const reg = this.workflows.get(versionKey(name, version));
2648
2996
  return reg?.hasBody ? reg.fn : void 0;
2649
2997
  }
2998
+ /** The registered versions of `name`, for the pinned-start error — "2 is not registered" is a
2999
+ * guess-the-typo error without them, and the answer (which versions this deploy actually carries)
3000
+ * is right here. `none` when the name is unknown entirely. */
3001
+ registeredVersions(name) {
3002
+ const versions = [
3003
+ ...this.workflows.values()
3004
+ ].filter((w) => w.name === name).map((w) => w.version);
3005
+ return versions.length ? versions.join(", ") : "none";
3006
+ }
2650
3007
  /**
2651
3008
  * Register a workflow whose body runs in another SDK (e.g. Python). The engine owns the run exactly
2652
3009
  * as for a TS workflow — it persists checkpoints, recovers, runs timers — but advances it by handing
@@ -2796,12 +3153,13 @@ var WorkflowEngine = class {
2796
3153
  }
2797
3154
  async start(workflow, input, runId, opts) {
2798
3155
  const name = workflowName(workflow);
2799
- let registered = this.latest.get(name);
2800
- if (!registered) {
3156
+ const pinned = opts?.version;
3157
+ let registered = pinned === void 0 ? this.latest.get(name) : this.workflows.get(versionKey(name, pinned));
3158
+ if (!registered && pinned === void 0) {
2801
3159
  const ancestor = await this.findRemoteAncestor(runId);
2802
3160
  if (ancestor) registered = this.synthesizeRemoteChild(name, ancestor.version, ancestor);
2803
3161
  }
2804
- if (!registered) {
3162
+ if (!registered && pinned === void 0) {
2805
3163
  const conventionRun = {
2806
3164
  id: runId,
2807
3165
  workflow: name,
@@ -2816,9 +3174,13 @@ var WorkflowEngine = class {
2816
3174
  createdAt: /* @__PURE__ */ new Date(),
2817
3175
  updatedAt: /* @__PURE__ */ new Date()
2818
3176
  };
2819
- registered = await this.resolveRemoteByConvention(conventionRun);
3177
+ registered = await this.resolveRemoteByConvention(conventionRun, {
3178
+ askFleetForVersion: true
3179
+ });
3180
+ }
3181
+ if (!registered) {
3182
+ throw new Error(pinned === void 0 ? `workflow ${name} is not registered` : `workflow ${versionKey(name, pinned)} is not registered \u2014 a pinned start never falls back to the newest version (registered: ${this.registeredVersions(name)})`);
2820
3183
  }
2821
- if (!registered) throw new Error(`workflow ${name} is not registered`);
2822
3184
  await registered.validateInput?.(input);
2823
3185
  const prior = await this.store.getRun(runId);
2824
3186
  if (prior) {
@@ -2830,10 +3192,16 @@ var WorkflowEngine = class {
2830
3192
  };
2831
3193
  }
2832
3194
  const now = /* @__PURE__ */ new Date();
2833
- const tags = mergeTags(registered.tags, registered.singleton ? [
3195
+ const stampedTags = [
2834
3196
  ...opts?.tags ?? [],
2835
- this.singletons.tag(registered.singleton, input)
2836
- ] : opts?.tags);
3197
+ ...registered.singleton ? [
3198
+ this.singletons.tag(registered.singleton, input)
3199
+ ] : [],
3200
+ ...registered.versionDeclared === false ? [
3201
+ VERSION_UNDECLARED_TAG
3202
+ ] : []
3203
+ ];
3204
+ const tags = mergeTags(registered.tags, stampedTags);
2837
3205
  if (registered.singleton) {
2838
3206
  await this.singletons.assertCapacity(name, registered.singleton, input);
2839
3207
  }
@@ -2943,14 +3311,15 @@ var WorkflowEngine = class {
2943
3311
  * Recomputed per call (never memoized into `this.workflows`) so it stays correct if group
2944
3312
  * liveness changes between calls, exactly as {@link synthesizeRemoteChild} does.
2945
3313
  */
2946
- async resolveRemoteByConvention(run) {
3314
+ async resolveRemoteByConvention(run, opts = {}) {
2947
3315
  const group = tenantGroup(sanitizeQueueToken(run.workflow), run.namespace);
2948
3316
  const liveTransport = await this.pool.transportWithLiveGroup(group);
2949
3317
  if (!liveTransport) return void 0;
2950
3318
  const executor = new RemoteWorkflowExecutor(liveTransport, run.workflow, run.namespace);
3319
+ const declared = opts.askFleetForVersion ? await this.declaredVersion(liveTransport, group, run.workflow) : void 0;
2951
3320
  return {
2952
3321
  name: run.workflow,
2953
- version: run.workflowVersion,
3322
+ version: declared ?? run.workflowVersion,
2954
3323
  fn: /* @__PURE__ */ __name(() => {
2955
3324
  throw new Error(`remote workflow ${run.workflow} has no local body`);
2956
3325
  }, "fn"),
@@ -2958,10 +3327,46 @@ var WorkflowEngine = class {
2958
3327
  remote: {
2959
3328
  group,
2960
3329
  executor
2961
- }
3330
+ },
3331
+ ...opts.askFleetForVersion ? {
3332
+ versionDeclared: declared !== void 0
3333
+ } : {}
2962
3334
  };
2963
3335
  }
2964
3336
  /**
3337
+ * The version the LIVE fleet declares for `workflow` on `group`, or `undefined` when it declares
3338
+ * none — which is a fact about the fleet, not a failure to read it.
3339
+ *
3340
+ * The read is the descriptor advertisement, scoped to the one group being resolved: the workers on
3341
+ * that queue, their `registrations`, the entry whose `name` matches. That is where a version can
3342
+ * come from at all — a heartbeat has no room for one, so a worker on an SDK too old to publish a
3343
+ * descriptor declares nothing and the caller is told so rather than handed a default.
3344
+ *
3345
+ * `undefined` when the announcers DISAGREE, and deliberately: two live workers claiming different
3346
+ * versions of one name is a deployment in the middle of something, and picking either would stamp
3347
+ * a run with a version half the fleet does not serve. Undeclared is the honest reading of a
3348
+ * contradiction, and it is the reading that refuses to let a pin pass on a coin flip.
3349
+ */
3350
+ async declaredVersion(transport, group, workflow) {
3351
+ if (!transport.readWorkerDescriptors) return void 0;
3352
+ let descriptors;
3353
+ try {
3354
+ descriptors = await transport.readWorkerDescriptors(group);
3355
+ } catch {
3356
+ return void 0;
3357
+ }
3358
+ const versions = /* @__PURE__ */ new Set();
3359
+ for (const announced of aggregateAnnouncements(descriptors)) {
3360
+ if (announced.name === workflow && announced.version !== void 0) {
3361
+ versions.add(announced.version);
3362
+ }
3363
+ }
3364
+ const only = [
3365
+ ...versions
3366
+ ];
3367
+ return only.length === 1 ? only[0] : void 0;
3368
+ }
3369
+ /**
2965
3370
  * Walk the parent chain of `childRunId` to the nearest ancestor registered as REMOTE, returning its
2966
3371
  * registration (or `undefined` if there is none). The parent of an awaited child is found via its
2967
3372
  * live `child:<childId>` signal waiter — present for as long as the parent is suspended awaiting the
@@ -3025,7 +3430,32 @@ var WorkflowEngine = class {
3025
3430
  /**
3026
3431
  * Cancel in-flight runs that have outlived their workflow's `executionTimeout`. Call it from the
3027
3432
  * timer poller alongside {@link resumeDueTimers}. A timed-out run is moved to `cancelled` with an
3028
- * `execution_timeout` error (terminal, so a late step result can't resurrect it).
3433
+ * `execution_timeout` error (terminal, so a late step result can't resurrect it), AND its children
3434
+ * are cascaded exactly as an explicit {@link cancel} cascades them.
3435
+ *
3436
+ * WHY THE TERMINAL WRITE IS DIRECT, not a call to {@link cancel}. Two reasons, both still standing:
3437
+ * the run must land with the `execution_timeout` error `cancel` knows nothing about (`cancel` writes
3438
+ * a plain `cancelled`, and the code is what a dashboard/alert distinguishes a timeout by), and this
3439
+ * is a SCAN — it walks every in-flight run of every timed workflow on every poller tick, so it must
3440
+ * not pay `cancel`'s per-run re-read and singleton/control-plane work on runs it isn't cancelling.
3441
+ * The cascade below is charged only to the runs that ACTUALLY timed out, which are rare by
3442
+ * construction; the scan itself is unchanged.
3443
+ *
3444
+ * The subtree, though, has no reason to differ, and before this it did: a timed-out parent's
3445
+ * children kept running with the parent gone and nothing pointing at them — a silent orphan you
3446
+ * could only find by reading the runs table by hand. {@link cancelChildren} is the SAME recursive
3447
+ * walk `cancel` uses, so children of children go too, and it inherits that path's two guards:
3448
+ * `cancel` returns early on a run that is already `completed`/`cancelled`/`dead` (a child that beat
3449
+ * the sweep to the finish is never clobbered, and a re-cancel is a no-op), and it writes the
3450
+ * parent's terminal status BEFORE recursing, so a cyclic parent→child graph terminates on the
3451
+ * second visit instead of looping. Two pollers sweeping the same run concurrently therefore write
3452
+ * the same terminal state twice and cascade into already-cancelled children — idempotent, not
3453
+ * corrupting. The one window it does NOT close is the pre-existing check-then-write race an
3454
+ * explicit `cancel` has always had (a child that completes between `cancel`'s read and its write);
3455
+ * closing that needs a compare-and-set in the store contract, not a change here.
3456
+ *
3457
+ * Cascade failures are swallowed per subtree for the same reason `cancelChildren` swallows them per
3458
+ * child: one unreachable child must not abort the sweep and leave every LATER timed-out run alive.
3029
3459
  */
3030
3460
  async sweepTimeouts(now = this.clock()) {
3031
3461
  for (const reg of new Set(this.latest.values())) {
@@ -3061,6 +3491,7 @@ var WorkflowEngine = class {
3061
3491
  namespace: run.namespace,
3062
3492
  error: error2
3063
3493
  });
3494
+ await this.cancelChildren(run.id).catch(() => void 0);
3064
3495
  }
3065
3496
  }
3066
3497
  }
@@ -3789,6 +4220,10 @@ var WorkflowEngine = class {
3789
4220
  * `child:<id>` waiter) and fire-and-forget (`ctx.startChild`, found via its `spawn:<id>`
3790
4221
  * checkpoint). Recursive, so a whole subtree is cancelled; the terminal guard in `cancel` stops it
3791
4222
  * at finished / already-cancelled runs (no loops, no re-cancel).
4223
+ *
4224
+ * Two callers, deliberately the same walk: an explicit {@link cancel}, and {@link sweepTimeouts}
4225
+ * when a parent hits its `executionTimeout` — a timed-out parent's subtree dies the same way an
4226
+ * explicitly cancelled one's does, or the children are orphans nobody is holding a reference to.
3792
4227
  */
3793
4228
  async cancelChildren(parentRunId, opts) {
3794
4229
  for (const id of await this.getRunChildren(parentRunId)) {
@@ -3868,6 +4303,50 @@ var WorkflowEngine = class {
3868
4303
  return out;
3869
4304
  }
3870
4305
  /**
4306
+ * What workflows the LIVE fleet announces it can execute — the deployment-wide registry a picker
4307
+ * ("call this existing workflow") needs, and the one question this engine's own registry cannot
4308
+ * answer. {@link workflowBody} answers only for this process, and a missing body is ambiguous: it
4309
+ * means "not registered here", but equally "registered via {@link registerRemote} against another
4310
+ * SDK" or "a group resolved by convention against a worker". An inferred answer would therefore
4311
+ * differ per replica. This one does not: every pod folds the same published announcements.
4312
+ *
4313
+ * Scoped like every other poll surface — an engine WITH a `namespace` sees only workers in it, and
4314
+ * an operator (namespace unset) sees the whole deployment ("ver tudo = ausência de namespace"). A
4315
+ * descriptor that states no namespace is `default`, matching how a single-tenant fleet publishes.
4316
+ *
4317
+ * Cost: one advertisement scan per CALL, on demand. Nothing is added to the poll loop and nothing
4318
+ * is retained — no pod holds a table of the fleet's registrations. Empty when no transport can
4319
+ * introspect the advertisement keyspace (a pure in-process transport advertises nothing).
4320
+ *
4321
+ * Workflows only, and deliberately: see the "steps are out of scope" note in `handshake/announced`.
4322
+ */
4323
+ async announcedWorkflows() {
4324
+ return (await this.workflowDirectory()).workflows;
4325
+ }
4326
+ /**
4327
+ * {@link announcedWorkflows}, plus the thing a list of workflows can never say: why it is the
4328
+ * length it is. See {@link WorkflowDirectory} for the three states an empty array used to collapse
4329
+ * into one — "nobody asked", "nothing is live", and the partition mismatch that reads exactly like
4330
+ * the second from the caller's side.
4331
+ *
4332
+ * Two live reads, both on demand and neither retained: the descriptor advertisement (what workers
4333
+ * SAY they serve) and the heartbeat keyspace (what is demonstrably consuming a queue right now).
4334
+ * The second is why a worker on an SDK too old to publish a descriptor appears here at all — it
4335
+ * beats and never describes itself, and reading only descriptors reported such a fleet as empty
4336
+ * while `resolveRemoteByConvention`, a few lines away in this same class, was routing calls to it
4337
+ * off those very keys. Two answers out of one Redis, disagreeing. Now they agree, and the entry
4338
+ * says which of the two kinds of evidence it rests on rather than flattening them together.
4339
+ */
4340
+ async workflowDirectory() {
4341
+ return buildWorkflowDirectory({
4342
+ descriptors: await this.pool.readAllWorkerDescriptors(),
4343
+ heartbeats: await this.pool.readAllWorkerHeartbeats(),
4344
+ namespace: this.namespace,
4345
+ supported: this.pool.introspectsFleet,
4346
+ sanitize: sanitizeQueueToken
4347
+ });
4348
+ }
4349
+ /**
3871
4350
  * The routing groups of every in-flight run's still-`pending` remote step — the queues where work is
3872
4351
  * dispatched and awaiting a worker. Folded into {@link workerHealth} so a step waiting on a group
3873
4352
  * that is NEITHER a registration NOR heartbeating (the exact shape of a run stuck on an offline
@@ -4946,10 +5425,13 @@ var WorkflowEngine = class {
4946
5425
  // namespace) is the one executing the parent. Otherwise an operator recovery-resuming a
4947
5426
  // `davi-local` pipeline would stamp its `processing` child `default` and it would leak off the
4948
5427
  // tenant's worker pool onto the shared/dev workers.
4949
- startChild: /* @__PURE__ */ __name((workflow, input, id, priority) => {
5428
+ startChild: /* @__PURE__ */ __name((workflow, input, id, priority, version) => {
4950
5429
  this.startChildDeferred(workflow, input, id, {
4951
5430
  priority,
4952
- namespace: parentNamespace
5431
+ namespace: parentNamespace,
5432
+ // Absent unless the body pinned one (`ctx.child(ref, input, { version })`) — an omitted
5433
+ // pin must stay omitted, so the child keeps resolving `latest` exactly as before.
5434
+ version
4953
5435
  });
4954
5436
  }, "startChild"),
4955
5437
  // Deferred for the same reentrancy reason as `startChild` above. `cancel()` is already
@@ -6323,6 +6805,7 @@ export {
6323
6805
  CURRENT_PROTOCOL_VERSION,
6324
6806
  CodecStateStore,
6325
6807
  ContinueAsNew,
6808
+ DEFAULT_PARTITION,
6326
6809
  DURABLE_OPTIONS,
6327
6810
  DURABLE_OPTIONS_CANONICAL,
6328
6811
  DURABLE_STEP_CONFIG,
@@ -6353,25 +6836,31 @@ export {
6353
6836
  TERMINAL_RUN_STATUSES,
6354
6837
  TRANSPORT,
6355
6838
  TRANSPORT_CANONICAL,
6839
+ VERSION_UNDECLARED_TAG,
6356
6840
  WORKFLOW_NAME_KEY,
6357
6841
  WorkflowBlocked,
6358
6842
  WorkflowEngine,
6359
6843
  WorkflowSuspended,
6844
+ aggregateAnnouncements,
6845
+ announcementsOf,
6360
6846
  applyAttributeQuery,
6361
6847
  attributeColumnFor,
6362
6848
  attributeOperand,
6363
6849
  bindWorkflowClass,
6364
6850
  breakpointToken,
6851
+ buildWorkflowDirectory,
6365
6852
  canRoute,
6366
6853
  capableWorkers,
6367
6854
  classifyWaiterToken,
6368
6855
  clearDurableExecutionWrappers,
6369
6856
  collectMetrics,
6370
6857
  controlPlaneDescriptor,
6858
+ coveredTokens,
6371
6859
  createStepLogger,
6372
6860
  currentStep,
6373
6861
  currentWorkflowCtx,
6374
6862
  debug,
6863
+ describeWorker,
6375
6864
  descriptorHash,
6376
6865
  error,
6377
6866
  eventMatchOf,
@@ -6392,7 +6881,9 @@ export {
6392
6881
  negotiate,
6393
6882
  normalizeAttributeRows,
6394
6883
  normalizeDescriptor,
6884
+ observedAnnouncements,
6395
6885
  parseDuration,
6886
+ partitionOfToken,
6396
6887
  planDispatch,
6397
6888
  prevCronFireMs,
6398
6889
  readSearchAttributes,