@camstack/server 0.1.8 → 0.2.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 (125) hide show
  1. package/package.json +9 -7
  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 +24 -4
  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 +64 -15
  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 +14 -6
  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 +11 -6
  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 +71 -17
  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/addon-settings.router.ts +4 -1
  60. package/src/api/core/agents.router.ts +52 -53
  61. package/src/api/core/auth.router.ts +55 -36
  62. package/src/api/core/bulk-update-coordinator.ts +25 -22
  63. package/src/api/core/cap-providers.ts +346 -202
  64. package/src/api/core/capabilities.router.ts +30 -23
  65. package/src/api/core/hwaccel.router.ts +37 -10
  66. package/src/api/core/live-events.router.ts +16 -9
  67. package/src/api/core/logs.router.ts +54 -25
  68. package/src/api/core/notifications.router.ts +2 -1
  69. package/src/api/core/repl.router.ts +1 -3
  70. package/src/api/core/settings-backend.router.ts +68 -70
  71. package/src/api/core/system-events.router.ts +41 -32
  72. package/src/api/health/health.routes.ts +7 -13
  73. package/src/api/oauth2/__tests__/oauth2-routes.spec.ts +12 -2
  74. package/src/api/oauth2/consent-page.ts +4 -3
  75. package/src/api/oauth2/oauth2-routes.ts +41 -12
  76. package/src/api/trpc/__tests__/scope-access-device.spec.ts +68 -23
  77. package/src/api/trpc/__tests__/scope-access.spec.ts +8 -13
  78. package/src/api/trpc/__tests__/webrtc-session-ua-enrich.spec.ts +10 -2
  79. package/src/api/trpc/cap-mount-helpers.ts +64 -55
  80. package/src/api/trpc/cap-route-error-formatter.ts +17 -9
  81. package/src/api/trpc/core-cap-bridge.ts +3 -1
  82. package/src/api/trpc/generated-cap-mounts.ts +593 -351
  83. package/src/api/trpc/generated-cap-routers.ts +3680 -579
  84. package/src/api/trpc/scope-access.ts +7 -7
  85. package/src/api/trpc/trpc.context.ts +7 -4
  86. package/src/api/trpc/trpc.middleware.ts +4 -2
  87. package/src/api/trpc/trpc.router.ts +79 -46
  88. package/src/auth/session-cookie.ts +10 -0
  89. package/src/boot/__tests__/integration-id-backfill.spec.ts +21 -6
  90. package/src/boot/boot-config.ts +103 -122
  91. package/src/boot/post-boot.service.ts +5 -3
  92. package/src/core/addon/__tests__/addon-registry-capability.test.ts +12 -3
  93. package/src/core/addon/addon-call-gateway.ts +20 -6
  94. package/src/core/addon/addon-package.service.ts +183 -89
  95. package/src/core/addon/addon-registry.service.ts +1163 -1305
  96. package/src/core/addon/addon-search.service.ts +2 -1
  97. package/src/core/addon/addon-settings-provider.ts +27 -7
  98. package/src/core/addon-bridge/addon-bridge.service.ts +11 -6
  99. package/src/core/addon-pages/addon-pages.service.ts +3 -1
  100. package/src/core/addon-widgets/addon-widgets.service.ts +5 -2
  101. package/src/core/agent/agent-registry.service.ts +60 -38
  102. package/src/core/auth/auth.service.spec.ts +6 -8
  103. package/src/core/config/config.service.spec.ts +1 -1
  104. package/src/core/events/event-bus.service.spec.ts +44 -21
  105. package/src/core/events/event-bus.service.ts +5 -1
  106. package/src/core/feature/feature.service.spec.ts +4 -1
  107. package/src/core/lifecycle/lifecycle-state-machine.spec.ts +8 -10
  108. package/src/core/logging/logging.service.spec.ts +61 -21
  109. package/src/core/logging/logging.service.ts +12 -3
  110. package/src/core/moleculer/cap-call-fn.spec.ts +17 -10
  111. package/src/core/moleculer/cap-call-fn.ts +5 -1
  112. package/src/core/moleculer/cap-route-authority.ts +18 -6
  113. package/src/core/moleculer/moleculer.service.ts +120 -32
  114. package/src/core/network/network-quality.service.spec.ts +6 -1
  115. package/src/core/notification/notification-wrapper.service.ts +1 -3
  116. package/src/core/notification/toast-wrapper.service.ts +1 -5
  117. package/src/core/repl/repl-engine.service.spec.ts +66 -39
  118. package/src/core/repl/repl-engine.service.ts +11 -12
  119. package/src/core/storage/storage-location-manager.spec.ts +12 -3
  120. package/src/core/streaming/stream-probe.service.ts +22 -13
  121. package/src/core/topology/topology-emitter.service.ts +5 -1
  122. package/src/launcher.ts +14 -9
  123. package/src/main.ts +602 -531
  124. package/src/manual-boot.ts +133 -154
  125. package/tsconfig.json +20 -8
@@ -1,13 +1,13 @@
1
1
  /* eslint-disable @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-argument, @typescript-eslint/no-unsafe-return, @typescript-eslint/no-unsafe-member-access -- pre-existing lint debt across this 2200-line orchestration class. The flagged sites (StorageService.setLocationManager / setSettingsBackend, LoggingService.addDestination, RouteRegistry, etc.) are typed as `unknown` by their owning services to break circular construction-order dependencies; runtime contracts are validated structurally. Tracked separately; do not amend in unrelated edits. */
2
- import * as os from "node:os";
3
- import { ConfigService } from "../config/config.service";
4
- import { overlayDeclaration } from "./addon-row-manifest";
5
- import { LoggingService } from "../logging/logging.service";
6
- import { EventBusService } from "../events/event-bus.service";
7
- import { StorageService } from "../storage/storage.service";
8
- import { StreamProbeService } from "../streaming/stream-probe.service";
9
-
10
- import { CapabilityService } from "../capability/capability.service";
2
+ import * as os from 'node:os'
3
+ import { ConfigService } from '../config/config.service'
4
+ import { overlayDeclaration } from './addon-row-manifest'
5
+ import { LoggingService } from '../logging/logging.service'
6
+ import { EventBusService } from '../events/event-bus.service'
7
+ import { StorageService } from '../storage/storage.service'
8
+ import { StreamProbeService } from '../streaming/stream-probe.service'
9
+
10
+ import { CapabilityService } from '../capability/capability.service'
11
11
  import {
12
12
  EventCategory,
13
13
  createDeviceProxy,
@@ -16,7 +16,7 @@ import {
16
16
  isAgentOnlyPlacement,
17
17
  resolveRunnerId,
18
18
  resolveAddonPlacement,
19
- } from "@camstack/types";
19
+ } from '@camstack/types'
20
20
  import type {
21
21
  AddonDeclaration,
22
22
  CapabilityDeclaration,
@@ -24,20 +24,15 @@ import type {
24
24
  InferProvider,
25
25
  RunnerPlan,
26
26
  RunnerAddonPlacement,
27
- } from "@camstack/types";
28
- import type {
29
- ICamstackAddon,
30
- AddonContext,
31
- InternalAddonContext,
32
- } from "@camstack/types";
33
- import type { IScopedLogger } from "@camstack/types";
27
+ } from '@camstack/types'
28
+ import type { ICamstackAddon, AddonContext, InternalAddonContext } from '@camstack/types'
29
+ import type { IScopedLogger } from '@camstack/types'
34
30
  import {
35
31
  CapabilityRegistry,
36
32
  CustomActionRegistry,
37
33
  INFRA_CAPABILITIES,
38
34
  AddonLoader,
39
35
  AddonHealthMonitor,
40
-
41
36
  DeviceRegistry,
42
37
  adaptBrokerToCluster,
43
38
  createAddonService,
@@ -50,35 +45,36 @@ import {
50
45
  type ISettingsStore,
51
46
  type AddonSettingsView,
52
47
  type AddonHealthSnapshot,
53
- } from "@camstack/kernel";
54
- import { localProviderLink, brokerTransportLink } from "@camstack/kernel";
55
- import { createTRPCClient } from "@trpc/client";
56
- import { MoleculerService } from "../moleculer/moleculer.service";
57
- import { AddonDepsManager } from "@camstack/kernel";
58
- import type { SavedDevice, ReadinessScope } from "@camstack/types";
59
- import { IntegrationRegistry } from "@camstack/core";
60
- import type {
61
- IStorageProvider as INewStorageProvider,
62
- ISettingsBackend,
63
- } from "@camstack/types";
64
- import { AddonRouteRegistry, DataPlaneRegistry } from "@camstack/core";
65
- import { randomUUID } from "node:crypto";
66
- import * as path from "node:path";
67
- import * as fs from "node:fs";
68
- import { pathToFileURL } from "node:url";
69
- import { createAddonSettingsProvider } from "./addon-settings-provider.js";
70
- import { AddonCallGateway } from "./addon-call-gateway.js";
71
- import { addonSettingsCapability } from "@camstack/types";
72
- import { DisposerChain } from "@camstack/types";
73
-
74
- type AddonSource = "core" | "installed";
48
+ } from '@camstack/kernel'
49
+ import { localProviderLink, brokerTransportLink } from '@camstack/kernel'
50
+ import { createTRPCClient } from '@trpc/client'
51
+ import { MoleculerService } from '../moleculer/moleculer.service'
52
+ import { AddonDepsManager } from '@camstack/kernel'
53
+ import type { SavedDevice, ReadinessScope } from '@camstack/types'
54
+ import { IntegrationRegistry } from '@camstack/core'
55
+ import type { IStorageProvider as INewStorageProvider, ISettingsBackend } from '@camstack/types'
56
+ import { AddonRouteRegistry, DataPlaneRegistry } from '@camstack/core'
57
+ import { randomUUID } from 'node:crypto'
58
+ import * as path from 'node:path'
59
+ import * as fs from 'node:fs'
60
+ import { pathToFileURL } from 'node:url'
61
+ import { createAddonSettingsProvider } from './addon-settings-provider.js'
62
+ import { AddonCallGateway } from './addon-call-gateway.js'
63
+ import { addonSettingsCapability } from '@camstack/types'
64
+ import { DisposerChain } from '@camstack/types'
65
+
66
+ type AddonSource = 'core' | 'installed'
75
67
 
76
68
  /**
77
69
  * Local narrowing of the Moleculer ServiceBroker surface we actually use.
78
70
  * See the `broker` getter docstring below for why this is necessary.
79
71
  */
80
72
  interface BrokerLike {
81
- call<T = unknown>(action: string, params?: unknown, opts?: { nodeID?: string; timeout?: number }): Promise<T>
73
+ call<T = unknown>(
74
+ action: string,
75
+ params?: unknown,
76
+ opts?: { nodeID?: string; timeout?: number },
77
+ ): Promise<T>
82
78
  nodeID: string
83
79
  createService(schema: unknown): unknown
84
80
  }
@@ -90,27 +86,25 @@ interface BrokerLike {
90
86
  * `@camstack/core` implements both surfaces; forked addon backends may
91
87
  * not, in which case we skip the ConfigService wiring.
92
88
  */
93
- function isSettingsStore(
94
- backend: ISettingsBackend,
95
- ): backend is ISettingsBackend & ISettingsStore {
89
+ function isSettingsStore(backend: ISettingsBackend): backend is ISettingsBackend & ISettingsStore {
96
90
  const required: readonly (keyof ISettingsStore)[] = [
97
- "getSystem",
98
- "setSystem",
99
- "getAllSystem",
100
- "getAllAddon",
101
- "setAllAddon",
102
- "getAllProvider",
103
- "setProvider",
104
- "getAllDevice",
105
- "setDevice",
106
- "getAddonDevice",
107
- "setAddonDevice",
108
- "clearAddonDevice",
109
- ];
91
+ 'getSystem',
92
+ 'setSystem',
93
+ 'getAllSystem',
94
+ 'getAllAddon',
95
+ 'setAllAddon',
96
+ 'getAllProvider',
97
+ 'setProvider',
98
+ 'getAllDevice',
99
+ 'setDevice',
100
+ 'getAddonDevice',
101
+ 'setAddonDevice',
102
+ 'clearAddonDevice',
103
+ ]
110
104
  for (const key of required) {
111
- if (typeof Reflect.get(backend, key) !== "function") return false;
105
+ if (typeof Reflect.get(backend, key) !== 'function') return false
112
106
  }
113
- return true;
107
+ return true
114
108
  }
115
109
 
116
110
  /**
@@ -121,41 +115,39 @@ function isSettingsStore(
121
115
  */
122
116
  interface AddonRoutesInvoker {
123
117
  invoke(
124
- input: import("@camstack/types").AddonRouteInvokeRequest,
125
- ): Promise<import("@camstack/types").AddonRouteReplyEnvelope>;
118
+ input: import('@camstack/types').AddonRouteInvokeRequest,
119
+ ): Promise<import('@camstack/types').AddonRouteReplyEnvelope>
126
120
  }
127
121
 
128
122
  /** Structural guard: true when an `addon-routes` provider also exposes `invoke`. */
129
- function isAddonRoutesInvoker<T extends object>(
130
- provider: T,
131
- ): provider is T & AddonRoutesInvoker {
132
- return typeof Reflect.get(provider, "invoke") === "function";
123
+ function isAddonRoutesInvoker<T extends object>(provider: T): provider is T & AddonRoutesInvoker {
124
+ return typeof Reflect.get(provider, 'invoke') === 'function'
133
125
  }
134
126
 
135
- const ROUTE_METHODS: readonly import("@camstack/types").IAddonHttpRoute["method"][] = [
136
- "GET",
137
- "POST",
138
- "PUT",
139
- "DELETE",
140
- ];
141
- const ROUTE_ACCESS: readonly import("@camstack/types").RouteAccess[] = [
142
- "public",
143
- "authenticated",
144
- "admin",
145
- ];
146
-
147
- function asRouteMethod(value: string): import("@camstack/types").IAddonHttpRoute["method"] {
148
- const upper = value.toUpperCase();
149
- for (const m of ROUTE_METHODS) if (m === upper) return m;
150
- throw new Error(`addon-routes: unsupported HTTP method "${value}"`);
127
+ const ROUTE_METHODS: readonly import('@camstack/types').IAddonHttpRoute['method'][] = [
128
+ 'GET',
129
+ 'POST',
130
+ 'PUT',
131
+ 'DELETE',
132
+ ]
133
+ const ROUTE_ACCESS: readonly import('@camstack/types').RouteAccess[] = [
134
+ 'public',
135
+ 'authenticated',
136
+ 'admin',
137
+ ]
138
+
139
+ function asRouteMethod(value: string): import('@camstack/types').IAddonHttpRoute['method'] {
140
+ const upper = value.toUpperCase()
141
+ for (const m of ROUTE_METHODS) if (m === upper) return m
142
+ throw new Error(`addon-routes: unsupported HTTP method "${value}"`)
151
143
  }
152
144
 
153
- function asRouteAccess(value: unknown): import("@camstack/types").RouteAccess {
154
- if (typeof value === "string") {
155
- for (const a of ROUTE_ACCESS) if (a === value) return a;
145
+ function asRouteAccess(value: unknown): import('@camstack/types').RouteAccess {
146
+ if (typeof value === 'string') {
147
+ for (const a of ROUTE_ACCESS) if (a === value) return a
156
148
  }
157
149
  // Default to the most restrictive sensible default the original mount used.
158
- return "public";
150
+ return 'public'
159
151
  }
160
152
 
161
153
  /**
@@ -165,33 +157,33 @@ function asRouteAccess(value: unknown): import("@camstack/types").RouteAccess {
165
157
  * `method`/`path`/`access`/`description` are present.
166
158
  */
167
159
  interface ParsedRouteDescriptor {
168
- readonly method: import("@camstack/types").IAddonHttpRoute["method"];
169
- readonly path: string;
170
- readonly access: import("@camstack/types").RouteAccess;
171
- readonly description?: string;
160
+ readonly method: import('@camstack/types').IAddonHttpRoute['method']
161
+ readonly path: string
162
+ readonly access: import('@camstack/types').RouteAccess
163
+ readonly description?: string
172
164
  }
173
165
 
174
166
  function parseSerializableRouteDescriptors(raw: unknown): readonly ParsedRouteDescriptor[] {
175
167
  if (!Array.isArray(raw)) {
176
- throw new Error("addon-routes: child returned a non-array route descriptor set");
168
+ throw new Error('addon-routes: child returned a non-array route descriptor set')
177
169
  }
178
170
  return raw.map((entry: unknown): ParsedRouteDescriptor => {
179
- if (entry === null || typeof entry !== "object") {
180
- throw new Error("addon-routes: route descriptor is not an object");
171
+ if (entry === null || typeof entry !== 'object') {
172
+ throw new Error('addon-routes: route descriptor is not an object')
181
173
  }
182
- const method = Reflect.get(entry, "method");
183
- const path = Reflect.get(entry, "path");
184
- if (typeof method !== "string" || typeof path !== "string") {
185
- throw new Error("addon-routes: route descriptor missing method/path");
174
+ const method = Reflect.get(entry, 'method')
175
+ const path = Reflect.get(entry, 'path')
176
+ if (typeof method !== 'string' || typeof path !== 'string') {
177
+ throw new Error('addon-routes: route descriptor missing method/path')
186
178
  }
187
- const description = Reflect.get(entry, "description");
179
+ const description = Reflect.get(entry, 'description')
188
180
  return {
189
181
  method: asRouteMethod(method),
190
182
  path,
191
- access: asRouteAccess(Reflect.get(entry, "access")),
192
- ...(typeof description === "string" ? { description } : {}),
193
- };
194
- });
183
+ access: asRouteAccess(Reflect.get(entry, 'access')),
184
+ ...(typeof description === 'string' ? { description } : {}),
185
+ }
186
+ })
195
187
  }
196
188
 
197
189
  /**
@@ -199,42 +191,44 @@ function parseSerializableRouteDescriptors(raw: unknown): readonly ParsedRouteDe
199
191
  * into typed data-plane endpoint descriptors (`prefix/access/baseUrl/secret`).
200
192
  * Pure data — the addon's `ctx.dataPlane` facility produced them.
201
193
  */
202
- function parseDataPlaneEndpoints(raw: unknown): readonly import("@camstack/types").AddonDataPlaneEndpoint[] {
194
+ function parseDataPlaneEndpoints(
195
+ raw: unknown,
196
+ ): readonly import('@camstack/types').AddonDataPlaneEndpoint[] {
203
197
  if (!Array.isArray(raw)) {
204
- throw new Error("data-planes: child returned a non-array endpoint set");
198
+ throw new Error('data-planes: child returned a non-array endpoint set')
205
199
  }
206
- return raw.map((entry: unknown): import("@camstack/types").AddonDataPlaneEndpoint => {
207
- if (entry === null || typeof entry !== "object") {
208
- throw new Error("data-planes: endpoint descriptor is not an object");
209
- }
210
- const prefix = Reflect.get(entry, "prefix");
211
- const baseUrl = Reflect.get(entry, "baseUrl");
212
- const secret = Reflect.get(entry, "secret");
213
- if (typeof prefix !== "string" || typeof baseUrl !== "string" || typeof secret !== "string") {
214
- throw new Error("data-planes: endpoint descriptor missing prefix/baseUrl/secret");
215
- }
216
- return { prefix, access: asRouteAccess(Reflect.get(entry, "access")), baseUrl, secret };
217
- });
200
+ return raw.map((entry: unknown): import('@camstack/types').AddonDataPlaneEndpoint => {
201
+ if (entry === null || typeof entry !== 'object') {
202
+ throw new Error('data-planes: endpoint descriptor is not an object')
203
+ }
204
+ const prefix = Reflect.get(entry, 'prefix')
205
+ const baseUrl = Reflect.get(entry, 'baseUrl')
206
+ const secret = Reflect.get(entry, 'secret')
207
+ if (typeof prefix !== 'string' || typeof baseUrl !== 'string' || typeof secret !== 'string') {
208
+ throw new Error('data-planes: endpoint descriptor missing prefix/baseUrl/secret')
209
+ }
210
+ return { prefix, access: asRouteAccess(Reflect.get(entry, 'access')), baseUrl, secret }
211
+ })
218
212
  }
219
213
 
220
214
  interface AddonEntry {
221
- readonly addon: ICamstackAddon;
222
- initialized: boolean;
223
- source: AddonSource;
215
+ readonly addon: ICamstackAddon
216
+ initialized: boolean
217
+ source: AddonSource
224
218
  /** npm package name from package.json (e.g. '@camstack/addon-detection-pipeline') */
225
- packageName: string;
219
+ packageName: string
226
220
  /** npm package version from package.json */
227
- packageVersion: string;
221
+ packageVersion: string
228
222
  /** Human-readable package name from camstack.displayName */
229
- packageDisplayName?: string;
223
+ packageDisplayName?: string
230
224
  /** Optional bundle metadata when the package ships multiple addon entries. */
231
- bundle?: { displayName: string; description?: string; icon?: string };
225
+ bundle?: { displayName: string; description?: string; icon?: string }
232
226
  /** Capabilities declared in package.json camstack.addons (source of truth) */
233
- declaredCapabilities: readonly CapabilityDeclaration[];
227
+ declaredCapabilities: readonly CapabilityDeclaration[]
234
228
  /** Addon directory on disk */
235
- addonDir?: string;
229
+ addonDir?: string
236
230
  /** Full package.json declaration (AddonDeclaration from @camstack/types) */
237
- declaration?: AddonDeclaration;
231
+ declaration?: AddonDeclaration
238
232
  }
239
233
 
240
234
  // Phase 11 (settings redesign): `stripValue` helper and the legacy
@@ -244,14 +238,14 @@ interface AddonEntry {
244
238
  // endpoints on the `addon-settings` singleton capability.
245
239
 
246
240
  export class AddonRegistryService {
247
- private readonly addonEntries = new Map<string, AddonEntry>();
248
- private readonly capabilityRegistry: CapabilityRegistry;
241
+ private readonly addonEntries = new Map<string, AddonEntry>()
242
+ private readonly capabilityRegistry: CapabilityRegistry
249
243
  /** Single router for addon-level calls (routes / custom / settings). */
250
- private addonCallGateway!: AddonCallGateway;
244
+ private addonCallGateway!: AddonCallGateway
251
245
  // Task 7.1: hub-wide registry of addon custom actions. Populated on
252
246
  // each addon's initialize() (in-process path); Task 7.2 will dispatch
253
247
  // through this from the `api.addons.custom` tRPC procedure.
254
- private readonly customActionRegistry = new CustomActionRegistry();
248
+ private readonly customActionRegistry = new CustomActionRegistry()
255
249
  /**
256
250
  * AddonIds whose group-runner disconnect is operator-initiated (update /
257
251
  * restart / uninstall). The Moleculer `$node.disconnected` handler skips
@@ -259,12 +253,12 @@ export class AddonRegistryService {
259
253
  * banner during the kill→respawn window. The set is cleared as soon as
260
254
  * `restartAddon` completes (success or failure) or by a 90s safety timer.
261
255
  */
262
- private readonly restartingAddons = new Map<string, NodeJS.Timeout>();
263
- private readonly logger: IScopedLogger;
264
- private addonLoader!: AddonLoader;
265
- private healthMonitor!: AddonHealthMonitor;
266
- private addonRouteRegistry: AddonRouteRegistry | null = null;
267
- private dataPlaneRegistry: DataPlaneRegistry | null = null;
256
+ private readonly restartingAddons = new Map<string, NodeJS.Timeout>()
257
+ private readonly logger: IScopedLogger
258
+ private addonLoader!: AddonLoader
259
+ private healthMonitor!: AddonHealthMonitor
260
+ private addonRouteRegistry: AddonRouteRegistry | null = null
261
+ private dataPlaneRegistry: DataPlaneRegistry | null = null
268
262
 
269
263
  // Broker-routed AddonApi proxy — every addon's `ctx.api` resolves
270
264
  // to this. Calls go through `broker.call('${addonId}.${capName}.${method}')`
@@ -274,30 +268,25 @@ export class AddonRegistryService {
274
268
  // and forked workers — `ctx.api` is uniform across all deployment
275
269
  // shapes. Lazily constructed so tests that don't wire the broker
276
270
  // still instantiate the service.
277
- private brokerApi: import("@camstack/types").AddonApi | null = null;
271
+ private brokerApi: import('@camstack/types').AddonApi | null = null
278
272
  /**
279
273
  * In-process tRPC client over the broker — same `AddonApi` shape every
280
274
  * addon sees via `ctx.api`. Exposed as public so the REPL service can
281
275
  * build a `SystemManager` from it without crossing the network boundary.
282
276
  */
283
- public getBrokerApi(): import("@camstack/types").AddonApi {
277
+ public getBrokerApi(): import('@camstack/types').AddonApi {
284
278
  if (!this.brokerApi) {
285
- const reg = this.capabilityRegistry;
279
+ const reg = this.capabilityRegistry
286
280
  const resolver = {
287
281
  getByName: (capName: string): unknown | null =>
288
- reg.getSingleton(capName) ??
289
- (reg.getAllProviders(capName)[0] as unknown) ??
290
- null,
291
- };
282
+ reg.getSingleton(capName) ?? (reg.getAllProviders(capName)[0] as unknown) ?? null,
283
+ }
292
284
  const client: unknown = createTRPCClient({
293
- links: [
294
- localProviderLink(resolver),
295
- brokerTransportLink(this.moleculer.broker),
296
- ],
297
- });
298
- this.brokerApi = client as import("@camstack/types").AddonApi;
299
- }
300
- return this.brokerApi;
285
+ links: [localProviderLink(resolver), brokerTransportLink(this.moleculer.broker)],
286
+ })
287
+ this.brokerApi = client as import('@camstack/types').AddonApi
288
+ }
289
+ return this.brokerApi
301
290
  }
302
291
 
303
292
  // Common settings resolver (3-level: defaults → global → per-device).
@@ -306,15 +295,13 @@ export class AddonRegistryService {
306
295
  // without wiring ConfigService into the resolver.
307
296
 
308
297
  // Active capability providers (set by consumers when capabilities are wired)
309
- private activeStorageProvider: INewStorageProvider | null = null;
310
- private activeSettingsBackend: ISettingsBackend | null = null;
311
- private integrationRegistry:
312
- | import("@camstack/core").IntegrationRegistry
313
- | null = null;
298
+ private activeStorageProvider: INewStorageProvider | null = null
299
+ private activeSettingsBackend: ISettingsBackend | null = null
300
+ private integrationRegistry: import('@camstack/core').IntegrationRegistry | null = null
314
301
 
315
302
  // Device architecture — in-memory registry of live IDevice instances.
316
303
  // Persistence is owned by the `device-manager` capability addon.
317
- private readonly deviceRegistry = new DeviceRegistry();
304
+ private readonly deviceRegistry = new DeviceRegistry()
318
305
 
319
306
  /**
320
307
  * Typed accessor for the Moleculer broker. The Moleculer `index.d.ts`
@@ -328,7 +315,7 @@ export class AddonRegistryService {
328
315
  * `this.broker.call(...)` / `this.broker.nodeID` lint clean.
329
316
  */
330
317
  private get broker(): BrokerLike {
331
- return this.moleculer.broker as unknown as BrokerLike;
318
+ return this.moleculer.broker as unknown as BrokerLike
332
319
  }
333
320
 
334
321
  constructor(
@@ -340,10 +327,8 @@ export class AddonRegistryService {
340
327
  private readonly moleculer: MoleculerService,
341
328
  private readonly streamProbe: StreamProbeService,
342
329
  ) {
343
- this.logger = this.loggingService.createLogger("AddonRegistry");
344
- this.addonLoader = new AddonLoader(
345
- this.loggingService.createLogger("AddonLoader"),
346
- );
330
+ this.logger = this.loggingService.createLogger('AddonRegistry')
331
+ this.addonLoader = new AddonLoader(this.loggingService.createLogger('AddonLoader'))
347
332
 
348
333
  // Kernel-level addon health monitor — drives the 5-min boot grace
349
334
  // window + aggressive auto-retry loop (60s/120s/300s, never gives
@@ -353,51 +338,42 @@ export class AddonRegistryService {
353
338
  // AddonLoadFailed / AddonLoadRecovered events consumed by
354
339
  // AlertCenter (see addon-health-monitor.ts spec).
355
340
  this.healthMonitor = new AddonHealthMonitor({
356
- eventBus: this.eventBusService as unknown as import("@camstack/types").IEventBus,
357
- logger: this.loggingService.createLogger("AddonHealthMonitor"),
341
+ eventBus: this.eventBusService as unknown as import('@camstack/types').IEventBus,
342
+ logger: this.loggingService.createLogger('AddonHealthMonitor'),
358
343
  retryFn: (packageName) => this.tryReloadPackage(packageName),
359
- });
344
+ })
360
345
 
361
346
  // Create capability registry with config reader for singleton preferences
362
347
  this.capabilityRegistry = new CapabilityRegistry(
363
- this.loggingService.createLogger("CapabilityRegistry"),
348
+ this.loggingService.createLogger('CapabilityRegistry'),
364
349
  this.eventBusService,
365
- );
366
- this.capabilityRegistry.setConfigReader(
367
- (capability: string): string | undefined => {
368
- try {
369
- return (
370
- this.configService.get<string>(
371
- `capabilities.singleton.${capability}`,
372
- ) ?? undefined
373
- );
374
- } catch (err) {
375
- this.logger.debug(
376
- 'settings-store not wired yet during early boot',
377
- { meta: { capability, error: errMsg(err) } },
378
- );
379
- return undefined;
380
- }
381
- },
382
- );
350
+ )
351
+ this.capabilityRegistry.setConfigReader((capability: string): string | undefined => {
352
+ try {
353
+ return this.configService.get<string>(`capabilities.singleton.${capability}`) ?? undefined
354
+ } catch (err) {
355
+ this.logger.debug('settings-store not wired yet during early boot', {
356
+ meta: { capability, error: errMsg(err) },
357
+ })
358
+ return undefined
359
+ }
360
+ })
383
361
 
384
362
  this.capabilityRegistry.setNodeConfigReader(
385
363
  (capability: string, nodeId: string): string | undefined => {
386
364
  try {
387
365
  return (
388
- this.configService.get<string>(
389
- `capabilities.singletonNode.${capability}.${nodeId}`,
390
- ) ?? undefined
391
- );
366
+ this.configService.get<string>(`capabilities.singletonNode.${capability}.${nodeId}`) ??
367
+ undefined
368
+ )
392
369
  } catch (err) {
393
- this.logger.debug(
394
- 'settings-store not wired yet during early boot',
395
- { meta: { capability, nodeId, error: errMsg(err) } },
396
- );
397
- return undefined;
370
+ this.logger.debug('settings-store not wired yet during early boot', {
371
+ meta: { capability, nodeId, error: errMsg(err) },
372
+ })
373
+ return undefined
398
374
  }
399
375
  },
400
- );
376
+ )
401
377
 
402
378
  // Collection-provider enable/disable persistence. Reads the same
403
379
  // `capabilities.collection.<cap>` ConfigService key the `capabilities`
@@ -406,68 +382,59 @@ export class AddonRegistryService {
406
382
  this.capabilityRegistry.setCollectionConfigReader(
407
383
  (capability: string): readonly string[] | undefined => {
408
384
  try {
409
- const raw = this.configService.get<string>(
410
- `capabilities.collection.${capability}`,
411
- );
412
- if (!raw) return undefined;
413
- const parsed: unknown = JSON.parse(raw);
385
+ const raw = this.configService.get<string>(`capabilities.collection.${capability}`)
386
+ if (!raw) return undefined
387
+ const parsed: unknown = JSON.parse(raw)
414
388
  if (
415
- typeof parsed === "object" &&
389
+ typeof parsed === 'object' &&
416
390
  parsed !== null &&
417
- "disabled" in parsed &&
391
+ 'disabled' in parsed &&
418
392
  Array.isArray((parsed as { disabled: unknown }).disabled)
419
393
  ) {
420
- const disabled = (parsed as { disabled: unknown[] }).disabled;
421
- return disabled.filter(
422
- (id): id is string => typeof id === "string",
423
- );
394
+ const disabled = (parsed as { disabled: unknown[] }).disabled
395
+ return disabled.filter((id): id is string => typeof id === 'string')
424
396
  }
425
- return undefined;
397
+ return undefined
426
398
  } catch (err) {
427
- this.logger.debug(
428
- "settings-store not wired yet or malformed collection preference",
429
- { meta: { capability, error: errMsg(err) } },
430
- );
431
- return undefined;
399
+ this.logger.debug('settings-store not wired yet or malformed collection preference', {
400
+ meta: { capability, error: errMsg(err) },
401
+ })
402
+ return undefined
432
403
  }
433
404
  },
434
- );
405
+ )
435
406
 
436
407
  // Wire the registry into the CapabilityService so all server services can use it
437
- this.capabilityService.setRegistry(this.capabilityRegistry);
408
+ this.capabilityService.setRegistry(this.capabilityRegistry)
438
409
 
439
410
  // Register kernel-provided infrastructure capabilities so addons
440
411
  // can resolve them via the capability registry like any other cap.
441
412
  // These are kernel pseudo-caps (no tRPC methods, object-reference only),
442
413
  // so we declare them with empty method maps before registering providers.
443
414
  this.capabilityRegistry.declareCapability({
444
- name: "device-registry",
445
- scope: "system",
446
- mode: "singleton",
415
+ name: 'device-registry',
416
+ scope: 'system',
417
+ mode: 'singleton',
447
418
  methods: {},
448
- });
419
+ })
449
420
  this.capabilityRegistry.declareCapability({
450
- name: "cluster-broker",
451
- scope: "system",
452
- mode: "singleton",
421
+ name: 'cluster-broker',
422
+ scope: 'system',
423
+ mode: 'singleton',
453
424
  methods: {},
454
- });
455
- this.capabilityRegistry.registerProvider(
456
- "device-registry",
457
- "$kernel",
458
- this.deviceRegistry,
459
- );
460
- this.capabilityRegistry.registerProvider("cluster-broker", "$kernel", {
425
+ })
426
+ this.capabilityRegistry.registerProvider('device-registry', '$kernel', this.deviceRegistry)
427
+ this.capabilityRegistry.registerProvider('cluster-broker', '$kernel', {
461
428
  // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment -- moleculer types unresolvable; see `broker` getter docstring
462
429
  broker: this.moleculer.broker,
463
- });
430
+ })
464
431
  }
