@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.
@@ -1,4 +1,4 @@
1
- import { $ as boolean, A as encodeVectorBase64, B as vectorDimFromBase64, C as alarmPanelCapability, D as customAction, E as cosineSimilarity, F as notificationRulesCapability, G as DeviceType, H as zoneAnalyticsCapability, I as pipelineAnalyticsCapability, J as isDeviceScopedCap, K as createEvent, L as plateGalleryCapability, N as isScheduleActive, O as defineCustomActions, P as kebabToCamel, Q as array, R as readDeviceStateFrom, S as addonWidgetsSourceCapability, T as buildEventKindDescriptor, U as errMsg, V as videoclipsCapability, W as BaseAddon, X as sleep, Y as nodePin, Z as _enum, _ as OpsLogEntrySchema, a as LabelAttributionSchema, at as unknown, b as TimelapseRuleSchema, c as NC_TAXONOMY, d as NcRulePatchSchema, et as literal, f as NcRuleSchema, g as NcTaxonomySchema, h as NcSnoozeSuppressedSchema, i as EVENT_PAD_MS, it as string, j as faceGalleryCapability, l as NcConditionDescriptorSchema, m as NcSnoozeSchema, n as DEFAULT_EVENT_COLOR, nt as object, o as MACRO_LABELS, ot as EventCategory, p as NcSnoozeInputSchema, q as hydrateSchema, r as EVENT_KIND_BY_CAP, rt as record, s as NC_CONDITION_CATALOG, t as BaseDevice, tt as number, u as NcRuleInputSchema, v as RetrainStatusSchema, w as audioMetricsCapability, x as TrackSourceSchema, y as TimelapseRuleInputSchema, z as subKindsOf } from "../dist-CDLLzMJw.mjs";
1
+ import { $ as array, B as subKindsOf, C as addonWidgetsSourceCapability, D as cosineSimilarity, E as buildEventKindDescriptor, F as kebabToCamel, G as BaseAddon, H as videoclipsCapability, I as notificationRulesCapability, J as hydrateSchema, K as DeviceType, L as pipelineAnalyticsCapability, M as faceGalleryCapability, O as customAction, P as isScheduleActive, Q as _enum, R as plateGalleryCapability, S as TrackSourceSchema, T as audioMetricsCapability, U as zoneAnalyticsCapability, V as vectorDimFromBase64, W as errMsg, X as nodePin, Y as isDeviceScopedCap, Z as sleep, _ as NcTaxonomySchema, a as EVENT_PAD_MS, at as string, b as TimelapseRuleInputSchema, c as NC_CONDITION_CATALOG, d as NcRuleInputSchema, et as boolean, f as NcRulePatchSchema, g as NcSnoozeSuppressedSchema, h as NcSnoozeSchema, i as EVENT_KIND_BY_CAP, it as record, j as encodeVectorBase64, k as defineCustomActions, l as NC_TAXONOMY, m as NcSnoozeInputSchema, n as DEFAULT_EVENT_COLOR, nt as number, o as LabelAttributionSchema, ot as unknown, p as NcRuleSchema, q as createEvent, r as DeclaredDevices, rt as object, s as MACRO_LABELS, st as EventCategory, t as BaseDevice, tt as literal, u as NcConditionDescriptorSchema, v as OpsLogEntrySchema, w as alarmPanelCapability, x as TimelapseRuleSchema, y as RetrainStatusSchema, z as readDeviceStateFrom } from "../dist-BVOgkikr.mjs";
2
2
  import { promises } from "node:fs";
3
3
  import path from "node:path";
4
4
  import { createHash, createHmac, randomUUID, timingSafeEqual } from "node:crypto";
@@ -692,77 +692,34 @@ var NcAlarmPanelDevice = class extends BaseDevice {
692
692
  }
693
693
  };
694
694
  //#endregion
695
- //#region src/notification-center/alarm/ensure-alarm-panel.ts
695
+ //#region src/notification-center/alarm/alarm-panel-declaration.ts
696
696
  /**
697
- * Bring the Notification Center's alarm panel into existence, once.
697
+ * The Notification Center's alarm panel, as a DECLARATION.
698
698
  *
699
- * Two idempotent steps — a fixed integration to hang from, and one device under
700
- * it. Both are `get-or-create`, because this runs on every boot and an operator
701
- * must never end up with a second panel after a restart.
699
+ * There is no `ensureAlarmPanel` any more. The get-or-create, the `fixed`
700
+ * re-assertion, the adopt-don't-skip rule and the hub-ownership gate all moved
701
+ * to `DeclaredDevices` in `@camstack/types` — the one primitive for a device an
702
+ * addon declares. What is left here is the *declaration*: the stable id, the
703
+ * seed name, the type and the class. Data, not procedure.
702
704
  *
703
- * The integration is FIXED: it exists because the addon says so, not because an
704
- * operator added it, and there is no add/delete flow for it. That is the whole
705
- * meaning of "a system integration" here — the same `integrations` row every
706
- * provider uses, created by its owner instead of by a form.
705
+ * The bug the hand-rolled version carried died in that move: it read the device
706
+ * index once, so a transiently empty answer took the create branch and
707
+ * re-stamped `Alarm` over the operator's rename. `DeclaredDevices` requires a
708
+ * second read to agree before it does anything destructive (D49).
707
709
  */
708
710
  /** Stable id of the one panel. Never derived from a name — a rename must not
709
711
  * mint a second device. */
710
712
  var NC_ALARM_STABLE_ID = "nc-alarm-panel";
713
+ /** Seeded ONCE, at create. A later boot never re-stamps it. */
711
714
  var NC_ALARM_DEVICE_NAME = "Alarm";
712
- /**
713
- * Idempotent. Returns the ids either way so the caller can hold onto the panel.
714
- *
715
- * Failures are the CALLER's to handle: a hub whose alarm could not be created
716
- * should say so loudly and keep notifying, not fail to boot — but that decision
717
- * does not belong in here.
718
- */
719
- async function ensureAlarmPanel(deps) {
720
- const existingIntegration = await deps.getIntegration(deps.addonId);
721
- if (existingIntegration !== null && !existingIntegration.fixed) {
722
- await deps.markFixed(existingIntegration.id);
723
- deps.logger.info("marked the notification-center integration as fixed", { meta: { integrationId: existingIntegration.id } });
724
- }
725
- const integration = existingIntegration ?? await deps.createIntegration({
726
- addonId: deps.addonId,
727
- name: deps.integrationName,
728
- info: { fixed: true }
729
- });
730
- if (await deps.findDeviceByStableId("nc-alarm-panel") !== null) {
731
- const adopted = await deps.adoptDevice({ stableId: NC_ALARM_STABLE_ID });
732
- deps.logger.info("alarm panel adopted", {
733
- tags: { deviceId: adopted.id },
734
- meta: {
735
- integrationId: integration.id,
736
- stableId: NC_ALARM_STABLE_ID
737
- }
738
- });
739
- return {
740
- integrationId: integration.id,
741
- deviceId: adopted.id,
742
- created: false
743
- };
744
- }
745
- const device = await deps.createDevice({
746
- stableId: NC_ALARM_STABLE_ID,
747
- integrationId: integration.id,
748
- name: NC_ALARM_DEVICE_NAME
749
- });
750
- deps.logger.info("alarm panel created", {
751
- tags: { deviceId: device.id },
752
- meta: {
753
- integrationId: integration.id,
754
- stableId: NC_ALARM_STABLE_ID
755
- }
756
- });
757
- return {
758
- integrationId: integration.id,
759
- deviceId: device.id,
760
- created: true
761
- };
762
- }
763
- /** The device class + type the caller passes to `kernel.devices.create`. */
764
- var NC_ALARM_DEVICE_CLASS = NcAlarmPanelDevice;
765
- var NC_ALARM_DEVICE_TYPE = DeviceType.AlarmPanel;
715
+ /** Display name of the FIXED integration the panel hangs from. */
716
+ var NC_INTEGRATION_NAME = "Notification Center";
717
+ var NC_ALARM_DECLARATION = {
718
+ stableId: NC_ALARM_STABLE_ID,
719
+ name: NC_ALARM_DEVICE_NAME,
720
+ type: DeviceType.AlarmPanel,
721
+ DeviceClass: NcAlarmPanelDevice
722
+ };
766
723
  //#endregion
767
724
  //#region src/notification-center/artifact-url.ts
768
725
  /**
@@ -1102,6 +1059,220 @@ function mimeFromExtension(file) {
1102
1059
  return "application/octet-stream";
1103
1060
  }
1104
1061
  //#endregion
1062
+ //#region src/notification-center/action-buttons.ts
1063
+ /** Sequence names the rule actually declares. A button may only name one. */
1064
+ function declaredSequences(actions) {
1065
+ const names = /* @__PURE__ */ new Set();
1066
+ for (const sequence of actions.onTrigger ?? []) names.add(sequence.name);
1067
+ return names;
1068
+ }
1069
+ /**
1070
+ * Build the notification's buttons.
1071
+ *
1072
+ * Returns an empty array rather than `undefined` — the caller decides whether
1073
+ * to set the field, and an empty array is the honest answer to "which buttons
1074
+ * survived", which `undefined` would conflate with "the rule declared none".
1075
+ */
1076
+ function buildActionButtons(input) {
1077
+ const actions = input.actions;
1078
+ if (actions === void 0) return [];
1079
+ const buttons = actions.buttons ?? [];
1080
+ if (buttons.length === 0) return [];
1081
+ const known = declaredSequences(actions);
1082
+ const seen = /* @__PURE__ */ new Set();
1083
+ const out = [];
1084
+ for (const button of buttons) {
1085
+ if (!known.has(button.sequence)) continue;
1086
+ if (seen.has(button.id)) continue;
1087
+ seen.add(button.id);
1088
+ out.push({
1089
+ id: button.id,
1090
+ label: button.label,
1091
+ url: input.mintUrl({
1092
+ sequence: button.sequence,
1093
+ actionId: button.id
1094
+ }),
1095
+ ...button.icon !== void 0 ? { icon: button.icon } : {},
1096
+ ...button.destructive !== void 0 ? { destructive: button.destructive } : {}
1097
+ });
1098
+ }
1099
+ return out;
1100
+ }
1101
+ /**
1102
+ * Which of a rule's buttons name a sequence it does not have.
1103
+ *
1104
+ * Exported so the caller can LOG the drop. A button silently missing from a
1105
+ * notification is the exact shape of failure this repo keeps paying for — the
1106
+ * operator authored it, it never appeared, and nothing said why.
1107
+ */
1108
+ function unresolvableButtons(actions) {
1109
+ if (actions === void 0) return [];
1110
+ const buttons = actions.buttons ?? [];
1111
+ if (buttons.length === 0) return [];
1112
+ const known = declaredSequences(actions);
1113
+ return buttons.filter((b) => !known.has(b.sequence)).map((b) => `${b.id}→${b.sequence}`);
1114
+ }
1115
+ //#endregion
1116
+ //#region src/notification-center/device-mute-store.ts
1117
+ var NC_DEVICE_MUTES_COLLECTION = "notification-center:device-mutes";
1118
+ var NC_DEVICE_MUTES_COLUMNS = [
1119
+ (
1120
+ /** `String(deviceId)` — one row per muted camera; unmute DELETES the row. */
1121
+ {
1122
+ name: "id",
1123
+ type: "TEXT",
1124
+ primaryKey: true,
1125
+ notNull: true
1126
+ }),
1127
+ {
1128
+ name: "deviceId",
1129
+ type: "INTEGER",
1130
+ notNull: true
1131
+ },
1132
+ (
1133
+ /** When an admin muted it — the only thing an audit ever wants to know. */
1134
+ {
1135
+ name: "mutedAt",
1136
+ type: "INTEGER",
1137
+ notNull: true
1138
+ })
1139
+ ];
1140
+ /**
1141
+ * Absence of a row is "not muted"; there is no `muted: false` row. That keeps
1142
+ * the table proportional to what the operator actually silenced, and makes the
1143
+ * hot-path read a `Set.has`.
1144
+ */
1145
+ var NcDeviceMuteStore = class {
1146
+ muted = /* @__PURE__ */ new Set();
1147
+ store;
1148
+ logger;
1149
+ now;
1150
+ constructor(deps) {
1151
+ this.store = deps.store;
1152
+ this.logger = deps.logger;
1153
+ this.now = deps.now ?? (() => Date.now());
1154
+ }
1155
+ static async declare(store) {
1156
+ await store.declareCollection.mutate({
1157
+ collection: NC_DEVICE_MUTES_COLLECTION,
1158
+ columns: [...NC_DEVICE_MUTES_COLUMNS]
1159
+ });
1160
+ }
1161
+ /**
1162
+ * (Re)hydrate. A failed load KEEPS what is already in memory rather than
1163
+ * clearing it: dropping every mute on a transient store error would start
1164
+ * notifying for a camera the operator silenced, without them asking — the
1165
+ * same reasoning as `NcSnoozeStore.load`.
1166
+ */
1167
+ async load() {
1168
+ try {
1169
+ const rows = await this.store.query.query({
1170
+ collection: NC_DEVICE_MUTES_COLLECTION,
1171
+ filter: { limit: 1e4 }
1172
+ });
1173
+ const next = /* @__PURE__ */ new Set();
1174
+ let skipped = 0;
1175
+ for (const row of rows) {
1176
+ const raw = row.data["deviceId"];
1177
+ if (typeof raw !== "number" || !Number.isInteger(raw)) {
1178
+ skipped += 1;
1179
+ continue;
1180
+ }
1181
+ next.add(raw);
1182
+ }
1183
+ this.muted.clear();
1184
+ for (const id of next) this.muted.add(id);
1185
+ if (skipped > 0) this.logger.warn("device-mute rows skipped as malformed — they silence NOTHING", { meta: { skipped } });
1186
+ } catch (err) {
1187
+ this.logger.warn("device-mute load failed — keeping the mutes already in memory", { meta: {
1188
+ error: String(err),
1189
+ muted: this.muted.size
1190
+ } });
1191
+ }
1192
+ }
1193
+ /** Hot-path read, called once per evaluated subject. */
1194
+ isMuted(deviceId) {
1195
+ return this.muted.has(deviceId);
1196
+ }
1197
+ /** Every muted camera, ascending — a stable order so two callers agree. */
1198
+ listMuted() {
1199
+ return [...this.muted].toSorted((a, b) => a - b);
1200
+ }
1201
+ /**
1202
+ * Mute or unmute. Idempotent in both directions; the in-memory set is
1203
+ * updated only after the write lands, so a failed write cannot leave the
1204
+ * hot path believing a camera is silenced when the next boot will not.
1205
+ */
1206
+ async setMuted(deviceId, muted) {
1207
+ const id = String(deviceId);
1208
+ if (muted) {
1209
+ await this.store.set.mutate({
1210
+ collection: NC_DEVICE_MUTES_COLLECTION,
1211
+ key: id,
1212
+ value: {
1213
+ id,
1214
+ deviceId,
1215
+ mutedAt: this.now()
1216
+ }
1217
+ });
1218
+ this.muted.add(deviceId);
1219
+ } else {
1220
+ await this.store.delete.mutate({
1221
+ collection: NC_DEVICE_MUTES_COLLECTION,
1222
+ key: id
1223
+ });
1224
+ this.muted.delete(deviceId);
1225
+ }
1226
+ }
1227
+ };
1228
+ var NcDeviceStateCache = class {
1229
+ deps;
1230
+ states = /* @__PURE__ */ new Map();
1231
+ lastOkAt = 0;
1232
+ ids = [];
1233
+ now;
1234
+ constructor(deps) {
1235
+ this.deps = deps;
1236
+ this.now = deps.now ?? (() => Date.now());
1237
+ }
1238
+ /**
1239
+ * The device ids worth reading — recomputed from the rule set, so a cluster
1240
+ * with no gated rule does no work at all.
1241
+ */
1242
+ setWatched(ids) {
1243
+ this.ids = [...new Set(ids)].sort((a, b) => a - b);
1244
+ }
1245
+ watched() {
1246
+ return this.ids;
1247
+ }
1248
+ /** Hot-path read. No I/O, no await — this is called per event. */
1249
+ get(deviceId) {
1250
+ if (this.now() - this.lastOkAt > 3e5) return void 0;
1251
+ return this.states.get(deviceId);
1252
+ }
1253
+ /**
1254
+ * Re-read every watched state. Never throws: a gate that could fail the
1255
+ * evaluation tick would take the notification pipeline with it.
1256
+ */
1257
+ async refresh() {
1258
+ if (this.ids.length === 0) {
1259
+ this.states = /* @__PURE__ */ new Map();
1260
+ return;
1261
+ }
1262
+ try {
1263
+ const next = await this.deps.readStates(this.ids);
1264
+ this.states = new Map(next);
1265
+ this.lastOkAt = this.now();
1266
+ 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 } });
1267
+ } catch (err) {
1268
+ this.deps.logger.warn("device-state refresh failed — keeping the previous states until they age out", { meta: {
1269
+ watched: this.ids.length,
1270
+ error: String(err)
1271
+ } });
1272
+ }
1273
+ }
1274
+ };
1275
+ //#endregion
1105
1276
  //#region src/pipeline-analytics/pipeline/label-utils.ts
1106
1277
  /**
1107
1278
  * Every macro class the pipeline can carry on a detection. A `label` exists to
@@ -1514,12 +1685,13 @@ function readSensorEventType(value) {
1514
1685
  * what the operator named in the rule. Carrying only the camera is what made a
1515
1686
  * sensor-scoped rule unmatched — see {@link NcRuleSubject.sourceDeviceId}.
1516
1687
  */
1517
- function subjectFromSensorEvent(ev) {
1688
+ function subjectFromSensorEvent(ev, markerTrackId) {
1518
1689
  const eventType = readSensorEventType(ev.value);
1519
1690
  return {
1520
1691
  kind: "device-event",
1521
1692
  recordId: ev.id,
1522
1693
  deviceId: ev.deviceId,
1694
+ ...markerTrackId !== void 0 ? { trackId: markerTrackId } : {},
1523
1695
  ...ev.sourceDeviceId !== ev.deviceId ? { sourceDeviceId: ev.sourceDeviceId } : {},
1524
1696
  timestamp: ev.timestamp,
1525
1697
  classNames: [],
@@ -2386,7 +2558,16 @@ var NcDispatcher = class {
2386
2558
  name: "zone.jpg"
2387
2559
  } : still);
2388
2560
  } else out.push(still);
2389
- }
2561
+ } else if (entry.payload.media !== "none") this.deps.logger.info("no still could be resolved — this notification ships text-only", {
2562
+ tags: { deviceId: entry.payload.subject.deviceId },
2563
+ meta: {
2564
+ recordKind: entry.recordKind,
2565
+ recordId: entry.recordId,
2566
+ policy: entry.payload.media,
2567
+ ...entry.payload.mediaFrame !== void 0 ? { frame: entry.payload.mediaFrame } : {},
2568
+ owners: [...entry.payload.subject.eventId !== void 0 ? [`event:${entry.payload.subject.eventId}`] : [], ...entry.payload.subject.trackId !== void 0 ? [`track:${entry.payload.subject.trackId}`] : []]
2569
+ }
2570
+ });
2390
2571
  const wantGif = entry.payload.mediaGif === true;
2391
2572
  const wantMp4 = entry.payload.mediaClip === true;
