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