@camstack/server 1.1.24 → 1.1.26

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.
@@ -1,4 +1,24 @@
1
1
  "use strict";
2
+ /**
3
+ * Health endpoints — fast probe surface for monitoring (k8s, uptime,
4
+ * external watchdogs).
5
+ *
6
+ * Routes:
7
+ * - GET /health → hub self-health (in-memory registry ONLY, no RPC)
8
+ * - GET /health/agents → online agent node IDs (in-memory registry ONLY, no RPC)
9
+ * - GET /health/agents/:nodeId → forward to agent's `$agent.health`
10
+ * - GET /health/cluster → hub + every online agent in one shot
11
+ *
12
+ * `/health` and `/health/agents` are served purely from the hub's in-memory
13
+ * Moleculer node registry (`listNodeLiveness`) and NEVER fan out to agent
14
+ * nodes — a frozen/ghost agent cannot slow them. This is what makes `/health`
15
+ * safe as the admin-ui's 4s hub-liveness probe (#26): the old path went
16
+ * through `listNodes()` which RPCs `$agent.status`/`$process.list` at 5s/node.
17
+ * `/health/agents/:nodeId` and `/health/cluster` are the (intentional)
18
+ * forwarding surfaces backed by the `$agent.health` Moleculer action, so
19
+ * monitors talking to the hub and monitors talking directly to an agent
20
+ * (`http://<agent>:4444/health`) see identical payloads.
21
+ */
2
22
  Object.defineProperty(exports, "__esModule", { value: true });
3
23
  exports.buildHubHealth = buildHubHealth;
4
24
  exports.registerHealthRoutes = registerHealthRoutes;
@@ -7,9 +27,10 @@ function nowIso() {
7
27
  return new Date().toISOString();
8
28
  }
9
29
  async function buildHubHealth(deps, proc = process) {
10
- const nodes = await deps.agentRegistry.listNodes();
30
+ // Fan-out-free: in-memory registry snapshot only, never a per-node RPC (#26).
31
+ const nodes = deps.agentRegistry.listNodeLiveness();
11
32
  const remote = nodes.filter((n) => !n.isHub);
12
- const offlineNodes = remote.filter((n) => n.isOnline === false);
33
+ const offlineNodes = remote.filter((n) => !n.isOnline);
13
34
  const total = remote.length;
14
35
  const online = total - offlineNodes.length;
15
36
  const memUsage = proc.memoryUsage();
@@ -27,7 +48,7 @@ async function buildHubHealth(deps, proc = process) {
27
48
  offline: offlineNodes.length,
28
49
  // Named so health consumers (admin-ui connection banner) can say
29
50
  // WHICH node is degraded, not only how many.
30
- offlineIds: offlineNodes.map((n) => n.info.id),
51
+ offlineIds: offlineNodes.map((n) => n.id),
31
52
  },
32
53
  cpuPercent: 0,
33
54
  memoryPercent,
@@ -55,10 +76,8 @@ function registerHealthRoutes(fastify, deps) {
55
76
  return health;
56
77
  });
57
78
  fastify.get('/health/agents', async () => {
58
- const nodes = await deps.agentRegistry.listNodes();
59
- return {
60
- agents: nodes.filter((n) => !n.isHub && n.isOnline !== false).map((n) => n.info.id),
61
- };
79
+ const nodes = deps.agentRegistry.listNodeLiveness();
80
+ return { agents: nodes.filter((n) => !n.isHub && n.isOnline).map((n) => n.id) };
62
81
  });
63
82
  fastify.get('/health/agents/:nodeId', async (req, reply) => {
64
83
  const { nodeId } = req.params;
@@ -73,9 +92,10 @@ function registerHealthRoutes(fastify, deps) {
73
92
  });
74
93
  fastify.get('/health/cluster', async () => {
75
94
  const hub = await buildHubHealth(deps);
76
- const nodes = await deps.agentRegistry.listNodes();
77
- const remote = nodes.filter((n) => !n.isHub && n.isOnline !== false);
78
- const agents = await Promise.all(remote.map((n) => fetchAgentHealth(deps, n.info.id)));
95
+ // Enumeration only (fan-out-free); the $agent.health fan-out below is intentional.
96
+ const nodes = deps.agentRegistry.listNodeLiveness();
97
+ const remote = nodes.filter((n) => !n.isHub && n.isOnline);
98
+ const agents = await Promise.all(remote.map((n) => fetchAgentHealth(deps, n.id)));
79
99
  const ok = hub.ok && agents.every((a) => a.ok);
80
100
  return { ok, hub, agents, checkedAt: nowIso() };
81
101
  });
@@ -2337,6 +2337,33 @@ function createCapRouter_deviceManager(getProvider, createRemoteProxy) {
2337
2337
  // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-argument
2338
2338
  return p.setDeviceLinks(methodInput);
2339
2339
  }),
