@camstack/addon-post-analysis 1.2.284 → 1.2.285

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 NcTemplateVarDescriptorSchema, $t as sensorRaisedOf, A as NC_DEFAULT_SNOOZE_MINUTES, An as unknown, At as failureContributionCapability, B as NcRuleInputSchema, Bt as mayWriteToLocation, C as MAX_BIRTH_DECISION_RECORDS, Cn as discriminatedUnion, Ct as deriveRecordingMode, D as NC_ALARM_SYSTEM_EVENT_KINDS, Dn as partialRecord, Dt as evaluateSensorEdge, E as MediaFileKindEnum, En as object, Et as encodeVectorBase64, F as NC_TEMPLATE_VARS, Ft as isMediaOwnerType, G as NcScheduleSchema, Gt as pipelineAnalyticsCapability, H as NcRuleSchema, Ht as occupancyScope, I as NcAlarmConfigSchema, It as isScheduleActive, J as NcSnoozeSuppressedSchema, Jt as readTimelapseGeneratedAt, K as NcSnoozeInputSchema, Kt as plateGalleryCapability, L as NcAlarmSettingsPatchSchema, Lt as isSourceCap, Mt as isDetectionMacroClass, N as NC_TANK_SYSTEM_EVENT_KINDS, Nt as isEventOwnerType, O as NC_CONDITION_CATALOG, On as record, Ot as evictionPolicyOfLocation, P as NC_TAXONOMY, Pt as isKnownTemplateVar, Q as NcTemplatePreviewSchema, Qt as sceneMonitorCapability, R as NcConditionDescriptorSchema, Rt as isSystemDelivery, S as MAX_ARCHIVED_DEBUG_NOTES, Sn as boolean, St as cosineSimilarity$1, T as MOTION_CLOSE_AFTER_MS, Tn as number, U as NcRuleTargetSchema, V as NcRulePatchSchema, Vt as notificationRulesCapability, W as NcRuleUpdateInputSchema, Wt as pickClusterStepModels, X as NcTaxonomySchema, Y as NcSystemEventKindSchema, Yt as resolveLocationMode, Z as NcTemplatePreviewInputSchema, Zt as ruleBelongsToAlarm$1, _ as FIRST_LEVEL_MACRO_CLASSES, _n as isDeviceScopedCap, _t as assertTimelapseCadences, a as BirthDecisionRecordSchema, an as templateVarsFor, b as LabelAttributionSchema, bn as _enum, bt as buildEventKindDescriptor, c as CoverStateSchema, cn as errMsg$1, ct as SCENE_DIVERGED, dn as CamProfileSchema, dt as TimelapseRuleInputSchema, en as servedTemplateVars, et as OpsLogEntrySchema, f as DETECTION_MACRO_CLASSES, fn as DeviceType, ft as TimelapseRulePatchSchema, g as EVENT_OWNER_TYPES, gn as hydrateSchema, gt as alarmPanelCapability, h as EVENT_KIND_BY_CAP, hn as defineCustomActions, ht as addonWidgetsSourceCapability, i as BaseDevice, in as systemEventFilterApplies, it as RetrainStatusSchema, j as NC_SENSOR_TRANSITION_KINDS, jn as EventCategory, k as NC_CONSUMABLE_SYSTEM_EVENT_KINDS, kn as string, kt as faceGalleryCapability, l as DEFAULT_EVENT_COLOR, ln as canonicalHash, lt as SceneMonitorSchema, m as DeclaredDevices, mn as customAction, mt as TrackSourceSchema, n as AlarmArmModeSchema, nn as subKindsOf, nt as RECORDING_EXPORT_MAX_READ_BYTES, o as CLUSTER_MODEL_SCOPED_STEPS, on as vectorDimFromBase64, ot as SCENE_DEFAULT_ANCHOR_THRESHOLD, pn as createEvent, pt as TimelapseRuleSchema, q as NcSnoozeSchema, qt as readDeviceStateFrom, r as ArchivedDebugNoteSchema, rn as systemEventFamilyProblems, rt as RECORDING_SOURCE_CAMSTACK_ADDON, s as COCO_TO_MACRO, sn as zoneAnalyticsCapability, st as SCENE_DEFAULT_UNCOVERED_POLICY, t as AUDIO_MACRO_LABELS, tn as storageOccupancyCapability, un as BaseAddon, ut as TIMELAPSE_DENSE_FLOOR_SEC, v as FULL_IMAGE_BBOX, vn as nodePin, vt as audioMetricsCapability, wn as literal, wt as describeSystemEventFamilyProblem, x as LockStateSchema, xn as array, xt as consumableLowPercentOf, y as FailureCounters, yn as sleep$1, yt as audioModeOf, z as NcConditionsSchema, zt as kebabToCamel } from "../dist-C770IfQJ.mjs";
1
+ import { $ as NcTemplatePreviewSchema, A as NC_DEFAULT_SNOOZE_MINUTES, An as literal, At as evictionPolicyOfLocation, B as NcConditionsSchema, Bt as isSystemDelivery, C as MAX_BIRTH_DECISION_RECORDS, Cn as isDeviceScopedCap, Ct as cosineSimilarity$1, D as NC_ALARM_SYSTEM_EVENT_KINDS, Dn as array, E as MediaFileKindEnum, En as _enum, Et as describeSystemEventFamilyProblem, F as NC_TAXONOMY, Fn as string, Ft as isEventOwnerType, G as NcRuleUpdateInputSchema, Gt as occupancyScope, H as NcRulePatchSchema, Ht as kebabToCamel, I as NC_TEMPLATE_VARS, In as unknown, It as isKnownTemplateVar, J as NcSnoozeSchema, Jt as pipelineAnalyticsCapability, K as NcScheduleSchema, L as NcAlarmConfigSchema, Ln as EventCategory, Lt as isMediaOwnerType, Mn as object, Mt as failureContributionCapability, N as NC_SYSTEM_EVENT_KIND_GROUP_SPECS, Nn as partialRecord, O as NC_CONDITION_CATALOG, On as boolean, Ot as encodeVectorBase64, P as NC_TANK_SYSTEM_EVENT_KINDS, Pn as record, Pt as isDetectionMacroClass, Q as NcTemplatePreviewInputSchema, Qt as resolveLocationMode, R as NcAlarmSettingsPatchSchema, Rt as isScheduleActive, S as MAX_ARCHIVED_DEBUG_NOTES, Sn as hydrateSchema, St as consumableLowPercentOf, T as MOTION_CLOSE_AFTER_MS, Tn as sleep$1, Tt as describeSystemEventDeliveryProblem, U as NcRuleSchema, Ut as mayWriteToLocation, V as NcRuleInputSchema, Vt as isSystemEventSummaryRule, W as NcRuleTargetSchema, Wt as notificationRulesCapability, X as NcSystemEventKindSchema, Xt as readDeviceStateFrom, Y as NcSnoozeSuppressedSchema, Yt as plateGalleryCapability, Z as NcTaxonomySchema, Zt as readTimelapseGeneratedAt, _ as FIRST_LEVEL_MACRO_CLASSES, _n as CamProfileSchema, _t as alarmPanelCapability, a as BirthDecisionRecordSchema, an as subKindsOf, at as RetrainStatusSchema, b as LabelAttributionSchema, bn as customAction, bt as audioModeOf, c as CoverStateSchema, cn as systemEventFilterApplies, ct as SCENE_DEFAULT_UNCOVERED_POLICY, dn as templateVarsFor, dt as TIMELAPSE_DENSE_FLOOR_SEC, en as ruleBelongsToAlarm$1, et as NcTemplateVarDescriptorSchema, f as DETECTION_MACRO_CLASSES, fn as vectorDimFromBase64, ft as TimelapseRuleInputSchema, g as EVENT_OWNER_TYPES, gn as BaseAddon, gt as addonWidgetsSourceCapability, h as EVENT_KIND_BY_CAP, hn as canonicalHash, ht as TrackSourceSchema, i as BaseDevice, in as storageOccupancyCapability, it as RECORDING_SOURCE_CAMSTACK_ADDON, j as NC_SENSOR_TRANSITION_KINDS, jn as number, jt as faceGalleryCapability, k as NC_CONSUMABLE_SYSTEM_EVENT_KINDS, kn as discriminatedUnion, kt as evaluateSensorEdge, l as DEFAULT_EVENT_COLOR, ln as systemEventSummaryScheduleOf, lt as SCENE_DIVERGED, m as DeclaredDevices, mn as errMsg$1, mt as TimelapseRuleSchema, n as AlarmArmModeSchema, nn as sensorRaisedOf, o as CLUSTER_MODEL_SCOPED_STEPS, on as systemEventDeliveryProblems, pn as zoneAnalyticsCapability, pt as TimelapseRulePatchSchema, q as NcSnoozeInputSchema, qt as pickClusterStepModels, r as ArchivedDebugNoteSchema, rn as servedTemplateVars, rt as RECORDING_EXPORT_MAX_READ_BYTES, s as COCO_TO_MACRO, sn as systemEventFamilyProblems, st as SCENE_DEFAULT_ANCHOR_THRESHOLD, t as AUDIO_MACRO_LABELS, tn as sceneMonitorCapability, tt as OpsLogEntrySchema, un as templateCarriesSummary, ut as SceneMonitorSchema, v as FULL_IMAGE_BBOX, vn as DeviceType, vt as assertTimelapseCadences, wn as nodePin, wt as deriveRecordingMode, x as LockStateSchema, xn as defineCustomActions, xt as buildEventKindDescriptor, y as FailureCounters, yn as createEvent, yt as audioMetricsCapability, z as NcConditionDescriptorSchema, zt as isSourceCap } from "../dist-D2tXUMfE.mjs";
2
2
  import { t as __exportAll } from "../embedding-encoder/index.mjs";
3
3
  import * as fs from "node:fs";
4
4
  import { promises } from "node:fs";
