@camstack/server 1.2.543 → 1.2.545
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/agent/agent-hub-registration.js +108 -0
- package/dist/agent/agent-mesh-join.js +18 -1
- package/dist/agent/agent-service.js +14 -0
- package/dist/agent/main.js +45 -56
- package/dist/api/core/cap-providers.js +20 -6
- package/dist/api/trpc/generated-cap-routers.js +23 -0
- package/dist/core/moleculer/agent-registration-request.js +57 -0
- package/dist/core/moleculer/moleculer.service.js +19 -0
- package/dist/core/topology/topology-emitter.service.js +6 -2
- package/dist/manual-boot.js +1 -1
- package/package.json +9 -9
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.createAgentHubRegistration = createAgentHubRegistration;
|
|
4
|
+
/**
|
|
5
|
+
* The agent's upward capability registration (D3), bound to the mesh join.
|
|
6
|
+
*
|
|
7
|
+
* The hub forgets an agent — its join AND its registration — on every
|
|
8
|
+
* `$node.disconnected` the HUB sees. That event is not symmetric: Moleculer
|
|
9
|
+
* marks a node offline on gossip from a third node, on a failed write, on a
|
|
10
|
+
* heartbeat timeout, and the agent, whose own view of the hub never broke,
|
|
11
|
+
* sees none of it. On 2026-10-14 (the D848 rollout) the hub flapped
|
|
12
|
+
* little-unraid and the Mac offline/online every 2–6 s for ~35 min while older
|
|
13
|
+
* agents still gossiped about them; each flap wiped the agent's capabilities
|
|
14
|
+
* on the hub, the agent re-joined (its next call came back "not
|
|
15
|
+
* authenticated") but never re-registered, and both stayed online with ZERO
|
|
16
|
+
* capabilities until a manual restart.
|
|
17
|
+
*
|
|
18
|
+
* So the registration follows the JOIN, not the agent's own `$node.connected`:
|
|
19
|
+
* - every join that settles in a state that lets calls through re-sends the
|
|
20
|
+
* complete manifest — a join runs exactly when the hub's view of this agent
|
|
21
|
+
* was reset (a new connection, or the hub said it lost us);
|
|
22
|
+
* - a registration the hub refuses as "cluster secret mismatch" while THIS
|
|
23
|
+
* connection's join proved the secret is not a mismatch: the hub forgot the
|
|
24
|
+
* join. The agent joins again, and that join re-registers;
|
|
25
|
+
* - the hub may ask for it (`$agent.requestRegistration`, sent on the hub's
|
|
26
|
+
* own `$node.connected`), which closes the window in which the agent makes
|
|
27
|
+
* no call that could tell it the hub forgot.
|
|
28
|
+
*
|
|
29
|
+
* Each send is ONE atomic `$hub.registerNode` carrying the full set; sends are
|
|
30
|
+
* coalesced (one in flight, at most one queued, the manifest built when it
|
|
31
|
+
* goes). Nothing waits on a timer: the triggers are events, the retry is the ack.
|
|
32
|
+
*/
|
|
33
|
+
const system_1 = require("@camstack/system");
|
|
34
|
+
/** States in which a call to the hub is refused locally — nothing to send. */
|
|
35
|
+
const BLOCKING = new Set(['secret-mismatch', 'hub-unverified']);
|
|
36
|
+
function createAgentHubRegistration(opts) {
|
|
37
|
+
let waiting = false;
|
|
38
|
+
let sending = false;
|
|
39
|
+
let queuedReason = null;
|
|
40
|
+
const onRefused = (err) => {
|
|
41
|
+
if (!(0, system_1.isClusterSecretMismatchError)(err))
|
|
42
|
+
return; // aborted: shutting down
|
|
43
|
+
if (opts.join.client.state === 'authenticated') {
|
|
44
|
+
// This connection's join PROVED the secret, so the hub did not refuse
|
|
45
|
+
// the secret — it forgot the join (it saw this node disconnect). Join
|
|
46
|
+
// again; the settled join re-registers.
|
|
47
|
+
opts.log('[register-node] the hub refused the registration although this connection proved the cluster secret — it has forgotten the join; joining again');
|
|
48
|
+
opts.join.rejoin();
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
opts.onSecretRefused?.(err);
|
|
52
|
+
};
|
|
53
|
+
const send = (reason) => {
|
|
54
|
+
if (sending) {
|
|
55
|
+
queuedReason = reason;
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
sending = true;
|
|
59
|
+
const manifest = opts.buildManifest();
|
|
60
|
+
const bindings = manifest.addons.reduce((n, a) => n + a.capabilities.length, 0);
|
|
61
|
+
// Each attempt resolves the CURRENT broker: a retry that outlives a
|
|
62
|
+
// `reconnect()` must not keep calling the stopped one.
|
|
63
|
+
const broker = {
|
|
64
|
+
call: (action, params, callOpts) => opts.broker().call(action, params, callOpts),
|
|
65
|
+
};
|
|
66
|
+
(0, system_1.callRegisterNodeWithRetry)(broker, manifest, {
|
|
67
|
+
target: 'hub',
|
|
68
|
+
...(opts.signal !== undefined ? { signal: opts.signal } : {}),
|
|
69
|
+
log: opts.log,
|
|
70
|
+
})
|
|
71
|
+
.then(() => {
|
|
72
|
+
opts.log(`[register-node] the hub acked this node's capabilities: ${manifest.addons.length} addon(s), ${bindings} binding(s) (${reason})`);
|
|
73
|
+
opts.onAcked?.();
|
|
74
|
+
})
|
|
75
|
+
.catch(onRefused)
|
|
76
|
+
.finally(() => {
|
|
77
|
+
sending = false;
|
|
78
|
+
const next = queuedReason;
|
|
79
|
+
queuedReason = null;
|
|
80
|
+
if (next !== null)
|
|
81
|
+
trigger(next);
|
|
82
|
+
});
|
|
83
|
+
};
|
|
84
|
+
const trigger = (reason) => {
|
|
85
|
+
// A send already waiting for the join builds its manifest when it goes.
|
|
86
|
+
if (waiting)
|
|
87
|
+
return;
|
|
88
|
+
waiting = true;
|
|
89
|
+
opts.join.client.whenCallable().then(() => {
|
|
90
|
+
waiting = false;
|
|
91
|
+
send(reason);
|
|
92
|
+
}, () => {
|
|
93
|
+
// Blocked (mismatch / unverified hub) or the join is still pending:
|
|
94
|
+
// the next settled join sends it (`onJoinSettled`).
|
|
95
|
+
waiting = false;
|
|
96
|
+
});
|
|
97
|
+
};
|
|
98
|
+
return {
|
|
99
|
+
trigger,
|
|
100
|
+
onJoinSettled(state) {
|
|
101
|
+
if (BLOCKING.has(state))
|
|
102
|
+
return;
|
|
103
|
+
// Every settled join means the hub's view of this node was reset (a new
|
|
104
|
+
// connection, or the hub said it lost us): the full set goes again.
|
|
105
|
+
trigger(`join settled: ${state}`);
|
|
106
|
+
},
|
|
107
|
+
};
|
|
108
|
+
}
|
|
@@ -49,6 +49,14 @@ function createAgentMeshJoin(opts) {
|
|
|
49
49
|
opts.log?.(`mesh inbound: node "${nodeId}" is calling without having authenticated — ` +
|
|
50
50
|
`tolerated while this agent has no cluster secret (first call: ${action})`);
|
|
51
51
|
},
|
|
52
|
+
// D848: what the hub sent before the join settled is held, then decided — said here.
|
|
53
|
+
onHoldReport: (r) => {
|
|
54
|
+
const what = `${r.events} hub event(s) and ${r.calls} hub call(s) held ${r.heldForMs} ms before the mesh join settled`;
|
|
55
|
+
const overflow = r.overflow > 0 ? `; ${r.overflow} more refused because the hold was full` : '';
|
|
56
|
+
opts.log?.(r.outcome === 'delivered'
|
|
57
|
+
? `[mesh-join] ${what}: delivered${overflow}`
|
|
58
|
+
: `[mesh-join] ${what}: REFUSED (join ${r.reason})${overflow}`);
|
|
59
|
+
},
|
|
52
60
|
});
|
|
53
61
|
const client = new system_1.MeshJoinClient({
|
|
54
62
|
nodeId: opts.nodeId,
|
|
@@ -77,6 +85,10 @@ function createAgentMeshJoin(opts) {
|
|
|
77
85
|
void runJoin();
|
|
78
86
|
};
|
|
79
87
|
const joinMiddleware = client.middleware(rejoin);
|
|
88
|
+
const eventScope = (0, system_1.createAgentEventScope)({
|
|
89
|
+
hubNodeId: HUB_NODE_ID,
|
|
90
|
+
...(opts.log !== undefined ? { log: opts.log } : {}),
|
|
91
|
+
});
|
|
80
92
|
const inboundMiddleware = (0, system_1.createMeshAccessMiddleware)({
|
|
81
93
|
ownNodeId: opts.nodeId,
|
|
82
94
|
authenticator,
|
|
@@ -95,7 +107,12 @@ function createAgentMeshJoin(opts) {
|
|
|
95
107
|
client,
|
|
96
108
|
authenticator,
|
|
97
109
|
middleware: joinMiddleware,
|
|
98
|
-
|
|
110
|
+
eventScope,
|
|
111
|
+
middlewares: [inboundMiddleware, joinMiddleware, eventScope.middleware],
|
|
112
|
+
rejoin() {
|
|
113
|
+
if (connected)
|
|
114
|
+
rejoin();
|
|
115
|
+
},
|
|
99
116
|
attach(broker) {
|
|
100
117
|
broker.localBus.on('$node.connected', (data) => {
|
|
101
118
|
if (nodeIdOf(data) !== HUB_NODE_ID)
|
|
@@ -584,6 +584,20 @@ function createAgentService(deps) {
|
|
|
584
584
|
return { success: true, addonId, pid: result.pid };
|
|
585
585
|
},
|
|
586
586
|
},
|
|
587
|
+
/**
|
|
588
|
+
* The hub asks for this node's complete capability set (sent on the
|
|
589
|
+
* hub's `$node.connected`). Answers at once; the registration itself
|
|
590
|
+
* goes out through the atomic `$hub.registerNode` (D3), after a fresh
|
|
591
|
+
* mesh join when the hub has forgotten this node's.
|
|
592
|
+
*/
|
|
593
|
+
requestRegistration: {
|
|
594
|
+
handler() {
|
|
595
|
+
if (deps.requestRegistration === undefined)
|
|
596
|
+
return { ok: false };
|
|
597
|
+
deps.requestRegistration();
|
|
598
|
+
return { ok: true };
|
|
599
|
+
},
|
|
600
|
+
},
|
|
587
601
|
/**
|
|
588
602
|
* D3 subtree aggregation: a forked group-runner child calls this action
|
|
589
603
|
* to deliver its complete capability manifest to the agent. The agent
|
package/dist/agent/main.js
CHANGED
|
@@ -44,6 +44,7 @@ const package_dir_utils_js_1 = require("../core/addon/package-dir-utils.js");
|
|
|
44
44
|
const single_copy_cleanup_runner_js_1 = require("../single-copy-cleanup-runner.js");
|
|
45
45
|
const addon_scan_js_1 = require("./addon-scan.js");
|
|
46
46
|
const agent_addon_updates_js_1 = require("./agent-addon-updates.js");
|
|
47
|
+
const agent_hub_registration_js_1 = require("./agent-hub-registration.js");
|
|
47
48
|
const agent_mesh_join_js_1 = require("./agent-mesh-join.js");
|
|
48
49
|
const agent_config_js_1 = require("./agent-config.js");
|
|
49
50
|
const agent_mesh_enroll_js_1 = require("./agent-mesh-enroll.js");
|
|
@@ -177,8 +178,6 @@ async function startAgent(configPath) {
|
|
|
177
178
|
// effect through `reconnect()` below, which re-reads the file.
|
|
178
179
|
let currentSecret = config.secret ?? null;
|
|
179
180
|
let joinState = 'pending';
|
|
180
|
-
/** A registration waited for a join that had not settled; the join re-fires it. */
|
|
181
|
-
let registrationDeferred = false;
|
|
182
181
|
const meshJoin = (0, agent_mesh_join_js_1.createAgentMeshJoin)({
|
|
183
182
|
nodeId: config.nodeId,
|
|
184
183
|
secret: () => currentSecret,
|
|
@@ -195,9 +194,9 @@ async function startAgent(configPath) {
|
|
|
195
194
|
else if (hubConnectionStateOverride === 'secret-mismatch') {
|
|
196
195
|
hubConnectionStateOverride = null;
|
|
197
196
|
}
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
197
|
+
// Every settled join re-sends the full manifest (D3, 2026-10-14): a join
|
|
198
|
+
// runs exactly when the hub's view of this node was reset.
|
|
199
|
+
registration.onJoinSettled(state);
|
|
201
200
|
if (state === 'no-secret') {
|
|
202
201
|
consoleLogger.warn("no cluster secret configured — the hub tolerates this agent only while its System → Cluster is in Transition. Copy the hub's cluster secret into this agent's setup");
|
|
203
202
|
}
|
|
@@ -255,9 +254,8 @@ async function startAgent(configPath) {
|
|
|
255
254
|
...(currentMeshTls !== null ? { meshTls: currentMeshTls } : {}),
|
|
256
255
|
});
|
|
257
256
|
meshJoin.attach(broker);
|
|
258
|
-
// The
|
|
259
|
-
// the
|
|
260
|
-
registrationDeferred = true;
|
|
257
|
+
// The new broker's hub connection runs a join, and its settle re-sends
|
|
258
|
+
// the manifest (`registration.onJoinSettled`).
|
|
261
259
|
await broker.start();
|
|
262
260
|
console.log('[Agent] Reconnected successfully');
|
|
263
261
|
};
|
|
@@ -385,46 +383,32 @@ async function startAgent(configPath) {
|
|
|
385
383
|
: { name: rootPackage.name, version: rootPackage.version });
|
|
386
384
|
}
|
|
387
385
|
/**
|
|
388
|
-
*
|
|
389
|
-
*
|
|
390
|
-
* -
|
|
391
|
-
* - on every `$agent.registerNode` from a child
|
|
392
|
-
* - on hub
|
|
386
|
+
* The upward `$hub.registerNode` registration carrying the agent's complete
|
|
387
|
+
* subtree union (`agent-hub-registration.ts`). Sent:
|
|
388
|
+
* - once boot has loaded every addon (`registration.ready()`);
|
|
389
|
+
* - on every `$agent.registerNode` from a child, and on `$agent.reload`;
|
|
390
|
+
* - on every mesh join that settles — a new hub connection, or the hub
|
|
391
|
+
* saying it lost this node — and when the hub asks for it
|
|
392
|
+
* (`$agent.requestRegistration`, on the hub's own `$node.connected`).
|
|
393
|
+
* D802: never before the join lets calls to the hub through, and the
|
|
394
|
+
* manifest is built AFTER the join, so it carries the legacy hash only for a
|
|
395
|
+
* hub that needs it.
|
|
393
396
|
*/
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
target: 'hub',
|
|
410
|
-
signal: registerAbortController.signal,
|
|
411
|
-
log: (msg) => console.log(`[Agent] ${msg}`),
|
|
412
|
-
})
|
|
413
|
-
.then(() => {
|
|
414
|
-
// The hub acked the manifest — the agent reached its registered state.
|
|
415
|
-
// Run the single-copy GC sweep once (first ack only; no-op after).
|
|
416
|
-
confirmAgentBootHealthy();
|
|
417
|
-
})
|
|
418
|
-
.catch((err) => {
|
|
419
|
-
if ((0, system_1.isClusterSecretMismatchError)(err)) {
|
|
420
|
-
consoleLogger.error("hub registration rejected: cluster secret mismatch — enter the hub's cluster secret (System → Cluster) in this agent's setup (agent.json) and restart the agent");
|
|
421
|
-
// Surface the mismatch in the UI status.
|
|
422
|
-
hubConnectionStateOverride = 'secret-mismatch';
|
|
423
|
-
return;
|
|
424
|
-
}
|
|
425
|
-
// abort (shutdown) — preserve prior void behaviour: ignore.
|
|
426
|
-
});
|
|
427
|
-
}
|
|
397
|
+
const registration = (0, agent_hub_registration_js_1.createAgentHubRegistration)({
|
|
398
|
+
join: meshJoin,
|
|
399
|
+
buildManifest: () => aggregateManifest(),
|
|
400
|
+
broker: () => broker,
|
|
401
|
+
signal: registerAbortController.signal,
|
|
402
|
+
log: (msg) => console.log(`[Agent] ${msg}`),
|
|
403
|
+
// The hub acked the manifest — the agent reached its registered state.
|
|
404
|
+
// Run the single-copy GC sweep once (first ack only; no-op after).
|
|
405
|
+
onAcked: () => confirmAgentBootHealthy(),
|
|
406
|
+
onSecretRefused: () => {
|
|
407
|
+
consoleLogger.error("hub registration rejected: cluster secret mismatch — enter the hub's cluster secret (System → Cluster) in this agent's setup (agent.json) and restart the agent");
|
|
408
|
+
// Surface the mismatch in the UI status.
|
|
409
|
+
hubConnectionStateOverride = 'secret-mismatch';
|
|
410
|
+
},
|
|
411
|
+
});
|
|
428
412
|
const consoleLogger = {
|
|
429
413
|
info: (msg) => console.log(`[Agent] ${msg}`),
|
|
430
414
|
warn: (msg) => console.warn(`[Agent] ${msg}`),
|
|
@@ -543,14 +527,19 @@ async function startAgent(configPath) {
|
|
|
543
527
|
// so the hub's `CapRouteResolver` can route to what this node now has.
|
|
544
528
|
// Hung on the existing `$agent.reload` trigger; no timer (D3).
|
|
545
529
|
reconcileNodeRegistration: () => {
|
|
546
|
-
|
|
530
|
+
registration.trigger('addons reloaded');
|
|
531
|
+
},
|
|
532
|
+
// The hub saw this node (re)connect and asks for its capability set —
|
|
533
|
+
// the hub may have dropped it on a disconnect this agent never saw.
|
|
534
|
+
requestRegistration: () => {
|
|
535
|
+
registration.trigger('the hub asked');
|
|
547
536
|
},
|
|
548
537
|
// D3 subtree aggregation: when a group-runner child delivers its manifest
|
|
549
538
|
// via `$agent.registerNode`, merge it into the local subtree registry and
|
|
550
539
|
// immediately re-register the complete union with the hub.
|
|
551
540
|
onChildRegistered: (params) => {
|
|
552
541
|
subtree.registerNode(params);
|
|
553
|
-
|
|
542
|
+
registration.trigger(`child ${params.nodeId} registered`);
|
|
554
543
|
},
|
|
555
544
|
expectedClusterSecretHash: config.secret ? (0, system_1.hashClusterSecret)(config.secret) : undefined,
|
|
556
545
|
meshAuth: meshJoin.authenticator,
|
|
@@ -686,13 +675,13 @@ async function startAgent(configPath) {
|
|
|
686
675
|
const childNodeId = `${agentNodeId}/${child.childId}`;
|
|
687
676
|
const childParams = buildAgentChildUdsManifest(childNodeId, child.childId, child.caps);
|
|
688
677
|
subtree.registerNode(childParams);
|
|
689
|
-
|
|
678
|
+
registration.trigger(`child ${childNodeId} connected`);
|
|
690
679
|
consoleLogger.info(`UDS child registered — subtree updated: ${childNodeId}`);
|
|
691
680
|
});
|
|
692
681
|
agentUdsRegistry.onChildGone((childId) => {
|
|
693
682
|
const childNodeId = `${agentNodeId}/${childId}`;
|
|
694
683
|
subtree.removeNode(childNodeId);
|
|
695
|
-
|
|
684
|
+
registration.trigger(`child ${childNodeId} gone`);
|
|
696
685
|
consoleLogger.info(`UDS child gone — subtree updated: ${childNodeId}`);
|
|
697
686
|
});
|
|
698
687
|
// B2: forward UDS child logs onward to the hub's log-receiver service so
|
|
@@ -883,8 +872,9 @@ async function startAgent(configPath) {
|
|
|
883
872
|
// ── D3: Fire the initial upward hub registration carrying the agent's
|
|
884
873
|
// complete in-process manifest (+ any children that already registered).
|
|
885
874
|
// Group-runner children call `$agent.registerNode` and trigger a re-fire
|
|
886
|
-
// via `onChildRegistered`;
|
|
887
|
-
|
|
875
|
+
// via `onChildRegistered`; every settled mesh join re-fires it
|
|
876
|
+
// (`registration.onJoinSettled`), and so does the hub's ask.
|
|
877
|
+
registration.trigger('boot');
|
|
888
878
|
// Fire `onHubReachable()` on every in-process addon as soon as the hub
|
|
889
879
|
// node connects to our broker — this is the safe point for ctx.api.* calls
|
|
890
880
|
// into hub-provided capabilities. Forked children get the same hook via
|
|
@@ -899,9 +889,8 @@ async function startAgent(configPath) {
|
|
|
899
889
|
if (hubConnectionStateOverride === 'secret-mismatch') {
|
|
900
890
|
hubConnectionStateOverride = null;
|
|
901
891
|
}
|
|
902
|
-
//
|
|
903
|
-
//
|
|
904
|
-
triggerUpwardRegistration();
|
|
892
|
+
// No re-registration here: this connection runs a mesh join, and the
|
|
893
|
+
// join's settle re-sends the manifest (`registration.onJoinSettled`).
|
|
905
894
|
if (hubReachableFired)
|
|
906
895
|
return;
|
|
907
896
|
hubReachableFired = true;
|
|
@@ -265,15 +265,25 @@ function getLocalIps() {
|
|
|
265
265
|
return ips;
|
|
266
266
|
}
|
|
267
267
|
/** {@link computeTopology}, keeping the live entries the projection consumed. */
|
|
268
|
-
async function computeTopologyPass(agentRegistry, addonRegistry, getNodeRootPackage) {
|
|
269
|
-
const [nodes, liveNodes] = await computeTopologyInternal(agentRegistry, addonRegistry, getNodeRootPackage);
|
|
268
|
+
async function computeTopologyPass(agentRegistry, addonRegistry, getNodeRootPackage, isNodeRegistered) {
|
|
269
|
+
const [nodes, liveNodes] = await computeTopologyInternal(agentRegistry, addonRegistry, getNodeRootPackage, isNodeRegistered);
|
|
270
270
|
return { nodes, liveNodes };
|
|
271
271
|
}
|
|
272
|
-
async function computeTopology(agentRegistry, addonRegistry, getNodeRootPackage) {
|
|
273
|
-
const [nodes] = await computeTopologyInternal(agentRegistry, addonRegistry, getNodeRootPackage);
|
|
272
|
+
async function computeTopology(agentRegistry, addonRegistry, getNodeRootPackage, isNodeRegistered) {
|
|
273
|
+
const [nodes] = await computeTopologyInternal(agentRegistry, addonRegistry, getNodeRootPackage, isNodeRegistered);
|
|
274
274
|
return nodes;
|
|
275
275
|
}
|
|
276
|
-
|
|
276
|
+
/**
|
|
277
|
+
* The `capabilitiesRegistered` field of an ONLINE agent row: whether the hub
|
|
278
|
+
* holds that agent's `registerNode` manifest. Absent for the hub, offline rows
|
|
279
|
+
* and when no lookup is wired.
|
|
280
|
+
*/
|
|
281
|
+
function registrationField(nodeId, isHub, isOnline, isNodeRegistered) {
|
|
282
|
+
if (isHub || !isOnline || isNodeRegistered === undefined)
|
|
283
|
+
return {};
|
|
284
|
+
return { capabilitiesRegistered: isNodeRegistered(nodeId) };
|
|
285
|
+
}
|
|
286
|
+
async function computeTopologyInternal(agentRegistry, addonRegistry, getNodeRootPackage, isNodeRegistered) {
|
|
277
287
|
// The live cluster walk and the durable offline-node history read NOTHING
|
|
278
288
|
// from each other, and the history read is one settings-store round trip —
|
|
279
289
|
// measured on the live hub at 14.8–18.3 s while the store is stalled. Awaited
|
|
@@ -419,6 +429,10 @@ async function computeTopologyInternal(agentRegistry, addonRegistry, getNodeRoot
|
|
|
419
429
|
// Mesh mTLS Release A: observed by the hub's transporter (certificate CN),
|
|
420
430
|
// never self-reported by the agent. Hub row and unknown: no badge.
|
|
421
431
|
...(node.isHub ? {} : meshTransportField(node.info.id)),
|
|
432
|
+
// An agent online in the mesh whose capability registration the hub
|
|
433
|
+
// dropped (a disconnect only the hub saw) is reported, never silently
|
|
434
|
+
// capless (2026-10-14).
|
|
435
|
+
...registrationField(node.info.id, node.isHub, node.isOnline !== false, isNodeRegistered),
|
|
422
436
|
};
|
|
423
437
|
});
|
|
424
438
|
// A2: union in OFFLINE rows for every persisted node no longer present in the
|
|
@@ -474,7 +488,7 @@ function createNodeRootPackageLookup(moleculer, serverUpdate) {
|
|
|
474
488
|
function buildNodesProvider(agentRegistry, moleculer, addonRegistry, getNodeRootPackage, logger) {
|
|
475
489
|
const broker = moleculer.broker;
|
|
476
490
|
return {
|
|
477
|
-
topology: async () => computeTopology(agentRegistry, addonRegistry, getNodeRootPackage),
|
|
491
|
+
topology: async () => computeTopology(agentRegistry, addonRegistry, getNodeRootPackage, (nodeId) => moleculer.isNodeRegistered(nodeId)),
|
|
478
492
|
directory: async () => agentRegistry.listNodeDirectory(),
|
|
479
493
|
deployAddon: async () => {
|
|
480
494
|
// Placeholder — actual deployment orchestration TBD
|
|
@@ -8280,6 +8280,22 @@ function createCapRouter_pipelineOrchestrator(getProvider, _createRemoteProxy) {
|
|
|
8280
8280
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-argument
|
|
8281
8281
|
return p.setCameraStepToggle(input);
|
|
8282
8282
|
}),
|
|
8283
|
+
getCameraDetectionSource: trpc_middleware_js_1.protectedProcedure
|
|
8284
|
+
.input(types_97.pipelineOrchestratorCapability.methods.getCameraDetectionSource.input.loose())
|
|
8285
|
+
.output(types_97.pipelineOrchestratorCapability.methods.getCameraDetectionSource.output)
|
|
8286
|
+
.query(async ({ input, ctx }) => {
|
|
8287
|
+
const p = requireCapProvider('pipeline-orchestrator', () => getProvider(ctx));
|
|
8288
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-argument
|
|
8289
|
+
return p.getCameraDetectionSource(input);
|
|
8290
|
+
}),
|
|
8291
|
+
setCameraDetectionSource: trpc_middleware_js_1.adminProcedure
|
|
8292
|
+
.input(types_97.pipelineOrchestratorCapability.methods.setCameraDetectionSource.input.loose())
|
|
8293
|
+
.output(types_97.pipelineOrchestratorCapability.methods.setCameraDetectionSource.output)
|
|
8294
|
+
.mutation(async ({ input, ctx }) => {
|
|
8295
|
+
const p = requireCapProvider('pipeline-orchestrator', () => getProvider(ctx));
|
|
8296
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-argument
|
|
8297
|
+
return p.setCameraDetectionSource(input);
|
|
8298
|
+
}),
|
|
8283
8299
|
getCameraStepOverrides: trpc_middleware_js_1.protectedProcedure
|
|
8284
8300
|
.input(types_97.pipelineOrchestratorCapability.methods.getCameraStepOverrides.input.loose())
|
|
8285
8301
|
.output(types_97.pipelineOrchestratorCapability.methods.getCameraStepOverrides.output)
|
|
@@ -10565,6 +10581,13 @@ function createCapRouter_streamBroker(getProvider, createRemoteProxy) {
|
|
|
10565
10581
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-argument
|
|
10566
10582
|
return p.listClients(methodInput);
|
|
10567
10583
|
}),
|
|
10584
|
+
getLiveViewerCounts: trpc_middleware_js_1.protectedProcedure
|
|
10585
|
+
.input(zod_1.z.object({ nodeId: zod_1.z.string().optional() }).optional())
|
|
10586
|
+
.output(types_121.streamBrokerCapability.methods.getLiveViewerCounts.output)
|
|
10587
|
+
.query(async ({ input, ctx }) => {
|
|
10588
|
+
const p = resolveProvider('stream-broker', input?.nodeId, () => getProvider(ctx), createRemoteProxy);
|
|
10589
|
+
return p.getLiveViewerCounts();
|
|
10590
|
+
}),
|
|
10568
10591
|
killClient: trpc_middleware_js_1.adminProcedure
|
|
10569
10592
|
.input(types_121.streamBrokerCapability.methods.killClient.input.loose())
|
|
10570
10593
|
.output(types_121.streamBrokerCapability.methods.killClient.output)
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* The hub asks a (re)connected agent for its capability set.
|
|
4
|
+
*
|
|
5
|
+
* The hub drops an agent's registration — and its mesh join — on every
|
|
6
|
+
* `$node.disconnected` it sees (`removeNodeFromRegistry`, `meshGuard.forget`).
|
|
7
|
+
* Moleculer raises that event on the HUB alone when a third node gossips the
|
|
8
|
+
* agent offline, when a write to it fails, or when its heartbeat is late; the
|
|
9
|
+
* agent's own view of the hub never breaks, so nothing on the agent says "send
|
|
10
|
+
* your manifest again". When the hub then sees the agent again
|
|
11
|
+
* (`$node.connected`), it holds an online node with NO capabilities. On
|
|
12
|
+
* 2026-10-14 little-unraid and the Mac sat that way for 50 minutes.
|
|
13
|
+
*
|
|
14
|
+
* So on every `$node.connected` of an agent the hub calls
|
|
15
|
+
* `$agent.requestRegistration`: one RPC, on the event, no timer (D3). The
|
|
16
|
+
* agent answers through the one atomic `$hub.registerNode`, joining again
|
|
17
|
+
* first when the hub forgot its join. An agent that predates the action cannot
|
|
18
|
+
* be asked: that is said, with whether the hub holds its registration, so an
|
|
19
|
+
* online-but-capless node is never silent.
|
|
20
|
+
*/
|
|
21
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
22
|
+
exports.AGENT_REQUEST_REGISTRATION_ACTION = void 0;
|
|
23
|
+
exports.requestAgentRegistration = requestAgentRegistration;
|
|
24
|
+
exports.AGENT_REQUEST_REGISTRATION_ACTION = '$agent.requestRegistration';
|
|
25
|
+
function errorType(err) {
|
|
26
|
+
if (typeof err !== 'object' || err === null)
|
|
27
|
+
return null;
|
|
28
|
+
const type = Reflect.get(err, 'type');
|
|
29
|
+
return typeof type === 'string' ? type : null;
|
|
30
|
+
}
|
|
31
|
+
function errorText(err) {
|
|
32
|
+
return err instanceof Error ? err.message : String(err);
|
|
33
|
+
}
|
|
34
|
+
/** Ask `nodeId` to re-send its complete capability manifest. Never throws. */
|
|
35
|
+
async function requestAgentRegistration(deps, nodeId) {
|
|
36
|
+
try {
|
|
37
|
+
await deps.call(exports.AGENT_REQUEST_REGISTRATION_ACTION, {}, { nodeID: nodeId });
|
|
38
|
+
return 'requested';
|
|
39
|
+
}
|
|
40
|
+
catch (err) {
|
|
41
|
+
const type = errorType(err);
|
|
42
|
+
const unsupported = type === 'SERVICE_NOT_FOUND' || type === 'SERVICE_NOT_AVAILABLE';
|
|
43
|
+
const registered = deps.isRegistered(nodeId);
|
|
44
|
+
const meta = { registered, error: errorText(err) };
|
|
45
|
+
if (registered) {
|
|
46
|
+
deps.info(unsupported
|
|
47
|
+
? 'agent cannot be asked for its capabilities (it predates $agent.requestRegistration) — the hub holds its registration'
|
|
48
|
+
: 'asking the agent for its capabilities failed — the hub holds its registration', nodeId, meta);
|
|
49
|
+
}
|
|
50
|
+
else {
|
|
51
|
+
deps.warn(unsupported
|
|
52
|
+
? 'agent is online and the hub holds NO capability registration for it; it predates $agent.requestRegistration and cannot be asked — if it stays without capabilities, restart it'
|
|
53
|
+
: 'agent is online and the hub holds NO capability registration for it; asking it to re-register failed', nodeId, meta);
|
|
54
|
+
}
|
|
55
|
+
return unsupported ? 'unsupported' : 'failed';
|
|
56
|
+
}
|
|
57
|
+
}
|
|
@@ -9,6 +9,7 @@ const types_1 = require("@camstack/types");
|
|
|
9
9
|
const cap_router_runtime_js_1 = require("../../api/trpc/cap-router-runtime.js");
|
|
10
10
|
const core_cap_bridge_js_1 = require("../../api/trpc/core-cap-bridge.js");
|
|
11
11
|
const agent_readiness_pull_js_1 = require("./agent-readiness-pull.js");
|
|
12
|
+
const agent_registration_request_js_1 = require("./agent-registration-request.js");
|
|
12
13
|
const hub_mesh_guard_js_1 = require("./hub-mesh-guard.js");
|
|
13
14
|
const cap_call_fn_js_1 = require("./cap-call-fn.js");
|
|
14
15
|
const cap_route_authority_js_1 = require("./cap-route-authority.js");
|
|
@@ -656,6 +657,15 @@ class MoleculerService {
|
|
|
656
657
|
if (nodeId === this.brokerSafe.nodeID || nodeId.includes('/'))
|
|
657
658
|
return;
|
|
658
659
|
void this.pullAgentReadinessSnapshot(nodeId);
|
|
660
|
+
// The hub may have dropped this agent's registration on a disconnect
|
|
661
|
+
// only the hub saw (gossip, a failed write): ask it for its full set
|
|
662
|
+
// (`agent-registration-request.ts`, 2026-10-14).
|
|
663
|
+
void (0, agent_registration_request_js_1.requestAgentRegistration)({
|
|
664
|
+
call: (action, params, opts) => this.brokerSafe.call(action, params, opts),
|
|
665
|
+
isRegistered: (id) => this.isNodeRegistered(id),
|
|
666
|
+
warn: (message, id, meta) => this.logger.warn(message, { tags: { nodeId: id }, meta }),
|
|
667
|
+
info: (message, id, meta) => this.logger.info(message, { tags: { nodeId: id }, meta }),
|
|
668
|
+
}, nodeId);
|
|
659
669
|
});
|
|
660
670
|
this.agentReadinessPullTimer = setInterval(() => {
|
|
661
671
|
for (const nodeId of this.nodeRegistry.listNodeIds()) {
|
|
@@ -1275,6 +1285,15 @@ class MoleculerService {
|
|
|
1275
1285
|
listClusterNativeCapsForDevice(deviceId) {
|
|
1276
1286
|
return this.nodeRegistry.listNativeCapEntriesForDevice(deviceId);
|
|
1277
1287
|
}
|
|
1288
|
+
/**
|
|
1289
|
+
* Does the hub hold a capability registration for `nodeId`? `false` for an
|
|
1290
|
+
* agent that is online in the mesh but whose manifest was dropped (a
|
|
1291
|
+
* disconnect only the hub saw) and not yet re-sent — reported as such by
|
|
1292
|
+
* `nodes.topology` (`capabilitiesRegistered`), never silently capless.
|
|
1293
|
+
*/
|
|
1294
|
+
isNodeRegistered(nodeId) {
|
|
1295
|
+
return this.nodeRegistry.getNodeManifest(nodeId) !== undefined;
|
|
1296
|
+
}
|
|
1278
1297
|
/**
|
|
1279
1298
|
* The root-package identity a node reported in its `registerNode`
|
|
1280
1299
|
* manifest (runtime-updatable node packages: version visibility), or null
|
|
@@ -39,17 +39,21 @@ class TopologyEmitterService {
|
|
|
39
39
|
agentRegistry;
|
|
40
40
|
addonRegistry;
|
|
41
41
|
getNodeRootPackage;
|
|
42
|
+
isNodeRegistered;
|
|
42
43
|
unsubscribers = [];
|
|
43
44
|
debounceTimer = null;
|
|
44
45
|
heartbeatTimer = null;
|
|
45
46
|
emitting = false;
|
|
46
47
|
constructor(eventBus, agentRegistry, addonRegistry,
|
|
47
48
|
/** Root-package identity per node (version visibility) — optional. */
|
|
48
|
-
getNodeRootPackage
|
|
49
|
+
getNodeRootPackage,
|
|
50
|
+
/** Whether the hub holds each agent's capability registration — optional. */
|
|
51
|
+
isNodeRegistered) {
|
|
49
52
|
this.eventBus = eventBus;
|
|
50
53
|
this.agentRegistry = agentRegistry;
|
|
51
54
|
this.addonRegistry = addonRegistry;
|
|
52
55
|
this.getNodeRootPackage = getNodeRootPackage;
|
|
56
|
+
this.isNodeRegistered = isNodeRegistered;
|
|
53
57
|
}
|
|
54
58
|
onModuleInit() {
|
|
55
59
|
for (const category of LIFECYCLE_CATEGORIES) {
|
|
@@ -89,7 +93,7 @@ class TopologyEmitterService {
|
|
|
89
93
|
return;
|
|
90
94
|
this.emitting = true;
|
|
91
95
|
try {
|
|
92
|
-
const { nodes, liveNodes } = await (0, cap_providers_1.computeTopologyPass)(this.agentRegistry, this.addonRegistry, this.getNodeRootPackage);
|
|
96
|
+
const { nodes, liveNodes } = await (0, cap_providers_1.computeTopologyPass)(this.agentRegistry, this.addonRegistry, this.getNodeRootPackage, this.isNodeRegistered);
|
|
93
97
|
this.eventBus.emit({
|
|
94
98
|
id: (0, node_crypto_1.randomUUID)(),
|
|
95
99
|
timestamp: new Date(),
|
package/dist/manual-boot.js
CHANGED
|
@@ -257,7 +257,7 @@ async function bootManual(opts) {
|
|
|
257
257
|
eventBus: eventBusService,
|
|
258
258
|
updateAvailabilityStore: new update_availability_store_js_1.FileUpdateAvailabilityStore(availabilityDataDir, 'server-update', loggingService.createLogger('UpdateAvailability')),
|
|
259
259
|
});
|
|
260
|
-
const topologyEmitterService = new topology_emitter_service_1.TopologyEmitterService(eventBusService, agentRegistryService, addonRegistryService, (0, cap_providers_1.createNodeRootPackageLookup)(moleculerService, serverUpdateService));
|
|
260
|
+
const topologyEmitterService = new topology_emitter_service_1.TopologyEmitterService(eventBusService, agentRegistryService, addonRegistryService, (0, cap_providers_1.createNodeRootPackageLookup)(moleculerService, serverUpdateService), (nodeId) => moleculerService.isNodeRegistered(nodeId));
|
|
261
261
|
const postBootService = new post_boot_service_1.PostBootService(addonRegistryService, eventBusService, loggingService);
|
|
262
262
|
// ---- Update availability poller ---------------------------------------
|
|
263
263
|
// "Tell me, don't install." Deliberately NOT gated on the auto-update
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@camstack/server",
|
|
3
|
-
"version": "1.2.
|
|
3
|
+
"version": "1.2.545",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"private": false,
|
|
6
6
|
"files": [
|
|
@@ -38,19 +38,19 @@
|
|
|
38
38
|
]
|
|
39
39
|
},
|
|
40
40
|
"dependencies": {
|
|
41
|
-
"@camstack/addon-admin-ui": "1.2.
|
|
42
|
-
"@camstack/addon-agent-ui": "1.2.
|
|
41
|
+
"@camstack/addon-admin-ui": "1.2.410",
|
|
42
|
+
"@camstack/addon-agent-ui": "1.2.225",
|
|
43
43
|
"@camstack/addon-auth": "1.2.141",
|
|
44
44
|
"@camstack/addon-decoder-nodeav": "1.2.123",
|
|
45
45
|
"@camstack/addon-notifiers": "1.2.134",
|
|
46
|
-
"@camstack/addon-pipeline": "1.2.
|
|
47
|
-
"@camstack/addon-pipeline-orchestrator": "1.2.
|
|
48
|
-
"@camstack/addon-post-analysis": "1.2.
|
|
46
|
+
"@camstack/addon-pipeline": "1.2.362",
|
|
47
|
+
"@camstack/addon-pipeline-orchestrator": "1.2.312",
|
|
48
|
+
"@camstack/addon-post-analysis": "1.2.361",
|
|
49
49
|
"@camstack/develop": "0.1.5",
|
|
50
50
|
"@camstack/sdk": "1.2.131",
|
|
51
|
-
"@camstack/system": "1.2.
|
|
52
|
-
"@camstack/types": "1.2.
|
|
53
|
-
"@camstack/ui-library": "1.2.
|
|
51
|
+
"@camstack/system": "1.2.421",
|
|
52
|
+
"@camstack/types": "1.2.358",
|
|
53
|
+
"@camstack/ui-library": "1.2.282",
|
|
54
54
|
"@fastify/compress": "^9.0.0",
|
|
55
55
|
"@fastify/cookie": "^11.0.2",
|
|
56
56
|
"@fastify/cors": "^11.2.0",
|