diff --git a/desktop/playwright.config.ts b/desktop/playwright.config.ts index 9c35447e84..7a2f08559d 100644 --- a/desktop/playwright.config.ts +++ b/desktop/playwright.config.ts @@ -114,6 +114,7 @@ export default defineConfig({ "**/inbox-live-update.spec.ts", "**/mesh-compute.spec.ts", "**/observer-archive-policy.spec.ts", + "**/agent-usage.spec.ts", ], use: { ...devices["Desktop Chrome"], diff --git a/desktop/scripts/check-file-sizes.mjs b/desktop/scripts/check-file-sizes.mjs index 1963acc3bc..76499ad138 100644 --- a/desktop/scripts/check-file-sizes.mjs +++ b/desktop/scripts/check-file-sizes.mjs @@ -75,7 +75,13 @@ const overrides = new Map([ // is test-only content; the override covers the test growth accumulated // across the local-archive + agent-metric-archive PR series. store_tests.rs // (~731 lines) is under 1000 so needs no override. - ["src-tauri/src/archive/mod_tests.rs", 1208], + // agent-usage-archive (Rev 3): persistedAgentMetrics decrypt-success + // assertion + re-ingest no-double-count test, plus three + // get_agent_usage_series integration tests (fresh ingest, agentPubkey + // filter + hasArchivedEvidence, pre-existing-row backfill) exercising the + // command's SQLite core end to end. Test-only content; ratcheted + // 1208 -> 1424. + ["src-tauri/src/archive/mod_tests.rs", 1424], // unified-agent-model 1A.1: profile reconcile split to agents_profile.rs, // ratcheting 1443 -> 1295. Queued to split further in the A2 fold. // global-agent-config: resolve_deploy_model_provider + visibility exports @@ -341,7 +347,10 @@ const overrides = new Map([ ["src/features/profile/ui/UserProfilePanelSections.tsx", 1140], // +14 for openEditAgent event subscription (config-nudge card "Open Edit Agent" action). // +11 for editAgentFocus state + initialFocus prop threading (deep-link granularity). - ["src/features/profile/ui/UserProfilePanel.tsx", 1025], + // +1 for A13 fail-closed canViewUsage wiring + AgentUsageFocusedView mount. + // +14 for query-derived usageIngressTrailing summary threaded into the + // Info-tab ingress row (replaces the hardcoded "View" label, plan:328). + ["src/features/profile/ui/UserProfilePanel.tsx", 1040], // PersistBackend enum + marker-on-keyring-success plumbing and its three // fail-closed regression tests (silent identity rotation on keyring outage). // A small overage from load-bearing security plumbing on a file already at @@ -518,6 +527,13 @@ const overrides = new Map([ // runtimeSupportsLlmProviderSelection guard on discovery provider (codex fix); // hideProviderIds computation for Databricks v1 gate. Queued to split. ["src/features/agents/ui/AgentDefinitionDialog.tsx", 1035], + // agent-usage-archive (Rev 3): Phase 2's single invoke_handler registration + // line for get_agent_usage_series (826d79221) pre-existed under the prior + // 1000-line ceiling; rebasing this branch onto a later main (which grew + // lib.rs by 2 lines upstream, unrelated to this feature) tipped the file + // to 1001. Not generic debt growth from this PR — one line, pre-existing. + // Queued to split with the rest of this list. + ["src-tauri/src/lib.rs", 1001], ]); await runFileSizeCheck({ diff --git a/desktop/src-tauri/src/archive/agent_usage.rs b/desktop/src-tauri/src/archive/agent_usage.rs new file mode 100644 index 0000000000..431c068a1b --- /dev/null +++ b/desktop/src-tauri/src/archive/agent_usage.rs @@ -0,0 +1,677 @@ +//! Pure NIP-AM usage accounting: request validation, wire types, and the +//! per-field cumulative/direct-fallback ladder. +//! +//! No Tauri or filesystem dependency — every function here takes already +//! loaded rows (or plain values) and returns plain values. The caller +//! (`mod.rs`'s `get_agent_usage_series` command, Phase 2) owns SQLite access +//! (`metric_store.rs`) and glues the two together: load window rows, compute +//! [`window_probe_keys`], load those exact-key rows, then call +//! [`compute_series`]. +//! +//! Accounting contract (Rev 3, frozen plan + amendments A1/A2/A4/A9/A11–A13): +//! see `docs/nips/NIP-AM.md:119-160` for the NIP itself. + +use std::collections::{HashMap, HashSet}; + +use serde::{Deserialize, Serialize}; + +use super::metric_store::AgentMetricIndexRow; + +// ── Request ────────────────────────────────────────────────────────────────── + +/// Request for [`compute_series`]'s caller. `bucket_boundaries` are exact +/// local-midnight Unix-second boundaries built by the frontend (inclusive +/// start / exclusive end per adjacent pair): 8 entries = 7 buckets, 31 +/// entries = 30 buckets. +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AgentUsageSeriesRequest { + pub bucket_boundaries: Vec, + pub agent_pubkey: Option, +} + +/// Widest interval NIP-AM query validation admits (A9): wide enough to admit +/// every real civil-day transition (ordinary DST, 30-minute-offset zones, +/// historical calendar skips) while still rejecting arbitrary bins. +const MAX_INTERVAL_SECS: i64 = 48 * 3600; + +/// Validate a request per the frozen contract + A9 (drops the 23–25h band +/// for a `> 0 && <= 48h` sanity band) and A13 pubkey normalization. +/// +/// Fails closed before any SQLite work. Returns the normalized (lowercased) +/// agent pubkey, if one was supplied. +pub(super) fn validate_request(req: &AgentUsageSeriesRequest) -> Result, String> { + let n = req.bucket_boundaries.len(); + if n != 8 && n != 31 { + return Err(format!( + "bucket_boundaries must have exactly 8 or 31 entries, got {n}" + )); + } + + for i in 0..n - 1 { + let (a, b) = (req.bucket_boundaries[i], req.bucket_boundaries[i + 1]); + if b <= a { + return Err(format!( + "bucket_boundaries must be strictly increasing (index {i}: {a} >= {b})" + )); + } + let interval = b - a; + if interval > MAX_INTERVAL_SECS { + return Err(format!( + "bucket_boundaries interval at index {i} is {interval}s, exceeds {MAX_INTERVAL_SECS}s" + )); + } + } + + // Finite Unix-second bounds: must be representable as an RFC 3339 + // instant (chrono's timestamp range), independent of local timezone. + for &t in &req.bucket_boundaries { + if chrono::DateTime::from_timestamp(t, 0).is_none() { + return Err(format!("bucket boundary {t} is out of representable range")); + } + } + + let normalized_pubkey = match &req.agent_pubkey { + None => None, + Some(pk) => { + if pk.len() != 64 || !pk.chars().all(|c| c.is_ascii_hexdigit()) { + return Err("agent_pubkey must be exactly 64 hex characters".to_string()); + } + Some(pk.to_lowercase()) + } + }; + + Ok(normalized_pubkey) +} + +// ── Wire types ─────────────────────────────────────────────────────────────── + +/// Per-field completeness (A2): `value: null` means no known increment in +/// scope; `incomplete: true` on a non-null value means the value is a +/// reported lower bound, not full coverage. +#[derive(Debug, Clone, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct UsageField { + pub value: Option, + pub incomplete: bool, +} + +#[derive(Debug, Clone, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct CostField { + pub value: Option, + pub incomplete: bool, +} + +#[derive(Debug, Clone, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ReportedUsage { + pub input_tokens: UsageField, + pub output_tokens: UsageField, + pub total_tokens: UsageField, + pub estimated_cost_usd: CostField, +} + +#[derive(Debug, Clone, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct SeriesBucket { + pub start: i64, + pub end: i64, + pub usage: ReportedUsage, + pub report_count: i64, + pub has_unknown_usage: bool, +} + +#[derive(Debug, Clone, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ModelUsage { + pub model: Option, + pub usage: ReportedUsage, + pub report_count: i64, + pub has_unknown_usage: bool, +} + +#[derive(Debug, Clone, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct AgentUsage { + pub agent_pubkey: String, + pub usage: ReportedUsage, + pub buckets: Vec, + pub models: Vec, + pub report_count: i64, + pub has_unknown_usage: bool, +} + +#[derive(Debug, Clone, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct Coverage { + pub first_archived_at: Option, + pub last_archived_at: Option, + pub first_reported_at: Option, + pub last_reported_at: Option, + pub report_count: i64, + pub invalid_report_count: i64, + pub has_unknown_usage: bool, +} + +#[derive(Debug, Clone, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct AgentUsageSeries { + pub collection_enabled: bool, + pub buckets: Vec, + pub agents: Vec, + pub coverage: Coverage, + /// A13: `null` when the request had no `agentPubkey` filter; otherwise + /// `true` iff at least one surviving `agent_metric_index` row (either + /// `parse_status`) exists for that author, with no bucket-boundary + /// restriction. Callers compute this (DB access) and pass it through. + pub has_archived_evidence: Option, +} + +// ── Per-event field ladder (A1, A4, A11, A12) ─────────────────────────────── + +#[derive(Debug, Clone, Copy)] +enum FieldValue { + Known(T), + Unknown, +} + +struct EventOutcome { + input: FieldValue, + output: FieldValue, + total: FieldValue, + cost: FieldValue, +} + +/// Per-field ladder for token counters (A1): adjacent nondecreasing cumulative +/// pair → diff; adjacent decreasing pair → unknown, terminal, no fallback; +/// no usable baseline for this field → `deltaReliable` direct value or +/// unknown. +fn ladder_token( + baseline_cumulative: Option, + current_cumulative: Option, + current_turn: Option, + delta_reliable: bool, +) -> FieldValue { + if let (Some(prev), Some(cur)) = (baseline_cumulative, current_cumulative) { + return if cur >= prev { + FieldValue::Known(cur - prev) + } else { + FieldValue::Unknown + }; + } + if delta_reliable { + if let Some(v) = current_turn { + return FieldValue::Known(v); + } + } + FieldValue::Unknown +} + +/// Same ladder for `costUsd` (f64). +fn ladder_cost( + baseline_cumulative: Option, + current_cumulative: Option, + current_turn: Option, + delta_reliable: bool, +) -> FieldValue { + if let (Some(prev), Some(cur)) = (baseline_cumulative, current_cumulative) { + return if cur >= prev { + FieldValue::Known(cur - prev) + } else { + FieldValue::Unknown + }; + } + if delta_reliable { + if let Some(v) = current_turn { + return FieldValue::Known(v); + } + } + FieldValue::Unknown +} + +/// Resolve the exact-`S-1` baseline row for `row`, or `None` if no usable +/// baseline exists (A11/A12): missing session/sequence key, duplicate row at +/// `row`'s own sequence (A4 — no cumulative delta for any row at a +/// duplicated sequence), sequence `0` (no predecessor, `checked_sub` +/// underflow), no predecessor row (gap), or duplicate rows at the +/// predecessor sequence (ambiguous baseline). +/// +/// `probe_by_key` must contain, for every key queried, ALL valid rows at +/// that exact `(agent, session, turnSeq)` — used for both this baseline +/// lookup and the A4/A11 duplicate-cardinality check, which is why absence +/// of a key from the map is treated identically to an empty group. +fn resolve_baseline<'a>( + row: &AgentMetricIndexRow, + probe_by_key: &HashMap<(String, String, u64), Vec<&'a AgentMetricIndexRow>>, +) -> Option<&'a AgentMetricIndexRow> { + let (agent, session, seq) = row.accounting_key()?; + + let own_group = probe_by_key.get(&(agent.clone(), session.clone(), seq))?; + if own_group.len() > 1 { + return None; // A4: duplicate at own sequence — no cumulative delta. + } + + let pred_seq = seq.checked_sub(1)?; // A12: seq == 0 has no baseline. + let pred_group = probe_by_key.get(&(agent, session, pred_seq))?; + if pred_group.len() != 1 { + return None; // Missing (gap) or ambiguous (duplicate) predecessor. + } + Some(pred_group[0]) +} + +fn compute_event_outcome( + row: &AgentMetricIndexRow, + probe_by_key: &HashMap<(String, String, u64), Vec<&AgentMetricIndexRow>>, +) -> EventOutcome { + let baseline = resolve_baseline(row, probe_by_key); + let delta_reliable = row.delta_reliable.unwrap_or(false); + + EventOutcome { + input: ladder_token( + baseline.and_then(|b| b.cumulative_input_tokens), + row.cumulative_input_tokens, + row.turn_input_tokens, + delta_reliable, + ), + output: ladder_token( + baseline.and_then(|b| b.cumulative_output_tokens), + row.cumulative_output_tokens, + row.turn_output_tokens, + delta_reliable, + ), + total: ladder_token( + baseline.and_then(|b| b.cumulative_total_tokens), + row.cumulative_total_tokens, + row.turn_total_tokens, + delta_reliable, + ), + cost: ladder_cost( + baseline.and_then(|b| b.cumulative_cost_usd), + row.cumulative_cost_usd, + row.turn_cost_usd, + delta_reliable, + ), + } +} + +/// The exact `(agent, session, turnSeq)` keys the caller must load via +/// `metric_store::load_rows_at_exact_keys` before calling [`compute_series`] +/// (A11): each in-window row's own key, plus its checked predecessor key +/// (`turnSeq - 1`) when one exists. Pure and DB-free so it is unit-testable +/// without SQLite. +pub(super) fn window_probe_keys( + window_rows: &[AgentMetricIndexRow], +) -> HashSet<(String, String, u64)> { + let mut keys = HashSet::new(); + for row in window_rows { + if let Some((agent, session, seq)) = row.accounting_key() { + if let Some(pred) = seq.checked_sub(1) { + keys.insert((agent.clone(), session.clone(), pred)); + } + keys.insert((agent, session, seq)); + } + } + keys +} + +// ── Accumulators ───────────────────────────────────────────────────────────── + +/// Sums known per-event increments with `checked_add`; an event with an +/// unknown value, or an overflow, marks the scope `incomplete` (A2) without +/// ever wrapping (overflow freezes the sum at its last valid value). +#[derive(Debug, Default, Clone)] +struct TokenAccumulator { + value: Option, + incomplete: bool, + overflowed: bool, +} + +impl TokenAccumulator { + fn add(&mut self, v: FieldValue) { + match v { + FieldValue::Unknown => self.incomplete = true, + FieldValue::Known(x) => { + if self.overflowed { + self.incomplete = true; + return; + } + self.value = Some(match self.value { + None => x, + Some(cur) => match cur.checked_add(x) { + Some(sum) => sum, + None => { + self.overflowed = true; + self.incomplete = true; + cur + } + }, + }); + } + } + } + + fn has_unknown(&self) -> bool { + self.incomplete + } + + fn finish(self) -> UsageField { + UsageField { + value: self.value.map(|v| v.to_string()), + incomplete: self.incomplete, + } + } +} + +/// Same contract as [`TokenAccumulator`] for `f64` costs: "checked finite +/// addition" means a sum that would become non-finite is rejected and the +/// scope freezes at its last valid value, flagged incomplete. +#[derive(Debug, Default, Clone)] +struct CostAccumulator { + value: Option, + incomplete: bool, + overflowed: bool, +} + +impl CostAccumulator { + fn add(&mut self, v: FieldValue) { + match v { + FieldValue::Unknown => self.incomplete = true, + FieldValue::Known(x) => { + if self.overflowed { + self.incomplete = true; + return; + } + let candidate = match self.value { + None => x, + Some(cur) => cur + x, + }; + if candidate.is_finite() { + self.value = Some(candidate); + } else { + self.overflowed = true; + self.incomplete = true; + } + } + } + } + + fn has_unknown(&self) -> bool { + self.incomplete + } + + fn finish(self) -> CostField { + CostField { + value: self.value, + incomplete: self.incomplete, + } + } +} + +#[derive(Debug, Default, Clone)] +struct UsageAccumulator { + input: TokenAccumulator, + output: TokenAccumulator, + total: TokenAccumulator, + cost: CostAccumulator, +} + +impl UsageAccumulator { + fn add(&mut self, outcome: &EventOutcome) { + self.input.add(outcome.input); + self.output.add(outcome.output); + self.total.add(outcome.total); + self.cost.add(outcome.cost); + } + + fn has_unknown(&self) -> bool { + self.input.has_unknown() + || self.output.has_unknown() + || self.total.has_unknown() + || self.cost.has_unknown() + } + + /// Raw total-token value, used only for the A2 ranking rule (sort by + /// known `totalTokens`, unknown-total scopes listed after). + fn total_tokens_value(&self) -> Option { + self.total.value + } + + fn finish(self) -> ReportedUsage { + ReportedUsage { + input_tokens: self.input.finish(), + output_tokens: self.output.finish(), + total_tokens: self.total.finish(), + estimated_cost_usd: self.cost.finish(), + } + } +} + +// ── Bucket assignment ──────────────────────────────────────────────────────── + +/// Which `[boundaries[i], boundaries[i+1])` bucket `t` falls in, or `None` +/// if outside every bucket (defensive; callers scope their row query to +/// `[boundaries[0], boundaries[last])` so this should never miss). +fn assign_bucket_index(boundaries: &[i64], t: i64) -> Option { + (0..boundaries.len().saturating_sub(1)).find(|&i| t >= boundaries[i] && t < boundaries[i + 1]) +} + +// ── compute_series ─────────────────────────────────────────────────────────── + +/// Per-agent accumulation scope, built while walking `window_rows` once. +struct AgentScope { + buckets: Vec, + bucket_counts: Vec, + total: UsageAccumulator, + report_count: i64, + models: HashMap, (UsageAccumulator, i64)>, +} + +/// Compute the full [`AgentUsageSeries`] from already-loaded rows. +/// +/// - `window_rows`: valid rows with `reported_at` in `[boundaries[0], +/// boundaries[last])` (optionally pre-filtered to one agent), from +/// `metric_store::load_window_valid_rows`. +/// - `probe_rows`: valid rows at the exact keys from [`window_probe_keys`], +/// from `metric_store::load_rows_at_exact_keys` — used for baseline +/// resolution and A4/A11 duplicate-cardinality checks, unrestricted by the +/// window. +/// - `invalid_report_count`: from `metric_store::count_invalid_rows_in_window`. +/// - `has_archived_evidence`: from `metric_store::has_archived_evidence`, +/// already resolved to `None` when the request has no `agentPubkey` filter +/// (A13) — this function does not decide that; it only carries the value. +pub(super) fn compute_series( + window_rows: &[AgentMetricIndexRow], + probe_rows: &[AgentMetricIndexRow], + invalid_report_count: i64, + boundaries: &[i64], + has_archived_evidence: Option, + collection_enabled: bool, +) -> AgentUsageSeries { + let bucket_count = boundaries.len().saturating_sub(1); + + let mut probe_by_key: HashMap<(String, String, u64), Vec<&AgentMetricIndexRow>> = + HashMap::new(); + for r in probe_rows { + if let Some(key) = r.accounting_key() { + probe_by_key.entry(key).or_default().push(r); + } + } + + let mut overall_buckets: Vec = (0..bucket_count) + .map(|_| UsageAccumulator::default()) + .collect(); + let mut overall_bucket_counts: Vec = vec![0; bucket_count]; + + let mut agents: HashMap = HashMap::new(); + + let mut first_reported_at: Option = None; + let mut last_reported_at: Option = None; + let mut first_archived_at: Option = None; + let mut last_archived_at: Option = None; + + for row in window_rows { + // Defensive: the loader already scopes to reported_at in-window and + // parse_status = 'valid'; a miss here means the caller passed rows + // it should not have, so skip rather than panic or miscount. + let Some(reported_at) = row.reported_at else { + continue; + }; + let Some(bucket_idx) = assign_bucket_index(boundaries, reported_at) else { + continue; + }; + + first_reported_at = Some(first_reported_at.map_or(reported_at, |v| v.min(reported_at))); + last_reported_at = Some(last_reported_at.map_or(reported_at, |v| v.max(reported_at))); + first_archived_at = + Some(first_archived_at.map_or(row.archived_at, |v| v.min(row.archived_at))); + last_archived_at = + Some(last_archived_at.map_or(row.archived_at, |v| v.max(row.archived_at))); + + let outcome = compute_event_outcome(row, &probe_by_key); + + overall_buckets[bucket_idx].add(&outcome); + overall_bucket_counts[bucket_idx] += 1; + + let scope = agents + .entry(row.agent_pubkey.clone()) + .or_insert_with(|| AgentScope { + buckets: (0..bucket_count) + .map(|_| UsageAccumulator::default()) + .collect(), + bucket_counts: vec![0; bucket_count], + total: UsageAccumulator::default(), + report_count: 0, + models: HashMap::new(), + }); + scope.buckets[bucket_idx].add(&outcome); + scope.bucket_counts[bucket_idx] += 1; + scope.total.add(&outcome); + scope.report_count += 1; + + let model_entry = scope + .models + .entry(row.model.clone()) + .or_insert_with(|| (UsageAccumulator::default(), 0i64)); + model_entry.0.add(&outcome); + model_entry.1 += 1; + } + + let overall_report_count: i64 = overall_bucket_counts.iter().sum(); + + let buckets: Vec = overall_buckets + .into_iter() + .zip(overall_bucket_counts) + .enumerate() + .map(|(i, (acc, count))| { + let has_unknown_usage = acc.has_unknown(); + SeriesBucket { + start: boundaries[i], + end: boundaries[i + 1], + usage: acc.finish(), + report_count: count, + has_unknown_usage, + } + }) + .collect(); + let any_overall_bucket_unknown = buckets.iter().any(|b| b.has_unknown_usage); + + // Build agent rows, then apply the A2 ranking rule: known totalTokens + // descending, unknown-total agents after, pubkey as the tiebreak/final + // key in both groups for determinism. + let mut agent_rows: Vec<(Option, String, AgentUsage)> = agents + .into_iter() + .map(|(agent_pubkey, scope)| { + let total_tokens_value = scope.total.total_tokens_value(); + let has_unknown_usage = scope.total.has_unknown(); + + let buckets: Vec = scope + .buckets + .into_iter() + .zip(scope.bucket_counts) + .enumerate() + .map(|(i, (acc, count))| { + let has_unknown_usage = acc.has_unknown(); + SeriesBucket { + start: boundaries[i], + end: boundaries[i + 1], + usage: acc.finish(), + report_count: count, + has_unknown_usage, + } + }) + .collect(); + + let mut model_rows: Vec<(Option, Option, ModelUsage)> = scope + .models + .into_iter() + .map(|(model, (acc, count))| { + let model_total = acc.total_tokens_value(); + let has_unknown_usage = acc.has_unknown(); + ( + model_total, + model.clone(), + ModelUsage { + model, + usage: acc.finish(), + report_count: count, + has_unknown_usage, + }, + ) + }) + .collect(); + model_rows.sort_by(|a, b| match (a.0, b.0) { + (Some(av), Some(bv)) => bv.cmp(&av).then_with(|| a.1.cmp(&b.1)), + (Some(_), None) => std::cmp::Ordering::Less, + (None, Some(_)) => std::cmp::Ordering::Greater, + (None, None) => a.1.cmp(&b.1), + }); + let models = model_rows.into_iter().map(|(_, _, m)| m).collect(); + + ( + total_tokens_value, + agent_pubkey.clone(), + AgentUsage { + agent_pubkey, + usage: scope.total.finish(), + buckets, + models, + report_count: scope.report_count, + has_unknown_usage, + }, + ) + }) + .collect(); + agent_rows.sort_by(|a, b| match (a.0, b.0) { + (Some(av), Some(bv)) => bv.cmp(&av).then_with(|| a.1.cmp(&b.1)), + (Some(_), None) => std::cmp::Ordering::Less, + (None, Some(_)) => std::cmp::Ordering::Greater, + (None, None) => a.1.cmp(&b.1), + }); + let any_agent_unknown = agent_rows.iter().any(|(_, _, a)| a.has_unknown_usage); + let agents: Vec = agent_rows.into_iter().map(|(_, _, a)| a).collect(); + + AgentUsageSeries { + collection_enabled, + buckets, + agents, + coverage: Coverage { + first_archived_at, + last_archived_at, + first_reported_at, + last_reported_at, + report_count: overall_report_count, + invalid_report_count, + has_unknown_usage: any_overall_bucket_unknown + || any_agent_unknown + || invalid_report_count > 0, + }, + has_archived_evidence, + } +} + +// ── Tests ──────────────────────────────────────────────────────────────────── + +#[cfg(test)] +#[path = "agent_usage_tests.rs"] +mod agent_usage_tests; diff --git a/desktop/src-tauri/src/archive/agent_usage_tests.rs b/desktop/src-tauri/src/archive/agent_usage_tests.rs new file mode 100644 index 0000000000..4e08c31c72 --- /dev/null +++ b/desktop/src-tauri/src/archive/agent_usage_tests.rs @@ -0,0 +1,700 @@ +//! Tests for the pure NIP-AM accounting ladder, accumulators, and request +//! validation. No SQLite — rows are constructed directly. + +use super::*; +use crate::archive::metric_store::ParseStatus; + +// ── Row builder ────────────────────────────────────────────────────────────── + +/// Build a fully-specified valid row for test scenarios. Defaults every +/// optional field to `None`/appropriate zero; callers override what a +/// scenario needs via struct-update syntax. +fn row(id: &str, agent: &str, session: &str, seq: u64, reported_at: i64) -> AgentMetricIndexRow { + AgentMetricIndexRow { + id: id.to_string(), + agent_pubkey: agent.to_string(), + event_created_at: reported_at, + archived_at: reported_at, + reported_at: Some(reported_at), + session_id: Some(session.to_string()), + turn_seq: Some(seq), + model: None, + delta_reliable: Some(true), + turn_input_tokens: None, + turn_output_tokens: None, + turn_total_tokens: None, + turn_cost_usd: None, + cumulative_input_tokens: None, + cumulative_output_tokens: None, + cumulative_total_tokens: None, + cumulative_cost_usd: None, + parse_status: ParseStatus::Valid, + } +} + +/// Standard 8-boundary window covering one day, seconds since epoch. +const DAY: i64 = 86_400; +fn boundaries_7() -> Vec { + (0..=7).map(|i| i * DAY).collect() +} + +fn probe_map( + rows: &[AgentMetricIndexRow], +) -> std::collections::HashMap<(String, String, u64), Vec<&AgentMetricIndexRow>> { + let mut m = std::collections::HashMap::new(); + for r in rows { + if let Some(key) = r.accounting_key() { + m.entry(key).or_insert_with(Vec::new).push(r); + } + } + m +} + +// ── Ladder: direct / cumulative / fallback ────────────────────────────────── + +#[test] +fn direct_turn_value_used_when_no_baseline() { + let r = AgentMetricIndexRow { + turn_input_tokens: Some(100), + delta_reliable: Some(true), + ..row("e1", "agent1", "s1", 1, 0) + }; + let __probe_rows = [r.clone()]; + let probes = probe_map(&__probe_rows); + let outcome = compute_event_outcome(&r, &probes); + assert!(matches!(outcome.input, FieldValue::Known(100))); +} + +#[test] +fn adjacent_cumulative_preferred_over_direct() { + let prev = AgentMetricIndexRow { + cumulative_input_tokens: Some(1000), + ..row("e0", "agent1", "s1", 1, 0) + }; + let cur = AgentMetricIndexRow { + cumulative_input_tokens: Some(1300), + turn_input_tokens: Some(999), // deliberately wrong direct value + delta_reliable: Some(true), + ..row("e1", "agent1", "s1", 2, 10) + }; + let __probe_rows = [prev, cur.clone()]; + let probes = probe_map(&__probe_rows); + let outcome = compute_event_outcome(&cur, &probes); + assert!(matches!(outcome.input, FieldValue::Known(300))); +} + +#[test] +fn direct_fallback_when_baseline_missing() { + // seq 5 has no row at seq 4 in the probe set → gap, direct-reliable only. + let cur = AgentMetricIndexRow { + cumulative_input_tokens: Some(500), + turn_input_tokens: Some(42), + delta_reliable: Some(true), + ..row("e1", "agent1", "s1", 5, 0) + }; + let __probe_rows = [cur.clone()]; + let probes = probe_map(&__probe_rows); + let outcome = compute_event_outcome(&cur, &probes); + assert!(matches!(outcome.input, FieldValue::Known(42))); +} + +#[test] +fn unreliable_delta_with_no_baseline_is_unknown() { + let cur = AgentMetricIndexRow { + turn_input_tokens: Some(42), + delta_reliable: Some(false), + ..row("e1", "agent1", "s1", 5, 0) + }; + let __probe_rows = [cur.clone()]; + let probes = probe_map(&__probe_rows); + let outcome = compute_event_outcome(&cur, &probes); + assert!(matches!(outcome.input, FieldValue::Unknown)); +} + +#[test] +fn sequence_gap_no_diff_but_direct_reliable_may_count() { + // predecessor exists at seq 1 but current is seq 3 (gap at seq 2) — + // predecessor lookup requires exact S-1, so seq 3's predecessor probe is + // for seq 2, which is absent. Direct fallback applies. + let baseline = AgentMetricIndexRow { + cumulative_input_tokens: Some(100), + ..row("e0", "agent1", "s1", 1, 0) + }; + let cur = AgentMetricIndexRow { + cumulative_input_tokens: Some(400), + turn_input_tokens: Some(50), + delta_reliable: Some(true), + ..row("e1", "agent1", "s1", 3, 10) + }; + let __probe_rows = [baseline, cur.clone()]; + let probes = probe_map(&__probe_rows); + let outcome = compute_event_outcome(&cur, &probes); + assert!(matches!(outcome.input, FieldValue::Known(50))); +} + +// ── A1: counter decrease is terminal, no fallback ─────────────────────────── + +#[test] +fn adjacent_decrease_with_reliable_direct_present_is_unknown() { + // Required A1 test: decreasing cumulative pair + deltaReliable true + + // direct turn value present → field unknown, NOT the direct value. + let prev = AgentMetricIndexRow { + cumulative_input_tokens: Some(1000), + ..row("e0", "agent1", "s1", 1, 0) + }; + let cur = AgentMetricIndexRow { + cumulative_input_tokens: Some(600), // decreased + turn_input_tokens: Some(77), // present, but must NOT be used + delta_reliable: Some(true), + ..row("e1", "agent1", "s1", 2, 10) + }; + let __probe_rows = [prev, cur.clone()]; + let probes = probe_map(&__probe_rows); + let outcome = compute_event_outcome(&cur, &probes); + assert!(matches!(outcome.input, FieldValue::Unknown)); +} + +#[test] +fn decrease_taints_only_the_affected_field() { + // Interpretation note in A1: a decrease on one field must not zero out + // sibling fields whose own adjacent pair is nondecreasing. + let prev = AgentMetricIndexRow { + cumulative_input_tokens: Some(1000), + cumulative_output_tokens: Some(200), + ..row("e0", "agent1", "s1", 1, 0) + }; + let cur = AgentMetricIndexRow { + cumulative_input_tokens: Some(600), // decreased + cumulative_output_tokens: Some(250), // increased, still valid + delta_reliable: Some(true), + ..row("e1", "agent1", "s1", 2, 10) + }; + let __probe_rows = [prev, cur.clone()]; + let probes = probe_map(&__probe_rows); + let outcome = compute_event_outcome(&cur, &probes); + assert!(matches!(outcome.input, FieldValue::Unknown)); + assert!(matches!(outcome.output, FieldValue::Known(50))); +} + +// ── Cost ladder (mirrors the token ladder tests above, f64-specific) ─────── + +#[test] +fn ladder_cost_direct_value_used_when_no_baseline() { + let r = AgentMetricIndexRow { + turn_cost_usd: Some(0.05), + delta_reliable: Some(true), + ..row("e1", "agent1", "s1", 1, 0) + }; + let __probe_rows = [r.clone()]; + let probes = probe_map(&__probe_rows); + let outcome = compute_event_outcome(&r, &probes); + assert!(matches!(outcome.cost, FieldValue::Known(v) if v == 0.05)); +} + +#[test] +fn ladder_cost_adjacent_cumulative_preferred_over_direct() { + let prev = AgentMetricIndexRow { + cumulative_cost_usd: Some(1.0), + ..row("e0", "agent1", "s1", 1, 0) + }; + let cur = AgentMetricIndexRow { + cumulative_cost_usd: Some(1.3), + turn_cost_usd: Some(999.0), // deliberately wrong direct value + delta_reliable: Some(true), + ..row("e1", "agent1", "s1", 2, 10) + }; + let __probe_rows = [prev, cur.clone()]; + let probes = probe_map(&__probe_rows); + let outcome = compute_event_outcome(&cur, &probes); + assert!(matches!(outcome.cost, FieldValue::Known(v) if (v - 0.3).abs() < f64::EPSILON)); +} + +#[test] +fn ladder_cost_adjacent_decrease_is_unknown_not_direct() { + // A1 applies identically to the cost field: a decreasing cumulative + // pair is terminal-unknown even with a present direct value. + let prev = AgentMetricIndexRow { + cumulative_cost_usd: Some(5.0), + ..row("e0", "agent1", "s1", 1, 0) + }; + let cur = AgentMetricIndexRow { + cumulative_cost_usd: Some(3.0), // decreased + turn_cost_usd: Some(1.0), // present, but must NOT be used + delta_reliable: Some(true), + ..row("e1", "agent1", "s1", 2, 10) + }; + let __probe_rows = [prev, cur.clone()]; + let probes = probe_map(&__probe_rows); + let outcome = compute_event_outcome(&cur, &probes); + assert!(matches!(outcome.cost, FieldValue::Unknown)); +} + +// ── A4/A11: duplicate sequence quarantine ─────────────────────────────────── + +#[test] +fn duplicate_at_sequence_quarantines_successor() { + // Two rows at N, one at N+1: N+1 must not cumulative-diff against + // either N candidate. + let dup_a = AgentMetricIndexRow { + cumulative_input_tokens: Some(100), + ..row("e0a", "agent1", "s1", 5, 0) + }; + let dup_b = AgentMetricIndexRow { + cumulative_input_tokens: Some(150), + ..row("e0b", "agent1", "s1", 5, 1) + }; + let successor = AgentMetricIndexRow { + cumulative_input_tokens: Some(400), + turn_input_tokens: Some(30), + delta_reliable: Some(true), + ..row("e1", "agent1", "s1", 6, 10) + }; + let __probe_rows = [dup_a, dup_b, successor.clone()]; + let probes = probe_map(&__probe_rows); + let outcome = compute_event_outcome(&successor, &probes); + // No usable baseline (ambiguous predecessor) → direct-reliable fallback. + assert!(matches!(outcome.input, FieldValue::Known(30))); +} + +#[test] +fn duplicate_row_itself_gets_no_cumulative_delta() { + let baseline = AgentMetricIndexRow { + cumulative_input_tokens: Some(100), + ..row("e_base", "agent1", "s1", 4, 0) + }; + let dup_a = AgentMetricIndexRow { + cumulative_input_tokens: Some(200), + turn_input_tokens: Some(99), + delta_reliable: Some(true), + ..row("e5a", "agent1", "s1", 5, 1) + }; + let dup_b = AgentMetricIndexRow { + cumulative_input_tokens: Some(250), + ..row("e5b", "agent1", "s1", 5, 2) + }; + let __probe_rows = [baseline, dup_a.clone(), dup_b]; + let probes = probe_map(&__probe_rows); + let outcome = compute_event_outcome(&dup_a, &probes); + // Own sequence has >1 row → no cumulative delta; direct-reliable value + // for dup_a specifically still counts (A4: "only an independently + // reliable direct turn value may count"). + assert!(matches!(outcome.input, FieldValue::Known(99))); +} + +#[test] +fn duplicate_out_of_window_peer_still_quarantines_in_window_row() { + // A11: an out-of-window duplicate at the same sequence still poisons the + // in-window row's cumulative eligibility, because the probe set has no + // reported_at restriction. + let out_of_window_dup = AgentMetricIndexRow { + cumulative_input_tokens: Some(500), + ..row("e_old", "agent1", "s1", 5, -1000) + }; + let baseline = AgentMetricIndexRow { + cumulative_input_tokens: Some(100), + ..row("e_base", "agent1", "s1", 4, 0) + }; + let in_window = AgentMetricIndexRow { + cumulative_input_tokens: Some(200), + turn_input_tokens: Some(11), + delta_reliable: Some(true), + ..row("e5", "agent1", "s1", 5, 1) + }; + let __probe_rows = [out_of_window_dup, baseline, in_window.clone()]; + let probes = probe_map(&__probe_rows); + let outcome = compute_event_outcome(&in_window, &probes); + assert!(matches!(outcome.input, FieldValue::Known(11))); +} + +// ── A12: checked_sub sequence arithmetic ──────────────────────────────────── + +#[test] +fn adjacent_pair_at_u64_max_computes_normally() { + let prev = AgentMetricIndexRow { + cumulative_input_tokens: Some(1000), + ..row("e_prev", "agent1", "s1", u64::MAX - 1, 0) + }; + let cur = AgentMetricIndexRow { + cumulative_input_tokens: Some(1500), + ..row("e_max", "agent1", "s1", u64::MAX, 10) + }; + let __probe_rows = [prev, cur.clone()]; + let probes = probe_map(&__probe_rows); + let outcome = compute_event_outcome(&cur, &probes); + assert!(matches!(outcome.input, FieldValue::Known(500))); +} + +#[test] +fn duplicate_at_u64_max_needs_no_successor_probe() { + // u64::MAX has no successor sequence to quarantine — verify duplicate + // handling at MAX itself still works (own-sequence cardinality check). + let dup_a = AgentMetricIndexRow { + cumulative_input_tokens: Some(100), + turn_input_tokens: Some(5), + delta_reliable: Some(true), + ..row("e_max_a", "agent1", "s1", u64::MAX, 0) + }; + let dup_b = AgentMetricIndexRow { + cumulative_input_tokens: Some(150), + ..row("e_max_b", "agent1", "s1", u64::MAX, 1) + }; + let __probe_rows = [dup_a.clone(), dup_b]; + let probes = probe_map(&__probe_rows); + let outcome = compute_event_outcome(&dup_a, &probes); + assert!(matches!(outcome.input, FieldValue::Known(5))); +} + +#[test] +fn seq_zero_has_no_baseline_underflow() { + let cur = AgentMetricIndexRow { + cumulative_input_tokens: Some(100), + turn_input_tokens: Some(100), + delta_reliable: Some(true), + ..row("e0", "agent1", "s1", 0, 0) + }; + let __probe_rows = [cur.clone()]; + let probes = probe_map(&__probe_rows); + // Must not panic (checked_sub) and must fall back to direct. + let outcome = compute_event_outcome(&cur, &probes); + assert!(matches!(outcome.input, FieldValue::Known(100))); +} + +// ── Null total / per-field independence ───────────────────────────────────── + +#[test] +fn null_total_tokens_stays_unknown_even_when_input_output_known() { + let r = AgentMetricIndexRow { + turn_input_tokens: Some(10), + turn_output_tokens: Some(20), + turn_total_tokens: None, + delta_reliable: Some(true), + ..row("e1", "agent1", "s1", 1, 0) + }; + let __probe_rows = [r.clone()]; + let probes = probe_map(&__probe_rows); + let outcome = compute_event_outcome(&r, &probes); + assert!(matches!(outcome.input, FieldValue::Known(10))); + assert!(matches!(outcome.output, FieldValue::Known(20))); + assert!(matches!(outcome.total, FieldValue::Unknown)); +} + +// ── Cross-session / cross-agent isolation ─────────────────────────────────── + +#[test] +fn cumulative_diff_never_crosses_session_boundary() { + let other_session = AgentMetricIndexRow { + cumulative_input_tokens: Some(999_999), + ..row("e_other", "agent1", "s_other", 1, 0) + }; + let cur = AgentMetricIndexRow { + cumulative_input_tokens: Some(50), + turn_input_tokens: Some(50), + delta_reliable: Some(true), + ..row("e1", "agent1", "s1", 1, 10) + }; + let __probe_rows = [other_session, cur.clone()]; + let probes = probe_map(&__probe_rows); + let outcome = compute_event_outcome(&cur, &probes); + // seq 1 has no predecessor (seq 0) in ANY session — direct fallback. + assert!(matches!(outcome.input, FieldValue::Known(50))); +} + +#[test] +fn cumulative_diff_never_crosses_agent_boundary() { + let other_agent = AgentMetricIndexRow { + cumulative_input_tokens: Some(999_999), + ..row("e_other", "agent2", "s1", 1, 0) + }; + let cur = AgentMetricIndexRow { + cumulative_input_tokens: Some(60), + turn_input_tokens: Some(60), + delta_reliable: Some(true), + ..row("e1", "agent1", "s1", 2, 10) + }; + let __probe_rows = [other_agent, cur.clone()]; + let probes = probe_map(&__probe_rows); + let outcome = compute_event_outcome(&cur, &probes); + // seq 2's predecessor (seq 1) does not exist for agent1 — direct. + assert!(matches!(outcome.input, FieldValue::Known(60))); +} + +// ── window_probe_keys ──────────────────────────────────────────────────────── + +#[test] +fn window_probe_keys_includes_own_and_predecessor() { + let r = row("e1", "agent1", "s1", 5, 0); + let keys = window_probe_keys(&[r]); + assert!(keys.contains(&("agent1".to_string(), "s1".to_string(), 5))); + assert!(keys.contains(&("agent1".to_string(), "s1".to_string(), 4))); + assert_eq!(keys.len(), 2); +} + +#[test] +fn window_probe_keys_skips_predecessor_at_seq_zero() { + let r = row("e1", "agent1", "s1", 0, 0); + let keys = window_probe_keys(&[r]); + assert_eq!(keys.len(), 1); + assert!(keys.contains(&("agent1".to_string(), "s1".to_string(), 0))); +} + +#[test] +fn window_probe_keys_skips_rows_without_session_or_seq() { + let r = AgentMetricIndexRow { + session_id: None, + turn_seq: None, + ..row("e1", "agent1", "s1", 5, 0) + }; + let keys = window_probe_keys(&[r]); + assert!(keys.is_empty()); +} + +// ── assign_bucket_index: boundary edges ───────────────────────────────────── + +#[test] +fn assign_bucket_index_start_is_inclusive_end_is_exclusive() { + let boundaries = boundaries_7(); + // Exactly on bucket 1's start boundary → bucket 1, not bucket 0. + assert_eq!(assign_bucket_index(&boundaries, DAY), Some(1)); + // One second before bucket 1's start → still bucket 0 (end-exclusive). + assert_eq!(assign_bucket_index(&boundaries, DAY - 1), Some(0)); +} + +#[test] +fn assign_bucket_index_returns_none_outside_every_bucket() { + let boundaries = boundaries_7(); + assert_eq!(assign_bucket_index(&boundaries, -1), None); + assert_eq!(assign_bucket_index(&boundaries, boundaries[7]), None); // last boundary is exclusive end +} + +// ── compute_series: bucketing, overflow, ranking, models ──────────────────── + +#[test] +fn compute_series_buckets_by_reported_at_not_created_at() { + let r = AgentMetricIndexRow { + turn_input_tokens: Some(10), + delta_reliable: Some(true), + event_created_at: 999_999_999, // deliberately wrong/misleading + ..row("e1", "agent1", "s1", 1, DAY + 100) // reported_at lands in bucket 1 + }; + let boundaries = boundaries_7(); + let series = compute_series( + std::slice::from_ref(&r), + std::slice::from_ref(&r), + 0, + &boundaries, + None, + true, + ); + assert_eq!(series.buckets[0].report_count, 0); + assert_eq!(series.buckets[1].report_count, 1); +} + +#[test] +fn compute_series_checked_add_overflow_marks_incomplete_without_wrapping() { + let r1 = AgentMetricIndexRow { + turn_input_tokens: Some(u64::MAX), + delta_reliable: Some(true), + ..row("e1", "agent1", "s1", 10, 0) + }; + let r2 = AgentMetricIndexRow { + turn_input_tokens: Some(5), + delta_reliable: Some(true), + ..row("e2", "agent1", "s2", 10, 1) // different session avoids adjacency + }; + let boundaries = boundaries_7(); + let rows = vec![r1, r2]; + let series = compute_series(&rows, &rows, 0, &boundaries, None, true); + let bucket = &series.buckets[0]; + assert!(bucket.has_unknown_usage); + // Value freezes at the last valid sum (u64::MAX) rather than wrapping. + assert_eq!(bucket.usage.input_tokens.value, Some(u64::MAX.to_string())); + assert!(bucket.usage.input_tokens.incomplete); +} + +#[test] +fn compute_series_cost_non_finite_marks_incomplete() { + let r1 = AgentMetricIndexRow { + turn_cost_usd: Some(f64::MAX), + delta_reliable: Some(true), + ..row("e1", "agent1", "s1", 10, 0) + }; + let r2 = AgentMetricIndexRow { + turn_cost_usd: Some(f64::MAX), + delta_reliable: Some(true), + ..row("e2", "agent1", "s2", 10, 1) + }; + let boundaries = boundaries_7(); + let rows = vec![r1, r2]; + let series = compute_series(&rows, &rows, 0, &boundaries, None, true); + assert!(series.buckets[0].usage.estimated_cost_usd.incomplete); +} + +#[test] +fn compute_series_ranks_known_total_before_unknown_total() { + let known = AgentMetricIndexRow { + turn_total_tokens: Some(500), + delta_reliable: Some(true), + ..row("e1", "agent_known", "s1", 1, 0) + }; + let unknown = AgentMetricIndexRow { + turn_total_tokens: None, + delta_reliable: Some(true), + ..row("e2", "agent_unknown", "s1", 1, 0) + }; + let boundaries = boundaries_7(); + let rows = vec![known, unknown]; + let series = compute_series(&rows, &rows, 0, &boundaries, None, true); + assert_eq!(series.agents[0].agent_pubkey, "agent_known"); + assert_eq!(series.agents[1].agent_pubkey, "agent_unknown"); +} + +#[test] +fn compute_series_ties_on_known_total_break_by_pubkey_ascending() { + // Equal totalTokens for two agents: the A2 tiebreak must be + // deterministic pubkey order, not insertion/hash order. + let agent_z = AgentMetricIndexRow { + turn_total_tokens: Some(100), + delta_reliable: Some(true), + ..row("e1", "agent_zzz", "s1", 1, 0) + }; + let agent_a = AgentMetricIndexRow { + turn_total_tokens: Some(100), + delta_reliable: Some(true), + ..row("e2", "agent_aaa", "s1", 1, 0) + }; + let boundaries = boundaries_7(); + let rows = vec![agent_z, agent_a]; + let series = compute_series(&rows, &rows, 0, &boundaries, None, true); + assert_eq!(series.agents[0].agent_pubkey, "agent_aaa"); + assert_eq!(series.agents[1].agent_pubkey, "agent_zzz"); +} + +#[test] +fn compute_series_model_breakdown_attributes_per_event_model() { + let r1 = AgentMetricIndexRow { + model: Some("model-a".to_string()), + turn_input_tokens: Some(10), + delta_reliable: Some(true), + ..row("e1", "agent1", "s1", 1, 0) + }; + let r2 = AgentMetricIndexRow { + model: Some("model-b".to_string()), + turn_input_tokens: Some(20), + delta_reliable: Some(true), + ..row("e2", "agent1", "s2", 1, 1) + }; + let boundaries = boundaries_7(); + let rows = vec![r1, r2]; + let series = compute_series(&rows, &rows, 0, &boundaries, None, true); + assert_eq!(series.agents.len(), 1); + assert_eq!(series.agents[0].models.len(), 2); +} + +#[test] +fn compute_series_invalid_report_count_passed_through_and_not_bucketed() { + let boundaries = boundaries_7(); + let series = compute_series(&[], &[], 3, &boundaries, None, true); + assert_eq!(series.coverage.invalid_report_count, 3); + assert_eq!(series.coverage.report_count, 0); + assert!(series.coverage.has_unknown_usage); +} + +#[test] +fn compute_series_zero_invalid_and_no_unknown_rows_is_not_unknown() { + let boundaries = boundaries_7(); + let series = compute_series(&[], &[], 0, &boundaries, None, true); + assert_eq!(series.coverage.invalid_report_count, 0); + assert!(!series.coverage.has_unknown_usage); +} + +#[test] +fn compute_series_collection_enabled_passthrough() { + let boundaries = boundaries_7(); + let series = compute_series(&[], &[], 0, &boundaries, None, false); + assert!(!series.collection_enabled); +} + +#[test] +fn compute_series_has_archived_evidence_passthrough() { + let boundaries = boundaries_7(); + let series = compute_series(&[], &[], 0, &boundaries, Some(true), true); + assert_eq!(series.has_archived_evidence, Some(true)); + let series_none = compute_series(&[], &[], 0, &boundaries, None, true); + assert_eq!(series_none.has_archived_evidence, None); +} + +// ── validate_request ───────────────────────────────────────────────────────── + +fn req(boundaries: Vec, agent_pubkey: Option) -> AgentUsageSeriesRequest { + AgentUsageSeriesRequest { + bucket_boundaries: boundaries, + agent_pubkey, + } +} + +#[test] +fn validate_request_accepts_8_and_31_boundaries() { + assert!(validate_request(&req(boundaries_7(), None)).is_ok()); + let b31: Vec = (0..=30).map(|i| i * DAY).collect(); + assert!(validate_request(&req(b31, None)).is_ok()); +} + +#[test] +fn validate_request_rejects_wrong_boundary_count() { + let bad: Vec = (0..=5).map(|i| i * DAY).collect(); + assert!(validate_request(&req(bad, None)).is_err()); +} + +#[test] +fn validate_request_rejects_non_increasing_boundaries() { + let mut b = boundaries_7(); + b[3] = b[2]; // zero-width interval + assert!(validate_request(&req(b, None)).is_err()); +} + +#[test] +fn validate_request_rejects_interval_over_48h() { + let mut b = boundaries_7(); + b[1] = b[0] + 49 * 3600; + assert!(validate_request(&req(b, None)).is_err()); +} + +#[test] +fn validate_request_rejects_boundary_out_of_chrono_representable_range() { + // All 7 intervals stay well within the 48h band (exactly one day each) + // so only the finite-range check can be responsible for the rejection; + // the window is shifted to straddle chrono's actual max representable + // instant rather than a hardcoded guess. + let max_ts = chrono::DateTime::::MAX_UTC.timestamp(); + let base = max_ts - 6 * DAY; + let b: Vec = (0..=7).map(|i| base + i * DAY).collect(); + assert!( + b[7] > max_ts, + "test setup must push the last boundary out of range" + ); + assert!(validate_request(&req(b, None)).is_err()); +} + +#[test] +fn validate_request_accepts_30_minute_dst_interval() { + // Lord Howe Island: 30-minute DST offset. A day-boundary pair differing + // by 23.5h must be accepted under the 48h sanity band (A9). + let mut b = boundaries_7(); + b[1] = b[0] + 23 * 3600 + 1800; + assert!(validate_request(&req(b, None)).is_ok()); +} + +#[test] +fn validate_request_normalizes_pubkey_to_lowercase() { + let pk = "AB".repeat(32); + let result = validate_request(&req(boundaries_7(), Some(pk.clone()))); + assert_eq!(result.unwrap(), Some(pk.to_lowercase())); +} + +#[test] +fn validate_request_rejects_malformed_pubkey() { + let short = "ab".repeat(10); + assert!(validate_request(&req(boundaries_7(), Some(short))).is_err()); + let non_hex = "zz".repeat(32); + assert!(validate_request(&req(boundaries_7(), Some(non_hex))).is_err()); +} diff --git a/desktop/src-tauri/src/archive/metric_store.rs b/desktop/src-tauri/src/archive/metric_store.rs new file mode 100644 index 0000000000..a2c739dc84 --- /dev/null +++ b/desktop/src-tauri/src/archive/metric_store.rs @@ -0,0 +1,562 @@ +//! Parsed index of kind 44200 (NIP-AM agent turn metric) archive rows. +//! +//! `agent_metric_index` is a derived, rebuildable cache of parsed NIP-AM +//! payload fields, keyed by `(identity_pubkey, relay_url, id)` exactly like +//! `archived_events`. It exists so the accounting algorithm in +//! `agent_usage.rs` can query parsed columns instead of re-parsing JSON on +//! every render. The canonical source of truth remains +//! `archived_events.raw_json`; every row here is reproducible from it alone +//! via [`AgentMetricIndexRow::from_payload`]. +//! +//! Kept in a sibling file (not `store.rs`) to keep that file under the +//! 1000-line gate, per the existing `pipeline.rs` precedent. + +use rusqlite::{params, Connection, OptionalExtension}; + +use buzz_core_pkg::agent_turn_metric::AgentTurnMetricPayload; + +// ── u64-safe sortable encoding ─────────────────────────────────────────────── + +/// Fixed-width digit count for the lexicographically order-preserving decimal +/// encoding of a `u64`. `u64::MAX` = 18446744073709551615 is 20 digits. +const U64_SORTABLE_WIDTH: usize = 20; + +/// Encode a `u64` as a fixed-width zero-padded decimal string so SQLite TEXT +/// ordering matches numeric ordering, and so the full `u64` range survives +/// SQLite's signed-`i64` INTEGER column type (rusqlite has no unsigned +/// binding). Used for both token counters and `turn_seq`. +pub(super) fn encode_u64_sortable(value: u64) -> String { + format!("{value:0U64_SORTABLE_WIDTH$}") +} + +/// Decode a value written by [`encode_u64_sortable`]. Returns `None` if the +/// string is not a well-formed same-width decimal `u64` — defensive only; +/// every value written by this module is always well-formed. +pub(super) fn decode_u64_sortable(text: &str) -> Option { + if text.len() != U64_SORTABLE_WIDTH { + return None; + } + text.parse::().ok() +} + +fn parse_rfc3339_secs(timestamp: &str) -> Option { + chrono::DateTime::parse_from_rfc3339(timestamp) + .ok() + .map(|dt| dt.timestamp()) +} + +// ── Row type ────────────────────────────────────────────────────────────── + +/// Parse status of a stored `agent_metric_index` row. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(super) enum ParseStatus { + Valid, + Invalid, +} + +impl ParseStatus { + fn as_str(self) -> &'static str { + match self { + ParseStatus::Valid => "valid", + ParseStatus::Invalid => "invalid", + } + } + + fn from_str(s: &str) -> Self { + match s { + "valid" => ParseStatus::Valid, + _ => ParseStatus::Invalid, + } + } +} + +/// One fully parsed `agent_metric_index` row. +#[derive(Debug, Clone, PartialEq)] +pub(super) struct AgentMetricIndexRow { + pub id: String, + pub agent_pubkey: String, + pub event_created_at: i64, + pub archived_at: i64, + pub reported_at: Option, + pub session_id: Option, + pub turn_seq: Option, + pub model: Option, + pub delta_reliable: Option, + pub turn_input_tokens: Option, + pub turn_output_tokens: Option, + pub turn_total_tokens: Option, + pub turn_cost_usd: Option, + pub cumulative_input_tokens: Option, + pub cumulative_output_tokens: Option, + pub cumulative_total_tokens: Option, + pub cumulative_cost_usd: Option, + pub parse_status: ParseStatus, +} + +impl AgentMetricIndexRow { + /// Parse a decrypted NIP-AM payload (the plaintext JSON already stored in + /// `archived_events.raw_json` for kind 44200 rows) into an index row. + /// + /// New ingest and backfill share this single parser so validity rules + /// cannot drift between the two paths (frozen plan requirement). + /// + /// "Invalid" per Rev 2 A5: the payload's JSON decodes but fails a + /// semantic check this layer owns: unparseable RFC3339 `timestamp`, or + /// `cumulative` present without both `sessionId` and `turnSeq` (NIP-AM + /// REQUIREs both whenever `cumulative` is present — a row missing either + /// cannot supply the complete `(agent, session, seq)` key needed to + /// compete as a cumulative snapshot). The upstream fail-closed ingest + /// path (`pipeline.rs::commit_archive`) has already decrypted, + /// deserialized, and numeric-validated (non-negative/finite `costUsd`) + /// before this row is ever produced — a raw-JSON parse failure here + /// would indicate on-disk corruption, not a normal producer error, but + /// is still handled fail-closed rather than panicking. + pub(super) fn from_payload( + raw_json: &str, + id: &str, + agent_pubkey: &str, + event_created_at: i64, + archived_at: i64, + ) -> Self { + let Ok(payload) = serde_json::from_str::(raw_json) else { + return Self::invalid(id, agent_pubkey, event_created_at, archived_at); + }; + + let reported_at = parse_rfc3339_secs(&payload.timestamp); + let cumulative_requires_session_seq = payload.cumulative.is_some(); + let has_session_and_seq = payload.session_id.is_some() && payload.turn_seq.is_some(); + + if reported_at.is_none() || (cumulative_requires_session_seq && !has_session_and_seq) { + return Self::invalid(id, agent_pubkey, event_created_at, archived_at); + } + + let turn = payload.turn.as_ref(); + let cumulative = payload.cumulative.as_ref(); + + Self { + id: id.to_string(), + agent_pubkey: agent_pubkey.to_string(), + event_created_at, + archived_at, + reported_at, + session_id: payload.session_id, + turn_seq: payload.turn_seq, + model: payload.model, + delta_reliable: Some(payload.delta_reliable), + turn_input_tokens: turn.and_then(|t| t.input_tokens), + turn_output_tokens: turn.and_then(|t| t.output_tokens), + turn_total_tokens: turn.and_then(|t| t.total_tokens), + turn_cost_usd: turn.and_then(|t| t.cost_usd), + cumulative_input_tokens: cumulative.and_then(|c| c.input_tokens), + cumulative_output_tokens: cumulative.and_then(|c| c.output_tokens), + cumulative_total_tokens: cumulative.and_then(|c| c.total_tokens), + cumulative_cost_usd: cumulative.and_then(|c| c.cost_usd), + parse_status: ParseStatus::Valid, + } + } + + fn invalid(id: &str, agent_pubkey: &str, event_created_at: i64, archived_at: i64) -> Self { + Self { + id: id.to_string(), + agent_pubkey: agent_pubkey.to_string(), + event_created_at, + archived_at, + reported_at: None, + session_id: None, + turn_seq: None, + model: None, + delta_reliable: None, + turn_input_tokens: None, + turn_output_tokens: None, + turn_total_tokens: None, + turn_cost_usd: None, + cumulative_input_tokens: None, + cumulative_output_tokens: None, + cumulative_total_tokens: None, + cumulative_cost_usd: None, + parse_status: ParseStatus::Invalid, + } + } + + /// The `(agent_pubkey, session_id, turn_seq)` cumulative-accounting key, + /// or `None` if this row cannot participate in cumulative delta + /// recomputation (missing session/sequence). + pub(super) fn accounting_key(&self) -> Option<(String, String, u64)> { + match (&self.session_id, self.turn_seq) { + (Some(sid), Some(seq)) => Some((self.agent_pubkey.clone(), sid.clone(), seq)), + _ => None, + } + } +} + +fn row_from_sql(row: &rusqlite::Row) -> rusqlite::Result { + let turn_seq_text: Option = row.get("turn_seq")?; + let turn_input_text: Option = row.get("turn_input_tokens")?; + let turn_output_text: Option = row.get("turn_output_tokens")?; + let turn_total_text: Option = row.get("turn_total_tokens")?; + let cum_input_text: Option = row.get("cumulative_input_tokens")?; + let cum_output_text: Option = row.get("cumulative_output_tokens")?; + let cum_total_text: Option = row.get("cumulative_total_tokens")?; + let delta_reliable_int: Option = row.get("delta_reliable")?; + let parse_status_str: String = row.get("parse_status")?; + + Ok(AgentMetricIndexRow { + id: row.get("id")?, + agent_pubkey: row.get("agent_pubkey")?, + event_created_at: row.get("event_created_at")?, + archived_at: row.get("archived_at")?, + reported_at: row.get("reported_at")?, + session_id: row.get("session_id")?, + turn_seq: turn_seq_text.as_deref().and_then(decode_u64_sortable), + model: row.get("model")?, + delta_reliable: delta_reliable_int.map(|v| v != 0), + turn_input_tokens: turn_input_text.as_deref().and_then(decode_u64_sortable), + turn_output_tokens: turn_output_text.as_deref().and_then(decode_u64_sortable), + turn_total_tokens: turn_total_text.as_deref().and_then(decode_u64_sortable), + turn_cost_usd: row.get("turn_cost_usd")?, + cumulative_input_tokens: cum_input_text.as_deref().and_then(decode_u64_sortable), + cumulative_output_tokens: cum_output_text.as_deref().and_then(decode_u64_sortable), + cumulative_total_tokens: cum_total_text.as_deref().and_then(decode_u64_sortable), + cumulative_cost_usd: row.get("cumulative_cost_usd")?, + parse_status: ParseStatus::from_str(&parse_status_str), + }) +} + +const ROW_COLUMNS: &str = "id, agent_pubkey, event_created_at, archived_at, reported_at, \ + session_id, turn_seq, model, delta_reliable, turn_input_tokens, turn_output_tokens, \ + turn_total_tokens, turn_cost_usd, cumulative_input_tokens, cumulative_output_tokens, \ + cumulative_total_tokens, cumulative_cost_usd, parse_status"; + +// ── Write path ─────────────────────────────────────────────────────────────── + +/// Insert one metric index row inside the caller's transaction. +/// +/// Called from `pipeline::commit_archive` ONLY when the corresponding +/// `archived_events` row was newly inserted (never for a duplicate), and +/// from the backfill driver for pre-existing unindexed rows. `INSERT OR +/// IGNORE` on the shared PK makes a second call for the same +/// `(identity, relay, id)` a safe no-op (defensive; callers already guard +/// against re-indexing). +pub(super) fn insert_metric_index_row( + conn: &Connection, + identity_pubkey: &str, + relay_url: &str, + row: &AgentMetricIndexRow, +) -> Result { + let turn_seq = row.turn_seq.map(encode_u64_sortable); + let turn_input = row.turn_input_tokens.map(encode_u64_sortable); + let turn_output = row.turn_output_tokens.map(encode_u64_sortable); + let turn_total = row.turn_total_tokens.map(encode_u64_sortable); + let cum_input = row.cumulative_input_tokens.map(encode_u64_sortable); + let cum_output = row.cumulative_output_tokens.map(encode_u64_sortable); + let cum_total = row.cumulative_total_tokens.map(encode_u64_sortable); + let delta_reliable = row.delta_reliable.map(|b| b as i64); + + let affected = conn + .execute( + "INSERT INTO agent_metric_index + (identity_pubkey, relay_url, id, agent_pubkey, event_created_at, + archived_at, reported_at, session_id, turn_seq, model, + delta_reliable, turn_input_tokens, turn_output_tokens, + turn_total_tokens, turn_cost_usd, cumulative_input_tokens, + cumulative_output_tokens, cumulative_total_tokens, + cumulative_cost_usd, parse_status) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, + ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19, ?20) + ON CONFLICT (identity_pubkey, relay_url, id) DO NOTHING", + params![ + identity_pubkey, + relay_url, + row.id, + row.agent_pubkey, + row.event_created_at, + row.archived_at, + row.reported_at, + row.session_id, + turn_seq, + row.model, + delta_reliable, + turn_input, + turn_output, + turn_total, + row.turn_cost_usd, + cum_input, + cum_output, + cum_total, + row.cumulative_cost_usd, + row.parse_status.as_str(), + ], + ) + .map_err(|e| format!("failed to insert agent_metric_index row: {e}"))?; + Ok(affected > 0) +} + +// ── Backfill ───────────────────────────────────────────────────────────────── + +/// Backfill existing `archived_events` kind-44200 rows that have no matching +/// `agent_metric_index` row yet, for the given identity + relay. +/// +/// Runs in bounded chunks (~500 rows per transaction) so a large existing +/// archive never holds one unbounded write lock; each chunk is independently +/// atomic and the whole backfill is idempotent (anti-join + index PK is the +/// source of truth) and restartable — interruption between chunks loses +/// nothing, and a later run simply resumes against the still-missing rows. +/// +/// Returns the total number of newly indexed rows. +pub(super) fn backfill_agent_metric_index( + conn: &Connection, + identity_pubkey: &str, + relay_url: &str, +) -> Result { + const CHUNK_SIZE: i64 = 500; + let mut total = 0usize; + + loop { + let mut stmt = conn + .prepare( + "SELECT ae.id, ae.pubkey, ae.created_at, ae.archived_at, ae.raw_json + FROM archived_events ae + WHERE ae.identity_pubkey = ?1 + AND ae.relay_url = ?2 + AND ae.kind = 44200 + AND ae.id NOT IN ( + SELECT id FROM agent_metric_index + WHERE identity_pubkey = ?1 + AND relay_url = ?2 + ) + ORDER BY ae.created_at ASC, ae.id ASC + LIMIT ?3", + ) + .map_err(|e| format!("prepare backfill_agent_metric_index select: {e}"))?; + + let chunk: Vec<(String, String, i64, i64, String)> = stmt + .query_map(params![identity_pubkey, relay_url, CHUNK_SIZE], |row| { + Ok(( + row.get::<_, String>(0)?, + row.get::<_, String>(1)?, + row.get::<_, i64>(2)?, + row.get::<_, i64>(3)?, + row.get::<_, String>(4)?, + )) + }) + .map_err(|e| format!("query backfill_agent_metric_index select: {e}"))? + .collect::, _>>() + .map_err(|e| format!("read backfill_agent_metric_index row: {e}"))?; + drop(stmt); + + if chunk.is_empty() { + break; + } + let chunk_len = chunk.len(); + + let tx = conn + .unchecked_transaction() + .map_err(|e| format!("failed to begin backfill chunk transaction: {e}"))?; + for (id, pubkey, created_at, archived_at, raw_json) in &chunk { + let parsed = + AgentMetricIndexRow::from_payload(raw_json, id, pubkey, *created_at, *archived_at); + insert_metric_index_row(&tx, identity_pubkey, relay_url, &parsed)?; + } + tx.commit() + .map_err(|e| format!("failed to commit backfill chunk: {e}"))?; + + total += chunk_len; + if (chunk_len as i64) < CHUNK_SIZE { + break; + } + } + + Ok(total) +} + +// ── GC / orphan repair ─────────────────────────────────────────────────────── + +/// Delete `agent_metric_index` rows whose canonical `archived_events` row no +/// longer exists. Called from `store::gc_orphaned_events` inside the SAME +/// SQLite transaction as the canonical delete (A6) so the index can never +/// observe a canonical row as gone while its own row survives. +pub(super) fn delete_orphaned_metric_index_rows( + conn: &Connection, + identity_pubkey: &str, + relay_url: &str, +) -> Result { + let affected = conn + .execute( + "DELETE FROM agent_metric_index + WHERE identity_pubkey = ?1 + AND relay_url = ?2 + AND id NOT IN ( + SELECT id FROM archived_events + WHERE identity_pubkey = ?1 + AND relay_url = ?2 + )", + params![identity_pubkey, relay_url], + ) + .map_err(|e| format!("failed to gc orphaned agent_metric_index rows: {e}"))?; + Ok(affected) +} + +/// Read-time orphan repair: same anti-join delete as +/// [`delete_orphaned_metric_index_rows`], run defensively before every read +/// so a planted/legacy orphan is self-healed even if a future deletion path +/// forgets to call the GC cascade. Defense in depth, not an atomicity +/// substitute for A6. +pub(super) fn repair_orphaned_metric_index_rows( + conn: &Connection, + identity_pubkey: &str, + relay_url: &str, +) -> Result { + delete_orphaned_metric_index_rows(conn, identity_pubkey, relay_url) +} + +// ── Read path ──────────────────────────────────────────────────────────────── + +/// Load all VALID rows whose `reported_at` falls in `[start, end)`, optionally +/// filtered to one agent author. This is the exact set of rows that may ever +/// be counted into a bucket (A11 step 1). +pub(super) fn load_window_valid_rows( + conn: &Connection, + identity_pubkey: &str, + relay_url: &str, + start: i64, + end: i64, + agent_pubkey: Option<&str>, +) -> Result, String> { + let sql = format!( + "SELECT {ROW_COLUMNS} FROM agent_metric_index + WHERE identity_pubkey = ?1 AND relay_url = ?2 AND parse_status = 'valid' + AND reported_at >= ?3 AND reported_at < ?4 + AND (?5 IS NULL OR agent_pubkey = ?5) + ORDER BY reported_at ASC, id ASC" + ); + let mut stmt = stmt_prepare(conn, &sql)?; + let rows = stmt + .query_map( + params![identity_pubkey, relay_url, start, end, agent_pubkey], + row_from_sql, + ) + .map_err(|e| format!("query load_window_valid_rows: {e}"))?; + rows.collect::, _>>() + .map_err(|e| format!("read load_window_valid_rows row: {e}")) +} + +/// Count INVALID rows whose `event_created_at` falls in `[start, end)` +/// (invalid rows have no trustworthy `reported_at`, so coarse signed +/// `created_at` is the only available time signal), optionally filtered to +/// one agent author. +pub(super) fn count_invalid_rows_in_window( + conn: &Connection, + identity_pubkey: &str, + relay_url: &str, + start: i64, + end: i64, + agent_pubkey: Option<&str>, +) -> Result { + conn.query_row( + "SELECT COUNT(*) FROM agent_metric_index + WHERE identity_pubkey = ?1 AND relay_url = ?2 AND parse_status = 'invalid' + AND event_created_at >= ?3 AND event_created_at < ?4 + AND (?5 IS NULL OR agent_pubkey = ?5)", + params![identity_pubkey, relay_url, start, end, agent_pubkey], + |row| row.get(0), + ) + .map_err(|e| format!("count_invalid_rows_in_window: {e}")) +} + +/// For the given set of exact `(agent_pubkey, session_id, turn_seq)` keys, +/// load ALL valid rows matching those exact keys with NO `reported_at` +/// restriction (A11). Used both for duplicate-cardinality checks at a +/// sequence and for exact-predecessor baseline lookups — a single probe +/// covers both, since the predecessor key is included in the request set +/// alongside each window row's own key. +/// +/// Keys are grouped by `(agent_pubkey, session_id)` and queried with a +/// `turn_seq IN (...)` clause per group (typically few groups per window), +/// served by `idx_agent_metric_session`. +pub(super) fn load_rows_at_exact_keys( + conn: &Connection, + identity_pubkey: &str, + relay_url: &str, + keys: &std::collections::HashSet<(String, String, u64)>, +) -> Result, String> { + use std::collections::HashMap; + + // Group by (agent, session) so each group becomes one IN-list query. + let mut groups: HashMap<(String, String), Vec> = HashMap::new(); + for (agent, session, seq) in keys { + groups + .entry((agent.clone(), session.clone())) + .or_default() + .push(*seq); + } + + let mut out = Vec::new(); + for ((agent, session), seqs) in groups { + let encoded: Vec = seqs.into_iter().map(encode_u64_sortable).collect(); + let sql = format!( + "SELECT {ROW_COLUMNS} FROM agent_metric_index + WHERE identity_pubkey = ?1 AND relay_url = ?2 AND parse_status = 'valid' + AND agent_pubkey = ?3 AND session_id = ?4 + AND turn_seq IN ({})", + encoded + .iter() + .enumerate() + .map(|(i, _)| format!("?{}", i + 5)) + .collect::>() + .join(",") + ); + let mut stmt = stmt_prepare(conn, &sql)?; + let mut bound: Vec> = vec![ + Box::new(identity_pubkey.to_owned()), + Box::new(relay_url.to_owned()), + Box::new(agent.clone()), + Box::new(session.clone()), + ]; + for e in &encoded { + bound.push(Box::new(e.clone())); + } + let refs: Vec<&dyn rusqlite::ToSql> = bound.iter().map(|b| b.as_ref()).collect(); + let rows = stmt + .query_map(refs.as_slice(), row_from_sql) + .map_err(|e| format!("query load_rows_at_exact_keys: {e}"))?; + for r in rows { + out.push(r.map_err(|e| format!("read load_rows_at_exact_keys row: {e}"))?); + } + } + + Ok(out) +} + +/// `hasArchivedEvidence` (A13): does at least one surviving `agent_metric_index` +/// row (either `parse_status`) exist for this author under the active +/// identity+relay, with NO bucket-boundary restriction? Computed after +/// backfill + orphan repair by the caller. +pub(super) fn has_archived_evidence( + conn: &Connection, + identity_pubkey: &str, + relay_url: &str, + agent_pubkey: &str, +) -> Result { + let exists: Option = conn + .query_row( + "SELECT 1 FROM agent_metric_index + WHERE identity_pubkey = ?1 AND relay_url = ?2 AND agent_pubkey = ?3 + LIMIT 1", + params![identity_pubkey, relay_url, agent_pubkey], + |row| row.get(0), + ) + .optional() + .map_err(|e| format!("has_archived_evidence: {e}"))?; + Ok(exists.is_some()) +} + +fn stmt_prepare<'a>(conn: &'a Connection, sql: &str) -> Result, String> { + conn.prepare(sql) + .map_err(|e| format!("prepare failed: {e} — sql: {sql}")) +} + +// ── Tests ──────────────────────────────────────────────────────────────────── + +#[cfg(test)] +#[path = "metric_store_tests.rs"] +mod metric_store_tests; diff --git a/desktop/src-tauri/src/archive/metric_store_tests.rs b/desktop/src-tauri/src/archive/metric_store_tests.rs new file mode 100644 index 0000000000..48fa091fdf --- /dev/null +++ b/desktop/src-tauri/src/archive/metric_store_tests.rs @@ -0,0 +1,579 @@ +//! Unit tests for `archive/metric_store.rs`. +//! +//! Kept in a sibling file so `metric_store.rs` stays under the file-size +//! gate; `#[path]`-included from there. + +use super::*; +use crate::archive::store::{self, SCHEMA}; + +fn in_memory() -> Connection { + let conn = Connection::open_in_memory().unwrap(); + conn.pragma_update(None, "journal_mode", "WAL").unwrap(); + conn.pragma_update(None, "busy_timeout", 5000).unwrap(); + conn.execute_batch(SCHEMA).unwrap(); + conn +} + +fn valid_payload_json(session_id: &str, seq: u64, timestamp: &str) -> String { + format!( + r#"{{"harness":"goose","model":"claude","channelId":null,"sessionId":"{session_id}","turnId":null,"turnSeq":{seq},"timestamp":"{timestamp}","turn":{{"inputTokens":10,"outputTokens":20,"totalTokens":30,"costUsd":0.01}},"cumulative":{{"inputTokens":100,"outputTokens":200,"totalTokens":300,"costUsd":0.1}},"deltaReliable":true,"stopReason":"end_turn"}}"# + ) +} + +#[allow(clippy::too_many_arguments)] +fn insert_archived_event( + conn: &Connection, + identity: &str, + relay: &str, + id: &str, + kind: i64, + pubkey: &str, + created_at: i64, + raw_json: &str, + archived_at: i64, +) { + store::upsert_archived_event( + conn, + identity, + relay, + id, + kind, + pubkey, + created_at, + raw_json, + archived_at, + ) + .unwrap(); +} + +// ── u64 sortable encoding ──────────────────────────────────────────────────── + +#[test] +fn u64_sortable_round_trips_zero_and_max() { + for v in [0u64, 1, 12345, u64::MAX - 1, u64::MAX] { + let encoded = encode_u64_sortable(v); + assert_eq!(encoded.len(), U64_SORTABLE_WIDTH); + assert_eq!(decode_u64_sortable(&encoded), Some(v)); + } +} + +#[test] +fn u64_sortable_encoding_preserves_numeric_order_across_i64_max() { + let below = i64::MAX as u64; + let above = (i64::MAX as u64) + 1; + let e_below = encode_u64_sortable(below); + let e_above = encode_u64_sortable(above); + assert!( + e_below < e_above, + "lexicographic order must match numeric order" + ); +} + +#[test] +fn decode_rejects_wrong_width() { + assert_eq!(decode_u64_sortable("123"), None); + assert_eq!(decode_u64_sortable(""), None); +} + +// ── from_payload parsing ───────────────────────────────────────────────────── + +#[test] +fn from_payload_parses_valid_row() { + let json = valid_payload_json("s1", 7, "2026-07-01T20:11:03.213Z"); + let row = AgentMetricIndexRow::from_payload(&json, "eid1", "agent1", 100, 200); + assert_eq!(row.parse_status, ParseStatus::Valid); + assert_eq!(row.session_id, Some("s1".to_string())); + assert_eq!(row.turn_seq, Some(7)); + assert_eq!(row.turn_input_tokens, Some(10)); + assert_eq!(row.cumulative_input_tokens, Some(100)); + assert_eq!(row.model, Some("claude".to_string())); +} + +#[test] +fn from_payload_marks_unparseable_json_invalid() { + let row = AgentMetricIndexRow::from_payload("not json", "eid1", "agent1", 100, 200); + assert_eq!(row.parse_status, ParseStatus::Invalid); + assert_eq!(row.turn_input_tokens, None); +} + +#[test] +fn from_payload_marks_unparseable_timestamp_invalid() { + let json = r#"{"harness":"goose","timestamp":"not-a-timestamp"}"#; + let row = AgentMetricIndexRow::from_payload(json, "eid1", "agent1", 100, 200); + assert_eq!(row.parse_status, ParseStatus::Invalid); +} + +#[test] +fn from_payload_marks_cumulative_without_session_seq_invalid() { + // cumulative present but sessionId/turnSeq missing — semantic-invalid per A5. + let json = r#"{"harness":"goose","timestamp":"2026-07-01T20:11:03Z","cumulative":{"inputTokens":1,"outputTokens":null,"totalTokens":null,"costUsd":null}}"#; + let row = AgentMetricIndexRow::from_payload(json, "eid1", "agent1", 100, 200); + assert_eq!(row.parse_status, ParseStatus::Invalid); +} + +#[test] +fn from_payload_accepts_missing_cumulative_without_session_seq() { + // No cumulative object at all — session/seq are not required. + let json = r#"{"harness":"goose","timestamp":"2026-07-01T20:11:03Z","turn":{"inputTokens":5,"outputTokens":null,"totalTokens":null,"costUsd":null}}"#; + let row = AgentMetricIndexRow::from_payload(json, "eid1", "agent1", 100, 200); + assert_eq!(row.parse_status, ParseStatus::Valid); + assert_eq!(row.turn_input_tokens, Some(5)); +} + +// ── insert / idempotence ────────────────────────────────────────────────────── + +#[test] +fn insert_metric_index_row_is_idempotent_on_pk() { + let conn = in_memory(); + let row = AgentMetricIndexRow::from_payload( + &valid_payload_json("s1", 1, "2026-07-01T00:00:00Z"), + "eid1", + "agent1", + 100, + 200, + ); + let first = insert_metric_index_row(&conn, "id", "relay", &row).unwrap(); + let second = insert_metric_index_row(&conn, "id", "relay", &row).unwrap(); + assert!(first); + assert!(!second, "second insert of the same PK must be a no-op"); + + let count: i64 = conn + .query_row("SELECT COUNT(*) FROM agent_metric_index", [], |r| r.get(0)) + .unwrap(); + assert_eq!(count, 1); +} + +#[test] +fn insert_metric_index_row_round_trips_u64_max() { + let conn = in_memory(); + let row = AgentMetricIndexRow { + turn_seq: Some(u64::MAX), + turn_input_tokens: Some(u64::MAX), + cumulative_input_tokens: Some(u64::MAX), + ..AgentMetricIndexRow::from_payload( + &valid_payload_json("s1", 1, "2026-07-01T00:00:00Z"), + "eid1", + "agent1", + 100, + 200, + ) + }; + insert_metric_index_row(&conn, "id", "relay", &row).unwrap(); + + let loaded = load_window_valid_rows(&conn, "id", "relay", 0, i64::MAX, None).unwrap(); + assert_eq!(loaded.len(), 1); + assert_eq!(loaded[0].turn_seq, Some(u64::MAX)); + assert_eq!(loaded[0].turn_input_tokens, Some(u64::MAX)); + assert_eq!(loaded[0].cumulative_input_tokens, Some(u64::MAX)); +} + +#[test] +fn insert_invalid_row_preserves_null_parsed_columns() { + let conn = in_memory(); + let row = AgentMetricIndexRow::from_payload("bad json", "eid1", "agent1", 100, 200); + insert_metric_index_row(&conn, "id", "relay", &row).unwrap(); + + let count = count_invalid_rows_in_window(&conn, "id", "relay", 0, i64::MAX, None).unwrap(); + assert_eq!(count, 1); +} + +// ── Identity/relay isolation ───────────────────────────────────────────────── + +#[test] +fn load_window_valid_rows_scoped_to_identity_and_relay() { + let conn = in_memory(); + let row_a = AgentMetricIndexRow::from_payload( + &valid_payload_json("s1", 1, "2026-07-01T00:00:00Z"), + "eidA", + "agent1", + 100, + 200, + ); + let row_b = AgentMetricIndexRow::from_payload( + &valid_payload_json("s1", 1, "2026-07-01T00:00:00Z"), + "eidB", + "agent1", + 100, + 200, + ); + insert_metric_index_row(&conn, "identityA", "relay1", &row_a).unwrap(); + insert_metric_index_row(&conn, "identityB", "relay1", &row_b).unwrap(); + + let loaded_a = load_window_valid_rows(&conn, "identityA", "relay1", 0, i64::MAX, None).unwrap(); + assert_eq!(loaded_a.len(), 1); + assert_eq!(loaded_a[0].id, "eidA"); + + let loaded_b = load_window_valid_rows(&conn, "identityB", "relay1", 0, i64::MAX, None).unwrap(); + assert_eq!(loaded_b.len(), 1); + assert_eq!(loaded_b[0].id, "eidB"); +} + +#[test] +fn load_window_valid_rows_filters_by_agent_pubkey() { + let conn = in_memory(); + let row_a = AgentMetricIndexRow::from_payload( + &valid_payload_json("s1", 1, "2026-07-01T00:00:00Z"), + "eidA", + "agentA", + 100, + 200, + ); + let row_b = AgentMetricIndexRow::from_payload( + &valid_payload_json("s1", 1, "2026-07-01T00:00:00Z"), + "eidB", + "agentB", + 100, + 200, + ); + insert_metric_index_row(&conn, "id", "relay", &row_a).unwrap(); + insert_metric_index_row(&conn, "id", "relay", &row_b).unwrap(); + + let loaded = load_window_valid_rows(&conn, "id", "relay", 0, i64::MAX, Some("agentA")).unwrap(); + assert_eq!(loaded.len(), 1); + assert_eq!(loaded[0].agent_pubkey, "agentA"); +} + +#[test] +fn load_window_valid_rows_excludes_out_of_window_reported_at() { + let conn = in_memory(); + let in_window = AgentMetricIndexRow::from_payload( + &valid_payload_json("s1", 1, "2026-01-02T00:00:00Z"), + "eid_in", + "agent1", + 0, + 0, + ); + let out_of_window = AgentMetricIndexRow::from_payload( + &valid_payload_json("s1", 2, "2020-01-01T00:00:00Z"), + "eid_out", + "agent1", + 0, + 0, + ); + insert_metric_index_row(&conn, "id", "relay", &in_window).unwrap(); + insert_metric_index_row(&conn, "id", "relay", &out_of_window).unwrap(); + + let start = chrono::DateTime::parse_from_rfc3339("2026-01-01T00:00:00Z") + .unwrap() + .timestamp(); + let end = chrono::DateTime::parse_from_rfc3339("2026-01-03T00:00:00Z") + .unwrap() + .timestamp(); + let loaded = load_window_valid_rows(&conn, "id", "relay", start, end, None).unwrap(); + assert_eq!(loaded.len(), 1); + assert_eq!(loaded[0].id, "eid_in"); +} + +// ── load_rows_at_exact_keys ─────────────────────────────────────────────────── + +#[test] +fn load_rows_at_exact_keys_matches_multiple_groups() { + let conn = in_memory(); + let r1 = AgentMetricIndexRow::from_payload( + &valid_payload_json("s1", 5, "2026-07-01T00:00:00Z"), + "e1", + "agent1", + 0, + 0, + ); + let r2 = AgentMetricIndexRow::from_payload( + &valid_payload_json("s2", 9, "2026-07-01T00:00:00Z"), + "e2", + "agent1", + 0, + 0, + ); + let r3_not_requested = AgentMetricIndexRow::from_payload( + &valid_payload_json("s1", 99, "2026-07-01T00:00:00Z"), + "e3", + "agent1", + 0, + 0, + ); + insert_metric_index_row(&conn, "id", "relay", &r1).unwrap(); + insert_metric_index_row(&conn, "id", "relay", &r2).unwrap(); + insert_metric_index_row(&conn, "id", "relay", &r3_not_requested).unwrap(); + + let mut keys = std::collections::HashSet::new(); + keys.insert(("agent1".to_string(), "s1".to_string(), 5u64)); + keys.insert(("agent1".to_string(), "s2".to_string(), 9u64)); + + let loaded = load_rows_at_exact_keys(&conn, "id", "relay", &keys).unwrap(); + let mut ids: Vec<&str> = loaded.iter().map(|r| r.id.as_str()).collect(); + ids.sort(); + assert_eq!(ids, vec!["e1", "e2"]); +} + +#[test] +fn load_rows_at_exact_keys_returns_all_duplicates_at_one_key() { + let conn = in_memory(); + let dup_a = AgentMetricIndexRow::from_payload( + &valid_payload_json("s1", 5, "2026-07-01T00:00:00Z"), + "eA", + "agent1", + 0, + 0, + ); + let dup_b = AgentMetricIndexRow::from_payload( + &valid_payload_json("s1", 5, "2026-07-01T00:00:01Z"), + "eB", + "agent1", + 0, + 1, + ); + insert_metric_index_row(&conn, "id", "relay", &dup_a).unwrap(); + insert_metric_index_row(&conn, "id", "relay", &dup_b).unwrap(); + + let mut keys = std::collections::HashSet::new(); + keys.insert(("agent1".to_string(), "s1".to_string(), 5u64)); + let loaded = load_rows_at_exact_keys(&conn, "id", "relay", &keys).unwrap(); + assert_eq!(loaded.len(), 2); +} + +// ── has_archived_evidence ───────────────────────────────────────────────────── + +#[test] +fn has_archived_evidence_true_for_either_parse_status() { + let conn = in_memory(); + let invalid_row = AgentMetricIndexRow::from_payload("bad", "eid1", "agentX", 0, 0); + insert_metric_index_row(&conn, "id", "relay", &invalid_row).unwrap(); + + assert!(has_archived_evidence(&conn, "id", "relay", "agentX").unwrap()); + assert!(!has_archived_evidence(&conn, "id", "relay", "agentY").unwrap()); +} + +#[test] +fn has_archived_evidence_ignores_bucket_boundaries() { + let conn = in_memory(); + // A very old row (outside any realistic window) still counts as evidence. + let old_row = AgentMetricIndexRow::from_payload( + &valid_payload_json("s1", 1, "1999-01-01T00:00:00Z"), + "eid1", + "agentX", + 0, + 0, + ); + insert_metric_index_row(&conn, "id", "relay", &old_row).unwrap(); + assert!(has_archived_evidence(&conn, "id", "relay", "agentX").unwrap()); +} + +// ── Backfill ────────────────────────────────────────────────────────────────── + +#[test] +fn backfill_indexes_existing_unindexed_kind_44200_rows() { + let conn = in_memory(); + let json = valid_payload_json("s1", 1, "2026-07-01T00:00:00Z"); + insert_archived_event( + &conn, "id", "relay", "eid1", 44200, "agent1", 100, &json, 200, + ); + + let indexed = backfill_agent_metric_index(&conn, "id", "relay").unwrap(); + assert_eq!(indexed, 1); + + let loaded = load_window_valid_rows(&conn, "id", "relay", 0, i64::MAX, None).unwrap(); + assert_eq!(loaded.len(), 1); + assert_eq!(loaded[0].id, "eid1"); +} + +#[test] +fn backfill_is_idempotent_anti_join() { + let conn = in_memory(); + let json = valid_payload_json("s1", 1, "2026-07-01T00:00:00Z"); + insert_archived_event( + &conn, "id", "relay", "eid1", 44200, "agent1", 100, &json, 200, + ); + + backfill_agent_metric_index(&conn, "id", "relay").unwrap(); + let second_run = backfill_agent_metric_index(&conn, "id", "relay").unwrap(); + assert_eq!(second_run, 0, "second backfill run must index nothing new"); + + let count: i64 = conn + .query_row("SELECT COUNT(*) FROM agent_metric_index", [], |r| r.get(0)) + .unwrap(); + assert_eq!(count, 1); +} + +#[test] +fn backfill_processes_chunks_larger_than_500_rows() { + let conn = in_memory(); + // 501 rows exercises the CHUNK_SIZE=500 boundary. + for i in 0..501 { + let json = valid_payload_json(&format!("s{i}"), 1, "2026-07-01T00:00:00Z"); + insert_archived_event( + &conn, + "id", + "relay", + &format!("eid{i}"), + 44200, + "agent1", + 100 + i as i64, + &json, + 200, + ); + } + let indexed = backfill_agent_metric_index(&conn, "id", "relay").unwrap(); + assert_eq!(indexed, 501); + + let count: i64 = conn + .query_row("SELECT COUNT(*) FROM agent_metric_index", [], |r| r.get(0)) + .unwrap(); + assert_eq!(count, 501); +} + +#[test] +fn backfill_ignores_non_44200_rows() { + let conn = in_memory(); + insert_archived_event(&conn, "id", "relay", "eid1", 1, "author1", 100, "{}", 200); + let indexed = backfill_agent_metric_index(&conn, "id", "relay").unwrap(); + assert_eq!(indexed, 0); +} + +// ── GC / orphan repair ──────────────────────────────────────────────────────── + +#[test] +fn delete_orphaned_metric_index_rows_removes_rows_with_no_canonical_event() { + let conn = in_memory(); + // Planted orphan: index row with no matching archived_events row. + let orphan = AgentMetricIndexRow::from_payload( + &valid_payload_json("s1", 1, "2026-07-01T00:00:00Z"), + "orphan_id", + "agent1", + 0, + 0, + ); + insert_metric_index_row(&conn, "id", "relay", &orphan).unwrap(); + + let deleted = delete_orphaned_metric_index_rows(&conn, "id", "relay").unwrap(); + assert_eq!(deleted, 1); + + let count: i64 = conn + .query_row("SELECT COUNT(*) FROM agent_metric_index", [], |r| r.get(0)) + .unwrap(); + assert_eq!(count, 0); +} + +#[test] +fn delete_orphaned_metric_index_rows_preserves_rows_with_canonical_event() { + let conn = in_memory(); + let json = valid_payload_json("s1", 1, "2026-07-01T00:00:00Z"); + insert_archived_event( + &conn, "id", "relay", "eid1", 44200, "agent1", 100, &json, 200, + ); + let row = AgentMetricIndexRow::from_payload(&json, "eid1", "agent1", 100, 200); + insert_metric_index_row(&conn, "id", "relay", &row).unwrap(); + + let deleted = delete_orphaned_metric_index_rows(&conn, "id", "relay").unwrap(); + assert_eq!(deleted, 0); +} + +#[test] +fn repair_orphaned_metric_index_rows_self_heals_planted_orphan_before_read() { + let conn = in_memory(); + let orphan = AgentMetricIndexRow::from_payload( + &valid_payload_json("s1", 1, "2026-07-01T00:00:00Z"), + "orphan_id", + "agent1", + 0, + 0, + ); + insert_metric_index_row(&conn, "id", "relay", &orphan).unwrap(); + + repair_orphaned_metric_index_rows(&conn, "id", "relay").unwrap(); + let loaded = load_window_valid_rows(&conn, "id", "relay", 0, i64::MAX, None).unwrap(); + assert!( + loaded.is_empty(), + "planted orphan must never be reported after repair" + ); +} + +#[test] +fn gc_orphaned_events_cascades_to_metric_index_atomically() { + let conn = in_memory(); + let json = valid_payload_json("s1", 1, "2026-07-01T00:00:00Z"); + insert_archived_event( + &conn, "id", "relay", "eid1", 44200, "agent1", 100, &json, 200, + ); + let row = AgentMetricIndexRow::from_payload(&json, "eid1", "agent1", 100, 200); + insert_metric_index_row(&conn, "id", "relay", &row).unwrap(); + + // Remove the last scope row so the event becomes orphaned, then GC. + // (No scope row was ever added in this test, so the event is already + // orphaned by construction — gc_orphaned_events should delete both the + // canonical row and its index row in one transaction.) + store::gc_orphaned_events(&conn, "id", "relay").unwrap(); + + let event_count: i64 = conn + .query_row("SELECT COUNT(*) FROM archived_events", [], |r| r.get(0)) + .unwrap(); + let index_count: i64 = conn + .query_row("SELECT COUNT(*) FROM agent_metric_index", [], |r| r.get(0)) + .unwrap(); + assert_eq!(event_count, 0); + assert_eq!( + index_count, 0, + "index row must not outlive its canonical event" + ); +} + +// ── EXPLAIN QUERY PLAN index assertions (A7) ───────────────────────────────── + +fn query_plan(conn: &Connection, sql: &str, params: &[&dyn rusqlite::ToSql]) -> String { + let explain_sql = format!("EXPLAIN QUERY PLAN {sql}"); + let mut stmt = conn.prepare(&explain_sql).unwrap(); + let mut rows = stmt.query(params).unwrap(); + let mut plan = String::new(); + while let Some(row) = rows.next().unwrap() { + let detail: String = row.get(3).unwrap(); + plan.push_str(&detail); + plan.push('\n'); + } + plan +} + +#[test] +fn backfill_anti_join_uses_partial_index() { + let conn = in_memory(); + let plan = query_plan( + &conn, + "SELECT ae.id FROM archived_events ae + WHERE ae.identity_pubkey = ?1 AND ae.relay_url = ?2 AND ae.kind = 44200 + AND ae.id NOT IN (SELECT id FROM agent_metric_index WHERE identity_pubkey = ?1 AND relay_url = ?2)", + &[&"id", &"relay"], + ); + assert!( + plan.contains("idx_archived_events_agent_metric"), + "backfill anti-join must use the partial index, plan was:\n{plan}" + ); +} + +#[test] +fn window_scan_uses_reported_index() { + let conn = in_memory(); + let plan = query_plan( + &conn, + "SELECT * FROM agent_metric_index + WHERE identity_pubkey = ?1 AND relay_url = ?2 AND parse_status = 'valid' + AND reported_at >= ?3 AND reported_at < ?4", + &[&"id", &"relay", &0i64, &100i64], + ); + assert!( + plan.contains("idx_agent_metric_reported"), + "window scan must use the reported-time index, plan was:\n{plan}" + ); +} + +#[test] +fn predecessor_lookup_uses_session_index() { + let conn = in_memory(); + let plan = query_plan( + &conn, + "SELECT * FROM agent_metric_index + WHERE identity_pubkey = ?1 AND relay_url = ?2 AND parse_status = 'valid' + AND agent_pubkey = ?3 AND session_id = ?4 AND turn_seq IN (?5)", + &[&"id", &"relay", &"agent1", &"s1", &"00000000000000000005"], + ); + assert!( + plan.contains("idx_agent_metric_session"), + "predecessor lookup must use the session index, plan was:\n{plan}" + ); +} diff --git a/desktop/src-tauri/src/archive/mod.rs b/desktop/src-tauri/src/archive/mod.rs index a65b126a4d..0f56c432b6 100644 --- a/desktop/src-tauri/src/archive/mod.rs +++ b/desktop/src-tauri/src/archive/mod.rs @@ -17,6 +17,8 @@ //! validation (sig/id + kind + p-tag + agent tag + frame=telemetry + author //! == agent) is applied fail-closed. +mod agent_usage; +mod metric_store; mod pipeline; pub mod store; @@ -116,9 +118,17 @@ pub struct MatchedScope { /// Result of a batch archive call. #[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] pub struct ArchiveBatchResult { /// Events successfully written to the store (event + scope rows). pub persisted: u32, + /// Newly-indexed `agent_metric_index` rows (valid or invalid) written in + /// this call — the count the frontend uses to decide whether an + /// agent-usage query needs to be invalidated. Distinct from `persisted`: + /// a re-ingested duplicate can be `persisted` (event/scope rows upserted, + /// no-op) without incrementing this counter, since the index row for + /// that id was already inserted by whichever earlier batch first saw it. + pub persisted_agent_metrics: u32, /// Events dropped due to access denial or invalid payload (not an error). pub dropped: u32, } @@ -666,6 +676,96 @@ pub async fn read_archived_events( .await } +// ── get_agent_usage_series ─────────────────────────────────────────────────── + +/// Compute the locally archived NIP-AM usage series for one identity/relay. +/// +/// Synchronous SQLite core of [`get_agent_usage_series`], split out so tests +/// can drive it directly against an in-memory `Connection` without a Tauri +/// `AppState`. Backfills any unindexed kind-44200 rows, repairs orphaned +/// index rows (defense in depth alongside A6's GC-time cascade), validates +/// the request, loads the window + exact-key probe rows, and hands them to +/// the pure `agent_usage::compute_series`. +fn agent_usage_series( + conn: &Connection, + identity_pk: &str, + relay_url: &str, + request: &agent_usage::AgentUsageSeriesRequest, +) -> Result { + // Fail-closed request validation happens before any SQLite work. + let agent_pubkey = agent_usage::validate_request(request)?; + + metric_store::backfill_agent_metric_index(conn, identity_pk, relay_url)?; + metric_store::repair_orphaned_metric_index_rows(conn, identity_pk, relay_url)?; + + let collection_enabled = { + let kinds_json = + store::get_subscription_kinds(conn, identity_pk, relay_url, "owner_p", identity_pk)? + .unwrap_or_else(|| "[]".to_string()); + let kinds: Vec = serde_json::from_str(&kinds_json).unwrap_or_default(); + kinds.contains(&(KIND_AGENT_TURN_METRIC as u64)) + }; + + // A13: only meaningful when the request is scoped to one author. + let has_archived_evidence = match &agent_pubkey { + None => None, + Some(pk) => Some(metric_store::has_archived_evidence( + conn, + identity_pk, + relay_url, + pk, + )?), + }; + + let start = request.bucket_boundaries[0]; + let end = *request + .bucket_boundaries + .last() + .expect("validate_request already rejected fewer than 8 boundaries"); + + let window_rows = metric_store::load_window_valid_rows( + conn, + identity_pk, + relay_url, + start, + end, + agent_pubkey.as_deref(), + )?; + let invalid_report_count = metric_store::count_invalid_rows_in_window( + conn, + identity_pk, + relay_url, + start, + end, + agent_pubkey.as_deref(), + )?; + let probe_keys = agent_usage::window_probe_keys(&window_rows); + let probe_rows = + metric_store::load_rows_at_exact_keys(conn, identity_pk, relay_url, &probe_keys)?; + + Ok(agent_usage::compute_series( + &window_rows, + &probe_rows, + invalid_report_count, + &request.bucket_boundaries, + has_archived_evidence, + collection_enabled, + )) +} + +/// Compute the locally archived NIP-AM usage series for the active identity +/// + relay (Rev 3 frozen contract). See [`agent_usage_series`] for the logic. +#[tauri::command] +pub async fn get_agent_usage_series( + state: State<'_, AppState>, + request: agent_usage::AgentUsageSeriesRequest, +) -> Result { + let identity_pk = identity_pubkey(&state)?; + let relay_url = relay_ws_url_with_override(&state); + run_archive_db_task(move |conn| agent_usage_series(conn, &identity_pk, &relay_url, &request)) + .await +} + // ── Tests ──────────────────────────────────────────────────────────────────── #[cfg(test)] diff --git a/desktop/src-tauri/src/archive/mod_tests.rs b/desktop/src-tauri/src/archive/mod_tests.rs index 288d2ab34c..0addb8b6cf 100644 --- a/desktop/src-tauri/src/archive/mod_tests.rs +++ b/desktop/src-tauri/src/archive/mod_tests.rs @@ -740,6 +740,10 @@ fn test_turn_metric_decrypt_success_stores_plaintext() { assert_eq!(result.persisted, 1, "event must be persisted"); assert_eq!(result.dropped, 0, "no drops on successful decrypt"); + assert_eq!( + result.persisted_agent_metrics, 1, + "one newly-indexed agent_metric_index row on first ingest" + ); // The stored raw_json must be plaintext JSON, not NIP-44 ciphertext. let raw_json: String = conn @@ -798,6 +802,226 @@ fn test_turn_metric_decrypt_fail_drops_fail_closed() { ); } +/// Re-ingesting a batch containing an already-archived kind-44200 event must +/// no-op the metric index insert: `persisted` still counts the (idempotent) +/// event/scope upsert, but `persisted_agent_metrics` must be 0 for the +/// duplicate — the row was already indexed by the first ingest (A5: this is +/// exactly the signal the frontend uses to skip a redundant query +/// invalidation). +#[test] +fn test_reingest_of_same_metric_event_does_not_double_count_persisted_agent_metrics() { + 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 cand1 = candidate(&ev, ScopeType::OwnerP, &owner_pk); + + let first = run_batch_sync_with_keys( + vec![cand1], + &owner_pk, + relay_url, + &conn, + vec![ev.clone()], + &owner_keys, + ); + assert_eq!(first.persisted, 1); + assert_eq!(first.persisted_agent_metrics, 1); + + // Same event re-ingested in a second batch (e.g. relay redelivery). + let cand2 = candidate(&ev, ScopeType::OwnerP, &owner_pk); + let second = run_batch_sync_with_keys( + vec![cand2], + &owner_pk, + relay_url, + &conn, + vec![ev.clone()], + &owner_keys, + ); + assert_eq!( + second.persisted, 1, + "re-ingest of a duplicate is still an accepted (idempotent) write" + ); + assert_eq!( + second.persisted_agent_metrics, 0, + "re-ingest must NOT double-count the metric index row" + ); + + let index_count: i64 = conn + .query_row("SELECT COUNT(*) FROM agent_metric_index", [], |r| r.get(0)) + .unwrap(); + assert_eq!(index_count, 1, "exactly one index row must exist total"); +} + +// ── get_agent_usage_series integration ────────────────────────────────────── +// +// Exercises `agent_usage_series` (the sync core `get_agent_usage_series` +// delegates to) end to end: ingest a real encrypted turn-metric event +// through the full archive pipeline, then read it back through the command +// core, proving backfill/indexing, collection-enabled detection, and the +// pure accounting ladder are wired together correctly — not just each in +// isolation. + +/// A freshly ingested single turn-metric event surfaces in the series with +/// its direct (delta-reliable, no-baseline) token counts, and +/// `collectionEnabled` reflects the active owner_p/44200 subscription. +#[test] +fn test_agent_usage_series_surfaces_freshly_ingested_event() { + let conn = in_memory(); + let owner_keys = Keys::generate(); + let agent_keys = Keys::generate(); + let owner_pk = owner_keys.public_key().to_hex(); + let agent_pk = agent_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 batch = run_batch_sync_with_keys( + vec![cand], + &owner_pk, + relay_url, + &conn, + vec![ev.clone()], + &owner_keys, + ); + assert_eq!(batch.persisted_agent_metrics, 1, "event must be indexed"); + + // `make_turn_metric_event`'s payload timestamp is 2026-07-01T00:00:00Z. + const EVENT_DAY_START: i64 = 1_782_864_000; + let boundaries: Vec = (0..=7).map(|i| EVENT_DAY_START + i * 86_400).collect(); + let request = agent_usage::AgentUsageSeriesRequest { + bucket_boundaries: boundaries, + agent_pubkey: None, + }; + + let series = agent_usage_series(&conn, &owner_pk, relay_url, &request).unwrap(); + + assert!( + series.collection_enabled, + "owner_p subscription includes kind 44200" + ); + assert_eq!(series.coverage.report_count, 1); + assert_eq!(series.coverage.invalid_report_count, 0); + assert_eq!(series.agents.len(), 1, "exactly one agent reported usage"); + let agent = &series.agents[0]; + assert_eq!(agent.agent_pubkey, agent_pk); + // No baseline row exists, so the ladder falls back to the direct + // (delta-reliable) turn values from the payload: 100/50/150. + assert_eq!(agent.usage.input_tokens.value.as_deref(), Some("100")); + assert_eq!(agent.usage.output_tokens.value.as_deref(), Some("50")); + assert_eq!(agent.usage.total_tokens.value.as_deref(), Some("150")); + assert!(!agent.usage.input_tokens.incomplete); + assert_eq!( + series.has_archived_evidence, None, + "no agentPubkey filter was supplied" + ); +} + +/// Filtering by `agentPubkey` scopes both the returned series and +/// `hasArchivedEvidence` (A13) to that one author; an unrelated agent's +/// events must not leak into either. +#[test] +fn test_agent_usage_series_filters_by_agent_pubkey_and_sets_has_archived_evidence() { + let conn = in_memory(); + let owner_keys = Keys::generate(); + let target_agent = Keys::generate(); + let other_agent = Keys::generate(); + let owner_pk = owner_keys.public_key().to_hex(); + let target_pk = target_agent.public_key().to_hex(); + let relay_url = "wss://relay.example"; + add_sub(&conn, &owner_pk, relay_url, "owner_p", &owner_pk, "[44200]"); + + let target_ev = make_turn_metric_event(&owner_keys, &target_agent); + let other_ev = make_turn_metric_event(&owner_keys, &other_agent); + let cands = vec![ + candidate(&target_ev, ScopeType::OwnerP, &owner_pk), + candidate(&other_ev, ScopeType::OwnerP, &owner_pk), + ]; + let batch = run_batch_sync_with_keys( + cands, + &owner_pk, + relay_url, + &conn, + vec![target_ev.clone(), other_ev.clone()], + &owner_keys, + ); + assert_eq!(batch.persisted_agent_metrics, 2); + + const EVENT_DAY_START: i64 = 1_782_864_000; + let boundaries: Vec = (0..=7).map(|i| EVENT_DAY_START + i * 86_400).collect(); + let request = agent_usage::AgentUsageSeriesRequest { + bucket_boundaries: boundaries, + agent_pubkey: Some(target_pk.clone()), + }; + + let series = agent_usage_series(&conn, &owner_pk, relay_url, &request).unwrap(); + + assert_eq!( + series.agents.len(), + 1, + "only the filtered agent's usage must be returned" + ); + assert_eq!(series.agents[0].agent_pubkey, target_pk); + assert_eq!( + series.has_archived_evidence, + Some(true), + "A13: evidence exists for the filtered author" + ); +} + +/// An unindexed pre-existing kind-44200 row (simulating an event archived +/// by a prior build before `agent_metric_index` existed) is picked up by +/// the command's backfill step before the window is read. +#[test] +fn test_agent_usage_series_backfills_unindexed_row_before_reading() { + 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"; + + // Insert directly into `archived_events`, bypassing `commit_archive`, so + // no `agent_metric_index` row is created — the exact state a fresh + // backfill must repair. + let ev = make_turn_metric_event(&owner_keys, &agent_keys); + let plaintext = r#"{"harness":"test-harness","model":"test-model","sessionId":"sess-1","turnId":"turn-1","turnSeq":1,"timestamp":"2026-07-01T00:00:00Z","turn":{"inputTokens":100,"outputTokens":50,"totalTokens":150,"costUsd":0.001},"deltaReliable":true}"#; + store::upsert_archived_event( + &conn, + &owner_pk, + relay_url, + &ev.id.to_hex(), + 44200, + &agent_keys.public_key().to_hex(), + ev.created_at.as_secs() as i64, + plaintext, + 0, + ) + .unwrap(); + + let index_count_before: i64 = conn + .query_row("SELECT COUNT(*) FROM agent_metric_index", [], |r| r.get(0)) + .unwrap(); + assert_eq!(index_count_before, 0, "no index row before backfill"); + + const EVENT_DAY_START: i64 = 1_782_864_000; + let boundaries: Vec = (0..=7).map(|i| EVENT_DAY_START + i * 86_400).collect(); + let request = agent_usage::AgentUsageSeriesRequest { + bucket_boundaries: boundaries, + agent_pubkey: None, + }; + + let series = agent_usage_series(&conn, &owner_pk, relay_url, &request).unwrap(); + + assert_eq!( + series.coverage.report_count, 1, + "backfill must index the pre-existing row before the window read" + ); +} + // ── Real-relay integration tests ────────────────────────────────────────── // // Gated on `#[cfg(not(target_os = "windows"))]` because `build_app_state()` diff --git a/desktop/src-tauri/src/archive/pipeline.rs b/desktop/src-tauri/src/archive/pipeline.rs index 2bd149dceb..98ff64dff4 100644 --- a/desktop/src-tauri/src/archive/pipeline.rs +++ b/desktop/src-tauri/src/archive/pipeline.rs @@ -268,6 +268,7 @@ pub(super) fn commit_archive( conn: &Connection, ) -> Result { let mut persisted: u32 = 0; + let mut persisted_agent_metrics: u32 = 0; let mut dropped: u32 = pre_dropped; // Collect writes; count drops first, then execute inside a single @@ -399,6 +400,36 @@ pub(super) fn commit_archive( &w.scope_value, now, )?; + + // Index kind-44200 rows in the SAME transaction as the canonical + // insert (Rev 2 F5): the plaintext payload was already decrypted + // above into `w.raw_json`, so this is parse-only, no re-decrypt. + // `insert_metric_index_row`'s own `ON CONFLICT DO NOTHING` makes + // a duplicate call for an already-indexed id (e.g. re-ingest of + // a row seen in an earlier batch) a safe no-op — its `bool` + // return tells us whether this call actually inserted a new + // index row, which is exactly what `persisted_agent_metrics` + // counts (A5: newly-indexed rows, valid or invalid, not raw + // write attempts). + if w.kind == super::KIND_AGENT_TURN_METRIC as i64 { + let index_row = super::metric_store::AgentMetricIndexRow::from_payload( + &w.raw_json, + &w.eid, + &w.pubkey, + w.created_at, + now, + ); + let index_inserted = super::metric_store::insert_metric_index_row( + &tx, + identity_pk, + relay_url, + &index_row, + )?; + if index_inserted { + persisted_agent_metrics += 1; + } + } + persisted += 1; } @@ -450,5 +481,9 @@ pub(super) fn commit_archive( .map_err(|e| format!("failed to commit archive transaction: {e}"))?; } - Ok(ArchiveBatchResult { persisted, dropped }) + Ok(ArchiveBatchResult { + persisted, + persisted_agent_metrics, + dropped, + }) } diff --git a/desktop/src-tauri/src/archive/store.rs b/desktop/src-tauri/src/archive/store.rs index ae0ef92e4b..d3e469c6b5 100644 --- a/desktop/src-tauri/src/archive/store.rs +++ b/desktop/src-tauri/src/archive/store.rs @@ -75,6 +75,59 @@ CREATE TABLE IF NOT EXISTS archive_migrations ( name TEXT PRIMARY KEY, applied_at INTEGER NOT NULL ); + +-- Parsed index of kind 44200 (NIP-AM agent turn metric) archive rows. +-- +-- Rebuildable from `archived_events.raw_json` — never the source of truth. +-- Every archived kind-44200 row gets exactly one row here, keyed by +-- (identity, relay, id), with `parse_status` 'valid' or 'invalid'. Token +-- counters are stored as fixed-width 20-digit zero-padded decimal TEXT +-- (order-preserving lexicographically) because SQLite INTEGER is signed +-- i64 and NIP-AM counters are full-range u64. `turn_seq` uses the same +-- encoding so it survives sequence values above i64::MAX. +CREATE TABLE IF NOT EXISTS agent_metric_index ( + identity_pubkey TEXT NOT NULL, + relay_url TEXT NOT NULL, + id TEXT NOT NULL, + agent_pubkey TEXT NOT NULL, + event_created_at INTEGER NOT NULL, + archived_at INTEGER NOT NULL, + reported_at INTEGER, + session_id TEXT, + turn_seq TEXT, + model TEXT, + delta_reliable INTEGER, + turn_input_tokens TEXT, + turn_output_tokens TEXT, + turn_total_tokens TEXT, + turn_cost_usd REAL, + cumulative_input_tokens TEXT, + cumulative_output_tokens TEXT, + cumulative_total_tokens TEXT, + cumulative_cost_usd REAL, + parse_status TEXT NOT NULL CHECK (parse_status IN ('valid','invalid')), + PRIMARY KEY (identity_pubkey, relay_url, id) +); + +-- Backfill/anti-join source: scoped partial index so the per-read backfill +-- scan over archived_events only touches kind-44200 rows. +CREATE INDEX IF NOT EXISTS idx_archived_events_agent_metric + ON archived_events (identity_pubkey, relay_url, id) + WHERE kind = 44200; + +-- Predecessor/baseline lookup: exact-sequence probes (A11) and duplicate +-- cardinality checks key on this prefix. +CREATE INDEX IF NOT EXISTS idx_agent_metric_session + ON agent_metric_index (identity_pubkey, relay_url, agent_pubkey, session_id, turn_seq, id); + +-- Window scan by reported time. +CREATE INDEX IF NOT EXISTS idx_agent_metric_reported + ON agent_metric_index (identity_pubkey, relay_url, reported_at); + +-- Coarse-time scan for invalid-row coverage (invalid rows lack a trustworthy +-- reported_at, so their window membership is judged by event_created_at). +CREATE INDEX IF NOT EXISTS idx_agent_metric_created + ON agent_metric_index (identity_pubkey, relay_url, event_created_at, parse_status); "; // ── Open / init ───────────────────────────────────────────────────────────── @@ -442,6 +495,8 @@ pub fn get_subscription_kinds( /// Upsert an event row (idempotent on the PK). /// /// Does nothing if the event is already archived (same identity/relay/id). +/// Returns `true` iff this call inserted a new row (`false` if the row +/// already existed and the `ON CONFLICT DO NOTHING` no-op'd). // Args mirror the archived_events columns; a params struct would just rename them. #[allow(clippy::too_many_arguments)] pub fn upsert_archived_event( @@ -454,25 +509,26 @@ pub fn upsert_archived_event( created_at: i64, raw_json: &str, archived_at: i64, -) -> Result<(), String> { - conn.execute( - "INSERT INTO archived_events - (identity_pubkey, relay_url, id, kind, pubkey, created_at, raw_json, archived_at) - VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8) - ON CONFLICT (identity_pubkey, relay_url, id) DO NOTHING", - params![ - identity_pubkey, - relay_url, - event_id, - kind, - pubkey, - created_at, - raw_json, - archived_at - ], - ) - .map_err(|e| format!("failed to upsert archived event: {e}"))?; - Ok(()) +) -> Result { + let affected = conn + .execute( + "INSERT INTO archived_events + (identity_pubkey, relay_url, id, kind, pubkey, created_at, raw_json, archived_at) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8) + ON CONFLICT (identity_pubkey, relay_url, id) DO NOTHING", + params![ + identity_pubkey, + relay_url, + event_id, + kind, + pubkey, + created_at, + raw_json, + archived_at + ], + ) + .map_err(|e| format!("failed to upsert archived event: {e}"))?; + Ok(affected > 0) } /// Upsert a scope membership row for an event. @@ -771,17 +827,28 @@ pub fn read_archived_observer_events_for_channel( .map_err(|e| format!("read read_archived_observer_events_for_channel row: {e}")) } -/// GC: delete orphaned event rows whose last scope row was just removed. +/// GC: delete orphaned event rows whose last scope row was just removed, and +/// atomically cascade-delete any `agent_metric_index` rows whose canonical +/// `archived_events` row no longer exists. +/// +/// Both deletes run inside ONE SQLite transaction (not two autocommit +/// statements) so the derived index can never observe a canonical row as +/// gone while the index row it produced still exists — an index row must +/// never outlive the event it was parsed from (A6). /// -/// Called after any batch deletion of scope rows. Uses a LEFT JOIN so only -/// events with zero remaining scope rows are deleted. +/// Called after any batch deletion of scope rows. Uses a LEFT JOIN-equivalent +/// anti-join so only events with zero remaining scope rows are deleted. #[allow(dead_code)] // Used by P4 purge commands; not yet wired to a Tauri command. pub fn gc_orphaned_events( conn: &Connection, identity_pubkey: &str, relay_url: &str, ) -> Result { - let affected = conn + let tx = conn + .unchecked_transaction() + .map_err(|e| format!("failed to begin gc_orphaned_events transaction: {e}"))?; + + let affected = tx .execute( "DELETE FROM archived_events WHERE identity_pubkey = ?1 @@ -794,6 +861,11 @@ pub fn gc_orphaned_events( params![identity_pubkey, relay_url], ) .map_err(|e| format!("failed to gc orphaned events: {e}"))?; + + super::metric_store::delete_orphaned_metric_index_rows(&tx, identity_pubkey, relay_url)?; + + tx.commit() + .map_err(|e| format!("failed to commit gc_orphaned_events transaction: {e}"))?; Ok(affected) } diff --git a/desktop/src-tauri/src/archive/store_tests.rs b/desktop/src-tauri/src/archive/store_tests.rs index 0a07811e7c..bbd15391e7 100644 --- a/desktop/src-tauri/src/archive/store_tests.rs +++ b/desktop/src-tauri/src/archive/store_tests.rs @@ -285,9 +285,13 @@ fn test_remove_owner_p_kind_noop_when_kind_absent() { #[test] fn test_upsert_archived_event_is_idempotent() { let conn = in_memory(); - upsert_archived_event(&conn, "pk", "wss://r", "id1", 1, "author", 100, "{}", 200).unwrap(); - // Second call must not error or duplicate. - upsert_archived_event(&conn, "pk", "wss://r", "id1", 1, "author", 100, "{}", 201).unwrap(); + let first = + upsert_archived_event(&conn, "pk", "wss://r", "id1", 1, "author", 100, "{}", 200).unwrap(); + assert!(first, "first insert of a new id must report newly-inserted"); + // Second call must not error or duplicate, and must report false (no new row). + let second = + upsert_archived_event(&conn, "pk", "wss://r", "id1", 1, "author", 100, "{}", 201).unwrap(); + assert!(!second, "duplicate insert must report NOT newly-inserted"); let count: i64 = conn .query_row("SELECT COUNT(*) FROM archived_events", [], |r| r.get(0)) .unwrap(); diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index feddd4dad1..979b721e6b 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -876,6 +876,7 @@ pub fn run() { archive::read_archived_observer_events_for_channel, archive::index_observer_channel_id, archive::read_unindexed_observer_rows, + archive::get_agent_usage_series, is_auto_update_supported, set_window_vibrancy, ]) diff --git a/desktop/src/features/agent-usage/hooks.test.mjs b/desktop/src/features/agent-usage/hooks.test.mjs new file mode 100644 index 0000000000..64128961d0 --- /dev/null +++ b/desktop/src/features/agent-usage/hooks.test.mjs @@ -0,0 +1,283 @@ +/** + * Fake-timer proof for the M4-frozen midnight-rollover contract: + * `useLocalDayBoundaries` schedules exactly one `setTimeout` per local + * midnight, and each fire both rebuilds the boundary set (so the query key + * changes) AND reschedules the next fire — never `setInterval`, which would + * drift across DST. + * + * Uses the same minimal DOM shim + `react-dom/client` `createRoot`/`act` + * harness pattern as `useAnchoredScroll.lifecycle.test.mjs`, combined with + * `node:test`'s `mock.timers` (as used in `activeAgentTurnsStore.test.mjs`) + * to drive the wall clock deterministically across the rollover boundary. + */ + +import assert from "node:assert/strict"; +import test from "node:test"; +import { mock } from "node:test"; + +function installDOMShim() { + class EventTargetShim { + constructor() { + this.listeners = new Map(); + } + + addEventListener(type, listener) { + this.listeners.set(type, [...(this.listeners.get(type) ?? []), listener]); + } + + removeEventListener(type, listener) { + this.listeners.set( + type, + (this.listeners.get(type) ?? []).filter( + (current) => current !== listener, + ), + ); + } + + dispatchEvent(event) { + for (const listener of this.listeners.get(event.type) ?? []) + listener(event); + return true; + } + } + + class NodeShim extends EventTargetShim { + constructor(tagName) { + super(); + this.tagName = tagName; + this.nodeName = tagName.toUpperCase(); + this.nodeType = 1; + this.namespaceURI = "http://www.w3.org/1999/xhtml"; + this.children = []; + this.childNodes = []; + this.style = {}; + this.parentNode = null; + } + + get ownerDocument() { + return globalThis.document; + } + + get firstChild() { + return this.children[0] ?? null; + } + + get lastChild() { + return this.children.at(-1) ?? null; + } + + get nextSibling() { + return null; + } + + get nodeValue() { + return null; + } + + appendChild(child) { + this.children.push(child); + this.childNodes.push(child); + child.parentNode = this; + return child; + } + + removeChild(child) { + this.children = this.children.filter((current) => current !== child); + this.childNodes = this.childNodes.filter((current) => current !== child); + child.parentNode = null; + return child; + } + + insertBefore(child, reference) { + if (!reference) return this.appendChild(child); + const index = this.children.indexOf(reference); + if (index < 0) return this.appendChild(child); + this.children.splice(index, 0, child); + this.childNodes.splice(index, 0, child); + child.parentNode = this; + return child; + } + + contains(node) { + return ( + this === node || this.children.some((child) => child.contains(node)) + ); + } + } + + class DocumentShim extends EventTargetShim { + constructor() { + super(); + this.nodeType = 9; + this.defaultView = globalThis; + } + + createElement(tagName) { + return new NodeShim(tagName); + } + + createTextNode(value) { + const node = new NodeShim("#text"); + node.nodeType = 3; + node.nodeValue = value; + return node; + } + + createComment(value) { + const node = new NodeShim("#comment"); + node.nodeType = 8; + node.nodeValue = value; + return node; + } + + get activeElement() { + return null; + } + } + + globalThis.document = new DocumentShim(); + globalThis.HTMLIFrameElement = NodeShim; + globalThis.HTMLElement = NodeShim; + globalThis.IS_REACT_ACT_ENVIRONMENT = true; + process.env.IS_REACT_ACT_ENVIRONMENT = "true"; + Object.defineProperty(globalThis, "window", { + configurable: true, + value: globalThis, + }); + // Never route through the mocked `setTimeout` — React's scheduler falls + // back to a real `MessageChannel` (present natively in Node), so mocking + // only `setTimeout`/`Date` below cannot stall a commit. + globalThis.requestAnimationFrame = (callback) => setTimeout(callback, 0); + globalThis.cancelAnimationFrame = (id) => clearTimeout(id); + globalThis.CSS = { escape: (value) => value }; +} + +installDOMShim(); + +import React from "react"; +import { act } from "react"; +import { createRoot } from "react-dom/client"; + +import { useLocalDayBoundaries } from "./hooks.ts"; + +function Harness({ days, onBoundaries }) { + const boundaries = useLocalDayBoundaries(days); + onBoundaries(boundaries); + return null; +} + +test("useLocalDayBoundaries reschedules across two local-midnight rollovers, rebuilding boundaries each time", async () => { + // One minute before local midnight, so the first scheduled `setTimeout` + // fires quickly under `mock.timers`. + const beforeMidnight = new Date(2026, 5, 15, 23, 59, 0); + mock.timers.enable({ + apis: ["setTimeout", "Date"], + now: beforeMidnight.getTime(), + }); + + try { + let latest = null; + const captured = []; + const onBoundaries = (boundaries) => { + latest = boundaries; + }; + + const root = createRoot(document.createElement("div")); + await act(async () => { + root.render(React.createElement(Harness, { days: 7, onBoundaries })); + }); + + assert.equal(latest.length, 8, "7-day window yields 8 boundaries"); + captured.push(latest); + + // Advance 1 minute — crosses the Jun 16 local midnight. The single + // scheduled `setTimeout` must fire, bump `rolloverTick`, and rebuild the + // boundary set (React flushes the resulting state update inside `act`). + await act(async () => { + mock.timers.tick(60_000); + }); + + assert.notDeepEqual( + latest, + captured[0], + "boundaries must rebuild after the first midnight rollover fires", + ); + assert.equal(latest.length, 8, "boundary count is unchanged by a rollover"); + const juneSeventeenTomorrow = Math.floor( + new Date(2026, 5, 17, 0, 0, 0, 0).getTime() / 1_000, + ); + assert.equal( + latest.at(-1), + juneSeventeenTomorrow, + "newest boundary must shift forward to tomorrow of the new window", + ); + captured.push(latest); + + // Advance a full day — crosses the Jun 17 local midnight. This only + // fires if the first rollover's effect RESCHEDULED a fresh `setTimeout` + // rather than firing once and going silent. + await act(async () => { + mock.timers.tick(24 * 60 * 60 * 1000); + }); + + assert.notDeepEqual( + latest, + captured[1], + "boundaries must rebuild again after the second midnight rollover fires, proving the timer rescheduled itself", + ); + const juneEighteenTomorrow = Math.floor( + new Date(2026, 5, 18, 0, 0, 0, 0).getTime() / 1_000, + ); + assert.equal( + latest.at(-1), + juneEighteenTomorrow, + "newest boundary must shift forward again after the rescheduled rollover", + ); + + await act(async () => { + root.unmount(); + }); + } finally { + mock.timers.reset(); + } +}); + +test("useLocalDayBoundaries clears its scheduled timeout on unmount (no post-unmount rollover)", async () => { + const beforeMidnight = new Date(2026, 5, 15, 23, 59, 0); + mock.timers.enable({ + apis: ["setTimeout", "Date"], + now: beforeMidnight.getTime(), + }); + + try { + let renderCount = 0; + const onBoundaries = () => { + renderCount++; + }; + + const root = createRoot(document.createElement("div")); + await act(async () => { + root.render(React.createElement(Harness, { days: 7, onBoundaries })); + }); + const countAtUnmount = renderCount; + + await act(async () => { + root.unmount(); + }); + + // Crossing the midnight the pending timeout targeted must not throw or + // invoke a setState-after-unmount path — `clearTimeout` in the effect's + // cleanup must have already cancelled it. + await act(async () => { + mock.timers.tick(60_000); + }); + + assert.equal( + renderCount, + countAtUnmount, + "no render (and no error) after the component unmounted", + ); + } finally { + mock.timers.reset(); + } +}); diff --git a/desktop/src/features/agent-usage/hooks.ts b/desktop/src/features/agent-usage/hooks.ts new file mode 100644 index 0000000000..8a6c09bc9c --- /dev/null +++ b/desktop/src/features/agent-usage/hooks.ts @@ -0,0 +1,102 @@ +import * as React from "react"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; + +import { + getAgentUsageSeries, + onAgentMetricsChanged, + type AgentUsageSeries, +} from "@/shared/api/tauriArchive"; +import { + buildLocalDayBoundaries, + msUntilNextLocalMidnight, + type UsageWindowDays, +} from "./lib/agentUsage"; + +/** Root query key for the whole `agent-usage` family — invalidated en masse on any agent-metric change (M4/A13). */ +export const agentUsageQueryKeyRoot = ["agent-usage"] as const; + +/** + * Stable key incorporating the exact boundary set (so a midnight rollover or + * 7d/30d switch produces a new cache entry) and the optional author filter. + */ +export function agentUsageQueryKey( + boundaries: readonly number[], + agentPubkey?: string, +) { + return [ + ...agentUsageQueryKeyRoot, + boundaries.join(","), + agentPubkey ?? null, + ] as const; +} + +/** + * Local-day boundaries for the given window that recompute automatically at + * every local midnight (M4) — no `setInterval` (which would drift across + * DST), just a single scheduled `setTimeout` that reschedules itself each + * time it fires. Split out of {@link useAgentUsageSeries} so the rollover + * mechanics are testable without a `QueryClientProvider`. + */ +export function useLocalDayBoundaries(days: UsageWindowDays): number[] { + // Bumped once per local midnight so `boundaries` below recomputes even + // though `days` hasn't changed. + const [rolloverTick, setRolloverTick] = React.useState(0); + + // `rolloverTick` is the only intended dependency: each fire reschedules + // against a freshly computed `Date.now()`, never a fixed interval that + // would drift across DST. + // biome-ignore lint/correctness/useExhaustiveDependencies: rolloverTick is read to reschedule, not to avoid a stale closure + React.useEffect(() => { + const timeoutId = setTimeout(() => { + setRolloverTick((tick) => tick + 1); + }, msUntilNextLocalMidnight()); + return () => clearTimeout(timeoutId); + }, [rolloverTick]); + + // `rolloverTick` intentionally forces a recompute at local midnight even + // though it carries no boundary data itself. + // biome-ignore lint/correctness/useExhaustiveDependencies: rolloverTick drives recompute, not boundary data + return React.useMemo( + () => buildLocalDayBoundaries(days), + [days, rolloverTick], + ); +} + +/** + * Local NIP-AM usage series for the Agents overview or a single agent's + * profile drill-in. Rebuilds boundaries once per local midnight (M4, no + * polling) and invalidates on `onAgentMetricsChanged` — new archived + * metrics, or a kind-44200 subscription toggle — instead of a refetch + * interval. + */ +export function useAgentUsageSeries({ + agentPubkey, + days, + enabled = true, +}: { + agentPubkey?: string; + days: UsageWindowDays; + enabled?: boolean; +}) { + const queryClient = useQueryClient(); + const boundaries = useLocalDayBoundaries(days); + + React.useEffect( + () => + onAgentMetricsChanged(() => { + void queryClient.invalidateQueries({ + queryKey: agentUsageQueryKeyRoot, + }); + }), + [queryClient], + ); + + return useQuery({ + queryKey: agentUsageQueryKey(boundaries, agentPubkey), + queryFn: () => + getAgentUsageSeries({ bucketBoundaries: boundaries, agentPubkey }), + enabled, + staleTime: 60_000, + gcTime: 5 * 60_000, + }); +} diff --git a/desktop/src/features/agent-usage/lib/agentUsage.test.mjs b/desktop/src/features/agent-usage/lib/agentUsage.test.mjs new file mode 100644 index 0000000000..1ed5a152c4 --- /dev/null +++ b/desktop/src/features/agent-usage/lib/agentUsage.test.mjs @@ -0,0 +1,546 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + bigintRatio, + buildLocalDayBoundaries, + deriveUsageIngressTrailing, + formatEstimatedCostUsd, + formatTokenCountCompact, + formatTokenCountExact, + isPartialField, + isUnknownField, + msUntilNextLocalMidnight, + parseTokenCount, + sortAgentsByKnownTotal, + sortModelsByKnownTotal, + sumKnownBucketTotals, +} from "./agentUsage.ts"; + +// ── Fixtures ───────────────────────────────────────────────────────────────── + +function usageField(overrides = {}) { + return { value: null, incomplete: false, ...overrides }; +} + +function reportedUsage(overrides = {}) { + return { + inputTokens: usageField(), + outputTokens: usageField(), + totalTokens: usageField(), + estimatedCostUsd: usageField(), + ...overrides, + }; +} + +function agentUsage(pubkey, totalTokensValue, overrides = {}) { + return { + agentPubkey: pubkey, + usage: reportedUsage({ + totalTokens: usageField({ value: totalTokensValue }), + }), + buckets: [], + models: [], + reportCount: 0, + hasUnknownUsage: false, + ...overrides, + }; +} + +function modelUsage(model, totalTokensValue, overrides = {}) { + return { + model, + usage: reportedUsage({ + totalTokens: usageField({ value: totalTokensValue }), + }), + reportCount: 0, + hasUnknownUsage: false, + ...overrides, + }; +} + +// ── buildLocalDayBoundaries ────────────────────────────────────────────────── + +test("buildLocalDayBoundaries returns 8 boundaries for a 7-day window", () => { + const now = new Date(2026, 5, 15, 14, 30, 0); // June 15, 2026, 14:30 local + const boundaries = buildLocalDayBoundaries(7, now); + assert.equal(boundaries.length, 8); +}); + +test("buildLocalDayBoundaries returns 31 boundaries for a 30-day window", () => { + const now = new Date(2026, 5, 15, 14, 30, 0); + const boundaries = buildLocalDayBoundaries(30, now); + assert.equal(boundaries.length, 31); +}); + +test("buildLocalDayBoundaries produces strictly increasing boundaries ending at tomorrow's local midnight", () => { + const now = new Date(2026, 5, 15, 14, 30, 0); + const boundaries = buildLocalDayBoundaries(7, now); + for (let i = 1; i < boundaries.length; i++) { + assert.ok( + boundaries[i] > boundaries[i - 1], + `boundary ${i} must exceed boundary ${i - 1}`, + ); + } + const tomorrowMidnight = new Date(2026, 5, 16, 0, 0, 0, 0); + assert.equal( + boundaries.at(-1), + Math.floor(tomorrowMidnight.getTime() / 1000), + ); +}); + +test("buildLocalDayBoundaries is independent of time-of-day within the reference day", () => { + const morning = buildLocalDayBoundaries(7, new Date(2026, 5, 15, 0, 0, 1)); + const night = buildLocalDayBoundaries(7, new Date(2026, 5, 15, 23, 59, 59)); + assert.deepEqual(morning, night); +}); + +// ── buildLocalDayBoundaries / msUntilNextLocalMidnight explicit-TZ coverage ── +// +// `process.env.TZ` is read by the JS engine on every `Date` field access +// (not just pinned at process start), so each test below mutates it +// directly and restores the original value in a `finally` — no subprocess +// needed, but evidence (`Date#toString()`) is asserted so a future runtime +// that DOES pin `TZ` at startup fails loudly instead of silently passing +// against the wrong offset. + +function withTz(tz, fn) { + const original = process.env.TZ; + process.env.TZ = tz; + try { + return fn(); + } finally { + if (original === undefined) delete process.env.TZ; + else process.env.TZ = original; + } +} + +function assertStrictlyIncreasing(boundaries) { + for (let i = 1; i < boundaries.length; i++) { + assert.ok( + boundaries[i] > boundaries[i - 1], + `boundary ${i} (${boundaries[i]}) must exceed boundary ${i - 1} (${boundaries[i - 1]})`, + ); + } +} + +test("buildLocalDayBoundaries stays strictly increasing across an ordinary US spring-forward DST transition", () => { + withTz("America/New_York", () => { + const now = new Date(2024, 2, 12, 10, 0, 0); // Mar 12, after Mar 10 spring-forward + assert.equal( + now.toString().includes("Daylight"), + true, + "sanity: DST active", + ); + const boundaries = buildLocalDayBoundaries(7, now); + assert.equal(boundaries.length, 8); + assertStrictlyIncreasing(boundaries); + }); +}); + +test("buildLocalDayBoundaries stays strictly increasing across an ordinary US fall-back DST transition", () => { + withTz("America/New_York", () => { + const now = new Date(2024, 10, 6, 10, 0, 0); // Nov 6, after Nov 3 fall-back + assert.equal( + now.toString().includes("Standard"), + true, + "sanity: standard time active", + ); + const boundaries = buildLocalDayBoundaries(7, now); + assert.equal(boundaries.length, 8); + assertStrictlyIncreasing(boundaries); + }); +}); + +test("buildLocalDayBoundaries stays strictly increasing across Lord Howe Island's 30-minute DST shift", () => { + withTz("Australia/Lord_Howe", () => { + const now = new Date(2024, 9, 8, 10, 0, 0); // Oct 8, after Oct 6 spring-forward (+30min) + const boundaries = buildLocalDayBoundaries(7, now); + assert.equal(boundaries.length, 8); + assertStrictlyIncreasing(boundaries); + // The transition day is a 23.5h day, not the ordinary 24h. + const deltas = []; + for (let i = 1; i < boundaries.length; i++) { + deltas.push(boundaries[i] - boundaries[i - 1]); + } + assert.ok( + deltas.some((d) => d === 23.5 * 3600), + `expected a 23.5h transition delta, got ${JSON.stringify(deltas)}`, + ); + }); +}); + +test("buildLocalDayBoundaries stays strictly increasing across Pacific/Apia's skipped 2011-12-30 civil date", () => { + withTz("Pacific/Apia", () => { + // Samoa skipped 2011-12-30 entirely moving across the International + // Date Line; Dec 29 was immediately followed by Dec 31. The UTC + // offset itself jumped by exactly 24h (UTC-11 -> UTC+13) at that + // instant, so real elapsed time across the 2-civil-day jump is only + // 24h, not 48h — but the boundary construction must not collapse the + // two distinct local midnights (Dec 29, Dec 31) into one duplicate. + const now = new Date(2011, 11, 31, 12, 0, 0); + const boundaries = buildLocalDayBoundaries(7, now); + assert.equal(boundaries.length, 8); + assertStrictlyIncreasing(boundaries); + const dec29 = Math.floor(new Date(2011, 11, 29, 0, 0, 0).getTime() / 1000); + const dec31 = Math.floor(new Date(2011, 11, 31, 0, 0, 0).getTime() / 1000); + assert.ok( + boundaries.includes(dec29), + "expected Dec 29 local midnight as a boundary", + ); + assert.ok( + boundaries.includes(dec31), + "expected Dec 31 local midnight as a boundary", + ); + }); +}); + +test("buildLocalDayBoundaries produces days+1 boundaries even when a civil date is skipped", () => { + withTz("Pacific/Apia", () => { + const boundaries7 = buildLocalDayBoundaries( + 7, + new Date(2011, 11, 31, 12, 0, 0), + ); + assert.equal(boundaries7.length, 8); + const boundaries30 = buildLocalDayBoundaries( + 30, + new Date(2011, 11, 31, 12, 0, 0), + ); + assert.equal(boundaries30.length, 31); + assertStrictlyIncreasing(boundaries30); + }); +}); + +// ── msUntilNextLocalMidnight ───────────────────────────────────────────────── + +test("msUntilNextLocalMidnight returns the exact gap to the next local midnight", () => { + const now = new Date(2026, 5, 15, 23, 0, 0, 0); + const ms = msUntilNextLocalMidnight(now); + assert.equal(ms, 60 * 60 * 1000); +}); + +test("msUntilNextLocalMidnight is always positive, even called exactly at midnight", () => { + const now = new Date(2026, 5, 15, 0, 0, 0, 0); + const ms = msUntilNextLocalMidnight(now); + assert.equal(ms, 24 * 60 * 60 * 1000); +}); + +test("msUntilNextLocalMidnight is positive and lands on a real local midnight across DST/date-line TZs", () => { + const cases = [ + ["America/New_York", new Date(2024, 2, 9, 23, 30, 0)], // eve of spring-forward + ["Australia/Lord_Howe", new Date(2024, 9, 5, 23, 45, 0)], // eve of 30-min shift + ["Pacific/Apia", new Date(2011, 11, 29, 23, 0, 0)], // eve of the skipped date + ]; + for (const [tz, now] of cases) { + withTz(tz, () => { + const ms = msUntilNextLocalMidnight(now); + assert.ok(ms > 0, `${tz}: expected positive ms, got ${ms}`); + const landed = new Date(now.getTime() + ms); + assert.equal( + landed.getHours(), + 0, + `${tz}: expected to land on local midnight, got ${landed.toString()}`, + ); + assert.equal(landed.getMinutes(), 0, `${tz}: expected :00 minutes`); + }); + } +}); + +// ── parseTokenCount ────────────────────────────────────────────────────────── + +test("parseTokenCount parses a plain decimal string to bigint", () => { + assert.equal(parseTokenCount("12345"), 12345n); +}); + +test("parseTokenCount preserves u64::MAX precision beyond Number.MAX_SAFE_INTEGER", () => { + assert.equal(parseTokenCount("18446744073709551615"), 18446744073709551615n); +}); + +test("parseTokenCount returns null for null input", () => { + assert.equal(parseTokenCount(null), null); +}); + +test("parseTokenCount fails closed on malformed wire data instead of throwing", () => { + for (const malformed of ["", "-1", "1.5", "abc", "1e10", " 1", "1 "]) { + assert.equal( + parseTokenCount(malformed), + null, + `expected null for ${JSON.stringify(malformed)}`, + ); + } +}); + +test("parseTokenCount accepts zero", () => { + assert.equal(parseTokenCount("0"), 0n); +}); + +// ── formatTokenCountCompact / formatTokenCountExact ───────────────────────── + +test("formatTokenCountCompact abbreviates thousands/millions/billions", () => { + assert.equal(formatTokenCountCompact(999n), "999"); + assert.equal(formatTokenCountCompact(1_234n), "1.2K"); + assert.equal(formatTokenCountCompact(1_000_000n), "1M"); + assert.equal(formatTokenCountCompact(1_500_000_000n), "1.5B"); +}); + +test("formatTokenCountCompact handles negative magnitudes symmetrically", () => { + assert.equal(formatTokenCountCompact(-1_234n), "-1.2K"); +}); + +test("formatTokenCountExact renders full grouped digits, never abbreviated", () => { + assert.equal(formatTokenCountExact(1_234_567n), "1,234,567"); + assert.equal(formatTokenCountExact(0n), "0"); +}); + +// ── formatEstimatedCostUsd ─────────────────────────────────────────────────── + +test("formatEstimatedCostUsd renders two-decimal USD currency", () => { + assert.equal(formatEstimatedCostUsd(1.5), "$1.50"); + assert.equal(formatEstimatedCostUsd(0), "$0.00"); +}); + +// ── bigintRatio ────────────────────────────────────────────────────────────── + +test("bigintRatio computes a bounded ratio without losing bigint precision on large magnitudes", () => { + const whole = 18_446_744_073_709_551_614n; // largest even value near u64::MAX + assert.equal(bigintRatio(whole / 2n, whole), 0.5); +}); + +test("bigintRatio returns 0 for a zero or negative whole (divide-by-zero guard)", () => { + assert.equal(bigintRatio(5n, 0n), 0); + assert.equal(bigintRatio(5n, -10n), 0); +}); + +test("bigintRatio clamps part to [0, whole]", () => { + assert.equal(bigintRatio(-5n, 100n), 0); + assert.equal(bigintRatio(200n, 100n), 1); +}); + +// ── sortAgentsByKnownTotal / sortModelsByKnownTotal ───────────────────────── + +test("sortAgentsByKnownTotal ranks known totals descending", () => { + const agents = [ + agentUsage("a1", "100"), + agentUsage("a2", "300"), + agentUsage("a3", "200"), + ]; + const sorted = sortAgentsByKnownTotal(agents); + assert.deepEqual( + sorted.map((a) => a.agentPubkey), + ["a2", "a3", "a1"], + ); +}); + +test("sortAgentsByKnownTotal lists unknown-total agents after all known-total agents, never interleaved", () => { + const agents = [ + agentUsage("unknown-b", null), + agentUsage("known", "50"), + agentUsage("unknown-a", null), + ]; + const sorted = sortAgentsByKnownTotal(agents); + assert.equal(sorted[0].agentPubkey, "known"); + // Unknown-total agents tiebreak by normalized pubkey. + assert.deepEqual( + sorted.slice(1).map((a) => a.agentPubkey), + ["unknown-a", "unknown-b"], + ); +}); + +test("sortAgentsByKnownTotal tiebreaks equal known totals by pubkey", () => { + const agents = [agentUsage("b", "100"), agentUsage("a", "100")]; + const sorted = sortAgentsByKnownTotal(agents); + assert.deepEqual( + sorted.map((a) => a.agentPubkey), + ["a", "b"], + ); +}); + +test("sortModelsByKnownTotal sorts null model ('Unknown model') last among ties", () => { + const models = [ + modelUsage(null, "100"), + modelUsage("gpt-4", "100"), + modelUsage("claude", "100"), + ]; + const sorted = sortModelsByKnownTotal(models); + assert.deepEqual( + sorted.map((m) => m.model), + ["claude", "gpt-4", null], + ); +}); + +// ── isPartialField / isUnknownField ────────────────────────────────────────── + +test("isPartialField is true only for a known value flagged incomplete", () => { + assert.equal( + isPartialField(usageField({ value: "10", incomplete: true })), + true, + ); + assert.equal( + isPartialField(usageField({ value: "10", incomplete: false })), + false, + ); + assert.equal( + isPartialField(usageField({ value: null, incomplete: true })), + false, + ); +}); + +test("isUnknownField is true only when there is no known value at all", () => { + assert.equal(isUnknownField(usageField({ value: null })), true); + assert.equal(isUnknownField(usageField({ value: "0" })), false); +}); + +// ── sumKnownBucketTotals ────────────────────────────────────────────────────── + +function bucket(overrides = {}) { + return { + start: 1_700_000_000, + end: 1_700_086_400, + usage: reportedUsage(), + reportCount: 0, + hasUnknownUsage: false, + ...overrides, + }; +} + +test("sumKnownBucketTotals returns knownTotal null and partial false for an all-empty window", () => { + const result = sumKnownBucketTotals([ + bucket({ reportCount: 0 }), + bucket({ reportCount: 0 }), + ]); + assert.equal(result.knownTotal, null); + assert.equal(result.partial, false); +}); + +test("sumKnownBucketTotals sums all known totals when every bucket is fully known", () => { + const result = sumKnownBucketTotals([ + bucket({ + usage: reportedUsage({ totalTokens: usageField({ value: "100" }) }), + reportCount: 1, + }), + bucket({ + usage: reportedUsage({ totalTokens: usageField({ value: "200" }) }), + reportCount: 1, + }), + ]); + assert.equal(result.knownTotal, 300n); + assert.equal(result.partial, false); +}); + +test("sumKnownBucketTotals marks partial true when any bucket has an incomplete (known lower-bound) total", () => { + const result = sumKnownBucketTotals([ + bucket({ + usage: reportedUsage({ + totalTokens: usageField({ value: "100", incomplete: true }), + }), + reportCount: 1, + }), + bucket({ + usage: reportedUsage({ totalTokens: usageField({ value: "200" }) }), + reportCount: 1, + }), + ]); + assert.equal(result.knownTotal, 300n); + assert.equal(result.partial, true); +}); + +test("sumKnownBucketTotals marks partial true when any bucket has reports but null total (activity not fully counted)", () => { + const result = sumKnownBucketTotals([ + bucket({ + usage: reportedUsage({ totalTokens: usageField({ value: "100" }) }), + reportCount: 1, + }), + bucket({ usage: reportedUsage(), reportCount: 1, hasUnknownUsage: true }), + ]); + assert.equal(result.knownTotal, 100n); + assert.equal(result.partial, true); +}); + +// ── deriveUsageIngressTrailing ──────────────────────────────────────────────── + +function baseSeries(overrides = {}) { + return { + collectionEnabled: true, + buckets: [], + agents: [], + coverage: { + firstArchivedAt: null, + firstReportedAt: null, + hasUnknownUsage: false, + invalidReportCount: 0, + lastArchivedAt: null, + lastReportedAt: null, + reportCount: 0, + }, + hasArchivedEvidence: null, + ...overrides, + }; +} + +test("deriveUsageIngressTrailing returns 'Collection off' when collection is disabled", () => { + const series = baseSeries({ collectionEnabled: false }); + assert.equal(deriveUsageIngressTrailing(series), "Collection off"); +}); + +test("deriveUsageIngressTrailing returns 'No recent data' when collection is on but no agents present", () => { + const series = baseSeries({ agents: [] }); + assert.equal(deriveUsageIngressTrailing(series), "No recent data"); +}); + +test("deriveUsageIngressTrailing returns compact token count when a known non-partial total is available", () => { + const series = baseSeries({ + agents: [agentUsage("a", "1500")], + }); + assert.equal(deriveUsageIngressTrailing(series), "1.5K"); +}); + +test("deriveUsageIngressTrailing appends '· Partial' when the total is a known lower bound", () => { + const series = baseSeries({ + agents: [ + agentUsage("a", null, { + usage: reportedUsage({ + totalTokens: usageField({ value: "1500", incomplete: true }), + }), + }), + ], + }); + assert.equal(deriveUsageIngressTrailing(series), "1.5K · Partial"); +}); + +test("deriveUsageIngressTrailing returns 'Input/output reported' when only input or output is known", () => { + const series = baseSeries({ + agents: [ + agentUsage("a", null, { + usage: reportedUsage({ + inputTokens: usageField({ value: "800" }), + outputTokens: usageField({ value: "200" }), + }), + }), + ], + }); + assert.equal(deriveUsageIngressTrailing(series), "Input/output reported"); +}); + +test("deriveUsageIngressTrailing appends '· Partial' when only incomplete I/O fields are known", () => { + const series = baseSeries({ + agents: [ + agentUsage("a", null, { + usage: reportedUsage({ + inputTokens: usageField({ value: "800", incomplete: true }), + outputTokens: usageField({ value: "200" }), + }), + }), + ], + }); + assert.equal( + deriveUsageIngressTrailing(series), + "Input/output reported · Partial", + ); +}); + +test("deriveUsageIngressTrailing returns 'No recent data' when all usage fields are unknown", () => { + const series = baseSeries({ + agents: [agentUsage("a", null)], + }); + assert.equal(deriveUsageIngressTrailing(series), "No recent data"); +}); diff --git a/desktop/src/features/agent-usage/lib/agentUsage.ts b/desktop/src/features/agent-usage/lib/agentUsage.ts new file mode 100644 index 0000000000..0c54c20b75 --- /dev/null +++ b/desktop/src/features/agent-usage/lib/agentUsage.ts @@ -0,0 +1,316 @@ +//! Frontend-owned local-day boundary construction, bigint-safe token +//! handling, and truthful-state derivation for the NIP-AM local agent usage +//! feature. +//! +//! Rust request validation (`agent_usage.rs::validate_request`) only bounds +//! query span and shape — per M5, the trusted frontend is the single source +//! of local-midnight civil-day construction. Every consumer of +//! `AgentUsageSeriesRequest.bucketBoundaries` must build them here. + +import type { + AgentUsage, + AgentUsageModel, + AgentUsageSeries, + AgentUsageSeriesBucket, + CostField, + UsageField, +} from "@/shared/api/tauriArchive"; + +// ── Local-day boundary construction (M5, A9) ───────────────────────────────── + +export type UsageWindowDays = 7 | 30; + +const DISTINCT_MIDNIGHT_MAX_STEP = 3; + +/** + * The local midnight strictly before `from` (which must itself be a local + * midnight), found via `Date#setDate` day-arithmetic so ordinary DST + * transitions land on the correct calendar day. `Date#setDate` normalizes a + * *nonexistent* local date (a full civil day dropped by a date-line move, + * e.g. `Pacific/Apia`'s 2011-12-30) forward to the next real one, which can + * renormalize back to `from` itself — so this widens the step by one + * calendar day at a time until it actually lands on a distinct instant. + */ +function previousDistinctLocalMidnight(from: Date): Date { + let probe = from; + for (let step = 1; step <= DISTINCT_MIDNIGHT_MAX_STEP; step++) { + probe = new Date(from); + probe.setDate(probe.getDate() - step); + probe.setHours(0, 0, 0, 0); + if (probe.getTime() !== from.getTime()) return probe; + } + return probe; +} + +/** The local midnight strictly after `from`; see {@link previousDistinctLocalMidnight}. */ +function nextDistinctLocalMidnight(from: Date): Date { + let probe = from; + for (let step = 1; step <= DISTINCT_MIDNIGHT_MAX_STEP; step++) { + probe = new Date(from); + probe.setDate(probe.getDate() + step); + probe.setHours(0, 0, 0, 0); + if (probe.getTime() !== from.getTime()) return probe; + } + return probe; +} + +/** + * Build `days + 1` exact local-midnight Unix-second boundaries ending at the + * start of tomorrow's local day, covering the trailing `days` calendar days + * (today plus `days - 1` prior days). + * + * Walks to each boundary's *distinct* local midnight one civil day at a + * time (never independent `Date#setDate` offsets from one shared base date, + * and never `N * 86_400`), so boundaries stay correct across DST + * transitions — including 30-minute offset zones (e.g. Lord Howe Island), + * where a "day" is 23.5h or 24.5h — and across a skipped local civil date + * (e.g. `Pacific/Apia`'s 2011 date-line move), where independently offsetting + * from one base date would normalize the nonexistent date forward and emit + * a duplicate boundary. A skipped date instead produces one interval + * spanning the elapsed real time between the two surviving distinct + * midnights (which can exceed the ordinary 24h, up to the 48h band + * `validate_request`'s `MAX_INTERVAL_SECS` (A9) admits) rather than a + * duplicate. `referenceNow` is injectable for deterministic tests and the + * midnight-rollover timer (M4). + */ +export function buildLocalDayBoundaries( + days: UsageWindowDays, + referenceNow: Date = new Date(), +): number[] { + const todayMidnight = new Date(referenceNow); + todayMidnight.setHours(0, 0, 0, 0); + + const tomorrowMidnight = nextDistinctLocalMidnight(todayMidnight); + + // Oldest boundary is `days - 1` distinct local midnights before today's; + // the window covers today plus the (days - 1) preceding calendar days. + const priorMidnights: Date[] = []; + let cursor = todayMidnight; + for (let i = 0; i < days - 1; i++) { + cursor = previousDistinctLocalMidnight(cursor); + priorMidnights.push(cursor); + } + priorMidnights.reverse(); + + return [...priorMidnights, todayMidnight, tomorrowMidnight].map((d) => + Math.floor(d.getTime() / 1_000), + ); +} + +/** + * Milliseconds until the next local midnight after `referenceNow`, for the + * single-`setTimeout` rollover (M4). Recompute and reschedule each time the + * timer fires — never use `setInterval`, which drifts across DST. + */ +export function msUntilNextLocalMidnight( + referenceNow: Date = new Date(), +): number { + const nextMidnight = new Date(referenceNow); + nextMidnight.setHours(24, 0, 0, 0); + return nextMidnight.getTime() - referenceNow.getTime(); +} + +// ── Bigint-safe token parsing/formatting ───────────────────────────────────── + +/** + * Parse a decimal token-count string to `bigint`, fail-closed. The wire + * sends token counters as decimal strings specifically so the full valid + * `u64` range survives the Tauri boundary — never round-trip through + * `Number(...)`, which loses precision above 2^53. + * + * Returns `null` for a null/missing value or a string that isn't a plain + * non-negative decimal integer (defensive: malformed wire data becomes + * "unknown", not a thrown parse error that would crash the panel). + */ +export function parseTokenCount(value: string | null): bigint | null { + if (value === null) return null; + if (!/^\d+$/.test(value)) return null; + try { + return BigInt(value); + } catch { + return null; + } +} + +/** Compact display, e.g. `1234` -> "1.2K", `1_000_000` -> "1M". Never lossy for exact copy — use `formatTokenCountExact` for that. */ +export function formatTokenCountCompact(value: bigint): string { + const abs = value < 0n ? -value : value; + const units: Array<[bigint, string]> = [ + [1_000_000_000n, "B"], + [1_000_000n, "M"], + [1_000n, "K"], + ]; + for (const [threshold, suffix] of units) { + if (abs >= threshold) { + // One decimal place, computed in bigint math to stay exact until the + // final float division (bounded to a single small ratio, not the + // original magnitude, so no precision loss that matters visually). + const scaled = Number((value * 10n) / threshold) / 10; + return `${scaled}${suffix}`; + } + } + return value.toString(); +} + +/** Exact grouped display, e.g. `1234567` -> "1,234,567". Safe for arbitrary `bigint` magnitude. */ +export function formatTokenCountExact(value: bigint): string { + return value.toLocaleString("en-US"); +} + +/** Exact USD display, e.g. `1.5` -> "$1.50". `null` callers should render "Estimated" copy elsewhere, never "$0.00". */ +export function formatEstimatedCostUsd(value: number): string { + return new Intl.NumberFormat("en-US", { + style: "currency", + currency: "USD", + }).format(value); +} + +/** + * Bigint-safe ratio in `[0, 1]` for a relative bar, e.g. `part` tokens against + * `whole` tokens. Never converts the full magnitude through `Number(...)`; + * only the final small ratio is a float. Returns `0` when `whole` is zero or + * negative (guards a divide-by-zero, not a real data case). + */ +export function bigintRatio(part: bigint, whole: bigint): number { + if (whole <= 0n) return 0; + const clampedPart = part < 0n ? 0n : part > whole ? whole : part; + // Scale into an integer permille before the single float division so the + // division only ever operates on bounded small integers. + const permille = (clampedPart * 1000n) / whole; + return Number(permille) / 1000; +} + +// ── Ranking (A2: known lower-bound totals rank; null totals list after) ───── + +type Ranked = { item: T; totalTokens: bigint | null }; + +function rankByKnownTotal( + items: readonly T[], + totalTokens: (item: T) => UsageField, + tiebreak: (a: T, b: T) => number, +): T[] { + const withTotals: Ranked[] = items.map((item) => ({ + item, + totalTokens: parseTokenCount(totalTokens(item).value), + })); + + return withTotals + .sort((a, b) => { + if (a.totalTokens !== null && b.totalTokens !== null) { + if (a.totalTokens !== b.totalTokens) { + return a.totalTokens > b.totalTokens ? -1 : 1; + } + return tiebreak(a.item, b.item); + } + // Known-total rows rank before unknown-total rows; never interleave. + if (a.totalTokens !== null) return -1; + if (b.totalTokens !== null) return 1; + return tiebreak(a.item, b.item); + }) + .map((ranked) => ranked.item); +} + +/** Agents sort by known `totalTokens` descending, then normalized pubkey (A2/plan). Unknown-total agents list after all known-total agents, unranked among themselves beyond the pubkey tiebreak. */ +export function sortAgentsByKnownTotal( + agents: readonly AgentUsage[], +): AgentUsage[] { + return rankByKnownTotal( + agents, + (agent) => agent.usage.totalTokens, + (a, b) => a.agentPubkey.localeCompare(b.agentPubkey), + ); +} + +/** Model rows use the same ranking rule as agents, tiebroken by model name (`null` model sorts last as "Unknown model"). */ +export function sortModelsByKnownTotal( + models: readonly AgentUsageModel[], +): AgentUsageModel[] { + return rankByKnownTotal( + models, + (model) => model.usage.totalTokens, + (a, b) => { + if (a.model === b.model) return 0; + if (a.model === null) return 1; + if (b.model === null) return -1; + return a.model.localeCompare(b.model); + }, + ); +} + +// ── Coverage / partial-state copy helpers ──────────────────────────────────── + +/** A field is a "Partial" lower bound when it has a known value that is flagged incomplete. Distinct from fully unknown (`value === null`), which renders as an omitted/unknown state, never zero. */ +export function isPartialField(field: UsageField | CostField): boolean { + return field.value !== null && field.incomplete; +} + +/** True when a field has no known value at all — omit from totals/bars, never render as zero. */ +export function isUnknownField(field: UsageField | CostField): boolean { + return field.value === null; +} + +/** + * Truthful trailing summary for the profile Info-tab Usage ingress row + * (plan:328): the viewer's own agent's 7-day known total, `Partial` when + * incomplete, `Input/output reported` when only those fields are known, + * or `No recent data` when nothing in the window is known. Never renders + * the placeholder `"View"` the ingress row used to show unconditionally. + */ +export function deriveUsageIngressTrailing(series: AgentUsageSeries): string { + if (!series.collectionEnabled) return "Collection off"; + + const agent = series.agents[0]; + if (agent === undefined) return "No recent data"; + + const { inputTokens, outputTokens, totalTokens } = agent.usage; + const knownTotal = parseTokenCount(totalTokens.value); + if (knownTotal !== null) { + const compact = formatTokenCountCompact(knownTotal); + return isPartialField(totalTokens) ? `${compact} · Partial` : compact; + } + if ( + parseTokenCount(inputTokens.value) !== null || + parseTokenCount(outputTokens.value) !== null + ) { + const ioPartial = + isPartialField(inputTokens) || isPartialField(outputTokens); + return ioPartial + ? "Input/output reported · Partial" + : "Input/output reported"; + } + return "No recent data"; +} + +/** + * Bigint-safe sum of each bucket's known `totalTokens` across a daily + * series, for the overview/focused-view header total. `partial` is true + * when any bucket has a known-but-incomplete total OR any bucket's total is + * fully unknown (activity happened somewhere in the window that this exact + * sum cannot include) — never silently reported as a complete figure. + * Returns `knownTotal: null` only when every bucket has zero reports (a + * true empty window, not partial data). + */ +export function sumKnownBucketTotals( + buckets: readonly AgentUsageSeriesBucket[], +): { + knownTotal: bigint | null; + partial: boolean; +} { + let sum = 0n; + let sawKnown = false; + let partial = false; + + for (const bucket of buckets) { + const total = bucket.usage.totalTokens; + const known = parseTokenCount(total.value); + if (known !== null) { + sum += known; + sawKnown = true; + } + if (isPartialField(total) || (bucket.reportCount > 0 && known === null)) { + partial = true; + } + } + + return { knownTotal: sawKnown ? sum : null, partial }; +} diff --git a/desktop/src/features/agent-usage/ui/AgentUsageDailyBars.tsx b/desktop/src/features/agent-usage/ui/AgentUsageDailyBars.tsx new file mode 100644 index 0000000000..ec6c8a6816 --- /dev/null +++ b/desktop/src/features/agent-usage/ui/AgentUsageDailyBars.tsx @@ -0,0 +1,179 @@ +import * as React from "react"; + +import { cn } from "@/shared/lib/cn"; +import type { AgentUsageSeriesBucket } from "@/shared/api/tauriArchive"; +import { + bigintRatio, + formatTokenCountCompact, + isPartialField, + parseTokenCount, +} from "../lib/agentUsage"; + +const BAR_TRACK_HEIGHT_PX = 56; +const UNKNOWN_BASELINE_HEIGHT_PX = 10; +const KNOWN_MIN_HEIGHT_PX = 3; + +// A hatched, non-zero baseline for "activity happened but the total +// couldn't be counted" — deliberately never a zero-height bar, so unknown +// usage is never visually indistinguishable from a day with no activity +// (plan:306/329: "does not encode unknown as zero"). +const UNKNOWN_BAR_STYLE: React.CSSProperties = { + backgroundImage: + "repeating-linear-gradient(45deg, var(--muted-foreground) 0, var(--muted-foreground) 1px, transparent 1px, transparent 5px)", + backgroundColor: "transparent", + height: UNKNOWN_BASELINE_HEIGHT_PX, + opacity: 0.35, +}; + +function dateLabelOf(unixSeconds: number): string { + return new Date(unixSeconds * 1000).toLocaleDateString(undefined, { + month: "short", + day: "numeric", + }); +} + +/** + * One bar's derived render state, computed from backend truth rather than + * the field's `value` alone: `reportCount === 0` is a genuine zero-activity + * day (the field is `null` because nothing happened), which is a different + * state from `hasUnknownUsage` (activity happened but the total could not + * be fully counted) even though both leave `usage.totalTokens.value` null. + */ +function deriveBarState(bucket: AgentUsageSeriesBucket) { + const total = bucket.usage.totalTokens; + const known = parseTokenCount(total.value); + const dateLabel = dateLabelOf(bucket.start); + + if (bucket.reportCount === 0) { + return { + accessibleLabel: `${dateLabel} · no usage reported`, + kind: "empty" as const, + knownTokens: 0n, + }; + } + if (known === null) { + return { + accessibleLabel: `${dateLabel} · unknown usage`, + kind: "unknown" as const, + knownTokens: null, + }; + } + const partial = isPartialField(total); + return { + accessibleLabel: `${dateLabel} · ${formatTokenCountCompact(known)} reported tokens${ + partial ? " (partial)" : "" + }`, + kind: partial ? ("partial" as const) : ("known" as const), + knownTokens: known, + }; +} + +/** + * CSS-only daily bar row for a usage series (plan:305-306/329). Columns are + * equal CSS-grid fractions of the container so the row never causes + * horizontal overflow regardless of window width or bucket count (8 or 31). + * Each bar carries an accessible `title`/`aria-label` (`date · reported + * tokens` or `date · unknown usage`) and a visible textual total beneath + * it; a day with reported-but-uncountable usage renders a fixed hatched + * baseline, never a zero-height bar. + */ +export function AgentUsageDailyBars({ + buckets, +}: { + buckets: AgentUsageSeriesBucket[]; +}) { + const maxKnownTotal = React.useMemo( + () => + buckets.reduce((max, bucket) => { + const total = parseTokenCount(bucket.usage.totalTokens.value); + return total !== null && total > max ? total : max; + }, 0n), + [buckets], + ); + + if (buckets.length === 0) return null; + + return ( +
+ {buckets.map((bucket) => ( + + ))} +
+ ); +} + +function DailyBar({ + bucket, + maxKnownTotal, +}: { + bucket: AgentUsageSeriesBucket; + maxKnownTotal: bigint; +}) { + const { accessibleLabel, kind, knownTokens } = deriveBarState(bucket); + + const knownHeightPx = + knownTokens !== null && maxKnownTotal > 0n + ? Math.max( + Math.round( + bigintRatio(knownTokens, maxKnownTotal) * BAR_TRACK_HEIGHT_PX, + ), + knownTokens > 0n ? KNOWN_MIN_HEIGHT_PX : 1, + ) + : KNOWN_MIN_HEIGHT_PX; + + const trailingText = + kind === "unknown" + ? "—" + : kind === "partial" + ? `≥${formatTokenCountCompact(knownTokens ?? 0n)}` + : formatTokenCountCompact(knownTokens ?? 0n); + + return ( +
+
+ {kind === "unknown" ? ( +
+ ) : ( +
+ )} +
+ + {trailingText} + +
+ ); +} diff --git a/desktop/src/features/agent-usage/ui/AgentUsageFocusedView.tsx b/desktop/src/features/agent-usage/ui/AgentUsageFocusedView.tsx new file mode 100644 index 0000000000..8b81edef07 --- /dev/null +++ b/desktop/src/features/agent-usage/ui/AgentUsageFocusedView.tsx @@ -0,0 +1,380 @@ +import * as React from "react"; +import { RefreshCw } from "lucide-react"; + +import { useAppShell } from "@/app/AppShellContext"; +import type { + AgentUsageModel, + AgentUsageSeries, +} from "@/shared/api/tauriArchive"; +import { Alert, AlertDescription } from "@/shared/ui/alert"; +import { Badge } from "@/shared/ui/badge"; +import { Button } from "@/shared/ui/button"; +import { Card } from "@/shared/ui/card"; +import { Skeleton } from "@/shared/ui/skeleton"; +import { Tabs, TabsList, TabsTrigger } from "@/shared/ui/tabs"; +import { useAgentUsageSeries } from "../hooks"; +import { + formatEstimatedCostUsd, + formatTokenCountCompact, + formatTokenCountExact, + isPartialField, + isUnknownField, + parseTokenCount, + sortModelsByKnownTotal, + type UsageWindowDays, +} from "../lib/agentUsage"; +import { AgentUsageDailyBars } from "./AgentUsageDailyBars"; + +/** + * Per-agent Usage focused subview, rendered from the profile panel when + * `view === 'usage'` (M4/A9/A13, frozen Rev 3 plan). Owns its own 7d/30d + * selector and author-filtered query — independent of the Agents overview. + * + * A13 fail-closed: eligibility is ownership (`canViewUsage`) OR archived + * evidence for a historical/deleted agent (`hasArchivedEvidence === true`). + * A hand-authored `?profileView=usage` URL with neither falls back to the + * summary view via `onIneligible` — but only once the query resolves, so a + * still-loading owner-eligible or evidence-eligible agent is never bounced. + */ +export function AgentUsageFocusedView({ + agentPubkey, + canViewUsage, + onIneligible, +}: { + agentPubkey: string; + canViewUsage: boolean; + onIneligible: () => void; +}) { + const [days, setDays] = React.useState(7); + const query = useAgentUsageSeries({ agentPubkey, days }); + const { onOpenSettings } = useAppShell(); + + React.useEffect(() => { + if (canViewUsage || !query.data) return; + if (query.data.hasArchivedEvidence !== true) onIneligible(); + }, [canViewUsage, onIneligible, query.data]); + + return ( +
+ setDays(value === "30" ? 30 : 7)} + value={String(days)} + > + + + 7d + + + 30d + + + + + {query.isLoading ? ( + + ) : query.isError ? ( + + + Couldn't load usage data. + + + + ) : query.data ? ( + + ) : null} +
+ ); +} + +function AgentUsageFocusedSkeleton() { + return ( + + + + + + ); +} + +function AgentUsageFocusedContent({ + days, + onOpenSettings, + series, +}: { + days: UsageWindowDays; + onOpenSettings: ((section: "local-archive") => void) | null; + series: AgentUsageSeries; +}) { + const agent = series.agents[0]; + const collectionOff = !series.collectionEnabled; + const hasRetainedData = series.coverage.reportCount > 0; + // Invalid-only: in-window invalid rows exist but none were bucketed (A5/A11). + // Distinct from outside-window history — we have evidence in this window, + // it just couldn't be counted. Must not be mislabeled as outside-window. + const hasInvalidOnlyInWindow = + agent === undefined && + series.collectionEnabled && + series.coverage.invalidReportCount > 0; + const hasEvidenceOutsideWindow = + agent === undefined && + !hasInvalidOnlyInWindow && + series.hasArchivedEvidence === true; + + if ( + !collectionOff && + agent === undefined && + !hasEvidenceOutsideWindow && + !hasInvalidOnlyInWindow + ) { + return ( +

+ No locally archived usage in the last {days} days. Usage appears after + this agent completes a usage-reporting turn. +

+ ); + } + + return ( +
+ {collectionOff ? ( + + + + {hasRetainedData + ? `Collection off · data through ${formatCoverageDate( + series.coverage.lastArchivedAt, + )}` + : "Local usage collection is off."} + + + + + ) : null} + + {agent ? ( + + ) : hasEvidenceOutsideWindow ? ( +

+ No locally archived usage in the last {days} days, but this agent has + reported usage previously. Try the 30-day window. +

+ ) : hasInvalidOnlyInWindow ? ( +

+ Usage was collected in the last {days} days but could not be counted — + reports with unreadable timestamps or missing session totals are + excluded and are not assigned to any day. +

+ ) : null} +
+ ); +} + +function AgentUsageFocusedTotals({ + agent, + coverage, +}: { + agent: AgentUsageSeries["agents"][number]; + coverage: AgentUsageSeries["coverage"]; +}) { + const { estimatedCostUsd, inputTokens, outputTokens, totalTokens } = + agent.usage; + const models = sortModelsByKnownTotal(agent.models); + const explainPartial = + agent.hasUnknownUsage || coverage.invalidReportCount > 0; + + return ( + +
+ + + + +
+ + {agent.buckets.length > 0 ? ( +
+

Daily usage

+ +
+ ) : null} + + {models.length > 0 ? ( +
+

By model

+ {models.map((model) => ( +
+ + {model.model ?? "Unknown model"} + + + {isUnknownField(model.usage.totalTokens) + ? formatModelIndependentFields(model) + : formatTokenCountExact( + parseTokenCount(model.usage.totalTokens.value) ?? 0n, + )} + {isPartialField(model.usage.totalTokens) || + isModelIoPartial(model) ? ( + + Partial + + ) : null} + +
+ ))} +
+ ) : null} + +
+

+ {agent.reportCount} reported turn{agent.reportCount === 1 ? "" : "s"} + {" · "} + {formatCoverageRange(coverage)} +

+ {explainPartial ? ( +

+ Some usage could not be counted: reports with an unreadable + timestamp or a cumulative total missing its session are excluded, + and unknown intervals are omitted rather than shown as zero. +

+ ) : null} +
+
+ ); +} + +function TokenStat({ + field, + label, +}: { + field: { value: string | null; incomplete: boolean }; + label: string; +}) { + const parsed = parseTokenCount(field.value); + return ( + + ); +} + +function UsageStat({ + display, + isPartial, + label, +}: { + display: string | null; + isPartial: boolean; + label: string; +}) { + return ( +
+

{label}

+

{display ?? "—"}

+ {isPartial ? Partial : null} +
+ ); +} + +function formatCoverageDate(unixSeconds: number | null): string { + if (unixSeconds === null) return "unknown"; + return new Date(unixSeconds * 1000).toLocaleDateString(undefined, { + month: "short", + day: "numeric", + }); +} + +/** + * Human-readable coverage range for the focused view's footer, from the + * exact first/last reported timestamps the backend already computes + * (plan:329's "coverage dates"). `null` on either end means no reported row + * fell in this window (the caller only renders this once `agent` exists, + * so both are actually set in practice, but the fallback stays honest). + */ +function formatCoverageRange(coverage: AgentUsageSeries["coverage"]): string { + const { firstReportedAt, lastReportedAt } = coverage; + if (firstReportedAt === null || lastReportedAt === null) { + return "coverage unknown"; + } + if (firstReportedAt === lastReportedAt) { + return `reported ${formatCoverageDate(firstReportedAt)}`; + } + return `${formatCoverageDate(firstReportedAt)} – ${formatCoverageDate(lastReportedAt)}`; +} + +/** + * Render known model I/O fields when the model total is unknown — never + * collapses to "No usage reported" when input or output is actually known + * (A2 per-field completeness). Mirrors `formatIndependentFields` in the + * overview row. + */ +function formatModelIndependentFields(model: AgentUsageModel): string { + const input = parseTokenCount(model.usage.inputTokens.value); + const output = parseTokenCount(model.usage.outputTokens.value); + if (input !== null || output !== null) { + const parts: string[] = []; + if (input !== null) parts.push(`in ${formatTokenCountCompact(input)}`); + if (output !== null) parts.push(`out ${formatTokenCountCompact(output)}`); + return parts.join(" · "); + } + return "No usage reported"; +} + +/** + * True when a model has no known total but its displayed I/O fields carry + * incomplete truth — so the Partial badge must still appear (A2). + */ +function isModelIoPartial(model: AgentUsageModel): boolean { + return ( + isUnknownField(model.usage.totalTokens) && + (isPartialField(model.usage.inputTokens) || + isPartialField(model.usage.outputTokens)) + ); +} diff --git a/desktop/src/features/agent-usage/ui/AgentUsageSection.tsx b/desktop/src/features/agent-usage/ui/AgentUsageSection.tsx new file mode 100644 index 0000000000..c6f0d4fba6 --- /dev/null +++ b/desktop/src/features/agent-usage/ui/AgentUsageSection.tsx @@ -0,0 +1,338 @@ +import * as React from "react"; +import { RefreshCw } from "lucide-react"; + +import { useAppShell } from "@/app/AppShellContext"; +import type { ProfilePanelOpenOptions } from "@/shared/context/ProfilePanelContext"; +import { useUsersBatchQuery } from "@/features/profile/hooks"; +import { + resolveUserLabel, + type UserProfileLookup, +} from "@/features/profile/lib/identity"; +import { ProfileAvatar } from "@/features/profile/ui/ProfileAvatar"; +import type { AgentUsage, AgentUsageSeries } from "@/shared/api/tauriArchive"; +import { Alert, AlertDescription } from "@/shared/ui/alert"; +import { Badge } from "@/shared/ui/badge"; +import { Button } from "@/shared/ui/button"; +import { Card } from "@/shared/ui/card"; +import { SectionHeader } from "@/shared/ui/PageHeader"; +import { Progress } from "@/shared/ui/progress"; +import { Skeleton } from "@/shared/ui/skeleton"; +import { Tabs, TabsList, TabsTrigger } from "@/shared/ui/tabs"; +import { useAgentUsageSeries } from "../hooks"; +import { + bigintRatio, + formatTokenCountCompact, + isPartialField, + isUnknownField, + parseTokenCount, + sortAgentsByKnownTotal, + sumKnownBucketTotals, + type UsageWindowDays, +} from "../lib/agentUsage"; +import { AgentUsageDailyBars } from "./AgentUsageDailyBars"; + +/** + * Compact "Usage" section on the Agents page: local NIP-AM usage totals for + * the last 7 or 30 days, broken down per agent, with a click-through to the + * per-agent focused view in the profile panel (M4/A9/A13, frozen Rev 3 plan). + */ +export function AgentUsageSection({ + onOpenAgentProfile, +}: { + onOpenAgentProfile: ( + pubkey: string, + options?: ProfilePanelOpenOptions, + ) => void; +}) { + const [days, setDays] = React.useState(7); + const query = useAgentUsageSeries({ days }); + const { onOpenSettings } = useAppShell(); + + const agents = React.useMemo( + () => sortAgentsByKnownTotal(query.data?.agents ?? []), + [query.data?.agents], + ); + const pubkeys = React.useMemo( + () => agents.map((agent) => agent.agentPubkey), + [agents], + ); + const usersBatchQuery = useUsersBatchQuery(pubkeys, { + enabled: pubkeys.length > 0, + }); + + return ( +
+ setDays(value === "30" ? 30 : 7)} + value={String(days)} + > + + + 7d + + + 30d + + + + } + description="Locally archived, agent-reported usage." + title="Usage" + /> + + {query.isLoading ? ( + + ) : query.isError ? ( + + + Couldn't load usage data. + + + + ) : query.data ? ( + + ) : null} +
+ ); +} + +function AgentUsageSkeleton() { + return ( + + + + + + + ); +} + +function AgentUsageCard({ + agents, + days, + onOpenAgentProfile, + onOpenSettings, + profiles, + series, +}: { + agents: AgentUsage[]; + days: UsageWindowDays; + onOpenAgentProfile: ( + pubkey: string, + options?: ProfilePanelOpenOptions, + ) => void; + onOpenSettings: ((section: "local-archive") => void) | null; + profiles: UserProfileLookup | undefined; + series: AgentUsageSeries; +}) { + const hasRows = agents.length > 0; + const collectionOff = !series.collectionEnabled; + const hasRetainedData = series.coverage.reportCount > 0; + // True when the window has in-window invalid rows but no valid/bucketed rows. + // These rows are correctly excluded from buckets (A5/A11) but the window is + // not empty — coverage.hasUnknownUsage reflects this via the F1 roll-up. + const hasInvalidOnlyInWindow = + !hasRows && + series.collectionEnabled && + series.coverage.invalidReportCount > 0; + + // Relative bars are decorative (aria-hidden, per plan) — scale each agent's + // known total against the largest known total in the current window so the + // sorted-by-total list also reads as a bar chart. + const maxKnownTotal = React.useMemo( + () => + agents.reduce((max, agent) => { + const total = parseTokenCount(agent.usage.totalTokens.value); + return total !== null && total > max ? total : max; + }, 0n), + [agents], + ); + + const overallTotal = React.useMemo( + () => sumKnownBucketTotals(series.buckets), + [series.buckets], + ); + + return ( + + {series.buckets.length > 0 ? ( +
+
+

Daily usage

+ + {overallTotal.knownTotal !== null + ? `${formatTokenCountCompact(overallTotal.knownTotal)} tokens` + : hasInvalidOnlyInWindow + ? "Usage uncountable" + : "No usage reported"} + {overallTotal.partial || hasInvalidOnlyInWindow ? ( + + Partial + + ) : null} + +
+ +
+ ) : null} + + {collectionOff ? ( + + + + {hasRetainedData + ? `Collection off · data through ${formatCoverageDate( + series.coverage.lastArchivedAt, + )}` + : "Local usage collection is off."} + + + + + ) : null} + + {hasRows ? ( +
+ {agents.map((agent) => ( + + ))} +
+ ) : ( +

+ {collectionOff + ? "Turn on collection to start tracking agent usage." + : hasInvalidOnlyInWindow + ? `Usage was collected in the last ${days} days but could not be counted — reports with unreadable timestamps or missing session totals are excluded.` + : `No locally archived usage in the last ${days} days. Usage appears after an agent completes a usage-reporting turn.`} +

+ )} +
+ ); +} + +function formatCoverageDate(unixSeconds: number | null): string { + if (unixSeconds === null) return "unknown"; + return new Date(unixSeconds * 1000).toLocaleDateString(undefined, { + month: "short", + day: "numeric", + }); +} + +function AgentUsageRow({ + agent, + days, + label, + maxKnownTotal, + onOpenAgentProfile, + profileAvatarUrl, +}: { + agent: AgentUsage; + days: UsageWindowDays; + label: string; + maxKnownTotal: bigint; + onOpenAgentProfile: ( + pubkey: string, + options?: ProfilePanelOpenOptions, + ) => void; + profileAvatarUrl: string | null; +}) { + const total = agent.usage.totalTokens; + const knownTotal = parseTokenCount(total.value); + const partial = isPartialField(total); + const unknown = isUnknownField(total); + + // When total is null (unknown), check whether any displayed I/O field is + // incomplete — per-field partial truth must be preserved at every seam (A2). + const ioPartial = + knownTotal === null && + (isPartialField(agent.usage.inputTokens) || + isPartialField(agent.usage.outputTokens)); + + const trailing = + knownTotal !== null + ? formatTokenCountCompact(knownTotal) + : formatIndependentFields(agent); + + return ( + + ); +} + +function formatIndependentFields(agent: AgentUsage): string { + const input = parseTokenCount(agent.usage.inputTokens.value); + const output = parseTokenCount(agent.usage.outputTokens.value); + if (input !== null || output !== null) { + const parts: string[] = []; + if (input !== null) parts.push(`in ${formatTokenCountCompact(input)}`); + if (output !== null) parts.push(`out ${formatTokenCountCompact(output)}`); + return parts.join(" · "); + } + return "No usage reported"; +} diff --git a/desktop/src/features/agents/ui/AgentsScreen.tsx b/desktop/src/features/agents/ui/AgentsScreen.tsx index 361199c50d..bbb528c5a8 100644 --- a/desktop/src/features/agents/ui/AgentsScreen.tsx +++ b/desktop/src/features/agents/ui/AgentsScreen.tsx @@ -72,7 +72,8 @@ export function AgentsScreen() { profile: pubkey, profilePersona: null, profileTab: options?.tab === "info" ? null : (options?.tab ?? null), - profileView: null, + profileView: + options?.view === "summary" ? null : (options?.view ?? null), }); }, [applyPatch], diff --git a/desktop/src/features/agents/ui/AgentsView.tsx b/desktop/src/features/agents/ui/AgentsView.tsx index f750e266db..5aac0a66b6 100644 --- a/desktop/src/features/agents/ui/AgentsView.tsx +++ b/desktop/src/features/agents/ui/AgentsView.tsx @@ -4,6 +4,7 @@ import { consumePendingSnapshotImport, subscribeSnapshotImport, } from "@/features/agents/openSnapshotImportFromUrlEvent"; +import { AgentUsageSection } from "@/features/agent-usage/ui/AgentUsageSection"; import { AddAgentToChannelDialog } from "./AddAgentToChannelDialog"; import { AddTeamToChannelDialog } from "./AddTeamToChannelDialog"; import { AgentDefaultsDialog } from "./AgentDefaultsDialog"; @@ -202,6 +203,12 @@ export function AgentsView() { }} /> + { + openProfilePanel?.(pubkey, options); + }} + /> + { ); }); +test("manager_notifies_agent_metrics_changed_when_persisted_agent_metrics_positive", async () => { + const relay = makeFakeRelayClient(); + const archive = makeFakeArchive(); + archive.setSubs([ + { + scopeType: "owner_p", + scopeValue: "agent-pk", + kinds: [44200], + identityPubkey: "pk", + relayUrl: "wss://r", + createdAt: 0, + }, + ]); + const mgr = makeManager(relay, archive, { flushBatchSize: 1 }); + await mgr.start(); + await tick(); + + let fired = 0; + const off = onAgentMetricsChanged(() => { + fired++; + }); + + archive.setNextPersistedAgentMetrics(1); + const filter = JSON.parse([...relay.subs.keys()][0]); + relay.push(filter, { + id: "metric-1", + kind: 44200, + pubkey: "agent-pk", + created_at: 1, + content: "encrypted", + tags: [], + }); + await tick(); + + assert.equal(fired, 1, "notifier fires once when persistedAgentMetrics > 0"); + off(); + mgr.destroy(); +}); + +test("manager_does_not_notify_agent_metrics_changed_when_persisted_agent_metrics_zero", async () => { + const relay = makeFakeRelayClient(); + const archive = makeFakeArchive(); + archive.setSubs([ + { + scopeType: "channel_h", + scopeValue: "chan-1", + kinds: [9], + identityPubkey: "pk", + relayUrl: "wss://r", + createdAt: 0, + }, + ]); + const mgr = makeManager(relay, archive, { flushBatchSize: 1 }); + await mgr.start(); + await tick(); + + let fired = 0; + const off = onAgentMetricsChanged(() => { + fired++; + }); + + // Non-metric event; archiveEvents defaults to persistedAgentMetrics: 0. + const filter = JSON.parse([...relay.subs.keys()][0]); + relay.push(filter, { + id: "ev1", + kind: 9, + pubkey: "pk", + created_at: 1, + content: "hi", + tags: [], + }); + await tick(); + + assert.equal(fired, 0, "notifier must not fire for persistedAgentMetrics: 0"); + off(); + mgr.destroy(); +}); + +test("manager_does_not_notify_agent_metrics_changed_on_archive_events_rejection", async () => { + const relay = makeFakeRelayClient(); + const archive = makeFakeArchive(); + archive.setSubs([ + { + scopeType: "owner_p", + scopeValue: "agent-pk", + kinds: [44200], + identityPubkey: "pk", + relayUrl: "wss://r", + createdAt: 0, + }, + ]); + const mgr = makeManager(relay, archive, { + flushBatchSize: 1, + archiveEvents: async () => { + throw new Error("simulated archive_events failure"); + }, + }); + await mgr.start(); + await tick(); + + let fired = 0; + const off = onAgentMetricsChanged(() => { + fired++; + }); + + const filter = JSON.parse([...relay.subs.keys()][0]); + relay.push(filter, { + id: "metric-1", + kind: 44200, + pubkey: "agent-pk", + created_at: 1, + content: "encrypted", + tags: [], + }); + await tick(); + + assert.equal(fired, 0, "a rejected archiveEvents call must never notify"); + off(); + mgr.destroy(); +}); + +test("manager_notifies_agent_metrics_changed_on_destroy_flush", async () => { + const relay = makeFakeRelayClient(); + const archive = makeFakeArchive(); + archive.setSubs([ + { + scopeType: "owner_p", + scopeValue: "agent-pk", + kinds: [44200], + identityPubkey: "pk", + relayUrl: "wss://r", + createdAt: 0, + }, + ]); + // Large batch size / idle so the event stays buffered until destroy() flushes it. + const mgr = makeManager(relay, archive, { + flushBatchSize: 100, + flushIdleMs: 10000, + }); + await mgr.start(); + await tick(); + + let fired = 0; + const off = onAgentMetricsChanged(() => { + fired++; + }); + + archive.setNextPersistedAgentMetrics(1); + const filter = JSON.parse([...relay.subs.keys()][0]); + relay.push(filter, { + id: "metric-1", + kind: 44200, + pubkey: "agent-pk", + created_at: 1, + content: "encrypted", + tags: [], + }); + assert.equal(archive.archiveCalls.length, 0, "buffered, not yet flushed"); + + mgr.destroy(); + await tick(); + + assert.equal(archive.archiveCalls.length, 1, "destroy() flushes the buffer"); + assert.equal( + fired, + 1, + "destroy flush notifies when persistedAgentMetrics > 0", + ); + off(); +}); + test("manager_flushes_buffer_on_destroy", async () => { const relay = makeFakeRelayClient(); const archive = makeFakeArchive(); diff --git a/desktop/src/features/local-archive/archiveSyncManager.ts b/desktop/src/features/local-archive/archiveSyncManager.ts index c973641695..3672989a7d 100644 --- a/desktop/src/features/local-archive/archiveSyncManager.ts +++ b/desktop/src/features/local-archive/archiveSyncManager.ts @@ -4,7 +4,9 @@ import type { RelayEvent } from "@/shared/api/types"; import { archiveEvents as defaultArchiveEvents, listSaveSubscriptions as defaultListSaveSubscriptions, + notifyAgentMetricsChanged, onSubscriptionChange as defaultOnSubscriptionChange, + type ArchiveBatchResult, type SaveSubscription, type ScopeType, } from "@/shared/api/tauriArchive"; @@ -30,7 +32,7 @@ export interface ArchiveSyncDeps { rawEventJson: string; matchedScope: { scopeType: ScopeType; scopeValue: string }; }>, - ) => Promise; + ) => Promise; onSubscriptionChange: (listener: () => void) => () => void; flushBatchSize?: number; flushIdleMs?: number; @@ -135,9 +137,7 @@ export class ArchiveSyncManager { // Flush any buffered events before tearing down. if (this.buffer.length > 0) { const toFlush = this.buffer.splice(0); - void this.deps.archiveEvents(toFlush).catch((err: unknown) => { - console.warn("[archiveSyncManager] flush on destroy failed:", err); - }); + this.sendBatch(toFlush, "flush on destroy failed"); } for (const [, unsub] of this.active) { void unsub(); @@ -292,9 +292,33 @@ export class ArchiveSyncManager { } if (this.buffer.length === 0) return; const batch = this.buffer.splice(0); - void this.deps.archiveEvents(batch).catch((err: unknown) => { - console.warn("[archiveSyncManager] archive_events failed:", err); - }); + this.sendBatch(batch, "archive_events failed"); + } + + /** + * Fire-and-forget `archiveEvents(batch)`, shared by the idle/size-triggered + * flush and the destroy-time flush. Notifies `onAgentMetricsChanged` + * subscribers only when the backend confirms `persistedAgentMetrics > 0` — + * the backend is authoritative, so a rejected call, a duplicate-only batch, + * or a batch with no kind-44200 events never notifies. + */ + private sendBatch( + batch: Array<{ + rawEventJson: string; + matchedScope: { scopeType: ScopeType; scopeValue: string }; + }>, + errLabel: string, + ): void { + void this.deps + .archiveEvents(batch) + .then((result) => { + if (result.persistedAgentMetrics > 0) { + notifyAgentMetricsChanged(); + } + }) + .catch((err: unknown) => { + console.warn(`[archiveSyncManager] ${errLabel}:`, err); + }); } } diff --git a/desktop/src/features/profile/ui/UserProfilePanel.tsx b/desktop/src/features/profile/ui/UserProfilePanel.tsx index 09e8d51487..0e8f7b6964 100644 --- a/desktop/src/features/profile/ui/UserProfilePanel.tsx +++ b/desktop/src/features/profile/ui/UserProfilePanel.tsx @@ -71,6 +71,9 @@ import { ProfileSummaryView, } from "@/features/profile/ui/UserProfilePanelSections"; import { AgentConfigurationFocusedView } from "@/features/profile/ui/UserProfilePanelAgentDetails"; +import { AgentUsageFocusedView } from "@/features/agent-usage/ui/AgentUsageFocusedView"; +import { useAgentUsageSeries } from "@/features/agent-usage/hooks"; +import { deriveUsageIngressTrailing } from "@/features/agent-usage/lib/agentUsage"; import { UserProfileAgentSettingsMenuSlot } from "@/features/profile/ui/UserProfileAgentActions"; import { useProfileAgentDeletion } from "@/features/profile/ui/UserProfilePanelDeletion"; import { useProfileFieldBuckets } from "@/features/profile/ui/UserProfilePanelFields"; @@ -342,6 +345,18 @@ export function UserProfilePanel({ viewerIsOwner && Boolean(effectivePubkey) && canOpenAgentActivity(effectivePubkey); + const canViewUsage = viewerIsOwner && isBot && Boolean(effectivePubkey); + // Info-tab ingress row's trailing text (plan:328) needs its own 7-day + // query — independent of the focused view's own 7d/30d selector, and + // gated off entirely when the row won't render. + const usageIngressQuery = useAgentUsageSeries({ + agentPubkey: effectivePubkey ?? undefined, + days: 7, + enabled: canViewUsage, + }); + const usageIngressTrailing = usageIngressQuery.data + ? deriveUsageIngressTrailing(usageIngressQuery.data) + : undefined; const canOpenAgentLogs = isOwner === true && managedAgent?.backend.type === "local"; const canInstantiateAgent = @@ -819,6 +834,8 @@ export function UserProfilePanel({ canInstantiateAgent={canInstantiateAgent} canOpenAgentLogs={canOpenAgentLogs} canViewActivity={canViewActivity} + canViewUsage={canViewUsage} + usageIngressTrailing={usageIngressTrailing} callerChannelId={callerChannelId} channelCount={profileChannels.length} channelIdToName={channelIdToName} @@ -853,6 +870,7 @@ export function UserProfilePanel({ onOpenChannel={handleOpenChannel} onOpenDiagnostics={() => setView("diagnostics")} onOpenInstructions={() => setView("instructions")} + onOpenUsage={() => setView("usage")} onTabChange={setTab} onOpenDm={onOpenDm} presenceStatus={presenceStatus} @@ -870,6 +888,13 @@ export function UserProfilePanel({ viewerIsOwner={viewerIsOwner} /> ) : null} + {view === "usage" && effectivePubkey ? ( + setView("summary", { replace: true })} + /> + ) : null} {view === "info" ? ( ) : null} diff --git a/desktop/src/features/profile/ui/UserProfilePanelSections.tsx b/desktop/src/features/profile/ui/UserProfilePanelSections.tsx index 2a46dcc7a5..0eb0dc0f1e 100644 --- a/desktop/src/features/profile/ui/UserProfilePanelSections.tsx +++ b/desktop/src/features/profile/ui/UserProfilePanelSections.tsx @@ -68,6 +68,8 @@ export type ProfileSummaryViewProps = { canEditAgent: boolean; canOpenAgentLogs: boolean; canViewActivity: boolean; + canViewUsage: boolean; + usageIngressTrailing: string | undefined; channelCount: number; channelIdToName: Record; channels: ProfileChannelLink[]; @@ -101,6 +103,7 @@ export type ProfileSummaryViewProps = { onOpenChannel: (channelId: string) => void; onOpenDiagnostics: () => void; onOpenInstructions: () => void; + onOpenUsage: () => void; onTabChange: (tab: ProfilePanelTab, options?: { replace?: boolean }) => void; onOpenDm?: (pubkeys: string[]) => Promise | void; presenceStatus: "online" | "away" | "offline" | undefined; @@ -180,6 +183,8 @@ export function ProfileSummaryView({ canEditAgent, canOpenAgentLogs, canViewActivity, + canViewUsage, + usageIngressTrailing, channelCount, channelIdToName, channels, @@ -213,6 +218,7 @@ export function ProfileSummaryView({ onOpenChannel, onOpenDiagnostics, onOpenInstructions, + onOpenUsage, onTabChange, onOpenDm, presenceStatus, @@ -250,11 +256,13 @@ export function ProfileSummaryView({ diagnosticsFields.some((field) => field.label !== "Status") || canOpenAgentLogs; const showActivityIngress = canViewActivity; + const showUsageIngress = canViewUsage; const showInfoTab = agentInfoFields.length > 0 || instances.length > 1 || isArchived || showActivityIngress || + showUsageIngress || !showRuntimeTab; const diagnosticsErrorField = diagnosticsFields.find( @@ -397,8 +405,11 @@ export function ProfileSummaryView({ isArchived={isArchived} onOpenActivity={onOpenActivity} onOpenInstance={onOpenInstance} + onOpenUsage={onOpenUsage} pubkey={pubkey} showActivityIngress={showActivityIngress} + showUsageIngress={showUsageIngress} + usageIngressTrailing={usageIngressTrailing} /> ) : null} {activeTab === "runtime" ? ( diff --git a/desktop/src/features/profile/ui/UserProfilePanelTabs.tsx b/desktop/src/features/profile/ui/UserProfilePanelTabs.tsx index 0fe4e19581..3f59b9fbc6 100644 --- a/desktop/src/features/profile/ui/UserProfilePanelTabs.tsx +++ b/desktop/src/features/profile/ui/UserProfilePanelTabs.tsx @@ -3,6 +3,7 @@ import type { LucideIcon } from "lucide-react"; import { Activity, Archive, + BarChart3, ChevronRight, Info, RefreshCw, @@ -287,8 +288,11 @@ export function ProfileInfoTabContent({ isArchived, onOpenActivity, onOpenInstance, + onOpenUsage, pubkey, showActivityIngress, + showUsageIngress, + usageIngressTrailing, }: { activeTurns: ActiveTurnSummary[]; activityAgent: ProfileActivityAgent | null; @@ -299,8 +303,11 @@ export function ProfileInfoTabContent({ isArchived: boolean; onOpenActivity: (channelId?: string | null) => void; onOpenInstance: (pubkey: string) => void; + onOpenUsage: () => void; pubkey: string | null; showActivityIngress: boolean; + showUsageIngress: boolean; + usageIngressTrailing: string | undefined; }) { const infoFields: ProfileField[] = isArchived ? [ @@ -320,7 +327,12 @@ export function ProfileInfoTabContent({ const showLiveActivityEmbed = showActivityIngress && (feedScope.isLive || feedScope.hasFeedContent); - if (!hasInfoFields && !showActivityIngress && !hasInstances) { + if ( + !hasInfoFields && + !showActivityIngress && + !hasInstances && + !showUsageIngress + ) { return null; } @@ -346,6 +358,15 @@ export function ProfileInfoTabContent({ /> ) ) : null} + {showUsageIngress ? ( + + ) : null} {hasInfoFields ? : null} {hasInstances ? ( { "memories", "channels", "logs", + "usage", ]) { assert.equal(parseProfilePanelView(view), view); } diff --git a/desktop/src/features/profile/ui/UserProfilePanelUtils.ts b/desktop/src/features/profile/ui/UserProfilePanelUtils.ts index 07f57803b4..f547a407d7 100644 --- a/desktop/src/features/profile/ui/UserProfilePanelUtils.ts +++ b/desktop/src/features/profile/ui/UserProfilePanelUtils.ts @@ -25,7 +25,8 @@ export type ProfilePanelView = | "diagnostics" | "memories" | "channels" - | "logs"; + | "logs" + | "usage"; export type ProfilePanelTab = "info" | "runtime" | "channels" | "memories"; @@ -38,6 +39,7 @@ export const PROFILE_PANEL_VIEW_TITLES: Record = { memories: "Memories", channels: "Channels", logs: "Harness Log", + usage: "Usage", }; const PROFILE_PANEL_VIEWS = new Set( diff --git a/desktop/src/shared/api/tauriArchive.ts b/desktop/src/shared/api/tauriArchive.ts index 0e4be6c9ac..96c2e6d7be 100644 --- a/desktop/src/shared/api/tauriArchive.ts +++ b/desktop/src/shared/api/tauriArchive.ts @@ -1,5 +1,85 @@ +import { KIND_AGENT_TURN_METRIC } from "@/shared/constants/kinds"; + import { invokeTauri } from "./tauri"; +// ── Agent usage wire types (NIP-AM, `get_agent_usage_series`) ──────────────── +// +// Mirrors `desktop-src-tauri/src/archive/agent_usage.rs` field-for-field. +// Token counters cross the Tauri boundary as decimal strings (JS cannot +// exactly represent the full `u64` range); parse with `BigInt(...)` in the +// feature slice, never `Number(...)`. + +export type UsageField = { value: string | null; incomplete: boolean }; +export type CostField = { value: number | null; incomplete: boolean }; + +export type ReportedUsage = { + inputTokens: UsageField; + outputTokens: UsageField; + totalTokens: UsageField; + estimatedCostUsd: CostField; +}; + +export type AgentUsageSeriesBucket = { + start: number; + end: number; + usage: ReportedUsage; + reportCount: number; + hasUnknownUsage: boolean; +}; + +export type AgentUsageModel = { + model: string | null; + usage: ReportedUsage; + reportCount: number; + hasUnknownUsage: boolean; +}; + +export type AgentUsage = { + agentPubkey: string; + usage: ReportedUsage; + buckets: AgentUsageSeriesBucket[]; + models: AgentUsageModel[]; + reportCount: number; + hasUnknownUsage: boolean; +}; + +export type AgentUsageCoverage = { + firstArchivedAt: number | null; + lastArchivedAt: number | null; + firstReportedAt: number | null; + lastReportedAt: number | null; + reportCount: number; + invalidReportCount: number; + hasUnknownUsage: boolean; +}; + +export type AgentUsageSeries = { + collectionEnabled: boolean; + buckets: AgentUsageSeriesBucket[]; + agents: AgentUsage[]; + coverage: AgentUsageCoverage; + /** + * A13: `null` when the request had no `agentPubkey` filter; otherwise + * `true` iff at least one surviving `agent_metric_index` row (either + * `parseStatus`) exists for that author, independent of the requested + * bucket window. Drives profile focused-view eligibility for historical + * agents whose only evidence falls outside the current 7d/30d window. + */ + hasArchivedEvidence: boolean | null; +}; + +export type AgentUsageSeriesRequest = { + /** + * Exact local-midnight Unix-second boundaries, inclusive start/exclusive + * end per adjacent pair. Exactly 8 entries (7 buckets) or 31 entries (30 + * buckets) — build with the feature slice's DST-safe boundary helper, + * never `N * 86_400`. + */ + bucketBoundaries: number[]; + /** Normalized 64-hex author filter for the profile drill-in, or omit for the overview. */ + agentPubkey?: string; +}; + // ── Wire-shape types (raw Tauri responses) ─────────────────────────────────── /** @@ -32,9 +112,33 @@ export type SaveSubscription = { export type ArchiveBatchResult = { persisted: number; + /** + * Newly-indexed `agent_metric_index` rows (valid or invalid) written by + * this call. A re-ingested duplicate event does not increment this even + * when `persisted` counts it, because the index row for that id was + * already written by whichever earlier batch first saw it. Missing on + * the wire (older/mocked responses) decodes as `0` — see + * `decodeArchiveBatchResult`. + */ + persistedAgentMetrics: number; dropped: number; }; +/** + * Rust sends camelCase (`#[serde(rename_all = "camelCase")]` on + * `ArchiveBatchResult`), but decode defensively rather than trust every + * caller (including mocks/tests) to supply every field. + */ +function decodeArchiveBatchResult( + raw: Partial, +): ArchiveBatchResult { + return { + persisted: raw.persisted ?? 0, + persistedAgentMetrics: raw.persistedAgentMetrics ?? 0, + dropped: raw.dropped ?? 0, + }; +} + // ── Subscription-change notifier ───────────────────────────────────────────── /** @@ -55,6 +159,28 @@ function notifySubscriptionChange(): void { } } +// ── Agent-metrics-change notifier ──────────────────────────────────────────── + +/** + * Module-level notifier for newly persisted agent turn metrics (kind 44200). + * `useAgentUsageSeries` subscribes to this to invalidate its query without + * polling. Fired only when the backend confirms `persistedAgentMetrics > 0` + * for a successful `archiveEvents` call, or when a kind-44200 subscription + * mutation succeeds (`collectionEnabled` is part of the usage query result). + */ +const agentMetricsChangeListeners = new Set<() => void>(); + +export function onAgentMetricsChanged(listener: () => void): () => void { + agentMetricsChangeListeners.add(listener); + return () => agentMetricsChangeListeners.delete(listener); +} + +export function notifyAgentMetricsChanged(): void { + for (const listener of agentMetricsChangeListeners) { + listener(); + } +} + // ── Decoder ────────────────────────────────────────────────────────────────── function decodeRawSubscription(raw: RawSaveSubscription): SaveSubscription { @@ -124,6 +250,12 @@ export async function agentMetricArchiveDefaultEnabled(): Promise { export async function mergeSaveSubscriptionKinds(kind: number): Promise { await invokeTauri("merge_save_subscription_kinds", { kind }); notifySubscriptionChange(); + // `collectionEnabled` is part of the usage query result — toggling kind + // 44200 on must invalidate mounted usage queries. Other kinds don't affect + // usage state. + if (kind === KIND_AGENT_TURN_METRIC) { + notifyAgentMetricsChanged(); + } } /** @@ -142,6 +274,9 @@ export async function mergeSaveSubscriptionKinds(kind: number): Promise { export async function removeSaveSubscriptionKind(kind: number): Promise { await invokeTauri("remove_save_subscription_kind", { kind }); notifySubscriptionChange(); + if (kind === KIND_AGENT_TURN_METRIC) { + notifyAgentMetricsChanged(); + } } /** @@ -209,7 +344,7 @@ export async function archiveEvents( matchedScope: { scopeType: ScopeType; scopeValue: string }; }>, ): Promise { - return invokeTauri("archive_events", { + const raw = await invokeTauri>("archive_events", { candidates: candidates.map((c) => ({ raw_event_json: c.rawEventJson, matched_scope: { @@ -218,6 +353,7 @@ export async function archiveEvents( }, })), }); + return decodeArchiveBatchResult(raw); } /** @@ -306,6 +442,18 @@ export async function readUnindexedObserverRows(): Promise< })); } +/** + * Read the locally archived NIP-AM usage series for the active identity + + * relay (Rev 3 frozen contract). Rust owns identity/relay scoping, request + * validation, backfill-before-read, and the accounting ladder — this is a + * thin typed wrapper with no client-side logic. + */ +export async function getAgentUsageSeries( + request: AgentUsageSeriesRequest, +): Promise { + return invokeTauri("get_agent_usage_series", { request }); +} + /** * Read a paginated page of archived raw events for a scope. * diff --git a/desktop/src/shared/context/ProfilePanelContext.tsx b/desktop/src/shared/context/ProfilePanelContext.tsx index 3f47364c5a..4127172f06 100644 --- a/desktop/src/shared/context/ProfilePanelContext.tsx +++ b/desktop/src/shared/context/ProfilePanelContext.tsx @@ -1,9 +1,11 @@ import * as React from "react"; +import type { ProfilePanelView } from "@/features/profile/ui/UserProfilePanelUtils"; import type { AgentPersona } from "@/shared/api/types"; export type ProfilePanelOpenOptions = { tab?: "info" | "runtime" | "channels" | "memories"; + view?: ProfilePanelView; }; type ProfilePanelContextValue = { diff --git a/desktop/src/testing/e2eBridge.ts b/desktop/src/testing/e2eBridge.ts index 5d0505a308..23cbffd2ca 100644 --- a/desktop/src/testing/e2eBridge.ts +++ b/desktop/src/testing/e2eBridge.ts @@ -123,6 +123,77 @@ type MockSearchProfileSeed = { isAgent?: boolean; }; +// ── Agent usage (NIP-AM) mock wire shapes ──────────────────────────────────── +// Mirrors `desktop/src/shared/api/tauriArchive.ts`'s camelCase types +// field-for-field, kept independent of that module so the bridge has no +// runtime dependency on the feature slice it's mocking for. + +type RawUsageField = { value: string | null; incomplete: boolean }; +type RawCostField = { value: number | null; incomplete: boolean }; + +type RawReportedUsage = { + inputTokens: RawUsageField; + outputTokens: RawUsageField; + totalTokens: RawUsageField; + estimatedCostUsd: RawCostField; +}; + +type RawAgentUsageSeriesBucket = { + start: number; + end: number; + usage: RawReportedUsage; + reportCount: number; + hasUnknownUsage: boolean; +}; + +type RawAgentUsageModel = { + model: string | null; + usage: RawReportedUsage; + reportCount: number; + hasUnknownUsage: boolean; +}; + +type RawAgentUsage = { + agentPubkey: string; + usage: RawReportedUsage; + buckets: RawAgentUsageSeriesBucket[]; + models: RawAgentUsageModel[]; + reportCount: number; + hasUnknownUsage: boolean; +}; + +type RawAgentUsageSeries = { + collectionEnabled: boolean; + buckets: RawAgentUsageSeriesBucket[]; + agents: RawAgentUsage[]; + coverage: { + firstArchivedAt: number | null; + lastArchivedAt: number | null; + firstReportedAt: number | null; + lastReportedAt: number | null; + reportCount: number; + invalidReportCount: number; + hasUnknownUsage: boolean; + }; + hasArchivedEvidence: boolean | null; +}; + +const DEFAULT_MOCK_AGENT_USAGE_SERIES: RawAgentUsageSeries = { + collectionEnabled: true, + buckets: [], + agents: [], + coverage: { + firstArchivedAt: null, + lastArchivedAt: null, + firstReportedAt: null, + lastReportedAt: null, + reportCount: 0, + invalidReportCount: 0, + hasUnknownUsage: false, + }, + hasArchivedEvidence: null, +}; + type E2eConfig = { mode?: "mock" | "relay"; mock?: { @@ -296,6 +367,23 @@ type E2eConfig = { */ observerArchiveDefaultEnabledError?: string; agentMetricArchiveDefaultEnabled?: boolean; + /** + * Response for `get_agent_usage_series` (NIP-AM local agent usage, + * `desktop/src/features/agent-usage`). Mirrors + * `desktop/src/shared/api/tauriArchive.ts`'s `AgentUsageSeries` wire + * shape field-for-field so specs can seed exact fixtures without a real + * SQLite archive. Omitted → an empty, collection-enabled series (no + * agents, no coverage, `hasArchivedEvidence: null`), which renders the + * "no locally archived usage" empty state. + */ + agentUsageSeries?: RawAgentUsageSeries; + /** Sequenced `get_agent_usage_series` failures, call-count indexed + * (mirrors `addChannelMembersErrors`): a string rejects that call; + * `null` succeeds. When exhausted, the last entry repeats. Drives the + * retry error state without deleting mock config mid-test. */ + agentUsageErrors?: (string | null)[]; + /** Delay (ms) before `get_agent_usage_series` resolves; drives the loading-skeleton state. */ + agentUsageDelayMs?: number; saveSubscriptions?: Array<{ scope_type: string; scope_value: string; @@ -6853,6 +6941,7 @@ async function handleConnectAcpRuntime( // re-evaluated via addInitScript, so the counter starts at 0 for every test. let installCallCount = 0; let addChannelMembersCallCount = 0; +let agentUsageSeriesCallCount = 0; let mockGlobalAgentConfig: { env_vars: Record; provider: string | null; @@ -10489,6 +10578,35 @@ export function maybeInstallE2eTauriMocks() { } return activeConfig?.mock?.observerArchiveDefaultEnabled ?? false; } + case "get_agent_usage_series": { + // `AgentUsageSeriesRequest` is validated Rust-side; the mock trusts + // the seeded fixture as-is and ignores `bucketBoundaries`/`agentPubkey` + // filtering — specs seed the exact series they want per window/agent. + const configuredErrors = activeConfig?.mock?.agentUsageErrors; + if (configuredErrors && configuredErrors.length > 0) { + const index = Math.min( + agentUsageSeriesCallCount, + configuredErrors.length - 1, + ); + agentUsageSeriesCallCount += 1; + const error = configuredErrors[index]; + if (error) { + throw new Error(error); + } + } else { + agentUsageSeriesCallCount += 1; + } + const usageDelayMs = activeConfig?.mock?.agentUsageDelayMs ?? 0; + if (usageDelayMs > 0) { + await new Promise((resolve) => + window.setTimeout(resolve, usageDelayMs), + ); + } + return ( + activeConfig?.mock?.agentUsageSeries ?? + DEFAULT_MOCK_AGENT_USAGE_SERIES + ); + } case "agent_metric_archive_default_enabled": return activeConfig?.mock?.agentMetricArchiveDefaultEnabled ?? false; case "set_prevent_sleep_active": diff --git a/desktop/tests/e2e/agent-usage.spec.ts b/desktop/tests/e2e/agent-usage.spec.ts new file mode 100644 index 0000000000..1e6490a49f --- /dev/null +++ b/desktop/tests/e2e/agent-usage.spec.ts @@ -0,0 +1,947 @@ +import { expect, test, type Page } from "@playwright/test"; + +import { + installMockBridge, + TEST_IDENTITIES, + type MockAgentUsage, + type MockAgentUsageSeries, +} from "../helpers/bridge"; + +/** + * A non-owned, non-managed agent (declared owner is `outsider`, not the mock + * viewer) — used by the A13 fail-closed tests where eligibility must come + * from archived evidence alone, not ownership. + */ +const HISTORICAL_AGENT_PUBKEY = "6".repeat(64); + +function getHashSearchParam(page: Page, name: string) { + const hash = new URL(page.url()).hash.replace(/^#/, ""); + const queryStart = hash.indexOf("?"); + if (queryStart === -1) { + return null; + } + return new URLSearchParams(hash.slice(queryStart + 1)).get(name); +} + +async function expectHashSearchParam( + page: Page, + name: string, + value: string | null, +) { + await expect.poll(() => getHashSearchParam(page, name)).toBe(value); +} + +function usageField(value: string | null, incomplete = false) { + return { value, incomplete }; +} + +function costField(value: number | null, incomplete = false) { + return { value, incomplete }; +} + +function reportedUsage( + overrides: Partial<{ + inputTokens: string | null; + outputTokens: string | null; + totalTokens: string | null; + estimatedCostUsd: number | null; + }> = {}, +) { + return { + estimatedCostUsd: costField(overrides.estimatedCostUsd ?? null), + inputTokens: usageField(overrides.inputTokens ?? null), + outputTokens: usageField(overrides.outputTokens ?? null), + totalTokens: usageField(overrides.totalTokens ?? null), + }; +} + +function mockAgentUsage( + agentPubkey: string, + overrides: Partial = {}, +): MockAgentUsage { + return { + agentPubkey, + buckets: [], + hasUnknownUsage: false, + models: [], + reportCount: 1, + usage: reportedUsage({ totalTokens: "1500" }), + ...overrides, + }; +} + +function mockUsageSeries( + overrides: Partial = {}, +): MockAgentUsageSeries { + return { + agents: [], + buckets: [], + collectionEnabled: true, + coverage: { + firstArchivedAt: null, + firstReportedAt: null, + hasUnknownUsage: false, + invalidReportCount: 0, + lastArchivedAt: null, + lastReportedAt: null, + reportCount: 0, + }, + hasArchivedEvidence: null, + ...overrides, + }; +} + +async function addGenericAgent( + page: Page, + channelName: string, + agentName: string, +): Promise { + await page.getByTestId(`channel-${channelName}`).click(); + await expect(page.getByTestId("chat-title")).toHaveText(channelName); + const channelId = await page + .getByTestId(`channel-${channelName}`) + .getAttribute("data-channel-id"); + if (!channelId) { + throw new Error(`Channel ${channelName} is missing a data-channel-id.`); + } + + await page.waitForFunction(() => { + return Boolean( + (window as Window & { __BUZZ_E2E_INVOKE_MOCK_COMMAND__?: unknown }) + .__BUZZ_E2E_INVOKE_MOCK_COMMAND__, + ); + }); + return page.evaluate( + async ({ agentName, channelId }): Promise => { + const invoke = ( + window as Window & { + __BUZZ_E2E_INVOKE_MOCK_COMMAND__?: ( + command: string, + payload?: Record, + ) => Promise<{ agent?: { pubkey: string } }>; + } + ).__BUZZ_E2E_INVOKE_MOCK_COMMAND__; + if (!invoke) { + throw new Error("Mock bridge is not installed."); + } + + const created = (await invoke("create_managed_agent", { + input: { + name: agentName, + spawnAfterCreate: true, + systemPrompt: "Watch the channel and help when asked.", + }, + })) as { agent?: { pubkey: string } }; + const pubkey = created.agent?.pubkey; + if (!pubkey) { + throw new Error("Mock managed agent creation did not return a pubkey."); + } + + await invoke("add_channel_members", { + channelId, + pubkeys: [pubkey], + role: "bot", + }); + + await ( + window as Window & { + __BUZZ_E2E_QUERY_CLIENT__?: { + invalidateQueries: () => Promise; + }; + } + ).__BUZZ_E2E_QUERY_CLIENT__?.invalidateQueries(); + + return pubkey; + }, + { agentName, channelId }, + ); +} + +async function openAgentsView(page: Page) { + await page.goto("/", { waitUntil: "domcontentloaded" }); + await page.getByTestId("open-agents-view").click(); + await expect(page.getByTestId("agents-usage-section")).toBeVisible({ + timeout: 10_000, + }); +} + +test("shows a loading skeleton while the usage series is in flight", async ({ + page, +}) => { + await installMockBridge(page, { + agentUsageSeries: mockUsageSeries(), + agentUsageDelayMs: 2_000, + }); + + await openAgentsView(page); + + await expect(page.getByTestId("agent-usage-skeleton")).toBeVisible(); + await expect(page.getByTestId("agent-usage-card")).toBeVisible({ + timeout: 5_000, + }); + await expect(page.getByTestId("agent-usage-skeleton")).toHaveCount(0); +}); + +test("renders ranked agent rows and switches between the 7d and 30d windows", async ({ + page, +}) => { + await installMockBridge(page); + await openAgentsView(page); + + const agentPubkey = await addGenericAgent(page, "general", "Token Bot"); + + await page.evaluate( + (series) => { + const testWindow = window as Window & { + __BUZZ_E2E__?: { mock?: { agentUsageSeries?: unknown } }; + }; + testWindow.__BUZZ_E2E__ ??= {}; + testWindow.__BUZZ_E2E__.mock ??= {}; + testWindow.__BUZZ_E2E__.mock.agentUsageSeries = series; + }, + mockUsageSeries({ + agents: [ + mockAgentUsage(agentPubkey, { + usage: reportedUsage({ + inputTokens: "1200", + outputTokens: "300", + totalTokens: "1500", + }), + }), + ], + coverage: { + firstArchivedAt: 1_700_000_000, + firstReportedAt: 1_700_000_000, + hasUnknownUsage: false, + invalidReportCount: 0, + lastArchivedAt: 1_700_086_400, + lastReportedAt: 1_700_086_400, + reportCount: 1, + }, + }), + ); + await page.getByTestId("open-agents-view").click(); + await expect(page.getByTestId("agents-usage-section")).toBeVisible(); + + const row = page.getByTestId(`agent-usage-row-${agentPubkey}`); + await expect(row).toBeVisible(); + await expect(row).toContainText("1.5K"); + await expect(row).toContainText("Token Bot"); + + await page.getByTestId("agent-usage-window-30").click(); + await expect(page.getByTestId("agent-usage-window-30")).toHaveAttribute( + "data-state", + "active", + ); +}); + +test("clicking an agent row opens the profile panel's Usage focused view", async ({ + page, +}) => { + await installMockBridge(page); + await openAgentsView(page); + + const agentPubkey = await addGenericAgent(page, "general", "Drilldown Bot"); + await page.evaluate( + ({ series }) => { + const testWindow = window as Window & { + __BUZZ_E2E__?: { mock?: { agentUsageSeries?: unknown } }; + }; + testWindow.__BUZZ_E2E__ ??= {}; + testWindow.__BUZZ_E2E__.mock ??= {}; + testWindow.__BUZZ_E2E__.mock.agentUsageSeries = series; + }, + { + series: mockUsageSeries({ + agents: [ + mockAgentUsage(agentPubkey, { + models: [ + { + hasUnknownUsage: false, + model: "claude-opus", + reportCount: 1, + usage: reportedUsage({ totalTokens: "1500" }), + }, + ], + usage: reportedUsage({ + estimatedCostUsd: 0.42, + inputTokens: "1200", + outputTokens: "300", + totalTokens: "1500", + }), + }), + ], + }), + }, + ); + await page.getByTestId("open-agents-view").click(); + await expect( + page.getByTestId(`agent-usage-row-${agentPubkey}`), + ).toBeVisible(); + + await page.getByTestId(`agent-usage-row-${agentPubkey}`).click(); + await expect(page.getByTestId("user-profile-panel")).toBeVisible(); + await expect(page.getByTestId("agent-usage-focused-view")).toBeVisible(); + await expect(page.getByTestId("agent-usage-focused-totals")).toContainText( + "1,500", + ); + await expect(page.getByTestId("agent-usage-focused-models")).toContainText( + "claude-opus", + ); + + // Reaching the same view via the Info-tab ingress row lands on the same + // subview (covers the ProfileIngressRow entry point, not just the + // row-click shortcut). + await page.getByTestId("user-profile-panel-back").click(); + await expect(page.getByTestId("user-profile-tab-info")).toBeVisible(); + await page.getByTestId(`user-profile-view-usage-${agentPubkey}`).click(); + await expect(page.getByTestId("agent-usage-focused-view")).toBeVisible(); +}); + +// A non-owned, non-managed agent seeded purely via `searchProfiles` (no +// managed-agent creation) — used by the ingress-visibility test's +// non-owner leg, which must be hidden regardless of archived evidence. +const NON_OWNER_AGENT_PUBKEY = "7".repeat(64); + +test("Info-tab Usage ingress is visible for an owner-viewed agent, and absent for a human or a non-owner agent", async ({ + page, +}) => { + await installMockBridge(page, { + agentUsageSeries: mockUsageSeries(), + searchProfiles: [ + { + pubkey: NON_OWNER_AGENT_PUBKEY, + displayName: "Someone Else's Bot", + isAgent: true, + ownerPubkey: TEST_IDENTITIES.outsider.pubkey, + }, + ], + }); + + // Owner case: a locally-managed agent is owned by the mock viewer by + // construction, so `canViewUsage` (viewerIsOwner && isBot) is true. + await openAgentsView(page); + const ownedAgentPubkey = await addGenericAgent(page, "general", "Own Bot"); + await page.evaluate( + (series) => { + const testWindow = window as Window & { + __BUZZ_E2E__?: { mock?: { agentUsageSeries?: unknown } }; + }; + testWindow.__BUZZ_E2E__ ??= {}; + testWindow.__BUZZ_E2E__.mock ??= {}; + testWindow.__BUZZ_E2E__.mock.agentUsageSeries = series; + }, + mockUsageSeries({ agents: [mockAgentUsage(ownedAgentPubkey)] }), + ); + await page.getByTestId("open-agents-view").click(); + await page.getByTestId(`agent-usage-row-${ownedAgentPubkey}`).click(); + await expect(page.getByTestId("user-profile-panel")).toBeVisible(); + await page.getByTestId("user-profile-panel-back").click(); + await expect(page.getByTestId("user-profile-tab-info")).toBeVisible(); + await expect( + page.getByTestId(`user-profile-view-usage-${ownedAgentPubkey}`), + ).toBeVisible(); + + // Human case: `canViewUsage` requires `isBot`, so a plain human profile + // never renders the row, regardless of ownership. + await page.evaluate((pubkey) => { + ( + window as Window & { + __TSR_ROUTER__?: { navigate: (opts: Record) => void }; + } + ).__TSR_ROUTER__?.navigate({ + to: "/agents", + search: { profile: pubkey }, + }); + }, TEST_IDENTITIES.bob.pubkey); + await expect(page.getByTestId("user-profile-panel")).toBeVisible(); + await expect( + page.getByTestId(`user-profile-view-usage-${TEST_IDENTITIES.bob.pubkey}`), + ).toHaveCount(0); + + // Non-owner agent case: `isBot` is true but `viewerIsOwner` is false (the + // declared owner is `outsider`, not the mock viewer) — the row stays + // hidden even though the agent is eligible for the focused view via + // archived evidence (A13 fail-closed is a separate axis from ingress + // visibility). + await page.evaluate((pubkey) => { + ( + window as Window & { + __TSR_ROUTER__?: { navigate: (opts: Record) => void }; + } + ).__TSR_ROUTER__?.navigate({ + to: "/agents", + search: { profile: pubkey }, + }); + }, NON_OWNER_AGENT_PUBKEY); + await expect(page.getByTestId("user-profile-panel")).toBeVisible(); + await expect( + page.getByTestId(`user-profile-view-usage-${NON_OWNER_AGENT_PUBKEY}`), + ).toHaveCount(0); +}); + +test("the usage card fits a compact viewport without horizontal overflow, and its controls stay keyboard-focusable", async ({ + page, +}) => { + await installMockBridge(page); + await openAgentsView(page); + + const agentPubkey = await addGenericAgent(page, "general", "Compact Bot"); + await page.evaluate( + (series) => { + const testWindow = window as Window & { + __BUZZ_E2E__?: { mock?: { agentUsageSeries?: unknown } }; + }; + testWindow.__BUZZ_E2E__ ??= {}; + testWindow.__BUZZ_E2E__.mock ??= {}; + testWindow.__BUZZ_E2E__.mock.agentUsageSeries = series; + }, + mockUsageSeries({ + agents: [mockAgentUsage(agentPubkey)], + buckets: Array.from({ length: 8 }, (_, i) => ({ + start: 1_700_000_000 + i * 86_400, + end: 1_700_000_000 + (i + 1) * 86_400, + usage: reportedUsage({ totalTokens: String((i + 1) * 100) }), + reportCount: 1, + hasUnknownUsage: false, + })), + }), + ); + await page.getByTestId("open-agents-view").click(); + await expect(page.getByTestId("agent-usage-card")).toBeVisible(); + + await page.setViewportSize({ width: 520, height: 900 }); + await expect(page.getByTestId("agent-usage-card")).toBeVisible(); + + const overflow = await page.evaluate( + () => document.documentElement.scrollWidth > window.innerWidth, + ); + expect(overflow).toBe(false); + + // Window-selector tabs and the agent row remain keyboard-focusable at + // this width — Tab forward from the 7d control must reach 30d, and the + // row must remain reachable and clickable via focus + Enter. + const window7 = page.getByTestId("agent-usage-window-7"); + const window30 = page.getByTestId("agent-usage-window-30"); + await window7.focus(); + await expect(window7).toBeFocused(); + // Radix's `Tabs` uses a roving tabindex: only the active tab is in the + // Tab order, and arrow keys move focus (and selection) between tabs. + await page.keyboard.press("ArrowRight"); + await expect(window30).toBeFocused(); + + const row = page.getByTestId(`agent-usage-row-${agentPubkey}`); + await row.focus(); + await expect(row).toBeFocused(); + await page.keyboard.press("Enter"); + await expect(page.getByTestId("agent-usage-focused-view")).toBeVisible(); +}); + +test("surfaces a retry affordance when the usage query fails, and recovers on retry", async ({ + page, +}) => { + // React Query's global `retry: 1` (queryClient.ts) auto-retries once + // before the UI ever sees an error, silently consuming one entry of the + // sequence — so two failures are needed before the UI's own error state + // (and its Retry button) appears; the third entry is the button click. + await installMockBridge(page, { + agentUsageErrors: ["archive unavailable", "archive unavailable", null], + }); + + await openAgentsView(page); + + const error = page.getByTestId("agent-usage-error"); + await expect(error).toBeVisible(); + await expect(error).toContainText("Couldn't load usage data."); + + await error.getByRole("button", { name: "Retry" }).click(); + + await expect(page.getByTestId("agent-usage-card")).toBeVisible(); + await expect(page.getByTestId("agent-usage-error")).toHaveCount(0); +}); + +test("shows the empty state when collection is on but nothing has been archived yet", async ({ + page, +}) => { + await installMockBridge(page, { + agentUsageSeries: mockUsageSeries(), + }); + + await openAgentsView(page); + + const empty = page.getByTestId("agent-usage-empty"); + await expect(empty).toBeVisible(); + await expect(empty).toContainText("No locally archived usage"); + await expect(page.getByTestId("agent-usage-collection-off")).toHaveCount(0); +}); + +test("shows the collection-off banner with a settings deep link, with and without retained data", async ({ + page, +}) => { + await installMockBridge(page, { + agentUsageSeries: mockUsageSeries({ collectionEnabled: false }), + }); + + await openAgentsView(page); + + const banner = page.getByTestId("agent-usage-collection-off"); + await expect(banner).toBeVisible(); + await expect(banner).toContainText("Local usage collection is off."); + await expect(page.getByTestId("agent-usage-empty")).toContainText( + "Turn on collection", + ); + + await banner + .getByRole("button", { name: "Open Local Archive settings" }) + .click(); + await expect(page.getByTestId("settings-view")).toBeVisible(); + await expect(page.getByTestId("settings-local-archive")).toBeVisible({ + timeout: 10_000, + }); + + await page.goBack(); + await expect(page.getByTestId("agents-usage-section")).toBeVisible(); +}); + +test("shows retained-data coverage copy when collection is off but usage was previously archived", async ({ + page, +}) => { + await installMockBridge(page, { + agentUsageSeries: mockUsageSeries({ + collectionEnabled: false, + coverage: { + firstArchivedAt: 1_700_000_000, + firstReportedAt: 1_700_000_000, + hasUnknownUsage: false, + invalidReportCount: 0, + lastArchivedAt: 1_700_086_400, + lastReportedAt: 1_700_086_400, + reportCount: 3, + }, + }), + }); + + await openAgentsView(page); + + await expect(page.getByTestId("agent-usage-collection-off")).toContainText( + "Collection off · data through", + ); +}); + +// A13 fail-closed contract (Rev 3): the focused view's eligibility is +// ownership OR archived evidence, decided only once the author-filtered +// query resolves. Both tests below deep-link straight to +// `?profileView=usage` for a non-owned, non-managed agent (declared owner is +// `outsider`, not the mock viewer) so neither test depends on ownership. +async function openUsageViewForHistoricalAgent( + page: Page, + agentUsageSeries: MockAgentUsageSeries, +) { + await installMockBridge(page, { + agentUsageSeries, + searchProfiles: [ + { + pubkey: HISTORICAL_AGENT_PUBKEY, + displayName: "Historical Bot", + isAgent: true, + ownerPubkey: TEST_IDENTITIES.outsider.pubkey, + }, + ], + }); + await page.goto("/#/agents", { waitUntil: "domcontentloaded" }); + await expect(page.getByTestId("agents-usage-section")).toBeVisible({ + timeout: 10_000, + }); + await page.evaluate((pubkey) => { + ( + window as Window & { + __TSR_ROUTER__?: { + navigate: (opts: Record) => void; + }; + } + ).__TSR_ROUTER__?.navigate({ + to: "/agents", + search: { profile: pubkey, profileView: "usage" }, + }); + }, HISTORICAL_AGENT_PUBKEY); + await expect(page.getByTestId("user-profile-panel")).toBeVisible({ + timeout: 10_000, + }); +} + +test("a historical author with 30d-only archived evidence gets a valid empty 7d focused view, not a redirect", async ({ + page, +}) => { + await openUsageViewForHistoricalAgent( + page, + mockUsageSeries({ agents: [], hasArchivedEvidence: true }), + ); + + const outsideWindow = page.getByTestId("agent-usage-focused-outside-window"); + await expect(outsideWindow).toBeVisible(); + await expect(outsideWindow).toContainText("Try the 30-day window."); + + // Eligible via archived evidence alone (no ownership) — never bounced. + await expectHashSearchParam(page, "profileView", "usage"); + const panel = page.getByTestId("user-profile-panel"); + await expect( + panel.getByRole("heading", { level: 2, name: "Usage" }), + ).toBeVisible(); +}); + +test("a hand-authored usage URL with no ownership and no archived evidence falls back to summary", async ({ + page, +}) => { + await openUsageViewForHistoricalAgent( + page, + mockUsageSeries({ agents: [], hasArchivedEvidence: null }), + ); + + // The redirect from "usage" → null may fire before the first poll + // observes profileView=usage — skip that transient assertion; the null + // landing + summary heading carry the contract. + await expectHashSearchParam(page, "profileView", null); + await expect(page.getByTestId("agent-usage-focused-view")).toHaveCount(0); + const panel = page.getByTestId("user-profile-panel"); + await expect( + panel.getByRole("heading", { level: 2, name: "Profile" }), + ).toBeVisible(); +}); + +test("renders a Partial badge for an agent whose total is a known lower bound", async ({ + page, +}) => { + await installMockBridge(page); + await openAgentsView(page); + + const agentPubkey = await addGenericAgent(page, "general", "Partial Bot"); + await page.evaluate( + ({ series }) => { + const testWindow = window as Window & { + __BUZZ_E2E__?: { mock?: { agentUsageSeries?: unknown } }; + }; + testWindow.__BUZZ_E2E__ ??= {}; + testWindow.__BUZZ_E2E__.mock ??= {}; + testWindow.__BUZZ_E2E__.mock.agentUsageSeries = series; + }, + { + series: mockUsageSeries({ + agents: [ + mockAgentUsage(agentPubkey, { + hasUnknownUsage: true, + usage: { + estimatedCostUsd: costField(null), + inputTokens: usageField(null), + outputTokens: usageField(null), + totalTokens: usageField("900", true), + }, + }), + ], + }), + }, + ); + await page.getByTestId("open-agents-view").click(); + + const row = page.getByTestId(`agent-usage-row-${agentPubkey}`); + await expect(row).toBeVisible(); + await expect(row.getByText("Partial", { exact: true })).toBeVisible(); +}); + +// ── F4: daily bars behavioral coverage ─────────────────────────────────────── + +test("overview card renders daily bars with correct accessible labels distinguishing known, unknown, and empty days", async ({ + page, +}) => { + await installMockBridge(page); + await openAgentsView(page); + + const agentPubkey = await addGenericAgent(page, "general", "Bar Bot"); + + // Three top-level buckets: one known (100 tokens), one unknown (reports but + // null total), one empty (zero reports). The daily bar component must never + // encode "unknown" as "zero" — confirmed via aria-label inspection. + const knownStart = 1_700_000_000; + const unknownStart = knownStart + 86_400; + const emptyStart = unknownStart + 86_400; + + await page.evaluate( + ({ series }) => { + const testWindow = window as Window & { + __BUZZ_E2E__?: { mock?: { agentUsageSeries?: unknown } }; + }; + testWindow.__BUZZ_E2E__ ??= {}; + testWindow.__BUZZ_E2E__.mock ??= {}; + testWindow.__BUZZ_E2E__.mock.agentUsageSeries = series; + }, + { + series: mockUsageSeries({ + agents: [mockAgentUsage(agentPubkey, { buckets: [] })], + buckets: [ + { + start: knownStart, + end: knownStart + 86_400, + usage: reportedUsage({ totalTokens: "100" }), + reportCount: 1, + hasUnknownUsage: false, + }, + { + start: unknownStart, + end: unknownStart + 86_400, + // reportCount > 0 but totalTokens.value null → unknown, not zero + usage: reportedUsage({ totalTokens: null }), + reportCount: 1, + hasUnknownUsage: true, + }, + { + start: emptyStart, + end: emptyStart + 86_400, + usage: reportedUsage({ totalTokens: null }), + reportCount: 0, + hasUnknownUsage: false, + }, + ], + }), + }, + ); + + await page.getByTestId("open-agents-view").click(); + await expect(page.getByTestId("agent-usage-overall-bars")).toBeVisible(); + + const knownBar = page.getByTestId(`agent-usage-daily-bar-${knownStart}`); + const unknownBar = page.getByTestId(`agent-usage-daily-bar-${unknownStart}`); + const emptyBar = page.getByTestId(`agent-usage-daily-bar-${emptyStart}`); + + await expect(knownBar).toBeVisible(); + await expect(unknownBar).toBeVisible(); + await expect(emptyBar).toBeVisible(); + + // Unknown day must have a distinct label — NOT "no usage reported" and NOT + // a token count. Known and empty must each carry their own label too. + const knownLabel = await knownBar + .locator("[aria-label]") + .first() + .getAttribute("aria-label"); + const unknownLabel = await unknownBar + .locator("[aria-label]") + .first() + .getAttribute("aria-label"); + const emptyLabel = await emptyBar + .locator("[aria-label]") + .first() + .getAttribute("aria-label"); + + expect(knownLabel).toMatch(/reported tokens/i); + expect(unknownLabel).toMatch(/unknown usage/i); + expect(emptyLabel).toMatch(/no usage reported/i); + + // Verify the three labels are all distinct — unknown must not collapse to zero. + expect(unknownLabel).not.toBe(emptyLabel); + expect(unknownLabel).not.toBe(knownLabel); +}); + +test("focused view shows daily bars, coverage dates, and a partial explanation when usage is incomplete", async ({ + page, +}) => { + await installMockBridge(page); + await openAgentsView(page); + + const agentPubkey = await addGenericAgent(page, "general", "Coverage Bot"); + + const bucketStart = 1_700_000_000; + await page.evaluate( + ({ series }) => { + const testWindow = window as Window & { + __BUZZ_E2E__?: { mock?: { agentUsageSeries?: unknown } }; + }; + testWindow.__BUZZ_E2E__ ??= {}; + testWindow.__BUZZ_E2E__.mock ??= {}; + testWindow.__BUZZ_E2E__.mock.agentUsageSeries = series; + }, + { + series: mockUsageSeries({ + agents: [ + mockAgentUsage(agentPubkey, { + hasUnknownUsage: true, + reportCount: 3, + buckets: [ + { + start: bucketStart, + end: bucketStart + 86_400, + // incomplete=true → triggers partial explanation + usage: reportedUsage({ totalTokens: "500" }), + reportCount: 2, + hasUnknownUsage: true, + }, + ], + usage: reportedUsage({ totalTokens: "500" }), + }), + ], + coverage: { + firstArchivedAt: 1_700_000_000, + firstReportedAt: 1_700_000_000, + hasUnknownUsage: true, + invalidReportCount: 1, + lastArchivedAt: 1_700_086_400, + lastReportedAt: 1_700_086_400, + reportCount: 3, + }, + }), + }, + ); + + // Navigate to the focused view via the row click shortcut. + await page.getByTestId("open-agents-view").click(); + await expect( + page.getByTestId(`agent-usage-row-${agentPubkey}`), + ).toBeVisible(); + await page.getByTestId(`agent-usage-row-${agentPubkey}`).click(); + await expect(page.getByTestId("agent-usage-focused-view")).toBeVisible(); + + // Daily bars section must appear in the focused view. + await expect( + page.getByTestId("agent-usage-focused-daily-bars"), + ).toBeVisible(); + + // Coverage section must contain the report count. + const coverage = page.getByTestId("agent-usage-focused-coverage"); + await expect(coverage).toBeVisible(); + await expect(coverage).toContainText("reported turn"); + + // The partial explanation must appear when usage is known-incomplete. + // (explainPartial = hasUnknownUsage || invalidReportCount > 0 — both true here.) + await expect( + page.getByTestId("agent-usage-focused-partial-explanation"), + ).toBeVisible(); +}); + +// ── T1: invalid-only window behavioral coverage ─────────────────────────────── + +test("overview and focused view distinguish invalid-only windows from ordinary empty windows", async ({ + page, +}) => { + // An invalid-only window: invalidReportCount > 0, zero valid agents/buckets. + // The overview must NOT say "No locally archived usage in the last N days" + // and the focused view must NOT show "Try the 30-day window" — both would + // mislabel in-window-but-uncountable evidence as absent. + await installMockBridge(page); + await openAgentsView(page); + + const agentPubkey = await addGenericAgent(page, "general", "Invalid Bot"); + + await page.evaluate( + ({ series }) => { + const testWindow = window as Window & { + __BUZZ_E2E__?: { mock?: { agentUsageSeries?: unknown } }; + }; + testWindow.__BUZZ_E2E__ ??= {}; + testWindow.__BUZZ_E2E__.mock ??= {}; + testWindow.__BUZZ_E2E__.mock.agentUsageSeries = series; + }, + { + series: mockUsageSeries({ + agents: [], // no valid rows + buckets: [], // invalid rows never bucketed + coverage: { + firstArchivedAt: 1_700_000_000, + firstReportedAt: null, + hasUnknownUsage: true, + invalidReportCount: 2, // the signal + lastArchivedAt: 1_700_086_400, + lastReportedAt: null, + reportCount: 0, + }, + hasArchivedEvidence: true, // A13 returns true for invalid rows too + }), + }, + ); + + await page.getByTestId("open-agents-view").click(); + await expect(page.getByTestId("agent-usage-card")).toBeVisible(); + + // Overview empty-state must reflect uncountable usage, not ordinary empty. + const empty = page.getByTestId("agent-usage-empty"); + await expect(empty).toBeVisible(); + await expect(empty).not.toContainText("No locally archived usage"); + await expect(empty).toContainText("could not be counted"); + + // Navigate directly to the focused usage view for this agent. + await page.evaluate((pubkey) => { + ( + window as Window & { + __TSR_ROUTER__?: { navigate: (opts: Record) => void }; + } + ).__TSR_ROUTER__?.navigate({ + to: "/agents", + search: { profile: pubkey, profileView: "usage" }, + }); + }, agentPubkey); + await expect(page.getByTestId("agent-usage-focused-view")).toBeVisible({ + timeout: 10_000, + }); + + // Focused view must show the invalid-only state, NOT "Try the 30-day window". + await expect( + page.getByTestId("agent-usage-focused-invalid-only"), + ).toBeVisible(); + await expect( + page.getByTestId("agent-usage-focused-outside-window"), + ).toHaveCount(0); +}); + +// ── T2: I/O-incomplete partial behavioral coverage ──────────────────────────── + +test("overview row shows Partial badge and ingress shows partial marker when I/O fields are incomplete with null total", async ({ + page, +}) => { + await installMockBridge(page); + await openAgentsView(page); + + const agentPubkey = await addGenericAgent(page, "general", "IO Partial Bot"); + + await page.evaluate( + ({ series }) => { + const testWindow = window as Window & { + __BUZZ_E2E__?: { mock?: { agentUsageSeries?: unknown } }; + }; + testWindow.__BUZZ_E2E__ ??= {}; + testWindow.__BUZZ_E2E__.mock ??= {}; + testWindow.__BUZZ_E2E__.mock.agentUsageSeries = series; + }, + { + series: mockUsageSeries({ + agents: [ + mockAgentUsage(agentPubkey, { + usage: { + // null total, incomplete I/O — per-field Partial must surface + estimatedCostUsd: costField(null), + inputTokens: usageField("800", true), // incomplete + outputTokens: usageField("200", false), + totalTokens: usageField(null), + }, + }), + ], + }), + }, + ); + + await page.getByTestId("open-agents-view").click(); + const row = page.getByTestId(`agent-usage-row-${agentPubkey}`); + await expect(row).toBeVisible(); + + // Row must show Partial badge — not just the I/O text. + await expect(row.getByText("Partial", { exact: true })).toBeVisible(); + // Row must show the I/O breakdown text. + await expect(row).toContainText("in 800"); + + // Open the profile panel to reach the Info tab for ingress verification. + await row.click(); + await expect(page.getByTestId("user-profile-panel")).toBeVisible(); + await page.getByTestId("user-profile-panel-back").click(); + await expect(page.getByTestId("user-profile-tab-info")).toBeVisible(); + + // The ingress trailing for an I/O-only-with-partial series must contain + // "Partial" (i.e., "Input/output reported · Partial"). + const ingressRow = page.getByTestId(`user-profile-view-usage-${agentPubkey}`); + await expect(ingressRow).toBeVisible(); + await expect(ingressRow).toContainText("Partial"); +}); diff --git a/desktop/tests/helpers/bridge.ts b/desktop/tests/helpers/bridge.ts index 25a93820f8..c3c797fcc8 100644 --- a/desktop/tests/helpers/bridge.ts +++ b/desktop/tests/helpers/bridge.ts @@ -126,6 +126,61 @@ export type MockAgentMemoryListing = { fetchedAt: number; }; +// ── Agent usage (NIP-AM) mock wire shapes ──────────────────────────────────── +// Mirrors `desktop/src/shared/api/tauriArchive.ts`'s camelCase types +// field-for-field, kept local (no cross-file type import) to match this +// file's existing Mock* type convention. + +export type MockUsageField = { value: string | null; incomplete: boolean }; +export type MockCostField = { value: number | null; incomplete: boolean }; + +export type MockReportedUsage = { + inputTokens: MockUsageField; + outputTokens: MockUsageField; + totalTokens: MockUsageField; + estimatedCostUsd: MockCostField; +}; + +export type MockAgentUsageSeriesBucket = { + start: number; + end: number; + usage: MockReportedUsage; + reportCount: number; + hasUnknownUsage: boolean; +}; + +export type MockAgentUsageModel = { + model: string | null; + usage: MockReportedUsage; + reportCount: number; + hasUnknownUsage: boolean; +}; + +export type MockAgentUsage = { + agentPubkey: string; + usage: MockReportedUsage; + buckets: MockAgentUsageSeriesBucket[]; + models: MockAgentUsageModel[]; + reportCount: number; + hasUnknownUsage: boolean; +}; + +export type MockAgentUsageSeries = { + collectionEnabled: boolean; + buckets: MockAgentUsageSeriesBucket[]; + agents: MockAgentUsage[]; + coverage: { + firstArchivedAt: number | null; + lastArchivedAt: number | null; + firstReportedAt: number | null; + lastReportedAt: number | null; + reportCount: number; + invalidReportCount: number; + hasUnknownUsage: boolean; + }; + hasArchivedEvidence: boolean | null; +}; + type MockBridgeOptions = { /** Builderlab account returned by hosted-community onboarding. Null/omitted = signed out. */ builderlabAuth?: { email?: string; name?: string; expiresAt: string } | null; @@ -258,6 +313,20 @@ type MockBridgeOptions = { * drives the fail-closed path when the policy check itself fails. */ observerArchiveDefaultEnabledError?: string; + /** + * Response for the mocked `get_agent_usage_series` command (NIP-AM local + * agent usage). Mirrors `desktop/src/shared/api/tauriArchive.ts`'s + * `AgentUsageSeries` wire shape field-for-field; see e2eBridge mock config + * for the same shape and default (empty, collection-enabled series). + */ + agentUsageSeries?: MockAgentUsageSeries; + /** Sequenced `get_agent_usage_series` failures, call-count indexed + * (mirrors `addChannelMembersErrors`): a string rejects that call; `null` + * succeeds. When exhausted, the last entry repeats. Drives the retry + * error state without deleting mock config mid-test. */ + agentUsageErrors?: (string | null)[]; + /** Delay (ms) before `get_agent_usage_series` resolves; drives the loading-skeleton state. */ + agentUsageDelayMs?: number; // NIP-IA gate inputs — drive the archive-button gate matrix in // tests/e2e/identity-archive.spec.ts. /**