@camstack/server 0.2.2 → 1.0.1

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.
Files changed (234) hide show
  1. package/{src/agent-status-page.ts → dist/agent-status-page.js} +30 -45
  2. package/dist/api/addon-upload.js +441 -0
  3. package/dist/api/addons-custom.router.js +91 -0
  4. package/dist/api/auth-whoami.js +55 -0
  5. package/dist/api/bridge-addons.router.js +109 -0
  6. package/dist/api/capabilities.router.js +229 -0
  7. package/dist/api/core/addon-settings.router.js +117 -0
  8. package/dist/api/core/agents.router.js +73 -0
  9. package/dist/api/core/auth.router.js +286 -0
  10. package/dist/api/core/bulk-update-coordinator.js +229 -0
  11. package/dist/api/core/cap-providers.js +1124 -0
  12. package/dist/api/core/capabilities.router.js +138 -0
  13. package/dist/api/core/collection-preference.js +17 -0
  14. package/dist/api/core/event-bus-proxy.router.js +45 -0
  15. package/dist/api/core/hwaccel.router.js +91 -0
  16. package/dist/api/core/live-events.router.js +61 -0
  17. package/dist/api/core/logs.router.js +172 -0
  18. package/dist/api/core/notifications.router.js +67 -0
  19. package/dist/api/core/repl.router.js +35 -0
  20. package/dist/api/core/settings-backend.router.js +121 -0
  21. package/dist/api/core/stream-probe.router.js +58 -0
  22. package/dist/api/core/system-events.router.js +100 -0
  23. package/dist/api/health/health.routes.js +68 -0
  24. package/{src/api/oauth2/consent-page.ts → dist/api/oauth2/consent-page.js} +11 -20
  25. package/dist/api/oauth2/oauth2-routes.js +219 -0
  26. package/dist/api/trpc/cap-mount-helpers.js +194 -0
  27. package/dist/api/trpc/cap-route-error-formatter.js +133 -0
  28. package/dist/api/trpc/client-ip.js +147 -0
  29. package/dist/api/trpc/core-cap-bridge.js +115 -0
  30. package/dist/api/trpc/generated-cap-mounts.js +388 -0
  31. package/dist/api/trpc/generated-cap-routers.js +7635 -0
  32. package/dist/api/trpc/scope-access.js +93 -0
  33. package/dist/api/trpc/trpc.context.js +184 -0
  34. package/dist/api/trpc/trpc.middleware.js +139 -0
  35. package/dist/api/trpc/trpc.router.js +188 -0
  36. package/dist/auth/session-cookie.js +47 -0
  37. package/dist/boot/boot-config.js +241 -0
  38. package/dist/boot/integration-id-backfill.js +76 -0
  39. package/dist/boot/post-boot.service.js +85 -0
  40. package/dist/core/addon/addon-call-gateway.js +99 -0
  41. package/dist/core/addon/addon-package.service.js +1560 -0
  42. package/dist/core/addon/addon-registry.service.js +2739 -0
  43. package/{src/core/addon/addon-row-manifest.ts → dist/core/addon/addon-row-manifest.js} +5 -5
  44. package/dist/core/addon/addon-search.service.js +62 -0
  45. package/dist/core/addon/addon-settings-provider.js +102 -0
  46. package/dist/core/addon/addon.tokens.js +5 -0
  47. package/dist/core/addon-bridge/addon-bridge.service.js +145 -0
  48. package/dist/core/addon-pages/addon-pages.service.js +107 -0
  49. package/dist/core/addon-widgets/addon-widgets.service.js +120 -0
  50. package/dist/core/agent/agent-registry.service.js +477 -0
  51. package/dist/core/auth/auth.service.js +10 -0
  52. package/dist/core/capability/capability.service.js +58 -0
  53. package/dist/core/config/config.schema.js +7 -0
  54. package/dist/core/config/config.service.js +10 -0
  55. package/dist/core/events/event-bus.service.js +83 -0
  56. package/dist/core/feature/feature.service.js +10 -0
  57. package/dist/core/lifecycle/lifecycle-state-machine.js +6 -0
  58. package/dist/core/logging/log-ring-buffer.js +6 -0
  59. package/dist/core/logging/logging.service.js +130 -0
  60. package/dist/core/logging/scoped-logger.js +6 -0
  61. package/dist/core/moleculer/cap-call-fn.js +50 -0
  62. package/dist/core/moleculer/cap-route-authority.js +122 -0
  63. package/dist/core/moleculer/moleculer.service.js +898 -0
  64. package/dist/core/network/network-quality.service.js +7 -0
  65. package/dist/core/notification/notification-wrapper.service.js +33 -0
  66. package/dist/core/notification/toast-wrapper.service.js +25 -0
  67. package/dist/core/provider/provider.tokens.js +4 -0
  68. package/dist/core/repl/repl-engine.service.js +140 -0
  69. package/dist/core/storage/fs-storage-backend.js +6 -0
  70. package/dist/core/storage/storage-location-manager.js +6 -0
  71. package/dist/core/storage/storage.service.js +7 -0
  72. package/dist/core/streaming/stream-probe.service.js +209 -0
  73. package/dist/core/topology/topology-emitter.service.js +106 -0
  74. package/dist/launcher.js +325 -0
  75. package/dist/main.js +1098 -0
  76. package/dist/manual-boot.js +227 -0
  77. package/package.json +5 -1
  78. package/src/__tests__/addon-install-e2e.test.ts +0 -74
  79. package/src/__tests__/addon-pages-e2e.test.ts +0 -200
  80. package/src/__tests__/addon-route-session.test.ts +0 -17
  81. package/src/__tests__/addon-settings-router.spec.ts +0 -67
  82. package/src/__tests__/addon-upload.spec.ts +0 -475
  83. package/src/__tests__/agent-registry.spec.ts +0 -179
  84. package/src/__tests__/agent-status-page.spec.ts +0 -82
  85. package/src/__tests__/auth-session-cookie.test.ts +0 -48
  86. package/src/__tests__/bulk-update-coordinator.spec.ts +0 -303
  87. package/src/__tests__/cap-ownership-authority.spec.ts +0 -431
  88. package/src/__tests__/cap-providers/cap-providers-location-import.spec.ts +0 -206
  89. package/src/__tests__/cap-providers/cap-usage-graph.spec.ts +0 -37
  90. package/src/__tests__/cap-providers/compute-topology-categories.spec.ts +0 -110
  91. package/src/__tests__/cap-providers/integrations-delete-cascade.spec.ts +0 -292
  92. package/src/__tests__/cap-providers-bulk-update.spec.ts +0 -408
  93. package/src/__tests__/cap-route-adapter.spec.ts +0 -302
  94. package/src/__tests__/cap-routers/_meta.spec.ts +0 -199
  95. package/src/__tests__/cap-routers/addon-settings.router.spec.ts +0 -115
  96. package/src/__tests__/cap-routers/broker-routing.router.spec.ts +0 -177
  97. package/src/__tests__/cap-routers/cap-route-error-formatter.spec.ts +0 -125
  98. package/src/__tests__/cap-routers/capabilities-node.spec.ts +0 -68
  99. package/src/__tests__/cap-routers/device-link-overlay.spec.ts +0 -137
  100. package/src/__tests__/cap-routers/device-manager-aggregate.router.spec.ts +0 -194
  101. package/src/__tests__/cap-routers/harness.ts +0 -163
  102. package/src/__tests__/cap-routers/metrics-provider.router.spec.ts +0 -133
  103. package/src/__tests__/cap-routers/null-provider-guard.spec.ts +0 -64
  104. package/src/__tests__/cap-routers/pipeline-executor.router.spec.ts +0 -159
  105. package/src/__tests__/cap-routers/settings-store.router.spec.ts +0 -291
  106. package/src/__tests__/capability-e2e.test.ts +0 -384
  107. package/src/__tests__/cli-e2e.test.ts +0 -150
  108. package/src/__tests__/core-cap-bridge.spec.ts +0 -91
  109. package/src/__tests__/dev-bootstrap-shm-ring.spec.ts +0 -40
  110. package/src/__tests__/device-settings-contribution-dispatch.spec.ts +0 -280
  111. package/src/__tests__/embedded-deps-e2e.test.ts +0 -125
  112. package/src/__tests__/event-bus-proxy-router.spec.ts +0 -75
  113. package/src/__tests__/fixtures/mock-analysis-addon-a.ts +0 -37
  114. package/src/__tests__/fixtures/mock-analysis-addon-b.ts +0 -37
  115. package/src/__tests__/fixtures/mock-log-addon.ts +0 -37
  116. package/src/__tests__/fixtures/mock-storage-addon.ts +0 -40
  117. package/src/__tests__/framework-allowlist.spec.ts +0 -96
  118. package/src/__tests__/framework-installer-defer-restart.spec.ts +0 -165
  119. package/src/__tests__/https-e2e.test.ts +0 -124
  120. package/src/__tests__/lifecycle-e2e.test.ts +0 -189
  121. package/src/__tests__/live-events-subscription.spec.ts +0 -149
  122. package/src/__tests__/moleculer/uds-readiness.spec.ts +0 -150
  123. package/src/__tests__/moleculer/uds-topology.spec.ts +0 -418
  124. package/src/__tests__/moleculer/uds-unowned-call.spec.ts +0 -383
  125. package/src/__tests__/moleculer-register-node-idempotency.spec.ts +0 -273
  126. package/src/__tests__/native-cap-route.spec.ts +0 -427
  127. package/src/__tests__/oauth2-account-linking.spec.ts +0 -867
  128. package/src/__tests__/post-boot-restart.spec.ts +0 -161
  129. package/src/__tests__/singleton-contention.test.ts +0 -499
  130. package/src/__tests__/streaming-diagnostic.test.ts +0 -615
  131. package/src/__tests__/streaming-scale.test.ts +0 -314
  132. package/src/__tests__/uds-addon-call-wiring.spec.ts +0 -242
  133. package/src/__tests__/uds-log-ingest.spec.ts +0 -183
  134. package/src/api/__tests__/addons-custom.spec.ts +0 -148
  135. package/src/api/__tests__/capabilities.router.test.ts +0 -56
  136. package/src/api/addon-upload.ts +0 -529
  137. package/src/api/addons-custom.router.ts +0 -101
  138. package/src/api/auth-whoami.ts +0 -101
  139. package/src/api/bridge-addons.router.ts +0 -122
  140. package/src/api/capabilities.router.ts +0 -265
  141. package/src/api/core/__tests__/auth-router-totp.spec.ts +0 -297
  142. package/src/api/core/__tests__/integration-markers.spec.ts +0 -10
  143. package/src/api/core/addon-settings.router.ts +0 -127
  144. package/src/api/core/agents.router.ts +0 -86
  145. package/src/api/core/auth.router.ts +0 -322
  146. package/src/api/core/bulk-update-coordinator.ts +0 -305
  147. package/src/api/core/cap-providers.ts +0 -1339
  148. package/src/api/core/capabilities.router.ts +0 -149
  149. package/src/api/core/collection-preference.ts +0 -40
  150. package/src/api/core/event-bus-proxy.router.ts +0 -45
  151. package/src/api/core/hwaccel.router.ts +0 -108
  152. package/src/api/core/live-events.router.ts +0 -67
  153. package/src/api/core/logs.router.ts +0 -195
  154. package/src/api/core/notifications.router.ts +0 -66
  155. package/src/api/core/repl.router.ts +0 -39
  156. package/src/api/core/settings-backend.router.ts +0 -140
  157. package/src/api/core/stream-probe.router.ts +0 -57
  158. package/src/api/core/system-events.router.ts +0 -125
  159. package/src/api/health/health.routes.ts +0 -117
  160. package/src/api/oauth2/__tests__/oauth2-routes.spec.ts +0 -62
  161. package/src/api/oauth2/oauth2-routes.ts +0 -281
  162. package/src/api/trpc/__tests__/client-ip.spec.ts +0 -146
  163. package/src/api/trpc/__tests__/scope-access-device.spec.ts +0 -268
  164. package/src/api/trpc/__tests__/scope-access.spec.ts +0 -102
  165. package/src/api/trpc/__tests__/webrtc-session-ua-enrich.spec.ts +0 -136
  166. package/src/api/trpc/cap-mount-helpers.ts +0 -245
  167. package/src/api/trpc/cap-route-error-formatter.ts +0 -171
  168. package/src/api/trpc/client-ip.ts +0 -147
  169. package/src/api/trpc/core-cap-bridge.ts +0 -154
  170. package/src/api/trpc/generated-cap-mounts.ts +0 -1240
  171. package/src/api/trpc/generated-cap-routers.ts +0 -11523
  172. package/src/api/trpc/scope-access.ts +0 -110
  173. package/src/api/trpc/trpc.context.ts +0 -258
  174. package/src/api/trpc/trpc.middleware.ts +0 -146
  175. package/src/api/trpc/trpc.router.ts +0 -389
  176. package/src/auth/session-cookie.ts +0 -54
  177. package/src/boot/__tests__/integration-id-backfill.spec.ts +0 -131
  178. package/src/boot/boot-config.ts +0 -259
  179. package/src/boot/integration-id-backfill.ts +0 -109
  180. package/src/boot/post-boot.service.ts +0 -105
  181. package/src/core/addon/__tests__/addon-registry-capability.test.ts +0 -62
  182. package/src/core/addon/__tests__/addon-row-manifest.spec.ts +0 -62
  183. package/src/core/addon/addon-call-gateway.ts +0 -171
  184. package/src/core/addon/addon-package.service.ts +0 -1787
  185. package/src/core/addon/addon-registry.service.ts +0 -3130
  186. package/src/core/addon/addon-search.service.ts +0 -91
  187. package/src/core/addon/addon-settings-provider.ts +0 -220
  188. package/src/core/addon/addon.tokens.ts +0 -2
  189. package/src/core/addon-bridge/addon-bridge.service.ts +0 -130
  190. package/src/core/addon-pages/addon-pages.service.spec.ts +0 -117
  191. package/src/core/addon-pages/addon-pages.service.ts +0 -82
  192. package/src/core/addon-widgets/addon-widgets.service.ts +0 -95
  193. package/src/core/agent/agent-registry.service.ts +0 -529
  194. package/src/core/auth/auth.service.spec.ts +0 -86
  195. package/src/core/auth/auth.service.ts +0 -8
  196. package/src/core/capability/capability.service.ts +0 -66
  197. package/src/core/config/config.schema.ts +0 -3
  198. package/src/core/config/config.service.spec.ts +0 -175
  199. package/src/core/config/config.service.ts +0 -7
  200. package/src/core/events/event-bus.service.spec.ts +0 -235
  201. package/src/core/events/event-bus.service.ts +0 -89
  202. package/src/core/feature/feature.service.spec.ts +0 -99
  203. package/src/core/feature/feature.service.ts +0 -8
  204. package/src/core/lifecycle/lifecycle-state-machine.spec.ts +0 -166
  205. package/src/core/lifecycle/lifecycle-state-machine.ts +0 -3
  206. package/src/core/logging/log-ring-buffer.ts +0 -3
  207. package/src/core/logging/logging.service.spec.ts +0 -287
  208. package/src/core/logging/logging.service.ts +0 -143
  209. package/src/core/logging/scoped-logger.ts +0 -3
  210. package/src/core/moleculer/cap-call-fn.spec.ts +0 -173
  211. package/src/core/moleculer/cap-call-fn.ts +0 -107
  212. package/src/core/moleculer/cap-route-authority.ts +0 -194
  213. package/src/core/moleculer/moleculer.service.ts +0 -1072
  214. package/src/core/network/network-quality.service.spec.ts +0 -53
  215. package/src/core/network/network-quality.service.ts +0 -5
  216. package/src/core/notification/notification-wrapper.service.ts +0 -34
  217. package/src/core/notification/toast-wrapper.service.ts +0 -27
  218. package/src/core/provider/provider.tokens.ts +0 -1
  219. package/src/core/repl/repl-engine.service.spec.ts +0 -444
  220. package/src/core/repl/repl-engine.service.ts +0 -155
  221. package/src/core/storage/fs-storage-backend.spec.ts +0 -70
  222. package/src/core/storage/fs-storage-backend.ts +0 -3
  223. package/src/core/storage/storage-location-manager.spec.ts +0 -130
  224. package/src/core/storage/storage-location-manager.ts +0 -3
  225. package/src/core/storage/storage.service.spec.ts +0 -73
  226. package/src/core/storage/storage.service.ts +0 -3
  227. package/src/core/streaming/stream-probe.service.ts +0 -221
  228. package/src/core/topology/topology-emitter.service.ts +0 -105
  229. package/src/launcher.ts +0 -314
  230. package/src/main.ts +0 -1245
  231. package/src/manual-boot.ts +0 -301
  232. package/tsconfig.build.json +0 -8
  233. package/tsconfig.json +0 -33
  234. package/vitest.config.ts +0 -26