@@ -4097,7 +4097,7 @@ function mimeFromExtension(file) {
4097
4097
  * A value that is negative, NaN or absent falls back to the shipped default:
4098
4098
  * an unreadable window must not become a window nobody chose.
4099
4099
  */
4100
- var DAY_MS$2 = 24 * 36e5;
4100
+ var DAY_MS$4 = 24 * 36e5;
4101
4101
  /**
4102
4102
  * The shipped windows. Each one is the value that was hardcoded before this
4103
4103
  * module existed, except the mosaic — see {@link PRUNE_SETTING_KEYS}.
@@ -4143,8 +4143,8 @@ var PRUNE_SETTING_KEYS = [
4143
4143
  */
4144
4144
  function windowMs(raw, defaultDays) {
4145
4145
  if (raw === 0) return null;
4146
- if (typeof raw !== "number" || !Number.isFinite(raw) || raw < 0) return defaultDays * DAY_MS$2;
4147
- return raw * DAY_MS$2;
4146
+ if (typeof raw !== "number" || !Number.isFinite(raw) || raw < 0) return defaultDays * DAY_MS$4;
4147
+ return raw * DAY_MS$4;
4148
4148
  }
4149
4149
  function resolvePruneWindows(settings) {
4150
4150
  return {
@@ -8255,6 +8255,19 @@ var NcAlarmLiveReadLog = class {
8255
8255
  };
8256
8256
  //#endregion
8257
8257
  //#region src/notification-center/alerts/alert-episode.ts
8258
+ /** The row with `detail` set (or removed). Unchanged rows are returned as is. */
8259
+ function withAlertDetail(row, detail) {
8260
+ if (row.detail === detail) return row;
8261
+ const { subject, alerting, since, lastNotifiedAt, reminders } = row;
8262
+ return {
8263
+ subject,
8264
+ alerting,
8265
+ since,
8266
+ lastNotifiedAt,
8267
+ reminders,
8268
+ ...detail !== void 0 ? { detail } : {}
8269
+ };
8270
+ }
8258
8271
  function decideAlert(input) {
8259
8272
  const previous = input.previous;
8260
8273
  if (previous === void 0) return input.alerting ? { kind: "raise" } : { kind: "seeded" };
@@ -8398,6 +8411,12 @@ var NC_ALERTS_COLUMNS = [
8398
8411
  {
8399
8412
  name: "deviceId",
8400
8413
  type: "INTEGER"
8414
+ }),
8415
+ (
8416
+ /** What the open episode is — a tank's band (D647 review M6). Nullable, added in place. */
8417
+ {
8418
+ name: "detail",
8419
+ type: "TEXT"
8401
8420
  })
8402
8421
  ];
8403
8422
  var NC_ALERTS_INDEXES = [{
@@ -8420,7 +8439,8 @@ var NC_ALERTS_SPEC = {
8420
8439
  since: row.since,
8421
8440
  lastNotifiedAt: row.lastNotifiedAt,
8422
8441
  reminders: row.reminders,
8423
- ...deviceIdOf(row.subject) !== void 0 ? { deviceId: deviceIdOf(row.subject) } : {}
8442
+ ...deviceIdOf(row.subject) !== void 0 ? { deviceId: deviceIdOf(row.subject) } : {},
8443
+ ...row.detail !== void 0 ? { detail: row.detail } : {}
8424
8444
  }),
8425
8445
  fromRecord: (key, data) => {
8426
8446
  const since = data["since"];
@@ -8429,12 +8449,14 @@ var NC_ALERTS_SPEC = {
8429
8449
  if (typeof since !== "number") return null;
8430
8450
  if (typeof lastNotifiedAt !== "number") return null;
8431
8451
  if (typeof reminders !== "number") return null;
8452
+ const detail = data["detail"];
8432
8453
  return {
8433
8454
  subject: key,
8434
8455
  alerting: data["alerting"] === 1 || data["alerting"] === true,
8435
8456
  since,
8436
8457
  lastNotifiedAt,
8437
- reminders
8458
+ reminders,
8459
+ ...typeof detail === "string" ? { detail } : {}
8438
8460
  };
8439
8461
  },
8440
8462
  equalFact: (held, incoming) => held.alerting === incoming.alerting,
@@ -8467,7 +8489,8 @@ var NcAlertStore = class {
8467
8489
  this.ledger.put(row).catch(() => void 0);
8468
8490
  },
8469
8491
  all: () => this.ledger.snapshot(),
8470
- remove: (subject) => this.ledger.forget(subject)
8492
+ remove: (subject) => this.ledger.forget(subject),
8493
+ answered: () => this.ledger.hasAnswered()
8471
8494
  };
8472
8495
  }
8473
8496
  };
@@ -11915,6 +11938,80 @@ function vacuumTanksOf(data) {
11915
11938
  };
11916
11939
  }
11917
11940
  //#endregion
11941
+ //#region src/notification-center/alerts/open-alert.ts
11942
+ /**
11943
+ * The raise of an episode whose reading has not arrived since the restart —
11944
+ * worded with what the row KNOWS: no invented percentage (the binary battery
11945
+ * sentence), the item key as its name, and a tank kind that only the filters
11946
+ * read (the summary words an unmeasured tank without a band).
11947
+ */
11948
+ function unmeasuredEpisode(input) {
11949
+ const deviceId = alertSubjectDeviceId(input.subject);
11950
+ if (deviceId === void 0) return null;
11951
+ const identity = input.lookup(deviceId);
11952
+ const facts = input.facts;
11953
+ if (input.family === "battery") return incomingFromBatteryBand({
11954
+ facts,
11955
+ deviceId,
11956
+ band: "low",
11957
+ reading: {
11958
+ percentage: 0,
11959
+ lastUpdated: 0,
11960
+ binary: true
11961
+ },
11962
+ identity
11963
+ });
11964
+ const parts = parseConsumableSubject(input.subject);
11965
+ if (input.family === "consumable" && parts !== null) return incomingFromConsumableBand({
11966
+ facts,
11967
+ deviceId,
11968
+ band: "low",
11969
+ item: {
11970
+ key: parts.key,
11971
+ label: parts.key,
11972
+ level: null,
11973
+ status: "replace"
11974
+ },
11975
+ thresholdPercent: parts.thresholdPercent,
11976
+ identity
11977
+ });
11978
+ const tank = NC_TANK_KEYS.find((key) => input.subject === `tank:${String(deviceId)}:${key}`);
11979
+ if (tank === void 0) return null;
11980
+ return incomingFromTankBand({
11981
+ facts,
11982
+ deviceId,
11983
+ band: input.detail === "full" ? "full" : "low",
11984
+ tank,
11985
+ identity
11986
+ });
11987
+ }
11988
+ /**
11989
+ * One alerting row as an open episode, or `null` when it cannot be worded (the
11990
+ * caller logs it). A legacy consumable row is skipped: it is adopted or
11991
+ * retired first. A tank whose band the row remembers is worded by it
11992
+ * (review M6), even without a reading.
11993
+ */
11994
+ function openAlertOf(row, input) {
11995
+ const parts = parseConsumableSubject(row.subject);
11996
+ if (parts?.legacy === true) return null;
11997
+ const family = parts !== null ? "consumable" : row.subject.startsWith("tank:") ? "tank" : "battery";
11998
+ const subject = (input.restated ?? unmeasuredEpisode({
11999
+ subject: row.subject,
12000
+ family,
12001
+ facts: input.facts,
12002
+ lookup: input.lookup,
12003
+ ...row.detail !== void 0 ? { detail: row.detail } : {}
12004
+ }))?.subject.systemEvent;
12005
+ if (subject === void 0) return null;
12006
+ const measured = input.restated !== null || family === "tank" && row.detail !== void 0;
12007
+ return {
12008
+ family,
12009
+ since: row.since,
12010
+ subject,
12011
+ measured
12012
+ };
12013
+ }
12014
+ //#endregion
11918
12015
  //#region src/notification-center/alerts/battery-band.ts
11919
12016
  /**
11920
12017
  * The stand-in a provider writes for a binary NORMAL. Anything below it, on a
@@ -12122,7 +12219,7 @@ var NcDeviceAlerts = class {
12122
12219
  band: alerting ? band : "normal",
12123
12220
  tank: tank.key,
12124
12221
  identity: this.deps.lookup(deviceId)
12125
- }));
12222
+ }), band === "normal" ? void 0 : band);
12126
12223
  }
12127
12224
  /**
12128
12225
  * Has this tank band held long enough to move its episode? A band that
@@ -12240,6 +12337,37 @@ var NcDeviceAlerts = class {
12240
12337
  }
12241
12338
  }
12242
12339
  /**
12340
+ * Every OPEN episode, restated (D647). `null` while the rows have not been
12341
+ * read. A legacy consumable row is skipped (it is adopted or retired first),
12342
+ * and so is a tank whose band is unknown and whose device the summary could
12343
+ * not word — never silently: each skip is a line.
12344
+ */
12345
+ openEpisodes() {
12346
+ if (this.deps.rows.answered?.() === false) return null;
12347
+ const facts = {
12348
+ eventId: "state-summary",
12349
+ timestamp: this.now(),
12350
+ category: "state-summary",
12351
+ data: {}
12352
+ };
12353
+ const out = [];
12354
+ for (const row of this.deps.rows.all()) {
12355
+ if (!row.alerting) continue;
12356
+ const restated = this.restatementOf(row.subject);
12357
+ const open = openAlertOf(row, {
12358
+ facts,
12359
+ restated: restated === null ? null : restated(facts),
12360
+ lookup: this.deps.lookup
12361
+ });
12362
+ if (open !== null) out.push(open);
12363
+ else this.logger()?.debug("open alert left out of the state summary — it cannot be worded", {
12364
+ ...tagsOf(row.subject),
12365
+ meta: { subject: row.subject }
12366
+ });
12367
+ }
12368
+ return out;
12369
+ }
12370
+ /**
12243
12371
  * Every device that holds an alert row — what the deleted-device reconcile
12244
12372
  * must look at besides the devices a rule names (a battery device no rule
12245
12373
  * names still has a row).
@@ -12381,7 +12509,7 @@ var NcDeviceAlerts = class {
12381
12509
  * a state we never recorded leaving is a state we can never announce
12382
12510
  * entering again.
12383
12511
  */
12384
- apply(subject, alerting, settings, build) {
12512
+ apply(subject, alerting, settings, build, detail) {
12385
12513
  const nowMs = this.now();
12386
12514
  const previous = this.deps.rows.get(subject);
12387
12515
  const verdict = decideAlert({
@@ -12391,7 +12519,7 @@ var NcDeviceAlerts = class {
12391
12519
  renotifyMs: settings.renotifyMinutes * 6e4,
12392
12520
  notifyRecovery: settings.notifyRecovery
12393
12521
  });
12394
- const next = nextAlertRow(subject, previous, alerting, verdict, nowMs);
12522
+ const next = withAlertDetail(nextAlertRow(subject, previous, alerting, verdict, nowMs), alerting ? detail ?? previous?.detail : void 0);
12395
12523
  if (next !== previous) this.deps.rows.put(next);
12396
12524
  if (!speaks(verdict)) {
12397
12525
  this.report(subject, verdict, alerting);
@@ -13026,7 +13154,31 @@ var en_default = {
13026
13154
  },
13027
13155
  "summary.body.detections": "{{detectionSummary}}",
13028
13156
  "summary.body.truncated": "showing the {{shown}} most relevant of {{total}}",
13029
- "summary.body.empty": "nothing was seen in this window"
13157
+ "summary.body.empty": "nothing was seen in this window",
13158
+ "system.state-summary.title": "{{rule}}",
13159
+ "system.state-summary.body": "{{summaryText}}",
13160
+ "summary.section.device-state": "Devices offline or switched off",
13161
+ "summary.section.streams": "Streams offline",
13162
+ "summary.section.battery": "Low batteries",
13163
+ "summary.section.consumables": "Consumables to replace",
13164
+ "summary.section.tanks": "Tanks",
13165
+ "summary.section.detection-blind": "Cameras detecting nothing",
13166
+ "summary.section.nodes": "Nodes",
13167
+ "summary.section.addons": "Addons",
13168
+ "summary.section.updates": "Server and container updates",
13169
+ "summary.section.unavailable": "{{section}}: unavailable — the state could not be read",
13170
+ "summary.line.device-offline": "{{deviceName}} — offline (since {{since}})",
13171
+ "summary.line.device-disabled": "{{deviceName}} — switched off (since {{since}})",
13172
+ "summary.line.stream-offline": "{{deviceName}} — stream offline (since {{since}})",
13173
+ "summary.line.stream-offline.profiled": "{{deviceName}} — {{profile}} stream offline (since {{since}})",
13174
+ "summary.line.detection-blind": "{{deviceName}} — detecting nothing (since {{since}})",
13175
+ "summary.line.node-offline": "{{nodeId}} — offline (since {{since}})",
13176
+ "summary.line.node-inference-unavailable": "{{nodeId}} — cannot run inference (since {{since}})",
13177
+ "summary.line.addon-crash-loop": "{{nodeId}} — {{addons}} stopped after repeated crashes (since {{since}})",
13178
+ "summary.line.update": "{{packageName}} {{currentVersion}} → {{latestVersion}}",
13179
+ "summary.line.update.onNode": "{{packageName}} {{currentVersion}} → {{latestVersion}} on {{nodeId}}",
13180
+ "summary.line.wrapper-update": "Container {{currentVersion}} → {{latestVersion}}",
13181
+ "summary.line.tank-unread": "{{deviceName}} — the {{tank}} tank needs attention"
13030
13182
  };
13031
13183
  //#endregion
13032
13184
  //#region src/notification-center/texts/it.json
@@ -13330,7 +13482,31 @@ var it_default = {
13330
13482
  },
13331
13483
  "summary.body.detections": "{{detectionSummary}}",
13332
13484
  "summary.body.truncated": "mostrati i {{shown}} più rilevanti su {{total}}",
13333
- "summary.body.empty": "nessun evento in questa finestra"
13485
+ "summary.body.empty": "nessun evento in questa finestra",
13486
+ "system.state-summary.title": "{{rule}}",
13487
+ "system.state-summary.body": "{{summaryText}}",
13488
+ "summary.section.device-state": "Dispositivi offline o spenti",
13489
+ "summary.section.streams": "Stream offline",
13490
+ "summary.section.battery": "Batterie scariche",
13491
+ "summary.section.consumables": "Consumabili da sostituire",
13492
+ "summary.section.tanks": "Serbatoi",
13493
+ "summary.section.detection-blind": "Camere che non rilevano nulla",
13494
+ "summary.section.nodes": "Nodi",
13495
+ "summary.section.addons": "Addon",
13496
+ "summary.section.updates": "Aggiornamenti di server e container",
13497
+ "summary.section.unavailable": "{{section}}: non disponibile — lo stato non è leggibile",
13498
+ "summary.line.device-offline": "{{deviceName}} — offline (da {{since}})",
13499
+ "summary.line.device-disabled": "{{deviceName}} — spento (da {{since}})",
13500
+ "summary.line.stream-offline": "{{deviceName}} — stream offline (da {{since}})",
13501
+ "summary.line.stream-offline.profiled": "{{deviceName}} — stream {{profile}} offline (da {{since}})",
13502
+ "summary.line.detection-blind": "{{deviceName}} — non rileva nulla (da {{since}})",
13503
+ "summary.line.node-offline": "{{nodeId}} — offline (da {{since}})",
13504
+ "summary.line.node-inference-unavailable": "{{nodeId}} — non può eseguire l'inferenza (da {{since}})",
13505
+ "summary.line.addon-crash-loop": "{{nodeId}} — {{addons}} fermati dopo crash ripetuti (da {{since}})",
13506
+ "summary.line.update": "{{packageName}} {{currentVersion}} → {{latestVersion}}",
13507
+ "summary.line.update.onNode": "{{packageName}} {{currentVersion}} → {{latestVersion}} su {{nodeId}}",
13508
+ "summary.line.wrapper-update": "Container {{currentVersion}} → {{latestVersion}}",
13509
+ "summary.line.tank-unread": "{{deviceName}} — il serbatoio {{tank}} richiede attenzione"
13334
13510
  };
13335
13511
  //#endregion
13336
13512
  //#region src/notification-center/text-catalog.ts
@@ -13748,7 +13924,7 @@ function assertPatchKeepsFamily(existing, patch) {
13748
13924
  * the offending kind or filter). Then rethrow. A refused save is dropped work,
13749
13925
  * and dropped work is never silent. Any other error passes through unlogged.
13750
13926
  */
13751
- function logRefusal$2(check, ctx) {
13927
+ function logRefusal$3(check, ctx) {
13752
13928
  try {
13753
13929
  check();
13754
13930
  } catch (err) {
@@ -13764,11 +13940,11 @@ function logRefusal$2(check, ctx) {
13764
13940
  }
13765
13941
  /** {@link assertRuleFamily}, logged. The create path of both routes. */
13766
13942
  function guardRuleFamily(rule, ctx) {
13767
- logRefusal$2(() => assertRuleFamily(rule), ctx);
13943
+ logRefusal$3(() => assertRuleFamily(rule), ctx);
13768
13944
  }
13769
13945
  /** {@link assertPatchKeepsFamily}, logged. The update path of both routes. */
13770
13946
  function guardPatchFamily(existing, patch, ctx) {
13771
- logRefusal$2(() => assertPatchKeepsFamily(existing, patch), ctx);
13947
+ logRefusal$3(() => assertPatchKeepsFamily(existing, patch), ctx);
13772
13948
  }
13773
13949
  //#endregion
13774
13950
  //#region src/notification-center/device-scope-guard.ts
@@ -13793,7 +13969,7 @@ function assertDevicesExcludeDelivery(rule) {
13793
13969
  throw new NcDevicesExcludeRefusal(rule.delivery, excluded);
13794
13970
  }
13795
13971
  /** Run the check; a refusal is dropped work, so it is logged before it is rethrown. */
13796
- function logRefusal$1(check, ctx) {
13972
+ function logRefusal$2(check, ctx) {
13797
13973
  try {
13798
13974
  check();
13799
13975
  } catch (err) {
@@ -13811,7 +13987,7 @@ function warnRefusal$1(logger, err, ctx) {
13811
13987
  }
13812
13988
  /** The create path of both routes. */
13813
13989
  function guardRuleDevicesExclude(rule, ctx) {
13814
- logRefusal$1(() => assertDevicesExcludeDelivery(rule), ctx);
13990
+ logRefusal$2(() => assertDevicesExcludeDelivery(rule), ctx);
13815
13991
  }
13816
13992
  /**
13817
13993
  * The update path of both routes: the MERGED rule is judged, and only when the
@@ -13821,7 +13997,86 @@ function guardPatchDevicesExclude(existing, patch, ctx) {
13821
13997
  if (existing === null) return;
13822
13998
  const merged = mergedFamilySubject(existing, patch);
13823
13999
  if (merged === null) return;
13824
- logRefusal$1(() => assertDevicesExcludeDelivery(merged), ctx);
14000
+ logRefusal$2(() => assertDevicesExcludeDelivery(merged), ctx);
14001
+ }
14002
+ //#endregion
14003
+ //#region src/notification-center/rule-summary-guard.ts
14004
+ /**
14005
+ * The hub's half of D647: `systemEventDelivery: { mode: 'summary' }` belongs to
14006
+ * a `device-events` or `system` rule, and nowhere else — the alarm stays
14007
+ * instant. Refused, loudly, on both save routes (the cap provider and the
14008
+ * `nc.*Rule` bridge), beside the D637 family guard.
14009
+ *
14010
+ * The rule is judged by `systemEventDeliveryProblems` (`@camstack/types`), the
14011
+ * one statement both editors read too. On an update it is the MERGED rule —
14012
+ * merged the way the store merges it, `clear` included — and only when the
14013
+ * patch changes something the verdict reads: a rename of a stored rule is
14014
+ * never refused for what it already was.
14015
+ *
14016
+ * Not in Zod, for the reason `rule-family-guard.ts` gives: a cross-field refine
14017
+ * would also run when the store LOADS a rule.
14018
+ */
14019
+ var NcSystemEventDeliveryRefusal = class extends Error {
14020
+ problems;
14021
+ constructor(problems) {
14022
+ super(`rule refused: ${problems.map(describeSystemEventDeliveryProblem).join("; ")}`);
14023
+ this.name = "NcSystemEventDeliveryRefusal";
14024
+ this.problems = problems;
14025
+ }
14026
+ };
14027
+ function assertSystemEventDelivery(rule) {
14028
+ const problems = systemEventDeliveryProblems(rule);
14029
+ if (problems.length > 0) throw new NcSystemEventDeliveryRefusal(problems);
14030
+ }
14031
+ var JUDGED_KEYS = [
14032
+ "delivery",
14033
+ "conditions",
14034
+ "systemEventDelivery",
14035
+ "schedule",
14036
+ "actions"
14037
+ ];
14038
+ /**
14039
+ * The rule the patch WOULD produce, or `null` when it changes nothing the
14040
+ * verdict reads. `clear` removes `schedule` / `actions` before the merge, as
14041
+ * the store does.
14042
+ */
14043
+ function mergedDeliverySubject(existing, patch, clear) {
14044
+ const merged = {
14045
+ delivery: existing.delivery,
14046
+ conditions: existing.conditions,
14047
+ ...existing.systemEventDelivery !== void 0 ? { systemEventDelivery: existing.systemEventDelivery } : {},
14048
+ ...existing.schedule !== void 0 && !clear.includes("schedule") ? { schedule: existing.schedule } : {},
14049
+ ...existing.actions !== void 0 && !clear.includes("actions") ? { actions: existing.actions } : {},
14050
+ ...patch.delivery !== void 0 ? { delivery: patch.delivery } : {},
14051
+ ...patch.conditions !== void 0 ? { conditions: patch.conditions } : {},
14052
+ ...patch.systemEventDelivery !== void 0 ? { systemEventDelivery: patch.systemEventDelivery } : {},
14053
+ ...patch.schedule !== void 0 ? { schedule: patch.schedule } : {},
14054
+ ...patch.actions !== void 0 ? { actions: patch.actions } : {}
14055
+ };
14056
+ return JUDGED_KEYS.some((key) => canonicalHash(merged[key] ?? null) !== canonicalHash(existing[key] ?? null)) ? merged : null;
14057
+ }
14058
+ function logRefusal$1(check, ctx) {
14059
+ try {
14060
+ check();
14061
+ } catch (err) {
14062
+ if (err instanceof NcSystemEventDeliveryRefusal) ctx.logger.warn("nc rule refused: summary delivery not allowed here", { meta: {
14063
+ ...ctx.ruleId !== void 0 ? { ruleId: ctx.ruleId } : {},
14064
+ by: ctx.by,
14065
+ problems: err.problems.map(describeSystemEventDeliveryProblem)
14066
+ } });
14067
+ throw err;
14068
+ }
14069
+ }
14070
+ /** The create path of both routes. */
14071
+ function guardRuleSystemEventDelivery(rule, ctx) {
14072
+ logRefusal$1(() => assertSystemEventDelivery(rule), ctx);
14073
+ }
14074
+ /** The update path of both routes: the MERGED rule, when the patch changes what is judged. */
14075
+ function guardPatchSystemEventDelivery(existing, patch, clear, ctx) {
14076
+ if (existing === null) return;
14077
+ const merged = mergedDeliverySubject(existing, patch, clear);
14078
+ if (merged === null) return;
14079
+ logRefusal$1(() => assertSystemEventDelivery(merged), ctx);
13825
14080
  }
13826
14081
  //#endregion
13827
14082
  //#region src/notification-center/sensor-transition-guard.ts
@@ -16614,6 +16869,10 @@ function makeNcActionHandlers(deps) {
16614
16869
  logger: deps.logger,
16615
16870
  by: c.userId
16616
16871
  });
16872
+ guardRuleSystemEventDelivery(parsed, {
16873
+ logger: deps.logger,
16874
+ by: c.userId
16875
+ });
16617
16876
  await assertTargetsOwned(parsed.targets.map((t) => t.targetId), c);
16618
16877
  const rule = await deps.ruleStore.create({
16619
16878
  ...parsed,
@@ -16647,6 +16906,11 @@ function makeNcActionHandlers(deps) {
16647
16906
  by: c.userId,
16648
16907
  ruleId: input.ruleId
16649
16908
  });
16909
+ guardPatchSystemEventDelivery(existing, patch, clear, {
16910
+ logger: deps.logger,
16911
+ by: c.userId,
16912
+ ruleId: input.ruleId
16913
+ });
16650
16914
  if (patch.targets !== void 0) await assertTargetsOwned(patch.targets.map((t) => t.targetId), c);
16651
16915
  const { ownerUserId: _owner, disabledTargetIds: _optOut, ...safe } = patch;
16652
16916
  const rule = await deps.ruleStore.update(input.ruleId, safe, clear);
@@ -17072,6 +17336,87 @@ function makeNcActionHandlers(deps) {
17072
17336
  };
17073
17337
  }
17074
17338
  //#endregion
17339
+ //#region src/notification-center/state-summary/state-summary-delivery.ts
17340
+ /**
17341
+ * A state summary as an ORDINARY system-event subject (D647), so it reaches the
17342
+ * phone the way every system rule does: `buildEntries` → the durable outbox →
17343
+ * the drain's snooze gate, retries and per-target history → the dispatcher,
17344
+ * which renders the rule's own template with the summary's variables.
17345
+ *
17346
+ * - `recordId` is `state-summary:<ruleId>:<windowEndMs>` — the outbox dedup
17347
+ * key, so a restart can never enqueue one window twice.
17348
+ * - The row files under the system device (0): a summary spans devices.
17349
+ * `targetUsers` therefore reach admins only — a user whose grant is
17350
+ * narrower than the whole installation must not be sent the names of
17351
+ * devices they cannot open.
17352
+ * - The words are FROZEN at enqueue (`textParams`), like the digest's: the
17353
+ * state is the state at the window's end, not at a retry hours later. The
17354
+ * LANGUAGE is frozen with them.
17355
+ * - `kind` is the rule's first kind: the history row needs one, and no kind
17356
+ * is "the" event of a summary — `data.stateSummary` says what the row is,
17357
+ * and `{{eventKind}}` renders empty on it.
17358
+ */
17359
+ var NC_STATE_SUMMARY_TITLE_KEY = "system.state-summary.title";
17360
+ var NC_STATE_SUMMARY_BODY_KEY = "system.state-summary.body";
17361
+ function stateSummaryRecordId(ruleId, windowEndMs) {
17362
+ return `state-summary:${ruleId}:${String(windowEndMs)}`;
17363
+ }
17364
+ /** Is this system-event data the data of a state-summary row? */
17365
+ function isStateSummaryData(data) {
17366
+ return data["stateSummary"] === true;
17367
+ }
17368
+ /** `null` when the rule has no system-event condition (the guard refuses that). */
17369
+ function stateSummarySubject(input) {
17370
+ const kind = input.rule.conditions.systemEvent?.kinds[0];
17371
+ if (kind === void 0) return null;
17372
+ return {
17373
+ kind: "system-event",
17374
+ recordId: stateSummaryRecordId(input.rule.id, input.window.endMs),
17375
+ deviceId: 0,
17376
+ timestamp: input.window.endMs,
17377
+ classNames: [],
17378
+ zones: [],
17379
+ systemEvent: {
17380
+ kind,
17381
+ subject: `state-summary:${input.rule.id}`,
17382
+ titleKey: NC_STATE_SUMMARY_TITLE_KEY,
17383
+ bodyKey: NC_STATE_SUMMARY_BODY_KEY,
17384
+ textParams: {
17385
+ summaryText: input.text.text,
17386
+ summaryCount: String(input.text.count),
17387
+ ...input.text.sectionVars
17388
+ },
17389
+ textCount: input.text.count,
17390
+ data: {
17391
+ stateSummary: true,
17392
+ windowStartMs: input.window.startMs,
17393
+ windowEndMs: input.window.endMs,
17394
+ sections: input.summary.sections.map((section) => ({
17395
+ id: section.id,
17396
+ status: section.status,
17397
+ items: section.items.length
17398
+ }))
17399
+ }
17400
+ }
17401
+ };
17402
+ }
17403
+ /**
17404
+ * The rule as its summary is ENQUEUED: a custom body that carries none of the
17405
+ * summary's content gets `{{summaryText}}` appended on its own line, so an
17406
+ * operator's body never sends an empty summary (review M3). `null` = unchanged.
17407
+ */
17408
+ function withSummaryBody(rule) {
17409
+ const body = rule.template?.body;
17410
+ if (templateCarriesSummary(body)) return null;
17411
+ return {
17412
+ ...rule,
17413
+ template: {
17414
+ ...rule.template,
17415
+ body: `${body ?? ""}\n{{summaryText}}`
17416
+ }
17417
+ };
17418
+ }
17419
+ //#endregion
17075
17420
  //#region src/notification-center/system-event-tap-url.ts
17076
17421
  /** The admin UI's own routes, as `packages/addon-admin-ui/src/App.tsx` declares them. */
17077
17422
  var ADMIN_ADDONS_PATH = "/system/addons";
@@ -17498,6 +17843,32 @@ function selectTriggerZoneCandidates(input) {
17498
17843
  //#endregion
17499
17844
  //#region src/notification-center/dispatcher.ts
17500
17845
  /**
17846
+ * NcDispatcher — turns a durable outbox entry into ONE canonical
17847
+ * `notification-output.send` RPC.
17848
+ *
17849
+ * Deps are injected tRPC-shaped closures (the wiring in
17850
+ * pipeline-analytics binds them to `ctx.api` / the media store), so the
17851
+ * dispatcher is unit-testable with fakes and stays extractable.
17852
+ *
17853
+ * Responsibilities:
17854
+ * - resolve the outbox entry's `targetId` against the live
17855
+ * `notification-output` target catalog (TTL-cached; the send call is
17856
+ * routed to the owning provider by the target's `addonId`);
17857
+ * - resolve the media attachment per the rule's policy from the media
17858
+ * store BY OWNER at SEND time — D-3 "best media AVAILABLE at the
17859
+ * moment": the drain runs after the persist moment, so late-landing
17860
+ * crops are picked up without ever delaying delivery;
17861
+ * - render the `{{var}}` template (title/body) over the frozen subject;
17862
+ * - map recognized per-target passthrough params onto canonical
17863
+ * Notification fields (level/sound/clickUrl/ttl) — everything else is
17864
+ * the degrade engine's job.
17865
+ *
17866
+ * Failure semantics: an UNKNOWN target (no longer in the catalog) ⇒ PERMANENT
17867
+ * failure (the outbox dead-letters the row — genuine misconfig); a globally
17868
+ * DISABLED target (operator kill-switch) ⇒ benign skip (no send, no dead row);
17869
+ * transport/backend errors ⇒ transient (retried with backoff).
17870
+ */
17871
+ /**
17501
17872
  * Where the model's sentence goes.
17502
17873
  *
17503
17874
  * Three cases, and the third is the one that would otherwise be reported as a
@@ -19065,7 +19436,7 @@ function buildTemplateVars(texts, entry, deviceName, zoneLabels, timeZone) {
19065
19436
  count: occupancy !== void 0 ? `${occupancy.count}` : group !== void 0 && group.members.length > 1 ? `${group.members.length}` : "",
19066
19437
  capacity: occupancy !== void 0 ? `${occupancy.capacity}` : "",
19067
19438
  op: occupancy !== void 0 ? ncOccupancyOp(texts, occupancy.occupied) : "",
19068
- eventKind: systemEvent?.kind ?? "",
19439
+ eventKind: systemEvent === void 0 || isStateSummaryData(systemEvent.data) ? "" : systemEvent.kind,
19069
19440
  node: systemEvent?.nodeId ?? "",
19070
19441
  package: systemEvent?.packageName ?? "",
19071
19442
  currentVersion: typeof systemEvent?.data["currentVersion"] === "string" ? systemEvent.data["currentVersion"] : "",
@@ -22086,6 +22457,10 @@ var NcLivenessLedger = class {
22086
22457
  async load() {
22087
22458
  return (await this.ledger.load()).length;
22088
22459
  }
22460
+ /** Has a load READ the store once? Until then the mirror is not the truth (D647 reads it). */
22461
+ hasAnswered() {
22462
+ return this.ledger.hasAnswered();
22463
+ }
22089
22464
  /**
22090
22465
  * Boot seed from the DEVICE DIRECTORY — the second half of the reseed, and
22091
22466
  * the fix for the outage this ledger used to swallow.
@@ -28412,6 +28787,1328 @@ var AddonUpdateCoalescer = class {
28412
28787
  }
28413
28788
  };
28414
28789
  //#endregion
28790
+ //#region src/notification-center/state-summary/state-sources.ts
28791
+ /**
28792
+ * Where each section of a STATE SUMMARY reads its state (D647) — the same
28793
+ * mirrors the instant raises read, and nothing else:
28794
+ *
28795
+ * | section | mirror |
28796
+ * | --- | --- |
28797
+ * | battery, consumables, tanks | the open alert episodes (`NcDeviceAlerts`, durable rows) |
28798
+ * | device-state | the liveness ledger (offline) + the system-state ledger (switched off) |
28799
+ * | streams, detection-blind, addons, updates | the system-state ledger |
28800
+ * | nodes | the liveness ledger (offline) + the system-state ledger (cannot infer) |
28801
+ *
28802
+ * Every source is RAM. A source that has not answered (its durable store never
28803
+ * read) makes the section UNAVAILABLE — never empty (D49/D315).
28804
+ *
28805
+ * A device offline for less than the liveness hold-off is left out: the instant
28806
+ * path would not have announced it yet either, and a summary must not report a
28807
+ * blip the rule itself would have stayed silent about.
28808
+ */
28809
+ var UNREAD_ALERTS = "the battery / consumable ledger has not been read yet";
28810
+ var UNREAD_LIVENESS = "the liveness ledger has not been read yet";
28811
+ var UNREAD_STATE = "the system-state ledger has not been read yet";
28812
+ var UNREAD_DEVICES = "the device directory has not answered yet";
28813
+ var UNREAD_NODES = "the node directory did not answer completely";
28814
+ /** Sections whose items name a node: the node directory must have answered. */
28815
+ var NODE_SECTIONS = [
28816
+ "nodes",
28817
+ "addons",
28818
+ "updates"
28819
+ ];
28820
+ function deviceParams(deviceId, identity) {
28821
+ const name = identity?.name ?? "";
28822
+ return {
28823
+ deviceId: String(deviceId),
28824
+ deviceName: name.length > 0 ? name : String(deviceId)
28825
+ };
28826
+ }
28827
+ function alertItem(section, open) {
28828
+ const params = open.subject.textParams ?? {};
28829
+ const unreadTank = open.family === "tank" && !open.measured;
28830
+ return {
28831
+ section,
28832
+ subject: open.subject,
28833
+ lineKey: unreadTank ? "summary.line.tank-unread" : open.subject.bodyKey ?? "",
28834
+ params,
28835
+ since: open.since
28836
+ };
28837
+ }
28838
+ function livenessItem(row, lookup) {
28839
+ if (row.subject.startsWith("node:")) {
28840
+ const nodeId = row.subject.slice(5);
28841
+ return {
28842
+ section: "nodes",
28843
+ subject: {
28844
+ kind: "node-offline",
28845
+ subject: row.subject,
28846
+ nodeId,
28847
+ textParams: { nodeId },
28848
+ data: {}
28849
+ },
28850
+ lineKey: "summary.line.node-offline",
28851
+ params: { nodeId },
28852
+ since: row.updatedAt
28853
+ };
28854
+ }
28855
+ if (row.deviceId === void 0) return null;
28856
+ const identity = lookup(row.deviceId);
28857
+ const params = deviceParams(row.deviceId, identity);
28858
+ return {
28859
+ section: "device-state",
28860
+ subject: {
28861
+ kind: "device-offline",
28862
+ subject: row.subject,
28863
+ deviceId: row.deviceId,
28864
+ ...identity !== void 0 ? { deviceType: identity.type } : {},
28865
+ textParams: params,
28866
+ data: {}
28867
+ },
28868
+ lineKey: "summary.line.device-offline",
28869
+ params,
28870
+ since: row.updatedAt
28871
+ };
28872
+ }
28873
+ /** The section and the line of an open system-state row. */
28874
+ function stateLine(row) {
28875
+ switch (row.kind) {
28876
+ case "device-disabled": return {
28877
+ section: "device-state",
28878
+ lineKey: "summary.line.device-disabled"
28879
+ };
28880
+ case "stream-offline": return {
28881
+ section: "streams",
28882
+ lineKey: row.params["profile"] !== void 0 ? "summary.line.stream-offline.profiled" : "summary.line.stream-offline"
28883
+ };
28884
+ case "detection-blind": return {
28885
+ section: "detection-blind",
28886
+ lineKey: "summary.line.detection-blind"
28887
+ };
28888
+ case "node-inference-unavailable": return {
28889
+ section: "nodes",
28890
+ lineKey: "summary.line.node-inference-unavailable"
28891
+ };
28892
+ case "addon-crash-loop": return {
28893
+ section: "addons",
28894
+ lineKey: "summary.line.addon-crash-loop"
28895
+ };
28896
+ case "wrapper-update-available": return {
28897
+ section: "updates",
28898
+ lineKey: "summary.line.wrapper-update"
28899
+ };
28900
+ default: {
28901
+ const onNode = row.params["nodeId"] !== void 0;
28902
+ return {
28903
+ section: row.kind === "addon-update-available" ? "addons" : "updates",
28904
+ lineKey: onNode ? "summary.line.update.onNode" : "summary.line.update"
28905
+ };
28906
+ }
28907
+ }
28908
+ }
28909
+ function stateItem(row, lookup) {
28910
+ const identity = row.deviceId !== void 0 ? lookup(row.deviceId) : void 0;
28911
+ const params = row.deviceId !== void 0 ? {
28912
+ ...row.params,
28913
+ ...deviceParams(row.deviceId, identity)
28914
+ } : row.params;
28915
+ const deviceType = identity?.type ?? row.deviceType;
28916
+ const subject = {
28917
+ kind: row.kind,
28918
+ subject: row.key,
28919
+ ...row.deviceId !== void 0 ? { deviceId: row.deviceId } : {},
28920
+ ...deviceType !== void 0 ? { deviceType } : {},
28921
+ ...row.nodeId !== void 0 ? { nodeId: row.nodeId } : {},
28922
+ ...row.packageName !== void 0 ? {
28923
+ packageName: row.packageName,
28924
+ packageNames: [row.packageName]
28925
+ } : {},
28926
+ textParams: params,
28927
+ data: {}
28928
+ };
28929
+ return {
28930
+ ...stateLine(row),
28931
+ subject,
28932
+ params,
28933
+ since: row.since
28934
+ };
28935
+ }
28936
+ /**
28937
+ * THE RECONCILE (D647 review I2, D8): a ledger row is a memory, never the
28938
+ * truth. A device item is listed only while the device EXISTS in the directory
28939
+ * and is not an accessory — and an "offline" one only while the device
28940
+ * manager does not call it online. A node item only while the node is known
28941
+ * to a complete node directory (a forgotten node is gone), and an "offline"
28942
+ * one only while the directory does not call it online.
28943
+ */
28944
+ function stillTrue(item, ports) {
28945
+ const deviceId = item.subject.deviceId;
28946
+ if (deviceId !== void 0) {
28947
+ const identity = ports.lookup(deviceId);
28948
+ if (identity === void 0 || isAccessoryIdentity(identity)) return false;
28949
+ if (item.subject.kind === "device-offline" && identity.online === true) return false;
28950
+ }
28951
+ const nodeId = item.subject.nodeId;
28952
+ if (nodeId !== void 0 && ports.nodes !== null) {
28953
+ const online = ports.nodes.online(nodeId);
28954
+ if (online === void 0) return false;
28955
+ if (item.subject.kind === "node-offline" && online) return false;
28956
+ }
28957
+ return true;
28958
+ }
28959
+ /** Why a section cannot be reconciled right now, or `null` when it can. */
28960
+ function unreconcilable(section, ports) {
28961
+ if (NODE_SECTIONS.includes(section) && (ports.nodes === null || !ports.nodes.complete)) return UNREAD_NODES;
28962
+ if (!NODE_SECTIONS.includes(section) && !ports.devicesAnswered()) return UNREAD_DEVICES;
28963
+ return null;
28964
+ }
28965
+ /** The section, read and reconciled. */
28966
+ function readSection(section, ports, nowMs) {
28967
+ const blocked = unreconcilable(section, ports);
28968
+ if (blocked !== null) return {
28969
+ answered: false,
28970
+ reason: blocked
28971
+ };
28972
+ const read = readRaw(section, ports, nowMs);
28973
+ if (!read.answered) return read;
28974
+ return {
28975
+ answered: true,
28976
+ items: read.items.filter((item) => stillTrue(item, ports))
28977
+ };
28978
+ }
28979
+ /** Which ports a section needs, and why each one is unavailable. */
28980
+ function readRaw(section, ports, nowMs) {
28981
+ if (section === "battery" || section === "consumables" || section === "tanks") {
28982
+ const alerts = ports.alerts();
28983
+ if (alerts === null) return {
28984
+ answered: false,
28985
+ reason: UNREAD_ALERTS
28986
+ };
28987
+ const family = section === "battery" ? "battery" : section === "tanks" ? "tank" : "consumable";
28988
+ return {
28989
+ answered: true,
28990
+ items: alerts.filter((open) => open.family === family).map((open) => alertItem(section, open))
28991
+ };
28992
+ }
28993
+ const state = ports.state();
28994
+ if (state === null) return {
28995
+ answered: false,
28996
+ reason: UNREAD_STATE
28997
+ };
28998
+ const items = [];
28999
+ if (section === "device-state" || section === "nodes") {
29000
+ const liveness = ports.liveness();
29001
+ if (liveness === null) return {
29002
+ answered: false,
29003
+ reason: UNREAD_LIVENESS
29004
+ };
29005
+ for (const row of liveness) {
29006
+ if (row.state !== "offline" || nowMs - row.updatedAt < ports.livenessHoldoffMs) continue;
29007
+ const item = livenessItem(row, ports.lookup);
29008
+ if (item !== null && item.section === section) items.push(item);
29009
+ }
29010
+ }
29011
+ for (const row of state) {
29012
+ const item = stateItem(row, ports.lookup);
29013
+ if (item.section === section) items.push(item);
29014
+ }
29015
+ return {
29016
+ answered: true,
29017
+ items
29018
+ };
29019
+ }
29020
+ function stateSources(ports) {
29021
+ return { read: (section, nowMs) => readSection(section, ports, nowMs) };
29022
+ }
29023
+ //#endregion
29024
+ //#region src/notification-center/state-summary/state-model.ts
29025
+ /**
29026
+ * The STATE SUMMARY's vocabulary (D647): its sections, and what one reported
29027
+ * item is.
29028
+ *
29029
+ * A section is one of the D637 kind GROUPS (`NC_SYSTEM_EVENT_KIND_GROUP_SPECS`)
29030
+ * — the same grouping both editors list kinds under — so "the rule selected a
29031
+ * battery kind" and "the summary has a battery section" are the same fact.
29032
+ * `backups-exports` is the one group with no section: a backup or an export is
29033
+ * a job that COMPLETED, and the state it leaves behind is not a condition that
29034
+ * is still true. The ADR records why it is excluded rather than approximated.
29035
+ */
29036
+ var NC_STATE_SUMMARY_SECTION_IDS = [
29037
+ "device-state",
29038
+ "streams",
29039
+ "battery",
29040
+ "consumables",
29041
+ "tanks",
29042
+ "detection-blind",
29043
+ "nodes",
29044
+ "addons",
29045
+ "updates"
29046
+ ];
29047
+ /** The kind groups that have no section, and why — said on the preview. */
29048
+ var NC_STATE_SUMMARY_EXCLUDED_GROUPS = { "backups-exports": "a backup or an export is a completed job, not a state that is still true" };
29049
+ /** The template variable that carries one section's lines (`template-vars.ts`). */
29050
+ var NC_STATE_SECTION_VAR = {
29051
+ "device-state": "summaryDevices",
29052
+ streams: "summaryStreams",
29053
+ battery: "summaryBattery",
29054
+ consumables: "summaryConsumables",
29055
+ tanks: "summaryTanks",
29056
+ "detection-blind": "summaryDetection",
29057
+ nodes: "summaryNodes",
29058
+ addons: "summaryAddons",
29059
+ updates: "summaryUpdates"
29060
+ };
29061
+ /** The kinds of a section — its kind group. */
29062
+ function sectionKinds(section) {
29063
+ return NC_SYSTEM_EVENT_KIND_GROUP_SPECS.find((group) => group.id === section)?.kinds ?? [];
29064
+ }
29065
+ /**
29066
+ * The sections a rule reports, in section order: every section whose kind
29067
+ * group shares a kind with the rule. A rule that selected only
29068
+ * `device-battery-low` still reports the battery STATE — the state has no
29069
+ * direction, only the event did.
29070
+ */
29071
+ function sectionsOfRule(condition) {
29072
+ return NC_STATE_SUMMARY_SECTION_IDS.filter((section) => sectionKinds(section).some((kind) => condition.kinds.includes(kind)));
29073
+ }
29074
+ /** The kind groups the rule selected that have no section. */
29075
+ function excludedGroupsOfRule(condition) {
29076
+ return NC_SYSTEM_EVENT_KIND_GROUP_SPECS.filter((group) => NC_STATE_SUMMARY_EXCLUDED_GROUPS[group.id] !== void 0 && group.kinds.some((kind) => condition.kinds.includes(kind))).map((group) => group.id);
29077
+ }
29078
+ /**
29079
+ * The condition the summary judges its items with: the rule's own, with its
29080
+ * kinds widened to every kind of every section it reports — so a rule that
29081
+ * named the raise is not told "not my kind" about the state the raise left
29082
+ * behind. Every FILTER is the rule's, untouched.
29083
+ */
29084
+ function summaryCondition(condition) {
29085
+ const kinds = sectionsOfRule(condition).flatMap((section) => sectionKinds(section));
29086
+ return {
29087
+ ...condition,
29088
+ kinds: [...new Set([...condition.kinds, ...kinds])]
29089
+ };
29090
+ }
29091
+ //#endregion
29092
+ //#region src/notification-center/state-summary/state-summary.ts
29093
+ /** Nothing to say: no item, and every section READ. Such a summary is not sent. */
29094
+ function isEmptyStateSummary(summary) {
29095
+ return summary.itemCount === 0 && summary.unavailableCount === 0;
29096
+ }
29097
+ function collectStateSummary(input) {
29098
+ const condition = input.rule.conditions.systemEvent;
29099
+ if (condition === void 0) return {
29100
+ sections: [],
29101
+ itemCount: 0,
29102
+ unavailableCount: 0,
29103
+ dropped: {},
29104
+ excludedGroups: []
29105
+ };
29106
+ const judge = {
29107
+ ...input.rule,
29108
+ conditions: {
29109
+ ...input.rule.conditions,
29110
+ systemEvent: summaryCondition(condition)
29111
+ },
29112
+ schedule: void 0,
29113
+ systemEventDelivery: void 0
29114
+ };
29115
+ const dropped = {};
29116
+ const drop = (reason) => {
29117
+ dropped[reason] = (dropped[reason] ?? 0) + 1;
29118
+ };
29119
+ const sections = [];
29120
+ for (const id of sectionsOfRule(condition)) {
29121
+ const read = input.sources.read(id, input.nowMs);
29122
+ if (!read.answered) {
29123
+ sections.push({
29124
+ id,
29125
+ status: "unavailable",
29126
+ items: [],
29127
+ reason: read.reason
29128
+ });
29129
+ continue;
29130
+ }
29131
+ const items = read.items.filter((item) => {
29132
+ const deviceId = item.subject.deviceId;
29133
+ if (deviceId !== void 0 && input.isMuted(deviceId)) {
29134
+ drop("muted");
29135
+ return false;
29136
+ }
29137
+ const verdict = evaluateRule(judge, ruleSubjectOf(item, input.nowMs));
29138
+ if (verdict.matched) return true;
29139
+ drop(verdict.excludedBySystemEventFilter ?? verdict.failedCondition ?? "unknown");
29140
+ return false;
29141
+ });
29142
+ sections.push({
29143
+ id,
29144
+ status: "read",
29145
+ items: items.toSorted(bySince)
29146
+ });
29147
+ }
29148
+ return {
29149
+ sections,
29150
+ itemCount: sections.reduce((sum, section) => sum + section.items.length, 0),
29151
+ unavailableCount: sections.filter((section) => section.status === "unavailable").length,
29152
+ dropped,
29153
+ excludedGroups: excludedGroupsOfRule(condition)
29154
+ };
29155
+ }
29156
+ function bySince(a, b) {
29157
+ return (a.since ?? 0) - (b.since ?? 0);
29158
+ }
29159
+ function ruleSubjectOf(item, nowMs) {
29160
+ return {
29161
+ kind: "system-event",
29162
+ recordId: `state:${item.subject.subject}`,
29163
+ deviceId: item.subject.deviceId ?? 0,
29164
+ timestamp: nowMs,
29165
+ classNames: [],
29166
+ zones: [],
29167
+ systemEvent: item.subject
29168
+ };
29169
+ }
29170
+ var DAY_MS$3 = 864e5;
29171
+ /** "07:12" when it began in the last day, "25 Sep, 07:12" before that. */
29172
+ function sinceText(texts, sinceMs, nowMs, timeZone) {
29173
+ if (nowMs - sinceMs < DAY_MS$3) return ncClockText(texts.language, sinceMs, timeZone);
29174
+ return new Intl.DateTimeFormat(texts.language, {
29175
+ day: "numeric",
29176
+ month: "short",
29177
+ hour: "2-digit",
29178
+ minute: "2-digit",
29179
+ ...timeZone !== void 0 ? { timeZone } : {}
29180
+ }).format(new Date(sinceMs));
29181
+ }
29182
+ function lineOf(input, item) {
29183
+ const since = item.since !== void 0 ? sinceText(input.texts, item.since, input.nowMs, input.timeZone) : "";
29184
+ return input.texts.text({
29185
+ key: item.lineKey,
29186
+ vars: {
29187
+ ...item.params,
29188
+ since
29189
+ }
29190
+ });
29191
+ }
29192
+ function blockOf(input, section) {
29193
+ const title = input.texts.text({ key: `summary.section.${section.id}` });
29194
+ if (section.status === "unavailable") return input.texts.text({
29195
+ key: "summary.section.unavailable",
29196
+ vars: { section: title }
29197
+ });
29198
+ if (section.items.length === 0) return null;
29199
+ const lines = section.items.map((item) => `• ${lineOf(input, item)}`);
29200
+ return [`${title}:`, ...lines].join("\n");
29201
+ }
29202
+ function stateSummaryText(input) {
29203
+ const sectionVars = {};
29204
+ const blocks = [];
29205
+ for (const section of input.summary.sections) {
29206
+ const block = blockOf(input, section);
29207
+ sectionVars[NC_STATE_SECTION_VAR[section.id]] = block ?? "";
29208
+ if (block !== null) blocks.push(block);
29209
+ }
29210
+ return {
29211
+ text: blocks.join("\n"),
29212
+ count: input.summary.itemCount,
29213
+ sectionVars
29214
+ };
29215
+ }
29216
+ //#endregion
29217
+ //#region src/notification-center/state-summary/zoned-clock.ts
29218
+ var WEEKDAYS = {
29219
+ Sun: 0,
29220
+ Mon: 1,
29221
+ Tue: 2,
29222
+ Wed: 3,
29223
+ Thu: 4,
29224
+ Fri: 5,
29225
+ Sat: 6
29226
+ };
29227
+ var FORMATTERS = /* @__PURE__ */ new Map();
29228
+ function formatterFor(timezone) {
29229
+ const held = FORMATTERS.get(timezone);
29230
+ if (held !== void 0) return held;
29231
+ let made;
29232
+ try {
29233
+ made = new Intl.DateTimeFormat("en-US", {
29234
+ timeZone: timezone,
29235
+ year: "numeric",
29236
+ month: "numeric",
29237
+ day: "numeric",
29238
+ weekday: "short",
29239
+ hour: "numeric",
29240
+ minute: "numeric",
29241
+ hourCycle: "h23"
29242
+ });
29243
+ } catch {
29244
+ made = null;
29245
+ }
29246
+ FORMATTERS.set(timezone, made);
29247
+ return made;
29248
+ }
29249
+ function wallOf(atMs, timezone) {
29250
+ const date = new Date(atMs);
29251
+ const formatter = timezone !== void 0 ? formatterFor(timezone) : null;
29252
+ if (formatter !== null) {
29253
+ const parts = new Map(formatter.formatToParts(date).map((p) => [p.type, p.value]));
29254
+ const weekday = WEEKDAYS[parts.get("weekday") ?? ""];
29255
+ const wall = {
29256
+ year: Number(parts.get("year")),
29257
+ month: Number(parts.get("month")),
29258
+ day: Number(parts.get("day")),
29259
+ hour: Number(parts.get("hour")),
29260
+ minute: Number(parts.get("minute"))
29261
+ };
29262
+ if (weekday !== void 0 && Object.values(wall).every(Number.isFinite)) return {
29263
+ ...wall,
29264
+ weekday
29265
+ };
29266
+ }
29267
+ return {
29268
+ year: date.getFullYear(),
29269
+ month: date.getMonth() + 1,
29270
+ day: date.getDate(),
29271
+ weekday: date.getDay(),
29272
+ hour: date.getHours(),
29273
+ minute: date.getMinutes()
29274
+ };
29275
+ }
29276
+ /** The calendar day `atMs` falls on, in the timezone. */
29277
+ function zonedDayOf(atMs, timezone) {
29278
+ const { year, month, day, weekday } = wallOf(atMs, timezone);
29279
+ return {
29280
+ year,
29281
+ month,
29282
+ day,
29283
+ weekday
29284
+ };
29285
+ }
29286
+ /** The day `offset` calendar days after `day`. Pure calendar arithmetic. */
29287
+ function addDays(day, offset) {
29288
+ const shifted = new Date(Date.UTC(day.year, day.month - 1, day.day + offset));
29289
+ return {
29290
+ year: shifted.getUTCFullYear(),
29291
+ month: shifted.getUTCMonth() + 1,
29292
+ day: shifted.getUTCDate(),
29293
+ weekday: ((day.weekday + offset) % 7 + 7) % 7
29294
+ };
29295
+ }
29296
+ /**
29297
+ * The instant of `minuteOfDay` on `day`, in the timezone. Two passes of the
29298
+ * offset read settle a DST change; a wall time that does not exist (the spring
29299
+ * gap) lands next to it, and the caller verifies every instant it produces.
29300
+ */
29301
+ function zonedInstant(day, minuteOfDay, timezone) {
29302
+ const hour = Math.floor(minuteOfDay / 60);
29303
+ const minute = minuteOfDay % 60;
29304
+ if (timezone === void 0 || formatterFor(timezone) === null) return new Date(day.year, day.month - 1, day.day, hour, minute).getTime();
29305
+ const asUtc = Date.UTC(day.year, day.month - 1, day.day, hour, minute);
29306
+ const offsetAt = (atMs) => {
29307
+ const wall = wallOf(atMs, timezone);
29308
+ return Date.UTC(wall.year, wall.month - 1, wall.day, wall.hour, wall.minute) - atMs;
29309
+ };
29310
+ return asUtc - offsetAt(asUtc - offsetAt(asUtc));
29311
+ }
29312
+ /** The widest look back: a weekly schedule closes at least once in eight days. */
29313
+ var NC_STATE_SUMMARY_MAX_LOOKBACK_MS = 8 * 864e5;
29314
+ /** A close found by the tick right after it is on time; later than this is `late`. */
29315
+ var ON_TIME_MS = 2 * 6e4;
29316
+ var MINUTE_MS = 6e4;
29317
+ var DAY_MS$2 = 864e5;
29318
+ /**
29319
+ * Every verified transition of `kind` in `(fromMs, toMs]`, ascending.
29320
+ * Candidates are each window's start and end minute on each day of the range
29321
+ * (the day before `fromMs` included, for a window crossing midnight).
29322
+ */
29323
+ function scheduleTransitions(schedule, fromMs, toMs, kind, probe = isScheduleActive) {
29324
+ if (toMs <= fromMs) return [];
29325
+ const first = zonedDayOf(fromMs - DAY_MS$2, schedule.timezone);
29326
+ const days = Math.ceil((toMs - fromMs) / DAY_MS$2) + 2;
29327
+ const minutes = /* @__PURE__ */ new Set();
29328
+ for (const window of schedule.windows) {
29329
+ minutes.add(window.startMinute);
29330
+ minutes.add(window.endMinute);
29331
+ }
29332
+ const found = /* @__PURE__ */ new Set();
29333
+ for (let offset = 0; offset <= days; offset += 1) {
29334
+ const day = addDays(first, offset);
29335
+ for (const minute of minutes) {
29336
+ const at = zonedInstant(day, minute, schedule.timezone);
29337
+ if (at <= fromMs || at > toMs || found.has(at)) continue;
29338
+ const before = probe(schedule, at - MINUTE_MS);
29339
+ const after = probe(schedule, at);
29340
+ if (kind === "close" ? before && !after : !before && after) found.add(at);
29341
+ }
29342
+ }
29343
+ return [...found].toSorted((a, b) => a - b);
29344
+ }
29345
+ /** The first window close after `afterMs`, within `horizonMs`, or `null`. */
29346
+ function nextStateSummaryClose(schedule, afterMs, horizonMs = NC_STATE_SUMMARY_MAX_LOOKBACK_MS, probe) {
29347
+ return scheduleTransitions(schedule, afterMs, afterMs + horizonMs, "close", probe)[0] ?? null;
29348
+ }
29349
+ function decideStateSummary(input) {
29350
+ const from = Math.max(input.floorMs, input.nowMs - NC_STATE_SUMMARY_MAX_LOOKBACK_MS);
29351
+ const endMs = scheduleTransitions(input.schedule, from, input.nowMs, "close", input.probe).at(-1);
29352
+ if (endMs === void 0) return { kind: "idle" };
29353
+ const window = {
29354
+ startMs: scheduleTransitions(input.schedule, endMs - DAY_MS$2, endMs, "open", input.probe).at(-1) ?? endMs,
29355
+ endMs
29356
+ };
29357
+ const overdueMs = input.nowMs - endMs;
29358
+ if (overdueMs > (input.graceMs ?? 108e5)) return {
29359
+ kind: "missed",
29360
+ window
29361
+ };
29362
+ return {
29363
+ kind: "send",
29364
+ window,
29365
+ late: overdueMs > ON_TIME_MS
29366
+ };
29367
+ }
29368
+ //#endregion
29369
+ //#region src/notification-center/state-summary/state-summary-scheduler.ts
29370
+ /**
29371
+ * The STATE SUMMARY's tick (D647): for every enabled summary rule, is a
29372
+ * window owed, and if so collect → word → enqueue → stamp.
29373
+ *
29374
+ * Driven by the centre's existing rule-reload tick — no timer of its own — and
29375
+ * single-flight. It reads RAM only: the rule mirror, the stamps and the state
29376
+ * mirrors (D447: nothing on this schedule queries SQLite).
29377
+ *
29378
+ * Every branch that sends nothing SAYS so: an empty state (INFO), a missed
29379
+ * window (WARN), a stamp ledger that has not been read (WARN — nothing is sent
29380
+ * until it has, because without it a restart could send a window twice), a
29381
+ * delivery that failed (WARN, retried on the next tick inside the grace).
29382
+ *
29383
+ * The stamp is LAST, as in the digest: a window whose enqueue failed stays owed.
29384
+ */
29385
+ var NcStateSummaryScheduler = class {
29386
+ deps;
29387
+ inFlight = false;
29388
+ /** Per rule: the next window close, valid for one floor and one schedule. */
29389
+ nextClose = /* @__PURE__ */ new Map();
29390
+ /** One WARN per hold, not one per tick. */
29391
+ holdReported = false;
29392
+ constructor(deps) {
29393
+ this.deps = deps;
29394
+ }
29395
+ async tick() {
29396
+ const report = {
29397
+ decisions: 0,
29398
+ sent: 0,
29399
+ empty: 0,
29400
+ missed: 0,
29401
+ failed: 0,
29402
+ held: false
29403
+ };
29404
+ if (this.inFlight) return report;
29405
+ this.inFlight = true;
29406
+ try {
29407
+ const rules = this.deps.rules().filter((rule) => systemEventSummaryScheduleOf(rule) !== null);
29408
+ if (rules.length === 0) return report;
29409
+ if (!this.deps.stamps.answered()) {
29410
+ report.held = true;
29411
+ if (!this.holdReported) {
29412
+ this.holdReported = true;
29413
+ this.deps.logger.warn("state summaries held — the send ledger has not been read yet", { meta: { rules: rules.map((rule) => rule.id) } });
29414
+ }
29415
+ return report;
29416
+ }
29417
+ this.holdReported = false;
29418
+ const nowMs = this.deps.now();
29419
+ for (const rule of rules) {
29420
+ const schedule = systemEventSummaryScheduleOf(rule);
29421
+ if (schedule !== null) await this.tickRule(rule, schedule, nowMs, report);
29422
+ }
29423
+ return report;
29424
+ } finally {
29425
+ this.inFlight = false;
29426
+ }
29427
+ }
29428
+ async tickRule(rule, schedule, nowMs, report) {
29429
+ const stamp = await this.armed(rule, schedule, nowMs);
29430
+ if (stamp === null) return;
29431
+ const floorMs = Math.max(stamp.armedAt, stamp.lastWindowEnd);
29432
+ const cached = this.nextClose.get(rule.id);
29433
+ if (cached !== void 0 && cached.floorMs === floorMs && cached.scheduleKey === stamp.scheduleKey && nowMs < cached.atMs) return;
29434
+ report.decisions += 1;
29435
+ const decision = decideStateSummary({
29436
+ schedule,
29437
+ nowMs,
29438
+ floorMs,
29439
+ ...this.deps.graceMs !== void 0 ? { graceMs: this.deps.graceMs } : {}
29440
+ });
29441
+ if (decision.kind === "idle") {
29442
+ const next = nextStateSummaryClose(schedule, nowMs);
29443
+ this.nextClose.set(rule.id, {
29444
+ floorMs,
29445
+ scheduleKey: stamp.scheduleKey,
29446
+ atMs: next ?? nowMs + 6912e5
29447
+ });
29448
+ return;
29449
+ }
29450
+ this.nextClose.delete(rule.id);
29451
+ const meta = {
29452
+ ruleId: rule.id,
29453
+ rule: rule.name,
29454
+ windowStart: decision.window.startMs,
29455
+ windowEnd: decision.window.endMs
29456
+ };
29457
+ if (decision.kind === "missed") {
29458
+ report.missed += 1;
29459
+ this.deps.logger.warn("state summary window MISSED — closed too long ago to send now", { meta: {
29460
+ ...meta,
29461
+ overdueMs: nowMs - decision.window.endMs
29462
+ } });
29463
+ await this.stamp(stamp, decision.window.endMs);
29464
+ return;
29465
+ }
29466
+ const summary = await this.deps.collect(rule, nowMs);
29467
+ if (isEmptyStateSummary(summary)) {
29468
+ report.empty += 1;
29469
+ this.deps.logger.info("state summary has nothing to report — not sent", { meta: {
29470
+ ...meta,
29471
+ dropped: summary.dropped
29472
+ } });
29473
+ await this.stamp(stamp, decision.window.endMs);
29474
+ return;
29475
+ }
29476
+ let inserted;
29477
+ try {
29478
+ inserted = await this.deps.deliver(rule, summary, decision.window);
29479
+ } catch (err) {
29480
+ report.failed += 1;
29481
+ this.deps.logger.warn("state summary could not be enqueued — retried on the next tick", { meta: {
29482
+ ...meta,
29483
+ error: err instanceof Error ? err.message : String(err)
29484
+ } });
29485
+ return;
29486
+ }
29487
+ report.sent += 1;
29488
+ this.deps.logger.info("state summary enqueued", { meta: {
29489
+ ...meta,
29490
+ late: decision.late,
29491
+ items: summary.itemCount,
29492
+ unavailable: summary.unavailableCount,
29493
+ sections: summary.sections.map((section) => `${section.id}:${section.status}`),
29494
+ dropped: summary.dropped,
29495
+ inserted
29496
+ } });
29497
+ await this.stamp(stamp, decision.window.endMs);
29498
+ }
29499
+ /**
29500
+ * The rule's stamp, armed NOW when the rule is new to summary mode or its
29501
+ * schedule changed. `null` when arming could not be written — nothing is
29502
+ * sent on an unarmed rule.
29503
+ */
29504
+ async armed(rule, schedule, nowMs) {
29505
+ const scheduleKey = canonicalHash(schedule);
29506
+ const held = this.deps.stamps.get(rule.id);
29507
+ if (held !== void 0 && held.scheduleKey === scheduleKey) return held;
29508
+ const armed = {
29509
+ ruleId: rule.id,
29510
+ armedAt: nowMs,
29511
+ lastWindowEnd: held?.lastWindowEnd ?? 0,
29512
+ scheduleKey
29513
+ };
29514
+ try {
29515
+ await this.deps.stamps.put(armed);
29516
+ } catch (err) {
29517
+ this.deps.logger.warn("state summary rule could not be armed — nothing is sent until it is", { meta: {
29518
+ ruleId: rule.id,
29519
+ error: err instanceof Error ? err.message : String(err)
29520
+ } });
29521
+ return null;
29522
+ }
29523
+ this.deps.logger.info("state summary rule armed — windows closing from now on are sent", { meta: {
29524
+ ruleId: rule.id,
29525
+ rule: rule.name,
29526
+ rearmed: held !== void 0
29527
+ } });
29528
+ return armed;
29529
+ }
29530
+ async stamp(held, windowEndMs) {
29531
+ try {
29532
+ await this.deps.stamps.put({
29533
+ ...held,
29534
+ lastWindowEnd: Math.max(held.lastWindowEnd, windowEndMs)
29535
+ });
29536
+ } catch (err) {
29537
+ this.deps.logger.warn("state summary stamp did not land — the outbox dedup still holds", { meta: {
29538
+ ruleId: held.ruleId,
29539
+ windowEnd: windowEndMs,
29540
+ error: err instanceof Error ? err.message : String(err)
29541
+ } });
29542
+ }
29543
+ }
29544
+ };
29545
+ var SPEC = {
29546
+ collection: "notification-center:state-summary-stamps",
29547
+ columns: [
29548
+ {
29549
+ name: "ruleId",
29550
+ type: "TEXT",
29551
+ primaryKey: true,
29552
+ notNull: true
29553
+ },
29554
+ {
29555
+ name: "armedAt",
29556
+ type: "INTEGER",
29557
+ notNull: true
29558
+ },
29559
+ {
29560
+ name: "lastWindowEnd",
29561
+ type: "INTEGER",
29562
+ notNull: true
29563
+ },
29564
+ {
29565
+ name: "scheduleKey",
29566
+ type: "TEXT",
29567
+ notNull: true
29568
+ }
29569
+ ],
29570
+ writeMode: "write-through",
29571
+ keyOf: (row) => row.ruleId,
29572
+ toValue: (row) => ({
29573
+ armedAt: row.armedAt,
29574
+ lastWindowEnd: row.lastWindowEnd,
29575
+ scheduleKey: row.scheduleKey
29576
+ }),
29577
+ fromRecord: (key, data) => {
29578
+ const armedAt = data["armedAt"];
29579
+ const lastWindowEnd = data["lastWindowEnd"];
29580
+ const scheduleKey = data["scheduleKey"];
29581
+ if (typeof armedAt !== "number" || typeof lastWindowEnd !== "number") return null;
29582
+ if (typeof scheduleKey !== "string") return null;
29583
+ return {
29584
+ ruleId: key,
29585
+ armedAt,
29586
+ lastWindowEnd,
29587
+ scheduleKey
29588
+ };
29589
+ },
29590
+ loadLimit: 1e4
29591
+ };
29592
+ var NcStateSummaryStampStore = class {
29593
+ ledger;
29594
+ constructor(deps) {
29595
+ this.ledger = new DurableLedger({
29596
+ spec: SPEC,
29597
+ store: deps.store,
29598
+ logger: deps.logger
29599
+ });
29600
+ }
29601
+ static declare(store) {
29602
+ return DurableLedger.declare(store, SPEC);
29603
+ }
29604
+ async load() {
29605
+ return (await this.ledger.load()).length;
29606
+ }
29607
+ port() {
29608
+ return {
29609
+ get: (ruleId) => this.ledger.get(ruleId),
29610
+ put: (row) => this.ledger.put(row),
29611
+ answered: () => this.ledger.hasAnswered()
29612
+ };
29613
+ }
29614
+ };
29615
+ //#endregion
29616
+ //#region src/notification-center/state-summary/system-state-ledger.ts
29617
+ /**
29618
+ * @durable class=ledger owner=notification-center
29619
+ * write="a system state the centre accepted OPENING (a stream offline, a device switched off, a camera detecting nothing, an update available, a node that cannot infer, a crash-looped runner); its closing event deletes the row"
29620
+ * retention="none — a row lives exactly as long as its state is open. Bounded by the installation: streams, devices, nodes and installed packages."
29621
+ */
29622
+ var NC_SYSTEM_STATE_COLLECTION = "notification-center:system-state";
29623
+ var NC_SYSTEM_STATE_COLUMNS = [
29624
+ (
29625
+ /** The state's subject (`stream:<id>`, `device-switch:<id>`,
29626
+ * `update:addon:<node>:<package>`, ...). */
29627
+ {
29628
+ name: "key",
29629
+ type: "TEXT",
29630
+ primaryKey: true,
29631
+ notNull: true
29632
+ }),
29633
+ (
29634
+ /** The OPENING kind. */
29635
+ {
29636
+ name: "kind",
29637
+ type: "TEXT",
29638
+ notNull: true
29639
+ }),
29640
+ {
29641
+ name: "since",
29642
+ type: "INTEGER",
29643
+ notNull: true
29644
+ },
29645
+ (
29646
+ /** Indexed: every question about a device is asked per device. */
29647
+ {
29648
+ name: "deviceId",
29649
+ type: "INTEGER"
29650
+ }),
29651
+ {
29652
+ name: "nodeId",
29653
+ type: "TEXT"
29654
+ },
29655
+ {
29656
+ name: "packageName",
29657
+ type: "TEXT"
29658
+ },
29659
+ (
29660
+ /** The words' facts (device name, profile, versions, addons). */
29661
+ {
29662
+ name: "params",
29663
+ type: "JSON",
29664
+ notNull: true
29665
+ })
29666
+ ];
29667
+ var NC_SYSTEM_STATE_INDEXES = [{
29668
+ name: "idx_nc_system_state_device",
29669
+ columns: ["deviceId"]
29670
+ }];
29671
+ /** The kinds that OPEN a row. */
29672
+ var NC_SYSTEM_STATE_OPENING_KINDS = [
29673
+ "stream-offline",
29674
+ "device-disabled",
29675
+ "detection-blind",
29676
+ "node-inference-unavailable",
29677
+ "addon-crash-loop",
29678
+ "addon-update-available",
29679
+ "server-update-available",
29680
+ "wrapper-update-available"
29681
+ ];
29682
+ function isOpeningKind(value) {
29683
+ return NC_SYSTEM_STATE_OPENING_KINDS.some((kind) => kind === value);
29684
+ }
29685
+ function stringRecord(value) {
29686
+ if (typeof value !== "object" || value === null || Array.isArray(value)) return null;
29687
+ const out = {};
29688
+ for (const [key, entry] of Object.entries(value)) if (typeof entry === "string") out[key] = entry;
29689
+ return out;
29690
+ }
29691
+ var NC_SYSTEM_STATE_SPEC = {
29692
+ collection: NC_SYSTEM_STATE_COLLECTION,
29693
+ columns: NC_SYSTEM_STATE_COLUMNS,
29694
+ indexes: NC_SYSTEM_STATE_INDEXES,
29695
+ writeMode: "write-behind",
29696
+ keyOf: (row) => row.key,
29697
+ toValue: (row) => ({
29698
+ kind: row.kind,
29699
+ since: row.since,
29700
+ ...row.deviceId !== void 0 ? { deviceId: row.deviceId } : {},
29701
+ ...row.nodeId !== void 0 ? { nodeId: row.nodeId } : {},
29702
+ ...row.packageName !== void 0 ? { packageName: row.packageName } : {},
29703
+ params: {
29704
+ ...row.params,
29705
+ ...row.deviceType !== void 0 ? { deviceType: row.deviceType } : {}
29706
+ }
29707
+ }),
29708
+ fromRecord: (key, data) => {
29709
+ const kind = data["kind"];
29710
+ const since = data["since"];
29711
+ const params = stringRecord(data["params"]);
29712
+ if (!isOpeningKind(kind) || typeof since !== "number" || params === null) return null;
29713
+ const deviceId = data["deviceId"];
29714
+ const nodeId = data["nodeId"];
29715
+ const packageName = data["packageName"];
29716
+ const { deviceType, ...words } = params;
29717
+ return {
29718
+ key,
29719
+ kind,
29720
+ since,
29721
+ ...typeof deviceId === "number" ? { deviceId } : {},
29722
+ ...deviceType !== void 0 ? { deviceType } : {},
29723
+ ...typeof nodeId === "string" ? { nodeId } : {},
29724
+ ...typeof packageName === "string" ? { packageName } : {},
29725
+ params: words
29726
+ };
29727
+ },
29728
+ deviceIdOf: (row) => row.deviceId
29729
+ };
29730
+ function isUpdateTarget(value) {
29731
+ return value === "addon" || value === "server" || value === "wrapper";
29732
+ }
29733
+ function updateKind(target) {
29734
+ return target === "addon" ? "addon-update-available" : target === "server" ? "server-update-available" : "wrapper-update-available";
29735
+ }
29736
+ function updateStateKey(target, nodeId, packageName) {
29737
+ return `update:${target}:${nodeId ?? "hub"}:${packageName}`;
29738
+ }
29739
+ var NcSystemStateLedger = class {
29740
+ deps;
29741
+ constructor(deps) {
29742
+ this.deps = deps;
29743
+ }
29744
+ /** The open rows, or `null` while the store has never answered. */
29745
+ openRows() {
29746
+ return this.deps.rows.answered() ? this.deps.rows.all() : null;
29747
+ }
29748
+ /**
29749
+ * One system subject the intake accepted. The update-available kinds are
29750
+ * not read here: {@link observeUpdateList} replaces them from the raw event.
29751
+ */
29752
+ observe(system, atMs) {
29753
+ switch (system.kind) {
29754
+ case "stream-offline":
29755
+ case "device-disabled":
29756
+ case "detection-blind":
29757
+ case "node-inference-unavailable":
29758
+ case "addon-crash-loop":
29759
+ this.open(system, atMs);
29760
+ return;
29761
+ case "stream-online":
29762
+ case "device-enabled":
29763
+ this.close(system.subject, system.kind);
29764
+ return;
29765
+ case "node-online":
29766
+ case "node-offline":
29767
+ if (system.nodeId !== void 0) this.closeNodeFaults(system.nodeId, system.kind);
29768
+ return;
29769
+ case "server-updated":
29770
+ if (system.nodeId !== void 0) this.closeServerUpdate(system.nodeId);
29771
+ return;
29772
+ default: return;
29773
+ }
29774
+ }
29775
+ /** A detection on a camera: it is not blind. Called for every detection — a Map lookup. */
29776
+ observeDetection(deviceId) {
29777
+ const key = `detection-blind:${String(deviceId)}`;
29778
+ if (this.deps.rows.get(key) === void 0) return;
29779
+ this.close(key, "detection");
29780
+ }
29781
+ /**
29782
+ * Any `stream.online`, whatever its reason (D647 review I1). The intake gate
29783
+ * drops a `first-packet` online — a recreated broker's — because it is not
29784
+ * a recovery worth a notification; it is still proof the stream is up.
29785
+ */
29786
+ observeStreamOnline(camStreamId) {
29787
+ this.close(`stream:${camStreamId}`, "stream-online");
29788
+ }
29789
+ /** One `update.available` list: REPLACE the rows of every node it names. */
29790
+ observeUpdateList(list, atMs) {
29791
+ const keep = new Set(list.rows.map((pkg) => updateStateKey(list.target, pkg.nodeId, pkg.packageName)));
29792
+ for (const nodeId of list.nodeIds) {
29793
+ const prefix = `update:${list.target}:${nodeId}:`;
29794
+ for (const row of this.deps.rows.all()) if (row.key.startsWith(prefix) && !keep.has(row.key)) this.close(row.key, "update-list");
29795
+ }
29796
+ for (const pkg of list.rows) this.openUpdate(list.target, pkg, atMs);
29797
+ }
29798
+ /**
29799
+ * An addon STARTED on `nodeId` (D647 review I5): the runner hosting it is
29800
+ * up again, so a crash-loop row naming it on THAT node is over — and only
29801
+ * on that node.
29802
+ */
29803
+ observeAddonStarted(addonId, nodeId) {
29804
+ for (const row of this.deps.rows.all()) {
29805
+ if (row.kind !== "addon-crash-loop") continue;
29806
+ if (row.nodeId !== nodeId) continue;
29807
+ if ((row.params["addons"] ?? "").split(",").map((id) => id.trim()).includes(addonId)) this.close(row.key, "addon-started");
29808
+ }
29809
+ }
29810
+ /**
29811
+ * The balancer placed a camera on a node (not pinned): placement skips a
29812
+ * node that cannot infer, so the node can infer again (D647 review I5).
29813
+ */
29814
+ observeCameraPlaced(nodeId) {
29815
+ this.close(`node-inference:${nodeId}`, "camera-placed");
29816
+ }
29817
+ /** `addon.updated` — the package is current on that node. */
29818
+ observeAddonUpdated(update) {
29819
+ this.close(updateStateKey("addon", update.nodeId, update.packageName), "addon-updated");
29820
+ }
29821
+ /** Every device that holds an open state — the deleted-device reconcile reads it. */
29822
+ deviceIds() {
29823
+ const ids = /* @__PURE__ */ new Set();
29824
+ for (const row of this.deps.rows.all()) if (row.deviceId !== void 0) ids.add(row.deviceId);
29825
+ return [...ids];
29826
+ }
29827
+ /** A deleted device leaves (D633). Returns how many rows went. */
29828
+ forgetDevice(deviceId) {
29829
+ const keys = this.deps.rows.all().filter((row) => row.deviceId === deviceId).map((row) => row.key);
29830
+ for (const key of keys) this.deps.rows.remove(key);
29831
+ return keys.length;
29832
+ }
29833
+ open(system, atMs) {
29834
+ const held = this.deps.rows.get(system.subject);
29835
+ const since = held !== void 0 && held.kind === system.kind ? held.since : atMs;
29836
+ this.deps.rows.put({
29837
+ key: system.subject,
29838
+ kind: system.kind,
29839
+ since,
29840
+ ...system.deviceId !== void 0 ? { deviceId: system.deviceId } : {},
29841
+ ...system.deviceType !== void 0 ? { deviceType: system.deviceType } : {},
29842
+ ...system.nodeId !== void 0 ? { nodeId: system.nodeId } : {},
29843
+ params: system.textParams ?? {}
29844
+ });
29845
+ }
29846
+ openUpdate(target, pkg, atMs) {
29847
+ const key = updateStateKey(target, pkg.nodeId, pkg.packageName);
29848
+ const held = this.deps.rows.get(key);
29849
+ const kind = updateKind(target);
29850
+ this.deps.rows.put({
29851
+ key,
29852
+ kind,
29853
+ since: held?.since ?? atMs,
29854
+ ...pkg.nodeId !== void 0 ? { nodeId: pkg.nodeId } : {},
29855
+ packageName: pkg.packageName,
29856
+ params: {
29857
+ packageName: pkg.packageName,
29858
+ currentVersion: pkg.currentVersion,
29859
+ latestVersion: pkg.latestVersion,
29860
+ ...pkg.nodeId !== void 0 ? { nodeId: pkg.nodeId } : {}
29861
+ }
29862
+ });
29863
+ }
29864
+ close(key, by) {
29865
+ const held = this.deps.rows.get(key);
29866
+ if (held === void 0) return;
29867
+ this.deps.rows.remove(key);
29868
+ this.deps.logger?.debug("system state closed", {
29869
+ ...held.deviceId !== void 0 ? { tags: { deviceId: held.deviceId } } : {},
29870
+ meta: {
29871
+ key,
29872
+ kind: held.kind,
29873
+ by,
29874
+ since: held.since
29875
+ }
29876
+ });
29877
+ }
29878
+ closeNodeFaults(nodeId, by) {
29879
+ for (const row of this.deps.rows.all()) {
29880
+ if (row.nodeId !== nodeId) continue;
29881
+ if (row.kind === "node-inference-unavailable" || row.kind === "addon-crash-loop") this.close(row.key, by);
29882
+ }
29883
+ }
29884
+ closeServerUpdate(nodeId) {
29885
+ for (const row of this.deps.rows.all()) {
29886
+ if ((row.nodeId ?? "hub") !== nodeId) continue;
29887
+ if (row.kind === "server-update-available" || row.kind === "wrapper-update-available" || row.kind === "addon-crash-loop" || row.kind === "node-inference-unavailable") this.close(row.key, "server-updated");
29888
+ }
29889
+ }
29890
+ };
29891
+ /** The durable home of the rows. */
29892
+ var NcSystemStateStore = class {
29893
+ ledger;
29894
+ constructor(deps) {
29895
+ this.ledger = new DurableLedger({
29896
+ spec: NC_SYSTEM_STATE_SPEC,
29897
+ store: deps.store,
29898
+ logger: deps.logger
29899
+ });
29900
+ }
29901
+ static declare(store) {
29902
+ return DurableLedger.declare(store, NC_SYSTEM_STATE_SPEC);
29903
+ }
29904
+ /** Boot reseed. A failed load keeps the mirror and leaves `answered` false. */
29905
+ async load() {
29906
+ return (await this.ledger.load()).length;
29907
+ }
29908
+ port() {
29909
+ return {
29910
+ get: (key) => this.ledger.get(key),
29911
+ put: (row) => {
29912
+ this.ledger.put(row).catch(() => void 0);
29913
+ },
29914
+ remove: (key) => {
29915
+ this.ledger.forget(key);
29916
+ },
29917
+ all: () => this.ledger.snapshot(),
29918
+ answered: () => this.ledger.hasAnswered()
29919
+ };
29920
+ }
29921
+ };
29922
+ //#endregion
29923
+ //#region src/notification-center/state-summary/state-summary-service.ts
29924
+ function stringOf(data, key) {
29925
+ const value = data[key];
29926
+ return typeof value === "string" && value.length > 0 ? value : void 0;
29927
+ }
29928
+ /**
29929
+ * The full list an `update.available` event carries, and the nodes it speaks
29930
+ * for: every node a row names, plus the event's own `nodeId`. The emitter
29931
+ * never sends an empty list (it emits only when a `latestVersion` changes), so
29932
+ * the empty case is defensive, not a path that clears anything today — see
29933
+ * {@link NcUpdateList} for the limit that leaves.
29934
+ */
29935
+ function updateListOf(data) {
29936
+ const target = data["target"];
29937
+ if (!isUpdateTarget(target)) return null;
29938
+ const rows = updatePackages(data);
29939
+ const eventNode = stringOf(data, "nodeId");
29940
+ const nodes = new Set(rows.map((row) => row.nodeId ?? eventNode ?? "hub"));
29941
+ if (eventNode !== void 0) nodes.add(eventNode);
29942
+ const withNode = rows.map((row) => row.nodeId === void 0 && eventNode !== void 0 ? {
29943
+ ...row,
29944
+ nodeId: eventNode
29945
+ } : row);
29946
+ return {
29947
+ target,
29948
+ nodeIds: [...nodes],
29949
+ rows: withNode
29950
+ };
29951
+ }
29952
+ var NcStateSummaryService = class {
29953
+ deps;
29954
+ stateStore;
29955
+ stampStore;
29956
+ ledger;
29957
+ scheduler;
29958
+ constructor(deps) {
29959
+ this.deps = deps;
29960
+ this.stateStore = new NcSystemStateStore({
29961
+ store: deps.store,
29962
+ logger: deps.logger
29963
+ });
29964
+ this.stampStore = new NcStateSummaryStampStore({
29965
+ store: deps.store,
29966
+ logger: deps.logger
29967
+ });
29968
+ this.ledger = new NcSystemStateLedger({
29969
+ rows: this.stateStore.port(),
29970
+ logger: deps.logger
29971
+ });
29972
+ this.scheduler = new NcStateSummaryScheduler({
29973
+ rules: deps.rules,
29974
+ stamps: this.stampStore.port(),
29975
+ collect: (rule, nowMs) => this.collect(rule, nowMs),
29976
+ deliver: (rule, summary, window) => this.deliver(rule, summary, window),
29977
+ now: deps.now,
29978
+ logger: deps.logger
29979
+ });
29980
+ }
29981
+ static async declare(store) {
29982
+ await NcSystemStateStore.declare(store);
29983
+ await NcStateSummaryStampStore.declare(store);
29984
+ }
29985
+ /** Boot back-fill. Each load that fails leaves its mirror unanswered — the summary then says so. */
29986
+ async load() {
29987
+ return {
29988
+ states: await this.stateStore.load(),
29989
+ stamps: await this.stampStore.load()
29990
+ };
29991
+ }
29992
+ /** One system subject the intake accepted. */
29993
+ observeSystem(system, atMs) {
29994
+ this.ledger.observe(system, atMs);
29995
+ }
29996
+ /**
29997
+ * The RAW bus event, before the intake's gates (D647 review I1/I3/I5): the
29998
+ * facts that close or replace a state even when no notification is owed —
29999
+ * any `stream.online`, the full `update.available` list of a node, an addon
30000
+ * that started, a camera the balancer placed.
30001
+ */
30002
+ observeRawEvent(category, data, atMs, sourceNodeId) {
30003
+ switch (category) {
30004
+ case "stream.online": {
30005
+ const camStreamId = stringOf(data, "camStreamId");
30006
+ if (camStreamId !== void 0) this.ledger.observeStreamOnline(camStreamId);
30007
+ return;
30008
+ }
30009
+ case "update.available": {
30010
+ const list = updateListOf(data);
30011
+ if (list !== null) this.ledger.observeUpdateList(list, atMs);
30012
+ return;
30013
+ }
30014
+ case "addon.started":
30015
+ case "addon.restarted": {
30016
+ const addonId = stringOf(data, "addonId");
30017
+ if (addonId !== void 0) this.ledger.observeAddonStarted(addonId, sourceNodeId ?? "hub");
30018
+ return;
30019
+ }
30020
+ case "pipeline.camera-assigned": {
30021
+ const nodeId = stringOf(data, "agentNodeId");
30022
+ const placed = data["pinned"] === false && data["reason"] !== "unassigned";
30023
+ if (nodeId !== void 0 && placed) this.ledger.observeCameraPlaced(nodeId);
30024
+ return;
30025
+ }
30026
+ default: return;
30027
+ }
30028
+ }
30029
+ observeDetection(deviceId) {
30030
+ this.ledger.observeDetection(deviceId);
30031
+ }
30032
+ observeAddonUpdated(update) {
30033
+ this.ledger.observeAddonUpdated(update);
30034
+ }
30035
+ tick() {
30036
+ return this.scheduler.tick();
30037
+ }
30038
+ /** The summary this rule would send NOW (`previewStateSummary`) — sends nothing, stamps nothing. */
30039
+ async preview(rule) {
30040
+ const nowMs = this.deps.now();
30041
+ const summary = await this.collect(rule, nowMs);
30042
+ const text = this.word(summary, nowMs);
30043
+ return {
30044
+ empty: summary.itemCount === 0 && summary.unavailableCount === 0,
30045
+ count: text.count,
30046
+ text: text.text,
30047
+ sections: summary.sections.map((section) => ({
30048
+ id: section.id,
30049
+ status: section.status,
30050
+ items: section.items.length,
30051
+ ...section.reason !== void 0 ? { reason: section.reason } : {}
30052
+ })),
30053
+ dropped: { ...summary.dropped },
30054
+ excludedGroups: [...summary.excludedGroups]
30055
+ };
30056
+ }
30057
+ async collect(rule, nowMs) {
30058
+ const nodes = await this.nodeView();
30059
+ return collectStateSummary({
30060
+ rule,
30061
+ nowMs,
30062
+ isMuted: this.deps.isMuted,
30063
+ sources: stateSources({
30064
+ alerts: this.deps.alerts,
30065
+ liveness: this.deps.liveness,
30066
+ state: () => this.ledger.openRows(),
30067
+ lookup: this.deps.lookup,
30068
+ devicesAnswered: this.deps.devicesAnswered,
30069
+ nodes,
30070
+ livenessHoldoffMs: this.deps.livenessHoldoffMs
30071
+ })
30072
+ });
30073
+ }
30074
+ /** The node directory for one summary, or `null` (logged) when it did not answer. */
30075
+ async nodeView() {
30076
+ const read = this.deps.readNodes;
30077
+ if (read === void 0) return null;
30078
+ try {
30079
+ const answer = await read();
30080
+ const online = new Map(answer.nodes.map((node) => [node.id, node.isOnline]));
30081
+ return {
30082
+ complete: answer.complete,
30083
+ online: (nodeId) => online.get(nodeId)
30084
+ };
30085
+ } catch (err) {
30086
+ this.deps.logger.warn("state summary: the node directory did not answer — node sections unavailable", { meta: { error: err instanceof Error ? err.message : String(err) } });
30087
+ return null;
30088
+ }
30089
+ }
30090
+ word(summary, nowMs) {
30091
+ return stateSummaryText({
30092
+ texts: this.deps.texts(),
30093
+ summary,
30094
+ nowMs
30095
+ });
30096
+ }
30097
+ async deliver(rule, summary, window) {
30098
+ const subject = stateSummarySubject({
30099
+ rule,
30100
+ summary,
30101
+ text: this.word(summary, window.endMs),
30102
+ window
30103
+ });
30104
+ if (subject === null) {
30105
+ this.deps.logger.warn("state summary rule has no system-event condition — not sent", { meta: { ruleId: rule.id } });
30106
+ return 0;
30107
+ }
30108
+ return this.deps.enqueue(rule, subject);
30109
+ }
30110
+ };
30111
+ //#endregion
28415
30112
  //#region src/notification-center/zone-owner-cache.ts
28416
30113
  var NcZoneOwnershipCache = class {
28417
30114
  deps;
@@ -28969,6 +30666,8 @@ var NotificationCenter = class NotificationCenter {
28969
30666
  alertStore;
28970
30667
  /** Battery band → notification, and the reminder sweep. */
28971
30668
  deviceAlerts;
30669
+ /** The scheduled STATE SUMMARY of system rules (D647): its ledgers and its tick. */
30670
+ stateSummary;
28972
30671
  /** States the `deviceState` rule gate reads. Refreshed on the reload tick;
28973
30672
  * read with no I/O on the persist hook. */
28974
30673
  deviceStates;
@@ -29862,6 +31561,21 @@ var NotificationCenter = class NotificationCenter {
29862
31561
  logger: this.logger.child("device-alerts"),
29863
31562
  ...deps.now !== void 0 ? { now: deps.now } : {}
29864
31563
  });
31564
+ this.stateSummary = new NcStateSummaryService({
31565
+ store: deps.store,
31566
+ logger: this.logger.child("state-summary"),
31567
+ now: () => this.now(),
31568
+ rules: () => this.rules.listEnabled("system-event"),
31569
+ alerts: () => this.deviceAlerts.openEpisodes(),
31570
+ liveness: () => this.liveness.hasAnswered() ? this.liveness.snapshot() : null,
31571
+ lookup: (deviceId) => this.deviceDirectory.get(deviceId),
31572
+ devicesAnswered: () => this.deviceDirectory.isWarm(),
31573
+ ...deps.readNodeDirectory !== void 0 ? { readNodes: deps.readNodeDirectory } : {},
31574
+ livenessHoldoffMs: deps.livenessHoldoffMs ?? 3e5,
31575
+ isMuted: (deviceId) => this.deviceMutes.isMuted(deviceId),
31576
+ texts: () => this.texts,
31577
+ enqueue: (rule, subject) => this.enqueueStateSummary(rule, subject)
31578
+ });
29865
31579
  this.confirmGate = deps.generateVision !== void 0 ? new NcConfirmGate({
29866
31580
  logger: this.logger.child("confirm"),
29867
31581
  generateVision: deps.generateVision,
@@ -29959,7 +31673,10 @@ var NotificationCenter = class NotificationCenter {
29959
31673
  summary: this.summaryRules,
29960
31674
  mutes: this.deviceMutes,
29961
31675
  alarm: () => this.alarmPanel,
29962
- alerts: this.deviceAlerts,
31676
+ alerts: {
31677
+ deviceIds: () => [...new Set([...this.deviceAlerts.deviceIds(), ...this.stateSummary.ledger.deviceIds()])],
31678
+ forget: async (deviceId) => await this.deviceAlerts.forget(deviceId) + this.stateSummary.ledger.forgetDevice(deviceId)
31679
+ },
29963
31680
  directory: this.deviceDirectory,
29964
31681
  isDeviceDeleted: deps.isDeviceDeleted,
29965
31682
  targetCatalog: () => this.dispatcher.catalogSnapshot()
@@ -30070,6 +31787,7 @@ var NotificationCenter = class NotificationCenter {
30070
31787
  await OccupancyStore.declare(store);
30071
31788
  await NcLivenessLedger.declare(store);
30072
31789
  await NcAlertStore.declare(store);
31790
+ await NcStateSummaryService.declare(store);
30073
31791
  await NcSnoozeStore.declare(store);
30074
31792
  await NcDeviceMuteStore.declare(store);
30075
31793
  await TimelapseStore.declare(store);
@@ -30263,6 +31981,10 @@ var NotificationCenter = class NotificationCenter {
30263
31981
  const rows = await this.alertStore.load();
30264
31982
  this.logger.info("device alert ledger reseeded", { meta: { rows } });
30265
31983
  })) return;
31984
+ if (!await phase("stateSummary", async () => {
31985
+ const loaded = await this.stateSummary.load();
31986
+ this.logger.info("state summary ledgers reseeded", { meta: loaded });
31987
+ })) return;
30266
31988
  if (!await phase("occupancy", () => this.hydrateOccupancy())) return;
30267
31989
  if (!await phase("outbox", () => this.outbox.load())) return;
30268
31990
  if (!await phase("cooldowns", () => this.seedCooldowns())) return;
@@ -30568,6 +32290,7 @@ var NotificationCenter = class NotificationCenter {
30568
32290
  atMs: event.timestamp,
30569
32291
  ...event.trackId !== void 0 ? { trackId: event.trackId } : {}
30570
32292
  });
32293
+ if (this.evaluationActive) this.stateSummary.observeDetection(event.deviceId);
30571
32294
  this.consumeEvent(incomingFromObjectEvent(event));
30572
32295
  }
30573
32296
  /**
@@ -30951,9 +32674,15 @@ var NotificationCenter = class NotificationCenter {
30951
32674
  if (!this.evaluationActive) return;
30952
32675
  if (event.category === "addon.updated") {
30953
32676
  const update = parseAddonUpdateComplete(event.data);
30954
- if (update !== null) this.addonUpdates.observe(update);
32677
+ if (update !== null) {
32678
+ this.addonUpdates.observe(update);
32679
+ this.stateSummary.observeAddonUpdated(update);
32680
+ }
30955
32681
  return;
30956
32682
  }
32683
+ this.stateSummary.observeRawEvent(event.category, event.data, this.now(), event.source.nodeId);
32684
+ if (event.category === "addon.started" || event.category === "addon.restarted") return;
32685
+ if (event.category === "pipeline.camera-assigned") return;
30957
32686
  const battery = batterySliceOf(event);
30958
32687
  if (battery !== null) {
30959
32688
  this.deviceAlerts.observeBattery(battery.deviceId, battery.reading);
@@ -30983,6 +32712,7 @@ var NotificationCenter = class NotificationCenter {
30983
32712
  return;
30984
32713
  }
30985
32714
  const system = incoming.subject.systemEvent;
32715
+ if (system !== void 0) this.stateSummary.observeSystem(system, this.now());
30986
32716
  const observation = system !== void 0 ? ledgeredLiveness(system) : null;
30987
32717
  if (observation !== null) {
30988
32718
  const previous = this.liveness.stateOf(observation.subject);
@@ -31037,6 +32767,10 @@ var NotificationCenter = class NotificationCenter {
31037
32767
  logger: this.logger,
31038
32768
  by: caller.userId
31039
32769
  });
32770
+ guardRuleSystemEventDelivery(rule, {
32771
+ logger: this.logger,
32772
+ by: caller.userId
32773
+ });
31040
32774
  await this.validateTargetRefs(rule.targets.map((t) => t.targetId));
31041
32775
  const created = await this.rules.create(rule, caller.userId);
31042
32776
  this.logger.info("notification rule created", { meta: {
@@ -31065,6 +32799,11 @@ var NotificationCenter = class NotificationCenter {
31065
32799
  by: caller.userId,
31066
32800
  ruleId
31067
32801
  });
32802
+ guardPatchSystemEventDelivery(existing, patch, clear ?? [], {
32803
+ logger: this.logger,
32804
+ by: caller.userId,
32805
+ ruleId
32806
+ });
31068
32807
  const { disabledTargetIds: _optOut, ...safePatch } = patch;
31069
32808
  const updated = await this.rules.update(ruleId, safePatch, clear ?? []);
31070
32809
  this.logger.info("notification rule updated", { meta: {
@@ -31083,6 +32822,7 @@ var NotificationCenter = class NotificationCenter {
31083
32822
  return { success: true };
31084
32823
  },
31085
32824
  testRule: async ({ rule, lookbackMinutes }) => ({ results: [...await this.dryRun(rule, lookbackMinutes)] }),
32825
+ previewStateSummary: async ({ rule }) => this.stateSummary.preview(rule),
31086
32826
  getConditionCatalog: async () => ({
31087
32827
  catalog: [...NC_CONDITION_CATALOG],
31088
32828
  taxonomy: NC_TAXONOMY
@@ -31964,6 +33704,29 @@ var NotificationCenter = class NotificationCenter {
31964
33704
  });
31965
33705
  continue;
31966
33706
  }
33707
+ if (isSystemEventSummaryRule(rule)) {
33708
+ this.logger.debug("rule matched — held for its scheduled state summary", {
33709
+ tags: { deviceId: subject.deviceId },
33710
+ meta: {
33711
+ ruleId: rule.id,
33712
+ rule: rule.name,
33713
+ kind: subject.systemEvent?.kind,
33714
+ eventId: subject.recordId
33715
+ }
33716
+ });
33717
+ outcomes.push({
33718
+ ruleId: rule.id,
33719
+ ruleName: rule.name,
33720
+ matched: true,
33721
+ ...evaluation.matchedOn !== void 0 ? { matchedOn: [...evaluation.matchedOn] } : {},
33722
+ cooldown: "n/a",
33723
+ enqueued: 0,
33724
+ targetIds: [],
33725
+ heldForSummary: true
33726
+ });
33727
+ anyMatched = true;
33728
+ continue;
33729
+ }
31967
33730
  this.sceneGateReported.delete(rule.id);
31968
33731
  this.runRuleActions(rule, subject, kind, controls);
31969
33732
  const admission = this.admitToGroup(rule, subject, now);
@@ -32155,6 +33918,31 @@ var NotificationCenter = class NotificationCenter {
32155
33918
  return report({ candidates: candidates.length });
32156
33919
  }
32157
33920
  /**
33921
+ * Enqueue ONE state summary (D647) through the rule's ordinary rows: its
33922
+ * targets, its users (admins only — the row files under the system device),
33923
+ * its template, and the drain's snooze gate and retries. The record id is
33924
+ * the dedup key, so a window is enqueued once whatever the restarts.
33925
+ */
33926
+ async enqueueStateSummary(rule, subject) {
33927
+ const appended = withSummaryBody(rule);
33928
+ if (appended !== null) this.logger.warn("state summary rule body names no summary content — {{summaryText}} appended", { meta: {
33929
+ ruleId: rule.id,
33930
+ rule: rule.name
33931
+ } });
33932
+ const sent = appended ?? rule;
33933
+ const userTargets = await this.resolveUserTargets(sent, subject.deviceId);
33934
+ const entries = this.buildEntries(sent, subject, "system-event", ["systemEvent"], userTargets, "system");
33935
+ if (entries.length === 0) {
33936
+ this.logger.warn("state summary reached NO target — its users are not admins, and it has no targets", { meta: {
33937
+ ruleId: rule.id,
33938
+ rule: rule.name,
33939
+ targetUsers: rule.targetUsers ?? []
33940
+ } });
33941
+ return 0;
33942
+ }
33943
+ return this.outbox.enqueue(entries);
33944
+ }
33945
+ /**
32158
33946
  * The rules a trigger may fire, which is `listEnabled(delivery)` plus and
32159
33947
  * minus exactly one thing: `waitForEnhancement`.
32160
33948
  *
@@ -32897,6 +34685,9 @@ var NotificationCenter = class NotificationCenter {
32897
34685
  async reloadRules() {
32898
34686
  await this.rules.load();
32899
34687
  if (this.evaluationActive) this.deviceAlerts.tick();
34688
+ if (this.evaluationActive) this.stateSummary.tick().catch((err) => {
34689
+ this.logger.warn("state summary tick failed", { meta: { error: String(err) } });
34690
+ });
32900
34691
  await this.timelapseRules.load();
32901
34692
  await this.summaryRules.load();
32902
34693
  await this.ruleAi.load();
@@ -47254,6 +49045,16 @@ var FrameProcessor = class {
47254
49045
  };
47255
49046
  }
47256
49047
  };
49048
+ //#endregion
49049
+ //#region src/pipeline-analytics/pipeline/track-provenance.ts
49050
+ function trackProvenanceOf(frame) {
49051
+ if (!(frame.width > 0 && frame.height > 0)) return void 0;
49052
+ return {
49053
+ ...frame.rootModelId !== void 0 && frame.rootModelId.length > 0 ? { modelId: frame.rootModelId } : {},
49054
+ frameWidth: frame.width,
49055
+ frameHeight: frame.height
49056
+ };
49057
+ }
47257
49058
  /** Bounded trackId → pending-upgrade map. Insertion-ordered, so the cap
47258
49059
  * evicts the oldest record. */
47259
49060
  var PendingPreviewUpgrades = class {
@@ -50075,51 +51876,6 @@ var PlateGalleryProvider = class {
50075
51876
  }
50076
51877
  };
50077
51878
  //#endregion
50078
- //#region src/pipeline-analytics/replay/frame-processor-pass.ts
50079
- /**
50080
- * Run a FRESH `FrameProcessor` over a fixed, ordered sequence of frames and
50081
- * return the tracks it produced.
50082
- *
50083
- * No `TrackStore`, no bus, no media — a track here is a summary built
50084
- * ENTIRELY from `FrameProcessor.process()`'s in-memory return value.
50085
- */
50086
- function runFrameProcessorPass(input) {
50087
- const processor = new FrameProcessor(input.deviceId, {}, {}, {}, input.source);
50088
- if (input.zones !== void 0) processor.setZones(input.zones);
50089
- if (input.detectionRules !== void 0) processor.setDetectionRules(input.detectionRules);
50090
- if (input.zoneMembershipMinOverlap !== void 0) processor.setZoneMembershipMinOverlap(input.zoneMembershipMinOverlap);
50091
- const accum = /* @__PURE__ */ new Map();
50092
- for (const { timestamp, frame } of input.frames) {
50093
- const result = processor.process({
50094
- timestamp,
50095
- frame
50096
- });
50097
- for (const td of result.tracked) {
50098
- const existing = accum.get(td.trackId);
50099
- const matched = td.matchedThisFrame ? 1 : 0;
50100
- if (existing === void 0) accum.set(td.trackId, {
50101
- className: td.className,
50102
- firstSeenMs: timestamp,
50103
- lastSeenMs: timestamp,
50104
- bbox: { ...td.bbox },
50105
- framesMatched: matched
50106
- });
50107
- else {
50108
- existing.className = td.className;
50109
- existing.lastSeenMs = timestamp;
50110
- existing.framesMatched += matched;
50111
- }
50112
- }
50113
- }
50114
- return { tracks: [...accum.values()].map((t) => ({
50115
- className: t.className,
50116
- firstSeenMs: t.firstSeenMs,
50117
- lastSeenMs: t.lastSeenMs,
50118
- bbox: t.bbox,
50119
- framesMatched: t.framesMatched
50120
- })) };
50121
- }
50122
- //#endregion
50123
51879
  //#region src/pipeline-analytics/retention/disk-reconcile.ts
50124
51880
  /** Is this an event-owned media row — `motion` / `object` / `audio` (D476;
50125
51881
  * `event` used to cover all three under one owner kind). */
@@ -52390,6 +54146,10 @@ var TRACKS_COLUMNS = [
52390
54146
  name: "frameHeight",
52391
54147
  type: "INTEGER"
52392
54148
  },
54149
+ {
54150
+ name: "modelId",
54151
+ type: "TEXT"
54152
+ },
52393
54153
  {
52394
54154
  name: "closedReason",
52395
54155
  type: "TEXT"
@@ -52554,7 +54314,12 @@ function cloneTrack(t) {
52554
54314
  ...t.hasFace !== void 0 ? { hasFace: t.hasFace } : {},
52555
54315
  ...t.hasEmbeddedFace !== void 0 ? { hasEmbeddedFace: t.hasEmbeddedFace } : {},
52556
54316
  ...t.hasRider !== void 0 ? { hasRider: t.hasRider } : {},
52557
- ...t.closedReason !== void 0 ? { closedReason: t.closedReason } : {}
54317
+ ...t.closedReason !== void 0 ? { closedReason: t.closedReason } : {},
54318
+ ...t.provenance?.modelId !== void 0 ? { modelId: t.provenance.modelId } : {},
54319
+ ...t.provenance !== void 0 ? {
54320
+ frameWidth: t.provenance.frameWidth,
54321
+ frameHeight: t.provenance.frameHeight
54322
+ } : {}
52558
54323
  };
52559
54324
  }
52560
54325
  var TrackStore = class {
@@ -52651,7 +54416,8 @@ var TrackStore = class {
52651
54416
  totalDistance: 0,
52652
54417
  state: params.state,
52653
54418
  active: true,
52654
- lastSnapshotAt: 0
54419
+ lastSnapshotAt: 0,
54420
+ ...params.provenance !== void 0 ? { provenance: params.provenance } : {}
52655
54421
  };
52656
54422
  this.active.set(params.trackId, fresh);
52657
54423
  return fresh;
@@ -54112,7 +55878,10 @@ var TrackStore = class {
54112
55878
  });
54113
55879
  }
54114
55880
  async persistCompleted(t) {
54115
- const dims = this.frameDims?.(t.deviceId);
55881
+ const dims = t.frameWidth !== void 0 && t.frameHeight !== void 0 ? {
55882
+ w: t.frameWidth,
55883
+ h: t.frameHeight
55884
+ } : this.frameDims?.(t.deviceId);
54116
55885
  const envelope = computeTrackEnvelope(t.positions, dims?.w, dims?.h);
54117
55886
  await this.store.set.mutate({
54118
55887
  collection: TRACKS_COLLECTION,
@@ -54148,14 +55917,17 @@ var TrackStore = class {
54148
55917
  ...t.hasEmbeddedFace !== void 0 ? { hasEmbeddedFace: t.hasEmbeddedFace } : {},
54149
55918
  ...t.hasRider !== void 0 ? { hasRider: t.hasRider } : {},
54150
55919
  ...t.closedReason !== void 0 ? { closedReason: t.closedReason } : {},
54151
- ...envelope !== null && dims !== void 0 ? {
55920
+ ...envelope !== null ? {
54152
55921
  envMinX: envelope.minX,
54153
55922
  envMinY: envelope.minY,
54154
55923
  envMaxX: envelope.maxX,
54155
- envMaxY: envelope.maxY,
55924
+ envMaxY: envelope.maxY
55925
+ } : {},
55926
+ ...dims !== void 0 ? {
54156
55927
  frameWidth: dims.w,
54157
55928
  frameHeight: dims.h
54158
- } : {}
55929
+ } : {},
55930
+ ...t.modelId !== void 0 ? { modelId: t.modelId } : {}
54159
55931
  }
54160
55932
  });
54161
55933
  }
@@ -54215,6 +55987,9 @@ var TrackStore = class {
54215
55987
  /** Free-form composed token line (see `composePreviewMissReason`) — a
54216
55988
  * string read, absent stays absent. */
54217
55989
  const previewMissReason = data["previewMissReason"];
55990
+ const modelId = data["modelId"];
55991
+ const frameWidth = data["frameWidth"];
55992
+ const frameHeight = data["frameHeight"];
54218
55993
  const envMinX = data["envMinX"];
54219
55994
  const envMinY = data["envMinY"];
54220
55995
  const envMaxX = data["envMaxX"];
@@ -54258,7 +56033,12 @@ var TrackStore = class {
54258
56033
  ...closedReason !== void 0 ? { closedReason } : {},
54259
56034
  ...typeof previewMissReason === "string" && previewMissReason.length > 0 ? { previewMissReason } : {},
54260
56035
  ...observations !== void 0 ? { observations } : {},
54261
- ...envelope !== null ? { envelope } : {}
56036
+ ...envelope !== null ? { envelope } : {},
56037
+ ...typeof modelId === "string" && modelId.length > 0 ? { modelId } : {},
56038
+ ...typeof frameWidth === "number" && typeof frameHeight === "number" ? {
56039
+ frameWidth,
56040
+ frameHeight
56041
+ } : {}
54262
56042
  });
54263
56043
  }
54264
56044
  };
@@ -59660,7 +61440,6 @@ var DEFAULT_EMBEDDING_CONFIG = {
59660
61440
  enabled: true,
59661
61441
  modelId: "clip-vit-b32",
59662
61442
  agentId: "local",
59663
- runtime: "node",
59664
61443
  backend: "cpu",
59665
61444
  classes: [],
59666
61445
  minConfidence: .5,
@@ -77598,6 +79377,9 @@ var NC_SYSTEM_EVENT_CATEGORIES = [
77598
79377
  EventCategory.PipelineNodeInferenceUnavailable,
77599
79378
  EventCategory.PipelineDetectionBlind,
77600
79379
  EventCategory.AddonRunnerFailed,
79380
+ EventCategory.AddonStarted,
79381
+ EventCategory.AddonRestarted,
79382
+ EventCategory.PipelineCameraAssigned,
77601
79383
  EventCategory.UpdateAvailable,
77602
79384
  EventCategory.AddonUpdated,
77603
79385
  EventCategory.SystemRestartCompleted,
@@ -87220,6 +89002,7 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
87220
89002
  };
87221
89003
  },
87222
89004
  isDeviceDeleted: async (deviceId) => await api.deviceManager.getDevice.query({ deviceId }) === null,
89005
+ readNodeDirectory: () => api.nodes.directory.query(),
87223
89006
  getSnapshot: (deviceId) => api.snapshot.getSnapshot.query({ deviceId }),
87224
89007
  ...buildLinkedSnapshotReads(api.snapshot),
87225
89008
  identityIdsByName: async () => {
@@ -87946,21 +89729,6 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
87946
89729
  ...input.nodeId !== void 0 ? { nodeId: input.nodeId } : {}
87947
89730
  });
87948
89731
  }
87949
- /**
87950
- * The FrameProcessor pass of a replay run — see the cap doc on
87951
- * `runReplayFrameProcessor` for why this is not the `processFrame` method
87952
- * this addon deliberately does not have.
87953
- *
87954
- * `runFrameProcessorPass` is pure w.r.t. this addon's own state: it
87955
- * constructs a FRESH `FrameProcessor` and does not touch `this.trackStore`,
87956
- * `this.stationaryRegistry` or any other instance field — unlike
87957
- * `getOrCreateProcessor` (the production path), which wires both.
87958
- * `scripts/check-replay-isolation.ts` holds that boundary on the
87959
- * `replay/` directory this delegates into.
87960
- */
87961
- async runReplayFrameProcessor(input) {
87962
- return runFrameProcessorPass(input);
87963
- }
87964
89732
  async listRetrainAnnotations(input) {
87965
89733
  return this.requireRetrain().listAnnotations(input.trackId);
87966
89734
  }
@@ -89066,6 +90834,7 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
89066
90834
  });
89067
90835
  const currentTrackIds = /* @__PURE__ */ new Set();
89068
90836
  const positionsCountById = /* @__PURE__ */ new Map();
90837
+ const provenance = trackProvenanceOf(frame);
89069
90838
  for (const t of result.tracked) {
89070
90839
  currentTrackIds.add(t.trackId);
89071
90840
  if (this.suppressedBirths.isRejected(key, t.trackId)) continue;
@@ -89089,7 +90858,8 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
89089
90858
  score: t.confidence
89090
90859
  },
89091
90860
  zones: t.zones,
89092
- state: t.state
90861
+ state: t.state,
90862
+ ...provenance !== void 0 ? { provenance } : {}
89093
90863
  });
89094
90864
  positionsCountById.set(t.trackId, upserted.positions.length);
89095
90865
  }