2392
2573
  if ((wantGif || wantMp4) && this.deps.renderEventMedia !== void 0) try {
@@ -2595,6 +2776,123 @@ function copyBytes(bytes) {
2595
2776
  return out;
2596
2777
  }
2597
2778
  //#endregion
2779
+ //#region src/notification-center/event-intake.ts
2780
+ /** Object-event insert (movement-state transitions + zone crossings). */
2781
+ function incomingFromObjectEvent(event) {
2782
+ return {
2783
+ subject: subjectFromObjectEvent(event),
2784
+ kind: "object-event",
2785
+ origin: "pipeline",
2786
+ log: () => ({
2787
+ tags: { deviceId: event.deviceId },
2788
+ meta: { eventId: event.id }
2789
+ })
2790
+ };
2791
+ }
2792
+ /** `TrackCloser.closeExpired` — the ONE close point. */
2793
+ function incomingFromTrack(track, info) {
2794
+ return {
2795
+ subject: subjectFromTrack(track, info),
2796
+ kind: "track-end",
2797
+ origin: "pipeline",
2798
+ log: () => ({
2799
+ tags: { deviceId: track.deviceId },
2800
+ meta: { trackId: track.trackId }
2801
+ })
2802
+ };
2803
+ }
2804
+ /**
2805
+ * Sensor-event persist (one row per linked camera).
2806
+ *
2807
+ * `markerTrackId` is the synthetic marker track the same state change
2808
+ * projected onto this camera — the notification's media owner, and the only
2809
+ * frame a device event has. Absent when no marker landed.
2810
+ */
2811
+ function incomingFromSensorEvent(event, markerTrackId) {
2812
+ return {
2813
+ subject: subjectFromSensorEvent(event, markerTrackId),
2814
+ kind: "device-event",
2815
+ origin: "sensor",
2816
+ log: () => ({
2817
+ tags: { deviceId: event.deviceId },
2818
+ meta: {
2819
+ sensorEventId: event.id,
2820
+ kind: event.kind,
2821
+ ...markerTrackId !== void 0 ? { trackId: markerTrackId } : {}
2822
+ }
2823
+ })
2824
+ };
2825
+ }
2826
+ /** Audio-event persist (classified episode). */
2827
+ function incomingFromAudioEvent(event) {
2828
+ return {
2829
+ subject: subjectFromAudioEvent(event),
2830
+ kind: "audio-event",
2831
+ origin: "pipeline",
2832
+ log: () => ({
2833
+ tags: { deviceId: event.deviceId },
2834
+ meta: {
2835
+ audioEventId: event.id,
2836
+ class: event.classification?.className
2837
+ }
2838
+ })
2839
+ };
2840
+ }
2841
+ /** Package object-event persist (delivered / picked-up). */
2842
+ function incomingFromPackageEvent(event, phase) {
2843
+ return {
2844
+ subject: subjectFromPackageEvent(event, phase),
2845
+ kind: "package-event",
2846
+ origin: "pipeline",
2847
+ log: () => ({
2848
+ tags: { deviceId: event.deviceId },
2849
+ meta: {
2850
+ eventId: event.id,
2851
+ phase
2852
+ }
2853
+ })
2854
+ };
2855
+ }
2856
+ /** A committed ZoneAnalytics occupancy edge. */
2857
+ function incomingFromOccupancyEdge(edge) {
2858
+ return {
2859
+ subject: subjectFromOccupancyEvent(edge),
2860
+ kind: "occupancy-event",
2861
+ origin: "pipeline",
2862
+ log: () => ({
2863
+ tags: { deviceId: edge.deviceId },
2864
+ meta: {
2865
+ zoneId: edge.zoneId ?? "@frame",
2866
+ className: edge.className ?? "@all",
2867
+ count: edge.count,
2868
+ threshold: edge.threshold,
2869
+ occupied: edge.occupied
2870
+ }
2871
+ })
2872
+ };
2873
+ }
2874
+ /** Re-stamp a live-producer envelope as a boot-reconcile replay of the same
2875
+ * record — same subject, honest origin in the log. */
2876
+ function asReconcile(event) {
2877
+ return {
2878
+ ...event,
2879
+ origin: "reconcile"
2880
+ };
2881
+ }
2882
+ /**
2883
+ * The delivery an intake kind rides. `object-event` and `audio-event` both ride
2884
+ * `immediate` (classified audio has no delivery of its own — it is gated by the
2885
+ * audio-class opt-in instead); `occupancy-event` rides `device-event`.
2886
+ *
2887
+ * Extracted from the centre so the tester can tell an operator which rules a
2888
+ * given simulated trigger will even be considered against, without evaluating.
2889
+ */
2890
+ function deliveryForKind(kind) {
2891
+ if (kind === "object-event" || kind === "audio-event") return "immediate";
2892
+ if (kind === "occupancy-event") return "device-event";
2893
+ return kind;
2894
+ }
2895
+ //#endregion
2598
2896
  //#region src/notification-center/occupancy-watcher.ts
2599
2897
  /** Sentinel key segments for the "no zone" (whole-frame) and "no class" scopes. */
2600
2898
  var FRAME_SCOPE = "@frame";
@@ -3450,460 +3748,6 @@ function rowToEntry$1(id, data) {
3450
3748
  };
3451
3749
  }
3452
3750
  //#endregion
