@camstack/addon-post-analysis 1.2.52 → 1.2.54

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.
@@ -2,7 +2,7 @@ Object.defineProperties(exports, {
2
2
  __esModule: { value: true },
3
3
  [Symbol.toStringTag]: { value: "Module" }
4
4
  });
5
- const require_dist = require("../dist-k21GqaWS.js");
5
+ const require_dist = require("../dist-BJ65Xxa1.js");
6
6
  let node_fs = require("node:fs");
7
7
  let node_path = require("node:path");
8
8
  node_path = require_dist.__toESM(node_path);
@@ -698,77 +698,34 @@ var NcAlarmPanelDevice = class extends require_dist.BaseDevice {
698
698
  }
699
699
  };
700
700
  //#endregion
701
- //#region src/notification-center/alarm/ensure-alarm-panel.ts
701
+ //#region src/notification-center/alarm/alarm-panel-declaration.ts
702
702
  /**
703
- * Bring the Notification Center's alarm panel into existence, once.
703
+ * The Notification Center's alarm panel, as a DECLARATION.
704
704
  *
705
- * Two idempotent steps — a fixed integration to hang from, and one device under
706
- * it. Both are `get-or-create`, because this runs on every boot and an operator
707
- * must never end up with a second panel after a restart.
705
+ * There is no `ensureAlarmPanel` any more. The get-or-create, the `fixed`
706
+ * re-assertion, the adopt-don't-skip rule and the hub-ownership gate all moved
707
+ * to `DeclaredDevices` in `@camstack/types` — the one primitive for a device an
708
+ * addon declares. What is left here is the *declaration*: the stable id, the
709
+ * seed name, the type and the class. Data, not procedure.
708
710
  *
709
- * The integration is FIXED: it exists because the addon says so, not because an
710
- * operator added it, and there is no add/delete flow for it. That is the whole
711
- * meaning of "a system integration" here — the same `integrations` row every
712
- * provider uses, created by its owner instead of by a form.
711
+ * The bug the hand-rolled version carried died in that move: it read the device
712
+ * index once, so a transiently empty answer took the create branch and
713
+ * re-stamped `Alarm` over the operator's rename. `DeclaredDevices` requires a
714
+ * second read to agree before it does anything destructive (D49).
713
715
  */
714
716
  /** Stable id of the one panel. Never derived from a name — a rename must not
715
717
  * mint a second device. */
716
718
  var NC_ALARM_STABLE_ID = "nc-alarm-panel";
719
+ /** Seeded ONCE, at create. A later boot never re-stamps it. */
717
720
  var NC_ALARM_DEVICE_NAME = "Alarm";
718
- /**
719
- * Idempotent. Returns the ids either way so the caller can hold onto the panel.
720
- *
721
- * Failures are the CALLER's to handle: a hub whose alarm could not be created
722
- * should say so loudly and keep notifying, not fail to boot — but that decision
723
- * does not belong in here.
724
- */
725
- async function ensureAlarmPanel(deps) {
726
- const existingIntegration = await deps.getIntegration(deps.addonId);
727
- if (existingIntegration !== null && !existingIntegration.fixed) {
728
- await deps.markFixed(existingIntegration.id);
729
- deps.logger.info("marked the notification-center integration as fixed", { meta: { integrationId: existingIntegration.id } });
730
- }
731
- const integration = existingIntegration ?? await deps.createIntegration({
732
- addonId: deps.addonId,
733
- name: deps.integrationName,
734
- info: { fixed: true }
735
- });
736
- if (await deps.findDeviceByStableId("nc-alarm-panel") !== null) {
737
- const adopted = await deps.adoptDevice({ stableId: NC_ALARM_STABLE_ID });
738
- deps.logger.info("alarm panel adopted", {
739
- tags: { deviceId: adopted.id },
740
- meta: {
741
- integrationId: integration.id,
742
- stableId: NC_ALARM_STABLE_ID
743
- }
744
- });
745
- return {
746
- integrationId: integration.id,
747
- deviceId: adopted.id,
748
- created: false
749
- };
750
- }
751
- const device = await deps.createDevice({
752
- stableId: NC_ALARM_STABLE_ID,
753
- integrationId: integration.id,
754
- name: NC_ALARM_DEVICE_NAME
755
- });
756
- deps.logger.info("alarm panel created", {
757
- tags: { deviceId: device.id },
758
- meta: {
759
- integrationId: integration.id,
760
- stableId: NC_ALARM_STABLE_ID
761
- }
762
- });
763
- return {
764
- integrationId: integration.id,
765
- deviceId: device.id,
766
- created: true
767
- };
768
- }
769
- /** The device class + type the caller passes to `kernel.devices.create`. */
770
- var NC_ALARM_DEVICE_CLASS = NcAlarmPanelDevice;
771
- var NC_ALARM_DEVICE_TYPE = require_dist.DeviceType.AlarmPanel;
721
+ /** Display name of the FIXED integration the panel hangs from. */
722
+ var NC_INTEGRATION_NAME = "Notification Center";
723
+ var NC_ALARM_DECLARATION = {
724
+ stableId: NC_ALARM_STABLE_ID,
725
+ name: NC_ALARM_DEVICE_NAME,
726
+ type: require_dist.DeviceType.AlarmPanel,
727
+ DeviceClass: NcAlarmPanelDevice
728
+ };
772
729
  //#endregion
773
730
  //#region src/notification-center/artifact-url.ts
774
731
  /**
@@ -1108,6 +1065,220 @@ function mimeFromExtension(file) {
1108
1065
  return "application/octet-stream";
1109
1066
  }
1110
1067
  //#endregion
1068
+ //#region src/notification-center/action-buttons.ts
1069
+ /** Sequence names the rule actually declares. A button may only name one. */
1070
+ function declaredSequences(actions) {
1071
+ const names = /* @__PURE__ */ new Set();
1072
+ for (const sequence of actions.onTrigger ?? []) names.add(sequence.name);
1073
+ return names;
1074
+ }
1075
+ /**
1076
+ * Build the notification's buttons.
1077
+ *
1078
+ * Returns an empty array rather than `undefined` — the caller decides whether
1079
+ * to set the field, and an empty array is the honest answer to "which buttons
1080
+ * survived", which `undefined` would conflate with "the rule declared none".
1081
+ */
1082
+ function buildActionButtons(input) {
1083
+ const actions = input.actions;
1084
+ if (actions === void 0) return [];
1085
+ const buttons = actions.buttons ?? [];
1086
+ if (buttons.length === 0) return [];
1087
+ const known = declaredSequences(actions);
1088
+ const seen = /* @__PURE__ */ new Set();
1089
+ const out = [];
1090
+ for (const button of buttons) {
1091
+ if (!known.has(button.sequence)) continue;
1092
+ if (seen.has(button.id)) continue;
1093
+ seen.add(button.id);
1094
+ out.push({
1095
+ id: button.id,
1096
+ label: button.label,
1097
+ url: input.mintUrl({
1098
+ sequence: button.sequence,
1099
+ actionId: button.id
1100
+ }),
1101
+ ...button.icon !== void 0 ? { icon: button.icon } : {},
1102
+ ...button.destructive !== void 0 ? { destructive: button.destructive } : {}
1103
+ });
1104
+ }
1105
+ return out;
1106
+ }
1107
+ /**
1108
+ * Which of a rule's buttons name a sequence it does not have.
1109
+ *
1110
+ * Exported so the caller can LOG the drop. A button silently missing from a
1111
+ * notification is the exact shape of failure this repo keeps paying for — the
1112
+ * operator authored it, it never appeared, and nothing said why.
1113
+ */
1114
+ function unresolvableButtons(actions) {
1115
+ if (actions === void 0) return [];
1116
+ const buttons = actions.buttons ?? [];
1117
+ if (buttons.length === 0) return [];
1118
+ const known = declaredSequences(actions);
1119
+ return buttons.filter((b) => !known.has(b.sequence)).map((b) => `${b.id}→${b.sequence}`);
1120
+ }
1121
+ //#endregion
1122
+ //#region src/notification-center/device-mute-store.ts
1123
+ var NC_DEVICE_MUTES_COLLECTION = "notification-center:device-mutes";
1124
+ var NC_DEVICE_MUTES_COLUMNS = [
1125
+ (
1126
+ /** `String(deviceId)` — one row per muted camera; unmute DELETES the row. */
1127
+ {
1128
+ name: "id",
1129
+ type: "TEXT",
1130
+ primaryKey: true,
1131
+ notNull: true
1132
+ }),
1133
+ {
1134
+ name: "deviceId",
1135
+ type: "INTEGER",
1136
+ notNull: true
1137
+ },
1138
+ (
1139
+ /** When an admin muted it — the only thing an audit ever wants to know. */
1140
+ {
1141
+ name: "mutedAt",
1142
+ type: "INTEGER",
1143
+ notNull: true
1144
+ })
1145
+ ];
1146
+ /**
1147
+ * Absence of a row is "not muted"; there is no `muted: false` row. That keeps
1148
+ * the table proportional to what the operator actually silenced, and makes the
1149
+ * hot-path read a `Set.has`.
1150
+ */
1151
+ var NcDeviceMuteStore = class {
1152
+ muted = /* @__PURE__ */ new Set();
1153
+ store;
1154
+ logger;
1155
+ now;
1156
+ constructor(deps) {
1157
+ this.store = deps.store;
1158
+ this.logger = deps.logger;
1159
+ this.now = deps.now ?? (() => Date.now());
1160
+ }
1161
+ static async declare(store) {
1162
+ await store.declareCollection.mutate({
1163
+ collection: NC_DEVICE_MUTES_COLLECTION,
1164
+ columns: [...NC_DEVICE_MUTES_COLUMNS]
1165
+ });
1166
+ }
1167
+ /**
1168
+ * (Re)hydrate. A failed load KEEPS what is already in memory rather than
1169
+ * clearing it: dropping every mute on a transient store error would start
1170
+ * notifying for a camera the operator silenced, without them asking — the
1171
+ * same reasoning as `NcSnoozeStore.load`.
1172
+ */
1173
+ async load() {
1174
+ try {
1175
+ const rows = await this.store.query.query({
1176
+ collection: NC_DEVICE_MUTES_COLLECTION,
1177
+ filter: { limit: 1e4 }
1178
+ });
1179
+ const next = /* @__PURE__ */ new Set();
1180
+ let skipped = 0;
1181
+ for (const row of rows) {
1182
+ const raw = row.data["deviceId"];
1183
+ if (typeof raw !== "number" || !Number.isInteger(raw)) {
1184
+ skipped += 1;
1185
+ continue;
1186
+ }
1187
+ next.add(raw);
1188
+ }
1189
+ this.muted.clear();
1190
+ for (const id of next) this.muted.add(id);
1191
+ if (skipped > 0) this.logger.warn("device-mute rows skipped as malformed — they silence NOTHING", { meta: { skipped } });
1192
+ } catch (err) {
1193
+ this.logger.warn("device-mute load failed — keeping the mutes already in memory", { meta: {
1194
+ error: String(err),
1195
+ muted: this.muted.size
1196
+ } });
1197
+ }
1198
+ }
1199
+ /** Hot-path read, called once per evaluated subject. */
1200
+ isMuted(deviceId) {
1201
+ return this.muted.has(deviceId);
1202
+ }
1203
+ /** Every muted camera, ascending — a stable order so two callers agree. */
1204
+ listMuted() {
1205
+ return [...this.muted].toSorted((a, b) => a - b);
1206
+ }
1207
+ /**
1208
+ * Mute or unmute. Idempotent in both directions; the in-memory set is
1209
+ * updated only after the write lands, so a failed write cannot leave the
1210
+ * hot path believing a camera is silenced when the next boot will not.
1211
+ */
1212
+ async setMuted(deviceId, muted) {
1213
+ const id = String(deviceId);
1214
+ if (muted) {
1215
+ await this.store.set.mutate({
1216
+ collection: NC_DEVICE_MUTES_COLLECTION,
1217
+ key: id,
1218
+ value: {
1219
+ id,
1220
+ deviceId,
1221
+ mutedAt: this.now()
1222
+ }
1223
+ });
1224
+ this.muted.add(deviceId);
1225
+ } else {
1226
+ await this.store.delete.mutate({
1227
+ collection: NC_DEVICE_MUTES_COLLECTION,
1228
+ key: id
1229
+ });
1230
+ this.muted.delete(deviceId);
1231
+ }
1232
+ }
1233
+ };
1234
+ var NcDeviceStateCache = class {
1235
+ deps;
1236
+ states = /* @__PURE__ */ new Map();
1237
+ lastOkAt = 0;
1238
+ ids = [];
1239
+ now;
1240
+ constructor(deps) {
1241
+ this.deps = deps;
1242
+ this.now = deps.now ?? (() => Date.now());
1243
+ }
1244
+ /**
1245
+ * The device ids worth reading — recomputed from the rule set, so a cluster
1246
+ * with no gated rule does no work at all.
1247
+ */
1248
+ setWatched(ids) {
1249
+ this.ids = [...new Set(ids)].sort((a, b) => a - b);
1250
+ }
1251
+ watched() {
1252
+ return this.ids;
1253
+ }
1254
+ /** Hot-path read. No I/O, no await — this is called per event. */
1255
+ get(deviceId) {
1256
+ if (this.now() - this.lastOkAt > 3e5) return void 0;
1257
+ return this.states.get(deviceId);
1258
+ }
1259
+ /**
1260
+ * Re-read every watched state. Never throws: a gate that could fail the
1261
+ * evaluation tick would take the notification pipeline with it.
1262
+ */
1263
+ async refresh() {
1264
+ if (this.ids.length === 0) {
1265
+ this.states = /* @__PURE__ */ new Map();
1266
+ return;
1267
+ }
1268
+ try {
1269
+ const next = await this.deps.readStates(this.ids);
1270
+ this.states = new Map(next);
1271
+ this.lastOkAt = this.now();
1272
+ for (const id of this.ids) if (!next.has(id)) this.deps.logger.info("device-state gate: no state for a watched device", { tags: { deviceId: id } });
1273
+ } catch (err) {
1274
+ this.deps.logger.warn("device-state refresh failed — keeping the previous states until they age out", { meta: {
1275
+ watched: this.ids.length,
1276
+ error: String(err)
1277
+ } });
1278
+ }
1279
+ }
1280
+ };
1281
+ //#endregion
1111
1282
  //#region src/pipeline-analytics/pipeline/label-utils.ts
1112
1283
  /**
1113
1284
  * Every macro class the pipeline can carry on a detection. A `label` exists to
@@ -1520,12 +1691,13 @@ function readSensorEventType(value) {
1520
1691
  * what the operator named in the rule. Carrying only the camera is what made a
1521
1692
  * sensor-scoped rule unmatched — see {@link NcRuleSubject.sourceDeviceId}.
1522
1693
  */
1523
- function subjectFromSensorEvent(ev) {
1694
+ function subjectFromSensorEvent(ev, markerTrackId) {
1524
1695
  const eventType = readSensorEventType(ev.value);
1525
1696
  return {
1526
1697
  kind: "device-event",
1527
1698
  recordId: ev.id,
1528
1699
  deviceId: ev.deviceId,
1700
+ ...markerTrackId !== void 0 ? { trackId: markerTrackId } : {},
1529
1701
  ...ev.sourceDeviceId !== ev.deviceId ? { sourceDeviceId: ev.sourceDeviceId } : {},
1530
1702
  timestamp: ev.timestamp,
1531
1703
  classNames: [],
@@ -2392,7 +2564,16 @@ var NcDispatcher = class {
2392
2564
  name: "zone.jpg"
2393
2565
  } : still);
2394
2566
  } else out.push(still);
2395
- }
2567
+ } else if (entry.payload.media !== "none") this.deps.logger.info("no still could be resolved — this notification ships text-only", {
2568
+ tags: { deviceId: entry.payload.subject.deviceId },
2569
+ meta: {
2570
+ recordKind: entry.recordKind,
2571
+ recordId: entry.recordId,
2572
+ policy: entry.payload.media,
2573
+ ...entry.payload.mediaFrame !== void 0 ? { frame: entry.payload.mediaFrame } : {},
2574
+ owners: [...entry.payload.subject.eventId !== void 0 ? [`event:${entry.payload.subject.eventId}`] : [], ...entry.payload.subject.trackId !== void 0 ? [`track:${entry.payload.subject.trackId}`] : []]
2575
+ }
2576
+ });
2396
2577
  const wantGif = entry.payload.mediaGif === true;
2397
2578
  const wantMp4 = entry.payload.mediaClip === true;
2398
2579
  if ((wantGif || wantMp4) && this.deps.renderEventMedia !== void 0) try {
@@ -2601,6 +2782,123 @@ function copyBytes(bytes) {
2601
2782
  return out;
2602
2783
  }
2603
2784
  //#endregion
2785
+ //#region src/notification-center/event-intake.ts
2786
+ /** Object-event insert (movement-state transitions + zone crossings). */
2787
+ function incomingFromObjectEvent(event) {
2788
+ return {
2789
+ subject: subjectFromObjectEvent(event),
2790
+ kind: "object-event",
2791
+ origin: "pipeline",
2792
+ log: () => ({
2793
+ tags: { deviceId: event.deviceId },
2794
+ meta: { eventId: event.id }
2795
+ })
2796
+ };
2797
+ }
2798
+ /** `TrackCloser.closeExpired` — the ONE close point. */
2799
+ function incomingFromTrack(track, info) {
2800
+ return {
2801
+ subject: subjectFromTrack(track, info),
2802
+ kind: "track-end",
2803
+ origin: "pipeline",
2804
+ log: () => ({
2805
+ tags: { deviceId: track.deviceId },
2806
+ meta: { trackId: track.trackId }
2807
+ })
2808
+ };
2809
+ }
2810
+ /**
2811
+ * Sensor-event persist (one row per linked camera).
2812
+ *
2813
+ * `markerTrackId` is the synthetic marker track the same state change
2814
+ * projected onto this camera — the notification's media owner, and the only
2815
+ * frame a device event has. Absent when no marker landed.
2816
+ */
2817
+ function incomingFromSensorEvent(event, markerTrackId) {
2818
+ return {
2819
+ subject: subjectFromSensorEvent(event, markerTrackId),
2820
+ kind: "device-event",
2821
+ origin: "sensor",
2822
+ log: () => ({
2823
+ tags: { deviceId: event.deviceId },
2824
+ meta: {
2825
+ sensorEventId: event.id,
2826
+ kind: event.kind,
2827
+ ...markerTrackId !== void 0 ? { trackId: markerTrackId } : {}
2828
+ }
2829
+ })
2830
+ };
2831
+ }
2832
+ /** Audio-event persist (classified episode). */
2833
+ function incomingFromAudioEvent(event) {
2834
+ return {
2835
+ subject: subjectFromAudioEvent(event),
2836
+ kind: "audio-event",
2837
+ origin: "pipeline",
2838
+ log: () => ({
2839
+ tags: { deviceId: event.deviceId },
2840
+ meta: {
2841
+ audioEventId: event.id,
2842
+ class: event.classification?.className
2843
+ }
2844
+ })
2845
+ };
2846
+ }
2847
+ /** Package object-event persist (delivered / picked-up). */
2848
+ function incomingFromPackageEvent(event, phase) {
2849
+ return {
2850
+ subject: subjectFromPackageEvent(event, phase),
2851
+ kind: "package-event",
2852
+ origin: "pipeline",
2853
+ log: () => ({
2854
+ tags: { deviceId: event.deviceId },
2855
+ meta: {
2856
+ eventId: event.id,
2857
+ phase
2858
+ }
2859
+ })
2860
+ };
2861
+ }
2862
+ /** A committed ZoneAnalytics occupancy edge. */
2863
+ function incomingFromOccupancyEdge(edge) {
2864
+ return {
2865
+ subject: subjectFromOccupancyEvent(edge),
2866
+ kind: "occupancy-event",
2867
+ origin: "pipeline",
2868
+ log: () => ({
2869
+ tags: { deviceId: edge.deviceId },
2870
+ meta: {
2871
+ zoneId: edge.zoneId ?? "@frame",
2872
+ className: edge.className ?? "@all",
2873
+ count: edge.count,
2874
+ threshold: edge.threshold,
2875
+ occupied: edge.occupied
2876
+ }
2877
+ })
2878
+ };
2879
+ }
2880
+ /** Re-stamp a live-producer envelope as a boot-reconcile replay of the same
2881
+ * record — same subject, honest origin in the log. */
2882
+ function asReconcile(event) {
2883
+ return {
2884
+ ...event,
2885
+ origin: "reconcile"
2886
+ };
2887
+ }
2888
+ /**
2889
+ * The delivery an intake kind rides. `object-event` and `audio-event` both ride
2890
+ * `immediate` (classified audio has no delivery of its own — it is gated by the
2891
+ * audio-class opt-in instead); `occupancy-event` rides `device-event`.
2892
+ *
2893
+ * Extracted from the centre so the tester can tell an operator which rules a
2894
+ * given simulated trigger will even be considered against, without evaluating.
2895
+ */
2896
+ function deliveryForKind(kind) {
2897
+ if (kind === "object-event" || kind === "audio-event") return "immediate";
2898
+ if (kind === "occupancy-event") return "device-event";
2899
+ return kind;
2900
+ }
2901
+ //#endregion
2604
2902
  //#region src/notification-center/occupancy-watcher.ts
2605
2903
  /** Sentinel key segments for the "no zone" (whole-frame) and "no class" scopes. */
2606
2904
  var FRAME_SCOPE = "@frame";
@@ -3456,468 +3754,6 @@ function rowToEntry$1(id, data) {
3456
3754
  };
3457
3755
  }
