@camstack/types 1.2.160 → 1.2.162

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -1432,378 +1432,6 @@ function logLevelAtMost(level, threshold) {
1432
1432
  return LOG_LEVEL_RANK[level] <= LOG_LEVEL_RANK[threshold];
1433
1433
  }
1434
1434
  //#endregion
1435
- //#region src/logging/log-channel.ts
1436
- /**
1437
- * Per-component log CHANNELS — the gate a hot path consults, and the registry
1438
- * an addon declares its channels in.
1439
- *
1440
- * ## Two axes, deliberately separated
1441
- *
1442
- * - **DECLARATION** — which channels exist. Only the addon knows:
1443
- * `stream-broker` knows webrtc/ICE/RTP, `provider-reolink` knows
1444
- * baichuan/handshake. A hand-wired central list rots at the first addition,
1445
- * and rots silently. So a channel is declared where it is consulted, and the
1446
- * `log-channels` capability enumerates the declarations.
1447
- * - **VALUE** — at which level, for which scope, until when. That stays ONE
1448
- * thing: the logging settings document on the `system` cap. Two authorities
1449
- * over the values is the exact defect
1450
- * `docs/design/plans/2026-08-26-logging-per-componente.md` was written to
1451
- * remove; re-introducing it from the cure side would be grotesque.
1452
- *
1453
- * Nothing in this file reads a clock, an env var or a store. The registry is
1454
- * a MIRROR: it is moved only by {@link LogChannelRegistry.apply}, called off
1455
- * the hot path with a value somebody actually read, and by
1456
- * {@link LogChannelRegistry.tick}, called on a timer. A store read that fails
1457
- * never reaches here, so it can neither disarm an armed channel nor arm a
1458
- * disarmed one (D49).
1459
- *
1460
- * ## The canonical call shape
1461
- *
1462
- * ```ts
1463
- * if (CH_RTP.on && CH_RTP.wants(deviceId)) {
1464
- * CH_RTP.log(logger, 'rtp subscriber added', { tags: { deviceId }, meta: { ssrc } })
1465
- * }
1466
- * ```
1467
- *
1468
- * `on` is a plain boolean FIELD — never a getter — and it is the FIRST thing
1469
- * read. Disarmed, a call site costs one load and one branch, and the `extras`
1470
- * object literal is never constructed because it lives inside the branch. It
1471
- * is the same shape already proven in production at `stream-broker.ts:1650`,
1472
- * and the same discipline `LoggingGate.allowsDestination` uses for the
1473
- * destination floor (measured at 1.93 ns/call when off).
1474
- *
1475
- * ## Why a channel emits at `info`
1476
- *
1477
- * `loki-logging.addon.ts` pins the destination default at `info` and
1478
- * `loki-destination.ts` drops everything below it, so a line emitted at
1479
- * `debug` never reaches Loki and the hub's in-memory ring only holds ~35
1480
- * minutes. A diagnostic that cannot be read an hour later is worse than no
1481
- * diagnostic, because it looks done. {@link LogChannelGate.log} therefore
1482
- * emits at the channel's declared level, whose schema floor is `info`.
1483
- */
1484
- /**
1485
- * The level a channel writes at once armed.
1486
- *
1487
- * `debug` is absent ON PURPOSE and not by omission: below `info` the line does
1488
- * not leave the process for Loki, and the whole point of arming a channel is
1489
- * to read it later.
1490
- */
1491
- var LogChannelLevelSchema = zod.z.enum([
1492
- "info",
1493
- "warn",
1494
- "error"
1495
- ]);
1496
- /**
1497
- * What an addon declares about one channel. No value, no state — a
1498
- * declaration is inert.
1499
- */
1500
- var LogChannelDescriptorSchema = zod.z.object({
1501
- /**
1502
- * Dotted `area.thing`, unique across the workspace. `area` is conventionally
1503
- * the addon's short name so an operator reading a channel list can tell who
1504
- * owns it without a second lookup.
1505
- */
1506
- name: zod.z.string().min(3).regex(/^[a-z0-9-]+(\.[a-z0-9-]+)+$/, "a channel name is dotted lower-kebab, e.g. area.thing"),
1507
- /** One sentence: what the operator will SEE after arming it. */
1508
- description: zod.z.string().min(1),
1509
- /** The level its lines are emitted at. Never below `info`. */
1510
- defaultLevel: LogChannelLevelSchema,
1511
- /**
1512
- * Whether this channel can be narrowed to a camera.
1513
- *
1514
- * `true` is a PROMISE with two halves, and both must hold: the gate is
1515
- * consulted with the numeric device id, AND every line the channel admits
1516
- * carries `tags: { deviceId }` with that same numeric id. The second half is
1517
- * what makes `| json | deviceId="617"` work in Loki — `loki-payload.ts`
1518
- * keeps `deviceId` out of the stream labels for cardinality, so the tag in
1519
- * the body is the only way to filter.
1520
- *
1521
- * A channel whose lines carry the device only in `meta` (or not at all) is
1522
- * declared `false`. Declaring it `true` anyway would be a lie the UI repeats:
1523
- * the operator narrows to one camera, sees nothing, and concludes the code
1524
- * path was never taken.
1525
- */
1526
- perDevice: zod.z.boolean()
1527
- });
1528
- /**
1529
- * An armed window over one channel, as the document hands it to a mirror.
1530
- *
1531
- * A window is a DEADLINE, never a flag (ADR-0244): a channel somebody forgot
1532
- * expires by itself, which is the one failure a boolean cannot avoid.
1533
- */
1534
- var LogChannelWindowSchema = zod.z.object({
1535
- channel: zod.z.string().min(1),
1536
- /** Epoch ms the window closes at. */
1537
- armedUntilMs: zod.z.number(),
1538
- /** `null` = every camera. A non-empty list narrows to those numeric ids. */
1539
- deviceIds: zod.z.array(zod.z.number().int()).readonly().nullable()
1540
- });
1541
- /**
1542
- * The gate a hot path holds.
1543
- *
1544
- * Obtain it ONCE — at module scope or in a constructor — and keep the
1545
- * reference. Looking a channel up by name per line would put a Map lookup on
1546
- * the path this class exists to keep free.
1547
- */
1548
- var LogChannelGate = class {
1549
- descriptor;
1550
- /**
1551
- * HOT PATH GUARD. A plain data FIELD, and it must stay one.
1552
- *
1553
- * `log-channel.spec.ts` asserts the property descriptor has no getter and
1554
- * booby-traps the device set, so turning this into an accessor — or reading
1555
- * anything before it — fails the spec instead of taxing every line the
1556
- * process emits.
1557
- */
1558
- on = false;
1559
- /** `null` while armed for every camera. Never read while `on` is false. */
1560
- devices = null;
1561
- level;
1562
- closesAtMs = 0;
1563
- constructor(descriptor) {
1564
- this.descriptor = descriptor;
1565
- this.level = descriptor.defaultLevel;
1566
- }
1567
- /** Epoch ms this channel disarms itself at. 0 when disarmed. */
1568
- get armedUntilMs() {
1569
- return this.on ? this.closesAtMs : 0;
1570
- }
1571
- /**
1572
- * Does this channel want a line about `deviceId`?
1573
- *
1574
- * Call it only behind `gate.on &&`. On its own it is still correct — the
1575
- * guard is repeated inside — but the point of the prefix is that a disarmed
1576
- * channel must not pay the call at all.
1577
- */
1578
- wants(deviceId) {
1579
- if (!this.on) return false;
1580
- return this.devices === null || this.devices.has(deviceId);
1581
- }
1582
- /**
1583
- * Emit one line on this channel, at the channel's declared level.
1584
- *
1585
- * The channel name is added as `tags.logChannel` so LogQL can select the
1586
- * channel without matching on the message text, and whatever `tags` the
1587
- * caller passed — `deviceId` above all — is preserved.
1588
- */
1589
- log(logger, message, extras) {
1590
- if (!this.on) return;
1591
- const tags = {
1592
- ...extras.tags,
1593
- logChannel: this.descriptor.name
1594
- };
1595
- const line = {
1596
- ...extras,
1597
- tags
1598
- };
1599
- if (this.level === "error") logger.error(message, line);
1600
- else if (this.level === "warn") logger.warn(message, line);
1601
- else logger.info(message, line);
1602
- }
1603
- /**
1604
- * Arm (or RE-arm, restarting) this channel. Off the hot path only.
1605
- *
1606
- * An empty `deviceIds` list is treated as "every camera" rather than "no
1607
- * camera": a window that matches nothing is indistinguishable from a
1608
- * disarmed one, and the operator who asked for it would wait for lines that
1609
- * can never come.
1610
- */
1611
- arm(window) {
1612
- const ids = window.deviceIds;
1613
- this.devices = ids === null || ids.length === 0 ? null : new Set(ids);
1614
- this.closesAtMs = window.armedUntilMs;
1615
- this.on = true;
1616
- }
1617
- /** Disarm. Off the hot path only. */
1618
- disarm() {
1619
- this.on = false;
1620
- this.devices = null;
1621
- this.closesAtMs = 0;
1622
- }
1623
- };
1624
- /**
1625
- * Every channel this PROCESS declares, and the mirror of what is armed on it.
1626
- *
1627
- * One per process. A forked runner has its own, and it is refreshed through
1628
- * the `log-channels` capability by the hub that owns the document — the
1629
- * registry never reaches for a value itself.
1630
- */
1631
- var LogChannelRegistry = class {
1632
- gates = /* @__PURE__ */ new Map();
1633
- /**
1634
- * Declare a channel and get its gate.
1635
- *
1636
- * A duplicate name throws. Two declarations of one name is a programming
1637
- * error, not a merge: the operator would arm one and the other would stay
1638
- * dark, which is the dead-knob shape (D62) with an extra step.
1639
- */
1640
- declare(descriptor) {
1641
- const parsed = LogChannelDescriptorSchema.parse(descriptor);
1642
- if (this.gates.get(parsed.name) !== void 0) throw new Error(`log channel "${parsed.name}" is already declared in this process — two declarations of one name is a programming error, not a merge`);
1643
- const gate = new LogChannelGate(parsed);
1644
- this.gates.set(parsed.name, gate);
1645
- return gate;
1646
- }
1647
- /** The declarations, sorted by name so a list is stable to read and diff. */
1648
- list() {
1649
- return [...this.gates.values()].map((gate) => gate.descriptor).sort((a, b) => a.name.localeCompare(b.name));
1650
- }
1651
- /** The gate for a declared channel, or `undefined`. */
1652
- gate(name) {
1653
- return this.gates.get(name);
1654
- }
1655
- /**
1656
- * Apply the FULL set of armed windows. Off the hot path.
1657
- *
1658
- * Full, not incremental, and that is the whole design: the document is the
1659
- * authority, so a channel the document does not name is disarmed here. An
1660
- * incremental apply would let a disarm get lost in transit and leave a
1661
- * channel running that nobody can see is running.
1662
- *
1663
- * A window already past its deadline is ignored rather than armed — a
1664
- * restore that re-armed an expired window would make a forgotten diagnostic
1665
- * immortal across restarts.
1666
- *
1667
- * Returns the names it could not place, so the caller can log them: a
1668
- * channel named in the document that this process does not declare is
1669
- * either a typo or an addon that has not booted yet, and both deserve a
1670
- * line rather than silence.
1671
- */
1672
- apply(windows, nowMs) {
1673
- const wanted = /* @__PURE__ */ new Map();
1674
- const unknown = [];
1675
- for (const window of windows) {
1676
- if (window.armedUntilMs <= nowMs) continue;
1677
- if (!this.gates.has(window.channel)) {
1678
- unknown.push(window.channel);
1679
- continue;
1680
- }
1681
- wanted.set(window.channel, window);
1682
- }
1683
- for (const [name, gate] of this.gates) {
1684
- const window = wanted.get(name);
1685
- if (window === void 0) gate.disarm();
1686
- else gate.arm(window);
1687
- }
1688
- return unknown;
1689
- }
1690
- /**
1691
- * Disarm whatever has run out. Called on a timer, NEVER from a log path — a
1692
- * diagnostic that adds a `Date.now()` to the path it is measuring measures
1693
- * itself.
1694
- *
1695
- * Returns the names it closed, so the caller can write the one line that
1696
- * says a window ended and stops "it went quiet" from reading as "the branch
1697
- * was not taken".
1698
- */
1699
- tick(nowMs) {
1700
- const closed = [];
1701
- for (const [name, gate] of this.gates) if (gate.on && gate.armedUntilMs <= nowMs) {
1702
- gate.disarm();
1703
- closed.push(name);
1704
- }
1705
- return closed;
1706
- }
1707
- /** The channels armed right now, as the document would describe them. */
1708
- armed() {
1709
- const out = [];
1710
- for (const [name, gate] of this.gates) if (gate.on) out.push({
1711
- channel: name,
1712
- armedUntilMs: gate.armedUntilMs,
1713
- deviceIds: null
1714
- });
1715
- return out;
1716
- }
1717
- };
1718
- //#endregion
1719
- //#region src/logging/log-channel.singleton.ts
1720
- /**
1721
- * Process-wide holder for the {@link LogChannelRegistry}.
1722
- *
1723
- * Three call sites that never meet need the SAME instance: the hot paths that
1724
- * declare a gate at module scope, the `log-channels` provider that enumerates
1725
- * the declarations for the hub, and the same provider applying the windows the
1726
- * document hands down. A registry built inside any one of them would be
1727
- * refreshed and collected — the shape of a knob that never does anything.
1728
- *
1729
- * Same idiom as `logging-gate.singleton.ts` and
1730
- * `http-request-census.singleton.ts`.
1731
- */
1732
- var instance = null;
1733
- /** The process-wide log channel registry. Created empty on first use. */
1734
- function getLogChannelRegistry() {
1735
- instance ??= new LogChannelRegistry();
1736
- return instance;
1737
- }
1738
- /**
1739
- * Declare a channel on the process-wide registry and get its gate.
1740
- *
1741
- * The one call an addon makes. Keep the returned gate in a module-scope
1742
- * `const`: looking a channel up by name per line would put a Map lookup on
1743
- * exactly the path this mechanism exists to keep free.
1744
- *
1745
- * `scripts/check-log-channel-gated.ts` reads these call sites. It pairs the
1746
- * declared name with the binding it is assigned to and refuses to let a
1747
- * channel ship that no `<binding>.on` anywhere consults — a declared channel
1748
- * nobody reads is a knob the operator turns with nothing happening, forever,
1749
- * and without a line. That is D62, and this repo has now shipped it three
1750
- * times (`audioThresholdDbfs`, the HA entities with no source, the second
1751
- * per-camera switch that wrote a store nobody read).
1752
- */
1753
- function declareLogChannel(descriptor) {
1754
- return getLogChannelRegistry().declare(descriptor);
1755
- }
1756
- /** Test-only: drop the instance so a spec starts from an empty registry. */
1757
- function __resetLogChannelRegistryForTests() {
1758
- instance = null;
1759
- }
1760
- //#endregion
1761
- //#region src/logging/log-channel-provider.ts
1762
- /**
1763
- * How often expiry is noticed. Coarse on purpose: the cost of a channel
1764
- * running a few seconds past its deadline is a few extra lines, and the cost
1765
- * of a tight timer in every addon process is paid forever.
1766
- */
1767
- var LOG_CHANNEL_TICK_MS = 5e3;
1768
- /**
1769
- * Build the `log-channels` provider for this process.
1770
- *
1771
- * `logger` is used ONLY off the hot path — for the arm/expiry lines — so a
1772
- * channel that is never armed costs this module nothing but a timer.
1773
- */
1774
- function createLogChannelsProvider(logger, options = {}) {
1775
- const registry = getLogChannelRegistry();
1776
- const now = options.now ?? Date.now;
1777
- const tickMs = options.tickMs ?? 5e3;
1778
- const timer = setInterval(() => {
1779
- const closed = registry.tick(now());
1780
- for (const name of closed) logger.info("log channel window closed", {
1781
- tags: { logChannel: name },
1782
- meta: { channel: name }
1783
- });
1784
- }, tickMs);
1785
- timer.unref?.();
1786
- return {
1787
- list: () => registry.list(),
1788
- apply: (input) => {
1789
- const unknown = registry.apply(input.windows, now());
1790
- const armed = registry.armed();
1791
- logger.info("log channels applied", { meta: {
1792
- armed: armed.map((window) => window.channel),
1793
- unknown,
1794
- declared: registry.list().length
1795
- } });
1796
- return {
1797
- armed: armed.length,
1798
- unknown
1799
- };
1800
- },
1801
- stop: () => {
1802
- clearInterval(timer);
1803
- }
1804
- };
1805
- }
1806
- //#endregion
1807
1435
  //#region src/interfaces/ops-log.ts