465
432
 
466
433
  async onModuleInit(): Promise<void> {
467
434
  // Register the addon-settings singleton provider — replaces the
468
435
  // former `$addonHost` Moleculer service. The provider resolves
469
436
  // addonId → local addon instance (hub) or remote Moleculer call.
470
- this.capabilityRegistry.declareCapability(addonSettingsCapability);
437
+ this.capabilityRegistry.declareCapability(addonSettingsCapability)
471
438
  // The SINGLE router for addon-level calls (routes / custom / settings).
472
439
  // It classifies in-process | hub-local-child (UDS) | remote-agent
473
440
  // (Moleculer) in ONE place — `resolveNode` reports only the BASE node
@@ -480,104 +447,89 @@ export class AddonRegistryService {
480
447
  this.addonCallGateway = new AddonCallGateway({
481
448
  hubNodeId: this.broker.nodeID,
482
449
  resolveNode: (addonId) => {
483
- const entry = this.addonEntries.get(addonId);
450
+ const entry = this.addonEntries.get(addonId)
484
451
  // Every addon in `addonEntries` is hub-local — report the hub node;
485
452
  // forked-vs-in-process is decided by `isChildKnown` in the gateway.
486
- return entry ? this.broker.nodeID : "hub";
453
+ return entry ? this.broker.nodeID : 'hub'
487
454
  },
488
455
  getChildRegistry: () => this.moleculer.childRegistry,
489
456
  // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment -- moleculer types unresolvable; see `broker` getter docstring
490
457
  broker: this.moleculer.broker,
491
- });
458
+ })
492
459
  const settingsProvider = createAddonSettingsProvider({
493
460
  getAddon: (addonId) => {
494
- const entry = this.addonEntries.get(addonId);
495
- return entry?.addon ?? null;
461
+ const entry = this.addonEntries.get(addonId)
462
+ return entry?.addon ?? null
496
463
  },
497
464
  gateway: this.addonCallGateway,
498
- });
499
- this.capabilityRegistry.registerProvider(
500
- "addon-settings",
501
- "$hub",
502
- settingsProvider,
503
- );
465
+ })
466
+ this.capabilityRegistry.registerProvider('addon-settings', '$hub', settingsProvider)
504
467
 
505
468
  // Wire capability consumer actions via EventBus
506
- this.wireCapabilityConsumers();
469
+ this.wireCapabilityConsumers()
507
470
 
508
471
  // Subscribe to capability.binding-changed so hub-side capability
509
472
  // overrides take effect on the fly. The orchestrator addon emits
510
473
  // these when the operator swaps the addon implementing a cap on a
511
474
  // node; we only react when the target node is the hub's own nodeID.
512
- this.eventBusService.subscribe(
513
- { category: "capability.binding-changed" },
514
- (event) => {
515
- const data = (event.data ?? {}) as {
516
- nodeId?: string;
517
- capName?: string;
518
- addonId?: string | null;
519
- };
520
- if (!data.capName) return;
521
- const localNodeId = this.broker.nodeID;
522
- if (data.nodeId && data.nodeId !== localNodeId) return;
523
- this.capabilityRegistry.setSingletonActiveAddon(
524
- data.capName,
525
- data.addonId ?? null,
526
- );
527
- },
528
- );
475
+ this.eventBusService.subscribe({ category: 'capability.binding-changed' }, (event) => {
476
+ const data = (event.data ?? {}) as {
477
+ nodeId?: string
478
+ capName?: string
479
+ addonId?: string | null
480
+ }
481
+ if (!data.capName) return
482
+ const localNodeId = this.broker.nodeID
483
+ if (data.nodeId && data.nodeId !== localNodeId) return
484
+ this.capabilityRegistry.setSingletonActiveAddon(data.capName, data.addonId ?? null)
485
+ })
529
486
 
530
487
  // 2. Load all addons from data/addons/ directory (including core builtins)
531
- const dataDir = path.resolve(process.env.CAMSTACK_DATA ?? "camstack-data");
532
- const addonsDir = path.resolve(dataDir, "addons");
488
+ const dataDir = path.resolve(process.env.CAMSTACK_DATA ?? 'camstack-data')
489
+ const addonsDir = path.resolve(dataDir, 'addons')
533
490
 
534
- await this.addonLoader.loadFromDirectory(addonsDir);
491
+ await this.addonLoader.loadFromDirectory(addonsDir)
535
492
 
536
493
  // Hand pre-init load failures (broken package.json, missing
537
494
  // dist, import-time errors) to the health monitor so its retry
538
495
  // loop and post-grace alerting cover them. Failures during the
539
496
  // init phase below are recorded separately.
540
497
  for (const fail of this.addonLoader.listLoadFailures()) {
541
- this.healthMonitor.recordFailure(fail.packageName, fail.error, fail.addonId);
498
+ this.healthMonitor.recordFailure(fail.packageName, fail.error, fail.addonId)
542
499
  }
543
500
 
544
- const loadedAddons = this.addonLoader.listAddons();
501
+ const loadedAddons = this.addonLoader.listAddons()
545
502
 
546
503
  for (const registered of loadedAddons) {
547
504
  // Skip agent-only addons — they never run on the hub, only on remote
548
505
  // agents that opt in via `execution.placement: 'agent-only'`.
549
506
  if (isAgentOnlyPlacement(registered.declaration)) {
550
- this.logger.info(
551
- 'Skipping agent-only addon on hub',
552
- { tags: { addonId: registered.declaration.id }, meta: { packageName: registered.packageName } },
553
- );
554
- continue;
507
+ this.logger.info('Skipping agent-only addon on hub', {
508
+ tags: { addonId: registered.declaration.id },
509
+ meta: { packageName: registered.packageName },
510
+ })
511
+ continue
555
512
  }
556
513
  if (registered.declaration.capabilities) {
557
514
  for (const cap of registered.declaration.capabilities) {
558
- this.capabilityRegistry.declareFromManifest(
559
- cap,
560
- registered.declaration.id,
561
- );
515
+ this.capabilityRegistry.declareFromManifest(cap, registered.declaration.id)
562
516
  }
563
517
  }
564
518
  // Create and store addon instance — cast from @camstack/types to server's
565
519
  // local ICamstackAddon (structurally compatible at runtime).
566
520
  // Wrap in try/catch so a single broken addon doesn't prevent the rest from loading.
567
521
  try {
568
- const addon = this.addonLoader.createInstance(
569
- registered.declaration.id,
570
- );
522
+ const addon = this.addonLoader.createInstance(registered.declaration.id)
571
523
  // Capabilities come from package.json declaration (source of truth),
572
524
  // merged with any declared in the addon class manifest.
573
525
  const declCaps = (registered.declaration.capabilities ?? []).map(
574
526
  (c: string | CapabilityDeclaration) =>
575
- typeof c === "string" ? { name: c, mode: "singleton" as const } : c,
576
- );
527
+ typeof c === 'string' ? { name: c, mode: 'singleton' as const } : c,
528
+ )
577
529
  this.addonEntries.set(registered.declaration.id, {
578
530
  addon,
579
531
  initialized: false,
580
- source: "installed",
532
+ source: 'installed',
581
533
  packageName: registered.packageName,
582
534
  packageVersion: registered.packageVersion,
583
535
  packageDisplayName: registered.packageDisplayName,
@@ -585,20 +537,19 @@ export class AddonRegistryService {
585
537
  declaredCapabilities: declCaps,
586
538
  addonDir: path.join(addonsDir, registered.packageName),
587
539
  declaration: registered.declaration,
588
- });
540
+ })
589
541
  } catch (err) {
590
- const msg = errMsg(err);
591
- this.logger.error(
592
- 'Failed to create instance of addon',
593
- { tags: { addonId: registered.declaration.id }, meta: { error: msg } },
594
- );
542
+ const msg = errMsg(err)
543
+ this.logger.error('Failed to create instance of addon', {
544
+ tags: { addonId: registered.declaration.id },
545
+ meta: { error: msg },
546
+ })
595
547
  }
596
548
  }
597
549
 
598
- this.logger.info(
599
- 'Loaded addons from directory',
600
- { meta: { count: loadedAddons.length, addonsDir } },
601
- );
550
+ this.logger.info('Loaded addons from directory', {
551
+ meta: { count: loadedAddons.length, addonsDir },
552
+ })
602
553
  // Platform probing is no longer orchestrated by the backend. The
603
554
  // `platform-probe` capability (shipped as a core builtin under
604
555
  // `@camstack/core/dist/builtins/platform-probe/` since Phase B of
@@ -607,14 +558,14 @@ export class AddonRegistryService {
607
558
  // inside their own `onInitialize`.
608
559
 
609
560
  // 4. Initialize all installed addons in correct order (installed = active)
610
- const allIds = [...this.addonEntries.keys()];
561
+ const allIds = [...this.addonEntries.keys()]
611
562
 
612
563
  // Mark registry as ready BEFORE any addon initializes. Addons' onInitialize
613
564
  // may call ctx.api.<cap>.* which routes through localProviderLink; that
614
565
  // resolver depends on getAllProviders/getSingleton which gate on `_ready`.
615
566
  // Without this, Phase 1 and Phase 2 addons would all fall through to the
616
567
  // Moleculer broker transport and fail with "service not registered".
617
- this.capabilityRegistry.ready();
568
+ this.capabilityRegistry.ready()
618
569
 
619
570
  // Native-cap cross-process bridge: when a hub consumer resolves a native
620
571
  // provider for a device whose IDevice lives in a forked worker, we
@@ -638,13 +589,13 @@ export class AddonRegistryService {
638
589
  // genuinely has no cross-process provider and the fallback returns null — wrappers
639
590
  // cleanly fall through to their own secondary strategy.
640
591
  {
641
- const { buildNativeCapProxy } = await import("@camstack/kernel");
592
+ const { buildNativeCapProxy } = await import('@camstack/kernel')
642
593
  // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment -- moleculer types unresolvable; see `broker` getter docstring
643
- const broker = this.moleculer.broker;
594
+ const broker = this.moleculer.broker
644
595
  this.capabilityRegistry.setNativeFallback(
645
596
  (capName: string, deviceId: number): unknown | null => {
646
- const resolver = this.moleculer.capRouteResolver;
647
- const hubNodeId = this.moleculer.nodeId;
597
+ const resolver = this.moleculer.capRouteResolver
598
+ const hubNodeId = this.moleculer.nodeId
648
599
 
649
600
  // 1. Hub-local path: the resolver's hub-local-child authority is the
650
601
  // single source of truth for whether a forked hub-local UDS child
@@ -677,8 +628,11 @@ export class AddonRegistryService {
677
628
  : { deviceId }
678
629
  // Re-resolve at call time so a child that respawned under a
679
630
  // new runner id is still reachable (route is cheap to build).
680
- const r = resolver.resolveHubLocalUdsRoute(capName, deviceId)
681
- ?? { kind: 'hub-local-uds' as const, capName, childId }
631
+ const r = resolver.resolveHubLocalUdsRoute(capName, deviceId) ?? {
632
+ kind: 'hub-local-uds' as const,
633
+ capName,
634
+ childId,
635
+ }
682
636
  return resolver.dispatch(r, property, mergedInput)
683
637
  }
684
638
  },
@@ -697,21 +651,21 @@ export class AddonRegistryService {
697
651
  resolveNativeCapOwnerSync?: (
698
652
  capName: string,
699
653
  deviceId: number,
700
- ) => { addonId: string; nodeId: string } | null;
701
- }>("device-manager");
702
- const owner = dm?.resolveNativeCapOwnerSync?.(capName, deviceId) ?? null;
703
- if (!owner) return null;
654
+ ) => { addonId: string; nodeId: string } | null
655
+ }>('device-manager')
656
+ const owner = dm?.resolveNativeCapOwnerSync?.(capName, deviceId) ?? null
657
+ if (!owner) return null
704
658
 
705
659
  // Guard: skip hub-local owners here — they were already handled (or skipped
706
660
  // because the resolver was null / not initialised yet). Returning null avoids
707
661
  // building a Moleculer proxy that points at a UDS-only child.
708
- if (owner.nodeId.startsWith(`${hubNodeId}/`)) return null;
662
+ if (owner.nodeId.startsWith(`${hubNodeId}/`)) return null
709
663
 
710
664
  // Remote native cap → Moleculer with native-provider infix.
711
665
  // buildNativeCapProxy uses the action `${addonId}.native-provider.${cap}.${method}`.
712
- return buildNativeCapProxy(broker, owner.addonId, capName, deviceId);
666
+ return buildNativeCapProxy(broker, owner.addonId, capName, deviceId)
713
667
  },
714
- );
668
+ )
715
669
  }
716
670
 
717
671
  // Runner spawn: every non-core, placement-eligible addon runs in its
