@camstack/system 1.2.79 → 1.2.81

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.
Files changed (55) hide show
  1. package/dist/builtins/addon-pages-aggregator/addon-pages-aggregator.addon.js +1 -1
  2. package/dist/builtins/addon-pages-aggregator/addon-pages-aggregator.addon.mjs +1 -1
  3. package/dist/builtins/addon-widgets-aggregator/addon-widgets-aggregator.addon.js +1 -1
  4. package/dist/builtins/addon-widgets-aggregator/addon-widgets-aggregator.addon.mjs +1 -1
  5. package/dist/builtins/alerts/alerts.addon.js +1 -1
  6. package/dist/builtins/alerts/alerts.addon.mjs +1 -1
  7. package/dist/builtins/backup-orchestrator/backup-orchestrator.addon.js +1 -1
  8. package/dist/builtins/backup-orchestrator/backup-orchestrator.addon.mjs +1 -1
  9. package/dist/builtins/console-logging/index.js +1 -1
  10. package/dist/builtins/console-logging/index.mjs +1 -1
  11. package/dist/builtins/core-blocks/core-blocks.addon.js +1 -1
  12. package/dist/builtins/core-blocks/core-blocks.addon.mjs +1 -1
  13. package/dist/builtins/device-manager/device-bindings-store.d.ts +21 -0
  14. package/dist/builtins/device-manager/device-manager.addon.js +23 -2
  15. package/dist/builtins/device-manager/device-manager.addon.mjs +23 -2
  16. package/dist/builtins/doorbell/virtual-doorbell.addon.js +1 -1
  17. package/dist/builtins/doorbell/virtual-doorbell.addon.mjs +1 -1
  18. package/dist/builtins/hub-forwarder/index.js +1 -1
  19. package/dist/builtins/hub-forwarder/index.mjs +1 -1
  20. package/dist/builtins/liveness-monitor/liveness-monitor.addon.js +1 -1
  21. package/dist/builtins/liveness-monitor/liveness-monitor.addon.mjs +1 -1
  22. package/dist/builtins/local-auth/local-auth.addon.js +1 -1
  23. package/dist/builtins/local-auth/local-auth.addon.mjs +1 -1
  24. package/dist/builtins/local-network/local-network.addon.js +1 -1
  25. package/dist/builtins/local-network/local-network.addon.mjs +1 -1
  26. package/dist/builtins/loki-logging/index.js +1 -1
  27. package/dist/builtins/loki-logging/index.mjs +1 -1
  28. package/dist/builtins/native-metrics/native-metrics.addon.js +1 -1
  29. package/dist/builtins/native-metrics/native-metrics.addon.mjs +1 -1
  30. package/dist/builtins/platform-probe/index.js +1 -1
  31. package/dist/builtins/platform-probe/index.mjs +1 -1
  32. package/dist/builtins/remote-access-orchestrator/remote-access-orchestrator.addon.js +1 -1
  33. package/dist/builtins/remote-access-orchestrator/remote-access-orchestrator.addon.mjs +1 -1
  34. package/dist/builtins/snapshot/index.js +557 -533
  35. package/dist/builtins/snapshot/index.mjs +556 -533
  36. package/dist/builtins/snapshot/snapshot-coalescing.d.ts +6 -1
  37. package/dist/builtins/snapshot/snapshot-courtesy.d.ts +36 -0
  38. package/dist/builtins/snapshot/snapshot-resize.d.ts +43 -11
  39. package/dist/builtins/snapshot/snapshot.addon.d.ts +52 -45
  40. package/dist/builtins/sqlite-storage/filesystem-storage.addon.js +1 -1
  41. package/dist/builtins/sqlite-storage/filesystem-storage.addon.mjs +1 -1
  42. package/dist/builtins/sqlite-storage/sqlite-settings.addon.js +1 -1
  43. package/dist/builtins/sqlite-storage/sqlite-settings.addon.mjs +1 -1
  44. package/dist/builtins/storage-orchestrator/storage-orchestrator.addon.js +1 -1
  45. package/dist/builtins/storage-orchestrator/storage-orchestrator.addon.mjs +1 -1
  46. package/dist/builtins/system-config/system-config.addon.js +1 -1
  47. package/dist/builtins/system-config/system-config.addon.mjs +1 -1
  48. package/dist/builtins/winston-logging/index.js +1 -1
  49. package/dist/builtins/winston-logging/index.mjs +1 -1
  50. package/dist/{dist-cINpngi1.js → dist-CA2mim74.js} +48 -16
  51. package/dist/{dist-CIIFhXqm.mjs → dist-DZOZtn-0.mjs} +48 -16
  52. package/dist/index.js +1 -1
  53. package/dist/index.mjs +1 -1
  54. package/package.json +1 -1
  55. package/dist/builtins/snapshot/snapshot-keep-warm.d.ts +0 -86
@@ -42,8 +42,13 @@ export declare const SWR_COLD_WAIT_MS = 10000;
42
42
  * Max simultaneous ffmpeg keyframe grabs — the WRAPPER path, the common case for
