@camstack/server 0.1.6 → 0.1.8
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 +3 -3
- package/src/__tests__/addon-upload.spec.ts +58 -0
- package/src/__tests__/bulk-update-coordinator.spec.ts +286 -0
- package/src/__tests__/cap-ownership-authority.spec.ts +400 -0
- package/src/__tests__/cap-providers/cap-providers-location-import.spec.ts +186 -0
- package/src/__tests__/cap-providers/integrations-delete-cascade.spec.ts +243 -0
- package/src/__tests__/cap-providers-bulk-update.spec.ts +388 -0
- package/src/__tests__/cap-route-adapter.spec.ts +289 -0
- package/src/__tests__/cap-routers/broker-routing.router.spec.ts +169 -0
- package/src/__tests__/cap-routers/cap-route-error-formatter.spec.ts +123 -0
- package/src/__tests__/cap-routers/capabilities-node.spec.ts +55 -0
- package/src/__tests__/cap-routers/device-link-overlay.spec.ts +132 -0
- package/src/__tests__/dev-bootstrap-shm-ring.spec.ts +30 -0
- package/src/__tests__/device-settings-contribution-dispatch.spec.ts +249 -0
- package/src/__tests__/framework-installer-defer-restart.spec.ts +165 -0
- package/src/__tests__/moleculer/uds-readiness.spec.ts +143 -0
- package/src/__tests__/moleculer/uds-topology.spec.ts +390 -0
- package/src/__tests__/moleculer/uds-unowned-call.spec.ts +329 -0
- package/src/__tests__/moleculer-register-node-idempotency.spec.ts +39 -4
- package/src/__tests__/native-cap-route.spec.ts +404 -0
- package/src/__tests__/oauth2-account-linking.spec.ts +85 -0
- package/src/__tests__/uds-addon-call-wiring.spec.ts +237 -0
- package/src/__tests__/uds-log-ingest.spec.ts +183 -0
- package/src/api/addon-upload.ts +27 -1
- package/src/api/capabilities.router.ts +1 -1
- package/src/api/core/__tests__/integration-markers.spec.ts +10 -0
- package/src/api/core/bulk-update-coordinator.ts +302 -0
- package/src/api/core/cap-providers.ts +211 -9
- package/src/api/core/capabilities.router.ts +26 -3
- package/src/api/core/logs.router.ts +4 -0
- package/src/api/oauth2/oauth2-routes.ts +5 -1
- package/src/api/trpc/__tests__/client-ip.spec.ts +146 -0
- package/src/api/trpc/__tests__/webrtc-session-ua-enrich.spec.ts +128 -0
- package/src/api/trpc/cap-mount-helpers.ts +12 -1
- package/src/api/trpc/cap-route-error-formatter.ts +163 -0
- package/src/api/trpc/client-ip.ts +147 -0
- package/src/api/trpc/generated-cap-mounts.ts +299 -8
- package/src/api/trpc/generated-cap-routers.ts +2384 -302
- package/src/api/trpc/trpc.middleware.ts +5 -1
- package/src/api/trpc/trpc.router.ts +84 -3
- package/src/boot/__tests__/integration-id-backfill.spec.ts +116 -0
- package/src/boot/integration-id-backfill.ts +109 -0
- package/src/core/addon/__tests__/addon-row-manifest.spec.ts +62 -0
- package/src/core/addon/addon-call-gateway.ts +157 -0
- package/src/core/addon/addon-package.service.ts +9 -0
- package/src/core/addon/addon-registry.service.ts +453 -107
- package/src/core/addon/addon-row-manifest.ts +29 -0
- package/src/core/addon/addon-settings-provider.ts +40 -116
- package/src/core/capability/capability.service.ts +9 -0
- package/src/core/logging/logging.service.ts +7 -2
- package/src/core/moleculer/cap-call-fn.spec.ts +166 -0
- package/src/core/moleculer/cap-call-fn.ts +103 -0
- package/src/core/moleculer/cap-route-authority.ts +182 -0
- package/src/core/moleculer/moleculer.service.ts +408 -36
- package/src/core/network/network-quality.service.spec.ts +2 -1
- package/src/main.ts +137 -12
- package/src/core/storage/settings-store.spec.ts +0 -213
- package/src/core/storage/settings-store.ts +0 -2
- package/src/core/storage/sql-schema.spec.ts +0 -140
- package/src/core/storage/sql-schema.ts +0 -3
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
/* eslint-disable @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-return, @typescript-eslint/no-unsafe-argument -- The server installs @camstack/types@0.1.38 (last published) in server/backend/node_modules, while the workspace has 0.1.39 with the new BulkUpdate* types. ESLint's type-checker resolves against 0.1.38 and treats the new imports as `any`. Runtime is correct because Node module resolution walks up to root node_modules → workspace symlink. This disable mirrors the pattern in cap-providers.ts (same root cause). Will resolve when 0.1.39 is published and the local dist is synced. */
|
|
2
|
+
import { randomUUID } from 'node:crypto'
|
|
3
|
+
import {
|
|
4
|
+
EventCategory,
|
|
5
|
+
type BulkUpdateItem,
|
|
6
|
+
type BulkUpdateState,
|
|
7
|
+
type BulkUpdatePhase,
|
|
8
|
+
type BulkUpdateItemStatus,
|
|
9
|
+
} from '@camstack/types'
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Narrow event-bus interface required by BulkUpdateCoordinator.
|
|
13
|
+
* Intentionally narrower than IEventBus: the coordinator only emits a single
|
|
14
|
+
* topic, so we avoid importing the full IEventBus (which lives in @camstack/types
|
|
15
|
+
* and would pull in all event-catalog types). The full IEventBus satisfies this
|
|
16
|
+
* interface structurally, so wiring in cap-providers.ts is cast-free.
|
|
17
|
+
*/
|
|
18
|
+
export interface IBulkUpdateEventBus {
|
|
19
|
+
emit(category: EventCategory.AddonsBulkUpdateProgress, payload: BulkUpdateState): void
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface IBulkUpdateLogger {
|
|
23
|
+
info(message: string, ...args: unknown[]): void
|
|
24
|
+
warn(message: string, ...args: unknown[]): void
|
|
25
|
+
error(message: string, ...args: unknown[]): void
|
|
26
|
+
debug(message: string, ...args: unknown[]): void
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface BulkUpdateCoordinatorDeps {
|
|
30
|
+
readonly eventBus: IBulkUpdateEventBus
|
|
31
|
+
readonly updateAddon: (input: { name: string; version: string }) => Promise<void>
|
|
32
|
+
readonly updateFrameworkPackage: (input: { packageName: string; version: string; deferRestart: boolean }) => Promise<void>
|
|
33
|
+
readonly restartServer: (input: { confirm: true }) => Promise<void>
|
|
34
|
+
readonly logger: IBulkUpdateLogger
|
|
35
|
+
/** Injectable clock for tests. Default: `() => Date.now()`. */
|
|
36
|
+
readonly clock?: () => number
|
|
37
|
+
/** How long to keep completed state before purging. Default: 5 minutes. */
|
|
38
|
+
readonly cleanupAfterMs?: number
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface StartBulkUpdateInput {
|
|
42
|
+
readonly nodeId: string
|
|
43
|
+
readonly items: readonly { name: string; version: string; isSystem: boolean }[]
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const DEFAULT_CLEANUP_AFTER_MS = 5 * 60 * 1_000
|
|
47
|
+
|
|
48
|
+
export class BulkUpdateCoordinator {
|
|
49
|
+
private readonly states = new Map<string, BulkUpdateState>()
|
|
50
|
+
private readonly cancelFlags = new Map<string, { cancelled: boolean }>()
|
|
51
|
+
/**
|
|
52
|
+
* Tracks wall-clock time (ms) when each bulk completed. Used for lazy
|
|
53
|
+
* cleanup in `get()` — avoids scheduling a fake-timer `setTimeout` that
|
|
54
|
+
* would be eagerly fired by `vi.runAllTimersAsync()` in tests.
|
|
55
|
+
*/
|
|
56
|
+
private readonly completedWallMs = new Map<string, number>()
|
|
57
|
+
/** Tracks which nodeIds currently have an active (non-completed) bulk update. */
|
|
58
|
+
private readonly activeNodeIds = new Set<string>()
|
|
59
|
+
|
|
60
|
+
private readonly now: () => number
|
|
61
|
+
private readonly cleanupAfterMs: number
|
|
62
|
+
/** Wall-clock source. Fake timers intercept `Date.now`, so tests can advance via `advanceTimersByTimeAsync`. */
|
|
63
|
+
private readonly wallNow: () => number
|
|
64
|
+
|
|
65
|
+
constructor(private readonly deps: BulkUpdateCoordinatorDeps) {
|
|
66
|
+
this.now = deps.clock ?? (() => Date.now())
|
|
67
|
+
this.cleanupAfterMs = deps.cleanupAfterMs ?? DEFAULT_CLEANUP_AFTER_MS
|
|
68
|
+
this.wallNow = () => Date.now()
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// ── Public API ────────────────────────────────────────────────────
|
|
72
|
+
|
|
73
|
+
start(input: StartBulkUpdateInput): { id: string } {
|
|
74
|
+
if (this.activeNodeIds.has(input.nodeId)) {
|
|
75
|
+
throw new Error(`Bulk update already in progress for node ${input.nodeId}`)
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const id = randomUUID()
|
|
79
|
+
|
|
80
|
+
const items: BulkUpdateItem[] = input.items.map(i => ({
|
|
81
|
+
name: i.name,
|
|
82
|
+
isSystem: i.isSystem,
|
|
83
|
+
// fromVersion: the cap interface receives name+version+isSystem only;
|
|
84
|
+
// the caller (cap-providers.ts) may enrich this with the current version
|
|
85
|
+
// if available. Empty string is acceptable per plan spec.
|
|
86
|
+
fromVersion: '',
|
|
87
|
+
toVersion: i.version,
|
|
88
|
+
status: 'queued' as BulkUpdateItemStatus,
|
|
89
|
+
}))
|
|
90
|
+
|
|
91
|
+
const state: BulkUpdateState = {
|
|
92
|
+
id,
|
|
93
|
+
nodeId: input.nodeId,
|
|
94
|
+
startedAtMs: this.now(),
|
|
95
|
+
total: items.length,
|
|
96
|
+
completed: 0,
|
|
97
|
+
failed: 0,
|
|
98
|
+
current: null,
|
|
99
|
+
phase: 'regular',
|
|
100
|
+
cancelled: false,
|
|
101
|
+
items,
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
this.states.set(id, state)
|
|
105
|
+
this.activeNodeIds.add(input.nodeId)
|
|
106
|
+
|
|
107
|
+
const cancelFlag = { cancelled: false }
|
|
108
|
+
this.cancelFlags.set(id, cancelFlag)
|
|
109
|
+
|
|
110
|
+
// Emit initial state so clients see the bulk as started immediately
|
|
111
|
+
this.emit(state)
|
|
112
|
+
|
|
113
|
+
void this.runLoop(id, cancelFlag).catch(err => {
|
|
114
|
+
this.deps.logger.error('BulkUpdateCoordinator: loop crashed unexpectedly', err)
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
return { id }
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
get(id: string): BulkUpdateState | null {
|
|
121
|
+
const state = this.states.get(id)
|
|
122
|
+
if (state === undefined) return null
|
|
123
|
+
// Lazy cleanup: purge if the wall-clock elapsed since completion exceeds threshold.
|
|
124
|
+
// This avoids scheduling a long-lived setTimeout that would be eagerly fired
|
|
125
|
+
// by vi.runAllTimersAsync() in tests.
|
|
126
|
+
const completedWall = this.completedWallMs.get(id)
|
|
127
|
+
if (completedWall !== undefined && this.wallNow() - completedWall >= this.cleanupAfterMs) {
|
|
128
|
+
this.purge(id)
|
|
129
|
+
return null
|
|
130
|
+
}
|
|
131
|
+
return state
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
list(nodeId?: string): readonly BulkUpdateState[] {
|
|
135
|
+
const all = [...this.states.keys()]
|
|
136
|
+
.map(id => this.get(id)) // get() applies lazy-cleanup
|
|
137
|
+
.filter((s): s is BulkUpdateState => s !== null)
|
|
138
|
+
return nodeId === undefined ? all : all.filter(s => s.nodeId === nodeId)
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
cancel(id: string): { cancelled: boolean } {
|
|
142
|
+
const state = this.states.get(id)
|
|
143
|
+
const flag = this.cancelFlags.get(id)
|
|
144
|
+
if (state === undefined || flag === undefined) return { cancelled: false }
|
|
145
|
+
// Once restarting, the hub restart is committed — cancel has no effect.
|
|
146
|
+
if (state.phase === 'restarting') return { cancelled: false }
|
|
147
|
+
// Already completed.
|
|
148
|
+
if (state.completedAtMs !== undefined) return { cancelled: false }
|
|
149
|
+
|
|
150
|
+
flag.cancelled = true
|
|
151
|
+
this.mutate(id, s => ({ ...s, cancelled: true }))
|
|
152
|
+
return { cancelled: true }
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// ── Internal loop ─────────────────────────────────────────────────
|
|
156
|
+
|
|
157
|
+
private async runLoop(id: string, cancelFlag: { cancelled: boolean }): Promise<void> {
|
|
158
|
+
const initial = this.states.get(id)!
|
|
159
|
+
|
|
160
|
+
// ── Phase 1: regular addons ──────────────────────────────────────
|
|
161
|
+
this.transitionPhase(id, 'regular')
|
|
162
|
+
|
|
163
|
+
for (const item of initial.items.filter(i => !i.isSystem)) {
|
|
164
|
+
if (cancelFlag.cancelled) break
|
|
165
|
+
await this.processItem(id, item, false)
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// ── Phase 2: system packages (deferRestart: true) ────────────────
|
|
169
|
+
if (!cancelFlag.cancelled && initial.items.some(i => i.isSystem)) {
|
|
170
|
+
this.transitionPhase(id, 'system')
|
|
171
|
+
|
|
172
|
+
for (const item of initial.items.filter(i => i.isSystem)) {
|
|
173
|
+
if (cancelFlag.cancelled) break
|
|
174
|
+
await this.processItem(id, item, true)
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// ── Phase 3: single restart ──────────────────────────────────
|
|
178
|
+
const anySystemPendingRestart = this.states.get(id)!.items.some(
|
|
179
|
+
i => i.isSystem && i.status === 'done-pending-restart',
|
|
180
|
+
)
|
|
181
|
+
|
|
182
|
+
if (anySystemPendingRestart && !cancelFlag.cancelled) {
|
|
183
|
+
this.transitionPhase(id, 'restarting')
|
|
184
|
+
try {
|
|
185
|
+
await this.deps.restartServer({ confirm: true })
|
|
186
|
+
// NOTE: In production, restartServer kills+respawns the hub process.
|
|
187
|
+
// Code below this point will not execute in that scenario.
|
|
188
|
+
// If the mock/stub returns (e.g. in tests), we fall through to finalizing.
|
|
189
|
+
} catch (err) {
|
|
190
|
+
// Restart failed but the npm installs already completed. Promote all
|
|
191
|
+
// done-pending-restart items to done with a caveat error so the UI
|
|
192
|
+
// can inform the user that a manual restart is needed.
|
|
193
|
+
this.deps.logger.error('BulkUpdateCoordinator: restart failed', err)
|
|
194
|
+
const errMsg = err instanceof Error ? err.message : String(err)
|
|
195
|
+
for (const it of this.states.get(id)!.items) {
|
|
196
|
+
if (it.status === 'done-pending-restart') {
|
|
197
|
+
this.setItemStatus(id, it.name, 'done', {
|
|
198
|
+
error: `Restart failed; manual restart required (${errMsg})`,
|
|
199
|
+
})
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// ── Phase 4: finalize ────────────────────────────────────────────
|
|
207
|
+
// Reached when:
|
|
208
|
+
// a) no system packages at all, OR
|
|
209
|
+
// b) restart failed (process continued), OR
|
|
210
|
+
// c) cancelled before the restart phase.
|
|
211
|
+
this.transitionPhase(id, 'finalizing')
|
|
212
|
+
this.completeBulk(id)
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
private async processItem(id: string, item: BulkUpdateItem, isSystem: boolean): Promise<void> {
|
|
216
|
+
this.setItemStatus(id, item.name, 'updating', { startedAtMs: this.now() })
|
|
217
|
+
this.mutate(id, s => ({ ...s, current: item.name }))
|
|
218
|
+
this.emit(this.states.get(id)!)
|
|
219
|
+
|
|
220
|
+
try {
|
|
221
|
+
if (isSystem) {
|
|
222
|
+
await this.deps.updateFrameworkPackage({
|
|
223
|
+
packageName: item.name,
|
|
224
|
+
version: item.toVersion,
|
|
225
|
+
deferRestart: true,
|
|
226
|
+
})
|
|
227
|
+
this.setItemStatus(id, item.name, 'done-pending-restart', { completedAtMs: this.now() })
|
|
228
|
+
} else {
|
|
229
|
+
await this.deps.updateAddon({ name: item.name, version: item.toVersion })
|
|
230
|
+
this.setItemStatus(id, item.name, 'done', { completedAtMs: this.now() })
|
|
231
|
+
}
|
|
232
|
+
} catch (err) {
|
|
233
|
+
const msg = err instanceof Error ? err.message : String(err)
|
|
234
|
+
this.setItemStatus(id, item.name, 'failed', { error: msg, completedAtMs: this.now() })
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
this.mutate(id, s => ({ ...s, current: null }))
|
|
238
|
+
this.emit(this.states.get(id)!)
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// ── State mutation helpers ────────────────────────────────────────
|
|
242
|
+
|
|
243
|
+
private setItemStatus(
|
|
244
|
+
id: string,
|
|
245
|
+
name: string,
|
|
246
|
+
status: BulkUpdateItemStatus,
|
|
247
|
+
fields: { error?: string; startedAtMs?: number; completedAtMs?: number } = {},
|
|
248
|
+
): void {
|
|
249
|
+
this.mutate(id, s => {
|
|
250
|
+
const items = s.items.map(it =>
|
|
251
|
+
it.name === name ? { ...it, status, ...fields } : it,
|
|
252
|
+
)
|
|
253
|
+
// completed = all terminal states: done | done-pending-restart | failed
|
|
254
|
+
const completed = items.filter(
|
|
255
|
+
it => it.status === 'done' || it.status === 'done-pending-restart' || it.status === 'failed',
|
|
256
|
+
).length
|
|
257
|
+
const failed = items.filter(it => it.status === 'failed').length
|
|
258
|
+
return { ...s, items, completed, failed }
|
|
259
|
+
})
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
private transitionPhase(id: string, phase: BulkUpdatePhase): void {
|
|
263
|
+
this.mutate(id, s => ({ ...s, phase }))
|
|
264
|
+
this.emit(this.states.get(id)!)
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
private completeBulk(id: string): void {
|
|
268
|
+
this.mutate(id, s => ({ ...s, completedAtMs: this.now(), current: null }))
|
|
269
|
+
this.emit(this.states.get(id)!)
|
|
270
|
+
|
|
271
|
+
// Free the nodeId slot so a new bulk for the same node can be started
|
|
272
|
+
const nodeId = this.states.get(id)!.nodeId
|
|
273
|
+
this.activeNodeIds.delete(nodeId)
|
|
274
|
+
|
|
275
|
+
// Record wall-clock completion time for lazy cleanup in `get()`.
|
|
276
|
+
// We intentionally avoid scheduling a setTimeout here: a long-lived
|
|
277
|
+
// setTimeout (5 min) would be eagerly fired by vi.runAllTimersAsync()
|
|
278
|
+
// in tests, causing `get()` to return null immediately after the run.
|
|
279
|
+
// Instead, `get()` lazily checks whether the cleanup threshold has
|
|
280
|
+
// elapsed using Date.now() — which fake timers DO advance via
|
|
281
|
+
// advanceTimersByTimeAsync(), making the cleanup testable without
|
|
282
|
+
// a long-running timer.
|
|
283
|
+
this.completedWallMs.set(id, this.wallNow())
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
private purge(id: string): void {
|
|
287
|
+
this.states.delete(id)
|
|
288
|
+
this.cancelFlags.delete(id)
|
|
289
|
+
this.completedWallMs.delete(id)
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/** Immutably update the state for the given id. No-op if id is unknown. */
|
|
293
|
+
private mutate(id: string, update: (s: BulkUpdateState) => BulkUpdateState): void {
|
|
294
|
+
const current = this.states.get(id)
|
|
295
|
+
if (current === undefined) return
|
|
296
|
+
this.states.set(id, update(current))
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
private emit(state: BulkUpdateState): void {
|
|
300
|
+
this.deps.eventBus.emit(EventCategory.AddonsBulkUpdateProgress, state)
|
|
301
|
+
}
|
|
302
|
+
}
|
|
@@ -41,6 +41,7 @@ import type {
|
|
|
41
41
|
Integration,
|
|
42
42
|
IIntegrationRegistry,
|
|
43
43
|
IDeviceProvider,
|
|
44
|
+
IBrokerProvider,
|
|
44
45
|
CapabilityMethodAuth,
|
|
45
46
|
} from '@camstack/types'
|
|
46
47
|
import { asJsonObject, asJsonArray, errMsg } from '@camstack/types'
|
|
@@ -57,7 +58,10 @@ import type { AddonRegistryService } from '../../core/addon/addon-registry.servi
|
|
|
57
58
|
import type { AddonPackageService } from '../../core/addon/addon-package.service'
|
|
58
59
|
import type { NetworkQualityService } from '../../core/network/network-quality.service'
|
|
59
60
|
import type { ConfigService } from '../../core/config/config.service'
|
|
61
|
+
import { planDeleteTimeStamps } from '../../boot/integration-id-backfill'
|
|
60
62
|
import { persistCollectionDisabled } from './collection-preference.js'
|
|
63
|
+
import { BulkUpdateCoordinator } from './bulk-update-coordinator.js'
|
|
64
|
+
import { FRAMEWORK_PACKAGE_ALLOWLIST } from '../../core/addon/addon-package.service.js'
|
|
61
65
|
|
|
62
66
|
const execFileAsync = promisify(execFile)
|
|
63
67
|
|
|
@@ -114,6 +118,7 @@ export function buildNetworkQualityProvider(
|
|
|
114
118
|
rttMs: input.rttMs,
|
|
115
119
|
jitterMs: input.jitterMs,
|
|
116
120
|
estimatedBandwidthKbps: input.estimatedBandwidthKbps,
|
|
121
|
+
packetLossPercent: input.packetLossPercent,
|
|
117
122
|
})
|
|
118
123
|
},
|
|
119
124
|
}
|
|
@@ -477,9 +482,17 @@ export function buildNodesProvider(
|
|
|
477
482
|
return reg.getGraph({ windowSeconds: input.windowSeconds, nowMs: Date.now() })
|
|
478
483
|
},
|
|
479
484
|
setProcessLogLevel: async (input) => {
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
485
|
+
// E2: for hub-local children, try the UDS path first (fire-and-forget);
|
|
486
|
+
// fall back to the Moleculer $node-mgmt.setLogLevel action for remote
|
|
487
|
+
// nodes (agents) that still run a per-node broker. The UDS path is always
|
|
488
|
+
// attempted for hub/<runner> nodeIds even when the Moleculer path would
|
|
489
|
+
// also work — both are safe to run in parallel during Phase E.
|
|
490
|
+
const reachedViaUds = moleculer.setChildLogLevelByNodeId(input.nodeId, input.level)
|
|
491
|
+
if (!reachedViaUds) {
|
|
492
|
+
await broker.call('$node-mgmt.setLogLevel', {
|
|
493
|
+
level: input.level,
|
|
494
|
+
}, { nodeID: input.nodeId, timeout: 5_000 })
|
|
495
|
+
}
|
|
483
496
|
return { success: true }
|
|
484
497
|
},
|
|
485
498
|
executeQuery: async (input) => {
|
|
@@ -515,10 +528,27 @@ function getDeviceProvider(ar: AddonRegistryService, addonId: string): IDevicePr
|
|
|
515
528
|
return isDeviceProvider(provider) ? provider : null
|
|
516
529
|
}
|
|
517
530
|
|
|
531
|
+
/**
|
|
532
|
+
* Marker caps that flag an addon as a creatable integration type:
|
|
533
|
+
* - `device-provider` — classic providers (Reolink/ONVIF/Frigate)
|
|
534
|
+
* that expose `createDevice` + `discoverDevices` via their
|
|
535
|
+
* device-provider cap.
|
|
536
|
+
* - `device-adoption` — integration-style providers (Home Assistant
|
|
537
|
+
* and future siblings) that materialise devices via a generic
|
|
538
|
+
* adoption cap instead of a manual create-form. The picker treats
|
|
539
|
+
* them the same way; the wizard's discovery step routes through the
|
|
540
|
+
* specific cap based on the addon's declared surface.
|
|
541
|
+
*
|
|
542
|
+
* Exported so the integration-markers spec can assert the recognised set
|
|
543
|
+
* without booting the whole provider factory.
|
|
544
|
+
*/
|
|
545
|
+
export const INTEGRATION_CAP_MARKERS = new Set(['device-provider', 'device-adoption'])
|
|
546
|
+
|
|
518
547
|
export function buildIntegrationsProvider(
|
|
519
548
|
ar: AddonRegistryService,
|
|
520
549
|
eb: EventBusService,
|
|
521
550
|
loggingService: LoggingService,
|
|
551
|
+
capabilityRegistry: CapabilityRegistry | null,
|
|
522
552
|
): IIntegrationsProvider {
|
|
523
553
|
const logger = loggingService.createLogger('integrations')
|
|
524
554
|
const withProcessState = (i: Integration): IntegrationWithProcessState => ({
|
|
@@ -645,6 +675,77 @@ export function buildIntegrationsProvider(
|
|
|
645
675
|
'removing',
|
|
646
676
|
{ tags: { integrationId: input.id, addonId: integration.addonId }, meta: { phase: 'delete', name: integration.name } },
|
|
647
677
|
)
|
|
678
|
+
|
|
679
|
+
// Cascade-delete every live device whose integrationId matches.
|
|
680
|
+
// Best-effort: a device-removal hiccup must not abort the integration
|
|
681
|
+
// delete — log a warning and continue so the record + event always fire.
|
|
682
|
+
const dm = capabilityRegistry?.getSingleton<{
|
|
683
|
+
removeByIntegration?: (input: { integrationId: string }) => Promise<{ removed: number }>
|
|
684
|
+
listAll?: (input: Record<string, never>) => Promise<readonly {
|
|
685
|
+
id: number; addonId: string; parentDeviceId: number | null; integrationId?: string
|
|
686
|
+
}[]>
|
|
687
|
+
setIntegrationId?: (input: { deviceId: number; integrationId: string }) => Promise<void>
|
|
688
|
+
}>('device-manager') ?? null
|
|
689
|
+
|
|
690
|
+
// Claim legacy un-tagged devices BEFORE the cascade. Devices created
|
|
691
|
+
// before stamping (or whose provider never stamps, e.g. `provider-rtsp`)
|
|
692
|
+
// carry no integrationId, so `removeByIntegration` (which matches on
|
|
693
|
+
// integrationId) would leave them orphaned forever once their integration
|
|
694
|
+
// is gone. While the integration record still exists, stamp the
|
|
695
|
+
// unambiguous ones (addons hosting exactly one integration) so the cascade
|
|
696
|
+
// below removes them too. Best-effort: never abort the delete.
|
|
697
|
+
if (dm?.listAll && dm?.setIntegrationId) {
|
|
698
|
+
try {
|
|
699
|
+
const [integrations, devices] = await Promise.all([
|
|
700
|
+
reg.listIntegrations(),
|
|
701
|
+
dm.listAll({}),
|
|
702
|
+
])
|
|
703
|
+
const stamps = planDeleteTimeStamps(
|
|
704
|
+
input.id,
|
|
705
|
+
integrations.map((i) => ({ id: i.id, addonId: i.addonId })),
|
|
706
|
+
devices.map((d) => ({
|
|
707
|
+
id: d.id,
|
|
708
|
+
addonId: d.addonId,
|
|
709
|
+
parentDeviceId: d.parentDeviceId,
|
|
710
|
+
...(d.integrationId !== undefined ? { integrationId: d.integrationId } : {}),
|
|
711
|
+
})),
|
|
712
|
+
)
|
|
713
|
+
for (const stamp of stamps) {
|
|
714
|
+
await dm.setIntegrationId({ deviceId: stamp.deviceId, integrationId: stamp.integrationId })
|
|
715
|
+
}
|
|
716
|
+
if (stamps.length > 0) {
|
|
717
|
+
logger.info('claimed legacy un-tagged devices for cascade', {
|
|
718
|
+
tags: { integrationId: input.id, addonId: integration.addonId },
|
|
719
|
+
meta: { phase: 'delete', claimed: stamps.length },
|
|
720
|
+
})
|
|
721
|
+
}
|
|
722
|
+
} catch (err) {
|
|
723
|
+
logger.warn('legacy device claim failed (best-effort — continuing)', {
|
|
724
|
+
tags: { integrationId: input.id }, meta: { phase: 'delete', error: errMsg(err) },
|
|
725
|
+
})
|
|
726
|
+
}
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
if (dm?.removeByIntegration) {
|
|
730
|
+
try {
|
|
731
|
+
const result = await dm.removeByIntegration({ integrationId: input.id })
|
|
732
|
+
logger.info(
|
|
733
|
+
'cascade-removed devices',
|
|
734
|
+
{ tags: { integrationId: input.id }, meta: { phase: 'delete', removed: result.removed } },
|
|
735
|
+
)
|
|
736
|
+
} catch (err) {
|
|
737
|
+
logger.warn(
|
|
738
|
+
'device cascade-remove failed (best-effort — continuing)',
|
|
739
|
+
{ tags: { integrationId: input.id }, meta: { phase: 'delete', error: errMsg(err) } },
|
|
740
|
+
)
|
|
741
|
+
}
|
|
742
|
+
} else {
|
|
743
|
+
logger.warn(
|
|
744
|
+
'device-manager not available — skipping cascade device removal',
|
|
745
|
+
{ tags: { integrationId: input.id }, meta: { phase: 'delete' } },
|
|
746
|
+
)
|
|
747
|
+
}
|
|
748
|
+
|
|
648
749
|
await reg.deleteIntegration(input.id)
|
|
649
750
|
|
|
650
751
|
eb.emit({
|
|
@@ -694,11 +795,16 @@ export function buildIntegrationsProvider(
|
|
|
694
795
|
// integration against an addon that didn't load produces an orphaned
|
|
695
796
|
// row that `createFilteredRegistry` then filters out — silent data
|
|
696
797
|
// loss from the operator's POV. Filter at the source instead.
|
|
798
|
+
//
|
|
799
|
+
// Markers that flag an addon as a creatable integration type live
|
|
800
|
+
// in the module-level `INTEGRATION_CAP_MARKERS` set (exported so the
|
|
801
|
+
// integration-markers spec can assert the recognised caps).
|
|
697
802
|
const providerAddons = addons.filter(a =>
|
|
698
803
|
a.process?.state !== 'failed' &&
|
|
699
|
-
a.manifest.capabilities?.some(c =>
|
|
700
|
-
typeof c === 'string' ? c
|
|
701
|
-
|
|
804
|
+
a.manifest.capabilities?.some(c => {
|
|
805
|
+
const name = typeof c === 'string' ? c : c.name
|
|
806
|
+
return typeof name === 'string' && INTEGRATION_CAP_MARKERS.has(name)
|
|
807
|
+
}),
|
|
702
808
|
)
|
|
703
809
|
const integrations = await reg.listIntegrations()
|
|
704
810
|
return providerAddons.map(addon => {
|
|
@@ -710,6 +816,29 @@ export function buildIntegrationsProvider(
|
|
|
710
816
|
const existing = integrations.filter(i => i.addonId === m.id)
|
|
711
817
|
const provider = getDeviceProvider(ar, m.id)
|
|
712
818
|
const discoveryMode = provider?.discoveryMode ?? 'manual'
|
|
819
|
+
|
|
820
|
+
// Branch by CAP, not by addon name. Surface which integration-marker
|
|
821
|
+
// cap the addon declared so the wizard routes `device-adoption`
|
|
822
|
+
// (Approach A: pick/create a broker, store `{ brokerId }`) vs the
|
|
823
|
+
// legacy `device-provider` config → discovery flow. A `device-adoption`
|
|
824
|
+
// marker wins when both are present (an integration-style addon may
|
|
825
|
+
// also expose a `device-provider` shim); the broker step is the
|
|
826
|
+
// intended entry point for it.
|
|
827
|
+
const capNames = (m.capabilities ?? []).map(c => (typeof c === 'string' ? c : c.name))
|
|
828
|
+
const kind: 'device-adoption' | 'device-provider' =
|
|
829
|
+
capNames.includes('device-adoption') ? 'device-adoption' : 'device-provider'
|
|
830
|
+
|
|
831
|
+
// For device-adoption addons, the broker kind to create/link comes
|
|
832
|
+
// from the addon manifest (`brokerKind`). Null for device-provider
|
|
833
|
+
// addons, which carry no broker.
|
|
834
|
+
const brokerKind = kind === 'device-adoption'
|
|
835
|
+
? (d?.brokerKind ?? m.brokerKind ?? null)
|
|
836
|
+
: null
|
|
837
|
+
|
|
838
|
+
const supportsLocationImport = kind === 'device-adoption'
|
|
839
|
+
? (d?.supportsLocationImport ?? m.supportsLocationImport ?? false)
|
|
840
|
+
: false
|
|
841
|
+
|
|
713
842
|
return {
|
|
714
843
|
addonId: m.id,
|
|
715
844
|
name: m.name ?? m.id,
|
|
@@ -718,6 +847,9 @@ export function buildIntegrationsProvider(
|
|
|
718
847
|
color,
|
|
719
848
|
instanceMode,
|
|
720
849
|
discoveryMode,
|
|
850
|
+
kind,
|
|
851
|
+
brokerKind,
|
|
852
|
+
supportsLocationImport,
|
|
721
853
|
existingInstances: existing.map(i => ({
|
|
722
854
|
id: i.id,
|
|
723
855
|
name: i.name,
|
|
@@ -727,6 +859,33 @@ export function buildIntegrationsProvider(
|
|
|
727
859
|
})
|
|
728
860
|
},
|
|
729
861
|
testConnection: async (input) => {
|
|
862
|
+
// Broker-backed integrations (Approach A) carry their connection
|
|
863
|
+
// identity as a `brokerId` in settings — testing is a broker
|
|
864
|
+
// concern now, so delegate to the addon's `broker` cap. The broker
|
|
865
|
+
// already owns the real semantic check (HA opens a temporary WS
|
|
866
|
+
// handshake; MQTT pings the bridge). We translate the broker's
|
|
867
|
+
// discriminated result (`{ok:true,latencyMs}|{ok:false,error}`) into
|
|
868
|
+
// the integrations `{success, error?}` output shape. Falls back to
|
|
869
|
+
// the default RTSP/ffprobe path below for legacy device-provider
|
|
870
|
+
// addons (Reolink/Frigate/ONVIF) that probe a stream URL.
|
|
871
|
+
const registry = ar.getCapabilityRegistry()
|
|
872
|
+
const brokerId = input.settings['brokerId']
|
|
873
|
+
if (typeof brokerId === 'string' && brokerId.length > 0) {
|
|
874
|
+
const brokerProvider = registry.getProviderByAddonId<IBrokerProvider>('broker', input.addonId)
|
|
875
|
+
if (!brokerProvider) {
|
|
876
|
+
return { success: false, error: `Broker provider for addon '${input.addonId}' is not available` }
|
|
877
|
+
}
|
|
878
|
+
try {
|
|
879
|
+
const result = await brokerProvider.testConnection({ id: brokerId })
|
|
880
|
+
return result.ok
|
|
881
|
+
? { success: true }
|
|
882
|
+
: { success: false, error: result.error }
|
|
883
|
+
} catch (err) {
|
|
884
|
+
return { success: false, error: errMsg(err) }
|
|
885
|
+
}
|
|
886
|
+
}
|
|
887
|
+
|
|
888
|
+
// Default — RTSP/Frigate/ONVIF legacy path: probe the stream URL.
|
|
730
889
|
const url = String(
|
|
731
890
|
input.settings['main_stream_url'] ?? input.settings['url'] ?? '',
|
|
732
891
|
).trim()
|
|
@@ -806,8 +965,45 @@ export function buildAddonsProvider(
|
|
|
806
965
|
moleculer: MoleculerService,
|
|
807
966
|
configService: ConfigService,
|
|
808
967
|
ctx: TrpcContext,
|
|
968
|
+
eb: EventBusService,
|
|
809
969
|
): IAddonsProvider {
|
|
810
970
|
const broker = moleculer.broker as unknown as BrokerLike
|
|
971
|
+
|
|
972
|
+
// Adapt the hub EventBusService (which takes a full SystemEvent object) to
|
|
973
|
+
// the IBulkUpdateEventBus interface (which takes (category, payload) pairs).
|
|
974
|
+
// Using `import { EventCategory }` from @camstack/types avoids a new import
|
|
975
|
+
// — it is already resolved in the generated-cap-routers layer above. The
|
|
976
|
+
// `eb.emit` overload that takes a TypedSystemEvent is the type-safe path.
|
|
977
|
+
const bulkEventBus = {
|
|
978
|
+
emit: (category: Parameters<typeof eb.emit>[0]['category'], payload: unknown) => {
|
|
979
|
+
eb.emit({
|
|
980
|
+
id: randomUUID(),
|
|
981
|
+
timestamp: new Date(),
|
|
982
|
+
source: { type: 'core', id: 'bulk-update-coordinator' },
|
|
983
|
+
category,
|
|
984
|
+
data: payload as Record<string, unknown>,
|
|
985
|
+
})
|
|
986
|
+
},
|
|
987
|
+
}
|
|
988
|
+
|
|
989
|
+
const bulkCoordinator = new BulkUpdateCoordinator({
|
|
990
|
+
eventBus: bulkEventBus,
|
|
991
|
+
updateAddon: async (i) => { await ps.updatePackage(i.name, i.version) },
|
|
992
|
+
updateFrameworkPackage: async (i) => {
|
|
993
|
+
await ps.updateFrameworkPackage({
|
|
994
|
+
packageName: i.packageName,
|
|
995
|
+
version: i.version,
|
|
996
|
+
deferRestart: i.deferRestart,
|
|
997
|
+
})
|
|
998
|
+
},
|
|
999
|
+
restartServer: async () => {
|
|
1000
|
+
await ps.restartServer(ctx.user?.username ?? ctx.user?.id)
|
|
1001
|
+
},
|
|
1002
|
+
logger: ls.createLogger('bulk-update'),
|
|
1003
|
+
})
|
|
1004
|
+
|
|
1005
|
+
const frameworkAllowSet = new Set<string>(FRAMEWORK_PACKAGE_ALLOWLIST)
|
|
1006
|
+
|
|
811
1007
|
return {
|
|
812
1008
|
list: async () => {
|
|
813
1009
|
const rollbackable = ps.getRollbackablePackages()
|
|
@@ -843,9 +1039,10 @@ export function buildAddonsProvider(
|
|
|
843
1039
|
},
|
|
844
1040
|
listUpdates: async (input) => {
|
|
845
1041
|
const nodeId = input.nodeId
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
1042
|
+
const updates = nodeId === undefined || isHubNode(nodeId)
|
|
1043
|
+
? await ps.checkUpdates()
|
|
1044
|
+
: await ps.checkUpdatesForInstalled(await fetchAgentInstalledPackages(broker, nodeId))
|
|
1045
|
+
return updates.map(u => ({ ...u, isSystem: frameworkAllowSet.has(u.name) }))
|
|
849
1046
|
},
|
|
850
1047
|
updatePackage: async (input) => {
|
|
851
1048
|
const nodeId = input.nodeId
|
|
@@ -937,6 +1134,7 @@ export function buildAddonsProvider(
|
|
|
937
1134
|
: ctx.user?.id !== undefined
|
|
938
1135
|
? { requestedBy: ctx.user.id }
|
|
939
1136
|
: {}),
|
|
1137
|
+
...(input.deferRestart !== undefined ? { deferRestart: input.deferRestart } : {}),
|
|
940
1138
|
}),
|
|
941
1139
|
getVersions: async (input) => ps.getPackageVersions(input.name),
|
|
942
1140
|
restartAddon: async (input) => ar.restartAddon(input.addonId),
|
|
@@ -955,6 +1153,10 @@ export function buildAddonsProvider(
|
|
|
955
1153
|
}
|
|
956
1154
|
return { success: true as const }
|
|
957
1155
|
},
|
|
1156
|
+
startBulkUpdate: async (input) => bulkCoordinator.start(input),
|
|
1157
|
+
getBulkUpdateState: async ({ id }) => bulkCoordinator.get(id),
|
|
1158
|
+
cancelBulkUpdate: async ({ id }) => bulkCoordinator.cancel(id),
|
|
1159
|
+
listActiveBulkUpdates: async ({ nodeId }) => bulkCoordinator.list(nodeId),
|
|
958
1160
|
custom: async (input) => {
|
|
959
1161
|
const registry = ar.getCustomActionRegistry()
|
|
960
1162
|
const entry = registry.resolve(input.addonId, input.action)
|
|
@@ -28,12 +28,35 @@ export function createCapabilitiesRouter(
|
|
|
28
28
|
}),
|
|
29
29
|
|
|
30
30
|
setActiveSingleton: adminProcedure
|
|
31
|
-
.input(z.object({
|
|
31
|
+
.input(z.object({
|
|
32
|
+
capability: z.string(),
|
|
33
|
+
addonId: z.string(),
|
|
34
|
+
nodeId: z.string().optional(),
|
|
35
|
+
}))
|
|
32
36
|
.output(z.void())
|
|
33
37
|
.mutation(async ({ input }) => {
|
|
34
38
|
if (!registry) throw new Error('Capability registry unavailable')
|
|
35
|
-
await registry.setActiveSingleton(input.capability, input.addonId)
|
|
36
|
-
|
|
39
|
+
await registry.setActiveSingleton(input.capability, input.addonId, input.nodeId)
|
|
40
|
+
if (input.nodeId !== undefined) {
|
|
41
|
+
// Per-node override: persist under the node-qualified key so the boot
|
|
42
|
+
// restorer (`setNodeConfigReader`) replays it on restart.
|
|
43
|
+
config.set(`capabilities.singletonNode.${input.capability}.${input.nodeId}`, input.addonId)
|
|
44
|
+
} else {
|
|
45
|
+
// Cluster-global default: persist to the SAME key the boot restorer reads
|
|
46
|
+
// (addon-registry `setConfigReader` → `capabilities.singleton.<cap>`).
|
|
47
|
+
config.set(`capabilities.singleton.${input.capability}`, input.addonId)
|
|
48
|
+
}
|
|
49
|
+
}),
|
|
50
|
+
|
|
51
|
+
clearSingletonNodeOverride: adminProcedure
|
|
52
|
+
.input(z.object({ capability: z.string(), nodeId: z.string() }))
|
|
53
|
+
.output(z.void())
|
|
54
|
+
.mutation(({ input }) => {
|
|
55
|
+
if (!registry) throw new Error('Capability registry unavailable')
|
|
56
|
+
registry.clearSingletonNodeOverride(input.capability, input.nodeId)
|
|
57
|
+
// Persist the cleared state as null so the boot restorer doesn't re-apply
|
|
58
|
+
// a stale override on restart.
|
|
59
|
+
config.set(`capabilities.singletonNode.${input.capability}.${input.nodeId}`, null)
|
|
37
60
|
}),
|
|
38
61
|
|
|
39
62
|
/**
|
|
@@ -18,6 +18,10 @@ const LogTagsSchema = z.object({
|
|
|
18
18
|
nodeId: z.string().optional(),
|
|
19
19
|
/** Numeric progressive id (or its string form for legacy callers). */
|
|
20
20
|
deviceId: z.union([z.string(), z.number()]).optional(),
|
|
21
|
+
/** Parent container id — set on every accessory child's logs (and on the
|
|
22
|
+
* container's own logs). Filtering by it returns the whole container subtree
|
|
23
|
+
* (container + all children) in one query. */
|
|
24
|
+
containerDeviceId: z.union([z.string(), z.number()]).optional(),
|
|
21
25
|
deviceName: z.string().optional(),
|
|
22
26
|
integrationId: z.string().optional(),
|
|
23
27
|
addonId: z.string().optional(),
|