2340
+ setDisplay: trpc_middleware_js_1.adminProcedure
2341
+ .input(types_38.deviceManagerCapability.methods.setDisplay.input.loose())
2342
+ .output(types_38.deviceManagerCapability.methods.setDisplay.output)
2343
+ .mutation(async ({ input, ctx }) => {
2344
+ const { nodeId, ...methodInput } = input;
2345
+ const p = resolveProvider('device-manager', nodeId, () => getProvider(ctx), createRemoteProxy);
2346
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-argument
2347
+ return p.setDisplay(methodInput);
2348
+ }),
2349
+ getRoleDisplayDefaults: trpc_middleware_js_1.protectedProcedure
2350
+ .input(types_38.deviceManagerCapability.methods.getRoleDisplayDefaults.input.loose())
2351
+ .output(types_38.deviceManagerCapability.methods.getRoleDisplayDefaults.output)
2352
+ .query(async ({ input, ctx }) => {
2353
+ const { nodeId, ...methodInput } = input;
2354
+ const p = resolveProvider('device-manager', nodeId, () => getProvider(ctx), createRemoteProxy);
2355
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-argument
2356
+ return p.getRoleDisplayDefaults(methodInput);
2357
+ }),
2358
+ setRoleDisplayDefaults: trpc_middleware_js_1.adminProcedure
2359
+ .input(types_38.deviceManagerCapability.methods.setRoleDisplayDefaults.input.loose())
2360
+ .output(types_38.deviceManagerCapability.methods.setRoleDisplayDefaults.output)
2361
+ .mutation(async ({ input, ctx }) => {
2362
+ const { nodeId, ...methodInput } = input;
2363
+ const p = resolveProvider('device-manager', nodeId, () => getProvider(ctx), createRemoteProxy);
2364
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-argument
2365
+ return p.setRoleDisplayDefaults(methodInput);
2366
+ }),
2340
2367
  getWireableFields: trpc_middleware_js_1.protectedProcedure
2341
2368
  .input(types_38.deviceManagerCapability.methods.getWireableFields.input.loose())
2342
2369
  .output(types_38.deviceManagerCapability.methods.getWireableFields.output)
@@ -34,6 +34,7 @@ var __importStar = (this && this.__importStar) || (function () {
34
34
  })();
35
35
  Object.defineProperty(exports, "__esModule", { value: true });
36
36
  exports.AgentRegistryService = void 0;
37
+ exports.toNodeLiveness = toNodeLiveness;
37
38
  const node_crypto_1 = require("node:crypto");
38
39
  const os = __importStar(require("node:os"));
39
40
  const system_1 = require("@camstack/system");
@@ -70,6 +71,16 @@ const AGENT_BOOTSTRAP_PACKAGES = new Set([
70
71
  '@camstack/system',
71
72
  '@camstack/addon-agent-ui',
72
73
  ]);
