@camstack/addon-provider-reolink 1.2.100 → 1.2.102

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 (3) hide show
  1. package/dist/addon.js +1585 -1568
  2. package/dist/addon.mjs +1585 -1568
  3. package/package.json +1 -1
package/dist/addon.js CHANGED
@@ -7653,1709 +7653,1713 @@ var CameraSwitchGroupSchema = object({
7653
7653
  fetchedAt: number()
7654
7654
  });
7655
7655
  /**
7656
- * Per-component log CHANNELS — the gate a hot path consults, and the registry
7657
- * an addon declares its channels in.
7658
- *
7659
- * ## Two axes, deliberately separated
7660
- *
7661
- * - **DECLARATION** — which channels exist. Only the addon knows:
7662
- * `stream-broker` knows webrtc/ICE/RTP, `provider-reolink` knows
7663
- * baichuan/handshake. A hand-wired central list rots at the first addition,
7664
- * and rots silently. So a channel is declared where it is consulted, and the
7665
- * `log-channels` capability enumerates the declarations.
7666
- * - **VALUE** — at which level, for which scope, until when. That stays ONE
7667
- * thing: the logging settings document on the `system` cap. Two authorities
7668
- * over the values is the exact defect
7669
- * `docs/design/plans/2026-08-26-logging-per-componente.md` was written to
7670
- * remove; re-introducing it from the cure side would be grotesque.
7671
- *
7672
- * Nothing in this file reads a clock, an env var or a store. The registry is
7673
- * a MIRROR: it is moved only by {@link LogChannelRegistry.apply}, called off
7674
- * the hot path with a value somebody actually read, and by
7675
- * {@link LogChannelRegistry.tick}, called on a timer. A store read that fails
7676
- * never reaches here, so it can neither disarm an armed channel nor arm a
7677
- * disarmed one (D49).
7678
- *
7679
- * ## The canonical call shape
7656
+ * Ops-log — the durable, append-only operations audit shared by the
7657
+ * recordings and events management surfaces.
7680
7658
  *
7681
- * ```ts
7682
- * if (CH_RTP.on && CH_RTP.wants(deviceId)) {
7683
- * CH_RTP.log(logger, 'rtp subscriber added', { tags: { deviceId }, meta: { ssrc } })
7684
- * }
7685
- * ```
7659
+ * ONE row shape is reused for both domains so a single "Activity" view can
7660
+ * merge the recorder's DurableState ring (recordings ops-log) and the
7661
+ * pipeline-analytics SQLite collection (events ops-log). Each row records a
7662
+ * management operation, WHY it ran (reason), and its measurable effect
7663
+ * (itemsAffected + bytesReclaimed). Writes are best-effort — a failed log must
7664
+ * never fail the operation it records.
7665
+ */
7666
+ /** Which management domain the operation belongs to. */
7667
+ var OpsLogDomainSchema = _enum(["recording", "events"]);
7668
+ /** The kind of management operation performed. */
7669
+ var OpsLogOpSchema = _enum([
7670
+ "prune",
7671
+ "manual-delete",
7672
+ "rescan",
7673
+ "retention-run",
7674
+ "relocate",
7675
+ "orphan-audit"
7676
+ ]);
7677
+ /** Why the operation ran. */
7678
+ var OpsLogReasonSchema = _enum([
7679
+ "retention",
7680
+ "quota",
7681
+ "manual",
7682
+ "operator",
7683
+ "maintenance",
7684
+ "orphaned-device"
7685
+ ]);
7686
+ /** One audit row, shared verbatim by both domains. */
7687
+ var OpsLogEntrySchema = object({
7688
+ /** Unique row id. */
7689
+ id: string(),
7690
+ /** Epoch ms the operation completed. */
7691
+ at: number(),
7692
+ domain: OpsLogDomainSchema,
7693
+ op: OpsLogOpSchema,
7694
+ reason: OpsLogReasonSchema,
7695
+ /** The camera the op targeted; null for a cluster/global op. */
7696
+ deviceId: number().nullable(),
7697
+ /** Node that performed the op (the log carries nodeId — no cross-node aggregation). */
7698
+ nodeId: string(),
7699
+ /** Buckets / rows deleted (op-specific unit). */
7700
+ itemsAffected: number(),
7701
+ /** Bytes reclaimed by the op (0 when not measurable). */
7702
+ bytesReclaimed: number(),
7703
+ /** Free-text detail (e.g. "floor moved to <ts>"); null when none. */
7704
+ detail: string().nullable(),
7705
+ /** Who/what triggered the op. */
7706
+ actor: string()
7707
+ });
7708
+ /** Shared query input for the per-domain `listOpsLog` cap methods. */
7709
+ var OpsLogQueryInputSchema = object({
7710
+ /** Restrict to a single camera; omit for every row. */
7711
+ deviceId: number().optional(),
7712
+ /** Max rows returned, newest-first. */
7713
+ limit: number().int().min(1).max(1e3).optional()
7714
+ });
7715
+ var LabelDefinitionSchema = object({
7716
+ id: string(),
7717
+ name: string(),
7718
+ category: string().optional(),
7719
+ description: string().optional(),
7720
+ icon: string().optional()
7721
+ });
7722
+ /** Detection-macro targets a catalog `classMap` may resolve to. */
7723
+ var CLASS_MAP_MACRO_TARGETS = [
7724
+ "person",
7725
+ "vehicle",
7726
+ "animal",
7727
+ "package"
7728
+ ];
7729
+ /**
7730
+ * Le macro classi di PRIMO LIVELLO: quelle che un object detector emette e che
7731
+ * un operatore può selezionare.
7686
7732
  *
7687
- * `on` is a plain boolean FIELD — never a getter — and it is the FIRST thing
7688
- * read. Disarmed, a call site costs one load and one branch, and the `extras`
7689
- * object literal is never constructed because it lives inside the branch. It
7690
- * is the same shape already proven in production at `stream-broker.ts:1650`,
7691
- * and the same discipline `LoggingGate.allowsDestination` uses for the
7692
- * destination floor (measured at 1.93 ns/call when off).
7733
+ * Sono le tre offerte dallo step `object-detection`
7734
+ * (`addon-pipeline/src/detection-pipeline/registry/step-definitions.ts`,
7735
+ * `enabledMacroClasses`). `package` sta in {@link CLASS_MAP_MACRO_TARGETS} e in
7736
+ * `MACRO_LABELS` è una macro vera ma NON qui: appartiene allo step
7737
+ * `package-detection`, la cui abilitazione è guidata dalle zone, e offrire la
7738
+ * stessa parola due volte ha già fatto accendere a un operatore il proxy COCO
7739
+ * (suitcase/backpack/handbag) lasciando spento il detector dedicato.
7693
7740
  *
7694
- * ## Why a channel emits at `info`
7741
+ * UNA lista. Prima di oggi le stesse tre erano scritte a mano nell'offerta
7742
+ * dello step e una seconda volta come union `FirstLevelMacro`
7743
+ * (`types/detection.ts`); una terza copia per il trigger di registrazione
7744
+ * (`RecordingTriggers.objectClasses`) avrebbe reso invisibile la divergenza
7745
+ * successiva.
7746
+ */
7747
+ var FIRST_LEVEL_MACRO_CLASSES = [
7748
+ "person",
7749
+ "vehicle",
7750
+ "animal"
7751
+ ];
7752
+ /**
7753
+ * Wire schema for a per-model CATALOG classMap override
7754
+ * (`ModelCatalogEntry.classMap` / `ModelConvertMetadata.classMap`) —
7755
+ * restricted to {@link CLASS_MAP_MACRO_TARGETS}, the only macros the
7756
+ * detection pipeline executor actually routes.
7695
7757
  *
7696
- * `loki-logging.addon.ts` pins the destination default at `info` and
7697
- * `loki-destination.ts` drops everything below it, so a line emitted at
7698
- * `debug` never reaches Loki and the hub's in-memory ring only holds ~35
7699
- * minutes. A diagnostic that cannot be read an hour later is worse than no
7700
- * diagnostic, because it looks done. {@link LogChannelGate.log} therefore
7701
- * emits at the channel's declared level, whose schema floor is `info`.
7758
+ * This is deliberately a DIFFERENT, narrower shape than the general-purpose
7759
+ * `ClassMapDefinition` interface above (e.g. `IDetectionAddon.getClassMap()`
7760
+ * and the audio `YAMNET_TO_MACRO` catalog both use macro targets outside this
7761
+ * enum) the two used to share the name `ClassMapDefinition`/
7762
+ * `ClassMapDefinitionSchema`, which made the schema-type-twin guard
7763
+ * (`scripts/check-schema-type-twins.ts`) flag them as a duplicated shape. They
7764
+ * are not: it is two different concepts colliding on a name. Keep this type
7765
+ * under its own name rather than reusing `ClassMapDefinition` — reusing it
7766
+ * would either narrow every `ClassMapDefinition` consumer to the four
7767
+ * detection macros (breaking `YAMNET_TO_MACRO`) or drop the validation this
7768
+ * schema exists for (see the "rejects a classMap whose target is not a
7769
+ * detection macro" test in `model-catalog-schema.test.ts`).
7702
7770
  */
7771
+ var DetectionCatalogClassMapSchema = object({
7772
+ mapping: record(string(), _enum(CLASS_MAP_MACRO_TARGETS)),
7773
+ preserveOriginal: boolean()
7774
+ });
7703
7775
  /**
7704
- * The level a channel writes at once armed.
7776
+ * Numeric day-of-week: 0 = Sunday 6 = Saturday (matches `Date.getDay`).
7777
+ * Named `RecordingWeekday` to avoid collision with the string-union
7778
+ * `Weekday` exported from `interfaces/timezones.ts`.
7779
+ */
7780
+ var RecordingWeekdaySchema = number().int().min(0).max(6);
7781
+ var HHMM = /^([01]\d|2[0-3]):[0-5]\d$/;
7782
+ /**
7783
+ * DERIVED per-camera storage summary — the single field cheap consumers read
7784
+ * (the viewer's status dot, the camera list) instead of walking `bands`:
7785
+ * - `off` — no band covers the camera (or it is disabled).
7786
+ * - `events` — every band records around triggers only.
7787
+ * - `continuous` — at least one band records continuously.
7788
+ * - `on-device-decision`— the DEVICE decides: recording runs for as long as the
7789
+ * camera raises its own `recording-signal` level (a robot that cleans). There
7790
+ * is no schedule to author, because there is no hour to program — see
7791
+ * {@link RecordingConfigSchema}`.deviceDecision`.
7705
7792
  *
7706
- * `debug` is absent ON PURPOSE and not by omission: below `info` the line does
7707
- * not leave the process for Loki, and the whole point of arming a channel is
7708
- * to read it later.
7793
+ * NEVER authored: the recorder stamps it from the authoritative intent
7794
+ * (`bands` + `deviceDecision`) on every save (`activeModeForConfig`). Writing it
7795
+ * has no effect.
7796
+ *
7797
+ * `on-device-decision` is named for WHO decides, not for how the recording is
7798
+ * requested. `on-demand` was rejected: in this repo's vocabulary a "demand" is
7799
+ * something the operator makes (the live gate is the recorder's own "demand
7800
+ * window"), and a knob whose name suggests the operator starts it while the
7801
+ * device actually does is the D62 shape — a control nobody can predict.
7709
7802
  */
