@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.js CHANGED
@@ -13863,11 +13863,33 @@ var NotificationFormatSchema = _enum([
13863
13863
  * Named by INTENT, never by glyph. "check" would tie the vocabulary to one
13864
13864
  * renderer's icon set; "acknowledge" survives an adapter that draws it
13865
13865
  * differently.
13866
+ *
13867
+ * ── A TOKEN IS NOT A WIRE VALUE ─────────────────────────────────────
13868
+ *
13869
+ * These names are for US. **No adapter may forward one verbatim.** Each maps
13870
+ * the whole set onto its own renderer's vocabulary through a
13871
+ * `Record<NotificationActionIcon, string>` — a Record, never a lookup with a
13872
+ * fallback, so adding a member here fails every adapter's build until someone
13873
+ * decides its glyph, which is the only place that decision can be made
13874
+ * honestly.
13875
+ *
13876
+ * This paragraph is the bug. Zentik declared `actionIcons: true` and passed
13877
+ * `disarm` straight through; iOS feeds that string to
13878
+ * `UNNotificationActionIcon(systemImageName:)`, `disarm` is not an SF Symbol,
13879
+ * and every snooze and alarm button arrived BLANK. A pass-through is not a
13880
+ * mapping, and "the field is documented" is not "the value renders".
13881
+ *
13882
+ * Adding a member is TRAIN-BOUND. The enum lives in the published
13883
+ * `@camstack/server` closure and the cap seam validates against the HUB's copy,
13884
+ * so an addon that emits a token the running hub does not know does not lose an
13885
+ * icon — its whole `send` fails Zod validation and the notification never
13886
+ * arrives. Never emit a new token from an addon before the train carrying it.
13866
13887
  */
13867
13888
  var NotificationActionIconSchema = _enum([
13868
13889
  "acknowledge",
13869
13890
  "dismiss",
13870
13891
  "silence",
13892
+ "snooze",
13871
13893
  "view",
13872
13894
  "play",
13873
13895
  "open",
@@ -13875,9 +13897,13 @@ var NotificationActionIconSchema = _enum([
13875
13897
  "lock",
13876
13898
  "unlock",
13877
13899
  "arm",
13900
+ "arm-home",
13901
+ "arm-away",
13902
+ "arm-night",
13878
13903
  "disarm",
13879
13904
  "light",
13880
- "alert"
13905
+ "alert",
13906
+ "camera"
13881
13907
  ]);
13882
13908
  /** A single tap-through action button. */
13883
13909
  var NotificationActionSchema = object({
@@ -13893,7 +13919,23 @@ var NotificationActionSchema = object({
13893
13919
  * else — see `notification-center/action-token.ts` for what that does and
13894
13920
  * does not buy.
13895
13921
  */
13896
- destructive: boolean().optional()
13922
+ destructive: boolean().optional(),
13923
+ /**
13924
+ * How the tap should REACH the url.
13925
+ *
13926
+ * `navigate` (absent, and every button authored before this field) opens it:
13927
+ * the phone leaves the notification and shows whatever the callback returns.
13928
+ * That is right for a button whose answer the operator wants to read.
13929
+ *
13930
+ * `background` fires it as a POST and stays put. It exists for the buttons
13931
+ * whose whole point is not to interrupt — "silence this for 30 minutes" is
13932
+ * an answer to the notification, and being thrown into a browser tab to
13933
+ * confirm it costs more attention than the notification did. A backend that
13934
+ * cannot do a background call renders it as an ordinary link (the adapters
13935
+ * fall back rather than dropping the button), so this is a preference, never
13936
+ * a requirement.
13937
+ */
13938
+ mode: _enum(["navigate", "background"]).optional()
13897
13939
  });
13898
13940
  /**
13899
13941
  * The canonical notification. `body` is the only hard field (Apprise model).
@@ -14061,6 +14103,24 @@ method(object({}), array(TargetKindSchema)), method(object({}), array(TargetSche
14061
14103
  targetId: string(),
14062
14104
  enabled: boolean()
14063
14105
  }), _void(), { kind: "mutation" });
14106
+ new Set([
14107
+ {
14108
+ id: "person",
14109
+ name: "Person"
14110
+ },
14111
+ {
14112
+ id: "vehicle",
14113
+ name: "Vehicle"
14114
+ },
14115
+ {
14116
+ id: "animal",
14117
+ name: "Animal"
14118
+ },
14119
+ {
14120
+ id: "package",
14121
+ name: "Package"
14122
+ }
14123
+ ].map((l) => l.id));
14064
14124
  var COCO_TO_MACRO = {
14065
14125
  mapping: {
14066
14126
  person: "person",
@@ -14858,6 +14918,8 @@ var NcSystemEventKindSchema = _enum([
14858
14918
  "alarm-triggered",
14859
14919
  "alarm-armed",
14860
14920
  "alarm-disarmed",
14921
+ "alarm-arming",
14922
+ "alarm-arm-refused",
14861
14923
  "camera-online",
14862
14924
  "camera-offline",
14863
14925
  "camera-disabled",
@@ -14894,6 +14956,9 @@ var NcSystemEventConditionSchema = object({
14894
14956
  nodeIds: array(string().min(1)).min(1).optional(),
14895
14957
  packageNames: array(string().min(1)).min(1).optional()
14896
14958
  });
14959
+ /** Hard ceiling on a window (24h). A snooze that could not expire would be an
14960
+ * outage the operator asked for once and forgot. */
14961
+ var NC_SNOOZE_MAX_MINUTES = 1440;
14897
14962
  /** Weekly schedule — OR of windows; absence on the rule = always active. */
14898
14963
  var NcScheduleSchema = object({
14899
14964
  windows: array(object({
@@ -15270,15 +15335,15 @@ var NcConditionsSchema = object({
15270
15335
  * (an `immediate` rule naming an `audio-*` class, one notification per
15271
15336
  * classified sample) stays exactly as it was for rules that already use it.
15272
15337
  *
15273
- * NOT in {@link NC_CONDITION_CATALOG} yet, and that is the sequencing rule
15274
- * rather than an oversight: the viewer mirrors the descriptor enums BY HAND
15275
- * (`camstack/src/data/notification-center.ts`, guarded by
15276
- * `scripts/check-viewer-condition-mirror.ts`) and its rule editor STRIPS the
15338
+ * In {@link NC_CONDITION_CATALOG} since P2, and the ORDER it got there is the
15339
+ * rule rather than an accident: the viewer mirrors the descriptor enums BY
15340
+ * HAND (`camstack/src/data/notification-center.ts`, guarded by
15341
+ * `scripts/check-viewer-condition-mirror.ts`) and its rule editor strips the
15277
15342
  * condition fields it does not know when a rule is saved from the phone.
15278
15343
  * Publishing an editor for a condition the app cannot round-trip is how an
15279
- * operator loses a rule's conditions by opening it — so the descriptor, the
15280
- * admin widget and the viewer mirror land together (P2 + P3), and only then
15281
- * does an audio rule become authorable.
15344
+ * operator loses a rule's conditions by opening it — so the viewer mirror
15345
+ * (P3, shipped) went FIRST, and the descriptor an editor renders from
15346
+ * follows here.
15282
15347
  */
15283
15348
  audio: NcAudioConditionSchema.optional()
15284
15349
  });
@@ -15514,6 +15579,30 @@ var NcRuleInputSchema = object({
15514
15579
  */
15515
15580
  snoozeAllowGlobal: boolean().optional(),
15516
15581
  /**
15582
+ * The snooze durations THIS rule's notification offers as buttons, in
15583
+ * minutes.
15584
+ *
15585
+ * Three states, and all three are distinct — which is exactly why this is
15586
+ * `.optional()` and never `.default()`. A Zod default does not run on the
15587
+ * addon cap path (three production failures in one day), so a schema default
15588
+ * would collapse the first two:
15589
+ *
15590
+ * | value | meaning |
15591
+ * | --- | --- |
15592
+ * | absent | the operator never said ⇒ {@link NC_DEFAULT_SNOOZE_MINUTES} |
15593
+ * | `[]` | **no snooze buttons on this rule** — the explicit override |
15594
+ * | a list | these choices, de-duplicated and sorted, at most four |
15595
+ *
15596
+ * `.max(4)` because the notifier's own action budget is small (ntfy allows
15597
+ * three buttons in total) and a rule that spent it all on snooze choices
15598
+ * would push its own tap-through actions off the notification.
15599
+ *
15600
+ * An empty list is NOT an alarm exemption: a rule the alarm is about, or
15601
+ * that arms the panel, is exempt automatically and cannot be silenced by a
15602
+ * window from anywhere (D133).
15603
+ */
15604
+ snoozeOptions: array(number().int().min(1).max(NC_SNOOZE_MAX_MINUTES)).max(4).optional(),
15605
+ /**
15517
15606
  * Devices this rule ACTUATES — arm the alarm, open a gate, turn on a light.
15518
15607
  *
15519
15608
  * This is what makes the rule set the alarm's trigger set without the alarm
@@ -15613,6 +15702,7 @@ var NcConditionDescriptorSchema = object({
15613
15702
  "device",
15614
15703
  "package",
15615
15704
  "occupancy",
15705
+ "audio",
15616
15706
  "system"
15617
15707
  ]),
15618
15708
  label: string(),
@@ -15631,6 +15721,7 @@ var NcConditionDescriptorSchema = object({
15631
15721
  "crossingSelect",
15632
15722
  "polygonDraw",
15633
15723
  "occupancy",
15724
+ "audio",
15634
15725
  "deviceState",
15635
15726
  "systemEvent"
15636
15727
  ]),
@@ -15782,7 +15873,20 @@ var NcSnoozeInputSchema = object({
15782
15873
  ruleId: string().optional(),
15783
15874
  /** Required when `scope: 'device'`. */
15784
15875
  deviceId: number().int().optional(),
15785
- durationMinutes: number().int().min(1).max(1440),
15876
+ /**
15877
+ * Narrow the window to these subject classes — "the cat, not the person".
15878
+ *
15879
+ * ORTHOGONAL to `scope`, deliberately, and absent means EVERY class: that is
15880
+ * what every window authored before this field meant, so no persisted row
15881
+ * changes meaning and no client has to learn anything to keep working.
15882
+ *
15883
+ * It is what makes the window's real key `(deviceId, classes[])` and lets it
15884
+ * cross rules (D133): the operator points at a camera and a kind of thing,
15885
+ * not at whichever of their four rules happened to produce the notification
15886
+ * they are dismissing.
15887
+ */
15888
+ classes: array(string().min(1)).min(1).optional(),
15889
+ durationMinutes: number().int().min(1).max(NC_SNOOZE_MAX_MINUTES),
15786
15890
  /**
15787
15891
  * Silence this for EVERY recipient, not just the caller. Permission is
15788
15892
  * checked server-side (the rule's `snoozeAllowGlobal`, or admin for the
@@ -15807,6 +15911,10 @@ var NcSnoozeSchema = object({
15807
15911
  scope: NcSnoozeScopeSchema,
15808
15912
  ruleId: string().optional(),
15809
15913
  deviceId: number().int().optional(),
15914
+ /** Subject classes this window covers. ABSENT = every class — see
15915
+ * {@link NcSnoozeInputSchema.shape.classes}. Lives in the JSON blob and has
15916
+ * no SQLite column: nothing queries a window by class. */
15917
+ classes: array(string().min(1)).min(1).optional(),
15810
15918
  startedAt: number(),
15811
15919
  /** Exclusive: at exactly this instant the snooze is over. Expiry is a
15812
15920
  * COMPARISON, not a job — no sweeper can leave the operator silenced. */
@@ -18059,7 +18167,14 @@ var zonesCapability = {
18059
18167
  * handle. Slice shape is `{ zones: Zone[] }` so future extensions
18060
18168
  * (e.g. zone groupings) can sit alongside the polygon list.
18061
18169
  */
18062
- runtimeState: object({ zones: array(ZoneSchema).readonly() })
18170
+ runtimeState: object({ zones: array(ZoneSchema).readonly() }),
18171
+ /**
18172
+ * 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.
18173
+ *
18174
+ * See `RuntimeStateDurability`. Enforced by
18175
+ * `scripts/check-runtime-state-durability.ts`.
18176
+ */
18177
+ durability: "restored"
18063
18178
  };
18064
18179
  /**
18065
18180
  * A bounding box in NORMALIZED [0,1] frame coordinates for `getNativeCrop`. The
@@ -18229,6 +18344,22 @@ var detectionFpsField = {
18229
18344
  default: 10,
18230
18345
  step: 1
18231
18346
  };
18347
+ /**
18348
+ * The occupancy re-check interval. DEFAULT 300 s (2026-08-13 — was 30 s).
18349
+ *
18350
+ * The recheck is now on by default (a parked car is invisible to occupancy
18351
+ * rules until the stationary registry has been rebuilt by motion, which after a
18352
+ * restart may be never on a quiet camera). Each cycle re-subscribes a detection
18353
+ * session — an RTSP re-dial — so the switch is only affordable at a WIDE
18354
+ * interval: 300 s is ~12 re-dials an hour per camera, against 120 at the old
18355
+ * 30 s. A parked car is therefore counted within 5 minutes of a restart.
18356
+ *
18357
+ * Why not wider: `max` is 300 and raising it is TRAIN-BOUND, not addon-bound —
18358
+ * the host validates `attachCamera` against ITS copy of this schema, so a
18359
+ * runner asked for 600 would be rejected by the hub until a `@camstack/server`
18360
+ * carrying the wider bound is installed everywhere. 300 is the widest value
18361
+ * that ships with an addon deploy.
18362
+ */
18232
18363
  var occupancyRecheckSecField = {
18233
18364
  min: 0,
18234
18365
  max: 300,
@@ -18430,15 +18561,21 @@ var RunnerCameraConfigSchema = object({
18430
18561
  */
18431
18562
  onboardMotionDrivesAnalyzer: boolean().default(true),
18432
18563
  /**
18433
- * Master toggle for the occupancy re-check. When `false` (DEFAULT) the runner
18434
- * never arms the periodic recheck timer, regardless of `occupancyRecheckSec` —
18435
- * this is off by default because the recheck re-subscribes a detection session
18436
- * every N seconds while `watching`, a major source of pull-decoder re-dial
18437
- * churn (each cycle creates+tears a session → RTSP re-dial → latency). The
18564
+ * Master toggle for the occupancy re-check. When `false` the runner never arms
18565
+ * the periodic recheck timer, regardless of `occupancyRecheckSec`; the
18438
18566
  * `occupancyRecheckSec` / `occupancyRecheckFrames` sliders only take effect
18439
18567
  * (and only render) when this is enabled.
18440
- */
18441
- occupancyRecheckEnabled: boolean().default(false),
18568
+ *
18569
+ * DEFAULT `true` since 2026-08-13 (was `false`). It was off because the
18570
+ * recheck re-subscribes a detection session every N seconds while `watching`
18571
+ * — each cycle creates+tears a session ⇒ an RTSP re-dial ⇒ latency, a major
18572
+ * pull-decoder churn source. What that bought was a blind spot: a STATIONARY
18573
+ * object is counted only while the stationary registry holds it, and the
18574
+ * registry rebuilds from motion, so after a restart a parked car was invisible
18575
+ * to every occupancy rule until something moved in front of it. The churn is
18576
+ * now paid on the interval instead — see `occupancyRecheckSecField`.
18577
+ */
18578
+ occupancyRecheckEnabled: boolean().default(true),
18442
18579
  occupancyRecheckSec: number().min(occupancyRecheckSecField.min).max(occupancyRecheckSecField.max).default(occupancyRecheckSecField.default),
18443
18580
  occupancyRecheckFrames: number().min(occupancyRecheckFramesField.min).max(occupancyRecheckFramesField.max).default(occupancyRecheckFramesField.default),
18444
18581
  /**
@@ -18571,8 +18708,8 @@ var RunnerCameraDeviceUIFields = [
18571
18708
  type: "boolean",
18572
18709
  style: "checkbox",
18573
18710
  label: "Occupancy re-check",
18574
- 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.",
18575
- default: false
18711
+ 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.",
18712
+ default: true
18576
18713
  },
18577
18714
  {
18578
18715
  key: "occupancyRecheckSec",
@@ -26413,7 +26550,14 @@ var zoneRulesCapability = {
26413
26550
  motion: array(ZoneRuleSchema).readonly(),
26414
26551
  detection: array(ZoneRuleSchema).readonly(),
26415
26552
  package: array(ZoneRuleSchema).readonly()
26416
- })
26553
+ }),
26554
+ /**
26555
+ * Runtime-state durability: **restored** — operator intent, mutation-only, same argument as `zones`.
26556
+ *
26557
+ * See `RuntimeStateDurability`. Enforced by
26558
+ * `scripts/check-runtime-state-durability.ts`.
26559
+ */
26560
+ durability: "restored"
26417
26561
  };
26418
26562
  /**
26419
26563
  * Accessory device helpers — shared across drivers.
@@ -32094,6 +32238,7 @@ Object.freeze({
32094
32238
  "network-access": "ingress",
32095
32239
  "smtp-provider": "email"
32096
32240
  });
32241
+ new Map(AUDIO_MACRO_LABELS.flatMap((macro) => macro.icon === void 0 ? [] : [[macro.id, macro.icon]]));
32097
32242
  new Set(["devices", "classes"]);
32098
32243
  /**
32099
32244
  * TimelapseRule — the STANDALONE scheduled timelapse producer's rule model.
@@ -32913,7 +33058,55 @@ var OrchestratorDiagnosticsSchema = object({
32913
33058
  cameraConfigCount: number().int().min(0),
32914
33059
  activeDetectionCount: number().int().min(0)
32915
33060
  });
32916
- var pipelineOrchestratorActions = defineCustomActions({ dumpState: customAction(_void(), OrchestratorDiagnosticsSchema) });
33061
+ /**
33062
+ * The node-stress long-term-statistics read surface.
33063
+ *
33064
+ * A custom action rather than a cap method, matching how the orchestrator
33065
+ * already serves `dumpState`: this is a hub-local read over a table the hub
33066
+ * owns, and it ships with one `camstack deploy` instead of a release train.
33067
+ * The MEAN is derived here and returned alongside the addable `sum`/`samples`
33068
+ * — a chart wants the first, a re-bucketing caller wants the second, and a
33069
+ * stored mean is a field that can disagree with both.
33070
+ */
33071
+ var NodeStressStatsInputSchema = object({
33072
+ /** `node-score` | `node-queue-pressure` | `node-drop-ratio` | `node-fps-deficit`. */
33073
+ series: string().optional(),
33074
+ /** A node id. Omit for every node. */
33075
+ subject: string().optional(),
33076
+ /** Inclusive bucket-start bounds, ms. */
33077
+ from: number().int().optional(),
33078
+ to: number().int().optional(),
33079
+ limit: number().int().positive().max(5e3).optional()
33080
+ });
33081
+ var NodeStressStatsRowSchema = object({
33082
+ subject: string(),
33083
+ series: string(),
33084
+ scope: string(),
33085
+ bucketStart: number(),
33086
+ samples: number(),
33087
+ sum: number(),
33088
+ mean: number(),
33089
+ min: number(),
33090
+ max: number()
33091
+ });
33092
+ var NodeStressStatsOutputSchema = object({
33093
+ rows: array(NodeStressStatsRowSchema).readonly(),
33094
+ /** Buckets still accumulating — "is it running" answerable at once, rather
33095
+ * than after five minutes of indistinguishable silence. */
33096
+ open: array(NodeStressStatsRowSchema).readonly(),
33097
+ /** The durable failover history the anti-flap guards read, newest first.
33098
+ * Exposed for the same reason the heartbeat exists: "nothing moved" has to
33099
+ * be distinguishable from "nothing is watching". */
33100
+ moves: array(object({
33101
+ deviceId: number(),
33102
+ fromNodeId: string(),
33103
+ at: number()
33104
+ })).readonly()
33105
+ });
33106
+ var pipelineOrchestratorActions = defineCustomActions({
33107
+ dumpState: customAction(_void(), OrchestratorDiagnosticsSchema),
33108
+ nodeStressStats: customAction(NodeStressStatsInputSchema, NodeStressStatsOutputSchema, { auth: "admin" })
33109
+ });
32917
33110
  /**
32918
33111
  * Sentinel returned by `buildDetectionConfig` when the profile-slot READ
32919
33112
  * itself failed transiently (e.g. `listAllProfileSlots` discovery timeout
@@ -35499,7 +35692,7 @@ function resolveDetectionSettings(input) {
35499
35692
  const userOnboardMotionDrivesAnalyzer = raw["onboardMotionDrivesAnalyzer"];
35500
35693
  const onboardMotionDrivesAnalyzer = typeof userOnboardMotionDrivesAnalyzer === "boolean" ? userOnboardMotionDrivesAnalyzer : true;
35501
35694
  const userOccupancyRecheckEnabled = raw["occupancyRecheckEnabled"];
35502
- const occupancyRecheckEnabled = typeof userOccupancyRecheckEnabled === "boolean" ? userOccupancyRecheckEnabled : false;
35695
+ const occupancyRecheckEnabled = typeof userOccupancyRecheckEnabled === "boolean" ? userOccupancyRecheckEnabled : true;
35503
35696
  const occupancyRecheckSec = mustNumber(flat, deviceId, "occupancyRecheckSec");
35504
35697
  const occupancyRecheckFrames = mustNumber(flat, deviceId, "occupancyRecheckFrames");
35505
35698
  return {
@@ -35633,12 +35826,341 @@ function buildRunnerConfig(base, overrides) {
35633
35826
  };
35634
35827
  }
35635
35828
  //#endregion
35636
- //#region src/device-features-mirror.ts
35829
+ //#region src/durable/durable-ledger.ts
35830
+ /** Default reseed cap — every current consumer's row set is installation-bounded. */
35831
+ var DEFAULT_LOAD_LIMIT = 1e5;
35832
+ var DurableLedger = class DurableLedger {
35833
+ mirror = /* @__PURE__ */ new Map();
35834
+ spec;
35835
+ store;
35836
+ logger;
35837
+ constructor(deps) {
35838
+ this.spec = deps.spec;
35839
+ this.store = deps.store;
35840
+ this.logger = deps.logger;
35841
+ }
35842
+ /**
35843
+ * Register the collection. MUST run at boot, before any read or write: the
35844
+ * SQLite backend answers 412 for an undeclared collection and takes the whole
35845
+ * runner down with it (the addon-ai boot-crash lesson).
35846
+ */
35847
+ static declare(store, spec) {
35848
+ return store.declareCollection.mutate({
35849
+ collection: spec.collection,
35850
+ columns: [...spec.columns],
35851
+ ...spec.indexes !== void 0 ? { indexes: [...spec.indexes] } : {}
35852
+ });
35853
+ }
35854
+ declare() {
35855
+ return DurableLedger.declare(this.store, this.spec);
35856
+ }
35857
+ /** The collection this ledger owns — for the caller's own log lines. */
35858
+ get collection() {
35859
+ return this.spec.collection;
35860
+ }
35861
+ /**
35862
+ * Boot reseed. Replaces the mirror with what the store holds and returns the
35863
+ * rows, so a caller that must hydrate something else (a watcher, a registry)
35864
+ * gets them without a second read.
35865
+ *
35866
+ * **A failure returns what is already mirrored** rather than clearing it —
35867
+ * see contract rule 2. The count is worth logging out loud at the call site:
35868
+ * "loaded 0" after a container recreate is the one line that explains a
35869
+ * silent flood.
35870
+ */
35871
+ async load() {
35872
+ try {
35873
+ const records = await this.store.query.query({
35874
+ collection: this.spec.collection,
35875
+ filter: { limit: this.spec.loadLimit ?? DEFAULT_LOAD_LIMIT }
35876
+ });
35877
+ const next = /* @__PURE__ */ new Map();
35878
+ let skipped = 0;
35879
+ for (const record of records) {
35880
+ const row = this.spec.fromRecord(record.id, record.data);
35881
+ if (row === null) {
35882
+ skipped += 1;
35883
+ continue;
35884
+ }
35885
+ next.set(this.spec.keyOf(row), row);
35886
+ }
35887
+ this.mirror.clear();
35888
+ for (const [key, row] of next) this.mirror.set(key, row);
35889
+ if (skipped > 0) this.logger.warn("durable rows skipped as malformed — they gate NOTHING", { meta: {
35890
+ collection: this.spec.collection,
35891
+ skipped
35892
+ } });
35893
+ return [...this.mirror.values()];
35894
+ } catch (err) {
35895
+ this.logger.warn("durable load failed — keeping the state already in memory", { meta: {
35896
+ collection: this.spec.collection,
35897
+ error: String(err),
35898
+ held: this.mirror.size
35899
+ } });
35900
+ return [...this.mirror.values()];
35901
+ }
35902
+ }
35903
+ /** Every mirrored row, insertion-ordered. */
35904
+ snapshot() {
35905
+ return [...this.mirror.values()];
35906
+ }
35907
+ /** The row currently held for a key, if any. Pure RAM — never I/O. */
35908
+ get(key) {
35909
+ return this.mirror.get(key);
35910
+ }
35911
+ has(key) {
35912
+ return this.mirror.has(key);
35913
+ }
35914
+ get size() {
35915
+ return this.mirror.size;
35916
+ }
35917
+ /**
35918
+ * Judge ONE observation against what the ledger already accepted, and advance
35919
+ * it.
35920
+ *
35921
+ * Synchronous on purpose: the verdict is a function of the in-RAM mirror
35922
+ * alone, so a decision can never be gated on an I/O that might fail (D49).
35923
+ * The durable write is kicked off behind it and its failure changes no
35924
+ * verdict.
35925
+ *
35926
+ * `no-flip` leaves the held row UNTOUCHED — including any timestamp it
35927
+ * carries, which therefore means "when this key last CHANGED", not "when it
35928
+ * last spoke". That is the timestamp anyone reading the table wants.
35929
+ */
35930
+ observe(row) {
35931
+ const equalFact = this.spec.equalFact;
35932
+ if (equalFact === void 0) throw new Error(`DurableLedger(${this.spec.collection}): observe() requires the spec to declare equalFact`);
35933
+ const key = this.spec.keyOf(row);
35934
+ const held = this.mirror.get(key);
35935
+ if (held !== void 0 && equalFact(held, row)) return "no-flip";
35936
+ this.mirror.set(key, row);
35937
+ this.persist(row);
35938
+ return held === void 0 ? "seeded" : "flip";
35939
+ }
35940
+ /**
35941
+ * Upsert a row without a verdict — the write path for a ledger whose owner
35942
+ * has already decided the value changed.
35943
+ *
35944
+ * The order of the mirror advance and the durable write is the spec's
35945
+ * {@link DurableWriteMode}, not the call site's: two call sites that
35946
+ * disagreed about it would be two different durability guarantees on one
35947
+ * collection.
35948
+ */
35949
+ async put(row) {
35950
+ const key = this.spec.keyOf(row);
35951
+ if (this.spec.writeMode === "write-behind") {
35952
+ this.mirror.set(key, row);
35953
+ await this.persist(row);
35954
+ return;
35955
+ }
35956
+ await this.store.set.mutate({
35957
+ collection: this.spec.collection,
35958
+ key,
35959
+ value: this.spec.toValue(row)
35960
+ });
35961
+ this.mirror.set(key, row);
35962
+ }
35963
+ /**
35964
+ * Advance the mirror WITHOUT persisting, for an owner that deliberately
35965
+ * coalesces its writes.
35966
+ *
35967
+ * The stationary registry is the reason this exists: a parked car is
35968
+ * re-confirmed on every processed frame (5–30 Hz), and persisting each
35969
+ * confirmation would offer thousands of commits a day to the checkpoint
35970
+ * lottery (D96) to maintain a handful of rows. It stages the advance and
35971
+ * flushes on its 5-minute sweep — 288 writes a day instead of ~10⁶.
35972
+ *
35973
+ * **The cost is stated, not hidden**: a staged value that is never flushed
35974
+ * is lost on a crash. An owner may only stage a field whose staleness its
35975
+ * own TTL absorbs. Anything that GATES work must go through {@link put} or
35976
+ * {@link observe}.
35977
+ */
35978
+ stage(row) {
35979
+ this.mirror.set(this.spec.keyOf(row), row);
35980
+ }
35981
+ /**
35982
+ * Drop a key from the MIRROR only — the durable row survives.
35983
+ *
35984
+ * What a scope-unbind needs: this process stops holding the value, and a
35985
+ * rebind reloads it from the store. Deliberately distinct from
35986
+ * {@link forget}, which deletes; conflating the two is how an unbind turns
35987
+ * into a wipe.
35988
+ */
35989
+ evict(key) {
35990
+ this.mirror.delete(key);
35991
+ }
35992
+ /**
35993
+ * Drop one key, mirror and row. Best-effort on the durable half: a failed
35994
+ * delete leaves a row that the next load will re-mirror, which is a stale
35995
+ * value rather than a lost one.
35996
+ */
35997
+ async forget(key) {
35998
+ this.mirror.delete(key);
35999
+ try {
36000
+ await this.store.delete.mutate({
36001
+ collection: this.spec.collection,
36002
+ key
36003
+ });
36004
+ } catch (err) {
36005
+ this.logger.debug("durable delete failed", { meta: {
36006
+ collection: this.spec.collection,
36007
+ key,
36008
+ error: String(err)
36009
+ } });
36010
+ }
36011
+ }
36012
+ /**
36013
+ * Drop every mirrored key NOT in `activeKeys`. Returns how many rows went.
36014
+ *
36015
+ * **The caller must hold an AUTHORITATIVE active set.** A prune driven by a
36016
+ * fallible read is work destroyed on an error (D49/D130) — that is why this
36017
+ * is a method a feature opts into rather than a policy the primitive runs.
36018
+ * Best-effort per row: a failed delete keeps the key (retried next prune)
36019
+ * rather than aborting the sweep.
36020
+ */
36021
+ async pruneExcept(activeKeys) {
36022
+ let pruned = 0;
36023
+ for (const key of [...this.mirror.keys()]) {
36024
+ if (activeKeys.has(key)) continue;
36025
+ try {
36026
+ await this.store.delete.mutate({
36027
+ collection: this.spec.collection,
36028
+ key
36029
+ });
36030
+ this.mirror.delete(key);
36031
+ pruned += 1;
36032
+ } catch (err) {
36033
+ this.logger.debug("durable prune delete failed", { meta: {
36034
+ collection: this.spec.collection,
36035
+ key,
36036
+ error: String(err)
36037
+ } });
36038
+ }
36039
+ }
36040
+ return pruned;
36041
+ }
36042
+ /**
36043
+ * Write-behind durable upsert. Best-effort and logged, never thrown at the
36044
+ * decision path: the mirror already holds the truth for this process, and the
36045
+ * worst a lost write can do is one silent re-seed after the next restart.
36046
+ */
36047
+ async persist(row) {
36048
+ const deviceId = this.spec.deviceIdOf?.(row);
36049
+ try {
36050
+ await this.store.set.mutate({
36051
+ collection: this.spec.collection,
36052
+ key: this.spec.keyOf(row),
36053
+ value: this.spec.toValue(row)
36054
+ });
36055
+ } catch (err) {
36056
+ this.logger.warn("durable persist failed — this key may re-seed on boot", {
36057
+ ...deviceId !== void 0 ? { tags: { deviceId } } : {},
36058
+ meta: {
36059
+ collection: this.spec.collection,
36060
+ key: this.spec.keyOf(row),
36061
+ error: String(err)
36062
+ }
36063
+ });
36064
+ }
36065
+ }
36066
+ };
36067
+ var DEVICE_FEATURES_SPEC = {
36068
+ collection: "pipeline-orchestrator:device-features",
36069
+ columns: [
36070
+ {
36071
+ name: "deviceId",
36072
+ type: "TEXT",
36073
+ primaryKey: true,
36074
+ notNull: true
36075
+ },
36076
+ (
36077
+ /** The feature name list, verbatim. */
36078
+ {
36079
+ name: "features",
36080
+ type: "JSON",
36081
+ notNull: true
36082
+ }),
36083
+ {
36084
+ name: "updatedAt",
36085
+ type: "INTEGER",
36086
+ notNull: true
36087
+ }
36088
+ ],
36089
+ writeMode: "write-behind",
36090
+ keyOf: (row) => String(row.deviceId),
36091
+ toValue: (row) => ({
36092
+ features: [...row.features],
36093
+ updatedAt: row.updatedAt
36094
+ }),
36095
+ fromRecord: (key, data) => {
36096
+ const deviceId = Number(key);
36097
+ const raw = data["features"];
36098
+ if (!Number.isFinite(deviceId) || !Array.isArray(raw)) return null;
36099
+ const features = raw.filter((f) => typeof f === "string");
36100
+ if (features.length === 0) return null;
36101
+ const updatedAt = Number(data["updatedAt"]);
36102
+ return {
36103
+ deviceId,
36104
+ features,
36105
+ updatedAt: Number.isFinite(updatedAt) ? updatedAt : 0,
36106
+ restored: true
36107
+ };
36108
+ },
36109
+ deviceIdOf: (row) => row.deviceId
36110
+ };
35637
36111
  var DeviceFeaturesMirror = class {
36112
+ /** Process-local fallback, used only when no store was supplied. */
36113
+ local = /* @__PURE__ */ new Map();
36114
+ durable;
35638
36115
  logger;
35639
- lastKnown = /* @__PURE__ */ new Map();
35640
- constructor(logger) {
35641
- this.logger = logger;
36116
+ now;
36117
+ constructor(deps) {
36118
+ this.logger = deps.logger;
36119
+ this.now = deps.now ?? (() => Date.now());
36120
+ this.durable = deps.store === void 0 ? null : new DurableLedger({
36121
+ spec: DEVICE_FEATURES_SPEC,
36122
+ store: deps.store,
36123
+ logger: deps.logger
36124
+ });
36125
+ }
36126
+ static declare(store) {
36127
+ return DurableLedger.declare(store, DEVICE_FEATURES_SPEC);
36128
+ }
36129
+ /**
36130
+ * Seed the mirror from the last session. Call once at boot, after `declare`
36131
+ * and BEFORE the first `resolve` — the whole value of the row is that it is
36132
+ * already there when the first read fails.
36133
+ */
36134
+ async hydrate() {
36135
+ if (this.durable === null) return 0;
36136
+ const rows = await this.durable.load();
36137
+ this.logger.info("device-features mirror restored", { meta: { devices: rows.length } });
36138
+ return rows.length;
36139
+ }
36140
+ held(deviceId) {
36141
+ return this.durable === null ? this.local.get(deviceId) : this.durable.get(String(deviceId));
36142
+ }
36143
+ remember(deviceId, features) {
36144
+ const row = {
36145
+ deviceId,
36146
+ features: [...features],
36147
+ updatedAt: this.now(),
36148
+ restored: false
36149
+ };
36150
+ if (this.durable === null) {
36151
+ this.local.set(deviceId, row);
36152
+ return;
36153
+ }
36154
+ const held = this.durable.get(String(deviceId));
36155
+ if (held !== void 0 && !held.restored && sameFeatures(held.features, features)) return;
36156
+ this.durable.put(row);
36157
+ }
36158
+ drop(deviceId) {
36159
+ if (this.durable === null) {
36160
+ this.local.delete(deviceId);
36161
+ return;
36162
+ }
36163
+ this.durable.forget(String(deviceId));
35642
36164
  }
35643
36165
  /**
35644
36166
  * Resolve a device's features, preferring a fresh read but never letting a
@@ -35647,15 +36169,19 @@ var DeviceFeaturesMirror = class {
35647
36169
  async resolve(deviceId, read) {
35648
36170
  const first = await read();
35649
36171
  if (first !== null && first.length > 0) {
35650
- this.lastKnown.set(deviceId, [...first]);
36172
+ this.remember(deviceId, first);
35651
36173
  return first;
35652
36174
  }
35653
- const mirrored = this.lastKnown.get(deviceId);
36175
+ const heldRow = this.held(deviceId);
36176
+ const mirrored = heldRow?.features;
35654
36177
  if (first === null) {
35655
36178
  if (mirrored !== void 0) {
35656
36179
  this.logger.warn("device features unavailable — serving last-known mirror", {
35657
36180
  tags: { deviceId },
35658
- meta: { mirrored: mirrored.length }
36181
+ meta: {
36182
+ mirrored: mirrored.length,
36183
+ restored: heldRow?.restored === true
36184
+ }
35659
36185
  });
35660
36186
  return mirrored;
35661
36187
  }
@@ -35669,7 +36195,7 @@ var DeviceFeaturesMirror = class {
35669
36195
  tags: { deviceId },
35670
36196
  meta: { features: second.length }
35671
36197
  });
35672
- this.lastKnown.set(deviceId, [...second]);
36198
+ this.remember(deviceId, second);
35673
36199
  return second;
35674
36200
  }
35675
36201
  if (second === null) {
@@ -35680,14 +36206,20 @@ var DeviceFeaturesMirror = class {
35680
36206
  tags: { deviceId },
35681
36207
  meta: { previously: mirrored.length }
35682
36208
  });
35683
- this.lastKnown.delete(deviceId);
36209
+ this.drop(deviceId);
35684
36210
  return [];
35685
36211
  }
35686
36212
  /** Drop a device's mirror — call when the device is removed. */
35687
36213
  forget(deviceId) {
35688
- this.lastKnown.delete(deviceId);
36214
+ this.drop(deviceId);
35689
36215
  }
35690
36216
  };
36217
+ /** Order-insensitive feature-set equality — the read's order is not a fact. */
36218
+ function sameFeatures(a, b) {
36219
+ if (a.length !== b.length) return false;
36220
+ const held = new Set(a);
36221
+ return b.every((f) => held.has(f));
36222
+ }
35691
36223
  //#endregion
35692
36224
  //#region src/watchdog-camera.ts
35693
36225
  /**
@@ -35754,7 +36286,20 @@ var DetectionWiringController = class {
35754
36286
  featuresMirror;
35755
36287
  constructor(deps) {
35756
36288
  this.deps = deps;
35757
- this.featuresMirror = new DeviceFeaturesMirror(deps.logger);
36289
+ this.featuresMirror = new DeviceFeaturesMirror({
36290
+ logger: deps.logger,
36291
+ ...deps.featuresStore !== void 0 ? { store: deps.featuresStore } : {}
36292
+ });
36293
+ }
36294
+ /**
36295
+ * Declare + seed the device-features mirror. Call once at boot, BEFORE the
36296
+ * first detection start — the restored row is worth nothing after the read
36297
+ * that would have needed it.
36298
+ */
36299
+ async hydrateFeaturesMirror() {
36300
+ if (this.deps.featuresStore === void 0) return;
36301
+ await DeviceFeaturesMirror.declare(this.deps.featuresStore);
36302
+ await this.featuresMirror.hydrate();
35758
36303
  }
35759
36304
  /** `activeDetections.get(deviceId)`. */
35760
36305
  getActiveDetectionConfig(deviceId) {
@@ -36965,7 +37510,6 @@ var NodeStressController = class NodeStressController {
36965
37510
  static HEARTBEAT_MS = 60 * 6e4;
36966
37511
  samples = /* @__PURE__ */ new Map();
36967
37512
  memories = /* @__PURE__ */ new Map();
36968
- history = [];
36969
37513
  /** When the last heartbeat went out. `null` ⇒ the next sweep emits one. */
36970
37514
  lastHeartbeatAt = null;
36971
37515
  timer = null;
@@ -37009,6 +37553,18 @@ var NodeStressController = class NodeStressController {
37009
37553
  forgetDevice(deviceId) {
37010
37554
  this.samples.delete(deviceId);
37011
37555
  }
37556
+ /**
37557
+ * Recover the move history from disk BEFORE the first sweep can act.
37558
+ *
37559
+ * Best-effort by construction (the ledger keeps whatever it already has on a
37560
+ * read failure), and the count is logged out loud: a restart that recovers 0
37561
+ * moves while the operator remembers three is the line that says the budget
37562
+ * has been reset.
37563
+ */
37564
+ async hydrate() {
37565
+ const recovered = await this.deps.moves.load();
37566
+ this.deps.logger.info("node-stress move history recovered", { meta: { moves: recovered } });
37567
+ }
37012
37568
  start() {
37013
37569
  if (this.timer) return;
37014
37570
  this.timer = setInterval(() => {
@@ -37024,12 +37580,24 @@ var NodeStressController = class NodeStressController {
37024
37580
  }
37025
37581
  this.samples.clear();
37026
37582
  this.memories.clear();
37027
- this.history = [];
37028
37583
  }
37029
37584
  /** The last computed state per node — empty before the first sweep. */
37030
37585
  statesView() {
37031
37586
  return new Map([...this.memories].map(([nodeId, m]) => [nodeId, m.state]));
37032
37587
  }
37588
+ /** The durable move history, newest first — the guards' own evidence, so an
37589
+ * operator asking "why did nothing move" can read the budget. */
37590
+ historyView() {
37591
+ return this.deps.moves.entries().toSorted((a, b) => b.at - a.at);
37592
+ }
37593
+ /**
37594
+ * The per-node stress signals as of `now`, for the LTS aggregator and for
37595
+ * diagnostics. Computed from the same live samples the sweep uses, so a
37596
+ * chart can never disagree with a verdict.
37597
+ */
37598
+ signalsView(now) {
37599
+ return new Map(this.buildInputs(now).map((input) => [input.nodeId, input.signals]));
37600
+ }
37033
37601
  /** Group live samples by node and reduce each group. Exposed for tests. */
37034
37602
  buildInputs(now) {
37035
37603
  const byNode = /* @__PURE__ */ new Map();
@@ -37060,6 +37628,7 @@ var NodeStressController = class NodeStressController {
37060
37628
  const verdicts = evaluateNodeStress(this.memories, this.buildInputs(now), now, this.thresholds());
37061
37629
  for (const v of verdicts) {
37062
37630
  this.memories.set(v.nodeId, v.memory);
37631
+ this.deps.lts?.noteSignals(v.nodeId, v.signals, now);
37063
37632
  if (v.changed) this.logTransition(v);
37064
37633
  }
37065
37634
  this.maybeHeartbeat(mode, verdicts, now);
@@ -37118,11 +37687,12 @@ var NodeStressController = class NodeStressController {
37118
37687
  async actOn(verdicts, now) {
37119
37688
  if (this.moveInFlight) return;
37120
37689
  if (!verdicts.some((v) => v.state === "saturated")) return;
37121
- this.history = this.history.filter((h) => now - h.at < NodeStressController.HISTORY_TTL_MS);
37690
+ this.deps.moves.pruneOlderThan(now - NodeStressController.HISTORY_TTL_MS);
37691
+ const history = this.deps.moves.entries().filter((h) => now - h.at < NodeStressController.HISTORY_TTL_MS);
37122
37692
  const plan = planStressFailover({
37123
37693
  verdicts,
37124
37694
  candidates: await this.collectCandidates(now),
37125
- history: this.history,
37695
+ history,
37126
37696
  nodeCaps: await this.deps.settingsStore.buildNodeCaps(),
37127
37697
  attachedByNode: this.attachedByNode()
37128
37698
  }, now, this.guards());
@@ -37146,7 +37716,7 @@ var NodeStressController = class NodeStressController {
37146
37716
  await this.deps.detach(plan.fromNodeId, plan.deviceId);
37147
37717
  await this.deps.attach(plan.toNodeId, config);
37148
37718
  this.deps.ledger.recordAssignment(plan.deviceId, plan.toNodeId, "rebalance", false);
37149
- this.history.push({
37719
+ this.deps.moves.record({
37150
37720
  deviceId: plan.deviceId,
37151
37721
  fromNodeId: plan.fromNodeId,
37152
37722
  at: now
@@ -37218,6 +37788,513 @@ var NodeStressController = class NodeStressController {
37218
37788
  }
37219
37789
  };
37220
37790
  //#endregion
37791
+ //#region src/durable/lts-aggregator.ts
37792
+ /** Wall-clock bucket width. Five minutes, matching HA's statistics tier. */
37793
+ var LTS_BUCKET_MS = 5 * 6e4;
37794
+ /** How often the row cap is enforced. Rarely: it is a bound, not a deadline. */
37795
+ var CAP_SWEEP_INTERVAL_MS = 6 * 36e5;
37796
+ var LTS_COLUMNS = [
37797
+ (
37798
+ /** `<subject>|<series>|<scope>|<bucketStart>` — deterministic, so a bucket
37799
+ * flushed twice replaces itself rather than doubling. */
37800
+ {
37801
+ name: "id",
37802
+ type: "TEXT",
37803
+ primaryKey: true,
37804
+ notNull: true
37805
+ }),
37806
+ (
37807
+ /** The camera id, or the node id. One column, because every query is
37808
+ * "this thing over time" and the thing is one or the other. */
37809
+ {
37810
+ name: "subject",
37811
+ type: "TEXT",
37812
+ notNull: true
37813
+ }),
37814
+ (
37815
+ /** Numeric mirror of `subject` for camera rows, so a per-camera question is
37816
+ * answered by an integer index — every log line and every query about a
37817
+ * device in this repo is keyed by the numeric id. `NULL` for node rows. */
37818
+ {
37819
+ name: "deviceId",
37820
+ type: "INTEGER"
37821
+ }),
37822
+ {
37823
+ name: "series",
37824
+ type: "TEXT",
37825
+ notNull: true
37826
+ },
37827
+ (
37828
+ /** Sub-scope within a series: a zoneId for occupancy, `''` otherwise. */
37829
+ {
37830
+ name: "scope",
37831
+ type: "TEXT",
37832
+ notNull: true
37833
+ }),
37834
+ (
37835
+ /** Bucket start, wall-clock aligned to {@link LTS_BUCKET_MS}. */
37836
+ {
37837
+ name: "bucketStart",
37838
+ type: "INTEGER",
37839
+ notNull: true
37840
+ }),
37841
+ {
37842
+ name: "samples",
37843
+ type: "INTEGER",
37844
+ notNull: true
37845
+ },
37846
+ {
37847
+ name: "sum",
37848
+ type: "REAL",
37849
+ notNull: true
37850
+ },
37851
+ {
37852
+ name: "min",
37853
+ type: "REAL",
37854
+ notNull: true
37855
+ },
37856
+ {
37857
+ name: "max",
37858
+ type: "REAL",
37859
+ notNull: true
37860
+ }
37861
+ ];
37862
+ var LTS_INDEXES = [{
37863
+ name: "idx_lts_series_bucket",
37864
+ columns: ["series", "bucketStart"]
37865
+ }, {
37866
+ name: "idx_lts_device",
37867
+ columns: ["deviceId"]
37868
+ }];
37869
+ function ltsBucketStart(at, bucketMs = LTS_BUCKET_MS) {
37870
+ return Math.floor(at / bucketMs) * bucketMs;
37871
+ }
37872
+ var LtsAggregator = class {
37873
+ open = /* @__PURE__ */ new Map();
37874
+ /** Every (subject, series, scope) that has produced a row in this process —
37875
+ * the groups the cap sweep has any reason to look at. */
37876
+ groups = /* @__PURE__ */ new Set();
37877
+ lastCapSweepAt = 0;
37878
+ collection;
37879
+ store;
37880
+ logger;
37881
+ nowFn;
37882
+ bucketMs;
37883
+ maxRows;
37884
+ constructor(deps) {
37885
+ this.collection = deps.collection;
37886
+ this.store = deps.store;
37887
+ this.logger = deps.logger;
37888
+ this.nowFn = deps.now ?? (() => Date.now());
37889
+ this.bucketMs = deps.bucketMs ?? 3e5;
37890
+ this.maxRows = deps.maxRowsPerSeries ?? 105120;
37891
+ }
37892
+ static declare(store, collection) {
37893
+ return store.declareCollection.mutate({
37894
+ collection,
37895
+ columns: [...LTS_COLUMNS],
37896
+ indexes: [...LTS_INDEXES]
37897
+ });
37898
+ }
37899
+ /**
37900
+ * Record one observation. Synchronous, allocation-free after the first
37901
+ * sample of a bucket, and it cannot throw — it sits on paths that are
37902
+ * already producing the value for another reason and must not learn a new
37903
+ * failure mode.
37904
+ *
37905
+ * A non-finite value is DROPPED rather than folded in: one `NaN` would make
37906
+ * `sum`, `min` and `max` all `NaN` for the whole bucket, turning a skewed
37907
+ * row into a meaningless one.
37908
+ */
37909
+ note(input) {
37910
+ if (!Number.isFinite(input.value)) return;
37911
+ const at = input.at ?? this.nowFn();
37912
+ const scope = input.scope ?? "";
37913
+ const bucketStart = ltsBucketStart(at, this.bucketMs);
37914
+ const key = rowId(input.subject, input.series, scope, bucketStart);
37915
+ const held = this.open.get(key);
37916
+ if (held === void 0) {
37917
+ this.open.set(key, {
37918
+ subject: input.subject,
37919
+ ...input.deviceId !== void 0 ? { deviceId: input.deviceId } : {},
37920
+ series: input.series,
37921
+ scope,
37922
+ bucketStart,
37923
+ samples: 1,
37924
+ sum: input.value,
37925
+ min: input.value,
37926
+ max: input.value
37927
+ });
37928
+ return;
37929
+ }
37930
+ held.samples += 1;
37931
+ held.sum += input.value;
37932
+ if (input.value < held.min) held.min = input.value;
37933
+ if (input.value > held.max) held.max = input.value;
37934
+ }
37935
+ /** Buckets currently accumulating — diagnostics, and what a flush would write. */
37936
+ openBuckets() {
37937
+ return [...this.open.values()].map(toRow);
37938
+ }
37939
+ /**
37940
+ * Write every bucket that has CLOSED (its window ended at or before `now`)
37941
+ * and drop it from RAM. Returns the number of rows written.
37942
+ *
37943
+ * The current bucket is deliberately left alone: writing it early would mean
37944
+ * rewriting it on the next tick, which turns one row into up to sixty and
37945
+ * offers every one of them to the checkpoint lottery (D96).
37946
+ */
37947
+ async flushDue(now = this.nowFn()) {
37948
+ const currentBucket = ltsBucketStart(now, this.bucketMs);
37949
+ const due = [...this.open.values()].filter((b) => b.bucketStart < currentBucket);
37950
+ if (due.length === 0) {
37951
+ await this.maybeSweepCap(now);
37952
+ return 0;
37953
+ }
37954
+ let written = 0;
37955
+ for (const bucket of due) {
37956
+ const key = rowId(bucket.subject, bucket.series, bucket.scope, bucket.bucketStart);
37957
+ try {
37958
+ await this.store.set.mutate({
37959
+ collection: this.collection,
37960
+ key,
37961
+ value: {
37962
+ subject: bucket.subject,
37963
+ ...bucket.deviceId !== void 0 ? { deviceId: bucket.deviceId } : {},
37964
+ series: bucket.series,
37965
+ scope: bucket.scope,
37966
+ bucketStart: bucket.bucketStart,
37967
+ samples: bucket.samples,
37968
+ sum: bucket.sum,
37969
+ min: bucket.min,
37970
+ max: bucket.max
37971
+ }
37972
+ });
37973
+ written += 1;
37974
+ this.groups.add(groupKey(bucket.subject, bucket.series, bucket.scope));
37975
+ } catch (err) {
37976
+ this.logger.warn("lts bucket write failed — this interval will be missing", {
37977
+ ...bucket.deviceId !== void 0 ? { tags: { deviceId: bucket.deviceId } } : {},
37978
+ meta: {
37979
+ collection: this.collection,
37980
+ series: bucket.series,
37981
+ subject: bucket.subject,
37982
+ bucketStart: bucket.bucketStart,
37983
+ error: String(err)
37984
+ }
37985
+ });
37986
+ }
37987
+ this.open.delete(key);
37988
+ }
37989
+ await this.maybeSweepCap(now);
37990
+ return written;
37991
+ }
37992
+ /** Read closed buckets back. The read surface every chart will use. */
37993
+ async read(query = {}) {
37994
+ const where = {};
37995
+ if (query.series !== void 0) where["series"] = query.series;
37996
+ if (query.subject !== void 0) where["subject"] = query.subject;
37997
+ if (query.scope !== void 0) where["scope"] = query.scope;
37998
+ const records = await this.store.query.query({
37999
+ collection: this.collection,
38000
+ filter: {
38001
+ ...Object.keys(where).length > 0 ? { where } : {},
38002
+ ...query.from !== void 0 || query.to !== void 0 ? { whereBetween: { bucketStart: [query.from ?? 0, query.to ?? Number.MAX_SAFE_INTEGER] } } : {},
38003
+ orderBy: {
38004
+ field: "bucketStart",
38005
+ direction: "asc"
38006
+ },
38007
+ limit: query.limit ?? 5e3
38008
+ }
38009
+ });
38010
+ const rows = [];
38011
+ for (const record of records) {
38012
+ const row = recordToRow(record.data);
38013
+ if (row !== null) rows.push(row);
38014
+ }
38015
+ return rows;
38016
+ }
38017
+ /**
38018
+ * Enforce the per-(subject, series, scope) row cap, at most once every six
38019
+ * hours. Only groups this process has written to are examined: a group with
38020
+ * no new rows cannot have crossed a cap it was under.
38021
+ */
38022
+ async maybeSweepCap(now) {
38023
+ if (now - this.lastCapSweepAt < CAP_SWEEP_INTERVAL_MS) return;
38024
+ this.lastCapSweepAt = now;
38025
+ for (const group of this.groups) {
38026
+ const [subject, series, scope] = group.split("\0");
38027
+ if (subject === void 0 || series === void 0 || scope === void 0) continue;
38028
+ const where = {
38029
+ subject,
38030
+ series,
38031
+ scope
38032
+ };
38033
+ try {
38034
+ const excess = await this.store.count.query({
38035
+ collection: this.collection,
38036
+ filter: { where }
38037
+ }) - this.maxRows;
38038
+ if (excess <= 0) continue;
38039
+ const cutoff = (await this.store.query.query({
38040
+ collection: this.collection,
38041
+ filter: {
38042
+ where,
38043
+ orderBy: {
38044
+ field: "bucketStart",
38045
+ direction: "asc"
38046
+ },
38047
+ limit: excess
38048
+ }
38049
+ })).at(-1)?.data["bucketStart"];
38050
+ if (typeof cutoff !== "number") continue;
38051
+ const { deleted } = await this.store.deleteWhere.mutate({
38052
+ collection: this.collection,
38053
+ filter: {
38054
+ where,
38055
+ whereBetween: { bucketStart: [0, cutoff] }
38056
+ }
38057
+ });
38058
+ this.logger.info("lts row cap enforced", { meta: {
38059
+ collection: this.collection,
38060
+ series,
38061
+ subject,
38062
+ deleted,
38063
+ cap: this.maxRows
38064
+ } });
38065
+ } catch (err) {
38066
+ this.logger.warn("lts row cap sweep failed — the series keeps growing this cycle", { meta: {
38067
+ collection: this.collection,
38068
+ series,
38069
+ subject,
38070
+ error: String(err)
38071
+ } });
38072
+ }
38073
+ }
38074
+ }
38075
+ };
38076
+ function groupKey(subject, series, scope) {
38077
+ return `${subject}${series}${scope}`;
38078
+ }
38079
+ function rowId(subject, series, scope, bucketStart) {
38080
+ return `${subject}|${series}|${scope}|${bucketStart}`;
38081
+ }
38082
+ function toRow(bucket) {
38083
+ return {
38084
+ subject: bucket.subject,
38085
+ ...bucket.deviceId !== void 0 ? { deviceId: bucket.deviceId } : {},
38086
+ series: bucket.series,
38087
+ scope: bucket.scope,
38088
+ bucketStart: bucket.bucketStart,
38089
+ samples: bucket.samples,
38090
+ sum: bucket.sum,
38091
+ min: bucket.min,
38092
+ max: bucket.max
38093
+ };
38094
+ }
38095
+ /** Structural validation on read. A malformed row is skipped, never charted. */
38096
+ function recordToRow(data) {
38097
+ const subject = data["subject"];
38098
+ const series = data["series"];
38099
+ const scope = data["scope"];
38100
+ if (typeof subject !== "string" || typeof series !== "string") return null;
38101
+ const bucketStart = Number(data["bucketStart"]);
38102
+ const samples = Number(data["samples"]);
38103
+ const sum = Number(data["sum"]);
38104
+ const min = Number(data["min"]);
38105
+ const max = Number(data["max"]);
38106
+ if (![
38107
+ bucketStart,
38108
+ samples,
38109
+ sum,
38110
+ min,
38111
+ max
38112
+ ].every((n) => Number.isFinite(n))) return null;
38113
+ const rawDeviceId = data["deviceId"];
38114
+ const deviceId = typeof rawDeviceId === "number" && Number.isFinite(rawDeviceId) ? rawDeviceId : void 0;
38115
+ return {
38116
+ subject,
38117
+ ...deviceId !== void 0 ? { deviceId } : {},
38118
+ series,
38119
+ scope: typeof scope === "string" ? scope : "",
38120
+ bucketStart,
38121
+ samples,
38122
+ sum,
38123
+ min,
38124
+ max
38125
+ };
38126
+ }
38127
+ //#endregion
38128
+ //#region src/node-stress-lts.ts
38129
+ /**
38130
+ * @durable class=ledger owner=pipeline-orchestrator
38131
+ * write="one row per (node, series, 5-min bucket), written ONCE when the bucket closes; a node reporting no samples writes nothing"
38132
+ * 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."
38133
+ */
38134
+ var NODE_STRESS_LTS_COLLECTION = "pipeline-orchestrator:stats-5m";
38135
+ var NodeStressLts = class {
38136
+ lts;
38137
+ constructor(deps) {
38138
+ this.lts = new LtsAggregator({
38139
+ collection: NODE_STRESS_LTS_COLLECTION,
38140
+ store: deps.store,
38141
+ logger: deps.logger,
38142
+ ...deps.now !== void 0 ? { now: deps.now } : {}
38143
+ });
38144
+ }
38145
+ static declare(store) {
38146
+ return LtsAggregator.declare(store, NODE_STRESS_LTS_COLLECTION);
38147
+ }
38148
+ /** Fold one sweep's verdict for one node into the open buckets. */
38149
+ noteSignals(nodeId, signals, at) {
38150
+ this.lts.note({
38151
+ subject: nodeId,
38152
+ series: "node-score",
38153
+ value: signals.score,
38154
+ at
38155
+ });
38156
+ this.lts.note({
38157
+ subject: nodeId,
38158
+ series: "node-queue-pressure",
38159
+ value: signals.queuePressure,
38160
+ at
38161
+ });
38162
+ this.lts.note({
38163
+ subject: nodeId,
38164
+ series: "node-drop-ratio",
38165
+ value: signals.dropRatio,
38166
+ at
38167
+ });
38168
+ this.lts.note({
38169
+ subject: nodeId,
38170
+ series: "node-fps-deficit",
38171
+ value: signals.fpsDeficit,
38172
+ at
38173
+ });
38174
+ }
38175
+ flushDue(now) {
38176
+ return this.lts.flushDue(now);
38177
+ }
38178
+ read(query) {
38179
+ return this.lts.read(query);
38180
+ }
38181
+ openBuckets() {
38182
+ return this.lts.openBuckets();
38183
+ }
38184
+ };
38185
+ //#endregion
38186
+ //#region src/node-stress-move-ledger.ts
38187
+ /**
38188
+ * @durable class=ledger owner=pipeline-orchestrator
38189
+ * write="one row per APPLIED failover move (never per attempt); write-behind"
38190
+ * 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"
38191
+ */
38192
+ var NODE_STRESS_MOVES_COLLECTION = "pipeline-orchestrator:node-stress-moves";
38193
+ var NODE_STRESS_MOVES_COLUMNS = [
38194
+ (
38195
+ /** `<deviceId>:<at>` — one camera can be moved more than once, and each move
38196
+ * spends its own slice of the budget. */
38197
+ {
38198
+ name: "id",
38199
+ type: "TEXT",
38200
+ primaryKey: true,
38201
+ notNull: true
38202
+ }),
38203
+ (
38204
+ /** Indexed: every question about a move is asked per-camera. */
38205
+ {
38206
+ name: "deviceId",
38207
+ type: "INTEGER",
38208
+ notNull: true
38209
+ }),
38210
+ (
38211
+ /** The node the camera LEFT — the return ban is about coming back here. */
38212
+ {
38213
+ name: "fromNodeId",
38214
+ type: "TEXT",
38215
+ notNull: true
38216
+ }),
38217
+ {
38218
+ name: "at",
38219
+ type: "INTEGER",
38220
+ notNull: true
38221
+ }
38222
+ ];
38223
+ var NODE_STRESS_MOVES_INDEXES = [{
38224
+ name: "idx_node_stress_moves_device",
38225
+ columns: ["deviceId"]
38226
+ }];
38227
+ function moveId(entry) {
38228
+ return `${entry.deviceId}:${entry.at}`;
38229
+ }
38230
+ var NODE_STRESS_MOVES_SPEC = {
38231
+ collection: NODE_STRESS_MOVES_COLLECTION,
38232
+ columns: NODE_STRESS_MOVES_COLUMNS,
38233
+ indexes: NODE_STRESS_MOVES_INDEXES,
38234
+ writeMode: "write-behind",
38235
+ keyOf: (row) => row.id,
38236
+ toValue: (row) => ({
38237
+ deviceId: row.deviceId,
38238
+ fromNodeId: row.fromNodeId,
38239
+ at: row.at
38240
+ }),
38241
+ fromRecord: (id, data) => {
38242
+ const deviceId = Number(data["deviceId"]);
38243
+ const at = Number(data["at"]);
38244
+ const fromNodeId = data["fromNodeId"];
38245
+ if (!Number.isFinite(deviceId) || !Number.isFinite(at)) return null;
38246
+ if (typeof fromNodeId !== "string" || fromNodeId.length === 0) return null;
38247
+ return {
38248
+ id,
38249
+ deviceId,
38250
+ fromNodeId,
38251
+ at
38252
+ };
38253
+ },
38254
+ deviceIdOf: (row) => row.deviceId,
38255
+ loadLimit: 1e4
38256
+ };
38257
+ var NodeStressMoveLedger = class {
38258
+ ledger;
38259
+ constructor(deps) {
38260
+ this.ledger = new DurableLedger({
38261
+ spec: NODE_STRESS_MOVES_SPEC,
38262
+ store: deps.store,
38263
+ logger: deps.logger
38264
+ });
38265
+ }
38266
+ static declare(store) {
38267
+ return DurableLedger.declare(store, NODE_STRESS_MOVES_SPEC);
38268
+ }
38269
+ /** Boot reseed. Returns the row count so the caller can say out loud how much
38270
+ * budget it recovered — "moves recovered: 0" after a restart is the line that
38271
+ * explains a burst of relocations. */
38272
+ async load() {
38273
+ return (await this.ledger.load()).length;
38274
+ }
38275
+ /** The history the guards read. Pure RAM — a guard must never wait on I/O. */
38276
+ entries() {
38277
+ return this.ledger.snapshot().map(({ deviceId, fromNodeId, at }) => ({
38278
+ deviceId,
38279
+ fromNodeId,
38280
+ at
38281
+ }));
38282
+ }
38283
+ /** Record one APPLIED move. Mirror first; the durable write follows and its
38284
+ * failure is logged, never thrown at a relocation that already happened. */
38285
+ record(entry) {
38286
+ this.ledger.put({
38287
+ ...entry,
38288
+ id: moveId(entry)
38289
+ });
38290
+ }
38291
+ /** Drop moves older than `cutoff` — no guard can read them. Returns the count. */
38292
+ pruneOlderThan(cutoff) {
38293
+ const keep = new Set(this.ledger.snapshot().filter((row) => row.at >= cutoff).map((row) => row.id));
38294
+ return this.ledger.pruneExcept(keep);
38295
+ }
38296
+ };
38297
+ //#endregion
37221
38298
  //#region src/dispatch-reconcile.ts
37222
38299
  function runnerAttachmentKey(nodeId, deviceId) {
37223
38300
  return `${nodeId}:${deviceId}`;
@@ -42354,8 +43431,18 @@ async function buildOrchestratorControllers(deps) {
42354
43431
  readGlobalSettings: () => globalSettings,
42355
43432
  getInitTimestamp: () => deps.initTimestamp
42356
43433
  });
43434
+ const nodeStressMoves = new NodeStressMoveLedger({
43435
+ store: deps.ctx().api.settingsStore,
43436
+ logger: deps.ctx().logger.child("node-stress")
43437
+ });
43438
+ const nodeStressLts = new NodeStressLts({
43439
+ store: deps.ctx().api.settingsStore,
43440
+ logger: deps.ctx().logger.child("node-stress-lts")
43441
+ });
42357
43442
  const nodeStress = new NodeStressController({
42358
43443
  ledger,
43444
+ moves: nodeStressMoves,
43445
+ lts: nodeStressLts,
42359
43446
  topology,
42360
43447
  settingsStore,
42361
43448
  logger: deps.ctx().logger,
@@ -42364,6 +43451,17 @@ async function buildOrchestratorControllers(deps) {
42364
43451
  readPipelinePin: (deviceId) => deps.readPipelinePin(deviceId),
42365
43452
  readGlobalSettings: () => globalSettings
42366
43453
  });
43454
+ await NodeStressMoveLedger.declare(deps.ctx().api.settingsStore).then(() => nodeStress.hydrate()).catch((err) => {
43455
+ deps.ctx().logger.warn("node-stress move history unavailable — this boot starts cold", { meta: { error: errMsg(err) } });
43456
+ });
43457
+ await NodeStressLts.declare(deps.ctx().api.settingsStore).catch((err) => {
43458
+ deps.ctx().logger.warn("node-stress statistics unavailable — no baseline this boot", { meta: { error: errMsg(err) } });
43459
+ });
43460
+ const nodeStressLtsTimer = setInterval(() => {
43461
+ nodeStressLts.flushDue().catch((err) => {
43462
+ deps.ctx().logger.warn("node-stress statistics flush failed", { meta: { error: errMsg(err) } });
43463
+ });
43464
+ }, 6e4);
42367
43465
  nodeStress.start();
42368
43466
  const inferenceRotation = new RoundRobinInferenceDeviceRotation();
42369
43467
  /**
@@ -42707,6 +43805,7 @@ async function buildOrchestratorControllers(deps) {
42707
43805
  reconcile.scheduleReconcile();
42708
43806
  const detectionWiring = new DetectionWiringController({
42709
43807
  ctx: () => deps.ctx(),
43808
+ featuresStore: deps.ctx().api.settingsStore,
42710
43809
  ledger,
42711
43810
  placement,
42712
43811
  audio,
@@ -42731,10 +43830,15 @@ async function buildOrchestratorControllers(deps) {
42731
43830
  deviceHasOnboardMotionCap: (deviceId) => deps.deviceHasOnboardMotionCap(deviceId),
42732
43831
  deviceSettingsSchema: () => deps.deviceSettingsSchema()
42733
43832
  });
43833
+ await detectionWiring.hydrateFeaturesMirror().catch((err) => {
43834
+ deps.ctx().logger.warn("device-features mirror not restored — this boot starts cold", { meta: { error: errMsg(err) } });
43835
+ });
42734
43836
  return {
42735
43837
  ledger,
42736
43838
  topology,
42737
43839
  loadService,
43840
+ nodeStressLts,
43841
+ nodeStressLtsTimer,
42738
43842
  audio,
42739
43843
  settingsStore,
42740
43844
  loadShed,
@@ -43287,6 +44391,18 @@ function deriveRuntimeSettings(config) {
43287
44391
  }
43288
44392
  //#endregion
43289
44393
  //#region src/index.ts
44394
+ /**
44395
+ * The FULL action catalog, under the name the HUB HARVESTS.
44396
+ *
44397
+ * The hub's forked-addon harvest imports this entry module and reads the
44398
+ * `customActions` NAMED export (`loadForkedCustomActionCatalog`) — returning a
44399
+ * catalog from `onInitialize` is not enough. Without this line the bridge
44400
+ * answers *"no addon 'pipeline-orchestrator' registers custom actions"* for
44401
+ * every action here, which is what `dumpState` had been doing, silently, since
44402
+ * it was written: an admin diagnostic that 404s is worse than no diagnostic,
44403
+ * because nobody discovers it is missing until they need it.
44404
+ */
44405
+ var customActions = pipelineOrchestratorActions;
43290
44406
  var PipelineOrchestratorAddon = class PipelineOrchestratorAddon extends BaseAddon {
43291
44407
  /** This node's Moleculer nodeId (from this.ctx.kernel.localNodeId). */
43292
44408
  localNodeId = "hub";
@@ -43504,6 +44620,10 @@ var PipelineOrchestratorAddon = class PipelineOrchestratorAddon extends BaseAddo
43504
44620
  pendingRetryTimer = null;
43505
44621
  /** Periodic auto-rebalance sweep timer (drift correction under hysteresis). */
43506
44622
  autoRebalanceTimer = null;
44623
+ /** Node-stress five-minute statistics + its flush timer (see
44624
+ * `node-stress-lts.ts`). `observe` mode's first durable output. */
44625
+ nodeStressLts = null;
44626
+ nodeStressLtsTimer = null;
43507
44627
  initTimestamp = 0;
43508
44628
  /** Storage migration maintenance lease. It only gates dispatch; it does not
43509
44629
  * change any camera wrapper or persistent pipeline configuration. */
@@ -43597,6 +44717,8 @@ var PipelineOrchestratorAddon = class PipelineOrchestratorAddon extends BaseAddo
43597
44717
  this.unsubOrchestratorSubscriptions = controllers.unsubOrchestratorSubscriptions;
43598
44718
  this.pendingRetryTimer = controllers.pendingRetryTimer;
43599
44719
  this.autoRebalanceTimer = controllers.autoRebalanceTimer;
44720
+ this.nodeStressLts = controllers.nodeStressLts;
44721
+ this.nodeStressLtsTimer = controllers.nodeStressLtsTimer;
43600
44722
  return {
43601
44723
  providers: [
43602
44724
  {
@@ -43625,7 +44747,23 @@ var PipelineOrchestratorAddon = class PipelineOrchestratorAddon extends BaseAddo
43625
44747
  }
43626
44748
  ],
43627
44749
  customActions: pipelineOrchestratorActions,
43628
- actionHandlers: { dumpState: async () => this.dumpDiagnostics() }
44750
+ actionHandlers: {
44751
+ dumpState: async () => this.dumpDiagnostics(),
44752
+ nodeStressStats: async (input) => {
44753
+ const lts = this.nodeStressLts;
44754
+ const moves = this.nodeStress?.historyView() ?? [];
44755
+ if (!lts) return {
44756
+ rows: [],
44757
+ open: [],
44758
+ moves: [...moves]
44759
+ };
44760
+ return {
44761
+ rows: (await lts.read(input)).map(withStatsMean),
44762
+ open: lts.openBuckets().map(withStatsMean),
44763
+ moves: [...moves]
44764
+ };
44765
+ }
44766
+ }
43629
44767
  };
43630
44768
  }
43631
44769
  /**
@@ -43660,6 +44798,11 @@ var PipelineOrchestratorAddon = class PipelineOrchestratorAddon extends BaseAddo
43660
44798
  clearInterval(this.autoRebalanceTimer);
43661
44799
  this.autoRebalanceTimer = null;
43662
44800
  }
44801
+ if (this.nodeStressLtsTimer !== null) {
44802
+ clearInterval(this.nodeStressLtsTimer);
44803
+ this.nodeStressLtsTimer = null;
44804
+ }
44805
+ this.nodeStressLts = null;
43663
44806
  this.unsubOrchestratorSubscriptions?.();
43664
44807
  this.unsubOrchestratorSubscriptions = null;
43665
44808
  this.reconcile?.dispose();
@@ -44613,8 +45756,16 @@ var PipelineOrchestratorAddon = class PipelineOrchestratorAddon extends BaseAddo
44613
45756
  return this.isSessionCamera(deviceId) && !this.session.hasActiveSession(deviceId);
44614
45757
  }
44615
45758
  };
45759
+ /** The derived mean for one statistics row — see `node-stress-lts.ts`. */
45760
+ function withStatsMean(row) {
45761
+ return {
45762
+ ...row,
45763
+ mean: row.samples > 0 ? row.sum / row.samples : 0
45764
+ };
45765
+ }
44616
45766
  //#endregion
44617
45767
  exports.balance = balance;
44618
45768
  exports.computeCapacityScore = computeCapacityScore;
45769
+ exports.customActions = customActions;
44619
45770
  exports.default = PipelineOrchestratorAddon;
44620
45771
  exports.pipelineOrchestratorActions = pipelineOrchestratorActions;