@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,1339 +0,0 @@
1
- /* eslint-disable @typescript-eslint/no-unsafe-return, @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access -- pre-existing lint debt across this 800-line provider-factory module. The flagged sites delegate into Moleculer/EventBus/IntegrationRegistry surfaces typed as `unknown` to break circular dependencies; runtime contracts are validated by the cap-mount-helper layer above. Tracked separately. */
2
- /**
3
- * Capability provider factories for the Phase E core caps —
4
- * `system`, `network-quality`, `toast`, `nodes`, `integrations`,
5
- * `addons`. Each factory builds a fresh provider object that
6
- * fulfils the cap's `InferProvider<...>` contract by delegating
7
- * to the existing backend services.
8
- *
9
- * Why factories instead of static singletons?
10
- * - `addons.custom` needs per-request `ctx.user` for per-action
11
- * auth checks. The cap-router codegen already passes `ctx`
12
- * into `getProvider(ctx)`, so closing over it is cheap and
13
- * keeps the auth surface tight.
14
- * - The other caps don't need ctx, but we keep the signature
15
- * uniform for symmetry.
16
- *
17
- * No addon "owns" these surfaces — they manage cluster state
18
- * (cluster topology, integrations, addon packages) or expose
19
- * server-level singletons (toast bus, network-quality tracker,
20
- * feature flags + retention controls).
21
- *
22
- * Phase E (2026-05-06): replaces the hand-written core routers in
23
- * `server/backend/src/api/core/{system,network-quality,toast,
24
- * nodes,integrations,addons}.router.ts`.
25
- */
26
- import * as os from 'node:os'
27
- import { execFile } from 'node:child_process'
28
- import { promisify } from 'node:util'
29
- import { randomUUID } from 'node:crypto'
30
- import { TRPCError } from '@trpc/server'
31
- import type {
32
- IAddonsProvider,
33
- IIntegrationsProvider,
34
- INetworkQualityProvider,
35
- INodesProvider,
36
- ISystemProvider,
37
- IToastProvider,
38
- IAnalysisDataPersistence,
39
- Toast,
40
- TopologyNode,
41
- Integration,
42
- IIntegrationRegistry,
43
- IDeviceProvider,
44
- IBrokerProvider,
45
- CapabilityMethodAuth,
46
- } from '@camstack/types'
47
- import { asJsonObject, asJsonArray, errMsg, EventCategory } from '@camstack/types'
48
- import type { CapabilityRegistry } from '@camstack/kernel'
49
- import { getCapUsageRegistry } from '@camstack/kernel'
50
- import type { ToastService, NotificationService } from '@camstack/core'
51
- import type { TrpcContext } from '../trpc/trpc.context.js'
52
- import type { FeatureService } from '../../core/feature/feature.service'
53
- import type { LoggingService } from '../../core/logging/logging.service'
54
- import type { EventBusService } from '../../core/events/event-bus.service'
55
- import type { AgentRegistryService } from '../../core/agent/agent-registry.service'
56
- import type { MoleculerService } from '../../core/moleculer/moleculer.service'
57
- import type { AddonRegistryService } from '../../core/addon/addon-registry.service'
58
- import type { AddonPackageService } from '../../core/addon/addon-package.service'
59
- import type { NetworkQualityService } from '../../core/network/network-quality.service'
60
- import type { ConfigService } from '../../core/config/config.service'
61
- import { planDeleteTimeStamps } from '../../boot/integration-id-backfill'
62
- import { persistCollectionDisabled } from './collection-preference.js'
63
- import { BulkUpdateCoordinator } from './bulk-update-coordinator.js'
64
- import { FRAMEWORK_PACKAGE_ALLOWLIST } from '../../core/addon/addon-package.service.js'
65
-
66
- const execFileAsync = promisify(execFile)
67
-
68
- // ── system ──────────────────────────────────────────────────────────
69
-
70
- function getRetention(registry: CapabilityRegistry | null) {
71
- return (
72
- registry?.getSingleton<IAnalysisDataPersistence>('analysis-data-persistence')?.retention ?? null
73
- )
74
- }
75
-
76
- export function buildSystemProvider(
77
- feature: FeatureService,
78
- registry: CapabilityRegistry | null,
79
- ): ISystemProvider {
80
- return {
81
- info: async () => feature.getManifest(),
82
- health: async () => ({ status: 'ok' as const, uptime: process.uptime() }),
83
- featureFlags: async () => feature.getManifest(),
84
- networkAddresses: async () => {
85
- const ifaces = os.networkInterfaces()
86
- const result: Array<{ name: string; address: string; family: string; internal: boolean }> = []
87
- for (const [name, addrs] of Object.entries(ifaces)) {
88
- for (const addr of addrs ?? []) {
89
- result.push({
90
- name,
91
- address: addr.address,
92
- family: addr.family,
93
- internal: addr.internal,
94
- })
95
- }
96
- }
97
- return result
98
- },
99
- getRetentionConfig: async () => getRetention(registry)?.getConfig() ?? null,
100
- setRetentionConfig: async (input) => {
101
- getRetention(registry)?.setConfig(input)
102
- return null
103
- },
104
- forceRetentionCleanup: async () => {
105
- await getRetention(registry)?.forceCleanup()
106
- },
107
- }
108
- }
109
-
110
- // ── network-quality ─────────────────────────────────────────────────
111
-
112
- export function buildNetworkQualityProvider(nq: NetworkQualityService): INetworkQualityProvider {
113
- return {
114
- getDeviceStats: async (input) => nq.getDeviceStats(input.deviceId),
115
- getAllStats: async () => nq.getAllStats(),
116
- reportClientStats: async (input) => {
117
- nq.reportClientStats(input.deviceId, {
118
- rttMs: input.rttMs,
119
- jitterMs: input.jitterMs,
120
- estimatedBandwidthKbps: input.estimatedBandwidthKbps,
121
- packetLossPercent: input.packetLossPercent,
122
- })
123
- },
124
- }
125
- }
126
-
127
- // ── toast ───────────────────────────────────────────────────────────
128
- //
129
- // Per-request factory: each subscription opens its own connectionId.
130
- // The factory captures `ctx.user.id` so the underlying ToastService
131
- // can scope deliveries (broadcast vs. per-user). The cap defines
132
- // `onToast` as a subscription — the codegen wires it through the
133
- // `iterableSubscription` adapter and pushes raw `Toast` payloads.
134
-
135
- export function buildToastProvider(
136
- toastService: ToastService | null,
137
- ctx: TrpcContext,
138
- ): IToastProvider {
139
- return {
140
- onToast: (_input, push) => {
141
- if (!toastService) return () => {}
142
- const userId = ctx.user?.id ?? 'anonymous'
143
- const connectionId = randomUUID()
144
- const unsubscribe = toastService.subscribe(connectionId, userId, (toast: Toast) =>
145
- push(toast),
146
- )
147
- return unsubscribe ?? (() => {})
148
- },
149
- }
150
- }
151
-
152
- // ── nodes ───────────────────────────────────────────────────────────
153
-
154
- interface SubProcessLite {
155
- readonly pid: number
156
- readonly name: string
157
- readonly state: string
158
- readonly cpuPercent: number
159
- readonly memoryRss: number
160
- readonly uptimeSeconds: number
161
- readonly addonIds?: readonly string[]
162
- readonly groupId?: string | null
163
- }
164
-
165
- function getLocalIps(): string[] {
166
- const interfaces = os.networkInterfaces()
167
- const ips: string[] = []
168
- for (const ifaces of Object.values(interfaces)) {
169
- if (!ifaces) continue
170
- for (const iface of ifaces) {
171
- if (iface.internal) continue
172
- ips.push(iface.address)
173
- }
174
- }
175
- return ips
176
- }
177
-
178
- /**
179
- * Pure (well, async) topology computation — same shape returned by the
180
- * `nodes.topology` cap procedure. Extracted so the topology emitter
181
- * service can produce identical snapshots without going through tRPC.
182
- */
183
- export async function computeTopology(
184
- agentRegistry: AgentRegistryService,
185
- addonRegistry?: AddonRegistryService,
186
- ): Promise<readonly TopologyNode[]> {
187
- const nodes = await agentRegistry.listNodes()
188
- const allAddons = addonRegistry?.listAddons() ?? []
189
- const getInGroupAddonIds = (node: (typeof nodes)[number]): readonly string[] => {
190
- const subs = (node.subProcesses ?? []) as readonly SubProcessLite[]
191
- return subs.flatMap((p) => p.addonIds ?? [])
192
- }
193
- const addonCaps = new Map<string, readonly string[]>()
194
- for (const a of allAddons) {
195
- const id = a.manifest?.id ?? ''
196
- const caps = a.declaration?.capabilities?.map((c) => (typeof c === 'string' ? c : c.name)) ?? []
197
- addonCaps.set(id, caps)
198
- }
199
- const addonCategory = new Map<string, string>()
200
- for (const a of allAddons) {
201
- const id = a.manifest?.id ?? ''
202
- const category = a.declaration?.category ?? 'system'
203
- addonCategory.set(id, category)
204
- }
205
- return nodes.map((node) => {
206
- const inGroupAddonIds = new Set(getInGroupAddonIds(node))
207
- const agentAddonIds: readonly string[] =
208
- (node as { agentAddons?: readonly string[] }).agentAddons ?? []
209
- type NodeAddonEntry = { id: string; capabilities: readonly string[]; status: 'running' }
210
- const allNodeAddons: NodeAddonEntry[] = node.isHub
211
- ? allAddons.map((a) => {
212
- const id = a.manifest?.id ?? 'unknown'
213
- return { id, capabilities: [...(addonCaps.get(id) ?? [])], status: 'running' as const }
214
- })
215
- : agentAddonIds.map((addonId) => ({
216
- id: addonId,
217
- capabilities: [...(addonCaps.get(addonId) ?? [])],
218
- status: 'running' as const,
219
- }))
220
- const inProcessAddons = allNodeAddons.filter((a) => !inGroupAddonIds.has(a.id))
221
- const isolatedProcesses = (node.subProcesses ?? []) as readonly SubProcessLite[]
222
- const mainProcessServices = inProcessAddons.map((a) => ({
223
- addonId: a.id,
224
- capabilities: a.capabilities,
225
- status: a.status,
226
- }))
227
- const mainProcess = node.isHub
228
- ? {
229
- pid: process.pid,
230
- name: 'hub (core)',
231
- state: 'running' as const,
232
- cpuPercent: node.status?.cpuPercent ?? 0,
233
- memoryRss: process.memoryUsage().rss,
234
- uptimeSeconds: Math.floor(process.uptime()),
235
- services: mainProcessServices,
236
- }
237
- : {
238
- pid: 0,
239
- name: `${node.info.id} (core)`,
240
- state: 'running' as const,
241
- cpuPercent: node.status?.cpuPercent ?? 0,
242
- memoryRss: 0,
243
- uptimeSeconds: Math.floor((Date.now() - node.connectedSince) / 1000),
244
- services: mainProcessServices,
245
- }
246
- const childProcesses = isolatedProcesses.map((p) => {
247
- const memberIds = p.addonIds && p.addonIds.length > 0 ? p.addonIds : [p.name]
248
- return {
249
- pid: p.pid,
250
- name: p.name,
251
- state: p.state,
252
- cpuPercent: p.cpuPercent,
253
- memoryRss: p.memoryRss,
254
- uptimeSeconds: p.uptimeSeconds,
255
- groupId: p.groupId ?? p.name,
256
- services: memberIds.map((addonId) => ({
257
- addonId,
258
- capabilities: [...(addonCaps.get(addonId) ?? [])],
259
- status: p.state,
260
- })),
261
- }
262
- })
263
- // Aggregate node-local addons by category. `allNodeAddons` already
264
- // contains the per-node addon roster (hub uses every installed
265
- // addon; agents use their assigned agentAddons subset).
266
- const byCategory = new Map<
267
- string,
268
- {
269
- category: string
270
- total: number
271
- healthy: number
272
- addons: { id: string; status: string; cpuPercent: number; memoryRss: number }[]
273
- }
274
- >()
275
- const procByAddon = new Map<string, { cpuPercent: number; memoryRss: number; state: string }>()
276
- for (const p of (node.subProcesses ?? []) as readonly SubProcessLite[]) {
277
- for (const addonId of p.addonIds ?? []) {
278
- procByAddon.set(addonId, {
279
- cpuPercent: p.cpuPercent,
280
- memoryRss: p.memoryRss,
281
- state: p.state,
282
- })
283
- }
284
- }
285
- for (const a of allNodeAddons) {
286
- const category = addonCategory.get(a.id) ?? 'system'
287
- const procInfo = procByAddon.get(a.id)
288
- const entry = byCategory.get(category) ?? { category, total: 0, healthy: 0, addons: [] }
289
- const status = procInfo?.state ?? a.status
290
- entry.total += 1
291
- if (status === 'running') entry.healthy += 1
292
- entry.addons.push({
293
- id: a.id,
294
- status,
295
- cpuPercent: procInfo?.cpuPercent ?? 0,
296
- memoryRss: procInfo?.memoryRss ?? 0,
297
- })
298
- byCategory.set(category, entry)
299
- }
300
- const categoriesProjection = [...byCategory.values()]
301
- return {
302
- id: node.info.id,
303
- name: node.info.name,
304
- hostname: node.isHub ? os.hostname() : (node.info.hostname ?? node.info.id),
305
- platform: node.info.platform ?? 'unknown',
306
- arch: node.info.arch ?? 'unknown',
307
- cpuModel: node.info.cpuModel ?? null,
308
- cpuCores: node.info.cpuCores ?? 0,
309
- memoryMB: node.info.memoryMB ?? 0,
310
- engines: [...(node.info.pythonRuntimes ?? [])],
311
- isHub: node.isHub,
312
- isOnline: node.isOnline !== false,
313
- cpuPercent: node.status?.cpuPercent ?? 0,
314
- memoryPercent: node.status?.memoryPercent ?? 0,
315
- uptime: Date.now() - node.connectedSince,
316
- lastSeen: new Date().toISOString(),
317
- localIps: node.isHub ? getLocalIps() : (node.localIps ?? []),
318
- addons: allNodeAddons,
319
- processes: [mainProcess, ...childProcesses],
320
- categories: categoriesProjection,
321
- }
322
- })
323
- }
324
-
325
- /**
326
- * Local narrowing of Moleculer's broker. Going through `moleculer.broker`
327
- * directly leaves typescript-eslint with `error`-typed access — Moleculer's
328
- * `index.d.ts` chains through `eventemitter2` whose package.json has no
329
- * `types` field; under `moduleResolution: node` the parser loses the chain
330
- * even though `tsc --skipLibCheck` accepts it. Same shim is used in
331
- * `addon-registry.service.ts`; consider hoisting if a third call site appears.
332
- */
333
- interface BrokerLike {
334
- call<T = unknown>(
335
- action: string,
336
- params?: unknown,
337
- opts?: { nodeID?: string; timeout?: number },
338
- ): Promise<T>
339
- }
340
-
341
- export function buildNodesProvider(
342
- agentRegistry: AgentRegistryService,
343
- moleculer: MoleculerService,
344
- addonRegistry?: AddonRegistryService,
345
- ): INodesProvider {
346
- const broker = moleculer.broker as unknown as BrokerLike
347
- return {
348
- topology: async () => computeTopology(agentRegistry, addonRegistry),
349
- deployAddon: async () => {
350
- // Placeholder — actual deployment orchestration TBD
351
- return { success: true }
352
- },
353
- undeployAddon: async (input) => {
354
- await broker.call(
355
- '$agent.undeploy',
356
- {
357
- addonId: input.addonId,
358
- },
359
- { nodeID: input.nodeId, timeout: 30_000 },
360
- )
361
- return { success: true }
362
- },
363
- restartAddon: async (input) => {
364
- const isHubLocal = input.nodeId === 'hub' || input.nodeId.startsWith('hub/')
365
- if (isHubLocal && addonRegistry) {
366
- const result: unknown = await addonRegistry.restartAddon(input.addonId)
367
- if (typeof result === 'object' && result !== null && 'success' in result) {
368
- return result as { success: boolean }
369
- }
370
- return { success: true }
371
- }
372
- const agentNodeId = input.nodeId.includes('/') ? input.nodeId.split('/')[0]! : input.nodeId
373
- await broker.call(
374
- '$agent.restart',
375
- {
376
- addonId: input.addonId,
377
- },
378
- { nodeID: agentNodeId, timeout: 30_000 },
379
- )
380
- return { success: true }
381
- },
382
- restartProcess: async (input) => {
383
- return (await broker.call(
384
- '$process.restart',
385
- {
386
- name: input.processName,
387
- },
388
- { nodeID: input.nodeId, timeout: 30_000 },
389
- )) as { success: boolean; reason?: string }
390
- },
391
- restartNode: async (input) => {
392
- return (await broker.call(
393
- '$process.restartAll',
394
- {},
395
- {
396
- nodeID: input.nodeId,
397
- timeout: 60_000,
398
- },
399
- )) as { restarted: readonly string[]; failed: readonly string[] }
400
- },
401
- shutdownNode: async (input) => {
402
- if (input.nodeId === 'hub') {
403
- setTimeout(() => process.exit(0), 500)
404
- return { success: true }
405
- }
406
- await broker.call(
407
- '$agent.shutdown',
408
- {},
409
- {
410
- nodeID: input.nodeId,
411
- timeout: 10_000,
412
- },
413
- )
414
- return { success: true }
415
- },
416
- renameNode: async (input) => {
417
- const trimmed = input.name.trim()
418
- if (input.nodeId === 'hub') {
419
- const key = `node-display-name:${input.nodeId}`
420
- await broker.call('settings-store.set', {
421
- collection: 'system-settings',
422
- key,
423
- value: trimmed,
424
- })
425
- } else {
426
- await broker.call(
427
- '$agent.rename',
428
- {
429
- name: trimmed,
430
- },
431
- { nodeID: input.nodeId, timeout: 10_000 },
432
- )
433
- agentRegistry.updateAgentName(input.nodeId, trimmed)
434
- }
435
- return { nodeId: input.nodeId, name: trimmed }
436
- },
437
- getNodeAddons: async (input) => {
438
- // Hub branch: read straight from the local registry. Surfaces every
439
- // loaded addon with its package name + version so the per-node Addons
440
- // tab can render the same shape regardless of whether the target is
441
- // hub or agent.
442
- if (input.nodeId === 'hub') {
443
- const rows = addonRegistry?.listAddons() ?? []
444
- return rows.map((r) => ({
445
- id: r.manifest.id,
446
- status: r.process?.state ?? 'running',
447
- version: r.manifest.packageVersion,
448
- packageName: r.manifest.packageName,
449
- }))
450
- }
451
- // Agent branch: forward to `$agent.status` and pick out its `addons`
452
- // field. Done as a direct call (not via `agentRegistry`'s cached
453
- // listing) so the UI always sees fresh data when it opens the tab
454
- // — the cache otherwise lags the cluster heartbeat interval.
455
- try {
456
- const status = await broker.call<{
457
- addons?: readonly { id: string; status: string; version?: string; packageName?: string }[]
458
- }>('$agent.status', {}, { nodeID: input.nodeId, timeout: 5_000 })
459
- return (status.addons ?? []).map((a) => ({
460
- id: a.id,
461
- status: a.status,
462
- ...(a.version !== undefined ? { version: a.version } : {}),
463
- ...(a.packageName !== undefined ? { packageName: a.packageName } : {}),
464
- }))
465
- } catch {
466
- return []
467
- }
468
- },
469
- clusterAddonStatus: async () => {
470
- const hubAddons = addonRegistry?.listAllAddons() ?? []
471
- const hubMap = new Map(hubAddons.map((a) => [a.manifest.id, a]))
472
-
473
- const nodes = await agentRegistry.listNodes()
474
- const remoteNodes = nodes.filter((n) => !n.isHub && n.connectedSince > 0)
475
-
476
- const remoteStatuses = await Promise.all(
477
- remoteNodes.map(async (node) => {
478
- try {
479
- const status = (await broker.call(
480
- '$agent.status',
481
- {},
482
- {
483
- nodeID: node.info.id,
484
- timeout: 5_000,
485
- },
486
- )) as {
487
- addons?: readonly {
488
- id: string
489
- status: string
490
- version?: string
491
- packageName?: string
492
- }[]
493
- }
494
- return {
495
- nodeId: node.info.id,
496
- name: node.info.name,
497
- online: true,
498
- addons: status.addons ?? [],
499
- }
500
- } catch {
501
- return {
502
- nodeId: node.info.id,
503
- name: node.info.name,
504
- online: false,
505
- addons: [] as readonly {
506
- id: string
507
- status: string
508
- version?: string
509
- packageName?: string
510
- }[],
511
- }
512
- }
513
- }),
514
- )
515
-
516
- type NodeDeployment = {
517
- nodeId: string
518
- name: string
519
- version: string
520
- status: string
521
- synced: boolean
522
- }
523
- const result: Record<string, { hubVersion: string; nodes: NodeDeployment[] }> = {}
524
-
525
- for (const [addonId, hubAddon] of hubMap) {
526
- const hubVersion = hubAddon.manifest.packageVersion
527
- const addonNodes: NodeDeployment[] = [
528
- { nodeId: 'hub', name: 'hub', version: hubVersion, status: 'running', synced: true },
529
- ]
530
-
531
- for (const remote of remoteStatuses) {
532
- const remoteAddon = remote.addons.find((a) => a.id === addonId)
533
- if (remoteAddon) {
534
- const remoteVersion = remoteAddon.version ?? 'unknown'
535
- addonNodes.push({
536
- nodeId: remote.nodeId,
537
- name: remote.name,
538
- version: remoteVersion,
539
- status: remote.online ? remoteAddon.status : 'offline',
540
- synced: remoteVersion === hubVersion,
541
- })
542
- }
543
- }
544
-
545
- result[addonId] = { hubVersion, nodes: addonNodes }
546
- }
547
-
548
- return result
549
- },
550
- getCapUsageGraph: async (input) => {
551
- const reg = getCapUsageRegistry()
552
- return reg.getGraph({ windowSeconds: input.windowSeconds, nowMs: Date.now() })
553
- },
554
- setProcessLogLevel: async (input) => {
555
- // E2: for hub-local children, try the UDS path first (fire-and-forget);
556
- // fall back to the Moleculer $node-mgmt.setLogLevel action for remote
557
- // nodes (agents) that still run a per-node broker. The UDS path is always
558
- // attempted for hub/<runner> nodeIds even when the Moleculer path would
559
- // also work — both are safe to run in parallel during Phase E.
560
- const reachedViaUds = moleculer.setChildLogLevelByNodeId(input.nodeId, input.level)
561
- if (!reachedViaUds) {
562
- await broker.call(
563
- '$node-mgmt.setLogLevel',
564
- {
565
- level: input.level,
566
- },
567
- { nodeID: input.nodeId, timeout: 5_000 },
568
- )
569
- }
570
- return { success: true }
571
- },
572
- executeQuery: async (input) => {
573
- return await broker.call(
574
- `${input.addonId}.queryable.query`,
575
- { queryName: input.queryName, params: input.params ?? {} },
576
- { nodeID: input.nodeId, timeout: 30_000 },
577
- )
578
- },
579
- }
580
- }
581
-
582
- // ── integrations ────────────────────────────────────────────────────
583
-
584
- interface IntegrationWithProcessState extends Integration {
585
- readonly processState: string
586
- }
587
-
588
- function requireIntegrationRegistry(ar: AddonRegistryService): IIntegrationRegistry {
589
- const reg = ar.getIntegrationRegistry()
590
- if (!reg) throw new Error('IntegrationRegistry not available')
591
- return reg
592
- }
593
-
594
- function isDeviceProvider(value: unknown): value is IDeviceProvider {
595
- return (
596
- value !== null &&
597
- typeof value === 'object' &&
598
- typeof Reflect.get(value, 'discoverDevices') === 'function'
599
- )
600
- }
601
-
602
- function getDeviceProvider(ar: AddonRegistryService, addonId: string): IDeviceProvider | null {
603
- const provider = ar.getCapabilityRegistry().getProviderByAddon('device-provider', addonId)
604
- return isDeviceProvider(provider) ? provider : null
605
- }
606
-
607
- /**
608
- * Marker caps that flag an addon as a creatable integration type:
609
- * - `device-provider` — classic providers (Reolink/ONVIF/Frigate)
610
- * that expose `createDevice` + `discoverDevices` via their
611
- * device-provider cap.
612
- * - `device-adoption` — integration-style providers (Home Assistant
613
- * and future siblings) that materialise devices via a generic
614
- * adoption cap instead of a manual create-form. The picker treats
615
- * them the same way; the wizard's discovery step routes through the
616
- * specific cap based on the addon's declared surface.
617
- *
618
- * Exported so the integration-markers spec can assert the recognised set
619
- * without booting the whole provider factory.
620
- */
621
- export const INTEGRATION_CAP_MARKERS = new Set(['device-provider', 'device-adoption'])
622
-
623
- export function buildIntegrationsProvider(
624
- ar: AddonRegistryService,
625
- eb: EventBusService,
626
- loggingService: LoggingService,
627
- capabilityRegistry: CapabilityRegistry | null,
628
- ): IIntegrationsProvider {
629
- const logger = loggingService.createLogger('integrations')
630
- const withProcessState = (i: Integration): IntegrationWithProcessState => ({
631
- ...i,
632
- processState:
633
- ar.listAddons().find((a) => a.manifest.id === i.addonId)?.process?.state ?? 'unknown',
634
- })
635
-
636
- return {
637
- list: async () => {
638
- const integrations = await requireIntegrationRegistry(ar).listIntegrations()
639
- return integrations.map(withProcessState)
640
- },
641
- get: async (input) => {
642
- const integration = await requireIntegrationRegistry(ar).getIntegration(input.id)
643
- if (!integration) throw new Error(`Integration "${input.id}" not found`)
644
- return withProcessState(integration)
645
- },
646
- getByAddonId: async (input) =>
647
- requireIntegrationRegistry(ar).getIntegrationByAddonId(input.addonId),
648
- create: async (input) => {
649
- const { skipRestart, ...payload } = input
650
- const reg = requireIntegrationRegistry(ar)
651
-
652
- logger.info('request', {
653
- tags: { addonId: input.addonId },
654
- meta: { phase: 'create', name: input.name },
655
- })
656
-
657
- const addon = ar.listAddons().find((a) => a.manifest.id === input.addonId)
658
- const instanceMode =
659
- addon?.declaration?.instanceMode ?? addon?.manifest?.instanceMode ?? 'multiple'
660
- if (instanceMode === 'unique') {
661
- const existing = (await reg.listIntegrations()).filter((i) => i.addonId === input.addonId)
662
- if (existing.length > 0) {
663
- logger.warn('rejected duplicate unique', {
664
- tags: { addonId: input.addonId, integrationId: existing[0]!.id },
665
- meta: { phase: 'create' },
666
- })
667
- throw new Error(
668
- `Addon "${input.addonId}" is unique-instance and already has an integration (${existing[0]!.id})`,
669
- )
670
- }
671
- }
672
-
673
- const integration = await reg.createIntegration(payload)
674
- logger.info('persisted', {
675
- tags: { integrationId: integration.id, addonId: integration.addonId },
676
- meta: { phase: 'create' },
677
- })
678
-
679
- const hasSettings = input.settings != null && Object.keys(input.settings).length > 0
680
- if (!skipRestart && hasSettings) {
681
- logger.info('settings present — restarting addon', {
682
- tags: { addonId: input.addonId },
683
- meta: { phase: 'create' },
684
- })
685
- await ar.restartAddon(input.addonId)
686
- logger.info('addon restart complete', {
687
- tags: { addonId: input.addonId },
688
- meta: { phase: 'create' },
689
- })
690
- } else {
691
- logger.info('skipping restart (no settings or skipRestart=true)', {
692
- meta: { phase: 'create' },
693
- })
694
- }
695
- return integration
696
- },
697
- update: async (input) => {
698
- const reg = requireIntegrationRegistry(ar)
699
- const previous = await reg.getIntegration(input.id)
700
- if (!previous) {
701
- logger.warn('not found', { tags: { integrationId: input.id }, meta: { phase: 'update' } })
702
- throw new Error(`Integration "${input.id}" not found`)
703
- }
704
- const { id, ...updates } = input
705
- const changedFields = Object.keys(updates).filter(
706
- (k) => (updates as Record<string, unknown>)[k] !== undefined,
707
- )
708
- logger.info('request', {
709
- tags: { integrationId: input.id, addonId: previous.addonId },
710
- meta: { phase: 'update', fields: changedFields },
711
- })
712
-
713
- const result = await reg.updateIntegration(id, updates)
714
- if (!result) throw new Error(`Integration "${id}" not found`)
715
-
716
- const enabledChanged = input.enabled !== undefined && input.enabled !== previous.enabled
717
- if (enabledChanged) {
718
- const category = input.enabled ? 'integration.enabled' : 'integration.disabled'
719
- logger.info('enabled state changed', {
720
- tags: { integrationId: result.id, addonId: result.addonId },
721
- meta: { phase: 'update', enabled: input.enabled },
722
- })
723
- eb.emit({
724
- id: `integration-${category}-${Date.now()}`,
725
- timestamp: new Date(),
726
- source: { type: 'integration', id: result.id },
727
- category,
728
- data: {
729
- integrationId: result.id,
730
- addonId: result.addonId,
731
- },
732
- })
733
- }
734
-
735
- if (input.name !== undefined && input.name !== previous.name) {
736
- logger.info('renamed', {
737
- tags: { integrationId: result.id },
738
- meta: { phase: 'update', previousName: previous.name, newName: input.name },
739
- })
740
- }
741
-
742
- const infoChanged = input.info !== undefined
743
- if (infoChanged) {
744
- logger.info('info changed — restarting addon', {
745
- tags: { addonId: result.addonId },
746
- meta: { phase: 'update' },
747
- })
748
- await ar.restartAddon(result.addonId)
749
- logger.info('addon restart complete', {
750
- tags: { addonId: result.addonId },
751
- meta: { phase: 'update' },
752
- })
753
- } else {
754
- logger.info('no restart needed (only enabled/name changed)', { meta: { phase: 'update' } })
755
- }
756
- return result
757
- },
758
- delete: async (input) => {
759
- const reg = requireIntegrationRegistry(ar)
760
- logger.info('request', { tags: { integrationId: input.id }, meta: { phase: 'delete' } })
761
-
762
- const integration = await reg.getIntegration(input.id)
763
- if (!integration) {
764
- logger.warn('not found', { tags: { integrationId: input.id }, meta: { phase: 'delete' } })
765
- throw new Error(`Integration "${input.id}" not found`)
766
- }
767
- logger.info('removing', {
768
- tags: { integrationId: input.id, addonId: integration.addonId },
769
- meta: { phase: 'delete', name: integration.name },
770
- })
771
-
772
- // Cascade-delete every live device whose integrationId matches.
773
- // Best-effort: a device-removal hiccup must not abort the integration
774
- // delete — log a warning and continue so the record + event always fire.
775
- const dm =
776
- capabilityRegistry?.getSingleton<{
777
- removeByIntegration?: (input: { integrationId: string }) => Promise<{ removed: number }>
778
- listAll?: (input: Record<string, never>) => Promise<
779
- readonly {
780
- id: number
781
- addonId: string
782
- parentDeviceId: number | null
783
- integrationId?: string
784
- }[]
785
- >
786
- setIntegrationId?: (input: { deviceId: number; integrationId: string }) => Promise<void>
787
- }>('device-manager') ?? null
788
-
789
- // Claim legacy un-tagged devices BEFORE the cascade. Devices created
790
- // before stamping (or whose provider never stamps, e.g. `provider-rtsp`)
791
- // carry no integrationId, so `removeByIntegration` (which matches on
792
- // integrationId) would leave them orphaned forever once their integration
793
- // is gone. While the integration record still exists, stamp the
794
- // unambiguous ones (addons hosting exactly one integration) so the cascade
795
- // below removes them too. Best-effort: never abort the delete.
796
- if (dm?.listAll && dm?.setIntegrationId) {
797
- try {
798
- const [integrations, devices] = await Promise.all([
799
- reg.listIntegrations(),
800
- dm.listAll({}),
801
- ])
802
- const stamps = planDeleteTimeStamps(
803
- input.id,
804
- integrations.map((i) => ({ id: i.id, addonId: i.addonId })),
805
- devices.map((d) => ({
806
- id: d.id,
807
- addonId: d.addonId,
808
- parentDeviceId: d.parentDeviceId,
809
- ...(d.integrationId !== undefined ? { integrationId: d.integrationId } : {}),
810
- })),
811
- )
812
- for (const stamp of stamps) {
813
- await dm.setIntegrationId({
814
- deviceId: stamp.deviceId,
815
- integrationId: stamp.integrationId,
816
- })
817
- }
818
- if (stamps.length > 0) {
819
- logger.info('claimed legacy un-tagged devices for cascade', {
820
- tags: { integrationId: input.id, addonId: integration.addonId },
821
- meta: { phase: 'delete', claimed: stamps.length },
822
- })
823
- }
824
- } catch (err) {
825
- logger.warn('legacy device claim failed (best-effort — continuing)', {
826
- tags: { integrationId: input.id },
827
- meta: { phase: 'delete', error: errMsg(err) },
828
- })
829
- }
830
- }
831
-
832
- if (dm?.removeByIntegration) {
833
- try {
834
- const result = await dm.removeByIntegration({ integrationId: input.id })
835
- logger.info('cascade-removed devices', {
836
- tags: { integrationId: input.id },
837
- meta: { phase: 'delete', removed: result.removed },
838
- })
839
- } catch (err) {
840
- logger.warn('device cascade-remove failed (best-effort — continuing)', {
841
- tags: { integrationId: input.id },
842
- meta: { phase: 'delete', error: errMsg(err) },
843
- })
844
- }
845
- } else {
846
- logger.warn('device-manager not available — skipping cascade device removal', {
847
- tags: { integrationId: input.id },
848
- meta: { phase: 'delete' },
849
- })
850
- }
851
-
852
- await reg.deleteIntegration(input.id)
853
-
854
- eb.emit({
855
- id: `integration-deleted-${Date.now()}`,
856
- timestamp: new Date(),
857
- source: { type: 'integration', id: input.id },
858
- category: EventCategory.IntegrationDeleted,
859
- data: {
860
- integrationId: input.id,
861
- addonId: integration.addonId,
862
- },
863
- })
864
- logger.info('completed (no restart)', {
865
- tags: { integrationId: input.id },
866
- meta: { phase: 'delete' },
867
- })
868
-
869
- return { success: true, deletedId: input.id }
870
- },
871
- getSettings: async (input) => {
872
- const reg = requireIntegrationRegistry(ar)
873
- const integration = await reg.getIntegration(input.id)
874
- if (!integration) throw new Error(`Integration "${input.id}" not found`)
875
- return reg.getIntegrationSettings(input.id)
876
- },
877
- setSettings: async (input) => {
878
- const reg = requireIntegrationRegistry(ar)
879
- const integration = await reg.getIntegration(input.id)
880
- if (!integration) {
881
- logger.warn('not found', {
882
- tags: { integrationId: input.id },
883
- meta: { phase: 'setSettings' },
884
- })
885
- throw new Error(`Integration "${input.id}" not found`)
886
- }
887
- const settingsKeys = Object.keys(input.settings)
888
- logger.info('request', {
889
- tags: { integrationId: input.id, addonId: integration.addonId },
890
- meta: { phase: 'setSettings', keys: settingsKeys },
891
- })
892
- await reg.setIntegrationSettings(input.id, input.settings)
893
-
894
- logger.info('persisted — restarting addon', {
895
- tags: { addonId: integration.addonId },
896
- meta: { phase: 'setSettings' },
897
- })
898
- await ar.restartAddon(integration.addonId)
899
- logger.info('addon restart complete', {
900
- tags: { addonId: integration.addonId },
901
- meta: { phase: 'setSettings' },
902
- })
903
- return { success: true }
904
- },
905
- getAvailableTypes: async () => {
906
- const reg = requireIntegrationRegistry(ar)
907
- const addons = ar.listAddons()
908
- // Hide failed-to-load packages from the picker. Fix #7 surfaces them in
909
- // the Addons page so the operator can uninstall, but creating an
910
- // integration against an addon that didn't load produces an orphaned
911
- // row that `createFilteredRegistry` then filters out — silent data
912
- // loss from the operator's POV. Filter at the source instead.
913
- //
914
- // Markers that flag an addon as a creatable integration type live
915
- // in the module-level `INTEGRATION_CAP_MARKERS` set (exported so the
916
- // integration-markers spec can assert the recognised caps).
917
- const providerAddons = addons.filter(
918
- (a) =>
919
- a.process?.state !== 'failed' &&
920
- a.manifest.capabilities?.some((c) => {
921
- const name = typeof c === 'string' ? c : c.name
922
- return typeof name === 'string' && INTEGRATION_CAP_MARKERS.has(name)
923
- }),
924
- )
925
- const integrations = await reg.listIntegrations()
926
- return providerAddons.map((addon) => {
927
- const m = addon.manifest
928
- const d = addon.declaration
929
- const icon = d?.icon ?? m.icon
930
- const color = d?.color ?? m.color ?? '#78716c'
931
- const instanceMode = d?.instanceMode ?? m.instanceMode ?? 'multiple'
932
- const existing = integrations.filter((i) => i.addonId === m.id)
933
- const provider = getDeviceProvider(ar, m.id)
934
- const discoveryMode = provider?.discoveryMode ?? 'manual'
935
-
936
- // Branch by CAP, not by addon name. Surface which integration-marker
937
- // cap the addon declared so the wizard routes `device-adoption`
938
- // (Approach A: pick/create a broker, store `{ brokerId }`) vs the
939
- // legacy `device-provider` config → discovery flow. A `device-adoption`
940
- // marker wins when both are present (an integration-style addon may
941
- // also expose a `device-provider` shim); the broker step is the
942
- // intended entry point for it.
943
- const capNames = (m.capabilities ?? []).map((c) => (typeof c === 'string' ? c : c.name))
944
- const kind: 'device-adoption' | 'device-provider' = capNames.includes('device-adoption')
945
- ? 'device-adoption'
946
- : 'device-provider'
947
-
948
- // For device-adoption addons, the broker kind to create/link comes
949
- // from the addon manifest (`brokerKind`). Null for device-provider
950
- // addons, which carry no broker.
951
- const brokerKind =
952
- kind === 'device-adoption' ? (d?.brokerKind ?? m.brokerKind ?? null) : null
953
-
954
- const supportsLocationImport =
955
- kind === 'device-adoption'
956
- ? (d?.supportsLocationImport ?? m.supportsLocationImport ?? false)
957
- : false
958
-
959
- return {
960
- addonId: m.id,
961
- name: m.name ?? m.id,
962
- description: m.description ?? '',
963
- iconUrl: icon ? `/api/addon-assets/${m.id}/${icon}` : null,
964
- color,
965
- instanceMode,
966
- discoveryMode,
967
- kind,
968
- brokerKind,
969
- supportsLocationImport,
970
- existingInstances: existing.map((i) => ({
971
- id: i.id,
972
- name: i.name,
973
- })),
974
- canAdd: instanceMode === 'multiple' || existing.length === 0,
975
- }
976
- })
977
- },
978
- testConnection: async (input) => {
979
- // Broker-backed integrations (Approach A) carry their connection
980
- // identity as a `brokerId` in settings — testing is a broker
981
- // concern now, so delegate to the addon's `broker` cap. The broker
982
- // already owns the real semantic check (HA opens a temporary WS
983
- // handshake; MQTT pings the bridge). We translate the broker's
984
- // discriminated result (`{ok:true,latencyMs}|{ok:false,error}`) into
985
- // the integrations `{success, error?}` output shape. Falls back to
986
- // the default RTSP/ffprobe path below for legacy device-provider
987
- // addons (Reolink/Frigate/ONVIF) that probe a stream URL.
988
- const registry = ar.getCapabilityRegistry()
989
- const brokerId = input.settings['brokerId']
990
- if (typeof brokerId === 'string' && brokerId.length > 0) {
991
- const brokerProvider = registry.getProviderByAddonId<IBrokerProvider>(
992
- 'broker',
993
- input.addonId,
994
- )
995
- if (!brokerProvider) {
996
- return {
997
- success: false,
998
- error: `Broker provider for addon '${input.addonId}' is not available`,
999
- }
1000
- }
1001
- try {
1002
- const result = await brokerProvider.testConnection({ id: brokerId })
1003
- return result.ok ? { success: true } : { success: false, error: result.error }
1004
- } catch (err) {
1005
- return { success: false, error: errMsg(err) }
1006
- }
1007
- }
1008
-
1009
- // Default — RTSP/Frigate/ONVIF legacy path: probe the stream URL.
1010
- const url = String(input.settings['main_stream_url'] ?? input.settings['url'] ?? '').trim()
1011
- if (!url) return { success: false, error: 'No stream URL provided' }
1012
- try {
1013
- const { stdout } = await execFileAsync(
1014
- 'ffprobe',
1015
- [
1016
- '-v',
1017
- 'error',
1018
- '-rtsp_transport',
1019
- 'tcp',
1020
- '-timeout',
1021
- '3000000',
1022
- '-show_entries',
1023
- 'stream=codec_name,width,height',
1024
- '-of',
1025
- 'json',
1026
- url,
1027
- ],
1028
- { timeout: 5000 },
1029
- )
1030
- const parsed = asJsonObject(JSON.parse(stdout))
1031
- const streams = asJsonArray(parsed?.streams)
1032
- return streams.length > 0
1033
- ? { success: true }
1034
- : { success: false, error: 'No streams found at URL' }
1035
- } catch (err) {
1036
- return {
1037
- success: false,
1038
- error: `Connection failed: ${errMsg(err)}`,
1039
- }
1040
- }
1041
- },
1042
- }
1043
- }
1044
-
1045
- // ── addons ──────────────────────────────────────────────────────────
1046
- //
1047
- // `addons.custom` enforces per-action auth dynamically. The cap-router
1048
- // codegen passes ctx into `getProvider(ctx)` so we can close over
1049
- // `ctx.user` and reject before dispatching.
1050
-
1051
- function ensureCustomActionAuth(ctx: TrpcContext, level: CapabilityMethodAuth): void {
1052
- if (level === 'public') return
1053
- if (!ctx.user) {
1054
- throw new TRPCError({ code: 'UNAUTHORIZED' })
1055
- }
1056
- if (level === 'protected') return
1057
- if (level === 'admin') {
1058
- if (!ctx.user.isAdmin) {
1059
- throw new TRPCError({ code: 'FORBIDDEN', message: 'custom action requires admin' })
1060
- }
1061
- return
1062
- }
1063
- }
1064
-
1065
- /** A node id that refers to the hub itself (top-level or a hub group runner). */
1066
- function isHubNode(nodeId: string): boolean {
1067
- return nodeId === 'hub' || nodeId.startsWith('hub/')
1068
- }
1069
-
1070
- /**
1071
- * Read an agent's installed npm packages via `$agent.status`. The
1072
- * agent reports its addon roster (id + status + version + packageName);
1073
- * we keep only entries that carry both a package name and a version so
1074
- * the hub can diff them against npm.
1075
- */
1076
- async function fetchAgentInstalledPackages(
1077
- broker: BrokerLike,
1078
- nodeId: string,
1079
- ): Promise<readonly { name: string; version: string }[]> {
1080
- const status = await broker.call<{
1081
- addons?: readonly { id: string; status: string; version?: string; packageName?: string }[]
1082
- }>('$agent.status', {}, { nodeID: nodeId, timeout: 5_000 })
1083
- const out: { name: string; version: string }[] = []
1084
- for (const a of status.addons ?? []) {
1085
- if (typeof a.packageName === 'string' && typeof a.version === 'string') {
1086
- out.push({ name: a.packageName, version: a.version })
1087
- }
1088
- }
1089
- return out
1090
- }
1091
-
1092
- export function buildAddonsProvider(
1093
- ar: AddonRegistryService,
1094
- ps: AddonPackageService,
1095
- ls: LoggingService,
1096
- moleculer: MoleculerService,
1097
- configService: ConfigService,
1098
- ctx: TrpcContext,
1099
- eb: EventBusService,
1100
- ): IAddonsProvider {
1101
- const broker = moleculer.broker as unknown as BrokerLike
1102
-
1103
- // Adapt the hub EventBusService (which takes a full SystemEvent object) to
1104
- // the IBulkUpdateEventBus interface (which takes (category, payload) pairs).
1105
- // Using `import { EventCategory }` from @camstack/types avoids a new import
1106
- // — it is already resolved in the generated-cap-routers layer above. The
1107
- // `eb.emit` overload that takes a TypedSystemEvent is the type-safe path.
1108
- const bulkEventBus = {
1109
- emit: (category: Parameters<typeof eb.emit>[0]['category'], payload: unknown) => {
1110
- eb.emit({
1111
- id: randomUUID(),
1112
- timestamp: new Date(),
1113
- source: { type: 'core', id: 'bulk-update-coordinator' },
1114
- category,
1115
- data: payload as Record<string, unknown>,
1116
- })
1117
- },
1118
- }
1119
-
1120
- const bulkCoordinator = new BulkUpdateCoordinator({
1121
- eventBus: bulkEventBus,
1122
- updateAddon: async (i) => {
1123
- await ps.updatePackage(i.name, i.version)
1124
- },
1125
- updateFrameworkPackage: async (i) => {
1126
- await ps.updateFrameworkPackage({
1127
- packageName: i.packageName,
1128
- version: i.version,
1129
- deferRestart: i.deferRestart,
1130
- })
1131
- },
1132
- restartServer: async () => {
1133
- await ps.restartServer(ctx.user?.username ?? ctx.user?.id)
1134
- },
1135
- logger: ls.createLogger('bulk-update'),
1136
- })
1137
-
1138
- const frameworkAllowSet = new Set<string>(FRAMEWORK_PACKAGE_ALLOWLIST)
1139
-
1140
- return {
1141
- list: async () => {
1142
- const rollbackable = ps.getRollbackablePackages()
1143
- const healthByPackage = new Map<
1144
- string,
1145
- ReturnType<typeof ar.getAddonHealthSnapshot>[number]
1146
- >()
1147
- for (const h of ar.getAddonHealthSnapshot()) {
1148
- healthByPackage.set(h.packageName, h)
1149
- }
1150
- return ar.listAllAddons().map((item) => ({
1151
- ...item,
1152
- hasBackup: rollbackable.has(item.manifest.packageName),
1153
- health: healthByPackage.get(item.manifest.packageName) ?? null,
1154
- }))
1155
- },
1156
- getLogs: async (input) =>
1157
- ls.query({
1158
- tags: { addonId: input.addonId },
1159
- limit: input.limit,
1160
- level: input.level,
1161
- }),
1162
- listPackages: async () => ps.listInstalled(),
1163
- installPackage: async (input) => ps.installAndLoad(input.packageName, input.version),
1164
- installFromWorkspace: async (input) => ps.installFromWorkspaceAndLoad(input.packageName),
1165
- isWorkspaceAvailable: async () => ps.isWorkspaceAvailable(),
1166
- listWorkspacePackages: async () => ps.listWorkspacePackages(),
1167
- uninstallPackage: async (input) => ps.uninstallAndReload(input.packageName),
1168
- reloadPackages: async () => ps.reloadPackages(),
1169
- searchAvailable: async (input) => {
1170
- const results = await ps.searchNpm(input?.query)
1171
- const installedIds = new Set(ps.listInstalled().map((p) => p.name))
1172
- return results.map((r) => ({
1173
- ...r,
1174
- installed: installedIds.has(r.name),
1175
- }))
1176
- },
1177
- listUpdates: async (input) => {
1178
- const nodeId = input.nodeId
1179
- const updates =
1180
- nodeId === undefined || isHubNode(nodeId)
1181
- ? await ps.checkUpdates()
1182
- : await ps.checkUpdatesForInstalled(await fetchAgentInstalledPackages(broker, nodeId))
1183
- return updates.map((u) => ({ ...u, isSystem: frameworkAllowSet.has(u.name) }))
1184
- },
1185
- updatePackage: async (input) => {
1186
- const nodeId = input.nodeId
1187
- if (nodeId === undefined || isHubNode(nodeId)) {
1188
- return ps.updatePackage(input.name, input.version)
1189
- }
1190
- // Agent target: the hub packs the resolved version and ships the
1191
- // tarball over `$agent.deploy` — the agent has no npm runtime.
1192
- const packed = await ps.packPackage(input.name, input.version)
1193
- await broker.call(
1194
- '$agent.deploy',
1195
- { addonId: input.name, bundle: packed.buffer },
1196
- { nodeID: nodeId, timeout: 120_000 },
1197
- )
1198
- await broker.call('$agent.reload', {}, { nodeID: nodeId, timeout: 120_000 })
1199
- return { success: true, name: input.name, version: packed.version, nodeId }
1200
- },
1201
- rollbackPackage: async (input) => ps.rollbackPackage(input.name),
1202
- forceRefresh: async (input) => {
1203
- const nodeId = input.nodeId
1204
- if (nodeId === undefined || isHubNode(nodeId)) return ps.checkUpdates(true)
1205
- // Agent rosters carry no hub-side cache — the diff is always live.
1206
- const installed = await fetchAgentInstalledPackages(broker, nodeId)
1207
- return ps.checkUpdatesForInstalled(installed)
1208
- },
1209
- restartServer: async () => ps.restartServer(ctx.user?.username ?? ctx.user?.id),
1210
- getLastRestart: async () => {
1211
- // Avoid pulling PostBootService through the cap-providers tree —
1212
- // dynamic import keeps the wiring loose and lets us read the
1213
- // static cache without a constructor dependency.
1214
- const mod = await import('../../boot/post-boot.service.js')
1215
- return mod.PostBootService.getLastRestart()
1216
- },
1217
- listFrameworkPackages: async () => ps.listFrameworkPackages(),
1218
- listCapabilityProviders: async (input) => {
1219
- const registry = ar.getCapabilityRegistry()
1220
- const caps = registry.listCapabilities()
1221
- const found = caps.find((c) => c.name === input.capName)
1222
- if (!found) return []
1223
- const mode = found.mode === 'collection' ? ('collection' as const) : ('singleton' as const)
1224
- const disabled = new Set(found.disabledProviders)
1225
- return found.providers.map((addonId) => ({
1226
- addonId,
1227
- mode,
1228
- isActive: mode === 'collection' ? !disabled.has(addonId) : found.activeProvider === addonId,
1229
- }))
1230
- },
1231
- setCapabilityProviderEnabled: async (input) => {
1232
- const registry = ar.getCapabilityRegistry()
1233
- const caps = registry.listCapabilities()
1234
- const found = caps.find((c) => c.name === input.capName)
1235
- if (!found) {
1236
- throw new TRPCError({
1237
- code: 'NOT_FOUND',
1238
- message: `Unknown capability: ${input.capName}`,
1239
- })
1240
- }
1241
- if (found.mode !== 'collection') {
1242
- throw new TRPCError({
1243
- code: 'BAD_REQUEST',
1244
- message: `Capability "${input.capName}" is not a collection`,
1245
- })
1246
- }
1247
- if (!found.providers.includes(input.addonId)) {
1248
- throw new TRPCError({
1249
- code: 'BAD_REQUEST',
1250
- message: `Provider "${input.addonId}" is not registered for "${input.capName}"`,
1251
- })
1252
- }
1253
- if (input.enabled) {
1254
- registry.enableCollectionProvider(input.capName, input.addonId)
1255
- } else {
1256
- registry.disableCollectionProvider(input.capName, input.addonId)
1257
- }
1258
- // Persist the new disabled-set so the choice survives a hub reboot.
1259
- // Reuses the same `capabilities.collection.<cap>` key/format the
1260
- // `capabilities` core router writes — via the shared canonical writer.
1261
- const updated = registry.listCapabilities().find((c) => c.name === input.capName)
1262
- persistCollectionDisabled(configService, input.capName, updated?.disabledProviders ?? [])
1263
- return { success: true as const }
1264
- },
1265
- updateFrameworkPackage: async (input) =>
1266
- ps.updateFrameworkPackage({
1267
- packageName: input.packageName,
1268
- ...(input.version !== undefined ? { version: input.version } : {}),
1269
- ...(ctx.user?.username !== undefined
1270
- ? { requestedBy: ctx.user.username }
1271
- : ctx.user?.id !== undefined
1272
- ? { requestedBy: ctx.user.id }
1273
- : {}),
1274
- ...(input.deferRestart !== undefined ? { deferRestart: input.deferRestart } : {}),
1275
- }),
1276
- getVersions: async (input) => ps.getPackageVersions(input.name),
1277
- restartAddon: async (input) => ar.restartAddon(input.addonId),
1278
- retryLoad: async (input) => {
1279
- await ar.retryAddonLoad(input.packageName)
1280
- return { success: true as const }
1281
- },
1282
- getAutoUpdateSettings: async () => ps.getAutoUpdateSettings(),
1283
- setAutoUpdateSettings: async (input) =>
1284
- ps.setAutoUpdateSettings(input.channel, input.intervalSeconds),
1285
- getAddonAutoUpdate: async (input) => ps.getAddonAutoUpdate(input.addonId),
1286
- setAddonAutoUpdate: async (input) => ps.setAddonAutoUpdate(input.addonId, input.channel),
1287
- applyAutoUpdateToAll: async (input) => {
1288
- await ps.setAutoUpdateSettings(input.channel)
1289
- for (const pkg of ps.listInstalled()) {
1290
- await ps.setAddonAutoUpdate(pkg.name, input.channel)
1291
- }
1292
- return { success: true as const }
1293
- },
1294
- startBulkUpdate: async (input) => bulkCoordinator.start(input),
1295
- getBulkUpdateState: async ({ id }) => bulkCoordinator.get(id),
1296
- cancelBulkUpdate: async ({ id }) => bulkCoordinator.cancel(id),
1297
- listActiveBulkUpdates: async ({ nodeId }) => bulkCoordinator.list(nodeId),
1298
- custom: async (input) => {
1299
- const registry = ar.getCustomActionRegistry()
1300
- const entry = registry.resolve(input.addonId, input.action)
1301
- if (!entry) {
1302
- throw new TRPCError({
1303
- code: 'NOT_FOUND',
1304
- message: `addon '${input.addonId}' has no custom action '${input.action}'`,
1305
- })
1306
- }
1307
- ensureCustomActionAuth(ctx, entry.spec.auth)
1308
- const parsedInput = entry.spec.input.parse(input.input)
1309
- const result = await entry.handler(parsedInput)
1310
- return entry.spec.output.parse(result)
1311
- },
1312
- onAddonLogs: (input, push) => {
1313
- const unsubscribe = ls.subscribe(
1314
- { tags: { addonId: input.addonId }, level: input.level },
1315
- (entry: {
1316
- timestamp: Date | string | number
1317
- level: string
1318
- message: string
1319
- scope?: string
1320
- }) => {
1321
- push({
1322
- timestamp:
1323
- entry.timestamp instanceof Date
1324
- ? entry.timestamp.toISOString()
1325
- : String(entry.timestamp),
1326
- level: entry.level,
1327
- message: entry.message,
1328
- scope: entry.scope,
1329
- })
1330
- },
1331
- )
1332
- return unsubscribe ?? (() => {})
1333
- },
1334
- }
1335
- }
1336
-
1337
- // Avoid an unused-import warning in environments where NotificationService
1338
- // is referenced solely via type imports above.
1339
- export type _NotificationServiceHint = NotificationService