@@ -732,25 +686,24 @@ export class AddonRegistryService {
732
686
  // would violate one-addon-one-process). Retry policy for a failed
733
687
  // spawn is governed by the kernel circuit-breaker.
734
688
  {
735
- const plan = this.buildAddonGroupPlan(allIds);
689
+ const plan = this.buildAddonGroupPlan(allIds)
736
690
  for (const [runnerId, runnerAddons] of plan) {
737
691
  try {
738
- await this.initializeAddonGroup(runnerId, runnerAddons);
692
+ await this.initializeAddonGroup(runnerId, runnerAddons)
739
693
  for (const { addonId } of runnerAddons) {
740
- this.wireCapabilities(addonId);
694
+ this.wireCapabilities(addonId)
741
695
  }
742
696
  } catch (error: unknown) {
743
- const msg = errMsg(error);
697
+ const msg = errMsg(error)
744
698
  for (const { addonId } of runnerAddons) {
745
- this.emitAddonLifecycleEvent("addon.error", addonId, {
699
+ this.emitAddonLifecycleEvent('addon.error', addonId, {
746
700
  error: msg,
747
- phase: "init",
748
- });
701
+ phase: 'init',
702
+ })
749
703
  }
750
- this.logger.error(
751
- "Runner spawn failed addons on this runner will be skipped",
752
- { meta: { runnerId, addonIds: runnerAddons.map((a) => a.addonId), error: msg } },
753
- );
704
+ this.logger.error('Runner spawn failed — addons on this runner will be skipped', {
705
+ meta: { runnerId, addonIds: runnerAddons.map((a) => a.addonId), error: msg },
706
+ })
754
707
  }
755
708
  }
756
709
  }
@@ -769,106 +722,102 @@ export class AddonRegistryService {
769
722
  // still declare `execution` (e.g. `platform-probe`); the package
770
723
  // boundary is the selection criterion.
771
724
  const isCoreBuiltin = (id: string): boolean =>
772
- this.addonEntries.get(id)?.packageName === "@camstack/core";
725
+ this.addonEntries.get(id)?.packageName === '@camstack/core'
773
726
 
774
727
  // Pass 1 — infrastructure builtins. A failed REQUIRED infra cap
775
728
  // aborts boot: nothing downstream can run without storage/settings.
776
729
  for (const infra of INFRA_CAPABILITIES) {
777
- const addonId = this.findAddonForCapability(infra.name, allIds);
730
+ const addonId = this.findAddonForCapability(infra.name, allIds)
778
731
  if (addonId) {
779
- const entry = this.addonEntries.get(addonId);
732
+ const entry = this.addonEntries.get(addonId)
780
733
  // Only core builtins boot in-process here. A non-core addon that
781
734
  // happens to provide an infra cap (e.g. an alternate
782
735
  // storage-provider) lives in its own runner — never in-process.
783
- if (!entry || entry.initialized || !isCoreBuiltin(addonId)) continue;
736
+ if (!entry || entry.initialized || !isCoreBuiltin(addonId)) continue
784
737
  try {
785
- await this.initializeAddon(addonId);
786
- this.wireCapabilities(addonId);
738
+ await this.initializeAddon(addonId)
739
+ this.wireCapabilities(addonId)
787
740
  } catch (error: unknown) {
788
- const msg = errMsg(error);
789
- this.emitAddonLifecycleEvent("addon.error", addonId, {
741
+ const msg = errMsg(error)
742
+ this.emitAddonLifecycleEvent('addon.error', addonId, {
790
743
  error: msg,
791
- phase: "init",
792
- });
744
+ phase: 'init',
745
+ })
793
746
  if (infra.required) {
794
- throw new Error(
795
- `Required infrastructure addon "${addonId}" failed: ${msg}`,
796
- { cause: error },
797
- );
747
+ throw new Error(`Required infrastructure addon "${addonId}" failed: ${msg}`, {
748
+ cause: error,
749
+ })
798
750
  }
799
- this.logger.warn(
800
- 'Optional infra addon failed -- continuing',
801
- { tags: { addonId }, meta: { error: msg } },
802
- );
751
+ this.logger.warn('Optional infra addon failed -- continuing', {
752
+ tags: { addonId },
753
+ meta: { error: msg },
754
+ })
803
755
  }
804
756
  } else if (infra.required) {
805
- throw new Error(
806
- `No addon provides required infrastructure capability "${infra.name}"`,
807
- );
757
+ throw new Error(`No addon provides required infrastructure capability "${infra.name}"`)
808
758
  }
809
759
  }
810
760
 
811
761
  // Pass 2 — remaining core builtins, in capability-dependency order.
812
- const bootOrder = this.capabilityRegistry.getBootOrder();
813
- const infraNames = new Set(INFRA_CAPABILITIES.map((c) => c.name));
762
+ const bootOrder = this.capabilityRegistry.getBootOrder()
763
+ const infraNames = new Set(INFRA_CAPABILITIES.map((c) => c.name))
814
764
 
815
765
  for (const capName of bootOrder) {
816
- if (infraNames.has(capName)) continue; // Handled in pass 1
766
+ if (infraNames.has(capName)) continue // Handled in pass 1
817
767
 
818
768
  for (const id of allIds) {
819
- const entry = this.addonEntries.get(id);
820
- if (!entry || entry.initialized || !isCoreBuiltin(id)) continue;
769
+ const entry = this.addonEntries.get(id)
770
+ if (!entry || entry.initialized || !isCoreBuiltin(id)) continue
821
771
 
822
772
  // Check if this addon provides this capability
823
- const provides = this.getAddonCapabilities(entry.addon);
824
- if (!provides.some((c) => c.name === capName)) continue;
773
+ const provides = this.getAddonCapabilities(entry.addon)
774
+ if (!provides.some((c) => c.name === capName)) continue
825
775
 
826
776
  try {
827
- await this.initializeAddon(id);
828
- this.wireCapabilities(id);
777
+ await this.initializeAddon(id)
778
+ this.wireCapabilities(id)
829
779
  } catch (error: unknown) {
830
- const msg = errMsg(error);
831
- this.emitAddonLifecycleEvent("addon.error", id, {
780
+ const msg = errMsg(error)
781
+ this.emitAddonLifecycleEvent('addon.error', id, {
832
782
  error: msg,
833
- phase: "init",
834
- });
835
- this.logger.error(
836
- 'Core builtin failed to initialize -- skipping',
837
- { tags: { addonId: id }, meta: { error: msg } },
838
- );
783
+ phase: 'init',
784
+ })
785
+ this.logger.error('Core builtin failed to initialize -- skipping', {
786
+ tags: { addonId: id },
787
+ meta: { error: msg },
788
+ })
839
789
  }
840
790
  }
841
791
  }
842
792
 
843
793
  // Pass 3 — core builtins that declare no capabilities at all.
844
794
  for (const id of allIds) {
845
- const entry = this.addonEntries.get(id);
795
+ const entry = this.addonEntries.get(id)
846
796
  if (entry && !entry.initialized && isCoreBuiltin(id)) {
847
797
  try {
848
- await this.initializeAddon(id);
849
- this.wireCapabilities(id);
798
+ await this.initializeAddon(id)
799
+ this.wireCapabilities(id)
850
800
  } catch (error: unknown) {
851
- const msg = errMsg(error);
852
- this.emitAddonLifecycleEvent("addon.error", id, {
801
+ const msg = errMsg(error)
802
+ this.emitAddonLifecycleEvent('addon.error', id, {
853
803
  error: msg,
854
- phase: "init",
855
- });
856
- this.logger.error(
857
- 'Core builtin failed to initialize -- skipping',
858
- { tags: { addonId: id }, meta: { error: msg } },
859
- );
804
+ phase: 'init',
805
+ })
806
+ this.logger.error('Core builtin failed to initialize -- skipping', {
807
+ tags: { addonId: id },
808
+ meta: { error: msg },
809
+ })
860
810
  }
861
811
  }
862
812
  }
863
813
 
864
814
  const initializedIds = [...this.addonEntries.entries()]
865
815
  .filter(([, e]) => e.initialized)
866
- .map(([id]) => id);
816
+ .map(([id]) => id)
867
817
 
868
- this.logger.info(
869
- 'Addons initialized',
870
- { meta: { initializedCount: initializedIds.length, totalCount: this.addonEntries.size } },
871
- );
818
+ this.logger.info('Addons initialized', {
819
+ meta: { initializedCount: initializedIds.length, totalCount: this.addonEntries.size },
820
+ })
872
821
 
873
822
  // Health snapshot: every addon entry that survived the loader is
874
823
  // either initialized (record success) or failed init (record
@@ -876,7 +825,7 @@ export class AddonRegistryService {
876
825
  // recorded above from `addonLoader.listLoadFailures()`.
877
826
  for (const [id, entry] of this.addonEntries.entries()) {
878
827
  if (entry.initialized) {
879
- this.healthMonitor.recordSuccess(entry.packageName, id);
828
+ this.healthMonitor.recordSuccess(entry.packageName, id)
880
829
  } else {
881
830
  // Init failed (caught by either Phase 1 or Phase 2 catch
882
831
  // blocks above). The catch handlers don't carry the error
@@ -887,14 +836,14 @@ export class AddonRegistryService {
887
836
  entry.packageName,
888
837
  new Error(`Addon "${id}" failed to initialize — see addon logs for details`),
889
838
  id,
890
- );
839
+ )
891
840
  }
892
841
  }
893
842
 
894
843
  // Start the kernel-level retry tick (30s). Fires immediately
895
844
  // on the first interval boundary; the 5-min boot grace window
896
845
  // suppresses alerts until the system has had time to stabilize.
897
- this.healthMonitor.start();
846
+ this.healthMonitor.start()
898
847
 
899
848
  // Group-runner / forked-addon crash detection (point 5 of the
900
849
  // operator's spec — "ogni fork/moleculer deve essere considerato").
@@ -934,7 +883,7 @@ export class AddonRegistryService {
934
883
  entry.packageName,
935
884
  new Error(`Addon runner ${nodeId} disconnected`),
936
885
  id,
937
- );
886
+ )
938
887
  }
939
888
  })
940
889
  localBus.on('$node.connected', (payload) => {
@@ -944,7 +893,7 @@ export class AddonRegistryService {
944
893
  if (!entry.declaration) continue
945
894
  const runnerId = resolveRunnerId(entry.declaration, id)
946
895
  if (`${this.broker.nodeID}/${runnerId}` !== nodeId) continue
947
- this.healthMonitor.recordSuccess(entry.packageName, id);
896
+ this.healthMonitor.recordSuccess(entry.packageName, id)
948
897
  }
949
898
  })
950
899
  }
@@ -952,10 +901,10 @@ export class AddonRegistryService {
952
901
  this.eventBusService.emit({
953
902
  id: randomUUID(),
954
903
  timestamp: new Date(),
955
- source: { type: "core", id: "addon-registry" },
904
+ source: { type: 'core', id: 'addon-registry' },
956
905
  category: EventCategory.SystemAddonsReady,
957
906
  data: { activeAddons: initializedIds },
958
- });
907
+ })
959
908
  }
960
909
 
961
910
  /**
@@ -975,89 +924,91 @@ export class AddonRegistryService {
975
924
  * monitor's catch path schedules the next retry.
976
925
  */
977
926
  async tryReloadPackage(packageName: string): Promise<void> {
978
- const dataDir = path.resolve(process.env.CAMSTACK_DATA ?? "camstack-data");
979
- const addonsDir = path.resolve(dataDir, "addons");
980
- const addonDir = path.join(addonsDir, packageName);
927
+ const dataDir = path.resolve(process.env.CAMSTACK_DATA ?? 'camstack-data')
928
+ const addonsDir = path.resolve(dataDir, 'addons')
929
+ const addonDir = path.join(addonsDir, packageName)
981
930
 
982
931
  if (!fs.existsSync(addonDir)) {
983
- throw new Error(`Package directory missing: ${addonDir}`);
932
+ throw new Error(`Package directory missing: ${addonDir}`)
984
933
  }
985
934
 
986
935
  // Branch 1: package known to the registry already → restart each
987
936
  // addon entry. `restartAddon` rebuilds AddonContext + runs init
988
937
  // again; on success the entry's `initialized` flag flips back to
989
938
  // true and the monitor's auto-resolve path records success.
990
- const knownAddonIds: string[] = [];
939
+ const knownAddonIds: string[] = []
991
940
  for (const [id, entry] of this.addonEntries.entries()) {
992
941
  if (entry.packageName === packageName) {
993
- knownAddonIds.push(id);
942
+ knownAddonIds.push(id)
994
943
  }
995
944
  }
996
945
 
997
946
  if (knownAddonIds.length > 0) {
998
- const errors: string[] = [];
947
+ const errors: string[] = []
999
948
  for (const id of knownAddonIds) {
1000
949
  try {
1001
- await this.restartAddon(id);
1002
- this.healthMonitor.recordSuccess(packageName, id);
950
+ await this.restartAddon(id)
951
+ this.healthMonitor.recordSuccess(packageName, id)
1003
952
  } catch (err) {
1004
- errors.push(`${id}: ${errMsg(err)}`);
1005
- this.healthMonitor.recordFailure(packageName, err, id);
953
+ errors.push(`${id}: ${errMsg(err)}`)
954
+ this.healthMonitor.recordFailure(packageName, err, id)
1006
955
  }
1007
956
  }
1008
957
  if (errors.length > 0) {
1009
- throw new Error(`tryReloadPackage failed for ${packageName}: ${errors.join('; ')}`);
958
+ throw new Error(`tryReloadPackage failed for ${packageName}: ${errors.join('; ')}`)
1010
959
  }
1011
- return;
960
+ return
1012
961
  }
1013
962
 
1014
963
  // Branch 2: package not yet known → run loadNewAddons which
1015
964
  // walks disk, instantiates new entries, and initializes them.
1016
965
  // It records its own loaded/failed in the return; we re-throw
1017
966
  // when our package is in the failed list so the monitor knows.
1018
- const result = await this.loadNewAddons();
967
+ const result = await this.loadNewAddons()
1019
968
  if (result.failed.length > 0) {
1020
- const failed = result.failed.join(', ');
1021
- throw new Error(`loadNewAddons reported failures: ${failed}`);
969
+ const failed = result.failed.join(', ')
970
+ throw new Error(`loadNewAddons reported failures: ${failed}`)
1022
971
  }
1023
972
  if (result.loaded.length === 0) {
1024
- throw new Error(`No new addons loaded for ${packageName} — package may still have a broken manifest`);
973
+ throw new Error(
974
+ `No new addons loaded for ${packageName} — package may still have a broken manifest`,
975
+ )
1025
976
  }
1026
977
  // Match loaded addons to this package + record success.
1027
978
  for (const id of result.loaded) {
1028
- const entry = this.addonEntries.get(id);
979
+ const entry = this.addonEntries.get(id)
1029
980
  if (entry?.packageName === packageName) {
1030
- this.healthMonitor.recordSuccess(packageName, id);
981
+ this.healthMonitor.recordSuccess(packageName, id)
1031
982
  }
1032
983
  }
1033
984
  }
1034
985
 
1035
986
  /** Health snapshot — exposed to the addons.list tRPC procedure. */
1036
987
  getAddonHealthSnapshot(): readonly AddonHealthSnapshot[] {
1037
- return this.healthMonitor.getHealthSnapshot();
988
+ return this.healthMonitor.getHealthSnapshot()
1038
989
  }
1039
990
 
1040
991
  /** Manual user-triggered retry (resets retry counter). */
1041
992
  async retryAddonLoad(packageName: string): Promise<void> {
1042
- return this.healthMonitor.retryNow(packageName);
993
+ return this.healthMonitor.retryNow(packageName)
1043
994
  }
1044
995
 
1045
996
  async onModuleDestroy(): Promise<void> {
1046
- this.logger.info("Shutting down all addons…");
1047
- this.healthMonitor.stop();
1048
- await this.shutdownAll();
1049
- this.logger.info("All addons shut down");
997
+ this.logger.info('Shutting down all addons…')
998
+ this.healthMonitor.stop()
999
+ await this.shutdownAll()
1000
+ this.logger.info('All addons shut down')
1050
1001
  }
1051
1002
 
1052
1003
  /** Set the AddonRouteRegistry for wiring addon-routes capabilities */
1053
1004
  setAddonRouteRegistry(registry: AddonRouteRegistry): void {
1054
- this.addonRouteRegistry = registry;
1005
+ this.addonRouteRegistry = registry
1055
1006
  }
1056
1007
 
1057
1008
  /** Set the DataPlaneRegistry the hub's `/addon/:id/*` dispatch reverse-proxies
1058
1009
  * against (addon HTTP data-planes). */
1059
1010
  setDataPlaneRegistry(registry: DataPlaneRegistry): void {
1060
- this.dataPlaneRegistry = registry;
1011
+ this.dataPlaneRegistry = registry
1061
1012
  }
1062
1013
 
1063
1014
  /**
@@ -1069,23 +1020,23 @@ export class AddonRegistryService {
1069
1020
  * the time any of its caps register.
1070
1021
  */
1071
1022
  private async mountAddonDataPlanes(addonId: string): Promise<void> {
1072
- const registry = this.dataPlaneRegistry;
1073
- if (!registry) return;
1074
- const entry = this.addonEntries.get(addonId);
1075
- const childRegistry = this.moleculer.childRegistry;
1023
+ const registry = this.dataPlaneRegistry
1024
+ if (!registry) return
1025
+ const entry = this.addonEntries.get(addonId)
1026
+ const childRegistry = this.moleculer.childRegistry
1076
1027
  // Forked addons only for now — co-located builtins would publish into the
1077
1028
  // registry directly via a hub-side sink (deferred; no builtin serves a
1078
1029
  // data-plane yet).
1079
- if (!entry || !this.isForkedAddonEntry(entry)) return;
1080
- if (childRegistry === null || !childRegistry.isChildKnown(addonId)) return;
1030
+ if (!entry || !this.isForkedAddonEntry(entry)) return
1031
+ if (childRegistry === null || !childRegistry.isChildKnown(addonId)) return
1081
1032
 
1082
- const raw = await this.addonCallGateway.callForked(addonId, { target: "data-planes" });
1083
- const endpoints = parseDataPlaneEndpoints(raw);
1084
- registry.registerAddon(addonId, endpoints);
1033
+ const raw = await this.addonCallGateway.callForked(addonId, { target: 'data-planes' })
1034
+ const endpoints = parseDataPlaneEndpoints(raw)
1035
+ registry.registerAddon(addonId, endpoints)
1085
1036
  if (endpoints.length > 0) {
1086
- this.logger.info("Addon data-planes mounted (reverse-proxy)", {
1087
- meta: { phase: "v2", addonId, prefixes: endpoints.map((e) => e.prefix) },
1088
- });
1037
+ this.logger.info('Addon data-planes mounted (reverse-proxy)', {
1038
+ meta: { phase: 'v2', addonId, prefixes: endpoints.map((e) => e.prefix) },
1039
+ })
1089
1040
  }
1090
1041
  }
1091
1042
 
@@ -1097,22 +1048,22 @@ export class AddonRegistryService {
1097
1048
  */
1098
1049
  async setAppRouter(_router: unknown): Promise<void> {
1099
1050
  this.logger.debug(
1100
- "setAppRouter called — broker-routed addon API in use, no direct caller needed",
1101
- );
1051
+ 'setAppRouter called — broker-routed addon API in use, no direct caller needed',
1052
+ )
1102
1053
  }
1103
1054
 
1104
1055
  /** Get the CapabilityRegistry for external consumers to register */
1105
1056
  getCapabilityRegistry(): CapabilityRegistry {
1106
- return this.capabilityRegistry;
1057
+ return this.capabilityRegistry
1107
1058
  }
1108
1059
 
1109
1060
  /** Get the CustomActionRegistry — Task 7.2 will use this from the `api.addons.custom` tRPC procedure. */
1110
1061
  getCustomActionRegistry(): CustomActionRegistry {
1111
- return this.customActionRegistry;
1062
+ return this.customActionRegistry
1112
1063
  }
1113
1064
 
1114
1065
  getDeviceRegistry(): DeviceRegistry {
1115
- return this.deviceRegistry;
1066
+ return this.deviceRegistry
1116
1067
  }
1117
1068
 
1118
1069
  /** Load persisted collection disabled-lists from settings-store into the registry */
@@ -1125,11 +1076,9 @@ export class AddonRegistryService {
1125
1076
  * (where the addon is not currently installed). Orphaned data stays in DB
1126
1077
  * and reconnects automatically when the addon is reinstalled.
1127
1078
  */
1128
- getIntegrationRegistry():
1129
- | import("@camstack/types").IIntegrationRegistry
1130
- | null {
1131
- if (!this.integrationRegistry) return null;
1132
- return this.createFilteredRegistry(this.integrationRegistry);
1079
+ getIntegrationRegistry(): import('@camstack/types').IIntegrationRegistry | null {
1080
+ if (!this.integrationRegistry) return null
1081
+ return this.createFilteredRegistry(this.integrationRegistry)
1133
1082
  }
1134
1083
 
1135
1084
  /**
@@ -1140,91 +1089,86 @@ export class AddonRegistryService {
1140
1089
  * overrides directly.
1141
1090
  */
1142
1091
  getSettingsBackend(): ISettingsBackend | null {
1143
- return this.activeSettingsBackend;
1092
+ return this.activeSettingsBackend
1144
1093
  }
1145
1094
 
1146
1095
  /** Get the raw (unfiltered) registry — only for internal addon wiring */
1147
- getRawIntegrationRegistry():
1148
- | import("@camstack/types").IIntegrationRegistry
1149
- | null {
1150
- return this.integrationRegistry;
1096
+ getRawIntegrationRegistry(): import('@camstack/types').IIntegrationRegistry | null {
1097
+ return this.integrationRegistry
1151
1098
  }
1152
1099
 
1153
1100
  private createFilteredRegistry(
1154
- raw: import("@camstack/types").IIntegrationRegistry,
1155
- ): import("@camstack/types").IIntegrationRegistry {
1156
- const installedAddonIds = new Set([...this.addonEntries.keys()]);
1101
+ raw: import('@camstack/types').IIntegrationRegistry,
1102
+ ): import('@camstack/types').IIntegrationRegistry {
1103
+ const installedAddonIds = new Set(this.addonEntries.keys())
1157
1104
 
1158
1105
  // Build set of integration IDs whose addon IS installed
1159
1106
  // Cache per call — lightweight, called infrequently
1160
- let activeIntegrationIds: Set<string> | null = null;
1107
+ let activeIntegrationIds: Set<string> | null = null
1161
1108
  const ensureActiveIds = async () => {
1162
- if (activeIntegrationIds) return activeIntegrationIds;
1163
- const all = await raw.listIntegrations();
1109
+ if (activeIntegrationIds) return activeIntegrationIds
1110
+ const all = await raw.listIntegrations()
1164
1111
  activeIntegrationIds = new Set(
1165
1112
  all.filter((i) => installedAddonIds.has(i.addonId)).map((i) => i.id),
1166
- );
1167
- return activeIntegrationIds;
1168
- };
1113
+ )
1114
+ return activeIntegrationIds
1115
+ }
1169
1116
 
1170
1117
  return {
1171
1118
  // Integrations: filter out orphaned
1172
1119
  createIntegration: (input) => raw.createIntegration(input),
1173
1120
  getIntegration: async (id) => {
1174
- const i = await raw.getIntegration(id);
1175
- return i && installedAddonIds.has(i.addonId) ? i : null;
1121
+ const i = await raw.getIntegration(id)
1122
+ return i && installedAddonIds.has(i.addonId) ? i : null
1176
1123
  },
1177
1124
  getIntegrationByAddonId: async (addonId) => {
1178
- if (!installedAddonIds.has(addonId)) return null;
1179
- return raw.getIntegrationByAddonId(addonId);
1125
+ if (!installedAddonIds.has(addonId)) return null
1126
+ return raw.getIntegrationByAddonId(addonId)
1180
1127
  },
1181
1128
  listIntegrations: async () => {
1182
- const all = await raw.listIntegrations();
1183
- return all.filter((i) => installedAddonIds.has(i.addonId));
1129
+ const all = await raw.listIntegrations()
1130
+ return all.filter((i) => installedAddonIds.has(i.addonId))
1184
1131
  },
1185
1132
  updateIntegration: (id, updates) => raw.updateIntegration(id, updates),
1186
1133
  deleteIntegration: (id) => raw.deleteIntegration(id),
1187
1134
 
1188
1135
  // Integration settings: passthrough (already gated by getIntegration)
1189
1136
  getIntegrationSettings: (id) => raw.getIntegrationSettings(id),
1190
- setIntegrationSetting: (id, key, value) =>
1191
- raw.setIntegrationSetting(id, key, value),
1192
- setIntegrationSettings: (id, settings) =>
1193
- raw.setIntegrationSettings(id, settings),
1137
+ setIntegrationSetting: (id, key, value) => raw.setIntegrationSetting(id, key, value),
1138
+ setIntegrationSettings: (id, settings) => raw.setIntegrationSettings(id, settings),
1194
1139
 
1195
1140
  // Devices: filter out devices belonging to orphaned integrations
1196
1141
  createDevice: (input) => raw.createDevice(input),
1197
1142
  getDevice: async (id) => {
1198
- const d = await raw.getDevice(id);
1199
- if (!d) return null;
1200
- const ids = await ensureActiveIds();
1201
- return ids.has(d.integrationId) ? d : null;
1143
+ const d = await raw.getDevice(id)
1144
+ if (!d) return null
1145
+ const ids = await ensureActiveIds()
1146
+ return ids.has(d.integrationId) ? d : null
1202
1147
  },
1203
1148
  getDeviceByStableId: async (stableId) => {
1204
- const d = await raw.getDeviceByStableId(stableId);
1205
- if (!d) return null;
1206
- const ids = await ensureActiveIds();
1207
- return ids.has(d.integrationId) ? d : null;
1149
+ const d = await raw.getDeviceByStableId(stableId)
1150
+ if (!d) return null
1151
+ const ids = await ensureActiveIds()
1152
+ return ids.has(d.integrationId) ? d : null
1208
1153
  },
1209
1154
  listDevices: async (integrationId) => {
1210
- const devices = await raw.listDevices(integrationId);
1211
- const ids = await ensureActiveIds();
1212
- return devices.filter((d) => ids.has(d.integrationId));
1155
+ const devices = await raw.listDevices(integrationId)
1156
+ const ids = await ensureActiveIds()
1157
+ return devices.filter((d) => ids.has(d.integrationId))
1213
1158
  },
1214
1159
  listCameras: async () => {
1215
- const cameras = await raw.listCameras();
1216
- const ids = await ensureActiveIds();
1217
- return cameras.filter((d) => ids.has(d.integrationId));
1160
+ const cameras = await raw.listCameras()
1161
+ const ids = await ensureActiveIds()
1162
+ return cameras.filter((d) => ids.has(d.integrationId))
1218
1163
  },
1219
1164
  updateDevice: (id, updates) => raw.updateDevice(id, updates),
1220
1165
  deleteDevice: (id) => raw.deleteDevice(id),
1221
1166
 
1222
1167
  // Device settings: passthrough
1223
1168
  getDeviceSettings: (id) => raw.getDeviceSettings(id),
1224
- setDeviceSetting: (id, key, value) =>
1225
- raw.setDeviceSetting(id, key, value),
1169
+ setDeviceSetting: (id, key, value) => raw.setDeviceSetting(id, key, value),
1226
1170
  setDeviceSettings: (id, settings) => raw.setDeviceSettings(id, settings),
1227
- };
1171
+ }
1228
1172
  }