1808
1436
  /**
1809
1437
  * Ops-log — the durable, append-only operations audit shared by the
@@ -3041,8 +2669,11 @@ var StorageLocationTypeSchema = zod.z.string().regex(/^[a-z][a-zA-Z0-9-]*$/);
3041
2669
  * `STORAGE_LOCATION_CARDINALITY` map has been removed.
3042
2670
  *
3043
2671
  * `id` is a stable namespaced string of the form `<type>:<slug>`.
3044
- * The default location for a type uses `id === <type>:default` by
3045
- * convention (the bare type ref like `'backups'` resolves to it).
2672
+ * The seed names its first instance `<type>:default` — a NAME, not a flag.
2673
+ * There is no default location any more (D383): `enabled` is the whole write
2674
+ * model, and a bare type ref resolves to the sole location of the type, or —
2675
+ * transitionally, only while legacy NULL-stamped rows exist — to the row whose
2676
+ * slug is `default`.
3046
2677
  *
3047
2678
  * `isSystem` is a legacy persisted flag. Seed still creates the initial
3048
2679
  * `<type>:default` locations; the flag is no longer a lock, a badge, or a
@@ -3063,21 +2694,20 @@ var StorageLocationSchema = zod.z.object({
3063
2694
  * flag at upsert time, not here (the schema is provider-agnostic).
3064
2695
  */
3065
2696
  nodeId: zod.z.string().optional(),
3066
- isDefault: zod.z.boolean().default(false),
3067
2697
  isSystem: zod.z.boolean().default(false),
3068
2698
  /**
3069
- * Operator opt-in: whether consumers that BALANCE across several locations
3070
- * of a type may write here. Recordings reads it today; event media and
3071
- * backups are the next consumers, which is why the flag lives on the
3072
- * location rather than in any one addon's store — nothing has to be
3073
- * extended to add the next consumer.
2699
+ * THE write switch, and the only one (D383). `enabled: true` means every
2700
+ * consumer that chooses a write target for this type may write here, and all
2701
+ * enabled locations of a type are used TOGETHER; `false` means read-only —
2702
+ * still read, still played back, still age-swept, still drained, never
2703
+ * written.
3074
2704
  *
3075
- * OPTIONAL, and ABSENT MEANS ACTIVE. Every location persisted before the
3076
- * flag existed reads back with no flag and keeps working exactly as before;
3077
- * that is the whole compat story, and it is why no migration ships with it.
3078
- * A newly CREATED sibling is stamped `false` by the orchestrator (creating a
3079
- * disk must not silently start writing to it); the default of a type is
3080
- * always stamped `true`.
2705
+ * OPTIONAL only for the wire: an upsert that omits it means "leave what is
2706
+ * stored" on an update and "born inert unless it is the first location of its
2707
+ * type" on a create. On a PERSISTED row absence is legacy and it means
2708
+ * enabled — {@link isLocationEnabled} is the one place that says so, and the
2709
+ * orchestrator stamps every flagless row `true` once at hydrate so absence
2710
+ * stops existing rather than being re-derived on every read.
3081
2711
  */
3082
2712
  enabled: zod.z.boolean().optional(),
