@dudousxd/nestjs-durable-core 0.37.0 → 0.39.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.d.cts CHANGED
@@ -1122,6 +1122,15 @@ interface WorkflowCtx {
1122
1122
  random(): Promise<number>;
1123
1123
  /** Deterministic UUID v4: recorded once, then replayed. Use instead of `crypto.randomUUID()`. */
1124
1124
  uuid(): Promise<string>;
1125
+ /**
1126
+ * Merge `attrs` into THIS run's {@link WorkflowRun.searchAttributes} — the indexed metadata the
1127
+ * dashboard and {@link RunQuery} filter on. Shallow merge: keys you don't pass are kept. Durable +
1128
+ * exactly-once — recorded at this position on the first run and SKIPPED on replay, so it does one
1129
+ * write, not one per turn. Use this instead of injecting the {@link StateStore} to mutate the run
1130
+ * you're executing (`@Inject(state-store)` + `store.updateRun(ctx.runId, …)` becomes
1131
+ * `ctx.upsertSearchAttributes(…)`).
1132
+ */
1133
+ upsertSearchAttributes(attrs: SearchAttributes): Promise<void>;
1125
1134
  }
1126
1135
  /** Result of executing or resuming a workflow run. */
1127
1136
  interface RunResult {
@@ -1494,6 +1503,16 @@ declare class WorkflowEngine {
1494
1503
  * Register a workflow version. Register multiple versions of the same name to keep in-flight
1495
1504
  * runs working across a breaking change: old runs resume on the version they started on, new
1496
1505
  * runs start on the newest registered version.
1506
+ *
1507
+ * GROUP-SERVED (uniform dispatch, opt-in): pass `{ group, executor }` to have the engine DISPATCH
1508
+ * this workflow's turns to a worker `group` (via `executor.advance`) instead of running `fn`
1509
+ * inline — exactly as {@link registerRemote} does for a cross-SDK (e.g. Python) body, EXCEPT the
1510
+ * body is a TS `fn` the engine retains so an IN-APP worker on `group` can fetch it by name
1511
+ * ({@link workflowBody}) and run it. This is "one app, both roles": the same process owns the
1512
+ * engine AND consumes its own group. Recovery, timers, singleton, cancel and dead-lettering stay
1513
+ * engine concerns, identical to a remote run. OMIT `group`/`executor` (the default) to keep the
1514
+ * inline fast path — `fn` runs in-process and the run pays zero dispatch round-trips. The first
1515
+ * step toward routing every run through a group with no local/remote flag (Phase 3).
1497
1516
  */
1498
1517
  register(name: string, version: string, fn: WorkflowFn, opts?: {
1499
1518
  tags?: string[] | undefined;
@@ -1502,7 +1521,22 @@ declare class WorkflowEngine {
1502
1521
  validateInput?: ((input: unknown) => void | Promise<void>) | undefined;
1503
1522
  onEvent?: string[] | undefined;
1504
1523
  eventBatch?: EventBatchConfig | undefined;
1524
+ /** Worker group to DISPATCH this workflow's turns to (uniform dispatch). Requires `executor`.
1525
+ * Omit for the default inline fast path. See the method doc. */
1526
+ group?: string | undefined;
1527
+ /** Advances a turn by dispatching a {@link WorkflowTask} to `group`. Requires `group`. For an
1528
+ * in-app worker, pair a transport-backed {@link RemoteWorkflowExecutor} (group consumed by a
1529
+ * worker that calls {@link workflowBody}) so the retained `fn` runs through the transport hop. */
1530
+ executor?: WorkflowExecutor | undefined;
1505
1531
  }): void;
1532
+ /**
1533
+ * The retained in-process body for `name@version`, for an IN-APP worker that consumes this engine's
1534
+ * own group to SERVE a group-served `register(..., { group, executor })` workflow (uniform dispatch):
1535
+ * the worker fetches the body by name when it picks up the dispatched {@link WorkflowTask}, replays
1536
+ * it, and returns the {@link WorkflowDecision}. `undefined` for an unregistered name or a body-less
1537
+ * remote (a `registerRemote` / synthesized child carries only a throwing stub, never handed out).
1538
+ */
1539
+ workflowBody(name: string, version: string): WorkflowFn | undefined;
1506
1540
  /**
1507
1541
  * Register a workflow whose body runs in another SDK (e.g. Python). The engine owns the run exactly
1508
1542
  * as for a TS workflow — it persists checkpoints, recovers, runs timers — but advances it by handing
@@ -1566,6 +1600,49 @@ declare class WorkflowEngine {
1566
1600
  /** Read a run's current persisted state (or null if unknown). A thin pass-through to the store. */
1567
1601
  getRun(runId: string): Promise<WorkflowRun | null>;
1568
1602
  resume(runId: string): Promise<RunResult>;
1603
+ /**
1604
+ * The registration for an UNREGISTERED run, INHERITED from its nearest REMOTE ancestor — or
1605
+ * `undefined` if it has none (the skew-protection error case in {@link resume}). Callers check the
1606
+ * flat registry FIRST (so an explicit `registerRemote(name, …)` ALWAYS wins, and a registered run
1607
+ * never reaches here); this resolves only the new case: a child spawned by a remote workflow (e.g. a
1608
+ * Python `gather_children` / `start_child` fan-out) of a name the host never `registerRemote`-ed. It
1609
+ * is driven as a remote run on the ancestor's group + the SAME executor instance, so the host needs
1610
+ * no redundant registration per child name.
1611
+ *
1612
+ * RECOMPUTED per resume rather than memoized into `this.workflows`: a synthesized child is not a real
1613
+ * registration and must not leak into `latest`, `knownGroups`, or `sweepTimeouts` (which iterate the
1614
+ * registry); recomputing also stays correct if the ancestor's remote registration is re-pointed at a
1615
+ * new executor across a deploy. Only unregistered runs pay it, so registered workflows pay nothing.
1616
+ */
1617
+ private findInheritedRegistration;
1618
+ /**
1619
+ * A throwaway {@link RegisteredWorkflow} that routes `name@version` as a remote run on the ancestor's
1620
+ * group via the ancestor's SAME executor instance. The executor is group-scoped (it dispatches a
1621
+ * {@link WorkflowTask} carrying `run.workflow`, and the worker picks the body by name), so reusing it
1622
+ * is correct AND required — a second executor on the same group would race the worker for results.
1623
+ * Only `remote` is inherited; the child gets none of the ancestor's singleton/timeout/validator, so
1624
+ * its identity stays its own.
1625
+ */
1626
+ private synthesizeRemoteChild;
1627
+ /**
1628
+ * Walk the parent chain of `childRunId` to the nearest ancestor registered as REMOTE, returning its
1629
+ * registration (or `undefined` if there is none). The parent of an awaited child is found via its
1630
+ * live `child:<childId>` signal waiter — present for as long as the parent is suspended awaiting the
1631
+ * child, i.e. throughout the child's executable lifetime (start, every resume, crash recovery, cancel
1632
+ * cascade), which is exactly when we resolve. A registered-but-LOCAL parent stops the walk (an
1633
+ * unregistered child of a TS workflow is a genuine misconfiguration → skew error); only an
1634
+ * unregistered parent keeps walking, so a child of an inherited child still reaches the remote root.
1635
+ * `visited` guards against a pathological cyclic id graph.
1636
+ */
1637
+ private findRemoteAncestor;
1638
+ /**
1639
+ * The run that spawned `childRunId`, via its live `child:<childRunId>` signal waiter — every awaited
1640
+ * child (`ctx.child`, `ctx.all`, a remote `start_child`) suspends its parent on exactly this waiter,
1641
+ * and the waiter is written BEFORE the child is started. Only reached for an UNREGISTERED child, so
1642
+ * registered runs never pay the scan; returns `undefined` once the parent has settled the child (the
1643
+ * waiter is consumed), but by then the child is terminal and no longer resolved.
1644
+ */
1645
+ private findParentRunId;
1569
1646
  /** Track an in-flight execution so {@link drain} can wait for it. */
1570
1647
  private track;
1571
1648
  /**
package/dist/index.d.ts CHANGED
@@ -1122,6 +1122,15 @@ interface WorkflowCtx {
1122
1122
  random(): Promise<number>;
1123
1123
  /** Deterministic UUID v4: recorded once, then replayed. Use instead of `crypto.randomUUID()`. */
1124
1124
  uuid(): Promise<string>;
1125
+ /**
1126
+ * Merge `attrs` into THIS run's {@link WorkflowRun.searchAttributes} — the indexed metadata the
1127
+ * dashboard and {@link RunQuery} filter on. Shallow merge: keys you don't pass are kept. Durable +
1128
+ * exactly-once — recorded at this position on the first run and SKIPPED on replay, so it does one
1129
+ * write, not one per turn. Use this instead of injecting the {@link StateStore} to mutate the run
1130
+ * you're executing (`@Inject(state-store)` + `store.updateRun(ctx.runId, …)` becomes
1131
+ * `ctx.upsertSearchAttributes(…)`).
1132
+ */
1133
+ upsertSearchAttributes(attrs: SearchAttributes): Promise<void>;
1125
1134
  }
1126
1135
  /** Result of executing or resuming a workflow run. */
1127
1136
  interface RunResult {
@@ -1494,6 +1503,16 @@ declare class WorkflowEngine {
1494
1503
  * Register a workflow version. Register multiple versions of the same name to keep in-flight
1495
1504
  * runs working across a breaking change: old runs resume on the version they started on, new
1496
1505
  * runs start on the newest registered version.
1506
+ *
1507
+ * GROUP-SERVED (uniform dispatch, opt-in): pass `{ group, executor }` to have the engine DISPATCH
1508
+ * this workflow's turns to a worker `group` (via `executor.advance`) instead of running `fn`
1509
+ * inline — exactly as {@link registerRemote} does for a cross-SDK (e.g. Python) body, EXCEPT the
1510
+ * body is a TS `fn` the engine retains so an IN-APP worker on `group` can fetch it by name
1511
+ * ({@link workflowBody}) and run it. This is "one app, both roles": the same process owns the
1512
+ * engine AND consumes its own group. Recovery, timers, singleton, cancel and dead-lettering stay
1513
+ * engine concerns, identical to a remote run. OMIT `group`/`executor` (the default) to keep the
1514
+ * inline fast path — `fn` runs in-process and the run pays zero dispatch round-trips. The first
1515
+ * step toward routing every run through a group with no local/remote flag (Phase 3).
1497
1516
  */
1498
1517
  register(name: string, version: string, fn: WorkflowFn, opts?: {
1499
1518
  tags?: string[] | undefined;
@@ -1502,7 +1521,22 @@ declare class WorkflowEngine {
1502
1521
  validateInput?: ((input: unknown) => void | Promise<void>) | undefined;
1503
1522
  onEvent?: string[] | undefined;
1504
1523
  eventBatch?: EventBatchConfig | undefined;
1524
+ /** Worker group to DISPATCH this workflow's turns to (uniform dispatch). Requires `executor`.
1525
+ * Omit for the default inline fast path. See the method doc. */
1526
+ group?: string | undefined;
1527
+ /** Advances a turn by dispatching a {@link WorkflowTask} to `group`. Requires `group`. For an
1528
+ * in-app worker, pair a transport-backed {@link RemoteWorkflowExecutor} (group consumed by a
1529
+ * worker that calls {@link workflowBody}) so the retained `fn` runs through the transport hop. */
1530
+ executor?: WorkflowExecutor | undefined;
1505
1531
  }): void;
1532
+ /**
1533
+ * The retained in-process body for `name@version`, for an IN-APP worker that consumes this engine's
1534
+ * own group to SERVE a group-served `register(..., { group, executor })` workflow (uniform dispatch):
1535
+ * the worker fetches the body by name when it picks up the dispatched {@link WorkflowTask}, replays
1536
+ * it, and returns the {@link WorkflowDecision}. `undefined` for an unregistered name or a body-less
1537
+ * remote (a `registerRemote` / synthesized child carries only a throwing stub, never handed out).
1538
+ */
1539
+ workflowBody(name: string, version: string): WorkflowFn | undefined;
1506
1540
  /**
1507
1541
  * Register a workflow whose body runs in another SDK (e.g. Python). The engine owns the run exactly
1508
1542
  * as for a TS workflow — it persists checkpoints, recovers, runs timers — but advances it by handing
@@ -1566,6 +1600,49 @@ declare class WorkflowEngine {
1566
1600
  /** Read a run's current persisted state (or null if unknown). A thin pass-through to the store. */
1567
1601
  getRun(runId: string): Promise<WorkflowRun | null>;
1568
1602
  resume(runId: string): Promise<RunResult>;
1603
+ /**
1604
+ * The registration for an UNREGISTERED run, INHERITED from its nearest REMOTE ancestor — or
1605
+ * `undefined` if it has none (the skew-protection error case in {@link resume}). Callers check the
1606
+ * flat registry FIRST (so an explicit `registerRemote(name, …)` ALWAYS wins, and a registered run
1607
+ * never reaches here); this resolves only the new case: a child spawned by a remote workflow (e.g. a
1608
+ * Python `gather_children` / `start_child` fan-out) of a name the host never `registerRemote`-ed. It
1609
+ * is driven as a remote run on the ancestor's group + the SAME executor instance, so the host needs
1610
+ * no redundant registration per child name.
1611
+ *
1612
+ * RECOMPUTED per resume rather than memoized into `this.workflows`: a synthesized child is not a real
1613
+ * registration and must not leak into `latest`, `knownGroups`, or `sweepTimeouts` (which iterate the
1614
+ * registry); recomputing also stays correct if the ancestor's remote registration is re-pointed at a
1615
+ * new executor across a deploy. Only unregistered runs pay it, so registered workflows pay nothing.
1616
+ */
1617
+ private findInheritedRegistration;
1618
+ /**
1619
+ * A throwaway {@link RegisteredWorkflow} that routes `name@version` as a remote run on the ancestor's
1620
+ * group via the ancestor's SAME executor instance. The executor is group-scoped (it dispatches a
1621
+ * {@link WorkflowTask} carrying `run.workflow`, and the worker picks the body by name), so reusing it
1622
+ * is correct AND required — a second executor on the same group would race the worker for results.
1623
+ * Only `remote` is inherited; the child gets none of the ancestor's singleton/timeout/validator, so
1624
+ * its identity stays its own.
1625
+ */
1626
+ private synthesizeRemoteChild;
1627
+ /**
1628
+ * Walk the parent chain of `childRunId` to the nearest ancestor registered as REMOTE, returning its
1629
+ * registration (or `undefined` if there is none). The parent of an awaited child is found via its
1630
+ * live `child:<childId>` signal waiter — present for as long as the parent is suspended awaiting the
1631
+ * child, i.e. throughout the child's executable lifetime (start, every resume, crash recovery, cancel
1632
+ * cascade), which is exactly when we resolve. A registered-but-LOCAL parent stops the walk (an
1633
+ * unregistered child of a TS workflow is a genuine misconfiguration → skew error); only an
1634
+ * unregistered parent keeps walking, so a child of an inherited child still reaches the remote root.
1635
+ * `visited` guards against a pathological cyclic id graph.
1636
+ */
1637
+ private findRemoteAncestor;
1638
+ /**
1639
+ * The run that spawned `childRunId`, via its live `child:<childRunId>` signal waiter — every awaited
1640
+ * child (`ctx.child`, `ctx.all`, a remote `start_child`) suspends its parent on exactly this waiter,
1641
+ * and the waiter is written BEFORE the child is started. Only reached for an UNREGISTERED child, so
1642
+ * registered runs never pay the scan; returns `undefined` once the parent has settled the child (the
1643
+ * waiter is consumed), but by then the child is terminal and no longer resolved.
1644
+ */
1645
+ private findParentRunId;
1569
1646
  /** Track an in-flight execution so {@link drain} can wait for it. */
1570
1647
  private track;
1571
1648
  /**
package/dist/index.js CHANGED
@@ -1362,9 +1362,28 @@ function createWorkflowCtx(host, runId, compensations, workflow = "") {
1362
1362
  const now = /* @__PURE__ */ __name(() => step("now", async () => host.clock()), "now");
1363
1363
  const random = /* @__PURE__ */ __name(() => step("random", async () => Math.random()), "random");
1364
1364
  const uuid = /* @__PURE__ */ __name(() => step("uuid", async () => globalThis.crypto.randomUUID()), "uuid");
1365
+ const upsertSearchAttributes = /* @__PURE__ */ __name(async (attrs) => {
1366
+ const current = pos.next();
1367
+ const existing = await readCheckpoint(current);
1368
+ if (existing) {
1369
+ if (existing.name !== "searchAttributes") {
1370
+ throw new NonDeterminismError(runId, current, "searchAttributes", existing.name);
1371
+ }
1372
+ return;
1373
+ }
1374
+ await host.upsertSearchAttributes(runId, attrs);
1375
+ await writeCheckpoint(instantCheckpoint({
1376
+ runId,
1377
+ seq: current,
1378
+ name: "searchAttributes",
1379
+ kind: "local",
1380
+ output: attrs
1381
+ }));
1382
+ }, "upsertSearchAttributes");
1365
1383
  return {
1366
1384
  runId,
1367
1385
  step,
1386
+ upsertSearchAttributes,
1368
1387
  transaction,
1369
1388
  callEntity,
1370
1389
  signalEntity,
@@ -1607,8 +1626,21 @@ var WorkflowEngine = class {
1607
1626
  * Register a workflow version. Register multiple versions of the same name to keep in-flight
1608
1627
  * runs working across a breaking change: old runs resume on the version they started on, new
1609
1628
  * runs start on the newest registered version.
1629
+ *
1630
+ * GROUP-SERVED (uniform dispatch, opt-in): pass `{ group, executor }` to have the engine DISPATCH
1631
+ * this workflow's turns to a worker `group` (via `executor.advance`) instead of running `fn`
1632
+ * inline — exactly as {@link registerRemote} does for a cross-SDK (e.g. Python) body, EXCEPT the
1633
+ * body is a TS `fn` the engine retains so an IN-APP worker on `group` can fetch it by name
1634
+ * ({@link workflowBody}) and run it. This is "one app, both roles": the same process owns the
1635
+ * engine AND consumes its own group. Recovery, timers, singleton, cancel and dead-lettering stay
1636
+ * engine concerns, identical to a remote run. OMIT `group`/`executor` (the default) to keep the
1637
+ * inline fast path — `fn` runs in-process and the run pays zero dispatch round-trips. The first
1638
+ * step toward routing every run through a group with no local/remote flag (Phase 3).
1610
1639
  */
1611
1640
  register(name, version, fn, opts) {
1641
+ if (opts?.group == null !== (opts?.executor == null)) {
1642
+ throw new Error(`workflow ${name}@${version}: register({ group, executor }) needs BOTH group and executor (or neither)`);
1643
+ }
1612
1644
  const registered = {
1613
1645
  name,
1614
1646
  version,
@@ -1618,8 +1650,17 @@ var WorkflowEngine = class {
1618
1650
  executionTimeoutMs: opts?.executionTimeout != null ? parseDuration(opts.executionTimeout) : void 0,
1619
1651
  validateInput: opts?.validateInput,
1620
1652
  onEvent: opts?.onEvent,
1621
- eventBatch: opts?.eventBatch
1653
+ eventBatch: opts?.eventBatch,
1654
+ // A real, retained in-process body — so `workflowBody` can hand it to an in-app worker, and so
1655
+ // it is NOT confused with the throwing stub `registerRemote` installs for a body-less remote.
1656
+ hasBody: true
1622
1657
  };
1658
+ if (opts?.group != null && opts?.executor != null) {
1659
+ registered.remote = {
1660
+ group: opts.group,
1661
+ executor: opts.executor
1662
+ };
1663
+ }
1623
1664
  this.workflows.set(versionKey(name, version), registered);
1624
1665
  const current = this.latest.get(name);
1625
1666
  if (!current || isNewerVersion(version, current.version)) this.latest.set(name, registered);
@@ -1630,6 +1671,17 @@ var WorkflowEngine = class {
1630
1671
  }
1631
1672
  }
1632
1673
  /**
1674
+ * The retained in-process body for `name@version`, for an IN-APP worker that consumes this engine's
1675
+ * own group to SERVE a group-served `register(..., { group, executor })` workflow (uniform dispatch):
1676
+ * the worker fetches the body by name when it picks up the dispatched {@link WorkflowTask}, replays
1677
+ * it, and returns the {@link WorkflowDecision}. `undefined` for an unregistered name or a body-less
1678
+ * remote (a `registerRemote` / synthesized child carries only a throwing stub, never handed out).
1679
+ */
1680
+ workflowBody(name, version) {
1681
+ const reg = this.workflows.get(versionKey(name, version));
1682
+ return reg?.hasBody ? reg.fn : void 0;
1683
+ }
1684
+ /**
1633
1685
  * Register a workflow whose body runs in another SDK (e.g. Python). The engine owns the run exactly
1634
1686
  * as for a TS workflow — it persists checkpoints, recovers, runs timers — but advances it by handing
1635
1687
  * the run's history to `executor` (which dispatches a {@link WorkflowTask} to the worker) and applying
@@ -1755,7 +1807,11 @@ var WorkflowEngine = class {
1755
1807
  }
1756
1808
  async start(workflow, input, runId, opts) {
1757
1809
  const name = workflowName(workflow);
1758
- const registered = this.latest.get(name);
1810
+ let registered = this.latest.get(name);
1811
+ if (!registered) {
1812
+ const ancestor = await this.findRemoteAncestor(runId);
1813
+ if (ancestor) registered = this.synthesizeRemoteChild(name, ancestor.version, ancestor);
1814
+ }
1759
1815
  if (!registered) throw new Error(`workflow ${name} is not registered`);
1760
1816
  await registered.validateInput?.(input);
1761
1817
  const prior = await this.store.getRun(runId);
@@ -1816,12 +1872,87 @@ var WorkflowEngine = class {
1816
1872
  error: run.error
1817
1873
  };
1818
1874
  }
1819
- const registered = this.workflows.get(versionKey(run.workflow, run.workflowVersion));
1875
+ const registered = this.workflows.get(versionKey(run.workflow, run.workflowVersion)) ?? await this.findInheritedRegistration(run);
1820
1876
  if (!registered) {
1821
1877
  throw new Error(`workflow ${run.workflow}@${run.workflowVersion} is not registered \u2014 keep the prior version deployed so in-flight runs can drain (skew protection)`);
1822
1878
  }
1823
1879
  return this.track(this.execute(run, registered.fn));
1824
1880
  }
1881
+ /**
1882
+ * The registration for an UNREGISTERED run, INHERITED from its nearest REMOTE ancestor — or
1883
+ * `undefined` if it has none (the skew-protection error case in {@link resume}). Callers check the
1884
+ * flat registry FIRST (so an explicit `registerRemote(name, …)` ALWAYS wins, and a registered run
1885
+ * never reaches here); this resolves only the new case: a child spawned by a remote workflow (e.g. a
1886
+ * Python `gather_children` / `start_child` fan-out) of a name the host never `registerRemote`-ed. It
1887
+ * is driven as a remote run on the ancestor's group + the SAME executor instance, so the host needs
1888
+ * no redundant registration per child name.
1889
+ *
1890
+ * RECOMPUTED per resume rather than memoized into `this.workflows`: a synthesized child is not a real
1891
+ * registration and must not leak into `latest`, `knownGroups`, or `sweepTimeouts` (which iterate the
1892
+ * registry); recomputing also stays correct if the ancestor's remote registration is re-pointed at a
1893
+ * new executor across a deploy. Only unregistered runs pay it, so registered workflows pay nothing.
1894
+ */
1895
+ async findInheritedRegistration(run) {
1896
+ const ancestor = await this.findRemoteAncestor(run.id);
1897
+ if (!ancestor) return void 0;
1898
+ return this.synthesizeRemoteChild(run.workflow, run.workflowVersion, ancestor);
1899
+ }
1900
+ /**
1901
+ * A throwaway {@link RegisteredWorkflow} that routes `name@version` as a remote run on the ancestor's
1902
+ * group via the ancestor's SAME executor instance. The executor is group-scoped (it dispatches a
1903
+ * {@link WorkflowTask} carrying `run.workflow`, and the worker picks the body by name), so reusing it
1904
+ * is correct AND required — a second executor on the same group would race the worker for results.
1905
+ * Only `remote` is inherited; the child gets none of the ancestor's singleton/timeout/validator, so
1906
+ * its identity stays its own.
1907
+ */
1908
+ synthesizeRemoteChild(name, version, ancestor) {
1909
+ const remote = ancestor.remote;
1910
+ if (!remote) throw new Error(`workflow ${ancestor.name} is not a remote workflow`);
1911
+ return {
1912
+ name,
1913
+ version,
1914
+ fn: /* @__PURE__ */ __name(() => {
1915
+ throw new Error(`workflow ${name} is remote \u2014 it has no in-process body`);
1916
+ }, "fn"),
1917
+ remote
1918
+ };
1919
+ }
1920
+ /**
1921
+ * Walk the parent chain of `childRunId` to the nearest ancestor registered as REMOTE, returning its
1922
+ * registration (or `undefined` if there is none). The parent of an awaited child is found via its
1923
+ * live `child:<childId>` signal waiter — present for as long as the parent is suspended awaiting the
1924
+ * child, i.e. throughout the child's executable lifetime (start, every resume, crash recovery, cancel
1925
+ * cascade), which is exactly when we resolve. A registered-but-LOCAL parent stops the walk (an
1926
+ * unregistered child of a TS workflow is a genuine misconfiguration → skew error); only an
1927
+ * unregistered parent keeps walking, so a child of an inherited child still reaches the remote root.
1928
+ * `visited` guards against a pathological cyclic id graph.
1929
+ */
1930
+ async findRemoteAncestor(childRunId, visited = /* @__PURE__ */ new Set()) {
1931
+ if (visited.has(childRunId)) return void 0;
1932
+ visited.add(childRunId);
1933
+ const parentRunId = await this.findParentRunId(childRunId);
1934
+ if (!parentRunId) return void 0;
1935
+ const parent = await this.store.getRun(parentRunId);
1936
+ if (!parent) return void 0;
1937
+ const parentReg = this.workflows.get(versionKey(parent.workflow, parent.workflowVersion));
1938
+ if (parentReg?.remote) return parentReg;
1939
+ if (parentReg) return void 0;
1940
+ return this.findRemoteAncestor(parent.id, visited);
1941
+ }
1942
+ /**
1943
+ * The run that spawned `childRunId`, via its live `child:<childRunId>` signal waiter — every awaited
1944
+ * child (`ctx.child`, `ctx.all`, a remote `start_child`) suspends its parent on exactly this waiter,
1945
+ * and the waiter is written BEFORE the child is started. Only reached for an UNREGISTERED child, so
1946
+ * registered runs never pay the scan; returns `undefined` once the parent has settled the child (the
1947
+ * waiter is consumed), but by then the child is terminal and no longer resolved.
1948
+ */
1949
+ async findParentRunId(childRunId) {
1950
+ const token = `child:${childRunId}`;
1951
+ for (const waiter of await this.store.listSignalWaiters("child:")) {
1952
+ if (waiter.token === token) return waiter.runId;
1953
+ }
1954
+ return void 0;
1955
+ }
1825
1956
  /** Track an in-flight execution so {@link drain} can wait for it. */
1826
1957
  track(p) {
1827
1958
  this.inflight.add(p);
@@ -2511,7 +2642,7 @@ var WorkflowEngine = class {
2511
2642
  });
2512
2643
  }
2513
2644
  async execute(run, fn) {
2514
- const registered = this.workflows.get(versionKey(run.workflow, run.workflowVersion));
2645
+ const registered = this.workflows.get(versionKey(run.workflow, run.workflowVersion)) ?? await this.findInheritedRegistration(run);
2515
2646
  const lockNow = this.clock();
2516
2647
  if (run.lockedBy !== this.instanceId || (run.lockedUntil ?? 0) <= lockNow) {
2517
2648
  if (!await this.store.tryLockRun(run.id, this.instanceId, lockNow + this.leaseMs, lockNow)) {
@@ -3093,6 +3224,17 @@ var WorkflowEngine = class {
3093
3224
  priority
3094
3225
  }).catch(() => void 0));
3095
3226
  }, "startChild"),
3227
+ // Shallow-merge into the run's searchAttributes (the ctx primitive makes this exactly-once).
3228
+ upsertSearchAttributes: /* @__PURE__ */ __name(async (runId, attrs) => {
3229
+ const run = await this.store.getRun(runId);
3230
+ await this.store.updateRun(runId, {
3231
+ searchAttributes: {
3232
+ ...run?.searchAttributes ?? {},
3233
+ ...attrs
3234
+ },
3235
+ updatedAt: /* @__PURE__ */ new Date()
3236
+ });
3237
+ }, "upsertSearchAttributes"),
3096
3238
  signalEntity: /* @__PURE__ */ __name((name, key, op, arg, reply) => {
3097
3239
  queueMicrotask(() => void this.entities.dispatch(name, key, op, arg, reply).catch(() => void 0));
3098
3240
  }, "signalEntity"),