@camstack/server 0.1.3

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 (133) hide show
  1. package/.env.example +17 -0
  2. package/package.json +55 -0
  3. package/src/__tests__/addon-install-e2e.test.ts +75 -0
  4. package/src/__tests__/addon-pages-e2e.test.ts +178 -0
  5. package/src/__tests__/addon-route-session.test.ts +17 -0
  6. package/src/__tests__/addon-settings-router.spec.ts +62 -0
  7. package/src/__tests__/addon-upload.spec.ts +355 -0
  8. package/src/__tests__/agent-registry.spec.ts +162 -0
  9. package/src/__tests__/agent-status-page.spec.ts +84 -0
  10. package/src/__tests__/auth-session-cookie.test.ts +21 -0
  11. package/src/__tests__/cap-providers/cap-usage-graph.spec.ts +23 -0
  12. package/src/__tests__/cap-providers/compute-topology-categories.spec.ts +64 -0
  13. package/src/__tests__/cap-routers/_meta.spec.ts +200 -0
  14. package/src/__tests__/cap-routers/addon-settings.router.spec.ts +106 -0
  15. package/src/__tests__/cap-routers/device-manager-aggregate.router.spec.ts +142 -0
  16. package/src/__tests__/cap-routers/harness.ts +159 -0
  17. package/src/__tests__/cap-routers/metrics-provider.router.spec.ts +119 -0
  18. package/src/__tests__/cap-routers/null-provider-guard.spec.ts +66 -0
  19. package/src/__tests__/cap-routers/pipeline-executor.router.spec.ts +135 -0
  20. package/src/__tests__/cap-routers/settings-store.router.spec.ts +247 -0
  21. package/src/__tests__/capability-e2e.test.ts +386 -0
  22. package/src/__tests__/cli-e2e.test.ts +129 -0
  23. package/src/__tests__/core-cap-bridge.spec.ts +89 -0
  24. package/src/__tests__/embedded-deps-e2e.test.ts +109 -0
  25. package/src/__tests__/event-bus-proxy-router.spec.ts +72 -0
  26. package/src/__tests__/fixtures/mock-analysis-addon-a.ts +37 -0
  27. package/src/__tests__/fixtures/mock-analysis-addon-b.ts +37 -0
  28. package/src/__tests__/fixtures/mock-log-addon.ts +37 -0
  29. package/src/__tests__/fixtures/mock-storage-addon.ts +40 -0
  30. package/src/__tests__/framework-allowlist.spec.ts +95 -0
  31. package/src/__tests__/https-e2e.test.ts +118 -0
  32. package/src/__tests__/lifecycle-e2e.test.ts +140 -0
  33. package/src/__tests__/live-events-subscription.spec.ts +150 -0
  34. package/src/__tests__/moleculer-register-node-idempotency.spec.ts +229 -0
  35. package/src/__tests__/oauth2-account-linking.spec.ts +736 -0
  36. package/src/__tests__/post-boot-restart.spec.ts +161 -0
  37. package/src/__tests__/singleton-contention.test.ts +487 -0
  38. package/src/__tests__/streaming-diagnostic.test.ts +512 -0
  39. package/src/__tests__/streaming-scale.test.ts +280 -0
  40. package/src/agent-status-page.ts +121 -0
  41. package/src/api/__tests__/addons-custom.spec.ts +134 -0
  42. package/src/api/__tests__/capabilities.router.test.ts +47 -0
  43. package/src/api/addon-upload.ts +472 -0
  44. package/src/api/addons-custom.router.ts +100 -0
  45. package/src/api/auth-whoami.ts +99 -0
  46. package/src/api/bridge-addons.router.ts +120 -0
  47. package/src/api/capabilities.router.ts +226 -0
  48. package/src/api/core/__tests__/auth-router-totp.spec.ts +256 -0
  49. package/src/api/core/addon-settings.router.ts +124 -0
  50. package/src/api/core/agents.router.ts +87 -0
  51. package/src/api/core/auth.router.ts +303 -0
  52. package/src/api/core/cap-providers.ts +993 -0
  53. package/src/api/core/capabilities.router.ts +119 -0
  54. package/src/api/core/collection-preference.ts +40 -0
  55. package/src/api/core/event-bus-proxy.router.ts +45 -0
  56. package/src/api/core/hwaccel.router.ts +81 -0
  57. package/src/api/core/live-events.router.ts +60 -0
  58. package/src/api/core/logs.router.ts +162 -0
  59. package/src/api/core/notifications.router.ts +65 -0
  60. package/src/api/core/repl.router.ts +41 -0
  61. package/src/api/core/settings-backend.router.ts +142 -0
  62. package/src/api/core/stream-probe.router.ts +57 -0
  63. package/src/api/core/system-events.router.ts +116 -0
  64. package/src/api/health/health.routes.ts +123 -0
  65. package/src/api/oauth2/__tests__/oauth2-routes.spec.ts +52 -0
  66. package/src/api/oauth2/consent-page.ts +42 -0
  67. package/src/api/oauth2/oauth2-routes.ts +248 -0
  68. package/src/api/trpc/__tests__/scope-access-device.spec.ts +223 -0
  69. package/src/api/trpc/__tests__/scope-access.spec.ts +107 -0
  70. package/src/api/trpc/cap-mount-helpers.ts +225 -0
  71. package/src/api/trpc/core-cap-bridge.ts +152 -0
  72. package/src/api/trpc/generated-cap-mounts.ts +707 -0
  73. package/src/api/trpc/generated-cap-routers.ts +6340 -0
  74. package/src/api/trpc/scope-access.ts +110 -0
  75. package/src/api/trpc/trpc.context.ts +255 -0
  76. package/src/api/trpc/trpc.middleware.ts +140 -0
  77. package/src/api/trpc/trpc.router.ts +275 -0
  78. package/src/auth/session-cookie.ts +44 -0
  79. package/src/boot/boot-config.ts +278 -0
  80. package/src/boot/post-boot.service.ts +103 -0
  81. package/src/core/addon/__tests__/addon-registry-capability.test.ts +53 -0
  82. package/src/core/addon/addon-package.service.ts +1684 -0
  83. package/src/core/addon/addon-registry.service.ts +2926 -0
  84. package/src/core/addon/addon-search.service.ts +90 -0
  85. package/src/core/addon/addon-settings-provider.ts +276 -0
  86. package/src/core/addon/addon.tokens.ts +2 -0
  87. package/src/core/addon-bridge/addon-bridge.service.ts +125 -0
  88. package/src/core/addon-pages/addon-pages.service.spec.ts +117 -0
  89. package/src/core/addon-pages/addon-pages.service.ts +80 -0
  90. package/src/core/addon-widgets/addon-widgets.service.ts +92 -0
  91. package/src/core/agent/agent-registry.service.ts +507 -0
  92. package/src/core/auth/auth.service.spec.ts +88 -0
  93. package/src/core/auth/auth.service.ts +8 -0
  94. package/src/core/capability/capability.service.ts +57 -0
  95. package/src/core/config/config.schema.ts +3 -0
  96. package/src/core/config/config.service.spec.ts +175 -0
  97. package/src/core/config/config.service.ts +7 -0
  98. package/src/core/events/event-bus.service.spec.ts +212 -0
  99. package/src/core/events/event-bus.service.ts +85 -0
  100. package/src/core/feature/feature.service.spec.ts +96 -0
  101. package/src/core/feature/feature.service.ts +8 -0
  102. package/src/core/lifecycle/lifecycle-state-machine.spec.ts +168 -0
  103. package/src/core/lifecycle/lifecycle-state-machine.ts +3 -0
  104. package/src/core/logging/log-ring-buffer.ts +3 -0
  105. package/src/core/logging/logging.service.spec.ts +247 -0
  106. package/src/core/logging/logging.service.ts +129 -0
  107. package/src/core/logging/scoped-logger.ts +3 -0
  108. package/src/core/moleculer/moleculer.service.ts +612 -0
  109. package/src/core/network/network-quality.service.spec.ts +47 -0
  110. package/src/core/network/network-quality.service.ts +5 -0
  111. package/src/core/notification/notification-wrapper.service.ts +36 -0
  112. package/src/core/notification/toast-wrapper.service.ts +31 -0
  113. package/src/core/provider/provider.tokens.ts +1 -0
  114. package/src/core/repl/repl-engine.service.spec.ts +417 -0
  115. package/src/core/repl/repl-engine.service.ts +156 -0
  116. package/src/core/storage/fs-storage-backend.spec.ts +70 -0
  117. package/src/core/storage/fs-storage-backend.ts +3 -0
  118. package/src/core/storage/settings-store.spec.ts +213 -0
  119. package/src/core/storage/settings-store.ts +2 -0
  120. package/src/core/storage/sql-schema.spec.ts +140 -0
  121. package/src/core/storage/sql-schema.ts +3 -0
  122. package/src/core/storage/storage-location-manager.spec.ts +121 -0
  123. package/src/core/storage/storage-location-manager.ts +3 -0
  124. package/src/core/storage/storage.service.spec.ts +73 -0
  125. package/src/core/storage/storage.service.ts +3 -0
  126. package/src/core/streaming/stream-probe.service.ts +212 -0
  127. package/src/core/topology/topology-emitter.service.ts +101 -0
  128. package/src/launcher.ts +309 -0
  129. package/src/main.ts +1049 -0
  130. package/src/manual-boot.ts +322 -0
  131. package/tsconfig.build.json +8 -0
  132. package/tsconfig.json +21 -0
  133. package/vitest.config.ts +26 -0
