From 52ec97fd9defe1e8666aec0a32b6bda2f761a1ca Mon Sep 17 00:00:00 2001 From: npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7 Date: Mon, 6 Jul 2026 17:17:19 -0400 Subject: [PATCH] feat(archive): add agent turn-metric (kind 44200) local archive MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Subscribe to all owned agents' turn-metric events (kind 44200) as a special case of the #1442 local-save archive primitive. Mirrors the observer-feed (24200) wiring but uses the persistent /query proof path since the relay stores and #p-gates 44200 events. Key design decisions: - owner_p+44200 routes to the persistent bucket (relay is source of truth); owner_p+24200 stays on the ephemeral path unchanged. - Decrypt at ingest (NIP-44, agent→owner); store plaintext payload JSON so token-usage calculators can read archive.db directly without the owner key. Fail-closed: decrypt error → drop, never store ciphertext. - owner_p filter arm added to plan_archive: {ids, #p, kinds}. - Both seed hooks (observer + metric) now merge existing owner_p kinds before upserting, preventing concurrent first-run seeds from clobbering each other. Observer toggle likewise merges rather than stomping. - Build-time flag BUZZ_BUILD_AGENT_METRIC_ARCHIVE_DEFAULT (presence remap → BUZZ_DESKTOP_BUILD_AGENT_METRIC_ARCHIVE_DEFAULT=1); default off for OSS, baked on for internal builds via buzz-releases. - Separate toggle in LocalArchiveSettingsCard with its own enabled state. - KIND_AGENT_TURN_METRIC = 44200 added to kinds.ts. Co-authored-by: Will Pfleger Signed-off-by: Will Pfleger --- desktop/scripts/check-file-sizes.mjs | 6 +- desktop/src-tauri/build.rs | 8 + desktop/src-tauri/src/archive/mod.rs | 207 +++++++++++++++++- desktop/src-tauri/src/archive/pipeline.rs | 71 ++++-- .../src/commands/agent_metric_archive.rs | 35 +++ desktop/src-tauri/src/commands/mod.rs | 2 + desktop/src-tauri/src/lib.rs | 1 + desktop/src/app/AppShell.tsx | 2 + .../agentMetricArchivePreference.ts | 71 ++++++ .../ui/LocalArchiveSettingsCard.tsx | 142 +++++++++++- .../useAgentMetricArchiveSeed.test.mjs | 195 +++++++++++++++++ .../useAgentMetricArchiveSeed.ts | 136 ++++++++++++ .../local-archive/useObserverArchiveSeed.ts | 29 ++- desktop/src/shared/api/tauriArchive.ts | 12 + desktop/src/shared/constants/kinds.ts | 1 + 15 files changed, 884 insertions(+), 34 deletions(-) create mode 100644 desktop/src-tauri/src/commands/agent_metric_archive.rs create mode 100644 desktop/src/features/local-archive/agentMetricArchivePreference.ts create mode 100644 desktop/src/features/local-archive/useAgentMetricArchiveSeed.test.mjs create mode 100644 desktop/src/features/local-archive/useAgentMetricArchiveSeed.ts diff --git a/desktop/scripts/check-file-sizes.mjs b/desktop/scripts/check-file-sizes.mjs index b410a1ce3..e9ba37875 100644 --- a/desktop/scripts/check-file-sizes.mjs +++ b/desktop/scripts/check-file-sizes.mjs @@ -69,7 +69,11 @@ const overrides = new Map([ // E2E test-depth hardening added the owner_p content round-trip assert and // two empty-table drop asserts (~24 lines). Queued to split the test module // into archive/mod_tests.rs in a follow-up. - ["src-tauri/src/archive/mod.rs", 1465], + // agent-metric-archive PR added 4 new unit tests (owner_p+44200 routing, + // decrypt-success plaintext storage, decrypt-fail-closed, 24200 still + // ephemeral) + run_batch_sync_with_keys helper (~175 lines). Same test-growth + // category as above. Still queued to split. + ["src-tauri/src/archive/mod.rs", 1670], ["src-tauri/src/commands/agents.rs", 1437], // #1418 read-path fix: get_thread_replies' blocker fix (shared TIMELINE_KINDS // const + build_thread_replies_filter helper, mirroring the channel sibling so diff --git a/desktop/src-tauri/build.rs b/desktop/src-tauri/build.rs index cce1716dc..022ef4768 100644 --- a/desktop/src-tauri/build.rs +++ b/desktop/src-tauri/build.rs @@ -14,6 +14,7 @@ fn main() { 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:rustc-check-cfg=cfg(buzz_updater_enabled)"); if let Ok(relay_url) = std::env::var("BUZZ_RELAY_URL") { @@ -81,6 +82,13 @@ fn main() { 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"); + } + let updater_public_key = std::env::var("BUZZ_UPDATER_PUBLIC_KEY") .ok() .map(|value| value.trim().to_string()) diff --git a/desktop/src-tauri/src/archive/mod.rs b/desktop/src-tauri/src/archive/mod.rs index 300c54532..b2857b018 100644 --- a/desktop/src-tauri/src/archive/mod.rs +++ b/desktop/src-tauri/src/archive/mod.rs @@ -5,9 +5,11 @@ //! //! Two access proof paths, chosen by event kind: //! -//! **Persistent scopes** (`channel_h`, `referenced_e`): the relay is the -//! source of truth. Candidates are grouped and re-queried via a batched -//! authed `/query`; only events the relay returns are inserted. +//! **Persistent scopes** (`channel_h`, `referenced_e`, and `owner_p`+44200): +//! the relay is the source of truth. Candidates are grouped and re-queried via +//! a batched authed `/query`; only events the relay returns are inserted. +//! For kind-44200 (agent turn metrics), content is decrypted at ingest and +//! stored as plaintext JSON — fail-closed (decrypt error → drop). //! //! **Ephemeral scope** (`owner_p`, kind 24200 observer frames): the relay //! never stores these, so `/query` cannot verify them. The relay's REQ-time @@ -32,6 +34,7 @@ use crate::relay::{query_relay, relay_ws_url_with_override}; // ── Constants ─────────────────────────────────────────────────────────────── const KIND_AGENT_OBSERVER_FRAME: u16 = 24200; +const KIND_AGENT_TURN_METRIC: u16 = 44200; const OBSERVER_FRAME_TELEMETRY: &str = "telemetry"; // ── DB helpers ─────────────────────────────────────────────────────────────── @@ -143,12 +146,18 @@ pub async fn archive_events( // ── Phase 3: persist (sync) ────────────────────────────────────────────── let conn = open_db()?; + let owner_keys = { + let keys_guard = state.keys.lock().map_err(|e| e.to_string())?; + keys_guard.clone() + // guard drops here + }; commit_archive( bucket_results, plan.ephemeral, plan.pre_dropped, &identity_pk, &relay_url, + &owner_keys, now, &conn, ) @@ -519,6 +528,26 @@ mod tests { relay_url: &str, conn: &Connection, fake_relay_events: Vec, + ) -> ArchiveBatchResult { + let owner_keys = Keys::generate(); + run_batch_sync_with_keys( + candidates, + identity_pk, + relay_url, + conn, + fake_relay_events, + &owner_keys, + ) + } + + /// Like `run_batch_sync` but with a specific owner `Keys` for decrypt. + fn run_batch_sync_with_keys( + candidates: Vec, + identity_pk: &str, + relay_url: &str, + conn: &Connection, + fake_relay_events: Vec, + owner_keys: &Keys, ) -> ArchiveBatchResult { let plan = plan_archive(candidates, identity_pk, relay_url, conn).unwrap(); @@ -544,6 +573,7 @@ mod tests { plan.pre_dropped, identity_pk, relay_url, + owner_keys, 0, conn, ) @@ -1064,6 +1094,175 @@ mod tests { ); } + // ── Kind-44200 agent-turn-metric archive tests ─────────────────────────── + + fn make_turn_metric_event(owner_keys: &Keys, agent_keys: &Keys) -> Event { + use buzz_core_pkg::agent_turn_metric::{ + encrypt_agent_turn_metric, AgentTurnMetricPayload, TokenCounts, + }; + let owner_pk = owner_keys.public_key().to_hex(); + let payload = AgentTurnMetricPayload { + harness: "test-harness".to_string(), + model: Some("test-model".to_string()), + channel_id: None, + session_id: Some("sess-1".to_string()), + turn_id: Some("turn-1".to_string()), + turn_seq: Some(1), + timestamp: "2026-07-01T00:00:00Z".to_string(), + turn: Some(TokenCounts { + input_tokens: Some(100), + output_tokens: Some(50), + total_tokens: Some(150), + cost_usd: Some(0.001), + cache_read_tokens: None, + cache_write_tokens: None, + }), + cumulative: None, + delta_reliable: true, + stop_reason: None, + }; + let ciphertext = + encrypt_agent_turn_metric(agent_keys, &owner_keys.public_key(), &payload).unwrap(); + let tags = vec![ + Tag::parse(["p", &owner_pk]).unwrap(), + Tag::parse(["agent", &agent_keys.public_key().to_hex()]).unwrap(), + ]; + EventBuilder::new(Kind::Custom(44200), &ciphertext) + .tags(tags) + .sign_with_keys(agent_keys) + .unwrap() + } + + /// A kind-44200 event with `owner_p` scope must route to the persistent + /// (relay-query) path, NOT the ephemeral path. + #[test] + fn test_owner_p_44200_routes_to_persistent_path() { + let conn = in_memory(); + let owner_keys = Keys::generate(); + let agent_keys = Keys::generate(); + let owner_pk = owner_keys.public_key().to_hex(); + let relay_url = "wss://relay.example"; + // Subscription for kind 44200 under owner_p. + add_sub(&conn, &owner_pk, relay_url, "owner_p", &owner_pk, "[44200]"); + + let ev = make_turn_metric_event(&owner_keys, &agent_keys); + let cand = candidate(&ev, ScopeType::OwnerP, &owner_pk); + + let plan = + plan_archive(vec![cand], &owner_pk, relay_url, &conn).unwrap(); + + // Must be in persistent buckets, NOT ephemeral list. + assert_eq!(plan.buckets.len(), 1, "kind-44200 must land in a bucket"); + assert_eq!( + plan.ephemeral.len(), + 0, + "kind-44200 must NOT be on the ephemeral path" + ); + assert_eq!( + plan.buckets[0].scope_type_str, "owner_p", + "bucket scope_type must be owner_p" + ); + } + + /// A kind-24200 event with `owner_p` scope must still route to ephemeral. + #[test] + fn test_owner_p_24200_still_routes_to_ephemeral() { + let conn = in_memory(); + let owner_keys = Keys::generate(); + let agent_keys = Keys::generate(); + let owner_pk = owner_keys.public_key().to_hex(); + let relay_url = "wss://relay.example"; + add_sub(&conn, &owner_pk, relay_url, "owner_p", &owner_pk, "[24200]"); + + let ev = make_observer_frame(&owner_keys, &agent_keys, OBSERVER_FRAME_TELEMETRY); + let cand = candidate(&ev, ScopeType::OwnerP, &owner_pk); + + let plan = + plan_archive(vec![cand], &owner_pk, relay_url, &conn).unwrap(); + + assert_eq!(plan.buckets.len(), 0, "kind-24200 must NOT land in a bucket"); + assert_eq!(plan.ephemeral.len(), 1, "kind-24200 must be on the ephemeral path"); + } + + /// Decrypt success: plaintext payload JSON is stored, not raw ciphertext. + #[test] + fn test_turn_metric_decrypt_success_stores_plaintext() { + let conn = in_memory(); + let owner_keys = Keys::generate(); + let agent_keys = Keys::generate(); + let owner_pk = owner_keys.public_key().to_hex(); + let relay_url = "wss://relay.example"; + add_sub(&conn, &owner_pk, relay_url, "owner_p", &owner_pk, "[44200]"); + + let ev = make_turn_metric_event(&owner_keys, &agent_keys); + let cand = candidate(&ev, ScopeType::OwnerP, &owner_pk); + let result = run_batch_sync_with_keys( + vec![cand], + &owner_pk, + relay_url, + &conn, + vec![ev.clone()], + &owner_keys, + ); + + assert_eq!(result.persisted, 1, "event must be persisted"); + assert_eq!(result.dropped, 0, "no drops on successful decrypt"); + + // The stored raw_json must be plaintext JSON, not NIP-44 ciphertext. + let raw_json: String = conn + .query_row("SELECT raw_json FROM archived_events", [], |r| r.get(0)) + .unwrap(); + // Plaintext JSON should be a valid object with "harness" key. + let parsed: serde_json::Value = serde_json::from_str(&raw_json) + .expect("stored raw_json must be valid JSON"); + assert_eq!( + parsed["harness"], "test-harness", + "stored plaintext must decode to AgentTurnMetricPayload" + ); + // Sanity: must NOT be the original NIP-44 ciphertext (which is not JSON). + assert_ne!( + raw_json, + ev.content, + "stored content must differ from original ciphertext" + ); + } + + /// Decrypt fail: event is dropped, nothing written to the store (fail-closed). + #[test] + fn test_turn_metric_decrypt_fail_drops_fail_closed() { + let conn = in_memory(); + let owner_keys = Keys::generate(); + let wrong_keys = Keys::generate(); // wrong owner key — decrypt will fail + let agent_keys = Keys::generate(); + let owner_pk = owner_keys.public_key().to_hex(); + let relay_url = "wss://relay.example"; + // Register subscription under owner_pk so the event passes plan-phase, + // but use `wrong_keys` in commit so decrypt fails. + add_sub(&conn, &owner_pk, relay_url, "owner_p", &owner_pk, "[44200]"); + + let ev = make_turn_metric_event(&owner_keys, &agent_keys); + let cand = candidate(&ev, ScopeType::OwnerP, &owner_pk); + let result = run_batch_sync_with_keys( + vec![cand], + &owner_pk, + relay_url, + &conn, + vec![ev.clone()], + &wrong_keys, // wrong key → decrypt fails + ); + + assert_eq!(result.persisted, 0, "decrypt failure must not persist the event"); + assert_eq!(result.dropped, 1, "decrypt failure must count as dropped"); + + let event_count: i64 = conn + .query_row("SELECT COUNT(*) FROM archived_events", [], |r| r.get(0)) + .unwrap(); + assert_eq!( + event_count, 0, + "no rows must be written to archived_events on decrypt failure" + ); + } + // ── Real-relay integration tests ────────────────────────────────────────── // // Gated on `#[cfg(not(target_os = "windows"))]` because `build_app_state()` @@ -1202,12 +1401,14 @@ mod tests { // Phase 3: persist (sync). Fresh connection, same file. let conn = store::open_archive_db(db_path).expect("open archive db for commit"); + let owner_keys = state.keys.lock().unwrap().clone(); commit_archive( bucket_results, plan.ephemeral, plan.pre_dropped, &identity_pk, &relay_url, + &owner_keys, 0, &conn, ) diff --git a/desktop/src-tauri/src/archive/pipeline.rs b/desktop/src-tauri/src/archive/pipeline.rs index bcfbaf2fd..260c83c1b 100644 --- a/desktop/src-tauri/src/archive/pipeline.rs +++ b/desktop/src-tauri/src/archive/pipeline.rs @@ -124,7 +124,15 @@ pub(super) fn plan_archive( continue; } - if cand.matched_scope.scope_type.is_ephemeral() { + // owner_p scope splits by kind: + // kind 24200 (observer frames) → ephemeral path (relay never stores them). + // kind 44200 (turn metrics) → persistent path (relay stores, #p-gated). + // Any other kind under owner_p follows the same ephemeral path as 24200 + // (conservative default for unknowns). + let is_ephemeral = cand.matched_scope.scope_type.is_ephemeral() + && raw_kind != super::KIND_AGENT_TURN_METRIC as u64; + + if is_ephemeral { ephemeral.push(Parsed { event, raw_json: cand.raw_event_json, @@ -188,6 +196,11 @@ pub(super) fn plan_archive( "#e": [&scope_value], "kinds": allowed_kinds, }), + "owner_p" => serde_json::json!({ + "ids": ids, + "#p": [&scope_value], + "kinds": allowed_kinds, + }), _ => { pre_dropped += group.len() as u32; continue; @@ -249,6 +262,7 @@ pub(super) fn commit_archive( pre_dropped: u32, identity_pk: &str, relay_url: &str, + owner_keys: &nostr::Keys, now: i64, conn: &Connection, ) -> Result { @@ -257,16 +271,19 @@ pub(super) fn commit_archive( // Collect writes; count drops first, then execute inside a single // transaction so event and scope rows are always committed atomically. - struct WriteRow<'a> { + // + // raw_json is owned so kind-44200 rows can store decrypted plaintext + // instead of the original NIP-44 ciphertext. + struct WriteRow { eid: String, kind: i64, pubkey: String, created_at: i64, - raw_json: &'a str, - scope_type: &'a str, - scope_value: &'a str, + raw_json: String, + scope_type: String, + scope_value: String, } - let mut writes: Vec> = Vec::new(); + let mut writes: Vec = Vec::new(); // ── Persistent path ────────────────────────────────────────────────────── for result in &bucket_results { @@ -293,7 +310,35 @@ pub(super) fn commit_archive( continue; } - // The relay returning this event for {ids, #h/#e, kinds} IS the + // For kind-44200 (agent turn metrics): decrypt at ingest and store + // the plaintext payload JSON so token-usage calculators can read + // the archive without needing the owner key. Fail-closed: if + // decrypt fails for any reason, drop the event — never store + // ciphertext or partial output. + let stored_json = if p.event.kind.as_u16() as u64 + == super::KIND_AGENT_TURN_METRIC as u64 + { + match buzz_core_pkg::agent_turn_metric::decrypt_agent_turn_metric( + owner_keys, + &p.event, + ) { + Ok(payload) => match serde_json::to_string(&payload) { + Ok(json) => json, + Err(_) => { + dropped += 1; + continue; + } + }, + Err(_) => { + dropped += 1; + continue; + } + } + } else { + p.raw_json.clone() + }; + + // The relay returning this event for {ids, #h/#e/#p, kinds} IS the // proof of scope membership. Use scope_value directly; no local // tag re-derivation (which would incorrectly drop h-less events // matched via the relay's StoredEvent.channel_id fallback). @@ -302,9 +347,9 @@ pub(super) fn commit_archive( kind: p.event.kind.as_u16() as i64, pubkey: p.event.pubkey.to_hex(), created_at: p.event.created_at.as_secs() as i64, - raw_json: &p.raw_json, - scope_type: &result.scope_type_str, - scope_value: &result.scope_value, + raw_json: stored_json, + scope_type: result.scope_type_str.clone(), + scope_value: result.scope_value.clone(), }); } } @@ -343,7 +388,7 @@ pub(super) fn commit_archive( w.kind, &w.pubkey, w.created_at, - w.raw_json, + &w.raw_json, now, )?; store::upsert_event_scope( @@ -351,8 +396,8 @@ pub(super) fn commit_archive( identity_pk, relay_url, &w.eid, - w.scope_type, - w.scope_value, + &w.scope_type, + &w.scope_value, now, )?; persisted += 1; diff --git a/desktop/src-tauri/src/commands/agent_metric_archive.rs b/desktop/src-tauri/src/commands/agent_metric_archive.rs new file mode 100644 index 000000000..43cfc7b08 --- /dev/null +++ b/desktop/src-tauri/src/commands/agent_metric_archive.rs @@ -0,0 +1,35 @@ +//! Build-time flag for agent-turn-metric archive default. +//! +//! 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. + +/// Returns `true` when an internal build has agent-turn-metric archive +/// default-on. +/// +/// 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. +#[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" + ); + } +} diff --git a/desktop/src-tauri/src/commands/mod.rs b/desktop/src-tauri/src/commands/mod.rs index e4298bcb0..68f5e1e78 100644 --- a/desktop/src-tauri/src/commands/mod.rs +++ b/desktop/src-tauri/src/commands/mod.rs @@ -1,5 +1,6 @@ mod agent_config; mod agent_discovery; +mod agent_metric_archive; mod agent_models; mod agent_providers; mod agent_settings; @@ -42,6 +43,7 @@ mod workspace; pub use agent_config::*; pub use agent_discovery::*; +pub use agent_metric_archive::*; pub use agent_models::*; pub use agent_providers::*; pub use agent_settings::*; diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index 00084a653..bbc759367 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -615,6 +615,7 @@ pub fn run() { relay_reconnect_hook, relay_reconnect_hook_configured, observer_archive_default_enabled, + agent_metric_archive_default_enabled, archive::archive_events, archive::create_save_subscription, archive::list_save_subscriptions, diff --git a/desktop/src/app/AppShell.tsx b/desktop/src/app/AppShell.tsx index 022ae67e8..be7cf268d 100644 --- a/desktop/src/app/AppShell.tsx +++ b/desktop/src/app/AppShell.tsx @@ -51,6 +51,7 @@ import { import { useWorkspaceEmojiLiveUpdates } from "@/features/custom-emoji/hooks"; import { useArchiveSync } from "@/features/local-archive/archiveSyncManager"; import { useObserverArchiveSeed } from "@/features/local-archive/useObserverArchiveSeed"; +import { useAgentMetricArchiveSeed } from "@/features/local-archive/useAgentMetricArchiveSeed"; import { useProfileQuery } from "@/features/profile/hooks"; import { DEFAULT_SETTINGS_SECTION, @@ -151,6 +152,7 @@ export function AppShell() { useAgentsDataRefresh(); useArchiveSync(); useObserverArchiveSeed(identityQuery.data?.pubkey); + useAgentMetricArchiveSeed(identityQuery.data?.pubkey); const profileQuery = useProfileQuery(); const deferredPubkey = startupReady ? identityQuery.data?.pubkey : undefined; useRelayAutoHeal(); diff --git a/desktop/src/features/local-archive/agentMetricArchivePreference.ts b/desktop/src/features/local-archive/agentMetricArchivePreference.ts new file mode 100644 index 000000000..092d53ef0 --- /dev/null +++ b/desktop/src/features/local-archive/agentMetricArchivePreference.ts @@ -0,0 +1,71 @@ +/** + * Persists whether the user has made an explicit choice about the + * agent-turn-metric 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 workspace 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:agent-metric-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 hasExplicitAgentMetricArchiveChoice( + 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 + } +} + +/** + * 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 setExplicitAgentMetricArchiveChoice( + 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 (create_save_subscription is idempotent). + } +} + +/** + * Clear the explicit choice for this identity (for testing / reset flows). + */ +export function clearExplicitAgentMetricArchiveChoice( + 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 85d078cd6..5bf59574b 100644 --- a/desktop/src/features/local-archive/ui/LocalArchiveSettingsCard.tsx +++ b/desktop/src/features/local-archive/ui/LocalArchiveSettingsCard.tsx @@ -9,7 +9,10 @@ import { type SaveSubscription, type ScopeType, } from "@/shared/api/tauriArchive"; -import { KIND_AGENT_OBSERVER_FRAME } from "@/shared/constants/kinds"; +import { + KIND_AGENT_OBSERVER_FRAME, + KIND_AGENT_TURN_METRIC, +} from "@/shared/constants/kinds"; import { useChannelsQuery } from "@/features/channels/hooks"; import { useIdentityQuery } from "@/shared/api/hooks"; import { Button } from "@/shared/ui/button"; @@ -21,6 +24,7 @@ import { } from "@/features/settings/ui/SettingsOptionGroup"; import { SettingsSectionHeader } from "@/features/settings/ui/SettingsSectionHeader"; import { setExplicitObserverArchiveChoice } from "../observerArchivePreference"; +import { setExplicitAgentMetricArchiveChoice } from "../agentMetricArchivePreference"; import { buildSubscriptionRequest, @@ -42,6 +46,9 @@ function scopeLabel( return channelNameById.get(sub.scopeValue) ?? sub.scopeValue; } if (sub.scopeType === "owner_p") { + if (sub.kinds.includes(KIND_AGENT_TURN_METRIC)) { + return "My agents' turn metrics"; + } return "My agent session frames"; } return sub.scopeValue; @@ -97,6 +104,50 @@ function ObserverArchiveSection({ ); } +// ── Agent-turn-metric archive section ──────────────────────────────────────── + +type AgentMetricSectionProps = { + enabled: boolean; + toggling: boolean; + onToggle: (checked: boolean) => void; +}; + +function AgentMetricArchiveSection({ + enabled, + toggling, + onToggle, +}: AgentMetricSectionProps) { + return ( +
+

Agent turn metrics

+ + +
+ +

+ Saves kind {KIND_AGENT_TURN_METRIC} turn-metric events addressed + to your pubkey. Stored as plaintext in your local archive so + token-usage calculators can read them directly. +

+
+ +
+
+
+ ); +} + // ── Add-subscription form ───────────────────────────────────────────────────── type KindChecklistProps = { @@ -331,6 +382,7 @@ export function LocalArchiveSettingsCard() { const [deletingKey, setDeletingKey] = React.useState(null); const [isAddingOpen, setIsAddingOpen] = React.useState(false); const [observerToggling, setObserverToggling] = React.useState(false); + const [metricToggling, setMetricToggling] = React.useState(false); const pubkey = identityQuery.data?.pubkey ?? ""; @@ -381,24 +433,42 @@ export function LocalArchiveSettingsCard() { [reload], ); - const observerEnabled = subs.some((s) => s.scopeType === "owner_p"); + const observerEnabled = subs.some( + (s) => + s.scopeType === "owner_p" && s.kinds.includes(KIND_AGENT_OBSERVER_FRAME), + ); + const metricEnabled = subs.some( + (s) => + s.scopeType === "owner_p" && s.kinds.includes(KIND_AGENT_TURN_METRIC), + ); const handleObserverToggle = React.useCallback( async (checked: boolean) => { if (!pubkey) return; setObserverToggling(true); try { - if (checked) { - await createSaveSubscription("owner_p", pubkey, [ - KIND_AGENT_OBSERVER_FRAME, - ]); - setExplicitObserverArchiveChoice(pubkey, true); - toast.success("Observer feed archive enabled."); + // The owner_p row is keyed by (scope_type, scope_value) — both observer + // (24200) and metric (44200) share the same row. Merge kinds atomically: + // read current kinds, add or remove 24200, upsert the result. + const currentKinds = + subs + .find((s) => s.scopeType === "owner_p" && s.scopeValue === pubkey) + ?.kinds.filter((k) => k !== KIND_AGENT_OBSERVER_FRAME) ?? []; + const nextKinds = checked + ? [...currentKinds, KIND_AGENT_OBSERVER_FRAME] + : currentKinds; + + if (nextKinds.length > 0) { + await createSaveSubscription("owner_p", pubkey, nextKinds); } else { await deleteSaveSubscription("owner_p", pubkey); - setExplicitObserverArchiveChoice(pubkey, false); - toast.success("Observer feed archive disabled."); } + setExplicitObserverArchiveChoice(pubkey, checked); + toast.success( + checked + ? "Observer feed archive enabled." + : "Observer feed archive disabled.", + ); await reload(); } catch (err) { toast.error( @@ -410,10 +480,51 @@ export function LocalArchiveSettingsCard() { setObserverToggling(false); } }, - [pubkey, reload], + [pubkey, subs, reload], ); - // Non-observer subscriptions shown in the active-subscriptions list. + const handleMetricToggle = React.useCallback( + async (checked: boolean) => { + if (!pubkey) return; + setMetricToggling(true); + try { + // Same row as observer — merge 44200 in or out. + const currentKinds = + subs + .find((s) => s.scopeType === "owner_p" && s.scopeValue === pubkey) + ?.kinds.filter((k) => k !== KIND_AGENT_TURN_METRIC) ?? []; + const nextKinds = checked + ? [...currentKinds, KIND_AGENT_TURN_METRIC] + : currentKinds; + + if (nextKinds.length > 0) { + await createSaveSubscription("owner_p", pubkey, nextKinds); + } else { + await deleteSaveSubscription("owner_p", pubkey); + } + setExplicitAgentMetricArchiveChoice(pubkey, checked); + toast.success( + checked + ? "Agent turn metric archive enabled." + : "Agent turn metric archive disabled.", + ); + await reload(); + } catch (err) { + toast.error( + err instanceof Error + ? err.message + : "Failed to update agent metric archive.", + ); + } finally { + setMetricToggling(false); + } + }, + [pubkey, subs, reload], + ); + + // Non-owner_p subscriptions shown in the active-subscriptions list. + // observer (24200) and metric (44200) owner_p subs each have their own + // dedicated section above. const channelSubs = subs.filter((s) => s.scopeType !== "owner_p"); return ( @@ -431,6 +542,13 @@ export function LocalArchiveSettingsCard() { toggling={observerToggling} /> + {/* Agent-turn-metric archive — dedicated first-class section */} + void handleMetricToggle(checked)} + toggling={metricToggling} + /> + {/* Channel subscriptions */}

