@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.
@@ -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
- setDeviceCollectionFilter: trpc_middleware_1.adminProcedure
209
- .input(zod_1.z.object({ deviceId: zod_1.z.string(), capability: zod_1.z.string(), addonIds: zod_1.z.array(zod_1.z.string()) }))
210
- .mutation(({ input }) => {
211
- const registry = addonRegistry.getCapabilityRegistry();
212
- registry.setDeviceCollectionFilter(input.deviceId, input.capability, input.addonIds);
213
- logger.info('Device collection filter set', {
214
- tags: { deviceId: Number(input.deviceId) },
215
- meta: { capability: input.capability, addonIds: input.addonIds },
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
- setDeviceCollectionFilter: trpc_middleware_js_1.adminProcedure
118
- .input(zod_1.z.object({
119
- deviceId: zod_1.z.string(),
120
- capability: zod_1.z.string(),
121
- addonIds: zod_1.z.array(zod_1.z.string()),
122
- }))
123
- .output(zod_1.z.void())
124
- .mutation(({ input }) => {
125
- if (!registry)
126
- throw new Error('Capability registry unavailable');
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
- if (def.mode !== 'collection')
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.getDefinition(capName)?.mode !== 'collection')
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.getDefinition(capName)?.mode !== 'collection')
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?.getSingleton('audio-analysis') ?? null, remoteCapProxy),
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?.getSingleton('camera-pipeline-config') ?? null, remoteCapProxy),
111
- cameraStreams: (0, generated_cap_routers_js_1.createCapRouter_cameraStreams)((_ctx) => reg?.getSingleton('camera-streams') ?? null, remoteCapProxy),
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?.getSingleton('detection-pipeline') ?? null, remoteCapProxy),
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?.getSingleton('motion-detection') ??
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?.getSingleton('pipeline-analytics') ?? null, remoteCapProxy),
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?.getSingleton('scene-monitor') ?? null, remoteCapProxy),
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?.getSingleton('videoclips') ?? null, remoteCapProxy),
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.adminProcedure
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
- getClipPlayback: trpc_middleware_js_1.adminProcedure
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 readPackageVersion(this.runningPackageJsonPath);
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 pending = readPendingRootSwap(this.rootDir());
1214
- const updateAvailable = latestVersion !== null && runningVersion !== null && compareSemver(latestVersion, runningVersion) > 0;
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: pending?.version ?? null,
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 !== null && runningVersion !== null && compareSemver(latestVersion, runningVersion) > 0;
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.stageClosure(target);
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.353",
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.242",
39
- "@camstack/addon-agent-ui": "1.2.131",
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.271",
44
- "@camstack/addon-pipeline-orchestrator": "1.2.230",
45
- "@camstack/addon-post-analysis": "1.2.270",
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.274",
48
- "@camstack/types": "1.2.232",
49
- "@camstack/ui-library": "1.2.189",
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",