@@ -0,0 +1,612 @@
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 { createBroker, createHubService, createProcessService, isInfraCapability, registerEventBusService, createReadinessServiceForRegistry, createStreamProbeBrokerService, createHwAccelService, createKernelHwAccel, HubNodeRegistry, serializeTypedArrays, callWithServiceDiscovery, hashClusterSecret } from '@camstack/kernel'
20
+ import type { HubServiceDeps, CallFn, RegisterNodeParams, RegisteredAddonManifest } from '@camstack/kernel'
21
+ import { EventCategory, expandCapMethods, ReadinessRegistry, emitReadiness } from '@camstack/types'
22
+ import type { CapabilityDefinition } from '@camstack/types'
23
+ import { randomUUID } from 'node:crypto'
24
+ import { EventBusService } from '../events/event-bus.service'
25
+ import { ConfigService } from '../config/config.service'
26
+ import { LoggingService } from '../logging/logging.service'
27
+ import { CapabilityService } from '../capability/capability.service'
28
+ import { StreamProbeService } from '../streaming/stream-probe.service'
29
+
30
+ /**
31
+ * Narrow-typed view of the Moleculer broker surface used by the
32
+ * `$node.disconnected` listener — extends `BrokerLike` with `localBus`
33
+ * so the handler can be fully typed (same pattern as agent-registry.service.ts).
34
+ */
35
+ interface BrokerWithLocalBus {
36
+ localBus: {
37
+ on(event: string, handler: (payload: { node: { id: string } }) => void): void
38
+ }
39
+ }
40
+
41
+ /**
42
+ * One `(addon, capability)` pair that a node manifest applies onto the
43
+ * CapabilityRegistry, with its resolved `CapabilityDefinition`. Built by
44
+ * `applyNodeManifest`'s diff so the register loop never re-resolves the
45
+ * cap def. Keyed in the diff map by `` `${registryKey}::${capName}` ``.
46
+ */
47
+ interface AppliedCapEntry {
48
+ readonly addonId: string
49
+ readonly capName: string
50
+ readonly capDef: CapabilityDefinition
51
+ }
52
+
53
+ export class MoleculerService {
54
+ readonly broker: ServiceBroker
55
+ /** Narrow-typed view of `this.broker` — see `BrokerLike` doc above. */
56
+ private get brokerSafe(): BrokerLike { return this.broker as unknown as BrokerLike }
57
+ private readonly logger: ReturnType<LoggingService['createLogger']>
58
+ /**
59
+ * D3 authority: union of every node's manifest delivered via
60
+ * `$hub.registerNode`. Populated by `onRegisterNode` in `hubDeps`.
61
+ */
62
+ private readonly nodeRegistry = new HubNodeRegistry()
63
+ private readonly nodeCallFns = new Map<string, CallFn>()
64
+ /**
65
+ * D3: callback invoked whenever a bare-ID agent node completes the
66
+ * `$hub.registerNode` handshake. Registered by `AgentRegistryService`
67
+ * via `setOnAgentRegistered()`. The handshake is the authoritative
68
+ * completeness signal — the hub has the full manifest at this point
69
+ * and reconciliation can run immediately (no grace delay needed).
70
+ */
71
+ private onAgentRegisteredCb: ((nodeId: string) => void) | null = null
72
+ /**
73
+ * Hub-side authoritative readiness registry. Subscribed to the
74
+ * shared `EventBusService` so it ingests both hub-local emits and
75
+ * remote emits forwarded via `$hub.event`. Exposed to:
76
+ * - the `$readiness.getSnapshot` Moleculer action (consumed by
77
+ * workers / agents on boot)
78
+ * - `ctx.kernel.readinessRegistry` on every hub addon context so
79
+ * hub consumers share the same snapshot.
80
+ */
81
+ readonly readinessRegistry: ReadinessRegistry
82
+ /**
83
+ * Resolved cluster secret (`CAMSTACK_CLUSTER_SECRET` env, else
84
+ * `cluster.secret` config), or `undefined` when none is configured.
85
+ * Threaded both into the broker factory and the hub's
86
+ * `expectedClusterSecretHash` so `$hub.registerNode` can gate on it.
87
+ */
88
+ private readonly clusterSecret: string | undefined
89
+
90
+ constructor(
91
+ private readonly eventBus: EventBusService,
92
+ private readonly config: ConfigService,
93
+ private readonly logging: LoggingService,
94
+ private readonly capabilityService: CapabilityService,
95
+ private readonly streamProbe: StreamProbeService,
96
+ ) {
97
+ this.logger = this.logging.createLogger('moleculer')
98
+ // Optional port overrides. Live primarily for the e2e harness: when
99
+ // a developer's dev:full is up on the default 6000/4445 ports, an
100
+ // isolated test hub can't reuse them. Production keeps the defaults
101
+ // so cluster discovery + agent-to-hub connections keep working
102
+ // without per-deploy config.
103
+ const tcpPortEnv = process.env['CAMSTACK_HUB_TCP_PORT']
104
+ const udpPortEnv = process.env['CAMSTACK_HUB_UDP_PORT']
105
+ const tcpPort = tcpPortEnv ? Number(tcpPortEnv) : undefined
106
+ const udpPort = udpPortEnv ? Number(udpPortEnv) : undefined
107
+ // Two-step cast: createBroker's dist `.d.ts` chains through
108
+ // moleculer→eventemitter2 whose types are unresolvable at this
109
+ // boundary, so the inference falls to `error` and trips
110
+ // `no-unsafe-assignment`. Going via `unknown` documents the boundary.
111
+ this.clusterSecret = process.env['CAMSTACK_CLUSTER_SECRET'] ?? this.config.get<string>('cluster.secret')
112
+ const broker = createBroker({
113
+ nodeID: 'hub',
114
+ mode: 'hub',
115
+ logLevel: this.config.get<string>('moleculer.logLevel') ?? 'warn',
116
+ secret: this.clusterSecret,
117
+ ...(tcpPort && !Number.isNaN(tcpPort) ? { tcpPort } : {}),
118
+ ...(udpPort && !Number.isNaN(udpPort) ? { udpPort } : {}),
119
+ }) as unknown
120
+ // `ServiceBroker` itself surfaces as `error`-typed at this boundary
121
+ // (eventemitter2 chain unresolvable). Documented + single-site cast.
122
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
123
+ this.broker = broker as ServiceBroker
124
+ this.readinessRegistry = new ReadinessRegistry({
125
+ eventBus: this.eventBus,
126
+ sourceNodeId: this.brokerSafe.nodeID,
127
+ logger: this.logging.createLogger('readiness'),
128
+ })
129
+ }
130
+
131
+ /**
132
+ * D3: register the callback that fires when an agent node completes the
133
+ * `$hub.registerNode` handshake. Called by `AgentRegistryService` during
134
+ * its own `onModuleInit` — after `MoleculerService.onModuleInit` has
135
+ * returned, so the broker is already live. Option (b) direct-callback
136
+ * wiring: no event-bus round-trip needed for internal core wiring.
137
+ */
138
+ setOnAgentRegistered(cb: (nodeId: string) => void): void {
139
+ this.onAgentRegisteredCb = cb
140
+ }
141
+
142
+ async onModuleInit(): Promise<void> {
143
+ const logger = this.logging.createLogger('moleculer')
144
+
145
+ const hubDeps: HubServiceDeps = {
146
+ getAddonConfig: (addonId) => {
147
+ return this.config.getAddonConfig(addonId)
148
+ },
149
+ getSettings: (scope, key) => {
150
+ return this.config.get(key ? `${scope}.${key}` : scope)
151
+ },
152
+ getRecentEvents: (category, limit) => {
153
+ return this.eventBus.getRecent(category ? { category } : undefined, limit)
154
+ },
155
+ onLog: (entry) => {
156
+ this.logging.writeFromWorker({
157
+ addonId: entry.addonId,
158
+ nodeId: entry.nodeId,
159
+ level: entry.level,
160
+ message: entry.message,
161
+ ...(entry.scope !== undefined ? { scope: entry.scope } : {}),
162
+ ...(entry.tags ? { tags: entry.tags } : {}),
163
+ ...(entry.meta ? { meta: entry.meta } : {}),
164
+ })
165
+ },
166
+ onSetLogLevel: (level) => {
167
+ const factory = this.brokerSafe.logger
168
+ const appenders = factory['appenders'] as Array<{ opts: { level: string } }> | undefined
169
+ if (appenders) {
170
+ for (const appender of appenders) {
171
+ appender.opts.level = level
172
+ }
173
+ }
174
+ const cache = factory['cache'] as Map<string, unknown> | undefined
175
+ if (cache) {
176
+ cache.clear()
177
+ }
178
+ logger.info('Moleculer log level changed', { meta: { level } })
179
+ this.brokerSafe.call('$process.setLogLevel', { level }).catch(() => {})
180
+ },
181
+ // D3: registration-handshake path. Nodes send $hub.registerNode with
182
+ // their complete capability manifest; the hub applies it immediately.
183
+ onRegisterNode: (params) => {
184
+ this.onRegisterNode(params)
185
+ },
186
+ onUnregisterNode: (nodeId) => {
187
+ this.removeNodeFromRegistry(nodeId)
188
+ },
189
+ expectedClusterSecretHash: this.clusterSecret ? hashClusterSecret(this.clusterSecret) : undefined,
190
+ }
191
+
192
+ const hubService: unknown = createHubService(hubDeps)
193
+ this.brokerSafe.createService(hubService)
194
+
195
+ const dataDir = this.config.get<string>('dataDir') ?? 'camstack-data'
196
+ const processService: unknown = createProcessService(this.brokerSafe.nodeID, dataDir)
197
+ this.brokerSafe.createService(processService)
198
+
199
+ // $addonHost — REMOVED (Sprint 6). Three-level settings are now
200
+ // served by the `addon-settings` singleton capability. Per-addon
201
+ // Moleculer services expose `settings.*` actions for remote agents.
202
+
203
+ // D3: mirror $node.disconnected onto the registry path so nodes that
204
+ // sent a $hub.registerNode manifest get cleaned up on disconnect.
205
+ const bridgeBus = this.broker as unknown as BrokerWithLocalBus
206
+ bridgeBus.localBus.on('$node.disconnected', ({ node }: { node: { id: string } }) => {
207
+ this.removeNodeFromRegistry(node.id)
208
+ })
209
+
210
+ // Register the $event-bus service BEFORE broker.start(). Moleculer
211
+ // announces service subscriptions to remote nodes only during discovery
212
+ // handshake, not dynamically after post-start `createService()`.
213
+ // Without this, cross-node broadcasts (camstack.evt.<category>) would
214
+ // arrive unreliably for the first ~10s after each node joins.
215
+ registerEventBusService(this.broker)
216
+
217
+ // Register the hub-authoritative `$readiness.getSnapshot` service
218
+ // BEFORE broker.start() so workers / agents see it in their initial
219
+ // INFO packet — post-start `createService` calls propagate via
220
+ // heartbeat (several seconds) and would force workers to poll.
221
+ this.brokerSafe.createService(createReadinessServiceForRegistry(this.readinessRegistry))
222
+
223
+ // Register the hub-authoritative `$stream-probe` service —
224
+ // workers route RTSP probe + field-probe through this action when
225
+ // the tRPC WSS link isn't available (default path for forked
226
+ // workers). Keeps ffprobe + HTTP-reachability as a single
227
+ // hub-side implementation; see `createStreamProbeBrokerService`
228
+ // for the shape.
229
+ this.brokerSafe.createService(createStreamProbeBrokerService({
230
+ probe: (url, options) => this.streamProbe.probe(url, options),
231
+ probeField: (key, value) => this.streamProbe.probeField(key, value),
232
+ }))
233
+
234
+ // Register `$hwaccel` on hub — every node in the cluster does the
235
+ // same so `broker.call('$hwaccel.resolve', params, { nodeID })`
236
+ // returns the backend list for whichever host the caller targets.
237
+ // Admin UI uses this to show per-agent hwaccel info on the
238
+ // pipeline / NodeDetail pages.
239
+ this.brokerSafe.createService(createHwAccelService(createKernelHwAccel()))
240
+
241
+ await this.brokerSafe.start()
242
+ logger.info('Moleculer broker started (TCP transport)')
243
+
244
+ // Wire the hub's EventBusService into the broker so hub-addon
245
+ // emissions fan out to every remote process via
246
+ // `camstack.evt.<category>`, and incoming $event-bus events land on
247
+ // the same local bus that subscribers already use. The
248
+ // EventBusService's `emit` override handles the "only broadcast
249
+ // locally-originated events" guard, and id-based dedup absorbs the
250
+ // duplicate delivery when `createBrokerEventBus` on a remote uses
251
+ // both broadcast + `$hub.event` for back-compat.
252
+ this.eventBus.attachBroker(this.broker)
253
+ }
254
+
255
+ /**
256
+ * Register the log-receiver service for agent log forwarding.
257
+ * Must be called AFTER app.init() so Moleculer re-advertises
258
+ * the updated service list to the network.
259
+ */
260
+ registerLogReceiver(): void {
261
+ this.brokerSafe.createService({
262
+ name: 'log-receiver',
263
+ actions: {
264
+ ingest: {
265
+ handler: (ctx: {
266
+ params: {
267
+ level: string
268
+ message: string
269
+ addonId: string
270
+ nodeId: string
271
+ scope?: string
272
+ tags?: import('@camstack/types').LogTags
273
+ meta?: Record<string, unknown>
274
+ }
275
+ }) => {
276
+ this.logging.writeFromWorker({
277
+ addonId: ctx.params.addonId,
278
+ nodeId: ctx.params.nodeId,
279
+ level: ctx.params.level,
280
+ message: ctx.params.message,
281
+ ...(ctx.params.scope !== undefined ? { scope: ctx.params.scope } : {}),
282
+ ...(ctx.params.tags ? { tags: ctx.params.tags } : {}),
283
+ ...(ctx.params.meta ? { meta: ctx.params.meta } : {}),
284
+ })
285
+ return true
286
+ },
287
+ },
288
+ },
289
+ })
290
+ }
291
+
292
+ /**
293
+ * Register the `$core-caps` Moleculer service that bridges the hub's
294
+ * core (non-addon) tRPC routers onto the cluster mesh.
295
+ *
296
+ * Called from `main.ts` after the appRouter is built — that happens
297
+ * after `app.init()`, so the broker is already started. Post-start
298
+ * `createService` is fine: the service propagates to remote nodes via
299
+ * heartbeat and `brokerTransportLink` polls discovery, so forked
300
+ * addons and late-joining agents still resolve `ctx.api.<coreCap>`.
301
+ * `registerLogReceiver` relies on the same post-init registration.
302
+ */
303
+ registerCoreCapService(service: ServiceSchema): void {
304
+ this.brokerSafe.createService(service)
305
+ }
306
+
307
+ /**
308
+ * Call a capability method on a specific node.
309
+ * Hub: calls the local provider directly (same process, no Moleculer overhead).
310
+ * Remote: routes through the stored Moleculer CallFn for that node.
311
+ */
312
+ async callCapabilityOnNode(
313
+ nodeId: string,
314
+ capabilityName: string,
315
+ methodName: string,
316
+ params: unknown,
317
+ ): Promise<unknown> {
318
+ // Hub — call local provider directly when registered on hub root.
319
+ // For caps hosted in a per-addon runner (e.g. 'hub/detection-pipeline'),
320
+ // the hub root registry has nothing; we then fall through to the
321
+ // remote findCallFn prefix lookup which routes to the subnode.
322
+ if (nodeId === 'hub' || nodeId === this.brokerSafe.nodeID) {
323
+ const registry = this.capabilityService.getRegistry()
324
+ const provider = registry?.getSingleton(capabilityName) as Record<string, unknown> | null
325
+ if (provider) {
326
+ const fn = provider[methodName]
327
+ if (typeof fn !== 'function') throw new Error(`Method "${methodName}" not found on "${capabilityName}"`)
328
+ return (fn as (p: unknown) => unknown).call(provider, params)
329
+ }
330
+ // Fall through to the prefix-fallback findCallFn below.
331
+ }
332
+
333
+ // Remote — find stored callFn. The closure already captures the
334
+ // FULL nodeId at registration time (e.g. `dev-agent-1/detection-pipeline`
335
+ // for forkable addons). Passing the caller's `nodeId` here as the
336
+ // 3rd arg would override the closure's capture with the bare parent
337
+ // prefix (`dev-agent-1`), which Moleculer doesn't know about → the
338
+ // call fails with "Service not found on '<prefix>' node". Omit it
339
+ // so the closure routes to the correct worker node.
340
+ const callFn = this.findCallFn(nodeId, capabilityName)
341
+ if (callFn) {
342
+ return callFn(methodName, params)
343
+ }
344
+
345
+ throw new Error(`Capability "${capabilityName}" not available on node "${nodeId}"`)
346
+ }
347
+
348
+ /**
349
+ * D3 registration-handshake entrypoint — invoked by the `$hub.registerNode`
350
+ * Moleculer action (wired through `hubDeps.onRegisterNode`).
351
+ *
352
+ * Captures the node's PREVIOUS manifest before `nodeRegistry.registerNode`
353
+ * overwrites it, then hands both manifests to `applyNodeManifest` so the
354
+ * CapabilityRegistry update is a diff (atomic replace) rather than an
355
+ * unconditional re-register — see `applyNodeManifest` for the rationale.
356
+ */
357
+ private onRegisterNode(params: RegisterNodeParams): void {
358
+ const previousManifest = this.nodeRegistry.getNodeManifest(params.nodeId)
359
+ this.nodeRegistry.registerNode(params)
360
+ this.applyNodeManifest(params, previousManifest)
361
+ // Notify AgentRegistryService to reconcile placement for bare-ID
362
+ // agent nodes (no '/' = not a hub child worker, not the hub itself).
363
+ // The handshake is the authoritative completeness signal — the full
364
+ // manifest is available here so reconciliation runs without delay.
365
+ const { nodeId } = params
366
+ if (nodeId !== 'hub' && !nodeId.includes('/') && this.onAgentRegisteredCb) {
367
+ this.onAgentRegisteredCb(nodeId)
368
+ }
369
+ }
370
+
371
+ /**
372
+ * D3: apply a node's registered manifest onto the CapabilityRegistry.
373
+ * Builds a method proxy for each capability — same registryKey rule
374
+ * (local child → bare addonId, remote agent → addonId@nodeId), same
375
+ * `broker.call` routing shape, same `expandCapMethods` method surface.
376
+ *
377
+ * Called from `onRegisterNode` whenever a node handshakes.
378
+ *
379
+ * Diff-based / idempotent: the D3 protocol legitimately re-handshakes (a
380
+ * node re-sends its COMPLETE manifest — e.g. the post-device-restore
381
+ * `nativeCaps` re-handshake). `registerProvider` throws on a duplicate
382
+ * `(cap, addonId)` pair, so a blind re-register would throw on every
383
+ * re-handshake and trip the registering node's retry loop into a storm.
384
+ * Instead this diffs the NEW manifest against `previousManifest`:
385
+ * unchanged caps are left untouched, dropped caps are unregistered, new
386
+ * caps are registered. This honours the invariant "`registerNode`
387
+ * replaces the node's entire cap set atomically".
388
+ *
389
+ * NOTE: `params.nativeCaps` is stored by `nodeRegistry.registerNode()`
390
+ * already; this method handles only `params.addons` (system caps).
391
+ * Native-cap wiring into device-manager is done in a later task.
392
+ */
393
+ private applyNodeManifest(params: RegisterNodeParams, previousManifest?: readonly RegisteredAddonManifest[]): void {
394
+ const { nodeId, addons } = params
395
+ const hubNodeId = this.brokerSafe.nodeID
396
+ const isLocalChild = nodeId.startsWith(hubNodeId + '/')
397
+ const isHubInProcess = nodeId === hubNodeId
398
+
399
+ // Hub in-process addons register themselves during initialize() — skip.
400
+ if (isHubInProcess) return
401
+
402
+ const registry = this.capabilityService.getRegistry()
403
+ if (!registry) return
404
+
405
+ // Same registryKey rule as CapabilityBridge / onProviderConnected:
406
+ // local child → bare addonId (one instance per forked child)
407
+ // remote agent → addonId@nodeId (unique per agent node)
408
+ // `nodeId` is identical for the previous and new manifest (same node
409
+ // re-handshaking), so the rule resolves the same key on both sides.
410
+ const registryKeyFor = (addonId: string): string =>
411
+ isLocalChild ? addonId : `${addonId}@${nodeId}`
412
+
413
+ // Collect the `(registryKey, capName)` pairs a manifest would APPLY —
414
+ // applying the SAME `isInfraCapability` skip and `capDef` existence
415
+ // check the register block below uses, so the set reflects exactly
416
+ // what is (or would have been) registered. Keyed `${registryKey}::${capName}`.
417
+ // The resolved `capDef` is carried through so the register loop never
418
+ // re-looks it up (and never needs a non-null assertion).
419
+ const appliedKeys = (manifest: readonly RegisteredAddonManifest[]): Map<string, AppliedCapEntry> => {
420
+ const keys = new Map<string, AppliedCapEntry>()
421
+ for (const addon of manifest) {
422
+ const registryKey = registryKeyFor(addon.addonId)
423
+ for (const capName of addon.capabilities) {
424
+ if (isInfraCapability(capName)) continue
425
+ const capDef = registry.getDefinition(capName)
426
+ if (!capDef) continue
427
+ keys.set(`${registryKey}::${capName}`, { addonId: addon.addonId, capName, capDef })
428
+ }
429
+ }
430
+ return keys
431
+ }
432
+
433
+ const desired = appliedKeys(addons)
434
+ const previous = appliedKeys(previousManifest ?? [])
435
+
436
+ // ── UNREGISTER ── caps the previous manifest applied but the new one
437
+ // does not. Quiet — readiness is driven by `$node.connected/disconnected`,
438
+ // not by a re-handshake, so no readiness events here.
439
+ for (const [key, { addonId, capName }] of previous) {
440
+ if (desired.has(key)) continue
441
+ registry.unregisterProvider(capName, registryKeyFor(addonId))
442
+ this.nodeCallFns.delete(`${nodeId}::${capName}`)
443
+ }
444
+
445
+ // ── REGISTER ── caps the new manifest applies that the previous one
446
+ // did not. Caps present in BOTH sets are left untouched — zero churn,
447
+ // no duplicate `registerProvider`, no spurious page/widget re-emit.
448
+ for (const [key, { addonId, capName, capDef }] of desired) {
449
+ if (previous.has(key)) continue
450
+
451
+ const registryKey = registryKeyFor(addonId)
452
+
453
+ // Build a callFn that routes through Moleculer, matching the shape
454
+ // produced by CapabilityBridge: service name = addonId, action path
455
+ // = `<addonId>.<capName>.<method>`, nodeID = the registering node.
456
+ const callFn: CallFn =
457
+ (method: string, methodParams: unknown, targetNodeId?: string) =>
458
+ callWithServiceDiscovery(
459
+ this.brokerSafe,
460
+ addonId,
461
+ `${addonId}.${capName}.${method}`,
462
+ serializeTypedArrays(methodParams),
463
+ { nodeID: targetNodeId ?? nodeId, timeout: 60_000 },
464
+ )
465
+
466
+ const proxy: Record<string, unknown> = { id: addonId, nodeId }
467
+ for (const methodName of Object.keys(expandCapMethods(capDef))) {
468
+ proxy[methodName] = (methodParams: unknown) => callFn(methodName, methodParams)
469
+ }
470
+ registry.registerProvider(capName, registryKey, proxy)
471
+
472
+ // Emit AddonPageReady / AddonWidgetReady so the admin-UI sidebar
473
+ // refreshes its page/widget registry for cross-process addons.
474
+ if (capName === 'addon-pages-source') {
475
+ this.eventBus.emit({
476
+ id: randomUUID(),
477
+ timestamp: new Date(),
478
+ source: { type: 'addon', id: addonId },
479
+ category: EventCategory.AddonPageReady,
480
+ data: { addonId, nodeId },
481
+ })
482
+ }
483
+ if (capName === 'addon-widgets-source') {
484
+ this.eventBus.emit({
485
+ id: randomUUID(),
486
+ timestamp: new Date(),
487
+ source: { type: 'addon', id: addonId },
488
+ category: EventCategory.AddonWidgetReady,
489
+ data: { addonId, nodeId },
490
+ })
491
+ }
492
+
493
+ // Store callFn so `callCapabilityOnNode` and `createCapabilityProxy`
494
+ // can reach manifest-registered nodes.
495
+ this.nodeCallFns.set(`${nodeId}::${capName}`, callFn)
496
+ }
497
+ }
498
+
499
+ /**
500
+ * D3: remove a node's manifest from the CapabilityRegistry on disconnect.
501
+ * Unregisters every cap the node's last manifest declared and emits
502
+ * synthetic readiness-down events for each.
503
+ */
504
+ private removeNodeFromRegistry(nodeId: string): void {
505
+ const manifest = this.nodeRegistry.getNodeManifest(nodeId)
506
+ if (!manifest) return // node never sent a handshake — nothing to do
507
+
508
+ const hubNodeId = this.brokerSafe.nodeID
509
+ const isLocalChild = nodeId.startsWith(hubNodeId + '/')
510
+ const disconnectGen = `disconnect-${nodeId}-${randomUUID()}`
511
+ const agentNodeId = nodeId.includes('/') ? nodeId.split('/')[0]! : nodeId
512
+
513
+ const registry = this.capabilityService.getRegistry()
514
+
515
+ for (const addon of manifest) {
516
+ const { addonId, capabilities } = addon
517
+ const registryKey = isLocalChild ? addonId : `${addonId}@${nodeId}`
518
+
519
+ if (registry) {
520
+ for (const capName of capabilities) {
521
+ registry.unregisterProvider(capName, registryKey)
522
+ }
523
+ }
524
+
525
+ for (const capName of capabilities) {
526
+ this.nodeCallFns.delete(`${nodeId}::${capName}`)
527
+
528
+ try {
529
+ emitReadiness(this.eventBus, {
530
+ capName,
531
+ scope: { type: 'node', nodeId: agentNodeId },
532
+ state: 'down',
533
+ generation: disconnectGen,
534
+ sourceNodeId: hubNodeId,
535
+ })
536
+ } catch (err) {
537
+ this.logger.warn('Failed to emit synthetic readiness down', {
538
+ tags: { addonId, nodeId },
539
+ meta: { capName, err: err instanceof Error ? err.message : String(err) },
540
+ })
541
+ }
542
+ }
543
+ }
544
+
545
+ this.nodeRegistry.removeNode(nodeId)
546
+ }
547
+
548
+ private findCallFn(nodeId: string, capabilityName: string): CallFn | undefined {
549
+ // Exact match first — direct hit when the caller knows the full
550
+ // nodeId (e.g. `hub/detection-pipeline`) or when the cap is hosted
551
+ // on a bare top-level node (remote agent with in-process addons).
552
+ const direct = this.nodeCallFns.get(`${nodeId}::${capabilityName}`)
553
+ if (direct) return direct
554
+ // Prefix fallback: forkable addons register under
555
+ // `<parent>/<processName>` (e.g. `dev-agent-0/detection-pipeline`).
556
+ // UI callers typically pass the bare parent nodeId (`dev-agent-0`)
557
+ // because that's what they get from AgentOnline events and the
558
+ // orchestrator assignments. Resolve by finding any registered node
559
+ // whose id starts with `<nodeId>/` and hosts the cap.
560
+ const prefix = `${nodeId}/`
561
+ for (const key of this.nodeCallFns.keys()) {
562
+ const sep = key.lastIndexOf('::')
563
+ if (sep < 0) continue
564
+ const keyNode = key.slice(0, sep)
565
+ const keyCap = key.slice(sep + 2)
566
+ if (keyCap !== capabilityName) continue
567
+ if (keyNode.startsWith(prefix)) return this.nodeCallFns.get(key)
568
+ }
569
+ return undefined
570
+ }
571
+
572
+
573
+ /**
574
+ * Build a proxy object that forwards every method call on a capability
575
+ * to a remote node via Moleculer. Returns null if the capability is not
576
+ * reachable on that node. Used by the generated cap routers when a
577
+ * request includes a `nodeId` field for transparent node routing.
578
+ */
579
+ createCapabilityProxy(capabilityName: string, nodeId: string): Record<string, (params: unknown) => Promise<unknown>> | null {
580
+ // Check if we can reach this capability on the target node
581
+ const callFn = this.findCallFn(nodeId, capabilityName)
582
+ if (!callFn) return null
583
+
584
+ // Build a dynamic proxy: every property access returns a function that
585
+ // calls the remote capability method via the stored callFn.
586
+ return new Proxy<Record<string, (params: unknown) => Promise<unknown>>>({}, {
587
+ get: (_target, methodName: string) => {
588
+ return (params: unknown): Promise<unknown> =>
589
+ this.callCapabilityOnNode(nodeId, capabilityName, methodName, params)
590
+ },
591
+ })
592
+ }
593
+
594
+ /**
595
+ * D3 handshake-fed native-cap view of the whole cluster.
596
+ * Returns every `(nodeId, addonId, capName, deviceId)` tuple stored by
597
+ * `onRegisterNode` — updated atomically each time a node re-handshakes
598
+ * (e.g. after device restore completes). Used by `device-manager.addon.ts`
599
+ * as a reliable fallback when push-based `DeviceBindingsChanged` events
600
+ * were lost in the Moleculer transport handshake window.
601
+ *
602
+ * NOT a Moleculer action — only the hub process calls this directly
603
+ * through the `ctx.kernel.listClusterNativeCaps` injection.
604
+ */
605
+ listClusterNativeCaps(): readonly import('@camstack/kernel').NodeNativeCapEntry[] {
606
+ return this.nodeRegistry.listNativeCapEntries()
607
+ }
608
+
609
+ async onModuleDestroy(): Promise<void> {
610
+ await this.brokerSafe.stop()
611
+ }
612
+ }
@@ -0,0 +1,47 @@
1
+ import { describe, it, expect, beforeEach } from 'vitest'
2
+ import { NetworkQualityService } from './network-quality.service'
3
+
4
+ describe('NetworkQualityService', () => {
5
+ let service: NetworkQualityService
6
+
7
+ beforeEach(() => {
8
+ service = new NetworkQualityService()
9
+ })
10
+
11
+ it('should return null for unknown device', () => {
12
+ expect(service.getDeviceStats(999)).toBeNull()
13
+ })
14
+
15
+ it('should track stream bitrate with rolling average', () => {
16
+ service.reportStreamStats(1, 'main', 8000)
17
+ service.reportStreamStats(1, 'main', 10000)
18
+ service.reportStreamStats(1, 'main', 9000)
19
+
20
+ const stats = service.getDeviceStats(1)
21
+ expect(stats).not.toBeNull()
22
+ expect(stats!.streams['main']!.observedBitrateKbps).toBe(9000)
23
+ expect(stats!.streams['main']!.peakBitrateKbps).toBe(10000)
24
+ })
25
+
26
+ it('should track packet loss', () => {
27
+ service.reportStreamStats(1, 'main', 8000, 0.5)
28
+ service.reportStreamStats(1, 'main', 8000, 1.5)
29
+
30
+ const stats = service.getDeviceStats(1)
31
+ expect(stats!.streams['main']!.packetLossPercent).toBe(1.0)
32
+ })
33
+
34
+ it('should track client stats', () => {
35
+ service.reportClientStats(1, { rttMs: 50, jitterMs: 5, estimatedBandwidthKbps: 20000 })
36
+ const stats = service.getDeviceStats(1)
37
+ expect(stats!.client?.rttMs).toBe(50)
38
+ expect(stats!.client?.estimatedBandwidthKbps).toBe(20000)
39
+ })
40
+
41
+ it('should list all device stats', () => {
42
+ service.reportStreamStats(1, 'main', 8000)
43
+ service.reportStreamStats(2, 'sub', 2000)
44
+ const all = service.getAllStats()
45
+ expect(all).toHaveLength(2)
46
+ })
47
+ })
@@ -0,0 +1,5 @@
1
+ import { NetworkQualityTracker } from '@camstack/core'
2
+
3
+ export class NetworkQualityService extends NetworkQualityTracker {
4
+ // NestJS DI wrapper — delegates entirely to core NetworkQualityTracker
5
+ }