@camstack/server 1.2.353 → 1.2.355
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/dist/api/capabilities.router.js +10 -20
- package/dist/api/core/capabilities.router.js +10 -20
- package/dist/api/trpc/cap-router-runtime.js +42 -3
- package/dist/api/trpc/device-collection-dispatch.js +484 -0
- package/dist/api/trpc/generated-cap-mounts.js +8 -9
- package/dist/api/trpc/generated-cap-routers.js +11 -2
- package/dist/server-root/index.js +169 -22
- package/package.json +9 -9
|
@@ -205,25 +205,15 @@ function createCapabilitiesRouter(addonRegistry, configService, loggingService)
|
|
|
205
205
|
const overrides = registry.getDeviceOverrides(input.deviceId);
|
|
206
206
|
return Object.fromEntries(overrides);
|
|
207
207
|
}),
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
clearDeviceCollectionFilter: trpc_middleware_1.adminProcedure
|
|
219
|
-
.input(zod_1.z.object({ deviceId: zod_1.z.string(), capability: zod_1.z.string() }))
|
|
220
|
-
.mutation(({ input }) => {
|
|
221
|
-
const registry = addonRegistry.getCapabilityRegistry();
|
|
222
|
-
registry.clearDeviceCollectionFilter(input.deviceId, input.capability);
|
|
223
|
-
logger.info('Device collection filter cleared', {
|
|
224
|
-
tags: { deviceId: Number(input.deviceId) },
|
|
225
|
-
meta: { capability: input.capability },
|
|
226
|
-
});
|
|
227
|
-
}),
|
|
208
|
+
/*
|
|
209
|
+
* `setDeviceCollectionFilter` / `clearDeviceCollectionFilter` were REMOVED
|
|
210
|
+
* here (D556). They wrote an in-memory per-device allow list that nothing
|
|
211
|
+
* persisted, so an operator's pick survived until the next hub restart and
|
|
212
|
+
* then silently reverted — a control that looks like it works, which is the
|
|
213
|
+
* defect D62 exists for. Which sources a camera has is now asked of the ONE
|
|
214
|
+
* authority that already owned a per-camera function switch:
|
|
215
|
+
* `deviceManager.setWrapperActive`. The registry keeps only its projection,
|
|
216
|
+
* written by `device-source-activation.ts` and by nothing else.
|
|
217
|
+
*/
|
|
228
218
|
});
|
|
229
219
|
}
|
|
@@ -114,25 +114,15 @@ function createCapabilitiesRouter(registry, config) {
|
|
|
114
114
|
}
|
|
115
115
|
return result;
|
|
116
116
|
}),
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
registry.setDeviceCollectionFilter(input.deviceId, input.capability, input.addonIds);
|
|
128
|
-
}),
|
|
129
|
-
clearDeviceCollectionFilter: trpc_middleware_js_1.adminProcedure
|
|
130
|
-
.input(zod_1.z.object({ deviceId: zod_1.z.string(), capability: zod_1.z.string() }))
|
|
131
|
-
.output(zod_1.z.void())
|
|
132
|
-
.mutation(({ input }) => {
|
|
133
|
-
if (!registry)
|
|
134
|
-
throw new Error('Capability registry unavailable');
|
|
135
|
-
registry.clearDeviceCollectionFilter(input.deviceId, input.capability);
|
|
136
|
-
}),
|
|
117
|
+
/*
|
|
118
|
+
* `setDeviceCollectionFilter` / `clearDeviceCollectionFilter` were REMOVED
|
|
119
|
+
* here (D556). They wrote an in-memory per-device allow list that nothing
|
|
120
|
+
* persisted, so an operator's pick survived until the next hub restart and
|
|
121
|
+
* then silently reverted — a control that looks like it works, which is the
|
|
122
|
+
* defect D62 exists for. Which sources a camera has is now asked of the ONE
|
|
123
|
+
* authority that already owned a per-camera function switch:
|
|
124
|
+
* `deviceManager.setWrapperActive`. The registry keeps only its projection,
|
|
125
|
+
* written by `device-source-activation.ts` and by nothing else.
|
|
126
|
+
*/
|
|
137
127
|
});
|
|
138
128
|
}
|
|
@@ -28,8 +28,10 @@ exports.createCapRouterPrimitives = createCapRouterPrimitives;
|
|
|
28
28
|
exports.createCapRouterServices = createCapRouterServices;
|
|
29
29
|
exports.buildRuntimeCapRouters = buildRuntimeCapRouters;
|
|
30
30
|
const system_1 = require("@camstack/system");
|
|
31
|
+
const types_1 = require("@camstack/types");
|
|
31
32
|
const server_1 = require("@trpc/server");
|
|
32
33
|
const cap_mount_helpers_js_1 = require("./cap-mount-helpers.js");
|
|
34
|
+
const device_collection_dispatch_js_1 = require("./device-collection-dispatch.js");
|
|
33
35
|
const trpc_middleware_js_1 = require("./trpc.middleware.js");
|
|
34
36
|
/**
|
|
35
37
|
* Pre-computed set of capName → array-output method names for collection
|
|
@@ -41,7 +43,10 @@ exports.COLLECTION_ARRAY_METHODS = computeArrayMethods();
|
|
|
41
43
|
function computeArrayMethods() {
|
|
42
44
|
const out = new Map();
|
|
43
45
|
for (const def of system_1.ALL_CAPABILITY_DEFINITIONS) {
|
|
44
|
-
|
|
46
|
+
// The effective MOUNT, never `mode`: a device-scoped wrapper collection
|
|
47
|
+
// (`videoclips`, D554) fans over ONE DEVICE's sources through the
|
|
48
|
+
// per-device dispatcher, and must never be fanned globally here.
|
|
49
|
+
if ((0, types_1.resolveCapMount)(def).kind !== 'collection')
|
|
45
50
|
continue;
|
|
46
51
|
const names = [];
|
|
47
52
|
for (const [name, schema] of Object.entries(def.methods)) {
|
|
@@ -56,6 +61,22 @@ function computeArrayMethods() {
|
|
|
56
61
|
}
|
|
57
62
|
return out;
|
|
58
63
|
}
|
|
64
|
+
/**
|
|
65
|
+
* Is this cap served by the GLOBAL collection mount — every registered
|
|
66
|
+
* provider, for every caller?
|
|
67
|
+
*
|
|
68
|
+
* `mode: 'collection'` alone does not answer it any more: a device-scoped
|
|
69
|
+
* wrapper collection keeps the per-device mount (D554,
|
|
70
|
+
* `device-collection-dispatch.ts`), and fanning it globally here would answer
|
|
71
|
+
* one camera with another camera's sources on the `ctx.api` path while the
|
|
72
|
+
* tRPC path resolved per device — two authorities disagreeing about the same
|
|
73
|
+
* call, which is exactly what {@link aggregateCollectionCall} exists to
|
|
74
|
+
* prevent.
|
|
75
|
+
*/
|
|
76
|
+
function isGlobalCollection(reg, capName) {
|
|
77
|
+
const def = reg.getDefinition(capName);
|
|
78
|
+
return def !== undefined && (0, types_1.resolveCapMount)(def).kind === 'collection';
|
|
79
|
+
}
|
|
59
80
|
/**
|
|
60
81
|
* Resolve a collection cap's provider exactly as the codegen-emitted
|
|
61
82
|
* mount did:
|
|
@@ -108,7 +129,7 @@ function resolveCollectionProvider(reg, capName, addonId) {
|
|
|
108
129
|
async function aggregateCollectionCall(reg, capName, method, args) {
|
|
109
130
|
if (!reg)
|
|
110
131
|
return null;
|
|
111
|
-
if (reg
|
|
132
|
+
if (!isGlobalCollection(reg, capName))
|
|
112
133
|
return null;
|
|
113
134
|
if (!exports.COLLECTION_ARRAY_METHODS.get(capName)?.includes(method))
|
|
114
135
|
return null;
|
|
@@ -141,7 +162,7 @@ async function aggregateCollectionCall(reg, capName, method, args) {
|
|
|
141
162
|
function addonPinOf(reg, capName, method, args) {
|
|
142
163
|
if (!reg)
|
|
143
164
|
return null;
|
|
144
|
-
if (reg
|
|
165
|
+
if (!isGlobalCollection(reg, capName))
|
|
145
166
|
return null;
|
|
146
167
|
if (exports.COLLECTION_ARRAY_METHODS.get(capName)?.includes(method) === true)
|
|
147
168
|
return null;
|
|
@@ -258,8 +279,26 @@ function createCapRouterServices(deps) {
|
|
|
258
279
|
const factory = deps.serverProviders[capName];
|
|
259
280
|
return factory ? factory(ctx) : null;
|
|
260
281
|
}
|
|
282
|
+
// Both per-device mounts go through the SAME dispatcher. It resolves
|
|
283
|
+
// `getNativeProvider` first (the provider IS the device) and falls
|
|
284
|
+
// through to the D13 resolver `getProviderForDevice` — which is what a
|
|
285
|
+
// `device-scoped` WRAPPER needs: the per-device binding
|
|
286
|
+
// (`setWrapperActive` → `setDeviceOverride`) when there is one, the
|
|
287
|
+
// global active provider when there is not. No second resolver.
|
|
261
288
|
case 'device-native':
|
|
289
|
+
case 'device-scoped': {
|
|
290
|
+
// A device-scoped cap whose mode is `collection` has SEVERAL providers
|
|
291
|
+
// per device — sources listed beside each other (D549 decision 2,
|
|
292
|
+
// D554). `requireDeviceScoped` resolves exactly one of them, and on a
|
|
293
|
+
// collection cap `getProviderForDevice` answers with whichever addon
|
|
294
|
+
// registered FIRST: every camera served by one source, silently. The
|
|
295
|
+
// collection twin fans the array methods over the device's sources and
|
|
296
|
+
// routes the scalar ones by the source inside the id.
|
|
297
|
+
if (reg?.getMode(capName) === 'collection') {
|
|
298
|
+
return (0, device_collection_dispatch_js_1.requireDeviceCollection)(reg, capName, deps.logger);
|
|
299
|
+
}
|
|
262
300
|
return (0, cap_mount_helpers_js_1.requireDeviceScoped)(reg, capName);
|
|
301
|
+
}
|
|
263
302
|
case 'collection':
|
|
264
303
|
return resolveCollectionProvider(reg, capName, addonId);
|
|
265
304
|
// singleton / per-node / hub-only resolve the active singleton.
|
|
@@ -0,0 +1,484 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* The per-device COLLECTION dispatcher — a capability whose providers are
|
|
4
|
+
* SOURCES of one device, listed beside each other.
|
|
5
|
+
*
|
|
6
|
+
* `requireDeviceScoped` (`cap-mount-helpers.ts`) resolves ONE provider for a
|
|
7
|
+
* device: the native, else the D13 `getProviderForDevice`. That is right for a
|
|
8
|
+
* cap where a device has one answer. `videoclips` is not that cap any more
|
|
9
|
+
* (D549 decision 2, D554): a camera's clips live in several places at once —
|
|
10
|
+
* the analytics visit log, the camera's own SD card, a hub's storage, HomeKit
|
|
11
|
+
* — and the operator asked for all of them, side by side, never one hidden
|
|
12
|
+
* behind another. `getProviderForDevice` on a collection cap returns the FIRST
|
|
13
|
+
* registered provider, so leaving `videoclips` on it would have answered every
|
|
14
|
+
* camera from whichever addon booted first: the same silent single-answer
|
|
15
|
+
* defect D552 closed, wearing a different hat.
|
|
16
|
+
*
|
|
17
|
+
* The three rules this module implements, and nothing else:
|
|
18
|
+
*
|
|
19
|
+
* 1. **Fan out, per device.** An ARRAY-returning method is called on every
|
|
20
|
+
* source the DEVICE has (`CapabilityRegistry.resolveCollectionForDevice` —
|
|
21
|
+
* the enabled providers, minus the ones the operator switched off for this
|
|
22
|
+
* camera through `deviceManager.setWrapperActive`, D556), and the results
|
|
23
|
+
* are concatenated. No dedup, no best-source
|
|
24
|
+
* pick: a clip held in two storages appears twice, once per source, and
|
|
25
|
+
* each row says which (D549 decision 11 — stated there as a consequence
|
|
26
|
+
* precisely so it is not "fixed" later).
|
|
27
|
+
* 2. **One failing source never empties the others.** Every source is called
|
|
28
|
+
* independently and a rejection removes only its own rows — and says so,
|
|
29
|
+
* per source, with `tags: { deviceId }`. A fan-out where one throwing
|
|
30
|
+
* provider empties the list is worse than no fan-out. For the SOURCE
|
|
31
|
+
* listing itself the failure becomes a row (`unreachable` + the reason):
|
|
32
|
+
* an empty list from a camera nobody could reach reads as "this camera has
|
|
33
|
+
* no recordings", which is the defect this whole line of work is about.
|
|
34
|
+
* 3. **A scalar method routes by the SOURCE inside the id it carries.** Clip
|
|
35
|
+
* ids are self-contained and source-prefixed (`analytics:…`,
|
|
36
|
+
* `native:reolink:onboard:…`, `hksv:…`), so the dispatcher asks each
|
|
37
|
+
* source which ids it owns ({@link SOURCE_LISTING_METHOD}) and hands the
|
|
38
|
+
* call to the one that claims the longest matching prefix. An id nobody
|
|
39
|
+
* claims is REFUSED by name — never handed to the first provider, which is
|
|
40
|
+
* how a fabricated `addonId` once came back with another addon's answer
|
|
41
|
+
* (D552 consequences, `rejectUnresolvedAddonPin`).
|
|
42
|
+
*
|
|
43
|
+
* A device with exactly ONE source skips 1's merge and 3's probe entirely: its
|
|
44
|
+
* rows are returned verbatim in the provider's own order and its scalar calls
|
|
45
|
+
* go straight through. That is not an optimisation — it is the promise that a
|
|
46
|
+
* camera with one provider behaves exactly as it did before this mount
|
|
47
|
+
* existed, and it is pinned by spec.
|
|
48
|
+
*/
|
|
49
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
50
|
+
exports.SOURCE_LISTING_METHOD = void 0;
|
|
51
|
+
exports.requireDeviceCollection = requireDeviceCollection;
|
|
52
|
+
const system_1 = require("@camstack/system");
|
|
53
|
+
const types_1 = require("@camstack/types");
|
|
54
|
+
const server_1 = require("@trpc/server");
|
|
55
|
+
/**
|
|
56
|
+
* The device-collection CONVENTION: a cap whose providers are sources declares
|
|
57
|
+
* this method, taking `{ deviceId }` and returning one row per source with a
|
|
58
|
+
* `source` id and an `availability` block. It is what makes rule 3 possible
|
|
59
|
+
* without a second registry of id prefixes — the provider that mints the ids
|
|
60
|
+
* is the one that names them.
|
|
61
|
+
*/
|
|
62
|
+
exports.SOURCE_LISTING_METHOD = 'listSources';
|
|
63
|
+
/** The field on a source row that carries its id / clip-id namespace. */
|
|
64
|
+
const SOURCE_ID_FIELD = 'source';
|
|
65
|
+
/** The field on a source row that names the addon serving it (D555/D557). */
|
|
66
|
+
const SOURCE_ADDON_FIELD = 'addonId';
|
|
67
|
+
/** The field a source row carries the operator's off switch on (D556 amended). */
|
|
68
|
+
const SOURCE_SWITCHED_OFF_FIELD = 'switchedOff';
|
|
69
|
+
/** The availability field a switched-off row must not carry a value in. */
|
|
70
|
+
const CATALOG_AGE_FIELD = 'catalogAsOf';
|
|
71
|
+
/**
|
|
72
|
+
* EVERY source a DEVICE has, in registration order with the device's native
|
|
73
|
+
* provider first when it has one — each one marked with whether the operator
|
|
74
|
+
* switched it off.
|
|
75
|
+
*
|
|
76
|
+
* `resolveCollectionForDevice` decides which of the registered providers this
|
|
77
|
+
* device may use: it drops disabled ones and ones inactive for the system. The
|
|
78
|
+
* ones the operator switched OFF for this camera are NOT dropped here — they
|
|
79
|
+
* are marked, because a switched-off source must still produce a ROW to be
|
|
80
|
+
* switched back on from, and only its own provider can name it (D554 decision
|
|
81
|
+
* 4; D556 as amended). What that mark costs the source is everything else:
|
|
82
|
+
* {@link requireDeviceCollection} calls it for the source listing and for
|
|
83
|
+
* nothing else. The deny set is the registry's PROJECTION of the persisted
|
|
84
|
+
* wrapper activation — the operator's verb is `deviceManager.setWrapperActive`
|
|
85
|
+
* and the authority is its store, never a second one here (D556).
|
|
86
|
+
*
|
|
87
|
+
* `resolveCollectionForDevice` returns provider objects only, so the addon ids
|
|
88
|
+
* — needed to NAME a source that failed, to stamp the rows, and to apply the
|
|
89
|
+
* addon-grained half of the deny set — come from `getCollectionEntries` by
|
|
90
|
+
* object identity: both come from the same registry map in the same call, so
|
|
91
|
+
* the reference IS the key.
|
|
92
|
+
*/
|
|
93
|
+
function sourcesForDevice(registry, capName, deviceId) {
|
|
94
|
+
const admitted = new Set(registry.resolveCollectionForDevice(capName, String(deviceId), {
|
|
95
|
+
includeSwitchedOff: true,
|
|
96
|
+
}));
|
|
97
|
+
const sources = [];
|
|
98
|
+
const seen = new Set();
|
|
99
|
+
const native = registry.getNativeProvider(capName, deviceId);
|
|
100
|
+
if (native !== null) {
|
|
101
|
+
// The native is NOT in `state.providers`, so `resolveCollectionForDevice`
|
|
102
|
+
// never sees it — it is asked about separately, against the same authority
|
|
103
|
+
// (D556 decision 5). Without this the picker would carry one row the
|
|
104
|
+
// operator cannot switch off, and it is the camera's OWN recordings:
|
|
105
|
+
// exactly the source the external-videoclips line is about.
|
|
106
|
+
const nativeAddonId = registry.getNativeAddonId(capName, deviceId) ?? 'native';
|
|
107
|
+
sources.push({
|
|
108
|
+
addonId: nativeAddonId,
|
|
109
|
+
provider: native,
|
|
110
|
+
switchedOff: registry.isDeviceSourceDisabled(String(deviceId), capName, nativeAddonId),
|
|
111
|
+
});
|
|
112
|
+
seen.add(native);
|
|
113
|
+
}
|
|
114
|
+
for (const [addonId, provider] of registry.getCollectionEntries(capName)) {
|
|
115
|
+
if (!admitted.has(provider) || seen.has(provider))
|
|
116
|
+
continue;
|
|
117
|
+
seen.add(provider);
|
|
118
|
+
sources.push({
|
|
119
|
+
addonId,
|
|
120
|
+
provider,
|
|
121
|
+
switchedOff: registry.isDeviceSourceDisabled(String(deviceId), capName, addonId),
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
return sources;
|
|
125
|
+
}
|
|
126
|
+
/** Is `method` one of the cap's array-returning (fan-out) methods? */
|
|
127
|
+
function isFanOutMethod(registry, capName, method) {
|
|
128
|
+
const def = registry.getDefinition(capName);
|
|
129
|
+
if (def === undefined)
|
|
130
|
+
return false;
|
|
131
|
+
const schema = (0, types_1.expandCapMethods)(def)[method];
|
|
132
|
+
if (schema === undefined || schema.kind === 'subscription')
|
|
133
|
+
return false;
|
|
134
|
+
return (0, types_1.isArrayOutputSchema)(schema.output);
|
|
135
|
+
}
|
|
136
|
+
function errMessage(err) {
|
|
137
|
+
return err instanceof Error ? err.message : String(err);
|
|
138
|
+
}
|
|
139
|
+
/** Call one source, naming the cap, the source and the method on any refusal. */
|
|
140
|
+
async function callSource(source, method, input, capName, deviceId) {
|
|
141
|
+
const fn = source.provider[method];
|
|
142
|
+
if (typeof fn !== 'function') {
|
|
143
|
+
throw new server_1.TRPCError({
|
|
144
|
+
code: 'NOT_IMPLEMENTED',
|
|
145
|
+
message: `Capability "${capName}" source "${source.addonId}" for device ${deviceId} does not implement "${method}"`,
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
return await fn.call(source.provider, input);
|
|
149
|
+
}
|
|
150
|
+
/**
|
|
151
|
+
* The `startMs` a row is ordered by, or null when the row does not declare a
|
|
152
|
+
* time range. A merge across sources is newest-first (D549 decision 2's
|
|
153
|
+
* `(startMs, endMs, id)`); rows that carry no window — a source listing, say —
|
|
154
|
+
* keep the order their providers produced them in.
|
|
155
|
+
*/
|
|
156
|
+
function startMsOf(row) {
|
|
157
|
+
if (row === null || typeof row !== 'object')
|
|
158
|
+
return null;
|
|
159
|
+
const range = Reflect.get(row, 'timeRange');
|
|
160
|
+
if (range === null || typeof range !== 'object')
|
|
161
|
+
return null;
|
|
162
|
+
const startMs = Reflect.get(range, 'startMs');
|
|
163
|
+
return typeof startMs === 'number' ? startMs : null;
|
|
164
|
+
}
|
|
165
|
+
function endMsOf(row) {
|
|
166
|
+
if (row === null || typeof row !== 'object')
|
|
167
|
+
return 0;
|
|
168
|
+
const range = Reflect.get(row, 'timeRange');
|
|
169
|
+
if (range === null || typeof range !== 'object')
|
|
170
|
+
return 0;
|
|
171
|
+
const endMs = Reflect.get(range, 'endMs');
|
|
172
|
+
return typeof endMs === 'number' ? endMs : 0;
|
|
173
|
+
}
|
|
174
|
+
function idOf(row) {
|
|
175
|
+
if (row === null || typeof row !== 'object')
|
|
176
|
+
return '';
|
|
177
|
+
const id = Reflect.get(row, 'id');
|
|
178
|
+
return typeof id === 'string' ? id : '';
|
|
179
|
+
}
|
|
180
|
+
/** Newest-first by `(startMs, endMs, id)`; applied only when EVERY row has a window. */
|
|
181
|
+
function mergeRows(rows) {
|
|
182
|
+
if (rows.some((row) => startMsOf(row) === null))
|
|
183
|
+
return rows;
|
|
184
|
+
return [...rows].sort((a, b) => {
|
|
185
|
+
const byStart = (startMsOf(b) ?? 0) - (startMsOf(a) ?? 0);
|
|
186
|
+
if (byStart !== 0)
|
|
187
|
+
return byStart;
|
|
188
|
+
const byEnd = endMsOf(b) - endMsOf(a);
|
|
189
|
+
if (byEnd !== 0)
|
|
190
|
+
return byEnd;
|
|
191
|
+
return idOf(a).localeCompare(idOf(b));
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
/**
|
|
195
|
+
* The row a source listing gets when the source could not be reached AT ALL.
|
|
196
|
+
*
|
|
197
|
+
* The source id is the addon's, because a provider that threw could not name
|
|
198
|
+
* its own sources — saying "this provider, as a whole, did not answer" is the
|
|
199
|
+
* honest version, and the reason carries the refusal verbatim. Never composed
|
|
200
|
+
* with a provider's output: this row replaces rows that do not exist.
|
|
201
|
+
*/
|
|
202
|
+
function unreachableSourceRow(addonId, reason) {
|
|
203
|
+
return {
|
|
204
|
+
[SOURCE_ID_FIELD]: addonId,
|
|
205
|
+
label: addonId,
|
|
206
|
+
availability: { state: 'unreachable', reason },
|
|
207
|
+
};
|
|
208
|
+
}
|
|
209
|
+
async function fanOut(registry, sources, method, input, capName, deviceId, logger) {
|
|
210
|
+
const isSourceListing = method === exports.SOURCE_LISTING_METHOD;
|
|
211
|
+
const settled = await Promise.allSettled(sources.map(async (source) => await callSource(source, method, input, capName, deviceId)));
|
|
212
|
+
const rows = [];
|
|
213
|
+
for (const [index, result] of settled.entries()) {
|
|
214
|
+
const source = sources[index];
|
|
215
|
+
if (source === undefined)
|
|
216
|
+
continue;
|
|
217
|
+
if (result.status === 'rejected') {
|
|
218
|
+
const reason = errMessage(result.reason);
|
|
219
|
+
// A branch that drops work says so — a fan-out that silently loses a
|
|
220
|
+
// source is indistinguishable from a camera that has none.
|
|
221
|
+
logger?.warn(`${capName}.${method}: source failed — its rows are missing from this answer`, {
|
|
222
|
+
tags: { deviceId, addonId: source.addonId },
|
|
223
|
+
meta: { capability: capName, method, reason },
|
|
224
|
+
});
|
|
225
|
+
if (isSourceListing)
|
|
226
|
+
rows.push(unreachableSourceRow(source.addonId, reason));
|
|
227
|
+
continue;
|
|
228
|
+
}
|
|
229
|
+
if (!Array.isArray(result.value)) {
|
|
230
|
+
logger?.warn(`${capName}.${method}: source answered a non-array — contributing nothing`, {
|
|
231
|
+
tags: { deviceId, addonId: source.addonId },
|
|
232
|
+
meta: { capability: capName, method },
|
|
233
|
+
});
|
|
234
|
+
continue;
|
|
235
|
+
}
|
|
236
|
+
rows.push(...finishRows(registry, capName, deviceId, source, result.value, isSourceListing));
|
|
237
|
+
}
|
|
238
|
+
return mergeRows(rows);
|
|
239
|
+
}
|
|
240
|
+
/**
|
|
241
|
+
* The availability block without its catalog age.
|
|
242
|
+
*
|
|
243
|
+
* `catalogAsOf` means "when this source's catalog was last CONFIRMED". A source
|
|
244
|
+
* the operator switched off is never asked for a catalog, so it has no
|
|
245
|
+
* confirmation to report and will never get a fresher one — and an age that can
|
|
246
|
+
* only grow draws a STALLING source rather than an off switch (D62: disabled
|
|
247
|
+
* must never look like broken). The rest of the block is left exactly as the
|
|
248
|
+
* provider said it: what the source CAN do is a different question from whether
|
|
249
|
+
* it may.
|
|
250
|
+
*/
|
|
251
|
+
function withoutCatalogAge(availability) {
|
|
252
|
+
if (availability === null || typeof availability !== 'object')
|
|
253
|
+
return availability;
|
|
254
|
+
return Object.fromEntries(Object.entries(availability).filter(([key]) => key !== CATALOG_AGE_FIELD));
|
|
255
|
+
}
|
|
256
|
+
/**
|
|
257
|
+
* Stamp each row with the addon that really served it, mark a source listing
|
|
258
|
+
* row the operator switched off, and drop the CLIP rows of such a source.
|
|
259
|
+
*
|
|
260
|
+
* The two halves are deliberately asymmetric, and that asymmetry is the whole
|
|
261
|
+
* of D556's amendment: a switched-off source contributes no clips (the point of
|
|
262
|
+
* the switch) and keeps its row (the control the switch is undone from). A
|
|
263
|
+
* `listSources` that dropped the row left an unticked source with nothing to
|
|
264
|
+
* un-tick — on a Reolink camera, which has exactly one source since D555's
|
|
265
|
+
* collapse, that is the entire picker.
|
|
266
|
+
*
|
|
267
|
+
* **The stamp** is authoritative: a surface cannot otherwise resolve a source
|
|
268
|
+
* row to who answers it, and the alternative — a `native:reolink:* →
|
|
269
|
+
* provider-reolink` prefix table inside the widget — is a second authority on
|
|
270
|
+
* provider identity living in the one package with no business knowing it
|
|
271
|
+
* (D557). The registry's id is a fact; a provider's own value is a claim, so
|
|
272
|
+
* the fact wins.
|
|
273
|
+
*
|
|
274
|
+
* **The drop** happens here rather than in `sourcesForDevice` because one
|
|
275
|
+
* addon serves SEVERAL of a device's sources (`addon-provider-reolink` answers
|
|
276
|
+
* a hub child with `onboard` AND `hub`), so dropping the PROVIDER would turn
|
|
277
|
+
* off every source it has while the operator ticked one. Rows are where
|
|
278
|
+
* sources are actually named, so this is the grain the per-source authority
|
|
279
|
+
* applies at — same registry projection, same migration rule, one step finer.
|
|
280
|
+
*
|
|
281
|
+
* A camera with NO deny entry is untouched: the rows come back verbatim, in
|
|
282
|
+
* the provider's own order, which is D554 decision 6's guarantee and today's
|
|
283
|
+
* behaviour for every camera on the fleet.
|
|
284
|
+
*/
|
|
285
|
+
function finishRows(registry, capName, deviceId, source, rows, isSourceListing) {
|
|
286
|
+
const denied = registry.getDeviceDisabledSources(String(deviceId), capName);
|
|
287
|
+
const out = [];
|
|
288
|
+
for (const row of rows) {
|
|
289
|
+
if (row === null || typeof row !== 'object') {
|
|
290
|
+
out.push(row);
|
|
291
|
+
continue;
|
|
292
|
+
}
|
|
293
|
+
const rowSourceId = Reflect.get(row, SOURCE_ID_FIELD);
|
|
294
|
+
// ONE predicate for the whole deny set, migration rule included: an entry
|
|
295
|
+
// is a source id, or (pre-re-key, D555) the id of the addon that serves it.
|
|
296
|
+
const off = denied.length > 0 &&
|
|
297
|
+
(0, system_1.isSourceDenied)(denied, {
|
|
298
|
+
addonId: source.addonId,
|
|
299
|
+
...(typeof rowSourceId === 'string' ? { sourceId: rowSourceId } : {}),
|
|
300
|
+
});
|
|
301
|
+
if (isSourceListing) {
|
|
302
|
+
const stamped = {
|
|
303
|
+
...row,
|
|
304
|
+
[SOURCE_ADDON_FIELD]: source.addonId,
|
|
305
|
+
};
|
|
306
|
+
out.push(off
|
|
307
|
+
? {
|
|
308
|
+
...stamped,
|
|
309
|
+
availability: withoutCatalogAge(Reflect.get(row, 'availability')),
|
|
310
|
+
[SOURCE_SWITCHED_OFF_FIELD]: true,
|
|
311
|
+
}
|
|
312
|
+
: stamped);
|
|
313
|
+
continue;
|
|
314
|
+
}
|
|
315
|
+
if (off)
|
|
316
|
+
continue;
|
|
317
|
+
out.push(row);
|
|
318
|
+
}
|
|
319
|
+
return out;
|
|
320
|
+
}
|
|
321
|
+
async function claimsOf(sources, capName, deviceId, logger) {
|
|
322
|
+
const claimed = new Map();
|
|
323
|
+
const unknownClaims = [];
|
|
324
|
+
const settled = await Promise.allSettled(sources.map(async (source) => await callSource(source, exports.SOURCE_LISTING_METHOD, { deviceId }, capName, deviceId)));
|
|
325
|
+
for (const [index, result] of settled.entries()) {
|
|
326
|
+
const source = sources[index];
|
|
327
|
+
if (source === undefined)
|
|
328
|
+
continue;
|
|
329
|
+
if (result.status === 'rejected' || !Array.isArray(result.value)) {
|
|
330
|
+
// It could not name its sources, so it cannot be excluded on that basis
|
|
331
|
+
// either — a read that FAILS must not destroy work (D49).
|
|
332
|
+
logger?.warn(`${capName}.${exports.SOURCE_LISTING_METHOD}: source could not name its ids — routing falls back to it only if it is the only such source`, {
|
|
333
|
+
tags: { deviceId, addonId: source.addonId },
|
|
334
|
+
meta: {
|
|
335
|
+
capability: capName,
|
|
336
|
+
reason: result.status === 'rejected' ? errMessage(result.reason) : 'non-array answer',
|
|
337
|
+
},
|
|
338
|
+
});
|
|
339
|
+
unknownClaims.push(source);
|
|
340
|
+
continue;
|
|
341
|
+
}
|
|
342
|
+
for (const row of result.value) {
|
|
343
|
+
if (row === null || typeof row !== 'object')
|
|
344
|
+
continue;
|
|
345
|
+
const id = Reflect.get(row, SOURCE_ID_FIELD);
|
|
346
|
+
if (typeof id === 'string' && id.length > 0 && !claimed.has(id))
|
|
347
|
+
claimed.set(id, source);
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
return { claimed, unknownClaims };
|
|
351
|
+
}
|
|
352
|
+
/** Every top-level string value of an object input — the candidate routing keys. */
|
|
353
|
+
function stringFieldsOf(input) {
|
|
354
|
+
if (input === null || typeof input !== 'object' || Array.isArray(input))
|
|
355
|
+
return [];
|
|
356
|
+
const out = [];
|
|
357
|
+
for (const value of Object.values(input)) {
|
|
358
|
+
if (typeof value === 'string' && value.length > 0)
|
|
359
|
+
out.push(value);
|
|
360
|
+
}
|
|
361
|
+
return out;
|
|
362
|
+
}
|
|
363
|
+
/**
|
|
364
|
+
* The source that owns a source-prefixed id: `<sourceId>` itself, or anything
|
|
365
|
+
* under `<sourceId>:`. The LONGEST matching source wins, so
|
|
366
|
+
* `native:reolink:onboard` beats a hypothetical `native:reolink`.
|
|
367
|
+
*/
|
|
368
|
+
function ownerOf(claims, input) {
|
|
369
|
+
let best = null;
|
|
370
|
+
for (const value of stringFieldsOf(input)) {
|
|
371
|
+
for (const [sourceId, source] of claims.claimed) {
|
|
372
|
+
if (value !== sourceId && !value.startsWith(`${sourceId}:`))
|
|
373
|
+
continue;
|
|
374
|
+
if (best === null || sourceId.length > best.sourceId.length)
|
|
375
|
+
best = { source, sourceId };
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
return best;
|
|
379
|
+
}
|
|
380
|
+
async function dispatchScalar(sources, method, input, capName, deviceId, logger) {
|
|
381
|
+
const claims = await claimsOf(sources, capName, deviceId, logger);
|
|
382
|
+
const owner = ownerOf(claims, input);
|
|
383
|
+
if (owner !== null)
|
|
384
|
+
return await callSource(owner.source, method, input, capName, deviceId);
|
|
385
|
+
const fallback = claims.unknownClaims.length === 1 ? claims.unknownClaims[0] : undefined;
|
|
386
|
+
if (fallback !== undefined) {
|
|
387
|
+
logger?.warn(`${capName}.${method}: no source claimed this id — using the one source that could not name its ids`, {
|
|
388
|
+
tags: { deviceId, addonId: fallback.addonId },
|
|
389
|
+
meta: { capability: capName, method },
|
|
390
|
+
});
|
|
391
|
+
return await callSource(fallback, method, input, capName, deviceId);
|
|
392
|
+
}
|
|
393
|
+
const known = [...claims.claimed.keys()];
|
|
394
|
+
logger?.warn(`${capName}.${method}: refused — no source of this device claims the id`, {
|
|
395
|
+
tags: { deviceId },
|
|
396
|
+
meta: { capability: capName, method, knownSources: known, input },
|
|
397
|
+
});
|
|
398
|
+
throw new server_1.TRPCError({
|
|
399
|
+
code: 'BAD_REQUEST',
|
|
400
|
+
message: `Capability "${capName}.${method}": no source of device ${deviceId} claims ` +
|
|
401
|
+
`${JSON.stringify(input)}. Sources: ${known.length > 0 ? known.join(', ') : '(none named)'}. ` +
|
|
402
|
+
`An unclaimed id is refused rather than answered by another source.`,
|
|
403
|
+
});
|
|
404
|
+
}
|
|
405
|
+
/**
|
|
406
|
+
* Build the per-device COLLECTION dispatcher for `capName` — the collection
|
|
407
|
+
* twin of `requireDeviceScoped`, and the provider object the cap router's
|
|
408
|
+
* `device-scoped` mount resolves when the cap's mode is `collection`.
|
|
409
|
+
*
|
|
410
|
+
* Returns null without a registry (the router then surfaces
|
|
411
|
+
* PRECONDITION_FAILED), exactly as the singleton dispatcher does.
|
|
412
|
+
*/
|
|
413
|
+
function requireDeviceCollection(registry, capName, logger) {
|
|
414
|
+
if (!registry)
|
|
415
|
+
return null;
|
|
416
|
+
const dispatcher = new Proxy({}, {
|
|
417
|
+
get(_target, prop) {
|
|
418
|
+
if (typeof prop !== 'string')
|
|
419
|
+
return undefined;
|
|
420
|
+
return async (input) => {
|
|
421
|
+
const deviceId = input !== null && typeof input === 'object' ? Reflect.get(input, 'deviceId') : undefined;
|
|
422
|
+
if (typeof deviceId !== 'number') {
|
|
423
|
+
throw new server_1.TRPCError({
|
|
424
|
+
code: 'BAD_REQUEST',
|
|
425
|
+
message: `${capName}.${prop}: input must carry numeric "deviceId"`,
|
|
426
|
+
});
|
|
427
|
+
}
|
|
428
|
+
const all = sourcesForDevice(registry, capName, deviceId);
|
|
429
|
+
if (all.length === 0) {
|
|
430
|
+
throw new server_1.TRPCError({
|
|
431
|
+
code: 'PRECONDITION_FAILED',
|
|
432
|
+
message: `Capability "${capName}" not registered for device ${deviceId}`,
|
|
433
|
+
});
|
|
434
|
+
}
|
|
435
|
+
// The LISTING sees every source the device has, switched off or not
|
|
436
|
+
// — that row is the only control the operator has to switch one back
|
|
437
|
+
// on. Everything else sees the SERVING set.
|
|
438
|
+
const listing = prop === exports.SOURCE_LISTING_METHOD;
|
|
439
|
+
const sources = listing ? all : all.filter((source) => !source.switchedOff);
|
|
440
|
+
if (sources.length === 0) {
|
|
441
|
+
// Every source is switched off. That is an operator's decision and
|
|
442
|
+
// not a missing provider, so it must not wear PRECONDITION_FAILED's
|
|
443
|
+
// "not registered" — the rows say which it is, and the answer to
|
|
444
|
+
// "what clips does it have" is honestly none (D556 left this open).
|
|
445
|
+
logger?.info(`${capName}.${prop}: every source of this device is switched off`, {
|
|
446
|
+
tags: { deviceId },
|
|
447
|
+
meta: { capability: capName, method: prop, sources: all.map((s) => s.addonId) },
|
|
448
|
+
});
|
|
449
|
+
if (isFanOutMethod(registry, capName, prop))
|
|
450
|
+
return [];
|
|
451
|
+
throw new server_1.TRPCError({
|
|
452
|
+
code: 'BAD_REQUEST',
|
|
453
|
+
message: `Capability "${capName}.${prop}": every source of device ${deviceId} is ` +
|
|
454
|
+
`switched off for this camera. Switch one back on to serve it.`,
|
|
455
|
+
});
|
|
456
|
+
}
|
|
457
|
+
const first = sources[0];
|
|
458
|
+
if (isFanOutMethod(registry, capName, prop)) {
|
|
459
|
+
// One source is not a merge: its rows travel verbatim, in its own
|
|
460
|
+
// order, so a camera with one provider answers exactly as it did
|
|
461
|
+
// before this dispatcher existed.
|
|
462
|
+
if (sources.length === 1 && first !== undefined) {
|
|
463
|
+
// One PROVIDER is not a merge — but it may still serve several
|
|
464
|
+
// SOURCES, and the operator may have switched one of them off.
|
|
465
|
+
// So the rows are finished (stamped, and filtered by source)
|
|
466
|
+
// even here; with no deny entry that is the identity function,
|
|
467
|
+
// so an untouched camera answers exactly as it did before
|
|
468
|
+
// (D554 decision 6).
|
|
469
|
+
const answered = await callSource(first, prop, input, capName, deviceId);
|
|
470
|
+
if (!Array.isArray(answered))
|
|
471
|
+
return answered;
|
|
472
|
+
return finishRows(registry, capName, deviceId, first, answered, prop === exports.SOURCE_LISTING_METHOD);
|
|
473
|
+
}
|
|
474
|
+
return await fanOut(registry, sources, prop, input, capName, deviceId, logger);
|
|
475
|
+
}
|
|
476
|
+
if (sources.length === 1 && first !== undefined) {
|
|
477
|
+
return await callSource(first, prop, input, capName, deviceId);
|
|
478
|
+
}
|
|
479
|
+
return await dispatchScalar(sources, prop, input, capName, deviceId, logger);
|
|
480
|
+
};
|
|
481
|
+
},
|
|
482
|
+
});
|
|
483
|
+
return dispatcher;
|
|
484
|
+
}
|
|
@@ -78,7 +78,7 @@ function mountAllCaps(services) {
|
|
|
78
78
|
alarmPanel: (0, generated_cap_routers_js_1.createCapRouter_alarmPanel)((_ctx) => (0, cap_mount_helpers_js_1.requireDeviceScoped)(reg, 'alarm-panel'), remoteCapProxy),
|
|
79
79
|
alerts: (0, generated_cap_routers_js_1.createCapRouter_alerts)((_ctx) => reg?.getSingleton('alerts') ?? null, remoteCapProxy),
|
|
80
80
|
ambientLightSensor: (0, generated_cap_routers_js_1.createCapRouter_ambientLightSensor)((_ctx) => (0, cap_mount_helpers_js_1.requireDeviceScoped)(reg, 'ambient-light-sensor'), remoteCapProxy),
|
|
81
|
-
audioAnalysis: (0, generated_cap_routers_js_1.createCapRouter_audioAnalysis)((_ctx) => reg
|
|
81
|
+
audioAnalysis: (0, generated_cap_routers_js_1.createCapRouter_audioAnalysis)((_ctx) => (0, cap_mount_helpers_js_1.requireDeviceScoped)(reg, 'audio-analysis'), remoteCapProxy),
|
|
82
82
|
audioAnalyzer: (0, generated_cap_routers_js_1.createCapRouter_audioAnalyzer)((_ctx) => reg?.getSingleton('audio-analyzer') ?? null, remoteCapProxy),
|
|
83
83
|
audioCodec: (0, generated_cap_routers_js_1.createCapRouter_audioCodec)((_ctx) => reg?.getSingleton('audio-codec') ?? null, remoteCapProxy),
|
|
84
84
|
audioMetrics: (0, generated_cap_routers_js_1.createCapRouter_audioMetrics)((_ctx) => reg?.getSingleton('audio-metrics') ?? null, remoteCapProxy),
|
|
@@ -107,8 +107,8 @@ function mountAllCaps(services) {
|
|
|
107
107
|
button: (0, generated_cap_routers_js_1.createCapRouter_button)((_ctx) => (0, cap_mount_helpers_js_1.requireDeviceScoped)(reg, 'button'), remoteCapProxy),
|
|
108
108
|
cameraCredentials: (0, generated_cap_routers_js_1.createCapRouter_cameraCredentials)((_ctx) => (0, cap_mount_helpers_js_1.requireDeviceScoped)(reg, 'camera-credentials'), remoteCapProxy),
|
|
109
109
|
cameraGridLayout: (0, generated_cap_routers_js_1.createCapRouter_cameraGridLayout)((_ctx) => (0, cap_mount_helpers_js_1.requireDeviceScoped)(reg, 'camera-grid-layout'), remoteCapProxy),
|
|
110
|
-
cameraPipelineConfig: (0, generated_cap_routers_js_1.createCapRouter_cameraPipelineConfig)((_ctx) => reg
|
|
111
|
-
cameraStreams: (0, generated_cap_routers_js_1.createCapRouter_cameraStreams)((_ctx) => reg
|
|
110
|
+
cameraPipelineConfig: (0, generated_cap_routers_js_1.createCapRouter_cameraPipelineConfig)((_ctx) => (0, cap_mount_helpers_js_1.requireDeviceScoped)(reg, 'camera-pipeline-config'), remoteCapProxy),
|
|
111
|
+
cameraStreams: (0, generated_cap_routers_js_1.createCapRouter_cameraStreams)((_ctx) => (0, cap_mount_helpers_js_1.requireDeviceScoped)(reg, 'camera-streams'), remoteCapProxy),
|
|
112
112
|
carbonMonoxide: (0, generated_cap_routers_js_1.createCapRouter_carbonMonoxide)((_ctx) => (0, cap_mount_helpers_js_1.requireDeviceScoped)(reg, 'carbon-monoxide'), remoteCapProxy),
|
|
113
113
|
climateControl: (0, generated_cap_routers_js_1.createCapRouter_climateControl)((_ctx) => (0, cap_mount_helpers_js_1.requireDeviceScoped)(reg, 'climate-control'), remoteCapProxy),
|
|
114
114
|
color: (0, generated_cap_routers_js_1.createCapRouter_color)((_ctx) => (0, cap_mount_helpers_js_1.requireDeviceScoped)(reg, 'color'), remoteCapProxy),
|
|
@@ -162,7 +162,7 @@ function mountAllCaps(services) {
|
|
|
162
162
|
}, remoteCapProxy),
|
|
163
163
|
dayNight: (0, generated_cap_routers_js_1.createCapRouter_dayNight)((_ctx) => (0, cap_mount_helpers_js_1.requireDeviceScoped)(reg, 'day-night'), remoteCapProxy),
|
|
164
164
|
decoder: (0, generated_cap_routers_js_1.createCapRouter_decoder)((_ctx) => reg?.getSingleton('decoder') ?? null, remoteCapProxy),
|
|
165
|
-
detectionPipeline: (0, generated_cap_routers_js_1.createCapRouter_detectionPipeline)((_ctx) => reg
|
|
165
|
+
detectionPipeline: (0, generated_cap_routers_js_1.createCapRouter_detectionPipeline)((_ctx) => (0, cap_mount_helpers_js_1.requireDeviceScoped)(reg, 'detection-pipeline'), remoteCapProxy),
|
|
166
166
|
deviceAdoption: (0, generated_cap_routers_js_1.createCapRouter_deviceAdoption)((_ctx) => reg?.getSingleton('device-adoption') ??
|
|
167
167
|
null, remoteCapProxy),
|
|
168
168
|
deviceDiscovery: (0, generated_cap_routers_js_1.createCapRouter_deviceDiscovery)((_ctx) => (0, cap_mount_helpers_js_1.requireDeviceScoped)(reg, 'device-discovery'), remoteCapProxy),
|
|
@@ -337,8 +337,7 @@ function mountAllCaps(services) {
|
|
|
337
337
|
modelDistributor: (0, generated_cap_routers_js_1.createCapRouter_modelDistributor)((_ctx) => reg?.getSingleton('model-distributor') ??
|
|
338
338
|
null, remoteCapProxy),
|
|
339
339
|
motion: (0, generated_cap_routers_js_1.createCapRouter_motion)((_ctx) => (0, cap_mount_helpers_js_1.requireDeviceScoped)(reg, 'motion'), remoteCapProxy),
|
|
340
|
-
motionDetection: (0, generated_cap_routers_js_1.createCapRouter_motionDetection)((_ctx) => reg
|
|
341
|
-
null, remoteCapProxy),
|
|
340
|
+
motionDetection: (0, generated_cap_routers_js_1.createCapRouter_motionDetection)((_ctx) => (0, cap_mount_helpers_js_1.requireDeviceScoped)(reg, 'motion-detection'), remoteCapProxy),
|
|
342
341
|
motionTrigger: (0, generated_cap_routers_js_1.createCapRouter_motionTrigger)((_ctx) => (0, cap_mount_helpers_js_1.requireDeviceScoped)(reg, 'motion-trigger'), remoteCapProxy),
|
|
343
342
|
motionZones: (0, generated_cap_routers_js_1.createCapRouter_motionZones)((_ctx) => (0, cap_mount_helpers_js_1.requireDeviceScoped)(reg, 'motion-zones'), remoteCapProxy),
|
|
344
343
|
mqttBroker: (0, generated_cap_routers_js_1.createCapRouter_mqttBroker)((_ctx, addonId) => {
|
|
@@ -412,7 +411,7 @@ function mountAllCaps(services) {
|
|
|
412
411
|
osd: (0, generated_cap_routers_js_1.createCapRouter_osd)((_ctx) => (0, cap_mount_helpers_js_1.requireDeviceScoped)(reg, 'osd'), remoteCapProxy),
|
|
413
412
|
osdManager: (0, generated_cap_routers_js_1.createCapRouter_osdManager)((_ctx) => reg?.getSingleton('osd-manager') ?? null, remoteCapProxy),
|
|
414
413
|
petFeeder: (0, generated_cap_routers_js_1.createCapRouter_petFeeder)((_ctx) => (0, cap_mount_helpers_js_1.requireDeviceScoped)(reg, 'pet-feeder'), remoteCapProxy),
|
|
415
|
-
pipelineAnalytics: (0, generated_cap_routers_js_1.createCapRouter_pipelineAnalytics)((_ctx) => reg
|
|
414
|
+
pipelineAnalytics: (0, generated_cap_routers_js_1.createCapRouter_pipelineAnalytics)((_ctx) => (0, cap_mount_helpers_js_1.requireDeviceScoped)(reg, 'pipeline-analytics'), remoteCapProxy),
|
|
416
415
|
pipelineExecutor: (0, generated_cap_routers_js_1.createCapRouter_pipelineExecutor)((_ctx) => reg?.getSingleton('pipeline-executor') ??
|
|
417
416
|
null, remoteCapProxy),
|
|
418
417
|
pipelineOrchestrator: (0, generated_cap_routers_js_1.createCapRouter_pipelineOrchestrator)((_ctx) => reg?.getSingleton('pipeline-orchestrator') ?? null, remoteCapProxy),
|
|
@@ -431,7 +430,7 @@ function mountAllCaps(services) {
|
|
|
431
430
|
recordingExport: (0, generated_cap_routers_js_1.createCapRouter_recordingExport)((_ctx) => reg?.getSingleton('recording-export') ??
|
|
432
431
|
null, remoteCapProxy),
|
|
433
432
|
recordingSignal: (0, generated_cap_routers_js_1.createCapRouter_recordingSignal)((_ctx) => (0, cap_mount_helpers_js_1.requireDeviceScoped)(reg, 'recording-signal'), remoteCapProxy),
|
|
434
|
-
sceneMonitor: (0, generated_cap_routers_js_1.createCapRouter_sceneMonitor)((_ctx) => reg
|
|
433
|
+
sceneMonitor: (0, generated_cap_routers_js_1.createCapRouter_sceneMonitor)((_ctx) => (0, cap_mount_helpers_js_1.requireDeviceScoped)(reg, 'scene-monitor'), remoteCapProxy),
|
|
435
434
|
scriptRunner: (0, generated_cap_routers_js_1.createCapRouter_scriptRunner)((_ctx) => (0, cap_mount_helpers_js_1.requireDeviceScoped)(reg, 'script-runner'), remoteCapProxy),
|
|
436
435
|
serverManagement: (0, generated_cap_routers_js_1.createCapRouter_serverManagement)((_ctx) => reg?.getSingleton('server-management') ??
|
|
437
436
|
null, remoteCapProxy),
|
|
@@ -542,7 +541,7 @@ function mountAllCaps(services) {
|
|
|
542
541
|
valve: (0, generated_cap_routers_js_1.createCapRouter_valve)((_ctx) => (0, cap_mount_helpers_js_1.requireDeviceScoped)(reg, 'valve'), remoteCapProxy),
|
|
543
542
|
vectorStore: (0, generated_cap_routers_js_1.createCapRouter_vectorStore)((_ctx) => reg?.getSingleton('vector-store') ?? null, remoteCapProxy),
|
|
544
543
|
vibration: (0, generated_cap_routers_js_1.createCapRouter_vibration)((_ctx) => (0, cap_mount_helpers_js_1.requireDeviceScoped)(reg, 'vibration'), remoteCapProxy),
|
|
545
|
-
videoclips: (0, generated_cap_routers_js_1.createCapRouter_videoclips)((_ctx) => reg
|
|
544
|
+
videoclips: (0, generated_cap_routers_js_1.createCapRouter_videoclips)((_ctx) => (0, cap_mount_helpers_js_1.requireDeviceScoped)(reg, 'videoclips'), remoteCapProxy),
|
|
546
545
|
viewerUi: (0, generated_cap_routers_js_1.createCapRouter_viewerUi)((_ctx) => reg?.getSingleton('viewer-ui') ?? null, remoteCapProxy),
|
|
547
546
|
waterHeater: (0, generated_cap_routers_js_1.createCapRouter_waterHeater)((_ctx) => (0, cap_mount_helpers_js_1.requireDeviceScoped)(reg, 'water-heater'), remoteCapProxy),
|
|
548
547
|
weather: (0, generated_cap_routers_js_1.createCapRouter_weather)((_ctx) => (0, cap_mount_helpers_js_1.requireDeviceScoped)(reg, 'weather'), remoteCapProxy),
|
|
@@ -10733,7 +10733,7 @@ function createCapRouter_vibration(getProvider, createRemoteProxy) {
|
|
|
10733
10733
|
}
|
|
10734
10734
|
function createCapRouter_videoclips(getProvider, createRemoteProxy) {
|
|
10735
10735
|
return (0, trpc_middleware_js_1.trpcRouter)({
|
|
10736
|
-
listClips: trpc_middleware_js_1.
|
|
10736
|
+
listClips: trpc_middleware_js_1.protectedProcedure
|
|
10737
10737
|
.input(types_126.videoclipsCapability.methods.listClips.input.loose())
|
|
10738
10738
|
.output(types_126.videoclipsCapability.methods.listClips.output)
|
|
10739
10739
|
.query(async ({ input, ctx }) => {
|
|
@@ -10742,7 +10742,16 @@ function createCapRouter_videoclips(getProvider, createRemoteProxy) {
|
|
|
10742
10742
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-argument
|
|
10743
10743
|
return p.listClips(methodInput);
|
|
10744
10744
|
}),
|
|
10745
|
-
|
|
10745
|
+
listSources: trpc_middleware_js_1.protectedProcedure
|
|
10746
|
+
.input(types_126.videoclipsCapability.methods.listSources.input.loose())
|
|
10747
|
+
.output(types_126.videoclipsCapability.methods.listSources.output)
|
|
10748
|
+
.query(async ({ input, ctx }) => {
|
|
10749
|
+
const { nodeId, ...methodInput } = input;
|
|
10750
|
+
const p = resolveProvider('videoclips', nodeId, () => getProvider(ctx), createRemoteProxy);
|
|
10751
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-argument
|
|
10752
|
+
return p.listSources(methodInput);
|
|
10753
|
+
}),
|
|
10754
|
+
getClipPlayback: trpc_middleware_js_1.protectedProcedure
|
|
10746
10755
|
.input(types_126.videoclipsCapability.methods.getClipPlayback.input.loose())
|
|
10747
10756
|
.output(types_126.videoclipsCapability.methods.getClipPlayback.output)
|
|
10748
10757
|
.query(async ({ input, ctx }) => {
|
|
@@ -83,9 +83,13 @@ var require_dist = __commonJS({
|
|
|
83
83
|
PENDING_ROOT_SWAP_FILE: /* @__PURE__ */ __name(() => PENDING_ROOT_SWAP_FILE, "PENDING_ROOT_SWAP_FILE"),
|
|
84
84
|
REQUIRED_NATIVE_PACKAGES: /* @__PURE__ */ __name(() => REQUIRED_NATIVE_PACKAGES, "REQUIRED_NATIVE_PACKAGES"),
|
|
85
85
|
RESTART_INTENT_FILE: /* @__PURE__ */ __name(() => RESTART_INTENT_FILE, "RESTART_INTENT_FILE"),
|
|
86
|
+
ROLLBACK_UNSUPPORTED_REASON: /* @__PURE__ */ __name(() => ROLLBACK_UNSUPPORTED_REASON, "ROLLBACK_UNSUPPORTED_REASON"),
|
|
86
87
|
RootUpdateService: /* @__PURE__ */ __name(() => RootUpdateService, "RootUpdateService"),
|
|
87
88
|
SERVER_ROOT_DIRNAME: /* @__PURE__ */ __name(() => SERVER_ROOT_DIRNAME, "SERVER_ROOT_DIRNAME"),
|
|
88
89
|
SERVER_ROOT_STATE_FILE: /* @__PURE__ */ __name(() => SERVER_ROOT_STATE_FILE, "SERVER_ROOT_STATE_FILE"),
|
|
90
|
+
STAGING_ATTEMPTS: /* @__PURE__ */ __name(() => STAGING_ATTEMPTS, "STAGING_ATTEMPTS"),
|
|
91
|
+
STAGING_FAILED_REASON: /* @__PURE__ */ __name(() => STAGING_FAILED_REASON, "STAGING_FAILED_REASON"),
|
|
92
|
+
STAGING_RETRY_DELAYS_MS: /* @__PURE__ */ __name(() => STAGING_RETRY_DELAYS_MS, "STAGING_RETRY_DELAYS_MS"),
|
|
89
93
|
applyPendingRootSwap: /* @__PURE__ */ __name(() => applyPendingRootSwap, "applyPendingRootSwap"),
|
|
90
94
|
assessImageContract: /* @__PURE__ */ __name(() => assessImageContract, "assessImageContract"),
|
|
91
95
|
clearPendingRootSwap: /* @__PURE__ */ __name(() => clearPendingRootSwap, "clearPendingRootSwap"),
|
|
@@ -105,6 +109,7 @@ var require_dist = __commonJS({
|
|
|
105
109
|
isDevChannelVersion: /* @__PURE__ */ __name(() => isDevChannelVersion, "isDevChannelVersion"),
|
|
106
110
|
isHostExternal: /* @__PURE__ */ __name(() => isHostExternal, "isHostExternal"),
|
|
107
111
|
isServerRootState: /* @__PURE__ */ __name(() => isServerRootState, "isServerRootState"),
|
|
112
|
+
isTransientNpmNetworkError: /* @__PURE__ */ __name(() => isTransientNpmNetworkError, "isTransientNpmNetworkError"),
|
|
108
113
|
migrateToSingleCopy: /* @__PURE__ */ __name(() => migrateToSingleCopy, "migrateToSingleCopy"),
|
|
109
114
|
minNodeMajorOf: /* @__PURE__ */ __name(() => minNodeMajorOf, "minNodeMajorOf"),
|
|
110
115
|
pendingRootSwapPath: /* @__PURE__ */ __name(() => pendingRootSwapPath, "pendingRootSwapPath"),
|
|
@@ -1071,6 +1076,18 @@ var require_dist = __commonJS({
|
|
|
1071
1076
|
__name(buildNpmRegistryArgs, "buildNpmRegistryArgs");
|
|
1072
1077
|
var NPM_VIEW_TIMEOUT_MS = 2e4;
|
|
1073
1078
|
var NPM_INSTALL_TIMEOUT_MS = 15 * 6e4;
|
|
1079
|
+
var STAGING_ATTEMPTS = 3;
|
|
1080
|
+
var STAGING_RETRY_DELAYS_MS = [
|
|
1081
|
+
5e3,
|
|
1082
|
+
15e3
|
|
1083
|
+
];
|
|
1084
|
+
var TRANSIENT_NPM_ERROR_RE = /npm error code (?:ECONNRESET|ETIMEDOUT|EAI_AGAIN|ENOTFOUND|ECONNREFUSED|EPIPE|E50[234]|E429)\b|npm error network/i;
|
|
1085
|
+
function isTransientNpmNetworkError(message) {
|
|
1086
|
+
return TRANSIENT_NPM_ERROR_RE.test(message);
|
|
1087
|
+
}
|
|
1088
|
+
__name(isTransientNpmNetworkError, "isTransientNpmNetworkError");
|
|
1089
|
+
var STAGING_FAILED_REASON = "staging-failed";
|
|
1090
|
+
var ROLLBACK_UNSUPPORTED_REASON = "rollback-unsupported";
|
|
1074
1091
|
var CLOSURE_ALLOW_SCRIPTS = {
|
|
1075
1092
|
"@camstack/server": true,
|
|
1076
1093
|
"better-sqlite3": true,
|
|
@@ -1131,8 +1148,20 @@ var require_dist = __commonJS({
|
|
|
1131
1148
|
env;
|
|
1132
1149
|
readWrapperProbesFn;
|
|
1133
1150
|
now;
|
|
1151
|
+
sleep;
|
|
1134
1152
|
runningPackageJsonPath;
|
|
1135
1153
|
workspaceProbeDir;
|
|
1154
|
+
/**
|
|
1155
|
+
* The version THIS PROCESS loaded — read once, here, from the package.json
|
|
1156
|
+
* of the copy the code came from, and never again. The file is the only
|
|
1157
|
+
* place the fact exists in a form the process can read, and the moment of
|
|
1158
|
+
* construction is the only moment it is guaranteed to describe what is in
|
|
1159
|
+
* memory: the starter swaps `current/` BEFORE loading the entry, and nothing
|
|
1160
|
+
* else may move it, so a later read that disagrees is a disk that changed
|
|
1161
|
+
* under a live process — reported through `installedVersion`, never folded
|
|
1162
|
+
* into this number.
|
|
1163
|
+
*/
|
|
1164
|
+
loadedVersion;
|
|
1136
1165
|
checkCache = null;
|
|
1137
1166
|
inFlight = "idle";
|
|
1138
1167
|
constructor(options) {
|
|
@@ -1160,8 +1189,22 @@ var require_dist = __commonJS({
|
|
|
1160
1189
|
this.env = options.env ?? process.env;
|
|
1161
1190
|
this.readWrapperProbesFn = options.readWrapperProbes ?? readWrapperProbes;
|
|
1162
1191
|
this.now = options.now ?? Date.now;
|
|
1192
|
+
this.sleep = options.sleep ?? ((ms) => new Promise((resolve3) => {
|
|
1193
|
+
setTimeout(resolve3, ms);
|
|
1194
|
+
}));
|
|
1163
1195
|
this.runningPackageJsonPath = options.runningPackageJsonPath;
|
|
1164
1196
|
this.workspaceProbeDir = options.workspaceProbeDir;
|
|
1197
|
+
this.loadedVersion = readPackageVersion(this.runningPackageJsonPath);
|
|
1198
|
+
const bootedVersion = this.env[this.envNames.activeVersion];
|
|
1199
|
+
if (bootedVersion !== void 0 && this.loadedVersion !== null && bootedVersion !== this.loadedVersion) {
|
|
1200
|
+
this.logger.warn("running root package disagrees with the version the starter booted", {
|
|
1201
|
+
tags: this.logTags,
|
|
1202
|
+
meta: {
|
|
1203
|
+
loadedVersion: this.loadedVersion,
|
|
1204
|
+
bootedVersion
|
|
1205
|
+
}
|
|
1206
|
+
});
|
|
1207
|
+
}
|
|
1165
1208
|
}
|
|
1166
1209
|
// ── Status ────────────────────────────────────────────────────────────
|
|
1167
1210
|
resolveBootMode() {
|
|
@@ -1169,8 +1212,36 @@ var require_dist = __commonJS({
|
|
|
1169
1212
|
if (raw === "workspace" || raw === "baked" || raw === "data-root") return raw;
|
|
1170
1213
|
return detectWorkspaceRoot(this.workspaceProbeDir) !== null ? "workspace" : "baked";
|
|
1171
1214
|
}
|
|
1215
|
+
/** What THIS process loaded — see {@link loadedVersion}. Never a disk read. */
|
|
1172
1216
|
runningVersion() {
|
|
1173
|
-
return
|
|
1217
|
+
return this.loadedVersion;
|
|
1218
|
+
}
|
|
1219
|
+
/**
|
|
1220
|
+
* What is in the data-root `current/` copy RIGHT NOW — the version the next
|
|
1221
|
+
* boot would load if nothing is staged. Only answered on a `data-root` boot:
|
|
1222
|
+
* on a workspace boot there is no such copy, and on a `baked` boot the
|
|
1223
|
+
* starter already judged `current/` unloadable (or absent), so a version
|
|
1224
|
+
* read from it would claim "runs after restart" about code that will not.
|
|
1225
|
+
*/
|
|
1226
|
+
installedVersion() {
|
|
1227
|
+
if (this.resolveBootMode() !== "data-root") return null;
|
|
1228
|
+
return readPackageVersion(path6.join(rootPackageDir2(currentDir(this.rootDir()), this.spec), "package.json"));
|
|
1229
|
+
}
|
|
1230
|
+
/**
|
|
1231
|
+
* The three facts a restart question needs, read together so no surface can
|
|
1232
|
+
* quote one without the others: what is staged, what is on disk, and whether
|
|
1233
|
+
* the code that would run after a restart differs from the code running now.
|
|
1234
|
+
*/
|
|
1235
|
+
restartFacts() {
|
|
1236
|
+
const pendingVersion = readPendingRootSwap(this.rootDir())?.version ?? null;
|
|
1237
|
+
const installedVersion = this.installedVersion();
|
|
1238
|
+
const running = this.runningVersion();
|
|
1239
|
+
const restartRequired = pendingVersion !== null || installedVersion !== null && running !== null && installedVersion !== running;
|
|
1240
|
+
return {
|
|
1241
|
+
pendingVersion,
|
|
1242
|
+
installedVersion,
|
|
1243
|
+
restartRequired
|
|
1244
|
+
};
|
|
1174
1245
|
}
|
|
1175
1246
|
/** The running root package's identity — feeds registerNode / topology rows. */
|
|
1176
1247
|
getRunningRootPackage() {
|
|
@@ -1207,16 +1278,26 @@ var require_dist = __commonJS({
|
|
|
1207
1278
|
if (readPendingRootSwap(this.rootDir()) !== null) return "pending-restart";
|
|
1208
1279
|
return "idle";
|
|
1209
1280
|
}
|
|
1281
|
+
/**
|
|
1282
|
+
* `latest` is an update this node can still TAKE: newer than what runs, and
|
|
1283
|
+
* not the version already staged here — that one is a restart away, and
|
|
1284
|
+
* re-applying it is refused (`already-staged`).
|
|
1285
|
+
*/
|
|
1286
|
+
isUpdateAvailable(latestVersion, runningVersion, pendingVersion) {
|
|
1287
|
+
return latestVersion !== null && runningVersion !== null && latestVersion !== pendingVersion && compareSemver(latestVersion, runningVersion) > 0;
|
|
1288
|
+
}
|
|
1210
1289
|
async getServerPackageStatus() {
|
|
1211
1290
|
const runningVersion = this.runningVersion();
|
|
1212
1291
|
const latestVersion = this.checkCache?.latestVersion ?? null;
|
|
1213
|
-
const
|
|
1214
|
-
const updateAvailable = latestVersion
|
|
1292
|
+
const { pendingVersion, installedVersion, restartRequired } = this.restartFacts();
|
|
1293
|
+
const updateAvailable = this.isUpdateAvailable(latestVersion, runningVersion, pendingVersion);
|
|
1215
1294
|
return {
|
|
1216
1295
|
packageName: this.spec.packageName,
|
|
1217
1296
|
runningVersion,
|
|
1218
1297
|
nodeRuntimeVersion: process.versions.node,
|
|
1219
1298
|
activeVersion: this.env[this.envNames.activeVersion] ?? runningVersion,
|
|
1299
|
+
installedVersion,
|
|
1300
|
+
restartRequired,
|
|
1220
1301
|
// Single-copy model: no N-1 retention, no rollback record.
|
|
1221
1302
|
previousVersion: null,
|
|
1222
1303
|
seedVersion: this.seedVersion(),
|
|
@@ -1224,7 +1305,7 @@ var require_dist = __commonJS({
|
|
|
1224
1305
|
updateAvailable,
|
|
1225
1306
|
bootMode: this.resolveBootMode(),
|
|
1226
1307
|
updateState: this.updateState(),
|
|
1227
|
-
pendingVersion
|
|
1308
|
+
pendingVersion,
|
|
1228
1309
|
rolledBack: null,
|
|
1229
1310
|
stateFileCorrupt: false,
|
|
1230
1311
|
lastCheckedAtMs: this.checkCache?.checkedAtMs ?? null,
|
|
@@ -1246,6 +1327,7 @@ var require_dist = __commonJS({
|
|
|
1246
1327
|
// ── Check ─────────────────────────────────────────────────────────────
|
|
1247
1328
|
async checkServerUpdate() {
|
|
1248
1329
|
const runningVersion = this.runningVersion();
|
|
1330
|
+
const { pendingVersion, restartRequired } = this.restartFacts();
|
|
1249
1331
|
if (this.inFlight !== "idle") {
|
|
1250
1332
|
return {
|
|
1251
1333
|
packageName: this.spec.packageName,
|
|
@@ -1253,7 +1335,9 @@ var require_dist = __commonJS({
|
|
|
1253
1335
|
latestVersion: this.checkCache?.latestVersion ?? null,
|
|
1254
1336
|
updateAvailable: false,
|
|
1255
1337
|
checkedAtMs: this.checkCache?.checkedAtMs ?? this.now(),
|
|
1256
|
-
error: `busy: ${this.inFlight}
|
|
1338
|
+
error: `busy: ${this.inFlight}`,
|
|
1339
|
+
pendingVersion,
|
|
1340
|
+
restartRequired
|
|
1257
1341
|
};
|
|
1258
1342
|
}
|
|
1259
1343
|
this.inFlight = "checking";
|
|
@@ -1288,14 +1372,16 @@ var require_dist = __commonJS({
|
|
|
1288
1372
|
}
|
|
1289
1373
|
});
|
|
1290
1374
|
}
|
|
1291
|
-
const updateAvailable = latestVersion
|
|
1375
|
+
const updateAvailable = this.isUpdateAvailable(latestVersion, runningVersion, pendingVersion);
|
|
1292
1376
|
return {
|
|
1293
1377
|
packageName: this.spec.packageName,
|
|
1294
1378
|
runningVersion,
|
|
1295
1379
|
latestVersion,
|
|
1296
1380
|
updateAvailable,
|
|
1297
1381
|
checkedAtMs: this.checkCache.checkedAtMs,
|
|
1298
|
-
error: null
|
|
1382
|
+
error: null,
|
|
1383
|
+
pendingVersion,
|
|
1384
|
+
restartRequired
|
|
1299
1385
|
};
|
|
1300
1386
|
} catch (err) {
|
|
1301
1387
|
const message = err instanceof Error ? err.message : String(err);
|
|
@@ -1315,7 +1401,9 @@ var require_dist = __commonJS({
|
|
|
1315
1401
|
latestVersion: this.checkCache.latestVersion,
|
|
1316
1402
|
updateAvailable: false,
|
|
1317
1403
|
checkedAtMs: this.checkCache.checkedAtMs,
|
|
1318
|
-
error: message
|
|
1404
|
+
error: message,
|
|
1405
|
+
pendingVersion,
|
|
1406
|
+
restartRequired
|
|
1319
1407
|
};
|
|
1320
1408
|
} finally {
|
|
1321
1409
|
this.inFlight = "idle";
|
|
@@ -1345,7 +1433,12 @@ var require_dist = __commonJS({
|
|
|
1345
1433
|
accepted: false,
|
|
1346
1434
|
targetVersion,
|
|
1347
1435
|
restarting: false,
|
|
1348
|
-
message
|
|
1436
|
+
message,
|
|
1437
|
+
// "Already running" is not a gate saying no — it is the work already
|
|
1438
|
+
// done. Every other reason is a refusal the caller may need to clear.
|
|
1439
|
+
outcome: reason === "already-running" ? "already-current" : "refused",
|
|
1440
|
+
reason,
|
|
1441
|
+
error: null
|
|
1349
1442
|
};
|
|
1350
1443
|
}
|
|
1351
1444
|
async applyServerUpdate(input) {
|
|
@@ -1388,21 +1481,17 @@ var require_dist = __commonJS({
|
|
|
1388
1481
|
this.inFlight = "staging";
|
|
1389
1482
|
let stagingPath;
|
|
1390
1483
|
try {
|
|
1391
|
-
stagingPath = await this.
|
|
1484
|
+
stagingPath = await this.stageClosureWithRetry(target);
|
|
1392
1485
|
} catch (err) {
|
|
1393
1486
|
const message = err instanceof Error ? err.message : String(err);
|
|
1394
|
-
this.logger.error("root package update staging failed", {
|
|
1395
|
-
tags: this.logTags,
|
|
1396
|
-
meta: {
|
|
1397
|
-
targetVersion: target,
|
|
1398
|
-
error: message
|
|
1399
|
-
}
|
|
1400
|
-
});
|
|
1401
1487
|
return {
|
|
1402
1488
|
accepted: false,
|
|
1403
1489
|
targetVersion: target,
|
|
1404
1490
|
restarting: false,
|
|
1405
|
-
message: `Staging failed: ${message}
|
|
1491
|
+
message: `Staging failed: ${message}`,
|
|
1492
|
+
outcome: "failed",
|
|
1493
|
+
reason: STAGING_FAILED_REASON,
|
|
1494
|
+
error: message
|
|
1406
1495
|
};
|
|
1407
1496
|
} finally {
|
|
1408
1497
|
this.inFlight = "idle";
|
|
@@ -1431,10 +1520,59 @@ var require_dist = __commonJS({
|
|
|
1431
1520
|
accepted: true,
|
|
1432
1521
|
targetVersion: target,
|
|
1433
1522
|
restarting: true,
|
|
1434
|
-
message: `Staged ${this.spec.packageName}@${target} \u2014 restarting to apply (single automatic reboot)
|
|
1523
|
+
message: `Staged ${this.spec.packageName}@${target} \u2014 restarting to apply (single automatic reboot). Until that restart completes the node still runs ${runningVersion ?? "the previous version"}.`,
|
|
1524
|
+
outcome: "staged",
|
|
1525
|
+
reason: null,
|
|
1526
|
+
error: null
|
|
1435
1527
|
};
|
|
1436
1528
|
}
|
|
1437
1529
|
/**
|
|
1530
|
+
* {@link stageClosure}, attempted up to {@link STAGING_ATTEMPTS} times when
|
|
1531
|
+
* the failure is a transient registry network error and ONLY then. Every
|
|
1532
|
+
* attempt is logged with its ordinal; the final failure is logged at ERROR
|
|
1533
|
+
* with the attempt count, so a log that shows one line shows the whole
|
|
1534
|
+
* story. Throws the last error when every attempt failed.
|
|
1535
|
+
*/
|
|
1536
|
+
async stageClosureWithRetry(target) {
|
|
1537
|
+
let lastError = new Error("staging never ran");
|
|
1538
|
+
for (let attempt = 1; attempt <= STAGING_ATTEMPTS; attempt += 1) {
|
|
1539
|
+
try {
|
|
1540
|
+
return await this.stageClosure(target);
|
|
1541
|
+
} catch (err) {
|
|
1542
|
+
lastError = err;
|
|
1543
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
1544
|
+
const transient = isTransientNpmNetworkError(message);
|
|
1545
|
+
const delayMs = STAGING_RETRY_DELAYS_MS[attempt - 1];
|
|
1546
|
+
const willRetry = transient && attempt < STAGING_ATTEMPTS && delayMs !== void 0;
|
|
1547
|
+
if (!willRetry) {
|
|
1548
|
+
this.logger.error("root package update staging failed", {
|
|
1549
|
+
tags: this.logTags,
|
|
1550
|
+
meta: {
|
|
1551
|
+
targetVersion: target,
|
|
1552
|
+
attempt,
|
|
1553
|
+
attempts: STAGING_ATTEMPTS,
|
|
1554
|
+
transient,
|
|
1555
|
+
error: message
|
|
1556
|
+
}
|
|
1557
|
+
});
|
|
1558
|
+
break;
|
|
1559
|
+
}
|
|
1560
|
+
this.logger.warn("root package update staging failed \u2014 transient network error, retrying", {
|
|
1561
|
+
tags: this.logTags,
|
|
1562
|
+
meta: {
|
|
1563
|
+
targetVersion: target,
|
|
1564
|
+
attempt,
|
|
1565
|
+
attempts: STAGING_ATTEMPTS,
|
|
1566
|
+
delayMs,
|
|
1567
|
+
error: message
|
|
1568
|
+
}
|
|
1569
|
+
});
|
|
1570
|
+
await this.sleep(delayMs);
|
|
1571
|
+
}
|
|
1572
|
+
}
|
|
1573
|
+
throw lastError;
|
|
1574
|
+
}
|
|
1575
|
+
/**
|
|
1438
1576
|
* npm-install the target closure into a SAME-FS `.staging-*` dir, then
|
|
1439
1577
|
* validate it (entry + version + native prebuilds). Returns the staging dir
|
|
1440
1578
|
* path for the pending-root-swap marker; the STARTER swaps it into `current`
|
|
@@ -1583,7 +1721,10 @@ var require_dist = __commonJS({
|
|
|
1583
1721
|
accepted: false,
|
|
1584
1722
|
targetVersion: null,
|
|
1585
1723
|
restarting: false,
|
|
1586
|
-
message: "Rollback is not supported in the single-copy model (no retained previous version). Recover by re-applying a known-good version via applyServerUpdate, or reinstall the image seed."
|
|
1724
|
+
message: "Rollback is not supported in the single-copy model (no retained previous version). Recover by re-applying a known-good version via applyServerUpdate, or reinstall the image seed.",
|
|
1725
|
+
outcome: "refused",
|
|
1726
|
+
reason: ROLLBACK_UNSUPPORTED_REASON,
|
|
1727
|
+
error: null
|
|
1587
1728
|
};
|
|
1588
1729
|
}
|
|
1589
1730
|
// ── Plain restart ─────────────────────────────────────────────────────
|
|
@@ -1608,7 +1749,10 @@ var require_dist = __commonJS({
|
|
|
1608
1749
|
accepted: false,
|
|
1609
1750
|
targetVersion,
|
|
1610
1751
|
restarting: false,
|
|
1611
|
-
message
|
|
1752
|
+
message,
|
|
1753
|
+
outcome: "refused",
|
|
1754
|
+
reason,
|
|
1755
|
+
error: null
|
|
1612
1756
|
};
|
|
1613
1757
|
}
|
|
1614
1758
|
restartNode() {
|
|
@@ -1630,7 +1774,10 @@ var require_dist = __commonJS({
|
|
|
1630
1774
|
accepted: true,
|
|
1631
1775
|
targetVersion: runningVersion,
|
|
1632
1776
|
restarting: true,
|
|
1633
|
-
message: "Restarting the node \u2014 the supervisor relaunches it on the same version."
|
|
1777
|
+
message: "Restarting the node \u2014 the supervisor relaunches it on the same version.",
|
|
1778
|
+
outcome: "restart-scheduled",
|
|
1779
|
+
reason: null,
|
|
1780
|
+
error: null
|
|
1634
1781
|
};
|
|
1635
1782
|
}
|
|
1636
1783
|
// ── Boot health confirmation ──────────────────────────────────────────
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@camstack/server",
|
|
3
|
-
"version": "1.2.
|
|
3
|
+
"version": "1.2.355",
|
|
4
4
|
"private": false,
|
|
5
5
|
"files": [
|
|
6
6
|
"dist",
|
|
@@ -35,18 +35,18 @@
|
|
|
35
35
|
]
|
|
36
36
|
},
|
|
37
37
|
"dependencies": {
|
|
38
|
-
"@camstack/addon-admin-ui": "1.2.
|
|
39
|
-
"@camstack/addon-agent-ui": "1.2.
|
|
38
|
+
"@camstack/addon-admin-ui": "1.2.244",
|
|
39
|
+
"@camstack/addon-agent-ui": "1.2.132",
|
|
40
40
|
"@camstack/addon-auth": "1.2.123",
|
|
41
41
|
"@camstack/addon-decoder-nodeav": "1.2.115",
|
|
42
42
|
"@camstack/addon-notifiers": "1.2.119",
|
|
43
|
-
"@camstack/addon-pipeline": "1.2.
|
|
44
|
-
"@camstack/addon-pipeline-orchestrator": "1.2.
|
|
45
|
-
"@camstack/addon-post-analysis": "1.2.
|
|
43
|
+
"@camstack/addon-pipeline": "1.2.273",
|
|
44
|
+
"@camstack/addon-pipeline-orchestrator": "1.2.231",
|
|
45
|
+
"@camstack/addon-post-analysis": "1.2.272",
|
|
46
46
|
"@camstack/sdk": "1.2.118",
|
|
47
|
-
"@camstack/system": "1.2.
|
|
48
|
-
"@camstack/types": "1.2.
|
|
49
|
-
"@camstack/ui-library": "1.2.
|
|
47
|
+
"@camstack/system": "1.2.276",
|
|
48
|
+
"@camstack/types": "1.2.234",
|
|
49
|
+
"@camstack/ui-library": "1.2.190",
|
|
50
50
|
"@fastify/compress": "^9.0.0",
|
|
51
51
|
"@fastify/cookie": "^11.0.2",
|
|
52
52
|
"@fastify/cors": "^11.2.0",
|