@camstack/server 0.1.7 → 0.2.0

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 (135) hide show
  1. package/package.json +11 -9
  2. package/src/__tests__/addon-install-e2e.test.ts +0 -1
  3. package/src/__tests__/addon-pages-e2e.test.ts +40 -18
  4. package/src/__tests__/addon-settings-router.spec.ts +6 -1
  5. package/src/__tests__/addon-upload.spec.ts +91 -29
  6. package/src/__tests__/agent-registry.spec.ts +26 -9
  7. package/src/__tests__/agent-status-page.spec.ts +1 -3
  8. package/src/__tests__/auth-session-cookie.test.ts +28 -1
  9. package/src/__tests__/bulk-update-coordinator.spec.ts +48 -31
  10. package/src/__tests__/cap-ownership-authority.spec.ts +39 -8
  11. package/src/__tests__/cap-providers/cap-providers-location-import.spec.ts +206 -0
  12. package/src/__tests__/cap-providers/cap-usage-graph.spec.ts +17 -3
  13. package/src/__tests__/cap-providers/compute-topology-categories.spec.ts +57 -11
  14. package/src/__tests__/cap-providers/integrations-delete-cascade.spec.ts +292 -0
  15. package/src/__tests__/cap-providers-bulk-update.spec.ts +27 -7
  16. package/src/__tests__/cap-route-adapter.spec.ts +28 -15
  17. package/src/__tests__/cap-routers/_meta.spec.ts +6 -7
  18. package/src/__tests__/cap-routers/addon-settings.router.spec.ts +19 -10
  19. package/src/__tests__/cap-routers/broker-routing.router.spec.ts +177 -0
  20. package/src/__tests__/cap-routers/cap-route-error-formatter.spec.ts +3 -1
  21. package/src/__tests__/cap-routers/capabilities-node.spec.ts +18 -5
  22. package/src/__tests__/cap-routers/device-link-overlay.spec.ts +137 -0
  23. package/src/__tests__/cap-routers/device-manager-aggregate.router.spec.ts +72 -20
  24. package/src/__tests__/cap-routers/harness.ts +11 -7
  25. package/src/__tests__/cap-routers/metrics-provider.router.spec.ts +17 -3
  26. package/src/__tests__/cap-routers/null-provider-guard.spec.ts +5 -7
  27. package/src/__tests__/cap-routers/pipeline-executor.router.spec.ts +35 -11
  28. package/src/__tests__/cap-routers/settings-store.router.spec.ts +59 -15
  29. package/src/__tests__/capability-e2e.test.ts +9 -11
  30. package/src/__tests__/cli-e2e.test.ts +80 -59
  31. package/src/__tests__/core-cap-bridge.spec.ts +3 -1
  32. package/src/__tests__/dev-bootstrap-shm-ring.spec.ts +12 -2
  33. package/src/__tests__/device-settings-contribution-dispatch.spec.ts +61 -30
  34. package/src/__tests__/embedded-deps-e2e.test.ts +35 -19
  35. package/src/__tests__/event-bus-proxy-router.spec.ts +3 -0
  36. package/src/__tests__/framework-allowlist.spec.ts +5 -4
  37. package/src/__tests__/https-e2e.test.ts +12 -6
  38. package/src/__tests__/lifecycle-e2e.test.ts +60 -11
  39. package/src/__tests__/live-events-subscription.spec.ts +17 -18
  40. package/src/__tests__/moleculer/uds-readiness.spec.ts +11 -4
  41. package/src/__tests__/moleculer/uds-topology.spec.ts +39 -11
  42. package/src/__tests__/moleculer/uds-unowned-call.spec.ts +265 -5
  43. package/src/__tests__/moleculer-register-node-idempotency.spec.ts +16 -7
  44. package/src/__tests__/native-cap-route.spec.ts +42 -19
  45. package/src/__tests__/oauth2-account-linking.spec.ts +63 -17
  46. package/src/__tests__/singleton-contention.test.ts +23 -11
  47. package/src/__tests__/streaming-diagnostic.test.ts +156 -53
  48. package/src/__tests__/streaming-scale.test.ts +69 -35
  49. package/src/__tests__/uds-addon-call-wiring.spec.ts +6 -1
  50. package/src/agent-status-page.ts +4 -3
  51. package/src/api/__tests__/addons-custom.spec.ts +22 -8
  52. package/src/api/__tests__/capabilities.router.test.ts +18 -9
  53. package/src/api/addon-upload.ts +46 -15
  54. package/src/api/addons-custom.router.ts +7 -6
  55. package/src/api/auth-whoami.ts +3 -1
  56. package/src/api/bridge-addons.router.ts +3 -1
  57. package/src/api/capabilities.router.ts +117 -78
  58. package/src/api/core/__tests__/auth-router-totp.spec.ts +57 -16
  59. package/src/api/core/__tests__/integration-markers.spec.ts +10 -0
  60. package/src/api/core/addon-settings.router.ts +4 -1
  61. package/src/api/core/agents.router.ts +52 -53
  62. package/src/api/core/auth.router.ts +55 -36
  63. package/src/api/core/bulk-update-coordinator.ts +25 -22
  64. package/src/api/core/cap-providers.ts +459 -166
  65. package/src/api/core/capabilities.router.ts +30 -23
  66. package/src/api/core/hwaccel.router.ts +37 -10
  67. package/src/api/core/live-events.router.ts +16 -9
  68. package/src/api/core/logs.router.ts +58 -25
  69. package/src/api/core/notifications.router.ts +2 -1
  70. package/src/api/core/repl.router.ts +1 -3
  71. package/src/api/core/settings-backend.router.ts +68 -70
  72. package/src/api/core/system-events.router.ts +41 -32
  73. package/src/api/health/health.routes.ts +7 -13
  74. package/src/api/oauth2/__tests__/oauth2-routes.spec.ts +12 -2
  75. package/src/api/oauth2/consent-page.ts +4 -3
  76. package/src/api/oauth2/oauth2-routes.ts +41 -12
  77. package/src/api/trpc/__tests__/client-ip.spec.ts +27 -1
  78. package/src/api/trpc/__tests__/scope-access-device.spec.ts +68 -23
  79. package/src/api/trpc/__tests__/scope-access.spec.ts +8 -13
  80. package/src/api/trpc/__tests__/webrtc-session-ua-enrich.spec.ts +136 -0
  81. package/src/api/trpc/cap-mount-helpers.ts +64 -44
  82. package/src/api/trpc/cap-route-error-formatter.ts +17 -9
  83. package/src/api/trpc/client-ip.ts +17 -0
  84. package/src/api/trpc/core-cap-bridge.ts +3 -1
  85. package/src/api/trpc/generated-cap-mounts.ts +801 -286
  86. package/src/api/trpc/generated-cap-routers.ts +5723 -719
  87. package/src/api/trpc/scope-access.ts +7 -7
  88. package/src/api/trpc/trpc.context.ts +7 -4
  89. package/src/api/trpc/trpc.middleware.ts +4 -2
  90. package/src/api/trpc/trpc.router.ts +117 -48
  91. package/src/auth/session-cookie.ts +10 -0
  92. package/src/boot/__tests__/integration-id-backfill.spec.ts +131 -0
  93. package/src/boot/boot-config.ts +103 -122
  94. package/src/boot/integration-id-backfill.ts +109 -0
  95. package/src/boot/post-boot.service.ts +5 -3
  96. package/src/core/addon/__tests__/addon-registry-capability.test.ts +12 -3
  97. package/src/core/addon/__tests__/addon-row-manifest.spec.ts +62 -0
  98. package/src/core/addon/addon-call-gateway.ts +20 -6
  99. package/src/core/addon/addon-package.service.ts +183 -89
  100. package/src/core/addon/addon-registry.service.ts +1212 -1267
  101. package/src/core/addon/addon-row-manifest.ts +29 -0
  102. package/src/core/addon/addon-search.service.ts +2 -1
  103. package/src/core/addon/addon-settings-provider.ts +27 -7
  104. package/src/core/addon-bridge/addon-bridge.service.ts +11 -6
  105. package/src/core/addon-pages/addon-pages.service.ts +3 -1
  106. package/src/core/addon-widgets/addon-widgets.service.ts +5 -2
  107. package/src/core/agent/agent-registry.service.ts +60 -38
  108. package/src/core/auth/auth.service.spec.ts +6 -8
  109. package/src/core/config/config.service.spec.ts +1 -1
  110. package/src/core/events/event-bus.service.spec.ts +44 -21
  111. package/src/core/events/event-bus.service.ts +5 -1
  112. package/src/core/feature/feature.service.spec.ts +4 -1
  113. package/src/core/lifecycle/lifecycle-state-machine.spec.ts +8 -10
  114. package/src/core/logging/logging.service.spec.ts +61 -21
  115. package/src/core/logging/logging.service.ts +19 -5
  116. package/src/core/moleculer/cap-call-fn.spec.ts +17 -10
  117. package/src/core/moleculer/cap-call-fn.ts +5 -1
  118. package/src/core/moleculer/cap-route-authority.ts +18 -6
  119. package/src/core/moleculer/moleculer.service.ts +145 -29
  120. package/src/core/network/network-quality.service.spec.ts +7 -1
  121. package/src/core/notification/notification-wrapper.service.ts +1 -3
  122. package/src/core/notification/toast-wrapper.service.ts +1 -5
  123. package/src/core/repl/repl-engine.service.spec.ts +66 -39
  124. package/src/core/repl/repl-engine.service.ts +11 -12
  125. package/src/core/storage/storage-location-manager.spec.ts +12 -3
  126. package/src/core/streaming/stream-probe.service.ts +22 -13
  127. package/src/core/topology/topology-emitter.service.ts +5 -1
  128. package/src/launcher.ts +14 -9
  129. package/src/main.ts +658 -495
  130. package/src/manual-boot.ts +133 -154
  131. package/tsconfig.json +20 -8
  132. package/src/core/storage/settings-store.spec.ts +0 -213
  133. package/src/core/storage/settings-store.ts +0 -2
  134. package/src/core/storage/sql-schema.spec.ts +0 -140
  135. package/src/core/storage/sql-schema.ts +0 -3