74
+ /**
75
+ * Map raw Moleculer registry nodes to the health-surface liveness rows.
76
+ * Child runner ids (`hub/foo`, `agent/bar`) are processes, not cluster
77
+ * nodes — excluded, same rule as `listNodes()` / `classifyNode`.
78
+ */
79
+ function toNodeLiveness(nodes) {
80
+ return nodes
81
+ .filter((node) => !node.id.includes('/'))
82
+ .map((node) => ({ id: node.id, isHub: node.id === 'hub', isOnline: node.available }));
83
+ }
73
84
  class AgentRegistryService {
74
85
  eventBus;
75
86
  moleculer;
@@ -232,6 +243,14 @@ class AgentRegistryService {
232
243
  * match — a single package can ship multiple addons with distinct ids
233
244
  * and placements.
234
245
  *
246
+ * Version-skew exception: a decl-id the hub has never heard of is NOT
247
+ * stale when the hub already ships that addon's PACKAGE. A newer agent
248
+ * bundle can add a decl-id within a package the hub also ships (e.g. a
249
+ * new `decoder-nodeav` inside `@camstack/addon-pipeline`); undeploying
250
+ * it would delete the whole shared bundle dir on the agent and crash
251
+ * every sibling runner. Only a decl-id whose PACKAGE is absent from the
252
+ * hub is treated as genuinely stale.
253
+ *
235
254
  * All errors are caught and logged so a single bad agent never breaks
236
255
  * the caller (connect handler or boot pass).
237
256
  */
@@ -257,14 +276,20 @@ class AgentRegistryService {
257
276
  if (agentAddons.length === 0)
258
277
  return;
259
278
  // Build the hub's placement map: decl id → placement. Absence from
260
- // this map means "not installed on the hub".
279
+ // this map means "not installed on the hub". Also collect the set of
280
+ // PACKAGE names the hub ships — used by the version-skew guard below.
261
281
  const hubPlacements = new Map();
282
+ const hubPackages = new Set();
262
283
  for (const row of this.addonRegistry.listAddons()) {
263
284
  const declId = row.manifest.id;
264
285
  if (typeof declId !== 'string')
265
286
  continue;
266
287
  const decl = row.declaration ?? row.manifest;
267
288
  hubPlacements.set(declId, (0, types_1.resolveAddonPlacement)(decl));
289
+ const packageName = row.manifest.packageName;
290
+ if (typeof packageName === 'string' && packageName.length > 0) {
291
+ hubPackages.add(packageName);
292
+ }
268
293
  }
269
294
  const stale = agentAddons.filter((addon) => {
270
295
  // Agent bootstrap infrastructure (storage/settings/metrics/logging from
@@ -278,9 +303,16 @@ class AgentRegistryService {
278
303
  return false;
279
304
  }
280
305
  const placement = hubPlacements.get(addon.id);
281
- // Not installed on the hub → stale.
282
- if (placement === undefined)
306
+ if (placement === undefined) {
307
+ // decl-id unknown to the hub. If the hub ships this PACKAGE, this is a
308
+ // version-skew sibling (a newer agent bundle added a decl-id within a
309
+ // package the hub already ships) — undeploying it would delete the whole
310
+ // shared bundle on the agent. Only undeploy when the PACKAGE itself is
311
+ // absent from the hub.
312
+ if (addon.packageName !== undefined && hubPackages.has(addon.packageName))
313
+ return false;
283
314
  return true;
315
+ }
284
316
  // Installed but pinned to the hub → must not run on an agent.
285
317
  return placement === 'hub-only';
286
318
  });
@@ -339,6 +371,19 @@ class AgentRegistryService {
339
371
  updateAgentName(nodeId, name) {
340
372
  console.log(`[agent-registry] Agent renamed: "${nodeId}" → "${name}"`);
341
373
  }
374
+ /**
375
+ * Fan-out-free liveness snapshot for the `/health` surface. Reads ONLY
376
+ * the hub's in-memory Moleculer node registry — never a per-node RPC —
377
+ * so a frozen/ghost agent can never slow the hub-liveness probe.
378
+ * (`listNodes()` fans `$agent.status`/`$process.list` out at 5s/node;
379
+ * the admin-ui probe aborts `/health` at 4s, which turned one frozen
380
+ * node into a false RED "Server unreachable" banner — #26.)
381
+ * `onlyAvailable: false` keeps recently-offline nodes listed with
382
+ * `available:false`, which is what populates `agents.offlineIds`.
383
+ */
384
+ listNodeLiveness() {
385
+ return toNodeLiveness(this.moleculer.broker.registry.getNodeList({ onlyAvailable: false }));
386
+ }
342
387
  async listNodes() {
343
388
  // Get child processes for hub via $process.list
344
389
  let hubProcesses = [];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@camstack/server",
3
- "version": "1.1.24",
3
+ "version": "1.1.26",
4
4
  "private": false,
5
5
  "files": [
6
6
  "dist",