@camstack/server 1.2.704 → 1.2.706

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.
@@ -33,7 +33,7 @@ var __importStar = (this && this.__importStar) || (function () {
33
33
  };
34
34
  })();
35
35
  Object.defineProperty(exports, "__esModule", { value: true });
36
- exports.AgentRegistryService = exports.AGENT_BACKFILL_RPC_TIMEOUT_MS = exports.FORGOTTEN_NODE_REVOCATION_REASON = void 0;
36
+ exports.AgentRegistryService = exports.ADDON_CONVERGENCE_DRY_RUN_KEY = exports.ADDON_CONVERGENCE_INTERVAL_MS = exports.AGENT_BACKFILL_RPC_TIMEOUT_MS = exports.FORGOTTEN_NODE_REVOCATION_REASON = void 0;
37
37
  exports.toNodeLiveness = toNodeLiveness;
38
38
  exports.toDescriptor = toDescriptor;
39
39
  const node_crypto_1 = require("node:crypto");
@@ -41,6 +41,7 @@ const os = __importStar(require("node:os"));
41
41
  const system_1 = require("@camstack/system");
42
42
  const types_1 = require("@camstack/types");
43
43
  const agent_addon_backfill_1 = require("./agent-addon-backfill");
44
+ const agent_desired_set_1 = require("./agent-desired-set");
44
45
  const agent_delivery_ledger_1 = require("./agent-delivery-ledger");
45
46
  const agent_liveness_event_1 = require("./agent-liveness-event");
46
47
  const cluster_node_history_mirror_1 = require("./cluster-node-history-mirror");
@@ -118,6 +119,19 @@ const AGENT_BOOTSTRAP_PACKAGES = new Set([
118
119
  '@camstack/system',
119
120
  '@camstack/addon-agent-ui',
120
121
  ]);
122
+ /**
123
+ * What the CONVERGENCE never manages (D982): only the framework carrier. The
124
+ * agent-only dashboard is not bootstrap to the convergence — the hub tracks it
125
+ * and keeps it at the hub's version — though the stale sweep above still never
126
+ * undeploys it as "not installed on the hub".
127
+ */
128
+ const CONVERGENCE_BOOTSTRAP = new Set(['@camstack/system']);
129
+ /** The fixed schedule of the convergence round over every online node (D982). */
130
+ exports.ADDON_CONVERGENCE_INTERVAL_MS = 10 * 60_000;
131
+ /** Settings key: `true` (the default) logs the convergence plan and applies nothing. */
132
+ exports.ADDON_CONVERGENCE_DRY_RUN_KEY = 'addons.convergence.dryRun';
133
+ /** How long a resolved registry version of a tracked package is reused. */
134
+ const TRACKED_VERSION_TTL_MS = 5 * 60_000;
121
135
  /** Derive the package roster (name → version) from a node's addon list. */