3453
- //#region src/notification-center/event-intake.ts
3454
- /** Object-event insert (movement-state transitions + zone crossings). */
3455
- function incomingFromObjectEvent(event) {
3456
- return {
3457
- subject: subjectFromObjectEvent(event),
3458
- kind: "object-event",
3459
- origin: "pipeline",
3460
- log: () => ({
3461
- tags: { deviceId: event.deviceId },
3462
- meta: { eventId: event.id }
3463
- })
3464
- };
3465
- }
3466
- /** `TrackCloser.closeExpired` — the ONE close point. */
3467
- function incomingFromTrack(track, info) {
3468
- return {
3469
- subject: subjectFromTrack(track, info),
3470
- kind: "track-end",
3471
- origin: "pipeline",
3472
- log: () => ({
3473
- tags: { deviceId: track.deviceId },
3474
- meta: { trackId: track.trackId }
3475
- })
3476
- };
3477
- }
3478
- /** Sensor-event persist (one row per linked camera). */
3479
- function incomingFromSensorEvent(event) {
3480
- return {
3481
- subject: subjectFromSensorEvent(event),
3482
- kind: "device-event",
3483
- origin: "sensor",
3484
- log: () => ({
3485
- tags: { deviceId: event.deviceId },
3486
- meta: {
3487
- sensorEventId: event.id,
3488
- kind: event.kind
3489
- }
3490
- })
3491
- };
3492
- }
3493
- /** Audio-event persist (classified episode). */
3494
- function incomingFromAudioEvent(event) {
3495
- return {
3496
- subject: subjectFromAudioEvent(event),
3497
- kind: "audio-event",
3498
- origin: "pipeline",
3499
- log: () => ({
3500
- tags: { deviceId: event.deviceId },
3501
- meta: {
3502
- audioEventId: event.id,
3503
- class: event.classification?.className
3504
- }
3505
- })
3506
- };
3507
- }
3508
- /** Package object-event persist (delivered / picked-up). */
3509
- function incomingFromPackageEvent(event, phase) {
3510
- return {
3511
- subject: subjectFromPackageEvent(event, phase),
3512
- kind: "package-event",
3513
- origin: "pipeline",
3514
- log: () => ({
3515
- tags: { deviceId: event.deviceId },
3516
- meta: {
3517
- eventId: event.id,
3518
- phase
3519
- }
3520
- })
3521
- };
3522
- }
3523
- /** A committed ZoneAnalytics occupancy edge. */
3524
- function incomingFromOccupancyEdge(edge) {
3525
- return {
3526
- subject: subjectFromOccupancyEvent(edge),
3527
- kind: "occupancy-event",
3528
- origin: "pipeline",
3529
- log: () => ({
3530
- tags: { deviceId: edge.deviceId },
3531
- meta: {
3532
- zoneId: edge.zoneId ?? "@frame",
3533
- className: edge.className ?? "@all",
3534
- count: edge.count,
3535
- threshold: edge.threshold,
3536
- occupied: edge.occupied
3537
- }
3538
- })
3539
- };
3540
- }
3541
- /** Re-stamp a live-producer envelope as a boot-reconcile replay of the same
3542
- * record — same subject, honest origin in the log. */
3543
- function asReconcile(event) {
3544
- return {
3545
- ...event,
3546
- origin: "reconcile"
3547
- };
3548
- }
3549
- /**
3550
- * The delivery an intake kind rides. `object-event` and `audio-event` both ride
3551
- * `immediate` (classified audio has no delivery of its own — it is gated by the
3552
- * audio-class opt-in instead); `occupancy-event` rides `device-event`.
3553
- *
3554
- * Extracted from the centre so the tester can tell an operator which rules a
3555
- * given simulated trigger will even be considered against, without evaluating.
3556
- */
3557
- function deliveryForKind(kind) {
3558
- if (kind === "object-event" || kind === "audio-event") return "immediate";
3559
- if (kind === "occupancy-event") return "device-event";
3560
- return kind;
3561
- }
3562
- //#endregion
3563
- //#region src/notification-center/test-event.ts
3564
- /** Normalized 0..1 rectangle, top-left origin — the subject bbox space. */
3565
- var BboxSchema = object({
3566
- x: number().min(0).max(1),
3567
- y: number().min(0).max(1),
3568
- w: number().min(0).max(1),
3569
- h: number().min(0).max(1)
3570
- });
3571
- /**
3572
- * What the operator typed. Every field maps onto exactly one field of
3573
- * {@link NcRuleSubject} — there is no test-only condition input, because a
3574
- * condition the tester could set and a real record could not is a condition the
3575
- * test result would lie about.
3576
- */
3577
- var NcTestEventInputSchema = object({
3578
- trigger: _enum([
3579
- "object-event",
3580
- "track-end",
3581
- "device-event",
3582
- "package-event",
3583
- "audio-event",
3584
- "occupancy-event"
3585
- ]),
3586
- /** The CAMERA the event is attributed to (what a notification shows). */
3587
- deviceId: number().int(),
3588
- /** DEVICE-EVENT: the linked device whose state changed — the contact, the
3589
- * button, the doorbell — when that is not the camera. */
3590
- sourceDeviceId: number().int().optional(),
3591
- /** Detection class ids, from the NC taxonomy (`person`, `car`, `audio-…`). */
3592
- classNames: array(string()).default([]),
3593
- /** Collapsed display label — an identity name, a plate, a subclass. */
3594
- label: string().optional(),
3595
- /** Detection confidence [0,1]. ABSENT is meaningful: a `minConfidence`
3596
- * condition FAILS on an absent confidence, and the tester must be able to
3597
- * reproduce that. */
3598
- confidence: number().min(0).max(1).optional(),
3599
- /** Identity/plate MATCH confidence — distinct from `confidence`. */
3600
- labelConfidence: number().min(0).max(1).optional(),
3601
- /** Key-event importance [0,1] (track-end only, in practice). */
3602
- importance: number().min(0).max(1).optional(),
3603
- /** Track lifespan in seconds (track-end only). */
3604
- dwellSeconds: number().min(0).optional(),
3605
- /** Admin zone ids the subject is in (object) / visited (track). */
3606
- zones: array(string()).default([]),
3607
- /** OBJECT-EVENT: simulate a confirmed zone crossing. */
3608
- crossing: object({
3609
- zoneId: string(),
3610
- direction: _enum(["enter", "exit"])
3611
- }).optional(),
3612
- /** DEVICE-EVENT: the taxonomy kind id (`doorbell`, `contact`, `button`, …). */
3613
- sensorKind: string().optional(),
3614
- /** DEVICE-EVENT: the raw device event-type token, when the source is an
3615
- * event-emitter slice. */
3616
- eventType: string().optional(),
3617
- /** PACKAGE-EVENT: which lifecycle phase. */
3618
- packagePhase: _enum(["delivered", "picked-up"]).optional(),
3619
- /** Detection provenance. Absent = `pipeline` (what a record with no stamped
3620
- * source reads as). */
3621
- source: _enum([
3622
- "pipeline",
3623
- "onboard",
3624
- "sensor",
3625
- "audio"
3626
- ]).optional(),
3627
- /** Normalized bbox — feeds `customZones`, AND is what lets the still be
3628
- * cropped / boxed the way the rule's media policy asks. */
3629
- bbox: BboxSchema.optional(),
3630
- /** OCCUPANCY-EVENT: the committed count edge. */
3631
- occupancy: object({
3632
- zoneId: string().optional(),
3633
- zoneName: string().optional(),
3634
- className: string().optional(),
3635
- count: number().int().min(0),
3636
- previousCount: number().int().min(0).default(0),
3637
- occupied: boolean(),
3638
- threshold: number().int().min(1)
3639
- }).optional(),
3640
- /** The instant the event happened. Absent = now. Set it to test a schedule
3641
- * condition without waiting for the hour. */
3642
- timestamp: number().int().optional(),
3643
- /** Evaluate only this rule (what the per-rule Test button sends). */
3644
- onlyRuleId: string().optional(),
3645
- bypassCooldown: boolean().default(false),
3646
- bypassMute: boolean().default(false),
3647
- /** Run the matched rule's `onTrigger` sequences. OFF by default: a test must
3648
- * not open a gate the operator did not ask to open. */
3649
- runActions: boolean().default(false),
3650
- /** Where the still comes from. `live` takes a snapshot of the camera NOW;
3651
- * `none` sends the notification without one. */
3652
- still: _enum(["live", "none"]).default("live")
3653
- });
3654
- /** Per-rule verdict, as the tester reports it. */
3655
- var NcTestEventRuleOutcomeSchema = object({
3656
- ruleId: string(),
3657
- ruleName: string(),
3658
- matched: boolean(),
3659
- failedCondition: string().optional(),
3660
- matchedOn: array(string()).optional(),
3661
- cooldown: _enum([
3662
- "n/a",
3663
- "clear",
3664
- "suppressed",
3665
- "bypassed"
3666
- ]),
3667
- enqueued: number().int(),
3668
- targetIds: array(string())
3669
- });
3670
- var NcTestEventResultSchema = object({
3671
- /** The synthetic record id. It is `synthetic:<uuid>` and it is what to grep
3672
- * for in the logs, in the outbox and in the delivery history. */
3673
- recordId: string(),
3674
- /** Always `synthetic` — the marker the whole path carried. */
3675
- origin: literal("synthetic"),
3676
- /** The delivery the chosen trigger rides. A rule declaring anything else was
3677
- * never a candidate, which is the answer to most "it did not fire". */
3678
- delivery: string(),
3679
- candidates: number().int(),
3680
- outcomes: array(NcTestEventRuleOutcomeSchema),
3681
- /** What the still ended up being. `unattachable` means the chosen TRIGGER
3682
- * cannot carry one at all — a device-event notification has no media owner,
3683
- * live or synthetic — and that is a property of production, not of the test. */
3684
- still: _enum([
3685
- "live",
3686
- "none",
3687
- "unavailable",
3688
- "unattachable"
3689
- ]),
3690
- /** Everything the operator has to know to read the result honestly. */
3691
- notes: array(string()),
3692
- droppedBy: string().optional()
3693
- });
3694
- /** The id shape every synthetic record carries. One prefix, everywhere. */
3695
- var SYNTHETIC_ID_PREFIX = "synthetic:";
3696
- /** True for a record id this module minted. Used by the media shim. */
3697
- function isSyntheticId(id) {
3698
- return id.startsWith(SYNTHETIC_ID_PREFIX);
3699
- }
3700
- /**
3701
- * Whether a subject of this kind can carry a still AT ALL, in production.
3702
- *
3703
- * The attachment ladder resolves media by OWNER, and `buildEntries` freezes an
3704
- * owner onto the payload only for the kinds that have one: an `eventId` for
3705
- * object/package events, a `trackId` for track-scoped ones. A device-event, an
3706
- * audio episode and an occupancy edge freeze neither — so those notifications
3707
- * ship text-only today, and a tester that attached an image to them would be
3708
- * advertising a feature that does not exist.
3709
- */
3710
- function triggerCanCarryStill(kind) {
3711
- return kind === "object-event" || kind === "package-event" || kind === "track-end";
3712
- }
3713
- /**
3714
- * Build the synthetic envelope. Pure: same input + same id + same clock ⇒ same
3715
- * event, so the red-green test can assert that this subject and a
3716
- * producer-built one are indistinguishable.
3717
- */
3718
- function buildSyntheticEvent(input, recordId, now) {
3719
- const kind = input.trigger;
3720
- const timestamp = input.timestamp ?? now;
3721
- const trackScoped = kind === "object-event" || kind === "track-end";
3722
- return {
3723
- subject: {
3724
- kind,
3725
- recordId,
3726
- deviceId: input.deviceId,
3727
- ...input.sourceDeviceId !== void 0 && input.sourceDeviceId !== input.deviceId ? { sourceDeviceId: input.sourceDeviceId } : {},
3728
- timestamp,
3729
- classNames: input.classNames,
3730
- ...input.label !== void 0 ? { label: input.label } : {},
3731
- ...input.confidence !== void 0 ? { confidence: input.confidence } : {},
3732
- zones: input.zones,
3733
- ...trackScoped ? { trackId: recordId } : {},
3734
- source: input.source ?? (kind === "device-event" ? "sensor" : "pipeline"),
3735
- ...input.importance !== void 0 ? { importance: input.importance } : {},
3736
- ...input.dwellSeconds !== void 0 ? { dwellSeconds: input.dwellSeconds } : {},
3737
- ...input.labelConfidence !== void 0 ? { labelConfidence: input.labelConfidence } : {},
3738
- ...input.sensorKind !== void 0 ? { sensorKind: input.sensorKind } : {},
3739
- ...input.eventType !== void 0 ? { eventType: input.eventType } : {},
3740
- ...input.packagePhase !== void 0 ? { packagePhase: input.packagePhase } : {},
3741
- ...input.bbox !== void 0 ? { bbox: input.bbox } : {},
3742
- ...input.crossing !== void 0 ? { crossing: input.crossing } : {},
3743
- ...input.occupancy !== void 0 ? { occupancy: {
3744
- ...input.occupancy.zoneId !== void 0 ? { zoneId: input.occupancy.zoneId } : {},
3745
- ...input.occupancy.zoneName !== void 0 ? { zoneName: input.occupancy.zoneName } : {},
3746
- ...input.occupancy.className !== void 0 ? { className: input.occupancy.className } : {},
3747
- count: input.occupancy.count,
3748
- previousCount: input.occupancy.previousCount,
3749
- occupied: input.occupancy.occupied,
3750
- threshold: input.occupancy.threshold
3751
- } } : {}
3752
- },
3753
- kind,
3754
- origin: "synthetic",
3755
- controls: {
3756
- ...input.onlyRuleId !== void 0 ? { onlyRuleId: input.onlyRuleId } : {},
3757
- bypassCooldown: input.bypassCooldown,
3758
- bypassMute: input.bypassMute,
3759
- runActions: input.runActions
3760
- },
3761
- log: () => ({
3762
- tags: { deviceId: input.deviceId },
3763
- meta: {
3764
- eventId: recordId,
3765
- synthetic: true,
3766
- trigger: input.trigger,
3767
- ...input.onlyRuleId !== void 0 ? { onlyRuleId: input.onlyRuleId } : {}
3768
- }
3769
- })
3770
- };
3771
- }
3772
- /**
3773
- * Turn one live JPEG into the media set the dispatcher's ladders look for.
3774
- *
3775
- * Every entry is the SAME photograph, differing only in how it was cut — which
3776
- * is exactly what the ladders mean by their kind names, so nothing here claims
3777
- * a frame it does not have:
3778
- *
3779
- * - `fullFrame` / `keyFrame` / `firstFrame` — the clean scene, verbatim.
3780
- * - `fullFrameBoxed` — the clean scene with the operator's bbox drawn on it.
3781
- * - `crop` / `thumbnail` — that bbox, cut out.
3782
- *
3783
- * With NO bbox only the clean-scene kinds are produced, so a rule asking for
3784
- * `frame: 'cropped'` correctly finds nothing rather than being handed a whole
3785
- * scene it did not ask for. sharp failures degrade the same way: the clean
3786
- * scene always survives.
3787
- */
3788
- async function deriveSyntheticMedia(jpeg, bbox, timestamp) {
3789
- const base64 = Buffer.from(jpeg).toString("base64");
3790
- const clean = (kind) => ({
3791
- key: `${SYNTHETIC_ID_PREFIX}${kind}`,
3792
- kind,
3793
- base64,
3794
- sizeBytes: jpeg.byteLength,
3795
- timestamp
3796
- });
3797
- const files = [
3798
- clean("fullFrame"),
3799
- clean("keyFrame"),
3800
- clean("firstFrame")
3801
- ];
3802
- if (bbox === void 0) return files;
3803
- try {
3804
- const { default: sharp } = await import("sharp");
3805
- const meta = await sharp(Buffer.from(jpeg)).metadata();
3806
- const W = meta.width ?? 0;
3807
- const H = meta.height ?? 0;
3808
- if (W === 0 || H === 0) return files;
3809
- const left = Math.max(0, Math.min(W - 1, Math.floor(bbox.x * W)));
3810
- const top = Math.max(0, Math.min(H - 1, Math.floor(bbox.y * H)));
3811
- const width = Math.max(1, Math.min(W - left, Math.ceil(bbox.w * W)));
3812
- const height = Math.max(1, Math.min(H - top, Math.ceil(bbox.h * H)));
3813
- const cropBuf = await sharp(Buffer.from(jpeg)).extract({
3814
- left,
3815
- top,
3816
- width,
3817
- height
3818
- }).jpeg({ quality: 85 }).toBuffer();
3819
- const cropB64 = cropBuf.toString("base64");
3820
- files.push({
3821
- key: `${SYNTHETIC_ID_PREFIX}crop`,
3822
- kind: "crop",
3823
- base64: cropB64,
3824
- sizeBytes: cropBuf.byteLength,
3825
- timestamp
3826
- }, {
3827
- key: `${SYNTHETIC_ID_PREFIX}thumbnail`,
3828
- kind: "thumbnail",
3829
- base64: cropB64,
3830
- sizeBytes: cropBuf.byteLength,
3831
- timestamp
3832
- });
3833
- const stroke = Math.max(2, Math.round(Math.min(W, H) / 240));
3834
- 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>`);
3835
- const boxedBuf = await sharp(Buffer.from(jpeg)).composite([{
3836
- input: overlay,
3837
- top: 0,
3838
- left: 0
3839
- }]).jpeg({ quality: 85 }).toBuffer();
3840
- files.push({
3841
- key: `${SYNTHETIC_ID_PREFIX}fullFrameBoxed`,
3842
- kind: "fullFrameBoxed",
3843
- base64: boxedBuf.toString("base64"),
3844
- sizeBytes: boxedBuf.byteLength,
3845
- timestamp
3846
- });
3847
- } catch {
3848
- return files;
3849
- }
3850
- return files;
3851
- }
3852
- //#endregion
3853
- //#region src/notification-center/action-buttons.ts
3854
- /** Sequence names the rule actually declares. A button may only name one. */
3855
- function declaredSequences(actions) {
3856
- const names = /* @__PURE__ */ new Set();
3857
- for (const sequence of actions.onTrigger ?? []) names.add(sequence.name);
3858
- return names;
3859
- }
3860
- /**
3861
- * Build the notification's buttons.
3862
- *
3863
- * Returns an empty array rather than `undefined` — the caller decides whether
3864
- * to set the field, and an empty array is the honest answer to "which buttons
3865
- * survived", which `undefined` would conflate with "the rule declared none".
3866
- */
3867
- function buildActionButtons(input) {
3868
- const actions = input.actions;
3869
- if (actions === void 0) return [];
3870
- const buttons = actions.buttons ?? [];
3871
- if (buttons.length === 0) return [];
3872
- const known = declaredSequences(actions);
3873
- const seen = /* @__PURE__ */ new Set();
3874
- const out = [];
3875
- for (const button of buttons) {
3876
- if (!known.has(button.sequence)) continue;
3877
- if (seen.has(button.id)) continue;
3878
- seen.add(button.id);
3879
- out.push({
3880
- id: button.id,
3881
- label: button.label,
3882
- url: input.mintUrl({
3883
- sequence: button.sequence,
3884
- actionId: button.id
3885
- }),
3886
- ...button.icon !== void 0 ? { icon: button.icon } : {},
3887
- ...button.destructive !== void 0 ? { destructive: button.destructive } : {}
3888
- });
3889
- }
3890
- return out;
3891
- }
3892
- /**
3893
- * Which of a rule's buttons name a sequence it does not have.
3894
- *
3895
- * Exported so the caller can LOG the drop. A button silently missing from a
3896
- * notification is the exact shape of failure this repo keeps paying for — the
3897
- * operator authored it, it never appeared, and nothing said why.
3898
- */
3899
- function unresolvableButtons(actions) {
3900
- if (actions === void 0) return [];
3901
- const buttons = actions.buttons ?? [];
3902
- if (buttons.length === 0) return [];
3903
- const known = declaredSequences(actions);
3904
- return buttons.filter((b) => !known.has(b.sequence)).map((b) => `${b.id}→${b.sequence}`);
3905
- }
3906
- //#endregion
3907
3751
  //#region src/notification-center/rule-actions.ts
3908
3752
  var NcRuleActionRunner = class {
3909
3753
  deps;
@@ -4216,53 +4060,6 @@ var NcRuleStore = class {
4216
4060
  });
4217
4061
  }
4218
4062
  };
4219
- var NcDeviceStateCache = class {
4220
- deps;
4221
- states = /* @__PURE__ */ new Map();
4222
- lastOkAt = 0;
4223
- ids = [];
4224
- now;
4225
- constructor(deps) {
4226
- this.deps = deps;
4227
- this.now = deps.now ?? (() => Date.now());
4228
- }
4229
- /**
4230
- * The device ids worth reading — recomputed from the rule set, so a cluster
4231
- * with no gated rule does no work at all.
4232
- */
4233
- setWatched(ids) {
4234
- this.ids = [...new Set(ids)].sort((a, b) => a - b);
4235
- }
4236
- watched() {
4237
- return this.ids;
4238
- }
4239
- /** Hot-path read. No I/O, no await — this is called per event. */
4240
- get(deviceId) {
4241
- if (this.now() - this.lastOkAt > 3e5) return void 0;
4242
- return this.states.get(deviceId);
4243
- }
4244
- /**
4245
- * Re-read every watched state. Never throws: a gate that could fail the
4246
- * evaluation tick would take the notification pipeline with it.
4247
- */
4248
- async refresh() {
4249
- if (this.ids.length === 0) {
4250
- this.states = /* @__PURE__ */ new Map();
4251
- return;
4252
- }
4253
- try {
4254
- const next = await this.deps.readStates(this.ids);
4255
- this.states = new Map(next);
4256
- this.lastOkAt = this.now();
4257
- 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 } });
4258
- } catch (err) {
4259
- this.deps.logger.warn("device-state refresh failed — keeping the previous states until they age out", { meta: {
4260
- watched: this.ids.length,
4261
- error: String(err)
4262
- } });
4263
- }
4264
- }
4265
- };
4266
4063
  //#endregion
4267
4064
  //#region src/notification-center/snooze-digest.ts
4268
4065
  /** `14:03` in the node's local zone — a digest is read by a person, at home. */
@@ -4379,119 +4176,6 @@ function dueSummaries(entries, nowMs, alreadySent) {
4379
4176
  return entries.filter((e) => e.summary && nowMs >= e.expiresAt && !alreadySent.has(e.id));
4380
4177
  }
4381
4178
  //#endregion
4382
- //#region src/notification-center/device-mute-store.ts
4383
- var NC_DEVICE_MUTES_COLLECTION = "notification-center:device-mutes";
4384
- var NC_DEVICE_MUTES_COLUMNS = [
4385
- (
4386
- /** `String(deviceId)` — one row per muted camera; unmute DELETES the row. */
4387
- {
4388
- name: "id",
4389
- type: "TEXT",
4390
- primaryKey: true,
4391
- notNull: true
4392
- }),
4393
- {
4394
- name: "deviceId",
4395
- type: "INTEGER",
4396
- notNull: true
4397
- },
4398
- (
4399
- /** When an admin muted it — the only thing an audit ever wants to know. */
4400
- {
4401
- name: "mutedAt",
4402
- type: "INTEGER",
4403
- notNull: true
4404
- })
4405
- ];
4406
- /**
4407
- * Absence of a row is "not muted"; there is no `muted: false` row. That keeps
4408
- * the table proportional to what the operator actually silenced, and makes the
4409
- * hot-path read a `Set.has`.
4410
- */
4411
- var NcDeviceMuteStore = class {
4412
- muted = /* @__PURE__ */ new Set();
4413
- store;
4414
- logger;
4415
- now;
4416
- constructor(deps) {
4417
- this.store = deps.store;
4418
- this.logger = deps.logger;
4419
- this.now = deps.now ?? (() => Date.now());
4420
- }
4421
- static async declare(store) {
4422
- await store.declareCollection.mutate({
4423
- collection: NC_DEVICE_MUTES_COLLECTION,
4424
- columns: [...NC_DEVICE_MUTES_COLUMNS]
4425
- });
4426
- }
4427
- /**
4428
- * (Re)hydrate. A failed load KEEPS what is already in memory rather than
4429
- * clearing it: dropping every mute on a transient store error would start
4430
- * notifying for a camera the operator silenced, without them asking — the
4431
- * same reasoning as `NcSnoozeStore.load`.
4432
- */
4433
- async load() {
4434
- try {
4435
- const rows = await this.store.query.query({
4436
- collection: NC_DEVICE_MUTES_COLLECTION,
4437
- filter: { limit: 1e4 }
4438
- });
4439
- const next = /* @__PURE__ */ new Set();
4440
- let skipped = 0;
4441
- for (const row of rows) {
4442
- const raw = row.data["deviceId"];
4443
- if (typeof raw !== "number" || !Number.isInteger(raw)) {
4444
- skipped += 1;
4445
- continue;
4446
- }
4447
- next.add(raw);
4448
- }
4449
- this.muted.clear();
4450
- for (const id of next) this.muted.add(id);
4451
- if (skipped > 0) this.logger.warn("device-mute rows skipped as malformed — they silence NOTHING", { meta: { skipped } });
4452
- } catch (err) {
4453
- this.logger.warn("device-mute load failed — keeping the mutes already in memory", { meta: {
4454
- error: String(err),
4455
- muted: this.muted.size
4456
- } });
4457
- }
4458
- }
4459
- /** Hot-path read, called once per evaluated subject. */
4460
- isMuted(deviceId) {
4461
- return this.muted.has(deviceId);
4462
- }
4463
- /** Every muted camera, ascending — a stable order so two callers agree. */
4464
- listMuted() {
4465
- return [...this.muted].toSorted((a, b) => a - b);
4466
- }
4467
- /**
4468
- * Mute or unmute. Idempotent in both directions; the in-memory set is
4469
- * updated only after the write lands, so a failed write cannot leave the
4470
- * hot path believing a camera is silenced when the next boot will not.
4471
- */
4472
- async setMuted(deviceId, muted) {
4473
- const id = String(deviceId);
4474
- if (muted) {
4475
- await this.store.set.mutate({
4476
- collection: NC_DEVICE_MUTES_COLLECTION,
4477
- key: id,
4478
- value: {
4479
- id,
4480
- deviceId,
4481
- mutedAt: this.now()
4482
- }
4483
- });
4484
- this.muted.add(deviceId);
4485
- } else {
4486
- await this.store.delete.mutate({
4487
- collection: NC_DEVICE_MUTES_COLLECTION,
4488
- key: id
4489
- });
4490
- this.muted.delete(deviceId);
4491
- }
4492
- }
4493
- };
4494
- //#endregion
4495
4179
  //#region src/notification-center/snooze-store.ts
4496
4180
  /**
4497
4181
  * NcSnoozeStore — the durable half of snooze.
@@ -4845,6 +4529,306 @@ var NcSnoozeStore = class {
4845
4529
  }
4846
4530
  };
4847
4531
  //#endregion
4532
+ //#region src/notification-center/test-event.ts
4533
+ /** Normalized 0..1 rectangle, top-left origin — the subject bbox space. */
4534
+ var BboxSchema = object({
4535
+ x: number().min(0).max(1),
4536
+ y: number().min(0).max(1),
4537
+ w: number().min(0).max(1),
4538
+ h: number().min(0).max(1)
4539
+ });
4540
+ /**
4541
+ * What the operator typed. Every field maps onto exactly one field of
4542
+ * {@link NcRuleSubject} — there is no test-only condition input, because a
4543
+ * condition the tester could set and a real record could not is a condition the
4544
+ * test result would lie about.
4545
+ */
4546
+ var NcTestEventInputSchema = object({
4547
+ trigger: _enum([
4548
+ "object-event",
4549
+ "track-end",
4550
+ "device-event",
4551
+ "package-event",
4552
+ "audio-event",
4553
+ "occupancy-event"
4554
+ ]),
4555
+ /** The CAMERA the event is attributed to (what a notification shows). */
4556
+ deviceId: number().int(),
4557
+ /** DEVICE-EVENT: the linked device whose state changed — the contact, the
4558
+ * button, the doorbell — when that is not the camera. */
4559
+ sourceDeviceId: number().int().optional(),
4560
+ /** Detection class ids, from the NC taxonomy (`person`, `car`, `audio-…`). */
4561
+ classNames: array(string()).default([]),
4562
+ /** Collapsed display label — an identity name, a plate, a subclass. */
4563
+ label: string().optional(),
4564
+ /** Detection confidence [0,1]. ABSENT is meaningful: a `minConfidence`
4565
+ * condition FAILS on an absent confidence, and the tester must be able to
4566
+ * reproduce that. */
4567
+ confidence: number().min(0).max(1).optional(),
4568
+ /** Identity/plate MATCH confidence — distinct from `confidence`. */
4569
+ labelConfidence: number().min(0).max(1).optional(),
4570
+ /** Key-event importance [0,1] (track-end only, in practice). */
4571
+ importance: number().min(0).max(1).optional(),
4572
+ /** Track lifespan in seconds (track-end only). */
4573
+ dwellSeconds: number().min(0).optional(),
4574
+ /** Admin zone ids the subject is in (object) / visited (track). */
4575
+ zones: array(string()).default([]),
4576
+ /** OBJECT-EVENT: simulate a confirmed zone crossing. */
4577
+ crossing: object({
4578
+ zoneId: string(),
4579
+ direction: _enum(["enter", "exit"])
4580
+ }).optional(),
4581
+ /** DEVICE-EVENT: the taxonomy kind id (`doorbell`, `contact`, `button`, …). */
4582
+ sensorKind: string().optional(),
4583
+ /** DEVICE-EVENT: the raw device event-type token, when the source is an
4584
+ * event-emitter slice. */
4585
+ eventType: string().optional(),
4586
+ /** PACKAGE-EVENT: which lifecycle phase. */
4587
+ packagePhase: _enum(["delivered", "picked-up"]).optional(),
4588
+ /** Detection provenance. Absent = `pipeline` (what a record with no stamped
4589
+ * source reads as). */
4590
+ source: _enum([
4591
+ "pipeline",
4592
+ "onboard",
4593
+ "sensor",
4594
+ "audio"
4595
+ ]).optional(),
4596
+ /** Normalized bbox — feeds `customZones`, AND is what lets the still be
4597
+ * cropped / boxed the way the rule's media policy asks. */
4598
+ bbox: BboxSchema.optional(),
4599
+ /** OCCUPANCY-EVENT: the committed count edge. */
4600
+ occupancy: object({
4601
+ zoneId: string().optional(),
4602
+ zoneName: string().optional(),
4603
+ className: string().optional(),
4604
+ count: number().int().min(0),
4605
+ previousCount: number().int().min(0).default(0),
4606
+ occupied: boolean(),
4607
+ threshold: number().int().min(1)
4608
+ }).optional(),
4609
+ /** The instant the event happened. Absent = now. Set it to test a schedule
4610
+ * condition without waiting for the hour. */
4611
+ timestamp: number().int().optional(),
4612
+ /** Evaluate only this rule (what the per-rule Test button sends). */
4613
+ onlyRuleId: string().optional(),
4614
+ bypassCooldown: boolean().default(false),
4615
+ bypassMute: boolean().default(false),
4616
+ /** Run the matched rule's `onTrigger` sequences. OFF by default: a test must
4617
+ * not open a gate the operator did not ask to open. */
4618
+ runActions: boolean().default(false),
4619
+ /** Where the still comes from. `live` takes a snapshot of the camera NOW;
4620
+ * `none` sends the notification without one. */
4621
+ still: _enum(["live", "none"]).default("live")
4622
+ });
4623
+ /** Per-rule verdict, as the tester reports it. */
4624
+ var NcTestEventRuleOutcomeSchema = object({
4625
+ ruleId: string(),
4626
+ ruleName: string(),
4627
+ matched: boolean(),
4628
+ failedCondition: string().optional(),
4629
+ matchedOn: array(string()).optional(),
4630
+ cooldown: _enum([
4631
+ "n/a",
4632
+ "clear",
4633
+ "suppressed",
4634
+ "bypassed"
4635
+ ]),
4636
+ enqueued: number().int(),
4637
+ targetIds: array(string())
4638
+ });
4639
+ var NcTestEventResultSchema = object({
4640
+ /** The synthetic record id. It is `synthetic:<uuid>` and it is what to grep
4641
+ * for in the logs, in the outbox and in the delivery history. */
4642
+ recordId: string(),
4643
+ /** Always `synthetic` — the marker the whole path carried. */
4644
+ origin: literal("synthetic"),
4645
+ /** The delivery the chosen trigger rides. A rule declaring anything else was
4646
+ * never a candidate, which is the answer to most "it did not fire". */
4647
+ delivery: string(),
4648
+ candidates: number().int(),
4649
+ outcomes: array(NcTestEventRuleOutcomeSchema),
4650
+ /** What the still ended up being. `unattachable` means the chosen TRIGGER
4651
+ * cannot carry one at all — an audio episode and an occupancy edge freeze no
4652
+ * media owner, so those notifications ship text-only in production too, and
4653
+ * that is a property of production, not of the test. A DEVICE event is no
4654
+ * longer one of them: its press projects a marker track that owns the frame. */
4655
+ still: _enum([
4656
+ "live",
4657
+ "none",
4658
+ "unavailable",
4659
+ "unattachable"
4660
+ ]),
4661
+ /** Everything the operator has to know to read the result honestly. */
4662
+ notes: array(string()),
4663
+ droppedBy: string().optional()
4664
+ });
4665
+ /** The id shape every synthetic record carries. One prefix, everywhere. */
4666
+ var SYNTHETIC_ID_PREFIX = "synthetic:";
4667
+ /** True for a record id this module minted. Used by the media shim. */
4668
+ function isSyntheticId(id) {
4669
+ return id.startsWith(SYNTHETIC_ID_PREFIX);
4670
+ }
4671
+ /**
4672
+ * Whether a subject of this kind can carry a still AT ALL, in production.
4673
+ *
4674
+ * The attachment ladder resolves media by OWNER, and `buildEntries` freezes an
4675
+ * owner onto the payload only for the kinds that have one:
4676
+ *
4677
+ * - an `eventId` for object and package events;
4678
+ * - a `trackId` for track-scoped ones — and, since 2026-08-08, for a
4679
+ * DEVICE event too: the same state change projects a synthetic marker track
4680
+ * carrying the camera's frame (`sensor-marker-projector.ts`), and the
4681
+ * notification names it as its owner.
4682
+ *
4683
+ * An audio episode and an occupancy edge still freeze neither. Audio HAS a
4684
+ * marker projection, but its id does not reach the notification subject yet;
4685
+ * occupancy has no projection at all. Those two therefore remain honestly
4686
+ * unattachable — a tester that invented a picture for them would be advertising
4687
+ * a feature that does not exist.
4688
+ */
4689
+ function triggerCanCarryStill(kind) {
4690
+ return kind === "object-event" || kind === "package-event" || kind === "track-end" || kind === "device-event";
4691
+ }
4692
+ /**
4693
+ * Build the synthetic envelope. Pure: same input + same id + same clock ⇒ same
4694
+ * event, so the red-green test can assert that this subject and a
4695
+ * producer-built one are indistinguishable.
4696
+ */
4697
+ function buildSyntheticEvent(input, recordId, now) {
4698
+ const kind = input.trigger;
4699
+ const timestamp = input.timestamp ?? now;
4700
+ const carriesTrackId = kind === "object-event" || kind === "track-end" || kind === "device-event";
4701
+ return {
4702
+ subject: {
4703
+ kind,
4704
+ recordId,
4705
+ deviceId: input.deviceId,
4706
+ ...input.sourceDeviceId !== void 0 && input.sourceDeviceId !== input.deviceId ? { sourceDeviceId: input.sourceDeviceId } : {},
4707
+ timestamp,
4708
+ classNames: input.classNames,
4709
+ ...input.label !== void 0 ? { label: input.label } : {},
4710
+ ...input.confidence !== void 0 ? { confidence: input.confidence } : {},
4711
+ zones: input.zones,
4712
+ ...carriesTrackId ? { trackId: recordId } : {},
4713
+ source: input.source ?? (kind === "device-event" ? "sensor" : "pipeline"),
4714
+ ...input.importance !== void 0 ? { importance: input.importance } : {},
4715
+ ...input.dwellSeconds !== void 0 ? { dwellSeconds: input.dwellSeconds } : {},
4716
+ ...input.labelConfidence !== void 0 ? { labelConfidence: input.labelConfidence } : {},
4717
+ ...input.sensorKind !== void 0 ? { sensorKind: input.sensorKind } : {},
4718
+ ...input.eventType !== void 0 ? { eventType: input.eventType } : {},
4719
+ ...input.packagePhase !== void 0 ? { packagePhase: input.packagePhase } : {},
4720
+ ...input.bbox !== void 0 ? { bbox: input.bbox } : {},
4721
+ ...input.crossing !== void 0 ? { crossing: input.crossing } : {},
4722
+ ...input.occupancy !== void 0 ? { occupancy: {
4723
+ ...input.occupancy.zoneId !== void 0 ? { zoneId: input.occupancy.zoneId } : {},
4724
+ ...input.occupancy.zoneName !== void 0 ? { zoneName: input.occupancy.zoneName } : {},
4725
+ ...input.occupancy.className !== void 0 ? { className: input.occupancy.className } : {},
4726
+ count: input.occupancy.count,
4727
+ previousCount: input.occupancy.previousCount,
4728
+ occupied: input.occupancy.occupied,
4729
+ threshold: input.occupancy.threshold
4730
+ } } : {}
4731
+ },
4732
+ kind,
4733
+ origin: "synthetic",
4734
+ controls: {
4735
+ ...input.onlyRuleId !== void 0 ? { onlyRuleId: input.onlyRuleId } : {},
4736
+ bypassCooldown: input.bypassCooldown,
4737
+ bypassMute: input.bypassMute,
4738
+ runActions: input.runActions
4739
+ },
4740
+ log: () => ({
4741
+ tags: { deviceId: input.deviceId },
4742
+ meta: {
4743
+ eventId: recordId,
4744
+ synthetic: true,
4745
+ trigger: input.trigger,
4746
+ ...input.onlyRuleId !== void 0 ? { onlyRuleId: input.onlyRuleId } : {}
4747
+ }
4748
+ })
4749
+ };
4750
+ }
4751
+ /**
4752
+ * Turn one live JPEG into the media set the dispatcher's ladders look for.
4753
+ *
4754
+ * Every entry is the SAME photograph, differing only in how it was cut — which
4755
+ * is exactly what the ladders mean by their kind names, so nothing here claims
4756
+ * a frame it does not have:
4757
+ *
4758
+ * - `fullFrame` / `keyFrame` / `firstFrame` — the clean scene, verbatim.
4759
+ * - `fullFrameBoxed` — the clean scene with the operator's bbox drawn on it.
4760
+ * - `crop` / `thumbnail` — that bbox, cut out.
4761
+ *
4762
+ * With NO bbox only the clean-scene kinds are produced, so a rule asking for
4763
+ * `frame: 'cropped'` correctly finds nothing rather than being handed a whole
4764
+ * scene it did not ask for. sharp failures degrade the same way: the clean
4765
+ * scene always survives.
4766
+ */
4767
+ async function deriveSyntheticMedia(jpeg, bbox, timestamp) {
4768
+ const base64 = Buffer.from(jpeg).toString("base64");
4769
+ const clean = (kind) => ({
4770
+ key: `${SYNTHETIC_ID_PREFIX}${kind}`,
4771
+ kind,
4772
+ base64,
4773
+ sizeBytes: jpeg.byteLength,
4774
+ timestamp
4775
+ });
4776
+ const files = [
4777
+ clean("fullFrame"),
4778
+ clean("keyFrame"),
4779
+ clean("firstFrame")
4780
+ ];
4781
+ if (bbox === void 0) return files;
4782
+ try {
4783
+ const { default: sharp } = await import("sharp");
4784
+ const meta = await sharp(Buffer.from(jpeg)).metadata();
4785
+ const W = meta.width ?? 0;
4786
+ const H = meta.height ?? 0;
4787
+ if (W === 0 || H === 0) return files;
4788
+ const left = Math.max(0, Math.min(W - 1, Math.floor(bbox.x * W)));
4789
+ const top = Math.max(0, Math.min(H - 1, Math.floor(bbox.y * H)));
4790
+ const width = Math.max(1, Math.min(W - left, Math.ceil(bbox.w * W)));
4791
+ const height = Math.max(1, Math.min(H - top, Math.ceil(bbox.h * H)));
4792
+ const cropBuf = await sharp(Buffer.from(jpeg)).extract({
4793
+ left,
4794
+ top,
4795
+ width,
4796
+ height
4797
+ }).jpeg({ quality: 85 }).toBuffer();
4798
+ const cropB64 = cropBuf.toString("base64");
4799
+ files.push({
4800
+ key: `${SYNTHETIC_ID_PREFIX}crop`,
4801
+ kind: "crop",
4802
+ base64: cropB64,
4803
+ sizeBytes: cropBuf.byteLength,
4804
+ timestamp
4805
+ }, {
4806
+ key: `${SYNTHETIC_ID_PREFIX}thumbnail`,
4807
+ kind: "thumbnail",
4808
+ base64: cropB64,
4809
+ sizeBytes: cropBuf.byteLength,
4810
+ timestamp
4811
+ });
4812
+ const stroke = Math.max(2, Math.round(Math.min(W, H) / 240));
4813
+ 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>`);
4814
+ const boxedBuf = await sharp(Buffer.from(jpeg)).composite([{
4815
+ input: overlay,
4816
+ top: 0,
4817
+ left: 0
4818
+ }]).jpeg({ quality: 85 }).toBuffer();
4819
+ files.push({
4820
+ key: `${SYNTHETIC_ID_PREFIX}fullFrameBoxed`,
4821
+ kind: "fullFrameBoxed",
4822
+ base64: boxedBuf.toString("base64"),
4823
+ sizeBytes: boxedBuf.byteLength,
4824
+ timestamp
4825
+ });
4826
+ } catch {
4827
+ return files;
4828
+ }
4829
+ return files;
4830
+ }
4831
+ //#endregion
4848
4832
  //#region src/notification-center/timelapse/timelapse-store.ts
