@camstack/addon-pipeline-orchestrator 1.2.51 → 1.2.53

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.mjs CHANGED
@@ -13835,11 +13835,33 @@ var NotificationFormatSchema = _enum([
13835
13835
  * Named by INTENT, never by glyph. "check" would tie the vocabulary to one
13836
13836
  * renderer's icon set; "acknowledge" survives an adapter that draws it
13837
13837
  * differently.
13838
+ *
13839
+ * ── A TOKEN IS NOT A WIRE VALUE ─────────────────────────────────────
13840
+ *
13841
+ * These names are for US. **No adapter may forward one verbatim.** Each maps
13842
+ * the whole set onto its own renderer's vocabulary through a
13843
+ * `Record<NotificationActionIcon, string>` — a Record, never a lookup with a
13844
+ * fallback, so adding a member here fails every adapter's build until someone
13845
+ * decides its glyph, which is the only place that decision can be made
13846
+ * honestly.
13847
+ *
13848
+ * This paragraph is the bug. Zentik declared `actionIcons: true` and passed
13849
+ * `disarm` straight through; iOS feeds that string to
13850
+ * `UNNotificationActionIcon(systemImageName:)`, `disarm` is not an SF Symbol,
13851
+ * and every snooze and alarm button arrived BLANK. A pass-through is not a
13852
+ * mapping, and "the field is documented" is not "the value renders".
13853
+ *
13854
+ * Adding a member is TRAIN-BOUND. The enum lives in the published
13855
+ * `@camstack/server` closure and the cap seam validates against the HUB's copy,
13856
+ * so an addon that emits a token the running hub does not know does not lose an
13857
+ * icon — its whole `send` fails Zod validation and the notification never
13858
+ * arrives. Never emit a new token from an addon before the train carrying it.
13838
13859
  */
13839
13860
  var NotificationActionIconSchema = _enum([
13840
13861
  "acknowledge",
13841
13862
  "dismiss",
13842
13863
  "silence",
13864
+ "snooze",
13843
13865
  "view",
13844
13866
  "play",
13845
13867
  "open",
@@ -13847,9 +13869,13 @@ var NotificationActionIconSchema = _enum([
13847
13869
  "lock",
13848
13870
  "unlock",
13849
13871
  "arm",
13872
+ "arm-home",
13873
+ "arm-away",
13874
+ "arm-night",
13850
13875
  "disarm",
13851
13876
  "light",
13852
- "alert"
13877
+ "alert",
13878
+ "camera"
13853
13879
  ]);
13854
13880
  /** A single tap-through action button. */
13855
13881
  var NotificationActionSchema = object({
@@ -13865,7 +13891,23 @@ var NotificationActionSchema = object({
13865
13891
  * else — see `notification-center/action-token.ts` for what that does and
13866
13892
  * does not buy.
13867
13893
  */
13868
- destructive: boolean().optional()
13894
+ destructive: boolean().optional(),
13895
+ /**
13896
+ * How the tap should REACH the url.
13897
+ *
13898
+ * `navigate` (absent, and every button authored before this field) opens it:
13899
+ * the phone leaves the notification and shows whatever the callback returns.
13900
+ * That is right for a button whose answer the operator wants to read.
13901
+ *
13902
+ * `background` fires it as a POST and stays put. It exists for the buttons
13903
+ * whose whole point is not to interrupt — "silence this for 30 minutes" is
13904
+ * an answer to the notification, and being thrown into a browser tab to
13905
+ * confirm it costs more attention than the notification did. A backend that
13906
+ * cannot do a background call renders it as an ordinary link (the adapters
13907
+ * fall back rather than dropping the button), so this is a preference, never
13908
+ * a requirement.
13909
+ */
13910
+ mode: _enum(["navigate", "background"]).optional()
13869
13911
  });
13870
13912
  /**
13871
13913
  * The canonical notification. `body` is the only hard field (Apprise model).
@@ -14033,6 +14075,24 @@ method(object({}), array(TargetKindSchema)), method(object({}), array(TargetSche
14033
14075
  targetId: string(),
14034
14076
  enabled: boolean()
14035
14077
  }), _void(), { kind: "mutation" });
14078
+ new Set([
14079
+ {
14080
+ id: "person",
14081
+ name: "Person"
14082
+ },
14083
+ {
14084
+ id: "vehicle",
14085
+ name: "Vehicle"
14086
+ },
14087
+ {
14088
+ id: "animal",
14089
+ name: "Animal"
14090
+ },
14091
+ {
14092
+ id: "package",
14093
+ name: "Package"
14094
+ }
14095
+ ].map((l) => l.id));
14036
14096
  var COCO_TO_MACRO = {
14037
14097
  mapping: {
14038
14098
  person: "person",
@@ -14830,6 +14890,8 @@ var NcSystemEventKindSchema = _enum([
14830
14890
  "alarm-triggered",
14831
14891
  "alarm-armed",
14832
14892
  "alarm-disarmed",
14893
+ "alarm-arming",
14894
+ "alarm-arm-refused",
14833
14895
  "camera-online",
14834
14896
  "camera-offline",
14835
14897
  "camera-disabled",
@@ -14866,6 +14928,9 @@ var NcSystemEventConditionSchema = object({
14866
14928
  nodeIds: array(string().min(1)).min(1).optional(),
14867
14929
  packageNames: array(string().min(1)).min(1).optional()
14868
14930
  });
14931
+ /** Hard ceiling on a window (24h). A snooze that could not expire would be an
14932
+ * outage the operator asked for once and forgot. */
14933
+ var NC_SNOOZE_MAX_MINUTES = 1440;
14869
14934
  /** Weekly schedule — OR of windows; absence on the rule = always active. */
14870
14935
  var NcScheduleSchema = object({
14871
14936
  windows: array(object({
@@ -15242,15 +15307,15 @@ var NcConditionsSchema = object({
15242
15307
  * (an `immediate` rule naming an `audio-*` class, one notification per
15243
15308
  * classified sample) stays exactly as it was for rules that already use it.
15244
15309
  *
15245
- * NOT in {@link NC_CONDITION_CATALOG} yet, and that is the sequencing rule
15246
- * rather than an oversight: the viewer mirrors the descriptor enums BY HAND
15247
- * (`camstack/src/data/notification-center.ts`, guarded by
15248
- * `scripts/check-viewer-condition-mirror.ts`) and its rule editor STRIPS the
15310
+ * In {@link NC_CONDITION_CATALOG} since P2, and the ORDER it got there is the
15311
+ * rule rather than an accident: the viewer mirrors the descriptor enums BY
15312
+ * HAND (`camstack/src/data/notification-center.ts`, guarded by
15313
+ * `scripts/check-viewer-condition-mirror.ts`) and its rule editor strips the
15249
15314
  * condition fields it does not know when a rule is saved from the phone.
15250
15315
  * Publishing an editor for a condition the app cannot round-trip is how an
15251
- * operator loses a rule's conditions by opening it — so the descriptor, the
15252
- * admin widget and the viewer mirror land together (P2 + P3), and only then
15253
- * does an audio rule become authorable.
15316
+ * operator loses a rule's conditions by opening it — so the viewer mirror
15317
+ * (P3, shipped) went FIRST, and the descriptor an editor renders from
15318
+ * follows here.
15254
15319
  */
15255
15320
  audio: NcAudioConditionSchema.optional()
15256
15321
  });
@@ -15486,6 +15551,30 @@ var NcRuleInputSchema = object({
15486
15551
  */
15487
15552
  snoozeAllowGlobal: boolean().optional(),
15488
15553
  /**
15554
+ * The snooze durations THIS rule's notification offers as buttons, in
15555
+ * minutes.
15556
+ *
15557
+ * Three states, and all three are distinct — which is exactly why this is
15558
+ * `.optional()` and never `.default()`. A Zod default does not run on the
15559
+ * addon cap path (three production failures in one day), so a schema default
15560
+ * would collapse the first two:
15561
+ *
15562
+ * | value | meaning |
15563
+ * | --- | --- |
15564
+ * | absent | the operator never said ⇒ {@link NC_DEFAULT_SNOOZE_MINUTES} |
15565
+ * | `[]` | **no snooze buttons on this rule** — the explicit override |
15566
+ * | a list | these choices, de-duplicated and sorted, at most four |
15567
+ *
15568
+ * `.max(4)` because the notifier's own action budget is small (ntfy allows
15569
+ * three buttons in total) and a rule that spent it all on snooze choices
15570
+ * would push its own tap-through actions off the notification.
15571
+ *
15572
+ * An empty list is NOT an alarm exemption: a rule the alarm is about, or
15573
+ * that arms the panel, is exempt automatically and cannot be silenced by a
15574
+ * window from anywhere (D133).
15575
+ */
15576
+ snoozeOptions: array(number().int().min(1).max(NC_SNOOZE_MAX_MINUTES)).max(4).optional(),
15577
+ /**
15489
15578
  * Devices this rule ACTUATES — arm the alarm, open a gate, turn on a light.
15490
15579
  *
15491
15580
  * This is what makes the rule set the alarm's trigger set without the alarm
@@ -15585,6 +15674,7 @@ var NcConditionDescriptorSchema = object({
15585
15674
  "device",
15586
15675
  "package",
15587
15676
  "occupancy",
15677
+ "audio",
15588
15678
  "system"
15589
15679
  ]),
15590
15680
  label: string(),
@@ -15603,6 +15693,7 @@ var NcConditionDescriptorSchema = object({
15603
15693
  "crossingSelect",
15604
15694
  "polygonDraw",
15605
15695
  "occupancy",
15696
+ "audio",
15606
15697
  "deviceState",
15607
15698
  "systemEvent"
15608
15699
  ]),
@@ -15754,7 +15845,20 @@ var NcSnoozeInputSchema = object({
15754
15845
  ruleId: string().optional(),
15755
15846
  /** Required when `scope: 'device'`. */
15756
15847
  deviceId: number().int().optional(),
15757
- durationMinutes: number().int().min(1).max(1440),
15848
+ /**
15849
+ * Narrow the window to these subject classes — "the cat, not the person".
15850
+ *
15851
+ * ORTHOGONAL to `scope`, deliberately, and absent means EVERY class: that is
15852
+ * what every window authored before this field meant, so no persisted row
15853
+ * changes meaning and no client has to learn anything to keep working.
15854
+ *
15855
+ * It is what makes the window's real key `(deviceId, classes[])` and lets it
15856
+ * cross rules (D133): the operator points at a camera and a kind of thing,
15857
+ * not at whichever of their four rules happened to produce the notification
15858
+ * they are dismissing.
15859
+ */
15860
+ classes: array(string().min(1)).min(1).optional(),
15861
+ durationMinutes: number().int().min(1).max(NC_SNOOZE_MAX_MINUTES),
15758
15862
  /**
15759
15863
  * Silence this for EVERY recipient, not just the caller. Permission is
15760
15864
  * checked server-side (the rule's `snoozeAllowGlobal`, or admin for the
@@ -15779,6 +15883,10 @@ var NcSnoozeSchema = object({
15779
15883
  scope: NcSnoozeScopeSchema,
15780
15884
  ruleId: string().optional(),
15781
15885
  deviceId: number().int().optional(),
15886
+ /** Subject classes this window covers. ABSENT = every class — see
15887
+ * {@link NcSnoozeInputSchema.shape.classes}. Lives in the JSON blob and has
15888
+ * no SQLite column: nothing queries a window by class. */
15889
+ classes: array(string().min(1)).min(1).optional(),
15782
15890
  startedAt: number(),
15783
15891
  /** Exclusive: at exactly this instant the snooze is over. Expiry is a
15784
15892
  * COMPARISON, not a job — no sweeper can leave the operator silenced. */
@@ -18031,7 +18139,14 @@ var zonesCapability = {
18031
18139
  * handle. Slice shape is `{ zones: Zone[] }` so future extensions
18032
18140
  * (e.g. zone groupings) can sit alongside the polygon list.
18033
18141
  */
18034
- runtimeState: object({ zones: array(ZoneSchema).readonly() })
18142
+ runtimeState: object({ zones: array(ZoneSchema).readonly() }),
18143
+ /**
18144
+ * Runtime-state durability: **restored** — written only on operator mutation, so a camera that never had one has nothing to re-derive from. This is the slice `zone-mirror-hydration.ts` exists to paper over.
18145
+ *
18146
+ * See `RuntimeStateDurability`. Enforced by
18147
+ * `scripts/check-runtime-state-durability.ts`.
18148
+ */
18149
+ durability: "restored"
18035
18150
  };
18036
18151
  /**
18037
18152
  * A bounding box in NORMALIZED [0,1] frame coordinates for `getNativeCrop`. The
@@ -18201,6 +18316,22 @@ var detectionFpsField = {
18201
18316
  default: 10,
18202
18317
  step: 1
18203
18318
  };
18319
+ /**
18320
+ * The occupancy re-check interval. DEFAULT 300 s (2026-08-13 — was 30 s).
18321
+ *
18322
+ * The recheck is now on by default (a parked car is invisible to occupancy
18323
+ * rules until the stationary registry has been rebuilt by motion, which after a
18324
+ * restart may be never on a quiet camera). Each cycle re-subscribes a detection
18325
+ * session — an RTSP re-dial — so the switch is only affordable at a WIDE
18326
+ * interval: 300 s is ~12 re-dials an hour per camera, against 120 at the old
18327
+ * 30 s. A parked car is therefore counted within 5 minutes of a restart.
18328
+ *
18329
+ * Why not wider: `max` is 300 and raising it is TRAIN-BOUND, not addon-bound —
18330
+ * the host validates `attachCamera` against ITS copy of this schema, so a
18331
+ * runner asked for 600 would be rejected by the hub until a `@camstack/server`
18332
+ * carrying the wider bound is installed everywhere. 300 is the widest value
18333
+ * that ships with an addon deploy.
18334
+ */
18204
18335
  var occupancyRecheckSecField = {
18205
18336
  min: 0,
18206
18337
  max: 300,
@@ -18402,15 +18533,21 @@ var RunnerCameraConfigSchema = object({
18402
18533
  */
18403
18534
  onboardMotionDrivesAnalyzer: boolean().default(true),
18404
18535
  /**
18405
- * Master toggle for the occupancy re-check. When `false` (DEFAULT) the runner
18406
- * never arms the periodic recheck timer, regardless of `occupancyRecheckSec`
18407
- * this is off by default because the recheck re-subscribes a detection session
18408
- * every N seconds while `watching`, a major source of pull-decoder re-dial
18409
- * churn (each cycle creates+tears a session → RTSP re-dial → latency). The
18536
+ * Master toggle for the occupancy re-check. When `false` the runner never arms
18537
+ * the periodic recheck timer, regardless of `occupancyRecheckSec`; the
18410
18538
  * `occupancyRecheckSec` / `occupancyRecheckFrames` sliders only take effect
18411
18539
  * (and only render) when this is enabled.
18412
- */
18413
- occupancyRecheckEnabled: boolean().default(false),
18540
+ *
18541
+ * DEFAULT `true` since 2026-08-13 (was `false`). It was off because the
18542
+ * recheck re-subscribes a detection session every N seconds while `watching`
18543
+ * — each cycle creates+tears a session ⇒ an RTSP re-dial ⇒ latency, a major
18544
+ * pull-decoder churn source. What that bought was a blind spot: a STATIONARY
18545
+ * object is counted only while the stationary registry holds it, and the
18546
+ * registry rebuilds from motion, so after a restart a parked car was invisible
18547
+ * to every occupancy rule until something moved in front of it. The churn is
18548
+ * now paid on the interval instead — see `occupancyRecheckSecField`.
18549
+ */
18550
+ occupancyRecheckEnabled: boolean().default(true),
18414
18551
  occupancyRecheckSec: number().min(occupancyRecheckSecField.min).max(occupancyRecheckSecField.max).default(occupancyRecheckSecField.default),
18415
18552
  occupancyRecheckFrames: number().min(occupancyRecheckFramesField.min).max(occupancyRecheckFramesField.max).default(occupancyRecheckFramesField.default),
18416
18553
  /**
@@ -18543,8 +18680,8 @@ var RunnerCameraDeviceUIFields = [
18543
18680
  type: "boolean",
18544
18681
  style: "checkbox",
18545
18682
  label: "Occupancy re-check",
18546
- description: "Periodically re-sample a few frames during the watching phase to confirm the scene is truly empty (catches stationary objects motion-gating would miss). Off by default it adds decoder re-dial churn.",
18547
- default: false
18683
+ description: "Periodically re-sample a few frames during the watching phase, so objects that are simply parked are still counted (motion gating alone loses them after a restart). On by default at a wide interval; turn it off on cameras where the extra decoder re-dial is expensive.",
18684
+ default: true
18548
18685
  },
18549
18686
  {
18550
18687
  key: "occupancyRecheckSec",
@@ -26385,7 +26522,14 @@ var zoneRulesCapability = {
26385
26522
  motion: array(ZoneRuleSchema).readonly(),
26386
26523
  detection: array(ZoneRuleSchema).readonly(),
26387
26524
  package: array(ZoneRuleSchema).readonly()
26388
- })
26525
+ }),
26526
+ /**
26527
+ * Runtime-state durability: **restored** — operator intent, mutation-only, same argument as `zones`.
26528
+ *
26529
+ * See `RuntimeStateDurability`. Enforced by
26530
+ * `scripts/check-runtime-state-durability.ts`.
26531
+ */
26532
+ durability: "restored"
26389
26533
  };
26390
26534
  /**
26391
26535
  * Accessory device helpers — shared across drivers.
@@ -32066,6 +32210,7 @@ Object.freeze({
32066
32210
  "network-access": "ingress",
32067
32211
  "smtp-provider": "email"
32068
32212
  });
32213
+ new Map(AUDIO_MACRO_LABELS.flatMap((macro) => macro.icon === void 0 ? [] : [[macro.id, macro.icon]]));
32069
32214
  new Set(["devices", "classes"]);
32070
32215
  /**
32071
32216
  * TimelapseRule — the STANDALONE scheduled timelapse producer's rule model.
@@ -32885,7 +33030,55 @@ var OrchestratorDiagnosticsSchema = object({
32885
33030
  cameraConfigCount: number().int().min(0),
32886
33031
  activeDetectionCount: number().int().min(0)
32887
33032
  });
32888
- var pipelineOrchestratorActions = defineCustomActions({ dumpState: customAction(_void(), OrchestratorDiagnosticsSchema) });
33033
+ /**
33034
+ * The node-stress long-term-statistics read surface.
33035
+ *
33036
+ * A custom action rather than a cap method, matching how the orchestrator
33037
+ * already serves `dumpState`: this is a hub-local read over a table the hub
33038
+ * owns, and it ships with one `camstack deploy` instead of a release train.
33039
+ * The MEAN is derived here and returned alongside the addable `sum`/`samples`
33040
+ * — a chart wants the first, a re-bucketing caller wants the second, and a
33041
+ * stored mean is a field that can disagree with both.
33042
+ */
33043
+ var NodeStressStatsInputSchema = object({
33044
+ /** `node-score` | `node-queue-pressure` | `node-drop-ratio` | `node-fps-deficit`. */
33045
+ series: string().optional(),
33046
+ /** A node id. Omit for every node. */
33047
+ subject: string().optional(),
33048
+ /** Inclusive bucket-start bounds, ms. */
33049
+ from: number().int().optional(),
33050
+ to: number().int().optional(),
33051
+ limit: number().int().positive().max(5e3).optional()
33052
+ });
33053
+ var NodeStressStatsRowSchema = object({
33054
+ subject: string(),
33055
+ series: string(),
33056
+ scope: string(),
33057
+ bucketStart: number(),
33058
+ samples: number(),
33059
+ sum: number(),
33060
+ mean: number(),
33061
+ min: number(),
33062
+ max: number()
33063
+ });
33064
+ var NodeStressStatsOutputSchema = object({
33065
+ rows: array(NodeStressStatsRowSchema).readonly(),
33066
+ /** Buckets still accumulating — "is it running" answerable at once, rather
33067
+ * than after five minutes of indistinguishable silence. */
33068
+ open: array(NodeStressStatsRowSchema).readonly(),
33069
+ /** The durable failover history the anti-flap guards read, newest first.
33070
+ * Exposed for the same reason the heartbeat exists: "nothing moved" has to
33071
+ * be distinguishable from "nothing is watching". */
33072
+ moves: array(object({
33073
+ deviceId: number(),
33074
+ fromNodeId: string(),
33075
+ at: number()
33076
+ })).readonly()
33077
+ });
33078
+ var pipelineOrchestratorActions = defineCustomActions({
33079
+ dumpState: customAction(_void(), OrchestratorDiagnosticsSchema),
33080
+ nodeStressStats: customAction(NodeStressStatsInputSchema, NodeStressStatsOutputSchema, { auth: "admin" })
33081
+ });
32889
33082
  /**
32890
33083
  * Sentinel returned by `buildDetectionConfig` when the profile-slot READ
32891
33084
  * itself failed transiently (e.g. `listAllProfileSlots` discovery timeout
@@ -35471,7 +35664,7 @@ function resolveDetectionSettings(input) {
35471
35664
  const userOnboardMotionDrivesAnalyzer = raw["onboardMotionDrivesAnalyzer"];
35472
35665
  const onboardMotionDrivesAnalyzer = typeof userOnboardMotionDrivesAnalyzer === "boolean" ? userOnboardMotionDrivesAnalyzer : true;
35473
35666
  const userOccupancyRecheckEnabled = raw["occupancyRecheckEnabled"];
35474
- const occupancyRecheckEnabled = typeof userOccupancyRecheckEnabled === "boolean" ? userOccupancyRecheckEnabled : false;
35667
+ const occupancyRecheckEnabled = typeof userOccupancyRecheckEnabled === "boolean" ? userOccupancyRecheckEnabled : true;
35475
35668
  const occupancyRecheckSec = mustNumber(flat, deviceId, "occupancyRecheckSec");
35476
35669
  const occupancyRecheckFrames = mustNumber(flat, deviceId, "occupancyRecheckFrames");
35477
35670
  return {
@@ -35605,12 +35798,341 @@ function buildRunnerConfig(base, overrides) {
35605
35798
  };
35606
35799
  }
35607
35800
  //#endregion
35608
- //#region src/device-features-mirror.ts
35801
+ //#region src/durable/durable-ledger.ts
35802
+ /** Default reseed cap — every current consumer's row set is installation-bounded. */
35803
+ var DEFAULT_LOAD_LIMIT = 1e5;
35804
+ var DurableLedger = class DurableLedger {
35805
+ mirror = /* @__PURE__ */ new Map();
35806
+ spec;
35807
+ store;
35808
+ logger;
35809
+ constructor(deps) {
35810
+ this.spec = deps.spec;
35811
+ this.store = deps.store;
35812
+ this.logger = deps.logger;
35813
+ }
35814
+ /**
35815
+ * Register the collection. MUST run at boot, before any read or write: the
35816
+ * SQLite backend answers 412 for an undeclared collection and takes the whole
35817
+ * runner down with it (the addon-ai boot-crash lesson).
35818
+ */
35819
+ static declare(store, spec) {
35820
+ return store.declareCollection.mutate({
35821
+ collection: spec.collection,
35822
+ columns: [...spec.columns],
35823
+ ...spec.indexes !== void 0 ? { indexes: [...spec.indexes] } : {}
35824
+ });
35825
+ }
35826
+ declare() {
35827
+ return DurableLedger.declare(this.store, this.spec);
35828
+ }
35829
+ /** The collection this ledger owns — for the caller's own log lines. */
35830
+ get collection() {
35831
+ return this.spec.collection;
35832
+ }
35833
+ /**
35834
+ * Boot reseed. Replaces the mirror with what the store holds and returns the
35835
+ * rows, so a caller that must hydrate something else (a watcher, a registry)
35836
+ * gets them without a second read.
35837
+ *
35838
+ * **A failure returns what is already mirrored** rather than clearing it —
35839
+ * see contract rule 2. The count is worth logging out loud at the call site:
35840
+ * "loaded 0" after a container recreate is the one line that explains a
35841
+ * silent flood.
35842
+ */
35843
+ async load() {
35844
+ try {
35845
+ const records = await this.store.query.query({
35846
+ collection: this.spec.collection,
35847
+ filter: { limit: this.spec.loadLimit ?? DEFAULT_LOAD_LIMIT }
35848
+ });
35849
+ const next = /* @__PURE__ */ new Map();
35850
+ let skipped = 0;
35851
+ for (const record of records) {
35852
+ const row = this.spec.fromRecord(record.id, record.data);
35853
+ if (row === null) {
35854
+ skipped += 1;
35855
+ continue;
35856
+ }
35857
+ next.set(this.spec.keyOf(row), row);
35858
+ }
35859
+ this.mirror.clear();
35860
+ for (const [key, row] of next) this.mirror.set(key, row);
35861
+ if (skipped > 0) this.logger.warn("durable rows skipped as malformed — they gate NOTHING", { meta: {
35862
+ collection: this.spec.collection,
35863
+ skipped
35864
+ } });
35865
+ return [...this.mirror.values()];
35866
+ } catch (err) {
35867
+ this.logger.warn("durable load failed — keeping the state already in memory", { meta: {
35868
+ collection: this.spec.collection,
35869
+ error: String(err),
35870
+ held: this.mirror.size
35871
+ } });
35872
+ return [...this.mirror.values()];
35873
+ }
35874
+ }
35875
+ /** Every mirrored row, insertion-ordered. */
35876
+ snapshot() {
35877
+ return [...this.mirror.values()];
35878
+ }
35879
+ /** The row currently held for a key, if any. Pure RAM — never I/O. */
35880
+ get(key) {
35881
+ return this.mirror.get(key);
35882
+ }
35883
+ has(key) {
35884
+ return this.mirror.has(key);
35885
+ }
35886
+ get size() {
35887
+ return this.mirror.size;
35888
+ }
35889
+ /**
35890
+ * Judge ONE observation against what the ledger already accepted, and advance
35891
+ * it.
35892
+ *
35893
+ * Synchronous on purpose: the verdict is a function of the in-RAM mirror
35894
+ * alone, so a decision can never be gated on an I/O that might fail (D49).
35895
+ * The durable write is kicked off behind it and its failure changes no
35896
+ * verdict.
35897
+ *
35898
+ * `no-flip` leaves the held row UNTOUCHED — including any timestamp it
35899
+ * carries, which therefore means "when this key last CHANGED", not "when it
35900
+ * last spoke". That is the timestamp anyone reading the table wants.
35901
+ */
35902
+ observe(row) {
35903
+ const equalFact = this.spec.equalFact;
35904
+ if (equalFact === void 0) throw new Error(`DurableLedger(${this.spec.collection}): observe() requires the spec to declare equalFact`);
35905
+ const key = this.spec.keyOf(row);
35906
+ const held = this.mirror.get(key);
35907
+ if (held !== void 0 && equalFact(held, row)) return "no-flip";
35908
+ this.mirror.set(key, row);
35909
+ this.persist(row);
35910
+ return held === void 0 ? "seeded" : "flip";
35911
+ }
35912
+ /**
35913
+ * Upsert a row without a verdict — the write path for a ledger whose owner
35914
+ * has already decided the value changed.
35915
+ *
35916
+ * The order of the mirror advance and the durable write is the spec's
35917
+ * {@link DurableWriteMode}, not the call site's: two call sites that
35918
+ * disagreed about it would be two different durability guarantees on one
35919
+ * collection.
35920
+ */
35921
+ async put(row) {
35922
+ const key = this.spec.keyOf(row);
35923
+ if (this.spec.writeMode === "write-behind") {
35924
+ this.mirror.set(key, row);
35925
+ await this.persist(row);
35926
+ return;
35927
+ }
35928
+ await this.store.set.mutate({
35929
+ collection: this.spec.collection,
35930
+ key,
35931
+ value: this.spec.toValue(row)
35932
+ });
35933
+ this.mirror.set(key, row);
35934
+ }
35935
+ /**
35936
+ * Advance the mirror WITHOUT persisting, for an owner that deliberately
35937
+ * coalesces its writes.
35938
+ *
35939
+ * The stationary registry is the reason this exists: a parked car is
35940
+ * re-confirmed on every processed frame (5–30 Hz), and persisting each
35941
+ * confirmation would offer thousands of commits a day to the checkpoint
35942
+ * lottery (D96) to maintain a handful of rows. It stages the advance and
35943
+ * flushes on its 5-minute sweep — 288 writes a day instead of ~10⁶.
35944
+ *
35945
+ * **The cost is stated, not hidden**: a staged value that is never flushed
35946
+ * is lost on a crash. An owner may only stage a field whose staleness its
35947
+ * own TTL absorbs. Anything that GATES work must go through {@link put} or
35948
+ * {@link observe}.
35949
+ */
35950
+ stage(row) {
35951
+ this.mirror.set(this.spec.keyOf(row), row);
35952
+ }
35953
+ /**
35954
+ * Drop a key from the MIRROR only — the durable row survives.
35955
+ *
35956
+ * What a scope-unbind needs: this process stops holding the value, and a
35957
+ * rebind reloads it from the store. Deliberately distinct from
35958
+ * {@link forget}, which deletes; conflating the two is how an unbind turns
35959
+ * into a wipe.
35960
+ */
35961
+ evict(key) {
35962
+ this.mirror.delete(key);
35963
+ }
35964
+ /**
35965
+ * Drop one key, mirror and row. Best-effort on the durable half: a failed
35966
+ * delete leaves a row that the next load will re-mirror, which is a stale
35967
+ * value rather than a lost one.
35968
+ */
35969
+ async forget(key) {
35970
+ this.mirror.delete(key);
35971
+ try {
35972
+ await this.store.delete.mutate({
35973
+ collection: this.spec.collection,
35974
+ key
35975
+ });
35976
+ } catch (err) {
35977
+ this.logger.debug("durable delete failed", { meta: {
35978
+ collection: this.spec.collection,
35979
+ key,
35980
+ error: String(err)
35981
+ } });
35982
+ }
35983
+ }
35984
+ /**
35985
+ * Drop every mirrored key NOT in `activeKeys`. Returns how many rows went.
35986
+ *
35987
+ * **The caller must hold an AUTHORITATIVE active set.** A prune driven by a
35988
+ * fallible read is work destroyed on an error (D49/D130) — that is why this
35989
+ * is a method a feature opts into rather than a policy the primitive runs.
35990
+ * Best-effort per row: a failed delete keeps the key (retried next prune)
35991
+ * rather than aborting the sweep.
35992
+ */
35993
+ async pruneExcept(activeKeys) {
35994
+ let pruned = 0;
35995
+ for (const key of [...this.mirror.keys()]) {
35996
+ if (activeKeys.has(key)) continue;
35997
+ try {
35998
+ await this.store.delete.mutate({
35999
+ collection: this.spec.collection,
36000
+ key
36001
+ });
36002
+ this.mirror.delete(key);
36003
+ pruned += 1;
36004
+ } catch (err) {
36005
+ this.logger.debug("durable prune delete failed", { meta: {
36006
+ collection: this.spec.collection,
36007
+ key,
36008
+ error: String(err)
36009
+ } });
36010
+ }
36011
+ }
36012
+ return pruned;
36013
+ }
36014
+ /**
36015
+ * Write-behind durable upsert. Best-effort and logged, never thrown at the
36016
+ * decision path: the mirror already holds the truth for this process, and the
36017
+ * worst a lost write can do is one silent re-seed after the next restart.
36018
+ */
36019
+ async persist(row) {
36020
+ const deviceId = this.spec.deviceIdOf?.(row);
36021
+ try {
36022
+ await this.store.set.mutate({
36023
+ collection: this.spec.collection,
36024
+ key: this.spec.keyOf(row),
36025
+ value: this.spec.toValue(row)
36026
+ });
36027
+ } catch (err) {
36028
+ this.logger.warn("durable persist failed — this key may re-seed on boot", {
36029
+ ...deviceId !== void 0 ? { tags: { deviceId } } : {},
36030
+ meta: {
36031
+ collection: this.spec.collection,
36032
+ key: this.spec.keyOf(row),
36033
+ error: String(err)
36034
+ }
36035
+ });
36036
+ }
36037
+ }
36038
+ };
36039
+ var DEVICE_FEATURES_SPEC = {
36040
+ collection: "pipeline-orchestrator:device-features",
36041
+ columns: [
36042
+ {
36043
+ name: "deviceId",
36044
+ type: "TEXT",
36045
+ primaryKey: true,
36046
+ notNull: true
36047
+ },
36048
+ (
36049
+ /** The feature name list, verbatim. */
36050
+ {
36051
+ name: "features",
36052
+ type: "JSON",
36053
+ notNull: true
36054
+ }),
36055
+ {
36056
+ name: "updatedAt",
36057
+ type: "INTEGER",
36058
+ notNull: true
36059
+ }
36060
+ ],
36061
+ writeMode: "write-behind",
36062
+ keyOf: (row) => String(row.deviceId),
36063
+ toValue: (row) => ({
36064
+ features: [...row.features],
36065
+ updatedAt: row.updatedAt
36066
+ }),
36067
+ fromRecord: (key, data) => {
36068
+ const deviceId = Number(key);
36069
+ const raw = data["features"];
36070
+ if (!Number.isFinite(deviceId) || !Array.isArray(raw)) return null;
36071
+ const features = raw.filter((f) => typeof f === "string");
36072
+ if (features.length === 0) return null;
36073
+ const updatedAt = Number(data["updatedAt"]);
36074
+ return {
36075
+ deviceId,
36076
+ features,
36077
+ updatedAt: Number.isFinite(updatedAt) ? updatedAt : 0,
36078
+ restored: true
36079
+ };
36080
+ },
36081
+ deviceIdOf: (row) => row.deviceId
36082
+ };
35609
36083
  var DeviceFeaturesMirror = class {
36084
+ /** Process-local fallback, used only when no store was supplied. */
36085
+ local = /* @__PURE__ */ new Map();
36086
+ durable;
35610
36087
  logger;
35611
- lastKnown = /* @__PURE__ */ new Map();
35612
- constructor(logger) {
35613
- this.logger = logger;
36088
+ now;
36089
+ constructor(deps) {
36090
+ this.logger = deps.logger;
36091
+ this.now = deps.now ?? (() => Date.now());
36092
+ this.durable = deps.store === void 0 ? null : new DurableLedger({
36093
+ spec: DEVICE_FEATURES_SPEC,
36094
+ store: deps.store,
36095
+ logger: deps.logger
36096
+ });
36097
+ }
36098
+ static declare(store) {
36099
+ return DurableLedger.declare(store, DEVICE_FEATURES_SPEC);
36100
+ }
36101
+ /**
36102
+ * Seed the mirror from the last session. Call once at boot, after `declare`
36103
+ * and BEFORE the first `resolve` — the whole value of the row is that it is
36104
+ * already there when the first read fails.
36105
+ */
36106
+ async hydrate() {
36107
+ if (this.durable === null) return 0;
36108
+ const rows = await this.durable.load();
36109
+ this.logger.info("device-features mirror restored", { meta: { devices: rows.length } });
36110
+ return rows.length;
36111
+ }
36112
+ held(deviceId) {
36113
+ return this.durable === null ? this.local.get(deviceId) : this.durable.get(String(deviceId));
36114
+ }
36115
+ remember(deviceId, features) {
36116
+ const row = {
36117
+ deviceId,
36118
+ features: [...features],
36119
+ updatedAt: this.now(),
36120
+ restored: false
36121
+ };
36122
+ if (this.durable === null) {
36123
+ this.local.set(deviceId, row);
36124
+ return;
36125
+ }
36126
+ const held = this.durable.get(String(deviceId));
36127
+ if (held !== void 0 && !held.restored && sameFeatures(held.features, features)) return;
36128
+ this.durable.put(row);
36129
+ }
36130
+ drop(deviceId) {
36131
+ if (this.durable === null) {
36132
+ this.local.delete(deviceId);
36133
+ return;
36134
+ }
36135
+ this.durable.forget(String(deviceId));
35614
36136
  }
35615
36137
  /**
35616
36138
  * Resolve a device's features, preferring a fresh read but never letting a
@@ -35619,15 +36141,19 @@ var DeviceFeaturesMirror = class {
35619
36141
  async resolve(deviceId, read) {
35620
36142
  const first = await read();
35621
36143
  if (first !== null && first.length > 0) {
35622
- this.lastKnown.set(deviceId, [...first]);
36144
+ this.remember(deviceId, first);
35623
36145
  return first;
35624
36146
  }
35625
- const mirrored = this.lastKnown.get(deviceId);
36147
+ const heldRow = this.held(deviceId);
36148
+ const mirrored = heldRow?.features;
35626
36149
  if (first === null) {
35627
36150
  if (mirrored !== void 0) {
35628
36151
  this.logger.warn("device features unavailable — serving last-known mirror", {
35629
36152
  tags: { deviceId },
35630
- meta: { mirrored: mirrored.length }
36153
+ meta: {
36154
+ mirrored: mirrored.length,
36155
+ restored: heldRow?.restored === true
36156
+ }
35631
36157
  });
35632
36158
  return mirrored;
35633
36159
  }
@@ -35641,7 +36167,7 @@ var DeviceFeaturesMirror = class {
35641
36167
  tags: { deviceId },
35642
36168
  meta: { features: second.length }
35643
36169
  });
35644
- this.lastKnown.set(deviceId, [...second]);
36170
+ this.remember(deviceId, second);
35645
36171
  return second;
35646
36172
  }
35647
36173
  if (second === null) {
@@ -35652,14 +36178,20 @@ var DeviceFeaturesMirror = class {
35652
36178
  tags: { deviceId },
35653
36179
  meta: { previously: mirrored.length }
35654
36180
  });
35655
- this.lastKnown.delete(deviceId);
36181
+ this.drop(deviceId);
35656
36182
  return [];
35657
36183
  }
35658
36184
  /** Drop a device's mirror — call when the device is removed. */
35659
36185
  forget(deviceId) {
35660
- this.lastKnown.delete(deviceId);
36186
+ this.drop(deviceId);
35661
36187
  }
35662
36188
  };
36189
+ /** Order-insensitive feature-set equality — the read's order is not a fact. */
36190
+ function sameFeatures(a, b) {
36191
+ if (a.length !== b.length) return false;
36192
+ const held = new Set(a);
36193
+ return b.every((f) => held.has(f));
36194
+ }
35663
36195
  //#endregion
35664
36196
  //#region src/watchdog-camera.ts
35665
36197
  /**
@@ -35726,7 +36258,20 @@ var DetectionWiringController = class {
35726
36258
  featuresMirror;
35727
36259
  constructor(deps) {
35728
36260
  this.deps = deps;
35729
- this.featuresMirror = new DeviceFeaturesMirror(deps.logger);
36261
+ this.featuresMirror = new DeviceFeaturesMirror({
36262
+ logger: deps.logger,
36263
+ ...deps.featuresStore !== void 0 ? { store: deps.featuresStore } : {}
36264
+ });
36265
+ }
36266
+ /**
36267
+ * Declare + seed the device-features mirror. Call once at boot, BEFORE the
36268
+ * first detection start — the restored row is worth nothing after the read
36269
+ * that would have needed it.
36270
+ */
36271
+ async hydrateFeaturesMirror() {
36272
+ if (this.deps.featuresStore === void 0) return;
36273
+ await DeviceFeaturesMirror.declare(this.deps.featuresStore);
36274
+ await this.featuresMirror.hydrate();
35730
36275
  }
35731
36276
  /** `activeDetections.get(deviceId)`. */
35732
36277
  getActiveDetectionConfig(deviceId) {
@@ -36937,7 +37482,6 @@ var NodeStressController = class NodeStressController {
36937
37482
  static HEARTBEAT_MS = 60 * 6e4;
36938
37483
  samples = /* @__PURE__ */ new Map();
36939
37484
  memories = /* @__PURE__ */ new Map();
36940
- history = [];
36941
37485
  /** When the last heartbeat went out. `null` ⇒ the next sweep emits one. */
36942
37486
  lastHeartbeatAt = null;
36943
37487
  timer = null;
@@ -36981,6 +37525,18 @@ var NodeStressController = class NodeStressController {
36981
37525
  forgetDevice(deviceId) {
36982
37526
  this.samples.delete(deviceId);
36983
37527
  }
37528
+ /**
37529
+ * Recover the move history from disk BEFORE the first sweep can act.
37530
+ *
37531
+ * Best-effort by construction (the ledger keeps whatever it already has on a
37532
+ * read failure), and the count is logged out loud: a restart that recovers 0
37533
+ * moves while the operator remembers three is the line that says the budget
37534
+ * has been reset.
37535
+ */
37536
+ async hydrate() {
37537
+ const recovered = await this.deps.moves.load();
37538
+ this.deps.logger.info("node-stress move history recovered", { meta: { moves: recovered } });
37539
+ }
36984
37540
  start() {
36985
37541
  if (this.timer) return;
36986
37542
  this.timer = setInterval(() => {
@@ -36996,12 +37552,24 @@ var NodeStressController = class NodeStressController {
36996
37552
  }
36997
37553
  this.samples.clear();
36998
37554
  this.memories.clear();
36999
- this.history = [];
37000
37555
  }
37001
37556
  /** The last computed state per node — empty before the first sweep. */
37002
37557
  statesView() {
37003
37558
  return new Map([...this.memories].map(([nodeId, m]) => [nodeId, m.state]));
37004
37559
  }
37560
+ /** The durable move history, newest first — the guards' own evidence, so an
37561
+ * operator asking "why did nothing move" can read the budget. */
37562
+ historyView() {
37563
+ return this.deps.moves.entries().toSorted((a, b) => b.at - a.at);
37564
+ }
37565
+ /**
37566
+ * The per-node stress signals as of `now`, for the LTS aggregator and for
37567
+ * diagnostics. Computed from the same live samples the sweep uses, so a
37568
+ * chart can never disagree with a verdict.
37569
+ */
37570
+ signalsView(now) {
37571
+ return new Map(this.buildInputs(now).map((input) => [input.nodeId, input.signals]));
37572
+ }
37005
37573
  /** Group live samples by node and reduce each group. Exposed for tests. */
37006
37574
  buildInputs(now) {
37007
37575
  const byNode = /* @__PURE__ */ new Map();
@@ -37032,6 +37600,7 @@ var NodeStressController = class NodeStressController {
37032
37600
  const verdicts = evaluateNodeStress(this.memories, this.buildInputs(now), now, this.thresholds());
37033
37601
  for (const v of verdicts) {
37034
37602
  this.memories.set(v.nodeId, v.memory);
37603
+ this.deps.lts?.noteSignals(v.nodeId, v.signals, now);
37035
37604
  if (v.changed) this.logTransition(v);
37036
37605
  }
37037
37606
  this.maybeHeartbeat(mode, verdicts, now);
@@ -37090,11 +37659,12 @@ var NodeStressController = class NodeStressController {
37090
37659
  async actOn(verdicts, now) {
37091
37660
  if (this.moveInFlight) return;
37092
37661
  if (!verdicts.some((v) => v.state === "saturated")) return;
37093
- this.history = this.history.filter((h) => now - h.at < NodeStressController.HISTORY_TTL_MS);
37662
+ this.deps.moves.pruneOlderThan(now - NodeStressController.HISTORY_TTL_MS);
37663
+ const history = this.deps.moves.entries().filter((h) => now - h.at < NodeStressController.HISTORY_TTL_MS);
37094
37664
  const plan = planStressFailover({
37095
37665
  verdicts,
37096
37666
  candidates: await this.collectCandidates(now),
37097
- history: this.history,
37667
+ history,
37098
37668
  nodeCaps: await this.deps.settingsStore.buildNodeCaps(),
37099
37669
  attachedByNode: this.attachedByNode()
37100
37670
  }, now, this.guards());
@@ -37118,7 +37688,7 @@ var NodeStressController = class NodeStressController {
37118
37688
  await this.deps.detach(plan.fromNodeId, plan.deviceId);
37119
37689
  await this.deps.attach(plan.toNodeId, config);
37120
37690
  this.deps.ledger.recordAssignment(plan.deviceId, plan.toNodeId, "rebalance", false);
37121
- this.history.push({
37691
+ this.deps.moves.record({
37122
37692
  deviceId: plan.deviceId,
37123
37693
  fromNodeId: plan.fromNodeId,
37124
37694
  at: now
@@ -37190,6 +37760,513 @@ var NodeStressController = class NodeStressController {
37190
37760
  }
37191
37761
  };
37192
37762
  //#endregion
37763
+ //#region src/durable/lts-aggregator.ts
37764
+ /** Wall-clock bucket width. Five minutes, matching HA's statistics tier. */
37765
+ var LTS_BUCKET_MS = 5 * 6e4;
37766
+ /** How often the row cap is enforced. Rarely: it is a bound, not a deadline. */
37767
+ var CAP_SWEEP_INTERVAL_MS = 6 * 36e5;
37768
+ var LTS_COLUMNS = [
37769
+ (
37770
+ /** `<subject>|<series>|<scope>|<bucketStart>` — deterministic, so a bucket
37771
+ * flushed twice replaces itself rather than doubling. */
37772
+ {
37773
+ name: "id",
37774
+ type: "TEXT",
37775
+ primaryKey: true,
37776
+ notNull: true
37777
+ }),
37778
+ (
37779
+ /** The camera id, or the node id. One column, because every query is
37780
+ * "this thing over time" and the thing is one or the other. */
37781
+ {
37782
+ name: "subject",
37783
+ type: "TEXT",
37784
+ notNull: true
37785
+ }),
37786
+ (
37787
+ /** Numeric mirror of `subject` for camera rows, so a per-camera question is
37788
+ * answered by an integer index — every log line and every query about a
37789
+ * device in this repo is keyed by the numeric id. `NULL` for node rows. */
37790
+ {
37791
+ name: "deviceId",
37792
+ type: "INTEGER"
37793
+ }),
37794
+ {
37795
+ name: "series",
37796
+ type: "TEXT",
37797
+ notNull: true
37798
+ },
37799
+ (
37800
+ /** Sub-scope within a series: a zoneId for occupancy, `''` otherwise. */
37801
+ {
37802
+ name: "scope",
37803
+ type: "TEXT",
37804
+ notNull: true
37805
+ }),
37806
+ (
37807
+ /** Bucket start, wall-clock aligned to {@link LTS_BUCKET_MS}. */
37808
+ {
37809
+ name: "bucketStart",
37810
+ type: "INTEGER",
37811
+ notNull: true
37812
+ }),
37813
+ {
37814
+ name: "samples",
37815
+ type: "INTEGER",
37816
+ notNull: true
37817
+ },
37818
+ {
37819
+ name: "sum",
37820
+ type: "REAL",
37821
+ notNull: true
37822
+ },
37823
+ {
37824
+ name: "min",
37825
+ type: "REAL",
37826
+ notNull: true
37827
+ },
37828
+ {
37829
+ name: "max",
37830
+ type: "REAL",
37831
+ notNull: true
37832
+ }
37833
+ ];
37834
+ var LTS_INDEXES = [{
37835
+ name: "idx_lts_series_bucket",
37836
+ columns: ["series", "bucketStart"]
37837
+ }, {
37838
+ name: "idx_lts_device",
37839
+ columns: ["deviceId"]
37840
+ }];
37841
+ function ltsBucketStart(at, bucketMs = LTS_BUCKET_MS) {
37842
+ return Math.floor(at / bucketMs) * bucketMs;
37843
+ }
37844
+ var LtsAggregator = class {
37845
+ open = /* @__PURE__ */ new Map();
37846
+ /** Every (subject, series, scope) that has produced a row in this process —
37847
+ * the groups the cap sweep has any reason to look at. */
37848
+ groups = /* @__PURE__ */ new Set();
37849
+ lastCapSweepAt = 0;
37850
+ collection;
37851
+ store;
37852
+ logger;
37853
+ nowFn;
37854
+ bucketMs;
37855
+ maxRows;
37856
+ constructor(deps) {
37857
+ this.collection = deps.collection;
37858
+ this.store = deps.store;
37859
+ this.logger = deps.logger;
37860
+ this.nowFn = deps.now ?? (() => Date.now());
37861
+ this.bucketMs = deps.bucketMs ?? 3e5;
37862
+ this.maxRows = deps.maxRowsPerSeries ?? 105120;
37863
+ }
37864
+ static declare(store, collection) {
37865
+ return store.declareCollection.mutate({
37866
+ collection,
37867
+ columns: [...LTS_COLUMNS],
37868
+ indexes: [...LTS_INDEXES]
37869
+ });
37870
+ }
37871
+ /**
37872
+ * Record one observation. Synchronous, allocation-free after the first
37873
+ * sample of a bucket, and it cannot throw — it sits on paths that are
37874
+ * already producing the value for another reason and must not learn a new
37875
+ * failure mode.
37876
+ *
37877
+ * A non-finite value is DROPPED rather than folded in: one `NaN` would make
37878
+ * `sum`, `min` and `max` all `NaN` for the whole bucket, turning a skewed
37879
+ * row into a meaningless one.
37880
+ */
37881
+ note(input) {
37882
+ if (!Number.isFinite(input.value)) return;
37883
+ const at = input.at ?? this.nowFn();
37884
+ const scope = input.scope ?? "";
37885
+ const bucketStart = ltsBucketStart(at, this.bucketMs);
37886
+ const key = rowId(input.subject, input.series, scope, bucketStart);
37887
+ const held = this.open.get(key);
37888
+ if (held === void 0) {
37889
+ this.open.set(key, {
37890
+ subject: input.subject,
37891
+ ...input.deviceId !== void 0 ? { deviceId: input.deviceId } : {},
37892
+ series: input.series,
37893
+ scope,
37894
+ bucketStart,
37895
+ samples: 1,
37896
+ sum: input.value,
37897
+ min: input.value,
37898
+ max: input.value
37899
+ });
37900
+ return;
37901
+ }
37902
+ held.samples += 1;
37903
+ held.sum += input.value;
37904
+ if (input.value < held.min) held.min = input.value;
37905
+ if (input.value > held.max) held.max = input.value;
37906
+ }
37907
+ /** Buckets currently accumulating — diagnostics, and what a flush would write. */
37908
+ openBuckets() {
37909
+ return [...this.open.values()].map(toRow);
37910
+ }
37911
+ /**
37912
+ * Write every bucket that has CLOSED (its window ended at or before `now`)
37913
+ * and drop it from RAM. Returns the number of rows written.
37914
+ *
37915
+ * The current bucket is deliberately left alone: writing it early would mean
37916
+ * rewriting it on the next tick, which turns one row into up to sixty and
37917
+ * offers every one of them to the checkpoint lottery (D96).
37918
+ */
37919
+ async flushDue(now = this.nowFn()) {
37920
+ const currentBucket = ltsBucketStart(now, this.bucketMs);
37921
+ const due = [...this.open.values()].filter((b) => b.bucketStart < currentBucket);
37922
+ if (due.length === 0) {
37923
+ await this.maybeSweepCap(now);
37924
+ return 0;
37925
+ }
37926
+ let written = 0;
37927
+ for (const bucket of due) {
37928
+ const key = rowId(bucket.subject, bucket.series, bucket.scope, bucket.bucketStart);
37929
+ try {
37930
+ await this.store.set.mutate({
37931
+ collection: this.collection,
37932
+ key,
37933
+ value: {
37934
+ subject: bucket.subject,
37935
+ ...bucket.deviceId !== void 0 ? { deviceId: bucket.deviceId } : {},
37936
+ series: bucket.series,
37937
+ scope: bucket.scope,
37938
+ bucketStart: bucket.bucketStart,
37939
+ samples: bucket.samples,
37940
+ sum: bucket.sum,
37941
+ min: bucket.min,
37942
+ max: bucket.max
37943
+ }
37944
+ });
37945
+ written += 1;
37946
+ this.groups.add(groupKey(bucket.subject, bucket.series, bucket.scope));
37947
+ } catch (err) {
37948
+ this.logger.warn("lts bucket write failed — this interval will be missing", {
37949
+ ...bucket.deviceId !== void 0 ? { tags: { deviceId: bucket.deviceId } } : {},
37950
+ meta: {
37951
+ collection: this.collection,
37952
+ series: bucket.series,
37953
+ subject: bucket.subject,
37954
+ bucketStart: bucket.bucketStart,
37955
+ error: String(err)
37956
+ }
37957
+ });
37958
+ }
37959
+ this.open.delete(key);
37960
+ }
37961
+ await this.maybeSweepCap(now);
37962
+ return written;
37963
+ }
37964
+ /** Read closed buckets back. The read surface every chart will use. */
37965
+ async read(query = {}) {
37966
+ const where = {};
37967
+ if (query.series !== void 0) where["series"] = query.series;
37968
+ if (query.subject !== void 0) where["subject"] = query.subject;
37969
+ if (query.scope !== void 0) where["scope"] = query.scope;
37970
+ const records = await this.store.query.query({
37971
+ collection: this.collection,
37972
+ filter: {
37973
+ ...Object.keys(where).length > 0 ? { where } : {},
37974
+ ...query.from !== void 0 || query.to !== void 0 ? { whereBetween: { bucketStart: [query.from ?? 0, query.to ?? Number.MAX_SAFE_INTEGER] } } : {},
37975
+ orderBy: {
37976
+ field: "bucketStart",
37977
+ direction: "asc"
37978
+ },
37979
+ limit: query.limit ?? 5e3
37980
+ }
37981
+ });
37982
+ const rows = [];
37983
+ for (const record of records) {
37984
+ const row = recordToRow(record.data);
37985
+ if (row !== null) rows.push(row);
37986
+ }
37987
+ return rows;
37988
+ }
37989
+ /**
37990
+ * Enforce the per-(subject, series, scope) row cap, at most once every six
37991
+ * hours. Only groups this process has written to are examined: a group with
37992
+ * no new rows cannot have crossed a cap it was under.
37993
+ */
37994
+ async maybeSweepCap(now) {
37995
+ if (now - this.lastCapSweepAt < CAP_SWEEP_INTERVAL_MS) return;
37996
+ this.lastCapSweepAt = now;
37997
+ for (const group of this.groups) {
37998
+ const [subject, series, scope] = group.split("\0");
37999
+ if (subject === void 0 || series === void 0 || scope === void 0) continue;
38000
+ const where = {
38001
+ subject,
38002
+ series,
38003
+ scope
38004
+ };
38005
+ try {
38006
+ const excess = await this.store.count.query({
38007
+ collection: this.collection,
38008
+ filter: { where }
38009
+ }) - this.maxRows;
38010
+ if (excess <= 0) continue;
38011
+ const cutoff = (await this.store.query.query({
38012
+ collection: this.collection,
38013
+ filter: {
38014
+ where,
38015
+ orderBy: {
38016
+ field: "bucketStart",
38017
+ direction: "asc"
38018
+ },
38019
+ limit: excess
38020
+ }
38021
+ })).at(-1)?.data["bucketStart"];
38022
+ if (typeof cutoff !== "number") continue;
38023
+ const { deleted } = await this.store.deleteWhere.mutate({
38024
+ collection: this.collection,
38025
+ filter: {
38026
+ where,
38027
+ whereBetween: { bucketStart: [0, cutoff] }
38028
+ }
38029
+ });
38030
+ this.logger.info("lts row cap enforced", { meta: {
38031
+ collection: this.collection,
38032
+ series,
38033
+ subject,
38034
+ deleted,
38035
+ cap: this.maxRows
38036
+ } });
38037
+ } catch (err) {
38038
+ this.logger.warn("lts row cap sweep failed — the series keeps growing this cycle", { meta: {
38039
+ collection: this.collection,
38040
+ series,
38041
+ subject,
38042
+ error: String(err)
38043
+ } });
38044
+ }
38045
+ }
38046
+ }
38047
+ };
38048
+ function groupKey(subject, series, scope) {
38049
+ return `${subject}${series}${scope}`;
38050
+ }
38051
+ function rowId(subject, series, scope, bucketStart) {
38052
+ return `${subject}|${series}|${scope}|${bucketStart}`;
38053
+ }
38054
+ function toRow(bucket) {
38055
+ return {
38056
+ subject: bucket.subject,
38057
+ ...bucket.deviceId !== void 0 ? { deviceId: bucket.deviceId } : {},
38058
+ series: bucket.series,
38059
+ scope: bucket.scope,
38060
+ bucketStart: bucket.bucketStart,
38061
+ samples: bucket.samples,
38062
+ sum: bucket.sum,
38063
+ min: bucket.min,
38064
+ max: bucket.max
38065
+ };
38066
+ }
38067
+ /** Structural validation on read. A malformed row is skipped, never charted. */
38068
+ function recordToRow(data) {
38069
+ const subject = data["subject"];
38070
+ const series = data["series"];
38071
+ const scope = data["scope"];
38072
+ if (typeof subject !== "string" || typeof series !== "string") return null;
38073
+ const bucketStart = Number(data["bucketStart"]);
38074
+ const samples = Number(data["samples"]);
38075
+ const sum = Number(data["sum"]);
38076
+ const min = Number(data["min"]);
38077
+ const max = Number(data["max"]);
38078
+ if (![
38079
+ bucketStart,
38080
+ samples,
38081
+ sum,
38082
+ min,
38083
+ max
38084
+ ].every((n) => Number.isFinite(n))) return null;
38085
+ const rawDeviceId = data["deviceId"];
38086
+ const deviceId = typeof rawDeviceId === "number" && Number.isFinite(rawDeviceId) ? rawDeviceId : void 0;
38087
+ return {
38088
+ subject,
38089
+ ...deviceId !== void 0 ? { deviceId } : {},
38090
+ series,
38091
+ scope: typeof scope === "string" ? scope : "",
38092
+ bucketStart,
38093
+ samples,
38094
+ sum,
38095
+ min,
38096
+ max
38097
+ };
38098
+ }
38099
+ //#endregion
38100
+ //#region src/node-stress-lts.ts
38101
+ /**
38102
+ * @durable class=ledger owner=pipeline-orchestrator
38103
+ * write="one row per (node, series, 5-min bucket), written ONCE when the bucket closes; a node reporting no samples writes nothing"
38104
+ * retention="row CAP per (subject, series, scope) — 105,120 rows ≈ one year of 5-minute buckets. Not an age sweep: an LTS row summarises an interval and has no owner to be orphaned from."
38105
+ */
38106
+ var NODE_STRESS_LTS_COLLECTION = "pipeline-orchestrator:stats-5m";
38107
+ var NodeStressLts = class {
38108
+ lts;
38109
+ constructor(deps) {
38110
+ this.lts = new LtsAggregator({
38111
+ collection: NODE_STRESS_LTS_COLLECTION,
38112
+ store: deps.store,
38113
+ logger: deps.logger,
38114
+ ...deps.now !== void 0 ? { now: deps.now } : {}
38115
+ });
38116
+ }
38117
+ static declare(store) {
38118
+ return LtsAggregator.declare(store, NODE_STRESS_LTS_COLLECTION);
38119
+ }
38120
+ /** Fold one sweep's verdict for one node into the open buckets. */
38121
+ noteSignals(nodeId, signals, at) {
38122
+ this.lts.note({
38123
+ subject: nodeId,
38124
+ series: "node-score",
38125
+ value: signals.score,
38126
+ at
38127
+ });
38128
+ this.lts.note({
38129
+ subject: nodeId,
38130
+ series: "node-queue-pressure",
38131
+ value: signals.queuePressure,
38132
+ at
38133
+ });
38134
+ this.lts.note({
38135
+ subject: nodeId,
38136
+ series: "node-drop-ratio",
38137
+ value: signals.dropRatio,
38138
+ at
38139
+ });
38140
+ this.lts.note({
38141
+ subject: nodeId,
38142
+ series: "node-fps-deficit",
38143
+ value: signals.fpsDeficit,
38144
+ at
38145
+ });
38146
+ }
38147
+ flushDue(now) {
38148
+ return this.lts.flushDue(now);
38149
+ }
38150
+ read(query) {
38151
+ return this.lts.read(query);
38152
+ }
38153
+ openBuckets() {
38154
+ return this.lts.openBuckets();
38155
+ }
38156
+ };
38157
+ //#endregion
38158
+ //#region src/node-stress-move-ledger.ts
38159
+ /**
38160
+ * @durable class=ledger owner=pipeline-orchestrator
38161
+ * write="one row per APPLIED failover move (never per attempt); write-behind"
38162
+ * retention="pruned by the sweep once older than the longest guard window (2 h) — a move no guard can read is growth with no reader"
38163
+ */
38164
+ var NODE_STRESS_MOVES_COLLECTION = "pipeline-orchestrator:node-stress-moves";
38165
+ var NODE_STRESS_MOVES_COLUMNS = [
38166
+ (
38167
+ /** `<deviceId>:<at>` — one camera can be moved more than once, and each move
38168
+ * spends its own slice of the budget. */
38169
+ {
38170
+ name: "id",
38171
+ type: "TEXT",
38172
+ primaryKey: true,
38173
+ notNull: true
38174
+ }),
38175
+ (
38176
+ /** Indexed: every question about a move is asked per-camera. */
38177
+ {
38178
+ name: "deviceId",
38179
+ type: "INTEGER",
38180
+ notNull: true
38181
+ }),
38182
+ (
38183
+ /** The node the camera LEFT — the return ban is about coming back here. */
38184
+ {
38185
+ name: "fromNodeId",
38186
+ type: "TEXT",
38187
+ notNull: true
38188
+ }),
38189
+ {
38190
+ name: "at",
38191
+ type: "INTEGER",
38192
+ notNull: true
38193
+ }
38194
+ ];
38195
+ var NODE_STRESS_MOVES_INDEXES = [{
38196
+ name: "idx_node_stress_moves_device",
38197
+ columns: ["deviceId"]
38198
+ }];
38199
+ function moveId(entry) {
38200
+ return `${entry.deviceId}:${entry.at}`;
38201
+ }
38202
+ var NODE_STRESS_MOVES_SPEC = {
38203
+ collection: NODE_STRESS_MOVES_COLLECTION,
38204
+ columns: NODE_STRESS_MOVES_COLUMNS,
38205
+ indexes: NODE_STRESS_MOVES_INDEXES,
38206
+ writeMode: "write-behind",
38207
+ keyOf: (row) => row.id,
38208
+ toValue: (row) => ({
38209
+ deviceId: row.deviceId,
38210
+ fromNodeId: row.fromNodeId,
38211
+ at: row.at
38212
+ }),
38213
+ fromRecord: (id, data) => {
38214
+ const deviceId = Number(data["deviceId"]);
38215
+ const at = Number(data["at"]);
38216
+ const fromNodeId = data["fromNodeId"];
38217
+ if (!Number.isFinite(deviceId) || !Number.isFinite(at)) return null;
38218
+ if (typeof fromNodeId !== "string" || fromNodeId.length === 0) return null;
38219
+ return {
38220
+ id,
38221
+ deviceId,
38222
+ fromNodeId,
38223
+ at
38224
+ };
38225
+ },
38226
+ deviceIdOf: (row) => row.deviceId,
38227
+ loadLimit: 1e4
38228
+ };
38229
+ var NodeStressMoveLedger = class {
38230
+ ledger;
38231
+ constructor(deps) {
38232
+ this.ledger = new DurableLedger({
38233
+ spec: NODE_STRESS_MOVES_SPEC,
38234
+ store: deps.store,
38235
+ logger: deps.logger
38236
+ });
38237
+ }
38238
+ static declare(store) {
38239
+ return DurableLedger.declare(store, NODE_STRESS_MOVES_SPEC);
38240
+ }
38241
+ /** Boot reseed. Returns the row count so the caller can say out loud how much
38242
+ * budget it recovered — "moves recovered: 0" after a restart is the line that
38243
+ * explains a burst of relocations. */
38244
+ async load() {
38245
+ return (await this.ledger.load()).length;
38246
+ }
38247
+ /** The history the guards read. Pure RAM — a guard must never wait on I/O. */
38248
+ entries() {
38249
+ return this.ledger.snapshot().map(({ deviceId, fromNodeId, at }) => ({
38250
+ deviceId,
38251
+ fromNodeId,
38252
+ at
38253
+ }));
38254
+ }
38255
+ /** Record one APPLIED move. Mirror first; the durable write follows and its
38256
+ * failure is logged, never thrown at a relocation that already happened. */
38257
+ record(entry) {
38258
+ this.ledger.put({
38259
+ ...entry,
38260
+ id: moveId(entry)
38261
+ });
38262
+ }
38263
+ /** Drop moves older than `cutoff` — no guard can read them. Returns the count. */
38264
+ pruneOlderThan(cutoff) {
38265
+ const keep = new Set(this.ledger.snapshot().filter((row) => row.at >= cutoff).map((row) => row.id));
38266
+ return this.ledger.pruneExcept(keep);
38267
+ }
38268
+ };
38269
+ //#endregion
37193
38270
  //#region src/dispatch-reconcile.ts
37194
38271
  function runnerAttachmentKey(nodeId, deviceId) {
37195
38272
  return `${nodeId}:${deviceId}`;
@@ -42326,8 +43403,18 @@ async function buildOrchestratorControllers(deps) {
42326
43403
  readGlobalSettings: () => globalSettings,
42327
43404
  getInitTimestamp: () => deps.initTimestamp
42328
43405
  });
43406
+ const nodeStressMoves = new NodeStressMoveLedger({
43407
+ store: deps.ctx().api.settingsStore,
43408
+ logger: deps.ctx().logger.child("node-stress")
43409
+ });
43410
+ const nodeStressLts = new NodeStressLts({
43411
+ store: deps.ctx().api.settingsStore,
43412
+ logger: deps.ctx().logger.child("node-stress-lts")
43413
+ });
42329
43414
  const nodeStress = new NodeStressController({
42330
43415
  ledger,
43416
+ moves: nodeStressMoves,
43417
+ lts: nodeStressLts,
42331
43418
  topology,
42332
43419
  settingsStore,
42333
43420
  logger: deps.ctx().logger,
@@ -42336,6 +43423,17 @@ async function buildOrchestratorControllers(deps) {
42336
43423
  readPipelinePin: (deviceId) => deps.readPipelinePin(deviceId),
42337
43424
  readGlobalSettings: () => globalSettings
42338
43425
  });
43426
+ await NodeStressMoveLedger.declare(deps.ctx().api.settingsStore).then(() => nodeStress.hydrate()).catch((err) => {
43427
+ deps.ctx().logger.warn("node-stress move history unavailable — this boot starts cold", { meta: { error: errMsg(err) } });
43428
+ });
43429
+ await NodeStressLts.declare(deps.ctx().api.settingsStore).catch((err) => {
43430
+ deps.ctx().logger.warn("node-stress statistics unavailable — no baseline this boot", { meta: { error: errMsg(err) } });
43431
+ });
43432
+ const nodeStressLtsTimer = setInterval(() => {
43433
+ nodeStressLts.flushDue().catch((err) => {
43434
+ deps.ctx().logger.warn("node-stress statistics flush failed", { meta: { error: errMsg(err) } });
43435
+ });
43436
+ }, 6e4);
42339
43437
  nodeStress.start();
42340
43438
  const inferenceRotation = new RoundRobinInferenceDeviceRotation();
42341
43439
  /**
@@ -42679,6 +43777,7 @@ async function buildOrchestratorControllers(deps) {
42679
43777
  reconcile.scheduleReconcile();
42680
43778
  const detectionWiring = new DetectionWiringController({
42681
43779
  ctx: () => deps.ctx(),
43780
+ featuresStore: deps.ctx().api.settingsStore,
42682
43781
  ledger,
42683
43782
  placement,
42684
43783
  audio,
@@ -42703,10 +43802,15 @@ async function buildOrchestratorControllers(deps) {
42703
43802
  deviceHasOnboardMotionCap: (deviceId) => deps.deviceHasOnboardMotionCap(deviceId),
42704
43803
  deviceSettingsSchema: () => deps.deviceSettingsSchema()
42705
43804
  });
43805
+ await detectionWiring.hydrateFeaturesMirror().catch((err) => {
43806
+ deps.ctx().logger.warn("device-features mirror not restored — this boot starts cold", { meta: { error: errMsg(err) } });
43807
+ });
42706
43808
  return {
42707
43809
  ledger,
42708
43810
  topology,
42709
43811
  loadService,
43812
+ nodeStressLts,
43813
+ nodeStressLtsTimer,
42710
43814
  audio,
42711
43815
  settingsStore,
42712
43816
  loadShed,
@@ -43259,6 +44363,18 @@ function deriveRuntimeSettings(config) {
43259
44363
  }
43260
44364
  //#endregion
43261
44365
  //#region src/index.ts
44366
+ /**
44367
+ * The FULL action catalog, under the name the HUB HARVESTS.
44368
+ *
44369
+ * The hub's forked-addon harvest imports this entry module and reads the
44370
+ * `customActions` NAMED export (`loadForkedCustomActionCatalog`) — returning a
44371
+ * catalog from `onInitialize` is not enough. Without this line the bridge
44372
+ * answers *"no addon 'pipeline-orchestrator' registers custom actions"* for
44373
+ * every action here, which is what `dumpState` had been doing, silently, since
44374
+ * it was written: an admin diagnostic that 404s is worse than no diagnostic,
44375
+ * because nobody discovers it is missing until they need it.
44376
+ */
44377
+ var customActions = pipelineOrchestratorActions;
43262
44378
  var PipelineOrchestratorAddon = class PipelineOrchestratorAddon extends BaseAddon {
43263
44379
  /** This node's Moleculer nodeId (from this.ctx.kernel.localNodeId). */
43264
44380
  localNodeId = "hub";
@@ -43476,6 +44592,10 @@ var PipelineOrchestratorAddon = class PipelineOrchestratorAddon extends BaseAddo
43476
44592
  pendingRetryTimer = null;
43477
44593
  /** Periodic auto-rebalance sweep timer (drift correction under hysteresis). */
43478
44594
  autoRebalanceTimer = null;
44595
+ /** Node-stress five-minute statistics + its flush timer (see
44596
+ * `node-stress-lts.ts`). `observe` mode's first durable output. */
44597
+ nodeStressLts = null;
44598
+ nodeStressLtsTimer = null;
43479
44599
  initTimestamp = 0;
43480
44600
  /** Storage migration maintenance lease. It only gates dispatch; it does not
43481
44601
  * change any camera wrapper or persistent pipeline configuration. */
@@ -43569,6 +44689,8 @@ var PipelineOrchestratorAddon = class PipelineOrchestratorAddon extends BaseAddo
43569
44689
  this.unsubOrchestratorSubscriptions = controllers.unsubOrchestratorSubscriptions;
43570
44690
  this.pendingRetryTimer = controllers.pendingRetryTimer;
43571
44691
  this.autoRebalanceTimer = controllers.autoRebalanceTimer;
44692
+ this.nodeStressLts = controllers.nodeStressLts;
44693
+ this.nodeStressLtsTimer = controllers.nodeStressLtsTimer;
43572
44694
  return {
43573
44695
  providers: [
43574
44696
  {
@@ -43597,7 +44719,23 @@ var PipelineOrchestratorAddon = class PipelineOrchestratorAddon extends BaseAddo
43597
44719
  }
43598
44720
  ],
43599
44721
  customActions: pipelineOrchestratorActions,
43600
- actionHandlers: { dumpState: async () => this.dumpDiagnostics() }
44722
+ actionHandlers: {
44723
+ dumpState: async () => this.dumpDiagnostics(),
44724
+ nodeStressStats: async (input) => {
44725
+ const lts = this.nodeStressLts;
44726
+ const moves = this.nodeStress?.historyView() ?? [];
44727
+ if (!lts) return {
44728
+ rows: [],
44729
+ open: [],
44730
+ moves: [...moves]
44731
+ };
44732
+ return {
44733
+ rows: (await lts.read(input)).map(withStatsMean),
44734
+ open: lts.openBuckets().map(withStatsMean),
44735
+ moves: [...moves]
44736
+ };
44737
+ }
44738
+ }
43601
44739
  };
43602
44740
  }
43603
44741
  /**
@@ -43632,6 +44770,11 @@ var PipelineOrchestratorAddon = class PipelineOrchestratorAddon extends BaseAddo
43632
44770
  clearInterval(this.autoRebalanceTimer);
43633
44771
  this.autoRebalanceTimer = null;
43634
44772
  }
44773
+ if (this.nodeStressLtsTimer !== null) {
44774
+ clearInterval(this.nodeStressLtsTimer);
44775
+ this.nodeStressLtsTimer = null;
44776
+ }
44777
+ this.nodeStressLts = null;
43635
44778
  this.unsubOrchestratorSubscriptions?.();
43636
44779
  this.unsubOrchestratorSubscriptions = null;
43637
44780
  this.reconcile?.dispose();
@@ -44585,5 +45728,12 @@ var PipelineOrchestratorAddon = class PipelineOrchestratorAddon extends BaseAddo
44585
45728
  return this.isSessionCamera(deviceId) && !this.session.hasActiveSession(deviceId);
44586
45729
  }
44587
45730
  };
45731
+ /** The derived mean for one statistics row — see `node-stress-lts.ts`. */
45732
+ function withStatsMean(row) {
45733
+ return {
45734
+ ...row,
45735
+ mean: row.samples > 0 ? row.sum / row.samples : 0
45736
+ };
45737
+ }
44588
45738
  //#endregion
44589
- export { balance, computeCapacityScore, PipelineOrchestratorAddon as default, pipelineOrchestratorActions };
45739
+ export { balance, computeCapacityScore, customActions, PipelineOrchestratorAddon as default, pipelineOrchestratorActions };