From 83d71ac37d3eff7e4dbca2a03c38fed31f10711f Mon Sep 17 00:00:00 2001 From: WuShang-d <2915293869@qq.com> Date: Sat, 24 Oct 2026 13:27:33 +0800 Subject: [PATCH 1/4] feat(desktop): integrate exact-Scope statistics through native IPC --- desktop/VALIDATION.md | 32 + desktop/scripts/generate.mjs | 1 + desktop/src-tauri/build.rs | 2 + desktop/src-tauri/capabilities/main.json | 2 + desktop/src-tauri/examples/export_ipc.rs | 7 +- desktop/src-tauri/examples/server_probe.rs | 57 +- desktop/src-tauri/src/commands.rs | 19 + .../src/connections/compatibility.json | 20 + desktop/src-tauri/src/connections/session.rs | 41 + desktop/src-tauri/src/lib.rs | 2 + desktop/src-tauri/src/transport/api.rs | 72 +- desktop/src-tauri/src/transport/mod.rs | 5 +- .../src-tauri/src/transport/operations.json | 4 + desktop/src-tauri/src/transport/wire.rs | 871 ++++++++++++++++++ desktop/src-tauri/tests/ipc.rs | 7 +- desktop/src-tauri/tests/statistics.rs | 347 +++++++ desktop/tests/real_server.py | 41 + desktop/ui/src/generated/ipc.ts | 37 + desktop/ui/src/generated/operations.ts | 4 + desktop/ui/src/shared/ipc.ts | 6 + docs/en/development/desktop-statistics.md | 35 + docs/zh/development/desktop-statistics.md | 28 + 22 files changed, 1633 insertions(+), 7 deletions(-) create mode 100644 desktop/src-tauri/tests/statistics.rs create mode 100644 docs/en/development/desktop-statistics.md create mode 100644 docs/zh/development/desktop-statistics.md diff --git a/desktop/VALIDATION.md b/desktop/VALIDATION.md index 5ec28c8e48..06b04481fe 100644 --- a/desktop/VALIDATION.md +++ b/desktop/VALIDATION.md @@ -28,6 +28,38 @@ The real Server harness covers SQLite with anonymous loopback, static Bearer, HT Selecting a compatibility profile does not attest the remote binary identity. Existing `sqlite-1.1.1-v1` / `sqlite-63f918b7-v1` / `sqlite-ab43e3a7-v1` / `sqlite-f1089f4e-v1` / `sqlite-6e237568-v1` / `sqlite-legacy-58f7f4f6-v1` selections require explicitly choosing an available matching profile and rechecking the connection. Desktop reads memories only through Atomic Memory references, so Servers that answer only with Memory entry citations are not qualified. CLI diagnostics separately verify the registered executable path and digest, then validate the diagnostic response schema. The observed version is optional report information; a registration does not require a version. +### Current-Scope statistics qualification + +`sqlite-stats-1da99747-v1` records Server commit +`1da997476651c2850ff7141e2b7cd512d0152a90`, contract digest +`5878999ce9e239d6642e4da858074b281ed465629432c578b3584eec47cbce6c`, and wheel +`powercontext-1.4.1.dev139+g1da997476.d20261024-py3-none-any.whl` with SHA-256 +`4eda168954bb5950aa4f0bbc0d534b2b2bdb15ff43f97de7df279963c46c70a8`. +All 457 Python runtime files in that wheel match the checkout's runtime sources; +their sorted `path sha256` manifest, newline terminated, hashes to +`49fe8325db1955ee250c3a76a8efad3d87fc393dc1007a95817524f38ded41ad`. +The Desktop adapter and harness had local changes during this run; the Server +runtime, OpenAPI contract and dependency lock matched the recorded commit. + +On macOS 26.7 ARM64, Python 3.12.13 and Rust 1.95.0, the native `ConnectionManager` +and `ServerApi` passed the isolated built-wheel harness in anonymous loopback, +Bearer loopback, HTTPS with explicit CA/base path, and enforced-provider modes. +The eleven recorded operations include `get_stats`. Statistics checks exercise +the exact Scope, genuine empty inventory, `today`/`7d`/`30d` UTC periods, +persisted Memory inventory, no-model zero usage, and same-Principal permission +revocation without selection fallback. Partial provider usage is explicitly +seeded through the real Runtime recorder: two generation requests, unknown input +tokens and nine output tokens survive persistence, aggregation and native parsing. +This seeded input does not establish that an actual model provider emitted it. + +Synthetic native tests separately cover cancellation, changed Scope, independent +Memory cancellation, response bounds, and invalid response selection/period. +The harness reports are generated under `.artifacts/`; reproduce them with +`fixture_qualification.py`, the `server_probe` example and `real_server.py`. +These results qualify the observed Server protocol; they do not establish the +statistics page's installed Windows/Intel/macOS UI, IME, accessibility, signing +or release acceptance. + ### Current Server protocol qualification #### Server d9a78567 protocol qualification diff --git a/desktop/scripts/generate.mjs b/desktop/scripts/generate.mjs index 9e055311af..2268421191 100644 --- a/desktop/scripts/generate.mjs +++ b/desktop/scripts/generate.mjs @@ -38,6 +38,7 @@ const wanted = [ "remember_memory", "search_memory", "get_artifact_revision", + "get_stats", ]; const operations = {}; for (const [path, item] of Object.entries(contract.paths)) { diff --git a/desktop/src-tauri/build.rs b/desktop/src-tauri/build.rs index 7bc3a15a92..02ba137e0b 100644 --- a/desktop/src-tauri/build.rs +++ b/desktop/src-tauri/build.rs @@ -39,6 +39,8 @@ fn main() { "search_memory", "atomic_memory_entry", "cancel_memory_reads", + "get_stats", + "cancel_stats_reads", "desktop_state", "save_profile", "remove_profile", diff --git a/desktop/src-tauri/capabilities/main.json b/desktop/src-tauri/capabilities/main.json index 668f40f201..0a725ab157 100644 --- a/desktop/src-tauri/capabilities/main.json +++ b/desktop/src-tauri/capabilities/main.json @@ -12,6 +12,8 @@ "allow-search-memory", "allow-atomic-memory-entry", "allow-cancel-memory-reads", + "allow-get-stats", + "allow-cancel-stats-reads", "allow-desktop-state", "allow-save-profile", "allow-remove-profile", diff --git a/desktop/src-tauri/examples/export_ipc.rs b/desktop/src-tauri/examples/export_ipc.rs index 66623da867..a9b1771f83 100644 --- a/desktop/src-tauri/examples/export_ipc.rs +++ b/desktop/src-tauri/examples/export_ipc.rs @@ -27,8 +27,8 @@ use powercontext_desktop::{ error::SafeError, ipc::FoundationInfo, transport::{ - ApiFailure, AtomicMemoryDetail, MemorySaveResponse, MemorySearchHit, MemorySearchResponse, - wire, + ApiFailure, AtomicMemoryDetail, ExactRecallTokenStatistics, ExactScopeStatistics, + ExactScopeStatisticsItem, MemorySaveResponse, MemorySearchHit, MemorySearchResponse, wire, }, }; use ts_rs::TS; @@ -72,6 +72,9 @@ fn main() { MemorySearchHit::decl(&config), MemorySearchResponse::decl(&config), AtomicMemoryDetail::decl(&config), + ExactScopeStatistics::decl(&config), + ExactRecallTokenStatistics::decl(&config), + ExactScopeStatisticsItem::decl(&config), ] .into_iter() .chain(wire::declarations(&config)) diff --git a/desktop/src-tauri/examples/server_probe.rs b/desktop/src-tauri/examples/server_probe.rs index d807b12bce..5f5c603634 100644 --- a/desktop/src-tauri/examples/server_probe.rs +++ b/desktop/src-tauri/examples/server_probe.rs @@ -22,13 +22,17 @@ use powercontext_desktop::{ }, credentials::{NativeVault, Secret}, error::SafeError, - transport::{Endpoint, ServerApi, wire::ArtifactReference}, + transport::{ + Endpoint, ServerApi, + wire::{ArtifactReference, StatsPeriod}, + }, }; use serde::Deserialize; #[derive(Deserialize)] struct Fixture { compatibility_profile: CompatibilityProfile, response_loss_path: Option, + statistics_usage_path: Option, identity_change_path: Option, endpoint: String, scope_id: String, @@ -108,6 +112,14 @@ async fn main() { fixture.scope_id ); let keyword = format!("desktop{}", uuid::Uuid::new_v4().simple()); + // No model is configured: a fresh Scope must expose real empty inventory + // and the Server's zero-filled usage, through the reviewed stats contract. + let empty_stats = api + .stats(&fixture.scope_id, StatsPeriod::Today) + .await + .unwrap(); + assert_eq!(empty_stats.inventory.memory.entries.total, 0); + assert_eq!(empty_stats.usage.totals.generation.requests, 0); let text = format!("{keyword} 中文记录 café\nSecond line."); let saved = api.remember(&fixture.scope_id, &text).await.unwrap(); assert_eq!(saved.records.len(), 1); @@ -151,6 +163,36 @@ async fn main() { .unwrap() .generation; let keyword = format!("context{}", uuid::Uuid::new_v4().simple()); + for period in [ + StatsPeriod::Today, + StatsPeriod::Variant7d, + StatsPeriod::Variant30d, + ] { + let stats = manager.stats(generation, period).await.unwrap(); + assert_eq!(stats.scope_ids, [fixture.scope_id.clone()]); + assert_eq!(stats.inventory.memory.entries.total, 1); + assert_eq!(stats.usage.period.preset, period); + assert_eq!(stats.usage.totals.generation.requests, 0); + assert_eq!(stats.usage.totals.generation.input_tokens, Some(0)); + assert_eq!(stats.usage.totals.embedding.requests, 0); + assert_eq!(stats.recall.totals.preparations, 0); + assert_eq!( + stats.usage.daily.len(), + match period { + StatsPeriod::Today => 1, + StatsPeriod::Variant7d => 7, + StatsPeriod::Variant30d => 30, + } + ); + } + if let Some(path) = &fixture.statistics_usage_path { + std::fs::write(path, b"seed-partial-usage").unwrap(); + let partial = manager.stats(generation, StatsPeriod::Today).await.unwrap(); + assert_eq!(partial.usage.totals.generation.requests, 2); + assert_eq!(partial.usage.totals.generation.input_tokens, None); + assert_eq!(partial.usage.totals.generation.output_tokens, Some(9)); + assert_eq!(partial.usage.daily.last().unwrap().generation.requests, 2); + } let submitted = format!(" {keyword} 中文 café\nSecond line. "); let expected = format!("{keyword} 中文 café\nSecond line."); let saved = manager.remember(generation, &submitted).await.unwrap(); @@ -338,6 +380,10 @@ async fn verify_revocation( ) .unwrap(); let principal = reader.principal().await.unwrap(); + reader + .stats(&fixture.scope_id, StatsPeriod::Today) + .await + .unwrap(); assert_eq!( reader .atomic_entry(&fixture.scope_id, artifact) @@ -359,6 +405,15 @@ async fn verify_revocation( .error_for_status() .unwrap(); assert_eq!(reader.principal().await.unwrap(), principal); + assert_eq!( + reader + .stats(&fixture.scope_id, StatsPeriod::Today) + .await + .err() + .unwrap() + .code, + SafeError::Forbidden + ); assert_eq!( reader .atomic_entry(&fixture.scope_id, artifact) diff --git a/desktop/src-tauri/src/commands.rs b/desktop/src-tauri/src/commands.rs index d6e3cfe1c1..b1da736c90 100644 --- a/desktop/src-tauri/src/commands.rs +++ b/desktop/src-tauri/src/commands.rs @@ -165,6 +165,25 @@ pub async fn local_diagnostics( } } +#[tauri::command] +pub async fn get_stats( + window: tauri::WebviewWindow, + state: tauri::State<'_, HostState>, + generation: u32, + period: crate::transport::wire::StatsPeriod, +) -> Result { + manager(&window, &state)?.stats(generation, period).await +} +#[tauri::command] +pub fn cancel_stats_reads( + window: tauri::WebviewWindow, + state: tauri::State<'_, HostState>, + generation: u32, +) -> Result<(), ApiFailure> { + manager(&window, &state)? + .cancel_stats_reads(generation) + .map_err(Into::into) +} #[tauri::command] pub async fn remember_memory( window: tauri::WebviewWindow, diff --git a/desktop/src-tauri/src/connections/compatibility.json b/desktop/src-tauri/src/connections/compatibility.json index 9132847dfb..5af32baceb 100644 --- a/desktop/src-tauri/src/connections/compatibility.json +++ b/desktop/src-tauri/src/connections/compatibility.json @@ -151,5 +151,25 @@ "search_memory" ], "evidence": "desktop/VALIDATION.md#server-d9a78567-protocol-qualification" + }, + { + "id": "sqlite-stats-1da99747-v1", + "serverCommit": "1da997476651c2850ff7141e2b7cd512d0152a90", + "contractSha256": "5878999ce9e239d6642e4da858074b281ed465629432c578b3584eec47cbce6c", + "artifactSha256": "4eda168954bb5950aa4f0bbc0d534b2b2bdb15ff43f97de7df279963c46c70a8", + "operations": [ + "get_access_principal", + "get_artifact_revision", + "get_capabilities", + "get_default_scope", + "get_liveness", + "get_readiness", + "get_scope", + "get_stats", + "list_scopes", + "remember_memory", + "search_memory" + ], + "evidence": "desktop/VALIDATION.md#current-scope-statistics-qualification" } ] diff --git a/desktop/src-tauri/src/connections/session.rs b/desktop/src-tauri/src/connections/session.rs index ca97ba304b..f664746385 100644 --- a/desktop/src-tauri/src/connections/session.rs +++ b/desktop/src-tauri/src/connections/session.rs @@ -161,6 +161,7 @@ pub struct ConnectionManager { probes: tokio::sync::Semaphore, writes: tokio::sync::Semaphore, memory_reads: watch::Sender, + stats_reads: watch::Sender, } impl ConnectionManager { pub fn new(profiles: ProfileRepository) -> Self { @@ -203,6 +204,7 @@ impl ConnectionManager { probes: tokio::sync::Semaphore::new(1), writes: tokio::sync::Semaphore::new(1), memory_reads: watch::channel(0).0, + stats_reads: watch::channel(0).0, } } fn lock(&self) -> Result, SafeError> { @@ -569,6 +571,45 @@ impl ConnectionManager { pub fn cancel_memory_reads(&self, generation: u32) -> Result<(), SafeError> { self.begin_memory_read(generation).map(|_| ()) } + fn begin_stats_read(&self, generation: u32) -> Result, SafeError> { + let inner = self.lock()?; + if inner.generation != generation { + return Err(SafeError::StaleContext); + } + let next = self + .stats_reads + .borrow() + .checked_add(1) + .ok_or(SafeError::Storage)?; + self.stats_reads.send_replace(next); + Ok(self.stats_reads.subscribe()) + } + pub fn cancel_stats_reads(&self, generation: u32) -> Result<(), SafeError> { + self.begin_stats_read(generation).map(|_| ()) + } + pub async fn stats( + &self, + generation: u32, + period: crate::transport::wire::StatsPeriod, + ) -> Result { + let ( + ReadSnapshot { + api, + identity, + mut cancelled, + }, + context, + ) = self.memory_snapshot(generation, "get_stats")?; + let mut query_cancelled = self.begin_stats_read(generation)?; + Self::cancellable( + &mut cancelled, + Self::cancellable(&mut query_cancelled, async { + self.identity_unchanged(&api, &identity, generation).await?; + api.stats(&context.scope_id, period).await + }), + ) + .await + } pub async fn search_memory( &self, generation: u32, diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index 6c7726e1a3..8abec8aa84 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -46,6 +46,8 @@ pub fn run() { commands::search_memory, commands::atomic_memory_entry, commands::cancel_memory_reads, + commands::get_stats, + commands::cancel_stats_reads, commands::desktop_state, commands::save_profile, commands::remove_profile, diff --git a/desktop/src-tauri/src/transport/api.rs b/desktop/src-tauri/src/transport/api.rs index c48e684176..7933094920 100644 --- a/desktop/src-tauri/src/transport/api.rs +++ b/desktop/src-tauri/src/transport/api.rs @@ -21,7 +21,7 @@ use reqwest::{ Method, header::{AUTHORIZATION, HeaderValue}, }; -use serde::{Serialize, de::DeserializeOwned}; +use serde::{Deserialize, Serialize, de::DeserializeOwned}; use ts_rs::TS; #[derive(Clone, Debug, Serialize, TS)] @@ -71,6 +71,46 @@ pub struct AtomicMemoryDetail { pub source_refs: Vec, } +/// Desktop accepts one exact Scope, never an all-Scopes or subtree selection. +/// Use the concrete generated selection so this IPC boundary cannot widen it. +#[derive(Clone, Debug, Serialize, Deserialize, TS)] +#[serde(deny_unknown_fields)] +pub struct ExactScopeStatistics { + pub selection: ExactScopeSelection, + pub scope_ids: Vec, + pub as_of: String, + pub inventory: InventoryStatistics, + pub usage: UsageStatistics, + pub recall: ExactRecallTokenStatistics, + pub by_scope: Vec, +} + +// The public contract permits a null estimator at both aggregation levels. +#[derive(Clone, Debug, Serialize, Deserialize, TS)] +#[serde(deny_unknown_fields)] +pub struct ExactRecallTokenStatistics { + pub period: ResolvedUsagePeriod, + pub estimator: Option, + pub totals: RecallTokenValue, + pub daily: Vec, +} + +#[derive(Clone, Debug, Serialize, Deserialize, TS)] +#[serde(deny_unknown_fields)] +pub struct ExactScopeStatisticsItem { + pub scope_id: String, + pub inventory: InventoryStatistics, + pub usage: UsageStatistics, + pub recall: ExactRecallTokenStatistics, + pub recurrence: RecurrenceStatistics, +} + +#[derive(Serialize)] +struct ExactStatsRequest { + selection: ExactScopeSelection, + period: StatsPeriod, +} + pub struct ServerApi { transport: Transport, endpoint: Endpoint, @@ -311,6 +351,36 @@ impl ServerApi { self.execute("get_default_scope", None, &[], None, false) .await } + pub async fn stats( + &self, + scope: &str, + period: StatsPeriod, + ) -> Result { + validate_scope(scope)?; + let selection = + ExactScopeSelection::new(ExactScopeSelectionMode::Exact, vec![scope.into()]); + let request = ExactStatsRequest { + selection: selection.clone(), + period, + }; + let body = serde_json::to_value(request).map_err(|_| SafeError::InvalidInput)?; + let result: ExactScopeStatistics = self + .execute("get_stats", None, &[], Some(body), false) + .await?; + if result.selection != selection + || result.scope_ids != [scope] + || result.by_scope.len() != 1 + || result.by_scope[0].scope_id != scope + || result.usage.period.preset != period + || result.recall.period != result.usage.period + || result.usage.daily.len() > 30 + || result.recall.daily.len() > 30 + || result.usage.by_purpose.len() > 16 + { + return Err(SafeError::InvalidResponse.into()); + } + Ok(result) + } pub async fn remember( &self, scope: &str, diff --git a/desktop/src-tauri/src/transport/mod.rs b/desktop/src-tauri/src/transport/mod.rs index aeee5896d7..19070e9883 100644 --- a/desktop/src-tauri/src/transport/mod.rs +++ b/desktop/src-tauri/src/transport/mod.rs @@ -17,8 +17,9 @@ mod api; pub mod wire; pub use api::{ - ApiFailure, AtomicMemoryDetail, MemorySaveResponse, MemorySearchHit, MemorySearchResponse, - ServerApi, validate_text, + ApiFailure, AtomicMemoryDetail, ExactRecallTokenStatistics, ExactScopeStatistics, + ExactScopeStatisticsItem, MemorySaveResponse, MemorySearchHit, MemorySearchResponse, ServerApi, + validate_text, }; // Only native adapters use this transport; it is not an IPC fetch primitive. diff --git a/desktop/src-tauri/src/transport/operations.json b/desktop/src-tauri/src/transport/operations.json index 2b7d10dd77..6ff9dc751f 100644 --- a/desktop/src-tauri/src/transport/operations.json +++ b/desktop/src-tauri/src/transport/operations.json @@ -33,6 +33,10 @@ "method": "POST", "path": "/v1/memory/search" }, + "get_stats": { + "method": "POST", + "path": "/v1/stats" + }, "get_artifact_revision": { "method": "GET", "path": "/v1/scopes/{scope_id}/artifacts/{family}/{artifact_id}/revisions/{revision}" diff --git a/desktop/src-tauri/src/transport/wire.rs b/desktop/src-tauri/src/transport/wire.rs index 73828fceaa..89fae4da4d 100644 --- a/desktop/src-tauri/src/transport/wire.rs +++ b/desktop/src-tauri/src/transport/wire.rs @@ -276,6 +276,28 @@ impl std::fmt::Display for AccessRole { } } +#[derive(Clone, Default, Debug, PartialEq, Serialize, Deserialize, TS)] +#[serde(deny_unknown_fields)] +pub struct AllScopeSelection { + #[serde(rename = "mode")] + pub mode: AllScopeSelectionMode, +} + +impl AllScopeSelection { + #[allow(clippy::too_many_arguments)] + pub fn new(mode: AllScopeSelectionMode) -> AllScopeSelection { + AllScopeSelection { mode } + } +} +#[derive( + Clone, Copy, Debug, Eq, PartialEq, Ord, PartialOrd, Hash, Serialize, Deserialize, TS, Default, +)] +pub enum AllScopeSelectionMode { + #[serde(rename = "all")] + #[default] + All, +} + #[derive(Clone, Default, Debug, PartialEq, Serialize, Deserialize, TS)] #[serde(deny_unknown_fields)] pub struct ArtifactDreamingOperationCapability { @@ -369,6 +391,22 @@ pub enum ShareUnit { MemoryEntry, } +#[derive(Clone, Default, Debug, PartialEq, Serialize, Deserialize, TS)] +#[serde(deny_unknown_fields)] +pub struct ArtifactInventoryStatistics { + #[serde(rename = "total")] + pub total: i64, + #[serde(rename = "by_family")] + pub by_family: Vec, +} + +impl ArtifactInventoryStatistics { + #[allow(clippy::too_many_arguments)] + pub fn new(total: i64, by_family: Vec) -> ArtifactInventoryStatistics { + ArtifactInventoryStatistics { total, by_family } + } +} + #[derive( Clone, Copy, Debug, Eq, PartialEq, Ord, PartialOrd, Hash, Serialize, Deserialize, TS, Default, )] @@ -645,6 +683,109 @@ impl std::fmt::Display for AtomicMemoryState { } } +#[derive( + Clone, Copy, Debug, Eq, PartialEq, Ord, PartialOrd, Hash, Serialize, Deserialize, TS, Default, +)] +pub enum CandidateFamily { + #[serde(rename = "experience")] + #[default] + Experience, + #[serde(rename = "skill")] + Skill, + #[serde(rename = "profile")] + Profile, + #[serde(rename = "atomic-memory")] + AtomicMemory, + #[serde(rename = "topic-memory")] + TopicMemory, + #[serde(rename = "handoff")] + Handoff, + #[serde(rename = "prompt")] + Prompt, +} + +impl std::fmt::Display for CandidateFamily { + fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { + match self { + Self::Experience => write!(f, "experience"), + Self::Skill => write!(f, "skill"), + Self::Profile => write!(f, "profile"), + Self::AtomicMemory => write!(f, "atomic-memory"), + Self::TopicMemory => write!(f, "topic-memory"), + Self::Handoff => write!(f, "handoff"), + Self::Prompt => write!(f, "prompt"), + } + } +} + +#[derive(Clone, Default, Debug, PartialEq, Serialize, Deserialize, TS)] +#[serde(deny_unknown_fields)] +pub struct CandidateFamilyCount { + #[serde(rename = "family")] + pub family: CandidateFamily, + #[serde(rename = "total")] + pub total: i64, + #[serde(rename = "pending")] + pub pending: i64, + #[serde(rename = "approved")] + pub approved: i64, + #[serde(rename = "rejected")] + pub rejected: i64, +} + +impl CandidateFamilyCount { + #[allow(clippy::too_many_arguments)] + pub fn new( + family: CandidateFamily, + total: i64, + pending: i64, + approved: i64, + rejected: i64, + ) -> CandidateFamilyCount { + CandidateFamilyCount { + family, + total, + pending, + approved, + rejected, + } + } +} + +#[derive(Clone, Default, Debug, PartialEq, Serialize, Deserialize, TS)] +#[serde(deny_unknown_fields)] +pub struct CandidateInventoryStatistics { + #[serde(rename = "total")] + pub total: i64, + #[serde(rename = "pending")] + pub pending: i64, + #[serde(rename = "approved")] + pub approved: i64, + #[serde(rename = "rejected")] + pub rejected: i64, + #[serde(rename = "by_family")] + pub by_family: Vec, +} + +impl CandidateInventoryStatistics { + #[allow(clippy::too_many_arguments)] + pub fn new( + total: i64, + pending: i64, + approved: i64, + rejected: i64, + by_family: Vec, + ) -> CandidateInventoryStatistics { + CandidateInventoryStatistics { + total, + pending, + approved, + rejected, + by_family, + } + } +} + #[derive(Clone, Default, Debug, PartialEq, Serialize, Deserialize, TS)] #[serde(deny_unknown_fields)] pub struct Capabilities { @@ -771,6 +912,30 @@ impl std::fmt::Display for DreamOperation { } } +#[derive(Clone, Default, Debug, PartialEq, Serialize, Deserialize, TS)] +#[serde(deny_unknown_fields)] +pub struct ExactScopeSelection { + #[serde(rename = "mode")] + pub mode: ExactScopeSelectionMode, + #[serde(rename = "scope_ids")] + pub scope_ids: Vec, +} + +impl ExactScopeSelection { + #[allow(clippy::too_many_arguments)] + pub fn new(mode: ExactScopeSelectionMode, scope_ids: Vec) -> ExactScopeSelection { + ExactScopeSelection { mode, scope_ids } + } +} +#[derive( + Clone, Copy, Debug, Eq, PartialEq, Ord, PartialOrd, Hash, Serialize, Deserialize, TS, Default, +)] +pub enum ExactScopeSelectionMode { + #[serde(rename = "exact")] + #[default] + Exact, +} + #[derive(Clone, Default, Debug, PartialEq, Serialize, Deserialize, TS)] #[serde(deny_unknown_fields)] pub struct ExtractionBackground { @@ -980,6 +1145,42 @@ pub enum Configuration { Unknown, } +#[derive(Clone, Default, Debug, PartialEq, Serialize, Deserialize, TS)] +#[serde(deny_unknown_fields)] +pub struct FamilyCount { + #[serde(rename = "family")] + pub family: String, + #[serde(rename = "total")] + pub total: i64, +} + +impl FamilyCount { + #[allow(clippy::too_many_arguments)] + pub fn new(family: String, total: i64) -> FamilyCount { + FamilyCount { family, total } + } +} + +#[derive(Clone, Default, Debug, PartialEq, Serialize, Deserialize, TS)] +#[serde(deny_unknown_fields)] +pub struct GetStatsRequest { + #[serde(rename = "selection")] + pub selection: ScopeSelection, + #[ts(optional)] + #[serde(rename = "period", skip_serializing_if = "Option::is_none")] + pub period: Option, +} + +impl GetStatsRequest { + #[allow(clippy::too_many_arguments)] + pub fn new(selection: ScopeSelection) -> GetStatsRequest { + GetStatsRequest { + selection, + period: None, + } + } +} + #[derive(Clone, Default, Debug, PartialEq, Serialize, Deserialize, TS)] #[serde(deny_unknown_fields)] pub struct HealthResponse { @@ -994,6 +1195,105 @@ impl HealthResponse { } } +#[derive(Clone, Default, Debug, PartialEq, Serialize, Deserialize, TS)] +#[serde(deny_unknown_fields)] +pub struct InventoryStatistics { + #[serde(rename = "sources")] + pub sources: SourceInventoryStatistics, + #[serde(rename = "artifacts")] + pub artifacts: ArtifactInventoryStatistics, + #[serde(rename = "candidates")] + pub candidates: CandidateInventoryStatistics, + #[serde(rename = "memory")] + pub memory: MemoryInventoryStatistics, +} + +impl InventoryStatistics { + #[allow(clippy::too_many_arguments)] + pub fn new( + sources: SourceInventoryStatistics, + artifacts: ArtifactInventoryStatistics, + candidates: CandidateInventoryStatistics, + memory: MemoryInventoryStatistics, + ) -> InventoryStatistics { + InventoryStatistics { + sources, + artifacts, + candidates, + memory, + } + } +} + +#[derive(Clone, Default, Debug, PartialEq, Serialize, Deserialize, TS)] +#[serde(deny_unknown_fields)] +pub struct MemoryEntryInventoryStatistics { + #[serde(rename = "total")] + pub total: i64, + #[serde(rename = "active")] + pub active: i64, + #[serde(rename = "inactive")] + pub inactive: i64, + #[serde(rename = "by_kind")] + pub by_kind: Vec, +} + +impl MemoryEntryInventoryStatistics { + #[allow(clippy::too_many_arguments)] + pub fn new( + total: i64, + active: i64, + inactive: i64, + by_kind: Vec, + ) -> MemoryEntryInventoryStatistics { + MemoryEntryInventoryStatistics { + total, + active, + inactive, + by_kind, + } + } +} + +#[derive(Clone, Default, Debug, PartialEq, Serialize, Deserialize, TS)] +#[serde(deny_unknown_fields)] +pub struct MemoryInventoryStatistics { + #[serde(rename = "entries")] + pub entries: MemoryEntryInventoryStatistics, +} + +impl MemoryInventoryStatistics { + #[allow(clippy::too_many_arguments)] + pub fn new(entries: MemoryEntryInventoryStatistics) -> MemoryInventoryStatistics { + MemoryInventoryStatistics { entries } + } +} + +#[derive(Clone, Default, Debug, PartialEq, Serialize, Deserialize, TS)] +#[serde(deny_unknown_fields)] +pub struct MemoryKindCount { + #[serde(rename = "kind")] + pub kind: String, + #[serde(rename = "total")] + pub total: i64, + #[serde(rename = "active")] + pub active: i64, + #[serde(rename = "inactive")] + pub inactive: i64, +} + +impl MemoryKindCount { + #[allow(clippy::too_many_arguments)] + pub fn new(kind: String, total: i64, active: i64, inactive: i64) -> MemoryKindCount { + MemoryKindCount { + kind, + total, + active, + inactive, + } + } +} + #[derive(Clone, Default, Debug, PartialEq, Serialize, Deserialize, TS)] #[serde(deny_unknown_fields)] pub struct MemoryMutationResponse { @@ -1036,6 +1336,109 @@ impl std::fmt::Display for MemorySearchMode { } } +#[derive(Clone, Default, Debug, PartialEq, Serialize, Deserialize, TS)] +#[serde(deny_unknown_fields)] +pub struct ModelUsageDay { + #[serde(rename = "date")] + pub date: String, + #[serde(rename = "generation")] + pub generation: ModelUsageValue, + #[serde(rename = "embedding")] + pub embedding: ModelUsageValue, + #[serde(rename = "by_purpose")] + pub by_purpose: Vec, +} + +impl ModelUsageDay { + #[allow(clippy::too_many_arguments)] + pub fn new( + date: String, + generation: ModelUsageValue, + embedding: ModelUsageValue, + by_purpose: Vec, + ) -> ModelUsageDay { + ModelUsageDay { + date, + generation, + embedding, + by_purpose, + } + } +} + +#[derive(Clone, Default, Debug, PartialEq, Serialize, Deserialize, TS)] +#[serde(deny_unknown_fields)] +pub struct ModelUsagePurposeBreakdown { + #[serde(rename = "purpose")] + pub purpose: String, + #[serde(rename = "generation")] + pub generation: ModelUsageValue, + #[serde(rename = "embedding")] + pub embedding: ModelUsageValue, +} + +impl ModelUsagePurposeBreakdown { + #[allow(clippy::too_many_arguments)] + pub fn new( + purpose: String, + generation: ModelUsageValue, + embedding: ModelUsageValue, + ) -> ModelUsagePurposeBreakdown { + ModelUsagePurposeBreakdown { + purpose, + generation, + embedding, + } + } +} + +#[derive(Clone, Default, Debug, PartialEq, Serialize, Deserialize, TS)] +#[serde(deny_unknown_fields)] +pub struct ModelUsageStatistics { + #[serde(rename = "generation")] + pub generation: ModelUsageValue, + #[serde(rename = "embedding")] + pub embedding: ModelUsageValue, +} + +impl ModelUsageStatistics { + #[allow(clippy::too_many_arguments)] + pub fn new(generation: ModelUsageValue, embedding: ModelUsageValue) -> ModelUsageStatistics { + ModelUsageStatistics { + generation, + embedding, + } + } +} + +#[derive(Clone, Default, Debug, PartialEq, Serialize, Deserialize, TS)] +#[serde(deny_unknown_fields)] +pub struct ModelUsageValue { + #[serde(rename = "requests")] + pub requests: i64, + #[ts(type = "number | null")] + #[serde(rename = "input_tokens", deserialize_with = "Option::deserialize")] + pub input_tokens: Option, + #[ts(type = "number | null")] + #[serde(rename = "output_tokens", deserialize_with = "Option::deserialize")] + pub output_tokens: Option, +} + +impl ModelUsageValue { + #[allow(clippy::too_many_arguments)] + pub fn new( + requests: i64, + input_tokens: Option, + output_tokens: Option, + ) -> ModelUsageValue { + ModelUsageValue { + requests, + input_tokens, + output_tokens, + } + } +} + #[derive( Clone, Copy, Debug, Eq, PartialEq, Ord, PartialOrd, Hash, Serialize, Deserialize, TS, Default, )] @@ -1167,6 +1570,184 @@ impl std::fmt::Display for ReadinessStatus { } } +#[derive(Clone, Default, Debug, PartialEq, Serialize, Deserialize, TS)] +#[serde(deny_unknown_fields)] +pub struct RecallTokenDay { + #[serde(rename = "date")] + pub date: String, + #[serde(rename = "preparations")] + pub preparations: i64, + #[serde(rename = "ready_preparations")] + pub ready_preparations: i64, + #[serde(rename = "comparable_preparations")] + pub comparable_preparations: i64, + #[serde(rename = "baseline_tokens")] + pub baseline_tokens: i64, + #[serde(rename = "recalled_tokens")] + pub recalled_tokens: i64, + #[serde(rename = "token_reduction")] + pub token_reduction: i64, +} + +impl RecallTokenDay { + #[allow(clippy::too_many_arguments)] + pub fn new( + date: String, + preparations: i64, + ready_preparations: i64, + comparable_preparations: i64, + baseline_tokens: i64, + recalled_tokens: i64, + token_reduction: i64, + ) -> RecallTokenDay { + RecallTokenDay { + date, + preparations, + ready_preparations, + comparable_preparations, + baseline_tokens, + recalled_tokens, + token_reduction, + } + } +} + +#[derive(Clone, Default, Debug, PartialEq, Serialize, Deserialize, TS)] +#[serde(deny_unknown_fields)] +pub struct RecallTokenStatistics { + #[serde(rename = "period")] + pub period: ResolvedUsagePeriod, + #[serde(rename = "estimator")] + pub estimator: TokenEstimatorProfile, + #[serde(rename = "totals")] + pub totals: RecallTokenValue, + #[serde(rename = "daily")] + pub daily: Vec, +} + +impl RecallTokenStatistics { + #[allow(clippy::too_many_arguments)] + pub fn new( + period: ResolvedUsagePeriod, + estimator: TokenEstimatorProfile, + totals: RecallTokenValue, + daily: Vec, + ) -> RecallTokenStatistics { + RecallTokenStatistics { + period, + estimator, + totals, + daily, + } + } +} + +#[derive(Clone, Default, Debug, PartialEq, Serialize, Deserialize, TS)] +#[serde(deny_unknown_fields)] +pub struct RecallTokenValue { + #[serde(rename = "preparations")] + pub preparations: i64, + #[serde(rename = "ready_preparations")] + pub ready_preparations: i64, + #[serde(rename = "comparable_preparations")] + pub comparable_preparations: i64, + #[serde(rename = "baseline_tokens")] + pub baseline_tokens: i64, + #[serde(rename = "recalled_tokens")] + pub recalled_tokens: i64, + #[serde(rename = "token_reduction")] + pub token_reduction: i64, +} + +impl RecallTokenValue { + #[allow(clippy::too_many_arguments)] + pub fn new( + preparations: i64, + ready_preparations: i64, + comparable_preparations: i64, + baseline_tokens: i64, + recalled_tokens: i64, + token_reduction: i64, + ) -> RecallTokenValue { + RecallTokenValue { + preparations, + ready_preparations, + comparable_preparations, + baseline_tokens, + recalled_tokens, + token_reduction, + } + } +} + +#[derive(Clone, Default, Debug, PartialEq, Serialize, Deserialize, TS)] +#[serde(deny_unknown_fields)] +pub struct RecurrenceStatistics { + #[serde(rename = "selected")] + pub selected: i64, + #[serde(rename = "recurred")] + pub recurred: i64, + #[serde(rename = "avoided")] + pub avoided: i64, + #[serde(rename = "unknown")] + pub unknown: i64, + #[serde(rename = "unlinked_handoff_citations")] + pub unlinked_handoff_citations: i64, + #[serde(rename = "needing_review")] + pub needing_review: i64, + #[serde(rename = "top_revisions")] + pub top_revisions: Vec, +} + +impl RecurrenceStatistics { + #[allow(clippy::too_many_arguments)] + pub fn new( + selected: i64, + recurred: i64, + avoided: i64, + unknown: i64, + unlinked_handoff_citations: i64, + needing_review: i64, + top_revisions: Vec, + ) -> RecurrenceStatistics { + RecurrenceStatistics { + selected, + recurred, + avoided, + unknown, + unlinked_handoff_citations, + needing_review, + top_revisions, + } + } +} + +#[derive(Clone, Default, Debug, PartialEq, Serialize, Deserialize, TS)] +#[serde(deny_unknown_fields)] +pub struct RecurrenceStreak { + #[serde(rename = "artifact_ref")] + pub artifact_ref: ArtifactReference, + #[serde(rename = "signature_key")] + pub signature_key: String, + #[serde(rename = "terminal_recurred_streak")] + pub terminal_recurred_streak: i64, +} + +impl RecurrenceStreak { + #[allow(clippy::too_many_arguments)] + pub fn new( + artifact_ref: ArtifactReference, + signature_key: String, + terminal_recurred_streak: i64, + ) -> RecurrenceStreak { + RecurrenceStreak { + artifact_ref, + signature_key, + terminal_recurred_streak, + } + } +} + #[derive(Clone, Default, Debug, PartialEq, Serialize, Deserialize, TS)] #[serde(deny_unknown_fields)] pub struct RememberMemoryRequest { @@ -1210,6 +1791,44 @@ impl RememberMemoryRequest { } } +#[derive(Clone, Default, Debug, PartialEq, Serialize, Deserialize, TS)] +#[serde(deny_unknown_fields)] +pub struct ResolvedUsagePeriod { + #[serde(rename = "preset")] + pub preset: StatsPeriod, + #[serde(rename = "start_date")] + pub start_date: String, + #[serde(rename = "end_date")] + pub end_date: String, + #[serde(rename = "timezone")] + pub timezone: Timezone, +} + +impl ResolvedUsagePeriod { + #[allow(clippy::too_many_arguments)] + pub fn new( + preset: StatsPeriod, + start_date: String, + end_date: String, + timezone: Timezone, + ) -> ResolvedUsagePeriod { + ResolvedUsagePeriod { + preset, + start_date, + end_date, + timezone, + } + } +} +#[derive( + Clone, Copy, Debug, Eq, PartialEq, Ord, PartialOrd, Hash, Serialize, Deserialize, TS, Default, +)] +pub enum Timezone { + #[serde(rename = "UTC")] + #[default] + Utc, +} + #[derive(Clone, Default, Debug, PartialEq, Serialize, Deserialize, TS)] #[serde(deny_unknown_fields)] pub struct ScopeDescriptor { @@ -1329,6 +1948,99 @@ impl std::fmt::Display for ScopeQueryField { } } +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, TS)] +#[serde(tag = "mode")] +pub enum ScopeSelection { + #[serde(rename = "AllScopeSelection")] + AllScopeSelection(AllScopeSelection), + #[serde(rename = "ExactScopeSelection")] + ExactScopeSelection(ExactScopeSelection), + #[serde(rename = "SubtreeScopeSelection")] + SubtreeScopeSelection(SubtreeScopeSelection), +} + +impl Default for ScopeSelection { + fn default() -> Self { + Self::AllScopeSelection(Default::default()) + } +} + +#[derive(Clone, Default, Debug, PartialEq, Serialize, Deserialize, TS)] +#[serde(deny_unknown_fields)] +pub struct ScopeStats { + #[serde(rename = "scope_id")] + pub scope_id: String, + #[serde(rename = "inventory")] + pub inventory: InventoryStatistics, + #[serde(rename = "usage")] + pub usage: UsageStatistics, + #[serde(rename = "recall")] + pub recall: RecallTokenStatistics, + #[serde(rename = "recurrence")] + pub recurrence: RecurrenceStatistics, +} + +impl ScopeStats { + #[allow(clippy::too_many_arguments)] + pub fn new( + scope_id: String, + inventory: InventoryStatistics, + usage: UsageStatistics, + recall: RecallTokenStatistics, + recurrence: RecurrenceStatistics, + ) -> ScopeStats { + ScopeStats { + scope_id, + inventory, + usage, + recall, + recurrence, + } + } +} + +#[derive(Clone, Default, Debug, PartialEq, Serialize, Deserialize, TS)] +#[serde(deny_unknown_fields)] +pub struct ScopedStats { + #[serde(rename = "selection")] + pub selection: ScopeSelection, + #[serde(rename = "scope_ids")] + pub scope_ids: Vec, + #[serde(rename = "as_of")] + pub as_of: String, + #[serde(rename = "inventory")] + pub inventory: InventoryStatistics, + #[serde(rename = "usage")] + pub usage: UsageStatistics, + #[serde(rename = "recall")] + pub recall: RecallTokenStatistics, + #[serde(rename = "by_scope")] + pub by_scope: Vec, +} + +impl ScopedStats { + #[allow(clippy::too_many_arguments)] + pub fn new( + selection: ScopeSelection, + scope_ids: Vec, + as_of: String, + inventory: InventoryStatistics, + usage: UsageStatistics, + recall: RecallTokenStatistics, + by_scope: Vec, + ) -> ScopedStats { + ScopedStats { + selection, + scope_ids, + as_of, + inventory, + usage, + recall, + by_scope, + } + } +} + #[derive(Clone, Default, Debug, PartialEq, Serialize, Deserialize, TS)] #[serde(deny_unknown_fields)] pub struct SearchMemoryRequest { @@ -1379,6 +2091,32 @@ impl SearchMemoryResponse { } } +#[derive(Clone, Default, Debug, PartialEq, Serialize, Deserialize, TS)] +#[serde(deny_unknown_fields)] +pub struct SourceInventoryStatistics { + #[serde(rename = "total")] + pub total: i64, + #[serde(rename = "memory_processed")] + pub memory_processed: i64, + #[serde(rename = "memory_pending")] + pub memory_pending: i64, +} + +impl SourceInventoryStatistics { + #[allow(clippy::too_many_arguments)] + pub fn new( + total: i64, + memory_processed: i64, + memory_pending: i64, + ) -> SourceInventoryStatistics { + SourceInventoryStatistics { + total, + memory_processed, + memory_pending, + } + } +} + #[derive(Clone, Default, Debug, PartialEq, Serialize, Deserialize, TS)] #[serde(deny_unknown_fields)] pub struct SourceTypeReference { @@ -1400,6 +2138,56 @@ impl SourceTypeReference { } } +#[derive( + Clone, Copy, Debug, Eq, PartialEq, Ord, PartialOrd, Hash, Serialize, Deserialize, TS, Default, +)] +pub enum StatsPeriod { + #[serde(rename = "today")] + #[default] + Today, + #[serde(rename = "7d")] + Variant7d, + #[serde(rename = "30d")] + Variant30d, +} + +impl std::fmt::Display for StatsPeriod { + fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { + match self { + Self::Today => write!(f, "today"), + Self::Variant7d => write!(f, "7d"), + Self::Variant30d => write!(f, "30d"), + } + } +} + +#[derive(Clone, Default, Debug, PartialEq, Serialize, Deserialize, TS)] +#[serde(deny_unknown_fields)] +pub struct SubtreeScopeSelection { + #[serde(rename = "mode")] + pub mode: SubtreeScopeSelectionMode, + #[serde(rename = "root_scope_id")] + pub root_scope_id: String, +} + +impl SubtreeScopeSelection { + #[allow(clippy::too_many_arguments)] + pub fn new(mode: SubtreeScopeSelectionMode, root_scope_id: String) -> SubtreeScopeSelection { + SubtreeScopeSelection { + mode, + root_scope_id, + } + } +} +#[derive( + Clone, Copy, Debug, Eq, PartialEq, Ord, PartialOrd, Hash, Serialize, Deserialize, TS, Default, +)] +pub enum SubtreeScopeSelectionMode { + #[serde(rename = "subtree")] + #[default] + Subtree, +} + #[derive(Clone, Default, Debug, PartialEq, Serialize, Deserialize, TS)] #[serde(deny_unknown_fields)] pub struct TagFilter { @@ -1440,6 +2228,55 @@ impl std::fmt::Display for TagMatch { } } +#[derive(Clone, Default, Debug, PartialEq, Serialize, Deserialize, TS)] +#[serde(deny_unknown_fields)] +pub struct TokenEstimatorProfile { + #[serde(rename = "estimator_id")] + pub estimator_id: String, + #[serde(rename = "version")] + pub version: String, +} + +impl TokenEstimatorProfile { + #[allow(clippy::too_many_arguments)] + pub fn new(estimator_id: String, version: String) -> TokenEstimatorProfile { + TokenEstimatorProfile { + estimator_id, + version, + } + } +} + +#[derive(Clone, Default, Debug, PartialEq, Serialize, Deserialize, TS)] +#[serde(deny_unknown_fields)] +pub struct UsageStatistics { + #[serde(rename = "period")] + pub period: ResolvedUsagePeriod, + #[serde(rename = "totals")] + pub totals: ModelUsageStatistics, + #[serde(rename = "by_purpose")] + pub by_purpose: Vec, + #[serde(rename = "daily")] + pub daily: Vec, +} + +impl UsageStatistics { + #[allow(clippy::too_many_arguments)] + pub fn new( + period: ResolvedUsagePeriod, + totals: ModelUsageStatistics, + by_purpose: Vec, + daily: Vec, + ) -> UsageStatistics { + UsageStatistics { + period, + totals, + by_purpose, + daily, + } + } +} + #[rustfmt::skip] pub fn declarations(config: &ts_rs::Config) -> Vec { vec![ @@ -1450,8 +2287,11 @@ pub fn declarations(config: &ts_rs::Config) -> Vec { ::decl(config), ::decl(config), ::decl(config), + ::decl(config), + ::decl(config), ::decl(config), ::decl(config), + ::decl(config), ::decl(config), ::decl(config), ::decl(config), @@ -1460,20 +2300,35 @@ pub fn declarations(config: &ts_rs::Config) -> Vec { ::decl(config), ::decl(config), ::decl(config), + ::decl(config), + ::decl(config), + ::decl(config), ::decl(config), ::decl(config), ::decl(config), ::decl(config), + ::decl(config), + ::decl(config), ::decl(config), ::decl(config), ::decl(config), ::decl(config), ::decl(config), + ::decl(config), + ::decl(config), ::decl(config), + ::decl(config), ::decl(config), ::decl(config), + ::decl(config), + ::decl(config), + ::decl(config), ::decl(config), ::decl(config), + ::decl(config), + ::decl(config), + ::decl(config), + ::decl(config), ::decl(config), ::decl(config), ::decl(config), @@ -1481,20 +2336,36 @@ pub fn declarations(config: &ts_rs::Config) -> Vec { ::decl(config), ::decl(config), ::decl(config), + ::decl(config), + ::decl(config), + ::decl(config), + ::decl(config), + ::decl(config), ::decl(config), + ::decl(config), ::decl(config), ::decl(config), ::decl(config), ::decl(config), ::decl(config), + ::decl(config), + ::decl(config), + ::decl(config), ::decl(config), ::decl(config), ::decl(config), + ::decl(config), ::decl(config), ::decl(config), ::decl(config), + ::decl(config), + ::decl(config), + ::decl(config), ::decl(config), ::decl(config), + ::decl(config), + ::decl(config), ::decl(config), + ::decl(config), ] } diff --git a/desktop/src-tauri/tests/ipc.rs b/desktop/src-tauri/tests/ipc.rs index 6dbd91efb3..59bff1bd14 100644 --- a/desktop/src-tauri/tests/ipc.rs +++ b/desktop/src-tauri/tests/ipc.rs @@ -172,12 +172,15 @@ fn memory_commands_reject_untrusted_callers_before_accessing_the_connection() { commands::remember_memory, commands::search_memory, commands::atomic_memory_entry, - commands::cancel_memory_reads + commands::cancel_memory_reads, + commands::get_stats, + commands::cancel_stats_reads ]) .build(test_context()) .unwrap(); let args = serde_json::json!({ "generation": 0, + "period": "today", "text": "synthetic permission test", "query": "synthetic", "artifact": {"family":"atomic-memory", "artifact_id":"test", "revision":1} @@ -187,6 +190,8 @@ fn memory_commands_reject_untrusted_callers_before_accessing_the_connection() { "search_memory", "atomic_memory_entry", "cancel_memory_reads", + "get_stats", + "cancel_stats_reads", ] { for (label, origin) in [ ("main", LOCAL_ORIGIN), diff --git a/desktop/src-tauri/tests/statistics.rs b/desktop/src-tauri/tests/statistics.rs new file mode 100644 index 0000000000..be2d816719 --- /dev/null +++ b/desktop/src-tauri/tests/statistics.rs @@ -0,0 +1,347 @@ +/* + * Copyright (c) 2026 OceanBase. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +mod common; + +use powercontext_desktop::{ + connections::{profiles::ProfileRepository, session::ConnectionManager}, + credentials::NativeVault, + error::SafeError, + transport::{ + ExactRecallTokenStatistics, ExactScopeStatistics, ExactScopeStatisticsItem, + MAX_RESPONSE_BYTES, wire::*, + }, +}; +use std::sync::{ + Arc, Mutex, + atomic::{AtomicBool, AtomicU16, Ordering}, +}; +use tokio::{ + io::{AsyncReadExt, AsyncWriteExt}, + net::TcpListener, + sync::Notify, +}; + +fn response() -> serde_json::Value { + let period = ResolvedUsagePeriod::new( + StatsPeriod::Today, + "2026-10-24".into(), + "2026-10-24".into(), + Timezone::Utc, + ); + let usage = UsageStatistics { + period: period.clone(), + ..Default::default() + }; + let recall = ExactRecallTokenStatistics { + period, + estimator: None, + totals: RecallTokenValue::default(), + daily: vec![], + }; + serde_json::to_value(ExactScopeStatistics { + selection: ExactScopeSelection::new(ExactScopeSelectionMode::Exact, vec!["scope-a".into()]), + scope_ids: vec!["scope-a".into()], + as_of: "2026-10-24T00:00:00Z".into(), + inventory: InventoryStatistics::default(), + usage: usage.clone(), + recall: recall.clone(), + by_scope: vec![ExactScopeStatisticsItem { + scope_id: "scope-a".into(), + usage, + recall, + inventory: InventoryStatistics::default(), + recurrence: RecurrenceStatistics::default(), + }], + }) + .unwrap() +} + +struct Fixture { + manager: Arc, + body: Arc>, + requests: Arc>>, + status: Arc, + blocked: Arc, + started: Arc, + release: Arc, + task: tokio::task::JoinHandle<()>, + _dir: tempfile::TempDir, +} +impl Drop for Fixture { + fn drop(&mut self) { + self.task.abort(); + self.release.notify_waiters(); + } +} +async fn setup() -> Fixture { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let endpoint = format!("http://{}", listener.local_addr().unwrap()); + let body = Arc::new(Mutex::new(response())); + let requests = Arc::new(Mutex::new(Vec::new())); + let status = Arc::new(AtomicU16::new(200)); + let blocked = Arc::new(AtomicBool::new(false)); + let started = Arc::new(Notify::new()); + let release = Arc::new(Notify::new()); + let (bodies, captured, code, block, start, finish) = ( + body.clone(), + requests.clone(), + status.clone(), + blocked.clone(), + started.clone(), + release.clone(), + ); + let task = tokio::spawn(async move { + loop { + let (mut socket, _) = listener.accept().await.unwrap(); + let (bodies, captured, code, block, start, finish) = ( + bodies.clone(), + captured.clone(), + code.clone(), + block.clone(), + start.clone(), + finish.clone(), + ); + tokio::spawn(async move { + let mut bytes = Vec::new(); + let end = loop { + let mut chunk = [0; 4096]; + let n = socket.read(&mut chunk).await.unwrap_or(0); + if n == 0 { + return; + } + bytes.extend_from_slice(&chunk[..n]); + if let Some(end) = bytes.windows(4).position(|v| v == b"\r\n\r\n") { + let headers = String::from_utf8_lossy(&bytes[..end]); + let length = headers + .lines() + .find_map(|line| { + line.to_lowercase() + .strip_prefix("content-length: ") + .map(str::to_owned) + }) + .map(|s| s.parse::().unwrap()) + .unwrap_or(0); + if bytes.len() >= end + 4 + length { + break end; + } + } + }; + let request = String::from_utf8_lossy(&bytes[..end]) + .lines() + .next() + .unwrap() + .to_owned(); + let (status, value) = if request == "POST /v1/stats HTTP/1.1" { + captured + .lock() + .unwrap() + .push(serde_json::from_slice(&bytes[end + 4..]).unwrap()); + start.notify_one(); + if block.load(Ordering::Relaxed) { + finish.notified().await; + } + (code.load(Ordering::Relaxed), bodies.lock().unwrap().clone()) + } else if request.contains("/v1/access/me ") { + ( + 503, + serde_json::json!({"error":{"code":"runtime_not_ready"}}), + ) + } else if request.contains("/health/ready ") { + ( + 200, + serde_json::json!({"status":"ready","checks":{"access_mode":"disabled"}}), + ) + } else if request.contains("/v1/capabilities ") { + (403, serde_json::json!({})) + } else if request.contains("/v1/scopes/") { + let id = request + .split_whitespace() + .nth(1) + .unwrap() + .rsplit('/') + .next() + .unwrap(); + ( + 200, + serde_json::json!({"scope_id":id,"title":"Synthetic scope","summary":"","context_references":[],"external_references":[],"version":1}), + ) + } else { + (200, serde_json::json!({"status":"ok"})) + }; + if status == 0 { + return; + } + let body = value.to_string(); + let response = format!( + "HTTP/1.1 {status} Result\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", + body.len() + ); + let _ = socket.write_all(response.as_bytes()).await; + }); + } + }); + let dir = tempfile::tempdir().unwrap(); + let manager = Arc::new(common::connection_manager( + ProfileRepository::open(dir.path().join("profiles.json"), Arc::new(NativeVault)).unwrap(), + )); + let profile = manager.save_profile(serde_json::from_value(serde_json::json!({"id":null,"revision":null,"name":"Statistics fixture","endpoint":endpoint,"authentication":"unauthenticated_loopback","caPem":null,"compatibility":common::PROFILE_ID,"keepCredential":false,"credential":null})).unwrap()).unwrap().profiles[0].id.clone(); + let generation = manager.check(&profile, true).await.unwrap().generation; + manager.select_scope(generation, "scope-a").await.unwrap(); + Fixture { + manager, + body, + requests, + status, + blocked, + started, + release, + task, + _dir: dir, + } +} + +#[tokio::test] +async fn statistics_use_only_the_current_exact_scope_and_preserve_null_usage() { + let fixture = setup().await; + let generation = fixture.manager.state().unwrap().generation; + let result = fixture + .manager + .stats(generation, StatsPeriod::Today) + .await + .unwrap(); + assert_eq!(result.scope_ids, ["scope-a"]); + assert!(result.usage.totals.generation.input_tokens.is_none()); + assert!(result.recall.estimator.is_none()); + assert!(result.by_scope[0].recall.estimator.is_none()); + assert_eq!( + fixture.requests.lock().unwrap().as_slice(), + &[ + serde_json::json!({"selection":{"mode":"exact","scope_ids":["scope-a"]},"period":"today"}) + ] + ); +} + +#[tokio::test] +async fn denied_statistics_never_retry_with_broader_selection() { + for (status, expected) in [ + (401, SafeError::Unauthorized), + (403, SafeError::Forbidden), + (0, SafeError::Network), + ] { + let fixture = setup().await; + fixture.status.store(status, Ordering::Relaxed); + let generation = fixture.manager.state().unwrap().generation; + assert_eq!( + fixture + .manager + .stats(generation, StatsPeriod::Today) + .await + .unwrap_err() + .code, + expected + ); + assert_eq!(fixture.requests.lock().unwrap().len(), 1); + } +} + +#[tokio::test] +async fn rejects_wrong_selection_scope_period_and_oversized_content() { + for (field, value, expected) in [ + ( + "scope_ids", + serde_json::json!(["scope-b"]), + SafeError::InvalidResponse, + ), + ( + "selection", + serde_json::json!({"mode":"all"}), + SafeError::InvalidResponse, + ), + ( + "by_scope", + serde_json::json!([]), + SafeError::InvalidResponse, + ), + ( + "usage", + { + let mut usage = response()["usage"].clone(); + usage["period"]["preset"] = "7d".into(); + usage + }, + SafeError::InvalidResponse, + ), + ( + "as_of", + serde_json::json!("x".repeat(MAX_RESPONSE_BYTES)), + SafeError::ResponseTooLarge, + ), + ] { + let fixture = setup().await; + fixture.body.lock().unwrap()[field] = value; + let generation = fixture.manager.state().unwrap().generation; + assert_eq!( + fixture + .manager + .stats(generation, StatsPeriod::Today) + .await + .unwrap_err() + .code, + expected + ); + } +} + +#[tokio::test] +async fn cancelled_or_changed_context_discards_pending_statistics() { + for change_scope in [false, true] { + let fixture = setup().await; + fixture.blocked.store(true, Ordering::Relaxed); + let generation = fixture.manager.state().unwrap().generation; + let manager = fixture.manager.clone(); + let pending = + tokio::spawn(async move { manager.stats(generation, StatsPeriod::Today).await }); + fixture.started.notified().await; + if change_scope { + fixture + .manager + .select_scope(generation, "scope-b") + .await + .unwrap(); + } else { + fixture.manager.cancel_stats_reads(generation).unwrap(); + } + let result = tokio::time::timeout(std::time::Duration::from_secs(3), pending) + .await + .unwrap() + .unwrap(); + assert_eq!(result.unwrap_err().code, SafeError::StaleContext); + } +} + +#[tokio::test] +async fn memory_cancellation_does_not_cancel_statistics() { + let fixture = setup().await; + fixture.blocked.store(true, Ordering::Relaxed); + let generation = fixture.manager.state().unwrap().generation; + let manager = fixture.manager.clone(); + let pending = tokio::spawn(async move { manager.stats(generation, StatsPeriod::Today).await }); + fixture.started.notified().await; + fixture.manager.cancel_memory_reads(generation).unwrap(); + fixture.release.notify_one(); + assert!(pending.await.unwrap().is_ok()); +} diff --git a/desktop/tests/real_server.py b/desktop/tests/real_server.py index fb2808e591..bef6255a88 100644 --- a/desktop/tests/real_server.py +++ b/desktop/tests/real_server.py @@ -132,6 +132,40 @@ def wait_for_release(): await send(message) +async def forward_with_statistics_usage(app, scope, receive, send, control_path): + """Seed a partial provider report through the real Runtime's usage recorder. + + This is explicit fixture input, not evidence of an actual provider call. + The HTTP stats response, aggregation, persistence and native parsing are real. + """ + control = Path(control_path) if control_path else None + if scope["type"] != "http" or scope["path"] != "/v1/stats" or control is None or not control.exists(): + await app(scope, receive, send) + return + from powercontext.builtin.inference import InferenceUsage + from powercontext.builtin.statistics import ModelUsageOperation, ModelUsagePurpose + + messages = [] + while True: + message = await receive() + messages.append(message) + if not message.get("more_body", False): + break + request = json.loads(b"".join(message.get("body", b"") for message in messages)) + scope_id = request["selection"]["scope_ids"][0] + await app.state.application.statistics.for_scope(scope_id).record_model_usage( + ModelUsagePurpose.MEMORY_RECALL, + ModelUsageOperation.GENERATION, + InferenceUsage(requests=2, input_tokens=None, output_tokens=9), + ) + control.unlink() + + async def replay(): + return messages.pop(0) if messages else await receive() + + await app(scope, replay, send) + + def serve(config_path: Path) -> None: config = json.loads(config_path.read_text(encoding="utf-8")) sys.path.insert(0, config["wheel_root"]) @@ -171,6 +205,9 @@ async def proxy(scope, receive, send): if gate and scope["type"] == "http" and scope["path"] == "/v1/memory/remember": await forward_with_response_gate(app, scope, receive, send, gate) return + if config.get("statistics_usage_path") and scope["type"] == "http" and scope["path"] == "/v1/stats": + await forward_with_statistics_usage(app, scope, receive, send, config["statistics_usage_path"]) + return await forward_with_response_loss(app, scope, receive, send, config.get("response_loss_path")) server = uvicorn.Server( @@ -259,6 +296,7 @@ def main() -> None: endpoint = f"{'https' if tls else 'http'}://127.0.0.1:{port}{prefix}" config = { "response_loss_path": str(case / "response-loss-count"), + "statistics_usage_path": str(case / "statistics-partial-usage"), "reader_token": secrets.token_urlsafe(32), "provider": mode == "loopback-provider", "identity_change_path": str(case / "identity-changed"), @@ -326,6 +364,7 @@ def main() -> None: fixture = { "compatibility_profile": compatibility, "response_loss_path": config["response_loss_path"], + "statistics_usage_path": config["statistics_usage_path"], "reader_token": config["reader_token"] if config["provider"] else None, "identity_change_path": config["identity_change_path"] if config["provider"] else None, "endpoint": endpoint, @@ -349,6 +388,8 @@ def main() -> None: "mode": mode, "result": "passed", "serverAliveAfterClientExit": True, + "exactScopeStatisticsAllPeriods": True, + "statisticsPartialReportSeededThroughRuntime": True, "committedWriteWithLostResponseUnknownWithoutReplay": True, "providerIdentityChangeInvalidatesContext": bool(config["provider"]), "sameIdentityRevocationDeniesExactMemoryRead": bool(config["provider"]), diff --git a/desktop/ui/src/generated/ipc.ts b/desktop/ui/src/generated/ipc.ts index 7474c29357..4acb83b8ed 100644 --- a/desktop/ui/src/generated/ipc.ts +++ b/desktop/ui/src/generated/ipc.ts @@ -46,6 +46,9 @@ export type MemorySaveResponse = { changed: boolean, records: Array, }; export type MemorySearchResponse = { hits: Array, }; export type AtomicMemoryDetail = { artifact: ArtifactReference, kind: string, text: string, source_refs: Array, }; +export type ExactScopeStatistics = { selection: ExactScopeSelection, scope_ids: Array, as_of: string, inventory: InventoryStatistics, usage: UsageStatistics, recall: ExactRecallTokenStatistics, by_scope: Array, }; +export type ExactRecallTokenStatistics = { period: ResolvedUsagePeriod, estimator: TokenEstimatorProfile | null, totals: RecallTokenValue, daily: Array, }; +export type ExactScopeStatisticsItem = { scope_id: string, inventory: InventoryStatistics, usage: UsageStatistics, recall: ExactRecallTokenStatistics, recurrence: RecurrenceStatistics, }; export type AccessAction = "server.observe" | "server.admin" | "scope.read" | "scope.contribute" | "scope.review" | "scope.delegate" | "scope.admin" | "artifact.read" | "artifact.write" | "artifact.share" | "handoff.evidence.inspect" | "handoff.acknowledge" | "prompt.use"; export type AccessControlMode = "disabled" | "enforced"; export type AccessMeResponse = { principal: AccessPrincipal, mode: AccessControlMode, resource_kinds: Array, provider_capabilities: AccessProviderCapabilities, artifact_families: Array, }; @@ -53,8 +56,11 @@ export type AccessPrincipal = { type: Type, id: string, description?: string | n export type AccessProviderCapabilities = { safe_resource_filtering: boolean, multi_requirement_check: boolean, relationship_management: boolean, group_subjects: boolean, multi_principal: boolean, max_direct_resource_keys: number, }; export type AccessResourceType = "server" | "scope" | "artifact"; export type AccessRole = "handoff.viewer" | "handoff.receiver" | "artifact.viewer" | "prompt.user" | "artifact.owner" | "scope.viewer" | "scope.contributor" | "scope.reviewer" | "scope.delegator" | "scope.admin" | "server.observer" | "server.admin"; +export type AllScopeSelection = { mode: AllScopeSelectionMode, }; +export type AllScopeSelectionMode = "all"; export type ArtifactDreamingOperationCapability = { operation: DreamOperation, output_kind: OutputKind, effect: Effect, }; export type ArtifactFamilyAccessCapability = { family: string, enabled: boolean, share_unit: ShareUnit, actions: Array, grantable_roles: Array, }; +export type ArtifactInventoryStatistics = { total: number, by_family: Array, }; export type ArtifactReadFamily = "memory" | "atomic-memory" | "experience" | "skill" | "handoff" | "profile" | "prompt" | "topic-memory"; export type ArtifactReference = { family: string, artifact_id: string, revision: number, }; export type ArtifactRevision = { scope_id: string, family: ArtifactReadFamily, artifact_id: string, revision: number, content: Record, sources: Array, artifacts: Array, content_digest: string, @@ -83,6 +89,9 @@ score: number, matched_by: Array, }; export type AtomicMemorySearchMode = "text" | "vector" | "hybrid"; export type AtomicMemoryState = "active" | "forgotten" | "merged" | "retired"; export type BuiltinProfile = "coding" | "conversation"; +export type CandidateFamily = "experience" | "skill" | "profile" | "atomic-memory" | "topic-memory" | "handoff" | "prompt"; +export type CandidateFamilyCount = { family: CandidateFamily, total: number, pending: number, approved: number, rejected: number, }; +export type CandidateInventoryStatistics = { total: number, pending: number, approved: number, rejected: number, by_family: Array, }; export type Capabilities = { prompts?: { [key in string]: PromptCapability }, /** * Whether asynchronous Artifact Dream execution is configured. @@ -115,6 +124,8 @@ handoff_generation: boolean, search_modes: Array, context_vers export type Configuration = "configured" | "unconfigured" | "unknown"; export type DreamOperation = "refine_experience" | "derive_skill" | "revise_skill" | "revise_profile" | "revise_memory" | "revise_topic_memory" | "refresh_handoff" | "revise_prompt" | "revise_tags"; export type Effect = "review_then_publish" | "review_then_publish_configuration" | "review_then_replace_tags"; +export type ExactScopeSelection = { mode: ExactScopeSelectionMode, scope_ids: Array, }; +export type ExactScopeSelectionMode = "exact"; export type ExtractionBackground = { /** * Placement of the Memory Supervisor. None means no background executor; synchronous flush may still work. @@ -164,11 +175,21 @@ export type ExtractionStatus = { * Whether a local extraction model or custom pipeline is assembled. Configured does not verify credentials or connectivity. Unknown means execution is external and its configuration is not observed. */ configuration: Configuration, background: ExtractionBackground, observation: ExtractionObservation, }; +export type FamilyCount = { family: string, total: number, }; +export type GetStatsRequest = { selection: ScopeSelection, period?: StatsPeriod, }; export type HealthResponse = { status: string, }; +export type InventoryStatistics = { sources: SourceInventoryStatistics, artifacts: ArtifactInventoryStatistics, candidates: CandidateInventoryStatistics, memory: MemoryInventoryStatistics, }; export type Location = "local" | "external" | "none"; export type MatchedBy = "text" | "vector"; +export type MemoryEntryInventoryStatistics = { total: number, active: number, inactive: number, by_kind: Array, }; +export type MemoryInventoryStatistics = { entries: MemoryEntryInventoryStatistics, }; +export type MemoryKindCount = { kind: string, total: number, active: number, inactive: number, }; export type MemoryMutationResponse = { changed: boolean, records: Array, }; export type MemorySearchMode = "auto" | "fts" | "vector" | "hybrid"; +export type ModelUsageDay = { date: string, generation: ModelUsageValue, embedding: ModelUsageValue, by_purpose: Array, }; +export type ModelUsagePurposeBreakdown = { purpose: string, generation: ModelUsageValue, embedding: ModelUsageValue, }; +export type ModelUsageStatistics = { generation: ModelUsageValue, embedding: ModelUsageValue, }; +export type ModelUsageValue = { requests: number, input_tokens: number | null, output_tokens: number | null, }; export type OutputKind = "candidate" | "tag_candidate"; export type PreparedContextSchema = "powercontext.prepared-context.v1"; export type PromptCapability = { status: PromptCapabilityStatus, reason: Reason | null, definition_version: string, builtin_version: string, builtin_profile: BuiltinProfile | null, }; @@ -176,19 +197,29 @@ export type PromptCapabilityStatus = "supported" | "disabled" | "unsupported"; export type ReadinessResponse = { status: ReadinessStatus, checks: { [key in string]: string }, }; export type ReadinessStatus = "ready" | "degraded" | "not_ready"; export type Reason = "operation_disabled" | "provider_not_configured" | "injected_component"; +export type RecallTokenDay = { date: string, preparations: number, ready_preparations: number, comparable_preparations: number, baseline_tokens: number, recalled_tokens: number, token_reduction: number, }; +export type RecallTokenStatistics = { period: ResolvedUsagePeriod, estimator: TokenEstimatorProfile, totals: RecallTokenValue, daily: Array, }; +export type RecallTokenValue = { preparations: number, ready_preparations: number, comparable_preparations: number, baseline_tokens: number, recalled_tokens: number, token_reduction: number, }; +export type RecurrenceStatistics = { selected: number, recurred: number, avoided: number, unknown: number, unlinked_handoff_citations: number, needing_review: number, top_revisions: Array, }; +export type RecurrenceStreak = { artifact_ref: ArtifactReference, signature_key: string, terminal_recurred_streak: number, }; export type RememberMemoryRequest = { scope_id: string, kind: string, /** * Must not exceed 8192 UTF-8 bytes after normalization. */ text: string, reason?: string | null, expected_revision?: number | null, }; +export type ResolvedUsagePeriod = { preset: StatsPeriod, start_date: string, end_date: string, timezone: Timezone, }; export type Role = "leader" | "standby"; export type ScopeDescriptor = { scope_id: string, title: string, summary: string, parent_scope_id?: string | null, context_references: Array, external_references: Array, version: number, }; export type ScopeExternalReference = { kind: string, value: string, }; export type ScopePage = { items: Array, next_cursor?: string | null, }; export type ScopeQueryField = "scope_id" | "title" | "summary" | "external_reference_value" | "binding_external_id"; +export type ScopeSelection = { "mode": "AllScopeSelection" } & AllScopeSelection | { "mode": "ExactScopeSelection" } & ExactScopeSelection | { "mode": "SubtreeScopeSelection" } & SubtreeScopeSelection; +export type ScopeStats = { scope_id: string, inventory: InventoryStatistics, usage: UsageStatistics, recall: RecallTokenStatistics, recurrence: RecurrenceStatistics, }; +export type ScopedStats = { selection: ScopeSelection, scope_ids: Array, as_of: string, inventory: InventoryStatistics, usage: UsageStatistics, recall: RecallTokenStatistics, by_scope: Array, }; export type SearchMemoryRequest = { tag_filter?: TagFilter, scope_id: string, query: string, limit?: number, mode?: MemorySearchMode, }; export type SearchMemoryResponse = { mode: AtomicMemorySearchMode, hits: Array, }; export type ShareUnit = "artifact" | "memory_entry"; +export type SourceInventoryStatistics = { total: number, memory_processed: number, memory_pending: number, }; export type SourceTypeReference = { /** * Stable Source type, including dynamically registered Source names. @@ -200,6 +231,12 @@ source_type: string, source_id: string, }; export type Stage = "inference" | "flush" | "worker" | "supervisor" | "lease_renewal" | "scope_discovery"; export type State = "running" | "degraded" | "stopped" | "unknown"; +export type StatsPeriod = "today" | "7d" | "30d"; +export type SubtreeScopeSelection = { mode: SubtreeScopeSelectionMode, root_scope_id: string, }; +export type SubtreeScopeSelectionMode = "subtree"; export type TagFilter = { tags: Array, match?: TagMatch, }; export type TagMatch = "all" | "any"; +export type Timezone = "UTC"; +export type TokenEstimatorProfile = { estimator_id: string, version: string, }; export type Type = "user" | "service"; +export type UsageStatistics = { period: ResolvedUsagePeriod, totals: ModelUsageStatistics, by_purpose: Array, daily: Array, }; diff --git a/desktop/ui/src/generated/operations.ts b/desktop/ui/src/generated/operations.ts index 9fe3089b6f..b04460838d 100644 --- a/desktop/ui/src/generated/operations.ts +++ b/desktop/ui/src/generated/operations.ts @@ -49,6 +49,10 @@ export const operations = { "method": "POST", "path": "/v1/memory/search" }, + "get_stats": { + "method": "POST", + "path": "/v1/stats" + }, "get_artifact_revision": { "method": "GET", "path": "/v1/scopes/{scope_id}/artifacts/{family}/{artifact_id}/revisions/{revision}" diff --git a/desktop/ui/src/shared/ipc.ts b/desktop/ui/src/shared/ipc.ts index 65d2b1089b..b5f3bdb91a 100644 --- a/desktop/ui/src/shared/ipc.ts +++ b/desktop/ui/src/shared/ipc.ts @@ -16,6 +16,8 @@ import { invoke, isTauri } from "@tauri-apps/api/core"; import type { + ExactScopeStatistics, + StatsPeriod, FoundationInfo, WriteOutcome, AtomicMemoryDetail, @@ -33,6 +35,10 @@ export async function getFoundationInfo(): Promise { } export const desktopApi = { + stats: (generation: number, period: StatsPeriod) => + invoke("get_stats", { generation, period }), + cancelStats: (generation: number) => + invoke("cancel_stats_reads", { generation }), remember: (generation: number, text: string) => invoke("remember_memory", { generation, text }), search: (generation: number, query: string) => diff --git a/docs/en/development/desktop-statistics.md b/docs/en/development/desktop-statistics.md new file mode 100644 index 0000000000..d99a45bff4 --- /dev/null +++ b/docs/en/development/desktop-statistics.md @@ -0,0 +1,35 @@ +--- +title: "Desktop Scope statistics" +description: "Exact-Scope native statistics, isolation and qualification." +--- + +# Desktop Scope statistics + +Desktop consumes `POST /v1/stats` through the native `get_stats` command. The +renderer supplies only the active generation and `today`, `7d` or `30d`; native +code obtains the current Scope and constructs an exact, single-Scope selection. +There is no renderer-supplied endpoint, Scope list or all/subtree mode. + +The connection must have a matching qualified operation profile. Each read +rechecks identity using the existing connection policy, preserves Server +authorization failures, and applies the existing transport timeout and 1 MiB +response bound. A denial never retries against a broader selection. Responses +must retain the requested selection, Scope IDs and period. Aggregate and per-Scope +recall estimators may be null, as permitted by the public contract. + +Statistics use their own cancellation channel. A replacement statistics read, +`cancel_stats_reads`, or a generation change cancels pending statistics; Memory +read cancellation does not cancel them. Submitted Memory writes retain their +existing original-target behavior. + +The operation catalog and reachable wire models come from +`pnpm --dir desktop generate`; IPC declarations come from the Rust `export_ipc` +example. The native exact-selection projection reuses concrete generated field +types while preserving the nullable estimator and prohibiting broader selections. +Neither Server APIs nor historical qualification records are changed. + +See [the qualification record](../../../desktop/VALIDATION.md#current-scope-statistics-qualification) +for the tested Server/contract/wheel identities and actual validation limits. +Synthetic tests establish local handling; the built-wheel harness separately +establishes the observed SQLite protocol behavior. Installed Windows/macOS page +acceptance remains a platform gate. diff --git a/docs/zh/development/desktop-statistics.md b/docs/zh/development/desktop-statistics.md new file mode 100644 index 0000000000..49a8c90a65 --- /dev/null +++ b/docs/zh/development/desktop-statistics.md @@ -0,0 +1,28 @@ +--- +title: "桌面范围统计" +description: "精确范围的原生统计、隔离与兼容资格。" +--- + +# 桌面范围统计 + +桌面通过原生 `get_stats` 命令调用 `POST /v1/stats`。渲染层只提供当前 +generation 和 `today`、`7d`、`30d` 周期;原生层取得当前 Scope,构造精确、 +单范围的 selection。渲染层不能传入地址、范围列表或 all/subtree 模式。 + +连接必须具有匹配合同且已验证该操作的兼容资格。每次读取沿用连接边界的身份 +复核策略,保留 Server 的授权失败结果,并遵守现有传输超时与 1 MiB 响应上限。 +权限拒绝后不会扩大查询范围。响应的 selection、范围 ID 和周期必须与请求一致。 +按照公共合同,合计及各范围的召回估算器均可为空。 + +统计有独立的取消通道。新的统计读取、`cancel_stats_reads` 或 generation +变化都会取消待完成统计;Memory 读取的取消不会影响统计。已提交的 Memory +写入继续绑定原目标。 + +操作目录和可达 wire 类型通过 `pnpm --dir desktop generate` 生成;IPC 声明通过 +Rust `export_ipc` example 生成。原生的精确选择投影复用具体生成字段类型,同时 +保留可空估算器并禁止扩大 selection。不改变 Server API 或历史资格记录。 + +已验证的 Server、合同、wheel 标识和实际验证边界见 +[资格记录](../../../desktop/VALIDATION.md#current-scope-statistics-qualification)。 +模拟测试验证本地处理;构建 wheel 的独立夹具另行验证实际 SQLite 协议行为。 +Windows/macOS 安装后的页面验收仍由平台 gate 负责。 From 34c61c5f3237006c3bd1f3cf6f56c0c11adad999 Mon Sep 17 00:00:00 2001 From: WuShang-d <2915293869@qq.com> Date: Sat, 24 Oct 2026 13:50:33 +0800 Subject: [PATCH 2/4] feat(desktop): visualize current-Scope inventory and usage --- README.md | 1 + README_CN.md | 1 + desktop/README.md | 13 +- desktop/README.zh.md | 11 +- desktop/ui/src/app/App.tsx | 23 +- desktop/ui/src/app/Statistics.tsx | 502 ++++++++++++++++++++++ desktop/ui/src/app/statistics-messages.ts | 152 +++++++ desktop/ui/src/app/style.css | 69 +++ desktop/ui/tests/statistics.test.tsx | 326 ++++++++++++++ docs/en/development/desktop-statistics.md | 26 ++ docs/zh/development/desktop-statistics.md | 19 + 11 files changed, 1134 insertions(+), 9 deletions(-) create mode 100644 desktop/ui/src/app/Statistics.tsx create mode 100644 desktop/ui/src/app/statistics-messages.ts create mode 100644 desktop/ui/tests/statistics.test.tsx diff --git a/README.md b/README.md index 14f2c2ff22..cc67ba9560 100644 --- a/README.md +++ b/README.md @@ -318,6 +318,7 @@ Coding and memory evaluations, performance benchmarks, and Skill regressions liv ## Learn more +- [Desktop preview: exact-Scope inventory and usage](desktop/README.md#current-scope-inventory-and-usage) - [Get started](https://powercontext.oceanbase.io/en/docs/get-started/quickstart/) - [Connect Agents](https://powercontext.oceanbase.io/en/docs/integrations/) - [Manage context](https://powercontext.oceanbase.io/en/docs/workflows/) diff --git a/README_CN.md b/README_CN.md index 12c36b0d3a..fbebeb3f72 100644 --- a/README_CN.md +++ b/README_CN.md @@ -261,6 +261,7 @@ make test ## 进一步了解 +- [桌面预览:精确范围的存量与用量](desktop/README.zh.md#当前范围的存量与用量) - [开始使用](https://powercontext.oceanbase.io/zh/docs/get-started/quickstart/) - [接入 Agent](https://powercontext.oceanbase.io/zh/docs/integrations/) - [管理上下文](https://powercontext.oceanbase.io/zh/docs/workflows/) diff --git a/desktop/README.md b/desktop/README.md index a661c77d41..7cd3c5821a 100644 --- a/desktop/README.md +++ b/desktop/README.md @@ -65,9 +65,20 @@ Search uses FTS in the selected Scope and returns at most 10 matches. This is no A successful save that returns no memory record is reported as an operation success without inventing a reference. A timeout or interrupted dispatched write is **unknown**, not a safe invitation to retry: inspect the original Server/Scope before deciding whether to submit again. Identical text alone cannot identify that operation. Desktop does not automatically replay writes or keep an offline queue. Switching context hides old results while retaining minimal original-operation metadata for this session. See [validation guide](VALIDATION.md) for tested behavior and qualification gaps. +## Current-Scope inventory and usage + +In **Overview**, explicitly activate a qualified connection, select an exact Scope, +choose **Today**, **Last 7 days** or **Last 30 days**, then press **Load statistics**. +The view displays the Server timestamp, Scope coverage, inventory snapshot, +recorded UTC model usage and recall token estimates. Missing token counts remain +**Unknown**; estimates are not billing savings or quality scores. Every chart has +an equivalent table and keyboard-scrollable region. A statistics error does not +disable independent Memory actions. See the [developer contract](../docs/en/development/desktop-statistics.md) +and [qualification evidence](VALIDATION.md#current-scope-statistics-qualification). + ## Contracts and resources -`pnpm --dir desktop generate` derives TypeScript schemas, the reviewed ten-operation manifest, and the Rust wire models from `openapi/powercontext.yaml`; `generate:check` detects drift, including a normalized contract SHA-256. The OpenAPI Generator CLI version is pinned in `desktop/openapitools.json`, and the limited Rust customization lives in `desktop/scripts/openapi-generator/rust/`. Generated `src-tauri/src/transport/wire.rs` must not be edited by hand: after changing the contract or generator template, run `pnpm --dir desktop generate`, then run `pnpm --dir desktop generate:check` in CI. Typed Rust adapters consume that generated manifest and generated wire schemas. No public route is independently handwritten in the application. The manifest is not an IPC permission grant. +`pnpm --dir desktop generate` derives TypeScript schemas, the reviewed eleven-operation manifest, and the Rust wire models from `openapi/powercontext.yaml`; `generate:check` detects drift, including a normalized contract SHA-256. The OpenAPI Generator CLI version is pinned in `desktop/openapitools.json`, and the limited Rust customization lives in `desktop/scripts/openapi-generator/rust/`. Generated `src-tauri/src/transport/wire.rs` must not be edited by hand: after changing the contract or generator template, run `pnpm --dir desktop generate`, then run `pnpm --dir desktop generate:check` in CI. Typed Rust adapters consume that generated manifest and generated wire schemas. No public route is independently handwritten in the application. The manifest is not an IPC permission grant. `cargo run --locked --manifest-path desktop/src-tauri/Cargo.toml --example export_ipc` derives the TypeScript IPC request/receipt/error types from Rust. `ipc:check` verifies them. IPC exposes typed connection, Scope, memory and diagnostic operations to the main window only; no generic fetch, shell, file, database or secret-read command is granted. diff --git a/desktop/README.zh.md b/desktop/README.zh.md index 40fd44df3e..387d03a262 100644 --- a/desktop/README.zh.md +++ b/desktop/README.zh.md @@ -45,7 +45,7 @@ pnpm --dir desktop desktop:dev 工程边界: -- OpenAPI 自动派生的 TypeScript schema 和十项 operation 映射,包含契约摘要及漂移检查。 +- OpenAPI 自动派生的 TypeScript schema 和十一项 operation 映射,包含契约摘要及漂移检查。 - `desktop/openapitools.json` 固定 OpenAPI Generator CLI 版本,Rust 模板位于 `desktop/scripts/openapi-generator/rust/`。修改 OpenAPI 合同或模板后,在仓库根目录执行 `pnpm --dir desktop generate`,不要手动编辑生成的 `src-tauri/src/transport/wire.rs`;CI 通过 `pnpm --dir desktop generate:check` 检查生成结果是否漂移。 - Rust 自动派生的 IPC 类型与安全错误;凭据只写请求必须显式选择持久化或仅会话,成功回执只含存储模式,不含秘密或凭据标识。 - Windows Credential Manager 原生适配器;保存失败直接报错,只有显式选择才采用仅会话内存。秘密类型不支持序列化,不实现 Debug。 @@ -86,6 +86,15 @@ Desktop 在每次诊断前检查固定路径和摘要,然后只允许 `service [验证说明](VALIDATION.md)列出可复现的检查方式和待完成的平台、产品验收项。 +## 当前范围的存量与用量 + +在“总览”中显式启用已验证兼容的连接、选择精确范围和“今天/最近 7 天/最近 +30 天”后,点击“加载统计”。页面展示 Server 时间、范围覆盖、存量快照、已记录 +的 UTC 模型用量和召回 token 估算。缺失 token 数保持“未知”;估算不代表账单 +节省或质量提升。每个图表均有等价表格,滚动区域可通过键盘聚焦。统计错误不禁用 +独立的 Memory 操作。详见[开发契约](../docs/zh/development/desktop-statistics.md)和 +[资格证据](VALIDATION.md#current-scope-statistics-qualification)。 + ## 远程安装包验收 Windows GitHub Actions 会构建未签名安装包,安装到临时中文路径,并通过匹配且验证 Microsoft 签名的 WebDriver 操作实际安装的 WebView2 页面。测试使用独立 SQLite Server,覆盖启用连接、精确范围选择、中文多行笔记保存、全文查找、精确阅读,以及正文和引用复制后的粘贴核对。Server 使用合成数据,测试结束后清理。 diff --git a/desktop/ui/src/app/App.tsx b/desktop/ui/src/app/App.tsx index 6fc3e89e4a..fd77a17e8d 100644 --- a/desktop/ui/src/app/App.tsx +++ b/desktop/ui/src/app/App.tsx @@ -26,6 +26,7 @@ import settingsIcon from "../assets/settings.svg"; import { TopBar } from "./TopBar"; import { Overview } from "./Overview"; +import { Statistics } from "./Statistics"; import { MemoryWorkspace } from "./MemoryWorkspace"; import { Diagnostics } from "./Diagnostics"; import { Connections } from "./Connections"; @@ -194,13 +195,21 @@ export function App() { )} {page === "home" && ( - void refresh()} - /> +
+ void refresh()} + /> + +
)} {page === "memories" && ( void; +}; +type Cell = string | number | null; +const operations = ["generation", "embedding"] as const; +const metrics = ["requests", "input_tokens", "output_tokens"] as const; +const recallFields = [ + "preparations", + "ready_preparations", + "comparable_preparations", + "baseline_tokens", + "recalled_tokens", + "token_reduction", +] as const; + +/** Load statistics explicitly, using the native generation to isolate private results. */ +export function Statistics({ state, language, onState }: Props) { + const t = statisticsMessages[language]; + const titleId = useId(); + const generation = state?.active?.generation; + const active = state?.active; + const enabled = + !!active?.scope && + active.report.compatibilityVerified && + active.report.supportedOperations.includes("get_stats"); + const [period, setPeriod] = useState("30d"); + const [metric, setMetric] = useState<(typeof metrics)[number]>("requests"); + const [result, setResult] = useState<{ + generation: number; + period: StatsPeriod; + data: ExactScopeStatistics; + } | null>(null); + const [error, setError] = useState(null); + const [busy, setBusy] = useState(false); + const sequence = useRef(0); + const running = useRef(false); + const pendingCancel = useRef(Promise.resolve()); + const context = useRef({ generation, enabled, period }); + context.current = { generation, enabled, period }; + useEffect(() => { + setResult(null); + setError(null); + setBusy(false); + running.current = false; + return () => { + sequence.current++; + if (generation !== undefined) { + pendingCancel.current = pendingCancel.current + .then(() => desktopApi.cancelStats(generation)) + .catch(() => {}); + } + }; + }, [generation]); + function changePeriod(next: StatsPeriod) { + sequence.current++; + running.current = false; + setPeriod(next); + setResult(null); + setError(null); + setBusy(false); + if (generation !== undefined) { + pendingCancel.current = pendingCancel.current + .then(() => desktopApi.cancelStats(generation)) + .catch(() => {}); + } + } + async function load() { + if (!enabled || generation === undefined || running.current) return; + const ticket = ++sequence.current; + const isCurrent = () => + ticket === sequence.current && + context.current.generation === generation && + context.current.enabled && + context.current.period === period; + running.current = true; + setBusy(true); + setResult(null); + setError(null); + try { + await pendingCancel.current; + if (!isCurrent()) return; + const data = await desktopApi.stats(generation, period); + if (isCurrent()) setResult({ generation, period, data }); + } catch (failure) { + if (isCurrent()) { + setError(failure); + try { + const next = await desktopApi.state(); + if (isCurrent()) onState(next); + } catch { + /* Keep the operation's safe error. */ + } + } + } finally { + if (ticket === sequence.current) { + running.current = false; + setBusy(false); + } + } + } + const data = + result && + enabled && + result.generation === generation && + result.period === period + ? result.data + : null; + return ( +
+

{t.title}

+ {!active?.scope ? ( +

{t.noScope}

+ ) : !enabled ? ( +

{t.unavailable}

+ ) : ( + <> +

+ {t.scope}: {active.scope.title}{" "} + {active.scope.scope_id} +

+
+ + +
+ {busy &&

{t.loading}

} + {error != null && ( +

{connectionError(error, language)}

+ )} + {!busy && error == null && !data &&

{t.initial}

} + {data && ( + + )} + + )} +
+ ); +} + +function StatisticsData({ + data, + language, + metric, + onMetric, +}: { + data: ExactScopeStatistics; + language: Language; + metric: (typeof metrics)[number]; + onMetric: (metric: (typeof metrics)[number]) => void; +}) { + const t = statisticsMessages[language]; + const { inventory, usage, recall } = data; + const inventoryRows: Cell[][] = [ + [t.sources, inventory.sources.total], + [t.processed, inventory.sources.memory_processed], + [t.pendingSources, inventory.sources.memory_pending], + [t.artifacts, inventory.artifacts.total], + [t.candidates, inventory.candidates.total], + [t.pending, inventory.candidates.pending], + [t.approved, inventory.candidates.approved], + [t.rejected, inventory.candidates.rejected], + [t.memories, inventory.memory.entries.total], + [t.active, inventory.memory.entries.active], + [t.inactive, inventory.memory.entries.inactive], + ]; + const table = (caption: string, columns: string[], rows: Cell[][]) => ( + + ); + const usageColumns = [t.operation, ...metrics.map((key) => t[key])]; + return ( + <> +
+
+
{t.asOf}
+
+ +
+
+
+
{t.coverage}
+
+ {data.scope_ids.length} / 1 ·{" "} + {data.scope_ids.join(", ")} +
+
+
+
{t.dates}
+
+ {usage.period.start_date} – {usage.period.end_date} ·{" "} + {usage.period.timezone} +
+
+
+
+

{t.inventory}

+

{t.snapshot}

+ + {table(t.inventory, [t.item, t.count], inventoryRows)} + {table( + t.artifactFamilies, + [t.family, t.total], + inventory.artifacts.by_family.map((row) => [row.family, row.total]), + )} + {table( + t.candidateFamilies, + [t.family, t.total, t.pending, t.approved, t.rejected], + inventory.candidates.by_family.map((row) => [ + row.family, + row.total, + row.pending, + row.approved, + row.rejected, + ]), + )} + {table( + t.memoryKinds, + [t.family, t.total, t.active, t.inactive], + inventory.memory.entries.by_kind.map((row) => [ + row.kind, + row.total, + row.active, + row.inactive, + ]), + )} +
+
+

{t.usage}

+

{t.usageNote}

+ {table( + t.totals, + usageColumns, + operations.map((operation) => [ + t[operation], + ...metrics.map((key) => usage.totals[operation][key]), + ]), + )} + + + operations.map((operation): [string, number | null] => [ + `${day.date} · ${t[operation]}`, + day[operation][metric], + ]), + )} + language={language} + /> + {table( + t.daily, + [t.date, ...usageColumns], + usage.daily.flatMap((day) => + operations.map((operation) => [ + day.date, + t[operation], + ...metrics.map((key) => day[operation][key]), + ]), + ), + )} + {table( + t.purposes, + [t.purpose, ...usageColumns], + usage.by_purpose.flatMap((row) => + operations.map((operation) => [ + row.purpose, + t[operation], + ...metrics.map((key) => row[operation][key]), + ]), + ), + )} +
+
+

{t.recall}

+

{t.recallNote}

+

+ {t.estimator}:{" "} + {recall.estimator + ? `${recall.estimator.estimator_id} / ${recall.estimator.version}` + : t.unknown} +

+ {table( + t.recall, + [t.item, t.count], + recallFields.map((key) => [t[key], recall.totals[key]]), + )} + [day.date, day.token_reduction])} + language={language} + /> + {table( + t.recallDaily, + [t.date, ...recallFields.map((key) => t[key])], + recall.daily.map((day) => [ + day.date, + ...recallFields.map((key) => day[key]), + ]), + )} +
+ + ); +} + +function DataTable({ + caption, + columns, + rows, + language, +}: { + caption: string; + columns: string[]; + rows: Cell[][]; + language: Language; +}) { + const t = statisticsMessages[language]; + const format = new Intl.NumberFormat(language); + return ( +
+ + + + + {columns.map((column) => ( + + ))} + + + + {rows.length ? ( + rows.map((row, index) => ( + + {row.map((cell, column) => + column === 0 ? ( + + ) : ( + + ), + )} + + )) + ) : ( + + + + )} + +
{caption}
+ {column} +
+ {cell} + + {cell === null + ? t.unknown + : typeof cell === "number" + ? format.format(cell) + : cell} +
{t.noRows}
+
+ ); +} + +function Bars({ + title, + rows, + language, +}: { + title: string; + rows: [string, number | null][]; + language: Language; +}) { + const t = statisticsMessages[language]; + if (!rows.length) return null; + const values = rows.flatMap(([, value]) => (value === null ? [] : [value])); + const minimum = Math.min(0, ...values); + const maximum = Math.max(0, ...values); + const position = (value: number) => + 220 + (240 * (value - minimum)) / (maximum - minimum || 1); + const zero = position(0); + const format = new Intl.NumberFormat(language); + return ( +
+ + {title} + {t.chartNote} + + {rows.map(([label, value], index) => ( + + + {label.length > 30 ? `${label.slice(0, 29)}…` : label} + + {value !== null && ( + + )} + + {value === null ? t.unknown : format.format(value)} + + + ))} + +
+ ); +} diff --git a/desktop/ui/src/app/statistics-messages.ts b/desktop/ui/src/app/statistics-messages.ts new file mode 100644 index 0000000000..b8393bc11e --- /dev/null +++ b/desktop/ui/src/app/statistics-messages.ts @@ -0,0 +1,152 @@ +/* + * Copyright (c) 2026 OceanBase. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export const statisticsMessages = { + en: { + title: "Current-Scope inventory and usage", + noScope: + "Activate a connection and select an exact Scope to view statistics.", + unavailable: + "Statistics are not qualified for this connection. Memory operations remain independent.", + initial: "Statistics have not been loaded for this Scope and period.", + refresh: "Load statistics", + loading: "Loading statistics…", + period: "Usage period (UTC)", + today: "Today", + "7d": "Last 7 days", + "30d": "Last 30 days", + scope: "Exact Scope", + coverage: "Returned Scope coverage", + asOf: "As of", + dates: "Inclusive UTC dates", + unknown: "Unknown", + inventory: "Inventory snapshot", + snapshot: + "Current counts at the Server timestamp. These are independent categories, not a historical trend or a sum.", + item: "Item", + count: "Count", + sources: "Sources", + processed: "Sources processed for Memory", + pendingSources: "Sources pending Memory processing", + artifacts: "Artifacts", + candidates: "Candidates", + pending: "Pending candidates", + approved: "Approved candidates", + rejected: "Rejected candidates", + memories: "Memory entries", + active: "Active Memory entries", + inactive: "Inactive Memory entries", + artifactFamilies: "Artifacts by family", + candidateFamilies: "Candidates by family", + memoryKinds: "Memory entries by kind", + family: "Family / kind", + total: "Total", + usage: "Model usage", + usageNote: + "Only calls recorded by the Server are included. Missing provider token counts remain unknown; recorded zero values remain zero. These are not billing totals.", + operation: "Model operation", + generation: "Generation", + embedding: "Embedding", + requests: "Requests", + input_tokens: "Input tokens", + output_tokens: "Output tokens", + totals: "Recorded model usage totals", + daily: "Daily model usage (UTC)", + purposes: "Recorded usage by purpose", + purpose: "Purpose", + date: "UTC date", + metric: "Daily chart metric", + recall: "Recall token estimates", + recallNote: + "Estimates compare the baseline with recalled content only for comparable preparations. They do not measure billing savings or answer quality. Negative reduction means more estimated tokens were recalled.", + estimator: "Estimator / version", + preparations: "Preparations", + ready_preparations: "Ready preparations", + comparable_preparations: "Comparable preparations", + baseline_tokens: "Baseline tokens (estimate)", + recalled_tokens: "Recalled tokens (estimate)", + token_reduction: "Token reduction (estimate)", + recallDaily: "Daily recall estimates (UTC)", + noRows: "No rows were returned. No values have been inferred.", + chartNote: "Equivalent values are in the following data table.", + }, + zh: { + title: "当前范围的存量与用量", + noScope: "请启用连接并选择精确范围后查看统计。", + unavailable: "此连接尚无统计操作的兼容资格。不影响独立的 Memory 操作。", + initial: "尚未加载此范围及周期的统计。", + refresh: "加载统计", + loading: "正在加载统计…", + period: "用量周期(UTC)", + today: "今天", + "7d": "最近 7 天", + "30d": "最近 30 天", + scope: "精确范围", + coverage: "返回的范围覆盖", + asOf: "统计时间", + dates: "UTC 起止日期(含首尾)", + unknown: "未知", + inventory: "存量快照", + snapshot: + "Server 时间点上的当前数量。各类别独立统计,不是历史趋势,也不应相加。", + item: "项目", + count: "数量", + sources: "来源", + processed: "已完成 Memory 处理的来源", + pendingSources: "待处理 Memory 的来源", + artifacts: "制品", + candidates: "候选", + pending: "待决候选", + approved: "已批准候选", + rejected: "已拒绝候选", + memories: "记忆条目", + active: "有效记忆条目", + inactive: "无效记忆条目", + artifactFamilies: "按类型统计制品", + candidateFamilies: "按类型统计候选", + memoryKinds: "按种类统计记忆条目", + family: "类型/种类", + total: "总数", + usage: "模型用量", + usageNote: + "仅包含 Server 已记录的调用。提供商未报告的 token 数保持未知,已记录的零值按零显示。这些数据不是账单总量。", + operation: "模型操作", + generation: "生成", + embedding: "嵌入", + requests: "请求次数", + input_tokens: "输入 token", + output_tokens: "输出 token", + totals: "已记录的模型用量合计", + daily: "每日模型用量(UTC)", + purposes: "按用途统计已记录用量", + purpose: "用途", + date: "UTC 日期", + metric: "每日图表指标", + recall: "召回 token 估算", + recallNote: + "仅在可比较的上下文准备中比较基线与召回内容。估算不代表账单节省或答案质量;减少量为负时,表示召回内容的估算 token 更多。", + estimator: "估算器/版本", + preparations: "准备次数", + ready_preparations: "就绪准备次数", + comparable_preparations: "可比较准备次数", + baseline_tokens: "基线 token(估算)", + recalled_tokens: "召回 token(估算)", + token_reduction: "token 减少量(估算)", + recallDaily: "每日召回估算(UTC)", + noRows: "Server 未返回数据行,未据此推断任何数值。", + chartNote: "等价数值见下方数据表。", + }, +}; diff --git a/desktop/ui/src/app/style.css b/desktop/ui/src/app/style.css index a23d56477a..e57137ba47 100644 --- a/desktop/ui/src/app/style.css +++ b/desktop/ui/src/app/style.css @@ -792,6 +792,75 @@ dd { gap: 16px; align-items: start; } +.statistics { + min-width: 0; +} +.statistics-controls { + flex-wrap: wrap; +} +.statistics .fact-grid { + grid-template-columns: repeat(auto-fit, minmax(min(100%, 250px), 1fr)); +} +.statistics .fact { + display: block; +} +.statistics .fact dd { + margin: 6px 0 0; + text-align: left; + overflow-wrap: anywhere; +} +.statistics-table, +.statistics-chart { + max-width: 100%; + overflow: auto; + border: 1px solid var(--border); + border-radius: 8px; +} +.statistics-table table { + width: 100%; + border-collapse: collapse; +} +.statistics-table caption { + text-align: left; + padding: 12px; + font-weight: 600; +} +.statistics-table th, +.statistics-table td { + padding: 8px 12px; + text-align: left; + vertical-align: top; + border-top: 1px solid var(--border); +} +.statistics-table td { + font-variant-numeric: tabular-nums; + white-space: nowrap; +} +.statistics-table th { + white-space: nowrap; +} +.statistics-table td[colspan] { + white-space: normal; +} +.statistics-chart { + max-height: 340px; + padding: 12px; +} +.statistics-chart svg { + width: 100%; + min-width: 560px; + display: block; +} +.statistics-chart text { + fill: var(--text); + font-size: 12px; +} +.statistics-chart rect { + fill: var(--accent); +} +.statistics-zero { + stroke: var(--border); +} .memory-hits { list-style: none; padding: 0; diff --git a/desktop/ui/tests/statistics.test.tsx b/desktop/ui/tests/statistics.test.tsx new file mode 100644 index 0000000000..1f0112f25b --- /dev/null +++ b/desktop/ui/tests/statistics.test.tsx @@ -0,0 +1,326 @@ +/* + * Copyright (c) 2026 OceanBase. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { afterEach, expect, test, vi } from "vitest"; +import { act, cleanup, render, screen, within } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { Statistics } from "../src/app/Statistics"; +import { MemoryWorkspace } from "../src/app/MemoryWorkspace"; +import { desktopApi } from "../src/shared/ipc"; +import type { DesktopState, ExactScopeStatistics } from "../src/generated/ipc"; + +vi.mock("../src/shared/ipc", () => ({ + desktopApi: { + stats: vi.fn(), + state: vi.fn(), + cancelStats: vi.fn().mockResolvedValue(undefined), + cancelMemory: vi.fn().mockResolvedValue(undefined), + }, +})); +afterEach(() => { + cleanup(); + vi.clearAllMocks(); +}); + +const state: DesktopState = { + generation: 1, + profiles: [], + reports: [], + compatibilityProfiles: [], + pendingCredentialCleanup: 0, + lastWrite: null, + active: { + connectionId: "connection-a", + generation: 1, + scope: { + scope_id: "scope-a", + title: "Scope A", + summary: "", + context_references: [], + external_references: [], + version: 1, + }, + report: { + connectionId: "connection-a", + revision: 1, + checkedAt: 1, + compatibilityVerified: true, + anonymousAccess: true, + supportedOperations: ["get_stats"], + liveness: { value: null, error: null }, + readiness: { value: null, error: null }, + identity: { value: null, error: null }, + capabilities: { value: null, error: null }, + }, + }, +}; +function statistics(): ExactScopeStatistics { + const zero = { requests: 0, input_tokens: 0, output_tokens: 0 }; + const period = { + preset: "30d" as const, + start_date: "2026-09-25", + end_date: "2026-10-24", + timezone: "UTC" as const, + }; + const usage = { + period, + totals: { generation: { ...zero }, embedding: { ...zero } }, + daily: [ + { + date: "2026-10-24", + generation: { ...zero }, + embedding: { ...zero }, + by_purpose: [], + }, + ], + by_purpose: [], + }; + const recallTotals = { + preparations: 0, + ready_preparations: 0, + comparable_preparations: 0, + baseline_tokens: 0, + recalled_tokens: 0, + token_reduction: 0, + }; + return { + selection: { mode: "exact", scope_ids: ["scope-a"] }, + scope_ids: ["scope-a"], + as_of: "2026-10-24T00:01:00Z", + inventory: { + sources: { total: 0, memory_processed: 0, memory_pending: 0 }, + artifacts: { total: 0, by_family: [] }, + candidates: { + total: 0, + pending: 0, + approved: 0, + rejected: 0, + by_family: [], + }, + memory: { entries: { total: 0, active: 0, inactive: 0, by_kind: [] } }, + }, + usage, + recall: { + period, + estimator: null, + totals: { ...recallTotals }, + daily: [{ date: "2026-10-24", ...recallTotals }], + }, + by_scope: [], + }; +} +const props = { state, language: "en" as const, onState: vi.fn() }; + +test("statistics require an exact Scope and operation qualification without fabricating zero counts", () => { + const view = render(); + expect(screen.getByText(/Activate a connection/)).toBeTruthy(); + expect(screen.queryByRole("table")).toBeNull(); + view.rerender( + , + ); + expect(screen.getByText(/Statistics are not qualified/)).toBeTruthy(); + expect(screen.queryByRole("button", { name: "Load statistics" })).toBeNull(); + expect(desktopApi.stats).not.toHaveBeenCalled(); +}); + +test.each(["en", "zh"] as const)( + "empty inventory preserves real zeros, UTC dates and chart tables in %s", + async (language) => { + const user = userEvent.setup(); + vi.mocked(desktopApi.stats).mockResolvedValue(statistics()); + render(); + expect(screen.queryByRole("table")).toBeNull(); + await user.click( + screen.getByRole("button", { + name: language === "en" ? "Load statistics" : "加载统计", + }), + ); + expect(desktopApi.stats).toHaveBeenCalledWith(1, "30d"); + expect(await screen.findByText("2026-10-24T00:01:00Z")).toBeTruthy(); + const inventory = screen.getByRole("table", { + name: language === "en" ? "Inventory snapshot" : "存量快照", + }); + const source = within(inventory).getAllByRole("row")[1]; + expect(within(source).getByRole("cell").textContent).toBe("0"); + const daily = + language === "en" ? "Daily model usage (UTC)" : "每日模型用量(UTC)"; + expect(screen.getByRole("table", { name: daily })).toBeTruthy(); + expect( + screen.getByRole("img", { + name: new RegExp(daily.replace(/[()]/g, "\\$&")), + }), + ).toBeTruthy(); + }, +); + +test("partial token reports stay unknown and signed recall estimates remain signed", async () => { + const user = userEvent.setup(); + const data = statistics(); + data.usage.totals.generation = { + requests: 2, + input_tokens: null, + output_tokens: 9, + }; + data.usage.daily[0].generation = { ...data.usage.totals.generation }; + data.recall.totals = { + preparations: 3, + ready_preparations: 2, + comparable_preparations: 1, + baseline_tokens: 3, + recalled_tokens: 8, + token_reduction: -5, + }; + data.recall.daily[0] = { date: "2026-10-24", ...data.recall.totals }; + vi.mocked(desktopApi.stats).mockResolvedValue(data); + render(); + await user.click(screen.getByRole("button", { name: "Load statistics" })); + const usage = await screen.findByRole("table", { + name: "Recorded model usage totals", + }); + const row = within(usage).getByRole("row", { + name: "Generation 2 Unknown 9", + }); + expect(within(row).getByRole("cell", { name: "Unknown" })).toBeTruthy(); + const recall = screen.getByRole("table", { name: "Recall token estimates" }); + expect( + screen.getByRole("img", { + name: "Daily recall estimates (UTC) · Token reduction (estimate)", + }), + ).toBeTruthy(); + expect( + within(recall).getByRole("row", { name: "Token reduction (estimate) -5" }), + ).toBeTruthy(); + await user.selectOptions( + screen.getByLabelText("Daily chart metric"), + "input_tokens", + ); + expect( + screen.getByRole("img", { name: "Daily model usage (UTC) · Input tokens" }) + .textContent, + ).toContain("Unknown"); +}); + +test.each(["scope", "connection"])( + "a %s switch hides private statistics and ignores a late response", + async (kind) => { + const user = userEvent.setup(); + let finish!: (value: ExactScopeStatistics) => void; + vi.mocked(desktopApi.stats) + .mockResolvedValueOnce(statistics()) + .mockImplementationOnce( + () => + new Promise((resolve) => { + finish = resolve; + }), + ); + const view = render(); + await user.click(screen.getByRole("button", { name: "Load statistics" })); + expect( + screen.getByRole("table", { name: "Inventory snapshot" }), + ).toBeTruthy(); + await user.click(screen.getByRole("button", { name: "Load statistics" })); + const next: DesktopState = { + ...state, + generation: 2, + active: { + ...state.active!, + generation: 2, + connectionId: kind === "connection" ? "connection-b" : "connection-a", + scope: { + ...state.active!.scope!, + scope_id: "scope-b", + title: "Scope B", + }, + }, + }; + view.rerender(); + await act(async () => { + finish(statistics()); + }); + expect(screen.queryByText("2026-10-24T00:01:00Z")).toBeNull(); + expect(screen.queryByRole("table")).toBeNull(); + expect(desktopApi.cancelStats).toHaveBeenCalledWith(1); + expect(screen.getByText(/Statistics have not been loaded/)).toBeTruthy(); + }, +); + +test("changing periods ignores late failure and refreshes only the selected period", async () => { + const user = userEvent.setup(); + let fail!: (error: unknown) => void; + const today = statistics(); + today.usage.period = { + ...today.usage.period, + preset: "today", + start_date: "2026-10-24", + }; + today.recall.period = { ...today.usage.period }; + vi.mocked(desktopApi.stats) + .mockImplementationOnce( + () => + new Promise((_, reject) => { + fail = reject; + }), + ) + .mockResolvedValueOnce(today); + render(); + await user.click(screen.getByRole("button", { name: "Load statistics" })); + await user.selectOptions( + screen.getByLabelText("Usage period (UTC)"), + "today", + ); + await act(async () => { + fail({ code: "forbidden" }); + }); + expect(screen.queryByRole("alert")).toBeNull(); + await user.click(screen.getByRole("button", { name: "Load statistics" })); + expect(desktopApi.stats).toHaveBeenLastCalledWith(1, "today"); +}); + +test.each(["forbidden", "network"])( + "a %s statistics failure clears private data without disabling Memory or widening selection", + async (code) => { + const user = userEvent.setup(); + vi.mocked(desktopApi.stats) + .mockResolvedValueOnce(statistics()) + .mockRejectedValueOnce({ code }); + vi.mocked(desktopApi.state).mockResolvedValue(state); + render( + <> + + + , + ); + await user.click(screen.getByRole("button", { name: "Load statistics" })); + await user.click(screen.getByRole("button", { name: "Load statistics" })); + expect(await screen.findByRole("alert")).toBeTruthy(); + expect(screen.queryByRole("table")).toBeNull(); + expect(desktopApi.stats).toHaveBeenCalledTimes(2); + await user.type(screen.getByLabelText("Memory text"), "still available"); + expect( + (screen.getByRole("button", { name: "Save" }) as HTMLButtonElement) + .disabled, + ).toBe(false); + }, +); diff --git a/docs/en/development/desktop-statistics.md b/docs/en/development/desktop-statistics.md index d99a45bff4..5c5a64f834 100644 --- a/docs/en/development/desktop-statistics.md +++ b/docs/en/development/desktop-statistics.md @@ -5,6 +5,32 @@ description: "Exact-Scope native statistics, isolation and qualification." # Desktop Scope statistics +## Inventory and usage view + +Overview contains the self-contained **Current-Scope inventory and usage** view. +Activate a qualified connection, select an exact Scope, choose a UTC usage period +and press **Load statistics**. Loading is explicit; opening the view does not +silently restore a connection or issue a statistics request. The shared shell's +Observation container can mount the same `Statistics` component with the current +state, language and state-refresh callback. + +The view displays the Server's `as_of`, exact returned Scope coverage and +inclusive UTC dates. Inventory is a current snapshot. Model request/input/output +token counts, purpose breakdowns and daily rows retain returned zero and null +values; null tokens display **Unknown**. The page does not fill missing rows or +infer complete provider reporting from recorded calls. Recall coverage includes +preparation, ready and comparable counts, estimator identity/version, baseline, +recalled tokens and signed reduction. These are estimates, not billing savings +or answer-quality measurements. + +Every chart has an equivalent table with captions and row/column headers. Tables +and charts have keyboard-focusable scroll regions for narrow layouts. Labels are +available in English and Chinese. Changing connection, Scope or period clears +the old view and invalidates late success/error responses. A statistics failure +does not disable independent Memory actions. + +## Native boundary + Desktop consumes `POST /v1/stats` through the native `get_stats` command. The renderer supplies only the active generation and `today`, `7d` or `30d`; native code obtains the current Scope and constructs an exact, single-Scope selection. diff --git a/docs/zh/development/desktop-statistics.md b/docs/zh/development/desktop-statistics.md index 49a8c90a65..f4c7fefe7b 100644 --- a/docs/zh/development/desktop-statistics.md +++ b/docs/zh/development/desktop-statistics.md @@ -5,6 +5,25 @@ description: "精确范围的原生统计、隔离与兼容资格。" # 桌面范围统计 +## 存量与用量视图 + +总览包含独立的“当前范围的存量与用量”视图。启用已验证兼容的连接、选择精确 +范围及 UTC 周期后,点击“加载统计”。加载是显式操作;打开视图不会自动恢复 +连接或发起统计请求。共享布局的 Observation 容器可以使用相同的 `Statistics` +组件,传入当前状态、语言及状态刷新回调。 + +视图显示 Server 返回的 `as_of`、精确范围覆盖及含首尾的 UTC 日期。存量是当前 +快照。模型请求、输入/输出 token、用途分组和每日数据保留返回的零值及空值; +空 token 显示“未知”。页面不补齐缺失行,也不从已记录调用推断提供商完整上报。 +召回覆盖展示准备、就绪及可比较次数、估算器及版本、基线、召回 token 和有符号的 +减少量。这些是估算,不是账单节省或答案质量指标。 + +每个图表都有带标题及行列头的等价表格。图表和表格的滚动区域可通过键盘聚焦, +适用于窄布局;文案支持中英文。切换连接、范围或周期会清除旧视图,并忽略迟到的 +成功或失败响应。统计失败不禁用独立的 Memory 操作。 + +## 原生边界 + 桌面通过原生 `get_stats` 命令调用 `POST /v1/stats`。渲染层只提供当前 generation 和 `today`、`7d`、`30d` 周期;原生层取得当前 Scope,构造精确、 单范围的 selection。渲染层不能传入地址、范围列表或 all/subtree 模式。 From bde99724042cdcfa81003ed71ac212ed7551a5df Mon Sep 17 00:00:00 2001 From: WuShang-d <2915293869@qq.com> Date: Sat, 24 Oct 2026 15:25:21 +0800 Subject: [PATCH 3/4] test(desktop): validate native statistics journeys and zoom --- desktop/VALIDATION.md | 29 ++- desktop/src-tauri/capabilities/main.json | 1 + desktop/src-tauri/tests/ipc.rs | 29 +++ desktop/tests/installed_fixture.py | 5 +- desktop/tests/installed_statistics.py | 277 ++++++++++++++++++++++ desktop/tests/installed_workflow.py | 15 +- docs/en/development/desktop-statistics.md | 6 +- docs/zh/development/desktop-statistics.md | 4 +- 8 files changed, 354 insertions(+), 12 deletions(-) create mode 100644 desktop/tests/installed_statistics.py diff --git a/desktop/VALIDATION.md b/desktop/VALIDATION.md index 06b04481fe..3264a2947e 100644 --- a/desktop/VALIDATION.md +++ b/desktop/VALIDATION.md @@ -22,7 +22,7 @@ cargo test --locked --manifest-path desktop/src-tauri/Cargo.toml ## Compatibility scope -[compatibility.json](src-tauri/src/connections/compatibility.json) preserves the recorded Server sources, normalized OpenAPI contract digests and qualification-wheel digests for the historical profiles below. The current contract is qualified by `sqlite-atomic-d9a78567-v1` below; ordinary builds require explicitly selecting it and checking the connection before business operations become available. The `sqlite-atomic-caf2a35a-v1`, `sqlite-atomic-683e2db0-v1` and `sqlite-atomic-cab054db-v1` records qualify their corresponding Server source deployments and do not match this bundled contract. Historical records are disabled in the selector, and saved unavailable selections remain visible until explicitly replaced. CI builds have their own artifact identities and do not change product qualification. +[compatibility.json](src-tauri/src/connections/compatibility.json) preserves the recorded Server sources, normalized OpenAPI contract digests and qualification-wheel digests for the historical profiles below. The current contract is qualified by `sqlite-stats-1da99747-v1` below; ordinary builds require explicitly selecting it and checking the connection before business operations become available. The `sqlite-atomic-caf2a35a-v1`, `sqlite-atomic-683e2db0-v1` and `sqlite-atomic-cab054db-v1` records qualify their corresponding Server source deployments and do not match this bundled contract. Historical records are disabled in the selector, and saved unavailable selections remain visible until explicitly replaced. CI builds have their own artifact identities and do not change product qualification. The real Server harness covers SQLite with anonymous loopback, static Bearer, HTTPS with explicit CA/base path, and injected-provider/enforced access. It exercises save/search/exact reads, identity changes, binding revocation, ambiguous writes without replay and Scope pagination. Installed UI checks cover repeated launches preserving the existing editor, late committed-save responses preserving a new Scope draft, explicit connection/Scope selection, clipboard copies, connection isolation, draft cancellation, byte limits and independent Server survival after Desktop exit. @@ -56,15 +56,34 @@ Synthetic native tests separately cover cancellation, changed Scope, independent Memory cancellation, response bounds, and invalid response selection/period. The harness reports are generated under `.artifacts/`; reproduce them with `fixture_qualification.py`, the `server_probe` example and `real_server.py`. -These results qualify the observed Server protocol; they do not establish the -statistics page's installed Windows/Intel/macOS UI, IME, accessibility, signing -or release acceptance. +These results qualify the observed Server protocol. + +The bundled macOS ARM64 debug app was also exercised against two isolated real +Servers on this host, using an isolated application identifier and an 800 × 600 +window. The build used Desktop `34c61c5f` plus the main-window zoom permission +in this PR; its executable SHA-256 is +`43d0d149d3194739171ffb70a57736280c14d2cd3d63fe47001c8f2b3381065d`. +Observed results include explicit activation after restart, empty inventory, +Chinese and English partial-token displays, clearing on Scope/connection changes, +and the second Server's independent one-entry inventory with zero usage. +Command+0 followed by five Command+= steps exercised 200% webview zoom, including +a successful statistics reload. The zoom IPC permission is restricted to the +trusted local main window. This was a bundled debug-app check, not installer, +IME, screen-reader, signing or complete accessibility acceptance. + +`installed_statistics.py` extends the Windows installed WebDriver journey with +independent real `/v1/stats` table comparisons, all three UTC periods, bilingual +unknown-token handling, and Scope/connection isolation. Its fixture and Python +checks ran locally; the Windows UI journey requires actual Windows execution. +Hosted workflows at `34c61c5f` required maintainer approval (`action_required`), +so that head had no Windows acceptance result. Intel and release gates remain +separate. ### Current Server protocol qualification #### Server d9a78567 protocol qualification -The bundled contract is qualified by `sqlite-atomic-d9a78567-v1`. The record identifies Server +The historical contract is qualified by `sqlite-atomic-d9a78567-v1`. The record identifies Server sources at `d9a785675dedb72cd72a386b346e590f4c067ac0` and normalized OpenAPI digest `a6758fd35296abefb55ab86b152584a369d5f2deedfee7a3f9822e16ad1edc25`. The qualification wheel is `powercontext-1.4.1.dev69+ga200d6094.d20261022-py3-none-any.whl`, diff --git a/desktop/src-tauri/capabilities/main.json b/desktop/src-tauri/capabilities/main.json index 0a725ab157..ff9081d00b 100644 --- a/desktop/src-tauri/capabilities/main.json +++ b/desktop/src-tauri/capabilities/main.json @@ -6,6 +6,7 @@ "main" ], "permissions": [ + "core:webview:allow-set-webview-zoom", "allow-foundation-info", "allow-local-diagnostics", "allow-remember-memory", diff --git a/desktop/src-tauri/tests/ipc.rs b/desktop/src-tauri/tests/ipc.rs index 59bff1bd14..19212699f0 100644 --- a/desktop/src-tauri/tests/ipc.rs +++ b/desktop/src-tauri/tests/ipc.rs @@ -94,6 +94,35 @@ fn permissions_reject_untrusted_window_origin_and_arbitrary_commands() { } } #[test] +fn zoom_is_available_only_to_the_trusted_main_window() { + use tauri::Manager; + let app = mock_builder().build(test_context()).unwrap(); + for (label, origin, allowed) in [ + ("main", LOCAL_ORIGIN, true), + ("other", LOCAL_ORIGIN, false), + ("main", "https://evil.example", false), + ] { + let window = app.get_webview_window(label).unwrap_or_else(|| { + tauri::WebviewWindowBuilder::new(&app, label, Default::default()) + .build() + .unwrap() + }); + let result = get_ipc_response( + &window, + InvokeRequest { + cmd: "plugin:webview|set_webview_zoom".into(), + callback: CallbackFn(0), + error: CallbackFn(1), + url: origin.parse().unwrap(), + body: InvokeBody::Json(serde_json::json!({"value":2.0})), + headers: Default::default(), + invoke_key: INVOKE_KEY.into(), + }, + ); + assert_eq!(result.is_ok(), allowed, "{label} {origin}: {result:?}"); + } +} +#[test] fn blocks_remote_navigation_and_userinfo() { for url in [ "https://evil.example", diff --git a/desktop/tests/installed_fixture.py b/desktop/tests/installed_fixture.py index 015b2326a7..eee76fecdf 100644 --- a/desktop/tests/installed_fixture.py +++ b/desktop/tests/installed_fixture.py @@ -82,7 +82,9 @@ def wait_ready(client: httpx.Client, process: subprocess.Popen[bytes]) -> None: @contextmanager def isolated_server( - response_loss_counter: Path | None = None, response_gate: Path | None = None + response_loss_counter: Path | None = None, + response_gate: Path | None = None, + statistics_usage_control: Path | None = None, ) -> Iterator[tuple[httpx.Client, str, str]]: desktop = Path(__file__).resolve().parents[1] wheels = list((desktop / ".artifacts/server-wheel").glob("*.whl")) @@ -107,6 +109,7 @@ def isolated_server( "tls": False, "response_gate_path": str(response_gate) if response_gate else None, "response_loss_path": str(response_loss_counter) if response_loss_counter else None, + "statistics_usage_path": str(statistics_usage_control) if statistics_usage_control else None, } config_path = temp / "server.json" config_path.write_text(json.dumps(config), encoding="utf-8") diff --git a/desktop/tests/installed_statistics.py b/desktop/tests/installed_statistics.py new file mode 100644 index 0000000000..fe3ffa4889 --- /dev/null +++ b/desktop/tests/installed_statistics.py @@ -0,0 +1,277 @@ +# Copyright (c) 2026 OceanBase. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Compare the real installed statistics view with independent Server responses.""" + +from __future__ import annotations + +from collections import Counter +from datetime import datetime, timedelta +from typing import TYPE_CHECKING, Any + +import httpx +from installed_fixture import isolated_server +from real_server import HarnessFailure + +if TYPE_CHECKING: + from pathlib import Path + + from installed_workflow import InstalledPage + +LABELS: dict[str, dict[str, Any]] = { + "zh": { + "load": "加载统计", + "period": "用量周期(UTC)", # noqa: RUF001 - exact localized UI label + "inventory": "存量快照", + "usage": "已记录的模型用量合计", + "daily": "每日模型用量(UTC)", # noqa: RUF001 - exact localized UI caption + "recall": "召回 token 估算", + "recall_daily": "每日召回估算(UTC)", # noqa: RUF001 - exact localized UI caption + "coverage": "返回的范围覆盖", + "dates": "UTC 起止日期(含首尾)", # noqa: RUF001 - exact localized UI label + "unknown": "未知", + "operations": ["生成", "嵌入"], + "recall_rows": [ + "准备次数", + "就绪准备次数", + "可比较准备次数", + "基线 token(估算)", # noqa: RUF001 - exact localized UI label + "召回 token(估算)", # noqa: RUF001 - exact localized UI label + "token 减少量(估算)", # noqa: RUF001 - exact localized UI label + ], + "inventory_rows": [ + "来源", + "已完成 Memory 处理的来源", + "待处理 Memory 的来源", + "制品", + "候选", + "待决候选", + "已批准候选", + "已拒绝候选", + "记忆条目", + "有效记忆条目", + "无效记忆条目", + ], + }, + "en": { + "load": "Load statistics", + "period": "Usage period (UTC)", + "inventory": "Inventory snapshot", + "usage": "Recorded model usage totals", + "daily": "Daily model usage (UTC)", + "recall": "Recall token estimates", + "recall_daily": "Daily recall estimates (UTC)", + "coverage": "Returned Scope coverage", + "dates": "Inclusive UTC dates", + "unknown": "Unknown", + "operations": ["Generation", "Embedding"], + "recall_rows": [ + "Preparations", + "Ready preparations", + "Comparable preparations", + "Baseline tokens (estimate)", + "Recalled tokens (estimate)", + "Token reduction (estimate)", + ], + "inventory_rows": [ + "Sources", + "Sources processed for Memory", + "Sources pending Memory processing", + "Artifacts", + "Candidates", + "Pending candidates", + "Approved candidates", + "Rejected candidates", + "Memory entries", + "Active Memory entries", + "Inactive Memory entries", + ], + }, +} + + +def expect_cleared(page: InstalledPage, previous_scope: str) -> None: + page.wait( + """const root = document.querySelector('.statistics'); + return root && !root.querySelector('table') && !root.textContent.includes(arguments[0]);""", + [previous_scope], + ) + + +def load_and_compare( + page: InstalledPage, server: httpx.Client, scope: str, period: str, language: str = "zh" +) -> dict[str, Any]: + labels = LABELS[language] + page.click( + f"//section[contains(@class,'statistics')]//label[normalize-space(text())='{labels['period']}']/select/option[@value='{period}']" + ) + # Establish a new completed response, rather than accepting a previous table + # while the click's React update is still pending. + previous = page.observe("return document.querySelector('.statistics time')?.dateTime || null;") + for _ in range(3): + page.button(labels["load"]) + page.wait( + """const root = document.querySelector('.statistics'); + const time = root?.querySelector('time'); + return time && time.dateTime !== arguments[0] && root.querySelector('table');""", + [previous], + ) + observed = page.observe("""const root = document.querySelector('.statistics'); + return { + asOf: root.querySelector('time').dateTime, + facts: Object.fromEntries([...root.querySelectorAll('dl > div')].map(row => [ + row.querySelector('dt').textContent.trim(), row.querySelector('dd').textContent.trim()])), + charts: [...root.querySelectorAll('svg[role=img]')].map(chart => chart.getAttribute('aria-label')), + tables: Object.fromEntries([...root.querySelectorAll('table')].map(table => [ + table.querySelector('caption').textContent.trim(), + [...table.querySelectorAll('tbody tr')].filter(row => !row.querySelector('td[colspan]')) + .map(row => [...row.children].map(cell => cell.textContent.trim()))])) + };""") + response = server.post( + "/v1/stats", json={"selection": {"mode": "exact", "scope_ids": [scope]}, "period": period} + ) + response.raise_for_status() + expected = response.json() + dates = observed["facts"][labels["dates"]] + resolved = expected["usage"]["period"] + if resolved["start_date"] in dates and resolved["end_date"] in dates: + break + # The two independent reads can straddle UTC midnight. Reload against + # the new inclusive dates without fabricating historical values. + previous = observed["asOf"] + else: + raise HarnessFailure("installed_statistics_period_did_not_settle") + as_of = datetime.fromisoformat(observed["asOf"].replace("Z", "+00:00")) + if as_of.utcoffset() != timedelta(0) or "UTC" not in dates: + raise HarnessFailure("installed_statistics_utc_missing") + if scope not in observed["facts"][labels["coverage"]] or len(observed["charts"]) != 3: + raise HarnessFailure("installed_statistics_scope_or_charts_missing") + + def display(value: Any) -> str: + if value is None: + return labels["unknown"] + return f"{value:,}" if isinstance(value, int) else str(value) + + def table(caption: str, rows: list[list[Any]]) -> None: + actual = Counter(tuple(row) for row in observed["tables"][caption]) + wanted = Counter(tuple(display(value) for value in row) for row in rows) + if actual != wanted: + raise HarnessFailure("installed_statistics_table_mismatch", caption) + + inventory = expected["inventory"] + counts = [inventory["sources"][key] for key in ("total", "memory_processed", "memory_pending")] + counts += [inventory["artifacts"]["total"]] + counts += [inventory["candidates"][key] for key in ("total", "pending", "approved", "rejected")] + counts += [inventory["memory"]["entries"][key] for key in ("total", "active", "inactive")] + table(labels["inventory"], [[name, value] for name, value in zip(labels["inventory_rows"], counts, strict=True)]) + operations = ("generation", "embedding") + metrics = ("requests", "input_tokens", "output_tokens") + usage = expected["usage"] + table( + labels["usage"], + [ + [labels["operations"][index], *[usage["totals"][operation][key] for key in metrics]] + for index, operation in enumerate(operations) + ], + ) + table( + labels["daily"], + [ + [day["date"], labels["operations"][index], *[day[operation][key] for key in metrics]] + for day in usage["daily"] + for index, operation in enumerate(operations) + ], + ) + fields = ( + "preparations", + "ready_preparations", + "comparable_preparations", + "baseline_tokens", + "recalled_tokens", + "token_reduction", + ) + table( + labels["recall"], + [[label, expected["recall"]["totals"][key]] for label, key in zip(labels["recall_rows"], fields, strict=True)], + ) + table(labels["recall_daily"], [[day["date"], *[day[key] for key in fields]] for day in expected["recall"]["daily"]]) + return expected + + +def exercise_statistics( + page: InstalledPage, server: httpx.Client, scope: str, usage_control: Path +) -> dict[str, object]: + page.button("总览") + page.wait("return !!document.querySelector('.statistics');") + if page.observe("return !!document.querySelector('.statistics table');"): + raise HarnessFailure("installed_statistics_loaded_without_action") + empty = load_and_compare(page, server, scope, "30d") + if empty["inventory"]["memory"]["entries"]["total"] != 0: + raise HarnessFailure("installed_statistics_fresh_inventory_not_empty") + usage_control.touch() + partial = load_and_compare(page, server, scope, "7d") + if partial["usage"]["totals"]["generation"] != {"requests": 2, "input_tokens": None, "output_tokens": 9}: + raise HarnessFailure("installed_statistics_partial_usage_not_preserved") + for period in ("today", "30d"): + load_and_compare(page, server, scope, period) + page.button("设置与诊断") + page.click("//select[@id='language']/option[@value='en']") + page.button("Overview") + load_and_compare(page, server, scope, "7d", "en") + page.button("Settings & diagnostics") + page.click("//select[@id='language']/option[@value='zh']") + page.button("总览") + load_and_compare(page, server, scope, "7d") + created = server.post( + "/v1/scopes", + json={ + "title": "Desktop installed CI stats B", + "summary": "Statistics isolation", + "idempotency_key": "desktop-stats-scope-b", + }, + ) + created.raise_for_status() + next_scope = created.json()["scope_id"] + page.select_scope(next_scope) + expect_cleared(page, scope) + load_and_compare(page, server, next_scope, "7d") + with isolated_server() as (other, other_scope, _): + seeded = other.post( + "/v1/memory/remember", + json={"scope_id": other_scope, "kind": "note", "text": "Statistics installed fixture note"}, + ) + seeded.raise_for_status() + page.connect("Desktop CI stats B", str(other.base_url).rstrip("/")) + page.select_scope(other_scope) + page.button("总览") + expect_cleared(page, next_scope) + different = load_and_compare(page, other, other_scope, "7d") + if different["inventory"]["memory"]["entries"]["total"] != 1: + raise HarnessFailure("installed_statistics_other_inventory_mismatch") + page.button("连接") + page.profile("Desktop CI synthetic") + page.activate("Desktop CI synthetic") + page.select_scope(scope) + page.button("总览") + expect_cleared(page, other_scope) + load_and_compare(page, server, scope, "7d") + page.button("记忆") + return { + "exactScopeInventoryAndUsage": True, + "independentServerTables": True, + "utcPeriods": ["today", "7d", "30d"], + "partialTokenReportRemainsUnknown": True, + "bilingualStatistics": True, + "scopeAndConnectionStatisticsIsolation": True, + } diff --git a/desktop/tests/installed_workflow.py b/desktop/tests/installed_workflow.py index d0e793fdfa..5a1bed50ec 100644 --- a/desktop/tests/installed_workflow.py +++ b/desktop/tests/installed_workflow.py @@ -30,6 +30,7 @@ saved_reference, search_reference, ) +from installed_statistics import exercise_statistics from real_server import HarnessFailure ELEMENT = "element-6066-11e4-a52e-4f735466cecf" @@ -107,8 +108,11 @@ def open_connection_menu(self) -> None: def activate(self, name: str) -> None: self.button("使用此连接") - self.element( - f"//ul[@class='profile-list']/li[.//span[@class='profile-name'][normalize-space(.)='{name}']]//span[contains(@class,'badge')]" + self.wait( + """return [...document.querySelectorAll('.profile-list li')].some(item => + item.querySelector('.profile-name')?.textContent.trim() === arguments[0] && + [...item.querySelectorAll('.badge')].some(badge => badge.textContent.trim() === '当前连接'));""", + [name], ) self.button("记忆") @@ -183,9 +187,13 @@ def clear_note(self) -> None: def exercise_memory(client: httpx.Client, prefix: str) -> dict[str, object]: page = InstalledPage(client, prefix) - with isolated_server() as (server, scope_id, wheel_digest): + with ( + tempfile.TemporaryDirectory(prefix="desktop-statistics-ui-") as directory, + isolated_server(statistics_usage_control=Path(directory) / "seed-usage") as (server, scope_id, wheel_digest), + ): page.connect("Desktop CI synthetic", str(server.base_url).rstrip("/")) page.select_scope(scope_id) + statistics = exercise_statistics(page, server, scope_id, Path(directory) / "seed-usage") exercise_note_budget(page, server, scope_id) page.type("记忆内容", NOTE, "textarea") before_submit = server.post( @@ -214,6 +222,7 @@ def exercise_memory(client: httpx.Client, prefix: str) -> dict[str, object]: exercise_unknown_write(page) return { "serverWheelSha256": wheel_digest, + "statistics": statistics, "mode": "anonymous loopback SQLite, no model", "explicitConnectionAndScope": True, "saveSearchExactRead": True, diff --git a/docs/en/development/desktop-statistics.md b/docs/en/development/desktop-statistics.md index 5c5a64f834..047136cb31 100644 --- a/docs/en/development/desktop-statistics.md +++ b/docs/en/development/desktop-statistics.md @@ -57,5 +57,7 @@ Neither Server APIs nor historical qualification records are changed. See [the qualification record](../../../desktop/VALIDATION.md#current-scope-statistics-qualification) for the tested Server/contract/wheel identities and actual validation limits. Synthetic tests establish local handling; the built-wheel harness separately -establishes the observed SQLite protocol behavior. Installed Windows/macOS page -acceptance remains a platform gate. +establishes the observed SQLite protocol behavior. The record also distinguishes +the exercised macOS ARM64 bundled debug-app journey from Windows installed UI, +Intel, IME and screen-reader gates. The Windows installed journey compares the +page's tables with independent real Server responses. diff --git a/docs/zh/development/desktop-statistics.md b/docs/zh/development/desktop-statistics.md index f4c7fefe7b..c0e1d32c6c 100644 --- a/docs/zh/development/desktop-statistics.md +++ b/docs/zh/development/desktop-statistics.md @@ -44,4 +44,6 @@ Rust `export_ipc` example 生成。原生的精确选择投影复用具体生成 已验证的 Server、合同、wheel 标识和实际验证边界见 [资格记录](../../../desktop/VALIDATION.md#current-scope-statistics-qualification)。 模拟测试验证本地处理;构建 wheel 的独立夹具另行验证实际 SQLite 协议行为。 -Windows/macOS 安装后的页面验收仍由平台 gate 负责。 +记录另行列明已执行的 macOS ARM64 打包调试应用流程,以及仍待验证的 Windows +安装界面、Intel、输入法和屏幕阅读器 gate。Windows 安装流程将页面表格与独立的 +真实 Server 响应进行比较。 From 31e9294c7aaab6e8b51be8749d6db6f3657435e1 Mon Sep 17 00:00:00 2001 From: WuShang-d <2915293869@qq.com> Date: Tue, 3 Nov 2026 21:27:06 +0800 Subject: [PATCH 4/4] ci: rerun validation after SQLite concurrency failure