43
43
  * cameras with no vendor snapshot URL. A 20-cam grid mount spawns at most this
44
44
  * many ffmpeg processes; the rest serve cached frames (SWR) or queue.
45
+ *
46
+ * Raised 3 → 6 on 2026-08-12 (operator): a 17-camera cold start serialised
47
+ * into 6 waves and the client-side "image displayed" clustering matched the
48
+ * pool exactly (p90 10.8 s). Measured client-side after the raise — if the
49
+ * hub shows decode pressure, this is the first knob to lower.
45
50
  */
46
- export declare const MAX_CONCURRENT_GRABS = 3;
51
+ export declare const MAX_CONCURRENT_GRABS = 6;
47
52
  /**
48
53
  * Max simultaneous cheap native snapshot fetches (vendor HTTP/ONVIF). Higher than
49
54
  * the ffmpeg bound because a native fetch is a lightweight HTTP round-trip, not a
@@ -0,0 +1,36 @@
1
+ /** Why a camera cannot produce a frame right now. */
2
+ export type CourtesyReason = 'disabled' | 'offline' | 'sleeping';
3
+ /** Default tile size when the caller asks for no particular width. */
4
+ export declare const COURTESY_DEFAULT_WIDTH = 640;
5
+ /** The word the frame carries. Deliberately the operator's vocabulary. */
6
+ export declare function courtesyLabel(reason: CourtesyReason): string;
7
+ /**
8
+ * Background per reason. A disabled camera is a DELIBERATE state and must not
9
+ * read as an alarm, so it is neutral grey; offline is a fault and is warmer.
10
+ */
11
+ export declare function courtesyBackground(reason: CourtesyReason): string;
12
+ /** XML escaping for text placed inside an SVG `<text>` node. */
13
+ export declare function escapeCourtesyText(value: string): string;
14
+ export interface CourtesyFrameSpec {
15
+ readonly deviceName: string;
16
+ readonly reason: CourtesyReason;
17
+ readonly width: number;
18
+ readonly height: number;
19
+ }
20
+ /**
21
+ * The SVG the frame is rasterized from: the state large and centred, the
22
+ * camera's name under it, smaller and dimmer.
23
+ *
24
+ * Sizes derive from the width so a 240 px grid tile and a 1920 px full-bleed
25
+ * frame read the same, with a floor so a thumbnail stays legible.
26
+ */
27
+ export declare function buildCourtesySvg(spec: CourtesyFrameSpec): string;
28
+ /** Cache key — a courtesy frame is a pure function of these four. */
29
+ export declare function courtesyCacheKey(spec: CourtesyFrameSpec): string;
30
+ /**
31
+ * Render the frame.
32
+ *
33
+ * Rejects on failure — the caller decides what to do, exactly as `resizeJpeg`
34
+ * does, so a broken courtesy path is never mistaken for a broken camera.
35
+ */
36
+ export declare function renderCourtesyJpeg(spec: CourtesyFrameSpec): Promise<Buffer>;
@@ -7,24 +7,56 @@
7
7
  * ffmpeg filter chain to hook into. Resizing the finished bytes gives both the
8
8
  * same behaviour with one implementation.
9
9
  *
10
- * The cost is one ffmpeg run per (device, stream, width) per cache window — not
11
- * one per request. A camera whose card is on screen pays it once every
12
- * `snapshotMaxAgeS`.
10
+ * ── Why sharp and not ffmpeg (2026-08-11) ─────────────────────────────────
11
+ * This used to `spawn('ffmpeg')` per resize. The snapshot wrapper is a system
12
+ * builtin, so it loads in the hub's ROOT process: sampled on the live hub,
13
+ * ~1.6 of those children were running at any instant, every one of them
14
+ * parented by the root PID.
13
15
  *
14
- * `ffmpeg` is already this addon's dependency (the keyframe grab shells out to
15
- * it), so this adds no new one.
16
+ * Be precise about what that cost the root process, because it is easy to
17
+ * overstate. The transcode itself ran in the CHILD and was charged to ffmpeg
18
+ * (16 % + 13 % of a core in that same sample), not to the parent. What the
19
+ * parent paid was the fork, the exec, the JPEG written into one pipe and read
20
+ * back out of the other, and the base64 — real event-loop work, on the loop
21
+ * that also serves the tRPC API, but NOT the 52 ms below.
22
+ *
23
+ * Benchmarked in that container, 2560×1440 → 640 wide, wall-clock per resize:
24
+ *
25
+ * ffmpeg 52.0 ms (fork + exec + pipe round-trip + teardown)
26
+ * sharp 4.8 ms (in-process libvips, on its own threadpool)
27
+ *
28
+ * So this removes ~29 % of a core of container CPU and the per-resize process
29
+ * churn from the root process. It was NOT verified to be the cause of that
30
+ * process sitting at 90 % — nobody has profiled it.
31
+ *
32
+ * `sharp` is not a new dependency: it is already a host-external
33
+ * (`HOST_EXTERNAL_SPECIFIERS`), already resolved from the framework closure at
34
+ * runtime, and already used by the terminal frame renderer. The build preset
35
+ * keeps it out of the bundle, so this import costs nothing at pack time.
36
+ *
37
+ * The cost is still one resize per (device, stream, width) per cache window —
38
+ * not one per request. What changed is what a resize COSTS.
16
39
  */
