@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 +12 -0
- package/dist/api/addon-upload.js +17 -5
- package/dist/api/core/cap-providers.js +58 -5
- package/dist/core/addon/addon-package.service.js +8 -0
- package/dist/core/agent/agent-addon-backfill.js +25 -130
- package/dist/core/agent/agent-desired-set.js +89 -0
- package/dist/core/agent/agent-registry.service.js +306 -73
- package/dist/core/agent/cluster-node-history-store.js +76 -5
- package/dist/main.js +1 -1
- package/dist/manual-boot.js +5 -0
- package/package.json +5 -5
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
|
package/dist/api/addon-upload.js
CHANGED
|
@@ -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) =>
|
|
1577
|
-
|
|
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
|
-
|
|
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
|
|
1650
|
-
|
|
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
|
|
4
|
-
*
|
|
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
|
-
*
|
|
7
|
-
*
|
|
8
|
-
* the
|
|
9
|
-
*
|
|
10
|
-
*
|
|
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
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
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
|
|
47
|
-
* into an endless push loop. A different hub version
|
|
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({
|
|
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
|
|
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 (
|
|
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 (
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
869
|
-
*
|
|
870
|
-
*
|
|
871
|
-
*
|
|
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
|
|
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
|
|
892
|
-
const
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
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
|
-
|
|
899
|
-
freshNode: priorRoster.length === 0 ? { hubPackagePlacements } : undefined,
|
|
1043
|
+
bootstrap: CONVERGENCE_BOOTSTRAP,
|
|
900
1044
|
});
|
|
901
|
-
|
|
902
|
-
|
|
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]
|
|
929
|
-
`(${
|
|
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
|
|
934
|
-
//
|
|
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]
|
|
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
|
-
//
|
|
945
|
-
//
|
|
946
|
-
|
|
947
|
-
if (
|
|
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
|
-
|
|
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
|
-
//
|
|
954
|
-
const closeWindow = this.deliveryLedger.begin(agentId,
|
|
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,
|
|
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]
|
|
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} (
|
|
1200
|
+
return `${d.name} ${from} → ${d.version} (${d.versionSource})`;
|
|
967
1201
|
})
|
|
968
|
-
.join(', ')} — trigger=${trigger}, node reported ${
|
|
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: '
|
|
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]
|
|
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]
|
|
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
|
|
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({
|
|
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)', {
|
|
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', {
|
|
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
|
package/dist/manual-boot.js
CHANGED
|
@@ -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.
|
|
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.
|
|
53
|
+
"@camstack/addon-admin-ui": "1.2.530",
|
|
54
54
|
"@camstack/addon-agent-ui": "1.2.300",
|
|
55
|
-
"@camstack/addon-auth": "1.2.
|
|
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.
|
|
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.
|
|
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",
|