From cc8da01d378e25354ef5b63eaed1eb0766ddfd51 Mon Sep 17 00:00:00 2001 From: Hayt <9e1c23a3fd83f61da34420e4e88ff1b16e45cafcc0cd9019eb07d4ecfa8ca9b0@buzz.block.builderlab.xyz> Date: Fri, 21 Aug 2026 14:27:57 -0400 Subject: [PATCH] feat(desktop): add observer-frame retention control and archive size readout MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The local archive Settings card exposed archive subscriptions but no way to configure how long observer frames (kind 24200) are retained, and no visibility into how large the archive DB has grown. The retention backend (#5719) shipped the get/set/size commands but nothing consumed them. Adds a retention field bound to 1..=36500 days (validated client-side to match the fail-closed backend) with copy stating metrics and every other archived kind are kept forever, plus a size readout showing physical bytes (main + WAL) and reclaimable freelist bytes — labeled so it never implies the file has already shrunk (freed pages only return space after an offline VACUUM). Co-authored-by: Will Pfleger Signed-off-by: Will Pfleger --- .../local-archive/retentionSettings.test.mjs | 122 ++++++++++ .../local-archive/retentionSettings.ts | 85 +++++++ .../ui/LocalArchiveSettingsCard.test.mjs | 209 ++++++++++++++++++ .../ui/LocalArchiveSettingsCard.tsx | 162 ++++++++++++++ desktop/src/shared/api/tauriArchive.ts | 47 ++++ 5 files changed, 625 insertions(+) create mode 100644 desktop/src/features/local-archive/retentionSettings.test.mjs create mode 100644 desktop/src/features/local-archive/retentionSettings.ts create mode 100644 desktop/src/features/local-archive/ui/LocalArchiveSettingsCard.test.mjs diff --git a/desktop/src/features/local-archive/retentionSettings.test.mjs b/desktop/src/features/local-archive/retentionSettings.test.mjs new file mode 100644 index 0000000000..f6568b35f9 --- /dev/null +++ b/desktop/src/features/local-archive/retentionSettings.test.mjs @@ -0,0 +1,122 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + MAX_RETENTION_DAYS, + MIN_RETENTION_DAYS, + deriveSizeReadout, + formatBytes, + parseRetentionDays, +} from "./retentionSettings.ts"; + +// ── parseRetentionDays ──────────────────────────────────────────────────────── + +test("parseRetentionDays_validInteger_ok", () => { + assert.deepEqual(parseRetentionDays("30"), { ok: true, days: 30 }); +}); + +test("parseRetentionDays_trimsWhitespace", () => { + assert.deepEqual(parseRetentionDays(" 7 "), { ok: true, days: 7 }); +}); + +test("parseRetentionDays_min_ok", () => { + assert.deepEqual(parseRetentionDays(String(MIN_RETENTION_DAYS)), { + ok: true, + days: MIN_RETENTION_DAYS, + }); +}); + +test("parseRetentionDays_max_ok", () => { + assert.deepEqual(parseRetentionDays(String(MAX_RETENTION_DAYS)), { + ok: true, + days: MAX_RETENTION_DAYS, + }); +}); + +test("parseRetentionDays_empty_error", () => { + const r = parseRetentionDays(" "); + assert.equal(r.ok, false); +}); + +test("parseRetentionDays_zero_belowMin", () => { + const r = parseRetentionDays("0"); + assert.equal(r.ok, false); + assert.match(r.error, /at least 1/); +}); + +test("parseRetentionDays_negative_rejected", () => { + // The leading "-" fails the digits-only shape before the range check. + const r = parseRetentionDays("-5"); + assert.equal(r.ok, false); + assert.match(r.error, /whole number/); +}); + +test("parseRetentionDays_fractional_rejected", () => { + const r = parseRetentionDays("3.5"); + assert.equal(r.ok, false); + assert.match(r.error, /whole number/); +}); + +test("parseRetentionDays_nonNumeric_rejected", () => { + const r = parseRetentionDays("abc"); + assert.equal(r.ok, false); +}); + +test("parseRetentionDays_aboveMax_rejected", () => { + const r = parseRetentionDays(String(MAX_RETENTION_DAYS + 1)); + assert.equal(r.ok, false); + assert.match(r.error, /at most/); +}); + +// ── formatBytes ─────────────────────────────────────────────────────────────── + +test("formatBytes_zeroAndNegative_clampToZero", () => { + assert.equal(formatBytes(0), "0 B"); + assert.equal(formatBytes(-100), "0 B"); + assert.equal(formatBytes(Number.NaN), "0 B"); +}); + +test("formatBytes_bytes", () => { + assert.equal(formatBytes(820), "820 B"); +}); + +test("formatBytes_kilobytes_oneDecimalUnderTen", () => { + assert.equal(formatBytes(1024 * 3 + 410), "3.4 KB"); +}); + +test("formatBytes_gigabytes", () => { + assert.equal(formatBytes(7.4 * 1024 ** 3), "7.4 GB"); +}); + +test("formatBytes_roundsAtTenOrAbove", () => { + // 12.4 GB rounds to whole number once ≥ 10 in its unit. + assert.equal(formatBytes(12.4 * 1024 ** 3), "12 GB"); +}); + +// ── deriveSizeReadout ───────────────────────────────────────────────────────── + +test("deriveSizeReadout_physicalIsMainPlusWal", () => { + const r = deriveSizeReadout({ + mainFileBytes: 1024 ** 3, + walFileBytes: 512 * 1024 ** 2, + pageSize: 4096, + pageCount: 1000, + freelistCount: 0, + }); + assert.equal(r.physicalBytes, 1024 ** 3 + 512 * 1024 ** 2); + assert.equal(r.physicalLabel, "1.5 GB"); + assert.equal(r.reclaimableBytes, 0); + assert.equal(r.reclaimableLabel, "0 B"); +}); + +test("deriveSizeReadout_reclaimableIsFreelistTimesPageSize", () => { + const r = deriveSizeReadout({ + mainFileBytes: 0, + walFileBytes: 0, + pageSize: 4096, + freelistCount: 262_144, // 256K pages × 4KB = 1 GB + pageCount: 300_000, + }); + assert.equal(r.reclaimableBytes, 1024 ** 3); + assert.equal(r.reclaimableLabel, "1.0 GB"); +}); diff --git a/desktop/src/features/local-archive/retentionSettings.ts b/desktop/src/features/local-archive/retentionSettings.ts new file mode 100644 index 0000000000..3e8fd59039 --- /dev/null +++ b/desktop/src/features/local-archive/retentionSettings.ts @@ -0,0 +1,85 @@ +// Pure helpers for the observer-frame retention control and the archive size +// readout. Kept free of React and Tauri so the branch-heavy logic — input +// parsing, client-side bound validation, and byte formatting — is unit-tested +// directly, matching the `localArchiveKinds` split in this feature. + +import type { ArchiveSizeStats } from "@/shared/api/tauriArchive"; + +/** Inclusive bounds mirroring the backend's `validate_days` (`retention.rs`). */ +export const MIN_RETENTION_DAYS = 1; +export const MAX_RETENTION_DAYS = 36_500; + +export type ParsedDays = + | { ok: true; days: number } + | { ok: false; error: string }; + +/** + * Parse and bound-check a retention-days text input. Accepts only a plain + * non-negative integer in `[MIN, MAX]`; anything else (blank, non-numeric, + * fractional, signed, out-of-range) is rejected with a message suitable for + * inline display. Mirrors the backend contract so the invoke is only issued + * for values the store will accept. + */ +export function parseRetentionDays(raw: string): ParsedDays { + const trimmed = raw.trim(); + if (trimmed === "") return { ok: false, error: "Enter a number of days." }; + // Reject signs, decimals, whitespace-in-the-middle — only bare digits. + if (!/^\d+$/.test(trimmed)) { + return { ok: false, error: "Days must be a whole number." }; + } + const days = Number(trimmed); + if (days < MIN_RETENTION_DAYS) { + return { ok: false, error: `Days must be at least ${MIN_RETENTION_DAYS}.` }; + } + if (days > MAX_RETENTION_DAYS) { + return { + ok: false, + error: `Days must be at most ${MAX_RETENTION_DAYS.toLocaleString()}.`, + }; + } + return { ok: true, days }; +} + +/** + * Human-readable byte size: "0 B", "820 B", "12.4 KB", "3.1 MB", "7.4 GB". + * Binary (1024) units, matching `FileCard`'s `formatFileSize`. Negative or + * non-finite inputs clamp to "0 B" rather than rendering nonsense. + */ +export function formatBytes(bytes: number): string { + if (!Number.isFinite(bytes) || bytes <= 0) return "0 B"; + if (bytes < 1024) return `${Math.round(bytes)} B`; + const units = ["KB", "MB", "GB", "TB"]; + let size = bytes / 1024; + let i = 0; + while (size >= 1024 && i < units.length - 1) { + size /= 1024; + i += 1; + } + return `${size < 10 ? size.toFixed(1) : Math.round(size)} ${units[i]}`; +} + +export type ArchiveSizeReadout = { + /** Physical bytes actually on disk: main DB file + `-wal` sidecar. */ + physicalBytes: number; + /** Reclaimable bytes sitting on the freelist (`freelistCount × pageSize`). */ + reclaimableBytes: number; + physicalLabel: string; + reclaimableLabel: string; +}; + +/** + * Derive the size readout from raw stats. Physical size is the honest on-disk + * footprint (main + WAL); reclaimable is freelist pages, which only shrink the + * file after an offline VACUUM — until then they are savings, not a smaller + * file. Callers label them so the UI never implies the file has already shrunk. + */ +export function deriveSizeReadout(stats: ArchiveSizeStats): ArchiveSizeReadout { + const physicalBytes = stats.mainFileBytes + stats.walFileBytes; + const reclaimableBytes = Math.max(0, stats.freelistCount * stats.pageSize); + return { + physicalBytes, + reclaimableBytes, + physicalLabel: formatBytes(physicalBytes), + reclaimableLabel: formatBytes(reclaimableBytes), + }; +} diff --git a/desktop/src/features/local-archive/ui/LocalArchiveSettingsCard.test.mjs b/desktop/src/features/local-archive/ui/LocalArchiveSettingsCard.test.mjs new file mode 100644 index 0000000000..58069ade55 --- /dev/null +++ b/desktop/src/features/local-archive/ui/LocalArchiveSettingsCard.test.mjs @@ -0,0 +1,209 @@ +/** + * Mounted-render tests for the two archive-retention sections added in P3': + * ObserverRetentionSection and ArchiveSizeSection. + * + * These mount the REAL subcomponents (exported from LocalArchiveSettingsCard) + * against a mocked Tauri IPC bridge, so the render → validate → invoke path is + * exercised end-to-end. The full card is not mounted: it depends on the + * communities/channels query context, which is orthogonal to what these + * sections do. The bound-parsing and byte-formatting logic they build on is + * unit-tested directly in `retentionSettings.test.mjs`. + */ + +import assert from "node:assert/strict"; +import { after, afterEach, before, test } from "node:test"; + +import { JSDOM } from "jsdom"; + +const dom = new JSDOM("", { + url: "http://localhost", +}); + +const ipcHandlers = new Map(); + +let act; +let cleanup; +let fireEvent; +let render; +let screen; +let createElement; +let ObserverRetentionSection; +let ArchiveSizeSection; + +before(async () => { + Object.assign(globalThis, { + document: dom.window.document, + HTMLElement: dom.window.HTMLElement, + window: dom.window, + IS_REACT_ACT_ENVIRONMENT: true, + }); + Object.defineProperty(globalThis, "navigator", { + configurable: true, + value: dom.window.navigator, + writable: true, + }); + dom.window.matchMedia = () => ({ + matches: false, + addEventListener() {}, + removeEventListener() {}, + }); + dom.window.__TAURI_INTERNALS__ = { + invoke: (cmd, args) => { + const handler = ipcHandlers.get(cmd); + if (handler) return handler(args); + return Promise.reject(new Error(`unmocked Tauri command: ${cmd}`)); + }, + transformCallback: () => Math.random(), + }; + + ({ act, cleanup, fireEvent, render, screen } = await import( + "@testing-library/react" + )); + ({ createElement } = await import("react")); + ({ ObserverRetentionSection, ArchiveSizeSection } = await import( + "./LocalArchiveSettingsCard.tsx" + )); +}); + +afterEach(() => { + cleanup?.(); + ipcHandlers.clear(); +}); + +after(() => dom.window.close()); + +// ── ObserverRetentionSection ────────────────────────────────────────────────── + +test("retention field seeds from the saved value and disables Save while unchanged", async () => { + await act(async () => { + render( + createElement(ObserverRetentionSection, { + savedDays: 30, + onSaved: () => {}, + }), + ); + }); + + const input = screen.getByTestId("local-archive-retention-days"); + assert.equal(input.value, "30"); + const save = screen.getByTestId("local-archive-retention-save"); + assert.equal(save.disabled, true, "unchanged value cannot be saved"); +}); + +test("input disabled until the saved value loads", async () => { + await act(async () => { + render( + createElement(ObserverRetentionSection, { + savedDays: null, + onSaved: () => {}, + }), + ); + }); + + assert.equal( + screen.getByTestId("local-archive-retention-days").disabled, + true, + ); +}); + +test("out-of-bounds input surfaces an inline error and blocks Save", async () => { + await act(async () => { + render( + createElement(ObserverRetentionSection, { + savedDays: 30, + onSaved: () => {}, + }), + ); + }); + + const input = screen.getByTestId("local-archive-retention-days"); + await act(async () => { + fireEvent.change(input, { target: { value: "0" } }); + }); + + assert.match( + screen.getByTestId("local-archive-retention-error").textContent, + /at least 1/, + ); + assert.equal( + screen.getByTestId("local-archive-retention-save").disabled, + true, + ); +}); + +test("a valid change saves through the invoke and reports the new value", async () => { + const setCalls = []; + ipcHandlers.set("set_observer_retention_days", (args) => { + setCalls.push(args); + return Promise.resolve(null); + }); + + let saved = null; + await act(async () => { + render( + createElement(ObserverRetentionSection, { + savedDays: 30, + onSaved: (d) => { + saved = d; + }, + }), + ); + }); + + const input = screen.getByTestId("local-archive-retention-days"); + await act(async () => { + fireEvent.change(input, { target: { value: "14" } }); + }); + + const save = screen.getByTestId("local-archive-retention-save"); + assert.equal(save.disabled, false, "a changed valid value enables Save"); + + await act(async () => { + fireEvent.click(save); + }); + + assert.deepEqual(setCalls, [{ days: 14 }], "invoke receives the parsed days"); + assert.equal(saved, 14, "onSaved reports the persisted value"); +}); + +// ── ArchiveSizeSection ──────────────────────────────────────────────────────── + +test("size readout shows physical size and reclaimable bytes", async () => { + await act(async () => { + render( + createElement(ArchiveSizeSection, { + stats: { + mainFileBytes: 1024 ** 3, + walFileBytes: 512 * 1024 ** 2, + pageSize: 4096, + pageCount: 300_000, + freelistCount: 262_144, // 1 GB reclaimable + }, + }), + ); + }); + + assert.equal( + screen.getByTestId("local-archive-size-physical").textContent, + "1.5 GB", + ); + const copy = screen.getByTestId("local-archive-size-section").textContent; + assert.match(copy, /1\.5 GB/); + assert.match(copy, /1\.0 GB is reclaimable/); + assert.match(copy, /only shrinks after an offline compaction/); +}); + +test("size readout shows a loading fallback before stats arrive", async () => { + await act(async () => { + render(createElement(ArchiveSizeSection, { stats: null })); + }); + + assert.equal( + screen.getByTestId("local-archive-size-physical").textContent, + "—", + ); + assert.match( + screen.getByTestId("local-archive-size-section").textContent, + /Loading…/, + ); +}); diff --git a/desktop/src/features/local-archive/ui/LocalArchiveSettingsCard.tsx b/desktop/src/features/local-archive/ui/LocalArchiveSettingsCard.tsx index c03faa0948..3854df57bf 100644 --- a/desktop/src/features/local-archive/ui/LocalArchiveSettingsCard.tsx +++ b/desktop/src/features/local-archive/ui/LocalArchiveSettingsCard.tsx @@ -3,11 +3,15 @@ import * as React from "react"; import { toast } from "sonner"; import { + archiveSizeStats, createSaveSubscription, deleteSaveSubscription, + getObserverRetentionDays, listSaveSubscriptions, mergeSaveSubscriptionKinds, removeSaveSubscriptionKind, + setObserverRetentionDays, + type ArchiveSizeStats, type SaveSubscription, type ScopeType, } from "@/shared/api/tauriArchive"; @@ -27,6 +31,7 @@ import { import { SettingsSectionHeader } from "@/features/settings/ui/SettingsSectionHeader"; import { setExplicitAgentMetricArchiveChoice } from "../agentMetricArchivePreference"; import { setExplicitObserverArchiveChoice } from "../observerArchivePreference"; +import { deriveSizeReadout, parseRetentionDays } from "../retentionSettings"; import { buildSubscriptionRequest, @@ -153,6 +158,134 @@ function AgentMetricArchiveSection({ ); } +// ── Observer retention section ──────────────────────────────────────────────── + +type RetentionSectionProps = { + /** Persisted value, or `null` while the initial load is in flight. */ + savedDays: number | null; + onSaved: (days: number) => void; +}; + +export function ObserverRetentionSection({ + savedDays, + onSaved, +}: RetentionSectionProps) { + const [draft, setDraft] = React.useState(""); + const [isSaving, setIsSaving] = React.useState(false); + + // Seed the field from the persisted value once it loads, and re-seed whenever + // it changes underneath us (e.g. a successful save elsewhere). + React.useEffect(() => { + if (savedDays !== null) setDraft(String(savedDays)); + }, [savedDays]); + + const parsed = parseRetentionDays(draft); + const invalid = !parsed.ok; + const unchanged = + parsed.ok && savedDays !== null && parsed.days === savedDays; + const canSave = parsed.ok && !unchanged && !isSaving; + + const handleSave = React.useCallback(async () => { + if (!parsed.ok) return; + setIsSaving(true); + try { + await setObserverRetentionDays(parsed.days); + onSaved(parsed.days); + toast.success("Retention window updated."); + } catch (err) { + toast.error( + err instanceof Error ? err.message : "Failed to update retention.", + ); + } finally { + setIsSaving(false); + } + }, [parsed, onSaved]); + + return ( +
+ +
+
+
+ + setDraft(e.target.value)} + placeholder="30" + type="text" + value={draft} + /> +
+ +
+ {invalid && draft.trim() !== "" && ( +

+ {parsed.error} +

+ )} +
+
+
+ ); +} + +// ── Archive size readout section ────────────────────────────────────────────── + +export function ArchiveSizeSection({ + stats, +}: { + stats: ArchiveSizeStats | null; +}) { + const readout = stats ? deriveSizeReadout(stats) : null; + return ( +
+ + +
+