1229
1173
 
1230
1174
  // InferenceCapabilitiesService removed — now lives in pipeline-executor addon.
@@ -1232,21 +1176,24 @@ export class AddonRegistryService {
1232
1176
 
1233
1177
  /** Log a standardized lifecycle line for addon start/restart */
1234
1178
  private logAddonLifecycle(
1235
- event: "started" | "restarted",
1179
+ event: 'started' | 'restarted',
1236
1180
  id: string,
1237
- mode: "in-process" | "isolated",
1181
+ mode: 'in-process' | 'isolated',
1238
1182
  ): void {
1239
- const entry = this.addonEntries.get(id);
1240
- if (!entry) return;
1241
- const agentName = process.env.CAMSTACK_AGENT_NAME ?? "hub";
1242
- const platform = `${os.platform()}/${os.arch()}`;
1243
- const message = `Addon ${event} — v${entry.packageVersion} (${entry.packageName}), ${mode}, ${platform}, agent: ${agentName}`;
1183
+ const entry = this.addonEntries.get(id)
1184
+ if (!entry) return
1185
+ const agentName = process.env.CAMSTACK_AGENT_NAME ?? 'hub'
1186
+ const platform = `${os.platform()}/${os.arch()}`
1187
+ const message = `Addon ${event} — v${entry.packageVersion} (${entry.packageName}), ${mode}, ${platform}, agent: ${agentName}`
1244
1188
  // Log under AddonRegistry scope
1245
- this.logger.info(message, { tags: { addonId: id, agentId: agentName }, meta: { phase: 'lifecycle' } });
1189
+ this.logger.info(message, {
1190
+ tags: { addonId: id, agentId: agentName },
1191
+ meta: { phase: 'lifecycle' },
1192
+ })
1246
1193
  // Also log under the addon's own tagged logger so it appears in the per-addon
1247
1194
  // log viewer. No scope — brand bracket shows the addon id.
1248
- const addonLogger = this.loggingService.createLogger();
1249
- addonLogger.info(message, { tags: { addonId: id, agentId: agentName } });
1195
+ const addonLogger = this.loggingService.createLogger()
1196
+ addonLogger.info(message, { tags: { addonId: id, agentId: agentName } })
1250
1197
  }
1251
1198
 
1252
1199
  // Platform probing and inference-config resolution have moved into the
@@ -1257,36 +1204,34 @@ export class AddonRegistryService {
1257
1204
 
1258
1205
  registerAddon(
1259
1206
  addon: ICamstackAddon,
1260
- source: AddonSource = "installed",
1207
+ source: AddonSource = 'installed',
1261
1208
  packageName?: string,
1262
1209
  packageVersion?: string,
1263
1210
  ): void {
1264
- const manifest = addon.manifest;
1211
+ const manifest = addon.manifest
1265
1212
  if (!manifest) {
1266
1213
  throw new Error(
1267
- "Cannot register addon without manifest — was it created via AddonLoader.createInstance()?",
1268
- );
1214
+ 'Cannot register addon without manifest — was it created via AddonLoader.createInstance()?',
1215
+ )
1269
1216
  }
1270
1217
  this.addonEntries.set(manifest.id, {
1271
1218
  addon,
1272
1219
  initialized: false,
1273
1220
  source,
1274
1221
  packageName: packageName ?? manifest.name ?? manifest.id,
1275
- packageVersion: packageVersion ?? manifest.version ?? "0.0.0",
1222
+ packageVersion: packageVersion ?? manifest.version ?? '0.0.0',
1276
1223
  declaredCapabilities: this.getAddonCapabilities(addon),
1277
- });
1224
+ })
1278
1225
  }
1279
1226
 
1280
1227
  async initializeAddon(id: string): Promise<void> {
1281
- const entry = this.addonEntries.get(id);
1228
+ const entry = this.addonEntries.get(id)
1282
1229
  if (!entry) {
1283
- throw new Error(`Addon "${id}" is not registered`);
1230
+ throw new Error(`Addon "${id}" is not registered`)
1284
1231
  }
1285
1232
 
1286
1233
  if (!entry.addon?.manifest?.id) {
1287
- throw new Error(
1288
- `Addon "${id}" has no manifest.id — check the addon class default export`,
1289
- );
1234
+ throw new Error(`Addon "${id}" has no manifest.id — check the addon class default export`)
1290
1235
  }
1291
1236
 
1292
1237
  // Decide whether this addon should boot in its own forked runner
@@ -1306,25 +1251,22 @@ export class AddonRegistryService {
1306
1251
  // silently run on the hub. (Task 7's circuit breaker governs the
1307
1252
  // retry policy on top of this.)
1308
1253
  try {
1309
- await this.broker.call("$process.spawnRunner", {
1254
+ await this.broker.call('$process.spawnRunner', {
1310
1255
  runnerId: id,
1311
1256
  addons: [{ addonId: id, addonDir: entry.addonDir }],
1312
- });
1257
+ })
1313
1258
  } catch (err) {
1314
- const msg = errMsg(err);
1315
- this.logger.error(
1316
- 'Failed to spawn isolated runner for addon',
1317
- { tags: { addonId: id }, meta: { error: msg } },
1318
- );
1319
- this.emitAddonLifecycleEvent("addon.error", id, {
1259
+ const msg = errMsg(err)
1260
+ this.logger.error('Failed to spawn isolated runner for addon', {
1261
+ tags: { addonId: id },
1262
+ meta: { error: msg },
1263
+ })
1264
+ this.emitAddonLifecycleEvent('addon.error', id, {
1320
1265
  error: msg,
1321
- action: "initialize",
1322
- });
1323
- this.healthMonitor.recordFailure(entry.packageName, err, id);
1324
- throw new Error(
1325
- `Failed to spawn runner for addon "${id}": ${msg}`,
1326
- { cause: err },
1327
- );
1266
+ action: 'initialize',
1267
+ })
1268
+ this.healthMonitor.recordFailure(entry.packageName, err, id)
1269
+ throw new Error(`Failed to spawn runner for addon "${id}": ${msg}`, { cause: err })
1328
1270
  }
1329
1271
 
1330
1272
  // Provider registration for forkable addons is delegated to the
@@ -1345,24 +1287,22 @@ export class AddonRegistryService {
1345
1287
  id,
1346
1288
  // `isForkedAddonEntry` narrowed `entry.declaration` to non-null.
1347
1289
  resolveRunnerId(entry.declaration, id),
1348
- );
1290
+ )
1349
1291
 
1350
- entry.initialized = true;
1351
- this.logger.info('Addon spawned as isolated process', { tags: { addonId: id } });
1352
- this.emitAddonLifecycleEvent("addon.started", id);
1353
- return;
1292
+ entry.initialized = true
1293
+ this.logger.info('Addon spawned as isolated process', { tags: { addonId: id } })
1294
+ this.emitAddonLifecycleEvent('addon.started', id)
1295
+ return
1354
1296
  }
1355
1297
 
1356
1298
  // In-process initialization — capture providers from initialize() return value
1357
- const context = await this.createAddonContext(entry.addon);
1358
- const capturedProviders = new Map<string, unknown>();
1299
+ const context = await this.createAddonContext(entry.addon)
1300
+ const capturedProviders = new Map<string, unknown>()
1359
1301
  // Device-manager needs privileged access to CapabilityRegistry — it
1360
1302
  // resolves native addon ids for getBindings, lists registered wrappers,
1361
1303
  // etc. Inject before initialize() runs so the addon's onInitialize can
1362
1304
  // rely on it.
1363
- const initResult = normalizeAddonInitResult(
1364
- await entry.addon.initialize(context),
1365
- );
1305
+ const initResult = normalizeAddonInitResult(await entry.addon.initialize(context))
1366
1306
  // NOTE: `postBrokerStart()` is deliberately NOT called here. It used to
1367
1307
  // fire right after `initialize()` and would emit readiness events whose
1368
1308
  // subscribers (remote workers) immediately called `ctx.api.<addon>.*` —
@@ -1371,9 +1311,9 @@ export class AddonRegistryService {
1371
1311
  // service mount so the advertised service is live before readiness.
1372
1312
 
1373
1313
  for (const reg of initResult?.providers ?? []) {
1374
- const capName = reg.capability.name;
1375
- capturedProviders.set(capName, reg.provider);
1376
- context.registerProvider(capName, reg.provider);
1314
+ const capName = reg.capability.name
1315
+ capturedProviders.set(capName, reg.provider)
1316
+ context.registerProvider(capName, reg.provider)
1377
1317
  // D4: wrapper behaviour is read from the cap DEFINITION. The runtime
1378
1318
  // ProviderRegistration.kind/defaultActive are deprecated hints — if an
1379
1319
  // addon still sets them and they disagree, warn (the cap def wins).
@@ -1382,42 +1322,35 @@ export class AddonRegistryService {
1382
1322
  kind: reg.kind,
1383
1323
  // eslint-disable-next-line @typescript-eslint/no-deprecated -- this IS the drift detector; it reads the deprecated hint on purpose
1384
1324
  defaultActive: reg.defaultActive,
1385
- });
1325
+ })
1386
1326
  if (kindDrift) {
1387
1327
  this.logger.warn(kindDrift, {
1388
1328
  tags: { addonId: id },
1389
1329
  meta: { capability: capName },
1390
- });
1330
+ })
1391
1331
  }
1392
- if (reg.capability.kind === "wrapper") {
1332
+ if (reg.capability.kind === 'wrapper') {
1393
1333
  this.capabilityRegistry.registerWrapper(capName, id, {
1394
1334
  defaultActive: reg.capability.defaultActive === true,
1395
- });
1335
+ })
1396
1336
  }
1397
1337
  }
1398
1338
 
1399
1339
  // Task 7.1: register system-level custom actions if the addon declares any.
1400
1340
  if (initResult?.customActions && initResult.actionHandlers) {
1401
- const handlers = initResult.actionHandlers;
1341
+ const handlers = initResult.actionHandlers
1402
1342
  try {
1403
- this.customActionRegistry.registerAddon(
1404
- id,
1405
- initResult.customActions,
1406
- (action, input) => {
1407
- const fn = handlers[action];
1408
- if (!fn)
1409
- throw new Error(
1410
- `addon '${id}' has no handler for custom action '${action}'`,
1411
- );
1412
- return fn(input);
1413
- },
1414
- );
1343
+ this.customActionRegistry.registerAddon(id, initResult.customActions, (action, input) => {
1344
+ const fn = handlers[action]
1345
+ if (!fn) throw new Error(`addon '${id}' has no handler for custom action '${action}'`)
1346
+ return fn(input)
1347
+ })
1415
1348
  } catch (err) {
1416
- this.logger.error(
1417
- 'Failed to register custom actions for addon',
1418
- { tags: { addonId: id }, meta: { error: errMsg(err) } },
1419
- );
1420
- throw err;
1349
+ this.logger.error('Failed to register custom actions for addon', {
1350
+ tags: { addonId: id },
1351
+ meta: { error: errMsg(err) },
1352
+ })
1353
+ throw err
1421
1354
  }
1422
1355
  }
1423
1356
 
@@ -1429,45 +1362,37 @@ export class AddonRegistryService {
1429
1362
  // without forcing addons into a single return-path pattern.
1430
1363
  if (entry.declaration) {
1431
1364
  for (const cap of entry.declaration.capabilities ?? []) {
1432
- const capName = typeof cap === "string" ? cap : cap.name;
1433
- if (capturedProviders.has(capName)) continue;
1434
- const provider = this.capabilityRegistry.getProviderByAddon(
1435
- capName,
1436
- id,
1437
- );
1438
- if (provider) capturedProviders.set(capName, provider);
1365
+ const capName = typeof cap === 'string' ? cap : cap.name
1366
+ if (capturedProviders.has(capName)) continue
1367
+ const provider = this.capabilityRegistry.getProviderByAddon(capName, id)
1368
+ if (provider) capturedProviders.set(capName, provider)
1439
1369
  }
1440
- validateProviderRegistrations(
1441
- id,
1442
- entry.declaration,
1443
- capturedProviders,
1444
- this.logger,
1445
- );
1370
+ validateProviderRegistrations(id, entry.declaration, capturedProviders, this.logger)
1446
1371
  }
1447
1372
 
1448
- await this.restoreAddonDevices(id, entry.addon);
1449
- entry.initialized = true;
1373
+ await this.restoreAddonDevices(id, entry.addon)
1374
+ entry.initialized = true
1450
1375
 
1451
1376
  // Register as Moleculer service for remote discoverability
1452
1377
  if (entry.declaration) {
1453
1378
  try {
1454
1379
  // Resolve method names from CapabilityDefinitions registered in the registry
1455
1380
  const methodResolver = (capName: string): readonly string[] => {
1456
- const def = this.capabilityRegistry.getDefinition(capName);
1457
- if (!def?.methods) return [];
1458
- return Object.keys(def.methods);
1459
- };
1381
+ const def = this.capabilityRegistry.getDefinition(capName)
1382
+ if (!def?.methods) return []
1383
+ return Object.keys(def.methods)
1384
+ }
1460
1385
  // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment -- createAddonService return type unresolvable due to upstream eventemitter2/moleculer type chain
1461
1386
  const schema = createAddonService(entry.addon, entry.declaration!, {
1462
1387
  methodResolver,
1463
1388
  providers: capturedProviders,
1464
- });
1465
- this.broker.createService(schema);
1389
+ })
1390
+ this.broker.createService(schema)
1466
1391
  } catch (err) {
1467
- this.logger.warn(
1468
- 'Failed to register Moleculer service',
1469
- { tags: { addonId: id }, meta: { error: errMsg(err) } },
1470
- );
1392
+ this.logger.warn('Failed to register Moleculer service', {
1393
+ tags: { addonId: id },
1394
+ meta: { error: errMsg(err) },
1395
+ })
1471
1396
  }
1472
1397
  }
1473
1398
 
@@ -1476,14 +1401,14 @@ export class AddonRegistryService {
1476
1401
  // immediately call `ctx.api.<addon>.*` without hitting a phantom
1477
1402
  // service window. Hub addons with `autoEmitReadiness=true` emit here;
1478
1403
  // addons that manage their own readiness override `autoEmitReadiness`.
1479
- entry.addon.postBrokerStart();
1480
-
1481
- this.logger.info(
1482
- 'Addon initialized in-process',
1483
- { tags: { addonId: id }, meta: { packageName: entry.packageName } },
1484
- );
1485
- this.logAddonLifecycle("started", id, "in-process");
1486
- this.emitAddonLifecycleEvent("addon.started", id);
1404
+ entry.addon.postBrokerStart()
1405
+
1406
+ this.logger.info('Addon initialized in-process', {
1407
+ tags: { addonId: id },
1408
+ meta: { packageName: entry.packageName },
1409
+ })
1410
+ this.logAddonLifecycle('started', id, 'in-process')
1411
+ this.emitAddonLifecycleEvent('addon.started', id)
1487
1412
  }
1488
1413
 
1489
1414
  /**
@@ -1491,70 +1416,65 @@ export class AddonRegistryService {
1491
1416
  * Calls addon.restoreDevices() with the list of devices saved to the DB.
1492
1417
  * No-op if the addon does not implement restoreDevices() or stores are not available.
1493
1418
  */
1494
- private async restoreAddonDevices(
1495
- addonId: string,
1496
- addon: ICamstackAddon,
1497
- ): Promise<void> {
1498
- if (typeof addon.restoreDevices !== "function") return;
1419
+ private async restoreAddonDevices(addonId: string, addon: ICamstackAddon): Promise<void> {
1420
+ if (typeof addon.restoreDevices !== 'function') return
1499
1421
 
1500
1422
  // Route through the device-persistence capability instead of
1501
1423
  // accessing DeviceStore/ConfigStore directly. This keeps
1502
1424
  // AddonRegistryService decoupled from the persistence layer —
1503
1425
  // the capability addon owns the stores.
1504
- const api = this.getBrokerApi();
1426
+ const api = this.getBrokerApi()
1505
1427
  try {
1506
1428
  const listResult: unknown = await (
1507
- Reflect.get(
1508
- Reflect.get(api, "deviceManager") as object,
1509
- "listPersistedByAddon",
1510
- ) as { query: (input: unknown) => Promise<unknown> }
1511
- ).query({ addonId });
1429
+ Reflect.get(Reflect.get(api, 'deviceManager') as object, 'listPersistedByAddon') as {
1430
+ query: (input: unknown) => Promise<unknown>
1431
+ }
1432
+ ).query({ addonId })
1512
1433
 
1513
1434
  const rows = Array.isArray(listResult)
1514
1435
  ? (listResult as Array<{
1515
- id: number;
1516
- stableId: string;
1517
- type: string;
1518
- name: string;
1519
- location?: string | null;
1520
- disabled?: boolean;
1521
- parentDeviceId: number | null;
1436
+ id: number
1437
+ stableId: string
1438
+ type: string
1439
+ name: string
1440
+ location?: string | null
1441
+ disabled?: boolean
1442
+ parentDeviceId: number | null
1522
1443
  }>)
1523
- : [];
1524
- if (rows.length === 0) return;
1444
+ : []
1445
+ if (rows.length === 0) return
1525
1446
 
1526
1447
  const savedDevices: SavedDevice[] = await Promise.all(
1527
1448
  rows.map(async (row) => {
1528
1449
  const configResult: unknown = await (
1529
- Reflect.get(
1530
- Reflect.get(api, "deviceManager") as object,
1531
- "loadConfig",
1532
- ) as { query: (input: unknown) => Promise<unknown> }
1533
- ).query({ deviceId: row.id });
1450
+ Reflect.get(Reflect.get(api, 'deviceManager') as object, 'loadConfig') as {
1451
+ query: (input: unknown) => Promise<unknown>
1452
+ }
1453
+ ).query({ deviceId: row.id })
1534
1454
 
1535
1455
  return {
1536
1456
  id: row.id,
1537
1457
  stableId: row.stableId,
1538
- type: row.type as import("@camstack/types").DeviceType,
1458
+ type: row.type as import('@camstack/types').DeviceType,
1539
1459
  name: row.name,
1540
1460
  location: row.location ?? null,
1541
1461
  disabled: row.disabled ?? false,
1542
1462
  parentDeviceId: row.parentDeviceId,
1543
1463
  config: (configResult ?? {}) as Record<string, unknown>,
1544
- };
1464
+ }
1545
1465
  }),
1546
- );
1466
+ )
1547
1467
 
1548
- await addon.restoreDevices!(savedDevices);
1549
- this.logger.info(
1550
- 'Restored devices for addon',
1551
- { tags: { addonId }, meta: { count: savedDevices.length } },
1552
- );
1468
+ await addon.restoreDevices!(savedDevices)
1469
+ this.logger.info('Restored devices for addon', {
1470
+ tags: { addonId },
1471
+ meta: { count: savedDevices.length },
1472
+ })
1553
1473
  } catch (err) {
1554
- this.logger.error(
1555
- 'restoreDevices failed for addon',
1556
- { tags: { addonId }, meta: { error: errMsg(err) } },
1557
- );
1474
+ this.logger.error('restoreDevices failed for addon', {
1475
+ tags: { addonId },
1476
+ meta: { error: errMsg(err) },
1477
+ })
1558
1478
  }
1559
1479
  }
1560
1480
 
