@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.
@@ -1,4 +1,4 @@
1
- import { $ as readDeviceStateFrom, A as TimelapseRuleInputSchema, B as customAction, C as NcSnoozeSchema, Ct as unknown, D as RECORDING_EXPORT_MAX_READ_BYTES, E as OpsLogEntrySchema, F as alarmPanelCapability, G as faceGalleryCapability, H as deriveRecordingMode, I as assertTimelapseCadences, J as isScheduleActive, L as audioMetricsCapability, M as TimelapseRuleSchema, N as TrackSourceSchema, O as RetrainStatusSchema, P as addonWidgetsSourceCapability, Q as plateGalleryCapability, R as buildEventKindDescriptor, S as NcSnoozeInputSchema, St as string, T as NcTaxonomySchema, V as defineCustomActions, W as encodeVectorBase64, X as notificationRulesCapability, Y as kebabToCamel, Z as pipelineAnalyticsCapability, _ as NcRuleInputSchema, _t as literal, at as errMsg, b as NcRuleTargetSchema, bt as partialRecord, c as EVENT_PAD_MS, ct as createEvent, d as NC_ALARM_SYSTEM_EVENT_KINDS, dt as nodePin, et as readTimelapseGeneratedAt, f as NC_CONDITION_CATALOG, ft as sleep, g as NcConditionDescriptorSchema, gt as discriminatedUnion, h as NC_TAXONOMY, ht as boolean, i as DETECTION_MACRO_CLASSES, it as zoneAnalyticsCapability, j as TimelapseRulePatchSchema, k as TIMELAPSE_DENSE_FLOOR_SEC, l as LabelAttributionSchema, lt as hydrateSchema, mt as array, n as DEFAULT_EVENT_COLOR, nt as vectorDimFromBase64, o as DeclaredDevices, ot as BaseAddon, p as NC_DEFAULT_SNOOZE_MINUTES, pt as _enum, q as isDetectionMacroClass, rt as videoclipsCapability, s as EVENT_KIND_BY_CAP, st as DeviceType, t as BaseDevice, tt as subKindsOf, u as MACRO_LABELS, ut as isDeviceScopedCap, v as NcRulePatchSchema, vt as number, w as NcSnoozeSuppressedSchema, wt as EventCategory, x as NcScheduleSchema, xt as record, y as NcRuleSchema, yt as object, z as cosineSimilarity } from "../dist-B--dE3Ry.mjs";
1
+ import { $ as readDeviceStateFrom, A as TimelapseRuleInputSchema, B as customAction, C as NcSnoozeSchema, Ct as unknown, D as RECORDING_EXPORT_MAX_READ_BYTES, E as OpsLogEntrySchema, F as alarmPanelCapability, G as faceGalleryCapability, H as deriveRecordingMode, I as assertTimelapseCadences, J as isScheduleActive, L as audioMetricsCapability, M as TimelapseRuleSchema, N as TrackSourceSchema, O as RetrainStatusSchema, P as addonWidgetsSourceCapability, Q as plateGalleryCapability, R as buildEventKindDescriptor, S as NcSnoozeInputSchema, St as string, T as NcTaxonomySchema, V as defineCustomActions, W as encodeVectorBase64, X as notificationRulesCapability, Y as kebabToCamel, Z as pipelineAnalyticsCapability, _ as NcRuleInputSchema, _t as literal, at as errMsg, b as NcRuleTargetSchema, bt as partialRecord, c as EVENT_PAD_MS, ct as createEvent, d as NC_ALARM_SYSTEM_EVENT_KINDS, dt as nodePin, et as readTimelapseGeneratedAt, f as NC_CONDITION_CATALOG, ft as sleep, g as NcConditionDescriptorSchema, gt as discriminatedUnion, h as NC_TAXONOMY, ht as boolean, i as DETECTION_MACRO_CLASSES, it as zoneAnalyticsCapability, j as TimelapseRulePatchSchema, k as TIMELAPSE_DENSE_FLOOR_SEC, l as LabelAttributionSchema, lt as hydrateSchema, mt as array, n as DEFAULT_EVENT_COLOR, nt as vectorDimFromBase64, o as DeclaredDevices, ot as BaseAddon, p as NC_DEFAULT_SNOOZE_MINUTES, pt as _enum, q as isDetectionMacroClass, rt as videoclipsCapability, s as EVENT_KIND_BY_CAP, st as DeviceType, t as BaseDevice, tt as subKindsOf, u as MACRO_LABELS, ut as isDeviceScopedCap, v as NcRulePatchSchema, vt as number, w as NcSnoozeSuppressedSchema, wt as EventCategory, x as NcScheduleSchema, xt as record, y as NcRuleSchema, yt as object, z as cosineSimilarity } from "../dist-6te1200B.mjs";
2
2
  import { t as __exportAll } from "../embedding-encoder/index.mjs";
3
3
  import * as fs from "node:fs";
4
4
  import { promises } from "node:fs";
@@ -1573,6 +1573,401 @@ var NC_ALARM_DECLARATION = {
1573
1573
  DeviceClass: NcAlarmPanelDevice
1574
1574
  };
1575
1575
  //#endregion
