@camstack/addon-post-analysis 1.2.71 → 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-DjPaVd2L.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");
@@ -1579,6 +1579,401 @@ var NC_ALARM_DECLARATION = {
1579
1579
  DeviceClass: NcAlarmPanelDevice
1580
1580
  };
1581
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
1582
1977
  //#region ../types/dist/node.mjs
1583
1978
  var node_exports = /* @__PURE__ */ require_dist.__exportAll({
1584
1979
  FilesystemStorageProvider: () => FilesystemStorageProvider,
@@ -1969,6 +2364,61 @@ var NcArtifactPlane = class {
1969
2364
  }
1970
2365
  }
1971
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
+ /**
1972
2422
  * Absolutize a path against the SAME base URL an artifact link would use.
1973
2423
  *
1974
2424
  * For links this plane does not host but a RECIPIENT still has to open — a
@@ -2034,6 +2484,124 @@ var NcArtifactPlane = class {
2034
2484
  }
2035
2485
  };
2036
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
2037
2605
  //#region src/notification-center/artifact-store.ts
2038
2606
  /**
2039
2607
  * NcArtifactStore — the bytes behind a signed notification-artifact URL.
@@ -2048,9 +2616,25 @@ var NcArtifactPlane = class {
2048
2616
  * bites first, the oldest artifacts go. A sweep on boot clears whatever a crash
2049
2617
  * left behind — the directory is disposable by construction, so a lost file is
2050
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.
2051
2633
  */
2052
2634
  var DEFAULT_MAX_AGE_MS = 6 * 36e5;
2053
2635
  var DEFAULT_MAX_ENTRIES = 500;
2636
+ /** The retained shelf's subdirectory name, inside the scratch directory. */
2637
+ var KEPT_SUBDIR = "kept";
2054
2638
  /** `image/jpeg` → `jpg`, so a fetched link has a name a client will accept. */
2055
2639
  function extensionFor(mime) {
2056
2640
  if (mime === "image/jpeg") return "jpg";
@@ -2064,6 +2648,10 @@ var NcArtifactStore = class {
2064
2648
  now;
2065
2649
  maxAgeMs;
2066
2650
  maxEntries;
2651
+ keptMaxAgeMs;
2652
+ keptMaxEntries;
2653
+ keptMaxBytes;
2654
+ keptDir;
2067
2655
  /** id → mime, so the route can answer with the right content-type without
2068
2656
  * re-deriving it from the extension on every request. */
2069
2657
  mimeById = /* @__PURE__ */ new Map();
@@ -2072,17 +2660,32 @@ var NcArtifactStore = class {
2072
2660
  this.now = options.now ?? (() => Date.now());
2073
2661
  this.maxAgeMs = options.maxAgeMs ?? DEFAULT_MAX_AGE_MS;
2074
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;
2075
2672
  }
2076
2673
  /** Create the directory and sweep whatever a previous run left behind. */
2077
2674
  async start() {
2078
2675
  await node_fs.promises.mkdir(this.options.dir, { recursive: true });
2079
2676
  await this.sweep();
2080
2677
  }
2081
- /** Persist bytes and return the artifact handle (never throws on sweep). */
2082
- 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 = {}) {
2083
2685
  const id = `${this.now()}-${(0, node_crypto.randomUUID)()}`;
2084
- const file = node_path.default.join(this.options.dir, `${id}.${extensionFor(mime)}`);
2085
- 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 });
2086
2689
  await node_fs.promises.writeFile(file, bytes);
2087
2690
  this.mimeById.set(id, mime);
2088
2691
  await this.sweep();
@@ -2095,7 +2698,7 @@ var NcArtifactStore = class {
2095
2698
  /** Read an artifact's bytes + mime, or null when it is gone (expired/swept). */
2096
2699
  async read(id) {
2097
2700
  if (!/^[0-9]+-[0-9a-f-]{36}$/i.test(id)) return null;
2098
- 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);
2099
2702
  if (!found) return null;
2100
2703
  try {
2101
2704
  return {
@@ -2106,6 +2709,10 @@ var NcArtifactStore = class {
2106
2709
  return null;
2107
2710
  }
2108
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
+ }
2109
2716
  /** Delete every artifact (operator cleanup / shutdown). */
2110
2717
  async clear() {
2111
2718
  await node_fs.promises.rm(this.options.dir, {
@@ -2114,13 +2721,39 @@ var NcArtifactStore = class {
2114
2721
  }).catch(() => {});
2115
2722
  this.mimeById.clear();
2116
2723
  }
2117
- /** 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. */
2118
2726
  async sweep() {
2119
- const entries = await this.list();
2120
- 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;
2121
2746
  const doomed = entries.filter((e) => e.mtimeMs < cutoff);
2122
- const survivors = entries.filter((e) => e.mtimeMs >= cutoff).toSorted((a, b) => b.mtimeMs - a.mtimeMs);
2123
- 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
+ }
2124
2757
  for (const e of doomed) try {
2125
2758
  await node_fs.promises.rm(e.file, { force: true });
2126
2759
  this.mimeById.delete(e.id);
@@ -2131,23 +2764,24 @@ var NcArtifactStore = class {
2131
2764
  } });
2132
2765
  }
2133
2766
  }
2134
- async list() {
2767
+ async list(dir) {
2135
2768
  let names;
2136
2769
  try {
2137
- names = await node_fs.promises.readdir(this.options.dir);
2770
+ names = await node_fs.promises.readdir(dir);
2138
2771
  } catch {
2139
2772
  return [];
2140
2773
  }
2141
2774
  const out = [];
2142
2775
  for (const name of names) {
2143
- const file = node_path.default.join(this.options.dir, name);
2776
+ const file = node_path.default.join(dir, name);
2144
2777
  try {
2145
2778
  const st = await node_fs.promises.stat(file);
2146
2779
  if (!st.isFile()) continue;
2147
2780
  out.push({
2148
2781
  id: name.slice(0, name.lastIndexOf(".")),
2149
2782
  file,
2150
- mtimeMs: st.mtimeMs
2783
+ mtimeMs: st.mtimeMs,
2784
+ sizeBytes: st.size
2151
2785
  });
2152
2786
  } catch {}
2153
2787
  }
@@ -10011,7 +10645,15 @@ var NcSummaryProducer = class {
10011
10645
  }
10012
10646
  const bytes = new Uint8Array(rendered.jpeg.byteLength);
10013
10647
  bytes.set(rendered.jpeg);
10014
- 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
+ });
10015
10657
  if (url === null) {
10016
10658
  this.deps.logger.warn("summary mosaic not published: no reachable public endpoint", { meta: { ruleId: rule.id } });
10017
10659
  return {
@@ -11013,7 +11655,7 @@ function unknownNcTextKeys(keys, bundles = NC_TEXT_BUNDLES) {
11013
11655
  * durable document stays the sparse map its schema describes.
11014
11656
  */
11015
11657
  function applyNcTextOverridePatch(current, language, patch) {
11016
- const next = { ...current[language] ?? {} };
11658
+ const next = { ...current[language] };
11017
11659
  for (const [key, value] of Object.entries(patch)) {
11018
11660
  if (value === null || value.trim().length === 0) {
11019
11661
  delete next[key];
@@ -11581,7 +12223,7 @@ var TimelapseRenderer = class {
11581
12223
  }
11582
12224
  });
11583
12225
  const finished = await this.awaitReady(queued, tags);
11584
- const published = await this.publish(finished.id, deviceId, tags, request.previewText, request.previewMode ?? "image");
12226
+ const published = await this.publish(finished.id, request, tags);
11585
12227
  const downloadUrl = await this.ports.resolveDownloadUrl?.({ exportId: finished.id }).catch(() => null);
11586
12228
  return {
11587
12229
  exportId: finished.id,
@@ -11633,7 +12275,9 @@ var TimelapseRenderer = class {
11633
12275
  * notification, not a failed window — and failing here would leave the pair
11634
12276
  * unstamped and re-render the same eight hours on the next tick.
11635
12277
  */
11636
- async publish(exportId, deviceId, tags, previewText, previewMode) {
12278
+ async publish(exportId, request, tags) {
12279
+ const { deviceId, previewText } = request;
12280
+ const previewMode = request.previewMode ?? "image";
11637
12281
  const publish = this.ports.publishArtifact;
11638
12282
  const none = {
11639
12283
  video: null,
@@ -11653,7 +12297,11 @@ var TimelapseRenderer = class {
11653
12297
  ...previewText !== void 0 ? {
11654
12298
  previewText,
11655
12299
  previewMode
11656
- } : {}
12300
+ } : {},
12301
+ capturedAt: request.window.endMs,
12302
+ ruleId: request.ruleId,
12303
+ ruleName: request.ruleName,
12304
+ ...request.ownerUserId !== void 0 ? { ownerUserId: request.ownerUserId } : {}
11657
12305
  });
11658
12306
  if (set.video === null) this.ports.logger.warn("timelapse artifact could not be published — link-less delivery", {
11659
12307
  tags,
@@ -12009,7 +12657,8 @@ var TimelapseScheduler = class {
12009
12657
  ...rule.previewText !== void 0 ? { text: rule.previewText } : {}
12010
12658
  }),
12011
12659
  previewMode: rule.previewMode,
12012
- ...rule.reportClasses !== void 0 ? { reportClasses: rule.reportClasses } : {}
12660
+ ...rule.reportClasses !== void 0 ? { reportClasses: rule.reportClasses } : {},
12661
+ ...rule.ownerUserId !== void 0 ? { ownerUserId: rule.ownerUserId } : {}
12013
12662
  });
12014
12663
  const rows = buildTimelapseOutboxInputs({
12015
12664
  texts: this.texts,
@@ -13325,6 +13974,7 @@ var NotificationCenter = class NotificationCenter {
13325
13974
  await TimelapseStore.declare(store);
13326
13975
  await NcSummaryStore.declare(store);
13327
13976
  await NcTextCatalogStore.declare(store);
13977
+ await NcArtifactIndex.declare(store);
13328
13978
  }
13329
13979
  /**
13330
13980
  * Load rules (every node — the cap provider serves CRUD from any node).
@@ -15382,7 +16032,23 @@ var ncActions = require_dist.defineCustomActions({
15382
16032
  }), NcTextCatalogViewSchema, {
15383
16033
  kind: "mutation",
15384
16034
  auth: "admin"
15385
- })
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" })
15386
16052
  });
15387
16053
  /** Fail-closed caller resolution — an absent forwarded caller is NEVER admin. */
15388
16054
  function requireCaller(caller) {
@@ -15432,6 +16098,12 @@ function makeNcActionHandlers(deps) {
15432
16098
  if (sum === void 0) throw new Error("this node does not host the summary producer — use the hub");
15433
16099
  return sum;
15434
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
+ };
15435
16107
  /** The text catalog editor, or an honest refusal. */
15436
16108
  const requireTexts = () => {
15437
16109
  const editor = deps.texts;
@@ -15743,7 +16415,26 @@ function makeNcActionHandlers(deps) {
15743
16415
  return input.language === void 0 ? texts.view() : texts.view(input.language);
15744
16416
  },
15745
16417
  "nc.setLanguage": async (input) => requireTexts().setLanguage(input.language),
15746
- "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
+ }
15747
16438
  };
15748
16439
  }
15749
16440
  //#endregion
@@ -15797,7 +16488,8 @@ var PREVIEW_FALLBACK_NAME = "timelapse-preview.jpg";
15797
16488
  * member has a logged reason. It never rejects.
15798
16489
  */
15799
16490
  function buildTimelapseArtifactPublisher(ports) {
15800
- return async ({ exportId, deviceId, previewText, previewMode }) => {
16491
+ return async (request) => {
16492
+ const { exportId, deviceId, previewText, previewMode } = request;
15801
16493
  const tags = { deviceId };
15802
16494
  const none = {
15803
16495
  video: null,
@@ -15828,15 +16520,32 @@ function buildTimelapseArtifactPublisher(ports) {
15828
16520
  return none;
15829
16521
  }
15830
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
+ };
15831
16532
  const preview = await publishPreview(ports, {
15832
16533
  mp4,
15833
16534
  deviceId,
15834
16535
  tags,
15835
16536
  previewText,
15836
- previewMode: previewMode ?? "image"
16537
+ previewMode: previewMode ?? "image",
16538
+ meta: {
16539
+ ...common,
16540
+ kind: "timelapse-preview"
16541
+ }
15837
16542
  });
15838
16543
  try {
15839
- 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
+ });
15840
16549
  if (url === null) {
15841
16550
  ports.logger.warn("timelapse artifact not published: no reachable public endpoint", {
15842
16551
  tags,
@@ -15844,7 +16553,7 @@ function buildTimelapseArtifactPublisher(ports) {
15844
16553
  });
15845
16554
  return {
15846
16555
  video: null,
15847
- preview
16556
+ preview: preview.ref
15848
16557
  };
15849
16558
  }
15850
16559
  ports.logger.info("timelapse artifact published", {
@@ -15853,7 +16562,7 @@ function buildTimelapseArtifactPublisher(ports) {
15853
16562
  exportId,
15854
16563
  bytes: read.bytes,
15855
16564
  name: read.name,
15856
- preview: preview !== null
16565
+ preview: preview.ref !== null
15857
16566
  }
15858
16567
  });
15859
16568
  return {
@@ -15862,7 +16571,7 @@ function buildTimelapseArtifactPublisher(ports) {
15862
16571
  mime: read.contentType,
15863
16572
  name: read.name
15864
16573
  },
15865
- preview
16574
+ preview: preview.ref
15866
16575
  };
15867
16576
  } catch (err) {
15868
16577
  ports.logger.warn("timelapse artifact not published: artifact plane refused the bytes", {
@@ -15874,18 +16583,22 @@ function buildTimelapseArtifactPublisher(ports) {
15874
16583
  });
15875
16584
  return {
15876
16585
  video: null,
15877
- preview
16586
+ preview: preview.ref
15878
16587
  };
15879
16588
  }
15880
16589
  };
15881
16590
  }
16591
+ var NO_PREVIEW = {
16592
+ ref: null,
16593
+ artifactId: null
16594
+ };
15882
16595
  /**
15883
16596
  * The preview half. Best-effort at every step and never throws: the picture is
15884
16597
  * an enhancement, the video is the notification.
15885
16598
  */
15886
16599
  async function publishPreview(ports, input) {
15887
16600
  const render = ports.renderPreview;
15888
- if (render === void 0 || input.previewText === void 0) return null;
16601
+ if (render === void 0 || input.previewText === void 0) return NO_PREVIEW;
15889
16602
  try {
15890
16603
  const rendered = await render({
15891
16604
  mp4: input.mp4,
@@ -15893,23 +16606,29 @@ async function publishPreview(ports, input) {
15893
16606
  deviceId: input.deviceId,
15894
16607
  mode: input.previewMode
15895
16608
  });
15896
- if (rendered === null) return null;
15897
- 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);
15898
16611
  if (url === null) {
15899
16612
  ports.logger.warn("timelapse preview not published: no reachable public endpoint", { tags: input.tags });
15900
- return null;
16613
+ return {
16614
+ ref: null,
16615
+ artifactId
16616
+ };
15901
16617
  }
15902
16618
  return {
15903
- url,
15904
- mime: rendered.mime,
15905
- 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
15906
16625
  };
15907
16626
  } catch (err) {
15908
16627
  ports.logger.warn("timelapse preview not published: publish threw", {
15909
16628
  tags: input.tags,
15910
16629
  meta: { error: String(err) }
15911
16630
  });
15912
- return null;
16631
+ return NO_PREVIEW;
15913
16632
  }
15914
16633
  }
15915
16634
  /** Title size as a fraction of the frame WIDTH — so it reads the same on a
@@ -16396,6 +17115,10 @@ function startAlarmPanelConvergence(ports) {
16396
17115
  * `data-plane-access.spec.ts` asserts they still match the constants their
16397
17116
  * handlers register with — a mirror nothing checks is a lie waiting to happen.
16398
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;
16399
17122
  var TRAINING_EXPORT_PREFIX_REF = "training-export";
16400
17123
  var RETRAIN_EXPORT_PREFIX_REF = "retrain-export";
16401
17124
  var DEBUG_EXPORT_PREFIX_REF = "debug-export";
@@ -16411,6 +17134,14 @@ var PIPELINE_ANALYTICS_PLANE_ACCESS = {
16411
17134
  "nc-action": "public",
16412
17135
  /** Self-authenticating single-use artifact token (D47). */
16413
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",
16414
17145
  /** Every frame behind a marked selection — a corpus, not an item. */
16415
17146
  [TRAINING_EXPORT_PREFIX_REF]: "admin",
16416
17147
  [RETRAIN_EXPORT_PREFIX_REF]: "admin",
@@ -41353,6 +42084,15 @@ var REBUILD_FALLBACK_NODE_ID = "hub";
41353
42084
  */
41354
42085
  var TIMELAPSE_TRACK_QUERY_LIMIT = 2e3;
41355
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
+ };
41356
42096
  /**
41357
42097
  * The FULL action catalog for this addon.
41358
42098
  *
@@ -41634,6 +42374,12 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
41634
42374
  * data-plane handle. Null until served / when the facility is absent. */
41635
42375
  ncArtifactPlane = null;
41636
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;
41637
42383
  /**
41638
42384
  * Roadmap 4b: ONE cut of an event, in every container the rule asked for.
41639
42385
  *
@@ -41820,6 +42566,31 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
41820
42566
  * Best-effort at every step: no facility, no secret or no reachable base URL
41821
42567
  * ⇒ no plane, and the dispatcher keeps shipping bytes exactly as before.
41822
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
+ }
41823
42594
  async serveNcArtifactPlane() {
41824
42595
  try {
41825
42596
  const secretState = this.state("ncArtifactSecret", NcArtifactSecretSchema, "");
@@ -41833,9 +42604,18 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
41833
42604
  logger: this.ctx.logger.child("nc-artifacts")
41834
42605
  });
41835
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;
41836
42615
  const plane = new NcArtifactPlane({
41837
42616
  store,
41838
42617
  secret,
42618
+ index,
41839
42619
  logger: this.ctx.logger.child("nc-artifacts"),
41840
42620
  routePrefix: `/addon/${this.ctx.id}/nc-artifact`,
41841
42621
  listEndpoints: () => this.ncEndpoints()
@@ -41846,7 +42626,23 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
41846
42626
  handler: plane.handler
41847
42627
  }) ?? null;
41848
42628
  this.ncArtifactPlane = this.ncArtifactDataPlane !== null ? plane : null;
41849
- 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
+ } });
41850
42646
  } catch (err) {
41851
42647
  this.ctx.logger.warn("nc-artifact data-plane failed to serve", { meta: { error: require_dist.errMsg(err) } });
41852
42648
  }
@@ -42294,7 +43090,8 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
42294
43090
  store: center.summaryStore,
42295
43091
  runNow: (input) => center.runSummaryNow(input)
42296
43092
  },
42297
- texts: center.textCatalogEditor
43093
+ texts: center.textCatalogEditor,
43094
+ ...this.ncArtifactIndex !== null ? { artifacts: this.ncArtifactIndex } : {}
42298
43095
  });
42299
43096
  }
42300
43097
  /** Declare typed collections up-front so the first insert doesn't race with
@@ -43083,7 +43880,7 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
43083
43880
  timestamp,
43084
43881
  data: jpeg
43085
43882
  }),
43086
- 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
43087
43884
  };
43088
43885
  }
43089
43886
  /**
@@ -43156,7 +43953,7 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
43156
43953
  publishArtifact: buildTimelapseArtifactPublisher({
43157
43954
  logger: this.ctx.logger.child("Timelapse"),
43158
43955
  readExportBytes: ({ exportId }) => api.recordingExport.readExportBytes.query({ exportId }),
43159
- 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,
43160
43957
  renderPreview: async ({ mode, ...input }) => {
43161
43958
  const previewPorts = {
43162
43959
  logger: this.ctx.logger.child("Timelapse"),
@@ -43823,6 +44620,14 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
43823
44620
  this.retrainExportDataPlane = null;
43824
44621
  await this.debugExportDataPlane?.dispose();
43825
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;
43826
44631
  }
43827
44632
  async handleInferenceResult(data) {
43828
44633
  if (this.shuttingDown) return;