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