1576
+ //#region src/notification-center/artifact-index.ts
1577
+ var NC_ARTIFACTS_COLLECTION = "notification-center:artifacts";
1578
+ var NcArtifactKindSchema = _enum([
1579
+ "timelapse",
1580
+ "timelapse-preview",
1581
+ "summary-mosaic"
1582
+ ]);
1583
+ /**
1584
+ * How the viewer should DRAW it. Derived from the mime once, here, so no client
1585
+ * has to carry a mime table — and so a GIF that degraded to a JPEG is drawn as
1586
+ * the JPEG it actually is.
1587
+ */
1588
+ var NcArtifactMediaTypeSchema = _enum([
1589
+ "image",
1590
+ "gif",
1591
+ "video"
1592
+ ]);
1593
+ /** The relative hub path of the authenticated bytes route for an artefact id. */
1594
+ var NC_ARTIFACT_READ_PREFIX = "nc-artifact-read";
1595
+ var NcArtifactRecordSchema = object({
1596
+ /** The `NcArtifactStore` id — the same id the bytes route resolves. */
1597
+ id: string().min(1),
1598
+ kind: NcArtifactKindSchema,
1599
+ mediaType: NcArtifactMediaTypeSchema,
1600
+ mime: string().min(1),
1601
+ sizeBytes: number().int().nonnegative(),
1602
+ /** When the artefact was PRODUCED. */
1603
+ createdAt: number().int(),
1604
+ /** What the content is ABOUT (window end / event time). The sort key: a
1605
+ * timelapse rendered at 06:05 is about the night before, and a list ordered
1606
+ * by production time puts it in the wrong morning. */
1607
+ capturedAt: number().int(),
1608
+ /** After this the bytes are gone. Derived from the retained shelf's own age
1609
+ * bound — never a second retention policy. */
1610
+ expiresAt: number().int(),
1611
+ /** The camera the artefact is FILED under. Null only if it belongs to none. */
1612
+ deviceId: number().int().nullable(),
1613
+ /** Every camera the artefact covers, for display. */
1614
+ deviceIds: array(number().int()),
1615
+ ruleId: string().optional(),
1616
+ ruleName: string().optional(),
1617
+ title: string().optional(),
1618
+ /** A companion still for a video artefact (a timelapse's preview frame) —
1619
+ * what a list renders as the thumbnail. */
1620
+ posterArtifactId: string().optional(),
1621
+ /** RELATIVE hub path of the authenticated bytes route. Relative on purpose:
1622
+ * the viewer already holds the hub origin it is authenticated against, and
1623
+ * only the SIGNED plane needs to guess which host a stranger can reach. */
1624
+ downloadPath: string().min(1)
1625
+ });
1626
+ var NcArtifactQuerySchema = object({
1627
+ kinds: array(NcArtifactKindSchema).optional(),
1628
+ deviceIds: array(number().int()).optional(),
1629
+ ruleId: string().optional(),
1630
+ /** Inclusive `capturedAt` window. */
1631
+ fromMs: number().int().optional(),
1632
+ toMs: number().int().optional(),
1633
+ limit: number().int().min(1).max(200).default(50),
1634
+ offset: number().int().min(0).default(0)
1635
+ });
1636
+ var NC_ARTIFACTS_COLUMNS = [
1637
+ {
1638
+ name: "id",
1639
+ type: "TEXT",
1640
+ primaryKey: true,
1641
+ notNull: true
1642
+ },
1643
+ {
1644
+ name: "kind",
1645
+ type: "TEXT",
1646
+ notNull: true
1647
+ },
1648
+ {
1649
+ name: "deviceId",
1650
+ type: "INTEGER"
1651
+ },
1652
+ {
1653
+ name: "ruleId",
1654
+ type: "TEXT"
1655
+ },
1656
+ (
1657
+ /** `''` for a global artefact — see the header note on visibility. */
1658
+ {
1659
+ name: "ownerUserId",
1660
+ type: "TEXT",
1661
+ notNull: true
1662
+ }),
1663
+ {
1664
+ name: "capturedAt",
1665
+ type: "INTEGER",
1666
+ notNull: true
1667
+ },
1668
+ {
1669
+ name: "expiresAt",
1670
+ type: "INTEGER",
1671
+ notNull: true
1672
+ },
1673
+ (
1674
+ /** The FULL record (Zod-validated on read) — the scalars above are indexed
1675
+ * projections only. */
1676
+ {
1677
+ name: "record",
1678
+ type: "JSON",
1679
+ notNull: true
1680
+ })
1681
+ ];
1682
+ var NC_ARTIFACTS_INDEXES = [
1683
+ {
1684
+ name: "idx_nc_artifacts_captured",
1685
+ columns: ["capturedAt"]
1686
+ },
1687
+ {
1688
+ name: "idx_nc_artifacts_device",
1689
+ columns: ["deviceId", "capturedAt"]
1690
+ },
1691
+ {
1692
+ name: "idx_nc_artifacts_kind",
1693
+ columns: ["kind", "capturedAt"]
1694
+ },
1695
+ {
1696
+ name: "idx_nc_artifacts_expires",
1697
+ columns: ["expiresAt"]
1698
+ }
1699
+ ];
1700
+ /** Upper bound for a half-open `whereBetween` — the store has no `>=`. */
1701
+ var TIME_MAX = 864e13;
1702
+ var TIME_MIN = -864e13;
1703
+ /** Prune scan ceiling — the index is bounded by the shelf it mirrors (2000
1704
+ * entries by default), so this only has to be comfortably above it. */
1705
+ var NC_ARTIFACT_PRUNE_SCAN_LIMIT = 2e4;
1706
+ /** `image/gif` → `gif`. Keyed on what the bytes ARE, never on what was asked
1707
+ * for: a GIF that degraded to a still must be drawn as a still. */
1708
+ function mediaTypeForMime(mime) {
1709
+ if (mime === "image/gif") return "gif";
1710
+ if (mime.startsWith("video/")) return "video";
1711
+ return "image";
1712
+ }
1713
+ /** The relative hub path an authenticated client fetches the bytes from. */
1714
+ function artifactDownloadPath(addonId, artifactId) {
1715
+ return `/addon/${addonId}/${NC_ARTIFACT_READ_PREFIX}/${artifactId}`;
1716
+ }
1717
+ var NcArtifactIndex = class {
1718
+ store;
1719
+ logger;
1720
+ addonId;
1721
+ retentionMs;
1722
+ now;
1723
+ constructor(deps) {
1724
+ this.store = deps.store;
1725
+ this.logger = deps.logger;
1726
+ this.addonId = deps.addonId;
1727
+ this.retentionMs = deps.retentionMs;
1728
+ this.now = deps.now ?? (() => Date.now());
1729
+ }
1730
+ static async declare(store) {
1731
+ await store.declareCollection.mutate({
1732
+ collection: NC_ARTIFACTS_COLLECTION,
1733
+ columns: [...NC_ARTIFACTS_COLUMNS],
1734
+ indexes: [...NC_ARTIFACTS_INDEXES]
1735
+ });
1736
+ }
1737
+ /**
1738
+ * Record one retained artefact. Returns the row as the viewer will see it, or
1739
+ * null when the write failed — and a failure is LOGGED, per camera: an
1740
+ * artefact silently absent from the index reads to an operator as an artefact
1741
+ * that was never produced.
1742
+ */
1743
+ async record(input) {
1744
+ const createdAt = this.now();
1745
+ const record = NcArtifactRecordSchema.parse({
1746
+ id: input.id,
1747
+ kind: input.kind,
1748
+ mediaType: mediaTypeForMime(input.mime),
1749
+ mime: input.mime,
1750
+ sizeBytes: input.sizeBytes,
1751
+ createdAt,
1752
+ capturedAt: input.capturedAt,
1753
+ expiresAt: createdAt + this.retentionMs,
1754
+ deviceId: input.deviceId,
1755
+ deviceIds: [...input.deviceIds],
1756
+ ...input.ruleId !== void 0 ? { ruleId: input.ruleId } : {},
1757
+ ...input.ruleName !== void 0 ? { ruleName: input.ruleName } : {},
1758
+ ...input.title !== void 0 ? { title: input.title } : {},
1759
+ ...input.posterArtifactId !== void 0 ? { posterArtifactId: input.posterArtifactId } : {},
1760
+ downloadPath: artifactDownloadPath(this.addonId, input.id)
1761
+ });
1762
+ try {
1763
+ await this.store.set.mutate({
1764
+ collection: NC_ARTIFACTS_COLLECTION,
1765
+ key: record.id,
1766
+ value: {
1767
+ kind: record.kind,
1768
+ deviceId: record.deviceId,
1769
+ ruleId: record.ruleId ?? "",
1770
+ ownerUserId: input.ownerUserId ?? "",
1771
+ capturedAt: record.capturedAt,
1772
+ expiresAt: record.expiresAt,
1773
+ record
1774
+ }
1775
+ });
1776
+ return record;
1777
+ } catch (err) {
1778
+ this.logger.warn("artifact not indexed — it will not appear in the viewer", {
1779
+ ...record.deviceId !== null ? { tags: { deviceId: record.deviceId } } : {},
1780
+ meta: {
1781
+ artifactId: record.id,
1782
+ kind: record.kind,
1783
+ error: String(err)
1784
+ }
1785
+ });
1786
+ return null;
1787
+ }
1788
+ }
1789
+ /**
1790
+ * One page, newest `capturedAt` first.
1791
+ *
1792
+ * Expiry is a PREDICATE, not a post-filter: a row whose bytes the shelf has
1793
+ * already swept must not consume a slot of the page the caller asked for.
1794
+ */
1795
+ async list(query, viewer) {
1796
+ const filter = this.buildFilter(query, viewer);
1797
+ try {
1798
+ const [rows, total] = await Promise.all([this.store.query.query({
1799
+ collection: NC_ARTIFACTS_COLLECTION,
1800
+ filter: {
1801
+ ...filter,
1802
+ orderBy: {
1803
+ field: "capturedAt",
1804
+ direction: "desc"
1805
+ },
1806
+ limit: query.limit,
1807
+ offset: query.offset
1808
+ }
1809
+ }), this.store.count.query({
1810
+ collection: NC_ARTIFACTS_COLLECTION,
1811
+ filter
1812
+ })]);
1813
+ const artifacts = this.parseRows(rows);
1814
+ return {
1815
+ artifacts,
1816
+ total,
1817
+ hasMore: query.offset + artifacts.length < total
1818
+ };
1819
+ } catch (err) {
1820
+ this.logger.warn("artifact index query failed", { meta: { error: String(err) } });
1821
+ return {
1822
+ artifacts: [],
1823
+ total: 0,
1824
+ hasMore: false
1825
+ };
1826
+ }
1827
+ }
1828
+ /**
1829
+ * One artefact by id, or null when it does not exist, has expired, or belongs
1830
+ * to another user. All three answer the same way ON PURPOSE — a surface that
1831
+ * distinguishes "not yours" from "not there" tells a caller what other people
1832
+ * own.
1833
+ */
1834
+ async get(artifactId, viewer) {
1835
+ const row = await this.readRow(artifactId);
1836
+ if (row === null) return null;
1837
+ if (row.record.expiresAt <= this.now()) return null;
1838
+ if (!viewer.isAdmin && row.ownerUserId !== "" && row.ownerUserId !== viewer.userId) return null;
1839
+ return row.record;
1840
+ }
1841
+ /**
1842
+ * Resolve an id for the BYTES route — visibility-free by design.
1843
+ *
1844
+ * The route is served `authenticated`, so the hub has already established a
1845
+ * principal; what this answers is only "is this id an indexed artefact, and is
1846
+ * it still meant to exist". Per-user artefact scoping on the byte route would
1847
+ * be a second authorization model layered on an unguessable id, and this repo
1848
+ * has one of those already (`data-plane-access.ts`): a plane serving ONE item
1849
+ * the caller had to be told about is `authenticated`.
1850
+ */
1851
+ async resolve(artifactId) {
1852
+ return (await this.readRow(artifactId))?.record ?? null;
1853
+ }
1854
+ /** One row by primary key, parsed. Null when absent or no longer valid. */
1855
+ async readRow(artifactId) {
1856
+ let raw;
1857
+ try {
1858
+ raw = await this.store.get.query({
1859
+ collection: NC_ARTIFACTS_COLLECTION,
1860
+ key: artifactId
1861
+ });
1862
+ } catch (err) {
1863
+ this.logger.warn("artifact index lookup failed", { meta: {
1864
+ artifactId,
1865
+ error: String(err)
1866
+ } });
1867
+ return null;
1868
+ }
1869
+ if (typeof raw !== "object" || raw === null || Array.isArray(raw)) return null;
1870
+ const row = { ...raw };
1871
+ const parsed = NcArtifactRecordSchema.safeParse(row["record"]);
1872
+ if (!parsed.success) return null;
1873
+ const owner = row["ownerUserId"];
1874
+ return {
1875
+ record: parsed.data,
1876
+ ownerUserId: typeof owner === "string" ? owner : ""
1877
+ };
1878
+ }
1879
+ /**
1880
+ * Drop rows whose bytes are gone, in ONE `deleteWhere` rather than an N+1
1881
+ * drain.
1882
+ *
1883
+ * TWO halves, and only one of them is safe everywhere:
1884
+ *
1885
+ * - **Expired rows** are time-based and node-independent. Always swept.
1886
+ * - **Orphans** — rows whose file a byte/count bound took before the age bound
1887
+ * did — are found by reconciling against the shelf. That reconcile is only
1888
+ * valid for a caller whose shelf holds the bytes for the WHOLE index, and
1889
+ * the index is CENTRAL while the shelf is local: a second node running this
1890
+ * with its own (empty) shelf would read every row as an orphan and delete
1891
+ * the entire library. Hence `keptIds: null` — a caller that is not the
1892
+ * producing node states so, and the destructive half becomes unreachable
1893
+ * rather than merely un-called.
1894
+ *
1895
+ * @param keptIds the ids the caller's shelf holds, or `null` when this node is
1896
+ * not the one that produces artefacts.
1897
+ */
1898
+ async prune(keptIds) {
1899
+ let removed = 0;
1900
+ try {
1901
+ const expired = await this.store.deleteWhere.mutate({
1902
+ collection: NC_ARTIFACTS_COLLECTION,
1903
+ filter: { whereBetween: { expiresAt: [TIME_MIN, this.now() - 1] } }
1904
+ });
1905
+ removed += expired.deleted;
1906
+ if (keptIds === null) {
1907
+ if (removed > 0) this.logger.debug("artifact index pruned (expired only)", { meta: { removed } });
1908
+ return removed;
1909
+ }
1910
+ const alive = new Set(keptIds);
1911
+ const orphans = (await this.store.query.query({
1912
+ collection: NC_ARTIFACTS_COLLECTION,
1913
+ filter: { limit: NC_ARTIFACT_PRUNE_SCAN_LIMIT }
1914
+ })).map((r) => r.id).filter((id) => !alive.has(id));
1915
+ for (const id of orphans) {
1916
+ await this.store.delete.mutate({
1917
+ collection: NC_ARTIFACTS_COLLECTION,
1918
+ key: id
1919
+ });
1920
+ removed += 1;
1921
+ }
1922
+ if (removed > 0) this.logger.debug("artifact index pruned", { meta: {
1923
+ removed,
1924
+ expired: expired.deleted,
1925
+ orphaned: orphans.length
1926
+ } });
1927
+ return removed;
1928
+ } catch (err) {
1929
+ this.logger.warn("artifact index prune failed", { meta: { error: String(err) } });
1930
+ return removed;
1931
+ }
1932
+ }
1933
+ /** The visibility predicate: a caller sees global artefacts plus his own; an
1934
+ * admin sees every one. */
1935
+ visibility(viewer) {
1936
+ if (viewer.isAdmin) return {};
1937
+ return { whereIn: { ownerUserId: ["", viewer.userId] } };
1938
+ }
1939
+ buildFilter(query, viewer) {
1940
+ const whereIn = { ...this.visibility(viewer).whereIn };
1941
+ if (query.kinds !== void 0 && query.kinds.length > 0) whereIn["kind"] = [...query.kinds];
1942
+ if (query.deviceIds !== void 0 && query.deviceIds.length > 0) whereIn["deviceId"] = [...query.deviceIds];
1943
+ const whereBetween = {
1944
+ capturedAt: [query.fromMs ?? TIME_MIN, query.toMs ?? TIME_MAX],
1945
+ expiresAt: [this.now(), TIME_MAX]
1946
+ };
1947
+ return {
1948
+ ...query.ruleId !== void 0 ? { where: { ruleId: query.ruleId } } : {},
1949
+ ...Object.keys(whereIn).length > 0 ? { whereIn } : {},
1950
+ whereBetween
1951
+ };
1952
+ }
1953
+ /** Parse the JSON column, skipping rows that no longer validate. A degraded
1954
+ * row must never cost the whole page. */
1955
+ parseRows(rows) {
1956
+ const out = [];
1957
+ let skipped = 0;
1958
+ for (const row of rows) {
1959
+ const parsed = NcArtifactRecordSchema.safeParse(row.data["record"]);
1960
+ if (!parsed.success) {
1961
+ skipped += 1;
1962
+ continue;
1963
+ }
1964
+ out.push(parsed.data);
1965
+ }
1966
+ if (skipped > 0) this.logger.warn("artifact index rows skipped as invalid", { meta: { skipped } });
1967
+ return out;
1968
+ }
1969
+ };
1970
+ //#endregion
1576
1971
  //#region ../types/dist/node.mjs