122
136
  function rosterFromAddons(addons) {
123
137
  const byName = new Map();
@@ -254,6 +268,14 @@ class AgentRegistryService {
254
268
  * diff and each deliver it.
255
269
  */
256
270
  reconcilingNodes = new Set();
271
+ /** Resolves the version of an agent-tracked package (agent-ui); `null` = not wired. */
272
+ trackedVersionResolver = null;
273
+ trackedVersionCache = new Map();
274
+ /** Reads the dry-run switch per round, so flipping the setting needs no restart. */
275
+ convergenceDryRun = () => true;
276
+ convergenceTimer = null;
277
+ convergenceRunning = false;
278
+ convergenceRerun = null;
257
279
  constructor(eventBus, moleculer, capabilityService) {
258
280
  this.eventBus = eventBus;
259
281
  this.moleculer = moleculer;
@@ -322,6 +344,15 @@ class AgentRegistryService {
322
344
  setAddonBackfill(seam) {
323
345
  this.addonBackfill = seam;
324
346
  }
347
+ /** Wire how the hub resolves the version of an agent-only tracked package. */
348
+ setTrackedVersionResolver(resolver) {
349
+ this.trackedVersionResolver = resolver;
350
+ this.trackedVersionCache.clear();
351
+ }
352
+ /** Wire the dry-run switch; unwired it is ON (D982: the first live run only logs). */
353
+ setConvergenceDryRun(read) {
354
+ this.convergenceDryRun = read;
355
+ }
325
356
  /**
326
357
  * Delivery failures the breaker is tracking for a node — surfaced so a node
327
358
  * that is missing capabilities never merely *looks* healthy.
@@ -342,9 +373,18 @@ class AgentRegistryService {
342
373
  const packageName = this.resolvePackageForAddon(addonId);
343
374
  if (packageName === null)
344
375
  return;
345
- if (AGENT_BOOTSTRAP_PACKAGES.has(packageName))
376
+ if (CONVERGENCE_BOOTSTRAP.has(packageName))
346
377
  return;
347
378
  await this.historyStore?.prunePackages(nodeId, [packageName]);
379
+ await this.historyStore?.addTombstones(nodeId, [packageName]);
380
+ }
381
+ /**
382
+ * The operator delivered a package to ONE node on purpose (`updatePackage` with
383
+ * a `nodeId`, `camstack deploy -n`): that lifts its tombstone, so the desired
384
+ * set wants it there again.
385
+ */
386
+ async noteExplicitDelivery(nodeId, packageName) {
387
+ await this.historyStore?.clearTombstones(nodeId, [packageName]);
348
388
  }
349
389
  /**
350
390
  * The operator removed a whole PACKAGE from a node (`addons.uninstallPackage`
@@ -355,9 +395,10 @@ class AgentRegistryService {
355
395
  * stale-addon reconcile does.
356
396
  */
357
397
  async notePackageUndeployed(nodeId, packageName, addonIds) {
358
- if (AGENT_BOOTSTRAP_PACKAGES.has(packageName))
398
+ if (CONVERGENCE_BOOTSTRAP.has(packageName))
359
399
  return;
360
400
  await this.historyStore?.prunePackages(nodeId, [packageName]);
401
+ await this.historyStore?.addTombstones(nodeId, [packageName]);
361
402
  for (const addonId of addonIds) {
362
403
  this.eventBus.emit({
363
404
  id: (0, node_crypto_1.randomUUID)(),
@@ -649,6 +690,94 @@ class AgentRegistryService {
649
690
  catch {
650
691
  // Registry shape varies across Moleculer versions — never fatal.
651
692
  }
693
+ this.startConvergenceTimer();
694
+ }
695
+ /**
696
+ * The fixed schedule of D982: every online agent is converged to its desired
697
+ * set every {@link ADDON_CONVERGENCE_INTERVAL_MS}. A round never gates
698
+ * registration (D3) and a failed delivery waits for the next round — the
699
+ * per-`(node, package)` breaker bounds the retries, there is no tight loop.
700
+ */
701
+ startConvergenceTimer() {
702
+ if (this.convergenceTimer !== null)
703
+ return;
704
+ const timer = setInterval(() => {
705
+ void this.requestConvergence('periodic');
706
+ }, exports.ADDON_CONVERGENCE_INTERVAL_MS);
707
+ timer.unref();
708
+ this.convergenceTimer = timer;
709
+ }
710
+ /** Stop the convergence schedule (shutdown, specs). */
711
+ stopConvergenceTimer() {
712
+ if (this.convergenceTimer === null)
713
+ return;
714
+ clearInterval(this.convergenceTimer);
715
+ this.convergenceTimer = null;
716
+ }
717
+ /**
718
+ * Converge every online agent to its desired set. Requests that arrive while a
719
+ * round runs collapse into ONE more round after it — a burst of hub installs
720
+ * cannot stack passes, and a round never re-triggers itself.
721
+ */
722
+ async requestConvergence(trigger) {
723
+ if (this.convergenceRunning) {
724
+ this.convergenceRerun = trigger;
725
+ return;
726
+ }
727
+ this.convergenceRunning = true;
728
+ try {
729
+ let next = trigger;
730
+ while (next !== null) {
731
+ this.convergenceRerun = null;
732
+ for (const agentId of this.onlineAgentIds()) {
733
+ await this.reconcileAgentAddons(agentId, next);
734
+ }
735
+ next = this.convergenceRerun;
736
+ }
737
+ }
738
+ finally {
739
+ this.convergenceRunning = false;
740
+ }
741
+ }
742
+ /** Bare agent ids currently online (workers and the hub excluded). */
743
+ onlineAgentIds() {
744
+ const registry = this.nodeCatalogRegistry();
745
+ const nodes = registry?.getNodeList?.({ onlyAvailable: true }) ?? [];
746
+ return nodes
747
+ .map((n) => n.id)
748
+ .filter((id) => typeof id === 'string' && id !== 'hub' && !id.includes('/'));
749
+ }
750
+ /** The version the hub follows for an agent-only tracked package, or `null`. */
751
+ async resolveTrackedVersions() {
752
+ const resolver = this.trackedVersionResolver;
753
+ const out = new Map();
754
+ if (resolver === null)
755
+ return out;
756
+ const now = Date.now();
757
+ for (const name of agent_desired_set_1.AGENT_TRACKED_PACKAGES) {
758
+ const cached = this.trackedVersionCache.get(name);
759
+ if (cached !== undefined && now - cached.at < TRACKED_VERSION_TTL_MS) {
760
+ out.set(name, cached.version);
761
+ continue;
762
+ }
763
+ try {
764
+ const version = await resolver(name);
765
+ if (version === null)
766
+ continue;
767
+ this.trackedVersionCache.set(name, { version, at: now });
768
+ out.set(name, version);
769
+ }
770
+ catch (err) {
771
+ // Unresolved = not wanted this round; a node is never moved on a guess.
772
+ this.logger?.warn('convergence: could not resolve a tracked package version', {
773
+ meta: {
774
+ package: name,
775
+ error: err instanceof Error ? err.message : String(err),
776
+ },
777
+ });
778
+ }
779
+ }
780
+ return out;
652
781
  }
653
782
  /** The durable history's display name for a node, when it knows a real one. */
654
783
  knownNodeName(nodeId) {
@@ -665,11 +794,7 @@ class AgentRegistryService {
665
794
  * must not abort the pass or hub boot.
666
795
  */
667
796
  async reconcileConnectedAgents() {
668
- const registry = this.nodeCatalogRegistry();
669
- const nodes = registry?.getNodeList?.({ onlyAvailable: true }) ?? [];
670
- const agentIds = nodes
671
- .map((n) => n.id)
672
- .filter((id) => typeof id === 'string' && id !== 'hub' && !id.includes('/'));
797
+ const agentIds = this.onlineAgentIds();
673
798
  if (agentIds.length === 0)
674
799
  return;
675
800
  console.log(`[agent-registry] Boot reconcile: ${agentIds.length} connected agent(s)`);
@@ -788,7 +913,13 @@ class AgentRegistryService {
788
913
  }
789
914
  }
790
915
  }
916
+ // The decl-level sweep keeps its connect-time blast radius: the scheduled and
917
+ // hub-change rounds (D982) converge by the desired set only, so a hub whose
918
+ // registry is momentarily short can never undeploy a node's addons on a timer.
919
+ const sweepsStale = trigger !== 'periodic' && trigger !== 'hub-change';
791
920
  const stale = agentAddons.filter((addon) => {
921
+ if (!sweepsStale)
922
+ return false;
792
923
  // Agent bootstrap infrastructure (storage/settings/metrics/logging from
793
924
  // `@camstack/system`, plus the agent-only `addon-agent-ui` dashboard)
794
925
  // runs on every node as bootstrap infrastructure — never hub-managed
@@ -813,13 +944,14 @@ class AgentRegistryService {
813
944
  // Installed but pinned to the hub → must not run on an agent.
814
945
  return placement === 'hub-only';
815
946
  });
816
- if (stale.length === 0) {
947
+ if (stale.length === 0 && sweepsStale) {
817
948
  console.log(`[agent-registry] Reconcile ${agentId}: no stale addons (${agentAddons.length} checked)`);
818
949
  }
819
950
  // Packages fully removed from the node by this pass. Recorded so the
820
951
  // back-fill below cannot immediately put them back, and so the node's
821
952
  // intended set shrinks — the only way that happens.
822
953
  const undeployedPackages = new Set();
954
+ const undeployedIds = new Set();
823
955
  const survivingIds = new Set(agentAddons.filter((a) => !stale.includes(a)).map((a) => a.id));
824
956
  for (const addon of stale) {
825
957
  const reason = hubPlacements.has(addon.id)
@@ -830,6 +962,7 @@ class AgentRegistryService {
830
962
  nodeID: agentId,
831
963
  timeout: AGENT_RECONCILE_RPC_TIMEOUT_MS,
832
964
  });
965
+ undeployedIds.add(addon.id);
833
966
  console.log(`[agent-registry] Reconcile ${agentId}: undeployed stale addon "${addon.id}" (${reason})`);
834
967
  // A package only leaves the intended set when NO surviving addon
835
968
  // still belongs to it — one package ships many addons, and the agent
@@ -852,7 +985,14 @@ class AgentRegistryService {
852
985
  console.error(`[agent-registry] Reconcile ${agentId}: failed to undeploy "${addon.id}":`, err instanceof Error ? err.message : String(err));
853
986
  }
854
987
  }
855
- await this.backfillAgentAddons(agentId, agentAddons, undeployedPackages, hubVersions, hubPackagePlacements, trigger);
988
+ const hubPackageFacts = new Map();
989
+ for (const [name, placements] of hubPackagePlacements) {
990
+ hubPackageFacts.set(name, {
991
+ version: hubVersions.get(name) ?? null,
992
+ placements,
993
+ });
994
+ }
995
+ await this.convergeAgentAddons(agentId, agentAddons.filter((a) => !undeployedIds.has(a.id)), undeployedPackages, hubPackageFacts, trigger);
856
996
  }
857
997
  catch (err) {
858
998
  console.error(`[agent-registry] Reconcile failed for agent ${agentId}:`, err instanceof Error ? err.message : String(err));
@@ -862,24 +1002,25 @@ class AgentRegistryService {
862
1002
  }
863
1003
  }
864
1004
  /**
865
- * Converge a node UP to the roster it last declared.
1005
+ * Converge a node to ITS DESIRED SET ([D982](../../../../docs/decisions/adr-0982-the-hub-owns-every-nodes-addon-set.md)).
1006
+ *
1007
+ * The desired set comes from the hub (placements, versions, the agent-only
1008
+ * packages it tracks, the node's tombstones); the roster the node reports is
1009
+ * the observation it is diffed against and is recorded only as history.
866
1010
  *
867
1011
  * Order matters and is deliberate:
868
- * 1. record what the node declares NOW (grow-only merge) — a wiped node
869
- * reporting 4 packages must not erase the record of its 14;
870
- * 2. prune what this pass undeployed — the intended set's only shrink;
871
- * 3. read the resulting intended set and deliver the difference.
1012
+ * 1. record what the node declares NOW — history, never intent;
1013
+ * 2. prune what the stale sweep undeployed from that record;
1014
+ * 3. compute the plan, then apply it behind the gates (trigger allowlist,
1015
+ * delivery window, confirmation read, dry-run switch).
872
1016
  *
873
1017
  * Every leg is idempotent: run twice with no change in between and step 3
874
1018
  * finds nothing to do.
875
1019
  */
876
- async backfillAgentAddons(agentId, agentAddons, undeployedPackages, hubVersions, hubPackagePlacements, trigger) {
1020
+ async convergeAgentAddons(agentId, agentAddons, undeployedPackages, hubPackages, trigger) {
877
1021
  const store = this.historyStore;
878
1022
  if (!store)
879
1023
  return;
880
- // Read BEFORE recording: a fresh node is one that never declared a roster
881
- // (D962), and the record below would make it look like it had one.
882
- const priorRoster = await store.getPackages(agentId);
883
1024
  const live = rosterFromAddons(agentAddons);
884
1025
  await store.recordPackages(agentId, live);
885
1026
  if (undeployedPackages.size > 0) {
@@ -888,85 +1029,159 @@ class AgentRegistryService {
888
1029
  const seam = this.addonBackfill;
889
1030
  if (!seam)
890
1031
  return;
891
- const recorded = await store.getPackages(agentId);
892
- const planned = (0, agent_addon_backfill_1.computeBackfillPlan)({
893
- recorded,
894
- live,
895
- bootstrap: AGENT_BOOTSTRAP_PACKAGES,
896
- undeployed: undeployedPackages,
1032
+ const tombstones = await store.getTombstones(agentId);
1033
+ const desired = (0, agent_desired_set_1.computeDesiredSet)({
1034
+ hubPackages,
1035
+ trackedForAgents: await this.resolveTrackedVersions(),
1036
+ tombstones,
1037
+ bootstrap: CONVERGENCE_BOOTSTRAP,
1038
+ });
1039
+ const planned = (0, agent_desired_set_1.diffNodeAgainstDesired)({
1040
+ desired,
1041
+ observed: live.filter((p) => !undeployedPackages.has(p.name)),
897
1042
  retired: this.backfillTracker.retiredFor(agentId),
898
- hubVersions,
899
- freshNode: priorRoster.length === 0 ? { hubPackagePlacements } : undefined,
1043
+ bootstrap: CONVERGENCE_BOOTSTRAP,
900
1044
  });
901
- if (priorRoster.length === 0) {
902
- this.logger?.info('back-fill: fresh node (no prior declared roster), plan from the hub any-node set', {
903
- tags: { nodeId: agentId },
904
- meta: {
905
- planned: planned.targets.map((t) => `${t.name}@${t.version}`),
906
- },
907
- });
908
- for (const skip of planned.skipped) {
909
- this.logger?.info('back-fill: fresh node not given a hub package', {
910
- tags: { nodeId: agentId },
911
- meta: { package: skip.name, reason: skip.reason },
912
- });
913
- }
914
- }
915
- // Never a delivery, and said so: an addon the hub no longer installs stays
916
- // off the node even though the node once declared it (the stale sweep above
917
- // removes a copy that is still there).
918
- if (planned.notOnHub.length > 0) {
919
- console.log(`[agent-registry] Back-fill ${agentId}: NOT delivering ${planned.notOnHub.join(', ')} — ` +
920
- `in the node's declared roster but not installed on the hub`);
921
- }
922
- if (planned.targets.length === 0 && planned.retired.length === 0) {
1045
+ const changes = planned.install.length + planned.update.length + planned.remove.length;
1046
+ if (changes === 0 && planned.retired.length === 0)
1047
+ return;
1048
+ const dryRun = this.convergenceDryRun();
1049
+ this.logger?.info(dryRun ? 'convergence plan (DRY RUN, nothing applied)' : 'convergence plan', {
1050
+ tags: { nodeId: agentId },
1051
+ meta: {
1052
+ trigger,
1053
+ install: planned.install.map((t) => `${t.name}@${t.version}`),
1054
+ update: planned.update.map((t) => `${t.name} ${t.from ?? 'absent'} -> ${t.version}`),
1055
+ remove: planned.remove.map((r) => `${r.name} (${r.reason})`),
1056
+ retired: planned.retired,
1057
+ observed: live.length,
1058
+ },
1059
+ });
1060
+ if (dryRun)
923
1061
  return;
924
- }
925
1062
  // GATE 1 — the trigger allowlist. A pass that must not install says so and
926
1063
  // stops; silence here would read as "nothing was missing".
927
1064
  if (!(0, agent_addon_backfill_1.triggerMayDeliver)(trigger)) {
928
- console.log(`[agent-registry] Back-fill ${agentId}: SKIPPED — trigger "${trigger}" may not deliver ` +
929
- `(${planned.targets.length} package(s) would have been installed)`);
1065
+ console.log(`[agent-registry] Convergence ${agentId}: SKIPPED — trigger "${trigger}" may not deliver ` +
1066
+ `(${changes} change(s) planned)`);
930
1067
  return;
931
1068
  }
932
1069
  // GATE 2 — the delivery window. A roster gap the hub itself opened is not
933
- // evidence a node is missing anything. This is the trigger that made every
934
- // `camstack deploy … -n <agent>` on 2026-08-07 get reverted ~1.5s later.
1070
+ // evidence a node is missing anything (`camstack deploy … -n <agent>` was
1071
+ // reverted ~1.5s later on 2026-08-07).
935
1072
  const suppression = this.deliveryLedger.suppressionFor(agentId);
936
1073
  if (suppression !== null) {
937
- console.log(`[agent-registry] Back-fill ${agentId}: SKIPPED — a hub delivery to this node is ` +
1074
+ console.log(`[agent-registry] Convergence ${agentId}: SKIPPED — a hub delivery to this node is ` +
938
1075
  `in flight or just finished (inFlight=${suppression.inFlight}, ` +
939
1076
  `msSinceLastDelivery=${suppression.msSinceLastDelivery ?? 'n/a'}, ` +
940
- `packages=${suppression.packages.join(', ') || 'none'}); ` +
941
- `the node reported ${planned.liveCount} package(s) and the roster gap is OURS, not the node's`);
1077
+ `packages=${suppression.packages.join(', ') || 'none'}); the roster gap is OURS, not the node's`);
942
1078
  return;
943
1079
  }
944
- // GATE 3 — the confirmation read (D49). A back-fill overwrites whatever is
945
- // on the node, so the destroying direction needs a SECOND read to agree.
946
- const plan = await this.confirmBackfillTargets(agentId, planned);
947
- if (plan === null)
1080
+ await this.applyRemovals(agentId, agentAddons, planned);
1081
+ await this.applyDeliveries(agentId, live, planned, trigger, seam);
1082
+ }
1083
+ /**
1084
+ * Remove tombstoned and hub-only leftovers. A removal DESTROYS work, so it
1085
+ * needs a second roster read to agree (D49) and fails closed when that read
1086
+ * cannot be taken. Every removal logs its reason.
1087
+ */
1088
+ async applyRemovals(agentId, agentAddons, plan) {
1089
+ if (plan.remove.length === 0)
1090
+ return;
1091
+ let secondAddons = null;
1092
+ try {
1093
+ secondAddons = this.extractAgentAddons(await this.broker.call('$agent.status', {}, { nodeID: agentId, timeout: AGENT_RECONCILE_RPC_TIMEOUT_MS }));
1094
+ }
1095
+ catch (err) {
1096
+ this.logger?.warn('convergence: removals skipped, the confirmation read failed', {
1097
+ tags: { nodeId: agentId },
1098
+ meta: { error: err instanceof Error ? err.message : String(err) },
1099
+ });
948
1100
  return;
949
- if (plan.targets.length === 0 && plan.retired.length === 0) {
1101
+ }
1102
+ if (secondAddons === null) {
1103
+ this.logger?.warn('convergence: removals skipped, the confirmation read had no addon list', {
1104
+ tags: { nodeId: agentId },
1105
+ });
950
1106
  return;
951
1107
  }
1108
+ const stillThere = new Set(secondAddons.flatMap((a) => (a.packageName === undefined ? [] : [a.packageName])));
1109
+ for (const removal of plan.remove) {
1110
+ if (!stillThere.has(removal.name))
1111
+ continue;
1112
+ const addonIds = agentAddons.filter((a) => a.packageName === removal.name).map((a) => a.id);
1113
+ try {
1114
+ // One package-mode call removes every addon the package ships; the first
1115
+ // id rides along so an agent that predates package mode still removes it.
1116
+ await this.broker.call('$agent.undeploy', {
1117
+ packageName: removal.name,
1118
+ ...(addonIds[0] === undefined ? {} : { addonId: addonIds[0] }),
1119
+ }, { nodeID: agentId, timeout: AGENT_RECONCILE_RPC_TIMEOUT_MS });
1120
+ this.logger?.info('convergence: removed a package from the node', {
1121
+ tags: { nodeId: agentId },
1122
+ meta: { package: removal.name, reason: removal.reason, addonIds },
1123
+ });
1124
+ this.eventBus.emit({
1125
+ id: (0, node_crypto_1.randomUUID)(),
1126
+ timestamp: new Date(),
1127
+ source: { type: 'core', id: 'agent-registry' },
1128
+ category: types_1.EventCategory.AddonUninstalled,
1129
+ data: {
1130
+ agentId,
1131
+ packageName: removal.name,
1132
+ reason: `convergence: ${removal.reason}`,
1133
+ },
1134
+ });
1135
+ }
1136
+ catch (err) {
1137
+ this.logger?.warn('convergence: removing a package failed, the next round retries', {
1138
+ tags: { nodeId: agentId },
1139
+ meta: {
1140
+ package: removal.name,
1141
+ reason: removal.reason,
1142
+ error: err instanceof Error ? err.message : String(err),
1143
+ },
1144
+ });
1145
+ }
1146
+ }
1147
+ }
1148
+ /** Install and update, behind the D49 confirmation read. */
1149
+ async applyDeliveries(agentId, live, plan, trigger, seam) {
1150
+ const planned = {
1151
+ targets: [...plan.install, ...plan.update].map((t) => ({
1152
+ name: t.name,
1153
+ version: t.version,
1154
+ versionSource: t.origin === 'agent-tracked' ? 'agent-tracked' : 'hub-installed',
1155
+ })),
1156
+ retired: plan.retired,
1157
+ liveCount: live.length,
1158
+ };
1159
+ if (planned.targets.length === 0 && planned.retired.length === 0)
1160
+ return;
1161
+ // GATE 3 — the confirmation read (D49). A delivery overwrites whatever is
1162
+ // on the node, so the destroying direction needs a SECOND read to agree.
1163
+ const confirmed = await this.confirmBackfillTargets(agentId, planned);
1164
+ if (confirmed === null)
1165
+ return;
1166
+ if (confirmed.targets.length === 0 && confirmed.retired.length === 0)
1167
+ return;
952
1168
  // Hold the delivery window open for the whole pass, so the reload this
953
- // back-fill is about to cause cannot trigger a second one.
954
- const closeWindow = this.deliveryLedger.begin(agentId, plan.targets.map((t) => t.name).join(', ') || 'back-fill');
1169
+ // delivery is about to cause cannot trigger a second one.
1170
+ const closeWindow = this.deliveryLedger.begin(agentId, confirmed.targets.map((t) => t.name).join(', ') || 'convergence');
955
1171
  let outcome;
956
1172
  try {
957
- outcome = await (0, agent_addon_backfill_1.executeBackfill)(seam, agentId, plan, this.backfillTracker);
1173
+ outcome = await (0, agent_addon_backfill_1.executeBackfill)(seam, agentId, confirmed, this.backfillTracker);
958
1174
  }
959
1175
  finally {
960
1176
  closeWindow();
961
1177
  }
962
1178
  if (outcome.delivered.length > 0) {
963
- console.log(`[agent-registry] Back-fill ${agentId}: delivered ${outcome.delivered
1179
+ console.log(`[agent-registry] Convergence ${agentId}: delivered ${outcome.delivered
964
1180
  .map((d) => {
965
1181
  const from = live.find((p) => p.name === d.name)?.version ?? 'absent';
966
- return `${d.name} ${from} → ${d.version} (agent follows the hub installed version)`;
1182
+ return `${d.name} ${from} → ${d.version} (${d.versionSource})`;
967
1183
  })
968
- .join(', ')} — trigger=${trigger}, node reported ${plan.liveCount} package(s) and its ` +
969
- `declared roster has ${recorded.length}`);
1184
+ .join(', ')} — trigger=${trigger}, node reported ${confirmed.liveCount} package(s)`);
970
1185
  this.eventBus.emit({
971
1186
  id: (0, node_crypto_1.randomUUID)(),
972
1187
  timestamp: new Date(),
@@ -975,19 +1190,19 @@ class AgentRegistryService {
975
1190
  data: {
976
1191
  agentId,
977
1192
  packages: outcome.delivered.map((d) => `${d.name}@${d.version}`),
978
- reason: 'back-fill: node missing packages from its declared roster',
1193
+ reason: 'convergence: the hub desired set differs from what the node runs',
979
1194
  },
980
1195
  });
981
1196
  }
982
1197
  // Fail LOUD: a node missing capabilities must never merely look healthy.
983
1198
  for (const failure of outcome.failed) {
984
- console.error(`[agent-registry] Back-fill ${agentId}: FAILED to deliver ${failure.name}@${failure.version} — ${failure.error}` +
1199
+ console.error(`[agent-registry] Convergence ${agentId}: FAILED to deliver ${failure.name}@${failure.version} — ${failure.error}` +
985
1200
  (failure.retired
986
1201
  ? ` (retired after ${agent_addon_backfill_1.MAX_BACKFILL_ATTEMPTS} attempts; this node is running WITHOUT that package)`
987
- : ''));
1202
+ : ' (the next round retries)'));
988
1203
  }
989
1204
  for (const name of outcome.skipped) {
990
- console.error(`[agent-registry] Back-fill ${agentId}: ${name} is still missing and RETIRED — the node is degraded until an operator intervenes`);
1205
+ console.error(`[agent-registry] Convergence ${agentId}: ${name} is still wrong and RETIRED — the node is degraded until an operator intervenes`);
991
1206
  }
992
1207
  }
993
1208
  /**
@@ -21,6 +21,7 @@ const DescriptorSchema = zod_1.z.object({
21
21
  .array(zod_1.z.object({ name: zod_1.z.string(), version: zod_1.z.string().nullable() }))
22
22
  .readonly()
23
23
  .optional(),
24
+ tombstones: zod_1.z.array(zod_1.z.string()).readonly().optional(),
24
25
  });
25
26
  /** A row as the store reads it: `at` as `lastActive`, plus the descriptor. */
26
27
  const RowDataSchema = zod_1.z.object({
@@ -54,7 +55,8 @@ function descriptorsEqual(a, b) {
54
55
  sameStrings(a.engines, b.engines) &&
55
56
  sameStrings(a.localIps, b.localIps) &&
56
57
  sameStrings(a.addonIds, b.addonIds) &&
57
- samePackages(a.packages, b.packages));
58
+ samePackages(a.packages, b.packages) &&
59
+ sameStrings(a.tombstones ?? [], b.tombstones ?? []));
58
60
  }
59
61
  function sameStrings(a, b) {
60
62
  return a.length === b.length && a.every((v, i) => v === b[i]);
@@ -103,7 +105,12 @@ class ClusterNodeHistoryStore {
103
105
  // the back-fill's intended set on the very next topology poll.
104
106
  const existing = await this.readStored(kind, descriptor.id);
105
107
  const packages = descriptor.packages ?? existing?.descriptor.packages;
106
- const next = packages === undefined ? descriptor : { ...descriptor, packages };
108
+ const tombstones = descriptor.tombstones ?? existing?.descriptor.tombstones;
109
+ const next = {
110
+ ...descriptor,
111
+ ...(packages === undefined ? {} : { packages }),
112
+ ...(tombstones === undefined ? {} : { tombstones }),
113
+ };
107
114
  if (existing !== null && !this.worthWriting(existing, next))
108
115
  return;
109
116
  await this.writeStored(kind, descriptor.id, Date.now(), next);
@@ -251,6 +258,63 @@ class ClusterNodeHistoryStore {
251
258
  });
252
259
  }
253
260
  }
261
+ /** The packages tombstoned for a node (D982). Empty when none or unreadable. */
262
+ async getTombstones(nodeId) {
263
+ const kind = this.resolveKind();
264
+ if (kind === null)
265
+ return new Set();
266
+ try {
267
+ return new Set((await this.readStored(kind, nodeId))?.descriptor.tombstones ?? []);
268
+ }
269
+ catch (err) {
270
+ this.logger.warn('getTombstones failed (best-effort)', {
271
+ tags: { nodeId },
272
+ meta: { error: (0, types_1.errMsg)(err) },
273
+ });
274
+ return new Set();
275
+ }
276
+ }
277
+ /** Keep packages off a node: an operator undeploy / hub uninstall for it. */
278
+ async addTombstones(nodeId, names) {
279
+ await this.editTombstones(nodeId, names, 'add');
280
+ }
281
+ /** An explicit delivery to the node lifts the tombstone. */
282
+ async clearTombstones(nodeId, names) {
283
+ await this.editTombstones(nodeId, names, 'clear');
284
+ }
285
+ async editTombstones(nodeId, names, mode) {
286
+ if (names.length === 0)
287
+ return;
288
+ const kind = this.resolveKind();
289
+ if (kind === null)
290
+ return;
291
+ try {
292
+ const existing = await this.readStored(kind, nodeId);
293
+ if (existing === null && mode === 'clear')
294
+ return;
295
+ const current = new Set(existing?.descriptor.tombstones ?? []);
296
+ const before = current.size;
297
+ for (const name of names) {
298
+ if (mode === 'add')
299
+ current.add(name);
300
+ else
301
+ current.delete(name);
302
+ }
303
+ if (current.size === before)
304
+ return;
305
+ const base = existing?.descriptor ?? ClusterNodeHistoryStore.placeholderDescriptor(nodeId);
306
+ await this.writeStored(kind, nodeId, existing?.lastActive ?? Date.now(), {
307
+ ...base,
308
+ tombstones: [...current].sort(),
309
+ });
310
+ }
311
+ catch (err) {
312
+ this.logger.warn('editTombstones failed (best-effort)', {
313
+ tags: { nodeId },
314
+ meta: { error: (0, types_1.errMsg)(err), mode },
315
+ });
316
+ }
317
+ }
254
318
  /**
255
319
  * Touch `lastActive` for a node without rewriting its descriptor — used on
256
320
  * `$node.disconnected` so an offline node's "last seen" is the disconnect
@@ -302,12 +366,17 @@ class ClusterNodeHistoryStore {
302
366
  });
303
367
  continue;
304
368
  }
305
- result.push({ ...parsed.data.descriptor, lastActive: parsed.data.lastActive });
369
+ result.push({
370
+ ...parsed.data.descriptor,
371
+ lastActive: parsed.data.lastActive,
372
+ });
306
373
  }
307
374
  return result;
308
375
  }
309
376
  catch (err) {
310
- this.logger.warn('history read failed (best-effort)', { meta: { error: (0, types_1.errMsg)(err) } });
377
+ this.logger.warn('history read failed (best-effort)', {
378
+ meta: { error: (0, types_1.errMsg)(err) },
379
+ });
311
380
  return null;
312
381
  }
313
382
  }
@@ -330,7 +399,9 @@ class ClusterNodeHistoryStore {
330
399
  const existed = (await this.readStored(kind, nodeId)) !== null;
331
400
  await kind.delete(nodeId);
332
401
  if (!existed) {
333
- this.logger.info('cluster node had no history row to forget', { tags: { nodeId } });
402
+ this.logger.info('cluster node had no history row to forget', {
403
+ tags: { nodeId },
404
+ });
334
405
  return 'not-found';
335
406
  }
336
407
  this.logger.info('forgot cluster node', { tags: { nodeId } });
package/dist/main.js CHANGED
@@ -456,7 +456,7 @@ async function bootstrap() {
456
456
  // gap a deploy opens and repair the node over the bytes just shipped.
457
457
  await (0, addon_upload_1.registerAddonUploadRoute)(fastify, uploadAddonBridge, uploadAuthService, uploadMoleculer, uploadAddonRegistry, uploadAddonPackage, uploadLogger, (0, hub_cluster_url_1.clusterBaseUrlResolver)(() => uploadAddonRegistry
458
458
  .getCapabilityRegistry()
459
- .getSingleton('local-network')), app.get(agent_registry_service_1.AgentRegistryService).deliveryLedger);
459
+ .getSingleton('local-network')), app.get(agent_registry_service_1.AgentRegistryService).deliveryLedger, app.get(agent_registry_service_1.AgentRegistryService));
460
460
  console.log('[bootstrap] Addon upload route registered at POST /api/addons/upload');
461
461
  // Dev server-deploy channel gateway (`camstack deploy-server`). MUST be
462
462
  // registered AFTER registerAddonUploadRoute — it reuses the multipart