@camstack/server 1.1.27 → 1.1.29

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
+ }
@@ -5668,34 +5668,6 @@ function createCapRouter_platformProbe(getProvider, createRemoteProxy) {
5668
5668
  // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-argument
5669
5669
  return p.resolveHwAccel(methodInput);
5670
5670
  }),
5671
- getHardwareEncoders: trpc_middleware_js_1.protectedProcedure
5672
- .input(zod_1.z.object({ nodeId: zod_1.z.string().optional() }).optional())
5673
- .output(types_78.platformProbeCapability.methods.getHardwareEncoders.output)
5674
- .query(async ({ input, ctx }) => {
5675
- const p = resolveProvider('platform-probe', input?.nodeId, () => getProvider(ctx), createRemoteProxy);
5676
- return p.getHardwareEncoders();
5677
- }),
5678
- refreshHardwareEncoders: trpc_middleware_js_1.adminProcedure
5679
- .input(zod_1.z.object({ nodeId: zod_1.z.string().optional() }).optional())
5680
- .output(types_78.platformProbeCapability.methods.refreshHardwareEncoders.output)
5681
- .mutation(async ({ input, ctx }) => {
5682
- const p = resolveProvider('platform-probe', input?.nodeId, () => getProvider(ctx), createRemoteProxy);
5683
- return p.refreshHardwareEncoders();
5684
- }),
5685
- getHardwareDecodeAccels: trpc_middleware_js_1.protectedProcedure
5686
- .input(zod_1.z.object({ nodeId: zod_1.z.string().optional() }).optional())
5687
- .output(types_78.platformProbeCapability.methods.getHardwareDecodeAccels.output)
5688
- .query(async ({ input, ctx }) => {
5689
- const p = resolveProvider('platform-probe', input?.nodeId, () => getProvider(ctx), createRemoteProxy);
5690
- return p.getHardwareDecodeAccels();
5691
- }),
5692
- refreshHardwareDecodeAccels: trpc_middleware_js_1.adminProcedure
5693
- .input(zod_1.z.object({ nodeId: zod_1.z.string().optional() }).optional())
5694
- .output(types_78.platformProbeCapability.methods.refreshHardwareDecodeAccels.output)
5695
- .mutation(async ({ input, ctx }) => {
5696
- const p = resolveProvider('platform-probe', input?.nodeId, () => getProvider(ctx), createRemoteProxy);
5697
- return p.refreshHardwareDecodeAccels();
5698
- }),
5699
5671
  });
5700
5672
  }