@@ -41,9 +41,10 @@ import type {
41
41
  Integration,
42
42
  IIntegrationRegistry,
43
43
  IDeviceProvider,
44
+ IBrokerProvider,
44
45
  CapabilityMethodAuth,
45
46
  } from '@camstack/types'
46
- import { asJsonObject, asJsonArray, errMsg } from '@camstack/types'
47
+ import { asJsonObject, asJsonArray, errMsg, EventCategory } from '@camstack/types'
47
48
  import type { CapabilityRegistry } from '@camstack/kernel'
48
49
  import { getCapUsageRegistry } from '@camstack/kernel'
49
50
  import type { ToastService, NotificationService } from '@camstack/core'
@@ -57,6 +58,7 @@ import type { AddonRegistryService } from '../../core/addon/addon-registry.servi
57
58
  import type { AddonPackageService } from '../../core/addon/addon-package.service'
58
59
  import type { NetworkQualityService } from '../../core/network/network-quality.service'
59
60
  import type { ConfigService } from '../../core/config/config.service'
61
+ import { planDeleteTimeStamps } from '../../boot/integration-id-backfill'
60
62
  import { persistCollectionDisabled } from './collection-preference.js'
61
63
  import { BulkUpdateCoordinator } from './bulk-update-coordinator.js'
62
64
  import { FRAMEWORK_PACKAGE_ALLOWLIST } from '../../core/addon/addon-package.service.js'
