@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.
@@ -0,0 +1,595 @@
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.resolveDeployAction = resolveDeployAction;
37
+ exports.createAgentService = createAgentService;
38
+ const fs = __importStar(require("node:fs"));
39
+ const os = __importStar(require("node:os"));
40
+ const path = __importStar(require("node:path"));
41
+ const system_1 = require("@camstack/system");
42
+ const moleculer_1 = require("moleculer");
43
+ const agent_deploy_swap_js_1 = require("./agent-deploy-swap.js");
44
+ const apply_model_distribution_js_1 = require("./apply-model-distribution.js");
45
+ const fetch_bundle_from_hub_js_1 = require("./fetch-bundle-from-hub.js");
46
+ /**
47
+ * Console-backed scoped logger for the deploy swap (the only consumer in this
48
+ * file). `applyDeployedBundle` logs only on the rare restore-after-failed-swap
49
+ * path, so a thin console adapter is sufficient.
50
+ */
51
+ const deploySwapLogger = {
52
+ info: (msg) => console.log(`[Agent] ${msg}`),
53
+ warn: (msg) => console.warn(`[Agent] ${msg}`),
54
+ error: (msg) => console.error(`[Agent] ${msg}`),
55
+ debug: (msg) => console.debug(`[Agent] ${msg}`),
56
+ child: () => deploySwapLogger,
57
+ withTags: () => deploySwapLogger,
58
+ };
59
+ /** Timeout for the agent-local `$process.restart` delegation. */
60
+ const AGENT_PROCESS_RESTART_TIMEOUT_MS = 30_000;
61
+ function getLocalIps() {
62
+ const interfaces = os.networkInterfaces();
63
+ const ips = [];
64
+ for (const ifaces of Object.values(interfaces)) {
65
+ if (!ifaces)
66
+ continue;
67
+ for (const iface of ifaces) {
68
+ if (iface.internal)
69
+ continue;
70
+ ips.push(iface.address);
71
+ }
72
+ }
73
+ return ips;
74
+ }
75
+ /**
76
+ * Resolve a promise but never block longer than `ms` — on timeout, resolve to
77
+ * `fallback`. `$agent.status`/`$agent.health` MUST always answer promptly: the
78
+ * hub drops a node from `nodes.topology` whenever `$agent.status` times out, so
79
+ * a status RPC that hangs on a transiently-unavailable metrics provider (e.g.
80
+ * mid-reload) makes the agent silently vanish from the cluster. Bounding the
81
+ * metrics fetch keeps the node visible with a degraded (zeroed) metrics block
82
+ * instead of disappearing.
83
+ */
84
+ async function withTimeout(p, ms, fallback) {
85
+ let timer;
86
+ const timeout = new Promise((resolve) => {
87
+ timer = setTimeout(() => resolve(fallback), ms);
88
+ });
89
+ try {
90
+ return await Promise.race([p, timeout]);
91
+ }
92
+ finally {
93
+ if (timer)
94
+ clearTimeout(timer);
95
+ }
96
+ }
97
+ /** Bound for the metrics snapshot inside status/health — see `withTimeout`. */
98
+ const METRICS_SNAPSHOT_TIMEOUT_MS = 2_000;
99
+ /**
100
+ * Pure factory that builds the deploy routing logic from a seam. Exported so
101
+ * the routing can be unit-tested without Moleculer or a real filesystem.
102
+ */
103
+ function resolveDeployAction(seam) {
104
+ return async (params) => {
105
+ const { addonId, source, bundle } = params;
106
+ if (source && (0, system_1.isAddonDeploySource)(source)) {
107
+ if (source.kind === 'npm') {
108
+ await seam.installFromNpm(addonId, source.version);
109
+ // npm path installs to disk via AddonInstaller and returns no addonDir; the
110
+ // loadedAddons eviction (onApplied) only applies to bundle-extracted installs.
111
+ return { success: true, addonId };
112
+ }
113
+ const buf = await seam.fetchBundle(source);
114
+ const { addonDir } = await seam.applyBundle(buf);
115
+ seam.onApplied(addonDir);
116
+ return { success: true, addonId, path: addonDir };
117
+ }
118
+ // Legacy inline-bundle path (back-compat, removed next release).
119
+ if (bundle === undefined) {
120
+ throw new Error('$agent.deploy: no source descriptor and no legacy bundle provided');
121
+ }
122
+ const buf = typeof bundle === 'string' ? Buffer.from(bundle, 'base64') : bundle;
123
+ const { addonDir } = await seam.applyBundle(buf);
124
+ seam.onApplied(addonDir);
125
+ return { success: true, addonId, path: addonDir };
126
+ };
127
+ }
128
+ function readHubAddressFromConfig(configPath) {
129
+ if (!configPath)
130
+ return null;
131
+ try {
132
+ if (!fs.existsSync(configPath))
133
+ return null;
134
+ const raw = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
135
+ return typeof raw.hubAddress === 'string' && raw.hubAddress.length > 0 ? raw.hubAddress : null;
136
+ }
137
+ catch {
138
+ return null;
139
+ }
140
+ }
141
+ /**
142
+ * Read the addon declaration ids contributed by a deployed package.
143
+ *
144
+ * `loadedAddons` is keyed by `camstack.addons[].id` (the declaration
145
+ * id), not the package name. A redeploy's `$agent.deploy` param is the
146
+ * package name, so the deploy handler reads the freshly-extracted
147
+ * `package.json` to recover the real ids it must evict before
148
+ * `$agent.reload`. Best-effort — returns `[]` on a missing/corrupt
149
+ * manifest (the reload then just falls back to its on-disk scan).
150
+ */
151
+ function readDeployedAddonIds(addonDir) {
152
+ try {
153
+ const manifestPath = path.join(addonDir, 'package.json');
154
+ if (!fs.existsSync(manifestPath))
155
+ return [];
156
+ const raw = JSON.parse(fs.readFileSync(manifestPath, 'utf-8'));
157
+ const entries = raw.camstack?.addons ?? [];
158
+ const ids = [];
159
+ for (const entry of entries) {
160
+ if (typeof entry.id === 'string' && entry.id.length > 0)
161
+ ids.push(entry.id);
162
+ }
163
+ return ids;
164
+ }
165
+ catch {
166
+ return [];
167
+ }
168
+ }
169
+ function isHubConnected(broker) {
170
+ try {
171
+ const registry = broker.registry;
172
+ const nodes = registry?.getNodeList?.({ onlyAvailable: true }) ?? [];
173
+ return nodes.some((n) => n.id === 'hub');
174
+ }
175
+ catch {
176
+ return false;
177
+ }
178
+ }
179
+ function createAgentService(deps) {
180
+ return {
181
+ name: '$agent',
182
+ actions: {
183
+ status: {
184
+ handler: async (ctx) => {
185
+ const { broker } = ctx;
186
+ const cpus = os.cpus();
187
+ let cpuPercent = 0;
188
+ let memoryPercent = 0;
189
+ const metrics = deps.getMetricsProvider?.();
190
+ if (metrics) {
191
+ // Bounded so $agent.status never hangs (and drops the node from
192
+ // topology) when the metrics provider is mid-reload/unavailable.
193
+ const snapshot = await withTimeout(metrics.getCached(), METRICS_SNAPSHOT_TIMEOUT_MS, null);
194
+ if (snapshot) {
195
+ cpuPercent = snapshot.cpu.total;
196
+ memoryPercent = snapshot.memory.percent;
197
+ }
198
+ }
199
+ const mem = process.memoryUsage();
200
+ return {
201
+ nodeId: broker.nodeID,
202
+ name: deps.agentName,
203
+ platform: os.platform(),
204
+ arch: os.arch(),
205
+ hostname: os.hostname(),
206
+ cpuCores: cpus.length,
207
+ cpuModel: cpus[0]?.model,
208
+ totalMemoryMB: Math.round(os.totalmem() / 1024 / 1024),
209
+ freeMemoryMB: Math.round(os.freemem() / 1024 / 1024),
210
+ cpuPercent,
211
+ memoryPercent,
212
+ uptime: os.uptime(),
213
+ // The agent *process* itself (distinct from the host `uptime` /
214
+ // memory above) — surfaced so the UI can show how long THIS agent
215
+ // has been running and how much RAM its own runtime holds.
216
+ agentProcess: {
217
+ pid: process.pid,
218
+ uptimeSeconds: Math.round(process.uptime()),
219
+ rssMB: Math.round(mem.rss / 1024 / 1024),
220
+ heapUsedMB: Math.round(mem.heapUsed / 1024 / 1024),
221
+ heapTotalMB: Math.round(mem.heapTotal / 1024 / 1024),
222
+ },
223
+ localIps: getLocalIps(),
224
+ addons: [...deps.loadedAddons.values()].map((a) => ({
225
+ id: a.id,
226
+ status: a.status,
227
+ // Report the PACKAGE version (falls back to the declaration
228
+ // version) so the hub's per-node update check is accurate.
229
+ version: a.packageVersion ?? a.version,
230
+ packageName: a.packageName,
231
+ })),
232
+ };
233
+ },
234
+ },
235
+ health: {
236
+ handler: async (ctx) => {
237
+ const { broker } = ctx;
238
+ let cpuPercent = 0;
239
+ let memoryPercent = 0;
240
+ const metrics = deps.getMetricsProvider?.();
241
+ if (metrics) {
242
+ try {
243
+ // Bounded — see $agent.status. Health must answer promptly too.
244
+ const snapshot = await withTimeout(metrics.getCached(), METRICS_SNAPSHOT_TIMEOUT_MS, null);
245
+ if (snapshot) {
246
+ cpuPercent = snapshot.cpu.total;
247
+ memoryPercent = snapshot.memory.percent;
248
+ }
249
+ }
250
+ catch {
251
+ /* metrics may be transiently unavailable */
252
+ }
253
+ }
254
+ let total = 0;
255
+ let running = 0;
256
+ let errored = 0;
257
+ for (const a of deps.loadedAddons.values()) {
258
+ total++;
259
+ if (a.status === 'running')
260
+ running++;
261
+ else if (a.status === 'error')
262
+ errored++;
263
+ }
264
+ const hubAddress = readHubAddressFromConfig(deps.configPath);
265
+ return {
266
+ ok: errored === 0,
267
+ nodeId: broker.nodeID,
268
+ name: deps.agentName,
269
+ version: deps.agentVersion ?? 'unknown',
270
+ uptimeSeconds: Math.round(process.uptime()),
271
+ pid: process.pid,
272
+ hubConnected: isHubConnected(broker),
273
+ hubAddress,
274
+ addons: { total, running, error: errored },
275
+ cpuPercent,
276
+ memoryPercent,
277
+ checkedAt: new Date().toISOString(),
278
+ };
279
+ },
280
+ },
281
+ shutdown: {
282
+ handler() {
283
+ // Graceful shutdown — schedule so the Moleculer response goes out first
284
+ setTimeout(() => process.exit(0), 500);
285
+ return { success: true };
286
+ },
287
+ },
288
+ rename: {
289
+ handler(ctx) {
290
+ const { params, broker } = ctx;
291
+ const newName = params.name;
292
+ if (!newName || typeof newName !== 'string') {
293
+ throw new Error('$agent.rename: name is required');
294
+ }
295
+ const oldName = deps.agentName;
296
+ // Update in-memory name (affects subsequent $agent.status responses)
297
+ deps.agentName = newName.trim();
298
+ broker.logger.info(`Agent renamed: "${oldName}" → "${deps.agentName}"`);
299
+ // Persist to config file
300
+ try {
301
+ const configFile = path.resolve(deps.configPath);
302
+ let raw = {};
303
+ if (fs.existsSync(configFile)) {
304
+ try {
305
+ raw = JSON.parse(fs.readFileSync(configFile, 'utf-8'));
306
+ }
307
+ catch {
308
+ /* corrupt */
309
+ }
310
+ }
311
+ raw.name = deps.agentName;
312
+ fs.mkdirSync(path.dirname(configFile), { recursive: true });
313
+ fs.writeFileSync(configFile, JSON.stringify(raw, null, 2), 'utf-8');
314
+ broker.logger.info(`Agent name persisted to ${configFile}`);
315
+ }
316
+ catch (err) {
317
+ broker.logger.warn('Agent rename: config file write failed (in-memory rename still active)', { error: String(err) });
318
+ }
319
+ return { success: true, name: deps.agentName };
320
+ },
321
+ },
322
+ listAddons: {
323
+ handler() {
324
+ return [...deps.loadedAddons.keys()];
325
+ },
326
+ },
327
+ deploy: {
328
+ handler: async (ctx) => {
329
+ const { params } = ctx;
330
+ const { addonId, source, bundle } = params;
331
+ // execFile (no shell) instead of execSync — addonId comes from RPC
332
+ // params and was previously interpolated into a shell command, which
333
+ // is a command-injection vector. argv form doesn't go through a shell
334
+ // so any payload in addonId stays a literal path arg. Use the ASYNC
335
+ // form (not execFileSync): a synchronous extract blocks the agent
336
+ // event loop for the whole untar, which on a large package starves
337
+ // concurrent hub RPCs ($agent.status heartbeat) long enough for the
338
+ // hub to time them out and drop the node from topology mid-deploy.
339
+ const { execFile } = await Promise.resolve().then(() => __importStar(require('node:child_process')));
340
+ const { promisify } = await Promise.resolve().then(() => __importStar(require('node:util')));
341
+ const execFileAsync = promisify(execFile);
342
+ // Atomic install: extract into a temp dir then swap into place. The
343
+ // old body `rm`'d the live dir THEN untarred — a killed/timed-out
344
+ // untar (e.g. a redeploy whose RPC was cut) left the addon dir
345
+ // MISSING. `applyDeployedBundle` never leaves the live dir absent
346
+ // (the non-atomic-deploy fix).
347
+ const extract = async (tgz, destDir) => {
348
+ const tgzPath = path.join(destDir, '..', `.${path.basename(destDir)}.tgz`);
349
+ fs.writeFileSync(tgzPath, tgz);
350
+ try {
351
+ await execFileAsync('tar', ['-xzf', tgzPath, '-C', destDir, '--strip-components=1'], {
352
+ timeout: 60000,
353
+ });
354
+ }
355
+ finally {
356
+ try {
357
+ fs.unlinkSync(tgzPath);
358
+ }
359
+ catch {
360
+ /* best-effort temp cleanup */
361
+ }
362
+ }
363
+ };
364
+ const seam = {
365
+ installFromNpm: (pkg, v) => {
366
+ if (!deps.installFromNpm) {
367
+ throw new Error('npm deploy source unsupported on this agent');
368
+ }
369
+ return deps.installFromNpm(pkg, v);
370
+ },
371
+ // #17: route the bundle through the agent's AddonInstaller when
372
+ // wired — installFromTgz runs the FULL post-install (manifest
373
+ // strip, runtime-deps npm install, manifest-driven NATIVE deps)
374
+ // with its own staged extract + atomic swap. The plain tar swap
375
+ // below never installed deps, so every pipeline/post-analysis
376
+ // deploy on the Mac Electron agent silently shipped without
377
+ // `sharp` et al. Fallback kept for agents without an installer
378
+ // (test harnesses).
379
+ applyBundle: async (buf) => {
380
+ if (deps.installBundleTgz) {
381
+ const tgzPath = path.join(os.tmpdir(), `camstack-deploy-${addonId.replace(/[^\w.-]/g, '_')}-${Date.now()}.tgz`);
382
+ fs.writeFileSync(tgzPath, buf);
383
+ try {
384
+ const { name } = await deps.installBundleTgz(tgzPath);
385
+ return { addonDir: path.join(deps.addonsDir, name) };
386
+ }
387
+ finally {
388
+ try {
389
+ fs.unlinkSync(tgzPath);
390
+ }
391
+ catch {
392
+ /* best-effort temp cleanup */
393
+ }
394
+ }
395
+ }
396
+ return (0, agent_deploy_swap_js_1.applyDeployedBundle)({
397
+ addonsDir: deps.addonsDir,
398
+ addonId,
399
+ bundle: buf,
400
+ extract,
401
+ logger: deploySwapLogger,
402
+ });
403
+ },
404
+ fetchBundle: (s) => (0, fetch_bundle_from_hub_js_1.fetchBundleFromHub)(s),
405
+ // Evict every addon DECLARATION id this package contributes
406
+ // from `loadedAddons` so the follow-up `$agent.reload` actually
407
+ // re-instantiates it. `loadDeployedAddons` skips any addon
408
+ // still present in `loadedAddons`; without this eviction a
409
+ // redeploy would leave the agent pinned to the pre-update
410
+ // version. The `deploy` param `addonId` is the PACKAGE name
411
+ // (used only as the on-disk dir), whereas `loadedAddons` is
412
+ // keyed by the addon DECLARATION id — they differ for scoped
413
+ // packages, so we read the extracted manifest to bridge them.
414
+ onApplied: (addonDir) => {
415
+ for (const declId of readDeployedAddonIds(addonDir)) {
416
+ deps.loadedAddons.delete(declId);
417
+ }
418
+ },
419
+ };
420
+ return resolveDeployAction(seam)({ addonId, source, bundle });
421
+ },
422
+ },
423
+ /**
424
+ * Pull a staged model tarball from the hub and untar it into this node's
425
+ * `<dataDir>/models` (Model Studio P2). Reuses the agent-pull machinery:
426
+ * `fetchBundleFromHub` verifies bytes + sha256 before extraction. The
427
+ * existing `isModelDownloaded` then reports the model present, so the
428
+ * detection-pipeline can load it locally.
429
+ */
430
+ distributeModel: {
431
+ handler: async (ctx) => {
432
+ const { params } = ctx;
433
+ const { execFile } = await Promise.resolve().then(() => __importStar(require('node:child_process')));
434
+ const { promisify } = await Promise.resolve().then(() => __importStar(require('node:util')));
435
+ const execFileAsync = promisify(execFile);
436
+ const modelsDir = path.join(deps.dataDir, 'models');
437
+ const seam = {
438
+ modelsDir,
439
+ fetchBundle: (s) => (0, fetch_bundle_from_hub_js_1.fetchBundleFromHub)(s),
440
+ extract: async (tgz, destDir) => {
441
+ const tmp = path.join(destDir, `.dist-${Date.now()}-${Math.random().toString(36).slice(2)}.tgz`);
442
+ fs.writeFileSync(tmp, tgz);
443
+ try {
444
+ await execFileAsync('tar', ['-xzf', tmp, '-C', destDir], { timeout: 60000 });
445
+ }
446
+ finally {
447
+ try {
448
+ fs.unlinkSync(tmp);
449
+ }
450
+ catch {
451
+ /* best-effort temp cleanup */
452
+ }
453
+ }
454
+ },
455
+ mkdirp: (dir) => fs.mkdirSync(dir, { recursive: true }),
456
+ };
457
+ return (0, apply_model_distribution_js_1.applyModelDistribution)(seam, params);
458
+ },
459
+ },
460
+ /**
461
+ * Genuinely drop a deployed addon from this agent.
462
+ *
463
+ * A plain `loadedAddons.delete()` only forgets the bookkeeping entry —
464
+ * the addon instance keeps running and, crucially, its Moleculer
465
+ * service keeps advertising the addon's capabilities into the cluster
466
+ * (the hub still sees the provider as a live `<cap>@<agent>` entry).
467
+ * To truly undeploy we must, in order:
468
+ * 1. `shutdown()` the running instance (releases timers, sockets,
469
+ * native handles) — same hook the agent's SIGTERM path uses.
470
+ * 2. `broker.destroyService(addonId)` — the deployed-addon Moleculer
471
+ * service is named after the addon DECLARATION id (see
472
+ * `createAddonService` → `name: declaration.id`). Destroying it
473
+ * unregisters every capability action so the cluster stops
474
+ * routing to / advertising this agent's provider.
475
+ * 3. Drop the `loadedAddons` entry and delete the on-disk folder.
476
+ *
477
+ * `addonId` here is the addon DECLARATION id — the same key
478
+ * `loadedAddons` uses and the same value `$agent.status` reports, so
479
+ * the hub reconciler can match it directly.
480
+ */
481
+ undeploy: {
482
+ handler: async (ctx) => {
483
+ const { params, broker } = ctx;
484
+ const { addonId } = params;
485
+ const entry = deps.loadedAddons.get(addonId);
486
+ // 1. Shut the running instance down (best-effort — a throwing
487
+ // disposer must not block the rest of the teardown).
488
+ if (entry?.addon?.shutdown) {
489
+ try {
490
+ await entry.addon.shutdown();
491
+ }
492
+ catch (err) {
493
+ broker.logger.warn(`$agent.undeploy: ${addonId} shutdown() threw`, {
494
+ error: String(err),
495
+ });
496
+ }
497
+ }
498
+ // 2. Destroy the Moleculer service so the cluster stops seeing
499
+ // this agent's capability providers for the addon.
500
+ try {
501
+ await broker.destroyService(addonId);
502
+ }
503
+ catch {
504
+ // Service may not exist (group-spawned addons have no per-addon
505
+ // service on the agent broker, or it was never created).
506
+ }
507
+ // 3. Forget the entry and remove the deployed folder. The deploy
508
+ // handler keys the on-disk dir by the PACKAGE name, but $agent
509
+ // redeploys for a single addon use addonId === packageName for
510
+ // unscoped packages; remove whichever folder matches.
511
+ deps.loadedAddons.delete(addonId);
512
+ const addonDir = path.join(deps.addonsDir, addonId);
513
+ if (fs.existsSync(addonDir)) {
514
+ fs.rmSync(addonDir, { recursive: true, force: true });
515
+ }
516
+ // Defense-in-depth: one package dir can host MANY addons (e.g.
517
+ // `@camstack/addon-pipeline` ships decoder-ffmpeg, recorder,
518
+ // motion-wasm, …). Removing the whole bundle because ONE member was
519
+ // undeployed is destructive — every sibling runner would crash-loop
520
+ // on the missing package.json. Only remove the package dir when no
521
+ // OTHER loaded addon still lives in it (this addon's own entry was
522
+ // already deleted from `loadedAddons` above, so the remaining
523
+ // values are exactly the siblings).
524
+ const pkgName = entry?.packageName;
525
+ if (pkgName && pkgName !== addonId) {
526
+ const pkgShared = [...deps.loadedAddons.values()].some((e) => e.packageName === pkgName);
527
+ if (!pkgShared) {
528
+ const pkgDir = path.join(deps.addonsDir, pkgName);
529
+ if (fs.existsSync(pkgDir)) {
530
+ fs.rmSync(pkgDir, { recursive: true, force: true });
531
+ }
532
+ }
533
+ }
534
+ broker.logger.info(`$agent.undeploy: ${addonId} disposed (instance + service + folder)`);
535
+ return { success: true, addonId };
536
+ },
537
+ },
538
+ /**
539
+ * Re-run `loadDeployedAddons` so addons just dropped onto disk via
540
+ * `$agent.deploy` (or via the hub broadcasting an upload) start
541
+ * immediately. Without this the agent only discovers them on boot.
542
+ * Returns the ids that were newly loaded — the hub uses this to
543
+ * surface a per-agent diff in the upload response.
544
+ */
545
+ reload: {
546
+ handler: async () => {
547
+ if (!deps.reloadDeployedAddons) {
548
+ return { success: false, loaded: [] };
549
+ }
550
+ const loaded = await deps.reloadDeployedAddons();
551
+ return { success: true, loaded };
552
+ },
553
+ },
554
+ restart: {
555
+ handler: async (ctx) => {
556
+ const { params, broker } = ctx;
557
+ const { addonId } = params;
558
+ // Delegate to THIS agent's own `$process` service — the SAME primitive
559
+ // the hub uses for a local forked addon (AddonRegistryService.restartAddon
560
+ // → `$process.restart`). `resolveRunnerName` maps `addonId` to its hosting
561
+ // runner (direct runner key OR `runnerAddons` membership), so the child is
562
+ // respawned, the crash streak reset (`crashSupervisor.reset`), the stale
563
+ // Moleculer node evicted, and the addon's caps re-registered — identical
564
+ // semantics to a hub-local restart. Pin to the agent's own node so the
565
+ // call always hits THIS agent's `$process`, never the hub's.
566
+ const result = await broker.call('$process.restart', { name: addonId }, { nodeID: broker.nodeID, timeout: AGENT_PROCESS_RESTART_TIMEOUT_MS });
567
+ if (!result.success) {
568
+ // Fail loudly so the hub's `nodes.restartAddon` surfaces the real
569
+ // error instead of the old fire-and-forget phantom success.
570
+ throw new moleculer_1.Errors.MoleculerError(`$agent.restart: process restart failed for "${addonId}": ${result.reason ?? 'unknown'}`, 500, 'AGENT_RESTART_FAILED');
571
+ }
572
+ return { success: true, addonId, pid: result.pid };
573
+ },
574
+ },
575
+ /**
576
+ * D3 subtree aggregation: a forked group-runner child calls this action
577
+ * to deliver its complete capability manifest to the agent. The agent
578
+ * then re-registers the UNION of its own in-process addons + all children
579
+ * with the hub via `$hub.registerNode`.
580
+ */
581
+ registerNode: {
582
+ handler(ctx) {
583
+ const { params } = ctx;
584
+ // Cluster-secret gate: when the agent has a secret configured, a child
585
+ // runner must present a matching hash or the registration is rejected.
586
+ if (!(0, system_1.clusterSecretMatches)(deps.expectedClusterSecretHash, params.clusterSecretHash)) {
587
+ throw new moleculer_1.Errors.MoleculerError(`cluster secret mismatch — node "${params.nodeId}" rejected`, 403, system_1.CLUSTER_SECRET_MISMATCH_TYPE);
588
+ }
589
+ deps.onChildRegistered?.(params);
590
+ return { ok: true };
591
+ },
592
+ },
593
+ },
594
+ };
595
+ }