@camstack/server 1.2.705 → 1.2.707

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/CHANGELOG.md CHANGED
@@ -7,6 +7,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [1.2.707] - 2026-10-31
11
+
12
+ ### Changed
13
+
14
+ - Agents receive addon installs and updates again: the convergence dry run no longer blocks every reconcile and holds back only removals.
15
+
16
+ ## [1.2.706] - 2026-10-31
17
+
18
+ ### Changed
19
+
20
+ - The hub now owns every node's addon set: nodes converge to the hub's any-node addons and tracked agent-ui on connect, after a hub install or uninstall, and every 10 minutes (dry run until addons.convergence.dryRun is set to false); updating agent-ui from the hub now reaches every agent
21
+
10
22
  ## [1.2.705] - 2026-10-31
11
23
 
12
24
  ### Fixed
@@ -124,7 +124,13 @@ resolveClusterBaseUrl,
124
124
  * opens for a node that lost its addons. Optional so route unit tests need
125
125
  * not build one; `main.ts` always passes the AgentRegistryService's.
126
126
  */
127
- deliveryLedger) {
127
+ deliveryLedger,
128
+ /**
129
+ * The D982 convergence: an explicit delivery to one node lifts its tombstone,
130
+ * and a hub install asks for a convergence round. Optional so route unit tests
131
+ * need not build one; `main.ts` passes the AgentRegistryService.
132
+ */
133
+ convergence) {
128
134
  await fastify.register(Promise.resolve().then(() => __importStar(require('@fastify/multipart'))), {
129
135
  limits: { fileSize: MAX_UPLOAD_BYTES },
130
136
  });
@@ -181,10 +187,10 @@ deliveryLedger) {
181
187
  // the package's addons (i.e. anything not marked hub-only). Any other
182
188
  // explicit `nodeId` value routes only to that agent via `$agent.deploy`.
183
189
  if (!nodeId || nodeId === 'hub') {
184
- return installToHub(reply, addonBridge, addonRegistry, addonPackageService, moleculer, logger, data.filename, buffer, resolveClusterBaseUrl, deliveryLedger);
190
+ return installToHub(reply, addonBridge, addonRegistry, addonPackageService, moleculer, logger, data.filename, buffer, resolveClusterBaseUrl, deliveryLedger, convergence);
185
191
  }
186
192
  const agentAddonId = addonIdHint ?? manifest.name;
187
- return deployToAgent(reply, moleculer, logger, nodeId, agentAddonId, buffer, resolveClusterBaseUrl, deliveryLedger);
193
+ return deployToAgent(reply, moleculer, logger, nodeId, agentAddonId, buffer, resolveClusterBaseUrl, deliveryLedger, convergence);
188
194
  });
189
195
  }
190
196
  /**
@@ -276,7 +282,7 @@ async function propagateToAgents(moleculer, logger, packageName, buffer, resolve
276
282
  * Without this the CLI push was write-to-disk-only and required a server
277
283
  * restart to actually run the new code.
278
284
  */
279
- async function installToHub(reply, addonBridge, addonRegistry, addonPackageService, moleculer, logger, filename, buffer, resolveClusterBaseUrl, deliveryLedger) {
285
+ async function installToHub(reply, addonBridge, addonRegistry, addonPackageService, moleculer, logger, filename, buffer, resolveClusterBaseUrl, deliveryLedger, convergence) {
280
286
  const tmpDir = path.join(os.tmpdir(), `camstack-addon-upload-${Date.now()}`);
281
287
  fs.mkdirSync(tmpDir, { recursive: true });
282
288
  const tgzPath = path.join(tmpDir, filename);
@@ -405,8 +411,12 @@ async function installToHub(reply, addonBridge, addonRegistry, addonPackageServi
405
411
  logger.info('propagation done', {
406
412
  meta: { packageName: result.name, agents: agentResults },
407
413
  });
414
+ void convergence?.requestConvergence('hub-change');
408
415
  });
409
416
  }
