@camstack/server 1.1.75 → 1.2.0
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/dist/agent/agent-cap-dispatch-service.js +93 -0
- package/dist/agent/agent-config.js +127 -0
- package/dist/agent/agent-deploy-swap.js +137 -0
- package/dist/agent/agent-group-runner.js +175 -0
- package/dist/agent/agent-http-auth.js +76 -0
- package/dist/agent/agent-http.js +444 -0
- package/dist/agent/agent-service.js +595 -0
- package/dist/agent/agent-update-service.js +184 -0
- package/dist/agent/apply-model-distribution.js +14 -0
- package/dist/agent/derive-hub-url.js +139 -0
- package/dist/agent/fetch-bundle-from-hub.js +46 -0
- package/dist/agent/main.js +1137 -0
- package/dist/agent/register-agent-cap-dispatch.js +37 -0
- package/dist/api/trpc/generated-cap-routers.js +24 -15
- package/dist/core/agent/agent-registry.service.js +4 -3
- package/dist/core/server-update/server-update.service.js +14 -5
- package/dist/core/server-update/system-exec-npm.js +33 -0
- package/dist/launcher.js +28 -5
- package/dist/node-role.js +11 -0
- package/dist/server-root/index.js +0 -8
- package/package.json +27 -14
|
@@ -0,0 +1,1137 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
14
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
15
|
+
}) : function(o, v) {
|
|
16
|
+
o["default"] = v;
|
|
17
|
+
});
|
|
18
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
19
|
+
var ownKeys = function(o) {
|
|
20
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
21
|
+
var ar = [];
|
|
22
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
23
|
+
return ar;
|
|
24
|
+
};
|
|
25
|
+
return ownKeys(o);
|
|
26
|
+
};
|
|
27
|
+
return function (mod) {
|
|
28
|
+
if (mod && mod.__esModule) return mod;
|
|
29
|
+
var result = {};
|
|
30
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
31
|
+
__setModuleDefault(result, mod);
|
|
32
|
+
return result;
|
|
33
|
+
};
|
|
34
|
+
})();
|
|
35
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
|
+
exports.startAgent = startAgent;
|
|
37
|
+
const fs = __importStar(require("node:fs"));
|
|
38
|
+
const path = __importStar(require("node:path"));
|
|
39
|
+
const agent_http_js_1 = require("./agent-http.js");
|
|
40
|
+
const derive_hub_url_js_1 = require("./derive-hub-url.js");
|
|
41
|
+
const system_1 = require("@camstack/system");
|
|
42
|
+
const types_1 = require("@camstack/types");
|
|
43
|
+
// Capability definitions — must be declared before addon registerProvider() calls
|
|
44
|
+
const types_2 = require("@camstack/types");
|
|
45
|
+
const agent_config_js_1 = require("./agent-config.js");
|
|
46
|
+
const agent_update_service_js_1 = require("./agent-update-service.js");
|
|
47
|
+
const server_management_provider_js_1 = require("../api/core/server-management.provider.js");
|
|
48
|
+
const agent_service_js_1 = require("./agent-service.js");
|
|
49
|
+
const agent_group_runner_js_1 = require("./agent-group-runner.js");
|
|
50
|
+
const register_agent_cap_dispatch_js_1 = require("./register-agent-cap-dispatch.js");
|
|
51
|
+
// ---------------------------------------------------------------------------
|
|
52
|
+
// Agent LogManager — shared log pipeline for all addon loggers.
|
|
53
|
+
// The hub-forwarder addon registers as a destination, so all log
|
|
54
|
+
// entries flow through it (console output + hub forwarding).
|
|
55
|
+
// ---------------------------------------------------------------------------
|
|
56
|
+
const system_2 = require("@camstack/system");
|
|
57
|
+
const agentLogManager = new system_2.LogManager(5000);
|
|
58
|
+
/**
|
|
59
|
+
* Caps whose `nodeId` arg is provider DATA (`nodeIdMode: 'data'` —
|
|
60
|
+
* `addon-settings`, `addons`, `nodes`, `pipeline-orchestrator`), never a
|
|
61
|
+
* routing pin. The agent's own capabilityRegistry only declares the agent
|
|
62
|
+
* caps, so derive the set from the static cap definitions shipped in
|
|
63
|
+
* `@camstack/types`. Fed to `createParentUnownedCallHandler` so a forked
|
|
64
|
+
* addon on this agent calling e.g.
|
|
65
|
+
* `addon-settings.getGlobalSettings({addonId, nodeId})` forwards UNPINNED to
|
|
66
|
+
* the hub singleton (which reads `nodeId` as data) instead of being pinned to
|
|
67
|
+
* a node with no provider (dead broker fallback → ServiceNotFoundError).
|
|
68
|
+
*/
|
|
69
|
+
const DATA_NODEID_CAPS = new Set(types_2.ALL_CAPABILITY_DEFINITIONS.filter((c) => c.nodeIdMode === 'data').map((c) => c.name));
|
|
70
|
+
// ---------------------------------------------------------------------------
|
|
71
|
+
// Main
|
|
72
|
+
// ---------------------------------------------------------------------------
|
|
73
|
+
async function startAgent(configPath) {
|
|
74
|
+
const config = (0, agent_config_js_1.loadAgentConfig)(configPath);
|
|
75
|
+
// Derive CAMSTACK_HUB_URL from the configured hub address so remote agents
|
|
76
|
+
// no longer need a second, redundant env var set by hand (the cross-node
|
|
77
|
+
// source trap). An explicit operator value ALWAYS wins — capture whether it
|
|
78
|
+
// was set BEFORE deriving so reconnect() can still update a derived value.
|
|
79
|
+
// This runs before every runner spawn (loadClusterCapableAddons /
|
|
80
|
+
// loadDeployedAddons below), and process-service spreads `process.env` into
|
|
81
|
+
// child env, so pipeline-runner + recorder children inherit it for free.
|
|
82
|
+
const hubUrlWasExplicit = process.env['CAMSTACK_HUB_URL'] !== undefined;
|
|
83
|
+
const derivedHubUrl = (0, derive_hub_url_js_1.deriveHubUrlForExport)(hubUrlWasExplicit, config.hubAddress);
|
|
84
|
+
if (derivedHubUrl !== undefined) {
|
|
85
|
+
process.env['CAMSTACK_HUB_URL'] = derivedHubUrl;
|
|
86
|
+
console.log(`[Agent] Derived CAMSTACK_HUB_URL=${derivedHubUrl} from hub address "${config.hubAddress}"`);
|
|
87
|
+
}
|
|
88
|
+
if (config.hubAddress) {
|
|
89
|
+
console.log(`[Agent] Starting node "${config.nodeId}" (name="${config.name}") connecting to ${config.hubAddress}`);
|
|
90
|
+
}
|
|
91
|
+
else {
|
|
92
|
+
console.log(`[Agent] Starting node "${config.nodeId}" (name="${config.name}") in discovery mode`);
|
|
93
|
+
}
|
|
94
|
+
console.log(`[Agent] Config: ${config.configPath}`);
|
|
95
|
+
console.log(`[Agent] Data dir: ${config.dataDir}`);
|
|
96
|
+
// Ensure required addon packages are installed in the agent's addonsDir.
|
|
97
|
+
// Resolution order:
|
|
98
|
+
// 1. `CAMSTACK_BUNDLED_ADDONS_DIR` — Electron-packaged builds copy
|
|
99
|
+
// from `<resourcesPath>/addons` (`'local'` mode).
|
|
100
|
+
// 2. Otherwise npm from registry. Local dev pushes addons via
|
|
101
|
+
// `camstack deploy` (CLI tarball upload), bypassing this path.
|
|
102
|
+
const explicitSource = process.env['CAMSTACK_INSTALL_SOURCE'];
|
|
103
|
+
const bundledDir = process.env['CAMSTACK_BUNDLED_ADDONS_DIR'];
|
|
104
|
+
let workspaceDir = null;
|
|
105
|
+
let resolvedSource = explicitSource;
|
|
106
|
+
if (bundledDir && fs.existsSync(bundledDir)) {
|
|
107
|
+
workspaceDir = bundledDir;
|
|
108
|
+
resolvedSource = 'local';
|
|
109
|
+
console.log(`[Agent] Using bundled addons from ${bundledDir}`);
|
|
110
|
+
}
|
|
111
|
+
else if (explicitSource === 'local') {
|
|
112
|
+
workspaceDir = (0, system_1.detectWorkspacePackagesDir)(config.dataDir);
|
|
113
|
+
}
|
|
114
|
+
const installer = new system_1.AddonInstaller({
|
|
115
|
+
addonsDir: config.addonsDir,
|
|
116
|
+
workspacePackagesDir: workspaceDir ?? undefined,
|
|
117
|
+
installSource: resolvedSource,
|
|
118
|
+
});
|
|
119
|
+
await installer.ensureRequiredPackages(system_1.AddonInstaller.AGENT_PACKAGES);
|
|
120
|
+
console.log(`[Agent] Addon packages ready in ${config.addonsDir}`);
|
|
121
|
+
let broker = (0, system_1.createBroker)({
|
|
122
|
+
nodeID: config.nodeId,
|
|
123
|
+
mode: 'agent',
|
|
124
|
+
hubAddress: config.hubAddress,
|
|
125
|
+
logLevel: config.logLevel,
|
|
126
|
+
secret: config.secret,
|
|
127
|
+
});
|
|
128
|
+
/**
|
|
129
|
+
* Reconnect: stop the current broker, reload config from file,
|
|
130
|
+
* create a new broker with the updated hub/secret, and restart.
|
|
131
|
+
* The HTTP server stays alive throughout.
|
|
132
|
+
*/
|
|
133
|
+
const reconnect = async () => {
|
|
134
|
+
console.log('[Agent] Reconnecting with updated config...');
|
|
135
|
+
try {
|
|
136
|
+
await broker.stop();
|
|
137
|
+
}
|
|
138
|
+
catch {
|
|
139
|
+
/* already stopped */
|
|
140
|
+
}
|
|
141
|
+
// Reload config from file (UI may have written new hubAddress/secret)
|
|
142
|
+
const fresh = (0, agent_config_js_1.loadAgentConfig)(undefined, config.dataDir);
|
|
143
|
+
console.log(`[Agent] New config: hub=${fresh.hubAddress ?? 'discovery'}, secret=${fresh.secret ? 'yes' : 'none'}`);
|
|
144
|
+
// Re-derive CAMSTACK_HUB_URL for the discovery-mode → dashboard-configured
|
|
145
|
+
// flow (config file freshly written a hubAddress). Explicit operator values
|
|
146
|
+
// still win via the boot-captured `hubUrlWasExplicit` flag.
|
|
147
|
+
// KNOWN LIMITATION (Stage-0): runners already spawned before this reconnect
|
|
148
|
+
// keep the env they inherited; picking up a changed hub address in live
|
|
149
|
+
// children would need a runner respawn. Boot-time — the actual trap — is
|
|
150
|
+
// fully covered.
|
|
151
|
+
const freshHubUrl = (0, derive_hub_url_js_1.deriveHubUrlForExport)(hubUrlWasExplicit, fresh.hubAddress);
|
|
152
|
+
if (freshHubUrl !== undefined && process.env['CAMSTACK_HUB_URL'] !== freshHubUrl) {
|
|
153
|
+
process.env['CAMSTACK_HUB_URL'] = freshHubUrl;
|
|
154
|
+
console.log(`[Agent] Derived CAMSTACK_HUB_URL=${freshHubUrl} from hub address "${fresh.hubAddress}"`);
|
|
155
|
+
}
|
|
156
|
+
broker = (0, system_1.createBroker)({
|
|
157
|
+
nodeID: config.nodeId,
|
|
158
|
+
mode: 'agent',
|
|
159
|
+
hubAddress: fresh.hubAddress,
|
|
160
|
+
logLevel: fresh.logLevel,
|
|
161
|
+
secret: fresh.secret,
|
|
162
|
+
});
|
|
163
|
+
await broker.start();
|
|
164
|
+
console.log('[Agent] Reconnected successfully');
|
|
165
|
+
};
|
|
166
|
+
const loadedAddons = new Map();
|
|
167
|
+
// D3 subtree registry: accumulates manifests from every group-runner child
|
|
168
|
+
// that calls `$agent.registerNode`. The agent re-registers the UNION of its
|
|
169
|
+
// own in-process addons + all children with the hub on every child update.
|
|
170
|
+
const subtree = new system_1.HubNodeRegistry();
|
|
171
|
+
// ---------------------------------------------------------------------------
|
|
172
|
+
// Hub connection state tracking (for richer UI status)
|
|
173
|
+
// ---------------------------------------------------------------------------
|
|
174
|
+
// Tracks the last known discriminated state so the HTTP status endpoint
|
|
175
|
+
// can surface `secret-mismatch` which is not observable from the broker
|
|
176
|
+
// registry alone. All mutations are synchronous/single-threaded (Node.js
|
|
177
|
+
// event loop) so no locking is needed.
|
|
178
|
+
let hubConnectionStateOverride = null;
|
|
179
|
+
function getHubConnectionState() {
|
|
180
|
+
// If a definitive override is set (e.g. secret-mismatch), return it
|
|
181
|
+
// regardless of registry state.
|
|
182
|
+
if (hubConnectionStateOverride !== null)
|
|
183
|
+
return hubConnectionStateOverride;
|
|
184
|
+
// Fall through to live registry-derived state (connected/searching/disconnected).
|
|
185
|
+
// getRegistryNodes-equivalent inline: broker.registry.getNodeList
|
|
186
|
+
try {
|
|
187
|
+
const registry = broker.registry;
|
|
188
|
+
const nodes = registry?.getNodeList?.({ onlyAvailable: true }) ?? [];
|
|
189
|
+
if (nodes.some((n) => n.id === 'hub'))
|
|
190
|
+
return 'connected';
|
|
191
|
+
}
|
|
192
|
+
catch {
|
|
193
|
+
/* fall through */
|
|
194
|
+
}
|
|
195
|
+
const configNow = readConfigFile(config.configPath);
|
|
196
|
+
const discoveryMode = typeof configNow.hubAddress !== 'string' || configNow.hubAddress.length === 0;
|
|
197
|
+
return discoveryMode ? 'searching' : 'disconnected';
|
|
198
|
+
}
|
|
199
|
+
function readConfigFile(p) {
|
|
200
|
+
if (!fs.existsSync(p))
|
|
201
|
+
return {};
|
|
202
|
+
try {
|
|
203
|
+
return JSON.parse(fs.readFileSync(p, 'utf-8'));
|
|
204
|
+
}
|
|
205
|
+
catch {
|
|
206
|
+
return {};
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
// AbortController for the upward hub registration retry loop — cancelled on
|
|
210
|
+
// agent shutdown so we don't leak the retry loop after stop().
|
|
211
|
+
const registerAbortController = new AbortController();
|
|
212
|
+
/**
|
|
213
|
+
* Build the manifest for the agent's OWN in-process addons (those loaded by
|
|
214
|
+
* bootCoreAddons and loadDeployedAddons that ended up in `loadedAddons` WITH
|
|
215
|
+
* an `addon` instance). Group-spawned addons (loadClusterCapableAddons) have
|
|
216
|
+
* no `addon` instance and register via `$agent.registerNode` from the child.
|
|
217
|
+
*
|
|
218
|
+
* We reconstruct per-addon capability lists by reverse-querying the
|
|
219
|
+
* capabilityRegistry: for every declared capability, check which addonId
|
|
220
|
+
* currently holds the provider and group the result by addonId.
|
|
221
|
+
*/
|
|
222
|
+
function buildAgentOwnManifest() {
|
|
223
|
+
// Build addonId → capNames map from the registry.
|
|
224
|
+
const addonCapMap = new Map();
|
|
225
|
+
for (const cap of agentCapabilities) {
|
|
226
|
+
if (cap.mode === 'collection') {
|
|
227
|
+
// Collection caps: multiple providers keyed by addonId.
|
|
228
|
+
for (const [addonId] of capabilityRegistry.getCollectionEntries(cap.name)) {
|
|
229
|
+
const list = addonCapMap.get(addonId) ?? [];
|
|
230
|
+
list.push(cap.name);
|
|
231
|
+
addonCapMap.set(addonId, list);
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
else {
|
|
235
|
+
// Singleton caps: one active provider.
|
|
236
|
+
const addonId = capabilityRegistry.getSingletonAddonId(cap.name);
|
|
237
|
+
if (addonId) {
|
|
238
|
+
const list = addonCapMap.get(addonId) ?? [];
|
|
239
|
+
list.push(cap.name);
|
|
240
|
+
addonCapMap.set(addonId, list);
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
// Only emit in-process addons (those with an `addon` instance).
|
|
245
|
+
const result = [];
|
|
246
|
+
for (const [addonId, entry] of loadedAddons) {
|
|
247
|
+
if (!entry.addon)
|
|
248
|
+
continue;
|
|
249
|
+
const caps = addonCapMap.get(addonId) ?? [];
|
|
250
|
+
result.push({ addonId, capabilities: caps });
|
|
251
|
+
}
|
|
252
|
+
// The agent runtime's OWN providers (server-management) are registered by
|
|
253
|
+
// the bootstrap, not an addon — append their synthetic manifest entry so
|
|
254
|
+
// the hub's HubNodeRegistry (and nodeId-pinned cap routing) sees them.
|
|
255
|
+
result.push((0, agent_update_service_js_1.agentRuntimeManifestEntry)());
|
|
256
|
+
return result;
|
|
257
|
+
}
|
|
258
|
+
/**
|
|
259
|
+
* Aggregate the agent's own manifest + every child's manifest into a single
|
|
260
|
+
* RegisterNodeParams for the agent's nodeId. This is sent upward to the hub.
|
|
261
|
+
*/
|
|
262
|
+
function aggregateManifest() {
|
|
263
|
+
const ownAddons = buildAgentOwnManifest();
|
|
264
|
+
const allAddons = [...ownAddons];
|
|
265
|
+
const allNativeCaps = [];
|
|
266
|
+
for (const childNodeId of subtree.listNodeIds()) {
|
|
267
|
+
const childAddons = subtree.getNodeManifest(childNodeId);
|
|
268
|
+
if (childAddons) {
|
|
269
|
+
allAddons.push(...childAddons);
|
|
270
|
+
}
|
|
271
|
+
const childNativeCaps = subtree.getNodeNativeCaps(childNodeId);
|
|
272
|
+
if (childNativeCaps) {
|
|
273
|
+
allNativeCaps.push(...childNativeCaps);
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
// Version visibility (runtime-updatable node packages): the manifest
|
|
277
|
+
// carries the agent's root-package identity so the hub's HubNodeRegistry
|
|
278
|
+
// (and the Server management UI) sees every node's exact version.
|
|
279
|
+
const rootPackage = agentUpdateService.getRunningRootPackage();
|
|
280
|
+
return (0, system_1.buildNodeManifest)(config.nodeId, allAddons, allNativeCaps.length > 0 ? allNativeCaps : undefined, config.secret ? (0, system_1.hashClusterSecret)(config.secret) : undefined, rootPackage.version === null
|
|
281
|
+
? undefined
|
|
282
|
+
: { name: rootPackage.name, version: rootPackage.version });
|
|
283
|
+
}
|
|
284
|
+
/**
|
|
285
|
+
* Fire (or re-fire) the upward `$hub.registerNode` registration carrying
|
|
286
|
+
* the agent's complete subtree union. Called:
|
|
287
|
+
* - after initial addon loading completes
|
|
288
|
+
* - on every `$agent.registerNode` from a child
|
|
289
|
+
* - on hub reconnect (via `$node.connected`)
|
|
290
|
+
*/
|
|
291
|
+
function triggerUpwardRegistration() {
|
|
292
|
+
(0, system_1.callRegisterNodeWithRetry)(broker, aggregateManifest(), {
|
|
293
|
+
target: 'hub',
|
|
294
|
+
signal: registerAbortController.signal,
|
|
295
|
+
log: (msg) => console.log(`[Agent] ${msg}`),
|
|
296
|
+
})
|
|
297
|
+
.then(() => {
|
|
298
|
+
// The hub acked the manifest — the agent reached its registered state.
|
|
299
|
+
// Run the single-copy GC sweep once (first ack only; no-op after).
|
|
300
|
+
confirmAgentBootHealthy();
|
|
301
|
+
})
|
|
302
|
+
.catch((err) => {
|
|
303
|
+
if ((0, system_1.isClusterSecretMismatchError)(err)) {
|
|
304
|
+
consoleLogger.error('hub registration rejected: cluster secret mismatch — correct CAMSTACK_CLUSTER_SECRET and restart the agent');
|
|
305
|
+
// Surface the mismatch in the UI status.
|
|
306
|
+
hubConnectionStateOverride = 'secret-mismatch';
|
|
307
|
+
return;
|
|
308
|
+
}
|
|
309
|
+
// abort (shutdown) — preserve prior void behaviour: ignore.
|
|
310
|
+
});
|
|
311
|
+
}
|
|
312
|
+
const consoleLogger = {
|
|
313
|
+
info: (msg) => console.log(`[Agent] ${msg}`),
|
|
314
|
+
warn: (msg) => console.warn(`[Agent] ${msg}`),
|
|
315
|
+
error: (msg) => console.error(`[Agent] ${msg}`),
|
|
316
|
+
debug: (msg) => console.debug(`[Agent] ${msg}`),
|
|
317
|
+
child: () => consoleLogger,
|
|
318
|
+
withTags: () => consoleLogger,
|
|
319
|
+
};
|
|
320
|
+
const capabilityRegistry = new system_1.CapabilityRegistry(consoleLogger);
|
|
321
|
+
// Declare all capabilities the agent uses — required before registerProvider()
|
|
322
|
+
const agentCapabilities = [
|
|
323
|
+
types_2.storageCapability,
|
|
324
|
+
types_2.storageProviderCapability,
|
|
325
|
+
types_2.settingsStoreCapability,
|
|
326
|
+
types_2.logDestinationCapability,
|
|
327
|
+
types_2.metricsProviderCapability,
|
|
328
|
+
types_2.decoderCapability,
|
|
329
|
+
types_2.motionDetectionCapability,
|
|
330
|
+
types_2.pipelineExecutorCapability,
|
|
331
|
+
types_2.pipelineRunnerCapability,
|
|
332
|
+
types_2.audioAnalyzerCapability,
|
|
333
|
+
types_2.platformProbeCapability,
|
|
334
|
+
types_2.serverManagementCapability,
|
|
335
|
+
];
|
|
336
|
+
for (const cap of agentCapabilities) {
|
|
337
|
+
capabilityRegistry.declareCapability(cap);
|
|
338
|
+
}
|
|
339
|
+
// ── Runtime-updatable root package ───────────────────────────────────
|
|
340
|
+
// The agent hosts its own `server-management` provider, backed by the
|
|
341
|
+
// update service that stages `@camstack/server` closures into
|
|
342
|
+
// `<dataDir>/server-root/`. Registered under the synthetic
|
|
343
|
+
// `agent-runtime` addonId (no addon owns it); `buildAgentOwnManifest`
|
|
344
|
+
// appends the matching manifest entry so the hub can route
|
|
345
|
+
// nodeId-pinned `server-management` calls here.
|
|
346
|
+
const agentUpdateService = new agent_update_service_js_1.AgentUpdateService({
|
|
347
|
+
logger: consoleLogger,
|
|
348
|
+
dataDir: config.dataDir,
|
|
349
|
+
});
|
|
350
|
+
capabilityRegistry.registerProvider('server-management', agent_update_service_js_1.AGENT_RUNTIME_ADDON_ID, (0, server_management_provider_js_1.buildServerManagementProvider)(agentUpdateService));
|
|
351
|
+
// Boot-health confirmation: on the agent's FIRST acked `$hub.registerNode`,
|
|
352
|
+
// run the single-copy GC sweep once. Under the single-copy model there is NO
|
|
353
|
+
// probation boot, NO N-1 promotion, and NO auto-rollback — the starter
|
|
354
|
+
// already swapped the one `current/` copy in place, so `confirmBootHealthy`
|
|
355
|
+
// is a pure best-effort sweep of orphaned transient dirs. Idempotent: it
|
|
356
|
+
// runs at most once per boot.
|
|
357
|
+
let agentBootConfirmed = false;
|
|
358
|
+
const confirmAgentBootHealthy = () => {
|
|
359
|
+
if (agentBootConfirmed)
|
|
360
|
+
return;
|
|
361
|
+
agentBootConfirmed = true;
|
|
362
|
+
try {
|
|
363
|
+
agentUpdateService.confirmBootHealthy();
|
|
364
|
+
}
|
|
365
|
+
catch (err) {
|
|
366
|
+
consoleLogger.warn(`agent root boot confirmation failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
367
|
+
}
|
|
368
|
+
};
|
|
369
|
+
// Logger factory — creates scoped loggers from the shared LogManager.
|
|
370
|
+
// All entries flow through registered ILogDestination providers (hub-forwarder).
|
|
371
|
+
// No scope — the brand bracket `[agent/addonId]` already identifies the addon.
|
|
372
|
+
// `addonId` tag is required for the brand bracket resolver.
|
|
373
|
+
const loggerFactory = (addonId) => agentLogManager.createLogger().withTags({ addonId });
|
|
374
|
+
const agentServiceSchema = (0, agent_service_js_1.createAgentService)({
|
|
375
|
+
addonsDir: config.addonsDir,
|
|
376
|
+
dataDir: config.dataDir,
|
|
377
|
+
agentName: config.name,
|
|
378
|
+
configPath: config.configPath,
|
|
379
|
+
loadedAddons,
|
|
380
|
+
// Resolve the current metrics-provider cap lazily from the registry.
|
|
381
|
+
// The cap is registered during bootCoreAddons but may not exist in test
|
|
382
|
+
// scenarios — `null` is a documented fallback for both.
|
|
383
|
+
getMetricsProvider: () => capabilityRegistry.getSingleton('metrics-provider'),
|
|
384
|
+
agentVersion: readAgentVersion(),
|
|
385
|
+
// Drives `$agent.reload` — re-runs the same discovery pass the bootstrap
|
|
386
|
+
// does at startup, picking up tarballs just landed via `$agent.deploy`.
|
|
387
|
+
// `storageProvider` is resolved lazily on each call because this closure
|
|
388
|
+
// is captured here, BEFORE `bootCoreAddons` registers the storage cap.
|
|
389
|
+
reloadDeployedAddons: async () => {
|
|
390
|
+
const before = new Set(loadedAddons.keys());
|
|
391
|
+
const storage = capabilityRegistry.getSingleton('storage') ?? undefined;
|
|
392
|
+
await loadDeployedAddons(broker, config.addonsDir, config.dataDir, loadedAddons, storage, loggerFactory, capabilityRegistry);
|
|
393
|
+
const loaded = [];
|
|
394
|
+
for (const id of loadedAddons.keys()) {
|
|
395
|
+
if (!before.has(id))
|
|
396
|
+
loaded.push(id);
|
|
397
|
+
}
|
|
398
|
+
return loaded;
|
|
399
|
+
},
|
|
400
|
+
// D3 subtree aggregation: when a group-runner child delivers its manifest
|
|
401
|
+
// via `$agent.registerNode`, merge it into the local subtree registry and
|
|
402
|
+
// immediately re-register the complete union with the hub.
|
|
403
|
+
onChildRegistered: (params) => {
|
|
404
|
+
subtree.registerNode(params);
|
|
405
|
+
triggerUpwardRegistration();
|
|
406
|
+
},
|
|
407
|
+
expectedClusterSecretHash: config.secret ? (0, system_1.hashClusterSecret)(config.secret) : undefined,
|
|
408
|
+
installFromNpm: async (pkg, version) => {
|
|
409
|
+
await installer.install(pkg, version);
|
|
410
|
+
},
|
|
411
|
+
// #17: deployed bundles go through the installer's full post-install
|
|
412
|
+
// (manifest strip + runtime deps + native deps + atomic swap) instead of
|
|
413
|
+
// the bare tar swap that shipped addons without their node_modules.
|
|
414
|
+
installBundleTgz: (tgzPath) => installer.installFromTgz(tgzPath),
|
|
415
|
+
});
|
|
416
|
+
broker.createService(agentServiceSchema);
|
|
417
|
+
// $process service — manages forked child processes (same as hub).
|
|
418
|
+
// Pass the agent's own TCP listen port so spawned addon-runners connect
|
|
419
|
+
// back here instead of falling back to port 6000 (the hub). Bug-4:
|
|
420
|
+
// without this, runners called `$agent.registerNode` through the hub
|
|
421
|
+
// which has no `$agent` service → retry storm (attempt 80+).
|
|
422
|
+
const agentTcpPort = (0, system_1.deriveAgentListenPort)(broker.nodeID);
|
|
423
|
+
// UDS local transport — the agent hosts a LocalChildRegistry so its
|
|
424
|
+
// forked addon-runners route cap calls over a Unix-domain socket. The
|
|
425
|
+
// broker stays as the no-route fallback. On failure the children fall
|
|
426
|
+
// back to broker-only (no parentUdsPath propagated). See moleculer.service.ts.
|
|
427
|
+
let agentParentUdsPath;
|
|
428
|
+
let agentUdsRegistry;
|
|
429
|
+
try {
|
|
430
|
+
const agentNodeId = broker.nodeID;
|
|
431
|
+
// F0 (slice-5 outbound): route a forked child's unowned `ctx.api.<cap>`
|
|
432
|
+
// call from the agent (the parent) instead of throwing UDS_NO_ROUTE. The
|
|
433
|
+
// agent has NO CapRouteResolver — it routes everything to the hub over its
|
|
434
|
+
// own broker — so `getResolver` returns null and the handler uses the
|
|
435
|
+
// broker fallback exclusively. The agent's `broker.call` reaches the hub
|
|
436
|
+
// mesh (and any cluster node) via the same service-discovery + action-name
|
|
437
|
+
// convention the child's brokerTransportLink used before F0. F1+F2 removes
|
|
438
|
+
// the child broker, so the agent must own this outbound path.
|
|
439
|
+
const onUnownedCall = (0, system_1.createParentUnownedCallHandler)({
|
|
440
|
+
getResolver: () => null,
|
|
441
|
+
broker: broker,
|
|
442
|
+
// The agent's subtree registry — lets the broker fallback pin a
|
|
443
|
+
// device-scoped child call to the owning node instead of load-balancing.
|
|
444
|
+
nodeRegistry: subtree,
|
|
445
|
+
// Hub-local UDS child dispatcher — routes a device-scoped native cap
|
|
446
|
+
// owned by an agent-local child directly over UDS before any broker
|
|
447
|
+
// fallback. Getter: `agentUdsRegistry` is assigned later in this scope,
|
|
448
|
+
// after the handler is constructed.
|
|
449
|
+
getLocalDispatcher: () => agentUdsRegistry ?? null,
|
|
450
|
+
// `nodeIdMode: 'data'` signal: these caps carry `nodeId` as provider
|
|
451
|
+
// DATA (the hub singleton dispatches internally), never a routing pin —
|
|
452
|
+
// suppressing the pin lets the call forward unpinned to the hub's
|
|
453
|
+
// singleton resolution, with `args.nodeId` intact for the provider.
|
|
454
|
+
isDataNodeIdCap: (capName) => DATA_NODEID_CAPS.has(capName),
|
|
455
|
+
// AGENT → HUB forward: a cap no agent-local child owns is routed to the
|
|
456
|
+
// hub's `$hub-cap-fwd.forward`, which runs it through the hub's own
|
|
457
|
+
// routing. Only the hub registers `$hub-cap-fwd`, so the undirected
|
|
458
|
+
// `broker.call` discovers it there (no nodeID pin needed). This is what
|
|
459
|
+
// makes an agent's forked addon reach a hub-hosted cap (`stream-broker`,
|
|
460
|
+
// `settings-store`, …) instead of 30s-deadlining on raw `${cap}.*`
|
|
461
|
+
// discovery (hub-local addon runners expose no Moleculer service).
|
|
462
|
+
forwardToHub: (input) => broker.call(system_1.HUB_CAP_FWD_ACTION, {
|
|
463
|
+
capName: input.capName,
|
|
464
|
+
method: input.method,
|
|
465
|
+
args: input.args,
|
|
466
|
+
...(input.deviceId !== undefined ? { deviceId: input.deviceId } : {}),
|
|
467
|
+
...(input.nodeId !== undefined ? { nodeId: input.nodeId } : {}),
|
|
468
|
+
}, { timeout: 60_000 }),
|
|
469
|
+
logger: { warn: (msg) => consoleLogger.warn(`[uds-fallback] ${msg}`) },
|
|
470
|
+
});
|
|
471
|
+
agentUdsRegistry = new system_1.LocalChildRegistry({
|
|
472
|
+
server: (0, system_1.createLocalTransport)().createServer(agentNodeId),
|
|
473
|
+
// The agent's own id — a cap call pinned to THIS agent (e.g. the
|
|
474
|
+
// benchmark running `pipeline-executor.runPipeline` on the very node it
|
|
475
|
+
// is on) is served by the co-resident sibling instead of being forwarded
|
|
476
|
+
// to the hub (the agent has no CapRouteResolver, and the hub would reject
|
|
477
|
+
// it with "no provider registered" unless it knew the agent hosts the
|
|
478
|
+
// cap). A pin to ANOTHER node still bypasses the sibling → onUnownedCall.
|
|
479
|
+
ownNodeId: agentNodeId,
|
|
480
|
+
onUnownedCall,
|
|
481
|
+
});
|
|
482
|
+
await agentUdsRegistry.start();
|
|
483
|
+
// E1: apply child manifest + cleanup from the UDS lifecycle (agent-local children).
|
|
484
|
+
// When a runner connects over UDS, synthesise a RegisterNodeParams for the
|
|
485
|
+
// agent's subtree and trigger upward hub registration — same effect as the
|
|
486
|
+
// `$agent.registerNode` Moleculer RPC path. Idempotent: if the Moleculer path
|
|
487
|
+
// fires first, the subtree's atomic-replace handles the re-registration safely.
|
|
488
|
+
agentUdsRegistry.onChildRegistered((child) => {
|
|
489
|
+
const childNodeId = `${agentNodeId}/${child.childId}`;
|
|
490
|
+
const childParams = buildAgentChildUdsManifest(childNodeId, child.childId, child.caps);
|
|
491
|
+
subtree.registerNode(childParams);
|
|
492
|
+
triggerUpwardRegistration();
|
|
493
|
+
consoleLogger.info(`UDS child registered — subtree updated: ${childNodeId}`);
|
|
494
|
+
});
|
|
495
|
+
agentUdsRegistry.onChildGone((childId) => {
|
|
496
|
+
const childNodeId = `${agentNodeId}/${childId}`;
|
|
497
|
+
subtree.removeNode(childNodeId);
|
|
498
|
+
triggerUpwardRegistration();
|
|
499
|
+
consoleLogger.info(`UDS child gone — subtree updated: ${childNodeId}`);
|
|
500
|
+
});
|
|
501
|
+
// B2: forward UDS child logs onward to the hub's log-receiver service so
|
|
502
|
+
// they appear in the hub LogManager / admin-UI log stream.
|
|
503
|
+
//
|
|
504
|
+
// The agent has NO local LogManager readable by the admin-UI — all log
|
|
505
|
+
// entries must reach the hub. We re-use the same `log-receiver.ingest`
|
|
506
|
+
// Moleculer call that `HubForwarderDestination` uses for the agent's OWN
|
|
507
|
+
// logs, but preserve the child's original `addonId`/`nodeId`/`tags` so
|
|
508
|
+
// the admin-UI shows the originating addon (not the agent's identity).
|
|
509
|
+
//
|
|
510
|
+
// If the hub is not yet reachable, the call silently fails — the same
|
|
511
|
+
// best-effort semantic as `HubForwarderDestination.forward`. Phase F will
|
|
512
|
+
// retire the broker path once every log source emits over UDS end-to-end.
|
|
513
|
+
agentUdsRegistry.onChildLog((childId, entry) => {
|
|
514
|
+
const workerEntry = (0, system_1.udsChildLogToWorkerEntry)(childId, entry);
|
|
515
|
+
broker.call('log-receiver.ingest', workerEntry).catch(() => {
|
|
516
|
+
// Hub unreachable or not yet discovered — silently drop.
|
|
517
|
+
// HubForwarderDestination handles the agent's own buffered logs;
|
|
518
|
+
// child UDS logs emitted before hub connection are not buffered here.
|
|
519
|
+
});
|
|
520
|
+
});
|
|
521
|
+
agentParentUdsPath = (0, system_1.localEndpointPath)(agentNodeId);
|
|
522
|
+
consoleLogger.info(`UDS child registry listening on ${agentParentUdsPath}`);
|
|
523
|
+
}
|
|
524
|
+
catch (err) {
|
|
525
|
+
consoleLogger.warn(`UDS child registry failed to start; children stay broker-only: ${err instanceof Error ? err.message : String(err)}`);
|
|
526
|
+
}
|
|
527
|
+
const processServiceSchema = (0, system_1.createProcessService)(broker.nodeID, config.dataDir, undefined, agentTcpPort, agentParentUdsPath);
|
|
528
|
+
broker.createService(processServiceSchema);
|
|
529
|
+
// Slice-5, Task 7 — register the agent-side cap-dispatch service so the hub
|
|
530
|
+
// can route `agent-child-forward` cap calls to this agent's UDS children.
|
|
531
|
+
// Registered only when the UDS child registry started successfully; if it
|
|
532
|
+
// didn't start the service would have nothing to forward to.
|
|
533
|
+
//
|
|
534
|
+
// Caps hosted in the agent's OWN main process — core addons such as
|
|
535
|
+
// `platform-probe` and `metrics-provider` register singleton providers in
|
|
536
|
+
// `capabilityRegistry`, NOT as forked UDS children — are exposed via this
|
|
537
|
+
// in-process lookup so hub-dispatched `agent-child-forward` calls resolve to
|
|
538
|
+
// them instead of failing with "no provider". Mirrors the hub's
|
|
539
|
+
// `createInProcessProviderLookup`.
|
|
540
|
+
const agentInProcessLookup = (capName) => {
|
|
541
|
+
const provider = capabilityRegistry.getSingleton(capName);
|
|
542
|
+
if (provider === null || provider === undefined)
|
|
543
|
+
return null;
|
|
544
|
+
return {
|
|
545
|
+
invoke: (method, args) => {
|
|
546
|
+
const fn = provider[method];
|
|
547
|
+
if (typeof fn !== 'function') {
|
|
548
|
+
return Promise.reject(new Error(`method "${method}" not found on cap "${capName}"`));
|
|
549
|
+
}
|
|
550
|
+
const result = fn.call(provider, args);
|
|
551
|
+
return Promise.resolve(result);
|
|
552
|
+
},
|
|
553
|
+
};
|
|
554
|
+
};
|
|
555
|
+
(0, register_agent_cap_dispatch_js_1.registerAgentCapDispatch)(broker, agentUdsRegistry, agentInProcessLookup, consoleLogger);
|
|
556
|
+
// $addonHost — REMOVED (Sprint 6). Three-level settings are now
|
|
557
|
+
// exposed per-addon via `settings.*` actions in createAddonService.
|
|
558
|
+
// Register $event-bus BEFORE start so the service subscription
|
|
559
|
+
// is announced during discovery. See addon-context-factory.ts for
|
|
560
|
+
// the rationale — post-start registration propagates via heartbeat
|
|
561
|
+
// and is unreliable for the first ~10s.
|
|
562
|
+
(0, system_1.registerEventBusService)(broker);
|
|
563
|
+
// Start broker BEFORE bootCoreAddons. Core infra addons (storage,
|
|
564
|
+
// sqlite-settings, ...) run their own BaseAddon.initialize() which
|
|
565
|
+
// calls `ctx.settings.readAddonStore()` on every addon; that call
|
|
566
|
+
// routes through `brokerTransportLink` and would deadline-free poll
|
|
567
|
+
// for the hub's `settings-store.get` service if the broker weren't
|
|
568
|
+
// connected to the mesh yet. Starting the broker first lets service
|
|
569
|
+
// discovery resolve (or time out cleanly via the read-blob fallback)
|
|
570
|
+
// so agent boot completes instead of hanging on the first infra addon.
|
|
571
|
+
await broker.start();
|
|
572
|
+
// ── Registry-derived CAMSTACK_HUB_URL (Option A — discovery-mode fallback) ──
|
|
573
|
+
// In UDP-discovery mode `config.hubAddress` is null, so the boot-time
|
|
574
|
+
// derivation above (Option B) produced nothing. The agent parent is the only
|
|
575
|
+
// agent-side process with a Moleculer registry (addon-runner children are
|
|
576
|
+
// broker-less), so derive the hub host from the LIVE hub connection —
|
|
577
|
+
// `udpAddress` is wire truth — and export it through the same env contract
|
|
578
|
+
// before the runner spawns in loadClusterCapableAddons below.
|
|
579
|
+
// Precedence: explicit operator env > config-derived (B) > registry (A).
|
|
580
|
+
// A fills only the empty case; on later hub (re)connects it refreshes only
|
|
581
|
+
// its OWN previous value (hub IP change), never an explicit or B-derived one.
|
|
582
|
+
// BOOT-ORDER LIMITATION (Stage-0): a runner spawned before the first hub
|
|
583
|
+
// connect in discovery mode inherits an empty env until it is respawned —
|
|
584
|
+
// same class as Option B's reconnect limitation documented above. The first
|
|
585
|
+
// hub connect normally precedes any orchestrator attach dispatch, but the
|
|
586
|
+
// child env snapshot is fixed at spawn time.
|
|
587
|
+
let hubUrlFromRegistry;
|
|
588
|
+
const reconcileHubUrlFromRegistry = () => {
|
|
589
|
+
if (hubUrlWasExplicit)
|
|
590
|
+
return;
|
|
591
|
+
const current = process.env['CAMSTACK_HUB_URL'];
|
|
592
|
+
if (current !== undefined && current !== hubUrlFromRegistry)
|
|
593
|
+
return;
|
|
594
|
+
const fromRegistry = (0, derive_hub_url_js_1.deriveHubUrlFromRegistry)((0, agent_http_js_1.getRegistryNodes)(broker));
|
|
595
|
+
if (fromRegistry === undefined || fromRegistry === current)
|
|
596
|
+
return;
|
|
597
|
+
process.env['CAMSTACK_HUB_URL'] = fromRegistry;
|
|
598
|
+
hubUrlFromRegistry = fromRegistry;
|
|
599
|
+
console.log(`[Agent] Derived CAMSTACK_HUB_URL=${fromRegistry} from live hub connection`);
|
|
600
|
+
};
|
|
601
|
+
// Immediate attempt (the hub may already be in the registry), then reconcile
|
|
602
|
+
// on every hub (re)connect via the `$node.connected` localBus idiom — NO
|
|
603
|
+
// timers, NO registry polling (CLAUDE.md invariant).
|
|
604
|
+
reconcileHubUrlFromRegistry();
|
|
605
|
+
broker.localBus.on('$node.connected', (data) => {
|
|
606
|
+
const node = data.node;
|
|
607
|
+
if (node?.id !== 'hub')
|
|
608
|
+
return;
|
|
609
|
+
reconcileHubUrlFromRegistry();
|
|
610
|
+
});
|
|
611
|
+
// C2: wire the UDS ↔ Moleculer event bridge so events emitted by UDS
|
|
612
|
+
// children fan to siblings and reach the cluster, and cluster / parent-
|
|
613
|
+
// local events propagate to every UDS child. Inert when agentUdsRegistry
|
|
614
|
+
// was not started (no UDS children). Wired after broker.start() so
|
|
615
|
+
// getBrokerEventBus returns the fully operational shared bus.
|
|
616
|
+
let udsEventBridgeDispose = null;
|
|
617
|
+
if (agentUdsRegistry !== undefined) {
|
|
618
|
+
const agentBrokerEventBus = (0, system_1.getBrokerEventBus)(broker);
|
|
619
|
+
udsEventBridgeDispose = (0, system_1.createUdsEventBridge)({
|
|
620
|
+
registry: agentUdsRegistry,
|
|
621
|
+
parentBus: agentBrokerEventBus,
|
|
622
|
+
parentNodeId: broker.nodeID,
|
|
623
|
+
// D2: relay to children via the pass-through subscription so the bridge's
|
|
624
|
+
// wildcard does NOT count toward this node's local interest (otherwise the
|
|
625
|
+
// gate could never filter anything).
|
|
626
|
+
subscribePassthrough: (handler) => (0, system_1.subscribePassthrough)(broker, handler),
|
|
627
|
+
});
|
|
628
|
+
// D2: teach the agent's `$event-bus` inbound handler which cross-node
|
|
629
|
+
// (Moleculer) event categories any of its UDS children actually want, so the
|
|
630
|
+
// agent drops hub-origin categories no local subscriber cares about instead
|
|
631
|
+
// of fanning every category into its bus. Read per-event, so a child
|
|
632
|
+
// (un)subscribing takes effect immediately. Fails OPEN when a child is
|
|
633
|
+
// undeclared (aggregateEventInterest → null). Gated by
|
|
634
|
+
// CAMSTACK_MOLECULER_EVENT_FANOUT (filter|shadow|broadcast).
|
|
635
|
+
const interestRegistry = agentUdsRegistry;
|
|
636
|
+
(0, system_1.setNodeEventInterest)(broker, () => interestRegistry.aggregateEventInterest());
|
|
637
|
+
// D1: answer readiness-snapshot requests from UDS children over the
|
|
638
|
+
// agent's own readiness registry view. The agent hydrates its registry
|
|
639
|
+
// from the hub's `$readiness.getSnapshot` Moleculer action (broker
|
|
640
|
+
// path, intact until Phase F) — its snapshot reflects the hub's
|
|
641
|
+
// authoritative view plus any agent-local readiness events. Children
|
|
642
|
+
// request the snapshot over UDS without an additional Moleculer hop.
|
|
643
|
+
// `getOrInitReadinessRegistry` is the same function addon-context-factory
|
|
644
|
+
// uses, so the shared per-broker instance is returned on the first call
|
|
645
|
+
// and reused on subsequent calls — no separate registry is created.
|
|
646
|
+
// Wired after broker.start() so the broker event bus is operational.
|
|
647
|
+
const agentReadinessRegistry = (0, system_1.getOrInitReadinessRegistry)(broker, agentBrokerEventBus, consoleLogger);
|
|
648
|
+
agentUdsRegistry.onReadinessSnapshotRequest(() => agentReadinessRegistry.getSnapshotForTransport());
|
|
649
|
+
broker.createService((0, system_1.createReadinessServiceForRegistry)(agentReadinessRegistry, system_1.AGENT_READINESS_SERVICE_NAME));
|
|
650
|
+
}
|
|
651
|
+
// ── HTTP server (Fastify) — status API + process management + UI ──
|
|
652
|
+
// Started early (before addon boot) so the status page is reachable
|
|
653
|
+
// even while addons are still loading.
|
|
654
|
+
const getBrokerFn = (() => broker);
|
|
655
|
+
void (0, agent_http_js_1.startAgentHttpServer)(getBrokerFn, {
|
|
656
|
+
port: config.statusPort ?? 4444,
|
|
657
|
+
nodeId: config.nodeId,
|
|
658
|
+
dataDir: config.dataDir,
|
|
659
|
+
configPath: config.configPath,
|
|
660
|
+
onReconnect: reconnect,
|
|
661
|
+
getHubConnectionState,
|
|
662
|
+
});
|
|
663
|
+
// ── Phase 1: Load core infrastructure addons (in-process) ──
|
|
664
|
+
// storage + settings + metrics + hub-forwarder (log destination)
|
|
665
|
+
await bootCoreAddons(broker, config, capabilityRegistry, loadedAddons, loggerFactory);
|
|
666
|
+
// Plug every registered `log-destination` provider into the shared
|
|
667
|
+
// LogManager. The LogManager replays its ring buffer to each destination
|
|
668
|
+
// on registration, so boot-time log entries still reach destinations that
|
|
669
|
+
// came up mid-boot (e.g. hub-forwarder arriving after the first logs).
|
|
670
|
+
for (const dest of capabilityRegistry.getCollection('log-destination')) {
|
|
671
|
+
agentLogManager.addDestination(dest);
|
|
672
|
+
}
|
|
673
|
+
// Everything downstream reads the resolved infra providers from the
|
|
674
|
+
// capability registry — no hand-curated side-channel.
|
|
675
|
+
const storageProvider = capabilityRegistry.getSingleton('storage') ?? undefined;
|
|
676
|
+
// `ctx.api` for every addon is built inside `createAddonContext` from
|
|
677
|
+
// `[localProviderLink, brokerTransportLink(broker)]`. Unresolved calls
|
|
678
|
+
// route via Moleculer to the hub (or any other node hosting the cap).
|
|
679
|
+
// No separate hub WSS client, no `CAMSTACK_HUB_API_URL` — all cross-node
|
|
680
|
+
// traffic rides the broker mesh.
|
|
681
|
+
// ── Phase 1.5: Load cluster-capable addon packages (forkable → child process) ──
|
|
682
|
+
await loadClusterCapableAddons(broker, config, capabilityRegistry, loadedAddons);
|
|
683
|
+
// ── Phase 2: Load deployed addons (from hub $agent.deploy) ──
|
|
684
|
+
await loadDeployedAddons(broker, config.addonsDir, config.dataDir, loadedAddons, storageProvider, loggerFactory, capabilityRegistry);
|
|
685
|
+
// ── D3: Fire the initial upward hub registration carrying the agent's
|
|
686
|
+
// complete in-process manifest (+ any children that already registered).
|
|
687
|
+
// Group-runner children call `$agent.registerNode` and trigger a re-fire
|
|
688
|
+
// via `onChildRegistered`; hub reconnects re-fire via `$node.connected`.
|
|
689
|
+
triggerUpwardRegistration();
|
|
690
|
+
// Fire `onHubReachable()` on every in-process addon as soon as the hub
|
|
691
|
+
// node connects to our broker — this is the safe point for ctx.api.* calls
|
|
692
|
+
// into hub-provided capabilities. Forked children get the same hook via
|
|
693
|
+
// `process-runner.ts`.
|
|
694
|
+
let hubReachableFired = false;
|
|
695
|
+
broker.localBus.on('$node.connected', (data) => {
|
|
696
|
+
const node = data.node;
|
|
697
|
+
if (node?.id !== 'hub')
|
|
698
|
+
return;
|
|
699
|
+
// Hub connected — clear any stale secret-mismatch override so the
|
|
700
|
+
// status endpoint reflects the live state again.
|
|
701
|
+
if (hubConnectionStateOverride === 'secret-mismatch') {
|
|
702
|
+
hubConnectionStateOverride = null;
|
|
703
|
+
}
|
|
704
|
+
// Re-register with the hub on reconnect — the hub may have restarted and
|
|
705
|
+
// lost the agent's manifest. D3 idempotent-replace: safe to re-fire.
|
|
706
|
+
triggerUpwardRegistration();
|
|
707
|
+
if (hubReachableFired)
|
|
708
|
+
return;
|
|
709
|
+
hubReachableFired = true;
|
|
710
|
+
for (const [, entry] of loadedAddons) {
|
|
711
|
+
if (!entry.addon || typeof entry.addon.onHubReachable !== 'function')
|
|
712
|
+
continue;
|
|
713
|
+
Promise.resolve(entry.addon.onHubReachable()).catch((err) => {
|
|
714
|
+
console.error(`[Agent] ${entry.id} onHubReachable() threw:`, err);
|
|
715
|
+
});
|
|
716
|
+
}
|
|
717
|
+
});
|
|
718
|
+
console.log(`[Agent] Node "${config.nodeId}" (name="${config.name}") ready — ${loadedAddons.size} addon(s) loaded`);
|
|
719
|
+
// Graceful shutdown
|
|
720
|
+
const shutdown = async () => {
|
|
721
|
+
console.log('[Agent] Shutting down...');
|
|
722
|
+
// Abort any in-flight upward hub registration retry loop.
|
|
723
|
+
registerAbortController.abort();
|
|
724
|
+
// Dispose the UDS event bridge to remove the parent-bus subscriber and
|
|
725
|
+
// clear the child-event handler, preventing subscriber leaks on shutdown.
|
|
726
|
+
udsEventBridgeDispose?.();
|
|
727
|
+
udsEventBridgeDispose = null;
|
|
728
|
+
for (const [, entry] of loadedAddons) {
|
|
729
|
+
if (entry.addon?.shutdown) {
|
|
730
|
+
try {
|
|
731
|
+
await entry.addon.shutdown();
|
|
732
|
+
}
|
|
733
|
+
catch {
|
|
734
|
+
/* */
|
|
735
|
+
}
|
|
736
|
+
}
|
|
737
|
+
}
|
|
738
|
+
await broker.stop();
|
|
739
|
+
process.exit(0);
|
|
740
|
+
};
|
|
741
|
+
process.on('SIGTERM', shutdown);
|
|
742
|
+
process.on('SIGINT', shutdown);
|
|
743
|
+
}
|
|
744
|
+
// ---------------------------------------------------------------------------
|
|
745
|
+
// Phase 1: Boot core addons (storage, settings, metrics — NO winston)
|
|
746
|
+
// ---------------------------------------------------------------------------
|
|
747
|
+
// Core infra addons to load on agent — all infra including log-destination (hub-forwarder)
|
|
748
|
+
const AGENT_INFRA = system_1.INFRA_CAPABILITIES;
|
|
749
|
+
async function bootCoreAddons(broker, config, registry, loadedAddons, loggerFactory) {
|
|
750
|
+
// Scan every installed addon package — infra providers may live outside
|
|
751
|
+
// `@camstack/system` (e.g. `@camstack/addon-platform-probe-native`).
|
|
752
|
+
const packageDirs = resolveAddonPackageDirs(config.addonsDir);
|
|
753
|
+
if (packageDirs.length === 0) {
|
|
754
|
+
console.warn('[Agent] No addon packages found — running without infrastructure addons');
|
|
755
|
+
return;
|
|
756
|
+
}
|
|
757
|
+
console.log(`[Agent] Scanning ${packageDirs.length} addon package(s) for infra providers`);
|
|
758
|
+
const loader = new system_1.AddonLoader();
|
|
759
|
+
for (const dir of packageDirs) {
|
|
760
|
+
try {
|
|
761
|
+
await loader.loadFromAddonDir(dir);
|
|
762
|
+
}
|
|
763
|
+
catch (err) {
|
|
764
|
+
console.warn(`[Agent] Failed to scan ${dir}: ${(0, types_2.errMsg)(err)}`);
|
|
765
|
+
}
|
|
766
|
+
}
|
|
767
|
+
for (const infra of AGENT_INFRA) {
|
|
768
|
+
const candidates = loader.listAddons().filter((a) => a.declaration.capabilities?.some((c) => {
|
|
769
|
+
const capName = typeof c === 'string' ? c : c.name;
|
|
770
|
+
return capName === infra.name;
|
|
771
|
+
}));
|
|
772
|
+
// For log-destination, prefer hub-forwarder over winston-logging
|
|
773
|
+
const addon = infra.name === 'log-destination'
|
|
774
|
+
? (candidates.find((a) => a.declaration.id === 'hub-forwarder') ?? candidates[0])
|
|
775
|
+
: candidates[0];
|
|
776
|
+
if (!addon) {
|
|
777
|
+
if (infra.required) {
|
|
778
|
+
console.error(`[Agent] Required infrastructure addon for "${infra.name}" not found`);
|
|
779
|
+
}
|
|
780
|
+
continue;
|
|
781
|
+
}
|
|
782
|
+
const addonId = addon.declaration.id;
|
|
783
|
+
try {
|
|
784
|
+
const instance = new addon.addonClass();
|
|
785
|
+
// Seed ctx.kernel.storage from whatever storage provider is already
|
|
786
|
+
// in the registry (the storage addon declares itself before the
|
|
787
|
+
// addons that depend on it per AGENT_INFRA order).
|
|
788
|
+
const storageProvider = registry.getSingleton('storage') ?? undefined;
|
|
789
|
+
const context = await (0, system_1.createAddonContext)(broker, addon.declaration, config.dataDir, {
|
|
790
|
+
storageProvider,
|
|
791
|
+
addonConfig: { rootPath: config.dataDir },
|
|
792
|
+
createLogger: loggerFactory,
|
|
793
|
+
capabilityRegistry: registry,
|
|
794
|
+
// Feed the storage-orchestrator its first-boot seed declarations
|
|
795
|
+
// (addons-data:default, models:default, …). Without this the agent's
|
|
796
|
+
// sqlite-settings aborts boot: "No default storage location
|
|
797
|
+
// configured for type addons-data" → fatal crash-loop.
|
|
798
|
+
listStorageLocationDeclarations: () => loader.listStorageLocationDeclarations(),
|
|
799
|
+
});
|
|
800
|
+
const initResult = (0, types_1.normalizeAddonInitResult)(await instance.initialize(context));
|
|
801
|
+
for (const reg of initResult?.providers ?? []) {
|
|
802
|
+
const capName = reg.capability.name;
|
|
803
|
+
registry.registerProvider(capName, addonId, reg.provider);
|
|
804
|
+
// Also register in the per-broker context registry
|
|
805
|
+
context.registerProvider(capName, reg.provider);
|
|
806
|
+
}
|
|
807
|
+
loadedAddons.set(addonId, {
|
|
808
|
+
id: addonId,
|
|
809
|
+
status: 'running',
|
|
810
|
+
version: addon.declaration.version,
|
|
811
|
+
packageName: addon.packageName,
|
|
812
|
+
packageVersion: addon.packageVersion,
|
|
813
|
+
addon: instance,
|
|
814
|
+
});
|
|
815
|
+
console.log(`[Agent] Core addon "${addonId}" initialized`);
|
|
816
|
+
}
|
|
817
|
+
catch (err) {
|
|
818
|
+
const msg = (0, types_2.errMsg)(err);
|
|
819
|
+
console.error(`[Agent] Failed to initialize core addon "${addonId}": ${msg}`);
|
|
820
|
+
if (infra.required) {
|
|
821
|
+
throw new Error(`Required infrastructure addon "${addonId}" failed: ${msg}`, { cause: err });
|
|
822
|
+
}
|
|
823
|
+
}
|
|
824
|
+
}
|
|
825
|
+
}
|
|
826
|
+
// ---------------------------------------------------------------------------
|
|
827
|
+
// Phase 1.5: Load cluster-capable addon packages
|
|
828
|
+
// ---------------------------------------------------------------------------
|
|
829
|
+
async function loadClusterCapableAddons(broker, config, capabilityRegistry, loadedAddons) {
|
|
830
|
+
const addonPackageDirs = resolveAddonPackageDirs(config.addonsDir);
|
|
831
|
+
if (addonPackageDirs.length === 0)
|
|
832
|
+
return;
|
|
833
|
+
// ── Phase 0 (cross-package): collect every group-eligible addon
|
|
834
|
+
// across ALL package dirs FIRST so we issue exactly one
|
|
835
|
+
// `$process.spawnGroup` per group. Doing this inside the per-dir
|
|
836
|
+
// loop spawned the same group multiple times — Moleculer rejected
|
|
837
|
+
// subsequent attempts with "already running" and the surviving
|
|
838
|
+
// subprocess only had the first dir's subset of addons.
|
|
839
|
+
const allGroupCandidates = [];
|
|
840
|
+
// Loaders are reused below for the per-addon legacy path; cache them
|
|
841
|
+
// so each dir is parsed once.
|
|
842
|
+
const dirToLoader = new Map();
|
|
843
|
+
for (const dir of addonPackageDirs) {
|
|
844
|
+
const loader = new system_1.AddonLoader();
|
|
845
|
+
try {
|
|
846
|
+
await loader.loadFromAddonDir(dir);
|
|
847
|
+
}
|
|
848
|
+
catch (err) {
|
|
849
|
+
console.warn(`[Agent] Skipping ${dir}: ${(0, types_2.errMsg)(err)}`);
|
|
850
|
+
continue;
|
|
851
|
+
}
|
|
852
|
+
dirToLoader.set(dir, loader);
|
|
853
|
+
for (const registered of loader.listAddons()) {
|
|
854
|
+
if (loadedAddons.has(registered.declaration.id))
|
|
855
|
+
continue;
|
|
856
|
+
if (registered.declaration.execution === undefined)
|
|
857
|
+
continue;
|
|
858
|
+
const placement = (0, types_1.resolveAddonPlacement)(registered.declaration);
|
|
859
|
+
if (placement === 'hub-only')
|
|
860
|
+
continue;
|
|
861
|
+
allGroupCandidates.push({
|
|
862
|
+
groupId: (0, types_1.resolveRunnerId)(registered.declaration, registered.declaration.id),
|
|
863
|
+
addonId: registered.declaration.id,
|
|
864
|
+
addonDir: dir,
|
|
865
|
+
version: registered.declaration.version ?? '0.0.0',
|
|
866
|
+
packageName: registered.packageName,
|
|
867
|
+
packageVersion: registered.packageVersion,
|
|
868
|
+
capabilities: registered.declaration.capabilities ?? [],
|
|
869
|
+
});
|
|
870
|
+
}
|
|
871
|
+
}
|
|
872
|
+
if (allGroupCandidates.length > 0) {
|
|
873
|
+
const grouped = new Map();
|
|
874
|
+
for (const c of allGroupCandidates) {
|
|
875
|
+
const arr = grouped.get(c.groupId) ?? [];
|
|
876
|
+
arr.push(c);
|
|
877
|
+
grouped.set(c.groupId, arr);
|
|
878
|
+
}
|
|
879
|
+
for (const [groupId, addons] of grouped) {
|
|
880
|
+
try {
|
|
881
|
+
await broker.call('$process.spawnRunner', {
|
|
882
|
+
runnerId: groupId,
|
|
883
|
+
addons: addons.map((a) => ({ addonId: a.addonId, addonDir: a.addonDir })),
|
|
884
|
+
});
|
|
885
|
+
// Shared with the reload path (`ensureGroupRunner`) so the boot and
|
|
886
|
+
// reload registrations can never drift — the drift was the in-process
|
|
887
|
+
// reload-wedge's root cause.
|
|
888
|
+
(0, agent_group_runner_js_1.registerGroupRunnerProviders)({ broker, capabilityRegistry, loadedAddons }, addons);
|
|
889
|
+
console.log(`[Agent] Group "${groupId}" spawned with ${addons.length} addon(s): ${addons.map((a) => a.addonId).join(', ')}`);
|
|
890
|
+
}
|
|
891
|
+
catch (err) {
|
|
892
|
+
const msg = err instanceof Error ? (err.stack ?? err.message) : String(err);
|
|
893
|
+
console.error(`[Agent] Failed to spawn group "${groupId}": ${msg}`);
|
|
894
|
+
for (const a of addons) {
|
|
895
|
+
loadedAddons.set(a.addonId, { id: a.addonId, status: 'error' });
|
|
896
|
+
}
|
|
897
|
+
}
|
|
898
|
+
}
|
|
899
|
+
}
|
|
900
|
+
// Diagnostic — list addons that exist in the agent's package dir but
|
|
901
|
+
// were filtered out by Phase 0 (placement=hub-only or no execution).
|
|
902
|
+
for (const dir of addonPackageDirs) {
|
|
903
|
+
const loader = dirToLoader.get(dir);
|
|
904
|
+
if (!loader)
|
|
905
|
+
continue;
|
|
906
|
+
for (const registered of loader.listAddons()) {
|
|
907
|
+
const addonId = registered.declaration.id;
|
|
908
|
+
if (loadedAddons.has(addonId))
|
|
909
|
+
continue;
|
|
910
|
+
if (!(0, types_1.isDeployableToAgent)(registered.declaration))
|
|
911
|
+
continue;
|
|
912
|
+
console.warn(`[Agent] Addon "${addonId}" is deployable but missing from any spawned group — verify package.json execution field`);
|
|
913
|
+
}
|
|
914
|
+
}
|
|
915
|
+
}
|
|
916
|
+
// ---------------------------------------------------------------------------
|
|
917
|
+
// Phase 2: Load deployed addons (pushed from hub via $agent.deploy)
|
|
918
|
+
// ---------------------------------------------------------------------------
|
|
919
|
+
async function loadDeployedAddons(broker, addonsDir, dataDir, loadedAddons, storageProvider, loggerFactory, capabilityRegistry) {
|
|
920
|
+
if (!fs.existsSync(addonsDir))
|
|
921
|
+
return;
|
|
922
|
+
// Discover deployable addons by reading each package's MANIFEST
|
|
923
|
+
// (package.json `camstack.addons[]`) — NOT by importing the entry module.
|
|
924
|
+
// Importing heavy native addon entries (node-av / onnxruntime bridge / …) on
|
|
925
|
+
// the agent MAIN thread is what wedged `$agent.reload` and dropped the node
|
|
926
|
+
// from topology. A group-runner addon only needs its declaration to be
|
|
927
|
+
// (re)dispatched to a subprocess, which loads the real module itself.
|
|
928
|
+
const packageDirs = resolveAddonPackageDirs(addonsDir);
|
|
929
|
+
const groupCandidates = [];
|
|
930
|
+
// Dirs holding a deployable NON-group addon — and ONLY these — need the
|
|
931
|
+
// importing loader. None exist under the default `hub-only` placement
|
|
932
|
+
// (deployable ⟹ cluster-capable), so the heavy import path is never taken.
|
|
933
|
+
const inProcessDirs = new Set();
|
|
934
|
+
for (const dir of packageDirs) {
|
|
935
|
+
const manifest = (0, agent_group_runner_js_1.readPackageManifestAddons)(dir);
|
|
936
|
+
if (!manifest)
|
|
937
|
+
continue;
|
|
938
|
+
for (const decl of manifest.declarations) {
|
|
939
|
+
const addonId = decl.id;
|
|
940
|
+
if (loadedAddons.has(addonId))
|
|
941
|
+
continue;
|
|
942
|
+
if (!(0, types_1.isDeployableToAgent)(decl))
|
|
943
|
+
continue;
|
|
944
|
+
if ((0, agent_group_runner_js_1.isGroupRunnerAddon)(decl)) {
|
|
945
|
+
groupCandidates.push({
|
|
946
|
+
groupId: (0, types_1.resolveRunnerId)(decl, addonId),
|
|
947
|
+
addonId,
|
|
948
|
+
addonDir: dir,
|
|
949
|
+
version: decl.version ?? '0.0.0',
|
|
950
|
+
packageName: manifest.packageName,
|
|
951
|
+
packageVersion: manifest.packageVersion,
|
|
952
|
+
capabilities: decl.capabilities ?? [],
|
|
953
|
+
});
|
|
954
|
+
}
|
|
955
|
+
else {
|
|
956
|
+
inProcessDirs.add(dir);
|
|
957
|
+
}
|
|
958
|
+
}
|
|
959
|
+
}
|
|
960
|
+
// Rare in-process fallback (imports lazily, per-dir, only when such an addon
|
|
961
|
+
// actually exists — never under the current placement model).
|
|
962
|
+
if (inProcessDirs.size > 0) {
|
|
963
|
+
await loadInProcessDeployedAddons(broker, [...inProcessDirs], dataDir, loadedAddons, storageProvider, loggerFactory, capabilityRegistry);
|
|
964
|
+
}
|
|
965
|
+
// (Re)spawn each cluster-capable group runner with the current on-disk code —
|
|
966
|
+
// no module import on the main thread.
|
|
967
|
+
if (groupCandidates.length === 0)
|
|
968
|
+
return;
|
|
969
|
+
if (!capabilityRegistry) {
|
|
970
|
+
console.warn(`[Agent] ${groupCandidates.length} deployed group addon(s) skipped — no capabilityRegistry passed`);
|
|
971
|
+
return;
|
|
972
|
+
}
|
|
973
|
+
const grouped = new Map();
|
|
974
|
+
for (const c of groupCandidates) {
|
|
975
|
+
const arr = grouped.get(c.groupId) ?? [];
|
|
976
|
+
arr.push(c);
|
|
977
|
+
grouped.set(c.groupId, arr);
|
|
978
|
+
}
|
|
979
|
+
const deployLogger = loggerFactory('agent-group-runner');
|
|
980
|
+
for (const [groupId, addons] of grouped) {
|
|
981
|
+
await (0, agent_group_runner_js_1.ensureGroupRunner)({ broker, capabilityRegistry, loadedAddons, logger: deployLogger }, groupId, addons);
|
|
982
|
+
}
|
|
983
|
+
}
|
|
984
|
+
/**
|
|
985
|
+
* In-process loader path for deployable NON-group addons (rare). Uses the
|
|
986
|
+
* importing `AddonLoader` — invoked lazily, per-dir, ONLY when such an addon
|
|
987
|
+
* exists. Group-runner addons never reach here, so heavy native modules are
|
|
988
|
+
* never imported on the agent main thread during a reload.
|
|
989
|
+
*/
|
|
990
|
+
async function loadInProcessDeployedAddons(broker, dirs, dataDir, loadedAddons, storageProvider, loggerFactory, capabilityRegistry) {
|
|
991
|
+
// Node-local models dir — anchored at this node's data root (CAMSTACK_DATA ??
|
|
992
|
+
// the agent --data dir), never the hub-singleton `storage` cap. Mirrors
|
|
993
|
+
// BaseAddon.resolveModelsDir so addons that read the hint and those that
|
|
994
|
+
// self-resolve agree on one path.
|
|
995
|
+
const modelsDir = path.join(process.env['CAMSTACK_DATA'] ?? dataDir, 'models');
|
|
996
|
+
const contextOptions = {
|
|
997
|
+
storageProvider,
|
|
998
|
+
addonConfig: { modelsDir },
|
|
999
|
+
createLogger: loggerFactory,
|
|
1000
|
+
capabilityRegistry,
|
|
1001
|
+
};
|
|
1002
|
+
for (const dir of dirs) {
|
|
1003
|
+
const loader = new system_1.AddonLoader();
|
|
1004
|
+
try {
|
|
1005
|
+
await loader.loadFromAddonDir(dir);
|
|
1006
|
+
}
|
|
1007
|
+
catch (err) {
|
|
1008
|
+
console.warn(`[Agent] Skipping ${dir}: ${(0, types_2.errMsg)(err)}`);
|
|
1009
|
+
continue;
|
|
1010
|
+
}
|
|
1011
|
+
for (const registered of loader.listAddons()) {
|
|
1012
|
+
const addonId = registered.declaration.id;
|
|
1013
|
+
if (loadedAddons.has(addonId))
|
|
1014
|
+
continue;
|
|
1015
|
+
if (!(0, types_1.isDeployableToAgent)(registered.declaration))
|
|
1016
|
+
continue;
|
|
1017
|
+
// Group-runner addons are dispatched to subprocesses by the caller.
|
|
1018
|
+
if ((0, agent_group_runner_js_1.isGroupRunnerAddon)(registered.declaration))
|
|
1019
|
+
continue;
|
|
1020
|
+
try {
|
|
1021
|
+
const instance = new registered.addonClass();
|
|
1022
|
+
const context = await (0, system_1.createAddonContext)(broker, registered.declaration, dataDir, contextOptions);
|
|
1023
|
+
await instance.initialize(context);
|
|
1024
|
+
const serviceSchema = (0, system_1.createAddonService)(instance, registered.declaration);
|
|
1025
|
+
broker.createService(serviceSchema);
|
|
1026
|
+
loadedAddons.set(addonId, {
|
|
1027
|
+
id: addonId,
|
|
1028
|
+
status: 'running',
|
|
1029
|
+
version: registered.declaration.version,
|
|
1030
|
+
packageName: registered.packageName,
|
|
1031
|
+
packageVersion: registered.packageVersion,
|
|
1032
|
+
addon: instance,
|
|
1033
|
+
});
|
|
1034
|
+
console.log(`[Agent] Deployed addon "${addonId}" loaded in-process`);
|
|
1035
|
+
}
|
|
1036
|
+
catch (err) {
|
|
1037
|
+
const msg = (0, types_2.errMsg)(err);
|
|
1038
|
+
console.error(`[Agent] Failed to load deployed addon "${addonId}": ${msg}`);
|
|
1039
|
+
loadedAddons.set(addonId, { id: addonId, status: 'error' });
|
|
1040
|
+
}
|
|
1041
|
+
}
|
|
1042
|
+
}
|
|
1043
|
+
}
|
|
1044
|
+
// ---------------------------------------------------------------------------
|
|
1045
|
+
// Helpers
|
|
1046
|
+
// ---------------------------------------------------------------------------
|
|
1047
|
+
/**
|
|
1048
|
+
* Read the agent's own root package.json version (best-effort). The agent
|
|
1049
|
+
* boots the `@camstack/server` closure (via CAMSTACK_ROLE=agent), so its
|
|
1050
|
+
* on-disk root package is `@camstack/server`.
|
|
1051
|
+
*/
|
|
1052
|
+
function readAgentVersion() {
|
|
1053
|
+
// package.json sits above `dist/`. Walk up until we hit it.
|
|
1054
|
+
const candidates = [
|
|
1055
|
+
path.resolve(__dirname, '..', 'package.json'),
|
|
1056
|
+
path.resolve(__dirname, '..', '..', 'package.json'),
|
|
1057
|
+
];
|
|
1058
|
+
for (const candidate of candidates) {
|
|
1059
|
+
try {
|
|
1060
|
+
if (!fs.existsSync(candidate))
|
|
1061
|
+
continue;
|
|
1062
|
+
const raw = JSON.parse(fs.readFileSync(candidate, 'utf-8'));
|
|
1063
|
+
if (raw.name === '@camstack/server' && typeof raw.version === 'string')
|
|
1064
|
+
return raw.version;
|
|
1065
|
+
}
|
|
1066
|
+
catch {
|
|
1067
|
+
/* keep searching */
|
|
1068
|
+
}
|
|
1069
|
+
}
|
|
1070
|
+
return 'unknown';
|
|
1071
|
+
}
|
|
1072
|
+
/** Check if path is a directory (follows symlinks) */
|
|
1073
|
+
function isDir(p) {
|
|
1074
|
+
try {
|
|
1075
|
+
return fs.statSync(p).isDirectory();
|
|
1076
|
+
}
|
|
1077
|
+
catch {
|
|
1078
|
+
return false;
|
|
1079
|
+
}
|
|
1080
|
+
}
|
|
1081
|
+
/** Scan addonsDir for addon packages (scoped and unscoped, follows symlinks) */
|
|
1082
|
+
function resolveAddonPackageDirs(addonsDir) {
|
|
1083
|
+
const dirs = [];
|
|
1084
|
+
if (!fs.existsSync(addonsDir))
|
|
1085
|
+
return dirs;
|
|
1086
|
+
for (const name of fs.readdirSync(addonsDir)) {
|
|
1087
|
+
const full = path.join(addonsDir, name);
|
|
1088
|
+
if (name.startsWith('@') && isDir(full)) {
|
|
1089
|
+
// Scoped packages: @camstack/addon-xyz
|
|
1090
|
+
for (const sub of fs.readdirSync(full)) {
|
|
1091
|
+
const subFull = path.join(full, sub);
|
|
1092
|
+
if (isDir(subFull) && fs.existsSync(path.join(subFull, 'package.json'))) {
|
|
1093
|
+
dirs.push(subFull);
|
|
1094
|
+
}
|
|
1095
|
+
}
|
|
1096
|
+
}
|
|
1097
|
+
else if (isDir(full) && fs.existsSync(path.join(full, 'package.json'))) {
|
|
1098
|
+
dirs.push(full);
|
|
1099
|
+
}
|
|
1100
|
+
}
|
|
1101
|
+
return dirs;
|
|
1102
|
+
}
|
|
1103
|
+
// ---------------------------------------------------------------------------
|
|
1104
|
+
// E1 helper — agent-local UDS child manifest adaptation
|
|
1105
|
+
// ---------------------------------------------------------------------------
|
|
1106
|
+
/**
|
|
1107
|
+
* Adapt a child's UDS `ChildCapDescriptor[]` into a `RegisterNodeParams`
|
|
1108
|
+
* for the agent's subtree HubNodeRegistry.
|
|
1109
|
+
*
|
|
1110
|
+
* Multi-addon manifest support (co-location): descriptors are grouped by the
|
|
1111
|
+
* `addonId` each one carries, producing one manifest entry per hosted addon —
|
|
1112
|
+
* a GROUPED runner (`execution.group`) registers each addon's caps under its
|
|
1113
|
+
* REAL addon id instead of collapsing them under a synthetic
|
|
1114
|
+
* `addonId = childId` (the group name). A legacy child that omits `addonId`
|
|
1115
|
+
* falls back to `childId` — identical to the historical single-addon
|
|
1116
|
+
* behaviour (childId = runnerId = addonId when no group is declared).
|
|
1117
|
+
* Mirrors the hub's `buildChildUdsManifest`.
|
|
1118
|
+
*
|
|
1119
|
+
* Only system (non-device-scoped) caps go into `addons`; device-scoped native
|
|
1120
|
+
* caps arrive on a later re-handshake via the Moleculer path.
|
|
1121
|
+
*/
|
|
1122
|
+
function buildAgentChildUdsManifest(nodeId, childId, caps) {
|
|
1123
|
+
const capsByAddon = new Map();
|
|
1124
|
+
for (const cap of caps) {
|
|
1125
|
+
if (cap.deviceId !== undefined)
|
|
1126
|
+
continue;
|
|
1127
|
+
const addonId = cap.addonId ?? childId;
|
|
1128
|
+
const set = capsByAddon.get(addonId) ?? new Set();
|
|
1129
|
+
set.add(cap.capName);
|
|
1130
|
+
capsByAddon.set(addonId, set);
|
|
1131
|
+
}
|
|
1132
|
+
if (capsByAddon.size === 0) {
|
|
1133
|
+
capsByAddon.set(childId, new Set());
|
|
1134
|
+
}
|
|
1135
|
+
const addons = [...capsByAddon.entries()].map(([addonId, capNames]) => ({ addonId, capabilities: [...capNames] }));
|
|
1136
|
+
return { nodeId, addons };
|
|
1137
|
+
}
|