17
40
  /** Runs the resize. Injected so the caller is testable without a real ffmpeg. */
18
41
  export type JpegResizer = (bytes: Buffer, width: number) => Promise<Buffer>;
19
42
  export declare const RESIZE_TIMEOUT_MS = 10000;
20
43
  /**
21
- * Scale to `width`, preserving aspect ratio (`-2` keeps the height even, which
22
- * mjpeg requires).
44
+ * Scale to `width`, preserving aspect ratio.
45
+ *
46
+ * Rejects on failure or timeout. It NEVER falls back to the original silently —
47
+ * a caller that quietly served the 4K frame when the resize failed would
48
+ * reproduce exactly the bug this whole module exists to fix, and nobody would
49
+ * see it. The caller decides, and logs.
23
50
  *
24
- * Rejects on a non-zero exit or a timeout. It NEVER falls back to the original
25
- * silently a caller that quietly served the 4K frame when the resize failed
26
- * would reproduce exactly the bug this whole change exists to fix, and nobody
27
- * would see it. The caller decides, and logs.
51
+ * It DOES upscale a source narrower than `width`, and that is deliberate. A
52
+ * `withoutEnlargement: true` was tried first it is the obvious saving, since
53
+ * upscaling pays encode cost for a blurrier, larger image. But the caller
54
+ * stamps the response ETag from the width it asked for, and its resize-FAILURE
55
+ * path already returns `width: undefined` specifically so a response can never
56
+ * "claim a width the bytes do not have". Silently returning 320 px bytes for a
57
+ * `w=640` request breaks that invariant on the SUCCESS path, where nobody is
58
+ * looking. Honest output width beats a few saved pixels; revisit only together
59
+ * with the ETag.
28
60
  */
29
61
  export declare function resizeJpeg(bytes: Buffer, width: number, timeoutMs?: number): Promise<Buffer>;
30
62
  /**
@@ -34,6 +34,15 @@ export declare class SnapshotAddon extends BaseAddon<SnapshotAddonConfig> {
34
34
  /** Card-sized derivatives of the frames above, keyed by width and validated
35
35
  * against the source frame's timestamp. */
36
36
  private readonly variants;
37
+ /**
38
+ * Rendered courtesy frames, by (reason, size, device name).
39
+ *
40
+ * A courtesy frame is a pure function of those three, so it is rendered once
41
+ * and reused for as long as the process lives — a disabled camera must not
42
+ * cost an ffmpeg run per poll. Unbounded on purpose: the key space is
43
+ * (3 reasons × this node's own cameras), not user input.
44
+ */
45
+ private readonly courtesyFrames;
37
46
  /**
38
47
  * De-dupes concurrent captures per `${deviceId}:${streamId}` and holds a
39
48
  * settled SUCCESS for COALESCE_MS so a grid-mount burst (and a row of refresh
@@ -72,17 +81,6 @@ export declare class SnapshotAddon extends BaseAddon<SnapshotAddonConfig> {
72
81
  * secret that rolled on every boot would invalidate every link a client is
73
82
  * currently holding, which is a fleet-wide blank grid after each restart. */
74
83
  private linkSecret;
75
- /**
76
- * Who is being looked at, inferred from data-plane traffic. The keep-warm
77
- * loop's entire bound — see `snapshot-keep-warm.ts`.
78
- */
79
- private readonly subscriptions;
80
- /** Chained warm-tick timer (never `setInterval` — see `scheduleWarmTick`). */
81
- private warmTimer;
82
- /** Set while a warm tick is running so ticks can never stack. */
83
- private warmTickRunning;
84
- /** Flipped on shutdown so an in-flight tick does not reschedule itself. */
85
- private stopped;
86
84
  /**
87
85
  * How a full frame becomes a card-sized one. Defaults to the real ffmpeg
88
86
  * resize; injectable so the wrapper's own logic is testable without spawning a
@@ -190,40 +188,6 @@ export declare class SnapshotAddon extends BaseAddon<SnapshotAddonConfig> {
190
188
  * which is the exact staleness this change exists to remove.
191
189
  */
192
190
  private peekFreshFrame;