3458
3756
  //#endregion
3459
- //#region src/notification-center/event-intake.ts
3460
- /** Object-event insert (movement-state transitions + zone crossings). */
3461
- function incomingFromObjectEvent(event) {
3462
- return {
3463
- subject: subjectFromObjectEvent(event),
3464
- kind: "object-event",
3465
- origin: "pipeline",
3466
- log: () => ({
3467
- tags: { deviceId: event.deviceId },
3468
- meta: { eventId: event.id }
3469
- })
3470
- };
3471
- }
3472
- /** `TrackCloser.closeExpired` — the ONE close point. */
3473
- function incomingFromTrack(track, info) {
3474
- return {
3475
- subject: subjectFromTrack(track, info),
3476
- kind: "track-end",
3477
- origin: "pipeline",
3478
- log: () => ({
3479
- tags: { deviceId: track.deviceId },
3480
- meta: { trackId: track.trackId }
3481
- })
3482
- };
3483
- }
3484
- /** Sensor-event persist (one row per linked camera). */
3485
- function incomingFromSensorEvent(event) {
3486
- return {
3487
- subject: subjectFromSensorEvent(event),
3488
- kind: "device-event",
3489
- origin: "sensor",
3490
- log: () => ({
3491
- tags: { deviceId: event.deviceId },
3492
- meta: {
3493
- sensorEventId: event.id,
3494
- kind: event.kind
3495
- }
3496
- })
3497
- };
3498
- }
3499
- /** Audio-event persist (classified episode). */
3500
- function incomingFromAudioEvent(event) {
3501
- return {
3502
- subject: subjectFromAudioEvent(event),
3503
- kind: "audio-event",
3504
- origin: "pipeline",
3505
- log: () => ({
3506
- tags: { deviceId: event.deviceId },
3507
- meta: {
3508
- audioEventId: event.id,
3509
- class: event.classification?.className
3510
- }
3511
- })
3512
- };
3513
- }
3514
- /** Package object-event persist (delivered / picked-up). */
3515
- function incomingFromPackageEvent(event, phase) {
3516
- return {
3517
- subject: subjectFromPackageEvent(event, phase),
3518
- kind: "package-event",
3519
- origin: "pipeline",
3520
- log: () => ({
3521
- tags: { deviceId: event.deviceId },
3522
- meta: {
3523
- eventId: event.id,
3524
- phase
3525
- }
3526
- })
3527
- };
3528
- }
3529
- /** A committed ZoneAnalytics occupancy edge. */
3530
- function incomingFromOccupancyEdge(edge) {
3531
- return {
3532
- subject: subjectFromOccupancyEvent(edge),
3533
- kind: "occupancy-event",
3534
- origin: "pipeline",
3535
- log: () => ({
3536
- tags: { deviceId: edge.deviceId },
3537
- meta: {
3538
- zoneId: edge.zoneId ?? "@frame",
3539
- className: edge.className ?? "@all",
3540
- count: edge.count,
3541
- threshold: edge.threshold,
3542
- occupied: edge.occupied
3543
- }
3544
- })
3545
- };
3546
- }
3547
- /** Re-stamp a live-producer envelope as a boot-reconcile replay of the same
3548
- * record — same subject, honest origin in the log. */
3549
- function asReconcile(event) {
3550
- return {
3551
- ...event,
3552
- origin: "reconcile"
3553
- };
3554
- }
3555
- /**
3556
- * The delivery an intake kind rides. `object-event` and `audio-event` both ride
3557
- * `immediate` (classified audio has no delivery of its own — it is gated by the
3558
- * audio-class opt-in instead); `occupancy-event` rides `device-event`.
3559
- *
3560
- * Extracted from the centre so the tester can tell an operator which rules a
3561
- * given simulated trigger will even be considered against, without evaluating.
3562
- */
3563
- function deliveryForKind(kind) {
3564
- if (kind === "object-event" || kind === "audio-event") return "immediate";
3565
- if (kind === "occupancy-event") return "device-event";
3566
- return kind;
3567
- }
3568
- //#endregion
3569
- //#region src/notification-center/test-event.ts
3570
- /** Normalized 0..1 rectangle, top-left origin — the subject bbox space. */
3571
- var BboxSchema = require_dist.object({
3572
- x: require_dist.number().min(0).max(1),
3573
- y: require_dist.number().min(0).max(1),
3574
- w: require_dist.number().min(0).max(1),
3575
- h: require_dist.number().min(0).max(1)
3576
- });
3577
- /**
3578
- * Which trigger to simulate. One value per {@link NcSubjectKind}, because the
3579
- * kind is what decides the delivery a rule must declare to be a candidate at
3580
- * all — picking the wrong one is the single most common reason an operator's
3581
- * rule "does not fire", and making it an explicit choice is half the point of
3582
- * the tester.
3583
- */
3584
- var TriggerSchema = require_dist._enum([
3585
- "object-event",
3586
- "track-end",
3587
- "device-event",
3588
- "package-event",
3589
- "audio-event",
3590
- "occupancy-event"
3591
- ]);
3592
- /**
3593
- * What the operator typed. Every field maps onto exactly one field of
3594
- * {@link NcRuleSubject} — there is no test-only condition input, because a
3595
- * condition the tester could set and a real record could not is a condition the
3596
- * test result would lie about.
3597
- */
3598
- var NcTestEventInputSchema = require_dist.object({
3599
- trigger: TriggerSchema,
3600
- /** The CAMERA the event is attributed to (what a notification shows). */
3601
- deviceId: require_dist.number().int(),
3602
- /** DEVICE-EVENT: the linked device whose state changed — the contact, the
3603
- * button, the doorbell — when that is not the camera. */
3604
- sourceDeviceId: require_dist.number().int().optional(),
3605
- /** Detection class ids, from the NC taxonomy (`person`, `car`, `audio-…`). */
3606
- classNames: require_dist.array(require_dist.string()).default([]),
3607
- /** Collapsed display label — an identity name, a plate, a subclass. */
3608
- label: require_dist.string().optional(),
3609
- /** Detection confidence [0,1]. ABSENT is meaningful: a `minConfidence`
3610
- * condition FAILS on an absent confidence, and the tester must be able to
3611
- * reproduce that. */
3612
- confidence: require_dist.number().min(0).max(1).optional(),
3613
- /** Identity/plate MATCH confidence — distinct from `confidence`. */
3614
- labelConfidence: require_dist.number().min(0).max(1).optional(),
3615
- /** Key-event importance [0,1] (track-end only, in practice). */
3616
- importance: require_dist.number().min(0).max(1).optional(),
3617
- /** Track lifespan in seconds (track-end only). */
3618
- dwellSeconds: require_dist.number().min(0).optional(),
3619
- /** Admin zone ids the subject is in (object) / visited (track). */
3620
- zones: require_dist.array(require_dist.string()).default([]),
3621
- /** OBJECT-EVENT: simulate a confirmed zone crossing. */
3622
- crossing: require_dist.object({
3623
- zoneId: require_dist.string(),
3624
- direction: require_dist._enum(["enter", "exit"])
3625
- }).optional(),
3626
- /** DEVICE-EVENT: the taxonomy kind id (`doorbell`, `contact`, `button`, …). */
3627
- sensorKind: require_dist.string().optional(),
3628
- /** DEVICE-EVENT: the raw device event-type token, when the source is an
3629
- * event-emitter slice. */
3630
- eventType: require_dist.string().optional(),
3631
- /** PACKAGE-EVENT: which lifecycle phase. */
3632
- packagePhase: require_dist._enum(["delivered", "picked-up"]).optional(),
3633
- /** Detection provenance. Absent = `pipeline` (what a record with no stamped
3634
- * source reads as). */
3635
- source: require_dist._enum([
3636
- "pipeline",
3637
- "onboard",
3638
- "sensor",
3639
- "audio"
3640
- ]).optional(),
3641
- /** Normalized bbox — feeds `customZones`, AND is what lets the still be
3642
- * cropped / boxed the way the rule's media policy asks. */
3643
- bbox: BboxSchema.optional(),
3644
- /** OCCUPANCY-EVENT: the committed count edge. */
3645
- occupancy: require_dist.object({
3646
- zoneId: require_dist.string().optional(),
3647
- zoneName: require_dist.string().optional(),
3648
- className: require_dist.string().optional(),
3649
- count: require_dist.number().int().min(0),
3650
- previousCount: require_dist.number().int().min(0).default(0),
3651
- occupied: require_dist.boolean(),
3652
- threshold: require_dist.number().int().min(1)
3653
- }).optional(),
3654
- /** The instant the event happened. Absent = now. Set it to test a schedule
3655
- * condition without waiting for the hour. */
3656
- timestamp: require_dist.number().int().optional(),
3657
- /** Evaluate only this rule (what the per-rule Test button sends). */
3658
- onlyRuleId: require_dist.string().optional(),
3659
- bypassCooldown: require_dist.boolean().default(false),
3660
- bypassMute: require_dist.boolean().default(false),
3661
- /** Run the matched rule's `onTrigger` sequences. OFF by default: a test must
3662
- * not open a gate the operator did not ask to open. */
3663
- runActions: require_dist.boolean().default(false),
3664
- /** Where the still comes from. `live` takes a snapshot of the camera NOW;
3665
- * `none` sends the notification without one. */
3666
- still: require_dist._enum(["live", "none"]).default("live")
3667
- });
3668
- /** Per-rule verdict, as the tester reports it. */
3669
- var NcTestEventRuleOutcomeSchema = require_dist.object({
3670
- ruleId: require_dist.string(),
3671
- ruleName: require_dist.string(),
3672
- matched: require_dist.boolean(),
3673
- failedCondition: require_dist.string().optional(),
3674
- matchedOn: require_dist.array(require_dist.string()).optional(),
3675
- cooldown: require_dist._enum([
3676
- "n/a",
3677
- "clear",
3678
- "suppressed",
3679
- "bypassed"
3680
- ]),
3681
- enqueued: require_dist.number().int(),
3682
- targetIds: require_dist.array(require_dist.string())
3683
- });
3684
- var NcTestEventResultSchema = require_dist.object({
3685
- /** The synthetic record id. It is `synthetic:<uuid>` and it is what to grep
3686
- * for in the logs, in the outbox and in the delivery history. */
3687
- recordId: require_dist.string(),
3688
- /** Always `synthetic` — the marker the whole path carried. */
3689
- origin: require_dist.literal("synthetic"),
3690
- /** The delivery the chosen trigger rides. A rule declaring anything else was
3691
- * never a candidate, which is the answer to most "it did not fire". */
3692
- delivery: require_dist.string(),
3693
- candidates: require_dist.number().int(),
3694
- outcomes: require_dist.array(NcTestEventRuleOutcomeSchema),
3695
- /** What the still ended up being. `unattachable` means the chosen TRIGGER
3696
- * cannot carry one at all — a device-event notification has no media owner,
3697
- * live or synthetic — and that is a property of production, not of the test. */
3698
- still: require_dist._enum([
3699
- "live",
3700
- "none",
3701
- "unavailable",
3702
- "unattachable"
3703
- ]),
3704
- /** Everything the operator has to know to read the result honestly. */
3705
- notes: require_dist.array(require_dist.string()),
3706
- droppedBy: require_dist.string().optional()
3707
- });
3708
- /** The id shape every synthetic record carries. One prefix, everywhere. */
3709
- var SYNTHETIC_ID_PREFIX = "synthetic:";
3710
- /** True for a record id this module minted. Used by the media shim. */
3711
- function isSyntheticId(id) {
3712
- return id.startsWith(SYNTHETIC_ID_PREFIX);
3713
- }
3714
- /**
3715
- * Whether a subject of this kind can carry a still AT ALL, in production.
3716
- *
3717
- * The attachment ladder resolves media by OWNER, and `buildEntries` freezes an
3718
- * owner onto the payload only for the kinds that have one: an `eventId` for
3719
- * object/package events, a `trackId` for track-scoped ones. A device-event, an
3720
- * audio episode and an occupancy edge freeze neither — so those notifications
3721
- * ship text-only today, and a tester that attached an image to them would be
3722
- * advertising a feature that does not exist.
3723
- */
3724
- function triggerCanCarryStill(kind) {
3725
- return kind === "object-event" || kind === "package-event" || kind === "track-end";
3726
- }
3727
- /**
3728
- * Build the synthetic envelope. Pure: same input + same id + same clock ⇒ same
3729
- * event, so the red-green test can assert that this subject and a
3730
- * producer-built one are indistinguishable.
3731
- */
3732
- function buildSyntheticEvent(input, recordId, now) {
3733
- const kind = input.trigger;
3734
- const timestamp = input.timestamp ?? now;
3735
- const trackScoped = kind === "object-event" || kind === "track-end";
3736
- return {
3737
- subject: {
3738
- kind,
3739
- recordId,
3740
- deviceId: input.deviceId,
3741
- ...input.sourceDeviceId !== void 0 && input.sourceDeviceId !== input.deviceId ? { sourceDeviceId: input.sourceDeviceId } : {},
3742
- timestamp,
3743
- classNames: input.classNames,
3744
- ...input.label !== void 0 ? { label: input.label } : {},
3745
- ...input.confidence !== void 0 ? { confidence: input.confidence } : {},
3746
- zones: input.zones,
3747
- ...trackScoped ? { trackId: recordId } : {},
3748
- source: input.source ?? (kind === "device-event" ? "sensor" : "pipeline"),
3749
- ...input.importance !== void 0 ? { importance: input.importance } : {},
3750
- ...input.dwellSeconds !== void 0 ? { dwellSeconds: input.dwellSeconds } : {},
3751
- ...input.labelConfidence !== void 0 ? { labelConfidence: input.labelConfidence } : {},
3752
- ...input.sensorKind !== void 0 ? { sensorKind: input.sensorKind } : {},
3753
- ...input.eventType !== void 0 ? { eventType: input.eventType } : {},
3754
- ...input.packagePhase !== void 0 ? { packagePhase: input.packagePhase } : {},
3755
- ...input.bbox !== void 0 ? { bbox: input.bbox } : {},
3756
- ...input.crossing !== void 0 ? { crossing: input.crossing } : {},
3757
- ...input.occupancy !== void 0 ? { occupancy: {
3758
- ...input.occupancy.zoneId !== void 0 ? { zoneId: input.occupancy.zoneId } : {},
3759
- ...input.occupancy.zoneName !== void 0 ? { zoneName: input.occupancy.zoneName } : {},
3760
- ...input.occupancy.className !== void 0 ? { className: input.occupancy.className } : {},
3761
- count: input.occupancy.count,
3762
- previousCount: input.occupancy.previousCount,
3763
- occupied: input.occupancy.occupied,
3764
- threshold: input.occupancy.threshold
3765
- } } : {}
3766
- },
3767
- kind,
3768
- origin: "synthetic",
3769
- controls: {
3770
- ...input.onlyRuleId !== void 0 ? { onlyRuleId: input.onlyRuleId } : {},
3771
- bypassCooldown: input.bypassCooldown,
3772
- bypassMute: input.bypassMute,
3773
- runActions: input.runActions
3774
- },
3775
- log: () => ({
3776
- tags: { deviceId: input.deviceId },
3777
- meta: {
3778
- eventId: recordId,
3779
- synthetic: true,
3780
- trigger: input.trigger,
3781
- ...input.onlyRuleId !== void 0 ? { onlyRuleId: input.onlyRuleId } : {}
3782
- }
3783
- })
3784
- };
3785
- }
3786
- /**
3787
- * Turn one live JPEG into the media set the dispatcher's ladders look for.
3788
- *
3789
- * Every entry is the SAME photograph, differing only in how it was cut — which
3790
- * is exactly what the ladders mean by their kind names, so nothing here claims
3791
- * a frame it does not have:
3792
- *
3793
- * - `fullFrame` / `keyFrame` / `firstFrame` — the clean scene, verbatim.
3794
- * - `fullFrameBoxed` — the clean scene with the operator's bbox drawn on it.
3795
- * - `crop` / `thumbnail` — that bbox, cut out.
3796
- *
3797
- * With NO bbox only the clean-scene kinds are produced, so a rule asking for
3798
- * `frame: 'cropped'` correctly finds nothing rather than being handed a whole
3799
- * scene it did not ask for. sharp failures degrade the same way: the clean
3800
- * scene always survives.
3801
- */
3802
- async function deriveSyntheticMedia(jpeg, bbox, timestamp) {
3803
- const base64 = Buffer.from(jpeg).toString("base64");
3804
- const clean = (kind) => ({
3805
- key: `${SYNTHETIC_ID_PREFIX}${kind}`,
3806
- kind,
3807
- base64,
3808
- sizeBytes: jpeg.byteLength,
3809
- timestamp
3810
- });
3811
- const files = [
3812
- clean("fullFrame"),
3813
- clean("keyFrame"),
3814
- clean("firstFrame")
3815
- ];
3816
- if (bbox === void 0) return files;
3817
- try {
3818
- const { default: sharp$7 } = await import("sharp");
3819
- const meta = await sharp$7(Buffer.from(jpeg)).metadata();
3820
- const W = meta.width ?? 0;
3821
- const H = meta.height ?? 0;
3822
- if (W === 0 || H === 0) return files;
3823
- const left = Math.max(0, Math.min(W - 1, Math.floor(bbox.x * W)));
3824
- const top = Math.max(0, Math.min(H - 1, Math.floor(bbox.y * H)));
3825
- const width = Math.max(1, Math.min(W - left, Math.ceil(bbox.w * W)));
3826
- const height = Math.max(1, Math.min(H - top, Math.ceil(bbox.h * H)));
3827
- const cropBuf = await sharp$7(Buffer.from(jpeg)).extract({
3828
- left,
3829
- top,
3830
- width,
3831
- height
3832
- }).jpeg({ quality: 85 }).toBuffer();
3833
- const cropB64 = cropBuf.toString("base64");
3834
- files.push({
3835
- key: `${SYNTHETIC_ID_PREFIX}crop`,
3836
- kind: "crop",
3837
- base64: cropB64,
3838
- sizeBytes: cropBuf.byteLength,
3839
- timestamp
3840
- }, {
3841
- key: `${SYNTHETIC_ID_PREFIX}thumbnail`,
3842
- kind: "thumbnail",
3843
- base64: cropB64,
3844
- sizeBytes: cropBuf.byteLength,
3845
- timestamp
3846
- });
3847
- const stroke = Math.max(2, Math.round(Math.min(W, H) / 240));
3848
- const overlay = Buffer.from(`<svg width="${W}" height="${H}"><rect x="${left}" y="${top}" width="${width}" height="${height}" fill="none" stroke="#22d3ee" stroke-width="${stroke}"/></svg>`);
3849
- const boxedBuf = await sharp$7(Buffer.from(jpeg)).composite([{
3850
- input: overlay,
3851
- top: 0,
3852
- left: 0
3853
- }]).jpeg({ quality: 85 }).toBuffer();
3854
- files.push({
3855
- key: `${SYNTHETIC_ID_PREFIX}fullFrameBoxed`,
3856
- kind: "fullFrameBoxed",
3857
- base64: boxedBuf.toString("base64"),
3858
- sizeBytes: boxedBuf.byteLength,
3859
- timestamp
3860
- });
3861
- } catch {
3862
- return files;
3863
- }
3864
- return files;
3865
- }
3866
- //#endregion
3867
- //#region src/notification-center/action-buttons.ts
3868
- /** Sequence names the rule actually declares. A button may only name one. */
3869
- function declaredSequences(actions) {
3870
- const names = /* @__PURE__ */ new Set();
3871
- for (const sequence of actions.onTrigger ?? []) names.add(sequence.name);
3872
- return names;
3873
- }
3874
- /**
3875
- * Build the notification's buttons.
3876
- *
3877
- * Returns an empty array rather than `undefined` — the caller decides whether
3878
- * to set the field, and an empty array is the honest answer to "which buttons
3879
- * survived", which `undefined` would conflate with "the rule declared none".
3880
- */
3881
- function buildActionButtons(input) {
3882
- const actions = input.actions;
3883
- if (actions === void 0) return [];
3884
- const buttons = actions.buttons ?? [];
3885
- if (buttons.length === 0) return [];
3886
- const known = declaredSequences(actions);
3887
- const seen = /* @__PURE__ */ new Set();
3888
- const out = [];
3889
- for (const button of buttons) {
3890
- if (!known.has(button.sequence)) continue;
3891
- if (seen.has(button.id)) continue;
3892
- seen.add(button.id);
3893
- out.push({
3894
- id: button.id,
3895
- label: button.label,
3896
- url: input.mintUrl({
3897
- sequence: button.sequence,
3898
- actionId: button.id
3899
- }),
3900
- ...button.icon !== void 0 ? { icon: button.icon } : {},
3901
- ...button.destructive !== void 0 ? { destructive: button.destructive } : {}
3902
- });
3903
- }
3904
- return out;
3905
- }
3906
- /**
3907
- * Which of a rule's buttons name a sequence it does not have.
3908
- *
3909
- * Exported so the caller can LOG the drop. A button silently missing from a
3910
- * notification is the exact shape of failure this repo keeps paying for — the
3911
- * operator authored it, it never appeared, and nothing said why.
3912
- */
3913
- function unresolvableButtons(actions) {
3914
- if (actions === void 0) return [];
3915
- const buttons = actions.buttons ?? [];
3916
- if (buttons.length === 0) return [];
3917
- const known = declaredSequences(actions);
3918
- return buttons.filter((b) => !known.has(b.sequence)).map((b) => `${b.id}→${b.sequence}`);
3919
- }
3920
- //#endregion
3921
3757
  //#region src/notification-center/rule-actions.ts
