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,11 +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", @@ -31,15 +33,12 @@ test.describe("observer archive policy — Settings toggle", () => { 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(); + await expect(toggle).toBeEnabled(); + await expect(toggle).toBeChecked(); }); - test("OSS policy: toggle is functional", async ({ page }) => { + test("toggle click OFF disables, then ON re-enables", async ({ page }) => { await installMockBridge(page, { - observerArchiveDefaultEnabled: false, saveSubscriptions: [ { scope_type: "owner_p", @@ -52,118 +51,80 @@ test.describe("observer archive policy — Settings toggle", () => { 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(); - }); - - 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. - 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(); + // OFF: removes kind 24200. + await toggle.click(); await expect(toggle).not.toBeChecked(); - }); - - test("policy pending: toggle disabled, then enabled once resolved", 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(); + // ON again: re-creates the row from empty. + await toggle.click(); + await expect(toggle).toBeChecked(); }); - test("policy check fails: toggle stays disabled and issues no mutation", async ({ + test("explicit opt-out persists across reload: toggle stays OFF", async ({ page, }) => { + // 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}`, + }, + ); + 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__ ?? [], - ); - expect( - commands.filter( - (c) => - c === "merge_save_subscription_kinds" || - c === "remove_save_subscription_kind", - ), - ).toEqual([]); + await expect(toggle).toBeEnabled(); + await expect(toggle).not.toBeChecked(); }); - test("OSS policy: toggle click ON merges kind 24200, click OFF removes the row", async ({ + 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, { - 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).not.toBeChecked(); - // ON: merges kind 24200 into a fresh owner_p row (the row-creation edge - // of merge_save_subscription_kinds). - await toggle.click(); + // 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 ({ - page, - }) => { - 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 ({ + test("fresh install with empty subscriptions: reconciliation seeds kind 24200", 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). + // 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, 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. /**