193
- /**
194
- * Schedule the next warm tick. A CHAINED timer, not `setInterval`: the next
195
- * tick is armed only after the previous one settles, so a fleet that is slower
196
- * than the interval degrades to "as fast as it can" instead of stacking ticks
197
- * on top of each other and exhausting the ffmpeg pool.
198
- *
199
- * `unref` so the loop never holds the process open.
200
- */
201
- private scheduleWarmTick;
202
- /**
203
- * Refresh the cache for every camera somebody is currently looking at.
204
- *
205
- * Deliberately NOT a blind poll of the whole fleet: with nobody watching this
206
- * walks an empty list and costs nothing, which is the entire difference
207
- * between this and a background poller.
208
- *
209
- * Battery cameras need no special case here and must not get one. A warm tick
210
- * is a plain non-forced `getSnapshot`, so it meets the two gates that already
211
- * exist — the 3600 s battery max-age (which makes the tick a cache hit) and
212
- * the sleeping-battery gate (which refuses to capture without `force`). A
213
- * second battery check here would be a rule that disagrees with the authority
214
- * that already owns it.
215
- */
216
- private runWarmTick;
217
- /**
218
- * Warm one (device, stream): refresh the frame, then pre-derive the widths
219
- * that were actually requested.
220
- *
221
- * Deriving the variants matters as much as the capture. Warming only the full
222
- * frame would leave every grid tile still paying an ffmpeg resize on the
223
- * request path — the cache would be fresh and the tile would still be slow.
224
- */
225
- private warmOne;
226
- private warmOneUnbounded;
227
191
  /**
228
192
  * The frame at a card-sized width, derived once per (device, stream, width)
229
193
  * per capture.
@@ -253,6 +217,16 @@ export declare class SnapshotAddon extends BaseAddon<SnapshotAddonConfig> {
253
217
  * and no cache propagates.
254
218
  */
255
219
  private resolveOutcome;
220
+ /**
221
+ * Reconcile backstop for a stale hit: is the frame we are about to serve the
222
+ * property of a device that no longer exists?
223
+ *
224
+ * Asked ONLY past `staleTtlMs`, which is already the "something is wrong"
225
+ * threshold — so the common path costs nothing. A `true` answer evicts and
226
+ * the caller returns null; anything else leaves the existing
227
+ * keep-the-UI-from-going-blank contract untouched.
228
+ */
229
+ private staleHitBelongsToRemovedDevice;
256
230
  /**
257
231
  * Run the capture ladder ONCE for a device: native provider first, then the
258
232
  * stream-broker ffmpeg fallback. Never rejects — resolves a {@link
@@ -325,6 +299,25 @@ export declare class SnapshotAddon extends BaseAddon<SnapshotAddonConfig> {
325
299
  */
326
300
  private runGrabWithResumeRetry;
327
301
  private invalidateCache;
