@camstack/server 1.2.139 → 1.2.141

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.
@@ -1,6 +1,7 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.MoleculerService = void 0;
4
+ exports.childOwnerToken = childOwnerToken;
4
5
  exports.buildChildUdsManifest = buildChildUdsManifest;
5
6
  const node_crypto_1 = require("node:crypto");
6
7
  const system_1 = require("@camstack/system");
@@ -27,6 +28,21 @@ class MoleculerService {
27
28
  * `$hub.registerNode`. Populated by `onRegisterNode` in `hubDeps`.
28
29
  */
29
30
  nodeRegistry = new system_1.HubNodeRegistry();
31
+ /**
32
+ * nodeId → the owner token of the INCARNATION whose manifest currently backs
33
+ * that node's capability registrations.
34
+ *
35
+ * A node id is not unique over time: `resolveRunnerId` is deterministic, so a
36
+ * runner replaced by an addon update reconnects as the same `hub/<runner>`
37
+ * node. Without an incarnation the predecessor's teardown and the successor's
38
+ * registration are the same `(cap, addonId)` key, and the teardown wins by
39
+ * arriving last — `recording`, `storage-evictable` and `recording-export`
40
+ * were unregistered from a healthy process for 7 hours on 2026-08-20.
41
+ * Populated only for transports that can observe an incarnation (UDS
42
+ * children); remote nodes stay ownerless and behave exactly as before.
43
+ * See `docs/decisions/adr-0188-an-unregister-carries-proof-of-ownership.md`.
44
+ */
45
+ nodeOwners = new Map();
30
46
  /**
31
47
  * Fixed-period agent-readiness snapshot sweep (D8 reconcile) — repairs
32
48
  * agent-origin readiness deltas lost while the agent stayed connected.
@@ -405,16 +421,23 @@ class MoleculerService {
405
421
  const hubNodeId = this.brokerSafe.nodeID;
406
422
  const childNodeId = `${hubNodeId}/${child.childId}`;
407
423
  const params = buildChildUdsManifest(childNodeId, child.childId, child.caps);
408
- this.onRegisterNode(params);
409
- logger.info('UDS child registered manifest applied', { meta: { nodeId: childNodeId } });
424
+ // The UDS connection is the only thing that can tell two generations of
425
+ // the same runner apart carry its incarnation into the registry as
426
+ // the owner of every registration this manifest makes.
427
+ this.onRegisterNode(params, childOwnerToken(childNodeId, child.incarnation));
428
+ logger.info('UDS child registered — manifest applied', {
429
+ meta: { nodeId: childNodeId, incarnation: child.incarnation },
430
+ });
410
431
  });
411
432
  // E1: cleanup on child disconnect — same effect as `$node.disconnected`
412
433
  // for hub-local children. The Moleculer path stays for AGENT nodes.
413
- registry.onChildGone((childId) => {
434
+ registry.onChildGone((childId, incarnation) => {
414
435
  const hubNodeId = this.brokerSafe.nodeID;
415
436
  const childNodeId = `${hubNodeId}/${childId}`;
416
- logger.info('UDS child gone — removing from registry', { meta: { childId } });
417
- this.removeNodeFromRegistry(childNodeId);
437
+ logger.info('UDS child gone — removing from registry', {
438
+ meta: { childId, incarnation },
439
+ });
440
+ this.removeNodeFromRegistry(childNodeId, childOwnerToken(childNodeId, incarnation));
418
441
  });
419
442
  // B2: ingest UDS child logs into the hub's LoggingService so they appear
420
443
  // in the LogManager / admin-UI log stream alongside broker-forwarded logs.
@@ -449,6 +472,10 @@ class MoleculerService {
449
472
  // sent a $hub.registerNode manifest get cleaned up on disconnect.
450
473
  const bridgeBus = this.broker;
451
474
  bridgeBus.localBus.on('$node.disconnected', ({ node }) => {
475
+ // No owner token: this listener cannot observe WHICH incarnation died.
476
+ // For a hub-local child the UDS `onChildGone` is the authority (it holds
477
+ // the incarnation) and this call is refused if the id has been reclaimed;
478
+ // remote nodes register no owner at all, so they clean up as before.
452
479
  this.removeNodeFromRegistry(node.id);
453
480
  });
454
481
  // D8 reverse-leg snapshot-reconcile: on every agent (re)connect, pull
@@ -682,10 +709,15 @@ class MoleculerService {
682
709
  * CapabilityRegistry update is a diff (atomic replace) rather than an
683
710
  * unconditional re-register — see `applyNodeManifest` for the rationale.
684
711
  */
685
- onRegisterNode(params) {
712
+ onRegisterNode(params, owner) {
686
713
  const previousManifest = this.nodeRegistry.getNodeManifest(params.nodeId);
714
+ const previousOwner = this.nodeOwners.get(params.nodeId);
687
715
  this.nodeRegistry.registerNode(params);
688
- this.applyNodeManifest(params, previousManifest);
716
+ if (owner === undefined)
717
+ this.nodeOwners.delete(params.nodeId);
718
+ else
719
+ this.nodeOwners.set(params.nodeId, owner);
720
+ this.applyNodeManifest(params, previousManifest, owner, previousOwner);
689
721
  // Notify AgentRegistryService to reconcile placement for bare-ID
690
722
  // agent nodes (no '/' = not a hub child worker, not the hub itself).
691
723
  // The handshake is the authoritative completeness signal — the full
@@ -717,7 +749,7 @@ class MoleculerService {
717
749
  * already; this method handles only `params.addons` (system caps).
718
750
  * Native-cap wiring into device-manager is done in a later task.
719
751
  */
720
- applyNodeManifest(params, previousManifest) {
752
+ applyNodeManifest(params, previousManifest, owner, previousOwner) {
721
753
  const { nodeId, addons } = params;
722
754
  const hubNodeId = this.brokerSafe.nodeID;
723
755
  const isLocalChild = nodeId.startsWith(hubNodeId + '/');
@@ -763,9 +795,23 @@ class MoleculerService {
763
795
  for (const [key, { addonId, capName }] of previous) {
764
796
  if (desired.has(key))
765
797
  continue;
766
- registry.unregisterProvider(capName, registryKeyFor(addonId));
798
+ // Present the token the DROPPED registration was made under — this node
799
+ // is dropping its own cap, and the registry refuses anything else.
800
+ registry.unregisterProvider(capName, registryKeyFor(addonId), previousOwner);
767
801
  this.nodeCallFns.delete(`${nodeId}::${capName}`);
768
802
  }
803
+ // ── TAKE OVER ── caps present in BOTH manifests when the node id has been
804
+ // reclaimed by a NEW incarnation. The registration is correct and stays
805
+ // (zero churn), but it is still stamped with the dead generation's token —
806
+ // leave it there and nothing the live generation can present would ever
807
+ // clean it up when IT dies. Re-stamp instead.
808
+ if (owner !== undefined && previousOwner !== undefined && owner !== previousOwner) {
809
+ for (const [key, { addonId, capName }] of desired) {
810
+ if (!previous.has(key))
811
+ continue;
812
+ registry.reassignProviderOwner(capName, registryKeyFor(addonId), owner);
813
+ }
814
+ }
769
815
  // ── REGISTER ── caps the new manifest applies that the previous one
770
816
  // did not. Caps present in BOTH sets are left untouched — zero churn,
771
817
  // no duplicate `registerProvider`, no spurious page/widget re-emit.
@@ -810,7 +856,7 @@ class MoleculerService {
810
856
  for (const methodName of Object.keys((0, system_1.expandCapMethods)(capDef))) {
811
857
  proxy[methodName] = (methodParams) => callFn(methodName, methodParams);
812
858
  }
813
- registry.registerProvider(capName, registryKey, proxy);
859
+ registry.registerProvider(capName, registryKey, proxy, owner);
814
860
  // Local-first singleton preference (UDS regression fix). A
815
861
  // `placement: 'any-node'` singleton (e.g. `pipeline-executor`) can
816
862
  // register on BOTH the hub-local forked child and a remote agent.
@@ -857,7 +903,17 @@ class MoleculerService {
857
903
  * Unregisters every cap the node's last manifest declared and emits
858
904
  * synthetic readiness-down events for each.
859
905
  */
860
- removeNodeFromRegistry(nodeId) {
906
+ removeNodeFromRegistry(nodeId, owner) {
907
+ const currentOwner = this.nodeOwners.get(nodeId);
908
+ if (currentOwner !== undefined && currentOwner !== owner) {
909
+ // The node id has been reclaimed by a newer incarnation since this
910
+ // teardown was scheduled. Tearing down here would unregister the LIVE
911
+ // process's capabilities and remove a node that is still connected.
912
+ this.logger.info('Node teardown ignored — node id reclaimed by a newer incarnation', {
913
+ meta: { nodeId, currentOwner, presentedOwner: owner ?? null },
914
+ });
915
+ return;
916
+ }
861
917
  const manifest = this.nodeRegistry.getNodeManifest(nodeId);
862
918
  if (!manifest)
863
919
  return; // node never sent a handshake — nothing to do
@@ -871,7 +927,7 @@ class MoleculerService {
871
927
  const registryKey = isLocalChild ? addonId : `${addonId}@${nodeId}`;
872
928
  if (registry) {
873
929
  for (const capName of capabilities) {
874
- registry.unregisterProvider(capName, registryKey);
930
+ registry.unregisterProvider(capName, registryKey, owner);
875
931
  }
876
932
  }
877
933
  for (const capName of capabilities) {
@@ -893,6 +949,7 @@ class MoleculerService {
893
949
  }
894
950
  }
895
951
  }
952
+ this.nodeOwners.delete(nodeId);
896
953
  this.nodeRegistry.removeNode(nodeId);
897
954
  }
898
955
  findCallFn(nodeId, capabilityName) {
@@ -1083,6 +1140,18 @@ exports.MoleculerService = MoleculerService;
1083
1140
  // ---------------------------------------------------------------------------
1084
1141
  // Module-level helpers
1085
1142
  // ---------------------------------------------------------------------------
1143
+ /**
1144
+ * The owner token a hub-local UDS child's capability registrations carry.
1145
+ *
1146
+ * `nodeId` alone is NOT an identity over time — a runner replaced by an addon
1147
+ * update reconnects under the same deterministic `hub/<runner>` id — so the
1148
+ * connection's incarnation is what makes the pair unique. Exported for the
1149
+ * regression suite: the rule is only worth anything if register and unregister
1150
+ * derive the token the SAME way.
1151
+ */
1152
+ function childOwnerToken(childNodeId, incarnation) {
1153
+ return `${childNodeId}#${incarnation}`;
1154
+ }
1086
1155
  /**
1087
1156
  * E1: Adapt a child's UDS `ChildCapDescriptor[]` into a `RegisterNodeParams`
1088
1157
  * that `onRegisterNode` / `applyNodeManifest` can consume.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@camstack/server",
3
- "version": "1.2.139",
3
+ "version": "1.2.141",
4
4
  "private": false,
5
5
  "files": [
6
6
  "dist",
@@ -38,12 +38,12 @@
38
38
  "@camstack/addon-auth": "1.2.26",
39
39
  "@camstack/addon-decoder-nodeav": "1.2.22",
40
40
  "@camstack/addon-notifiers": "1.2.27",
41
- "@camstack/addon-pipeline": "1.2.102",
42
- "@camstack/addon-pipeline-orchestrator": "1.2.84",
43
- "@camstack/addon-post-analysis": "1.2.102",
41
+ "@camstack/addon-pipeline": "1.2.103",
42
+ "@camstack/addon-pipeline-orchestrator": "1.2.86",
43
+ "@camstack/addon-post-analysis": "1.2.104",
44
44
  "@camstack/sdk": "1.2.25",
45
- "@camstack/system": "1.2.110",
46
- "@camstack/types": "1.2.92",
45
+ "@camstack/system": "1.2.112",
46
+ "@camstack/types": "1.2.93",
47
47
  "@camstack/ui-library": "1.2.63",
48
48
  "@fastify/compress": "^9.0.0",
49
49
  "@fastify/cookie": "^11.0.2",