1577
1972
  var node_exports = /* @__PURE__ */ __exportAll({
1578
1973
  FilesystemStorageProvider: () => FilesystemStorageProvider,
@@ -1963,6 +2358,61 @@ var NcArtifactPlane = class {
1963
2358
  }
1964
2359
  }
1965
2360
  /**
2361
+ * Publish bytes AND file them in the viewer's browsable index (task #36).
2362
+ *
2363
+ * Two differences from {@link publish}, both deliberate:
2364
+ *
2365
+ * 1. **The bytes are stored FIRST, and stored even when no public base URL
2366
+ * exists.** `publish` returns early on a loopback-only install because a
2367
+ * signed link nobody can open is worth nothing — but the VIEWER reaches
2368
+ * the hub directly over its own session, so an install with no tunnel
2369
+ * still gets a full artifact library. Making the index depend on a
2370
+ * reachable public address would have made the feature absent exactly on
2371
+ * the installs that are LAN-only.
2372
+ * 2. **It answers the artefact id**, so a caller can link one artefact to
2373
+ * another — a timelapse's poster is its preview frame, and the video row
2374
+ * cannot name it without knowing the id the preview got.
2375
+ *
2376
+ * Never throws, for the same reason `publish` does not: an artefact library
2377
+ * is an enhancement and must never cost a notification.
2378
+ */
2379
+ async publishIndexed(bytes, mime, meta) {
2380
+ let artifactId = null;
2381
+ try {
2382
+ const index = this.deps.index;
2383
+ const stored = await this.deps.store.put(bytes, mime, { retain: index !== void 0 });
2384
+ if (index !== void 0) artifactId = (await index.record({
2385
+ ...meta,
2386
+ id: stored.id,
2387
+ mime,
2388
+ sizeBytes: stored.bytes
2389
+ }))?.id ?? null;
2390
+ const baseUrl = await this.resolveBaseUrl();
2391
+ return {
2392
+ url: baseUrl === null ? null : buildArtifactUrl({
2393
+ baseUrl,
2394
+ routePrefix: this.deps.routePrefix,
2395
+ id: stored.id,
2396
+ secret: this.deps.secret,
2397
+ expMs: this.now() + (this.deps.ttlMs ?? DEFAULT_TTL_MS)
2398
+ }),
2399
+ artifactId
2400
+ };
2401
+ } catch (err) {
2402
+ this.deps.logger.warn("indexed artifact publish failed — shipping bytes only", {
2403
+ ...meta.deviceId !== null ? { tags: { deviceId: meta.deviceId } } : {},
2404
+ meta: {
2405
+ kind: meta.kind,
2406
+ error: String(err)
2407
+ }
2408
+ });
2409
+ return {
2410
+ url: null,
2411
+ artifactId
2412
+ };
2413
+ }
2414
+ }
2415
+ /**
1966
2416
  * Absolutize a path against the SAME base URL an artifact link would use.
1967
2417
  *
1968
2418
  * For links this plane does not host but a RECIPIENT still has to open — a
@@ -2028,6 +2478,124 @@ var NcArtifactPlane = class {
2028
2478
  }
2029
2479
  };
2030
2480
  //#endregion
2481
+ //#region src/notification-center/artifact-read-plane.ts
2482
+ /**
2483
+ * Parse a single-range `Range` header against a known length.
2484
+ *
2485
+ * Returns `null` when there is nothing to honour (absent, malformed, multi-range
2486
+ * — all of which mean "send the whole thing"), and `'unsatisfiable'` when the
2487
+ * client asked for a range that cannot exist, which is a 416 and not a 200.
2488
+ */
2489
+ function parseByteRange(header, length) {
2490
+ if (header === void 0) return null;
2491
+ const match = /^bytes=(\d*)-(\d*)$/.exec(header.trim());
2492
+ if (match === null) return null;
2493
+ const rawStart = match[1] ?? "";
2494
+ const rawEnd = match[2] ?? "";
2495
+ if (rawStart === "" && rawEnd === "") return null;
2496
+ if (length === 0) return "unsatisfiable";
2497
+ if (rawStart === "") {
2498
+ const suffix = Number(rawEnd);
2499
+ if (suffix === 0) return "unsatisfiable";
2500
+ return {
2501
+ start: Math.max(0, length - suffix),
2502
+ end: length - 1
2503
+ };
2504
+ }
2505
+ const start = Number(rawStart);
2506
+ if (start >= length) return "unsatisfiable";
2507
+ const end = rawEnd === "" ? length - 1 : Math.min(Number(rawEnd), length - 1);
2508
+ if (end < start) return "unsatisfiable";
2509
+ return {
2510
+ start,
2511
+ end
2512
+ };
2513
+ }
2514
+ /** The last path segment, percent-decoded. */
2515
+ function artifactIdFrom(req) {
2516
+ const url = new URL(req.url ?? "/", "http://placeholder");
2517
+ try {
2518
+ return decodeURIComponent(url.pathname.split("/").filter(Boolean).pop() ?? "");
2519
+ } catch {
2520
+ return "";
2521
+ }
2522
+ }
2523
+ var NcArtifactReadPlane = class {
2524
+ deps;
2525
+ constructor(deps) {
2526
+ this.deps = deps;
2527
+ }
2528
+ handler = async (req, res) => {
2529
+ const artifactId = artifactIdFrom(req);
2530
+ const record = await this.deps.index.resolve(artifactId);
2531
+ if (record === null) {
2532
+ this.refuse(res, 404, "not found");
2533
+ return;
2534
+ }
2535
+ const found = await this.deps.store.read(artifactId);
2536
+ if (found === null) {
2537
+ this.deps.logger.warn("artifact bytes gone — index row outlived the shelf", {
2538
+ ...record.deviceId !== null ? { tags: { deviceId: record.deviceId } } : {},
2539
+ meta: {
2540
+ artifactId,
2541
+ kind: record.kind,
2542
+ expiresAt: record.expiresAt
2543
+ }
2544
+ });
2545
+ this.refuse(res, 404, "not found");
2546
+ return;
2547
+ }
2548
+ if (req.method === "HEAD") {
2549
+ res.writeHead(200, this.baseHeaders(record, found.bytes.byteLength, found.mime));
2550
+ res.end();
2551
+ return;
2552
+ }
2553
+ const range = parseByteRange(headerOf(req, "range"), found.bytes.byteLength);
2554
+ if (range === "unsatisfiable") {
2555
+ res.writeHead(416, {
2556
+ "content-range": `bytes */${found.bytes.byteLength}`,
2557
+ "content-type": "text/plain"
2558
+ });
2559
+ res.end("range not satisfiable");
2560
+ return;
2561
+ }
2562
+ if (range === null) {
2563
+ res.writeHead(200, this.baseHeaders(record, found.bytes.byteLength, found.mime));
2564
+ res.end(found.bytes);
2565
+ return;
2566
+ }
2567
+ const slice = found.bytes.subarray(range.start, range.end + 1);
2568
+ res.writeHead(206, {
2569
+ ...this.baseHeaders(record, slice.byteLength, found.mime),
2570
+ "content-range": `bytes ${range.start}-${range.end}/${found.bytes.byteLength}`
2571
+ });
2572
+ res.end(slice);
2573
+ };
2574
+ baseHeaders(record, length, mime) {
2575
+ return {
2576
+ "content-type": mime,
2577
+ "content-length": String(length),
2578
+ "accept-ranges": "bytes",
2579
+ "cache-control": "private, max-age=3600",
2580
+ "content-disposition": `inline; filename="${filenameFor(record)}"`
2581
+ };
2582
+ }
2583
+ refuse(res, status, body) {
2584
+ res.writeHead(status, { "content-type": "text/plain" });
2585
+ res.end(body);
2586
+ }
2587
+ };
2588
+ /** A name a client will accept, from what the artefact IS. */
2589
+ function filenameFor(record) {
2590
+ const ext = record.mime === "video/mp4" ? "mp4" : record.mime === "image/gif" ? "gif" : record.mime === "image/png" ? "png" : "jpg";
2591
+ return `${record.kind}-${record.capturedAt}.${ext}`;
2592
+ }
2593
+ function headerOf(req, name) {
2594
+ const value = req.headers[name];
2595
+ if (Array.isArray(value)) return value[0];
2596
+ return value;
2597
+ }
2598
+ //#endregion
2031
2599
  //#region src/notification-center/artifact-store.ts
2032
2600
  /**
2033
2601
  * NcArtifactStore — the bytes behind a signed notification-artifact URL.
@@ -2042,9 +2610,25 @@ var NcArtifactPlane = class {
2042
2610
  * bites first, the oldest artifacts go. A sweep on boot clears whatever a crash
2043
2611
  * left behind — the directory is disposable by construction, so a lost file is
2044
2612
  * a 404 on one link, never a corrupt state.
2613
+ *
2614
+ * ## The retained shelf (`kept/`)
2615
+ *
2616
+ * Task #36 gave the viewer a browsable artifact index, and an index whose bytes
2617
+ * evaporate in six hours is an index of 404s. So a `put` may ask to be RETAINED:
2618
+ * the bytes then land on a second shelf inside the same directory with its OWN
2619
+ * three bounds (age, count, and — because a timelapse mp4 is megabytes — total
2620
+ * BYTES). It is still a scratch, just a longer one: the index row carries an
2621
+ * `expiresAt` derived from the same age bound, so "gone" is a state the viewer
2622
+ * is told about rather than a surprise 404.
2623
+ *
2624
+ * The shelf is a subdirectory ON PURPOSE. The scratch's `list()` skips
2625
+ * non-files, so the two shelves cannot sweep each other, and neither bound had
2626
+ * to learn about the other's entries.
2045
2627
  */
2046
2628
  var DEFAULT_MAX_AGE_MS = 6 * 36e5;
2047
2629
  var DEFAULT_MAX_ENTRIES = 500;
2630
+ /** The retained shelf's subdirectory name, inside the scratch directory. */
2631
+ var KEPT_SUBDIR = "kept";
2048
2632
  /** `image/jpeg` → `jpg`, so a fetched link has a name a client will accept. */
2049
2633
  function extensionFor(mime) {
2050
2634
  if (mime === "image/jpeg") return "jpg";
@@ -2058,6 +2642,10 @@ var NcArtifactStore = class {
2058
2642
  now;
2059
2643
  maxAgeMs;
2060
2644
  maxEntries;
2645
+ keptMaxAgeMs;
2646
+ keptMaxEntries;
2647
+ keptMaxBytes;
2648
+ keptDir;
2061
2649
  /** id → mime, so the route can answer with the right content-type without
2062
2650
  * re-deriving it from the extension on every request. */
2063
2651
  mimeById = /* @__PURE__ */ new Map();
@@ -2066,17 +2654,32 @@ var NcArtifactStore = class {
2066
2654
  this.now = options.now ?? (() => Date.now());
2067
2655
  this.maxAgeMs = options.maxAgeMs ?? DEFAULT_MAX_AGE_MS;
2068
2656
  this.maxEntries = options.maxEntries ?? DEFAULT_MAX_ENTRIES;
2657
+ this.keptMaxAgeMs = options.keptMaxAgeMs ?? 12096e5;
2658
+ this.keptMaxEntries = options.keptMaxEntries ?? 2e3;
2659
+ this.keptMaxBytes = options.keptMaxBytes ?? 2147483648;
2660
+ this.keptDir = path.join(options.dir, KEPT_SUBDIR);
2661
+ }
2662
+ /** How long a retained artifact stays fetchable — the index's `expiresAt`
2663
+ * is derived from this, so the two can never disagree. */
2664
+ get retentionMs() {
2665
+ return this.keptMaxAgeMs;
2069
2666
  }
2070
2667
  /** Create the directory and sweep whatever a previous run left behind. */
2071
2668
  async start() {
2072
2669
  await promises.mkdir(this.options.dir, { recursive: true });
2073
2670
  await this.sweep();
2074
2671
  }
2075
- /** Persist bytes and return the artifact handle (never throws on sweep). */
2076
- async put(bytes, mime) {
2672
+ /**
2673
+ * Persist bytes and return the artifact handle (never throws on sweep).
2674
+ *
2675
+ * The retained shelf is created LAZILY: an install that never produces a
2676
+ * browsable artifact keeps a directory that is empty when it is empty.
2677
+ */
2678
+ async put(bytes, mime, options = {}) {
2077
2679
  const id = `${this.now()}-${randomUUID()}`;
2078
- const file = path.join(this.options.dir, `${id}.${extensionFor(mime)}`);
2079
- await promises.mkdir(this.options.dir, { recursive: true });
2680
+ const dir = options.retain === true ? this.keptDir : this.options.dir;
2681
+ const file = path.join(dir, `${id}.${extensionFor(mime)}`);
2682
+ await promises.mkdir(dir, { recursive: true });
2080
2683
  await promises.writeFile(file, bytes);
2081
2684
  this.mimeById.set(id, mime);
2082
2685
  await this.sweep();
@@ -2089,7 +2692,7 @@ var NcArtifactStore = class {
2089
2692
  /** Read an artifact's bytes + mime, or null when it is gone (expired/swept). */
2090
2693
  async read(id) {
2091
2694
  if (!/^[0-9]+-[0-9a-f-]{36}$/i.test(id)) return null;
2092
- const found = (await this.list()).find((e) => e.id === id);
2695
+ const found = [...await this.list(this.options.dir), ...await this.list(this.keptDir)].find((e) => e.id === id);
2093
2696
  if (!found) return null;
2094
2697
  try {
2095
2698
  return {
@@ -2100,6 +2703,10 @@ var NcArtifactStore = class {
2100
2703
  return null;
2101
2704
  }
2102
2705
  }
2706
+ /** The ids still on the retained shelf — what the index reconciles against. */
2707
+ async keptIds() {
2708
+ return (await this.list(this.keptDir)).map((e) => e.id);
2709
+ }
2103
2710
  /** Delete every artifact (operator cleanup / shutdown). */
2104
2711
  async clear() {
2105
2712
  await promises.rm(this.options.dir, {
@@ -2108,13 +2715,39 @@ var NcArtifactStore = class {
2108
2715
  }).catch(() => {});
2109
2716
  this.mimeById.clear();
2110
2717
  }
2111
- /** Age- then count-bounded prune. Best-effort: a failed unlink is logged once. */
2718
+ /** Prune both shelves under their own bounds. Best-effort: a failed unlink is
2719
+ * logged once and the rest of the sweep still runs. */
2112
2720
  async sweep() {
2113
- const entries = await this.list();
2114
- const cutoff = this.now() - this.maxAgeMs;
2721
+ await this.sweepShelf(await this.list(this.options.dir), {
2722
+ maxAgeMs: this.maxAgeMs,
2723
+ maxEntries: this.maxEntries
2724
+ });
2725
+ await this.sweepShelf(await this.list(this.keptDir), {
2726
+ maxAgeMs: this.keptMaxAgeMs,
2727
+ maxEntries: this.keptMaxEntries,
2728
+ maxBytes: this.keptMaxBytes
2729
+ });
2730
+ }
2731
+ /**
2732
+ * Age first, then count, then total bytes — newest always survives.
2733
+ *
2734
+ * The newest entry is never swept by the byte bound even when it alone
2735
+ * exceeds it: a shelf that deletes what it has just written can serve
2736
+ * nothing, and the caller has already paid for the render.
2737
+ */
2738
+ async sweepShelf(entries, bounds) {
2739
+ const cutoff = this.now() - bounds.maxAgeMs;
2115
2740
  const doomed = entries.filter((e) => e.mtimeMs < cutoff);
2116
- const survivors = entries.filter((e) => e.mtimeMs >= cutoff).toSorted((a, b) => b.mtimeMs - a.mtimeMs);
2117
- doomed.push(...survivors.slice(this.maxEntries));
2741
+ const fresh = entries.filter((e) => e.mtimeMs >= cutoff).toSorted((a, b) => b.mtimeMs - a.mtimeMs);
2742
+ doomed.push(...fresh.slice(bounds.maxEntries));
2743
+ const maxBytes = bounds.maxBytes;
2744
+ if (maxBytes !== void 0) {
2745
+ let running = 0;
2746
+ for (const [i, e] of fresh.slice(0, bounds.maxEntries).entries()) {
2747
+ running += e.sizeBytes;
2748
+ if (running > maxBytes && i > 0) doomed.push(e);
2749
+ }
2750
+ }
2118
2751
  for (const e of doomed) try {
2119
2752
  await promises.rm(e.file, { force: true });
2120
2753
  this.mimeById.delete(e.id);
@@ -2125,23 +2758,24 @@ var NcArtifactStore = class {
2125
2758
  } });
2126
2759
  }
2127
2760
  }
2128
- async list() {
2761
+ async list(dir) {
2129
2762
  let names;
2130
2763
  try {
2131
- names = await promises.readdir(this.options.dir);
2764
+ names = await promises.readdir(dir);
2132
2765
  } catch {
2133
2766
  return [];
2134
2767
  }
2135
2768
  const out = [];
2136
2769
  for (const name of names) {
2137
- const file = path.join(this.options.dir, name);
2770
+ const file = path.join(dir, name);
2138
2771
  try {
2139
2772
  const st = await promises.stat(file);
2140
2773
  if (!st.isFile()) continue;
2141
2774
  out.push({
2142
2775
  id: name.slice(0, name.lastIndexOf(".")),
2143
2776
  file,
2144
- mtimeMs: st.mtimeMs
2777
+ mtimeMs: st.mtimeMs,
2778
+ sizeBytes: st.size
2145
2779
  });
2146
2780
  } catch {}
2147
2781
  }
@@ -10005,7 +10639,15 @@ var NcSummaryProducer = class {
10005
10639
  }
10006
10640
  const bytes = new Uint8Array(rendered.jpeg.byteLength);
10007
10641
  bytes.set(rendered.jpeg);
10008
- const url = await this.deps.publish(bytes, "image/jpeg");
10642
+ const { url } = await this.deps.publish(bytes, "image/jpeg", {
10643
+ kind: "summary-mosaic",
10644
+ deviceId: rule.deviceIds[0] ?? null,
10645
+ deviceIds: [...new Set(tiles.map((t) => t.candidate.deviceId))],
10646
+ capturedAt: window.endMs,
10647
+ ruleId: rule.id,
10648
+ ruleName: rule.name,
10649
+ ...rule.ownerUserId !== void 0 ? { ownerUserId: rule.ownerUserId } : {}
10650
+ });
10009
10651
  if (url === null) {
10010
10652
  this.deps.logger.warn("summary mosaic not published: no reachable public endpoint", { meta: { ruleId: rule.id } });
10011
10653
  return {
@@ -10999,7 +11641,7 @@ function unknownNcTextKeys(keys, bundles = NC_TEXT_BUNDLES) {
10999
11641
  * durable document stays the sparse map its schema describes.
11000
11642
  */
11001
11643
  function applyNcTextOverridePatch(current, language, patch) {
11002
- const next = { ...current[language] ?? {} };
11644
+ const next = { ...current[language] };
11003
11645
  for (const [key, value] of Object.entries(patch)) {
11004
11646
  if (value === null || value.trim().length === 0) {
11005
11647
  delete next[key];
@@ -11567,7 +12209,7 @@ var TimelapseRenderer = class {
11567
12209
  }
11568
12210
  });
11569
12211
  const finished = await this.awaitReady(queued, tags);
11570
- const published = await this.publish(finished.id, deviceId, tags, request.previewText, request.previewMode ?? "image");
12212
+ const published = await this.publish(finished.id, request, tags);
11571
12213
  const downloadUrl = await this.ports.resolveDownloadUrl?.({ exportId: finished.id }).catch(() => null);
11572
12214
  return {
11573
12215
  exportId: finished.id,
@@ -11619,7 +12261,9 @@ var TimelapseRenderer = class {
11619
12261
  * notification, not a failed window — and failing here would leave the pair
11620
12262
  * unstamped and re-render the same eight hours on the next tick.
11621
12263
  */
11622
- async publish(exportId, deviceId, tags, previewText, previewMode) {
12264
+ async publish(exportId, request, tags) {
12265
+ const { deviceId, previewText } = request;
12266
+ const previewMode = request.previewMode ?? "image";
11623
12267
  const publish = this.ports.publishArtifact;
11624
12268
  const none = {
11625
12269
  video: null,
@@ -11639,7 +12283,11 @@ var TimelapseRenderer = class {
11639
12283
  ...previewText !== void 0 ? {
11640
12284
  previewText,
11641
12285
  previewMode
11642
- } : {}
12286
+ } : {},
12287
+ capturedAt: request.window.endMs,
12288
+ ruleId: request.ruleId,
12289
+ ruleName: request.ruleName,
12290
+ ...request.ownerUserId !== void 0 ? { ownerUserId: request.ownerUserId } : {}
11643
12291
  });
11644
12292
  if (set.video === null) this.ports.logger.warn("timelapse artifact could not be published — link-less delivery", {
11645
12293
  tags,
@@ -11995,7 +12643,8 @@ var TimelapseScheduler = class {
11995
12643
  ...rule.previewText !== void 0 ? { text: rule.previewText } : {}
11996
12644
  }),
11997
12645
  previewMode: rule.previewMode,
11998
- ...rule.reportClasses !== void 0 ? { reportClasses: rule.reportClasses } : {}
12646
+ ...rule.reportClasses !== void 0 ? { reportClasses: rule.reportClasses } : {},
12647
+ ...rule.ownerUserId !== void 0 ? { ownerUserId: rule.ownerUserId } : {}
11999
12648
  });
12000
12649
  const rows = buildTimelapseOutboxInputs({
12001
12650
  texts: this.texts,
@@ -13311,6 +13960,7 @@ var NotificationCenter = class NotificationCenter {
13311
13960
  await TimelapseStore.declare(store);
13312
13961
  await NcSummaryStore.declare(store);
13313
13962
  await NcTextCatalogStore.declare(store);
13963
+ await NcArtifactIndex.declare(store);
13314
13964
  }
13315
13965
  /**
13316
13966
  * Load rules (every node — the cap provider serves CRUD from any node).
@@ -15368,7 +16018,23 @@ var ncActions = defineCustomActions({
15368
16018
  }), NcTextCatalogViewSchema, {
15369
16019
  kind: "mutation",
15370
16020
  auth: "admin"
15371
- })
16021
+ }),
16022
+ /**
16023
+ * One page of artefacts, newest `capturedAt` first.
16024
+ *
16025
+ * `deviceIds` filters on the camera an artefact is FILED under. A digest
16026
+ * spans cameras and is filed under its rule's first one (the same camera its
16027
+ * outbox row uses); to ask "everything this digest produced" use `ruleId`.
16028
+ */
16029
+ "nc.listArtifacts": customAction(NcArtifactQuerySchema, object({
16030
+ artifacts: array(NcArtifactRecordSchema),
16031
+ /** Rows matching the filter, not rows on this page. */
16032
+ total: number().int().nonnegative(),
16033
+ hasMore: boolean()
16034
+ }), { caller: "required" }),
16035
+ /** One artefact's metadata. `null` for unknown, expired AND not-yours alike —
16036
+ * a surface that distinguishes them reports what other people own. */
16037
+ "nc.getArtifact": customAction(object({ artifactId: string().min(1) }), object({ artifact: NcArtifactRecordSchema.nullable() }), { caller: "required" })
15372
16038
  });
15373
16039
  /** Fail-closed caller resolution — an absent forwarded caller is NEVER admin. */
15374
16040
  function requireCaller(caller) {
@@ -15418,6 +16084,12 @@ function makeNcActionHandlers(deps) {
15418
16084
  if (sum === void 0) throw new Error("this node does not host the summary producer — use the hub");
15419
16085
  return sum;
15420
16086
  };
16087
+ /** The artefact index, or an honest refusal. */
16088
+ const requireArtifacts = () => {
16089
+ const index = deps.artifacts;
16090
+ if (index === void 0) throw new Error("this node does not host the notification artifact index — use the hub");
16091
+ return index;
16092
+ };
15421
16093
  /** The text catalog editor, or an honest refusal. */
15422
16094
  const requireTexts = () => {
15423
16095
  const editor = deps.texts;
@@ -15729,7 +16401,26 @@ function makeNcActionHandlers(deps) {
15729
16401
  return input.language === void 0 ? texts.view() : texts.view(input.language);
15730
16402
  },
15731
16403
  "nc.setLanguage": async (input) => requireTexts().setLanguage(input.language),
15732
- "nc.setTextOverrides": async (input) => requireTexts().setOverrides(input.language, input.overrides)
16404
+ "nc.setTextOverrides": async (input) => requireTexts().setOverrides(input.language, input.overrides),
16405
+ "nc.listArtifacts": async (input, caller) => {
16406
+ const c = requireCaller(caller);
16407
+ const page = await requireArtifacts().list(input, {
16408
+ userId: c.userId,
16409
+ isAdmin: c.isAdmin
16410
+ });
16411
+ return {
16412
+ artifacts: [...page.artifacts],
16413
+ total: page.total,
16414
+ hasMore: page.hasMore
16415
+ };
16416
+ },
16417
+ "nc.getArtifact": async (input, caller) => {
16418
+ const c = requireCaller(caller);
16419
+ return { artifact: await requireArtifacts().get(input.artifactId, {
16420
+ userId: c.userId,
16421
+ isAdmin: c.isAdmin
16422
+ }) };
16423
+ }
15733
16424
  };
15734
16425
  }
15735
16426
  //#endregion
@@ -15783,7 +16474,8 @@ var PREVIEW_FALLBACK_NAME = "timelapse-preview.jpg";
15783
16474
  * member has a logged reason. It never rejects.
15784
16475
  */
15785
16476
  function buildTimelapseArtifactPublisher(ports) {
15786
- return async ({ exportId, deviceId, previewText, previewMode }) => {
16477
+ return async (request) => {
16478
+ const { exportId, deviceId, previewText, previewMode } = request;
15787
16479
  const tags = { deviceId };
15788
16480
  const none = {
15789
16481
  video: null,
@@ -15814,15 +16506,32 @@ function buildTimelapseArtifactPublisher(ports) {
15814
16506
  return none;
15815
16507
  }
15816
16508
  const mp4 = Buffer$1.from(read.base64, "base64");
16509
+ const capturedAt = request.capturedAt ?? Date.now();
16510
+ const common = {
16511
+ deviceId,
16512
+ deviceIds: [deviceId],
16513
+ capturedAt,
16514
+ ...request.ruleId !== void 0 ? { ruleId: request.ruleId } : {},
16515
+ ...request.ruleName !== void 0 ? { ruleName: request.ruleName } : {},
16516
+ ...request.ownerUserId !== void 0 ? { ownerUserId: request.ownerUserId } : {}
16517
+ };
15817
16518
  const preview = await publishPreview(ports, {
15818
16519
  mp4,
15819
16520
  deviceId,
15820
16521
  tags,
15821
16522
  previewText,
15822
- previewMode: previewMode ?? "image"
16523
+ previewMode: previewMode ?? "image",
16524
+ meta: {
16525
+ ...common,
16526
+ kind: "timelapse-preview"
16527
+ }
15823
16528
  });
15824
16529
  try {
15825
- const url = await ports.publish(mp4, read.contentType);
16530
+ const { url } = await ports.publish(mp4, read.contentType, {
16531
+ ...common,
16532
+ kind: "timelapse",
16533
+ ...preview.artifactId !== null ? { posterArtifactId: preview.artifactId } : {}
16534
+ });
15826
16535
  if (url === null) {
15827
16536
  ports.logger.warn("timelapse artifact not published: no reachable public endpoint", {
15828
16537
  tags,
@@ -15830,7 +16539,7 @@ function buildTimelapseArtifactPublisher(ports) {
15830
16539
  });
15831
16540
  return {
15832
16541
  video: null,
15833
- preview
16542
+ preview: preview.ref
15834
16543
  };
15835
16544
  }
15836
16545
  ports.logger.info("timelapse artifact published", {
@@ -15839,7 +16548,7 @@ function buildTimelapseArtifactPublisher(ports) {
15839
16548
  exportId,
15840
16549
  bytes: read.bytes,
15841
16550
  name: read.name,
15842
- preview: preview !== null
16551
+ preview: preview.ref !== null
15843
16552
  }
15844
16553
  });
15845
16554
  return {
@@ -15848,7 +16557,7 @@ function buildTimelapseArtifactPublisher(ports) {
15848
16557
  mime: read.contentType,
15849
16558
  name: read.name
15850
16559
  },
15851
- preview
16560
+ preview: preview.ref
15852
16561
  };
15853
16562
  } catch (err) {
15854
16563
  ports.logger.warn("timelapse artifact not published: artifact plane refused the bytes", {
@@ -15860,18 +16569,22 @@ function buildTimelapseArtifactPublisher(ports) {
15860
16569
  });
15861
16570
  return {
15862
16571
  video: null,
15863
- preview
16572
+ preview: preview.ref
15864
16573
  };
15865
16574
  }
15866
16575
  };
15867
16576
  }
16577
+ var NO_PREVIEW = {
16578
+ ref: null,
16579
+ artifactId: null
16580
+ };
15868
16581
  /**
15869
16582
  * The preview half. Best-effort at every step and never throws: the picture is
15870
16583
  * an enhancement, the video is the notification.
15871
16584
  */
15872
16585
  async function publishPreview(ports, input) {
15873
16586
  const render = ports.renderPreview;
15874
- if (render === void 0 || input.previewText === void 0) return null;
16587
+ if (render === void 0 || input.previewText === void 0) return NO_PREVIEW;
15875
16588
  try {
15876
16589
  const rendered = await render({
15877
16590
  mp4: input.mp4,
@@ -15879,23 +16592,29 @@ async function publishPreview(ports, input) {
15879
16592
  deviceId: input.deviceId,
15880
16593
  mode: input.previewMode
15881
16594
  });
15882
- if (rendered === null) return null;
15883
- const url = await ports.publish(rendered.bytes, rendered.mime);
16595
+ if (rendered === null) return NO_PREVIEW;
16596
+ const { url, artifactId } = await ports.publish(rendered.bytes, rendered.mime, input.meta);
15884
16597
  if (url === null) {
15885
16598
  ports.logger.warn("timelapse preview not published: no reachable public endpoint", { tags: input.tags });
15886
- return null;
16599
+ return {
16600
+ ref: null,
16601
+ artifactId
16602
+ };
15887
16603
  }
15888
16604
  return {
15889
- url,
15890
- mime: rendered.mime,
15891
- name: PREVIEW_NAME_BY_MIME[rendered.mime] ?? PREVIEW_FALLBACK_NAME
16605
+ ref: {
16606
+ url,
16607
+ mime: rendered.mime,
16608
+ name: PREVIEW_NAME_BY_MIME[rendered.mime] ?? PREVIEW_FALLBACK_NAME
16609
+ },
16610
+ artifactId
15892
16611
  };
15893
16612
  } catch (err) {
15894
16613
  ports.logger.warn("timelapse preview not published: publish threw", {
15895
16614
  tags: input.tags,
15896
16615
  meta: { error: String(err) }
15897
16616
  });
15898
- return null;
16617
+ return NO_PREVIEW;
15899
16618
  }
15900
16619
  }
15901
16620
  /** Title size as a fraction of the frame WIDTH — so it reads the same on a
@@ -16382,6 +17101,10 @@ function startAlarmPanelConvergence(ports) {
16382
17101
  * `data-plane-access.spec.ts` asserts they still match the constants their
16383
17102
  * handlers register with — a mirror nothing checks is a lie waiting to happen.
16384
17103
  */
17104
+ /** The artefact read route's prefix, imported rather than mirrored: it is
17105
+ * ALSO the prefix `artifactDownloadPath` writes into every index row, and two
17106
+ * copies of that string is a 404 nobody would find. */
17107
+ var NC_ARTIFACT_READ_PREFIX_REF = NC_ARTIFACT_READ_PREFIX;
16385
17108
  var TRAINING_EXPORT_PREFIX_REF = "training-export";
16386
17109
  var RETRAIN_EXPORT_PREFIX_REF = "retrain-export";
16387
17110
  var DEBUG_EXPORT_PREFIX_REF = "debug-export";
@@ -16397,6 +17120,14 @@ var PIPELINE_ANALYTICS_PLANE_ACCESS = {
16397
17120
  "nc-action": "public",
16398
17121
  /** Self-authenticating single-use artifact token (D47). */
16399
17122
  "nc-artifact": "public",
17123
+ /**
17124
+ * The VIEWER's read of the same bytes (task #36). One indexed artefact by an
17125
+ * unguessable id the caller was handed by `nc.listArtifacts` — the rule above,
17126
+ * applied unchanged. It is deliberately NOT the `public` signed plane: the
17127
+ * viewer holds a session, and pre-minting a 24 h signed link for every
17128
+ * artefact a user might scroll to is a link-leak surface for no gain.
17129
+ */
17130
+ [NC_ARTIFACT_READ_PREFIX_REF]: "authenticated",
16400
17131
  /** Every frame behind a marked selection — a corpus, not an item. */
16401
17132
  [TRAINING_EXPORT_PREFIX_REF]: "admin",
16402
17133
  [RETRAIN_EXPORT_PREFIX_REF]: "admin",
@@ -41339,6 +42070,15 @@ var REBUILD_FALLBACK_NODE_ID = "hub";
41339
42070
  */
41340
42071
  var TIMELAPSE_TRACK_QUERY_LIMIT = 2e3;
41341
42072
  var PIPELINE_ANALYTICS_ADDON_ID = "pipeline-analytics";
42073
+ /** Artefact index ⇄ shelf reconcile cadence. Hourly: the two only drift on the
42074
+ * bounds' own timescale (14 days / 2000 entries / 2 GiB). */
42075
+ var NC_ARTIFACT_PRUNE_INTERVAL_MS = 36e5;
42076
+ /** What a publish answers on a node whose artifact plane never came up: no
42077
+ * link, no index row — and, exactly as before, bytes-only delivery. */
42078
+ var NO_PUBLICATION = {
42079
+ url: null,
42080
+ artifactId: null
42081
+ };
41342
42082
  /**
41343
42083
  * The FULL action catalog for this addon.
41344
42084
  *
@@ -41620,6 +42360,12 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
41620
42360
  * data-plane handle. Null until served / when the facility is absent. */
41621
42361
  ncArtifactPlane = null;
41622
42362
  ncArtifactDataPlane = null;
42363
+ /** The viewer's artefact library (task #36): the index, its authenticated
42364
+ * bytes route, and the store the two share. Null until served. */
42365
+ ncArtifactIndex = null;
42366
+ ncArtifactStore = null;
42367
+ ncArtifactReadDataPlane = null;
42368
+ ncArtifactPruneTimer = null;
41623
42369
  /**
41624
42370
  * Roadmap 4b: ONE cut of an event, in every container the rule asked for.
41625
42371
  *
@@ -41806,6 +42552,31 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
41806
42552
  * Best-effort at every step: no facility, no secret or no reachable base URL
41807
42553
  * ⇒ no plane, and the dispatcher keeps shipping bytes exactly as before.
41808
42554
  */
42555
+ /**
42556
+ * Reconcile the artefact index against the shelf that holds the bytes.
42557
+ *
42558
+ * Hourly, because the two drift only on the bounds' timescale: a row expires
42559
+ * on the shelf's own age bound (14 d) or its file is evicted by the count/byte
42560
+ * bound. Without this, a list eventually serves rows whose bytes 404 — and a
42561
+ * broken tile is the one failure an operator DOES report.
42562
+ */
42563
+ startNcArtifactPrune() {
42564
+ if (this.ncArtifactPruneTimer !== null) return;
42565
+ const run = async () => {
42566
+ const index = this.ncArtifactIndex;
42567
+ const store = this.ncArtifactStore;
42568
+ if (index === null || store === null) return;
42569
+ try {
42570
+ await store.sweep();
42571
+ await index.prune(this.isPostProcessingNode ? await store.keptIds() : null);
42572
+ } catch (err) {
42573
+ this.ctx.logger.warn("nc artifact prune failed", { meta: { error: errMsg(err) } });
42574
+ }
42575
+ };
42576
+ run();
42577
+ this.ncArtifactPruneTimer = setInterval(() => void run(), NC_ARTIFACT_PRUNE_INTERVAL_MS);
42578
+ this.ncArtifactPruneTimer.unref?.();
42579
+ }
41809
42580
  async serveNcArtifactPlane() {
41810
42581
  try {
41811
42582
  const secretState = this.state("ncArtifactSecret", NcArtifactSecretSchema, "");
@@ -41819,9 +42590,18 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
41819
42590
  logger: this.ctx.logger.child("nc-artifacts")
41820
42591
  });
41821
42592
  await store.start();
42593
+ this.ncArtifactStore = store;
42594
+ const index = new NcArtifactIndex({
42595
+ store: this.ctx.api.settingsStore,
42596
+ logger: this.ctx.logger.child("nc-artifacts"),
42597
+ addonId: this.ctx.id,
42598
+ retentionMs: store.retentionMs
42599
+ });
42600
+ this.ncArtifactIndex = index;
41822
42601
  const plane = new NcArtifactPlane({
41823
42602
  store,
41824
42603
  secret,
42604
+ index,
41825
42605
  logger: this.ctx.logger.child("nc-artifacts"),
41826
42606
  routePrefix: `/addon/${this.ctx.id}/nc-artifact`,
41827
42607
  listEndpoints: () => this.ncEndpoints()
@@ -41832,7 +42612,23 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
41832
42612
  handler: plane.handler
41833
42613
  }) ?? null;
41834
42614
  this.ncArtifactPlane = this.ncArtifactDataPlane !== null ? plane : null;
41835
- this.ctx.logger.info("nc-artifact data-plane served", { meta: { served: this.ncArtifactPlane !== null } });
42615
+ const readPlane = new NcArtifactReadPlane({
42616
+ index,
42617
+ store,
42618
+ logger: this.ctx.logger.child("nc-artifacts")
42619
+ });
42620
+ this.ncArtifactReadDataPlane = await this.ctx.dataPlane?.serve({
42621
+ prefix: NC_ARTIFACT_READ_PREFIX_REF,
42622
+ access: planeAccess(NC_ARTIFACT_READ_PREFIX_REF),
42623
+ handler: readPlane.handler
42624
+ }) ?? null;
42625
+ this.startNcArtifactPrune();
42626
+ this.ctx.logger.info("nc-artifact data-plane served", { meta: {
42627
+ served: this.ncArtifactPlane !== null,
42628
+ libraryServed: this.ncArtifactReadDataPlane !== null,
42629
+ libraryAccess: planeAccess(NC_ARTIFACT_READ_PREFIX_REF),
42630
+ retentionDays: Math.round(store.retentionMs / 864e5)
42631
+ } });
41836
42632
  } catch (err) {
41837
42633
  this.ctx.logger.warn("nc-artifact data-plane failed to serve", { meta: { error: errMsg(err) } });
41838
42634
  }
@@ -42280,7 +43076,8 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
42280
43076
  store: center.summaryStore,
42281
43077
  runNow: (input) => center.runSummaryNow(input)
42282
43078
  },
42283
- texts: center.textCatalogEditor
43079
+ texts: center.textCatalogEditor,
43080
+ ...this.ncArtifactIndex !== null ? { artifacts: this.ncArtifactIndex } : {}
42284
43081
  });
42285
43082
  }
42286
43083
  /** Declare typed collections up-front so the first insert doesn't race with
@@ -43069,7 +43866,7 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
43069
43866
  timestamp,
43070
43867
  data: jpeg
43071
43868
  }),
43072
- publish: async (bytes, mime) => await this.ncArtifactPlane?.publish(bytes, mime) ?? null
43869
+ publish: async (bytes, mime, meta) => await this.ncArtifactPlane?.publishIndexed(bytes, mime, meta) ?? NO_PUBLICATION
43073
43870
  };
43074
43871
  }
43075
43872
  /**
@@ -43142,7 +43939,7 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
43142
43939
  publishArtifact: buildTimelapseArtifactPublisher({
43143
43940
  logger: this.ctx.logger.child("Timelapse"),
43144
43941
  readExportBytes: ({ exportId }) => api.recordingExport.readExportBytes.query({ exportId }),
43145
- publish: async (bytes, mime) => await this.ncArtifactPlane?.publish(bytes, mime) ?? null,
43942
+ publish: async (bytes, mime, meta) => await this.ncArtifactPlane?.publishIndexed(bytes, mime, meta) ?? NO_PUBLICATION,
43146
43943
  renderPreview: async ({ mode, ...input }) => {
43147
43944
  const previewPorts = {
43148
43945
  logger: this.ctx.logger.child("Timelapse"),
@@ -43809,6 +44606,14 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
43809
44606
  this.retrainExportDataPlane = null;
43810
44607
  await this.debugExportDataPlane?.dispose();
43811
44608
  this.debugExportDataPlane = null;
44609
+ if (this.ncArtifactPruneTimer !== null) {
44610
+ clearInterval(this.ncArtifactPruneTimer);
44611
+ this.ncArtifactPruneTimer = null;
44612
+ }
44613
+ await this.ncArtifactReadDataPlane?.dispose();
44614
+ this.ncArtifactReadDataPlane = null;
44615
+ this.ncArtifactIndex = null;
44616
+ this.ncArtifactStore = null;
43812
44617
  }
43813
44618
  async handleInferenceResult(data) {
43814
44619
  if (this.shuttingDown) return;