@@ -1565,11 +1485,11 @@ export class AddonRegistryService {
1565
1485
  refreshPackageVersion(packageName: string, newVersion: string): void {
1566
1486
  for (const [, entry] of this.addonEntries) {
1567
1487
  if (entry.packageName === packageName) {
1568
- entry.packageVersion = newVersion;
1569
- this.logger.info(
1570
- 'Updated addon packageVersion',
1571
- { tags: { addonId: entry.addon.manifest!.id }, meta: { newVersion } },
1572
- );
1488
+ entry.packageVersion = newVersion
1489
+ this.logger.info('Updated addon packageVersion', {
1490
+ tags: { addonId: entry.addon.manifest!.id },
1491
+ meta: { newVersion },
1492
+ })
1573
1493
  }
1574
1494
  }
1575
1495
  }
@@ -1578,29 +1498,25 @@ export class AddonRegistryService {
1578
1498
  emitUninstallEvent(packageName: string): void {
1579
1499
  for (const [id, entry] of this.addonEntries) {
1580
1500
  if (entry.packageName === packageName) {
1581
- this.emitAddonLifecycleEvent("addon.uninstalled", id);
1501
+ this.emitAddonLifecycleEvent('addon.uninstalled', id)
1582
1502
  }
1583
1503
  }
1584
1504
  // Drop the package from the health monitor so its retry loop
1585
1505
  // doesn't keep trying to reload an addon the operator explicitly
1586
1506
  // removed. Matching `clearLoadFailures` purges any pre-init
1587
1507
  // failures still tracked by the addon-loader.
1588
- this.healthMonitor.forget(packageName);
1589
- this.addonLoader.clearLoadFailures(packageName);
1508
+ this.healthMonitor.forget(packageName)
1509
+ this.addonLoader.clearLoadFailures(packageName)
1590
1510
  }
1591
1511
 
1592
1512
  /** Emit addon.updated lifecycle event for all addons belonging to a package */
1593
- emitUpdateEvent(
1594
- packageName: string,
1595
- fromVersion: string,
1596
- toVersion: string,
1597
- ): void {
1513
+ emitUpdateEvent(packageName: string, fromVersion: string, toVersion: string): void {
1598
1514
  for (const [id, entry] of this.addonEntries) {
1599
1515
  if (entry.packageName === packageName) {
1600
- this.emitAddonLifecycleEvent("addon.updated", id, {
1516
+ this.emitAddonLifecycleEvent('addon.updated', id, {
1601
1517
  fromVersion,
1602
1518
  toVersion,
1603
- });
1519
+ })
1604
1520
  }
1605
1521
  }
1606
1522
  }
@@ -1609,37 +1525,33 @@ export class AddonRegistryService {
1609
1525
  * Restart an addon by ID.
1610
1526
  * Shuts down, unregisters capabilities, re-initializes, and re-wires.
1611
1527
  */
1612
- async restartAddon(
1613
- addonId: string,
1614
- ): Promise<{ success: boolean; error?: string }> {
1615
- const entry = this.addonEntries.get(addonId);
1528
+ async restartAddon(addonId: string): Promise<{ success: boolean; error?: string }> {
1529
+ const entry = this.addonEntries.get(addonId)
1616
1530
  if (!entry) {
1617
- return { success: false, error: `Addon "${addonId}" not found` };
1531
+ return { success: false, error: `Addon "${addonId}" not found` }
1618
1532
  }
1619
1533
 
1620
- this.logger.info('Addon restarting...', { tags: { addonId }, meta: { phase: 'lifecycle' } });
1534
+ this.logger.info('Addon restarting...', { tags: { addonId }, meta: { phase: 'lifecycle' } })
1621
1535
 
1622
1536
  // Suppress the "Failed to load" health banner during operator-initiated
1623
1537
  // restarts. The Moleculer `$node.disconnected` handler skips entries
1624
1538
  // present in `restartingAddons`; a 90s safety timer clears the flag
1625
1539
  // even if the restart path throws before the finally block runs.
1626
- const existingTimer = this.restartingAddons.get(addonId);
1627
- if (existingTimer) clearTimeout(existingTimer);
1540
+ const existingTimer = this.restartingAddons.get(addonId)
1541
+ if (existingTimer) clearTimeout(existingTimer)
1628
1542
  const safetyTimer = setTimeout(() => {
1629
- this.restartingAddons.delete(addonId);
1630
- }, 90_000);
1631
- this.restartingAddons.set(addonId, safetyTimer);
1543
+ this.restartingAddons.delete(addonId)
1544
+ }, 90_000)
1545
+ this.restartingAddons.set(addonId, safetyTimer)
1632
1546
 
1633
1547
  try {
1634
1548
  // Group-runner-hosted addon — delegate to $process.restart for the group
1635
1549
  if (this.isForkedAddonEntry(entry)) {
1636
- const result = (await this.broker.call("$process.restart", {
1550
+ const result = (await this.broker.call('$process.restart', {
1637
1551
  name: addonId,
1638
- })) as { success: boolean; reason?: string };
1552
+ })) as { success: boolean; reason?: string }
1639
1553
  if (!result.success) {
1640
- throw new Error(
1641
- `Process restart failed: ${result.reason ?? "unknown"}`,
1642
- );
1554
+ throw new Error(`Process restart failed: ${result.reason ?? 'unknown'}`)
1643
1555
  }
1644
1556
 
1645
1557
  // $process.restart resolves as soon as the child is respawned, not when its
@@ -1647,7 +1559,7 @@ export class AddonRegistryService {
1647
1559
  // immediately try to route to the provider and hit a transient null. Block here
1648
1560
  // until every declared capability is back on the registry so the restart is
1649
1561
  // observable-consistent from the caller's perspective.
1650
- const declared = entry.declaredCapabilities;
1562
+ const declared = entry.declaredCapabilities
1651
1563
  if (declared.length > 0) {
1652
1564
  // Group-runner restarts pay a multi-step cost: child fork
1653
1565
  // (~1s) → addon init (Python pool warmup ~5-10s, or a CLI
@@ -1661,15 +1573,15 @@ export class AddonRegistryService {
1661
1573
  // always click Cancel on the UI mutation.
1662
1574
  const waits = declared.map((cap) =>
1663
1575
  this.capabilityRegistry.waitForProvider(cap.name, addonId, Number.POSITIVE_INFINITY),
1664
- );
1665
- const settled = await Promise.all(waits);
1576
+ )
1577
+ const settled = await Promise.all(waits)
1666
1578
  const missing = declared
1667
1579
  .map((cap, i) => (settled[i] == null ? cap.name : null))
1668
- .filter((name): name is string => name !== null);
1580
+ .filter((name): name is string => name !== null)
1669
1581
  if (missing.length > 0) {
1670
1582
  throw new Error(
1671
- `Addon "${addonId}" restarted but did not re-register capabilities in time: ${missing.join(", ")}`,
1672
- );
1583
+ `Addon "${addonId}" restarted but did not re-register capabilities in time: ${missing.join(', ')}`,
1584
+ )
1673
1585
  }
1674
1586
  }
1675
1587
 
@@ -1681,12 +1593,12 @@ export class AddonRegistryService {
1681
1593
  // drops its custom actions (the catalog is only registered once,
1682
1594
  // at boot, in `initializeAddonGroup`). Reads a fresh catalog from
1683
1595
  // the just-updated on-disk bundle.
1684
- const runnerId = resolveRunnerId(entry.declaration!, addonId);
1685
- await this.registerForkedAddonCustomActions(addonId, runnerId);
1596
+ const runnerId = resolveRunnerId(entry.declaration!, addonId)
1597
+ await this.registerForkedAddonCustomActions(addonId, runnerId)
1686
1598
 
1687
- this.logAddonLifecycle("restarted", addonId, "isolated");
1688
- this.emitAddonLifecycleEvent("addon.restarted", addonId);
1689
- return { success: true };
1599
+ this.logAddonLifecycle('restarted', addonId, 'isolated')
1600
+ this.emitAddonLifecycleEvent('addon.restarted', addonId)
1601
+ return { success: true }
1690
1602
  }
1691
1603
 
1692
1604
  // D5: a forked addon ALWAYS restarts via `$process.restart` above
@@ -1695,51 +1607,51 @@ export class AddonRegistryService {
1695
1607
  // — they have no runner and legitimately reload in-process:
1696
1608
  // shutdown, unregister, re-initialize, re-wire.
1697
1609
  if (entry.initialized && entry.addon.shutdown) {
1698
- await entry.addon.shutdown();
1610
+ await entry.addon.shutdown()
1699
1611
  }
1700
1612
  // Drain disposers registered via ctx.addDisposer(...)
1701
- await this.drainDisposerChain(addonId);
1613
+ await this.drainDisposerChain(addonId)
1702
1614
 
1703
1615
  // Unregister all capabilities provided by this addon
1704
1616
  for (const cap of entry.declaredCapabilities) {
1705
- this.capabilityRegistry.unregisterProvider(cap.name, addonId);
1617
+ this.capabilityRegistry.unregisterProvider(cap.name, addonId)
1706
1618
  }
1707
- const manifestCaps = this.getAddonCapabilities(entry.addon);
1619
+ const manifestCaps = this.getAddonCapabilities(entry.addon)
1708
1620
  for (const cap of manifestCaps) {
1709
- this.capabilityRegistry.unregisterProvider(cap.name, addonId);
1621
+ this.capabilityRegistry.unregisterProvider(cap.name, addonId)
1710
1622
  }
1711
- this.capabilityRegistry.unregisterAllWrappersForAddon(addonId);
1623
+ this.capabilityRegistry.unregisterAllWrappersForAddon(addonId)
1712
1624
 
1713
1625
  // Task 7.1: drop any registered custom actions so re-initialization re-registers cleanly.
1714
- this.customActionRegistry.unregisterAddon(addonId);
1626
+ this.customActionRegistry.unregisterAddon(addonId)
1715
1627
 
1716
1628
  // Destroy existing Moleculer service for this addon
1717
1629
  try {
1718
1630
  await (
1719
1631
  this.moleculer.broker as unknown as {
1720
- destroyService(name: string): Promise<void>;
1632
+ destroyService(name: string): Promise<void>
1721
1633
  }
1722
- ).destroyService(addonId);
1634
+ ).destroyService(addonId)
1723
1635
  } catch {
1724
1636
  // Service may not exist if it was never registered
1725
1637
  }
1726
1638
 
1727
- entry.initialized = false;
1639
+ entry.initialized = false
1728
1640
 
1729
1641
  // Re-initialize and re-wire capabilities
1730
- await this.initializeAddon(addonId);
1731
- this.wireCapabilities(addonId);
1642
+ await this.initializeAddon(addonId)
1643
+ this.wireCapabilities(addonId)
1732
1644
 
1733
- this.logAddonLifecycle("restarted", addonId, "in-process");
1734
- this.emitAddonLifecycleEvent("addon.restarted", addonId);
1735
- return { success: true };
1645
+ this.logAddonLifecycle('restarted', addonId, 'in-process')
1646
+ this.emitAddonLifecycleEvent('addon.restarted', addonId)
1647
+ return { success: true }
1736
1648
  } catch (err) {
1737
- const msg = errMsg(err);
1738
- this.logger.error('Failed to restart addon', { tags: { addonId }, meta: { error: msg } });
1739
- this.emitAddonLifecycleEvent("addon.error", addonId, {
1649
+ const msg = errMsg(err)
1650
+ this.logger.error('Failed to restart addon', { tags: { addonId }, meta: { error: msg } })
1651
+ this.emitAddonLifecycleEvent('addon.error', addonId, {
1740
1652
  error: msg,
1741
- action: "restart",
1742
- });
1653
+ action: 'restart',
1654
+ })
1743
1655
  // Record the failure on the health monitor so the addon shows up
1744
1656
  // `failed` with its `lastError` instead of a stale `healthy`. The
1745
1657
  // in-process branch already unregistered every capability before
@@ -1749,26 +1661,26 @@ export class AddonRegistryService {
1749
1661
  // only holds for a UI-clicked restart; a `camstack deploy` or a
1750
1662
  // `requiresRestart` auto-restart is fire-and-forget — the failure
1751
1663
  // would otherwise vanish. recordFailure also arms the retry loop.
1752
- this.healthMonitor.recordFailure(entry.packageName, err, addonId);
1753
- return { success: false, error: msg };
1664
+ this.healthMonitor.recordFailure(entry.packageName, err, addonId)
1665
+ return { success: false, error: msg }
1754
1666
  } finally {
1755
1667
  // Clear the suppression flag regardless of success/failure — if the
1756
1668
  // restart failed, the operator will see the real error via the
1757
1669
  // mutation result rather than a misleading transient health blip.
1758
- const timer = this.restartingAddons.get(addonId);
1759
- if (timer) clearTimeout(timer);
1760
- this.restartingAddons.delete(addonId);
1670
+ const timer = this.restartingAddons.get(addonId)
1671
+ if (timer) clearTimeout(timer)
1672
+ this.restartingAddons.delete(addonId)
1761
1673
  }
1762
1674
  }
1763
1675
 
1764
1676
  getAddon(id: string): ICamstackAddon | null {
1765
- const entry = this.addonEntries.get(id);
1766
- return entry?.addon ?? null;
1677
+ const entry = this.addonEntries.get(id)
1678
+ return entry?.addon ?? null
1767
1679
  }
1768
1680
 
1769
1681
  getAddonEntry(id: string): { addon: ICamstackAddon } | null {
1770
- const entry = this.addonEntries.get(id);
1771
- return entry ? { addon: entry.addon } : null;
1682
+ const entry = this.addonEntries.get(id)
1683
+ return entry ? { addon: entry.addon } : null
1772
1684
  }
1773
1685
 
1774
1686
  /**
@@ -1782,48 +1694,50 @@ export class AddonRegistryService {
1782
1694
  * sees a broken-image placeholder for any failed addon.
1783
1695
  */
1784
1696
  getAddonPackageDir(id: string): string | null {
1785
- const entry = this.addonEntries.get(id);
1786
- if (entry?.addonDir) return entry.addonDir;
1787
- return this.findAddonDirOnDisk(id);
1697
+ const entry = this.addonEntries.get(id)
1698
+ if (entry?.addonDir) return entry.addonDir
1699
+ return this.findAddonDirOnDisk(id)
1788
1700
  }
1789
1701
 
1790
1702
  private findAddonDirOnDisk(addonId: string): string | null {
1791
- const dataDir = path.resolve(process.env.CAMSTACK_DATA ?? "camstack-data");
1792
- const addonsDir = path.resolve(dataDir, "addons");
1793
- if (!fs.existsSync(addonsDir)) return null;
1703
+ const dataDir = path.resolve(process.env.CAMSTACK_DATA ?? 'camstack-data')
1704
+ const addonsDir = path.resolve(dataDir, 'addons')
1705
+ if (!fs.existsSync(addonsDir)) return null
1794
1706
 
1795
1707
  const visit = (pkgDir: string): string | null => {
1796
- const pkgJsonPath = path.join(pkgDir, "package.json");
1797
- if (!fs.existsSync(pkgJsonPath)) return null;
1708
+ const pkgJsonPath = path.join(pkgDir, 'package.json')
1709
+ if (!fs.existsSync(pkgJsonPath)) return null
1798
1710
  try {
1799
- const pkg = JSON.parse(fs.readFileSync(pkgJsonPath, "utf-8")) as Record<string, unknown>;
1800
- const camstack = (pkg["camstack"] as { addons?: unknown[] } | undefined) ?? {};
1801
- const addons = Array.isArray(camstack.addons) ? camstack.addons as Record<string, unknown>[] : [];
1802
- if (addons.some((a) => a["id"] === addonId)) return pkgDir;
1711
+ const pkg = JSON.parse(fs.readFileSync(pkgJsonPath, 'utf-8')) as Record<string, unknown>
1712
+ const camstack = (pkg['camstack'] as { addons?: unknown[] } | undefined) ?? {}
1713
+ const addons = Array.isArray(camstack.addons)
1714
+ ? (camstack.addons as Record<string, unknown>[])
1715
+ : []
1716
+ if (addons.some((a) => a['id'] === addonId)) return pkgDir
1803
1717
  } catch {
1804
- return null;
1718
+ return null
1805
1719
  }
1806
- return null;
1807
- };
1720
+ return null
1721
+ }
1808
1722
 
1809
- const entries = fs.readdirSync(addonsDir, { withFileTypes: true });
1723
+ const entries = fs.readdirSync(addonsDir, { withFileTypes: true })
1810
1724
  for (const entry of entries) {
1811
- if (!entry.isDirectory()) continue;
1812
- const dirName = entry.name;
1813
- if (dirName === "node_modules" || dirName.startsWith(".")) continue;
1814
- const fullPath = path.join(addonsDir, dirName);
1815
- if (dirName.startsWith("@")) {
1725
+ if (!entry.isDirectory()) continue
1726
+ const dirName = entry.name
1727
+ if (dirName === 'node_modules' || dirName.startsWith('.')) continue
1728
+ const fullPath = path.join(addonsDir, dirName)
1729
+ if (dirName.startsWith('@')) {
1816
1730
  for (const sub of fs.readdirSync(fullPath, { withFileTypes: true })) {
1817
- if (!sub.isDirectory()) continue;
1818
- const found = visit(path.join(fullPath, sub.name));
1819
- if (found) return found;
1731
+ if (!sub.isDirectory()) continue
1732
+ const found = visit(path.join(fullPath, sub.name))
1733
+ if (found) return found
1820
1734
  }
1821
1735
  } else {
1822
- const found = visit(fullPath);
1823
- if (found) return found;
1736
+ const found = visit(fullPath)
1737
+ if (found) return found
1824
1738
  }
1825
1739
  }
1826
- return null;
1740
+ return null
1827
1741
  }
1828
1742
 
1829
1743
  /**
@@ -1841,13 +1755,13 @@ export class AddonRegistryService {
1841
1755
  * has no `entry` field. The returned `distDir` is an absolute path.
1842
1756
  */
1843
1757
  getAddonInstallPath(addonId: string): { addonDir: string; distDir: string } | null {
1844
- const entry = this.addonEntries.get(addonId);
1845
- if (!entry?.addonDir || !entry.declaration?.entry) return null;
1846
- const distSubdir = path.dirname(entry.declaration.entry);
1758
+ const entry = this.addonEntries.get(addonId)
1759
+ if (!entry?.addonDir || !entry.declaration?.entry) return null
1760
+ const distSubdir = path.dirname(entry.declaration.entry)
1847
1761
  return {
1848
1762
  addonDir: entry.addonDir,
1849
1763
  distDir: path.resolve(entry.addonDir, distSubdir),
1850
- };
1764
+ }
1851
1765
  }
1852
1766
 
1853
1767
  /**
@@ -1855,9 +1769,9 @@ export class AddonRegistryService {
1855
1769
  * Used by the benchmark system to create fresh addon instances with custom config.
1856
1770
  */
1857
1771
  async buildAddonContext(addonId: string): Promise<AddonContext | null> {
1858
- const entry = this.addonEntries.get(addonId);
1859
- if (!entry) return null;
1860
- return this.createAddonContext(entry.addon);
1772
+ const entry = this.addonEntries.get(addonId)
1773
+ if (!entry) return null
1774
+ return this.createAddonContext(entry.addon)
1861
1775
  }
1862
1776
 
1863
1777
  /**
@@ -1870,10 +1784,10 @@ export class AddonRegistryService {
1870
1784
  */
1871
1785
  isPackageProtected(packageName: string): boolean {
1872
1786
  for (const entry of this.addonEntries.values()) {
1873
- if (entry.packageName !== packageName) continue;
1874
- if (entry.declaration?.protected === true) return true;
1787
+ if (entry.packageName !== packageName) continue
1788
+ if (entry.declaration?.protected === true) return true
1875
1789
  }
1876
- return false;
1790
+ return false
1877
1791
  }
1878
1792
 
1879
1793
  /**
@@ -1898,76 +1812,68 @@ export class AddonRegistryService {
1898
1812
  return !!(
1899
1813
  entry.declaration &&
1900
1814
  entry.addonDir &&
1901
- entry.packageName !== "@camstack/core" &&
1815
+ entry.packageName !== '@camstack/core' &&
1902
1816
  resolveAddonPlacement(entry.declaration) !== 'agent-only'
1903
- );
1817
+ )
1904
1818
  }
1905
1819
 
1906
1820
  /** Per-entry helper used by `listAddons()` to mark a row removable / not. */
1907
1821
  private isRequiredEntry(entry: AddonEntry): boolean {
1908
- if (entry.declaration?.protected === true) return true;
1822
+ if (entry.declaration?.protected === true) return true
1909
1823
  // Fall back to a sibling addon in the same package — the
1910
1824
  // protected-by-association rule above. Catches the case where the
1911
1825
  // SAME package ships an aggregator addon (protected: true) plus
1912
1826
  // a source addon (protected: false): both rows should render
1913
1827
  // as non-removable because uninstalling the package would tear
1914
1828
  // out both.
1915
- return this.isPackageProtected(entry.packageName);
1829
+ return this.isPackageProtected(entry.packageName)
1916
1830
  }
1917
1831
 
1918
1832
  listAddons(): Array<{
1919
1833
  manifest: AddonDeclaration & {
1920
- packageName: string;
1921
- packageVersion: string;
1922
- packageDisplayName?: string;
1923
- bundle?: { displayName: string; description?: string; icon?: string };
1924
- protected?: boolean;
1925
- removable?: boolean;
1926
- };
1927
- declaration?: AddonDeclaration;
1928
- source: AddonSource;
1929
- installSource?: "npm" | "local" | "upload";
1930
- process?: { pid?: number; mode: "in-process"; state: string };
1834
+ packageName: string
1835
+ packageVersion: string
1836
+ packageDisplayName?: string
1837
+ bundle?: { displayName: string; description?: string; icon?: string }
1838
+ protected?: boolean
1839
+ removable?: boolean
1840
+ }
1841
+ declaration?: AddonDeclaration
1842
+ source: AddonSource
1843
+ installSource?: 'npm' | 'local' | 'upload'
1844
+ process?: { pid?: number; mode: 'in-process'; state: string }
1931
1845
  }> {
1932
1846
  // Build process info map from in-memory data (sync, no stats)
1933
- const processMap = new Map<
1934
- string,
1935
- { pid?: number; mode: "in-process"; state: string }
1936
- >();
1847
+ const processMap = new Map<string, { pid?: number; mode: 'in-process'; state: string }>()
1937
1848
  for (const [id, entry] of this.addonEntries) {
1938
1849
  if (entry.initialized) {
1939
1850
  processMap.set(`addon:${id}`, {
1940
1851
  pid: process.pid,
1941
- mode: "in-process",
1942
- state: "running",
1943
- });
1852
+ mode: 'in-process',
1853
+ state: 'running',
1854
+ })
1944
1855
  }
1945
1856
  }
1946
1857
 
1947
1858
  const live = Array.from(this.addonEntries.values())
1948
1859
  .filter((entry) => entry.addon?.manifest?.id)
1949
1860
  .map((entry) => {
1950
- let installSource: "npm" | "local" | "upload" | undefined;
1861
+ let installSource: 'npm' | 'local' | 'upload' | undefined
1951
1862
  if (entry.addonDir) {
1952
1863
  try {
1953
- const markerPath = path.join(entry.addonDir, ".install-source");
1864
+ const markerPath = path.join(entry.addonDir, '.install-source')
1954
1865
  if (fs.existsSync(markerPath)) {
1955
- const raw = fs.readFileSync(markerPath, "utf-8").trim();
1866
+ const raw = fs.readFileSync(markerPath, 'utf-8').trim()
1956
1867
  // Normalize legacy 'workspace' → 'local'
1957
- const normalized = raw === "workspace" ? "local" : raw;
1958
- if (
1959
- normalized === "npm" ||
1960
- normalized === "local" ||
1961
- normalized === "upload"
1962
- ) {
1963
- installSource = normalized;
1868
+ const normalized = raw === 'workspace' ? 'local' : raw
1869
+ if (normalized === 'npm' || normalized === 'local' || normalized === 'upload') {
1870
+ installSource = normalized
1964
1871
  }
1965
1872
  }
1966
1873
  } catch (err) {
1967
- this.logger.debug(
1968
- 'Failed to read install-source marker for addon',
1969
- { meta: { error: errMsg(err) } },
1970
- );
1874
+ this.logger.debug('Failed to read install-source marker for addon', {
1875
+ meta: { error: errMsg(err) },
1876
+ })
1971
1877
  }
1972
1878
  }
1973
1879
  return {
@@ -1991,17 +1897,17 @@ export class AddonRegistryService {
1991
1897
  source: entry.source,
1992
1898
  installSource,
1993
1899
  process: processMap.get(`addon:${entry.addon.manifest!.id}`),
1994
- };
1995
- });
1900
+ }
1901
+ })
1996
1902
 
1997
1903
  // Surface packages that exist on disk but failed to load — typical
1998
1904
  // causes are dep-version mismatch, missing native binary, or a
1999
1905
  // corrupted dist. Without this, an operator sees "installed" via
2000
1906
  // the package manifest but can't find the row in the addons UI to
2001
1907
  // diagnose or uninstall, leaving the package stranded.
2002
- const seenPackages = new Set(live.map((row) => row.manifest.packageName));
2003
- const failed = this.scanFailedToLoadPackages(seenPackages);
2004
- return [...live, ...failed];
1908
+ const seenPackages = new Set(live.map((row) => row.manifest.packageName))
1909
+ const failed = this.scanFailedToLoadPackages(seenPackages)
1910
+ return [...live, ...failed]
2005
1911
  }
2006
1912
 
2007
1913
  /**
@@ -2011,74 +1917,84 @@ export class AddonRegistryService {
2011
1917
  * carry a `process.state: 'failed'` flag so the AddonCard can render
2012
1918
  * the diagnostic state distinctly from a normal addon.
2013
1919
  */