302
+ /** Forget every cached artefact of one device: frames, derived variants and
303
+ * any settled single-flight result that would answer the next request. */
304
+ private dropDeviceCaches;
305
+ /**
306
+ * The device is GONE — drop its cached frames and say so.
307
+ *
308
+ * Distinct from `invalidateCache` (a refresh, expected and silent): this is
309
+ * the terminal case, and a deleted camera that keeps answering with its last
310
+ * JPEG looks to an operator exactly like a camera that was never deleted.
311
+ */
312
+ private evictRemovedDevice;
313
+ /**
314
+ * Does device-manager still know this id?
315
+ *
316
+ * `null` means the question could not be answered — a transport hiccup is
317
+ * not a deletion (D49), and the caller keeps serving what it has. Only an
318
+ * explicit "no such device" evicts.
319
+ */
320
+ private deviceStillExists;
328
321
  /**
329
322
  * Sleep state from the device-state MIRROR, not from a cap round-trip.
330
323
  *
@@ -378,6 +371,20 @@ export declare class SnapshotAddon extends BaseAddon<SnapshotAddonConfig> {
378
371
  private getStatus;
379
372
  private readDeviceSettings;
380
373
  private buildDeviceSettingsContribution;
374
+ /**
375
+ * The frame a camera that CANNOT capture answers with.
376
+ *
377
+ * Returns null only if rendering itself fails — the caller then falls through
378
+ * to its previous behaviour, so a broken courtesy path degrades to the old
379
+ * 404 rather than masking a real capture failure as a placeholder.
380
+ *
381
+ * Rendered at the full tile size; the existing resize ladder derives the
382
+ * card-sized variants from it exactly as it does for a real frame, so no
383
+ * downstream consumer needs to know this frame is synthetic.
384
+ */
385
+ private courtesyFrame;
386
+ /** A courtesy frame shaped as the cap's response, or null when unrenderable. */
387
+ private courtesyImage;
381
388
  /**
382
389
  * Single-trip device lookup against device-manager. Returns the
383
390
  * fields the wrapper actually consults — name (logging) + battery
@@ -3,7 +3,7 @@ Object.defineProperties(exports, {
3
3
  [Symbol.toStringTag]: { value: "Module" }
4
4
  });
5
5
  const require_chunk = require("../../chunk-Cek0wNdY.js");
6
- const require_dist = require("../../dist-cINpngi1.js");
6
+ const require_dist = require("../../dist-CA2mim74.js");
7
7
  let node_crypto = require("node:crypto");
8
8
  let node_fs_promises = require("node:fs/promises");
9
9
  let node_path = require("node:path");
@@ -1,4 +1,4 @@
1
- import { gt as BaseAddon, ut as storageProviderCapability, z as filesystemBrowseCapability } from "../../dist-CIIFhXqm.mjs";
1
+ import { gt as BaseAddon, ut as storageProviderCapability, z as filesystemBrowseCapability } from "../../dist-DZOZtn-0.mjs";
2
2
  import { randomUUID } from "node:crypto";
3
3
  import { mkdir, readdir, realpath, statfs } from "node:fs/promises";
4
4
  import * as path$1 from "node:path";
@@ -3,7 +3,7 @@ Object.defineProperties(exports, {
3
3
  [Symbol.toStringTag]: { value: "Module" }
4
4
  });
5
5
  const require_chunk = require("../../chunk-Cek0wNdY.js");
6
- const require_dist = require("../../dist-cINpngi1.js");
6
+ const require_dist = require("../../dist-CA2mim74.js");
7
7
  let node_crypto = require("node:crypto");
8
8
  let node_fs = require("node:fs");
9
9
  let node_module = require("node:module");
@@ -1,4 +1,4 @@
1
- import { A as decodeVectorBase64, Et as asJsonObject, Lt as parseJsonUnknown, f as RUNTIME_DEFAULTS, gt as BaseAddon, ht as errMsg, k as dataStoreProviderCapability, mt as vectorStoreCapability, pt as vectorDimFromBase64, w as bareAddonId } from "../../dist-CIIFhXqm.mjs";
1
+ import { A as decodeVectorBase64, Et as asJsonObject, Lt as parseJsonUnknown, f as RUNTIME_DEFAULTS, gt as BaseAddon, ht as errMsg, k as dataStoreProviderCapability, mt as vectorStoreCapability, pt as vectorDimFromBase64, w as bareAddonId } from "../../dist-DZOZtn-0.mjs";
2
2
  import { createRequire } from "node:module";
3
3
  import { randomUUID } from "node:crypto";
4
4
  import { statSync } from "node:fs";
@@ -3,7 +3,7 @@ Object.defineProperties(exports, {
3
3
  [Symbol.toStringTag]: { value: "Module" }
4
4
  });
5
5
  const require_chunk = require("../../chunk-Cek0wNdY.js");
6
- const require_dist = require("../../dist-cINpngi1.js");
6
+ const require_dist = require("../../dist-CA2mim74.js");
7
7
  let node_crypto = require("node:crypto");
8
8
  let node_fs_promises = require("node:fs/promises");
9
9
  node_fs_promises = require_chunk.__toESM(node_fs_promises);
@@ -1,4 +1,4 @@
1
- import { It as parseJsonObject, ct as storageCapability, g as StorageMigrationJobSchema, gt as BaseAddon, h as StorageLocationTypeSchema, lt as storageMigrationCapability, ot as settingsStoreCapability } from "../../dist-CIIFhXqm.mjs";
1
+ import { It as parseJsonObject, ct as storageCapability, g as StorageMigrationJobSchema, gt as BaseAddon, h as StorageLocationTypeSchema, lt as storageMigrationCapability, ot as settingsStoreCapability } from "../../dist-DZOZtn-0.mjs";
2
2
  import { randomUUID } from "node:crypto";
3
3
  import * as fs from "node:fs/promises";
4
4
  import * as path$1 from "node:path";
@@ -3,7 +3,7 @@ Object.defineProperties(exports, {
3
3
  [Symbol.toStringTag]: { value: "Module" }
4
4
  });
5
5
  require("../../chunk-Cek0wNdY.js");
6
- const require_dist = require("../../dist-cINpngi1.js");
6
+ const require_dist = require("../../dist-CA2mim74.js");
7
7
  //#region src/builtins/system-config/system-config.addon.ts
8
8
  /**
9
9
  * Built-in `system-config` addon — Phase 4 of the settings redesign.
@@ -1,4 +1,4 @@
1
- import { Nt as hydrateSchema, gt as BaseAddon, ht as errMsg } from "../../dist-CIIFhXqm.mjs";
1
+ import { Nt as hydrateSchema, gt as BaseAddon, ht as errMsg } from "../../dist-DZOZtn-0.mjs";
2
2
  //#region src/builtins/system-config/system-config.addon.ts
3
3
  /**
4
4
  * Built-in `system-config` addon — Phase 4 of the settings redesign.
@@ -3,7 +3,7 @@ Object.defineProperties(exports, {
3
3
  [Symbol.toStringTag]: { value: "Module" }
4
4
  });
5
5
  const require_chunk = require("../../chunk-Cek0wNdY.js");
6
- const require_dist = require("../../dist-cINpngi1.js");
6
+ const require_dist = require("../../dist-CA2mim74.js");
7
7
  const require_formatter = require("../../formatter-DqAKDlvN.js");
8
8
  let node_path = require("node:path");
9
9
  node_path = require_chunk.__toESM(node_path);
@@ -1,4 +1,4 @@
1
- import { J as logDestinationCapability, gt as BaseAddon } from "../../dist-CIIFhXqm.mjs";
1
+ import { J as logDestinationCapability, gt as BaseAddon } from "../../dist-DZOZtn-0.mjs";
2
2
  import { t as formatLogLine } from "../../formatter-B7qW8bPJ.mjs";
3
3
  import * as path$1 from "node:path";
4
4
  import path from "node:path";
@@ -17500,24 +17500,28 @@ var snapshotCapability = {
17500
17500
  *
17501
17501
  * `getSnapshotOverview` is cache-only by contract: it answers from whatever
17502
17502
  * the wrapper happens to hold and never captures. Under D93 the client
17503
- * versions its image URL on that answer, and an image REQUEST is what enrols
17504
- * a camera in the keep-warm loop. Both of those are satisfiable by the
17505
- * client's own image cache — `expo-image` is URL-keyed and never revalidates
17506
- * — so a URL painted in a previous session comes off disk with no network,
17507
- * no enrolment, and nothing warming. Measured on the live hub: reopening
17508
- * after two minutes idle painted 15 of 16 tiles at **168 s old** with zero
17509
- * HTTP requests, and the fleet only recovered because a later poll happened
17510
- * to observe a different identity.
17503
+ * versions its image URL on that answer, and an image REQUEST was the only
17504
+ * demand signal. Both of those are satisfiable by the client's own image
17505
+ * cache — `expo-image` is URL-keyed and never revalidates — so a URL painted
17506
+ * in a previous session comes off disk with no network, no demand, and no
17507
+ * capture. Measured on the live hub: reopening after two minutes idle
17508
+ * painted 15 of 16 tiles at **168 s old** with zero HTTP requests, and the
17509
+ * fleet only recovered because a later poll happened to observe a different
17510
+ * identity.
17511
17511
  *
17512
17512
  * ## The two properties that fix it
17513
17513
  *
17514
17514
  * **It is an RPC, so no client cache can answer it.** The demand signal
17515
- * always reaches the wrapper. This method therefore MAY create keep-warm
17516
- * subscriptions, where `getSnapshotOverview` must never (D93) — the
17517
- * distinction is not "one is newer" but that the overview poll is app-wide
17518
- * (a creating overview would warm every camera on the install) while this is
17519
- * called by a rendered surface naming the tiles it is actually painting, at
17520
- * the width it is painting them.
17515
+ * always reaches the wrapper. This method therefore CAPTURES, where
17516
+ * `getSnapshotOverview` must never (D93) — the distinction is not "one is
17517
+ * newer" but that the overview poll is app-wide (a capturing overview would
17518
+ * dial every camera on the install) while this is called by a rendered
17519
+ * surface naming the tiles it is actually painting, at the width it is
17520
+ * painting them.
17521
+ *
17522
+ * Since 2026-08-11 this is the ONLY thing that refreshes a snapshot: the
17523
+ * server-side keep-warm loop was removed (operator directive — on-demand,
17524
+ * always), so a camera nobody is looking at costs nothing at all.
17521
17525
  *
17522
17526
  * **It waits, briefly and boundedly, for the capture it triggered.** The
17523
17527
  * returned `capturedAt` is the frame the link will serve, not the frame the
@@ -27344,9 +27348,10 @@ var DeclaredDevices = class {
27344
27348
  }
27345
27349
  const integrationId = spec.integrationId ?? await this.ensureIntegration(spec.integrationName);
27346
27350
  const index = await this.readIndex();
27351
+ const live = await this.readLiveByStableId();
27347
27352
  const outcomes = [];
27348
27353
  for (const declaration of spec.devices) {
27349
- const outcome = await this.applyDeclaration(declaration, integrationId, index);
27354
+ const outcome = await this.applyDeclaration(declaration, integrationId, index, live);
27350
27355
  if (outcome !== null) outcomes.push(outcome);
27351
27356
  }
27352
27357
  return {
@@ -27392,6 +27397,26 @@ var DeclaredDevices = class {
27392
27397
  return new Map(rows.map((row) => [row.stableId, row]));
27393
27398
  }
27394
27399
  /**
27400
+ * Devices this kernel already has CONSTRUCTED, by stableId.
27401
+ *
27402
+ * Distinct from {@link readIndex}, and the distinction is the bug: the index
27403
+ * is persisted rows, this is live objects. A row without an object must be
27404
+ * adopted; an object must be left exactly as it is.
27405
+ *
27406
+ * Failure is non-fatal and deliberately so — an empty map degrades to the
27407
+ * previous behaviour (attempt the adopt) rather than skipping a device that
27408
+ * genuinely needs bringing up.
27409
+ */
27410
+ async readLiveByStableId() {
27411
+ try {
27412
+ const devices = await this.ports.devices.getAll();
27413
+ return new Map(devices.map((device) => [device.stableId, device]));
27414
+ } catch (err) {
27415
+ this.ports.logger.warn("could not read live devices — falling back to adopt-by-row", { meta: { error: err instanceof Error ? err.message : String(err) } });
27416
+ return /* @__PURE__ */ new Map();
27417
+ }
27418
+ }
27419
+ /**
27395
27420
  * One declaration: adopt what exists, create what does not.
27396
27421
  *
27397
27422
  * The create branch is the destructive one — it seeds `initialMeta`, and
@@ -27400,8 +27425,15 @@ var DeclaredDevices = class {
27400
27425
  * the declared name over the operator's rename. D49: that branch needs a
27401
27426
  * second read to agree.
27402
27427
  */
