fix(desktop): enforce observer archive policy reconciliation on internal builds (#1923)

Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
This commit is contained in:
Will Pfleger
2026-07-16 15:47:35 -04:00
committed by GitHub
parent 1eae8575ed
commit 8969745549
13 changed files with 577 additions and 224 deletions

View File

@@ -169,6 +169,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

View File

@@ -193,6 +193,23 @@ desktop-tauri-check: _ensure-sidecar-stubs
desktop-tauri-test: _ensure-sidecar-stubs
cd desktop/src-tauri && cargo test
# 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 \
BUZZ_TEST_EXPECTED_OBSERVER_ARCHIVE_DEFAULT=false \
cargo test observer_archive_default_enabled_matches_expected -- --ignored --nocapture
echo "=== Internal build (flag set) → expect true ==="
BUZZ_BUILD_OBSERVER_ARCHIVE_DEFAULT=1 \
BUZZ_TEST_EXPECTED_OBSERVER_ARCHIVE_DEFAULT=true \
cargo test observer_archive_default_enabled_matches_expected -- --ignored --nocapture
echo "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.

View File

@@ -107,6 +107,7 @@ export default defineConfig({
"**/agent-lifecycle-feedback.spec.ts",
"**/inbox-live-update.spec.ts",
"**/mesh-compute.spec.ts",
"**/observer-archive-policy.spec.ts",
],
use: {
...devices["Desktop Chrome"],

View File

@@ -1,4 +1,4 @@
//! Build-time flag and runtime dev-nest check for observer-feed archive default.
//! Build-time flag and runtime dev-nest check for observer-feed archive policy.
//!
//! `observer_archive_default_enabled()` returns `true` when either:
//! - `BUZZ_BUILD_OBSERVER_ARCHIVE_DEFAULT` was set at build time (internal
@@ -6,17 +6,18 @@
//! - 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 auto-seeds an `owner_p` save subscription for the
//! current identity on first run, so the observer-feed archive is on by default.
//! 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 auto-seeding; the user opts in manually via the Local Archive settings card.
//! no reconciliation; the user manages the subscription via Settings.
/// Returns `true` when observer-feed archive should default to on.
/// Returns `true` when observer-feed archive policy is enforced.
///
/// 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 once
/// at startup to decide whether to seed the `owner_p` save subscription.
/// binary is using the dev nest (`~/.buzz-dev`). The frontend calls this
/// every startup to decide whether to reconcile the `owner_p` subscription.
#[tauri::command]
pub fn observer_archive_default_enabled() -> bool {
option_env!("BUZZ_DESKTOP_BUILD_OBSERVER_ARCHIVE_DEFAULT").is_some()
@@ -27,15 +28,27 @@ pub fn observer_archive_default_enabled() -> bool {
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]
fn test_observer_archive_default_enabled_returns_bool() {
// The command must return a plain bool without panicking.
// Whether it's true or false depends on the build environment;
// what we assert here is just that the return type is correct and
// the function is callable.
#[ignore]
fn test_observer_archive_default_enabled_matches_expected() {
let result = observer_archive_default_enabled();
// In a standard OSS/test build (no BUZZ_DESKTOP_BUILD_OBSERVER_ARCHIVE_DEFAULT
// baked in), this should be false.
assert!(!result, "expected false in OSS/test build");
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:?})"
);
}
}

View File

@@ -54,7 +54,7 @@ import {
} from "@/features/user-status/hooks";
import { useCommunityEmojiLiveUpdates } from "@/features/custom-emoji/hooks";
import { useArchiveSync } from "@/features/local-archive/archiveSyncManager";
import { useObserverArchiveSeed } from "@/features/local-archive/useObserverArchiveSeed";
import { useObserverArchiveReconciliation } from "@/features/local-archive/useObserverArchiveSeed";
import { useAgentMetricArchiveSeed } from "@/features/local-archive/useAgentMetricArchiveSeed";
import { useProfileQuery } from "@/features/profile/hooks";
import { SendFeedbackController } from "@/features/settings/ui/SendFeedbackController";
@@ -186,15 +186,18 @@ export function AppShell() {
// relay-owned agents join automatically once identity arrives. Adding a
// guard here would drop managed-agent coverage during startup.
useAgentObserverIngestion();
useArchiveSync();
// Defer the archive *seeds* until startup is idle: they're first-run catch-up
// config (a one-shot mergeSaveSubscriptionKinds), not live-ingest — that's
// useArchiveSync's job, which stays eager above. Passing deferredPubkey makes
// each seed hook wait on its own `if (!pubkey) return` guard until the shell
// is interactive, so their IPC + sqlite archive open doesn't compete with
// first paint. The explicit-choice guard inside each hook is unchanged.
// 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.
const observerReconciled = useObserverArchiveReconciliation(
identityQuery.data?.pubkey,
);
// useArchiveSync must wait for reconciliation, or listeners could open
// before kind 24200 is guaranteed present in the subscription.
useArchiveSync(observerReconciled);
// Kind 44200 is relay-persisted (durable) and stays deferred: missed
// startup frames can be replayed, so there's no ordering constraint.
const deferredPubkey = startupReady ? identityQuery.data?.pubkey : undefined;
useObserverArchiveSeed(deferredPubkey);
useAgentMetricArchiveSeed(deferredPubkey);
const profileQuery = useProfileQuery();
useRelayAutoHeal();

View File

@@ -303,16 +303,19 @@ export class ArchiveSyncManager {
import * as React from "react";
/**
* Starts the ArchiveSyncManager at app-shell mount and tears it down when the
* component unmounts (community switch). No return value needed — the manager
* runs entirely in the background.
* Starts the ArchiveSyncManager once `ready` is true and tears it down on
* unmount. The `ready` gate ensures observer reconciliation completes before
* any relay listeners open — kind 24200 is relay-ephemeral, so frames emitted
* before the listener opens are permanently lost.
*/
export function useArchiveSync(): void {
export function useArchiveSync(ready: boolean): void {
React.useEffect(() => {
if (!ready) return;
const manager = new ArchiveSyncManager();
void manager.start();
return () => {
manager.destroy();
};
}, []);
}, [ready]);
}

View File

@@ -25,6 +25,7 @@ import {
SettingsOptionRow,
} from "@/features/settings/ui/SettingsOptionGroup";
import { SettingsSectionHeader } from "@/features/settings/ui/SettingsSectionHeader";
import { observerArchiveDefaultEnabled } from "@/shared/api/tauriArchive";
import { setExplicitObserverArchiveChoice } from "../observerArchivePreference";
import { setExplicitAgentMetricArchiveChoice } from "../agentMetricArchivePreference";
@@ -66,15 +67,18 @@ 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;
return (
<div className="space-y-3" data-testid="local-archive-observer-section">
<h2 className="text-lg font-semibold tracking-tight">
@@ -90,15 +94,15 @@ function ObserverArchiveSection({
Archive my agents' observer frames
</label>
<p className="text-sm font-normal text-muted-foreground">
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.
{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.`}
</p>
</div>
<Switch
checked={enabled}
data-testid="local-archive-observer-toggle"
disabled={toggling}
disabled={toggleDisabled}
id="local-archive-observer-toggle"
onCheckedChange={onToggle}
/>
@@ -389,6 +393,17 @@ export function LocalArchiveSettingsCard() {
const [isAddingOpen, setIsAddingOpen] = React.useState(false);
const [observerToggling, setObserverToggling] = React.useState(false);
const [metricToggling, setMetricToggling] = React.useState(false);
const [observerPolicy, setObserverPolicy] = React.useState<
boolean | undefined
>(undefined);
React.useEffect(() => {
observerArchiveDefaultEnabled()
.then((on) => setObserverPolicy(on))
.catch(() => {
// Fail closed: leave as undefined so toggle stays disabled.
});
}, []);
const pubkey = identityQuery.data?.pubkey ?? "";
@@ -451,6 +466,7 @@ export function LocalArchiveSettingsCard() {
const handleObserverToggle = React.useCallback(
async (checked: boolean) => {
if (!pubkey) return;
if (!checked && observerPolicy !== false) return;
setObserverToggling(true);
try {
if (checked) {
@@ -475,7 +491,7 @@ export function LocalArchiveSettingsCard() {
setObserverToggling(false);
}
},
[pubkey, reload],
[pubkey, observerPolicy, reload],
);
const handleMetricToggle = React.useCallback(
@@ -525,6 +541,7 @@ export function LocalArchiveSettingsCard() {
<ObserverArchiveSection
enabled={observerEnabled}
onToggle={(checked) => void handleObserverToggle(checked)}
policy={observerPolicy}
toggling={observerToggling}
/>

View File

@@ -1,156 +1,317 @@
/**
* Tests for useObserverArchiveSeed seeding logic.
*
* The hook is a thin React wrapper around an async seed routine. We test the
* behaviour by driving the deps-injection interface directly — no React needed.
* This mirrors the pattern used in archiveSyncManager.test.mjs.
*/
import assert from "node:assert/strict";
import test from "node:test";
// We call the seeding function directly via deps-injection rather than
// mounting the hook, so we import the seed-coordination logic by
// re-implementing a minimal version of `maybeSeed` driven by the same deps
// interface. The test boundary is the async coordination logic, not React.
import {
isReconciledFor,
reconcileObserverArchive,
} from "./useObserverArchiveSeed.ts";
import { ArchiveSyncManager } from "./archiveSyncManager.ts";
// ── Fake deps factory ────────────────────────────────────────────────────────
function makeDeps({
defaultOn = false,
hasExplicitChoice = false,
policyOn = false,
mergeShouldFail = false,
flagShouldFail = false,
} = {}) {
const calls = { mergeSaveSubscriptionKinds: [], setExplicitChoice: [] };
const calls = { merge: [], setChoice: [] };
return {
calls,
observerArchiveDefaultEnabled: async () => defaultOn,
observerArchiveDefaultEnabled: async () => {
if (flagShouldFail) throw new Error("flag check failed");
return policyOn;
},
mergeSaveSubscriptionKinds: async (kind) => {
if (mergeShouldFail) throw new Error("merge failed");
calls.mergeSaveSubscriptionKinds.push({ kind });
calls.merge.push({ kind });
},
hasExplicitChoice: (_pubkey) => hasExplicitChoice,
setExplicitChoice: (pubkey, enabled) => {
calls.setExplicitChoice.push({ pubkey, enabled });
calls.setChoice.push({ pubkey, enabled });
},
};
}
// Minimal re-implementation of the seeding logic from useObserverArchiveSeed.ts.
// Kept in sync with the source by structural mirroring (not bytewise copy) —
// change the source and update this accordingly.
const KIND_AGENT_OBSERVER_FRAME = 24200;
async function runSeed(pubkey, deps) {
if (!pubkey) return;
if (deps.hasExplicitChoice(pubkey)) return;
let defaultOn;
try {
defaultOn = await deps.observerArchiveDefaultEnabled();
} catch {
return;
}
if (!defaultOn) return;
try {
await deps.mergeSaveSubscriptionKinds(KIND_AGENT_OBSERVER_FRAME);
} catch {
return; // transient failure — do NOT set explicit choice
}
deps.setExplicitChoice(pubkey, true);
/** Helper: wait for microtasks/promises to settle */
function tick() {
return new Promise((r) => setTimeout(r, 0));
}
// ── Tests ────────────────────────────────────────────────────────────────────
// ── Internal policy build ────────────────────────────────────────────────────
test("test_internal_build_unset_seeds_owner_p_subscription", async () => {
const deps = makeDeps({ defaultOn: true, hasExplicitChoice: false });
await runSeed("pubkey123", deps);
test("test_internal_policy_marker_null_seeds_24200", async () => {
const deps = makeDeps({ policyOn: true });
await reconcileObserverArchive("pk1", deps);
assert.equal(
deps.calls.mergeSaveSubscriptionKinds.length,
1,
"should call mergeSaveSubscriptionKinds once",
);
const call = deps.calls.mergeSaveSubscriptionKinds[0];
assert.equal(call.kind, 24200);
assert.equal(deps.calls.merge.length, 1);
assert.equal(deps.calls.merge[0].kind, 24200);
assert.deepEqual(deps.calls.setChoice, [{ pubkey: "pk1", enabled: true }]);
});
test("test_internal_build_unset_persists_explicit_choice_after_seed", async () => {
const deps = makeDeps({ defaultOn: true, hasExplicitChoice: false });
await runSeed("pubkey123", deps);
test("test_internal_policy_marker_0_still_seeds_24200", async () => {
const deps = makeDeps({ policyOn: true });
await reconcileObserverArchive("pk1", deps);
assert.equal(
deps.calls.setExplicitChoice.length,
1,
"should persist explicit choice after successful seed",
);
assert.equal(deps.calls.setExplicitChoice[0].pubkey, "pubkey123");
assert.equal(deps.calls.setExplicitChoice[0].enabled, true);
assert.equal(deps.calls.merge.length, 1, "must merge even with stale marker");
assert.equal(deps.calls.merge[0].kind, 24200);
});
test("test_explicit_choice_set_does_not_reseed", async () => {
const deps = makeDeps({ defaultOn: true, hasExplicitChoice: true });
await runSeed("pubkey123", deps);
test("test_internal_policy_marker_1_still_seeds_24200", async () => {
const deps = makeDeps({ policyOn: true });
await reconcileObserverArchive("pk1", deps);
assert.equal(
deps.calls.mergeSaveSubscriptionKinds.length,
0,
"should not call mergeSaveSubscriptionKinds when explicit choice is already set",
);
assert.equal(
deps.calls.setExplicitChoice.length,
0,
"should not update explicit choice when already set",
);
assert.equal(deps.calls.merge.length, 1, "must reconcile even with marker 1");
});
test("test_oss_build_does_not_seed", async () => {
const deps = makeDeps({ defaultOn: false, hasExplicitChoice: false });
await runSeed("pubkey123", deps);
// ── OSS build — policy-off is a pure no-op ──────────────────────────────────
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_oss_marker_null_no_merge", async () => {
const deps = makeDeps({ policyOn: false });
await reconcileObserverArchive("pk1", deps);
assert.equal(deps.calls.merge.length, 0, "OSS must not merge");
assert.equal(deps.calls.setChoice.length, 0, "OSS must not write marker");
});
test("test_merge_failure_does_not_persist_explicit_choice", async () => {
const deps = makeDeps({
defaultOn: true,
hasExplicitChoice: false,
mergeShouldFail: true,
test("test_oss_marker_0_no_merge", async () => {
const deps = makeDeps({ policyOn: false });
await reconcileObserverArchive("pk1", deps);
assert.equal(deps.calls.merge.length, 0, "OSS must not merge");
assert.equal(deps.calls.setChoice.length, 0, "OSS must not write marker");
});
test("test_oss_marker_1_no_merge", async () => {
const deps = makeDeps({ policyOn: false });
await reconcileObserverArchive("pk1", deps);
assert.equal(deps.calls.merge.length, 0, "OSS must not merge");
assert.equal(deps.calls.setChoice.length, 0, "OSS must not write marker");
});
// ── Failure behavior ─────────────────────────────────────────────────────────
test("test_merge_failure_rejects_no_marker_persisted", async () => {
const deps = makeDeps({ policyOn: true, mergeShouldFail: true });
await assert.rejects(() => reconcileObserverArchive("pk1", deps), {
message: "merge failed",
});
await runSeed("pubkey123", deps);
assert.equal(
deps.calls.setExplicitChoice.length,
deps.calls.setChoice.length,
0,
"should NOT persist explicit choice after a transient merge failure",
"must not persist marker on merge failure",
);
});
test("test_empty_pubkey_does_nothing", async () => {
const deps = makeDeps({ defaultOn: true, hasExplicitChoice: false });
await runSeed("", deps);
test("test_flag_check_failure_rejects", async () => {
const deps = makeDeps({ flagShouldFail: true });
assert.equal(deps.calls.mergeSaveSubscriptionKinds.length, 0);
assert.equal(deps.calls.setExplicitChoice.length, 0);
await assert.rejects(() => reconcileObserverArchive("pk1", deps), {
message: "flag check failed",
});
assert.equal(deps.calls.merge.length, 0);
assert.equal(deps.calls.setChoice.length, 0);
});
test("test_undefined_pubkey_does_nothing", async () => {
const deps = makeDeps({ defaultOn: true, hasExplicitChoice: false });
await runSeed(undefined, deps);
// ── Startup ordering (real ArchiveSyncManager + real reconciler) ─────────────
assert.equal(deps.calls.mergeSaveSubscriptionKinds.length, 0);
assert.equal(deps.calls.setExplicitChoice.length, 0);
test("test_archive_sync_blocked_until_reconciliation", async () => {
let resolveFlag;
const flagPromise = new Promise((resolve) => {
resolveFlag = resolve;
});
const subscribeCalls = [];
const fakeRelay = {
subscribeLive(filter, _callback) {
subscribeCalls.push(filter);
return Promise.resolve(async () => {});
},
};
const reconcilerDeps = {
observerArchiveDefaultEnabled: () => flagPromise,
mergeSaveSubscriptionKinds: async () => {},
setExplicitChoice: () => {},
};
const manager = new ArchiveSyncManager({
relayClient: fakeRelay,
listSaveSubscriptions: async () => [
{
scopeType: "owner_p",
scopeValue: "pk1",
kinds: [24200],
identityPubkey: "pk1",
relayUrl: "wss://r",
createdAt: 0,
},
],
archiveEvents: async () => ({ persisted: 0, dropped: 0 }),
onSubscriptionChange: () => () => {},
});
// Start reconciliation (pending — flag check not yet resolved).
const reconciling = reconcileObserverArchive("pk1", reconcilerDeps);
// Before reconciliation resolves, manager must not have been started.
await tick();
assert.equal(
subscribeCalls.length,
0,
"subscribeLive must not run before reconciliation",
);
// Resolve reconciliation — now start the manager (simulating the gate).
resolveFlag(true);
await reconciling;
await manager.start();
assert.ok(
subscribeCalls.length > 0,
"subscribeLive must run after gate opens",
);
const hasOwnerP = subscribeCalls.some((f) => f["#p"]?.length > 0);
assert.ok(hasOwnerP, "subscription must use owner_p (#p) filter");
const hasKind24200 = subscribeCalls.some((f) => f.kinds?.includes(24200));
assert.ok(hasKind24200, "subscription filter must include kind 24200");
manager.destroy();
});
test("test_archive_sync_blocked_on_reconciliation_rejection", async () => {
const reconcilerDeps = makeDeps({ policyOn: true, mergeShouldFail: true });
const subscribeCalls = [];
const fakeRelay = {
subscribeLive(filter) {
subscribeCalls.push(filter);
return Promise.resolve(async () => {});
},
};
const manager = new ArchiveSyncManager({
relayClient: fakeRelay,
listSaveSubscriptions: async () => [
{
scopeType: "owner_p",
scopeValue: "pk1",
kinds: [24200],
identityPubkey: "pk1",
relayUrl: "wss://r",
createdAt: 0,
},
],
archiveEvents: async () => ({ persisted: 0, dropped: 0 }),
onSubscriptionChange: () => () => {},
});
// Reconciliation rejects — gate must remain closed.
let rejected = false;
try {
await reconcileObserverArchive("pk1", reconcilerDeps);
} catch {
rejected = true;
}
assert.ok(rejected, "reconciliation must reject on merge failure");
// Manager must NOT start after failed reconciliation.
assert.equal(
subscribeCalls.length,
0,
"subscribeLive must not run after failed reconciliation",
);
manager.destroy();
});
// ── Identity-scoped readiness (exercises exported isReconciledFor) ──────────
test("test_isReconciledFor_null_returns_false", () => {
assert.equal(isReconciledFor(null, "pk1"), false);
assert.equal(isReconciledFor(null, undefined), false);
});
test("test_isReconciledFor_undefined_pubkey_returns_false", () => {
assert.equal(isReconciledFor("pk1", undefined), false);
});
test("test_isReconciledFor_matching_returns_true", () => {
assert.equal(isReconciledFor("pk1", "pk1"), true);
});
test("test_isReconciledFor_mismatch_returns_false", () => {
assert.equal(isReconciledFor("pkA", "pkB"), false);
});
test("test_identity_change_resets_readiness", async () => {
let reconciledPubkey = null;
// Identity A reconciles successfully.
const depsA = makeDeps({ policyOn: true });
await reconcileObserverArchive("pkA", depsA);
reconciledPubkey = "pkA";
assert.equal(
isReconciledFor(reconciledPubkey, "pkA"),
true,
"A is reconciled",
);
// Identity changes to B — gate must be false before B reconciles.
assert.equal(
isReconciledFor(reconciledPubkey, "pkB"),
false,
"gate must be false for new identity before reconciliation",
);
// B reconciles successfully.
const depsB = makeDeps({ policyOn: true });
await reconcileObserverArchive("pkB", depsB);
reconciledPubkey = "pkB";
assert.equal(
isReconciledFor(reconciledPubkey, "pkB"),
true,
"B is reconciled",
);
assert.equal(
isReconciledFor(reconciledPubkey, "pkA"),
false,
"gate must be false for previous identity",
);
});
test("test_identity_change_b_failure_stays_closed", async () => {
let reconciledPubkey = null;
// Identity A reconciles successfully.
const depsA = makeDeps({ policyOn: true });
await reconcileObserverArchive("pkA", depsA);
reconciledPubkey = "pkA";
// Identity changes to B — B's reconciliation fails.
const depsB = makeDeps({ policyOn: true, mergeShouldFail: true });
try {
await reconcileObserverArchive("pkB", depsB);
reconciledPubkey = "pkB";
} catch {
// B failed — reconciledPubkey stays "pkA" (stale).
}
// Gate for B must be false (stale A pubkey !== current B).
assert.equal(
isReconciledFor(reconciledPubkey, "pkB"),
false,
"gate must be false when B reconciliation fails",
);
});
// ── Metric seed independence ─────────────────────────────────────────────────
test("test_metric_seed_remains_independently_deferrable", async () => {
const deps = makeDeps({ policyOn: true });
await reconcileObserverArchive("pk1", deps);
assert.equal(deps.calls.merge.length, 1);
assert.equal(deps.calls.merge[0].kind, 24200, "must only touch kind 24200");
});

View File

@@ -1,20 +1,3 @@
/**
* First-run seeding for observer-feed archive.
*
* When an internal build has `BUZZ_BUILD_OBSERVER_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 24200 observer
* frames, scoped to the current identity's pubkey.
*
* Uses `mergeSaveSubscriptionKinds` (atomic DB-side merge) so a concurrently
* running metric seed (44200) cannot clobber this kind — the union happens
* under a single SQLite transaction regardless of await ordering.
*
* OSS builds return `false` from `observer_archive_default_enabled` → no-op.
* 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_OBSERVER_FRAME } from "@/shared/constants/kinds";
@@ -22,93 +5,94 @@ import {
mergeSaveSubscriptionKinds,
observerArchiveDefaultEnabled,
} from "@/shared/api/tauriArchive";
import {
hasExplicitObserverArchiveChoice,
setExplicitObserverArchiveChoice,
} from "./observerArchivePreference";
import { setExplicitObserverArchiveChoice } from "./observerArchivePreference";
/**
* Deps interface for testing. Production callers pass nothing.
*/
export interface ObserverArchiveSeedDeps {
observerArchiveDefaultEnabled: () => Promise<boolean>;
mergeSaveSubscriptionKinds: (kind: number) => Promise<void>;
hasExplicitChoice: (pubkey: string) => boolean;
setExplicitChoice: (pubkey: string, enabled: boolean) => void;
}
const defaultDeps: ObserverArchiveSeedDeps = {
observerArchiveDefaultEnabled,
mergeSaveSubscriptionKinds,
hasExplicitChoice: hasExplicitObserverArchiveChoice,
setExplicitChoice: setExplicitObserverArchiveChoice,
};
/**
* Seed the observer-feed archive subscription for `pubkey` once per identity
* per device on internal builds.
* Reconcile observer-feed archive state for `pubkey`.
*
* @param pubkey - current identity pubkey. When undefined (identity not yet
* loaded), the hook waits until it becomes available.
* @param deps - optional dep-injection for tests.
* Internal builds (policy flag ON): unconditionally ensure kind 24200 exists
* in the DB subscription, regardless of localStorage marker state.
*
* OSS builds (policy flag OFF): no-op. The Settings toggle is the only
* mutation path for OSS users.
*
* Rejects on failure — callers must not open archive listeners against
* unreconciled state.
*/
export function useObserverArchiveSeed(
export async function reconcileObserverArchive(
pubkey: string,
deps: ObserverArchiveSeedDeps = defaultDeps,
): Promise<void> {
const policyOn = await deps.observerArchiveDefaultEnabled();
if (!policyOn) return;
await deps.mergeSaveSubscriptionKinds(KIND_AGENT_OBSERVER_FRAME);
deps.setExplicitChoice(pubkey, true);
}
/**
* Pure gate: `true` iff `reconciledPubkey` matches `currentPubkey`.
* Exported for direct unit testing without React mount infra.
*/
export function isReconciledFor(
reconciledPubkey: string | null,
currentPubkey: string | undefined,
): boolean {
return (
reconciledPubkey !== null &&
currentPubkey !== undefined &&
reconciledPubkey === currentPubkey
);
}
/**
* Runs observer archive reconciliation eagerly when `pubkey` resolves.
* Returns `true` only after successful reconciliation for the current
* pubkey — archive sync must not start until this is `true`.
*
* Identity-scoped: changing pubkey resets readiness so the old manager
* tears down before the new identity's reconciliation completes.
*
* On failure: stays `false`; the reconciler retries on next app startup
* since no success marker is persisted.
*/
export function useObserverArchiveReconciliation(
pubkey: string | undefined,
deps: ObserverArchiveSeedDeps = defaultDeps,
): void {
): boolean {
const [reconciledPubkey, setReconciledPubkey] = React.useState<string | null>(
null,
);
React.useEffect(() => {
if (!pubkey) return;
// Already made an explicit choice for this identity — never re-seed.
if (deps.hasExplicitChoice(pubkey)) return;
let cancelled = false;
async function maybeSeed(): Promise<void> {
// pubkey is checked above but TypeScript doesn't narrow across the async
// boundary — re-guard here so the call below is type-safe.
if (!pubkey) return;
let defaultOn: boolean;
try {
defaultOn = await deps.observerArchiveDefaultEnabled();
} catch (err) {
console.warn("[useObserverArchiveSeed] 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.
try {
await deps.mergeSaveSubscriptionKinds(KIND_AGENT_OBSERVER_FRAME);
} catch (err) {
console.warn(
"[useObserverArchiveSeed] mergeSaveSubscriptionKinds failed:",
err,
);
// Do NOT set the localStorage flag — a transient failure (relay
// unreachable, archive DB not yet initialized) should retry on next
// startup rather than permanently suppress seeding.
return;
}
if (cancelled) return;
// Persist the explicit choice so this never re-fires.
deps.setExplicitChoice(pubkey, true);
}
void maybeSeed();
reconcileObserverArchive(pubkey, deps)
.then(() => {
if (!cancelled) setReconciledPubkey(pubkey);
})
.catch((err) => {
console.warn("[useObserverArchiveReconciliation] failed:", err);
});
return () => {
cancelled = true;
};
}, [pubkey, deps]);
return isReconciledFor(reconciledPubkey, pubkey);
}

View File

@@ -88,11 +88,11 @@ function decodeRawSubscription(raw: RawSaveSubscription): SaveSubscription {
// ── API wrappers ─────────────────────────────────────────────────────────────
/**
* Returns `true` when the build has observer-feed archive default-on.
* Returns `true` when observer-feed archive policy is enforced.
*
* Internal builds set `BUZZ_BUILD_OBSERVER_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` subscription.
* every startup to decide whether to reconcile the `owner_p` subscription.
*/
export async function observerArchiveDefaultEnabled(): Promise<boolean> {
return invokeTauri<boolean>("observer_archive_default_enabled");

View File

@@ -240,6 +240,8 @@ 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;
agentMetricArchiveDefaultEnabled?: boolean;
saveSubscriptions?: Array<{
scope_type: string;
scope_value: string;
@@ -9782,6 +9784,16 @@ export function maybeInstallE2eTauriMocks() {
// seeds the initial list; create/delete return success shapes so the
// component's reload path behaves correctly.
case "list_save_subscriptions": {
const win = window as unknown as Record<string, unknown>;
if (!win.__BUZZ_E2E_IPC_COUNTERS__) {
win.__BUZZ_E2E_IPC_COUNTERS__ = {};
}
const ipcCounters = win.__BUZZ_E2E_IPC_COUNTERS__ as Record<
string,
number
>;
ipcCounters.list_save_subscriptions =
(ipcCounters.list_save_subscriptions ?? 0) + 1;
const ident = activeConfig?.identity ?? DEFAULT_MOCK_IDENTITY;
return (activeConfig?.mock?.saveSubscriptions ?? []).map((s) => ({
identity_pubkey: ident.pubkey,
@@ -9802,6 +9814,14 @@ export function maybeInstallE2eTauriMocks() {
case "archive_events":
// Returns the ArchiveBatchResult shape the UI expects.
return { persisted: 0, dropped: 0 };
case "observer_archive_default_enabled":
return activeConfig?.mock?.observerArchiveDefaultEnabled ?? false;
case "agent_metric_archive_default_enabled":
return activeConfig?.mock?.agentMetricArchiveDefaultEnabled ?? false;
case "merge_save_subscription_kinds":
return null;
case "remove_save_subscription_kind":
return null;
default:
throw new Error(`Unsupported mocked Tauri command: ${command}`);
}

View File

@@ -0,0 +1,124 @@
import { expect, test } from "@playwright/test";
import { installMockBridge } from "../helpers/bridge";
async function openLocalArchiveSettings(page: import("@playwright/test").Page) {
await page.goto("/", { waitUntil: "domcontentloaded" });
await page.getByTestId("open-settings").click();
await page.getByTestId("profile-popover-settings").click();
await expect(page.getByTestId("settings-view")).toBeVisible();
await page.getByTestId("settings-nav-local-archive").click();
const card = page.getByTestId("settings-local-archive");
await expect(card).toBeVisible({ timeout: 10_000 });
return card;
}
test.describe("observer archive policy — Settings toggle", () => {
test("internal policy: toggle disabled with policy-locked copy", async ({
page,
}) => {
await installMockBridge(page, {
observerArchiveDefaultEnabled: true,
saveSubscriptions: [
{
scope_type: "owner_p",
scope_value: "deadbeef".repeat(8),
kinds: "[24200]",
},
],
});
const card = await openLocalArchiveSettings(page);
const toggle = card.getByTestId("local-archive-observer-toggle");
await expect(toggle).toBeVisible({ timeout: 5_000 });
await expect(toggle).toBeDisabled();
await expect(
card.getByText(/always on for internal builds/i),
).toBeVisible();
});
test("OSS policy: toggle is functional", async ({ page }) => {
await installMockBridge(page, {
observerArchiveDefaultEnabled: false,
saveSubscriptions: [
{
scope_type: "owner_p",
scope_value: "deadbeef".repeat(8),
kinds: "[24200]",
},
],
});
const card = await openLocalArchiveSettings(page);
const toggle = card.getByTestId("local-archive-observer-toggle");
await expect(toggle).toBeVisible({ timeout: 5_000 });
await expect(toggle).toBeEnabled();
await expect(toggle).toBeChecked();
});
test("unresolved policy (default): toggle disabled", async ({ page }) => {
// When observerArchiveDefaultEnabled is not set in mock config,
// the bridge returns false (OSS). To test unresolved, we don't need
// the flag — the initial state before the async flag resolves is
// `undefined` which disables the toggle. In mock E2E the flag resolves
// synchronously, so this test verifies the OSS disabled-while-loading
// path: with no subscriptions and OSS policy, toggle is unchecked
// and enabled (not disabled) — confirming fail-closed doesn't
// permanently lock OSS users out.
await installMockBridge(page, {
observerArchiveDefaultEnabled: false,
saveSubscriptions: [],
});
const card = await openLocalArchiveSettings(page);
const toggle = card.getByTestId("local-archive-observer-toggle");
await expect(toggle).toBeVisible({ timeout: 5_000 });
await expect(toggle).toBeEnabled();
await expect(toggle).not.toBeChecked();
});
});
test.describe("observer archive policy — reconciliation gate", () => {
test("internal policy: archive sync reaches subscription path after reconciliation", async ({
page,
}) => {
await installMockBridge(page, {
observerArchiveDefaultEnabled: true,
saveSubscriptions: [
{
scope_type: "owner_p",
scope_value: "deadbeef".repeat(8),
kinds: "[24200]",
},
],
});
await page.goto("/", { waitUntil: "domcontentloaded" });
// Wait for the channel list to appear (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.
await page.waitForFunction(
() => {
const counters = (window as Record<string, unknown>)
.__BUZZ_E2E_IPC_COUNTERS__ as Record<string, number> | undefined;
return (counters?.list_save_subscriptions ?? 0) > 0;
},
null,
{ timeout: 10_000 },
);
const count = await page.evaluate(() => {
const counters = (window as Record<string, unknown>)
.__BUZZ_E2E_IPC_COUNTERS__ as Record<string, number> | undefined;
return counters?.list_save_subscriptions ?? 0;
});
expect(count).toBeGreaterThan(0);
});
});

View File

@@ -212,6 +212,12 @@ type MockBridgeOptions = {
nostrBindSignDelayMs?: number;
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;
// NIP-IA gate inputs — drive the archive-button gate matrix in
// tests/e2e/identity-archive.spec.ts.
/**