diff --git a/README.md b/README.md index a1732a6b27..06c372672e 100644 --- a/README.md +++ b/README.md @@ -329,6 +329,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 e30db6583c..ca2cca640f 100644 --- a/README_CN.md +++ b/README_CN.md @@ -268,6 +268,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 5c7283af92..cd456a89fb 100644 --- a/desktop/README.md +++ b/desktop/README.md @@ -6,7 +6,7 @@ See [中文说明](README.zh.md), [security boundary](SECURITY.md), and [validat ## Observation -The sidebar provides Overview, Connections, Memories, and Observation, with Settings & diagnostics at the bottom. Overview is the initial page. Observation contains Runtime state, Current-Scope inventory & usage, and This session tabs. Runtime state and inventory data remain explicitly unavailable until integrated. This session reads native session observations; opening the page or switching tabs does not run connection checks or local diagnostics. English/Chinese and existing theme settings apply to Observation. +The sidebar provides Overview, Connections, Memories, and Observation, with Settings & diagnostics at the bottom. Overview is the initial page. Observation contains Runtime state, Current-Scope inventory & usage, and This session tabs. Inventory & usage hosts the qualified Statistics view; This session reads native session observations. Runtime state remains explicitly unavailable until integrated. Opening the page or switching tabs does not run connection checks or local diagnostics. English/Chinese and existing theme settings apply to Observation. The native host collects a bounded, in-memory window of HTTP requests and Memory/Scope user operations independently of page navigation. Trusted typed IPC provides an allowlisted summary, reset and safe JSON preview. The window retains at most 512 terminal records; it preserves cancellation and unknown-write outcomes and contains no content or target identities. See the [session observation contract](../docs/en/development/desktop-session-observations.md) for measurement units, coverage, reset and failure isolation. This session shows separate request/action counts, outcomes and millisecond latency, UTC snapshot/retention coverage, manual refresh and confirmed reset. **Preview safe diagnostic summary** opens the exact native text; copying is a separate explicit action with feedback and selectable text for manual recovery. Leaving the view or changing context closes its preview and discards late UI responses. @@ -110,6 +110,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 **Observation → Current-Scope inventory & usage**, 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). + +Statistics require the explicit `sqlite-stats-63e9665c-v1` profile; the separately +qualified `sqlite-atomic-3cefa45a-v1` profile covers Memory operations only. + ## Native discovery results Connection checks call the public `get_server_info` operation through the existing native transport. `CheckReport.discovery` reports `supported`, `missing`, `denied`, `unavailable`, `invalid`, or `incompatible`, the observed Server metadata, safe errors, and per-operation `qualified`/`unsupported`/`unverified` results with reasons. Compatible discovery minor versions accept additional optional fields in the response and its nested version/feature objects. @@ -184,7 +198,7 @@ Installed boundary checks exercise an 8192-byte Unicode note, reject over-budget After upgrading from an earlier preview, open Connections and select your saved connection. Profiles matching the bundled API contract appear under “Available for this Desktop build”; compare their tested Server builds with your deployment before selecting one, saving, checking and explicitly using the connection again. New connections start with no compatibility selection. -Historical profiles appear as disabled records when their contract differs from this build. A saved incompatible or unavailable selection remains visible with an explanation until explicitly replaced. The `sqlite-atomic-3cefa45a-v1` record matches this build's contract and qualifies eleven SQLite protocol operations against the exact Server source and wheel recorded in the validation guide. Select it only after comparing that tested build with your deployment. Existing records retain their original Server source, contract, wheel and operation identities, including the historical `sqlite-atomic-f3a90b2f-v1` qualification. The `ci-fixtures` build is for disposable acceptance tests and does not qualify a production deployment. Qualification evidence and remaining requirements are documented in the [validation guide](VALIDATION.md#compatibility-scope). +Historical records whose digest differs from this build remain disabled. A saved incompatible or unavailable selection stays visible until explicitly replaced; it is never silently upgraded. For statistics, explicitly select `sqlite-stats-63e9665c-v1`, whose recorded Server source and wheel must match the intended deployment, then check and activate the connection and select an exact Scope. This record matches the bundled contract and qualifies all twelve Desktop operations, including `get_stats`. Other records retain their original source, digest, wheel and operation sets; selecting them does not add statistics qualification. The `ci-fixtures` build is only for disposable acceptance. Source identities and remaining platform requirements are in the [validation guide](VALIDATION.md#server-63e9665c-statistics-product-qualification). ## Build on macOS diff --git a/desktop/README.zh.md b/desktop/README.zh.md index 3fb427d152..32bafa6544 100644 --- a/desktop/README.zh.md +++ b/desktop/README.zh.md @@ -8,7 +8,7 @@ ## 观察 -侧栏提供总览、连接、记忆、观察入口,“设置与诊断”位于侧栏底部。默认进入总览。“观察”包含运行状态、当前 Scope 库存与用量、本次会话三个标签页。运行状态和库存视图接入前明确显示尚未可用;本次会话读取原生会话观察。进入页面或切换标签不会执行连接检查或本机诊断。观察页面沿用现有中英文和主题设置。 +侧栏提供总览、连接、记忆、观察入口,“设置与诊断”位于侧栏底部。默认进入总览。“观察”包含运行状态、当前 Scope 库存与用量、本次会话三个标签页。库存与用量已接入统计视图;本次会话读取原生会话观察。运行状态接入前明确显示尚未可用。进入页面或切换标签不会执行连接检查或本机诊断。观察页面沿用现有中英文和主题设置。 原生宿主独立于页面导航,采集有界的内存窗口,分别记录 HTTP 请求和 Memory/Scope 用户操作。可信的类型化 IPC 提供白名单摘要、重置和安全 JSON 预览。窗口最多保留 512 条终态记录,保留取消及未知写入的区别,不包含内容或目标身份。测量单位、覆盖范围、重置和故障隔离见[会话观察契约](../docs/zh/development/desktop-session-observations.md)。本次会话分别显示请求与操作的数量、结果和毫秒延迟,以及 UTC 快照时间和保留范围;支持手动刷新和确认后重置。“预览安全诊断摘要”展示原生生成的准确文本,复制是独立的显式操作,提供成功或失败反馈及可选择的手动复制文本。离开视图或切换上下文会关闭预览并丢弃晚到的界面响应。 @@ -150,6 +150,18 @@ Desktop 在每次诊断前检查固定路径和摘要,然后只允许 `service [验证说明](VALIDATION.md)列出可复现的检查方式和待完成的平台、产品验收项。 +## 当前范围的存量与用量 + +统计需要显式选择 `sqlite-stats-63e9665c-v1` 配置;独立验证的 +`sqlite-atomic-3cefa45a-v1` 配置仅覆盖 Memory 操作。 + +在“观察 → 当前 Scope 库存与用量”中显式启用已验证兼容的连接、选择精确范围和“今天/最近 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 使用合成数据,测试结束后清理。 @@ -166,7 +178,7 @@ Windows GitHub Actions 会构建未签名安装包,安装到临时中文路径 从早期预览版升级后,请打开「连接」并选中已保存的连接。与当前 API 契约匹配的配置显示在「此 Desktop 构建可用」中;先核对已测试的 Server 构建与实际部署,再明确选择、保存、检查并使用该连接。新连接默认不选择兼容配置。 -历史配置在契约不匹配时显示为不可选择的记录。已保存的不匹配或不可用配置仍会显示并说明原因,须明确替换,不会静默升级。`sqlite-atomic-3cefa45a-v1` 与当前构建的契约匹配,已针对验证说明中记录的精确 Server 源码和 wheel 验证 11 项 SQLite 协议操作;请先核对该受测构建与实际部署,再明确选择。现有记录保留各自原始的 Server 源码、契约、wheel 和操作身份。`ci-fixtures` 构建仅用于临时验收测试,不代表实际部署已通过兼容性验证。资格证据和剩余要求见[验证说明](VALIDATION.md#compatibility-scope)。 +契约摘要不匹配的历史记录不可选择。已保存的不匹配或不可用配置仍会显示,须明确替换,不会静默升级。使用统计时,请明确选择 `sqlite-stats-63e9665c-v1`,并核对部署与记录的 Server 源码、wheel 身份一致,再检查和激活连接、选择精确范围。该记录匹配打包契约,覆盖包括 `get_stats` 在内的十二项桌面操作。其他记录保留各自的源码、摘要、wheel 和操作集合,选择记录不会扩展其统计资格。`ci-fixtures` 仅用于临时验收。[验证说明](VALIDATION.md#server-63e9665c-statistics-product-qualification)记录来源和剩余平台要求。 ## 在 macOS 上构建 diff --git a/desktop/VALIDATION.md b/desktop/VALIDATION.md index af7256fbfc..cc64a50312 100644 --- a/desktop/VALIDATION.md +++ b/desktop/VALIDATION.md @@ -173,16 +173,357 @@ The native `get_server_info` adapter and full real-Server harness were exercised The native suite passed all 61 tests, including synthetic HTTP regressions for missing/denied/unavailable/invalid/incompatible discovery, optional-field additions, operation membership, duplicate deployment-identity fields, qualification isolation, cancellation, credentials and uncertain writes. A synthetic HTTP regression verifies that a qualified connection blocks Scope requests after discovery returns 503 and allows them again after an explicit successful recheck. The bundled-product qualification test also exercised explicit Scope browsing through the normal manager. All 116 frontend tests passed, including distinct bilingual discovery diagnostics, replacement of a temporary-failure diagnostic after a successful recheck, and per-operation Memory/Scope gating with restriction reasons while independently supported operations remain available, including exact-read gating for records returned by a save. Clippy with `--all-targets -- -D warnings`, Rust formatting, generated contract drift, IPC export drift, frontend lint/type checks/build and seven focused Python contract/fixture tests passed. Generation was executed from a Windows checkout whose path contains a space. Repository hooks passed except default Windows `ty check`, which reported POSIX-only attributes in unchanged Python modules; `ty check --python-platform linux` passed. Synthetic HTTP and UI fixtures establish local status/recovery handling, not installed-host or release acceptance. -The historical Server c535d816 protocol qualification below records the specific source, wheel and operation identities independently of the CI-only fixture. It does not establish installed UI or release acceptance. The focused `server_probe discovery-only` mode calls discovery without readiness or Memory requests and accepts the full probe's fixture format. Historical product records remain unchanged; the locally generated qualification file is a CI-only fixture. This historical discovery qualification retains its original contract digest; the `cc5980f0`, `dd62723d` and `4ffdf4e9` records retain their ten independently qualified operations and do not add discovery qualification. The `cc5980f0` acceptance predates the native discovery adapter and does not establish real-Server acceptance of the combined client and Server sources. Deployment-aware read/write fencing, explicit readiness separation, local setup approval handoff and installed Windows/macOS qualification remain separate acceptance requirements. +The historical Server c535d816 protocol qualification below records the specific source, wheel and operation identities independently of the CI-only fixture. It does not establish installed UI or release acceptance. The focused `server_probe discovery-only` mode calls discovery without readiness or Memory requests and accepts the full probe's fixture format. Historical product records remain unchanged; the locally generated qualification file is a CI-only fixture. This historical discovery qualification retains its original contract digest; the `cc5980f0`, `dd62723d` and `4ffdf4e9` records retain their ten independently qualified operations and do not add discovery qualification. The `cc5980f0` acceptance predates the native discovery adapter and does not establish real-Server acceptance of the combined client and Server sources. The combined statistics/discovery records below retain their own source-specific qualification evidence and original contract digests. Deployment-aware read/write fencing, explicit readiness separation, local setup approval handoff and installed Windows/macOS qualification remain separate acceptance requirements. ## Compatibility scope -[compatibility.json](src-tauri/src/connections/compatibility.json) preserves each recorded Server source, normalized OpenAPI contract digest, qualification-wheel digest and operation set. The `sqlite-atomic-3cefa45a-v1` record matches the bundled API contract and qualifies its eleven recorded SQLite protocol operations against the exact Server source and wheel below. Historical records retain their exact identities and evidence. Ordinary builds require explicitly selecting the record whose tested Server source and wheel match the deployment, then checking the connection before business operations become available. Records with a different contract digest are disabled in the selector, and saved unavailable selections remain visible until explicitly replaced. Selecting a profile does not extend its recorded operation set or qualify a different Server source. CI builds have their own artifact identities and do not change product qualification. +[compatibility.json](src-tauri/src/connections/compatibility.json) preserves each recorded Server source, normalized OpenAPI contract digest, qualification-wheel digest and operation set. `sqlite-stats-63e9665c-v1` records the current bundled contract's twelve operations, including statistics, qualified below. `sqlite-atomic-3cefa45a-v1` retains its independently qualified eleven operations at the same contract digest. `sqlite-stats-9c8f061c-v1` retains its original historical qualification and digest. `sqlite-atomic-112db11e-v1` retains its independently qualified eleven operations. Historical records retain their exact identities and evidence, including `sqlite-source-artifact-87b6fae2-v1` and the preceding `sqlite-artifact-merge-87d01f68-v1` record. The `sqlite-atomic-dd62723d-v1` and `sqlite-atomic-4ffdf4e9-v1` records retain their migration-framework and release-preparation source qualifications with their original contract digest. Other historical records also retain their exact identities and evidence. Ordinary builds require explicitly selecting the record whose tested Server source and wheel match the deployment, then checking the connection before business operations become available. Records with a different contract digest are disabled in the selector, and saved unavailable selections remain visible until explicitly replaced. Selecting a profile does not extend its recorded operation set or qualify a different Server source. 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. Historical record `sqlite-atomic-f3a90b2f-v1` remains preserved with its original identities and is disabled for this contract. 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 + +#### Server 63e9665c statistics product qualification + +`sqlite-stats-63e9665c-v1` qualifies all twelve Desktop operations, including +`get_stats` and discovery, against clean Server source +`63e9665c689fc7a37018dacb7f5236951a9a618d` and normalized OpenAPI digest +`d59da9040814d0f2b25d930ecb36b93d932ff8906440606d259d3b9456364d3d`. +Wheel: `powercontext-1.5.1.dev25+g63e9665c6-py3-none-any.whl`. +Wheel SHA-256: `0d4d9c5f52425a12debcedb18ff8596109e69a9fd1ca6025460c84d2b217db51`. +All 686 packaged runtime files in `powercontext/` and +`powercontext_service_bootstrap/` match the clean source byte for byte. +The sorted newline-terminated `wheel/path sha256` manifest hashes to +`2b2557255d64d1c3ef83f9ad374e4128e64aefe71e7746e08f669cdd6ff2b3c3`. + +On 2026-11-04, macOS ARM64 with Python 3.11.15 and Rust 1.95.0 passed all four +built-wheel SQLite modes: anonymous loopback, static Bearer loopback, explicit-CA +HTTPS with a base path, and enforced authentication Provider. The Desktop client +ran the integrated working tree based on `daddf658` and this Server commit; +the report records that uncommitted Desktop tree. The Server wheel came from +the separate clean source checkout identified above. The native probe +was built without `ci-fixtures` and used ordinary `ConnectionManager::new` +profile selection, checking, activation and exact-Scope selection. It exercised +statistics for UTC today/7d/30d, persisted inventory, zero and partial token +reports, independent Memory operations, identity/revocation and unknown writes +without replay. Partial usage is seeded Runtime-recorder input, not a live +model-provider call. The harness verifies the selected product-record receipt +and wheel/contract/operation identity. + +```sh +cargo build --locked --manifest-path desktop/src-tauri/Cargo.toml --example server_probe +uv run --locked python desktop/tests/real_server.py --product-qualification sqlite-stats-63e9665c-v1 +cargo test --locked --manifest-path desktop/src-tauri/Cargo.toml --test session bundled_product_qualification_covers_every_desktop_operation +``` + +The matching-digest product gate rejected the eleven-operation qualification +before this statistics record was added, then passed with the new record. The separate +`sqlite-atomic-3cefa45a-v1` record retains its exact eleven-operation evidence; +all historical records retain their original identities and digests. Local +reports are under ignored `desktop/.artifacts/real-server.json` and +`desktop/.artifacts/server63-runtime-manifest.txt`. These checks establish the +local built-wheel/native SQLite path; installed Windows/Intel, signed packages, +external production deployments and full release acceptance require separate +evidence. Discovery alone does not attest a remote binary. + +新记录在实际构建的 SQLite Server 上验证十二项操作,使用普通产品资格选择链, +没有注入 CI fixture 资格;历史记录及其来源身份均保留。部分 token 上报为显式 +Runtime 测试输入,不代表真实模型调用,也不代替安装包、签名或发布验收。 + +#### Server 9c8f061c statistics product qualification + +`sqlite-stats-9c8f061c-v1` qualifies all twelve bundled Desktop operations, +including `get_stats` and discovery, against Server source +`9c8f061cf76b4002641e8ca8a5d23a469f433e56` and normalized OpenAPI digest +`83dc68194770a41facea55a3fe2279bc0e8b01812a82a1d838334232c67bedc7`. +The qualification wheel is +`powercontext-1.4.1.dev314+g5bb0b7eb5.d20261103-py3-none-any.whl`, SHA-256 +`250be633f359e42eee52a07e5b7815aa423ed1346e79374dff1e9f59891dc7d1`. +Its version reflects the Desktop checkout used to build it during integration; +all 684 packaged runtime files in `powercontext/` and +`powercontext_service_bootstrap/` match the recorded Server commit byte for byte. +The sorted newline-terminated `wheel/path sha256` manifest hashes to +`fb0bbf08c3be0993feb0cb7126be377681aedcee59f75ebbdeb6ed380dfa7c0c`. + +On 2026-11-03, macOS ARM64, the isolated built-wheel SQLite Server passed +anonymous loopback, static Bearer loopback, explicit-CA HTTPS with a base path, +and enforced authentication Provider modes. The native probe was built without +`ci-fixtures`. In product-qualification mode it uses `ConnectionManager::new` +and selects this checked-in record through the ordinary connection check, +activation and exact-Scope selection path; it does not inject a qualification. +All four modes completed explicit statistics reads through that manager for +UTC today/7d/30d, persisted inventory, zero and partial usage, and independent +Memory operations. Statistics revalidates Principal and deployment identity before and after the read. +Existing identity/revocation, context cancellation and +uncertain-write checks also passed. Partial model usage is seeded into the +Runtime recorder; this is not an actual model-provider invocation. + +Reproduce with a wheel whose digest matches the record and a default native build: + +```sh +cargo build --locked --manifest-path desktop/src-tauri/Cargo.toml --example server_probe +uv run --locked python desktop/tests/real_server.py --product-qualification sqlite-stats-9c8f061c-v1 +cargo test --locked --manifest-path desktop/src-tauri/Cargo.toml --test session bundled_product_qualification_covers_every_desktop_operation +``` + +The harness rejects a mismatched wheel/digest or an incomplete operation set. +It also requires the native probe to return the selected product record ID and +product qualification source; a stale fixture-only executable cannot satisfy +product acceptance merely by ignoring the input flag. +The regression reads only checked-in product records, even when CI fixture +features are enabled. It requires one matching-digest record covering every +bundled operation; historical partial qualifications remain unchanged. +The regression failed before the statistics product record was added and passed +afterwards. Ignored local evidence is in +`desktop/.artifacts/stats-9c8f061c/wheel-identity.json`, +`desktop/.artifacts/stats-9c8f061c/product-server.log`, and +`desktop/.artifacts/stats-9c8f061c/real-server.json` (`qualificationSource: bundled product records`). + +These checks establish a local built-wheel/native SQLite path. They do not +establish installed Windows/Intel acceptance, signing/notarization, external +production deployment, or release/upgrade qualification. Operators must match +the recorded Server source and wheel; discovery alone does not attest a remote binary. + + +#### Server 5032075a statistics product qualification + +`sqlite-stats-5032075a-v1` qualifies all twelve bundled Desktop operations, +including `get_stats` and discovery, against Server source +`5032075ad31d6cbd3fc333175a578a9b3e64020e` and normalized OpenAPI digest +`0f6befcd87645b83e63ea6393ba57002387302b4e53cf85fc474a218292f4104`. +The qualification wheel is +`powercontext-1.4.1.dev288+g4f0be5efa.d20261102-py3-none-any.whl`, SHA-256 +`e15c277dccf95c230b242ba9043ad912cd3c44c59346c2dfc7e4952ddc19d14e`. +Its version reflects the Desktop checkout used to build it during integration; +all 670 packaged runtime files in `powercontext/` and +`powercontext_service_bootstrap/` match the recorded Server commit byte for byte. +The sorted newline-terminated `wheel/path sha256` manifest hashes to +`0f889be03894ee03b264e8d3f11e4bec443a08c727413593736303244565026f`. + +On 2026-11-02, macOS ARM64, the isolated built-wheel SQLite Server passed +anonymous loopback, static Bearer loopback, explicit-CA HTTPS with a base path, +and enforced authentication Provider modes. The native probe was built without +`ci-fixtures`. In product-qualification mode it uses `ConnectionManager::new` +and selects this checked-in record through the ordinary connection check, +activation and exact-Scope selection path; it does not inject a qualification. +All four modes completed explicit statistics reads through that manager for +UTC today/7d/30d, persisted inventory, zero and partial usage, and independent +Memory operations. Existing identity/revocation, context cancellation and +uncertain-write checks also passed. Partial model usage is seeded into the +Runtime recorder; this is not an actual model-provider invocation. + +Reproduce with a wheel whose digest matches the record and a default native build: + +```sh +cargo build --locked --manifest-path desktop/src-tauri/Cargo.toml --example server_probe +uv run --locked python desktop/tests/real_server.py --product-qualification sqlite-stats-5032075a-v1 +cargo test --locked --manifest-path desktop/src-tauri/Cargo.toml --test session bundled_product_qualification_covers_every_desktop_operation +``` + +The harness rejects a mismatched wheel/digest or an incomplete operation set. +It also requires the native probe to return the selected product record ID and +product qualification source; a stale fixture-only executable cannot satisfy +product acceptance merely by ignoring the input flag. +The regression reads only checked-in product records, even when CI fixture +features are enabled. It requires one matching-digest record covering every +bundled operation; historical partial qualifications remain unchanged. +The regression failed before the statistics product record was added and passed +afterwards. Ignored local evidence is in +`desktop/.artifacts/stats-5032075a/wheel-identity.json`, +`desktop/.artifacts/stats-5032075a/product-server.log`, and +`desktop/.artifacts/stats-5032075a/real-server.json` (`qualificationSource: bundled product records`). + +These checks establish a local built-wheel/native SQLite path. They do not +establish installed Windows/Intel acceptance, signing/notarization, external +production deployment, or release/upgrade qualification. Operators must match +the recorded Server source and wheel; discovery alone does not attest a remote binary. + + +#### Server 0b0a1c71 statistics product qualification + +`sqlite-stats-0b0a1c71-v1` qualifies all twelve bundled Desktop operations, +including `get_stats` and discovery, against Server source +`0b0a1c71f56bf00fb9ce1cffb7d7a185cc50752d` and normalized OpenAPI digest +`37936d7897ec2d981dcc195d3b7b7a9f99c572d866c0193b37df43617077c155`. +The qualification wheel is +`powercontext-1.4.1.dev276+g9e7d8d8da.d20261101-py3-none-any.whl`, SHA-256 +`a4371b7102ed22008ca0002f6e99c811df0917bcbd8bad0c4138f315dbcb6d26`. +Its version reflects the Desktop checkout used to build it during integration; +all 665 packaged runtime files in `powercontext/` and +`powercontext_service_bootstrap/` match the recorded Server commit byte for byte. +The sorted newline-terminated `wheel/path sha256` manifest hashes to +`5949a03b7c47da3191dec0cbe4c5fa056d93194e95732fee63a3869a529c6e58`. + +On 2026-11-01, macOS ARM64, the isolated built-wheel SQLite Server passed +anonymous loopback, static Bearer loopback, explicit-CA HTTPS with a base path, +and enforced authentication Provider modes. The native probe was built without +`ci-fixtures`. In product-qualification mode it uses `ConnectionManager::new` +and selects this checked-in record through the ordinary connection check, +activation and exact-Scope selection path; it does not inject a qualification. +All four modes completed explicit statistics reads through that manager for +UTC today/7d/30d, persisted inventory, zero and partial usage, and independent +Memory operations. Existing identity/revocation, context cancellation and +uncertain-write checks also passed. Partial model usage is seeded into the +Runtime recorder; this is not an actual model-provider invocation. + +Reproduce with a wheel whose digest matches the record and a default native build: + +```sh +cargo build --locked --manifest-path desktop/src-tauri/Cargo.toml --example server_probe +uv run --locked python desktop/tests/real_server.py --product-qualification sqlite-stats-0b0a1c71-v1 +cargo test --locked --manifest-path desktop/src-tauri/Cargo.toml --test session bundled_product_qualification_covers_every_desktop_operation +``` + +The harness rejects a mismatched wheel/digest or an incomplete operation set. +It also requires the native probe to return the selected product record ID and +product qualification source; a stale fixture-only executable cannot satisfy +product acceptance merely by ignoring the input flag. +The regression reads only checked-in product records, even when CI fixture +features are enabled. It requires one matching-digest record covering every +bundled operation; historical partial qualifications remain unchanged. +The regression failed before the statistics product record was added and passed +afterwards. Ignored local evidence is in +`desktop/.artifacts/stats-current/wheel-identity.json`, +`desktop/.artifacts/stats-current/product-server.log`, and +`desktop/.artifacts/real-server.json` (`qualificationSource: bundled product records`). + +These checks establish a local built-wheel/native SQLite path. They do not +establish installed Windows/Intel acceptance, signing/notarization, external +production deployment, or release/upgrade qualification. Operators must match +the recorded Server source and wheel; discovery alone does not attest a remote binary. + + +#### Server 27404931 statistics and discovery qualification + +`sqlite-stats-27404931-v1` qualifies Server sources at +`274049318d1ed61885b2f75b202c261e6c60715b`, normalized contract digest +`c12901b88b8e958c9cd63469298b9a04b9d5e2b0b4564d83b50f7ae95542519a`, and wheel +`powercontext-1.4.1.dev185+g434859f8d.d20261028-py3-none-any.whl`, SHA-256 +`e944f46e2a04c0101c47667700667a34b805ca625f3ef8c69e73837784040d3b`. +All 617 packaged runtime files match that Server commit byte for byte. The sorted +`path sha256` manifest, newline terminated, hashes to +`31cf15628d1a45127b2c93a06ce0ea2df34efb195218075b501f22fca4b30853`. +The build included local Desktop merge changes; Server runtime, OpenAPI and +lockfile sources match the recorded commit. + +On 2026-10-28, macOS ARM64, Python 3.12.13 and Rust 1.95.0 exercised all twelve +native operations, including `get_server_info` and `get_stats`, against that +wheel in anonymous loopback, Bearer loopback, explicit-CA HTTPS/base path and +enforced-provider modes. All four passed, preserving discovery qualification, +exact-Scope statistics, partial token reports, context isolation, authorization, +exact reads and unknown-write handling. Partial reports are explicit Runtime +recorder fixture inputs, not actual provider calls. Reports and source comparison +are generated under `.artifacts/stats-27404931/` and `.artifacts/real-server.json`. + +The App mounts Statistics through `Observation.panels.inventory`; bilingual +App navigation regressions exercise the named tab, exact Scope and explicit load. +The Windows installed journey uses that same tab for its real Server table +comparisons. Local App tests use mocked IPC; installed-platform execution on the +current Desktop head and full upgrade acceptance retain their separate CI gates. + +#### Server bad4c0c2 statistics qualification + +`sqlite-stats-bad4c0c2-v1` qualifies Server sources at +`bad4c0c23fb6f8febbb91d5da050a6765a5887a2`, normalized contract digest +`c12901b88b8e958c9cd63469298b9a04b9d5e2b0b4564d83b50f7ae95542519a`, and wheel +`powercontext-1.4.1.dev180+g7f6ef3c02.d20261027-py3-none-any.whl`, SHA-256 +`0f5e5be815b143a63dc605d93c741b0251717e1db0af2c32fe1a98acad758c7d`. +All 609 packaged runtime files match the recorded Server commit byte for byte. +The sorted `path sha256` manifest, newline terminated, hashes to +`4d0dc40f50bdd5bfb151c1169ad6c41af3466ad777d7ee65a26a39f7852fb083`. +The wheel was built with local Desktop merge changes; Server runtime, OpenAPI +and dependency-lock sources match the recorded commit. + +On 2026-10-27, macOS ARM64 with Python 3.12.13 and Rust 1.95.0 ran the native +`ConnectionManager` / `server_probe` against independent SQLite Server processes +from that wheel. Anonymous loopback, Bearer loopback, HTTPS with CA/base path and +enforced-provider modes passed. Exact-Scope statistics cover empty and persisted +inventory, today/7d/30d UTC periods, zero usage, partial token reports and permission +revocation without fallback. The partial reports are explicit inputs to the real +Runtime recorder, not evidence of model-provider calls. The merged harness retains +bounded request-completion diagnostics and Server cleanup; the four runs completed. +Source identity and harness output are generated under `.artifacts/stats-bad4c0c2/` +and `.artifacts/real-server.json`. This is protocol qualification for the recorded +sources and wheel; installed-platform and release acceptance remain separate. + +#### Server 31ecdd61 statistics qualification + +`sqlite-stats-31ecdd61-v1` qualifies Server sources at +`31ecdd61beadb7ac8de5da20998dead9a9e07845`, normalized contract digest +`3165f114185aa403b563c936d5fbd0829388628f4a37540d9d1519202a07871c`, and wheel +`powercontext-1.4.1.dev157+g0cc9036b0.d20261026-py3-none-any.whl`, SHA-256 +`7a17cf9c641d1fa7ae9635204b605aedca8e33f0ceddc0bf8fcd9dd02667cb4b`. +All 552 packaged runtime files match that Server commit byte for byte. The sorted +`path sha256` manifest, newline terminated, hashes to +`9dbada6473027441b6b4934803d9efe718e84a48b4f77723d41b0de414eea125`. +The wheel was built with local Desktop merge changes; its Server runtime, +OpenAPI contract and dependency lock match the recorded sources. + +On 2026-10-26, macOS ARM64 with Python 3.12.13 and Rust 1.95.0 ran the native +`ConnectionManager` / `server_probe` against independent SQLite processes from +that wheel. Anonymous loopback, Bearer loopback, HTTPS with CA/base path and +enforced-provider modes passed. The eleven operations include exact-Scope +statistics: empty and persisted inventory, today/7d/30d UTC periods, zero usage, +partial token reports and permission revocation. Partial reports are seeded via +the real Runtime recorder, not observed model-provider calls. Identity changes, +exact reads and ambiguous writes retain the existing harness checks. +Evidence is generated under `.artifacts/stats-31ecdd61/` and `.artifacts/real-server.json`. +This qualifies the recorded protocol and sources; installed UI and release +acceptance require the checks for their corresponding Desktop head. + +#### Server 1da99747 statistics qualification (historical) + +`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. + +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. + ### Server protocol qualification records diff --git a/desktop/scripts/generate.mjs b/desktop/scripts/generate.mjs index 5f0dfa51ce..59b28fac7c 100644 --- a/desktop/scripts/generate.mjs +++ b/desktop/scripts/generate.mjs @@ -47,6 +47,7 @@ const wanted = [ "remember_memory", "search_memory", "get_artifact_revision", + "get_stats", ]; const operations = {}; const operationFeatures = {}; diff --git a/desktop/src-tauri/build.rs b/desktop/src-tauri/build.rs index 3a87ac0b73..fca5a187c1 100644 --- a/desktop/src-tauri/build.rs +++ b/desktop/src-tauri/build.rs @@ -47,6 +47,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 a75e82e713..e0f6ce4e19 100644 --- a/desktop/src-tauri/capabilities/main.json +++ b/desktop/src-tauri/capabilities/main.json @@ -6,12 +6,15 @@ "main" ], "permissions": [ + "core:webview:allow-set-webview-zoom", "allow-foundation-info", "allow-local-diagnostics", "allow-remember-memory", "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 42764a27bb..4ca05a9945 100644 --- a/desktop/src-tauri/examples/export_ipc.rs +++ b/desktop/src-tauri/examples/export_ipc.rs @@ -37,8 +37,8 @@ use powercontext_desktop::{ SessionObservation, SessionSummary, }, transport::{ - ApiFailure, AtomicMemoryDetail, MemorySaveResponse, MemorySearchHit, MemorySearchResponse, - wire, + ApiFailure, AtomicMemoryDetail, ExactRecallTokenStatistics, ExactScopeStatistics, + ExactScopeStatisticsItem, MemorySaveResponse, MemorySearchHit, MemorySearchResponse, wire, }, }; use ts_rs::TS; @@ -136,6 +136,9 @@ fn main() { MemorySearchHit::decl(&config), MemorySearchResponse::decl(&config), AtomicMemoryDetail::decl(&config), + ExactScopeStatistics::decl(&config), + ExactRecallTokenStatistics::decl(&config), + ExactScopeStatisticsItem::decl(&config), ObservationUnit::decl(&config), ObservedOperation::decl(&config), ObservationOutcome::decl(&config), diff --git a/desktop/src-tauri/examples/server_probe.rs b/desktop/src-tauri/examples/server_probe.rs index f53ae28a15..55e09e9fa7 100644 --- a/desktop/src-tauri/examples/server_probe.rs +++ b/desktop/src-tauri/examples/server_probe.rs @@ -22,13 +22,19 @@ use powercontext_desktop::{ }, credentials::{NativeVault, Secret}, error::SafeError, - transport::{DiscoveryDocument, Endpoint, ServerApi, wire::ArtifactReference}, + transport::{ + DiscoveryDocument, Endpoint, ServerApi, + wire::{ArtifactReference, StatsPeriod}, + }, }; use serde::Deserialize; #[derive(Deserialize)] struct Fixture { compatibility_profile: CompatibilityProfile, + #[serde(default)] + product_qualification: bool, response_loss_path: Option, + statistics_usage_path: Option, identity_change_path: Option, endpoint: String, scope_id: String, @@ -119,6 +125,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); @@ -137,14 +151,22 @@ async fn main() { assert_eq!(exact.text, text); // Exercise the same native context owner used by product IPC, not only bare HTTP adapters. let temporary = tempfile::tempdir().unwrap(); - let manager = ConnectionManager::with_compatibility( - ProfileRepository::open( - temporary.path().join("profiles.json"), - std::sync::Arc::new(NativeVault), + let repository = ProfileRepository::open( + temporary.path().join("profiles.json"), + std::sync::Arc::new(NativeVault), + ) + .unwrap(); + if fixture.product_qualification && cfg!(feature = "ci-fixtures") { + panic!("product qualification must not inject CI records"); + } + let manager = if fixture.product_qualification { + ConnectionManager::new(repository) + } else { + ConnectionManager::with_compatibility( + repository, + vec![fixture.compatibility_profile.clone()], ) - .unwrap(), - vec![fixture.compatibility_profile.clone()], - ); + }; let raw: serde_json::Value = serde_json::from_slice(&std::fs::read(&args[1]).unwrap()).unwrap(); let compatibility = &fixture.compatibility_profile.id; let credential = raw["token"] @@ -171,6 +193,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(); @@ -312,7 +364,8 @@ async fn main() { "{}", serde_json::json!({ "result":"passed", - "qualification":"candidate native real-Server evidence; product profile is separate", + "qualificationSource": if fixture.product_qualification { "bundled product records" } else { "disposable fixture" }, + "qualificationId":fixture.compatibility_profile.id, "contractSha256":fixture.compatibility_profile.contract_sha256, "performance": { "scope":"Native ConnectionManager round trips, including identity recheck; local fixture; no UI latency or approved budget", @@ -362,6 +415,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) @@ -383,6 +440,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 8e7ca75a0e..c0838f9e00 100644 --- a/desktop/src-tauri/src/commands.rs +++ b/desktop/src-tauri/src/commands.rs @@ -193,6 +193,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 3f6bc30f4b..c50c15a235 100644 --- a/desktop/src-tauri/src/connections/compatibility.json +++ b/desktop/src-tauri/src/connections/compatibility.json @@ -736,5 +736,170 @@ "search_memory" ], "evidence": "desktop/VALIDATION.md#server-3cefa45a-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" + }, + { + "id": "sqlite-stats-31ecdd61-v1", + "serverCommit": "31ecdd61beadb7ac8de5da20998dead9a9e07845", + "contractSha256": "3165f114185aa403b563c936d5fbd0829388628f4a37540d9d1519202a07871c", + "artifactSha256": "7a17cf9c641d1fa7ae9635204b605aedca8e33f0ceddc0bf8fcd9dd02667cb4b", + "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#server-31ecdd61-statistics-qualification" + }, + { + "id": "sqlite-stats-bad4c0c2-v1", + "serverCommit": "bad4c0c23fb6f8febbb91d5da050a6765a5887a2", + "contractSha256": "c12901b88b8e958c9cd63469298b9a04b9d5e2b0b4564d83b50f7ae95542519a", + "artifactSha256": "0f5e5be815b143a63dc605d93c741b0251717e1db0af2c32fe1a98acad758c7d", + "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#server-bad4c0c2-statistics-qualification" + }, + { + "id": "sqlite-stats-27404931-v1", + "serverCommit": "274049318d1ed61885b2f75b202c261e6c60715b", + "contractSha256": "c12901b88b8e958c9cd63469298b9a04b9d5e2b0b4564d83b50f7ae95542519a", + "artifactSha256": "e944f46e2a04c0101c47667700667a34b805ca625f3ef8c69e73837784040d3b", + "operations": [ + "get_access_principal", + "get_artifact_revision", + "get_capabilities", + "get_default_scope", + "get_liveness", + "get_readiness", + "get_scope", + "get_server_info", + "get_stats", + "list_scopes", + "remember_memory", + "search_memory" + ], + "evidence": "desktop/VALIDATION.md#server-27404931-statistics-and-discovery-qualification" + }, + { + "id": "sqlite-stats-0b0a1c71-v1", + "serverCommit": "0b0a1c71f56bf00fb9ce1cffb7d7a185cc50752d", + "contractSha256": "37936d7897ec2d981dcc195d3b7b7a9f99c572d866c0193b37df43617077c155", + "artifactSha256": "a4371b7102ed22008ca0002f6e99c811df0917bcbd8bad0c4138f315dbcb6d26", + "operations": [ + "get_access_principal", + "get_artifact_revision", + "get_capabilities", + "get_default_scope", + "get_liveness", + "get_readiness", + "get_scope", + "get_server_info", + "get_stats", + "list_scopes", + "remember_memory", + "search_memory" + ], + "evidence": "desktop/VALIDATION.md#server-0b0a1c71-statistics-product-qualification" + }, + { + "id": "sqlite-stats-5032075a-v1", + "serverCommit": "5032075ad31d6cbd3fc333175a578a9b3e64020e", + "contractSha256": "0f6befcd87645b83e63ea6393ba57002387302b4e53cf85fc474a218292f4104", + "artifactSha256": "e15c277dccf95c230b242ba9043ad912cd3c44c59346c2dfc7e4952ddc19d14e", + "operations": [ + "get_access_principal", + "get_artifact_revision", + "get_capabilities", + "get_default_scope", + "get_liveness", + "get_readiness", + "get_scope", + "get_server_info", + "get_stats", + "list_scopes", + "remember_memory", + "search_memory" + ], + "evidence": "desktop/VALIDATION.md#server-5032075a-statistics-product-qualification" + }, + { + "id": "sqlite-stats-9c8f061c-v1", + "serverCommit": "9c8f061cf76b4002641e8ca8a5d23a469f433e56", + "contractSha256": "83dc68194770a41facea55a3fe2279bc0e8b01812a82a1d838334232c67bedc7", + "artifactSha256": "250be633f359e42eee52a07e5b7815aa423ed1346e79374dff1e9f59891dc7d1", + "operations": [ + "get_access_principal", + "get_artifact_revision", + "get_capabilities", + "get_default_scope", + "get_liveness", + "get_readiness", + "get_scope", + "get_server_info", + "get_stats", + "list_scopes", + "remember_memory", + "search_memory" + ], + "evidence": "desktop/VALIDATION.md#server-9c8f061c-statistics-product-qualification" + }, + { + "id": "sqlite-stats-63e9665c-v1", + "serverCommit": "63e9665c689fc7a37018dacb7f5236951a9a618d", + "contractSha256": "d59da9040814d0f2b25d930ecb36b93d932ff8906440606d259d3b9456364d3d", + "artifactSha256": "0d4d9c5f52425a12debcedb18ff8596109e69a9fd1ca6025460c84d2b217db51", + "operations": [ + "get_access_principal", + "get_artifact_revision", + "get_capabilities", + "get_default_scope", + "get_liveness", + "get_readiness", + "get_scope", + "get_server_info", + "get_stats", + "list_scopes", + "remember_memory", + "search_memory" + ], + "evidence": "desktop/VALIDATION.md#server-63e9665c-statistics-product-qualification" } ] diff --git a/desktop/src-tauri/src/connections/session.rs b/desktop/src-tauri/src/connections/session.rs index 0c7de8a4e0..c7ef709b94 100644 --- a/desktop/src-tauri/src/connections/session.rs +++ b/desktop/src-tauri/src/connections/session.rs @@ -171,6 +171,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 { @@ -214,6 +215,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> { @@ -672,6 +674,54 @@ 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 { + self.observations + .user_operation(ObservedOperation::GetStats, async { + let ( + ReadSnapshot { + api, + server_id, + 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, server_id.as_deref(), generation) + .await?; + let result = api.stats(&context.scope_id, period).await; + self.identity_unchanged(&api, &identity, server_id.as_deref(), generation) + .await?; + result + }), + ) + .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 0fde2b01f8..56c8ae224f 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -56,6 +56,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/session_observations.rs b/desktop/src-tauri/src/session_observations.rs index 05e42d1a2e..b00f355057 100644 --- a/desktop/src-tauri/src/session_observations.rs +++ b/desktop/src-tauri/src/session_observations.rs @@ -52,6 +52,7 @@ pub enum ObservedOperation { ListScopes, GetScope, GetDefaultScope, + GetStats, RememberMemory, SearchMemory, GetArtifactRevision, @@ -67,6 +68,7 @@ impl ObservedOperation { Self::ListScopes => "list_scopes", Self::GetScope => "get_scope", Self::GetDefaultScope => "get_default_scope", + Self::GetStats => "get_stats", Self::RememberMemory => "remember_memory", Self::SearchMemory => "search_memory", Self::GetArtifactRevision => "get_artifact_revision", @@ -308,6 +310,7 @@ impl SessionObservations { ObservedOperation::ListScopes, ObservedOperation::GetScope, ObservedOperation::GetDefaultScope, + ObservedOperation::GetStats, ObservedOperation::RememberMemory, ObservedOperation::SearchMemory, ObservedOperation::GetArtifactRevision, @@ -316,6 +319,7 @@ impl SessionObservations { ObservedOperation::ListScopes, ObservedOperation::GetScope, ObservedOperation::GetDefaultScope, + ObservedOperation::GetStats, ObservedOperation::RememberMemory, ObservedOperation::SearchMemory, ObservedOperation::GetArtifactRevision, diff --git a/desktop/src-tauri/src/transport/api.rs b/desktop/src-tauri/src/transport/api.rs index ad74a786e4..db1313755a 100644 --- a/desktop/src-tauri/src/transport/api.rs +++ b/desktop/src-tauri/src/transport/api.rs @@ -75,6 +75,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, @@ -389,6 +429,36 @@ impl ServerApi { self.execute(ObservedOperation::GetDefaultScope, 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(ObservedOperation::GetStats, 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 d11551e82a..5da31bdd82 100644 --- a/desktop/src-tauri/src/transport/mod.rs +++ b/desktop/src-tauri/src/transport/mod.rs @@ -17,7 +17,8 @@ mod api; pub mod wire; pub use api::{ - ApiFailure, AtomicMemoryDetail, DiscoveryDocument, MemorySaveResponse, MemorySearchHit, + ApiFailure, AtomicMemoryDetail, DiscoveryDocument, ExactRecallTokenStatistics, + ExactScopeStatistics, ExactScopeStatisticsItem, MemorySaveResponse, MemorySearchHit, MemorySearchResponse, ServerApi, validate_text, }; diff --git a/desktop/src-tauri/src/transport/operations.json b/desktop/src-tauri/src/transport/operations.json index 6141bee260..ddf80cd639 100644 --- a/desktop/src-tauri/src/transport/operations.json +++ b/desktop/src-tauri/src/transport/operations.json @@ -37,6 +37,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}" @@ -82,6 +86,7 @@ "search_memory": [ "memory.explicit" ], + "get_stats": [], "get_artifact_revision": [], "get_access_principal": [ "access.principal" diff --git a/desktop/src-tauri/src/transport/wire.rs b/desktop/src-tauri/src/transport/wire.rs index e7ffda6e5a..8542a7020e 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, Default, Debug, PartialEq, Serialize, Deserialize, TS)] #[serde(deny_unknown_fields)] pub struct ArtifactReference { @@ -618,6 +656,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 { @@ -886,6 +1027,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 { @@ -1095,6 +1260,22 @@ 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 } + } +} + /// FeatureContract : Compatibility version for exactly the listed OpenAPI operation IDs. Adding operations or compatible semantics increments minor; removing, renaming or incompatibly changing a listed operation increments major. #[derive(Clone, Default, Debug, PartialEq, Serialize, Deserialize, TS)] pub struct FeatureContract { @@ -1115,6 +1296,26 @@ impl FeatureContract { } } +#[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 { @@ -1129,6 +1330,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 { @@ -1171,6 +1471,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, )] @@ -1302,6 +1705,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 { @@ -1356,6 +1937,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 { @@ -1475,6 +2094,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 { @@ -1576,6 +2288,32 @@ pub enum Product { Powercontext, } +#[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 { @@ -1597,6 +2335,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 { @@ -1637,6 +2425,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![ @@ -1647,8 +2484,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), format!("type ArtifactReadFamily = {};", ::name(config)), ::decl(config), ::decl(config), @@ -1657,6 +2497,9 @@ 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), @@ -1665,17 +2508,29 @@ 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), @@ -1684,21 +2539,37 @@ 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), ] } diff --git a/desktop/src-tauri/tests/ipc.rs b/desktop/src-tauri/tests/ipc.rs index 7ad171c337..52e4e4032d 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", @@ -253,12 +282,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} @@ -268,6 +300,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/session.rs b/desktop/src-tauri/tests/session.rs index e4190a7631..fbf9ce1870 100644 --- a/desktop/src-tauri/tests/session.rs +++ b/desktop/src-tauri/tests/session.rs @@ -372,3 +372,22 @@ async fn bundled_qualification_allows_explicit_scope_browsing() { server.release.notify_one(); assert!(manager.scopes(active.generation, "", None).await.is_ok()); } + +#[test] +fn bundled_product_qualification_covers_every_desktop_operation() { + // Read only checked-in product records: CI fixture injection must not satisfy this gate. + let profiles: Vec = + serde_json::from_str(include_str!("../src/connections/compatibility.json")).unwrap(); + let contract: serde_json::Value = + serde_json::from_str(include_str!("../src/transport/operations.json")).unwrap(); + let required = contract["operations"].as_object().unwrap(); + assert!( + profiles.iter().any(|profile| { + Some(profile.contract_sha256.as_str()) == contract["contractSha256"].as_str() + && required + .keys() + .all(|operation| profile.operations.contains(operation)) + }), + "the bundled contract needs a product qualification covering all Desktop operations, including statistics" + ); +} diff --git a/desktop/src-tauri/tests/statistics.rs b/desktop/src-tauri/tests/statistics.rs new file mode 100644 index 0000000000..3ff19c0a5d --- /dev/null +++ b/desktop/src-tauri/tests/statistics.rs @@ -0,0 +1,446 @@ +/* + * 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, + session_observations::{ObservationOutcome, ObservationUnit, ObservedOperation}, + 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, + server_changed: 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 server_changed = Arc::new(AtomicBool::new(false)); + let changed = server_changed.clone(); + 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(), + ); + let changed = changed.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/server-info ") { + ( + 200, + serde_json::json!({ + "schema_version":{"major":1,"minor":0}, + "product":"powercontext", + "server_id": if changed.load(Ordering::Relaxed) { "replacement-statistics-server" } else { "synthetic-statistics-server" }, + "package_version":"synthetic", + "api_contract_version":{"major":1,"minor":4}, + "feature_contracts":{ + "scope.selection":{ + "version":{"major":1,"minor":0}, + "operations":["get_scope","list_scopes","get_default_scope"] + } + } + }), + ) + } 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, + server_changed, + 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"]); + let summary = fixture.manager.observations().summary().unwrap(); + assert!( + summary + .coverage + .http_operations + .contains(&ObservedOperation::GetStats) + ); + assert!( + summary + .coverage + .user_operations + .contains(&ObservedOperation::GetStats) + ); + for unit in [ObservationUnit::HttpRequest, ObservationUnit::UserOperation] { + let records: Vec<_> = summary + .recent + .iter() + .filter(|record| record.operation == ObservedOperation::GetStats && record.unit == unit) + .collect(); + assert_eq!(records.len(), 1); + assert_eq!(records[0].outcome, ObservationOutcome::Success); + } + 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); + let summary = fixture.manager.observations().summary().unwrap(); + for unit in [ObservationUnit::HttpRequest, ObservationUnit::UserOperation] { + let records: Vec<_> = summary + .recent + .iter() + .filter(|record| { + record.operation == ObservedOperation::GetStats && record.unit == unit + }) + .collect(); + assert_eq!(records.len(), 1); + assert_eq!(records[0].outcome, ObservationOutcome::Failure); + } + } +} + +#[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); + let summary = fixture.manager.observations().summary().unwrap(); + for unit in [ObservationUnit::HttpRequest, ObservationUnit::UserOperation] { + let records: Vec<_> = summary + .recent + .iter() + .filter(|record| { + record.operation == ObservedOperation::GetStats && record.unit == unit + }) + .collect(); + assert_eq!(records.len(), 1); + assert_eq!(records[0].outcome, ObservationOutcome::Cancelled); + } + } +} + +#[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()); +} + +#[tokio::test] +async fn deployment_changes_before_or_during_statistics_discard_private_results() { + for during_read in [false, true] { + let fixture = setup().await; + let generation = fixture.manager.state().unwrap().generation; + fixture.blocked.store(during_read, Ordering::Relaxed); + if !during_read { + fixture.server_changed.store(true, Ordering::Relaxed); + } + let manager = fixture.manager.clone(); + let pending = + tokio::spawn(async move { manager.stats(generation, StatsPeriod::Today).await }); + if during_read { + fixture.started.notified().await; + fixture.server_changed.store(true, Ordering::Relaxed); + fixture.release.notify_one(); + } + let result = tokio::time::timeout(std::time::Duration::from_secs(3), pending) + .await + .unwrap() + .unwrap(); + assert_eq!(result.unwrap_err().code, SafeError::StaleContext); + assert!(fixture.manager.state().unwrap().active.is_none()); + assert_eq!( + fixture.requests.lock().unwrap().len(), + usize::from(during_read) + ); + } +} diff --git a/desktop/tests/installed_fixture.py b/desktop/tests/installed_fixture.py index 037a4fc776..d7ef6e769b 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")) @@ -109,6 +111,7 @@ def isolated_server( "uvicorn_log_level": "warning", "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..4eb9939905 --- /dev/null +++ b/desktop/tests/installed_statistics.py @@ -0,0 +1,283 @@ +# 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 open_statistics(page: InstalledPage, language: str = "zh") -> None: + page.button("Observation" if language == "en" else "观察") + tab = "Current-Scope inventory & usage" if language == "en" else "当前 Scope 库存与用量" + page.click(f"//button[@role='tab' and normalize-space(.)='{tab}']") + + +def exercise_statistics( + page: InstalledPage, server: httpx.Client, scope: str, usage_control: Path +) -> dict[str, object]: + open_statistics(page) + 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']") + open_statistics(page, "en") + load_and_compare(page, server, scope, "7d", "en") + page.button("Settings & diagnostics") + page.click("//select[@id='language']/option[@value='zh']") + open_statistics(page) + 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) + open_statistics(page) + 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) + open_statistics(page) + 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 31618581c2..611dbb149a 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" @@ -129,8 +130,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.element("//div[@role='dialog' and @aria-label='选择精确范围']") @@ -206,9 +210,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( @@ -237,6 +245,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/desktop/tests/real_server.py b/desktop/tests/real_server.py index 43db2b8821..a3e0e66e6b 100644 --- a/desktop/tests/real_server.py +++ b/desktop/tests/real_server.py @@ -21,6 +21,7 @@ # ruff: noqa: S603, S607 from __future__ import annotations +import argparse import asyncio import hashlib import json @@ -46,10 +47,15 @@ def __init__(self, code: str, detail: str = "") -> None: super().__init__(f"Desktop isolated fixture failed ({code}): {detail}") -def probe_measurement(output: str) -> dict[str, object]: +def probe_measurement(output: str, product_profile_id: str | None = None) -> dict[str, object]: measurement = json.loads(output) if measurement.get("result") != "passed": raise HarnessFailure("native_probe_result") + if product_profile_id is not None and ( + measurement.get("qualificationSource") != "bundled product records" + or measurement.get("qualificationId") != product_profile_id + ): + raise HarnessFailure("product_probe_qualification") return measurement["performance"] @@ -134,6 +140,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 measured_requests(app): """Observe synthetic HTTP completion without recording request content.""" request_number = 0 @@ -213,6 +253,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( @@ -330,6 +373,22 @@ def retained_fixture_server( print("Fixture Server diagnostics could not be retained.", file=sys.stderr) +def product_profile(desktop: Path, profile_id: str) -> dict[str, object]: + """Require recorded wheel/contract identity and complete product operation coverage.""" + from fixture_qualification import build_profile + + profiles = json.loads((desktop / "src-tauri/src/connections/compatibility.json").read_text(encoding="utf-8")) + compatibility = next((profile for profile in profiles if profile["id"] == profile_id), None) + if compatibility is None: + raise HarnessFailure("product_qualification_missing") + built = build_profile(desktop, compatibility["serverCommit"]) + if any(compatibility[field] != built[field] for field in ("contractSha256", "artifactSha256")) or not set( + built["operations"] + ).issubset(compatibility["operations"]): + raise HarnessFailure("product_qualification_mismatch") + return compatibility + + def main() -> None: import httpx from fixture_qualification import fixture_profile @@ -341,7 +400,12 @@ def main() -> None: if len(wheels) != 1: raise HarnessFailure("wheel_count") wheel = wheels[0] - compatibility = fixture_profile() + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--product-qualification", help="Use a checked-in product record and the normal native manager") + args = parser.parse_args() + compatibility = ( + product_profile(desktop, args.product_qualification) if args.product_qualification else fixture_profile() + ) executable = ( desktop / "src-tauri/target/debug/examples" / ("server_probe.exe" if os.name == "nt" else "server_probe") ) @@ -364,6 +428,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"), @@ -438,7 +503,9 @@ def main() -> None: scope_id = result.json()["scope_id"] fixture = { "compatibility_profile": compatibility, + "product_qualification": bool(args.product_qualification), "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, @@ -466,7 +533,7 @@ def main() -> None: "providerIdentityChangeInvalidatesContext": bool(config["provider"]), "sameIdentityRevocationDeniesExactMemoryRead": bool(config["provider"]), "sameTitleScopePagination51": bool(config["provider"]), - "performance": probe_measurement(probe.stdout), + "performance": probe_measurement(probe.stdout, args.product_qualification), }) report = { "environment": { @@ -477,6 +544,8 @@ def main() -> None: "percentileMethod": "nearest rank", "budget": "No approved numerical budget; observation only", }, + "qualificationId": compatibility["id"], + "qualificationSource": "bundled product records" if args.product_qualification else "disposable CI fixture", "serverWheel": wheel.name, "serverWheelSha256": hashlib.sha256(wheel.read_bytes()).hexdigest(), "contractSha256": hashlib.sha256( diff --git a/desktop/ui/src/app/App.tsx b/desktop/ui/src/app/App.tsx index a3b895a6fa..712deaf01e 100644 --- a/desktop/ui/src/app/App.tsx +++ b/desktop/ui/src/app/App.tsx @@ -31,6 +31,7 @@ import observationIcon from "../assets/retrieval.svg"; import { TopBar } from "./TopBar"; import { Overview } from "./Overview"; +import { Statistics } from "./Statistics"; import { MemoryWorkspace, SubmittedDraftRecovery, @@ -421,6 +422,15 @@ export function App() { tab={observationTab} onTabChange={setObservationTab} panels={{ + inventory: ( + + ), session: ( void; + contextPending?: boolean; +}; +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, + contextPending = false, +}: Props) { + const t = statisticsMessages[language]; + const titleId = useId(); + const generation = state?.active?.generation; + const active = state?.active; + const enabled = + !contextPending && + !!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, enabled]); + 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}

+ {contextPending ? ( +

{t.contextPending}

+ ) : !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/session-messages.ts b/desktop/ui/src/app/session-messages.ts index 678e798d96..cb0b2e39a0 100644 --- a/desktop/ui/src/app/session-messages.ts +++ b/desktop/ui/src/app/session-messages.ts @@ -154,6 +154,7 @@ export const sessionMessages = { remember_memory: "保存记忆", search_memory: "搜索记忆", get_artifact_revision: "读取精确版本", + get_stats: "读取当前 Scope 统计", }, }, en: { @@ -300,6 +301,7 @@ export const sessionMessages = { remember_memory: "Save Memory", search_memory: "Search Memory", get_artifact_revision: "Read exact revision", + get_stats: "Read current-Scope statistics", }, }, } as const; diff --git a/desktop/ui/src/app/statistics-messages.ts b/desktop/ui/src/app/statistics-messages.ts new file mode 100644 index 0000000000..9315216730 --- /dev/null +++ b/desktop/ui/src/app/statistics-messages.ts @@ -0,0 +1,154 @@ +/* + * 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.", + contextPending: "Switching connection or Scope…", + 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: "请启用连接并选择精确范围后查看统计。", + contextPending: "正在切换连接或范围…", + 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 8076af40c0..3dfddbac22 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, .saved-hits { list-style: none; diff --git a/desktop/ui/src/generated/ipc.ts b/desktop/ui/src/generated/ipc.ts index fdfe8d9d6f..694a854a7a 100644 --- a/desktop/ui/src/generated/ipc.ts +++ b/desktop/ui/src/generated/ipc.ts @@ -100,8 +100,11 @@ 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 ObservationUnit = "http_request" | "user_operation"; -export type ObservedOperation = "get_liveness" | "get_server_info" | "get_readiness" | "get_capabilities" | "get_access_principal" | "list_scopes" | "get_scope" | "get_default_scope" | "remember_memory" | "search_memory" | "get_artifact_revision"; +export type ObservedOperation = "get_liveness" | "get_server_info" | "get_readiness" | "get_capabilities" | "get_access_principal" | "list_scopes" | "get_scope" | "get_default_scope" | "get_stats" | "remember_memory" | "search_memory" | "get_artifact_revision"; export type ObservationOutcome = "success" | "failure" | "cancelled" | "unknown"; export type SessionObservation = { unit: ObservationUnit, operation: ObservedOperation, outcome: ObservationOutcome, completedAtMs: number, durationMs: number, httpStatus: number | null, errorCode: SafeError | null, }; export type OutcomeCounts = { success: number, failure: number, cancelled: number, unknown: number, }; @@ -125,8 +128,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 = string; export type ArtifactReference = { family: string, artifact_id: string, revision: number, }; export type ArtifactRevision = { scope_id: string, family: string, artifact_id: string, @@ -159,6 +165,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 = { custom_artifact_schemas?: Array, prompts?: { [key in string]: PromptCapability }, /** * Whether asynchronous Artifact Dream execution is configured. @@ -203,6 +212,8 @@ search_fields: Array, search?: Record, }; 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. @@ -252,12 +263,22 @@ 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 FeatureContract = { version: ContractVersion, operations: Array, }; +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 Product = "powercontext"; @@ -266,6 +287,11 @@ 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. @@ -275,11 +301,15 @@ text: string, reason?: string | null, expected_revision?: number | null, * Optional supplied single-line summary. When omitted, the Server stores a whitespace-collapsed text prefix of at most 200 Unicode characters, including a trailing ellipsis when truncated. Explicit writes do not invoke a generation model. */ description?: string | 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 ServerInfo = { @@ -300,6 +330,7 @@ api_contract_version: ContractVersion, */ feature_contracts: { [key in string]: FeatureContract }, }; 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. @@ -311,6 +342,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 1e2f36add1..c5b738c274 100644 --- a/desktop/ui/src/generated/operations.ts +++ b/desktop/ui/src/generated/operations.ts @@ -53,6 +53,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 b9e75642d5..c3c49647d4 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, @@ -34,6 +36,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 }), sessionSummary: () => invoke("session_summary"), resetSessionObservations: () => invoke("reset_session_observations"), previewSessionDiagnostics: () => diff --git a/desktop/ui/tests/observation.test.tsx b/desktop/ui/tests/observation.test.tsx index 8164c61163..2d0b50b792 100644 --- a/desktop/ui/tests/observation.test.tsx +++ b/desktop/ui/tests/observation.test.tsx @@ -38,6 +38,8 @@ vi.mock("../src/shared/ipc", () => ({ check: vi.fn(), cancelMemory: vi.fn().mockResolvedValue(undefined), diagnostics: vi.fn(), + stats: vi.fn(), + cancelStats: vi.fn().mockResolvedValue(undefined), }, })); const disconnected: DesktopState = { @@ -101,7 +103,7 @@ test("Observation tabs support the keyboard and read only local session observat screen.getByRole("tab", { name: "当前 Scope 库存与用量" }), ); expect(screen.getByRole("tabpanel").textContent).toContain( - "未知值不表示为零", + "请启用连接并选择精确范围后查看统计。", ); await user.keyboard("{End}"); expect(document.activeElement).toBe( @@ -142,6 +144,109 @@ test("Observation follows the existing language setting and retains its selected ).toBeTruthy(); }); +test.each(["en", "zh"] as const)( + "qualified exact-Scope statistics are reachable through App Observation in %s", + async (language) => { + const user = userEvent.setup(); + const fact = { value: null, error: null }; + vi.mocked(desktopApi.state).mockResolvedValue({ + ...disconnected, + generation: 1, + active: { + connectionId: "fixture", + generation: 1, + scope: { + scope_id: "scope-a", + title: "Scope A", + summary: "", + version: 1, + context_references: [], + external_references: [], + }, + report: { + connectionId: "fixture", + revision: 1, + checkedAt: 1, + liveness: fact, + readiness: fact, + identity: fact, + capabilities: fact, + compatibilityVerified: true, + anonymousAccess: true, + supportedOperations: ["get_stats"], + }, + }, + }); + vi.mocked(desktopApi.stats).mockRejectedValueOnce({ code: "network" }); + render(); + await screen.findByRole("button", { name: /Scope A/ }); + if (language === "en") { + await user.click(screen.getByRole("button", { name: "设置与诊断" })); + await user.selectOptions(screen.getByLabelText("语言"), "en"); + } + expect( + screen.queryByRole("button", { name: /^(Load statistics|加载统计)$/ }), + ).toBeNull(); + await user.click( + screen.getByRole("button", { + name: language === "en" ? "Observation" : "观察", + }), + ); + await user.click( + screen.getByRole("tab", { + name: + language === "en" + ? "Current-Scope inventory & usage" + : "当前 Scope 库存与用量", + }), + ); + const panel = within(screen.getByRole("tabpanel")); + expect(panel.getByText("scope-a", { selector: "code" })).toBeTruthy(); + expect( + panel.queryByText( + /此观察视图尚未可用|This observation view is not available yet/, + ), + ).toBeNull(); + expect(desktopApi.stats).not.toHaveBeenCalled(); + await user.click( + panel.getByRole("button", { + name: language === "en" ? "Load statistics" : "加载统计", + }), + ); + await waitFor(() => + expect(desktopApi.stats).toHaveBeenCalledWith(1, "30d"), + ); + expect(await panel.findByRole("alert")).toBeTruthy(); + await user.click( + screen.getByRole("tab", { + name: language === "en" ? "This session" : "本次会话", + }), + ); + expect( + await screen.findByRole("heading", { + name: + language === "en" + ? "No completed observations yet" + : "尚无已完成的观察", + }), + ).toBeTruthy(); + await user.click( + screen.getByRole("tab", { + name: + language === "en" + ? "Current-Scope inventory & usage" + : "当前 Scope 库存与用量", + }), + ); + expect( + within(screen.getByRole("tabpanel")).getByRole("button", { + name: language === "en" ? "Load statistics" : "加载统计", + }), + ).toBeTruthy(); + expect(desktopApi.stats).toHaveBeenCalledTimes(1); + }, +); + test("entering Observation respects the existing Memory draft confirmation", async () => { const user = userEvent.setup(); const fact = { value: null, error: null }; diff --git a/desktop/ui/tests/session.test.tsx b/desktop/ui/tests/session.test.tsx index 8a2010281e..de9979b796 100644 --- a/desktop/ui/tests/session.test.tsx +++ b/desktop/ui/tests/session.test.tsx @@ -73,6 +73,31 @@ for (const language of ["zh", "en"] as const) { expect(desktopApi.previewSessionDiagnostics).not.toHaveBeenCalled(); }); + test(`statistics requests and actions have localized names in coverage and summary (${language})`, async () => { + const summary = recordedSessionSummary(); + summary.coverage.httpOperations = ["get_stats"]; + summary.coverage.userOperations = ["get_stats"]; + summary.operations = summary.operations.map((operation) => ({ + ...operation, + operation: "get_stats", + })); + vi.mocked(desktopApi.sessionSummary).mockResolvedValue(summary); + render(); + const t = sessionMessages[language]; + for (const name of [t.http, t.actions]) { + const table = await screen.findByRole("table", { name }); + expect( + within(table).getByRole("rowheader", { + name: `${t.operations.get_stats} get_stats`, + }), + ).toBeTruthy(); + } + await userEvent.setup().click(screen.getByText(t.operationCoverage)); + expect( + screen.getAllByText(t.operations.get_stats, { selector: "li" }), + ).toHaveLength(2); + }); + test(`HTTP and native action outcomes, units, latency and incomplete coverage remain distinct (${language})`, async () => { vi.mocked(desktopApi.sessionSummary).mockResolvedValue( recordedSessionSummary(), diff --git a/desktop/ui/tests/statistics.test.tsx b/desktop/ui/tests/statistics.test.tsx new file mode 100644 index 0000000000..645166353f --- /dev/null +++ b/desktop/ui/tests/statistics.test.tsx @@ -0,0 +1,368 @@ +/* + * 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", "remember_memory"], + 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)( + "a pending context transition hides statistics and discards its late response in %s", + async (language) => { + const user = userEvent.setup(); + const localized = { ...props, language }; + const loadLabel = language === "en" ? "Load statistics" : "加载统计"; + let finish!: (value: ExactScopeStatistics) => void; + vi.mocked(desktopApi.stats).mockImplementationOnce( + () => + new Promise((resolve) => { + finish = resolve; + }), + ); + const view = render(); + await user.click(screen.getByRole("button", { name: loadLabel })); + view.rerender(); + expect(screen.queryByRole("button", { name: loadLabel })).toBeNull(); + expect(screen.queryByRole("table")).toBeNull(); + expect(screen.getByRole("status").textContent).toBe( + language === "en" + ? "Switching connection or Scope…" + : "正在切换连接或范围…", + ); + expect( + screen.queryByText( + language === "en" + ? /Statistics are not qualified/ + : /此连接尚无统计操作/, + ), + ).toBeNull(); + // Even a failed transition that leaves the original generation must not + // revive a response started before that transition. + view.rerender(); + await act(async () => finish(statistics())); + expect(screen.queryByRole("table")).toBeNull(); + expect( + screen.getByText(language === "en" ? /not been loaded/ : /尚未加载/), + ).toBeTruthy(); + expect(desktopApi.stats).toHaveBeenCalledTimes(1); + }, +); + +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-session-observations.md b/docs/en/development/desktop-session-observations.md index 290030983f..82e3cad085 100644 --- a/docs/en/development/desktop-session-observations.md +++ b/docs/en/development/desktop-session-observations.md @@ -18,16 +18,16 @@ Every record declares `unit` and the owning operation ID in `operation`. | Unit | Counted work | Duration in milliseconds | | --- | --- | --- | | `http_request` | One request handed to the native HTTP client | Monotonic time from dispatch through bounded response reading and JSON decoding, or failure/cancellation | -| `user_operation` | One native Memory or Scope action invocation, including rejected invocations | Monotonic time from entering the native action through context/identity checks, response validation and result construction, or failure/cancellation | +| `user_operation` | One native Memory, Scope or statistics action invocation, including rejected invocations | Monotonic time from entering the native action through context/identity checks, response validation and result construction, or failure/cancellation | HTTP coverage includes `get_server_info`, `get_liveness`, `get_readiness`, `get_capabilities`, -`get_access_principal`, `list_scopes`, `get_scope`, `get_default_scope`, `remember_memory`, `search_memory`, and `get_artifact_revision`. Native connection +`get_access_principal`, `list_scopes`, `get_scope`, `get_default_scope`, `get_stats`, `remember_memory`, `search_memory`, and `get_artifact_revision`. Native connection checks contribute their individual HTTP requests, not an additional user operation. Identity revalidation and its existing anonymous-access readiness fallback also contribute HTTP requests. A user action can therefore issue multiple HTTP requests. The summary's `coverage.httpOperations` and `coverage.identityChecksIncluded` disclose that coverage. -User-operation coverage is the six Memory/Scope operations listed in `coverage.userOperations`: `list_scopes`, -`get_scope`, `get_default_scope`, `remember_memory`, `search_memory`, and `get_artifact_revision`. It does not include +User-operation coverage is the seven Memory/Scope/statistics operations listed in `coverage.userOperations`: `list_scopes`, +`get_scope`, `get_default_scope`, `get_stats`, `remember_memory`, `search_memory`, and `get_artifact_revision`. It does not include profile editing, connection checks, renderer debounce, UI confirmation time, or clipboard actions. Native pre-dispatch validation/concurrency refusals can produce a failed user operation without an HTTP record. HTTP latency is client request time, not Server processing time; user-operation latency is native action time, not the entire UI journey. diff --git a/docs/en/development/desktop-statistics.md b/docs/en/development/desktop-statistics.md new file mode 100644 index 0000000000..954608a320 --- /dev/null +++ b/docs/en/development/desktop-statistics.md @@ -0,0 +1,63 @@ +--- +title: "Desktop Scope statistics" +description: "Exact-Scope native statistics, isolation and qualification." +--- + +# Desktop Scope statistics + +## Inventory and usage view + +**Observation → Current-Scope inventory & usage** contains the Statistics view. +Explicitly select the statistics-qualified `sqlite-stats-63e9665c-v1` profile, +activate the 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 mounts `Statistics` in the Observation inventory panel with the +current state, language, pending-context guard 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. +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](https://github.com/oceanbase/powercontext/blob/master/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. 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-session-observations.md b/docs/zh/development/desktop-session-observations.md index 8b68b8ef95..eabaedc6fa 100644 --- a/docs/zh/development/desktop-session-observations.md +++ b/docs/zh/development/desktop-session-observations.md @@ -18,15 +18,15 @@ Desktop 为 [#2107](https://github.com/oceanbase/powercontext/issues/2107) 提 | 单位 | 计数对象 | 耗时口径,单位为毫秒 | | --- | --- | --- | | `http_request` | 一次交给原生 HTTP 客户端的请求 | 从派发到完成有界响应读取、JSON 解码,或失败、取消的单调时钟耗时 | -| `user_operation` | 一次原生 Memory 或 Scope 操作调用,包括被拒绝的调用 | 从进入原生操作到完成上下文与身份检查、响应校验及结果构造,或失败、取消的单调时钟耗时 | +| `user_operation` | 一次原生 Memory、Scope 或统计操作调用,包括被拒绝的调用 | 从进入原生操作到完成上下文与身份检查、响应校验及结果构造,或失败、取消的单调时钟耗时 | HTTP 覆盖 `get_server_info`、`get_liveness`、`get_readiness`、`get_capabilities`、`get_access_principal`、`list_scopes`、 -`get_scope`、`get_default_scope`、`remember_memory`、`search_memory` 和 `get_artifact_revision`。 +`get_scope`、`get_default_scope`、`get_stats`、`remember_memory`、`search_memory` 和 `get_artifact_revision`。 连接检查只计入它实际发出的各项 HTTP 请求,不额外产生用户操作。身份复核及现有匿名访问的就绪检查回退也计入 HTTP 请求。 因此,一次用户操作可能产生多个请求;`coverage.httpOperations` 和 `coverage.identityChecksIncluded` 明确说明该范围。 -用户操作覆盖 `coverage.userOperations` 中的六项 Memory/Scope 操作:`list_scopes`、`get_scope`、 -`get_default_scope`、`remember_memory`、`search_memory` 和 `get_artifact_revision`。不包含配置编辑、连接检查、 +用户操作覆盖 `coverage.userOperations` 中的七项 Memory/Scope/统计操作:`list_scopes`、`get_scope`、 +`get_default_scope`、`get_stats`、`remember_memory`、`search_memory` 和 `get_artifact_revision`。不包含配置编辑、连接检查、 渲染层防抖、界面确认等待或剪贴板操作。派发前的本地校验和并发限制可能产生失败的用户操作,而没有 HTTP 记录。 HTTP 延迟是客户端请求耗时,不能解释为 Server 内部处理耗时;用户操作延迟是原生操作耗时,不代表完整界面旅程。 diff --git a/docs/zh/development/desktop-statistics.md b/docs/zh/development/desktop-statistics.md new file mode 100644 index 0000000000..bc6565c1d1 --- /dev/null +++ b/docs/zh/development/desktop-statistics.md @@ -0,0 +1,52 @@ +--- +title: "桌面范围统计" +description: "精确范围的原生统计、隔离与兼容资格。" +--- + +# 桌面范围统计 + +## 存量与用量视图 + +统计需要显式选择 `sqlite-stats-63e9665c-v1` 配置;独立验证的 +`sqlite-atomic-3cefa45a-v1` 配置仅覆盖 Memory 操作。 + +“观察 → 当前 Scope 库存与用量”包含统计视图。启用已验证兼容的连接、选择精确 +范围及 UTC 周期后,点击“加载统计”。加载是显式操作;打开视图不会自动恢复 +连接或发起统计请求。共享布局在 Observation 库存面板挂载 `Statistics` +组件,传入当前状态、语言、上下文切换保护及状态刷新回调。 + +视图显示 Server 返回的 `as_of`、精确范围覆盖及含首尾的 UTC 日期。存量是当前 +快照。模型请求、输入/输出 token、用途分组和每日数据保留返回的零值及空值; +空 token 显示“未知”。页面不补齐缺失行,也不从已记录调用推断提供商完整上报。 +召回覆盖展示准备、就绪及可比较次数、估算器及版本、基线、召回 token 和有符号的 +减少量。这些是估算,不是账单节省或答案质量指标。 + +每个图表都有带标题及行列头的等价表格。图表和表格的滚动区域可通过键盘聚焦, +适用于窄布局;文案支持中英文。切换连接、范围或周期会清除旧视图,并忽略迟到的 +成功或失败响应。统计失败不禁用独立的 Memory 操作。 + +## 原生边界 + +桌面通过原生 `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 标识和实际验证边界见 +[资格记录](https://github.com/oceanbase/powercontext/blob/master/desktop/VALIDATION.md#current-scope-statistics-qualification)。 +模拟测试验证本地处理;构建 wheel 的独立夹具另行验证实际 SQLite 协议行为。 +记录另行列明已执行的 macOS ARM64 打包调试应用流程,以及仍待验证的 Windows +安装界面、Intel、输入法和屏幕阅读器 gate。Windows 安装流程将页面表格与独立的 +真实 Server 响应进行比较。 diff --git a/tests/test_desktop_fixture_qualification.py b/tests/test_desktop_fixture_qualification.py index f53948154d..f226b9b2ea 100644 --- a/tests/test_desktop_fixture_qualification.py +++ b/tests/test_desktop_fixture_qualification.py @@ -107,3 +107,21 @@ def test_stale_generated_contract_cannot_create_fixture_qualification(desktop: P with pytest.raises(ValueError, match="fixture_generated_contract_drift"): qualification.build_profile(desktop, "a" * 40) assert not (desktop / ".artifacts/ci-compatibility.json").exists() + + +def test_product_acceptance_rejects_a_fixture_only_native_probe() -> None: + specification = importlib.util.spec_from_file_location("desktop_real_server", ROOT / "desktop/tests/real_server.py") + assert specification is not None and specification.loader is not None + module = importlib.util.module_from_spec(specification) + specification.loader.exec_module(module) + # A stale executable can ignore the new input flag and still complete using + # injected qualification. It must not be accepted as product-path evidence. + old_probe = {"result": "passed", "performance": {"samples": []}} + with pytest.raises(module.HarnessFailure, match="product_probe_qualification"): + module.probe_measurement(json.dumps(old_probe), "qualified-product") + product_probe = { + **old_probe, + "qualificationSource": "bundled product records", + "qualificationId": "qualified-product", + } + assert module.probe_measurement(json.dumps(product_probe), "qualified-product") == {"samples": []}