@camstack/addon-post-analysis 1.2.70 → 1.2.72

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -2,7 +2,7 @@ Object.defineProperties(exports, {
2
2
  __esModule: { value: true },
3
3
  [Symbol.toStringTag]: { value: "Module" }
4
4
  });
5
- const require_dist = require("../dist-DnpVHUcH.js");
5
+ const require_dist = require("../dist-C2w3cl2p.js");
6
6
  let node_fs = require("node:fs");
7
7
  node_fs = require_dist.__toESM(node_fs, 1);
8
8
  let node_path = require("node:path");
@@ -555,7 +555,14 @@ function alarmCoverageGap(coverage) {
555
555
  return "all-skipped";
556
556
  }
557
557
  /**
558
- * "Away armed — 3 cameras: Front door, Garage, Gate."
558
+ * "Attivi: Cancello, Garage — Esclusi: Porta retro."
559
+ *
560
+ * A LIST, not a sentence. The mode is in the title and the body is one
561
+ * `Label: names` line per device group, because this is read on a lock screen
562
+ * and the question is always "which one" — the prose version ("3 dispositivi
563
+ * attivi: …. Esclusi: ….") made the operator parse a paragraph to find a
564
+ * window. Each variant still owns its own catalog key, so the Texts tab can
565
+ * re-word "nothing is armed" without touching "these are armed".
559
566
  *
560
567
  * Names, not ids: this is read by a person standing at a door. An id that has
561
568
  * no name falls back to a NAMED, translated placeholder rather than being
@@ -634,7 +641,7 @@ function buildArmAnnouncement(coverage, deviceName, bypassed = []) {
634
641
  };
635
642
  }
636
643
  /**
637
- * "Home armed — with the veranda window open, excluded."
644
+ * "Attivi: 2 — Bypassati: Finestra veranda."
638
645
  *
639
646
  * The bypassed sensors are named and the armed ones are COUNTED, not listed:
640
647
  * this sentence's subject is the exception, and burying it under thirteen
@@ -657,13 +664,13 @@ function armedWithBypass(coverage, bypassed, deviceName) {
657
664
  };
658
665
  }
659
666
  /**
660
- * "The alarm was NOT armed: the veranda window is open."
667
+ * "Aperti: Finestra veranda, Porta retro" + how to arm anyway.
661
668
  *
662
669
  * The one alarm sentence about something that did not happen, so it has to
663
670
  * carry both halves an operator needs to act: WHICH sensors are open, and that
664
671
  * pressing arm again arms without them. Naming them is the whole message — a
665
672
  * refusal that says only "cannot arm" is a panel the operator will conclude is
666
- * broken.
673
+ * broken — so the names are the FIRST line and the retry hint is the second.
667
674
  */
