@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,95 +0,0 @@
1
- import * as path from 'node:path'
2
- import * as fs from 'node:fs'
3
- import { LoggingService } from '../logging/logging.service'
4
- import { CapabilityService } from '../capability/capability.service'
5
- import { AddonRegistryService } from '../addon/addon-registry.service'
6
- import type { IScopedLogger, IAddonWidgetsSourceProvider } from '@camstack/types'
7
-
8
- /**
9
- * Strip the `@<nodeId>` suffix the CapabilityBridge appends to
10
- * collection-provider registry keys for cross-node addons. The widget
11
- * static-file route always receives the bare manifest id.
12
- */
13
- function stripNodeSuffix(registryKey: string): string {
14
- const at = registryKey.indexOf('@')
15
- return at === -1 ? registryKey : registryKey.slice(0, at)
16
- }
17
-
18
- /**
19
- * AddonWidgetsService — server-side helper that backs the static file
20
- * route `/api/addon-widgets/:addonId/*` (registered in `main.ts`).
21
- *
22
- * Mirrors `AddonPagesService` exactly. The public listing surface
23
- * (`addonWidgets.listWidgets` on the AppRouter) lives in the
24
- * `addon-widgets-aggregator` builtin which walks every
25
- * `addon-widgets-source` collection provider and stamps versioned
26
- * `bundleUrl`s. Both ends flow through codegen.
27
- *
28
- * What stays here: filesystem path resolution + traversal protection
29
- * for the static file route. The registered-provider check uses
30
- * `CapabilityService` (so unknown / unregistered addons can't be
31
- * probed via path traversal tricks); the on-disk path comes from
32
- * `AddonRegistryService.getAddonInstallPath()` so bundled addons
33
- * (where one npm package ships multiple addon entries under
34
- * `dist/<entryId>/`) resolve to the right sub-folder instead of the
35
- * pre-bundle `addons/@camstack/addon-<id>/dist/` layout.
36
- */
37
- export class AddonWidgetsService {
38
- private readonly logger: IScopedLogger
39
-
40
- constructor(
41
- private readonly loggingService: LoggingService,
42
- private readonly caps: CapabilityService,
43
- private readonly registry: AddonRegistryService,
44
- ) {
45
- this.logger = this.loggingService.createLogger('AddonWidgetsService')
46
- }
47
-
48
- /**
49
- * Resolve the filesystem path to an addon's widget bundle file.
50
- * Returns null if the addon is not registered, the file doesn't exist,
51
- * or the path would escape the addon directory (path traversal protection).
52
- */
53
- resolveBundle(addonId: string, filePath: string): string | null {
54
- // The collection cap doesn't carry an `id` on its provider, so we
55
- // attribute via `getCollectionEntries` (returns `[addonId, provider]`).
56
- //
57
- // For cross-node addons the CapabilityBridge registers the provider
58
- // under a node-qualified key `<addonId>@<nodeId>` (see
59
- // `moleculer.service.ts`). The bundle is hub-resident and keyed by
60
- // the bare manifest id, so the route always carries the bare
61
- // `addonId` — match it against each registry key with the
62
- // `@<nodeId>` suffix stripped.
63
- const entries =
64
- this.caps.getCollectionEntries<IAddonWidgetsSourceProvider>('addon-widgets-source')
65
- const isRegistered = entries.some(([id]) => stripNodeSuffix(id) === addonId)
66
- if (!isRegistered) {
67
- this.logger.warn('Bundle resolve failed: addon not registered as widget provider', {
68
- tags: { addonId },
69
- })
70
- return null
71
- }
72
-
73
- const installPath = this.registry.getAddonInstallPath(addonId)
74
- if (!installPath) {
75
- this.logger.warn('Bundle resolve failed: addon install path not found', { tags: { addonId } })
76
- return null
77
- }
78
-
79
- const resolvedBase = path.resolve(installPath.distDir)
80
- const resolvedFile = path.resolve(installPath.distDir, filePath)
81
-
82
- // Path traversal protection
83
- if (!resolvedFile.startsWith(resolvedBase + path.sep) && resolvedFile !== resolvedBase) {
84
- this.logger.warn('Path traversal denied for addon', { tags: { addonId }, meta: { filePath } })
85
- return null
86
- }
87
-
88
- if (!fs.existsSync(resolvedFile)) {
89
- this.logger.debug('Bundle file not found', { meta: { resolvedFile } })
90
- return null
91
- }
92
-
93
- return resolvedFile
94
- }
95
- }
@@ -1,529 +0,0 @@
1
- import { randomUUID } from 'node:crypto'
2
- import * as os from 'node:os'
3
- import { EventCategory, resolveAddonPlacement } from '@camstack/types'
4
- import type {
5
- AgentListItem,
6
- CameraRoleAssignment,
7
- AgentCapability,
8
- IMetricsProvider,
9
- } from '@camstack/types'
10
- import { EventBusService } from '../events/event-bus.service'
11
- import { MoleculerService } from '../moleculer/moleculer.service'
12
- import { CapabilityService } from '../capability/capability.service'
13
- import type { AddonRegistryService } from '../addon/addon-registry.service'
14
-
15
- /** Per-call timeout for `$agent.*` RPC during reconciliation. */
16
- const AGENT_RECONCILE_RPC_TIMEOUT_MS = 8_000
17
-
18
- /** Shape of one addon entry in the `$agent.status` response. */
19
- interface AgentStatusAddon {
20
- readonly id: string
21
- readonly status?: string
22
- readonly version?: string
23
- readonly packageName?: string
24
- }
25
-
26
- /**
27
- * Narrow typed view of the Moleculer broker — its own `.d.ts` chains
28
- * through eventemitter2 and resolves to `error`, tripping the
29
- * `no-unsafe-*` rules. One documented cast through this interface
30
- * (see the `broker` getter) keeps every call site type-safe.
31
- */
32
- interface ReconcileBrokerLike {
33
- call(
34
- action: string,
35
- params?: Record<string, unknown>,
36
- options?: { nodeID?: string; timeout?: number },
37
- ): Promise<unknown>
38
- localBus: {
39
- on(event: string, handler: (payload: { node: { id: string } }) => void): void
40
- }
41
- }
42
-
43
- export class AgentRegistryService {
44
- // Role assignments: key = `${cameraId}:${role}`
45
- private readonly assignments: Map<string, CameraRoleAssignment> = new Map()
46
- private readonly bootTimestamp = Date.now()
47
-
48
- /**
49
- * Hub addon registry — the source of truth for which addons are
50
- * installed and their `execution.placement`. Injected via
51
- * `setAddonRegistry()` rather than the constructor because
52
- * `AddonRegistryService` is built AFTER this service in the boot
53
- * order (see `manual-boot.ts`).
54
- */
55
- private addonRegistry: AddonRegistryService | null = null
56
-
57
- constructor(
58
- private readonly eventBus: EventBusService,
59
- private readonly moleculer: MoleculerService,
60
- private readonly capabilityService: CapabilityService,
61
- ) {}
62
-
63
- /** Wire the hub addon registry once it has been constructed. */
64
- setAddonRegistry(addonRegistry: AddonRegistryService): void {
65
- this.addonRegistry = addonRegistry
66
- }
67
-
68
- /** Typed view of the Moleculer broker — single documented cast. */
69
- private get broker(): ReconcileBrokerLike {
70
- return this.moleculer.broker as unknown as ReconcileBrokerLike
71
- }
72
-
73
- onModuleInit(): void {
74
- const classifyNode = (id: string): 'agent' | 'worker' | null => {
75
- if (id === 'hub') return null
76
- if (id.includes('/')) return 'worker'
77
- return 'agent'
78
- }
79
-
80
- // D3: reconcile placement when an agent completes the $hub.registerNode
81
- // handshake — the manifest is authoritative and complete at that point,
82
- // no grace delay needed. MoleculerService fires this callback from its
83
- // onRegisterNode dep for every bare-ID agent node.
84
- this.moleculer.setOnAgentRegistered((agentId) => {
85
- void this.reconcileAgentAddons(agentId)
86
- })
87
-
88
- this.broker.localBus.on('$node.connected', ({ node }: { node: { id: string } }) => {
89
- const kind = classifyNode(node.id)
90
- if (!kind) return
91
- if (kind === 'agent') {
92
- console.log(`[agent-registry] Agent connected: ${node.id}`)
93
- this.eventBus.emit({
94
- id: randomUUID(),
95
- timestamp: new Date(),
96
- source: { type: 'core', id: 'agent-registry' },
97
- category: EventCategory.AgentOnline,
98
- data: { agentId: node.id },
99
- })
100
- } else {
101
- this.eventBus.emit({
102
- id: randomUUID(),
103
- timestamp: new Date(),
104
- source: { type: 'core', id: 'agent-registry' },
105
- category: EventCategory.WorkerOnline,
106
- data: { workerId: node.id },
107
- })
108
- }
109
- })
110
-
111
- this.broker.localBus.on('$node.disconnected', ({ node }: { node: { id: string } }) => {
112
- const kind = classifyNode(node.id)
113
- if (!kind) return
114
- if (kind === 'agent') {
115
- console.log(`[agent-registry] Agent disconnected: ${node.id}`)
116
- this.eventBus.emit({
117
- id: randomUUID(),
118
- timestamp: new Date(),
119
- source: { type: 'core', id: 'agent-registry' },
120
- category: EventCategory.AgentOffline,
121
- data: { agentId: node.id },
122
- })
123
- } else {
124
- this.eventBus.emit({
125
- id: randomUUID(),
126
- timestamp: new Date(),
127
- source: { type: 'core', id: 'agent-registry' },
128
- category: EventCategory.WorkerOffline,
129
- data: { workerId: node.id },
130
- })
131
- }
132
- })
133
-
134
- // Retroactive scan: emit lifecycle events for nodes already
135
- // connected when the listener registers. Classifies each as
136
- // agent or worker — same logic as the live handlers above.
137
- try {
138
- const registry = (this.moleculer.broker as unknown as Record<string, unknown>).registry as
139
- | { getNodeList?: (opts: { onlyAvailable: boolean }) => readonly { id: string }[] }
140
- | undefined
141
- const existing = registry?.getNodeList?.({ onlyAvailable: true }) ?? []
142
- for (const { id } of existing) {
143
- const kind = classifyNode(id)
144
- if (!kind) continue
145
- if (kind === 'agent') {
146
- this.eventBus.emit({
147
- id: randomUUID(),
148
- timestamp: new Date(),
149
- source: { type: 'core', id: 'agent-registry' },
150
- category: EventCategory.AgentOnline,
151
- data: { agentId: id },
152
- })
153
- } else {
154
- this.eventBus.emit({
155
- id: randomUUID(),
156
- timestamp: new Date(),
157
- source: { type: 'core', id: 'agent-registry' },
158
- category: EventCategory.WorkerOnline,
159
- data: { workerId: id },
160
- })
161
- }
162
- }
163
- } catch {
164
- // Registry shape varies across Moleculer versions — never fatal.
165
- }
166
- }
167
-
168
- // ---- Placement reconciliation ----
169
-
170
- /**
171
- * Boot-time reconciliation pass. Agents already connected when the hub
172
- * starts never fire a fresh `$node.connected` event, so the per-connect
173
- * trigger would miss them — this method walks the current node list once
174
- * and reconciles every connected agent. Must be invoked AFTER
175
- * `AddonRegistryService.onModuleInit()` so the hub's installed-addon set
176
- * is populated. Per-agent failures are isolated — one unreachable agent
177
- * must not abort the pass or hub boot.
178
- */
179
- async reconcileConnectedAgents(): Promise<void> {
180
- const registry = (this.moleculer.broker as unknown as Record<string, unknown>).registry as
181
- | { getNodeList?: (opts: { onlyAvailable: boolean }) => readonly { id: string }[] }
182
- | undefined
183
- const nodes = registry?.getNodeList?.({ onlyAvailable: true }) ?? []
184
- const agentIds = nodes.map((n) => n.id).filter((id) => id !== 'hub' && !id.includes('/'))
185
- if (agentIds.length === 0) return
186
- console.log(`[agent-registry] Boot reconcile: ${agentIds.length} connected agent(s)`)
187
- for (const agentId of agentIds) {
188
- await this.reconcileAgentAddons(agentId)
189
- }
190
- }
191
-
192
- /**
193
- * Reconcile a single agent's deployed addons against the hub's installed
194
- * set + placements. An addon running on the agent is STALE — and must be
195
- * undeployed — when it is either:
196
- * - not installed on the hub at all, or
197
- * - installed but declared `execution.placement: 'hub-only'`.
198
- *
199
- * `agent-only` / `any-node` addons that ARE installed on the hub are
200
- * legitimate agent residents and left untouched.
201
- *
202
- * Matching is by addon DECLARATION id: `$agent.status` reports
203
- * `addons[].id` (the decl id), and the hub's `listAddons()` rows expose
204
- * the same decl id at `manifest.id`. Package names are NOT used for the
205
- * match — a single package can ship multiple addons with distinct ids
206
- * and placements.
207
- *
208
- * All errors are caught and logged so a single bad agent never breaks
209
- * the caller (connect handler or boot pass).
210
- */
211
- async reconcileAgentAddons(agentId: string): Promise<void> {
212
- if (!this.addonRegistry) {
213
- console.warn(`[agent-registry] Reconcile skipped for ${agentId}: addon registry not wired`)
214
- return
215
- }
216
- try {
217
- const broker = this.broker
218
- const statusRaw = await broker.call(
219
- '$agent.status',
220
- {},
221
- {
222
- nodeID: agentId,
223
- timeout: AGENT_RECONCILE_RPC_TIMEOUT_MS,
224
- },
225
- )
226
- const agentAddons = this.extractAgentAddons(statusRaw)
227
- if (agentAddons.length === 0) return
228
-
229
- // Build the hub's placement map: decl id → placement. Absence from
230
- // this map means "not installed on the hub".
231
- const hubPlacements = new Map<string, ReturnType<typeof resolveAddonPlacement>>()
232
- for (const row of this.addonRegistry.listAddons()) {
233
- const declId = row.manifest.id
234
- if (typeof declId !== 'string') continue
235
- const decl = row.declaration ?? row.manifest
236
- hubPlacements.set(declId, resolveAddonPlacement(decl))
237
- }
238
-
239
- const stale = agentAddons.filter((addon) => {
240
- const placement = hubPlacements.get(addon.id)
241
- // Not installed on the hub → stale.
242
- if (placement === undefined) return true
243
- // Installed but pinned to the hub → must not run on an agent.
244
- return placement === 'hub-only'
245
- })
246
-
247
- if (stale.length === 0) {
248
- console.log(
249
- `[agent-registry] Reconcile ${agentId}: no stale addons (${agentAddons.length} checked)`,
250
- )
251
- return
252
- }
253
-
254
- for (const addon of stale) {
255
- const reason = hubPlacements.has(addon.id)
256
- ? 'placement is hub-only'
257
- : 'not installed on hub'
258
- try {
259
- await broker.call(
260
- '$agent.undeploy',
261
- { addonId: addon.id },
262
- {
263
- nodeID: agentId,
264
- timeout: AGENT_RECONCILE_RPC_TIMEOUT_MS,
265
- },
266
- )
267
- console.log(
268
- `[agent-registry] Reconcile ${agentId}: undeployed stale addon "${addon.id}" (${reason})`,
269
- )
270
- this.eventBus.emit({
271
- id: randomUUID(),
272
- timestamp: new Date(),
273
- source: { type: 'core', id: 'agent-registry' },
274
- category: EventCategory.AddonUninstalled,
275
- data: { addonId: addon.id, agentId, reason },
276
- })
277
- } catch (err) {
278
- console.error(
279
- `[agent-registry] Reconcile ${agentId}: failed to undeploy "${addon.id}":`,
280
- err instanceof Error ? err.message : String(err),
281
- )
282
- }
283
- }
284
- } catch (err) {
285
- console.error(
286
- `[agent-registry] Reconcile failed for agent ${agentId}:`,
287
- err instanceof Error ? err.message : String(err),
288
- )
289
- }
290
- }
291
-
292
- /** Narrow the `$agent.status` response down to its addon list. */
293
- private extractAgentAddons(statusRaw: unknown): readonly AgentStatusAddon[] {
294
- if (statusRaw === null || typeof statusRaw !== 'object') return []
295
- const addons = (statusRaw as { addons?: unknown }).addons
296
- if (!Array.isArray(addons)) return []
297
- const result: AgentStatusAddon[] = []
298
- for (const entry of addons) {
299
- if (entry === null || typeof entry !== 'object') continue
300
- const id = (entry as { id?: unknown }).id
301
- if (typeof id !== 'string' || id.length === 0) continue
302
- result.push({ id })
303
- }
304
- return result
305
- }
306
-
307
- /** Log an agent rename (name changes are persisted via $agent.rename RPC). */
308
- updateAgentName(nodeId: string, name: string): void {
309
- console.log(`[agent-registry] Agent renamed: "${nodeId}" → "${name}"`)
310
- }
311
-
312
- async listNodes(): Promise<readonly AgentListItem[]> {
313
- // Get child processes for hub via $process.list
314
- let hubProcesses: readonly unknown[] = []
315
- try {
316
- const processes = (await this.broker.call('$process.list')) as readonly Record<
317
- string,
318
- unknown
319
- >[]
320
- hubProcesses = processes.map((p) => ({
321
- pid: (p.pid as number) ?? 0,
322
- name: (p.name as string) ?? '',
323
- command: 'moleculer-service',
324
- state: (p.state as 'running' | 'stopped' | 'crashed') ?? 'running',
325
- cpuPercent: (p.cpuPercent as number) ?? 0,
326
- memoryRss: (p.memoryRss as number) ?? 0,
327
- uptimeSeconds: (p.uptimeSeconds as number) ?? 0,
328
- addonIds: (p.addonIds as readonly string[] | undefined) ?? [],
329
- groupId: (p.groupId as string | undefined) ?? null,
330
- }))
331
- } catch {
332
- // $process service may not be ready yet
333
- }
334
-
335
- const hubEntry = await this.buildHubEntry(hubProcesses)
336
-
337
- const remoteEntries: AgentListItem[] = []
338
- const registry = (this.moleculer.broker as unknown as Record<string, unknown>).registry as
339
- | { getNodeList?: (opts: { onlyAvailable: boolean }) => Array<{ id: string }> }
340
- | undefined
341
- const nodes = registry?.getNodeList?.({ onlyAvailable: true }) ?? []
342
-
343
- for (const node of nodes) {
344
- const nodeId = node.id
345
- // Skip hub (already included) and child processes (contain '/')
346
- if (nodeId === 'hub' || nodeId.includes('/')) continue
347
-
348
- try {
349
- const status = (await this.broker.call(
350
- '$agent.status',
351
- {},
352
- {
353
- nodeID: nodeId,
354
- timeout: 5000,
355
- },
356
- )) as Record<string, unknown>
357
-
358
- // Get real sub-process stats from the agent's $process.list
359
- let subProcesses: readonly unknown[] = []
360
- try {
361
- const processes = (await this.broker.call(
362
- '$process.list',
363
- {},
364
- {
365
- nodeID: nodeId,
366
- timeout: 5000,
367
- },
368
- )) as readonly Record<string, unknown>[]
369
- subProcesses = processes.map((p) => ({
370
- pid: (p.pid as number) ?? 0,
371
- name: (p.name as string) ?? '',
372
- command: 'moleculer-service',
373
- state: (p.state as 'running' | 'stopped' | 'crashed') ?? 'running',
374
- cpuPercent: (p.cpuPercent as number) ?? 0,
375
- memoryRss: (p.memoryRss as number) ?? 0,
376
- uptimeSeconds: (p.uptimeSeconds as number) ?? 0,
377
- addonIds: (p.addonIds as readonly string[] | undefined) ?? [],
378
- groupId: (p.groupId as string | undefined) ?? null,
379
- }))
380
- } catch {
381
- // Fall back to addon list from $agent.status (no stats)
382
- subProcesses =
383
- (status.addons as readonly Record<string, unknown>[] | undefined)?.map((a) => ({
384
- pid: 0,
385
- name: (a.id as string) ?? '',
386
- command: 'moleculer-service',
387
- state: ((a.status as string) ?? 'running') as 'running' | 'stopped' | 'crashed',
388
- cpuPercent: 0,
389
- memoryRss: 0,
390
- uptimeSeconds: 0,
391
- })) ?? []
392
- }
393
-
394
- // Extract addon IDs from $agent.status
395
- const agentAddons: readonly string[] =
396
- (status.addons as readonly { id: string }[] | undefined)?.map((a) => a.id) ?? []
397
-
398
- const hostname = typeof status.hostname === 'string' ? status.hostname : null
399
- const agentName = typeof status.name === 'string' ? status.name : nodeId
400
- remoteEntries.push({
401
- info: {
402
- id: nodeId,
403
- name: agentName,
404
- hostname: hostname ?? nodeId,
405
- capabilities: [],
406
- platform: (status.platform as string) ?? 'unknown',
407
- arch: (status.arch as string) ?? 'unknown',
408
- cpuCores: (status.cpuCores as number) ?? 0,
409
- memoryMB: (status.totalMemoryMB as number) ?? 0,
410
- cpuModel: status.cpuModel as string | undefined,
411
- },
412
- localIps: Array.isArray(status.localIps) ? (status.localIps as string[]) : [],
413
- status: {
414
- activeCameras: 0,
415
- cpuPercent: (status.cpuPercent as number) ?? 0,
416
- memoryPercent: (status.memoryPercent as number) ?? 0,
417
- fps: {},
418
- errors: [],
419
- },
420
- connectedSince:
421
- typeof status.uptime === 'number'
422
- ? Date.now() - (status.uptime as number) * 1000
423
- : Date.now(),
424
- isHub: false,
425
- subProcesses,
426
- agentAddons,
427
- })
428
- } catch {
429
- // Skip nodes without $agent service
430
- }
431
- }
432
-
433
- // TODO(D3 follow-up): offline-agent history dropped with knownAgents.
434
- // Previously, agents that disconnected were kept in a shadow map and
435
- // surfaced here as offline rows. listNodes now reflects only live
436
- // broker.registry nodes.
437
-
438
- return [hubEntry, ...remoteEntries]
439
- }
440
-
441
- private async buildHubEntry(subProcesses: readonly unknown[] = []): Promise<AgentListItem> {
442
- const cpus = os.cpus()
443
-
444
- // Get live metrics from the metrics-provider capability (NativeMetricsProvider).
445
- // The cap contract only exposes async snapshots; the cached read is cheap
446
- // because the addon's background sampler keeps the snapshot warm.
447
- let cpuPercent = 0
448
- let memoryPercent = 0
449
- const registry = this.capabilityService.getRegistry()
450
- if (registry) {
451
- const metricsProvider = registry.getSingleton<IMetricsProvider>('metrics-provider')
452
- if (metricsProvider) {
453
- const snapshot = await metricsProvider.getCached()
454
- if (snapshot) {
455
- cpuPercent = snapshot.cpu.total
456
- memoryPercent = snapshot.memory.percent
457
- }
458
- }
459
- }
460
-
461
- return {
462
- info: {
463
- id: 'hub',
464
- name: 'Hub',
465
- hostname: os.hostname(),
466
- capabilities: [],
467
- platform: os.platform(),
468
- arch: os.arch(),
469
- cpuCores: cpus.length,
470
- memoryMB: Math.round(os.totalmem() / 1024 / 1024),
471
- cpuModel: cpus[0]?.model,
472
- },
473
- status: {
474
- activeCameras: 0,
475
- cpuPercent,
476
- memoryPercent,
477
- fps: {},
478
- errors: [],
479
- },
480
- connectedSince: this.bootTimestamp,
481
- isHub: true,
482
- subProcesses,
483
- }
484
- }
485
-
486
- // ---- Role assignments ----
487
-
488
- getAssignments(cameraId?: number): CameraRoleAssignment[] {
489
- const all = [...this.assignments.values()]
490
- if (cameraId !== undefined) return all.filter((a) => a.cameraId === cameraId)
491
- return all
492
- }
493
-
494
- setAssignment(assignment: CameraRoleAssignment): void {
495
- const key = `${assignment.cameraId}:${assignment.role}`
496
- this.assignments.set(key, { ...assignment })
497
- }
498
-
499
- removeAssignment(cameraId: number, role: AgentCapability): void {
500
- const key = `${cameraId}:${role}`
501
- this.assignments.delete(key)
502
- }
503
-
504
- activateBackup(cameraId: number, role: AgentCapability): void {
505
- const primaryKey = `${cameraId}:${role}`
506
- const primary = this.assignments.get(primaryKey)
507
-
508
- const backup = [...this.assignments.values()].find(
509
- (a) => a.cameraId === cameraId && a.role === role && a.priority === 'backup',
510
- )
511
-
512
- if (!backup) return
513
-
514
- if (primary) {
515
- this.assignments.delete(primaryKey)
516
- }
517
-
518
- const promoted: CameraRoleAssignment = { ...backup, priority: 'primary' }
519
- this.assignments.set(primaryKey, promoted)
520
-
521
- this.eventBus.emit({
522
- id: randomUUID(),
523
- timestamp: new Date(),
524
- source: { type: 'core', id: 'agent-registry' },
525
- category: EventCategory.AgentBackupActivated,
526
- data: { cameraId, role, agentId: promoted.agentId },
527
- })
528
- }
529
- }