@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.
@@ -1,4 +1,4 @@
1
- import { $ as NC_AUDIO_CONFIRM_WINDOW_SEC_DEFAULT, $n as ruleDeviceCompatCapQueries, $t as TimelapseRuleInputSchema, A as EMBEDDING_REQUIREMENTS_FAILED, An as isDetectionMacroClass, Ar as ZodObject, At as NcTemplateVarDescriptorSchema, B as ListTrackRelationsInputSchema, Bn as memoizePinnedFfmpeg, Br as record, Bt as SCENE_DEFAULT_ANCHOR_THRESHOLD, C as DEFAULT_EVENT_COLOR, Cn as encodeVectorBase64, Cr as customAction, Ct as NcSnoozeInputSchema, D as DeclaredDevices, Dn as gifDeriveSpeed, Dr as nodePin, Dt as NcTaxonomySchema, E as DETECTION_PIPELINE_CAP_NAME, En as failureContributionCapability, Er as isDeviceScopedCap, Et as NcSystemEventKindSchema, F as EXPORT_DENSE_MAX_RANGES, Fn as isSystemDelivery, Fr as literal, Ft as RECORDING_SOURCE_CAMSTACK_ADDON, G as MAX_BIRTH_LATENCY_PROXY_MS, Gn as plateGalleryCapability, Gr as unknown, Gt as SCENE_DIVERGED, H as LockStateSchema, Hn as occupancyScope, Hr as string, Ht as SCENE_DEFAULT_OBSERVATION_SPACING_SEC, I as FIRST_LEVEL_MACRO_CLASSES, In as isSystemEventSummaryRule, Ir as looseObject, It as RebuildObjectEmbeddingsRefusalSchema, J as MOTION_CLOSE_AFTER_MS, Jn as readTimelapseGeneratedAt, Jt as SetTrackPurityInputSchema, K as MAX_TRACK_PURITIES, Kn as readDeviceStateFrom, Kr as EventCategory, Kt as SCENE_RESET_RECAPTURES, L as FULL_IMAGE_BBOX, Ln as isVectorIndexDimConflict, Lr as number, Lt as RetrainStatusSchema, M as EVENT_DENSITY_BATCH_MAX, Mn as isKnownTemplateVar, Mr as array, Mt as OpsLogEntrySchema, N as EVENT_KIND_BY_CAP, Nn as isMediaOwnerType, Nr as boolean, O as DetectionSourceSchema, On as gifFromMp4Args, Ot as NcTemplatePreviewInputSchema, P as EVENT_OWNER_TYPES, Pn as isScheduleActive, Pr as discriminatedUnion, Pt as RECORDING_EXPORT_MAX_READ_BYTES, Q as NC_AUDIO_CONFIRM_HITS_DEFAULT, Qn as ruleBelongsToAlarm$1, Qt as TRACK_RELATIONS_DEFAULT_LIMIT, R as FailureCounters, Rn as kebabToCamel, Rr as object, Rt as SCENE_CONFIRM_DEFAULT_MAX_IMAGE_PX, S as CoverStateSchema, Sr as createEvent, St as NcScheduleSchema, T as DETECTION_MACRO_CLASSES, Tn as faceGalleryCapability, Tr as hydrateSchema, Tt as NcSnoozeSuppressedSchema, U as MAX_ARCHIVED_DEBUG_NOTES, Ur as tuple, Ut as SCENE_DEFAULT_QUIET_SECONDS, V as ListTrackRelationsResultSchema, Vn as notificationRulesCapability, Vr as strictObject, Vt as SCENE_DEFAULT_CHECK_INTERVAL_SEC, W as MAX_BIRTH_DECISION_RECORDS, Wn as pipelineAnalyticsCapability, Wr as union, Wt as SCENE_DEFAULT_UNCOVERED_POLICY, X as MotionTriggeredBySchema, Xn as resolveLocationMode, Xt as SetTrackRelationInputSchema, Y as MediaFileKindEnum, Yn as resolveClipSearchMinScore, Yt as SetTrackPurityResultSchema, Z as NC_ALARM_SYSTEM_EVENT_KINDS, Zt as TIMELAPSE_DENSE_FLOOR_SEC, _ as BaseDevice, _n as describeSystemEventFamilyProblem, _r as isSourceCap, _t as NcRuleInputSchema, a as CLIP_EMBEDDING_STEP_ID, an as TrackSideSchema, ar as subKindsOf, at as NC_CONSUMABLE_SYSTEM_EVENT_KINDS, b as ClearTrackRelationInputSchema, bn as embeddingRefusalOf, br as BaseAddon, bt as NcRuleTargetSchema, c as resolveClusterModelPin, cn as alarmPanelCapability, cr as systemEventFilterApplies, ct as NC_SNOOZE_MAX_MINUTES, d as APPEARANCE_RESOLVES_AMBIGUITY_DEFAULT, dn as audioModeOf, dr as templateVarsFor, dt as NC_TAXONOMY, en as TimelapseRulePatchSchema, er as ruleKindOf, et as NC_CLIP_POST_ROLL_DEFAULT_SEC, f as ARCHIVED_DEBUG_NOTES_DEFAULT_LIMIT, fn as buildEventKindDescriptor, fr as vectorDimFromBase64, ft as NC_TEMPLATE_VARS, g as BIRTH_DECISION_DEFAULT_LIMIT, gn as describeSystemEventDeliveryProblem, gr as evaluateSensorEdge, gt as NcConditionsSchema, h as ArchivedDebugNoteSchema, hn as deriveRecordingMode, hr as canonicalHash, ht as NcConditionDescriptorSchema, in as TrackRelationSchema, ir as storageOccupancyCapability, it as NC_CONSUMABLE_LOW_PERCENT_DEFAULT, j as EMBEDDING_REQUIREMENTS_UNAVAILABLE, jn as isEventOwnerType, jr as _enum, jt as OPS_LOG_DEFAULT_LIMIT, k as EMBEDDING_MODEL_FAILED, kn as isAudioClassName, kr as sleep$1, kt as NcTemplatePreviewSchema, l as APPEARANCE_BIN_COUNT, ln as assertTimelapseCadences, lr as systemEventSummaryScheduleOf, lt as NC_SYSTEM_EVENT_KIND_GROUP_SPECS, m as AlarmArmModeSchema, mn as cosineSimilarity$1, mr as DEFAULT_FIRST_SIGHTING_FRESHNESS_MS, mt as NcAlarmSettingsPatchSchema, nn as TrackPuritySchema, nr as sensorRaisedOf, nt as NC_CLIP_RETENTION_MAX_SEC, o as FACE_EMBEDDING_STEP_ID, on as TrackSourceSchema, or as systemEventDeliveryProblems, ot as NC_DEFAULT_SNOOZE_MINUTES, p as AUDIO_MACRO_LABELS, pn as consumableLowPercentOf, pr as zoneAnalyticsCapability, pt as NcAlarmConfigSchema, q as MAX_TRACK_RELATIONS, qn as readMotionTriggeredBy, qt as SceneMonitorSchema, rn as TrackRelationKindSchema, rr as servedTemplateVars, rt as NC_CONDITION_CATALOG, s as readClusterModelPinStrict, sn as addonWidgetsSourceCapability, sr as systemEventFamilyProblems, st as NC_SENSOR_TRANSITION_KINDS, t as BUILTIN_CLIP_MODELS, tn as TimelapseRuleSchema, tr as sceneMonitorCapability, tt as NC_CLIP_PRE_ROLL_DEFAULT_SEC, u as APPEARANCE_BLOCKS, un as audioMetricsCapability, ur as templateCarriesSummary, ut as NC_TANK_SYSTEM_EVENT_KINDS, v as BirthDecisionRecordSchema, vr as DeviceType, vt as NcRulePatchSchema, w as DEFAULT_TIMELAPSE_PREVIEW_TEXT, wn as evictionPolicyOfLocation, wr as defineCustomActions, wt as NcSnoozeSchema, x as ClearTrackRelationResultSchema, xr as CamProfileSchema, xt as NcRuleUpdateInputSchema, y as COCO_TO_MACRO, yr as errMsg, yt as NcRuleSchema, z as LabelAttributionSchema, zn as mayWriteToLocation, zr as partialRecord, zt as SCENE_CONFIRM_DEFAULT_TIMEOUT_MS } from "../clip-model-registry-C9Aw4qkh.mjs";
1
+ import { $ as NC_AUDIO_CONFIRM_WINDOW_SEC_DEFAULT, $n as ruleDeviceCompatCapQueries, $t as TimelapseRuleInputSchema, A as EMBEDDING_REQUIREMENTS_FAILED, An as isDetectionMacroClass, Ar as sleep$1, At as NcTemplateVarDescriptorSchema, B as ListTrackRelationsInputSchema, Bn as memoizePinnedFfmpeg, Br as partialRecord, Bt as SCENE_DEFAULT_ANCHOR_THRESHOLD, C as DEFAULT_EVENT_COLOR, Cn as encodeVectorBase64, Cr as createEvent, Ct as NcSnoozeInputSchema, D as DeclaredDevices, Dn as gifDeriveSpeed, Dr as isDeviceScopedCap, Dt as NcTaxonomySchema, E as DETECTION_PIPELINE_CAP_NAME, En as failureContributionCapability, Er as hydrateSchema, Et as NcSystemEventKindSchema, F as EXPORT_DENSE_MAX_RANGES, Fn as isSystemDelivery, Fr as discriminatedUnion, Ft as RECORDING_SOURCE_CAMSTACK_ADDON, G as MAX_BIRTH_LATENCY_PROXY_MS, Gn as plateGalleryCapability, Gr as union, Gt as SCENE_DIVERGED, H as LockStateSchema, Hn as occupancyScope, Hr as strictObject, Ht as SCENE_DEFAULT_OBSERVATION_SPACING_SEC, I as FIRST_LEVEL_MACRO_CLASSES, In as isSystemEventSummaryRule, Ir as literal, It as RebuildObjectEmbeddingsRefusalSchema, J as MOTION_CLOSE_AFTER_MS, Jn as readTimelapseGeneratedAt, Jt as SetTrackPurityInputSchema, K as MAX_TRACK_PURITIES, Kn as readDeviceStateFrom, Kr as unknown, Kt as SCENE_RESET_RECAPTURES, L as FULL_IMAGE_BBOX, Ln as isVectorIndexDimConflict, Lr as looseObject, Lt as RetrainStatusSchema, M as EVENT_DENSITY_BATCH_MAX, Mn as isKnownTemplateVar, Mr as _enum, Mt as OpsLogEntrySchema, N as EVENT_KIND_BY_CAP, Nn as isMediaOwnerType, Nr as array, O as DetectionSourceSchema, On as gifFromMp4Args, Or as nodePin, Ot as NcTemplatePreviewInputSchema, P as EVENT_OWNER_TYPES, Pn as isScheduleActive, Pr as boolean, Pt as RECORDING_EXPORT_MAX_READ_BYTES, Q as NC_AUDIO_CONFIRM_HITS_DEFAULT, Qn as ruleBelongsToAlarm$1, Qt as TRACK_RELATIONS_DEFAULT_LIMIT, R as FailureCounters, Rn as kebabToCamel, Rr as number, Rt as SCENE_CONFIRM_DEFAULT_MAX_IMAGE_PX, S as CoverStateSchema, Sr as CamProfileSchema, St as NcScheduleSchema, T as DETECTION_MACRO_CLASSES, Tn as faceGalleryCapability, Tr as defineCustomActions, Tt as NcSnoozeSuppressedSchema, U as MAX_ARCHIVED_DEBUG_NOTES, Ur as string, Ut as SCENE_DEFAULT_QUIET_SECONDS, V as ListTrackRelationsResultSchema, Vn as notificationRulesCapability, Vr as record, Vt as SCENE_DEFAULT_CHECK_INTERVAL_SEC, W as MAX_BIRTH_DECISION_RECORDS, Wn as pipelineAnalyticsCapability, Wr as tuple, Wt as SCENE_DEFAULT_UNCOVERED_POLICY, X as MotionTriggeredBySchema, Xn as resolveLocationMode, Xt as SetTrackRelationInputSchema, Y as MediaFileKindEnum, Yn as resolveClipSearchMinScore, Yt as SetTrackPurityResultSchema, Z as NC_ALARM_SYSTEM_EVENT_KINDS, Zt as TIMELAPSE_DENSE_FLOOR_SEC, _ as BaseDevice, _n as describeSystemEventFamilyProblem, _r as evaluateSensorEdge, _t as NcRuleInputSchema, a as CLIP_EMBEDDING_STEP_ID, an as TrackSideSchema, ar as subKindsOf, at as NC_CONSUMABLE_SYSTEM_EVENT_KINDS, b as ClearTrackRelationInputSchema, bn as embeddingRefusalOf, br as errMsg, bt as NcRuleTargetSchema, c as resolveClusterModelPin, cn as alarmPanelCapability, cr as systemEventFilterApplies, ct as NC_SNOOZE_MAX_MINUTES, d as APPEARANCE_RESOLVES_AMBIGUITY_DEFAULT, dn as audioModeOf, dr as templateCarriesSummary, dt as NC_TAXONOMY, en as TimelapseRulePatchSchema, er as ruleKindOf, et as NC_CLIP_POST_ROLL_DEFAULT_SEC, f as ARCHIVED_DEBUG_NOTES_DEFAULT_LIMIT, fn as buildEventKindDescriptor, fr as templateVarsFor, ft as NC_TEMPLATE_VARS, g as BIRTH_DECISION_DEFAULT_LIMIT, gn as describeSystemEventDeliveryProblem, gr as canonicalHash, gt as NcConditionsSchema, h as ArchivedDebugNoteSchema, hn as deriveRecordingMode, hr as DEFAULT_FIRST_SIGHTING_FRESHNESS_MS, ht as NcConditionDescriptorSchema, in as TrackRelationSchema, ir as storageOccupancyCapability, it as NC_CONSUMABLE_LOW_PERCENT_DEFAULT, j as EMBEDDING_REQUIREMENTS_UNAVAILABLE, jn as isEventOwnerType, jr as ZodObject, jt as OPS_LOG_DEFAULT_LIMIT, k as EMBEDDING_MODEL_FAILED, kn as isAudioClassName, kt as NcTemplatePreviewSchema, l as APPEARANCE_BIN_COUNT, ln as assertTimelapseCadences, lr as systemEventSummaryScheduleOf, lt as NC_SYSTEM_EVENT_KIND_GROUP_SPECS, m as AlarmArmModeSchema, mn as cosineSimilarity$1, mr as zoneAnalyticsCapability, mt as NcAlarmSettingsPatchSchema, nn as TrackPuritySchema, nr as sensorRaisedOf, nt as NC_CLIP_RETENTION_MAX_SEC, o as FACE_EMBEDDING_STEP_ID, on as TrackSourceSchema, or as systemEventDeliveryProblems, ot as NC_DEFAULT_SNOOZE_MINUTES, p as AUDIO_MACRO_LABELS, pn as consumableLowPercentOf, pr as vectorDimFromBase64, pt as NcAlarmConfigSchema, q as MAX_TRACK_RELATIONS, qn as readMotionTriggeredBy, qr as EventCategory, qt as SceneMonitorSchema, rn as TrackRelationKindSchema, rr as servedTemplateVars, rt as NC_CONDITION_CATALOG, s as readClusterModelPinStrict, sn as addonWidgetsSourceCapability, sr as systemEventFamilyProblems, st as NC_SENSOR_TRANSITION_KINDS, t as BUILTIN_CLIP_MODELS, tn as TimelapseRuleSchema, tr as sceneMonitorCapability, tt as NC_CLIP_PRE_ROLL_DEFAULT_SEC, u as APPEARANCE_BLOCKS, un as audioMetricsCapability, ur as systemEventSummarySplitOf, ut as NC_TANK_SYSTEM_EVENT_KINDS, v as BirthDecisionRecordSchema, vr as isSourceCap, vt as NcRulePatchSchema, w as DEFAULT_TIMELAPSE_PREVIEW_TEXT, wn as evictionPolicyOfLocation, wr as customAction, wt as NcSnoozeSchema, x as ClearTrackRelationResultSchema, xr as BaseAddon, xt as NcRuleUpdateInputSchema, y as COCO_TO_MACRO, yr as DeviceType, yt as NcRuleSchema, z as LabelAttributionSchema, zn as mayWriteToLocation, zr as object, zt as SCENE_CONFIRM_DEFAULT_TIMEOUT_MS } from "../clip-model-registry-CrBn6Qvt.mjs";
2
2
  import { t as __exportAll } from "../embedding-encoder/index.mjs";
