@camstack/server 1.2.704 → 1.2.706
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +12 -0
- package/dist/agent/agent-shutdown.js +61 -0
- package/dist/agent/main.js +44 -28
- 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 +288 -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
|
@@ -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,159 @@ 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
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
// removes a copy that is still there).
|
|
918
|
-
if (planned.notOnHub.length > 0) {
|
|
919
|
-
console.log(`[agent-registry] Back-fill ${agentId}: NOT delivering ${planned.notOnHub.join(', ')} — ` +
|
|
920
|
-
`in the node's declared roster but not installed on the hub`);
|
|
921
|
-
}
|
|
922
|
-
if (planned.targets.length === 0 && planned.retired.length === 0) {
|
|
1045
|
+
const changes = planned.install.length + planned.update.length + planned.remove.length;
|
|
1046
|
+
if (changes === 0 && planned.retired.length === 0)
|
|
1047
|
+
return;
|
|
1048
|
+
const dryRun = this.convergenceDryRun();
|
|
1049
|
+
this.logger?.info(dryRun ? 'convergence plan (DRY RUN, nothing applied)' : 'convergence plan', {
|
|
1050
|
+
tags: { nodeId: agentId },
|
|
1051
|
+
meta: {
|
|
1052
|
+
trigger,
|
|
1053
|
+
install: planned.install.map((t) => `${t.name}@${t.version}`),
|
|
1054
|
+
update: planned.update.map((t) => `${t.name} ${t.from ?? 'absent'} -> ${t.version}`),
|
|
1055
|
+
remove: planned.remove.map((r) => `${r.name} (${r.reason})`),
|
|
1056
|
+
retired: planned.retired,
|
|
1057
|
+
observed: live.length,
|
|
1058
|
+
},
|
|
1059
|
+
});
|
|
1060
|
+
if (dryRun)
|
|
923
1061
|
return;
|
|
924
|
-
}
|
|
925
1062
|
// GATE 1 — the trigger allowlist. A pass that must not install says so and
|
|
926
1063
|
// stops; silence here would read as "nothing was missing".
|
|
927
1064
|
if (!(0, agent_addon_backfill_1.triggerMayDeliver)(trigger)) {
|
|
928
|
-
console.log(`[agent-registry]
|
|
929
|
-
`(${
|
|
1065
|
+
console.log(`[agent-registry] Convergence ${agentId}: SKIPPED — trigger "${trigger}" may not deliver ` +
|
|
1066
|
+
`(${changes} change(s) planned)`);
|
|
930
1067
|
return;
|
|
931
1068
|
}
|
|
932
1069
|
// GATE 2 — the delivery window. A roster gap the hub itself opened is not
|
|
933
|
-
// evidence a node is missing anything
|
|
934
|
-
//
|
|
1070
|
+
// evidence a node is missing anything (`camstack deploy … -n <agent>` was
|
|
1071
|
+
// reverted ~1.5s later on 2026-08-07).
|
|
935
1072
|
const suppression = this.deliveryLedger.suppressionFor(agentId);
|
|
936
1073
|
if (suppression !== null) {
|
|
937
|
-
console.log(`[agent-registry]
|
|
1074
|
+
console.log(`[agent-registry] Convergence ${agentId}: SKIPPED — a hub delivery to this node is ` +
|
|
938
1075
|
`in flight or just finished (inFlight=${suppression.inFlight}, ` +
|
|
939
1076
|
`msSinceLastDelivery=${suppression.msSinceLastDelivery ?? 'n/a'}, ` +
|
|
940
|
-
`packages=${suppression.packages.join(', ') || 'none'}); `
|
|
941
|
-
`the node reported ${planned.liveCount} package(s) and the roster gap is OURS, not the node's`);
|
|
1077
|
+
`packages=${suppression.packages.join(', ') || 'none'}); the roster gap is OURS, not the node's`);
|
|
942
1078
|
return;
|
|
943
1079
|
}
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
1080
|
+
await this.applyRemovals(agentId, agentAddons, planned);
|
|
1081
|
+
await this.applyDeliveries(agentId, live, planned, trigger, seam);
|
|
1082
|
+
}
|
|
1083
|
+
/**
|
|
1084
|
+
* Remove tombstoned and hub-only leftovers. A removal DESTROYS work, so it
|
|
1085
|
+
* needs a second roster read to agree (D49) and fails closed when that read
|
|
1086
|
+
* cannot be taken. Every removal logs its reason.
|
|
1087
|
+
*/
|
|
1088
|
+
async applyRemovals(agentId, agentAddons, plan) {
|
|
1089
|
+
if (plan.remove.length === 0)
|
|
1090
|
+
return;
|
|
1091
|
+
let secondAddons = null;
|
|
1092
|
+
try {
|
|
1093
|
+
secondAddons = this.extractAgentAddons(await this.broker.call('$agent.status', {}, { nodeID: agentId, timeout: AGENT_RECONCILE_RPC_TIMEOUT_MS }));
|
|
1094
|
+
}
|
|
1095
|
+
catch (err) {
|
|
1096
|
+
this.logger?.warn('convergence: removals skipped, the confirmation read failed', {
|
|
1097
|
+
tags: { nodeId: agentId },
|
|
1098
|
+
meta: { error: err instanceof Error ? err.message : String(err) },
|
|
1099
|
+
});
|
|
948
1100
|
return;
|
|
949
|
-
|
|
1101
|
+
}
|
|
1102
|
+
if (secondAddons === null) {
|
|
1103
|
+
this.logger?.warn('convergence: removals skipped, the confirmation read had no addon list', {
|
|
1104
|
+
tags: { nodeId: agentId },
|
|
1105
|
+
});
|
|
950
1106
|
return;
|
|
951
1107
|
}
|
|
1108
|
+
const stillThere = new Set(secondAddons.flatMap((a) => (a.packageName === undefined ? [] : [a.packageName])));
|
|
1109
|
+
for (const removal of plan.remove) {
|
|
1110
|
+
if (!stillThere.has(removal.name))
|
|
1111
|
+
continue;
|
|
1112
|
+
const addonIds = agentAddons.filter((a) => a.packageName === removal.name).map((a) => a.id);
|
|
1113
|
+
try {
|
|
1114
|
+
// One package-mode call removes every addon the package ships; the first
|
|
1115
|
+
// id rides along so an agent that predates package mode still removes it.
|
|
1116
|
+
await this.broker.call('$agent.undeploy', {
|
|
1117
|
+
packageName: removal.name,
|
|
1118
|
+
...(addonIds[0] === undefined ? {} : { addonId: addonIds[0] }),
|
|
1119
|
+
}, { nodeID: agentId, timeout: AGENT_RECONCILE_RPC_TIMEOUT_MS });
|
|
1120
|
+
this.logger?.info('convergence: removed a package from the node', {
|
|
1121
|
+
tags: { nodeId: agentId },
|
|
1122
|
+
meta: { package: removal.name, reason: removal.reason, addonIds },
|
|
1123
|
+
});
|
|
1124
|
+
this.eventBus.emit({
|
|
1125
|
+
id: (0, node_crypto_1.randomUUID)(),
|
|
1126
|
+
timestamp: new Date(),
|
|
1127
|
+
source: { type: 'core', id: 'agent-registry' },
|
|
1128
|
+
category: types_1.EventCategory.AddonUninstalled,
|
|
1129
|
+
data: {
|
|
1130
|
+
agentId,
|
|
1131
|
+
packageName: removal.name,
|
|
1132
|
+
reason: `convergence: ${removal.reason}`,
|
|
1133
|
+
},
|
|
1134
|
+
});
|
|
1135
|
+
}
|
|
1136
|
+
catch (err) {
|
|
1137
|
+
this.logger?.warn('convergence: removing a package failed, the next round retries', {
|
|
1138
|
+
tags: { nodeId: agentId },
|
|
1139
|
+
meta: {
|
|
1140
|
+
package: removal.name,
|
|
1141
|
+
reason: removal.reason,
|
|
1142
|
+
error: err instanceof Error ? err.message : String(err),
|
|
1143
|
+
},
|
|
1144
|
+
});
|
|
1145
|
+
}
|
|
1146
|
+
}
|
|
1147
|
+
}
|
|
1148
|
+
/** Install and update, behind the D49 confirmation read. */
|
|
1149
|
+
async applyDeliveries(agentId, live, plan, trigger, seam) {
|
|
1150
|
+
const planned = {
|
|
1151
|
+
targets: [...plan.install, ...plan.update].map((t) => ({
|
|
1152
|
+
name: t.name,
|
|
1153
|
+
version: t.version,
|
|
1154
|
+
versionSource: t.origin === 'agent-tracked' ? 'agent-tracked' : 'hub-installed',
|
|
1155
|
+
})),
|
|
1156
|
+
retired: plan.retired,
|
|
1157
|
+
liveCount: live.length,
|
|
1158
|
+
};
|
|
1159
|
+
if (planned.targets.length === 0 && planned.retired.length === 0)
|
|
1160
|
+
return;
|
|
1161
|
+
// GATE 3 — the confirmation read (D49). A delivery overwrites whatever is
|
|
1162
|
+
// on the node, so the destroying direction needs a SECOND read to agree.
|
|
1163
|
+
const confirmed = await this.confirmBackfillTargets(agentId, planned);
|
|
1164
|
+
if (confirmed === null)
|
|
1165
|
+
return;
|
|
1166
|
+
if (confirmed.targets.length === 0 && confirmed.retired.length === 0)
|
|
1167
|
+
return;
|
|
952
1168
|
// Hold the delivery window open for the whole pass, so the reload this
|
|
953
|
-
//
|
|
954
|
-
const closeWindow = this.deliveryLedger.begin(agentId,
|
|
1169
|
+
// delivery is about to cause cannot trigger a second one.
|
|
1170
|
+
const closeWindow = this.deliveryLedger.begin(agentId, confirmed.targets.map((t) => t.name).join(', ') || 'convergence');
|
|
955
1171
|
let outcome;
|
|
956
1172
|
try {
|
|
957
|
-
outcome = await (0, agent_addon_backfill_1.executeBackfill)(seam, agentId,
|
|
1173
|
+
outcome = await (0, agent_addon_backfill_1.executeBackfill)(seam, agentId, confirmed, this.backfillTracker);
|
|
958
1174
|
}
|
|
959
1175
|
finally {
|
|
960
1176
|
closeWindow();
|
|
961
1177
|
}
|
|
962
1178
|
if (outcome.delivered.length > 0) {
|
|
963
|
-
console.log(`[agent-registry]
|
|
1179
|
+
console.log(`[agent-registry] Convergence ${agentId}: delivered ${outcome.delivered
|
|
964
1180
|
.map((d) => {
|
|
965
1181
|
const from = live.find((p) => p.name === d.name)?.version ?? 'absent';
|
|
966
|
-
return `${d.name} ${from} → ${d.version} (
|
|
1182
|
+
return `${d.name} ${from} → ${d.version} (${d.versionSource})`;
|
|
967
1183
|
})
|
|
968
|
-
.join(', ')} — trigger=${trigger}, node reported ${
|
|
969
|
-
`declared roster has ${recorded.length}`);
|
|
1184
|
+
.join(', ')} — trigger=${trigger}, node reported ${confirmed.liveCount} package(s)`);
|
|
970
1185
|
this.eventBus.emit({
|
|
971
1186
|
id: (0, node_crypto_1.randomUUID)(),
|
|
972
1187
|
timestamp: new Date(),
|
|
@@ -975,19 +1190,19 @@ class AgentRegistryService {
|
|
|
975
1190
|
data: {
|
|
976
1191
|
agentId,
|
|
977
1192
|
packages: outcome.delivered.map((d) => `${d.name}@${d.version}`),
|
|
978
|
-
reason: '
|
|
1193
|
+
reason: 'convergence: the hub desired set differs from what the node runs',
|
|
979
1194
|
},
|
|
980
1195
|
});
|
|
981
1196
|
}
|
|
982
1197
|
// Fail LOUD: a node missing capabilities must never merely look healthy.
|
|
983
1198
|
for (const failure of outcome.failed) {
|
|
984
|
-
console.error(`[agent-registry]
|
|
1199
|
+
console.error(`[agent-registry] Convergence ${agentId}: FAILED to deliver ${failure.name}@${failure.version} — ${failure.error}` +
|
|
985
1200
|
(failure.retired
|
|
986
1201
|
? ` (retired after ${agent_addon_backfill_1.MAX_BACKFILL_ATTEMPTS} attempts; this node is running WITHOUT that package)`
|
|
987
|
-
: ''));
|
|
1202
|
+
: ' (the next round retries)'));
|
|
988
1203
|
}
|
|
989
1204
|
for (const name of outcome.skipped) {
|
|
990
|
-
console.error(`[agent-registry]
|
|
1205
|
+
console.error(`[agent-registry] Convergence ${agentId}: ${name} is still wrong and RETIRED — the node is degraded until an operator intervenes`);
|
|
991
1206
|
}
|
|
992
1207
|
}
|
|
993
1208
|
/**
|
|
@@ -21,6 +21,7 @@ const DescriptorSchema = zod_1.z.object({
|
|
|
21
21
|
.array(zod_1.z.object({ name: zod_1.z.string(), version: zod_1.z.string().nullable() }))
|
|
22
22
|
.readonly()
|
|
23
23
|
.optional(),
|
|
24
|
+
tombstones: zod_1.z.array(zod_1.z.string()).readonly().optional(),
|
|
24
25
|
});
|
|
25
26
|
/** A row as the store reads it: `at` as `lastActive`, plus the descriptor. */
|
|
26
27
|
const RowDataSchema = zod_1.z.object({
|
|
@@ -54,7 +55,8 @@ function descriptorsEqual(a, b) {
|
|
|
54
55
|
sameStrings(a.engines, b.engines) &&
|
|
55
56
|
sameStrings(a.localIps, b.localIps) &&
|
|
56
57
|
sameStrings(a.addonIds, b.addonIds) &&
|
|
57
|
-
samePackages(a.packages, b.packages)
|
|
58
|
+
samePackages(a.packages, b.packages) &&
|
|
59
|
+
sameStrings(a.tombstones ?? [], b.tombstones ?? []));
|
|
58
60
|
}
|
|
59
61
|
function sameStrings(a, b) {
|
|
60
62
|
return a.length === b.length && a.every((v, i) => v === b[i]);
|
|
@@ -103,7 +105,12 @@ class ClusterNodeHistoryStore {
|
|
|
103
105
|
// the back-fill's intended set on the very next topology poll.
|
|
104
106
|
const existing = await this.readStored(kind, descriptor.id);
|
|
105
107
|
const packages = descriptor.packages ?? existing?.descriptor.packages;
|
|
106
|
-
const
|
|
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
|