3922
3758
  var NcRuleActionRunner = class {
3923
3759
  deps;
@@ -4230,53 +4066,6 @@ var NcRuleStore = class {
4230
4066
  });
4231
4067
  }
4232
4068
  };
4233
- var NcDeviceStateCache = class {
4234
- deps;
4235
- states = /* @__PURE__ */ new Map();
4236
- lastOkAt = 0;
4237
- ids = [];
4238
- now;
4239
- constructor(deps) {
4240
- this.deps = deps;
4241
- this.now = deps.now ?? (() => Date.now());
4242
- }
4243
- /**
4244
- * The device ids worth reading — recomputed from the rule set, so a cluster
4245
- * with no gated rule does no work at all.
4246
- */
4247
- setWatched(ids) {
4248
- this.ids = [...new Set(ids)].sort((a, b) => a - b);
4249
- }
4250
- watched() {
4251
- return this.ids;
4252
- }
4253
- /** Hot-path read. No I/O, no await — this is called per event. */
4254
- get(deviceId) {
4255
- if (this.now() - this.lastOkAt > 3e5) return void 0;
4256
- return this.states.get(deviceId);
4257
- }
4258
- /**
4259
- * Re-read every watched state. Never throws: a gate that could fail the
4260
- * evaluation tick would take the notification pipeline with it.
4261
- */
4262
- async refresh() {
4263
- if (this.ids.length === 0) {
4264
- this.states = /* @__PURE__ */ new Map();
4265
- return;
4266
- }
4267
- try {
4268
- const next = await this.deps.readStates(this.ids);
4269
- this.states = new Map(next);
4270
- this.lastOkAt = this.now();
4271
- for (const id of this.ids) if (!next.has(id)) this.deps.logger.info("device-state gate: no state for a watched device", { tags: { deviceId: id } });
4272
- } catch (err) {
4273
- this.deps.logger.warn("device-state refresh failed — keeping the previous states until they age out", { meta: {
4274
- watched: this.ids.length,
4275
- error: String(err)
4276
- } });
4277
- }
4278
- }
4279
- };
4280
4069
  //#endregion
4281
4070
  //#region src/notification-center/snooze-digest.ts
4282
4071
  /** `14:03` in the node's local zone — a digest is read by a person, at home. */
@@ -4393,119 +4182,6 @@ function dueSummaries(entries, nowMs, alreadySent) {
4393
4182
  return entries.filter((e) => e.summary && nowMs >= e.expiresAt && !alreadySent.has(e.id));
4394
4183
  }
4395
4184
  //#endregion
4396
- //#region src/notification-center/device-mute-store.ts
4397
- var NC_DEVICE_MUTES_COLLECTION = "notification-center:device-mutes";
4398
- var NC_DEVICE_MUTES_COLUMNS = [
4399
- (
4400
- /** `String(deviceId)` — one row per muted camera; unmute DELETES the row. */
4401
- {
4402
- name: "id",
4403
- type: "TEXT",
4404
- primaryKey: true,
4405
- notNull: true
4406
- }),
4407
- {
4408
- name: "deviceId",
4409
- type: "INTEGER",
4410
- notNull: true
4411
- },
4412
- (
4413
- /** When an admin muted it — the only thing an audit ever wants to know. */
4414
- {
4415
- name: "mutedAt",
4416
- type: "INTEGER",
4417
- notNull: true
4418
- })
4419
- ];
4420
- /**
4421
- * Absence of a row is "not muted"; there is no `muted: false` row. That keeps
4422
- * the table proportional to what the operator actually silenced, and makes the
4423
- * hot-path read a `Set.has`.
4424
- */
4425
- var NcDeviceMuteStore = class {
4426
- muted = /* @__PURE__ */ new Set();
4427
- store;
4428
- logger;
4429
- now;
4430
- constructor(deps) {
4431
- this.store = deps.store;
4432
- this.logger = deps.logger;
4433
- this.now = deps.now ?? (() => Date.now());
4434
- }
4435
- static async declare(store) {
4436
- await store.declareCollection.mutate({
4437
- collection: NC_DEVICE_MUTES_COLLECTION,
4438
- columns: [...NC_DEVICE_MUTES_COLUMNS]
4439
- });
4440
- }
4441
- /**
4442
- * (Re)hydrate. A failed load KEEPS what is already in memory rather than
4443
- * clearing it: dropping every mute on a transient store error would start
4444
- * notifying for a camera the operator silenced, without them asking — the
4445
- * same reasoning as `NcSnoozeStore.load`.
4446
- */
4447
- async load() {
4448
- try {
4449
- const rows = await this.store.query.query({
4450
- collection: NC_DEVICE_MUTES_COLLECTION,
4451
- filter: { limit: 1e4 }
4452
- });
4453
- const next = /* @__PURE__ */ new Set();
4454
- let skipped = 0;
4455
- for (const row of rows) {
4456
- const raw = row.data["deviceId"];
4457
- if (typeof raw !== "number" || !Number.isInteger(raw)) {
4458
- skipped += 1;
4459
- continue;
4460
- }
4461
- next.add(raw);
4462
- }
4463
- this.muted.clear();
4464
- for (const id of next) this.muted.add(id);
4465
- if (skipped > 0) this.logger.warn("device-mute rows skipped as malformed — they silence NOTHING", { meta: { skipped } });
4466
- } catch (err) {
4467
- this.logger.warn("device-mute load failed — keeping the mutes already in memory", { meta: {
4468
- error: String(err),
4469
- muted: this.muted.size
4470
- } });
4471
- }
4472
- }
4473
- /** Hot-path read, called once per evaluated subject. */
4474
- isMuted(deviceId) {
4475
- return this.muted.has(deviceId);
4476
- }
4477
- /** Every muted camera, ascending — a stable order so two callers agree. */
4478
- listMuted() {
4479
- return [...this.muted].toSorted((a, b) => a - b);
4480
- }
4481
- /**
4482
- * Mute or unmute. Idempotent in both directions; the in-memory set is
4483
- * updated only after the write lands, so a failed write cannot leave the
4484
- * hot path believing a camera is silenced when the next boot will not.
4485
- */
4486
- async setMuted(deviceId, muted) {
4487
- const id = String(deviceId);
4488
- if (muted) {
4489
- await this.store.set.mutate({
4490
- collection: NC_DEVICE_MUTES_COLLECTION,
4491
- key: id,
4492
- value: {
4493
- id,
4494
- deviceId,
4495
- mutedAt: this.now()
4496
- }
4497
- });
4498
- this.muted.add(deviceId);
4499
- } else {
4500
- await this.store.delete.mutate({
4501
- collection: NC_DEVICE_MUTES_COLLECTION,
4502
- key: id
4503
- });
4504
- this.muted.delete(deviceId);
4505
- }
4506
- }
4507
- };
4508
- //#endregion
4509
4185
  //#region src/notification-center/snooze-store.ts
4510
4186
  /**
4511
4187
  * NcSnoozeStore — the durable half of snooze.
@@ -4859,6 +4535,314 @@ var NcSnoozeStore = class {
4859
4535
  }
4860
4536
  };
4861
4537
  //#endregion
4538
+ //#region src/notification-center/test-event.ts
4539
+ /** Normalized 0..1 rectangle, top-left origin — the subject bbox space. */
4540
+ var BboxSchema = require_dist.object({
4541
+ x: require_dist.number().min(0).max(1),
4542
+ y: require_dist.number().min(0).max(1),
4543
+ w: require_dist.number().min(0).max(1),
4544
+ h: require_dist.number().min(0).max(1)
4545
+ });
4546
+ /**
4547
+ * Which trigger to simulate. One value per {@link NcSubjectKind}, because the
4548
+ * kind is what decides the delivery a rule must declare to be a candidate at
4549
+ * all — picking the wrong one is the single most common reason an operator's
4550
+ * rule "does not fire", and making it an explicit choice is half the point of
4551
+ * the tester.
4552
+ */
4553
+ var TriggerSchema = require_dist._enum([
4554
+ "object-event",
4555
+ "track-end",
4556
+ "device-event",
4557
+ "package-event",
4558
+ "audio-event",
4559
+ "occupancy-event"
4560
+ ]);
4561
+ /**
4562
+ * What the operator typed. Every field maps onto exactly one field of
4563
+ * {@link NcRuleSubject} — there is no test-only condition input, because a
4564
+ * condition the tester could set and a real record could not is a condition the
4565
+ * test result would lie about.
4566
+ */
4567
+ var NcTestEventInputSchema = require_dist.object({
4568
+ trigger: TriggerSchema,
4569
+ /** The CAMERA the event is attributed to (what a notification shows). */
4570
+ deviceId: require_dist.number().int(),
4571
+ /** DEVICE-EVENT: the linked device whose state changed — the contact, the
4572
+ * button, the doorbell — when that is not the camera. */
4573
+ sourceDeviceId: require_dist.number().int().optional(),
4574
+ /** Detection class ids, from the NC taxonomy (`person`, `car`, `audio-…`). */
4575
+ classNames: require_dist.array(require_dist.string()).default([]),
4576
+ /** Collapsed display label — an identity name, a plate, a subclass. */
4577
+ label: require_dist.string().optional(),
4578
+ /** Detection confidence [0,1]. ABSENT is meaningful: a `minConfidence`
4579
+ * condition FAILS on an absent confidence, and the tester must be able to
4580
+ * reproduce that. */
4581
+ confidence: require_dist.number().min(0).max(1).optional(),
4582
+ /** Identity/plate MATCH confidence — distinct from `confidence`. */
4583
+ labelConfidence: require_dist.number().min(0).max(1).optional(),
4584
+ /** Key-event importance [0,1] (track-end only, in practice). */
4585
+ importance: require_dist.number().min(0).max(1).optional(),
4586
+ /** Track lifespan in seconds (track-end only). */
4587
+ dwellSeconds: require_dist.number().min(0).optional(),
4588
+ /** Admin zone ids the subject is in (object) / visited (track). */
4589
+ zones: require_dist.array(require_dist.string()).default([]),
4590
+ /** OBJECT-EVENT: simulate a confirmed zone crossing. */
4591
+ crossing: require_dist.object({
4592
+ zoneId: require_dist.string(),
4593
+ direction: require_dist._enum(["enter", "exit"])
4594
+ }).optional(),
4595
+ /** DEVICE-EVENT: the taxonomy kind id (`doorbell`, `contact`, `button`, …). */
4596
+ sensorKind: require_dist.string().optional(),
4597
+ /** DEVICE-EVENT: the raw device event-type token, when the source is an
4598
+ * event-emitter slice. */
4599
+ eventType: require_dist.string().optional(),
4600
+ /** PACKAGE-EVENT: which lifecycle phase. */
4601
+ packagePhase: require_dist._enum(["delivered", "picked-up"]).optional(),
4602
+ /** Detection provenance. Absent = `pipeline` (what a record with no stamped
4603
+ * source reads as). */
4604
+ source: require_dist._enum([
4605
+ "pipeline",
4606
+ "onboard",
4607
+ "sensor",
4608
+ "audio"
4609
+ ]).optional(),
4610
+ /** Normalized bbox — feeds `customZones`, AND is what lets the still be
4611
+ * cropped / boxed the way the rule's media policy asks. */
4612
+ bbox: BboxSchema.optional(),
4613
+ /** OCCUPANCY-EVENT: the committed count edge. */
4614
+ occupancy: require_dist.object({
4615
+ zoneId: require_dist.string().optional(),
4616
+ zoneName: require_dist.string().optional(),
4617
+ className: require_dist.string().optional(),
4618
+ count: require_dist.number().int().min(0),
4619
+ previousCount: require_dist.number().int().min(0).default(0),
4620
+ occupied: require_dist.boolean(),
4621
+ threshold: require_dist.number().int().min(1)
4622
+ }).optional(),
4623
+ /** The instant the event happened. Absent = now. Set it to test a schedule
4624
+ * condition without waiting for the hour. */
4625
+ timestamp: require_dist.number().int().optional(),
4626
+ /** Evaluate only this rule (what the per-rule Test button sends). */
4627
+ onlyRuleId: require_dist.string().optional(),
4628
+ bypassCooldown: require_dist.boolean().default(false),
4629
+ bypassMute: require_dist.boolean().default(false),
4630
+ /** Run the matched rule's `onTrigger` sequences. OFF by default: a test must
4631
+ * not open a gate the operator did not ask to open. */
4632
+ runActions: require_dist.boolean().default(false),
4633
+ /** Where the still comes from. `live` takes a snapshot of the camera NOW;
4634
+ * `none` sends the notification without one. */
4635
+ still: require_dist._enum(["live", "none"]).default("live")
4636
+ });
4637
+ /** Per-rule verdict, as the tester reports it. */
4638
+ var NcTestEventRuleOutcomeSchema = require_dist.object({
4639
+ ruleId: require_dist.string(),
4640
+ ruleName: require_dist.string(),
4641
+ matched: require_dist.boolean(),
4642
+ failedCondition: require_dist.string().optional(),
4643
+ matchedOn: require_dist.array(require_dist.string()).optional(),
4644
+ cooldown: require_dist._enum([
4645
+ "n/a",
4646
+ "clear",
4647
+ "suppressed",
4648
+ "bypassed"
4649
+ ]),
4650
+ enqueued: require_dist.number().int(),
4651
+ targetIds: require_dist.array(require_dist.string())
4652
+ });
4653
+ var NcTestEventResultSchema = require_dist.object({
4654
+ /** The synthetic record id. It is `synthetic:<uuid>` and it is what to grep
4655
+ * for in the logs, in the outbox and in the delivery history. */
4656
+ recordId: require_dist.string(),
4657
+ /** Always `synthetic` — the marker the whole path carried. */
4658
+ origin: require_dist.literal("synthetic"),
4659
+ /** The delivery the chosen trigger rides. A rule declaring anything else was
4660
+ * never a candidate, which is the answer to most "it did not fire". */
4661
+ delivery: require_dist.string(),
4662
+ candidates: require_dist.number().int(),
4663
+ outcomes: require_dist.array(NcTestEventRuleOutcomeSchema),
4664
+ /** What the still ended up being. `unattachable` means the chosen TRIGGER
4665
+ * cannot carry one at all — an audio episode and an occupancy edge freeze no
4666
+ * media owner, so those notifications ship text-only in production too, and
4667
+ * that is a property of production, not of the test. A DEVICE event is no
4668
+ * longer one of them: its press projects a marker track that owns the frame. */
4669
+ still: require_dist._enum([
4670
+ "live",
4671
+ "none",
4672
+ "unavailable",
4673
+ "unattachable"
4674
+ ]),
4675
+ /** Everything the operator has to know to read the result honestly. */
4676
+ notes: require_dist.array(require_dist.string()),
4677
+ droppedBy: require_dist.string().optional()
4678
+ });
4679
+ /** The id shape every synthetic record carries. One prefix, everywhere. */
4680
+ var SYNTHETIC_ID_PREFIX = "synthetic:";
4681
+ /** True for a record id this module minted. Used by the media shim. */
4682
+ function isSyntheticId(id) {
4683
+ return id.startsWith(SYNTHETIC_ID_PREFIX);
4684
+ }
4685
+ /**
4686
+ * Whether a subject of this kind can carry a still AT ALL, in production.
4687
+ *
4688
+ * The attachment ladder resolves media by OWNER, and `buildEntries` freezes an
4689
+ * owner onto the payload only for the kinds that have one:
4690
+ *
4691
+ * - an `eventId` for object and package events;
4692
+ * - a `trackId` for track-scoped ones — and, since 2026-08-08, for a
4693
+ * DEVICE event too: the same state change projects a synthetic marker track
4694
+ * carrying the camera's frame (`sensor-marker-projector.ts`), and the
4695
+ * notification names it as its owner.
4696
+ *
4697
+ * An audio episode and an occupancy edge still freeze neither. Audio HAS a
4698
+ * marker projection, but its id does not reach the notification subject yet;
4699
+ * occupancy has no projection at all. Those two therefore remain honestly
4700
+ * unattachable — a tester that invented a picture for them would be advertising
4701
+ * a feature that does not exist.
4702
+ */
4703
+ function triggerCanCarryStill(kind) {
4704
+ return kind === "object-event" || kind === "package-event" || kind === "track-end" || kind === "device-event";
4705
+ }
4706
+ /**
4707
+ * Build the synthetic envelope. Pure: same input + same id + same clock ⇒ same
4708
+ * event, so the red-green test can assert that this subject and a
4709
+ * producer-built one are indistinguishable.
4710
+ */
4711
+ function buildSyntheticEvent(input, recordId, now) {
4712
+ const kind = input.trigger;
4713
+ const timestamp = input.timestamp ?? now;
4714
+ const carriesTrackId = kind === "object-event" || kind === "track-end" || kind === "device-event";
4715
+ return {
4716
+ subject: {
4717
+ kind,
4718
+ recordId,
4719
+ deviceId: input.deviceId,
4720
+ ...input.sourceDeviceId !== void 0 && input.sourceDeviceId !== input.deviceId ? { sourceDeviceId: input.sourceDeviceId } : {},
4721
+ timestamp,
4722
+ classNames: input.classNames,
4723
+ ...input.label !== void 0 ? { label: input.label } : {},
4724
+ ...input.confidence !== void 0 ? { confidence: input.confidence } : {},
4725
+ zones: input.zones,
4726
+ ...carriesTrackId ? { trackId: recordId } : {},
4727
+ source: input.source ?? (kind === "device-event" ? "sensor" : "pipeline"),
4728
+ ...input.importance !== void 0 ? { importance: input.importance } : {},
4729
+ ...input.dwellSeconds !== void 0 ? { dwellSeconds: input.dwellSeconds } : {},
4730
+ ...input.labelConfidence !== void 0 ? { labelConfidence: input.labelConfidence } : {},
4731
+ ...input.sensorKind !== void 0 ? { sensorKind: input.sensorKind } : {},
4732
+ ...input.eventType !== void 0 ? { eventType: input.eventType } : {},
4733
+ ...input.packagePhase !== void 0 ? { packagePhase: input.packagePhase } : {},
4734
+ ...input.bbox !== void 0 ? { bbox: input.bbox } : {},
4735
+ ...input.crossing !== void 0 ? { crossing: input.crossing } : {},
4736
+ ...input.occupancy !== void 0 ? { occupancy: {
4737
+ ...input.occupancy.zoneId !== void 0 ? { zoneId: input.occupancy.zoneId } : {},
4738
+ ...input.occupancy.zoneName !== void 0 ? { zoneName: input.occupancy.zoneName } : {},
4739
+ ...input.occupancy.className !== void 0 ? { className: input.occupancy.className } : {},
4740
+ count: input.occupancy.count,
4741
+ previousCount: input.occupancy.previousCount,
4742
+ occupied: input.occupancy.occupied,
4743
+ threshold: input.occupancy.threshold
4744
+ } } : {}
4745
+ },
4746
+ kind,
4747
+ origin: "synthetic",
4748
+ controls: {
4749
+ ...input.onlyRuleId !== void 0 ? { onlyRuleId: input.onlyRuleId } : {},
4750
+ bypassCooldown: input.bypassCooldown,
4751
+ bypassMute: input.bypassMute,
4752
+ runActions: input.runActions
4753
+ },
4754
+ log: () => ({
4755
+ tags: { deviceId: input.deviceId },
4756
+ meta: {
4757
+ eventId: recordId,
4758
+ synthetic: true,
4759
+ trigger: input.trigger,
4760
+ ...input.onlyRuleId !== void 0 ? { onlyRuleId: input.onlyRuleId } : {}
4761
+ }
4762
+ })
4763
+ };
4764
+ }
4765
+ /**
4766
+ * Turn one live JPEG into the media set the dispatcher's ladders look for.
4767
+ *
4768
+ * Every entry is the SAME photograph, differing only in how it was cut — which
4769
+ * is exactly what the ladders mean by their kind names, so nothing here claims
4770
+ * a frame it does not have:
4771
+ *
4772
+ * - `fullFrame` / `keyFrame` / `firstFrame` — the clean scene, verbatim.
4773
+ * - `fullFrameBoxed` — the clean scene with the operator's bbox drawn on it.
4774
+ * - `crop` / `thumbnail` — that bbox, cut out.
4775
+ *
4776
+ * With NO bbox only the clean-scene kinds are produced, so a rule asking for
4777
+ * `frame: 'cropped'` correctly finds nothing rather than being handed a whole
4778
+ * scene it did not ask for. sharp failures degrade the same way: the clean
4779
+ * scene always survives.
4780
+ */
4781
+ async function deriveSyntheticMedia(jpeg, bbox, timestamp) {
4782
+ const base64 = Buffer.from(jpeg).toString("base64");
4783
+ const clean = (kind) => ({
4784
+ key: `${SYNTHETIC_ID_PREFIX}${kind}`,
4785
+ kind,
4786
+ base64,
4787
+ sizeBytes: jpeg.byteLength,
4788
+ timestamp
4789
+ });
4790
+ const files = [
4791
+ clean("fullFrame"),
4792
+ clean("keyFrame"),
4793
+ clean("firstFrame")
4794
+ ];
4795
+ if (bbox === void 0) return files;
4796
+ try {
4797
+ const { default: sharp$7 } = await import("sharp");
4798
+ const meta = await sharp$7(Buffer.from(jpeg)).metadata();
4799
+ const W = meta.width ?? 0;
4800
+ const H = meta.height ?? 0;
4801
+ if (W === 0 || H === 0) return files;
4802
+ const left = Math.max(0, Math.min(W - 1, Math.floor(bbox.x * W)));
4803
+ const top = Math.max(0, Math.min(H - 1, Math.floor(bbox.y * H)));
4804
+ const width = Math.max(1, Math.min(W - left, Math.ceil(bbox.w * W)));
4805
+ const height = Math.max(1, Math.min(H - top, Math.ceil(bbox.h * H)));
4806
+ const cropBuf = await sharp$7(Buffer.from(jpeg)).extract({
4807
+ left,
4808
+ top,
4809
+ width,
4810
+ height
4811
+ }).jpeg({ quality: 85 }).toBuffer();
4812
+ const cropB64 = cropBuf.toString("base64");
4813
+ files.push({
4814
+ key: `${SYNTHETIC_ID_PREFIX}crop`,
4815
+ kind: "crop",
4816
+ base64: cropB64,
4817
+ sizeBytes: cropBuf.byteLength,
4818
+ timestamp
4819
+ }, {
4820
+ key: `${SYNTHETIC_ID_PREFIX}thumbnail`,
4821
+ kind: "thumbnail",
4822
+ base64: cropB64,
4823
+ sizeBytes: cropBuf.byteLength,
4824
+ timestamp
4825
+ });
4826
+ const stroke = Math.max(2, Math.round(Math.min(W, H) / 240));
4827
+ const overlay = Buffer.from(`<svg width="${W}" height="${H}"><rect x="${left}" y="${top}" width="${width}" height="${height}" fill="none" stroke="#22d3ee" stroke-width="${stroke}"/></svg>`);
4828
+ const boxedBuf = await sharp$7(Buffer.from(jpeg)).composite([{
4829
+ input: overlay,
4830
+ top: 0,
4831
+ left: 0
4832
+ }]).jpeg({ quality: 85 }).toBuffer();
4833
+ files.push({
4834
+ key: `${SYNTHETIC_ID_PREFIX}fullFrameBoxed`,
4835
+ kind: "fullFrameBoxed",
4836
+ base64: boxedBuf.toString("base64"),
4837
+ sizeBytes: boxedBuf.byteLength,
4838
+ timestamp
4839
+ });
4840
+ } catch {
4841
+ return files;
4842
+ }
4843
+ return files;
4844
+ }
4845
+ //#endregion
4862
4846
  //#region src/notification-center/timelapse/timelapse-store.ts
