@camstack/server 1.1.66 → 1.1.68

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.
@@ -1419,6 +1419,16 @@ class AddonRegistryService {
1419
1419
  // roster from the freshly-swapped on-disk bundle. Updating ONE member of a
1420
1420
  // group therefore respawns the ENTIRE group — they share a process.
1421
1421
  if (this.isForkedAddonEntry(entry)) {
1422
+ // Snapshot which declared caps are ACTUALLY registered right now —
1423
+ // the post-restart wait below must only gate on these. Device-scoped
1424
+ // caps (vacuum-control, valve, humidifier, …) register only when a
1425
+ // device using them exists: waiting for a cap that was NOT registered
1426
+ // before the restart burns the full 120s timeout for nothing (live:
1427
+ // integrations.create held its mutation — and the creation modal —
1428
+ // for 2 minutes on the HA addon's 8 deviceless caps).
1429
+ const preRegistered = new Set(entry.declaredCapabilities
1430
+ .map((cap) => cap.name)
1431
+ .filter((name) => this.capabilityRegistry.getProviderByAddon(name, addonId) != null));
1422
1432
  await this.ensureForkedRunner(addonId, { restart: true });
1423
1433
  // The respawn resolves as soon as the child is up, not when its
1424
1434
  // capabilities are re-registered. Callers (integrations.create, UI forms) may
@@ -1450,9 +1460,21 @@ class AddonRegistryService {
1450
1460
  // *misleading error* on timeout — a warning avoids that while still
1451
1461
  // letting the update finalize).
1452
1462
  const REGISTER_WAIT_MS = 120_000;
1453
- const waits = declared.map((cap) => this.capabilityRegistry.waitForProvider(cap.name, addonId, REGISTER_WAIT_MS));
1463
+ // Gate ONLY on the caps that were registered BEFORE the restart —
1464
+ // those are the ones a caller could have been routing to. Caps that
1465
+ // were absent (device-scoped, no device yet) re-register
1466
+ // asynchronously whenever their devices appear; waiting for them
1467
+ // here held integrations.create (and its modal) for the full
1468
+ // timeout on every deviceless declared cap.
1469
+ const gated = declared.filter((cap) => preRegistered.has(cap.name));
1470
+ const skipped = declared.filter((cap) => !preRegistered.has(cap.name));
1471
+ if (skipped.length > 0) {
1472
+ this.logger.debug(`Addon "${addonId}" restart: not gating on ${skipped.length} cap(s) that were ` +
1473
+ `unregistered pre-restart (device-scoped, awaiting devices): ${skipped.map((c) => c.name).join(', ')}`, { tags: { addonId } });
1474
+ }
1475
+ const waits = gated.map((cap) => this.capabilityRegistry.waitForProvider(cap.name, addonId, REGISTER_WAIT_MS));
1454
1476
  const settled = await Promise.all(waits);
1455
- const missing = declared
1477
+ const missing = gated
1456
1478
  .map((cap, i) => (settled[i] == null ? cap.name : null))
1457
1479
  .filter((name) => name !== null);
1458
1480
  if (missing.length > 0) {
@@ -0,0 +1,40 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.filterAgentAuthoritativeRecords = filterAgentAuthoritativeRecords;
4
+ const VALID_STATES = new Set(['starting', 'ready', 'down']);
5
+ const VALID_SCOPE_TYPES = new Set(['global', 'node', 'device']);
6
+ /** Structural validation for a record received from a remote node. */
7
+ function isWellFormedRecord(value) {
8
+ if (typeof value !== 'object' || value === null)
9
+ return false;
10
+ const record = value;
11
+ if (typeof record.capName !== 'string' || record.capName.length === 0)
12
+ return false;
13
+ if (typeof record.state !== 'string' || !VALID_STATES.has(record.state))
14
+ return false;
15
+ if (typeof record.generation !== 'string')
16
+ return false;
17
+ if (typeof record.sourceNodeId !== 'string')
18
+ return false;
19
+ const scope = record.scope;
20
+ if (typeof scope !== 'object' || scope === null)
21
+ return false;
22
+ if (!VALID_SCOPE_TYPES.has(scope.type))
23
+ return false;
24
+ return true;
25
+ }
26
+ /**
27
+ * Keep only the records `agentNodeId` is authoritative for, dropping
28
+ * malformed entries. See module doc for why this filter must exist.
29
+ */
30
+ function filterAgentAuthoritativeRecords(records, agentNodeId) {
31
+ if (!Array.isArray(records))
32
+ return [];
33
+ return records.filter((record) => {
34
+ if (!isWellFormedRecord(record))
35
+ return false;
36
+ if (record.scope.type === 'node')
37
+ return record.scope.nodeId === agentNodeId;
38
+ return record.sourceNodeId === agentNodeId;
39
+ });
40
+ }
@@ -5,6 +5,7 @@ exports.buildChildUdsManifest = buildChildUdsManifest;
5
5
  const node_crypto_1 = require("node:crypto");
6
6
  const system_1 = require("@camstack/system");
7
7
  const types_1 = require("@camstack/types");
8
+ const agent_readiness_pull_js_1 = require("./agent-readiness-pull.js");
8
9
  const cap_router_runtime_js_1 = require("../../api/trpc/cap-router-runtime.js");
9
10
  const core_cap_bridge_js_1 = require("../../api/trpc/core-cap-bridge.js");
10
11
  const cap_call_fn_js_1 = require("./cap-call-fn.js");
@@ -26,6 +27,14 @@ class MoleculerService {
26
27
  * `$hub.registerNode`. Populated by `onRegisterNode` in `hubDeps`.
27
28
  */
28
29
  nodeRegistry = new system_1.HubNodeRegistry();
30
+ /**
31
+ * Fixed-period agent-readiness snapshot sweep (D8 reconcile) — repairs
32
+ * agent-origin readiness deltas lost while the agent stayed connected.
33
+ * Not a retry backoff: a steady-state reconcile against each agent's
34
+ * authoritative snapshot.
35
+ */
36
+ static AGENT_READINESS_PULL_INTERVAL_MS = 60_000;
37
+ agentReadinessPullTimer = null;
29
38
  nodeCallFns = new Map();
30
39
  /**
31
40
  * D3: callback invoked whenever a bare-ID agent node completes the
@@ -390,6 +399,30 @@ class MoleculerService {
390
399
  bridgeBus.localBus.on('$node.disconnected', ({ node }) => {
391
400
  this.removeNodeFromRegistry(node.id);
392
401
  });
402
+ // D8 reverse-leg snapshot-reconcile: on every agent (re)connect, pull
403
+ // the agent's own readiness snapshot (`$agent-readiness.getSnapshot`)
404
+ // and hydrate the hub-authoritative registry with the records the
405
+ // agent is authoritative for. Cross-node `system.ready-state` deltas
406
+ // are telemetry (lossy) — a lost post-restart `ready` used to leave
407
+ // this registry stuck on the synthesized `down` forever (2026-07-17:
408
+ // pipeline-executor@little-unraid, 3.5h dispatch outage). The
409
+ // periodic sweep below repairs deltas lost while CONNECTED.
410
+ bridgeBus.localBus.on('$node.connected', ({ node }) => {
411
+ const nodeId = node.id;
412
+ if (nodeId === this.brokerSafe.nodeID || nodeId.includes('/'))
413
+ return;
414
+ void this.pullAgentReadinessSnapshot(nodeId);
415
+ });
416
+ this.agentReadinessPullTimer = setInterval(() => {
417
+ for (const nodeId of this.nodeRegistry.listNodeIds()) {
418
+ if (nodeId === this.brokerSafe.nodeID || nodeId.includes('/'))
419
+ continue;
420
+ void this.pullAgentReadinessSnapshot(nodeId);
421
+ }
422
+ }, MoleculerService.AGENT_READINESS_PULL_INTERVAL_MS);
423
+ if (typeof this.agentReadinessPullTimer.unref === 'function') {
424
+ this.agentReadinessPullTimer.unref();
425
+ }
393
426
  // Register the $event-bus service BEFORE broker.start(). Moleculer
394
427
  // announces service subscriptions to remote nodes only during discovery
395
428
  // handshake, not dynamically after post-start `createService()`.
@@ -955,7 +988,39 @@ class MoleculerService {
955
988
  return false;
956
989
  return registry.setChildLogLevel(childId, level);
957
990
  }
991
+ /**
992
+ * Pull one agent's own readiness snapshot and hydrate the
993
+ * hub-authoritative registry with the records that agent is
994
+ * authoritative for (see `agent-readiness-pull.ts` for the filter
995
+ * rationale). Best-effort: agents predating `$agent-readiness` (or a
996
+ * service list still propagating right after connect) fail with
997
+ * "not found" — silent; the periodic sweep retries.
998
+ */
999
+ async pullAgentReadinessSnapshot(nodeId) {
1000
+ try {
1001
+ const snapshot = await this.brokerSafe.call(`${system_1.AGENT_READINESS_SERVICE_NAME}.getSnapshot`, {}, { nodeID: nodeId });
1002
+ if (!Array.isArray(snapshot) || snapshot.length === 0)
1003
+ return;
1004
+ const records = (0, agent_readiness_pull_js_1.filterAgentAuthoritativeRecords)(snapshot, nodeId);
1005
+ if (records.length === 0)
1006
+ return;
1007
+ this.readinessRegistry.hydrate(records);
1008
+ }
1009
+ catch (err) {
1010
+ const msg = err instanceof Error ? err.message : String(err);
1011
+ if (!msg.includes('not found') && !msg.includes('NOT_FOUND')) {
1012
+ this.logger.debug('agent readiness snapshot pull failed', {
1013
+ tags: { nodeId },
1014
+ meta: { err: msg },
1015
+ });
1016
+ }
1017
+ }
1018
+ }
958
1019
  async onModuleDestroy() {
1020
+ if (this.agentReadinessPullTimer !== null) {
1021
+ clearInterval(this.agentReadinessPullTimer);
1022
+ this.agentReadinessPullTimer = null;
1023
+ }
959
1024
  this.udsEventBridgeDispose?.();
960
1025
  this.udsEventBridgeDispose = null;
961
1026
  await this.brokerSafe.stop();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@camstack/server",
3
- "version": "1.1.66",
3
+ "version": "1.1.68",
4
4
  "private": false,
5
5
  "files": [
6
6
  "dist",
@@ -23,19 +23,19 @@
23
23
  "test:watch": "vitest"
24
24
  },
25
25
  "dependencies": {
26
- "@camstack/addon-admin-ui": "1.1.54",
27
- "@camstack/addon-advanced-notifier": "1.1.26",
28
- "@camstack/addon-auth": "1.1.11",
29
- "@camstack/addon-decoder-nodeav": "1.1.14",
30
- "@camstack/addon-notifiers": "1.1.26",
31
- "@camstack/addon-pipeline": "1.1.59",
32
- "@camstack/addon-pipeline-orchestrator": "1.1.49",
33
- "@camstack/addon-post-analysis": "1.1.31",
34
- "@camstack/sdk": "1.1.26",
35
- "@camstack/shm-ring": "1.0.25",
36
- "@camstack/system": "1.1.52",
37
- "@camstack/types": "1.1.47",
38
- "@camstack/ui-library": "1.1.38",
26
+ "@camstack/addon-admin-ui": "1.1.56",
27
+ "@camstack/addon-advanced-notifier": "1.1.28",
28
+ "@camstack/addon-auth": "1.1.13",
29
+ "@camstack/addon-decoder-nodeav": "1.1.16",
30
+ "@camstack/addon-notifiers": "1.1.28",
31
+ "@camstack/addon-pipeline": "1.1.61",
32
+ "@camstack/addon-pipeline-orchestrator": "1.1.51",
33
+ "@camstack/addon-post-analysis": "1.1.33",
34
+ "@camstack/sdk": "1.1.28",
35
+ "@camstack/shm-ring": "1.0.27",
36
+ "@camstack/system": "1.1.54",
37
+ "@camstack/types": "1.1.49",
38
+ "@camstack/ui-library": "1.1.40",
39
39
  "@fastify/compress": "^9.0.0",
40
40
  "@fastify/cookie": "^11.0.2",
41
41
  "@fastify/multipart": "^10.0.0",