@camstack/addon-post-analysis 1.2.307 → 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-C67UM_q_.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);
@@ -42620,6 +43139,280 @@ function parseDebugDeviceIds(url) {
42620
43139
  return out;
42621
43140
  }
42622
43141
  //#endregion
43142
+ //#region src/pipeline-analytics/store/tracking-tape-index-store.ts
43143
+ /**
43144
+ * @durable class=ledger owner=pipeline-analytics
43145
+ * write="one row per PROMOTED tracking-tape window (`debug.tape.promote`);
43146
+ * updated when the operator marks it reviewed or releases its pins;
43147
+ * deleted together with its file by `debug.tape.delete`."
43148
+ * retention="NOT an age clock and NOT track retention — a promoted window is
43149
+ * history being labelled (D681). Bounded by the promotion budget
43150
+ * (MAX_PROMOTED_TAPES = 60, 1 GiB): a promotion beyond it is REFUSED,
43151
+ * never satisfied by evicting another row."
43152
+ */
43153
+ var TRACKING_TAPES_COLLECTION = "pipeline-analytics:tracking-tapes";
43154
+ var TRACKING_TAPES_COLUMNS = [
43155
+ {
43156
+ name: "id",
43157
+ type: "TEXT",
43158
+ primaryKey: true,
43159
+ notNull: true
43160
+ },
43161
+ {
43162
+ name: "deviceId",
43163
+ type: "INTEGER",
43164
+ notNull: true
43165
+ },
43166
+ {
43167
+ name: "scenario",
43168
+ type: "TEXT",
43169
+ notNull: true
43170
+ },
43171
+ {
43172
+ name: "note",
43173
+ type: "TEXT"
43174
+ },
43175
+ {
43176
+ name: "lighting",
43177
+ type: "TEXT",
43178
+ notNull: true
43179
+ },
43180
+ {
43181
+ name: "fromMs",
43182
+ type: "INTEGER",
43183
+ notNull: true
43184
+ },
43185
+ {
43186
+ name: "toMs",
43187
+ type: "INTEGER",
43188
+ notNull: true
43189
+ },
43190
+ {
43191
+ name: "state",
43192
+ type: "TEXT",
43193
+ notNull: true
43194
+ },
43195
+ {
43196
+ name: "frames",
43197
+ type: "INTEGER",
43198
+ notNull: true
43199
+ },
43200
+ {
43201
+ name: "skips",
43202
+ type: "INTEGER",
43203
+ notNull: true
43204
+ },
43205
+ {
43206
+ name: "gaps",
43207
+ type: "INTEGER",
43208
+ notNull: true
43209
+ },
43210
+ {
43211
+ name: "bytes",
43212
+ type: "INTEGER",
43213
+ notNull: true
43214
+ },
43215
+ {
43216
+ name: "reviewed",
43217
+ type: "BOOLEAN",
43218
+ notNull: true
43219
+ },
43220
+ {
43221
+ name: "trackIds",
43222
+ type: "TEXT",
43223
+ notNull: true
43224
+ },
43225
+ {
43226
+ name: "createdAt",
43227
+ type: "INTEGER",
43228
+ notNull: true
43229
+ },
43230
+ {
43231
+ name: "releasedAt",
43232
+ type: "INTEGER"
43233
+ },
43234
+ {
43235
+ name: "pinsSettled",
43236
+ type: "BOOLEAN",
43237
+ notNull: true
43238
+ }
43239
+ ];
43240
+ var TrackingTapeRowSchema = require_clip_model_registry.object({
43241
+ id: require_clip_model_registry.string(),
43242
+ deviceId: require_clip_model_registry.number().int(),
43243
+ scenario: require_clip_model_registry.string(),
43244
+ note: require_clip_model_registry.string().nullable(),
43245
+ lighting: TapeLightingSchema,
43246
+ fromMs: require_clip_model_registry.number().int(),
43247
+ toMs: require_clip_model_registry.number().int(),
43248
+ state: require_clip_model_registry._enum(["complete", "incomplete"]),
43249
+ frames: require_clip_model_registry.number().int(),
43250
+ skips: require_clip_model_registry.number().int(),
43251
+ gaps: require_clip_model_registry.number().int(),
43252
+ bytes: require_clip_model_registry.number().int(),
43253
+ reviewed: require_clip_model_registry.boolean(),
43254
+ trackIds: require_clip_model_registry.array(require_clip_model_registry.string()),
43255
+ createdAt: require_clip_model_registry.number().int(),
43256
+ releasedAt: require_clip_model_registry.number().int().nullable(),
43257
+ pinsSettled: require_clip_model_registry.boolean()
43258
+ });
43259
+ var TrackIdsSchema = require_clip_model_registry.array(require_clip_model_registry.string());
43260
+ /**
43261
+ * True when the SQLite settings backend would hand `text` back DECODED: it
43262
+ * parses any TEXT that starts with `[` or `{` and is valid JSON
43263
+ * (`decodeColumnValue` decodes by content, not by declared type). Free text
43264
+ * like that cannot round-trip through a TEXT column (follow-up 617, review M2).
43265
+ */
43266
+ function decodesAsJson(text) {
43267
+ if (!(text.startsWith("[") || text.startsWith("{"))) return false;
43268
+ try {
43269
+ JSON.parse(text);
43270
+ return true;
43271
+ } catch {
43272
+ return false;
43273
+ }
43274
+ }
43275
+ /**
43276
+ * Free text (`scenario`, `note`) as the column hands it back: a value the
43277
+ * backend decoded is re-encoded, so a row written before `debug.tape.promote`
43278
+ * refused such text never vanishes (its whitespace may differ). Anything else
43279
+ * goes to the schema as it came.
43280
+ */
43281
+ function freeText(raw) {
43282
+ return raw !== null && typeof raw === "object" ? JSON.stringify(raw) : raw;
43283
+ }
43284
+ /**
43285
+ * `trackIds` as the column hands it back. The SQLite backend decodes a TEXT
43286
+ * that starts with `[` on read (`decodeColumnValue`), so the store sees the
43287
+ * ARRAY it wrote; a string is parsed here only for a backend (or an old row)
43288
+ * that hands the text back as written. `JSON.parse(String(array))` threw on the
43289
+ * decoded array and every promoted row was skipped as malformed (follow-up 617).
43290
+ * `null` = unreadable, absent included (the column is NOT NULL, so absence is
43291
+ * corruption) — the row is reported, never repaired.
43292
+ */
43293
+ function decodeTrackIds(raw) {
43294
+ if (raw === void 0 || raw === null) return null;
43295
+ if (typeof raw !== "string") return raw;
43296
+ try {
43297
+ return JSON.parse(raw);
43298
+ } catch {
43299
+ return null;
43300
+ }
43301
+ }
43302
+ var TrackingTapeIndexStore = class {
43303
+ store;
43304
+ logger;
43305
+ constructor(deps) {
43306
+ this.store = deps.store;
43307
+ this.logger = deps.logger;
43308
+ }
43309
+ static async declare(store) {
43310
+ await store.declareCollection.mutate({
43311
+ collection: TRACKING_TAPES_COLLECTION,
43312
+ columns: [...TRACKING_TAPES_COLUMNS],
43313
+ indexes: [{
43314
+ name: "idx_trackingtapes_device_created",
43315
+ columns: ["deviceId", "createdAt"]
43316
+ }]
43317
+ });
43318
+ }
43319
+ async insert(row) {
43320
+ const { id, trackIds, ...rest } = TrackingTapeRowSchema.parse(row);
43321
+ await this.store.insert.mutate({
43322
+ collection: TRACKING_TAPES_COLLECTION,
43323
+ record: {
43324
+ id,
43325
+ data: {
43326
+ ...rest,
43327
+ trackIds: JSON.stringify(trackIds)
43328
+ }
43329
+ }
43330
+ });
43331
+ }
43332
+ async get(id) {
43333
+ const first = (await this.store.query.query({
43334
+ collection: TRACKING_TAPES_COLLECTION,
43335
+ filter: {
43336
+ where: { id },
43337
+ limit: 1
43338
+ }
43339
+ }))[0];
43340
+ return first === void 0 ? null : this.parse(first.id, first.data);
43341
+ }
43342
+ async list(deviceId) {
43343
+ const rows = await this.store.query.query({
43344
+ collection: TRACKING_TAPES_COLLECTION,
43345
+ filter: {
43346
+ ...deviceId !== void 0 ? { where: { deviceId } } : {},
43347
+ orderBy: {
43348
+ field: "createdAt",
43349
+ direction: "desc"
43350
+ },
43351
+ limit: 1e3
43352
+ }
43353
+ });
43354
+ const out = [];
43355
+ for (const r of rows) {
43356
+ const parsed = this.parse(r.id, r.data);
43357
+ if (parsed !== null) out.push(parsed);
43358
+ }
43359
+ return out;
43360
+ }
43361
+ async setReviewed(id, reviewed) {
43362
+ await this.store.update.mutate({
43363
+ collection: TRACKING_TAPES_COLLECTION,
43364
+ id,
43365
+ data: { reviewed }
43366
+ });
43367
+ }
43368
+ /** Release AND unsettle in one row write: the unpins the release implies
43369
+ * are owed from this moment, whether or not the caller lives to make them. */
43370
+ async setReleased(id, atMs) {
43371
+ await this.store.update.mutate({
43372
+ collection: TRACKING_TAPES_COLLECTION,
43373
+ id,
43374
+ data: {
43375
+ releasedAt: atMs,
43376
+ pinsSettled: false
43377
+ }
43378
+ });
43379
+ }
43380
+ async setPinsSettled(id, settled) {
43381
+ await this.store.update.mutate({
43382
+ collection: TRACKING_TAPES_COLLECTION,
43383
+ id,
43384
+ data: { pinsSettled: settled }
43385
+ });
43386
+ }
43387
+ async remove(id) {
43388
+ await this.store.delete.mutate({
43389
+ collection: TRACKING_TAPES_COLLECTION,
43390
+ key: id
43391
+ });
43392
+ }
43393
+ parse(id, data) {
43394
+ const ids = TrackIdsSchema.safeParse(decodeTrackIds(data["trackIds"]));
43395
+ const parsed = TrackingTapeRowSchema.safeParse({
43396
+ id,
43397
+ ...data,
43398
+ scenario: freeText(data["scenario"]),
43399
+ note: freeText(data["note"] ?? null),
43400
+ releasedAt: data["releasedAt"] ?? null,
43401
+ trackIds: ids.success ? ids.data : null
43402
+ });
43403
+ if (parsed.success) return parsed.data;
43404
+ this.logger.warn("tracking tape index: skipped a malformed row", {
43405
+ meta: {
43406
+ id,
43407
+ issues: parsed.error.issues.map((i) => i.path.join(".")),
43408
+ trackIdsType: typeof data["trackIds"]
43409
+ },
43410
+ ...typeof data["deviceId"] === "number" ? { tags: { deviceId: data["deviceId"] } } : {}
43411
+ });
43412
+ return null;
43413
+ }
43414
+ };
43415
+ //#endregion
42623
43416
  //#region src/pipeline-analytics/debug/tape-actions.ts
