@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.
- package/dist/api/core/cap-providers.js +43 -2
- package/dist/api/core/cluster-nodes.router.js +32 -0
- package/dist/api/trpc/generated-cap-routers.js +0 -28
- package/dist/api/trpc/trpc.router.js +4 -0
- package/dist/core/agent/agent-registry.service.js +71 -7
- package/dist/core/agent/cluster-node-history-store.js +152 -0
- package/dist/manual-boot.js +6 -0
- package/package.json +1 -1
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
505
|
-
//
|
|
506
|
-
//
|
|
507
|
-
//
|
|
508
|
-
//
|
|
509
|
-
//
|
|
510
|
-
//
|
|
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;
|
package/dist/manual-boot.js
CHANGED
|
@@ -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).
|