@camstack/server 1.1.26 → 1.1.28

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.
@@ -167,6 +167,12 @@ function getLocalIps() {
167
167
  */
168
168
  async function computeTopology(agentRegistry, addonRegistry) {
169
169
  const nodes = await agentRegistry.listNodes();
170
+ // A2: durable offline-node history. `listNodes()` (above) has just
171
+ // snapshotted every ONLINE node into this store, so `lastActive` is fresh
172
+ // for live rows and preserved (at disconnect time) for offline ones.
173
+ const history = await agentRegistry.getClusterNodeHistory();
174
+ const historyById = new Map(history.map((h) => [h.id, h]));
175
+ const liveIds = new Set(nodes.map((n) => n.info.id));
170
176
  const allAddons = addonRegistry?.listAddons() ?? [];
171
177
  const getInGroupAddonIds = (node) => {
172
178
  const subs = (node.subProcesses ?? []);
@@ -184,7 +190,7 @@ async function computeTopology(agentRegistry, addonRegistry) {
184
190
  const category = a.declaration?.category ?? 'system';
185
191
  addonCategory.set(id, category);
186
192
  }
187
- return nodes.map((node) => {
193
+ const liveNodes = nodes.map((node) => {
188
194
  const inGroupAddonIds = new Set(getInGroupAddonIds(node));
189
195
  const agentAddonIds = node.agentAddons ?? [];
190
196
  const allNodeAddons = node.isHub
@@ -286,13 +292,48 @@ async function computeTopology(agentRegistry, addonRegistry) {
286
292
  cpuPercent: node.status?.cpuPercent ?? 0,
287
293
  memoryPercent: node.status?.memoryPercent ?? 0,
288
294
  uptime: Date.now() - node.connectedSince,
289
- lastSeen: new Date().toISOString(),
295
+ // Fix (was hardcoded `new Date().toISOString()` — always "now", a bug
296
+ // even for online nodes): report the persisted `lastActive` when we have
297
+ // it, falling back to now only for a node with no history row yet.
298
+ lastSeen: new Date(historyById.get(node.info.id)?.lastActive ?? Date.now()).toISOString(),
290
299
  localIps: node.isHub ? getLocalIps() : (node.localIps ?? []),
291
300
  addons: allNodeAddons,
292
301
  processes: [mainProcess, ...childProcesses],
293
302
  categories: categoriesProjection,
294
303
  };
295
304
  });
305
+ // A2: union in OFFLINE rows for every persisted node no longer present in the
306
+ // live Moleculer window (or gone entirely after a hub restart). These render
307
+ // straight from the last-known descriptor with live metrics zeroed. Nothing
308
+ // here participates in capability routing — it is purely a topology row.
309
+ const offlineNodes = history
310
+ .filter((persisted) => !liveIds.has(persisted.id))
311
+ .map((persisted) => ({
312
+ id: persisted.id,
313
+ name: persisted.name,
314
+ hostname: persisted.hostname,
315
+ platform: persisted.platform,
316
+ arch: persisted.arch,
317
+ cpuModel: persisted.cpuModel,
318
+ cpuCores: persisted.cpuCores,
319
+ memoryMB: persisted.memoryMB,
320
+ engines: [...persisted.engines],
321
+ isHub: persisted.isHub,
322
+ isOnline: false,
323
+ cpuPercent: 0,
324
+ memoryPercent: 0,
325
+ uptime: 0,
326
+ lastSeen: new Date(persisted.lastActive).toISOString(),
327
+ localIps: [...persisted.localIps],
328
+ addons: persisted.addonIds.map((id) => ({
329
+ id,
330
+ capabilities: [...(addonCaps.get(id) ?? [])],
331
+ status: 'stopped',
332
+ })),
333
+ processes: [],
334
+ categories: [],
335
+ }));
336
+ return [...liveNodes, ...offlineNodes];
296
337
  }
297
338
  function buildNodesProvider(agentRegistry, moleculer, addonRegistry) {
298
339
  const broker = moleculer.broker;
@@ -0,0 +1,32 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.createClusterNodesRouter = createClusterNodesRouter;
4
+ /**
5
+ * Cluster-nodes router — fixed core API (not a capability).
6
+ *
7
+ * Exposes the durable offline-node history's write-side cleanup so the admin
8
+ * UI's inline "Forget node" action can purge a node the operator never expects
9
+ * to see again. Hand-written (not a `*.cap.ts`) on purpose: it needs no
10
+ * per-provider routing and adding a cap method would force a full codegen pass.
11
+ *
12
+ * The read side (`getClusterNodeHistory`) is consumed server-side by
13
+ * `computeTopology` and is intentionally NOT exposed here — the admin UI reads
14
+ * offline rows off the pushed `cluster.topology-snapshot`, never by polling.
15
+ */
16
+ const zod_1 = require("zod");
17
+ const trpc_middleware_js_1 = require("../trpc/trpc.middleware.js");
18
+ const ForgetNodeInputSchema = zod_1.z.object({ nodeId: zod_1.z.string().min(1) });
19
+ function createClusterNodesRouter(agentRegistry) {
20
+ return (0, trpc_middleware_js_1.trpcRouter)({
21
+ // Purge one node's persisted offline history. Pairs with
22
+ // `pipelineOrchestrator.removeAgentSettings` on the frontend to fully
23
+ // forget an offline node (history row + per-node pipeline assignments).
24
+ forgetNode: trpc_middleware_js_1.adminProcedure
25
+ .input(ForgetNodeInputSchema)
26
+ .output(zod_1.z.object({ success: zod_1.z.boolean() }))
27
+ .mutation(async ({ input }) => {
28
+ await agentRegistry.forgetClusterNode(input.nodeId);
29
+ return { success: true };
30
+ }),
31
+ });
32
+ }
@@ -4,16 +4,16 @@ exports.createHwAccelRouter = createHwAccelRouter;
4
4
  /**
5
5
  * Hwaccel router — fixed core API (not a capability).
6
6
  *
7
- * Thin wrapper around the per-node `$hwaccel.resolve` Moleculer
8
- * service. The hub-local instance (same process) serves the default
9
- * `resolve` call; the per-node `resolveForNode` action targets any
10
- * node in the cluster — used by the admin UI pipeline / NodeDetail
11
- * pages to show the hwaccel backends available on each agent.
7
+ * Repointed (S3 consolidation) onto the `platform-probe` capability — the SOLE
8
+ * cross-node-pinnable hwaccel surface instead of the retired `$hwaccel`
9
+ * Moleculer service. The hub-local instance is resolved via
10
+ * `capabilityRegistry.getSingleton('platform-probe')`; any other cluster node
11
+ * via `moleculer.createCapabilityProxy('platform-probe', nodeId)` (the same
12
+ * node-routing the codegen'd cap routers use). Used by the admin UI pipeline /
13
+ * NodeDetail pages to show which hardware backend each node will use.
12
14
  *
13
- * Cross-node behaviour: `resolveForNode({ nodeId })` forwards via
14
- * `broker.call('$hwaccel.resolve', params, { nodeID })`. Every node
15
- * (hub + forked worker + remote agent) registers `$hwaccel` at
16
- * bootstrap, so any reachable nodeId works.
15
+ * The tRPC input/output shapes are UNCHANGED for clients (`{prefer}` in,
16
+ * `{preferred, rationale}` out) no SDK break.
17
17
  */
18
18
  const zod_1 = require("zod");
19
19
  const trpc_middleware_js_1 = require("../trpc/trpc.middleware.js");
@@ -33,33 +33,69 @@ const HwAccelPreferSchema = zod_1.z
33
33
  .union([HwAccelBackendSchema, zod_1.z.literal('none')])
34
34
  .nullable()
35
35
  .optional();
36
+ // `preferred` is a plain string array (not the decode-backend enum) to match
37
+ // the `platform-probe` cap's own resolution schema: the resolver only ever
38
+ // emits decode backends, but a narrow enum would reject any future addition and
39
+ // force a cast at the cap boundary.
36
40
  const HwAccelResolutionSchema = zod_1.z.object({
37
- preferred: zod_1.z.array(HwAccelBackendSchema).readonly(),
41
+ preferred: zod_1.z.array(zod_1.z.string()).readonly(),
38
42
  rationale: zod_1.z.string(),
39
43
  });
40
- function createHwAccelRouter(broker) {
44
+ /**
45
+ * Resolve hwaccel on a specific cluster node through the `platform-probe` cap
46
+ * proxy. `createCapabilityProxy` returns `Record<string, (params) =>
47
+ * Promise<unknown>> | null`; rather than casting that to the provider type
48
+ * (the webrtc-session mount does — repo rule forbids it), we call the proxy's
49
+ * `resolveHwAccel` and narrow its result through the cap's Zod OUTPUT schema.
50
+ * A missing proxy (node unreachable), a missing method, a malformed response,
51
+ * or a thrown call all degrade to an EXPLAINED empty list rather than
52
+ * corrupting the typed API surface.
53
+ */
54
+ async function resolveHwAccelOnNode(moleculer, nodeId, prefer) {
55
+ const proxy = moleculer?.createCapabilityProxy('platform-probe', nodeId) ?? null;
56
+ const resolveFn = proxy?.['resolveHwAccel'];
57
+ if (!resolveFn) {
58
+ return { preferred: [], rationale: `node ${nodeId} unreachable` };
59
+ }
60
+ try {
61
+ const raw = await resolveFn({ prefer });
62
+ const parsed = HwAccelResolutionSchema.safeParse(raw);
63
+ if (!parsed.success) {
64
+ return { preferred: [], rationale: `invalid probe response from ${nodeId}` };
65
+ }
66
+ return parsed.data;
67
+ }
68
+ catch (err) {
69
+ const msg = err instanceof Error ? err.message : String(err);
70
+ return { preferred: [], rationale: `resolve failed: ${msg}` };
71
+ }
72
+ }
73
+ function listAvailableNodeIds(moleculer) {
74
+ const broker = moleculer?.broker ?? null;
75
+ if (!broker)
76
+ return [];
77
+ const withRegistry = broker;
78
+ return withRegistry.registry.getNodeList({ onlyAvailable: true }).map((n) => n.id);
79
+ }
80
+ function createHwAccelRouter(capabilityRegistry, moleculer) {
41
81
  return (0, trpc_middleware_js_1.trpcRouter)({
42
- /** Probe the current hub process. */
82
+ /** Probe the current hub process via the local `platform-probe` singleton. */
43
83
  resolve: trpc_middleware_js_1.adminProcedure
44
84
  .input(zod_1.z.object({ prefer: HwAccelPreferSchema }).optional())
45
85
  .output(HwAccelResolutionSchema)
46
86
  .query(async ({ input }) => {
47
- if (!broker)
48
- throw new Error('Moleculer broker not available');
49
- const params = { prefer: input?.prefer ?? null };
50
- return broker.call('$hwaccel.resolve', params);
87
+ const provider = capabilityRegistry?.getSingleton('platform-probe') ?? null;
88
+ if (!provider) {
89
+ return { preferred: [], rationale: 'platform-probe capability not available' };
90
+ }
91
+ return provider.resolveHwAccel({ prefer: input?.prefer ?? null });
51
92
  }),
52
93
  /** Probe a specific node in the cluster — used by per-agent UI cards. */
53
94
  resolveForNode: trpc_middleware_js_1.adminProcedure
54
95
  .input(zod_1.z.object({ nodeId: zod_1.z.string(), prefer: HwAccelPreferSchema }))
55
96
  .output(HwAccelResolutionSchema)
56
97
  .query(async ({ input }) => {
57
- if (!broker)
58
- throw new Error('Moleculer broker not available');
59
- const params = { prefer: input.prefer ?? null };
60
- return broker.call('$hwaccel.resolve', params, {
61
- nodeID: input.nodeId,
62
- });
98
+ return resolveHwAccelOnNode(moleculer, input.nodeId, input.prefer ?? null);
63
99
  }),
64
100
  /** List every node currently reachable and the hwaccel each resolves to. */
65
101
  resolveAll: trpc_middleware_js_1.adminProcedure
@@ -68,24 +104,11 @@ function createHwAccelRouter(broker) {
68
104
  resolution: HwAccelResolutionSchema,
69
105
  })))
70
106
  .query(async () => {
71
- if (!broker)
72
- throw new Error('Moleculer broker not available');
73
- const registry = broker.registry;
74
- const nodes = registry.getNodeList({ onlyAvailable: true });
75
- const results = await Promise.all(nodes.map(async (n) => {
76
- try {
77
- const resolution = (await broker.call('$hwaccel.resolve', { prefer: null }, { nodeID: n.id }));
78
- return { nodeId: n.id, resolution };
79
- }
80
- catch (err) {
81
- const msg = err instanceof Error ? err.message : String(err);
82
- return {
83
- nodeId: n.id,
84
- resolution: { preferred: [], rationale: `resolve failed: ${msg}` },
85
- };
86
- }
87
- }));
88
- return results;
107
+ const nodeIds = listAvailableNodeIds(moleculer);
108
+ return Promise.all(nodeIds.map(async (nodeId) => ({
109
+ nodeId,
110
+ resolution: await resolveHwAccelOnNode(moleculer, nodeId, null),
111
+ })));
89
112
  }),
90
113
  });
91
114
  }
@@ -2,7 +2,7 @@
2
2
  // AUTO-GENERATED by scripts/generate-cap-mounts.ts — DO NOT EDIT
3
3
  // Re-run: npx tsx scripts/generate-cap-mounts.ts
4
4
  //
5
- // Mounted: 130 Skipped (legacy): 6
5
+ // Mounted: 131 Skipped (legacy): 6
6
6
  Object.defineProperty(exports, "__esModule", { value: true });
7
7
  exports.LEGACY_SHAPE_SKIP = void 0;
8
8
  exports.mountAllCaps = mountAllCaps;
@@ -294,6 +294,7 @@ function mountAllCaps(services) {
294
294
  return entries[0]?.[1] ?? null;
295
295
  }, remoteCapProxy),
296
296
  osd: (0, generated_cap_routers_js_1.createCapRouter_osd)((_ctx) => (0, cap_mount_helpers_js_1.requireDeviceScoped)(reg, 'osd'), remoteCapProxy),
297
+ petFeeder: (0, generated_cap_routers_js_1.createCapRouter_petFeeder)((_ctx) => (0, cap_mount_helpers_js_1.requireDeviceScoped)(reg, 'pet-feeder'), remoteCapProxy),
297
298
  pipelineAnalytics: (0, generated_cap_routers_js_1.createCapRouter_pipelineAnalytics)((_ctx) => reg?.getSingleton('pipeline-analytics') ?? null, remoteCapProxy),
298
299
  pipelineExecutor: (0, generated_cap_routers_js_1.createCapRouter_pipelineExecutor)((_ctx) => reg?.getSingleton('pipeline-executor') ??
299
300
  null, remoteCapProxy),