3083
2713
  /** COMPUTED at read time by the orchestrator (statfs of the backing volume
@@ -3091,9 +2721,31 @@ var StorageLocationSchema = zod.z.object({
3091
2721
  updatedAt: zod.z.number()
3092
2722
  });
3093
2723
  /**
2724
+ * The retired `isDefault` key, DECLARED rather than dropped.
2725
+ *
2726
+ * A key removed from a non-strict `z.object` is stripped in silence (D380,
2727
+ * D381): a reader that still needs the old value gets `undefined` and cannot
2728
+ * tell "absent" from "never sent". The one reader that legitimately needs it —
2729
+ * the location store's boot migration, which turns the old default flag into
2730
+ * the `enabled` write set — parses THIS schema against the raw row instead, so
2731
+ * the migration is explicit and the live schema stays clean. Nothing else in
2732
+ * the repo may read it; `scripts/check-no-storage-default.ts` enforces that.
2733
+ */
2734
+ var LegacyStorageLocationDefaultSchema = zod.z.object({ isDefault: zod.z.boolean().optional() });
2735
+ /**
2736
+ * Is this location a write target? THE one place that decides what an absent
2737
+ * `enabled` means on a persisted row — legacy rows predate the flag and were
2738
+ * active, so absence is enabled. Every consumer asks here rather than spelling
2739
+ * `enabled !== false` again, so the tri-state has exactly one interpretation.
2740
+ */
2741
+ function isLocationEnabled(location) {
2742
+ return location.enabled !== false;
2743
+ }
2744
+ /**
3094
2745
  * Reference accepted by consumer-facing `api.storage.*` calls.
3095
2746
  * Either:
3096
- * - a `StorageLocationType` (e.g. `'backups'`) → orchestrator resolves to the default of that type
2747
+ * - a `StorageLocationType` (e.g. `'backups'`) → the sole location of that type
2748
+ * (transitionally, the `<type>:default`-slugged row when several exist)
3097
2749
  * - a fully-qualified id (e.g. `'backups:nas-01'`) → addresses a specific instance
3098
2750
  *
3099
2751
  * The orchestrator's `resolveRef(ref)` handles both cases.
@@ -3457,6 +3109,701 @@ function findTimezone(id) {
3457
3109
  return TIMEZONES.find((tz) => tz.id === id);
3458
3110
  }
3459
3111
  //#endregion
3112
+ //#region src/logging/log-channel.ts
3113
+ /**
3114
+ * Per-component log CHANNELS — the gate a hot path consults, and the registry
3115
+ * an addon declares its channels in.
3116
+ *
3117
+ * ## Two axes, deliberately separated
3118
+ *
3119
+ * - **DECLARATION** — which channels exist. Only the addon knows:
3120
+ * `stream-broker` knows webrtc/ICE/RTP, `provider-reolink` knows
3121
+ * baichuan/handshake. A hand-wired central list rots at the first addition,
3122
+ * and rots silently. So a channel is declared where it is consulted, and the
3123
+ * `log-channels` capability enumerates the declarations.
3124
+ * - **VALUE** — at which level, for which scope, until when. That stays ONE
3125
+ * thing: the logging settings document on the `system` cap. Two authorities
3126
+ * over the values is the exact defect
3127
+ * `docs/design/plans/2026-08-26-logging-per-componente.md` was written to
3128
+ * remove; re-introducing it from the cure side would be grotesque.
3129
+ *
3130
+ * Nothing in this file reads a clock, an env var or a store. The registry is
3131
+ * a MIRROR: it is moved only by {@link LogChannelRegistry.apply}, called off
3132
+ * the hot path with a value somebody actually read, and by
3133
+ * {@link LogChannelRegistry.tick}, called on a timer. A store read that fails
3134
+ * never reaches here, so it can neither disarm an armed channel nor arm a
3135
+ * disarmed one (D49).
3136
+ *
3137
+ * ## The canonical call shape
3138
+ *
3139
+ * ```ts
3140
+ * if (CH_RTP.on && CH_RTP.wants(deviceId)) {
3141
+ * CH_RTP.log(logger, 'rtp subscriber added', { tags: { deviceId }, meta: { ssrc } })
3142
+ * }
3143
+ * ```
3144
+ *
3145
+ * `on` is a plain boolean FIELD — never a getter — and it is the FIRST thing
3146
+ * read. Disarmed, a call site costs one load and one branch, and the `extras`
3147
+ * object literal is never constructed because it lives inside the branch. It
3148
+ * is the same shape already proven in production at `stream-broker.ts:1650`,
3149
+ * and the same discipline `LoggingGate.allowsDestination` uses for the
3150
+ * destination floor (measured at 1.93 ns/call when off).
3151
+ *
3152
+ * ## Why a channel emits at `info`
3153
+ *
3154
+ * `loki-logging.addon.ts` pins the destination default at `info` and
3155
+ * `loki-destination.ts` drops everything below it, so a line emitted at
3156
+ * `debug` never reaches Loki and the hub's in-memory ring only holds ~35
3157
+ * minutes. A diagnostic that cannot be read an hour later is worse than no
3158
+ * diagnostic, because it looks done. {@link LogChannelGate.log} therefore
3159
+ * emits at the channel's declared level, whose schema floor is `info`.
3160
+ */
3161
+ /**
3162
+ * The level a channel writes at once armed.
3163
+ *
3164
+ * `debug` is absent ON PURPOSE and not by omission: below `info` the line does
3165
+ * not leave the process for Loki, and the whole point of arming a channel is
3166
+ * to read it later.
3167
+ */
3168
+ var LogChannelLevelSchema = zod.z.enum([
3169
+ "info",
3170
+ "warn",
3171
+ "error"
3172
+ ]);
3173
+ /**
3174
+ * What an addon declares about one channel. No value, no state — a
3175
+ * declaration is inert.
3176
+ */
3177
+ var LogChannelDescriptorSchema = zod.z.object({
3178
+ /**
3179
+ * Dotted `area.thing`, unique across the workspace. `area` is conventionally
3180
+ * the addon's short name so an operator reading a channel list can tell who
3181
+ * owns it without a second lookup.
3182
+ */
3183
+ name: zod.z.string().min(3).regex(/^[a-z0-9-]+(\.[a-z0-9-]+)+$/, "a channel name is dotted lower-kebab, e.g. area.thing"),
3184
+ /** One sentence: what the operator will SEE after arming it. */
3185
+ description: zod.z.string().min(1),
3186
+ /** The level its lines are emitted at. Never below `info`. */
3187
+ defaultLevel: LogChannelLevelSchema,
3188
+ /**
3189
+ * Whether this channel can be narrowed to a camera.
3190
+ *
3191
+ * `true` is a PROMISE with two halves, and both must hold: the gate is
3192
+ * consulted with the numeric device id, AND every line the channel admits
3193
+ * carries `tags: { deviceId }` with that same numeric id. The second half is
3194
+ * what makes `| json | deviceId="617"` work in Loki — `loki-payload.ts`
3195
+ * keeps `deviceId` out of the stream labels for cardinality, so the tag in
3196
+ * the body is the only way to filter.
3197
+ *
3198
+ * A channel whose lines carry the device only in `meta` (or not at all) is
3199
+ * declared `false`. Declaring it `true` anyway would be a lie the UI repeats:
3200
+ * the operator narrows to one camera, sees nothing, and concludes the code
3201
+ * path was never taken.
3202
+ */
3203
+ perDevice: zod.z.boolean()
3204
+ });
3205
+ /**
3206
+ * An armed window over one channel, as the document hands it to a mirror.
3207
+ *
3208
+ * A window is a DEADLINE, never a flag (ADR-0244): a channel somebody forgot
3209
+ * expires by itself, which is the one failure a boolean cannot avoid.
3210
+ */
3211
+ var LogChannelWindowSchema = zod.z.object({
3212
+ channel: zod.z.string().min(1),
3213
+ /** Epoch ms the window closes at. */
3214
+ armedUntilMs: zod.z.number(),
3215
+ /** `null` = every camera. A non-empty list narrows to those numeric ids. */
3216
+ deviceIds: zod.z.array(zod.z.number().int()).readonly().nullable()
3217
+ });
3218
+ /**
3219
+ * The gate a hot path holds.
3220
+ *
3221
+ * Obtain it ONCE — at module scope or in a constructor — and keep the
3222
+ * reference. Looking a channel up by name per line would put a Map lookup on
3223
+ * the path this class exists to keep free.
3224
+ */
3225
+ var LogChannelGate = class {
3226
+ descriptor;
3227
+ /**
3228
+ * HOT PATH GUARD. A plain data FIELD, and it must stay one.
3229
+ *
3230
+ * `log-channel.spec.ts` asserts the property descriptor has no getter and
3231
+ * booby-traps the device set, so turning this into an accessor — or reading
3232
+ * anything before it — fails the spec instead of taxing every line the
3233
+ * process emits.
3234
+ */
3235
+ on = false;
3236
+ /** `null` while armed for every camera. Never read while `on` is false. */
3237
+ devices = null;
3238
+ level;
3239
+ closesAtMs = 0;
3240
+ constructor(descriptor) {
3241
+ this.descriptor = descriptor;
3242
+ this.level = descriptor.defaultLevel;
3243
+ }
3244
+ /** Epoch ms this channel disarms itself at. 0 when disarmed. */
3245
+ get armedUntilMs() {
3246
+ return this.on ? this.closesAtMs : 0;
3247
+ }
3248
+ /**
3249
+ * Does this channel want a line about `deviceId`?
3250
+ *
3251
+ * Call it only behind `gate.on &&`. On its own it is still correct — the
3252
+ * guard is repeated inside — but the point of the prefix is that a disarmed
3253
+ * channel must not pay the call at all.
3254
+ */
3255
+ wants(deviceId) {
3256
+ if (!this.on) return false;
3257
+ return this.devices === null || this.devices.has(deviceId);
3258
+ }
3259
+ /**
3260
+ * Emit one line on this channel, at the channel's declared level.
3261
+ *
3262
+ * The channel name is added as `tags.logChannel` so LogQL can select the
3263
+ * channel without matching on the message text, and whatever `tags` the
3264
+ * caller passed — `deviceId` above all — is preserved.
3265
+ */
3266
+ log(logger, message, extras) {
3267
+ if (!this.on) return;
3268
+ const tags = {
3269
+ ...extras.tags,
3270
+ logChannel: this.descriptor.name
3271
+ };
3272
+ const line = {
3273
+ ...extras,
3274
+ tags
3275
+ };
3276
+ if (this.level === "error") logger.error(message, line);
3277
+ else if (this.level === "warn") logger.warn(message, line);
3278
+ else logger.info(message, line);
3279
+ }
3280
+ /**
3281
+ * Arm (or RE-arm, restarting) this channel. Off the hot path only.
3282
+ *
3283
+ * An empty `deviceIds` list is treated as "every camera" rather than "no
3284
+ * camera": a window that matches nothing is indistinguishable from a
3285
+ * disarmed one, and the operator who asked for it would wait for lines that
3286
+ * can never come.
3287
+ */
3288
+ arm(window) {
3289
+ const ids = window.deviceIds;
3290
+ this.devices = ids === null || ids.length === 0 ? null : new Set(ids);
3291
+ this.closesAtMs = window.armedUntilMs;
3292
+ this.on = true;
3293
+ }
3294
+ /** Disarm. Off the hot path only. */
3295
+ disarm() {
3296
+ this.on = false;
3297
+ this.devices = null;
3298
+ this.closesAtMs = 0;
3299
+ }
3300
+ };
3301
+ /**
3302
+ * Every channel this PROCESS declares, and the mirror of what is armed on it.
3303
+ *
3304
+ * One per process. A forked runner has its own, and it is refreshed through
3305
+ * the `log-channels` capability by the hub that owns the document — the
3306
+ * registry never reaches for a value itself.
3307
+ */
3308
+ var LogChannelRegistry = class {
3309
+ gates = /* @__PURE__ */ new Map();
3310
+ /**
3311
+ * Declare a channel and get its gate.
3312
+ *
3313
+ * A duplicate name throws. Two declarations of one name is a programming
3314
+ * error, not a merge: the operator would arm one and the other would stay
3315
+ * dark, which is the dead-knob shape (D62) with an extra step.
3316
+ */
3317
+ declare(descriptor) {
3318
+ const parsed = LogChannelDescriptorSchema.parse(descriptor);
3319
+ if (this.gates.get(parsed.name) !== void 0) throw new Error(`log channel "${parsed.name}" is already declared in this process — two declarations of one name is a programming error, not a merge`);
3320
+ const gate = new LogChannelGate(parsed);
3321
+ this.gates.set(parsed.name, gate);
3322
+ return gate;
3323
+ }
3324
+ /** The declarations, sorted by name so a list is stable to read and diff. */
3325
+ list() {
3326
+ return [...this.gates.values()].map((gate) => gate.descriptor).sort((a, b) => a.name.localeCompare(b.name));
3327
+ }
3328
+ /** The gate for a declared channel, or `undefined`. */
3329
+ gate(name) {
3330
+ return this.gates.get(name);
3331
+ }
3332
+ /**
3333
+ * Apply the FULL set of armed windows. Off the hot path.
3334
+ *
3335
+ * Full, not incremental, and that is the whole design: the document is the
3336
+ * authority, so a channel the document does not name is disarmed here. An
3337
+ * incremental apply would let a disarm get lost in transit and leave a
3338
+ * channel running that nobody can see is running.
3339
+ *
3340
+ * A window already past its deadline is ignored rather than armed — a
3341
+ * restore that re-armed an expired window would make a forgotten diagnostic
3342
+ * immortal across restarts.
3343
+ *
3344
+ * Returns the names it could not place, so the caller can log them: a
3345
+ * channel named in the document that this process does not declare is
3346
+ * either a typo or an addon that has not booted yet, and both deserve a
3347
+ * line rather than silence.
3348
+ */
3349
+ apply(windows, nowMs) {
3350
+ const wanted = /* @__PURE__ */ new Map();
3351
+ const unknown = [];
3352
+ for (const window of windows) {
3353
+ if (window.armedUntilMs <= nowMs) continue;
3354
+ if (!this.gates.has(window.channel)) {
3355
+ unknown.push(window.channel);
3356
+ continue;
3357
+ }
3358
+ wanted.set(window.channel, window);
3359
+ }
3360
+ for (const [name, gate] of this.gates) {
3361
+ const window = wanted.get(name);
3362
+ if (window === void 0) gate.disarm();
3363
+ else gate.arm(window);
3364
+ }
3365
+ return unknown;
3366
+ }
3367
+ /**
3368
+ * Disarm whatever has run out. Called on a timer, NEVER from a log path — a
3369
+ * diagnostic that adds a `Date.now()` to the path it is measuring measures
3370
+ * itself.
3371
+ *
3372
+ * Returns the names it closed, so the caller can write the one line that
3373
+ * says a window ended and stops "it went quiet" from reading as "the branch
3374
+ * was not taken".
3375
+ */
3376
+ tick(nowMs) {
3377
+ const closed = [];
3378
+ for (const [name, gate] of this.gates) if (gate.on && gate.armedUntilMs <= nowMs) {
3379
+ gate.disarm();
3380
+ closed.push(name);
3381
+ }
3382
+ return closed;
3383
+ }
3384
+ /** The channels armed right now, as the document would describe them. */
3385
+ armed() {
3386
+ const out = [];
3387
+ for (const [name, gate] of this.gates) if (gate.on) out.push({
3388
+ channel: name,
3389
+ armedUntilMs: gate.armedUntilMs,
3390
+ deviceIds: null
3391
+ });
3392
+ return out;
3393
+ }
3394
+ };
3395
+ //#endregion
3396
+ //#region src/logging/log-channel.singleton.ts
3397
+ /**
3398
+ * Process-wide holder for the {@link LogChannelRegistry}.
3399
+ *
3400
+ * Three call sites that never meet need the SAME instance: the hot paths that
3401
+ * declare a gate at module scope, the `log-channels` provider that enumerates
3402
+ * the declarations for the hub, and the same provider applying the windows the
3403
+ * document hands down. A registry built inside any one of them would be
3404
+ * refreshed and collected — the shape of a knob that never does anything.
3405
+ *
3406
+ * Same idiom as `logging-gate.singleton.ts` and
3407
+ * `http-request-census.singleton.ts`.
3408
+ */
3409
+ var instance = null;
3410
+ /** The process-wide log channel registry. Created empty on first use. */
3411
+ function getLogChannelRegistry() {
3412
+ instance ??= new LogChannelRegistry();
3413
+ return instance;
3414
+ }
3415
+ /**
3416
+ * Declare a channel on the process-wide registry and get its gate.
3417
+ *
3418
+ * The one call an addon makes. Keep the returned gate in a module-scope
3419
+ * `const`: looking a channel up by name per line would put a Map lookup on
3420
+ * exactly the path this mechanism exists to keep free.
3421
+ *
3422
+ * `scripts/check-log-channel-gated.ts` reads these call sites. It pairs the
3423
+ * declared name with the binding it is assigned to and refuses to let a
3424
+ * channel ship that no `<binding>.on` anywhere consults — a declared channel
3425
+ * nobody reads is a knob the operator turns with nothing happening, forever,
3426
+ * and without a line. That is D62, and this repo has now shipped it three
3427
+ * times (`audioThresholdDbfs`, the HA entities with no source, the second
3428
+ * per-camera switch that wrote a store nobody read).
3429
+ */
3430
+ function declareLogChannel(descriptor) {
3431
+ return getLogChannelRegistry().declare(descriptor);
3432
+ }
3433
+ /** Test-only: drop the instance so a spec starts from an empty registry. */
3434
+ function __resetLogChannelRegistryForTests() {
3435
+ instance = null;
3436
+ }
3437
+ //#endregion
3438
+ //#region src/logging/log-channel-provider.ts
3439
+ /**
3440
+ * How often expiry is noticed. Coarse on purpose: the cost of a channel
3441
+ * running a few seconds past its deadline is a few extra lines, and the cost
3442
+ * of a tight timer in every addon process is paid forever.
3443
+ */
3444
+ var LOG_CHANNEL_TICK_MS = 5e3;
3445
+ /**
3446
+ * Build the `log-channels` provider for this process.
3447
+ *
3448
+ * `logger` is used ONLY off the hot path — for the arm/expiry lines — so a
3449
+ * channel that is never armed costs this module nothing but a timer.
3450
+ */
3451
+ function createLogChannelsProvider(logger, options = {}) {
3452
+ const registry = getLogChannelRegistry();
3453
+ const now = options.now ?? Date.now;
3454
+ const tickMs = options.tickMs ?? 5e3;
3455
+ const timer = setInterval(() => {
3456
+ const closed = registry.tick(now());
3457
+ for (const name of closed) logger.info("log channel window closed", {
3458
+ tags: { logChannel: name },
3459
+ meta: { channel: name }
3460
+ });
3461
+ }, tickMs);
3462
+ timer.unref?.();
3463
+ return {
3464
+ list: () => registry.list(),
3465
+ apply: (input) => {
3466
+ const unknown = registry.apply(input.windows, now());
3467
+ const armed = registry.armed();
3468
+ logger.info("log channels applied", { meta: {
3469
+ armed: armed.map((window) => window.channel),
3470
+ unknown,
3471
+ declared: registry.list().length
3472
+ } });
3473
+ return {
3474
+ armed: armed.length,
3475
+ unknown
3476
+ };
3477
+ },
3478
+ stop: () => {
3479
+ clearInterval(timer);
3480
+ }
3481
+ };
3482
+ }
3483
+ //#endregion
3484
+ //#region src/metrics/failure-counters.ts
3485
+ /**
3486
+ * `FailureCounters` — the per-camera counter every "how often does this camera
3487
+ * lose work, and why" question is answered from.
3488
+ *
3489
+ * ## Why a primitive and not four counters
3490
+ *
3491
+ * Four open failure modes were being triaged on 2026-08-28 and every one of
3492
+ * them was measured the same way: grep a log line, count it, and then guess at
3493
+ * the denominator. The guess is the defect. `enrichment crop native miss` read
3494
+ * as "35x worse than yesterday" and turned out to be **flat all day** the
3495
+ * moment it was divided by the successes on the same path — 0.25 misses per
3496
+ * landed capture, 0.08–0.33 across twelve hours, no trend. The count moved
3497
+ * because the traffic moved.
3498
+ *
3499
+ * So the unit here is not a counter. It is a **ratio with its denominator
3500
+ * attached**: {@link FailureCounterSample.attempts} is incremented on every
3501
+ * try, {@link FailureCounterSample.succeeded} on the ones that landed, and the
3502
+ * reasons partition the rest. A consumer can always divide; it can never
3503
+ * un-divide a bare count.
3504
+ *
3505
+ * ## Per camera, always
3506
+ *
3507
+ * The key is the numeric `deviceId` — the same value every log line in this
3508
+ * repo carries as `tags.deviceId`. There is no fleet-total mode and no
3509
+ * device-less bucket, because the question is always "why is 617 worse than
3510
+ * 615?" and a fleet total cannot answer it. A caller that cannot name the
3511
+ * camera must not note anything: an unnamed per-camera count is
3512
+ * indistinguishable from a shared one, which is how one camera's failures
3513
+ * quietly become everybody's.
3514
+ *
3515
+ * ## Cumulative, never drained
3516
+ *
3517
+ * A read NEVER resets anything. `load-contribution.cap.ts` already argued this
3518
+ * for CPU seconds and the argument carries over verbatim: *"a rate needs a
3519
+ * window, a window needs a sampler, and a new per-node sampler is the defect
3520
+ * half of `docs/architecture/load-ledger.md` documents. A counter can be
3521
+ * differenced by whoever already keeps a history; a rate cannot be
3522
+ * un-averaged."* A draining read has a second failure this surface cannot
3523
+ * afford — two consumers polling it would each destroy half of the other's
3524
+ * numbers, silently.
3525
+ *
3526
+ * {@link FailureCounterSample.sinceMs} is the incarnation marker: it is when
3527
+ * this counter started, and a consumer differencing two reads must drop the
3528
+ * interval when it changes, because the counter restarted from zero in a new
3529
+ * process.
3530
+ *
3531
+ * ## Bounded, and the bound is the point
3532
+ *
3533
+ * This lives in the memory of a process that is already the subject of an RSS
3534
+ * budget, so both dimensions are capped: {@link MAX_KEYS} counters and
3535
+ * {@link MAX_REASONS_PER_KEY} distinct reasons within one. Past the reason cap
3536
+ * the counts are folded into {@link OVERFLOW_REASON} rather than dropped —
3537
+ * losing them would make `attempts - succeeded` stop equalling the reason
3538
+ * total, and the ratio the whole surface exists to publish would quietly stop
3539
+ * adding up. Past the key cap a new key is refused and
3540
+ * {@link FailureCounters.keysRefused} says so, so the omission is visible
3541
+ * instead of silent.
3542
+ *
3543
+ * ## No timers, no IO, no logging
3544
+ *
3545
+ * Pure. It is read on whatever beat the caller already has. A telemetry
3546
+ * primitive that schedules its own work is a second sampler, and this repo has
3547
+ * paid for one of those already (`docs/architecture/load-ledger.md`).
3548
+ */
3549
+ /** Distinct reason strings kept per counter before folding. */
3550
+ var MAX_REASONS_PER_KEY = 16;
3551
+ /**
3552
+ * Distinct (device, family, variant) counters one instance will hold.
3553
+ *
3554
+ * A large fleet x the handful of families any single addon reports, with
3555
+ * slack. At ~200 B per counter this is a ~100 KB ceiling on a process that
3556
+ * already declares an RSS budget in the gigabytes.
3557
+ */
3558
+ var MAX_KEYS = 1024;
3559
+ /**
3560
+ * Where reasons past {@link MAX_REASONS_PER_KEY} go.
3561
+ *
3562
+ * They are FOLDED, never dropped: `attempts - succeeded` must always equal the
3563
+ * sum of the reason counts, or the ratio stops adding up.
3564
+ */
3565
+ var OVERFLOW_REASON = "other";
3566
+ /** `deviceId` + `family` + optional `variant`, flattened into the map key. */
3567
+ function counterKey(deviceId, family, variant) {
3568
+ return variant === void 0 ? `${deviceId}${family}` : `${deviceId}${family}${variant}`;
3569
+ }
3570
+ /**
3571
+ * A bounded set of per-camera, cumulative failure counters.
3572
+ *
3573
+ * One instance per contributing subsystem. `note` is O(1) and allocation-free
3574
+ * on the steady path; `snapshot` reads without mutating anything.
3575
+ */
3576
+ var FailureCounters = class {
3577
+ maxKeys;
3578
+ maxReasons;
3579
+ counters = /* @__PURE__ */ new Map();
3580
+ refused = 0;
3581
+ constructor(maxKeys = MAX_KEYS, maxReasons = 16) {
3582
+ this.maxKeys = maxKeys;
3583
+ this.maxReasons = maxReasons;
3584
+ }
3585
+ /**
3586
+ * Counters refused because {@link MAX_KEYS} was already held.
3587
+ *
3588
+ * Cumulative for the life of the instance: a bound that bit is a fact about
3589
+ * the deployment, and a surface that hid it would under-report a fleet
3590
+ * precisely when the fleet got large enough to matter.
3591
+ */
3592
+ get keysRefused() {
3593
+ return this.refused;
3594
+ }
3595
+ /** Counters currently held. */
3596
+ get size() {
3597
+ return this.counters.size;
3598
+ }
3599
+ /**
3600
+ * Fold one observation in.
3601
+ *
3602
+ * A non-positive or non-integer `deviceId` is REFUSED rather than bucketed:
3603
+ * see the module docblock — an entry that cannot name its camera is worse
3604
+ * than no entry.
3605
+ */
3606
+ note(observation, nowMs) {
3607
+ if (!Number.isInteger(observation.deviceId) || observation.deviceId <= 0) return;
3608
+ const key = counterKey(observation.deviceId, observation.family, observation.variant);
3609
+ let counter = this.counters.get(key);
3610
+ if (counter === void 0) {
3611
+ if (this.counters.size >= this.maxKeys) {
3612
+ this.refused += 1;
3613
+ return;
3614
+ }
3615
+ counter = {
3616
+ deviceId: observation.deviceId,
3617
+ family: observation.family,
3618
+ variant: observation.variant,
3619
+ sinceMs: nowMs,
3620
+ attempts: 0,
3621
+ succeeded: 0,
3622
+ reasons: /* @__PURE__ */ new Map()
3623
+ };
3624
+ this.counters.set(key, counter);
3625
+ }
3626
+ counter.attempts += 1;
3627
+ if (observation.reason === void 0) {
3628
+ counter.succeeded += 1;
3629
+ return;
3630
+ }
3631
+ const reason = counter.reasons.has(observation.reason) || counter.reasons.size < this.maxReasons ? observation.reason : OVERFLOW_REASON;
3632
+ counter.reasons.set(reason, (counter.reasons.get(reason) ?? 0) + 1);
3633
+ }
3634
+ /** Read every counter. Never mutates — see the module docblock. */
3635
+ snapshot(nowMs) {
3636
+ const out = [];
3637
+ for (const counter of this.counters.values()) out.push({
3638
+ deviceId: counter.deviceId,
3639
+ family: counter.family,
3640
+ ...counter.variant !== void 0 ? { variant: counter.variant } : {},
3641
+ sinceMs: counter.sinceMs,
3642
+ atMs: nowMs,
3643
+ attempts: counter.attempts,
3644
+ succeeded: counter.succeeded,
3645
+ reasons: [...counter.reasons.entries()].map(([reason, count]) => ({
3646
+ reason,
3647
+ count
3648
+ })).toSorted((a, b) => b.count - a.count)
3649
+ });
3650
+ return out;
3651
+ }
3652
+ /** Drop everything (host disposal). */
3653
+ clear() {
3654
+ this.counters.clear();
3655
+ }
3656
+ };
3657
+ /**
3658
+ * Losses per attempt, as a ratio — the number the operator actually reads.
3659
+ *
3660
+ * `null` when there were no attempts: a camera nobody asked anything of has no
3661
+ * failure rate, and reporting 0 would say it was perfect.
3662
+ */
3663
+ function failureRate(sample) {
3664
+ if (sample.attempts <= 0) return null;
3665
+ return (sample.attempts - sample.succeeded) / sample.attempts;
3666
+ }
3667
+ //#endregion
3668
+ //#region src/metrics/load-series-fold.ts
3669
+ /** Bucket key for rows no addon owns. Stable, so its series is continuous. */
3670
+ var UNATTRIBUTED_BUCKET_KEY = "__unattributed__";
3671
+ var ROOT_BUCKET_KEY = "__root__";
3672
+ function bucketFor(row) {
3673
+ if (row.addonId !== null) return {
3674
+ key: row.addonId,
3675
+ kind: "addon"
3676
+ };
3677
+ if (row.classification === "root") return {
3678
+ key: ROOT_BUCKET_KEY,
3679
+ kind: "root"
3680
+ };
3681
+ return {
3682
+ key: UNATTRIBUTED_BUCKET_KEY,
3683
+ kind: "unattributed"
3684
+ };
3685
+ }
3686
+ /** Round to one decimal — the precision both producers already emit at. */
3687
+ function deci(value) {
3688
+ return Math.round(value * 10) / 10;
3689
+ }
3690
+ /**
3691
+ * Fold one snapshot into one point per function.
3692
+ *
3693
+ * Produces a ONE-SAMPLE point: `min === max` on every field, `samples === 1`.
3694
+ * That is what lets a live event and a reduced server bucket sit in the same
3695
+ * series without the consumer knowing which is which.
3696
+ */
3697
+ function foldSnapshotByFunction(rows, atMs) {
3698
+ const acc = /* @__PURE__ */ new Map();
3699
+ for (const row of rows) {
3700
+ const { key, kind } = bucketFor(row);
3701
+ const cur = acc.get(key) ?? {
3702
+ kind,
3703
+ main: 0,
3704
+ gc: 0,
3705
+ lifetime: 0,
3706
+ memory: 0,
3707
+ count: 0,
3708
+ splitKnown: true
3709
+ };
3710
+ const known = row.cpuMainPercent !== null && row.cpuGcPercent !== null;
3711
+ acc.set(key, {
3712
+ kind: cur.kind,
3713
+ main: cur.main + (row.cpuMainPercent ?? 0),
3714
+ gc: cur.gc + (row.cpuGcPercent ?? 0),
3715
+ lifetime: cur.lifetime + row.cpuPercent,
3716
+ memory: cur.memory + row.memoryRssBytes,
3717
+ count: cur.count + 1,
3718
+ splitKnown: cur.splitKnown && known
3719
+ });
3720
+ }
3721
+ return [...acc.entries()].map(([key, a]) => {
3722
+ const main = a.splitKnown ? deci(a.main) : null;
3723
+ const gc = a.splitKnown ? deci(a.gc) : null;
3724
+ const lifetime = deci(a.lifetime);
3725
+ return {
3726
+ key,
3727
+ kind: a.kind,
3728
+ point: {
3729
+ atMs,
3730
+ samples: 1,
3731
+ cpuMainPercent: main,
3732
+ cpuMainPercentMin: main,
3733
+ cpuGcPercent: gc,
3734
+ cpuGcPercentMin: gc,
3735
+ cpuLifetimePercent: lifetime,
3736
+ cpuLifetimePercentMin: lifetime,
3737
+ memoryRssBytes: a.memory,
3738
+ memoryRssBytesMin: a.memory,
3739
+ processCount: a.count,
3740
+ processCountMin: a.count
3741
+ }
3742
+ };
3743
+ });
3744
+ }
3745
+ /** The narrower of two bounds, treating `null` (UNKNOWN) as absorbing. */
3746
+ function minNullable(a, b) {
3747
+ if (a === null || b === null) return null;
3748
+ return a < b ? a : b;
3749
+ }
3750
+ function maxNullable(a, b) {
3751
+ if (a === null || b === null) return null;
3752
+ return a > b ? a : b;
3753
+ }
3754
+ /** Merge `next` into `held`, keeping the widest [min, max] of each field. */
3755
+ function mergePoints(held, next, atMs) {
3756
+ return {
3757
+ atMs,
3758
+ samples: held.samples + next.samples,
3759
+ cpuMainPercent: maxNullable(held.cpuMainPercent, next.cpuMainPercent),
3760
+ cpuMainPercentMin: minNullable(held.cpuMainPercentMin, next.cpuMainPercentMin),
3761
+ cpuGcPercent: maxNullable(held.cpuGcPercent, next.cpuGcPercent),
3762
+ cpuGcPercentMin: minNullable(held.cpuGcPercentMin, next.cpuGcPercentMin),
3763
+ cpuLifetimePercent: Math.max(held.cpuLifetimePercent, next.cpuLifetimePercent),
3764
+ cpuLifetimePercentMin: Math.min(held.cpuLifetimePercentMin, next.cpuLifetimePercentMin),
3765
+ memoryRssBytes: Math.max(held.memoryRssBytes, next.memoryRssBytes),
3766
+ memoryRssBytesMin: Math.min(held.memoryRssBytesMin, next.memoryRssBytesMin),
3767
+ processCount: Math.max(held.processCount, next.processCount),
3768
+ processCountMin: Math.min(held.processCountMin, next.processCountMin)
3769
+ };
3770
+ }
3771
+ /**
3772
+ * The bucket width that brings `spanMs` down to at most `maxPoints` points,
3773
+ * snapped up to a whole multiple of the sampling cadence.
3774
+ *
3775
+ * Returns `cadenceMs` (no reduction) when the span already fits. A caller that
3776
+ * asks for a `maxPoints` of 0 or less gets no reduction rather than an
3777
+ * infinite bucket — a nonsensical request must not produce a plausible chart.
3778
+ */
3779
+ function resolveBucketMs(spanMs, cadenceMs, maxPoints) {
3780
+ if (maxPoints <= 0 || cadenceMs <= 0 || spanMs <= 0) return Math.max(cadenceMs, 1);
3781
+ const wanted = spanMs / maxPoints;
3782
+ if (wanted <= cadenceMs) return cadenceMs;
3783
+ return Math.ceil(wanted / cadenceMs) * cadenceMs;
3784
+ }
3785
+ /**
3786
+ * Reduce one function's points into buckets of `bucketMs`, preserving the
3787
+ * extremes.
3788
+ *
3789
+ * Points are expected oldest-first and are returned oldest-first. A bucket
3790
+ * with no samples is ABSENT — not zero, not interpolated, not the previous
3791
+ * value held over.
3792
+ */
3793
+ function reducePoints(points, bucketMs, origin) {
3794
+ if (bucketMs <= 0 || points.length === 0) return points;
3795
+ const buckets = /* @__PURE__ */ new Map();
3796
+ for (const point of points) {
3797
+ const start = origin + Math.floor((point.atMs - origin) / bucketMs) * bucketMs;
3798
+ const held = buckets.get(start);
3799
+ buckets.set(start, held === void 0 ? {
3800
+ ...point,
3801
+ atMs: start
3802
+ } : mergePoints(held, point, start));
3803
+ }
3804
+ return [...buckets.values()].toSorted((a, b) => a.atMs - b.atMs);
3805
+ }
3806
+ //#endregion
3460
3807
  //#region src/stream-selection.ts