4863
4847
  var NC_TIMELAPSE_RULES_COLLECTION = "notification-center:timelapse-rules";
4864
4848
  var NC_TIMELAPSE_RULES_COLUMNS = [
@@ -5697,10 +5681,16 @@ var NotificationCenter = class NotificationCenter {
5697
5681
  this.consumeEvent(incomingFromTrack(track, info));
5698
5682
  }
5699
5683
  /**
5700
- * Called at the SensorEvent persist site (`ingestSensorStateChange` — one row
5701
- * per linked camera), in the SAME moment as the durable insert. Feeds the
5702
- * `device-event` trigger (doorbell press / sensor state change). Fire-and-
5703
- * forget from the ingest loop; the outbox owns delivery from here.
5684
+ * Called for each SensorEvent row (`ingestSensorStateChange` — one per linked
5685
+ * camera), once the same state change has had its chance to project a marker
5686
+ * track. Feeds the `device-event` trigger (doorbell press / sensor state
5687
+ * change). Fire-and-forget from the ingest loop; the outbox owns delivery.
5688
+ *
5689
+ * `markerTrackId` is that marker — the synthetic track carrying the camera's
5690
+ * frame — and it is what gives this notification a picture: the payload
5691
+ * freezes it as the media owner and the dispatcher resolves it through the
5692
+ * ladder every other attachment uses. Absent means no marker landed, and the
5693
+ * notification is text-only; that is logged, per camera, at the enqueue.
5704
5694
  *
5705
5695
  * Delivery-grade boundary (honest): the SensorEventStore is itself fed from
5706
5696
  * the LOSSY `DeviceStateChanged` telemetry bus, and there is no NC crash-gap
@@ -5709,8 +5699,8 @@ var NotificationCenter = class NotificationCenter {
5709
5699
  * device-event notification — the durable guarantee begins at this hook, not
5710
5700
  * before it.
5711
5701
  */
5712
- onSensorEventPersisted(event) {
5713
- this.consumeEvent(incomingFromSensorEvent(event));
5702
+ onSensorEventPersisted(event, markerTrackId) {
5703
+ this.consumeEvent(incomingFromSensorEvent(event, markerTrackId));
5714
5704
  }
5715
5705
  /**
5716
5706
  * Called at the AUDIO-event persist site (`eventStore.insertAudio`), in the
@@ -5867,7 +5857,7 @@ var NotificationCenter = class NotificationCenter {
5867
5857
  }
5868
5858
  /**
5869
5859
  * Adopt the panel this node owns. Called once by the addon after
5870
- * `ensureAlarmPanel`; a node without one never calls it and the cap then
5860
+ * `declareAlarmPanel`; a node without one never calls it and the cap then
5871
5861
  * answers `deviceId: null`.
5872
5862
  */
5873
5863
  setAlarmPanel(panel) {
@@ -5997,6 +5987,8 @@ var NotificationCenter = class NotificationCenter {
5997
5987
  }
5998
5988
  const now = this.now();
5999
5989
  let anyMatched = false;
5990
+ /** One "no media owner" line per EVENT, not per matching rule. */
5991
+ let noOwnerReported = false;
6000
5992
  const rejections = [];
6001
5993
  for (const rule of candidates) {
6002
5994
  const evaluation = evaluateRule(rule, subject, (id) => this.deviceStates.get(id));
@@ -6079,6 +6071,18 @@ var NotificationCenter = class NotificationCenter {
6079
6071
  buttons: unresolvable.join(", ")
6080
6072
  }
6081
6073
  });
6074
+ if (!noOwnerReported && kind === "device-event" && subject.trackId === void 0 && rule.media.attach !== "none") {
6075
+ noOwnerReported = true;
6076
+ this.logger.warn("device event has no marker track — this notification ships text-only", {
6077
+ tags: { deviceId: subject.deviceId },
6078
+ meta: {
6079
+ recordId: subject.recordId,
6080
+ ...subject.sensorKind !== void 0 ? { sensorKind: subject.sensorKind } : {},
6081
+ ruleId: rule.id,
6082
+ rule: rule.name
6083
+ }
6084
+ });
6085
+ }
6082
6086
  const userTargets = await this.resolveUserTargets(rule, subject.deviceId);
6083
6087
  const entries = this.buildEntries(rule, subject, kind, evaluation.matchedOn, userTargets, origin);
6084
6088
  const inserted = await this.outbox.enqueue(entries);
@@ -6768,6 +6772,844 @@ function makeNcActionHandlers(deps) {
6768
6772
  };
6769
6773
  }
6770
6774
  //#endregion
6775
+ //#region src/pipeline-analytics/data-plane-access.ts
6776
+ /**
6777
+ * Prefix literals for the export planes, mirrored here so the policy reads as
6778
+ * a table without importing three handler modules into the addon's boot path.
6779
+ * `data-plane-access.spec.ts` asserts they still match the constants their
6780
+ * handlers register with — a mirror nothing checks is a lie waiting to happen.
6781
+ */
6782
+ var TRAINING_EXPORT_PREFIX_REF = "training-export";
6783
+ var RETRAIN_EXPORT_PREFIX_REF = "retrain-export";
6784
+ var DEBUG_EXPORT_PREFIX_REF = "debug-export";
6785
+ /**
6786
+ * Every data plane this addon serves, and the level the HUB enforces before it
6787
+ * proxies. Exhaustive by test: a new plane that forgets to appear here fails
6788
+ * `data-plane-access.spec.ts`.
6789
+ */
6790
+ var PIPELINE_ANALYTICS_PLANE_ACCESS = {
6791
+ /** One stored crop/thumbnail by an unguessable id the caller already holds. */
6792
+ "event-media": "authenticated",
6793
+ /** Self-authenticating single-use action token (D47). */
6794
+ "nc-action": "public",
6795
+ /** Self-authenticating single-use artifact token (D47). */
6796
+ "nc-artifact": "public",
6797
+ /** Every frame behind a marked selection — a corpus, not an item. */
6798
+ [TRAINING_EXPORT_PREFIX_REF]: "admin",
6799
+ [RETRAIN_EXPORT_PREFIX_REF]: "admin",
6800
+ /** Diagnostic bundle: logs, config and frames. */
6801
+ [DEBUG_EXPORT_PREFIX_REF]: "admin"
6802
+ };
6803
+ /** The declared level for a plane prefix. Typed, so a prefix with no declared
6804
+ * policy is a compile error rather than a silent default. */
6805
+ function planeAccess(prefix) {
6806
+ return PIPELINE_ANALYTICS_PLANE_ACCESS[prefix];
6807
+ }
6808
+ var MAX_EXPORT_BYTES = 256 * 1024 * 1024;
6809
+ var DebugFindingSchema = require_dist.object({
6810
+ code: require_dist.string(),
6811
+ severity: require_dist._enum([
6812
+ "error",
6813
+ "warn",
6814
+ "info"
6815
+ ]),
6816
+ detail: require_dist.string()
6817
+ });
6818
+ var DebugTrackListItemSchema = require_dist.object({
6819
+ trackId: require_dist.string(),
6820
+ deviceId: require_dist.number(),
6821
+ className: require_dist.string(),
6822
+ label: require_dist.string().optional(),
6823
+ firstSeen: require_dist.number(),
6824
+ lastSeen: require_dist.number(),
6825
+ durationMs: require_dist.number(),
6826
+ active: require_dist.boolean()
6827
+ });
6828
+ var DebugTrackReportSchema = require_dist.object({
6829
+ trackId: require_dist.string(),
6830
+ deviceId: require_dist.number(),
6831
+ className: require_dist.string(),
6832
+ label: require_dist.string().optional(),
6833
+ firstSeen: require_dist.number(),
6834
+ lastSeen: require_dist.number(),
6835
+ durationMs: require_dist.number(),
6836
+ active: require_dist.boolean(),
6837
+ mediaCount: require_dist.number(),
6838
+ mediaKinds: require_dist.array(require_dist.string()).readonly(),
6839
+ eventCount: require_dist.number(),
6840
+ positionCount: require_dist.number(),
6841
+ findings: require_dist.array(DebugFindingSchema).readonly()
6842
+ });
6843
+ var ListTracksInputSchema = require_dist.object({
6844
+ /** Empty or absent means every camera that has tracks. */
6845
+ deviceIds: require_dist.array(require_dist.number()).optional(),
6846
+ limit: require_dist.number().int().positive().max(200).optional()
6847
+ });
6848
+ var ListTracksOutputSchema = require_dist.object({
6849
+ tracks: require_dist.array(DebugTrackListItemSchema).readonly(),
6850
+ /**
6851
+ * True when a camera hit `limit`. Said rather than implied: a silently capped
6852
+ * list is how an operator concludes a mark did not take.
6853
+ */
6854
+ truncated: require_dist.boolean()
6855
+ });
6856
+ var AnalyzeInputSchema = require_dist.object({ trackIds: require_dist.array(require_dist.string()).min(1).max(200) });
6857
+ var AnalyzeOutputSchema = require_dist.object({
6858
+ reports: require_dist.array(DebugTrackReportSchema).readonly(),
6859
+ /**
6860
+ * Ids that were asked for and no longer exist. Per D81 a `debug` mark does NOT
6861
+ * pin retention, so a marked track can be evicted between the list and the
6862
+ * analysis — reported as a number the tab can show rather than as silence.
6863
+ */
6864
+ vanished: require_dist.array(require_dist.string()).readonly()
6865
+ });
6866
+ var debugActions = require_dist.defineCustomActions({
6867
+ "debug.listTracks": require_dist.customAction(ListTracksInputSchema, ListTracksOutputSchema, { auth: "admin" }),
6868
+ "debug.analyze": require_dist.customAction(AnalyzeInputSchema, AnalyzeOutputSchema, { auth: "admin" })
6869
+ });
6870
+ //#endregion
6871
+ //#region src/pipeline-analytics/debug/zip-stream.ts
6872
+ /**
6873
+ * A minimal STORE-only ZIP writer — enough to emit a debug bundle entry by
6874
+ * entry, without ever holding the archive in memory.
6875
+ *
6876
+ * ## Why hand-rolled, and why ZIP rather than the tar next door
6877
+ *
6878
+ * The archive the operator asked for is a ZIP, because it is the format that
6879
+ * opens with a double click on every desktop they use; the training exports use
6880
+ * ustar and that stays as it is. Both are hand-written here for the same two
6881
+ * reasons the {@link ../training-export/tar-stream.js tar writer} records:
6882
+ *
6883
+ * 1. **A dependency would not arrive.** A hub addon is deployed as its built
6884
+ * `dist/` and carries ZERO `node_modules` of its own — adding `yazl` or
6885
+ * `archiver` to `package.json` gets a module that is missing at runtime on
6886
+ * the node that actually runs the code. The only libraries an addon can rely
6887
+ * on are Node's builtins and what its bundler inlines.
6888
+ * 2. **Nothing to compress.** Every payload is JPEG or MP4, already compressed.
6889
+ * STORE (method 0) spends no CPU on the hub's one thread for a percent or
6890
+ * two, and — unlike DEFLATE — the entry's size and CRC are known before the
6891
+ * header is written, so no data descriptors and no seeking back: the writer
6892
+ * is append-only and therefore streamable.
6893
+ *
6894
+ * So: STORE-only ZIP, which is a 30-byte local header + verbatim body per entry
6895
+ * plus a central directory at the end, fully specified (APPNOTE 6.3.x), and
6896
+ * asserted against the system `unzip` binary in the spec next door rather than
6897
+ * against my reading of the format.
6898
+ *
6899
+ * ## What this deliberately does NOT do
6900
+ *
6901
+ * No ZIP64. The classic record set addresses 4 GiB and 65 535 entries, and this
6902
+ * archive is a hand-marked debug set — tens of tracks, not a dataset.
6903
+ * {@link ZipStreamWriter.fits} reports an entry that would cross either ceiling
6904
+ * so the caller can skip it and SAY it skipped it, rather than emitting an
6905
+ * archive with a wrapped offset that reads as corrupt.
6906
+ *
6907
+ * ## Entry names are ASCII, by construction
6908
+ *
6909
+ * The writer sets the UTF-8 name flag (bit 11) because that is what the format
6910
+ * says, but **it cannot be relied on**: the `unzip` shipped on macOS is Info-ZIP
6911
+ * 6.00, which ignores the flag and re-encodes the name from CP437 — `persona-è`
6912
+ * comes out of it as `persona-+¿`, and on a case where the mangled bytes are not
6913
+ * valid for the filesystem it refuses to extract the entry at all. Measured, not
6914
+ * assumed; the spec next door pins it.
6915
+ *
6916
+ * So {@link toAsciiZipName} is applied to every path, and — more importantly —
6917
+ * no free text goes into a path in the first place: an entry is addressed by
6918
+ * `deviceId` and `trackId`, and the camera name lives INSIDE `track.json` where
6919
+ * its encoding is JSON's problem and not the archive's.
6920
+ */
6921
+ var LOCAL_SIG = 67324752;
6922
+ var CENTRAL_SIG = 33639248;
6923
+ var EOCD_SIG = 101010256;
6924
+ /** Bit 11 — the entry name is UTF-8. */
6925
+ var FLAG_UTF8 = 2048;
6926
+ /** STORE. */
6927
+ var METHOD_STORE = 0;
6928
+ /** PKZIP 2.0, the floor for STORE + UTF-8 names. */
6929
+ var VERSION = 20;
6930
+ /** The classic (non-ZIP64) ceilings. Crossing either needs ZIP64; we refuse. */
6931
+ var ZIP_MAX_BYTES = 4294967295;
6932
+ var CRC_TABLE = (() => {
6933
+ const table = new Array(256);
6934
+ for (let i = 0; i < 256; i++) {
6935
+ let c = i;
6936
+ for (let k = 0; k < 8; k++) c = c & 1 ? 3988292384 ^ c >>> 1 : c >>> 1;
6937
+ table[i] = c >>> 0;
6938
+ }
6939
+ return table;
6940
+ })();
6941
+ /**
6942
+ * Reduce a path to the ASCII set every ZIP reader agrees on.
6943
+ *
6944
+ * Anything outside `[A-Za-z0-9._-]` (and the `/` separator) becomes `_`. Not a
6945
+ * transliteration: a name that survives Info-ZIP's CP437 pass unchanged is the
6946
+ * only name this archive can promise, and a lossy-but-stable mapping is better
6947
+ * than a pretty one that a reader mangles differently from the manifest.
6948
+ */
6949
+ function toAsciiZipName(name) {
6950
+ return name.replace(/[^A-Za-z0-9._/-]/g, "_");
6951
+ }
6952
+ /** CRC-32 (IEEE 802.3), the checksum every ZIP entry header carries. */
6953
+ function crc32(buf) {
6954
+ let c = 4294967295;
6955
+ for (const byte of buf) c = CRC_TABLE[(c ^ byte) & 255] ^ c >>> 8;
6956
+ return (c ^ 4294967295) >>> 0;
6957
+ }
6958
+ /**
6959
+ * MS-DOS date/time, the only timestamp a classic ZIP record carries.
6960
+ *
6961
+ * Two-second resolution and an epoch of 1980 — a date before that is clamped
6962
+ * rather than allowed to write a negative year field, which some readers show as
6963
+ * 2107 and others reject outright.
6964
+ */
6965
+ function dosDateTime(mtimeMs) {
6966
+ const d = new Date(mtimeMs);
6967
+ const year = Math.max(1980, d.getFullYear());
6968
+ return {
6969
+ time: d.getHours() << 11 | d.getMinutes() << 5 | d.getSeconds() >> 1,
6970
+ date: year - 1980 << 9 | d.getMonth() + 1 << 5 | d.getDate()
6971
+ };
6972
+ }
6973
+ /**
6974
+ * The 30-byte local header + name for one stored file.
6975
+ *
6976
+ * Sizes and CRC are written into the header itself (never a trailing data
6977
+ * descriptor) — STORE knows both before the body moves, and a header that tells
6978
+ * the truth is what lets a reader index the archive without scanning it.
6979
+ */
6980
+ function zipLocalHeader(entry, crc, nameBytes) {
6981
+ const head = Buffer.alloc(30);
6982
+ const { time, date } = dosDateTime(entry.mtimeMs);
6983
+ head.writeUInt32LE(LOCAL_SIG, 0);
6984
+ head.writeUInt16LE(VERSION, 4);
6985
+ head.writeUInt16LE(FLAG_UTF8, 6);
6986
+ head.writeUInt16LE(METHOD_STORE, 8);
6987
+ head.writeUInt16LE(time, 10);
6988
+ head.writeUInt16LE(date, 12);
6989
+ head.writeUInt32LE(crc, 14);
6990
+ head.writeUInt32LE(entry.body.length, 18);
6991
+ head.writeUInt32LE(entry.body.length, 22);
6992
+ head.writeUInt16LE(nameBytes.length, 26);
6993
+ head.writeUInt16LE(0, 28);
6994
+ return Buffer.concat([head, nameBytes]);
6995
+ }
6996
+ function centralHeader(entry) {
6997
+ const head = Buffer.alloc(46);
6998
+ head.writeUInt32LE(CENTRAL_SIG, 0);
6999
+ head.writeUInt16LE(VERSION, 4);
7000
+ head.writeUInt16LE(VERSION, 6);
7001
+ head.writeUInt16LE(FLAG_UTF8, 8);
7002
+ head.writeUInt16LE(METHOD_STORE, 10);
7003
+ head.writeUInt16LE(entry.dosTime, 12);
7004
+ head.writeUInt16LE(entry.dosDate, 14);
7005
+ head.writeUInt32LE(entry.crc, 16);
7006
+ head.writeUInt32LE(entry.size, 20);
7007
+ head.writeUInt32LE(entry.size, 24);
7008
+ head.writeUInt16LE(entry.nameBytes.length, 28);
7009
+ head.writeUInt16LE(0, 30);
7010
+ head.writeUInt16LE(0, 32);
7011
+ head.writeUInt16LE(0, 34);
7012
+ head.writeUInt16LE(0, 36);
7013
+ head.writeUInt32LE(0, 38);
7014
+ head.writeUInt32LE(entry.offset, 42);
7015
+ return Buffer.concat([head, entry.nameBytes]);
7016
+ }
7017
+ function endOfCentralDirectory(count, size, offset) {
7018
+ const eocd = Buffer.alloc(22);
7019
+ eocd.writeUInt32LE(EOCD_SIG, 0);
7020
+ eocd.writeUInt16LE(0, 4);
7021
+ eocd.writeUInt16LE(0, 6);
7022
+ eocd.writeUInt16LE(count, 8);
7023
+ eocd.writeUInt16LE(count, 10);
7024
+ eocd.writeUInt32LE(size, 12);
7025
+ eocd.writeUInt32LE(offset, 16);
7026
+ eocd.writeUInt16LE(0, 20);
7027
+ return eocd;
7028
+ }
7029
+ /**
7030
+ * True when one more entry still fits inside the classic (non-ZIP64) ceilings.
7031
+ *
7032
+ * Pure, and asked with a length rather than a Buffer, so the 4 GiB boundary can
7033
+ * be tested without allocating 4 GiB — the guard whose failure mode (a wrapped
7034
+ * 32-bit offset, producing an archive that looks complete and unpacks as
7035
+ * garbage) is precisely the one a test must be able to reach.
7036
+ */
7037
+ function zipEntryFits(query) {
7038
+ if (query.entryIndex >= 65535) return false;
7039
+ const nameLen = toAsciiZipName(query.name).length;
7040
+ return query.offset + 30 + nameLen + query.bodyLength <= ZIP_MAX_BYTES;
7041
+ }
7042
+ /**
7043
+ * An append-only ZIP writer.
7044
+ *
7045
+ * The caller supplies `write` — the real `res.write`, awaited, so backpressure
7046
+ * is the socket's. The writer holds only the central directory (one small record
7047
+ * per entry), never a body: {@link add} takes one file at a time and forgets it.
7048
+ */
7049
+ var ZipStreamWriter = class {
7050
+ write;
7051
+ entries = [];
7052
+ offset = 0;
7053
+ constructor(write) {
7054
+ this.write = write;
7055
+ }
7056
+ /** Entries accepted so far — what the manifest should claim, not what was asked. */
7057
+ get entryCount() {
7058
+ return this.entries.length;
7059
+ }
7060
+ /** Bytes emitted so far, central directory excluded. */
7061
+ get bytesWritten() {
7062
+ return this.offset;
7063
+ }
7064
+ /**
7065
+ * True when this entry still fits inside the classic ZIP ceilings.
7066
+ *
7067
+ * Checked BEFORE the header goes out, because a 32-bit offset that wraps
7068
+ * produces an archive that looks complete and unpacks as garbage — the failure
7069
+ * this writer most needs to make impossible.
7070
+ */
7071
+ fits(entry) {
7072
+ return zipEntryFits({
7073
+ offset: this.offset,
7074
+ entryIndex: this.entries.length,
7075
+ name: entry.name,
7076
+ bodyLength: entry.body.length
7077
+ });
7078
+ }
7079
+ /** Append one stored file. Returns false (and writes nothing) when it does not fit. */
7080
+ async add(entry) {
7081
+ if (!this.fits(entry)) return false;
7082
+ const nameBytes = Buffer.from(toAsciiZipName(entry.name), "ascii");
7083
+ const crc = crc32(entry.body);
7084
+ const { time, date } = dosDateTime(entry.mtimeMs);
7085
+ const header = zipLocalHeader(entry, crc, nameBytes);
7086
+ this.entries.push({
7087
+ nameBytes,
7088
+ crc,
7089
+ size: entry.body.length,
7090
+ offset: this.offset,
7091
+ dosTime: time,
7092
+ dosDate: date
7093
+ });
7094
+ await this.write(header);
7095
+ await this.write(entry.body);
7096
+ this.offset += header.length + entry.body.length;
7097
+ return true;
7098
+ }
7099
+ /** Write the central directory + EOCD. The archive is unreadable without it. */
7100
+ async finish() {
7101
+ const start = this.offset;
7102
+ let size = 0;
7103
+ for (const entry of this.entries) {
7104
+ const block = centralHeader(entry);
7105
+ await this.write(block);
7106
+ size += block.length;
7107
+ }
7108
+ await this.write(endOfCentralDirectory(this.entries.length, size, start));
7109
+ this.offset = start + size + 22;
7110
+ }
7111
+ };
7112
+ var DEBUG_MANIFEST_ENTRY = "manifest.json";
7113
+ var DEBUG_TRACK_ENTRY = "track.json";
7114
+ var DEBUG_ANALYSIS_ENTRY = "analysis.json";
7115
+ var DEBUG_EVENTS_ENTRY = "events.json";
7116
+ /** `tracks/<deviceId>/<trackId>` — ASCII by construction, both parts are ids. */
7117
+ function debugTrackDir(deviceId, trackId) {
7118
+ return toAsciiZipName(`tracks/${String(deviceId)}/${trackId}`);
7119
+ }
7120
+ /**
7121
+ * Resolve every media row of one track to an archive-relative name.
7122
+ *
7123
+ * Deterministic: the input order (the store returns `timestamp asc`) fully
7124
+ * determines the output, so re-exporting an unchanged track produces the same
7125
+ * names. A kind that occurs once is bare (`keyFrame.jpg`); a kind that repeats
7126
+ * carries its timestamp; a genuine duplicate of both gets `-2`, `-3`, …
7127
+ */
7128
+ function planTrackMediaNames(media) {
7129
+ const occurrences = /* @__PURE__ */ new Map();
7130
+ for (const m of media) occurrences.set(m.kind, (occurrences.get(m.kind) ?? 0) + 1);
7131
+ const used = /* @__PURE__ */ new Set();
7132
+ const out = [];
7133
+ for (const m of media) {
7134
+ const base = (occurrences.get(m.kind) ?? 0) > 1 ? `${m.kind}-${String(m.timestamp)}` : m.kind;
7135
+ let name = `media/${base}.jpg`;
7136
+ for (let n = 2; used.has(name); n++) name = `media/${base}-${String(n)}.jpg`;
7137
+ used.add(name);
7138
+ out.push({
7139
+ key: m.key,
7140
+ name: toAsciiZipName(name),
7141
+ kind: m.kind,
7142
+ timestamp: m.timestamp,
7143
+ sizeBytes: m.sizeBytes
7144
+ });
7145
+ }
7146
+ return out;
7147
+ }
7148
+ var DEBUG_RETENTION_NOTE = "A `debug` mark does not pin retention (D81). A track with no media here may have been evicted rather than never captured.";
7149
+ /** The archive filename, stamped so two downloads never collide in ~/Downloads. */
7150
+ function debugArchiveFilename(nowMs) {
7151
+ return `camstack-debug-${new Date(nowMs).toISOString().replace(/[:.]/g, "-")}.zip`.replace(/[\r\n"]/g, "");
7152
+ }
7153
+ /** Parse `?deviceIds=615,617` — empty means every camera. */
7154
+ function parseDebugDeviceIds(url) {
7155
+ const query = url.includes("?") ? url.slice(url.indexOf("?") + 1) : "";
7156
+ const raw = new URLSearchParams(query).get("deviceIds");
7157
+ if (raw === null || raw.trim() === "") return [];
7158
+ const out = [];
7159
+ for (const part of raw.split(",")) {
7160
+ const n = Number.parseInt(part.trim(), 10);
7161
+ if (Number.isInteger(n) && n > 0 && !out.includes(n)) out.push(n);
7162
+ }
7163
+ return out;
7164
+ }
7165
+ //#endregion
7166
+ //#region src/pipeline-analytics/debug/debug-export-handler.ts
7167
+ /**
7168
+ * Pacing. The export is a BACKGROUND job sharing a thread with live decode, so
7169
+ * it yields twice: between tracks, and between individual media files.
7170
+ *
7171
+ * The per-file yield is the one that matters. A track can own twenty blobs, and
7172
+ * reading them back to back — each a base64 decode of a few hundred KB —
7173
+ * occupies the loop for long enough that snapshot resizes start timing out. One
7174
+ * file at a time, with a gap, keeps the export off the critical path; it is slow
7175
+ * on purpose.
7176
+ */
7177
+ var TRACK_PACING_MS = 25;
7178
+ var MEDIA_PACING_MS = 15;
7179
+ function json(value) {
7180
+ return Buffer.from(JSON.stringify(value, null, 2), "utf8");
7181
+ }
7182
+ /** Total media rows the plan hopes to ship — the number HEAD reports. */
7183
+ function plannedMediaCount(plan) {
7184
+ let total = 0;
7185
+ for (const t of plan.tracks) total += t.media.length;
7186
+ return total;
7187
+ }
7188
+ function createDebugExportHandler(deps) {
7189
+ return async (req, res) => {
7190
+ const method = req.method ?? "GET";
7191
+ if (method !== "GET" && method !== "HEAD") {
7192
+ res.writeHead(405, { allow: "GET, HEAD" });
7193
+ res.end();
7194
+ return;
7195
+ }
7196
+ const deviceIds = parseDebugDeviceIds(req.url ?? "");
7197
+ let plan;
7198
+ try {
7199
+ plan = await deps.buildPlan(deviceIds);
7200
+ } catch (err) {
7201
+ deps.logger.warn("debug export plan failed", { meta: { error: String(err) } });
7202
+ res.writeHead(500, { "content-type": "text/plain" });
7203
+ res.end("debug export plan failed");
7204
+ return;
7205
+ }
7206
+ res.writeHead(200, {
7207
+ "content-type": "application/zip",
7208
+ "content-disposition": `attachment; filename="${debugArchiveFilename(deps.now())}"`,
7209
+ "cache-control": "no-store",
7210
+ "x-camstack-debug-tracks": String(plan.tracks.length),
7211
+ "x-camstack-debug-media": String(plannedMediaCount(plan))
7212
+ });
7213
+ if (method === "HEAD") {
7214
+ res.end();
7215
+ return;
7216
+ }
7217
+ const write = (chunk) => new Promise((resolve, reject) => {
7218
+ if (!res.write(chunk, (err) => {
7219
+ if (err) reject(err);
7220
+ else resolve();
7221
+ })) res.once("drain", resolve);
7222
+ });
7223
+ const zip = new ZipStreamWriter(write);
7224
+ const mtimeMs = deps.now();
7225
+ const startedAt = deps.now();
7226
+ const manifestTracks = [];
7227
+ let included = 0;
7228
+ let missing = 0;
7229
+ let stoppedBecause = plan.capped ? "track-cap" : null;
7230
+ for (const entry of plan.tracks) {
7231
+ if (zip.bytesWritten >= 268435456) {
7232
+ stoppedBecause ??= "byte-budget";
7233
+ deps.logger.warn("debug export stopped — byte budget reached", { meta: {
7234
+ budget: MAX_EXPORT_BYTES,
7235
+ written: zip.bytesWritten
7236
+ } });
7237
+ break;
7238
+ }
7239
+ const dir = debugTrackDir(entry.report.deviceId, entry.report.trackId);
7240
+ const deviceId = entry.report.deviceId;
7241
+ for (const [name, body] of [
7242
+ [DEBUG_TRACK_ENTRY, json(entry.track)],
7243
+ [DEBUG_ANALYSIS_ENTRY, json(entry.report)],
7244
+ [DEBUG_EVENTS_ENTRY, json(entry.events)]
7245
+ ]) if (!await zip.add({
7246
+ name: `${dir}/${name}`,
7247
+ body,
7248
+ mtimeMs
7249
+ })) stoppedBecause ??= "zip-ceiling";
7250
+ const mediaIncluded = [];
7251
+ const mediaMissing = [];
7252
+ for (const media of entry.media) {
7253
+ const bytes = await deps.readMedia(media.key);
7254
+ if (bytes === null) {
7255
+ missing += 1;
7256
+ mediaMissing.push(media.key);
7257
+ deps.logger.warn("debug export skipped a media file — the blob is unreadable", {
7258
+ tags: { deviceId },
7259
+ meta: {
7260
+ trackId: entry.report.trackId,
7261
+ mediaKey: media.key,
7262
+ kind: media.kind
7263
+ }
7264
+ });
7265
+ continue;
7266
+ }
7267
+ if (!await zip.add({
7268
+ name: `${dir}/${media.name}`,
7269
+ body: bytes,
7270
+ mtimeMs
7271
+ })) {
7272
+ stoppedBecause ??= "zip-ceiling";
7273
+ missing += 1;
7274
+ mediaMissing.push(media.key);
7275
+ deps.logger.warn("debug export skipped a media file — the archive is full", {
7276
+ tags: { deviceId },
7277
+ meta: {
7278
+ trackId: entry.report.trackId,
7279
+ mediaKey: media.key,
7280
+ kind: media.kind
7281
+ }
7282
+ });
7283
+ continue;
7284
+ }
7285
+ included += 1;
7286
+ mediaIncluded.push(media.name);
7287
+ await new Promise((resolve) => setTimeout(resolve, MEDIA_PACING_MS));
7288
+ }
7289
+ manifestTracks.push({
7290
+ trackId: entry.report.trackId,
7291
+ deviceId,
7292
+ dir,
7293
+ className: entry.report.className,
7294
+ ...entry.report.label === void 0 ? {} : { label: entry.report.label },
7295
+ firstSeen: entry.report.firstSeen,
7296
+ lastSeen: entry.report.lastSeen,
7297
+ durationMs: entry.report.durationMs,
7298
+ eventCount: entry.report.eventCount,
7299
+ findings: entry.report.findings.map((f) => f.code),
7300
+ mediaIncluded,
7301
+ mediaMissing
7302
+ });
7303
+ await new Promise((resolve) => setTimeout(resolve, TRACK_PACING_MS));
7304
+ }
7305
+ const manifest = {
7306
+ generatedAt: plan.generatedAt,
7307
+ deviceIds: plan.deviceIds,
7308
+ trackCount: manifestTracks.length,
7309
+ mediaIncluded: included,
7310
+ mediaMissing: missing,
7311
+ truncated: stoppedBecause !== null,
7312
+ stoppedBecause,
7313
+ retentionNote: DEBUG_RETENTION_NOTE,
7314
+ tracks: manifestTracks
7315
+ };
7316
+ await zip.add({
7317
+ name: DEBUG_MANIFEST_ENTRY,
7318
+ body: json(manifest),
7319
+ mtimeMs
7320
+ });
7321
+ await zip.finish();
7322
+ await new Promise((resolve) => {
7323
+ res.end(resolve);
7324
+ });
7325
+ deps.logger.info("debug export complete", { meta: {
7326
+ tracks: manifestTracks.length,
7327
+ mediaIncluded: included,
7328
+ mediaMissing: missing,
7329
+ stoppedBecause,
7330
+ bytes: zip.bytesWritten,
7331
+ elapsedMs: deps.now() - startedAt
7332
+ } });
7333
+ };
7334
+ }
7335
+ //#endregion
7336
+ //#region src/pipeline-analytics/debug/debug-track-analysis.ts
7337
+ /** A track is "short" below this — the pipeline rarely produces a full media set. */
7338
+ var SHORT_TRACK_MS = 2e3;
7339
+ /** Below this, the label the track settled on is weak enough to be worth saying. */
7340
+ var LOW_BEST_SCORE = .5;
7341
+ /** Duration in ms. Never negative — a clock that went backwards reads as 0. */
7342
+ function trackDurationMs(facts) {
7343
+ return Math.max(0, facts.lastSeen - facts.firstSeen);
7344
+ }
7345
+ function hasKind(media, kind) {
7346
+ return media.some((m) => m.kind === kind);
7347
+ }
7348
+ function latestMediaAt(media) {
7349
+ let latest = null;
7350
+ for (const m of media) if (latest === null || m.timestamp > latest) latest = m.timestamp;
7351
+ return latest;
7352
+ }
7353
+ function finding(code, severity, detail) {
7354
+ return {
7355
+ code,
7356
+ severity,
7357
+ detail
7358
+ };
7359
+ }
7360
+ /**
7361
+ * Analyse one track. Pure — same inventory in, same findings out.
7362
+ *
7363
+ * Order is stable and roughly "worst first": the media the whole rest of the
7364
+ * system reads, then the track's own substance, then the softer signals.
7365
+ */
7366
+ function analyseDebugTrack(inventory) {
7367
+ const { facts, media, eventCount } = inventory;
7368
+ const durationMs = trackDurationMs(facts);
7369
+ const closed = !facts.active;
7370
+ const out = [];
7371
+ if (media.length === 0) out.push(finding("MEDIA_EVICTED_OR_NEVER", "error", "No media at all — either nothing was captured, or it was evicted (a debug mark does not pin retention)."));
7372
+ else if (closed) {
7373
+ if (!hasKind(media, "keyFrame")) out.push(finding("MISSING_KEYFRAME", "error", "No keyFrame — the best-shot frame is absent."));
7374
+ if (!hasKind(media, "firstFrame")) out.push(finding("MISSING_FIRSTFRAME", "warn", "No firstFrame for a closed track."));
7375
+ if (!hasKind(media, "lastFrame")) out.push(finding("MISSING_LASTFRAME", "warn", "No lastFrame for a closed track."));
7376
+ if (!hasKind(media, "thumbnail")) out.push(finding("MISSING_THUMBNAIL", "warn", "No thumbnail — every list surface renders blank."));
7377
+ if (!hasKind(media, "crop") && !hasKind(media, "faceCrop") && !hasKind(media, "plateCrop")) out.push(finding("MISSING_BEST_CROP", "warn", "No crop of any kind — nothing was cut for a subject."));
7378
+ }
7379
+ if (durationMs < 2e3) out.push(finding("SHORT_TRACK", "warn", `Track lasted ${String(durationMs)} ms, under the ${String(SHORT_TRACK_MS)} ms floor — likely a fragment.`));
7380
+ if (eventCount === 0) out.push(finding("ZERO_EVENTS", "error", "No object events — the track exists but recorded nothing."));
7381
+ if (facts.positionCount === 0) out.push(finding("ZERO_POSITIONS", "error", "No positions — there is no trajectory to reconstruct."));
7382
+ if (durationMs >= 1e4 && facts.snapshotCount === 0 && !hasKind(media, "snapshot")) out.push(finding("NO_PERIODIC_SNAPSHOTS", "warn", `No periodic snapshot over ${String(durationMs)} ms — the movement gate never opened.`));
7383
+ if (closed && hasKind(media, "lastFrame")) {
7384
+ const lastFrameAt = media.filter((m) => m.kind === "lastFrame").reduce((max, m) => Math.max(max, m.timestamp), 0);
7385
+ const lag = facts.lastSeen - lastFrameAt;
7386
+ if (lag > 2e3) out.push(finding("LASTFRAME_STALE", "warn", `lastFrame is ${String(lag)} ms older than the track's end.`));
7387
+ }
7388
+ if (closed && durationMs >= 1e4 && media.length > 0) {
7389
+ const latest = latestMediaAt(media);
7390
+ if (latest !== null) {
7391
+ const covered = (latest - facts.firstSeen) / durationMs;
7392
+ if (covered < .5) out.push(finding("MEDIA_STOPPED_EARLY", "warn", `Media stops at ${String(Math.round(covered * 100))}% of the track's span.`));
7393
+ }
7394
+ }
7395
+ if (closed && facts.bestEventId === void 0) out.push(finding("NO_BEST_EVENT", "info", "No representative event was chosen for this track."));
7396
+ if (facts.labelScore !== void 0 && facts.labelScore < .5) out.push(finding("LOW_BEST_SCORE", "info", `Best label score ${facts.labelScore.toFixed(2)} is under ${String(LOW_BEST_SCORE)}.`));
7397
+ return out;
7398
+ }
7399
+ /** Fold an inventory into the report shape, findings included. */
7400
+ function buildDebugTrackReport(inventory) {
7401
+ const { facts, media, eventCount } = inventory;
7402
+ const kinds = [...new Set(media.map((m) => m.kind))].toSorted();
7403
+ return {
7404
+ trackId: facts.trackId,
7405
+ deviceId: facts.deviceId,
7406
+ className: facts.className,
7407
+ ...facts.label === void 0 ? {} : { label: facts.label },
7408
+ firstSeen: facts.firstSeen,
7409
+ lastSeen: facts.lastSeen,
7410
+ durationMs: trackDurationMs(facts),
7411
+ active: facts.active,
7412
+ mediaCount: media.length,
7413
+ mediaKinds: kinds,
7414
+ eventCount,
7415
+ positionCount: facts.positionCount,
7416
+ findings: analyseDebugTrack(inventory)
7417
+ };
7418
+ }
7419
+ //#endregion
7420
+ //#region src/pipeline-analytics/debug/debug-service.ts
7421
+ /**
7422
+ * The three media owners one track's files hang off.
7423
+ *
7424
+ * A track's face and plate crops are NOT owned by `track:<trackId>` — they live
7425
+ * under `face-<trackId>` and `plate-<trackId>`. An inventory that reads only the
7426
+ * first owner reports MISSING_BEST_CROP on every track that produced a face,
7427
+ * which is the opposite of the truth.
7428
+ */
7429
+ var MEDIA_OWNERS = [
7430
+ {
7431
+ kind: "track",
7432
+ prefix: ""
7433
+ },
7434
+ {
7435
+ kind: "face",
7436
+ prefix: "face-"
7437
+ },
7438
+ {
7439
+ kind: "plate",
7440
+ prefix: "plate-"
7441
+ }
7442
+ ];
7443
+ function toFacts(track) {
7444
+ return {
7445
+ trackId: track.trackId,
7446
+ deviceId: track.deviceId,
7447
+ className: track.className,
7448
+ ...track.label === void 0 ? {} : { label: track.label },
7449
+ ...track.labelScore === void 0 ? {} : { labelScore: track.labelScore },
7450
+ firstSeen: track.firstSeen,
7451
+ lastSeen: track.lastSeen,
7452
+ active: track.active,
7453
+ positionCount: track.positions.length,
7454
+ snapshotCount: track.snapshots.length,
7455
+ ...track.importance === void 0 ? {} : { importance: track.importance },
7456
+ ...track.bestEventId === void 0 ? {} : { bestEventId: track.bestEventId }
7457
+ };
7458
+ }
7459
+ function toListItem(track) {
7460
+ return {
7461
+ trackId: track.trackId,
7462
+ deviceId: track.deviceId,
7463
+ className: track.className,
7464
+ ...track.label === void 0 ? {} : { label: track.label },
7465
+ firstSeen: track.firstSeen,
7466
+ lastSeen: track.lastSeen,
7467
+ durationMs: trackDurationMs(toFacts(track)),
7468
+ active: track.active
7469
+ };
7470
+ }
7471
+ /** Which cameras to sweep — the caller's list, else every camera with tracks. */
7472
+ async function resolveDeviceIds(deps, requested) {
7473
+ if (requested !== void 0 && requested.length > 0) return requested;
7474
+ return deps.listDeviceIds();
7475
+ }
7476
+ /**
7477
+ * The marked set, newest first per camera.
7478
+ *
7479
+ * One indexed query per camera and nothing else — no media, no events, and no
7480
+ * trajectory: `listDebug` reads `slim` rows, so nothing here parses the
7481
+ * `positions` JSON. The counts an operator wants (media, events, positions) come
7482
+ * from `debug.analyze`, per track, by id.
7483
+ *
7484
+ * A camera that hits `limit` sets `truncated`, because a list that silently
7485
+ * stops at 200 is how an operator concludes a mark did not take.
7486
+ */
7487
+ async function listDebugTracks(deps, input) {
7488
+ const limit = input.limit ?? 200;
7489
+ const deviceIds = await resolveDeviceIds(deps, input.deviceIds);
7490
+ const tracks = [];
7491
+ let truncated = false;
7492
+ for (const deviceId of deviceIds) {
7493
+ const rows = await deps.listDebugTracks(deviceId, limit);
7494
+ if (rows.length >= limit) {
7495
+ truncated = true;
7496
+ deps.logger.warn("debug listTracks hit the per-camera cap", {
7497
+ tags: { deviceId },
7498
+ meta: {
7499
+ deviceId,
7500
+ limit
7501
+ }
7502
+ });
7503
+ }
7504
+ for (const row of rows) tracks.push(toListItem(row));
7505
+ }
7506
+ return {
7507
+ tracks: tracks.toSorted((a, b) => b.lastSeen - a.lastSeen),
7508
+ truncated
7509
+ };
7510
+ }
7511
+ /**
7512
+ * Gather one track's whole inventory: three media queries plus its events.
7513
+ *
7514
+ * The events are RETURNED as well as counted — the bundle writes them to
7515
+ * `events.json`, and fetching them a second time there would double the query
7516
+ * count of an export for a number the caller already holds.
7517
+ */
7518
+ async function gatherTrack(deps, track) {
7519
+ const media = [];
7520
+ for (const owner of MEDIA_OWNERS) media.push(...await deps.listMediaInfo(owner.kind, `${owner.prefix}${track.trackId}`));
7521
+ const events = await deps.listEvents(track.trackId);
7522
+ return {
7523
+ inventory: {
7524
+ facts: toFacts(track),
7525
+ media,
7526
+ eventCount: events.length
7527
+ },
7528
+ events
7529
+ };
7530
+ }
7531
+ /**
7532
+ * Analyse the named tracks.
7533
+ *
7534
+ * An id that no longer resolves is reported in `vanished` rather than dropped: a
7535
+ * `debug` mark does not pin retention (D81), so a track CAN disappear between
7536
+ * the list and the analysis, and the tab has to be able to say so instead of
7537
+ * quietly showing fewer rows than it listed.
7538
+ */
7539
+ async function analyzeDebugTracks(deps, input) {
7540
+ const reports = [];
7541
+ const vanished = [];
7542
+ for (const trackId of input.trackIds) {
7543
+ const track = await deps.getTrack(trackId);
7544
+ if (track === null) {
7545
+ vanished.push(trackId);
7546
+ deps.logger.info("debug analyze — track no longer exists", { meta: { trackId } });
7547
+ continue;
7548
+ }
7549
+ const gathered = await gatherTrack(deps, track);
7550
+ reports.push(buildDebugTrackReport(gathered.inventory));
7551
+ }
7552
+ return {
7553
+ reports,
7554
+ vanished
7555
+ };
7556
+ }
7557
+ /**
7558
+ * Plan the bundle — INDEX READS ONLY, no blob touched.
7559
+ *
7560
+ * The plan is what the manifest is built from and what the handler streams
7561
+ * against, so it must be produced in one pass: a plan that re-queried while
7562
+ * streaming could describe a set the archive does not contain.
7563
+ *
7564
+ * **Bounded at {@link MAX_EXPORT_TRACKS}, far below the list cap.** Listing is
7565
+ * index rows; exporting is every blob those rows point at, base64-decoded and
7566
+ * pushed through the hub's one thread. An unbounded "export all" is a
7567
+ * self-inflicted outage on a busy hub, so the plan stops and SAYS it stopped
7568
+ * rather than quietly shipping less than the button offered.
7569
+ *
7570
+ * Each row is re-read at full projection: `listDebugTracks` deliberately returns
7571
+ * `slim` rows, and a bundle built from those would report ZERO_POSITIONS on
7572
+ * every track and write an empty `positions` array into `track.json`.
7573
+ */
7574
+ async function buildDebugBundlePlan(deps, requestedDeviceIds) {
7575
+ const deviceIds = await resolveDeviceIds(deps, requestedDeviceIds);
7576
+ const tracks = [];
7577
+ const covered = [];
7578
+ let capped = false;
7579
+ for (const deviceId of deviceIds) {
7580
+ if (tracks.length >= 25) {
7581
+ capped = true;
7582
+ break;
7583
+ }
7584
+ const rows = await deps.listDebugTracks(deviceId, 200);
7585
+ if (rows.length > 0) covered.push(deviceId);
7586
+ for (const row of rows) {
7587
+ if (tracks.length >= 25) {
7588
+ capped = true;
7589
+ break;
7590
+ }
7591
+ const full = await deps.getTrack(row.trackId) ?? row;
7592
+ const { inventory, events } = await gatherTrack(deps, full);
7593
+ tracks.push({
7594
+ report: buildDebugTrackReport(inventory),
7595
+ track: full,
7596
+ events,
7597
+ media: planTrackMediaNames(inventory.media)
7598
+ });
7599
+ }
7600
+ }
7601
+ if (capped) deps.logger.warn("debug export capped — more marked tracks than one bundle may carry", { meta: {
7602
+ limit: 25,
7603
+ deviceIds: covered
7604
+ } });
7605
+ return {
7606
+ generatedAt: deps.now(),
7607
+ deviceIds: covered,
7608
+ tracks,
7609
+ capped
7610
+ };
7611
+ }
7612
+ //#endregion
6771
7613
  //#region src/pipeline-analytics/embedding-actions.ts
6772
7614
  /**
6773
7615
  * embedding-actions — operator buttons for the CLIP semantic-search index.
@@ -6847,6 +7689,25 @@ var embeddingActions = require_dist.defineCustomActions({
6847
7689
  "embedding.wipe": require_dist.customAction(NoInputSchema, WipeResultSchema, { auth: "admin" })
6848
7690
  });
6849
7691
  //#endregion
7692
+ //#region src/pipeline-analytics/map-with-concurrency.ts
7693
+ /**
7694
+ * Apply `mapper` to every item with at most `limit` in flight at once.
7695
+ *
7696
+ * A rejecting mapper rejects the whole call (same contract as `Promise.all`);
7697
+ * callers that must not fail a page for one bad row catch inside the mapper.
7698
+ */
7699
+ async function mapWithConcurrency(items, limit, mapper) {
7700
+ const results = new Array(items.length);
7701
+ if (items.length === 0) return results;
7702
+ const effectiveLimit = Math.max(1, Math.min(Math.trunc(limit), items.length));
7703
+ const entries = items.entries();
7704
+ const worker = async () => {
7705
+ for (const [index, item] of entries) results[index] = await mapper(item, index);
7706
+ };
7707
+ await Promise.all(Array.from({ length: effectiveLimit }, () => worker()));
7708
+ return results;
7709
+ }
7710
+ //#endregion
6850
7711
  //#region src/pipeline-analytics/pipeline/key-event-scoring.ts
6851
7712
  /**
6852
7713
  * key-event-scoring — the SINGLE deterministic "importance" scorer for a
@@ -7118,6 +7979,16 @@ function clusterByEmbedding(items, threshold = .5) {
7118
7979
  var OPERATOR_FACE_STEP_ID = "operator:face-gallery";
7119
7980
  /** Embedding model id used when enrolling face crops as identity samples. */
7120
7981
  var MODEL_ID = "arcface-r100";
7982
+ /**
7983
+ * How many face crops `listRecentFaces` resolves at once.
7984
+ *
7985
+ * Each crop costs a store round-trip plus a disk read, so the page is I/O-bound
7986
+ * and the serial form dominated the operator's assign latency. 16 recovers
7987
+ * nearly all of the win while leaving the UDS channel — shared with the live
7988
+ * detection path — far from saturated; unbounded would trade a slow page for a
7989
+ * stalled pipeline.
7990
+ */
7991
+ var FACE_CROP_FETCH_CONCURRENCY = 16;
7121
7992
  var FaceGalleryProvider = class {
7122
7993
  identityStore;
7123
7994
  faceStore;
@@ -7126,6 +7997,7 @@ var FaceGalleryProvider = class {
7126
7997
  eventStore;
7127
7998
  logger;
7128
7999
  refreshGallery;
8000
+ eventMediaBaseUrl;
7129
8001
  emitFaceGalleryChanged;
7130
8002
  constructor(deps) {
7131
8003
  this.identityStore = deps.identityStore;
@@ -7135,6 +8007,7 @@ var FaceGalleryProvider = class {
7135
8007
  this.eventStore = deps.eventStore;
7136
8008
  this.logger = deps.logger;
7137
8009
  this.refreshGallery = deps.refreshGallery;
8010
+ this.eventMediaBaseUrl = deps.eventMediaBaseUrl;
7138
8011
  this.emitFaceGalleryChanged = deps.emitFaceGalleryChanged;
7139
8012
  }
7140
8013
  /** Best-effort emit — never allowed to fail the mutation it follows. */
@@ -7226,6 +8099,21 @@ var FaceGalleryProvider = class {
7226
8099
  const media = await this.mediaStore.listByOwner("face", face.id);
7227
8100
  return media.length > 0 ? media[0].base64 : void 0;
7228
8101
  }
8102
+ /**
8103
+ * The crop as a data-plane URL, or undefined when there is no stored key or
8104
+ * no plane to serve it.
8105
+ *
8106
+ * The `event-media` plane resolves a raw MediaStore key (any id containing
8107
+ * `:` goes to `getByKey`), so a face crop needs NO new plane and no new
8108
+ * access decision — the same `authenticated` level already serves the track
8109
+ * thumbnails the same operator is looking at.
8110
+ */
8111
+ cropUrlFor(face) {
8112
+ if (face.mediaKey === void 0 || face.mediaKey === "") return void 0;
8113
+ const base = this.eventMediaBaseUrl();
8114
+ if (base === null) return void 0;
8115
+ return `${base}/${encodeURIComponent(face.mediaKey)}`;
8116
+ }
7229
8117
  async listRecentFaces(input) {
7230
8118
  const faces = await this.faceStore.listAllRecentFaces({
7231
8119
  limit: input?.limit,
@@ -7233,11 +8121,12 @@ var FaceGalleryProvider = class {
7233
8121
  });
7234
8122
  const identities = await this.identityStore.listIdentities();
7235
8123
  const nameMap = new Map(identities.map((i) => [i.id, i.name]));
7236
- const result = [];
7237
- for (const face of faces) {
7238
- const base64 = await this.faceCropBase64(face);
8124
+ const includeCrops = input?.includeCrops ?? true;
8125
+ return mapWithConcurrency(faces, FACE_CROP_FETCH_CONCURRENCY, async (face) => {
8126
+ const base64 = includeCrops ? await this.faceCropBase64(face) : void 0;
8127
+ const cropUrl = this.cropUrlFor(face);
7239
8128
  const identityName = face.recognizedIdentityId ? nameMap.get(face.recognizedIdentityId) : void 0;
7240
- result.push({
8129
+ return {
7241
8130
  faceId: face.id,
7242
8131
  deviceId: face.deviceId,
7243
8132
  trackId: face.trackId,
@@ -7246,15 +8135,15 @@ var FaceGalleryProvider = class {
7246
8135
  ...identityName !== void 0 ? { identityName } : {},
7247
8136
  assigned: face.assigned,
7248
8137
  ...base64 !== void 0 ? { base64 } : {},
8138
+ ...cropUrl !== void 0 ? { cropUrl } : {},
7249
8139
  ...face.faceBbox !== void 0 ? { faceBbox: face.faceBbox } : {},
7250
8140
  ...face.keyFrameMediaKey !== void 0 ? { keyFrameMediaKey: face.keyFrameMediaKey } : {},
7251
8141
  ...face.bestMatchScore != null ? { bestMatchScore: face.bestMatchScore } : {},
7252
8142
  ...face.nativeFaceShortSidePx != null ? { nativeFaceShortSidePx: face.nativeFaceShortSidePx } : {},
7253
8143
  ...face.suggestedIdentityId != null ? { suggestedIdentityId: face.suggestedIdentityId } : {},
7254
8144
  ...face.suggestedMatchScore != null ? { suggestedMatchScore: face.suggestedMatchScore } : {}
7255
- });
7256
- }
7257
- return result;
8145
+ };
8146
+ });
7258
8147
  }
7259
8148
  /**
7260
8149
  * Get a face by deviceId + trackId. Returns null if not found or if
@@ -11013,6 +11902,13 @@ function eventsToClips(events, pad) {
11013
11902
  ...events.audio.map((e) => make(e, "audio"))
11014
11903
  ].toSorted((a, b) => b.timeRange.startMs - a.timeRange.startMs);
11015
11904
  }
11905
+ /** Keep only clips whose window overlaps at least one recorded range — a clip
11906
+ * is a window over footage, so a window with nothing behind it is unplayable
11907
+ * and must not be listed (a camera that never recorded showed 15 dead audio
11908
+ * "clips" in the viewer ribbon, every tap a 500). Pure. */
11909
+ function filterClipsByFootage(clips, ranges) {
11910
+ return clips.filter((c) => ranges.some((r) => c.timeRange.startMs < r.endMs && c.timeRange.endMs > r.startMs));
11911
+ }
11016
11912
  function createVideoclipsProvider(deps) {
11017
11913
  return {
11018
11914
  listClips: async ({ deviceId, since, until, limit }) => {
@@ -11022,7 +11918,20 @@ function createVideoclipsProvider(deps) {
11022
11918
  until,
11023
11919
  limit
11024
11920
  }), deps.defaultPad);
11025
- return limit != null ? clips.slice(0, limit) : clips;
11921
+ let filtered = clips;
11922
+ if (deps.fetchAvailability) try {
11923
+ filtered = filterClipsByFootage(clips, await deps.fetchAvailability({
11924
+ deviceId,
11925
+ since,
11926
+ until
11927
+ }));
11928
+ } catch (err) {
11929
+ deps.logWarn?.("videoclips: availability read failed — listing unfiltered", {
11930
+ deviceId,
11931
+ error: err instanceof Error ? err.message : String(err)
11932
+ });
11933
+ }
11934
+ return limit != null ? filtered.slice(0, limit) : filtered;
11026
11935
  },
11027
11936
  getClipPlayback: async ({ deviceId, clipId }) => {
11028
11937
  const parsed = parseClipId(clipId);
@@ -11035,8 +11944,22 @@ function createVideoclipsProvider(deps) {
11035
11944
  }
11036
11945
  };
11037
11946
  }
11038
- /** Real time. See {@link EventMediaRequest.speed}. */
11039
- var DEFAULT_SPEED = 1;
11947
+ /**
11948
+ * The VIDEO's playback rate — 4×, the same as {@link NC_GIF_SPEED}.
11949
+ *
11950
+ * This was 1, and the reasoning for 1 was sound as far as it went: `speed !== 1`
11951
+ * fails `clipCanCopy`, so a sped-up video cannot be the camera's own H.264
11952
+ * copied — it is a `libx264` burst. The OPERATOR priced that and took it. It is
11953
+ * one encode per event over a ~12 s window, not a permanent transcode child
11954
+ * ([D84](../../../../../../docs/decisions/adr-0084.md) is about the latter), and
11955
+ * it was measured on a real 615 720p cut before being chosen: **0.23 s of
11956
+ * encode, 254 KB out**, against the 922 KB the copy of the same window carried.
11957
+ * The re-encode is smaller than what it replaces.
11958
+ *
11959
+ * So both attachments now agree on the timeline as well as on the window: one
11960
+ * clip, one rate, two containers.
11961
+ */
11962
+ var DEFAULT_SPEED = 4;
11040
11963
  /** The fallback mp4's requested width — the cap's own ceiling, so a rendition
11041
11964
  * at or below 1080p is an identity scale and the broker copies it. */
11042
11965
  var RING_MP4_MAX_WIDTH = 1920;
@@ -11081,7 +12004,8 @@ var EventMediaService = class {
11081
12004
  postSeconds: request.postRollSec,
11082
12005
  kinds,
11083
12006
  gifMaxWidth: 640,
11084
- gifFps: 8,
12007
+ gifFps: 12,
12008
+ gifSpeed: 4,
11085
12009
  speed: request.speed ?? DEFAULT_SPEED,
11086
12010
  ...request.profile !== void 0 ? { profile: request.profile } : {}
11087
12011
  });
@@ -11207,7 +12131,8 @@ var EventMediaService = class {
11207
12131
  ...window,
11208
12132
  format: "gif",
11209
12133
  maxWidth: 640,
11210
- fps: 8
12134
+ fps: 12 / 4,
12135
+ speed: 4
11211
12136
  }) : null;
11212
12137
  if ((mp4 === null || mp4.byteLength === 0) && (gif === null || gif.byteLength === 0)) {
11213
12138
  this.deps.logger.warn("nc media: the clip ring covered nothing — no footage attached", {
@@ -19246,6 +20171,21 @@ var PlateRecognizer = class {
19246
20171
  }
19247
20172
  };
19248
20173
  //#endregion
20174
+ //#region src/pipeline-analytics/read-slice.ts
20175
+ /**
20176
+ * The loaded slice, or `undefined` when it has never loaded and the one
20177
+ * permitted refresh also failed to produce it.
20178
+ *
20179
+ * A failing refresh is swallowed deliberately: every caller degrades to "no
20180
+ * zones / no rules", and a zone read must never take down the frame that asked.
20181
+ */
20182
+ async function readSliceOnce(handle) {
20183
+ const loaded = handle.value;
20184
+ if (loaded !== void 0) return loaded;
20185
+ await handle.refresh().catch(() => void 0);
20186
+ return handle.value;
20187
+ }
20188
+ //#endregion
19249
20189
  //#region src/pipeline-analytics/rebuild-detail-request.ts
19250
20190
  /** The one step a rebuild runs. */
19251
20191
  var REBUILD_DETAIL_STEP_ID = "clip-embedding";
@@ -22131,6 +23071,10 @@ var TRACKS_COLUMNS = [
22131
23071
  name: "debug",
22132
23072
  type: "BOOLEAN"
22133
23073
  },
23074
+ {
23075
+ name: "hasFace",
23076
+ type: "BOOLEAN"
23077
+ },
22134
23078
  {
22135
23079
  name: "envMinX",
22136
23080
  type: "REAL"
@@ -22211,7 +23155,8 @@ function cloneTrack(t) {
22211
23155
  retrainStatus: t.retrainStatus,
22212
23156
  markForTrain: t.retrainStatus === "staging"
22213
23157
  } : {},
22214
- ...t.debug !== void 0 ? { debug: t.debug } : {}
23158
+ ...t.debug !== void 0 ? { debug: t.debug } : {},
23159
+ ...t.hasFace !== void 0 ? { hasFace: t.hasFace } : {}
22215
23160
  };
22216
23161
  }
22217
23162
  var TrackStore = class {
@@ -22574,6 +23519,49 @@ var TrackStore = class {
22574
23519
  }
22575
23520
  }
22576
23521
  /**
23522
+ * Record that a face DETECTOR found a face on this track. Set-once, never
23523
+ * cleared, and deliberately NOT part of {@link setFlags}: that method is the
23524
+ * operator's write surface and this is the pipeline's.
23525
+ *
23526
+ * **The signal is detection, not recognition.** The caller is the detail-route
23527
+ * fan-out, keyed on the detail's own `className === 'face'` — BEFORE the
23528
+ * `minFacePx` gate, before the recognizer is consulted, and regardless of
23529
+ * whether face recognition is enabled at all. That is the whole point: the
23530
+ * viewer's "face present, identity unknown" badge is `hasFace && !subLabel`,
23531
+ * and a flag that only fired when the recognizer answered would be true
23532
+ * exactly when the badge must not show.
23533
+ *
23534
+ * **Idempotent, and it does not write per detail.** A face detail lands many
23535
+ * times over a track's life. While the track is LIVE the flag is set in RAM
23536
+ * only and rides `persistCompleted` at expiry — one row write, not one per
23537
+ * frame. Once the flag is set, every later call returns immediately.
23538
+ *
23539
+ * The persisted patch is for the LATE detail: enrichment answers routinely
23540
+ * arrive after the track has expired, when there is no RAM entry left and the
23541
+ * row is the only thing to correct. Best-effort — a failed patch costs a badge,
23542
+ * never a track.
23543
+ */
23544
+ async markFaceDetected(trackId) {
23545
+ const active = this.active.get(trackId);
23546
+ if (active) {
23547
+ if (active.hasFace === true) return;
23548
+ active.hasFace = true;
23549
+ return;
23550
+ }
23551
+ try {
23552
+ await this.store.update.mutate({
23553
+ collection: TRACKS_COLLECTION,
23554
+ id: trackId,
23555
+ data: { hasFace: true }
23556
+ });
23557
+ } catch (err) {
23558
+ this.logger.warn("markFaceDetected persist failed", { meta: {
23559
+ trackId,
23560
+ error: String(err)
23561
+ } });
23562
+ }
23563
+ }
23564
+ /**
22577
23565
  * Write the retrain LIFECYCLE state directly — the move `setFlags` cannot
22578
23566
  * express.
22579
23567
  *
@@ -22787,6 +23775,71 @@ var TrackStore = class {
22787
23775
  }
22788
23776
  }
22789
23777
  /**
23778
+ * The device's DEBUG-marked tracks, newest first — the Events → Debug tab.
23779
+ *
23780
+ * Two things about this query are load-bearing.
23781
+ *
23782
+ * **`where: { debug: true }` matches only the explicit `true`.** The column is
23783
+ * nullable with no default (unlike `retrainStatus`), so a row that was never
23784
+ * marked holds NULL and positive equality skips it — which is exactly the set
23785
+ * wanted here, and the reason the column was left nullable in the first place.
23786
+ *
23787
+ * **The result is re-checked in memory.** `compileFilter` DROPS a predicate
23788
+ * whose column does not exist in SQLite — an older hub whose `declareCollection`
23789
+ * fingerprint never re-ran would answer this query with EVERY track on the
23790
+ * camera, silently, and the tab would present the whole history as
23791
+ * operator-marked. A skipped predicate is indistinguishable from a matching one
23792
+ * at the wire, so the guard is the only thing that can tell them apart.
23793
+ *
23794
+ * **`projection: 'slim'`, and this is the expensive detail.** `positions` is a
23795
+ * JSON column averaging ~10 KB a row, and `'full'` parses it for every row
23796
+ * returned. That is affordable for a handful of marked tracks and ruinous in
23797
+ * the case this method already defends against: if the predicate is dropped,
23798
+ * the store hands back `limit` rows PER CAMERA and the trajectory of the
23799
+ * camera's whole recent history is parsed before the guard below throws it
23800
+ * away. The cheap question comes first (D56) — the list carries no trajectory,
23801
+ * and the per-track analysis reads the one row it needs through
23802
+ * `getPersistedByTrackId`.
23803
+ */
23804
+ async listDebug(deviceId, limit) {
23805
+ try {
23806
+ const tracks = (await this.store.query.query({
23807
+ collection: TRACKS_COLLECTION,
23808
+ filter: {
23809
+ where: {
23810
+ deviceId,
23811
+ debug: true
23812
+ },
23813
+ orderBy: {
23814
+ field: "lastSeen",
23815
+ direction: "desc"
23816
+ },
23817
+ limit
23818
+ }
23819
+ })).filter((r) => typeof r.id === "string").map((r) => this.rowToTrack(r.id, r.data, "slim"));
23820
+ const marked = tracks.filter((t) => t.debug === true);
23821
+ if (marked.length !== tracks.length) this.logger.warn("TrackStore.listDebug — the store returned unmarked rows; the `debug` predicate was not applied", {
23822
+ tags: { deviceId },
23823
+ meta: {
23824
+ deviceId,
23825
+ returned: tracks.length,
23826
+ marked: marked.length
23827
+ }
23828
+ });
23829
+ return marked;
23830
+ } catch (err) {
23831
+ this.logger.warn("TrackStore.listDebug failed", {
23832
+ tags: { deviceId },
23833
+ meta: {
23834
+ deviceId,
23835
+ limit,
23836
+ error: String(err)
23837
+ }
23838
+ });
23839
+ return [];
23840
+ }
23841
+ }
23842
+ /**
22790
23843
  * How many of this device's tracks are STAGING — optionally only those older
22791
23844
  * than a cutoff, i.e. exactly the rows a sweep just declined to touch.
22792
23845
  *
@@ -23193,6 +24246,7 @@ var TrackStore = class {
23193
24246
  ...t.audioLabels !== void 0 ? { audioLabels: [...t.audioLabels] } : {},
23194
24247
  ...t.retrainStatus !== void 0 ? { retrainStatus: t.retrainStatus } : {},
23195
24248
  ...t.debug !== void 0 ? { debug: t.debug } : {},
24249
+ ...t.hasFace !== void 0 ? { hasFace: t.hasFace } : {},
23196
24250
  ...envelope !== null && dims !== void 0 ? {
23197
24251
  envMinX: envelope.minX,
23198
24252
  envMinY: envelope.minY,
@@ -23227,6 +24281,7 @@ var TrackStore = class {
23227
24281
  const audioLabels = data["audioLabels"];
23228
24282
  const retrainParsed = require_dist.RetrainStatusSchema.safeParse(data["retrainStatus"]);
23229
24283
  const debug = data["debug"];
24284
+ const hasFace = data["hasFace"];
23230
24285
  const envMinX = data["envMinX"];
23231
24286
  const envMinY = data["envMinY"];
23232
24287
  const envMaxX = data["envMaxX"];
@@ -23262,6 +24317,7 @@ var TrackStore = class {
23262
24317
  markForTrain: retrainParsed.data === "staging"
23263
24318
  } : {},
23264
24319
  ...typeof debug === "boolean" ? { debug } : {},
24320
+ ...typeof hasFace === "boolean" ? { hasFace } : {},
23265
24321
  ...envelope !== null ? { envelope } : {}
23266
24322
  };
23267
24323
  }
@@ -26410,41 +27466,6 @@ var EventMediaDispatcher = class {
26410
27466
  }
26411
27467
  }
26412
27468
  };
26413
- //#endregion
26414
- //#region src/pipeline-analytics/services/sensor-marker-projector.ts
26415
- /**
26416
- * Sensor markers — the gate between "a mapped sensor/control cap changed
26417
- * state" and "a synthetic track exists on the camera's timeline"
26418
- * (event-kinds-taxonomy §8).
26419
- *
26420
- * It is the sibling of {@link ../services/audio-marker-projector.ts} and it
26421
- * exists for the same reason: the projection has gates, and a gate that drops
26422
- * work without saying so is indistinguishable from a feature that was never
26423
- * wired.
26424
- *
26425
- * **What this fixes.** The projection used to resolve its cameras with
26426
- * `camerasFor` alone — LINKED cameras only — while the history-row ingest
26427
- * beside it had gained the self-camera half on 2026-08-07. A doorbell wrapper
26428
- * is bound on the CAMERA (the virtual-doorbell builtin writes the `doorbell`
26429
- * slice onto camera 615 itself), so `camerasFor(615)` answered `[]` and the
26430
- * projection returned on a `debug` line nobody runs at. Camera 615's real
26431
- * press at 2026-08-08 06:59Z therefore produced a `SensorEvent` row, a HomeKit
26432
- * ring, an Alexa post and a matched notification rule — and no track. Both
26433
- * halves now derive their cameras from {@link resolveSensorEventCameras}.
26434
- *
26435
- * **The asymmetry that used to be here was defended by a wrong premise.** The
26436
- * old comment argued a self-projected track would feed the NC `track-end`
26437
- * trigger and make every press evaluate the camera's object rules. It does
26438
- * not: `persistSyntheticTrack` writes the row directly and the NC track-end
26439
- * hook lives in the LIVE tracker's closer, which no synthetic track ever
26440
- * enters. A linked contact sensor has been projecting onto its camera all
26441
- * along under exactly the same mechanism.
26442
- *
26443
- * **Nothing here is silent.** A marker that lands emits one `info` line; every
26444
- * branch that lands none emits one line carrying the reason. Both carry
26445
- * `tags: { deviceId }` with the CAMERA id, because the question always arrives
26446
- * as "why did 617 get a marker and 615 not".
26447
- */
26448
27469
  /**
26449
27470
  * Project one `DeviceStateChanged` of a mapped sensor/control cap into a
26450
27471
  * synthetic track per camera the event belongs to. Returns the number of
@@ -26483,6 +27504,7 @@ async function projectSensorMarkers(deps, data, timestamp) {
26483
27504
  source: "sensor",
26484
27505
  kind: descriptor.kind,
26485
27506
  timestamp,
27507
+ snapshotTimeoutMs: deps.snapshotTimeoutMs ?? 2500,
26486
27508
  ...producingDeviceName !== void 0 ? { producingDeviceName } : {}
26487
27509
  });
26488
27510
  if (track === null) {
@@ -26502,7 +27524,8 @@ async function projectSensorMarkers(deps, data, timestamp) {
26502
27524
  capName: data.capName,
26503
27525
  kind: descriptor.kind,
26504
27526
  trackId: track.trackId,
26505
- selfCamera: cameraId === data.deviceId
27527
+ selfCamera: cameraId === data.deviceId,
27528
+ hasMedia: track.snapshots.length > 0
26506
27529
  });
26507
27530
  }
26508
27531
  return landed;
@@ -26617,13 +27640,13 @@ var SyntheticTrackMaterializer = class {
26617
27640
  * answers: ≤10 s old on a mains camera, and on a battery camera the
26618
27641
  * intentionally stale-but-honest frame that costs no wake.
26619
27642
  */
26620
- async fetchSnapshot(cameraId) {
27643
+ async fetchSnapshot(cameraId, timeoutMs) {
26621
27644
  try {
26622
- const snap = await withTimeout(this.deps.snapshot.getSnapshot({ deviceId: cameraId }), this.snapshotTimeoutMs);
26623
- if (snap === null) this.deps.onError?.("getSnapshot", /* @__PURE__ */ new Error("snapshot returned null"));
27645
+ const snap = await withTimeout(this.deps.snapshot.getSnapshot({ deviceId: cameraId }), timeoutMs);
27646
+ if (snap === null) this.deps.onError?.("getSnapshot", /* @__PURE__ */ new Error("snapshot returned null"), cameraId);
26624
27647
  return snap;
26625
27648
  } catch (err) {
26626
- this.deps.onError?.(err instanceof SnapshotTimeoutError ? "snapshotTimeout" : "snapshotMedia", err);
27649
+ this.deps.onError?.(err instanceof SnapshotTimeoutError ? "snapshotTimeout" : "snapshotMedia", err, cameraId);
26627
27650
  return null;
26628
27651
  }
26629
27652
  }
@@ -26640,14 +27663,14 @@ var SyntheticTrackMaterializer = class {
26640
27663
  const trackId = this.makeId();
26641
27664
  const ts = input.timestamp;
26642
27665
  let mediaKey = null;
26643
- const snap = await this.fetchSnapshot(input.cameraId);
27666
+ const snap = await this.fetchSnapshot(input.cameraId, input.snapshotTimeoutMs ?? this.snapshotTimeoutMs);
26644
27667
  if (snap !== null) try {
26645
27668
  const raw = Buffer.from(snap.base64, "base64");
26646
27669
  let data = raw;
26647
27670
  try {
26648
27671
  data = await downscaleFullFrameJpeg(raw, 960, 540);
26649
27672
  } catch (err) {
26650
- this.deps.onError?.("downscaleSnapshot", err);
27673
+ this.deps.onError?.("downscaleSnapshot", err, input.cameraId);
26651
27674
  }
26652
27675
  mediaKey = await this.deps.media.put({
26653
27676
  deviceId: input.cameraId,
@@ -26658,7 +27681,7 @@ var SyntheticTrackMaterializer = class {
26658
27681
  data
26659
27682
  });
26660
27683
  } catch (err) {
26661
- this.deps.onError?.("snapshotMedia", err);
27684
+ this.deps.onError?.("snapshotMedia", err, input.cameraId);
26662
27685
  }
26663
27686
  const track = {
26664
27687
  trackId,
@@ -26683,7 +27706,7 @@ var SyntheticTrackMaterializer = class {
26683
27706
  try {
26684
27707
  await this.deps.tracks.persistSyntheticTrack(track);
26685
27708
  } catch (err) {
26686
- this.deps.onError?.("persistSyntheticTrack", err);
27709
+ this.deps.onError?.("persistSyntheticTrack", err, input.cameraId);
26687
27710
  return null;
26688
27711
  }
26689
27712
  return track;
@@ -29383,8 +30406,6 @@ var REBUILD_PAGE_SIZE = 200;
29383
30406
  */
29384
30407
  var REBUILD_FALLBACK_NODE_ID = "hub";
29385
30408
  var PIPELINE_ANALYTICS_ADDON_ID = "pipeline-analytics";
29386
- /** Operator-visible name of the fixed integration. */
29387
- var NC_INTEGRATION_NAME = "Notification Center";
29388
30409
  /**
29389
30410
  * The FULL action catalog for this addon.
29390
30411
  *
@@ -29395,7 +30416,8 @@ var NC_INTEGRATION_NAME = "Notification Center";
29395
30416
  */
29396
30417
  var customActions = {
29397
30418
  ...ncActions,
29398
- ...embeddingActions
30419
+ ...embeddingActions,
30420
+ ...debugActions
29399
30421
  };
29400
30422
  /**
29401
30423
  * Assist threshold when the caller does not pin one. Packages have their own
@@ -29642,6 +30664,8 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
29642
30664
  trainingExportDataPlane = null;
29643
30665
  /** Handle for the retrain-export data-plane listener (dispose on shutdown). */
29644
30666
  retrainExportDataPlane = null;
30667
+ /** Handle for the debug-export data-plane listener (dispose on shutdown). */
30668
+ debugExportDataPlane = null;
29645
30669
  /** The retrain curation surface — see `retrain/retrain-service.ts`. Null
29646
30670
  * until the stores are built. */
29647
30671
  retrain = null;
@@ -29680,6 +30704,7 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
29680
30704
  kinds: [...input.kinds],
29681
30705
  gifMaxWidth: input.gifMaxWidth,
29682
30706
  gifFps: input.gifFps,
30707
+ gifSpeed: input.gifSpeed,
29683
30708
  speed: input.speed,
29684
30709
  ...input.profile === "high" || input.profile === "mid" || input.profile === "low" ? { profile: input.profile } : {}
29685
30710
  }),
@@ -29804,7 +30829,7 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
29804
30829
  });
29805
30830
  const served = await this.ctx.dataPlane?.serve({
29806
30831
  prefix: "nc-action",
29807
- access: "public",
30832
+ access: planeAccess("nc-action"),
29808
30833
  handler: plane.handler
29809
30834
  }) ?? null;
29810
30835
  this.ncActionMintUrl = served === null ? null : async ({ id, expMs }) => {
@@ -29858,7 +30883,7 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
29858
30883
  });
29859
30884
  this.ncArtifactDataPlane = await this.ctx.dataPlane?.serve({
29860
30885
  prefix: "nc-artifact",
29861
- access: "public",
30886
+ access: planeAccess("nc-artifact"),
29862
30887
  handler: plane.handler
29863
30888
  }) ?? null;
29864
30889
  this.ncArtifactPlane = this.ncArtifactDataPlane !== null ? plane : null;
@@ -30141,7 +31166,7 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
30141
31166
  const api = this.ctx.api;
30142
31167
  if (!api) throw new Error("pipeline-analytics requires ctx.api (device-manager + settings-store)");
30143
31168
  await this.declareCollections(api);
30144
- await this.ensureAlarmPanel(api);
31169
+ await this.declareAlarmPanel(api);
30145
31170
  const logger = this.ctx.logger;
30146
31171
  const storage = await this.resolveMediaStorage(logger);
30147
31172
  const stores = await this.buildStores(api, logger, storage);
@@ -30203,11 +31228,13 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
30203
31228
  ...isHub && ncHandlers !== void 0 ? {
30204
31229
  customActions: {
30205
31230
  ...ncActions,
30206
- ...embeddingActions
31231
+ ...embeddingActions,
31232
+ ...debugActions
30207
31233
  },
30208
31234
  actionHandlers: {
30209
31235
  ...ncHandlers,
30210
- ...this.buildEmbeddingActionHandlers()
31236
+ ...this.buildEmbeddingActionHandlers(),
31237
+ ...this.buildDebugActionHandlers()
30211
31238
  }
30212
31239
  } : {}
30213
31240
  };
@@ -30238,64 +31265,38 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
30238
31265
  /** Declare typed collections up-front so the first insert doesn't race with
30239
31266
  * a CREATE TABLE. Idempotent. */
30240
31267
  /**
30241
- * Bring the Notification Center's alarm panel into existence, once.
31268
+ * DECLARE the Notification Center's alarm panel.
30242
31269
  *
30243
- * Both steps are get-or-create, because this runs on every boot and an
30244
- * operator must never end up with a second panel after a restart. The
30245
- * integration is FIXED: it exists because this addon says so, not because
30246
- * anybody added it, and there is no add/delete flow for it.
30247
- */
30248
- async ensureAlarmPanel(api) {
30249
- const devices = this.ctx.kernel.devices;
30250
- const rawNodeId = this.ctx.kernel.localNodeId ?? "hub";
30251
- const nodeId = rawNodeId.includes("/") ? rawNodeId.split("/")[0] ?? "hub" : rawNodeId;
30252
- if (nodeId !== "hub") {
30253
- this.ctx.logger.info("alarm panel is hub-owned — skipping on this node", { meta: {
30254
- nodeId,
30255
- rawNodeId
30256
- } });
30257
- return;
30258
- }
31270
+ * The procedure is not here any more — `DeclaredDevices` owns the
31271
+ * get-or-create, the FIXED integration and its every-pass `fixed`
31272
+ * re-assertion, the adopt-don't-skip rule, the hub-ownership gate and the D49
31273
+ * second read that stops a transiently empty index re-stamping the declared
31274
+ * name over the operator's rename. What is left is the declaration and the
31275
+ * ports.
31276
+ */
31277
+ async declareAlarmPanel(api) {
31278
+ const declared = new require_dist.DeclaredDevices({
31279
+ logger: this.ctx.logger.child("alarm"),
31280
+ addonId: PIPELINE_ANALYTICS_ADDON_ID,
31281
+ devices: this.ctx.kernel.devices,
31282
+ localNodeId: this.ctx.kernel.localNodeId,
31283
+ getIntegration: async (addonId) => api.integrations.getByAddonId.query({ addonId }),
31284
+ createIntegration: async (input) => api.integrations.create.mutate(input),
31285
+ updateIntegration: async ({ id, info }) => {
31286
+ await api.integrations.update.mutate({
31287
+ id,
31288
+ info,
31289
+ skipRestart: true
31290
+ });
31291
+ },
31292
+ listOwnDevices: async () => api.deviceManager.listAll.query({ addonId: PIPELINE_ANALYTICS_ADDON_ID })
31293
+ });
30259
31294
  try {
30260
- const result = await ensureAlarmPanel({
30261
- logger: this.ctx.logger.child("alarm"),
30262
- addonId: PIPELINE_ANALYTICS_ADDON_ID,
31295
+ const result = await declared.reconcile({
30263
31296
  integrationName: NC_INTEGRATION_NAME,
30264
- getIntegration: async (addonId) => {
30265
- const found = await api.integrations.getByAddonId.query({ addonId });
30266
- return found === null ? null : {
30267
- id: found.id,
30268
- fixed: found.info?.["fixed"] === true
30269
- };
30270
- },
30271
- markFixed: async (integrationId) => {
30272
- await api.integrations.update.mutate({
30273
- id: integrationId,
30274
- info: { fixed: true },
30275
- skipRestart: true
30276
- });
30277
- },
30278
- createIntegration: async (input) => api.integrations.create.mutate(input),
30279
- findDeviceByStableId: async (stableId) => {
30280
- const found = (await api.deviceManager.listAll.query({ addonId: PIPELINE_ANALYTICS_ADDON_ID })).find((d) => d.stableId === stableId);
30281
- return found === void 0 ? null : { id: found.id };
30282
- },
30283
- createDevice: async ({ stableId, integrationId, name }) => {
30284
- const device = await devices.create(stableId, NC_ALARM_DEVICE_CLASS, {}, null, {
30285
- type: NC_ALARM_DEVICE_TYPE,
30286
- name,
30287
- integrationId
30288
- });
30289
- this.holdAlarmPanel(device);
30290
- return { id: device.id };
30291
- },
30292
- adoptDevice: async ({ stableId }) => {
30293
- const device = await devices.create(stableId, NC_ALARM_DEVICE_CLASS, {}, null, void 0);
30294
- this.holdAlarmPanel(device);
30295
- return { id: device.id };
30296
- }
31297
+ devices: [NC_ALARM_DECLARATION]
30297
31298
  });
30298
- if (result.created) this.ctx.logger.info("notification-center alarm panel ready", { tags: { deviceId: result.deviceId } });
31299
+ for (const outcome of result.devices) this.holdAlarmPanel(outcome.device);
30299
31300
  } catch (err) {
30300
31301
  this.ctx.logger.warn("alarm panel could not be ensured — rules still notify", { meta: { error: err instanceof Error ? err.message : String(err) } });
30301
31302
  }
@@ -30318,7 +31319,7 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
30318
31319
  /**
30319
31320
  * Join the panel and the Notification Center, once both exist.
30320
31321
  *
30321
- * They are built in that order (`ensureAlarmPanel` runs before the centre is
31322
+ * They are built in that order (`declareAlarmPanel` runs before the centre is
30322
31323
  * constructed) and neither can reach the other on its own, so the join is
30323
31324
  * here. A node with no panel simply never calls it, and the cap answers
30324
31325
  * `deviceId: null` — which is what an agent node should say.
@@ -30366,7 +31367,7 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
30366
31367
  let storage = this.ctx.kernel.storage;
30367
31368
  const mediaRoot = process.env.CAMSTACK_MEDIA_ROOT?.trim();
30368
31369
  if (mediaRoot) {
30369
- const { FilesystemStorageProvider } = await Promise.resolve().then(() => require("../node-B5brepKo.js"));
31370
+ const { FilesystemStorageProvider } = await Promise.resolve().then(() => require("../node-DTrkngzv.js"));
30370
31371
  storage = new FilesystemStorageProvider(mediaRoot, { eventMedia: mediaRoot });
30371
31372
  logger.info("pipeline-analytics: event media rooted at CAMSTACK_MEDIA_ROOT", { meta: { mediaRoot } });
30372
31373
  }
@@ -30875,7 +31876,12 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
30875
31876
  }
30876
31877
  /** Event-media data-plane: serve thumbnail JPEGs at
30877
31878
  * `/addon/<addonId>/event-media/<eventId>` through the hub's port.
30878
- * Mirror the recorder playback pattern: try/warn, store handle for dispose. */
31879
+ * Mirror the recorder playback pattern: try/warn, store handle for dispose.
31880
+ *
31881
+ * `authenticated`, not `admin` — see `data-plane-access.ts` for the rule and
31882
+ * the reason it changed: `getTrackMedia` / `getEventMedia` are declared
31883
+ * `access: 'view'` and return the SAME stored blob base64 over tRPC, so the
31884
+ * admin gate protected nothing and cost every non-admin session its tiles. */
30879
31885
  async serveEventMediaDataPlane() {
30880
31886
  try {
30881
31887
  const handler = createEventMediaHandler({ getMedia: async (id, variant, preferKind) => {
@@ -30891,22 +31897,27 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
30891
31897
  } });
30892
31898
  this.eventMediaDataPlane = await this.ctx.dataPlane?.serve({
30893
31899
  prefix: "event-media",
30894
- access: "admin",
31900
+ access: planeAccess("event-media"),
30895
31901
  handler
30896
31902
  }) ?? null;
30897
31903
  this.eventMediaBaseUrl = this.eventMediaDataPlane !== null ? `/addon/${this.ctx.id}/event-media` : null;
30898
31904
  await this.serveNcArtifactPlane();
30899
31905
  await this.serveTrainingExportPlane();
30900
31906
  await this.serveRetrainExportPlane();
30901
- this.ctx.logger.info("event-media data-plane served", { meta: { baseUrl: this.eventMediaBaseUrl ?? "(no dataPlane facility)" } });
31907
+ await this.serveDebugExportPlane();
31908
+ this.ctx.logger.info("event-media data-plane served", { meta: {
31909
+ baseUrl: this.eventMediaBaseUrl ?? "(no dataPlane facility)",
31910
+ access: planeAccess("event-media")
31911
+ } });
30902
31912
  } catch (err) {
30903
31913
  this.ctx.logger.warn("event-media data-plane failed to serve", { meta: { error: require_dist.errMsg(err) } });
30904
31914
  }
30905
31915
  }
30906
31916
  /**
30907
- * The training-export data plane. Separate listener, `access: 'admin'` —
30908
- * marking a track is a viewer-level curation act (D81), but walking off with
30909
- * every stored frame behind the marked set is not.
31917
+ * The training-export data plane. Separate listener, admin-only (the level
31918
+ * is declared in `data-plane-access.ts`) — marking a track is a viewer-level
31919
+ * curation act (D81), but walking off with every stored frame behind the
31920
+ * marked set is not.
30910
31921
  */
30911
31922
  async serveTrainingExportPlane() {
30912
31923
  try {
@@ -30921,7 +31932,7 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
30921
31932
  });
30922
31933
  this.trainingExportDataPlane = await this.ctx.dataPlane?.serve({
30923
31934
  prefix: "training-export",
30924
- access: "admin",
31935
+ access: planeAccess("training-export"),
30925
31936
  handler
30926
31937
  }) ?? null;
30927
31938
  } catch (err) {
@@ -31050,6 +32061,60 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
31050
32061
  return { url: `/addon/${this.ctx.id}/${RETRAIN_EXPORT_PREFIX}${query}` };
31051
32062
  }
31052
32063
  /**
32064
+ * The store seam the `debug.*` actions and the debug bundle both read through.
32065
+ *
32066
+ * INDEX READS ONLY — three media-inventory queries and an event query per
32067
+ * track, no blob. The bytes are fetched one at a time, by key, by the export
32068
+ * handler, so a bundle of a hundred tracks never holds more than one file.
32069
+ */
32070
+ buildDebugServiceDeps() {
32071
+ const trackStore = this.trackStore;
32072
+ const mediaStore = this.mediaStore;
32073
+ const eventStore = this.eventStore;
32074
+ if (trackStore === null || mediaStore === null || eventStore === null) throw new Error("debug: analytics stores unavailable");
32075
+ return {
32076
+ listDeviceIds: () => trackStore.listDeviceIds(),
32077
+ listDebugTracks: (deviceId, limit) => trackStore.listDebug(deviceId, limit),
32078
+ getTrack: (trackId) => trackStore.getPersistedByTrackId(trackId),
32079
+ listMediaInfo: (ownerKind, ownerId) => mediaStore.listInfoByOwner(ownerKind, ownerId),
32080
+ listEvents: (trackId) => eventStore.queryObjectByTrackIds([trackId]),
32081
+ logger: this.ctx.logger.child("debug"),
32082
+ now: () => Date.now()
32083
+ };
32084
+ }
32085
+ /** The `debug.*` bridge handlers — hub-only, like `nc.*` and `embedding.*`. */
32086
+ buildDebugActionHandlers() {
32087
+ return {
32088
+ "debug.listTracks": async (input) => listDebugTracks(this.buildDebugServiceDeps(), input),
32089
+ "debug.analyze": async (input) => analyzeDebugTracks(this.buildDebugServiceDeps(), input)
32090
+ };
32091
+ }
32092
+ /**
32093
+ * The debug-export data plane. Its own listener, for the same reason the
32094
+ * retrain export has one: a client asking for the debug bundle must never be
32095
+ * able to receive the annotated dataset by changing a query parameter.
32096
+ */
32097
+ async serveDebugExportPlane() {
32098
+ try {
32099
+ const handler = createDebugExportHandler({
32100
+ buildPlan: (deviceIds) => buildDebugBundlePlan(this.buildDebugServiceDeps(), deviceIds),
32101
+ readMedia: async (key) => {
32102
+ const file = await this.mediaStore?.getByKey(key);
32103
+ return file === null || file === void 0 ? null : Buffer.from(file.base64, "base64");
32104
+ },
32105
+ logger: this.ctx.logger.child("debug-export"),
32106
+ now: () => Date.now()
32107
+ });
32108
+ this.debugExportDataPlane = await this.ctx.dataPlane?.serve({
32109
+ prefix: "debug-export",
32110
+ access: planeAccess("debug-export"),
32111
+ handler
32112
+ }) ?? null;
32113
+ } catch (err) {
32114
+ this.ctx.logger.warn("debug-export data-plane failed to serve", { meta: { error: require_dist.errMsg(err) } });
32115
+ }
32116
+ }
32117
+ /**
31053
32118
  * The retrain-export data plane. Separate listener from the training export
31054
32119
  * next door: different archive, different contents, and a client asking for
31055
32120
  * one must never be able to receive the other by changing a query parameter.
@@ -31071,7 +32136,7 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
31071
32136
  });
31072
32137
  this.retrainExportDataPlane = await this.ctx.dataPlane?.serve({
31073
32138
  prefix: "retrain-export",
31074
- access: "admin",
32139
+ access: planeAccess("retrain-export"),
31075
32140
  handler
31076
32141
  }) ?? null;
31077
32142
  } catch (err) {
@@ -31281,6 +32346,17 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
31281
32346
  audio
31282
32347
  };
31283
32348
  },
32349
+ fetchAvailability: async ({ deviceId, since, until }) => {
32350
+ return (await api.recording.getAvailability.query({
32351
+ deviceId,
32352
+ fromMs: since,
32353
+ toMs: until
32354
+ })).ranges;
32355
+ },
32356
+ logWarn: (message, meta) => this.ctx.logger.warn(message, {
32357
+ tags: { deviceId: Number(meta["deviceId"]) },
32358
+ meta
32359
+ }),
31284
32360
  resolvePlayback: async ({ deviceId, startMs, endMs }) => {
31285
32361
  const manifest = await api.recording.getPlaybackManifest.query({
31286
32362
  deviceId,
@@ -31302,6 +32378,7 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
31302
32378
  eventStore: stores.eventStore,
31303
32379
  logger: this.ctx.logger,
31304
32380
  refreshGallery: () => this.faceRecognizer?.refreshGallery(),
32381
+ eventMediaBaseUrl: () => this.eventMediaBaseUrl,
31305
32382
  emitFaceGalleryChanged: (payload) => this.emitFaceGalleryChanged(payload)
31306
32383
  });
31307
32384
  const plateGallery = new PlateGalleryProvider({
@@ -31402,6 +32479,8 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
31402
32479
  this.trainingExportDataPlane = null;
31403
32480
  await this.retrainExportDataPlane?.dispose();
31404
32481
  this.retrainExportDataPlane = null;
32482
+ await this.debugExportDataPlane?.dispose();
32483
+ this.debugExportDataPlane = null;
31405
32484
  }
31406
32485
  async handleInferenceResult(data) {
31407
32486
  if (this.shuttingDown) return;
@@ -32311,10 +33390,7 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
32311
33390
  async resolveDevicePackageRules(deviceId) {
32312
33391
  const proxy = await this.ensureProxy(deviceId);
32313
33392
  if (!proxy) return [];
32314
- const cached = proxy.state.zoneRules.value?.package;
32315
- if (cached && cached.length > 0) return cached;
32316
- await proxy.state.zoneRules.refresh().catch(() => void 0);
32317
- return proxy.state.zoneRules.value?.package ?? [];
33393
+ return (await readSliceOnce(proxy.state.zoneRules))?.package ?? [];
32318
33394
  }
32319
33395
  /**
32320
33396
  * Route one track's `runDetailSubtree` results (two-plane detail dispatch)
@@ -32330,6 +33406,7 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
32330
33406
  async routeDetailResults(deviceId, trackId, details, frame) {
32331
33407
  for (const d of details) try {
32332
33408
  const isFaceDetail = d.className === "face";
33409
+ if (isFaceDetail) await this.trackStore?.markFaceDetected(trackId);
32333
33410
  if (isFaceDetail && d.bbox !== void 0) this.overlayState.noteFaceDetail(deviceId, trackId, {
32334
33411
  x: d.bbox.x,
32335
33412
  y: d.bbox.y,
@@ -33621,10 +34698,7 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
33621
34698
  async resolveDeviceZones(deviceId) {
33622
34699
  const proxy = await this.ensureProxy(deviceId);
33623
34700
  if (!proxy) return [];
33624
- const cached = proxy.state.zones.value?.zones;
33625
- if (cached && cached.length > 0) return cached;
33626
- await proxy.state.zones.refresh().catch(() => void 0);
33627
- return proxy.state.zones.value?.zones ?? [];
34701
+ return (await readSliceOnce(proxy.state.zones))?.zones ?? [];
33628
34702
  }
33629
34703
  releaseProxy(deviceId) {
33630
34704
  const unsubs = this.proxyUnsubs.get(deviceId);
@@ -33752,11 +34826,12 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
33752
34826
  const store = this.sensorEventStore;
33753
34827
  const cache = this.linkedCamerasCache;
33754
34828
  if (store === null || cache === null) return;
34829
+ const persisted = [];
33755
34830
  try {
33756
34831
  await ingestSensorStateChange({
33757
34832
  sink: store,
33758
34833
  cache,
33759
- onPersisted: (ev) => this.notificationCenter?.onSensorEventPersisted(ev),
34834
+ onPersisted: (ev) => persisted.push(ev),
33760
34835
  onDropped: (reason, kind) => this.logSensorIngestDrop(data, reason, kind)
33761
34836
  }, data, timestamp);
33762
34837
  } catch (err) {
@@ -33768,23 +34843,30 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
33768
34843
  }
33769
34844
  });
33770
34845
  }
34846
+ const markerByCamera = /* @__PURE__ */ new Map();
33771
34847
  const materializer = this.syntheticTracks;
33772
- if (materializer === null) return;
33773
- try {
34848
+ if (materializer !== null) try {
33774
34849
  await projectSensorMarkers({
33775
34850
  cache,
33776
34851
  markers: materializer,
33777
34852
  producingDeviceName: (deviceId) => this.resolveProducingDeviceName(deviceId),
33778
- onProjected: (r) => this.ctx.logger.info("synthetic sensor-track projected", {
33779
- tags: { deviceId: r.cameraId },
33780
- meta: {
33781
- capName: r.capName,
33782
- kind: r.kind,
34853
+ onProjected: (r) => {
34854
+ markerByCamera.set(r.cameraId, {
33783
34855
  trackId: r.trackId,
33784
- sourceDeviceId: r.sourceDeviceId,
33785
- selfCamera: r.selfCamera
33786
- }
33787
- }),
34856
+ hasMedia: r.hasMedia
34857
+ });
34858
+ this.ctx.logger.info("synthetic sensor-track projected", {
34859
+ tags: { deviceId: r.cameraId },
34860
+ meta: {
34861
+ capName: r.capName,
34862
+ kind: r.kind,
34863
+ trackId: r.trackId,
34864
+ sourceDeviceId: r.sourceDeviceId,
34865
+ selfCamera: r.selfCamera,
34866
+ hasMedia: r.hasMedia
34867
+ }
34868
+ });
34869
+ },
33788
34870
  onSkipped: (r) => this.ctx.logger.debug("synthetic sensor-track NOT projected", {
33789
34871
  tags: { deviceId: r.cameraId ?? r.sourceDeviceId },
33790
34872
  meta: {
@@ -33804,6 +34886,7 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
33804
34886
  }
33805
34887
  });
33806
34888
  }
34889
+ for (const ev of persisted) this.notificationCenter?.onSensorEventPersisted(ev, markerByCamera.get(ev.deviceId)?.trackId);
33807
34890
  }
33808
34891
  /**
33809
34892
  * Say out loud that a MAPPED sensor cap ingested nothing, and why.