@camstack/addon-post-analysis 1.2.71 → 1.2.73

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 readDeviceStateFrom, A as TimelapseRuleInputSchema, B as customAction, C as NcSnoozeSchema, Ct as unknown, D as RECORDING_EXPORT_MAX_READ_BYTES, E as OpsLogEntrySchema, F as alarmPanelCapability, G as faceGalleryCapability, H as deriveRecordingMode, I as assertTimelapseCadences, J as isScheduleActive, L as audioMetricsCapability, M as TimelapseRuleSchema, N as TrackSourceSchema, O as RetrainStatusSchema, P as addonWidgetsSourceCapability, Q as plateGalleryCapability, R as buildEventKindDescriptor, S as NcSnoozeInputSchema, St as string, T as NcTaxonomySchema, V as defineCustomActions, W as encodeVectorBase64, X as notificationRulesCapability, Y as kebabToCamel, Z as pipelineAnalyticsCapability, _ as NcRuleInputSchema, _t as literal, at as errMsg, b as NcRuleTargetSchema, bt as partialRecord, c as EVENT_PAD_MS, ct as createEvent, d as NC_ALARM_SYSTEM_EVENT_KINDS, dt as nodePin, et as readTimelapseGeneratedAt, f as NC_CONDITION_CATALOG, ft as sleep, g as NcConditionDescriptorSchema, gt as discriminatedUnion, h as NC_TAXONOMY, ht as boolean, i as DETECTION_MACRO_CLASSES, it as zoneAnalyticsCapability, j as TimelapseRulePatchSchema, k as TIMELAPSE_DENSE_FLOOR_SEC, l as LabelAttributionSchema, lt as hydrateSchema, mt as array, n as DEFAULT_EVENT_COLOR, nt as vectorDimFromBase64, o as DeclaredDevices, ot as BaseAddon, p as NC_DEFAULT_SNOOZE_MINUTES, pt as _enum, q as isDetectionMacroClass, rt as videoclipsCapability, s as EVENT_KIND_BY_CAP, st as DeviceType, t as BaseDevice, tt as subKindsOf, u as MACRO_LABELS, ut as isDeviceScopedCap, v as NcRulePatchSchema, vt as number, w as NcSnoozeSuppressedSchema, wt as EventCategory, x as NcScheduleSchema, xt as record, y as NcRuleSchema, yt as object, z as cosineSimilarity } from "../dist-B--dE3Ry.mjs";
1
+ import { $ as plateGalleryCapability, A as TIMELAPSE_DENSE_FLOOR_SEC, B as cosineSimilarity, C as NcSnoozeSchema, Ct as string, D as OpsLogEntrySchema, E as NcTaxonomySchema, F as addonWidgetsSourceCapability, G as encodeVectorBase64, H as defineCustomActions, I as alarmPanelCapability, J as isDetectionMacroClass, K as faceGalleryCapability, L as assertTimelapseCadences, M as TimelapseRulePatchSchema, N as TimelapseRuleSchema, O as RECORDING_EXPORT_MAX_READ_BYTES, P as TrackSourceSchema, Q as pipelineAnalyticsCapability, R as audioMetricsCapability, S as NcSnoozeInputSchema, St as record, T as NcSystemEventKindSchema, Tt as EventCategory, U as deriveRecordingMode, V as customAction, X as kebabToCamel, Y as isScheduleActive, Z as notificationRulesCapability, _ as NcRuleInputSchema, _t as discriminatedUnion, at as zoneAnalyticsCapability, b as NcRuleTargetSchema, bt as object, c as EVENT_PAD_MS, ct as DeviceType, d as NC_ALARM_SYSTEM_EVENT_KINDS, dt as isDeviceScopedCap, et as readDeviceStateFrom, f as NC_CONDITION_CATALOG, ft as nodePin, g as NcConditionDescriptorSchema, gt as boolean, h as NC_TAXONOMY, ht as array, i as DETECTION_MACRO_CLASSES, it as videoclipsCapability, j as TimelapseRuleInputSchema, k as RetrainStatusSchema, l as LabelAttributionSchema, lt as createEvent, mt as _enum, n as DEFAULT_EVENT_COLOR, nt as subKindsOf, o as DeclaredDevices, ot as errMsg, p as NC_DEFAULT_SNOOZE_MINUTES, pt as sleep, rt as vectorDimFromBase64, s as EVENT_KIND_BY_CAP, st as BaseAddon, t as BaseDevice, tt as readTimelapseGeneratedAt, u as MACRO_LABELS, ut as hydrateSchema, v as NcRulePatchSchema, vt as literal, w as NcSnoozeSuppressedSchema, wt as unknown, x as NcScheduleSchema, xt as partialRecord, y as NcRuleSchema, yt as number, z as buildEventKindDescriptor } from "../dist-_D0jdUh4.mjs";
2
2
  import { t as __exportAll } from "../embedding-encoder/index.mjs";
3
3
  import * as fs from "node:fs";
4
4
  import { promises } from "node:fs";
@@ -1573,6 +1573,401 @@ var NC_ALARM_DECLARATION = {
1573
1573
  DeviceClass: NcAlarmPanelDevice
1574
1574
  };
1575
1575
  //#endregion
1576
+ //#region src/notification-center/artifact-index.ts
1577
+ var NC_ARTIFACTS_COLLECTION = "notification-center:artifacts";
1578
+ var NcArtifactKindSchema = _enum([
1579
+ "timelapse",
1580
+ "timelapse-preview",
1581
+ "summary-mosaic"
1582
+ ]);
1583
+ /**
1584
+ * How the viewer should DRAW it. Derived from the mime once, here, so no client
1585
+ * has to carry a mime table — and so a GIF that degraded to a JPEG is drawn as
1586
+ * the JPEG it actually is.
1587
+ */
1588
+ var NcArtifactMediaTypeSchema = _enum([
1589
+ "image",
1590
+ "gif",
1591
+ "video"
1592
+ ]);
1593
+ /** The relative hub path of the authenticated bytes route for an artefact id. */
1594
+ var NC_ARTIFACT_READ_PREFIX = "nc-artifact-read";
1595
+ var NcArtifactRecordSchema = object({
1596
+ /** The `NcArtifactStore` id — the same id the bytes route resolves. */
1597
+ id: string().min(1),
1598
+ kind: NcArtifactKindSchema,
1599
+ mediaType: NcArtifactMediaTypeSchema,
1600
+ mime: string().min(1),
1601
+ sizeBytes: number().int().nonnegative(),
1602
+ /** When the artefact was PRODUCED. */
1603
+ createdAt: number().int(),
1604
+ /** What the content is ABOUT (window end / event time). The sort key: a
1605
+ * timelapse rendered at 06:05 is about the night before, and a list ordered
1606
+ * by production time puts it in the wrong morning. */
1607
+ capturedAt: number().int(),
1608
+ /** After this the bytes are gone. Derived from the retained shelf's own age
1609
+ * bound — never a second retention policy. */
1610
+ expiresAt: number().int(),
1611
+ /** The camera the artefact is FILED under. Null only if it belongs to none. */
1612
+ deviceId: number().int().nullable(),
1613
+ /** Every camera the artefact covers, for display. */
1614
+ deviceIds: array(number().int()),
1615
+ ruleId: string().optional(),
1616
+ ruleName: string().optional(),
1617
+ title: string().optional(),
1618
+ /** A companion still for a video artefact (a timelapse's preview frame) —
1619
+ * what a list renders as the thumbnail. */
1620
+ posterArtifactId: string().optional(),
1621
+ /** RELATIVE hub path of the authenticated bytes route. Relative on purpose:
1622
+ * the viewer already holds the hub origin it is authenticated against, and
1623
+ * only the SIGNED plane needs to guess which host a stranger can reach. */
1624
+ downloadPath: string().min(1)
1625
+ });
1626
+ var NcArtifactQuerySchema = object({
1627
+ kinds: array(NcArtifactKindSchema).optional(),
1628
+ deviceIds: array(number().int()).optional(),
1629
+ ruleId: string().optional(),
1630
+ /** Inclusive `capturedAt` window. */
1631
+ fromMs: number().int().optional(),
1632
+ toMs: number().int().optional(),
1633
+ limit: number().int().min(1).max(200).default(50),
1634
+ offset: number().int().min(0).default(0)
1635
+ });
1636
+ var NC_ARTIFACTS_COLUMNS = [
1637
+ {
1638
+ name: "id",
1639
+ type: "TEXT",
1640
+ primaryKey: true,
1641
+ notNull: true
1642
+ },
1643
+ {
1644
+ name: "kind",
1645
+ type: "TEXT",
1646
+ notNull: true
1647
+ },
1648
+ {
1649
+ name: "deviceId",
1650
+ type: "INTEGER"
1651
+ },
1652
+ {
1653
+ name: "ruleId",
1654
+ type: "TEXT"
1655
+ },
1656
+ (
1657
+ /** `''` for a global artefact — see the header note on visibility. */
1658
+ {
1659
+ name: "ownerUserId",
1660
+ type: "TEXT",
1661
+ notNull: true
1662
+ }),
1663
+ {
1664
+ name: "capturedAt",
1665
+ type: "INTEGER",
1666
+ notNull: true
1667
+ },
1668
+ {
1669
+ name: "expiresAt",
1670
+ type: "INTEGER",
1671
+ notNull: true
1672
+ },
1673
+ (
1674
+ /** The FULL record (Zod-validated on read) — the scalars above are indexed
1675
+ * projections only. */
1676
+ {
1677
+ name: "record",
1678
+ type: "JSON",
1679
+ notNull: true
1680
+ })
1681
+ ];
1682
+ var NC_ARTIFACTS_INDEXES = [
1683
+ {
1684
+ name: "idx_nc_artifacts_captured",
1685
+ columns: ["capturedAt"]
1686
+ },
1687
+ {
1688
+ name: "idx_nc_artifacts_device",
1689
+ columns: ["deviceId", "capturedAt"]
1690
+ },
1691
+ {
1692
+ name: "idx_nc_artifacts_kind",
1693
+ columns: ["kind", "capturedAt"]
1694
+ },
1695
+ {
1696
+ name: "idx_nc_artifacts_expires",
1697
+ columns: ["expiresAt"]
1698
+ }
1699
+ ];
1700
+ /** Upper bound for a half-open `whereBetween` — the store has no `>=`. */
1701
+ var TIME_MAX = 864e13;
1702
+ var TIME_MIN = -864e13;
1703
+ /** Prune scan ceiling — the index is bounded by the shelf it mirrors (2000
1704
+ * entries by default), so this only has to be comfortably above it. */
1705
+ var NC_ARTIFACT_PRUNE_SCAN_LIMIT = 2e4;
1706
+ /** `image/gif` → `gif`. Keyed on what the bytes ARE, never on what was asked
1707
+ * for: a GIF that degraded to a still must be drawn as a still. */
1708
+ function mediaTypeForMime(mime) {
1709
+ if (mime === "image/gif") return "gif";
1710
+ if (mime.startsWith("video/")) return "video";
1711
+ return "image";
1712
+ }
1713
+ /** The relative hub path an authenticated client fetches the bytes from. */
1714
+ function artifactDownloadPath(addonId, artifactId) {
1715
+ return `/addon/${addonId}/${NC_ARTIFACT_READ_PREFIX}/${artifactId}`;
1716
+ }
1717
+ var NcArtifactIndex = class {
1718
+ store;
1719
+ logger;
1720
+ addonId;
1721
+ retentionMs;
1722
+ now;
1723
+ constructor(deps) {
1724
+ this.store = deps.store;
1725
+ this.logger = deps.logger;
1726
+ this.addonId = deps.addonId;
1727
+ this.retentionMs = deps.retentionMs;
1728
+ this.now = deps.now ?? (() => Date.now());
1729
+ }
1730
+ static async declare(store) {
1731
+ await store.declareCollection.mutate({
1732
+ collection: NC_ARTIFACTS_COLLECTION,
1733
+ columns: [...NC_ARTIFACTS_COLUMNS],
1734
+ indexes: [...NC_ARTIFACTS_INDEXES]
1735
+ });
1736
+ }
1737
+ /**
1738
+ * Record one retained artefact. Returns the row as the viewer will see it, or
1739
+ * null when the write failed — and a failure is LOGGED, per camera: an
1740
+ * artefact silently absent from the index reads to an operator as an artefact
1741
+ * that was never produced.
1742
+ */
1743
+ async record(input) {
1744
+ const createdAt = this.now();
1745
+ const record = NcArtifactRecordSchema.parse({
1746
+ id: input.id,
1747
+ kind: input.kind,
1748
+ mediaType: mediaTypeForMime(input.mime),
1749
+ mime: input.mime,
1750
+ sizeBytes: input.sizeBytes,
1751
+ createdAt,
1752
+ capturedAt: input.capturedAt,
1753
+ expiresAt: createdAt + this.retentionMs,
1754
+ deviceId: input.deviceId,
1755
+ deviceIds: [...input.deviceIds],
1756
+ ...input.ruleId !== void 0 ? { ruleId: input.ruleId } : {},
1757
+ ...input.ruleName !== void 0 ? { ruleName: input.ruleName } : {},
1758
+ ...input.title !== void 0 ? { title: input.title } : {},
1759
+ ...input.posterArtifactId !== void 0 ? { posterArtifactId: input.posterArtifactId } : {},
1760
+ downloadPath: artifactDownloadPath(this.addonId, input.id)
1761
+ });
1762
+ try {
1763
+ await this.store.set.mutate({
1764
+ collection: NC_ARTIFACTS_COLLECTION,
1765
+ key: record.id,
1766
+ value: {
1767
+ kind: record.kind,
1768
+ deviceId: record.deviceId,
1769
+ ruleId: record.ruleId ?? "",
1770
+ ownerUserId: input.ownerUserId ?? "",
1771
+ capturedAt: record.capturedAt,
1772
+ expiresAt: record.expiresAt,
1773
+ record
1774
+ }
1775
+ });
1776
+ return record;
1777
+ } catch (err) {
1778
+ this.logger.warn("artifact not indexed — it will not appear in the viewer", {
1779
+ ...record.deviceId !== null ? { tags: { deviceId: record.deviceId } } : {},
1780
+ meta: {
1781
+ artifactId: record.id,
1782
+ kind: record.kind,
1783
+ error: String(err)
1784
+ }
1785
+ });
1786
+ return null;
1787
+ }
1788
+ }
1789
+ /**
1790
+ * One page, newest `capturedAt` first.
1791
+ *
1792
+ * Expiry is a PREDICATE, not a post-filter: a row whose bytes the shelf has
1793
+ * already swept must not consume a slot of the page the caller asked for.
1794
+ */
1795
+ async list(query, viewer) {
1796
+ const filter = this.buildFilter(query, viewer);
1797
+ try {
1798
+ const [rows, total] = await Promise.all([this.store.query.query({
1799
+ collection: NC_ARTIFACTS_COLLECTION,
1800
+ filter: {
1801
+ ...filter,
1802
+ orderBy: {
1803
+ field: "capturedAt",
1804
+ direction: "desc"
1805
+ },
1806
+ limit: query.limit,
1807
+ offset: query.offset
1808
+ }
1809
+ }), this.store.count.query({
1810
+ collection: NC_ARTIFACTS_COLLECTION,
1811
+ filter
1812
+ })]);
1813
+ const artifacts = this.parseRows(rows);
1814
+ return {
1815
+ artifacts,
1816
+ total,
1817
+ hasMore: query.offset + artifacts.length < total
1818
+ };
1819
+ } catch (err) {
1820
+ this.logger.warn("artifact index query failed", { meta: { error: String(err) } });
1821
+ return {
1822
+ artifacts: [],
1823
+ total: 0,
1824
+ hasMore: false
1825
+ };
1826
+ }
1827
+ }
1828
+ /**
1829
+ * One artefact by id, or null when it does not exist, has expired, or belongs
1830
+ * to another user. All three answer the same way ON PURPOSE — a surface that
1831
+ * distinguishes "not yours" from "not there" tells a caller what other people
1832
+ * own.
1833
+ */
1834
+ async get(artifactId, viewer) {
1835
+ const row = await this.readRow(artifactId);
1836
+ if (row === null) return null;
1837
+ if (row.record.expiresAt <= this.now()) return null;
1838
+ if (!viewer.isAdmin && row.ownerUserId !== "" && row.ownerUserId !== viewer.userId) return null;
1839
+ return row.record;
1840
+ }
1841
+ /**
1842
+ * Resolve an id for the BYTES route — visibility-free by design.
1843
+ *
1844
+ * The route is served `authenticated`, so the hub has already established a
1845
+ * principal; what this answers is only "is this id an indexed artefact, and is
1846
+ * it still meant to exist". Per-user artefact scoping on the byte route would
1847
+ * be a second authorization model layered on an unguessable id, and this repo
1848
+ * has one of those already (`data-plane-access.ts`): a plane serving ONE item
1849
+ * the caller had to be told about is `authenticated`.
1850
+ */
1851
+ async resolve(artifactId) {
1852
+ return (await this.readRow(artifactId))?.record ?? null;
1853
+ }
1854
+ /** One row by primary key, parsed. Null when absent or no longer valid. */
1855
+ async readRow(artifactId) {
1856
+ let raw;
1857
+ try {
1858
+ raw = await this.store.get.query({
1859
+ collection: NC_ARTIFACTS_COLLECTION,
1860
+ key: artifactId
1861
+ });
1862
+ } catch (err) {
1863
+ this.logger.warn("artifact index lookup failed", { meta: {
1864
+ artifactId,
1865
+ error: String(err)
1866
+ } });
1867
+ return null;
1868
+ }
1869
+ if (typeof raw !== "object" || raw === null || Array.isArray(raw)) return null;
1870
+ const row = { ...raw };
1871
+ const parsed = NcArtifactRecordSchema.safeParse(row["record"]);
1872
+ if (!parsed.success) return null;
1873
+ const owner = row["ownerUserId"];
1874
+ return {
1875
+ record: parsed.data,
1876
+ ownerUserId: typeof owner === "string" ? owner : ""
1877
+ };
1878
+ }
1879
+ /**
1880
+ * Drop rows whose bytes are gone, in ONE `deleteWhere` rather than an N+1
1881
+ * drain.
1882
+ *
1883
+ * TWO halves, and only one of them is safe everywhere:
1884
+ *
1885
+ * - **Expired rows** are time-based and node-independent. Always swept.
1886
+ * - **Orphans** — rows whose file a byte/count bound took before the age bound
1887
+ * did — are found by reconciling against the shelf. That reconcile is only
1888
+ * valid for a caller whose shelf holds the bytes for the WHOLE index, and
1889
+ * the index is CENTRAL while the shelf is local: a second node running this
1890
+ * with its own (empty) shelf would read every row as an orphan and delete
1891
+ * the entire library. Hence `keptIds: null` — a caller that is not the
1892
+ * producing node states so, and the destructive half becomes unreachable
1893
+ * rather than merely un-called.
1894
+ *
1895
+ * @param keptIds the ids the caller's shelf holds, or `null` when this node is
1896
+ * not the one that produces artefacts.
1897
+ */
1898
+ async prune(keptIds) {
1899
+ let removed = 0;
1900
+ try {
1901
+ const expired = await this.store.deleteWhere.mutate({
1902
+ collection: NC_ARTIFACTS_COLLECTION,
1903
+ filter: { whereBetween: { expiresAt: [TIME_MIN, this.now() - 1] } }
1904
+ });
1905
+ removed += expired.deleted;
1906
+ if (keptIds === null) {
1907
+ if (removed > 0) this.logger.debug("artifact index pruned (expired only)", { meta: { removed } });
1908
+ return removed;
1909
+ }
1910
+ const alive = new Set(keptIds);
1911
+ const orphans = (await this.store.query.query({
1912
+ collection: NC_ARTIFACTS_COLLECTION,
1913
+ filter: { limit: NC_ARTIFACT_PRUNE_SCAN_LIMIT }
1914
+ })).map((r) => r.id).filter((id) => !alive.has(id));
1915
+ for (const id of orphans) {
1916
+ await this.store.delete.mutate({
1917
+ collection: NC_ARTIFACTS_COLLECTION,
1918
+ key: id
1919
+ });
1920
+ removed += 1;
1921
+ }
1922
+ if (removed > 0) this.logger.debug("artifact index pruned", { meta: {
1923
+ removed,
1924
+ expired: expired.deleted,
1925
+ orphaned: orphans.length
1926
+ } });
1927
+ return removed;
1928
+ } catch (err) {
1929
+ this.logger.warn("artifact index prune failed", { meta: { error: String(err) } });
1930
+ return removed;
1931
+ }
1932
+ }
1933
+ /** The visibility predicate: a caller sees global artefacts plus his own; an
1934
+ * admin sees every one. */
1935
+ visibility(viewer) {
1936
+ if (viewer.isAdmin) return {};
1937
+ return { whereIn: { ownerUserId: ["", viewer.userId] } };
1938
+ }
1939
+ buildFilter(query, viewer) {
1940
+ const whereIn = { ...this.visibility(viewer).whereIn };
1941
+ if (query.kinds !== void 0 && query.kinds.length > 0) whereIn["kind"] = [...query.kinds];
1942
+ if (query.deviceIds !== void 0 && query.deviceIds.length > 0) whereIn["deviceId"] = [...query.deviceIds];
1943
+ const whereBetween = {
1944
+ capturedAt: [query.fromMs ?? TIME_MIN, query.toMs ?? TIME_MAX],
1945
+ expiresAt: [this.now(), TIME_MAX]
1946
+ };
1947
+ return {
1948
+ ...query.ruleId !== void 0 ? { where: { ruleId: query.ruleId } } : {},
1949
+ ...Object.keys(whereIn).length > 0 ? { whereIn } : {},
1950
+ whereBetween
1951
+ };
1952
+ }
1953
+ /** Parse the JSON column, skipping rows that no longer validate. A degraded
1954
+ * row must never cost the whole page. */
1955
+ parseRows(rows) {
1956
+ const out = [];
1957
+ let skipped = 0;
1958
+ for (const row of rows) {
1959
+ const parsed = NcArtifactRecordSchema.safeParse(row.data["record"]);
1960
+ if (!parsed.success) {
1961
+ skipped += 1;
1962
+ continue;
1963
+ }
1964
+ out.push(parsed.data);
1965
+ }
1966
+ if (skipped > 0) this.logger.warn("artifact index rows skipped as invalid", { meta: { skipped } });
1967
+ return out;
1968
+ }
1969
+ };
1970
+ //#endregion
1576
1971
  //#region ../types/dist/node.mjs