3461
3808
  function pickPreferredRtspEntry(entries, pref, deviceId, options = {}) {
3462
3809
  if (entries.length === 0) return null;
@@ -3513,329 +3860,6 @@ function pickClosestResolution(entries, target) {
3513
3860
  return bestAbove?.entry ?? bestBelow?.entry;
3514
3861
  }
3515
3862
  //#endregion
3516
- //#region src/metrics/load-series-fold.ts
3517
- /** Bucket key for rows no addon owns. Stable, so its series is continuous. */
3518
- var UNATTRIBUTED_BUCKET_KEY = "__unattributed__";
3519
- var ROOT_BUCKET_KEY = "__root__";
3520
- function bucketFor(row) {
3521
- if (row.addonId !== null) return {
3522
- key: row.addonId,
3523
- kind: "addon"
3524
- };
3525
- if (row.classification === "root") return {
3526
- key: ROOT_BUCKET_KEY,
3527
- kind: "root"
3528
- };
3529
- return {
3530
- key: UNATTRIBUTED_BUCKET_KEY,
3531
- kind: "unattributed"
3532
- };
3533
- }
3534
- /** Round to one decimal — the precision both producers already emit at. */
3535
- function deci(value) {
3536
- return Math.round(value * 10) / 10;
3537
- }
3538
- /**
3539
- * Fold one snapshot into one point per function.
3540
- *
3541
- * Produces a ONE-SAMPLE point: `min === max` on every field, `samples === 1`.
3542
- * That is what lets a live event and a reduced server bucket sit in the same
3543
- * series without the consumer knowing which is which.
3544
- */
3545
- function foldSnapshotByFunction(rows, atMs) {
3546
- const acc = /* @__PURE__ */ new Map();
3547
- for (const row of rows) {
3548
- const { key, kind } = bucketFor(row);
3549
- const cur = acc.get(key) ?? {
3550
- kind,
3551
- main: 0,
3552
- gc: 0,
3553
- lifetime: 0,
3554
- memory: 0,
3555
- count: 0,
3556
- splitKnown: true
3557
- };
3558
- const known = row.cpuMainPercent !== null && row.cpuGcPercent !== null;
3559
- acc.set(key, {
3560
- kind: cur.kind,
3561
- main: cur.main + (row.cpuMainPercent ?? 0),
3562
- gc: cur.gc + (row.cpuGcPercent ?? 0),
3563
- lifetime: cur.lifetime + row.cpuPercent,
3564
- memory: cur.memory + row.memoryRssBytes,
3565
- count: cur.count + 1,
3566
- splitKnown: cur.splitKnown && known
3567
- });
3568
- }
3569
- return [...acc.entries()].map(([key, a]) => {
3570
- const main = a.splitKnown ? deci(a.main) : null;
3571
- const gc = a.splitKnown ? deci(a.gc) : null;
3572
- const lifetime = deci(a.lifetime);
3573
- return {
3574
- key,
3575
- kind: a.kind,
3576
- point: {
3577
- atMs,
3578
- samples: 1,
3579
- cpuMainPercent: main,
3580
- cpuMainPercentMin: main,
3581
- cpuGcPercent: gc,
3582
- cpuGcPercentMin: gc,
3583
- cpuLifetimePercent: lifetime,
3584
- cpuLifetimePercentMin: lifetime,
3585
- memoryRssBytes: a.memory,
3586
- memoryRssBytesMin: a.memory,
3587
- processCount: a.count,
3588
- processCountMin: a.count
3589
- }
3590
- };
3591
- });
3592
- }
3593
- /** The narrower of two bounds, treating `null` (UNKNOWN) as absorbing. */
3594
- function minNullable(a, b) {
3595
- if (a === null || b === null) return null;
3596
- return a < b ? a : b;
3597
- }
3598
- function maxNullable(a, b) {
3599
- if (a === null || b === null) return null;
3600
- return a > b ? a : b;
3601
- }
3602
- /** Merge `next` into `held`, keeping the widest [min, max] of each field. */
3603
- function mergePoints(held, next, atMs) {
3604
- return {
3605
- atMs,
3606
- samples: held.samples + next.samples,
3607
- cpuMainPercent: maxNullable(held.cpuMainPercent, next.cpuMainPercent),
3608
- cpuMainPercentMin: minNullable(held.cpuMainPercentMin, next.cpuMainPercentMin),
3609
- cpuGcPercent: maxNullable(held.cpuGcPercent, next.cpuGcPercent),
3610
- cpuGcPercentMin: minNullable(held.cpuGcPercentMin, next.cpuGcPercentMin),
3611
- cpuLifetimePercent: Math.max(held.cpuLifetimePercent, next.cpuLifetimePercent),
3612
- cpuLifetimePercentMin: Math.min(held.cpuLifetimePercentMin, next.cpuLifetimePercentMin),
3613
- memoryRssBytes: Math.max(held.memoryRssBytes, next.memoryRssBytes),
3614
- memoryRssBytesMin: Math.min(held.memoryRssBytesMin, next.memoryRssBytesMin),
3615
- processCount: Math.max(held.processCount, next.processCount),
3616
- processCountMin: Math.min(held.processCountMin, next.processCountMin)
3617
- };
3618
- }
3619
- /**
3620
- * The bucket width that brings `spanMs` down to at most `maxPoints` points,
3621
- * snapped up to a whole multiple of the sampling cadence.
3622
- *
3623
- * Returns `cadenceMs` (no reduction) when the span already fits. A caller that
3624
- * asks for a `maxPoints` of 0 or less gets no reduction rather than an
3625
- * infinite bucket — a nonsensical request must not produce a plausible chart.
3626
- */
3627
- function resolveBucketMs(spanMs, cadenceMs, maxPoints) {
3628
- if (maxPoints <= 0 || cadenceMs <= 0 || spanMs <= 0) return Math.max(cadenceMs, 1);
3629
- const wanted = spanMs / maxPoints;
3630
- if (wanted <= cadenceMs) return cadenceMs;
3631
- return Math.ceil(wanted / cadenceMs) * cadenceMs;
3632
- }
3633
- /**
3634
- * Reduce one function's points into buckets of `bucketMs`, preserving the
3635
- * extremes.
3636
- *
3637
- * Points are expected oldest-first and are returned oldest-first. A bucket
3638
- * with no samples is ABSENT — not zero, not interpolated, not the previous
3639
- * value held over.
3640
- */
3641
- function reducePoints(points, bucketMs, origin) {
3642
- if (bucketMs <= 0 || points.length === 0) return points;
3643
- const buckets = /* @__PURE__ */ new Map();
3644
- for (const point of points) {
3645
- const start = origin + Math.floor((point.atMs - origin) / bucketMs) * bucketMs;
3646
- const held = buckets.get(start);
3647
- buckets.set(start, held === void 0 ? {
3648
- ...point,
3649
- atMs: start
3650
- } : mergePoints(held, point, start));
3651
- }
3652
- return [...buckets.values()].toSorted((a, b) => a.atMs - b.atMs);
3653
- }
3654
- //#endregion
3655
- //#region src/metrics/failure-counters.ts
3656
- /**
3657
- * `FailureCounters` — the per-camera counter every "how often does this camera
3658
- * lose work, and why" question is answered from.
3659
- *
3660
- * ## Why a primitive and not four counters
3661
- *
3662
- * Four open failure modes were being triaged on 2026-08-28 and every one of
3663
- * them was measured the same way: grep a log line, count it, and then guess at
3664
- * the denominator. The guess is the defect. `enrichment crop native miss` read
3665
- * as "35x worse than yesterday" and turned out to be **flat all day** the
3666
- * moment it was divided by the successes on the same path — 0.25 misses per
3667
- * landed capture, 0.08–0.33 across twelve hours, no trend. The count moved
3668
- * because the traffic moved.
3669
- *
3670
- * So the unit here is not a counter. It is a **ratio with its denominator
3671
- * attached**: {@link FailureCounterSample.attempts} is incremented on every
3672
- * try, {@link FailureCounterSample.succeeded} on the ones that landed, and the
3673
- * reasons partition the rest. A consumer can always divide; it can never
3674
- * un-divide a bare count.
3675
- *
3676
- * ## Per camera, always
3677
- *
3678
- * The key is the numeric `deviceId` — the same value every log line in this
3679
- * repo carries as `tags.deviceId`. There is no fleet-total mode and no
3680
- * device-less bucket, because the question is always "why is 617 worse than
3681
- * 615?" and a fleet total cannot answer it. A caller that cannot name the
3682
- * camera must not note anything: an unnamed per-camera count is
3683
- * indistinguishable from a shared one, which is how one camera's failures
3684
- * quietly become everybody's.
3685
- *
3686
- * ## Cumulative, never drained
3687
- *
3688
- * A read NEVER resets anything. `load-contribution.cap.ts` already argued this
3689
- * for CPU seconds and the argument carries over verbatim: *"a rate needs a
3690
- * window, a window needs a sampler, and a new per-node sampler is the defect
3691
- * half of `docs/architecture/load-ledger.md` documents. A counter can be
3692
- * differenced by whoever already keeps a history; a rate cannot be
3693
- * un-averaged."* A draining read has a second failure this surface cannot
3694
- * afford — two consumers polling it would each destroy half of the other's
3695
- * numbers, silently.
3696
- *
3697
- * {@link FailureCounterSample.sinceMs} is the incarnation marker: it is when
3698
- * this counter started, and a consumer differencing two reads must drop the
3699
- * interval when it changes, because the counter restarted from zero in a new
3700
- * process.
3701
- *
3702
- * ## Bounded, and the bound is the point
3703
- *
3704
- * This lives in the memory of a process that is already the subject of an RSS
3705
- * budget, so both dimensions are capped: {@link MAX_KEYS} counters and
3706
- * {@link MAX_REASONS_PER_KEY} distinct reasons within one. Past the reason cap
3707
- * the counts are folded into {@link OVERFLOW_REASON} rather than dropped —
3708
- * losing them would make `attempts - succeeded` stop equalling the reason
3709
- * total, and the ratio the whole surface exists to publish would quietly stop
3710
- * adding up. Past the key cap a new key is refused and
3711
- * {@link FailureCounters.keysRefused} says so, so the omission is visible
3712
- * instead of silent.
3713
- *
3714
- * ## No timers, no IO, no logging
3715
- *
3716
- * Pure. It is read on whatever beat the caller already has. A telemetry
3717
- * primitive that schedules its own work is a second sampler, and this repo has
3718
- * paid for one of those already (`docs/architecture/load-ledger.md`).
3719
- */
3720
- /** Distinct reason strings kept per counter before folding. */
3721
- var MAX_REASONS_PER_KEY = 16;
3722
- /**
3723
- * Distinct (device, family, variant) counters one instance will hold.
3724
- *
3725
- * A large fleet x the handful of families any single addon reports, with
3726
- * slack. At ~200 B per counter this is a ~100 KB ceiling on a process that
3727
- * already declares an RSS budget in the gigabytes.
3728
- */
3729
- var MAX_KEYS = 1024;
3730
- /**
3731
- * Where reasons past {@link MAX_REASONS_PER_KEY} go.
3732
- *
3733
- * They are FOLDED, never dropped: `attempts - succeeded` must always equal the
3734
- * sum of the reason counts, or the ratio stops adding up.
3735
- */
3736
- var OVERFLOW_REASON = "other";
3737
- /** `deviceId` + `family` + optional `variant`, flattened into the map key. */
3738
- function counterKey(deviceId, family, variant) {
3739
- return variant === void 0 ? `${deviceId}${family}` : `${deviceId}${family}${variant}`;
3740
- }
3741
- /**
3742
- * A bounded set of per-camera, cumulative failure counters.
3743
- *
3744
- * One instance per contributing subsystem. `note` is O(1) and allocation-free
3745
- * on the steady path; `snapshot` reads without mutating anything.
3746
- */
3747
- var FailureCounters = class {
3748
- maxKeys;
3749
- maxReasons;
3750
- counters = /* @__PURE__ */ new Map();
3751
- refused = 0;
3752
- constructor(maxKeys = MAX_KEYS, maxReasons = 16) {
3753
- this.maxKeys = maxKeys;
3754
- this.maxReasons = maxReasons;
3755
- }
3756
- /**
3757
- * Counters refused because {@link MAX_KEYS} was already held.
3758
- *
3759
- * Cumulative for the life of the instance: a bound that bit is a fact about
3760
- * the deployment, and a surface that hid it would under-report a fleet
3761
- * precisely when the fleet got large enough to matter.
3762
- */
3763
- get keysRefused() {
3764
- return this.refused;
3765
- }
3766
- /** Counters currently held. */
3767
- get size() {
3768
- return this.counters.size;
3769
- }
3770
- /**
3771
- * Fold one observation in.
3772
- *
3773
- * A non-positive or non-integer `deviceId` is REFUSED rather than bucketed:
3774
- * see the module docblock — an entry that cannot name its camera is worse
3775
- * than no entry.
3776
- */
3777
- note(observation, nowMs) {
3778
- if (!Number.isInteger(observation.deviceId) || observation.deviceId <= 0) return;
3779
- const key = counterKey(observation.deviceId, observation.family, observation.variant);
3780
- let counter = this.counters.get(key);
3781
- if (counter === void 0) {
3782
- if (this.counters.size >= this.maxKeys) {
3783
- this.refused += 1;
3784
- return;
3785
- }
3786
- counter = {
3787
- deviceId: observation.deviceId,
3788
- family: observation.family,
3789
- variant: observation.variant,
3790
- sinceMs: nowMs,
3791
- attempts: 0,
3792
- succeeded: 0,
3793
- reasons: /* @__PURE__ */ new Map()
3794
- };
3795
- this.counters.set(key, counter);
3796
- }
3797
- counter.attempts += 1;
3798
- if (observation.reason === void 0) {
3799
- counter.succeeded += 1;
3800
- return;
3801
- }
3802
- const reason = counter.reasons.has(observation.reason) || counter.reasons.size < this.maxReasons ? observation.reason : OVERFLOW_REASON;
3803
- counter.reasons.set(reason, (counter.reasons.get(reason) ?? 0) + 1);
3804
- }
3805
- /** Read every counter. Never mutates — see the module docblock. */
3806
- snapshot(nowMs) {
3807
- const out = [];
3808
- for (const counter of this.counters.values()) out.push({
3809
- deviceId: counter.deviceId,
3810
- family: counter.family,
3811
- ...counter.variant !== void 0 ? { variant: counter.variant } : {},
3812
- sinceMs: counter.sinceMs,
3813
- atMs: nowMs,
3814
- attempts: counter.attempts,
3815
- succeeded: counter.succeeded,
3816
- reasons: [...counter.reasons.entries()].map(([reason, count]) => ({
3817
- reason,
3818
- count
3819
- })).toSorted((a, b) => b.count - a.count)
3820
- });
3821
- return out;
3822
- }
3823
- /** Drop everything (host disposal). */
3824
- clear() {
3825
- this.counters.clear();
3826
- }
3827
- };
3828
- /**
3829
- * Losses per attempt, as a ratio — the number the operator actually reads.
3830
- *
3831
- * `null` when there were no attempts: a camera nobody asked anything of has no
3832
- * failure rate, and reporting 0 would say it was perfect.
3833
- */
3834
- function failureRate(sample) {
3835
- if (sample.attempts <= 0) return null;
3836
- return (sample.attempts - sample.succeeded) / sample.attempts;
3837
- }
3838
- //#endregion
3839
3863
  //#region src/types/model-variant-groups.ts
3840
3864
  var FORMAT_KEYS = [
3841
3865
  "onnx",
@@ -6721,12 +6745,30 @@ var BackupDestinationInfoSchema = zod.z.object({
6721
6745
  lastSuccessAt: zod.z.number().optional(),
6722
6746
  /** Newest-archive size from `manifests.json`, or undefined. */
6723
6747
  lastSuccessSizeBytes: zod.z.number().optional(),
6724
- /** Per-destination cron expression. Empty = manual-only (no schedule). */
6748
+ /**
6749
+ * Cron cadence(s) of the ENABLED schedules that fan out to this
6750
+ * destination, comma-joined. Absent when no enabled schedule targets it
6751
+ * — a destination nothing is scheduled to write to must not advertise a
6752
+ * cadence (D384). This is never the `backup_destination_policies.cron`
6753
+ * column: that per-location cron has scheduled nothing since 2026-07-28
6754
+ * and reading it made a destination with a DISABLED schedule claim a
6755
+ * nightly run.
6756
+ */
6725
6757
  cron: zod.z.string().optional(),
6726
- /** ms-epoch of next computed firing for this destination's cron, if any. */
6758
+ /** ms-epoch of the next firing across those schedules (earliest), if any. */
6727
6759
  nextRunAt: zod.z.number().optional(),
6728
- /** ms-epoch of last successful scheduled run (mirrors policy.lastRunAt). */
6729
- lastRunAt: zod.z.number().optional()
6760
+ /**
6761
+ * ms-epoch of the last time a run ATTEMPTED to write here — success or
6762
+ * failure. Never a success stamp: pair it with `lastSuccessAt` (the
6763
+ * newest archive that actually landed) and `lastError`.
6764
+ */
6765
+ lastAttemptAt: zod.z.number().optional(),
6766
+ /**
6767
+ * Why the last attempt failed, verbatim. Absent when the last attempt
6768
+ * landed the archive. A destination that has never been written to has
6769
+ * neither this nor `lastAttemptAt`.
6770
+ */
6771
+ lastError: zod.z.string().optional()
6730
6772
  });
6731
6773
  /**
6732
6774
  * Per-archive entry returned by `backup.listArchives({ destinationId })`.
@@ -6889,8 +6931,16 @@ var BackupScheduleSchema = zod.z.object({
6889
6931
  retentionCount: zod.z.number().int().min(1).max(1e3),
6890
6932
  /** Optional subset of source locations to include; omitted = all. */
6891
6933
  dataSources: zod.z.array(zod.z.string()).readonly().optional(),
6892
- /** ms-epoch of last successful run. */
6893
- lastRunAt: zod.z.number().optional(),
6934
+ /**
6935
+ * ms-epoch of the last tick that FIRED this schedule. Stamped before the
6936
+ * archive runs (it is the dedupe anchor), so it says "attempted", never
6937
+ * "succeeded" — a run refused by every destination stamps it too.
6938
+ */
6939
+ lastAttemptAt: zod.z.number().optional(),
6940
+ /** ms-epoch of the last run of this schedule that landed at ≥1 destination. */
6941
+ lastSuccessAt: zod.z.number().optional(),
6942
+ /** Why the last fired run failed, verbatim. Absent when it succeeded. */
6943
+ lastError: zod.z.string().optional(),
6894
6944
  /** ms-epoch of next computed firing (read-only, filled on list). */
6895
6945
  nextRunAt: zod.z.number().optional()
6896
6946
  });