417
+ else {
418
+ void convergence?.requestConvergence('hub-change');
419
+ }
410
420
  return reply.send({
411
421
  success: true,
412
422
  target: 'hub',
@@ -432,7 +442,7 @@ async function installToHub(reply, addonBridge, addonRegistry, addonPackageServi
432
442
  * evidence. The response now carries what was reloaded and what was verified,
433
443
  * so the CLI can print facts instead of a bare tick.
434
444
  */
435
- async function deployToAgent(reply, moleculer, logger, nodeId, addonId, buffer, resolveClusterBaseUrl, deliveryLedger) {
445
+ async function deployToAgent(reply, moleculer, logger, nodeId, addonId, buffer, resolveClusterBaseUrl, deliveryLedger, convergence) {
436
446
  const hubBaseUrl = await resolveBaseUrlOrReason(resolveClusterBaseUrl);
437
447
  if (!hubBaseUrl.ok) {
438
448
  logger.error('agent deploy: no hub address the agent can reach — nothing delivered', {
@@ -457,6 +467,8 @@ async function deployToAgent(reply, moleculer, logger, nodeId, addonId, buffer,
457
467
  if (!result.success) {
458
468
  return reply.status(502).send({ error: `Agent deploy failed: ${result.error ?? 'unknown'}` });
459
469
  }
470
+ // An operator's explicit delivery to this node lifts its tombstone (D982).
471
+ await convergence?.noteExplicitDelivery(nodeId, addonId);
460
472
  return reply.send({
461
473
  success: true,
462
474
  target: nodeId,
@@ -79,6 +79,7 @@ const server_1 = require("@trpc/server");
79
79
  const integration_id_backfill_1 = require("../../boot/integration-id-backfill");
80
80
  const addon_package_service_js_1 = require("../../core/addon/addon-package.service.js");
81
81
  const addon_uninstall_js_1 = require("../../core/addon/addon-uninstall.js");
82
+ const agent_desired_set_1 = require("../../core/agent/agent-desired-set");
82
83
  const lifecycle_runner_singleton_js_1 = require("../../core/lifecycle/lifecycle-runner.singleton.js");
83
84
  const agent_installed_packages_js_1 = require("../../core/updates/agent-installed-packages.js");
84
85
  const http_request_census_singleton_js_1 = require("../trpc/http-request-census.singleton.js");
@@ -1517,6 +1518,20 @@ function buildAddonsProvider(ar, ps, ls, moleculer, configService, ctx, agentReg
1517
1518
  const broker = (0, system_1.asBrokerLike)(moleculer.broker);
1518
1519
  const uninstallSeam = buildUninstallSeam(ar, ps, broker, agentRegistry);
1519
1520
  const frameworkAllowSet = new Set([addon_package_service_js_1.SYSTEM_PACKAGE]);
1521
+ // D982: a hub install / update / uninstall changes every node's desired set.
1522
+ // Fire-and-forget — the round never gates the operator's call, and bursts
1523
+ // collapse into one extra round inside `requestConvergence`.
1524
+ const requestHubChangeConvergence = () => {
1525
+ void agentRegistry?.requestConvergence('hub-change');
1526
+ };
1527
+ // Ship an already-packed tarball to one agent and reload it. The reload
1528
+ // re-instantiates the changed package's addons; for a large package (the whole
1529
+ // pipeline stack) that can take well over a minute, so allow generous headroom
1530
+ // — a timeout here aborts the call even though the deploy already landed.
1531
+ const deployPackedToAgent = async (agentNodeId, packageName, bundle) => {
1532
+ await broker.call('$agent.deploy', { addonId: packageName, bundle }, { nodeID: agentNodeId, timeout: 300_000 });
1533
+ await broker.call('$agent.reload', {}, { nodeID: agentNodeId, timeout: 300_000 });
1534
+ };
1520
1535
  // Process-wide runner built once at boot (manual-boot wires its deps from the
1521
1536
  // addon-package service + event bus). `createdBy` travels per `startJob` call.
1522
1537
  const lifecycleRunner = (0, lifecycle_runner_singleton_js_1.getLifecycleRunner)();
@@ -1573,13 +1588,23 @@ function buildAddonsProvider(ar, ps, ls, moleculer, configService, ctx, agentReg
1573
1588
  level: input.level,
1574
1589
  }),
1575
1590
  listPackages: async () => ps.listInstalled(),
1576
- installPackage: async (input) => ps.installAndLoad(input.packageName, input.version),
1577
- installFromWorkspace: async (input) => ps.installFromWorkspaceAndLoad(input.packageName),
1591
+ installPackage: async (input) => {
1592
+ const result = await ps.installAndLoad(input.packageName, input.version);
1593
+ requestHubChangeConvergence();
1594
+ return result;
1595
+ },
1596
+ installFromWorkspace: async (input) => {
1597
+ const result = await ps.installFromWorkspaceAndLoad(input.packageName);
1598
+ requestHubChangeConvergence();
1599
+ return result;
1600
+ },
1578
1601
  isWorkspaceAvailable: async () => ps.isWorkspaceAvailable(),
1579
1602
  listWorkspacePackages: async () => ps.listWorkspacePackages(),
1580
1603
  uninstallPackage: async (input) => {
1581
1604
  try {
1582
- return await (0, addon_uninstall_js_1.uninstallPackage)(uninstallSeam, input);
1605
+ const result = await (0, addon_uninstall_js_1.uninstallPackage)(uninstallSeam, input);
1606
+ requestHubChangeConvergence();
1607
+ return result;
1583
1608
  }
1584
1609
  catch (err) {
1585
1610
  if (err instanceof addon_uninstall_js_1.PackageProtectedError) {
@@ -1607,6 +1632,32 @@ function buildAddonsProvider(ar, ps, ls, moleculer, configService, ctx, agentReg
1607
1632
  },
1608
1633
  updatePackage: async (input) => {
1609
1634
  const nodeId = input.nodeId;
1635
+ if ((0, agent_desired_set_1.isAgentTrackedPackage)(input.name) && (nodeId === undefined || isHubNode(nodeId))) {
1636
+ // An agent-only package the hub tracks without loading it (agent-ui, D982):
1637
+ // there is nothing to update on the hub, so the update fans out to every
1638
+ // online agent instead of failing with "not currently tracked in manifest".
1639
+ if (agentRegistry === undefined) {
1640
+ return {
1641
+ success: false,
1642
+ error: `${input.name} is delivered by the agent registry, which is not wired`,
1643
+ };
1644
+ }
1645
+ const packed = await ps.packPackage(input.name, input.version);
1646
+ const targets = agentRegistry.onlineAgentIds();
1647
+ const failures = [];
1648
+ for (const target of targets) {
1649
+ try {
1650
+ await deployPackedToAgent(target, input.name, packed.buffer);
1651
+ await agentRegistry.noteExplicitDelivery(target, input.name);
1652
+ }
1653
+ catch (err) {
1654
+ failures.push(`${target}: ${err instanceof Error ? err.message : String(err)}`);
1655
+ }
1656
+ }
1657
+ if (failures.length > 0)
1658
+ return { success: false, error: failures.join('; ') };
1659
+ return { success: true, name: input.name, version: packed.version };
1660
+ }
1610
1661
  if (nodeId === undefined || isHubNode(nodeId)) {
1611
1662
  // Hub-single: route through the lifecycle job engine so the fast
1612
1663
  // staged-swap path (applyStagedAddonUpdate) is always used.
@@ -1619,6 +1670,7 @@ function buildAddonsProvider(ar, ps, ls, moleculer, configService, ctx, agentReg
1619
1670
  const job = lifecycleRunner.getJob(jobId);
1620
1671
  const task = job?.tasks[0];
1621
1672
  if (task?.phase === 'done') {
1673
+ requestHubChangeConvergence();
1622
1674
  return { success: true, name: input.name, version, jobId };
1623
1675
  }
1624
1676
  return { success: false, error: task?.error ?? 'update failed', jobId };
@@ -1646,8 +1698,9 @@ function buildAddonsProvider(ar, ps, ls, moleculer, configService, ctx, agentReg
1646
1698
  // minute, so allow generous headroom — a timeout here aborts the call
1647
1699
  // even though the deploy already landed on disk.
1648
1700
  const packed = await ps.packPackage(input.name, input.version);
1649
- await broker.call('$agent.deploy', { addonId: input.name, bundle: packed.buffer }, { nodeID: nodeId, timeout: 300_000 });
1650
- await broker.call('$agent.reload', {}, { nodeID: nodeId, timeout: 300_000 });
1701
+ await deployPackedToAgent(nodeId, input.name, packed.buffer);
1702
+ // An operator's explicit delivery lifts the node's tombstone (D982).
1703
+ await agentRegistry?.noteExplicitDelivery(nodeId, input.name);
1651
1704
  return { success: true, name: input.name, version: packed.version, nodeId };
1652
1705
  },
1653
1706
  rollbackPackage: async (input) => ps.rollbackPackage(input.name),
@@ -752,6 +752,14 @@ class AddonPackageService {
752
752
  fs.rmSync(destDir, { recursive: true, force: true });
753
753
  }
754
754
  }
755
+ /**
756
+ * The version the registry serves as `latest` for a package — what the hub
757
+ * follows for an agent-only package it tracks without installing (D982).
758
+ * Throws when the registry cannot answer.
759
+ */
760
+ async resolveRegistryVersion(name) {
761
+ return resolveNpmVersion(name, 'latest', process.env['CAMSTACK_NPM_REGISTRY']);
762
+ }
755
763
  /**
756
764
  * Resolve `name@version` and `npm pack` it into a tarball buffer
757
765
  * WITHOUT installing. Used to push a package update to an agent: the
@@ -1,155 +1,45 @@
1
1
  "use strict";
2
2
  /**
3
- * Node addon back-fill — converge a node to the addon roster THAT NODE last
4
- * declared.
3
+ * Node addon delivery — the bookkeeping that carries a convergence plan to a
4
+ * node: the trigger allowlist, the delivery circuit breaker, the confirmation
5
+ * read and the execution seam.
5
6
  *
6
- * WHY THIS EXISTS
7
- * `AgentRegistryService.reconcileAgentAddons` used to be undeploy-only: it read
8
- * the node's live roster, removed what no longer belonged, and had no branch for
9
- * "installed on the hub but missing on the node". A node whose `<dataDir>/addons`
10
- * had been wiped therefore came back with only its bootstrap packages
11
- * (`@camstack/system` + `camstack.agentBootstrapAddons`) and the hub never sent
12
- * the rest — measured live as 4 of 14 packages on `agent-a`. Under the
13
- * volume-only image (no addons baked in) an empty addons dir is the ORDINARY
14
- * first-boot state of every new or rebuilt node, so that gap becomes the normal
15
- * case rather than an anomaly.
7
+ * WHAT A NODE SHOULD HAVE is decided in `agent-desired-set.ts`
8
+ * ([D982](../../../../docs/decisions/adr-0982-the-hub-owns-every-nodes-addon-set.md)):
9
+ * the hub's desired set, never the roster the node declared. This module only
10
+ * delivers a computed plan, and is seam-driven so that is tested with no
11
+ * Moleculer, filesystem or npm registry.
16
12
  *
17
- * THE INTENDED SET
18
- * A node's intended set is the roster the node itself last declared while
19
- * online, remembered durably hub-side (`ClusterNodeHistoryStore`). It is not
20
- * invented by the hub and it is not "every agent-deployable package the hub has
21
- * installed" — that would over-install and would override deliberate per-node
22
- * operator choices. The record GROWS when a node declares a package and SHRINKS
23
- * only on an explicit undeploy (operator gesture or the stale-reconcile), so
24
- * both mutations are attributable actions rather than heuristics.
25
- *
26
- * THE INTENDED VERSION
27
- * The hub's own installed version, and only that — including when the node
28
- * is AHEAD of the hub (D942). Never npm `latest` directly: the hub follows
29
- * the registry, and every node follows the hub. A package the hub's version
30
- * map does not name is left as it is when it is already installed, and is
31
- * never delivered when it is missing (`notOnHub`). An unreadable hub version
32
- * is not a reason to move.
33
- *
34
- * A package the hub does NOT install is never delivered, whatever the node last
35
- * reported. It used to fall back to the node's last-reported version, which
36
- * made the back-fill the one path that could put an UNINSTALLED addon back: an
37
- * operator removes `@camstack/addon-decoder-ffmpeg` from the hub and moves the
38
- * agent's copy aside, and the next reconnect reinstalled it from npm at the
39
- * version the node last ran — while the same pass's stale sweep exists to
40
- * remove exactly such an addon. The two directions disagreed; now the hub's
41
- * install set bounds both. Such packages are reported as `notOnHub`.
13
+ * THE VERSION is the hub's own installed version, and only that — including when
14
+ * the node is AHEAD of the hub (D942). Never npm `latest` directly for a package
15
+ * the hub installs: the hub follows the registry, and every node follows the hub.
16
+ * An agent-only package the hub tracks (agent-ui) is delivered at the registry
17
+ * version the hub resolved for it.
42
18
  *
43
19
  * BOUNDING
44
20
  * Delivery failures are counted per `(node, package, version)` and the pair is
45
21
  * retired after {@link MAX_BACKFILL_ATTEMPTS}, exactly as `CrashSupervisor`
46
- * bounds respawns (D6). A reconnect flap therefore cannot turn a broken package
47
- * into an endless push loop. A different hub version resets the count — a new
48
- * version is a new fact worth one more attempt.
49
- *
50
- * This module is pure/seam-driven on purpose: the decision of what a node should
51
- * have is asserted in unit tests with no Moleculer, filesystem or npm registry.
22
+ * bounds respawns (D6). A reconnect flap or a periodic round therefore cannot
23
+ * turn a broken package into an endless push loop. A different hub version
24
+ * resets the count — a new version is a new fact worth one more attempt.
52
25
  */
53
26
  Object.defineProperty(exports, "__esModule", { value: true });
54
27
  exports.BackfillAttemptTracker = exports.MAX_BACKFILL_ATTEMPTS = void 0;
55
28
  exports.triggerMayDeliver = triggerMayDeliver;
56
- exports.freshNodeSkipReason = freshNodeSkipReason;
57
- exports.computeBackfillPlan = computeBackfillPlan;
58
29
  exports.confirmBackfillPlan = confirmBackfillPlan;
59
30
  exports.executeBackfill = executeBackfill;
60
- const system_1 = require("@camstack/system");
61
31
  /** Triggers permitted to install packages onto a node. */
62
32
  const DELIVERING_TRIGGERS = new Set([
63
33
  'node-registration',
64
34
  'hub-boot',
65
35
  'operator-repair',
36
+ 'periodic',
37
+ 'hub-change',
66
38
  ]);
67
39
  /** Is this trigger allowed to put bytes on a node? */
68
40
  function triggerMayDeliver(trigger) {
69
41
  return DELIVERING_TRIGGERS.has(trigger);
70
42
  }
71
- /**
72
- * Why a hub package may not go to a fresh node, or `null` when it may.
73
- * A package with a hub-only addon is refused whole: the stale sweep would
74
- * undeploy that addon and with it the shared bundle directory.
75
- */
76
- function freshNodeSkipReason(placements) {
77
- if (placements.includes('hub-only'))
78
- return 'hub-only placement';
79
- if (!placements.includes('any-node'))
80
- return 'placement does not allow any node';
81
- return null;
82
- }
83
- /**
84
- * Decide what a node is missing relative to its own last-declared roster.
85
- * Pure — no I/O, no clock, no randomness.
86
- */
87
- function computeBackfillPlan(input) {
88
- if (input.freshNode !== undefined && input.recorded.length === 0) {
89
- return computeFreshNodePlan(input, input.freshNode);
90
- }
91
- const liveByName = new Map(input.live.map((p) => [p.name, p.version]));
92
- const targets = [];
93
- const retired = [];
94
- const notOnHub = [];
95
- for (const entry of input.recorded) {
96
- if (input.bootstrap.has(entry.name))
97
- continue;
98
- if (input.undeployed.has(entry.name))
99
- continue;
100
- const hubVersion = input.hubVersions.get(entry.name);
101
- const present = liveByName.has(entry.name);
102
- const liveVersion = present ? (liveByName.get(entry.name) ?? null) : null;
103
- if (hubVersion === undefined) {
104
- if (!present && input.retired.has(entry.name))
105
- retired.push(entry.name);
106
- else if (!present)
107
- notOnHub.push(entry.name);
108
- continue;
109
- }
110
- const decision = (0, system_1.decideAddonVersionMove)(present ? liveVersion : null, hubVersion, system_1.ADDON_VERSION_REASON_HUB);
111
- if (!decision.move)
112
- continue;
113
- if (input.retired.has(entry.name)) {
114
- retired.push(entry.name);
115
- continue;
116
- }
117
- targets.push({ name: entry.name, version: hubVersion, versionSource: 'hub-installed' });
118
- }
119
- return { targets, retired, notOnHub, liveCount: liveByName.size, skipped: [] };
120
- }
121
- function computeFreshNodePlan(input, fresh) {
122
- const liveByName = new Map(input.live.map((p) => [p.name, p.version]));
123
- const targets = [];
124
- const retired = [];
125
- const skipped = [];
126
- for (const [name, placements] of fresh.hubPackagePlacements) {
127
- if (input.bootstrap.has(name))
128
- continue;
129
- if (input.undeployed.has(name))
130
- continue;
131
- const reason = freshNodeSkipReason(placements);
132
- if (reason !== null) {
133
- skipped.push({ name, reason });
134
- continue;
135
- }
136
- const hubVersion = input.hubVersions.get(name);
137
- if (hubVersion === undefined) {
138
- skipped.push({ name, reason: 'hub version unknown' });
139
- continue;
140
- }
141
- const present = liveByName.has(name);
142
- const decision = (0, system_1.decideAddonVersionMove)(present ? (liveByName.get(name) ?? null) : null, hubVersion, system_1.ADDON_VERSION_REASON_HUB);
143
- if (!decision.move)
144
- continue;
145
- if (input.retired.has(name)) {
146
- retired.push(name);
147
- continue;
148
- }
149
- targets.push({ name, version: hubVersion, versionSource: 'hub-installed' });
150
- }
151
- return { targets, retired, notOnHub: [], liveCount: liveByName.size, skipped };
152
- }
153
43
  function confirmBackfillPlan(plan, secondRead) {
154
44
  const seen = new Map(secondRead.map((p) => [p.name, p.version]));
155
45
  const targets = [];
@@ -255,11 +145,16 @@ async function executeBackfill(seam, nodeId, plan, tracker) {
255
145
  catch (err) {
256
146
  const error = err instanceof Error ? err.message : String(err);
257
147
  const { retired } = tracker.recordFailure(nodeId, target.name, target.version, error);
258
- failed.push({ name: target.name, version: target.version, error, retired });
148
+ failed.push({
149
+ name: target.name,
150
+ version: target.version,
151
+ error,
152
+ retired,
153
+ });
259
154
  }
260
155
  }
261
156
  if (delivered.length > 0) {
262
157
  await seam.reload(nodeId);
263
158
  }
264
- return { delivered, failed, skipped: plan.retired, notOnHub: plan.notOnHub };
159
+ return { delivered, failed, skipped: plan.retired };
265
160
  }
@@ -0,0 +1,89 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.AGENT_TRACKED_PACKAGES = void 0;
4
+ exports.isAgentTrackedPackage = isAgentTrackedPackage;
5
+ exports.computeDesiredSet = computeDesiredSet;
6
+ exports.diffNodeAgainstDesired = diffNodeAgainstDesired;
7
+ const system_1 = require("@camstack/system");
8
+ /**
9
+ * Agent-only packages the hub tracks and delivers without loading them itself.
10
+ * The version the hub follows for each is the registry's `latest`, resolved by
11
+ * the caller and passed in as `trackedForAgents`.
12
+ */
13
+ exports.AGENT_TRACKED_PACKAGES = ['@camstack/addon-agent-ui'];
14
+ function isAgentTrackedPackage(name) {
15
+ return exports.AGENT_TRACKED_PACKAGES.includes(name);
16
+ }
17
+ function computeDesiredSet(input) {
18
+ const desired = new Map();
19
+ const hubOnly = new Set();
20
+ const excluded = [];
21
+ for (const [name, fact] of input.hubPackages) {
22
+ if (input.bootstrap.has(name))
23
+ continue;
24
+ if (fact.placements.length > 0 && fact.placements.every((p) => p === 'hub-only')) {
25
+ hubOnly.add(name);
26
+ excluded.push({ name, reason: 'hub-only placement' });
27
+ continue;
28
+ }
29
+ if (fact.placements.includes('hub-only')) {
30
+ excluded.push({ name, reason: 'mixes a hub-only addon' });
31
+ continue;
32
+ }
33
+ if (!fact.placements.includes('any-node')) {
34
+ excluded.push({ name, reason: 'placement does not allow any node' });
35
+ continue;
36
+ }
37
+ if (input.tombstones.has(name)) {
38
+ excluded.push({ name, reason: 'tombstoned' });
39
+ continue;
40
+ }
41
+ if (fact.version === null) {
42
+ excluded.push({ name, reason: 'hub version unknown' });
43
+ continue;
44
+ }
45
+ desired.set(name, { name, version: fact.version, origin: 'hub-any-node' });
46
+ }
47
+ for (const [name, version] of input.trackedForAgents) {
48
+ if (input.bootstrap.has(name))
49
+ continue;
50
+ if (input.tombstones.has(name)) {
51
+ excluded.push({ name, reason: 'tombstoned' });
52
+ continue;
53
+ }
54
+ desired.set(name, { name, version, origin: 'agent-tracked' });
55
+ }
56
+ return { desired, hubOnly, tombstones: input.tombstones, excluded };
57
+ }
58
+ function diffNodeAgainstDesired(input) {
59
+ const observed = new Map(input.observed.map((p) => [p.name, p.version]));
60
+ const install = [];
61
+ const update = [];
62
+ const remove = [];
63
+ const retired = [];
64
+ for (const entry of input.desired.desired.values()) {
65
+ const present = observed.has(entry.name);
66
+ const from = present ? (observed.get(entry.name) ?? null) : null;
67
+ const decision = (0, system_1.decideAddonVersionMove)(from, entry.version, system_1.ADDON_VERSION_REASON_HUB);
68
+ if (!decision.move)
69
+ continue;
70
+ if (input.retired.has(entry.name)) {
71
+ retired.push(entry.name);
72
+ continue;
73
+ }
74
+ const target = { ...entry, from };
75
+ if (present)
76
+ update.push(target);
77
+ else
78
+ install.push(target);
79
+ }
80
+ for (const name of observed.keys()) {
81
+ if (input.bootstrap.has(name))
82
+ continue;
83
+ if (input.desired.tombstones.has(name))
84
+ remove.push({ name, reason: 'tombstoned' });
85
+ else if (input.desired.hubOnly.has(name))
86
+ remove.push({ name, reason: 'hub-only placement' });
87
+ }
88
+ return { install, update, remove, retired };
89
+ }
@@ -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,177 @@ 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)
923
1047
  return;
924
- }
1048
+ const dryRun = this.readConvergenceDryRun(agentId);
1049
+ this.logger?.info(dryRun ? 'convergence plan (DRY RUN, removals held back)' : '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
+ });
925
1060
  // GATE 1 — the trigger allowlist. A pass that must not install says so and
926
1061
  // stops; silence here would read as "nothing was missing".
927
1062
  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)`);
1063
+ console.log(`[agent-registry] Convergence ${agentId}: SKIPPED — trigger "${trigger}" may not deliver ` +
1064
+ `(${changes} change(s) planned)`);
930
1065
  return;
931
1066
  }
932
1067
  // 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.
1068
+ // evidence a node is missing anything (`camstack deploy … -n <agent>` was
1069
+ // reverted ~1.5s later on 2026-08-07).
935
1070
  const suppression = this.deliveryLedger.suppressionFor(agentId);
936
1071
  if (suppression !== null) {
937
- console.log(`[agent-registry] Back-fill ${agentId}: SKIPPED — a hub delivery to this node is ` +
1072
+ console.log(`[agent-registry] Convergence ${agentId}: SKIPPED — a hub delivery to this node is ` +
938
1073
  `in flight or just finished (inFlight=${suppression.inFlight}, ` +
939
1074
  `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`);
1075
+ `packages=${suppression.packages.join(', ') || 'none'}); the roster gap is OURS, not the node's`);
942
1076
  return;
943
1077
  }
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)
1078
+ // The dry run holds back only what D982 added — removals. Installs and
1079
+ // updates are the delivery agents always had; holding them back stopped
1080
+ // every agent from updating.
1081
+ if (!dryRun)
1082
+ await this.applyRemovals(agentId, agentAddons, planned);
1083
+ await this.applyDeliveries(agentId, live, planned, trigger, seam);
1084
+ }
1085
+ /**
1086
+ * The dry-run switch, read fail-safe: a read that cannot be answered counts
1087
+ * as ON (nothing is removed) and never fails the reconcile.
1088
+ */
1089
+ readConvergenceDryRun(agentId) {
1090
+ try {
1091
+ return this.convergenceDryRun();
1092
+ }
1093
+ catch (err) {
1094
+ this.logger?.warn('convergence: dry-run switch unreadable, treated as ON', {
1095
+ tags: { nodeId: agentId },
1096
+ meta: { error: err instanceof Error ? err.message : String(err) },
1097
+ });
1098
+ return true;
1099
+ }
1100
+ }
1101
+ /**
1102
+ * Remove tombstoned and hub-only leftovers. A removal DESTROYS work, so it
1103
+ * needs a second roster read to agree (D49) and fails closed when that read
1104
+ * cannot be taken. Every removal logs its reason.
1105
+ */
1106
+ async applyRemovals(agentId, agentAddons, plan) {
1107
+ if (plan.remove.length === 0)
948
1108
  return;
949
- if (plan.targets.length === 0 && plan.retired.length === 0) {
1109
+ let secondAddons = null;
1110
+ try {
1111
+ secondAddons = this.extractAgentAddons(await this.broker.call('$agent.status', {}, { nodeID: agentId, timeout: AGENT_RECONCILE_RPC_TIMEOUT_MS }));
1112
+ }
1113
+ catch (err) {
1114
+ this.logger?.warn('convergence: removals skipped, the confirmation read failed', {
1115
+ tags: { nodeId: agentId },
1116
+ meta: { error: err instanceof Error ? err.message : String(err) },
1117
+ });
950
1118
  return;
951
1119
  }
1120
+ if (secondAddons === null) {
1121
+ this.logger?.warn('convergence: removals skipped, the confirmation read had no addon list', {
1122
+ tags: { nodeId: agentId },
1123
+ });
1124
+ return;
1125
+ }
1126
+ const stillThere = new Set(secondAddons.flatMap((a) => (a.packageName === undefined ? [] : [a.packageName])));
1127
+ for (const removal of plan.remove) {
1128
+ if (!stillThere.has(removal.name))
1129
+ continue;
1130
+ const addonIds = agentAddons.filter((a) => a.packageName === removal.name).map((a) => a.id);
1131
+ try {
1132
+ // One package-mode call removes every addon the package ships; the first
1133
+ // id rides along so an agent that predates package mode still removes it.
1134
+ await this.broker.call('$agent.undeploy', {
1135
+ packageName: removal.name,
1136
+ ...(addonIds[0] === undefined ? {} : { addonId: addonIds[0] }),
1137
+ }, { nodeID: agentId, timeout: AGENT_RECONCILE_RPC_TIMEOUT_MS });
1138
+ this.logger?.info('convergence: removed a package from the node', {
1139
+ tags: { nodeId: agentId },
1140
+ meta: { package: removal.name, reason: removal.reason, addonIds },
1141
+ });
1142
+ this.eventBus.emit({
1143
+ id: (0, node_crypto_1.randomUUID)(),
1144
+ timestamp: new Date(),
1145
+ source: { type: 'core', id: 'agent-registry' },
1146
+ category: types_1.EventCategory.AddonUninstalled,
1147
+ data: {
1148
+ agentId,
1149
+ packageName: removal.name,
1150
+ reason: `convergence: ${removal.reason}`,
1151
+ },
1152
+ });
1153
+ }
1154
+ catch (err) {
1155
+ this.logger?.warn('convergence: removing a package failed, the next round retries', {
1156
+ tags: { nodeId: agentId },
1157
+ meta: {
1158
+ package: removal.name,
1159
+ reason: removal.reason,
1160
+ error: err instanceof Error ? err.message : String(err),
1161
+ },
1162
+ });
1163
+ }
1164
+ }
1165
+ }
1166
+ /** Install and update, behind the D49 confirmation read. */
1167
+ async applyDeliveries(agentId, live, plan, trigger, seam) {
1168
+ const planned = {
1169
+ targets: [...plan.install, ...plan.update].map((t) => ({
1170
+ name: t.name,
1171
+ version: t.version,
1172
+ versionSource: t.origin === 'agent-tracked' ? 'agent-tracked' : 'hub-installed',
1173
+ })),
1174
+ retired: plan.retired,
1175
+ liveCount: live.length,
1176
+ };
1177
+ if (planned.targets.length === 0 && planned.retired.length === 0)
1178
+ return;
1179
+ // GATE 3 — the confirmation read (D49). A delivery overwrites whatever is
1180
+ // on the node, so the destroying direction needs a SECOND read to agree.
1181
+ const confirmed = await this.confirmBackfillTargets(agentId, planned);
1182
+ if (confirmed === null)
1183
+ return;
1184
+ if (confirmed.targets.length === 0 && confirmed.retired.length === 0)
1185
+ return;
952
1186
  // 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');
1187
+ // delivery is about to cause cannot trigger a second one.
1188
+ const closeWindow = this.deliveryLedger.begin(agentId, confirmed.targets.map((t) => t.name).join(', ') || 'convergence');
955
1189
  let outcome;
956
1190
  try {
957
- outcome = await (0, agent_addon_backfill_1.executeBackfill)(seam, agentId, plan, this.backfillTracker);
1191
+ outcome = await (0, agent_addon_backfill_1.executeBackfill)(seam, agentId, confirmed, this.backfillTracker);
958
1192
  }
959
1193
  finally {
960
1194
  closeWindow();
961
1195
  }
962
1196
  if (outcome.delivered.length > 0) {
963
- console.log(`[agent-registry] Back-fill ${agentId}: delivered ${outcome.delivered
1197
+ console.log(`[agent-registry] Convergence ${agentId}: delivered ${outcome.delivered
964
1198
  .map((d) => {
965
1199
  const from = live.find((p) => p.name === d.name)?.version ?? 'absent';
966
- return `${d.name} ${from} → ${d.version} (agent follows the hub installed version)`;
1200
+ return `${d.name} ${from} → ${d.version} (${d.versionSource})`;
967
1201
  })
968
- .join(', ')} — trigger=${trigger}, node reported ${plan.liveCount} package(s) and its ` +
969
- `declared roster has ${recorded.length}`);
1202
+ .join(', ')} — trigger=${trigger}, node reported ${confirmed.liveCount} package(s)`);
970
1203
  this.eventBus.emit({
971
1204
  id: (0, node_crypto_1.randomUUID)(),
972
1205
  timestamp: new Date(),
@@ -975,19 +1208,19 @@ class AgentRegistryService {
975
1208
  data: {
976
1209
  agentId,
977
1210
  packages: outcome.delivered.map((d) => `${d.name}@${d.version}`),
978
- reason: 'back-fill: node missing packages from its declared roster',
1211
+ reason: 'convergence: the hub desired set differs from what the node runs',
979
1212
  },
980
1213
  });
981
1214
  }
982
1215
  // Fail LOUD: a node missing capabilities must never merely look healthy.
983
1216
  for (const failure of outcome.failed) {
984
- console.error(`[agent-registry] Back-fill ${agentId}: FAILED to deliver ${failure.name}@${failure.version} — ${failure.error}` +
1217
+ console.error(`[agent-registry] Convergence ${agentId}: FAILED to deliver ${failure.name}@${failure.version} — ${failure.error}` +
985
1218
  (failure.retired
986
1219
  ? ` (retired after ${agent_addon_backfill_1.MAX_BACKFILL_ATTEMPTS} attempts; this node is running WITHOUT that package)`
987
- : ''));
1220
+ : ' (the next round retries)'));
988
1221
  }
989
1222
  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`);
1223
+ console.error(`[agent-registry] Convergence ${agentId}: ${name} is still wrong and RETIRED — the node is degraded until an operator intervenes`);
991
1224
  }
992
1225
  }
993
1226
  /**
@@ -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
@@ -254,6 +254,11 @@ async function bootManual(opts) {
254
254
  await moleculerService.broker.call('$agent.reload', {}, { nodeID: nodeId, timeout: agent_registry_service_1.AGENT_BACKFILL_RPC_TIMEOUT_MS });
255
255
  },
256
256
  });
257
+ // D982: the hub follows the registry's `latest` for an agent-only package it
258
+ // tracks without installing (agent-ui), and the convergence is DRY RUN unless
259
+ // the operator sets `addons.convergence.dryRun` to false.
260
+ agentRegistryService.setTrackedVersionResolver((name) => addonPackageService.resolveRegistryVersion(name));
261
+ agentRegistryService.setConvergenceDryRun(() => configService.get(agent_registry_service_1.ADDON_CONVERGENCE_DRY_RUN_KEY) !== false);
257
262
  // ---- Lifecycle job runner (boot singleton) -----------------------------
258
263
  // The runner used to be built per-tRPC-request inside the `addons` cap
259
264
  // factory. Boot-reconcile (F3 Task 4) and the auto-update scheduler (F3
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@camstack/server",
3
- "version": "1.2.705",
3
+ "version": "1.2.707",
4
4
  "license": "MIT",
5
5
  "repository": {
6
6
  "type": "git",
@@ -50,18 +50,18 @@
50
50
  ]
51
51
  },
52
52
  "dependencies": {
53
- "@camstack/addon-admin-ui": "1.2.529",
53
+ "@camstack/addon-admin-ui": "1.2.530",
54
54
  "@camstack/addon-agent-ui": "1.2.300",
55
- "@camstack/addon-auth": "1.2.172",
55
+ "@camstack/addon-auth": "1.2.173",
56
56
  "@camstack/addon-benchmark": "1.2.299",
57
57
  "@camstack/addon-decoder-nodeav": "1.2.144",
58
58
  "@camstack/addon-notifiers": "1.2.624",
59
59
  "@camstack/addon-pipeline": "1.2.635",
60
- "@camstack/addon-pipeline-orchestrator": "1.2.642",
60
+ "@camstack/addon-pipeline-orchestrator": "1.2.644",
61
61
  "@camstack/addon-post-analysis": "1.2.654",
62
62
  "@camstack/develop": "0.1.41",
63
63
  "@camstack/sdk": "1.2.154",
64
- "@camstack/system": "1.2.526",
64
+ "@camstack/system": "1.2.528",
65
65
  "@camstack/types": "1.2.453",
66
66
  "@camstack/ui-library": "1.2.358",
67
67
  "@fastify/compress": "^9.0.0",