@@ -66,7 +68,9 @@ const execFileAsync = promisify(execFile)
66
68
  // ── system ──────────────────────────────────────────────────────────
67
69
 
68
70
  function getRetention(registry: CapabilityRegistry | null) {
69
- return registry?.getSingleton<IAnalysisDataPersistence>('analysis-data-persistence')?.retention ?? null
71
+ return (
72
+ registry?.getSingleton<IAnalysisDataPersistence>('analysis-data-persistence')?.retention ?? null
73
+ )
70
74
  }
71
75
 
72
76
  export function buildSystemProvider(
@@ -105,9 +109,7 @@ export function buildSystemProvider(
105
109
 
106
110
  // ── network-quality ─────────────────────────────────────────────────
107
111
 
108
- export function buildNetworkQualityProvider(
109
- nq: NetworkQualityService,
110
- ): INetworkQualityProvider {
112
+ export function buildNetworkQualityProvider(nq: NetworkQualityService): INetworkQualityProvider {
111
113
  return {
112
114
  getDeviceStats: async (input) => nq.getDeviceStats(input.deviceId),
113
115
  getAllStats: async () => nq.getAllStats(),
@@ -116,6 +118,7 @@ export function buildNetworkQualityProvider(
116
118
  rttMs: input.rttMs,
117
119
  jitterMs: input.jitterMs,
118
120
  estimatedBandwidthKbps: input.estimatedBandwidthKbps,
121
+ packetLossPercent: input.packetLossPercent,
119
122
  })
120
123
  },
121
124
  }
@@ -138,7 +141,9 @@ export function buildToastProvider(
138
141
  if (!toastService) return () => {}
139
142
  const userId = ctx.user?.id ?? 'anonymous'
140
143
  const connectionId = randomUUID()
141
- const unsubscribe = toastService.subscribe(connectionId, userId, (toast: Toast) => push(toast))
144
+ const unsubscribe = toastService.subscribe(connectionId, userId, (toast: Toast) =>
145
+ push(toast),
146
+ )
142
147
  return unsubscribe ?? (() => {})
143
148
  },
144
149
  }
@@ -181,14 +186,14 @@ export async function computeTopology(
181
186
  ): Promise<readonly TopologyNode[]> {
182
187
  const nodes = await agentRegistry.listNodes()
183
188
  const allAddons = addonRegistry?.listAddons() ?? []
184
- const getInGroupAddonIds = (node: typeof nodes[number]): readonly string[] => {
189
+ const getInGroupAddonIds = (node: (typeof nodes)[number]): readonly string[] => {
185
190
  const subs = (node.subProcesses ?? []) as readonly SubProcessLite[]
186
191
  return subs.flatMap((p) => p.addonIds ?? [])
187
192
  }
188
193
  const addonCaps = new Map<string, readonly string[]>()
189
194
  for (const a of allAddons) {
190
195
  const id = a.manifest?.id ?? ''
191
- const caps = a.declaration?.capabilities?.map(c => typeof c === 'string' ? c : c.name) ?? []
196
+ const caps = a.declaration?.capabilities?.map((c) => (typeof c === 'string' ? c : c.name)) ?? []
192
197
  addonCaps.set(id, caps)
193
198
  }
194
199
  const addonCategory = new Map<string, string>()
@@ -199,7 +204,8 @@ export async function computeTopology(
199
204
  }
200
205
  return nodes.map((node) => {
201
206
  const inGroupAddonIds = new Set(getInGroupAddonIds(node))
202
- const agentAddonIds: readonly string[] = (node as { agentAddons?: readonly string[] }).agentAddons ?? []
207
+ const agentAddonIds: readonly string[] =
208
+ (node as { agentAddons?: readonly string[] }).agentAddons ?? []
203
209
  type NodeAddonEntry = { id: string; capabilities: readonly string[]; status: 'running' }
204
210
  const allNodeAddons: NodeAddonEntry[] = node.isHub
205
211
  ? allAddons.map((a) => {
@@ -212,31 +218,33 @@ export async function computeTopology(
212
218
  status: 'running' as const,
213
219
  }))
214
220
  const inProcessAddons = allNodeAddons.filter((a) => !inGroupAddonIds.has(a.id))
215
- const isolatedProcesses = ((node.subProcesses ?? []) as readonly SubProcessLite[])
216
- const mainProcessServices = inProcessAddons.map(a => ({
221
+ const isolatedProcesses = (node.subProcesses ?? []) as readonly SubProcessLite[]
222
+ const mainProcessServices = inProcessAddons.map((a) => ({
217
223
  addonId: a.id,
218
224
  capabilities: a.capabilities,
219
225
  status: a.status,
220
226
  }))
221
- const mainProcess = node.isHub ? {
222
- pid: process.pid,
223
- name: 'hub (core)',
224
- state: 'running' as const,
225
- cpuPercent: node.status?.cpuPercent ?? 0,
226
- memoryRss: process.memoryUsage().rss,
227
- uptimeSeconds: Math.floor(process.uptime()),
228
- services: mainProcessServices,
229
- } : {
230
- pid: 0,
231
- name: `${node.info.id} (core)`,
232
- state: 'running' as const,
233
- cpuPercent: node.status?.cpuPercent ?? 0,
234
- memoryRss: 0,
235
- uptimeSeconds: Math.floor((Date.now() - node.connectedSince) / 1000),
236
- services: mainProcessServices,
237
- }
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
+ }
238
246
  const childProcesses = isolatedProcesses.map((p) => {
239
- const memberIds = (p.addonIds && p.addonIds.length > 0) ? p.addonIds : [p.name]
247
+ const memberIds = p.addonIds && p.addonIds.length > 0 ? p.addonIds : [p.name]
240
248
  return {
241
249
  pid: p.pid,
242
250
  name: p.name,
@@ -255,16 +263,23 @@ export async function computeTopology(
255
263
  // Aggregate node-local addons by category. `allNodeAddons` already
256
264
  // contains the per-node addon roster (hub uses every installed
257
265
  // addon; agents use their assigned agentAddons subset).
258
- const byCategory = new Map<string, {
259
- category: string
260
- total: number
261
- healthy: number
262
- addons: { id: string; status: string; cpuPercent: number; memoryRss: number }[]
263
- }>()
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
+ >()
264
275
  const procByAddon = new Map<string, { cpuPercent: number; memoryRss: number; state: string }>()
265
276
  for (const p of (node.subProcesses ?? []) as readonly SubProcessLite[]) {
266
- for (const addonId of (p.addonIds ?? [])) {
267
- procByAddon.set(addonId, { cpuPercent: p.cpuPercent, memoryRss: p.memoryRss, state: p.state })
277
+ for (const addonId of p.addonIds ?? []) {
278
+ procByAddon.set(addonId, {
279
+ cpuPercent: p.cpuPercent,
280
+ memoryRss: p.memoryRss,
281
+ state: p.state,
282
+ })
268
283
  }
269
284
  }
270
285
  for (const a of allNodeAddons) {
@@ -301,10 +316,7 @@ export async function computeTopology(
301
316
  lastSeen: new Date().toISOString(),
302
317
  localIps: node.isHub ? getLocalIps() : (node.localIps ?? []),
303
318
  addons: allNodeAddons,
304
- processes: [
305
- mainProcess,
306
- ...childProcesses,
307
- ],
319
+ processes: [mainProcess, ...childProcesses],
308
320
  categories: categoriesProjection,
309
321
  }
310
322
  })
@@ -319,7 +331,11 @@ export async function computeTopology(
319
331
  * `addon-registry.service.ts`; consider hoisting if a third call site appears.
320
332
  */
321
333
  interface BrokerLike {
322
- call<T = unknown>(action: string, params?: unknown, opts?: { nodeID?: string; timeout?: number }): Promise<T>
334
+ call<T = unknown>(
335
+ action: string,
336
+ params?: unknown,
337
+ opts?: { nodeID?: string; timeout?: number },
338
+ ): Promise<T>
323
339
  }
324
340
 
325
341
  export function buildNodesProvider(
@@ -335,9 +351,13 @@ export function buildNodesProvider(
335
351
  return { success: true }
336
352
  },
337
353
  undeployAddon: async (input) => {
338
- await broker.call('$agent.undeploy', {
339
- addonId: input.addonId,
340
- }, { nodeID: input.nodeId, timeout: 30_000 })
354
+ await broker.call(
355
+ '$agent.undeploy',
356
+ {
357
+ addonId: input.addonId,
358
+ },
359
+ { nodeID: input.nodeId, timeout: 30_000 },
360
+ )
341
361
  return { success: true }
342
362
  },
343
363
  restartAddon: async (input) => {
@@ -350,31 +370,47 @@ export function buildNodesProvider(
350
370
  return { success: true }
351
371
  }
352
372
  const agentNodeId = input.nodeId.includes('/') ? input.nodeId.split('/')[0]! : input.nodeId
353
- await broker.call('$agent.restart', {
354
- addonId: input.addonId,
355
- }, { nodeID: agentNodeId, timeout: 30_000 })
373
+ await broker.call(
374
+ '$agent.restart',
375
+ {
376
+ addonId: input.addonId,
377
+ },
378
+ { nodeID: agentNodeId, timeout: 30_000 },
379
+ )
356
380
  return { success: true }
357
381
  },
358
382
  restartProcess: async (input) => {
359
- return await broker.call('$process.restart', {
360
- name: input.processName,
361
- }, { nodeID: input.nodeId, timeout: 30_000 }) as { success: boolean; reason?: string }
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 }
362
390
  },
363
391
  restartNode: async (input) => {
364
- return await broker.call('$process.restartAll', {}, {
365
- nodeID: input.nodeId,
366
- timeout: 60_000,
367
- }) as { restarted: readonly string[]; failed: readonly string[] }
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[] }
368
400
  },
369
401
  shutdownNode: async (input) => {
370
402
  if (input.nodeId === 'hub') {
371
403
  setTimeout(() => process.exit(0), 500)
372
404
  return { success: true }
373
405
  }
374
- await broker.call('$agent.shutdown', {}, {
375
- nodeID: input.nodeId,
376
- timeout: 10_000,
377
- })
406
+ await broker.call(
407
+ '$agent.shutdown',
408
+ {},
409
+ {
410
+ nodeID: input.nodeId,
411
+ timeout: 10_000,
412
+ },
413
+ )
378
414
  return { success: true }
379
415
  },
380
416
  renameNode: async (input) => {
@@ -387,9 +423,13 @@ export function buildNodesProvider(
387
423
  value: trimmed,
388
424
  })
389
425
  } else {
390
- await broker.call('$agent.rename', {
391
- name: trimmed,
392
- }, { nodeID: input.nodeId, timeout: 10_000 })
426
+ await broker.call(
427
+ '$agent.rename',
428
+ {
429
+ name: trimmed,
430
+ },
431
+ { nodeID: input.nodeId, timeout: 10_000 },
432
+ )
393
433
  agentRegistry.updateAgentName(input.nodeId, trimmed)
394
434
  }
395
435
  return { nodeId: input.nodeId, name: trimmed }
@@ -436,17 +476,50 @@ export function buildNodesProvider(
436
476
  const remoteStatuses = await Promise.all(
437
477
  remoteNodes.map(async (node) => {
438
478
  try {
439
- const status = await broker.call('$agent.status', {}, {
440
- nodeID: node.info.id, timeout: 5_000,
441
- }) as { addons?: readonly { id: string; status: string; version?: string; packageName?: string }[] }
442
- return { nodeId: node.info.id, name: node.info.name, online: true, addons: status.addons ?? [] }
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
+ }
443
500
  } catch {
444
- return { nodeId: node.info.id, name: node.info.name, online: false, addons: [] as readonly { id: string; status: string; version?: string; packageName?: string }[] }
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
+ }
445
512
  }
446
513
  }),
447
514
  )
448
515
 
449
- type NodeDeployment = { nodeId: string; name: string; version: string; status: string; synced: boolean }
516
+ type NodeDeployment = {
517
+ nodeId: string
518
+ name: string
519
+ version: string
520
+ status: string
521
+ synced: boolean
522
+ }
450
523
  const result: Record<string, { hubVersion: string; nodes: NodeDeployment[] }> = {}
451
524
 
452
525
  for (const [addonId, hubAddon] of hubMap) {
@@ -486,9 +559,13 @@ export function buildNodesProvider(
486
559
  // also work — both are safe to run in parallel during Phase E.
487
560
  const reachedViaUds = moleculer.setChildLogLevelByNodeId(input.nodeId, input.level)
488
561
  if (!reachedViaUds) {
489
- await broker.call('$node-mgmt.setLogLevel', {
490
- level: input.level,
491
- }, { nodeID: input.nodeId, timeout: 5_000 })
562
+ await broker.call(
563
+ '$node-mgmt.setLogLevel',
564
+ {
565
+ level: input.level,
566
+ },
567
+ { nodeID: input.nodeId, timeout: 5_000 },
568
+ )
492
569
  }
493
570
  return { success: true }
494
571
  },
@@ -515,9 +592,11 @@ function requireIntegrationRegistry(ar: AddonRegistryService): IIntegrationRegis
515
592
  }
516
593
 
517
594
  function isDeviceProvider(value: unknown): value is IDeviceProvider {
518
- return value !== null
519
- && typeof value === 'object'
520
- && typeof Reflect.get(value, 'discoverDevices') === 'function'
595
+ return (
596
+ value !== null &&
597
+ typeof value === 'object' &&
598
+ typeof Reflect.get(value, 'discoverDevices') === 'function'
599
+ )
521
600
  }
522
601
 
523
602
  function getDeviceProvider(ar: AddonRegistryService, addonId: string): IDeviceProvider | null {
@@ -525,17 +604,33 @@ function getDeviceProvider(ar: AddonRegistryService, addonId: string): IDevicePr
525
604
  return isDeviceProvider(provider) ? provider : null
526
605
  }
527
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
+
528
623
  export function buildIntegrationsProvider(
529
624
  ar: AddonRegistryService,
530
625
  eb: EventBusService,
531
626
  loggingService: LoggingService,
627
+ capabilityRegistry: CapabilityRegistry | null,
532
628
  ): IIntegrationsProvider {
533
629
  const logger = loggingService.createLogger('integrations')
534
630
  const withProcessState = (i: Integration): IntegrationWithProcessState => ({
535
631
  ...i,
536
632
  processState:
537
- ar.listAddons().find(a => a.manifest.id === i.addonId)?.process?.state
538
- ?? 'unknown',
633
+ ar.listAddons().find((a) => a.manifest.id === i.addonId)?.process?.state ?? 'unknown',
539
634
  })
540
635
 
541
636
  return {
@@ -548,25 +643,27 @@ export function buildIntegrationsProvider(
548
643
  if (!integration) throw new Error(`Integration "${input.id}" not found`)
549
644
  return withProcessState(integration)
550
645
  },
551
- getByAddonId: async (input) => requireIntegrationRegistry(ar).getIntegrationByAddonId(input.addonId),
646
+ getByAddonId: async (input) =>
647
+ requireIntegrationRegistry(ar).getIntegrationByAddonId(input.addonId),
552
648
  create: async (input) => {
553
649
  const { skipRestart, ...payload } = input
554
650
  const reg = requireIntegrationRegistry(ar)
555
651
 
556
- logger.info('request', { tags: { addonId: input.addonId }, meta: { phase: 'create', name: input.name } })
652
+ logger.info('request', {
653
+ tags: { addonId: input.addonId },
654
+ meta: { phase: 'create', name: input.name },
655
+ })
557
656
 
558
657
  const addon = ar.listAddons().find((a) => a.manifest.id === input.addonId)
559
658
  const instanceMode =
560
659
  addon?.declaration?.instanceMode ?? addon?.manifest?.instanceMode ?? 'multiple'
561
660
  if (instanceMode === 'unique') {
562
- const existing = (await reg.listIntegrations()).filter(
563
- (i) => i.addonId === input.addonId,
564
- )
661
+ const existing = (await reg.listIntegrations()).filter((i) => i.addonId === input.addonId)
565
662
  if (existing.length > 0) {
566
- logger.warn(
567
- 'rejected duplicate unique',
568
- { tags: { addonId: input.addonId, integrationId: existing[0]!.id }, meta: { phase: 'create' } },
569
- )
663
+ logger.warn('rejected duplicate unique', {
664
+ tags: { addonId: input.addonId, integrationId: existing[0]!.id },
665
+ meta: { phase: 'create' },
666
+ })
570
667
  throw new Error(
571
668
  `Addon "${input.addonId}" is unique-instance and already has an integration (${existing[0]!.id})`,
572
669
  )
@@ -574,15 +671,26 @@ export function buildIntegrationsProvider(
574
671
  }
575
672
 
576
673
  const integration = await reg.createIntegration(payload)
577
- logger.info('persisted', { tags: { integrationId: integration.id, addonId: integration.addonId }, meta: { phase: 'create' } })
674
+ logger.info('persisted', {
675
+ tags: { integrationId: integration.id, addonId: integration.addonId },
676
+ meta: { phase: 'create' },
677
+ })
578
678
 
579
679
  const hasSettings = input.settings != null && Object.keys(input.settings).length > 0
580
680
  if (!skipRestart && hasSettings) {
581
- logger.info('settings present — restarting addon', { tags: { addonId: input.addonId }, meta: { phase: 'create' } })
681
+ logger.info('settings present — restarting addon', {
682
+ tags: { addonId: input.addonId },
683
+ meta: { phase: 'create' },
684
+ })
582
685
  await ar.restartAddon(input.addonId)
583
- logger.info('addon restart complete', { tags: { addonId: input.addonId }, meta: { phase: 'create' } })
686
+ logger.info('addon restart complete', {
687
+ tags: { addonId: input.addonId },
688
+ meta: { phase: 'create' },
689
+ })
584
690
  } else {
585
- logger.info('skipping restart (no settings or skipRestart=true)', { meta: { phase: 'create' } })
691
+ logger.info('skipping restart (no settings or skipRestart=true)', {
692
+ meta: { phase: 'create' },
693
+ })
586
694
  }
587
695
  return integration
588
696
  },
@@ -597,22 +705,21 @@ export function buildIntegrationsProvider(
597
705
  const changedFields = Object.keys(updates).filter(
598
706
  (k) => (updates as Record<string, unknown>)[k] !== undefined,
599
707
  )
600
- logger.info(
601
- 'request',
602
- { tags: { integrationId: input.id, addonId: previous.addonId }, meta: { phase: 'update', fields: changedFields } },
603
- )
708
+ logger.info('request', {
709
+ tags: { integrationId: input.id, addonId: previous.addonId },
710
+ meta: { phase: 'update', fields: changedFields },
711
+ })
604
712
 
605
713
  const result = await reg.updateIntegration(id, updates)
606
714
  if (!result) throw new Error(`Integration "${id}" not found`)
607
715
 
608
- const enabledChanged =
609
- input.enabled !== undefined && input.enabled !== previous.enabled
716
+ const enabledChanged = input.enabled !== undefined && input.enabled !== previous.enabled
610
717
  if (enabledChanged) {
611
718
  const category = input.enabled ? 'integration.enabled' : 'integration.disabled'
612
- logger.info(
613
- 'enabled state changed',
614
- { tags: { integrationId: result.id, addonId: result.addonId }, meta: { phase: 'update', enabled: input.enabled } },
615
- )
719
+ logger.info('enabled state changed', {
720
+ tags: { integrationId: result.id, addonId: result.addonId },
721
+ meta: { phase: 'update', enabled: input.enabled },
722
+ })
616
723
  eb.emit({
617
724
  id: `integration-${category}-${Date.now()}`,
618
725
  timestamp: new Date(),
@@ -626,17 +733,23 @@ export function buildIntegrationsProvider(
626
733
  }
627
734
 
628
735
  if (input.name !== undefined && input.name !== previous.name) {
629
- logger.info(
630
- 'renamed',
631
- { tags: { integrationId: result.id }, meta: { phase: 'update', previousName: previous.name, newName: input.name } },
632
- )
736
+ logger.info('renamed', {
737
+ tags: { integrationId: result.id },
738
+ meta: { phase: 'update', previousName: previous.name, newName: input.name },
739
+ })
633
740
  }
634
741
 
635
742
  const infoChanged = input.info !== undefined
636
743
  if (infoChanged) {
637
- logger.info('info changed — restarting addon', { tags: { addonId: result.addonId }, meta: { phase: 'update' } })
744
+ logger.info('info changed — restarting addon', {
745
+ tags: { addonId: result.addonId },
746
+ meta: { phase: 'update' },
747
+ })
638
748
  await ar.restartAddon(result.addonId)
639
- logger.info('addon restart complete', { tags: { addonId: result.addonId }, meta: { phase: 'update' } })
749
+ logger.info('addon restart complete', {
750
+ tags: { addonId: result.addonId },
751
+ meta: { phase: 'update' },
752
+ })
640
753
  } else {
641
754
  logger.info('no restart needed (only enabled/name changed)', { meta: { phase: 'update' } })
642
755
  }
@@ -651,23 +764,107 @@ export function buildIntegrationsProvider(
651
764
  logger.warn('not found', { tags: { integrationId: input.id }, meta: { phase: 'delete' } })
652
765
  throw new Error(`Integration "${input.id}" not found`)
653
766
  }
654
- logger.info(
655
- 'removing',
656
- { tags: { integrationId: input.id, addonId: integration.addonId }, meta: { phase: 'delete', name: integration.name } },
657
- )
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
+
658
852
  await reg.deleteIntegration(input.id)
659
853
 
660
854
  eb.emit({
661
855
  id: `integration-deleted-${Date.now()}`,
662
856
  timestamp: new Date(),
663
857
  source: { type: 'integration', id: input.id },
664
- category: 'integration.deleted',
858
+ category: EventCategory.IntegrationDeleted,
665
859
  data: {
666
860
  integrationId: input.id,
667
861
  addonId: integration.addonId,
668
862
  },
669
863
  })
670
- logger.info('completed (no restart)', { tags: { integrationId: input.id }, meta: { phase: 'delete' } })
864
+ logger.info('completed (no restart)', {
865
+ tags: { integrationId: input.id },
866
+ meta: { phase: 'delete' },
867
+ })
671
868
 
672
869
  return { success: true, deletedId: input.id }
673
870
  },
@@ -681,19 +878,28 @@ export function buildIntegrationsProvider(
681
878
  const reg = requireIntegrationRegistry(ar)
682
879
  const integration = await reg.getIntegration(input.id)
683
880
  if (!integration) {
684
- logger.warn('not found', { tags: { integrationId: input.id }, meta: { phase: 'setSettings' } })
881
+ logger.warn('not found', {
882
+ tags: { integrationId: input.id },
883
+ meta: { phase: 'setSettings' },
884
+ })
685
885
  throw new Error(`Integration "${input.id}" not found`)
686
886
  }
687
887
  const settingsKeys = Object.keys(input.settings)
688
- logger.info(
689
- 'request',
690
- { tags: { integrationId: input.id, addonId: integration.addonId }, meta: { phase: 'setSettings', keys: settingsKeys } },
691
- )
888
+ logger.info('request', {
889
+ tags: { integrationId: input.id, addonId: integration.addonId },
890
+ meta: { phase: 'setSettings', keys: settingsKeys },
891
+ })
692
892
  await reg.setIntegrationSettings(input.id, input.settings)
693
893
 
694
- logger.info('persisted — restarting addon', { tags: { addonId: integration.addonId }, meta: { phase: 'setSettings' } })
894
+ logger.info('persisted — restarting addon', {
895
+ tags: { addonId: integration.addonId },
896
+ meta: { phase: 'setSettings' },
897
+ })
695
898
  await ar.restartAddon(integration.addonId)
696
- logger.info('addon restart complete', { tags: { addonId: integration.addonId }, meta: { phase: 'setSettings' } })
899
+ logger.info('addon restart complete', {
900
+ tags: { addonId: integration.addonId },
901
+ meta: { phase: 'setSettings' },
902
+ })
697
903
  return { success: true }
698
904
  },
699
905
  getAvailableTypes: async () => {
@@ -704,22 +910,52 @@ export function buildIntegrationsProvider(
704
910
  // integration against an addon that didn't load produces an orphaned
705
911
  // row that `createFilteredRegistry` then filters out — silent data
706
912
  // loss from the operator's POV. Filter at the source instead.
707
- const providerAddons = addons.filter(a =>
708
- a.process?.state !== 'failed' &&
709
- a.manifest.capabilities?.some(c =>
710
- typeof c === 'string' ? c === 'device-provider' : c.name === 'device-provider',
711
- ),
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
+ }),
712
924
  )
713
925
  const integrations = await reg.listIntegrations()
714
- return providerAddons.map(addon => {
926
+ return providerAddons.map((addon) => {
715
927
  const m = addon.manifest
716
928
  const d = addon.declaration
717
929
  const icon = d?.icon ?? m.icon
718
930
  const color = d?.color ?? m.color ?? '#78716c'
719
931
  const instanceMode = d?.instanceMode ?? m.instanceMode ?? 'multiple'
720
- const existing = integrations.filter(i => i.addonId === m.id)
932
+ const existing = integrations.filter((i) => i.addonId === m.id)
721
933
  const provider = getDeviceProvider(ar, m.id)
722
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
+
723
959
  return {
724
960
  addonId: m.id,
725
961
  name: m.name ?? m.id,
@@ -728,7 +964,10 @@ export function buildIntegrationsProvider(
728
964
  color,
729
965
  instanceMode,
730
966
  discoveryMode,
731
- existingInstances: existing.map(i => ({
967
+ kind,
968
+ brokerKind,
969
+ supportsLocationImport,
970
+ existingInstances: existing.map((i) => ({
732
971
  id: i.id,
733
972
  name: i.name,
734
973
  })),
@@ -737,14 +976,55 @@ export function buildIntegrationsProvider(
737
976
  })
738
977
  },
739
978
  testConnection: async (input) => {
740
- const url = String(
741
- input.settings['main_stream_url'] ?? input.settings['url'] ?? '',
742
- ).trim()
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()
743
1011
  if (!url) return { success: false, error: 'No stream URL provided' }
744
1012
  try {
745
1013
  const { stdout } = await execFileAsync(
746
1014
  'ffprobe',
747
- ['-v', 'error', '-rtsp_transport', 'tcp', '-timeout', '3000000', '-show_entries', 'stream=codec_name,width,height', '-of', 'json', url],
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
+ ],
748
1028
  { timeout: 5000 },
749
1029
  )
750
1030
  const parsed = asJsonObject(JSON.parse(stdout))
@@ -839,7 +1119,9 @@ export function buildAddonsProvider(
839
1119
 
840
1120
  const bulkCoordinator = new BulkUpdateCoordinator({
841
1121
  eventBus: bulkEventBus,
842
- updateAddon: async (i) => { await ps.updatePackage(i.name, i.version) },
1122
+ updateAddon: async (i) => {
1123
+ await ps.updatePackage(i.name, i.version)
1124
+ },
843
1125
  updateFrameworkPackage: async (i) => {
844
1126
  await ps.updateFrameworkPackage({
845
1127
  packageName: i.packageName,
@@ -858,7 +1140,10 @@ export function buildAddonsProvider(
858
1140
  return {
859
1141
  list: async () => {
860
1142
  const rollbackable = ps.getRollbackablePackages()
861
- const healthByPackage = new Map<string, ReturnType<typeof ar.getAddonHealthSnapshot>[number]>()
1143
+ const healthByPackage = new Map<
1144
+ string,
1145
+ ReturnType<typeof ar.getAddonHealthSnapshot>[number]
1146
+ >()
862
1147
  for (const h of ar.getAddonHealthSnapshot()) {
863
1148
  healthByPackage.set(h.packageName, h)
864
1149
  }
@@ -868,11 +1153,12 @@ export function buildAddonsProvider(
868
1153
  health: healthByPackage.get(item.manifest.packageName) ?? null,
869
1154
  }))
870
1155
  },
871
- getLogs: async (input) => ls.query({
872
- tags: { addonId: input.addonId },
873
- limit: input.limit,
874
- level: input.level,
875
- }),
1156
+ getLogs: async (input) =>
1157
+ ls.query({
1158
+ tags: { addonId: input.addonId },
1159
+ limit: input.limit,
1160
+ level: input.level,
1161
+ }),
876
1162
  listPackages: async () => ps.listInstalled(),
877
1163
  installPackage: async (input) => ps.installAndLoad(input.packageName, input.version),
878
1164
  installFromWorkspace: async (input) => ps.installFromWorkspaceAndLoad(input.packageName),
@@ -890,10 +1176,11 @@ export function buildAddonsProvider(
890
1176
  },
891
1177
  listUpdates: async (input) => {
892
1178
  const nodeId = input.nodeId
893
- const updates = nodeId === undefined || isHubNode(nodeId)
894
- ? await ps.checkUpdates()
895
- : await ps.checkUpdatesForInstalled(await fetchAgentInstalledPackages(broker, nodeId))
896
- return updates.map(u => ({ ...u, isSystem: frameworkAllowSet.has(u.name) }))
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) }))
897
1184
  },
898
1185
  updatePackage: async (input) => {
899
1186
  const nodeId = input.nodeId
@@ -903,7 +1190,11 @@ export function buildAddonsProvider(
903
1190
  // Agent target: the hub packs the resolved version and ships the
904
1191
  // tarball over `$agent.deploy` — the agent has no npm runtime.
905
1192
  const packed = await ps.packPackage(input.name, input.version)
906
- await broker.call('$agent.deploy', { addonId: input.name, bundle: packed.buffer }, { nodeID: nodeId, timeout: 120_000 })
1193
+ await broker.call(
1194
+ '$agent.deploy',
1195
+ { addonId: input.name, bundle: packed.buffer },
1196
+ { nodeID: nodeId, timeout: 120_000 },
1197
+ )
907
1198
  await broker.call('$agent.reload', {}, { nodeID: nodeId, timeout: 120_000 })
908
1199
  return { success: true, name: input.name, version: packed.version, nodeId }
909
1200
  },
@@ -929,14 +1220,12 @@ export function buildAddonsProvider(
929
1220
  const caps = registry.listCapabilities()
930
1221
  const found = caps.find((c) => c.name === input.capName)
931
1222
  if (!found) return []
932
- const mode = found.mode === 'collection' ? 'collection' as const : 'singleton' as const
1223
+ const mode = found.mode === 'collection' ? ('collection' as const) : ('singleton' as const)
933
1224
  const disabled = new Set(found.disabledProviders)
934
1225
  return found.providers.map((addonId) => ({
935
1226
  addonId,
936
1227
  mode,
937
- isActive: mode === 'collection'
938
- ? !disabled.has(addonId)
939
- : found.activeProvider === addonId,
1228
+ isActive: mode === 'collection' ? !disabled.has(addonId) : found.activeProvider === addonId,
940
1229
  }))
941
1230
  },
942
1231
  setCapabilityProviderEnabled: async (input) => {
@@ -970,23 +1259,20 @@ export function buildAddonsProvider(
970
1259
  // Reuses the same `capabilities.collection.<cap>` key/format the
971
1260
  // `capabilities` core router writes — via the shared canonical writer.
972
1261
  const updated = registry.listCapabilities().find((c) => c.name === input.capName)
973
- persistCollectionDisabled(
974
- configService,
975
- input.capName,
976
- updated?.disabledProviders ?? [],
977
- )
1262
+ persistCollectionDisabled(configService, input.capName, updated?.disabledProviders ?? [])
978
1263
  return { success: true as const }
979
1264
  },
980
- updateFrameworkPackage: async (input) => ps.updateFrameworkPackage({
981
- packageName: input.packageName,
982
- ...(input.version !== undefined ? { version: input.version } : {}),
983
- ...(ctx.user?.username !== undefined
984
- ? { requestedBy: ctx.user.username }
985
- : ctx.user?.id !== undefined
986
- ? { requestedBy: ctx.user.id }
987
- : {}),
988
- ...(input.deferRestart !== undefined ? { deferRestart: input.deferRestart } : {}),
989
- }),
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
+ }),
990
1276
  getVersions: async (input) => ps.getPackageVersions(input.name),
991
1277
  restartAddon: async (input) => ar.restartAddon(input.addonId),
992
1278
  retryLoad: async (input) => {
@@ -994,7 +1280,8 @@ export function buildAddonsProvider(
994
1280
  return { success: true as const }
995
1281
  },
996
1282
  getAutoUpdateSettings: async () => ps.getAutoUpdateSettings(),
997
- setAutoUpdateSettings: async (input) => ps.setAutoUpdateSettings(input.channel, input.intervalSeconds),
1283
+ setAutoUpdateSettings: async (input) =>
1284
+ ps.setAutoUpdateSettings(input.channel, input.intervalSeconds),
998
1285
  getAddonAutoUpdate: async (input) => ps.getAddonAutoUpdate(input.addonId),
999
1286
  setAddonAutoUpdate: async (input) => ps.setAddonAutoUpdate(input.addonId, input.channel),
1000
1287
  applyAutoUpdateToAll: async (input) => {
@@ -1025,11 +1312,17 @@ export function buildAddonsProvider(
1025
1312
  onAddonLogs: (input, push) => {
1026
1313
  const unsubscribe = ls.subscribe(
1027
1314
  { tags: { addonId: input.addonId }, level: input.level },
1028
- (entry: { timestamp: Date | string | number; level: string; message: string; scope?: string }) => {
1315
+ (entry: {
1316
+ timestamp: Date | string | number
1317
+ level: string
1318
+ message: string
1319
+ scope?: string
1320
+ }) => {
1029
1321
  push({
1030
- timestamp: entry.timestamp instanceof Date
1031
- ? entry.timestamp.toISOString()
1032
- : String(entry.timestamp),
1322
+ timestamp:
1323
+ entry.timestamp instanceof Date
1324
+ ? entry.timestamp.toISOString()
1325
+ : String(entry.timestamp),
1033
1326
  level: entry.level,
1034
1327
  message: entry.message,
1035
1328
  scope: entry.scope,