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