27403
- async applyDeclaration(declaration, integrationId, index) {
27428
+ async applyDeclaration(declaration, integrationId, index, live) {
27404
27429
  try {
27430
+ const alreadyLive = live.get(declaration.stableId);
27431
+ if (alreadyLive !== void 0) return {
27432
+ stableId: declaration.stableId,
27433
+ deviceId: alreadyLive.id,
27434
+ device: alreadyLive,
27435
+ created: false
27436
+ };
27405
27437
  let existing = index.get(declaration.stableId);
27406
27438
  if (existing === void 0) {
27407
27439
  existing = (await this.readIndex()).get(declaration.stableId);
@@ -17500,24 +17500,28 @@ var snapshotCapability = {
17500
17500
  *
17501
17501
  * `getSnapshotOverview` is cache-only by contract: it answers from whatever
17502
17502
  * the wrapper happens to hold and never captures. Under D93 the client
17503
- * versions its image URL on that answer, and an image REQUEST is what enrols
17504
- * a camera in the keep-warm loop. Both of those are satisfiable by the
17505
- * client's own image cache — `expo-image` is URL-keyed and never revalidates
17506
- * — so a URL painted in a previous session comes off disk with no network,
17507
- * no enrolment, and nothing warming. Measured on the live hub: reopening
17508
- * after two minutes idle painted 15 of 16 tiles at **168 s old** with zero
17509
- * HTTP requests, and the fleet only recovered because a later poll happened
17510
- * to observe a different identity.
17503
+ * versions its image URL on that answer, and an image REQUEST was the only
17504
+ * demand signal. Both of those are satisfiable by the client's own image
17505
+ * cache — `expo-image` is URL-keyed and never revalidates — so a URL painted
17506
+ * in a previous session comes off disk with no network, no demand, and no
17507
+ * capture. Measured on the live hub: reopening after two minutes idle
17508
+ * painted 15 of 16 tiles at **168 s old** with zero HTTP requests, and the
17509
+ * fleet only recovered because a later poll happened to observe a different
17510
+ * identity.
17511
17511
  *
17512
17512
  * ## The two properties that fix it
17513
17513
  *
17514
17514
  * **It is an RPC, so no client cache can answer it.** The demand signal
17515
- * always reaches the wrapper. This method therefore MAY create keep-warm
17516
- * subscriptions, where `getSnapshotOverview` must never (D93) — the
17517
- * distinction is not "one is newer" but that the overview poll is app-wide
17518
- * (a creating overview would warm every camera on the install) while this is
17519
- * called by a rendered surface naming the tiles it is actually painting, at
17520
- * the width it is painting them.
17515
+ * always reaches the wrapper. This method therefore CAPTURES, where
17516
+ * `getSnapshotOverview` must never (D93) — the distinction is not "one is
17517
+ * newer" but that the overview poll is app-wide (a capturing overview would
17518
+ * dial every camera on the install) while this is called by a rendered
17519
+ * surface naming the tiles it is actually painting, at the width it is
17520
+ * painting them.
17521
+ *
17522
+ * Since 2026-08-11 this is the ONLY thing that refreshes a snapshot: the
17523
+ * server-side keep-warm loop was removed (operator directive — on-demand,
17524
+ * always), so a camera nobody is looking at costs nothing at all.
17521
17525
  *
17522
17526
  * **It waits, briefly and boundedly, for the capture it triggered.** The
17523
17527
  * returned `capturedAt` is the frame the link will serve, not the frame the
@@ -27344,9 +27348,10 @@ var DeclaredDevices = class {
27344
27348
  }
27345
27349
  const integrationId = spec.integrationId ?? await this.ensureIntegration(spec.integrationName);
27346
27350
  const index = await this.readIndex();
27351
+ const live = await this.readLiveByStableId();
27347
27352
  const outcomes = [];
27348
27353
  for (const declaration of spec.devices) {
27349
- const outcome = await this.applyDeclaration(declaration, integrationId, index);
27354
+ const outcome = await this.applyDeclaration(declaration, integrationId, index, live);
27350
27355
  if (outcome !== null) outcomes.push(outcome);
27351
27356
  }
27352
27357
  return {
@@ -27392,6 +27397,26 @@ var DeclaredDevices = class {
27392
27397
  return new Map(rows.map((row) => [row.stableId, row]));
27393
27398
  }
27394
27399
  /**
27400
+ * Devices this kernel already has CONSTRUCTED, by stableId.
27401
+ *
27402
+ * Distinct from {@link readIndex}, and the distinction is the bug: the index
27403
+ * is persisted rows, this is live objects. A row without an object must be
27404
+ * adopted; an object must be left exactly as it is.
27405
+ *
27406
+ * Failure is non-fatal and deliberately so — an empty map degrades to the
27407
+ * previous behaviour (attempt the adopt) rather than skipping a device that
27408
+ * genuinely needs bringing up.
27409
+ */
27410
+ async readLiveByStableId() {
27411
+ try {
27412
+ const devices = await this.ports.devices.getAll();
27413
+ return new Map(devices.map((device) => [device.stableId, device]));
27414
+ } catch (err) {
27415
+ this.ports.logger.warn("could not read live devices — falling back to adopt-by-row", { meta: { error: err instanceof Error ? err.message : String(err) } });
27416
+ return /* @__PURE__ */ new Map();
27417
+ }
27418
+ }
27419
+ /**
27395
27420
  * One declaration: adopt what exists, create what does not.
27396
27421
  *
27397
27422
  * The create branch is the destructive one — it seeds `initialMeta`, and
@@ -27400,8 +27425,15 @@ var DeclaredDevices = class {
27400
27425
  * the declared name over the operator's rename. D49: that branch needs a
27401
27426
  * second read to agree.
27402
27427
  */
27403
- async applyDeclaration(declaration, integrationId, index) {
27428
+ async applyDeclaration(declaration, integrationId, index, live) {
27404
27429
  try {
27430
+ const alreadyLive = live.get(declaration.stableId);
27431
+ if (alreadyLive !== void 0) return {
27432
+ stableId: declaration.stableId,
27433
+ deviceId: alreadyLive.id,
27434
+ device: alreadyLive,
27435
+ created: false
27436
+ };
27405
27437
  let existing = index.get(declaration.stableId);
27406
27438
  if (existing === void 0) {
27407
27439
  existing = (await this.readIndex()).get(declaration.stableId);
package/dist/index.js CHANGED
@@ -1,6 +1,6 @@
1
1
  Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
2
2
  const require_chunk = require("./chunk-Cek0wNdY.js");
3
- const require_dist = require("./dist-cINpngi1.js");
3
+ const require_dist = require("./dist-CA2mim74.js");
4
4
  const require_builtins_alerts_alerts_addon = require("./builtins/alerts/alerts.addon.js");
5
5
  require("./builtins/alerts/index.js");
6
6
  const require_formatter = require("./formatter-DqAKDlvN.js");
package/dist/index.mjs CHANGED
@@ -1,5 +1,5 @@
1
1
  import { a as __toCommonJS, i as __require, n as __esmMin, o as __toESM$1, r as __exportAll, t as __commonJSMin$1 } from "./chunk-CNf5ZN-e.mjs";
2
- import { $ as objectInputDeclaresAddonId, At as emitDownForOwnedCaps, B as isArrayOutputSchema, Bt as scopeKey, Ct as ReadinessRegistry, Dt as asNumber, Et as asJsonObject$1, G as kebabToCamel, H as isObjectInput, Ht as EventCategory$1, It as parseJsonObject, K as lifecycleJobSchema, Lt as parseJsonUnknown$1, Mt as expandCapMethods, Ot as asString$1, R as extractNestedAddonId, Rt as readinessKey, V as isCollectionArrayMethod, W as isVoidInput, X as looseSchema, Y as logLevelAtMost, _t as DATAPLANE_SECRET_HEADER$1, d as METHOD_ACCESS_MAP, f as RUNTIME_DEFAULTS, ht as errMsg$1, it as scopesAllowDeviceCap, kt as createEvent, nt as procedureAuthKey, rt as scopesAllowAddon, t as ALL_CAPABILITY_DEFINITIONS, vt as DEVICE_SETTINGS_CONTRIBUTION_METHODS, wt as ReadinessTimeoutError, y as addonSettingsCapability, yt as DEVICE_STATUS_METHOD, zt as resolveCapMount } from "./dist-CIIFhXqm.mjs";
2
+ import { $ as objectInputDeclaresAddonId, At as emitDownForOwnedCaps, B as isArrayOutputSchema, Bt as scopeKey, Ct as ReadinessRegistry, Dt as asNumber, Et as asJsonObject$1, G as kebabToCamel, H as isObjectInput, Ht as EventCategory$1, It as parseJsonObject, K as lifecycleJobSchema, Lt as parseJsonUnknown$1, Mt as expandCapMethods, Ot as asString$1, R as extractNestedAddonId, Rt as readinessKey, V as isCollectionArrayMethod, W as isVoidInput, X as looseSchema, Y as logLevelAtMost, _t as DATAPLANE_SECRET_HEADER$1, d as METHOD_ACCESS_MAP, f as RUNTIME_DEFAULTS, ht as errMsg$1, it as scopesAllowDeviceCap, kt as createEvent, nt as procedureAuthKey, rt as scopesAllowAddon, t as ALL_CAPABILITY_DEFINITIONS, vt as DEVICE_SETTINGS_CONTRIBUTION_METHODS, wt as ReadinessTimeoutError, y as addonSettingsCapability, yt as DEVICE_STATUS_METHOD, zt as resolveCapMount } from "./dist-DZOZtn-0.mjs";
3
3
  import { AlertCenterAddon } from "./builtins/alerts/alerts.addon.mjs";
4
4
  import "./builtins/alerts/index.mjs";
5
5
  import { t as formatLogLine } from "./formatter-B7qW8bPJ.mjs";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@camstack/system",
3
- "version": "1.2.79",
3
+ "version": "1.2.81",
4
4
  "description": "Core addon for CamStack — builtins, pipeline, process management, auth, logging, events",
5
5
  "keywords": [
6
6
  "camstack",