From f9a11a0ca9b0f9744e456adb482abdfc220dd94d Mon Sep 17 00:00:00 2001 From: npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7 Date: Tue, 4 Aug 2026 13:10:59 -0400 Subject: [PATCH 1/5] fix(local-archive): default both archive settings to enabled MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both local archive settings (observer frames kind 24200 and agent turn metrics kind 44200) previously defaulted to OFF in OSS builds via build-time env vars, silently losing data for users who didn't discover the Settings toggle. Observer frames are ephemeral (not stored by the relay), so any missed events are permanently unrecoverable. Make both defaults unconditionally true: - observer_archive_default_enabled() and agent_metric_archive_default_enabled() now return true without any option_env! check. - Remove BUZZ_BUILD_OBSERVER_ARCHIVE_DEFAULT and BUZZ_BUILD_AGENT_METRIC_ARCHIVE_DEFAULT from build.rs (rerun-if-env declarations and baked-env blocks). - Remove the desktop-tauri-test-compiled-flags Justfile recipe and its CI step — the dual-compile test machinery has no purpose now. Existing explicit opt-outs are preserved: the hasExplicitChoice guard in useAgentMetricArchiveSeed and the subscription reconciliation in useObserverArchiveSeed only run for identities that have never made a choice. Simplify the TS seed hooks to remove the policy-flag dep entirely. Update ObserverArchiveSection to remove the policy prop, enable the toggle unconditionally, and drop the stale 'Always on for internal builds' copy. Update e2e mock defaults from false to true. Update both test suites to match. Co-authored-by: Will Pfleger Signed-off-by: Will Pfleger --- .github/workflows/ci.yml | 4 - Justfile | 24 ----- desktop/src-tauri/build.rs | 17 ---- .../src/commands/agent_metric_archive.rs | 41 +++----- .../src/commands/observer_archive.rs | 60 +++--------- .../ui/LocalArchiveSettingsCard.tsx | 24 +---- .../useAgentMetricArchiveSeed.test.mjs | 76 +++------------ .../useAgentMetricArchiveSeed.ts | 40 ++------ .../useObserverArchiveSeed.test.mjs | 93 +++++++------------ .../local-archive/useObserverArchiveSeed.ts | 17 +--- desktop/src/shared/api/tauriArchive.ts | 17 ++-- desktop/src/testing/e2eBridge.ts | 4 +- 12 files changed, 95 insertions(+), 322 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e65157705a..0e8db28083 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -202,10 +202,6 @@ jobs: run: just desktop-tauri-test env: CMAKE_POLICY_VERSION_MINIMUM: "3.5" - - name: Desktop Tauri compiled-flag verification - run: just desktop-tauri-test-compiled-flags - env: - CMAKE_POLICY_VERSION_MINIMUM: "3.5" - name: Upload desktop e2e artifacts if: failure() uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 diff --git a/Justfile b/Justfile index d80341ecac..20225fca0d 100644 --- a/Justfile +++ b/Justfile @@ -212,30 +212,6 @@ desktop-tauri-test: _ensure-sidecar-stubs 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 -# 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 - BUZZ_BUILD_AUTO_CONNECT_DEFAULT_RELAY=1 \ - BUZZ_TEST_EXPECTED_AUTO_CONNECT_DEFAULT_RELAY=true \ - cargo test compiled_flag_matches_expected -- --ignored --nocapture - echo "Both compiled states verified." - # Build the full desktop Tauri app locally (unsigned, for testing) # Sidecar binary list must stay in sync with _ensure-sidecar-stubs above. # pnpm install is unconditional here: release builds must start from a clean dep tree. 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/features/local-archive/ui/LocalArchiveSettingsCard.tsx b/desktop/src/features/local-archive/ui/LocalArchiveSettingsCard.tsx index fbf8c506eb..17d33931e5 100644 --- a/desktop/src/features/local-archive/ui/LocalArchiveSettingsCard.tsx +++ b/desktop/src/features/local-archive/ui/LocalArchiveSettingsCard.tsx @@ -25,7 +25,6 @@ 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 { @@ -66,18 +65,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 +90,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 +449,6 @@ export function LocalArchiveSettingsCard() { const handleObserverToggle = React.useCallback( async (checked: boolean) => { if (!pubkey) return; - if (!checked && observerPolicy !== false) return; setObserverToggling(true); try { if (checked) { @@ -489,7 +472,7 @@ export function LocalArchiveSettingsCard() { setObserverToggling(false); } }, - [pubkey, observerPolicy, reload], + [pubkey, reload], ); const handleMetricToggle = React.useCallback( @@ -539,7 +522,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..aa13061c5a 100644 --- a/desktop/src/features/local-archive/useObserverArchiveSeed.test.mjs +++ b/desktop/src/features/local-archive/useObserverArchiveSeed.test.mjs @@ -10,19 +10,11 @@ import { ArchiveSyncManager } from "./archiveSyncManager.ts"; // ── Fake deps factory ──────────────────────────────────────────────────────── -function makeDeps({ - policyOn = false, - mergeShouldFail = false, - flagShouldFail = false, -} = {}) { +function makeDeps({ mergeShouldFail = false } = {}) { const calls = { merge: [] }; 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 }); @@ -35,50 +27,32 @@ 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 }); +test("test_reconcile_always_seeds_24200", async () => { + const deps = makeDeps(); await reconcileObserverArchive(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), { message: "merge failed", }); }); -test("test_flag_check_failure_rejects", async () => { - const deps = makeDeps({ flagShouldFail: true }); - - await assert.rejects(() => reconcileObserverArchive(deps), { - message: "flag check failed", - }); - assert.equal(deps.calls.merge.length, 0); -}); - // ── 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 +64,7 @@ test("test_archive_sync_blocked_until_reconciliation", async () => { }; const reconcilerDeps = { - observerArchiveDefaultEnabled: () => flagPromise, - mergeSaveSubscriptionKinds: async () => {}, + mergeSaveSubscriptionKinds: () => mergePromise, }; const manager = new ArchiveSyncManager({ @@ -110,7 +83,7 @@ test("test_archive_sync_blocked_until_reconciliation", async () => { onSubscriptionChange: () => () => {}, }); - // Start reconciliation (pending — flag check not yet resolved). + // Start reconciliation (pending — merge not yet resolved). const reconciling = reconcileObserverArchive(reconcilerDeps); // Before reconciliation resolves, manager must not have been started. @@ -122,7 +95,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 +112,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 = { @@ -207,7 +180,7 @@ test("test_identity_change_resets_readiness", async () => { let reconciledPubkey = null; // Identity A reconciles successfully. - const depsA = makeDeps({ policyOn: true }); + const depsA = makeDeps(); await reconcileObserverArchive(depsA); reconciledPubkey = "pkA"; assert.equal( @@ -224,7 +197,7 @@ test("test_identity_change_resets_readiness", async () => { ); // B reconciles successfully. - const depsB = makeDeps({ policyOn: true }); + const depsB = makeDeps(); await reconcileObserverArchive(depsB); reconciledPubkey = "pkB"; assert.equal( @@ -243,12 +216,12 @@ test("test_identity_change_b_failure_stays_closed", async () => { let reconciledPubkey = null; // Identity A reconciles successfully. - const depsA = makeDeps({ policyOn: true }); + const depsA = makeDeps(); await reconcileObserverArchive(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); reconciledPubkey = "pkB"; @@ -272,7 +245,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 +256,12 @@ 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, }; const readyCalls = []; @@ -297,9 +269,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 +282,14 @@ 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, }; - const depsB = makeDeps({ policyOn: true }); + const depsB = makeDeps(); const readyCalls = []; const onReady = (pubkey) => readyCalls.push(pubkey); @@ -330,8 +301,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 +313,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,7 +325,7 @@ 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 }); + const deps = makeDeps(); await reconcileObserverArchive(deps); assert.equal(deps.calls.merge.length, 1); diff --git a/desktop/src/features/local-archive/useObserverArchiveSeed.ts b/desktop/src/features/local-archive/useObserverArchiveSeed.ts index 7fc87f010c..b534fefa22 100644 --- a/desktop/src/features/local-archive/useObserverArchiveSeed.ts +++ b/desktop/src/features/local-archive/useObserverArchiveSeed.ts @@ -1,29 +1,21 @@ import * as React from "react"; import { KIND_AGENT_OBSERVER_FRAME } from "@/shared/constants/kinds"; -import { - mergeSaveSubscriptionKinds, - observerArchiveDefaultEnabled, -} from "@/shared/api/tauriArchive"; +import { mergeSaveSubscriptionKinds } from "@/shared/api/tauriArchive"; export interface ObserverArchiveSeedDeps { - observerArchiveDefaultEnabled: () => Promise; mergeSaveSubscriptionKinds: (kind: number) => Promise; } const defaultDeps: ObserverArchiveSeedDeps = { - observerArchiveDefaultEnabled, mergeSaveSubscriptionKinds, }; /** * 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. This unconditionally ensures + * kind 24200 exists in the DB subscription via an atomic DB-side merge. * * Rejects on failure — callers must not open archive listeners against * unreconciled state. @@ -31,9 +23,6 @@ const defaultDeps: ObserverArchiveSeedDeps = { export async function reconcileObserverArchive( deps: ObserverArchiveSeedDeps = defaultDeps, ): Promise { - const policyOn = await deps.observerArchiveDefaultEnabled(); - if (!policyOn) return; - await deps.mergeSaveSubscriptionKinds(KIND_AGENT_OBSERVER_FRAME); } 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 e430549d43..42cf9da8ba 100644 --- a/desktop/src/testing/e2eBridge.ts +++ b/desktop/src/testing/e2eBridge.ts @@ -12678,10 +12678,10 @@ export function maybeInstallE2eTauriMocks() { if (error) { throw new Error(error); } - return activeConfig?.mock?.observerArchiveDefaultEnabled ?? false; + return activeConfig?.mock?.observerArchiveDefaultEnabled ?? true; } 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": From 28a8336f506bd690ce32046a873289b0735c4a41 Mon Sep 17 00:00:00 2001 From: npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7 Date: Tue, 4 Aug 2026 13:25:07 -0400 Subject: [PATCH 2/5] fix(local-archive): preserve observer opt-out across restarts reconcileObserverArchive previously merged kind 24200 unconditionally on every startup. Toggling the observer archive off then restarting caused reconciliation to re-enable it silently. Add observerArchivePreference.ts (mirrors agentMetricArchivePreference) with hasExplicitObserverArchiveChoice / getExplicitObserverArchiveChoice / setExplicitObserverArchiveChoice stored in localStorage, keyed by pubkey. Gate reconcileObserverArchive: skip merge when an explicit opt-out is stored. Persist an explicit enabled choice on first-run seeding so the guard is consistent from first startup onward. Wire setExplicitObserverArchiveChoice into handleObserverToggle (both toggle-on and toggle-off), matching the existing metric path. Tests: four new cases covering explicit opt-out, explicit opt-in, first-run seeding + choice persistence, and the toggle-off-then-restart failure mode. Co-authored-by: Will Pfleger Signed-off-by: Will Pfleger --- .../observerArchivePreference.ts | 89 ++++++++++++++ .../ui/LocalArchiveSettingsCard.tsx | 2 + .../useObserverArchiveSeed.test.mjs | 111 ++++++++++++++++-- .../local-archive/useObserverArchiveSeed.ts | 32 ++++- 4 files changed, 221 insertions(+), 13 deletions(-) create mode 100644 desktop/src/features/local-archive/observerArchivePreference.ts diff --git a/desktop/src/features/local-archive/observerArchivePreference.ts b/desktop/src/features/local-archive/observerArchivePreference.ts new file mode 100644 index 0000000000..6302f607b6 --- /dev/null +++ b/desktop/src/features/local-archive/observerArchivePreference.ts @@ -0,0 +1,89 @@ +/** + * 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). + */ + +const KEY_PREFIX = "buzz:observer-archive-default-seeded"; + +function storageKey(identityPubkey: string): string { + return `${KEY_PREFIX}:${identityPubkey}`; +} + +/** + * Returns `true` if the user has already made an explicit choice for this + * identity (either opted in or opted out). When `false`, the seeding path + * may fire. + */ +export function hasExplicitObserverArchiveChoice( + identityPubkey: string, +): boolean { + if (typeof window === "undefined") return true; // SSR/test: treat as set + try { + return window.localStorage.getItem(storageKey(identityPubkey)) !== null; + } catch { + return true; // storage error → treat as set, never auto-seed + } +} + +/** + * Returns the stored choice value, or `null` if no explicit choice has been + * made yet. Callers that only need presence (not direction) should use + * `hasExplicitObserverArchiveChoice`. + */ +export function getExplicitObserverArchiveChoice( + identityPubkey: string, +): boolean | null { + if (typeof window === "undefined") return null; + try { + const raw = window.localStorage.getItem(storageKey(identityPubkey)); + if (raw === null) return null; + return raw !== "0"; + } catch { + return null; + } +} + +/** + * 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 17d33931e5..f7dd331c2f 100644 --- a/desktop/src/features/local-archive/ui/LocalArchiveSettingsCard.tsx +++ b/desktop/src/features/local-archive/ui/LocalArchiveSettingsCard.tsx @@ -26,6 +26,7 @@ import { } from "@/features/settings/ui/SettingsOptionGroup"; import { SettingsSectionHeader } from "@/features/settings/ui/SettingsSectionHeader"; import { setExplicitAgentMetricArchiveChoice } from "../agentMetricArchivePreference"; +import { setExplicitObserverArchiveChoice } from "../observerArchivePreference"; import { buildSubscriptionRequest, @@ -456,6 +457,7 @@ export function LocalArchiveSettingsCard() { } else { await removeSaveSubscriptionKind(KIND_AGENT_OBSERVER_FRAME); } + setExplicitObserverArchiveChoice(pubkey, checked); toast.success( checked ? "Observer feed archive enabled." diff --git a/desktop/src/features/local-archive/useObserverArchiveSeed.test.mjs b/desktop/src/features/local-archive/useObserverArchiveSeed.test.mjs index aa13061c5a..2e0b5369bb 100644 --- a/desktop/src/features/local-archive/useObserverArchiveSeed.test.mjs +++ b/desktop/src/features/local-archive/useObserverArchiveSeed.test.mjs @@ -10,8 +10,14 @@ import { ArchiveSyncManager } from "./archiveSyncManager.ts"; // ── Fake deps factory ──────────────────────────────────────────────────────── -function makeDeps({ mergeShouldFail = false } = {}) { +function makeDeps({ mergeShouldFail = false, explicitChoice = null } = {}) { const calls = { merge: [] }; + // Simulates a per-pubkey localStorage map. + const choices = new Map(); + if (explicitChoice !== null) { + // Pre-populate a choice for any pubkey that asks (single-pubkey tests). + choices.set("__default__", explicitChoice); + } return { calls, @@ -19,6 +25,17 @@ function makeDeps({ mergeShouldFail = false } = {}) { if (mergeShouldFail) throw new Error("merge failed"); calls.merge.push({ kind }); }, + hasExplicitChoice: (pubkey) => + choices.has(pubkey) || + (choices.has("__default__") && explicitChoice !== null), + getExplicitChoice: (pubkey) => { + if (choices.has(pubkey)) return choices.get(pubkey); + if (choices.has("__default__")) return choices.get("__default__"); + return null; + }, + setExplicitChoice: (pubkey, enabled) => { + choices.set(pubkey, enabled); + }, }; } @@ -31,7 +48,7 @@ function tick() { test("test_reconcile_always_seeds_24200", async () => { const deps = makeDeps(); - await reconcileObserverArchive(deps); + await reconcileObserverArchive("pk1", deps); assert.equal(deps.calls.merge.length, 1); assert.equal(deps.calls.merge[0].kind, 24200); @@ -42,11 +59,76 @@ test("test_reconcile_always_seeds_24200", async () => { test("test_merge_failure_rejects", async () => { const deps = makeDeps({ mergeShouldFail: true }); - await assert.rejects(() => reconcileObserverArchive(deps), { + await assert.rejects(() => reconcileObserverArchive("pk1", deps), { message: "merge failed", }); }); +// ── Explicit opt-out survives restart ──────────────────────────────────────── + +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_explicit_optin_still_merges", async () => { + // Simulate a user who explicitly opted in (choice stored as true). + const deps = makeDeps({ explicitChoice: true }); + await reconcileObserverArchive("pk1", deps); + + assert.equal( + deps.calls.merge.length, + 1, + "merge must fire when user explicitly opted in", + ); + assert.equal(deps.calls.merge[0].kind, 24200); +}); + +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 set (to true). + assert.equal( + deps.hasExplicitChoice("pk1"), + true, + "choice must be persisted after first-run seed", + ); + assert.equal( + deps.getExplicitChoice("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 () => { @@ -65,6 +147,9 @@ test("test_archive_sync_blocked_until_reconciliation", async () => { const reconcilerDeps = { mergeSaveSubscriptionKinds: () => mergePromise, + hasExplicitChoice: () => false, + getExplicitChoice: () => null, + setExplicitChoice: () => {}, }; const manager = new ArchiveSyncManager({ @@ -84,7 +169,7 @@ test("test_archive_sync_blocked_until_reconciliation", async () => { }); // Start reconciliation (pending — merge not yet resolved). - const reconciling = reconcileObserverArchive(reconcilerDeps); + const reconciling = reconcileObserverArchive("pk1", reconcilerDeps); // Before reconciliation resolves, manager must not have been started. await tick(); @@ -141,7 +226,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; } @@ -181,7 +266,7 @@ test("test_identity_change_resets_readiness", async () => { // Identity A reconciles successfully. const depsA = makeDeps(); - await reconcileObserverArchive(depsA); + await reconcileObserverArchive("pkA", depsA); reconciledPubkey = "pkA"; assert.equal( isReconciledFor(reconciledPubkey, "pkA"), @@ -198,7 +283,7 @@ test("test_identity_change_resets_readiness", async () => { // B reconciles successfully. const depsB = makeDeps(); - await reconcileObserverArchive(depsB); + await reconcileObserverArchive("pkB", depsB); reconciledPubkey = "pkB"; assert.equal( isReconciledFor(reconciledPubkey, "pkB"), @@ -217,13 +302,13 @@ test("test_identity_change_b_failure_stays_closed", async () => { // Identity A reconciles successfully. const depsA = makeDeps(); - await reconcileObserverArchive(depsA); + await reconcileObserverArchive("pkA", depsA); reconciledPubkey = "pkA"; // Identity changes to B — B's reconciliation fails. const depsB = makeDeps({ mergeShouldFail: true }); try { - await reconcileObserverArchive(depsB); + await reconcileObserverArchive("pkB", depsB); reconciledPubkey = "pkB"; } catch { // B failed — reconciledPubkey stays "pkA" (stale). @@ -262,6 +347,9 @@ test("test_startReconciliation_unmount_before_resolve_suppresses_onReady", async }); const deps = { mergeSaveSubscriptionKinds: () => mergePromise, + hasExplicitChoice: () => false, + getExplicitChoice: () => null, + setExplicitChoice: () => {}, }; const readyCalls = []; @@ -288,6 +376,9 @@ test("test_startReconciliation_identity_switch_stale_completion_suppressed", asy }); const depsA = { mergeSaveSubscriptionKinds: () => mergePromiseA, + hasExplicitChoice: () => false, + getExplicitChoice: () => null, + setExplicitChoice: () => {}, }; const depsB = makeDeps(); const readyCalls = []; @@ -326,7 +417,7 @@ test("test_startReconciliation_failure_does_not_call_onReady", async () => { test("test_metric_seed_remains_independently_deferrable", async () => { const deps = makeDeps(); - await reconcileObserverArchive(deps); + 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 b534fefa22..d68f998736 100644 --- a/desktop/src/features/local-archive/useObserverArchiveSeed.ts +++ b/desktop/src/features/local-archive/useObserverArchiveSeed.ts @@ -2,28 +2,54 @@ import * as React from "react"; import { KIND_AGENT_OBSERVER_FRAME } from "@/shared/constants/kinds"; import { mergeSaveSubscriptionKinds } from "@/shared/api/tauriArchive"; +import { + getExplicitObserverArchiveChoice, + hasExplicitObserverArchiveChoice, + setExplicitObserverArchiveChoice, +} from "./observerArchivePreference"; export interface ObserverArchiveSeedDeps { mergeSaveSubscriptionKinds: (kind: number) => Promise; + hasExplicitChoice: (pubkey: string) => boolean; + getExplicitChoice: (pubkey: string) => boolean | null; + setExplicitChoice: (pubkey: string, enabled: boolean) => void; } const defaultDeps: ObserverArchiveSeedDeps = { mergeSaveSubscriptionKinds, + hasExplicitChoice: hasExplicitObserverArchiveChoice, + getExplicitChoice: getExplicitObserverArchiveChoice, + setExplicitChoice: setExplicitObserverArchiveChoice, }; /** * Reconcile observer-feed archive state for the current identity. * - * Archive defaults to enabled for all builds. This unconditionally ensures - * kind 24200 exists in the DB subscription via an atomic DB-side merge. + * 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 { + // If the user explicitly opted out, honour that choice and skip the merge. + if ( + deps.hasExplicitChoice(pubkey) && + deps.getExplicitChoice(pubkey) === false + ) { + return; + } await deps.mergeSaveSubscriptionKinds(KIND_AGENT_OBSERVER_FRAME); + // Record that this identity has been seeded (explicit choice = true) so + // future reconciliation calls are also guarded correctly. + if (!deps.hasExplicitChoice(pubkey)) { + deps.setExplicitChoice(pubkey, true); + } } /** @@ -62,7 +88,7 @@ export function startReconciliation( ): () => void { let cancelled = false; - reconcileObserverArchive(deps) + reconcileObserverArchive(pubkey, deps) .then(() => { if (!cancelled) onReady(pubkey); }) From 3bda5b14e58d80792ebad997ca446276a9ca2f91 Mon Sep 17 00:00:00 2001 From: npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7 Date: Tue, 4 Aug 2026 13:59:00 -0400 Subject: [PATCH 3/5] fix(desktop): clear Thufir pass-1 findings on archive defaults PR MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove dead nest_is_dev() / path_is_dev_nest() helpers and their four orphaned tests (no caller outside nest.rs since the build-flag code was dropped in the prior commit) - Collapse observer preference storage to a single tri-state read (readExplicitObserverArchiveChoice → true | false | "unset"); storage errors now fail-closed (return true, skip merge) matching the metric path - Update ObserverArchiveSeedDeps interface: replace hasExplicitChoice + getExplicitChoice with single readExplicitChoice; reconcileObserverArchive skips merge for any non-"unset" result - Update useObserverArchiveSeed.test.mjs: migrate all makeDeps factories and inline deps objects to new interface; fix test that expected re-merge on already-seeded opt-in (wrong under new model); add storage-error path test - Rewrite observer-archive-policy.spec.ts around the default-on model: drop internal/OSS/policy-pending/error cases; add fresh-identity-defaults-on, toggle-off-persists, toggle-on-off-cycle, reconciliation-gate, and fresh-install-seeding tests - Remove stale bridge seams: observerArchiveDefaultEnabled, observerArchiveDefaultEnabledDelayMs, deferObserverArchiveDefaultEnabled, observerArchiveDefaultEnabledError from bridge.ts types, e2eBridge.ts mock config type, window global declarations, deferredObserverArchivePolicyQueue let, install-reset block, and observer_archive_default_enabled IPC case Co-authored-by: Will Pfleger Signed-off-by: Will Pfleger --- desktop/src-tauri/src/managed_agents/nest.rs | 25 -- .../src/managed_agents/nest/tests.rs | 36 --- .../observerArchivePreference.ts | 42 ++- .../useObserverArchiveSeed.test.mjs | 60 ++-- .../local-archive/useObserverArchiveSeed.ts | 27 +- desktop/src/testing/e2eBridge.ts | 47 --- .../tests/e2e/observer-archive-policy.spec.ts | 278 +++--------------- desktop/tests/helpers/bridge.ts | 22 -- 8 files changed, 95 insertions(+), 442 deletions(-) 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/features/local-archive/observerArchivePreference.ts b/desktop/src/features/local-archive/observerArchivePreference.ts index 6302f607b6..53748e7d8b 100644 --- a/desktop/src/features/local-archive/observerArchivePreference.ts +++ b/desktop/src/features/local-archive/observerArchivePreference.ts @@ -11,6 +11,11 @@ * 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"; @@ -20,36 +25,27 @@ function storageKey(identityPubkey: string): string { } /** - * Returns `true` if the user has already made an explicit choice for this - * identity (either opted in or opted out). When `false`, the seeding path - * may fire. + * 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 hasExplicitObserverArchiveChoice( +export function readExplicitObserverArchiveChoice( identityPubkey: string, -): boolean { +): boolean | "unset" { if (typeof window === "undefined") return true; // SSR/test: treat as set - try { - return window.localStorage.getItem(storageKey(identityPubkey)) !== null; - } catch { - return true; // storage error → treat as set, never auto-seed - } -} - -/** - * Returns the stored choice value, or `null` if no explicit choice has been - * made yet. Callers that only need presence (not direction) should use - * `hasExplicitObserverArchiveChoice`. - */ -export function getExplicitObserverArchiveChoice( - identityPubkey: string, -): boolean | null { - if (typeof window === "undefined") return null; try { const raw = window.localStorage.getItem(storageKey(identityPubkey)); - if (raw === null) return null; + if (raw === null) return "unset"; return raw !== "0"; } catch { - return null; + return true; // storage error → treat as set, never auto-seed } } diff --git a/desktop/src/features/local-archive/useObserverArchiveSeed.test.mjs b/desktop/src/features/local-archive/useObserverArchiveSeed.test.mjs index 2e0b5369bb..2cf8df00ad 100644 --- a/desktop/src/features/local-archive/useObserverArchiveSeed.test.mjs +++ b/desktop/src/features/local-archive/useObserverArchiveSeed.test.mjs @@ -10,11 +10,11 @@ import { ArchiveSyncManager } from "./archiveSyncManager.ts"; // ── Fake deps factory ──────────────────────────────────────────────────────── -function makeDeps({ mergeShouldFail = false, explicitChoice = null } = {}) { +function makeDeps({ mergeShouldFail = false, explicitChoice = "unset" } = {}) { const calls = { merge: [] }; - // Simulates a per-pubkey localStorage map. + // Simulates a per-pubkey localStorage map. "unset" means no choice stored. const choices = new Map(); - if (explicitChoice !== null) { + if (explicitChoice !== "unset") { // Pre-populate a choice for any pubkey that asks (single-pubkey tests). choices.set("__default__", explicitChoice); } @@ -25,13 +25,10 @@ function makeDeps({ mergeShouldFail = false, explicitChoice = null } = {}) { if (mergeShouldFail) throw new Error("merge failed"); calls.merge.push({ kind }); }, - hasExplicitChoice: (pubkey) => - choices.has(pubkey) || - (choices.has("__default__") && explicitChoice !== null), - getExplicitChoice: (pubkey) => { + readExplicitChoice: (pubkey) => { if (choices.has(pubkey)) return choices.get(pubkey); if (choices.has("__default__")) return choices.get("__default__"); - return null; + return "unset"; }, setExplicitChoice: (pubkey, enabled) => { choices.set(pubkey, enabled); @@ -78,17 +75,36 @@ test("test_reconcile_explicit_optout_skips_merge", async () => { ); }); -test("test_reconcile_explicit_optin_still_merges", async () => { - // Simulate a user who explicitly opted in (choice stored as true). +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, - 1, - "merge must fire when user explicitly opted in", + 0, + "merge must NOT fire when choice is already recorded as opted-in", ); - assert.equal(deps.calls.merge[0].kind, 24200); }); test("test_reconcile_no_prior_choice_seeds_and_records_choice", async () => { @@ -96,14 +112,9 @@ test("test_reconcile_no_prior_choice_seeds_and_records_choice", async () => { await reconcileObserverArchive("pk1", deps); assert.equal(deps.calls.merge.length, 1, "merge must fire on first run"); - // After reconciliation the choice should now be set (to true). - assert.equal( - deps.hasExplicitChoice("pk1"), - true, - "choice must be persisted after first-run seed", - ); + // After reconciliation the choice should now be recorded as true. assert.equal( - deps.getExplicitChoice("pk1"), + deps.readExplicitChoice("pk1"), true, "stored choice must be true after seed", ); @@ -147,8 +158,7 @@ test("test_archive_sync_blocked_until_reconciliation", async () => { const reconcilerDeps = { mergeSaveSubscriptionKinds: () => mergePromise, - hasExplicitChoice: () => false, - getExplicitChoice: () => null, + readExplicitChoice: () => "unset", setExplicitChoice: () => {}, }; @@ -347,8 +357,7 @@ test("test_startReconciliation_unmount_before_resolve_suppresses_onReady", async }); const deps = { mergeSaveSubscriptionKinds: () => mergePromise, - hasExplicitChoice: () => false, - getExplicitChoice: () => null, + readExplicitChoice: () => "unset", setExplicitChoice: () => {}, }; const readyCalls = []; @@ -376,8 +385,7 @@ test("test_startReconciliation_identity_switch_stale_completion_suppressed", asy }); const depsA = { mergeSaveSubscriptionKinds: () => mergePromiseA, - hasExplicitChoice: () => false, - getExplicitChoice: () => null, + readExplicitChoice: () => "unset", setExplicitChoice: () => {}, }; const depsB = makeDeps(); diff --git a/desktop/src/features/local-archive/useObserverArchiveSeed.ts b/desktop/src/features/local-archive/useObserverArchiveSeed.ts index d68f998736..6a70ed2093 100644 --- a/desktop/src/features/local-archive/useObserverArchiveSeed.ts +++ b/desktop/src/features/local-archive/useObserverArchiveSeed.ts @@ -3,22 +3,19 @@ import * as React from "react"; import { KIND_AGENT_OBSERVER_FRAME } from "@/shared/constants/kinds"; import { mergeSaveSubscriptionKinds } from "@/shared/api/tauriArchive"; import { - getExplicitObserverArchiveChoice, - hasExplicitObserverArchiveChoice, + readExplicitObserverArchiveChoice, setExplicitObserverArchiveChoice, } from "./observerArchivePreference"; export interface ObserverArchiveSeedDeps { mergeSaveSubscriptionKinds: (kind: number) => Promise; - hasExplicitChoice: (pubkey: string) => boolean; - getExplicitChoice: (pubkey: string) => boolean | null; + readExplicitChoice: (pubkey: string) => boolean | "unset"; setExplicitChoice: (pubkey: string, enabled: boolean) => void; } const defaultDeps: ObserverArchiveSeedDeps = { mergeSaveSubscriptionKinds, - hasExplicitChoice: hasExplicitObserverArchiveChoice, - getExplicitChoice: getExplicitObserverArchiveChoice, + readExplicitChoice: readExplicitObserverArchiveChoice, setExplicitChoice: setExplicitObserverArchiveChoice, }; @@ -37,19 +34,13 @@ export async function reconcileObserverArchive( pubkey: string, deps: ObserverArchiveSeedDeps = defaultDeps, ): Promise { - // If the user explicitly opted out, honour that choice and skip the merge. - if ( - deps.hasExplicitChoice(pubkey) && - deps.getExplicitChoice(pubkey) === false - ) { - 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); - // Record that this identity has been seeded (explicit choice = true) so - // future reconciliation calls are also guarded correctly. - if (!deps.hasExplicitChoice(pubkey)) { - deps.setExplicitChoice(pubkey, true); - } + deps.setExplicitChoice(pubkey, true); } /** diff --git a/desktop/src/testing/e2eBridge.ts b/desktop/src/testing/e2eBridge.ts index 42cf9da8ba..0b5599e74c 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; @@ -1292,10 +1277,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. */ @@ -1405,7 +1386,6 @@ type DeferredGetEvent = { run: () => Promise; }; let deferredGetEventQueue: DeferredGetEvent[] = []; -let deferredObserverArchivePolicyQueue: Array<() => void> = []; let deferNextChannelsRead = false; let deferredChannelsReadResolve: (() => void) | null = null; @@ -9979,14 +9959,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; @@ -12661,25 +12633,6 @@ 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 ?? true; - } case "agent_metric_archive_default_enabled": return activeConfig?.mock?.agentMetricArchiveDefaultEnabled ?? true; case "set_prevent_sleep_active": diff --git a/desktop/tests/e2e/observer-archive-policy.spec.ts b/desktop/tests/e2e/observer-archive-policy.spec.ts index de297df5b6..96268fb51c 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,85 +51,38 @@ 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 already clicked OFF and whose opt-out is persisted + // (no owner_p/24200 row in the DB). The toggle must remain unchecked + // and enabled (user can re-enable) rather than re-seeding on load. 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: toggle ON merges kind 24200, toggle OFF removes it", async ({ page, }) => { await installMockBridge(page, { - observerArchiveDefaultEnabled: false, saveSubscriptions: [], }); @@ -139,31 +91,28 @@ test.describe("observer archive policy — Settings 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). + // ON: merges kind 24200 into a fresh owner_p row. await toggle.click(); 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 (row-delete-on-empty edge). 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, proving the delete was real. 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 +124,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 +147,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 +167,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. /** From 64ee835a22513c2414719480b0c47f2e0da161cc Mon Sep 17 00:00:00 2001 From: npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7 Date: Tue, 4 Aug 2026 14:26:16 -0400 Subject: [PATCH 4/5] fix(local-archive): fix smoke spec and stale comments for default-on model MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The two failing smoke tests encoded the old default-off assumption: seeding saveSubscriptions:[] and expecting the toggle OFF. Under the new model, empty subscriptions + no recorded choice = seed 24200 (ON). observer-archive-policy.spec.ts: - explicit-opt-out test: seed the identity-scoped localStorage key (buzz:observer-archive-default-seeded: = "0") before navigation so reconciliation honours the stored choice and leaves the toggle unchecked. - no-subscriptions test: invert premise — assert default-on seeding (toggle checked) first, then exercise OFF removal and ON re-creation. Spec result: 6 passed, 0 failed (pnpm build:e2e + playwright smoke). Stale comments updated: - AppShell.tsx: replace "unconditionally repairs on internal builds" with build-agnostic description (default-on seeding / opt-out no-op). - e2eBridge.ts: replace "fresh-internal-repair" / "OSS toggle" framing with default-on seeding and toggle ON/OFF language. Co-authored-by: Will Pfleger Signed-off-by: Will Pfleger --- desktop/src/app/AppShell.tsx | 4 +-- desktop/src/testing/e2eBridge.ts | 6 ++-- .../tests/e2e/observer-archive-policy.spec.ts | 30 +++++++++++++------ 3 files changed, 26 insertions(+), 14 deletions(-) diff --git a/desktop/src/app/AppShell.tsx b/desktop/src/app/AppShell.tsx index 41bcf04bf7..2fe286e74c 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/testing/e2eBridge.ts b/desktop/src/testing/e2eBridge.ts index 0b5599e74c..a091546188 100644 --- a/desktop/src/testing/e2eBridge.ts +++ b/desktop/src/testing/e2eBridge.ts @@ -2936,8 +2936,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; @@ -12575,7 +12575,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__) { diff --git a/desktop/tests/e2e/observer-archive-policy.spec.ts b/desktop/tests/e2e/observer-archive-policy.spec.ts index 96268fb51c..29b6a6b6e1 100644 --- a/desktop/tests/e2e/observer-archive-policy.spec.ts +++ b/desktop/tests/e2e/observer-archive-policy.spec.ts @@ -65,9 +65,20 @@ test.describe("observer archive policy — Settings toggle", () => { test("explicit opt-out persists across reload: toggle stays OFF", async ({ page, }) => { - // Simulate a user who already clicked OFF and whose opt-out is persisted - // (no owner_p/24200 row in the DB). The toggle must remain unchecked - // and enabled (user can re-enable) rather than re-seeding on load. + // 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, { saveSubscriptions: [], }); @@ -79,9 +90,12 @@ test.describe("observer archive policy — Settings toggle", () => { await expect(toggle).not.toBeChecked(); }); - test("no subscriptions: toggle ON merges kind 24200, toggle OFF removes it", 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, { saveSubscriptions: [], }); @@ -89,17 +103,15 @@ 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).not.toBeChecked(); - // ON: merges kind 24200 into a fresh owner_p row. - await toggle.click(); + // Default-on: reconciliation seeds the row, toggle must be checked. await expect(toggle).toBeChecked(); - // OFF: removes kind 24200 (row-delete-on-empty edge). + // OFF: removes kind 24200. await toggle.click(); await expect(toggle).not.toBeChecked(); - // ON again: re-creates the row, proving the delete was real. + // ON again: re-creates the row from empty. await toggle.click(); await expect(toggle).toBeChecked(); }); From ae5917c9b1b6a629a14465c6bf651222c120a9bf Mon Sep 17 00:00:00 2001 From: npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7 Date: Tue, 4 Aug 2026 15:11:08 -0400 Subject: [PATCH 5/5] fix(local-archive): restore compiled-flag CI for auto-connect default The deleted desktop-tauri-test-compiled-flags recipe covered two flags: observer-archive (now removed) and auto-connect (still present). Removing the full recipe orphaned the compiled_flag_matches_expected test in identity.rs with no invoker. Restore a slimmed recipe covering only BUZZ_BUILD_AUTO_CONNECT_DEFAULT_RELAY: - Clean state: unset flag, expect false - Internal state: flag set, expect true build.rs rerun-if-env-changed triggers real recompilation between passes. Restore the corresponding Desktop Tauri compiled-flag verification step in ci.yml so the coverage runs in CI on every Desktop Core build. Co-authored-by: Will Pfleger Signed-off-by: Will Pfleger --- .github/workflows/ci.yml | 4 ++++ Justfile | 17 +++++++++++++++++ 2 files changed, 21 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0e8db28083..e65157705a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -202,6 +202,10 @@ jobs: run: just desktop-tauri-test env: CMAKE_POLICY_VERSION_MINIMUM: "3.5" + - name: Desktop Tauri compiled-flag verification + run: just desktop-tauri-test-compiled-flags + env: + CMAKE_POLICY_VERSION_MINIMUM: "3.5" - name: Upload desktop e2e artifacts if: failure() uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 diff --git a/Justfile b/Justfile index 20225fca0d..c3d755ffeb 100644 --- a/Justfile +++ b/Justfile @@ -212,6 +212,23 @@ desktop-tauri-test: _ensure-sidecar-stubs 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 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_AUTO_CONNECT_DEFAULT_RELAY \ + BUZZ_TEST_EXPECTED_AUTO_CONNECT_DEFAULT_RELAY=false \ + cargo test compiled_flag_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 + echo "Both compiled states verified." + # Build the full desktop Tauri app locally (unsigned, for testing) # Sidecar binary list must stay in sync with _ensure-sidecar-stubs above. # pnpm install is unconditional here: release builds must start from a clean dep tree.