2014
- private scanFailedToLoadPackages(
2015
- seenPackages: ReadonlySet<string>,
2016
- ): Array<{
1920
+ private scanFailedToLoadPackages(seenPackages: ReadonlySet<string>): Array<{
2017
1921
  manifest: AddonDeclaration & {
2018
- packageName: string;
2019
- packageVersion: string;
2020
- packageDisplayName?: string;
2021
- protected?: boolean;
2022
- removable?: boolean;
2023
- };
2024
- declaration?: AddonDeclaration;
2025
- source: AddonSource;
2026
- installSource?: "npm" | "local" | "upload";
2027
- process?: { pid?: number; mode: "in-process"; state: string };
1922
+ packageName: string
1923
+ packageVersion: string
1924
+ packageDisplayName?: string
1925
+ protected?: boolean
1926
+ removable?: boolean
1927
+ }
1928
+ declaration?: AddonDeclaration
1929
+ source: AddonSource
1930
+ installSource?: 'npm' | 'local' | 'upload'
1931
+ process?: { pid?: number; mode: 'in-process'; state: string }
2028
1932
  }> {
2029
- const dataDir = path.resolve(process.env.CAMSTACK_DATA ?? "camstack-data");
2030
- const addonsDir = path.resolve(dataDir, "addons");
2031
- if (!fs.existsSync(addonsDir)) return [];
2032
-
2033
- type Row = ReturnType<AddonRegistryService["scanFailedToLoadPackages"]> extends Array<infer R> ? R : never;
2034
- const out: Row[] = [];
1933
+ const dataDir = path.resolve(process.env.CAMSTACK_DATA ?? 'camstack-data')
1934
+ const addonsDir = path.resolve(dataDir, 'addons')
1935
+ if (!fs.existsSync(addonsDir)) return []
1936
+
1937
+ type Row =
1938
+ ReturnType<AddonRegistryService['scanFailedToLoadPackages']> extends Array<infer R>
1939
+ ? R
1940
+ : never
1941
+ const out: Row[] = []
2035
1942
  const visit = (pkgDir: string): void => {
2036
- const pkgJsonPath = path.join(pkgDir, "package.json");
2037
- if (!fs.existsSync(pkgJsonPath)) return;
2038
- let pkg: Record<string, unknown>;
1943
+ const pkgJsonPath = path.join(pkgDir, 'package.json')
1944
+ if (!fs.existsSync(pkgJsonPath)) return
1945
+ let pkg: Record<string, unknown>
2039
1946
  try {
2040
- pkg = JSON.parse(fs.readFileSync(pkgJsonPath, "utf-8")) as Record<string, unknown>;
1947
+ pkg = JSON.parse(fs.readFileSync(pkgJsonPath, 'utf-8')) as Record<string, unknown>
2041
1948
  } catch {
2042
- return;
1949
+ return
2043
1950
  }
2044
- const packageName = typeof pkg["name"] === "string" ? pkg["name"] : "";
2045
- const packageVersion = typeof pkg["version"] === "string" ? pkg["version"] : "0.0.0";
2046
- if (!packageName || seenPackages.has(packageName)) return;
2047
- const camstack = (pkg["camstack"] as { addons?: unknown[] } | undefined) ?? {};
2048
- const addons = Array.isArray(camstack.addons) ? camstack.addons as Record<string, unknown>[] : [];
2049
- if (addons.length === 0) return;
2050
-
2051
- let installSource: "npm" | "local" | "upload" | undefined;
1951
+ const packageName = typeof pkg['name'] === 'string' ? pkg['name'] : ''
1952
+ const packageVersion = typeof pkg['version'] === 'string' ? pkg['version'] : '0.0.0'
1953
+ if (!packageName || seenPackages.has(packageName)) return
1954
+ const camstack = (pkg['camstack'] as { addons?: unknown[] } | undefined) ?? {}
1955
+ const addons = Array.isArray(camstack.addons)
1956
+ ? (camstack.addons as Record<string, unknown>[])
1957
+ : []
1958
+ if (addons.length === 0) return
1959
+
1960
+ let installSource: 'npm' | 'local' | 'upload' | undefined
2052
1961
  try {
2053
- const markerPath = path.join(pkgDir, ".install-source");
1962
+ const markerPath = path.join(pkgDir, '.install-source')
2054
1963
  if (fs.existsSync(markerPath)) {
2055
- const raw = fs.readFileSync(markerPath, "utf-8").trim();
2056
- const normalized = raw === "workspace" ? "local" : raw;
2057
- if (normalized === "npm" || normalized === "local" || normalized === "upload") {
2058
- installSource = normalized;
1964
+ const raw = fs.readFileSync(markerPath, 'utf-8').trim()
1965
+ const normalized = raw === 'workspace' ? 'local' : raw
1966
+ if (normalized === 'npm' || normalized === 'local' || normalized === 'upload') {
1967
+ installSource = normalized
2059
1968
  }
2060
1969
  }
2061
- } catch { /* non-critical */ }
1970
+ } catch {
1971
+ /* non-critical */
1972
+ }
2062
1973
 
2063
1974
  // Manifest-driven protection — agree with the live-rows path.
2064
- const isProtected = addons.some((a) => a["protected"] === true);
2065
- const packageDisplayName = (camstack as { displayName?: string }).displayName;
1975
+ const isProtected = addons.some((a) => a['protected'] === true)
1976
+ const packageDisplayName = (camstack as { displayName?: string }).displayName
2066
1977
  // Bundle metadata — present when the package ships multiple addon
2067
1978
  // entries that should render as collapsible children in the UI.
2068
- const bundleRaw = (camstack as { bundle?: { displayName?: string; description?: string; icon?: string } }).bundle;
2069
- const bundle = bundleRaw && typeof bundleRaw.displayName === 'string'
2070
- ? {
2071
- displayName: bundleRaw.displayName,
2072
- ...(bundleRaw.description !== undefined ? { description: bundleRaw.description } : {}),
2073
- ...(bundleRaw.icon !== undefined ? { icon: bundleRaw.icon } : {}),
2074
- }
2075
- : undefined;
1979
+ const bundleRaw = (
1980
+ camstack as { bundle?: { displayName?: string; description?: string; icon?: string } }
1981
+ ).bundle
1982
+ const bundle =
1983
+ bundleRaw && typeof bundleRaw.displayName === 'string'
1984
+ ? {
1985
+ displayName: bundleRaw.displayName,
1986
+ ...(bundleRaw.description !== undefined
1987
+ ? { description: bundleRaw.description }
1988
+ : {}),
1989
+ ...(bundleRaw.icon !== undefined ? { icon: bundleRaw.icon } : {}),
1990
+ }
1991
+ : undefined
2076
1992
 
2077
1993
  for (const decl of addons) {
2078
- const id = typeof decl["id"] === "string" ? decl["id"] : "";
2079
- const name = typeof decl["name"] === "string" ? decl["name"] : id;
2080
- if (!id) continue;
2081
- const manifest: Row["manifest"] = {
1994
+ const id = typeof decl['id'] === 'string' ? decl['id'] : ''
1995
+ const name = typeof decl['name'] === 'string' ? decl['name'] : id
1996
+ if (!id) continue
1997
+ const manifest: Row['manifest'] = {
2082
1998
  ...(decl as unknown as AddonDeclaration),
2083
1999
  id,
2084
2000
  name,
@@ -2088,34 +2004,34 @@ export class AddonRegistryService {
2088
2004
  ...(bundle !== undefined ? { bundle } : {}),
2089
2005
  protected: isProtected || undefined,
2090
2006
  removable: !isProtected,
2091
- };
2007
+ }
2092
2008
  out.push({
2093
2009
  manifest,
2094
2010
  declaration: decl as unknown as AddonDeclaration,
2095
- source: "installed" as AddonSource,
2011
+ source: 'installed' as AddonSource,
2096
2012
  ...(installSource ? { installSource } : {}),
2097
- process: { mode: "in-process" as const, state: "failed" },
2098
- });
2013
+ process: { mode: 'in-process' as const, state: 'failed' },
2014
+ })
2099
2015
  }
2100
- };
2016
+ }
2101
2017
 
2102
- const entries = fs.readdirSync(addonsDir, { withFileTypes: true });
2018
+ const entries = fs.readdirSync(addonsDir, { withFileTypes: true })
2103
2019
  for (const entry of entries) {
2104
- if (!entry.isDirectory()) continue;
2105
- const dirName = entry.name;
2106
- if (dirName === "node_modules" || dirName.startsWith(".")) continue;
2107
- const fullPath = path.join(addonsDir, dirName);
2020
+ if (!entry.isDirectory()) continue
2021
+ const dirName = entry.name
2022
+ if (dirName === 'node_modules' || dirName.startsWith('.')) continue
2023
+ const fullPath = path.join(addonsDir, dirName)
2108
2024
  // Scoped: walk one level deeper.
2109
- if (dirName.startsWith("@")) {
2110
- const scopedEntries = fs.readdirSync(fullPath, { withFileTypes: true });
2025
+ if (dirName.startsWith('@')) {
2026
+ const scopedEntries = fs.readdirSync(fullPath, { withFileTypes: true })
2111
2027
  for (const sub of scopedEntries) {
2112
- if (sub.isDirectory()) visit(path.join(fullPath, sub.name));
2028
+ if (sub.isDirectory()) visit(path.join(fullPath, sub.name))
2113
2029
  }
2114
2030
  } else {
2115
- visit(fullPath);
2031
+ visit(fullPath)
2116
2032
  }
2117
2033
  }
2118
- return out;
2034
+ return out
2119
2035
  }
2120
2036
 
2121
2037
  /**
@@ -2124,7 +2040,7 @@ export class AddonRegistryService {
2124
2040
  * so the frontend can group by package automatically.
2125
2041
  */
2126
2042
  listAllAddons() {
2127
- return this.listAddons();
2043
+ return this.listAddons()
2128
2044
  }
2129
2045
 
2130
2046
  /**
@@ -2133,49 +2049,49 @@ export class AddonRegistryService {
2133
2049
  */
2134
2050
  async listAddonProcesses(): Promise<
2135
2051
  ReadonlyArray<{
2136
- id: string;
2137
- label: string;
2138
- state: "running" | "stopped" | "crashed" | "starting";
2139
- pid?: number;
2052
+ id: string
2053
+ label: string
2054
+ state: 'running' | 'stopped' | 'crashed' | 'starting'
2055
+ pid?: number
2140
2056
  stats?: {
2141
- pid: number;
2142
- cpu: number;
2143
- memory: number;
2144
- uptime: number;
2145
- restartCount: number;
2146
- };
2147
- restartCount: number;
2148
- mode: "in-process";
2057
+ pid: number
2058
+ cpu: number
2059
+ memory: number
2060
+ uptime: number
2061
+ restartCount: number
2062
+ }
2063
+ restartCount: number
2064
+ mode: 'in-process'
2149
2065
  }>
2150
2066
  > {
2151
2067
  const result: Array<{
2152
- id: string;
2153
- label: string;
2154
- state: "running" | "stopped" | "crashed" | "starting";
2155
- pid?: number;
2068
+ id: string
2069
+ label: string
2070
+ state: 'running' | 'stopped' | 'crashed' | 'starting'
2071
+ pid?: number
2156
2072
  stats?: {
2157
- pid: number;
2158
- cpu: number;
2159
- memory: number;
2160
- uptime: number;
2161
- restartCount: number;
2162
- };
2163
- restartCount: number;
2164
- mode: "in-process";
2165
- }> = [];
2073
+ pid: number
2074
+ cpu: number
2075
+ memory: number
2076
+ uptime: number
2077
+ restartCount: number
2078
+ }
2079
+ restartCount: number
2080
+ mode: 'in-process'
2081
+ }> = []
2166
2082
 
2167
2083
  // Collect hub PID and fetch stats
2168
- const { getPidStats } = await import("@camstack/core");
2169
- const pidStats = await getPidStats([process.pid]);
2084
+ const { getPidStats } = await import('@camstack/core')
2085
+ const pidStats = await getPidStats([process.pid])
2170
2086
 
2171
2087
  // In-process addons
2172
- const hubStats = pidStats.get(process.pid);
2088
+ const hubStats = pidStats.get(process.pid)
2173
2089
  for (const [id, entry] of this.addonEntries) {
2174
2090
  if (entry.initialized) {
2175
2091
  result.push({
2176
2092
  id: `addon:${id}`,
2177
2093
  label: `Addon: ${id} (in-process)`,
2178
- state: "running",
2094
+ state: 'running',
2179
2095
  pid: process.pid,
2180
2096
  stats: {
2181
2097
  pid: process.pid,
@@ -2185,12 +2101,12 @@ export class AddonRegistryService {
2185
2101
  restartCount: 0,
2186
2102
  },
2187
2103
  restartCount: 0,
2188
- mode: "in-process",
2189
- });
2104
+ mode: 'in-process',
2105
+ })
2190
2106
  }
2191
2107
  }
2192
2108
 
2193
- return result;
2109
+ return result
2194
2110
  }
2195
2111
 
2196
2112
  /**
@@ -2209,17 +2125,15 @@ export class AddonRegistryService {
2209
2125
  * Called after install/uninstall to hot-load new addons without server restart.
2210
2126
  */
2211
2127
  async loadNewAddons(): Promise<{ loaded: string[]; failed: string[] }> {
2212
- const dataDir = path.resolve(process.env.CAMSTACK_DATA ?? "camstack-data");
2213
- const addonsDir = path.resolve(dataDir, "addons");
2128
+ const dataDir = path.resolve(process.env.CAMSTACK_DATA ?? 'camstack-data')
2129
+ const addonsDir = path.resolve(dataDir, 'addons')
2214
2130
 
2215
2131
  // Create a fresh loader to discover what's on disk (for diffing against existing)
2216
- const freshLoader = new AddonLoader(
2217
- this.loggingService.createLogger("AddonLoader"),
2218
- );
2219
- await freshLoader.loadFromDirectory(addonsDir);
2132
+ const freshLoader = new AddonLoader(this.loggingService.createLogger('AddonLoader'))
2133
+ await freshLoader.loadFromDirectory(addonsDir)
2220
2134
 
2221
- const loaded: string[] = [];
2222
- const failed: string[] = [];
2135
+ const loaded: string[] = []
2136
+ const failed: string[] = []
2223
2137
 
2224
2138
  for (const registered of freshLoader.listAddons()) {
2225
2139
  // Already-registered addon: refresh the in-memory metadata (the
@@ -2231,20 +2145,20 @@ export class AddonRegistryService {
2231
2145
  // showing the OLD version. We deliberately keep `entry.addon`
2232
2146
  // (the running instance) and `entry.initialized` untouched so
2233
2147
  // `restartAddon` still shuts the live instance down cleanly.
2234
- const existing = this.addonEntries.get(registered.declaration.id);
2148
+ const existing = this.addonEntries.get(registered.declaration.id)
2235
2149
  if (existing) {
2236
- existing.packageVersion = registered.packageVersion;
2237
- existing.packageName = registered.packageName;
2238
- existing.packageDisplayName = registered.packageDisplayName;
2150
+ existing.packageVersion = registered.packageVersion
2151
+ existing.packageName = registered.packageName
2152
+ existing.packageDisplayName = registered.packageDisplayName
2239
2153
  if (registered.bundle !== undefined) {
2240
- existing.bundle = registered.bundle;
2154
+ existing.bundle = registered.bundle
2241
2155
  }
2242
- existing.declaration = registered.declaration;
2156
+ existing.declaration = registered.declaration
2243
2157
  existing.declaredCapabilities = (registered.declaration.capabilities ?? []).map(
2244
2158
  (c: string | CapabilityDeclaration) =>
2245
- typeof c === "string" ? { name: c, mode: "singleton" as const } : c,
2246
- );
2247
- continue;
2159
+ typeof c === 'string' ? { name: c, mode: 'singleton' as const } : c,
2160
+ )
2161
+ continue
2248
2162
  }
2249
2163
 
2250
2164
  // Skip agent-only addons on the hub. Same rule as the boot loader
@@ -2253,33 +2167,29 @@ export class AddonRegistryService {
2253
2167
  // and a hub-resident `hub-forwarder` creates a write→ingest→
2254
2168
  // write feedback loop because the hub IS the log-receiver.
2255
2169
  if (isAgentOnlyPlacement(registered.declaration)) {
2256
- this.logger.debug(
2257
- 'loadNewAddons: skipping agent-only addon on hub',
2258
- { tags: { addonId: registered.declaration.id } },
2259
- );
2260
- continue;
2170
+ this.logger.debug('loadNewAddons: skipping agent-only addon on hub', {
2171
+ tags: { addonId: registered.declaration.id },
2172
+ })
2173
+ continue
2261
2174
  }
2262
2175
 
2263
2176
  if (registered.declaration.capabilities) {
2264
2177
  for (const cap of registered.declaration.capabilities) {
2265
- this.capabilityRegistry.declareFromManifest(
2266
- cap,
2267
- registered.declaration.id,
2268
- );
2178
+ this.capabilityRegistry.declareFromManifest(cap, registered.declaration.id)
2269
2179
  }
2270
2180
  }
2271
2181
 
2272
2182
  try {
2273
- const addon = freshLoader.createInstance(registered.declaration.id);
2183
+ const addon = freshLoader.createInstance(registered.declaration.id)
2274
2184
  const declCaps = (registered.declaration.capabilities ?? []).map(
2275
2185
  (c: string | CapabilityDeclaration) =>
2276
- typeof c === "string" ? { name: c, mode: "singleton" as const } : c,
2277
- );
2186
+ typeof c === 'string' ? { name: c, mode: 'singleton' as const } : c,
2187
+ )
2278
2188
 
2279
2189
  this.addonEntries.set(registered.declaration.id, {
2280
2190
  addon,
2281
2191
  initialized: false,
2282
- source: "installed",
2192
+ source: 'installed',
2283
2193
  packageName: registered.packageName,
2284
2194
  packageVersion: registered.packageVersion,
2285
2195
  packageDisplayName: registered.packageDisplayName,
@@ -2287,36 +2197,26 @@ export class AddonRegistryService {
2287
2197
  declaredCapabilities: declCaps,
2288
2198
  addonDir: path.join(addonsDir, registered.packageName),
2289
2199
  declaration: registered.declaration,
2290
- });
2200
+ })
2291
2201
 
2292
2202
  // Initialize the new addon
2293
- await this.initializeAddon(registered.declaration.id);
2294
- this.wireCapabilities(registered.declaration.id);
2295
- loaded.push(registered.declaration.id);
2296
- this.logger.info(
2297
- 'Hot-loaded addon',
2298
- { tags: { addonId: registered.declaration.id }, meta: { packageName: registered.packageName } },
2299
- );
2300
- this.emitAddonLifecycleEvent(
2301
- "addon.installed",
2302
- registered.declaration.id,
2303
- );
2203
+ await this.initializeAddon(registered.declaration.id)
2204
+ this.wireCapabilities(registered.declaration.id)
2205
+ loaded.push(registered.declaration.id)
2206
+ this.logger.info('Hot-loaded addon', {
2207
+ tags: { addonId: registered.declaration.id },
2208
+ meta: { packageName: registered.packageName },
2209
+ })
2210
+ this.emitAddonLifecycleEvent('addon.installed', registered.declaration.id)
2304
2211
  } catch (err) {
2305
- const msg = errMsg(err);
2306
- const stack = err instanceof Error ? err.stack : undefined;
2307
- this.logger.error(
2308
- 'Failed to hot-load addon',
2309
- {
2310
- tags: { addonId: registered.declaration.id },
2311
- meta: stack ? { error: msg, stack } : { error: msg },
2312
- },
2313
- );
2314
- this.emitAddonLifecycleEvent(
2315
- "addon.crashed",
2316
- registered.declaration.id,
2317
- { error: msg },
2318
- );
2319
- failed.push(registered.declaration.id);
2212
+ const msg = errMsg(err)
2213
+ const stack = err instanceof Error ? err.stack : undefined
2214
+ this.logger.error('Failed to hot-load addon', {
2215
+ tags: { addonId: registered.declaration.id },
2216
+ meta: stack ? { error: msg, stack } : { error: msg },
2217
+ })
2218
+ this.emitAddonLifecycleEvent('addon.crashed', registered.declaration.id, { error: msg })
2219
+ failed.push(registered.declaration.id)
2320
2220
  }
2321
2221
  }
2322
2222
 
