@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.cjs CHANGED
@@ -26,6 +26,7 @@ __export(index_exports, {
26
26
  CURRENT_PROTOCOL_VERSION: () => CURRENT_PROTOCOL_VERSION,
27
27
  CodecStateStore: () => CodecStateStore,
28
28
  ContinueAsNew: () => ContinueAsNew,
29
+ DEFAULT_PARTITION: () => DEFAULT_PARTITION,
29
30
  DURABLE_OPTIONS: () => DURABLE_OPTIONS,
30
31
  DURABLE_OPTIONS_CANONICAL: () => DURABLE_OPTIONS_CANONICAL,
31
32
  DURABLE_STEP_CONFIG: () => DURABLE_STEP_CONFIG,
@@ -56,25 +57,31 @@ __export(index_exports, {
56
57
  TERMINAL_RUN_STATUSES: () => TERMINAL_RUN_STATUSES,
57
58
  TRANSPORT: () => TRANSPORT,
58
59
  TRANSPORT_CANONICAL: () => TRANSPORT_CANONICAL,
60
+ VERSION_UNDECLARED_TAG: () => VERSION_UNDECLARED_TAG,
59
61
  WORKFLOW_NAME_KEY: () => WORKFLOW_NAME_KEY,
60
62
  WorkflowBlocked: () => WorkflowBlocked,
61
63
  WorkflowEngine: () => WorkflowEngine,
62
64
  WorkflowSuspended: () => WorkflowSuspended,
65
+ aggregateAnnouncements: () => aggregateAnnouncements,
66
+ announcementsOf: () => announcementsOf,
63
67
  applyAttributeQuery: () => applyAttributeQuery,
64
68
  attributeColumnFor: () => attributeColumnFor,
65
69
  attributeOperand: () => attributeOperand,
66
70
  bindWorkflowClass: () => bindWorkflowClass,
67
71
  breakpointToken: () => breakpointToken,
72
+ buildWorkflowDirectory: () => buildWorkflowDirectory,
68
73
  canRoute: () => canRoute,
69
74
  capableWorkers: () => capableWorkers,
70
75
  classifyWaiterToken: () => classifyWaiterToken,
71
76
  clearDurableExecutionWrappers: () => clearDurableExecutionWrappers,
72
77
  collectMetrics: () => collectMetrics,
73
78
  controlPlaneDescriptor: () => controlPlaneDescriptor,
79
+ coveredTokens: () => coveredTokens,
74
80
  createStepLogger: () => createStepLogger,
75
81
  currentStep: () => currentStep,
76
82
  currentWorkflowCtx: () => currentWorkflowCtx,
77
83
  debug: () => debug,
84
+ describeWorker: () => describeWorker,
78
85
  descriptorHash: () => descriptorHash,
79
86
  error: () => error,
80
87
  eventMatchOf: () => eventMatchOf,
@@ -95,7 +102,9 @@ __export(index_exports, {
95
102
  negotiate: () => negotiate,
96
103
  normalizeAttributeRows: () => normalizeAttributeRows,
97
104
  normalizeDescriptor: () => normalizeDescriptor,
105
+ observedAnnouncements: () => observedAnnouncements,
98
106
  parseDuration: () => parseDuration,
107
+ partitionOfToken: () => partitionOfToken,
99
108
  planDispatch: () => planDispatch,
100
109
  prevCronFireMs: () => prevCronFireMs,
101
110
  readSearchAttributes: () => readSearchAttributes,
@@ -1103,6 +1112,9 @@ function normalizeDescriptor(raw) {
1103
1112
  ],
1104
1113
  workflows: raw.workflows ?? [],
1105
1114
  steps: raw.steps ?? [],
1115
+ ...raw.registrations !== void 0 ? {
1116
+ registrations: raw.registrations
1117
+ } : {},
1106
1118
  ...raw.partition !== void 0 ? {
1107
1119
  partition: raw.partition
1108
1120
  } : {},
@@ -1113,6 +1125,23 @@ function normalizeDescriptor(raw) {
1113
1125
  };
1114
1126
  }
1115
1127
  __name(normalizeDescriptor, "normalizeDescriptor");
1128
+ function canonicalizeRegistrations(regs) {
1129
+ const canonical = regs.map((r) => ({
1130
+ name: r.name,
1131
+ version: r.version ?? null,
1132
+ group: r.group ?? null,
1133
+ requires: r.requires ? [
1134
+ ...new Set(r.requires)
1135
+ ].sort() : null,
1136
+ origin: r.origin ?? null
1137
+ }));
1138
+ const byJson = /* @__PURE__ */ new Map();
1139
+ for (const entry of canonical) byJson.set(JSON.stringify(entry), entry);
1140
+ return [
1141
+ ...byJson.keys()
1142
+ ].sort().map((k) => byJson.get(k));
1143
+ }
1144
+ __name(canonicalizeRegistrations, "canonicalizeRegistrations");
1116
1145
  function canonicalizeForHash(d) {
1117
1146
  const set = /* @__PURE__ */ __name((xs) => [
1118
1147
  ...new Set(xs)
@@ -1134,6 +1163,9 @@ function canonicalizeForHash(d) {
1134
1163
  capabilities: set(d.capabilities),
1135
1164
  workflows: set(d.workflows),
1136
1165
  steps: set(d.steps),
1166
+ ...d.registrations !== void 0 ? {
1167
+ registrations: canonicalizeRegistrations(d.registrations)
1168
+ } : {},
1137
1169
  partition: d.partition ?? null,
1138
1170
  namespace: d.namespace ?? null,
1139
1171
  startedAt: d.startedAt
@@ -1166,6 +1198,276 @@ function heartbeatStatus(descriptor, opts = {}) {
1166
1198
  };
1167
1199
  }
1168
1200
  __name(heartbeatStatus, "heartbeatStatus");
1201
+ function describeWorker(opts) {
1202
+ const version = CURRENT_PROTOCOL_VERSION;
1203
+ const registrations = [
1204
+ ...opts.registrations ?? []
1205
+ ].sort((a, b) => `${a.name}@${a.version ?? ""}`.localeCompare(`${b.name}@${b.version ?? ""}`));
1206
+ return {
1207
+ instanceId: opts.instanceId,
1208
+ runtime: opts.runtime,
1209
+ sdk: {
1210
+ name: opts.sdk.name,
1211
+ version: opts.sdk.version
1212
+ },
1213
+ protocol: {
1214
+ version,
1215
+ range: [
1216
+ version,
1217
+ version
1218
+ ]
1219
+ },
1220
+ capabilities: [
1221
+ ...LEGACY_V1_CAPABILITIES
1222
+ ],
1223
+ workflows: [
1224
+ ...opts.workflows ?? []
1225
+ ].sort(),
1226
+ steps: [
1227
+ ...opts.steps
1228
+ ].sort(),
1229
+ ...registrations.length > 0 ? {
1230
+ registrations
1231
+ } : {},
1232
+ ...opts.partition ? {
1233
+ partition: opts.partition
1234
+ } : {},
1235
+ ...opts.namespace && opts.namespace !== "default" ? {
1236
+ namespace: opts.namespace
1237
+ } : {},
1238
+ startedAt: opts.startedAt
1239
+ };
1240
+ }
1241
+ __name(describeWorker, "describeWorker");
1242
+
1243
+ // src/handshake/announced.ts
1244
+ function announcementsOf(raw) {
1245
+ const d = normalizeDescriptor(raw);
1246
+ const identity = {
1247
+ instanceId: d.instanceId,
1248
+ runtime: d.runtime
1249
+ };
1250
+ const announcements = (d.registrations ?? []).map((reg) => ({
1251
+ ...reg,
1252
+ ...identity
1253
+ }));
1254
+ const covered = new Set(announcements.map((a) => a.name));
1255
+ for (const name of d.workflows) {
1256
+ if (!covered.has(name)) announcements.push({
1257
+ name,
1258
+ ...identity
1259
+ });
1260
+ }
1261
+ return announcements;
1262
+ }
1263
+ __name(announcementsOf, "announcementsOf");
1264
+ function announcementKey(a) {
1265
+ return a.version === void 0 ? a.name : `${a.name}@${a.version}`;
1266
+ }
1267
+ __name(announcementKey, "announcementKey");
1268
+ function stated(values) {
1269
+ return [
1270
+ ...new Set(values.filter((v) => v !== void 0))
1271
+ ].sort();
1272
+ }
1273
+ __name(stated, "stated");
1274
+ function aggregateAnnouncements(descriptors) {
1275
+ const byKey = /* @__PURE__ */ new Map();
1276
+ for (const descriptor of descriptors) {
1277
+ for (const announcement of announcementsOf(descriptor)) {
1278
+ const key = announcementKey(announcement);
1279
+ const bucket = byKey.get(key);
1280
+ if (bucket) bucket.push(announcement);
1281
+ else byKey.set(key, [
1282
+ announcement
1283
+ ]);
1284
+ }
1285
+ }
1286
+ const out = [];
1287
+ for (const [key, announcements] of byKey) {
1288
+ const groups = stated(announcements.map((a) => a.group));
1289
+ const origins = stated(announcements.map((a) => a.origin));
1290
+ const requiresByJson = /* @__PURE__ */ new Map();
1291
+ for (const a of announcements) {
1292
+ if (a.requires === void 0) continue;
1293
+ const canonical = [
1294
+ ...new Set(a.requires)
1295
+ ].sort();
1296
+ requiresByJson.set(JSON.stringify(canonical), canonical);
1297
+ }
1298
+ const requires = [
1299
+ ...requiresByJson.values()
1300
+ ].sort((x, y) => x.join(",").localeCompare(y.join(",")));
1301
+ const disagreements = [];
1302
+ if (groups.length > 1) disagreements.push({
1303
+ axis: "group",
1304
+ values: groups
1305
+ });
1306
+ if (origins.length > 1) disagreements.push({
1307
+ axis: "origin",
1308
+ values: origins
1309
+ });
1310
+ if (requires.length > 1) {
1311
+ disagreements.push({
1312
+ axis: "requires",
1313
+ values: requires.map((r) => r.join(","))
1314
+ });
1315
+ }
1316
+ const first = announcements[0];
1317
+ if (!first) continue;
1318
+ out.push({
1319
+ evidence: "declared",
1320
+ key,
1321
+ name: first.name,
1322
+ ...first.version !== void 0 ? {
1323
+ version: first.version
1324
+ } : {},
1325
+ groups,
1326
+ origins,
1327
+ requires,
1328
+ runtimes: [
1329
+ ...new Set(announcements.map((a) => a.runtime))
1330
+ ].sort(),
1331
+ instances: [
1332
+ ...new Set(announcements.map((a) => a.instanceId))
1333
+ ].sort(),
1334
+ disagreements
1335
+ });
1336
+ }
1337
+ return out.sort((a, b) => a.key.localeCompare(b.key));
1338
+ }
1339
+ __name(aggregateAnnouncements, "aggregateAnnouncements");
1340
+ function observedAnnouncements(heartbeats, covered) {
1341
+ const byToken = /* @__PURE__ */ new Map();
1342
+ for (const beat of heartbeats) {
1343
+ if (!beat.group || covered.has(beat.group)) continue;
1344
+ const instances = byToken.get(beat.group);
1345
+ if (instances) instances.add(beat.instanceId);
1346
+ else byToken.set(beat.group, /* @__PURE__ */ new Set([
1347
+ beat.instanceId
1348
+ ]));
1349
+ }
1350
+ return [
1351
+ ...byToken.entries()
1352
+ ].map(([group, instances]) => ({
1353
+ evidence: "observed",
1354
+ key: group,
1355
+ name: group,
1356
+ groups: [
1357
+ group
1358
+ ],
1359
+ origins: [],
1360
+ requires: [],
1361
+ runtimes: [],
1362
+ instances: [
1363
+ ...instances
1364
+ ].sort(),
1365
+ disagreements: []
1366
+ })).sort((a, b) => a.key.localeCompare(b.key));
1367
+ }
1368
+ __name(observedAnnouncements, "observedAnnouncements");
1369
+ function coveredTokens(announced, descriptors, sanitize) {
1370
+ const tokens = /* @__PURE__ */ new Set();
1371
+ for (const entry of announced) {
1372
+ tokens.add(sanitize(entry.name));
1373
+ for (const group of entry.groups) tokens.add(group);
1374
+ }
1375
+ for (const raw of descriptors) {
1376
+ const d = normalizeDescriptor(raw);
1377
+ for (const step of d.steps) {
1378
+ const token = sanitize(step);
1379
+ tokens.add(token);
1380
+ if (d.partition) tokens.add(`${token}@${d.partition}`);
1381
+ }
1382
+ }
1383
+ return tokens;
1384
+ }
1385
+ __name(coveredTokens, "coveredTokens");
1386
+
1387
+ // src/handshake/directory.ts
1388
+ var DEFAULT_PARTITION = "default";
1389
+ function partitionOfToken(token) {
1390
+ const at = token.lastIndexOf("@");
1391
+ return at === -1 ? DEFAULT_PARTITION : token.slice(at + 1);
1392
+ }
1393
+ __name(partitionOfToken, "partitionOfToken");
1394
+ function buildWorkflowDirectory(input) {
1395
+ const { descriptors, heartbeats, namespace, supported, sanitize } = input;
1396
+ if (!supported) {
1397
+ return {
1398
+ supported: false,
1399
+ workflows: [],
1400
+ otherPartitions: [],
1401
+ 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."
1402
+ };
1403
+ }
1404
+ const mine = namespace === void 0 ? void 0 : namespace;
1405
+ const ownDescriptors = mine === void 0 ? descriptors : descriptors.filter((d) => (d.namespace ?? DEFAULT_PARTITION) === mine);
1406
+ const ownBeats = [];
1407
+ const foreign = /* @__PURE__ */ new Map();
1408
+ for (const beat of heartbeats) {
1409
+ if (!beat.group) continue;
1410
+ const partition = partitionOfToken(beat.group);
1411
+ if (mine === void 0 || partition === mine) {
1412
+ ownBeats.push(beat);
1413
+ continue;
1414
+ }
1415
+ const seen = foreign.get(partition);
1416
+ if (seen) {
1417
+ seen.groups.add(beat.group);
1418
+ seen.instances.add(beat.instanceId);
1419
+ } else {
1420
+ foreign.set(partition, {
1421
+ groups: /* @__PURE__ */ new Set([
1422
+ beat.group
1423
+ ]),
1424
+ instances: /* @__PURE__ */ new Set([
1425
+ beat.instanceId
1426
+ ])
1427
+ });
1428
+ }
1429
+ }
1430
+ const declared = aggregateAnnouncements(ownDescriptors);
1431
+ const observed = observedAnnouncements(ownBeats, coveredTokens(declared, ownDescriptors, sanitize));
1432
+ const workflows = [
1433
+ ...declared,
1434
+ ...observed
1435
+ ].sort((a, b) => a.key.localeCompare(b.key));
1436
+ const otherPartitions = [
1437
+ ...foreign.entries()
1438
+ ].map(([partition, seen]) => ({
1439
+ partition,
1440
+ groups: [
1441
+ ...seen.groups
1442
+ ].sort(),
1443
+ instances: [
1444
+ ...seen.instances
1445
+ ].sort()
1446
+ })).sort((a, b) => a.partition.localeCompare(b.partition));
1447
+ return {
1448
+ supported: true,
1449
+ workflows,
1450
+ otherPartitions,
1451
+ detail: describeDirectory(workflows, otherPartitions, mine)
1452
+ };
1453
+ }
1454
+ __name(buildWorkflowDirectory, "buildWorkflowDirectory");
1455
+ function summarize(names, limit = 3) {
1456
+ if (names.length <= limit) return names.join(", ");
1457
+ return `${names.slice(0, limit).join(", ")} and ${names.length - limit} more`;
1458
+ }
1459
+ __name(summarize, "summarize");
1460
+ function describeDirectory(workflows, otherPartitions, namespace) {
1461
+ const here = namespace === void 0 ? "this deployment" : `partition "${namespace}"`;
1462
+ 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.`;
1463
+ if (workflows.length === 0) {
1464
+ 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}`;
1465
+ }
1466
+ const observed = workflows.filter((w) => w.evidence === "observed").length;
1467
+ 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.`;
1468
+ 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}`;
1469
+ }
1470
+ __name(describeDirectory, "describeDirectory");
1169
1471
 
1170
1472
  // src/handshake/negotiate.ts
1171
1473
  var rangesOverlap = /* @__PURE__ */ __name((a, b) => Math.max(a[0], b[0]) <= Math.min(a[1], b[1]), "rangesOverlap");
@@ -1693,6 +1995,60 @@ var TransportPool = class {
1693
1995
  }
1694
1996
  return descriptors;
1695
1997
  }
1998
+ /**
1999
+ * Every live descriptor in the deployment, merged across every transport that can report them and
2000
+ * de-duplicated by `instanceId` — one worker publishes the same descriptor under each token it
2001
+ * consumes, and it must count once. Returns `[]` when no transport implements
2002
+ * {@link Transport.readAllWorkerDescriptors} (a pure in-process pool advertises nothing).
2003
+ *
2004
+ * First writer wins on a duplicate id: the copies are the same bytes by construction (one process,
2005
+ * one descriptor, published N times), so there is nothing to choose between. Two DIFFERENT
2006
+ * processes sharing an `instanceId` would collapse into one here — that is a misconfiguration the
2007
+ * fleet already suffers from (they also share heartbeat and descriptor keys, so they overwrite each
2008
+ * other's advertisement), not something this read can repair.
2009
+ */
2010
+ async readAllWorkerDescriptors() {
2011
+ const byInstance = /* @__PURE__ */ new Map();
2012
+ for (const { transport } of this.transports) {
2013
+ if (!transport.readAllWorkerDescriptors) continue;
2014
+ for (const descriptor of await transport.readAllWorkerDescriptors()) {
2015
+ if (!byInstance.has(descriptor.instanceId)) byInstance.set(descriptor.instanceId, descriptor);
2016
+ }
2017
+ }
2018
+ return [
2019
+ ...byInstance.values()
2020
+ ];
2021
+ }
2022
+ /**
2023
+ * Every live worker heartbeat in the deployment, merged across every transport that can report
2024
+ * them and de-duplicated by `(group, instanceId)` — the same worker is one beat per token, and two
2025
+ * transports scanning the same keyspace must not double-count it.
2026
+ *
2027
+ * Returns `[]` when no transport implements {@link Transport.readAllWorkerHeartbeats}, which is
2028
+ * indistinguishable from "nothing is beating" on its own — {@link introspectsFleet} is what tells
2029
+ * those apart, and the directory reads it rather than guessing from an empty array.
2030
+ */
2031
+ async readAllWorkerHeartbeats() {
2032
+ const seen = /* @__PURE__ */ new Map();
2033
+ for (const { transport } of this.transports) {
2034
+ if (!transport.readAllWorkerHeartbeats) continue;
2035
+ for (const beat of await transport.readAllWorkerHeartbeats()) {
2036
+ const key = `${beat.group}\0${beat.instanceId}`;
2037
+ if (!seen.has(key)) seen.set(key, beat);
2038
+ }
2039
+ }
2040
+ return [
2041
+ ...seen.values()
2042
+ ];
2043
+ }
2044
+ /**
2045
+ * Can ANY transport here introspect the fleet — descriptors or heartbeats? This is the difference
2046
+ * between "the fleet is empty" and "nobody asked", and it is a property of the POOL rather than of
2047
+ * a read's result, so it stays answerable when both reads legitimately come back empty.
2048
+ */
2049
+ get introspectsFleet() {
2050
+ return this.transports.some(({ transport }) => transport.readAllWorkerDescriptors || transport.readAllWorkerHeartbeats);
2051
+ }
1696
2052
  /** Distinct worker groups with a live heartbeat, merged across every transport that can report it. */
1697
2053
  async listWorkerGroups() {
1698
2054
  const groups = /* @__PURE__ */ new Set();
@@ -2093,7 +2449,7 @@ function createWorkflowCtx(host, runId, compensations, workflow = "", searchAttr
2093
2449
  return unwrapCompletion(await waitForSignal(`task:${runId}:${name}`), `task "${name}"`);
2094
2450
  }, "task");
2095
2451
  const child = /* @__PURE__ */ __name(async (workflow2, input, options) => {
2096
- const { childId, priority } = normalizeChildOptions(options);
2452
+ const { childId, priority, version } = normalizeChildOptions(options);
2097
2453
  const current = pos.next();
2098
2454
  const id = childId ?? `${runId}.child.${current}`;
2099
2455
  const existing = await readCheckpoint(current);
@@ -2121,7 +2477,7 @@ function createWorkflowCtx(host, runId, compensations, workflow = "", searchAttr
2121
2477
  }));
2122
2478
  return unwrapCompletion(buffered.payload, `child "${id}"`);
2123
2479
  }
2124
- if (!await store.getRun(id)) host.startChild(workflowName(workflow2), input, id, priority);
2480
+ if (!await store.getRun(id)) host.startChild(workflowName(workflow2), input, id, priority, version);
2125
2481
  if (!existing) {
2126
2482
  await writeCheckpoint(instantCheckpoint({
2127
2483
  runId,
@@ -2204,12 +2560,12 @@ function createWorkflowCtx(host, runId, compensations, workflow = "", searchAttr
2204
2560
  return outputs;
2205
2561
  }, "all");
2206
2562
  const startChild = /* @__PURE__ */ __name(async (workflow2, input, options) => {
2207
- const { childId, priority } = normalizeChildOptions(options);
2563
+ const { childId, priority, version } = normalizeChildOptions(options);
2208
2564
  const current = pos.next();
2209
2565
  const id = childId ?? `${runId}.child.${current}`;
2210
2566
  const existing = await readCheckpoint(current);
2211
2567
  if (existing && existing.status === "completed") return existing.output;
2212
- if (!await store.getRun(id)) host.startChild(workflowName(workflow2), input, id, priority);
2568
+ if (!await store.getRun(id)) host.startChild(workflowName(workflow2), input, id, priority, version);
2213
2569
  await writeCheckpoint(instantCheckpoint({
2214
2570
  runId,
2215
2571
  seq: current,
@@ -2457,6 +2813,7 @@ function mergeTags(staticTags, runTags) {
2457
2813
  __name(mergeTags, "mergeTags");
2458
2814
  var BREAKPOINT = "breakpoint";
2459
2815
  var isBreakpoint = /* @__PURE__ */ __name((cp) => cp.status === "pending" && cp.name.startsWith(BREAKPOINT), "isBreakpoint");
2816
+ var VERSION_UNDECLARED_TAG = "version:undeclared";
2460
2817
  var versionKey = /* @__PURE__ */ __name((name, version) => `${name}@${version}`, "versionKey");
2461
2818
  function stepGroup(step, runNamespace) {
2462
2819
  return tenantGroup(sanitizeQueueToken(step.name), step.partition ?? runNamespace);
@@ -2769,6 +3126,15 @@ var WorkflowEngine = class {
2769
3126
  const reg = this.workflows.get(versionKey(name, version));
2770
3127
  return reg?.hasBody ? reg.fn : void 0;
2771
3128
  }
3129
+ /** The registered versions of `name`, for the pinned-start error — "2 is not registered" is a
3130
+ * guess-the-typo error without them, and the answer (which versions this deploy actually carries)
3131
+ * is right here. `none` when the name is unknown entirely. */
3132
+ registeredVersions(name) {
3133
+ const versions = [
3134
+ ...this.workflows.values()
3135
+ ].filter((w) => w.name === name).map((w) => w.version);
3136
+ return versions.length ? versions.join(", ") : "none";
3137
+ }
2772
3138
  /**
2773
3139
  * Register a workflow whose body runs in another SDK (e.g. Python). The engine owns the run exactly
2774
3140
  * as for a TS workflow — it persists checkpoints, recovers, runs timers — but advances it by handing
@@ -2918,12 +3284,13 @@ var WorkflowEngine = class {
2918
3284
  }
2919
3285
  async start(workflow, input, runId, opts) {
2920
3286
  const name = workflowName(workflow);
2921
- let registered = this.latest.get(name);
2922
- if (!registered) {
3287
+ const pinned = opts?.version;
3288
+ let registered = pinned === void 0 ? this.latest.get(name) : this.workflows.get(versionKey(name, pinned));
3289
+ if (!registered && pinned === void 0) {
2923
3290
  const ancestor = await this.findRemoteAncestor(runId);
2924
3291
  if (ancestor) registered = this.synthesizeRemoteChild(name, ancestor.version, ancestor);
2925
3292
  }
2926
- if (!registered) {
3293
+ if (!registered && pinned === void 0) {
2927
3294
  const conventionRun = {
2928
3295
  id: runId,
2929
3296
  workflow: name,
@@ -2938,9 +3305,13 @@ var WorkflowEngine = class {
2938
3305
  createdAt: /* @__PURE__ */ new Date(),
2939
3306
  updatedAt: /* @__PURE__ */ new Date()
2940
3307
  };
2941
- registered = await this.resolveRemoteByConvention(conventionRun);
3308
+ registered = await this.resolveRemoteByConvention(conventionRun, {
3309
+ askFleetForVersion: true
3310
+ });
3311
+ }
3312
+ if (!registered) {
3313
+ 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)})`);
2942
3314
  }
2943
- if (!registered) throw new Error(`workflow ${name} is not registered`);
2944
3315
  await registered.validateInput?.(input);
2945
3316
  const prior = await this.store.getRun(runId);
2946
3317
  if (prior) {
@@ -2952,10 +3323,16 @@ var WorkflowEngine = class {
2952
3323
  };
2953
3324
  }
2954
3325
  const now = /* @__PURE__ */ new Date();
2955
- const tags = mergeTags(registered.tags, registered.singleton ? [
3326
+ const stampedTags = [
2956
3327
  ...opts?.tags ?? [],
2957
- this.singletons.tag(registered.singleton, input)
2958
- ] : opts?.tags);
3328
+ ...registered.singleton ? [
3329
+ this.singletons.tag(registered.singleton, input)
3330
+ ] : [],
3331
+ ...registered.versionDeclared === false ? [
3332
+ VERSION_UNDECLARED_TAG
3333
+ ] : []
3334
+ ];
3335
+ const tags = mergeTags(registered.tags, stampedTags);
2959
3336
  if (registered.singleton) {
2960
3337
  await this.singletons.assertCapacity(name, registered.singleton, input);
2961
3338
  }
@@ -3065,14 +3442,15 @@ var WorkflowEngine = class {
3065
3442
  * Recomputed per call (never memoized into `this.workflows`) so it stays correct if group
3066
3443
  * liveness changes between calls, exactly as {@link synthesizeRemoteChild} does.
3067
3444
  */
3068
- async resolveRemoteByConvention(run) {
3445
+ async resolveRemoteByConvention(run, opts = {}) {
3069
3446
  const group = tenantGroup(sanitizeQueueToken(run.workflow), run.namespace);
3070
3447
  const liveTransport = await this.pool.transportWithLiveGroup(group);
3071
3448
  if (!liveTransport) return void 0;
3072
3449
  const executor = new RemoteWorkflowExecutor(liveTransport, run.workflow, run.namespace);
3450
+ const declared = opts.askFleetForVersion ? await this.declaredVersion(liveTransport, group, run.workflow) : void 0;
3073
3451
  return {
3074
3452
  name: run.workflow,
3075
- version: run.workflowVersion,
3453
+ version: declared ?? run.workflowVersion,
3076
3454
  fn: /* @__PURE__ */ __name(() => {
3077
3455
  throw new Error(`remote workflow ${run.workflow} has no local body`);
3078
3456
  }, "fn"),
@@ -3080,10 +3458,46 @@ var WorkflowEngine = class {
3080
3458
  remote: {
3081
3459
  group,
3082
3460
  executor
3083
- }
3461
+ },
3462
+ ...opts.askFleetForVersion ? {
3463
+ versionDeclared: declared !== void 0
3464
+ } : {}
3084
3465
  };
3085
3466
  }
3086
3467
  /**
3468
+ * The version the LIVE fleet declares for `workflow` on `group`, or `undefined` when it declares
3469
+ * none — which is a fact about the fleet, not a failure to read it.
3470
+ *
3471
+ * The read is the descriptor advertisement, scoped to the one group being resolved: the workers on
3472
+ * that queue, their `registrations`, the entry whose `name` matches. That is where a version can
3473
+ * come from at all — a heartbeat has no room for one, so a worker on an SDK too old to publish a
3474
+ * descriptor declares nothing and the caller is told so rather than handed a default.
3475
+ *
3476
+ * `undefined` when the announcers DISAGREE, and deliberately: two live workers claiming different
3477
+ * versions of one name is a deployment in the middle of something, and picking either would stamp
3478
+ * a run with a version half the fleet does not serve. Undeclared is the honest reading of a
3479
+ * contradiction, and it is the reading that refuses to let a pin pass on a coin flip.
3480
+ */
3481
+ async declaredVersion(transport, group, workflow) {
3482
+ if (!transport.readWorkerDescriptors) return void 0;
3483
+ let descriptors;
3484
+ try {
3485
+ descriptors = await transport.readWorkerDescriptors(group);
3486
+ } catch {
3487
+ return void 0;
3488
+ }
3489
+ const versions = /* @__PURE__ */ new Set();
3490
+ for (const announced of aggregateAnnouncements(descriptors)) {
3491
+ if (announced.name === workflow && announced.version !== void 0) {
3492
+ versions.add(announced.version);
3493
+ }
3494
+ }
3495
+ const only = [
3496
+ ...versions
3497
+ ];
3498
+ return only.length === 1 ? only[0] : void 0;
3499
+ }
3500
+ /**
3087
3501
  * Walk the parent chain of `childRunId` to the nearest ancestor registered as REMOTE, returning its
3088
3502
  * registration (or `undefined` if there is none). The parent of an awaited child is found via its
3089
3503
  * live `child:<childId>` signal waiter — present for as long as the parent is suspended awaiting the
@@ -3147,7 +3561,32 @@ var WorkflowEngine = class {
3147
3561
  /**
3148
3562
  * Cancel in-flight runs that have outlived their workflow's `executionTimeout`. Call it from the
3149
3563
  * timer poller alongside {@link resumeDueTimers}. A timed-out run is moved to `cancelled` with an
3150
- * `execution_timeout` error (terminal, so a late step result can't resurrect it).
3564
+ * `execution_timeout` error (terminal, so a late step result can't resurrect it), AND its children
3565
+ * are cascaded exactly as an explicit {@link cancel} cascades them.
3566
+ *
3567
+ * WHY THE TERMINAL WRITE IS DIRECT, not a call to {@link cancel}. Two reasons, both still standing:
3568
+ * the run must land with the `execution_timeout` error `cancel` knows nothing about (`cancel` writes
3569
+ * a plain `cancelled`, and the code is what a dashboard/alert distinguishes a timeout by), and this
3570
+ * is a SCAN — it walks every in-flight run of every timed workflow on every poller tick, so it must
3571
+ * not pay `cancel`'s per-run re-read and singleton/control-plane work on runs it isn't cancelling.
3572
+ * The cascade below is charged only to the runs that ACTUALLY timed out, which are rare by
3573
+ * construction; the scan itself is unchanged.
3574
+ *
3575
+ * The subtree, though, has no reason to differ, and before this it did: a timed-out parent's
3576
+ * children kept running with the parent gone and nothing pointing at them — a silent orphan you
3577
+ * could only find by reading the runs table by hand. {@link cancelChildren} is the SAME recursive
3578
+ * walk `cancel` uses, so children of children go too, and it inherits that path's two guards:
3579
+ * `cancel` returns early on a run that is already `completed`/`cancelled`/`dead` (a child that beat
3580
+ * the sweep to the finish is never clobbered, and a re-cancel is a no-op), and it writes the
3581
+ * parent's terminal status BEFORE recursing, so a cyclic parent→child graph terminates on the
3582
+ * second visit instead of looping. Two pollers sweeping the same run concurrently therefore write
3583
+ * the same terminal state twice and cascade into already-cancelled children — idempotent, not
3584
+ * corrupting. The one window it does NOT close is the pre-existing check-then-write race an
3585
+ * explicit `cancel` has always had (a child that completes between `cancel`'s read and its write);
3586
+ * closing that needs a compare-and-set in the store contract, not a change here.
3587
+ *
3588
+ * Cascade failures are swallowed per subtree for the same reason `cancelChildren` swallows them per
3589
+ * child: one unreachable child must not abort the sweep and leave every LATER timed-out run alive.
3151
3590
  */
3152
3591
  async sweepTimeouts(now = this.clock()) {
3153
3592
  for (const reg of new Set(this.latest.values())) {
@@ -3183,6 +3622,7 @@ var WorkflowEngine = class {
3183
3622
  namespace: run.namespace,
3184
3623
  error: error2
3185
3624
  });
3625
+ await this.cancelChildren(run.id).catch(() => void 0);
3186
3626
  }
3187
3627
  }
3188
3628
  }
@@ -3911,6 +4351,10 @@ var WorkflowEngine = class {
3911
4351
  * `child:<id>` waiter) and fire-and-forget (`ctx.startChild`, found via its `spawn:<id>`
3912
4352
  * checkpoint). Recursive, so a whole subtree is cancelled; the terminal guard in `cancel` stops it
3913
4353
  * at finished / already-cancelled runs (no loops, no re-cancel).
4354
+ *
4355
+ * Two callers, deliberately the same walk: an explicit {@link cancel}, and {@link sweepTimeouts}
4356
+ * when a parent hits its `executionTimeout` — a timed-out parent's subtree dies the same way an
4357
+ * explicitly cancelled one's does, or the children are orphans nobody is holding a reference to.
3914
4358
  */
3915
4359
  async cancelChildren(parentRunId, opts) {
3916
4360
  for (const id of await this.getRunChildren(parentRunId)) {
@@ -3990,6 +4434,50 @@ var WorkflowEngine = class {
3990
4434
  return out;
3991
4435
  }
3992
4436
  /**
4437
+ * What workflows the LIVE fleet announces it can execute — the deployment-wide registry a picker
4438
+ * ("call this existing workflow") needs, and the one question this engine's own registry cannot
4439
+ * answer. {@link workflowBody} answers only for this process, and a missing body is ambiguous: it
4440
+ * means "not registered here", but equally "registered via {@link registerRemote} against another
4441
+ * SDK" or "a group resolved by convention against a worker". An inferred answer would therefore
4442
+ * differ per replica. This one does not: every pod folds the same published announcements.
4443
+ *
4444
+ * Scoped like every other poll surface — an engine WITH a `namespace` sees only workers in it, and
4445
+ * an operator (namespace unset) sees the whole deployment ("ver tudo = ausência de namespace"). A
4446
+ * descriptor that states no namespace is `default`, matching how a single-tenant fleet publishes.
4447
+ *
4448
+ * Cost: one advertisement scan per CALL, on demand. Nothing is added to the poll loop and nothing
4449
+ * is retained — no pod holds a table of the fleet's registrations. Empty when no transport can
4450
+ * introspect the advertisement keyspace (a pure in-process transport advertises nothing).
4451
+ *
4452
+ * Workflows only, and deliberately: see the "steps are out of scope" note in `handshake/announced`.
4453
+ */
4454
+ async announcedWorkflows() {
4455
+ return (await this.workflowDirectory()).workflows;
4456
+ }
4457
+ /**
4458
+ * {@link announcedWorkflows}, plus the thing a list of workflows can never say: why it is the
4459
+ * length it is. See {@link WorkflowDirectory} for the three states an empty array used to collapse
4460
+ * into one — "nobody asked", "nothing is live", and the partition mismatch that reads exactly like
4461
+ * the second from the caller's side.
4462
+ *
4463
+ * Two live reads, both on demand and neither retained: the descriptor advertisement (what workers
4464
+ * SAY they serve) and the heartbeat keyspace (what is demonstrably consuming a queue right now).
4465
+ * The second is why a worker on an SDK too old to publish a descriptor appears here at all — it
4466
+ * beats and never describes itself, and reading only descriptors reported such a fleet as empty
4467
+ * while `resolveRemoteByConvention`, a few lines away in this same class, was routing calls to it
4468
+ * off those very keys. Two answers out of one Redis, disagreeing. Now they agree, and the entry
4469
+ * says which of the two kinds of evidence it rests on rather than flattening them together.
4470
+ */
4471
+ async workflowDirectory() {
4472
+ return buildWorkflowDirectory({
4473
+ descriptors: await this.pool.readAllWorkerDescriptors(),
4474
+ heartbeats: await this.pool.readAllWorkerHeartbeats(),
4475
+ namespace: this.namespace,
4476
+ supported: this.pool.introspectsFleet,
4477
+ sanitize: sanitizeQueueToken
4478
+ });
4479
+ }
4480
+ /**
3993
4481
  * The routing groups of every in-flight run's still-`pending` remote step — the queues where work is
3994
4482
  * dispatched and awaiting a worker. Folded into {@link workerHealth} so a step waiting on a group
3995
4483
  * that is NEITHER a registration NOR heartbeating (the exact shape of a run stuck on an offline
@@ -5068,10 +5556,13 @@ var WorkflowEngine = class {
5068
5556
  // namespace) is the one executing the parent. Otherwise an operator recovery-resuming a
5069
5557
  // `davi-local` pipeline would stamp its `processing` child `default` and it would leak off the
5070
5558
  // tenant's worker pool onto the shared/dev workers.
5071
- startChild: /* @__PURE__ */ __name((workflow, input, id, priority) => {
5559
+ startChild: /* @__PURE__ */ __name((workflow, input, id, priority, version) => {
5072
5560
  this.startChildDeferred(workflow, input, id, {
5073
5561
  priority,
5074
- namespace: parentNamespace
5562
+ namespace: parentNamespace,
5563
+ // Absent unless the body pinned one (`ctx.child(ref, input, { version })`) — an omitted
5564
+ // pin must stay omitted, so the child keeps resolving `latest` exactly as before.
5565
+ version
5075
5566
  });
5076
5567
  }, "startChild"),
5077
5568
  // Deferred for the same reentrancy reason as `startChild` above. `cancel()` is already
@@ -6446,6 +6937,7 @@ var InMemoryTransport = class {
6446
6937
  CURRENT_PROTOCOL_VERSION,
6447
6938
  CodecStateStore,
6448
6939
  ContinueAsNew,
6940
+ DEFAULT_PARTITION,
6449
6941
  DURABLE_OPTIONS,
6450
6942
  DURABLE_OPTIONS_CANONICAL,
6451
6943
  DURABLE_STEP_CONFIG,
@@ -6476,25 +6968,31 @@ var InMemoryTransport = class {
6476
6968
  TERMINAL_RUN_STATUSES,
6477
6969
  TRANSPORT,
6478
6970
  TRANSPORT_CANONICAL,
6971
+ VERSION_UNDECLARED_TAG,
6479
6972
  WORKFLOW_NAME_KEY,
6480
6973
  WorkflowBlocked,
6481
6974
  WorkflowEngine,
6482
6975
  WorkflowSuspended,
6976
+ aggregateAnnouncements,
6977
+ announcementsOf,
6483
6978
  applyAttributeQuery,
6484
6979
  attributeColumnFor,
6485
6980
  attributeOperand,
6486
6981
  bindWorkflowClass,
6487
6982
  breakpointToken,
6983
+ buildWorkflowDirectory,
6488
6984
  canRoute,
6489
6985
  capableWorkers,
6490
6986
  classifyWaiterToken,
6491
6987
  clearDurableExecutionWrappers,
6492
6988
  collectMetrics,
6493
6989
  controlPlaneDescriptor,
6990
+ coveredTokens,
6494
6991
  createStepLogger,
6495
6992
  currentStep,
6496
6993
  currentWorkflowCtx,
6497
6994
  debug,
6995
+ describeWorker,
6498
6996
  descriptorHash,
6499
6997
  error,
6500
6998
  eventMatchOf,
@@ -6515,7 +7013,9 @@ var InMemoryTransport = class {
6515
7013
  negotiate,
6516
7014
  normalizeAttributeRows,
6517
7015
  normalizeDescriptor,
7016
+ observedAnnouncements,
6518
7017
  parseDuration,
7018
+ partitionOfToken,
6519
7019
  planDispatch,
6520
7020
  prevCronFireMs,
6521
7021
  readSearchAttributes,