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