42624
43417
  /**
42625
43418
  * `debug.tape.*` — the tracking-tape surface (D681, spec §3.3). Admin,
@@ -42650,12 +43443,19 @@ var TapeStatusRowSchema = require_clip_model_registry.object({
42650
43443
  var TapeStatusInputSchema = require_clip_model_registry.object({ deviceIds: require_clip_model_registry.array(require_clip_model_registry.number().int()).optional() });
42651
43444
  var TapeStatusOutputSchema = require_clip_model_registry.object({ cameras: require_clip_model_registry.array(TapeStatusRowSchema).readonly() });
42652
43445
  var TapeLightingInputSchema = require_clip_model_registry._enum(["day", "ir"]);
43446
+ /**
43447
+ * Free text the settings store would hand back decoded is refused by name
43448
+ * (follow-up 617, review M2): whole JSON array/object text in a TEXT column
43449
+ * comes back as an array or object, and the index could not read the row.
43450
+ */
43451
+ var NOT_JSON_TEXT = "must not be JSON array/object text — the settings store would hand it back decoded";
43452
+ var notJsonText = (s) => !decodesAsJson(s);
42653
43453
  var PromoteInputSchema = require_clip_model_registry.object({
42654
43454
  deviceId: require_clip_model_registry.number().int().positive(),
42655
43455
  fromMs: require_clip_model_registry.number().int(),
42656
43456
  toMs: require_clip_model_registry.number().int(),
42657
- scenario: require_clip_model_registry.string(),
42658
- note: require_clip_model_registry.string().max(500).optional(),
43457
+ scenario: require_clip_model_registry.string().refine(notJsonText, { message: NOT_JSON_TEXT }),
43458
+ note: require_clip_model_registry.string().max(500).refine(notJsonText, { message: NOT_JSON_TEXT }).optional(),
42659
43459
  lighting: TapeLightingInputSchema.optional()
42660
43460
  });
