mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
feat(archive): add agent turn-metric (kind 44200) local archive
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 <pfleger.will@gmail.com> Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
This commit is contained in:
co-authored by
Will Pfleger
parent
7110788077
commit
52ec97fd9d
@@ -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
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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<Event>,
|
||||
) -> 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<ArchiveCandidate>,
|
||||
identity_pk: &str,
|
||||
relay_url: &str,
|
||||
conn: &Connection,
|
||||
fake_relay_events: Vec<Event>,
|
||||
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,
|
||||
)
|
||||
|
||||
@@ -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<ArchiveBatchResult, String> {
|
||||
@@ -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<WriteRow<'_>> = Vec::new();
|
||||
let mut writes: Vec<WriteRow> = 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;
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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::*;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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 (
|
||||
<div className="space-y-3" data-testid="local-archive-agent-metric-section">
|
||||
<h3 className="text-sm font-medium">Agent turn metrics</h3>
|
||||
<SettingsOptionGroup>
|
||||
<SettingsOptionRow>
|
||||
<div className="min-w-0 flex-1">
|
||||
<label
|
||||
className="text-sm font-medium"
|
||||
htmlFor="local-archive-agent-metric-toggle"
|
||||
>
|
||||
Archive my agents' turn metrics
|
||||
</label>
|
||||
<p className="text-sm font-normal text-muted-foreground">
|
||||
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.
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={enabled}
|
||||
data-testid="local-archive-agent-metric-toggle"
|
||||
disabled={toggling}
|
||||
id="local-archive-agent-metric-toggle"
|
||||
onCheckedChange={onToggle}
|
||||
/>
|
||||
</SettingsOptionRow>
|
||||
</SettingsOptionGroup>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Add-subscription form ─────────────────────────────────────────────────────
|
||||
|
||||
type KindChecklistProps = {
|
||||
@@ -331,6 +382,7 @@ export function LocalArchiveSettingsCard() {
|
||||
const [deletingKey, setDeletingKey] = React.useState<string | null>(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 */}
|
||||
<AgentMetricArchiveSection
|
||||
enabled={metricEnabled}
|
||||
onToggle={(checked) => void handleMetricToggle(checked)}
|
||||
toggling={metricToggling}
|
||||
/>
|
||||
|
||||
{/* Channel subscriptions */}
|
||||
<div className="space-y-3" data-testid="local-archive-subscriptions">
|
||||
<h3 className="text-sm font-medium">
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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<boolean>;
|
||||
listSaveSubscriptions: () => Promise<
|
||||
Array<{ scopeType: string; kinds: number[] }>
|
||||
>;
|
||||
createSaveSubscription: (
|
||||
scopeType: "owner_p",
|
||||
scopeValue: string,
|
||||
kinds: number[],
|
||||
) => Promise<void>;
|
||||
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<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.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]);
|
||||
}
|
||||
@@ -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<boolean>;
|
||||
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:",
|
||||
|
||||
@@ -98,6 +98,18 @@ export async function observerArchiveDefaultEnabled(): Promise<boolean> {
|
||||
return invokeTauri<boolean>("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<boolean> {
|
||||
return invokeTauri<boolean>("agent_metric_archive_default_enabled");
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a save subscription.
|
||||
* Runs an access probe on the backend (channel membership, event readability).
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user