1577
1972
  var node_exports = /* @__PURE__ */ __exportAll({
1578
1973
  FilesystemStorageProvider: () => FilesystemStorageProvider,
@@ -1963,6 +2358,61 @@ var NcArtifactPlane = class {
1963
2358
  }
1964
2359
  }
1965
2360
  /**
2361
+ * Publish bytes AND file them in the viewer's browsable index (task #36).
2362
+ *
2363
+ * Two differences from {@link publish}, both deliberate:
2364
+ *
2365
+ * 1. **The bytes are stored FIRST, and stored even when no public base URL
2366
+ * exists.** `publish` returns early on a loopback-only install because a
2367
+ * signed link nobody can open is worth nothing — but the VIEWER reaches
2368
+ * the hub directly over its own session, so an install with no tunnel
2369
+ * still gets a full artifact library. Making the index depend on a
2370
+ * reachable public address would have made the feature absent exactly on
2371
+ * the installs that are LAN-only.
2372
+ * 2. **It answers the artefact id**, so a caller can link one artefact to
2373
+ * another — a timelapse's poster is its preview frame, and the video row
2374
+ * cannot name it without knowing the id the preview got.
2375
+ *
2376
+ * Never throws, for the same reason `publish` does not: an artefact library
2377
+ * is an enhancement and must never cost a notification.
2378
+ */
2379
+ async publishIndexed(bytes, mime, meta) {
2380
+ let artifactId = null;
2381
+ try {
2382
+ const index = this.deps.index;
2383
+ const stored = await this.deps.store.put(bytes, mime, { retain: index !== void 0 });
2384
+ if (index !== void 0) artifactId = (await index.record({
2385
+ ...meta,
2386
+ id: stored.id,
2387
+ mime,
2388
+ sizeBytes: stored.bytes
2389
+ }))?.id ?? null;
2390
+ const baseUrl = await this.resolveBaseUrl();
2391
+ return {
2392
+ url: baseUrl === null ? null : buildArtifactUrl({
2393
+ baseUrl,
2394
+ routePrefix: this.deps.routePrefix,
2395
+ id: stored.id,
2396
+ secret: this.deps.secret,
2397
+ expMs: this.now() + (this.deps.ttlMs ?? DEFAULT_TTL_MS)
2398
+ }),
2399
+ artifactId
2400
+ };
2401
+ } catch (err) {
2402
+ this.deps.logger.warn("indexed artifact publish failed — shipping bytes only", {
2403
+ ...meta.deviceId !== null ? { tags: { deviceId: meta.deviceId } } : {},
2404
+ meta: {
2405
+ kind: meta.kind,
2406
+ error: String(err)
2407
+ }
2408
+ });
2409
+ return {
2410
+ url: null,
2411
+ artifactId
2412
+ };
2413
+ }
2414
+ }
2415
+ /**
1966
2416
  * Absolutize a path against the SAME base URL an artifact link would use.
1967
2417
  *
1968
2418
  * For links this plane does not host but a RECIPIENT still has to open — a
@@ -2028,6 +2478,124 @@ var NcArtifactPlane = class {
2028
2478
  }
2029
2479
  };
2030
2480
  //#endregion
2481
+ //#region src/notification-center/artifact-read-plane.ts
2482
+ /**
2483
+ * Parse a single-range `Range` header against a known length.
2484
+ *
2485
+ * Returns `null` when there is nothing to honour (absent, malformed, multi-range
2486
+ * — all of which mean "send the whole thing"), and `'unsatisfiable'` when the
2487
+ * client asked for a range that cannot exist, which is a 416 and not a 200.
2488
+ */
2489
+ function parseByteRange(header, length) {
2490
+ if (header === void 0) return null;
2491
+ const match = /^bytes=(\d*)-(\d*)$/.exec(header.trim());
2492
+ if (match === null) return null;
2493
+ const rawStart = match[1] ?? "";
2494
+ const rawEnd = match[2] ?? "";
2495
+ if (rawStart === "" && rawEnd === "") return null;
2496
+ if (length === 0) return "unsatisfiable";
2497
+ if (rawStart === "") {
2498
+ const suffix = Number(rawEnd);
2499
+ if (suffix === 0) return "unsatisfiable";
2500
+ return {
2501
+ start: Math.max(0, length - suffix),
2502
+ end: length - 1
2503
+ };
2504
+ }
2505
+ const start = Number(rawStart);
2506
+ if (start >= length) return "unsatisfiable";
2507
+ const end = rawEnd === "" ? length - 1 : Math.min(Number(rawEnd), length - 1);
2508
+ if (end < start) return "unsatisfiable";
2509
+ return {
2510
+ start,
2511
+ end
2512
+ };
2513
+ }
2514
+ /** The last path segment, percent-decoded. */
2515
+ function artifactIdFrom(req) {
2516
+ const url = new URL(req.url ?? "/", "http://placeholder");
2517
+ try {
2518
+ return decodeURIComponent(url.pathname.split("/").filter(Boolean).pop() ?? "");
2519
+ } catch {
2520
+ return "";
2521
+ }
2522
+ }
2523
+ var NcArtifactReadPlane = class {
2524
+ deps;
2525
+ constructor(deps) {
2526
+ this.deps = deps;
2527
+ }
2528
+ handler = async (req, res) => {
2529
+ const artifactId = artifactIdFrom(req);
2530
+ const record = await this.deps.index.resolve(artifactId);
2531
+ if (record === null) {
2532
+ this.refuse(res, 404, "not found");
2533
+ return;
2534
+ }
2535
+ const found = await this.deps.store.read(artifactId);
2536
+ if (found === null) {
2537
+ this.deps.logger.warn("artifact bytes gone — index row outlived the shelf", {
2538
+ ...record.deviceId !== null ? { tags: { deviceId: record.deviceId } } : {},
2539
+ meta: {
2540
+ artifactId,
2541
+ kind: record.kind,
2542
+ expiresAt: record.expiresAt
2543
+ }
2544
+ });
2545
+ this.refuse(res, 404, "not found");
2546
+ return;
2547
+ }
2548
+ if (req.method === "HEAD") {
2549
+ res.writeHead(200, this.baseHeaders(record, found.bytes.byteLength, found.mime));
2550
+ res.end();
2551
+ return;
2552
+ }
2553
+ const range = parseByteRange(headerOf(req, "range"), found.bytes.byteLength);
2554
+ if (range === "unsatisfiable") {
2555
+ res.writeHead(416, {
2556
+ "content-range": `bytes */${found.bytes.byteLength}`,
2557
+ "content-type": "text/plain"
2558
+ });
2559
+ res.end("range not satisfiable");
2560
+ return;
2561
+ }
2562
+ if (range === null) {
2563
+ res.writeHead(200, this.baseHeaders(record, found.bytes.byteLength, found.mime));
2564
+ res.end(found.bytes);
2565
+ return;
2566
+ }
2567
+ const slice = found.bytes.subarray(range.start, range.end + 1);
2568
+ res.writeHead(206, {
2569
+ ...this.baseHeaders(record, slice.byteLength, found.mime),
2570
+ "content-range": `bytes ${range.start}-${range.end}/${found.bytes.byteLength}`
2571
+ });
2572
+ res.end(slice);
2573
+ };
2574
+ baseHeaders(record, length, mime) {
2575
+ return {
2576
+ "content-type": mime,
2577
+ "content-length": String(length),
2578
+ "accept-ranges": "bytes",
2579
+ "cache-control": "private, max-age=3600",
2580
+ "content-disposition": `inline; filename="${filenameFor(record)}"`
2581
+ };
2582
+ }
2583
+ refuse(res, status, body) {
2584
+ res.writeHead(status, { "content-type": "text/plain" });
2585
+ res.end(body);
2586
+ }
2587
+ };
2588
+ /** A name a client will accept, from what the artefact IS. */
2589
+ function filenameFor(record) {
2590
+ const ext = record.mime === "video/mp4" ? "mp4" : record.mime === "image/gif" ? "gif" : record.mime === "image/png" ? "png" : "jpg";
2591
+ return `${record.kind}-${record.capturedAt}.${ext}`;
2592
+ }
2593
+ function headerOf(req, name) {
2594
+ const value = req.headers[name];
2595
+ if (Array.isArray(value)) return value[0];
2596
+ return value;
2597
+ }
2598
+ //#endregion
2031
2599
  //#region src/notification-center/artifact-store.ts
2032
2600
  /**
2033
2601
  * NcArtifactStore — the bytes behind a signed notification-artifact URL.
@@ -2042,9 +2610,25 @@ var NcArtifactPlane = class {
2042
2610
  * bites first, the oldest artifacts go. A sweep on boot clears whatever a crash
2043
2611
  * left behind — the directory is disposable by construction, so a lost file is
2044
2612
  * a 404 on one link, never a corrupt state.
2613
+ *
2614
+ * ## The retained shelf (`kept/`)
2615
+ *
2616
+ * Task #36 gave the viewer a browsable artifact index, and an index whose bytes
2617
+ * evaporate in six hours is an index of 404s. So a `put` may ask to be RETAINED:
2618
+ * the bytes then land on a second shelf inside the same directory with its OWN
2619
+ * three bounds (age, count, and — because a timelapse mp4 is megabytes — total
2620
+ * BYTES). It is still a scratch, just a longer one: the index row carries an
2621
+ * `expiresAt` derived from the same age bound, so "gone" is a state the viewer
2622
+ * is told about rather than a surprise 404.
2623
+ *
2624
+ * The shelf is a subdirectory ON PURPOSE. The scratch's `list()` skips
2625
+ * non-files, so the two shelves cannot sweep each other, and neither bound had
2626
+ * to learn about the other's entries.
2045
2627
  */
2046
2628
  var DEFAULT_MAX_AGE_MS = 6 * 36e5;
2047
2629
  var DEFAULT_MAX_ENTRIES = 500;
2630
+ /** The retained shelf's subdirectory name, inside the scratch directory. */
2631
+ var KEPT_SUBDIR = "kept";
2048
2632
  /** `image/jpeg` → `jpg`, so a fetched link has a name a client will accept. */
2049
2633
  function extensionFor(mime) {
2050
2634
  if (mime === "image/jpeg") return "jpg";
@@ -2058,6 +2642,10 @@ var NcArtifactStore = class {
2058
2642
  now;
2059
2643
  maxAgeMs;
2060
2644
  maxEntries;
2645
+ keptMaxAgeMs;
2646
+ keptMaxEntries;
2647
+ keptMaxBytes;
2648
+ keptDir;
2061
2649
  /** id → mime, so the route can answer with the right content-type without
2062
2650
  * re-deriving it from the extension on every request. */
2063
2651
  mimeById = /* @__PURE__ */ new Map();
@@ -2066,17 +2654,32 @@ var NcArtifactStore = class {
2066
2654
  this.now = options.now ?? (() => Date.now());
2067
2655
  this.maxAgeMs = options.maxAgeMs ?? DEFAULT_MAX_AGE_MS;
2068
2656
  this.maxEntries = options.maxEntries ?? DEFAULT_MAX_ENTRIES;
2657
+ this.keptMaxAgeMs = options.keptMaxAgeMs ?? 12096e5;
2658
+ this.keptMaxEntries = options.keptMaxEntries ?? 2e3;
2659
+ this.keptMaxBytes = options.keptMaxBytes ?? 2147483648;
2660
+ this.keptDir = path.join(options.dir, KEPT_SUBDIR);
2661
+ }
2662
+ /** How long a retained artifact stays fetchable — the index's `expiresAt`
2663
+ * is derived from this, so the two can never disagree. */
2664
+ get retentionMs() {
2665
+ return this.keptMaxAgeMs;
2069
2666
  }
2070
2667
  /** Create the directory and sweep whatever a previous run left behind. */
2071
2668
  async start() {
2072
2669
  await promises.mkdir(this.options.dir, { recursive: true });
2073
2670
  await this.sweep();
2074
2671
  }
2075
- /** Persist bytes and return the artifact handle (never throws on sweep). */
2076
- async put(bytes, mime) {
2672
+ /**
2673
+ * Persist bytes and return the artifact handle (never throws on sweep).
2674
+ *
2675
+ * The retained shelf is created LAZILY: an install that never produces a
2676
+ * browsable artifact keeps a directory that is empty when it is empty.
2677
+ */
2678
+ async put(bytes, mime, options = {}) {
2077
2679
  const id = `${this.now()}-${randomUUID()}`;
2078
- const file = path.join(this.options.dir, `${id}.${extensionFor(mime)}`);
2079
- await promises.mkdir(this.options.dir, { recursive: true });
2680
+ const dir = options.retain === true ? this.keptDir : this.options.dir;
2681
+ const file = path.join(dir, `${id}.${extensionFor(mime)}`);
2682
+ await promises.mkdir(dir, { recursive: true });
2080
2683
  await promises.writeFile(file, bytes);
2081
2684
  this.mimeById.set(id, mime);
2082
2685
  await this.sweep();
@@ -2089,7 +2692,7 @@ var NcArtifactStore = class {
2089
2692
  /** Read an artifact's bytes + mime, or null when it is gone (expired/swept). */
2090
2693
  async read(id) {
2091
2694
  if (!/^[0-9]+-[0-9a-f-]{36}$/i.test(id)) return null;
2092
- const found = (await this.list()).find((e) => e.id === id);
2695
+ const found = [...await this.list(this.options.dir), ...await this.list(this.keptDir)].find((e) => e.id === id);
2093
2696
  if (!found) return null;
2094
2697
  try {
2095
2698
  return {
@@ -2100,6 +2703,10 @@ var NcArtifactStore = class {
2100
2703
  return null;
2101
2704
  }
2102
2705
  }
2706
+ /** The ids still on the retained shelf — what the index reconciles against. */
2707
+ async keptIds() {
2708
+ return (await this.list(this.keptDir)).map((e) => e.id);
2709
+ }
2103
2710
  /** Delete every artifact (operator cleanup / shutdown). */
2104
2711
  async clear() {
2105
2712
  await promises.rm(this.options.dir, {
@@ -2108,13 +2715,39 @@ var NcArtifactStore = class {
2108
2715
  }).catch(() => {});
2109
2716
  this.mimeById.clear();
2110
2717
  }
2111
- /** Age- then count-bounded prune. Best-effort: a failed unlink is logged once. */
2718
+ /** Prune both shelves under their own bounds. Best-effort: a failed unlink is
2719
+ * logged once and the rest of the sweep still runs. */
2112
2720
  async sweep() {
2113
- const entries = await this.list();
2114
- const cutoff = this.now() - this.maxAgeMs;
2721
+ await this.sweepShelf(await this.list(this.options.dir), {
2722
+ maxAgeMs: this.maxAgeMs,
2723
+ maxEntries: this.maxEntries
2724
+ });
2725
+ await this.sweepShelf(await this.list(this.keptDir), {
2726
+ maxAgeMs: this.keptMaxAgeMs,
2727
+ maxEntries: this.keptMaxEntries,
2728
+ maxBytes: this.keptMaxBytes
2729
+ });
2730
+ }
2731
+ /**
2732
+ * Age first, then count, then total bytes — newest always survives.
2733
+ *
2734
+ * The newest entry is never swept by the byte bound even when it alone
2735
+ * exceeds it: a shelf that deletes what it has just written can serve
2736
+ * nothing, and the caller has already paid for the render.
2737
+ */
2738
+ async sweepShelf(entries, bounds) {
2739
+ const cutoff = this.now() - bounds.maxAgeMs;
2115
2740
  const doomed = entries.filter((e) => e.mtimeMs < cutoff);
2116
- const survivors = entries.filter((e) => e.mtimeMs >= cutoff).toSorted((a, b) => b.mtimeMs - a.mtimeMs);
2117
- doomed.push(...survivors.slice(this.maxEntries));
2741
+ const fresh = entries.filter((e) => e.mtimeMs >= cutoff).toSorted((a, b) => b.mtimeMs - a.mtimeMs);
2742
+ doomed.push(...fresh.slice(bounds.maxEntries));
2743
+ const maxBytes = bounds.maxBytes;
2744
+ if (maxBytes !== void 0) {
2745
+ let running = 0;
2746
+ for (const [i, e] of fresh.slice(0, bounds.maxEntries).entries()) {
2747
+ running += e.sizeBytes;
2748
+ if (running > maxBytes && i > 0) doomed.push(e);
2749
+ }
2750
+ }
2118
2751
  for (const e of doomed) try {
2119
2752
  await promises.rm(e.file, { force: true });
2120
2753
  this.mimeById.delete(e.id);
@@ -2125,23 +2758,24 @@ var NcArtifactStore = class {
2125
2758
  } });
2126
2759
  }
2127
2760
  }
2128
- async list() {
2761
+ async list(dir) {
2129
2762
  let names;
2130
2763
  try {
2131
- names = await promises.readdir(this.options.dir);
2764
+ names = await promises.readdir(dir);
2132
2765
  } catch {
2133
2766
  return [];
2134
2767
  }
2135
2768
  const out = [];
2136
2769
  for (const name of names) {
2137
- const file = path.join(this.options.dir, name);
2770
+ const file = path.join(dir, name);
2138
2771
  try {
2139
2772
  const st = await promises.stat(file);
2140
2773
  if (!st.isFile()) continue;
2141
2774
  out.push({
2142
2775
  id: name.slice(0, name.lastIndexOf(".")),
2143
2776
  file,
2144
- mtimeMs: st.mtimeMs
2777
+ mtimeMs: st.mtimeMs,
2778
+ sizeBytes: st.size
2145
2779
  });
2146
2780
  } catch {}
2147
2781
  }
@@ -2904,6 +3538,17 @@ var NcDeviceDirectory = class {
2904
3538
  size() {
2905
3539
  return this.devices.size;
2906
3540
  }
3541
+ /**
3542
+ * Every identity in the current snapshot.
3543
+ *
3544
+ * Exists for the ONE caller that needs the whole directory rather than one
3545
+ * id: the liveness ledger's boot seed, which has to know what every device
3546
+ * was last known to be before the first event arrives. Off the event path by
3547
+ * construction — it is called once, from `start()`.
3548
+ */
3549
+ all() {
3550
+ return [...this.devices.values()];
3551
+ }
2907
3552
  /** Has a refresh ever succeeded? A cold directory notifies about nothing. */
2908
3553
  isWarm() {
2909
3554
  return this.loadedOnce;
@@ -3307,6 +3952,43 @@ function displayLabel(state) {
3307
3952
  return state.subLabel ?? state.label;
3308
3953
  }
3309
3954
  /**
3955
+ * The tiered columns a row should carry to AGREE with the track, or `undefined`
3956
+ * when the track says nothing yet.
3957
+ *
3958
+ * The projection is total: whichever tiers the track holds are copied, and a
3959
+ * track holding neither yields `undefined` rather than a patch of blanks — the
3960
+ * caller then leaves the row exactly as it was.
3961
+ */
3962
+ function knownTrackLabelPatch(state) {
3963
+ if (state === void 0) return void 0;
3964
+ if (state.label === void 0 && state.subLabel === void 0) return void 0;
3965
+ return {
3966
+ ...state.label !== void 0 ? { label: state.label } : {},
3967
+ ...state.labelScore !== void 0 ? { labelScore: state.labelScore } : {},
3968
+ ...state.labelMeta !== void 0 ? { labelMeta: state.labelMeta } : {},
3969
+ ...state.subLabel !== void 0 ? { subLabel: state.subLabel } : {},
3970
+ ...state.subLabelScore !== void 0 ? { subLabelScore: state.subLabelScore } : {},
3971
+ ...state.subLabelMeta !== void 0 ? { subLabelMeta: state.subLabelMeta } : {}
3972
+ };
3973
+ }
3974
+ /**
3975
+ * Does this row already say what the subject IS?
3976
+ *
3977
+ * The gate behind `DetailDispatcher.hasTrackLabel`: a periodic face-detection
3978
+ * refresh on a track that already has an answer runs geometry-only and drops
3979
+ * the ArcFace embedding. It reads {@link displayLabel} and not the `label`
3980
+ * column, and the difference is the whole point — an identity is a TIER-2
3981
+ * value and lands in `subLabel`, so the column read reported "unlabelled" for
3982
+ * every track ArcFace had already named and kept re-embedding it forever,
3983
+ * while suppressing the face chain on tracks that carried only a tier-1
3984
+ * sub-class (`van`). Exactly backwards, in both directions.
3985
+ */
3986
+ function hasResolvedLabel(state) {
3987
+ if (state === null || state === void 0) return false;
3988
+ const label = displayLabel(state);
3989
+ return label !== void 0 && label.length > 0;
3990
+ }
3991
+ /**
3310
3992
  * The display label of a frame-path tracked detection — the same
3311
3993
  * `subLabel ?? label` question asked of a patch rather than a row. Used by
3312
3994
  * every caller that wants the caption, not the tier (media captions, lifecycle
@@ -3316,6 +3998,36 @@ function patchDisplayLabel(patch) {
3316
3998
  if (patch === void 0) return void 0;
3317
3999
  return patch.subLabel ?? patch.label;
3318
4000
  }
4001
+ /**
4002
+ * WHAT a tier-2 label is about — a recognised PERSON or a read PLATE.
4003
+ *
4004
+ * Derived from the deciding step, never stored a second time: the step that
4005
+ * wrote the value is already on the attribution, and a second column stating
4006
+ * the same thing is a knob that can disagree with the first (D62). Both the
4007
+ * machine step and the operator override of each family are listed, because a
4008
+ * hand-assigned identity is still an identity.
4009
+ *
4010
+ * Callers use it to pick a SENTENCE: "Gianluca è arrivato" and "Veicolo AB123CD"
4011
+ * are different keys in the notification text catalog, and a single
4012
+ * `{{label}}` slot cannot word both.
4013
+ */
4014
+ var FACE_LABEL_STEP_IDS = new Set([
4015
+ "face-recognizer",
4016
+ "face-embedding",
4017
+ "operator:face-gallery"
4018
+ ]);
4019
+ /** Steps whose tier-2 value is a PLATE / vehicle name. See {@link FACE_LABEL_STEP_IDS}. */
4020
+ var PLATE_LABEL_STEP_IDS = new Set(["plate-ocr", "operator:plate-gallery"]);
4021
+ /**
4022
+ * Classify a tier-2 attribution. `undefined` for an unattributed value, a
4023
+ * migrated one, or any step outside the two recognition families (a species, a
4024
+ * sub-class) — the caller then falls back to the generic labelled sentence.
4025
+ */
4026
+ function labelKindOf(meta) {
4027
+ if (meta === void 0) return void 0;
4028
+ if (FACE_LABEL_STEP_IDS.has(meta.stepId)) return "identity";
4029
+ if (PLATE_LABEL_STEP_IDS.has(meta.stepId)) return "plate";
4030
+ }
3319
4031
  /** Whether an attribution came from the 4g migration rather than a real step. */
3320
4032
  function isMigrated(meta) {
3321
4033
  return meta?.stepId === MIGRATION_STEP_ID;
@@ -3371,7 +4083,8 @@ function resolveTieredLabelWrite(state, candidate) {
3371
4083
  const meta = {
3372
4084
  stepId: candidate.stepId,
3373
4085
  ...candidate.modelId !== void 0 ? { modelId: candidate.modelId } : {},
3374
- decidedAt: candidate.decidedAt
4086
+ decidedAt: candidate.decidedAt,
4087
+ ...candidate.identityId !== void 0 ? { identityId: candidate.identityId } : {}
3375
4088
  };
3376
4089
  const incumbent = candidate.tier === 1 ? state.label : state.subLabel;
3377
4090
  const incumbentScore = candidate.tier === 1 ? state.labelScore : state.subLabelScore;
@@ -3593,6 +4306,7 @@ function subjectFromObjectEvent(ev) {
3593
4306
  timestamp: ev.timestamp,
3594
4307
  classNames: [ev.className],
3595
4308
  ...displayLabel(ev) !== void 0 ? { label: displayLabel(ev) } : {},
4309
+ ...recognitionPatch(ev.subLabelMeta),
3596
4310
  ...ev.confidence !== void 0 ? { confidence: ev.confidence } : {},
3597
4311
  zones: ev.zones ?? [],
3598
4312
  ...ev.trackId !== void 0 ? { trackId: ev.trackId } : {},
@@ -3606,6 +4320,19 @@ function subjectFromObjectEvent(ev) {
3606
4320
  function bboxPatch(bbox) {
3607
4321
  return bbox !== void 0 ? { bbox } : {};
3608
4322
  }
4323
+ /**
4324
+ * The recognition half of a subject, read off the tier-2 attribution: WHICH
4325
+ * gallery row and WHAT KIND of thing. Both absent on an unattributed label, so
4326
+ * every condition that depends on them fails closed for a legacy record — which
4327
+ * is why the id/name matcher keeps its name leg.
4328
+ */
4329
+ function recognitionPatch(meta) {
4330
+ const kind = labelKindOf(meta);
4331
+ return {
4332
+ ...meta?.identityId !== void 0 ? { identityId: meta.identityId } : {},
4333
+ ...kind !== void 0 ? { labelKind: kind } : {}
4334
+ };
4335
+ }
3609
4336
  /** Convert a normalized 0..1 track envelope (min/max) into the subject's
3610
4337
  * top-left+size `BboxRect`. `undefined` in ⇒ `undefined` out (fail closed). */
3611
4338
  function envelopeToBbox(envelope) {
@@ -3770,6 +4497,7 @@ function subjectFromPackageEvent(ev, phase) {
3770
4497
  timestamp: ev.timestamp,
3771
4498
  classNames: [ev.className],
3772
4499
  ...displayLabel(ev) !== void 0 ? { label: displayLabel(ev) } : {},
4500
+ ...recognitionPatch(ev.subLabelMeta),
3773
4501
  ...ev.confidence !== void 0 ? { confidence: ev.confidence } : {},
3774
4502
  zones: ev.zones ?? [],
3775
4503
  ...ev.trackId !== void 0 ? { trackId: ev.trackId } : {},
@@ -3788,6 +4516,7 @@ function subjectFromTrack(track, info) {
3788
4516
  timestamp: track.lastSeen,
3789
4517
  classNames: track.classes && track.classes.length > 0 ? track.classes : [track.className],
3790
4518
  ...displayLabel(track) !== void 0 ? { label: displayLabel(track) } : {},
4519
+ ...recognitionPatch(track.subLabelMeta),
3791
4520
  ...info?.bestConfidence !== void 0 ? { confidence: info.bestConfidence } : {},
3792
4521
  zones: track.zonesVisited,
3793
4522
  trackId: track.trackId,
@@ -3823,7 +4552,7 @@ function presentConditionIds(c) {
3823
4552
  if (c.zonesExclude !== void 0 && c.zonesExclude.length > 0) ids.push("zonesExclude");
3824
4553
  if (c.crossing !== void 0 && c.crossing !== "enter") ids.push("crossing");
3825
4554
  if (c.labelEquals !== void 0 && c.labelEquals.length > 0) ids.push("labelEquals");
3826
- if (c.identities !== void 0 && c.identities.length > 0) ids.push("identities");
4555
+ if (c.identities !== void 0) ids.push("identities");
3827
4556
  if (c.identitiesExclude !== void 0 && c.identitiesExclude.length > 0) ids.push("identitiesExclude");
3828
4557
  if (c.minLabelConfidence !== void 0) ids.push("minLabelConfidence");
3829
4558
  if (c.plates !== void 0) ids.push("plates");
@@ -3909,6 +4638,70 @@ function toLowerSet(values) {
3909
4638
  return new Set(values.map((v) => v.trim().toLowerCase()));
3910
4639
  }
3911
4640
  /**
4641
+ * Does the subject's identity appear in `scope`?
4642
+ *
4643
+ * TWO LEGS, both load-bearing, and the reason is the migration rather than
4644
+ * indecision. `scope` holds gallery ids after `NcRuleStore.load` has resolved
4645
+ * them; a name it could not resolve is left as it stands (reported, never
4646
+ * dropped), and a record written before ids reached `LabelAttribution` carries
4647
+ * a name and no id. Matching only the id would silence the first case; matching
4648
+ * only the name would re-introduce the rename bug the id exists to fix.
4649
+ *
4650
+ * The id leg is exact (a uuid has no case); the name leg is the historical
4651
+ * trim + lowercase comparison, unchanged, so a rule that matched yesterday
4652
+ * matches today.
4653
+ */
4654
+ function matchesIdentityScope(subject, scope) {
4655
+ if (subject.identityId !== void 0 && scope.includes(subject.identityId)) return true;
4656
+ const label = subject.label?.trim().toLowerCase();
4657
+ return label !== void 0 && toLowerSet(scope).has(label);
4658
+ }
4659
+ /**
4660
+ * Does this rule's match depend on RECOGNITION — a face identity or a plate
4661
+ * read — rather than on the detection alone?
4662
+ *
4663
+ * Derived from the conditions the rule already carries; nothing stores it. It
4664
+ * is what makes `waitForEnhancement` inert on a rule with nothing to wait for,
4665
+ * and what decides whether a track close owes an "…is Gianluca" follow-up.
4666
+ * `identitiesExclude` is deliberately NOT in the set: a veto is satisfied by
4667
+ * the ABSENCE of a recognition, so waiting for one would delay every
4668
+ * notification the rule exists to send.
4669
+ */
4670
+ function ruleAwaitsRecognition(rule) {
4671
+ return rule.conditions.identities !== void 0 || rule.conditions.plates !== void 0;
4672
+ }
4673
+ /**
4674
+ * Does this rule stop firing on the object event and fire at TRACK CLOSE?
4675
+ *
4676
+ * `waitForEnhancement` AND something to wait for AND an `immediate` delivery.
4677
+ * A `track-end` rule already fires at close, and a rule with no recognition
4678
+ * scope would be delayed for nothing — in both cases the flag is inert, and
4679
+ * that is deliberate: the operator sets one switch and the rule tells them
4680
+ * whether it applies rather than silently changing when a notification arrives.
4681
+ *
4682
+ * The consequence is stated where the flag is declared and repeated in the
4683
+ * editor: a deferred rule inherits TRACK-CLOSE semantics. Its `zones` condition
4684
+ * tests every zone the track visited, and a `crossing` condition can no longer
4685
+ * be satisfied. Nothing is remembered between the two moments — the closed
4686
+ * track IS the evaluation, so a respawn in the middle costs nothing and the
4687
+ * doorbell-mirror failure (D49) has no surface here.
4688
+ */
4689
+ function ruleDefersToTrackEnd(rule) {
4690
+ return rule.waitForEnhancement === true && rule.delivery === "immediate" && ruleAwaitsRecognition(rule);
4691
+ }
4692
+ /**
4693
+ * Does a track close owe this rule an "…and it was Gianluca" SECOND
4694
+ * notification?
4695
+ *
4696
+ * The mirror of {@link ruleDefersToTrackEnd}: an `immediate` recognition rule
4697
+ * that did NOT wait fired seconds before the face was confirmed, so its
4698
+ * notification is true and incomplete. The upgrade is what makes "off" an
4699
+ * honest setting rather than a silent downgrade.
4700
+ */
4701
+ function ruleUpgradesAtTrackEnd(rule) {
4702
+ return rule.waitForEnhancement !== true && rule.delivery === "immediate" && ruleAwaitsRecognition(rule);
4703
+ }
4704
+ /**
3912
4705
  * Expand each selected class id to itself PLUS its taxonomy leaf subs, so a
3913
4706
  * MACRO selection (`vehicle`) matches its sub classNames (`car`/`truck`) — the
3914
4707
  * `EVENT_TAXONOMY` tree is the SINGLE source (mirrors the timeline/filter
@@ -3974,7 +4767,9 @@ function evaluateRule(rule, subject, deviceState, zoneOwners) {
3974
4767
  if (rule.conditions.occupancy !== void 0) return fail("occupancy");
3975
4768
  if (rule.conditions.systemEvent !== void 0) return fail("systemEvent");
3976
4769
  } else {
3977
- if ((rule.delivery === "immediate" ? "object-event" : rule.delivery) !== subject.kind) return fail("delivery");
4770
+ if ((rule.delivery === "immediate" ? "object-event" : rule.delivery) !== subject.kind) {
4771
+ if (!(subject.kind === "track-end" && ruleDefersToTrackEnd(rule))) return fail("delivery");
4772
+ }
3978
4773
  if (rule.conditions.occupancy !== void 0) return fail("occupancy");
3979
4774
  if (rule.conditions.systemEvent !== void 0) return fail("systemEvent");
3980
4775
  if (rule.conditions.audio !== void 0) return fail("audio");
@@ -4044,17 +4839,17 @@ function evaluateRule(rule, subject, deviceState, zoneOwners) {
4044
4839
  if (c.labelEquals !== void 0 && c.labelEquals.length > 0) {
4045
4840
  if (label === void 0 || !toLowerSet(c.labelEquals).has(label)) return fail("labelEquals");
4046
4841
  }
4047
- if (c.identities !== void 0 && c.identities.length > 0) {
4048
- if (label === void 0 || !toLowerSet(c.identities).has(label)) return fail("identities");
4842
+ if (c.identities !== void 0) {
4843
+ if (!(c.identities.length > 0 ? matchesIdentityScope(subject, c.identities) : subject.labelKind === "identity")) return fail("identities");
4049
4844
  }
4050
4845
  if (c.identitiesExclude !== void 0 && c.identitiesExclude.length > 0) {
4051
- if (label !== void 0 && toLowerSet(c.identitiesExclude).has(label)) return fail("identitiesExclude");
4846
+ if (matchesIdentityScope(subject, c.identitiesExclude)) return fail("identitiesExclude");
4052
4847
  }
4053
4848
  if (c.minLabelConfidence !== void 0) {
4054
4849
  if (subject.labelConfidence === void 0 || subject.labelConfidence < c.minLabelConfidence) return fail("minLabelConfidence");
4055
4850
  }
4056
4851
  if (c.plates !== void 0) {
4057
- if (subject.label === void 0 || !matchesPlate(subject.label, c.plates.values, c.plates.maxDistance)) return fail("plates");
4852
+ if (!(c.plates.values.length > 0 ? subject.label !== void 0 && matchesPlate(subject.label, c.plates.values, c.plates.maxDistance) : subject.labelKind === "plate")) return fail("plates");
4058
4853
  }
4059
4854
  if (c.sensorKinds !== void 0 && c.sensorKinds.length > 0) {
4060
4855
  if (subject.sensorKind === void 0 || !toLowerSet(c.sensorKinds).has(subject.sensorKind.trim().toLowerCase())) return fail("sensorKinds");
@@ -4119,6 +4914,27 @@ function keysPerClass(rule, subject) {
4119
4914
  if (subject.kind === "audio-event" || subject.kind === "audio-window") return true;
4120
4915
  return rule.throttle.granularity === "per-class";
4121
4916
  }
4917
+ /**
4918
+ * The cooldown scope of ONE system event — subject AND direction.
4919
+ *
4920
+ * The `kind` is here because a cooldown is a budget for VOLUME and must never
4921
+ * be a budget for a DIRECTION. `Eventi di sistema` (60 s, `rule-device`)
4922
+ * covers `device-offline` and `device-online` together, so on 2026-08-13 a
4923
+ * camera that came back 13 s after going down had its recovery suppressed:
4924
+ * the operator kept a "camera offline" as the last word while the camera was
4925
+ * up, and — because the liveness ledger HAD advanced on the suppressed flip —
4926
+ * the next outage was a genuine flip again and arrived as what read like a
4927
+ * duplicate. Same split D62 made one level up between `device-switch:<id>` and
4928
+ * `device:<id>`, for the same reason: two facts must not share a slot and
4929
+ * cancel each other out.
4930
+ *
4931
+ * ONE authority: `cooldownKey` and the boot-time cooldown reseed
4932
+ * (`NotificationCenter.seedCooldowns`) both call this. They used to build the
4933
+ * string separately, which is how a key format drifts across a restart.
4934
+ */
4935
+ function systemEventCooldownScope(systemEvent) {
4936
+ return `s:${systemEvent.subject}:k:${systemEvent.kind}`;
4937
+ }
4122
4938
  /** Stable cooldown key per the rule's throttle scope + class granularity. */
4123
4939
  /**
4124
4940
  * Does the rule's device scope cover this subject?
@@ -4145,8 +4961,9 @@ function matchesDeviceScope(scope, subject) {
4145
4961
  function cooldownKey(rule, subject) {
4146
4962
  const first = subject.classNames[0];
4147
4963
  const classKey = keysPerClass(rule, subject) && first !== void 0 ? `:c:${first}` : "";
4148
- const scopeRef = subject.systemEvent !== void 0 ? `s:${subject.systemEvent.subject}` : `d:${subject.deviceId}`;
4149
- return rule.throttle.scope === "rule" ? `r:${rule.id}${classKey}` : `r:${rule.id}:${scopeRef}${classKey}`;
4964
+ const scopeRef = subject.systemEvent !== void 0 ? systemEventCooldownScope(subject.systemEvent) : `d:${subject.deviceId}`;
4965
+ const kindKey = subject.systemEvent !== void 0 ? `:k:${subject.systemEvent.kind}` : "";
4966
+ return rule.throttle.scope === "rule" ? `r:${rule.id}${kindKey}${classKey}` : `r:${rule.id}:${scopeRef}${classKey}`;
4150
4967
  }
4151
4968
  /** True when the rule fired within its cooldown window before `now`. */
4152
4969
  function isCoolingDown(rule, lastFiredAt, now) {
@@ -4259,8 +5076,11 @@ var en_default = {
4259
5076
  "detection.title": "{{rule}}",
4260
5077
  "detection.body": "{{subject}} on {{camera}}{{inZones}}",
4261
5078
  "detection.body.trackEnd": "{{subject}} on {{camera}}{{inZones}} — visit ended",
5079
+ "detection.body.enhanced": "{{subject}} on {{camera}}{{inZones}} — now recognised",
4262
5080
  "detection.subject": "{{classNoun}}",
4263
5081
  "detection.subject.labelled": "{{classNoun}} ({{label}})",
5082
+ "detection.subject.identity": "{{label}}",
5083
+ "detection.subject.plate": "{{classNoun}} {{label}}",
4264
5084
  "detection.inZones": " in {{zones}}",
4265
5085
  "class.person": {
4266
5086
  "one": "person",
@@ -4340,6 +5160,7 @@ var en_default = {
4340
5160
  "system.camera-disabled.body": "Device {{deviceName}} was switched off",
4341
5161
  "system.camera-enabled.title": "Device switched on",
4342
5162
  "system.camera-enabled.body": "Device {{deviceName}} was switched back on",
5163
+ "test.prefix": "[TEST] {{body}}",
4343
5164
  "timelapse.title": "Timelapse — {{camera}}",
4344
5165
  "timelapse.body.window": "{{rule}} — {{from}}→{{to}}",
4345
5166
  "timelapse.body.tooManyEvents": "too many events for dense sampling: video at a uniform cadence",
@@ -4422,8 +5243,11 @@ var it_default = {
4422
5243
  "detection.title": "{{rule}}",
4423
5244
  "detection.body": "{{subject}} su {{camera}}{{inZones}}",
4424
5245
  "detection.body.trackEnd": "{{subject}} su {{camera}}{{inZones}} — visita terminata",
5246
+ "detection.body.enhanced": "{{subject}} su {{camera}}{{inZones}} — ora riconosciuto",
4425
5247
  "detection.subject": "{{classNoun}}",
4426
5248
  "detection.subject.labelled": "{{classNoun}} ({{label}})",
5249
+ "detection.subject.identity": "{{label}}",
5250
+ "detection.subject.plate": "{{classNoun}} {{label}}",
4427
5251
  "detection.inZones": " in {{zones}}",
4428
5252
  "class.person": {
4429
5253
  "one": "persona",
@@ -4503,6 +5327,7 @@ var it_default = {
4503
5327
  "system.camera-disabled.body": "Il dispositivo {{deviceName}} è stato spento",
4504
5328
  "system.camera-enabled.title": "Dispositivo riacceso",
4505
5329
  "system.camera-enabled.body": "Il dispositivo {{deviceName}} è stato riacceso",
5330
+ "test.prefix": "[TEST] {{body}}",
4506
5331
  "timelapse.title": "Timelapse — {{camera}}",
4507
5332
  "timelapse.body.window": "{{rule}} — {{from}}→{{to}}",
4508
5333
  "timelapse.body.tooManyEvents": "troppi eventi per il campionamento denso: video a cadenza uniforme",
@@ -4777,7 +5602,7 @@ function ncSubjectText(texts, input) {
4777
5602
  vars: { classNoun }
4778
5603
  });
4779
5604
  return texts.text({
4780
- key: "detection.subject.labelled",
5605
+ key: subjectLabelKey(input.labelKind),
4781
5606
  vars: {
4782
5607
  classNoun,
4783
5608
  label: input.label
@@ -4785,6 +5610,16 @@ function ncSubjectText(texts, input) {
4785
5610
  });
4786
5611
  }
4787
5612
  /**
5613
+ * Which labelled-subject key a recognition gets. A kind the catalog does not
5614
+ * name falls back to the generic key rather than printing nothing — the same
5615
+ * direction `ncClassNoun` degrades in, and for the same reason.
5616
+ */
5617
+ function subjectLabelKey(kind) {
5618
+ if (kind === "identity") return "detection.subject.identity";
5619
+ if (kind === "plate") return "detection.subject.plate";
5620
+ return "detection.subject.labelled";
5621
+ }
5622
+ /**
4788
5623
  * ` in gate, drive` — INCLUDING its leading space, and empty when there are no
4789
5624
  * zones.
4790
5625
  *
@@ -5283,13 +6118,18 @@ var NcDispatcher = class {
5283
6118
  ...systemCount,
5284
6119
  frozen: systemEvent?.title ?? entry.payload.ruleName
5285
6120
  });
5286
- const body = this.renderText({
6121
+ const rendered = this.renderText({
5287
6122
  template: entry.payload.template?.body,
5288
6123
  key: bodyKeyFor(entry),
5289
6124
  vars,
5290
6125
  ...systemCount,
5291
6126
  ...systemEvent?.body !== void 0 ? { frozen: systemEvent.body } : {}
5292
6127
  });
6128
+ const body = entry.payload.synthetic === true ? this.renderText({
6129
+ key: "test.prefix",
6130
+ vars: { body: rendered },
6131
+ frozen: rendered
6132
+ }) : rendered;
5293
6133
  const attachments = [...await this.withArtifactUrls(await this.resolveAttachments(entry)), ...frozenArtifactAttachments(entry)];
5294
6134
  const params = pickParams(entry.payload.params);
5295
6135
  const actions = await this.mintActions(entry, target);
@@ -5646,7 +6486,8 @@ function buildTemplateVars(texts, entry, deviceName, zoneLabels) {
5646
6486
  ...detection.label !== void 0 ? { label: detection.label } : {}
5647
6487
  }) : systemEvent?.subject ?? ncSubjectText(texts, {
5648
6488
  className: subject.className,
5649
- ...subject.label !== void 0 ? { label: subject.label } : {}
6489
+ ...subject.label !== void 0 ? { label: subject.label } : {},
6490
+ ...subject.labelKind !== void 0 ? { labelKind: subject.labelKind } : {}
5650
6491
  }),
5651
6492
  inZones: ncInZonesText(texts, zoneLabels),
5652
6493
  scope: occupancy?.zone ?? deviceName,
@@ -5678,6 +6519,7 @@ function bodyKeyFor(entry) {
5678
6519
  const subject = entry.payload.subject;
5679
6520
  if (subject.systemEvent !== void 0) return subject.systemEvent.bodyKey ?? "";
5680
6521
  if (subject.occupancy !== void 0) return "occupancy.body";
6522
+ if (entry.payload.enhancement === true) return "detection.body.enhanced";
5681
6523
  return entry.recordKind === "track-end" ? "detection.body.trackEnd" : "detection.body";
5682
6524
  }
5683
6525
  /** The default TITLE key — same shape, same pre-catalog reasoning. */
@@ -6667,6 +7509,73 @@ var NcLivenessLedger = class {
6667
7509
  async load() {
6668
7510
  return (await this.ledger.load()).length;
6669
7511
  }
7512
+ /**
7513
+ * Boot seed from the DEVICE DIRECTORY — the second half of the reseed, and
7514
+ * the fix for the outage this ledger used to swallow.
7515
+ *
7516
+ * ## The hole
7517
+ *
7518
+ * {@link load} recovers what the ledger itself accepted. A device that has
7519
+ * never produced a liveness event since the ledger was created has no row at
7520
+ * all, so its FIRST observation ever seeds silently — and if that first
7521
+ * observation is the camera DYING, the outage is thrown away. Live cost,
7522
+ * 2026-08-13 21:36 CEST: the operator unplugged four cameras and received two
7523
+ * notifications. `device:584` and `device:587` had never spoken; their
7524
+ * unplugging was recorded and never announced. A camera that is quiet until
7525
+ * it dies is a camera whose death is silent, indefinitely.
7526
+ *
7527
+ * ## Why only the ONLINE ones
7528
+ *
7529
+ * The directory is the device manager's own `online` flag (D132 §3: the
7530
+ * authority seeds the mirror at boot and stays the authority). Seeding only
7531
+ * the devices it calls ONLINE is what makes this change one-directional:
7532
+ *
7533
+ * - a row seeded `online` can only ever turn a later `device.offline` into a
7534
+ * notification — a true sentence about a device that just went away;
7535
+ * - it can NEVER turn a `device.online` into one, and that is the only shape
7536
+ * the 2026-08-13 notification flood (D130) ever had.
7537
+ *
7538
+ * A device the directory calls offline — which is also what a cold or
7539
+ * still-settling directory would say — gets no row and keeps today's
7540
+ * behaviour exactly. Failing toward discard, unchanged.
7541
+ *
7542
+ * ## D49
7543
+ *
7544
+ * An EXISTING row is never overwritten. The ledger is the authority for what
7545
+ * it accepted; the directory is a mirror refreshed on a tick and may be
7546
+ * stale. A stale `online` overwriting an accepted `offline` would make the
7547
+ * camera's real recovery a no-flip and lose it. A failed directory read
7548
+ * simply yields an empty list here and seeds nothing.
7549
+ *
7550
+ * ## Staged, not persisted
7551
+ *
7552
+ * The seed is DERIVED from an authority that is read again on every boot, so
7553
+ * losing it costs nothing — the next boot re-derives it. `stage` therefore
7554
+ * advances the mirror without a write, which matters on this hub: the
7555
+ * pipeline-analytics runner respawns several times an hour, and persisting
7556
+ * ~500 re-derivable rows on each respawn would offer thousands of pointless
7557
+ * commits a day to the checkpoint lottery (D96). The first real
7558
+ * {@link observe} flip persists through the ordinary write-behind path.
7559
+ *
7560
+ * Returns how many rows were seeded, so the caller can say it out loud.
7561
+ */
7562
+ seedFromDirectory(identities, now) {
7563
+ let seeded = 0;
7564
+ for (const identity of identities) {
7565
+ if (identity.online !== true) continue;
7566
+ const subject = `device:${identity.deviceId}`;
7567
+ if (this.ledger.has(subject)) continue;
7568
+ this.ledger.stage({
7569
+ subject,
7570
+ state: "online",
7571
+ kind: "device-online",
7572
+ deviceId: identity.deviceId,
7573
+ updatedAt: now
7574
+ });
7575
+ seeded += 1;
7576
+ }
7577
+ return seeded;
7578
+ }
6670
7579
  /** The in-RAM mirror, subject-ordered so two readers agree. */
6671
7580
  snapshot() {
6672
7581
  return this.ledger.snapshot().toSorted((a, b) => a.subject.localeCompare(b.subject));
@@ -7159,7 +8068,7 @@ function probeOccupancyNow(input) {
7159
8068
  * field for them would be a cross-package change for a sentence.
7160
8069
  */
7161
8070
  function occupancyProbeLabel(occupancy, probe, cooldownSec) {
7162
- const classNote = impossibleClassNote(occupancy.className);
8071
+ const classNote = occupancyClassWarning(occupancy.className);
7163
8072
  if (probe.kind === "unavailable") {
7164
8073
  const reason = probe.reason === "no-snapshot" ? "no ZoneAnalytics snapshot for this camera — the condition fails closed until one arrives" : "that zone is not in the camera’s current snapshot — the condition fails closed (deleted zone?)";
7165
8074
  return classNote === null ? reason : `${classNote} · ${reason}`;
@@ -7184,7 +8093,7 @@ function occupancyProbeLabel(occupancy, probe, cooldownSec) {
7184
8093
  * pixel-identical to an empty parking space. `byClass` is MACRO-keyed, so that
7185
8094
  * counter is 0 structurally and no scene can change it.
7186
8095
  */
7187
- function impossibleClassNote(className) {
8096
+ function occupancyClassWarning(className) {
7188
8097
  if (className === void 0 || isDetectionMacroClass(className)) return null;
7189
8098
  return `⚠ “${className}” is a sub-type, not a macro class: this counter is always 0 (use ${[...DETECTION_MACRO_CLASSES].join(" / ")})`;
7190
8099
  }
@@ -7438,7 +8347,7 @@ var DEFAULT_DRAIN_BATCH = 20;
7438
8347
  var DEDUP_HYDRATE_WINDOW_MS = 168 * 36e5;
7439
8348
  /** Stable dedup id — the durable per-track/per-record fire guarantee. */
7440
8349
  function outboxEntryId(input) {
7441
- const dedupRef = input.trackId ?? input.recordId;
8350
+ const dedupRef = input.dedupRef ?? input.trackId ?? input.recordId;
7442
8351
  return `${input.ruleId}:${dedupRef}:${input.targetId}`;
7443
8352
  }
7444
8353
  var NcOutbox = class {
@@ -7990,7 +8899,130 @@ var NcRuleActionRunner = class {
7990
8899
  }
7991
8900
  };
7992
8901
  //#endregion
8902
+ //#region src/notification-center/rule-status.ts
8903
+ /**
8904
+ * The live occupancy of the scope ONE rule counts.
8905
+ *
8906
+ * `count`/`occupied` come from the SAME probe the dry-run uses
8907
+ * (`probeOccupancyNow`), so the status and the dry-run can never disagree about
8908
+ * a camera. `since` comes from the watcher's CONFIRMED row, which is a
8909
+ * different fact and the one the operator actually asks for: the probe reads a
8910
+ * level, the watcher records when that level was last confirmed to change.
8911
+ */
8912
+ var NcOccupancyRuleStatusSchema = object({
8913
+ kind: literal("occupancy"),
8914
+ deviceId: number().int(),
8915
+ /** Zone NAME when the snapshot knows one, else the zone id, else absent for
8916
+ * whole-frame — the words a card puts after "in". */
8917
+ scope: string().optional(),
8918
+ /** The class counted; absent = every class. */
8919
+ className: string().optional(),
8920
+ /** The capacity the predicate compares against (`<=C` keys on C+1). */
8921
+ threshold: number().int(),
8922
+ sustainSeconds: number().int(),
8923
+ /** The count read for the scope. Absent when the probe was unavailable. */
8924
+ count: number().int().optional(),
8925
+ /** `count >= threshold` at the probe. Absent when unavailable. */
8926
+ occupied: boolean().optional(),
8927
+ /** Frame time of the snapshot the count came from — how FRESH it is, which
8928
+ * is a different question from "now". */
8929
+ observedAt: number().int().optional(),
8930
+ /** When the watcher last CONFIRMED a change of this key's level. Absent when
8931
+ * the watcher holds no confirmed row (a cold boot, or a key nothing
8932
+ * watches). */
8933
+ since: number().int().optional(),
8934
+ /** Why there is no count: the two fail-closed shapes of the live path. */
8935
+ unavailable: _enum(["no-snapshot", "zone-absent"]).optional(),
8936
+ /** A scope the counter can never answer (a sub-type class) — stated, because
8937
+ * its reading of 0 is pixel-identical to an empty zone. */
8938
+ warning: string().optional()
8939
+ });
8940
+ /** One camera of a timelapse rule — each renders its own video. */
8941
+ var NcTimelapseDeviceStatusSchema = object({
8942
+ deviceId: number().int(),
8943
+ /** Last successful generation for THIS camera (the per-device map, with the
8944
+ * rule-wide fallback). Absent = never generated. */
8945
+ lastGeneratedAt: number().int().optional(),
8946
+ /** The artefact that generation produced, when it is still on the shelf. */
8947
+ lastArtifact: NcArtifactRecordSchema.optional(),
8948
+ /** A render this process is waiting on right now. */
8949
+ rendering: object({
8950
+ startedAt: number().int(),
8951
+ windowStartMs: number().int(),
8952
+ windowEndMs: number().int()
8953
+ }).optional()
8954
+ });
8955
+ var NcTimelapseRuleStatusSchema = object({
8956
+ kind: literal("timelapse"),
8957
+ devices: array(NcTimelapseDeviceStatusSchema),
8958
+ /** The most recent window that has already closed — what the next tick would
8959
+ * render for any camera that has not got it yet. Absent = none in the
8960
+ * lookback (a schedule that never closes, or a rule younger than its window). */
8961
+ lastWindow: object({
8962
+ startMs: number(),
8963
+ endMs: number()
8964
+ }).optional(),
8965
+ /** The next OPENING. `endMs` null = the close is beyond the scan horizon. */
8966
+ nextWindow: object({
8967
+ startMs: number(),
8968
+ endMs: number().nullable()
8969
+ }).optional(),
8970
+ /** How long after a close the producer waits for the footage to settle. */
8971
+ settleMs: number().int()
8972
+ });
8973
+ var NcRuleStatusSchema = discriminatedUnion("kind", [
8974
+ object({
8975
+ kind: literal("none"),
8976
+ reason: string()
8977
+ }),
8978
+ NcOccupancyRuleStatusSchema,
8979
+ NcTimelapseRuleStatusSchema
8980
+ ]);
8981
+ /**
8982
+ * Assemble the occupancy status from the probe and the watcher's confirmed row.
8983
+ *
8984
+ * Pure. The two inputs are deliberately separate rather than one blob: they
8985
+ * answer different questions from different substrates, and merging them at the
8986
+ * source would let a missing snapshot erase a `since` the watcher still knows.
8987
+ */
8988
+ function buildOccupancyStatus(input) {
8989
+ const { condition, probe, threshold } = input;
8990
+ const key = occupancyKey(input.deviceId, condition.zoneId, condition.className, threshold);
8991
+ const row = input.confirmed.find((r) => r.key === key);
8992
+ const scope = probe.kind === "probed" ? probe.zoneName ?? condition.zoneId : condition.zoneId;
8993
+ return {
8994
+ kind: "occupancy",
8995
+ deviceId: input.deviceId,
8996
+ ...scope !== void 0 ? { scope } : {},
8997
+ ...condition.className !== void 0 ? { className: condition.className } : {},
8998
+ threshold,
8999
+ sustainSeconds: condition.sustainSeconds,
9000
+ ...probe.kind === "probed" ? {
9001
+ count: probe.count,
9002
+ occupied: probe.subject.occupancy?.occupied === true,
9003
+ observedAt: probe.observedAt
9004
+ } : { unavailable: probe.reason },
9005
+ ...row !== void 0 ? { since: row.lastChangeAt } : {},
9006
+ ...input.warning !== void 0 ? { warning: input.warning } : {}
9007
+ };
9008
+ }
9009
+ //#endregion
7993
9010
  //#region src/notification-center/rule-store.ts
9011
+ /**
9012
+ * NcRuleStore — durable Notification Center rule set.
9013
+ *
9014
+ * Follows the `stationary-registry.ts` precedent: a declared SQLite
9015
+ * collection (via the central `SettingsStoreClient`) mirrored into an
9016
+ * in-memory cache. The cache serves the hot read path (per-persist rule
9017
+ * matching) with zero I/O; every mutation writes through to the store
9018
+ * FIRST and only then updates the cache (a failed persist never leaves a
9019
+ * phantom in-RAM rule).
9020
+ *
9021
+ * Cross-node note: the collection lives in the hub's centralized
9022
+ * settings-store, so a CRUD served on one node is visible to the
9023
+ * evaluating (post-processing) node after its periodic `load()` refresh —
9024
+ * bounded staleness, never wrongness (D8 reconcile-over-events).
9025
+ */
7994
9026
  var NC_RULES_COLLECTION = "notification-center:rules";
7995
9027
  var NC_RULES_COLUMNS = [
7996
9028
  {
@@ -8032,17 +9064,75 @@ var NC_RULES_INDEXES = [{
8032
9064
  name: "idx_nc_rules_enabled",
8033
9065
  columns: ["enabled"]
8034
9066
  }];
9067
+ /**
9068
+ * Resolve display names to gallery ids inside one rule's identity conditions.
9069
+ *
9070
+ * NON-DESTRUCTIVE and IDEMPOTENT, in that order of importance:
9071
+ *
9072
+ * - a value the map answers for becomes the id;
9073
+ * - a value it does not answer for is LEFT AS IT STANDS, not dropped. It is
9074
+ * either already an id (the second load of a migrated rule, and every load
9075
+ * after that), or a name the gallery no longer holds — and a rule whose
9076
+ * scope silently shrank is exactly the failure this repo keeps paying for.
9077
+ * The engine's name leg keeps such a value matching what it always matched;
9078
+ * the caller reports it once.
9079
+ * - nothing is written back. The migration is a READ projection, so a gallery
9080
+ * that failed to load, or loaded empty, cannot rewrite a rule into one that
9081
+ * matches nobody. The stored rule keeps the operator's words forever.
9082
+ *
9083
+ * Returns the rule unchanged (by identity) when nothing resolved, so `load`
9084
+ * can tell a migrated rule from an untouched one without comparing.
9085
+ */
9086
+ function migrateIdentityNames(rule, idsByName) {
9087
+ const identities = rule.conditions.identities;
9088
+ const excluded = rule.conditions.identitiesExclude;
9089
+ if (identities === void 0 && excluded === void 0) return {
9090
+ rule,
9091
+ unresolved: []
9092
+ };
9093
+ const unresolved = [];
9094
+ const known = new Set(idsByName.values());
9095
+ const resolve = (values) => values.map((value) => {
9096
+ if (known.has(value)) return value;
9097
+ const id = idsByName.get(value.trim().toLowerCase());
9098
+ if (id !== void 0) return id;
9099
+ unresolved.push(value);
9100
+ return value;
9101
+ });
9102
+ const nextIdentities = identities !== void 0 ? resolve(identities) : void 0;
9103
+ const nextExcluded = excluded !== void 0 ? resolve(excluded) : void 0;
9104
+ if (!(nextIdentities !== void 0 && !sameList(identities ?? [], nextIdentities) || nextExcluded !== void 0 && !sameList(excluded ?? [], nextExcluded))) return {
9105
+ rule,
9106
+ unresolved
9107
+ };
9108
+ return {
9109
+ rule: {
9110
+ ...rule,
9111
+ conditions: {
9112
+ ...rule.conditions,
9113
+ ...nextIdentities !== void 0 ? { identities: nextIdentities } : {},
9114
+ ...nextExcluded !== void 0 ? { identitiesExclude: nextExcluded } : {}
9115
+ }
9116
+ },
9117
+ unresolved
9118
+ };
9119
+ }
9120
+ function sameList(a, b) {
9121
+ return a.length === b.length && a.every((v, i) => v === b[i]);
9122
+ }
8035
9123
  var NcRuleStore = class {
8036
9124
  byId = /* @__PURE__ */ new Map();
8037
9125
  store;
8038
9126
  logger;
8039
9127
  now;
8040
9128
  newId;
9129
+ identityIdsByName;
8041
9130
  constructor(deps) {
8042
9131
  this.store = deps.store;
8043
9132
  this.logger = deps.logger;
8044
9133
  this.now = deps.now ?? (() => Date.now());
8045
9134
  this.newId = deps.newId ?? (() => randomUUID());
9135
+ if (deps.identityIdsByName !== void 0) this.identityIdsByName = deps.identityIdsByName;
8046
9136
  }
8047
9137
  static async declare(store) {
8048
9138
  await store.declareCollection.mutate({
@@ -8063,24 +9153,48 @@ var NcRuleStore = class {
8063
9153
  collection: NC_RULES_COLLECTION,
8064
9154
  filter: { limit: 1e4 }
8065
9155
  });
9156
+ const idsByName = await this.readIdentityIds();
8066
9157
  this.byId.clear();
8067
9158
  let skipped = 0;
9159
+ let migrated = 0;
9160
+ const unresolved = /* @__PURE__ */ new Set();
8068
9161
  for (const row of rows) {
8069
9162
  const parsed = NcRuleSchema.safeParse(row.data["rule"]);
8070
9163
  if (!parsed.success) {
8071
9164
  skipped += 1;
8072
9165
  continue;
8073
9166
  }
8074
- this.byId.set(parsed.data.id, parsed.data);
9167
+ const result = migrateIdentityNames(parsed.data, idsByName);
9168
+ if (result.rule !== parsed.data) migrated += 1;
9169
+ for (const name of result.unresolved) unresolved.add(name);
9170
+ this.byId.set(result.rule.id, result.rule);
8075
9171
  }
8076
9172
  this.logger.debug("notification rules loaded", { meta: {
8077
9173
  rules: this.byId.size,
8078
- ...skipped > 0 ? { skippedInvalid: skipped } : {}
9174
+ ...skipped > 0 ? { skippedInvalid: skipped } : {},
9175
+ ...migrated > 0 ? { identitiesResolvedToIds: migrated } : {}
8079
9176
  } });
9177
+ if (unresolved.size > 0) this.logger.info("notification rules name identities the gallery does not know", { meta: { names: [...unresolved].join(", ") } });
8080
9178
  } catch (err) {
8081
9179
  this.logger.warn("notification rules load failed", { meta: { error: String(err) } });
8082
9180
  }
8083
9181
  }
9182
+ /**
9183
+ * The name→id map, lower-cased, or EMPTY when there is none to be had.
9184
+ *
9185
+ * Empty is the safe answer and the only one: `migrateIdentityNames` resolves
9186
+ * nothing from an empty map and leaves every rule as the operator wrote it.
9187
+ * A gallery read that throws must never be able to change what a rule matches.
9188
+ */
9189
+ async readIdentityIds() {
9190
+ if (this.identityIdsByName === void 0) return /* @__PURE__ */ new Map();
9191
+ try {
9192
+ return await this.identityIdsByName();
9193
+ } catch (err) {
9194
+ this.logger.debug("identity gallery read failed — rules left unmigrated", { meta: { error: String(err) } });
9195
+ return /* @__PURE__ */ new Map();
9196
+ }
9197
+ }
8084
9198
  list() {
8085
9199
  return [...this.byId.values()].sort((a, b) => b.updatedAt - a.updatedAt);
8086
9200
  }
@@ -8984,6 +10098,47 @@ function findLastClosedWindow(input) {
8984
10098
  };
8985
10099
  return null;
8986
10100
  }
10101
+ /**
10102
+ * The next window that will OPEN after `nowMs`, and when it closes.
10103
+ *
10104
+ * The forward twin of {@link findLastClosedWindow}, and the answer to the only
10105
+ * question an operator has about a timelapse rule that produced nothing yet:
10106
+ * "when is it going to run?". Standing INSIDE a window reports the NEXT one,
10107
+ * not the current one — the current one's boundaries are already answerable
10108
+ * from the last close, and reporting "next: now" would read as a render that is
10109
+ * owed.
10110
+ *
10111
+ * Derived by scanning `isScheduleActive` for the same reason the backwards scan
10112
+ * is: that function is the authority on what a window means (midnight crossing,
10113
+ * `invert`, IANA timezone), and a second implementation of it is drift this
10114
+ * repo has already paid for.
10115
+ *
10116
+ * @returns `null` for an always-active schedule (it never opens — it never
10117
+ * closed either), and for one with no opening inside the lookahead. A window
10118
+ * whose CLOSE falls beyond the horizon is reported with `endMs: null`: the
10119
+ * opening is the fact the operator asked for, and clamping the close to the
10120
+ * scan floor would report a window that ends when the scan gave up.
10121
+ */
10122
+ function findNextWindowOpen(input) {
10123
+ const step = Math.max(1e3, input.stepMs ?? 6e4);
10124
+ const from = Math.floor(input.nowMs / step) * step;
10125
+ const ceiling = input.nowMs + input.lookaheadMs;
10126
+ const active = (t) => isScheduleActive(input.schedule, t);
10127
+ let startMs = null;
10128
+ for (let t = from; t + step <= ceiling; t += step) if (!active(t) && active(t + step)) {
10129
+ startMs = t + step;
10130
+ break;
10131
+ }
10132
+ if (startMs === null) return null;
10133
+ for (let t = startMs; t + step <= ceiling; t += step) if (!active(t + step)) return {
10134
+ startMs,
10135
+ endMs: t + step
10136
+ };
10137
+ return {
10138
+ startMs,
10139
+ endMs: null
10140
+ };
10141
+ }
8987
10142
  /** The canvas behind a tile whose aspect does not fill its cell. */
8988
10143
  var MOSAIC_BACKGROUND = {
8989
10144
  r: 17,
@@ -9615,11 +10770,23 @@ var NcSummaryProducer = class {
9615
10770
  this.tickMs = deps.tickMs ?? 6e4;
9616
10771
  this.texts = deps.texts ?? new NcTextCatalog();
9617
10772
  }
10773
+ /**
10774
+ * Boot: arm the interval, then fire the back-fill tick WITHOUT waiting on it.
10775
+ *
10776
+ * Same contract as the timelapse producer, and for the same reason: a digest
10777
+ * tick renders a mosaic and publishes it, so awaiting it here would hold
10778
+ * `NotificationCenter.start()` → the addon's `initialize()` → the runner's
10779
+ * capability manifest. `start()` resolving means the producer is running,
10780
+ * never that the back-fill has finished. See `timelapse-scheduler.start()`
10781
+ * for the incident this pins.
10782
+ */
9618
10783
  async start() {
9619
- await this.tick();
9620
10784
  this.timer = setInterval(() => {
9621
10785
  this.tick();
9622
10786
  }, this.tickMs);
10787
+ this.tick().catch((err) => {
10788
+ this.deps.logger.warn("summary back-fill tick failed", { meta: { error: String(err) } });
10789
+ });
9623
10790
  }
9624
10791
  stop() {
9625
10792
  if (this.timer !== null) {
@@ -10005,7 +11172,15 @@ var NcSummaryProducer = class {
10005
11172
  }
10006
11173
  const bytes = new Uint8Array(rendered.jpeg.byteLength);
10007
11174
  bytes.set(rendered.jpeg);
10008
- const url = await this.deps.publish(bytes, "image/jpeg");
11175
+ const { url } = await this.deps.publish(bytes, "image/jpeg", {
11176
+ kind: "summary-mosaic",
11177
+ deviceId: rule.deviceIds[0] ?? null,
11178
+ deviceIds: [...new Set(tiles.map((t) => t.candidate.deviceId))],
11179
+ capturedAt: window.endMs,
11180
+ ruleId: rule.id,
11181
+ ruleName: rule.name,
11182
+ ...rule.ownerUserId !== void 0 ? { ownerUserId: rule.ownerUserId } : {}
11183
+ });
10009
11184
  if (url === null) {
10010
11185
  this.deps.logger.warn("summary mosaic not published: no reachable public endpoint", { meta: { ruleId: rule.id } });
10011
11186
  return {
@@ -10538,7 +11713,9 @@ var NcTestEventInputSchema = object({
10538
11713
  "device-event",
10539
11714
  "package-event",
10540
11715
  "audio-event",
10541
- "occupancy-event"
11716
+ "audio-window",
11717
+ "occupancy-event",
11718
+ "system-event"
10542
11719
  ]),
10543
11720
  /** The CAMERA the event is attributed to (what a notification shows). */
10544
11721
  deviceId: number().int(),
@@ -10594,6 +11771,47 @@ var NcTestEventInputSchema = object({
10594
11771
  occupied: boolean(),
10595
11772
  threshold: number().int().min(1)
10596
11773
  }).optional(),
11774
+ /**
11775
+ * AUDIO-WINDOW: the confirmed sampling window.
11776
+ *
11777
+ * Every field of the engine's {@link NcAudioWindowSubject}, because
11778
+ * `matchesAudio` pairs a rule to the window that BELONGS to it — the spec
11779
+ * fields (`samplingSeconds`, `dbThreshold`, `specLabels`) are compared for
11780
+ * equality, so a window built from anything but the rule's own condition
11781
+ * fails closed and the test would report a rule that works as broken.
11782
+ */
11783
+ audioWindow: object({
11784
+ hitPercent: number().min(0).max(100),
11785
+ samplingSeconds: number().int().min(1).max(300),
11786
+ samples: number().int().min(0),
11787
+ hits: number().int().min(0),
11788
+ dbThreshold: number().optional(),
11789
+ peakDbfs: number().optional(),
11790
+ specLabels: array(string().min(1)).optional(),
11791
+ labels: array(string().min(1)).default([])
11792
+ }).optional(),
11793
+ /**
11794
+ * SYSTEM-EVENT: the normalized infrastructure event.
11795
+ *
11796
+ * The catalog KEYS travel, never the sentence — exactly as the live intake
11797
+ * freezes them (`event-intake.ts`), so a tested system notification is
11798
+ * worded by the same text catalog, in the same language, as the real one. A
11799
+ * tester that froze English prose here would be testing its own prose.
11800
+ */
11801
+ systemEvent: object({
11802
+ kind: NcSystemEventKindSchema,
11803
+ /** The dedup subject (`device:617`, `node:agent-1`, …). */
11804
+ subject: string().min(1),
11805
+ titleKey: string().min(1),
11806
+ bodyKey: string().min(1),
11807
+ textParams: record(string(), string()).default({}),
11808
+ /** The device the event is ABOUT; absent = an event with no device. */
11809
+ deviceId: number().int().optional(),
11810
+ /** `DeviceType` value — what a `deviceTypes` condition is judged against. */
11811
+ deviceType: string().min(1).optional(),
11812
+ nodeId: string().min(1).optional(),
11813
+ packageName: string().min(1).optional()
11814
+ }).optional(),
10597
11815
  /** The instant the event happened. Absent = now. Set it to test a schedule
10598
11816
  * condition without waiting for the hour. */
10599
11817
  timestamp: number().int().optional(),
@@ -10681,6 +11899,12 @@ function isSyntheticId(id) {
10681
11899
  function triggerCanCarryStill(kind) {
10682
11900
  return kind === "object-event" || kind === "package-event" || kind === "track-end" || kind === "device-event" || kind === "occupancy-event";
10683
11901
  }
11902
+ /** The detection provenance production stamps on a subject of this kind. */
11903
+ function syntheticSource(kind) {
11904
+ if (kind === "device-event") return "sensor";
11905
+ if (kind === "audio-window") return "audio";
11906
+ return "pipeline";
11907
+ }
10684
11908
  /**
10685
11909
  * Build the synthetic envelope. Pure: same input + same id + same clock ⇒ same
10686
11910
  * event, so the red-green test can assert that this subject and a
@@ -10694,7 +11918,7 @@ function buildSyntheticEvent(input, recordId, now) {
10694
11918
  subject: {
10695
11919
  kind,
10696
11920
  recordId,
10697
- deviceId: input.deviceId,
11921
+ deviceId: kind === "system-event" ? 0 : input.deviceId,
10698
11922
  ...input.sourceDeviceId !== void 0 && input.sourceDeviceId !== input.deviceId ? { sourceDeviceId: input.sourceDeviceId } : {},
10699
11923
  timestamp,
10700
11924
  classNames: input.classNames,
@@ -10702,7 +11926,7 @@ function buildSyntheticEvent(input, recordId, now) {
10702
11926
  ...input.confidence !== void 0 ? { confidence: input.confidence } : {},
10703
11927
  zones: input.zones,
10704
11928
  ...carriesTrackId ? { trackId: recordId } : {},
10705
- source: input.source ?? (kind === "device-event" ? "sensor" : "pipeline"),
11929
+ source: input.source ?? syntheticSource(kind),
10706
11930
  ...input.importance !== void 0 ? { importance: input.importance } : {},
10707
11931
  ...input.dwellSeconds !== void 0 ? { dwellSeconds: input.dwellSeconds } : {},
10708
11932
  ...input.labelConfidence !== void 0 ? { labelConfidence: input.labelConfidence } : {},
@@ -10711,6 +11935,28 @@ function buildSyntheticEvent(input, recordId, now) {
10711
11935
  ...input.packagePhase !== void 0 ? { packagePhase: input.packagePhase } : {},
10712
11936
  ...input.bbox !== void 0 ? { bbox: input.bbox } : {},
10713
11937
  ...input.crossing !== void 0 ? { crossing: input.crossing } : {},
11938
+ ...input.audioWindow !== void 0 ? { audioWindow: {
11939
+ hitPercent: input.audioWindow.hitPercent,
11940
+ samplingSeconds: input.audioWindow.samplingSeconds,
11941
+ samples: input.audioWindow.samples,
11942
+ hits: input.audioWindow.hits,
11943
+ ...input.audioWindow.dbThreshold !== void 0 ? { dbThreshold: input.audioWindow.dbThreshold } : {},
11944
+ ...input.audioWindow.peakDbfs !== void 0 ? { peakDbfs: input.audioWindow.peakDbfs } : {},
11945
+ ...input.audioWindow.specLabels !== void 0 ? { specLabels: input.audioWindow.specLabels } : {},
11946
+ labels: input.audioWindow.labels
11947
+ } } : {},
11948
+ ...input.systemEvent !== void 0 ? { systemEvent: {
11949
+ kind: input.systemEvent.kind,
11950
+ subject: input.systemEvent.subject,
11951
+ titleKey: input.systemEvent.titleKey,
11952
+ bodyKey: input.systemEvent.bodyKey,
11953
+ textParams: input.systemEvent.textParams,
11954
+ ...input.systemEvent.deviceId !== void 0 ? { deviceId: input.systemEvent.deviceId } : {},
11955
+ ...input.systemEvent.deviceType !== void 0 ? { deviceType: input.systemEvent.deviceType } : {},
11956
+ ...input.systemEvent.nodeId !== void 0 ? { nodeId: input.systemEvent.nodeId } : {},
11957
+ ...input.systemEvent.packageName !== void 0 ? { packageName: input.systemEvent.packageName } : {},
11958
+ data: { category: "synthetic.test" }
11959
+ } } : {},
10714
11960
  ...input.occupancy !== void 0 ? { occupancy: {
10715
11961
  ...input.occupancy.zoneId !== void 0 ? { zoneId: input.occupancy.zoneId } : {},
10716
11962
  ...input.occupancy.zoneName !== void 0 ? { zoneName: input.occupancy.zoneName } : {},
@@ -10999,7 +12245,7 @@ function unknownNcTextKeys(keys, bundles = NC_TEXT_BUNDLES) {
10999
12245
  * durable document stays the sparse map its schema describes.
11000
12246
  */
11001
12247
  function applyNcTextOverridePatch(current, language, patch) {
11002
- const next = { ...current[language] ?? {} };
12248
+ const next = { ...current[language] };
11003
12249
  for (const [key, value] of Object.entries(patch)) {
11004
12250
  if (value === null || value.trim().length === 0) {
11005
12251
  delete next[key];
@@ -11369,6 +12615,84 @@ function planDenseRanges(input) {
11369
12615
  denseSec
11370
12616
  };
11371
12617
  }
12618
+ function newGroup(event) {
12619
+ const classCounts = /* @__PURE__ */ new Map();
12620
+ if (event.className !== void 0) classCounts.set(event.className, 1);
12621
+ return {
12622
+ ...event.trackId !== void 0 ? { trackId: event.trackId } : {},
12623
+ firstSeen: event.timestamp,
12624
+ lastSeen: event.timestamp,
12625
+ classCounts
12626
+ };
12627
+ }
12628
+ function absorb(group, event) {
12629
+ if (event.timestamp < group.firstSeen) group.firstSeen = event.timestamp;
12630
+ if (event.timestamp > group.lastSeen) group.lastSeen = event.timestamp;
12631
+ if (event.className === void 0) return;
12632
+ group.classCounts.set(event.className, (group.classCounts.get(event.className) ?? 0) + 1);
12633
+ }
12634
+ /** The class most rows agreed on; ties go to whichever appeared first, so the
12635
+ * same window always produces the same sentence. */
12636
+ function dominantClass(group) {
12637
+ let best;
12638
+ let bestCount = 0;
12639
+ for (const [className, count] of group.classCounts) if (count > bestCount) {
12640
+ best = className;
12641
+ bestCount = count;
12642
+ }
12643
+ return best;
12644
+ }
12645
+ function toInterval(group) {
12646
+ const className = dominantClass(group);
12647
+ return {
12648
+ ...group.trackId !== void 0 ? { trackId: group.trackId } : {},
12649
+ firstSeen: group.firstSeen,
12650
+ lastSeen: group.lastSeen,
12651
+ ...className !== void 0 ? { className } : {}
12652
+ };
12653
+ }
12654
+ /**
12655
+ * Fold persisted tracks and object events into one subject-per-entry array.
12656
+ *
12657
+ * @returns intervals sorted by `firstSeen` ascending. A persisted track is
12658
+ * returned unchanged and its own events are discarded: the store knows when
12659
+ * the track really began, the events only know when the emitter chose to
12660
+ * write one, and widening a persisted interval from event timestamps would
12661
+ * move a range boundary for no operator-visible gain.
12662
+ */
12663
+ function mergeDetectionIntervals(input) {
12664
+ const gapMs = Math.max(0, input.tracklessGapMs ?? 1e4);
12665
+ const persistedIds = /* @__PURE__ */ new Set();
12666
+ for (const track of input.tracks) if (track.trackId !== void 0) persistedIds.add(track.trackId);
12667
+ const ordered = input.events.toSorted((a, b) => a.timestamp - b.timestamp);
12668
+ const byTrackId = /* @__PURE__ */ new Map();
12669
+ const trackless = [];
12670
+ const openByClass = /* @__PURE__ */ new Map();
12671
+ for (const event of ordered) {
12672
+ const trackId = event.trackId;
12673
+ if (trackId !== void 0) {
12674
+ if (persistedIds.has(trackId)) continue;
12675
+ const existing = byTrackId.get(trackId);
12676
+ if (existing === void 0) byTrackId.set(trackId, newGroup(event));
12677
+ else absorb(existing, event);
12678
+ continue;
12679
+ }
12680
+ const key = event.className ?? "";
12681
+ const open = openByClass.get(key);
12682
+ if (open !== void 0 && event.timestamp - open.lastSeen <= gapMs) {
12683
+ absorb(open, event);
12684
+ continue;
12685
+ }
12686
+ const group = newGroup(event);
12687
+ trackless.push(group);
12688
+ openByClass.set(key, group);
12689
+ }
12690
+ return [
12691
+ ...input.tracks,
12692
+ ...[...byTrackId.values()].map(toInterval),
12693
+ ...trackless.map(toInterval)
12694
+ ].toSorted((a, b) => a.firstSeen - b.firstSeen);
12695
+ }
11372
12696
  //#endregion
11373
12697
  //#region src/notification-center/timelapse/footage-coverage.ts
11374
12698
  /**
@@ -11421,8 +12745,6 @@ function footageCoverage(ranges, windowStartMs, windowEndMs) {
11421
12745
  complete: windowMs - coveredMs <= COVERAGE_COMPLETE_SLACK_MS
11422
12746
  };
11423
12747
  }
11424
- /** How long a scheduled timelapse's file is kept by the export engine. */
11425
- var TIMELAPSE_EXPORT_LIFE_MS = 7 * 864e5;
11426
12748
  var TimelapseRenderError = class extends Error {
11427
12749
  reason;
11428
12750
  constructor(reason, message) {
@@ -11450,8 +12772,79 @@ function pickProfile(ranges, window) {
11450
12772
  }
11451
12773
  return best;
11452
12774
  }
11453
- /** `HH:MM`, for the export title an operator sees in the exports list. */
11454
- function clockOf$1(atMs) {
12775
+ /**
12776
+ * Is `record` the render this request is about to order?
12777
+ *
12778
+ * The range, the source profile and the BASE cadence must match exactly — those
12779
+ * three decide what the video is. The dense overlay deliberately does NOT: it
12780
+ * is derived from the tracks the window happened to hold, and a retry minutes
12781
+ * later plans it from a slightly longer list. Refusing to adopt over a moved
12782
+ * range boundary would re-order the whole ten hours for a difference nobody can
12783
+ * see, which is precisely the loop this exists to break.
12784
+ */
12785
+ function coversTheSameRender(record, want) {
12786
+ const timelapse = record.options.timelapse;
12787
+ if (timelapse === void 0) return false;
12788
+ return record.profile === want.profile && record.fromMs === want.fromMs && record.toMs === want.toMs && timelapse.everyMs === want.everyMs && timelapse.outputFps === want.outputFps;
12789
+ }
12790
+ /**
12791
+ * Would adopting `record` LOSE the slowdown this render planned?
12792
+ *
12793
+ * The adoption check deliberately ignores the dense RANGES (a retry plans them
12794
+ * from a slightly longer track list, and re-ordering ten hours over a moved
12795
+ * boundary is the loop adoption exists to break). It must NOT ignore whether a
12796
+ * dense overlay exists at all.
12797
+ *
12798
+ * The failure this closes: attempt one reads the detections while the store is
12799
+ * unreachable, plans nothing, and queues a UNIFORM ten-hour export; every retry
12800
+ * then plans ranges correctly, finds that uniform export alive under the same
12801
+ * (profile, range, base cadence, fps) key, adopts it, and delivers a video that
12802
+ * never slows down — for the whole night, no matter how many retries run. The
12803
+ * notification would even caption it "2 persone". A re-order costs ~20 min of
12804
+ * ffmpeg ONCE; adopting the wrong product costs the night.
12805
+ *
12806
+ * Bounded, and that is why it is safe: the verdict is a function of the plan, so
12807
+ * the export ordered after a refusal carries the dense overlay and the NEXT
12808
+ * retry adopts it. Only a plan that changed direction refuses, never a retry.
12809
+ * A candidate DENSER than what we want is adopted — more slowdown than asked is
12810
+ * not a defect, and refusing it would re-order for nothing.
12811
+ */
12812
+ function keepsThePlannedSlowdown(record, want) {
12813
+ if (want === void 0) return true;
12814
+ const dense = record.options.timelapse?.dense;
12815
+ if (dense === void 0 || dense.ranges.length === 0) return false;
12816
+ return dense.everyMs <= want.everyMs;
12817
+ }
12818
+ /** Can this row still become — or already be — a file we can fetch? */
12819
+ function isAdoptable(record, nowMs) {
12820
+ if (record.expiresAt <= nowMs) return false;
12821
+ if (record.state === "ready") return record.fileBytes !== null;
12822
+ return record.state === "queued" || record.state === "rendering";
12823
+ }
12824
+ /**
12825
+ * `HH:MM` in the RULE's timezone, for the export title an operator reads.
12826
+ *
12827
+ * The zone is not decoration. The hub container runs on UTC while the operator
12828
+ * and every one of his schedules run on Europe/Rome, so the host-local
12829
+ * derivation this used to do titled a 21:00→07:00 window `19:00→05:00` — two
12830
+ * hours off, on the one string whose whole job is to say which night the file
12831
+ * is. The window boundaries themselves were always correct: `isScheduleActive`
12832
+ * has resolved minutes through `schedule.timezone` from the start, and only the
12833
+ * label lied.
12834
+ *
12835
+ * An absent or unknown IANA name falls back to the host zone, which is exactly
12836
+ * what `localDayMinute` does for the same input — one behaviour for a bad
12837
+ * zone, not two.
12838
+ */
12839
+ function clockOf$1(atMs, timeZone) {
12840
+ if (timeZone !== void 0) try {
12841
+ return new Intl.DateTimeFormat("en-GB", {
12842
+ timeZone,
12843
+ hour: "2-digit",
12844
+ minute: "2-digit",
12845
+ hourCycle: "h23"
12846
+ }).format(new Date(atMs));
12847
+ } catch {}
11455
12848
  const d = new Date(atMs);
11456
12849
  return `${String(d.getHours()).padStart(2, "0")}:${String(d.getMinutes()).padStart(2, "0")}`;
11457
12850
  }
@@ -11483,12 +12876,25 @@ var TimelapseRenderer = class {
11483
12876
  });
11484
12877
  throw new TimelapseRenderError("no-footage", `no footage for ${deviceId} in ${window.startMs}..${window.endMs}`);
11485
12878
  }
11486
- const tracks = await this.ports.listTracks({
12879
+ const [tracks, events] = await Promise.all([this.ports.listTracks({
12880
+ deviceId,
12881
+ since: window.startMs,
12882
+ until: window.endMs
12883
+ }).catch((err) => {
12884
+ this.ports.logger.warn("timelapse track read failed — dense plan degraded", {
12885
+ tags,
12886
+ meta: {
12887
+ ruleId: request.ruleId,
12888
+ error: String(err)
12889
+ }
12890
+ });
12891
+ return [];
12892
+ }), this.ports.listDetectionEvents({
11487
12893
  deviceId,
11488
12894
  since: window.startMs,
11489
12895
  until: window.endMs
11490
12896
  }).catch((err) => {
11491
- this.ports.logger.warn("timelapse dense plan unavailable — rendering uniform", {
12897
+ this.ports.logger.warn("timelapse detection-event read failed — dense plan degraded", {
11492
12898
  tags,
11493
12899
  meta: {
11494
12900
  ruleId: request.ruleId,
@@ -11496,13 +12902,17 @@ var TimelapseRenderer = class {
11496
12902
  }
11497
12903
  });
11498
12904
  return [];
12905
+ })]);
12906
+ const subjects = mergeDetectionIntervals({
12907
+ tracks,
12908
+ events
11499
12909
  });
11500
12910
  const detections = countDetections({
11501
- tracks,
12911
+ tracks: subjects,
11502
12912
  ...request.reportClasses !== void 0 ? { reportClasses: request.reportClasses } : {}
11503
12913
  });
11504
12914
  const plan = planDenseRanges({
11505
- tracks,
12915
+ tracks: subjects,
11506
12916
  windowStartMs: window.startMs,
11507
12917
  windowEndMs: window.endMs,
11508
12918
  padSec: 5,
@@ -11535,16 +12945,25 @@ var TimelapseRenderer = class {
11535
12945
  mergedRanges: plan.mergedCount
11536
12946
  }
11537
12947
  });
11538
- const queued = await this.ports.createExport({
12948
+ const adopted = await this.findAdoptable({
12949
+ deviceId,
12950
+ profile: chosen.profile,
12951
+ fromMs: window.startMs,
12952
+ toMs: window.endMs
12953
+ }, {
12954
+ everyMs,
12955
+ outputFps: request.framerate
12956
+ }, dense, tags);
12957
+ const queued = adopted ?? await this.ports.createExport({
11539
12958
  deviceId,
11540
12959
  profile: chosen.profile,
11541
12960
  fromMs: window.startMs,
11542
12961
  toMs: window.endMs,
11543
12962
  options: {
11544
12963
  includeAudio: false,
11545
- maxLifeMs: TIMELAPSE_EXPORT_LIFE_MS,
12964
+ maxLifeMs: 6048e5,
11546
12965
  deleteAfterDownload: false,
11547
- title: `${request.ruleName} ${clockOf$1(window.startMs)}→${clockOf$1(window.endMs)}`,
12966
+ title: `${request.ruleName} ${clockOf$1(window.startMs, request.timezone)}→${clockOf$1(window.endMs, request.timezone)}`,
11548
12967
  timelapse: {
11549
12968
  everyMs,
11550
12969
  outputFps: request.framerate,
@@ -11552,7 +12971,7 @@ var TimelapseRenderer = class {
11552
12971
  }
11553
12972
  }
11554
12973
  });
11555
- this.ports.logger.info("timelapse export queued", {
12974
+ this.ports.logger.info(adopted === null ? "timelapse export queued" : "timelapse export adopted", {
11556
12975
  tags,
11557
12976
  meta: {
11558
12977
  ruleId: request.ruleId,
@@ -11563,11 +12982,15 @@ var TimelapseRenderer = class {
11563
12982
  denseSource: cadence.source,
11564
12983
  denseDwellApplied: cadence.dwellApplied,
11565
12984
  denseRanges: plan.ranges.length,
11566
- coveragePct: chosen.coverage.percent
12985
+ denseTracks: tracks.length,
12986
+ denseEvents: events.length,
12987
+ denseSubjects: subjects.length,
12988
+ coveragePct: chosen.coverage.percent,
12989
+ ...adopted !== null ? { adoptedState: adopted.state } : {}
11567
12990
  }
11568
12991
  });
11569
12992
  const finished = await this.awaitReady(queued, tags);
11570
- const published = await this.publish(finished.id, deviceId, tags, request.previewText, request.previewMode ?? "image");
12993
+ const published = await this.publish(finished.id, request, tags);
11571
12994
  const downloadUrl = await this.ports.resolveDownloadUrl?.({ exportId: finished.id }).catch(() => null);
11572
12995
  return {
11573
12996
  exportId: finished.id,
@@ -11581,14 +13004,79 @@ var TimelapseRenderer = class {
11581
13004
  fileBytes: finished.fileBytes
11582
13005
  };
11583
13006
  }
11584
- /** Poll to a terminal state, under a bound. */
13007
+ /**
13008
+ * The render this request would order, if the engine already owes it.
13009
+ *
13010
+ * Never throws: an adoption lookup that fails costs one duplicate export,
13011
+ * which is strictly better than costing the window. It says so, though — a
13012
+ * branch that silently drops the protection reads as "there was nothing to
13013
+ * adopt".
13014
+ */
13015
+ async findAdoptable(want, cadence, wantDense, tags) {
13016
+ const rows = await this.ports.listExports({ deviceId: want.deviceId }).catch((err) => {
13017
+ this.ports.logger.warn("timelapse could not check for an export already in flight", {
13018
+ tags,
13019
+ meta: { error: String(err) }
13020
+ });
13021
+ return null;
13022
+ });
13023
+ if (rows === null) return null;
13024
+ const now = this.ports.now();
13025
+ const sameRender = rows.filter((r) => isAdoptable(r, now) && coversTheSameRender(r, {
13026
+ ...want,
13027
+ ...cadence
13028
+ }));
13029
+ const usable = sameRender.filter((r) => keepsThePlannedSlowdown(r, wantDense));
13030
+ if (usable.length < sameRender.length) this.ports.logger.info("timelapse refused to adopt an export with no dense overlay", {
13031
+ tags,
13032
+ meta: {
13033
+ candidates: sameRender.length,
13034
+ refused: sameRender.length - usable.length,
13035
+ wantDenseEveryMs: wantDense?.everyMs ?? null,
13036
+ wantDenseRanges: wantDense?.ranges.length ?? 0
13037
+ }
13038
+ });
13039
+ const ready = usable.find((r) => r.state === "ready");
13040
+ if (ready !== void 0) return ready;
13041
+ return usable.toSorted((a, b) => a.createdAt - b.createdAt)[0] ?? null;
13042
+ }
13043
+ /**
13044
+ * Poll to a terminal state, under a bound.
13045
+ *
13046
+ * The bound belongs to the RENDER, not to any single poll of it. A
13047
+ * `getExport` that fails on the transport says nothing at all about the
13048
+ * ffmpeg on the recording node — and treating it as fatal is how a finished
13049
+ * 12.5 MB video was abandoned on 2026-08-14: one `UDS request timed out after
13050
+ * 60000ms` at 05:10:47 threw straight out of the wait for an export that
13051
+ * reached `ready` at 05:23:47. So a failed poll is retried until the
13052
+ * deadline, and only a TERMINAL state the engine actually reported ends the
13053
+ * wait early.
13054
+ */
11585
13055
  async awaitReady(queued, tags) {
11586
13056
  if (queued.state === "ready") return queued;
11587
13057
  const deadline = this.ports.now() + this.timeoutMs;
11588
13058
  let current = queued;
13059
+ let pollFailures = 0;
13060
+ let lastPollError = null;
11589
13061
  while (this.ports.now() < deadline) {
11590
13062
  await this.ports.sleep(this.pollMs);
11591
- current = await this.ports.getExport({ exportId: queued.id });
13063
+ const polled = await this.ports.getExport({ exportId: queued.id }).catch((err) => {
13064
+ pollFailures += 1;
13065
+ lastPollError = String(err);
13066
+ const entry = {
13067
+ tags,
13068
+ meta: {
13069
+ exportId: queued.id,
13070
+ pollFailures,
13071
+ error: lastPollError
13072
+ }
13073
+ };
13074
+ if (pollFailures === 1) this.ports.logger.warn("timelapse export poll failed — the render is still owed", entry);
13075
+ else this.ports.logger.debug("timelapse export poll failed again", entry);
13076
+ return null;
13077
+ });
13078
+ if (polled === null) continue;
13079
+ current = polled;
11592
13080
  if (current.state === "ready") return current;
11593
13081
  if (current.state === "failed" || current.state === "expired" || current.state === "deleted") {
11594
13082
  this.ports.logger.warn("timelapse export did not finish", {
@@ -11607,10 +13095,12 @@ var TimelapseRenderer = class {
11607
13095
  meta: {
11608
13096
  exportId: queued.id,
11609
13097
  timeoutMs: this.timeoutMs,
11610
- lastState: current.state
13098
+ lastState: current.state,
13099
+ pollFailures,
13100
+ ...lastPollError !== null ? { lastPollError } : {}
11611
13101
  }
11612
13102
  });
11613
- throw new TimelapseRenderError("export-timeout", `export ${queued.id} timed out after ${this.timeoutMs}ms`);
13103
+ throw new TimelapseRenderError("export-timeout", `export ${queued.id} timed out after ${this.timeoutMs}ms` + (pollFailures > 0 ? ` (${pollFailures} poll failures, last: ${lastPollError})` : ""));
11614
13104
  }
11615
13105
  /**
11616
13106
  * Mint the public link, or say why there is none.
@@ -11619,7 +13109,9 @@ var TimelapseRenderer = class {
11619
13109
  * notification, not a failed window — and failing here would leave the pair
11620
13110
  * unstamped and re-render the same eight hours on the next tick.
11621
13111
  */
11622
- async publish(exportId, deviceId, tags, previewText, previewMode) {
13112
+ async publish(exportId, request, tags) {
13113
+ const { deviceId, previewText } = request;
13114
+ const previewMode = request.previewMode ?? "image";
11623
13115
  const publish = this.ports.publishArtifact;
11624
13116
  const none = {
11625
13117
  video: null,
@@ -11639,7 +13131,11 @@ var TimelapseRenderer = class {
11639
13131
  ...previewText !== void 0 ? {
11640
13132
  previewText,
11641
13133
  previewMode
11642
- } : {}
13134
+ } : {},
13135
+ capturedAt: request.window.endMs,
13136
+ ruleId: request.ruleId,
13137
+ ruleName: request.ruleName,
13138
+ ...request.ownerUserId !== void 0 ? { ownerUserId: request.ownerUserId } : {}
11643
13139
  });
11644
13140
  if (set.video === null) this.ports.logger.warn("timelapse artifact could not be published — link-less delivery", {
11645
13141
  tags,
@@ -11813,6 +13309,25 @@ function buildTimelapseOutboxInputs(input) {
11813
13309
  };
11814
13310
  });
11815
13311
  }
13312
+ //#endregion
13313
+ //#region src/notification-center/timelapse/timelapse-scheduler.ts
13314
+ /**
13315
+ * How long after a window closes before it is rendered.
13316
+ *
13317
+ * A track is persisted when it CLOSES, and a track that was open at 06:00
13318
+ * lands minutes later. Rendering at the stroke of the boundary would miss
13319
+ * exactly the tracks the dense plan is built from — the ones still moving when
13320
+ * the window ended.
13321
+ */
13322
+ var TIMELAPSE_SETTLE_MS = 5 * 6e4;
13323
+ /**
13324
+ * How far back a tick may look for a close.
13325
+ *
13326
+ * Eight days, so a hub that was down over a long weekend still finds last
13327
+ * night. The scan short-circuits at the first close it meets, so a rule with a
13328
+ * nightly window costs about a day's worth of minute steps, not eight.
13329
+ */
13330
+ var TIMELAPSE_LOOKBACK_MS = 8 * 864e5;
11816
13331
  /**
11817
13332
  * The earliest window CLOSE a (rule, device) pair may ever be offered.
11818
13333
  *
@@ -11842,10 +13357,15 @@ function buildTimelapseOutboxInputs(input) {
11842
13357
  function timelapseEligibilityFloor(rule, deviceId) {
11843
13358
  return Math.max(readTimelapseGeneratedAt(rule, deviceId), rule.createdAt);
11844
13359
  }
13360
+ /** The in-flight map's key. One render per (rule, camera) pair by construction. */
13361
+ function renderFlightKey(ruleId, deviceId) {
13362
+ return `${ruleId}:${String(deviceId)}`;
13363
+ }
11845
13364
  var TimelapseScheduler = class {
11846
13365
  deps;
11847
13366
  timer = null;
11848
13367
  tickInFlight = false;
13368
+ inFlight = /* @__PURE__ */ new Map();
11849
13369
  settleMs;
11850
13370
  lookbackMs;
11851
13371
  tickMs;
@@ -11858,16 +13378,32 @@ var TimelapseScheduler = class {
11858
13378
  this.texts = deps.texts ?? new NcTextCatalog();
11859
13379
  }
11860
13380
  /**
11861
- * Boot: one tick (the back-fill) and then the interval.
13381
+ * Boot: arm the interval, then fire the back-fill tick WITHOUT waiting on it.
11862
13382
  *
11863
13383
  * The back-fill needs no special path — it IS a tick. A window that closed
11864
13384
  * while the process was down is simply a window whose pair has no stamp.
13385
+ *
13386
+ * `start()` resolving must mean "the producer is running", never "the
13387
+ * back-fill has finished". A back-fill tick renders video: one window can sit
13388
+ * in `awaitReady` for the full `RENDER_TIMEOUT_MS` (30 min), once per (rule,
13389
+ * camera) pair. Awaiting it here held `NotificationCenter.start()`, which held
13390
+ * the addon's `initialize()`, which is what publishes the runner's capability
13391
+ * manifest — so on 2026-08-14 a single 10-hour timelapse window took
13392
+ * `notification-rules`, `videoclips`, `face-gallery`, `plate-gallery`,
13393
+ * `zone-analytics`, `audio-metrics` and `pipeline-analytics` off the cluster
13394
+ * entirely, and the admin UI answered "provider not available".
13395
+ *
13396
+ * `tick()` never throws for a render failure — `produce()` swallows those and
13397
+ * leaves the pair unstamped — so the catch here only covers a store read that
13398
+ * fails, and it must not become an unhandled rejection.
11865
13399
  */
11866
13400
  async start() {
11867
- await this.tick();
11868
13401
  this.timer = setInterval(() => {
11869
13402
  this.tick();
11870
13403
  }, this.tickMs);
13404
+ this.tick().catch((err) => {
13405
+ this.deps.logger.warn("timelapse back-fill tick failed", { meta: { error: String(err) } });
13406
+ });
11871
13407
  }
11872
13408
  stop() {
11873
13409
  if (this.timer !== null) {
@@ -11976,6 +13512,13 @@ var TimelapseScheduler = class {
11976
13512
  */
11977
13513
  async produce(rule, deviceId, window, opts) {
11978
13514
  const tags = { deviceId };
13515
+ const flightKey = renderFlightKey(rule.id, deviceId);
13516
+ this.inFlight.set(flightKey, {
13517
+ ruleId: rule.id,
13518
+ deviceId,
13519
+ window,
13520
+ startedAt: this.deps.now()
13521
+ });
11979
13522
  try {
11980
13523
  const deviceName = await this.resolveDeviceName(deviceId);
11981
13524
  const outcome = await this.deps.render({
@@ -11987,6 +13530,7 @@ var TimelapseScheduler = class {
11987
13530
  framerate: rule.framerate,
11988
13531
  ...rule.denseCadenceSec !== void 0 ? { denseCadenceSec: rule.denseCadenceSec } : {},
11989
13532
  ...rule.minDwellSec !== void 0 ? { minDwellSec: rule.minDwellSec } : {},
13533
+ ...rule.schedule.timezone !== void 0 ? { timezone: rule.schedule.timezone } : {},
11990
13534
  previewText: timelapsePreviewText({
11991
13535
  deviceId,
11992
13536
  ...deviceName !== null ? { deviceName } : {},
@@ -11995,7 +13539,8 @@ var TimelapseScheduler = class {
11995
13539
  ...rule.previewText !== void 0 ? { text: rule.previewText } : {}
11996
13540
  }),
11997
13541
  previewMode: rule.previewMode,
11998
- ...rule.reportClasses !== void 0 ? { reportClasses: rule.reportClasses } : {}
13542
+ ...rule.reportClasses !== void 0 ? { reportClasses: rule.reportClasses } : {},
13543
+ ...rule.ownerUserId !== void 0 ? { ownerUserId: rule.ownerUserId } : {}
11999
13544
  });
12000
13545
  const rows = buildTimelapseOutboxInputs({
12001
13546
  texts: this.texts,
@@ -12058,8 +13603,15 @@ var TimelapseScheduler = class {
12058
13603
  window,
12059
13604
  error: err instanceof Error ? err.message : String(err)
12060
13605
  };
13606
+ } finally {
13607
+ this.inFlight.delete(flightKey);
12061
13608
  }
12062
13609
  }
13610
+ /** Renders this process currently owes, newest last. Read by the rule-status
13611
+ * surface; never a decision input for the scheduler itself. */
13612
+ renderInFlight() {
13613
+ return [...this.inFlight.values()];
13614
+ }
12063
13615
  async resolveDeviceName(deviceId) {
12064
13616
  const get = this.deps.getDeviceName;
12065
13617
  if (get === void 0) return null;
@@ -12471,6 +14023,14 @@ var NcZoneOwnershipCache = class {
12471
14023
  /** The one cap whose `trigger` the combined alarm notification joins on. */
12472
14024
  var ALARM_PANEL_CAP = alarmPanelCapability.name;
12473
14025
  /**
14026
+ * How far ahead a status may scan for the next window opening.
14027
+ *
14028
+ * Nine days rather than "as far as it takes": a weekly schedule opens within
14029
+ * seven, and a schedule that opens less often than that is one whose next run
14030
+ * is not the fact an operator is looking at a card for.
14031
+ */
14032
+ var TIMELAPSE_LOOKAHEAD_MS = 9 * 864e5;
14033
+ /**
12474
14034
  * The synthetic rule id an alarm ANNOUNCEMENT is enqueued under.
12475
14035
  *
12476
14036
  * The arm announcement is addressed to the panel's own `announceTargets`, not
@@ -12654,6 +14214,44 @@ function historyRecordKind(kind) {
12654
14214
  function contactReadingOf(state) {
12655
14215
  if (state === "open" || state === "closed") return state;
12656
14216
  }
14217
+ /**
14218
+ * The subject kind a persisted record kind maps to.
14219
+ *
14220
+ * `NcOutboxRecordKind` carries two kinds a rule subject never has —
14221
+ * `timelapse` and `ai-summary` are SCHEDULE boundaries, not records. They are
14222
+ * mapped to `device-event`, which is class-agnostic in `cooldownKey`, so their
14223
+ * key is the one the seeder already built for them. A switch rather than a
14224
+ * cast: a kind added to either union has to be answered for here.
14225
+ */
14226
+ function cooldownSubjectKind(kind) {
14227
+ switch (kind) {
14228
+ case "timelapse":
14229
+ case "ai-summary": return "device-event";
14230
+ default: return kind;
14231
+ }
14232
+ }
14233
+ /**
14234
+ * A persisted outbox row, reduced to exactly what `cooldownKey` reads.
14235
+ *
14236
+ * The point is that it reads NOTHING else: the boot-time cooldown reseed calls
14237
+ * the same builder the live path does, so the two can never address different
14238
+ * windows. Only the four fields the key is a function of are reconstructed —
14239
+ * the class (for `per-class` and audio), the subject kind (which decides
14240
+ * whether audio is keyed per class at all), the system event (subject +
14241
+ * direction) and the device.
14242
+ */
14243
+ function cooldownSubjectFromOutbox(entry) {
14244
+ const subject = entry.payload.subject;
14245
+ return {
14246
+ kind: cooldownSubjectKind(entry.recordKind),
14247
+ recordId: entry.recordId,
14248
+ deviceId: entry.deviceId,
14249
+ timestamp: subject.timestamp,
14250
+ classNames: subject.className.length > 0 ? [subject.className] : [],
14251
+ zones: [...subject.zones],
14252
+ ...subject.systemEvent !== void 0 ? { systemEvent: subject.systemEvent } : {}
14253
+ };
14254
+ }
12657
14255
  function historySystemEventKind(kind) {
12658
14256
  if (kind === "alarm-arming") return "alarm-armed";
12659
14257
  if (kind === "alarm-arm-refused") return "alarm-disarmed";
@@ -13117,7 +14715,8 @@ var NotificationCenter = class NotificationCenter {
13117
14715
  this.now = deps.now ?? (() => Date.now());
13118
14716
  this.rules = new NcRuleStore({
13119
14717
  store: deps.store,
13120
- logger: this.logger.child("rules")
14718
+ logger: this.logger.child("rules"),
14719
+ ...deps.identityIdsByName !== void 0 ? { identityIdsByName: deps.identityIdsByName } : {}
13121
14720
  });
13122
14721
  this.textStore = new NcTextCatalogStore({
13123
14722
  store: deps.store,
@@ -13311,6 +14910,7 @@ var NotificationCenter = class NotificationCenter {
13311
14910
  await TimelapseStore.declare(store);
13312
14911
  await NcSummaryStore.declare(store);
13313
14912
  await NcTextCatalogStore.declare(store);
14913
+ await NcArtifactIndex.declare(store);
13314
14914
  }
13315
14915
  /**
13316
14916
  * Load rules (every node — the cap provider serves CRUD from any node).
@@ -13490,6 +15090,120 @@ var NotificationCenter = class NotificationCenter {
13490
15090
  ...report.droppedBy !== void 0 ? { droppedBy: report.droppedBy } : {}
13491
15091
  };
13492
15092
  }
15093
+ /**
15094
+ * The live status of ONE detection rule — see `rule-status.ts`.
15095
+ *
15096
+ * `none` is a real answer, not a failure: most rule kinds have no live state
15097
+ * a card could show, and returning an empty object would read as a read that
15098
+ * failed. Only occupancy has one today, and it is the kind whose entire
15099
+ * subject is a number nobody can see.
15100
+ */
15101
+ async ruleStatusFor(rule) {
15102
+ const occupancy = rule.conditions.occupancy;
15103
+ if (occupancy === void 0) return {
15104
+ kind: "none",
15105
+ reason: "this rule has no live state to report"
15106
+ };
15107
+ const deviceId = rule.conditions.devices?.[0];
15108
+ const read = this.deps.readOccupancySnapshots;
15109
+ const threshold = occupancyThreshold(occupancy);
15110
+ if (deviceId === void 0 || read === void 0) return buildOccupancyStatus({
15111
+ deviceId: deviceId ?? 0,
15112
+ condition: occupancy,
15113
+ probe: {
15114
+ kind: "unavailable",
15115
+ reason: "no-snapshot"
15116
+ },
15117
+ confirmed: this.occupancyWatcher.snapshotState(),
15118
+ threshold
15119
+ });
15120
+ let snapshot = null;
15121
+ try {
15122
+ snapshot = (await read([deviceId])).get(deviceId) ?? null;
15123
+ } catch (err) {
15124
+ this.logger.warn("rule status could not read the live occupancy snapshot", {
15125
+ tags: { deviceId },
15126
+ meta: {
15127
+ ruleId: rule.id,
15128
+ error: String(err)
15129
+ }
15130
+ });
15131
+ }
15132
+ const probe = probeOccupancyNow({
15133
+ occupancy,
15134
+ deviceId,
15135
+ snapshot,
15136
+ now: this.now()
15137
+ });
15138
+ const warning = occupancyClassWarning(occupancy.className);
15139
+ return buildOccupancyStatus({
15140
+ deviceId,
15141
+ condition: occupancy,
15142
+ probe,
15143
+ confirmed: this.occupancyWatcher.snapshotState(),
15144
+ threshold,
15145
+ ...warning !== null ? { warning } : {}
15146
+ });
15147
+ }
15148
+ /**
15149
+ * The live status of one TIMELAPSE rule.
15150
+ *
15151
+ * Three facts, and each answers a question the rule itself cannot: when each
15152
+ * camera last produced (and the artefact it produced, while the shelf still
15153
+ * holds it), whether a render is in flight RIGHT NOW, and when the next
15154
+ * window opens. The window boundaries are scanned through `isScheduleActive`,
15155
+ * the same authority the producer uses — a second reading of a schedule is
15156
+ * how a status starts promising a run that is not coming.
15157
+ */
15158
+ async timelapseStatusFor(rule, lookup) {
15159
+ const now = this.now();
15160
+ const flights = this.timelapseScheduler?.renderInFlight() ?? [];
15161
+ const lastWindow = findLastClosedWindow({
15162
+ schedule: rule.schedule,
15163
+ nowMs: now,
15164
+ lookbackMs: TIMELAPSE_LOOKBACK_MS
15165
+ });
15166
+ const nextWindow = findNextWindowOpen({
15167
+ schedule: rule.schedule,
15168
+ nowMs: now,
15169
+ lookaheadMs: TIMELAPSE_LOOKAHEAD_MS
15170
+ });
15171
+ const devices = [];
15172
+ for (const deviceId of rule.deviceIds) {
15173
+ const generatedAt = readTimelapseGeneratedAt(rule, deviceId);
15174
+ const flight = flights.find((f) => f.ruleId === rule.id && f.deviceId === deviceId);
15175
+ const artifact = lookup === void 0 ? null : await lookup({
15176
+ ruleId: rule.id,
15177
+ deviceId
15178
+ }).catch((err) => {
15179
+ this.logger.warn("rule status could not read the timelapse artifact index", {
15180
+ tags: { deviceId },
15181
+ meta: {
15182
+ ruleId: rule.id,
15183
+ error: String(err)
15184
+ }
15185
+ });
15186
+ return null;
15187
+ });
15188
+ devices.push({
15189
+ deviceId,
15190
+ ...generatedAt > 0 ? { lastGeneratedAt: generatedAt } : {},
15191
+ ...artifact !== null ? { lastArtifact: artifact } : {},
15192
+ ...flight !== void 0 ? { rendering: {
15193
+ startedAt: flight.startedAt,
15194
+ windowStartMs: flight.window.startMs,
15195
+ windowEndMs: flight.window.endMs
15196
+ } } : {}
15197
+ });
15198
+ }
15199
+ return {
15200
+ kind: "timelapse",
15201
+ devices,
15202
+ ...lastWindow !== null ? { lastWindow } : {},
15203
+ ...nextWindow !== null ? { nextWindow } : {},
15204
+ settleMs: TIMELAPSE_SETTLE_MS
15205
+ };
15206
+ }
13493
15207
  /** One live frame, or null. Never throws into an injection. */
13494
15208
  async captureLiveStill(deviceId, deps) {
13495
15209
  const capture = deps.getSnapshot;
@@ -14264,7 +15978,7 @@ var NotificationCenter = class NotificationCenter {
14264
15978
  return report({ droppedBy: "muted" });
14265
15979
  }
14266
15980
  const delivery = deliveryForKind(kind);
14267
- const enabled = this.rules.listEnabled(delivery);
15981
+ const enabled = this.rulesForTrigger(delivery, kind, subject);
14268
15982
  const candidates = controls.onlyRuleId !== void 0 ? enabled.filter((r) => r.id === controls.onlyRuleId) : enabled;
14269
15983
  if (candidates.length === 0) {
14270
15984
  this.logger.debug("no enabled rule for this trigger", {
@@ -14276,6 +15990,7 @@ var NotificationCenter = class NotificationCenter {
14276
15990
  rulesLoaded: this.rules.list().length
14277
15991
  }
14278
15992
  });
15993
+ if (kind === "track-end") await this.enqueueRecognitionUpgrades(subject, outcomes);
14279
15994
  return report({ droppedBy: "no-candidate-rule" });
14280
15995
  }
14281
15996
  const now = this.now();
@@ -14376,6 +16091,7 @@ var NotificationCenter = class NotificationCenter {
14376
16091
  at: now
14377
16092
  });
14378
16093
  const inserted = await this.outbox.enqueue(entries);
16094
+ this.noteRecognitionFire(rule, subject, kind, entries, inserted);
14379
16095
  if (inserted > 0 && controls.bypassCooldown !== true) this.lastFiredAt.set(key, now);
14380
16096
  outcomes.push({
14381
16097
  ruleId: rule.id,
@@ -14389,9 +16105,124 @@ var NotificationCenter = class NotificationCenter {
14389
16105
  anyMatched = true;
14390
16106
  }
14391
16107
  if (!anyMatched && rejections.length > 0) this.reportNoMatch(subject, kind, rejections, now);
16108
+ if (kind === "track-end") await this.enqueueRecognitionUpgrades(subject, outcomes);
14392
16109
  return report({ candidates: candidates.length });
14393
16110
  }
14394
16111
  /**
16112
+ * The rules a trigger may fire, which is `listEnabled(delivery)` plus and
16113
+ * minus exactly one thing: `waitForEnhancement`.
16114
+ *
16115
+ * An `immediate` rule that agreed to wait for a name is REMOVED from the
16116
+ * object-event trigger and ADDED to the track close, because the closed track
16117
+ * is the first record that carries the name. Both halves are needed and the
16118
+ * removal is the load-bearing one — leaving it on the object event would fire
16119
+ * the early, nameless notification, and the dedup key (`ruleId:trackId:target`)
16120
+ * would then silently swallow the complete one as a duplicate. The operator
16121
+ * would have set the switch and received exactly what they set it to avoid.
16122
+ *
16123
+ * The drop is REPORTED. A rule vanishing from a trigger reads as a broken rule
16124
+ * from a log, and this repo has paid for that twice.
16125
+ */
16126
+ rulesForTrigger(delivery, kind, subject) {
16127
+ const enabled = this.rules.listEnabled(delivery);
16128
+ if (kind === "object-event") {
16129
+ const deferred = enabled.filter((r) => ruleDefersToTrackEnd(r));
16130
+ if (deferred.length === 0) return enabled;
16131
+ this.logger.debug("rules deferred to track close — waiting for recognition", {
16132
+ tags: { deviceId: subject.deviceId },
16133
+ meta: {
16134
+ rules: deferred.map((r) => r.name).join(", "),
16135
+ eventId: subject.recordId,
16136
+ ...subject.trackId !== void 0 ? { trackId: subject.trackId } : {}
16137
+ }
16138
+ });
16139
+ return enabled.filter((r) => !ruleDefersToTrackEnd(r));
16140
+ }
16141
+ if (kind === "track-end") return [...enabled, ...this.rules.listEnabled("immediate").filter((r) => ruleDefersToTrackEnd(r))];
16142
+ return enabled;
16143
+ }
16144
+ /**
16145
+ * What an `immediate` recognition rule already said about a track, kept only
16146
+ * until that track closes.
16147
+ *
16148
+ * Keyed `ruleIdtrackId`, holding the targets the first push reached and
16149
+ * the LABEL it froze. In RAM and deliberately not durable: losing it costs an
16150
+ * upgrade, which degrades to "the operator got the first notification and not
16151
+ * the second" — the state of the world before this feature. Making it durable
16152
+ * would trade that for a second write on the enqueue path and a table that
16153
+ * outlives the tracks it describes.
16154
+ */
16155
+ recognitionFires = /* @__PURE__ */ new Map();
16156
+ /** Bound on {@link recognitionFires}. A track that never closes (a crash
16157
+ * between its birth and its close) must not pin a row forever. */
16158
+ static RECOGNITION_FIRE_TTL_MS = 30 * 6e4;
16159
+ recognitionFireKey(ruleId, trackId) {
16160
+ return `${ruleId}${trackId}`;
16161
+ }
16162
+ noteRecognitionFire(rule, subject, kind, entries, inserted) {
16163
+ if (kind !== "object-event" || inserted === 0) return;
16164
+ if (subject.trackId === void 0 || !ruleUpgradesAtTrackEnd(rule)) return;
16165
+ const now = this.now();
16166
+ for (const [key, held] of this.recognitionFires) if (now - held.at > NotificationCenter.RECOGNITION_FIRE_TTL_MS) this.recognitionFires.delete(key);
16167
+ this.recognitionFires.set(this.recognitionFireKey(rule.id, subject.trackId), {
16168
+ targetIds: entries.map((e) => e.targetId),
16169
+ ...subject.label !== void 0 ? { label: subject.label } : {},
16170
+ at: now
16171
+ });
16172
+ }
16173
+ /**
16174
+ * Send the "…and it was Gianluca" follow-up for every rule that fired on this
16175
+ * track WITHOUT a name and has one now.
16176
+ *
16177
+ * Four gates, and each one exists because its absence is a notification the
16178
+ * operator did not ask for:
16179
+ *
16180
+ * - the rule fired for this track at all (`recognitionFires`) — an upgrade
16181
+ * with no original is a notification about a rule that never matched;
16182
+ * - the track ended RECOGNISED (`labelKind`) — a person nobody could name
16183
+ * has nothing to add;
16184
+ * - the name is NEW — plates are frequently read on the first frame (0.0 s
16185
+ * observed), and re-sending the same word is the duplicate this whole
16186
+ * feature exists to avoid;
16187
+ * - one row per (rule, track, target), enforced by the outbox on the
16188
+ * `<trackId>:enhanced` dedup ref, which survives a respawn.
16189
+ *
16190
+ * A rule that ALREADY matched this closed track through the ordinary loop
16191
+ * (`outcomes`) is skipped: it just sent a track-end notification carrying the
16192
+ * name, and an upgrade would be that same sentence twice.
16193
+ */
16194
+ async enqueueRecognitionUpgrades(subject, outcomes) {
16195
+ const trackId = subject.trackId;
16196
+ if (trackId === void 0 || subject.labelKind === void 0) return;
16197
+ const alreadyFired = new Set(outcomes.filter((o) => o.matched).map((o) => o.ruleId));
16198
+ for (const rule of this.rules.listEnabled("immediate")) {
16199
+ if (!ruleUpgradesAtTrackEnd(rule) || alreadyFired.has(rule.id)) continue;
16200
+ const key = this.recognitionFireKey(rule.id, trackId);
16201
+ const held = this.recognitionFires.get(key);
16202
+ if (held === void 0) continue;
16203
+ this.recognitionFires.delete(key);
16204
+ if (held.label === subject.label) continue;
16205
+ const entries = this.buildEntries(rule, subject, "track-end", ["identities"], [], "pipeline", void 0, {
16206
+ dedupRef: `${trackId}:enhanced`,
16207
+ enhancement: true,
16208
+ targetIds: held.targetIds
16209
+ });
16210
+ const inserted = await this.outbox.enqueue(entries);
16211
+ this.logger.info("recognition upgrade — enqueueing a second notification", {
16212
+ tags: { deviceId: subject.deviceId },
16213
+ meta: {
16214
+ ruleId: rule.id,
16215
+ rule: rule.name,
16216
+ trackId,
16217
+ was: held.label ?? "",
16218
+ now: subject.label ?? "",
16219
+ labelKind: subject.labelKind,
16220
+ enqueued: inserted
16221
+ }
16222
+ });
16223
+ }
16224
+ }
16225
+ /**
14395
16226
  * Run this rule's `onTrigger` sequences for this match.
14396
16227
  *
14397
16228
  * Called BEFORE the cooldown gate — see the call site for why. Everything
@@ -14459,7 +16290,7 @@ var NotificationCenter = class NotificationCenter {
14459
16290
  }
14460
16291
  });
14461
16292
  }
14462
- buildEntries(rule, subject, kind, matchedOn, userTargets = [], origin = "pipeline", cooldown) {
16293
+ buildEntries(rule, subject, kind, matchedOn, userTargets = [], origin = "pipeline", cooldown, upgrade) {
14463
16294
  const hasEventMedia = kind === "object-event" || kind === "package-event";
14464
16295
  const isTrackScoped = kind === "object-event" || kind === "track-end";
14465
16296
  const isSystemEvent = kind === "system-event";
@@ -14470,11 +16301,13 @@ var NotificationCenter = class NotificationCenter {
14470
16301
  const renderedZones = notificationZones(rule, subject);
14471
16302
  const confirm = frozenConfirmPolicy(rule, isSystemEvent);
14472
16303
  const cropZoneIds = rule.conditions.zones !== void 0 ? rule.conditions.zones.ids : rule.conditions.occupancy?.zoneId !== void 0 ? [rule.conditions.occupancy.zoneId] : void 0;
14473
- return [...rule.targets, ...userTargets.filter((t) => !direct.has(t.targetId))].filter((target) => !rule.disabledTargetIds.includes(target.targetId)).map((target) => {
16304
+ const allTargets = [...rule.targets, ...userTargets.filter((t) => !direct.has(t.targetId))];
16305
+ return (upgrade !== void 0 ? allTargets.filter((t) => upgrade.targetIds.includes(t.targetId)) : allTargets).filter((target) => !rule.disabledTargetIds.includes(target.targetId)).map((target) => {
14474
16306
  const payload = {
14475
16307
  ruleName: rule.name,
14476
16308
  delivery: rule.delivery,
14477
16309
  ...origin === "synthetic" ? { synthetic: true } : {},
16310
+ ...upgrade !== void 0 ? { enhancement: true } : {},
14478
16311
  priority: rule.priority,
14479
16312
  ...rule.template !== void 0 ? { template: rule.template } : {},
14480
16313
  media: noMedia ? "none" : mediaRule.media.attach,
@@ -14498,6 +16331,7 @@ var NotificationCenter = class NotificationCenter {
14498
16331
  ...alarmSource?.eventId !== void 0 ? { eventId: alarmSource.eventId } : hasEventMedia ? { eventId: subject.recordId } : {},
14499
16332
  className: subject.classNames[0] ?? subject.sensorKind ?? "event",
14500
16333
  ...subject.label !== void 0 ? { label: subject.label } : {},
16334
+ ...subject.labelKind !== void 0 ? { labelKind: subject.labelKind } : {},
14501
16335
  ...subject.confidence !== void 0 ? { confidence: subject.confidence } : {},
14502
16336
  zones: renderedZones,
14503
16337
  timestamp: subject.timestamp,
@@ -14512,6 +16346,7 @@ var NotificationCenter = class NotificationCenter {
14512
16346
  recordKind: kind === "occupancy-event" ? "device-event" : kind,
14513
16347
  recordId: subject.recordId,
14514
16348
  ...isTrackScoped && subject.trackId !== void 0 ? { trackId: subject.trackId } : {},
16349
+ ...upgrade !== void 0 ? { dedupRef: upgrade.dedupRef } : {},
14515
16350
  payload
14516
16351
  };
14517
16352
  });
@@ -14604,13 +16439,26 @@ var NotificationCenter = class NotificationCenter {
14604
16439
  }
14605
16440
  });
14606
16441
  }
14607
- /** Rebuild the cooldown map from persisted outbox rows (restart-proof). */
16442
+ /**
16443
+ * Rebuild the cooldown map from persisted outbox rows (restart-proof).
16444
+ *
16445
+ * The key comes from `cooldownKey` and NOWHERE else. This method used to
16446
+ * build the string itself, and the copy had drifted: it knew the
16447
+ * `r:<id>` / `r:<id>:d:<device>` shapes and neither the `:c:<class>` tail
16448
+ * (`granularity: 'per-class'`, and unconditional for audio) nor — after
16449
+ * 2026-08-14 — the `:k:<kind>` one. Every window it could not address was a
16450
+ * window that did not survive a restart, so a per-class or audio rule
16451
+ * re-fired immediately after every respawn despite the schema promising
16452
+ * otherwise. `notifications.md` had carried it as a known defect since
16453
+ * 2026-08-02 with the instruction "if you touch `cooldownKey`, fix the seeder
16454
+ * in the same commit"; this is that commit.
16455
+ */
14608
16456
  async seedCooldowns() {
14609
16457
  const entries = await this.outbox.queryRecentPersisted(this.now() - 864e5);
14610
16458
  for (const entry of entries) {
14611
16459
  const rule = this.rules.get(entry.ruleId);
14612
16460
  if (rule === null) continue;
14613
- const key = rule.throttle.scope === "rule" ? `r:${rule.id}` : entry.payload.subject.systemEvent !== void 0 ? `r:${rule.id}:s:${entry.payload.subject.systemEvent.subject}` : `r:${rule.id}:d:${entry.deviceId}`;
16461
+ const key = cooldownKey(rule, cooldownSubjectFromOutbox(entry));
14614
16462
  const prev = this.lastFiredAt.get(key) ?? 0;
14615
16463
  if (entry.createdAt > prev) this.lastFiredAt.set(key, entry.createdAt);
14616
16464
  }
@@ -14716,7 +16564,12 @@ var NotificationCenter = class NotificationCenter {
14716
16564
  */
14717
16565
  async hydrateLiveness() {
14718
16566
  const subjects = await this.liveness.load();
14719
- this.logger.info("liveness ledger reseeded", { meta: { subjects } });
16567
+ const seeded = this.liveness.seedFromDirectory(this.deviceDirectory.all(), this.now());
16568
+ this.logger.info("liveness ledger reseeded", { meta: {
16569
+ subjects,
16570
+ seededFromDirectory: seeded,
16571
+ directory: this.deviceDirectory.size()
16572
+ } });
14720
16573
  }
14721
16574
  /**
14722
16575
  * Say that the ledger refused a liveness event, and why.
@@ -15138,6 +16991,307 @@ var NotificationCenter = class NotificationCenter {
15138
16991
  }
15139
16992
  };
15140
16993
  //#endregion
16994
+ //#region src/notification-center/rule-test-plan.ts
16995
+ /**
16996
+ * Which subject kind THIS rule fires on.
16997
+ *
16998
+ * `delivery` alone cannot answer, because two features ride an existing
16999
+ * delivery and are discriminated by a CONDITION — exactly as the engine
17000
+ * discriminates them (`rule-engine.ts`: an audio window fires only for a rule
17001
+ * declaring `conditions.audio`, an occupancy edge only for one declaring
17002
+ * `conditions.occupancy`). A third case is the LEGACY audio-class rule: an
17003
+ * `immediate` rule whose classes are all `audio-*` and which declares no
17004
+ * `audio` condition fires on a classified episode and on nothing else.
17005
+ */
17006
+ function testTriggerForRule(rule) {
17007
+ if (rule.delivery === "system-event") return "system-event";
17008
+ if (rule.delivery === "track-end") return "track-end";
17009
+ if (rule.delivery === "package-event") return "package-event";
17010
+ if (rule.delivery === "device-event") return rule.conditions.occupancy !== void 0 ? "occupancy-event" : "device-event";
17011
+ if (rule.conditions.audio !== void 0) return "audio-window";
17012
+ const classes = rule.conditions.classes ?? [];
17013
+ if (classes.length > 0 && classes.every((c) => c.trim().toLowerCase().startsWith("audio-"))) return "audio-event";
17014
+ return "object-event";
17015
+ }
17016
+ /** Reserved device id for an event with no camera (mirrors the engine's). */
17017
+ var NC_SYSTEM_DEVICE_ID = 0;
17018
+ /** The grid a schedule is authored on — the same one `window-bounds.ts` scans. */
17019
+ var SCHEDULE_SCAN_STEP_MS = 6e4;
17020
+ /** How far back a schedule scan may look for an active minute (one week + a day). */
17021
+ var SCHEDULE_SCAN_LOOKBACK_MS = 8 * 864e5;
17022
+ /**
17023
+ * Nudge a threshold to a value that PASSES it, and that reads like something a
17024
+ * detector would produce. Clamped to 1 so a rule with a floor of 1 still gets a
17025
+ * legal value.
17026
+ */
17027
+ function justAbove(floor) {
17028
+ return Math.min(1, Math.round((floor + .05) * 100) / 100);
17029
+ }
17030
+ /**
17031
+ * A timestamp the rule's SCHEDULE is active at — `now` when it already is.
17032
+ *
17033
+ * Scans BACKWARDS on the schedule's own minute grid through `isScheduleActive`,
17034
+ * the single authority on what a window means (midnight crossing, `invert`,
17035
+ * IANA timezone). Backwards rather than forwards because a test must fire NOW:
17036
+ * a future instant would be a notification about something that has not
17037
+ * happened, and the outbox would deliver it immediately anyway.
17038
+ *
17039
+ * `null` when the schedule has no active minute in the lookback — a rule that
17040
+ * is currently unfireable, which is a note rather than a silent failure.
17041
+ */
17042
+ function scheduleActiveAt(schedule, now) {
17043
+ if (schedule === void 0) return now;
17044
+ if (isScheduleActive(schedule, now)) return now;
17045
+ const floor = now - SCHEDULE_SCAN_LOOKBACK_MS;
17046
+ for (let t = now - SCHEDULE_SCAN_STEP_MS; t >= floor; t -= SCHEDULE_SCAN_STEP_MS) if (isScheduleActive(schedule, t)) return t;
17047
+ return null;
17048
+ }
17049
+ /**
17050
+ * The bbox that satisfies a `customZones` polygon: the polygon's own bounding
17051
+ * box, clamped to the frame.
17052
+ *
17053
+ * The engine's test is `bboxPolygonOverlap(bbox, points) > 0`, and a polygon's
17054
+ * bounding box always overlaps it (a polygon with area is contained in its own
17055
+ * box). The admin simulator's centred half-frame box does NOT — a polygon drawn
17056
+ * in a corner fails it, and the operator reads a working rule as broken.
17057
+ */
17058
+ function bboxForPolygon(points) {
17059
+ if (points.length === 0) return void 0;
17060
+ const xs = points.map((p) => p.x);
17061
+ const ys = points.map((p) => p.y);
17062
+ const minX = Math.max(0, Math.min(...xs));
17063
+ const minY = Math.max(0, Math.min(...ys));
17064
+ const maxX = Math.min(1, Math.max(...xs));
17065
+ const maxY = Math.min(1, Math.max(...ys));
17066
+ const w = Math.max(.01, maxX - minX);
17067
+ const h = Math.max(.01, maxY - minY);
17068
+ return {
17069
+ x: minX,
17070
+ y: minY,
17071
+ w: Math.min(1 - minX, w),
17072
+ h: Math.min(1 - minY, h)
17073
+ };
17074
+ }
17075
+ /**
17076
+ * The window that answers an audio condition.
17077
+ *
17078
+ * Every spec field is copied from the CONDITION, because `matchesAudio`
17079
+ * compares them for equality — this is the pairing rule that stops a hub with
17080
+ * two sound rules on one camera answering one rule with the other's evidence.
17081
+ * `labels` are normalized and sorted exactly as `audioSpecFromCondition` does,
17082
+ * or the elementwise comparison fails on ordering alone.
17083
+ */
17084
+ function audioWindowFor(condition) {
17085
+ const labels = condition.labels !== void 0 && condition.labels.length > 0 ? [...new Set(condition.labels.map(normalizeAudioLabel))].sort() : void 0;
17086
+ const samples = Math.max(2, condition.samplingSeconds);
17087
+ const hits = Math.max(1, Math.ceil(samples * condition.hitPercent / 100));
17088
+ return {
17089
+ hitPercent: Math.min(100, Math.round(hits / samples * 100)),
17090
+ samplingSeconds: condition.samplingSeconds,
17091
+ samples,
17092
+ hits,
17093
+ ...condition.dbThreshold !== void 0 ? {
17094
+ dbThreshold: condition.dbThreshold,
17095
+ peakDbfs: Math.min(0, condition.dbThreshold + 6)
17096
+ } : {},
17097
+ ...labels !== void 0 ? { specLabels: labels } : {},
17098
+ labels: labels ?? []
17099
+ };
17100
+ }
17101
+ /**
17102
+ * The edge that satisfies an occupancy condition.
17103
+ *
17104
+ * Each op has exactly ONE edge that passes it (see `matchesOccupancy`): the
17105
+ * count arrives AT the threshold from below, falls to zero from it, or falls TO
17106
+ * the threshold from one above.
17107
+ */
17108
+ function occupancyEdgeFor(condition) {
17109
+ const threshold = Math.max(1, Math.round(condition.count));
17110
+ const shared = {
17111
+ ...condition.zoneId !== void 0 ? { zoneId: condition.zoneId } : {},
17112
+ ...condition.className !== void 0 ? { className: condition.className } : {}
17113
+ };
17114
+ if (condition.op === "became-free") return {
17115
+ ...shared,
17116
+ count: 0,
17117
+ previousCount: threshold,
17118
+ occupied: false,
17119
+ threshold
17120
+ };
17121
+ if (condition.op === "<=") {
17122
+ const count = Math.max(0, Math.round(condition.count));
17123
+ return {
17124
+ ...shared,
17125
+ count,
17126
+ previousCount: count + 1,
17127
+ occupied: false,
17128
+ threshold: count + 1
17129
+ };
17130
+ }
17131
+ return {
17132
+ ...shared,
17133
+ count: threshold,
17134
+ previousCount: Math.max(0, threshold - 1),
17135
+ occupied: true,
17136
+ threshold
17137
+ };
17138
+ }
17139
+ /**
17140
+ * The catalog keys a synthetic system event of this kind carries.
17141
+ *
17142
+ * MIRRORS `incomingFromSystemEvent`'s key derivation, and the mirror is pinned
17143
+ * by a spec that asserts every key exists in the shipped English bundle — a key
17144
+ * nothing resolves renders an EMPTY notification, which is the one failure a
17145
+ * tester must not be able to produce. The BODY VARIANTS the intake chooses from
17146
+ * the payload (`.profiled`, `.agent`, `.onNode`) are deliberately not chosen
17147
+ * here: the plain variant is the one every kind has.
17148
+ */
17149
+ function systemEventKeys(kind) {
17150
+ if (kind === "node-online" || kind === "node-offline") return {
17151
+ titleKey: `system.${kind}.title`,
17152
+ bodyKey: `system.${kind}.body.agent`
17153
+ };
17154
+ if (kind === "alarm-armed" || kind === "alarm-arming" || kind === "alarm-arm-refused") return {
17155
+ titleKey: `system.${kind}.title.${TEST_ALARM_MODE}`,
17156
+ bodyKey: `system.${kind}.body`
17157
+ };
17158
+ return {
17159
+ titleKey: `system.${kind}.title`,
17160
+ bodyKey: `system.${kind}.body`
17161
+ };
17162
+ }
17163
+ /** The arm mode a synthetic alarm event stands in for — see {@link systemEventKeys}. */
17164
+ var TEST_ALARM_MODE = "away";
17165
+ /** The dedup subject the live intake stamps for this kind — same families. */
17166
+ function systemEventSubject(kind, scope) {
17167
+ if (kind === "device-disabled" || kind === "device-enabled") return `device-switch:${scope.deviceId ?? 0}`;
17168
+ if (kind === "device-online" || kind === "device-offline") return `device:${scope.deviceId ?? 0}`;
17169
+ if (kind === "stream-online" || kind === "stream-offline") return `stream:${scope.deviceId ?? 0}`;
17170
+ if (kind === "node-online" || kind === "node-offline") return `node:${scope.nodeId ?? "hub"}`;
17171
+ if (kind === "addon-update-available" || kind === "server-update-available") return `package:${scope.packageName ?? "@camstack/server"}`;
17172
+ return `alarm:${scope.deviceId ?? 0}`;
17173
+ }
17174
+ /**
17175
+ * The class the subject carries, chosen so it PASSES the rule's class
17176
+ * conditions rather than merely existing.
17177
+ *
17178
+ * A macro selection (`vehicle`) is written verbatim: the engine expands macro →
17179
+ * leaves, so the macro itself is always in its own expansion. The exclude list
17180
+ * is then honoured — a class that is both wanted and vetoed is a rule that can
17181
+ * never fire, and the plan must not paper over it.
17182
+ */
17183
+ function classNamesFor(conditions) {
17184
+ const excluded = new Set((conditions.classesExclude ?? []).map((c) => c.trim().toLowerCase()));
17185
+ const first = (conditions.classes ?? []).filter((c) => !excluded.has(c.trim().toLowerCase()))[0] ?? conditions.classes?.[0];
17186
+ if (first !== void 0) return [first];
17187
+ return excluded.has("person") ? [] : ["person"];
17188
+ }
17189
+ /** The label that satisfies the rule's identity / plate / label matchers. */
17190
+ function labelFor(conditions) {
17191
+ return conditions.identities?.[0] ?? conditions.labelEquals?.[0] ?? conditions.plates?.values?.[0] ?? void 0;
17192
+ }
17193
+ /**
17194
+ * Plan the injection that fires this rule.
17195
+ *
17196
+ * Every condition the engine reads off the SUBJECT is satisfied here; the two
17197
+ * that read the world are reported in {@link NcRuleTestPlan.notes}.
17198
+ */
17199
+ function planRuleTest(input) {
17200
+ const { rule, now } = input;
17201
+ const c = rule.conditions;
17202
+ const trigger = testTriggerForRule(rule);
17203
+ const notes = [];
17204
+ const systemScope = {
17205
+ ...c.systemEvent?.deviceIds?.[0] !== void 0 ? { deviceId: c.systemEvent.deviceIds[0] } : {},
17206
+ ...c.systemEvent?.nodeIds?.[0] !== void 0 ? { nodeId: c.systemEvent.nodeIds[0] } : {},
17207
+ ...c.systemEvent?.packageNames?.[0] !== void 0 ? { packageName: c.systemEvent.packageNames[0] } : {}
17208
+ };
17209
+ const scopedDeviceId = trigger === "system-event" ? systemScope.deviceId ?? input.fallbackDeviceId ?? NC_SYSTEM_DEVICE_ID : c.devices?.[0] ?? input.fallbackDeviceId;
17210
+ if (scopedDeviceId === void 0) notes.push({
17211
+ conditionId: "devices",
17212
+ note: "This rule names no camera and none was supplied, so the event is attributed to no device and carries no picture. Scope the rule to a camera, or pick one, to see the notification it would really send."
17213
+ });
17214
+ const deviceId = scopedDeviceId ?? NC_SYSTEM_DEVICE_ID;
17215
+ const scheduledAt = scheduleActiveAt(rule.schedule, now);
17216
+ if (scheduledAt === null) notes.push({
17217
+ conditionId: "schedule",
17218
+ note: "This rule’s weekly schedule has no active minute in the last eight days, so the test fires outside it and the schedule condition will refuse the event — exactly as it refuses every real one."
17219
+ });
17220
+ else if (scheduledAt !== now) notes.push({
17221
+ conditionId: "schedule",
17222
+ note: "The rule is outside its schedule right now, so the event is stamped with the most recent instant inside a window. The notification therefore reports a past time."
17223
+ });
17224
+ if (c.deviceState !== void 0) notes.push({
17225
+ conditionId: "deviceState",
17226
+ note: `This rule only fires while device #${c.deviceState.deviceId} is in one of its chosen states. That gate reads the REAL device and cannot be simulated — put the device in that state first, or the test fails closed exactly as production would.`
17227
+ });
17228
+ if (c.customZones !== void 0 && c.customZones.length > 0) notes.push({
17229
+ conditionId: "customZones",
17230
+ note: "The subject is placed over the first drawn polygon so the condition passes. A rule that needs a specific polygon should be checked in the simulator, where the box can be moved."
17231
+ });
17232
+ const zones = c.zones?.ids ?? [];
17233
+ const label = labelFor(c);
17234
+ const bbox = c.customZones !== void 0 && c.customZones.length > 0 ? bboxForPolygon(c.customZones[0]?.points ?? []) : void 0;
17235
+ const audioWindow = trigger === "audio-window" && c.audio !== void 0 ? audioWindowFor(c.audio) : void 0;
17236
+ const classNames = trigger === "audio-window" ? (audioWindow?.labels ?? []).map((l) => `audio-${l}`) : trigger === "occupancy-event" ? [c.occupancy?.className ?? "occupancy"] : trigger === "device-event" || trigger === "system-event" ? [] : classNamesFor(c);
17237
+ const systemEventKind = c.systemEvent?.kinds[0];
17238
+ if (trigger === "system-event" && systemEventKind === void 0) notes.push({
17239
+ conditionId: "systemEvent",
17240
+ note: "This rule names no system event, so nothing can be injected for it — the hub refuses such a rule at save time and the engine matches nothing."
17241
+ });
17242
+ return {
17243
+ trigger,
17244
+ input: {
17245
+ trigger,
17246
+ deviceId,
17247
+ classNames: [...classNames],
17248
+ ...label !== void 0 ? { label } : {},
17249
+ ...c.minConfidence !== void 0 ? { confidence: justAbove(c.minConfidence) } : {},
17250
+ ...c.minLabelConfidence !== void 0 ? { labelConfidence: justAbove(c.minLabelConfidence) } : {},
17251
+ ...c.minImportance !== void 0 ? { importance: justAbove(c.minImportance) } : {},
17252
+ ...c.minDwellSeconds !== void 0 ? { dwellSeconds: Math.round(c.minDwellSeconds) + 1 } : {},
17253
+ zones: [...zones],
17254
+ ...trigger === "object-event" && c.crossing === "exit" ? { crossing: {
17255
+ zoneId: zones[0] ?? "zone",
17256
+ direction: "exit"
17257
+ } } : {},
17258
+ ...c.sensorKinds?.[0] !== void 0 ? { sensorKind: c.sensorKinds[0] } : {},
17259
+ ...c.eventTypeTokens?.[0] !== void 0 ? { eventType: c.eventTypeTokens[0] } : {},
17260
+ ...trigger === "package-event" ? { packagePhase: c.packagePhase === "picked-up" ? "picked-up" : "delivered" } : {},
17261
+ ...c.source !== void 0 && c.source !== "any" ? { source: c.source } : {},
17262
+ ...bbox !== void 0 ? { bbox } : {},
17263
+ ...trigger === "occupancy-event" && c.occupancy !== void 0 ? { occupancy: occupancyEdgeFor(c.occupancy) } : {},
17264
+ ...audioWindow !== void 0 ? { audioWindow } : {},
17265
+ ...trigger === "system-event" && systemEventKind !== void 0 ? { systemEvent: {
17266
+ kind: systemEventKind,
17267
+ subject: systemEventSubject(systemEventKind, systemScope),
17268
+ ...systemEventKeys(systemEventKind),
17269
+ textParams: {
17270
+ deviceId: String(systemScope.deviceId ?? deviceId),
17271
+ deviceName: `camera ${String(systemScope.deviceId ?? deviceId)}`,
17272
+ nodeId: systemScope.nodeId ?? "hub",
17273
+ packageName: systemScope.packageName ?? "@camstack/server",
17274
+ currentVersion: "0.0.0",
17275
+ latestVersion: "0.0.0",
17276
+ profile: "low",
17277
+ seconds: "30"
17278
+ },
17279
+ ...systemScope.deviceId !== void 0 ? { deviceId: systemScope.deviceId } : {},
17280
+ ...c.systemEvent?.deviceTypes?.[0] !== void 0 ? { deviceType: c.systemEvent.deviceTypes[0] } : {},
17281
+ ...systemScope.nodeId !== void 0 ? { nodeId: systemScope.nodeId } : {},
17282
+ ...systemScope.packageName !== void 0 ? { packageName: systemScope.packageName } : {}
17283
+ } } : {},
17284
+ timestamp: scheduledAt ?? now,
17285
+ onlyRuleId: rule.id,
17286
+ bypassCooldown: true,
17287
+ bypassMute: true,
17288
+ runActions: false,
17289
+ still: "live"
17290
+ },
17291
+ notes
17292
+ };
17293
+ }
17294
+ //#endregion
15141
17295
  //#region src/notification-center/nc-actions.ts
15142
17296
  /**
15143
17297
  * nc-actions — the `pipeline-analytics` rule-bridge action catalog.
@@ -15237,6 +17391,58 @@ var ncActions = defineCustomActions({
15237
17391
  auth: "admin",
15238
17392
  caller: "required"
15239
17393
  }),
17394
+ /**
17395
+ * FIRE THIS RULE NOW — the per-rule Test button.
17396
+ *
17397
+ * `nc.injectTestEvent` asks the operator to describe an event; this asks the
17398
+ * RULE. The server plans the synthetic subject from the rule's own conditions
17399
+ * (`rule-test-plan.ts`) and hands it to exactly the same injection, so the
17400
+ * notification travels the whole real path: `evaluateRule` → durable outbox →
17401
+ * the ordinary drain → the ordinary media ladders → the notifier. It arrives
17402
+ * on the phone, which is the entire point.
17403
+ *
17404
+ * It BYPASSES the cooldown and the per-camera mute, deliberately: the
17405
+ * operator pressed a button and must receive the notification, not a report
17406
+ * that a window from ten minutes ago swallowed it. Neither window is
17407
+ * CONSUMED, so the next real fire is unaffected. It never runs the rule's
17408
+ * action sequences — those unlock doors, and "does this notify" is not a
17409
+ * request to open a gate.
17410
+ *
17411
+ * `auth: 'admin'` for the same reason as the injection it wraps: it delivers
17412
+ * to every target the rule addresses, on other people's phones.
17413
+ */
17414
+ "nc.testRule": customAction(object({
17415
+ ruleId: string(),
17416
+ /** The camera to attribute the event to when the rule scopes none. */
17417
+ deviceId: number().int().optional()
17418
+ }), NcTestEventResultSchema.extend({
17419
+ /** The subject kind the rule was tested with — the answer to "why was my
17420
+ * rule not a candidate". */
17421
+ trigger: string(),
17422
+ /** What the plan could not reproduce (a device-state gate, a polygon). */
17423
+ planNotes: array(object({
17424
+ conditionId: string(),
17425
+ note: string()
17426
+ }))
17427
+ }), {
17428
+ kind: "mutation",
17429
+ auth: "admin",
17430
+ caller: "required"
17431
+ }),
17432
+ /**
17433
+ * What this rule is doing RIGHT NOW — a discriminated union per rule kind.
17434
+ *
17435
+ * Answers the question a rule LIST structurally cannot: an occupancy rule's
17436
+ * subject is a live count, and a timelapse rule's output arrives hours after
17437
+ * the window it is about. Everything returned is DERIVED from the watcher's
17438
+ * mirror, the live snapshot and the stores — nothing is a second copy of
17439
+ * state that could disagree with them.
17440
+ *
17441
+ * `caller: 'required'` and not admin-only: a personal rule's owner may read
17442
+ * the status of their own rule, and the handler refuses a rule they cannot
17443
+ * see rather than returning a redacted one.
17444
+ */
17445
+ "nc.getRuleStatus": customAction(object({ ruleId: string() }), NcRuleStatusSchema, { caller: "required" }),
15240
17446
  "nc.listTimelapseRules": customAction(object({}), object({ rules: array(NcViewerTimelapseRuleSchema) }), { caller: "required" }),
15241
17447
  /**
15242
17448
  * ONE action for create AND update, unlike the detection-rule pair.
@@ -15368,7 +17574,23 @@ var ncActions = defineCustomActions({
15368
17574
  }), NcTextCatalogViewSchema, {
15369
17575
  kind: "mutation",
15370
17576
  auth: "admin"
15371
- })
17577
+ }),
17578
+ /**
17579
+ * One page of artefacts, newest `capturedAt` first.
17580
+ *
17581
+ * `deviceIds` filters on the camera an artefact is FILED under. A digest
17582
+ * spans cameras and is filed under its rule's first one (the same camera its
17583
+ * outbox row uses); to ask "everything this digest produced" use `ruleId`.
17584
+ */
17585
+ "nc.listArtifacts": customAction(NcArtifactQuerySchema, object({
17586
+ artifacts: array(NcArtifactRecordSchema),
17587
+ /** Rows matching the filter, not rows on this page. */
17588
+ total: number().int().nonnegative(),
17589
+ hasMore: boolean()
17590
+ }), { caller: "required" }),
17591
+ /** One artefact's metadata. `null` for unknown, expired AND not-yours alike —
17592
+ * a surface that distinguishes them reports what other people own. */
17593
+ "nc.getArtifact": customAction(object({ artifactId: string().min(1) }), object({ artifact: NcArtifactRecordSchema.nullable() }), { caller: "required" })
15372
17594
  });
15373
17595
  /** Fail-closed caller resolution — an absent forwarded caller is NEVER admin. */
15374
17596
  function requireCaller(caller) {
@@ -15418,6 +17640,12 @@ function makeNcActionHandlers(deps) {
15418
17640
  if (sum === void 0) throw new Error("this node does not host the summary producer — use the hub");
15419
17641
  return sum;
15420
17642
  };
17643
+ /** The artefact index, or an honest refusal. */
17644
+ const requireArtifacts = () => {
17645
+ const index = deps.artifacts;
17646
+ if (index === void 0) throw new Error("this node does not host the notification artifact index — use the hub");
17647
+ return index;
17648
+ };
15421
17649
  /** The text catalog editor, or an honest refusal. */
15422
17650
  const requireTexts = () => {
15423
17651
  const editor = deps.texts;
@@ -15548,6 +17776,54 @@ function makeNcActionHandlers(deps) {
15548
17776
  });
15549
17777
  return inject(parsed);
15550
17778
  },
17779
+ "nc.testRule": async (input, caller) => {
17780
+ const c = requireCaller(caller);
17781
+ if (!c.isAdmin) throw new Error("forbidden: admin required to test a rule");
17782
+ const inject = deps.injectTestEvent;
17783
+ if (inject === void 0) throw new Error("this node does not host the notification centre — test on the hub");
17784
+ const rule = assertRuleVisible(input.ruleId, c);
17785
+ const plan = planRuleTest({
17786
+ rule,
17787
+ now: Date.now(),
17788
+ ...input.deviceId !== void 0 ? { fallbackDeviceId: input.deviceId } : {}
17789
+ });
17790
+ deps.logger.info("rule test requested", {
17791
+ tags: { deviceId: plan.input.deviceId },
17792
+ meta: {
17793
+ by: c.userId,
17794
+ ruleId: rule.id,
17795
+ ruleName: rule.name,
17796
+ trigger: plan.trigger,
17797
+ bypassCooldown: true,
17798
+ bypassMute: true,
17799
+ planNotes: plan.notes.map((n) => n.conditionId)
17800
+ }
17801
+ });
17802
+ return {
17803
+ ...await inject(plan.input),
17804
+ trigger: plan.trigger,
17805
+ planNotes: plan.notes.map((n) => ({
17806
+ conditionId: n.conditionId,
17807
+ note: n.note
17808
+ }))
17809
+ };
17810
+ },
17811
+ "nc.getRuleStatus": async (input, caller) => {
17812
+ const c = requireCaller(caller);
17813
+ const status = deps.ruleStatus;
17814
+ if (status === void 0) throw new Error("this node does not host the notification centre — ask the hub");
17815
+ const detection = deps.ruleStore.get(input.ruleId);
17816
+ if (detection !== null) {
17817
+ assertRuleVisible(input.ruleId, c);
17818
+ return status.forRule(detection);
17819
+ }
17820
+ const timelapse = deps.timelapse?.store.get(input.ruleId) ?? null;
17821
+ if (timelapse !== null) {
17822
+ if (!c.isAdmin && timelapse.ownerUserId !== c.userId) throw new Error(`forbidden: timelapse rule not owned: ${input.ruleId}`);
17823
+ return status.forTimelapseRule(timelapse);
17824
+ }
17825
+ throw new Error(`rule not found: ${input.ruleId}`);
17826
+ },
15551
17827
  "nc.listTimelapseRules": async (_input, caller) => {
15552
17828
  const c = requireCaller(caller);
15553
17829
  const tl = requireTimelapse();
@@ -15729,7 +18005,26 @@ function makeNcActionHandlers(deps) {
15729
18005
  return input.language === void 0 ? texts.view() : texts.view(input.language);
15730
18006
  },
15731
18007
  "nc.setLanguage": async (input) => requireTexts().setLanguage(input.language),
15732
- "nc.setTextOverrides": async (input) => requireTexts().setOverrides(input.language, input.overrides)
18008
+ "nc.setTextOverrides": async (input) => requireTexts().setOverrides(input.language, input.overrides),
18009
+ "nc.listArtifacts": async (input, caller) => {
18010
+ const c = requireCaller(caller);
18011
+ const page = await requireArtifacts().list(input, {
18012
+ userId: c.userId,
18013
+ isAdmin: c.isAdmin
18014
+ });
18015
+ return {
18016
+ artifacts: [...page.artifacts],
18017
+ total: page.total,
18018
+ hasMore: page.hasMore
18019
+ };
18020
+ },
18021
+ "nc.getArtifact": async (input, caller) => {
18022
+ const c = requireCaller(caller);
18023
+ return { artifact: await requireArtifacts().get(input.artifactId, {
18024
+ userId: c.userId,
18025
+ isAdmin: c.isAdmin
18026
+ }) };
18027
+ }
15733
18028
  };
15734
18029
  }
15735
18030
  //#endregion
@@ -15783,7 +18078,8 @@ var PREVIEW_FALLBACK_NAME = "timelapse-preview.jpg";
15783
18078
  * member has a logged reason. It never rejects.
15784
18079
  */
15785
18080
  function buildTimelapseArtifactPublisher(ports) {
15786
- return async ({ exportId, deviceId, previewText, previewMode }) => {
18081
+ return async (request) => {
18082
+ const { exportId, deviceId, previewText, previewMode } = request;
15787
18083
  const tags = { deviceId };
15788
18084
  const none = {
15789
18085
  video: null,
@@ -15814,15 +18110,32 @@ function buildTimelapseArtifactPublisher(ports) {
15814
18110
  return none;
15815
18111
  }
15816
18112
  const mp4 = Buffer$1.from(read.base64, "base64");
18113
+ const capturedAt = request.capturedAt ?? Date.now();
18114
+ const common = {
18115
+ deviceId,
18116
+ deviceIds: [deviceId],
18117
+ capturedAt,
18118
+ ...request.ruleId !== void 0 ? { ruleId: request.ruleId } : {},
18119
+ ...request.ruleName !== void 0 ? { ruleName: request.ruleName } : {},
18120
+ ...request.ownerUserId !== void 0 ? { ownerUserId: request.ownerUserId } : {}
18121
+ };
15817
18122
  const preview = await publishPreview(ports, {
15818
18123
  mp4,
15819
18124
  deviceId,
15820
18125
  tags,
15821
18126
  previewText,
15822
- previewMode: previewMode ?? "image"
18127
+ previewMode: previewMode ?? "image",
18128
+ meta: {
18129
+ ...common,
18130
+ kind: "timelapse-preview"
18131
+ }
15823
18132
  });
15824
18133
  try {
15825
- const url = await ports.publish(mp4, read.contentType);
18134
+ const { url } = await ports.publish(mp4, read.contentType, {
18135
+ ...common,
18136
+ kind: "timelapse",
18137
+ ...preview.artifactId !== null ? { posterArtifactId: preview.artifactId } : {}
18138
+ });
15826
18139
  if (url === null) {
15827
18140
  ports.logger.warn("timelapse artifact not published: no reachable public endpoint", {
15828
18141
  tags,
@@ -15830,7 +18143,7 @@ function buildTimelapseArtifactPublisher(ports) {
15830
18143
  });
15831
18144
  return {
15832
18145
  video: null,
15833
- preview
18146
+ preview: preview.ref
15834
18147
  };
15835
18148
  }
15836
18149
  ports.logger.info("timelapse artifact published", {
@@ -15839,7 +18152,7 @@ function buildTimelapseArtifactPublisher(ports) {
15839
18152
  exportId,
15840
18153
  bytes: read.bytes,
15841
18154
  name: read.name,
15842
- preview: preview !== null
18155
+ preview: preview.ref !== null
15843
18156
  }
15844
18157
  });
15845
18158
  return {
@@ -15848,7 +18161,7 @@ function buildTimelapseArtifactPublisher(ports) {
15848
18161
  mime: read.contentType,
15849
18162
  name: read.name
15850
18163
  },
15851
- preview
18164
+ preview: preview.ref
15852
18165
  };
15853
18166
  } catch (err) {
15854
18167
  ports.logger.warn("timelapse artifact not published: artifact plane refused the bytes", {
@@ -15860,18 +18173,22 @@ function buildTimelapseArtifactPublisher(ports) {
15860
18173
  });
15861
18174
  return {
15862
18175
  video: null,
15863
- preview
18176
+ preview: preview.ref
15864
18177
  };
15865
18178
  }
15866
18179
  };
15867
18180
  }
18181
+ var NO_PREVIEW = {
18182
+ ref: null,
18183
+ artifactId: null
18184
+ };
15868
18185
  /**
15869
18186
  * The preview half. Best-effort at every step and never throws: the picture is
15870
18187
  * an enhancement, the video is the notification.
15871
18188
  */
15872
18189
  async function publishPreview(ports, input) {
15873
18190
  const render = ports.renderPreview;
15874
- if (render === void 0 || input.previewText === void 0) return null;
18191
+ if (render === void 0 || input.previewText === void 0) return NO_PREVIEW;
15875
18192
  try {
15876
18193
  const rendered = await render({
15877
18194
  mp4: input.mp4,
@@ -15879,23 +18196,29 @@ async function publishPreview(ports, input) {
15879
18196
  deviceId: input.deviceId,
15880
18197
  mode: input.previewMode
15881
18198
  });
15882
- if (rendered === null) return null;
15883
- const url = await ports.publish(rendered.bytes, rendered.mime);
18199
+ if (rendered === null) return NO_PREVIEW;
18200
+ const { url, artifactId } = await ports.publish(rendered.bytes, rendered.mime, input.meta);
15884
18201
  if (url === null) {
15885
18202
  ports.logger.warn("timelapse preview not published: no reachable public endpoint", { tags: input.tags });
15886
- return null;
18203
+ return {
18204
+ ref: null,
18205
+ artifactId
18206
+ };
15887
18207
  }
15888
18208
  return {
15889
- url,
15890
- mime: rendered.mime,
15891
- name: PREVIEW_NAME_BY_MIME[rendered.mime] ?? PREVIEW_FALLBACK_NAME
18209
+ ref: {
18210
+ url,
18211
+ mime: rendered.mime,
18212
+ name: PREVIEW_NAME_BY_MIME[rendered.mime] ?? PREVIEW_FALLBACK_NAME
18213
+ },
18214
+ artifactId
15892
18215
  };
15893
18216
  } catch (err) {
15894
18217
  ports.logger.warn("timelapse preview not published: publish threw", {
15895
18218
  tags: input.tags,
15896
18219
  meta: { error: String(err) }
15897
18220
  });
15898
- return null;
18221
+ return NO_PREVIEW;
15899
18222
  }
15900
18223
  }
15901
18224
  /** Title size as a fraction of the frame WIDTH — so it reads the same on a
@@ -16382,6 +18705,10 @@ function startAlarmPanelConvergence(ports) {
16382
18705
  * `data-plane-access.spec.ts` asserts they still match the constants their
16383
18706
  * handlers register with — a mirror nothing checks is a lie waiting to happen.
16384
18707
  */
18708
+ /** The artefact read route's prefix, imported rather than mirrored: it is
18709
+ * ALSO the prefix `artifactDownloadPath` writes into every index row, and two
18710
+ * copies of that string is a 404 nobody would find. */
18711
+ var NC_ARTIFACT_READ_PREFIX_REF = NC_ARTIFACT_READ_PREFIX;
16385
18712
  var TRAINING_EXPORT_PREFIX_REF = "training-export";
16386
18713
  var RETRAIN_EXPORT_PREFIX_REF = "retrain-export";
16387
18714
  var DEBUG_EXPORT_PREFIX_REF = "debug-export";
@@ -16397,6 +18724,14 @@ var PIPELINE_ANALYTICS_PLANE_ACCESS = {
16397
18724
  "nc-action": "public",
16398
18725
  /** Self-authenticating single-use artifact token (D47). */
16399
18726
  "nc-artifact": "public",
18727
+ /**
18728
+ * The VIEWER's read of the same bytes (task #36). One indexed artefact by an
18729
+ * unguessable id the caller was handed by `nc.listArtifacts` — the rule above,
18730
+ * applied unchanged. It is deliberately NOT the `public` signed plane: the
18731
+ * viewer holds a session, and pre-minting a 24 h signed link for every
18732
+ * artefact a user might scroll to is a link-leak surface for no gain.
18733
+ */
18734
+ [NC_ARTIFACT_READ_PREFIX_REF]: "authenticated",
16400
18735
  /** Every frame behind a marked selection — a corpus, not an item. */
16401
18736
  [TRAINING_EXPORT_PREFIX_REF]: "admin",
16402
18737
  [RETRAIN_EXPORT_PREFIX_REF]: "admin",
@@ -17519,9 +19854,14 @@ async function recomputeTrackImportance(deps, trackId) {
17519
19854
  */
17520
19855
  async function applyTrackLabelCandidate(deps, deviceId, trackId, candidate) {
17521
19856
  let trackWritten = false;
19857
+ /** The patch the TRACK accepted — the ONE decision the events copy. */
19858
+ let accepted;
17522
19859
  try {
17523
19860
  const decision = await deps.trackStore?.applyLabelCandidate(trackId, candidate);
17524
- if (decision?.kind === "write") trackWritten = true;
19861
+ if (decision?.kind === "write") {
19862
+ trackWritten = true;
19863
+ accepted = decision.patch;
19864
+ }
17525
19865
  if (decision?.kind === "refused") {
17526
19866
  const message = decision.reason.kind === "macro-class" ? "label tier collapse refused" : "label write refused";
17527
19867
  deps.logger.info(message, {
@@ -17543,7 +19883,7 @@ async function applyTrackLabelCandidate(deps, deviceId, trackId, candidate) {
17543
19883
  }
17544
19884
  let eventsUpdated = 0;
17545
19885
  try {
17546
- eventsUpdated = await deps.eventStore?.applyLabelCandidateForTrack(trackId, candidate) ?? 0;
19886
+ eventsUpdated = accepted !== void 0 ? await deps.eventStore?.projectTrackLabel(trackId, accepted) ?? 0 : 0;
17547
19887
  } catch (err) {
17548
19888
  deps.logger.warn("event label back-patch failed", {
17549
19889
  tags: { deviceId },
@@ -17916,7 +20256,8 @@ var FaceGalleryProvider = class {
17916
20256
  score: 1,
17917
20257
  stepId: OPERATOR_FACE_STEP_ID,
17918
20258
  decidedAt: Date.now(),
17919
- operatorAuthority: true
20259
+ operatorAuthority: true,
20260
+ identityId
17920
20261
  });
17921
20262
  this.refreshGallery();
17922
20263
  this.safeEmitFaceGalleryChanged({
@@ -19441,6 +21782,12 @@ var FrameProcessor = class {
19441
21782
  /** Optional stationary-object gate (parked-object suppression). Null until
19442
21783
  * the addon wires it via {@link setStationaryGate}. */
19443
21784
  stationaryGate = null;
21785
+ /**
21786
+ * The label the TRACK currently carries, read (never mirrored) from the
21787
+ * active-track cache. See {@link setTrackLabelState} — and the use site in
21788
+ * `toObjectEvent`, which is the reason it exists.
21789
+ */
21790
+ trackLabelState = null;
19444
21791
  constructor(deviceId, trackerConfig = {}, stateConfig = {}, emitterConfig = {}, source = "pipeline") {
19445
21792
  this.deviceId = deviceId;
19446
21793
  this.source = source;
@@ -19457,6 +21804,19 @@ var FrameProcessor = class {
19457
21804
  this.zones = zones;
19458
21805
  }
19459
21806
  /**
21807
+ * Wire the reader that answers "what does this track already say it is".
21808
+ *
21809
+ * A READ, not a mirror the late-label path has to remember to feed: the
21810
+ * active-track cache is in-RAM and already the authority every other surface
21811
+ * resolves against, and a mirror that can be forgotten is how a stamped row
21812
+ * and an unstamped one end up in the same track. Absent (tests, and any
21813
+ * caller that has not wired it) ⇒ the emitter behaves exactly as it did
21814
+ * before, stamping only what the current frame decided.
21815
+ */
21816
+ setTrackLabelState(read) {
21817
+ this.trackLabelState = read;
21818
+ }
21819
+ /**
19460
21820
  * How much of a detection's box must lie inside a zone for the zone to be
19461
21821
  * stamped onto it (0–1 fraction of the box's own area).
19462
21822
  *
@@ -19785,7 +22145,7 @@ var FrameProcessor = class {
19785
22145
  const state = forcedState ?? mapObjectStateToTrackState(objectStates.find((o) => o.trackId === e.detection.trackId)?.state);
19786
22146
  const zones = zonesByTrack.get(e.detection.trackId) ?? [];
19787
22147
  const crossing = crossingOf(e);
19788
- const labelPatch = labelPatchByTrack.get(e.detection.trackId);
22148
+ const labelPatch = labelPatchByTrack.get(e.detection.trackId) ?? knownTrackLabelPatch(this.trackLabelState?.(e.detection.trackId));
19789
22149
  return {
19790
22150
  id: randomUUID(),
19791
22151
  deviceId: this.deviceId,
@@ -21456,7 +23816,8 @@ var PlateGalleryProvider = class {
21456
23816
  score: 1,
21457
23817
  stepId: OPERATOR_PLATE_STEP_ID,
21458
23818
  decidedAt: Date.now(),
21459
- operatorAuthority: true
23819
+ operatorAuthority: true,
23820
+ identityId: vehicle.id
21460
23821
  });
21461
23822
  this.refreshGallery();
21462
23823
  this.safeEmitPlateGalleryChanged({
@@ -24831,10 +27192,13 @@ var FaceRecognizer = class {
24831
27192
  tier: 2,
24832
27193
  score: work.matchScore ?? 0,
24833
27194
  stepId: FACE_RECOGNIZER_STEP_ID,
24834
- decidedAt: Date.now()
27195
+ decidedAt: Date.now(),
27196
+ identityId: work.assignedIdentityId
24835
27197
  };
27198
+ let accepted;
24836
27199
  try {
24837
- await this.deps.trackStore.applyLabelCandidate(work.trackId, candidate);
27200
+ const decision = await this.deps.trackStore.applyLabelCandidate(work.trackId, candidate);
27201
+ if (decision?.kind === "write") accepted = decision.patch;
24838
27202
  } catch (err) {
24839
27203
  this.deps.logger.warn("track identity label failed", {
24840
27204
  tags: { deviceId: input.deviceId },
@@ -24844,8 +27208,8 @@ var FaceRecognizer = class {
24844
27208
  }
24845
27209
  });
24846
27210
  }
24847
- try {
24848
- await this.deps.eventStore.applyLabelCandidateForTrack(work.trackId, candidate);
27211
+ if (accepted !== void 0) try {
27212
+ await this.deps.eventStore.projectTrackLabel(work.trackId, accepted);
24849
27213
  } catch (err) {
24850
27214
  this.deps.logger.warn("event identity label failed", {
24851
27215
  tags: { deviceId: input.deviceId },
@@ -30046,8 +32410,25 @@ var PlateRecognizer = class {
30046
32410
  * for an implausible read (junk OCR off a distant/oblique plate) — the
30047
32411
  * caller must NOT stamp a label then. */
30048
32412
  resolveLabel(text, score) {
32413
+ return this.resolveLabelWithVehicle(text, score)?.text ?? null;
32414
+ }
32415
+ /**
32416
+ * {@link resolveLabel} plus the GALLERY ROW it named, when a vehicle claimed
32417
+ * the read.
32418
+ *
32419
+ * The id is what a notification rule matches on — a display name is
32420
+ * renameable and a rule authored on the old spelling goes silently dark
32421
+ * (`LabelAttribution.identityId`). An unclaimed but plausible read still
32422
+ * returns its normalized text with NO `vehicleId`: it is a plate, it is just
32423
+ * nobody's plate.
32424
+ */
32425
+ resolveLabelWithVehicle(text, score) {
30049
32426
  if (!isPlausiblePlateRead(text, score)) return null;
30050
- return this.matchVehicle(text, score)?.name ?? normalizePlate(text);
32427
+ const matched = this.matchVehicle(text, score);
32428
+ return matched !== null ? {
32429
+ text: matched.name,
32430
+ vehicleId: matched.vehicleId
32431
+ } : { text: normalizePlate(text) };
30051
32432
  }
30052
32433
  async processFrame(input) {
30053
32434
  const minConfidence = input.minConfidence ?? 0;
@@ -31606,48 +33987,49 @@ var EventStore = class {
31606
33987
  }
31607
33988
  }
31608
33989
  /**
31609
- * Forward-only: offer a label CANDIDATE to every already-emitted object event
31610
- * of a track (roadmap 4g). Returns the number of events actually updated —
31611
- * which is NOT the number of rows, because the write rule is applied PER ROW
31612
- * against that row's own tiers.
33990
+ * Project the TRACK's label decision onto every object event of that track.
33991
+ * Returns how many rows took it.
31613
33992
  *
31614
- * Per row, not once for the track, and that is the point: the events of one
31615
- * track do not all carry the same label. An early row may hold a tier-1
31616
- * sub-class the frame path stamped and a later row a tier-2 identity; a
31617
- * single decision taken on the track and blanket-applied would either
31618
- * overwrite the stronger value or skip the rows that had nothing.
33993
+ * ONE DECISION, EVERY ROW — and that is the correction, not the design it
33994
+ * replaced. This used to re-run `resolveTieredLabelWrite` per row against
33995
+ * that row's own tiers, on the theory that "the events of one track do not
33996
+ * all carry the same label". They do: a track has ONE subject, and every
33997
+ * read surface (the timeline, the event page, a notification body, a rule's
33998
+ * `identities` condition) shows the track's answer. Re-deciding per row meant
33999
+ * a row whose incumbent happened to outscore the track's winner kept a
34000
+ * different word, and the rows drifted apart from the track and from each
34001
+ * other — measured 2026-08-14 as 407 of 437 tracks whose event rows
34002
+ * disagreed with the track they belong to. A notification quoting one row and
34003
+ * a timeline quoting another then told the operator two different names for
34004
+ * the same person.
34005
+ *
34006
+ * The caller passes the patch the TRACK row accepted, so the two can never
34007
+ * disagree by construction. A refused track write projects nothing: nothing
34008
+ * changed, so there is nothing to copy.
31619
34009
  *
31620
34010
  * Used when face recognition, plate matching or a classifier answers after
31621
34011
  * the events were emitted.
31622
34012
  */
31623
- async applyLabelCandidateForTrack(trackId, candidate) {
34013
+ async projectTrackLabel(trackId, patch) {
31624
34014
  const rows = await this.store.query.query({
31625
34015
  collection: OBJECT_EVENTS_COLLECTION,
31626
34016
  filter: { where: { trackId } }
31627
34017
  });
34018
+ const data = tieredLabelColumnData(patch);
31628
34019
  let updated = 0;
31629
- for (const row of rows) {
31630
- const className = row.data["className"];
31631
- if (typeof className !== "string") continue;
31632
- const decision = resolveTieredLabelWrite({
31633
- className,
31634
- ...readTieredLabelColumns(row.data)
31635
- }, candidate);
31636
- if (decision.kind !== "write") continue;
31637
- try {
31638
- await this.store.update.mutate({
31639
- collection: OBJECT_EVENTS_COLLECTION,
31640
- id: row.id,
31641
- data: tieredLabelColumnData(decision.patch)
31642
- });
31643
- updated++;
31644
- } catch (err) {
31645
- this.logger.warn("applyLabelCandidateForTrack update failed", { meta: {
31646
- trackId,
31647
- eventId: row.id,
31648
- error: String(err)
31649
- } });
31650
- }
34020
+ for (const row of rows) try {
34021
+ await this.store.update.mutate({
34022
+ collection: OBJECT_EVENTS_COLLECTION,
34023
+ id: row.id,
34024
+ data
34025
+ });
34026
+ updated++;
34027
+ } catch (err) {
34028
+ this.logger.warn("projectTrackLabel update failed", { meta: {
34029
+ trackId,
34030
+ eventId: row.id,
34031
+ error: String(err)
34032
+ } });
31651
34033
  }
31652
34034
  return updated;
31653
34035
  }
@@ -36062,138 +38444,6 @@ var BindingCache = class {
36062
38444
  }
36063
38445
  };
36064
38446
  //#endregion
36065
- //#region src/pipeline-analytics/runtime/detection-coverage-cache.ts
36066
- /**
36067
- * DetectionCoverageCache — fleet-seeded, event-invalidated mirror of WHICH
36068
- * devices carry a WRAPPED `detection-pipeline` binding. This is the alarm's
36069
- * coverage authority ("Away armed, 12 cameras" is false if three of them are
36070
- * offline or no longer wrapped), read by the notification centre's device
36071
- * directory (`listDeviceIdentities`).
36072
- *
36073
- * WHY a mirror, not a per-tick sweep. The directory refreshes on the NC's 30 s
36074
- * rule-reload tick. Answering "who still has detection?" with a fleet-wide
36075
- * `getAllBindings` on EVERY tick cost 903 ms / 373 KB / ~505 `getBindings:
36076
- * default wrappers skipped` log lines on the live hub (524 devices), forever —
36077
- * ~3% of hub-main's main thread plus a permanent log-spam baseline. Same defect
36078
- * and fix as `SystemMirror.refreshBinding` (639073e8): ONE fleet read to warm,
36079
- * then per-device updates driven by `DeviceBindingsChanged` events. A wrapper
36080
- * toggle emits that event carrying the device's id and `capName`
36081
- * (`detection-pipeline`), so a single `getBindings(deviceId)` — 21 ms / 613 B —
36082
- * keeps the mirror current without ever touching the fleet again.
36083
- *
36084
- * ── Destructive-direction discipline (D49) ──────────────────────────────────
36085
- *
36086
- * `detectionActive: false` reports a camera as UNCOVERED / switched-off (D62) —
36087
- * the destructive answer, and the one an operator reads as "my alarm is not
36088
- * watching that door". A transient `getBindings` blip must never flip a
36089
- * known-covered camera to uncovered:
36090
- * • a failed refresh KEEPS the previous value (a read that throws changes
36091
- * nothing), and
36092
- * • a single read that would flip a known `true → false` is confirmed by a
36093
- * concordant SECOND read before it lands — an empty binding list is not
36094
- * proof the wrapper is gone.
36095
- * A COLD mirror (seed not yet landed, or a seed that failed) answers `undefined`
36096
- * for every id — and unknown never reports a camera as excluded from coverage.
36097
- */
36098
- var DetectionCoverageCache = class {
36099
- api;
36100
- logger;
36101
- /** deviceId → "has a WRAPPED detection-pipeline binding". Absent = unknown. */
36102
- state = /* @__PURE__ */ new Map();
36103
- warm = false;
36104
- seeding = null;
36105
- constructor(deps) {
36106
- this.api = deps.api;
36107
- this.logger = deps.logger;
36108
- }
36109
- /** Has the ONE fleet seed ever landed? A cold mirror knows nothing. */
36110
- isWarm() {
36111
- return this.warm;
36112
- }
36113
- /** Do we have an answer for this id at all? (`false` ⇒ absent ⇒ unknown.) */
36114
- has(deviceId) {
36115
- return this.state.has(deviceId);
36116
- }
36117
- /** The coverage answer, or `undefined` when this id is unknown. No I/O. */
36118
- get(deviceId) {
36119
- return this.state.get(deviceId);
36120
- }
36121
- /**
36122
- * Seed the whole fleet ONCE with a single `getAllBindings`. Idempotent: a
36123
- * warm cache is a no-op, and concurrent callers coalesce onto one read. A
36124
- * failed seed leaves the mirror COLD so the next tick retries — during that
36125
- * window every id answers unknown, which never reports a camera as excluded.
36126
- */
36127
- async ensureWarm() {
36128
- if (this.warm) return;
36129
- if (this.seeding) return this.seeding;
36130
- this.seeding = this.seed();
36131
- try {
36132
- await this.seeding;
36133
- } finally {
36134
- this.seeding = null;
36135
- }
36136
- }
36137
- async seed() {
36138
- try {
36139
- const rows = await this.api.deviceManager.getAllBindings.query({});
36140
- for (const row of rows) {
36141
- if (this.state.has(row.deviceId)) continue;
36142
- this.state.set(row.deviceId, hasWrappedDetection(row.entries));
36143
- }
36144
- this.warm = true;
36145
- this.logger.info("detection-coverage mirror warm", { meta: { devices: this.state.size } });
36146
- } catch (err) {
36147
- this.logger.warn("detection-coverage seed failed — coverage unknown until it lands", { meta: { error: String(err) } });
36148
- }
36149
- }
36150
- /**
36151
- * A `DeviceBindingsChanged` for the detection cap re-reads THAT ONE device
36152
- * (never the fleet). Fire-and-forget from the synchronous event handler.
36153
- */
36154
- onBindingsChanged(event) {
36155
- if (event.capName !== "detection-pipeline") return Promise.resolve();
36156
- return this.refreshDevice(event.deviceId);
36157
- }
36158
- /** Drop a device the fleet no longer has (`device.unregistered`). */
36159
- forget(deviceId) {
36160
- this.state.delete(deviceId);
36161
- }
36162
- /** Return to cold — used on addon shutdown. */
36163
- clearAll() {
36164
- this.state.clear();
36165
- this.warm = false;
36166
- this.seeding = null;
36167
- }
36168
- async refreshDevice(deviceId) {
36169
- try {
36170
- const active = await this.readWrappedDetection(deviceId);
36171
- const prev = this.state.get(deviceId);
36172
- if (!active && prev === true) {
36173
- if (await this.readWrappedDetection(deviceId)) {
36174
- this.state.set(deviceId, true);
36175
- return;
36176
- }
36177
- }
36178
- this.state.set(deviceId, active);
36179
- } catch (err) {
36180
- this.logger.debug("detection-coverage refresh failed — keeping the previous value", {
36181
- tags: { deviceId },
36182
- meta: { error: String(err) }
36183
- });
36184
- }
36185
- }
36186
- async readWrappedDetection(deviceId) {
36187
- return hasWrappedDetection((await this.api.deviceManager.getBindings.query({ deviceId })).entries);
36188
- }
36189
- };
36190
- /** The one authority: a WRAPPED `detection-pipeline` binding is present. A
36191
- * native binding of the same cap is NOT coverage (D62: absence of the WRAPPED
36192
- * binding is how a deactivated wrapper is represented). */
36193
- function hasWrappedDetection(entries) {
36194
- return entries.some((e) => e.capName === "detection-pipeline" && e.kind === "wrapped");
36195
- }
36196
- //#endregion
36197
38447
  //#region src/pipeline-analytics/runtime/capture-scheduler.ts
36198
38448
  /**
36199
38449
  * Kind-derived default priorities (0 = highest). Explicit `priority` wins.
@@ -36466,6 +38716,138 @@ var CaptureScheduler = class {
36466
38716
  }
36467
38717
  };
36468
38718
  //#endregion
38719
+ //#region src/pipeline-analytics/runtime/detection-coverage-cache.ts
38720
+ /**
38721
+ * DetectionCoverageCache — fleet-seeded, event-invalidated mirror of WHICH
38722
+ * devices carry a WRAPPED `detection-pipeline` binding. This is the alarm's
38723
+ * coverage authority ("Away armed, 12 cameras" is false if three of them are
38724
+ * offline or no longer wrapped), read by the notification centre's device
38725
+ * directory (`listDeviceIdentities`).
38726
+ *
38727
+ * WHY a mirror, not a per-tick sweep. The directory refreshes on the NC's 30 s
38728
+ * rule-reload tick. Answering "who still has detection?" with a fleet-wide
38729
+ * `getAllBindings` on EVERY tick cost 903 ms / 373 KB / ~505 `getBindings:
38730
+ * default wrappers skipped` log lines on the live hub (524 devices), forever —
38731
+ * ~3% of hub-main's main thread plus a permanent log-spam baseline. Same defect
38732
+ * and fix as `SystemMirror.refreshBinding` (639073e8): ONE fleet read to warm,
38733
+ * then per-device updates driven by `DeviceBindingsChanged` events. A wrapper
38734
+ * toggle emits that event carrying the device's id and `capName`
38735
+ * (`detection-pipeline`), so a single `getBindings(deviceId)` — 21 ms / 613 B —
38736
+ * keeps the mirror current without ever touching the fleet again.
38737
+ *
38738
+ * ── Destructive-direction discipline (D49) ──────────────────────────────────
38739
+ *
38740
+ * `detectionActive: false` reports a camera as UNCOVERED / switched-off (D62) —
38741
+ * the destructive answer, and the one an operator reads as "my alarm is not
38742
+ * watching that door". A transient `getBindings` blip must never flip a
38743
+ * known-covered camera to uncovered:
38744
+ * • a failed refresh KEEPS the previous value (a read that throws changes
38745
+ * nothing), and
38746
+ * • a single read that would flip a known `true → false` is confirmed by a
38747
+ * concordant SECOND read before it lands — an empty binding list is not
38748
+ * proof the wrapper is gone.
38749
+ * A COLD mirror (seed not yet landed, or a seed that failed) answers `undefined`
38750
+ * for every id — and unknown never reports a camera as excluded from coverage.
38751
+ */
38752
+ var DetectionCoverageCache = class {
38753
+ api;
38754
+ logger;
38755
+ /** deviceId → "has a WRAPPED detection-pipeline binding". Absent = unknown. */
38756
+ state = /* @__PURE__ */ new Map();
38757
+ warm = false;
38758
+ seeding = null;
38759
+ constructor(deps) {
38760
+ this.api = deps.api;
38761
+ this.logger = deps.logger;
38762
+ }
38763
+ /** Has the ONE fleet seed ever landed? A cold mirror knows nothing. */
38764
+ isWarm() {
38765
+ return this.warm;
38766
+ }
38767
+ /** Do we have an answer for this id at all? (`false` ⇒ absent ⇒ unknown.) */
38768
+ has(deviceId) {
38769
+ return this.state.has(deviceId);
38770
+ }
38771
+ /** The coverage answer, or `undefined` when this id is unknown. No I/O. */
38772
+ get(deviceId) {
38773
+ return this.state.get(deviceId);
38774
+ }
38775
+ /**
38776
+ * Seed the whole fleet ONCE with a single `getAllBindings`. Idempotent: a
38777
+ * warm cache is a no-op, and concurrent callers coalesce onto one read. A
38778
+ * failed seed leaves the mirror COLD so the next tick retries — during that
38779
+ * window every id answers unknown, which never reports a camera as excluded.
38780
+ */
38781
+ async ensureWarm() {
38782
+ if (this.warm) return;
38783
+ if (this.seeding) return this.seeding;
38784
+ this.seeding = this.seed();
38785
+ try {
38786
+ await this.seeding;
38787
+ } finally {
38788
+ this.seeding = null;
38789
+ }
38790
+ }
38791
+ async seed() {
38792
+ try {
38793
+ const rows = await this.api.deviceManager.getAllBindings.query({});
38794
+ for (const row of rows) {
38795
+ if (this.state.has(row.deviceId)) continue;
38796
+ this.state.set(row.deviceId, hasWrappedDetection(row.entries));
38797
+ }
38798
+ this.warm = true;
38799
+ this.logger.info("detection-coverage mirror warm", { meta: { devices: this.state.size } });
38800
+ } catch (err) {
38801
+ this.logger.warn("detection-coverage seed failed — coverage unknown until it lands", { meta: { error: String(err) } });
38802
+ }
38803
+ }
38804
+ /**
38805
+ * A `DeviceBindingsChanged` for the detection cap re-reads THAT ONE device
38806
+ * (never the fleet). Fire-and-forget from the synchronous event handler.
38807
+ */
38808
+ onBindingsChanged(event) {
38809
+ if (event.capName !== "detection-pipeline") return Promise.resolve();
38810
+ return this.refreshDevice(event.deviceId);
38811
+ }
38812
+ /** Drop a device the fleet no longer has (`device.unregistered`). */
38813
+ forget(deviceId) {
38814
+ this.state.delete(deviceId);
38815
+ }
38816
+ /** Return to cold — used on addon shutdown. */
38817
+ clearAll() {
38818
+ this.state.clear();
38819
+ this.warm = false;
38820
+ this.seeding = null;
38821
+ }
38822
+ async refreshDevice(deviceId) {
38823
+ try {
38824
+ const active = await this.readWrappedDetection(deviceId);
38825
+ const prev = this.state.get(deviceId);
38826
+ if (!active && prev === true) {
38827
+ if (await this.readWrappedDetection(deviceId)) {
38828
+ this.state.set(deviceId, true);
38829
+ return;
38830
+ }
38831
+ }
38832
+ this.state.set(deviceId, active);
38833
+ } catch (err) {
38834
+ this.logger.debug("detection-coverage refresh failed — keeping the previous value", {
38835
+ tags: { deviceId },
38836
+ meta: { error: String(err) }
38837
+ });
38838
+ }
38839
+ }
38840
+ async readWrappedDetection(deviceId) {
38841
+ return hasWrappedDetection((await this.api.deviceManager.getBindings.query({ deviceId })).entries);
38842
+ }
38843
+ };
38844
+ /** The one authority: a WRAPPED `detection-pipeline` binding is present. A
38845
+ * native binding of the same cap is NOT coverage (D62: absence of the WRAPPED
38846
+ * binding is how a deactivated wrapper is represented). */
38847
+ function hasWrappedDetection(entries) {
38848
+ return entries.some((e) => e.capName === "detection-pipeline" && e.kind === "wrapped");
38849
+ }
38850
+ //#endregion
36469
38851
  //#region src/pipeline-analytics/pipeline/capture-crop.ts
36470
38852
  function createCaptureCrop(deps) {
36471
38853
  return async (frameHandle, bbox, frameWidth, frameHeight, padding, maxWidth, deviceId) => {
@@ -41338,7 +43720,27 @@ var REBUILD_FALLBACK_NODE_ID = "hub";
41338
43720
  * count. Two thousand is far above any real night and still a bounded read.
41339
43721
  */
41340
43722
  var TIMELAPSE_TRACK_QUERY_LIMIT = 2e3;
43723
+ /**
43724
+ * How many object-event rows ONE timelapse window may read.
43725
+ *
43726
+ * Higher than the track limit because these are per-FRAME rows, not per-subject:
43727
+ * one track routinely writes a handful (`entered`, some `moving`, `left`), and
43728
+ * the merge in `detection-intervals.ts` folds them back to one subject. Ten
43729
+ * thousand covers every night this deployment has produced and still bounds the
43730
+ * read; the store returns the NEWEST rows, so an overflow loses the start of the
43731
+ * window rather than a random slice of it.
43732
+ */
43733
+ var TIMELAPSE_EVENT_QUERY_LIMIT = 1e4;
41341
43734
  var PIPELINE_ANALYTICS_ADDON_ID = "pipeline-analytics";
43735
+ /** Artefact index ⇄ shelf reconcile cadence. Hourly: the two only drift on the
43736
+ * bounds' own timescale (14 days / 2000 entries / 2 GiB). */
43737
+ var NC_ARTIFACT_PRUNE_INTERVAL_MS = 36e5;
43738
+ /** What a publish answers on a node whose artifact plane never came up: no
43739
+ * link, no index row — and, exactly as before, bytes-only delivery. */
43740
+ var NO_PUBLICATION = {
43741
+ url: null,
43742
+ artifactId: null
43743
+ };
41342
43744
  /**
41343
43745
  * The FULL action catalog for this addon.
41344
43746
  *
@@ -41620,6 +44022,12 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
41620
44022
  * data-plane handle. Null until served / when the facility is absent. */
41621
44023
  ncArtifactPlane = null;
41622
44024
  ncArtifactDataPlane = null;
44025
+ /** The viewer's artefact library (task #36): the index, its authenticated
44026
+ * bytes route, and the store the two share. Null until served. */
44027
+ ncArtifactIndex = null;
44028
+ ncArtifactStore = null;
44029
+ ncArtifactReadDataPlane = null;
44030
+ ncArtifactPruneTimer = null;
41623
44031
  /**
41624
44032
  * Roadmap 4b: ONE cut of an event, in every container the rule asked for.
41625
44033
  *
@@ -41806,6 +44214,31 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
41806
44214
  * Best-effort at every step: no facility, no secret or no reachable base URL
41807
44215
  * ⇒ no plane, and the dispatcher keeps shipping bytes exactly as before.
41808
44216
  */
44217
+ /**
44218
+ * Reconcile the artefact index against the shelf that holds the bytes.
44219
+ *
44220
+ * Hourly, because the two drift only on the bounds' timescale: a row expires
44221
+ * on the shelf's own age bound (14 d) or its file is evicted by the count/byte
44222
+ * bound. Without this, a list eventually serves rows whose bytes 404 — and a
44223
+ * broken tile is the one failure an operator DOES report.
44224
+ */
44225
+ startNcArtifactPrune() {
44226
+ if (this.ncArtifactPruneTimer !== null) return;
44227
+ const run = async () => {
44228
+ const index = this.ncArtifactIndex;
44229
+ const store = this.ncArtifactStore;
44230
+ if (index === null || store === null) return;
44231
+ try {
44232
+ await store.sweep();
44233
+ await index.prune(this.isPostProcessingNode ? await store.keptIds() : null);
44234
+ } catch (err) {
44235
+ this.ctx.logger.warn("nc artifact prune failed", { meta: { error: errMsg(err) } });
44236
+ }
44237
+ };
44238
+ run();
44239
+ this.ncArtifactPruneTimer = setInterval(() => void run(), NC_ARTIFACT_PRUNE_INTERVAL_MS);
44240
+ this.ncArtifactPruneTimer.unref?.();
44241
+ }
41809
44242
  async serveNcArtifactPlane() {
41810
44243
  try {
41811
44244
  const secretState = this.state("ncArtifactSecret", NcArtifactSecretSchema, "");
@@ -41819,9 +44252,18 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
41819
44252
  logger: this.ctx.logger.child("nc-artifacts")
41820
44253
  });
41821
44254
  await store.start();
44255
+ this.ncArtifactStore = store;
44256
+ const index = new NcArtifactIndex({
44257
+ store: this.ctx.api.settingsStore,
44258
+ logger: this.ctx.logger.child("nc-artifacts"),
44259
+ addonId: this.ctx.id,
44260
+ retentionMs: store.retentionMs
44261
+ });
44262
+ this.ncArtifactIndex = index;
41822
44263
  const plane = new NcArtifactPlane({
41823
44264
  store,
41824
44265
  secret,
44266
+ index,
41825
44267
  logger: this.ctx.logger.child("nc-artifacts"),
41826
44268
  routePrefix: `/addon/${this.ctx.id}/nc-artifact`,
41827
44269
  listEndpoints: () => this.ncEndpoints()
@@ -41832,7 +44274,23 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
41832
44274
  handler: plane.handler
41833
44275
  }) ?? null;
41834
44276
  this.ncArtifactPlane = this.ncArtifactDataPlane !== null ? plane : null;
41835
- this.ctx.logger.info("nc-artifact data-plane served", { meta: { served: this.ncArtifactPlane !== null } });
44277
+ const readPlane = new NcArtifactReadPlane({
44278
+ index,
44279
+ store,
44280
+ logger: this.ctx.logger.child("nc-artifacts")
44281
+ });
44282
+ this.ncArtifactReadDataPlane = await this.ctx.dataPlane?.serve({
44283
+ prefix: NC_ARTIFACT_READ_PREFIX_REF,
44284
+ access: planeAccess(NC_ARTIFACT_READ_PREFIX_REF),
44285
+ handler: readPlane.handler
44286
+ }) ?? null;
44287
+ this.startNcArtifactPrune();
44288
+ this.ctx.logger.info("nc-artifact data-plane served", { meta: {
44289
+ served: this.ncArtifactPlane !== null,
44290
+ libraryServed: this.ncArtifactReadDataPlane !== null,
44291
+ libraryAccess: planeAccess(NC_ARTIFACT_READ_PREFIX_REF),
44292
+ retentionDays: Math.round(store.retentionMs / 864e5)
44293
+ } });
41836
44294
  } catch (err) {
41837
44295
  this.ctx.logger.warn("nc-artifact data-plane failed to serve", { meta: { error: errMsg(err) } });
41838
44296
  }
@@ -42280,7 +44738,25 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
42280
44738
  store: center.summaryStore,
42281
44739
  runNow: (input) => center.runSummaryNow(input)
42282
44740
  },
42283
- texts: center.textCatalogEditor
44741
+ texts: center.textCatalogEditor,
44742
+ ...this.ncArtifactIndex !== null ? { artifacts: this.ncArtifactIndex } : {},
44743
+ ruleStatus: {
44744
+ forRule: (rule) => center.ruleStatusFor(rule),
44745
+ forTimelapseRule: (rule) => center.timelapseStatusFor(rule, async ({ ruleId, deviceId }) => {
44746
+ const index = this.ncArtifactIndex;
44747
+ if (index === null) return null;
44748
+ return (await index.list({
44749
+ kinds: ["timelapse"],
44750
+ ruleId,
44751
+ deviceIds: [deviceId],
44752
+ limit: 1,
44753
+ offset: 0
44754
+ }, {
44755
+ userId: "",
44756
+ isAdmin: true
44757
+ })).artifacts[0] ?? null;
44758
+ })
44759
+ }
42284
44760
  });
42285
44761
  }
42286
44762
  /** Declare typed collections up-front so the first insert doesn't race with
@@ -42753,10 +45229,7 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
42753
45229
  const buf = await this.captureCrop(frame.frameHandle, { ...frame.bbox }, frame.frameWidth, frame.frameHeight, DETAIL_FALLBACK_CROP_PADDING);
42754
45230
  return buf ? buf.toString("base64") : null;
42755
45231
  },
42756
- hasTrackLabel: (trackId) => {
42757
- const label = this.trackStore?.getActiveByTrack(trackId)?.label;
42758
- return label !== void 0 && label.length > 0;
42759
- }
45232
+ hasTrackLabel: (trackId) => hasResolvedLabel(this.trackStore?.getActiveByTrack(trackId))
42760
45233
  });
42761
45234
  }
42762
45235
  /** Binding cache + the zone-analytics / audio-metrics cap providers. */
@@ -42848,6 +45321,14 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
42848
45321
  addonId: device.addonId
42849
45322
  };
42850
45323
  },
45324
+ identityIdsByName: async () => {
45325
+ const byName = /* @__PURE__ */ new Map();
45326
+ const identities = await stores.identityStore.listIdentities();
45327
+ for (const identity of identities) byName.set(identity.name.trim().toLowerCase(), identity.id);
45328
+ const vehicles = await stores.vehicleStore.listVehicles();
45329
+ for (const vehicle of vehicles) byName.set(vehicle.name.trim().toLowerCase(), vehicle.id);
45330
+ return byName;
45331
+ },
42851
45332
  listDeviceIdentities: async () => {
42852
45333
  const all = await api.deviceManager.listAll.query({ projection: "slim" });
42853
45334
  const coverage = this.detectionCoverage;
@@ -43069,7 +45550,7 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
43069
45550
  timestamp,
43070
45551
  data: jpeg
43071
45552
  }),
43072
- publish: async (bytes, mime) => await this.ncArtifactPlane?.publish(bytes, mime) ?? null
45553
+ publish: async (bytes, mime, meta) => await this.ncArtifactPlane?.publishIndexed(bytes, mime, meta) ?? NO_PUBLICATION
43073
45554
  };
43074
45555
  }
43075
45556
  /**
@@ -43110,11 +45591,40 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
43110
45591
  projection: "slim",
43111
45592
  limit: TIMELAPSE_TRACK_QUERY_LIMIT
43112
45593
  })).map((t) => ({
45594
+ trackId: t.trackId,
43113
45595
  firstSeen: t.firstSeen,
43114
45596
  lastSeen: t.lastSeen,
43115
45597
  className: t.className
43116
45598
  }));
43117
45599
  },
45600
+ /**
45601
+ * The other half of "did anything happen here": the object-event rows.
45602
+ *
45603
+ * The persisted track store is NOT a complete record of the window. Three
45604
+ * gates destroy a track before it is written — the confirmation gate
45605
+ * retracting a suspected false birth, stationary promotion dropping a
45606
+ * parked object, zero-media suppression cascading a thumbnail-less track
45607
+ * away — and on 2026-08-13 they left camera 615 with zero persisted tracks
45608
+ * across a ten-hour night that held a person pass at 23:31. These rows
45609
+ * survive all three.
45610
+ *
45611
+ * FULL projection, not slim: `slimObject` drops `trackId`, and without it
45612
+ * every event of one subject becomes its own detection. It costs nothing
45613
+ * at the store — the row is read whole either way and slim only strips
45614
+ * fields afterwards.
45615
+ */
45616
+ listDetectionEvents: async ({ deviceId, since, until }) => {
45617
+ return (await stores.eventStore.queryObject({
45618
+ deviceId,
45619
+ since,
45620
+ until,
45621
+ limit: TIMELAPSE_EVENT_QUERY_LIMIT
45622
+ })).map((e) => ({
45623
+ timestamp: e.timestamp,
45624
+ className: e.className,
45625
+ ...e.trackId !== void 0 ? { trackId: e.trackId } : {}
45626
+ }));
45627
+ },
43118
45628
  createExport: (input) => api.recordingExport.createExport.mutate({
43119
45629
  deviceId: input.deviceId,
43120
45630
  profile: input.profile,
@@ -43139,10 +45649,11 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
43139
45649
  }
43140
45650
  }),
43141
45651
  getExport: ({ exportId }) => api.recordingExport.getExport.query({ exportId }),
45652
+ listExports: ({ deviceId }) => api.recordingExport.listExports.query({ deviceId }),
43142
45653
  publishArtifact: buildTimelapseArtifactPublisher({
43143
45654
  logger: this.ctx.logger.child("Timelapse"),
43144
45655
  readExportBytes: ({ exportId }) => api.recordingExport.readExportBytes.query({ exportId }),
43145
- publish: async (bytes, mime) => await this.ncArtifactPlane?.publish(bytes, mime) ?? null,
45656
+ publish: async (bytes, mime, meta) => await this.ncArtifactPlane?.publishIndexed(bytes, mime, meta) ?? NO_PUBLICATION,
43146
45657
  renderPreview: async ({ mode, ...input }) => {
43147
45658
  const previewPorts = {
43148
45659
  logger: this.ctx.logger.child("Timelapse"),
@@ -43809,6 +46320,14 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
43809
46320
  this.retrainExportDataPlane = null;
43810
46321
  await this.debugExportDataPlane?.dispose();
43811
46322
  this.debugExportDataPlane = null;
46323
+ if (this.ncArtifactPruneTimer !== null) {
46324
+ clearInterval(this.ncArtifactPruneTimer);
46325
+ this.ncArtifactPruneTimer = null;
46326
+ }
46327
+ await this.ncArtifactReadDataPlane?.dispose();
46328
+ this.ncArtifactReadDataPlane = null;
46329
+ this.ncArtifactIndex = null;
46330
+ this.ncArtifactStore = null;
43812
46331
  }
43813
46332
  async handleInferenceResult(data) {
43814
46333
  if (this.shuttingDown) return;
@@ -44772,8 +47291,8 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
44772
47291
  });
44773
47292
  }
44774
47293
  }
44775
- const label = d.className === "plate" ? this.plateRecognizer?.resolveLabel(d.label, d.score) ?? null : d.label;
44776
- if (label !== null && label !== void 0) await this.applyTrackEnrichmentLabel(deviceId, trackId, label, d, frame.timestamp);
47294
+ const resolved = d.className === "plate" ? this.plateRecognizer?.resolveLabelWithVehicle(d.label, d.score) ?? null : { text: d.label };
47295
+ if (resolved !== null && resolved.text !== void 0) await this.applyTrackEnrichmentLabel(deviceId, trackId, resolved.text, d, frame.timestamp, resolved.vehicleId);
44777
47296
  }
44778
47297
  } catch (err) {
44779
47298
  this.ctx.logger.warn("detail result route failed", {
@@ -44861,7 +47380,7 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
44861
47380
  * a runner older than the declaration, because the alternative is guessing
44862
47381
  * and putting a species in the coarse slot.
44863
47382
  */
44864
- async applyTrackEnrichmentLabel(deviceId, trackId, label, detail, decidedAt) {
47383
+ async applyTrackEnrichmentLabel(deviceId, trackId, label, detail, decidedAt, identityId) {
44865
47384
  await applyTrackLabelCandidate({
44866
47385
  ...this.trackStore ? { trackStore: this.trackStore } : {},
44867
47386
  ...this.eventStore ? { eventStore: this.eventStore } : {},
@@ -44872,7 +47391,8 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
44872
47391
  score: detail.score,
44873
47392
  stepId: detail.stepId,
44874
47393
  ...detail.labelModelId !== void 0 ? { modelId: detail.labelModelId } : {},
44875
- decidedAt
47394
+ decidedAt,
47395
+ ...identityId !== void 0 ? { identityId } : {}
44876
47396
  });
44877
47397
  }
44878
47398
  /** Active CLIP image-embedding model id (== the text encoder's), TTL-cached.
@@ -45942,6 +48462,18 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
45942
48462
  cooldownSec,
45943
48463
  minTrackAgeMs: trk.minTrackAgeMs
45944
48464
  }, source);
48465
+ p.setTrackLabelState((trackId) => {
48466
+ const active = this.trackStore?.getActiveByTrack(trackId);
48467
+ return active !== null && active !== void 0 ? {
48468
+ className: active.className,
48469
+ ...active.label !== void 0 ? { label: active.label } : {},
48470
+ ...active.labelScore !== void 0 ? { labelScore: active.labelScore } : {},
48471
+ ...active.labelMeta !== void 0 ? { labelMeta: active.labelMeta } : {},
48472
+ ...active.subLabel !== void 0 ? { subLabel: active.subLabel } : {},
48473
+ ...active.subLabelScore !== void 0 ? { subLabelScore: active.subLabelScore } : {},
48474
+ ...active.subLabelMeta !== void 0 ? { subLabelMeta: active.subLabelMeta } : {}
48475
+ } : void 0;
48476
+ });
45945
48477
  if (source === "pipeline" && this.stationaryRegistry) {
45946
48478
  const registry = this.stationaryRegistry;
45947
48479
  p.setStationaryGate({ filter: (input) => {