@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.
@@ -2,7 +2,7 @@ Object.defineProperties(exports, {
2
2
  __esModule: { value: true },
3
3
  [Symbol.toStringTag]: { value: "Module" }
4
4
  });
5
- const require_dist = require("../dist-Bpx5JMso.js");
5
+ const require_dist = require("../dist-ITESpHou.js");
6
6
  let node_fs = require("node:fs");
7
7
  node_fs = require_dist.__toESM(node_fs, 1);
8
8
  let node_path = require("node:path");
@@ -4104,7 +4104,7 @@ function mimeFromExtension(file) {
4104
4104
  * A value that is negative, NaN or absent falls back to the shipped default:
4105
4105
  * an unreadable window must not become a window nobody chose.
4106
4106
  */
4107
- var DAY_MS$2 = 24 * 36e5;
4107
+ var DAY_MS$4 = 24 * 36e5;
4108
4108
  /**
4109
4109
  * The shipped windows. Each one is the value that was hardcoded before this
4110
4110
  * module existed, except the mosaic — see {@link PRUNE_SETTING_KEYS}.
@@ -4150,8 +4150,8 @@ var PRUNE_SETTING_KEYS = [
4150
4150
  */
4151
4151
  function windowMs(raw, defaultDays) {
4152
4152
  if (raw === 0) return null;
4153
- if (typeof raw !== "number" || !Number.isFinite(raw) || raw < 0) return defaultDays * DAY_MS$2;
4154
- return raw * DAY_MS$2;
4153
+ if (typeof raw !== "number" || !Number.isFinite(raw) || raw < 0) return defaultDays * DAY_MS$4;
4154
+ return raw * DAY_MS$4;
4155
4155
  }
4156
4156
  function resolvePruneWindows(settings) {
4157
4157
  return {
@@ -8262,6 +8262,19 @@ var NcAlarmLiveReadLog = class {
8262
8262
  };
8263
8263
  //#endregion
8264
8264
  //#region src/notification-center/alerts/alert-episode.ts
8265
+ /** The row with `detail` set (or removed). Unchanged rows are returned as is. */
8266
+ function withAlertDetail(row, detail) {
8267
+ if (row.detail === detail) return row;
8268
+ const { subject, alerting, since, lastNotifiedAt, reminders } = row;
8269
+ return {
8270
+ subject,
8271
+ alerting,
8272
+ since,
8273
+ lastNotifiedAt,
8274
+ reminders,
8275
+ ...detail !== void 0 ? { detail } : {}
8276
+ };
8277
+ }
8265
8278
  function decideAlert(input) {
8266
8279
  const previous = input.previous;
8267
8280
  if (previous === void 0) return input.alerting ? { kind: "raise" } : { kind: "seeded" };
@@ -8405,6 +8418,12 @@ var NC_ALERTS_COLUMNS = [
8405
8418
  {
8406
8419
  name: "deviceId",
8407
8420
  type: "INTEGER"
8421
+ }),
8422
+ (
8423
+ /** What the open episode is — a tank's band (D647 review M6). Nullable, added in place. */
8424
+ {
8425
+ name: "detail",
8426
+ type: "TEXT"
8408
8427
  })
8409
8428
  ];
8410
8429
  var NC_ALERTS_INDEXES = [{
@@ -8427,7 +8446,8 @@ var NC_ALERTS_SPEC = {
8427
8446
  since: row.since,
8428
8447
  lastNotifiedAt: row.lastNotifiedAt,
8429
8448
  reminders: row.reminders,
8430
- ...deviceIdOf(row.subject) !== void 0 ? { deviceId: deviceIdOf(row.subject) } : {}
8449
+ ...deviceIdOf(row.subject) !== void 0 ? { deviceId: deviceIdOf(row.subject) } : {},
8450
+ ...row.detail !== void 0 ? { detail: row.detail } : {}
8431
8451
  }),
8432
8452
  fromRecord: (key, data) => {
8433
8453
  const since = data["since"];
@@ -8436,12 +8456,14 @@ var NC_ALERTS_SPEC = {
8436
8456
  if (typeof since !== "number") return null;
8437
8457
  if (typeof lastNotifiedAt !== "number") return null;
8438
8458
  if (typeof reminders !== "number") return null;
8459
+ const detail = data["detail"];
8439
8460
  return {
8440
8461
  subject: key,
8441
8462
  alerting: data["alerting"] === 1 || data["alerting"] === true,
8442
8463
  since,
8443
8464
  lastNotifiedAt,
8444
- reminders
8465
+ reminders,
8466
+ ...typeof detail === "string" ? { detail } : {}
8445
8467
  };
8446
8468
  },
8447
8469
  equalFact: (held, incoming) => held.alerting === incoming.alerting,
@@ -8474,7 +8496,8 @@ var NcAlertStore = class {
8474
8496
  this.ledger.put(row).catch(() => void 0);
8475
8497
  },
8476
8498
  all: () => this.ledger.snapshot(),
8477
- remove: (subject) => this.ledger.forget(subject)
8499
+ remove: (subject) => this.ledger.forget(subject),
8500
+ answered: () => this.ledger.hasAnswered()
8478
8501
  };
8479
8502
  }
8480
8503
  };
@@ -11922,6 +11945,80 @@ function vacuumTanksOf(data) {
11922
11945
  };
11923
11946
  }
11924
11947
  //#endregion
11948
+ //#region src/notification-center/alerts/open-alert.ts
11949
+ /**
11950
+ * The raise of an episode whose reading has not arrived since the restart —
11951
+ * worded with what the row KNOWS: no invented percentage (the binary battery
11952
+ * sentence), the item key as its name, and a tank kind that only the filters
11953
+ * read (the summary words an unmeasured tank without a band).
11954
+ */
11955
+ function unmeasuredEpisode(input) {
11956
+ const deviceId = alertSubjectDeviceId(input.subject);
11957
+ if (deviceId === void 0) return null;
11958
+ const identity = input.lookup(deviceId);
11959
+ const facts = input.facts;
11960
+ if (input.family === "battery") return incomingFromBatteryBand({
11961
+ facts,
11962
+ deviceId,
11963
+ band: "low",
11964
+ reading: {
11965
+ percentage: 0,
11966
+ lastUpdated: 0,
11967
+ binary: true
11968
+ },
11969
+ identity
11970
+ });
11971
+ const parts = parseConsumableSubject(input.subject);
11972
+ if (input.family === "consumable" && parts !== null) return incomingFromConsumableBand({
11973
+ facts,
11974
+ deviceId,
11975
+ band: "low",
11976
+ item: {
11977
+ key: parts.key,
11978
+ label: parts.key,
11979
+ level: null,
11980
+ status: "replace"
11981
+ },
11982
+ thresholdPercent: parts.thresholdPercent,
11983
+ identity
11984
+ });
11985
+ const tank = NC_TANK_KEYS.find((key) => input.subject === `tank:${String(deviceId)}:${key}`);
11986
+ if (tank === void 0) return null;
11987
+ return incomingFromTankBand({
11988
+ facts,
11989
+ deviceId,
11990
+ band: input.detail === "full" ? "full" : "low",
11991
+ tank,
11992
+ identity
11993
+ });
11994
+ }
11995
+ /**
11996
+ * One alerting row as an open episode, or `null` when it cannot be worded (the
11997
+ * caller logs it). A legacy consumable row is skipped: it is adopted or
11998
+ * retired first. A tank whose band the row remembers is worded by it
11999
+ * (review M6), even without a reading.
12000
+ */
12001
+ function openAlertOf(row, input) {
12002
+ const parts = parseConsumableSubject(row.subject);
12003
+ if (parts?.legacy === true) return null;
12004
+ const family = parts !== null ? "consumable" : row.subject.startsWith("tank:") ? "tank" : "battery";
12005
+ const subject = (input.restated ?? unmeasuredEpisode({
12006
+ subject: row.subject,
12007
+ family,
12008
+ facts: input.facts,
12009
+ lookup: input.lookup,
12010
+ ...row.detail !== void 0 ? { detail: row.detail } : {}
12011
+ }))?.subject.systemEvent;
12012
+ if (subject === void 0) return null;
12013
+ const measured = input.restated !== null || family === "tank" && row.detail !== void 0;
12014
+ return {
12015
+ family,
12016
+ since: row.since,
12017
+ subject,
12018
+ measured
12019
+ };
12020
+ }
12021
+ //#endregion
11925
12022
  //#region src/notification-center/alerts/battery-band.ts
11926
12023
  /**
11927
12024
  * The stand-in a provider writes for a binary NORMAL. Anything below it, on a
@@ -12129,7 +12226,7 @@ var NcDeviceAlerts = class {
12129
12226
  band: alerting ? band : "normal",
12130
12227
  tank: tank.key,
12131
12228
  identity: this.deps.lookup(deviceId)
12132
- }));
12229
+ }), band === "normal" ? void 0 : band);
12133
12230
  }
12134
12231
  /**
12135
12232
  * Has this tank band held long enough to move its episode? A band that
@@ -12247,6 +12344,37 @@ var NcDeviceAlerts = class {
12247
12344
  }
12248
12345
  }
12249
12346
  /**
12347
+ * Every OPEN episode, restated (D647). `null` while the rows have not been
12348
+ * read. A legacy consumable row is skipped (it is adopted or retired first),
12349
+ * and so is a tank whose band is unknown and whose device the summary could
12350
+ * not word — never silently: each skip is a line.
12351
+ */
12352
+ openEpisodes() {
12353
+ if (this.deps.rows.answered?.() === false) return null;
12354
+ const facts = {
12355
+ eventId: "state-summary",
12356
+ timestamp: this.now(),
12357
+ category: "state-summary",
12358
+ data: {}
12359
+ };
12360
+ const out = [];
12361
+ for (const row of this.deps.rows.all()) {
12362
+ if (!row.alerting) continue;
12363
+ const restated = this.restatementOf(row.subject);
12364
+ const open = openAlertOf(row, {
12365
+ facts,
12366
+ restated: restated === null ? null : restated(facts),
12367
+ lookup: this.deps.lookup
12368
+ });
12369
+ if (open !== null) out.push(open);
12370
+ else this.logger()?.debug("open alert left out of the state summary — it cannot be worded", {
12371
+ ...tagsOf(row.subject),
12372
+ meta: { subject: row.subject }
12373
+ });
12374
+ }
12375
+ return out;
12376
+ }
12377
+ /**
12250
12378
  * Every device that holds an alert row — what the deleted-device reconcile
12251
12379
  * must look at besides the devices a rule names (a battery device no rule
12252
12380
  * names still has a row).
@@ -12388,7 +12516,7 @@ var NcDeviceAlerts = class {
12388
12516
  * a state we never recorded leaving is a state we can never announce
12389
12517
  * entering again.
12390
12518
  */
12391
- apply(subject, alerting, settings, build) {
12519
+ apply(subject, alerting, settings, build, detail) {
12392
12520
  const nowMs = this.now();
12393
12521
  const previous = this.deps.rows.get(subject);
12394
12522
  const verdict = decideAlert({
@@ -12398,7 +12526,7 @@ var NcDeviceAlerts = class {
12398
12526
  renotifyMs: settings.renotifyMinutes * 6e4,
12399
12527
  notifyRecovery: settings.notifyRecovery
12400
12528
  });
12401
- const next = nextAlertRow(subject, previous, alerting, verdict, nowMs);
12529
+ const next = withAlertDetail(nextAlertRow(subject, previous, alerting, verdict, nowMs), alerting ? detail ?? previous?.detail : void 0);
12402
12530
  if (next !== previous) this.deps.rows.put(next);
12403
12531
  if (!speaks(verdict)) {
12404
12532
  this.report(subject, verdict, alerting);
@@ -13033,7 +13161,31 @@ var en_default = {
13033
13161
  },
13034
13162
  "summary.body.detections": "{{detectionSummary}}",
13035
13163
  "summary.body.truncated": "showing the {{shown}} most relevant of {{total}}",
13036
- "summary.body.empty": "nothing was seen in this window"
13164
+ "summary.body.empty": "nothing was seen in this window",
13165
+ "system.state-summary.title": "{{rule}}",
13166
+ "system.state-summary.body": "{{summaryText}}",
13167
+ "summary.section.device-state": "Devices offline or switched off",
13168
+ "summary.section.streams": "Streams offline",
13169
+ "summary.section.battery": "Low batteries",
13170
+ "summary.section.consumables": "Consumables to replace",
13171
+ "summary.section.tanks": "Tanks",
13172
+ "summary.section.detection-blind": "Cameras detecting nothing",
13173
+ "summary.section.nodes": "Nodes",
13174
+ "summary.section.addons": "Addons",
13175
+ "summary.section.updates": "Server and container updates",
13176
+ "summary.section.unavailable": "{{section}}: unavailable — the state could not be read",
13177
+ "summary.line.device-offline": "{{deviceName}} — offline (since {{since}})",
13178
+ "summary.line.device-disabled": "{{deviceName}} — switched off (since {{since}})",
13179
+ "summary.line.stream-offline": "{{deviceName}} — stream offline (since {{since}})",
13180
+ "summary.line.stream-offline.profiled": "{{deviceName}} — {{profile}} stream offline (since {{since}})",
13181
+ "summary.line.detection-blind": "{{deviceName}} — detecting nothing (since {{since}})",
13182
+ "summary.line.node-offline": "{{nodeId}} — offline (since {{since}})",
13183
+ "summary.line.node-inference-unavailable": "{{nodeId}} — cannot run inference (since {{since}})",
13184
+ "summary.line.addon-crash-loop": "{{nodeId}} — {{addons}} stopped after repeated crashes (since {{since}})",
13185
+ "summary.line.update": "{{packageName}} {{currentVersion}} → {{latestVersion}}",
13186
+ "summary.line.update.onNode": "{{packageName}} {{currentVersion}} → {{latestVersion}} on {{nodeId}}",
13187
+ "summary.line.wrapper-update": "Container {{currentVersion}} → {{latestVersion}}",
13188
+ "summary.line.tank-unread": "{{deviceName}} — the {{tank}} tank needs attention"
13037
13189
  };
13038
13190
  //#endregion
13039
13191
  //#region src/notification-center/texts/it.json
@@ -13337,7 +13489,31 @@ var it_default = {
13337
13489
  },
13338
13490
  "summary.body.detections": "{{detectionSummary}}",
13339
13491
  "summary.body.truncated": "mostrati i {{shown}} più rilevanti su {{total}}",
13340
- "summary.body.empty": "nessun evento in questa finestra"
13492
+ "summary.body.empty": "nessun evento in questa finestra",
13493
+ "system.state-summary.title": "{{rule}}",
13494
+ "system.state-summary.body": "{{summaryText}}",
13495
+ "summary.section.device-state": "Dispositivi offline o spenti",
13496
+ "summary.section.streams": "Stream offline",
13497
+ "summary.section.battery": "Batterie scariche",
13498
+ "summary.section.consumables": "Consumabili da sostituire",
13499
+ "summary.section.tanks": "Serbatoi",
13500
+ "summary.section.detection-blind": "Camere che non rilevano nulla",
13501
+ "summary.section.nodes": "Nodi",
13502
+ "summary.section.addons": "Addon",
13503
+ "summary.section.updates": "Aggiornamenti di server e container",
13504
+ "summary.section.unavailable": "{{section}}: non disponibile — lo stato non è leggibile",
13505
+ "summary.line.device-offline": "{{deviceName}} — offline (da {{since}})",
13506
+ "summary.line.device-disabled": "{{deviceName}} — spento (da {{since}})",
13507
+ "summary.line.stream-offline": "{{deviceName}} — stream offline (da {{since}})",
13508
+ "summary.line.stream-offline.profiled": "{{deviceName}} — stream {{profile}} offline (da {{since}})",
13509
+ "summary.line.detection-blind": "{{deviceName}} — non rileva nulla (da {{since}})",
13510
+ "summary.line.node-offline": "{{nodeId}} — offline (da {{since}})",
13511
+ "summary.line.node-inference-unavailable": "{{nodeId}} — non può eseguire l'inferenza (da {{since}})",
13512
+ "summary.line.addon-crash-loop": "{{nodeId}} — {{addons}} fermati dopo crash ripetuti (da {{since}})",
13513
+ "summary.line.update": "{{packageName}} {{currentVersion}} → {{latestVersion}}",
13514
+ "summary.line.update.onNode": "{{packageName}} {{currentVersion}} → {{latestVersion}} su {{nodeId}}",
13515
+ "summary.line.wrapper-update": "Container {{currentVersion}} → {{latestVersion}}",
13516
+ "summary.line.tank-unread": "{{deviceName}} — il serbatoio {{tank}} richiede attenzione"
13341
13517
  };
13342
13518
  //#endregion
13343
13519
  //#region src/notification-center/text-catalog.ts
@@ -13755,7 +13931,7 @@ function assertPatchKeepsFamily(existing, patch) {
13755
13931
  * the offending kind or filter). Then rethrow. A refused save is dropped work,
13756
13932
  * and dropped work is never silent. Any other error passes through unlogged.
13757
13933
  */
13758
- function logRefusal$2(check, ctx) {
13934
+ function logRefusal$3(check, ctx) {
13759
13935
  try {
13760
13936
  check();
13761
13937
  } catch (err) {
@@ -13771,11 +13947,11 @@ function logRefusal$2(check, ctx) {
13771
13947
  }
13772
13948
  /** {@link assertRuleFamily}, logged. The create path of both routes. */
13773
13949
  function guardRuleFamily(rule, ctx) {
13774
- logRefusal$2(() => assertRuleFamily(rule), ctx);
13950
+ logRefusal$3(() => assertRuleFamily(rule), ctx);
13775
13951
  }
13776
13952
  /** {@link assertPatchKeepsFamily}, logged. The update path of both routes. */
13777
13953
  function guardPatchFamily(existing, patch, ctx) {
13778
- logRefusal$2(() => assertPatchKeepsFamily(existing, patch), ctx);
13954
+ logRefusal$3(() => assertPatchKeepsFamily(existing, patch), ctx);
13779
13955
  }
13780
13956
  //#endregion
13781
13957
  //#region src/notification-center/device-scope-guard.ts
@@ -13800,7 +13976,7 @@ function assertDevicesExcludeDelivery(rule) {
13800
13976
  throw new NcDevicesExcludeRefusal(rule.delivery, excluded);
13801
13977
  }
13802
13978
  /** Run the check; a refusal is dropped work, so it is logged before it is rethrown. */
13803
- function logRefusal$1(check, ctx) {
13979
+ function logRefusal$2(check, ctx) {
13804
13980
  try {
13805
13981
  check();
13806
13982
  } catch (err) {
@@ -13818,7 +13994,7 @@ function warnRefusal$1(logger, err, ctx) {
13818
13994
  }
13819
13995
  /** The create path of both routes. */
13820
13996
  function guardRuleDevicesExclude(rule, ctx) {
13821
- logRefusal$1(() => assertDevicesExcludeDelivery(rule), ctx);
13997
+ logRefusal$2(() => assertDevicesExcludeDelivery(rule), ctx);
13822
13998
  }
13823
13999
  /**
13824
14000
  * The update path of both routes: the MERGED rule is judged, and only when the
@@ -13828,7 +14004,86 @@ function guardPatchDevicesExclude(existing, patch, ctx) {
13828
14004
  if (existing === null) return;
13829
14005
  const merged = mergedFamilySubject(existing, patch);
13830
14006
  if (merged === null) return;
13831
- logRefusal$1(() => assertDevicesExcludeDelivery(merged), ctx);
14007
+ logRefusal$2(() => assertDevicesExcludeDelivery(merged), ctx);
14008
+ }
14009
+ //#endregion
14010
+ //#region src/notification-center/rule-summary-guard.ts
14011
+ /**
14012
+ * The hub's half of D647: `systemEventDelivery: { mode: 'summary' }` belongs to
14013
+ * a `device-events` or `system` rule, and nowhere else — the alarm stays
14014
+ * instant. Refused, loudly, on both save routes (the cap provider and the
14015
+ * `nc.*Rule` bridge), beside the D637 family guard.
14016
+ *
14017
+ * The rule is judged by `systemEventDeliveryProblems` (`@camstack/types`), the
14018
+ * one statement both editors read too. On an update it is the MERGED rule —
14019
+ * merged the way the store merges it, `clear` included — and only when the
14020
+ * patch changes something the verdict reads: a rename of a stored rule is
14021
+ * never refused for what it already was.
14022
+ *
14023
+ * Not in Zod, for the reason `rule-family-guard.ts` gives: a cross-field refine
14024
+ * would also run when the store LOADS a rule.
14025
+ */
14026
+ var NcSystemEventDeliveryRefusal = class extends Error {
14027
+ problems;
14028
+ constructor(problems) {
14029
+ super(`rule refused: ${problems.map(require_dist.describeSystemEventDeliveryProblem).join("; ")}`);
14030
+ this.name = "NcSystemEventDeliveryRefusal";
14031
+ this.problems = problems;
14032
+ }
14033
+ };
14034
+ function assertSystemEventDelivery(rule) {
14035
+ const problems = require_dist.systemEventDeliveryProblems(rule);
14036
+ if (problems.length > 0) throw new NcSystemEventDeliveryRefusal(problems);
14037
+ }
14038
+ var JUDGED_KEYS = [
14039
+ "delivery",
14040
+ "conditions",
14041
+ "systemEventDelivery",
14042
+ "schedule",
14043
+ "actions"
14044
+ ];
14045
+ /**
14046
+ * The rule the patch WOULD produce, or `null` when it changes nothing the
14047
+ * verdict reads. `clear` removes `schedule` / `actions` before the merge, as
14048
+ * the store does.
14049
+ */
14050
+ function mergedDeliverySubject(existing, patch, clear) {
14051
+ const merged = {
14052
+ delivery: existing.delivery,
14053
+ conditions: existing.conditions,
14054
+ ...existing.systemEventDelivery !== void 0 ? { systemEventDelivery: existing.systemEventDelivery } : {},
14055
+ ...existing.schedule !== void 0 && !clear.includes("schedule") ? { schedule: existing.schedule } : {},
14056
+ ...existing.actions !== void 0 && !clear.includes("actions") ? { actions: existing.actions } : {},
14057
+ ...patch.delivery !== void 0 ? { delivery: patch.delivery } : {},
14058
+ ...patch.conditions !== void 0 ? { conditions: patch.conditions } : {},
14059
+ ...patch.systemEventDelivery !== void 0 ? { systemEventDelivery: patch.systemEventDelivery } : {},
14060
+ ...patch.schedule !== void 0 ? { schedule: patch.schedule } : {},
14061
+ ...patch.actions !== void 0 ? { actions: patch.actions } : {}
14062
+ };
14063
+ return JUDGED_KEYS.some((key) => require_dist.canonicalHash(merged[key] ?? null) !== require_dist.canonicalHash(existing[key] ?? null)) ? merged : null;
14064
+ }
14065
+ function logRefusal$1(check, ctx) {
14066
+ try {
14067
+ check();
14068
+ } catch (err) {
14069
+ if (err instanceof NcSystemEventDeliveryRefusal) ctx.logger.warn("nc rule refused: summary delivery not allowed here", { meta: {
14070
+ ...ctx.ruleId !== void 0 ? { ruleId: ctx.ruleId } : {},
14071
+ by: ctx.by,
14072
+ problems: err.problems.map(require_dist.describeSystemEventDeliveryProblem)
14073
+ } });
14074
+ throw err;
14075
+ }
14076
+ }
14077
+ /** The create path of both routes. */
14078
+ function guardRuleSystemEventDelivery(rule, ctx) {
14079
+ logRefusal$1(() => assertSystemEventDelivery(rule), ctx);
14080
+ }
14081
+ /** The update path of both routes: the MERGED rule, when the patch changes what is judged. */
14082
+ function guardPatchSystemEventDelivery(existing, patch, clear, ctx) {
14083
+ if (existing === null) return;
14084
+ const merged = mergedDeliverySubject(existing, patch, clear);
14085
+ if (merged === null) return;
14086
+ logRefusal$1(() => assertSystemEventDelivery(merged), ctx);
13832
14087
  }
13833
14088
  //#endregion
13834
14089
  //#region src/notification-center/sensor-transition-guard.ts
@@ -16636,6 +16891,10 @@ function makeNcActionHandlers(deps) {
16636
16891
  logger: deps.logger,
16637
16892
  by: c.userId
16638
16893
  });
16894
+ guardRuleSystemEventDelivery(parsed, {
16895
+ logger: deps.logger,
16896
+ by: c.userId
16897
+ });
16639
16898
  await assertTargetsOwned(parsed.targets.map((t) => t.targetId), c);
16640
16899
  const rule = await deps.ruleStore.create({
16641
16900
  ...parsed,
@@ -16669,6 +16928,11 @@ function makeNcActionHandlers(deps) {
16669
16928
  by: c.userId,
16670
16929
  ruleId: input.ruleId
16671
16930
  });
16931
+ guardPatchSystemEventDelivery(existing, patch, clear, {
16932
+ logger: deps.logger,
16933
+ by: c.userId,
16934
+ ruleId: input.ruleId
16935
+ });
16672
16936
  if (patch.targets !== void 0) await assertTargetsOwned(patch.targets.map((t) => t.targetId), c);
16673
16937
  const { ownerUserId: _owner, disabledTargetIds: _optOut, ...safe } = patch;
16674
16938
  const rule = await deps.ruleStore.update(input.ruleId, safe, clear);
@@ -17094,6 +17358,87 @@ function makeNcActionHandlers(deps) {
17094
17358
  };
17095
17359
  }
17096
17360
  //#endregion
17361
+ //#region src/notification-center/state-summary/state-summary-delivery.ts
17362
+ /**
17363
+ * A state summary as an ORDINARY system-event subject (D647), so it reaches the
17364
+ * phone the way every system rule does: `buildEntries` → the durable outbox →
17365
+ * the drain's snooze gate, retries and per-target history → the dispatcher,
17366
+ * which renders the rule's own template with the summary's variables.
17367
+ *
17368
+ * - `recordId` is `state-summary:<ruleId>:<windowEndMs>` — the outbox dedup
17369
+ * key, so a restart can never enqueue one window twice.
17370
+ * - The row files under the system device (0): a summary spans devices.
17371
+ * `targetUsers` therefore reach admins only — a user whose grant is
17372
+ * narrower than the whole installation must not be sent the names of
17373
+ * devices they cannot open.
17374
+ * - The words are FROZEN at enqueue (`textParams`), like the digest's: the
17375
+ * state is the state at the window's end, not at a retry hours later. The
17376
+ * LANGUAGE is frozen with them.
17377
+ * - `kind` is the rule's first kind: the history row needs one, and no kind
17378
+ * is "the" event of a summary — `data.stateSummary` says what the row is,
17379
+ * and `{{eventKind}}` renders empty on it.
17380
+ */
17381
+ var NC_STATE_SUMMARY_TITLE_KEY = "system.state-summary.title";
17382
+ var NC_STATE_SUMMARY_BODY_KEY = "system.state-summary.body";
17383
+ function stateSummaryRecordId(ruleId, windowEndMs) {
17384
+ return `state-summary:${ruleId}:${String(windowEndMs)}`;
17385
+ }
17386
+ /** Is this system-event data the data of a state-summary row? */
17387
+ function isStateSummaryData(data) {
17388
+ return data["stateSummary"] === true;
17389
+ }
17390
+ /** `null` when the rule has no system-event condition (the guard refuses that). */
17391
+ function stateSummarySubject(input) {
17392
+ const kind = input.rule.conditions.systemEvent?.kinds[0];
17393
+ if (kind === void 0) return null;
17394
+ return {
17395
+ kind: "system-event",
17396
+ recordId: stateSummaryRecordId(input.rule.id, input.window.endMs),
17397
+ deviceId: 0,
17398
+ timestamp: input.window.endMs,
17399
+ classNames: [],
17400
+ zones: [],
17401
+ systemEvent: {
17402
+ kind,
17403
+ subject: `state-summary:${input.rule.id}`,
17404
+ titleKey: NC_STATE_SUMMARY_TITLE_KEY,
17405
+ bodyKey: NC_STATE_SUMMARY_BODY_KEY,
17406
+ textParams: {
17407
+ summaryText: input.text.text,
17408
+ summaryCount: String(input.text.count),
17409
+ ...input.text.sectionVars
17410
+ },
17411
+ textCount: input.text.count,
17412
+ data: {
17413
+ stateSummary: true,
17414
+ windowStartMs: input.window.startMs,
17415
+ windowEndMs: input.window.endMs,
17416
+ sections: input.summary.sections.map((section) => ({
17417
+ id: section.id,
17418
+ status: section.status,
17419
+ items: section.items.length
17420
+ }))
17421
+ }
17422
+ }
17423
+ };
17424
+ }
17425
+ /**
17426
+ * The rule as its summary is ENQUEUED: a custom body that carries none of the
17427
+ * summary's content gets `{{summaryText}}` appended on its own line, so an
17428
+ * operator's body never sends an empty summary (review M3). `null` = unchanged.
17429
+ */
17430
+ function withSummaryBody(rule) {
17431
+ const body = rule.template?.body;
17432
+ if (require_dist.templateCarriesSummary(body)) return null;
17433
+ return {
17434
+ ...rule,
17435
+ template: {
17436
+ ...rule.template,
17437
+ body: `${body ?? ""}\n{{summaryText}}`
17438
+ }
17439
+ };
17440
+ }
17441
+ //#endregion
17097
17442
  //#region src/notification-center/system-event-tap-url.ts
17098
17443
  /** The admin UI's own routes, as `packages/addon-admin-ui/src/App.tsx` declares them. */
17099
17444
  var ADMIN_ADDONS_PATH = "/system/addons";
@@ -17520,6 +17865,32 @@ function selectTriggerZoneCandidates(input) {
17520
17865
  //#endregion
17521
17866
  //#region src/notification-center/dispatcher.ts
17522
17867
  /**
17868
+ * NcDispatcher — turns a durable outbox entry into ONE canonical
17869
+ * `notification-output.send` RPC.
17870
+ *
17871
+ * Deps are injected tRPC-shaped closures (the wiring in
17872
+ * pipeline-analytics binds them to `ctx.api` / the media store), so the
17873
+ * dispatcher is unit-testable with fakes and stays extractable.
17874
+ *
17875
+ * Responsibilities:
17876
+ * - resolve the outbox entry's `targetId` against the live
17877
+ * `notification-output` target catalog (TTL-cached; the send call is
17878
+ * routed to the owning provider by the target's `addonId`);
17879
+ * - resolve the media attachment per the rule's policy from the media
17880
+ * store BY OWNER at SEND time — D-3 "best media AVAILABLE at the
17881
+ * moment": the drain runs after the persist moment, so late-landing
17882
+ * crops are picked up without ever delaying delivery;
17883
+ * - render the `{{var}}` template (title/body) over the frozen subject;
17884
+ * - map recognized per-target passthrough params onto canonical
17885
+ * Notification fields (level/sound/clickUrl/ttl) — everything else is
17886
+ * the degrade engine's job.
17887
+ *
17888
+ * Failure semantics: an UNKNOWN target (no longer in the catalog) ⇒ PERMANENT
17889
+ * failure (the outbox dead-letters the row — genuine misconfig); a globally
17890
+ * DISABLED target (operator kill-switch) ⇒ benign skip (no send, no dead row);
17891
+ * transport/backend errors ⇒ transient (retried with backoff).
17892
+ */
17893
+ /**
17523
17894
  * Where the model's sentence goes.
17524
17895
  *
17525
17896
  * Three cases, and the third is the one that would otherwise be reported as a
@@ -19087,7 +19458,7 @@ function buildTemplateVars(texts, entry, deviceName, zoneLabels, timeZone) {
19087
19458
  count: occupancy !== void 0 ? `${occupancy.count}` : group !== void 0 && group.members.length > 1 ? `${group.members.length}` : "",
19088
19459
  capacity: occupancy !== void 0 ? `${occupancy.capacity}` : "",
19089
19460
  op: occupancy !== void 0 ? ncOccupancyOp(texts, occupancy.occupied) : "",
19090
- eventKind: systemEvent?.kind ?? "",
19461
+ eventKind: systemEvent === void 0 || isStateSummaryData(systemEvent.data) ? "" : systemEvent.kind,
19091
19462
  node: systemEvent?.nodeId ?? "",
19092
19463
  package: systemEvent?.packageName ?? "",
19093
19464
  currentVersion: typeof systemEvent?.data["currentVersion"] === "string" ? systemEvent.data["currentVersion"] : "",
@@ -22108,6 +22479,10 @@ var NcLivenessLedger = class {
22108
22479
  async load() {
22109
22480
  return (await this.ledger.load()).length;
22110
22481
  }
22482
+ /** Has a load READ the store once? Until then the mirror is not the truth (D647 reads it). */
22483
+ hasAnswered() {
22484
+ return this.ledger.hasAnswered();
22485
+ }
22111
22486
  /**
22112
22487
  * Boot seed from the DEVICE DIRECTORY — the second half of the reseed, and
22113
22488
  * the fix for the outage this ledger used to swallow.
@@ -28434,6 +28809,1328 @@ var AddonUpdateCoalescer = class {
28434
28809
  }
28435
28810
  };
28436
28811
  //#endregion
28812
+ //#region src/notification-center/state-summary/state-sources.ts
28813
+ /**
28814
+ * Where each section of a STATE SUMMARY reads its state (D647) — the same
28815
+ * mirrors the instant raises read, and nothing else:
28816
+ *
28817
+ * | section | mirror |
28818
+ * | --- | --- |
28819
+ * | battery, consumables, tanks | the open alert episodes (`NcDeviceAlerts`, durable rows) |
28820
+ * | device-state | the liveness ledger (offline) + the system-state ledger (switched off) |
28821
+ * | streams, detection-blind, addons, updates | the system-state ledger |
28822
+ * | nodes | the liveness ledger (offline) + the system-state ledger (cannot infer) |
28823
+ *
28824
+ * Every source is RAM. A source that has not answered (its durable store never
28825
+ * read) makes the section UNAVAILABLE — never empty (D49/D315).
28826
+ *
28827
+ * A device offline for less than the liveness hold-off is left out: the instant
28828
+ * path would not have announced it yet either, and a summary must not report a
28829
+ * blip the rule itself would have stayed silent about.
28830
+ */
28831
+ var UNREAD_ALERTS = "the battery / consumable ledger has not been read yet";
28832
+ var UNREAD_LIVENESS = "the liveness ledger has not been read yet";
28833
+ var UNREAD_STATE = "the system-state ledger has not been read yet";
28834
+ var UNREAD_DEVICES = "the device directory has not answered yet";
28835
+ var UNREAD_NODES = "the node directory did not answer completely";
28836
+ /** Sections whose items name a node: the node directory must have answered. */
28837
+ var NODE_SECTIONS = [
28838
+ "nodes",
28839
+ "addons",
28840
+ "updates"
28841
+ ];
28842
+ function deviceParams(deviceId, identity) {
28843
+ const name = identity?.name ?? "";
28844
+ return {
28845
+ deviceId: String(deviceId),
28846
+ deviceName: name.length > 0 ? name : String(deviceId)
28847
+ };
28848
+ }
28849
+ function alertItem(section, open) {
28850
+ const params = open.subject.textParams ?? {};
28851
+ const unreadTank = open.family === "tank" && !open.measured;
28852
+ return {
28853
+ section,
28854
+ subject: open.subject,
28855
+ lineKey: unreadTank ? "summary.line.tank-unread" : open.subject.bodyKey ?? "",
28856
+ params,
28857
+ since: open.since
28858
+ };
28859
+ }
28860
+ function livenessItem(row, lookup) {
28861
+ if (row.subject.startsWith("node:")) {
28862
+ const nodeId = row.subject.slice(5);
28863
+ return {
28864
+ section: "nodes",
28865
+ subject: {
28866
+ kind: "node-offline",
28867
+ subject: row.subject,
28868
+ nodeId,
28869
+ textParams: { nodeId },
28870
+ data: {}
28871
+ },
28872
+ lineKey: "summary.line.node-offline",
28873
+ params: { nodeId },
28874
+ since: row.updatedAt
28875
+ };
28876
+ }
28877
+ if (row.deviceId === void 0) return null;
28878
+ const identity = lookup(row.deviceId);
28879
+ const params = deviceParams(row.deviceId, identity);
28880
+ return {
28881
+ section: "device-state",
28882
+ subject: {
28883
+ kind: "device-offline",
28884
+ subject: row.subject,
28885
+ deviceId: row.deviceId,
28886
+ ...identity !== void 0 ? { deviceType: identity.type } : {},
28887
+ textParams: params,
28888
+ data: {}
28889
+ },
28890
+ lineKey: "summary.line.device-offline",
28891
+ params,
28892
+ since: row.updatedAt
28893
+ };
28894
+ }
28895
+ /** The section and the line of an open system-state row. */
28896
+ function stateLine(row) {
28897
+ switch (row.kind) {
28898
+ case "device-disabled": return {
28899
+ section: "device-state",
28900
+ lineKey: "summary.line.device-disabled"
28901
+ };
28902
+ case "stream-offline": return {
28903
+ section: "streams",
28904
+ lineKey: row.params["profile"] !== void 0 ? "summary.line.stream-offline.profiled" : "summary.line.stream-offline"
28905
+ };
28906
+ case "detection-blind": return {
28907
+ section: "detection-blind",
28908
+ lineKey: "summary.line.detection-blind"
28909
+ };
28910
+ case "node-inference-unavailable": return {
28911
+ section: "nodes",
28912
+ lineKey: "summary.line.node-inference-unavailable"
28913
+ };
28914
+ case "addon-crash-loop": return {
28915
+ section: "addons",
28916
+ lineKey: "summary.line.addon-crash-loop"
28917
+ };
28918
+ case "wrapper-update-available": return {
28919
+ section: "updates",
28920
+ lineKey: "summary.line.wrapper-update"
28921
+ };
28922
+ default: {
28923
+ const onNode = row.params["nodeId"] !== void 0;
28924
+ return {
28925
+ section: row.kind === "addon-update-available" ? "addons" : "updates",
28926
+ lineKey: onNode ? "summary.line.update.onNode" : "summary.line.update"
28927
+ };
28928
+ }
28929
+ }
28930
+ }
28931
+ function stateItem(row, lookup) {
28932
+ const identity = row.deviceId !== void 0 ? lookup(row.deviceId) : void 0;
28933
+ const params = row.deviceId !== void 0 ? {
28934
+ ...row.params,
28935
+ ...deviceParams(row.deviceId, identity)
28936
+ } : row.params;
28937
+ const deviceType = identity?.type ?? row.deviceType;
28938
+ const subject = {
28939
+ kind: row.kind,
28940
+ subject: row.key,
28941
+ ...row.deviceId !== void 0 ? { deviceId: row.deviceId } : {},
28942
+ ...deviceType !== void 0 ? { deviceType } : {},
28943
+ ...row.nodeId !== void 0 ? { nodeId: row.nodeId } : {},
28944
+ ...row.packageName !== void 0 ? {
28945
+ packageName: row.packageName,
28946
+ packageNames: [row.packageName]
28947
+ } : {},
28948
+ textParams: params,
28949
+ data: {}
28950
+ };
28951
+ return {
28952
+ ...stateLine(row),
28953
+ subject,
28954
+ params,
28955
+ since: row.since
28956
+ };
28957
+ }
28958
+ /**
28959
+ * THE RECONCILE (D647 review I2, D8): a ledger row is a memory, never the
28960
+ * truth. A device item is listed only while the device EXISTS in the directory
28961
+ * and is not an accessory — and an "offline" one only while the device
28962
+ * manager does not call it online. A node item only while the node is known
28963
+ * to a complete node directory (a forgotten node is gone), and an "offline"
28964
+ * one only while the directory does not call it online.
28965
+ */
28966
+ function stillTrue(item, ports) {
28967
+ const deviceId = item.subject.deviceId;
28968
+ if (deviceId !== void 0) {
28969
+ const identity = ports.lookup(deviceId);
28970
+ if (identity === void 0 || isAccessoryIdentity(identity)) return false;
28971
+ if (item.subject.kind === "device-offline" && identity.online === true) return false;
28972
+ }
28973
+ const nodeId = item.subject.nodeId;
28974
+ if (nodeId !== void 0 && ports.nodes !== null) {
28975
+ const online = ports.nodes.online(nodeId);
28976
+ if (online === void 0) return false;
28977
+ if (item.subject.kind === "node-offline" && online) return false;
28978
+ }
28979
+ return true;
28980
+ }
28981
+ /** Why a section cannot be reconciled right now, or `null` when it can. */
28982
+ function unreconcilable(section, ports) {
28983
+ if (NODE_SECTIONS.includes(section) && (ports.nodes === null || !ports.nodes.complete)) return UNREAD_NODES;
28984
+ if (!NODE_SECTIONS.includes(section) && !ports.devicesAnswered()) return UNREAD_DEVICES;
28985
+ return null;
28986
+ }
28987
+ /** The section, read and reconciled. */
28988
+ function readSection(section, ports, nowMs) {
28989
+ const blocked = unreconcilable(section, ports);
28990
+ if (blocked !== null) return {
28991
+ answered: false,
28992
+ reason: blocked
28993
+ };
28994
+ const read = readRaw(section, ports, nowMs);
28995
+ if (!read.answered) return read;
28996
+ return {
28997
+ answered: true,
28998
+ items: read.items.filter((item) => stillTrue(item, ports))
28999
+ };
29000
+ }
29001
+ /** Which ports a section needs, and why each one is unavailable. */
29002
+ function readRaw(section, ports, nowMs) {
29003
+ if (section === "battery" || section === "consumables" || section === "tanks") {
29004
+ const alerts = ports.alerts();
29005
+ if (alerts === null) return {
29006
+ answered: false,
29007
+ reason: UNREAD_ALERTS
29008
+ };
29009
+ const family = section === "battery" ? "battery" : section === "tanks" ? "tank" : "consumable";
29010
+ return {
29011
+ answered: true,
29012
+ items: alerts.filter((open) => open.family === family).map((open) => alertItem(section, open))
29013
+ };
29014
+ }
29015
+ const state = ports.state();
29016
+ if (state === null) return {
29017
+ answered: false,
29018
+ reason: UNREAD_STATE
29019
+ };
29020
+ const items = [];
29021
+ if (section === "device-state" || section === "nodes") {
29022
+ const liveness = ports.liveness();
29023
+ if (liveness === null) return {
29024
+ answered: false,
29025
+ reason: UNREAD_LIVENESS
29026
+ };
29027
+ for (const row of liveness) {
29028
+ if (row.state !== "offline" || nowMs - row.updatedAt < ports.livenessHoldoffMs) continue;
29029
+ const item = livenessItem(row, ports.lookup);
29030
+ if (item !== null && item.section === section) items.push(item);
29031
+ }
29032
+ }
29033
+ for (const row of state) {
29034
+ const item = stateItem(row, ports.lookup);
29035
+ if (item.section === section) items.push(item);
29036
+ }
29037
+ return {
29038
+ answered: true,
29039
+ items
29040
+ };
29041
+ }
29042
+ function stateSources(ports) {
29043
+ return { read: (section, nowMs) => readSection(section, ports, nowMs) };
29044
+ }
29045
+ //#endregion
29046
+ //#region src/notification-center/state-summary/state-model.ts
29047
+ /**
29048
+ * The STATE SUMMARY's vocabulary (D647): its sections, and what one reported
29049
+ * item is.
29050
+ *
29051
+ * A section is one of the D637 kind GROUPS (`NC_SYSTEM_EVENT_KIND_GROUP_SPECS`)
29052
+ * — the same grouping both editors list kinds under — so "the rule selected a
29053
+ * battery kind" and "the summary has a battery section" are the same fact.
29054
+ * `backups-exports` is the one group with no section: a backup or an export is
29055
+ * a job that COMPLETED, and the state it leaves behind is not a condition that
29056
+ * is still true. The ADR records why it is excluded rather than approximated.
29057
+ */
29058
+ var NC_STATE_SUMMARY_SECTION_IDS = [
29059
+ "device-state",
29060
+ "streams",
29061
+ "battery",
29062
+ "consumables",
29063
+ "tanks",
29064
+ "detection-blind",
29065
+ "nodes",
29066
+ "addons",
29067
+ "updates"
29068
+ ];
29069
+ /** The kind groups that have no section, and why — said on the preview. */
29070
+ var NC_STATE_SUMMARY_EXCLUDED_GROUPS = { "backups-exports": "a backup or an export is a completed job, not a state that is still true" };
29071
+ /** The template variable that carries one section's lines (`template-vars.ts`). */
29072
+ var NC_STATE_SECTION_VAR = {
29073
+ "device-state": "summaryDevices",
29074
+ streams: "summaryStreams",
29075
+ battery: "summaryBattery",
29076
+ consumables: "summaryConsumables",
29077
+ tanks: "summaryTanks",
29078
+ "detection-blind": "summaryDetection",
29079
+ nodes: "summaryNodes",
29080
+ addons: "summaryAddons",
29081
+ updates: "summaryUpdates"
29082
+ };
29083
+ /** The kinds of a section — its kind group. */
29084
+ function sectionKinds(section) {
29085
+ return require_dist.NC_SYSTEM_EVENT_KIND_GROUP_SPECS.find((group) => group.id === section)?.kinds ?? [];
29086
+ }
29087
+ /**
29088
+ * The sections a rule reports, in section order: every section whose kind
29089
+ * group shares a kind with the rule. A rule that selected only
29090
+ * `device-battery-low` still reports the battery STATE — the state has no
29091
+ * direction, only the event did.
29092
+ */
29093
+ function sectionsOfRule(condition) {
29094
+ return NC_STATE_SUMMARY_SECTION_IDS.filter((section) => sectionKinds(section).some((kind) => condition.kinds.includes(kind)));
29095
+ }
29096
+ /** The kind groups the rule selected that have no section. */
29097
+ function excludedGroupsOfRule(condition) {
29098
+ return require_dist.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);
29099
+ }
29100
+ /**
29101
+ * The condition the summary judges its items with: the rule's own, with its
29102
+ * kinds widened to every kind of every section it reports — so a rule that
29103
+ * named the raise is not told "not my kind" about the state the raise left
29104
+ * behind. Every FILTER is the rule's, untouched.
29105
+ */
29106
+ function summaryCondition(condition) {
29107
+ const kinds = sectionsOfRule(condition).flatMap((section) => sectionKinds(section));
29108
+ return {
29109
+ ...condition,
29110
+ kinds: [...new Set([...condition.kinds, ...kinds])]
29111
+ };
29112
+ }
29113
+ //#endregion
29114
+ //#region src/notification-center/state-summary/state-summary.ts
29115
+ /** Nothing to say: no item, and every section READ. Such a summary is not sent. */
29116
+ function isEmptyStateSummary(summary) {
29117
+ return summary.itemCount === 0 && summary.unavailableCount === 0;
29118
+ }
29119
+ function collectStateSummary(input) {
29120
+ const condition = input.rule.conditions.systemEvent;
29121
+ if (condition === void 0) return {
29122
+ sections: [],
29123
+ itemCount: 0,
29124
+ unavailableCount: 0,
29125
+ dropped: {},
29126
+ excludedGroups: []
29127
+ };
29128
+ const judge = {
29129
+ ...input.rule,
29130
+ conditions: {
29131
+ ...input.rule.conditions,
29132
+ systemEvent: summaryCondition(condition)
29133
+ },
29134
+ schedule: void 0,
29135
+ systemEventDelivery: void 0
29136
+ };
29137
+ const dropped = {};
29138
+ const drop = (reason) => {
29139
+ dropped[reason] = (dropped[reason] ?? 0) + 1;
29140
+ };
29141
+ const sections = [];
29142
+ for (const id of sectionsOfRule(condition)) {
29143
+ const read = input.sources.read(id, input.nowMs);
29144
+ if (!read.answered) {
29145
+ sections.push({
29146
+ id,
29147
+ status: "unavailable",
29148
+ items: [],
29149
+ reason: read.reason
29150
+ });
29151
+ continue;
29152
+ }
29153
+ const items = read.items.filter((item) => {
29154
+ const deviceId = item.subject.deviceId;
29155
+ if (deviceId !== void 0 && input.isMuted(deviceId)) {
29156
+ drop("muted");
29157
+ return false;
29158
+ }
29159
+ const verdict = evaluateRule(judge, ruleSubjectOf(item, input.nowMs));
29160
+ if (verdict.matched) return true;
29161
+ drop(verdict.excludedBySystemEventFilter ?? verdict.failedCondition ?? "unknown");
29162
+ return false;
29163
+ });
29164
+ sections.push({
29165
+ id,
29166
+ status: "read",
29167
+ items: items.toSorted(bySince)
29168
+ });
29169
+ }
29170
+ return {
29171
+ sections,
29172
+ itemCount: sections.reduce((sum, section) => sum + section.items.length, 0),
29173
+ unavailableCount: sections.filter((section) => section.status === "unavailable").length,
29174
+ dropped,
29175
+ excludedGroups: excludedGroupsOfRule(condition)
29176
+ };
29177
+ }
29178
+ function bySince(a, b) {
29179
+ return (a.since ?? 0) - (b.since ?? 0);
29180
+ }
29181
+ function ruleSubjectOf(item, nowMs) {
29182
+ return {
29183
+ kind: "system-event",
29184
+ recordId: `state:${item.subject.subject}`,
29185
+ deviceId: item.subject.deviceId ?? 0,
29186
+ timestamp: nowMs,
29187
+ classNames: [],
29188
+ zones: [],
29189
+ systemEvent: item.subject
29190
+ };
29191
+ }
29192
+ var DAY_MS$3 = 864e5;
29193
+ /** "07:12" when it began in the last day, "25 Sep, 07:12" before that. */
29194
+ function sinceText(texts, sinceMs, nowMs, timeZone) {
29195
+ if (nowMs - sinceMs < DAY_MS$3) return ncClockText(texts.language, sinceMs, timeZone);
29196
+ return new Intl.DateTimeFormat(texts.language, {
29197
+ day: "numeric",
29198
+ month: "short",
29199
+ hour: "2-digit",
29200
+ minute: "2-digit",
29201
+ ...timeZone !== void 0 ? { timeZone } : {}
29202
+ }).format(new Date(sinceMs));
29203
+ }
29204
+ function lineOf(input, item) {
29205
+ const since = item.since !== void 0 ? sinceText(input.texts, item.since, input.nowMs, input.timeZone) : "";
29206
+ return input.texts.text({
29207
+ key: item.lineKey,
29208
+ vars: {
29209
+ ...item.params,
29210
+ since
29211
+ }
29212
+ });
29213
+ }
29214
+ function blockOf(input, section) {
29215
+ const title = input.texts.text({ key: `summary.section.${section.id}` });
29216
+ if (section.status === "unavailable") return input.texts.text({
29217
+ key: "summary.section.unavailable",
29218
+ vars: { section: title }
29219
+ });
29220
+ if (section.items.length === 0) return null;
29221
+ const lines = section.items.map((item) => `• ${lineOf(input, item)}`);
29222
+ return [`${title}:`, ...lines].join("\n");
29223
+ }
29224
+ function stateSummaryText(input) {
29225
+ const sectionVars = {};
29226
+ const blocks = [];
29227
+ for (const section of input.summary.sections) {
29228
+ const block = blockOf(input, section);
29229
+ sectionVars[NC_STATE_SECTION_VAR[section.id]] = block ?? "";
29230
+ if (block !== null) blocks.push(block);
29231
+ }
29232
+ return {
29233
+ text: blocks.join("\n"),
29234
+ count: input.summary.itemCount,
29235
+ sectionVars
29236
+ };
29237
+ }
29238
+ //#endregion
29239
+ //#region src/notification-center/state-summary/zoned-clock.ts
29240
+ var WEEKDAYS = {
29241
+ Sun: 0,
29242
+ Mon: 1,
29243
+ Tue: 2,
29244
+ Wed: 3,
29245
+ Thu: 4,
29246
+ Fri: 5,
29247
+ Sat: 6
29248
+ };
29249
+ var FORMATTERS = /* @__PURE__ */ new Map();
29250
+ function formatterFor(timezone) {
29251
+ const held = FORMATTERS.get(timezone);
29252
+ if (held !== void 0) return held;
29253
+ let made;
29254
+ try {
29255
+ made = new Intl.DateTimeFormat("en-US", {
29256
+ timeZone: timezone,
29257
+ year: "numeric",
29258
+ month: "numeric",
29259
+ day: "numeric",
29260
+ weekday: "short",
29261
+ hour: "numeric",
29262
+ minute: "numeric",
29263
+ hourCycle: "h23"
29264
+ });
29265
+ } catch {
29266
+ made = null;
29267
+ }
29268
+ FORMATTERS.set(timezone, made);
29269
+ return made;
29270
+ }
29271
+ function wallOf(atMs, timezone) {
29272
+ const date = new Date(atMs);
29273
+ const formatter = timezone !== void 0 ? formatterFor(timezone) : null;
29274
+ if (formatter !== null) {
29275
+ const parts = new Map(formatter.formatToParts(date).map((p) => [p.type, p.value]));
29276
+ const weekday = WEEKDAYS[parts.get("weekday") ?? ""];
29277
+ const wall = {
29278
+ year: Number(parts.get("year")),
29279
+ month: Number(parts.get("month")),
29280
+ day: Number(parts.get("day")),
29281
+ hour: Number(parts.get("hour")),
29282
+ minute: Number(parts.get("minute"))
29283
+ };
29284
+ if (weekday !== void 0 && Object.values(wall).every(Number.isFinite)) return {
29285
+ ...wall,
29286
+ weekday
29287
+ };
29288
+ }
29289
+ return {
29290
+ year: date.getFullYear(),
29291
+ month: date.getMonth() + 1,
29292
+ day: date.getDate(),
29293
+ weekday: date.getDay(),
29294
+ hour: date.getHours(),
29295
+ minute: date.getMinutes()
29296
+ };
29297
+ }
29298
+ /** The calendar day `atMs` falls on, in the timezone. */
29299
+ function zonedDayOf(atMs, timezone) {
29300
+ const { year, month, day, weekday } = wallOf(atMs, timezone);
29301
+ return {
29302
+ year,
29303
+ month,
29304
+ day,
29305
+ weekday
29306
+ };
29307
+ }
29308
+ /** The day `offset` calendar days after `day`. Pure calendar arithmetic. */
29309
+ function addDays(day, offset) {
29310
+ const shifted = new Date(Date.UTC(day.year, day.month - 1, day.day + offset));
29311
+ return {
29312
+ year: shifted.getUTCFullYear(),
29313
+ month: shifted.getUTCMonth() + 1,
29314
+ day: shifted.getUTCDate(),
29315
+ weekday: ((day.weekday + offset) % 7 + 7) % 7
29316
+ };
29317
+ }
29318
+ /**
29319
+ * The instant of `minuteOfDay` on `day`, in the timezone. Two passes of the
29320
+ * offset read settle a DST change; a wall time that does not exist (the spring
29321
+ * gap) lands next to it, and the caller verifies every instant it produces.
29322
+ */
29323
+ function zonedInstant(day, minuteOfDay, timezone) {
29324
+ const hour = Math.floor(minuteOfDay / 60);
29325
+ const minute = minuteOfDay % 60;
29326
+ if (timezone === void 0 || formatterFor(timezone) === null) return new Date(day.year, day.month - 1, day.day, hour, minute).getTime();
29327
+ const asUtc = Date.UTC(day.year, day.month - 1, day.day, hour, minute);
29328
+ const offsetAt = (atMs) => {
29329
+ const wall = wallOf(atMs, timezone);
29330
+ return Date.UTC(wall.year, wall.month - 1, wall.day, wall.hour, wall.minute) - atMs;
29331
+ };
29332
+ return asUtc - offsetAt(asUtc - offsetAt(asUtc));
29333
+ }
29334
+ /** The widest look back: a weekly schedule closes at least once in eight days. */
29335
+ var NC_STATE_SUMMARY_MAX_LOOKBACK_MS = 8 * 864e5;
29336
+ /** A close found by the tick right after it is on time; later than this is `late`. */
29337
+ var ON_TIME_MS = 2 * 6e4;
29338
+ var MINUTE_MS = 6e4;
29339
+ var DAY_MS$2 = 864e5;
29340
+ /**
29341
+ * Every verified transition of `kind` in `(fromMs, toMs]`, ascending.
29342
+ * Candidates are each window's start and end minute on each day of the range
29343
+ * (the day before `fromMs` included, for a window crossing midnight).
29344
+ */
29345
+ function scheduleTransitions(schedule, fromMs, toMs, kind, probe = require_dist.isScheduleActive) {
29346
+ if (toMs <= fromMs) return [];
29347
+ const first = zonedDayOf(fromMs - DAY_MS$2, schedule.timezone);
29348
+ const days = Math.ceil((toMs - fromMs) / DAY_MS$2) + 2;
29349
+ const minutes = /* @__PURE__ */ new Set();
29350
+ for (const window of schedule.windows) {
29351
+ minutes.add(window.startMinute);
29352
+ minutes.add(window.endMinute);
29353
+ }
29354
+ const found = /* @__PURE__ */ new Set();
29355
+ for (let offset = 0; offset <= days; offset += 1) {
29356
+ const day = addDays(first, offset);
29357
+ for (const minute of minutes) {
29358
+ const at = zonedInstant(day, minute, schedule.timezone);
29359
+ if (at <= fromMs || at > toMs || found.has(at)) continue;
29360
+ const before = probe(schedule, at - MINUTE_MS);
29361
+ const after = probe(schedule, at);
29362
+ if (kind === "close" ? before && !after : !before && after) found.add(at);
29363
+ }
29364
+ }
29365
+ return [...found].toSorted((a, b) => a - b);
29366
+ }
29367
+ /** The first window close after `afterMs`, within `horizonMs`, or `null`. */
29368
+ function nextStateSummaryClose(schedule, afterMs, horizonMs = NC_STATE_SUMMARY_MAX_LOOKBACK_MS, probe) {
29369
+ return scheduleTransitions(schedule, afterMs, afterMs + horizonMs, "close", probe)[0] ?? null;
29370
+ }
29371
+ function decideStateSummary(input) {
29372
+ const from = Math.max(input.floorMs, input.nowMs - NC_STATE_SUMMARY_MAX_LOOKBACK_MS);
29373
+ const endMs = scheduleTransitions(input.schedule, from, input.nowMs, "close", input.probe).at(-1);
29374
+ if (endMs === void 0) return { kind: "idle" };
29375
+ const window = {
29376
+ startMs: scheduleTransitions(input.schedule, endMs - DAY_MS$2, endMs, "open", input.probe).at(-1) ?? endMs,
29377
+ endMs
29378
+ };
29379
+ const overdueMs = input.nowMs - endMs;
29380
+ if (overdueMs > (input.graceMs ?? 108e5)) return {
29381
+ kind: "missed",
29382
+ window
29383
+ };
29384
+ return {
29385
+ kind: "send",
29386
+ window,
29387
+ late: overdueMs > ON_TIME_MS
29388
+ };
29389
+ }
29390
+ //#endregion
29391
+ //#region src/notification-center/state-summary/state-summary-scheduler.ts
29392
+ /**
29393
+ * The STATE SUMMARY's tick (D647): for every enabled summary rule, is a
29394
+ * window owed, and if so collect → word → enqueue → stamp.
29395
+ *
29396
+ * Driven by the centre's existing rule-reload tick — no timer of its own — and
29397
+ * single-flight. It reads RAM only: the rule mirror, the stamps and the state
29398
+ * mirrors (D447: nothing on this schedule queries SQLite).
29399
+ *
29400
+ * Every branch that sends nothing SAYS so: an empty state (INFO), a missed
29401
+ * window (WARN), a stamp ledger that has not been read (WARN — nothing is sent
29402
+ * until it has, because without it a restart could send a window twice), a
29403
+ * delivery that failed (WARN, retried on the next tick inside the grace).
29404
+ *
29405
+ * The stamp is LAST, as in the digest: a window whose enqueue failed stays owed.
29406
+ */
29407
+ var NcStateSummaryScheduler = class {
29408
+ deps;
29409
+ inFlight = false;
29410
+ /** Per rule: the next window close, valid for one floor and one schedule. */
29411
+ nextClose = /* @__PURE__ */ new Map();
29412
+ /** One WARN per hold, not one per tick. */
29413
+ holdReported = false;
29414
+ constructor(deps) {
29415
+ this.deps = deps;
29416
+ }
29417
+ async tick() {
29418
+ const report = {
29419
+ decisions: 0,
29420
+ sent: 0,
29421
+ empty: 0,
29422
+ missed: 0,
29423
+ failed: 0,
29424
+ held: false
29425
+ };
29426
+ if (this.inFlight) return report;
29427
+ this.inFlight = true;
29428
+ try {
29429
+ const rules = this.deps.rules().filter((rule) => require_dist.systemEventSummaryScheduleOf(rule) !== null);
29430
+ if (rules.length === 0) return report;
29431
+ if (!this.deps.stamps.answered()) {
29432
+ report.held = true;
29433
+ if (!this.holdReported) {
29434
+ this.holdReported = true;
29435
+ this.deps.logger.warn("state summaries held — the send ledger has not been read yet", { meta: { rules: rules.map((rule) => rule.id) } });
29436
+ }
29437
+ return report;
29438
+ }
29439
+ this.holdReported = false;
29440
+ const nowMs = this.deps.now();
29441
+ for (const rule of rules) {
29442
+ const schedule = require_dist.systemEventSummaryScheduleOf(rule);
29443
+ if (schedule !== null) await this.tickRule(rule, schedule, nowMs, report);
29444
+ }
29445
+ return report;
29446
+ } finally {
29447
+ this.inFlight = false;
29448
+ }
29449
+ }
29450
+ async tickRule(rule, schedule, nowMs, report) {
29451
+ const stamp = await this.armed(rule, schedule, nowMs);
29452
+ if (stamp === null) return;
29453
+ const floorMs = Math.max(stamp.armedAt, stamp.lastWindowEnd);
29454
+ const cached = this.nextClose.get(rule.id);
29455
+ if (cached !== void 0 && cached.floorMs === floorMs && cached.scheduleKey === stamp.scheduleKey && nowMs < cached.atMs) return;
29456
+ report.decisions += 1;
29457
+ const decision = decideStateSummary({
29458
+ schedule,
29459
+ nowMs,
29460
+ floorMs,
29461
+ ...this.deps.graceMs !== void 0 ? { graceMs: this.deps.graceMs } : {}
29462
+ });
29463
+ if (decision.kind === "idle") {
29464
+ const next = nextStateSummaryClose(schedule, nowMs);
29465
+ this.nextClose.set(rule.id, {
29466
+ floorMs,
29467
+ scheduleKey: stamp.scheduleKey,
29468
+ atMs: next ?? nowMs + 6912e5
29469
+ });
29470
+ return;
29471
+ }
29472
+ this.nextClose.delete(rule.id);
29473
+ const meta = {
29474
+ ruleId: rule.id,
29475
+ rule: rule.name,
29476
+ windowStart: decision.window.startMs,
29477
+ windowEnd: decision.window.endMs
29478
+ };
29479
+ if (decision.kind === "missed") {
29480
+ report.missed += 1;
29481
+ this.deps.logger.warn("state summary window MISSED — closed too long ago to send now", { meta: {
29482
+ ...meta,
29483
+ overdueMs: nowMs - decision.window.endMs
29484
+ } });
29485
+ await this.stamp(stamp, decision.window.endMs);
29486
+ return;
29487
+ }
29488
+ const summary = await this.deps.collect(rule, nowMs);
29489
+ if (isEmptyStateSummary(summary)) {
29490
+ report.empty += 1;
29491
+ this.deps.logger.info("state summary has nothing to report — not sent", { meta: {
29492
+ ...meta,
29493
+ dropped: summary.dropped
29494
+ } });
29495
+ await this.stamp(stamp, decision.window.endMs);
29496
+ return;
29497
+ }
29498
+ let inserted;
29499
+ try {
29500
+ inserted = await this.deps.deliver(rule, summary, decision.window);
29501
+ } catch (err) {
29502
+ report.failed += 1;
29503
+ this.deps.logger.warn("state summary could not be enqueued — retried on the next tick", { meta: {
29504
+ ...meta,
29505
+ error: err instanceof Error ? err.message : String(err)
29506
+ } });
29507
+ return;
29508
+ }
29509
+ report.sent += 1;
29510
+ this.deps.logger.info("state summary enqueued", { meta: {
29511
+ ...meta,
29512
+ late: decision.late,
29513
+ items: summary.itemCount,
29514
+ unavailable: summary.unavailableCount,
29515
+ sections: summary.sections.map((section) => `${section.id}:${section.status}`),
29516
+ dropped: summary.dropped,
29517
+ inserted
29518
+ } });
29519
+ await this.stamp(stamp, decision.window.endMs);
29520
+ }
29521
+ /**
29522
+ * The rule's stamp, armed NOW when the rule is new to summary mode or its
29523
+ * schedule changed. `null` when arming could not be written — nothing is
29524
+ * sent on an unarmed rule.
29525
+ */
29526
+ async armed(rule, schedule, nowMs) {
29527
+ const scheduleKey = require_dist.canonicalHash(schedule);
29528
+ const held = this.deps.stamps.get(rule.id);
29529
+ if (held !== void 0 && held.scheduleKey === scheduleKey) return held;
29530
+ const armed = {
29531
+ ruleId: rule.id,
29532
+ armedAt: nowMs,
29533
+ lastWindowEnd: held?.lastWindowEnd ?? 0,
29534
+ scheduleKey
29535
+ };
29536
+ try {
29537
+ await this.deps.stamps.put(armed);
29538
+ } catch (err) {
29539
+ this.deps.logger.warn("state summary rule could not be armed — nothing is sent until it is", { meta: {
29540
+ ruleId: rule.id,
29541
+ error: err instanceof Error ? err.message : String(err)
29542
+ } });
29543
+ return null;
29544
+ }
29545
+ this.deps.logger.info("state summary rule armed — windows closing from now on are sent", { meta: {
29546
+ ruleId: rule.id,
29547
+ rule: rule.name,
29548
+ rearmed: held !== void 0
29549
+ } });
29550
+ return armed;
29551
+ }
29552
+ async stamp(held, windowEndMs) {
29553
+ try {
29554
+ await this.deps.stamps.put({
29555
+ ...held,
29556
+ lastWindowEnd: Math.max(held.lastWindowEnd, windowEndMs)
29557
+ });
29558
+ } catch (err) {
29559
+ this.deps.logger.warn("state summary stamp did not land — the outbox dedup still holds", { meta: {
29560
+ ruleId: held.ruleId,
29561
+ windowEnd: windowEndMs,
29562
+ error: err instanceof Error ? err.message : String(err)
29563
+ } });
29564
+ }
29565
+ }
29566
+ };
29567
+ var SPEC = {
29568
+ collection: "notification-center:state-summary-stamps",
29569
+ columns: [
29570
+ {
29571
+ name: "ruleId",
29572
+ type: "TEXT",
29573
+ primaryKey: true,
29574
+ notNull: true
29575
+ },
29576
+ {
29577
+ name: "armedAt",
29578
+ type: "INTEGER",
29579
+ notNull: true
29580
+ },
29581
+ {
29582
+ name: "lastWindowEnd",
29583
+ type: "INTEGER",
29584
+ notNull: true
29585
+ },
29586
+ {
29587
+ name: "scheduleKey",
29588
+ type: "TEXT",
29589
+ notNull: true
29590
+ }
29591
+ ],
29592
+ writeMode: "write-through",
29593
+ keyOf: (row) => row.ruleId,
29594
+ toValue: (row) => ({
29595
+ armedAt: row.armedAt,
29596
+ lastWindowEnd: row.lastWindowEnd,
29597
+ scheduleKey: row.scheduleKey
29598
+ }),
29599
+ fromRecord: (key, data) => {
29600
+ const armedAt = data["armedAt"];
29601
+ const lastWindowEnd = data["lastWindowEnd"];
29602
+ const scheduleKey = data["scheduleKey"];
29603
+ if (typeof armedAt !== "number" || typeof lastWindowEnd !== "number") return null;
29604
+ if (typeof scheduleKey !== "string") return null;
29605
+ return {
29606
+ ruleId: key,
29607
+ armedAt,
29608
+ lastWindowEnd,
29609
+ scheduleKey
29610
+ };
29611
+ },
29612
+ loadLimit: 1e4
29613
+ };
29614
+ var NcStateSummaryStampStore = class {
29615
+ ledger;
29616
+ constructor(deps) {
29617
+ this.ledger = new DurableLedger({
29618
+ spec: SPEC,
29619
+ store: deps.store,
29620
+ logger: deps.logger
29621
+ });
29622
+ }
29623
+ static declare(store) {
29624
+ return DurableLedger.declare(store, SPEC);
29625
+ }
29626
+ async load() {
29627
+ return (await this.ledger.load()).length;
29628
+ }
29629
+ port() {
29630
+ return {
29631
+ get: (ruleId) => this.ledger.get(ruleId),
29632
+ put: (row) => this.ledger.put(row),
29633
+ answered: () => this.ledger.hasAnswered()
29634
+ };
29635
+ }
29636
+ };
29637
+ //#endregion
29638
+ //#region src/notification-center/state-summary/system-state-ledger.ts
29639
+ /**
29640
+ * @durable class=ledger owner=notification-center
29641
+ * 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"
29642
+ * retention="none — a row lives exactly as long as its state is open. Bounded by the installation: streams, devices, nodes and installed packages."
29643
+ */
29644
+ var NC_SYSTEM_STATE_COLLECTION = "notification-center:system-state";
29645
+ var NC_SYSTEM_STATE_COLUMNS = [
29646
+ (
29647
+ /** The state's subject (`stream:<id>`, `device-switch:<id>`,
29648
+ * `update:addon:<node>:<package>`, ...). */
29649
+ {
29650
+ name: "key",
29651
+ type: "TEXT",
29652
+ primaryKey: true,
29653
+ notNull: true
29654
+ }),
29655
+ (
29656
+ /** The OPENING kind. */
29657
+ {
29658
+ name: "kind",
29659
+ type: "TEXT",
29660
+ notNull: true
29661
+ }),
29662
+ {
29663
+ name: "since",
29664
+ type: "INTEGER",
29665
+ notNull: true
29666
+ },
29667
+ (
29668
+ /** Indexed: every question about a device is asked per device. */
29669
+ {
29670
+ name: "deviceId",
29671
+ type: "INTEGER"
29672
+ }),
29673
+ {
29674
+ name: "nodeId",
29675
+ type: "TEXT"
29676
+ },
29677
+ {
29678
+ name: "packageName",
29679
+ type: "TEXT"
29680
+ },
29681
+ (
29682
+ /** The words' facts (device name, profile, versions, addons). */
29683
+ {
29684
+ name: "params",
29685
+ type: "JSON",
29686
+ notNull: true
29687
+ })
29688
+ ];
29689
+ var NC_SYSTEM_STATE_INDEXES = [{
29690
+ name: "idx_nc_system_state_device",
29691
+ columns: ["deviceId"]
29692
+ }];
29693
+ /** The kinds that OPEN a row. */
29694
+ var NC_SYSTEM_STATE_OPENING_KINDS = [
29695
+ "stream-offline",
29696
+ "device-disabled",
29697
+ "detection-blind",
29698
+ "node-inference-unavailable",
29699
+ "addon-crash-loop",
29700
+ "addon-update-available",
29701
+ "server-update-available",
29702
+ "wrapper-update-available"
29703
+ ];
29704
+ function isOpeningKind(value) {
29705
+ return NC_SYSTEM_STATE_OPENING_KINDS.some((kind) => kind === value);
29706
+ }
29707
+ function stringRecord(value) {
29708
+ if (typeof value !== "object" || value === null || Array.isArray(value)) return null;
29709
+ const out = {};
29710
+ for (const [key, entry] of Object.entries(value)) if (typeof entry === "string") out[key] = entry;
29711
+ return out;
29712
+ }
29713
+ var NC_SYSTEM_STATE_SPEC = {
29714
+ collection: NC_SYSTEM_STATE_COLLECTION,
29715
+ columns: NC_SYSTEM_STATE_COLUMNS,
29716
+ indexes: NC_SYSTEM_STATE_INDEXES,
29717
+ writeMode: "write-behind",
29718
+ keyOf: (row) => row.key,
29719
+ toValue: (row) => ({
29720
+ kind: row.kind,
29721
+ since: row.since,
29722
+ ...row.deviceId !== void 0 ? { deviceId: row.deviceId } : {},
29723
+ ...row.nodeId !== void 0 ? { nodeId: row.nodeId } : {},
29724
+ ...row.packageName !== void 0 ? { packageName: row.packageName } : {},
29725
+ params: {
29726
+ ...row.params,
29727
+ ...row.deviceType !== void 0 ? { deviceType: row.deviceType } : {}
29728
+ }
29729
+ }),
29730
+ fromRecord: (key, data) => {
29731
+ const kind = data["kind"];
29732
+ const since = data["since"];
29733
+ const params = stringRecord(data["params"]);
29734
+ if (!isOpeningKind(kind) || typeof since !== "number" || params === null) return null;
29735
+ const deviceId = data["deviceId"];
29736
+ const nodeId = data["nodeId"];
29737
+ const packageName = data["packageName"];
29738
+ const { deviceType, ...words } = params;
29739
+ return {
29740
+ key,
29741
+ kind,
29742
+ since,
29743
+ ...typeof deviceId === "number" ? { deviceId } : {},
29744
+ ...deviceType !== void 0 ? { deviceType } : {},
29745
+ ...typeof nodeId === "string" ? { nodeId } : {},
29746
+ ...typeof packageName === "string" ? { packageName } : {},
29747
+ params: words
29748
+ };
29749
+ },
29750
+ deviceIdOf: (row) => row.deviceId
29751
+ };
29752
+ function isUpdateTarget(value) {
29753
+ return value === "addon" || value === "server" || value === "wrapper";
29754
+ }
29755
+ function updateKind(target) {
29756
+ return target === "addon" ? "addon-update-available" : target === "server" ? "server-update-available" : "wrapper-update-available";
29757
+ }
29758
+ function updateStateKey(target, nodeId, packageName) {
29759
+ return `update:${target}:${nodeId ?? "hub"}:${packageName}`;
29760
+ }
29761
+ var NcSystemStateLedger = class {
29762
+ deps;
29763
+ constructor(deps) {
29764
+ this.deps = deps;
29765
+ }
29766
+ /** The open rows, or `null` while the store has never answered. */
29767
+ openRows() {
29768
+ return this.deps.rows.answered() ? this.deps.rows.all() : null;
29769
+ }
29770
+ /**
29771
+ * One system subject the intake accepted. The update-available kinds are
29772
+ * not read here: {@link observeUpdateList} replaces them from the raw event.
29773
+ */
29774
+ observe(system, atMs) {
29775
+ switch (system.kind) {
29776
+ case "stream-offline":
29777
+ case "device-disabled":
29778
+ case "detection-blind":
29779
+ case "node-inference-unavailable":
29780
+ case "addon-crash-loop":
29781
+ this.open(system, atMs);
29782
+ return;
29783
+ case "stream-online":
29784
+ case "device-enabled":
29785
+ this.close(system.subject, system.kind);
29786
+ return;
29787
+ case "node-online":
29788
+ case "node-offline":
29789
+ if (system.nodeId !== void 0) this.closeNodeFaults(system.nodeId, system.kind);
29790
+ return;
29791
+ case "server-updated":
29792
+ if (system.nodeId !== void 0) this.closeServerUpdate(system.nodeId);
29793
+ return;
29794
+ default: return;
29795
+ }
29796
+ }
29797
+ /** A detection on a camera: it is not blind. Called for every detection — a Map lookup. */
29798
+ observeDetection(deviceId) {
29799
+ const key = `detection-blind:${String(deviceId)}`;
29800
+ if (this.deps.rows.get(key) === void 0) return;
29801
+ this.close(key, "detection");
29802
+ }
29803
+ /**
29804
+ * Any `stream.online`, whatever its reason (D647 review I1). The intake gate
29805
+ * drops a `first-packet` online — a recreated broker's — because it is not
29806
+ * a recovery worth a notification; it is still proof the stream is up.
29807
+ */
29808
+ observeStreamOnline(camStreamId) {
29809
+ this.close(`stream:${camStreamId}`, "stream-online");
29810
+ }
29811
+ /** One `update.available` list: REPLACE the rows of every node it names. */
29812
+ observeUpdateList(list, atMs) {
29813
+ const keep = new Set(list.rows.map((pkg) => updateStateKey(list.target, pkg.nodeId, pkg.packageName)));
29814
+ for (const nodeId of list.nodeIds) {
29815
+ const prefix = `update:${list.target}:${nodeId}:`;
29816
+ for (const row of this.deps.rows.all()) if (row.key.startsWith(prefix) && !keep.has(row.key)) this.close(row.key, "update-list");
29817
+ }
29818
+ for (const pkg of list.rows) this.openUpdate(list.target, pkg, atMs);
29819
+ }
29820
+ /**
29821
+ * An addon STARTED on `nodeId` (D647 review I5): the runner hosting it is
29822
+ * up again, so a crash-loop row naming it on THAT node is over — and only
29823
+ * on that node.
29824
+ */
29825
+ observeAddonStarted(addonId, nodeId) {
29826
+ for (const row of this.deps.rows.all()) {
29827
+ if (row.kind !== "addon-crash-loop") continue;
29828
+ if (row.nodeId !== nodeId) continue;
29829
+ if ((row.params["addons"] ?? "").split(",").map((id) => id.trim()).includes(addonId)) this.close(row.key, "addon-started");
29830
+ }
29831
+ }
29832
+ /**
29833
+ * The balancer placed a camera on a node (not pinned): placement skips a
29834
+ * node that cannot infer, so the node can infer again (D647 review I5).
29835
+ */
29836
+ observeCameraPlaced(nodeId) {
29837
+ this.close(`node-inference:${nodeId}`, "camera-placed");
29838
+ }
29839
+ /** `addon.updated` — the package is current on that node. */
29840
+ observeAddonUpdated(update) {
29841
+ this.close(updateStateKey("addon", update.nodeId, update.packageName), "addon-updated");
29842
+ }
29843
+ /** Every device that holds an open state — the deleted-device reconcile reads it. */
29844
+ deviceIds() {
29845
+ const ids = /* @__PURE__ */ new Set();
29846
+ for (const row of this.deps.rows.all()) if (row.deviceId !== void 0) ids.add(row.deviceId);
29847
+ return [...ids];
29848
+ }
29849
+ /** A deleted device leaves (D633). Returns how many rows went. */
29850
+ forgetDevice(deviceId) {
29851
+ const keys = this.deps.rows.all().filter((row) => row.deviceId === deviceId).map((row) => row.key);
29852
+ for (const key of keys) this.deps.rows.remove(key);
29853
+ return keys.length;
29854
+ }
29855
+ open(system, atMs) {
29856
+ const held = this.deps.rows.get(system.subject);
29857
+ const since = held !== void 0 && held.kind === system.kind ? held.since : atMs;
29858
+ this.deps.rows.put({
29859
+ key: system.subject,
29860
+ kind: system.kind,
29861
+ since,
29862
+ ...system.deviceId !== void 0 ? { deviceId: system.deviceId } : {},
29863
+ ...system.deviceType !== void 0 ? { deviceType: system.deviceType } : {},
29864
+ ...system.nodeId !== void 0 ? { nodeId: system.nodeId } : {},
29865
+ params: system.textParams ?? {}
29866
+ });
29867
+ }
29868
+ openUpdate(target, pkg, atMs) {
29869
+ const key = updateStateKey(target, pkg.nodeId, pkg.packageName);
29870
+ const held = this.deps.rows.get(key);
29871
+ const kind = updateKind(target);
29872
+ this.deps.rows.put({
29873
+ key,
29874
+ kind,
29875
+ since: held?.since ?? atMs,
29876
+ ...pkg.nodeId !== void 0 ? { nodeId: pkg.nodeId } : {},
29877
+ packageName: pkg.packageName,
29878
+ params: {
29879
+ packageName: pkg.packageName,
29880
+ currentVersion: pkg.currentVersion,
29881
+ latestVersion: pkg.latestVersion,
29882
+ ...pkg.nodeId !== void 0 ? { nodeId: pkg.nodeId } : {}
29883
+ }
29884
+ });
29885
+ }
29886
+ close(key, by) {
29887
+ const held = this.deps.rows.get(key);
29888
+ if (held === void 0) return;
29889
+ this.deps.rows.remove(key);
29890
+ this.deps.logger?.debug("system state closed", {
29891
+ ...held.deviceId !== void 0 ? { tags: { deviceId: held.deviceId } } : {},
29892
+ meta: {
29893
+ key,
29894
+ kind: held.kind,
29895
+ by,
29896
+ since: held.since
29897
+ }
29898
+ });
29899
+ }
29900
+ closeNodeFaults(nodeId, by) {
29901
+ for (const row of this.deps.rows.all()) {
29902
+ if (row.nodeId !== nodeId) continue;
29903
+ if (row.kind === "node-inference-unavailable" || row.kind === "addon-crash-loop") this.close(row.key, by);
29904
+ }
29905
+ }
29906
+ closeServerUpdate(nodeId) {
29907
+ for (const row of this.deps.rows.all()) {
29908
+ if ((row.nodeId ?? "hub") !== nodeId) continue;
29909
+ 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");
29910
+ }
29911
+ }
29912
+ };
29913
+ /** The durable home of the rows. */
29914
+ var NcSystemStateStore = class {
29915
+ ledger;
29916
+ constructor(deps) {
29917
+ this.ledger = new DurableLedger({
29918
+ spec: NC_SYSTEM_STATE_SPEC,
29919
+ store: deps.store,
29920
+ logger: deps.logger
29921
+ });
29922
+ }
29923
+ static declare(store) {
29924
+ return DurableLedger.declare(store, NC_SYSTEM_STATE_SPEC);
29925
+ }
29926
+ /** Boot reseed. A failed load keeps the mirror and leaves `answered` false. */
29927
+ async load() {
29928
+ return (await this.ledger.load()).length;
29929
+ }
29930
+ port() {
29931
+ return {
29932
+ get: (key) => this.ledger.get(key),
29933
+ put: (row) => {
29934
+ this.ledger.put(row).catch(() => void 0);
29935
+ },
29936
+ remove: (key) => {
29937
+ this.ledger.forget(key);
29938
+ },
29939
+ all: () => this.ledger.snapshot(),
29940
+ answered: () => this.ledger.hasAnswered()
29941
+ };
29942
+ }
29943
+ };
29944
+ //#endregion
29945
+ //#region src/notification-center/state-summary/state-summary-service.ts
29946
+ function stringOf(data, key) {
29947
+ const value = data[key];
29948
+ return typeof value === "string" && value.length > 0 ? value : void 0;
29949
+ }
29950
+ /**
29951
+ * The full list an `update.available` event carries, and the nodes it speaks
29952
+ * for: every node a row names, plus the event's own `nodeId`. The emitter
29953
+ * never sends an empty list (it emits only when a `latestVersion` changes), so
29954
+ * the empty case is defensive, not a path that clears anything today — see
29955
+ * {@link NcUpdateList} for the limit that leaves.
29956
+ */
29957
+ function updateListOf(data) {
29958
+ const target = data["target"];
29959
+ if (!isUpdateTarget(target)) return null;
29960
+ const rows = updatePackages(data);
29961
+ const eventNode = stringOf(data, "nodeId");
29962
+ const nodes = new Set(rows.map((row) => row.nodeId ?? eventNode ?? "hub"));
29963
+ if (eventNode !== void 0) nodes.add(eventNode);
29964
+ const withNode = rows.map((row) => row.nodeId === void 0 && eventNode !== void 0 ? {
29965
+ ...row,
29966
+ nodeId: eventNode
29967
+ } : row);
29968
+ return {
29969
+ target,
29970
+ nodeIds: [...nodes],
29971
+ rows: withNode
29972
+ };
29973
+ }
29974
+ var NcStateSummaryService = class {
29975
+ deps;
29976
+ stateStore;
29977
+ stampStore;
29978
+ ledger;
29979
+ scheduler;
29980
+ constructor(deps) {
29981
+ this.deps = deps;
29982
+ this.stateStore = new NcSystemStateStore({
29983
+ store: deps.store,
29984
+ logger: deps.logger
29985
+ });
29986
+ this.stampStore = new NcStateSummaryStampStore({
29987
+ store: deps.store,
29988
+ logger: deps.logger
29989
+ });
29990
+ this.ledger = new NcSystemStateLedger({
29991
+ rows: this.stateStore.port(),
29992
+ logger: deps.logger
29993
+ });
29994
+ this.scheduler = new NcStateSummaryScheduler({
29995
+ rules: deps.rules,
29996
+ stamps: this.stampStore.port(),
29997
+ collect: (rule, nowMs) => this.collect(rule, nowMs),
29998
+ deliver: (rule, summary, window) => this.deliver(rule, summary, window),
29999
+ now: deps.now,
30000
+ logger: deps.logger
30001
+ });
30002
+ }
30003
+ static async declare(store) {
30004
+ await NcSystemStateStore.declare(store);
30005
+ await NcStateSummaryStampStore.declare(store);
30006
+ }
30007
+ /** Boot back-fill. Each load that fails leaves its mirror unanswered — the summary then says so. */
30008
+ async load() {
30009
+ return {
30010
+ states: await this.stateStore.load(),
30011
+ stamps: await this.stampStore.load()
30012
+ };
30013
+ }
30014
+ /** One system subject the intake accepted. */
30015
+ observeSystem(system, atMs) {
30016
+ this.ledger.observe(system, atMs);
30017
+ }
30018
+ /**
30019
+ * The RAW bus event, before the intake's gates (D647 review I1/I3/I5): the
30020
+ * facts that close or replace a state even when no notification is owed —
30021
+ * any `stream.online`, the full `update.available` list of a node, an addon
30022
+ * that started, a camera the balancer placed.
30023
+ */
30024
+ observeRawEvent(category, data, atMs, sourceNodeId) {
30025
+ switch (category) {
30026
+ case "stream.online": {
30027
+ const camStreamId = stringOf(data, "camStreamId");
30028
+ if (camStreamId !== void 0) this.ledger.observeStreamOnline(camStreamId);
30029
+ return;
30030
+ }
30031
+ case "update.available": {
30032
+ const list = updateListOf(data);
30033
+ if (list !== null) this.ledger.observeUpdateList(list, atMs);
30034
+ return;
30035
+ }
30036
+ case "addon.started":
30037
+ case "addon.restarted": {
30038
+ const addonId = stringOf(data, "addonId");
30039
+ if (addonId !== void 0) this.ledger.observeAddonStarted(addonId, sourceNodeId ?? "hub");
30040
+ return;
30041
+ }
30042
+ case "pipeline.camera-assigned": {
30043
+ const nodeId = stringOf(data, "agentNodeId");
30044
+ const placed = data["pinned"] === false && data["reason"] !== "unassigned";
30045
+ if (nodeId !== void 0 && placed) this.ledger.observeCameraPlaced(nodeId);
30046
+ return;
30047
+ }
30048
+ default: return;
30049
+ }
30050
+ }
30051
+ observeDetection(deviceId) {
30052
+ this.ledger.observeDetection(deviceId);
30053
+ }
30054
+ observeAddonUpdated(update) {
30055
+ this.ledger.observeAddonUpdated(update);
30056
+ }
30057
+ tick() {
30058
+ return this.scheduler.tick();
30059
+ }
30060
+ /** The summary this rule would send NOW (`previewStateSummary`) — sends nothing, stamps nothing. */
30061
+ async preview(rule) {
30062
+ const nowMs = this.deps.now();
30063
+ const summary = await this.collect(rule, nowMs);
30064
+ const text = this.word(summary, nowMs);
30065
+ return {
30066
+ empty: summary.itemCount === 0 && summary.unavailableCount === 0,
30067
+ count: text.count,
30068
+ text: text.text,
30069
+ sections: summary.sections.map((section) => ({
30070
+ id: section.id,
30071
+ status: section.status,
30072
+ items: section.items.length,
30073
+ ...section.reason !== void 0 ? { reason: section.reason } : {}
30074
+ })),
30075
+ dropped: { ...summary.dropped },
30076
+ excludedGroups: [...summary.excludedGroups]
30077
+ };
30078
+ }
30079
+ async collect(rule, nowMs) {
30080
+ const nodes = await this.nodeView();
30081
+ return collectStateSummary({
30082
+ rule,
30083
+ nowMs,
30084
+ isMuted: this.deps.isMuted,
30085
+ sources: stateSources({
30086
+ alerts: this.deps.alerts,
30087
+ liveness: this.deps.liveness,
30088
+ state: () => this.ledger.openRows(),
30089
+ lookup: this.deps.lookup,
30090
+ devicesAnswered: this.deps.devicesAnswered,
30091
+ nodes,
30092
+ livenessHoldoffMs: this.deps.livenessHoldoffMs
30093
+ })
30094
+ });
30095
+ }
30096
+ /** The node directory for one summary, or `null` (logged) when it did not answer. */
30097
+ async nodeView() {
30098
+ const read = this.deps.readNodes;
30099
+ if (read === void 0) return null;
30100
+ try {
30101
+ const answer = await read();
30102
+ const online = new Map(answer.nodes.map((node) => [node.id, node.isOnline]));
30103
+ return {
30104
+ complete: answer.complete,
30105
+ online: (nodeId) => online.get(nodeId)
30106
+ };
30107
+ } catch (err) {
30108
+ this.deps.logger.warn("state summary: the node directory did not answer — node sections unavailable", { meta: { error: err instanceof Error ? err.message : String(err) } });
30109
+ return null;
30110
+ }
30111
+ }
30112
+ word(summary, nowMs) {
30113
+ return stateSummaryText({
30114
+ texts: this.deps.texts(),
30115
+ summary,
30116
+ nowMs
30117
+ });
30118
+ }
30119
+ async deliver(rule, summary, window) {
30120
+ const subject = stateSummarySubject({
30121
+ rule,
30122
+ summary,
30123
+ text: this.word(summary, window.endMs),
30124
+ window
30125
+ });
30126
+ if (subject === null) {
30127
+ this.deps.logger.warn("state summary rule has no system-event condition — not sent", { meta: { ruleId: rule.id } });
30128
+ return 0;
30129
+ }
30130
+ return this.deps.enqueue(rule, subject);
30131
+ }
30132
+ };
30133
+ //#endregion
28437
30134
  //#region src/notification-center/zone-owner-cache.ts
28438
30135
  var NcZoneOwnershipCache = class {
28439
30136
  deps;
@@ -28991,6 +30688,8 @@ var NotificationCenter = class NotificationCenter {
28991
30688
  alertStore;
28992
30689
  /** Battery band → notification, and the reminder sweep. */
28993
30690
  deviceAlerts;
30691
+ /** The scheduled STATE SUMMARY of system rules (D647): its ledgers and its tick. */
30692
+ stateSummary;
28994
30693
  /** States the `deviceState` rule gate reads. Refreshed on the reload tick;
28995
30694
  * read with no I/O on the persist hook. */
28996
30695
  deviceStates;
@@ -29884,6 +31583,21 @@ var NotificationCenter = class NotificationCenter {
29884
31583
  logger: this.logger.child("device-alerts"),
29885
31584
  ...deps.now !== void 0 ? { now: deps.now } : {}
29886
31585
  });
31586
+ this.stateSummary = new NcStateSummaryService({
31587
+ store: deps.store,
31588
+ logger: this.logger.child("state-summary"),
31589
+ now: () => this.now(),
31590
+ rules: () => this.rules.listEnabled("system-event"),
31591
+ alerts: () => this.deviceAlerts.openEpisodes(),
31592
+ liveness: () => this.liveness.hasAnswered() ? this.liveness.snapshot() : null,
31593
+ lookup: (deviceId) => this.deviceDirectory.get(deviceId),
31594
+ devicesAnswered: () => this.deviceDirectory.isWarm(),
31595
+ ...deps.readNodeDirectory !== void 0 ? { readNodes: deps.readNodeDirectory } : {},
31596
+ livenessHoldoffMs: deps.livenessHoldoffMs ?? 3e5,
31597
+ isMuted: (deviceId) => this.deviceMutes.isMuted(deviceId),
31598
+ texts: () => this.texts,
31599
+ enqueue: (rule, subject) => this.enqueueStateSummary(rule, subject)
31600
+ });
29887
31601
  this.confirmGate = deps.generateVision !== void 0 ? new NcConfirmGate({
29888
31602
  logger: this.logger.child("confirm"),
29889
31603
  generateVision: deps.generateVision,
@@ -29981,7 +31695,10 @@ var NotificationCenter = class NotificationCenter {
29981
31695
  summary: this.summaryRules,
29982
31696
  mutes: this.deviceMutes,
29983
31697
  alarm: () => this.alarmPanel,
29984
- alerts: this.deviceAlerts,
31698
+ alerts: {
31699
+ deviceIds: () => [...new Set([...this.deviceAlerts.deviceIds(), ...this.stateSummary.ledger.deviceIds()])],
31700
+ forget: async (deviceId) => await this.deviceAlerts.forget(deviceId) + this.stateSummary.ledger.forgetDevice(deviceId)
31701
+ },
29985
31702
  directory: this.deviceDirectory,
29986
31703
  isDeviceDeleted: deps.isDeviceDeleted,
29987
31704
  targetCatalog: () => this.dispatcher.catalogSnapshot()
@@ -30092,6 +31809,7 @@ var NotificationCenter = class NotificationCenter {
30092
31809
  await OccupancyStore.declare(store);
30093
31810
  await NcLivenessLedger.declare(store);
30094
31811
  await NcAlertStore.declare(store);
31812
+ await NcStateSummaryService.declare(store);
30095
31813
  await NcSnoozeStore.declare(store);
30096
31814
  await NcDeviceMuteStore.declare(store);
30097
31815
  await TimelapseStore.declare(store);
@@ -30285,6 +32003,10 @@ var NotificationCenter = class NotificationCenter {
30285
32003
  const rows = await this.alertStore.load();
30286
32004
  this.logger.info("device alert ledger reseeded", { meta: { rows } });
30287
32005
  })) return;
32006
+ if (!await phase("stateSummary", async () => {
32007
+ const loaded = await this.stateSummary.load();
32008
+ this.logger.info("state summary ledgers reseeded", { meta: loaded });
32009
+ })) return;
30288
32010
  if (!await phase("occupancy", () => this.hydrateOccupancy())) return;
30289
32011
  if (!await phase("outbox", () => this.outbox.load())) return;
30290
32012
  if (!await phase("cooldowns", () => this.seedCooldowns())) return;
@@ -30590,6 +32312,7 @@ var NotificationCenter = class NotificationCenter {
30590
32312
  atMs: event.timestamp,
30591
32313
  ...event.trackId !== void 0 ? { trackId: event.trackId } : {}
30592
32314
  });
32315
+ if (this.evaluationActive) this.stateSummary.observeDetection(event.deviceId);
30593
32316
  this.consumeEvent(incomingFromObjectEvent(event));
30594
32317
  }
30595
32318
  /**
@@ -30973,9 +32696,15 @@ var NotificationCenter = class NotificationCenter {
30973
32696
  if (!this.evaluationActive) return;
30974
32697
  if (event.category === "addon.updated") {
30975
32698
  const update = parseAddonUpdateComplete(event.data);
30976
- if (update !== null) this.addonUpdates.observe(update);
32699
+ if (update !== null) {
32700
+ this.addonUpdates.observe(update);
32701
+ this.stateSummary.observeAddonUpdated(update);
32702
+ }
30977
32703
  return;
30978
32704
  }
32705
+ this.stateSummary.observeRawEvent(event.category, event.data, this.now(), event.source.nodeId);
32706
+ if (event.category === "addon.started" || event.category === "addon.restarted") return;
32707
+ if (event.category === "pipeline.camera-assigned") return;
30979
32708
  const battery = batterySliceOf(event);
30980
32709
  if (battery !== null) {
30981
32710
  this.deviceAlerts.observeBattery(battery.deviceId, battery.reading);
@@ -31005,6 +32734,7 @@ var NotificationCenter = class NotificationCenter {
31005
32734
  return;
31006
32735
  }
31007
32736
  const system = incoming.subject.systemEvent;
32737
+ if (system !== void 0) this.stateSummary.observeSystem(system, this.now());
31008
32738
  const observation = system !== void 0 ? ledgeredLiveness(system) : null;
31009
32739
  if (observation !== null) {
31010
32740
  const previous = this.liveness.stateOf(observation.subject);
@@ -31059,6 +32789,10 @@ var NotificationCenter = class NotificationCenter {
31059
32789
  logger: this.logger,
31060
32790
  by: caller.userId
31061
32791
  });
32792
+ guardRuleSystemEventDelivery(rule, {
32793
+ logger: this.logger,
32794
+ by: caller.userId
32795
+ });
31062
32796
  await this.validateTargetRefs(rule.targets.map((t) => t.targetId));
31063
32797
  const created = await this.rules.create(rule, caller.userId);
31064
32798
  this.logger.info("notification rule created", { meta: {
@@ -31087,6 +32821,11 @@ var NotificationCenter = class NotificationCenter {
31087
32821
  by: caller.userId,
31088
32822
  ruleId
31089
32823
  });
32824
+ guardPatchSystemEventDelivery(existing, patch, clear ?? [], {
32825
+ logger: this.logger,
32826
+ by: caller.userId,
32827
+ ruleId
32828
+ });
31090
32829
  const { disabledTargetIds: _optOut, ...safePatch } = patch;
31091
32830
  const updated = await this.rules.update(ruleId, safePatch, clear ?? []);
31092
32831
  this.logger.info("notification rule updated", { meta: {
@@ -31105,6 +32844,7 @@ var NotificationCenter = class NotificationCenter {
31105
32844
  return { success: true };
31106
32845
  },
31107
32846
  testRule: async ({ rule, lookbackMinutes }) => ({ results: [...await this.dryRun(rule, lookbackMinutes)] }),
32847
+ previewStateSummary: async ({ rule }) => this.stateSummary.preview(rule),
31108
32848
  getConditionCatalog: async () => ({
31109
32849
  catalog: [...require_dist.NC_CONDITION_CATALOG],
31110
32850
  taxonomy: require_dist.NC_TAXONOMY
@@ -31986,6 +33726,29 @@ var NotificationCenter = class NotificationCenter {
31986
33726
  });
31987
33727
  continue;
31988
33728
  }
33729
+ if (require_dist.isSystemEventSummaryRule(rule)) {
33730
+ this.logger.debug("rule matched — held for its scheduled state summary", {
33731
+ tags: { deviceId: subject.deviceId },
33732
+ meta: {
33733
+ ruleId: rule.id,
33734
+ rule: rule.name,
33735
+ kind: subject.systemEvent?.kind,
33736
+ eventId: subject.recordId
33737
+ }
33738
+ });
33739
+ outcomes.push({
33740
+ ruleId: rule.id,
33741
+ ruleName: rule.name,
33742
+ matched: true,
33743
+ ...evaluation.matchedOn !== void 0 ? { matchedOn: [...evaluation.matchedOn] } : {},
33744
+ cooldown: "n/a",
33745
+ enqueued: 0,
33746
+ targetIds: [],
33747
+ heldForSummary: true
33748
+ });
33749
+ anyMatched = true;
33750
+ continue;
33751
+ }
31989
33752
  this.sceneGateReported.delete(rule.id);
31990
33753
  this.runRuleActions(rule, subject, kind, controls);
31991
33754
  const admission = this.admitToGroup(rule, subject, now);
@@ -32177,6 +33940,31 @@ var NotificationCenter = class NotificationCenter {
32177
33940
  return report({ candidates: candidates.length });
32178
33941
  }
32179
33942
  /**
33943
+ * Enqueue ONE state summary (D647) through the rule's ordinary rows: its
33944
+ * targets, its users (admins only — the row files under the system device),
33945
+ * its template, and the drain's snooze gate and retries. The record id is
33946
+ * the dedup key, so a window is enqueued once whatever the restarts.
33947
+ */
33948
+ async enqueueStateSummary(rule, subject) {
33949
+ const appended = withSummaryBody(rule);
33950
+ if (appended !== null) this.logger.warn("state summary rule body names no summary content — {{summaryText}} appended", { meta: {
33951
+ ruleId: rule.id,
33952
+ rule: rule.name
33953
+ } });
33954
+ const sent = appended ?? rule;
33955
+ const userTargets = await this.resolveUserTargets(sent, subject.deviceId);
33956
+ const entries = this.buildEntries(sent, subject, "system-event", ["systemEvent"], userTargets, "system");
33957
+ if (entries.length === 0) {
33958
+ this.logger.warn("state summary reached NO target — its users are not admins, and it has no targets", { meta: {
33959
+ ruleId: rule.id,
33960
+ rule: rule.name,
33961
+ targetUsers: rule.targetUsers ?? []
33962
+ } });
33963
+ return 0;
33964
+ }
33965
+ return this.outbox.enqueue(entries);
33966
+ }
33967
+ /**
32180
33968
  * The rules a trigger may fire, which is `listEnabled(delivery)` plus and
32181
33969
  * minus exactly one thing: `waitForEnhancement`.
32182
33970
  *
@@ -32919,6 +34707,9 @@ var NotificationCenter = class NotificationCenter {
32919
34707
  async reloadRules() {
32920
34708
  await this.rules.load();
32921
34709
  if (this.evaluationActive) this.deviceAlerts.tick();
34710
+ if (this.evaluationActive) this.stateSummary.tick().catch((err) => {
34711
+ this.logger.warn("state summary tick failed", { meta: { error: String(err) } });
34712
+ });
32922
34713
  await this.timelapseRules.load();
32923
34714
  await this.summaryRules.load();
32924
34715
  await this.ruleAi.load();
@@ -47279,6 +49070,16 @@ var FrameProcessor = class {
47279
49070
  };
47280
49071
  }
47281
49072
  };
49073
+ //#endregion
49074
+ //#region src/pipeline-analytics/pipeline/track-provenance.ts
49075
+ function trackProvenanceOf(frame) {
49076
+ if (!(frame.width > 0 && frame.height > 0)) return void 0;
49077
+ return {
49078
+ ...frame.rootModelId !== void 0 && frame.rootModelId.length > 0 ? { modelId: frame.rootModelId } : {},
49079
+ frameWidth: frame.width,
49080
+ frameHeight: frame.height
49081
+ };
49082
+ }
47282
49083
  /** Bounded trackId → pending-upgrade map. Insertion-ordered, so the cap
47283
49084
  * evicts the oldest record. */
47284
49085
  var PendingPreviewUpgrades = class {
@@ -50100,51 +51901,6 @@ var PlateGalleryProvider = class {
50100
51901
  }
50101
51902
  };
50102
51903
  //#endregion
50103
- //#region src/pipeline-analytics/replay/frame-processor-pass.ts
50104
- /**
50105
- * Run a FRESH `FrameProcessor` over a fixed, ordered sequence of frames and
50106
- * return the tracks it produced.
50107
- *
50108
- * No `TrackStore`, no bus, no media — a track here is a summary built
50109
- * ENTIRELY from `FrameProcessor.process()`'s in-memory return value.
50110
- */
50111
- function runFrameProcessorPass(input) {
50112
- const processor = new FrameProcessor(input.deviceId, {}, {}, {}, input.source);
50113
- if (input.zones !== void 0) processor.setZones(input.zones);
50114
- if (input.detectionRules !== void 0) processor.setDetectionRules(input.detectionRules);
50115
- if (input.zoneMembershipMinOverlap !== void 0) processor.setZoneMembershipMinOverlap(input.zoneMembershipMinOverlap);
50116
- const accum = /* @__PURE__ */ new Map();
50117
- for (const { timestamp, frame } of input.frames) {
50118
- const result = processor.process({
50119
- timestamp,
50120
- frame
50121
- });
50122
- for (const td of result.tracked) {
50123
- const existing = accum.get(td.trackId);
50124
- const matched = td.matchedThisFrame ? 1 : 0;
50125
- if (existing === void 0) accum.set(td.trackId, {
50126
- className: td.className,
50127
- firstSeenMs: timestamp,
50128
- lastSeenMs: timestamp,
50129
- bbox: { ...td.bbox },
50130
- framesMatched: matched
50131
- });
50132
- else {
50133
- existing.className = td.className;
50134
- existing.lastSeenMs = timestamp;
50135
- existing.framesMatched += matched;
50136
- }
50137
- }
50138
- }
50139
- return { tracks: [...accum.values()].map((t) => ({
50140
- className: t.className,
50141
- firstSeenMs: t.firstSeenMs,
50142
- lastSeenMs: t.lastSeenMs,
50143
- bbox: t.bbox,
50144
- framesMatched: t.framesMatched
50145
- })) };
50146
- }
50147
- //#endregion
50148
51904
  //#region src/pipeline-analytics/retention/disk-reconcile.ts
50149
51905
  /** Is this an event-owned media row — `motion` / `object` / `audio` (D476;
50150
51906
  * `event` used to cover all three under one owner kind). */
@@ -52415,6 +54171,10 @@ var TRACKS_COLUMNS = [
52415
54171
  name: "frameHeight",
52416
54172
  type: "INTEGER"
52417
54173
  },
54174
+ {
54175
+ name: "modelId",
54176
+ type: "TEXT"
54177
+ },
52418
54178
  {
52419
54179
  name: "closedReason",
52420
54180
  type: "TEXT"
@@ -52579,7 +54339,12 @@ function cloneTrack(t) {
52579
54339
  ...t.hasFace !== void 0 ? { hasFace: t.hasFace } : {},
52580
54340
  ...t.hasEmbeddedFace !== void 0 ? { hasEmbeddedFace: t.hasEmbeddedFace } : {},
52581
54341
  ...t.hasRider !== void 0 ? { hasRider: t.hasRider } : {},
52582
- ...t.closedReason !== void 0 ? { closedReason: t.closedReason } : {}
54342
+ ...t.closedReason !== void 0 ? { closedReason: t.closedReason } : {},
54343
+ ...t.provenance?.modelId !== void 0 ? { modelId: t.provenance.modelId } : {},
54344
+ ...t.provenance !== void 0 ? {
54345
+ frameWidth: t.provenance.frameWidth,
54346
+ frameHeight: t.provenance.frameHeight
54347
+ } : {}
52583
54348
  };
52584
54349
  }
52585
54350
  var TrackStore = class {
@@ -52676,7 +54441,8 @@ var TrackStore = class {
52676
54441
  totalDistance: 0,
52677
54442
  state: params.state,
52678
54443
  active: true,
52679
- lastSnapshotAt: 0
54444
+ lastSnapshotAt: 0,
54445
+ ...params.provenance !== void 0 ? { provenance: params.provenance } : {}
52680
54446
  };
52681
54447
  this.active.set(params.trackId, fresh);
52682
54448
  return fresh;
@@ -54137,7 +55903,10 @@ var TrackStore = class {
54137
55903
  });
54138
55904
  }
54139
55905
  async persistCompleted(t) {
54140
- const dims = this.frameDims?.(t.deviceId);
55906
+ const dims = t.frameWidth !== void 0 && t.frameHeight !== void 0 ? {
55907
+ w: t.frameWidth,
55908
+ h: t.frameHeight
55909
+ } : this.frameDims?.(t.deviceId);
54141
55910
  const envelope = computeTrackEnvelope(t.positions, dims?.w, dims?.h);
54142
55911
  await this.store.set.mutate({
54143
55912
  collection: TRACKS_COLLECTION,
@@ -54173,14 +55942,17 @@ var TrackStore = class {
54173
55942
  ...t.hasEmbeddedFace !== void 0 ? { hasEmbeddedFace: t.hasEmbeddedFace } : {},
54174
55943
  ...t.hasRider !== void 0 ? { hasRider: t.hasRider } : {},
54175
55944
  ...t.closedReason !== void 0 ? { closedReason: t.closedReason } : {},
54176
- ...envelope !== null && dims !== void 0 ? {
55945
+ ...envelope !== null ? {
54177
55946
  envMinX: envelope.minX,
54178
55947
  envMinY: envelope.minY,
54179
55948
  envMaxX: envelope.maxX,
54180
- envMaxY: envelope.maxY,
55949
+ envMaxY: envelope.maxY
55950
+ } : {},
55951
+ ...dims !== void 0 ? {
54181
55952
  frameWidth: dims.w,
54182
55953
  frameHeight: dims.h
54183
- } : {}
55954
+ } : {},
55955
+ ...t.modelId !== void 0 ? { modelId: t.modelId } : {}
54184
55956
  }
54185
55957
  });
54186
55958
  }
@@ -54240,6 +56012,9 @@ var TrackStore = class {
54240
56012
  /** Free-form composed token line (see `composePreviewMissReason`) — a
54241
56013
  * string read, absent stays absent. */
54242
56014
  const previewMissReason = data["previewMissReason"];
56015
+ const modelId = data["modelId"];
56016
+ const frameWidth = data["frameWidth"];
56017
+ const frameHeight = data["frameHeight"];
54243
56018
  const envMinX = data["envMinX"];
54244
56019
  const envMinY = data["envMinY"];
54245
56020
  const envMaxX = data["envMaxX"];
@@ -54283,7 +56058,12 @@ var TrackStore = class {
54283
56058
  ...closedReason !== void 0 ? { closedReason } : {},
54284
56059
  ...typeof previewMissReason === "string" && previewMissReason.length > 0 ? { previewMissReason } : {},
54285
56060
  ...observations !== void 0 ? { observations } : {},
54286
- ...envelope !== null ? { envelope } : {}
56061
+ ...envelope !== null ? { envelope } : {},
56062
+ ...typeof modelId === "string" && modelId.length > 0 ? { modelId } : {},
56063
+ ...typeof frameWidth === "number" && typeof frameHeight === "number" ? {
56064
+ frameWidth,
56065
+ frameHeight
56066
+ } : {}
54287
56067
  });
54288
56068
  }
54289
56069
  };
@@ -59736,7 +61516,6 @@ var DEFAULT_EMBEDDING_CONFIG = {
59736
61516
  enabled: true,
59737
61517
  modelId: "clip-vit-b32",
59738
61518
  agentId: "local",
59739
- runtime: "node",
59740
61519
  backend: "cpu",
59741
61520
  classes: [],
59742
61521
  minConfidence: .5,
@@ -77674,6 +79453,9 @@ var NC_SYSTEM_EVENT_CATEGORIES = [
77674
79453
  require_dist.EventCategory.PipelineNodeInferenceUnavailable,
77675
79454
  require_dist.EventCategory.PipelineDetectionBlind,
77676
79455
  require_dist.EventCategory.AddonRunnerFailed,
79456
+ require_dist.EventCategory.AddonStarted,
79457
+ require_dist.EventCategory.AddonRestarted,
79458
+ require_dist.EventCategory.PipelineCameraAssigned,
77677
79459
  require_dist.EventCategory.UpdateAvailable,
77678
79460
  require_dist.EventCategory.AddonUpdated,
77679
79461
  require_dist.EventCategory.SystemRestartCompleted,
@@ -87296,6 +89078,7 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_dist.B
87296
89078
  };
87297
89079
  },
87298
89080
  isDeviceDeleted: async (deviceId) => await api.deviceManager.getDevice.query({ deviceId }) === null,
89081
+ readNodeDirectory: () => api.nodes.directory.query(),
87299
89082
  getSnapshot: (deviceId) => api.snapshot.getSnapshot.query({ deviceId }),
87300
89083
  ...buildLinkedSnapshotReads(api.snapshot),
87301
89084
  identityIdsByName: async () => {
@@ -88022,21 +89805,6 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_dist.B
88022
89805
  ...input.nodeId !== void 0 ? { nodeId: input.nodeId } : {}
88023
89806
  });
88024
89807
  }
88025
- /**
88026
- * The FrameProcessor pass of a replay run — see the cap doc on
88027
- * `runReplayFrameProcessor` for why this is not the `processFrame` method
88028
- * this addon deliberately does not have.
88029
- *
88030
- * `runFrameProcessorPass` is pure w.r.t. this addon's own state: it
88031
- * constructs a FRESH `FrameProcessor` and does not touch `this.trackStore`,
88032
- * `this.stationaryRegistry` or any other instance field — unlike
88033
- * `getOrCreateProcessor` (the production path), which wires both.
88034
- * `scripts/check-replay-isolation.ts` holds that boundary on the
88035
- * `replay/` directory this delegates into.
88036
- */
88037
- async runReplayFrameProcessor(input) {
88038
- return runFrameProcessorPass(input);
88039
- }
88040
89808
  async listRetrainAnnotations(input) {
88041
89809
  return this.requireRetrain().listAnnotations(input.trackId);
88042
89810
  }
@@ -89142,6 +90910,7 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_dist.B
89142
90910
  });
89143
90911
  const currentTrackIds = /* @__PURE__ */ new Set();
89144
90912
  const positionsCountById = /* @__PURE__ */ new Map();
90913
+ const provenance = trackProvenanceOf(frame);
89145
90914
  for (const t of result.tracked) {
89146
90915
  currentTrackIds.add(t.trackId);
89147
90916
  if (this.suppressedBirths.isRejected(key, t.trackId)) continue;
@@ -89165,7 +90934,8 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_dist.B
89165
90934
  score: t.confidence
89166
90935
  },
89167
90936
  zones: t.zones,
89168
- state: t.state
90937
+ state: t.state,
90938
+ ...provenance !== void 0 ? { provenance } : {}
89169
90939
  });
89170
90940
  positionsCountById.set(t.trackId, upserted.positions.length);
89171
90941
  }