@@ -1,1072 +0,0 @@
1
- import { ServiceBroker, type Service, type ServiceSchema } from 'moleculer'
2
-
3
- /**
4
- * Narrow-typed view of the Moleculer surface this file actually uses.
5
- * Moleculer's published `.d.ts` chains through `eventemitter2` whose
6
- * package.json has no `types` field; typescript-eslint's `no-unsafe-*`
7
- * rules flag every `broker.X` access. Casting once at the boundary
8
- * collapses the rule violations into one documented narrowing.
9
- */
10
- interface BrokerLike {
11
- readonly nodeID: string
12
- readonly logger: Record<string, unknown>
13
- start(): Promise<void>
14
- stop(): Promise<void>
15
- createService(svc: Service | unknown): unknown
16
- call(action: string, params?: unknown, opts?: unknown): Promise<unknown>
17
- waitForServices(services: string[], timeout?: number): Promise<unknown>
18
- }
19
- import {
20
- createBroker,
21
- createHubService,
22
- createProcessService,
23
- isInfraCapability,
24
- registerEventBusService,
25
- createReadinessServiceForRegistry,
26
- createStreamProbeBrokerService,
27
- createHwAccelService,
28
- createKernelHwAccel,
29
- HubNodeRegistry,
30
- serializeTypedArrays,
31
- callWithServiceDiscovery,
32
- hashClusterSecret,
33
- LocalChildRegistry,
34
- createLocalTransport,
35
- localEndpointPath,
36
- CapRouteResolver,
37
- CapRouteError,
38
- capActionName,
39
- udsChildLogToWorkerEntry,
40
- createUdsEventBridge,
41
- createParentUnownedCallHandler,
42
- } from '@camstack/kernel'
43
- import type {
44
- HubServiceDeps,
45
- CallFn,
46
- RegisterNodeParams,
47
- RegisteredAddonManifest,
48
- ChildCapDescriptor,
49
- } from '@camstack/kernel'
50
- import { buildCapCallFn } from './cap-call-fn.js'
51
- import { createNodeCapAuthority, createInProcessProviderLookup } from './cap-route-authority.js'
52
- import { EventCategory, expandCapMethods, ReadinessRegistry, emitReadiness } from '@camstack/types'
53
- import type { CapabilityDefinition } from '@camstack/types'
54
- import { randomUUID } from 'node:crypto'
55
- import { EventBusService } from '../events/event-bus.service'
56
- import { ConfigService } from '../config/config.service'
57
- import { LoggingService } from '../logging/logging.service'
58
- import { CapabilityService } from '../capability/capability.service'
59
- import { StreamProbeService } from '../streaming/stream-probe.service'
60
-
61
- /**
62
- * Narrow-typed view of the Moleculer broker surface used by the
63
- * `$node.disconnected` listener — extends `BrokerLike` with `localBus`
64
- * so the handler can be fully typed (same pattern as agent-registry.service.ts).
65
- */
66
- interface BrokerWithLocalBus {
67
- localBus: {
68
- on(event: string, handler: (payload: { node: { id: string } }) => void): void
69
- }
70
- }
71
-
72
- /**
73
- * One `(addon, capability)` pair that a node manifest applies onto the
74
- * CapabilityRegistry, with its resolved `CapabilityDefinition`. Built by
75
- * `applyNodeManifest`'s diff so the register loop never re-resolves the
76
- * cap def. Keyed in the diff map by `` `${registryKey}::${capName}` ``.
77
- */
78
- interface AppliedCapEntry {
79
- readonly addonId: string
80
- readonly capName: string
81
- readonly capDef: CapabilityDefinition
82
- }
83
-
84
- export class MoleculerService {
85
- readonly broker: ServiceBroker
86
- /** Narrow-typed view of `this.broker` — see `BrokerLike` doc above. */
87
- private get brokerSafe(): BrokerLike {
88
- return this.broker as unknown as BrokerLike
89
- }
90
- private readonly logger: ReturnType<LoggingService['createLogger']>
91
- /**
92
- * D3 authority: union of every node's manifest delivered via
93
- * `$hub.registerNode`. Populated by `onRegisterNode` in `hubDeps`.
94
- */
95
- private readonly nodeRegistry = new HubNodeRegistry()
96
- private readonly nodeCallFns = new Map<string, CallFn>()
97
- /**
98
- * D3: callback invoked whenever a bare-ID agent node completes the
99
- * `$hub.registerNode` handshake. Registered by `AgentRegistryService`
100
- * via `setOnAgentRegistered()`. The handshake is the authoritative
101
- * completeness signal — the hub has the full manifest at this point
102
- * and reconciliation can run immediately (no grace delay needed).
103
- */
104
- private onAgentRegisteredCb: ((nodeId: string) => void) | null = null
105
- /**
106
- * Hub-side authoritative readiness registry. Subscribed to the
107
- * shared `EventBusService` so it ingests both hub-local emits and
108
- * remote emits forwarded via `$hub.event`. Exposed to:
109
- * - the `$readiness.getSnapshot` Moleculer action (consumed by
110
- * workers / agents on boot)
111
- * - `ctx.kernel.readinessRegistry` on every hub addon context so
112
- * hub consumers share the same snapshot.
113
- */
114
- readonly readinessRegistry: ReadinessRegistry
115
- /**
116
- * Resolved cluster secret (`CAMSTACK_CLUSTER_SECRET` env, else
117
- * `cluster.secret` config), or `undefined` when none is configured.
118
- * Threaded both into the broker factory and the hub's
119
- * `expectedClusterSecretHash` so `$hub.registerNode` can gate on it.
120
- */
121
- private readonly clusterSecret: string | undefined
122
- /**
123
- * UDS server that listens for addon-runners spawned by this hub node.
124
- * `null` when the UDS server failed to start (children run broker-only).
125
- */
126
- private localChildRegistry: LocalChildRegistry | null = null
127
- /**
128
- * Tracks cap names already logged as UDS-routed to avoid log spam.
129
- * Cleared on no external event — one INFO line per distinct capName
130
- * across the lifetime of the process.
131
- */
132
- private readonly udsRoutedCaps = new Set<string>()
133
- /**
134
- * CapRouteResolver — the single authority for cap dispatch routing.
135
- * Constructed at the end of onModuleInit, once `localChildRegistry` is
136
- * available and the broker has started. All cap dispatch flows through this.
137
- */
138
- private resolver: CapRouteResolver | null = null
139
- /**
140
- * Disposer returned by `createUdsEventBridge`. Called in `onModuleDestroy`
141
- * to unsubscribe the bridge from the parent bus and clear the child-event
142
- * handler, preventing subscriber leaks on shutdown.
143
- */
144
- private udsEventBridgeDispose: (() => void) | null = null
145
-
146
- get childRegistry(): LocalChildRegistry | null {
147
- return this.localChildRegistry
148
- }
149
-
150
- /** The CapRouteResolver once onModuleInit has completed; null before that. */
151
- get capRouteResolver(): CapRouteResolver | null {
152
- return this.resolver
153
- }
154
-
155
- /** This hub's Moleculer node id (e.g. `hub`). Hub-local forked children
156
- * register under `${nodeId}/${runnerId}`. */
157
- get nodeId(): string {
158
- return this.brokerSafe.nodeID
159
- }
160
-
161
- constructor(
162
- private readonly eventBus: EventBusService,
163
- private readonly config: ConfigService,
164
- private readonly logging: LoggingService,
165
- private readonly capabilityService: CapabilityService,
166
- private readonly streamProbe: StreamProbeService,
167
- ) {
168
- this.logger = this.logging.createLogger('moleculer')
169
- // Optional port overrides. Live primarily for the e2e harness: when
170
- // a developer's dev:full is up on the default 6000/4445 ports, an
171
- // isolated test hub can't reuse them. Production keeps the defaults
172
- // so cluster discovery + agent-to-hub connections keep working
173
- // without per-deploy config.
174
- const tcpPortEnv = process.env['CAMSTACK_HUB_TCP_PORT']
175
- const udpPortEnv = process.env['CAMSTACK_HUB_UDP_PORT']
176
- const tcpPort = tcpPortEnv ? Number(tcpPortEnv) : undefined
177
- const udpPort = udpPortEnv ? Number(udpPortEnv) : undefined
178
- // Two-step cast: createBroker's dist `.d.ts` chains through
179
- // moleculer→eventemitter2 whose types are unresolvable at this
180
- // boundary, so the inference falls to `error` and trips
181
- // `no-unsafe-assignment`. Going via `unknown` documents the boundary.
182
- this.clusterSecret =
183
- process.env['CAMSTACK_CLUSTER_SECRET'] ?? this.config.get<string>('cluster.secret')
184
- const broker = createBroker({
185
- nodeID: 'hub',
186
- mode: 'hub',
187
- logLevel: this.config.get<string>('moleculer.logLevel') ?? 'warn',
188
- secret: this.clusterSecret,
189
- ...(tcpPort && !Number.isNaN(tcpPort) ? { tcpPort } : {}),
190
- ...(udpPort && !Number.isNaN(udpPort) ? { udpPort } : {}),
191
- }) as unknown
192
- // `ServiceBroker` itself surfaces as `error`-typed at this boundary
193
- // (eventemitter2 chain unresolvable). Documented + single-site cast.
194
- // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
195
- this.broker = broker as ServiceBroker
196
- this.readinessRegistry = new ReadinessRegistry({
197
- eventBus: this.eventBus,
198
- sourceNodeId: this.brokerSafe.nodeID,
199
- logger: this.logging.createLogger('readiness'),
200
- })
201
- }
202
-
203
- /**
204
- * D3: register the callback that fires when an agent node completes the
205
- * `$hub.registerNode` handshake. Called by `AgentRegistryService` during
206
- * its own `onModuleInit` — after `MoleculerService.onModuleInit` has
207
- * returned, so the broker is already live. Option (b) direct-callback
208
- * wiring: no event-bus round-trip needed for internal core wiring.
209
- */
210
- setOnAgentRegistered(cb: (nodeId: string) => void): void {
211
- this.onAgentRegisteredCb = cb
212
- }
213
-
214
- async onModuleInit(): Promise<void> {
215
- const logger = this.logging.createLogger('moleculer')
216
-
217
- const hubDeps: HubServiceDeps = {
218
- getAddonConfig: (addonId) => {
219
- return this.config.getAddonConfig(addonId)
220
- },
221
- getSettings: (scope, key) => {
222
- return this.config.get(key ? `${scope}.${key}` : scope)
223
- },
224
- getRecentEvents: (category, limit) => {
225
- return this.eventBus.getRecent(category ? { category } : undefined, limit)
226
- },
227
- onLog: (entry) => {
228
- this.logging.writeFromWorker({
229
- addonId: entry.addonId,
230
- nodeId: entry.nodeId,
231
- level: entry.level,
232
- message: entry.message,
233
- ...(entry.scope !== undefined ? { scope: entry.scope } : {}),
234
- ...(entry.tags ? { tags: entry.tags } : {}),
235
- ...(entry.meta ? { meta: entry.meta } : {}),
236
- })
237
- },
238
- onSetLogLevel: (level) => {
239
- const factory = this.brokerSafe.logger
240
- const appenders = factory['appenders'] as Array<{ opts: { level: string } }> | undefined
241
- if (appenders) {
242
- for (const appender of appenders) {
243
- appender.opts.level = level
244
- }
245
- }
246
- const cache = factory['cache'] as Map<string, unknown> | undefined
247
- if (cache) {
248
- cache.clear()
249
- }
250
- logger.info('Moleculer log level changed', { meta: { level } })
251
- this.brokerSafe.call('$process.setLogLevel', { level }).catch(() => {})
252
- },
253
- // D3: registration-handshake path. Nodes send $hub.registerNode with
254
- // their complete capability manifest; the hub applies it immediately.
255
- onRegisterNode: (params) => {
256
- this.onRegisterNode(params)
257
- },
258
- onUnregisterNode: (nodeId) => {
259
- this.removeNodeFromRegistry(nodeId)
260
- },
261
- expectedClusterSecretHash: this.clusterSecret
262
- ? hashClusterSecret(this.clusterSecret)
263
- : undefined,
264
- }
265
-
266
- const hubService: unknown = createHubService(hubDeps)
267
- this.brokerSafe.createService(hubService)
268
-
269
- const dataDir = this.config.get<string>('dataDir') ?? 'camstack-data'
270
-
271
- // UDS local transport: the hub hosts a LocalChildRegistry so its
272
- // forked addon-runners route cap calls directly over a Unix-domain
273
- // socket instead of through Moleculer. The broker stays available as
274
- // the no-route fallback (remote agents + caps no local child owns).
275
- // If the registry fails to start, children transparently fall back to
276
- // broker-only — no parentUdsPath is propagated.
277
- let parentUdsPath: string | undefined
278
- try {
279
- const nodeId = this.brokerSafe.nodeID
280
- // F0 (slice-5 outbound): when a forked child issues `ctx.api.<cap>` for a
281
- // cap NO local sibling owns, route it from the PARENT and return the
282
- // result over UDS — resolver-first, broker-fallback. Closes over `this`
283
- // so it reads `this.resolver` at CALL time (the resolver is constructed
284
- // later in onModuleInit, after broker.start()). The broker fallback
285
- // reaches the hub's core `$`-infra services (`$core-caps`, `$stream-probe`,
286
- // settings-store, …) that are Moleculer services, NOT registered
287
- // capabilities the resolver can see. Before F0 this fell through
288
- // UDS_NO_ROUTE → the child's own brokerTransportLink; F1+F2 removes that
289
- // child broker, so the parent must own this path.
290
- const onUnownedCall = createParentUnownedCallHandler({
291
- getResolver: () => this.resolver,
292
- // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment -- moleculer types unresolvable; see `broker` getter docstring
293
- broker: this.broker,
294
- // Single capability authority — lets the broker fallback pin a
295
- // device-scoped call to its owning node instead of load-balancing it.
296
- nodeRegistry: this.nodeRegistry,
297
- // Hub-local UDS child dispatcher — routes a device-scoped native cap
298
- // owned by a hub-local child (reolink/hikvision cameras) directly over
299
- // UDS before any broker fallback. Getter: `this.localChildRegistry` is
300
- // assigned later in this method, after the handler is constructed.
301
- getLocalDispatcher: () => this.localChildRegistry,
302
- // Device-native signal for the registration-race recovery: a forked
303
- // child's device-native cap (e.g. `switch` on an export target) can be
304
- // briefly absent from the LocalChildRegistry just after a respawn. The
305
- // kernel layer has no cap registry, so feed it the `deviceNative` flag
306
- // from the cap definition. When true + binding-not-yet-registered, the
307
- // handler retries the hub-local route then throws a precise error rather
308
- // than the unroutable broker fallback (`switch.switch.getStatus`).
309
- isDeviceNativeCap: (capName) =>
310
- this.capabilityService.getRegistry()?.getDefinition(capName)?.deviceNative === true,
311
- logger: {
312
- warn: (msg, meta) =>
313
- logger.warn(
314
- msg,
315
- meta !== null && meta !== undefined
316
- ? { meta: meta as Record<string, unknown> }
317
- : undefined,
318
- ),
319
- },
320
- })
321
- const registry = new LocalChildRegistry({
322
- server: createLocalTransport().createServer(nodeId),
323
- onUnownedCall,
324
- logger: {
325
- info: (msg, meta) =>
326
- logger.info(
327
- msg,
328
- meta !== null && meta !== undefined
329
- ? { meta: meta as Record<string, unknown> }
330
- : undefined,
331
- ),
332
- },
333
- // Hand the UDS-routing layer a view into the operator's
334
- // active-singleton preference. Without this, when two local
335
- // children own the same singleton cap (e.g. two `webrtc-session`
336
- // providers), routing returns
337
- // the first-registered child by insertion order — silently
338
- // bypassing `setActiveSingleton`. The closure reads the live
339
- // registry on every call so a runtime swap takes effect
340
- // immediately without rebuilding the resolver snapshot.
341
- getActiveSingletonAddonId: (capName: string): string | null =>
342
- this.capabilityService.getRegistry()?.getSingletonAddonId(capName) ?? null,
343
- })
344
- await registry.start()
345
- // E1: apply child manifest + cleanup from the UDS lifecycle (hub-local children).
346
- // When a runner connects over UDS, apply its cap descriptors to the
347
- // CapabilityRegistry — same effect as the `$hub.registerNode` RPC for
348
- // `hub/<runner>` nodes. This runs in PARALLEL with the RPC path until
349
- // Phase F removes the child broker; `onRegisterNode`'s diff logic ensures
350
- // double-apply is idempotent (same nodeId + same caps → no-op on the second call).
351
- registry.onChildRegistered((child) => {
352
- const hubNodeId = this.brokerSafe.nodeID
353
- const nodeId = `${hubNodeId}/${child.childId}`
354
- const params = buildChildUdsManifest(nodeId, child.childId, child.caps)
355
- this.onRegisterNode(params)
356
- logger.info('UDS child registered — manifest applied', { meta: { nodeId } })
357
- })
358
- // E1: cleanup on child disconnect — same effect as `$node.disconnected`
359
- // for hub-local children. The Moleculer path stays for AGENT nodes.
360
- registry.onChildGone((childId) => {
361
- const hubNodeId = this.brokerSafe.nodeID
362
- const nodeId = `${hubNodeId}/${childId}`
363
- logger.info('UDS child gone — removing from registry', { meta: { childId } })
364
- this.removeNodeFromRegistry(nodeId)
365
- })
366
- // B2: ingest UDS child logs into the hub's LoggingService so they appear
367
- // in the LogManager / admin-UI log stream alongside broker-forwarded logs.
368
- // This runs in PARALLEL with the existing $hub.log / onLog broker path —
369
- // both stay active until Phase F removes the broker path.
370
- registry.onChildLog((childId, entry) => {
371
- this.logging.writeFromWorker(udsChildLogToWorkerEntry(childId, entry))
372
- })
373
- // D1: answer readiness-snapshot requests from UDS children so they can
374
- // hydrate without a `$readiness.getSnapshot` Moleculer call.
375
- // `this.readinessRegistry` is the hub-authoritative instance subscribed
376
- // to the shared EventBusService — same source `$readiness.getSnapshot` uses.
377
- // The handler is a live closure (calls `getSnapshotForTransport()` on each
378
- // request) so children always receive the current snapshot, not a stale copy.
379
- // Keep `$readiness.getSnapshot` intact — Phase F removes it.
380
- registry.onReadinessSnapshotRequest(() => this.readinessRegistry.getSnapshotForTransport())
381
- this.localChildRegistry = registry
382
- parentUdsPath = localEndpointPath(nodeId)
383
- logger.info('UDS child registry listening', { meta: { path: parentUdsPath } })
384
- } catch (err) {
385
- logger.warn('UDS child registry failed to start; children stay broker-only', {
386
- meta: { err: err instanceof Error ? err.message : String(err) },
387
- })
388
- }
389
-
390
- const processService: unknown = createProcessService(
391
- this.brokerSafe.nodeID,
392
- dataDir,
393
- undefined,
394
- undefined,
395
- parentUdsPath,
396
- )
397
- this.brokerSafe.createService(processService)
398
-
399
- // $addonHost — REMOVED (Sprint 6). Three-level settings are now
400
- // served by the `addon-settings` singleton capability. Per-addon
401
- // Moleculer services expose `settings.*` actions for remote agents.
402
-
403
- // D3: mirror $node.disconnected onto the registry path so nodes that
404
- // sent a $hub.registerNode manifest get cleaned up on disconnect.
405
- const bridgeBus = this.broker as unknown as BrokerWithLocalBus
406
- bridgeBus.localBus.on('$node.disconnected', ({ node }: { node: { id: string } }) => {
407
- this.removeNodeFromRegistry(node.id)
408
- })
409
-
410
- // Register the $event-bus service BEFORE broker.start(). Moleculer
411
- // announces service subscriptions to remote nodes only during discovery
412
- // handshake, not dynamically after post-start `createService()`.
413
- // Without this, cross-node broadcasts (camstack.evt.<category>) would
414
- // arrive unreliably for the first ~10s after each node joins.
415
- registerEventBusService(this.broker)
416
-
417
- // Register the hub-authoritative `$readiness.getSnapshot` service
418
- // BEFORE broker.start() so workers / agents see it in their initial
419
- // INFO packet — post-start `createService` calls propagate via
420
- // heartbeat (several seconds) and would force workers to poll.
421
- this.brokerSafe.createService(createReadinessServiceForRegistry(this.readinessRegistry))
422
-
423
- // Register the hub-authoritative `$stream-probe` service —
424
- // workers route RTSP probe + field-probe through this action when
425
- // the tRPC WSS link isn't available (default path for forked
426
- // workers). Keeps ffprobe + HTTP-reachability as a single
427
- // hub-side implementation; see `createStreamProbeBrokerService`
428
- // for the shape.
429
- this.brokerSafe.createService(
430
- createStreamProbeBrokerService({
431
- probe: (url, options) => this.streamProbe.probe(url, options),
432
- probeField: (key, value) => this.streamProbe.probeField(key, value),
433
- }),
434
- )
435
-
436
- // Register `$hwaccel` on hub — every node in the cluster does the
437
- // same so `broker.call('$hwaccel.resolve', params, { nodeID })`
438
- // returns the backend list for whichever host the caller targets.
439
- // Admin UI uses this to show per-agent hwaccel info on the
440
- // pipeline / NodeDetail pages.
441
- this.brokerSafe.createService(createHwAccelService(createKernelHwAccel()))
442
-
443
- await this.brokerSafe.start()
444
- logger.info('Moleculer broker started (TCP transport)')
445
-
446
- // Construct the CapRouteResolver now that both the broker and
447
- // localChildRegistry are ready. The resolver reads live registry state
448
- // via closure accessors on every call (not a frozen snapshot), so new
449
- // children connecting/disconnecting after this point are picked up
450
- // correctly. The localChildRegistry reference is captured and may be
451
- // null if UDS failed to start.
452
- this.resolver = new CapRouteResolver({
453
- hubNodeId: this.brokerSafe.nodeID,
454
- broker: this.brokerSafe,
455
- hubLocalRegistry: this.localChildRegistry,
456
- nodeAuthority: createNodeCapAuthority(this.nodeRegistry, {
457
- resolveSingleton: (capName, nodeId) =>
458
- this.capabilityService.getRegistry()?.resolveSingletonAddonIdForNode(capName, nodeId) ??
459
- null,
460
- }),
461
- inProcessProviders: createInProcessProviderLookup(this.capabilityService),
462
- })
463
-
464
- // Wire the hub's EventBusService into the broker so hub-addon
465
- // emissions fan out to every remote process via
466
- // `camstack.evt.<category>`, and incoming $event-bus events land on
467
- // the same local bus that subscribers already use. The
468
- // EventBusService's `emit` override handles the "only broadcast
469
- // locally-originated events" guard, and id-based dedup absorbs the
470
- // duplicate delivery when `createBrokerEventBus` on a remote uses
471
- // both broadcast + `$hub.event` for back-compat.
472
- this.eventBus.attachBroker(this.broker)
473
-
474
- // C2: wire the UDS ↔ Moleculer event bridge so events emitted by UDS
475
- // children fan to siblings and reach the cluster, and cluster / parent-
476
- // local events propagate to every UDS child. Inert when no children
477
- // are connected (bridge just adds a no-op bus subscriber). The bridge
478
- // is wired after attachBroker so the parentBus is backed by the real
479
- // shared broker bus and broker.broadcast is live.
480
- if (this.localChildRegistry !== null) {
481
- const hubNodeId = this.brokerSafe.nodeID
482
- this.udsEventBridgeDispose = createUdsEventBridge({
483
- registry: this.localChildRegistry,
484
- parentBus: this.eventBus,
485
- parentNodeId: hubNodeId,
486
- })
487
- }
488
- }
489
-
490
- /**
491
- * Register the log-receiver service for agent log forwarding.
492
- * Must be called AFTER app.init() so Moleculer re-advertises
493
- * the updated service list to the network.
494
- */
495
- registerLogReceiver(): void {
496
- this.brokerSafe.createService({
497
- name: 'log-receiver',
498
- actions: {
499
- ingest: {
500
- handler: (ctx: {
501
- params: {
502
- level: string
503
- message: string
504
- addonId: string
505
- nodeId: string
506
- scope?: string
507
- tags?: import('@camstack/types').LogTags
508
- meta?: Record<string, unknown>
509
- }
510
- }) => {
511
- this.logging.writeFromWorker({
512
- addonId: ctx.params.addonId,
513
- nodeId: ctx.params.nodeId,
514
- level: ctx.params.level,
515
- message: ctx.params.message,
516
- ...(ctx.params.scope !== undefined ? { scope: ctx.params.scope } : {}),
517
- ...(ctx.params.tags ? { tags: ctx.params.tags } : {}),
518
- ...(ctx.params.meta ? { meta: ctx.params.meta } : {}),
519
- })
520
- return true
521
- },
522
- },
523
- },
524
- })
525
- }
526
-
527
- /**
528
- * Register the `$core-caps` Moleculer service that bridges the hub's
529
- * core (non-addon) tRPC routers onto the cluster mesh.
530
- *
531
- * Called from `main.ts` after the appRouter is built — that happens
532
- * after `app.init()`, so the broker is already started. Post-start
533
- * `createService` is fine: the service propagates to remote nodes via
534
- * heartbeat and `brokerTransportLink` polls discovery, so forked
535
- * addons and late-joining agents still resolve `ctx.api.<coreCap>`.
536
- * `registerLogReceiver` relies on the same post-init registration.
537
- */
538
- registerCoreCapService(service: ServiceSchema): void {
539
- this.brokerSafe.createService(service)
540
- }
541
-
542
- /**
543
- * Call a capability method on a specific node.
544
- *
545
- * Delegates all routing decisions to the CapRouteResolver, which classifies
546
- * the (capName, nodeId) pair into a typed CapRoute and dispatches to the
547
- * appropriate transport (hub-in-process, hub-local-uds, remote-moleculer,
548
- * agent-child-forward). A genuinely-absent cap throws CapRouteError (typed,
549
- * with reason + rejected routes) instead of the old opaque error string.
550
- *
551
- * Falls back to the legacy findCallFn path when the resolver is not yet
552
- * constructed (before onModuleInit completes) or when the resolver throws a
553
- * no-provider / node-offline error for a node that IS in nodeCallFns — this
554
- * handles the window between registerNode applying a callFn and the resolver
555
- * seeing the new node (the resolver reads live registry state via closure
556
- * accessors, but nodeCallFns is populated by applyNodeManifest which may
557
- * have run before the resolver was constructed).
558
- */
559
- async callCapabilityOnNode(
560
- nodeId: string,
561
- capabilityName: string,
562
- methodName: string,
563
- params: unknown,
564
- ): Promise<unknown> {
565
- const resolver = this.resolver
566
- if (resolver !== null) {
567
- // Extract deviceId from params so device-scoped native caps (ptz, motion-zones, …)
568
- // resolve through the resolver's deviceId-aware snapshot instead of falling back to
569
- // the legacy callFn store. The deviceId hint is a number extracted from the method args.
570
- const rawDeviceId: unknown =
571
- params !== null && typeof params === 'object' ? Reflect.get(params, 'deviceId') : undefined
572
- const routeDeviceId: number | undefined =
573
- typeof rawDeviceId === 'number' ? rawDeviceId : undefined
574
- try {
575
- const route = resolver.resolveCapRoute(capabilityName, { nodeId, deviceId: routeDeviceId })
576
- return await resolver.dispatch(route, methodName, params)
577
- } catch (err) {
578
- if (
579
- err instanceof CapRouteError &&
580
- (err.reason === 'no-provider' || err.reason === 'node-offline')
581
- ) {
582
- // Resolver couldn't find the cap — try the legacy callFn store as a
583
- // fallback. This covers caps registered in nodeCallFns (e.g. agent
584
- // nodes that registered before the resolver's snapshot was built or
585
- // caps that the resolver's nodeAuthority doesn't see yet because the
586
- // resolver reads live registry state via closure accessors).
587
- // Device-scoped native caps now resolve via the resolver (M1/M5 thread deviceId),
588
- // so this fallback only handles genuinely-transitional stale-snapshot windows.
589
- const callFn = this.findCallFn(nodeId, capabilityName)
590
- if (callFn !== undefined) {
591
- return callFn(methodName, params)
592
- }
593
- }
594
- // Rethrow — includes transport-failed and all other errors
595
- throw err
596
- }
597
- }
598
-
599
- // Pre-init fallback (resolver not yet constructed — before onModuleInit).
600
- // This path is only reachable in tests that drive onRegisterNode without
601
- // calling onModuleInit first.
602
- if (nodeId === 'hub' || nodeId === this.brokerSafe.nodeID) {
603
- const registry = this.capabilityService.getRegistry()
604
- const provider = registry?.getSingleton<Record<string, unknown>>(capabilityName) ?? null
605
- if (provider !== null) {
606
- const fn = provider[methodName]
607
- if (typeof fn !== 'function')
608
- throw new Error(`Method "${methodName}" not found on "${capabilityName}"`)
609
- return fn.call(provider, params)
610
- }
611
- }
612
-
613
- const callFn = this.findCallFn(nodeId, capabilityName)
614
- if (callFn) {
615
- return callFn(methodName, params)
616
- }
617
-
618
- throw new CapRouteError(capabilityName, methodName, {
619
- reason: 'no-provider',
620
- nodeId,
621
- rejected: [
622
- { kind: 'hub-in-process', why: 'CapRouteResolver not initialised (pre-onModuleInit)' },
623
- ],
624
- })
625
- }
626
-
627
- /**
628
- * D3 registration-handshake entrypoint — invoked by the `$hub.registerNode`
629
- * Moleculer action (wired through `hubDeps.onRegisterNode`).
630
- *
631
- * Captures the node's PREVIOUS manifest before `nodeRegistry.registerNode`
632
- * overwrites it, then hands both manifests to `applyNodeManifest` so the
633
- * CapabilityRegistry update is a diff (atomic replace) rather than an
634
- * unconditional re-register — see `applyNodeManifest` for the rationale.
635
- */
636
- private onRegisterNode(params: RegisterNodeParams): void {
637
- const previousManifest = this.nodeRegistry.getNodeManifest(params.nodeId)
638
- this.nodeRegistry.registerNode(params)
639
- this.applyNodeManifest(params, previousManifest)
640
- // Notify AgentRegistryService to reconcile placement for bare-ID
641
- // agent nodes (no '/' = not a hub child worker, not the hub itself).
642
- // The handshake is the authoritative completeness signal — the full
643
- // manifest is available here so reconciliation runs without delay.
644
- const { nodeId } = params
645
- if (nodeId !== 'hub' && !nodeId.includes('/') && this.onAgentRegisteredCb) {
646
- this.onAgentRegisteredCb(nodeId)
647
- }
648
- }
649
-
650
- /**
651
- * D3: apply a node's registered manifest onto the CapabilityRegistry.
652
- * Builds a method proxy for each capability — same registryKey rule
653
- * (local child → bare addonId, remote agent → addonId@nodeId), same
654
- * `broker.call` routing shape, same `expandCapMethods` method surface.
655
- *
656
- * Called from `onRegisterNode` whenever a node handshakes.
657
- *
658
- * Diff-based / idempotent: the D3 protocol legitimately re-handshakes (a
659
- * node re-sends its COMPLETE manifest — e.g. the post-device-restore
660
- * `nativeCaps` re-handshake). `registerProvider` throws on a duplicate
661
- * `(cap, addonId)` pair, so a blind re-register would throw on every
662
- * re-handshake and trip the registering node's retry loop into a storm.
663
- * Instead this diffs the NEW manifest against `previousManifest`:
664
- * unchanged caps are left untouched, dropped caps are unregistered, new
665
- * caps are registered. This honours the invariant "`registerNode`
666
- * replaces the node's entire cap set atomically".
667
- *
668
- * NOTE: `params.nativeCaps` is stored by `nodeRegistry.registerNode()`
669
- * already; this method handles only `params.addons` (system caps).
670
- * Native-cap wiring into device-manager is done in a later task.
671
- */
672
- private applyNodeManifest(
673
- params: RegisterNodeParams,
674
- previousManifest?: readonly RegisteredAddonManifest[],
675
- ): void {
676
- const { nodeId, addons } = params
677
- const hubNodeId = this.brokerSafe.nodeID
678
- const isLocalChild = nodeId.startsWith(hubNodeId + '/')
679
- const isHubInProcess = nodeId === hubNodeId
680
-
681
- // Hub in-process addons register themselves during initialize() — skip.
682
- if (isHubInProcess) return
683
-
684
- const registry = this.capabilityService.getRegistry()
685
- if (!registry) return
686
-
687
- // Same registryKey rule as CapabilityBridge / onProviderConnected:
688
- // local child → bare addonId (one instance per forked child)
689
- // remote agent → addonId@nodeId (unique per agent node)
690
- // `nodeId` is identical for the previous and new manifest (same node
691
- // re-handshaking), so the rule resolves the same key on both sides.
692
- const registryKeyFor = (addonId: string): string =>
693
- isLocalChild ? addonId : `${addonId}@${nodeId}`
694
-
695
- // Collect the `(registryKey, capName)` pairs a manifest would APPLY —
696
- // applying the SAME `isInfraCapability` skip and `capDef` existence
697
- // check the register block below uses, so the set reflects exactly
698
- // what is (or would have been) registered. Keyed `${registryKey}::${capName}`.
699
- // The resolved `capDef` is carried through so the register loop never
700
- // re-looks it up (and never needs a non-null assertion).
701
- const appliedKeys = (
702
- manifest: readonly RegisteredAddonManifest[],
703
- ): Map<string, AppliedCapEntry> => {
704
- const keys = new Map<string, AppliedCapEntry>()
705
- for (const addon of manifest) {
706
- const registryKey = registryKeyFor(addon.addonId)
707
- for (const capName of addon.capabilities) {
708
- if (isInfraCapability(capName)) continue
709
- const capDef = registry.getDefinition(capName)
710
- if (!capDef) continue
711
- keys.set(`${registryKey}::${capName}`, { addonId: addon.addonId, capName, capDef })
712
- }
713
- }
714
- return keys
715
- }
716
-
717
- const desired = appliedKeys(addons)
718
- const previous = appliedKeys(previousManifest ?? [])
719
-
720
- // ── UNREGISTER ── caps the previous manifest applied but the new one
721
- // does not. Quiet — readiness is driven by `$node.connected/disconnected`,
722
- // not by a re-handshake, so no readiness events here.
723
- for (const [key, { addonId, capName }] of previous) {
724
- if (desired.has(key)) continue
725
- registry.unregisterProvider(capName, registryKeyFor(addonId))
726
- this.nodeCallFns.delete(`${nodeId}::${capName}`)
727
- }
728
-
729
- // ── REGISTER ── caps the new manifest applies that the previous one
730
- // did not. Caps present in BOTH sets are left untouched — zero churn,
731
- // no duplicate `registerProvider`, no spurious page/widget re-emit.
732
- for (const [key, { addonId, capName, capDef }] of desired) {
733
- if (previous.has(key)) continue
734
-
735
- const registryKey = registryKeyFor(addonId)
736
-
737
- // The runner id (= UDS childId) for a hub-local forked child is the
738
- // trailing segment of its nodeId `${hubNodeId}/${runnerId}`. Only
739
- // hub-local children are reachable over UDS; agent-hosted providers
740
- // (`<agent>/<runner>`) fall through to Moleculer.
741
- const udsChildId = isLocalChild ? nodeId.slice(hubNodeId.length + 1) : null
742
-
743
- // Per-(cap,node) dispatcher. Routing lives in the unit-tested
744
- // `buildCapCallFn` (see cap-call-fn.ts):
745
- // - hub-local child → per-child UDS (collection-safe; keyed by runner
746
- // id, never by capName which would collapse a COLLECTION cap onto
747
- // the first child). Fails fast if the child isn't providing — NEVER
748
- // a Moleculer fallback, since a hub-local child is not a Moleculer
749
- // service (a broker call would hang the full discovery timeout).
750
- // - agent-hosted / remote → the unified `CapRouteResolver`, which
751
- // classifies an agent node as `agent-child-forward` (hub→agent→UDS
752
- // child) and a direct remote as `remote-moleculer`. This closes the
753
- // UDS-migration gap where this dispatcher hand-rolled a `broker.call`
754
- // to an agent that exposes no Moleculer service for the cap.
755
- // - resolver not yet built (pre-init) → legacy Moleculer call.
756
- const callFn: CallFn = buildCapCallFn({
757
- capName,
758
- nodeId,
759
- udsChildId,
760
- getLocalChildRegistry: () => this.localChildRegistry,
761
- getResolver: () => this.resolver,
762
- legacyBrokerCall: (method, methodParams, targetNode) =>
763
- callWithServiceDiscovery(
764
- this.brokerSafe,
765
- addonId,
766
- capActionName(addonId, capName, method, false),
767
- serializeTypedArrays(methodParams),
768
- { nodeID: targetNode, timeout: 60_000 },
769
- ),
770
- onUdsRoute: (cap) => {
771
- if (!this.udsRoutedCaps.has(cap)) {
772
- this.udsRoutedCaps.add(cap)
773
- this.logger.info('routing cap over UDS', { meta: { capName: cap } })
774
- }
775
- },
776
- })
777
-
778
- const proxy: Record<string, unknown> = { id: addonId, nodeId }
779
- for (const methodName of Object.keys(expandCapMethods(capDef))) {
780
- proxy[methodName] = (methodParams: unknown) => callFn(methodName, methodParams)
781
- }
782
- registry.registerProvider(capName, registryKey, proxy)
783
-
784
- // Local-first singleton preference (UDS regression fix). A
785
- // `placement: 'any-node'` singleton (e.g. `pipeline-executor`) can
786
- // register on BOTH the hub-local forked child and a remote agent.
787
- // `CapabilityRegistry` keeps the FIRST-registered provider active, so a
788
- // race could leave the REMOTE agent proxy active — and its callFn routes
789
- // over Moleculer to a UDS-only agent runner that no longer hosts the
790
- // Moleculer service ("not found on <agent>"). The hub-local provider is
791
- // reachable over UDS, so prefer it whenever the current active is absent
792
- // or remote (`@`-keyed). Never steals from another local provider, so an
793
- // operator's binding choice (a bare-key local provider) is preserved.
794
- if (capDef.mode === 'singleton' && isLocalChild) {
795
- const activeKey = registry.getSingletonAddonId(capName)
796
- if (activeKey === null || activeKey.includes('@')) {
797
- registry.setSingletonActiveAddon(capName, registryKey)
798
- }
799
- }
800
-
801
- // Emit AddonPageReady / AddonWidgetReady so the admin-UI sidebar
802
- // refreshes its page/widget registry for cross-process addons.
803
- if (capName === 'addon-pages-source') {
804
- this.eventBus.emit({
805
- id: randomUUID(),
806
- timestamp: new Date(),
807
- source: { type: 'addon', id: addonId },
808
- category: EventCategory.AddonPageReady,
809
- data: { addonId, nodeId },
810
- })
811
- }
812
- if (capName === 'addon-widgets-source') {
813
- this.eventBus.emit({
814
- id: randomUUID(),
815
- timestamp: new Date(),
816
- source: { type: 'addon', id: addonId },
817
- category: EventCategory.AddonWidgetReady,
818
- data: { addonId, nodeId },
819
- })
820
- }
821
-
822
- // Store callFn so `callCapabilityOnNode` and `createCapabilityProxy`
823
- // can reach manifest-registered nodes.
824
- this.nodeCallFns.set(`${nodeId}::${capName}`, callFn)
825
- }
826
- }
827
-
828
- /**
829
- * D3: remove a node's manifest from the CapabilityRegistry on disconnect.
830
- * Unregisters every cap the node's last manifest declared and emits
831
- * synthetic readiness-down events for each.
832
- */
833
- private removeNodeFromRegistry(nodeId: string): void {
834
- const manifest = this.nodeRegistry.getNodeManifest(nodeId)
835
- if (!manifest) return // node never sent a handshake — nothing to do
836
-
837
- const hubNodeId = this.brokerSafe.nodeID
838
- const isLocalChild = nodeId.startsWith(hubNodeId + '/')
839
- const disconnectGen = `disconnect-${nodeId}-${randomUUID()}`
840
- const agentNodeId = nodeId.includes('/') ? nodeId.split('/')[0]! : nodeId
841
-
842
- const registry = this.capabilityService.getRegistry()
843
-
844
- for (const addon of manifest) {
845
- const { addonId, capabilities } = addon
846
- const registryKey = isLocalChild ? addonId : `${addonId}@${nodeId}`
847
-
848
- if (registry) {
849
- for (const capName of capabilities) {
850
- registry.unregisterProvider(capName, registryKey)
851
- }
852
- }
853
-
854
- for (const capName of capabilities) {
855
- this.nodeCallFns.delete(`${nodeId}::${capName}`)
856
-
857
- try {
858
- emitReadiness(this.eventBus, {
859
- capName,
860
- scope: { type: 'node', nodeId: agentNodeId },
861
- state: 'down',
862
- generation: disconnectGen,
863
- sourceNodeId: hubNodeId,
864
- })
865
- } catch (err) {
866
- this.logger.warn('Failed to emit synthetic readiness down', {
867
- tags: { addonId, nodeId },
868
- meta: { capName, err: err instanceof Error ? err.message : String(err) },
869
- })
870
- }
871
- }
872
- }
873
-
874
- this.nodeRegistry.removeNode(nodeId)
875
- }
876
-
877
- private findCallFn(nodeId: string, capabilityName: string): CallFn | undefined {
878
- // Exact match first — direct hit when the caller knows the full
879
- // nodeId (e.g. `hub/detection-pipeline`) or when the cap is hosted
880
- // on a bare top-level node (remote agent with in-process addons).
881
- const direct = this.nodeCallFns.get(`${nodeId}::${capabilityName}`)
882
- if (direct) return direct
883
- // Prefix fallback: forkable addons register under
884
- // `<parent>/<processName>` (e.g. `dev-agent-0/detection-pipeline`).
885
- // UI callers typically pass the bare parent nodeId (`dev-agent-0`)
886
- // because that's what they get from AgentOnline events and the
887
- // orchestrator assignments. Resolve by finding any registered node
888
- // whose id starts with `<nodeId>/` and hosts the cap.
889
- const prefix = `${nodeId}/`
890
- for (const key of this.nodeCallFns.keys()) {
891
- const sep = key.lastIndexOf('::')
892
- if (sep < 0) continue
893
- const keyNode = key.slice(0, sep)
894
- const keyCap = key.slice(sep + 2)
895
- if (keyCap !== capabilityName) continue
896
- if (keyNode.startsWith(prefix)) return this.nodeCallFns.get(key)
897
- }
898
- return undefined
899
- }
900
-
901
- /**
902
- * Returns true when a (nodeId, capabilityName) pair is reachable via the
903
- * legacy fallback paths: either a stored callFn in nodeCallFns, or a
904
- * hub-local forked child that is reachable over UDS (even without a
905
- * manifest callFn — e.g. device-scoped native caps). Used by
906
- * createCapabilityProxy to decide whether to build a proxy when the
907
- * CapRouteResolver cannot find a route.
908
- */
909
- private isReachableViaLegacy(nodeId: string, capabilityName: string): boolean {
910
- if (this.findCallFn(nodeId, capabilityName) !== undefined) return true
911
- return this.localChildRegistry !== null && nodeId.startsWith(`${this.brokerSafe.nodeID}/`)
912
- }
913
-
914
- /**
915
- * Build a proxy object that forwards every method call on a capability
916
- * to the correct transport via CapRouteResolver. Returns null if the
917
- * capability is provably not reachable on that node (resolver says no-provider
918
- * AND no legacy callFn exists AND it is not a hub-local child).
919
- *
920
- * Used by the generated cap routers when a request includes a `nodeId` field
921
- * for transparent node routing.
922
- *
923
- * Hub-local forked children (e.g. `hub/provider-reolink`) are reachable over
924
- * UDS even when no manifest callFn exists — device-scoped NATIVE caps (ptz,
925
- * motion-zones) aren't in `applyNodeManifest`'s callFn store. The proxy is
926
- * built unconditionally for hub-local children so `callCapabilityOnNode` can
927
- * route the actual method call via the resolver's hub-local-uds branch.
928
- */
929
- createCapabilityProxy(
930
- capabilityName: string,
931
- nodeId: string,
932
- ): Record<string, (params: unknown) => Promise<unknown>> | null {
933
- const resolver = this.resolver
934
- if (resolver !== null) {
935
- // Use the resolver to determine reachability. If it resolves a route, we
936
- // can build a proxy. If it throws no-provider but a legacy callFn exists
937
- // or the node is a hub-local child (for native caps), build the proxy anyway
938
- // because callCapabilityOnNode's fallback will handle it at dispatch time.
939
- try {
940
- resolver.resolveCapRoute(capabilityName, { nodeId })
941
- // Resolver found a route — proxy is reachable.
942
- } catch (err) {
943
- if (
944
- err instanceof CapRouteError &&
945
- (err.reason === 'no-provider' || err.reason === 'node-offline')
946
- ) {
947
- // Check legacy callFn store and hub-local child fallbacks.
948
- if (!this.isReachableViaLegacy(nodeId, capabilityName)) return null
949
- // Proxy reachable via fallback paths — fall through to build it.
950
- } else {
951
- throw err
952
- }
953
- }
954
- } else {
955
- // Pre-init: use legacy reachability check.
956
- if (!this.isReachableViaLegacy(nodeId, capabilityName)) return null
957
- }
958
-
959
- // Build a dynamic proxy: every property access returns a function that
960
- // routes the call through callCapabilityOnNode (which delegates to the resolver).
961
- return new Proxy<Record<string, (params: unknown) => Promise<unknown>>>(
962
- {},
963
- {
964
- get: (_target, methodName: string) => {
965
- return (params: unknown): Promise<unknown> =>
966
- this.callCapabilityOnNode(nodeId, capabilityName, methodName, params)
967
- },
968
- },
969
- )
970
- }
971
-
972
- /**
973
- * D3 handshake-fed native-cap view of the whole cluster.
974
- * Returns every `(nodeId, addonId, capName, deviceId)` tuple stored by
975
- * `onRegisterNode` — updated atomically each time a node re-handshakes
976
- * (e.g. after device restore completes). Used by `device-manager.addon.ts`
977
- * as a reliable fallback when push-based `DeviceBindingsChanged` events
978
- * were lost in the Moleculer transport handshake window.
979
- *
980
- * NOT a Moleculer action — only the hub process calls this directly
981
- * through the `ctx.kernel.listClusterNativeCaps` injection.
982
- */
983
- listClusterNativeCaps(): readonly import('@camstack/kernel').NodeNativeCapEntry[] {
984
- return this.nodeRegistry.listNativeCapEntries()
985
- }
986
-
987
- /**
988
- * Per-device slice of {@link listClusterNativeCaps}, served from the
989
- * registry's `deviceId → entries` index — O(caps-for-device). Used by the
990
- * per-device `getBindings` hot path so `getAllBindings` doesn't flatten the
991
- * whole cluster once per device.
992
- */
993
- listClusterNativeCapsForDevice(
994
- deviceId: number,
995
- ): readonly import('@camstack/kernel').NodeNativeCapEntry[] {
996
- return this.nodeRegistry.listNativeCapEntriesForDevice(deviceId)
997
- }
998
-
999
- /**
1000
- * E2: Send a `set-log-level` UDS message to a hub-local child identified by
1001
- * `nodeId` (e.g. `hub/provider-reolink`). Extracts the `childId` from the
1002
- * nodeId and delegates to `LocalChildRegistry.setChildLogLevel`.
1003
- *
1004
- * Returns `true` only if the nodeId is a hub-local child (`hub/<childId}`) AND
1005
- * the child is currently connected to the LocalChildRegistry (the UDS message
1006
- * was emitted). Returns `false` when the nodeId is not a hub-local child, the
1007
- * registry is absent, or the child is not yet/no longer connected — in all
1008
- * three cases the caller (setProcessLogLevel in cap-providers.ts) falls back
1009
- * to the Moleculer `$node-mgmt.setLogLevel` action.
1010
- */
1011
- setChildLogLevelByNodeId(nodeId: string, level: string): boolean {
1012
- const hubNodeId = this.brokerSafe.nodeID
1013
- if (!nodeId.startsWith(`${hubNodeId}/`)) return false
1014
- const childId = nodeId.slice(hubNodeId.length + 1)
1015
- const registry = this.localChildRegistry
1016
- if (registry === null) return false
1017
- return registry.setChildLogLevel(childId, level)
1018
- }
1019
-
1020
- async onModuleDestroy(): Promise<void> {
1021
- this.udsEventBridgeDispose?.()
1022
- this.udsEventBridgeDispose = null
1023
- await this.brokerSafe.stop()
1024
- await this.localChildRegistry?.close()
1025
- }
1026
- }
1027
-
1028
- // ---------------------------------------------------------------------------
1029
- // Module-level helpers
1030
- // ---------------------------------------------------------------------------
1031
-
1032
- /**
1033
- * E1: Adapt a child's UDS `ChildCapDescriptor[]` (which has no `addonId`) into
1034
- * a `RegisterNodeParams` that `onRegisterNode` / `applyNodeManifest` can consume.
1035
- *
1036
- * Strategy: use `childId` as the synthetic `addonId`. For currently-shipped addons
1037
- * `childId = runnerId = addonId` (one-addon-one-process, no shared group), so the
1038
- * `registryKey = childId` produced here matches what the Moleculer `$hub.registerNode`
1039
- * path uses — making double-apply via both paths fully idempotent.
1040
- *
1041
- * Singleton vs. device-scoped caps: `ChildCapDescriptor.deviceId` is present only
1042
- * for device-scoped native caps. The `addons` array carries system (singleton/collection)
1043
- * cap names; device-scoped native caps are handled separately via `nativeCaps` in the
1044
- * full `RegisterNodeParams`. For the parallel-window phase (E1), we populate only
1045
- * the `addons` portion — the Moleculer path carries `nativeCaps` on the re-handshake.
1046
- *
1047
- * TODO(co-location): This function synthesises ONE manifest entry with `addonId = childId`
1048
- * (the runner id). This is correct under the current one-addon-one-process invariant
1049
- * where `childId = runnerId = addonId`. If `execution.group` co-location is ever
1050
- * activated (multiple addons sharing one runner), a single runner would host multiple
1051
- * addonIds but this function would register all their caps under one synthetic addonId —
1052
- * collapsing distinct provider registryKeys into one and breaking per-addon routing.
1053
- * Multi-addon manifest support (splitting the `ChildCapDescriptor[]` by addonId once the
1054
- * protocol carries addonId) would be needed here before enabling co-location post-Phase-F.
1055
- */
1056
- export function buildChildUdsManifest(
1057
- nodeId: string,
1058
- childId: string,
1059
- caps: readonly ChildCapDescriptor[],
1060
- ): RegisterNodeParams {
1061
- // Collect unique system (non-device-scoped) cap names.
1062
- const systemCapNames = new Set<string>()
1063
- for (const cap of caps) {
1064
- if (cap.deviceId === undefined) {
1065
- systemCapNames.add(cap.capName)
1066
- }
1067
- }
1068
- const addons: readonly RegisteredAddonManifest[] = [
1069
- { addonId: childId, capabilities: [...systemCapNames] },
1070
- ]
1071
- return { nodeId, addons }
1072
- }