@@ -2327,23 +2227,21 @@ export class AddonRegistryService {
2327
2227
  // Only `@camstack/core` builtins — which have no runner — take the
2328
2228
  // in-process `shutdown()` + disposer-drain path.
2329
2229
  const onDiskIds = new Set(
2330
- freshLoader
2331
- .listAddons()
2332
- .map((a: { declaration: { id: string } }) => a.declaration.id),
2333
- );
2230
+ freshLoader.listAddons().map((a: { declaration: { id: string } }) => a.declaration.id),
2231
+ )
2334
2232
  for (const [id, entry] of this.addonEntries) {
2335
- if (entry.source === "installed" && !onDiskIds.has(id)) {
2233
+ if (entry.source === 'installed' && !onDiskIds.has(id)) {
2336
2234
  // Unregister capabilities before stopping the addon.
2337
2235
  for (const cap of entry.declaredCapabilities) {
2338
- this.capabilityRegistry.unregisterProvider(cap.name, id);
2236
+ this.capabilityRegistry.unregisterProvider(cap.name, id)
2339
2237
  }
2340
- const manifestCaps = this.getAddonCapabilities(entry.addon);
2238
+ const manifestCaps = this.getAddonCapabilities(entry.addon)
2341
2239
  for (const cap of manifestCaps) {
2342
- this.capabilityRegistry.unregisterProvider(cap.name, id);
2240
+ this.capabilityRegistry.unregisterProvider(cap.name, id)
2343
2241
  }
2344
- this.capabilityRegistry.unregisterAllWrappersForAddon(id);
2242
+ this.capabilityRegistry.unregisterAllWrappersForAddon(id)
2345
2243
  // Task 7.1: drop custom actions for removed addons.
2346
- this.customActionRegistry.unregisterAddon(id);
2244
+ this.customActionRegistry.unregisterAddon(id)
2347
2245
 
2348
2246
  if (this.isForkedAddonEntry(entry) && entry.initialized) {
2349
2247
  // Forked addon — stop the runner subprocess. `$process.stop`
@@ -2351,46 +2249,46 @@ export class AddonRegistryService {
2351
2249
  // hub's cap registry drops the gone provider with no per-
2352
2250
  // operation hub bookkeeping.
2353
2251
  try {
2354
- await this.broker.call("$process.stop", { name: id });
2252
+ await this.broker.call('$process.stop', { name: id })
2355
2253
  } catch (err) {
2356
- this.logger.warn(
2357
- 'Non-fatal: failed to stop runner for removed addon',
2358
- { tags: { addonId: id }, meta: { error: errMsg(err) } },
2359
- );
2254
+ this.logger.warn('Non-fatal: failed to stop runner for removed addon', {
2255
+ tags: { addonId: id },
2256
+ meta: { error: errMsg(err) },
2257
+ })
2360
2258
  }
2361
2259
  } else if (entry.initialized) {
2362
2260
  // `@camstack/core` builtin — reload model is in-process.
2363
2261
  try {
2364
- await entry.addon.shutdown();
2262
+ await entry.addon.shutdown()
2365
2263
  } catch (err) {
2366
- this.logger.debug(
2367
- 'Non-fatal: shutdown error for removed addon',
2368
- { tags: { addonId: id }, meta: { error: errMsg(err) } },
2369
- );
2264
+ this.logger.debug('Non-fatal: shutdown error for removed addon', {
2265
+ tags: { addonId: id },
2266
+ meta: { error: errMsg(err) },
2267
+ })
2370
2268
  }
2371
- await this.drainDisposerChain(id);
2269
+ await this.drainDisposerChain(id)
2372
2270
  }
2373
- this.addonEntries.delete(id);
2374
- this.logger.info('Removed addon (no longer on disk)', { tags: { addonId: id } });
2271
+ this.addonEntries.delete(id)
2272
+ this.logger.info('Removed addon (no longer on disk)', { tags: { addonId: id } })
2375
2273
  }
2376
2274
  }
2377
2275
 
2378
- return { loaded, failed };
2276
+ return { loaded, failed }
2379
2277
  }
2380
2278
 
2381
2279
  async shutdownAll(): Promise<void> {
2382
2280
  for (const [id, entry] of this.addonEntries) {
2383
2281
  if (entry.initialized) {
2384
- const capabilities = this.getAddonCapabilities(entry.addon);
2282
+ const capabilities = this.getAddonCapabilities(entry.addon)
2385
2283
  for (const cap of capabilities) {
2386
- this.capabilityRegistry.unregisterProvider(cap.name, id);
2284
+ this.capabilityRegistry.unregisterProvider(cap.name, id)
2387
2285
  }
2388
- this.capabilityRegistry.unregisterAllWrappersForAddon(id);
2286
+ this.capabilityRegistry.unregisterAllWrappersForAddon(id)
2389
2287
  // Task 7.1: drop custom actions on full shutdown.
2390
- this.customActionRegistry.unregisterAddon(id);
2391
- await entry.addon.shutdown();
2392
- await this.drainDisposerChain(id);
2393
- entry.initialized = false;
2288
+ this.customActionRegistry.unregisterAddon(id)
2289
+ await entry.addon.shutdown()
2290
+ await this.drainDisposerChain(id)
2291
+ entry.initialized = false
2394
2292
  }
2395
2293
  }
2396
2294
  }
@@ -2407,130 +2305,120 @@ export class AddonRegistryService {
2407
2305
  * Replaces the 11 registerConsumer callbacks from v1.
2408
2306
  */
2409
2307
  private wireCapabilityConsumers(): void {
2410
- if (!this.capabilityRegistry) return;
2411
-
2412
- this.eventBusService.subscribe(
2413
- { category: "capability:provider-registered" },
2414
- (event) => {
2415
- const rawCapability = event.data["capability"];
2416
- const rawAddonId = event.data["addonId"];
2417
- if (typeof rawCapability !== "string" || typeof rawAddonId !== "string")
2418
- return;
2419
- const capability = rawCapability;
2420
- const addonId = rawAddonId;
2421
-
2422
- // Broadcast readiness on the hub-node scope so subprocess
2423
- // brokers waiting on this cap (e.g. provider-rtsp's
2424
- // `system.ready-state` listener for stream-broker, or
2425
- // `runWorkerDeviceRestoreWithRetry` waiting on
2426
- // device-manager) wake up. We emit ONLY `{ type: 'node',
2427
- // nodeId: 'hub' }` most consumer filters are scope-agnostic
2428
- // (they match on capName + state), so emitting both `node` and
2429
- // `global` causes duplicate fan-out (e.g. provider-rtsp's
2430
- // `republishAll` running twice repeated `dispatchCamera`
2431
- // loops). The node-scoped emit is sufficient: the hydrate
2432
- // path on subprocess brokers replays whichever records the
2433
- // hub's `$readiness.getSnapshot` returns, scope and all.
2434
- try {
2435
- this.moleculer.readinessRegistry.emitReady(capability, { type: 'node', nodeId: 'hub' });
2436
- } catch (err) {
2437
- this.logger.warn('emitReady failed', {
2438
- meta: { capability, addonId, error: errMsg(err) },
2439
- });
2440
- }
2308
+ if (!this.capabilityRegistry) return
2309
+
2310
+ this.eventBusService.subscribe({ category: 'capability:provider-registered' }, (event) => {
2311
+ const rawCapability = event.data['capability']
2312
+ const rawAddonId = event.data['addonId']
2313
+ if (typeof rawCapability !== 'string' || typeof rawAddonId !== 'string') return
2314
+ const capability = rawCapability
2315
+ const addonId = rawAddonId
2316
+
2317
+ // Broadcast readiness on the hub-node scope so subprocess
2318
+ // brokers waiting on this cap (e.g. provider-rtsp's
2319
+ // `system.ready-state` listener for stream-broker, or
2320
+ // `runWorkerDeviceRestoreWithRetry` waiting on
2321
+ // device-manager) wake up. We emit ONLY `{ type: 'node',
2322
+ // nodeId: 'hub' }` most consumer filters are scope-agnostic
2323
+ // (they match on capName + state), so emitting both `node` and
2324
+ // `global` causes duplicate fan-out (e.g. provider-rtsp's
2325
+ // `republishAll` running twice repeated `dispatchCamera`
2326
+ // loops). The node-scoped emit is sufficient: the hydrate
2327
+ // path on subprocess brokers replays whichever records the
2328
+ // hub's `$readiness.getSnapshot` returns, scope and all.
2329
+ try {
2330
+ this.moleculer.readinessRegistry.emitReady(capability, { type: 'node', nodeId: 'hub' })
2331
+ } catch (err) {
2332
+ this.logger.warn('emitReady failed', {
2333
+ meta: { capability, addonId, error: errMsg(err) },
2334
+ })
2335
+ }
2441
2336
 
2442
- switch (capability) {
2443
- case "storage": {
2444
- // Storage-unification refactor (Task 8) — the consumer-
2445
- // facing `storage` cap is now a singleton owned by the
2446
- // `storage-orchestrator` builtin, exposing the codegen'd
2447
- // async `IStorageCapProvider` surface. The legacy
2448
- // synchronous `INewStorageProvider` (filesystem-only) used
2449
- // by `StorageService.setNewStorageProvider` and the
2450
- // `addons-data` dataDir resolution at boot is no longer
2451
- // wired here — filesystem-storage now registers under
2452
- // `storage-provider` (the upstream collection cap), and
2453
- // legacy callers fall back to the deterministic
2454
- // `camstack-data/addons-data/<addonId>` path. Task 17 will
2455
- // migrate those callers off `INewStorageProvider`
2456
- // entirely.
2457
- break;
2458
- }
2337
+ switch (capability) {
2338
+ case 'storage': {
2339
+ // Storage-unification refactor (Task 8) — the consumer-
2340
+ // facing `storage` cap is now a singleton owned by the
2341
+ // `storage-orchestrator` builtin, exposing the codegen'd
2342
+ // async `IStorageCapProvider` surface. The legacy
2343
+ // synchronous `INewStorageProvider` (filesystem-only) used
2344
+ // by `StorageService.setNewStorageProvider` and the
2345
+ // `addons-data` dataDir resolution at boot is no longer
2346
+ // wired here — filesystem-storage now registers under
2347
+ // `storage-provider` (the upstream collection cap), and
2348
+ // legacy callers fall back to the deterministic
2349
+ // `camstack-data/addons-data/<addonId>` path. Task 17 will
2350
+ // migrate those callers off `INewStorageProvider`
2351
+ // entirely.
2352
+ break
2353
+ }
2459
2354
 
2460
- case "settings-store": {
2461
- const provider = this.capabilityRegistry.getProviderByAddon(
2462
- "settings-store",
2463
- addonId,
2464
- );
2465
- if (!provider) return;
2466
- this.activeSettingsBackend = provider;
2467
- if (isSettingsStore(provider)) {
2468
- this.configService.setSettingsStore(provider);
2469
- }
2470
- this.storageService.setSettingsBackend(provider);
2471
- this.integrationRegistry = new IntegrationRegistry(provider);
2472
- void this.integrationRegistry.initialize().then(() => {
2473
- this.logger.info("IntegrationRegistry initialized", { meta: { phase: 'v2' } });
2474
- });
2475
- this.loadCollectionPreferences();
2476
- // DeviceStore/ConfigStore are owned by the `device-persistence`
2477
- // capability addon — no longer created here. The addon boots
2478
- // after `sqlite-settings` and extracts the DB handle via the
2479
- // capability registry.
2480
- this.logger.info("Settings backend wired", { meta: { phase: 'v2' } });
2481
- break;
2355
+ case 'settings-store': {
2356
+ const provider = this.capabilityRegistry.getProviderByAddon('settings-store', addonId)
2357
+ if (!provider) return
2358
+ this.activeSettingsBackend = provider
2359
+ if (isSettingsStore(provider)) {
2360
+ this.configService.setSettingsStore(provider)
2482
2361
  }
2362
+ this.storageService.setSettingsBackend(provider)
2363
+ this.integrationRegistry = new IntegrationRegistry(provider)
2364
+ void this.integrationRegistry.initialize().then(() => {
2365
+ this.logger.info('IntegrationRegistry initialized', { meta: { phase: 'v2' } })
2366
+ })
2367
+ this.loadCollectionPreferences()
2368
+ // DeviceStore/ConfigStore are owned by the `device-persistence`
2369
+ // capability addon — no longer created here. The addon boots
2370
+ // after `sqlite-settings` and extracts the DB handle via the
2371
+ // capability registry.
2372
+ this.logger.info('Settings backend wired', { meta: { phase: 'v2' } })
2373
+ break
2374
+ }
2483
2375
 
2484
- case "log-destination": {
2485
- const provider = this.capabilityRegistry.getProviderByAddon(
2486
- "log-destination",
2487
- addonId,
2488
- );
2489
- if (!provider) return;
2490
- this.loggingService.addDestination(provider);
2491
- this.logger.info("Log destination added", { meta: { phase: 'v2' } });
2492
- break;
2493
- }
2376
+ case 'log-destination': {
2377
+ const provider = this.capabilityRegistry.getProviderByAddon('log-destination', addonId)
2378
+ if (!provider) return
2379
+ this.loggingService.addDestination(provider)
2380
+ this.logger.info('Log destination added', { meta: { phase: 'v2' } })
2381
+ break
2382
+ }
2494
2383
 
2495
- case "restreamer":
2496
- case "webrtc":
2497
- case "decoder":
2498
- case "stream-broker":
2499
- // No wiring needed — consumers read from capabilityRegistry on demand.
2500
- break;
2501
-
2502
- case "addon-routes": {
2503
- // Route mounting is async for forked/group addons — the
2504
- // provider is a Moleculer proxy whose `getRoutes()` returns
2505
- // a Promise (the wire round-trips through the worker). The
2506
- // EventBus subscriber callback is synchronous, so delegate
2507
- // to an async helper and surface any failure via the
2508
- // logger instead of letting a rejected promise (or a
2509
- // `liveRoutes.some is not a function` TypeError) escape the
2510
- // subscriber unobserved.
2511
- void this.mountAddonRoutes(addonId).catch((err: unknown) => {
2512
- this.logger.error('Failed to mount addon routes', {
2513
- meta: { phase: 'v2', addonId, error: errMsg(err) },
2514
- })
2384
+ case 'restreamer':
2385
+ case 'webrtc':
2386
+ case 'decoder':
2387
+ case 'stream-broker':
2388
+ // No wiring needed — consumers read from capabilityRegistry on demand.
2389
+ break
2390
+
2391
+ case 'addon-routes': {
2392
+ // Route mounting is async for forked/group addons — the
2393
+ // provider is a Moleculer proxy whose `getRoutes()` returns
2394
+ // a Promise (the wire round-trips through the worker). The
2395
+ // EventBus subscriber callback is synchronous, so delegate
2396
+ // to an async helper and surface any failure via the
2397
+ // logger instead of letting a rejected promise (or a
2398
+ // `liveRoutes.some is not a function` TypeError) escape the
2399
+ // subscriber unobserved.
2400
+ void this.mountAddonRoutes(addonId).catch((err: unknown) => {
2401
+ this.logger.error('Failed to mount addon routes', {
2402
+ meta: { phase: 'v2', addonId, error: errMsg(err) },
2515
2403
  })
2516
- break;
2517
- }
2518
-
2519
- default:
2520
- break;
2404
+ })
2405
+ break
2521
2406
  }
2522
2407
 
2523
- // HTTP data-planes aren't capabilities, so no `case` fires for them —
2524
- // pull them off ANY cap-changed signal for this (forked) addon. Cheap +
2525
- // idempotent (replace-all), and re-pulls the fresh baseUrl/secret after a
2526
- // restart (the child re-handshakes → caps re-register → this re-fires).
2527
- void this.mountAddonDataPlanes(addonId).catch((err: unknown) => {
2528
- this.logger.error('Failed to mount addon data-planes', {
2529
- meta: { phase: 'v2', addonId, error: errMsg(err) },
2530
- })
2408
+ default:
2409
+ break
2410
+ }
2411
+
2412
+ // HTTP data-planes aren't capabilities, so no `case` fires for them —
2413
+ // pull them off ANY cap-changed signal for this (forked) addon. Cheap +
2414
+ // idempotent (replace-all), and re-pulls the fresh baseUrl/secret after a
2415
+ // restart (the child re-handshakes → caps re-register → this re-fires).
2416
+ void this.mountAddonDataPlanes(addonId).catch((err: unknown) => {
2417
+ this.logger.error('Failed to mount addon data-planes', {
2418
+ meta: { phase: 'v2', addonId, error: errMsg(err) },
2531
2419
  })
2532
- },
2533
- );
2420
+ })
2421
+ })
2534
2422
  }
2535
2423
 
2536
2424
  /**
@@ -2552,13 +2440,10 @@ export class AddonRegistryService {
2552
2440
  * directly.
2553
2441
  */
2554
2442
  private async mountAddonRoutes(addonId: string): Promise<void> {
2555
- if (!this.capabilityRegistry || !this.addonRouteRegistry) return;
2556
- const addonRouteRegistry = this.addonRouteRegistry;
2557
- const routeProvider = this.capabilityRegistry.getProviderByAddon(
2558
- "addon-routes",
2559
- addonId,
2560
- );
2561
- if (!routeProvider) return;
2443
+ if (!this.capabilityRegistry || !this.addonRouteRegistry) return
2444
+ const addonRouteRegistry = this.addonRouteRegistry
2445
+ const routeProvider = this.capabilityRegistry.getProviderByAddon('addon-routes', addonId)
2446
+ if (!routeProvider) return
2562
2447
 
2563
2448
  // ── Forked addon: fetch handler-stripped routes over UDS (F3) ──────
2564
2449
  // EVERY non-`@camstack/core` addon forks (the fork authority is
@@ -2580,29 +2465,29 @@ export class AddonRegistryService {
2580
2465
  // long as the child is connected, and the child is added to the UDS
2581
2466
  // registry BEFORE its manifest fires the cap-changed event that triggers
2582
2467
  // this mount, so the gate is satisfied on the happy path.
2583
- const entry = this.addonEntries.get(addonId);
2584
- const childRegistry = this.moleculer.childRegistry;
2468
+ const entry = this.addonEntries.get(addonId)
2469
+ const childRegistry = this.moleculer.childRegistry
2585
2470
  if (entry && this.isForkedAddonEntry(entry)) {
2586
2471
  if (childRegistry !== null && childRegistry.isChildKnown(addonId)) {
2587
- await this.mountForkedAddonRoutes(addonId, routeProvider, addonRouteRegistry);
2588
- return;
2472
+ await this.mountForkedAddonRoutes(addonId, routeProvider, addonRouteRegistry)
2473
+ return
2589
2474
  }
2590
2475
  // Child not yet connected over UDS: defer rather than register the async
2591
2476
  // proxy directly (which would crash on `.map`). The `addon-routes`
2592
2477
  // cap-changed event re-fires once the child finishes its handshake.
2593
2478
  this.logger.warn('Deferring forked addon route mount — child not yet UDS-reachable', {
2594
2479
  meta: { phase: 'v2', addonId },
2595
- });
2596
- return;
2480
+ })
2481
+ return
2597
2482
  }
2598
2483
 
2599
2484
  // ── Co-located addon (`@camstack/core` builtin): live handlers, register
2600
2485
  // the provider directly. The route handlers run in-process against the
2601
2486
  // real Fastify reply, so no wire bridge is needed.
2602
- addonRouteRegistry.registerRoutes(routeProvider.id, routeProvider);
2487
+ addonRouteRegistry.registerRoutes(routeProvider.id, routeProvider)
2603
2488
  this.logger.info('Addon routes mounted', {
2604
2489
  meta: { phase: 'v2', routeProviderId: routeProvider.id },
2605
- });
2490
+ })
2606
2491
  }
2607
2492
 
2608
2493
  /**
@@ -2631,51 +2516,49 @@ export class AddonRegistryService {
2631
2516
  this.logger.warn(
2632
2517
  'Forked addon-routes provider missing `invoke` method — routes will not dispatch. Use `buildAddonRouteProvider()` from @camstack/types.',
2633
2518
  { meta: { phase: 'v2', routeProviderId: routeProvider.id } },
2634
- );
2635
- return;
2519
+ )
2520
+ return
2636
2521
  }
2637
- const invoker = routeProvider;
2522
+ const invoker = routeProvider
2638
2523
 
2639
2524
  const rawRoutes = await this.addonCallGateway.callForked(addonId, {
2640
2525
  target: 'routes',
2641
- });
2642
- const descriptors = parseSerializableRouteDescriptors(rawRoutes);
2643
- const bridgeRoutes: import('@camstack/types').IAddonHttpRoute[] = descriptors.map(
2644
- (route) => ({
2645
- method: route.method,
2646
- path: route.path,
2647
- access: route.access,
2648
- ...(route.description !== undefined ? { description: route.description } : {}),
2649
- handler: async (req, reply) => {
2650
- const envelope = await invoker.invoke({
2651
- method: route.method,
2652
- path: route.path,
2653
- params: req.params,
2654
- query: req.query,
2655
- body: req.body,
2656
- headers: req.headers,
2657
- ...(req.user ? { user: req.user } : {}),
2658
- ...(req.scopedToken !== undefined ? { scopedToken: req.scopedToken } : {}),
2659
- });
2660
- reply.code(envelope.status);
2661
- if (envelope.contentType) reply.type(envelope.contentType);
2662
- for (const [k, v] of Object.entries(envelope.headers)) reply.header(k, v);
2663
- if (envelope.redirectUrl !== null) {
2664
- reply.header('Location', envelope.redirectUrl);
2665
- reply.send('');
2666
- } else {
2667
- reply.send(envelope.body);
2668
- }
2669
- },
2670
- }),
2671
- );
2526
+ })
2527
+ const descriptors = parseSerializableRouteDescriptors(rawRoutes)
2528
+ const bridgeRoutes: import('@camstack/types').IAddonHttpRoute[] = descriptors.map((route) => ({
2529
+ method: route.method,
2530
+ path: route.path,
2531
+ access: route.access,
2532
+ ...(route.description !== undefined ? { description: route.description } : {}),
2533
+ handler: async (req, reply) => {
2534
+ const envelope = await invoker.invoke({
2535
+ method: route.method,
2536
+ path: route.path,
2537
+ params: req.params,
2538
+ query: req.query,
2539
+ body: req.body,
2540
+ headers: req.headers,
2541
+ ...(req.user ? { user: req.user } : {}),
2542
+ ...(req.scopedToken !== undefined ? { scopedToken: req.scopedToken } : {}),
2543
+ })
2544
+ reply.code(envelope.status)
2545
+ if (envelope.contentType) reply.type(envelope.contentType)
2546
+ for (const [k, v] of Object.entries(envelope.headers)) reply.header(k, v)
2547
+ if (envelope.redirectUrl !== null) {
2548
+ reply.header('Location', envelope.redirectUrl)
2549
+ reply.send('')
2550
+ } else {
2551
+ reply.send(envelope.body)
2552
+ }
2553
+ },
2554
+ }))
2672
2555
  addonRouteRegistry.registerRoutes(routeProvider.id, {
2673
2556
  id: routeProvider.id,
2674
2557
  getRoutes: () => bridgeRoutes,
2675
- });
2558
+ })
2676
2559
  this.logger.info('Addon routes mounted (forked-bridge over UDS)', {
2677
2560
  meta: { phase: 'v2', routeProviderId: routeProvider.id, routes: bridgeRoutes.length },
2678
- });
2561
+ })
2679
2562
  }
2680
2563
 
2681
2564
  // Cleanup: `addonHasConfigFields` deleted. It was the last reader of
@@ -2687,23 +2570,23 @@ export class AddonRegistryService {
2687
2570
 
2688
2571
  private emitAddonLifecycleEvent(
2689
2572
  eventType:
2690
- | "addon.started"
2691
- | "addon.stopped"
2692
- | "addon.restarted"
2693
- | "addon.updated"
2694
- | "addon.installed"
2695
- | "addon.uninstalled"
2696
- | "addon.crashed"
2697
- | "addon.error",
2573
+ | 'addon.started'
2574
+ | 'addon.stopped'
2575
+ | 'addon.restarted'
2576
+ | 'addon.updated'
2577
+ | 'addon.installed'
2578
+ | 'addon.uninstalled'
2579
+ | 'addon.crashed'
2580
+ | 'addon.error',
2698
2581
  addonId: string,
2699
2582
  data?: Record<string, unknown>,
2700
2583
  ): void {
2701
- const entry = this.addonEntries.get(addonId);
2584
+ const entry = this.addonEntries.get(addonId)
2702
2585
  this.eventBusService.emit({
2703
2586
  id: randomUUID(),
2704
2587
  timestamp: new Date(),
2705
2588
  source: {
2706
- type: "addon",
2589
+ type: 'addon',
2707
2590
  id: addonId,
2708
2591
  nodeId: this.broker.nodeID,
2709
2592
  },
@@ -2712,10 +2595,10 @@ export class AddonRegistryService {
2712
2595
  addonId,
2713
2596
  packageName: entry?.packageName,
2714
2597
  packageVersion: entry?.packageVersion,
2715
- agent: process.env.CAMSTACK_AGENT_NAME ?? "hub",
2598
+ agent: process.env.CAMSTACK_AGENT_NAME ?? 'hub',
2716
2599
  ...data,
2717
2600
  },
2718
- });
2601
+ })
2719
2602
  }
2720
2603
 
2721
2604
  /**
@@ -2723,60 +2606,54 @@ export class AddonRegistryService {
2723
2606
  * Provider registration now happens in initialize() via context.registerProvider().
2724
2607
  */
2725
2608
  private wireCapabilities(addonId: string): void {
2726
- const entry = this.addonEntries.get(addonId);
2727
- if (!entry?.initialized) return;
2609
+ const entry = this.addonEntries.get(addonId)
2610
+ if (!entry?.initialized) return
2728
2611
 
2729
2612
  // Emit addon-pages event for UI notification. Cap name is
2730
2613
  // `addon-pages-source` after the consolidation split — collection
2731
2614
  // providers register on the source cap; the singleton aggregator
2732
2615
  // owns `addon-pages` cluster-wide.
2733
- const declaredCaps = entry.declaredCapabilities.map((c) => c.name);
2734
- if (declaredCaps.includes("addon-pages-source")) {
2616
+ const declaredCaps = entry.declaredCapabilities.map((c) => c.name)
2617
+ if (declaredCaps.includes('addon-pages-source')) {
2735
2618
  this.eventBusService.emit({
2736
2619
  id: randomUUID(),
2737
2620
  timestamp: new Date(),
2738
- source: { type: "addon", id: addonId },
2621
+ source: { type: 'addon', id: addonId },
2739
2622
  category: EventCategory.AddonPageReady,
2740
2623
  data: { addonId, packageName: entry.packageName },
2741
- });
2624
+ })
2742
2625
  }
2743
2626
  // Symmetric for `addon-widgets-source` — addons that contribute
2744
2627
  // widget bundles emit `AddonWidgetReady` so the
2745
2628
  // <WidgetRegistryProvider> in admin-ui invalidates its aggregator
2746
2629
  // query and the new bundle becomes available without a page
2747
2630
  // reload.
2748
- if (declaredCaps.includes("addon-widgets-source")) {
2631
+ if (declaredCaps.includes('addon-widgets-source')) {
2749
2632
  this.eventBusService.emit({
2750
2633
  id: randomUUID(),
2751
2634
  timestamp: new Date(),
2752
- source: { type: "addon", id: addonId },
2635
+ source: { type: 'addon', id: addonId },
2753
2636
  category: EventCategory.AddonWidgetReady,
2754
2637
  data: { addonId, packageName: entry.packageName },
2755
- });
2638
+ })
2756
2639
  }
2757
2640
  }
2758
2641
 
2759
2642
  private getAddonCapabilities(addon: ICamstackAddon): CapabilityDeclaration[] {
2760
- const caps = addon?.manifest?.capabilities;
2761
- if (!caps) return [];
2643
+ const caps = addon?.manifest?.capabilities
2644
+ if (!caps) return []
2762
2645
 
2763
- return caps.map(
2764
- (cap): CapabilityDeclaration =>
2765
- typeof cap === "string" ? { name: cap } : cap,
2766
- );
2646
+ return caps.map((cap): CapabilityDeclaration => (typeof cap === 'string' ? { name: cap } : cap))
2767
2647
  }
2768
2648
 
2769
- private findAddonForCapability(
2770
- capName: string,
2771
- addonIds: string[],
2772
- ): string | null {
2649
+ private findAddonForCapability(capName: string, addonIds: string[]): string | null {
2773
2650
  for (const id of addonIds) {
2774
- const entry = this.addonEntries.get(id);
2775
- if (!entry) continue;
2651
+ const entry = this.addonEntries.get(id)
2652
+ if (!entry) continue
2776
2653
  // Use declaredCapabilities (from package.json) as source of truth
2777
- if (entry.declaredCapabilities.some((c) => c.name === capName)) return id;
2654
+ if (entry.declaredCapabilities.some((c) => c.name === capName)) return id
2778
2655
  }
2779
- return null;
2656
+ return null
2780
2657
  }
2781
2658
 
2782
2659
  /**
@@ -2798,14 +2675,14 @@ export class AddonRegistryService {
2798
2675
  */
2799
2676
  private buildAddonConfig(addonId: string): Record<string, unknown> {
2800
2677
  // Start with per-addon SQL settings (may be empty for most addons)
2801
- let addonSpecific: Record<string, unknown> = {};
2678
+ let addonSpecific: Record<string, unknown> = {}
2802
2679
  try {
2803
- addonSpecific = this.configService.getAddonConfig(addonId);
2680
+ addonSpecific = this.configService.getAddonConfig(addonId)
2804
2681
  } catch (err) {
2805
- this.logger.debug(
2806
- 'ConfigManager not ready for addon',
2807
- { tags: { addonId }, meta: { error: errMsg(err) } },
2808
- );
2682
+ this.logger.debug('ConfigManager not ready for addon', {
2683
+ tags: { addonId },
2684
+ meta: { error: errMsg(err) },
2685
+ })
2809
2686
  }
2810
2687
 
2811
2688
  // No addon id special-casing here anymore. Bootstrap-level
@@ -2813,51 +2690,39 @@ export class AddonRegistryService {
2813
2690
  // owned by the addon itself — sqlite-settings imports the
2814
2691
  // constant directly from `@camstack/types` instead of relying on
2815
2692
  // a kernel-injected bootstrap field.
2816
- return addonSpecific;
2693
+ return addonSpecific
2817
2694
  }
2818
2695
 
2819
- private async createAddonContext(
2820
- addon: ICamstackAddon,
2821
- ): Promise<InternalAddonContext> {
2822
- const addonId = addon.manifest!.id;
2823
- const brokerNodeId = this.broker.nodeID;
2824
- const agentId = brokerNodeId.includes("/")
2825
- ? brokerNodeId.split("/")[0]!
2826
- : brokerNodeId;
2696
+ private async createAddonContext(addon: ICamstackAddon): Promise<InternalAddonContext> {
2697
+ const addonId = addon.manifest!.id
2698
+ const brokerNodeId = this.broker.nodeID
2699
+ const agentId = brokerNodeId.includes('/') ? brokerNodeId.split('/')[0]! : brokerNodeId
2827
2700
  // No scope on the addon root logger — the brand bracket already shows
2828
2701
  // `[agent/addonId]`, so `(addon:<addonId>)` was pure duplication.
2829
2702
  // Sub-components add their own scope via `.child('<name>')`.
2830
2703
  const logger = this.loggingService
2831
2704
  .createLogger()
2832
- .withTags({ addonId, nodeId: brokerNodeId, agentId });
2833
- const bootstrapConfig = this.buildAddonConfig(addonId);
2705
+ .withTags({ addonId, nodeId: brokerNodeId, agentId })
2706
+ const bootstrapConfig = this.buildAddonConfig(addonId)
2834
2707
 
2835
2708
  // Per-addon private data directory — resolved from active storage
2836
2709
  // provider if available, otherwise falls back to a deterministic
2837
2710
  // hardcoded path. Addons that need the full storage provider now
2838
2711
  // resolve it via the capability registry.
2839
- const storageProvider = this.activeStorageProvider;
2712
+ const storageProvider = this.activeStorageProvider
2840
2713
  const dataDir = storageProvider
2841
2714
  ? await storageProvider.resolve({
2842
- location: "addons-data",
2715
+ location: 'addons-data',
2843
2716
  relativePath: addonId,
2844
2717
  })
2845
- : `camstack-data/addons-data/${addonId}`;
2846
-
2847
- const registerProvider = (
2848
- capabilityName: string,
2849
- provider: unknown,
2850
- ): void => {
2851
- this.capabilityRegistry.registerProvider(
2852
- capabilityName,
2853
- addonId,
2854
- provider,
2855
- );
2856
- logger.info(
2857
- 'Registered provider via context.registerProvider()',
2858
- { meta: { capabilityName } },
2859
- );
2860
- };
2718
+ : `camstack-data/addons-data/${addonId}`
2719
+
2720
+ const registerProvider = (capabilityName: string, provider: unknown): void => {
2721
+ this.capabilityRegistry.registerProvider(capabilityName, addonId, provider)
2722
+ logger.info('Registered provider via context.registerProvider()', {
2723
+ meta: { capabilityName },
2724
+ })
2725
+ }
2861
2726
 
2862
2727
  // Raw three-level settings store API. The resolver service is a
2863
2728
  // thin wrapper over `ConfigManager` that exposes raw reads/writes
@@ -2866,8 +2731,7 @@ export class AddonRegistryService {
2866
2731
  // combines these raw reads with its own schema via
2867
2732
  // `hydrateSchema()` inside `getAddonSettings / getGlobalSettings
2868
2733
  // / getDeviceSettings`.
2869
- const settingsView: AddonSettingsView =
2870
- this.configService.createSettingsView(addonId);
2734
+ const settingsView: AddonSettingsView = this.configService.createSettingsView(addonId)
2871
2735
 
2872
2736
  // Device management — unified path for hub and worker addons.
2873
2737
  // Persistence routes through the `device-manager` capability
@@ -2876,11 +2740,11 @@ export class AddonRegistryService {
2876
2740
  // `device-manager` Moleculer service in-process — no network
2877
2741
  // hop. On a forked worker, the same proxy routes through the TCP
2878
2742
  // transport to the hub's service. Zero custom $hub.* actions needed.
2879
- const kernelStreamProbe: import("@camstack/types").IKernelStreamProbe = {
2743
+ const kernelStreamProbe: import('@camstack/types').IKernelStreamProbe = {
2880
2744
  probe: (url, options) => this.streamProbe.probe(url, options),
2881
2745
  probeField: (key, value) => this.streamProbe.probeField(key, value),
2882
- };
2883
- const deviceManagerApi: import("@camstack/types").DeviceManagerApi =
2746
+ }
2747
+ const deviceManagerApi: import('@camstack/types').DeviceManagerApi =
2884
2748
  createBrokerDeviceManagerApi({
2885
2749
  api: this.getBrokerApi(),
2886
2750
  addonId,
@@ -2890,22 +2754,26 @@ export class AddonRegistryService {
2890
2754
  registry: this.deviceRegistry,
2891
2755
  capabilityRegistry: this.capabilityRegistry,
2892
2756
  streamProbe: kernelStreamProbe,
2893
- });
2894
-
2895
- const registry = this;
2896
- const rr = this.moleculer.readinessRegistry;
2897
- const capHandleCache = new Map<string, CapabilityHandle<unknown>>();
2898
- function getOrCreateHandle<T>(capName: string, scope: ReadinessScope, timeoutMs: number): CapabilityHandle<T> {
2899
- const key = `${capName}::${scopeKey(scope)}`;
2900
- const existing = capHandleCache.get(key);
2901
- if (existing) return existing as CapabilityHandle<T>;
2902
- const handle = new CapabilityHandle<T>(capName, scope, rr, timeoutMs);
2903
- capHandleCache.set(key, handle as CapabilityHandle<unknown>);
2904
- return handle;
2757
+ })
2758
+
2759
+ const registry = this
2760
+ const rr = this.moleculer.readinessRegistry
2761
+ const capHandleCache = new Map<string, CapabilityHandle<unknown>>()
2762
+ function getOrCreateHandle<T>(
2763
+ capName: string,
2764
+ scope: ReadinessScope,
2765
+ timeoutMs: number,
2766
+ ): CapabilityHandle<T> {
2767
+ const key = `${capName}::${scopeKey(scope)}`
2768
+ const existing = capHandleCache.get(key)
2769
+ if (existing) return existing as CapabilityHandle<T>
2770
+ const handle = new CapabilityHandle<T>(capName, scope, rr, timeoutMs)
2771
+ capHandleCache.set(key, handle as CapabilityHandle<unknown>)
2772
+ return handle
2905
2773
  }
2906
2774
  const ctx: InternalAddonContext & {
2907
- integrationRegistry?: unknown;
2908
- capabilities: import("@camstack/types").CapabilitiesAccess;
2775
+ integrationRegistry?: unknown
2776
+ capabilities: import('@camstack/types').CapabilitiesAccess
2909
2777
  } = {
2910
2778
  id: `addon:${addonId}`,
2911
2779
  logger,
@@ -2913,7 +2781,7 @@ export class AddonRegistryService {
2913
2781
  addonConfig: bootstrapConfig,
2914
2782
  dataDir,
2915
2783
  get api() {
2916
- return registry.getBrokerApi();
2784
+ return registry.getBrokerApi()
2917
2785
  },
2918
2786
  integrationRegistry: this.integrationRegistry ?? undefined,
2919
2787
  // Live capability-collection accessor used by addons like stream-broker
@@ -2923,20 +2791,18 @@ export class AddonRegistryService {
2923
2791
  // Without this, those addons silently saw 0 providers and quietly
2924
2792
  // skipped the fan-out — no errors, just missing streams/wiring.
2925
2793
  capabilities: {
2926
- getCollection: <T = unknown>(
2927
- capName: string,
2928
- ): readonly T[] | undefined => {
2929
- const items = registry.capabilityRegistry.getCollection<T>(capName);
2930
- return items ?? undefined;
2794
+ getCollection: <T = unknown>(capName: string): readonly T[] | undefined => {
2795
+ const items = registry.capabilityRegistry.getCollection<T>(capName)
2796
+ return items ?? undefined
2931
2797
  },
2932
2798
  getCollectionEntries: <T = unknown>(
2933
2799
  capName: string,
2934
2800
  ): readonly (readonly [string, T])[] | undefined => {
2935
- const items = registry.capabilityRegistry.getCollectionEntries<T>(capName);
2936
- return items ?? undefined;
2801
+ const items = registry.capabilityRegistry.getCollectionEntries<T>(capName)
2802
+ return items ?? undefined
2937
2803
  },
2938
2804
  get: <T = unknown>(capName: string): T | undefined => {
2939
- return registry.capabilityRegistry.getSingleton<T>(capName) ?? undefined;
2805
+ return registry.capabilityRegistry.getSingleton<T>(capName) ?? undefined
2940
2806
  },
2941
2807
  },
2942
2808
  deps: new AddonDepsManager(dataDir, logger),
@@ -2952,8 +2818,7 @@ export class AddonRegistryService {
2952
2818
  // Per-addon storage-location declarations across every installed
2953
2819
  // addon — surfaced from the kernel's AddonLoader so the
2954
2820
  // storage-orchestrator builtin can aggregate them and seed defaults.
2955
- listStorageLocationDeclarations: () =>
2956
- this.addonLoader.listStorageLocationDeclarations(),
2821
+ listStorageLocationDeclarations: () => this.addonLoader.listStorageLocationDeclarations(),
2957
2822
  readinessRegistry: this.moleculer.readinessRegistry,
2958
2823
  // D3: handshake-fed native-cap view of the whole cluster. Backed by
2959
2824
  // `HubNodeRegistry.listNativeCapEntries()` populated by every
@@ -2968,10 +2833,7 @@ export class AddonRegistryService {
2968
2833
  },
2969
2834
  registerProvider,
2970
2835
  resolveProvider: <T = unknown>(capName: string): T | null => {
2971
- return (
2972
- (registry.capabilityRegistry.getSingleton<T>(capName) as T | null) ??
2973
- null
2974
- );
2836
+ return (registry.capabilityRegistry.getSingleton<T>(capName) as T | null) ?? null
2975
2837
  },
2976
2838
  getNativeProvider: <TCap extends CapabilityDefinition>(
2977
2839
  cap: TCap,
@@ -2979,33 +2841,32 @@ export class AddonRegistryService {
2979
2841
  ): InferProvider<TCap> => {
2980
2842
  // Hub-side addons live on the same process as the CapabilityRegistry;
2981
2843
  // native providers are always resolvable locally.
2982
- const local = registry.capabilityRegistry.getNativeProvider<
2983
- InferProvider<TCap>
2984
- >(cap.name, deviceId);
2844
+ const local = registry.capabilityRegistry.getNativeProvider<InferProvider<TCap>>(
2845
+ cap.name,
2846
+ deviceId,
2847
+ )
2985
2848
  if (!local) {
2986
- throw new Error(
2987
- `no native provider for capability '${cap.name}' on device '${deviceId}'`,
2988
- );
2849
+ throw new Error(`no native provider for capability '${cap.name}' on device '${deviceId}'`)
2989
2850
  }
2990
- return local;
2851
+ return local
2991
2852
  },
2992
2853
  fetchDevice: async (deviceId: number) => {
2993
- const api = registry.getBrokerApi();
2994
- const binding = await api.deviceManager.getBindings.query({ deviceId });
2995
- return createDeviceProxy(api, binding);
2854
+ const api = registry.getBrokerApi()
2855
+ const binding = await api.deviceManager.getBindings.query({ deviceId })
2856
+ return createDeviceProxy(api, binding)
2996
2857
  },
2997
2858
  settings: settingsView,
2998
2859
  useCapability<T = unknown>(capName: string, scope: ReadinessScope = { type: 'global' }) {
2999
- return getOrCreateHandle<T>(capName, scope, 15_000);
2860
+ return getOrCreateHandle<T>(capName, scope, 15_000)
3000
2861
  },
3001
2862
  async acquireCapability<T = unknown>(
3002
2863
  capName: string,
3003
2864
  scope: ReadinessScope = { type: 'global' },
3004
2865
  opts: { timeoutMs?: number } = {},
3005
2866
  ) {
3006
- const timeoutMs = opts.timeoutMs ?? 15_000;
3007
- const handle = getOrCreateHandle<T>(capName, scope, timeoutMs);
3008
- return handle;
2867
+ const timeoutMs = opts.timeoutMs ?? 15_000
2868
+ const handle = getOrCreateHandle<T>(capName, scope, timeoutMs)
2869
+ return handle
3009
2870
  },
3010
2871
  onCapabilityStateChange(
3011
2872
  capName: string,
@@ -3013,15 +2874,15 @@ export class AddonRegistryService {
3013
2874
  handler: (state: 'ready' | 'down') => void,
3014
2875
  ) {
3015
2876
  return rr.onReadyState(capName, scope, (t) => {
3016
- handler(t.state === 'ready' ? 'ready' : 'down');
3017
- });
2877
+ handler(t.state === 'ready' ? 'ready' : 'down')
2878
+ })
3018
2879
  },
3019
2880
  addDisposer(fn: () => void | Promise<void>) {
3020
- return registry.getOrCreateDisposerChain(addonId).add(fn);
2881
+ return registry.getOrCreateDisposerChain(addonId).add(fn)
3021
2882
  },
3022
- };
2883
+ }
3023
2884
 
3024
- return ctx;
2885
+ return ctx
3025
2886
  }
3026
2887
 
3027
2888
  /**
@@ -3030,22 +2891,22 @@ export class AddonRegistryService {
3030
2891
  * `unregisterAddon()` so cleanup callbacks run before the new addon
3031
2892
  * instance comes up.
3032
2893
  */
3033
- private readonly disposerChains = new Map<string, DisposerChain>();
2894
+ private readonly disposerChains = new Map<string, DisposerChain>()
3034
2895
 
3035
2896
  private getOrCreateDisposerChain(addonId: string): DisposerChain {
3036
- let chain = this.disposerChains.get(addonId);
2897
+ let chain = this.disposerChains.get(addonId)
3037
2898
  if (chain == null) {
3038
- const log = this.loggingService.createLogger().withTags({ addonId });
2899
+ const log = this.loggingService.createLogger().withTags({ addonId })
3039
2900
  chain = new DisposerChain({
3040
2901
  onError: (err, index) => {
3041
2902
  log.error(`Disposer #${index} threw during teardown`, {
3042
2903
  meta: { error: errMsg(err) },
3043
- });
2904
+ })
3044
2905
  },
3045
- });
3046
- this.disposerChains.set(addonId, chain);
2906
+ })
2907
+ this.disposerChains.set(addonId, chain)
3047
2908
  }
3048
- return chain;
2909
+ return chain
3049
2910
  }
3050
2911
 
3051
2912
  /**
@@ -3054,10 +2915,10 @@ export class AddonRegistryService {
3054
2915
  * `ctx.addDisposer(...)` clean up before the next instance boots.
3055
2916
  */
3056
2917
  private async drainDisposerChain(addonId: string): Promise<void> {
3057
- const chain = this.disposerChains.get(addonId);
3058
- if (chain == null) return;
3059
- this.disposerChains.delete(addonId);
3060
- await chain.dispose();
2918
+ const chain = this.disposerChains.get(addonId)
2919
+ if (chain == null) return
2920
+ this.disposerChains.delete(addonId)
2921
+ await chain.dispose()
3061
2922
  }
3062
2923
 
3063
2924
  // ── Group orchestration (Phase G3 — opt-in, not yet wired into boot) ──
@@ -3087,19 +2948,19 @@ export class AddonRegistryService {
3087
2948
  * diagnostics that want to render the current topology.
3088
2949
  */
3089
2950
  buildAddonGroupPlan(addonIds: readonly string[]): RunnerPlan {
3090
- const plan = new Map<string, RunnerAddonPlacement[]>();
2951
+ const plan = new Map<string, RunnerAddonPlacement[]>()
3091
2952
  for (const id of addonIds) {
3092
- const entry = this.addonEntries.get(id);
3093
- if (!entry?.declaration || !entry.addonDir) continue;
3094
- if (entry.packageName === "@camstack/core") continue;
3095
- const placement = resolveAddonPlacement(entry.declaration);
3096
- if (placement === "agent-only") continue;
3097
- const runnerId = resolveRunnerId(entry.declaration, id);
3098
- const bucket = plan.get(runnerId) ?? [];
3099
- bucket.push({ addonId: id, addonDir: entry.addonDir });
3100
- plan.set(runnerId, bucket);
3101
- }
3102
- return plan;
2953
+ const entry = this.addonEntries.get(id)
2954
+ if (!entry?.declaration || !entry.addonDir) continue
2955
+ if (entry.packageName === '@camstack/core') continue
2956
+ const placement = resolveAddonPlacement(entry.declaration)
2957
+ if (placement === 'agent-only') continue
2958
+ const runnerId = resolveRunnerId(entry.declaration, id)
2959
+ const bucket = plan.get(runnerId) ?? []
2960
+ bucket.push({ addonId: id, addonDir: entry.addonDir })
2961
+ plan.set(runnerId, bucket)
2962
+ }
2963
+ return plan
3103
2964
  }
3104
2965
 
3105
2966
  /**
@@ -3123,18 +2984,18 @@ export class AddonRegistryService {
3123
2984
  addons: readonly RunnerAddonPlacement[],
3124
2985
  ): Promise<void> {
3125
2986
  if (addons.length === 0) {
3126
- throw new Error(`initializeAddonGroup("${runnerId}") requires at least one addon`);
2987
+ throw new Error(`initializeAddonGroup("${runnerId}") requires at least one addon`)
3127
2988
  }
3128
2989
  try {
3129
- await this.broker.call("$process.spawnRunner", {
2990
+ await this.broker.call('$process.spawnRunner', {
3130
2991
  runnerId,
3131
2992
  addons,
3132
- });
2993
+ })
3133
2994
  } catch (err: unknown) {
3134
2995
  throw new Error(
3135
2996
  `Failed to spawn runner "${runnerId}" (${addons.length} addons): ${errMsg(err)}`,
3136
2997
  { cause: err },
3137
- );
2998
+ )
3138
2999
  }
3139
3000
 
3140
3001
  // Register custom actions for each addon on the runner. Provider
@@ -3142,19 +3003,19 @@ export class AddonRegistryService {
3142
3003
  // `CapabilityBridge.onProviderConnected` once the runner's
3143
3004
  // INFO heartbeat lands.
3144
3005
  for (const { addonId } of addons) {
3145
- await this.registerForkedAddonCustomActions(addonId, runnerId);
3006
+ await this.registerForkedAddonCustomActions(addonId, runnerId)
3146
3007
  // Mark the entry initialized so the in-process core-builtin boot
3147
3008
  // passes skip it (those passes only touch `@camstack/core`).
3148
- const entry = this.addonEntries.get(addonId);
3009
+ const entry = this.addonEntries.get(addonId)
3149
3010
  if (entry) {
3150
- entry.initialized = true;
3151
- this.emitAddonLifecycleEvent("addon.started", addonId);
3011
+ entry.initialized = true
3012
+ this.emitAddonLifecycleEvent('addon.started', addonId)
3152
3013
  }
3153
3014
  }
3154
3015
 
3155
- this.logger.info("Addon runner spawned", {
3016
+ this.logger.info('Addon runner spawned', {
3156
3017
  meta: { runnerId, addonCount: addons.length, addonIds: addons.map((a) => a.addonId) },
3157
- });
3018
+ })
3158
3019
  }
3159
3020
 
3160
3021
  /**
@@ -3180,76 +3041,73 @@ export class AddonRegistryService {
3180
3041
  * Idempotent: drops any prior registration first. No-op (with a debug
3181
3042
  * log) when the addon exports no `customActions` — most addons don't.
3182
3043
  */
3183
- private async registerForkedAddonCustomActions(
3184
- addonId: string,
3185
- runnerId: string,
3186
- ): Promise<void> {
3044
+ private async registerForkedAddonCustomActions(addonId: string, runnerId: string): Promise<void> {
3187
3045
  // Always clear first so a restart that REMOVES custom actions (or an
3188
3046
  // addon whose entry no longer exports them) doesn't leave stale
3189
3047
  // entries resolvable.
3190
- this.customActionRegistry.unregisterAddon(addonId);
3048
+ this.customActionRegistry.unregisterAddon(addonId)
3191
3049
 
3192
- const entry = this.addonEntries.get(addonId);
3193
- const addonDir = entry?.addonDir;
3194
- const declarationEntry = entry?.declaration?.entry;
3195
- if (!addonDir || !declarationEntry) return;
3050
+ const entry = this.addonEntries.get(addonId)
3051
+ const addonDir = entry?.addonDir
3052
+ const declarationEntry = entry?.declaration?.entry
3053
+ if (!addonDir || !declarationEntry) return
3196
3054
 
3197
3055
  // Resolve the built entry the same way `AddonLoader.loadDeclaration`
3198
3056
  // does: `./src/x.ts` → `dist/x.js`, with index.js fallbacks.
3199
3057
  const entryFile = declarationEntry
3200
- .replace(/^\.\//, "")
3201
- .replace(/^src\//, "dist/")
3202
- .replace(/\.ts$/, ".js");
3203
- let entryPath = path.resolve(addonDir, entryFile);
3058
+ .replace(/^\.\//, '')
3059
+ .replace(/^src\//, 'dist/')
3060
+ .replace(/\.ts$/, '.js')
3061
+ let entryPath = path.resolve(addonDir, entryFile)
3204
3062
  if (!fs.existsSync(entryPath)) {
3205
- const base = entryPath.replace(/\.(js|cjs|mjs)$/, "");
3063
+ const base = entryPath.replace(/\.(js|cjs|mjs)$/, '')
3206
3064
  const alternatives = [
3207
3065
  `${base}.cjs`,
3208
3066
  `${base}.mjs`,
3209
- path.resolve(addonDir, "dist", "index.js"),
3210
- path.resolve(addonDir, "dist", "index.cjs"),
3211
- path.resolve(addonDir, "dist", "index.mjs"),
3067
+ path.resolve(addonDir, 'dist', 'index.js'),
3068
+ path.resolve(addonDir, 'dist', 'index.cjs'),
3069
+ path.resolve(addonDir, 'dist', 'index.mjs'),
3212
3070
  path.resolve(addonDir, declarationEntry),
3213
- ];
3214
- entryPath = alternatives.find((p) => fs.existsSync(p)) ?? entryPath;
3071
+ ]
3072
+ entryPath = alternatives.find((p) => fs.existsSync(p)) ?? entryPath
3215
3073
  }
3216
- if (!fs.existsSync(entryPath)) return;
3074
+ if (!fs.existsSync(entryPath)) return
3217
3075
 
3218
- let catalog: unknown;
3076
+ let catalog: unknown
3219
3077
  try {
3220
3078
  // Cache-bust so a hot-updated bundle is re-read instead of served
3221
3079
  // from Node's ESM module cache.
3222
- const cacheBustedUrl = `${pathToFileURL(entryPath).href}?t=${Date.now()}`;
3223
- const modUnknown: unknown = await import(cacheBustedUrl);
3080
+ const cacheBustedUrl = `${pathToFileURL(entryPath).href}?t=${Date.now()}`
3081
+ const modUnknown: unknown = await import(cacheBustedUrl)
3224
3082
  catalog =
3225
- modUnknown && typeof modUnknown === "object"
3226
- ? (modUnknown as Record<string, unknown>)["customActions"]
3227
- : undefined;
3083
+ modUnknown && typeof modUnknown === 'object'
3084
+ ? (modUnknown as Record<string, unknown>)['customActions']
3085
+ : undefined
3228
3086
  } catch (err) {
3229
- this.logger.warn(
3230
- "Failed to load custom-action catalog for forked addon",
3231
- { tags: { addonId }, meta: { error: errMsg(err) } },
3232
- );
3233
- return;
3087
+ this.logger.warn('Failed to load custom-action catalog for forked addon', {
3088
+ tags: { addonId },
3089
+ meta: { error: errMsg(err) },
3090
+ })
3091
+ return
3234
3092
  }
3235
3093
 
3236
- if (!catalog || typeof catalog !== "object") {
3237
- this.logger.debug("Forked addon exports no custom actions", {
3094
+ if (!catalog || typeof catalog !== 'object') {
3095
+ this.logger.debug('Forked addon exports no custom actions', {
3238
3096
  tags: { addonId },
3239
3097
  meta: { runnerId },
3240
- });
3241
- return;
3098
+ })
3099
+ return
3242
3100
  }
3243
3101
 
3244
3102
  this.customActionRegistry.registerAddon(
3245
3103
  addonId,
3246
- catalog as import("@camstack/types").CustomActionsSpec,
3104
+ catalog as import('@camstack/types').CustomActionsSpec,
3247
3105
  (action, input) => this.dispatchForkedCustomAction(addonId, action, input),
3248
- );
3249
- this.logger.info("Runner addon custom actions registered", {
3106
+ )
3107
+ this.logger.info('Runner addon custom actions registered', {
3250
3108
  tags: { addonId },
3251
3109
  meta: { runnerId },
3252
- });
3110
+ })
3253
3111
  }
3254
3112
 
3255
3113
  /**
@@ -3264,9 +3122,9 @@ export class AddonRegistryService {
3264
3122
  input: unknown,
3265
3123
  ): Promise<unknown> {
3266
3124
  return this.addonCallGateway.callForked(addonId, {
3267
- target: "custom",
3125
+ target: 'custom',
3268
3126
  action,
3269
3127
  args: input,
3270
- });
3128
+ })
3271
3129
  }
3272
3130
  }