4849
4833
  var NC_TIMELAPSE_RULES_COLLECTION = "notification-center:timelapse-rules";
4850
4834
  var NC_TIMELAPSE_RULES_COLUMNS = [
@@ -5683,10 +5667,16 @@ var NotificationCenter = class NotificationCenter {
5683
5667
  this.consumeEvent(incomingFromTrack(track, info));
5684
5668
  }
5685
5669
  /**
5686
- * Called at the SensorEvent persist site (`ingestSensorStateChange` — one row
5687
- * per linked camera), in the SAME moment as the durable insert. Feeds the
5688
- * `device-event` trigger (doorbell press / sensor state change). Fire-and-
5689
- * forget from the ingest loop; the outbox owns delivery from here.
5670
+ * Called for each SensorEvent row (`ingestSensorStateChange` — one per linked
5671
+ * camera), once the same state change has had its chance to project a marker
5672
+ * track. Feeds the `device-event` trigger (doorbell press / sensor state
5673
+ * change). Fire-and-forget from the ingest loop; the outbox owns delivery.
5674
+ *
5675
+ * `markerTrackId` is that marker — the synthetic track carrying the camera's
5676
+ * frame — and it is what gives this notification a picture: the payload
5677
+ * freezes it as the media owner and the dispatcher resolves it through the
5678
+ * ladder every other attachment uses. Absent means no marker landed, and the
5679
+ * notification is text-only; that is logged, per camera, at the enqueue.
5690
5680
  *
5691
5681
  * Delivery-grade boundary (honest): the SensorEventStore is itself fed from
5692
5682
  * the LOSSY `DeviceStateChanged` telemetry bus, and there is no NC crash-gap
@@ -5695,8 +5685,8 @@ var NotificationCenter = class NotificationCenter {
5695
5685
  * device-event notification — the durable guarantee begins at this hook, not
5696
5686
  * before it.
5697
5687
  */
5698
- onSensorEventPersisted(event) {
5699
- this.consumeEvent(incomingFromSensorEvent(event));
5688
+ onSensorEventPersisted(event, markerTrackId) {
5689
+ this.consumeEvent(incomingFromSensorEvent(event, markerTrackId));
5700
5690
  }
5701
5691
  /**
5702
5692
  * Called at the AUDIO-event persist site (`eventStore.insertAudio`), in the
@@ -5853,7 +5843,7 @@ var NotificationCenter = class NotificationCenter {
5853
5843
  }
5854
5844
  /**
5855
5845
  * Adopt the panel this node owns. Called once by the addon after
5856
- * `ensureAlarmPanel`; a node without one never calls it and the cap then
5846
+ * `declareAlarmPanel`; a node without one never calls it and the cap then
5857
5847
  * answers `deviceId: null`.
5858
5848
  */
5859
5849
  setAlarmPanel(panel) {
@@ -5983,6 +5973,8 @@ var NotificationCenter = class NotificationCenter {
5983
5973
  }
5984
5974
  const now = this.now();
5985
5975
  let anyMatched = false;
5976
+ /** One "no media owner" line per EVENT, not per matching rule. */
5977
+ let noOwnerReported = false;
5986
5978
  const rejections = [];
5987
5979
  for (const rule of candidates) {
5988
5980
  const evaluation = evaluateRule(rule, subject, (id) => this.deviceStates.get(id));
@@ -6065,6 +6057,18 @@ var NotificationCenter = class NotificationCenter {
6065
6057
  buttons: unresolvable.join(", ")
6066
6058
  }
6067
6059
  });
6060
+ if (!noOwnerReported && kind === "device-event" && subject.trackId === void 0 && rule.media.attach !== "none") {
6061
+ noOwnerReported = true;
6062
+ this.logger.warn("device event has no marker track — this notification ships text-only", {
6063
+ tags: { deviceId: subject.deviceId },
6064
+ meta: {
6065
+ recordId: subject.recordId,
6066
+ ...subject.sensorKind !== void 0 ? { sensorKind: subject.sensorKind } : {},
6067
+ ruleId: rule.id,
6068
+ rule: rule.name
6069
+ }
6070
+ });
6071
+ }
6068
6072
  const userTargets = await this.resolveUserTargets(rule, subject.deviceId);
6069
6073
  const entries = this.buildEntries(rule, subject, kind, evaluation.matchedOn, userTargets, origin);
6070
6074
  const inserted = await this.outbox.enqueue(entries);
@@ -6754,6 +6758,844 @@ function makeNcActionHandlers(deps) {
6754
6758
  };
6755
6759
  }
6756
6760
  //#endregion
6761
+ //#region src/pipeline-analytics/data-plane-access.ts
6762
+ /**
6763
+ * Prefix literals for the export planes, mirrored here so the policy reads as
6764
+ * a table without importing three handler modules into the addon's boot path.
6765
+ * `data-plane-access.spec.ts` asserts they still match the constants their
6766
+ * handlers register with — a mirror nothing checks is a lie waiting to happen.
6767
+ */
6768
+ var TRAINING_EXPORT_PREFIX_REF = "training-export";
6769
+ var RETRAIN_EXPORT_PREFIX_REF = "retrain-export";
6770
+ var DEBUG_EXPORT_PREFIX_REF = "debug-export";
6771
+ /**
6772
+ * Every data plane this addon serves, and the level the HUB enforces before it
6773
+ * proxies. Exhaustive by test: a new plane that forgets to appear here fails
6774
+ * `data-plane-access.spec.ts`.
6775
+ */
6776
+ var PIPELINE_ANALYTICS_PLANE_ACCESS = {
6777
+ /** One stored crop/thumbnail by an unguessable id the caller already holds. */
6778
+ "event-media": "authenticated",
6779
+ /** Self-authenticating single-use action token (D47). */
6780
+ "nc-action": "public",
6781
+ /** Self-authenticating single-use artifact token (D47). */
6782
+ "nc-artifact": "public",
6783
+ /** Every frame behind a marked selection — a corpus, not an item. */
6784
+ [TRAINING_EXPORT_PREFIX_REF]: "admin",
6785
+ [RETRAIN_EXPORT_PREFIX_REF]: "admin",
6786
+ /** Diagnostic bundle: logs, config and frames. */
6787
+ [DEBUG_EXPORT_PREFIX_REF]: "admin"
6788
+ };
6789
+ /** The declared level for a plane prefix. Typed, so a prefix with no declared
6790
+ * policy is a compile error rather than a silent default. */
6791
+ function planeAccess(prefix) {
6792
+ return PIPELINE_ANALYTICS_PLANE_ACCESS[prefix];
6793
+ }
6794
+ var MAX_EXPORT_BYTES = 256 * 1024 * 1024;
6795
+ var DebugFindingSchema = object({
6796
+ code: string(),
6797
+ severity: _enum([
6798
+ "error",
6799
+ "warn",
6800
+ "info"
6801
+ ]),
6802
+ detail: string()
6803
+ });
6804
+ var DebugTrackListItemSchema = object({
6805
+ trackId: string(),
6806
+ deviceId: number(),
6807
+ className: string(),
6808
+ label: string().optional(),
6809
+ firstSeen: number(),
6810
+ lastSeen: number(),
6811
+ durationMs: number(),
6812
+ active: boolean()
6813
+ });
6814
+ var DebugTrackReportSchema = object({
6815
+ trackId: string(),
6816
+ deviceId: number(),
6817
+ className: string(),
6818
+ label: string().optional(),
6819
+ firstSeen: number(),
6820
+ lastSeen: number(),
6821
+ durationMs: number(),
6822
+ active: boolean(),
6823
+ mediaCount: number(),
6824
+ mediaKinds: array(string()).readonly(),
6825
+ eventCount: number(),
6826
+ positionCount: number(),
6827
+ findings: array(DebugFindingSchema).readonly()
6828
+ });
6829
+ var ListTracksInputSchema = object({
6830
+ /** Empty or absent means every camera that has tracks. */
6831
+ deviceIds: array(number()).optional(),
6832
+ limit: number().int().positive().max(200).optional()
6833
+ });
6834
+ var ListTracksOutputSchema = object({
6835
+ tracks: array(DebugTrackListItemSchema).readonly(),
6836
+ /**
6837
+ * True when a camera hit `limit`. Said rather than implied: a silently capped
6838
+ * list is how an operator concludes a mark did not take.
6839
+ */
6840
+ truncated: boolean()
6841
+ });
6842
+ var AnalyzeInputSchema = object({ trackIds: array(string()).min(1).max(200) });
6843
+ var AnalyzeOutputSchema = object({
6844
+ reports: array(DebugTrackReportSchema).readonly(),
6845
+ /**
6846
+ * Ids that were asked for and no longer exist. Per D81 a `debug` mark does NOT
6847
+ * pin retention, so a marked track can be evicted between the list and the
6848
+ * analysis — reported as a number the tab can show rather than as silence.
6849
+ */
6850
+ vanished: array(string()).readonly()
6851
+ });
6852
+ var debugActions = defineCustomActions({
6853
+ "debug.listTracks": customAction(ListTracksInputSchema, ListTracksOutputSchema, { auth: "admin" }),
6854
+ "debug.analyze": customAction(AnalyzeInputSchema, AnalyzeOutputSchema, { auth: "admin" })
6855
+ });
6856
+ //#endregion
6857
+ //#region src/pipeline-analytics/debug/zip-stream.ts
6858
+ /**
6859
+ * A minimal STORE-only ZIP writer — enough to emit a debug bundle entry by
6860
+ * entry, without ever holding the archive in memory.
6861
+ *
6862
+ * ## Why hand-rolled, and why ZIP rather than the tar next door
6863
+ *
6864
+ * The archive the operator asked for is a ZIP, because it is the format that
6865
+ * opens with a double click on every desktop they use; the training exports use
6866
+ * ustar and that stays as it is. Both are hand-written here for the same two
6867
+ * reasons the {@link ../training-export/tar-stream.js tar writer} records:
6868
+ *
6869
+ * 1. **A dependency would not arrive.** A hub addon is deployed as its built
6870
+ * `dist/` and carries ZERO `node_modules` of its own — adding `yazl` or
6871
+ * `archiver` to `package.json` gets a module that is missing at runtime on
6872
+ * the node that actually runs the code. The only libraries an addon can rely
6873
+ * on are Node's builtins and what its bundler inlines.
6874
+ * 2. **Nothing to compress.** Every payload is JPEG or MP4, already compressed.
6875
+ * STORE (method 0) spends no CPU on the hub's one thread for a percent or
6876
+ * two, and — unlike DEFLATE — the entry's size and CRC are known before the
6877
+ * header is written, so no data descriptors and no seeking back: the writer
6878
+ * is append-only and therefore streamable.
6879
+ *
6880
+ * So: STORE-only ZIP, which is a 30-byte local header + verbatim body per entry
6881
+ * plus a central directory at the end, fully specified (APPNOTE 6.3.x), and
6882
+ * asserted against the system `unzip` binary in the spec next door rather than
6883
+ * against my reading of the format.
6884
+ *
6885
+ * ## What this deliberately does NOT do
6886
+ *
6887
+ * No ZIP64. The classic record set addresses 4 GiB and 65 535 entries, and this
6888
+ * archive is a hand-marked debug set — tens of tracks, not a dataset.
6889
+ * {@link ZipStreamWriter.fits} reports an entry that would cross either ceiling
6890
+ * so the caller can skip it and SAY it skipped it, rather than emitting an
6891
+ * archive with a wrapped offset that reads as corrupt.
6892
+ *
6893
+ * ## Entry names are ASCII, by construction
6894
+ *
6895
+ * The writer sets the UTF-8 name flag (bit 11) because that is what the format
6896
+ * says, but **it cannot be relied on**: the `unzip` shipped on macOS is Info-ZIP
6897
+ * 6.00, which ignores the flag and re-encodes the name from CP437 — `persona-è`
6898
+ * comes out of it as `persona-+¿`, and on a case where the mangled bytes are not
6899
+ * valid for the filesystem it refuses to extract the entry at all. Measured, not
6900
+ * assumed; the spec next door pins it.
6901
+ *
6902
+ * So {@link toAsciiZipName} is applied to every path, and — more importantly —
6903
+ * no free text goes into a path in the first place: an entry is addressed by
6904
+ * `deviceId` and `trackId`, and the camera name lives INSIDE `track.json` where
6905
+ * its encoding is JSON's problem and not the archive's.
6906
+ */
6907
+ var LOCAL_SIG = 67324752;
6908
+ var CENTRAL_SIG = 33639248;
6909
+ var EOCD_SIG = 101010256;
6910
+ /** Bit 11 — the entry name is UTF-8. */
6911
+ var FLAG_UTF8 = 2048;
6912
+ /** STORE. */
6913
+ var METHOD_STORE = 0;
6914
+ /** PKZIP 2.0, the floor for STORE + UTF-8 names. */
6915
+ var VERSION = 20;
6916
+ /** The classic (non-ZIP64) ceilings. Crossing either needs ZIP64; we refuse. */
6917
+ var ZIP_MAX_BYTES = 4294967295;
6918
+ var CRC_TABLE = (() => {
6919
+ const table = new Array(256);
6920
+ for (let i = 0; i < 256; i++) {
6921
+ let c = i;
6922
+ for (let k = 0; k < 8; k++) c = c & 1 ? 3988292384 ^ c >>> 1 : c >>> 1;
6923
+ table[i] = c >>> 0;
6924
+ }
6925
+ return table;
6926
+ })();
6927
+ /**
6928
+ * Reduce a path to the ASCII set every ZIP reader agrees on.
6929
+ *
6930
+ * Anything outside `[A-Za-z0-9._-]` (and the `/` separator) becomes `_`. Not a
6931
+ * transliteration: a name that survives Info-ZIP's CP437 pass unchanged is the
6932
+ * only name this archive can promise, and a lossy-but-stable mapping is better
6933
+ * than a pretty one that a reader mangles differently from the manifest.
6934
+ */
6935
+ function toAsciiZipName(name) {
6936
+ return name.replace(/[^A-Za-z0-9._/-]/g, "_");
6937
+ }
6938
+ /** CRC-32 (IEEE 802.3), the checksum every ZIP entry header carries. */
6939
+ function crc32(buf) {
6940
+ let c = 4294967295;
6941
+ for (const byte of buf) c = CRC_TABLE[(c ^ byte) & 255] ^ c >>> 8;
6942
+ return (c ^ 4294967295) >>> 0;
6943
+ }
6944
+ /**
6945
+ * MS-DOS date/time, the only timestamp a classic ZIP record carries.
6946
+ *
6947
+ * Two-second resolution and an epoch of 1980 — a date before that is clamped
6948
+ * rather than allowed to write a negative year field, which some readers show as
6949
+ * 2107 and others reject outright.
6950
+ */
6951
+ function dosDateTime(mtimeMs) {
6952
+ const d = new Date(mtimeMs);
6953
+ const year = Math.max(1980, d.getFullYear());
6954
+ return {
6955
+ time: d.getHours() << 11 | d.getMinutes() << 5 | d.getSeconds() >> 1,
6956
+ date: year - 1980 << 9 | d.getMonth() + 1 << 5 | d.getDate()
6957
+ };
6958
+ }
6959
+ /**
6960
+ * The 30-byte local header + name for one stored file.
6961
+ *
6962
+ * Sizes and CRC are written into the header itself (never a trailing data
6963
+ * descriptor) — STORE knows both before the body moves, and a header that tells
6964
+ * the truth is what lets a reader index the archive without scanning it.
6965
+ */
6966
+ function zipLocalHeader(entry, crc, nameBytes) {
6967
+ const head = Buffer.alloc(30);
6968
+ const { time, date } = dosDateTime(entry.mtimeMs);
6969
+ head.writeUInt32LE(LOCAL_SIG, 0);
6970
+ head.writeUInt16LE(VERSION, 4);
6971
+ head.writeUInt16LE(FLAG_UTF8, 6);
6972
+ head.writeUInt16LE(METHOD_STORE, 8);
6973
+ head.writeUInt16LE(time, 10);
6974
+ head.writeUInt16LE(date, 12);
6975
+ head.writeUInt32LE(crc, 14);
6976
+ head.writeUInt32LE(entry.body.length, 18);
6977
+ head.writeUInt32LE(entry.body.length, 22);
6978
+ head.writeUInt16LE(nameBytes.length, 26);
6979
+ head.writeUInt16LE(0, 28);
6980
+ return Buffer.concat([head, nameBytes]);
6981
+ }
6982
+ function centralHeader(entry) {
6983
+ const head = Buffer.alloc(46);
6984
+ head.writeUInt32LE(CENTRAL_SIG, 0);
6985
+ head.writeUInt16LE(VERSION, 4);
6986
+ head.writeUInt16LE(VERSION, 6);
6987
+ head.writeUInt16LE(FLAG_UTF8, 8);
6988
+ head.writeUInt16LE(METHOD_STORE, 10);
6989
+ head.writeUInt16LE(entry.dosTime, 12);
6990
+ head.writeUInt16LE(entry.dosDate, 14);
6991
+ head.writeUInt32LE(entry.crc, 16);
6992
+ head.writeUInt32LE(entry.size, 20);
6993
+ head.writeUInt32LE(entry.size, 24);
6994
+ head.writeUInt16LE(entry.nameBytes.length, 28);
6995
+ head.writeUInt16LE(0, 30);
6996
+ head.writeUInt16LE(0, 32);
6997
+ head.writeUInt16LE(0, 34);
6998
+ head.writeUInt16LE(0, 36);
6999
+ head.writeUInt32LE(0, 38);
7000
+ head.writeUInt32LE(entry.offset, 42);
7001
+ return Buffer.concat([head, entry.nameBytes]);
7002
+ }
7003
+ function endOfCentralDirectory(count, size, offset) {
7004
+ const eocd = Buffer.alloc(22);
7005
+ eocd.writeUInt32LE(EOCD_SIG, 0);
7006
+ eocd.writeUInt16LE(0, 4);
7007
+ eocd.writeUInt16LE(0, 6);
7008
+ eocd.writeUInt16LE(count, 8);
7009
+ eocd.writeUInt16LE(count, 10);
7010
+ eocd.writeUInt32LE(size, 12);
7011
+ eocd.writeUInt32LE(offset, 16);
7012
+ eocd.writeUInt16LE(0, 20);
7013
+ return eocd;
7014
+ }
7015
+ /**
7016
+ * True when one more entry still fits inside the classic (non-ZIP64) ceilings.
7017
+ *
7018
+ * Pure, and asked with a length rather than a Buffer, so the 4 GiB boundary can
7019
+ * be tested without allocating 4 GiB — the guard whose failure mode (a wrapped
7020
+ * 32-bit offset, producing an archive that looks complete and unpacks as
7021
+ * garbage) is precisely the one a test must be able to reach.
7022
+ */
7023
+ function zipEntryFits(query) {
7024
+ if (query.entryIndex >= 65535) return false;
7025
+ const nameLen = toAsciiZipName(query.name).length;
7026
+ return query.offset + 30 + nameLen + query.bodyLength <= ZIP_MAX_BYTES;
7027
+ }
7028
+ /**
7029
+ * An append-only ZIP writer.
7030
+ *
7031
+ * The caller supplies `write` — the real `res.write`, awaited, so backpressure
7032
+ * is the socket's. The writer holds only the central directory (one small record
7033
+ * per entry), never a body: {@link add} takes one file at a time and forgets it.
7034
+ */
7035
+ var ZipStreamWriter = class {
7036
+ write;
7037
+ entries = [];
7038
+ offset = 0;
7039
+ constructor(write) {
7040
+ this.write = write;
7041
+ }
7042
+ /** Entries accepted so far — what the manifest should claim, not what was asked. */
7043
+ get entryCount() {
7044
+ return this.entries.length;
7045
+ }
7046
+ /** Bytes emitted so far, central directory excluded. */
7047
+ get bytesWritten() {
7048
+ return this.offset;
7049
+ }
7050
+ /**
7051
+ * True when this entry still fits inside the classic ZIP ceilings.
7052
+ *
7053
+ * Checked BEFORE the header goes out, because a 32-bit offset that wraps
7054
+ * produces an archive that looks complete and unpacks as garbage — the failure
7055
+ * this writer most needs to make impossible.
7056
+ */
7057
+ fits(entry) {
7058
+ return zipEntryFits({
7059
+ offset: this.offset,
7060
+ entryIndex: this.entries.length,
7061
+ name: entry.name,
7062
+ bodyLength: entry.body.length
7063
+ });
7064
+ }
7065
+ /** Append one stored file. Returns false (and writes nothing) when it does not fit. */
7066
+ async add(entry) {
7067
+ if (!this.fits(entry)) return false;
7068
+ const nameBytes = Buffer.from(toAsciiZipName(entry.name), "ascii");
7069
+ const crc = crc32(entry.body);
7070
+ const { time, date } = dosDateTime(entry.mtimeMs);
7071
+ const header = zipLocalHeader(entry, crc, nameBytes);
7072
+ this.entries.push({
7073
+ nameBytes,
7074
+ crc,
7075
+ size: entry.body.length,
7076
+ offset: this.offset,
7077
+ dosTime: time,
7078
+ dosDate: date
7079
+ });
7080
+ await this.write(header);
7081
+ await this.write(entry.body);
7082
+ this.offset += header.length + entry.body.length;
7083
+ return true;
7084
+ }
7085
+ /** Write the central directory + EOCD. The archive is unreadable without it. */
7086
+ async finish() {
7087
+ const start = this.offset;
7088
+ let size = 0;
7089
+ for (const entry of this.entries) {
7090
+ const block = centralHeader(entry);
7091
+ await this.write(block);
7092
+ size += block.length;
7093
+ }
7094
+ await this.write(endOfCentralDirectory(this.entries.length, size, start));
7095
+ this.offset = start + size + 22;
7096
+ }
7097
+ };
7098
+ var DEBUG_MANIFEST_ENTRY = "manifest.json";
7099
+ var DEBUG_TRACK_ENTRY = "track.json";
7100
+ var DEBUG_ANALYSIS_ENTRY = "analysis.json";
7101
+ var DEBUG_EVENTS_ENTRY = "events.json";
7102
+ /** `tracks/<deviceId>/<trackId>` — ASCII by construction, both parts are ids. */
7103
+ function debugTrackDir(deviceId, trackId) {
7104
+ return toAsciiZipName(`tracks/${String(deviceId)}/${trackId}`);
7105
+ }
7106
+ /**
7107
+ * Resolve every media row of one track to an archive-relative name.
7108
+ *
7109
+ * Deterministic: the input order (the store returns `timestamp asc`) fully
7110
+ * determines the output, so re-exporting an unchanged track produces the same
7111
+ * names. A kind that occurs once is bare (`keyFrame.jpg`); a kind that repeats
7112
+ * carries its timestamp; a genuine duplicate of both gets `-2`, `-3`, …
7113
+ */
7114
+ function planTrackMediaNames(media) {
7115
+ const occurrences = /* @__PURE__ */ new Map();
7116
+ for (const m of media) occurrences.set(m.kind, (occurrences.get(m.kind) ?? 0) + 1);
7117
+ const used = /* @__PURE__ */ new Set();
7118
+ const out = [];
7119
+ for (const m of media) {
7120
+ const base = (occurrences.get(m.kind) ?? 0) > 1 ? `${m.kind}-${String(m.timestamp)}` : m.kind;
7121
+ let name = `media/${base}.jpg`;
7122
+ for (let n = 2; used.has(name); n++) name = `media/${base}-${String(n)}.jpg`;
7123
+ used.add(name);
7124
+ out.push({
7125
+ key: m.key,
7126
+ name: toAsciiZipName(name),
7127
+ kind: m.kind,
7128
+ timestamp: m.timestamp,
7129
+ sizeBytes: m.sizeBytes
7130
+ });
7131
+ }
7132
+ return out;
7133
+ }
7134
+ 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.";
7135
+ /** The archive filename, stamped so two downloads never collide in ~/Downloads. */
7136
+ function debugArchiveFilename(nowMs) {
7137
+ return `camstack-debug-${new Date(nowMs).toISOString().replace(/[:.]/g, "-")}.zip`.replace(/[\r\n"]/g, "");
7138
+ }
7139
+ /** Parse `?deviceIds=615,617` — empty means every camera. */
7140
+ function parseDebugDeviceIds(url) {
7141
+ const query = url.includes("?") ? url.slice(url.indexOf("?") + 1) : "";
7142
+ const raw = new URLSearchParams(query).get("deviceIds");
7143
+ if (raw === null || raw.trim() === "") return [];
7144
+ const out = [];
7145
+ for (const part of raw.split(",")) {
7146
+ const n = Number.parseInt(part.trim(), 10);
7147
+ if (Number.isInteger(n) && n > 0 && !out.includes(n)) out.push(n);
7148
+ }
7149
+ return out;
7150
+ }
7151
+ //#endregion
7152
+ //#region src/pipeline-analytics/debug/debug-export-handler.ts
7153
+ /**
7154
+ * Pacing. The export is a BACKGROUND job sharing a thread with live decode, so
7155
+ * it yields twice: between tracks, and between individual media files.
7156
+ *
7157
+ * The per-file yield is the one that matters. A track can own twenty blobs, and
7158
+ * reading them back to back — each a base64 decode of a few hundred KB —
7159
+ * occupies the loop for long enough that snapshot resizes start timing out. One
7160
+ * file at a time, with a gap, keeps the export off the critical path; it is slow
7161
+ * on purpose.
7162
+ */
7163
+ var TRACK_PACING_MS = 25;
7164
+ var MEDIA_PACING_MS = 15;
7165
+ function json(value) {
7166
+ return Buffer.from(JSON.stringify(value, null, 2), "utf8");
7167
+ }
7168
+ /** Total media rows the plan hopes to ship — the number HEAD reports. */
7169
+ function plannedMediaCount(plan) {
7170
+ let total = 0;
7171
+ for (const t of plan.tracks) total += t.media.length;
7172
+ return total;
7173
+ }
7174
+ function createDebugExportHandler(deps) {
7175
+ return async (req, res) => {
7176
+ const method = req.method ?? "GET";
7177
+ if (method !== "GET" && method !== "HEAD") {
7178
+ res.writeHead(405, { allow: "GET, HEAD" });
7179
+ res.end();
7180
+ return;
7181
+ }
7182
+ const deviceIds = parseDebugDeviceIds(req.url ?? "");
7183
+ let plan;
7184
+ try {
7185
+ plan = await deps.buildPlan(deviceIds);
7186
+ } catch (err) {
7187
+ deps.logger.warn("debug export plan failed", { meta: { error: String(err) } });
7188
+ res.writeHead(500, { "content-type": "text/plain" });
7189
+ res.end("debug export plan failed");
7190
+ return;
7191
+ }
7192
+ res.writeHead(200, {
7193
+ "content-type": "application/zip",
7194
+ "content-disposition": `attachment; filename="${debugArchiveFilename(deps.now())}"`,
7195
+ "cache-control": "no-store",
7196
+ "x-camstack-debug-tracks": String(plan.tracks.length),
7197
+ "x-camstack-debug-media": String(plannedMediaCount(plan))
7198
+ });
7199
+ if (method === "HEAD") {
7200
+ res.end();
7201
+ return;
7202
+ }
7203
+ const write = (chunk) => new Promise((resolve, reject) => {
7204
+ if (!res.write(chunk, (err) => {
7205
+ if (err) reject(err);
7206
+ else resolve();
7207
+ })) res.once("drain", resolve);
7208
+ });
7209
+ const zip = new ZipStreamWriter(write);
7210
+ const mtimeMs = deps.now();
7211
+ const startedAt = deps.now();
7212
+ const manifestTracks = [];
7213
+ let included = 0;
7214
+ let missing = 0;
7215
+ let stoppedBecause = plan.capped ? "track-cap" : null;
7216
+ for (const entry of plan.tracks) {
7217
+ if (zip.bytesWritten >= 268435456) {
7218
+ stoppedBecause ??= "byte-budget";
7219
+ deps.logger.warn("debug export stopped — byte budget reached", { meta: {
7220
+ budget: MAX_EXPORT_BYTES,
7221
+ written: zip.bytesWritten
7222
+ } });
7223
+ break;
7224
+ }
7225
+ const dir = debugTrackDir(entry.report.deviceId, entry.report.trackId);
7226
+ const deviceId = entry.report.deviceId;
7227
+ for (const [name, body] of [
7228
+ [DEBUG_TRACK_ENTRY, json(entry.track)],
7229
+ [DEBUG_ANALYSIS_ENTRY, json(entry.report)],
7230
+ [DEBUG_EVENTS_ENTRY, json(entry.events)]
7231
+ ]) if (!await zip.add({
7232
+ name: `${dir}/${name}`,
7233
+ body,
7234
+ mtimeMs
7235
+ })) stoppedBecause ??= "zip-ceiling";
7236
+ const mediaIncluded = [];
7237
+ const mediaMissing = [];
7238
+ for (const media of entry.media) {
7239
+ const bytes = await deps.readMedia(media.key);
7240
+ if (bytes === null) {
7241
+ missing += 1;
7242
+ mediaMissing.push(media.key);
7243
+ deps.logger.warn("debug export skipped a media file — the blob is unreadable", {
7244
+ tags: { deviceId },
7245
+ meta: {
7246
+ trackId: entry.report.trackId,
7247
+ mediaKey: media.key,
7248
+ kind: media.kind
7249
+ }
7250
+ });
7251
+ continue;
7252
+ }
7253
+ if (!await zip.add({
7254
+ name: `${dir}/${media.name}`,
7255
+ body: bytes,
7256
+ mtimeMs
7257
+ })) {
7258
+ stoppedBecause ??= "zip-ceiling";
7259
+ missing += 1;
7260
+ mediaMissing.push(media.key);
7261
+ deps.logger.warn("debug export skipped a media file — the archive is full", {
7262
+ tags: { deviceId },
7263
+ meta: {
7264
+ trackId: entry.report.trackId,
7265
+ mediaKey: media.key,
7266
+ kind: media.kind
7267
+ }
7268
+ });
7269
+ continue;
7270
+ }
7271
+ included += 1;
7272
+ mediaIncluded.push(media.name);
7273
+ await new Promise((resolve) => setTimeout(resolve, MEDIA_PACING_MS));
7274
+ }
7275
+ manifestTracks.push({
7276
+ trackId: entry.report.trackId,
7277
+ deviceId,
7278
+ dir,
7279
+ className: entry.report.className,
7280
+ ...entry.report.label === void 0 ? {} : { label: entry.report.label },
7281
+ firstSeen: entry.report.firstSeen,
7282
+ lastSeen: entry.report.lastSeen,
7283
+ durationMs: entry.report.durationMs,
7284
+ eventCount: entry.report.eventCount,
7285
+ findings: entry.report.findings.map((f) => f.code),
7286
+ mediaIncluded,
7287
+ mediaMissing
7288
+ });
7289
+ await new Promise((resolve) => setTimeout(resolve, TRACK_PACING_MS));
7290
+ }
7291
+ const manifest = {
7292
+ generatedAt: plan.generatedAt,
7293
+ deviceIds: plan.deviceIds,
7294
+ trackCount: manifestTracks.length,
7295
+ mediaIncluded: included,
7296
+ mediaMissing: missing,
7297
+ truncated: stoppedBecause !== null,
7298
+ stoppedBecause,
7299
+ retentionNote: DEBUG_RETENTION_NOTE,
7300
+ tracks: manifestTracks
7301
+ };
7302
+ await zip.add({
7303
+ name: DEBUG_MANIFEST_ENTRY,
7304
+ body: json(manifest),
7305
+ mtimeMs
7306
+ });
7307
+ await zip.finish();
7308
+ await new Promise((resolve) => {
7309
+ res.end(resolve);
7310
+ });
7311
+ deps.logger.info("debug export complete", { meta: {
7312
+ tracks: manifestTracks.length,
7313
+ mediaIncluded: included,
7314
+ mediaMissing: missing,
7315
+ stoppedBecause,
7316
+ bytes: zip.bytesWritten,
7317
+ elapsedMs: deps.now() - startedAt
7318
+ } });
7319
+ };
7320
+ }
7321
+ //#endregion
7322
+ //#region src/pipeline-analytics/debug/debug-track-analysis.ts
7323
+ /** A track is "short" below this — the pipeline rarely produces a full media set. */
7324
+ var SHORT_TRACK_MS = 2e3;
7325
+ /** Below this, the label the track settled on is weak enough to be worth saying. */
7326
+ var LOW_BEST_SCORE = .5;
7327
+ /** Duration in ms. Never negative — a clock that went backwards reads as 0. */
7328
+ function trackDurationMs(facts) {
7329
+ return Math.max(0, facts.lastSeen - facts.firstSeen);
7330
+ }
7331
+ function hasKind(media, kind) {
7332
+ return media.some((m) => m.kind === kind);
7333
+ }
7334
+ function latestMediaAt(media) {
7335
+ let latest = null;
7336
+ for (const m of media) if (latest === null || m.timestamp > latest) latest = m.timestamp;
7337
+ return latest;
7338
+ }
7339
+ function finding(code, severity, detail) {
7340
+ return {
7341
+ code,
7342
+ severity,
7343
+ detail
7344
+ };
7345
+ }
7346
+ /**
7347
+ * Analyse one track. Pure — same inventory in, same findings out.
7348
+ *
7349
+ * Order is stable and roughly "worst first": the media the whole rest of the
7350
+ * system reads, then the track's own substance, then the softer signals.
7351
+ */
7352
+ function analyseDebugTrack(inventory) {
7353
+ const { facts, media, eventCount } = inventory;
7354
+ const durationMs = trackDurationMs(facts);
7355
+ const closed = !facts.active;
7356
+ const out = [];
7357
+ 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)."));
7358
+ else if (closed) {
7359
+ if (!hasKind(media, "keyFrame")) out.push(finding("MISSING_KEYFRAME", "error", "No keyFrame — the best-shot frame is absent."));
7360
+ if (!hasKind(media, "firstFrame")) out.push(finding("MISSING_FIRSTFRAME", "warn", "No firstFrame for a closed track."));
7361
+ if (!hasKind(media, "lastFrame")) out.push(finding("MISSING_LASTFRAME", "warn", "No lastFrame for a closed track."));
7362
+ if (!hasKind(media, "thumbnail")) out.push(finding("MISSING_THUMBNAIL", "warn", "No thumbnail — every list surface renders blank."));
7363
+ 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."));
7364
+ }
7365
+ 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.`));
7366
+ if (eventCount === 0) out.push(finding("ZERO_EVENTS", "error", "No object events — the track exists but recorded nothing."));
7367
+ if (facts.positionCount === 0) out.push(finding("ZERO_POSITIONS", "error", "No positions — there is no trajectory to reconstruct."));
7368
+ 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.`));
7369
+ if (closed && hasKind(media, "lastFrame")) {
7370
+ const lastFrameAt = media.filter((m) => m.kind === "lastFrame").reduce((max, m) => Math.max(max, m.timestamp), 0);
7371
+ const lag = facts.lastSeen - lastFrameAt;
7372
+ if (lag > 2e3) out.push(finding("LASTFRAME_STALE", "warn", `lastFrame is ${String(lag)} ms older than the track's end.`));
7373
+ }
7374
+ if (closed && durationMs >= 1e4 && media.length > 0) {
7375
+ const latest = latestMediaAt(media);
7376
+ if (latest !== null) {
7377
+ const covered = (latest - facts.firstSeen) / durationMs;
7378
+ if (covered < .5) out.push(finding("MEDIA_STOPPED_EARLY", "warn", `Media stops at ${String(Math.round(covered * 100))}% of the track's span.`));
7379
+ }
7380
+ }
7381
+ if (closed && facts.bestEventId === void 0) out.push(finding("NO_BEST_EVENT", "info", "No representative event was chosen for this track."));
7382
+ 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)}.`));
7383
+ return out;
7384
+ }
7385
+ /** Fold an inventory into the report shape, findings included. */
7386
+ function buildDebugTrackReport(inventory) {
7387
+ const { facts, media, eventCount } = inventory;
7388
+ const kinds = [...new Set(media.map((m) => m.kind))].toSorted();
7389
+ return {
7390
+ trackId: facts.trackId,
7391
+ deviceId: facts.deviceId,
7392
+ className: facts.className,
7393
+ ...facts.label === void 0 ? {} : { label: facts.label },
7394
+ firstSeen: facts.firstSeen,
7395
+ lastSeen: facts.lastSeen,
7396
+ durationMs: trackDurationMs(facts),
7397
+ active: facts.active,
7398
+ mediaCount: media.length,
7399
+ mediaKinds: kinds,
7400
+ eventCount,
7401
+ positionCount: facts.positionCount,
7402
+ findings: analyseDebugTrack(inventory)
7403
+ };
7404
+ }
7405
+ //#endregion
7406
+ //#region src/pipeline-analytics/debug/debug-service.ts
7407
+ /**
7408
+ * The three media owners one track's files hang off.
7409
+ *
7410
+ * A track's face and plate crops are NOT owned by `track:<trackId>` — they live
7411
+ * under `face-<trackId>` and `plate-<trackId>`. An inventory that reads only the
7412
+ * first owner reports MISSING_BEST_CROP on every track that produced a face,
7413
+ * which is the opposite of the truth.
7414
+ */
7415
+ var MEDIA_OWNERS = [
7416
+ {
7417
+ kind: "track",
7418
+ prefix: ""
7419
+ },
7420
+ {
7421
+ kind: "face",
7422
+ prefix: "face-"
7423
+ },
7424
+ {
7425
+ kind: "plate",
7426
+ prefix: "plate-"
7427
+ }
7428
+ ];
7429
+ function toFacts(track) {
7430
+ return {
7431
+ trackId: track.trackId,
7432
+ deviceId: track.deviceId,
7433
+ className: track.className,
7434
+ ...track.label === void 0 ? {} : { label: track.label },
7435
+ ...track.labelScore === void 0 ? {} : { labelScore: track.labelScore },
7436
+ firstSeen: track.firstSeen,
7437
+ lastSeen: track.lastSeen,
7438
+ active: track.active,
7439
+ positionCount: track.positions.length,
7440
+ snapshotCount: track.snapshots.length,
7441
+ ...track.importance === void 0 ? {} : { importance: track.importance },
7442
+ ...track.bestEventId === void 0 ? {} : { bestEventId: track.bestEventId }
7443
+ };
7444
+ }
7445
+ function toListItem(track) {
7446
+ return {
7447
+ trackId: track.trackId,
7448
+ deviceId: track.deviceId,
7449
+ className: track.className,
7450
+ ...track.label === void 0 ? {} : { label: track.label },
7451
+ firstSeen: track.firstSeen,
7452
+ lastSeen: track.lastSeen,
7453
+ durationMs: trackDurationMs(toFacts(track)),
7454
+ active: track.active
7455
+ };
7456
+ }
7457
+ /** Which cameras to sweep — the caller's list, else every camera with tracks. */
7458
+ async function resolveDeviceIds(deps, requested) {
7459
+ if (requested !== void 0 && requested.length > 0) return requested;
7460
+ return deps.listDeviceIds();
7461
+ }
7462
+ /**
7463
+ * The marked set, newest first per camera.
7464
+ *
7465
+ * One indexed query per camera and nothing else — no media, no events, and no
7466
+ * trajectory: `listDebug` reads `slim` rows, so nothing here parses the
7467
+ * `positions` JSON. The counts an operator wants (media, events, positions) come
7468
+ * from `debug.analyze`, per track, by id.
7469
+ *
7470
+ * A camera that hits `limit` sets `truncated`, because a list that silently
7471
+ * stops at 200 is how an operator concludes a mark did not take.
7472
+ */
7473
+ async function listDebugTracks(deps, input) {
7474
+ const limit = input.limit ?? 200;
7475
+ const deviceIds = await resolveDeviceIds(deps, input.deviceIds);
7476
+ const tracks = [];
7477
+ let truncated = false;
7478
+ for (const deviceId of deviceIds) {
7479
+ const rows = await deps.listDebugTracks(deviceId, limit);
7480
+ if (rows.length >= limit) {
7481
+ truncated = true;
7482
+ deps.logger.warn("debug listTracks hit the per-camera cap", {
7483
+ tags: { deviceId },
7484
+ meta: {
7485
+ deviceId,
7486
+ limit
7487
+ }
7488
+ });
7489
+ }
7490
+ for (const row of rows) tracks.push(toListItem(row));
7491
+ }
7492
+ return {
7493
+ tracks: tracks.toSorted((a, b) => b.lastSeen - a.lastSeen),
7494
+ truncated
7495
+ };
7496
+ }
7497
+ /**
7498
+ * Gather one track's whole inventory: three media queries plus its events.
7499
+ *
7500
+ * The events are RETURNED as well as counted — the bundle writes them to
7501
+ * `events.json`, and fetching them a second time there would double the query
7502
+ * count of an export for a number the caller already holds.
7503
+ */
7504
+ async function gatherTrack(deps, track) {
7505
+ const media = [];
7506
+ for (const owner of MEDIA_OWNERS) media.push(...await deps.listMediaInfo(owner.kind, `${owner.prefix}${track.trackId}`));
7507
+ const events = await deps.listEvents(track.trackId);
7508
+ return {
7509
+ inventory: {
7510
+ facts: toFacts(track),
7511
+ media,
7512
+ eventCount: events.length
7513
+ },
7514
+ events
7515
+ };
7516
+ }
7517
+ /**
7518
+ * Analyse the named tracks.
7519
+ *
7520
+ * An id that no longer resolves is reported in `vanished` rather than dropped: a
7521
+ * `debug` mark does not pin retention (D81), so a track CAN disappear between
7522
+ * the list and the analysis, and the tab has to be able to say so instead of
7523
+ * quietly showing fewer rows than it listed.
7524
+ */
7525
+ async function analyzeDebugTracks(deps, input) {
7526
+ const reports = [];
7527
+ const vanished = [];
7528
+ for (const trackId of input.trackIds) {
7529
+ const track = await deps.getTrack(trackId);
7530
+ if (track === null) {
7531
+ vanished.push(trackId);
7532
+ deps.logger.info("debug analyze — track no longer exists", { meta: { trackId } });
7533
+ continue;
7534
+ }
7535
+ const gathered = await gatherTrack(deps, track);
7536
+ reports.push(buildDebugTrackReport(gathered.inventory));
7537
+ }
7538
+ return {
7539
+ reports,
7540
+ vanished
7541
+ };
7542
+ }
7543
+ /**
7544
+ * Plan the bundle — INDEX READS ONLY, no blob touched.
7545
+ *
7546
+ * The plan is what the manifest is built from and what the handler streams
7547
+ * against, so it must be produced in one pass: a plan that re-queried while
7548
+ * streaming could describe a set the archive does not contain.
7549
+ *
7550
+ * **Bounded at {@link MAX_EXPORT_TRACKS}, far below the list cap.** Listing is
7551
+ * index rows; exporting is every blob those rows point at, base64-decoded and
7552
+ * pushed through the hub's one thread. An unbounded "export all" is a
7553
+ * self-inflicted outage on a busy hub, so the plan stops and SAYS it stopped
7554
+ * rather than quietly shipping less than the button offered.
7555
+ *
7556
+ * Each row is re-read at full projection: `listDebugTracks` deliberately returns
7557
+ * `slim` rows, and a bundle built from those would report ZERO_POSITIONS on
7558
+ * every track and write an empty `positions` array into `track.json`.
7559
+ */
7560
+ async function buildDebugBundlePlan(deps, requestedDeviceIds) {
7561
+ const deviceIds = await resolveDeviceIds(deps, requestedDeviceIds);
7562
+ const tracks = [];
7563
+ const covered = [];
7564
+ let capped = false;
7565
+ for (const deviceId of deviceIds) {
7566
+ if (tracks.length >= 25) {
7567
+ capped = true;
7568
+ break;
7569
+ }
7570
+ const rows = await deps.listDebugTracks(deviceId, 200);
7571
+ if (rows.length > 0) covered.push(deviceId);
7572
+ for (const row of rows) {
7573
+ if (tracks.length >= 25) {
7574
+ capped = true;
7575
+ break;
7576
+ }
7577
+ const full = await deps.getTrack(row.trackId) ?? row;
7578
+ const { inventory, events } = await gatherTrack(deps, full);
7579
+ tracks.push({
7580
+ report: buildDebugTrackReport(inventory),
7581
+ track: full,
7582
+ events,
7583
+ media: planTrackMediaNames(inventory.media)
7584
+ });
7585
+ }
7586
+ }
7587
+ if (capped) deps.logger.warn("debug export capped — more marked tracks than one bundle may carry", { meta: {
7588
+ limit: 25,
7589
+ deviceIds: covered
7590
+ } });
7591
+ return {
7592
+ generatedAt: deps.now(),
7593
+ deviceIds: covered,
7594
+ tracks,
7595
+ capped
7596
+ };
7597
+ }
7598
+ //#endregion
6757
7599
  //#region src/pipeline-analytics/embedding-actions.ts
6758
7600
  /**
6759
7601
  * embedding-actions — operator buttons for the CLIP semantic-search index.
@@ -6833,6 +7675,25 @@ var embeddingActions = defineCustomActions({
6833
7675
  "embedding.wipe": customAction(NoInputSchema, WipeResultSchema, { auth: "admin" })
6834
7676
  });
6835
7677
  //#endregion
7678
+ //#region src/pipeline-analytics/map-with-concurrency.ts
7679
+ /**
7680
+ * Apply `mapper` to every item with at most `limit` in flight at once.
7681
+ *
7682
+ * A rejecting mapper rejects the whole call (same contract as `Promise.all`);
7683
+ * callers that must not fail a page for one bad row catch inside the mapper.
7684
+ */
7685
+ async function mapWithConcurrency(items, limit, mapper) {
7686
+ const results = new Array(items.length);
7687
+ if (items.length === 0) return results;
7688
+ const effectiveLimit = Math.max(1, Math.min(Math.trunc(limit), items.length));
7689
+ const entries = items.entries();
7690
+ const worker = async () => {
7691
+ for (const [index, item] of entries) results[index] = await mapper(item, index);
7692
+ };
7693
+ await Promise.all(Array.from({ length: effectiveLimit }, () => worker()));
7694
+ return results;
7695
+ }
7696
+ //#endregion
6836
7697
  //#region src/pipeline-analytics/pipeline/key-event-scoring.ts
6837
7698
  /**
6838
7699
  * key-event-scoring — the SINGLE deterministic "importance" scorer for a
@@ -7104,6 +7965,16 @@ function clusterByEmbedding(items, threshold = .5) {
7104
7965
  var OPERATOR_FACE_STEP_ID = "operator:face-gallery";
7105
7966
  /** Embedding model id used when enrolling face crops as identity samples. */
7106
7967
  var MODEL_ID = "arcface-r100";
7968
+ /**
7969
+ * How many face crops `listRecentFaces` resolves at once.
7970
+ *
7971
+ * Each crop costs a store round-trip plus a disk read, so the page is I/O-bound
7972
+ * and the serial form dominated the operator's assign latency. 16 recovers
7973
+ * nearly all of the win while leaving the UDS channel — shared with the live
7974
+ * detection path — far from saturated; unbounded would trade a slow page for a
7975
+ * stalled pipeline.
7976
+ */
7977
+ var FACE_CROP_FETCH_CONCURRENCY = 16;
7107
7978
  var FaceGalleryProvider = class {
7108
7979
  identityStore;
7109
7980
  faceStore;
@@ -7112,6 +7983,7 @@ var FaceGalleryProvider = class {
7112
7983
  eventStore;
7113
7984
  logger;
7114
7985
  refreshGallery;
7986
+ eventMediaBaseUrl;
7115
7987
  emitFaceGalleryChanged;
7116
7988
  constructor(deps) {
7117
7989
  this.identityStore = deps.identityStore;
@@ -7121,6 +7993,7 @@ var FaceGalleryProvider = class {
7121
7993
  this.eventStore = deps.eventStore;
7122
7994
  this.logger = deps.logger;
7123
7995
  this.refreshGallery = deps.refreshGallery;
7996
+ this.eventMediaBaseUrl = deps.eventMediaBaseUrl;
7124
7997
  this.emitFaceGalleryChanged = deps.emitFaceGalleryChanged;
7125
7998
  }
7126
7999
  /** Best-effort emit — never allowed to fail the mutation it follows. */
@@ -7212,6 +8085,21 @@ var FaceGalleryProvider = class {
7212
8085
  const media = await this.mediaStore.listByOwner("face", face.id);
7213
8086
  return media.length > 0 ? media[0].base64 : void 0;
7214
8087
  }
8088
+ /**
8089
+ * The crop as a data-plane URL, or undefined when there is no stored key or
8090
+ * no plane to serve it.
8091
+ *
8092
+ * The `event-media` plane resolves a raw MediaStore key (any id containing
8093
+ * `:` goes to `getByKey`), so a face crop needs NO new plane and no new
8094
+ * access decision — the same `authenticated` level already serves the track
8095
+ * thumbnails the same operator is looking at.
8096
+ */
8097
+ cropUrlFor(face) {
8098
+ if (face.mediaKey === void 0 || face.mediaKey === "") return void 0;
8099
+ const base = this.eventMediaBaseUrl();
8100
+ if (base === null) return void 0;
8101
+ return `${base}/${encodeURIComponent(face.mediaKey)}`;
8102
+ }
7215
8103
  async listRecentFaces(input) {
7216
8104
  const faces = await this.faceStore.listAllRecentFaces({
7217
8105
  limit: input?.limit,
@@ -7219,11 +8107,12 @@ var FaceGalleryProvider = class {
7219
8107
  });
7220
8108
  const identities = await this.identityStore.listIdentities();
7221
8109
  const nameMap = new Map(identities.map((i) => [i.id, i.name]));
7222
- const result = [];
7223
- for (const face of faces) {
7224
- const base64 = await this.faceCropBase64(face);
8110
+ const includeCrops = input?.includeCrops ?? true;
8111
+ return mapWithConcurrency(faces, FACE_CROP_FETCH_CONCURRENCY, async (face) => {
8112
+ const base64 = includeCrops ? await this.faceCropBase64(face) : void 0;
8113
+ const cropUrl = this.cropUrlFor(face);
7225
8114
  const identityName = face.recognizedIdentityId ? nameMap.get(face.recognizedIdentityId) : void 0;
7226
- result.push({
8115
+ return {
7227
8116
  faceId: face.id,
7228
8117
  deviceId: face.deviceId,
7229
8118
  trackId: face.trackId,
@@ -7232,15 +8121,15 @@ var FaceGalleryProvider = class {
7232
8121
  ...identityName !== void 0 ? { identityName } : {},
7233
8122
  assigned: face.assigned,
7234
8123
  ...base64 !== void 0 ? { base64 } : {},
8124
+ ...cropUrl !== void 0 ? { cropUrl } : {},
7235
8125
  ...face.faceBbox !== void 0 ? { faceBbox: face.faceBbox } : {},
7236
8126
  ...face.keyFrameMediaKey !== void 0 ? { keyFrameMediaKey: face.keyFrameMediaKey } : {},
7237
8127
  ...face.bestMatchScore != null ? { bestMatchScore: face.bestMatchScore } : {},
7238
8128
  ...face.nativeFaceShortSidePx != null ? { nativeFaceShortSidePx: face.nativeFaceShortSidePx } : {},
7239
8129
  ...face.suggestedIdentityId != null ? { suggestedIdentityId: face.suggestedIdentityId } : {},
7240
8130
  ...face.suggestedMatchScore != null ? { suggestedMatchScore: face.suggestedMatchScore } : {}
7241
- });
7242
- }
7243
- return result;
8131
+ };
8132
+ });
7244
8133
  }
7245
8134
  /**
7246
8135
  * Get a face by deviceId + trackId. Returns null if not found or if
@@ -10999,6 +11888,13 @@ function eventsToClips(events, pad) {
10999
11888
  ...events.audio.map((e) => make(e, "audio"))
11000
11889
  ].toSorted((a, b) => b.timeRange.startMs - a.timeRange.startMs);
11001
11890
  }
11891
+ /** Keep only clips whose window overlaps at least one recorded range — a clip
11892
+ * is a window over footage, so a window with nothing behind it is unplayable
11893
+ * and must not be listed (a camera that never recorded showed 15 dead audio
11894
+ * "clips" in the viewer ribbon, every tap a 500). Pure. */
11895
+ function filterClipsByFootage(clips, ranges) {
11896
+ return clips.filter((c) => ranges.some((r) => c.timeRange.startMs < r.endMs && c.timeRange.endMs > r.startMs));
11897
+ }
11002
11898
  function createVideoclipsProvider(deps) {
11003
11899
  return {
11004
11900
  listClips: async ({ deviceId, since, until, limit }) => {
@@ -11008,7 +11904,20 @@ function createVideoclipsProvider(deps) {
11008
11904
  until,
11009
11905
  limit
11010
11906
  }), deps.defaultPad);
11011
- return limit != null ? clips.slice(0, limit) : clips;
11907
+ let filtered = clips;
11908
+ if (deps.fetchAvailability) try {
11909
+ filtered = filterClipsByFootage(clips, await deps.fetchAvailability({
11910
+ deviceId,
11911
+ since,
11912
+ until
11913
+ }));
11914
+ } catch (err) {
11915
+ deps.logWarn?.("videoclips: availability read failed — listing unfiltered", {
11916
+ deviceId,
11917
+ error: err instanceof Error ? err.message : String(err)
11918
+ });
11919
+ }
11920
+ return limit != null ? filtered.slice(0, limit) : filtered;
11012
11921
  },
11013
11922
  getClipPlayback: async ({ deviceId, clipId }) => {
11014
11923
  const parsed = parseClipId(clipId);
@@ -11021,8 +11930,22 @@ function createVideoclipsProvider(deps) {
11021
11930
  }
11022
11931
  };
11023
11932
  }
11024
- /** Real time. See {@link EventMediaRequest.speed}. */
11025
- var DEFAULT_SPEED = 1;
11933
+ /**
11934
+ * The VIDEO's playback rate — 4×, the same as {@link NC_GIF_SPEED}.
11935
+ *
11936
+ * This was 1, and the reasoning for 1 was sound as far as it went: `speed !== 1`
11937
+ * fails `clipCanCopy`, so a sped-up video cannot be the camera's own H.264
11938
+ * copied — it is a `libx264` burst. The OPERATOR priced that and took it. It is
11939
+ * one encode per event over a ~12 s window, not a permanent transcode child
11940
+ * ([D84](../../../../../../docs/decisions/adr-0084.md) is about the latter), and
11941
+ * it was measured on a real 615 720p cut before being chosen: **0.23 s of
11942
+ * encode, 254 KB out**, against the 922 KB the copy of the same window carried.
11943
+ * The re-encode is smaller than what it replaces.
11944
+ *
11945
+ * So both attachments now agree on the timeline as well as on the window: one
11946
+ * clip, one rate, two containers.
11947
+ */
11948
+ var DEFAULT_SPEED = 4;
11026
11949
  /** The fallback mp4's requested width — the cap's own ceiling, so a rendition
11027
11950
  * at or below 1080p is an identity scale and the broker copies it. */
11028
11951
  var RING_MP4_MAX_WIDTH = 1920;
@@ -11067,7 +11990,8 @@ var EventMediaService = class {
11067
11990
  postSeconds: request.postRollSec,
11068
11991
  kinds,
11069
11992
  gifMaxWidth: 640,
11070
- gifFps: 8,
11993
+ gifFps: 12,
11994
+ gifSpeed: 4,
11071
11995
  speed: request.speed ?? DEFAULT_SPEED,
11072
11996
  ...request.profile !== void 0 ? { profile: request.profile } : {}
11073
11997
  });
@@ -11193,7 +12117,8 @@ var EventMediaService = class {
11193
12117
  ...window,
11194
12118
  format: "gif",
11195
12119
  maxWidth: 640,
11196
- fps: 8
12120
+ fps: 12 / 4,
12121
+ speed: 4
11197
12122
  }) : null;
11198
12123
  if ((mp4 === null || mp4.byteLength === 0) && (gif === null || gif.byteLength === 0)) {
11199
12124
  this.deps.logger.warn("nc media: the clip ring covered nothing — no footage attached", {
@@ -19232,6 +20157,21 @@ var PlateRecognizer = class {
19232
20157
  }
19233
20158
  };
19234
20159
  //#endregion
20160
+ //#region src/pipeline-analytics/read-slice.ts
20161
+ /**
20162
+ * The loaded slice, or `undefined` when it has never loaded and the one
20163
+ * permitted refresh also failed to produce it.
20164
+ *
20165
+ * A failing refresh is swallowed deliberately: every caller degrades to "no
20166
+ * zones / no rules", and a zone read must never take down the frame that asked.
20167
+ */
20168
+ async function readSliceOnce(handle) {
20169
+ const loaded = handle.value;
20170
+ if (loaded !== void 0) return loaded;
20171
+ await handle.refresh().catch(() => void 0);
20172
+ return handle.value;
20173
+ }
20174
+ //#endregion
19235
20175
  //#region src/pipeline-analytics/rebuild-detail-request.ts
19236
20176
  /** The one step a rebuild runs. */
19237
20177
  var REBUILD_DETAIL_STEP_ID = "clip-embedding";
@@ -22117,6 +23057,10 @@ var TRACKS_COLUMNS = [
22117
23057
  name: "debug",
22118
23058
  type: "BOOLEAN"
22119
23059
  },
23060
+ {
23061
+ name: "hasFace",
23062
+ type: "BOOLEAN"
23063
+ },
22120
23064
  {
22121
23065
  name: "envMinX",
22122
23066
  type: "REAL"
@@ -22197,7 +23141,8 @@ function cloneTrack(t) {
22197
23141
  retrainStatus: t.retrainStatus,
22198
23142
  markForTrain: t.retrainStatus === "staging"
22199
23143
  } : {},
22200
- ...t.debug !== void 0 ? { debug: t.debug } : {}
23144
+ ...t.debug !== void 0 ? { debug: t.debug } : {},
23145
+ ...t.hasFace !== void 0 ? { hasFace: t.hasFace } : {}
22201
23146
  };
22202
23147
  }
22203
23148
  var TrackStore = class {
@@ -22560,6 +23505,49 @@ var TrackStore = class {
22560
23505
  }
22561
23506
  }
22562
23507
  /**
23508
+ * Record that a face DETECTOR found a face on this track. Set-once, never
23509
+ * cleared, and deliberately NOT part of {@link setFlags}: that method is the
23510
+ * operator's write surface and this is the pipeline's.
23511
+ *
23512
+ * **The signal is detection, not recognition.** The caller is the detail-route
23513
+ * fan-out, keyed on the detail's own `className === 'face'` — BEFORE the
23514
+ * `minFacePx` gate, before the recognizer is consulted, and regardless of
23515
+ * whether face recognition is enabled at all. That is the whole point: the
23516
+ * viewer's "face present, identity unknown" badge is `hasFace && !subLabel`,
23517
+ * and a flag that only fired when the recognizer answered would be true
23518
+ * exactly when the badge must not show.
23519
+ *
23520
+ * **Idempotent, and it does not write per detail.** A face detail lands many
23521
+ * times over a track's life. While the track is LIVE the flag is set in RAM
23522
+ * only and rides `persistCompleted` at expiry — one row write, not one per
23523
+ * frame. Once the flag is set, every later call returns immediately.
23524
+ *
23525
+ * The persisted patch is for the LATE detail: enrichment answers routinely
23526
+ * arrive after the track has expired, when there is no RAM entry left and the
23527
+ * row is the only thing to correct. Best-effort — a failed patch costs a badge,
23528
+ * never a track.
23529
+ */
23530
+ async markFaceDetected(trackId) {
23531
+ const active = this.active.get(trackId);
23532
+ if (active) {
23533
+ if (active.hasFace === true) return;
23534
+ active.hasFace = true;
23535
+ return;
23536
+ }
23537
+ try {
23538
+ await this.store.update.mutate({
23539
+ collection: TRACKS_COLLECTION,
23540
+ id: trackId,
23541
+ data: { hasFace: true }
23542
+ });
23543
+ } catch (err) {
23544
+ this.logger.warn("markFaceDetected persist failed", { meta: {
23545
+ trackId,
23546
+ error: String(err)
23547
+ } });
23548
+ }
23549
+ }
23550
+ /**
22563
23551
  * Write the retrain LIFECYCLE state directly — the move `setFlags` cannot
22564
23552
  * express.
22565
23553
  *
@@ -22773,6 +23761,71 @@ var TrackStore = class {
22773
23761
  }
22774
23762
  }
22775
23763
  /**
23764
+ * The device's DEBUG-marked tracks, newest first — the Events → Debug tab.
23765
+ *
23766
+ * Two things about this query are load-bearing.
23767
+ *
23768
+ * **`where: { debug: true }` matches only the explicit `true`.** The column is
23769
+ * nullable with no default (unlike `retrainStatus`), so a row that was never
23770
+ * marked holds NULL and positive equality skips it — which is exactly the set
23771
+ * wanted here, and the reason the column was left nullable in the first place.
23772
+ *
23773
+ * **The result is re-checked in memory.** `compileFilter` DROPS a predicate
23774
+ * whose column does not exist in SQLite — an older hub whose `declareCollection`
23775
+ * fingerprint never re-ran would answer this query with EVERY track on the
23776
+ * camera, silently, and the tab would present the whole history as
23777
+ * operator-marked. A skipped predicate is indistinguishable from a matching one
23778
+ * at the wire, so the guard is the only thing that can tell them apart.
23779
+ *
23780
+ * **`projection: 'slim'`, and this is the expensive detail.** `positions` is a
23781
+ * JSON column averaging ~10 KB a row, and `'full'` parses it for every row
23782
+ * returned. That is affordable for a handful of marked tracks and ruinous in
23783
+ * the case this method already defends against: if the predicate is dropped,
23784
+ * the store hands back `limit` rows PER CAMERA and the trajectory of the
23785
+ * camera's whole recent history is parsed before the guard below throws it
23786
+ * away. The cheap question comes first (D56) — the list carries no trajectory,
23787
+ * and the per-track analysis reads the one row it needs through
23788
+ * `getPersistedByTrackId`.
23789
+ */
23790
+ async listDebug(deviceId, limit) {
23791
+ try {
23792
+ const tracks = (await this.store.query.query({
23793
+ collection: TRACKS_COLLECTION,
23794
+ filter: {
23795
+ where: {
23796
+ deviceId,
23797
+ debug: true
23798
+ },
23799
+ orderBy: {
23800
+ field: "lastSeen",
23801
+ direction: "desc"
23802
+ },
23803
+ limit
23804
+ }
23805
+ })).filter((r) => typeof r.id === "string").map((r) => this.rowToTrack(r.id, r.data, "slim"));
23806
+ const marked = tracks.filter((t) => t.debug === true);
23807
+ if (marked.length !== tracks.length) this.logger.warn("TrackStore.listDebug — the store returned unmarked rows; the `debug` predicate was not applied", {
23808
+ tags: { deviceId },
23809
+ meta: {
23810
+ deviceId,
23811
+ returned: tracks.length,
23812
+ marked: marked.length
23813
+ }
23814
+ });
23815
+ return marked;
23816
+ } catch (err) {
23817
+ this.logger.warn("TrackStore.listDebug failed", {
23818
+ tags: { deviceId },
23819
+ meta: {
23820
+ deviceId,
23821
+ limit,
23822
+ error: String(err)
23823
+ }
23824
+ });
23825
+ return [];
23826
+ }
23827
+ }
23828
+ /**
22776
23829
  * How many of this device's tracks are STAGING — optionally only those older
22777
23830
  * than a cutoff, i.e. exactly the rows a sweep just declined to touch.
22778
23831
  *
@@ -23179,6 +24232,7 @@ var TrackStore = class {
23179
24232
  ...t.audioLabels !== void 0 ? { audioLabels: [...t.audioLabels] } : {},
23180
24233
  ...t.retrainStatus !== void 0 ? { retrainStatus: t.retrainStatus } : {},
23181
24234
  ...t.debug !== void 0 ? { debug: t.debug } : {},
24235
+ ...t.hasFace !== void 0 ? { hasFace: t.hasFace } : {},
23182
24236
  ...envelope !== null && dims !== void 0 ? {
23183
24237
  envMinX: envelope.minX,
23184
24238
  envMinY: envelope.minY,
@@ -23213,6 +24267,7 @@ var TrackStore = class {
23213
24267
  const audioLabels = data["audioLabels"];
23214
24268
  const retrainParsed = RetrainStatusSchema.safeParse(data["retrainStatus"]);
23215
24269
  const debug = data["debug"];
24270
+ const hasFace = data["hasFace"];
23216
24271
  const envMinX = data["envMinX"];
23217
24272
  const envMinY = data["envMinY"];
23218
24273
  const envMaxX = data["envMaxX"];
@@ -23248,6 +24303,7 @@ var TrackStore = class {
23248
24303
  markForTrain: retrainParsed.data === "staging"
23249
24304
  } : {},
23250
24305
  ...typeof debug === "boolean" ? { debug } : {},
24306
+ ...typeof hasFace === "boolean" ? { hasFace } : {},
23251
24307
  ...envelope !== null ? { envelope } : {}
23252
24308
  };
23253
24309
  }
@@ -26396,41 +27452,6 @@ var EventMediaDispatcher = class {
26396
27452
  }
26397
27453
  }
26398
27454
  };
26399
- //#endregion
26400
- //#region src/pipeline-analytics/services/sensor-marker-projector.ts
26401
- /**
26402
- * Sensor markers — the gate between "a mapped sensor/control cap changed
26403
- * state" and "a synthetic track exists on the camera's timeline"
26404
- * (event-kinds-taxonomy §8).
26405
- *
26406
- * It is the sibling of {@link ../services/audio-marker-projector.ts} and it
26407
- * exists for the same reason: the projection has gates, and a gate that drops
26408
- * work without saying so is indistinguishable from a feature that was never
26409
- * wired.
26410
- *
26411
- * **What this fixes.** The projection used to resolve its cameras with
26412
- * `camerasFor` alone — LINKED cameras only — while the history-row ingest
26413
- * beside it had gained the self-camera half on 2026-08-07. A doorbell wrapper
26414
- * is bound on the CAMERA (the virtual-doorbell builtin writes the `doorbell`
26415
- * slice onto camera 615 itself), so `camerasFor(615)` answered `[]` and the
26416
- * projection returned on a `debug` line nobody runs at. Camera 615's real
26417
- * press at 2026-08-08 06:59Z therefore produced a `SensorEvent` row, a HomeKit
26418
- * ring, an Alexa post and a matched notification rule — and no track. Both
26419
- * halves now derive their cameras from {@link resolveSensorEventCameras}.
26420
- *
26421
- * **The asymmetry that used to be here was defended by a wrong premise.** The
26422
- * old comment argued a self-projected track would feed the NC `track-end`
26423
- * trigger and make every press evaluate the camera's object rules. It does
26424
- * not: `persistSyntheticTrack` writes the row directly and the NC track-end
26425
- * hook lives in the LIVE tracker's closer, which no synthetic track ever
26426
- * enters. A linked contact sensor has been projecting onto its camera all
26427
- * along under exactly the same mechanism.
26428
- *
26429
- * **Nothing here is silent.** A marker that lands emits one `info` line; every
26430
- * branch that lands none emits one line carrying the reason. Both carry
26431
- * `tags: { deviceId }` with the CAMERA id, because the question always arrives
26432
- * as "why did 617 get a marker and 615 not".
26433
- */
26434
27455
  /**
26435
27456
  * Project one `DeviceStateChanged` of a mapped sensor/control cap into a
26436
27457
  * synthetic track per camera the event belongs to. Returns the number of
@@ -26469,6 +27490,7 @@ async function projectSensorMarkers(deps, data, timestamp) {
26469
27490
  source: "sensor",
26470
27491
  kind: descriptor.kind,
26471
27492
  timestamp,
27493
+ snapshotTimeoutMs: deps.snapshotTimeoutMs ?? 2500,
26472
27494
  ...producingDeviceName !== void 0 ? { producingDeviceName } : {}
26473
27495
  });
26474
27496
  if (track === null) {
@@ -26488,7 +27510,8 @@ async function projectSensorMarkers(deps, data, timestamp) {
26488
27510
  capName: data.capName,
26489
27511
  kind: descriptor.kind,
26490
27512
  trackId: track.trackId,
26491
- selfCamera: cameraId === data.deviceId
27513
+ selfCamera: cameraId === data.deviceId,
27514
+ hasMedia: track.snapshots.length > 0
26492
27515
  });
26493
27516
  }
26494
27517
  return landed;
@@ -26603,13 +27626,13 @@ var SyntheticTrackMaterializer = class {
26603
27626
  * answers: ≤10 s old on a mains camera, and on a battery camera the
26604
27627
  * intentionally stale-but-honest frame that costs no wake.
26605
27628
  */
26606
- async fetchSnapshot(cameraId) {
27629
+ async fetchSnapshot(cameraId, timeoutMs) {
26607
27630
  try {
26608
- const snap = await withTimeout(this.deps.snapshot.getSnapshot({ deviceId: cameraId }), this.snapshotTimeoutMs);
26609
- if (snap === null) this.deps.onError?.("getSnapshot", /* @__PURE__ */ new Error("snapshot returned null"));
27631
+ const snap = await withTimeout(this.deps.snapshot.getSnapshot({ deviceId: cameraId }), timeoutMs);
27632
+ if (snap === null) this.deps.onError?.("getSnapshot", /* @__PURE__ */ new Error("snapshot returned null"), cameraId);
26610
27633
  return snap;
26611
27634
  } catch (err) {
26612
- this.deps.onError?.(err instanceof SnapshotTimeoutError ? "snapshotTimeout" : "snapshotMedia", err);
27635
+ this.deps.onError?.(err instanceof SnapshotTimeoutError ? "snapshotTimeout" : "snapshotMedia", err, cameraId);
26613
27636
  return null;
26614
27637
  }
26615
27638
  }
@@ -26626,14 +27649,14 @@ var SyntheticTrackMaterializer = class {
26626
27649
  const trackId = this.makeId();
26627
27650
  const ts = input.timestamp;
26628
27651
  let mediaKey = null;
26629
- const snap = await this.fetchSnapshot(input.cameraId);
27652
+ const snap = await this.fetchSnapshot(input.cameraId, input.snapshotTimeoutMs ?? this.snapshotTimeoutMs);
26630
27653
  if (snap !== null) try {
26631
27654
  const raw = Buffer.from(snap.base64, "base64");
26632
27655
  let data = raw;
26633
27656
  try {
26634
27657
  data = await downscaleFullFrameJpeg(raw, 960, 540);
26635
27658
  } catch (err) {
26636
- this.deps.onError?.("downscaleSnapshot", err);
27659
+ this.deps.onError?.("downscaleSnapshot", err, input.cameraId);
26637
27660
  }
26638
27661
  mediaKey = await this.deps.media.put({
26639
27662
  deviceId: input.cameraId,
@@ -26644,7 +27667,7 @@ var SyntheticTrackMaterializer = class {
26644
27667
  data
26645
27668
  });
26646
27669
  } catch (err) {
26647
- this.deps.onError?.("snapshotMedia", err);
27670
+ this.deps.onError?.("snapshotMedia", err, input.cameraId);
26648
27671
  }
26649
27672
  const track = {
26650
27673
  trackId,
@@ -26669,7 +27692,7 @@ var SyntheticTrackMaterializer = class {
26669
27692
  try {
26670
27693
  await this.deps.tracks.persistSyntheticTrack(track);
26671
27694
  } catch (err) {
26672
- this.deps.onError?.("persistSyntheticTrack", err);
27695
+ this.deps.onError?.("persistSyntheticTrack", err, input.cameraId);
26673
27696
  return null;
26674
27697
  }
26675
27698
  return track;
@@ -29369,8 +30392,6 @@ var REBUILD_PAGE_SIZE = 200;
29369
30392
  */
29370
30393
  var REBUILD_FALLBACK_NODE_ID = "hub";
29371
30394
  var PIPELINE_ANALYTICS_ADDON_ID = "pipeline-analytics";
29372
- /** Operator-visible name of the fixed integration. */
29373
- var NC_INTEGRATION_NAME = "Notification Center";
29374
30395
  /**
29375
30396
  * The FULL action catalog for this addon.
29376
30397
  *
@@ -29381,7 +30402,8 @@ var NC_INTEGRATION_NAME = "Notification Center";
29381
30402
  */
29382
30403
  var customActions = {
29383
30404
  ...ncActions,
29384
- ...embeddingActions
30405
+ ...embeddingActions,
30406
+ ...debugActions
29385
30407
  };
29386
30408
  /**
29387
30409
  * Assist threshold when the caller does not pin one. Packages have their own
@@ -29628,6 +30650,8 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
29628
30650
  trainingExportDataPlane = null;
29629
30651
  /** Handle for the retrain-export data-plane listener (dispose on shutdown). */
29630
30652
  retrainExportDataPlane = null;
30653
+ /** Handle for the debug-export data-plane listener (dispose on shutdown). */
30654
+ debugExportDataPlane = null;
29631
30655
  /** The retrain curation surface — see `retrain/retrain-service.ts`. Null
29632
30656
  * until the stores are built. */
29633
30657
  retrain = null;
@@ -29666,6 +30690,7 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
29666
30690
  kinds: [...input.kinds],
29667
30691
  gifMaxWidth: input.gifMaxWidth,
29668
30692
  gifFps: input.gifFps,
30693
+ gifSpeed: input.gifSpeed,
29669
30694
  speed: input.speed,
29670
30695
  ...input.profile === "high" || input.profile === "mid" || input.profile === "low" ? { profile: input.profile } : {}
29671
30696
  }),
@@ -29790,7 +30815,7 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
29790
30815
  });
29791
30816
  const served = await this.ctx.dataPlane?.serve({
29792
30817
  prefix: "nc-action",
29793
- access: "public",
30818
+ access: planeAccess("nc-action"),
29794
30819
  handler: plane.handler
29795
30820
  }) ?? null;
29796
30821
  this.ncActionMintUrl = served === null ? null : async ({ id, expMs }) => {
@@ -29844,7 +30869,7 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
29844
30869
  });
29845
30870
  this.ncArtifactDataPlane = await this.ctx.dataPlane?.serve({
29846
30871
  prefix: "nc-artifact",
29847
- access: "public",
30872
+ access: planeAccess("nc-artifact"),
29848
30873
  handler: plane.handler
29849
30874
  }) ?? null;
29850
30875
  this.ncArtifactPlane = this.ncArtifactDataPlane !== null ? plane : null;
@@ -30127,7 +31152,7 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
30127
31152
  const api = this.ctx.api;
30128
31153
  if (!api) throw new Error("pipeline-analytics requires ctx.api (device-manager + settings-store)");
30129
31154
  await this.declareCollections(api);
30130
- await this.ensureAlarmPanel(api);
31155
+ await this.declareAlarmPanel(api);
30131
31156
  const logger = this.ctx.logger;
30132
31157
  const storage = await this.resolveMediaStorage(logger);
30133
31158
  const stores = await this.buildStores(api, logger, storage);
@@ -30189,11 +31214,13 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
30189
31214
  ...isHub && ncHandlers !== void 0 ? {
30190
31215
  customActions: {
30191
31216
  ...ncActions,
30192
- ...embeddingActions
31217
+ ...embeddingActions,
31218
+ ...debugActions
30193
31219
  },
30194
31220
  actionHandlers: {
30195
31221
  ...ncHandlers,
30196
- ...this.buildEmbeddingActionHandlers()
31222
+ ...this.buildEmbeddingActionHandlers(),
31223
+ ...this.buildDebugActionHandlers()
30197
31224
  }
30198
31225
  } : {}
30199
31226
  };
@@ -30224,64 +31251,38 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
30224
31251
  /** Declare typed collections up-front so the first insert doesn't race with
30225
31252
  * a CREATE TABLE. Idempotent. */
30226
31253
  /**
30227
- * Bring the Notification Center's alarm panel into existence, once.
31254
+ * DECLARE the Notification Center's alarm panel.
30228
31255
  *
30229
- * Both steps are get-or-create, because this runs on every boot and an
30230
- * operator must never end up with a second panel after a restart. The
30231
- * integration is FIXED: it exists because this addon says so, not because
30232
- * anybody added it, and there is no add/delete flow for it.
30233
- */
30234
- async ensureAlarmPanel(api) {
30235
- const devices = this.ctx.kernel.devices;
30236
- const rawNodeId = this.ctx.kernel.localNodeId ?? "hub";
30237
- const nodeId = rawNodeId.includes("/") ? rawNodeId.split("/")[0] ?? "hub" : rawNodeId;
30238
- if (nodeId !== "hub") {
30239
- this.ctx.logger.info("alarm panel is hub-owned — skipping on this node", { meta: {
30240
- nodeId,
30241
- rawNodeId
30242
- } });
30243
- return;
30244
- }
31256
+ * The procedure is not here any more — `DeclaredDevices` owns the
31257
+ * get-or-create, the FIXED integration and its every-pass `fixed`
31258
+ * re-assertion, the adopt-don't-skip rule, the hub-ownership gate and the D49
31259
+ * second read that stops a transiently empty index re-stamping the declared
31260
+ * name over the operator's rename. What is left is the declaration and the
31261
+ * ports.
31262
+ */
31263
+ async declareAlarmPanel(api) {
31264
+ const declared = new DeclaredDevices({
31265
+ logger: this.ctx.logger.child("alarm"),
31266
+ addonId: PIPELINE_ANALYTICS_ADDON_ID,
31267
+ devices: this.ctx.kernel.devices,
31268
+ localNodeId: this.ctx.kernel.localNodeId,
31269
+ getIntegration: async (addonId) => api.integrations.getByAddonId.query({ addonId }),
31270
+ createIntegration: async (input) => api.integrations.create.mutate(input),
31271
+ updateIntegration: async ({ id, info }) => {
31272
+ await api.integrations.update.mutate({
31273
+ id,
31274
+ info,
31275
+ skipRestart: true
31276
+ });
31277
+ },
31278
+ listOwnDevices: async () => api.deviceManager.listAll.query({ addonId: PIPELINE_ANALYTICS_ADDON_ID })
31279
+ });
30245
31280
  try {
30246
- const result = await ensureAlarmPanel({
30247
- logger: this.ctx.logger.child("alarm"),
30248
- addonId: PIPELINE_ANALYTICS_ADDON_ID,
31281
+ const result = await declared.reconcile({
30249
31282
  integrationName: NC_INTEGRATION_NAME,
30250
- getIntegration: async (addonId) => {
30251
- const found = await api.integrations.getByAddonId.query({ addonId });
30252
- return found === null ? null : {
30253
- id: found.id,
30254
- fixed: found.info?.["fixed"] === true
30255
- };
30256
- },
30257
- markFixed: async (integrationId) => {
30258
- await api.integrations.update.mutate({
30259
- id: integrationId,
30260
- info: { fixed: true },
30261
- skipRestart: true
30262
- });
30263
- },
30264
- createIntegration: async (input) => api.integrations.create.mutate(input),
30265
- findDeviceByStableId: async (stableId) => {
30266
- const found = (await api.deviceManager.listAll.query({ addonId: PIPELINE_ANALYTICS_ADDON_ID })).find((d) => d.stableId === stableId);
30267
- return found === void 0 ? null : { id: found.id };
30268
- },
30269
- createDevice: async ({ stableId, integrationId, name }) => {
30270
- const device = await devices.create(stableId, NC_ALARM_DEVICE_CLASS, {}, null, {
30271
- type: NC_ALARM_DEVICE_TYPE,
30272
- name,
30273
- integrationId
30274
- });
30275
- this.holdAlarmPanel(device);
30276
- return { id: device.id };
30277
- },
30278
- adoptDevice: async ({ stableId }) => {
30279
- const device = await devices.create(stableId, NC_ALARM_DEVICE_CLASS, {}, null, void 0);
30280
- this.holdAlarmPanel(device);
30281
- return { id: device.id };
30282
- }
31283
+ devices: [NC_ALARM_DECLARATION]
30283
31284
  });
30284
- if (result.created) this.ctx.logger.info("notification-center alarm panel ready", { tags: { deviceId: result.deviceId } });
31285
+ for (const outcome of result.devices) this.holdAlarmPanel(outcome.device);
30285
31286
  } catch (err) {
30286
31287
  this.ctx.logger.warn("alarm panel could not be ensured — rules still notify", { meta: { error: err instanceof Error ? err.message : String(err) } });
30287
31288
  }
@@ -30304,7 +31305,7 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
30304
31305
  /**
30305
31306
  * Join the panel and the Notification Center, once both exist.
30306
31307
  *
30307
- * They are built in that order (`ensureAlarmPanel` runs before the centre is
31308
+ * They are built in that order (`declareAlarmPanel` runs before the centre is
30308
31309
  * constructed) and neither can reach the other on its own, so the join is
30309
31310
  * here. A node with no panel simply never calls it, and the cap answers
30310
31311
  * `deviceId: null` — which is what an agent node should say.
@@ -30861,7 +31862,12 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
30861
31862
  }
30862
31863
  /** Event-media data-plane: serve thumbnail JPEGs at
30863
31864
  * `/addon/<addonId>/event-media/<eventId>` through the hub's port.
30864
- * Mirror the recorder playback pattern: try/warn, store handle for dispose. */
31865
+ * Mirror the recorder playback pattern: try/warn, store handle for dispose.
31866
+ *
31867
+ * `authenticated`, not `admin` — see `data-plane-access.ts` for the rule and
31868
+ * the reason it changed: `getTrackMedia` / `getEventMedia` are declared
31869
+ * `access: 'view'` and return the SAME stored blob base64 over tRPC, so the
31870
+ * admin gate protected nothing and cost every non-admin session its tiles. */
30865
31871
  async serveEventMediaDataPlane() {
30866
31872
  try {
30867
31873
  const handler = createEventMediaHandler({ getMedia: async (id, variant, preferKind) => {
@@ -30877,22 +31883,27 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
30877
31883
  } });
30878
31884
  this.eventMediaDataPlane = await this.ctx.dataPlane?.serve({
30879
31885
  prefix: "event-media",
30880
- access: "admin",
31886
+ access: planeAccess("event-media"),
30881
31887
  handler
30882
31888
  }) ?? null;
30883
31889
  this.eventMediaBaseUrl = this.eventMediaDataPlane !== null ? `/addon/${this.ctx.id}/event-media` : null;
30884
31890
  await this.serveNcArtifactPlane();
30885
31891
  await this.serveTrainingExportPlane();
30886
31892
  await this.serveRetrainExportPlane();
30887
- this.ctx.logger.info("event-media data-plane served", { meta: { baseUrl: this.eventMediaBaseUrl ?? "(no dataPlane facility)" } });
31893
+ await this.serveDebugExportPlane();
31894
+ this.ctx.logger.info("event-media data-plane served", { meta: {
31895
+ baseUrl: this.eventMediaBaseUrl ?? "(no dataPlane facility)",
31896
+ access: planeAccess("event-media")
31897
+ } });
30888
31898
  } catch (err) {
30889
31899
  this.ctx.logger.warn("event-media data-plane failed to serve", { meta: { error: errMsg(err) } });
30890
31900
  }
30891
31901
  }
30892
31902
  /**
30893
- * The training-export data plane. Separate listener, `access: 'admin'` —
30894
- * marking a track is a viewer-level curation act (D81), but walking off with
30895
- * every stored frame behind the marked set is not.
31903
+ * The training-export data plane. Separate listener, admin-only (the level
31904
+ * is declared in `data-plane-access.ts`) — marking a track is a viewer-level
31905
+ * curation act (D81), but walking off with every stored frame behind the
31906
+ * marked set is not.
30896
31907
  */
30897
31908
  async serveTrainingExportPlane() {
30898
31909
  try {
@@ -30907,7 +31918,7 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
30907
31918
  });
30908
31919
  this.trainingExportDataPlane = await this.ctx.dataPlane?.serve({
30909
31920
  prefix: "training-export",
30910
- access: "admin",
31921
+ access: planeAccess("training-export"),
30911
31922
  handler
30912
31923
  }) ?? null;
30913
31924
  } catch (err) {
@@ -31036,6 +32047,60 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
31036
32047
  return { url: `/addon/${this.ctx.id}/${RETRAIN_EXPORT_PREFIX}${query}` };
31037
32048
  }
31038
32049
  /**
32050
+ * The store seam the `debug.*` actions and the debug bundle both read through.
32051
+ *
32052
+ * INDEX READS ONLY — three media-inventory queries and an event query per
32053
+ * track, no blob. The bytes are fetched one at a time, by key, by the export
32054
+ * handler, so a bundle of a hundred tracks never holds more than one file.
32055
+ */
32056
+ buildDebugServiceDeps() {
32057
+ const trackStore = this.trackStore;
32058
+ const mediaStore = this.mediaStore;
32059
+ const eventStore = this.eventStore;
32060
+ if (trackStore === null || mediaStore === null || eventStore === null) throw new Error("debug: analytics stores unavailable");
32061
+ return {
32062
+ listDeviceIds: () => trackStore.listDeviceIds(),
32063
+ listDebugTracks: (deviceId, limit) => trackStore.listDebug(deviceId, limit),
32064
+ getTrack: (trackId) => trackStore.getPersistedByTrackId(trackId),
32065
+ listMediaInfo: (ownerKind, ownerId) => mediaStore.listInfoByOwner(ownerKind, ownerId),
32066
+ listEvents: (trackId) => eventStore.queryObjectByTrackIds([trackId]),
32067
+ logger: this.ctx.logger.child("debug"),
32068
+ now: () => Date.now()
32069
+ };
32070
+ }
32071
+ /** The `debug.*` bridge handlers — hub-only, like `nc.*` and `embedding.*`. */
32072
+ buildDebugActionHandlers() {
32073
+ return {
32074
+ "debug.listTracks": async (input) => listDebugTracks(this.buildDebugServiceDeps(), input),
32075
+ "debug.analyze": async (input) => analyzeDebugTracks(this.buildDebugServiceDeps(), input)
32076
+ };
32077
+ }
32078
+ /**
32079
+ * The debug-export data plane. Its own listener, for the same reason the
32080
+ * retrain export has one: a client asking for the debug bundle must never be
32081
+ * able to receive the annotated dataset by changing a query parameter.
32082
+ */
32083
+ async serveDebugExportPlane() {
32084
+ try {
32085
+ const handler = createDebugExportHandler({
32086
+ buildPlan: (deviceIds) => buildDebugBundlePlan(this.buildDebugServiceDeps(), deviceIds),
32087
+ readMedia: async (key) => {
32088
+ const file = await this.mediaStore?.getByKey(key);
32089
+ return file === null || file === void 0 ? null : Buffer.from(file.base64, "base64");
32090
+ },
32091
+ logger: this.ctx.logger.child("debug-export"),
32092
+ now: () => Date.now()
32093
+ });
32094
+ this.debugExportDataPlane = await this.ctx.dataPlane?.serve({
32095
+ prefix: "debug-export",
32096
+ access: planeAccess("debug-export"),
32097
+ handler
32098
+ }) ?? null;
32099
+ } catch (err) {
32100
+ this.ctx.logger.warn("debug-export data-plane failed to serve", { meta: { error: errMsg(err) } });
32101
+ }
32102
+ }
32103
+ /**
31039
32104
  * The retrain-export data plane. Separate listener from the training export
31040
32105
  * next door: different archive, different contents, and a client asking for
31041
32106
  * one must never be able to receive the other by changing a query parameter.
@@ -31057,7 +32122,7 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
31057
32122
  });
31058
32123
  this.retrainExportDataPlane = await this.ctx.dataPlane?.serve({
31059
32124
  prefix: "retrain-export",
31060
- access: "admin",
32125
+ access: planeAccess("retrain-export"),
31061
32126
  handler
31062
32127
  }) ?? null;
31063
32128
  } catch (err) {
@@ -31267,6 +32332,17 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
31267
32332
  audio
31268
32333
  };
31269
32334
  },
32335
+ fetchAvailability: async ({ deviceId, since, until }) => {
32336
+ return (await api.recording.getAvailability.query({
32337
+ deviceId,
32338
+ fromMs: since,
32339
+ toMs: until
32340
+ })).ranges;
32341
+ },
32342
+ logWarn: (message, meta) => this.ctx.logger.warn(message, {
32343
+ tags: { deviceId: Number(meta["deviceId"]) },
32344
+ meta
32345
+ }),
31270
32346
  resolvePlayback: async ({ deviceId, startMs, endMs }) => {
31271
32347
  const manifest = await api.recording.getPlaybackManifest.query({
31272
32348
  deviceId,
@@ -31288,6 +32364,7 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
31288
32364
  eventStore: stores.eventStore,
31289
32365
  logger: this.ctx.logger,
31290
32366
  refreshGallery: () => this.faceRecognizer?.refreshGallery(),
32367
+ eventMediaBaseUrl: () => this.eventMediaBaseUrl,
31291
32368
  emitFaceGalleryChanged: (payload) => this.emitFaceGalleryChanged(payload)
31292
32369
  });
31293
32370
  const plateGallery = new PlateGalleryProvider({
@@ -31388,6 +32465,8 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
31388
32465
  this.trainingExportDataPlane = null;
31389
32466
  await this.retrainExportDataPlane?.dispose();
31390
32467
  this.retrainExportDataPlane = null;
32468
+ await this.debugExportDataPlane?.dispose();
32469
+ this.debugExportDataPlane = null;
31391
32470
  }
31392
32471
  async handleInferenceResult(data) {
31393
32472
  if (this.shuttingDown) return;
@@ -32297,10 +33376,7 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
32297
33376
  async resolveDevicePackageRules(deviceId) {
32298
33377
  const proxy = await this.ensureProxy(deviceId);
32299
33378
  if (!proxy) return [];
32300
- const cached = proxy.state.zoneRules.value?.package;
32301
- if (cached && cached.length > 0) return cached;
32302
- await proxy.state.zoneRules.refresh().catch(() => void 0);
32303
- return proxy.state.zoneRules.value?.package ?? [];
33379
+ return (await readSliceOnce(proxy.state.zoneRules))?.package ?? [];
32304
33380
  }
32305
33381
  /**
32306
33382
  * Route one track's `runDetailSubtree` results (two-plane detail dispatch)
@@ -32316,6 +33392,7 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
32316
33392
  async routeDetailResults(deviceId, trackId, details, frame) {
32317
33393
  for (const d of details) try {
32318
33394
  const isFaceDetail = d.className === "face";
33395
+ if (isFaceDetail) await this.trackStore?.markFaceDetected(trackId);
32319
33396
  if (isFaceDetail && d.bbox !== void 0) this.overlayState.noteFaceDetail(deviceId, trackId, {
32320
33397
  x: d.bbox.x,
32321
33398
  y: d.bbox.y,
@@ -33607,10 +34684,7 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
33607
34684
  async resolveDeviceZones(deviceId) {
33608
34685
  const proxy = await this.ensureProxy(deviceId);
33609
34686
  if (!proxy) return [];
33610
- const cached = proxy.state.zones.value?.zones;
33611
- if (cached && cached.length > 0) return cached;
33612
- await proxy.state.zones.refresh().catch(() => void 0);
33613
- return proxy.state.zones.value?.zones ?? [];
34687
+ return (await readSliceOnce(proxy.state.zones))?.zones ?? [];
33614
34688
  }
33615
34689
  releaseProxy(deviceId) {
33616
34690
  const unsubs = this.proxyUnsubs.get(deviceId);
@@ -33738,11 +34812,12 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
33738
34812
  const store = this.sensorEventStore;
33739
34813
  const cache = this.linkedCamerasCache;
33740
34814
  if (store === null || cache === null) return;
34815
+ const persisted = [];
33741
34816
  try {
33742
34817
  await ingestSensorStateChange({
33743
34818
  sink: store,
33744
34819
  cache,
33745
- onPersisted: (ev) => this.notificationCenter?.onSensorEventPersisted(ev),
34820
+ onPersisted: (ev) => persisted.push(ev),
33746
34821
  onDropped: (reason, kind) => this.logSensorIngestDrop(data, reason, kind)
33747
34822
  }, data, timestamp);
33748
34823
  } catch (err) {
@@ -33754,23 +34829,30 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
33754
34829
  }
33755
34830
  });
33756
34831
  }
34832
+ const markerByCamera = /* @__PURE__ */ new Map();
33757
34833
  const materializer = this.syntheticTracks;
33758
- if (materializer === null) return;
33759
- try {
34834
+ if (materializer !== null) try {
33760
34835
  await projectSensorMarkers({
33761
34836
  cache,
33762
34837
  markers: materializer,
33763
34838
  producingDeviceName: (deviceId) => this.resolveProducingDeviceName(deviceId),
33764
- onProjected: (r) => this.ctx.logger.info("synthetic sensor-track projected", {
33765
- tags: { deviceId: r.cameraId },
33766
- meta: {
33767
- capName: r.capName,
33768
- kind: r.kind,
34839
+ onProjected: (r) => {
34840
+ markerByCamera.set(r.cameraId, {
33769
34841
  trackId: r.trackId,
33770
- sourceDeviceId: r.sourceDeviceId,
33771
- selfCamera: r.selfCamera
33772
- }
33773
- }),
34842
+ hasMedia: r.hasMedia
34843
+ });
34844
+ this.ctx.logger.info("synthetic sensor-track projected", {
34845
+ tags: { deviceId: r.cameraId },
34846
+ meta: {
34847
+ capName: r.capName,
34848
+ kind: r.kind,
34849
+ trackId: r.trackId,
34850
+ sourceDeviceId: r.sourceDeviceId,
34851
+ selfCamera: r.selfCamera,
34852
+ hasMedia: r.hasMedia
34853
+ }
34854
+ });
34855
+ },
33774
34856
  onSkipped: (r) => this.ctx.logger.debug("synthetic sensor-track NOT projected", {
33775
34857
  tags: { deviceId: r.cameraId ?? r.sourceDeviceId },
33776
34858
  meta: {
@@ -33790,6 +34872,7 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
33790
34872
  }
33791
34873
  });
33792
34874
  }
34875
+ for (const ev of persisted) this.notificationCenter?.onSensorEventPersisted(ev, markerByCamera.get(ev.deviceId)?.trackId);
33793
34876
  }
33794
34877
  /**
33795
34878
  * Say out loud that a MAPPED sensor cap ingested nothing, and why.