42661
43461
  var PromoteOutputSchema = require_clip_model_registry.discriminatedUnion("ok", [require_clip_model_registry.object({
@@ -51118,6 +51918,17 @@ function pairRiders(detections) {
51118
51918
  * (D438) no frame did, and a newcomer on the stale prediction took the id.
51119
51919
  */
51120
51920
  var MAX_PREDICTION_MS = 1e3;
51921
+ /**
51922
+ * A frame more than `maxMissedMs` behind the latest is a CLOCK STEP only as
51923
+ * the K-th such frame in a row, each in order with the one before (follow-up
51924
+ * 617, fix round 3; D49: a read that would destroy work needs another to
51925
+ * agree). A step ends every track on the camera; one stray timestamp — a
51926
+ * backlog spike 4.5 s late — or two runner clocks delivering interleaved
51927
+ * (a camera migrating between runners) must not. Three is the least that a
51928
+ * single late frame plus an interleaved partner cannot reach by chance; the
51929
+ * cost of a real step is K−1 dropped frames (~260 ms at 7.7 fps).
51930
+ */
51931
+ var CLOCK_STEP_CONFIRM_FRAMES = 3;
51121
51932
  /** Confident boxes kept on a tracklet's path (the old tracker's bound). */
51122
51933
  var MAX_PATH_LENGTH = 300;
51123
51934
  /** Swap test: crossed pairs cheaper than this over the chosen pairs are doubt. */
@@ -51923,6 +52734,35 @@ function linkReach(from, gapMs) {
51923
52734
  return diag * Math.min(LINK_REACH_MAX_DIAGONALS, Math.max(LINK_REACH_MIN_DIAGONALS, LINK_REACH_DIAGONALS_PER_SEC * gapMs / 1e3));
51924
52735
  }
51925
52736
  var arcKey = (from, to) => `${from}\u0000${to}`;
52737
+ /**
52738
+ * The rejections that mean "geometry or appearance said no to a plausible
52739
+ * continuation" of a confirmed predecessor — a track the operator sees break
52740
+ * (follow-up 617, F5). Every one of them is past `hardRejection`, so the
52741
+ * predecessor is confirmed. NOT here (fix round 1): `unconfirmed`;
52742
+ * `has-successor` (continued elsewhere); `concurrent` and `identity-conflict`
52743
+ * (two subjects — the normal case, and the most frequent); `dropped` (an
52744
+ * operator delete or a refused chain).
52745
+ */
52746
+ var BREAK_REJECTIONS = new Set([
52747
+ "reach",
52748
+ "area",
52749
+ "appearance",
52750
+ "contested",
52751
+ "cost"
52752
+ ]);
52753
+ /** The confirmed predecessors whose link a decision rejected as a break (F5). */
52754
+ function confirmedPredecessorRejections(candidates) {
52755
+ const out = [];
52756
+ for (const c of candidates ?? []) {
52757
+ if (c.rejected === void 0 || !BREAK_REJECTIONS.has(c.rejected)) continue;
52758
+ out.push({
52759
+ from: c.from,
52760
+ rejected: c.rejected,
52761
+ gapMs: c.gapMs
52762
+ });
52763
+ }
52764
+ return out;
52765
+ }
51926
52766
  function arcFacts(a) {
51927
52767
  return {
51928
52768
  from: a.from.id,
@@ -52000,6 +52840,26 @@ var DeferredLinker = class {
52000
52840
  if (seen === void 0) this.names.set(p.id, new Set([p.identity]));
52001
52841
  else seen.add(p.identity);
52002
52842
  }
52843
+ /**
52844
+ * A clock step (follow-up 617, fix round 2): every piece still undecided is
52845
+ * decided `new`, so nothing that existed before the step can be absorbed
52846
+ * across it — whatever was ready was committed just before. The facade
52847
+ * rebases its clock past `LINK_MAX_GAP_MS`, so no later piece can continue
52848
+ * one of these either. An extra id, never a mix-up.
52849
+ */
52850
+ closeUndecided(now, pieces) {
52851
+ for (const p of pieces) this.accept(p, now);
52852
+ for (const p of this.pieces.values()) {
52853
+ if (this.outcomes.has(p.id)) continue;
52854
+ this.decide(p.id, "new");
52855
+ this.observer?.({
52856
+ t: now,
52857
+ kind: "new",
52858
+ to: p.id,
52859
+ reason: "clock-step"
52860
+ });
52861
+ }
52862
+ }
52003
52863
  isTailFinal(id, now, isLive) {
52004
52864
  const root = this.rootOf(id);
52005
52865
  if (this.pieces.has(root) && !this.outcomes.has(root)) return false;
@@ -52750,7 +53610,15 @@ var Tracklet = class {
52750
53610
  h: this.assocBox.h
52751
53611
  };
52752
53612
  }
53613
+ /**
53614
+ * A confident sighting. One OLDER than the last (`t < lastSeenAt` — ~1 % of
53615
+ * frames reach the tracker out of capture order, up to 1.2 s back) is
53616
+ * `stale`: it changes nothing. A rewound tracklet would be measured from a
53617
+ * box its subject left a second ago, and L2 refused a link by reach on
53618
+ * exactly that (follow-up 617, F1). The caller reports it.
53619
+ */
52753
53620
  observeConfident(det, t, faceBearing) {
53621
+ if (t < this.lastSeenAt) return "stale";
52754
53622
  const dt = t - this.lastSeenAt;
52755
53623
  if (dt > 0) {
52756
53624
  const from = centreOf(this.lastBox);
@@ -52762,6 +53630,7 @@ var Tracklet = class {
52762
53630
  this.emitIntervalMs = dt;
52763
53631
  }
52764
53632
  this.recordConfident(det, t, faceBearing);
53633
+ return "recorded";
52765
53634
  }
52766
53635
  observeLowScore(box, t) {
52767
53636
  this.assocBox = box;
@@ -52863,6 +53732,15 @@ var TrackletTracker = class {
52863
53732
  demoteSilent(t) {
52864
53733
  for (const tr of this.live.values()) if (tr.mode === "active" && t - tr.lastSeenAt > MAX_PREDICTION_MS) tr.mode = "coasting";
52865
53734
  }
53735
+ /** Ends every live tracklet at its own last sighting (a clock step); the ids it ended. */
53736
+ endAll(reason) {
53737
+ const ended = [];
53738
+ for (const tr of [...this.live.values()]) {
53739
+ ended.push(tr.id);
53740
+ this.finish(tr, tr.lastSeenAt, reason);
53741
+ }
53742
+ return ended;
53743
+ }
52866
53744
  /** Silence: end what the coast budget no longer covers. */
52867
53745
  expire(now) {
52868
53746
  for (const tr of [...this.live.values()]) if (now - tr.lastSeenAt > this.config.maxMissedMs) this.finish(tr, tr.lastSeenAt, "missed");
@@ -52953,12 +53831,12 @@ var TrackletTracker = class {
52953
53831
  const d = frame.detections[pair.detection];
52954
53832
  if (tr === void 0 || d === void 0) return;
52955
53833
  frame.used.add(pair.detection);
53834
+ if (tr.observeConfident(d, frame.t, frame.ctx.faceBearing?.has(d.bbox) === true) === "stale") return;
52956
53835
  tr.mode = "active";
52957
53836
  if (d.class !== tr.klass) this.refuse({
52958
53837
  reason: "cross-class-absorbed",
52959
53838
  against: tr
52960
53839
  }, d);
52961
- tr.observeConfident(d, frame.t, frame.ctx.faceBearing?.has(d.bbox) === true);
52962
53840
  if (pair.contested) {
52963
53841
  tr.contestedHits += 1;
52964
53842
  tr.contestedSince ??= frame.t;
@@ -53147,27 +54025,151 @@ var TrackerV2 = class {
53147
54025
  l1;
53148
54026
  l2;
53149
54027
  pending = [];
54028
+ /**
54029
+ * Internal time = the caller's frame time + `offset`. Zero until a clock
54030
+ * step; each step moves it so the internal clock never goes back and
54031
+ * resumes past `LINK_MAX_GAP_MS` of silence (fix round 2, C1). L1, L2, their
54032
+ * facts and prunes run on internal time; fusions and `frameClock()` are
54033
+ * handed back in the caller's clock.
54034
+ */
54035
+ offset = 0;
53150
54036
  lastT = 0;
54037
+ /** The latest FRAME time taken (internal) — never the sweep's extrapolated `now`. */
54038
+ lastFrameT = Number.NEGATIVE_INFINITY;
54039
+ observers;
54040
+ /** Caller times of the consecutive frames past the stale bound so far — the
54041
+ * run a clock step needs `CLOCK_STEP_CONFIRM_FRAMES` of (fix round 3). */
54042
+ stepRun = [];
53151
54043
  constructor(config, deps) {
53152
54044
  this.config = config;
53153
- this.l1 = new TrackletTracker(config, deps.mintTrackId, deps.observers ?? {});
54045
+ this.observers = deps.observers ?? {};
54046
+ const out = this.observers;
54047
+ const internal = {
54048
+ ...out.refusal !== void 0 ? { refusal: out.refusal } : {},
54049
+ ended: (f) => out.ended?.({
54050
+ ...f,
54051
+ at: f.at - this.offset
54052
+ }),
54053
+ split: (f) => out.split?.({
54054
+ ...f,
54055
+ t: f.t - this.offset
54056
+ })
54057
+ };
54058
+ this.l1 = new TrackletTracker(config, deps.mintTrackId, internal);
53154
54059
  this.l2 = new DeferredLinker({
53155
54060
  maxMissedMs: config.maxMissedMs,
53156
54061
  appearanceResolvesAmbiguity: config.appearanceResolvesAmbiguity
53157
- }, deps.observers?.link);
54062
+ }, (f) => out.link?.({
54063
+ ...f,
54064
+ t: f.t - this.offset
54065
+ }));
53158
54066
  }
53159
- update(detections, t, ctx) {
54067
+ update(detections, frameT, ctx) {
54068
+ const order = this.classifyFrame(frameT);
54069
+ if (order === "out-of-order") return this.drop(frameT, detections.length);
54070
+ let t = frameT + this.offset;
54071
+ if (order === "clock-step") t = this.clockStep(frameT, this.lastFrameT - t);
54072
+ this.stepRun = [];
54073
+ this.lastFrameT = t;
53160
54074
  this.lastT = Math.max(this.lastT, t);
53161
54075
  const step = this.l1.step(detections, t, ctx);
53162
54076
  this.link(this.lastT);
53163
54077
  return step.emissions.map((e) => this.emit(e, t));
53164
54078
  }
53165
54079
  /**
54080
+ * A frame stamped before the latest one already taken (follow-up 617, F1;
54081
+ * ~1 % of frames, up to 1.2 s back). It is decided ONCE, for the whole
54082
+ * frame (fix round 1, H1): matched against the tracklets' NEWEST boxes, a
54083
+ * stale box can land on another subject — a walker's old box on the walker
54084
+ * behind — and be emitted under that id. So nothing binds, spawns, splits or
54085
+ * is demoted on it; the confirmed tracklets are emitted as on a frame that
54086
+ * matched nothing (no sighting), and one fact says the frame was dropped.
54087
+ */
54088
+ /**
54089
+ * How `update` will take a frame at `frameT` — without taking it. The
54090
+ * processor asks first (fix round 3, MEDIUM-2), so a dropped frame never
54091
+ * reaches the stationary gate, the zones or the event path.
54092
+ * - `in-order`: not behind the latest frame taken;
54093
+ * - `out-of-order`: behind it — within `maxMissedMs` (disorder, ≤ 1.2 s
54094
+ * measured), or further back but not yet the `CLOCK_STEP_CONFIRM_FRAMES`-th
54095
+ * such frame in a row;
54096
+ * - `clock-step`: the K-th consecutive frame past the bound, each in order
54097
+ * with the one before (within `maxMissedMs`).
54098
+ */
54099
+ classifyFrame(frameT) {
54100
+ const behindMs = this.lastFrameT - (frameT + this.offset);
54101
+ if (!(behindMs > 0)) return "in-order";
54102
+ if (behindMs <= this.config.maxMissedMs) return "out-of-order";
54103
+ return this.runLength(frameT) >= CLOCK_STEP_CONFIRM_FRAMES ? "clock-step" : "out-of-order";
54104
+ }
54105
+ /** The past-bound run this frame would make, itself included. */
54106
+ runLength(frameT) {
54107
+ const prev = this.stepRun.at(-1);
54108
+ return (prev !== void 0 && frameT >= prev && frameT - prev <= this.config.maxMissedMs ? this.stepRun.length : 0) + 1;
54109
+ }
54110
+ /**
54111
+ * Drops a frame `classifyFrame` called `out-of-order`. A frame past the
54112
+ * bound extends (or restarts) the run a clock step needs; any other breaks
54113
+ * it. One fact per frame.
54114
+ */
54115
+ drop(frameT, detections) {
54116
+ const behindMs = this.lastFrameT - (frameT + this.offset);
54117
+ let pendingClockStep = 0;
54118
+ if (behindMs > this.config.maxMissedMs) {
54119
+ pendingClockStep = this.runLength(frameT);
54120
+ this.stepRun = pendingClockStep === 1 ? [frameT] : [...this.stepRun, frameT];
54121
+ } else this.stepRun = [];
54122
+ this.observers.outOfOrder?.({
54123
+ t: frameT,
54124
+ lastFrameT: this.frameClock(),
54125
+ behindMs,
54126
+ detections,
54127
+ pendingClockStep
54128
+ });
54129
+ return this.l1.liveTracklets().filter((tr) => tr.confirmed).map((tracklet) => this.emit({
54130
+ tracklet,
54131
+ detection: void 0,
54132
+ matched: false
54133
+ }, this.lastFrameT));
54134
+ }
54135
+ /**
54136
+ * The frame clock stepped back further than `maxMissedMs` (fix round 2, C1):
54137
+ * not disorder — an NTP correction, or a camera moved to a runner whose clock
54138
+ * is behind. An unbounded gate dropped every frame until the new clock passed
54139
+ * the old high mark (60 s back: 461 frames, a new subject never tracked, the
54140
+ * departed one coasting on). Instead: commit what L2 has decided, end every
54141
+ * live tracklet at its own last sighting, decide every piece still undecided
54142
+ * `new` (no fusion crosses the step), and resume the internal clock past
54143
+ * `LINK_MAX_GAP_MS` of silence, so nothing later can continue anything
54144
+ * earlier. Returns the frame's internal time.
54145
+ */
54146
+ clockStep(frameT, behindMs) {
54147
+ const lastFrameT = this.frameClock();
54148
+ this.link(this.lastT);
54149
+ const ended = this.l1.endAll("clock-step");
54150
+ this.l2.closeUndecided(this.lastT, this.pieces());
54151
+ const resumeAt = Math.max(this.lastT, this.lastFrameT) + LINK_MAX_GAP_MS + 1;
54152
+ this.offset = resumeAt - frameT;
54153
+ this.lastT = resumeAt;
54154
+ this.observers.clockStep?.({
54155
+ t: frameT,
54156
+ lastFrameT,
54157
+ behindMs,
54158
+ ended
54159
+ });
54160
+ return resumeAt;
54161
+ }
54162
+ /** The latest frame time taken, in the caller's clock (rebased by a clock step). */
54163
+ frameClock() {
54164
+ return this.lastFrameT - this.offset;
54165
+ }
54166
+ /**
53166
54167
  * The close sweep's entry on a camera whose frames stopped (D438): end the
53167
54168
  * tracklets the coast budget no longer covers, THEN run L2 (review C2).
53168
54169
  * `now` is FRAME time (the processor converts wall clock, Task 11).
53169
54170
  */
53170
- advance(now) {
54171
+ advance(frameNow) {
54172
+ const now = frameNow + this.offset;
53171
54173
  this.lastT = Math.max(this.lastT, now);
53172
54174
  this.l1.expire(now);
53173
54175
  this.link(now);
@@ -53183,6 +54185,7 @@ var TrackerV2 = class {
53183
54185
  markSuppressed(id) {
53184
54186
  this.l1.markSuppressed(id);
53185
54187
  }
54188
+ /** In the caller's clock (translated when each was committed, LOW-5). */
53186
54189
  drainFusions() {
53187
54190
  const out = this.pending;
53188
54191
  this.pending = [];
@@ -53195,8 +54198,8 @@ var TrackerV2 = class {
53195
54198
  isLive(id) {
53196
54199
  return this.l2.membersOf(this.l2.rootOf(id)).some((m) => this.isLiveTracklet(m));
53197
54200
  }
53198
- isTailFinal(id, now) {
53199
- return this.l2.isTailFinal(id, now, (m) => this.isLiveTracklet(m));
54201
+ isTailFinal(id, frameNow) {
54202
+ return this.l2.isTailFinal(id, frameNow + this.offset, (m) => this.isLiveTracklet(m));
53200
54203
  }
53201
54204
  /**
53202
54205
  * Ends every live member of the chain. `stationary` (round 4): the
@@ -53212,9 +54215,15 @@ var TrackerV2 = class {
53212
54215
  const tr = this.l1.get(id);
53213
54216
  return tr !== void 0 && tr.endedAt === null;
53214
54217
  }
54218
+ pieces() {
54219
+ return [...this.l1.liveTracklets(), ...this.l1.endedTracklets()].map(toPiece);
54220
+ }
53215
54221
  link(now) {
53216
- const pieces = [...this.l1.liveTracklets(), ...this.l1.endedTracklets()].map(toPiece);
53217
- this.pending.push(...this.l2.step(now, pieces));
54222
+ const offset = this.offset;
54223
+ for (const f of this.l2.step(now, this.pieces())) this.pending.push({
54224
+ ...f,
54225
+ t: f.t - offset
54226
+ });
53218
54227
  this.l1.prune(now);
53219
54228
  this.l2.prune(now);
53220
54229
  }
@@ -53277,7 +54286,8 @@ var NO_ENDS = {
53277
54286
  missed: 0,
53278
54287
  ambiguous: 0,
53279
54288
  dropped: 0,
53280
- stationary: 0
54289
+ stationary: 0,
54290
+ "clock-step": 0
53281
54291
  };
53282
54292
  function emptyStats() {
53283
54293
  return {
@@ -53290,16 +54300,28 @@ function emptyStats() {
53290
54300
  linksRefused: 0,
53291
54301
  linksNew: 0,
53292
54302
  linksDisagreed: 0,
53293
- lowScoreHolds: 0
54303
+ lowScoreHolds: 0,
54304
+ outOfOrderFrames: 0,
54305
+ clockSteps: 0
53294
54306
  };
53295
54307
  }
53296
54308
  /** Nothing happened in the window: no tracklet ended, no split, no L2
53297
54309
  * decision, no low-score hold (review LOW-N1 — what the sweep stays quiet about). */
53298
54310
  function isIdleTrackerStats(stats) {
53299
- return [stats.tracklets.confirmed, stats.tracklets.unconfirmed].flatMap((c) => Object.values(c)).every((n) => n === 0) && stats.splits === 0 && stats.linksCommitted === 0 && stats.linksRefused === 0 && stats.linksNew === 0 && stats.linksDisagreed === 0 && stats.lowScoreHolds === 0;
54311
+ return [stats.tracklets.confirmed, stats.tracklets.unconfirmed].flatMap((c) => Object.values(c)).every((n) => n === 0) && stats.splits === 0 && stats.linksCommitted === 0 && stats.linksRefused === 0 && stats.linksNew === 0 && stats.linksDisagreed === 0 && stats.lowScoreHolds === 0 && stats.outOfOrderFrames === 0 && stats.clockSteps === 0;
53300
54312
  }
53301
54313
  /** Shared empty — a fold is rare and every other frame would allocate. */
53302
54314
  var EMPTY_RIDER_FOLDS = [];
54315
+ /** The tape observation of a dropped frame: no gate verdict, nothing bound. */
54316
+ var DROPPED_FRAME_OBSERVATION = {
54317
+ gate: {
54318
+ strong: [],
54319
+ weak: [],
54320
+ confirmed: [],
54321
+ woken: []
54322
+ },
54323
+ live: []
54324
+ };
53303
54325
  /** Mapping from StateAnalyzer's `ObjectState.state` values to the
53304
54326
  * canonical TrackState enum used on tracks + events. */
53305
54327
  function mapObjectStateToTrackState(s) {
@@ -53478,6 +54500,14 @@ var FrameProcessor = class {
53478
54500
  else if (f.kind === "new") this.stats.linksNew += 1;
53479
54501
  else if (f.kind === "disagreed") this.stats.linksDisagreed += 1;
53480
54502
  this.trackerObservers.link?.(f);
54503
+ },
54504
+ outOfOrder: (f) => {
54505
+ this.stats.outOfOrderFrames += 1;
54506
+ this.trackerObservers.outOfOrder?.(f);
54507
+ },
54508
+ clockStep: (f) => {
54509
+ this.stats.clockSteps += 1;
54510
+ this.trackerObservers.clockStep?.(f);
53481
54511
  }
53482
54512
  }
53483
54513
  });
@@ -53761,6 +54791,37 @@ var FrameProcessor = class {
53761
54791
  * Zero is the expected reading; a rising one means two people stand close
53762
54792
  * together on this camera, which is worth knowing per-camera rather than
53763
54793
  * per-fleet. */
54794
+ /**
54795
+ * A frame `classifyFrame` called out of order: the tracker records the drop
54796
+ * (and any clock-step run it extends) and nothing else reads the frame. The
54797
+ * per-frame side tables a reader asks for afterwards are emptied, so it never
54798
+ * re-reads the previous frame's.
54799
+ */
54800
+ droppedFrame(input) {
54801
+ this.tracker.drop(input.timestamp, input.frame.detections.length);
54802
+ this.lastZoneOverlaps = /* @__PURE__ */ new Map();
54803
+ this.lastZoneRejections = /* @__PURE__ */ new Map();
54804
+ this.lastLabelWriteRefusals = [];
54805
+ const fusions = this.tracker.drainFusions();
54806
+ this.applyFusions(fusions);
54807
+ return {
54808
+ deviceId: this.deviceId,
54809
+ timestamp: input.timestamp,
54810
+ frameWidth: input.frame.width,
54811
+ frameHeight: input.frame.height,
54812
+ tracked: [],
54813
+ objectEvents: [],
54814
+ appearanceEvents: [],
54815
+ rawTrackedDetections: [],
54816
+ stationaryConfirmed: [],
54817
+ stationaryWoken: [],
54818
+ stationaryContinued: 0,
54819
+ riderFolds: [],
54820
+ ...input.observe === true ? { observation: DROPPED_FRAME_OBSERVATION } : {},
54821
+ fusions,
54822
+ droppedOutOfOrder: true
54823
+ };
54824
+ }
53764
54825
  facesRefusedOutsideParent() {
53765
54826
  return this.faceOutsideParentCount;
53766
54827
  }
@@ -53783,6 +54844,7 @@ var FrameProcessor = class {
53783
54844
  }
53784
54845
  process(input) {
53785
54846
  const { timestamp, frame } = input;
54847
+ if (this.tracker.classifyFrame(timestamp) === "out-of-order") return this.droppedFrame(input);
53786
54848
  this.lastFrameT = timestamp;
53787
54849
  this.lastFrameWallAt = this.wallClock();
53788
54850
  const frameWidth = frame.width;
@@ -56534,239 +57596,6 @@ async function promoteTapeWindow(deps, input, signal) {
56534
57596
  };
56535
57597
  }
56536
57598
  //#endregion
56537
- //#region src/pipeline-analytics/store/tracking-tape-index-store.ts
56538
- /**
56539
- * @durable class=ledger owner=pipeline-analytics
56540
- * write="one row per PROMOTED tracking-tape window (`debug.tape.promote`);
56541
- * updated when the operator marks it reviewed or releases its pins;
56542
- * deleted together with its file by `debug.tape.delete`."
56543
- * retention="NOT an age clock and NOT track retention — a promoted window is
56544
- * history being labelled (D681). Bounded by the promotion budget
56545
- * (MAX_PROMOTED_TAPES = 60, 1 GiB): a promotion beyond it is REFUSED,
56546
- * never satisfied by evicting another row."
56547
- */
56548
- var TRACKING_TAPES_COLLECTION = "pipeline-analytics:tracking-tapes";
56549
- var TRACKING_TAPES_COLUMNS = [
56550
- {
56551
- name: "id",
56552
- type: "TEXT",
56553
- primaryKey: true,
56554
- notNull: true
56555
- },
56556
- {
56557
- name: "deviceId",
56558
- type: "INTEGER",
56559
- notNull: true
56560
- },
56561
- {
56562
- name: "scenario",
56563
- type: "TEXT",
56564
- notNull: true
56565
- },
56566
- {
56567
- name: "note",
56568
- type: "TEXT"
56569
- },
56570
- {
56571
- name: "lighting",
56572
- type: "TEXT",
56573
- notNull: true
56574
- },
56575
- {
56576
- name: "fromMs",
56577
- type: "INTEGER",
56578
- notNull: true
56579
- },
56580
- {
56581
- name: "toMs",
56582
- type: "INTEGER",
56583
- notNull: true
56584
- },
56585
- {
56586
- name: "state",
56587
- type: "TEXT",
56588
- notNull: true
56589
- },
56590
- {
56591
- name: "frames",
56592
- type: "INTEGER",
56593
- notNull: true
56594
- },
56595
- {
56596
- name: "skips",
56597
- type: "INTEGER",
56598
- notNull: true
56599
- },
56600
- {
56601
- name: "gaps",
56602
- type: "INTEGER",
56603
- notNull: true
56604
- },
56605
- {
56606
- name: "bytes",
56607
- type: "INTEGER",
56608
- notNull: true
56609
- },
56610
- {
56611
- name: "reviewed",
56612
- type: "BOOLEAN",
56613
- notNull: true
56614
- },
56615
- {
56616
- name: "trackIds",
56617
- type: "TEXT",
56618
- notNull: true
56619
- },
56620
- {
56621
- name: "createdAt",
56622
- type: "INTEGER",
56623
- notNull: true
56624
- },
56625
- {
56626
- name: "releasedAt",
56627
- type: "INTEGER"
56628
- },
56629
- {
56630
- name: "pinsSettled",
56631
- type: "BOOLEAN",
56632
- notNull: true
56633
- }
56634
- ];
56635
- var TrackingTapeRowSchema = require_clip_model_registry.object({
56636
- id: require_clip_model_registry.string(),
56637
- deviceId: require_clip_model_registry.number().int(),
56638
- scenario: require_clip_model_registry.string(),
56639
- note: require_clip_model_registry.string().nullable(),
56640
- lighting: TapeLightingSchema,
56641
- fromMs: require_clip_model_registry.number().int(),
56642
- toMs: require_clip_model_registry.number().int(),
56643
- state: require_clip_model_registry._enum(["complete", "incomplete"]),
56644
- frames: require_clip_model_registry.number().int(),
56645
- skips: require_clip_model_registry.number().int(),
56646
- gaps: require_clip_model_registry.number().int(),
56647
- bytes: require_clip_model_registry.number().int(),
56648
- reviewed: require_clip_model_registry.boolean(),
56649
- trackIds: require_clip_model_registry.array(require_clip_model_registry.string()),
56650
- createdAt: require_clip_model_registry.number().int(),
56651
- releasedAt: require_clip_model_registry.number().int().nullable(),
56652
- pinsSettled: require_clip_model_registry.boolean()
56653
- });
56654
- var TrackIdsSchema = require_clip_model_registry.array(require_clip_model_registry.string());
56655
- var TrackingTapeIndexStore = class {
56656
- store;
56657
- logger;
56658
- constructor(deps) {
56659
- this.store = deps.store;
56660
- this.logger = deps.logger;
56661
- }
56662
- static async declare(store) {
56663
- await store.declareCollection.mutate({
56664
- collection: TRACKING_TAPES_COLLECTION,
56665
- columns: [...TRACKING_TAPES_COLUMNS],
56666
- indexes: [{
56667
- name: "idx_trackingtapes_device_created",
56668
- columns: ["deviceId", "createdAt"]
56669
- }]
56670
- });
56671
- }
56672
- async insert(row) {
56673
- const { id, trackIds, ...rest } = TrackingTapeRowSchema.parse(row);
56674
- await this.store.insert.mutate({
56675
- collection: TRACKING_TAPES_COLLECTION,
56676
- record: {
56677
- id,
56678
- data: {
56679
- ...rest,
56680
- trackIds: JSON.stringify(trackIds)
56681
- }
56682
- }
56683
- });
56684
- }
56685
- async get(id) {
56686
- const first = (await this.store.query.query({
56687
- collection: TRACKING_TAPES_COLLECTION,
56688
- filter: {
56689
- where: { id },
56690
- limit: 1
56691
- }
56692
- }))[0];
56693
- return first === void 0 ? null : this.parse(first.id, first.data);
56694
- }
56695
- async list(deviceId) {
56696
- const rows = await this.store.query.query({
56697
- collection: TRACKING_TAPES_COLLECTION,
56698
- filter: {
56699
- ...deviceId !== void 0 ? { where: { deviceId } } : {},
56700
- orderBy: {
56701
- field: "createdAt",
56702
- direction: "desc"
56703
- },
56704
- limit: 1e3
56705
- }
56706
- });
56707
- const out = [];
56708
- for (const r of rows) {
56709
- const parsed = this.parse(r.id, r.data);
56710
- if (parsed !== null) out.push(parsed);
56711
- }
56712
- return out;
56713
- }
56714
- async setReviewed(id, reviewed) {
56715
- await this.store.update.mutate({
56716
- collection: TRACKING_TAPES_COLLECTION,
56717
- id,
56718
- data: { reviewed }
56719
- });
56720
- }
56721
- /** Release AND unsettle in one row write: the unpins the release implies
56722
- * are owed from this moment, whether or not the caller lives to make them. */
56723
- async setReleased(id, atMs) {
56724
- await this.store.update.mutate({
56725
- collection: TRACKING_TAPES_COLLECTION,
56726
- id,
56727
- data: {
56728
- releasedAt: atMs,
56729
- pinsSettled: false
56730
- }
56731
- });
56732
- }
56733
- async setPinsSettled(id, settled) {
56734
- await this.store.update.mutate({
56735
- collection: TRACKING_TAPES_COLLECTION,
56736
- id,
56737
- data: { pinsSettled: settled }
56738
- });
56739
- }
56740
- async remove(id) {
56741
- await this.store.delete.mutate({
56742
- collection: TRACKING_TAPES_COLLECTION,
56743
- key: id
56744
- });
56745
- }
56746
- parse(id, data) {
56747
- let trackIds = [];
56748
- try {
56749
- trackIds = JSON.parse(String(data["trackIds"] ?? "[]"));
56750
- } catch {
56751
- trackIds = null;
56752
- }
56753
- const ids = TrackIdsSchema.safeParse(trackIds);
56754
- const parsed = TrackingTapeRowSchema.safeParse({
56755
- id,
56756
- ...data,
56757
- note: data["note"] ?? null,
56758
- releasedAt: data["releasedAt"] ?? null,
56759
- trackIds: ids.success ? ids.data : null
56760
- });
56761
- if (parsed.success) return parsed.data;
56762
- this.logger.warn("tracking tape index: skipped a malformed row", {
56763
- meta: { id },
56764
- ...typeof data["deviceId"] === "number" ? { tags: { deviceId: data["deviceId"] } } : {}
56765
- });
56766
- return null;
56767
- }
56768
- };
56769
- //#endregion
56770
57599
  //#region src/pipeline-analytics/tracking-bench/recorder/tape-status.ts
56771
57600
  /** The one node that may hold tapes: sqlite and bulk state are hub-only, and
56772
57601
  * an agent persists nothing (spec §3.1). */
@@ -87918,6 +88747,18 @@ function isRecord(x) {
87918
88747
  }
87919
88748
  //#endregion
87920
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
+ */
87921
88762
  var TRACK_ALIASES_COLLECTION = "pipeline-analytics:track-aliases";
87922
88763
  var TRACK_ALIAS_COLUMNS = [
87923
88764
  {
@@ -88100,6 +88941,20 @@ var TrackAliasStore = class {
88100
88941
  dropped: 0
88101
88942
  }];
88102
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
+ }
88103
88958
  async deleteByTracks(trackIds) {
88104
88959
  const { deleted } = await this.store.deleteWhere.mutate({
88105
88960
  collection: TRACK_ALIASES_COLLECTION,
@@ -89026,7 +89881,8 @@ async function auditScope(scope, deps, limits, cursor) {
89026
89881
  stoppedBy,
89027
89882
  nextCursor: offset
89028
89883
  } });
89029
- 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: {
89030
89886
  collection: scope.collection,
89031
89887
  shard: scope.shard?.value ?? null,
89032
89888
  orphans: tally.orphans,
@@ -89140,6 +89996,40 @@ var ORPHAN_AUDIT_DEFAULTS = {
89140
89996
  pageSize: OrphanAuditPageSizeSchema.parse(void 0)
89141
89997
  };
89142
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
+ }
89143
90033
  function buildOrphanReclaimer(ports) {
89144
90034
  return async (collection, key) => {
89145
90035
  if (collection === MEDIA_COLLECTION) {
@@ -89163,6 +90053,10 @@ function buildOrphanReclaimer(ports) {
89163
90053
  bytes: null
89164
90054
  };
89165
90055
  }
90056
+ if (collection === TRACK_ALIASES_COLLECTION) return {
90057
+ rows: await ports.deleteTrackAlias(key) ? 1 : 0,
90058
+ bytes: null
90059
+ };
89166
90060
  if (RAW_DELETE_COLLECTIONS.has(collection)) return {
89167
90061
  rows: await ports.deleteRow(collection, key) ? 1 : 0,
89168
90062
  bytes: null
@@ -97697,6 +98591,12 @@ var TRACKER_FACT_LOG_SAMPLE_EVERY = 10;
97697
98591
  * positive spawns (review M1: ~1 750 `new` decisions per camera per hour) —
97698
98592
  * are sampled harder; the per-minute summary carries their exact counts. */
97699
98593
  var TRACKER_PIECE_LOG_SAMPLE_EVERY = 50;
98594
+ /** Out-of-order frames (follow-up 617, F1: ~1 % of frames) log the first N,
98595
+ * then one in this many — every line carries the count; the per-minute
98596
+ * summary carries the exact `outOfOrderFrames`. */
98597
+ var TRACKER_OUT_OF_ORDER_LOG_SAMPLE_EVERY = 50;
98598
+ /** Clock-step WARNs past the first ten (fix round 3, MEDIUM-1). */
98599
+ var TRACKER_CLOCK_STEP_LOG_SAMPLE_EVERY = 50;
97700
98600
  /** First N, then one in `every` — the refusal stream's cadence (D391). */
97701
98601
  function sampledTrackerFact(count, every = TRACKER_FACT_LOG_SAMPLE_EVERY) {
97702
98602
  return count <= TRACKER_FACT_LOG_ALWAYS_FIRST || count % every === 0;
@@ -99526,6 +100426,7 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_clip_m
99526
100426
  },
99527
100427
  listTrackGroups: (query) => center.listTrackGroups(query),
99528
100428
  injectTestEvent: (input) => center.injectTestEvent(input, { getSnapshot: (deviceId) => api.snapshot.getSnapshot.query({ deviceId }) }),
100429
+ testStateSummary: (rule) => center.testStateSummary(rule),
99529
100430
  timelapse: {
99530
100431
  store: center.timelapseStore,
99531
100432
  getRecordingConfig: (deviceId) => api.recordingArchive.getDeviceConfig.query({ deviceId }).catch(() => null),
@@ -102344,9 +103245,11 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_clip_m
102344
103245
  let splitCount = 0;
102345
103246
  let ignoredCount = 0;
102346
103247
  let newCount = 0;
103248
+ let outOfOrderCount = 0;
103249
+ let clockStepCount = 0;
102347
103250
  const endedCounts = /* @__PURE__ */ new Map();
102348
103251
  const logLink = (f) => {
102349
- const meta = {
103252
+ const decision = {
102350
103253
  source,
102351
103254
  t: f.t,
102352
103255
  kind: f.kind,
@@ -102355,7 +103258,10 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_clip_m
102355
103258
  cost: f.cost,
102356
103259
  gapMs: f.gapMs,
102357
103260
  reason: f.reason,
102358
- recognition: f.recognition,
103261
+ recognition: f.recognition
103262
+ };
103263
+ const meta = {
103264
+ ...decision,
102359
103265
  candidates: f.candidates
102360
103266
  };
102361
103267
  switch (f.kind) {
@@ -102393,7 +103299,14 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_clip_m
102393
103299
  meta
102394
103300
  });
102395
103301
  return;
102396
- case "new":
103302
+ case "new": {
103303
+ if (f.reason === "clock-step") {
103304
+ trackerLog.debug("tracker: piece decided new — closed by a frame clock step", {
103305
+ tags: { deviceId },
103306
+ meta
103307
+ });
103308
+ return;
103309
+ }
102397
103310
  if (f.reason === "unconfirmed") {
102398
103311
  trackerLog.debug("tracker: piece decided new — never confirmed", {
102399
103312
  tags: { deviceId },
@@ -102401,6 +103314,17 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_clip_m
102401
103314
  });
102402
103315
  return;
102403
103316
  }
103317
+ const rejectedPredecessors = confirmedPredecessorRejections(f.candidates);
103318
+ if (rejectedPredecessors.length > 0) {
103319
+ trackerLog.info("tracker: piece decided new — a confirmed predecessor was rejected", {
103320
+ tags: { deviceId },
103321
+ meta: {
103322
+ ...decision,
103323
+ rejectedPredecessors
103324
+ }
103325
+ });
103326
+ return;
103327
+ }
102404
103328
  newCount += 1;
102405
103329
  if (!sampledTrackerFact(newCount, TRACKER_PIECE_LOG_SAMPLE_EVERY)) return;
102406
103330
  trackerLog.info("tracker: piece decided new — no predecessor", {
@@ -102411,6 +103335,7 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_clip_m
102411
103335
  }
102412
103336
  });
102413
103337
  return;
103338
+ }
102414
103339
  }
102415
103340
  };
102416
103341
  p.setTrackerObservers({
@@ -102431,6 +103356,39 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_clip_m
102431
103356
  });
102432
103357
  },
102433
103358
  link: logLink,
103359
+ clockStep: (f) => {
103360
+ clockStepCount += 1;
103361
+ if (!sampledTrackerFact(clockStepCount, TRACKER_CLOCK_STEP_LOG_SAMPLE_EVERY)) return;
103362
+ trackerLog.warn("tracker: frame clock stepped back — tracklets ended, clock rebased", {
103363
+ tags: { deviceId },
103364
+ meta: {
103365
+ source,
103366
+ t: f.t,
103367
+ lastFrameT: f.lastFrameT,
103368
+ behindMs: f.behindMs,
103369
+ ended: f.ended,
103370
+ clockStepCount,
103371
+ sampledEvery: TRACKER_CLOCK_STEP_LOG_SAMPLE_EVERY
103372
+ }
103373
+ });
103374
+ },
103375
+ outOfOrder: (f) => {
103376
+ outOfOrderCount += 1;
103377
+ if (!sampledTrackerFact(outOfOrderCount, TRACKER_OUT_OF_ORDER_LOG_SAMPLE_EVERY)) return;
103378
+ trackerLog.info("tracker: out-of-order frame dropped — older than the latest frame", {
103379
+ tags: { deviceId },
103380
+ meta: {
103381
+ source,
103382
+ t: f.t,
103383
+ lastFrameT: f.lastFrameT,
103384
+ behindMs: f.behindMs,
103385
+ detections: f.detections,
103386
+ pendingClockStep: f.pendingClockStep,
103387
+ outOfOrderCount,
103388
+ sampledEvery: TRACKER_OUT_OF_ORDER_LOG_SAMPLE_EVERY
103389
+ }
103390
+ });
103391
+ },
102434
103392
  ended: (f) => {
102435
103393
  const k = `${f.reason}:${String(f.confirmed)}`;
102436
103394
  const n = (endedCounts.get(k) ?? 0) + 1;
@@ -102572,6 +103530,7 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_clip_m
102572
103530
  });
102573
103531
  });
102574
103532
  this.reportTrackerSummary(deviceId, key, processor, "frame");
103533
+ if (result.droppedOutOfOrder === true) return;
102575
103534
  for (const r of processor.getLastLabelWriteRefusals()) {
102576
103535
  const message = r.reason.kind === "macro-class" ? "label tier collapse refused" : "label write refused";
102577
103536
  this.ctx.logger.info(message, {
@@ -107226,10 +108185,12 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_clip_m
107226
108185
  bytes: count.bytes
107227
108186
  };
107228
108187
  },
107229
- deleteRow: async (collection, key) => await this.settingsStore?.delete.mutate({
107230
- collection,
107231
- key
107232
- }) !== 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),
107233
108194
  logger: this.ctx.logger
107234
108195
  }),
107235
108196
  now: () => Date.now(),