7710
- var LogChannelLevelSchema = _enum([
7711
- "info",
7712
- "warn",
7713
- "error"
7803
+ var RecordingStorageModeSchema = _enum([
7804
+ "off",
7805
+ "events",
7806
+ "continuous",
7807
+ "on-device-decision"
7714
7808
  ]);
7715
7809
  /**
7716
- * What an addon declares about one channel. No value, no state a
7717
- * declaration is inert.
7810
+ * Le macro classi che possono aprire una finestra di registrazionele stesse
7811
+ * tre offerte dallo step `object-detection`, da UNA lista
7812
+ * ({@link FIRST_LEVEL_MACRO_CLASSES}).
7718
7813
  */
7719
- var LogChannelDescriptorSchema = object({
7814
+ var RecordingObjectTriggerClassSchema = _enum(FIRST_LEVEL_MACRO_CLASSES);
7815
+ /**
7816
+ * True quando `values` non ripete un elemento.
7817
+ *
7818
+ * Un duplicato non è innocuo: ogni voce di `objectClasses` / `sensorDeviceIds`
7819
+ * diventa una SORGENTE in `bandTriggerSources`, e la stessa sorgente due volte
7820
+ * conterebbe due volte le sue finestre in `segmentMissedByMs`.
7821
+ */
7822
+ var noDuplicates = (values) => new Set(values).size === values.length;
7823
+ /** Which detectors trigger an `events`-mode band. */
7824
+ var RecordingTriggersSchema = object({
7825
+ motion: boolean().optional(),
7826
+ audioThresholdDbfs: number().optional(),
7720
7827
  /**
7721
- * Dotted `area.thing`, unique across the workspace. `area` is conventionally
7722
- * the addon's short name so an operator reading a channel list can tell who
7723
- * owns it without a second lookup.
7828
+ * Le macro classi la cui detection apre una finestra. ASSENTE = la sorgente
7829
+ * non è ascoltata; un array VUOTO è rifiutato, perché "banda events, trigger
7830
+ * object acceso, nessuna classe" è la stessa forma "abilitata e non registra
7831
+ * nulla, per sempre" contro cui è scritto `eventsBandCanEverDemand`.
7832
+ *
7833
+ * Il segnale letto è GIÀ FILTRATO: solo detection `source: 'pipeline'`, cioè
7834
+ * quelle che hanno attraversato `enabledMacroClasses`, i
7835
+ * `minConfidence<Macro>` e il full-frame guard. L'AI a bordo camera
7836
+ * (`source: 'onboard'`) non attraversa nessuno di quei gate e NON apre
7837
+ * finestre — vedi `recorder/object-trigger.ts`.
7724
7838
  */
7725
- name: string().min(3).regex(/^[a-z0-9-]+(\.[a-z0-9-]+)+$/, "a channel name is dotted lower-kebab, e.g. area.thing"),
7726
- /** One sentence: what the operator will SEE after arming it. */
7727
- description: string().min(1),
7728
- /** The level its lines are emitted at. Never below `info`. */
7729
- defaultLevel: LogChannelLevelSchema,
7839
+ objectClasses: array(RecordingObjectTriggerClassSchema).min(1).refine(noDuplicates, { message: "objectClasses must not repeat a class" }).optional(),
7730
7840
  /**
7731
- * Whether this channel can be narrowed to a camera.
7841
+ * I device LINKED il cui FRONTE ALTO apre una finestra. Assente = la sorgente
7842
+ * non è ascoltata; un array vuoto è rifiutato per la stessa ragione di
7843
+ * `objectClasses`.
7732
7844
  *
7733
- * `true` is a PROMISE with two halves, and both must hold: the gate is
7734
- * consulted with the numeric device id, AND every line the channel admits
7735
- * carries `tags: { deviceId }` with that same numeric id. The second half is
7736
- * what makes `| json | deviceId="617"` work in Loki — `loki-payload.ts`
7737
- * keeps `deviceId` out of the stream labels for cardinality, so the tag in
7738
- * the body is the only way to filter.
7845
+ * Sono id di device SORGENTE, non camere: la banda li nomina, quindi il
7846
+ * percorso caldo (`DeviceStateChanged`, a ritmo di bus su tutta la flotta)
7847
+ * non fa RPC. L'OFFERTA da cui l'operatore li sceglie è un'altra domanda, e
7848
+ * si risolve con `deviceManager.getLinkedDevices` + `getBindingsBatch` per
7849
+ * device (D12) mai un elenco globale di cap.
7739
7850
  *
7740
- * A channel whose lines carry the device only in `meta` (or not at all) is
7741
- * declared `false`. Declaring it `true` anyway would be a lie the UI repeats:
7742
- * the operator narrows to one camera, sees nothing, and concludes the code
7743
- * path was never taken.
7851
+ * Cosa vuol dire "alto" dipende dal TIPO di device e non è deciso qui:
7852
+ * `SOURCE_CAP_ACTIVE_FIELD` (`catalogs/sensor-active-state.ts`) è LA tabella,
7853
+ * la stessa che il virtual-doorbell usa dal 2026-08-05. Ed è il FRONTE, non
7854
+ * il livello: un contatto trovato già aperto al riavvio del runner non fa
7855
+ * registrare.
7744
7856
  */
7745
- perDevice: boolean()
7857
+ sensorDeviceIds: array(number().int().positive()).min(1).max(16).refine(noDuplicates, { message: "sensorDeviceIds must not repeat a device" }).optional()
7746
7858
  });
7747
7859
  /**
7748
- * An armed window over one channel, as the document hands it to a mirror.
7860
+ * Mode of a single recording band the recorder per-band vocabulary.
7749
7861
  *
7750
- * A window is a DEADLINE, never a flag (ADR-0244): a channel somebody forgot
7751
- * expires by itself, which is the one failure a boolean cannot avoid.
7862
+ * Distinct from `RecordingStorageModeSchema` (which carries `off`): a band is
7863
+ * only ever `continuous` or `events`; "off" is expressed by the absence of a
7864
+ * covering band, not by a band value.
7752
7865
  */
7753
- var LogChannelWindowSchema = object({
7754
- channel: string().min(1),
7755
- /** Epoch ms the window closes at. */
7756
- armedUntilMs: number(),
7757
- /** `null` = every camera. A non-empty list narrows to those numeric ids. */
7758
- deviceIds: array(number().int()).readonly().nullable()
7759
- });
7866
+ var RecordingBandModeSchema = _enum(["continuous", "events"]);
7760
7867
  /**
7761
- * The gate a hot path holds.
7868
+ * Triggers for an `events`-mode band. Identical shape to
7869
+ * `RecordingTriggersSchema` — reuse that schema as the band trigger type so the
7870
+ * two never drift.
7871
+ */
7872
+ var RecordingBandTriggersSchema = RecordingTriggersSchema;
7873
+ /**
7874
+ * A single mode-per-band window — the canonical recorder band shape, the
7875
+ * single source of truth re-used by `addon-pipeline/recorder`.
7762
7876
  *
7763
- * Obtain it ONCE at module scope or in a constructor and keep the
7764
- * reference. Looking a channel up by name per line would put a Map lookup on
7765
- * the path this class exists to keep free.
7877
+ * `days` lists the weekdays the band covers (empty = every day, matching the
7878
+ * band engine's `applies` rule). `start`/`end` are `HH:MM`; an `end <= start`
7879
+ * span wraps past midnight (handled by the band engine).
7766
7880
  */
7767
- var LogChannelGate = class {
7768
- descriptor;
7881
+ var RecordingBandSchema = object({
7882
+ days: array(RecordingWeekdaySchema),
7883
+ start: string().regex(HHMM),
7884
+ end: string().regex(HHMM),
7885
+ mode: RecordingBandModeSchema,
7886
+ triggers: RecordingBandTriggersSchema.optional(),
7769
7887
  /**
7770
- * HOT PATH GUARD. A plain data FIELD, and it must stay one.
7888
+ * RETIRED (D381). A band no longer carries a pre-buffer of its own.
7771
7889
  *
7772
- * `log-channel.spec.ts` asserts the property descriptor has no getter and
7773
- * booby-traps the device set, so turning this into an accessor or reading
7774
- * anything before it fails the spec instead of taxing every line the
7775
- * process emits.
7890
+ * It was the retroactive KEEP bound which already-written segments survive
7891
+ * a trigger and it had to be at least as wide as the widest pre-roll the
7892
+ * broker could serve, or the gate would delete the seconds the ring had just
7893
+ * handed the writer. That "at least as wide as" is the tell: it was a second
7894
+ * expression of the broker's retention, in different units, in a different
7895
+ * addon, with an invariant the operator had to maintain by hand. The keep
7896
+ * bound is now derived from the one prebuffer ceiling and there is nothing
7897
+ * left to configure here.
7898
+ *
7899
+ * The key survives in the SHAPE so a config written before that record still
7900
+ * parses and can be migrated deliberately — `RecordingBandSchema` is not
7901
+ * strict, so simply deleting it would strip an operator's value in silence on
7902
+ * the next save (the failure D380 came one commit from shipping). Nothing
7903
+ * reads it: {@link stripRetiredBandPreBufferSec} removes it on load.
7776
7904
  */
7777
- on = false;
7778
- /** `null` while armed for every camera. Never read while `on` is false. */
7779
- devices = null;
7780
- level;
7781
- closesAtMs = 0;
7782
- constructor(descriptor) {
7783
- this.descriptor = descriptor;
7784
- this.level = descriptor.defaultLevel;
7785
- }
7786
- /** Epoch ms this channel disarms itself at. 0 when disarmed. */
7787
- get armedUntilMs() {
7788
- return this.on ? this.closesAtMs : 0;
7789
- }
7905
+ preBufferSec: number().min(0).optional(),
7906
+ postBufferSec: number().min(0).optional()
7907
+ });
7908
+ ({ postBufferSec: 30 }).postBufferSec * 1e3;
7909
+ /**
7910
+ * Per-device retention overrides. Every field is optional; an unset or `0`
7911
+ * value inherits the node-wide recorder default. Only footage-lifetime limits
7912
+ * live per-camera: `maxAgeDays` and `maxSizeGb`. The disk-occupancy threshold
7913
+ * (when the volume is too full to keep recording) is NOT a per-camera concern —
7914
+ * it belongs to the StorageLocation (`StorageLocation.config.minFreePercent`),
7915
+ * shared by every camera writing to that volume.
7916
+ */
7917
+ var RecordingRetentionSchema = object({
7918
+ maxAgeDays: number().min(0).optional(),
7919
+ maxSizeGb: number().min(0).optional()
7920
+ });
7921
+ /**
7922
+ * The full per-camera recording intent — the wire shape of a RecordingTarget.
7923
+ *
7924
+ * `bands` is the ONLY authored recording intent: what to record, when, and on
7925
+ * which trigger. `mode` is a derived summary the recorder stamps on save; every
7926
+ * other field is a storage knob (profiles, segment length, retention, scrub).
7927
+ *
7928
+ * STRICT on purpose: the legacy authoring surface (`schedule`/`schedules`/
7929
+ * `triggers`/`preBufferSec`/`postBufferSec`/`rules`) was retired 2026-07-30,
7930
+ * and `scrubThumbnails` — a five-step fidelity knob for a sprite tier that was
7931
+ * deleted on 2026-07-24 and had ZERO consumers in the recorder — on 2026-08-25
7932
+ * (D62: a switch that writes a store nobody reads is worse than no switch).
7933
+ * Stored rows keep loading: the READ schema is `.strip()` (config-store.ts).
7934
+ * A stale caller must fail loudly — silently stripping its legacy intent would
7935
+ * persist a band-less config, i.e. silently stop recording the camera.
7936
+ */
7937
+ var RecordingConfigSchema = object({
7938
+ enabled: boolean(),
7939
+ /** DERIVED summary of `bands`, stamped by the recorder on every save.
7940
+ * Authoring it has no effect — see {@link RecordingStorageModeSchema}. */
7941
+ mode: RecordingStorageModeSchema.optional(),
7790
7942
  /**
7791
- * Does this channel want a line about `deviceId`?
7792
- *
7793
- * Call it only behind `gate.on &&`. On its own it is still correct — the
7794
- * guard is repeated inside but the point of the prefix is that a disarmed
7795
- * channel must not pay the call at all.
7943
+ * Which assigned broker slots to record. Absent / empty = {@link
7944
+ * DEFAULT_RECORDING_PROFILES} (`high`+`low`) intersected with the
7945
+ * camera's currently assigned slots never `mid` unless the operator
7946
+ * picks it, and never a slot the broker has not assigned.
7796
7947
  */
7797
- wants(deviceId) {
7798
- if (!this.on) return false;
7799
- return this.devices === null || this.devices.has(deviceId);
7800
- }
7948
+ profiles: array(CamProfileSchema).optional(),
7949
+ segmentSeconds: number().int().positive().optional(),
7801
7950
  /**
7802
- * Emit one line on this channel, at the channel's declared level.
7803
- *
7804
- * The channel name is added as `tags.logChannel` so LogQL can select the
7805
- * channel without matching on the message text, and whatever `tags` the
7806
- * caller passed — `deviceId` above all — is preserved.
7951
+ * AUTHORITATIVE mode-per-band recording model the single source of truth
7952
+ * the recorder's band engine consumes. An empty array = record nothing;
7953
+ * "off" is the absence of a covering band, never a band value.
7807
7954
  */
7808
- log(logger, message, extras) {
7809
- if (!this.on) return;
7810
- const tags = {
7811
- ...extras.tags,
7812
- logChannel: this.descriptor.name
7813
- };
7814
- const line = {
7815
- ...extras,
7816
- tags
7817
- };
7818
- if (this.level === "error") logger.error(message, line);
7819
- else if (this.level === "warn") logger.warn(message, line);
7820
- else logger.info(message, line);
7821
- }
7955
+ bands: array(RecordingBandSchema).default([]),
7822
7956
  /**
7823
- * Arm (or RE-arm, restarting) this channel. Off the hot path only.
7957
+ * THE device-decided intent: record for as long as the camera itself raises
7958
+ * `recording-signal` (`active: true` while the device is in function — a
7959
+ * robot that cleans). AUTHORED, unlike `mode`, and the only authored
7960
+ * recording intent that is not a band.
7824
7961
  *
7825
- * An empty `deviceIds` list is treated as "every camera" rather than "no
7826
- * camera": a window that matches nothing is indistinguishable from a
7827
- * disarmed one, and the operator who asked for it would wait for lines that
7828
- * can never come.
7962
+ * It carries NO schedule on purpose. A band is an HOUR, and this feature has
7963
+ * no hour to program: the device decides. What it costs the operator is one
7964
+ * flag "this camera can record on its own" — and what it buys is the same
7965
+ * hold/release the recorder already implements (D371, `holdTrigger` /
7966
+ * `releaseTrigger`): the window opens on the rise, stays open for the whole
7967
+ * job however long it is, and closes one pad after the fall.
7968
+ *
7969
+ * EXCLUSIVE with `bands` (below): two authorities deciding when the same
7970
+ * camera records is the D62 failure. `off` remains `enabled: false`, so an
7971
+ * operator switching this camera off is REPORTED off, never as broken.
7829
7972
  */
7830
- arm(window) {
7831
- const ids = window.deviceIds;
7832
- this.devices = ids === null || ids.length === 0 ? null : new Set(ids);
7833
- this.closesAtMs = window.armedUntilMs;
7834
- this.on = true;
7835
- }
7836
- /** Disarm. Off the hot path only. */
7837
- disarm() {
7838
- this.on = false;
7839
- this.devices = null;
7840
- this.closesAtMs = 0;
7841
- }
7842
- };
7973
+ deviceDecision: boolean().optional(),
7974
+ retention: RecordingRetentionSchema.optional()
7975
+ }).strict().refine((config) => config.deviceDecision !== true || config.bands.length === 0, {
7976
+ message: "deviceDecision is a whole-config mode and carries no schedule: it cannot be combined with bands",
7977
+ path: ["bands"]
7978
+ });
7843
7979
  /**
7844
- * Every channel this PROCESS declares, and the mirror of what is armed on it.
7980
+ * Entity-relocation job state (storage entity-routing spec, Phase 4).
7845
7981
  *
7846
- * One per process. A forked runner has its own, and it is refreshed through
7847
- * the `log-channels` capability by the hub that owns the document — the
7848
- * registry never reaches for a value itself.
7982
+ * One shape shared by the recorder and pipeline-analytics internal movers.
7983
+ * The public admin surface is `storage-migration`; child jobs remain in RAM
7984
+ * because copy-if-absent, verify, delete and index/row repoint are resumable.
7985
+ * Each completed/failed run also lands one durable ops-log row on its owning
7986
+ * addon surface.
7849
7987
  */
7850
- var LogChannelRegistry = class {
7851
- gates = /* @__PURE__ */ new Map();
7988
+ /**
7989
+ * `queued` exists because the recorder mover is SINGLE-FLIGHT and an operator
7990
+ * rebalance enqueues one job per (camera, profile). Refusing the second job —
7991
+ * what the engine did before — turned a fifteen-camera rebalance into fifteen
7992
+ * manual retries. Queued jobs run FIFO; a queued job that is cancelled never
7993
+ * runs at all.
7994
+ */
7995
+ var RelocateJobStateSchema = _enum([
7996
+ "queued",
7997
+ "running",
7998
+ "done",
7999
+ "failed",
8000
+ "cancelled"
8001
+ ]);
8002
+ var RelocateJobSchema = object({
8003
+ jobId: string(),
8004
+ state: RelocateJobStateSchema,
8005
+ /** Source location — for media relocation this is informational ('*': rows
8006
+ * move from wherever they are to the target). */
8007
+ fromLocationId: string(),
8008
+ toLocationId: string(),
8009
+ /** Scoped device, or null = every device. */
8010
+ deviceId: number().nullable(),
8011
+ /** What the job moves (owner-addon specific: segments or media). */
8012
+ entities: array(string()),
8013
+ filesMoved: number().int(),
8014
+ bytesMoved: number().int(),
8015
+ /** Total files discovered up front; null while (or when) unknown. */
8016
+ filesTotal: number().int().nullable(),
7852
8017
  /**
7853
- * Declare a channel and get its gate.
8018
+ * Rows this run CORRECTED while moving them — a durable mutation the move
8019
+ * made that nobody asked for, so it is reported where the operator reads the
8020
+ * job rather than only in a log line.
7854
8021
  *
7855
- * A duplicate name throws. Two declarations of one name is a programming
7856
- * error, not a merge: the operator would arm one and the other would stay
7857
- * dark, which is the dead-knob shape (D62) with an extra step.
8022
+ * A footage segment records its byte count in its own NAME, and the durable
8023
+ * hour row derives its aggregates from those names. A file that does not
8024
+ * match its name therefore makes the ledger's sums and with them quota and
8025
+ * pressure eviction — wrong by the difference, and only a rename can fix it.
8026
+ * On 2026-08-30 one such row also stalled a 110 749-file drain permanently.
8027
+ *
8028
+ * Absent on lanes where the question has no meaning: a media blob's size is
8029
+ * in its row, not in its name, so `MediaRelocateEngine` never reconciles one.
7858
8030
  */
7859
- declare(descriptor) {
7860
- const parsed = LogChannelDescriptorSchema.parse(descriptor);
7861
- 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`);
7862
- const gate = new LogChannelGate(parsed);
7863
- this.gates.set(parsed.name, gate);
7864
- return gate;
7865
- }
7866
- /** The declarations, sorted by name so a list is stable to read and diff. */
7867
- list() {
7868
- return [...this.gates.values()].map((gate) => gate.descriptor).sort((a, b) => a.name.localeCompare(b.name));
7869
- }
7870
- /** The gate for a declared channel, or `undefined`. */
7871
- gate(name) {
7872
- return this.gates.get(name);
7873
- }
8031
+ rowsReconciled: number().int().nonnegative().optional(),
7874
8032
  /**
7875
- * Apply the FULL set of armed windows. Off the hot path.
7876
- *
7877
- * Full, not incremental, and that is the whole design: the document is the
7878
- * authority, so a channel the document does not name is disarmed here. An
7879
- * incremental apply would let a disarm get lost in transit and leave a
7880
- * channel running that nobody can see is running.
8033
+ * Rows this run FORGOT because the file they name is not on disk.
7881
8034
  *
7882
- * A window already past its deadline is ignored rather than armed a
7883
- * restore that re-armed an expired window would make a forgotten diagnostic
7884
- * immortal across restarts.
8035
+ * The mover derived the path from the row's own fields and `stat`ed it; an
8036
+ * ENOENT there is a per-path confirmation that the segment is gone (D296),
8037
+ * and the durable row is dropped through the same channel eviction uses. It
8038
+ * is reported for the same reason `rowsReconciled` is: this is a durable
8039
+ * mutation nobody asked for, and a migration that quietly erases hour rows is
8040
+ * the same failure as one that quietly skips them (D295).
7885
8041
  *
7886
- * Returns the names it could not place, so the caller can log them: a
7887
- * channel named in the document that this process does not declare is
7888
- * either a typo or an addon that has not booted yet, and both deserve a
7889
- * line rather than silence.
8042
+ * The production drain of 2026-08-30 would have reported 11 074 here the
8043
+ * ledger claimed 5.65 GB of footage that no longer existed.
7890
8044
  */
7891
- apply(windows, nowMs) {
7892
- const wanted = /* @__PURE__ */ new Map();
7893
- const unknown = [];
7894
- for (const window of windows) {
7895
- if (window.armedUntilMs <= nowMs) continue;
7896
- if (!this.gates.has(window.channel)) {
7897
- unknown.push(window.channel);
7898
- continue;
7899
- }
7900
- wanted.set(window.channel, window);
7901
- }
7902
- for (const [name, gate] of this.gates) {
7903
- const window = wanted.get(name);
7904
- if (window === void 0) gate.disarm();
7905
- else gate.arm(window);
7906
- }
7907
- return unknown;
7908
- }
8045
+ rowsForgotten: number().int().nonnegative().optional(),
8046
+ startedAt: number(),
8047
+ finishedAt: number().nullable(),
8048
+ error: string().nullable()
8049
+ });
8050
+ /** Profile-derived footage selection used only by the migration coordinator:
8051
+ * `recordings` owns high+mid; `recordingsLow` owns low. */
8052
+ var RelocateFootageClassSchema = _enum(["recordings", "recordingsLow"]);
8053
+ var RelocateFootageInputSchema = object({
8054
+ fromLocationId: string(),
8055
+ toLocationId: string(),
8056
+ entities: array(_enum(["segments"])).optional(),
8057
+ /** Limits relocation to the logical profile class. Omit only for the
8058
+ * pre-orchestration compatibility path. */
8059
+ footageClass: RelocateFootageClassSchema.optional(),
8060
+ /** Scope the move to ONE camera. Absent = every camera on the source, which
8061
+ * is what a whole-disk drain means. The rebalance path always sets it: its
8062
+ * unit is a (camera, profile) pile, not a disk. */
8063
+ deviceId: number().int().optional(),
8064
+ /** Scope the move to specific segment profiles (`high` / `mid` / `low`).
8065
+ * Finer than `footageClass`, which cannot separate high from mid — and the
8066
+ * placement plan assigns those two independently, so a rebalance that could
8067
+ * only say "recordings" would move footage the plan never asked to move. */
8068
+ profiles: array(string()).optional(),
8069
+ /** Copy throttle in MB/s (default 40) — the drain is a background chore,
8070
+ * never allowed to starve live writers. */
8071
+ throttleMbps: number().min(1).max(1e3).optional(),
8072
+ /** Move only segments whose startMs is >= this. Absent = the whole source
8073
+ * pile. Used when a full drain is too expensive and the operator only
8074
+ * wants the recent window on the new disk. */
8075
+ sinceMs: number().int().optional()
8076
+ });
8077
+ /** Internal, lease-scoped participant operation. It is intentionally separate
8078
+ * from persistent recording settings: a migration never changes
8079
+ * `RecordingConfig.enabled` or camera wrapper bindings. */
8080
+ var StorageMigrationLeaseInputSchema = object({ leaseId: string().min(1) });
8081
+ var StorageMigrationFootageMoveInputSchema = RelocateFootageInputSchema.extend({ leaseId: string().min(1) });
8082
+ /**
8083
+ * What a `relocateMedia` pass DOES. One engine, three passes — never a second
8084
+ * mover (the engine already walks both collections with a timestamp cursor and
8085
+ * already has a stamp-without-copy path).
8086
+ *
8087
+ * - `move` — the default and the historical behaviour: event-media and
8088
+ * retrain blobs move to `toLocationId` and their rows are
8089
+ * stamped. The enrolled gallery is skipped (D197).
8090
+ * - `seal` — ROWS ONLY, no bytes. Every row whose `locationId` is NULL is
8091
+ * stamped with `toLocationId`. `toLocationId` here is the id the
8092
+ * bytes ALREADY sit on — today's `eventMedia` default — because
8093
+ * a NULL row means "wherever `eventMedia` points *now*", and the
8094
+ * instant a repoint moves that pointer the row reads from the
8095
+ * new disk while its bytes are on the old one.
8096
+ * - `gallery` — the inverse selection of `move`: ONLY the retention-exempt
8097
+ * (enrolled-gallery) rows, which `move` deliberately skips.
8098
+ * `galleryMedia` is `cardinality: 'single'`, so this pass can
8099
+ * never run beside a live second location: it is stop-the-world
8100
+ * by construction, which is acceptable only because the gallery
8101
+ * is a few KB per enrolled sample.
8102
+ */
8103
+ var MediaRelocateModeSchema = _enum([
8104
+ "move",
8105
+ "seal",
8106
+ "gallery"
8107
+ ]);
8108
+ var RelocateMediaInputSchema = object({
8109
+ toLocationId: string(),
7909
8110
  /**
7910
- * Disarm whatever has run out. Called on a timer, NEVER from a log path — a
7911
- * diagnostic that adds a `Date.now()` to the path it is measuring measures
7912
- * itself.
7913
- *
7914
- * Returns the names it closed, so the caller can write the one line that
7915
- * says a window ended and stops "it went quiet" from reading as "the branch
7916
- * was not taken".
8111
+ * Restrict the pass to rows currently on this location. Omitted / `'*'` =
8112
+ * every row that is not already on `toLocationId` (the historical
8113
+ * behaviour). A named source is what a from→to migration needs: without it
8114
+ * "move events off disk 2" also emptied disk 1.
7917
8115
  */
7918
- tick(nowMs) {
7919
- const closed = [];
7920
- for (const [name, gate] of this.gates) if (gate.on && gate.armedUntilMs <= nowMs) {
7921
- gate.disarm();
7922
- closed.push(name);
7923
- }
7924
- return closed;
7925
- }
7926
- /** The channels armed right now, as the document would describe them. */
7927
- armed() {
7928
- const out = [];
7929
- for (const [name, gate] of this.gates) if (gate.on) out.push({
7930
- channel: name,
7931
- armedUntilMs: gate.armedUntilMs,
7932
- deviceIds: null
7933
- });
7934
- return out;
7935
- }
7936
- };
8116
+ fromLocationId: string().optional(),
8117
+ throttleMbps: number().min(1).max(1e3).optional(),
8118
+ /** Omitted = `move`, the pre-existing behaviour. */
8119
+ mode: MediaRelocateModeSchema.optional()
8120
+ });
7937
8121
  /**
7938
- * Process-wide holder for the {@link LogChannelRegistry}.
8122
+ * The unstamped population of ONE collection — split, because the gate and the
8123
+ * operator ask two different questions and only one of them has to be cheap.
7939
8124
  *
7940
- * Three call sites that never meet need the SAME instance: the hot paths that
7941
- * declare a gate at module scope, the `log-channels` provider that enumerates
7942
- * the declarations for the hub, and the same provider applying the windows the
7943
- * document hands down. A registry built inside any one of them would be
7944
- * refreshed and collected — the shape of a knob that never does anything.
8125
+ * `present` is the GATE: "is there at least one row that would be orphaned by a
8126
+ * repoint". It is a single indexed seek to the first matching row, so it stays
8127
+ * answerable on a saturated disk and answers in O(log n) precisely in the state
8128
+ * that matters after a seal, when the population is empty.
7945
8129
  *
7946
- * Same idiom as `logging-gate.singleton.ts` and
7947
- * `http-request-census.singleton.ts`.
8130
+ * `rows` is the NUMBER, for the refusal message and the operator's sense of
8131
+ * scale. It is a second, indexed `COUNT(*)`, and `null` means **not
8132
+ * measurable** — never zero. `{ present: true, rows: null }` is a legitimate
8133
+ * and useful answer: "there are some, and this read could not say how many"
8134
+ * still refuses the cutover, which is the whole job.
7948
8135
  */
7949
- var instance = null;
7950
- /** The process-wide log channel registry. Created empty on first use. */
7951
- function getLogChannelRegistry() {
7952
- instance ??= new LogChannelRegistry();
7953
- return instance;
7954
- }
8136
+ var UnstampedRowsSchema = object({
8137
+ present: boolean(),
8138
+ rows: number().int().nonnegative().nullable()
8139
+ });
7955
8140
  /**
7956
- * Declare a channel on the process-wide registry and get its gate.
8141
+ * How many rows still carry NO `locationId` the population a repoint would
8142
+ * silently re-aim at a disk that does not hold their bytes.
7957
8143
  *
7958
- * The one call an addon makes. Keep the returned gate in a module-scope
7959
- * `const`: looking a channel up by name per line would put a Map lookup on
7960
- * exactly the path this mechanism exists to keep free.
8144
+ * **`null` = the count could not be taken**, and it is NOT permission to cut
8145
+ * over. The gate opens on a measured absence and on nothing else; an unread
8146
+ * collection and an empty one are different facts, and this repo has already
8147
+ * paid for conflating them (`RelocateResidueSchema`, D295).
8148
+ */
8149
+ var UnstampedEventMediaCountSchema = object({
8150
+ media: UnstampedRowsSchema,
8151
+ retrainFrames: UnstampedRowsSchema,
8152
+ /** True when EITHER collection holds one. The refusal reads this. */
8153
+ anyPresent: boolean(),
8154
+ /** Sum across both, or `null` when either lane could not be counted. */
8155
+ total: number().int().nonnegative().nullable()
8156
+ }).nullable();
8157
+ var StorageMigrationMediaMoveInputSchema = RelocateMediaInputSchema.extend({ leaseId: string().min(1) });
8158
+ /** The independently selectable logical storage classes — every class
8159
+ * `storage.listLocationDeclarations` reports, so an operator never meets a
8160
+ * Zod enum error where they should meet an explanation.
7961
8161
  *
7962
- * `scripts/check-log-channel-gated.ts` reads these call sites. It pairs the
7963
- * declared name with the binding it is assigned to and refuses to let a
7964
- * channel ship that no `<binding>.on` anywhere consults a declared channel
7965
- * nobody reads is a knob the operator turns with nothing happening, forever,
7966
- * and without a line. That is D62, and this repo has now shipped it three
7967
- * times (`audioThresholdDbfs`, the HA entities with no source, the second
7968
- * per-camera switch that wrote a store nobody read).
8162
+ * `recordings` encompasses the high and mid segment profiles; `recordingsLow`
8163
+ * is low segments; `eventMedia` is post-analysis blobs; `galleryMedia` is the
8164
+ * enrolled gallery; `backups` is the system backup archive. The last two have
8165
+ * their own rules see {@link StorageMigrationFindingCodeSchema}. */
8166
+ var StorageMigrationClassSchema = _enum([
8167
+ "recordings",
8168
+ "recordingsLow",
8169
+ "eventMedia",
8170
+ "backups",
8171
+ "galleryMedia"
8172
+ ]);
8173
+ /** A destination is always an existing, fully-qualified location id. The
8174
+ * migration API intentionally never changes a source location's `basePath`:
8175
+ * callers create a new `<type>:<slug>` location, then select it here. */
8176
+ var StorageMigrationDestinationsSchema = object({
8177
+ recordings: string().min(1).optional(),
8178
+ recordingsLow: string().min(1).optional(),
8179
+ eventMedia: string().min(1).optional(),
8180
+ backups: string().min(1).optional(),
8181
+ galleryMedia: string().min(1).optional()
8182
+ }).refine((value) => Object.keys(value).length > 0, { message: "select at least one storage class" });
8183
+ /**
8184
+ * Optional named source per class. Omitted = the class's current default
8185
+ * (the historical behaviour). A named source that is NOT the default is a
8186
+ * drain of that disk: bytes move, the default stays, and the source is
8187
+ * disabled when the move finishes.
7969
8188
  */
7970
- function declareLogChannel(descriptor) {
7971
- return getLogChannelRegistry().declare(descriptor);
7972
- }
8189
+ var StorageMigrationSourcesSchema = object({
8190
+ recordings: string().min(1).optional(),
8191
+ recordingsLow: string().min(1).optional(),
8192
+ eventMedia: string().min(1).optional(),
8193
+ backups: string().min(1).optional(),
8194
+ galleryMedia: string().min(1).optional()
8195
+ }).optional();
7973
8196
  /**
7974
- * Build the `log-channels` provider for this process.
8197
+ * How a migration sequences the cutover against the byte move.
7975
8198
  *
7976
- * `logger` is used ONLY off the hot path for the arm/expiry lines — so a
7977
- * channel that is never armed costs this module nothing but a timer.
8199
+ * - `blocking` the historical order: pause, move every byte, repoint,
8200
+ * resume. Recording is stopped for the whole move. Right
8201
+ * for a small or a cold class, and the only legal mode for
8202
+ * a `cardinality: 'single'` class.
8203
+ * - `nonBlocking` — repoint FIRST, drain behind: seal, pause, repoint,
8204
+ * refresh, resume, then move the past with everything
8205
+ * running. The pause is three bounded instants (a detach +
8206
+ * attach round, a write-gate drain, a lease) instead of one
8207
+ * bounded by bytes. 1.09 TB at 7–14 MB/s is thirty hours of
8208
+ * stopped recording under `blocking`; the same move is
8209
+ * seconds of stopped recording under `nonBlocking`.
8210
+ *
8211
+ * The mode is on the JOB, not only on the input, because `status` is where an
8212
+ * operator finds out which one is running.
7978
8213
  */
7979
- function createLogChannelsProvider(logger, options = {}) {
7980
- const registry = getLogChannelRegistry();
7981
- const now = options.now ?? Date.now;
7982
- const tickMs = options.tickMs ?? 5e3;
7983
- const timer = setInterval(() => {
7984
- const closed = registry.tick(now());
7985
- for (const name of closed) logger.info("log channel window closed", {
7986
- tags: { logChannel: name },
7987
- meta: { channel: name }
7988
- });
7989
- }, tickMs);
7990
- timer.unref?.();
7991
- return {
7992
- list: () => registry.list(),
7993
- apply: (input) => {
7994
- const unknown = registry.apply(input.windows, now());
7995
- const armed = registry.armed();
7996
- logger.info("log channels applied", { meta: {
7997
- armed: armed.map((window) => window.channel),
7998
- unknown,
7999
- declared: registry.list().length
8000
- } });
8001
- return {
8002
- armed: armed.length,
8003
- unknown
8004
- };
8005
- },
8006
- stop: () => {
8007
- clearInterval(timer);
8008
- }
8009
- };
8010
- }
8214
+ var StorageMigrationModeSchema = _enum(["blocking", "nonBlocking"]);
8215
+ /** Shared input for planning and starting an orchestrated storage migration. */
8216
+ var StorageMigrationInputSchema = object({
8217
+ destinations: StorageMigrationDestinationsSchema,
8218
+ /** Omitted = each class's current default. */
8219
+ sources: StorageMigrationSourcesSchema,
8220
+ throttleMbps: number().min(1).max(1e3).optional(),
8221
+ /** Omitted = `blocking`, which stays the default. */
8222
+ mode: StorageMigrationModeSchema.optional()
8223
+ });
8011
8224
  /**
8012
- * Ops-log — the durable, append-only operations audit shared by the
8013
- * recordings and events management surfaces.
8225
+ * The durable coordinator state machine.
8014
8226
  *
8015
- * ONE row shape is reused for both domains so a single "Activity" view can
8016
- * merge the recorder's DurableState ring (recordings ops-log) and the
8017
- * pipeline-analytics SQLite collection (events ops-log). Each row records a
8018
- * management operation, WHY it ran (reason), and its measurable effect
8019
- * (itemsAffected + bytesReclaimed). Writes are best-effort a failed log must
8020
- * never fail the operation it records.
8227
+ * `blocking`:
8228
+ * planning pausing moving verifying repointing → refreshing → resuming → done
8229
+ *
8230
+ * `nonBlocking`:
8231
+ * planning sealing pausing repointing refreshing resuming → draining → verifying → done
8232
+ *
8233
+ * Same phases, different order plus two new ones — not a second mover.
8234
+ * `sealing` closes the `eventMedia` NULL-row hole BEFORE anything is paused;
8235
+ * `draining` runs the same movers UNLEASED, after every writer is back up.
8236
+ * `repointing` is still the only phase that changes a default location.
8021
8237
  */
8022
- /** Which management domain the operation belongs to. */
8023
- var OpsLogDomainSchema = _enum(["recording", "events"]);
8024
- /** The kind of management operation performed. */
8025
- var OpsLogOpSchema = _enum([
8026
- "prune",
8027
- "manual-delete",
8028
- "rescan",
8029
- "retention-run",
8030
- "relocate",
8031
- "orphan-audit"
8238
+ var StorageMigrationPhaseSchema = _enum([
8239
+ "planning",
8240
+ "sealing",
8241
+ "pausing",
8242
+ "moving",
8243
+ "draining",
8244
+ "verifying",
8245
+ "repointing",
8246
+ "refreshing",
8247
+ "resuming",
8248
+ "done",
8249
+ "failed",
8250
+ "cancelled"
8032
8251
  ]);
8033
- /** Why the operation ran. */
8034
- var OpsLogReasonSchema = _enum([
8035
- "retention",
8036
- "quota",
8037
- "manual",
8038
- "operator",
8039
- "maintenance",
8040
- "orphaned-device"
8252
+ var StorageMigrationParticipantSchema = _enum([
8253
+ "pipeline",
8254
+ "recorder",
8255
+ "analytics"
8041
8256
  ]);
8042
- /** One audit row, shared verbatim by both domains. */
8043
- var OpsLogEntrySchema = object({
8044
- /** Unique row id. */
8045
- id: string(),
8046
- /** Epoch ms the operation completed. */
8047
- at: number(),
8048
- domain: OpsLogDomainSchema,
8049
- op: OpsLogOpSchema,
8050
- reason: OpsLogReasonSchema,
8051
- /** The camera the op targeted; null for a cluster/global op. */
8052
- deviceId: number().nullable(),
8053
- /** Node that performed the op (the log carries nodeId no cross-node aggregation). */
8054
- nodeId: string(),
8055
- /** Buckets / rows deleted (op-specific unit). */
8056
- itemsAffected: number(),
8057
- /** Bytes reclaimed by the op (0 when not measurable). */
8058
- bytesReclaimed: number(),
8059
- /** Free-text detail (e.g. "floor moved to <ts>"); null when none. */
8060
- detail: string().nullable(),
8061
- /** Who/what triggered the op. */
8062
- actor: string()
8257
+ /**
8258
+ * The mover's own numbers, folded onto the coordinator's durable move record.
8259
+ *
8260
+ * The long half of a non-blocking migration is `draining`, and it is measured
8261
+ * in hours: 136 885 files at ~4 MB/s is about five of them. Before this shape
8262
+ * existed the only place those numbers appeared was a Loki line, so an operator
8263
+ * watching the Admin UI saw `phase: draining` and nothing else for a whole
8264
+ * afternoon.
8265
+ *
8266
+ * It is POLLED, never pushed. Events are telemetry and may be dropped
8267
+ * (D8/D11), and a dropped progress event is indistinguishable from a stalled
8268
+ * mover which is the exact failure this is meant to end. The coordinator's
8269
+ * `waitForMoves` already fetches the whole {@link RelocateJob} on every tick to
8270
+ * read `state`; folding the counters costs no extra read and makes the durable
8271
+ * record say afterwards how far a move actually got.
8272
+ *
8273
+ * `filesTotal` is `null` for "no honest denominator" and is never zero-filled:
8274
+ * a windowed footage job (`sinceMs`) and a node with no ledger both genuinely
8275
+ * cannot say M, and a 0 there would render as "100 % done".
8276
+ */
8277
+ var StorageMigrationMoveProgressSchema = object({
8278
+ filesMoved: number().int().nonnegative(),
8279
+ /** The archive census — the **M** of "N of M" (D295). `null` = unknowable. */
8280
+ filesTotal: number().int().nonnegative().nullable(),
8281
+ bytesMoved: number().int().nonnegative(),
8282
+ /** Rows the mover corrected while moving them — see `RelocateJob`. Absent on
8283
+ * a lane that cannot reconcile. A migration that silently rewrote durable
8284
+ * rows would be the same failure as one that silently skipped them. */
8285
+ rowsReconciled: number().int().nonnegative().optional(),
8286
+ /** The MOVER's start, not the migration's: a drain restarted after an addon
8287
+ * crash gets a new mover, and a rate computed from the migration's start
8288
+ * would silently average in the time nothing was running. */
8289
+ startedAt: number(),
8290
+ /** When the coordinator last read these numbers. Paired with `startedAt` it
8291
+ * is the only honest rate: both clocks are the hub's, so a UI never has to
8292
+ * subtract its own. */
8293
+ observedAt: number()
8063
8294
  });
8064
- /** Shared query input for the per-domain `listOpsLog` cap methods. */
8065
- var OpsLogQueryInputSchema = object({
8066
- /** Restrict to a single camera; omit for every row. */
8067
- deviceId: number().optional(),
8068
- /** Max rows returned, newest-first. */
8069
- limit: number().int().min(1).max(1e3).optional()
8295
+ var StorageMigrationMoveSchema = object({
8296
+ storageClass: StorageMigrationClassSchema,
8297
+ fromLocationId: string(),
8298
+ toLocationId: string(),
8299
+ /**
8300
+ * True when `from` was NOT the class default at plan time. The move still
8301
+ * copies bytes, but the default is left alone and the source is disabled
8302
+ * once the copy verifies. Absent on jobs planned before this field existed
8303
+ * — those jobs always repointed, which is `false`.
8304
+ */
8305
+ freezeSource: boolean().optional(),
8306
+ moverJobId: string().nullable(),
8307
+ state: RelocateJobStateSchema.nullable(),
8308
+ error: string().nullable(),
8309
+ /** Last observed mover counters; `null` until the mover has been polled once. */
8310
+ progress: StorageMigrationMoveProgressSchema.nullable()
8070
8311
  });
8071
- var LabelDefinitionSchema = object({
8072
- id: string(),
8073
- name: string(),
8074
- category: string().optional(),
8075
- description: string().optional(),
8076
- icon: string().optional()
8312
+ var StorageMigrationJobSchema = object({
8313
+ jobId: string(),
8314
+ phase: StorageMigrationPhaseSchema,
8315
+ /** Which order this job is running. `status` is the only place an operator
8316
+ * can tell a seconds-long cutover from a thirty-hour one. */
8317
+ mode: StorageMigrationModeSchema,
8318
+ destinations: StorageMigrationDestinationsSchema,
8319
+ sources: StorageMigrationSourcesSchema,
8320
+ throttleMbps: number(),
8321
+ moves: array(StorageMigrationMoveSchema),
8322
+ pauseLeaseId: string().nullable(),
8323
+ pausedParticipants: array(StorageMigrationParticipantSchema),
8324
+ repointed: boolean(),
8325
+ cancelRequested: boolean(),
8326
+ startedAt: number(),
8327
+ updatedAt: number(),
8328
+ finishedAt: number().nullable(),
8329
+ error: string().nullable()
8330
+ });
8331
+ var StorageMigrationFindingSchema = object({
8332
+ code: _enum([
8333
+ "sharesDeviceWithSource",
8334
+ "deviceIdentityUnknown",
8335
+ "unstampedEventMediaRows",
8336
+ "blockingOnly",
8337
+ "noMover"
8338
+ ]),
8339
+ storageClass: StorageMigrationClassSchema,
8340
+ /** Human-readable, already carrying the ids and counts. */
8341
+ message: string()
8342
+ });
8343
+ var StorageMigrationPlanSchema = object({
8344
+ destinations: StorageMigrationDestinationsSchema,
8345
+ sources: StorageMigrationSourcesSchema,
8346
+ /** The mode this plan was built for. A plan is only valid for its mode: the
8347
+ * `eventMedia` seal gate and the single-cardinality refusal both depend on
8348
+ * it. */
8349
+ mode: StorageMigrationModeSchema,
8350
+ moves: array(object({
8351
+ storageClass: StorageMigrationClassSchema,
8352
+ fromLocationId: string(),
8353
+ toLocationId: string(),
8354
+ freezeSource: boolean().optional()
8355
+ })),
8356
+ findings: array(StorageMigrationFindingSchema)
8077
8357
  });
8078
- /** Detection-macro targets a catalog `classMap` may resolve to. */
8079
- var CLASS_MAP_MACRO_TARGETS = [
8080
- "person",
8081
- "vehicle",
8082
- "animal",
8083
- "package"
8084
- ];
8085
8358
  /**
8086
- * Le macro classi di PRIMO LIVELLO: quelle che un object detector emette e che
8087
- * un operatore può selezionare.
8359
+ * A mover as it exists RIGHT NOW, whether or not a migration job owns it.
8088
8360
  *
8089
- * Sono le tre offerte dallo step `object-detection`
8090
- * (`addon-pipeline/src/detection-pipeline/registry/step-definitions.ts`,
8091
- * `enabledMacroClasses`). `package` sta in {@link CLASS_MAP_MACRO_TARGETS} e in
8092
- * `MACRO_LABELS` è una macro vera ma NON qui: appartiene allo step
8093
- * `package-detection`, la cui abilitazione è guidata dalle zone, e offrire la
8094
- * stessa parola due volte ha già fatto accendere a un operatore il proxy COCO
8095
- * (suitcase/backpack/handbag) lasciando spento il detector dedicato.
8361
+ * The coordinator's job record is the state of record for a migration, and its
8362
+ * moves carry {@link StorageMigrationMoveProgress}. But the movers are usable
8363
+ * standalone — `recording.relocateFootage` and `pipelineAnalytics.relocateMedia`
8364
+ * are both operator-callable, and on 2026-08-29 a five-hour drain was armed that
8365
+ * way because no supported UI path existed. A mover armed like that has no job
8366
+ * to fold progress into, so it has to be readable on its own or it is invisible.
8096
8367
  *
8097
- * UNA lista. Prima di oggi le stesse tre erano scritte a mano nell'offerta
8098
- * dello step e una seconda volta come union `FirstLevelMacro`
8099
- * (`types/detection.ts`); una terza copia per il trigger di registrazione
8100
- * (`RecordingTriggers.objectClasses`) avrebbe reso invisibile la divergenza
8101
- * successiva.
8368
+ * `migrationJobId` is what tells the two apart: `null` means nothing here
8369
+ * orchestrated it.
8102
8370
  */
8103
- var FIRST_LEVEL_MACRO_CLASSES = [
8104
- "person",
8105
- "vehicle",
8106
- "animal"
8107
- ];
8371
+ var StorageMigrationMoverSchema = object({
8372
+ lane: _enum(["footage", "media"]),
8373
+ job: RelocateJobSchema,
8374
+ /** The coordinator job that armed this mover, or `null` for a mover armed
8375
+ * directly against the owning addon. */
8376
+ migrationJobId: string().nullable(),
8377
+ /** When the hub read these counters. Stamped here so a rate is `bytesMoved`
8378
+ * over (`observedAt` − `job.startedAt`) with BOTH ends on the hub's clock —
8379
+ * a browser subtracting its own `Date.now()` from a server `startedAt` is a
8380
+ * rate made of two different clocks. */
8381
+ observedAt: number()
8382
+ });
8108
8383
  /**
8109
- * Wire schema for a per-model CATALOG classMap override
8110
- * (`ModelCatalogEntry.classMap` / `ModelConvertMetadata.classMap`) —
8111
- * restricted to {@link CLASS_MAP_MACRO_TARGETS}, the only macros the
8112
- * detection pipeline executor actually routes.
8384
+ * What a SOURCE still holds for one storage class the number that makes a
8385
+ * "drain remaining" action honest rather than hopeful.
8113
8386
  *
8114
- * This is deliberately a DIFFERENT, narrower shape than the general-purpose
8115
- * `ClassMapDefinition` interface above (e.g. `IDetectionAddon.getClassMap()`
8116
- * and the audio `YAMNET_TO_MACRO` catalog both use macro targets outside this
8117
- * enum) the two used to share the name `ClassMapDefinition`/
8118
- * `ClassMapDefinitionSchema`, which made the schema-type-twin guard
8119
- * (`scripts/check-schema-type-twins.ts`) flag them as a duplicated shape. They
8120
- * are not: it is two different concepts colliding on a name. Keep this type
8121
- * under its own name rather than reusing `ClassMapDefinition` reusing it
8122
- * would either narrow every `ClassMapDefinition` consumer to the four
8123
- * detection macros (breaking `YAMNET_TO_MACRO`) or drop the validation this
8124
- * schema exists for (see the "rejects a classMap whose target is not a
8125
- * detection macro" test in `model-catalog-schema.test.ts`).
8387
+ * It comes from the archive (`SegmentHourLedger.census` for footage, the media
8388
+ * engine's own selection count for media), never from the resident index: a
8389
+ * drain sized off `RecordingIndex` is what reported `done` over 80.3 GB it had
8390
+ * never been told about (D295).
8391
+ *
8392
+ * `items`/`bytes` are `null` for "the archive could not be asked", which is
8393
+ * deliberately NOT zero: a drain is still offered for an unknown residue,
8394
+ * because refusing on an unanswerable read would hide exactly the case an
8395
+ * operator needs to act on.
8126
8396
  */
8127
- var DetectionCatalogClassMapSchema = object({
8128
- mapping: record(string(), _enum(CLASS_MAP_MACRO_TARGETS)),
8129
- preserveOriginal: boolean()
8397
+ var StorageMigrationResidueSchema = object({
8398
+ storageClass: StorageMigrationClassSchema,
8399
+ /** The location still holding the data. `'*'` for the media lane, whose rows
8400
+ * move from wherever they are rather than from one named source. */
8401
+ fromLocationId: string(),
8402
+ /** Where a drain would move it — the class's CURRENT default. */
8403
+ toLocationId: string(),
8404
+ /** Segments (footage lane) or rows (media lane) still on the source. */
8405
+ items: number().int().nonnegative().nullable(),
8406
+ /** Bytes on the source; `null` when the lane counts rows rather than bytes. */
8407
+ bytes: number().int().nonnegative().nullable()
8130
8408
  });
8131
8409
  /**
8132
- * Numeric day-of-week: 0 = Sunday 6 = Saturday (matches `Date.getDay`).
8133
- * Named `RecordingWeekday` to avoid collision with the string-union
8134
- * `Weekday` exported from `interfaces/timezones.ts`.
8135
- */
8136
- var RecordingWeekdaySchema = number().int().min(0).max(6);
8137
- var HHMM = /^([01]\d|2[0-3]):[0-5]\d$/;
8138
- /**
8139
- * DERIVED per-camera storage summary — the single field cheap consumers read
8140
- * (the viewer's status dot, the camera list) instead of walking `bands`:
8141
- * - `off` — no band covers the camera (or it is disabled).
8142
- * - `events` — every band records around triggers only.
8143
- * - `continuous` — at least one band records continuously.
8144
- * - `on-device-decision`— the DEVICE decides: recording runs for as long as the
8145
- * camera raises its own `recording-signal` level (a robot that cleans). There
8146
- * is no schedule to author, because there is no hour to program — see
8147
- * {@link RecordingConfigSchema}`.deviceDecision`.
8410
+ * Run the DRAIN half and nothing else.
8148
8411
  *
8149
- * NEVER authored: the recorder stamps it from the authoritative intent
8150
- * (`bands` + `deviceDecision`) on every save (`activeModeForConfig`). Writing it
8151
- * has no effect.
8412
+ * A migration that reached `done` has already repointed, so `start` correctly
8413
+ * refuses its destination ("already the default") there is nothing left to
8414
+ * repoint. But the drain can fail, be cancelled, be interrupted by a restart,
8415
+ * or finish against a work list that was a tenth of the archive (D295), and
8416
+ * before this there was no supported way to run only that half: the only way
8417
+ * through was calling `recording.relocateFootage` by hand over admin tRPC.
8152
8418
  *
8153
- * `on-device-decision` is named for WHO decides, not for how the recording is
8154
- * requested. `on-demand` was rejected: in this repo's vocabulary a "demand" is
8155
- * something the operator makes (the live gate is the recorder's own "demand
8156
- * window"), and a knob whose name suggests the operator starts it while the
8157
- * device actually does is the D62 shape — a control nobody can predict.
8158
- */
8159
- var RecordingStorageModeSchema = _enum([
8160
- "off",
8161
- "events",
8162
- "continuous",
8163
- "on-device-decision"
8164
- ]);
8165
- /**
8166
- * Le macro classi che possono aprire una finestra di registrazione — le stesse
8167
- * tre offerte dallo step `object-detection`, da UNA lista
8168
- * ({@link FIRST_LEVEL_MACRO_CLASSES}).
8419
+ * `drain` NEVER calls `setDefaultLocations`. That is what keeps `start`'s
8420
+ * refusal meaningful: the two verbs are disjoint, so nothing here can silently
8421
+ * re-repoint a class that is already migrated.
8169
8422
  */
8170
- var RecordingObjectTriggerClassSchema = _enum(FIRST_LEVEL_MACRO_CLASSES);
8423
+ var StorageMigrationDrainInputSchema = object({
8424
+ /** The classes to drain. Each must appear in `storageMigration.residue`, so
8425
+ * a class whose source is already empty is refused rather than started. */
8426
+ classes: array(StorageMigrationClassSchema).min(1),
8427
+ throttleMbps: number().min(1).max(1e3).optional()
8428
+ });
8429
+ /** What a footage source still holds, asked of the durable hour ledger. */
8430
+ var RelocateResidueInputSchema = object({
8431
+ fromLocationId: string().min(1),
8432
+ /** Narrow to one logical class; omit for every profile on the location. */
8433
+ footageClass: RelocateFootageClassSchema.optional()
8434
+ });
8435
+ /** `null` = the archive could not answer (no ledger on this node, or the
8436
+ * aggregate failed). Never conflated with an empty source. */
8437
+ var RelocateResidueSchema = object({
8438
+ segments: number().int().nonnegative(),
8439
+ bytes: number().int().nonnegative()
8440
+ }).nullable();
8171
8441
  /**
8172
- * True quando `values` non ripete un elemento.
8442
+ * Ask one location whether its durable hour rows describe the disk — the walk
8443
+ * (D319).
8173
8444
  *
8174
- * Un duplicato non è innocuo: ogni voce di `objectClasses` / `sensorDeviceIds`
8175
- * diventa una SORGENTE in `bandTriggerSources`, e la stessa sorgente due volte
8176
- * conterebbe due volte le sue finestre in `segmentMissedByMs`.
8445
+ * `apply` DEFAULTS TO FALSE and that default is the product: the operator's
8446
+ * missing tool is the question, and the dry run is how they sanity-check the
8447
+ * destructive run before authorising it.
8177
8448
  */
8178
- var noDuplicates = (values) => new Set(values).size === values.length;
8179
- /** Which detectors trigger an `events`-mode band. */
8180
- var RecordingTriggersSchema = object({
8181
- motion: boolean().optional(),
8182
- audioThresholdDbfs: number().optional(),
8183
- /**
8184
- * Le macro classi la cui detection apre una finestra. ASSENTE = la sorgente
8185
- * non è ascoltata; un array VUOTO è rifiutato, perché "banda events, trigger
8186
- * object acceso, nessuna classe" è la stessa forma "abilitata e non registra
8187
- * nulla, per sempre" contro cui è scritto `eventsBandCanEverDemand`.
8188
- *
8189
- * Il segnale letto è GIÀ FILTRATO: solo detection `source: 'pipeline'`, cioè
8190
- * quelle che hanno attraversato `enabledMacroClasses`, i
8191
- * `minConfidence<Macro>` e il full-frame guard. L'AI a bordo camera
8192
- * (`source: 'onboard'`) non attraversa nessuno di quei gate e NON apre
8193
- * finestre — vedi `recorder/object-trigger.ts`.
8194
- */
8195
- objectClasses: array(RecordingObjectTriggerClassSchema).min(1).refine(noDuplicates, { message: "objectClasses must not repeat a class" }).optional(),
8196
- /**
8197
- * I device LINKED il cui FRONTE ALTO apre una finestra. Assente = la sorgente
8198
- * non è ascoltata; un array vuoto è rifiutato per la stessa ragione di
8199
- * `objectClasses`.
8200
- *
8201
- * Sono id di device SORGENTE, non camere: la banda li nomina, quindi il
8202
- * percorso caldo (`DeviceStateChanged`, a ritmo di bus su tutta la flotta)
8203
- * non fa RPC. L'OFFERTA da cui l'operatore li sceglie è un'altra domanda, e
8204
- * si risolve con `deviceManager.getLinkedDevices` + `getBindingsBatch` per
8205
- * device (D12) mai un elenco globale di cap.
8206
- *
8207
- * Cosa vuol dire "alto" dipende dal TIPO di device e non è deciso qui:
8208
- * `SOURCE_CAP_ACTIVE_FIELD` (`catalogs/sensor-active-state.ts`) è LA tabella,
8209
- * la stessa che il virtual-doorbell usa dal 2026-08-05. Ed è il FRONTE, non
8210
- * il livello: un contatto trovato già aperto al riavvio del runner non fa
8211
- * registrare.
8212
- */
8213
- sensorDeviceIds: array(number().int().positive()).min(1).max(16).refine(noDuplicates, { message: "sensorDeviceIds must not repeat a device" }).optional()
8449
+ var LedgerWalkInputSchema = object({
8450
+ locationId: string().min(1),
8451
+ /** Forget the confirmed-absent rows, rather than only counting them. */
8452
+ apply: boolean().optional(),
8453
+ /** Narrow to one camera. */
8454
+ deviceId: number().int().positive().optional(),
8455
+ /** Narrow to these recording profiles; empty/absent = every profile. */
8456
+ profiles: array(string().min(1)).optional()
8457
+ });
8458
+ /** Why a whole walk did nothing. Every one leaves the ledger untouched. */
8459
+ var LedgerWalkRefusalSchema = _enum([
8460
+ "location-unknown",
8461
+ "source-writable",
8462
+ "no-ledger",
8463
+ "archive-unreadable",
8464
+ "anchor-absent",
8465
+ "anchor-unreadable",
8466
+ "anchor-moved"
8467
+ ]);
8468
+ _enum([
8469
+ "live-tail",
8470
+ "listing-error",
8471
+ "path-mismatch",
8472
+ "durable-refused"
8473
+ ]);
8474
+ /** Every skip reason, always present, always a number so a reason that never
8475
+ * fired reports as zero rather than absent and the report shape is constant
8476
+ * between passes. Spelled out rather than `z.record` for exactly that. */
8477
+ var LedgerWalkSkipCountsSchema = object({
8478
+ "live-tail": number().int().nonnegative(),
8479
+ "listing-error": number().int().nonnegative(),
8480
+ "path-mismatch": number().int().nonnegative(),
8481
+ "durable-refused": number().int().nonnegative()
8482
+ });
8483
+ /** One camera's share of a walk, so a report names cameras and not rows. */
8484
+ var LedgerWalkDeviceReportSchema = object({
8485
+ deviceId: number().int(),
8486
+ hoursWalked: number().int().nonnegative(),
8487
+ hoursMissing: number().int().nonnegative(),
8488
+ ghostSegments: number().int().nonnegative(),
8489
+ ghostBytes: number().int().nonnegative(),
8490
+ forgottenSegments: number().int().nonnegative(),
8491
+ orphanFiles: number().int().nonnegative()
8214
8492
  });
8215
8493
  /**
8216
- * Mode of a single recording band the recorder per-band vocabulary.
8494
+ * What one walk claimed, listed, found and (only when armed) forgot.
8217
8495
  *
8218
- * Distinct from `RecordingStorageModeSchema` (which carries `off`): a band is
8219
- * only ever `continuous` or `events`; "off" is expressed by the absence of a
8220
- * covering band, not by a band value.
8496
+ * `archiveSegments` is the **M** and `segmentsClaimed` the **N** (D295), so a
8497
+ * walk that saw a fraction of the location is visible in its own report rather
8498
+ * than in the absence of one.
8221
8499
  */
8222
- var RecordingBandModeSchema = _enum(["continuous", "events"]);
8500
+ var LedgerWalkReportSchema = object({
8501
+ locationId: string(),
8502
+ applied: boolean(),
8503
+ refused: LedgerWalkRefusalSchema.nullable(),
8504
+ archiveSegments: number().int().nonnegative().nullable(),
8505
+ archiveBytes: number().int().nonnegative().nullable(),
8506
+ hoursClaimed: number().int().nonnegative(),
8507
+ hoursWalked: number().int().nonnegative(),
8508
+ hoursMissing: number().int().nonnegative(),
8509
+ /** `readdir` calls issued — the cost, stated in the unit that is paid. */
8510
+ listings: number().int().nonnegative(),
8511
+ segmentsClaimed: number().int().nonnegative(),
8512
+ ghostSegments: number().int().nonnegative(),
8513
+ ghostBytes: number().int().nonnegative(),
8514
+ ghostHoursWhole: number().int().nonnegative(),
8515
+ forgottenSegments: number().int().nonnegative(),
8516
+ forgottenBytes: number().int().nonnegative(),
8517
+ /** Files under a claimed hour that no durable row names. Never deleted. */
8518
+ orphanFiles: number().int().nonnegative(),
8519
+ orphanSample: array(string()).readonly(),
8520
+ hoursSkipped: number().int().nonnegative(),
8521
+ skippedByReason: LedgerWalkSkipCountsSchema,
8522
+ /** The walk stopped at its per-pass hour bound with claims unwalked. */
8523
+ bounded: boolean(),
8524
+ byDevice: array(LedgerWalkDeviceReportSchema).readonly()
8525
+ });
8526
+ /** How many rows a media pass would still act on against a given target — the
8527
+ * media lane's denominator AND its residue, from ONE derivation so the two can
8528
+ * never disagree. `null` = the count could not be taken. */
8529
+ var RelocatableMediaCountSchema = object({ rows: number().int().nonnegative() }).nullable();
8530
+ var RelocatableMediaCountInputSchema = object({
8531
+ toLocationId: string().min(1),
8532
+ /** Restrict the count to one source; omitted / `'*'` = every non-target row. */
8533
+ fromLocationId: string().optional(),
8534
+ /** Omitted = `move`. */
8535
+ mode: MediaRelocateModeSchema.optional()
8536
+ });
8223
8537
  /**
8224
- * Triggers for an `events`-mode band. Identical shape to
8225
- * `RecordingTriggersSchema` reuse that schema as the band trigger type so the
8226
- * two never drift.
8538
+ * Operator cleanup of leftover analytics rows, optional debug media, and
8539
+ * ghost ledger entries on frozen footage locations.
8540
+ *
8541
+ * A pass is tens of minutes on a standing backlog. The hub method RETURNS
8542
+ * `{ jobId }` immediately; progress is `cleanupStatus`. Awaiting the work
8543
+ * is how `addons.custom` hit the 60 s UDS deadline while reclaim continued
8544
+ * with no operator-visible status.
8227
8545
  */
8228
- var RecordingBandTriggersSchema = RecordingTriggersSchema;
8546
+ var StorageCleanupPhaseSchema = _enum([
8547
+ "orphans",
8548
+ "debug-media",
8549
+ "ghost-ledger",
8550
+ "done",
8551
+ "failed",
8552
+ "cancelled"
8553
+ ]);
8554
+ var StorageCleanupInputSchema = object({
8555
+ /** Also walk motion stills / track filmstrips. Off by default. */
8556
+ includeDebugMedia: boolean().optional() });
8557
+ var StorageCleanupJobSchema = object({
8558
+ jobId: string(),
8559
+ phase: StorageCleanupPhaseSchema,
8560
+ includeDebugMedia: boolean(),
8561
+ orphansReclaimed: number().int().nonnegative(),
8562
+ orphanBytesReclaimed: number().int().nonnegative(),
8563
+ debugMediaReclaimed: number().int().nonnegative(),
8564
+ debugMediaBytesReclaimed: number().int().nonnegative(),
8565
+ ghostsForgotten: number().int().nonnegative(),
8566
+ ghostBytesForgotten: number().int().nonnegative(),
8567
+ /** Short operator-facing line: current collection, pass, or location. */
8568
+ detail: string().nullable(),
8569
+ cancelRequested: boolean(),
8570
+ startedAt: number(),
8571
+ updatedAt: number(),
8572
+ finishedAt: number().nullable(),
8573
+ error: string().nullable()
8574
+ });
8575
+ var StorageCleanupStatusInputSchema = object({ jobId: string().optional() });
8229
8576
  /**
8230
- * A single mode-per-band window the canonical recorder band shape, the
8231
- * single source of truth re-used by `addon-pipeline/recorder`.
8577
+ * `StorageLocationType` an addon-declared id that identifies the *kind* of
8578
+ * storage a location serves. Defined here (not in `capabilities/storage.cap.ts`)
8579
+ * so the persisted record schema and the consumer-facing cap can both consume it
8580
+ * without forming a circular import. The `storage` cap re-exports it
8581
+ * verbatim for back-compat.
8232
8582
  *
8233
- * `days` lists the weekdays the band covers (empty = every day, matching the
8234
- * band engine's `applies` rule). `start`/`end` are `HH:MM`; an `end <= start`
8235
- * span wraps past midnight (handled by the band engine).
8583
+ * This Zod schema is the **authoritative source** for `StorageLocationType`.
8584
+ * The TS alias in `./storage.ts` re-exports `z.infer<typeof
8585
+ * StorageLocationTypeSchema>` so the wire surface (cap) and the legacy
8586
+ * `IStorageProvider` interface stay in lockstep.
8587
+ *
8588
+ * The type is now an **open string** (not a closed enum) — addons declare
8589
+ * their own location kinds via `StorageLocationDeclaration.id`. The regex
8590
+ * enforces a safe id format: lowercase-start, alphanumeric + hyphens.
8236
8591
  */
8237
- var RecordingBandSchema = object({
8238
- days: array(RecordingWeekdaySchema),
8239
- start: string().regex(HHMM),
8240
- end: string().regex(HHMM),
8241
- mode: RecordingBandModeSchema,
8242
- triggers: RecordingBandTriggersSchema.optional(),
8592
+ var StorageLocationTypeSchema = string().regex(/^[a-z][a-zA-Z0-9-]*$/);
8593
+ /**
8594
+ * Persisted record for a storage location instance. Operators can register
8595
+ * multiple instances for multi-cardinality types (e.g. two `backups`
8596
+ * locations with different `providerId`s). Cardinality is now declared per
8597
+ * location via `StorageLocationDeclaration.cardinality` — the static
8598
+ * `STORAGE_LOCATION_CARDINALITY` map has been removed.
8599
+ *
8600
+ * `id` is a stable namespaced string of the form `<type>:<slug>`.
8601
+ * The seed names its first instance `<type>:default` — a NAME, not a flag.
8602
+ * There is no default location any more (D383): `enabled` is the whole write
8603
+ * model, and a bare type ref resolves to the sole location of the type, or —
8604
+ * transitionally, only while legacy NULL-stamped rows exist — to the row whose
8605
+ * slug is `default`.
8606
+ *
8607
+ * `isSystem` is a legacy persisted flag. Seed still creates the initial
8608
+ * `<type>:default` locations; the flag is no longer a lock, a badge, or a
8609
+ * prune selector. New writes leave it false. Deletion is gated on uniqueness
8610
+ * / last-enabled, not on this bit.
8611
+ */
8612
+ var StorageLocationSchema = object({
8613
+ id: string().regex(/^[a-z][a-zA-Z0-9-]*:[a-zA-Z0-9-]+$/),
8614
+ type: string(),
8615
+ displayName: string().min(1),
8616
+ providerId: string().min(1),
8617
+ config: record(string(), unknown()),
8243
8618
  /**
8244
- * RETIRED (D381). A band no longer carries a pre-buffer of its own.
8245
- *
8246
- * It was the retroactive KEEP bound which already-written segments survive
8247
- * a trigger and it had to be at least as wide as the widest pre-roll the
8248
- * broker could serve, or the gate would delete the seconds the ring had just
8249
- * handed the writer. That "at least as wide as" is the tell: it was a second
8250
- * expression of the broker's retention, in different units, in a different
8251
- * addon, with an invariant the operator had to maintain by hand. The keep
8252
- * bound is now derived from the one prebuffer ceiling and there is nothing
8253
- * left to configure here.
8619
+ * Cluster node this location physically lives on. REQUIRED for node-local
8620
+ * providers (filesystem — the path exists on one node's disk), null/absent
8621
+ * for node-agnostic providers (S3/SFTP/WebDAV, reachable from any node).
8622
+ * `'hub'` is the hub node. Validated against the provider's `nodeLocal`
8623
+ * flag at upsert time, not here (the schema is provider-agnostic).
8624
+ */
8625
+ nodeId: string().optional(),
8626
+ isSystem: boolean().default(false),
8627
+ /**
8628
+ * THE write switch, and the only one (D383). `enabled: true` means every
8629
+ * consumer that chooses a write target for this type may write here, and all
8630
+ * enabled locations of a type are used TOGETHER; `false` means read-only —
8631
+ * still read, still played back, still age-swept, still drained, never
8632
+ * written.
8254
8633
  *
8255
- * The key survives in the SHAPE so a config written before that record still
8256
- * parses and can be migrated deliberately `RecordingBandSchema` is not
8257
- * strict, so simply deleting it would strip an operator's value in silence on
8258
- * the next save (the failure D380 came one commit from shipping). Nothing
8259
- * reads it: {@link stripRetiredBandPreBufferSec} removes it on load.
8634
+ * OPTIONAL only for the wire: an upsert that omits it means "leave what is
8635
+ * stored" on an update and "born inert unless it is the first location of its
8636
+ * type" on a create. On a PERSISTED row absence is legacy and it means
8637
+ * enabled {@link isLocationEnabled} is the one place that says so, and the
8638
+ * orchestrator stamps every flagless row `true` once at hydrate so absence
8639
+ * stops existing rather than being re-derived on every read.
8260
8640
  */
8261
- preBufferSec: number().min(0).optional(),
8262
- postBufferSec: number().min(0).optional()
8641
+ enabled: boolean().optional(),
8642
+ /** COMPUTED at read time by the orchestrator (statfs of the backing volume
8643
+ * for node-local locations it can reach) — never persisted, absent when the
8644
+ * volume is remote/unreachable. The single capacity truth every UI reads. */
8645
+ capacity: object({
8646
+ totalBytes: number(),
8647
+ availableBytes: number()
8648
+ }).nullable().optional(),
8649
+ createdAt: number(),
8650
+ updatedAt: number()
8263
8651
  });
8264
- ({ postBufferSec: 30 }).postBufferSec * 1e3;
8652
+ object({ isDefault: boolean().optional() });
8265
8653
  /**
8266
- * Per-device retention overrides. Every field is optional; an unset or `0`
8267
- * value inherits the node-wide recorder default. Only footage-lifetime limits
8268
- * live per-camera: `maxAgeDays` and `maxSizeGb`. The disk-occupancy threshold
8269
- * (when the volume is too full to keep recording) is NOT a per-camera concern —
8270
- * it belongs to the StorageLocation (`StorageLocation.config.minFreePercent`),
8271
- * shared by every camera writing to that volume.
8654
+ * Reference accepted by consumer-facing `api.storage.*` calls.
8655
+ * Either:
8656
+ * - a `StorageLocationType` (e.g. `'backups'`) the sole location of that type
8657
+ * (transitionally, the `<type>:default`-slugged row when several exist)
8658
+ * - a fully-qualified id (e.g. `'backups:nas-01'`) → addresses a specific instance
8659
+ *
8660
+ * The orchestrator's `resolveRef(ref)` handles both cases.
8272
8661
  */
8273
- var RecordingRetentionSchema = object({
8274
- maxAgeDays: number().min(0).optional(),
8275
- maxSizeGb: number().min(0).optional()
8276
- });
8662
+ var StorageLocationRefSchema = union([StorageLocationTypeSchema, string().regex(/^[a-z][a-zA-Z0-9-]*:[a-zA-Z0-9-]+$/)]);
8277
8663
  /**
8278
- * The full per-camera recording intent the wire shape of a RecordingTarget.
8664
+ * `StorageLocationDeclaration`a single storage-location entry declared by
8665
+ * an addon in its `package.json` under `camstack.storageLocations`.
8279
8666
  *
8280
- * `bands` is the ONLY authored recording intent: what to record, when, and on
8281
- * which trigger. `mode` is a derived summary the recorder stamps on save; every
8282
- * other field is a storage knob (profiles, segment length, retention, scrub).
8667
+ * Design intent:
8668
+ * - **Addon declares its needs** each addon describes the logical storage
8669
+ * slots it requires (e.g. `recordings`, `recordingsLow`) without caring
8670
+ * about the physical path.
8671
+ * - **Kernel aggregates** — at boot the kernel collects declarations from all
8672
+ * installed addons, deduplicates by `id`, and exposes the union via the
8673
+ * storage-locations settings surface.
8674
+ * - **Orchestrator seeds** — for every declared `id` the orchestrator ensures
8675
+ * at least one instance named `<id>:default` is present, using
8676
+ * `defaultsTo` to inherit the resolved root from another location when the
8677
+ * declaration is a derivative slot (e.g. `recordingsLow` defaults to
8678
+ * `recordings`).
8679
+ * - **ids are global** — `id` values are shared across the entire deployment;
8680
+ * two addons declaring the same `id` must agree on `cardinality` (validated
8681
+ * at kernel aggregation time, not here).
8682
+ */
8683
+ /**
8684
+ * `StorageAccess` — how the service that DECLARED a storage-location kind
8685
+ * actually reaches the bytes. It is the constraint that decides which
8686
+ * `storage-provider`s may back a location of that kind.
8283
8687
  *
8284
- * STRICT on purpose: the legacy authoring surface (`schedule`/`schedules`/
8285
- * `triggers`/`preBufferSec`/`postBufferSec`/`rules`) was retired 2026-07-30,
8286
- * and `scrubThumbnails` a five-step fidelity knob for a sprite tier that was
8287
- * deleted on 2026-07-24 and had ZERO consumers in the recorder — on 2026-08-25
8288
- * (D62: a switch that writes a store nobody reads is worse than no switch).
8289
- * Stored rows keep loading: the READ schema is `.strip()` (config-store.ts).
8290
- * A stale caller must fail loudly silently stripping its legacy intent would
8291
- * persist a band-less config, i.e. silently stop recording the camera.
8688
+ * - `'local-path'` the service asks `storage.resolve` for a path string and
8689
+ * then does its own `node:fs` I/O on it (the recorder's segment writer, the
8690
+ * post-analysis media roots). Only a provider that serves a genuine local
8691
+ * filesystem (`getProviderInfo().nodeLocal === true`) can satisfy that: a
8692
+ * remote provider's `resolve` returns a path on the REMOTE host, and
8693
+ * `fs.readdir` of it on this node either fails or far worse — succeeds
8694
+ * against a same-named local directory that is something else entirely.
8695
+ *
8696
+ * - `'cap-mediated'` — every byte travels through the `storage` cap
8697
+ * (`read`/`write`, or `beginUpload`/`writeChunk`/`finalizeUpload`). The
8698
+ * service never sees a path, so any provider can back it. `backups` is the
8699
+ * one kind that qualifies today.
8700
+ *
8701
+ * Before this existed, `recordings` was unreachable by SFTP/S3/WebDAV only as
8702
+ * an EMERGENT property of how the recorder happened to be written. Nothing
8703
+ * refused the configuration; the first write simply went somewhere wrong, and
8704
+ * a recording write that goes wrong surfaces as a silent black window rather
8705
+ * than an error (the read path does not `stat`). This turns that accident into
8706
+ * a declared, enforced, testable refusal.
8292
8707
  */
8293
- var RecordingConfigSchema = object({
8294
- enabled: boolean(),
8295
- /** DERIVED summary of `bands`, stamped by the recorder on every save.
8296
- * Authoring it has no effect — see {@link RecordingStorageModeSchema}. */
8297
- mode: RecordingStorageModeSchema.optional(),
8708
+ var StorageAccessSchema = _enum(["local-path", "cap-mediated"]);
8709
+ var StorageLocationDeclarationSchema = object({
8298
8710
  /**
8299
- * Which assigned broker slots to record. Absent / empty = {@link
8300
- * DEFAULT_RECORDING_PROFILES} (`high`+`low`) intersected with the
8301
- * camera's currently assigned slots — never `mid` unless the operator
8302
- * picks it, and never a slot the broker has not assigned.
8711
+ * Global location identifier, e.g. `recordings` or `recordingsLow`.
8712
+ * Must start with a lowercase letter and may contain letters, digits, and
8713
+ * hyphens.
8303
8714
  */
8304
- profiles: array(CamProfileSchema).optional(),
8305
- segmentSeconds: number().int().positive().optional(),
8715
+ id: string().regex(/^[a-z][a-zA-Z0-9-]*$/, { message: "id must start with a lowercase letter and contain only letters, digits, or hyphens" }),
8716
+ /** Human-readable name shown in the admin UI. */
8717
+ displayName: string().min(1, { message: "displayName must not be empty" }),
8718
+ /** Optional longer explanation of what data this location stores. */
8719
+ description: string().optional(),
8306
8720
  /**
8307
- * AUTHORITATIVE mode-per-band recording model the single source of truth
8308
- * the recorder's band engine consumes. An empty array = record nothing;
8309
- * "off" is the absence of a covering band, never a band value.
8721
+ * `single` exactly one instance of this location is allowed system-wide
8722
+ * (e.g. `logs`, `models`). The operator can edit it but not add more.
8723
+ * `multi` — the operator may register several instances (e.g. a second
8724
+ * `recordings` on a NAS for disk tiering); one is the default at any time.
8310
8725
  */
8311
- bands: array(RecordingBandSchema).default([]),
8726
+ cardinality: _enum(["single", "multi"]),
8312
8727
  /**
8313
- * THE device-decided intent: record for as long as the camera itself raises
8314
- * `recording-signal` (`active: true` while the device is in function a
8315
- * robot that cleans). AUTHORED, unlike `mode`, and the only authored
8316
- * recording intent that is not a band.
8317
- *
8318
- * It carries NO schedule on purpose. A band is an HOUR, and this feature has
8319
- * no hour to program: the device decides. What it costs the operator is one
8320
- * flag — "this camera can record on its own" — and what it buys is the same
8321
- * hold/release the recorder already implements (D371, `holdTrigger` /
8322
- * `releaseTrigger`): the window opens on the rise, stays open for the whole
8323
- * job however long it is, and closes one pad after the fall.
8728
+ * HOW the declaring service reaches the bytes and therefore WHICH
8729
+ * providers may back a location of this kind. See {@link StorageAccessSchema}
8730
+ * and {@link STORAGE_ACCESS_FALLBACK}.
8324
8731
  *
8325
- * EXCLUSIVE with `bands` (below): two authorities deciding when the same
8326
- * camera records is the D62 failure. `off` remains `enabled: false`, so an
8327
- * operator switching this camera off is REPORTED off, never as broken.
8732
+ * Absent means `'local-path'`. That default is FAIL-CLOSED on purpose: it
8733
+ * can only over-restrict (refuse a remote provider for a kind that might
8734
+ * have coped) and never under-restrict. Declaring `'cap-mediated'` is the
8735
+ * permissive direction and is therefore never inferred — a repo guard
8736
+ * (`scripts/check-storage-access-declarations.ts`) refuses to let it be
8737
+ * reached by omission.
8328
8738
  */
8329
- deviceDecision: boolean().optional(),
8330
- retention: RecordingRetentionSchema.optional()
8331
- }).strict().refine((config) => config.deviceDecision !== true || config.bands.length === 0, {
8332
- message: "deviceDecision is a whole-config mode and carries no schedule: it cannot be combined with bands",
8333
- path: ["bands"]
8334
- });
8335
- /**
8336
- * Entity-relocation job state (storage entity-routing spec, Phase 4).
8337
- *
8338
- * One shape shared by the recorder and pipeline-analytics internal movers.
8339
- * The public admin surface is `storage-migration`; child jobs remain in RAM
8340
- * because copy-if-absent, verify, delete and index/row repoint are resumable.
8341
- * Each completed/failed run also lands one durable ops-log row on its owning
8342
- * addon surface.
8343
- */
8344
- /**
8345
- * `queued` exists because the recorder mover is SINGLE-FLIGHT and an operator
8346
- * rebalance enqueues one job per (camera, profile). Refusing the second job —
8347
- * what the engine did before — turned a fifteen-camera rebalance into fifteen
8348
- * manual retries. Queued jobs run FIFO; a queued job that is cancelled never
8349
- * runs at all.
8350
- */
8351
- var RelocateJobStateSchema = _enum([
8352
- "queued",
8353
- "running",
8354
- "done",
8355
- "failed",
8356
- "cancelled"
8357
- ]);
8358
- var RelocateJobSchema = object({
8359
- jobId: string(),
8360
- state: RelocateJobStateSchema,
8361
- /** Source location — for media relocation this is informational ('*': rows
8362
- * move from wherever they are to the target). */
8363
- fromLocationId: string(),
8364
- toLocationId: string(),
8365
- /** Scoped device, or null = every device. */
8366
- deviceId: number().nullable(),
8367
- /** What the job moves (owner-addon specific: segments or media). */
8368
- entities: array(string()),
8369
- filesMoved: number().int(),
8370
- bytesMoved: number().int(),
8371
- /** Total files discovered up front; null while (or when) unknown. */
8372
- filesTotal: number().int().nullable(),
8739
+ access: StorageAccessSchema.optional(),
8373
8740
  /**
8374
- * Rows this run CORRECTED while moving them a durable mutation the move
8375
- * made that nobody asked for, so it is reported where the operator reads the
8376
- * job rather than only in a log line.
8377
- *
8378
- * A footage segment records its byte count in its own NAME, and the durable
8379
- * hour row derives its aggregates from those names. A file that does not
8380
- * match its name therefore makes the ledger's sums — and with them quota and
8381
- * pressure eviction — wrong by the difference, and only a rename can fix it.
8382
- * On 2026-08-30 one such row also stalled a 110 749-file drain permanently.
8383
- *
8384
- * Absent on lanes where the question has no meaning: a media blob's size is
8385
- * in its row, not in its name, so `MediaRelocateEngine` never reconciles one.
8741
+ * When set, the default instance for this location inherits its resolved
8742
+ * root from the named location's default instance. Useful for derivative
8743
+ * slots (e.g. `recordingsLow` `recordings`) so operators only need to
8744
+ * configure the primary location.
8386
8745
  */
8387
- rowsReconciled: number().int().nonnegative().optional(),
8746
+ defaultsTo: string().optional(),
8388
8747
  /**
8389
- * Rows this run FORGOT because the file they name is not on disk.
8390
- *
8391
- * The mover derived the path from the row's own fields and `stat`ed it; an
8392
- * ENOENT there is a per-path confirmation that the segment is gone (D296),
8393
- * and the durable row is dropped through the same channel eviction uses. It
8394
- * is reported for the same reason `rowsReconciled` is: this is a durable
8395
- * mutation nobody asked for, and a migration that quietly erases hour rows is
8396
- * the same failure as one that quietly skips them (D295).
8748
+ * Which node root the seeded `<id>:default` instance is placed under on a
8749
+ * FRESH install:
8750
+ * - `'data'` (default) the node's data dir (`CAMSTACK_DATA` / boot dir),
8751
+ * the appData volume. Right for small/durable data (logs, models).
8752
+ * - `'media'` the dedicated media volume (`CAMSTACK_MEDIA_ROOT`) when that
8753
+ * env is set, else falls back to the data root. Right for bulky, hot media
8754
+ * (recordings, event media) that should stay off the appData disk.
8755
+ * - `'backup'` the dedicated backup volume (`CAMSTACK_BACKUP_ROOT`, default
8756
+ * `/backups` in the image) so archives live on their own mount rather than
8757
+ * filling the appData disk. Falls back to the data root when unset.
8397
8758
  *
8398
- * The production drain of 2026-08-30 would have reported 11 074 here — the
8399
- * ledger claimed 5.65 GB of footage that no longer existed.
8759
+ * Only affects the seeded default's `basePath`; operators can repoint any
8760
+ * location afterwards, and a `defaultsTo` slot inherits its parent's root
8761
+ * regardless of this field. Absent (the common case) is treated as `'data'`.
8400
8762
  */
8401
- rowsForgotten: number().int().nonnegative().optional(),
8402
- startedAt: number(),
8403
- finishedAt: number().nullable(),
8404
- error: string().nullable()
8763
+ defaultRoot: _enum([
8764
+ "data",
8765
+ "media",
8766
+ "backup"
8767
+ ]).optional()
8405
8768
  });
8406
- /** Profile-derived footage selection used only by the migration coordinator:
8407
- * `recordings` owns high+mid; `recordingsLow` owns low. */
8408
- var RelocateFootageClassSchema = _enum(["recordings", "recordingsLow"]);
8409
- var RelocateFootageInputSchema = object({
8410
- fromLocationId: string(),
8411
- toLocationId: string(),
8412
- entities: array(_enum(["segments"])).optional(),
8413
- /** Limits relocation to the logical profile class. Omit only for the
8414
- * pre-orchestration compatibility path. */
8415
- footageClass: RelocateFootageClassSchema.optional(),
8416
- /** Scope the move to ONE camera. Absent = every camera on the source, which
8417
- * is what a whole-disk drain means. The rebalance path always sets it: its
8418
- * unit is a (camera, profile) pile, not a disk. */
8419
- deviceId: number().int().optional(),
8420
- /** Scope the move to specific segment profiles (`high` / `mid` / `low`).
8421
- * Finer than `footageClass`, which cannot separate high from mid — and the
8422
- * placement plan assigns those two independently, so a rebalance that could
8423
- * only say "recordings" would move footage the plan never asked to move. */
8424
- profiles: array(string()).optional(),
8425
- /** Copy throttle in MB/s (default 40) — the drain is a background chore,
8426
- * never allowed to starve live writers. */
8427
- throttleMbps: number().min(1).max(1e3).optional(),
8428
- /** Move only segments whose startMs is >= this. Absent = the whole source
8429
- * pile. Used when a full drain is too expensive and the operator only
8430
- * wants the recent window on the new disk. */
8431
- sinceMs: number().int().optional()
8769
+ var DecoderStatsSchema = object({
8770
+ inputFps: number(),
8771
+ outputFps: number(),
8772
+ avgDecodeTimeMs: number(),
8773
+ droppedFrames: number(),
8774
+ /**
8775
+ * Pull-mode adaptive-fps telemetry (optional — only pull sessions run the
8776
+ * lag-driven controller; push sessions omit these). `lagMs` is the EWMA of
8777
+ * the decoder's real-time drift (rising = falling behind live); `adaptiveFps`
8778
+ * is the current lag-throttled emit rate (≤ `effectiveFps` ceiling).
8779
+ */
8780
+ lagMs: number().optional(),
8781
+ effectiveFps: number().optional(),
8782
+ adaptiveFps: number().optional()
8432
8783
  });
8433
- /** Internal, lease-scoped participant operation. It is intentionally separate
8434
- * from persistent recording settings: a migration never changes
8435
- * `RecordingConfig.enabled` or camera wrapper bindings. */
8436
- var StorageMigrationLeaseInputSchema = object({ leaseId: string().min(1) });
8437
- var StorageMigrationFootageMoveInputSchema = RelocateFootageInputSchema.extend({ leaseId: string().min(1) });
8438
- /**
8439
- * What a `relocateMedia` pass DOES. One engine, three passes — never a second
8440
- * mover (the engine already walks both collections with a timestamp cursor and
8441
- * already has a stamp-without-copy path).
8442
- *
8443
- * - `move` — the default and the historical behaviour: event-media and
8444
- * retrain blobs move to `toLocationId` and their rows are
8445
- * stamped. The enrolled gallery is skipped (D197).
8446
- * - `seal` — ROWS ONLY, no bytes. Every row whose `locationId` is NULL is
8447
- * stamped with `toLocationId`. `toLocationId` here is the id the
8448
- * bytes ALREADY sit on — today's `eventMedia` default — because
8449
- * a NULL row means "wherever `eventMedia` points *now*", and the
8450
- * instant a repoint moves that pointer the row reads from the
8451
- * new disk while its bytes are on the old one.
8452
- * - `gallery` — the inverse selection of `move`: ONLY the retention-exempt
8453
- * (enrolled-gallery) rows, which `move` deliberately skips.
8454
- * `galleryMedia` is `cardinality: 'single'`, so this pass can
8455
- * never run beside a live second location: it is stop-the-world
8456
- * by construction, which is acceptable only because the gallery
8457
- * is a few KB per enrolled sample.
8458
- */
8459
- var MediaRelocateModeSchema = _enum([
8460
- "move",
8461
- "seal",
8462
- "gallery"
8463
- ]);
8464
- var RelocateMediaInputSchema = object({
8465
- toLocationId: string(),
8784
+ var DecoderSessionConfigSchema = object({
8785
+ codec: string(),
8786
+ maxFps: number().default(0),
8787
+ outputFormat: _enum([
8788
+ "jpeg",
8789
+ "rgb",
8790
+ "bgr",
8791
+ "yuv420",
8792
+ "gray"
8793
+ ]).default("jpeg"),
8794
+ scale: number().default(1),
8795
+ width: number().optional(),
8796
+ height: number().optional(),
8466
8797
  /**
8467
- * Restrict the pass to rows currently on this location. Omitted / `'*'` =
8468
- * every row that is not already on `toLocationId` (the historical
8469
- * behaviour). A named source is what a from→to migration needs: without it
8470
- * "move events off disk 2" also emptied disk 1.
8798
+ * Identifier of the camera this decoder session serves. Optional
8799
+ * because the cap is generic (any caller could request decode), but
8800
+ * stream-broker passes it so decoder logs include `deviceId` for
8801
+ * per-camera filtering when diagnosing failures (e.g. node-av
8802
+ * sendPacket errors on a single hung camera).
8471
8803
  */
8472
- fromLocationId: string().optional(),
8473
- throttleMbps: number().min(1).max(1e3).optional(),
8474
- /** Omitted = `move`, the pre-existing behaviour. */
8475
- mode: MediaRelocateModeSchema.optional()
8804
+ deviceId: number().int().nonnegative().optional(),
8805
+ /**
8806
+ * Free-form tag for log scoping. Stream-broker uses
8807
+ * `broker:<deviceId>/<profile>`. Decoder session logger surfaces it
8808
+ * on every line so `grep tag=broker:5/high` filters one camera
8809
+ * profile cleanly.
8810
+ */
8811
+ tag: string().optional(),
8812
+ /**
8813
+ * Where the session delivers decoded frames (Phase 5 / D9):
8814
+ *
8815
+ * - `'callback'` (default) — the legacy pixel path: decoded frames are
8816
+ * buffered as `DecodedFrame`s and drained via `pullFrames`.
8817
+ * - `'shm'` — the shared-memory frame plane: decoded frames are written
8818
+ * into an OS shared-memory ring and drained as zero-pixel
8819
+ * `FrameHandle`s via `pullHandles`. A session is one mode or the
8820
+ * other — `pullFrames` returns nothing for an `'shm'` session and
8821
+ * `pullHandles` returns nothing for a `'callback'` session.
8822
+ */
8823
+ frameSink: _enum(["callback", "shm"]).default("callback"),
8824
+ /**
8825
+ * Per-camera decoder DEBUG facility. When `true`, a pull-mode session emits
8826
+ * a throttled (~1Hz) structured `decoder debug` line (effective/adaptive fps,
8827
+ * real-time lag, dropped-frame delta, avg decode time, hwaccel). Mirrors the
8828
+ * stream-broker's `streamingDebug` gate — off by default so production logs
8829
+ * stay quiet and the emit path pays zero per-frame cost when disabled.
8830
+ */
8831
+ debug: boolean().optional()
8476
8832
  });
8833
+ var EU_DST = {
8834
+ offsetHours: 1,
8835
+ startMonth: 3,
8836
+ startWeekIndex: 5,
8837
+ startWeekday: "Sunday",
8838
+ startHour: 2,
8839
+ endMonth: 10,
8840
+ endWeekIndex: 5,
8841
+ endWeekday: "Sunday",
8842
+ endHour: 3
8843
+ };
8844
+ var US_DST = {
8845
+ offsetHours: 1,
8846
+ startMonth: 3,
8847
+ startWeekIndex: 2,
8848
+ startWeekday: "Sunday",
8849
+ startHour: 2,
8850
+ endMonth: 11,
8851
+ endWeekIndex: 1,
8852
+ endWeekday: "Sunday",
8853
+ endHour: 2
8854
+ };
8477
8855
  /**
8478
- * The unstamped population of ONE collection — split, because the gate and the
8479
- * operator ask two different questions and only one of them has to be cheap.
8480
- *
8481
- * `present` is the GATE: "is there at least one row that would be orphaned by a
8482
- * repoint". It is a single indexed seek to the first matching row, so it stays
8483
- * answerable on a saturated disk and answers in O(log n) precisely in the state
8484
- * that matters — after a seal, when the population is empty.
8485
- *
8486
- * `rows` is the NUMBER, for the refusal message and the operator's sense of
8487
- * scale. It is a second, indexed `COUNT(*)`, and `null` means **not
8488
- * measurable** — never zero. `{ present: true, rows: null }` is a legitimate
8489
- * and useful answer: "there are some, and this read could not say how many"
8490
- * still refuses the cutover, which is the whole job.
8856
+ * Curated catalogue of common world zones, grouped by region. Not
8857
+ * exhaustive covers the everyday zones an operator is likely to pick.
8491
8858
  */
8492
- var UnstampedRowsSchema = object({
8493
- present: boolean(),
8494
- rows: number().int().nonnegative().nullable()
8495
- });
8496
- /**
8497
- * How many rows still carry NO `locationId` — the population a repoint would
8498
- * silently re-aim at a disk that does not hold their bytes.
8499
- *
8500
- * **`null` = the count could not be taken**, and it is NOT permission to cut
8501
- * over. The gate opens on a measured absence and on nothing else; an unread
8502
- * collection and an empty one are different facts, and this repo has already
8503
- * paid for conflating them (`RelocateResidueSchema`, D295).
8504
- */
8505
- var UnstampedEventMediaCountSchema = object({
8506
- media: UnstampedRowsSchema,
8507
- retrainFrames: UnstampedRowsSchema,
8508
- /** True when EITHER collection holds one. The refusal reads this. */
8509
- anyPresent: boolean(),
8510
- /** Sum across both, or `null` when either lane could not be counted. */
8511
- total: number().int().nonnegative().nullable()
8512
- }).nullable();
8513
- var StorageMigrationMediaMoveInputSchema = RelocateMediaInputSchema.extend({ leaseId: string().min(1) });
8514
- /** The independently selectable logical storage classes — every class
8515
- * `storage.listLocationDeclarations` reports, so an operator never meets a
8516
- * Zod enum error where they should meet an explanation.
8517
- *
8518
- * `recordings` encompasses the high and mid segment profiles; `recordingsLow`
8519
- * is low segments; `eventMedia` is post-analysis blobs; `galleryMedia` is the
8520
- * enrolled gallery; `backups` is the system backup archive. The last two have
8521
- * their own rules — see {@link StorageMigrationFindingCodeSchema}. */
8522
- var StorageMigrationClassSchema = _enum([
8523
- "recordings",
8524
- "recordingsLow",
8525
- "eventMedia",
8526
- "backups",
8527
- "galleryMedia"
8528
- ]);
8529
- /** A destination is always an existing, fully-qualified location id. The
8530
- * migration API intentionally never changes a source location's `basePath`:
8531
- * callers create a new `<type>:<slug>` location, then select it here. */
8532
- var StorageMigrationDestinationsSchema = object({
8533
- recordings: string().min(1).optional(),
8534
- recordingsLow: string().min(1).optional(),
8535
- eventMedia: string().min(1).optional(),
8536
- backups: string().min(1).optional(),
8537
- galleryMedia: string().min(1).optional()
8538
- }).refine((value) => Object.keys(value).length > 0, { message: "select at least one storage class" });
8539
- /**
8540
- * Optional named source per class. Omitted = the class's current default
8541
- * (the historical behaviour). A named source that is NOT the default is a
8542
- * drain of that disk: bytes move, the default stays, and the source is
8543
- * disabled when the move finishes.
8544
- */
8545
- var StorageMigrationSourcesSchema = object({
8546
- recordings: string().min(1).optional(),
8547
- recordingsLow: string().min(1).optional(),
8548
- eventMedia: string().min(1).optional(),
8549
- backups: string().min(1).optional(),
8550
- galleryMedia: string().min(1).optional()
8551
- }).optional();
8552
- /**
8553
- * How a migration sequences the cutover against the byte move.
8554
- *
8555
- * - `blocking` — the historical order: pause, move every byte, repoint,
8556
- * resume. Recording is stopped for the whole move. Right
8557
- * for a small or a cold class, and the only legal mode for
8558
- * a `cardinality: 'single'` class.
8559
- * - `nonBlocking` — repoint FIRST, drain behind: seal, pause, repoint,
8560
- * refresh, resume, then move the past with everything
8561
- * running. The pause is three bounded instants (a detach +
8562
- * attach round, a write-gate drain, a lease) instead of one
8563
- * bounded by bytes. 1.09 TB at 7–14 MB/s is thirty hours of
8564
- * stopped recording under `blocking`; the same move is
8565
- * seconds of stopped recording under `nonBlocking`.
8566
- *
8567
- * The mode is on the JOB, not only on the input, because `status` is where an
8568
- * operator finds out which one is running.
8569
- */
8570
- var StorageMigrationModeSchema = _enum(["blocking", "nonBlocking"]);
8571
- /** Shared input for planning and starting an orchestrated storage migration. */
8572
- var StorageMigrationInputSchema = object({
8573
- destinations: StorageMigrationDestinationsSchema,
8574
- /** Omitted = each class's current default. */
8575
- sources: StorageMigrationSourcesSchema,
8576
- throttleMbps: number().min(1).max(1e3).optional(),
8577
- /** Omitted = `blocking`, which stays the default. */
8578
- mode: StorageMigrationModeSchema.optional()
8579
- });
8580
- /**
8581
- * The durable coordinator state machine.
8582
- *
8583
- * `blocking`:
8584
- * planning → pausing → moving → verifying → repointing → refreshing → resuming → done
8585
- *
8586
- * `nonBlocking`:
8587
- * planning → sealing → pausing → repointing → refreshing → resuming → draining → verifying → done
8588
- *
8589
- * Same phases, different order plus two new ones — not a second mover.
8590
- * `sealing` closes the `eventMedia` NULL-row hole BEFORE anything is paused;
8591
- * `draining` runs the same movers UNLEASED, after every writer is back up.
8592
- * `repointing` is still the only phase that changes a default location.
8593
- */
8594
- var StorageMigrationPhaseSchema = _enum([
8595
- "planning",
8596
- "sealing",
8597
- "pausing",
8598
- "moving",
8599
- "draining",
8600
- "verifying",
8601
- "repointing",
8602
- "refreshing",
8603
- "resuming",
8604
- "done",
8605
- "failed",
8606
- "cancelled"
8607
- ]);
8608
- var StorageMigrationParticipantSchema = _enum([
8609
- "pipeline",
8610
- "recorder",
8611
- "analytics"
8612
- ]);
8613
- /**
8614
- * The mover's own numbers, folded onto the coordinator's durable move record.
8615
- *
8616
- * The long half of a non-blocking migration is `draining`, and it is measured
8617
- * in hours: 136 885 files at ~4 MB/s is about five of them. Before this shape
8618
- * existed the only place those numbers appeared was a Loki line, so an operator
8619
- * watching the Admin UI saw `phase: draining` and nothing else for a whole
8620
- * afternoon.
8621
- *
8622
- * It is POLLED, never pushed. Events are telemetry and may be dropped
8623
- * (D8/D11), and a dropped progress event is indistinguishable from a stalled
8624
- * mover — which is the exact failure this is meant to end. The coordinator's
8625
- * `waitForMoves` already fetches the whole {@link RelocateJob} on every tick to
8626
- * read `state`; folding the counters costs no extra read and makes the durable
8627
- * record say afterwards how far a move actually got.
8628
- *
8629
- * `filesTotal` is `null` for "no honest denominator" and is never zero-filled:
8630
- * a windowed footage job (`sinceMs`) and a node with no ledger both genuinely
8631
- * cannot say M, and a 0 there would render as "100 % done".
8632
- */
8633
- var StorageMigrationMoveProgressSchema = object({
8634
- filesMoved: number().int().nonnegative(),
8635
- /** The archive census — the **M** of "N of M" (D295). `null` = unknowable. */
8636
- filesTotal: number().int().nonnegative().nullable(),
8637
- bytesMoved: number().int().nonnegative(),
8638
- /** Rows the mover corrected while moving them — see `RelocateJob`. Absent on
8639
- * a lane that cannot reconcile. A migration that silently rewrote durable
8640
- * rows would be the same failure as one that silently skipped them. */
8641
- rowsReconciled: number().int().nonnegative().optional(),
8642
- /** The MOVER's start, not the migration's: a drain restarted after an addon
8643
- * crash gets a new mover, and a rate computed from the migration's start
8644
- * would silently average in the time nothing was running. */
8645
- startedAt: number(),
8646
- /** When the coordinator last read these numbers. Paired with `startedAt` it
8647
- * is the only honest rate: both clocks are the hub's, so a UI never has to
8648
- * subtract its own. */
8649
- observedAt: number()
8650
- });
8651
- var StorageMigrationMoveSchema = object({
8652
- storageClass: StorageMigrationClassSchema,
8653
- fromLocationId: string(),
8654
- toLocationId: string(),
8655
- /**
8656
- * True when `from` was NOT the class default at plan time. The move still
8657
- * copies bytes, but the default is left alone and the source is disabled
8658
- * once the copy verifies. Absent on jobs planned before this field existed
8659
- * — those jobs always repointed, which is `false`.
8660
- */
8661
- freezeSource: boolean().optional(),
8662
- moverJobId: string().nullable(),
8663
- state: RelocateJobStateSchema.nullable(),
8664
- error: string().nullable(),
8665
- /** Last observed mover counters; `null` until the mover has been polled once. */
8666
- progress: StorageMigrationMoveProgressSchema.nullable()
8667
- });
8668
- var StorageMigrationJobSchema = object({
8669
- jobId: string(),
8670
- phase: StorageMigrationPhaseSchema,
8671
- /** Which order this job is running. `status` is the only place an operator
8672
- * can tell a seconds-long cutover from a thirty-hour one. */
8673
- mode: StorageMigrationModeSchema,
8674
- destinations: StorageMigrationDestinationsSchema,
8675
- sources: StorageMigrationSourcesSchema,
8676
- throttleMbps: number(),
8677
- moves: array(StorageMigrationMoveSchema),
8678
- pauseLeaseId: string().nullable(),
8679
- pausedParticipants: array(StorageMigrationParticipantSchema),
8680
- repointed: boolean(),
8681
- cancelRequested: boolean(),
8682
- startedAt: number(),
8683
- updatedAt: number(),
8684
- finishedAt: number().nullable(),
8685
- error: string().nullable()
8686
- });
8687
- var StorageMigrationFindingSchema = object({
8688
- code: _enum([
8689
- "sharesDeviceWithSource",
8690
- "deviceIdentityUnknown",
8691
- "unstampedEventMediaRows",
8692
- "blockingOnly",
8693
- "noMover"
8694
- ]),
8695
- storageClass: StorageMigrationClassSchema,
8696
- /** Human-readable, already carrying the ids and counts. */
8697
- message: string()
8698
- });
8699
- var StorageMigrationPlanSchema = object({
8700
- destinations: StorageMigrationDestinationsSchema,
8701
- sources: StorageMigrationSourcesSchema,
8702
- /** The mode this plan was built for. A plan is only valid for its mode: the
8703
- * `eventMedia` seal gate and the single-cardinality refusal both depend on
8704
- * it. */
8705
- mode: StorageMigrationModeSchema,
8706
- moves: array(object({
8707
- storageClass: StorageMigrationClassSchema,
8708
- fromLocationId: string(),
8709
- toLocationId: string(),
8710
- freezeSource: boolean().optional()
8711
- })),
8712
- findings: array(StorageMigrationFindingSchema)
8713
- });
8714
- /**
8715
- * A mover as it exists RIGHT NOW, whether or not a migration job owns it.
8716
- *
8717
- * The coordinator's job record is the state of record for a migration, and its
8718
- * moves carry {@link StorageMigrationMoveProgress}. But the movers are usable
8719
- * standalone — `recording.relocateFootage` and `pipelineAnalytics.relocateMedia`
8720
- * are both operator-callable, and on 2026-08-29 a five-hour drain was armed that
8721
- * way because no supported UI path existed. A mover armed like that has no job
8722
- * to fold progress into, so it has to be readable on its own or it is invisible.
8723
- *
8724
- * `migrationJobId` is what tells the two apart: `null` means nothing here
8725
- * orchestrated it.
8726
- */
8727
- var StorageMigrationMoverSchema = object({
8728
- lane: _enum(["footage", "media"]),
8729
- job: RelocateJobSchema,
8730
- /** The coordinator job that armed this mover, or `null` for a mover armed
8731
- * directly against the owning addon. */
8732
- migrationJobId: string().nullable(),
8733
- /** When the hub read these counters. Stamped here so a rate is `bytesMoved`
8734
- * over (`observedAt` − `job.startedAt`) with BOTH ends on the hub's clock —
8735
- * a browser subtracting its own `Date.now()` from a server `startedAt` is a
8736
- * rate made of two different clocks. */
8737
- observedAt: number()
8738
- });
8739
- /**
8740
- * What a SOURCE still holds for one storage class — the number that makes a
8741
- * "drain remaining" action honest rather than hopeful.
8742
- *
8743
- * It comes from the archive (`SegmentHourLedger.census` for footage, the media
8744
- * engine's own selection count for media), never from the resident index: a
8745
- * drain sized off `RecordingIndex` is what reported `done` over 80.3 GB it had
8746
- * never been told about (D295).
8747
- *
8748
- * `items`/`bytes` are `null` for "the archive could not be asked", which is
8749
- * deliberately NOT zero: a drain is still offered for an unknown residue,
8750
- * because refusing on an unanswerable read would hide exactly the case an
8751
- * operator needs to act on.
8752
- */
8753
- var StorageMigrationResidueSchema = object({
8754
- storageClass: StorageMigrationClassSchema,
8755
- /** The location still holding the data. `'*'` for the media lane, whose rows
8756
- * move from wherever they are rather than from one named source. */
8757
- fromLocationId: string(),
8758
- /** Where a drain would move it — the class's CURRENT default. */
8759
- toLocationId: string(),
8760
- /** Segments (footage lane) or rows (media lane) still on the source. */
8761
- items: number().int().nonnegative().nullable(),
8762
- /** Bytes on the source; `null` when the lane counts rows rather than bytes. */
8763
- bytes: number().int().nonnegative().nullable()
8764
- });
8765
- /**
8766
- * Run the DRAIN half and nothing else.
8767
- *
8768
- * A migration that reached `done` has already repointed, so `start` correctly
8769
- * refuses its destination ("already the default") — there is nothing left to
8770
- * repoint. But the drain can fail, be cancelled, be interrupted by a restart,
8771
- * or finish against a work list that was a tenth of the archive (D295), and
8772
- * before this there was no supported way to run only that half: the only way
8773
- * through was calling `recording.relocateFootage` by hand over admin tRPC.
8774
- *
8775
- * `drain` NEVER calls `setDefaultLocations`. That is what keeps `start`'s
8776
- * refusal meaningful: the two verbs are disjoint, so nothing here can silently
8777
- * re-repoint a class that is already migrated.
8778
- */
8779
- var StorageMigrationDrainInputSchema = object({
8780
- /** The classes to drain. Each must appear in `storageMigration.residue`, so
8781
- * a class whose source is already empty is refused rather than started. */
8782
- classes: array(StorageMigrationClassSchema).min(1),
8783
- throttleMbps: number().min(1).max(1e3).optional()
8784
- });
8785
- /** What a footage source still holds, asked of the durable hour ledger. */
8786
- var RelocateResidueInputSchema = object({
8787
- fromLocationId: string().min(1),
8788
- /** Narrow to one logical class; omit for every profile on the location. */
8789
- footageClass: RelocateFootageClassSchema.optional()
8790
- });
8791
- /** `null` = the archive could not answer (no ledger on this node, or the
8792
- * aggregate failed). Never conflated with an empty source. */
8793
- var RelocateResidueSchema = object({
8794
- segments: number().int().nonnegative(),
8795
- bytes: number().int().nonnegative()
8796
- }).nullable();
8797
- /**
8798
- * Ask one location whether its durable hour rows describe the disk — the walk
8799
- * (D319).
8800
- *
8801
- * `apply` DEFAULTS TO FALSE and that default is the product: the operator's
8802
- * missing tool is the question, and the dry run is how they sanity-check the
8803
- * destructive run before authorising it.
8804
- */
8805
- var LedgerWalkInputSchema = object({
8806
- locationId: string().min(1),
8807
- /** Forget the confirmed-absent rows, rather than only counting them. */
8808
- apply: boolean().optional(),
8809
- /** Narrow to one camera. */
8810
- deviceId: number().int().positive().optional(),
8811
- /** Narrow to these recording profiles; empty/absent = every profile. */
8812
- profiles: array(string().min(1)).optional()
8813
- });
8814
- /** Why a whole walk did nothing. Every one leaves the ledger untouched. */
8815
- var LedgerWalkRefusalSchema = _enum([
8816
- "location-unknown",
8817
- "source-writable",
8818
- "no-ledger",
8819
- "archive-unreadable",
8820
- "anchor-absent",
8821
- "anchor-unreadable",
8822
- "anchor-moved"
8823
- ]);
8824
- _enum([
8825
- "live-tail",
8826
- "listing-error",
8827
- "path-mismatch",
8828
- "durable-refused"
8829
- ]);
8830
- /** Every skip reason, always present, always a number — so a reason that never
8831
- * fired reports as zero rather than absent and the report shape is constant
8832
- * between passes. Spelled out rather than `z.record` for exactly that. */
8833
- var LedgerWalkSkipCountsSchema = object({
8834
- "live-tail": number().int().nonnegative(),
8835
- "listing-error": number().int().nonnegative(),
8836
- "path-mismatch": number().int().nonnegative(),
8837
- "durable-refused": number().int().nonnegative()
8838
- });
8839
- /** One camera's share of a walk, so a report names cameras and not rows. */
8840
- var LedgerWalkDeviceReportSchema = object({
8841
- deviceId: number().int(),
8842
- hoursWalked: number().int().nonnegative(),
8843
- hoursMissing: number().int().nonnegative(),
8844
- ghostSegments: number().int().nonnegative(),
8845
- ghostBytes: number().int().nonnegative(),
8846
- forgottenSegments: number().int().nonnegative(),
8847
- orphanFiles: number().int().nonnegative()
8848
- });
8849
- /**
8850
- * What one walk claimed, listed, found and (only when armed) forgot.
8851
- *
8852
- * `archiveSegments` is the **M** and `segmentsClaimed` the **N** (D295), so a
8853
- * walk that saw a fraction of the location is visible in its own report rather
8854
- * than in the absence of one.
8855
- */
8856
- var LedgerWalkReportSchema = object({
8857
- locationId: string(),
8858
- applied: boolean(),
8859
- refused: LedgerWalkRefusalSchema.nullable(),
8860
- archiveSegments: number().int().nonnegative().nullable(),
8861
- archiveBytes: number().int().nonnegative().nullable(),
8862
- hoursClaimed: number().int().nonnegative(),
8863
- hoursWalked: number().int().nonnegative(),
8864
- hoursMissing: number().int().nonnegative(),
8865
- /** `readdir` calls issued — the cost, stated in the unit that is paid. */
8866
- listings: number().int().nonnegative(),
8867
- segmentsClaimed: number().int().nonnegative(),
8868
- ghostSegments: number().int().nonnegative(),
8869
- ghostBytes: number().int().nonnegative(),
8870
- ghostHoursWhole: number().int().nonnegative(),
8871
- forgottenSegments: number().int().nonnegative(),
8872
- forgottenBytes: number().int().nonnegative(),
8873
- /** Files under a claimed hour that no durable row names. Never deleted. */
8874
- orphanFiles: number().int().nonnegative(),
8875
- orphanSample: array(string()).readonly(),
8876
- hoursSkipped: number().int().nonnegative(),
8877
- skippedByReason: LedgerWalkSkipCountsSchema,
8878
- /** The walk stopped at its per-pass hour bound with claims unwalked. */
8879
- bounded: boolean(),
8880
- byDevice: array(LedgerWalkDeviceReportSchema).readonly()
8881
- });
8882
- /** How many rows a media pass would still act on against a given target — the
8883
- * media lane's denominator AND its residue, from ONE derivation so the two can
8884
- * never disagree. `null` = the count could not be taken. */
8885
- var RelocatableMediaCountSchema = object({ rows: number().int().nonnegative() }).nullable();
8886
- var RelocatableMediaCountInputSchema = object({
8887
- toLocationId: string().min(1),
8888
- /** Restrict the count to one source; omitted / `'*'` = every non-target row. */
8889
- fromLocationId: string().optional(),
8890
- /** Omitted = `move`. */
8891
- mode: MediaRelocateModeSchema.optional()
8892
- });
8859
+ var TIMEZONES = [
8860
+ {
8861
+ id: "UTC",
8862
+ label: "UTC (UTC+0)",
8863
+ region: "Universal",
8864
+ stdOffsetMinutes: 0,
8865
+ dst: null
8866
+ },
8867
+ {
8868
+ id: "Europe/London",
8869
+ label: "Europe/London (UTC+0 / BST)",
8870
+ region: "Europe",
8871
+ stdOffsetMinutes: 0,
8872
+ dst: EU_DST
8873
+ },
8874
+ {
8875
+ id: "Europe/Rome",
8876
+ label: "Europe/Rome (UTC+1 / CEST)",
8877
+ region: "Europe",
8878
+ stdOffsetMinutes: 60,
8879
+ dst: EU_DST
8880
+ },
8881
+ {
8882
+ id: "Europe/Paris",
8883
+ label: "Europe/Paris (UTC+1 / CEST)",
8884
+ region: "Europe",
8885
+ stdOffsetMinutes: 60,
8886
+ dst: EU_DST
8887
+ },
8888
+ {
8889
+ id: "Europe/Berlin",
8890
+ label: "Europe/Berlin (UTC+1 / CEST)",
8891
+ region: "Europe",
8892
+ stdOffsetMinutes: 60,
8893
+ dst: EU_DST
8894
+ },
8895
+ {
8896
+ id: "Europe/Madrid",
8897
+ label: "Europe/Madrid (UTC+1 / CEST)",
8898
+ region: "Europe",
8899
+ stdOffsetMinutes: 60,
8900
+ dst: EU_DST
8901
+ },
8902
+ {
8903
+ id: "Europe/Amsterdam",
8904
+ label: "Europe/Amsterdam (UTC+1 / CEST)",
8905
+ region: "Europe",
8906
+ stdOffsetMinutes: 60,
8907
+ dst: EU_DST
8908
+ },
8909
+ {
8910
+ id: "Europe/Istanbul",
8911
+ label: "Europe/Istanbul (UTC+3)",
8912
+ region: "Europe",
8913
+ stdOffsetMinutes: 180,
8914
+ dst: null
8915
+ },
8916
+ {
8917
+ id: "America/New_York",
8918
+ label: "America/New York (UTC−5 / EDT)",
8919
+ region: "Americas",
8920
+ stdOffsetMinutes: -300,
8921
+ dst: US_DST
8922
+ },
8923
+ {
8924
+ id: "America/Chicago",
8925
+ label: "America/Chicago (UTC−6 / CDT)",
8926
+ region: "Americas",
8927
+ stdOffsetMinutes: -360,
8928
+ dst: US_DST
8929
+ },
8930
+ {
8931
+ id: "America/Denver",
8932
+ label: "America/Denver (UTC−7 / MDT)",
8933
+ region: "Americas",
8934
+ stdOffsetMinutes: -420,
8935
+ dst: US_DST
8936
+ },
8937
+ {
8938
+ id: "America/Los_Angeles",
8939
+ label: "America/Los Angeles (UTC−8 / PDT)",
8940
+ region: "Americas",
8941
+ stdOffsetMinutes: -480,
8942
+ dst: US_DST
8943
+ },
8944
+ {
8945
+ id: "America/Sao_Paulo",
8946
+ label: "America/Sao Paulo (UTC−3)",
8947
+ region: "Americas",
8948
+ stdOffsetMinutes: -180,
8949
+ dst: null
8950
+ },
8951
+ {
8952
+ id: "Asia/Dubai",
8953
+ label: "Asia/Dubai (UTC+4)",
8954
+ region: "Asia",
8955
+ stdOffsetMinutes: 240,
8956
+ dst: null
8957
+ },
8958
+ {
8959
+ id: "Asia/Kolkata",
8960
+ label: "Asia/Kolkata (UTC+5:30)",
8961
+ region: "Asia",
8962
+ stdOffsetMinutes: 330,
8963
+ dst: null
8964
+ },
8965
+ {
8966
+ id: "Asia/Singapore",
8967
+ label: "Asia/Singapore (UTC+8)",
8968
+ region: "Asia",
8969
+ stdOffsetMinutes: 480,
8970
+ dst: null
8971
+ },
8972
+ {
8973
+ id: "Asia/Shanghai",
8974
+ label: "Asia/Shanghai (UTC+8)",
8975
+ region: "Asia",
8976
+ stdOffsetMinutes: 480,
8977
+ dst: null
8978
+ },
8979
+ {
8980
+ id: "Asia/Tokyo",
8981
+ label: "Asia/Tokyo (UTC+9)",
8982
+ region: "Asia",
8983
+ stdOffsetMinutes: 540,
8984
+ dst: null
8985
+ },
8986
+ {
8987
+ id: "Australia/Sydney",
8988
+ label: "Australia/Sydney (UTC+10 / AEDT)",
8989
+ region: "Oceania",
8990
+ stdOffsetMinutes: 600,
8991
+ dst: {
8992
+ offsetHours: 1,
8993
+ startMonth: 10,
8994
+ startWeekIndex: 1,
8995
+ startWeekday: "Sunday",
8996
+ startHour: 2,
8997
+ endMonth: 4,
8998
+ endWeekIndex: 1,
8999
+ endWeekday: "Sunday",
9000
+ endHour: 3
9001
+ }
9002
+ }
9003
+ ];
9004
+ /** Resolve an IANA id to its `Timezone`, or `undefined` if unknown. */
9005
+ function findTimezone(id) {
9006
+ return TIMEZONES.find((tz) => tz.id === id);
9007
+ }
8893
9008
  /**
8894
- * Operator cleanup of leftover analytics rows, optional debug media, and
8895
- * ghost ledger entries on frozen footage locations.
9009
+ * Per-component log CHANNELS the gate a hot path consults, and the registry
9010
+ * an addon declares its channels in.
8896
9011
  *
8897
- * A pass is tens of minutes on a standing backlog. The hub method RETURNS
8898
- * `{ jobId }` immediately; progress is `cleanupStatus`. Awaiting the work
8899
- * is how `addons.custom` hit the 60 s UDS deadline while reclaim continued
8900
- * with no operator-visible status.
8901
- */
8902
- var StorageCleanupPhaseSchema = _enum([
8903
- "orphans",
8904
- "debug-media",
8905
- "ghost-ledger",
8906
- "done",
8907
- "failed",
8908
- "cancelled"
8909
- ]);
8910
- var StorageCleanupInputSchema = object({
8911
- /** Also walk motion stills / track filmstrips. Off by default. */
8912
- includeDebugMedia: boolean().optional() });
8913
- var StorageCleanupJobSchema = object({
8914
- jobId: string(),
8915
- phase: StorageCleanupPhaseSchema,
8916
- includeDebugMedia: boolean(),
8917
- orphansReclaimed: number().int().nonnegative(),
8918
- orphanBytesReclaimed: number().int().nonnegative(),
8919
- debugMediaReclaimed: number().int().nonnegative(),
8920
- debugMediaBytesReclaimed: number().int().nonnegative(),
8921
- ghostsForgotten: number().int().nonnegative(),
8922
- ghostBytesForgotten: number().int().nonnegative(),
8923
- /** Short operator-facing line: current collection, pass, or location. */
8924
- detail: string().nullable(),
8925
- cancelRequested: boolean(),
8926
- startedAt: number(),
8927
- updatedAt: number(),
8928
- finishedAt: number().nullable(),
8929
- error: string().nullable()
8930
- });
8931
- var StorageCleanupStatusInputSchema = object({ jobId: string().optional() });
8932
- /**
8933
- * `StorageLocationType` — an addon-declared id that identifies the *kind* of
8934
- * storage a location serves. Defined here (not in `capabilities/storage.cap.ts`)
8935
- * so the persisted record schema and the consumer-facing cap can both consume it
8936
- * without forming a circular import. The `storage` cap re-exports it
8937
- * verbatim for back-compat.
9012
+ * ## Two axes, deliberately separated
8938
9013
  *
8939
- * This Zod schema is the **authoritative source** for `StorageLocationType`.
8940
- * The TS alias in `./storage.ts` re-exports `z.infer<typeof
8941
- * StorageLocationTypeSchema>` so the wire surface (cap) and the legacy
8942
- * `IStorageProvider` interface stay in lockstep.
9014
+ * - **DECLARATION** which channels exist. Only the addon knows:
9015
+ * `stream-broker` knows webrtc/ICE/RTP, `provider-reolink` knows
9016
+ * baichuan/handshake. A hand-wired central list rots at the first addition,
9017
+ * and rots silently. So a channel is declared where it is consulted, and the
9018
+ * `log-channels` capability enumerates the declarations.
9019
+ * - **VALUE** — at which level, for which scope, until when. That stays ONE
9020
+ * thing: the logging settings document on the `system` cap. Two authorities
9021
+ * over the values is the exact defect
9022
+ * `docs/design/plans/2026-08-26-logging-per-componente.md` was written to
9023
+ * remove; re-introducing it from the cure side would be grotesque.
8943
9024
  *
8944
- * The type is now an **open string** (not a closed enum) addons declare
8945
- * their own location kinds via `StorageLocationDeclaration.id`. The regex
8946
- * enforces a safe id format: lowercase-start, alphanumeric + hyphens.
8947
- */
8948
- var StorageLocationTypeSchema = string().regex(/^[a-z][a-zA-Z0-9-]*$/);
8949
- /**
8950
- * Persisted record for a storage location instance. Operators can register
8951
- * multiple instances for multi-cardinality types (e.g. two `backups`
8952
- * locations with different `providerId`s). Cardinality is now declared per
8953
- * location via `StorageLocationDeclaration.cardinality` — the static
8954
- * `STORAGE_LOCATION_CARDINALITY` map has been removed.
9025
+ * Nothing in this file reads a clock, an env var or a store. The registry is
9026
+ * a MIRROR: it is moved only by {@link LogChannelRegistry.apply}, called off
9027
+ * the hot path with a value somebody actually read, and by
9028
+ * {@link LogChannelRegistry.tick}, called on a timer. A store read that fails
9029
+ * never reaches here, so it can neither disarm an armed channel nor arm a
9030
+ * disarmed one (D49).
8955
9031
  *
8956
- * `id` is a stable namespaced string of the form `<type>:<slug>`.
8957
- * The default location for a type uses `id === <type>:default` by
8958
- * convention (the bare type ref like `'backups'` resolves to it).
9032
+ * ## The canonical call shape
8959
9033
  *
8960
- * `isSystem` is a legacy persisted flag. Seed still creates the initial
8961
- * `<type>:default` locations; the flag is no longer a lock, a badge, or a
8962
- * prune selector. New writes leave it false. Deletion is gated on uniqueness
8963
- * / last-enabled, not on this bit.
8964
- */
8965
- var StorageLocationSchema = object({
8966
- id: string().regex(/^[a-z][a-zA-Z0-9-]*:[a-zA-Z0-9-]+$/),
8967
- type: string(),
8968
- displayName: string().min(1),
8969
- providerId: string().min(1),
8970
- config: record(string(), unknown()),
8971
- /**
8972
- * Cluster node this location physically lives on. REQUIRED for node-local
8973
- * providers (filesystem — the path exists on one node's disk), null/absent
8974
- * for node-agnostic providers (S3/SFTP/WebDAV, reachable from any node).
8975
- * `'hub'` is the hub node. Validated against the provider's `nodeLocal`
8976
- * flag at upsert time, not here (the schema is provider-agnostic).
8977
- */
8978
- nodeId: string().optional(),
8979
- isDefault: boolean().default(false),
8980
- isSystem: boolean().default(false),
8981
- /**
8982
- * Operator opt-in: whether consumers that BALANCE across several locations
8983
- * of a type may write here. Recordings reads it today; event media and
8984
- * backups are the next consumers, which is why the flag lives on the
8985
- * location rather than in any one addon's store — nothing has to be
8986
- * extended to add the next consumer.
8987
- *
8988
- * OPTIONAL, and ABSENT MEANS ACTIVE. Every location persisted before the
8989
- * flag existed reads back with no flag and keeps working exactly as before;
8990
- * that is the whole compat story, and it is why no migration ships with it.
8991
- * A newly CREATED sibling is stamped `false` by the orchestrator (creating a
8992
- * disk must not silently start writing to it); the default of a type is
8993
- * always stamped `true`.
8994
- */
8995
- enabled: boolean().optional(),
8996
- /** COMPUTED at read time by the orchestrator (statfs of the backing volume
8997
- * for node-local locations it can reach) — never persisted, absent when the
8998
- * volume is remote/unreachable. The single capacity truth every UI reads. */
8999
- capacity: object({
9000
- totalBytes: number(),
9001
- availableBytes: number()
9002
- }).nullable().optional(),
9003
- createdAt: number(),
9004
- updatedAt: number()
9005
- });
9006
- /**
9007
- * Reference accepted by consumer-facing `api.storage.*` calls.
9008
- * Either:
9009
- * - a `StorageLocationType` (e.g. `'backups'`) → orchestrator resolves to the default of that type
9010
- * - a fully-qualified id (e.g. `'backups:nas-01'`) → addresses a specific instance
9034
+ * ```ts
9035
+ * if (CH_RTP.on && CH_RTP.wants(deviceId)) {
9036
+ * CH_RTP.log(logger, 'rtp subscriber added', { tags: { deviceId }, meta: { ssrc } })
9037
+ * }
9038
+ * ```
9011
9039
  *
9012
- * The orchestrator's `resolveRef(ref)` handles both cases.
9040
+ * `on` is a plain boolean FIELD — never a getter — and it is the FIRST thing
9041
+ * read. Disarmed, a call site costs one load and one branch, and the `extras`
9042
+ * object literal is never constructed because it lives inside the branch. It
9043
+ * is the same shape already proven in production at `stream-broker.ts:1650`,
9044
+ * and the same discipline `LoggingGate.allowsDestination` uses for the
9045
+ * destination floor (measured at 1.93 ns/call when off).
9046
+ *
9047
+ * ## Why a channel emits at `info`
9048
+ *
9049
+ * `loki-logging.addon.ts` pins the destination default at `info` and
9050
+ * `loki-destination.ts` drops everything below it, so a line emitted at
9051
+ * `debug` never reaches Loki and the hub's in-memory ring only holds ~35
9052
+ * minutes. A diagnostic that cannot be read an hour later is worse than no
9053
+ * diagnostic, because it looks done. {@link LogChannelGate.log} therefore
9054
+ * emits at the channel's declared level, whose schema floor is `info`.
9013
9055
  */
9014
- var StorageLocationRefSchema = union([StorageLocationTypeSchema, string().regex(/^[a-z][a-zA-Z0-9-]*:[a-zA-Z0-9-]+$/)]);
9015
9056
  /**
9016
- * `StorageLocationDeclaration` a single storage-location entry declared by
9017
- * an addon in its `package.json` under `camstack.storageLocations`.
9057
+ * The level a channel writes at once armed.
9018
9058
  *
9019
- * Design intent:
9020
- * - **Addon declares its needs** each addon describes the logical storage
9021
- * slots it requires (e.g. `recordings`, `recordingsLow`) without caring
9022
- * about the physical path.
9023
- * - **Kernel aggregates** — at boot the kernel collects declarations from all
9024
- * installed addons, deduplicates by `id`, and exposes the union via the
9025
- * storage-locations settings surface.
9026
- * - **Orchestrator seeds** — for every declared `id` the orchestrator ensures
9027
- * at least one instance named `<id>:default` is present, using
9028
- * `defaultsTo` to inherit the resolved root from another location when the
9029
- * declaration is a derivative slot (e.g. `recordingsLow` defaults to
9030
- * `recordings`).
9031
- * - **ids are global** — `id` values are shared across the entire deployment;
9032
- * two addons declaring the same `id` must agree on `cardinality` (validated
9033
- * at kernel aggregation time, not here).
9059
+ * `debug` is absent ON PURPOSE and not by omission: below `info` the line does
9060
+ * not leave the process for Loki, and the whole point of arming a channel is
9061
+ * to read it later.
9034
9062
  */
9063
+ var LogChannelLevelSchema = _enum([
9064
+ "info",
9065
+ "warn",
9066
+ "error"
9067
+ ]);
9035
9068
  /**
9036
- * `StorageAccess` how the service that DECLARED a storage-location kind
9037
- * actually reaches the bytes. It is the constraint that decides which
9038
- * `storage-provider`s may back a location of that kind.
9039
- *
9040
- * - `'local-path'` — the service asks `storage.resolve` for a path string and
9041
- * then does its own `node:fs` I/O on it (the recorder's segment writer, the
9042
- * post-analysis media roots). Only a provider that serves a genuine local
9043
- * filesystem (`getProviderInfo().nodeLocal === true`) can satisfy that: a
9044
- * remote provider's `resolve` returns a path on the REMOTE host, and
9045
- * `fs.readdir` of it on this node either fails or — far worse — succeeds
9046
- * against a same-named local directory that is something else entirely.
9047
- *
9048
- * - `'cap-mediated'` — every byte travels through the `storage` cap
9049
- * (`read`/`write`, or `beginUpload`/`writeChunk`/`finalizeUpload`). The
9050
- * service never sees a path, so any provider can back it. `backups` is the
9051
- * one kind that qualifies today.
9052
- *
9053
- * Before this existed, `recordings` was unreachable by SFTP/S3/WebDAV only as
9054
- * an EMERGENT property of how the recorder happened to be written. Nothing
9055
- * refused the configuration; the first write simply went somewhere wrong, and
9056
- * a recording write that goes wrong surfaces as a silent black window rather
9057
- * than an error (the read path does not `stat`). This turns that accident into
9058
- * a declared, enforced, testable refusal.
9069
+ * What an addon declares about one channel. No value, no state — a
9070
+ * declaration is inert.
9059
9071
  */
9060
- var StorageAccessSchema = _enum(["local-path", "cap-mediated"]);
9061
- var StorageLocationDeclarationSchema = object({
9062
- /**
9063
- * Global location identifier, e.g. `recordings` or `recordingsLow`.
9064
- * Must start with a lowercase letter and may contain letters, digits, and
9065
- * hyphens.
9066
- */
9067
- id: string().regex(/^[a-z][a-zA-Z0-9-]*$/, { message: "id must start with a lowercase letter and contain only letters, digits, or hyphens" }),
9068
- /** Human-readable name shown in the admin UI. */
9069
- displayName: string().min(1, { message: "displayName must not be empty" }),
9070
- /** Optional longer explanation of what data this location stores. */
9071
- description: string().optional(),
9072
+ var LogChannelDescriptorSchema = object({
9072
9073
  /**
9073
- * `single` exactly one instance of this location is allowed system-wide
9074
- * (e.g. `logs`, `models`). The operator can edit it but not add more.
9075
- * `multi` — the operator may register several instances (e.g. a second
9076
- * `recordings` on a NAS for disk tiering); one is the default at any time.
9074
+ * Dotted `area.thing`, unique across the workspace. `area` is conventionally
9075
+ * the addon's short name so an operator reading a channel list can tell who
9076
+ * owns it without a second lookup.
9077
9077
  */
9078
- cardinality: _enum(["single", "multi"]),
9078
+ name: string().min(3).regex(/^[a-z0-9-]+(\.[a-z0-9-]+)+$/, "a channel name is dotted lower-kebab, e.g. area.thing"),
9079
+ /** One sentence: what the operator will SEE after arming it. */
9080
+ description: string().min(1),
9081
+ /** The level its lines are emitted at. Never below `info`. */
9082
+ defaultLevel: LogChannelLevelSchema,
9079
9083
  /**
9080
- * HOW the declaring service reaches the bytes and therefore WHICH
9081
- * providers may back a location of this kind. See {@link StorageAccessSchema}
9082
- * and {@link STORAGE_ACCESS_FALLBACK}.
9084
+ * Whether this channel can be narrowed to a camera.
9083
9085
  *
9084
- * Absent means `'local-path'`. That default is FAIL-CLOSED on purpose: it
9085
- * can only over-restrict (refuse a remote provider for a kind that might
9086
- * have coped) and never under-restrict. Declaring `'cap-mediated'` is the
9087
- * permissive direction and is therefore never inferred a repo guard
9088
- * (`scripts/check-storage-access-declarations.ts`) refuses to let it be
9089
- * reached by omission.
9086
+ * `true` is a PROMISE with two halves, and both must hold: the gate is
9087
+ * consulted with the numeric device id, AND every line the channel admits
9088
+ * carries `tags: { deviceId }` with that same numeric id. The second half is
9089
+ * what makes `| json | deviceId="617"` work in Loki `loki-payload.ts`
9090
+ * keeps `deviceId` out of the stream labels for cardinality, so the tag in
9091
+ * the body is the only way to filter.
9092
+ *
9093
+ * A channel whose lines carry the device only in `meta` (or not at all) is
9094
+ * declared `false`. Declaring it `true` anyway would be a lie the UI repeats:
9095
+ * the operator narrows to one camera, sees nothing, and concludes the code
9096
+ * path was never taken.
9090
9097
  */
9091
- access: StorageAccessSchema.optional(),
9098
+ perDevice: boolean()
9099
+ });
9100
+ /**
9101
+ * An armed window over one channel, as the document hands it to a mirror.
9102
+ *
9103
+ * A window is a DEADLINE, never a flag (ADR-0244): a channel somebody forgot
9104
+ * expires by itself, which is the one failure a boolean cannot avoid.
9105
+ */
9106
+ var LogChannelWindowSchema = object({
9107
+ channel: string().min(1),
9108
+ /** Epoch ms the window closes at. */
9109
+ armedUntilMs: number(),
9110
+ /** `null` = every camera. A non-empty list narrows to those numeric ids. */
9111
+ deviceIds: array(number().int()).readonly().nullable()
9112
+ });
9113
+ /**
9114
+ * The gate a hot path holds.
9115
+ *
9116
+ * Obtain it ONCE — at module scope or in a constructor — and keep the
9117
+ * reference. Looking a channel up by name per line would put a Map lookup on
9118
+ * the path this class exists to keep free.
9119
+ */
9120
+ var LogChannelGate = class {
9121
+ descriptor;
9092
9122
  /**
9093
- * When set, the default instance for this location inherits its resolved
9094
- * root from the named location's default instance. Useful for derivative
9095
- * slots (e.g. `recordingsLow` `recordings`) so operators only need to
9096
- * configure the primary location.
9123
+ * HOT PATH GUARD. A plain data FIELD, and it must stay one.
9124
+ *
9125
+ * `log-channel.spec.ts` asserts the property descriptor has no getter and
9126
+ * booby-traps the device set, so turning this into an accessor — or reading
9127
+ * anything before it — fails the spec instead of taxing every line the
9128
+ * process emits.
9097
9129
  */
9098
- defaultsTo: string().optional(),
9130
+ on = false;
9131
+ /** `null` while armed for every camera. Never read while `on` is false. */
9132
+ devices = null;
9133
+ level;
9134
+ closesAtMs = 0;
9135
+ constructor(descriptor) {
9136
+ this.descriptor = descriptor;
9137
+ this.level = descriptor.defaultLevel;
9138
+ }
9139
+ /** Epoch ms this channel disarms itself at. 0 when disarmed. */
9140
+ get armedUntilMs() {
9141
+ return this.on ? this.closesAtMs : 0;
9142
+ }
9099
9143
  /**
9100
- * Which node root the seeded `<id>:default` instance is placed under on a
9101
- * FRESH install:
9102
- * - `'data'` (default) — the node's data dir (`CAMSTACK_DATA` / boot dir),
9103
- * the appData volume. Right for small/durable data (logs, models).
9104
- * - `'media'` — the dedicated media volume (`CAMSTACK_MEDIA_ROOT`) when that
9105
- * env is set, else falls back to the data root. Right for bulky, hot media
9106
- * (recordings, event media) that should stay off the appData disk.
9107
- * - `'backup'` — the dedicated backup volume (`CAMSTACK_BACKUP_ROOT`, default
9108
- * `/backups` in the image) so archives live on their own mount rather than
9109
- * filling the appData disk. Falls back to the data root when unset.
9144
+ * Does this channel want a line about `deviceId`?
9110
9145
  *
9111
- * Only affects the seeded default's `basePath`; operators can repoint any
9112
- * location afterwards, and a `defaultsTo` slot inherits its parent's root
9113
- * regardless of this field. Absent (the common case) is treated as `'data'`.
9146
+ * Call it only behind `gate.on &&`. On its own it is still correct — the
9147
+ * guard is repeated inside but the point of the prefix is that a disarmed
9148
+ * channel must not pay the call at all.
9114
9149
  */
9115
- defaultRoot: _enum([
9116
- "data",
9117
- "media",
9118
- "backup"
9119
- ]).optional()
9120
- });
9121
- var DecoderStatsSchema = object({
9122
- inputFps: number(),
9123
- outputFps: number(),
9124
- avgDecodeTimeMs: number(),
9125
- droppedFrames: number(),
9150
+ wants(deviceId) {
9151
+ if (!this.on) return false;
9152
+ return this.devices === null || this.devices.has(deviceId);
9153
+ }
9126
9154
  /**
9127
- * Pull-mode adaptive-fps telemetry (optional only pull sessions run the
9128
- * lag-driven controller; push sessions omit these). `lagMs` is the EWMA of
9129
- * the decoder's real-time drift (rising = falling behind live); `adaptiveFps`
9130
- * is the current lag-throttled emit rate (≤ `effectiveFps` ceiling).
9155
+ * Emit one line on this channel, at the channel's declared level.
9156
+ *
9157
+ * The channel name is added as `tags.logChannel` so LogQL can select the
9158
+ * channel without matching on the message text, and whatever `tags` the
9159
+ * caller passed — `deviceId` above all — is preserved.
9131
9160
  */
9132
- lagMs: number().optional(),
9133
- effectiveFps: number().optional(),
9134
- adaptiveFps: number().optional()
9135
- });
9136
- var DecoderSessionConfigSchema = object({
9137
- codec: string(),
9138
- maxFps: number().default(0),
9139
- outputFormat: _enum([
9140
- "jpeg",
9141
- "rgb",
9142
- "bgr",
9143
- "yuv420",
9144
- "gray"
9145
- ]).default("jpeg"),
9146
- scale: number().default(1),
9147
- width: number().optional(),
9148
- height: number().optional(),
9161
+ log(logger, message, extras) {
9162
+ if (!this.on) return;
9163
+ const tags = {
9164
+ ...extras.tags,
9165
+ logChannel: this.descriptor.name
9166
+ };
9167
+ const line = {
9168
+ ...extras,
9169
+ tags
9170
+ };
9171
+ if (this.level === "error") logger.error(message, line);
9172
+ else if (this.level === "warn") logger.warn(message, line);
9173
+ else logger.info(message, line);
9174
+ }
9149
9175
  /**
9150
- * Identifier of the camera this decoder session serves. Optional
9151
- * because the cap is generic (any caller could request decode), but
9152
- * stream-broker passes it so decoder logs include `deviceId` for
9153
- * per-camera filtering when diagnosing failures (e.g. node-av
9154
- * sendPacket errors on a single hung camera).
9176
+ * Arm (or RE-arm, restarting) this channel. Off the hot path only.
9177
+ *
9178
+ * An empty `deviceIds` list is treated as "every camera" rather than "no
9179
+ * camera": a window that matches nothing is indistinguishable from a
9180
+ * disarmed one, and the operator who asked for it would wait for lines that
9181
+ * can never come.
9155
9182
  */
9156
- deviceId: number().int().nonnegative().optional(),
9183
+ arm(window) {
9184
+ const ids = window.deviceIds;
9185
+ this.devices = ids === null || ids.length === 0 ? null : new Set(ids);
9186
+ this.closesAtMs = window.armedUntilMs;
9187
+ this.on = true;
9188
+ }
9189
+ /** Disarm. Off the hot path only. */
9190
+ disarm() {
9191
+ this.on = false;
9192
+ this.devices = null;
9193
+ this.closesAtMs = 0;
9194
+ }
9195
+ };
9196
+ /**
9197
+ * Every channel this PROCESS declares, and the mirror of what is armed on it.
9198
+ *
9199
+ * One per process. A forked runner has its own, and it is refreshed through
9200
+ * the `log-channels` capability by the hub that owns the document — the
9201
+ * registry never reaches for a value itself.
9202
+ */
9203
+ var LogChannelRegistry = class {
9204
+ gates = /* @__PURE__ */ new Map();
9157
9205
  /**
9158
- * Free-form tag for log scoping. Stream-broker uses
9159
- * `broker:<deviceId>/<profile>`. Decoder session logger surfaces it
9160
- * on every line so `grep tag=broker:5/high` filters one camera
9161
- * profile cleanly.
9206
+ * Declare a channel and get its gate.
9207
+ *
9208
+ * A duplicate name throws. Two declarations of one name is a programming
9209
+ * error, not a merge: the operator would arm one and the other would stay
9210
+ * dark, which is the dead-knob shape (D62) with an extra step.
9162
9211
  */
9163
- tag: string().optional(),
9212
+ declare(descriptor) {
9213
+ const parsed = LogChannelDescriptorSchema.parse(descriptor);
9214
+ 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`);
9215
+ const gate = new LogChannelGate(parsed);
9216
+ this.gates.set(parsed.name, gate);
9217
+ return gate;
9218
+ }
9219
+ /** The declarations, sorted by name so a list is stable to read and diff. */
9220
+ list() {
9221
+ return [...this.gates.values()].map((gate) => gate.descriptor).sort((a, b) => a.name.localeCompare(b.name));
9222
+ }
9223
+ /** The gate for a declared channel, or `undefined`. */
9224
+ gate(name) {
9225
+ return this.gates.get(name);
9226
+ }
9164
9227
  /**
9165
- * Where the session delivers decoded frames (Phase 5 / D9):
9228
+ * Apply the FULL set of armed windows. Off the hot path.
9166
9229
  *
9167
- * - `'callback'` (default) the legacy pixel path: decoded frames are
9168
- * buffered as `DecodedFrame`s and drained via `pullFrames`.
9169
- * - `'shm'` the shared-memory frame plane: decoded frames are written
9170
- * into an OS shared-memory ring and drained as zero-pixel
9171
- * `FrameHandle`s via `pullHandles`. A session is one mode or the
9172
- * other `pullFrames` returns nothing for an `'shm'` session and
9173
- * `pullHandles` returns nothing for a `'callback'` session.
9230
+ * Full, not incremental, and that is the whole design: the document is the
9231
+ * authority, so a channel the document does not name is disarmed here. An
9232
+ * incremental apply would let a disarm get lost in transit and leave a
9233
+ * channel running that nobody can see is running.
9234
+ *
9235
+ * A window already past its deadline is ignored rather than armed — a
9236
+ * restore that re-armed an expired window would make a forgotten diagnostic
9237
+ * immortal across restarts.
9238
+ *
9239
+ * Returns the names it could not place, so the caller can log them: a
9240
+ * channel named in the document that this process does not declare is
9241
+ * either a typo or an addon that has not booted yet, and both deserve a
9242
+ * line rather than silence.
9174
9243
  */
9175
- frameSink: _enum(["callback", "shm"]).default("callback"),
9244
+ apply(windows, nowMs) {
9245
+ const wanted = /* @__PURE__ */ new Map();
9246
+ const unknown = [];
9247
+ for (const window of windows) {
9248
+ if (window.armedUntilMs <= nowMs) continue;
9249
+ if (!this.gates.has(window.channel)) {
9250
+ unknown.push(window.channel);
9251
+ continue;
9252
+ }
9253
+ wanted.set(window.channel, window);
9254
+ }
9255
+ for (const [name, gate] of this.gates) {
9256
+ const window = wanted.get(name);
9257
+ if (window === void 0) gate.disarm();
9258
+ else gate.arm(window);
9259
+ }
9260
+ return unknown;
9261
+ }
9176
9262
  /**
9177
- * Per-camera decoder DEBUG facility. When `true`, a pull-mode session emits
9178
- * a throttled (~1Hz) structured `decoder debug` line (effective/adaptive fps,
9179
- * real-time lag, dropped-frame delta, avg decode time, hwaccel). Mirrors the
9180
- * stream-broker's `streamingDebug` gate — off by default so production logs
9181
- * stay quiet and the emit path pays zero per-frame cost when disabled.
9263
+ * Disarm whatever has run out. Called on a timer, NEVER from a log path — a
9264
+ * diagnostic that adds a `Date.now()` to the path it is measuring measures
9265
+ * itself.
9266
+ *
9267
+ * Returns the names it closed, so the caller can write the one line that
9268
+ * says a window ended and stops "it went quiet" from reading as "the branch
9269
+ * was not taken".
9182
9270
  */
9183
- debug: boolean().optional()
9184
- });
9185
- var EU_DST = {
9186
- offsetHours: 1,
9187
- startMonth: 3,
9188
- startWeekIndex: 5,
9189
- startWeekday: "Sunday",
9190
- startHour: 2,
9191
- endMonth: 10,
9192
- endWeekIndex: 5,
9193
- endWeekday: "Sunday",
9194
- endHour: 3
9195
- };
9196
- var US_DST = {
9197
- offsetHours: 1,
9198
- startMonth: 3,
9199
- startWeekIndex: 2,
9200
- startWeekday: "Sunday",
9201
- startHour: 2,
9202
- endMonth: 11,
9203
- endWeekIndex: 1,
9204
- endWeekday: "Sunday",
9205
- endHour: 2
9271
+ tick(nowMs) {
9272
+ const closed = [];
9273
+ for (const [name, gate] of this.gates) if (gate.on && gate.armedUntilMs <= nowMs) {
9274
+ gate.disarm();
9275
+ closed.push(name);
9276
+ }
9277
+ return closed;
9278
+ }
9279
+ /** The channels armed right now, as the document would describe them. */
9280
+ armed() {
9281
+ const out = [];
9282
+ for (const [name, gate] of this.gates) if (gate.on) out.push({
9283
+ channel: name,
9284
+ armedUntilMs: gate.armedUntilMs,
9285
+ deviceIds: null
9286
+ });
9287
+ return out;
9288
+ }
9206
9289
  };
9207
9290
  /**
9208
- * Curated catalogue of common world zones, grouped by region. Not
9209
- * exhaustive — covers the everyday zones an operator is likely to pick.
9291
+ * Process-wide holder for the {@link LogChannelRegistry}.
9292
+ *
9293
+ * Three call sites that never meet need the SAME instance: the hot paths that
9294
+ * declare a gate at module scope, the `log-channels` provider that enumerates
9295
+ * the declarations for the hub, and the same provider applying the windows the
9296
+ * document hands down. A registry built inside any one of them would be
9297
+ * refreshed and collected — the shape of a knob that never does anything.
9298
+ *
9299
+ * Same idiom as `logging-gate.singleton.ts` and
9300
+ * `http-request-census.singleton.ts`.
9210
9301
  */
9211
- var TIMEZONES = [
9212
- {
9213
- id: "UTC",
9214
- label: "UTC (UTC+0)",
9215
- region: "Universal",
9216
- stdOffsetMinutes: 0,
9217
- dst: null
9218
- },
9219
- {
9220
- id: "Europe/London",
9221
- label: "Europe/London (UTC+0 / BST)",
9222
- region: "Europe",
9223
- stdOffsetMinutes: 0,
9224
- dst: EU_DST
9225
- },
9226
- {
9227
- id: "Europe/Rome",
9228
- label: "Europe/Rome (UTC+1 / CEST)",
9229
- region: "Europe",
9230
- stdOffsetMinutes: 60,
9231
- dst: EU_DST
9232
- },
9233
- {
9234
- id: "Europe/Paris",
9235
- label: "Europe/Paris (UTC+1 / CEST)",
9236
- region: "Europe",
9237
- stdOffsetMinutes: 60,
9238
- dst: EU_DST
9239
- },
9240
- {
9241
- id: "Europe/Berlin",
9242
- label: "Europe/Berlin (UTC+1 / CEST)",
9243
- region: "Europe",
9244
- stdOffsetMinutes: 60,
9245
- dst: EU_DST
9246
- },
9247
- {
9248
- id: "Europe/Madrid",
9249
- label: "Europe/Madrid (UTC+1 / CEST)",
9250
- region: "Europe",
9251
- stdOffsetMinutes: 60,
9252
- dst: EU_DST
9253
- },
9254
- {
9255
- id: "Europe/Amsterdam",
9256
- label: "Europe/Amsterdam (UTC+1 / CEST)",
9257
- region: "Europe",
9258
- stdOffsetMinutes: 60,
9259
- dst: EU_DST
9260
- },
9261
- {
9262
- id: "Europe/Istanbul",
9263
- label: "Europe/Istanbul (UTC+3)",
9264
- region: "Europe",
9265
- stdOffsetMinutes: 180,
9266
- dst: null
9267
- },
9268
- {
9269
- id: "America/New_York",
9270
- label: "America/New York (UTC−5 / EDT)",
9271
- region: "Americas",
9272
- stdOffsetMinutes: -300,
9273
- dst: US_DST
9274
- },
9275
- {
9276
- id: "America/Chicago",
9277
- label: "America/Chicago (UTC−6 / CDT)",
9278
- region: "Americas",
9279
- stdOffsetMinutes: -360,
9280
- dst: US_DST
9281
- },
9282
- {
9283
- id: "America/Denver",
9284
- label: "America/Denver (UTC−7 / MDT)",
9285
- region: "Americas",
9286
- stdOffsetMinutes: -420,
9287
- dst: US_DST
9288
- },
9289
- {
9290
- id: "America/Los_Angeles",
9291
- label: "America/Los Angeles (UTC−8 / PDT)",
9292
- region: "Americas",
9293
- stdOffsetMinutes: -480,
9294
- dst: US_DST
9295
- },
9296
- {
9297
- id: "America/Sao_Paulo",
9298
- label: "America/Sao Paulo (UTC−3)",
9299
- region: "Americas",
9300
- stdOffsetMinutes: -180,
9301
- dst: null
9302
- },
9303
- {
9304
- id: "Asia/Dubai",
9305
- label: "Asia/Dubai (UTC+4)",
9306
- region: "Asia",
9307
- stdOffsetMinutes: 240,
9308
- dst: null
9309
- },
9310
- {
9311
- id: "Asia/Kolkata",
9312
- label: "Asia/Kolkata (UTC+5:30)",
9313
- region: "Asia",
9314
- stdOffsetMinutes: 330,
9315
- dst: null
9316
- },
9317
- {
9318
- id: "Asia/Singapore",
9319
- label: "Asia/Singapore (UTC+8)",
9320
- region: "Asia",
9321
- stdOffsetMinutes: 480,
9322
- dst: null
9323
- },
9324
- {
9325
- id: "Asia/Shanghai",
9326
- label: "Asia/Shanghai (UTC+8)",
9327
- region: "Asia",
9328
- stdOffsetMinutes: 480,
9329
- dst: null
9330
- },
9331
- {
9332
- id: "Asia/Tokyo",
9333
- label: "Asia/Tokyo (UTC+9)",
9334
- region: "Asia",
9335
- stdOffsetMinutes: 540,
9336
- dst: null
9337
- },
9338
- {
9339
- id: "Australia/Sydney",
9340
- label: "Australia/Sydney (UTC+10 / AEDT)",
9341
- region: "Oceania",
9342
- stdOffsetMinutes: 600,
9343
- dst: {
9344
- offsetHours: 1,
9345
- startMonth: 10,
9346
- startWeekIndex: 1,
9347
- startWeekday: "Sunday",
9348
- startHour: 2,
9349
- endMonth: 4,
9350
- endWeekIndex: 1,
9351
- endWeekday: "Sunday",
9352
- endHour: 3
9302
+ var instance = null;
9303
+ /** The process-wide log channel registry. Created empty on first use. */
9304
+ function getLogChannelRegistry() {
9305
+ instance ??= new LogChannelRegistry();
9306
+ return instance;
9307
+ }
9308
+ /**
9309
+ * Declare a channel on the process-wide registry and get its gate.
9310
+ *
9311
+ * The one call an addon makes. Keep the returned gate in a module-scope
9312
+ * `const`: looking a channel up by name per line would put a Map lookup on
9313
+ * exactly the path this mechanism exists to keep free.
9314
+ *
9315
+ * `scripts/check-log-channel-gated.ts` reads these call sites. It pairs the
9316
+ * declared name with the binding it is assigned to and refuses to let a
9317
+ * channel ship that no `<binding>.on` anywhere consults — a declared channel
9318
+ * nobody reads is a knob the operator turns with nothing happening, forever,
9319
+ * and without a line. That is D62, and this repo has now shipped it three
9320
+ * times (`audioThresholdDbfs`, the HA entities with no source, the second
9321
+ * per-camera switch that wrote a store nobody read).
9322
+ */
9323
+ function declareLogChannel(descriptor) {
9324
+ return getLogChannelRegistry().declare(descriptor);
9325
+ }
9326
+ /**
9327
+ * Build the `log-channels` provider for this process.
9328
+ *
9329
+ * `logger` is used ONLY off the hot path — for the arm/expiry lines — so a
9330
+ * channel that is never armed costs this module nothing but a timer.
9331
+ */
9332
+ function createLogChannelsProvider(logger, options = {}) {
9333
+ const registry = getLogChannelRegistry();
9334
+ const now = options.now ?? Date.now;
9335
+ const tickMs = options.tickMs ?? 5e3;
9336
+ const timer = setInterval(() => {
9337
+ const closed = registry.tick(now());
9338
+ for (const name of closed) logger.info("log channel window closed", {
9339
+ tags: { logChannel: name },
9340
+ meta: { channel: name }
9341
+ });
9342
+ }, tickMs);
9343
+ timer.unref?.();
9344
+ return {
9345
+ list: () => registry.list(),
9346
+ apply: (input) => {
9347
+ const unknown = registry.apply(input.windows, now());
9348
+ const armed = registry.armed();
9349
+ logger.info("log channels applied", { meta: {
9350
+ armed: armed.map((window) => window.channel),
9351
+ unknown,
9352
+ declared: registry.list().length
9353
+ } });
9354
+ return {
9355
+ armed: armed.length,
9356
+ unknown
9357
+ };
9358
+ },
9359
+ stop: () => {
9360
+ clearInterval(timer);
9353
9361
  }
9354
- }
9355
- ];
9356
- /** Resolve an IANA id to its `Timezone`, or `undefined` if unknown. */
9357
- function findTimezone(id) {
9358
- return TIMEZONES.find((tz) => tz.id === id);
9362
+ };
9359
9363
  }
9360
9364
  /**
9361
9365
  * Distinct (device, family, variant) counters one instance will hold.
@@ -11119,12 +11123,30 @@ var BackupDestinationInfoSchema = object({
11119
11123
  lastSuccessAt: number().optional(),
11120
11124
  /** Newest-archive size from `manifests.json`, or undefined. */
11121
11125
  lastSuccessSizeBytes: number().optional(),
11122
- /** Per-destination cron expression. Empty = manual-only (no schedule). */
11126
+ /**
11127
+ * Cron cadence(s) of the ENABLED schedules that fan out to this
11128
+ * destination, comma-joined. Absent when no enabled schedule targets it
11129
+ * — a destination nothing is scheduled to write to must not advertise a
11130
+ * cadence (D384). This is never the `backup_destination_policies.cron`
11131
+ * column: that per-location cron has scheduled nothing since 2026-07-28
11132
+ * and reading it made a destination with a DISABLED schedule claim a
11133
+ * nightly run.
11134
+ */
11123
11135
  cron: string().optional(),
11124
- /** ms-epoch of next computed firing for this destination's cron, if any. */
11136
+ /** ms-epoch of the next firing across those schedules (earliest), if any. */
11125
11137
  nextRunAt: number().optional(),
11126
- /** ms-epoch of last successful scheduled run (mirrors policy.lastRunAt). */
11127
- lastRunAt: number().optional()
11138
+ /**
11139
+ * ms-epoch of the last time a run ATTEMPTED to write here — success or
11140
+ * failure. Never a success stamp: pair it with `lastSuccessAt` (the
11141
+ * newest archive that actually landed) and `lastError`.
11142
+ */
11143
+ lastAttemptAt: number().optional(),
11144
+ /**
11145
+ * Why the last attempt failed, verbatim. Absent when the last attempt
11146
+ * landed the archive. A destination that has never been written to has
11147
+ * neither this nor `lastAttemptAt`.
11148
+ */
11149
+ lastError: string().optional()
11128
11150
  });
11129
11151
  /**
11130
11152
  * Per-archive entry returned by `backup.listArchives({ destinationId })`.
@@ -11287,8 +11309,16 @@ var BackupScheduleSchema = object({
11287
11309
  retentionCount: number().int().min(1).max(1e3),
11288
11310
  /** Optional subset of source locations to include; omitted = all. */
11289
11311
  dataSources: array(string()).readonly().optional(),
11290
- /** ms-epoch of last successful run. */
11291
- lastRunAt: number().optional(),
11312
+ /**
11313
+ * ms-epoch of the last tick that FIRED this schedule. Stamped before the
11314
+ * archive runs (it is the dedupe anchor), so it says "attempted", never
11315
+ * "succeeded" — a run refused by every destination stamps it too.
11316
+ */
11317
+ lastAttemptAt: number().optional(),
11318
+ /** ms-epoch of the last run of this schedule that landed at ≥1 destination. */
11319
+ lastSuccessAt: number().optional(),
11320
+ /** Why the last fired run failed, verbatim. Absent when it succeeded. */
11321
+ lastError: string().optional(),
11292
11322
  /** ms-epoch of next computed firing (read-only, filled on list). */
11293
11323
  nextRunAt: number().optional()
11294
11324
  });
@@ -11337,14 +11367,7 @@ method(_void(), array(BackupDestinationInfoSchema).readonly(), { auth: "admin" }
11337
11367
  locationId: string(),
11338
11368
  enabled: boolean(),
11339
11369
  retentionCount: number().int().min(1).max(1e3),
11340
- label: string().optional(),
11341
- /**
11342
- * Per-destination cron expression. Empty string clears the
11343
- * schedule (manual-only). Validated server-side via croner;
11344
- * malformed expressions reject the upsert with an actionable
11345
- * message.
11346
- */
11347
- cron: string().optional()
11370
+ label: string().optional()
11348
11371
  }), _void(), {
11349
11372
  kind: "mutation",
11350
11373
  auth: "admin"
@@ -22654,7 +22677,7 @@ method(object({
22654
22677
  downloadId: string(),
22655
22678
  offset: number(),
22656
22679
  length: number()
22657
- }), _instanceof(Uint8Array)), method(object({ downloadId: string() }), _void(), { kind: "mutation" }), method(object({ type: StorageLocationTypeSchema.optional() }), array(StorageLocationSchema).readonly()), method(object({ type: StorageLocationTypeSchema }), StorageLocationSchema.nullable()), method(_void(), array(StorageLocationDeclarationSchema).readonly()), method(StorageLocationSchema.omit({
22680
+ }), _instanceof(Uint8Array)), method(object({ downloadId: string() }), _void(), { kind: "mutation" }), method(object({ type: StorageLocationTypeSchema.optional() }), array(StorageLocationSchema).readonly()), method(_void(), array(StorageLocationDeclarationSchema).readonly()), method(StorageLocationSchema.omit({
22658
22681
  createdAt: true,
22659
22682
  updatedAt: true
22660
22683
  }), StorageLocationSchema, {
@@ -39982,12 +40005,6 @@ Object.freeze({
39982
40005
  addonId: null,
39983
40006
  access: "view"
39984
40007
  },
39985
- "storage.getDefaultLocation": {
39986
- capName: "storage",
39987
- capScope: "system",
39988
- addonId: null,
39989
- access: "view"
39990
- },
39991
40008
  "storage.list": {
39992
40009
  capName: "storage",
39993
40010
  capScope: "system",