diff --git a/desktop/src/features/local-archive/useAgentMetricArchiveSeed.test.mjs b/desktop/src/features/local-archive/useAgentMetricArchiveSeed.test.mjs new file mode 100644 index 000000000..796c7221c --- /dev/null +++ b/desktop/src/features/local-archive/useAgentMetricArchiveSeed.test.mjs @@ -0,0 +1,195 @@ +/** + * 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. + */ + +import assert from "node:assert/strict"; +import test from "node:test"; + +// ── Fake deps factory ──────────────────────────────────────────────────────── + +function makeDeps({ + defaultOn = false, + hasExplicitChoice = false, + createShouldFail = false, + existingKinds = [], +} = {}) { + const calls = { createSaveSubscription: [], setExplicitChoice: [] }; + + return { + calls, + agentMetricArchiveDefaultEnabled: async () => defaultOn, + listSaveSubscriptions: async () => + existingKinds.length > 0 + ? [{ scopeType: "owner_p", kinds: existingKinds }] + : [], + createSaveSubscription: async (scopeType, scopeValue, kinds) => { + if (createShouldFail) throw new Error("create failed"); + calls.createSaveSubscription.push({ scopeType, scopeValue, kinds }); + }, + hasExplicitChoice: (_pubkey) => hasExplicitChoice, + setExplicitChoice: (pubkey, enabled) => { + calls.setExplicitChoice.push({ pubkey, enabled }); + }, + }; +} + +// Minimal re-implementation of the seeding logic from useAgentMetricArchiveSeed.ts. +// Kept in sync with the source by structural mirroring. +const KIND_AGENT_TURN_METRIC = 44200; + +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 { + let existingKinds = []; + try { + const existing = await deps.listSaveSubscriptions(); + existingKinds = + existing.find((s) => s.scopeType === "owner_p")?.kinds ?? []; + } catch { + // best-effort + } + const mergedKinds = existingKinds.includes(KIND_AGENT_TURN_METRIC) + ? existingKinds + : [...existingKinds, KIND_AGENT_TURN_METRIC]; + await deps.createSaveSubscription("owner_p", pubkey, mergedKinds); + } catch { + return; // transient failure — do NOT set explicit choice + } + + deps.setExplicitChoice(pubkey, true); +} + +// ── Tests ──────────────────────────────────────────────────────────────────── + +test("test_internal_build_unset_seeds_owner_p_subscription", async () => { + const deps = makeDeps({ defaultOn: true, hasExplicitChoice: false }); + await runSeed("pubkey123", deps); + + assert.equal( + deps.calls.createSaveSubscription.length, + 1, + "should call createSaveSubscription once", + ); + const call = deps.calls.createSaveSubscription[0]; + assert.equal(call.scopeType, "owner_p"); + assert.equal(call.scopeValue, "pubkey123"); + assert.deepEqual(call.kinds, [44200]); +}); + +test("test_internal_build_merges_with_existing_observer_kinds", async () => { + // Observer already seeded [24200]; metric seed must produce [24200, 44200]. + const deps = makeDeps({ + defaultOn: true, + hasExplicitChoice: false, + existingKinds: [24200], + }); + await runSeed("pubkey123", deps); + + assert.equal(deps.calls.createSaveSubscription.length, 1); + const call = deps.calls.createSaveSubscription[0]; + assert.deepEqual(call.kinds, [24200, 44200]); +}); + +test("test_internal_build_idempotent_when_kind_already_present", async () => { + // 44200 already in the row — merged kinds should be the same. + const deps = makeDeps({ + defaultOn: true, + hasExplicitChoice: false, + existingKinds: [24200, 44200], + }); + await runSeed("pubkey123", deps); + + assert.equal(deps.calls.createSaveSubscription.length, 1); + const call = deps.calls.createSaveSubscription[0]; + assert.deepEqual(call.kinds, [24200, 44200]); +}); + +test("test_internal_build_unset_persists_explicit_choice_after_seed", async () => { + const deps = makeDeps({ defaultOn: true, hasExplicitChoice: false }); + await runSeed("pubkey123", 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); +}); + +test("test_explicit_choice_set_does_not_reseed", async () => { + const deps = makeDeps({ defaultOn: true, hasExplicitChoice: true }); + await runSeed("pubkey123", deps); + + assert.equal( + deps.calls.createSaveSubscription.length, + 0, + "should not call createSaveSubscription when explicit choice is already set", + ); + assert.equal( + deps.calls.setExplicitChoice.length, + 0, + "should not update explicit choice when already set", + ); +}); + +test("test_oss_build_does_not_seed", async () => { + const deps = makeDeps({ defaultOn: false, hasExplicitChoice: false }); + await runSeed("pubkey123", deps); + + assert.equal( + deps.calls.createSaveSubscription.length, + 0, + "should not call createSaveSubscription in OSS build", + ); + assert.equal( + deps.calls.setExplicitChoice.length, + 0, + "should not persist explicit choice in OSS build", + ); +}); + +test("test_create_failure_does_not_persist_explicit_choice", async () => { + const deps = makeDeps({ + defaultOn: true, + hasExplicitChoice: false, + createShouldFail: true, + }); + await runSeed("pubkey123", deps); + + assert.equal( + deps.calls.setExplicitChoice.length, + 0, + "should NOT persist explicit choice after a transient create failure", + ); +}); + +test("test_empty_pubkey_does_nothing", async () => { + const deps = makeDeps({ defaultOn: true, hasExplicitChoice: false }); + await runSeed("", deps); + + assert.equal(deps.calls.createSaveSubscription.length, 0); + assert.equal(deps.calls.setExplicitChoice.length, 0); +}); + +test("test_undefined_pubkey_does_nothing", async () => { + const deps = makeDeps({ defaultOn: true, hasExplicitChoice: false }); + await runSeed(undefined, deps); + + assert.equal(deps.calls.createSaveSubscription.length, 0); + assert.equal(deps.calls.setExplicitChoice.length, 0); +}); diff --git a/desktop/src/features/local-archive/useAgentMetricArchiveSeed.ts b/desktop/src/features/local-archive/useAgentMetricArchiveSeed.ts new file mode 100644 index 000000000..0ae1c48d3 --- /dev/null +++ b/desktop/src/features/local-archive/useAgentMetricArchiveSeed.ts @@ -0,0 +1,136 @@ +/** + * 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. + * + * Merges with any existing `owner_p` subscription (e.g. an observer + * subscription already seeded) rather than overwriting, so both kinds coexist + * in one row. + * + * 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. + */ + +import * as React from "react"; + +import { KIND_AGENT_TURN_METRIC } from "@/shared/constants/kinds"; +import { + createSaveSubscription, + listSaveSubscriptions, + agentMetricArchiveDefaultEnabled, +} from "@/shared/api/tauriArchive"; +import { + hasExplicitAgentMetricArchiveChoice, + setExplicitAgentMetricArchiveChoice, +} from "./agentMetricArchivePreference"; + +/** + * Deps interface for testing. Production callers pass nothing. + */ +export interface AgentMetricArchiveSeedDeps { + agentMetricArchiveDefaultEnabled: () => Promise; + listSaveSubscriptions: () => Promise< + Array<{ scopeType: string; kinds: number[] }> + >; + createSaveSubscription: ( + scopeType: "owner_p", + scopeValue: string, + kinds: number[], + ) => Promise; + hasExplicitChoice: (pubkey: string) => boolean; + setExplicitChoice: (pubkey: string, enabled: boolean) => void; +} + +const defaultDeps: AgentMetricArchiveSeedDeps = { + agentMetricArchiveDefaultEnabled, + listSaveSubscriptions, + createSaveSubscription, + hasExplicitChoice: hasExplicitAgentMetricArchiveChoice, + setExplicitChoice: setExplicitAgentMetricArchiveChoice, +}; + +/** + * Seed the agent-turn-metric archive subscription for `pubkey` once per + * identity per device on internal builds. + * + * @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. + */ +export function useAgentMetricArchiveSeed( + pubkey: string | undefined, + deps: AgentMetricArchiveSeedDeps = defaultDeps, +): void { + 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 { + // 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.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. + try { + // Merge with any existing owner_p subscription so a concurrently-seeded + // observer subscription (24200) is not overwritten. + let existingKinds: number[] = []; + try { + const existing = await deps.listSaveSubscriptions(); + existingKinds = + existing.find((s) => s.scopeType === "owner_p")?.kinds ?? []; + } catch { + // Best-effort — on error, seed with just our kind. + } + const mergedKinds = existingKinds.includes(KIND_AGENT_TURN_METRIC) + ? existingKinds + : [...existingKinds, KIND_AGENT_TURN_METRIC]; + await deps.createSaveSubscription("owner_p", pubkey, mergedKinds); + } catch (err) { + console.warn( + "[useAgentMetricArchiveSeed] createSaveSubscription 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(); + + return () => { + cancelled = true; + }; + }, [pubkey, deps]); +} diff --git a/desktop/src/features/local-archive/useObserverArchiveSeed.ts b/desktop/src/features/local-archive/useObserverArchiveSeed.ts index 6bb9bc326..fb1662742 100644 --- a/desktop/src/features/local-archive/useObserverArchiveSeed.ts +++ b/desktop/src/features/local-archive/useObserverArchiveSeed.ts @@ -3,8 +3,11 @@ * * 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 (kind 24200 observer frames, scoped to the - * current identity's pubkey). + * an `owner_p` save subscription including kind 24200 observer frames, scoped + * to the current identity's pubkey. + * + * Merges with any existing `owner_p` subscription (e.g. a metric subscription + * already seeded) rather than overwriting, so both kinds coexist in one row. * * OSS builds return `false` from `observer_archive_default_enabled` → no-op. * After any explicit user action (seeding or opt-out), the localStorage flag @@ -16,6 +19,7 @@ import * as React from "react"; import { KIND_AGENT_OBSERVER_FRAME } from "@/shared/constants/kinds"; import { createSaveSubscription, + listSaveSubscriptions, observerArchiveDefaultEnabled, } from "@/shared/api/tauriArchive"; import { @@ -28,6 +32,9 @@ import { */ export interface ObserverArchiveSeedDeps { observerArchiveDefaultEnabled: () => Promise; + listSaveSubscriptions: () => Promise< + Array<{ scopeType: string; kinds: number[] }> + >; createSaveSubscription: ( scopeType: "owner_p", scopeValue: string, @@ -39,6 +46,7 @@ export interface ObserverArchiveSeedDeps { const defaultDeps: ObserverArchiveSeedDeps = { observerArchiveDefaultEnabled, + listSaveSubscriptions, createSaveSubscription, hasExplicitChoice: hasExplicitObserverArchiveChoice, setExplicitChoice: setExplicitObserverArchiveChoice, @@ -87,9 +95,20 @@ export function useObserverArchiveSeed( // Internal build + no prior choice → auto-seed. try { - await deps.createSaveSubscription("owner_p", pubkey, [ - KIND_AGENT_OBSERVER_FRAME, - ]); + // Merge with any existing owner_p subscription so a concurrently-seeded + // metric subscription (44200) is not overwritten. + let existingKinds: number[] = []; + try { + const existing = await deps.listSaveSubscriptions(); + existingKinds = + existing.find((s) => s.scopeType === "owner_p")?.kinds ?? []; + } catch { + // Best-effort — on error, seed with just our kind. + } + const mergedKinds = existingKinds.includes(KIND_AGENT_OBSERVER_FRAME) + ? existingKinds + : [...existingKinds, KIND_AGENT_OBSERVER_FRAME]; + await deps.createSaveSubscription("owner_p", pubkey, mergedKinds); } catch (err) { console.warn( "[useObserverArchiveSeed] createSaveSubscription failed:", diff --git a/desktop/src/shared/api/tauriArchive.ts b/desktop/src/shared/api/tauriArchive.ts index 4940ec421..83f5a1311 100644 --- a/desktop/src/shared/api/tauriArchive.ts +++ b/desktop/src/shared/api/tauriArchive.ts @@ -98,6 +98,18 @@ export async function observerArchiveDefaultEnabled(): Promise { return invokeTauri("observer_archive_default_enabled"); } +/** + * Returns `true` when the build has agent-turn-metric archive default-on. + * + * 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. + */ +export async function agentMetricArchiveDefaultEnabled(): Promise { + return invokeTauri("agent_metric_archive_default_enabled"); +} + /** * Create a save subscription. * Runs an access probe on the backend (channel membership, event readability). diff --git a/desktop/src/shared/constants/kinds.ts b/desktop/src/shared/constants/kinds.ts index d59624233..c16fcb471 100644 --- a/desktop/src/shared/constants/kinds.ts +++ b/desktop/src/shared/constants/kinds.ts @@ -43,6 +43,7 @@ export const KIND_TEAM = 30176; export const KIND_MANAGED_AGENT = 30177; export const KIND_USER_STATUS = 30315; export const KIND_AGENT_OBSERVER_FRAME = 24200; +export const KIND_AGENT_TURN_METRIC = 44200; export const KIND_MESH_STATUS_REPORT = 24620; export const KIND_MESH_CONNECT_REQUEST = 24621; export const KIND_MESH_CALL_ME_NOW = 24622;