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:
npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7
2026-07-06 17:45:15 -04:00
co-authored by Will Pfleger
parent 7110788077
commit 52ec97fd9d
15 changed files with 884 additions and 34 deletions
+5 -1
View File
@@ -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
+8
View File
@@ -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())
+204 -3
View File
@@ -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,
)
+58 -13
View File
@@ -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"
);
}
}
+2
View File
@@ -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::*;
+1
View File
@@ -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,
+2
View File
@@ -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:",
+12
View File
@@ -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).
+1
View File
@@ -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;