5701
5673
  function createCapRouter_powerMeter(getProvider, createRemoteProxy) {
@@ -12,6 +12,7 @@ const hwaccel_router_js_1 = require("../core/hwaccel.router.js");
12
12
  const live_events_router_js_1 = require("../core/live-events.router.js");
13
13
  const logs_router_js_1 = require("../core/logs.router.js");
14
14
  const notifications_router_js_1 = require("../core/notifications.router.js");
15
+ const cluster_nodes_router_js_1 = require("../core/cluster-nodes.router.js");
15
16
  const repl_router_js_1 = require("../core/repl.router.js");
16
17
  const settings_backend_router_js_1 = require("../core/settings-backend.router.js");
17
18
  const stream_probe_router_js_1 = require("../core/stream-probe.router.js");
@@ -148,6 +149,9 @@ function buildCapabilityRouters(services) {
148
149
  // NodeDetail pages query per-node to show which hardware backend each
149
150
  // agent will use.
150
151
  hwaccel: (0, hwaccel_router_js_1.createHwAccelRouter)(services.capabilityRegistry, services.moleculer),
152
+ // clusterNodes — fixed core API. Write-side purge for the durable
153
+ // offline-node history (Track A "Forget node"); read side is push-only.
154
+ clusterNodes: (0, cluster_nodes_router_js_1.createClusterNodesRouter)(services.agentRegistry),
151
155
  auth: (0, auth_router_js_1.createAuthRouter)(services.authService, services.capabilityRegistry),
152
156
  // ── Cap overrides: `mount: { kind: 'custom' }` ──────────────────
153
157
  // `snapshot-provider.supportsDevice` is an OR across providers;
@@ -35,6 +35,7 @@ var __importStar = (this && this.__importStar) || (function () {
35
35
  Object.defineProperty(exports, "__esModule", { value: true });
36
36
  exports.AgentRegistryService = void 0;
37
37
  exports.toNodeLiveness = toNodeLiveness;
38
+ exports.toDescriptor = toDescriptor;
38
39
  const node_crypto_1 = require("node:crypto");
39
40
  const os = __importStar(require("node:os"));
40
41
  const system_1 = require("@camstack/system");
@@ -81,6 +82,28 @@ function toNodeLiveness(nodes) {
81
82
  .filter((node) => !node.id.includes('/'))
82
83
  .map((node) => ({ id: node.id, isHub: node.id === 'hub', isOnline: node.available }));
83
84
  }
85
+ /**
86
+ * Project a live `AgentListItem` into the durable history descriptor. Field
87
+ * derivations mirror `computeTopology` exactly (engines from `pythonRuntimes`,
88
+ * cpuModel `undefined`→`null`, addonIds from the reported `agentAddons`) so an
89
+ * offline row rendered from history is indistinguishable from the live shape.
90
+ */
91
+ function toDescriptor(entry) {
92
+ return {
93
+ id: entry.info.id,
94
+ name: entry.info.name,
95
+ hostname: entry.info.hostname ?? entry.info.id,
96
+ platform: entry.info.platform ?? 'unknown',
97
+ arch: entry.info.arch ?? 'unknown',
98
+ cpuModel: entry.info.cpuModel ?? null,
99
+ cpuCores: entry.info.cpuCores ?? 0,
100
+ memoryMB: entry.info.memoryMB ?? 0,
101
+ engines: [...(entry.info.pythonRuntimes ?? [])],
102
+ isHub: entry.isHub,
103
+ localIps: [...(entry.localIps ?? [])],
104
+ addonIds: [...(entry.agentAddons ?? [])],
105
+ };
106
+ }
84
107
  class AgentRegistryService {
85
108
  eventBus;
86
109
  moleculer;
@@ -96,6 +119,13 @@ class AgentRegistryService {
96
119
  * order (see `manual-boot.ts`).
97
120
  */
98
121
  addonRegistry = null;
122
+ /**
123
+ * Durable, routing-blind offline-node history (A1). Separate from
124
+ * `HubNodeRegistry` on purpose — see `cluster-node-history-store.ts`. Wired
125
+ * post-construction from manual-boot so the 3-arg constructor (and its unit
126
+ * fakes) stay untouched; a null store means "history disabled" (best-effort).
127
+ */
128
+ historyStore = null;
99
129
  constructor(eventBus, moleculer, capabilityService) {
100
130
  this.eventBus = eventBus;
101
131
  this.moleculer = moleculer;
@@ -105,6 +135,22 @@ class AgentRegistryService {
105
135
  setAddonRegistry(addonRegistry) {
106
136
  this.addonRegistry = addonRegistry;
107
137
  }
138
+ /** Wire the durable offline-node history store (A1). */
139
+ setClusterNodeHistoryStore(store) {
140
+ this.historyStore = store;
141
+ }
142
+ /**
143
+ * Read the durable offline-node history — consumed by `computeTopology`
144
+ * to render OFFLINE rows for nodes no longer in the live Moleculer window.
145
+ * Returns `[]` when no history store is wired (unit fakes).
146
+ */
147
+ async getClusterNodeHistory() {
148
+ return this.historyStore ? this.historyStore.getAll() : [];
149
+ }
150
+ /** Permanently forget a node's persisted history (inline "Forget node"). */
151
+ async forgetClusterNode(nodeId) {
152
+ await this.historyStore?.forget(nodeId);
153
+ }
108
154
  /** Typed view of the Moleculer broker — single documented cast. */
109
155
  get broker() {
110
156
  return this.moleculer.broker;
@@ -154,6 +200,10 @@ class AgentRegistryService {
154
200
  return;
155
201
  if (kind === 'agent') {
156
202
  console.log(`[agent-registry] Agent disconnected: ${node.id}`);
203
+ // A1: stamp the disconnect time as the node's `lastActive` so its
204
+ // offline row reports an honest "last seen". Best-effort, no-op if the
205
+ // node was never snapshotted while online.
206
+ void this.historyStore?.touch(node.id);
157
207
  this.eventBus.emit({
158
208
  id: (0, node_crypto_1.randomUUID)(),
159
209
  timestamp: new Date(),
@@ -501,15 +551,29 @@ class AgentRegistryService {
501
551
  // Skip nodes without $agent service
502
552
  }
503
553
  }
504
- // Offline agents ARE included above (see the `!node.available` branch)
505
- // as long as Moleculer's own registry still remembers them. That window
506
- // is transient it does not survive a hub restart and disappears once
507
- // `cleanOfflineNodesTimeout` purges the entry. A durable, restart-proof
508
- // offline-node history is a separate, deliberately deferred piece of
509
- // work (would need its own routing-blind store never a `knownAgents`-
510
- // style addition to `HubNodeRegistry`).
554
+ // A1 (R2 capture point): snapshot every ONLINE node's static descriptor +
555
+ // last-known addon roster into the durable, routing-blind history store on
556
+ // each refresh. This is the ONLY place cpuModel/cores/memory/localIps are
557
+ // resolved from live `$agent.status`, so it is where an offline row's
558
+ // descriptor must be captured while the node is still reachable. Offline
559
+ // entries (the `!node.available` branch above) are NOT re-snapshotted
560
+ // their `lastActive` is stamped on `$node.disconnected` instead. This is a
561
+ // separate durable store, never a `knownAgents`-style shadow of
562
+ // `HubNodeRegistry` (which stays the ephemeral live cap authority).
563
+ await this.snapshotOnlineNodes([hubEntry, ...remoteEntries]);
511
564
  return [hubEntry, ...remoteEntries];
512
565
  }
566
+ /**
567
+ * Best-effort upsert of every online entry's descriptor into the history
568
+ * store. Never throws — the store swallows its own errors and this awaits
569
+ * `allSettled` so a storage hiccup can never fail `listNodes()`.
570
+ */
571
+ async snapshotOnlineNodes(entries) {
572
+ if (!this.historyStore)
573
+ return;
574
+ const online = entries.filter((e) => e.isOnline !== false);
575
+ await Promise.allSettled(online.map((e) => this.historyStore.snapshot(toDescriptor(e))));
576
+ }
513
577
  /**
514
578
  * Minimal, honestly-degraded entry for a node Moleculer's registry still
515
579
  * remembers but marks `available: false`. No RPC round-trip is attempted
@@ -0,0 +1,152 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.ClusterNodeHistoryStore = void 0;
4
+ const zod_1 = require("zod");
5
+ const types_1 = require("@camstack/types");
6
+ const DescriptorSchema = zod_1.z.object({
7
+ id: zod_1.z.string(),
8
+ name: zod_1.z.string(),
9
+ hostname: zod_1.z.string(),
10
+ platform: zod_1.z.string(),
11
+ arch: zod_1.z.string(),
12
+ cpuModel: zod_1.z.string().nullable(),
13
+ cpuCores: zod_1.z.number(),
14
+ memoryMB: zod_1.z.number(),
15
+ engines: zod_1.z.array(zod_1.z.string()).readonly(),
16
+ isHub: zod_1.z.boolean(),
17
+ localIps: zod_1.z.array(zod_1.z.string()).readonly(),
18
+ addonIds: zod_1.z.array(zod_1.z.string()).readonly(),
19
+ });
20
+ /** Shape of a `settings-store.query` record's `data` for this collection. */
21
+ const RowDataSchema = zod_1.z.object({
22
+ lastActive: zod_1.z.number(),
23
+ descriptor: DescriptorSchema,
24
+ });
25
+ const COLLECTION = 'cluster-node-history';
26
+ class ClusterNodeHistoryStore {
27
+ resolveSettingsStore;
28
+ logger;
29
+ declared = false;
30
+ constructor(resolveSettingsStore, logger) {
31
+ this.resolveSettingsStore = resolveSettingsStore;
32
+ this.logger = logger;
33
+ }
34
+ /**
35
+ * Idempotently declare the typed collection. Returns the resolved provider
36
+ * or null if the settings-store isn't available yet (early boot / hub
37
+ * unreachable) — callers treat null as "skip, best-effort".
38
+ */
39
+ async ensureDeclared() {
40
+ const store = this.resolveSettingsStore();
41
+ if (!store)
42
+ return null;
43
+ if (this.declared)
44
+ return store;
45
+ try {
46
+ await store.declareCollection({
47
+ collection: COLLECTION,
48
+ columns: [
49
+ { name: 'id', type: 'TEXT', primaryKey: true, notNull: true },
50
+ { name: 'lastActive', type: 'INTEGER', notNull: true },
51
+ { name: 'descriptor', type: 'JSON', notNull: true },
52
+ ],
53
+ });
54
+ this.declared = true;
55
+ return store;
56
+ }
57
+ catch (err) {
58
+ this.logger.warn('declareCollection failed (best-effort)', {
59
+ meta: { error: (0, types_1.errMsg)(err) },
60
+ });
61
+ return null;
62
+ }
63
+ }
64
+ /**
65
+ * Upsert a node's descriptor + addon roster while it is ONLINE, stamping
66
+ * `lastActive = Date.now()`. Called on each `listNodes()` refresh for every
67
+ * live node (R2 capture point).
68
+ */
69
+ async snapshot(descriptor) {
70
+ const store = await this.ensureDeclared();
71
+ if (!store)
72
+ return;
73
+ try {
74
+ await store.set({
75
+ collection: COLLECTION,
76
+ key: descriptor.id,
77
+ value: { lastActive: Date.now(), descriptor },
78
+ });
79
+ }
80
+ catch (err) {
81
+ this.logger.warn('snapshot upsert failed (best-effort)', {
82
+ tags: { nodeId: descriptor.id },
83
+ meta: { error: (0, types_1.errMsg)(err) },
84
+ });
85
+ }
86
+ }
87
+ /**
88
+ * Touch `lastActive` for a node without rewriting its descriptor — used on
89
+ * `$node.disconnected` so an offline node's "last seen" is the disconnect
90
+ * time. A no-op if the node was never snapshotted (nothing to update).
91
+ */
92
+ async touch(nodeId) {
93
+ const store = await this.ensureDeclared();
94
+ if (!store)
95
+ return;
96
+ try {
97
+ await store.update({
98
+ collection: COLLECTION,
99
+ id: nodeId,
100
+ data: { lastActive: Date.now() },
101
+ });
102
+ }
103
+ catch (err) {
104
+ this.logger.warn('touch failed (best-effort)', {
105
+ tags: { nodeId },
106
+ meta: { error: (0, types_1.errMsg)(err) },
107
+ });
108
+ }
109
+ }
110
+ /** Read the full persisted history, validated. Malformed rows are dropped. */
111
+ async getAll() {
112
+ const store = await this.ensureDeclared();
113
+ if (!store)
114
+ return [];
115
+ try {
116
+ const rows = await store.query({ collection: COLLECTION });
117
+ const result = [];
118
+ for (const row of rows) {
119
+ const parsed = RowDataSchema.safeParse(row.data);
120
+ if (!parsed.success) {
121
+ this.logger.warn('dropping malformed cluster-node-history row', {
122
+ tags: { nodeId: row.id },
123
+ });
124
+ continue;
125
+ }
126
+ result.push({ ...parsed.data.descriptor, lastActive: parsed.data.lastActive });
127
+ }
128
+ return result;
129
+ }
130
+ catch (err) {
131
+ this.logger.warn('getAll failed (best-effort)', { meta: { error: (0, types_1.errMsg)(err) } });
132
+ return [];
133
+ }
134
+ }
135
+ /** Permanently forget a node's history row (the inline "Forget node" action). */
136
+ async forget(nodeId) {
137
+ const store = await this.ensureDeclared();
138
+ if (!store)
139
+ return;
140
+ try {
141
+ await store.delete({ collection: COLLECTION, key: nodeId });
142
+ this.logger.info('forgot cluster node', { tags: { nodeId } });
143
+ }
144
+ catch (err) {
145
+ this.logger.warn('forget failed (best-effort)', {
146
+ tags: { nodeId },
147
+ meta: { error: (0, types_1.errMsg)(err) },
148
+ });
149
+ }
150
+ }
151
+ }
152
+ exports.ClusterNodeHistoryStore = ClusterNodeHistoryStore;
@@ -67,6 +67,7 @@ const addon_widgets_service_1 = require("./core/addon-widgets/addon-widgets.serv
67
67
  const addon_bridge_service_1 = require("./core/addon-bridge/addon-bridge.service");
68
68
  const moleculer_service_1 = require("./core/moleculer/moleculer.service");
69
69
  const agent_registry_service_1 = require("./core/agent/agent-registry.service");
70
+ const cluster_node_history_store_1 = require("./core/agent/cluster-node-history-store");
70
71
  const addon_registry_service_1 = require("./core/addon/addon-registry.service");
71
72
  const addon_search_service_1 = require("./core/addon/addon-search.service");
72
73
  const addon_package_service_1 = require("./core/addon/addon-package.service");
@@ -128,6 +129,11 @@ async function bootManual(opts) {
128
129
  // reconciliation, but is constructed first (it has no other dependency
129
130
  // on AddonRegistryService) — wire it post-construction.
130
131
  agentRegistryService.setAddonRegistry(addonRegistryService);
132
+ // A1: durable, routing-blind offline-node history — persisted through the
133
+ // hub-resident `settings-store` cap (resolved lazily; null until it boots).
134
+ // Wired post-construction so the service's 3-arg constructor stays intact.
135
+ agentRegistryService.setClusterNodeHistoryStore(new cluster_node_history_store_1.ClusterNodeHistoryStore(() => capabilityService.getRegistry()?.getSingleton('settings-store') ??
136
+ null, loggingService.createLogger('cluster-node-history')));
131
137
  // ---- Layer 4: need AddonRegistry ---------------------------------------
132
138
  // AddonWidgetsService — needs AddonRegistryService for the bundled-addon
133
139
  // dist sub-folder lookup (see service docstring).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@camstack/server",
3
- "version": "1.1.27",
3
+ "version": "1.1.29",
4
4
  "private": false,
5
5
  "files": [
6
6
  "dist",