@@ -7019,14 +7069,7 @@ var backupCapability = {
7019
7069
  locationId: zod.z.string(),
7020
7070
  enabled: zod.z.boolean(),
7021
7071
  retentionCount: zod.z.number().int().min(1).max(1e3),
7022
- label: zod.z.string().optional(),
7023
- /**
7024
- * Per-destination cron expression. Empty string clears the
7025
- * schedule (manual-only). Validated server-side via croner;
7026
- * malformed expressions reject the upsert with an actionable
7027
- * message.
7028
- */
7029
- cron: zod.z.string().optional()
7072
+ label: zod.z.string().optional()
7030
7073
  }), zod.z.void(), {
7031
7074
  kind: "mutation",
7032
7075
  auth: "admin"
@@ -23129,7 +23172,6 @@ var storageCapability = {
23129
23172
  }), zod.z.instanceof(Uint8Array)),
23130
23173
  endDownload: require_sleep.method(zod.z.object({ downloadId: zod.z.string() }), zod.z.void(), { kind: "mutation" }),
23131
23174
  listLocations: require_sleep.method(zod.z.object({ type: StorageLocationTypeSchema.optional() }), zod.z.array(StorageLocationSchema).readonly()),
23132
- getDefaultLocation: require_sleep.method(zod.z.object({ type: StorageLocationTypeSchema }), StorageLocationSchema.nullable()),
23133
23175
  listLocationDeclarations: require_sleep.method(zod.z.void(), zod.z.array(StorageLocationDeclarationSchema).readonly()),