3
3
  import * as fs$1 from "node:fs";
4
4
  import { createWriteStream, promises } from "node:fs";
@@ -13484,6 +13484,8 @@ var en_default = {
13484
13484
  "summary.body.empty": "nothing was seen in this window",
13485
13485
  "system.state-summary.title": "{{rule}}",
13486
13486
  "system.state-summary.body": "{{summaryText}}",
13487
+ "system.state-summary.section.title": "{{rule}} — {{summarySection}}",
13488
+ "system.state-summary.section.body": "{{summaryText}}",
13487
13489
  "summary.section.device-state": "Devices offline or switched off",
13488
13490
  "summary.section.streams": "Streams offline",
13489
13491
  "summary.section.battery": "Low batteries",
@@ -13812,6 +13814,8 @@ var it_default = {
13812
13814
  "summary.body.empty": "nessun evento in questa finestra",
13813
13815
  "system.state-summary.title": "{{rule}}",
13814
13816
  "system.state-summary.body": "{{summaryText}}",
13817
+ "system.state-summary.section.title": "{{rule}} — {{summarySection}}",
13818
+ "system.state-summary.section.body": "{{summaryText}}",
13815
13819
  "summary.section.device-state": "Dispositivi offline o spenti",
13816
13820
  "summary.section.streams": "Stream offline",
13817
13821
  "summary.section.battery": "Batterie scariche",
@@ -15757,6 +15761,38 @@ function planRuleTest(input) {
15757
15761
  };
15758
15762
  }
15759
15763
  //#endregion
15764
+ //#region src/notification-center/state-summary/state-summary-test.ts
15765
+ /**
15766
+ * What TESTING a state-summary rule answers (D698, `nc.testRule`).
15767
+ *
15768
+ * A summary rule sends nothing per event (D647), so the per-event test the
15769
+ * other rules get — one synthetic event, injected — can never deliver for it.
15770
+ * Its test builds the summary NOW and enqueues it; this is the report: the
15771
+ * rows it enqueued (one, or one per section) and every section's read, so
15772
+ * "nothing to report" is an answer with its reason per section, never a
15773
+ * silence.
15774
+ */
15775
+ var NcStateSummaryTestSchema = object({
15776
+ /** The test's record id — a per-section row appends `:<sectionId>`. */
15777
+ recordId: string(),
15778
+ split: _enum(["rule", "section"]),
15779
+ /** Nothing open and every section read: nothing was enqueued. */
15780
+ nothingToReport: boolean(),
15781
+ sections: array(object({
15782
+ id: string(),
15783
+ status: _enum(["read", "unavailable"]),
15784
+ items: number().int().nonnegative(),
15785
+ reason: string().optional()
15786
+ })),
15787
+ rows: array(object({
15788
+ recordId: string(),
15789
+ /** The section of a per-section row. */
15790
+ section: string().optional(),
15791
+ enqueued: number().int().nonnegative(),
15792
+ targetIds: array(string())
15793
+ }))
15794
+ });
15795
+ //#endregion
15760
15796
  //#region src/notification-center/clock-text.ts
15761
15797
  /**
15762
15798
  * `{{time}}` — hours and minutes in the NOTIFICATION language.
@@ -17109,13 +17145,18 @@ var ncActions = defineCustomActions({
17109
17145
  deviceId: number().int().optional()
17110
17146
  }), NcTestEventResultSchema.extend({
17111
17147
  /** The subject kind the rule was tested with — the answer to "why was my
17112
- * rule not a candidate". */
17148
+ * rule not a candidate". `state-summary` for a summary rule (D698). */
17113
17149
  trigger: string(),
17114
17150
  /** What the plan could not reproduce (a device-state gate, a polygon). */
17115
17151
  planNotes: array(object({
17116
17152
  conditionId: string(),
17117
17153
  note: string()
17118
- }))
17154
+ })),
17155
+ /**
17156
+ * A STATE-SUMMARY rule's test (D698): the summary built now and the rows
17157
+ * it enqueued, or why nothing was — absent on every other rule.
17158
+ */
17159
+ stateSummary: NcStateSummaryTestSchema.optional()
17119
17160
  }), {
17120
17161
  kind: "mutation",
17121
17162
  auth: "admin",
@@ -17474,6 +17515,48 @@ function makeNcActionHandlers(deps) {
17474
17515
  if (rule.ownerUserId !== void 0 && rule.ownerUserId !== caller.userId) throw new Error(`forbidden: rule not owned: ${ruleId}`);
17475
17516
  return rule;
17476
17517
  };
17518
+ /**
17519
+ * TEST a STATE-SUMMARY rule (D698). The per-event injection cannot deliver
17520
+ * for it — a summary rule sends nothing per event (D647), and that test used
17521
+ * to be dropped without a word — so the summary is built NOW and enqueued.
17522
+ * The answer is shaped like every rule test's, plus the summary's own report.
17523
+ */
17524
+ const testSummaryRule = async (rule, by) => {
17525
+ const test = deps.testStateSummary;
17526
+ if (test === void 0) throw new Error("this node does not host the notification centre — test on the hub");
17527
+ deps.logger.info("rule test requested", { meta: {
17528
+ by,
17529
+ ruleId: rule.id,
17530
+ ruleName: rule.name,
17531
+ trigger: "state-summary",
17532
+ bypassMute: true
17533
+ } });
17534
+ const report = await test(rule);
17535
+ const enqueued = report.rows.reduce((sum, row) => sum + row.enqueued, 0);
17536
+ const notes = ["per-camera notification mute bypassed"];
17537
+ 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`);
17538
+ else if (enqueued === 0) notes.push("the summary was built but no row was enqueued — the rule reaches no target");
17539
+ return {
17540
+ recordId: report.recordId,
17541
+ origin: "synthetic",
17542
+ delivery: rule.delivery,
17543
+ candidates: 1,
17544
+ outcomes: [{
17545
+ ruleId: rule.id,
17546
+ ruleName: rule.name,
17547
+ matched: !report.nothingToReport,
17548
+ cooldown: "bypassed",
17549
+ enqueued,
17550
+ targetIds: [...new Set(report.rows.flatMap((row) => row.targetIds))]
17551
+ }],
17552
+ still: "unattachable",
17553
+ notes,
17554
+ ...report.nothingToReport ? { droppedBy: "nothing-to-report" } : {},
17555
+ trigger: "state-summary",
17556
+ planNotes: [],
17557
+ stateSummary: report
17558
+ };
17559
+ };
17477
17560
  /** The producer half, or an honest refusal. */
17478
17561
  const requireTimelapse = () => {
17479
17562
  const tl = deps.timelapse;
@@ -17808,6 +17891,7 @@ function makeNcActionHandlers(deps) {
17808
17891
  const inject = deps.injectTestEvent;
17809
17892
  if (inject === void 0) throw new Error("this node does not host the notification centre — test on the hub");
17810
17893
  const rule = assertRuleVisible(input.ruleId, c);
17894
+ if (isSystemEventSummaryRule(rule)) return testSummaryRule(rule, c.userId);
17811
17895
  const plan = planRuleTest({
17812
17896
  rule,
17813
17897
  now: Date.now(),
@@ -19642,6 +19726,77 @@ var NcDeviceStateCache = class {
19642
19726
  }
19643
19727
  };
19644
19728
  //#endregion
19729
+ //#region src/notification-center/state-summary/state-model.ts
19730
+ /**
19731
+ * The STATE SUMMARY's vocabulary (D647): its sections, and what one reported
19732
+ * item is.
19733
+ *
19734
+ * A section is one of the D637 kind GROUPS (`NC_SYSTEM_EVENT_KIND_GROUP_SPECS`)
19735
+ * — the same grouping both editors list kinds under — so "the rule selected a
19736
+ * battery kind" and "the summary has a battery section" are the same fact.
19737
+ * `backups-exports` is the one group with no section: a backup or an export is
19738
+ * a job that COMPLETED, and the state it leaves behind is not a condition that
19739
+ * is still true. The ADR records why it is excluded rather than approximated.
19740
+ */
19741
+ var NC_STATE_SUMMARY_SECTION_IDS = [
19742
+ "device-state",
19743
+ "streams",
19744
+ "battery",
19745
+ "consumables",
19746
+ "tanks",
19747
+ "detection-blind",
19748
+ "nodes",
19749
+ "addons",
19750
+ "updates"
19751
+ ];
19752
+ /** The kind groups that have no section, and why — said on the preview. */
19753
+ var NC_STATE_SUMMARY_EXCLUDED_GROUPS = { "backups-exports": "a backup or an export is a completed job, not a state that is still true" };
19754
+ function isStateSectionId(value) {
19755
+ return NC_STATE_SUMMARY_SECTION_IDS.some((id) => id === value);
19756
+ }
19757
+ /** The template variable that carries one section's lines (`template-vars.ts`). */
19758
+ var NC_STATE_SECTION_VAR = {
19759
+ "device-state": "summaryDevices",
19760
+ streams: "summaryStreams",
19761
+ battery: "summaryBattery",
19762
+ consumables: "summaryConsumables",
19763
+ tanks: "summaryTanks",
19764
+ "detection-blind": "summaryDetection",
19765
+ nodes: "summaryNodes",
19766
+ addons: "summaryAddons",
19767
+ updates: "summaryUpdates"
19768
+ };
19769
+ /** The kinds of a section — its kind group. */
19770
+ function sectionKinds(section) {
19771
+ return NC_SYSTEM_EVENT_KIND_GROUP_SPECS.find((group) => group.id === section)?.kinds ?? [];
19772
+ }
19773
+ /**
19774
+ * The sections a rule reports, in section order: every section whose kind
19775
+ * group shares a kind with the rule. A rule that selected only
19776
+ * `device-battery-low` still reports the battery STATE — the state has no
19777
+ * direction, only the event did.
19778
+ */
19779
+ function sectionsOfRule(condition) {
19780
+ return NC_STATE_SUMMARY_SECTION_IDS.filter((section) => sectionKinds(section).some((kind) => condition.kinds.includes(kind)));
19781
+ }
19782
+ /** The kind groups the rule selected that have no section. */
19783
+ function excludedGroupsOfRule(condition) {
19784
+ return NC_SYSTEM_EVENT_KIND_GROUP_SPECS.filter((group) => NC_STATE_SUMMARY_EXCLUDED_GROUPS[group.id] !== void 0 && group.kinds.some((kind) => condition.kinds.includes(kind))).map((group) => group.id);
19785
+ }
19786
+ /**
19787
+ * The condition the summary judges its items with: the rule's own, with its
19788
+ * kinds widened to every kind of every section it reports — so a rule that
19789
+ * named the raise is not told "not my kind" about the state the raise left
19790
+ * behind. Every FILTER is the rule's, untouched.
19791
+ */
19792
+ function summaryCondition(condition) {
19793
+ const kinds = sectionsOfRule(condition).flatMap((section) => sectionKinds(section));
19794
+ return {
19795
+ ...condition,
19796
+ kinds: [...new Set([...condition.kinds, ...kinds])]
19797
+ };
19798
+ }
19799
+ //#endregion
19645
19800
  //#region src/notification-center/state-summary/state-summary-delivery.ts
19646
19801
  /**
19647
19802
  * A state summary as an ORDINARY system-event subject (D647), so it reaches the
@@ -19650,7 +19805,9 @@ var NcDeviceStateCache = class {
19650
19805
  * which renders the rule's own template with the summary's variables.
19651
19806
  *
19652
19807
  * - `recordId` is `state-summary:<ruleId>:<windowEndMs>` — the outbox dedup
19653
- * key, so a restart can never enqueue one window twice.
19808
+ * key, so a restart can never enqueue one window twice. Split per SECTION
19809
+ * (D698), each row is `state-summary:<ruleId>:<windowEndMs>:<sectionId>`:
19810
+ * the same guarantee, per section.
19654
19811
  * - The row files under the system device (0): a summary spans devices.
19655
19812
  * `targetUsers` therefore reach admins only — a user whose grant is
19656
19813
  * narrower than the whole installation must not be sent the names of
@@ -19664,20 +19821,37 @@ var NcDeviceStateCache = class {
19664
19821
  */
19665
19822
  var NC_STATE_SUMMARY_TITLE_KEY = "system.state-summary.title";
19666
19823
  var NC_STATE_SUMMARY_BODY_KEY = "system.state-summary.body";
19824
+ /** A per-section row's title and body (D698): the rule's name and the section's. */
19825
+ var NC_STATE_SUMMARY_SECTION_TITLE_KEY = "system.state-summary.section.title";
19826
+ var NC_STATE_SUMMARY_SECTION_BODY_KEY = "system.state-summary.section.body";
19667
19827
  function stateSummaryRecordId(ruleId, windowEndMs) {
19668
19828
  return `state-summary:${ruleId}:${String(windowEndMs)}`;
19669
19829
  }
19830
+ /**
19831
+ * The record id of a TEST of the rule (D698, `nc.testRule`): its own prefix,
19832
+ * so a test can never take — or be swallowed by — a real window's dedup key.
19833
+ */
19834
+ function stateSummaryTestRecordId(ruleId, nowMs) {
19835
+ return `state-summary-test:${ruleId}:${String(nowMs)}`;
19836
+ }
19837
+ /** One section's row: the record id of the whole, plus `:<sectionId>` — the per-section dedup key. */
19838
+ function sectionRecordId(base, sectionId) {
19839
+ return `${base}:${sectionId}`;
19840
+ }
19670
19841
  /** Is this system-event data the data of a state-summary row? */
19671
19842
  function isStateSummaryData(data) {
19672
19843
  return data["stateSummary"] === true;
19673
19844
  }
19845
+ function recordBaseOf(input) {
19846
+ return input.recordBase ?? stateSummaryRecordId(input.rule.id, input.window.endMs);
19847
+ }
19674
19848
  /** `null` when the rule has no system-event condition (the guard refuses that). */
19675
19849
  function stateSummarySubject(input) {
19676
19850
  const kind = input.rule.conditions.systemEvent?.kinds[0];
19677
19851
  if (kind === void 0) return null;
19678
19852
  return {
19679
19853
  kind: "system-event",
19680
- recordId: stateSummaryRecordId(input.rule.id, input.window.endMs),
19854
+ recordId: recordBaseOf(input),
19681
19855
  deviceId: NC_SYSTEM_DEVICE_ID$1,
19682
19856
  timestamp: input.window.endMs,
19683
19857
  classNames: [],
@@ -19690,6 +19864,7 @@ function stateSummarySubject(input) {
19690
19864
  textParams: {
19691
19865
  summaryText: input.text.text,
19692
19866
  summaryCount: String(input.text.count),
19867
+ summarySection: input.text.sections.map((section) => section.title).join(", "),
19693
19868
  ...input.text.sectionVars
19694
19869
  },
19695
19870
  textCount: input.text.count,
@@ -19707,13 +19882,82 @@ function stateSummarySubject(input) {
19707
19882
  };
19708
19883
  }
19709
19884
  /**
19885
+ * The rows one summary window sends. `rule`: the one D647 row. `section`: one
19886
+ * row per section with something to say — its items, or the sentence that it
19887
+ * is UNAVAILABLE, exactly as the combined text words it. A section with
19888
+ * nothing open sends nothing; neither does a summary with nothing at all.
19889
+ */
19890
+ function stateSummarySubjects(input) {
19891
+ if (input.split === "rule") {
19892
+ const subject = stateSummarySubject(input);
19893
+ return subject === null ? [] : [subject];
19894
+ }
19895
+ const kind = input.rule.conditions.systemEvent?.kinds[0];
19896
+ if (kind === void 0) return [];
19897
+ return input.text.sections.map((section) => sectionSubject(input, kind, section));
19898
+ }
19899
+ function sectionSubject(input, kind, section) {
19900
+ const sectionVars = {};
19901
+ for (const name of Object.keys(input.text.sectionVars)) sectionVars[name] = "";
19902
+ sectionVars[NC_STATE_SECTION_VAR[section.id]] = section.text;
19903
+ return {
19904
+ kind: "system-event",
19905
+ recordId: sectionRecordId(recordBaseOf(input), section.id),
19906
+ deviceId: NC_SYSTEM_DEVICE_ID$1,
19907
+ timestamp: input.window.endMs,
19908
+ classNames: [],
19909
+ zones: [],
19910
+ systemEvent: {
19911
+ kind,
19912
+ subject: `state-summary:${input.rule.id}:${section.id}`,
19913
+ titleKey: NC_STATE_SUMMARY_SECTION_TITLE_KEY,
19914
+ bodyKey: NC_STATE_SUMMARY_SECTION_BODY_KEY,
19915
+ textParams: {
19916
+ summaryText: section.body,
19917
+ summaryCount: String(section.count),
19918
+ summarySection: section.title,
19919
+ ...sectionVars
19920
+ },
19921
+ textCount: section.count,
19922
+ data: {
19923
+ stateSummary: true,
19924
+ section: section.id,
19925
+ windowStartMs: input.window.startMs,
19926
+ windowEndMs: input.window.endMs,
19927
+ sections: [{
19928
+ id: section.id,
19929
+ status: section.status,
19930
+ items: section.count
19931
+ }]
19932
+ }
19933
+ }
19934
+ };
19935
+ }
19936
+ /** The section a per-section row is about (`data.section`), or `undefined` on a combined row. */
19937
+ function stateSummarySectionOf(subject) {
19938
+ const section = subject.systemEvent?.data["section"];
19939
+ return typeof section === "string" && isStateSectionId(section) ? section : void 0;
19940
+ }
19941
+ /** Does a body name `{{summaryText}}` or this section's own variable? */
19942
+ function bodyCarriesSection(body, section) {
19943
+ return new RegExp(`\\{\\{\\s*(?:summaryText|${NC_STATE_SECTION_VAR[section]})\\s*\\}\\}`).test(body);
19944
+ }
19945
+ /**
19710
19946
  * The rule as its summary is ENQUEUED: a custom body that carries none of the
19711
19947
  * summary's content gets `{{summaryText}}` appended on its own line, so an
19712
19948
  * operator's body never sends an empty summary (review M3). `null` = unchanged.
19949
+ *
19950
+ * On a per-SECTION row (D698 review M3) the content is THIS section's: a body
19951
+ * naming only `{{summaryBattery}}` carries the battery row and nothing on the
19952
+ * streams row, so every other section's row gets `{{summaryText}}` — its own
19953
+ * lines — appended.
19713
19954
  */
19714
- function withSummaryBody(rule) {
19955
+ function withSummaryBody(rule, section) {
19715
19956
  const body = rule.template?.body;
19716
- if (templateCarriesSummary(body)) return null;
19957
+ if (templateCarriesSummary(body)) {
19958
+ if (section === void 0 || body === void 0 || body.trim().length === 0) return null;
19959
+ if (bodyCarriesSection(body, section)) return null;
19960
+ }
19717
19961
  return {
19718
19962
  ...rule,
19719
19963
  template: {
@@ -23762,6 +24006,120 @@ function withTimeout$3(promise, ms, what) {
23762
24006
  });
23763
24007
  }
23764
24008
  //#endregion
24009
+ //#region src/notification-center/boot-window-rows.ts
24010
+ var BootWindowRows = class {
24011
+ deps;
24012
+ window = /* @__PURE__ */ new Map();
24013
+ /** Deferred closes, keyed so a repeat (one per detection) replaces rather than grows. */
24014
+ deferred = /* @__PURE__ */ new Map();
24015
+ settled = false;
24016
+ constructor(deps) {
24017
+ this.deps = deps;
24018
+ }
24019
+ /** Is the ledger still inside its boot window (not settled after an answered load)? */
24020
+ open() {
24021
+ return !this.settled;
24022
+ }
24023
+ /** Stage a row that would be CREATED inside the window — mirror only, never persisted. */
24024
+ stageProvisional(row) {
24025
+ this.deps.ledger.stage(row);
24026
+ this.window.set(this.deps.keyOf(row), {
24027
+ kind: "provisional",
24028
+ row
24029
+ });
24030
+ }
24031
+ /** A row the ledger persisted inside the window: the load must not regress it. */
24032
+ noteWritten(row) {
24033
+ this.window.set(this.deps.keyOf(row), {
24034
+ kind: "written",
24035
+ row
24036
+ });
24037
+ }
24038
+ /** A key the ledger removed inside the window: the load must not resurrect it. */
24039
+ noteRemoved(key) {
24040
+ this.window.set(key, { kind: "removed" });
24041
+ }
24042
+ /**
24043
+ * While the window is open, keep `replay` to run against the LOADED rows at
24044
+ * the settle; once settled, nothing — the caller's own pass already saw every
24045
+ * row (review LOW-5: no allocation per detection after boot). `key` names the
24046
+ * close: the same key deferred twice runs once, at the place it was first
24047
+ * deferred. `replay` answers how many rows it closed.
24048
+ */
24049
+ deferWhileOpen(key, replay) {
24050
+ if (this.open()) this.deferred.set(key, replay);
24051
+ }
24052
+ /**
24053
+ * Close the window — call right after the ledger's `load`. A load that did
24054
+ * not answer leaves the window open and returns `null`.
24055
+ */
24056
+ settle() {
24057
+ if (this.settled) return null;
24058
+ if (!this.deps.ledger.hasAnswered()) {
24059
+ this.deps.logger.warn("boot window NOT settled — the load did not answer", { meta: {
24060
+ collection: this.deps.ledger.collection,
24061
+ pendingCloses: this.deferred.size,
24062
+ provisionalRows: this.window.size
24063
+ } });
24064
+ return null;
24065
+ }
24066
+ this.settled = true;
24067
+ const ledger = this.deps.ledger;
24068
+ let replayed = 0;
24069
+ for (const replay of this.deferred.values()) replayed += replay();
24070
+ this.deferred.clear();
24071
+ let keptStored = 0;
24072
+ let written = 0;
24073
+ for (const [key, entry] of this.window) {
24074
+ if (entry.kind === "removed") {
24075
+ ledger.forget(key);
24076
+ continue;
24077
+ }
24078
+ if (entry.kind === "written") {
24079
+ ledger.stage(entry.row);
24080
+ continue;
24081
+ }
24082
+ const mirrored = ledger.get(key);
24083
+ const stored = mirrored === entry.row ? void 0 : mirrored;
24084
+ const held = this.deps.merge(stored, entry.row);
24085
+ const deviceId = this.deps.deviceIdOf(entry.row);
24086
+ const tags = deviceId !== void 0 ? { tags: { deviceId } } : {};
24087
+ if (stored !== void 0 && held === stored) {
24088
+ keptStored += 1;
24089
+ this.deps.logger.debug("boot-window row kept as stored — the restart learned nothing new", {
24090
+ ...tags,
24091
+ meta: {
24092
+ collection: ledger.collection,
24093
+ key
24094
+ }
24095
+ });
24096
+ continue;
24097
+ }
24098
+ ledger.put(held);
24099
+ written += 1;
24100
+ this.deps.logger.debug("boot-window row written at load", {
24101
+ ...tags,
24102
+ meta: {
24103
+ collection: ledger.collection,
24104
+ key,
24105
+ stored: stored !== void 0
24106
+ }
24107
+ });
24108
+ }
24109
+ this.window.clear();
24110
+ const merge = {
24111
+ keptStored,
24112
+ written,
24113
+ replayed
24114
+ };
24115
+ this.deps.logger.info("boot window settled", { meta: {
24116
+ collection: ledger.collection,
24117
+ ...merge
24118
+ } });
24119
+ return merge;
24120
+ }
24121
+ };
24122
+ //#endregion
23765
24123
  //#region src/notification-center/liveness-ledger.ts
23766
24124
  /**
23767
24125
  * @durable class=ledger owner=notification-center
@@ -23872,12 +24230,26 @@ var NC_LIVENESS_SPEC = {
23872
24230
  };
23873
24231
  var NcLivenessLedger = class {
23874
24232
  ledger;
24233
+ /**
24234
+ * What was observed before the boot load answered (D698) — see
24235
+ * `boot-window-rows.ts`. A stored row holding the same STATE keeps its own
24236
+ * `updatedAt`: a restart that re-hears "offline" learned nothing about WHEN
24237
+ * the device went offline, and `updatedAt` is the state summary's "since".
24238
+ */
24239
+ bootWindow;
23875
24240
  constructor(deps) {
23876
24241
  this.ledger = new DurableLedger({
23877
24242
  spec: NC_LIVENESS_SPEC,
23878
24243
  store: deps.store,
23879
24244
  logger: deps.logger
23880
24245
  });
24246
+ this.bootWindow = new BootWindowRows({
24247
+ ledger: this.ledger,
24248
+ keyOf: (row) => row.subject,
24249
+ deviceIdOf: (row) => row.deviceId,
24250
+ logger: deps.logger,
24251
+ merge: (stored, provisional) => stored !== void 0 && stored.state === provisional.state ? stored : provisional
24252
+ });
23881
24253
  }
23882
24254
  static declare(store) {
23883
24255
  return DurableLedger.declare(store, NC_LIVENESS_SPEC);
@@ -23892,7 +24264,9 @@ var NcLivenessLedger = class {
23892
24264
  * flood, so no error path may do it on purpose.
23893
24265
  */
23894
24266
  async load() {
23895
- return (await this.ledger.load()).length;
24267
+ const loaded = (await this.ledger.load()).length;
24268
+ this.bootWindow.settle();
24269
+ return loaded;
23896
24270
  }
23897
24271
  /** Has a load READ the store once? Until then the mirror is not the truth (D647 reads it). */
23898
24272
  hasAnswered() {
@@ -23985,13 +24359,21 @@ var NcLivenessLedger = class {
23985
24359
  * spoke". That is the timestamp anyone reading the table wants.
23986
24360
  */
23987
24361
  observe(observation, now) {
23988
- return this.ledger.observe({
24362
+ const row = {
23989
24363
  subject: observation.subject,
23990
24364
  state: observation.state,
23991
24365
  kind: observation.kind,
23992
24366
  ...observation.deviceId !== void 0 ? { deviceId: observation.deviceId } : {},
23993
24367
  updatedAt: now
23994
- });
24368
+ };
24369
+ if (!this.bootWindow.open()) return this.ledger.observe(row);
24370
+ if (this.ledger.get(row.subject) === void 0) {
24371
+ this.bootWindow.stageProvisional(row);
24372
+ return "seeded";
24373
+ }
24374
+ const verdict = this.ledger.observe(row);
24375
+ if (verdict === "flip") this.bootWindow.noteWritten(row);
24376
+ return verdict;
23995
24377
  }
23996
24378
  };
23997
24379
  /** The persisted column map (the `subject` PK is passed separately). */
@@ -30526,74 +30908,6 @@ function stateSources(ports) {
30526
30908
  return { read: (section, nowMs) => readSection(section, ports, nowMs) };
30527
30909
  }
30528
30910
  //#endregion
30529
- //#region src/notification-center/state-summary/state-model.ts
30530
- /**
30531
- * The STATE SUMMARY's vocabulary (D647): its sections, and what one reported
30532
- * item is.
30533
- *
30534
- * A section is one of the D637 kind GROUPS (`NC_SYSTEM_EVENT_KIND_GROUP_SPECS`)
30535
- * — the same grouping both editors list kinds under — so "the rule selected a
30536
- * battery kind" and "the summary has a battery section" are the same fact.
30537
- * `backups-exports` is the one group with no section: a backup or an export is
30538
- * a job that COMPLETED, and the state it leaves behind is not a condition that
30539
- * is still true. The ADR records why it is excluded rather than approximated.
30540
- */
30541
- var NC_STATE_SUMMARY_SECTION_IDS = [
30542
- "device-state",
30543
- "streams",
30544
- "battery",
30545
- "consumables",
30546
- "tanks",
30547
- "detection-blind",
30548
- "nodes",
30549
- "addons",
30550
- "updates"
30551
- ];
30552
- /** The kind groups that have no section, and why — said on the preview. */
30553
- var NC_STATE_SUMMARY_EXCLUDED_GROUPS = { "backups-exports": "a backup or an export is a completed job, not a state that is still true" };
30554
- /** The template variable that carries one section's lines (`template-vars.ts`). */
30555
- var NC_STATE_SECTION_VAR = {
30556
- "device-state": "summaryDevices",
30557
- streams: "summaryStreams",
30558
- battery: "summaryBattery",
30559
- consumables: "summaryConsumables",
30560
- tanks: "summaryTanks",
30561
- "detection-blind": "summaryDetection",
30562
- nodes: "summaryNodes",
30563
- addons: "summaryAddons",
30564
- updates: "summaryUpdates"
30565
- };
30566
- /** The kinds of a section — its kind group. */
30567
- function sectionKinds(section) {
30568
- return NC_SYSTEM_EVENT_KIND_GROUP_SPECS.find((group) => group.id === section)?.kinds ?? [];
30569
- }
30570
- /**
30571
- * The sections a rule reports, in section order: every section whose kind
30572
- * group shares a kind with the rule. A rule that selected only
30573
- * `device-battery-low` still reports the battery STATE — the state has no
30574
- * direction, only the event did.
30575
- */
30576
- function sectionsOfRule(condition) {
30577
- return NC_STATE_SUMMARY_SECTION_IDS.filter((section) => sectionKinds(section).some((kind) => condition.kinds.includes(kind)));
30578
- }
30579
- /** The kind groups the rule selected that have no section. */
30580
- function excludedGroupsOfRule(condition) {
30581
- return NC_SYSTEM_EVENT_KIND_GROUP_SPECS.filter((group) => NC_STATE_SUMMARY_EXCLUDED_GROUPS[group.id] !== void 0 && group.kinds.some((kind) => condition.kinds.includes(kind))).map((group) => group.id);
30582
- }
30583
- /**
30584
- * The condition the summary judges its items with: the rule's own, with its
30585
- * kinds widened to every kind of every section it reports — so a rule that
30586
- * named the raise is not told "not my kind" about the state the raise left
30587
- * behind. Every FILTER is the rule's, untouched.
30588
- */
30589
- function summaryCondition(condition) {
30590
- const kinds = sectionsOfRule(condition).flatMap((section) => sectionKinds(section));
30591
- return {
30592
- ...condition,
30593
- kinds: [...new Set([...condition.kinds, ...kinds])]
30594
- };
30595
- }
30596
- //#endregion
30597
30911
  //#region src/notification-center/state-summary/state-summary.ts
30598
30912
  /** Nothing to say: no item, and every section READ. Such a summary is not sent. */
30599
30913
  function isEmptyStateSummary(summary) {
@@ -30694,28 +31008,50 @@ function lineOf(input, item) {
30694
31008
  }
30695
31009
  });
30696
31010
  }
30697
- function blockOf(input, section) {
30698
- const title = input.texts.text({ key: `summary.section.${section.id}` });
30699
- if (section.status === "unavailable") return input.texts.text({
30700
- key: "summary.section.unavailable",
30701
- vars: { section: title }
30702
- });
31011
+ function sectionTitle(input, section) {
31012
+ return input.texts.text({ key: `summary.section.${section.id}` });
31013
+ }
31014
+ /** A section's words: its block, and the body a per-section row sends. `null` = nothing to say. */
31015
+ function wordsOf(input, section) {
31016
+ const title = sectionTitle(input, section);
31017
+ if (section.status === "unavailable") {
31018
+ const sentence = input.texts.text({
31019
+ key: "summary.section.unavailable",
31020
+ vars: { section: title }
31021
+ });
31022
+ return {
31023
+ block: sentence,
31024
+ body: sentence
31025
+ };
31026
+ }
30703
31027
  if (section.items.length === 0) return null;
30704
- const lines = section.items.map((item) => `• ${lineOf(input, item)}`);
30705
- return [`${title}:`, ...lines].join("\n");
31028
+ const lines = section.items.map((item) => `• ${lineOf(input, item)}`).join("\n");
31029
+ return {
31030
+ block: `${title}:\n${lines}`,
31031
+ body: lines
31032
+ };
30706
31033
  }
30707
31034
  function stateSummaryText(input) {
30708
31035
  const sectionVars = {};
30709
- const blocks = [];
31036
+ const sections = [];
30710
31037
  for (const section of input.summary.sections) {
30711
- const block = blockOf(input, section);
30712
- sectionVars[NC_STATE_SECTION_VAR[section.id]] = block ?? "";
30713
- if (block !== null) blocks.push(block);
31038
+ const words = wordsOf(input, section);
31039
+ sectionVars[NC_STATE_SECTION_VAR[section.id]] = words?.block ?? "";
31040
+ if (words === null) continue;
31041
+ sections.push({
31042
+ id: section.id,
31043
+ title: sectionTitle(input, section),
31044
+ text: words.block,
31045
+ body: words.body,
31046
+ count: section.items.length,
31047
+ status: section.status
31048
+ });
30714
31049
  }
30715
31050
  return {
30716
- text: blocks.join("\n"),
31051
+ text: sections.map((section) => section.text).join("\n"),
30717
31052
  count: input.summary.itemCount,
30718
- sectionVars
31053
+ sectionVars,
31054
+ sections
30719
31055
  };
30720
31056
  }
30721
31057
  //#endregion
@@ -31152,6 +31488,40 @@ var NcStateSummaryStampStore = class {
31152
31488
  //#endregion
31153
31489
  //#region src/notification-center/state-summary/system-state-ledger.ts
31154
31490
  /**
31491
+ * NcSystemStateLedger — what is STILL TRUE among the event-driven system facts
31492
+ * no other ledger holds (D647).
31493
+ *
31494
+ * Battery, consumable and tank episodes already have a durable row
31495
+ * (`alert-store.ts`), and device/node liveness has its own
31496
+ * (`liveness-ledger.ts`). The rest of the summarisable kinds are EVENTS the
31497
+ * centre hears once — a stream going offline, a device switched off, a camera
31498
+ * detecting nothing, an update becoming available — and nothing remembered
31499
+ * that they were still true. A state summary is a claim about now, so this
31500
+ * ledger keeps one row per OPEN state, fed by the same intake the instant
31501
+ * rules read, and removes it when the closing event arrives:
31502
+ *
31503
+ * | opens | closes |
31504
+ * | --- | --- |
31505
+ * | `stream-offline` | `stream-online` (same subject) |
31506
+ * | `device-disabled` | `device-enabled` (same subject) |
31507
+ * | `detection-blind` | a DETECTION on that camera — the only evidence of recovery there is |
31508
+ * | `node-inference-unavailable`, `addon-crash-loop` | the node's next liveness edge, or a server update on it |
31509
+ * | `addon-update-available` (per package, per node) | `addon.updated` for that package on that node |
31510
+ * | `server-` / `wrapper-update-available` | `server-updated` on that node |
31511
+ *
31512
+ * Two kinds have no recovery event at all (`node-inference-unavailable`,
31513
+ * `addon-crash-loop`): the orchestrator drops such a node until it comes back,
31514
+ * and a crash-looped runner stays down until something respawns it — a node
31515
+ * restart or an update. The closing edges above are those respawns. A newer
31516
+ * `update-available` for the same package REPLACES the row.
31517
+ *
31518
+ * Durable (write-behind), so a restart does not forget an outage it heard
31519
+ * about. It is fed only on the evaluating node, like the liveness ledger. The
31520
+ * summary reads the RAM mirror only — never SQLite on its schedule (D447) —
31521
+ * and a mirror that has not loaded answers `null`: the sections it feeds say
31522
+ * "unavailable", never "none" (D49).
31523
+ */
31524
+ /**
31155
31525
  * @durable class=ledger owner=notification-center
31156
31526
  * 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"
31157
31527
  * retention="none — a row lives exactly as long as its state is open. Bounded by the installation: streams, devices, nodes and installed packages."
@@ -31313,9 +31683,7 @@ var NcSystemStateLedger = class {
31313
31683
  }
31314
31684
  /** A detection on a camera: it is not blind. Called for every detection — a Map lookup. */
31315
31685
  observeDetection(deviceId) {
31316
- const key = `detection-blind:${String(deviceId)}`;
31317
- if (this.deps.rows.get(key) === void 0) return;
31318
- this.close(key, "detection");
31686
+ this.close(`detection-blind:${String(deviceId)}`, "detection");
31319
31687
  }
31320
31688
  /**
31321
31689
  * Any `stream.online`, whatever its reason (D647 review I1). The intake gate
@@ -31330,7 +31698,7 @@ var NcSystemStateLedger = class {
31330
31698
  const keep = new Set(list.rows.map((pkg) => updateStateKey(list.target, pkg.nodeId, pkg.packageName)));
31331
31699
  for (const nodeId of list.nodeIds) {
31332
31700
  const prefix = `update:${list.target}:${nodeId}:`;
31333
- for (const row of this.deps.rows.all()) if (row.key.startsWith(prefix) && !keep.has(row.key)) this.close(row.key, "update-list");
31701
+ this.closeWhere(`update-list:${list.target}:${nodeId}`, (row) => row.key.startsWith(prefix) && !keep.has(row.key), "update-list");
31334
31702
  }
31335
31703
  for (const pkg of list.rows) this.openUpdate(list.target, pkg, atMs);
31336
31704
  }
@@ -31340,11 +31708,7 @@ var NcSystemStateLedger = class {
31340
31708
  * on that node.
31341
31709
  */
31342
31710
  observeAddonStarted(addonId, nodeId) {
31343
- for (const row of this.deps.rows.all()) {
31344
- if (row.kind !== "addon-crash-loop") continue;
31345
- if (row.nodeId !== nodeId) continue;
31346
- if ((row.params["addons"] ?? "").split(",").map((id) => id.trim()).includes(addonId)) this.close(row.key, "addon-started");
31347
- }
31711
+ 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");
31348
31712
  }
31349
31713
  /**
31350
31714
  * The balancer placed a camera on a node (not pinned): placement skips a
@@ -31400,11 +31764,31 @@ var NcSystemStateLedger = class {
31400
31764
  }
31401
31765
  });
31402
31766
  }
31767
+ /**
31768
+ * Close one state: the row held now (a loaded one, or one opened in the boot
31769
+ * window), and — deferred while the boot window is open — the STORED row
31770
+ * the window cannot see yet (D698 review H1).
31771
+ */
31403
31772
  close(key, by) {
31773
+ this.closeIfHeld(key, by, false);
31774
+ this.deps.rows.deferWhileOpen(`close:${key}`, () => this.closeIfHeld(key, by, true) ? 1 : 0);
31775
+ }
31776
+ /** {@link close} for every row a predicate names — now, and against the loaded rows. */
31777
+ closeWhere(key, names, by) {
31778
+ const run = (replayed) => {
31779
+ let closed = 0;
31780
+ for (const row of this.deps.rows.all()) if (names(row) && this.closeIfHeld(row.key, by, replayed)) closed += 1;
31781
+ return closed;
31782
+ };
31783
+ run(false);
31784
+ this.deps.rows.deferWhileOpen(`where:${key}`, () => run(true));
31785
+ }
31786
+ /** Close the row if one is held; `true` when it was. */
31787
+ closeIfHeld(key, by, replayed) {
31404
31788
  const held = this.deps.rows.get(key);
31405
- if (held === void 0) return;
31789
+ if (held === void 0) return false;
31406
31790
  this.deps.rows.remove(key);
31407
- this.deps.logger?.debug("system state closed", {
31791
+ const line = {
31408
31792
  ...held.deviceId !== void 0 ? { tags: { deviceId: held.deviceId } } : {},
31409
31793
  meta: {
31410
31794
  key,
@@ -31412,49 +31796,78 @@ var NcSystemStateLedger = class {
31412
31796
  by,
31413
31797
  since: held.since
31414
31798
  }
31415
- });
31799
+ };
31800
+ if (replayed) {
31801
+ this.deps.logger?.info("system state closed by an event heard before the load", line);
31802
+ return true;
31803
+ }
31804
+ this.deps.logger?.debug("system state closed", line);
31805
+ return true;
31416
31806
  }
31417
31807
  closeNodeFaults(nodeId, by) {
31418
- for (const row of this.deps.rows.all()) {
31419
- if (row.nodeId !== nodeId) continue;
31420
- if (row.kind === "node-inference-unavailable" || row.kind === "addon-crash-loop") this.close(row.key, by);
31421
- }
31808
+ this.closeWhere(`node-faults:${nodeId}`, (row) => row.nodeId === nodeId && (row.kind === "node-inference-unavailable" || row.kind === "addon-crash-loop"), by);
31422
31809
  }
31423
31810
  closeServerUpdate(nodeId) {
31424
- for (const row of this.deps.rows.all()) {
31425
- if ((row.nodeId ?? HUB_NODE) !== nodeId) continue;
31426
- 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");
31427
- }
31811
+ 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");
31428
31812
  }
31429
31813
  };
31430
31814
  /** The durable home of the rows. */
31431
31815
  var NcSystemStateStore = class {
31432
31816
  ledger;
31817
+ /**
31818
+ * What the intake opened or closed before the boot load answered (D698,
31819
+ * `boot-window-rows.ts`). A stored row of the same KIND keeps its own
31820
+ * `since`: a stream re-announced offline at boot was not offline "since the
31821
+ * restart".
31822
+ */
31823
+ bootWindow;
31433
31824
  constructor(deps) {
31434
31825
  this.ledger = new DurableLedger({
31435
31826
  spec: NC_SYSTEM_STATE_SPEC,
31436
31827
  store: deps.store,
31437
31828
  logger: deps.logger
31438
31829
  });
31830
+ this.bootWindow = new BootWindowRows({
31831
+ ledger: this.ledger,
31832
+ keyOf: (row) => row.key,
31833
+ deviceIdOf: (row) => row.deviceId,
31834
+ logger: deps.logger,
31835
+ merge: (stored, provisional) => {
31836
+ if (stored === void 0 || stored.kind !== provisional.kind) return provisional;
31837
+ const merged = {
31838
+ ...provisional,
31839
+ since: stored.since
31840
+ };
31841
+ return isDeepStrictEqual(merged, stored) ? stored : merged;
31842
+ }
31843
+ });
31439
31844
  }
31440
31845
  static declare(store) {
31441
31846
  return DurableLedger.declare(store, NC_SYSTEM_STATE_SPEC);
31442
31847
  }
31443
31848
  /** Boot reseed. A failed load keeps the mirror and leaves `answered` false. */
31444
31849
  async load() {
31445
- return (await this.ledger.load()).length;
31850
+ const loaded = (await this.ledger.load()).length;
31851
+ this.bootWindow.settle();
31852
+ return loaded;
31446
31853
  }
31447
31854
  port() {
31448
31855
  return {
31449
31856
  get: (key) => this.ledger.get(key),
31450
31857
  put: (row) => {
31858
+ if (this.bootWindow.open()) {
31859
+ this.bootWindow.stageProvisional(row);
31860
+ return;
31861
+ }
31451
31862
  this.ledger.put(row).catch(() => void 0);
31452
31863
  },
31453
31864
  remove: (key) => {
31865
+ if (this.bootWindow.open()) this.bootWindow.noteRemoved(key);
31454
31866
  this.ledger.forget(key);
31455
31867
  },
31456
31868
  all: () => this.ledger.snapshot(),
31457
- answered: () => this.ledger.hasAnswered()
31869
+ answered: () => this.ledger.hasAnswered(),
31870
+ deferWhileOpen: (key, replay) => this.bootWindow.deferWhileOpen(key, replay)
31458
31871
  };
31459
31872
  }
31460
31873
  };
@@ -31579,8 +31992,21 @@ var NcStateSummaryService = class {
31579
31992
  const nowMs = this.deps.now();
31580
31993
  const summary = await this.collect(rule, nowMs);
31581
31994
  const text = this.word(summary, nowMs);
31995
+ const split = systemEventSummarySplitOf(rule);
31996
+ const empty = isEmptyStateSummary(summary);
31582
31997
  return {
31583
- empty: summary.itemCount === 0 && summary.unavailableCount === 0,
31998
+ empty,
31999
+ split,
32000
+ rows: empty ? [] : split === "section" ? text.sections.map((section) => ({
32001
+ section: section.id,
32002
+ title: section.title,
32003
+ text: section.body,
32004
+ count: section.count
32005
+ })) : [{
32006
+ title: text.sections.map((section) => section.title).join(", "),
32007
+ text: text.text,
32008
+ count: text.count
32009
+ }],
31584
32010
  count: text.count,
31585
32011
  text: text.text,
31586
32012
  sections: summary.sections.map((section) => ({
@@ -31593,12 +32019,84 @@ var NcStateSummaryService = class {
31593
32019
  excludedGroups: [...summary.excludedGroups]
31594
32020
  };
31595
32021
  }
31596
- async collect(rule, nowMs) {
32022
+ /**
32023
+ * TEST the rule (D698, `nc.testRule`): the summary it would send NOW,
32024
+ * ENQUEUED through the ordinary outbox under a test record id — split as the
32025
+ * rule is. The per-camera mute is bypassed, as every rule test bypasses it,
32026
+ * and a summary has no cooldown to bypass. Nothing to report sends nothing
32027
+ * and SAYS so, per section.
32028
+ */
32029
+ async test(rule) {
32030
+ const nowMs = this.deps.now();
32031
+ const summary = await this.collect(rule, nowMs, () => false);
32032
+ const split = systemEventSummarySplitOf(rule);
32033
+ const recordId = stateSummaryTestRecordId(rule.id, nowMs);
32034
+ const sections = summary.sections.map((section) => ({
32035
+ id: section.id,
32036
+ status: section.status,
32037
+ items: section.items.length,
32038
+ ...section.reason !== void 0 ? { reason: section.reason } : {}
32039
+ }));
32040
+ const meta = {
32041
+ ruleId: rule.id,
32042
+ rule: rule.name,
32043
+ split,
32044
+ bypassMute: true
32045
+ };
32046
+ if (isEmptyStateSummary(summary)) {
32047
+ this.deps.logger.info("state summary TEST has nothing to report — not sent", { meta: {
32048
+ ...meta,
32049
+ dropped: summary.dropped
32050
+ } });
32051
+ return {
32052
+ recordId,
32053
+ split,
32054
+ nothingToReport: true,
32055
+ sections,
32056
+ rows: []
32057
+ };
32058
+ }
32059
+ const subjects = stateSummarySubjects({
32060
+ rule,
32061
+ summary,
32062
+ text: this.word(summary, nowMs),
32063
+ window: {
32064
+ startMs: nowMs,
32065
+ endMs: nowMs
32066
+ },
32067
+ split,
32068
+ recordBase: recordId
32069
+ });
32070
+ const rows = [];
32071
+ for (const subject of subjects) {
32072
+ const enqueued = await this.deps.enqueue(rule, subject, "synthetic");
32073
+ const section = subject.systemEvent?.data["section"];
32074
+ rows.push({
32075
+ recordId: subject.recordId,
32076
+ ...typeof section === "string" ? { section } : {},
32077
+ enqueued: enqueued.inserted,
32078
+ targetIds: [...enqueued.targetIds]
32079
+ });
32080
+ }
32081
+ this.deps.logger.info("state summary TEST enqueued", { meta: {
32082
+ ...meta,
32083
+ items: summary.itemCount,
32084
+ rows: rows.map((row) => `${row.recordId}:${String(row.enqueued)}`)
32085
+ } });
32086
+ return {
32087
+ recordId,
32088
+ split,
32089
+ nothingToReport: false,
32090
+ sections,
32091
+ rows
32092
+ };
32093
+ }
32094
+ async collect(rule, nowMs, isMuted = this.deps.isMuted) {
31597
32095
  const nodes = await this.nodeView();
31598
32096
  return collectStateSummary({
31599
32097
  rule,
31600
32098
  nowMs,
31601
- isMuted: this.deps.isMuted,
32099
+ isMuted,
31602
32100
  sources: stateSources({
31603
32101
  alerts: this.deps.alerts,
31604
32102
  liveness: this.deps.liveness,
@@ -31634,17 +32132,24 @@ var NcStateSummaryService = class {
31634
32132
  });
31635
32133
  }
31636
32134
  async deliver(rule, summary, window) {
31637
- const subject = stateSummarySubject({
32135
+ const text = this.word(summary, window.endMs);
32136
+ const subjects = stateSummarySubjects({
31638
32137
  rule,
31639
32138
  summary,
31640
- text: this.word(summary, window.endMs),
31641
- window
32139
+ text,
32140
+ window,
32141
+ split: systemEventSummarySplitOf(rule)
31642
32142
  });
31643
- if (subject === null) {
31644
- this.deps.logger.warn("state summary rule has no system-event condition — not sent", { meta: { ruleId: rule.id } });
32143
+ if (subjects.length === 0) {
32144
+ this.deps.logger.warn("state summary produced no row — not sent", { meta: {
32145
+ ruleId: rule.id,
32146
+ sections: text.sections.length
32147
+ } });
31645
32148
  return 0;
31646
32149
  }
31647
- return this.deps.enqueue(rule, subject);
32150
+ let inserted = 0;
32151
+ for (const subject of subjects) inserted += (await this.deps.enqueue(rule, subject, "system")).inserted;
32152
+ return inserted;
31648
32153
  }
31649
32154
  };
31650
32155
  //#endregion
@@ -33370,7 +33875,7 @@ var NotificationCenter = class NotificationCenter {
33370
33875
  livenessHoldoffMs: deps.livenessHoldoffMs ?? DEVICE_LIVENESS_HOLDOFF_MS,
33371
33876
  isMuted: (deviceId) => this.deviceMutes.isMuted(deviceId),
33372
33877
  texts: () => this.texts,
33373
- enqueue: (rule, subject) => this.enqueueStateSummary(rule, subject)
33878
+ enqueue: (rule, subject, origin) => this.enqueueStateSummary(rule, subject, origin)
33374
33879
  });
33375
33880
  this.confirmGate = deps.generateVision !== void 0 ? new NcConfirmGate({
33376
33881
  logger: this.logger.child("confirm"),
@@ -35911,30 +36416,42 @@ var NotificationCenter = class NotificationCenter {
35911
36416
  await this.enqueuePickerTargets(event, reached, outcomes);
35912
36417
  return report({ candidates: candidates.length });
35913
36418
  }
36419
+ /** TEST a state-summary rule (D698, `nc.testRule`) — see `NcStateSummaryService.test`. */
36420
+ testStateSummary(rule) {
36421
+ return this.stateSummary.test(rule);
36422
+ }
35914
36423
  /**
35915
- * Enqueue ONE state summary (D647) through the rule's ordinary rows: its
36424
+ * Enqueue ONE state summary row (D647) through the rule's ordinary rows: its
35916
36425
  * targets, its users (admins only — the row files under the system device),
35917
36426
  * its template, and the drain's snooze gate and retries. The record id is
35918
- * the dedup key, so a window is enqueued once whatever the restarts.
36427
+ * the dedup key, so a window — or each section of it (D698) — is enqueued
36428
+ * once whatever the restarts. A TEST's rows carry the `synthetic` origin, so
36429
+ * they are marked as a test exactly like every other rule test's.
35919
36430
  */
35920
- async enqueueStateSummary(rule, subject) {
35921
- const appended = withSummaryBody(rule);
36431
+ async enqueueStateSummary(rule, subject, origin) {
36432
+ const appended = withSummaryBody(rule, stateSummarySectionOf(subject));
35922
36433
  if (appended !== null) this.logger.warn("state summary rule body names no summary content — {{summaryText}} appended", { meta: {
35923
36434
  ruleId: rule.id,
35924
36435
  rule: rule.name
35925
36436
  } });
35926
36437
  const sent = appended ?? rule;
35927
36438
  const userTargets = await this.resolveUserTargets(sent, subject.deviceId);
35928
- const entries = this.buildEntries(sent, subject, "system-event", ["systemEvent"], userTargets, "system");
36439
+ const entries = this.buildEntries(sent, subject, "system-event", ["systemEvent"], userTargets, origin);
35929
36440
  if (entries.length === 0) {
35930
36441
  this.logger.warn("state summary reached NO target — its users are not admins, and it has no targets", { meta: {
35931
36442
  ruleId: rule.id,
35932
36443
  rule: rule.name,
35933
36444
  targetUsers: rule.targetUsers ?? []
35934
36445
  } });
35935
- return 0;
36446
+ return {
36447
+ inserted: 0,
36448
+ targetIds: []
36449
+ };
35936
36450
  }
35937
- return this.outbox.enqueue(entries);
36451
+ return {
36452
+ inserted: await this.outbox.enqueue(entries),
36453
+ targetIds: entries.map((entry) => entry.targetId)
36454
+ };
35938
36455
  }
35939
36456
  /**
35940
36457
  * The rules a trigger may fire, which is `listEnabled(delivery)` plus and
@@ -36648,9 +37165,11 @@ var NotificationCenter = class NotificationCenter {
36648
37165
  *
36649
37166
  * A seed is INFO and a repeat is DEBUG on purpose: seeding happens once per
36650
37167
  * subject per lifetime of the ledger, and it is exactly what an operator
36651
- * reading the logs after a restart needs to see ("it recorded them, it did
36652
- * not announce them"). A no-flip is ordinary bus chatter — two categories
36653
- * describing one edge — and would be noise at info.
37168
+ * reading the logs after a restart needs to see ("it did not announce
37169
+ * them"). A seed heard BEFORE the boot load is provisional (D698): the load
37170
+ * keeps the stored row when it holds the same state, and its `boot window
37171
+ * settled` line says how many it kept and wrote. A no-flip is ordinary bus
37172
+ * chatter — two categories describing one edge — and would be noise at info.
36654
37173
  */
36655
37174
  reportLivenessDrop(incoming, verdict) {
36656
37175
  const dropped = discardedLivenessEvent(incoming, verdict);
@@ -42578,6 +43097,280 @@ function parseDebugDeviceIds(url) {
42578
43097
  return out;
42579
43098
  }
42580
43099
  //#endregion
43100
+ //#region src/pipeline-analytics/store/tracking-tape-index-store.ts
43101
+ /**
43102
+ * @durable class=ledger owner=pipeline-analytics
43103
+ * write="one row per PROMOTED tracking-tape window (`debug.tape.promote`);
43104
+ * updated when the operator marks it reviewed or releases its pins;
43105
+ * deleted together with its file by `debug.tape.delete`."
43106
+ * retention="NOT an age clock and NOT track retention — a promoted window is
43107
+ * history being labelled (D681). Bounded by the promotion budget
43108
+ * (MAX_PROMOTED_TAPES = 60, 1 GiB): a promotion beyond it is REFUSED,
43109
+ * never satisfied by evicting another row."
43110
+ */
43111
+ var TRACKING_TAPES_COLLECTION = "pipeline-analytics:tracking-tapes";
43112
+ var TRACKING_TAPES_COLUMNS = [
43113
+ {
43114
+ name: "id",
43115
+ type: "TEXT",
43116
+ primaryKey: true,
43117
+ notNull: true
43118
+ },
43119
+ {
43120
+ name: "deviceId",
43121
+ type: "INTEGER",
43122
+ notNull: true
43123
+ },
43124
+ {
43125
+ name: "scenario",
43126
+ type: "TEXT",
43127
+ notNull: true
43128
+ },
43129
+ {
43130
+ name: "note",
43131
+ type: "TEXT"
43132
+ },
43133
+ {
43134
+ name: "lighting",
43135
+ type: "TEXT",
43136
+ notNull: true
43137
+ },
43138
+ {
43139
+ name: "fromMs",
43140
+ type: "INTEGER",
43141
+ notNull: true
43142
+ },
43143
+ {
43144
+ name: "toMs",
43145
+ type: "INTEGER",
43146
+ notNull: true
43147
+ },
43148
+ {
43149
+ name: "state",
43150
+ type: "TEXT",
43151
+ notNull: true
43152
+ },
43153
+ {
43154
+ name: "frames",
43155
+ type: "INTEGER",
43156
+ notNull: true
43157
+ },
43158
+ {
43159
+ name: "skips",
43160
+ type: "INTEGER",
43161
+ notNull: true
43162
+ },
43163
+ {
43164
+ name: "gaps",
43165
+ type: "INTEGER",
43166
+ notNull: true
43167
+ },
43168
+ {
43169
+ name: "bytes",
43170
+ type: "INTEGER",
43171
+ notNull: true
43172
+ },
43173
+ {
43174
+ name: "reviewed",
43175
+ type: "BOOLEAN",
43176
+ notNull: true
43177
+ },
43178
+ {
43179
+ name: "trackIds",
43180
+ type: "TEXT",
43181
+ notNull: true
43182
+ },
43183
+ {
43184
+ name: "createdAt",
43185
+ type: "INTEGER",
43186
+ notNull: true
43187
+ },
43188
+ {
43189
+ name: "releasedAt",
43190
+ type: "INTEGER"
43191
+ },
43192
+ {
43193
+ name: "pinsSettled",
43194
+ type: "BOOLEAN",
43195
+ notNull: true
43196
+ }
43197
+ ];
43198
+ var TrackingTapeRowSchema = object({
43199
+ id: string(),
43200
+ deviceId: number().int(),
43201
+ scenario: string(),
43202
+ note: string().nullable(),
43203
+ lighting: TapeLightingSchema,
43204
+ fromMs: number().int(),
43205
+ toMs: number().int(),
43206
+ state: _enum(["complete", "incomplete"]),
43207
+ frames: number().int(),
43208
+ skips: number().int(),
43209
+ gaps: number().int(),
43210
+ bytes: number().int(),
43211
+ reviewed: boolean(),
43212
+ trackIds: array(string()),
43213
+ createdAt: number().int(),
43214
+ releasedAt: number().int().nullable(),
43215
+ pinsSettled: boolean()
43216
+ });
43217
+ var TrackIdsSchema = array(string());
43218
+ /**
43219
+ * True when the SQLite settings backend would hand `text` back DECODED: it
43220
+ * parses any TEXT that starts with `[` or `{` and is valid JSON
43221
+ * (`decodeColumnValue` decodes by content, not by declared type). Free text
43222
+ * like that cannot round-trip through a TEXT column (follow-up 617, review M2).
43223
+ */
43224
+ function decodesAsJson(text) {
43225
+ if (!(text.startsWith("[") || text.startsWith("{"))) return false;
43226
+ try {
43227
+ JSON.parse(text);
43228
+ return true;
43229
+ } catch {
43230
+ return false;
43231
+ }
43232
+ }
43233
+ /**
43234
+ * Free text (`scenario`, `note`) as the column hands it back: a value the
43235
+ * backend decoded is re-encoded, so a row written before `debug.tape.promote`
43236
+ * refused such text never vanishes (its whitespace may differ). Anything else
43237
+ * goes to the schema as it came.
43238
+ */
43239
+ function freeText(raw) {
43240
+ return raw !== null && typeof raw === "object" ? JSON.stringify(raw) : raw;
43241
+ }
43242
+ /**
43243
+ * `trackIds` as the column hands it back. The SQLite backend decodes a TEXT
43244
+ * that starts with `[` on read (`decodeColumnValue`), so the store sees the
43245
+ * ARRAY it wrote; a string is parsed here only for a backend (or an old row)
43246
+ * that hands the text back as written. `JSON.parse(String(array))` threw on the
43247
+ * decoded array and every promoted row was skipped as malformed (follow-up 617).
43248
+ * `null` = unreadable, absent included (the column is NOT NULL, so absence is
43249
+ * corruption) — the row is reported, never repaired.
43250
+ */
43251
+ function decodeTrackIds(raw) {
43252
+ if (raw === void 0 || raw === null) return null;
43253
+ if (typeof raw !== "string") return raw;
43254
+ try {
43255
+ return JSON.parse(raw);
43256
+ } catch {
43257
+ return null;
43258
+ }
43259
+ }
43260
+ var TrackingTapeIndexStore = class {
43261
+ store;
43262
+ logger;
43263
+ constructor(deps) {
43264
+ this.store = deps.store;
43265
+ this.logger = deps.logger;
43266
+ }
43267
+ static async declare(store) {
43268
+ await store.declareCollection.mutate({
43269
+ collection: TRACKING_TAPES_COLLECTION,
43270
+ columns: [...TRACKING_TAPES_COLUMNS],
43271
+ indexes: [{
43272
+ name: "idx_trackingtapes_device_created",
43273
+ columns: ["deviceId", "createdAt"]
43274
+ }]
43275
+ });
43276
+ }
43277
+ async insert(row) {
43278
+ const { id, trackIds, ...rest } = TrackingTapeRowSchema.parse(row);
43279
+ await this.store.insert.mutate({
43280
+ collection: TRACKING_TAPES_COLLECTION,
43281
+ record: {
43282
+ id,
43283
+ data: {
43284
+ ...rest,
43285
+ trackIds: JSON.stringify(trackIds)
43286
+ }
43287
+ }
43288
+ });
43289
+ }
43290
+ async get(id) {
43291
+ const first = (await this.store.query.query({
43292
+ collection: TRACKING_TAPES_COLLECTION,
43293
+ filter: {
43294
+ where: { id },
43295
+ limit: 1
43296
+ }
43297
+ }))[0];
43298
+ return first === void 0 ? null : this.parse(first.id, first.data);
43299
+ }
43300
+ async list(deviceId) {
43301
+ const rows = await this.store.query.query({
43302
+ collection: TRACKING_TAPES_COLLECTION,
43303
+ filter: {
43304
+ ...deviceId !== void 0 ? { where: { deviceId } } : {},
43305
+ orderBy: {
43306
+ field: "createdAt",
43307
+ direction: "desc"
43308
+ },
43309
+ limit: 1e3
43310
+ }
43311
+ });
43312
+ const out = [];
43313
+ for (const r of rows) {
43314
+ const parsed = this.parse(r.id, r.data);
43315
+ if (parsed !== null) out.push(parsed);
43316
+ }
43317
+ return out;
43318
+ }
43319
+ async setReviewed(id, reviewed) {
43320
+ await this.store.update.mutate({
43321
+ collection: TRACKING_TAPES_COLLECTION,
43322
+ id,
43323
+ data: { reviewed }
43324
+ });
43325
+ }
43326
+ /** Release AND unsettle in one row write: the unpins the release implies
43327
+ * are owed from this moment, whether or not the caller lives to make them. */
43328
+ async setReleased(id, atMs) {
43329
+ await this.store.update.mutate({
43330
+ collection: TRACKING_TAPES_COLLECTION,
43331
+ id,
43332
+ data: {
43333
+ releasedAt: atMs,
43334
+ pinsSettled: false
43335
+ }
43336
+ });
43337
+ }
43338
+ async setPinsSettled(id, settled) {
43339
+ await this.store.update.mutate({
43340
+ collection: TRACKING_TAPES_COLLECTION,
43341
+ id,
43342
+ data: { pinsSettled: settled }
43343
+ });
43344
+ }
43345
+ async remove(id) {
43346
+ await this.store.delete.mutate({
43347
+ collection: TRACKING_TAPES_COLLECTION,
43348
+ key: id
43349
+ });
43350
+ }
43351
+ parse(id, data) {
43352
+ const ids = TrackIdsSchema.safeParse(decodeTrackIds(data["trackIds"]));
43353
+ const parsed = TrackingTapeRowSchema.safeParse({
43354
+ id,
43355
+ ...data,
43356
+ scenario: freeText(data["scenario"]),
43357
+ note: freeText(data["note"] ?? null),
43358
+ releasedAt: data["releasedAt"] ?? null,
43359
+ trackIds: ids.success ? ids.data : null
43360
+ });
43361
+ if (parsed.success) return parsed.data;
43362
+ this.logger.warn("tracking tape index: skipped a malformed row", {
43363
+ meta: {
43364
+ id,
43365
+ issues: parsed.error.issues.map((i) => i.path.join(".")),
43366
+ trackIdsType: typeof data["trackIds"]
43367
+ },
43368
+ ...typeof data["deviceId"] === "number" ? { tags: { deviceId: data["deviceId"] } } : {}
43369
+ });
43370
+ return null;
43371
+ }
43372
+ };
43373
+ //#endregion
42581
43374
  //#region src/pipeline-analytics/debug/tape-actions.ts
42582
43375
  /**
42583
43376
  * `debug.tape.*` — the tracking-tape surface (D681, spec §3.3). Admin,
@@ -42608,12 +43401,19 @@ var TapeStatusRowSchema = object({
42608
43401
  var TapeStatusInputSchema = object({ deviceIds: array(number().int()).optional() });
42609
43402
  var TapeStatusOutputSchema = object({ cameras: array(TapeStatusRowSchema).readonly() });
42610
43403
  var TapeLightingInputSchema = _enum(["day", "ir"]);
43404
+ /**
43405
+ * Free text the settings store would hand back decoded is refused by name
43406
+ * (follow-up 617, review M2): whole JSON array/object text in a TEXT column
43407
+ * comes back as an array or object, and the index could not read the row.
43408
+ */
43409
+ var NOT_JSON_TEXT = "must not be JSON array/object text — the settings store would hand it back decoded";
43410
+ var notJsonText = (s) => !decodesAsJson(s);
42611
43411
  var PromoteInputSchema = object({
42612
43412
  deviceId: number().int().positive(),
42613
43413
  fromMs: number().int(),
42614
43414
  toMs: number().int(),
42615
- scenario: string(),
42616
- note: string().max(500).optional(),
43415
+ scenario: string().refine(notJsonText, { message: NOT_JSON_TEXT }),
43416
+ note: string().max(500).refine(notJsonText, { message: NOT_JSON_TEXT }).optional(),
42617
43417
  lighting: TapeLightingInputSchema.optional()
42618
43418
  });
42619
43419
  var PromoteOutputSchema = discriminatedUnion("ok", [object({
@@ -51074,6 +51874,17 @@ function pairRiders(detections) {
51074
51874
  * (D438) no frame did, and a newcomer on the stale prediction took the id.
51075
51875
  */
51076
51876
  var MAX_PREDICTION_MS = 1e3;
51877
+ /**
51878
+ * A frame more than `maxMissedMs` behind the latest is a CLOCK STEP only as
51879
+ * the K-th such frame in a row, each in order with the one before (follow-up
51880
+ * 617, fix round 3; D49: a read that would destroy work needs another to
51881
+ * agree). A step ends every track on the camera; one stray timestamp — a
51882
+ * backlog spike 4.5 s late — or two runner clocks delivering interleaved
51883
+ * (a camera migrating between runners) must not. Three is the least that a
51884
+ * single late frame plus an interleaved partner cannot reach by chance; the
51885
+ * cost of a real step is K−1 dropped frames (~260 ms at 7.7 fps).
51886
+ */
51887
+ var CLOCK_STEP_CONFIRM_FRAMES = 3;
51077
51888
  /** Confident boxes kept on a tracklet's path (the old tracker's bound). */
51078
51889
  var MAX_PATH_LENGTH = 300;
51079
51890
  /** Swap test: crossed pairs cheaper than this over the chosen pairs are doubt. */
@@ -51879,6 +52690,35 @@ function linkReach(from, gapMs) {
51879
52690
  return diag * Math.min(LINK_REACH_MAX_DIAGONALS, Math.max(LINK_REACH_MIN_DIAGONALS, LINK_REACH_DIAGONALS_PER_SEC * gapMs / 1e3));
51880
52691
  }
51881
52692
  var arcKey = (from, to) => `${from}\u0000${to}`;
52693
+ /**
52694
+ * The rejections that mean "geometry or appearance said no to a plausible
52695
+ * continuation" of a confirmed predecessor — a track the operator sees break
52696
+ * (follow-up 617, F5). Every one of them is past `hardRejection`, so the
52697
+ * predecessor is confirmed. NOT here (fix round 1): `unconfirmed`;
52698
+ * `has-successor` (continued elsewhere); `concurrent` and `identity-conflict`
52699
+ * (two subjects — the normal case, and the most frequent); `dropped` (an
52700
+ * operator delete or a refused chain).
52701
+ */
52702
+ var BREAK_REJECTIONS = new Set([
52703
+ "reach",
52704
+ "area",
52705
+ "appearance",
52706
+ "contested",
52707
+ "cost"
52708
+ ]);
52709
+ /** The confirmed predecessors whose link a decision rejected as a break (F5). */
52710
+ function confirmedPredecessorRejections(candidates) {
52711
+ const out = [];
52712
+ for (const c of candidates ?? []) {
52713
+ if (c.rejected === void 0 || !BREAK_REJECTIONS.has(c.rejected)) continue;
52714
+ out.push({
52715
+ from: c.from,
52716
+ rejected: c.rejected,
52717
+ gapMs: c.gapMs
52718
+ });
52719
+ }
52720
+ return out;
52721
+ }
51882
52722
  function arcFacts(a) {
51883
52723
  return {
51884
52724
  from: a.from.id,
@@ -51956,6 +52796,26 @@ var DeferredLinker = class {
51956
52796
  if (seen === void 0) this.names.set(p.id, new Set([p.identity]));
51957
52797
  else seen.add(p.identity);
51958
52798
  }
52799
+ /**
52800
+ * A clock step (follow-up 617, fix round 2): every piece still undecided is
52801
+ * decided `new`, so nothing that existed before the step can be absorbed
52802
+ * across it — whatever was ready was committed just before. The facade
52803
+ * rebases its clock past `LINK_MAX_GAP_MS`, so no later piece can continue
52804
+ * one of these either. An extra id, never a mix-up.
52805
+ */
52806
+ closeUndecided(now, pieces) {
52807
+ for (const p of pieces) this.accept(p, now);
52808
+ for (const p of this.pieces.values()) {
52809
+ if (this.outcomes.has(p.id)) continue;
52810
+ this.decide(p.id, "new");
52811
+ this.observer?.({
52812
+ t: now,
52813
+ kind: "new",
52814
+ to: p.id,
52815
+ reason: "clock-step"
52816
+ });
52817
+ }
52818
+ }
51959
52819
  isTailFinal(id, now, isLive) {
51960
52820
  const root = this.rootOf(id);
51961
52821
  if (this.pieces.has(root) && !this.outcomes.has(root)) return false;
@@ -52706,7 +53566,15 @@ var Tracklet = class {
52706
53566
  h: this.assocBox.h
52707
53567
  };
52708
53568
  }
53569
+ /**
53570
+ * A confident sighting. One OLDER than the last (`t < lastSeenAt` — ~1 % of
53571
+ * frames reach the tracker out of capture order, up to 1.2 s back) is
53572
+ * `stale`: it changes nothing. A rewound tracklet would be measured from a
53573
+ * box its subject left a second ago, and L2 refused a link by reach on
53574
+ * exactly that (follow-up 617, F1). The caller reports it.
53575
+ */
52709
53576
  observeConfident(det, t, faceBearing) {
53577
+ if (t < this.lastSeenAt) return "stale";
52710
53578
  const dt = t - this.lastSeenAt;
52711
53579
  if (dt > 0) {
52712
53580
  const from = centreOf(this.lastBox);
@@ -52718,6 +53586,7 @@ var Tracklet = class {
52718
53586
  this.emitIntervalMs = dt;
52719
53587
  }
52720
53588
  this.recordConfident(det, t, faceBearing);
53589
+ return "recorded";
52721
53590
  }
52722
53591
  observeLowScore(box, t) {
52723
53592
  this.assocBox = box;
@@ -52819,6 +53688,15 @@ var TrackletTracker = class {
52819
53688
  demoteSilent(t) {
52820
53689
  for (const tr of this.live.values()) if (tr.mode === "active" && t - tr.lastSeenAt > MAX_PREDICTION_MS) tr.mode = "coasting";
52821
53690
  }
53691
+ /** Ends every live tracklet at its own last sighting (a clock step); the ids it ended. */
53692
+ endAll(reason) {
53693
+ const ended = [];
53694
+ for (const tr of [...this.live.values()]) {
53695
+ ended.push(tr.id);
53696
+ this.finish(tr, tr.lastSeenAt, reason);
53697
+ }
53698
+ return ended;
53699
+ }
52822
53700
  /** Silence: end what the coast budget no longer covers. */
52823
53701
  expire(now) {
52824
53702
  for (const tr of [...this.live.values()]) if (now - tr.lastSeenAt > this.config.maxMissedMs) this.finish(tr, tr.lastSeenAt, "missed");
@@ -52909,12 +53787,12 @@ var TrackletTracker = class {
52909
53787
  const d = frame.detections[pair.detection];
52910
53788
  if (tr === void 0 || d === void 0) return;
52911
53789
  frame.used.add(pair.detection);
53790
+ if (tr.observeConfident(d, frame.t, frame.ctx.faceBearing?.has(d.bbox) === true) === "stale") return;
52912
53791
  tr.mode = "active";
52913
53792
  if (d.class !== tr.klass) this.refuse({
52914
53793
  reason: "cross-class-absorbed",
52915
53794
  against: tr
52916
53795
  }, d);
52917
- tr.observeConfident(d, frame.t, frame.ctx.faceBearing?.has(d.bbox) === true);
52918
53796
  if (pair.contested) {
52919
53797
  tr.contestedHits += 1;
52920
53798
  tr.contestedSince ??= frame.t;
@@ -53103,27 +53981,151 @@ var TrackerV2 = class {
53103
53981
  l1;
53104
53982
  l2;
53105
53983
  pending = [];
53984
+ /**
53985
+ * Internal time = the caller's frame time + `offset`. Zero until a clock
53986
+ * step; each step moves it so the internal clock never goes back and
53987
+ * resumes past `LINK_MAX_GAP_MS` of silence (fix round 2, C1). L1, L2, their
53988
+ * facts and prunes run on internal time; fusions and `frameClock()` are
53989
+ * handed back in the caller's clock.
53990
+ */
53991
+ offset = 0;
53106
53992
  lastT = 0;
53993
+ /** The latest FRAME time taken (internal) — never the sweep's extrapolated `now`. */
53994
+ lastFrameT = Number.NEGATIVE_INFINITY;
53995
+ observers;
53996
+ /** Caller times of the consecutive frames past the stale bound so far — the
53997
+ * run a clock step needs `CLOCK_STEP_CONFIRM_FRAMES` of (fix round 3). */
53998
+ stepRun = [];
53107
53999
  constructor(config, deps) {
53108
54000
  this.config = config;
53109
- this.l1 = new TrackletTracker(config, deps.mintTrackId, deps.observers ?? {});
54001
+ this.observers = deps.observers ?? {};
54002
+ const out = this.observers;
54003
+ const internal = {
54004
+ ...out.refusal !== void 0 ? { refusal: out.refusal } : {},
54005
+ ended: (f) => out.ended?.({
54006
+ ...f,
54007
+ at: f.at - this.offset
54008
+ }),
54009
+ split: (f) => out.split?.({
54010
+ ...f,
54011
+ t: f.t - this.offset
54012
+ })
54013
+ };
54014
+ this.l1 = new TrackletTracker(config, deps.mintTrackId, internal);
53110
54015
  this.l2 = new DeferredLinker({
53111
54016
  maxMissedMs: config.maxMissedMs,
53112
54017
  appearanceResolvesAmbiguity: config.appearanceResolvesAmbiguity
53113
- }, deps.observers?.link);
54018
+ }, (f) => out.link?.({
54019
+ ...f,
54020
+ t: f.t - this.offset
54021
+ }));
53114
54022
  }
53115
- update(detections, t, ctx) {
54023
+ update(detections, frameT, ctx) {
54024
+ const order = this.classifyFrame(frameT);
54025
+ if (order === "out-of-order") return this.drop(frameT, detections.length);
54026
+ let t = frameT + this.offset;
54027
+ if (order === "clock-step") t = this.clockStep(frameT, this.lastFrameT - t);
54028
+ this.stepRun = [];
54029
+ this.lastFrameT = t;
53116
54030
  this.lastT = Math.max(this.lastT, t);
53117
54031
  const step = this.l1.step(detections, t, ctx);
53118
54032
  this.link(this.lastT);
53119
54033
  return step.emissions.map((e) => this.emit(e, t));
53120
54034
  }
53121
54035
  /**
54036
+ * A frame stamped before the latest one already taken (follow-up 617, F1;
54037
+ * ~1 % of frames, up to 1.2 s back). It is decided ONCE, for the whole
54038
+ * frame (fix round 1, H1): matched against the tracklets' NEWEST boxes, a
54039
+ * stale box can land on another subject — a walker's old box on the walker
54040
+ * behind — and be emitted under that id. So nothing binds, spawns, splits or
54041
+ * is demoted on it; the confirmed tracklets are emitted as on a frame that
54042
+ * matched nothing (no sighting), and one fact says the frame was dropped.
54043
+ */
54044
+ /**
54045
+ * How `update` will take a frame at `frameT` — without taking it. The
54046
+ * processor asks first (fix round 3, MEDIUM-2), so a dropped frame never
54047
+ * reaches the stationary gate, the zones or the event path.
54048
+ * - `in-order`: not behind the latest frame taken;
54049
+ * - `out-of-order`: behind it — within `maxMissedMs` (disorder, ≤ 1.2 s
54050
+ * measured), or further back but not yet the `CLOCK_STEP_CONFIRM_FRAMES`-th
54051
+ * such frame in a row;
54052
+ * - `clock-step`: the K-th consecutive frame past the bound, each in order
54053
+ * with the one before (within `maxMissedMs`).
54054
+ */
54055
+ classifyFrame(frameT) {
54056
+ const behindMs = this.lastFrameT - (frameT + this.offset);
54057
+ if (!(behindMs > 0)) return "in-order";
54058
+ if (behindMs <= this.config.maxMissedMs) return "out-of-order";
54059
+ return this.runLength(frameT) >= CLOCK_STEP_CONFIRM_FRAMES ? "clock-step" : "out-of-order";
54060
+ }
54061
+ /** The past-bound run this frame would make, itself included. */
54062
+ runLength(frameT) {
54063
+ const prev = this.stepRun.at(-1);
54064
+ return (prev !== void 0 && frameT >= prev && frameT - prev <= this.config.maxMissedMs ? this.stepRun.length : 0) + 1;
54065
+ }
54066
+ /**
54067
+ * Drops a frame `classifyFrame` called `out-of-order`. A frame past the
54068
+ * bound extends (or restarts) the run a clock step needs; any other breaks
54069
+ * it. One fact per frame.
54070
+ */
54071
+ drop(frameT, detections) {
54072
+ const behindMs = this.lastFrameT - (frameT + this.offset);
54073
+ let pendingClockStep = 0;
54074
+ if (behindMs > this.config.maxMissedMs) {
54075
+ pendingClockStep = this.runLength(frameT);
54076
+ this.stepRun = pendingClockStep === 1 ? [frameT] : [...this.stepRun, frameT];
54077
+ } else this.stepRun = [];
54078
+ this.observers.outOfOrder?.({
54079
+ t: frameT,
54080
+ lastFrameT: this.frameClock(),
54081
+ behindMs,
54082
+ detections,
54083
+ pendingClockStep
54084
+ });
54085
+ return this.l1.liveTracklets().filter((tr) => tr.confirmed).map((tracklet) => this.emit({
54086
+ tracklet,
54087
+ detection: void 0,
54088
+ matched: false
54089
+ }, this.lastFrameT));
54090
+ }
54091
+ /**
54092
+ * The frame clock stepped back further than `maxMissedMs` (fix round 2, C1):
54093
+ * not disorder — an NTP correction, or a camera moved to a runner whose clock
54094
+ * is behind. An unbounded gate dropped every frame until the new clock passed
54095
+ * the old high mark (60 s back: 461 frames, a new subject never tracked, the
54096
+ * departed one coasting on). Instead: commit what L2 has decided, end every
54097
+ * live tracklet at its own last sighting, decide every piece still undecided
54098
+ * `new` (no fusion crosses the step), and resume the internal clock past
54099
+ * `LINK_MAX_GAP_MS` of silence, so nothing later can continue anything
54100
+ * earlier. Returns the frame's internal time.
54101
+ */
54102
+ clockStep(frameT, behindMs) {
54103
+ const lastFrameT = this.frameClock();
54104
+ this.link(this.lastT);
54105
+ const ended = this.l1.endAll("clock-step");
54106
+ this.l2.closeUndecided(this.lastT, this.pieces());
54107
+ const resumeAt = Math.max(this.lastT, this.lastFrameT) + LINK_MAX_GAP_MS + 1;
54108
+ this.offset = resumeAt - frameT;
54109
+ this.lastT = resumeAt;
54110
+ this.observers.clockStep?.({
54111
+ t: frameT,
54112
+ lastFrameT,
54113
+ behindMs,
54114
+ ended
54115
+ });
54116
+ return resumeAt;
54117
+ }
54118
+ /** The latest frame time taken, in the caller's clock (rebased by a clock step). */
54119
+ frameClock() {
54120
+ return this.lastFrameT - this.offset;
54121
+ }
54122
+ /**
53122
54123
  * The close sweep's entry on a camera whose frames stopped (D438): end the
53123
54124
  * tracklets the coast budget no longer covers, THEN run L2 (review C2).
53124
54125
  * `now` is FRAME time (the processor converts wall clock, Task 11).
53125
54126
  */
53126
- advance(now) {
54127
+ advance(frameNow) {
54128
+ const now = frameNow + this.offset;
53127
54129
  this.lastT = Math.max(this.lastT, now);
53128
54130
  this.l1.expire(now);
53129
54131
  this.link(now);
@@ -53139,6 +54141,7 @@ var TrackerV2 = class {
53139
54141
  markSuppressed(id) {
53140
54142
  this.l1.markSuppressed(id);
53141
54143
  }
54144
+ /** In the caller's clock (translated when each was committed, LOW-5). */
53142
54145
  drainFusions() {
53143
54146
  const out = this.pending;
53144
54147
  this.pending = [];
@@ -53151,8 +54154,8 @@ var TrackerV2 = class {
53151
54154
  isLive(id) {
53152
54155
  return this.l2.membersOf(this.l2.rootOf(id)).some((m) => this.isLiveTracklet(m));
53153
54156
  }
53154
- isTailFinal(id, now) {
53155
- return this.l2.isTailFinal(id, now, (m) => this.isLiveTracklet(m));
54157
+ isTailFinal(id, frameNow) {
54158
+ return this.l2.isTailFinal(id, frameNow + this.offset, (m) => this.isLiveTracklet(m));
53156
54159
  }
53157
54160
  /**
53158
54161
  * Ends every live member of the chain. `stationary` (round 4): the
@@ -53168,9 +54171,15 @@ var TrackerV2 = class {
53168
54171
  const tr = this.l1.get(id);
53169
54172
  return tr !== void 0 && tr.endedAt === null;
53170
54173
  }
54174
+ pieces() {
54175
+ return [...this.l1.liveTracklets(), ...this.l1.endedTracklets()].map(toPiece);
54176
+ }
53171
54177
  link(now) {
53172
- const pieces = [...this.l1.liveTracklets(), ...this.l1.endedTracklets()].map(toPiece);
53173
- this.pending.push(...this.l2.step(now, pieces));
54178
+ const offset = this.offset;
54179
+ for (const f of this.l2.step(now, this.pieces())) this.pending.push({
54180
+ ...f,
54181
+ t: f.t - offset
54182
+ });
53174
54183
  this.l1.prune(now);
53175
54184
  this.l2.prune(now);
53176
54185
  }
@@ -53233,7 +54242,8 @@ var NO_ENDS = {
53233
54242
  missed: 0,
53234
54243
  ambiguous: 0,
53235
54244
  dropped: 0,
53236
- stationary: 0
54245
+ stationary: 0,
54246
+ "clock-step": 0
53237
54247
  };
53238
54248
  function emptyStats() {
53239
54249
  return {
@@ -53246,16 +54256,28 @@ function emptyStats() {
53246
54256
  linksRefused: 0,
53247
54257
  linksNew: 0,
53248
54258
  linksDisagreed: 0,
53249
- lowScoreHolds: 0
54259
+ lowScoreHolds: 0,
54260
+ outOfOrderFrames: 0,
54261
+ clockSteps: 0
53250
54262
  };
53251
54263
  }
53252
54264
  /** Nothing happened in the window: no tracklet ended, no split, no L2
53253
54265
  * decision, no low-score hold (review LOW-N1 — what the sweep stays quiet about). */
53254
54266
  function isIdleTrackerStats(stats) {
53255
- 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;
54267
+ 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;
53256
54268
  }
53257
54269
  /** Shared empty — a fold is rare and every other frame would allocate. */
53258
54270
  var EMPTY_RIDER_FOLDS = [];
54271
+ /** The tape observation of a dropped frame: no gate verdict, nothing bound. */
54272
+ var DROPPED_FRAME_OBSERVATION = {
54273
+ gate: {
54274
+ strong: [],
54275
+ weak: [],
54276
+ confirmed: [],
54277
+ woken: []
54278
+ },
54279
+ live: []
54280
+ };
53259
54281
  /** Mapping from StateAnalyzer's `ObjectState.state` values to the
53260
54282
  * canonical TrackState enum used on tracks + events. */
53261
54283
  function mapObjectStateToTrackState(s) {
@@ -53434,6 +54456,14 @@ var FrameProcessor = class {
53434
54456
  else if (f.kind === "new") this.stats.linksNew += 1;
53435
54457
  else if (f.kind === "disagreed") this.stats.linksDisagreed += 1;
53436
54458
  this.trackerObservers.link?.(f);
54459
+ },
54460
+ outOfOrder: (f) => {
54461
+ this.stats.outOfOrderFrames += 1;
54462
+ this.trackerObservers.outOfOrder?.(f);
54463
+ },
54464
+ clockStep: (f) => {
54465
+ this.stats.clockSteps += 1;
54466
+ this.trackerObservers.clockStep?.(f);
53437
54467
  }
53438
54468
  }
53439
54469
  });
@@ -53717,6 +54747,37 @@ var FrameProcessor = class {
53717
54747
  * Zero is the expected reading; a rising one means two people stand close
53718
54748
  * together on this camera, which is worth knowing per-camera rather than
53719
54749
  * per-fleet. */
54750
+ /**
54751
+ * A frame `classifyFrame` called out of order: the tracker records the drop
54752
+ * (and any clock-step run it extends) and nothing else reads the frame. The
54753
+ * per-frame side tables a reader asks for afterwards are emptied, so it never
54754
+ * re-reads the previous frame's.
54755
+ */
54756
+ droppedFrame(input) {
54757
+ this.tracker.drop(input.timestamp, input.frame.detections.length);
54758
+ this.lastZoneOverlaps = /* @__PURE__ */ new Map();
54759
+ this.lastZoneRejections = /* @__PURE__ */ new Map();
54760
+ this.lastLabelWriteRefusals = [];
54761
+ const fusions = this.tracker.drainFusions();
54762
+ this.applyFusions(fusions);
54763
+ return {
54764
+ deviceId: this.deviceId,
54765
+ timestamp: input.timestamp,
54766
+ frameWidth: input.frame.width,
54767
+ frameHeight: input.frame.height,
54768
+ tracked: [],
54769
+ objectEvents: [],
54770
+ appearanceEvents: [],
54771
+ rawTrackedDetections: [],
54772
+ stationaryConfirmed: [],
54773
+ stationaryWoken: [],
54774
+ stationaryContinued: 0,
54775
+ riderFolds: [],
54776
+ ...input.observe === true ? { observation: DROPPED_FRAME_OBSERVATION } : {},
54777
+ fusions,
54778
+ droppedOutOfOrder: true
54779
+ };
54780
+ }
53720
54781
  facesRefusedOutsideParent() {
53721
54782
  return this.faceOutsideParentCount;
53722
54783
  }
@@ -53739,6 +54800,7 @@ var FrameProcessor = class {
53739
54800
  }
53740
54801
  process(input) {
53741
54802
  const { timestamp, frame } = input;
54803
+ if (this.tracker.classifyFrame(timestamp) === "out-of-order") return this.droppedFrame(input);
53742
54804
  this.lastFrameT = timestamp;
53743
54805
  this.lastFrameWallAt = this.wallClock();
53744
54806
  const frameWidth = frame.width;
@@ -56490,239 +57552,6 @@ async function promoteTapeWindow(deps, input, signal) {
56490
57552
  };
56491
57553
  }
56492
57554
  //#endregion
56493
- //#region src/pipeline-analytics/store/tracking-tape-index-store.ts
56494
- /**
56495
- * @durable class=ledger owner=pipeline-analytics
56496
- * write="one row per PROMOTED tracking-tape window (`debug.tape.promote`);
56497
- * updated when the operator marks it reviewed or releases its pins;
56498
- * deleted together with its file by `debug.tape.delete`."
56499
- * retention="NOT an age clock and NOT track retention — a promoted window is
56500
- * history being labelled (D681). Bounded by the promotion budget
56501
- * (MAX_PROMOTED_TAPES = 60, 1 GiB): a promotion beyond it is REFUSED,
56502
- * never satisfied by evicting another row."
56503
- */
56504
- var TRACKING_TAPES_COLLECTION = "pipeline-analytics:tracking-tapes";
56505
- var TRACKING_TAPES_COLUMNS = [
56506
- {
56507
- name: "id",
56508
- type: "TEXT",
56509
- primaryKey: true,
56510
- notNull: true
56511
- },
56512
- {
56513
- name: "deviceId",
56514
- type: "INTEGER",
56515
- notNull: true
56516
- },
56517
- {
56518
- name: "scenario",
56519
- type: "TEXT",
56520
- notNull: true
56521
- },
56522
- {
56523
- name: "note",
56524
- type: "TEXT"
56525
- },
56526
- {
56527
- name: "lighting",
56528
- type: "TEXT",
56529
- notNull: true
56530
- },
56531
- {
56532
- name: "fromMs",
56533
- type: "INTEGER",
56534
- notNull: true
56535
- },
56536
- {
56537
- name: "toMs",
56538
- type: "INTEGER",
56539
- notNull: true
56540
- },
56541
- {
56542
- name: "state",
56543
- type: "TEXT",
56544
- notNull: true
56545
- },
56546
- {
56547
- name: "frames",
56548
- type: "INTEGER",
56549
- notNull: true
56550
- },
56551
- {
56552
- name: "skips",
56553
- type: "INTEGER",
56554
- notNull: true
56555
- },
56556
- {
56557
- name: "gaps",
56558
- type: "INTEGER",
56559
- notNull: true
56560
- },
56561
- {
56562
- name: "bytes",
56563
- type: "INTEGER",
56564
- notNull: true
56565
- },
56566
- {
56567
- name: "reviewed",
56568
- type: "BOOLEAN",
56569
- notNull: true
56570
- },
56571
- {
56572
- name: "trackIds",
56573
- type: "TEXT",
56574
- notNull: true
56575
- },
56576
- {
56577
- name: "createdAt",
56578
- type: "INTEGER",
56579
- notNull: true
56580
- },
56581
- {
56582
- name: "releasedAt",
56583
- type: "INTEGER"
56584
- },
56585
- {
56586
- name: "pinsSettled",
56587
- type: "BOOLEAN",
56588
- notNull: true
56589
- }
56590
- ];
56591
- var TrackingTapeRowSchema = object({
56592
- id: string(),
56593
- deviceId: number().int(),
56594
- scenario: string(),
56595
- note: string().nullable(),
56596
- lighting: TapeLightingSchema,
56597
- fromMs: number().int(),
56598
- toMs: number().int(),
56599
- state: _enum(["complete", "incomplete"]),
56600
- frames: number().int(),
56601
- skips: number().int(),
56602
- gaps: number().int(),
56603
- bytes: number().int(),
56604
- reviewed: boolean(),
56605
- trackIds: array(string()),
56606
- createdAt: number().int(),
56607
- releasedAt: number().int().nullable(),
56608
- pinsSettled: boolean()
56609
- });
56610
- var TrackIdsSchema = array(string());
56611
- var TrackingTapeIndexStore = class {
56612
- store;
56613
- logger;
56614
- constructor(deps) {
56615
- this.store = deps.store;
56616
- this.logger = deps.logger;
56617
- }
56618
- static async declare(store) {
56619
- await store.declareCollection.mutate({
56620
- collection: TRACKING_TAPES_COLLECTION,
56621
- columns: [...TRACKING_TAPES_COLUMNS],
56622
- indexes: [{
56623
- name: "idx_trackingtapes_device_created",
56624
- columns: ["deviceId", "createdAt"]
56625
- }]
56626
- });
56627
- }
56628
- async insert(row) {
56629
- const { id, trackIds, ...rest } = TrackingTapeRowSchema.parse(row);
56630
- await this.store.insert.mutate({
56631
- collection: TRACKING_TAPES_COLLECTION,
56632
- record: {
56633
- id,
56634
- data: {
56635
- ...rest,
56636
- trackIds: JSON.stringify(trackIds)
56637
- }
56638
- }
56639
- });
56640
- }
56641
- async get(id) {
56642
- const first = (await this.store.query.query({
56643
- collection: TRACKING_TAPES_COLLECTION,
56644
- filter: {
56645
- where: { id },
56646
- limit: 1
56647
- }
56648
- }))[0];
56649
- return first === void 0 ? null : this.parse(first.id, first.data);
56650
- }
56651
- async list(deviceId) {
56652
- const rows = await this.store.query.query({
56653
- collection: TRACKING_TAPES_COLLECTION,
56654
- filter: {
56655
- ...deviceId !== void 0 ? { where: { deviceId } } : {},
56656
- orderBy: {
56657
- field: "createdAt",
56658
- direction: "desc"
56659
- },
56660
- limit: 1e3
56661
- }
56662
- });
56663
- const out = [];
56664
- for (const r of rows) {
56665
- const parsed = this.parse(r.id, r.data);
56666
- if (parsed !== null) out.push(parsed);
56667
- }
56668
- return out;
56669
- }
56670
- async setReviewed(id, reviewed) {
56671
- await this.store.update.mutate({
56672
- collection: TRACKING_TAPES_COLLECTION,
56673
- id,
56674
- data: { reviewed }
56675
- });
56676
- }
56677
- /** Release AND unsettle in one row write: the unpins the release implies
56678
- * are owed from this moment, whether or not the caller lives to make them. */
56679
- async setReleased(id, atMs) {
56680
- await this.store.update.mutate({
56681
- collection: TRACKING_TAPES_COLLECTION,
56682
- id,
56683
- data: {
56684
- releasedAt: atMs,
56685
- pinsSettled: false
56686
- }
56687
- });
56688
- }
56689
- async setPinsSettled(id, settled) {
56690
- await this.store.update.mutate({
56691
- collection: TRACKING_TAPES_COLLECTION,
56692
- id,
56693
- data: { pinsSettled: settled }
56694
- });
56695
- }
56696
- async remove(id) {
56697
- await this.store.delete.mutate({
56698
- collection: TRACKING_TAPES_COLLECTION,
56699
- key: id
56700
- });
56701
- }
56702
- parse(id, data) {
56703
- let trackIds = [];
56704
- try {
56705
- trackIds = JSON.parse(String(data["trackIds"] ?? "[]"));
56706
- } catch {
56707
- trackIds = null;
56708
- }
56709
- const ids = TrackIdsSchema.safeParse(trackIds);
56710
- const parsed = TrackingTapeRowSchema.safeParse({
56711
- id,
56712
- ...data,
56713
- note: data["note"] ?? null,
56714
- releasedAt: data["releasedAt"] ?? null,
56715
- trackIds: ids.success ? ids.data : null
56716
- });
56717
- if (parsed.success) return parsed.data;
56718
- this.logger.warn("tracking tape index: skipped a malformed row", {
56719
- meta: { id },
56720
- ...typeof data["deviceId"] === "number" ? { tags: { deviceId: data["deviceId"] } } : {}
56721
- });
56722
- return null;
56723
- }
56724
- };
56725
- //#endregion
56726
57555
  //#region src/pipeline-analytics/tracking-bench/recorder/tape-status.ts
56727
57556
  /** The one node that may hold tapes: sqlite and bulk state are hub-only, and
56728
57557
  * an agent persists nothing (spec §3.1). */
@@ -87823,6 +88652,18 @@ function isRecord(x) {
87823
88652
  }
87824
88653
  //#endregion
87825
88654
  //#region src/pipeline-analytics/store/track-alias-store.ts
88655
+ /**
88656
+ * The alias collection.
88657
+ *
88658
+ * @durable class=ledger owner=pipeline-analytics
88659
+ * write="one row per fused piece (D683): aliasId = the absorbed provisional
88660
+ * id, trackId = its survivor; rewritten when the survivor is itself
88661
+ * absorbed by a later fusion"
88662
+ * retention="TRACK-OWNED by its survivor through the declared `trackId`
88663
+ * column: aliases leave with the survivor in the retention cascade, and
88664
+ * widen that cascade (`expandLeafIds`) to rows still keyed by an
88665
+ * absorbed id"
88666
+ */
87826
88667
  var TRACK_ALIASES_COLLECTION = "pipeline-analytics:track-aliases";
87827
88668
  var TRACK_ALIAS_COLUMNS = [
87828
88669
  {
@@ -88005,6 +88846,20 @@ var TrackAliasStore = class {
88005
88846
  dropped: 0
88006
88847
  }];
88007
88848
  }
88849
+ /**
88850
+ * The orphan audit's reclaim of ONE alias row: the row AND the `recent`
88851
+ * entry that would otherwise keep resolving it for up to an hour. `true`
88852
+ * only when a row was actually deleted. (`misses` needs nothing: a deleted
88853
+ * alias IS a non-alias, which is what a miss records.)
88854
+ */
88855
+ async deleteAlias(aliasId) {
88856
+ const { deleted } = await this.store.deleteWhere.mutate({
88857
+ collection: TRACK_ALIASES_COLLECTION,
88858
+ filter: { where: { id: aliasId } }
88859
+ });
88860
+ this.recent.delete(aliasId);
88861
+ return deleted > 0;
88862
+ }
88008
88863
  async deleteByTracks(trackIds) {
88009
88864
  const { deleted } = await this.store.deleteWhere.mutate({
88010
88865
  collection: TRACK_ALIASES_COLLECTION,
@@ -88931,7 +89786,8 @@ async function auditScope(scope, deps, limits, cursor) {
88931
89786
  stoppedBy,
88932
89787
  nextCursor: offset
88933
89788
  } });
88934
- 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: {
89789
+ const freedNothing = scope.sizeField === null ? tally.reclaimed === 0 : tally.bytesReclaimed === 0;
89790
+ if (limits.mode === "reclaim" && tally.orphans > 0 && freedNothing) deps.logger.warn("orphan audit: reclaim pass freed no bytes against a standing backlog", { meta: {
88935
89791
  collection: scope.collection,
88936
89792
  shard: scope.shard?.value ?? null,
88937
89793
  orphans: tally.orphans,
@@ -89045,6 +89901,40 @@ var ORPHAN_AUDIT_DEFAULTS = {
89045
89901
  pageSize: OrphanAuditPageSizeSchema.parse(void 0)
89046
89902
  };
89047
89903
  var RAW_DELETE_COLLECTIONS = new Set(["pipeline-analytics:object-events", "pipeline-analytics:plates"]);
89904
+ /**
89905
+ * The `deleteRow` port: ONE row by its primary key `id` (what every
89906
+ * raw-delete collection above declares), answering whether a row went.
89907
+ *
89908
+ * Through `deleteWhere`, because it is the delete that SAYS: `delete`'s output
89909
+ * is `z.void()`, and reading it as `!== undefined` answered `false` for every
89910
+ * row it really deleted — the audit counted every plates/object-events reclaim
89911
+ * as "found nothing", and the per-scope reclaim ceiling never tripped.
89912
+ */
89913
+ function settingsRowDeleter(store) {
89914
+ return async (collection, key) => {
89915
+ const { deleted } = await store.deleteWhere.mutate({
89916
+ collection,
89917
+ filter: { where: { id: key } }
89918
+ });
89919
+ return deleted > 0;
89920
+ };
89921
+ }
89922
+ /**
89923
+ * The `deleteRow` port AS PRODUCTION WIRES IT: {@link settingsRowDeleter} over
89924
+ * a store that may not be bound yet. Unbound THROWS — the engine counts that as
89925
+ * `reclaimFailed` and keeps the row — rather than answering "nothing deleted",
89926
+ * which is indistinguishable from a row already gone. A named factory so the
89927
+ * wiring itself is under test (D695 T7 review M1: the inline wiring could be
89928
+ * reverted to the original bug with every spec green);
89929
+ * `scripts/check-orphan-reclaimers.ts` checks `index.ts` uses it.
89930
+ */
89931
+ function settingsRowDeleterOf(getStore) {
89932
+ return async (collection, key) => {
89933
+ const store = getStore();
89934
+ if (store === null) throw new Error("orphan audit: settings store not bound");
89935
+ return await settingsRowDeleter(store)(collection, key);
89936
+ };
89937
+ }
89048
89938
  function buildOrphanReclaimer(ports) {
89049
89939
  return async (collection, key) => {
89050
89940
  if (collection === MEDIA_COLLECTION) {
@@ -89068,6 +89958,10 @@ function buildOrphanReclaimer(ports) {
89068
89958
  bytes: null
89069
89959
  };
89070
89960
  }
89961
+ if (collection === TRACK_ALIASES_COLLECTION) return {
89962
+ rows: await ports.deleteTrackAlias(key) ? 1 : 0,
89963
+ bytes: null
89964
+ };
89071
89965
  if (RAW_DELETE_COLLECTIONS.has(collection)) return {
89072
89966
  rows: await ports.deleteRow(collection, key) ? 1 : 0,
89073
89967
  bytes: null
@@ -97602,6 +98496,12 @@ var TRACKER_FACT_LOG_SAMPLE_EVERY = 10;
97602
98496
  * positive spawns (review M1: ~1 750 `new` decisions per camera per hour) —
97603
98497
  * are sampled harder; the per-minute summary carries their exact counts. */
97604
98498
  var TRACKER_PIECE_LOG_SAMPLE_EVERY = 50;
98499
+ /** Out-of-order frames (follow-up 617, F1: ~1 % of frames) log the first N,
98500
+ * then one in this many — every line carries the count; the per-minute
98501
+ * summary carries the exact `outOfOrderFrames`. */
98502
+ var TRACKER_OUT_OF_ORDER_LOG_SAMPLE_EVERY = 50;
98503
+ /** Clock-step WARNs past the first ten (fix round 3, MEDIUM-1). */
98504
+ var TRACKER_CLOCK_STEP_LOG_SAMPLE_EVERY = 50;
97605
98505
  /** First N, then one in `every` — the refusal stream's cadence (D391). */
97606
98506
  function sampledTrackerFact(count, every = TRACKER_FACT_LOG_SAMPLE_EVERY) {
97607
98507
  return count <= TRACKER_FACT_LOG_ALWAYS_FIRST || count % every === 0;
@@ -99431,6 +100331,7 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
99431
100331
  },
99432
100332
  listTrackGroups: (query) => center.listTrackGroups(query),
99433
100333
  injectTestEvent: (input) => center.injectTestEvent(input, { getSnapshot: (deviceId) => api.snapshot.getSnapshot.query({ deviceId }) }),
100334
+ testStateSummary: (rule) => center.testStateSummary(rule),
99434
100335
  timelapse: {
99435
100336
  store: center.timelapseStore,
99436
100337
  getRecordingConfig: (deviceId) => api.recordingArchive.getDeviceConfig.query({ deviceId }).catch(() => null),
@@ -102249,9 +103150,11 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
102249
103150
  let splitCount = 0;
102250
103151
  let ignoredCount = 0;
102251
103152
  let newCount = 0;
103153
+ let outOfOrderCount = 0;
103154
+ let clockStepCount = 0;
102252
103155
  const endedCounts = /* @__PURE__ */ new Map();
102253
103156
  const logLink = (f) => {
102254
- const meta = {
103157
+ const decision = {
102255
103158
  source,
102256
103159
  t: f.t,
102257
103160
  kind: f.kind,
@@ -102260,7 +103163,10 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
102260
103163
  cost: f.cost,
102261
103164
  gapMs: f.gapMs,
102262
103165
  reason: f.reason,
102263
- recognition: f.recognition,
103166
+ recognition: f.recognition
103167
+ };
103168
+ const meta = {
103169
+ ...decision,
102264
103170
  candidates: f.candidates
102265
103171
  };
102266
103172
  switch (f.kind) {
@@ -102298,7 +103204,14 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
102298
103204
  meta
102299
103205
  });
102300
103206
  return;
102301
- case "new":
103207
+ case "new": {
103208
+ if (f.reason === "clock-step") {
103209
+ trackerLog.debug("tracker: piece decided new — closed by a frame clock step", {
103210
+ tags: { deviceId },
103211
+ meta
103212
+ });
103213
+ return;
103214
+ }
102302
103215
  if (f.reason === "unconfirmed") {
102303
103216
  trackerLog.debug("tracker: piece decided new — never confirmed", {
102304
103217
  tags: { deviceId },
@@ -102306,6 +103219,17 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
102306
103219
  });
102307
103220
  return;
102308
103221
  }
103222
+ const rejectedPredecessors = confirmedPredecessorRejections(f.candidates);
103223
+ if (rejectedPredecessors.length > 0) {
103224
+ trackerLog.info("tracker: piece decided new — a confirmed predecessor was rejected", {
103225
+ tags: { deviceId },
103226
+ meta: {
103227
+ ...decision,
103228
+ rejectedPredecessors
103229
+ }
103230
+ });
103231
+ return;
103232
+ }
102309
103233
  newCount += 1;
102310
103234
  if (!sampledTrackerFact(newCount, TRACKER_PIECE_LOG_SAMPLE_EVERY)) return;
102311
103235
  trackerLog.info("tracker: piece decided new — no predecessor", {
@@ -102316,6 +103240,7 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
102316
103240
  }
102317
103241
  });
102318
103242
  return;
103243
+ }
102319
103244
  }
102320
103245
  };
102321
103246
  p.setTrackerObservers({
@@ -102336,6 +103261,39 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
102336
103261
  });
102337
103262
  },
102338
103263
  link: logLink,
103264
+ clockStep: (f) => {
103265
+ clockStepCount += 1;
103266
+ if (!sampledTrackerFact(clockStepCount, TRACKER_CLOCK_STEP_LOG_SAMPLE_EVERY)) return;
103267
+ trackerLog.warn("tracker: frame clock stepped back — tracklets ended, clock rebased", {
103268
+ tags: { deviceId },
103269
+ meta: {
103270
+ source,
103271
+ t: f.t,
103272
+ lastFrameT: f.lastFrameT,
103273
+ behindMs: f.behindMs,
103274
+ ended: f.ended,
103275
+ clockStepCount,
103276
+ sampledEvery: TRACKER_CLOCK_STEP_LOG_SAMPLE_EVERY
103277
+ }
103278
+ });
103279
+ },
103280
+ outOfOrder: (f) => {
103281
+ outOfOrderCount += 1;
103282
+ if (!sampledTrackerFact(outOfOrderCount, TRACKER_OUT_OF_ORDER_LOG_SAMPLE_EVERY)) return;
103283
+ trackerLog.info("tracker: out-of-order frame dropped — older than the latest frame", {
103284
+ tags: { deviceId },
103285
+ meta: {
103286
+ source,
103287
+ t: f.t,
103288
+ lastFrameT: f.lastFrameT,
103289
+ behindMs: f.behindMs,
103290
+ detections: f.detections,
103291
+ pendingClockStep: f.pendingClockStep,
103292
+ outOfOrderCount,
103293
+ sampledEvery: TRACKER_OUT_OF_ORDER_LOG_SAMPLE_EVERY
103294
+ }
103295
+ });
103296
+ },
102339
103297
  ended: (f) => {
102340
103298
  const k = `${f.reason}:${String(f.confirmed)}`;
102341
103299
  const n = (endedCounts.get(k) ?? 0) + 1;
@@ -102477,6 +103435,7 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
102477
103435
  });
102478
103436
  });
102479
103437
  this.reportTrackerSummary(deviceId, key, processor, "frame");
103438
+ if (result.droppedOutOfOrder === true) return;
102480
103439
  for (const r of processor.getLastLabelWriteRefusals()) {
102481
103440
  const message = r.reason.kind === "macro-class" ? "label tier collapse refused" : "label write refused";
102482
103441
  this.ctx.logger.info(message, {
@@ -107131,10 +108090,12 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
107131
108090
  bytes: count.bytes
107132
108091
  };
107133
108092
  },
107134
- deleteRow: async (collection, key) => await this.settingsStore?.delete.mutate({
107135
- collection,
107136
- key
107137
- }) !== void 0,
108093
+ deleteTrackAlias: async (aliasId) => {
108094
+ const aliasStore = this.trackAliasStore;
108095
+ if (aliasStore === null) throw new Error("track alias store not bound");
108096
+ return await aliasStore.deleteAlias(aliasId);
108097
+ },
108098
+ deleteRow: settingsRowDeleterOf(() => this.settingsStore),
107138
108099
  logger: this.ctx.logger
107139
108100
  }),
107140
108101
  now: () => Date.now(),