Archive size on disk

+

+ {readout === null + ? "Loading…" + : `Physical size ${readout.physicalLabel} (database + write-ahead log). ${readout.reclaimableLabel} is reclaimable after pruning — freed pages become reusable space; the file only shrinks after an offline compaction.`} +

+
+ + {readout?.physicalLabel ?? "—"} + +
+
+
+ ); +} + // ── Add-subscription form ───────────────────────────────────────────────────── type KindChecklistProps = { @@ -397,6 +530,10 @@ export function LocalArchiveSettingsCard() { const [isAddingOpen, setIsAddingOpen] = React.useState(false); const [observerToggling, setObserverToggling] = React.useState(false); const [metricToggling, setMetricToggling] = React.useState(false); + const [retentionDays, setRetentionDays] = React.useState(null); + const [sizeStats, setSizeStats] = React.useState( + null, + ); const pubkey = identityQuery.data?.pubkey ?? ""; @@ -428,6 +565,22 @@ export function LocalArchiveSettingsCard() { void reload(); }, [reload]); + // Retention window + size readout load independently of the subscription + // list; a failure in either is non-fatal (the sections show their loading + // fallback) and must not blank the rest of the card. + React.useEffect(() => { + void getObserverRetentionDays() + .then(setRetentionDays) + .catch((err) => + console.warn("[LocalArchiveSettingsCard] retention load failed:", err), + ); + void archiveSizeStats() + .then(setSizeStats) + .catch((err) => + console.warn("[LocalArchiveSettingsCard] size load failed:", err), + ); + }, []); + const handleDelete = React.useCallback( async (scopeType: ScopeType, scopeValue: string) => { const key = `${scopeType}:${scopeValue}`; @@ -543,6 +696,15 @@ export function LocalArchiveSettingsCard() { toggling={metricToggling} /> + {/* Observer frame retention window */} + + + {/* Archive size readout */} + + {/* Channel subscriptions */}
{isLoading ? ( diff --git a/desktop/src/shared/api/tauriArchive.ts b/desktop/src/shared/api/tauriArchive.ts index 10fbb733ca..5018b8e0f4 100644 --- a/desktop/src/shared/api/tauriArchive.ts +++ b/desktop/src/shared/api/tauriArchive.ts @@ -99,6 +99,25 @@ export type AgentUsageSeriesRequest = { agentPubkey?: string; }; +/** + * Physical and logical size accounting for the archive DB, from + * `archive_size_stats`. Mirrors `retention::ArchiveSizeStats` + * (`#[serde(rename_all = "camelCase")]`) field-for-field. All figures are cheap + * PRAGMAs + file metadata — no payload scans. + */ +export type ArchiveSizeStats = { + /** On-disk size of the main DB file, in bytes. */ + mainFileBytes: number; + /** On-disk size of the `-wal` sidecar, in bytes; `0` when absent. */ + walFileBytes: number; + /** SQLite page size, in bytes. */ + pageSize: number; + /** Total pages in the main DB. */ + pageCount: number; + /** Free (reclaimable) pages on the freelist. */ + freelistCount: number; +}; + // ── Wire-shape types (raw Tauri responses) ─────────────────────────────────── /** @@ -555,3 +574,31 @@ export async function readArchivedEvents( }) .filter((e): e is import("@/shared/api/types").RelayEvent => e !== null); } + +// ── Retention configuration ────────────────────────────────────────────────── + +/** + * Read the global observer-frame (kind 24200) retention window, in days. Every + * other archived kind — NIP-AM metrics and any custom subscription — is kept + * indefinitely and has no setting. + */ +export async function getObserverRetentionDays(): Promise { + return invokeTauri("get_observer_retention_days"); +} + +/** + * Set the global observer-frame retention window, in days. The backend is + * fail-closed and rejects values outside `1..=36500`; callers validate the + * same bounds client-side before invoking (see `retentionSettings`). + */ +export async function setObserverRetentionDays(days: number): Promise { + await invokeTauri("set_observer_retention_days", { days }); +} + +/** + * Physical (file) and logical (page) size accounting for the archive DB, for + * the Settings size readout. PRAGMAs + file metadata only — no payload scans. + */ +export async function archiveSizeStats(): Promise { + return invokeTauri("archive_size_stats"); +}