23134
23176
  upsertLocation: require_sleep.method(StorageLocationSchema.omit({
23135
23177
  createdAt: true,
@@ -36350,134 +36392,6 @@ var RUNTIME_DEFAULTS = {
36350
36392
  "auth.tokenExpiry": "30d"
36351
36393
  };
36352
36394
  //#endregion
36353
- //#region src/device/container-primary-child.ts
36354
- /**
36355
- * WHICH child a container stands for — one definition, for every consumer.
36356
- *
36357
- * A CONTAINER device has no controllable surface of its own: it groups entity
36358
- * children (a Gree air-conditioner grouping a climate child plus light, x-fan
36359
- * and health switches). Everything that has to show or act on a container has
36360
- * to answer the same question — which child IS the container — and until now
36361
- * three places answered it separately:
36362
- *
36363
- * - `ui-library/device-controls/primary-child.ts` (admin-ui rendering)
36364
- * - `addon-provider-homeassistant` PARENT_TYPE_PRIORITY (adoption)
36365
- * - the viewer's own `container-primary.ts` (linked-devices panel)
36366
- *
36367
- * Each carried the same list and a comment asking the others to stay in sync.
36368
- * This is that list, in the one package all of them already depend on.
36369
- *
36370
- * `ui-library` and the server's linked-devices expansion IMPORT it. Two
36371
- * consumers cannot, and keep a checked copy instead: the viewer resolves
36372
- * `@camstack/types` from its own `node_modules` (an installed release, where a
36373
- * newly added export simply is not there), and the Home Assistant provider
36374
- * expresses the same precedence over the `DeviceType` enum because it answers
36375
- * a different question from the same ordering. `scripts/check-container-
36376
- * priority-in-sync.ts` fails the build when either drifts — the comment that
36377
- * used to ask for this could not.
36378
- *
36379
- * The rule has two halves and the ORDER matters: an operator's explicit pick
36380
- * wins outright, and only in its absence does type priority decide. The pick is
36381
- * keyed on the child's re-sync-stable `entityId`, not its numeric id, so it
36382
- * survives a re-sync that reallocates ids.
36383
- */
36384
- /**
36385
- * Type priority, most→least "primary". An actuator (climate / lock / cover / …)
36386
- * outranks a bare `switch` so a container's defining child wins over its
36387
- * auxiliary switches. Unknown or absent types sort after every entry.
36388
- *
36389
- * NB: `siren` deliberately sits BELOW `switch` — it is a switch-family
36390
- * actuator, and a camera's siren must not out-rank the thing the container is.
36391
- *
36392
- * These strings are matched against a child's `DeviceType` VALUE, so they must
36393
- * equal the enum's string values.
36394
- */
36395
- var CONTAINER_CHILD_PRIORITY = [
36396
- "media-player",
36397
- "alarm-panel",
36398
- "thermostat",
36399
- "climate",
36400
- "humidifier",
36401
- "water-heater",
36402
- "lock",
36403
- "cover",
36404
- "valve",
36405
- "fan",
36406
- "vacuum",
36407
- "lawn-mower",
36408
- "light",
36409
- "switch",
36410
- "siren",
36411
- "button",
36412
- "control",
36413
- "notifier",
36414
- "script",
36415
- "automation",
36416
- "update",
36417
- "presence",
36418
- "weather",
36419
- "image",
36420
- "sensor"
36421
- ];
36422
- /**
36423
- * Roles that say what a container IS, most→least defining. Consulted BEFORE
36424
- * type priority, because `DeviceType` cannot tell them apart: Home Assistant
36425
- * maps a door contact, a battery level, a temperature reading and a "last seen"
36426
- * timestamp all to `DeviceType.Sensor`. Measured on container 4127 — children
36427
- * `battery-sensor`, `temperature-sensor`, `datetime-sensor`, `contact-sensor`
36428
- * and a role-less `button` — the type list ranked `button` above `sensor` and
36429
- * the container stood for its "Identifica" button instead of the door contact
36430
- * it is named after.
36431
- *
36432
- * Only STATE-DEFINING roles belong here. A diagnostic reading (battery,
36433
- * temperature, humidity, signal, last-seen) is never what a container is, so
36434
- * they are deliberately absent and fall through to type priority.
36435
- */
36436
- var CONTAINER_CHILD_ROLE_PRIORITY = [
36437
- "contact-sensor",
36438
- "motion-sensor",
36439
- "occupancy-sensor",
36440
- "smoke-sensor",
36441
- "co-sensor",
36442
- "gas-sensor",
36443
- "leak-sensor",
36444
- "vibration-sensor",
36445
- "tamper-sensor",
36446
- "sound-sensor"
36447
- ];
36448
- /**
36449
- * NOT in the list, deliberately: `binary-sensor` and `binary-helper`. They are
36450
- * GENERIC — they say "this reports a boolean", not what the container is — and
36451
- * ranking them above type priority is a live regression, not a hypothetical:
36452
- * container 1837 holds a real `lock` (features `['lock-open']`) alongside a
36453
- * child named "Actuator" whose only role is `binary-sensor`, and the generic
36454
- * role beat the lock. A role earns a place here by naming a SUBJECT (a contact,
36455
- * a leak, smoke), never by naming a datatype.
36456
- */
36457
- function roleRank(role) {
36458
- if (role === void 0) return CONTAINER_CHILD_ROLE_PRIORITY.length;
36459
- const i = CONTAINER_CHILD_ROLE_PRIORITY.indexOf(role);
36460
- return i === -1 ? CONTAINER_CHILD_ROLE_PRIORITY.length : i;
36461
- }
36462
- function rank(type) {
36463
- const i = CONTAINER_CHILD_PRIORITY.indexOf(type);
36464
- return i === -1 ? CONTAINER_CHILD_PRIORITY.length : i;
36465
- }
36466
- /**
36467
- * The child a container stands for: the operator's pick when it still exists,
36468
- * else the highest-priority type. `null` for a childless container — a caller
36469
- * must decide what an empty container means for it, rather than being handed a
36470
- * child that is not there.
36471
- */
36472
- function resolveContainerPrimaryChild(children, overrideEntityId, containerName) {
36473
- if (overrideEntityId !== void 0 && overrideEntityId !== null) {
36474
- const picked = children.find((c) => c.stableId === overrideEntityId) ?? children.find((c) => c.entityId !== void 0 && c.entityId === overrideEntityId);
36475
- if (picked !== void 0) return picked;
36476
- }
36477
- const namesake = (c) => containerName !== void 0 && containerName.length > 0 && c.name !== void 0 && c.name.toLowerCase() === containerName.toLowerCase() ? 0 : 1;
36478
- return [...children].toSorted((a, b) => roleRank(a.role) - roleRank(b.role) || namesake(a) - namesake(b) || rank(a.type) - rank(b.type))[0] ?? null;
36479
- }
36480
- //#endregion
36481
36395
  //#region src/device/accessory.ts
36482
36396
  /**
36483
36397
  * Accessory device helpers — shared across drivers.
@@ -38063,6 +37977,134 @@ function isBatteryPresenceFault(presence) {
38063
37977
  return presence === "unreachable";
38064
37978
  }
38065
37979
  //#endregion
37980
+ //#region src/device/container-primary-child.ts
37981
+ /**
37982
+ * WHICH child a container stands for — one definition, for every consumer.
37983
+ *
37984
+ * A CONTAINER device has no controllable surface of its own: it groups entity
37985
+ * children (a Gree air-conditioner grouping a climate child plus light, x-fan
37986
+ * and health switches). Everything that has to show or act on a container has
37987
+ * to answer the same question — which child IS the container — and until now
37988
+ * three places answered it separately:
37989
+ *
37990
+ * - `ui-library/device-controls/primary-child.ts` (admin-ui rendering)
37991
+ * - `addon-provider-homeassistant` PARENT_TYPE_PRIORITY (adoption)
37992
+ * - the viewer's own `container-primary.ts` (linked-devices panel)
37993
+ *
37994
+ * Each carried the same list and a comment asking the others to stay in sync.
37995
+ * This is that list, in the one package all of them already depend on.
37996
+ *
37997
+ * `ui-library` and the server's linked-devices expansion IMPORT it. Two
37998
+ * consumers cannot, and keep a checked copy instead: the viewer resolves
37999
+ * `@camstack/types` from its own `node_modules` (an installed release, where a
38000
+ * newly added export simply is not there), and the Home Assistant provider
38001
+ * expresses the same precedence over the `DeviceType` enum because it answers
38002
+ * a different question from the same ordering. `scripts/check-container-
38003
+ * priority-in-sync.ts` fails the build when either drifts — the comment that
38004
+ * used to ask for this could not.
38005
+ *
38006
+ * The rule has two halves and the ORDER matters: an operator's explicit pick
38007
+ * wins outright, and only in its absence does type priority decide. The pick is
38008
+ * keyed on the child's re-sync-stable `entityId`, not its numeric id, so it
38009
+ * survives a re-sync that reallocates ids.
38010
+ */
38011
+ /**
38012
+ * Type priority, most→least "primary". An actuator (climate / lock / cover / …)
38013
+ * outranks a bare `switch` so a container's defining child wins over its
38014
+ * auxiliary switches. Unknown or absent types sort after every entry.
38015
+ *
38016
+ * NB: `siren` deliberately sits BELOW `switch` — it is a switch-family
38017
+ * actuator, and a camera's siren must not out-rank the thing the container is.
38018
+ *
38019
+ * These strings are matched against a child's `DeviceType` VALUE, so they must
38020
+ * equal the enum's string values.
38021
+ */
38022
+ var CONTAINER_CHILD_PRIORITY = [
38023
+ "media-player",
38024
+ "alarm-panel",
38025
+ "thermostat",
38026
+ "climate",
38027
+ "humidifier",
38028
+ "water-heater",
38029
+ "lock",
38030
+ "cover",
38031
+ "valve",
38032
+ "fan",
38033
+ "vacuum",
38034
+ "lawn-mower",
38035
+ "light",
38036
+ "switch",
38037
+ "siren",
38038
+ "button",
38039
+ "control",
38040
+ "notifier",
38041
+ "script",
38042
+ "automation",
38043
+ "update",
38044
+ "presence",
38045
+ "weather",
38046
+ "image",
38047
+ "sensor"
38048
+ ];
38049
+ /**
38050
+ * Roles that say what a container IS, most→least defining. Consulted BEFORE
38051
+ * type priority, because `DeviceType` cannot tell them apart: Home Assistant
38052
+ * maps a door contact, a battery level, a temperature reading and a "last seen"
38053
+ * timestamp all to `DeviceType.Sensor`. Measured on container 4127 — children
38054
+ * `battery-sensor`, `temperature-sensor`, `datetime-sensor`, `contact-sensor`
38055
+ * and a role-less `button` — the type list ranked `button` above `sensor` and
38056
+ * the container stood for its "Identifica" button instead of the door contact
38057
+ * it is named after.
38058
+ *
38059
+ * Only STATE-DEFINING roles belong here. A diagnostic reading (battery,
38060
+ * temperature, humidity, signal, last-seen) is never what a container is, so
38061
+ * they are deliberately absent and fall through to type priority.
38062
+ */
38063
+ var CONTAINER_CHILD_ROLE_PRIORITY = [
38064
+ "contact-sensor",
38065
+ "motion-sensor",
38066
+ "occupancy-sensor",
38067
+ "smoke-sensor",
38068
+ "co-sensor",
38069
+ "gas-sensor",
38070
+ "leak-sensor",
38071
+ "vibration-sensor",
38072
+ "tamper-sensor",
38073
+ "sound-sensor"
38074
+ ];
38075
+ /**
38076
+ * NOT in the list, deliberately: `binary-sensor` and `binary-helper`. They are
38077
+ * GENERIC — they say "this reports a boolean", not what the container is — and
38078
+ * ranking them above type priority is a live regression, not a hypothetical:
38079
+ * container 1837 holds a real `lock` (features `['lock-open']`) alongside a
38080
+ * child named "Actuator" whose only role is `binary-sensor`, and the generic
38081
+ * role beat the lock. A role earns a place here by naming a SUBJECT (a contact,
38082
+ * a leak, smoke), never by naming a datatype.
38083
+ */
38084
+ function roleRank(role) {
38085
+ if (role === void 0) return CONTAINER_CHILD_ROLE_PRIORITY.length;
38086
+ const i = CONTAINER_CHILD_ROLE_PRIORITY.indexOf(role);
38087
+ return i === -1 ? CONTAINER_CHILD_ROLE_PRIORITY.length : i;
38088
+ }
38089
+ function rank(type) {
38090
+ const i = CONTAINER_CHILD_PRIORITY.indexOf(type);
38091
+ return i === -1 ? CONTAINER_CHILD_PRIORITY.length : i;
38092
+ }
38093
+ /**
38094
+ * The child a container stands for: the operator's pick when it still exists,
38095
+ * else the highest-priority type. `null` for a childless container — a caller
38096
+ * must decide what an empty container means for it, rather than being handed a
38097
+ * child that is not there.
38098
+ */
38099
+ function resolveContainerPrimaryChild(children, overrideEntityId, containerName) {
38100
+ if (overrideEntityId !== void 0 && overrideEntityId !== null) {
38101
+ const picked = children.find((c) => c.stableId === overrideEntityId) ?? children.find((c) => c.entityId !== void 0 && c.entityId === overrideEntityId);
38102
+ if (picked !== void 0) return picked;
38103
+ }
38104
+ const namesake = (c) => containerName !== void 0 && containerName.length > 0 && c.name !== void 0 && c.name.toLowerCase() === containerName.toLowerCase() ? 0 : 1;
38105
+ return [...children].toSorted((a, b) => roleRank(a.role) - roleRank(b.role) || namesake(a) - namesake(b) || rank(a.type) - rank(b.type))[0] ?? null;
38106
+ }
38107
+ //#endregion
38066
38108
  //#region src/device/declared-device.ts
38067
38109
  /** Marker written to a declared integration's `info`. */
38068
38110
  var DECLARED_INTEGRATION_FIXED_KEY = "fixed";
@@ -45629,12 +45671,6 @@ var METHOD_ACCESS_MAP = Object.freeze({
45629
45671
  addonId: null,
45630
45672
  access: "view"
45631
45673
  },
45632
- "storage.getDefaultLocation": {
45633
- capName: "storage",
45634
- capScope: "system",
45635
- addonId: null,
45636
- access: "view"
45637
- },
45638
45674
  "storage.list": {
45639
45675
  capName: "storage",
45640
45676
  capScope: "system",
@@ -50062,7 +50098,6 @@ function createSystemProxy(api) {
50062
50098
  readChunk: (input) => dispatch("storage", "readChunk", "query", input),
50063
50099
  endDownload: (input) => dispatch("storage", "endDownload", "mutation", input),
50064
50100
  listLocations: (input) => dispatch("storage", "listLocations", "query", input),
50065
- getDefaultLocation: (input) => dispatch("storage", "getDefaultLocation", "query", input),
50066
50101
  listLocationDeclarations: (input) => dispatch("storage", "listLocationDeclarations", "query", input),
50067
50102
  upsertLocation: (input) => dispatch("storage", "upsertLocation", "mutation", input),
50068
50103
  deleteLocation: (input) => dispatch("storage", "deleteLocation", "mutation", input),
@@ -54530,6 +54565,7 @@ exports.LedgerWalkRefusalSchema = LedgerWalkRefusalSchema;
54530
54565
  exports.LedgerWalkReportSchema = LedgerWalkReportSchema;
54531
54566
  exports.LedgerWalkSkipCountsSchema = LedgerWalkSkipCountsSchema;
54532
54567
  exports.LedgerWalkSkipReasonSchema = LedgerWalkSkipReasonSchema;
54568
+ exports.LegacyStorageLocationDefaultSchema = LegacyStorageLocationDefaultSchema;
54533
54569
  exports.LinkedDeviceSchema = LinkedDeviceSchema;
54534
54570
  exports.LinkedDevicesModeSchema = LinkedDevicesModeSchema;
54535
54571
  exports.LlmDefaultSchema = LlmDefaultSchema;
@@ -55382,6 +55418,7 @@ exports.isDeviceScopedCap = require_sleep.isDeviceScopedCap;
55382
55418
  exports.isEvent = require_sleep.isEvent;
55383
55419
  exports.isFirstLevelMacroClass = isFirstLevelMacroClass;
55384
55420
  exports.isIsolatedBuiltin = isIsolatedBuiltin;
55421
+ exports.isLocationEnabled = isLocationEnabled;
55385
55422
  exports.isNode = isNode;
55386
55423
  exports.isObjectInput = isObjectInput;
55387
55424
  exports.isOccupancyRule = isOccupancyRule;