668
675
  function buildArmRefusedAnnouncement(mode, names, retrySeconds) {
669
676
  return {
@@ -678,7 +685,7 @@ function buildArmRefusedAnnouncement(mode, names, retrySeconds) {
678
685
  };
679
686
  }
680
687
  /**
681
- * The same coverage, said 30 seconds earlier: "Armed in 30 seconds."
688
+ * The EXCEPTIONS to the same coverage, said 30 seconds earlier.
682
689
  *
683
690
  * The exit-delay sentence is deliberately SHORTER than the arm one — it is read
684
691
  * on the way out of a door — and it carries exactly one thing the arm sentence
@@ -686,8 +693,15 @@ function buildArmRefusedAnnouncement(mode, names, retrySeconds) {
686
693
  * A mode that arms nothing is worth interrupting somebody for at second 0 and
687
694
  * merely worth recording at second 30.
688
695
  *
689
- * Pluralises on the SECONDS, not on the devices: this sentence's only number is
690
- * the countdown, and Italian needs "1 secondo" / "30 secondi".
696
+ * **It never repeats the device list.** The countdown lives in the TITLE
697
+ * (`system.alarm-arming.title.<mode>` renders `{{seconds}}`), and the armed
698
+ * list arrives thirty seconds later in the arm announcement — printing it
699
+ * twice is the verbosity that made the operator stop reading both. So the body
700
+ * is what is WRONG: what was bypassed, what is excluded, or that nothing is.
701
+ *
702
+ * `seconds` stays in the params because the title asks for it, and `textCount`
703
+ * stays with it: a row enqueued before this deploy still pluralises on the
704
+ * countdown it was written for.
691
705
  */
692
706
  function buildArmingAnnouncement(coverage, seconds, deviceName, bypassed = []) {
693
707
  const textParams = {
@@ -702,6 +716,7 @@ function buildArmingAnnouncement(coverage, seconds, deviceName, bypassed = []) {
702
716
  },
703
717
  textCount: seconds
704
718
  };
719
+ const skippedNames = coverage.skippedDevices.map((s) => deviceName(s.deviceId)).join(", ");
705
720
  const gap = alarmCoverageGap(coverage);
706
721
  if (gap === "uncovered") return {
707
722
  bodyKey: "alarm.arming.body.uncovered",
@@ -712,10 +727,18 @@ function buildArmingAnnouncement(coverage, seconds, deviceName, bypassed = []) {
712
727
  bodyKey: "alarm.arming.body.allSkipped",
713
728
  textParams: {
714
729
  ...textParams,
715
- names: coverage.skippedDevices.map((s) => deviceName(s.deviceId)).join(", ")
730
+ names: skippedNames
716
731
  },
717
732
  textCount: seconds
718
733
  };
734
+ if (coverage.skippedDevices.length > 0) return {
735
+ bodyKey: "alarm.arming.body.skipped",
736
+ textParams: {
737
+ ...textParams,
738
+ names: skippedNames
739
+ },
740
+ textCount: coverage.skippedDevices.length
741
+ };
719
742
  return {
720
743
  bodyKey: "alarm.arming.body",
721
744
  textParams,
@@ -1556,6 +1579,401 @@ var NC_ALARM_DECLARATION = {
1556
1579
  DeviceClass: NcAlarmPanelDevice
1557
1580
  };
1558
1581
  //#endregion
1582
+ //#region src/notification-center/artifact-index.ts
1583
+ var NC_ARTIFACTS_COLLECTION = "notification-center:artifacts";
1584
+ var NcArtifactKindSchema = require_dist._enum([
1585
+ "timelapse",
1586
+ "timelapse-preview",
1587
+ "summary-mosaic"
1588
+ ]);
1589
+ /**
1590
+ * How the viewer should DRAW it. Derived from the mime once, here, so no client
1591
+ * has to carry a mime table — and so a GIF that degraded to a JPEG is drawn as
1592
+ * the JPEG it actually is.
1593
+ */
1594
+ var NcArtifactMediaTypeSchema = require_dist._enum([
1595
+ "image",
1596
+ "gif",
1597
+ "video"
1598
+ ]);
1599
+ /** The relative hub path of the authenticated bytes route for an artefact id. */
1600
+ var NC_ARTIFACT_READ_PREFIX = "nc-artifact-read";
1601
+ var NcArtifactRecordSchema = require_dist.object({
1602
+ /** The `NcArtifactStore` id — the same id the bytes route resolves. */
1603
+ id: require_dist.string().min(1),
1604
+ kind: NcArtifactKindSchema,
1605
+ mediaType: NcArtifactMediaTypeSchema,
1606
+ mime: require_dist.string().min(1),
1607
+ sizeBytes: require_dist.number().int().nonnegative(),
1608
+ /** When the artefact was PRODUCED. */
1609
+ createdAt: require_dist.number().int(),
1610
+ /** What the content is ABOUT (window end / event time). The sort key: a
1611
+ * timelapse rendered at 06:05 is about the night before, and a list ordered
1612
+ * by production time puts it in the wrong morning. */
1613
+ capturedAt: require_dist.number().int(),
1614
+ /** After this the bytes are gone. Derived from the retained shelf's own age
1615
+ * bound — never a second retention policy. */
1616
+ expiresAt: require_dist.number().int(),
1617
+ /** The camera the artefact is FILED under. Null only if it belongs to none. */
1618
+ deviceId: require_dist.number().int().nullable(),
1619
+ /** Every camera the artefact covers, for display. */
1620
+ deviceIds: require_dist.array(require_dist.number().int()),
1621
+ ruleId: require_dist.string().optional(),
1622
+ ruleName: require_dist.string().optional(),
1623
+ title: require_dist.string().optional(),
1624
+ /** A companion still for a video artefact (a timelapse's preview frame) —
1625
+ * what a list renders as the thumbnail. */
1626
+ posterArtifactId: require_dist.string().optional(),
1627
+ /** RELATIVE hub path of the authenticated bytes route. Relative on purpose:
1628
+ * the viewer already holds the hub origin it is authenticated against, and
1629
+ * only the SIGNED plane needs to guess which host a stranger can reach. */
1630
+ downloadPath: require_dist.string().min(1)
1631
+ });
1632
+ var NcArtifactQuerySchema = require_dist.object({
1633
+ kinds: require_dist.array(NcArtifactKindSchema).optional(),
1634
+ deviceIds: require_dist.array(require_dist.number().int()).optional(),
1635
+ ruleId: require_dist.string().optional(),
1636
+ /** Inclusive `capturedAt` window. */
1637
+ fromMs: require_dist.number().int().optional(),
1638
+ toMs: require_dist.number().int().optional(),
1639
+ limit: require_dist.number().int().min(1).max(200).default(50),
1640
+ offset: require_dist.number().int().min(0).default(0)
1641
+ });
1642
+ var NC_ARTIFACTS_COLUMNS = [
1643
+ {
1644
+ name: "id",
1645
+ type: "TEXT",
1646
+ primaryKey: true,
1647
+ notNull: true
1648
+ },
1649
+ {
1650
+ name: "kind",
1651
+ type: "TEXT",
1652
+ notNull: true
1653
+ },
1654
+ {
1655
+ name: "deviceId",
1656
+ type: "INTEGER"
1657
+ },
1658
+ {
1659
+ name: "ruleId",
1660
+ type: "TEXT"
1661
+ },
1662
+ (
1663
+ /** `''` for a global artefact — see the header note on visibility. */
1664
+ {
1665
+ name: "ownerUserId",
1666
+ type: "TEXT",
1667
+ notNull: true
1668
+ }),
1669
+ {
1670
+ name: "capturedAt",
1671
+ type: "INTEGER",
1672
+ notNull: true
1673
+ },
1674
+ {
1675
+ name: "expiresAt",
1676
+ type: "INTEGER",
1677
+ notNull: true
1678
+ },
1679
+ (
1680
+ /** The FULL record (Zod-validated on read) — the scalars above are indexed
1681
+ * projections only. */
1682
+ {
1683
+ name: "record",
1684
+ type: "JSON",
1685
+ notNull: true
1686
+ })
1687
+ ];
1688
+ var NC_ARTIFACTS_INDEXES = [
1689
+ {
1690
+ name: "idx_nc_artifacts_captured",
1691
+ columns: ["capturedAt"]
1692
+ },
1693
+ {
1694
+ name: "idx_nc_artifacts_device",
1695
+ columns: ["deviceId", "capturedAt"]
1696
+ },
1697
+ {
1698
+ name: "idx_nc_artifacts_kind",
1699
+ columns: ["kind", "capturedAt"]
1700
+ },
1701
+ {
1702
+ name: "idx_nc_artifacts_expires",
1703
+ columns: ["expiresAt"]
1704
+ }
1705
+ ];
1706
+ /** Upper bound for a half-open `whereBetween` — the store has no `>=`. */
1707
+ var TIME_MAX = 864e13;
1708
+ var TIME_MIN = -864e13;
1709
+ /** Prune scan ceiling — the index is bounded by the shelf it mirrors (2000
1710
+ * entries by default), so this only has to be comfortably above it. */
1711
+ var NC_ARTIFACT_PRUNE_SCAN_LIMIT = 2e4;
1712
+ /** `image/gif` → `gif`. Keyed on what the bytes ARE, never on what was asked
1713
+ * for: a GIF that degraded to a still must be drawn as a still. */
1714
+ function mediaTypeForMime(mime) {
1715
+ if (mime === "image/gif") return "gif";
1716
+ if (mime.startsWith("video/")) return "video";
1717
+ return "image";
1718
+ }
1719
+ /** The relative hub path an authenticated client fetches the bytes from. */
1720
+ function artifactDownloadPath(addonId, artifactId) {
1721
+ return `/addon/${addonId}/${NC_ARTIFACT_READ_PREFIX}/${artifactId}`;
1722
+ }
1723
+ var NcArtifactIndex = class {
1724
+ store;
1725
+ logger;
1726
+ addonId;
1727
+ retentionMs;
1728
+ now;
1729
+ constructor(deps) {
1730
+ this.store = deps.store;
1731
+ this.logger = deps.logger;
1732
+ this.addonId = deps.addonId;
1733
+ this.retentionMs = deps.retentionMs;
1734
+ this.now = deps.now ?? (() => Date.now());
1735
+ }
1736
+ static async declare(store) {
1737
+ await store.declareCollection.mutate({
1738
+ collection: NC_ARTIFACTS_COLLECTION,
1739
+ columns: [...NC_ARTIFACTS_COLUMNS],
1740
+ indexes: [...NC_ARTIFACTS_INDEXES]
1741
+ });
1742
+ }
1743
+ /**
1744
+ * Record one retained artefact. Returns the row as the viewer will see it, or
1745
+ * null when the write failed — and a failure is LOGGED, per camera: an
1746
+ * artefact silently absent from the index reads to an operator as an artefact
1747
+ * that was never produced.
1748
+ */
1749
+ async record(input) {
1750
+ const createdAt = this.now();
1751
+ const record = NcArtifactRecordSchema.parse({
1752
+ id: input.id,
1753
+ kind: input.kind,
1754
+ mediaType: mediaTypeForMime(input.mime),
1755
+ mime: input.mime,
1756
+ sizeBytes: input.sizeBytes,
1757
+ createdAt,
1758
+ capturedAt: input.capturedAt,
1759
+ expiresAt: createdAt + this.retentionMs,
1760
+ deviceId: input.deviceId,
1761
+ deviceIds: [...input.deviceIds],
1762
+ ...input.ruleId !== void 0 ? { ruleId: input.ruleId } : {},
1763
+ ...input.ruleName !== void 0 ? { ruleName: input.ruleName } : {},
1764
+ ...input.title !== void 0 ? { title: input.title } : {},
1765
+ ...input.posterArtifactId !== void 0 ? { posterArtifactId: input.posterArtifactId } : {},
1766
+ downloadPath: artifactDownloadPath(this.addonId, input.id)
1767
+ });
1768
+ try {
1769
+ await this.store.set.mutate({
1770
+ collection: NC_ARTIFACTS_COLLECTION,
1771
+ key: record.id,
1772
+ value: {
1773
+ kind: record.kind,
1774
+ deviceId: record.deviceId,
1775
+ ruleId: record.ruleId ?? "",
1776
+ ownerUserId: input.ownerUserId ?? "",
1777
+ capturedAt: record.capturedAt,
1778
+ expiresAt: record.expiresAt,
1779
+ record
1780
+ }
1781
+ });
1782
+ return record;
1783
+ } catch (err) {
1784
+ this.logger.warn("artifact not indexed — it will not appear in the viewer", {
1785
+ ...record.deviceId !== null ? { tags: { deviceId: record.deviceId } } : {},
1786
+ meta: {
1787
+ artifactId: record.id,
1788
+ kind: record.kind,
1789
+ error: String(err)
1790
+ }
1791
+ });
1792
+ return null;
1793
+ }
1794
+ }
1795
+ /**
1796
+ * One page, newest `capturedAt` first.
1797
+ *
1798
+ * Expiry is a PREDICATE, not a post-filter: a row whose bytes the shelf has
1799
+ * already swept must not consume a slot of the page the caller asked for.
1800
+ */
1801
+ async list(query, viewer) {
1802
+ const filter = this.buildFilter(query, viewer);
1803
+ try {
1804
+ const [rows, total] = await Promise.all([this.store.query.query({
1805
+ collection: NC_ARTIFACTS_COLLECTION,
1806
+ filter: {
1807
+ ...filter,
1808
+ orderBy: {
1809
+ field: "capturedAt",
1810
+ direction: "desc"
1811
+ },
1812
+ limit: query.limit,
1813
+ offset: query.offset
1814
+ }
1815
+ }), this.store.count.query({
1816
+ collection: NC_ARTIFACTS_COLLECTION,
1817
+ filter
1818
+ })]);
1819
+ const artifacts = this.parseRows(rows);
1820
+ return {
1821
+ artifacts,
1822
+ total,
1823
+ hasMore: query.offset + artifacts.length < total
1824
+ };
1825
+ } catch (err) {
1826
+ this.logger.warn("artifact index query failed", { meta: { error: String(err) } });
1827
+ return {
1828
+ artifacts: [],
1829
+ total: 0,
1830
+ hasMore: false
1831
+ };
1832
+ }
1833
+ }
1834
+ /**
1835
+ * One artefact by id, or null when it does not exist, has expired, or belongs
1836
+ * to another user. All three answer the same way ON PURPOSE — a surface that
1837
+ * distinguishes "not yours" from "not there" tells a caller what other people
1838
+ * own.
1839
+ */
1840
+ async get(artifactId, viewer) {
1841
+ const row = await this.readRow(artifactId);
1842
+ if (row === null) return null;
1843
+ if (row.record.expiresAt <= this.now()) return null;
1844
+ if (!viewer.isAdmin && row.ownerUserId !== "" && row.ownerUserId !== viewer.userId) return null;
1845
+ return row.record;
1846
+ }
1847
+ /**
1848
+ * Resolve an id for the BYTES route — visibility-free by design.
1849
+ *
1850
+ * The route is served `authenticated`, so the hub has already established a
1851
+ * principal; what this answers is only "is this id an indexed artefact, and is
1852
+ * it still meant to exist". Per-user artefact scoping on the byte route would
1853
+ * be a second authorization model layered on an unguessable id, and this repo
1854
+ * has one of those already (`data-plane-access.ts`): a plane serving ONE item
1855
+ * the caller had to be told about is `authenticated`.
1856
+ */
1857
+ async resolve(artifactId) {
1858
+ return (await this.readRow(artifactId))?.record ?? null;
1859
+ }
1860
+ /** One row by primary key, parsed. Null when absent or no longer valid. */
1861
+ async readRow(artifactId) {
1862
+ let raw;
1863
+ try {
1864
+ raw = await this.store.get.query({
1865
+ collection: NC_ARTIFACTS_COLLECTION,
1866
+ key: artifactId
1867
+ });
1868
+ } catch (err) {
1869
+ this.logger.warn("artifact index lookup failed", { meta: {
1870
+ artifactId,
1871
+ error: String(err)
1872
+ } });
1873
+ return null;
1874
+ }
1875
+ if (typeof raw !== "object" || raw === null || Array.isArray(raw)) return null;
1876
+ const row = { ...raw };
1877
+ const parsed = NcArtifactRecordSchema.safeParse(row["record"]);
1878
+ if (!parsed.success) return null;
1879
+ const owner = row["ownerUserId"];
1880
+ return {
1881
+ record: parsed.data,
1882
+ ownerUserId: typeof owner === "string" ? owner : ""
1883
+ };
1884
+ }
1885
+ /**
1886
+ * Drop rows whose bytes are gone, in ONE `deleteWhere` rather than an N+1
1887
+ * drain.
1888
+ *
1889
+ * TWO halves, and only one of them is safe everywhere:
1890
+ *
1891
+ * - **Expired rows** are time-based and node-independent. Always swept.
1892
+ * - **Orphans** — rows whose file a byte/count bound took before the age bound
1893
+ * did — are found by reconciling against the shelf. That reconcile is only
1894
+ * valid for a caller whose shelf holds the bytes for the WHOLE index, and
1895
+ * the index is CENTRAL while the shelf is local: a second node running this
1896
+ * with its own (empty) shelf would read every row as an orphan and delete
1897
+ * the entire library. Hence `keptIds: null` — a caller that is not the
1898
+ * producing node states so, and the destructive half becomes unreachable
1899
+ * rather than merely un-called.
1900
+ *
1901
+ * @param keptIds the ids the caller's shelf holds, or `null` when this node is
1902
+ * not the one that produces artefacts.
1903
+ */
1904
+ async prune(keptIds) {
1905
+ let removed = 0;
1906
+ try {
1907
+ const expired = await this.store.deleteWhere.mutate({
1908
+ collection: NC_ARTIFACTS_COLLECTION,
1909
+ filter: { whereBetween: { expiresAt: [TIME_MIN, this.now() - 1] } }
1910
+ });
1911
+ removed += expired.deleted;
1912
+ if (keptIds === null) {
1913
+ if (removed > 0) this.logger.debug("artifact index pruned (expired only)", { meta: { removed } });
1914
+ return removed;
1915
+ }
1916
+ const alive = new Set(keptIds);
1917
+ const orphans = (await this.store.query.query({
1918
+ collection: NC_ARTIFACTS_COLLECTION,
1919
+ filter: { limit: NC_ARTIFACT_PRUNE_SCAN_LIMIT }
1920
+ })).map((r) => r.id).filter((id) => !alive.has(id));
1921
+ for (const id of orphans) {
1922
+ await this.store.delete.mutate({
1923
+ collection: NC_ARTIFACTS_COLLECTION,
1924
+ key: id
1925
+ });
1926
+ removed += 1;
1927
+ }
1928
+ if (removed > 0) this.logger.debug("artifact index pruned", { meta: {
1929
+ removed,
1930
+ expired: expired.deleted,
1931
+ orphaned: orphans.length
1932
+ } });
1933
+ return removed;
1934
+ } catch (err) {
1935
+ this.logger.warn("artifact index prune failed", { meta: { error: String(err) } });
1936
+ return removed;
1937
+ }
1938
+ }
1939
+ /** The visibility predicate: a caller sees global artefacts plus his own; an
1940
+ * admin sees every one. */
1941
+ visibility(viewer) {
1942
+ if (viewer.isAdmin) return {};
1943
+ return { whereIn: { ownerUserId: ["", viewer.userId] } };
1944
+ }
1945
+ buildFilter(query, viewer) {
1946
+ const whereIn = { ...this.visibility(viewer).whereIn };
1947
+ if (query.kinds !== void 0 && query.kinds.length > 0) whereIn["kind"] = [...query.kinds];
1948
+ if (query.deviceIds !== void 0 && query.deviceIds.length > 0) whereIn["deviceId"] = [...query.deviceIds];
1949
+ const whereBetween = {
1950
+ capturedAt: [query.fromMs ?? TIME_MIN, query.toMs ?? TIME_MAX],
1951
+ expiresAt: [this.now(), TIME_MAX]
1952
+ };
1953
+ return {
1954
+ ...query.ruleId !== void 0 ? { where: { ruleId: query.ruleId } } : {},
1955
+ ...Object.keys(whereIn).length > 0 ? { whereIn } : {},
1956
+ whereBetween
1957
+ };
1958
+ }
1959
+ /** Parse the JSON column, skipping rows that no longer validate. A degraded
1960
+ * row must never cost the whole page. */
1961
+ parseRows(rows) {
1962
+ const out = [];
1963
+ let skipped = 0;
1964
+ for (const row of rows) {
1965
+ const parsed = NcArtifactRecordSchema.safeParse(row.data["record"]);
1966
+ if (!parsed.success) {
1967
+ skipped += 1;
1968
+ continue;
1969
+ }
1970
+ out.push(parsed.data);
1971
+ }
1972
+ if (skipped > 0) this.logger.warn("artifact index rows skipped as invalid", { meta: { skipped } });
1973
+ return out;
1974
+ }
1975
+ };
1976
+ //#endregion
1559
1977
  //#region ../types/dist/node.mjs
1560
1978
  var node_exports = /* @__PURE__ */ require_dist.__exportAll({
1561
1979
  FilesystemStorageProvider: () => FilesystemStorageProvider,
@@ -1946,6 +2364,61 @@ var NcArtifactPlane = class {
1946
2364
  }
1947
2365
  }
1948
2366
  /**
2367
+ * Publish bytes AND file them in the viewer's browsable index (task #36).
2368
+ *
2369
+ * Two differences from {@link publish}, both deliberate:
2370
+ *
2371
+ * 1. **The bytes are stored FIRST, and stored even when no public base URL
2372
+ * exists.** `publish` returns early on a loopback-only install because a
2373
+ * signed link nobody can open is worth nothing — but the VIEWER reaches
2374
+ * the hub directly over its own session, so an install with no tunnel
2375
+ * still gets a full artifact library. Making the index depend on a
2376
+ * reachable public address would have made the feature absent exactly on
2377
+ * the installs that are LAN-only.
2378
+ * 2. **It answers the artefact id**, so a caller can link one artefact to
2379
+ * another — a timelapse's poster is its preview frame, and the video row
2380
+ * cannot name it without knowing the id the preview got.
2381
+ *
2382
+ * Never throws, for the same reason `publish` does not: an artefact library
2383
+ * is an enhancement and must never cost a notification.
2384
+ */
2385
+ async publishIndexed(bytes, mime, meta) {
2386
+ let artifactId = null;
2387
+ try {
2388
+ const index = this.deps.index;
2389
+ const stored = await this.deps.store.put(bytes, mime, { retain: index !== void 0 });
2390
+ if (index !== void 0) artifactId = (await index.record({
2391
+ ...meta,
2392
+ id: stored.id,
2393
+ mime,
2394
+ sizeBytes: stored.bytes
2395
+ }))?.id ?? null;
2396
+ const baseUrl = await this.resolveBaseUrl();
2397
+ return {
2398
+ url: baseUrl === null ? null : buildArtifactUrl({
2399
+ baseUrl,
2400
+ routePrefix: this.deps.routePrefix,
2401
+ id: stored.id,
2402
+ secret: this.deps.secret,
2403
+ expMs: this.now() + (this.deps.ttlMs ?? DEFAULT_TTL_MS)
2404
+ }),
2405
+ artifactId
2406
+ };
2407
+ } catch (err) {
2408
+ this.deps.logger.warn("indexed artifact publish failed — shipping bytes only", {
2409
+ ...meta.deviceId !== null ? { tags: { deviceId: meta.deviceId } } : {},
2410
+ meta: {
2411
+ kind: meta.kind,
2412
+ error: String(err)
2413
+ }
2414
+ });
2415
+ return {
2416
+ url: null,
2417
+ artifactId
2418
+ };
2419
+ }
2420
+ }
2421
+ /**
1949
2422
  * Absolutize a path against the SAME base URL an artifact link would use.
1950
2423
  *
1951
2424
  * For links this plane does not host but a RECIPIENT still has to open — a
@@ -2011,6 +2484,124 @@ var NcArtifactPlane = class {
2011
2484
  }
2012
2485
  };
2013
2486
  //#endregion
2487
+ //#region src/notification-center/artifact-read-plane.ts
2488
+ /**
2489
+ * Parse a single-range `Range` header against a known length.
2490
+ *
2491
+ * Returns `null` when there is nothing to honour (absent, malformed, multi-range
2492
+ * — all of which mean "send the whole thing"), and `'unsatisfiable'` when the
2493
+ * client asked for a range that cannot exist, which is a 416 and not a 200.
2494
+ */
2495
+ function parseByteRange(header, length) {
2496
+ if (header === void 0) return null;
2497
+ const match = /^bytes=(\d*)-(\d*)$/.exec(header.trim());
2498
+ if (match === null) return null;
2499
+ const rawStart = match[1] ?? "";
2500
+ const rawEnd = match[2] ?? "";
2501
+ if (rawStart === "" && rawEnd === "") return null;
2502
+ if (length === 0) return "unsatisfiable";
2503
+ if (rawStart === "") {
2504
+ const suffix = Number(rawEnd);
2505
+ if (suffix === 0) return "unsatisfiable";
2506
+ return {
2507
+ start: Math.max(0, length - suffix),
2508
+ end: length - 1
2509
+ };
2510
+ }
2511
+ const start = Number(rawStart);
2512
+ if (start >= length) return "unsatisfiable";
2513
+ const end = rawEnd === "" ? length - 1 : Math.min(Number(rawEnd), length - 1);
2514
+ if (end < start) return "unsatisfiable";
2515
+ return {
2516
+ start,
2517
+ end
2518
+ };
2519
+ }
2520
+ /** The last path segment, percent-decoded. */
2521
+ function artifactIdFrom(req) {
2522
+ const url = new URL(req.url ?? "/", "http://placeholder");
2523
+ try {
2524
+ return decodeURIComponent(url.pathname.split("/").filter(Boolean).pop() ?? "");
2525
+ } catch {
2526
+ return "";
2527
+ }
2528
+ }
2529
+ var NcArtifactReadPlane = class {
2530
+ deps;
2531
+ constructor(deps) {
2532
+ this.deps = deps;
2533
+ }
2534
+ handler = async (req, res) => {
2535
+ const artifactId = artifactIdFrom(req);
2536
+ const record = await this.deps.index.resolve(artifactId);
2537
+ if (record === null) {
2538
+ this.refuse(res, 404, "not found");
2539
+ return;
2540
+ }
2541
+ const found = await this.deps.store.read(artifactId);
2542
+ if (found === null) {
2543
+ this.deps.logger.warn("artifact bytes gone — index row outlived the shelf", {
2544
+ ...record.deviceId !== null ? { tags: { deviceId: record.deviceId } } : {},
2545
+ meta: {
2546
+ artifactId,
2547
+ kind: record.kind,
2548
+ expiresAt: record.expiresAt
2549
+ }
2550
+ });
2551
+ this.refuse(res, 404, "not found");
2552
+ return;
2553
+ }
2554
+ if (req.method === "HEAD") {
2555
+ res.writeHead(200, this.baseHeaders(record, found.bytes.byteLength, found.mime));
2556
+ res.end();
2557
+ return;
2558
+ }
2559
+ const range = parseByteRange(headerOf(req, "range"), found.bytes.byteLength);
2560
+ if (range === "unsatisfiable") {
2561
+ res.writeHead(416, {
2562
+ "content-range": `bytes */${found.bytes.byteLength}`,
2563
+ "content-type": "text/plain"
2564
+ });
2565
+ res.end("range not satisfiable");
2566
+ return;
2567
+ }
2568
+ if (range === null) {
2569
+ res.writeHead(200, this.baseHeaders(record, found.bytes.byteLength, found.mime));
2570
+ res.end(found.bytes);
2571
+ return;
2572
+ }
2573
+ const slice = found.bytes.subarray(range.start, range.end + 1);
2574
+ res.writeHead(206, {
2575
+ ...this.baseHeaders(record, slice.byteLength, found.mime),
2576
+ "content-range": `bytes ${range.start}-${range.end}/${found.bytes.byteLength}`
2577
+ });
2578
+ res.end(slice);
2579
+ };
2580
+ baseHeaders(record, length, mime) {
2581
+ return {
2582
+ "content-type": mime,
2583
+ "content-length": String(length),
2584
+ "accept-ranges": "bytes",
2585
+ "cache-control": "private, max-age=3600",
2586
+ "content-disposition": `inline; filename="${filenameFor(record)}"`
2587
+ };
2588
+ }
2589
+ refuse(res, status, body) {
2590
+ res.writeHead(status, { "content-type": "text/plain" });
2591
+ res.end(body);
2592
+ }
2593
+ };
2594
+ /** A name a client will accept, from what the artefact IS. */
2595
+ function filenameFor(record) {
2596
+ const ext = record.mime === "video/mp4" ? "mp4" : record.mime === "image/gif" ? "gif" : record.mime === "image/png" ? "png" : "jpg";
2597
+ return `${record.kind}-${record.capturedAt}.${ext}`;
2598
+ }
2599
+ function headerOf(req, name) {
2600
+ const value = req.headers[name];
2601
+ if (Array.isArray(value)) return value[0];
2602
+ return value;
2603
+ }
2604
+ //#endregion
2014
2605
  //#region src/notification-center/artifact-store.ts
2015
2606
  /**
2016
2607
  * NcArtifactStore — the bytes behind a signed notification-artifact URL.
@@ -2025,9 +2616,25 @@ var NcArtifactPlane = class {
2025
2616
  * bites first, the oldest artifacts go. A sweep on boot clears whatever a crash
2026
2617
  * left behind — the directory is disposable by construction, so a lost file is
2027
2618
  * a 404 on one link, never a corrupt state.
2619
+ *
2620
+ * ## The retained shelf (`kept/`)
2621
+ *
2622
+ * Task #36 gave the viewer a browsable artifact index, and an index whose bytes
2623
+ * evaporate in six hours is an index of 404s. So a `put` may ask to be RETAINED:
2624
+ * the bytes then land on a second shelf inside the same directory with its OWN
2625
+ * three bounds (age, count, and — because a timelapse mp4 is megabytes — total
2626
+ * BYTES). It is still a scratch, just a longer one: the index row carries an
2627
+ * `expiresAt` derived from the same age bound, so "gone" is a state the viewer
2628
+ * is told about rather than a surprise 404.
2629
+ *
2630
+ * The shelf is a subdirectory ON PURPOSE. The scratch's `list()` skips
2631
+ * non-files, so the two shelves cannot sweep each other, and neither bound had
2632
+ * to learn about the other's entries.
2028
2633
  */
2029
2634
  var DEFAULT_MAX_AGE_MS = 6 * 36e5;
2030
2635
  var DEFAULT_MAX_ENTRIES = 500;
2636
+ /** The retained shelf's subdirectory name, inside the scratch directory. */
2637
+ var KEPT_SUBDIR = "kept";
2031
2638
  /** `image/jpeg` → `jpg`, so a fetched link has a name a client will accept. */
2032
2639
  function extensionFor(mime) {
2033
2640
  if (mime === "image/jpeg") return "jpg";
@@ -2041,6 +2648,10 @@ var NcArtifactStore = class {
2041
2648
  now;
2042
2649
  maxAgeMs;
2043
2650
  maxEntries;
2651
+ keptMaxAgeMs;
2652
+ keptMaxEntries;
2653
+ keptMaxBytes;
2654
+ keptDir;
2044
2655
  /** id → mime, so the route can answer with the right content-type without
2045
2656
  * re-deriving it from the extension on every request. */
2046
2657
  mimeById = /* @__PURE__ */ new Map();
@@ -2049,17 +2660,32 @@ var NcArtifactStore = class {
2049
2660
  this.now = options.now ?? (() => Date.now());
2050
2661
  this.maxAgeMs = options.maxAgeMs ?? DEFAULT_MAX_AGE_MS;
2051
2662
  this.maxEntries = options.maxEntries ?? DEFAULT_MAX_ENTRIES;
2663
+ this.keptMaxAgeMs = options.keptMaxAgeMs ?? 12096e5;
2664
+ this.keptMaxEntries = options.keptMaxEntries ?? 2e3;
2665
+ this.keptMaxBytes = options.keptMaxBytes ?? 2147483648;
2666
+ this.keptDir = node_path.default.join(options.dir, KEPT_SUBDIR);
2667
+ }
2668
+ /** How long a retained artifact stays fetchable — the index's `expiresAt`
2669
+ * is derived from this, so the two can never disagree. */
2670
+ get retentionMs() {
2671
+ return this.keptMaxAgeMs;
2052
2672
  }
2053
2673
  /** Create the directory and sweep whatever a previous run left behind. */
2054
2674
  async start() {
2055
2675
  await node_fs.promises.mkdir(this.options.dir, { recursive: true });
2056
2676
  await this.sweep();
2057
2677
  }
2058
- /** Persist bytes and return the artifact handle (never throws on sweep). */
2059
- async put(bytes, mime) {
2678
+ /**
2679
+ * Persist bytes and return the artifact handle (never throws on sweep).
2680
+ *
2681
+ * The retained shelf is created LAZILY: an install that never produces a
2682
+ * browsable artifact keeps a directory that is empty when it is empty.
2683
+ */
2684
+ async put(bytes, mime, options = {}) {
2060
2685
  const id = `${this.now()}-${(0, node_crypto.randomUUID)()}`;
2061
- const file = node_path.default.join(this.options.dir, `${id}.${extensionFor(mime)}`);
2062
- await node_fs.promises.mkdir(this.options.dir, { recursive: true });
2686
+ const dir = options.retain === true ? this.keptDir : this.options.dir;
2687
+ const file = node_path.default.join(dir, `${id}.${extensionFor(mime)}`);
2688
+ await node_fs.promises.mkdir(dir, { recursive: true });
2063
2689
  await node_fs.promises.writeFile(file, bytes);
2064
2690
  this.mimeById.set(id, mime);
2065
2691
  await this.sweep();
@@ -2072,7 +2698,7 @@ var NcArtifactStore = class {
2072
2698
  /** Read an artifact's bytes + mime, or null when it is gone (expired/swept). */
2073
2699
  async read(id) {
2074
2700
  if (!/^[0-9]+-[0-9a-f-]{36}$/i.test(id)) return null;
2075
- const found = (await this.list()).find((e) => e.id === id);
2701
+ const found = [...await this.list(this.options.dir), ...await this.list(this.keptDir)].find((e) => e.id === id);
2076
2702
  if (!found) return null;
2077
2703
  try {
2078
2704
  return {
@@ -2083,6 +2709,10 @@ var NcArtifactStore = class {
2083
2709
  return null;
2084
2710
  }
2085
2711
  }
2712
+ /** The ids still on the retained shelf — what the index reconciles against. */
2713
+ async keptIds() {
2714
+ return (await this.list(this.keptDir)).map((e) => e.id);
2715
+ }
2086
2716
  /** Delete every artifact (operator cleanup / shutdown). */
2087
2717
  async clear() {
2088
2718
  await node_fs.promises.rm(this.options.dir, {
@@ -2091,13 +2721,39 @@ var NcArtifactStore = class {
2091
2721
  }).catch(() => {});
2092
2722
  this.mimeById.clear();
2093
2723
  }
2094
- /** Age- then count-bounded prune. Best-effort: a failed unlink is logged once. */
2724
+ /** Prune both shelves under their own bounds. Best-effort: a failed unlink is
2725
+ * logged once and the rest of the sweep still runs. */
2095
2726
  async sweep() {
2096
- const entries = await this.list();
2097
- const cutoff = this.now() - this.maxAgeMs;
2727
+ await this.sweepShelf(await this.list(this.options.dir), {
2728
+ maxAgeMs: this.maxAgeMs,
2729
+ maxEntries: this.maxEntries
2730
+ });
2731
+ await this.sweepShelf(await this.list(this.keptDir), {
2732
+ maxAgeMs: this.keptMaxAgeMs,
2733
+ maxEntries: this.keptMaxEntries,
2734
+ maxBytes: this.keptMaxBytes
2735
+ });
2736
+ }
2737
+ /**
2738
+ * Age first, then count, then total bytes — newest always survives.
2739
+ *
2740
+ * The newest entry is never swept by the byte bound even when it alone
2741
+ * exceeds it: a shelf that deletes what it has just written can serve
2742
+ * nothing, and the caller has already paid for the render.
2743
+ */
2744
+ async sweepShelf(entries, bounds) {
2745
+ const cutoff = this.now() - bounds.maxAgeMs;
2098
2746
  const doomed = entries.filter((e) => e.mtimeMs < cutoff);
2099
- const survivors = entries.filter((e) => e.mtimeMs >= cutoff).toSorted((a, b) => b.mtimeMs - a.mtimeMs);
2100
- doomed.push(...survivors.slice(this.maxEntries));
2747
+ const fresh = entries.filter((e) => e.mtimeMs >= cutoff).toSorted((a, b) => b.mtimeMs - a.mtimeMs);
2748
+ doomed.push(...fresh.slice(bounds.maxEntries));
2749
+ const maxBytes = bounds.maxBytes;
2750
+ if (maxBytes !== void 0) {
2751
+ let running = 0;
2752
+ for (const [i, e] of fresh.slice(0, bounds.maxEntries).entries()) {
2753
+ running += e.sizeBytes;
2754
+ if (running > maxBytes && i > 0) doomed.push(e);
2755
+ }
2756
+ }
2101
2757
  for (const e of doomed) try {
2102
2758
  await node_fs.promises.rm(e.file, { force: true });
2103
2759
  this.mimeById.delete(e.id);
@@ -2108,23 +2764,24 @@ var NcArtifactStore = class {
2108
2764
  } });
2109
2765
  }
2110
2766
  }
2111
- async list() {
2767
+ async list(dir) {
2112
2768
  let names;
2113
2769
  try {
2114
- names = await node_fs.promises.readdir(this.options.dir);
2770
+ names = await node_fs.promises.readdir(dir);
2115
2771
  } catch {
2116
2772
  return [];
2117
2773
  }
2118
2774
  const out = [];
2119
2775
  for (const name of names) {
2120
- const file = node_path.default.join(this.options.dir, name);
2776
+ const file = node_path.default.join(dir, name);
2121
2777
  try {
2122
2778
  const st = await node_fs.promises.stat(file);
2123
2779
  if (!st.isFile()) continue;
2124
2780
  out.push({
2125
2781
  id: name.slice(0, name.lastIndexOf(".")),
2126
2782
  file,
2127
- mtimeMs: st.mtimeMs
2783
+ mtimeMs: st.mtimeMs,
2784
+ sizeBytes: st.size
2128
2785
  });
2129
2786
  } catch {}
2130
2787
  }
@@ -4342,69 +4999,38 @@ var en_default = {
4342
4999
  "snooze.scope.anyDevice": "A camera",
4343
5000
  "snooze.scope.anyRule": "A rule",
4344
5001
  "snooze.scope.rule": "“{{rule}}”",
4345
- "alarm.arm.body.allDevices": {
4346
- "one": "Every device is armed ({{rules}} rule).",
4347
- "other": "Every device is armed ({{rules}} rules)."
4348
- },
4349
- "alarm.arm.body.noDevices": {
4350
- "one": "{{rules}} rule, no device restriction.",
4351
- "other": "{{rules}} rules, no device restriction."
4352
- },
4353
- "alarm.arm.body.devices": {
4354
- "one": "{{devices}} device armed: {{names}}.",
4355
- "other": "{{devices}} devices armed: {{names}}."
4356
- },
4357
- "alarm.arm.body.devicesSkipped": {
4358
- "one": "{{devices}} device armed: {{names}}. Excluded: {{skippedNames}}.",
4359
- "other": "{{devices}} devices armed: {{names}}. Excluded: {{skippedNames}}."
4360
- },
4361
- "alarm.arm.body.uncovered": "Nothing is armed — no enabled rule is gated on this mode.",
4362
- "alarm.arm.body.allSkipped": {
4363
- "one": "Nothing is armed: the {{devices}} device this mode covers is excluded ({{names}}).",
4364
- "other": "Nothing is armed: all {{devices}} devices this mode covers are excluded ({{names}})."
4365
- },
4366
- "alarm.arming.body": {
4367
- "one": "Armed in {{seconds}} second.",
4368
- "other": "Armed in {{seconds}} seconds."
4369
- },
4370
- "alarm.arming.body.uncovered": {
4371
- "one": "Armed in {{seconds}} second — but no enabled rule is gated on this mode, so it will protect nothing.",
4372
- "other": "Armed in {{seconds}} seconds — but no enabled rule is gated on this mode, so it will protect nothing."
4373
- },
4374
- "alarm.arming.body.allSkipped": {
4375
- "one": "Armed in {{seconds}} second — but every device this mode covers is excluded ({{names}}), so it will protect nothing.",
4376
- "other": "Armed in {{seconds}} seconds — but every device this mode covers is excluded ({{names}}), so it will protect nothing."
4377
- },
4378
- "alarm.arm.body.bypassed": {
4379
- "one": "{{devices}} device armed — open and excluded: {{bypassedNames}}.",
4380
- "other": "{{devices}} devices armed — open and excluded: {{bypassedNames}}."
4381
- },
4382
- "alarm.arming.body.bypassed": {
4383
- "one": "Armed in {{seconds}} second, excluding what is open: {{bypassedNames}}.",
4384
- "other": "Armed in {{seconds}} seconds, excluding what is open: {{bypassedNames}}."
4385
- },
4386
- "alarm.armRefused.body": {
4387
- "one": "{{names}} is open. Press arm again within {{seconds}}s to arm without it.",
4388
- "other": "These are open: {{names}}. Press arm again within {{seconds}}s to arm without them."
4389
- },
5002
+ "alarm.arm.body.allDevices": "Active: all devices",
5003
+ "alarm.arm.body.noDevices": "Active: no device restriction",
5004
+ "alarm.arm.body.devices": "Active: {{names}}",
5005
+ "alarm.arm.body.devicesSkipped": "Active: {{names}}\nExcluded: {{skippedNames}}",
5006
+ "alarm.arm.body.uncovered": "Active: none — no rule uses this mode",
5007
+ "alarm.arm.body.allSkipped": "Active: none\nExcluded: {{names}}",
5008
+ "alarm.arming.body": "No exclusions",
5009
+ "alarm.arming.body.uncovered": "Active: none — no rule uses this mode",
5010
+ "alarm.arming.body.allSkipped": "Active: none\nExcluded: {{names}}",
5011
+ "alarm.arming.body.skipped": "Excluded: {{names}}",
5012
+ "alarm.arm.body.bypassed": "Active: {{devices}}\nBypassed: {{bypassedNames}}",
5013
+ "alarm.arming.body.bypassed": "Bypassed: {{bypassedNames}}",
5014
+ "alarm.armRefused.body": "Open: {{names}}\nRepeat within {{seconds}}s to arm with bypass.",
4390
5015
  "alarm.device.unnamed": "Device {{id}}",
4391
- "system.alarm-arm-refused.title.home": "Home NOT armed",
4392
- "system.alarm-arm-refused.title.away": "Away NOT armed",
4393
- "system.alarm-arm-refused.title.night": "Night NOT armed",
4394
- "system.alarm-arm-refused.body": "The alarm could not be armed — something it covers is open.",
4395
- "system.alarm-arming.title.home": "Arming home",
4396
- "system.alarm-arming.title.away": "Arming away",
4397
- "system.alarm-arming.title.night": "Arming night",
4398
- "system.alarm-arming.body": "The alarm is arming",
4399
- "system.alarm-armed.title.home": "Home armed",
4400
- "system.alarm-armed.title.away": "Away armed",
4401
- "system.alarm-armed.title.night": "Night armed",
4402
- "system.alarm-armed.body": "The alarm was armed",
4403
- "system.alarm-disarmed.title": "Alarm disarmed",
4404
- "system.alarm-disarmed.body": "The alarm was disarmed",
5016
+ "alarm.combined.title": "Alarm: {{deviceName}}",
5017
+ "system.alarm-arm-refused.title.home": "Arming home refused",
5018
+ "system.alarm-arm-refused.title.away": "Arming away refused",
5019
+ "system.alarm-arm-refused.title.night": "Arming night refused",
5020
+ "system.alarm-arm-refused.body": "Arming refused",
5021
+ "system.alarm-arming.title.home": "Arming home — {{seconds}}s",
5022
+ "system.alarm-arming.title.away": "Arming away — {{seconds}}s",
5023
+ "system.alarm-arming.title.night": "Arming night — {{seconds}}s",
5024
+ "system.alarm-arming.body": "Arming",
5025
+ "system.alarm-armed.title.home": "Armed: home",
5026
+ "system.alarm-armed.title.away": "Armed: away",
5027
+ "system.alarm-armed.title.night": "Armed: night",
5028
+ "system.alarm-armed.body": "Alarm armed",
5029
+ "system.alarm-disarmed.title": "Disarmed",
5030
+ "system.alarm-disarmed.body": "Alarm disarmed",
4405
5031
  "system.alarm-triggered.title": "ALARM",
4406
- "system.alarm-triggered.body": "The alarm was triggered",
4407
- "system.alarm-triggered.body.rule": "The alarm was triggered by {{rule}} on {{deviceName}}",
5032
+ "system.alarm-triggered.body": "Alarm triggered",
5033
+ "system.alarm-triggered.body.rule": "Triggered by {{rule}} on {{deviceName}}",
4408
5034
  "button.snooze.minutes": "Snooze {{minutes}} min",
4409
5035
  "button.snooze.hours": {
4410
5036
  "one": "Snooze {{hours}} hour",
@@ -4536,69 +5162,38 @@ var it_default = {
4536
5162
  "snooze.scope.anyDevice": "Una camera",
4537
5163
  "snooze.scope.anyRule": "Una regola",
4538
5164
  "snooze.scope.rule": "“{{rule}}”",
4539
- "alarm.arm.body.allDevices": {
4540
- "one": "Ogni dispositivo è attivo ({{rules}} regola).",
4541
- "other": "Ogni dispositivo è attivo ({{rules}} regole)."
4542
- },
4543
- "alarm.arm.body.noDevices": {
4544
- "one": "{{rules}} regola, nessuna restrizione sui dispositivi.",
4545
- "other": "{{rules}} regole, nessuna restrizione sui dispositivi."
4546
- },
4547
- "alarm.arm.body.devices": {
4548
- "one": "{{devices}} dispositivo attivo: {{names}}.",
4549
- "other": "{{devices}} dispositivi attivi: {{names}}."
4550
- },
4551
- "alarm.arm.body.devicesSkipped": {
4552
- "one": "{{devices}} dispositivo attivo: {{names}}. Esclusi: {{skippedNames}}.",
4553
- "other": "{{devices}} dispositivi attivi: {{names}}. Esclusi: {{skippedNames}}."
4554
- },
4555
- "alarm.arm.body.uncovered": "Non è attivo nulla — nessuna regola abilitata è legata a questa modalità.",
4556
- "alarm.arm.body.allSkipped": {
4557
- "one": "Non è attivo nulla: l'unico dispositivo ({{devices}}) coperto da questa modalità è escluso ({{names}}).",
4558
- "other": "Non è attivo nulla: tutti i {{devices}} dispositivi coperti da questa modalità sono esclusi ({{names}})."
4559
- },
4560
- "alarm.arming.body": {
4561
- "one": "Attivo tra {{seconds}} secondo.",
4562
- "other": "Attivo tra {{seconds}} secondi."
4563
- },
4564
- "alarm.arming.body.uncovered": {
4565
- "one": "Attivo tra {{seconds}} secondo — ma nessuna regola abilitata è legata a questa modalità: non proteggerà nulla.",
4566
- "other": "Attivo tra {{seconds}} secondi — ma nessuna regola abilitata è legata a questa modalità: non proteggerà nulla."
4567
- },
4568
- "alarm.arming.body.allSkipped": {
4569
- "one": "Attivo tra {{seconds}} secondo — ma ogni dispositivo coperto da questa modalità è escluso ({{names}}): non proteggerà nulla.",
4570
- "other": "Attivo tra {{seconds}} secondi — ma ogni dispositivo coperto da questa modalità è escluso ({{names}}): non proteggerà nulla."
4571
- },
4572
- "alarm.arm.body.bypassed": {
4573
- "one": "{{devices}} dispositivo attivo — aperto ed escluso: {{bypassedNames}}.",
4574
- "other": "{{devices}} dispositivi attivi — aperti ed esclusi: {{bypassedNames}}."
4575
- },
4576
- "alarm.arming.body.bypassed": {
4577
- "one": "Attivo tra {{seconds}} secondo, escludendo ciò che è aperto: {{bypassedNames}}.",
4578
- "other": "Attivo tra {{seconds}} secondi, escludendo ciò che è aperto: {{bypassedNames}}."
4579
- },
4580
- "alarm.armRefused.body": {
4581
- "one": "{{names}} è aperto. Premi di nuovo entro {{seconds}}s per inserire senza di esso.",
4582
- "other": "Sono aperti: {{names}}. Premi di nuovo entro {{seconds}}s per inserire senza di essi."
4583
- },
5165
+ "alarm.arm.body.allDevices": "Attivi: tutti i dispositivi",
5166
+ "alarm.arm.body.noDevices": "Attivi: nessuna restrizione",
5167
+ "alarm.arm.body.devices": "Attivi: {{names}}",
5168
+ "alarm.arm.body.devicesSkipped": "Attivi: {{names}}\nEsclusi: {{skippedNames}}",
5169
+ "alarm.arm.body.uncovered": "Attivi: nessuno — nessuna regola usa questa modalità",
5170
+ "alarm.arm.body.allSkipped": "Attivi: nessuno\nEsclusi: {{names}}",
5171
+ "alarm.arming.body": "Nessuna esclusione",
5172
+ "alarm.arming.body.uncovered": "Attivi: nessuno — nessuna regola usa questa modalità",
5173
+ "alarm.arming.body.allSkipped": "Attivi: nessuno\nEsclusi: {{names}}",
5174
+ "alarm.arming.body.skipped": "Esclusi: {{names}}",
5175
+ "alarm.arm.body.bypassed": "Attivi: {{devices}}\nBypassati: {{bypassedNames}}",
5176
+ "alarm.arming.body.bypassed": "Bypassati: {{bypassedNames}}",
5177
+ "alarm.armRefused.body": "Aperti: {{names}}\nRipeti entro {{seconds}}s per inserire con bypass.",
4584
5178
  "alarm.device.unnamed": "Dispositivo {{id}}",
5179
+ "alarm.combined.title": "Allarme: {{deviceName}}",
4585
5180
  "system.alarm-arm-refused.title.home": "Inserimento Casa rifiutato",
4586
5181
  "system.alarm-arm-refused.title.away": "Inserimento Fuori rifiutato",
4587
5182
  "system.alarm-arm-refused.title.night": "Inserimento Notte rifiutato",
4588
- "system.alarm-arm-refused.body": "L'allarme non è stato inserito — qualcosa che copre è aperto.",
4589
- "system.alarm-arming.title.home": "Inserimento Casa",
4590
- "system.alarm-arming.title.away": "Inserimento Fuori",
4591
- "system.alarm-arming.title.night": "Inserimento Notte",
4592
- "system.alarm-arming.body": "L'allarme si sta inserendo",
4593
- "system.alarm-armed.title.home": "Casa attivato",
4594
- "system.alarm-armed.title.away": "Fuori attivato",
4595
- "system.alarm-armed.title.night": "Notte attivato",
4596
- "system.alarm-armed.body": "L'allarme è stato inserito",
4597
- "system.alarm-disarmed.title": "Allarme disattivato",
4598
- "system.alarm-disarmed.body": "L'allarme è stato disattivato",
5183
+ "system.alarm-arm-refused.body": "Inserimento rifiutato",
5184
+ "system.alarm-arming.title.home": "Inserimento Casa — {{seconds}}s",
5185
+ "system.alarm-arming.title.away": "Inserimento Fuori — {{seconds}}s",
5186
+ "system.alarm-arming.title.night": "Inserimento Notte — {{seconds}}s",
5187
+ "system.alarm-arming.body": "Inserimento in corso",
5188
+ "system.alarm-armed.title.home": "Inserito: Casa",
5189
+ "system.alarm-armed.title.away": "Inserito: Fuori",
5190
+ "system.alarm-armed.title.night": "Inserito: Notte",
5191
+ "system.alarm-armed.body": "Allarme inserito",
5192
+ "system.alarm-disarmed.title": "Disinserito",
5193
+ "system.alarm-disarmed.body": "Allarme disinserito",
4599
5194
  "system.alarm-triggered.title": "ALLARME",
4600
- "system.alarm-triggered.body": "L'allarme è scattato",
4601
- "system.alarm-triggered.body.rule": "L'allarme è scattato per {{rule}} su {{deviceName}}",
5195
+ "system.alarm-triggered.body": "Allarme scattato",
5196
+ "system.alarm-triggered.body.rule": "Scattato da {{rule}} su {{deviceName}}",
4602
5197
  "button.snooze.minutes": "Silenzia {{minutes}} min",
4603
5198
  "button.snooze.hours": {
4604
5199
  "one": "Silenzia {{hours}} ora",
@@ -5317,7 +5912,8 @@ var NcDispatcher = class {
5317
5912
  const systemEvent = subject.systemEvent;
5318
5913
  const displayDeviceId = systemEvent?.deviceId ?? subject.deviceId;
5319
5914
  const deviceName = systemEvent !== void 0 && systemEvent.deviceId === void 0 ? "system" : await this.deps.getDeviceName(displayDeviceId).catch(() => null) ?? `camera ${displayDeviceId}`;
5320
- const zoneLabels = systemEvent !== void 0 ? [] : await resolveZoneLabels(this.deps.getZoneNames, subject.deviceId, subject.zones);
5915
+ const alarmSource = systemEvent?.alarmSource;
5916
+ const zoneLabels = systemEvent === void 0 ? await resolveZoneLabels(this.deps.getZoneNames, subject.deviceId, subject.zones) : alarmSource?.zones !== void 0 && alarmSource.zones.length > 0 ? await resolveZoneLabels(this.deps.getZoneNames, alarmSource.deviceId, alarmSource.zones) : [];
5321
5917
  const vars = buildTemplateVars(this.texts, entry, deviceName, zoneLabels);
5322
5918
  const systemCount = systemEvent?.textCount !== void 0 ? { count: systemEvent.textCount } : {};
5323
5919
  const title = this.renderText({
@@ -5660,6 +6256,10 @@ async function resolveZoneLabels(getZoneNames, deviceId, zoneIds) {
5660
6256
  return [...zoneIds];
5661
6257
  }
5662
6258
  }
6259
+ /** `{{confidence}}` as a whole percent, or empty when nothing measured one. */
6260
+ function percentOf(confidence) {
6261
+ return confidence !== void 0 ? `${Math.round(confidence * 100)}%` : "";
6262
+ }
5663
6263
  /**
5664
6264
  * The variable map ONE render pass sees: scalars, then the composites built
5665
6265
  * from them, then the params a system-event row froze at intake.
@@ -5675,19 +6275,23 @@ function buildTemplateVars(texts, entry, deviceName, zoneLabels) {
5675
6275
  const subject = entry.payload.subject;
5676
6276
  const occupancy = subject.occupancy;
5677
6277
  const systemEvent = subject.systemEvent;
6278
+ const detection = systemEvent?.alarmSource?.className !== void 0 ? systemEvent.alarmSource : void 0;
5678
6279
  return {
5679
6280
  camera: deviceName,
5680
- class: subject.className,
5681
- label: subject.label ?? "",
6281
+ class: detection?.className ?? subject.className,
6282
+ label: detection?.label ?? subject.label ?? "",
5682
6283
  zones: zoneLabels.join(", "),
5683
- subject: systemEvent?.subject ?? ncSubjectText(texts, {
6284
+ subject: detection?.className !== void 0 ? ncSubjectText(texts, {
6285
+ className: detection.className,
6286
+ ...detection.label !== void 0 ? { label: detection.label } : {}
6287
+ }) : systemEvent?.subject ?? ncSubjectText(texts, {
5684
6288
  className: subject.className,
5685
6289
  ...subject.label !== void 0 ? { label: subject.label } : {}
5686
6290
  }),
5687
6291
  inZones: ncInZonesText(texts, zoneLabels),
5688
6292
  scope: occupancy?.zone ?? deviceName,
5689
6293
  zone: occupancy?.zone ?? zoneLabels[0] ?? "",
5690
- confidence: subject.confidence !== void 0 ? `${Math.round(subject.confidence * 100)}%` : "",
6294
+ confidence: percentOf(detection?.confidence ?? subject.confidence),
5691
6295
  time: new Date(subject.timestamp).toLocaleTimeString(),
5692
6296
  rule: entry.payload.ruleName,
5693
6297
  count: occupancy !== void 0 ? `${occupancy.count}` : "",
@@ -6227,6 +6831,35 @@ function incomingFromSystemEvent(event, lookup) {
6227
6831
  }
6228
6832
  }
6229
6833
  /**
6834
+ * The COMBINED row's two keys, or null when this transition is not one.
6835
+ *
6836
+ * A rule that trips the alarm produces ONE notification, and the operator has
6837
+ * to be able to read it: the title is the severity marker over the camera
6838
+ * (`alarm.combined.title`, shipped as `Alarm: {{deviceName}}` and re-wordable
6839
+ * per rule or in the Texts tab), and the body is `detection.body` — the exact
6840
+ * sentence that rule's ordinary notification would have carried, with its own
6841
+ * class, identity and zones.
6842
+ *
6843
+ * The alarm used to write its own prose here ("The alarm was triggered by
6844
+ * Person at gate on Ingresso"), which made the one notification that matters
6845
+ * the only one in the system that did not look like the rule's others: no
6846
+ * identity, no zone, a different subject. The prefix carries the severity; the
6847
+ * familiar line carries the meaning.
6848
+ *
6849
+ * **Gated on the detection vocabulary, not on the source.** `detection.body`
6850
+ * renders `{{subject}}`, and a fire ref with no class composes it from the
6851
+ * system event's dedup slot — "alarm:42 su Ingresso". A source without a class
6852
+ * therefore keeps the alarm's own sentence, which is also what every row
6853
+ * enqueued before this deploy carries.
6854
+ */
6855
+ function combinedAlarmText(source) {
6856
+ if (source?.className === void 0) return null;
6857
+ return {
6858
+ titleKey: "alarm.combined.title",
6859
+ bodyKey: "detection.body"
6860
+ };
6861
+ }
6862
+ /**
6230
6863
  * The panel's own transition, as a system-event subject.
6231
6864
  *
6232
6865
  * IN-PROCESS, not off the bus. The alarm and the Notification Center live in
@@ -6249,6 +6882,7 @@ function incomingFromAlarmTransition(input) {
6249
6882
  const deviceId = source?.deviceId ?? panelDeviceId;
6250
6883
  const identity = input.lookup(deviceId);
6251
6884
  const kind = transition.kind;
6885
+ const combined = combinedAlarmText(source);
6252
6886
  return systemSubject({
6253
6887
  facts: {
6254
6888
  eventId: `alarm:${kind}:${String(transition.at)}`,
@@ -6264,12 +6898,13 @@ function incomingFromAlarmTransition(input) {
6264
6898
  },
6265
6899
  kind,
6266
6900
  subject: `alarm:${String(panelDeviceId)}`,
6267
- titleKey: transition.mode !== void 0 ? `system.${kind}.title.${transition.mode}` : `system.${kind}.title`,
6268
- bodyKey: input.body?.bodyKey ?? (source !== void 0 ? `system.${kind}.body.rule` : `system.${kind}.body`),
6901
+ titleKey: combined !== null ? combined.titleKey : transition.mode !== void 0 ? `system.${kind}.title.${transition.mode}` : `system.${kind}.title`,
6902
+ bodyKey: combined?.bodyKey ?? input.body?.bodyKey ?? (source !== void 0 ? `system.${kind}.body.rule` : `system.${kind}.body`),
6269
6903
  textParams: {
6270
6904
  ...deviceTextParams(deviceId, identity),
6271
6905
  ...transition.mode !== void 0 ? { mode: transition.mode } : {},
6272
6906
  ...source !== void 0 ? { rule: source.ruleName } : {},
6907
+ ...kind === "alarm-arming" ? { seconds: `${transition.seconds ?? 0}` } : {},
6273
6908
  ...input.body?.textParams
6274
6909
  },
6275
6910
  deviceId,
@@ -10010,7 +10645,15 @@ var NcSummaryProducer = class {
10010
10645
  }
10011
10646
  const bytes = new Uint8Array(rendered.jpeg.byteLength);
10012
10647
  bytes.set(rendered.jpeg);
10013
- const url = await this.deps.publish(bytes, "image/jpeg");
10648
+ const { url } = await this.deps.publish(bytes, "image/jpeg", {
10649
+ kind: "summary-mosaic",
10650
+ deviceId: rule.deviceIds[0] ?? null,
10651
+ deviceIds: [...new Set(tiles.map((t) => t.candidate.deviceId))],
10652
+ capturedAt: window.endMs,
10653
+ ruleId: rule.id,
10654
+ ruleName: rule.name,
10655
+ ...rule.ownerUserId !== void 0 ? { ownerUserId: rule.ownerUserId } : {}
10656
+ });
10014
10657
  if (url === null) {
10015
10658
  this.deps.logger.warn("summary mosaic not published: no reachable public endpoint", { meta: { ruleId: rule.id } });
10016
10659
  return {
@@ -11012,7 +11655,7 @@ function unknownNcTextKeys(keys, bundles = NC_TEXT_BUNDLES) {
11012
11655
  * durable document stays the sparse map its schema describes.
11013
11656
  */
11014
11657
  function applyNcTextOverridePatch(current, language, patch) {
11015
- const next = { ...current[language] ?? {} };
11658
+ const next = { ...current[language] };
11016
11659
  for (const [key, value] of Object.entries(patch)) {
11017
11660
  if (value === null || value.trim().length === 0) {
11018
11661
  delete next[key];
@@ -11580,7 +12223,7 @@ var TimelapseRenderer = class {
11580
12223
  }
11581
12224
  });
11582
12225
  const finished = await this.awaitReady(queued, tags);
11583
- const published = await this.publish(finished.id, deviceId, tags, request.previewText, request.previewMode ?? "image");
12226
+ const published = await this.publish(finished.id, request, tags);
11584
12227
  const downloadUrl = await this.ports.resolveDownloadUrl?.({ exportId: finished.id }).catch(() => null);
11585
12228
  return {
11586
12229
  exportId: finished.id,
@@ -11632,7 +12275,9 @@ var TimelapseRenderer = class {
11632
12275
  * notification, not a failed window — and failing here would leave the pair
11633
12276
  * unstamped and re-render the same eight hours on the next tick.
11634
12277
  */
11635
- async publish(exportId, deviceId, tags, previewText, previewMode) {
12278
+ async publish(exportId, request, tags) {
12279
+ const { deviceId, previewText } = request;
12280
+ const previewMode = request.previewMode ?? "image";
11636
12281
  const publish = this.ports.publishArtifact;
11637
12282
  const none = {
11638
12283
  video: null,
@@ -11652,7 +12297,11 @@ var TimelapseRenderer = class {
11652
12297
  ...previewText !== void 0 ? {
11653
12298
  previewText,
11654
12299
  previewMode
11655
- } : {}
12300
+ } : {},
12301
+ capturedAt: request.window.endMs,
12302
+ ruleId: request.ruleId,
12303
+ ruleName: request.ruleName,
12304
+ ...request.ownerUserId !== void 0 ? { ownerUserId: request.ownerUserId } : {}
11656
12305
  });
11657
12306
  if (set.video === null) this.ports.logger.warn("timelapse artifact could not be published — link-less delivery", {
11658
12307
  tags,
@@ -12008,7 +12657,8 @@ var TimelapseScheduler = class {
12008
12657
  ...rule.previewText !== void 0 ? { text: rule.previewText } : {}
12009
12658
  }),
12010
12659
  previewMode: rule.previewMode,
12011
- ...rule.reportClasses !== void 0 ? { reportClasses: rule.reportClasses } : {}
12660
+ ...rule.reportClasses !== void 0 ? { reportClasses: rule.reportClasses } : {},
12661
+ ...rule.ownerUserId !== void 0 ? { ownerUserId: rule.ownerUserId } : {}
12012
12662
  });
12013
12663
  const rows = buildTimelapseOutboxInputs({
12014
12664
  texts: this.texts,
@@ -12584,6 +13234,7 @@ function frozenOccupancy(rule, occ) {
12584
13234
  */
12585
13235
  function fireRefFor(rule, subject, kind) {
12586
13236
  const hasEventMedia = kind === "object-event" || kind === "package-event";
13237
+ const className = subject.classNames[0];
12587
13238
  return {
12588
13239
  ruleId: rule.id,
12589
13240
  ruleName: rule.name,
@@ -12592,7 +13243,11 @@ function fireRefFor(rule, subject, kind) {
12592
13243
  recordId: subject.recordId,
12593
13244
  ...subject.trackId !== void 0 ? { trackId: subject.trackId } : {},
12594
13245
  ...hasEventMedia ? { eventId: subject.recordId } : {},
12595
- timestamp: subject.timestamp
13246
+ timestamp: subject.timestamp,
13247
+ ...className !== void 0 ? { className } : {},
13248
+ ...subject.label !== void 0 ? { label: subject.label } : {},
13249
+ zones: notificationZones(rule, subject),
13250
+ ...subject.confidence !== void 0 ? { confidence: subject.confidence } : {}
12596
13251
  };
12597
13252
  }
12598
13253
  function notificationZones(rule, subject) {
@@ -13319,6 +13974,7 @@ var NotificationCenter = class NotificationCenter {
13319
13974
  await TimelapseStore.declare(store);
13320
13975
  await NcSummaryStore.declare(store);
13321
13976
  await NcTextCatalogStore.declare(store);
13977
+ await NcArtifactIndex.declare(store);
13322
13978
  }
13323
13979
  /**
13324
13980
  * Load rules (every node — the cap provider serves CRUD from any node).
@@ -15376,7 +16032,23 @@ var ncActions = require_dist.defineCustomActions({
15376
16032
  }), NcTextCatalogViewSchema, {
15377
16033
  kind: "mutation",
15378
16034
  auth: "admin"
15379
- })
16035
+ }),
16036
+ /**
16037
+ * One page of artefacts, newest `capturedAt` first.
16038
+ *
16039
+ * `deviceIds` filters on the camera an artefact is FILED under. A digest
16040
+ * spans cameras and is filed under its rule's first one (the same camera its
16041
+ * outbox row uses); to ask "everything this digest produced" use `ruleId`.
16042
+ */
16043
+ "nc.listArtifacts": require_dist.customAction(NcArtifactQuerySchema, require_dist.object({
16044
+ artifacts: require_dist.array(NcArtifactRecordSchema),
16045
+ /** Rows matching the filter, not rows on this page. */
16046
+ total: require_dist.number().int().nonnegative(),
16047
+ hasMore: require_dist.boolean()
16048
+ }), { caller: "required" }),
16049
+ /** One artefact's metadata. `null` for unknown, expired AND not-yours alike —
16050
+ * a surface that distinguishes them reports what other people own. */
16051
+ "nc.getArtifact": require_dist.customAction(require_dist.object({ artifactId: require_dist.string().min(1) }), require_dist.object({ artifact: NcArtifactRecordSchema.nullable() }), { caller: "required" })
15380
16052
  });
15381
16053
  /** Fail-closed caller resolution — an absent forwarded caller is NEVER admin. */
15382
16054
  function requireCaller(caller) {
@@ -15426,6 +16098,12 @@ function makeNcActionHandlers(deps) {
15426
16098
  if (sum === void 0) throw new Error("this node does not host the summary producer — use the hub");
15427
16099
  return sum;
15428
16100
  };
16101
+ /** The artefact index, or an honest refusal. */
16102
+ const requireArtifacts = () => {
16103
+ const index = deps.artifacts;
16104
+ if (index === void 0) throw new Error("this node does not host the notification artifact index — use the hub");
16105
+ return index;
16106
+ };
15429
16107
  /** The text catalog editor, or an honest refusal. */
15430
16108
  const requireTexts = () => {
15431
16109
  const editor = deps.texts;
@@ -15737,7 +16415,26 @@ function makeNcActionHandlers(deps) {
15737
16415
  return input.language === void 0 ? texts.view() : texts.view(input.language);
15738
16416
  },
15739
16417
  "nc.setLanguage": async (input) => requireTexts().setLanguage(input.language),
15740
- "nc.setTextOverrides": async (input) => requireTexts().setOverrides(input.language, input.overrides)
16418
+ "nc.setTextOverrides": async (input) => requireTexts().setOverrides(input.language, input.overrides),
16419
+ "nc.listArtifacts": async (input, caller) => {
16420
+ const c = requireCaller(caller);
16421
+ const page = await requireArtifacts().list(input, {
16422
+ userId: c.userId,
16423
+ isAdmin: c.isAdmin
16424
+ });
16425
+ return {
16426
+ artifacts: [...page.artifacts],
16427
+ total: page.total,
16428
+ hasMore: page.hasMore
16429
+ };
16430
+ },
16431
+ "nc.getArtifact": async (input, caller) => {
16432
+ const c = requireCaller(caller);
16433
+ return { artifact: await requireArtifacts().get(input.artifactId, {
16434
+ userId: c.userId,
16435
+ isAdmin: c.isAdmin
16436
+ }) };
16437
+ }
15741
16438
  };
15742
16439
  }
15743
16440
  //#endregion
@@ -15791,7 +16488,8 @@ var PREVIEW_FALLBACK_NAME = "timelapse-preview.jpg";
15791
16488
  * member has a logged reason. It never rejects.
15792
16489
  */
15793
16490
  function buildTimelapseArtifactPublisher(ports) {
15794
- return async ({ exportId, deviceId, previewText, previewMode }) => {
16491
+ return async (request) => {
16492
+ const { exportId, deviceId, previewText, previewMode } = request;
15795
16493
  const tags = { deviceId };
15796
16494
  const none = {
15797
16495
  video: null,
@@ -15822,15 +16520,32 @@ function buildTimelapseArtifactPublisher(ports) {
15822
16520
  return none;
15823
16521
  }
15824
16522
  const mp4 = node_buffer.Buffer.from(read.base64, "base64");
16523
+ const capturedAt = request.capturedAt ?? Date.now();
16524
+ const common = {
16525
+ deviceId,
16526
+ deviceIds: [deviceId],
16527
+ capturedAt,
16528
+ ...request.ruleId !== void 0 ? { ruleId: request.ruleId } : {},
16529
+ ...request.ruleName !== void 0 ? { ruleName: request.ruleName } : {},
16530
+ ...request.ownerUserId !== void 0 ? { ownerUserId: request.ownerUserId } : {}
16531
+ };
15825
16532
  const preview = await publishPreview(ports, {
15826
16533
  mp4,
15827
16534
  deviceId,
15828
16535
  tags,
15829
16536
  previewText,
15830
- previewMode: previewMode ?? "image"
16537
+ previewMode: previewMode ?? "image",
16538
+ meta: {
16539
+ ...common,
16540
+ kind: "timelapse-preview"
16541
+ }
15831
16542
  });
15832
16543
  try {
15833
- const url = await ports.publish(mp4, read.contentType);
16544
+ const { url } = await ports.publish(mp4, read.contentType, {
16545
+ ...common,
16546
+ kind: "timelapse",
16547
+ ...preview.artifactId !== null ? { posterArtifactId: preview.artifactId } : {}
16548
+ });
15834
16549
  if (url === null) {
15835
16550
  ports.logger.warn("timelapse artifact not published: no reachable public endpoint", {
15836
16551
  tags,
@@ -15838,7 +16553,7 @@ function buildTimelapseArtifactPublisher(ports) {
15838
16553
  });
15839
16554
  return {
15840
16555
  video: null,
15841
- preview
16556
+ preview: preview.ref
15842
16557
  };
15843
16558
  }
15844
16559
  ports.logger.info("timelapse artifact published", {
@@ -15847,7 +16562,7 @@ function buildTimelapseArtifactPublisher(ports) {
15847
16562
  exportId,
15848
16563
  bytes: read.bytes,
15849
16564
  name: read.name,
15850
- preview: preview !== null
16565
+ preview: preview.ref !== null
15851
16566
  }
15852
16567
  });
15853
16568
  return {
@@ -15856,7 +16571,7 @@ function buildTimelapseArtifactPublisher(ports) {
15856
16571
  mime: read.contentType,
15857
16572
  name: read.name
15858
16573
  },
15859
- preview
16574
+ preview: preview.ref
15860
16575
  };
15861
16576
  } catch (err) {
15862
16577
  ports.logger.warn("timelapse artifact not published: artifact plane refused the bytes", {
@@ -15868,18 +16583,22 @@ function buildTimelapseArtifactPublisher(ports) {
15868
16583
  });
15869
16584
  return {
15870
16585
  video: null,
15871
- preview
16586
+ preview: preview.ref
15872
16587
  };
15873
16588
  }
15874
16589
  };
15875
16590
  }
16591
+ var NO_PREVIEW = {
16592
+ ref: null,
16593
+ artifactId: null
16594
+ };
15876
16595
  /**
15877
16596
  * The preview half. Best-effort at every step and never throws: the picture is
15878
16597
  * an enhancement, the video is the notification.
15879
16598
  */
15880
16599
  async function publishPreview(ports, input) {
15881
16600
  const render = ports.renderPreview;
15882
- if (render === void 0 || input.previewText === void 0) return null;
16601
+ if (render === void 0 || input.previewText === void 0) return NO_PREVIEW;
15883
16602
  try {
15884
16603
  const rendered = await render({
15885
16604
  mp4: input.mp4,
@@ -15887,23 +16606,29 @@ async function publishPreview(ports, input) {
15887
16606
  deviceId: input.deviceId,
15888
16607
  mode: input.previewMode
15889
16608
  });
15890
- if (rendered === null) return null;
15891
- const url = await ports.publish(rendered.bytes, rendered.mime);
16609
+ if (rendered === null) return NO_PREVIEW;
16610
+ const { url, artifactId } = await ports.publish(rendered.bytes, rendered.mime, input.meta);
15892
16611
  if (url === null) {
15893
16612
  ports.logger.warn("timelapse preview not published: no reachable public endpoint", { tags: input.tags });
15894
- return null;
16613
+ return {
16614
+ ref: null,
16615
+ artifactId
16616
+ };
15895
16617
  }
15896
16618
  return {
15897
- url,
15898
- mime: rendered.mime,
15899
- name: PREVIEW_NAME_BY_MIME[rendered.mime] ?? PREVIEW_FALLBACK_NAME
16619
+ ref: {
16620
+ url,
16621
+ mime: rendered.mime,
16622
+ name: PREVIEW_NAME_BY_MIME[rendered.mime] ?? PREVIEW_FALLBACK_NAME
16623
+ },
16624
+ artifactId
15900
16625
  };
15901
16626
  } catch (err) {
15902
16627
  ports.logger.warn("timelapse preview not published: publish threw", {
15903
16628
  tags: input.tags,
15904
16629
  meta: { error: String(err) }
15905
16630
  });
15906
- return null;
16631
+ return NO_PREVIEW;
15907
16632
  }
15908
16633
  }
15909
16634
  /** Title size as a fraction of the frame WIDTH — so it reads the same on a
@@ -16390,6 +17115,10 @@ function startAlarmPanelConvergence(ports) {
16390
17115
  * `data-plane-access.spec.ts` asserts they still match the constants their
16391
17116
  * handlers register with — a mirror nothing checks is a lie waiting to happen.
16392
17117
  */
17118
+ /** The artefact read route's prefix, imported rather than mirrored: it is
17119
+ * ALSO the prefix `artifactDownloadPath` writes into every index row, and two
17120
+ * copies of that string is a 404 nobody would find. */
17121
+ var NC_ARTIFACT_READ_PREFIX_REF = NC_ARTIFACT_READ_PREFIX;
16393
17122
  var TRAINING_EXPORT_PREFIX_REF = "training-export";
16394
17123
  var RETRAIN_EXPORT_PREFIX_REF = "retrain-export";
16395
17124
  var DEBUG_EXPORT_PREFIX_REF = "debug-export";
@@ -16405,6 +17134,14 @@ var PIPELINE_ANALYTICS_PLANE_ACCESS = {
16405
17134
  "nc-action": "public",
16406
17135
  /** Self-authenticating single-use artifact token (D47). */
16407
17136
  "nc-artifact": "public",
17137
+ /**
17138
+ * The VIEWER's read of the same bytes (task #36). One indexed artefact by an
17139
+ * unguessable id the caller was handed by `nc.listArtifacts` — the rule above,
17140
+ * applied unchanged. It is deliberately NOT the `public` signed plane: the
17141
+ * viewer holds a session, and pre-minting a 24 h signed link for every
17142
+ * artefact a user might scroll to is a link-leak surface for no gain.
17143
+ */
17144
+ [NC_ARTIFACT_READ_PREFIX_REF]: "authenticated",
16408
17145
  /** Every frame behind a marked selection — a corpus, not an item. */
16409
17146
  [TRAINING_EXPORT_PREFIX_REF]: "admin",
16410
17147
  [RETRAIN_EXPORT_PREFIX_REF]: "admin",
@@ -36070,6 +36807,138 @@ var BindingCache = class {
36070
36807
  }
36071
36808
  };
36072
36809
  //#endregion
36810
+ //#region src/pipeline-analytics/runtime/detection-coverage-cache.ts
36811
+ /**
36812
+ * DetectionCoverageCache — fleet-seeded, event-invalidated mirror of WHICH
36813
+ * devices carry a WRAPPED `detection-pipeline` binding. This is the alarm's
36814
+ * coverage authority ("Away armed, 12 cameras" is false if three of them are
36815
+ * offline or no longer wrapped), read by the notification centre's device
36816
+ * directory (`listDeviceIdentities`).
36817
+ *
36818
+ * WHY a mirror, not a per-tick sweep. The directory refreshes on the NC's 30 s
36819
+ * rule-reload tick. Answering "who still has detection?" with a fleet-wide
36820
+ * `getAllBindings` on EVERY tick cost 903 ms / 373 KB / ~505 `getBindings:
36821
+ * default wrappers skipped` log lines on the live hub (524 devices), forever —
36822
+ * ~3% of hub-main's main thread plus a permanent log-spam baseline. Same defect
36823
+ * and fix as `SystemMirror.refreshBinding` (639073e8): ONE fleet read to warm,
36824
+ * then per-device updates driven by `DeviceBindingsChanged` events. A wrapper
36825
+ * toggle emits that event carrying the device's id and `capName`
36826
+ * (`detection-pipeline`), so a single `getBindings(deviceId)` — 21 ms / 613 B —
36827
+ * keeps the mirror current without ever touching the fleet again.
36828
+ *
36829
+ * ── Destructive-direction discipline (D49) ──────────────────────────────────
36830
+ *
36831
+ * `detectionActive: false` reports a camera as UNCOVERED / switched-off (D62) —
36832
+ * the destructive answer, and the one an operator reads as "my alarm is not
36833
+ * watching that door". A transient `getBindings` blip must never flip a
36834
+ * known-covered camera to uncovered:
36835
+ * • a failed refresh KEEPS the previous value (a read that throws changes
36836
+ * nothing), and
36837
+ * • a single read that would flip a known `true → false` is confirmed by a
36838
+ * concordant SECOND read before it lands — an empty binding list is not
36839
+ * proof the wrapper is gone.
36840
+ * A COLD mirror (seed not yet landed, or a seed that failed) answers `undefined`
36841
+ * for every id — and unknown never reports a camera as excluded from coverage.
36842
+ */
36843
+ var DetectionCoverageCache = class {
36844
+ api;
36845
+ logger;
36846
+ /** deviceId → "has a WRAPPED detection-pipeline binding". Absent = unknown. */
36847
+ state = /* @__PURE__ */ new Map();
36848
+ warm = false;
36849
+ seeding = null;
36850
+ constructor(deps) {
36851
+ this.api = deps.api;
36852
+ this.logger = deps.logger;
36853
+ }
36854
+ /** Has the ONE fleet seed ever landed? A cold mirror knows nothing. */
36855
+ isWarm() {
36856
+ return this.warm;
36857
+ }
36858
+ /** Do we have an answer for this id at all? (`false` ⇒ absent ⇒ unknown.) */
36859
+ has(deviceId) {
36860
+ return this.state.has(deviceId);
36861
+ }
36862
+ /** The coverage answer, or `undefined` when this id is unknown. No I/O. */
36863
+ get(deviceId) {
36864
+ return this.state.get(deviceId);
36865
+ }
36866
+ /**
36867
+ * Seed the whole fleet ONCE with a single `getAllBindings`. Idempotent: a
36868
+ * warm cache is a no-op, and concurrent callers coalesce onto one read. A
36869
+ * failed seed leaves the mirror COLD so the next tick retries — during that
36870
+ * window every id answers unknown, which never reports a camera as excluded.
36871
+ */
36872
+ async ensureWarm() {
36873
+ if (this.warm) return;
36874
+ if (this.seeding) return this.seeding;
36875
+ this.seeding = this.seed();
36876
+ try {
36877
+ await this.seeding;
36878
+ } finally {
36879
+ this.seeding = null;
36880
+ }
36881
+ }
36882
+ async seed() {
36883
+ try {
36884
+ const rows = await this.api.deviceManager.getAllBindings.query({});
36885
+ for (const row of rows) {
36886
+ if (this.state.has(row.deviceId)) continue;
36887
+ this.state.set(row.deviceId, hasWrappedDetection(row.entries));
36888
+ }
36889
+ this.warm = true;
36890
+ this.logger.info("detection-coverage mirror warm", { meta: { devices: this.state.size } });
36891
+ } catch (err) {
36892
+ this.logger.warn("detection-coverage seed failed — coverage unknown until it lands", { meta: { error: String(err) } });
36893
+ }
36894
+ }
36895
+ /**
36896
+ * A `DeviceBindingsChanged` for the detection cap re-reads THAT ONE device
36897
+ * (never the fleet). Fire-and-forget from the synchronous event handler.
36898
+ */
36899
+ onBindingsChanged(event) {
36900
+ if (event.capName !== "detection-pipeline") return Promise.resolve();
36901
+ return this.refreshDevice(event.deviceId);
36902
+ }
36903
+ /** Drop a device the fleet no longer has (`device.unregistered`). */
36904
+ forget(deviceId) {
36905
+ this.state.delete(deviceId);
36906
+ }
36907
+ /** Return to cold — used on addon shutdown. */
36908
+ clearAll() {
36909
+ this.state.clear();
36910
+ this.warm = false;
36911
+ this.seeding = null;
36912
+ }
36913
+ async refreshDevice(deviceId) {
36914
+ try {
36915
+ const active = await this.readWrappedDetection(deviceId);
36916
+ const prev = this.state.get(deviceId);
36917
+ if (!active && prev === true) {
36918
+ if (await this.readWrappedDetection(deviceId)) {
36919
+ this.state.set(deviceId, true);
36920
+ return;
36921
+ }
36922
+ }
36923
+ this.state.set(deviceId, active);
36924
+ } catch (err) {
36925
+ this.logger.debug("detection-coverage refresh failed — keeping the previous value", {
36926
+ tags: { deviceId },
36927
+ meta: { error: String(err) }
36928
+ });
36929
+ }
36930
+ }
36931
+ async readWrappedDetection(deviceId) {
36932
+ return hasWrappedDetection((await this.api.deviceManager.getBindings.query({ deviceId })).entries);
36933
+ }
36934
+ };
36935
+ /** The one authority: a WRAPPED `detection-pipeline` binding is present. A
36936
+ * native binding of the same cap is NOT coverage (D62: absence of the WRAPPED
36937
+ * binding is how a deactivated wrapper is represented). */
36938
+ function hasWrappedDetection(entries) {
36939
+ return entries.some((e) => e.capName === "detection-pipeline" && e.kind === "wrapped");
36940
+ }
36941
+ //#endregion
36073
36942
  //#region src/pipeline-analytics/runtime/capture-scheduler.ts
36074
36943
  /**
36075
36944
  * Kind-derived default priorities (0 = highest). Explicit `priority` wins.
@@ -41215,6 +42084,15 @@ var REBUILD_FALLBACK_NODE_ID = "hub";
41215
42084
  */
41216
42085
  var TIMELAPSE_TRACK_QUERY_LIMIT = 2e3;
41217
42086
  var PIPELINE_ANALYTICS_ADDON_ID = "pipeline-analytics";
42087
+ /** Artefact index ⇄ shelf reconcile cadence. Hourly: the two only drift on the
42088
+ * bounds' own timescale (14 days / 2000 entries / 2 GiB). */
42089
+ var NC_ARTIFACT_PRUNE_INTERVAL_MS = 36e5;
42090
+ /** What a publish answers on a node whose artifact plane never came up: no
42091
+ * link, no index row — and, exactly as before, bytes-only delivery. */
42092
+ var NO_PUBLICATION = {
42093
+ url: null,
42094
+ artifactId: null
42095
+ };
41218
42096
  /**
41219
42097
  * The FULL action catalog for this addon.
41220
42098
  *
@@ -41452,6 +42330,12 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
41452
42330
  * path). Null on non-post-processing nodes or when disabled. */
41453
42331
  embeddingDispatcher = null;
41454
42332
  bindingCache = null;
42333
+ /** Alarm-coverage mirror the NC device directory reads — WHICH devices carry
42334
+ * a wrapped `detection-pipeline` binding. Fleet-seeded ONCE, then per-device
42335
+ * updates off `DeviceBindingsChanged`, so the 30 s rule-reload tick stops
42336
+ * sweeping `getAllBindings` over the whole fleet. See
42337
+ * `runtime/detection-coverage-cache.ts`. */
42338
+ detectionCoverage = null;
41455
42339
  /** Per-device throttle for the inactive-binding frame-drop warn (5 min). */
41456
42340
  inactiveBindingDropWarnAt = /* @__PURE__ */ new Map();
41457
42341
  zoneAnalytics = null;
@@ -41490,6 +42374,12 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
41490
42374
  * data-plane handle. Null until served / when the facility is absent. */
41491
42375
  ncArtifactPlane = null;
41492
42376
  ncArtifactDataPlane = null;
42377
+ /** The viewer's artefact library (task #36): the index, its authenticated
42378
+ * bytes route, and the store the two share. Null until served. */
42379
+ ncArtifactIndex = null;
42380
+ ncArtifactStore = null;
42381
+ ncArtifactReadDataPlane = null;
42382
+ ncArtifactPruneTimer = null;
41493
42383
  /**
41494
42384
  * Roadmap 4b: ONE cut of an event, in every container the rule asked for.
41495
42385
  *
@@ -41676,6 +42566,31 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
41676
42566
  * Best-effort at every step: no facility, no secret or no reachable base URL
41677
42567
  * ⇒ no plane, and the dispatcher keeps shipping bytes exactly as before.
41678
42568
  */
42569
+ /**
42570
+ * Reconcile the artefact index against the shelf that holds the bytes.
42571
+ *
42572
+ * Hourly, because the two drift only on the bounds' timescale: a row expires
42573
+ * on the shelf's own age bound (14 d) or its file is evicted by the count/byte
42574
+ * bound. Without this, a list eventually serves rows whose bytes 404 — and a
42575
+ * broken tile is the one failure an operator DOES report.
42576
+ */
42577
+ startNcArtifactPrune() {
42578
+ if (this.ncArtifactPruneTimer !== null) return;
42579
+ const run = async () => {
42580
+ const index = this.ncArtifactIndex;
42581
+ const store = this.ncArtifactStore;
42582
+ if (index === null || store === null) return;
42583
+ try {
42584
+ await store.sweep();
42585
+ await index.prune(this.isPostProcessingNode ? await store.keptIds() : null);
42586
+ } catch (err) {
42587
+ this.ctx.logger.warn("nc artifact prune failed", { meta: { error: require_dist.errMsg(err) } });
42588
+ }
42589
+ };
42590
+ run();
42591
+ this.ncArtifactPruneTimer = setInterval(() => void run(), NC_ARTIFACT_PRUNE_INTERVAL_MS);
42592
+ this.ncArtifactPruneTimer.unref?.();
42593
+ }
41679
42594
  async serveNcArtifactPlane() {
41680
42595
  try {
41681
42596
  const secretState = this.state("ncArtifactSecret", NcArtifactSecretSchema, "");
@@ -41689,9 +42604,18 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
41689
42604
  logger: this.ctx.logger.child("nc-artifacts")
41690
42605
  });
41691
42606
  await store.start();
42607
+ this.ncArtifactStore = store;
42608
+ const index = new NcArtifactIndex({
42609
+ store: this.ctx.api.settingsStore,
42610
+ logger: this.ctx.logger.child("nc-artifacts"),
42611
+ addonId: this.ctx.id,
42612
+ retentionMs: store.retentionMs
42613
+ });
42614
+ this.ncArtifactIndex = index;
41692
42615
  const plane = new NcArtifactPlane({
41693
42616
  store,
41694
42617
  secret,
42618
+ index,
41695
42619
  logger: this.ctx.logger.child("nc-artifacts"),
41696
42620
  routePrefix: `/addon/${this.ctx.id}/nc-artifact`,
41697
42621
  listEndpoints: () => this.ncEndpoints()
@@ -41702,7 +42626,23 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
41702
42626
  handler: plane.handler
41703
42627
  }) ?? null;
41704
42628
  this.ncArtifactPlane = this.ncArtifactDataPlane !== null ? plane : null;
41705
- this.ctx.logger.info("nc-artifact data-plane served", { meta: { served: this.ncArtifactPlane !== null } });
42629
+ const readPlane = new NcArtifactReadPlane({
42630
+ index,
42631
+ store,
42632
+ logger: this.ctx.logger.child("nc-artifacts")
42633
+ });
42634
+ this.ncArtifactReadDataPlane = await this.ctx.dataPlane?.serve({
42635
+ prefix: NC_ARTIFACT_READ_PREFIX_REF,
42636
+ access: planeAccess(NC_ARTIFACT_READ_PREFIX_REF),
42637
+ handler: readPlane.handler
42638
+ }) ?? null;
42639
+ this.startNcArtifactPrune();
42640
+ this.ctx.logger.info("nc-artifact data-plane served", { meta: {
42641
+ served: this.ncArtifactPlane !== null,
42642
+ libraryServed: this.ncArtifactReadDataPlane !== null,
42643
+ libraryAccess: planeAccess(NC_ARTIFACT_READ_PREFIX_REF),
42644
+ retentionDays: Math.round(store.retentionMs / 864e5)
42645
+ } });
41706
42646
  } catch (err) {
41707
42647
  this.ctx.logger.warn("nc-artifact data-plane failed to serve", { meta: { error: require_dist.errMsg(err) } });
41708
42648
  }
@@ -42150,7 +43090,8 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
42150
43090
  store: center.summaryStore,
42151
43091
  runNow: (input) => center.runSummaryNow(input)
42152
43092
  },
42153
- texts: center.textCatalogEditor
43093
+ texts: center.textCatalogEditor,
43094
+ ...this.ncArtifactIndex !== null ? { artifacts: this.ncArtifactIndex } : {}
42154
43095
  });
42155
43096
  }
42156
43097
  /** Declare typed collections up-front so the first insert doesn't race with
@@ -42635,6 +43576,10 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
42635
43576
  api,
42636
43577
  logger: logger.child("BindingCache")
42637
43578
  });
43579
+ this.detectionCoverage = new DetectionCoverageCache({
43580
+ api,
43581
+ logger: logger.child("DetectionCoverage")
43582
+ });
42638
43583
  const zoneAnalytics = new ZoneAnalyticsProvider({
42639
43584
  logger: logger.child("ZoneAnalytics"),
42640
43585
  onOccupancySample: (deviceId, count, at, zoneId) => this.lts?.note("occupancy", deviceId, count, at, zoneId ?? ""),
@@ -42716,17 +43661,15 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
42716
43661
  },
42717
43662
  listDeviceIdentities: async () => {
42718
43663
  const all = await api.deviceManager.listAll.query({ projection: "slim" });
42719
- const detection = await api.deviceManager.getAllBindings.query({}).then((rows) => new Map(rows.map((row) => [row.deviceId, row.entries.some((e) => e.capName === "detection-pipeline" && e.kind === "wrapped")]))).catch((err) => {
42720
- this.ctx.logger.debug("bindings read failed — alarm coverage cannot report skips", { meta: { error: String(err) } });
42721
- return /* @__PURE__ */ new Map();
42722
- });
43664
+ const coverage = this.detectionCoverage;
43665
+ if (coverage) await coverage.ensureWarm();
42723
43666
  return all.map((d) => ({
42724
43667
  deviceId: d.id,
42725
43668
  type: d.type,
42726
43669
  name: d.name,
42727
43670
  isCamera: d.isCamera,
42728
43671
  online: d.online && !d.disabled,
42729
- ...detection.has(d.id) ? { detectionActive: detection.get(d.id) === true } : {}
43672
+ ...coverage?.has(d.id) ? { detectionActive: coverage.get(d.id) === true } : {}
42730
43673
  }));
42731
43674
  },
42732
43675
  readDeviceZoneIds: async (ids) => {
@@ -42937,7 +43880,7 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
42937
43880
  timestamp,
42938
43881
  data: jpeg
42939
43882
  }),
42940
- publish: async (bytes, mime) => await this.ncArtifactPlane?.publish(bytes, mime) ?? null
43883
+ publish: async (bytes, mime, meta) => await this.ncArtifactPlane?.publishIndexed(bytes, mime, meta) ?? NO_PUBLICATION
42941
43884
  };
42942
43885
  }
42943
43886
  /**
@@ -43010,7 +43953,7 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
43010
43953
  publishArtifact: buildTimelapseArtifactPublisher({
43011
43954
  logger: this.ctx.logger.child("Timelapse"),
43012
43955
  readExportBytes: ({ exportId }) => api.recordingExport.readExportBytes.query({ exportId }),
43013
- publish: async (bytes, mime) => await this.ncArtifactPlane?.publish(bytes, mime) ?? null,
43956
+ publish: async (bytes, mime, meta) => await this.ncArtifactPlane?.publishIndexed(bytes, mime, meta) ?? NO_PUBLICATION,
43014
43957
  renderPreview: async ({ mode, ...input }) => {
43015
43958
  const previewPorts = {
43016
43959
  logger: this.ctx.logger.child("Timelapse"),
@@ -43434,6 +44377,7 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
43434
44377
  * `runtime/wire-subscriptions.ts`. */
43435
44378
  handleBindingsChangedEvent(data) {
43436
44379
  this.bindingCache?.onBindingsChanged(data);
44380
+ this.detectionCoverage?.onBindingsChanged(data);
43437
44381
  if (data.capName === "pipeline-analytics" && data.reason === "wrapper-deactivated") {
43438
44382
  this.trackStore?.clearDevice(data.deviceId);
43439
44383
  this.residents.closeDevice(data.deviceId);
@@ -43459,6 +44403,7 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
43459
44403
  this.audioMarkers?.forgetDevice(deviceId);
43460
44404
  this.settingsCaches.clearDevice(deviceId);
43461
44405
  this.bindingCache?.invalidate(deviceId);
44406
+ this.detectionCoverage?.forget(deviceId);
43462
44407
  this.zoneAnalytics?.forgetDevice(deviceId);
43463
44408
  this.audioMetrics?.forgetDevice(deviceId);
43464
44409
  this.releaseProxy(deviceId);
@@ -43664,6 +44609,7 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
43664
44609
  this.trackStore?.clearAll();
43665
44610
  this.stationaryRegistry = null;
43666
44611
  this.bindingCache?.clearAll();
44612
+ this.detectionCoverage?.clearAll();
43667
44613
  await this.eventMediaDataPlane?.dispose();
43668
44614
  this.eventMediaDataPlane = null;
43669
44615
  this.eventMediaBaseUrl = null;
@@ -43674,6 +44620,14 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
43674
44620
  this.retrainExportDataPlane = null;
43675
44621
  await this.debugExportDataPlane?.dispose();
43676
44622
  this.debugExportDataPlane = null;
44623
+ if (this.ncArtifactPruneTimer !== null) {
44624
+ clearInterval(this.ncArtifactPruneTimer);
44625
+ this.ncArtifactPruneTimer = null;
44626
+ }
44627
+ await this.ncArtifactReadDataPlane?.dispose();
44628
+ this.ncArtifactReadDataPlane = null;
44629
+ this.ncArtifactIndex = null;
44630
+ this.ncArtifactStore = null;
43677
44631
  }
43678
44632
  async handleInferenceResult(data) {
43679
44633
  if (this.shuttingDown) return;