@camstack/addon-post-analysis 1.2.308 → 1.2.309

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_clip_model_registry = require("../clip-model-registry-BqrTKyUe.js");
5
+ const require_clip_model_registry = require("../clip-model-registry-B3M4ZI17.js");
6
6
  let node_fs = require("node:fs");
7
7
  node_fs = require_clip_model_registry.__toESM(node_fs, 1);
8
8
  let node_path = require("node:path");
@@ -13492,6 +13492,8 @@ var en_default = {
13492
13492
  "summary.body.empty": "nothing was seen in this window",
13493
13493
  "system.state-summary.title": "{{rule}}",
13494
13494
  "system.state-summary.body": "{{summaryText}}",
13495
+ "system.state-summary.section.title": "{{rule}} — {{summarySection}}",
13496
+ "system.state-summary.section.body": "{{summaryText}}",
13495
13497
  "summary.section.device-state": "Devices offline or switched off",
13496
13498
  "summary.section.streams": "Streams offline",
13497
13499
  "summary.section.battery": "Low batteries",
@@ -13820,6 +13822,8 @@ var it_default = {
13820
13822
  "summary.body.empty": "nessun evento in questa finestra",
13821
13823
  "system.state-summary.title": "{{rule}}",
13822
13824
  "system.state-summary.body": "{{summaryText}}",
13825
+ "system.state-summary.section.title": "{{rule}} — {{summarySection}}",
13826
+ "system.state-summary.section.body": "{{summaryText}}",
13823
13827
  "summary.section.device-state": "Dispositivi offline o spenti",
13824
13828
  "summary.section.streams": "Stream offline",
13825
13829
  "summary.section.battery": "Batterie scariche",
@@ -15765,6 +15769,38 @@ function planRuleTest(input) {
15765
15769
  };
15766
15770
  }
15767
15771
  //#endregion
15772
+ //#region src/notification-center/state-summary/state-summary-test.ts
15773
+ /**
15774
+ * What TESTING a state-summary rule answers (D698, `nc.testRule`).
15775
+ *
15776
+ * A summary rule sends nothing per event (D647), so the per-event test the
15777
+ * other rules get — one synthetic event, injected — can never deliver for it.
15778
+ * Its test builds the summary NOW and enqueues it; this is the report: the
15779
+ * rows it enqueued (one, or one per section) and every section's read, so
15780
+ * "nothing to report" is an answer with its reason per section, never a
15781
+ * silence.
15782
+ */
15783
+ var NcStateSummaryTestSchema = require_clip_model_registry.object({
15784
+ /** The test's record id — a per-section row appends `:<sectionId>`. */
15785
+ recordId: require_clip_model_registry.string(),
15786
+ split: require_clip_model_registry._enum(["rule", "section"]),
15787
+ /** Nothing open and every section read: nothing was enqueued. */
15788
+ nothingToReport: require_clip_model_registry.boolean(),
15789
+ sections: require_clip_model_registry.array(require_clip_model_registry.object({
15790
+ id: require_clip_model_registry.string(),
15791
+ status: require_clip_model_registry._enum(["read", "unavailable"]),
15792
+ items: require_clip_model_registry.number().int().nonnegative(),
15793
+ reason: require_clip_model_registry.string().optional()
15794
+ })),
15795
+ rows: require_clip_model_registry.array(require_clip_model_registry.object({
15796
+ recordId: require_clip_model_registry.string(),
15797
+ /** The section of a per-section row. */
15798
+ section: require_clip_model_registry.string().optional(),
15799
+ enqueued: require_clip_model_registry.number().int().nonnegative(),
15800
+ targetIds: require_clip_model_registry.array(require_clip_model_registry.string())
15801
+ }))
15802
+ });
15803
+ //#endregion
15768
15804
  //#region src/notification-center/clock-text.ts
15769
15805
  /**
15770
15806
  * `{{time}}` — hours and minutes in the NOTIFICATION language.
@@ -17132,13 +17168,18 @@ var ncActions = require_clip_model_registry.defineCustomActions({
17132
17168
  deviceId: require_clip_model_registry.number().int().optional()
17133
17169
  }), NcTestEventResultSchema.extend({
17134
17170
  /** The subject kind the rule was tested with — the answer to "why was my
17135
- * rule not a candidate". */
17171
+ * rule not a candidate". `state-summary` for a summary rule (D698). */
17136
17172
  trigger: require_clip_model_registry.string(),
17137
17173
  /** What the plan could not reproduce (a device-state gate, a polygon). */
17138
17174
  planNotes: require_clip_model_registry.array(require_clip_model_registry.object({
17139
17175
  conditionId: require_clip_model_registry.string(),
17140
17176
  note: require_clip_model_registry.string()
17141
- }))
17177
+ })),
17178
+ /**
17179
+ * A STATE-SUMMARY rule's test (D698): the summary built now and the rows
17180
+ * it enqueued, or why nothing was — absent on every other rule.
17181
+ */
17182
+ stateSummary: NcStateSummaryTestSchema.optional()
17142
17183
  }), {
17143
17184
  kind: "mutation",
17144
17185
  auth: "admin",
@@ -17497,6 +17538,48 @@ function makeNcActionHandlers(deps) {
17497
17538
  if (rule.ownerUserId !== void 0 && rule.ownerUserId !== caller.userId) throw new Error(`forbidden: rule not owned: ${ruleId}`);
17498
17539
  return rule;
17499
17540
  };
17541
+ /**
17542
+ * TEST a STATE-SUMMARY rule (D698). The per-event injection cannot deliver
17543
+ * for it — a summary rule sends nothing per event (D647), and that test used
17544
+ * to be dropped without a word — so the summary is built NOW and enqueued.
17545
+ * The answer is shaped like every rule test's, plus the summary's own report.
17546
+ */
17547
+ const testSummaryRule = async (rule, by) => {
17548
+ const test = deps.testStateSummary;
17549
+ if (test === void 0) throw new Error("this node does not host the notification centre — test on the hub");
17550
+ deps.logger.info("rule test requested", { meta: {
17551
+ by,
17552
+ ruleId: rule.id,
17553
+ ruleName: rule.name,
17554
+ trigger: "state-summary",
17555
+ bypassMute: true
17556
+ } });
17557
+ const report = await test(rule);
17558
+ const enqueued = report.rows.reduce((sum, row) => sum + row.enqueued, 0);
17559
+ const notes = ["per-camera notification mute bypassed"];
17560
+ if (report.nothingToReport) notes.push(`nothing to report — ${report.sections.map((section) => `${section.id}: ${section.status === "read" ? `${String(section.items)} open` : "unavailable"}`).join(", ")} — nothing was sent, as the scheduled summary would send nothing`);
17561
+ else if (enqueued === 0) notes.push("the summary was built but no row was enqueued — the rule reaches no target");
17562
+ return {
17563
+ recordId: report.recordId,
17564
+ origin: "synthetic",
17565
+ delivery: rule.delivery,
17566
+ candidates: 1,
17567
+ outcomes: [{
17568
+ ruleId: rule.id,
17569
+ ruleName: rule.name,
17570
+ matched: !report.nothingToReport,
17571
+ cooldown: "bypassed",
17572
+ enqueued,
17573
+ targetIds: [...new Set(report.rows.flatMap((row) => row.targetIds))]
17574
+ }],
17575
+ still: "unattachable",
17576
+ notes,
17577
+ ...report.nothingToReport ? { droppedBy: "nothing-to-report" } : {},
17578
+ trigger: "state-summary",
17579
+ planNotes: [],
17580
+ stateSummary: report
17581
+ };
17582
+ };
17500
17583
  /** The producer half, or an honest refusal. */
17501
17584
  const requireTimelapse = () => {
17502
17585
  const tl = deps.timelapse;
@@ -17831,6 +17914,7 @@ function makeNcActionHandlers(deps) {
17831
17914
  const inject = deps.injectTestEvent;
17832
17915
  if (inject === void 0) throw new Error("this node does not host the notification centre — test on the hub");
17833
17916
  const rule = assertRuleVisible(input.ruleId, c);
17917
+ if (require_clip_model_registry.isSystemEventSummaryRule(rule)) return testSummaryRule(rule, c.userId);
17834
17918
  const plan = planRuleTest({
17835
17919
  rule,
17836
17920
  now: Date.now(),
@@ -19665,6 +19749,77 @@ var NcDeviceStateCache = class {
19665
19749
  }
19666
19750
  };
19667
19751
  //#endregion
19752
+ //#region src/notification-center/state-summary/state-model.ts
19753
+ /**
19754
+ * The STATE SUMMARY's vocabulary (D647): its sections, and what one reported
19755
+ * item is.
19756
+ *
19757
+ * A section is one of the D637 kind GROUPS (`NC_SYSTEM_EVENT_KIND_GROUP_SPECS`)
19758
+ * — the same grouping both editors list kinds under — so "the rule selected a
19759
+ * battery kind" and "the summary has a battery section" are the same fact.
19760
+ * `backups-exports` is the one group with no section: a backup or an export is
19761
+ * a job that COMPLETED, and the state it leaves behind is not a condition that
19762
+ * is still true. The ADR records why it is excluded rather than approximated.
19763
+ */
19764
+ var NC_STATE_SUMMARY_SECTION_IDS = [
19765
+ "device-state",
19766
+ "streams",
19767
+ "battery",
19768
+ "consumables",
19769
+ "tanks",
19770
+ "detection-blind",
19771
+ "nodes",
19772
+ "addons",
19773
+ "updates"
19774
+ ];
19775
+ /** The kind groups that have no section, and why — said on the preview. */
19776
+ var NC_STATE_SUMMARY_EXCLUDED_GROUPS = { "backups-exports": "a backup or an export is a completed job, not a state that is still true" };
19777
+ function isStateSectionId(value) {
19778
+ return NC_STATE_SUMMARY_SECTION_IDS.some((id) => id === value);
19779
+ }
19780
+ /** The template variable that carries one section's lines (`template-vars.ts`). */
19781
+ var NC_STATE_SECTION_VAR = {
19782
+ "device-state": "summaryDevices",
19783
+ streams: "summaryStreams",
19784
+ battery: "summaryBattery",
19785
+ consumables: "summaryConsumables",
19786
+ tanks: "summaryTanks",
19787
+ "detection-blind": "summaryDetection",
19788
+ nodes: "summaryNodes",
19789
+ addons: "summaryAddons",
19790
+ updates: "summaryUpdates"
19791
+ };
19792
+ /** The kinds of a section — its kind group. */
19793
+ function sectionKinds(section) {
19794
+ return require_clip_model_registry.NC_SYSTEM_EVENT_KIND_GROUP_SPECS.find((group) => group.id === section)?.kinds ?? [];
19795
+ }
19796
+ /**
19797
+ * The sections a rule reports, in section order: every section whose kind
19798
+ * group shares a kind with the rule. A rule that selected only
19799
+ * `device-battery-low` still reports the battery STATE — the state has no
19800
+ * direction, only the event did.
19801
+ */
19802
+ function sectionsOfRule(condition) {
19803
+ return NC_STATE_SUMMARY_SECTION_IDS.filter((section) => sectionKinds(section).some((kind) => condition.kinds.includes(kind)));
19804
+ }
19805
+ /** The kind groups the rule selected that have no section. */
19806
+ function excludedGroupsOfRule(condition) {
19807
+ return require_clip_model_registry.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);
19808
+ }
19809
+ /**
19810
+ * The condition the summary judges its items with: the rule's own, with its
19811
+ * kinds widened to every kind of every section it reports — so a rule that
19812
+ * named the raise is not told "not my kind" about the state the raise left
19813
+ * behind. Every FILTER is the rule's, untouched.
19814
+ */
19815
+ function summaryCondition(condition) {
19816
+ const kinds = sectionsOfRule(condition).flatMap((section) => sectionKinds(section));
19817
+ return {
19818
+ ...condition,
19819
+ kinds: [...new Set([...condition.kinds, ...kinds])]
19820
+ };
19821
+ }
19822
+ //#endregion
19668
19823
  //#region src/notification-center/state-summary/state-summary-delivery.ts
19669
19824
  /**
19670
19825
  * A state summary as an ORDINARY system-event subject (D647), so it reaches the
@@ -19673,7 +19828,9 @@ var NcDeviceStateCache = class {
19673
19828
  * which renders the rule's own template with the summary's variables.
19674
19829
  *
19675
19830
  * - `recordId` is `state-summary:<ruleId>:<windowEndMs>` — the outbox dedup
19676
- * key, so a restart can never enqueue one window twice.
19831
+ * key, so a restart can never enqueue one window twice. Split per SECTION
19832
+ * (D698), each row is `state-summary:<ruleId>:<windowEndMs>:<sectionId>`:
19833
+ * the same guarantee, per section.
19677
19834
  * - The row files under the system device (0): a summary spans devices.
19678
19835
  * `targetUsers` therefore reach admins only — a user whose grant is
19679
19836
  * narrower than the whole installation must not be sent the names of
@@ -19687,20 +19844,37 @@ var NcDeviceStateCache = class {
19687
19844
  */
19688
19845
  var NC_STATE_SUMMARY_TITLE_KEY = "system.state-summary.title";
19689
19846
  var NC_STATE_SUMMARY_BODY_KEY = "system.state-summary.body";
19847
+ /** A per-section row's title and body (D698): the rule's name and the section's. */
19848
+ var NC_STATE_SUMMARY_SECTION_TITLE_KEY = "system.state-summary.section.title";
19849
+ var NC_STATE_SUMMARY_SECTION_BODY_KEY = "system.state-summary.section.body";
19690
19850
  function stateSummaryRecordId(ruleId, windowEndMs) {
19691
19851
  return `state-summary:${ruleId}:${String(windowEndMs)}`;
19692
19852
  }
19853
+ /**
19854
+ * The record id of a TEST of the rule (D698, `nc.testRule`): its own prefix,
19855
+ * so a test can never take — or be swallowed by — a real window's dedup key.
19856
+ */
19857
+ function stateSummaryTestRecordId(ruleId, nowMs) {
19858
+ return `state-summary-test:${ruleId}:${String(nowMs)}`;
19859
+ }
19860
+ /** One section's row: the record id of the whole, plus `:<sectionId>` — the per-section dedup key. */
19861
+ function sectionRecordId(base, sectionId) {
19862
+ return `${base}:${sectionId}`;
19863
+ }
19693
19864
  /** Is this system-event data the data of a state-summary row? */
19694
19865
  function isStateSummaryData(data) {
19695
19866
  return data["stateSummary"] === true;
19696
19867
  }
19868
+ function recordBaseOf(input) {
19869
+ return input.recordBase ?? stateSummaryRecordId(input.rule.id, input.window.endMs);
19870
+ }
19697
19871
  /** `null` when the rule has no system-event condition (the guard refuses that). */
19698
19872
  function stateSummarySubject(input) {
19699
19873
  const kind = input.rule.conditions.systemEvent?.kinds[0];
19700
19874
  if (kind === void 0) return null;
19701
19875
  return {
19702
19876
  kind: "system-event",
19703
- recordId: stateSummaryRecordId(input.rule.id, input.window.endMs),
19877
+ recordId: recordBaseOf(input),
19704
19878
  deviceId: NC_SYSTEM_DEVICE_ID$1,
19705
19879
  timestamp: input.window.endMs,
19706
19880
  classNames: [],
@@ -19713,6 +19887,7 @@ function stateSummarySubject(input) {
19713
19887
  textParams: {
19714
19888
  summaryText: input.text.text,
19715
19889
  summaryCount: String(input.text.count),
19890
+ summarySection: input.text.sections.map((section) => section.title).join(", "),
19716
19891
  ...input.text.sectionVars
19717
19892
  },
19718
19893
  textCount: input.text.count,
@@ -19730,13 +19905,82 @@ function stateSummarySubject(input) {
19730
19905
  };
19731
19906
  }
19732
19907
  /**
19908
+ * The rows one summary window sends. `rule`: the one D647 row. `section`: one
19909
+ * row per section with something to say — its items, or the sentence that it
19910
+ * is UNAVAILABLE, exactly as the combined text words it. A section with
19911
+ * nothing open sends nothing; neither does a summary with nothing at all.
19912
+ */
19913
+ function stateSummarySubjects(input) {
19914
+ if (input.split === "rule") {
19915
+ const subject = stateSummarySubject(input);
19916
+ return subject === null ? [] : [subject];
19917
+ }
19918
+ const kind = input.rule.conditions.systemEvent?.kinds[0];
19919
+ if (kind === void 0) return [];
19920
+ return input.text.sections.map((section) => sectionSubject(input, kind, section));
19921
+ }
19922
+ function sectionSubject(input, kind, section) {
19923
+ const sectionVars = {};
19924
+ for (const name of Object.keys(input.text.sectionVars)) sectionVars[name] = "";
19925
+ sectionVars[NC_STATE_SECTION_VAR[section.id]] = section.text;
19926
+ return {
19927
+ kind: "system-event",
19928
+ recordId: sectionRecordId(recordBaseOf(input), section.id),
19929
+ deviceId: NC_SYSTEM_DEVICE_ID$1,
19930
+ timestamp: input.window.endMs,
19931
+ classNames: [],
19932
+ zones: [],
19933
+ systemEvent: {
19934
+ kind,
19935
+ subject: `state-summary:${input.rule.id}:${section.id}`,
19936
+ titleKey: NC_STATE_SUMMARY_SECTION_TITLE_KEY,
19937
+ bodyKey: NC_STATE_SUMMARY_SECTION_BODY_KEY,
19938
+ textParams: {
19939
+ summaryText: section.body,
19940
+ summaryCount: String(section.count),
19941
+ summarySection: section.title,
19942
+ ...sectionVars
19943
+ },
19944
+ textCount: section.count,
19945
+ data: {
19946
+ stateSummary: true,
19947
+ section: section.id,
19948
+ windowStartMs: input.window.startMs,
19949
+ windowEndMs: input.window.endMs,
19950
+ sections: [{
19951
+ id: section.id,
19952
+ status: section.status,
19953
+ items: section.count
19954
+ }]
19955
+ }
19956
+ }
19957
+ };
19958
+ }
19959
+ /** The section a per-section row is about (`data.section`), or `undefined` on a combined row. */
19960
+ function stateSummarySectionOf(subject) {
19961
+ const section = subject.systemEvent?.data["section"];
19962
+ return typeof section === "string" && isStateSectionId(section) ? section : void 0;
19963
+ }
19964
+ /** Does a body name `{{summaryText}}` or this section's own variable? */
19965
+ function bodyCarriesSection(body, section) {
19966
+ return new RegExp(`\\{\\{\\s*(?:summaryText|${NC_STATE_SECTION_VAR[section]})\\s*\\}\\}`).test(body);
19967
+ }
19968
+ /**
19733
19969
  * The rule as its summary is ENQUEUED: a custom body that carries none of the
19734
19970
  * summary's content gets `{{summaryText}}` appended on its own line, so an
19735
19971
  * operator's body never sends an empty summary (review M3). `null` = unchanged.
19972
+ *
19973
+ * On a per-SECTION row (D698 review M3) the content is THIS section's: a body
19974
+ * naming only `{{summaryBattery}}` carries the battery row and nothing on the
19975
+ * streams row, so every other section's row gets `{{summaryText}}` — its own
19976
+ * lines — appended.
19736
19977
  */
19737
- function withSummaryBody(rule) {
19978
+ function withSummaryBody(rule, section) {
19738
19979
  const body = rule.template?.body;
19739
- if (require_clip_model_registry.templateCarriesSummary(body)) return null;
19980
+ if (require_clip_model_registry.templateCarriesSummary(body)) {
19981
+ if (section === void 0 || body === void 0 || body.trim().length === 0) return null;
19982
+ if (bodyCarriesSection(body, section)) return null;
19983
+ }
19740
19984
  return {
19741
19985
  ...rule,
19742
19986
  template: {
@@ -23785,6 +24029,120 @@ function withTimeout$3(promise, ms, what) {
23785
24029
  });
23786
24030
  }
23787
24031
  //#endregion
24032
+ //#region src/notification-center/boot-window-rows.ts
24033
+ var BootWindowRows = class {
24034
+ deps;
24035
+ window = /* @__PURE__ */ new Map();
24036
+ /** Deferred closes, keyed so a repeat (one per detection) replaces rather than grows. */
24037
+ deferred = /* @__PURE__ */ new Map();
24038
+ settled = false;
24039
+ constructor(deps) {
24040
+ this.deps = deps;
24041
+ }
24042
+ /** Is the ledger still inside its boot window (not settled after an answered load)? */
24043
+ open() {
24044
+ return !this.settled;
24045
+ }
24046
+ /** Stage a row that would be CREATED inside the window — mirror only, never persisted. */
24047
+ stageProvisional(row) {
24048
+ this.deps.ledger.stage(row);
24049
+ this.window.set(this.deps.keyOf(row), {
24050
+ kind: "provisional",
24051
+ row
24052
+ });
24053
+ }
24054
+ /** A row the ledger persisted inside the window: the load must not regress it. */
24055
+ noteWritten(row) {
24056
+ this.window.set(this.deps.keyOf(row), {
24057
+ kind: "written",
24058
+ row
24059
+ });
24060
+ }
24061
+ /** A key the ledger removed inside the window: the load must not resurrect it. */
24062
+ noteRemoved(key) {
24063
+ this.window.set(key, { kind: "removed" });
24064
+ }
24065
+ /**
24066
+ * While the window is open, keep `replay` to run against the LOADED rows at
24067
+ * the settle; once settled, nothing — the caller's own pass already saw every
24068
+ * row (review LOW-5: no allocation per detection after boot). `key` names the
24069
+ * close: the same key deferred twice runs once, at the place it was first
24070
+ * deferred. `replay` answers how many rows it closed.
24071
+ */
24072
+ deferWhileOpen(key, replay) {
24073
+ if (this.open()) this.deferred.set(key, replay);
24074
+ }
24075
+ /**
24076
+ * Close the window — call right after the ledger's `load`. A load that did
24077
+ * not answer leaves the window open and returns `null`.
24078
+ */
24079
+ settle() {
24080
+ if (this.settled) return null;
24081
+ if (!this.deps.ledger.hasAnswered()) {
24082
+ this.deps.logger.warn("boot window NOT settled — the load did not answer", { meta: {
24083
+ collection: this.deps.ledger.collection,
24084
+ pendingCloses: this.deferred.size,
24085
+ provisionalRows: this.window.size
24086
+ } });
24087
+ return null;
24088
+ }
24089
+ this.settled = true;
24090
+ const ledger = this.deps.ledger;
24091
+ let replayed = 0;
24092
+ for (const replay of this.deferred.values()) replayed += replay();
24093
+ this.deferred.clear();
24094
+ let keptStored = 0;
24095
+ let written = 0;
24096
+ for (const [key, entry] of this.window) {
24097
+ if (entry.kind === "removed") {
24098
+ ledger.forget(key);
24099
+ continue;
24100
+ }
24101
+ if (entry.kind === "written") {
24102
+ ledger.stage(entry.row);
24103
+ continue;
24104
+ }
24105
+ const mirrored = ledger.get(key);
24106
+ const stored = mirrored === entry.row ? void 0 : mirrored;
24107
+ const held = this.deps.merge(stored, entry.row);
24108
+ const deviceId = this.deps.deviceIdOf(entry.row);
24109
+ const tags = deviceId !== void 0 ? { tags: { deviceId } } : {};
24110
+ if (stored !== void 0 && held === stored) {
24111
+ keptStored += 1;
24112
+ this.deps.logger.debug("boot-window row kept as stored — the restart learned nothing new", {
24113
+ ...tags,
24114
+ meta: {
24115
+ collection: ledger.collection,
24116
+ key
24117
+ }
24118
+ });
24119
+ continue;
24120
+ }
24121
+ ledger.put(held);
24122
+ written += 1;
24123
+ this.deps.logger.debug("boot-window row written at load", {
24124
+ ...tags,
24125
+ meta: {
24126
+ collection: ledger.collection,
24127
+ key,
24128
+ stored: stored !== void 0
24129
+ }
24130
+ });
24131
+ }
24132
+ this.window.clear();
24133
+ const merge = {
24134
+ keptStored,
24135
+ written,
24136
+ replayed
24137
+ };
24138
+ this.deps.logger.info("boot window settled", { meta: {
24139
+ collection: ledger.collection,
24140
+ ...merge
24141
+ } });
24142
+ return merge;
24143
+ }
24144
+ };
24145
+ //#endregion
23788
24146
  //#region src/notification-center/liveness-ledger.ts
23789
24147
  /**
23790
24148
  * @durable class=ledger owner=notification-center
@@ -23895,12 +24253,26 @@ var NC_LIVENESS_SPEC = {
23895
24253
  };
23896
24254
  var NcLivenessLedger = class {
23897
24255
  ledger;
24256
+ /**
24257
+ * What was observed before the boot load answered (D698) — see
24258
+ * `boot-window-rows.ts`. A stored row holding the same STATE keeps its own
24259
+ * `updatedAt`: a restart that re-hears "offline" learned nothing about WHEN
24260
+ * the device went offline, and `updatedAt` is the state summary's "since".
24261
+ */
24262
+ bootWindow;
23898
24263
  constructor(deps) {
23899
24264
  this.ledger = new DurableLedger({
23900
24265
  spec: NC_LIVENESS_SPEC,
23901
24266
  store: deps.store,
23902
24267
  logger: deps.logger
23903
24268
  });
24269
+ this.bootWindow = new BootWindowRows({
24270
+ ledger: this.ledger,
24271
+ keyOf: (row) => row.subject,
24272
+ deviceIdOf: (row) => row.deviceId,
24273
+ logger: deps.logger,
24274
+ merge: (stored, provisional) => stored !== void 0 && stored.state === provisional.state ? stored : provisional
24275
+ });
23904
24276
  }
23905
24277
  static declare(store) {
23906
24278
  return DurableLedger.declare(store, NC_LIVENESS_SPEC);
@@ -23915,7 +24287,9 @@ var NcLivenessLedger = class {
23915
24287
  * flood, so no error path may do it on purpose.
23916
24288
  */
23917
24289
  async load() {
23918
- return (await this.ledger.load()).length;
24290
+ const loaded = (await this.ledger.load()).length;
24291
+ this.bootWindow.settle();
24292
+ return loaded;
23919
24293
  }
23920
24294
  /** Has a load READ the store once? Until then the mirror is not the truth (D647 reads it). */
23921
24295
  hasAnswered() {
@@ -24008,13 +24382,21 @@ var NcLivenessLedger = class {
24008
24382
  * spoke". That is the timestamp anyone reading the table wants.
24009
24383
  */
24010
24384
  observe(observation, now) {
24011
- return this.ledger.observe({
24385
+ const row = {
24012
24386
  subject: observation.subject,
24013
24387
  state: observation.state,
24014
24388
  kind: observation.kind,
24015
24389
  ...observation.deviceId !== void 0 ? { deviceId: observation.deviceId } : {},
24016
24390
  updatedAt: now
24017
- });
24391
+ };
24392
+ if (!this.bootWindow.open()) return this.ledger.observe(row);
24393
+ if (this.ledger.get(row.subject) === void 0) {
24394
+ this.bootWindow.stageProvisional(row);
24395
+ return "seeded";
24396
+ }
24397
+ const verdict = this.ledger.observe(row);
24398
+ if (verdict === "flip") this.bootWindow.noteWritten(row);
24399
+ return verdict;
24018
24400
  }
24019
24401
  };
24020
24402
  /** The persisted column map (the `subject` PK is passed separately). */
@@ -30549,74 +30931,6 @@ function stateSources(ports) {
30549
30931
  return { read: (section, nowMs) => readSection(section, ports, nowMs) };
30550
30932
  }
30551
30933
  //#endregion
30552
- //#region src/notification-center/state-summary/state-model.ts
30553
- /**
30554
- * The STATE SUMMARY's vocabulary (D647): its sections, and what one reported
30555
- * item is.
30556
- *
30557
- * A section is one of the D637 kind GROUPS (`NC_SYSTEM_EVENT_KIND_GROUP_SPECS`)
30558
- * — the same grouping both editors list kinds under — so "the rule selected a
30559
- * battery kind" and "the summary has a battery section" are the same fact.
30560
- * `backups-exports` is the one group with no section: a backup or an export is
30561
- * a job that COMPLETED, and the state it leaves behind is not a condition that
30562
- * is still true. The ADR records why it is excluded rather than approximated.
30563
- */
30564
- var NC_STATE_SUMMARY_SECTION_IDS = [
30565
- "device-state",
30566
- "streams",
30567
- "battery",
30568
- "consumables",
30569
- "tanks",
30570
- "detection-blind",
30571
- "nodes",
30572
- "addons",
30573
- "updates"
30574
- ];
30575
- /** The kind groups that have no section, and why — said on the preview. */
30576
- var NC_STATE_SUMMARY_EXCLUDED_GROUPS = { "backups-exports": "a backup or an export is a completed job, not a state that is still true" };
30577
- /** The template variable that carries one section's lines (`template-vars.ts`). */
30578
- var NC_STATE_SECTION_VAR = {
30579
- "device-state": "summaryDevices",
30580
- streams: "summaryStreams",
30581
- battery: "summaryBattery",
30582
- consumables: "summaryConsumables",
30583
- tanks: "summaryTanks",
30584
- "detection-blind": "summaryDetection",
30585
- nodes: "summaryNodes",
30586
- addons: "summaryAddons",
30587
- updates: "summaryUpdates"
30588
- };
30589
- /** The kinds of a section — its kind group. */
30590
- function sectionKinds(section) {
30591
- return require_clip_model_registry.NC_SYSTEM_EVENT_KIND_GROUP_SPECS.find((group) => group.id === section)?.kinds ?? [];
30592
- }
30593
- /**
30594
- * The sections a rule reports, in section order: every section whose kind
30595
- * group shares a kind with the rule. A rule that selected only
30596
- * `device-battery-low` still reports the battery STATE — the state has no
30597
- * direction, only the event did.
30598
- */
30599
- function sectionsOfRule(condition) {
30600
- return NC_STATE_SUMMARY_SECTION_IDS.filter((section) => sectionKinds(section).some((kind) => condition.kinds.includes(kind)));
30601
- }
30602
- /** The kind groups the rule selected that have no section. */
30603
- function excludedGroupsOfRule(condition) {
30604
- return require_clip_model_registry.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);
30605
- }
30606
- /**
30607
- * The condition the summary judges its items with: the rule's own, with its
30608
- * kinds widened to every kind of every section it reports — so a rule that
30609
- * named the raise is not told "not my kind" about the state the raise left
30610
- * behind. Every FILTER is the rule's, untouched.
30611
- */
30612
- function summaryCondition(condition) {
30613
- const kinds = sectionsOfRule(condition).flatMap((section) => sectionKinds(section));
30614
- return {
30615
- ...condition,
30616
- kinds: [...new Set([...condition.kinds, ...kinds])]
30617
- };
30618
- }
30619
- //#endregion
30620
30934
  //#region src/notification-center/state-summary/state-summary.ts
30621
30935
  /** Nothing to say: no item, and every section READ. Such a summary is not sent. */
30622
30936
  function isEmptyStateSummary(summary) {
@@ -30717,28 +31031,50 @@ function lineOf(input, item) {
30717
31031
  }
30718
31032
  });
30719
31033
  }
30720
- function blockOf(input, section) {
30721
- const title = input.texts.text({ key: `summary.section.${section.id}` });
30722
- if (section.status === "unavailable") return input.texts.text({
30723
- key: "summary.section.unavailable",
30724
- vars: { section: title }
30725
- });
31034
+ function sectionTitle(input, section) {
31035
+ return input.texts.text({ key: `summary.section.${section.id}` });
31036
+ }
31037
+ /** A section's words: its block, and the body a per-section row sends. `null` = nothing to say. */
31038
+ function wordsOf(input, section) {
31039
+ const title = sectionTitle(input, section);
31040
+ if (section.status === "unavailable") {
31041
+ const sentence = input.texts.text({
31042
+ key: "summary.section.unavailable",
31043
+ vars: { section: title }
31044
+ });
31045
+ return {
31046
+ block: sentence,
31047
+ body: sentence
31048
+ };
31049
+ }
30726
31050
  if (section.items.length === 0) return null;
30727
- const lines = section.items.map((item) => `• ${lineOf(input, item)}`);
30728
- return [`${title}:`, ...lines].join("\n");
31051
+ const lines = section.items.map((item) => `• ${lineOf(input, item)}`).join("\n");
31052
+ return {
31053
+ block: `${title}:\n${lines}`,
31054
+ body: lines
31055
+ };
30729
31056
  }
30730
31057
  function stateSummaryText(input) {
30731
31058
  const sectionVars = {};
30732
- const blocks = [];
31059
+ const sections = [];
30733
31060
  for (const section of input.summary.sections) {
30734
- const block = blockOf(input, section);
30735
- sectionVars[NC_STATE_SECTION_VAR[section.id]] = block ?? "";
30736
- if (block !== null) blocks.push(block);
31061
+ const words = wordsOf(input, section);
31062
+ sectionVars[NC_STATE_SECTION_VAR[section.id]] = words?.block ?? "";
31063
+ if (words === null) continue;
31064
+ sections.push({
31065
+ id: section.id,
31066
+ title: sectionTitle(input, section),
31067
+ text: words.block,
31068
+ body: words.body,
31069
+ count: section.items.length,
31070
+ status: section.status
31071
+ });
30737
31072
  }
30738
31073
  return {
30739
- text: blocks.join("\n"),
31074
+ text: sections.map((section) => section.text).join("\n"),
30740
31075
  count: input.summary.itemCount,
30741
- sectionVars
31076
+ sectionVars,
31077
+ sections
30742
31078
  };
30743
31079
  }
30744
31080
  //#endregion
@@ -31175,6 +31511,40 @@ var NcStateSummaryStampStore = class {
31175
31511
  //#endregion
31176
31512
  //#region src/notification-center/state-summary/system-state-ledger.ts
31177
31513
  /**
31514
+ * NcSystemStateLedger — what is STILL TRUE among the event-driven system facts
31515
+ * no other ledger holds (D647).
31516
+ *
31517
+ * Battery, consumable and tank episodes already have a durable row
31518
+ * (`alert-store.ts`), and device/node liveness has its own
31519
+ * (`liveness-ledger.ts`). The rest of the summarisable kinds are EVENTS the
31520
+ * centre hears once — a stream going offline, a device switched off, a camera
31521
+ * detecting nothing, an update becoming available — and nothing remembered
31522
+ * that they were still true. A state summary is a claim about now, so this
31523
+ * ledger keeps one row per OPEN state, fed by the same intake the instant
31524
+ * rules read, and removes it when the closing event arrives:
31525
+ *
31526
+ * | opens | closes |
31527
+ * | --- | --- |
31528
+ * | `stream-offline` | `stream-online` (same subject) |
31529
+ * | `device-disabled` | `device-enabled` (same subject) |
31530
+ * | `detection-blind` | a DETECTION on that camera — the only evidence of recovery there is |
31531
+ * | `node-inference-unavailable`, `addon-crash-loop` | the node's next liveness edge, or a server update on it |
31532
+ * | `addon-update-available` (per package, per node) | `addon.updated` for that package on that node |
31533
+ * | `server-` / `wrapper-update-available` | `server-updated` on that node |
31534
+ *
31535
+ * Two kinds have no recovery event at all (`node-inference-unavailable`,
31536
+ * `addon-crash-loop`): the orchestrator drops such a node until it comes back,
31537
+ * and a crash-looped runner stays down until something respawns it — a node
31538
+ * restart or an update. The closing edges above are those respawns. A newer
31539
+ * `update-available` for the same package REPLACES the row.
31540
+ *
31541
+ * Durable (write-behind), so a restart does not forget an outage it heard
31542
+ * about. It is fed only on the evaluating node, like the liveness ledger. The
31543
+ * summary reads the RAM mirror only — never SQLite on its schedule (D447) —
31544
+ * and a mirror that has not loaded answers `null`: the sections it feeds say
31545
+ * "unavailable", never "none" (D49).
31546
+ */
31547
+ /**
31178
31548
  * @durable class=ledger owner=notification-center
31179
31549
  * 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"
31180
31550
  * retention="none — a row lives exactly as long as its state is open. Bounded by the installation: streams, devices, nodes and installed packages."
@@ -31336,9 +31706,7 @@ var NcSystemStateLedger = class {
31336
31706
  }
31337
31707
  /** A detection on a camera: it is not blind. Called for every detection — a Map lookup. */
31338
31708
  observeDetection(deviceId) {
31339
- const key = `detection-blind:${String(deviceId)}`;
31340
- if (this.deps.rows.get(key) === void 0) return;
31341
- this.close(key, "detection");
31709
+ this.close(`detection-blind:${String(deviceId)}`, "detection");
31342
31710
  }
31343
31711
  /**
31344
31712
  * Any `stream.online`, whatever its reason (D647 review I1). The intake gate
@@ -31353,7 +31721,7 @@ var NcSystemStateLedger = class {
31353
31721
  const keep = new Set(list.rows.map((pkg) => updateStateKey(list.target, pkg.nodeId, pkg.packageName)));
31354
31722
  for (const nodeId of list.nodeIds) {
31355
31723
  const prefix = `update:${list.target}:${nodeId}:`;
31356
- for (const row of this.deps.rows.all()) if (row.key.startsWith(prefix) && !keep.has(row.key)) this.close(row.key, "update-list");
31724
+ this.closeWhere(`update-list:${list.target}:${nodeId}`, (row) => row.key.startsWith(prefix) && !keep.has(row.key), "update-list");
31357
31725
  }
31358
31726
  for (const pkg of list.rows) this.openUpdate(list.target, pkg, atMs);
31359
31727
  }
@@ -31363,11 +31731,7 @@ var NcSystemStateLedger = class {
31363
31731
  * on that node.
31364
31732
  */
31365
31733
  observeAddonStarted(addonId, nodeId) {
31366
- for (const row of this.deps.rows.all()) {
31367
- if (row.kind !== "addon-crash-loop") continue;
31368
- if (row.nodeId !== nodeId) continue;
31369
- if ((row.params["addons"] ?? "").split(",").map((id) => id.trim()).includes(addonId)) this.close(row.key, "addon-started");
31370
- }
31734
+ this.closeWhere(`addon-started:${nodeId}:${addonId}`, (row) => row.kind === "addon-crash-loop" && row.nodeId === nodeId && (row.params["addons"] ?? "").split(",").map((id) => id.trim()).includes(addonId), "addon-started");
31371
31735
  }
31372
31736
  /**
31373
31737
  * The balancer placed a camera on a node (not pinned): placement skips a
@@ -31423,11 +31787,31 @@ var NcSystemStateLedger = class {
31423
31787
  }
31424
31788
  });
31425
31789
  }
31790
+ /**
31791
+ * Close one state: the row held now (a loaded one, or one opened in the boot
31792
+ * window), and — deferred while the boot window is open — the STORED row
31793
+ * the window cannot see yet (D698 review H1).
31794
+ */
31426
31795
  close(key, by) {
31796
+ this.closeIfHeld(key, by, false);
31797
+ this.deps.rows.deferWhileOpen(`close:${key}`, () => this.closeIfHeld(key, by, true) ? 1 : 0);
31798
+ }
31799
+ /** {@link close} for every row a predicate names — now, and against the loaded rows. */
31800
+ closeWhere(key, names, by) {
31801
+ const run = (replayed) => {
31802
+ let closed = 0;
31803
+ for (const row of this.deps.rows.all()) if (names(row) && this.closeIfHeld(row.key, by, replayed)) closed += 1;
31804
+ return closed;
31805
+ };
31806
+ run(false);
31807
+ this.deps.rows.deferWhileOpen(`where:${key}`, () => run(true));
31808
+ }
31809
+ /** Close the row if one is held; `true` when it was. */
31810
+ closeIfHeld(key, by, replayed) {
31427
31811
  const held = this.deps.rows.get(key);
31428
- if (held === void 0) return;
31812
+ if (held === void 0) return false;
31429
31813
  this.deps.rows.remove(key);
31430
- this.deps.logger?.debug("system state closed", {
31814
+ const line = {
31431
31815
  ...held.deviceId !== void 0 ? { tags: { deviceId: held.deviceId } } : {},
31432
31816
  meta: {
31433
31817
  key,
@@ -31435,49 +31819,78 @@ var NcSystemStateLedger = class {
31435
31819
  by,
31436
31820
  since: held.since
31437
31821
  }
31438
- });
31822
+ };
31823
+ if (replayed) {
31824
+ this.deps.logger?.info("system state closed by an event heard before the load", line);
31825
+ return true;
31826
+ }
31827
+ this.deps.logger?.debug("system state closed", line);
31828
+ return true;
31439
31829
  }
31440
31830
  closeNodeFaults(nodeId, by) {
31441
- for (const row of this.deps.rows.all()) {
31442
- if (row.nodeId !== nodeId) continue;
31443
- if (row.kind === "node-inference-unavailable" || row.kind === "addon-crash-loop") this.close(row.key, by);
31444
- }
31831
+ this.closeWhere(`node-faults:${nodeId}`, (row) => row.nodeId === nodeId && (row.kind === "node-inference-unavailable" || row.kind === "addon-crash-loop"), by);
31445
31832
  }
31446
31833
  closeServerUpdate(nodeId) {
31447
- for (const row of this.deps.rows.all()) {
31448
- if ((row.nodeId ?? HUB_NODE) !== nodeId) continue;
31449
- 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");
31450
- }
31834
+ this.closeWhere(`server-update:${nodeId}`, (row) => (row.nodeId ?? HUB_NODE) === nodeId && (row.kind === "server-update-available" || row.kind === "wrapper-update-available" || row.kind === "addon-crash-loop" || row.kind === "node-inference-unavailable"), "server-updated");
31451
31835
  }
31452
31836
  };
31453
31837
  /** The durable home of the rows. */
31454
31838
  var NcSystemStateStore = class {
31455
31839
  ledger;
31840
+ /**
31841
+ * What the intake opened or closed before the boot load answered (D698,
31842
+ * `boot-window-rows.ts`). A stored row of the same KIND keeps its own
31843
+ * `since`: a stream re-announced offline at boot was not offline "since the
31844
+ * restart".
31845
+ */
31846
+ bootWindow;
31456
31847
  constructor(deps) {
31457
31848
  this.ledger = new DurableLedger({
31458
31849
  spec: NC_SYSTEM_STATE_SPEC,
31459
31850
  store: deps.store,
31460
31851
  logger: deps.logger
31461
31852
  });
31853
+ this.bootWindow = new BootWindowRows({
31854
+ ledger: this.ledger,
31855
+ keyOf: (row) => row.key,
31856
+ deviceIdOf: (row) => row.deviceId,
31857
+ logger: deps.logger,
31858
+ merge: (stored, provisional) => {
31859
+ if (stored === void 0 || stored.kind !== provisional.kind) return provisional;
31860
+ const merged = {
31861
+ ...provisional,
31862
+ since: stored.since
31863
+ };
31864
+ return (0, node_util.isDeepStrictEqual)(merged, stored) ? stored : merged;
31865
+ }
31866
+ });
31462
31867
  }
31463
31868
  static declare(store) {
31464
31869
  return DurableLedger.declare(store, NC_SYSTEM_STATE_SPEC);
31465
31870
  }
31466
31871
  /** Boot reseed. A failed load keeps the mirror and leaves `answered` false. */
31467
31872
  async load() {
31468
- return (await this.ledger.load()).length;
31873
+ const loaded = (await this.ledger.load()).length;
31874
+ this.bootWindow.settle();
31875
+ return loaded;
31469
31876
  }
31470
31877
  port() {
31471
31878
  return {
31472
31879
  get: (key) => this.ledger.get(key),
31473
31880
  put: (row) => {
31881
+ if (this.bootWindow.open()) {
31882
+ this.bootWindow.stageProvisional(row);
31883
+ return;
31884
+ }
31474
31885
  this.ledger.put(row).catch(() => void 0);
31475
31886
  },
31476
31887
  remove: (key) => {
31888
+ if (this.bootWindow.open()) this.bootWindow.noteRemoved(key);
31477
31889
  this.ledger.forget(key);
31478
31890
  },
31479
31891
  all: () => this.ledger.snapshot(),
31480
- answered: () => this.ledger.hasAnswered()
31892
+ answered: () => this.ledger.hasAnswered(),
31893
+ deferWhileOpen: (key, replay) => this.bootWindow.deferWhileOpen(key, replay)
31481
31894
  };
31482
31895
  }
31483
31896
  };
@@ -31602,8 +32015,21 @@ var NcStateSummaryService = class {
31602
32015
  const nowMs = this.deps.now();
31603
32016
  const summary = await this.collect(rule, nowMs);
31604
32017
  const text = this.word(summary, nowMs);
32018
+ const split = require_clip_model_registry.systemEventSummarySplitOf(rule);
32019
+ const empty = isEmptyStateSummary(summary);
31605
32020
  return {
31606
- empty: summary.itemCount === 0 && summary.unavailableCount === 0,
32021
+ empty,
32022
+ split,
32023
+ rows: empty ? [] : split === "section" ? text.sections.map((section) => ({
32024
+ section: section.id,
32025
+ title: section.title,
32026
+ text: section.body,
32027
+ count: section.count
32028
+ })) : [{
32029
+ title: text.sections.map((section) => section.title).join(", "),
32030
+ text: text.text,
32031
+ count: text.count
32032
+ }],
31607
32033
  count: text.count,
31608
32034
  text: text.text,
31609
32035
  sections: summary.sections.map((section) => ({
@@ -31616,12 +32042,84 @@ var NcStateSummaryService = class {
31616
32042
  excludedGroups: [...summary.excludedGroups]
31617
32043
  };
31618
32044
  }
31619
- async collect(rule, nowMs) {
32045
+ /**
32046
+ * TEST the rule (D698, `nc.testRule`): the summary it would send NOW,
32047
+ * ENQUEUED through the ordinary outbox under a test record id — split as the
32048
+ * rule is. The per-camera mute is bypassed, as every rule test bypasses it,
32049
+ * and a summary has no cooldown to bypass. Nothing to report sends nothing
32050
+ * and SAYS so, per section.
32051
+ */
32052
+ async test(rule) {
32053
+ const nowMs = this.deps.now();
32054
+ const summary = await this.collect(rule, nowMs, () => false);
32055
+ const split = require_clip_model_registry.systemEventSummarySplitOf(rule);
32056
+ const recordId = stateSummaryTestRecordId(rule.id, nowMs);
32057
+ const sections = summary.sections.map((section) => ({
32058
+ id: section.id,
32059
+ status: section.status,
32060
+ items: section.items.length,
32061
+ ...section.reason !== void 0 ? { reason: section.reason } : {}
32062
+ }));
32063
+ const meta = {
32064
+ ruleId: rule.id,
32065
+ rule: rule.name,
32066
+ split,
32067
+ bypassMute: true
32068
+ };
32069
+ if (isEmptyStateSummary(summary)) {
32070
+ this.deps.logger.info("state summary TEST has nothing to report — not sent", { meta: {
32071
+ ...meta,
32072
+ dropped: summary.dropped
32073
+ } });
32074
+ return {
32075
+ recordId,
32076
+ split,
32077
+ nothingToReport: true,
32078
+ sections,
32079
+ rows: []
32080
+ };
32081
+ }
32082
+ const subjects = stateSummarySubjects({
32083
+ rule,
32084
+ summary,
32085
+ text: this.word(summary, nowMs),
32086
+ window: {
32087
+ startMs: nowMs,
32088
+ endMs: nowMs
32089
+ },
32090
+ split,
32091
+ recordBase: recordId
32092
+ });
32093
+ const rows = [];
32094
+ for (const subject of subjects) {
32095
+ const enqueued = await this.deps.enqueue(rule, subject, "synthetic");
32096
+ const section = subject.systemEvent?.data["section"];
32097
+ rows.push({
32098
+ recordId: subject.recordId,
32099
+ ...typeof section === "string" ? { section } : {},
32100
+ enqueued: enqueued.inserted,
32101
+ targetIds: [...enqueued.targetIds]
32102
+ });
32103
+ }
32104
+ this.deps.logger.info("state summary TEST enqueued", { meta: {
32105
+ ...meta,
32106
+ items: summary.itemCount,
32107
+ rows: rows.map((row) => `${row.recordId}:${String(row.enqueued)}`)
32108
+ } });
32109
+ return {
32110
+ recordId,
32111
+ split,
32112
+ nothingToReport: false,
32113
+ sections,
32114
+ rows
32115
+ };
32116
+ }
32117
+ async collect(rule, nowMs, isMuted = this.deps.isMuted) {
31620
32118
  const nodes = await this.nodeView();
31621
32119
  return collectStateSummary({
31622
32120
  rule,
31623
32121
  nowMs,
31624
- isMuted: this.deps.isMuted,
32122
+ isMuted,
31625
32123
  sources: stateSources({
31626
32124
  alerts: this.deps.alerts,
31627
32125
  liveness: this.deps.liveness,
@@ -31657,17 +32155,24 @@ var NcStateSummaryService = class {
31657
32155
  });
31658
32156
  }
31659
32157
  async deliver(rule, summary, window) {
31660
- const subject = stateSummarySubject({
32158
+ const text = this.word(summary, window.endMs);
32159
+ const subjects = stateSummarySubjects({
31661
32160
  rule,
31662
32161
  summary,
31663
- text: this.word(summary, window.endMs),
31664
- window
32162
+ text,
32163
+ window,
32164
+ split: require_clip_model_registry.systemEventSummarySplitOf(rule)
31665
32165
  });
31666
- if (subject === null) {
31667
- this.deps.logger.warn("state summary rule has no system-event condition — not sent", { meta: { ruleId: rule.id } });
32166
+ if (subjects.length === 0) {
32167
+ this.deps.logger.warn("state summary produced no row — not sent", { meta: {
32168
+ ruleId: rule.id,
32169
+ sections: text.sections.length
32170
+ } });
31668
32171
  return 0;
31669
32172
  }
31670
- return this.deps.enqueue(rule, subject);
32173
+ let inserted = 0;
32174
+ for (const subject of subjects) inserted += (await this.deps.enqueue(rule, subject, "system")).inserted;
32175
+ return inserted;
31671
32176
  }
31672
32177
  };
31673
32178
  //#endregion
@@ -33393,7 +33898,7 @@ var NotificationCenter = class NotificationCenter {
33393
33898
  livenessHoldoffMs: deps.livenessHoldoffMs ?? DEVICE_LIVENESS_HOLDOFF_MS,
33394
33899
  isMuted: (deviceId) => this.deviceMutes.isMuted(deviceId),
33395
33900
  texts: () => this.texts,
33396
- enqueue: (rule, subject) => this.enqueueStateSummary(rule, subject)
33901
+ enqueue: (rule, subject, origin) => this.enqueueStateSummary(rule, subject, origin)
33397
33902
  });
33398
33903
  this.confirmGate = deps.generateVision !== void 0 ? new NcConfirmGate({
33399
33904
  logger: this.logger.child("confirm"),
@@ -35934,30 +36439,42 @@ var NotificationCenter = class NotificationCenter {
35934
36439
  await this.enqueuePickerTargets(event, reached, outcomes);
35935
36440
  return report({ candidates: candidates.length });
35936
36441
  }
36442
+ /** TEST a state-summary rule (D698, `nc.testRule`) — see `NcStateSummaryService.test`. */
36443
+ testStateSummary(rule) {
36444
+ return this.stateSummary.test(rule);
36445
+ }
35937
36446
  /**
35938
- * Enqueue ONE state summary (D647) through the rule's ordinary rows: its
36447
+ * Enqueue ONE state summary row (D647) through the rule's ordinary rows: its
35939
36448
  * targets, its users (admins only — the row files under the system device),
35940
36449
  * its template, and the drain's snooze gate and retries. The record id is
35941
- * the dedup key, so a window is enqueued once whatever the restarts.
36450
+ * the dedup key, so a window — or each section of it (D698) — is enqueued
36451
+ * once whatever the restarts. A TEST's rows carry the `synthetic` origin, so
36452
+ * they are marked as a test exactly like every other rule test's.
35942
36453
  */
35943
- async enqueueStateSummary(rule, subject) {
35944
- const appended = withSummaryBody(rule);
36454
+ async enqueueStateSummary(rule, subject, origin) {
36455
+ const appended = withSummaryBody(rule, stateSummarySectionOf(subject));
35945
36456
  if (appended !== null) this.logger.warn("state summary rule body names no summary content — {{summaryText}} appended", { meta: {
35946
36457
  ruleId: rule.id,
35947
36458
  rule: rule.name
35948
36459
  } });
35949
36460
  const sent = appended ?? rule;
35950
36461
  const userTargets = await this.resolveUserTargets(sent, subject.deviceId);
35951
- const entries = this.buildEntries(sent, subject, "system-event", ["systemEvent"], userTargets, "system");
36462
+ const entries = this.buildEntries(sent, subject, "system-event", ["systemEvent"], userTargets, origin);
35952
36463
  if (entries.length === 0) {
35953
36464
  this.logger.warn("state summary reached NO target — its users are not admins, and it has no targets", { meta: {
35954
36465
  ruleId: rule.id,
35955
36466
  rule: rule.name,
35956
36467
  targetUsers: rule.targetUsers ?? []
35957
36468
  } });
35958
- return 0;
36469
+ return {
36470
+ inserted: 0,
36471
+ targetIds: []
36472
+ };
35959
36473
  }
35960
- return this.outbox.enqueue(entries);
36474
+ return {
36475
+ inserted: await this.outbox.enqueue(entries),
36476
+ targetIds: entries.map((entry) => entry.targetId)
36477
+ };
35961
36478
  }
35962
36479
  /**
35963
36480
  * The rules a trigger may fire, which is `listEnabled(delivery)` plus and
@@ -36671,9 +37188,11 @@ var NotificationCenter = class NotificationCenter {
36671
37188
  *
36672
37189
  * A seed is INFO and a repeat is DEBUG on purpose: seeding happens once per
36673
37190
  * subject per lifetime of the ledger, and it is exactly what an operator
36674
- * reading the logs after a restart needs to see ("it recorded them, it did
36675
- * not announce them"). A no-flip is ordinary bus chatter — two categories
36676
- * describing one edge — and would be noise at info.
37191
+ * reading the logs after a restart needs to see ("it did not announce
37192
+ * them"). A seed heard BEFORE the boot load is provisional (D698): the load
37193
+ * keeps the stored row when it holds the same state, and its `boot window
37194
+ * settled` line says how many it kept and wrote. A no-flip is ordinary bus
37195
+ * chatter — two categories describing one edge — and would be noise at info.
36677
37196
  */
36678
37197
  reportLivenessDrop(incoming, verdict) {
36679
37198
  const dropped = discardedLivenessEvent(incoming, verdict);
@@ -88228,6 +88747,18 @@ function isRecord(x) {
88228
88747
  }
88229
88748
  //#endregion
88230
88749
  //#region src/pipeline-analytics/store/track-alias-store.ts
88750
+ /**
88751
+ * The alias collection.
88752
+ *
88753
+ * @durable class=ledger owner=pipeline-analytics
88754
+ * write="one row per fused piece (D683): aliasId = the absorbed provisional
88755
+ * id, trackId = its survivor; rewritten when the survivor is itself
88756
+ * absorbed by a later fusion"
88757
+ * retention="TRACK-OWNED by its survivor through the declared `trackId`
88758
+ * column: aliases leave with the survivor in the retention cascade, and
88759
+ * widen that cascade (`expandLeafIds`) to rows still keyed by an
88760
+ * absorbed id"
88761
+ */
88231
88762
  var TRACK_ALIASES_COLLECTION = "pipeline-analytics:track-aliases";
88232
88763
  var TRACK_ALIAS_COLUMNS = [
88233
88764
  {
@@ -88410,6 +88941,20 @@ var TrackAliasStore = class {
88410
88941
  dropped: 0
88411
88942
  }];
88412
88943
  }
88944
+ /**
88945
+ * The orphan audit's reclaim of ONE alias row: the row AND the `recent`
88946
+ * entry that would otherwise keep resolving it for up to an hour. `true`
88947
+ * only when a row was actually deleted. (`misses` needs nothing: a deleted
88948
+ * alias IS a non-alias, which is what a miss records.)
88949
+ */
88950
+ async deleteAlias(aliasId) {
88951
+ const { deleted } = await this.store.deleteWhere.mutate({
88952
+ collection: TRACK_ALIASES_COLLECTION,
88953
+ filter: { where: { id: aliasId } }
88954
+ });
88955
+ this.recent.delete(aliasId);
88956
+ return deleted > 0;
88957
+ }
88413
88958
  async deleteByTracks(trackIds) {
88414
88959
  const { deleted } = await this.store.deleteWhere.mutate({
88415
88960
  collection: TRACK_ALIASES_COLLECTION,
@@ -89336,7 +89881,8 @@ async function auditScope(scope, deps, limits, cursor) {
89336
89881
  stoppedBy,
89337
89882
  nextCursor: offset
89338
89883
  } });
89339
- if (limits.mode === "reclaim" && tally.orphans > 0 && tally.bytesReclaimed === 0) deps.logger.warn("orphan audit: reclaim pass freed no bytes against a standing backlog", { meta: {
89884
+ const freedNothing = scope.sizeField === null ? tally.reclaimed === 0 : tally.bytesReclaimed === 0;
89885
+ if (limits.mode === "reclaim" && tally.orphans > 0 && freedNothing) deps.logger.warn("orphan audit: reclaim pass freed no bytes against a standing backlog", { meta: {
89340
89886
  collection: scope.collection,
89341
89887
  shard: scope.shard?.value ?? null,
89342
89888
  orphans: tally.orphans,
@@ -89450,6 +89996,40 @@ var ORPHAN_AUDIT_DEFAULTS = {
89450
89996
  pageSize: OrphanAuditPageSizeSchema.parse(void 0)
89451
89997
  };
89452
89998
  var RAW_DELETE_COLLECTIONS = new Set(["pipeline-analytics:object-events", "pipeline-analytics:plates"]);
89999
+ /**
90000
+ * The `deleteRow` port: ONE row by its primary key `id` (what every
90001
+ * raw-delete collection above declares), answering whether a row went.
90002
+ *
90003
+ * Through `deleteWhere`, because it is the delete that SAYS: `delete`'s output
90004
+ * is `z.void()`, and reading it as `!== undefined` answered `false` for every
90005
+ * row it really deleted — the audit counted every plates/object-events reclaim
90006
+ * as "found nothing", and the per-scope reclaim ceiling never tripped.
90007
+ */
90008
+ function settingsRowDeleter(store) {
90009
+ return async (collection, key) => {
90010
+ const { deleted } = await store.deleteWhere.mutate({
90011
+ collection,
90012
+ filter: { where: { id: key } }
90013
+ });
90014
+ return deleted > 0;
90015
+ };
90016
+ }
90017
+ /**
90018
+ * The `deleteRow` port AS PRODUCTION WIRES IT: {@link settingsRowDeleter} over
90019
+ * a store that may not be bound yet. Unbound THROWS — the engine counts that as
90020
+ * `reclaimFailed` and keeps the row — rather than answering "nothing deleted",
90021
+ * which is indistinguishable from a row already gone. A named factory so the
90022
+ * wiring itself is under test (D695 T7 review M1: the inline wiring could be
90023
+ * reverted to the original bug with every spec green);
90024
+ * `scripts/check-orphan-reclaimers.ts` checks `index.ts` uses it.
90025
+ */
90026
+ function settingsRowDeleterOf(getStore) {
90027
+ return async (collection, key) => {
90028
+ const store = getStore();
90029
+ if (store === null) throw new Error("orphan audit: settings store not bound");
90030
+ return await settingsRowDeleter(store)(collection, key);
90031
+ };
90032
+ }
89453
90033
  function buildOrphanReclaimer(ports) {
89454
90034
  return async (collection, key) => {
89455
90035
  if (collection === MEDIA_COLLECTION) {
@@ -89473,6 +90053,10 @@ function buildOrphanReclaimer(ports) {
89473
90053
  bytes: null
89474
90054
  };
89475
90055
  }
90056
+ if (collection === TRACK_ALIASES_COLLECTION) return {
90057
+ rows: await ports.deleteTrackAlias(key) ? 1 : 0,
90058
+ bytes: null
90059
+ };
89476
90060
  if (RAW_DELETE_COLLECTIONS.has(collection)) return {
89477
90061
  rows: await ports.deleteRow(collection, key) ? 1 : 0,
89478
90062
  bytes: null
@@ -99842,6 +100426,7 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_clip_m
99842
100426
  },
99843
100427
  listTrackGroups: (query) => center.listTrackGroups(query),
99844
100428
  injectTestEvent: (input) => center.injectTestEvent(input, { getSnapshot: (deviceId) => api.snapshot.getSnapshot.query({ deviceId }) }),
100429
+ testStateSummary: (rule) => center.testStateSummary(rule),
99845
100430
  timelapse: {
99846
100431
  store: center.timelapseStore,
99847
100432
  getRecordingConfig: (deviceId) => api.recordingArchive.getDeviceConfig.query({ deviceId }).catch(() => null),
@@ -107600,10 +108185,12 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_clip_m
107600
108185
  bytes: count.bytes
107601
108186
  };
107602
108187
  },
107603
- deleteRow: async (collection, key) => await this.settingsStore?.delete.mutate({
107604
- collection,
107605
- key
107606
- }) !== void 0,
108188
+ deleteTrackAlias: async (aliasId) => {
108189
+ const aliasStore = this.trackAliasStore;
108190
+ if (aliasStore === null) throw new Error("track alias store not bound");
108191
+ return await aliasStore.deleteAlias(aliasId);
108192
+ },
108193
+ deleteRow: settingsRowDeleterOf(() => this.settingsStore),
107607
108194
  logger: this.ctx.logger
107608
108195
  }),
107609
108196
  now: () => Date.now(),