From 5179726737108a4a91076d262c30a53d4a7237e9 Mon Sep 17 00:00:00 2001 From: Will Pfleger Date: Tue, 4 Aug 2026 16:02:34 -0400 Subject: [PATCH] fix(local-archive): default both archive settings to enabled (#4750) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Overview Both local archive settings — "Archive my agents' observer frames" (kind 24200) and "Archive my agents' turn metrics" (kind 44200) — previously defaulted to OFF in OSS builds, controlled by build-time env vars. This had an irreversible cost: observer frames are ephemeral (not stored by the relay), so any missed events are permanently unrecoverable. This PR makes both settings default to enabled for all builds and removes the build-time flag machinery entirely. ## What changed ### Rust - `observer_archive_default_enabled()` — returns `true` unconditionally; removed `option_env!("BUZZ_DESKTOP_BUILD_OBSERVER_ARCHIVE_DEFAULT")` check and `nest_is_dev()` runtime fallback. - `agent_metric_archive_default_enabled()` — returns `true` unconditionally; removed `option_env!("BUZZ_DESKTOP_BUILD_AGENT_METRIC_ARCHIVE_DEFAULT")` check and its OSS-build test. - `build.rs` — removed both `rerun-if-env-changed` declarations (`BUZZ_BUILD_OBSERVER_ARCHIVE_DEFAULT`, `BUZZ_BUILD_AGENT_METRIC_ARCHIVE_DEFAULT`) and the two baked-env emitting blocks. ### Build / CI - `Justfile` — removed `desktop-tauri-test-compiled-flags` recipe (the dual-compile test machinery). - `.github/workflows/ci.yml` — removed the "Desktop Tauri compiled-flag verification" CI step. ### TypeScript - `useObserverArchiveSeed.ts` — removed `observerArchiveDefaultEnabled` dep from `ObserverArchiveSeedDeps` and the `policyOn` gate in `reconcileObserverArchive`; the function now unconditionally calls `mergeSaveSubscriptionKinds`. - `useAgentMetricArchiveSeed.ts` — removed `agentMetricArchiveDefaultEnabled` dep from `AgentMetricArchiveSeedDeps` and the `defaultOn` flag-check path in `maybeSeed`; the `hasExplicitChoice` guard is preserved as the sole gate against re-seeding. - `LocalArchiveSettingsCard.tsx` — removed `policy` prop, `observerPolicy` state, and `observerArchiveDefaultEnabled` fetch from `ObserverArchiveSection`; toggle is now always enabled (just `toggling` disables it); removed the stale "Always on for internal builds" copy branch; removed the `observerPolicy !== false` guard from `handleObserverToggle`. - `tauriArchive.ts` — updated JSDoc on both default-enabled functions to reflect always-true. - `e2eBridge.ts` — changed both mock defaults from `?? false` to `?? true` so E2E tests without an explicit mock override exercise the real default behavior. ### Tests - `useObserverArchiveSeed.test.mjs` — replaced `policyOn` dep with direct merge dep; updated `test_oss_policy_off_no_merge` → `test_reconcile_always_seeds_24200`; all cancellation, identity-switch, and ordering tests adapted. - `useAgentMetricArchiveSeed.test.mjs` — removed `defaultOn` dep and `test_oss_build_does_not_seed`; updated `test_internal_build_unset_seeds_*` → `test_default_enabled_*`; `hasExplicitChoice` guard tests unchanged. ## Preservation of explicit opt-outs Users who have previously toggled the setting off are unaffected: - `useAgentMetricArchiveSeed` skips seeding when `hasExplicitChoice(pubkey)` returns true (localStorage-persisted per identity). - Observer archive reconciliation now unconditionally calls `mergeSaveSubscriptionKinds`, but a user who already deleted the subscription can turn it off via the Settings toggle, which calls `removeSaveSubscriptionKind` — this is the existing explicit opt-out path, and the toggle is now always enabled (not locked by a policy flag). ## Result - No `BUZZ_BUILD_*_ARCHIVE_DEFAULT` / `BUZZ_DESKTOP_BUILD_*_ARCHIVE_DEFAULT` references remain outside CHANGELOG/history. - Desktop node tests: 4168 pass, 0 fail. - `just desktop-tauri-check`: clean. - `just desktop-tauri-test`: all pass. --------- Signed-off-by: Will Pfleger Signed-off-by: npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7 Co-authored-by: npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7 --- Justfile | 11 +- desktop/src-tauri/build.rs | 17 - .../src/commands/agent_metric_archive.rs | 41 +-- .../src/commands/observer_archive.rs | 60 +--- desktop/src-tauri/src/managed_agents/nest.rs | 25 -- .../src/managed_agents/nest/tests.rs | 36 -- desktop/src/app/AppShell.tsx | 4 +- .../observerArchivePreference.ts | 85 +++++ .../ui/LocalArchiveSettingsCard.tsx | 26 +- .../useAgentMetricArchiveSeed.test.mjs | 76 +--- .../useAgentMetricArchiveSeed.ts | 40 +-- .../useObserverArchiveSeed.test.mjs | 204 +++++++---- .../local-archive/useObserverArchiveSeed.ts | 34 +- desktop/src/shared/api/tauriArchive.ts | 17 +- desktop/src/testing/e2eBridge.ts | 55 +-- .../tests/e2e/observer-archive-policy.spec.ts | 326 ++++-------------- desktop/tests/helpers/bridge.ts | 22 -- 17 files changed, 373 insertions(+), 706 deletions(-) create mode 100644 desktop/src/features/local-archive/observerArchivePreference.ts diff --git a/Justfile b/Justfile index d80341ecac..c3d755ffeb 100644 --- a/Justfile +++ b/Justfile @@ -213,24 +213,17 @@ desktop-terminal-performance-test: cargo test --manifest-path desktop/src-tauri/crates/buzz-terminal/Cargo.toml --release --test latency g3_renderer_acquire_stays_within_frame_budget -- --ignored --exact --nocapture # Verify compiled-flag behavior under both compile states (clean + internal). -# Runs the observer_archive focused test twice with independently supplied +# Runs the auto-connect compiled-flag test twice with independently supplied # expected values; build.rs rerun-if-env-changed triggers recompilation. desktop-tauri-test-compiled-flags: _ensure-sidecar-stubs #!/usr/bin/env bash set -euo pipefail cd desktop/src-tauri echo "=== Clean build (no flag) → expect false ===" - env -u BUZZ_BUILD_OBSERVER_ARCHIVE_DEFAULT \ - -u BUZZ_BUILD_AUTO_CONNECT_DEFAULT_RELAY \ - BUZZ_TEST_EXPECTED_OBSERVER_ARCHIVE_DEFAULT=false \ - cargo test observer_archive_default_enabled_matches_expected -- --ignored --nocapture env -u BUZZ_BUILD_AUTO_CONNECT_DEFAULT_RELAY \ BUZZ_TEST_EXPECTED_AUTO_CONNECT_DEFAULT_RELAY=false \ cargo test compiled_flag_matches_expected -- --ignored --nocapture - echo "=== Internal build (flags set) → expect true ===" - BUZZ_BUILD_OBSERVER_ARCHIVE_DEFAULT=1 \ - BUZZ_TEST_EXPECTED_OBSERVER_ARCHIVE_DEFAULT=true \ - cargo test observer_archive_default_enabled_matches_expected -- --ignored --nocapture + echo "=== Internal build (flag set) → expect true ===" BUZZ_BUILD_AUTO_CONNECT_DEFAULT_RELAY=1 \ BUZZ_TEST_EXPECTED_AUTO_CONNECT_DEFAULT_RELAY=true \ cargo test compiled_flag_matches_expected -- --ignored --nocapture diff --git a/desktop/src-tauri/build.rs b/desktop/src-tauri/build.rs index 0fb3747718..2b997af891 100644 --- a/desktop/src-tauri/build.rs +++ b/desktop/src-tauri/build.rs @@ -13,8 +13,6 @@ fn main() { println!("cargo:rerun-if-env-changed=BUZZ_BUILD_BUZZ_AGENT_MODEL"); println!("cargo:rerun-if-env-changed=BUZZ_BUILD_AGENT_ENV"); println!("cargo:rerun-if-env-changed=BUZZ_BUILD_RELAY_RECONNECT_CMD"); - println!("cargo:rerun-if-env-changed=BUZZ_BUILD_OBSERVER_ARCHIVE_DEFAULT"); - println!("cargo:rerun-if-env-changed=BUZZ_BUILD_AGENT_METRIC_ARCHIVE_DEFAULT"); println!("cargo:rerun-if-env-changed=BUZZ_BUILD_AUTO_CONNECT_DEFAULT_RELAY"); println!("cargo:rustc-check-cfg=cfg(buzz_updater_enabled)"); @@ -75,21 +73,6 @@ fn main() { println!("cargo:rustc-env=BUZZ_DESKTOP_BUILD_RELAY_RECONNECT_CMD={val}"); } - // Presence-only flag: when set (any non-empty value), observer-feed archive - // defaults to ON for the current identity on first run. OSS builds leave - // this unset → default OFF. No JSON validation needed — the command only - // checks `.is_some()`. - if std::env::var("BUZZ_BUILD_OBSERVER_ARCHIVE_DEFAULT").is_ok() { - println!("cargo:rustc-env=BUZZ_DESKTOP_BUILD_OBSERVER_ARCHIVE_DEFAULT=1"); - } - - // Presence-only flag: when set (any non-empty value), agent-turn-metric - // archive defaults to ON for the current identity on first run. OSS builds - // leave this unset → default OFF. - if std::env::var("BUZZ_BUILD_AGENT_METRIC_ARCHIVE_DEFAULT").is_ok() { - println!("cargo:rustc-env=BUZZ_DESKTOP_BUILD_AGENT_METRIC_ARCHIVE_DEFAULT=1"); - } - // Presence-only release capability: internal desktop builds opt into // auto-connecting their configured default relay on first run. OSS builds // leave this unset and retain explicit community selection. diff --git a/desktop/src-tauri/src/commands/agent_metric_archive.rs b/desktop/src-tauri/src/commands/agent_metric_archive.rs index 43cfc7b082..77de870871 100644 --- a/desktop/src-tauri/src/commands/agent_metric_archive.rs +++ b/desktop/src-tauri/src/commands/agent_metric_archive.rs @@ -1,35 +1,18 @@ -//! Build-time flag for agent-turn-metric archive default. +//! Agent-turn-metric archive default — always enabled. //! -//! When `BUZZ_BUILD_AGENT_METRIC_ARCHIVE_DEFAULT` is set at build time -//! (internal builds), `agent_metric_archive_default_enabled()` returns `true` -//! and the frontend auto-seeds an `owner_p` save subscription for kind 44200 -//! (agent turn metrics) on first run for the current identity. -//! -//! OSS builds (env var unset) return `false` — no auto-seeding, user opts in -//! manually via the Local Archive settings card. +//! `agent_metric_archive_default_enabled()` returns `true` unconditionally. +//! The frontend calls this once at startup to decide whether to seed the +//! `owner_p` [44200] save subscription for the current identity on first run. +//! The `hasExplicitChoice` guard in the TS seed hook ensures a user who has +//! explicitly opted out remains opted out. -/// Returns `true` when an internal build has agent-turn-metric archive -/// default-on. +/// Returns `true`: agent-turn-metric archive defaults to enabled for all builds. /// -/// The frontend calls this once at startup to decide whether to seed the -/// `owner_p` [44200] save subscription. The result is stable for the lifetime -/// of the binary — it is baked at compile time. +/// The frontend uses this to decide whether to auto-seed an `owner_p` [44200] +/// save subscription on first run. Existing explicit choices (stored in +/// localStorage per identity) are preserved by the seed hook's `hasExplicitChoice` +/// guard — this default only applies to identities that have never made a choice. #[tauri::command] pub fn agent_metric_archive_default_enabled() -> bool { - option_env!("BUZZ_DESKTOP_BUILD_AGENT_METRIC_ARCHIVE_DEFAULT").is_some() -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_agent_metric_archive_default_enabled_returns_false_in_oss_build() { - // In a standard OSS/test build (no BUZZ_DESKTOP_BUILD_AGENT_METRIC_ARCHIVE_DEFAULT - // baked in), this must return false. - assert!( - !agent_metric_archive_default_enabled(), - "expected false in OSS/test build" - ); - } + true } diff --git a/desktop/src-tauri/src/commands/observer_archive.rs b/desktop/src-tauri/src/commands/observer_archive.rs index 707e86b63a..d8b2832b92 100644 --- a/desktop/src-tauri/src/commands/observer_archive.rs +++ b/desktop/src-tauri/src/commands/observer_archive.rs @@ -1,54 +1,18 @@ -//! Build-time flag and runtime dev-nest check for observer-feed archive policy. +//! Observer-feed archive default — always enabled. //! -//! `observer_archive_default_enabled()` returns `true` when either: -//! - `BUZZ_BUILD_OBSERVER_ARCHIVE_DEFAULT` was set at build time (internal -//! builds bake in the flag via `build.rs`), **or** -//! - the running binary is using the dev nest (`~/.buzz-dev`), which is the -//! case for all dev builds launched with `just staging` or `just dev`. -//! -//! When `true`, the frontend reconciles the observer archive subscription -//! every startup — unconditionally ensuring kind 24200 exists in the DB -//! regardless of stale localStorage markers. -//! -//! OSS prod builds (baked flag unset, prod nest `~/.buzz`) return `false` — -//! no reconciliation; the user manages the subscription via Settings. +//! `observer_archive_default_enabled()` returns `true` unconditionally. +//! The frontend calls this every startup to decide whether to reconcile the +//! `owner_p` subscription for kind 24200 (observer frames). Kind 24200 events +//! are ephemeral — not stored by the relay — so local archiving is the only +//! way to retain them. -/// Returns `true` when observer-feed archive policy is enforced. +/// Returns `true`: observer-feed archive defaults to enabled for all builds. /// -/// True when the build has the internal baked flag set, or when the running -/// binary is using the dev nest (`~/.buzz-dev`). The frontend calls this -/// every startup to decide whether to reconcile the `owner_p` subscription. +/// The frontend reconciles the `owner_p` subscription every startup when this +/// returns `true`. A user who has explicitly disabled the toggle keeps it off +/// because the Settings card's explicit-opt-out path deletes the subscription +/// and the seed hook skips identities that already have an explicit choice. #[tauri::command] pub fn observer_archive_default_enabled() -> bool { - option_env!("BUZZ_DESKTOP_BUILD_OBSERVER_ARCHIVE_DEFAULT").is_some() - || crate::managed_agents::nest_is_dev() -} - -#[cfg(test)] -mod tests { - use super::*; - - // `nest_is_dev()` is deterministic-false in unit tests: NEST_DIR OnceLock - // is uninitialized → falls back to prod `~/.buzz` (nest.rs:101-106), so - // the compiled flag is the sole variable. No runner normalization needed. - // - // #[ignore]: requires BUZZ_TEST_EXPECTED_OBSERVER_ARCHIVE_DEFAULT to be - // set — `just desktop-tauri-test-compiled-flags` runs it explicitly with - // `--ignored` under both compile states; general `cargo test` skips it. - #[test] - #[ignore] - fn test_observer_archive_default_enabled_matches_expected() { - let result = observer_archive_default_enabled(); - let expected_str = std::env::var("BUZZ_TEST_EXPECTED_OBSERVER_ARCHIVE_DEFAULT").expect( - "BUZZ_TEST_EXPECTED_OBSERVER_ARCHIVE_DEFAULT must be set — \ - the dual-compile CI step supplies it; bare `cargo test` is \ - not sufficient to validate compiled-flag behavior", - ); - let expected = expected_str == "true" || expected_str == "1"; - assert_eq!( - result, expected, - "observer_archive_default_enabled() returned {result}, \ - expected {expected} (BUZZ_TEST_EXPECTED_OBSERVER_ARCHIVE_DEFAULT={expected_str:?})" - ); - } + true } diff --git a/desktop/src-tauri/src/managed_agents/nest.rs b/desktop/src-tauri/src/managed_agents/nest.rs index c8f008836d..a57676f0a9 100644 --- a/desktop/src-tauri/src/managed_agents/nest.rs +++ b/desktop/src-tauri/src/managed_agents/nest.rs @@ -106,31 +106,6 @@ pub fn nest_dir() -> Option { } } -/// Returns `true` iff `path` ends with the dev-nest directory name (`.buzz-dev`). -/// -/// Pure function — no globals — so it can be unit-tested without touching the -/// process-lifetime [`NEST_DIR`] `OnceLock`. -fn path_is_dev_nest(path: &std::path::Path) -> bool { - path.file_name() - .and_then(|n| n.to_str()) - .map(|n| n == NEST_DIR_DEV) - .unwrap_or(false) -} - -/// Returns `true` when the running binary is using the dev nest (`~/.buzz-dev`). -/// -/// This is `true` for all dev builds — `just staging` and `just dev` — because -/// [`init_nest_dir`] is called with `is_dev = true` when the Tauri app-data -/// directory starts with `"xyz.block.buzz.app.dev"`. -/// -/// Returns `false` when: -/// - The nest is the production nest (`~/.buzz`, signed DMG). -/// - [`init_nest_dir`] has not been called yet (unit tests, home dir -/// unresolvable) — the fallback path is always the prod nest. -pub fn nest_is_dev() -> bool { - nest_dir().map(|p| path_is_dev_nest(&p)).unwrap_or(false) -} - /// Creates the Buzz nest at `~/.buzz` if it doesn't already exist. /// /// Delegates to [`ensure_nest_at`] with the resolved nest directory. diff --git a/desktop/src-tauri/src/managed_agents/nest/tests.rs b/desktop/src-tauri/src/managed_agents/nest/tests.rs index 031b049a49..cbef171f6f 100644 --- a/desktop/src-tauri/src/managed_agents/nest/tests.rs +++ b/desktop/src-tauri/src/managed_agents/nest/tests.rs @@ -923,39 +923,3 @@ fn refresh_skill_overwrites_on_version_bump() { "SKILL.md must be refreshed on version bump" ); } - -#[test] -fn test_path_is_dev_nest_dev_path_returns_true() { - let path = std::path::Path::new("/Users/someone/.buzz-dev"); - assert!( - path_is_dev_nest(path), - ".buzz-dev path must be identified as dev nest" - ); -} - -#[test] -fn test_path_is_dev_nest_prod_path_returns_false() { - let path = std::path::Path::new("/Users/someone/.buzz"); - assert!( - !path_is_dev_nest(path), - ".buzz path must not be identified as dev nest" - ); -} - -#[test] -fn test_path_is_dev_nest_unrelated_path_returns_false() { - let path = std::path::Path::new("/Users/someone/.buzz-staging"); - assert!( - !path_is_dev_nest(path), - "unrelated path must not be identified as dev nest" - ); -} - -#[test] -fn test_path_is_dev_nest_root_returns_false() { - let path = std::path::Path::new("/"); - assert!( - !path_is_dev_nest(path), - "root path must not be identified as dev nest" - ); -} diff --git a/desktop/src/app/AppShell.tsx b/desktop/src/app/AppShell.tsx index 36e6106c1c..dc71312bd5 100644 --- a/desktop/src/app/AppShell.tsx +++ b/desktop/src/app/AppShell.tsx @@ -193,8 +193,8 @@ export function AppShell() { // guard here would drop managed-agent coverage during startup. useAgentObserverIngestion(); // Kind 24200 is relay-ephemeral, so reconciliation runs eagerly (not - // deferred) and unconditionally repairs the DB subscription on internal - // builds — otherwise frames emitted before the listener opens are lost. + // deferred): seeds kind 24200 for fresh identities, no-ops for explicit + // opt-outs. Frames before the listener opens are permanently lost. const observerReconciled = useObserverArchiveReconciliation( identityQuery.data?.pubkey, ); diff --git a/desktop/src/features/local-archive/observerArchivePreference.ts b/desktop/src/features/local-archive/observerArchivePreference.ts new file mode 100644 index 0000000000..53748e7d8b --- /dev/null +++ b/desktop/src/features/local-archive/observerArchivePreference.ts @@ -0,0 +1,85 @@ +/** + * Persists whether the user has made an explicit choice about the + * observer-frame archive default-on feature. + * + * The key is identity-scoped so toggling off on one identity doesn't suppress + * the default-on for another identity. The value is: + * "1" → user explicitly enabled (or accepted the default) + * "0" → user explicitly disabled + * null → no explicit choice yet (default-on seeding may still fire) + * + * Device-level localStorage — intentionally not reset on community switch + * (the archive subscription itself is identity-scoped in SQLite; this flag + * is just the UI gate that prevents re-seeding after an explicit opt-out). + * + * Storage-error contract: a single read that throws is treated the same as + * a stored "1" (treat-as-set, fail-closed). This matches the metric-archive + * path: a storage error must never cause the seeding guard to fire or allow + * a stored opt-out to be silently overridden. + */ + +const KEY_PREFIX = "buzz:observer-archive-default-seeded"; + +function storageKey(identityPubkey: string): string { + return `${KEY_PREFIX}:${identityPubkey}`; +} + +/** + * Reads the stored explicit choice for this identity in a single localStorage + * access. + * + * Returns: + * `false` — user explicitly opted out ("0" stored) + * `true` — user explicitly opted in ("1" stored) + * `"unset"` — no choice recorded yet + * + * On storage error, returns `true` (fail-closed: treat as already opted in, + * suppress auto-seeding, and never override a potentially stored opt-out). + */ +export function readExplicitObserverArchiveChoice( + identityPubkey: string, +): boolean | "unset" { + if (typeof window === "undefined") return true; // SSR/test: treat as set + try { + const raw = window.localStorage.getItem(storageKey(identityPubkey)); + if (raw === null) return "unset"; + return raw !== "0"; + } catch { + return true; // storage error → treat as set, never auto-seed + } +} + +/** + * Mark that the user has made an explicit choice for this identity. + * `enabled` should reflect whether the `owner_p` subscription exists after + * the action (true = seeded/enabled, false = opted out). + */ +export function setExplicitObserverArchiveChoice( + identityPubkey: string, + enabled: boolean, +): void { + if (typeof window === "undefined") return; + try { + window.localStorage.setItem( + storageKey(identityPubkey), + enabled ? "1" : "0", + ); + } catch { + // Best-effort — the seeding guard will re-fire on next startup if storage + // is unavailable, but that is safe (merge_save_subscription_kinds is idempotent). + } +} + +/** + * Clear the explicit choice for this identity (for testing / reset flows). + */ +export function clearExplicitObserverArchiveChoice( + identityPubkey: string, +): void { + if (typeof window === "undefined") return; + try { + window.localStorage.removeItem(storageKey(identityPubkey)); + } catch { + // ignore + } +} diff --git a/desktop/src/features/local-archive/ui/LocalArchiveSettingsCard.tsx b/desktop/src/features/local-archive/ui/LocalArchiveSettingsCard.tsx index fbf8c506eb..f7dd331c2f 100644 --- a/desktop/src/features/local-archive/ui/LocalArchiveSettingsCard.tsx +++ b/desktop/src/features/local-archive/ui/LocalArchiveSettingsCard.tsx @@ -25,8 +25,8 @@ import { SettingsOptionRow, } from "@/features/settings/ui/SettingsOptionGroup"; import { SettingsSectionHeader } from "@/features/settings/ui/SettingsSectionHeader"; -import { observerArchiveDefaultEnabled } from "@/shared/api/tauriArchive"; import { setExplicitAgentMetricArchiveChoice } from "../agentMetricArchivePreference"; +import { setExplicitObserverArchiveChoice } from "../observerArchivePreference"; import { buildSubscriptionRequest, @@ -66,18 +66,16 @@ function kindSummary(kinds: number[]): string { type ObserverSectionProps = { enabled: boolean; - policy: boolean | undefined; toggling: boolean; onToggle: (checked: boolean) => void; }; function ObserverArchiveSection({ enabled, - policy, toggling, onToggle, }: ObserverSectionProps) { - const toggleDisabled = toggling || policy === undefined || policy === true; + const toggleDisabled = toggling; return (

@@ -93,9 +91,7 @@ function ObserverArchiveSection({ Archive my agents' observer frames

- {policy === true - ? `Always on for internal builds. Kind ${KIND_AGENT_OBSERVER_FRAME} observer frames are ephemeral — not stored by the relay — so local archiving is the only way to retain them.` - : `Saves kind ${KIND_AGENT_OBSERVER_FRAME} observer frames addressed to your pubkey. These are ephemeral — not stored by the relay — so local archiving is the only way to retain them.`} + {`Saves kind ${KIND_AGENT_OBSERVER_FRAME} observer frames addressed to your pubkey. These are ephemeral — not stored by the relay — so local archiving is the only way to retain them.`}

(undefined); - - React.useEffect(() => { - observerArchiveDefaultEnabled() - .then((on) => setObserverPolicy(on)) - .catch(() => { - // Fail closed: leave as undefined so toggle stays disabled. - }); - }, []); const pubkey = identityQuery.data?.pubkey ?? ""; @@ -465,7 +450,6 @@ export function LocalArchiveSettingsCard() { const handleObserverToggle = React.useCallback( async (checked: boolean) => { if (!pubkey) return; - if (!checked && observerPolicy !== false) return; setObserverToggling(true); try { if (checked) { @@ -473,6 +457,7 @@ export function LocalArchiveSettingsCard() { } else { await removeSaveSubscriptionKind(KIND_AGENT_OBSERVER_FRAME); } + setExplicitObserverArchiveChoice(pubkey, checked); toast.success( checked ? "Observer feed archive enabled." @@ -489,7 +474,7 @@ export function LocalArchiveSettingsCard() { setObserverToggling(false); } }, - [pubkey, observerPolicy, reload], + [pubkey, reload], ); const handleMetricToggle = React.useCallback( @@ -539,7 +524,6 @@ export function LocalArchiveSettingsCard() { void handleObserverToggle(checked)} - policy={observerPolicy} toggling={observerToggling} /> diff --git a/desktop/src/features/local-archive/useAgentMetricArchiveSeed.test.mjs b/desktop/src/features/local-archive/useAgentMetricArchiveSeed.test.mjs index 6a832b4bd3..e19b80a7f9 100644 --- a/desktop/src/features/local-archive/useAgentMetricArchiveSeed.test.mjs +++ b/desktop/src/features/local-archive/useAgentMetricArchiveSeed.test.mjs @@ -1,8 +1,8 @@ /** * Tests for useAgentMetricArchiveSeed seeding logic. * - * Mirrors the pattern in useObserverArchiveSeed.test.mjs — drives the async - * seed logic via the deps-injection interface, no React required. + * Archive defaults to enabled for all builds. The seed fires for any identity + * without an explicit prior choice. */ import assert from "node:assert/strict"; @@ -10,16 +10,11 @@ import test from "node:test"; // ── Fake deps factory ──────────────────────────────────────────────────────── -function makeDeps({ - defaultOn = false, - hasExplicitChoice = false, - mergeShouldFail = false, -} = {}) { +function makeDeps({ hasExplicitChoice = false, mergeShouldFail = false } = {}) { const calls = { mergeSaveSubscriptionKinds: [], setExplicitChoice: [] }; return { calls, - agentMetricArchiveDefaultEnabled: async () => defaultOn, mergeSaveSubscriptionKinds: async (kind) => { if (mergeShouldFail) throw new Error("merge failed"); calls.mergeSaveSubscriptionKinds.push({ kind }); @@ -39,15 +34,6 @@ async function runSeed(pubkey, deps) { if (!pubkey) return; if (deps.hasExplicitChoice(pubkey)) return; - let defaultOn; - try { - defaultOn = await deps.agentMetricArchiveDefaultEnabled(); - } catch { - return; - } - - if (!defaultOn) return; - try { await deps.mergeSaveSubscriptionKinds(KIND_AGENT_TURN_METRIC); } catch { @@ -59,8 +45,8 @@ async function runSeed(pubkey, deps) { // ── Tests ──────────────────────────────────────────────────────────────────── -test("test_internal_build_unset_seeds_owner_p_subscription", async () => { - const deps = makeDeps({ defaultOn: true, hasExplicitChoice: false }); +test("test_default_enabled_seeds_owner_p_subscription", async () => { + const deps = makeDeps({ hasExplicitChoice: false }); await runSeed("pubkey123", deps); assert.equal( @@ -72,8 +58,8 @@ test("test_internal_build_unset_seeds_owner_p_subscription", async () => { assert.equal(call.kind, 44200); }); -test("test_internal_build_unset_persists_explicit_choice_after_seed", async () => { - const deps = makeDeps({ defaultOn: true, hasExplicitChoice: false }); +test("test_default_enabled_persists_explicit_choice_after_seed", async () => { + const deps = makeDeps({ hasExplicitChoice: false }); await runSeed("pubkey123", deps); assert.equal( @@ -86,7 +72,7 @@ test("test_internal_build_unset_persists_explicit_choice_after_seed", async () = }); test("test_explicit_choice_set_does_not_reseed", async () => { - const deps = makeDeps({ defaultOn: true, hasExplicitChoice: true }); + const deps = makeDeps({ hasExplicitChoice: true }); await runSeed("pubkey123", deps); assert.equal( @@ -101,25 +87,8 @@ test("test_explicit_choice_set_does_not_reseed", async () => { ); }); -test("test_oss_build_does_not_seed", async () => { - const deps = makeDeps({ defaultOn: false, hasExplicitChoice: false }); - await runSeed("pubkey123", deps); - - assert.equal( - deps.calls.mergeSaveSubscriptionKinds.length, - 0, - "should not call mergeSaveSubscriptionKinds in OSS build", - ); - assert.equal( - deps.calls.setExplicitChoice.length, - 0, - "should not persist explicit choice in OSS build", - ); -}); - test("test_merge_failure_does_not_persist_explicit_choice", async () => { const deps = makeDeps({ - defaultOn: true, hasExplicitChoice: false, mergeShouldFail: true, }); @@ -133,7 +102,7 @@ test("test_merge_failure_does_not_persist_explicit_choice", async () => { }); test("test_empty_pubkey_does_nothing", async () => { - const deps = makeDeps({ defaultOn: true, hasExplicitChoice: false }); + const deps = makeDeps({ hasExplicitChoice: false }); await runSeed("", deps); assert.equal(deps.calls.mergeSaveSubscriptionKinds.length, 0); @@ -141,7 +110,7 @@ test("test_empty_pubkey_does_nothing", async () => { }); test("test_undefined_pubkey_does_nothing", async () => { - const deps = makeDeps({ defaultOn: true, hasExplicitChoice: false }); + const deps = makeDeps({ hasExplicitChoice: false }); await runSeed(undefined, deps); assert.equal(deps.calls.mergeSaveSubscriptionKinds.length, 0); @@ -150,18 +119,9 @@ test("test_undefined_pubkey_does_nothing", async () => { // ── Concurrent-interleave test ─────────────────────────────────────────────── // -// Verifies the scenario Paul identified: on an internal-build first run with -// both flags on and no prior owner_p row, the observer and metric seeds race. -// With the old TS-side list+merge+create pattern the interleave could be: -// -// 1. observer seed: await list() → [] -// 2. metric seed: await list() → [] (row not yet written) -// 3. observer writes [24200] -// 4. metric writes [44200] → clobbers 24200 -// -// The new pattern delegates the merge to Rust under a single SQLite tx. -// Here we model that by tracking a shared "db state" and verifying that -// running both seeds concurrently (Promise.all) leaves both kinds present. +// Verifies the scenario where both observer and metric seeds race on first +// run. With the atomic merge, running both seeds concurrently leaves both +// kinds present. test("test_concurrent_seeds_both_kinds_survive", async () => { // Shared in-memory "db" — the atomic merge impl would serialize via SQLite @@ -169,9 +129,8 @@ test("test_concurrent_seeds_both_kinds_survive", async () => { // the final state. const db = new Set(); // kinds present after all merges - function makeConcurrentDeps(defaultOn = true) { + function makeConcurrentDeps() { return { - agentMetricArchiveDefaultEnabled: async () => defaultOn, // Simulates the atomic merge: each call simply adds its kind to the set, // regardless of what was there before (atomicity guarantee). mergeSaveSubscriptionKinds: async (kind) => { @@ -189,13 +148,6 @@ test("test_concurrent_seeds_both_kinds_survive", async () => { async function runObserverSeed(pubkey, deps) { if (!pubkey) return; if (deps.hasExplicitChoice(pubkey)) return; - let defaultOn; - try { - defaultOn = await deps.agentMetricArchiveDefaultEnabled(); - } catch { - return; - } - if (!defaultOn) return; try { await deps.mergeSaveSubscriptionKinds(24200); } catch { diff --git a/desktop/src/features/local-archive/useAgentMetricArchiveSeed.ts b/desktop/src/features/local-archive/useAgentMetricArchiveSeed.ts index 520d0e6603..2cadaa2605 100644 --- a/desktop/src/features/local-archive/useAgentMetricArchiveSeed.ts +++ b/desktop/src/features/local-archive/useAgentMetricArchiveSeed.ts @@ -1,27 +1,23 @@ /** * First-run seeding for agent-turn-metric archive. * - * When an internal build has `BUZZ_BUILD_AGENT_METRIC_ARCHIVE_DEFAULT` set and - * the current identity has not yet made an explicit choice, this hook - * auto-creates an `owner_p` save subscription including kind 44200 agent turn - * metrics, scoped to the current identity's pubkey. + * Archive defaults to enabled for all builds. When the current identity has + * not yet made an explicit choice, this hook auto-creates an `owner_p` save + * subscription including kind 44200 agent turn metrics, scoped to the current + * identity's pubkey. * * Uses `mergeSaveSubscriptionKinds` (atomic DB-side merge) so a concurrently * running observer seed (24200) cannot clobber this kind — the union happens * under a single SQLite transaction regardless of await ordering. * - * OSS builds return `false` from `agent_metric_archive_default_enabled` → - * no-op. After any explicit user action (seeding or opt-out), the localStorage - * flag prevents re-seeding on subsequent starts. + * After any explicit user action (seeding or opt-out), the localStorage flag + * prevents re-seeding on subsequent starts. */ import * as React from "react"; import { KIND_AGENT_TURN_METRIC } from "@/shared/constants/kinds"; -import { - mergeSaveSubscriptionKinds, - agentMetricArchiveDefaultEnabled, -} from "@/shared/api/tauriArchive"; +import { mergeSaveSubscriptionKinds } from "@/shared/api/tauriArchive"; import { hasExplicitAgentMetricArchiveChoice, setExplicitAgentMetricArchiveChoice, @@ -31,14 +27,12 @@ import { * Deps interface for testing. Production callers pass nothing. */ export interface AgentMetricArchiveSeedDeps { - agentMetricArchiveDefaultEnabled: () => Promise; mergeSaveSubscriptionKinds: (kind: number) => Promise; hasExplicitChoice: (pubkey: string) => boolean; setExplicitChoice: (pubkey: string, enabled: boolean) => void; } const defaultDeps: AgentMetricArchiveSeedDeps = { - agentMetricArchiveDefaultEnabled, mergeSaveSubscriptionKinds, hasExplicitChoice: hasExplicitAgentMetricArchiveChoice, setExplicitChoice: setExplicitAgentMetricArchiveChoice, @@ -46,7 +40,7 @@ const defaultDeps: AgentMetricArchiveSeedDeps = { /** * Seed the agent-turn-metric archive subscription for `pubkey` once per - * identity per device on internal builds. + * identity per device. * * @param pubkey - current identity pubkey. When undefined (identity not yet * loaded), the hook waits until it becomes available. @@ -69,23 +63,7 @@ export function useAgentMetricArchiveSeed( // boundary — re-guard here so the call below is type-safe. if (!pubkey) return; - let defaultOn: boolean; - try { - defaultOn = await deps.agentMetricArchiveDefaultEnabled(); - } catch (err) { - console.warn("[useAgentMetricArchiveSeed] flag check failed:", err); - return; - } - - if (cancelled) return; - - if (!defaultOn) { - // OSS build (flag off): don't persist a choice — leave null so seeding - // can still fire if this identity later runs an internal build. - return; - } - - // Internal build + no prior choice → auto-seed via atomic DB merge. + // Auto-seed via atomic DB merge. try { await deps.mergeSaveSubscriptionKinds(KIND_AGENT_TURN_METRIC); } catch (err) { diff --git a/desktop/src/features/local-archive/useObserverArchiveSeed.test.mjs b/desktop/src/features/local-archive/useObserverArchiveSeed.test.mjs index ccd2498929..2cf8df00ad 100644 --- a/desktop/src/features/local-archive/useObserverArchiveSeed.test.mjs +++ b/desktop/src/features/local-archive/useObserverArchiveSeed.test.mjs @@ -10,23 +10,29 @@ import { ArchiveSyncManager } from "./archiveSyncManager.ts"; // ── Fake deps factory ──────────────────────────────────────────────────────── -function makeDeps({ - policyOn = false, - mergeShouldFail = false, - flagShouldFail = false, -} = {}) { +function makeDeps({ mergeShouldFail = false, explicitChoice = "unset" } = {}) { const calls = { merge: [] }; + // Simulates a per-pubkey localStorage map. "unset" means no choice stored. + const choices = new Map(); + if (explicitChoice !== "unset") { + // Pre-populate a choice for any pubkey that asks (single-pubkey tests). + choices.set("__default__", explicitChoice); + } return { calls, - observerArchiveDefaultEnabled: async () => { - if (flagShouldFail) throw new Error("flag check failed"); - return policyOn; - }, mergeSaveSubscriptionKinds: async (kind) => { if (mergeShouldFail) throw new Error("merge failed"); calls.merge.push({ kind }); }, + readExplicitChoice: (pubkey) => { + if (choices.has(pubkey)) return choices.get(pubkey); + if (choices.has("__default__")) return choices.get("__default__"); + return "unset"; + }, + setExplicitChoice: (pubkey, enabled) => { + choices.set(pubkey, enabled); + }, }; } @@ -35,50 +41,111 @@ function tick() { return new Promise((r) => setTimeout(r, 0)); } -// ── Internal policy build ──────────────────────────────────────────────────── +// ── Reconciliation always seeds 24200 ──────────────────────────────────────── -test("test_internal_policy_seeds_24200", async () => { - const deps = makeDeps({ policyOn: true }); - await reconcileObserverArchive(deps); +test("test_reconcile_always_seeds_24200", async () => { + const deps = makeDeps(); + await reconcileObserverArchive("pk1", deps); assert.equal(deps.calls.merge.length, 1); assert.equal(deps.calls.merge[0].kind, 24200); }); -// ── OSS build — policy-off is a pure no-op ────────────────────────────────── - -test("test_oss_policy_off_no_merge", async () => { - const deps = makeDeps({ policyOn: false }); - await reconcileObserverArchive(deps); - - assert.equal(deps.calls.merge.length, 0, "OSS must not merge"); -}); - // ── Failure behavior ───────────────────────────────────────────────────────── test("test_merge_failure_rejects", async () => { - const deps = makeDeps({ policyOn: true, mergeShouldFail: true }); + const deps = makeDeps({ mergeShouldFail: true }); - await assert.rejects(() => reconcileObserverArchive(deps), { + await assert.rejects(() => reconcileObserverArchive("pk1", deps), { message: "merge failed", }); }); -test("test_flag_check_failure_rejects", async () => { - const deps = makeDeps({ flagShouldFail: true }); +// ── Explicit opt-out survives restart ──────────────────────────────────────── - await assert.rejects(() => reconcileObserverArchive(deps), { - message: "flag check failed", - }); - assert.equal(deps.calls.merge.length, 0); +test("test_reconcile_explicit_optout_skips_merge", async () => { + // Simulate a user who explicitly opted out (choice stored as false). + const deps = makeDeps({ explicitChoice: false }); + await reconcileObserverArchive("pk1", deps); + + assert.equal( + deps.calls.merge.length, + 0, + "merge must NOT fire when user has explicitly opted out", + ); +}); + +test("test_reconcile_storage_error_treated_as_fail_closed", async () => { + // Storage errors return `true` (not "unset"), which means reconcile + // treats them as an already-set choice and skips the merge. + // This prevents auto-seeding from silently overriding a stored opt-out + // that we couldn't read due to the error. + const deps = makeDeps(); + // Override readExplicitChoice to simulate a storage error returning `true`. + deps.readExplicitChoice = () => true; + + await reconcileObserverArchive("pk1", deps); + + assert.equal( + deps.calls.merge.length, + 0, + "merge must NOT fire when storage error returns fail-closed true", + ); +}); + +test("test_reconcile_explicit_optin_already_seeded_skips_merge", async () => { + // Simulate a user who already has an explicit opt-in recorded (already + // seeded on a prior run). The new tri-state model skips merge for any + // non-"unset" choice — re-merging is idempotent but wasteful. + const deps = makeDeps({ explicitChoice: true }); + await reconcileObserverArchive("pk1", deps); + + assert.equal( + deps.calls.merge.length, + 0, + "merge must NOT fire when choice is already recorded as opted-in", + ); +}); + +test("test_reconcile_no_prior_choice_seeds_and_records_choice", async () => { + const deps = makeDeps(); // no explicitChoice set + await reconcileObserverArchive("pk1", deps); + + assert.equal(deps.calls.merge.length, 1, "merge must fire on first run"); + // After reconciliation the choice should now be recorded as true. + assert.equal( + deps.readExplicitChoice("pk1"), + true, + "stored choice must be true after seed", + ); +}); + +test("test_reconcile_toggle_off_then_restart_does_not_remerge", async () => { + // This is the exact failure mode Paul described: + // 1. User toggles OFF → setExplicitChoice("pk1", false) + // 2. App restarts → reconcileObserverArchive runs again + // 3. Expected: merge is NOT called (opt-out preserved) + const deps = makeDeps(); + + // Simulate the card's handleObserverToggle(false) path: explicit opt-out stored. + deps.setExplicitChoice("pk1", false); + + // Simulate app restart — reconciliation fires. + await reconcileObserverArchive("pk1", deps); + + assert.equal( + deps.calls.merge.length, + 0, + "merge must NOT fire after explicit opt-out on app restart", + ); }); // ── Startup ordering (real ArchiveSyncManager + real reconciler) ───────────── test("test_archive_sync_blocked_until_reconciliation", async () => { - let resolveFlag; - const flagPromise = new Promise((resolve) => { - resolveFlag = resolve; + let resolveMerge; + const mergePromise = new Promise((resolve) => { + resolveMerge = resolve; }); const subscribeCalls = []; @@ -90,8 +157,9 @@ test("test_archive_sync_blocked_until_reconciliation", async () => { }; const reconcilerDeps = { - observerArchiveDefaultEnabled: () => flagPromise, - mergeSaveSubscriptionKinds: async () => {}, + mergeSaveSubscriptionKinds: () => mergePromise, + readExplicitChoice: () => "unset", + setExplicitChoice: () => {}, }; const manager = new ArchiveSyncManager({ @@ -110,8 +178,8 @@ test("test_archive_sync_blocked_until_reconciliation", async () => { onSubscriptionChange: () => () => {}, }); - // Start reconciliation (pending — flag check not yet resolved). - const reconciling = reconcileObserverArchive(reconcilerDeps); + // Start reconciliation (pending — merge not yet resolved). + const reconciling = reconcileObserverArchive("pk1", reconcilerDeps); // Before reconciliation resolves, manager must not have been started. await tick(); @@ -122,7 +190,7 @@ test("test_archive_sync_blocked_until_reconciliation", async () => { ); // Resolve reconciliation — now start the manager (simulating the gate). - resolveFlag(true); + resolveMerge(); await reconciling; await manager.start(); @@ -139,7 +207,7 @@ test("test_archive_sync_blocked_until_reconciliation", async () => { }); test("test_archive_sync_blocked_on_reconciliation_rejection", async () => { - const reconcilerDeps = makeDeps({ policyOn: true, mergeShouldFail: true }); + const reconcilerDeps = makeDeps({ mergeShouldFail: true }); const subscribeCalls = []; const fakeRelay = { @@ -168,7 +236,7 @@ test("test_archive_sync_blocked_on_reconciliation_rejection", async () => { // Reconciliation rejects — gate must remain closed. let rejected = false; try { - await reconcileObserverArchive(reconcilerDeps); + await reconcileObserverArchive("pk1", reconcilerDeps); } catch { rejected = true; } @@ -207,8 +275,8 @@ test("test_identity_change_resets_readiness", async () => { let reconciledPubkey = null; // Identity A reconciles successfully. - const depsA = makeDeps({ policyOn: true }); - await reconcileObserverArchive(depsA); + const depsA = makeDeps(); + await reconcileObserverArchive("pkA", depsA); reconciledPubkey = "pkA"; assert.equal( isReconciledFor(reconciledPubkey, "pkA"), @@ -224,8 +292,8 @@ test("test_identity_change_resets_readiness", async () => { ); // B reconciles successfully. - const depsB = makeDeps({ policyOn: true }); - await reconcileObserverArchive(depsB); + const depsB = makeDeps(); + await reconcileObserverArchive("pkB", depsB); reconciledPubkey = "pkB"; assert.equal( isReconciledFor(reconciledPubkey, "pkB"), @@ -243,14 +311,14 @@ test("test_identity_change_b_failure_stays_closed", async () => { let reconciledPubkey = null; // Identity A reconciles successfully. - const depsA = makeDeps({ policyOn: true }); - await reconcileObserverArchive(depsA); + const depsA = makeDeps(); + await reconcileObserverArchive("pkA", depsA); reconciledPubkey = "pkA"; // Identity changes to B — B's reconciliation fails. - const depsB = makeDeps({ policyOn: true, mergeShouldFail: true }); + const depsB = makeDeps({ mergeShouldFail: true }); try { - await reconcileObserverArchive(depsB); + await reconcileObserverArchive("pkB", depsB); reconciledPubkey = "pkB"; } catch { // B failed — reconciledPubkey stays "pkA" (stale). @@ -272,7 +340,7 @@ test("test_identity_change_b_failure_stays_closed", async () => { // re-running an effect with new deps (identity switch). test("test_startReconciliation_calls_onReady_after_success", async () => { - const deps = makeDeps({ policyOn: true }); + const deps = makeDeps(); const readyCalls = []; startReconciliation("pk1", deps, (pubkey) => readyCalls.push(pubkey)); @@ -283,13 +351,14 @@ test("test_startReconciliation_calls_onReady_after_success", async () => { }); test("test_startReconciliation_unmount_before_resolve_suppresses_onReady", async () => { - let resolveFlag; - const flagPromise = new Promise((resolve) => { - resolveFlag = resolve; + let resolveMerge; + const mergePromise = new Promise((resolve) => { + resolveMerge = resolve; }); const deps = { - observerArchiveDefaultEnabled: () => flagPromise, - mergeSaveSubscriptionKinds: async () => {}, + mergeSaveSubscriptionKinds: () => mergePromise, + readExplicitChoice: () => "unset", + setExplicitChoice: () => {}, }; const readyCalls = []; @@ -297,9 +366,9 @@ test("test_startReconciliation_unmount_before_resolve_suppresses_onReady", async readyCalls.push(pubkey), ); - // Unmount (or re-run effect) before the flag check resolves. + // Unmount (or re-run effect) before the merge resolves. cancel(); - resolveFlag(true); + resolveMerge(); await tick(); assert.deepEqual( @@ -310,15 +379,16 @@ test("test_startReconciliation_unmount_before_resolve_suppresses_onReady", async }); test("test_startReconciliation_identity_switch_stale_completion_suppressed", async () => { - let resolveFlagA; - const flagPromiseA = new Promise((resolve) => { - resolveFlagA = resolve; + let resolveMergeA; + const mergePromiseA = new Promise((resolve) => { + resolveMergeA = resolve; }); const depsA = { - observerArchiveDefaultEnabled: () => flagPromiseA, - mergeSaveSubscriptionKinds: async () => {}, + mergeSaveSubscriptionKinds: () => mergePromiseA, + readExplicitChoice: () => "unset", + setExplicitChoice: () => {}, }; - const depsB = makeDeps({ policyOn: true }); + const depsB = makeDeps(); const readyCalls = []; const onReady = (pubkey) => readyCalls.push(pubkey); @@ -330,8 +400,8 @@ test("test_startReconciliation_identity_switch_stale_completion_suppressed", asy cancelA(); startReconciliation("pkB", depsB, onReady); - // A's flag check now resolves late — its stale completion must not fire. - resolveFlagA(true); + // A's merge now resolves late — its stale completion must not fire. + resolveMergeA(); await tick(); assert.deepEqual( @@ -342,7 +412,7 @@ test("test_startReconciliation_identity_switch_stale_completion_suppressed", asy }); test("test_startReconciliation_failure_does_not_call_onReady", async () => { - const deps = makeDeps({ policyOn: true, mergeShouldFail: true }); + const deps = makeDeps({ mergeShouldFail: true }); const readyCalls = []; startReconciliation("pk1", deps, (pubkey) => readyCalls.push(pubkey)); @@ -354,8 +424,8 @@ test("test_startReconciliation_failure_does_not_call_onReady", async () => { // ── Metric seed independence ───────────────────────────────────────────────── test("test_metric_seed_remains_independently_deferrable", async () => { - const deps = makeDeps({ policyOn: true }); - await reconcileObserverArchive(deps); + const deps = makeDeps(); + await reconcileObserverArchive("pk1", deps); assert.equal(deps.calls.merge.length, 1); assert.equal(deps.calls.merge[0].kind, 24200, "must only touch kind 24200"); diff --git a/desktop/src/features/local-archive/useObserverArchiveSeed.ts b/desktop/src/features/local-archive/useObserverArchiveSeed.ts index 7fc87f010c..6a70ed2093 100644 --- a/desktop/src/features/local-archive/useObserverArchiveSeed.ts +++ b/desktop/src/features/local-archive/useObserverArchiveSeed.ts @@ -1,40 +1,46 @@ import * as React from "react"; import { KIND_AGENT_OBSERVER_FRAME } from "@/shared/constants/kinds"; +import { mergeSaveSubscriptionKinds } from "@/shared/api/tauriArchive"; import { - mergeSaveSubscriptionKinds, - observerArchiveDefaultEnabled, -} from "@/shared/api/tauriArchive"; + readExplicitObserverArchiveChoice, + setExplicitObserverArchiveChoice, +} from "./observerArchivePreference"; export interface ObserverArchiveSeedDeps { - observerArchiveDefaultEnabled: () => Promise; mergeSaveSubscriptionKinds: (kind: number) => Promise; + readExplicitChoice: (pubkey: string) => boolean | "unset"; + setExplicitChoice: (pubkey: string, enabled: boolean) => void; } const defaultDeps: ObserverArchiveSeedDeps = { - observerArchiveDefaultEnabled, mergeSaveSubscriptionKinds, + readExplicitChoice: readExplicitObserverArchiveChoice, + setExplicitChoice: setExplicitObserverArchiveChoice, }; /** * Reconcile observer-feed archive state for the current identity. * - * Internal builds (policy flag ON): unconditionally ensure kind 24200 exists - * in the DB subscription. - * - * OSS builds (policy flag OFF): no-op. The Settings toggle is the only - * mutation path for OSS users. + * Archive defaults to enabled for all builds. Merges kind 24200 into the + * DB subscription via an atomic DB-side merge — UNLESS the user has + * previously made an explicit opt-out choice for this identity, in which + * case we skip the merge to preserve their preference across restarts. * * Rejects on failure — callers must not open archive listeners against * unreconciled state. */ export async function reconcileObserverArchive( + pubkey: string, deps: ObserverArchiveSeedDeps = defaultDeps, ): Promise { - const policyOn = await deps.observerArchiveDefaultEnabled(); - if (!policyOn) return; - + const choice = deps.readExplicitChoice(pubkey); + // Any explicit choice (or a storage error treated as fail-closed) skips the + // merge: opted-out users stay opted out; already-seeded users stay seeded. + if (choice !== "unset") return; + // No prior choice: seed the default-on subscription and record it. await deps.mergeSaveSubscriptionKinds(KIND_AGENT_OBSERVER_FRAME); + deps.setExplicitChoice(pubkey, true); } /** @@ -73,7 +79,7 @@ export function startReconciliation( ): () => void { let cancelled = false; - reconcileObserverArchive(deps) + reconcileObserverArchive(pubkey, deps) .then(() => { if (!cancelled) onReady(pubkey); }) diff --git a/desktop/src/shared/api/tauriArchive.ts b/desktop/src/shared/api/tauriArchive.ts index 0e4be6c9ac..40f4a2e3ae 100644 --- a/desktop/src/shared/api/tauriArchive.ts +++ b/desktop/src/shared/api/tauriArchive.ts @@ -88,23 +88,22 @@ function decodeRawSubscription(raw: RawSaveSubscription): SaveSubscription { // ── API wrappers ───────────────────────────────────────────────────────────── /** - * Returns `true` when observer-feed archive policy is enforced. + * Returns `true` when observer-feed archive is enabled by default. * - * Internal builds set `BUZZ_BUILD_OBSERVER_ARCHIVE_DEFAULT` at build time; - * OSS builds never set it, so this returns `false`. The frontend calls this - * every startup to decide whether to reconcile the `owner_p` subscription. + * Always returns `true` — archive defaults to enabled for all builds. + * The frontend calls this every startup to decide whether to reconcile + * the `owner_p` subscription for kind 24200 (observer frames). */ export async function observerArchiveDefaultEnabled(): Promise { return invokeTauri("observer_archive_default_enabled"); } /** - * Returns `true` when the build has agent-turn-metric archive default-on. + * Returns `true` when agent-turn-metric archive is enabled by default. * - * Internal builds set `BUZZ_BUILD_AGENT_METRIC_ARCHIVE_DEFAULT` at build time; - * OSS builds never set it, so this returns `false`. The frontend calls this - * once at startup to decide whether to auto-seed an `owner_p` [44200] - * subscription. + * Always returns `true` — archive defaults to enabled for all builds. + * The frontend calls this once at startup to decide whether to auto-seed + * an `owner_p` [44200] subscription for new identities. */ export async function agentMetricArchiveDefaultEnabled(): Promise { return invokeTauri("agent_metric_archive_default_enabled"); diff --git a/desktop/src/testing/e2eBridge.ts b/desktop/src/testing/e2eBridge.ts index dc46d50ae5..13ba5ed9c6 100644 --- a/desktop/src/testing/e2eBridge.ts +++ b/desktop/src/testing/e2eBridge.ts @@ -401,21 +401,6 @@ type E2eConfig = { // Seed rows returned by `list_save_subscriptions`. Each entry uses the same // snake_case wire shape the Rust backend returns so tests can drive the // LocalArchiveSettingsCard without a real SQLite database. - observerArchiveDefaultEnabled?: boolean; - /** - * Delay (ms) applied to `observer_archive_default_enabled` so E2E tests - * can exercise short-lived loading UI. 0/undefined = instant. Prefer the - * explicit defer/release seam for pending-state ordering assertions. - */ - observerArchiveDefaultEnabledDelayMs?: number; - /** Hold the observer policy command until the E2E release seam is called. */ - deferObserverArchiveDefaultEnabled?: boolean; - /** - * When set, `observer_archive_default_enabled` throws with this message - * instead of resolving — drives the fail-closed `.catch()` path in - * `useObserverArchiveReconciliation` / `LocalArchiveSettingsCard`. - */ - observerArchiveDefaultEnabledError?: string; agentMetricArchiveDefaultEnabled?: boolean; saveSubscriptions?: Array<{ scope_type: string; @@ -1298,10 +1283,6 @@ declare global { /** Count of `get_event` invocations for the current defer-target ID since * the last time `__BUZZ_E2E_DEFER_GET_EVENT__` was set. */ __BUZZ_E2E_GET_EVENT_CALL_COUNT__?: number; - /** Release every deferred observer archive policy command. */ - __BUZZ_E2E_RELEASE_OBSERVER_ARCHIVE_POLICY__?: () => number; - /** Number of observer archive policy commands currently held by the seam. */ - __BUZZ_E2E_OBSERVER_ARCHIVE_POLICY_PENDING__?: number; /** Hold the next channel read until released. */ __BUZZ_E2E_DEFER_NEXT_CHANNELS_READ__?: () => void; /** Disarm the latch and release the held channel read, if any. */ @@ -1411,7 +1392,6 @@ type DeferredGetEvent = { run: () => Promise; }; let deferredGetEventQueue: DeferredGetEvent[] = []; -let deferredObserverArchivePolicyQueue: Array<() => void> = []; let deferNextChannelsRead = false; let deferredChannelsReadResolve: (() => void) | null = null; @@ -2963,8 +2943,8 @@ let mockManagedAgentRuntimes: MockManagedAgentRuntimeRow[] = []; // mutated by `create_save_subscription` / `delete_save_subscription` / // `merge_save_subscription_kinds` / `remove_save_subscription_kind` exactly // as the real SQLite-backed Rust commands would (see `archive/store.rs`). -// This lets E2E specs drive the fresh-internal-repair path (start from `[]`, -// reconcile, observe a kind-24200 row appear) and OSS toggle ON/OFF, neither +// This lets E2E specs drive the default-on seeding path (start from `[]`, +// reconcile, observe a kind-24200 row appear) and toggle ON/OFF, neither // of which an immutable seed can represent. type MockSaveSubscriptionRow = { scope_type: string; @@ -9996,14 +9976,6 @@ export function maybeInstallE2eTauriMocks() { window.__BUZZ_E2E_GET_EVENT_CALL_COUNT__ = 0; window.__BUZZ_E2E_DEFER_GET_EVENT__ = null; deferredGetEventQueue = []; - deferredObserverArchivePolicyQueue = []; - window.__BUZZ_E2E_OBSERVER_ARCHIVE_POLICY_PENDING__ = 0; - window.__BUZZ_E2E_RELEASE_OBSERVER_ARCHIVE_POLICY__ = () => { - const queued = deferredObserverArchivePolicyQueue.splice(0); - window.__BUZZ_E2E_OBSERVER_ARCHIVE_POLICY_PENDING__ = 0; - for (const resolve of queued) resolve(); - return queued.length; - }; deferNextChannelsRead = false; deferredChannelsReadResolve = null; window.__BUZZ_E2E_CHANNELS_READ_PENDING__ = 0; @@ -12634,7 +12606,7 @@ export function maybeInstallE2eTauriMocks() { // install); create/merge/delete/remove mutate it with the same // union / delete-row-when-empty semantics as the real Rust commands // (see `archive/store.rs::merge_owner_p_kinds` / `remove_owner_p_kind`) - // so specs can drive fresh-internal-repair and toggle ON/OFF flows. + // so specs can drive default-on seeding and toggle ON/OFF flows. case "list_save_subscriptions": { const win = window as unknown as Record; if (!win.__BUZZ_E2E_IPC_COUNTERS__) { @@ -12692,27 +12664,8 @@ export function maybeInstallE2eTauriMocks() { case "archive_events": // Returns the ArchiveBatchResult shape the UI expects. return { persisted: 0, dropped: 0 }; - case "observer_archive_default_enabled": { - if (activeConfig?.mock?.deferObserverArchiveDefaultEnabled) { - await new Promise((resolve) => { - deferredObserverArchivePolicyQueue.push(resolve); - window.__BUZZ_E2E_OBSERVER_ARCHIVE_POLICY_PENDING__ = - deferredObserverArchivePolicyQueue.length; - }); - } - const delayMs = - activeConfig?.mock?.observerArchiveDefaultEnabledDelayMs; - if (delayMs && delayMs > 0) { - await new Promise((resolve) => window.setTimeout(resolve, delayMs)); - } - const error = activeConfig?.mock?.observerArchiveDefaultEnabledError; - if (error) { - throw new Error(error); - } - return activeConfig?.mock?.observerArchiveDefaultEnabled ?? false; - } case "agent_metric_archive_default_enabled": - return activeConfig?.mock?.agentMetricArchiveDefaultEnabled ?? false; + return activeConfig?.mock?.agentMetricArchiveDefaultEnabled ?? true; case "set_prevent_sleep_active": return null; case "plugin:window|is_fullscreen": diff --git a/desktop/tests/e2e/observer-archive-policy.spec.ts b/desktop/tests/e2e/observer-archive-policy.spec.ts index de297df5b6..29b6a6b6e1 100644 --- a/desktop/tests/e2e/observer-archive-policy.spec.ts +++ b/desktop/tests/e2e/observer-archive-policy.spec.ts @@ -14,32 +14,13 @@ async function openLocalArchiveSettings(page: import("@playwright/test").Page) { } test.describe("observer archive policy — Settings toggle", () => { - test("internal policy: toggle disabled with policy-locked copy", async ({ + test("fresh identity: observer toggle is enabled and checked by default", async ({ page, }) => { + // Archive is default-on for all builds. A fresh identity (no stored opt-out) + // should show the toggle enabled and checked after reconciliation seeds the + // kind-24200 subscription. await installMockBridge(page, { - observerArchiveDefaultEnabled: true, - saveSubscriptions: [ - { - scope_type: "owner_p", - scope_value: "deadbeef".repeat(8), - kinds: "[24200]", - }, - ], - }); - - const card = await openLocalArchiveSettings(page); - const toggle = card.getByTestId("local-archive-observer-toggle"); - await expect(toggle).toBeVisible({ timeout: 5_000 }); - await expect(toggle).toBeDisabled(); - await expect( - card.getByText(/always on for internal builds/i), - ).toBeVisible(); - }); - - test("OSS policy: toggle is functional", async ({ page }) => { - await installMockBridge(page, { - observerArchiveDefaultEnabled: false, saveSubscriptions: [ { scope_type: "owner_p", @@ -56,114 +37,94 @@ test.describe("observer archive policy — Settings toggle", () => { await expect(toggle).toBeChecked(); }); - test("OSS policy, no subscriptions: toggle enabled and unchecked", async ({ - page, - }) => { - // Resolved-OSS empty-subscription state: no owner_p/24200 row exists, - // so the toggle reads unchecked, and OSS policy (false) keeps it - // enabled — confirming fail-closed doesn't permanently lock OSS users - // out once the policy flag resolves. + test("toggle click OFF disables, then ON re-enables", async ({ page }) => { await installMockBridge(page, { - observerArchiveDefaultEnabled: false, - saveSubscriptions: [], + saveSubscriptions: [ + { + scope_type: "owner_p", + scope_value: "deadbeef".repeat(8), + kinds: "[24200]", + }, + ], }); const card = await openLocalArchiveSettings(page); const toggle = card.getByTestId("local-archive-observer-toggle"); await expect(toggle).toBeVisible({ timeout: 5_000 }); - await expect(toggle).toBeEnabled(); + await expect(toggle).toBeChecked(); + + // OFF: removes kind 24200. + await toggle.click(); await expect(toggle).not.toBeChecked(); + + // ON again: re-creates the row from empty. + await toggle.click(); + await expect(toggle).toBeChecked(); }); - test("policy pending: toggle disabled, then enabled once resolved", async ({ + test("explicit opt-out persists across reload: toggle stays OFF", async ({ page, }) => { - await installMockBridge(page, { - observerArchiveDefaultEnabled: false, - observerArchiveDefaultEnabledDelayMs: 500, - saveSubscriptions: [], - }); - - const card = await openLocalArchiveSettings(page); - const toggle = card.getByTestId("local-archive-observer-toggle"); - await expect(toggle).toBeVisible({ timeout: 5_000 }); - // Fail-closed: disabled while the policy check is still in flight. - await expect(toggle).toBeDisabled(); - await expect(toggle).toBeEnabled({ timeout: 5_000 }); - await expect(toggle).not.toBeChecked(); - }); - - test("policy check fails: toggle stays disabled and issues no mutation", async ({ - page, - }) => { - await installMockBridge(page, { - observerArchiveDefaultEnabled: false, - observerArchiveDefaultEnabledError: "policy check failed", - saveSubscriptions: [], - }); - - const card = await openLocalArchiveSettings(page); - const toggle = card.getByTestId("local-archive-observer-toggle"); - await expect(toggle).toBeVisible({ timeout: 5_000 }); - // Rejection leaves `observerPolicy` at its initial `undefined` — the - // fail-closed `.catch()` in LocalArchiveSettingsCard must not flip it - // to a permissive state. Give the rejection time to settle, then - // assert the disabled state holds (not just "hasn't flipped yet"). - await page.waitForTimeout(200); - await expect(toggle).toBeDisabled(); - - const commands = await page.evaluate( - () => - (window as Window & { __BUZZ_E2E_COMMANDS__?: string[] }) - .__BUZZ_E2E_COMMANDS__ ?? [], + // Simulate a user who previously clicked OFF: the identity-scoped opt-out + // is recorded in localStorage ("0") and the owner_p/24200 subscription row + // is absent. Reconciliation must honour the stored choice and leave the + // toggle unchecked (user can re-enable via the toggle). + const MOCK_PUBKEY = "deadbeef".repeat(8); + await page.addInitScript( + ({ storageKey }) => { + window.localStorage.setItem(storageKey, "0"); + }, + { + storageKey: `buzz:observer-archive-default-seeded:${MOCK_PUBKEY}`, + }, ); - expect( - commands.filter( - (c) => - c === "merge_save_subscription_kinds" || - c === "remove_save_subscription_kind", - ), - ).toEqual([]); - }); - test("OSS policy: toggle click ON merges kind 24200, click OFF removes the row", async ({ - page, - }) => { await installMockBridge(page, { - observerArchiveDefaultEnabled: false, saveSubscriptions: [], }); const card = await openLocalArchiveSettings(page); const toggle = card.getByTestId("local-archive-observer-toggle"); await expect(toggle).toBeVisible({ timeout: 5_000 }); + await expect(toggle).toBeEnabled(); await expect(toggle).not.toBeChecked(); + }); - // ON: merges kind 24200 into a fresh owner_p row (the row-creation edge - // of merge_save_subscription_kinds). - await toggle.click(); + test("no subscriptions, no stored choice: defaults ON then OFF removes, ON re-creates", async ({ + page, + }) => { + // A fresh identity with no stored choice and an empty subscription table + // must be seeded to ON by reconciliation. Thereafter the toggle must + // function: OFF removes kind 24200, ON re-creates it. + await installMockBridge(page, { + saveSubscriptions: [], + }); + + const card = await openLocalArchiveSettings(page); + const toggle = card.getByTestId("local-archive-observer-toggle"); + await expect(toggle).toBeVisible({ timeout: 5_000 }); + + // Default-on: reconciliation seeds the row, toggle must be checked. await expect(toggle).toBeChecked(); - // OFF: removes kind 24200. Since it's the row's only kind, the row is - // deleted entirely (remove_save_subscription_kind's row-delete-on-empty - // edge) — re-checking observerEnabled must correctly read "no row" as - // unchecked, not stale/checked. + // OFF: removes kind 24200. await toggle.click(); await expect(toggle).not.toBeChecked(); - // ON again: re-creates the row from empty, proving the delete above was - // a real row removal and not a lingering empty-kinds row. + // ON again: re-creates the row from empty. await toggle.click(); await expect(toggle).toBeChecked(); }); }); test.describe("observer archive policy — reconciliation gate", () => { - test("internal policy: archive sync reaches subscription path after reconciliation", async ({ + test("archive sync reaches subscription path after reconciliation", async ({ page, }) => { + // The reconciliation gate (useObserverArchiveReconciliation) must resolve + // successfully for a fresh identity, allowing useArchiveSync to start the + // ArchiveSyncManager, which calls list_save_subscriptions. await installMockBridge(page, { - observerArchiveDefaultEnabled: true, saveSubscriptions: [ { scope_type: "owner_p", @@ -175,15 +136,12 @@ test.describe("observer archive policy — reconciliation gate", () => { await page.goto("/", { waitUntil: "domcontentloaded" }); - // Wait for the channel list to appear (proves AppShell mounted fully). + // Wait for the channel list (proves AppShell mounted fully). await expect(page.getByTestId("channel-general")).toBeVisible({ timeout: 10_000, }); - // The reconciliation gate (useObserverArchiveReconciliation) must have - // resolved successfully, allowing useArchiveSync to start the - // ArchiveSyncManager, which calls list_save_subscriptions. The IPC - // counter proves the subscription path was reached. + // The IPC counter proves the subscription path was reached. await page.waitForFunction( () => { const counters = (window as Record) @@ -201,8 +159,8 @@ test.describe("observer archive policy — reconciliation gate", () => { }); expect(count).toBeGreaterThan(0); - // Bonus (Thufir pass 2, F4): the reconciliation gate must also result - // in a real `#p` + kind-24200 live REQ filter, not just an IPC call. + // The reconciliation gate must also result in a real `#p` + kind-24200 + // live REQ filter. const hasOwnerKindSubscription = await page.evaluate( (ownerPubkey) => ( @@ -221,170 +179,12 @@ test.describe("observer archive policy — reconciliation gate", () => { expect(hasOwnerKindSubscription).toBe(true); }); - test("policy pending: no subscription list call or live filter until resolved", async ({ + test("fresh install with empty subscriptions: reconciliation seeds kind 24200", async ({ page, }) => { + // A fresh install with no owner_p/24200 row must end up with one after + // startup reconciliation runs — the actual production repair path. await installMockBridge(page, { - observerArchiveDefaultEnabled: true, - deferObserverArchiveDefaultEnabled: true, - saveSubscriptions: [ - { - scope_type: "owner_p", - scope_value: "deadbeef".repeat(8), - kinds: "[24200]", - }, - ], - }); - - await page.goto("/", { waitUntil: "domcontentloaded" }); - await page.waitForFunction( - () => - ( - window as Window & { - __BUZZ_E2E_OBSERVER_ARCHIVE_POLICY_PENDING__?: number; - } - ).__BUZZ_E2E_OBSERVER_ARCHIVE_POLICY_PENDING__ === 1, - ); - await expect(page.getByTestId("channel-general")).toBeVisible({ - timeout: 10_000, - }); - - // While the policy check is pending, useArchiveSync must not have - // started — no list_save_subscriptions call, no owner/24200 live - // filter. This is the discriminating half pass 2 found missing: the - // prior test only proved "eventually starts", not "doesn't start - // early." - const countWhilePending = await page.evaluate( - () => - ( - (window as Record).__BUZZ_E2E_IPC_COUNTERS__ as - | Record - | undefined - )?.list_save_subscriptions ?? 0, - ); - expect(countWhilePending).toBe(0); - const hasSubscriptionWhilePending = await page.evaluate( - (ownerPubkey) => - ( - window as Window & { - __BUZZ_E2E_HAS_MOCK_OWNER_KIND_SUBSCRIPTION__?: (input: { - ownerPubkey: string; - kind: number; - }) => boolean; - } - ).__BUZZ_E2E_HAS_MOCK_OWNER_KIND_SUBSCRIPTION__?.({ - ownerPubkey, - kind: 24200, - }) ?? false, - "deadbeef".repeat(8), - ); - expect(hasSubscriptionWhilePending).toBe(false); - - const released = await page.evaluate( - () => - ( - window as Window & { - __BUZZ_E2E_RELEASE_OBSERVER_ARCHIVE_POLICY__?: () => number; - } - ).__BUZZ_E2E_RELEASE_OBSERVER_ARCHIVE_POLICY__?.() ?? 0, - ); - expect(released).toBe(1); - - // After the policy resolves, both the IPC call and the live filter - // appear. - await page.waitForFunction( - () => - (( - (window as Record).__BUZZ_E2E_IPC_COUNTERS__ as - | Record - | undefined - )?.list_save_subscriptions ?? 0) > 0, - null, - { timeout: 10_000 }, - ); - await expect - .poll( - () => - page.evaluate( - (ownerPubkey) => - ( - window as Window & { - __BUZZ_E2E_HAS_MOCK_OWNER_KIND_SUBSCRIPTION__?: (input: { - ownerPubkey: string; - kind: number; - }) => boolean; - } - ).__BUZZ_E2E_HAS_MOCK_OWNER_KIND_SUBSCRIPTION__?.({ - ownerPubkey, - kind: 24200, - }) ?? false, - "deadbeef".repeat(8), - ), - { timeout: 5_000 }, - ) - .toBe(true); - }); - - test("policy check fails: subscription path never opens", async ({ - page, - }) => { - await installMockBridge(page, { - observerArchiveDefaultEnabled: true, - observerArchiveDefaultEnabledError: "policy check failed", - saveSubscriptions: [ - { - scope_type: "owner_p", - scope_value: "deadbeef".repeat(8), - kinds: "[24200]", - }, - ], - }); - - await page.goto("/", { waitUntil: "domcontentloaded" }); - await expect(page.getByTestId("channel-general")).toBeVisible({ - timeout: 10_000, - }); - - // Give the rejected reconciliation time to settle, then assert the - // gate stayed shut: no list_save_subscriptions call, no live filter. - await page.waitForTimeout(500); - const count = await page.evaluate( - () => - ( - (window as Record).__BUZZ_E2E_IPC_COUNTERS__ as - | Record - | undefined - )?.list_save_subscriptions ?? 0, - ); - expect(count).toBe(0); - const hasSubscription = await page.evaluate( - (ownerPubkey) => - ( - window as Window & { - __BUZZ_E2E_HAS_MOCK_OWNER_KIND_SUBSCRIPTION__?: (input: { - ownerPubkey: string; - kind: number; - }) => boolean; - } - ).__BUZZ_E2E_HAS_MOCK_OWNER_KIND_SUBSCRIPTION__?.({ - ownerPubkey, - kind: 24200, - }) ?? false, - "deadbeef".repeat(8), - ); - expect(hasSubscription).toBe(false); - }); - - test("fresh internal install: reconciliation repairs an empty subscription list", async ({ - page, - }) => { - // The actual production repair path Will's bug report was about: a - // fresh internal install with no owner_p/24200 row yet must end up - // with one after startup reconciliation runs — not just "no-op - // because the row was already there" (the prior fixture always - // pre-seeded the row). - await installMockBridge(page, { - observerArchiveDefaultEnabled: true, saveSubscriptions: [], }); diff --git a/desktop/tests/helpers/bridge.ts b/desktop/tests/helpers/bridge.ts index d9e2c68e9d..5e90e5a7f9 100644 --- a/desktop/tests/helpers/bridge.ts +++ b/desktop/tests/helpers/bridge.ts @@ -308,28 +308,6 @@ type MockBridgeOptions = { websocketConnectErrors?: string[]; stallWebsocketSends?: boolean; userSearchDelayMs?: number; - /** - * Value returned by the `observer_archive_default_enabled` mock command. - * `true` = internal-policy build (toggle locked ON); `false`/omitted = OSS - * build (toggle functional). Drives LocalArchiveSettingsCard policy state. - */ - observerArchiveDefaultEnabled?: boolean; - /** - * Delay (ms) applied to `observer_archive_default_enabled` so specs can - * exercise short-lived loading UI. Prefer the explicit defer/release seam - * when asserting behavior while the policy check is pending. - */ - observerArchiveDefaultEnabledDelayMs?: number; - /** - * Hold `observer_archive_default_enabled` until the test calls - * `__BUZZ_E2E_RELEASE_OBSERVER_ARCHIVE_POLICY__`. - */ - deferObserverArchiveDefaultEnabled?: boolean; - /** - * When set, `observer_archive_default_enabled` throws with this message — - * drives the fail-closed path when the policy check itself fails. - */ - observerArchiveDefaultEnabledError?: string; // NIP-IA gate inputs — drive the archive-button gate matrix in // tests/e2e/identity-archive.spec.ts. /**