From 0577b34c6a120f2bb33d8ffa9a87a0bbbd00dc66 Mon Sep 17 00:00:00 2001 From: knqiufan Date: Thu, 5 Nov 2026 00:28:54 +0800 Subject: [PATCH] feat(desktop): add Runtime state observations --- desktop/README.md | 12 +- desktop/README.zh.md | 7 +- desktop/src-tauri/build.rs | 4 + desktop/src-tauri/capabilities/main.json | 4 + desktop/src-tauri/examples/export_ipc.rs | 4 +- desktop/src-tauri/examples/server_probe.rs | 12 + desktop/src-tauri/src/commands.rs | 49 ++ desktop/src-tauri/src/connections/session.rs | 9 + .../src/connections/session/runtime.rs | 234 +++++++ desktop/src-tauri/src/lib.rs | 4 + .../src-tauri/tests/deployment_identity.rs | 165 ++++- desktop/src-tauri/tests/ipc.rs | 10 +- desktop/src-tauri/tests/macos_diagnostics.rs | 35 + desktop/ui/src/app/App.tsx | 11 + desktop/ui/src/app/Diagnostics.tsx | 34 +- desktop/ui/src/app/Runtime.tsx | 659 ++++++++++++++++++ desktop/ui/src/generated/ipc.ts | 2 + desktop/ui/src/shared/ipc.ts | 18 + desktop/ui/tests/runtime.test.tsx | 261 +++++++ 19 files changed, 1518 insertions(+), 16 deletions(-) create mode 100644 desktop/src-tauri/src/connections/session/runtime.rs create mode 100644 desktop/ui/src/app/Runtime.tsx create mode 100644 desktop/ui/tests/runtime.test.tsx diff --git a/desktop/README.md b/desktop/README.md index cd456a89fb..c2029998d9 100644 --- a/desktop/README.md +++ b/desktop/README.md @@ -6,7 +6,15 @@ 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. 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 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. Opening the page or switching tabs does not run connection checks or local diagnostics. English/Chinese and existing theme settings apply to Observation. + +**Observation → Runtime state** shows the selected Server's liveness and available capabilities, with separate UTC +snapshots and process-level Memory extraction history. Refresh does not probe models. **Probe readiness** is a separate, +explicit action for a qualified connection with current observation access; it may contact configured dependencies. +Local Ops inspection and registered CLI diagnostics describe this computer and work independently of the remote Server. +Installation, recovery and service changes belong in **Local Server settings**. Local states are shown only when the +native service manager or helper reports them; an unreachable HTTP endpoint does not establish that a service is stopped +or starting. 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. @@ -17,7 +25,7 @@ with independent retention/loss coverage, and contribute no HTTP or Memory/Scope ## Shared feedback and current context -Every page uses the same loading, empty, unavailable and error presentation. Loading local state is distinct from being disconnected; a local-state failure offers **Reload local state**, without probing Server readiness. Empty search results mean no matches for that query, not an empty Scope inventory. Unimplemented Observation views remain unavailable. +Every page uses the same loading, empty, unavailable and error presentation. Loading local state is distinct from being disconnected; a local-state failure offers **Reload local state**, without probing Server readiness. Empty search results mean no matches for that query, not an empty Scope inventory. Errors explain the affected operation and recovery. Read failures offer an explicit retry; exact reads retain the original Artifact revision. An expired Scope cursor restarts from the first page. Credential, permission and compatibility failures explain the required configuration or authorization change. Save failures keep the draft; unknown writes retain their original target and require the existing confirmation before another submission. Writes are never replayed automatically. Local diagnostic failures offer a retry of the same selected local check and do not indicate remote unavailability. diff --git a/desktop/README.zh.md b/desktop/README.zh.md index 32bafa6544..7c7a87e69f 100644 --- a/desktop/README.zh.md +++ b/desktop/README.zh.md @@ -8,7 +8,12 @@ ## 观察 -侧栏提供总览、连接、记忆、观察入口,“设置与诊断”位于侧栏底部。默认进入总览。“观察”包含运行状态、当前 Scope 库存与用量、本次会话三个标签页。库存与用量已接入统计视图;本次会话读取原生会话观察。运行状态接入前明确显示尚未可用。进入页面或切换标签不会执行连接检查或本机诊断。观察页面沿用现有中英文和主题设置。 +**观察 → 运行状态**分别显示当前 Server 的存活、能力和进程级记忆提取历史,并标明各自的 UTC 快照时间。 +普通刷新不探测模型。连接具备兼容资格和当前观察权限时,可以单独点击**探测就绪状态**,该操作可能访问已配置的依赖。 +本机 Ops 检查和已登记的 CLI 诊断只描述此电脑,与远程 Server 无关。安装、恢复及服务变更请前往**本机 Server 设置**。 +本机状态以服务管理器或可信助手明确报告的事实为准;仅凭 HTTP 无法访问不能判断服务已停止或正在启动。 + +侧栏提供总览、连接、记忆、观察入口,“设置与诊断”位于侧栏底部。默认进入总览。“观察”包含运行状态、当前 Scope 库存与用量、本次会话三个标签页。库存与用量已接入统计视图;本次会话读取原生会话观察。进入页面或切换标签不会执行连接检查或本机诊断。观察页面沿用现有中英文和主题设置。 原生宿主独立于页面导航,采集有界的内存窗口,分别记录 HTTP 请求和 Memory/Scope 用户操作。可信的类型化 IPC 提供白名单摘要、重置和安全 JSON 预览。窗口最多保留 512 条终态记录,保留取消及未知写入的区别,不包含内容或目标身份。测量单位、覆盖范围、重置和故障隔离见[会话观察契约](../docs/zh/development/desktop-session-observations.md)。本次会话分别显示请求与操作的数量、结果和毫秒延迟,以及 UTC 快照时间和保留范围;支持手动刷新和确认后重置。“预览安全诊断摘要”展示原生生成的准确文本,复制是独立的显式操作,提供成功或失败反馈及可选择的手动复制文本。离开视图或切换上下文会关闭预览并丢弃晚到的界面响应。 diff --git a/desktop/src-tauri/build.rs b/desktop/src-tauri/build.rs index fca5a187c1..378b83e5b6 100644 --- a/desktop/src-tauri/build.rs +++ b/desktop/src-tauri/build.rs @@ -47,6 +47,10 @@ fn main() { "search_memory", "atomic_memory_entry", "cancel_memory_reads", + "observe_runtime_liveness", + "observe_runtime_capabilities", + "probe_runtime_readiness", + "cancel_runtime_reads", "get_stats", "cancel_stats_reads", "desktop_state", diff --git a/desktop/src-tauri/capabilities/main.json b/desktop/src-tauri/capabilities/main.json index e0f6ce4e19..89537fa650 100644 --- a/desktop/src-tauri/capabilities/main.json +++ b/desktop/src-tauri/capabilities/main.json @@ -13,6 +13,10 @@ "allow-search-memory", "allow-atomic-memory-entry", "allow-cancel-memory-reads", + "allow-observe-runtime-liveness", + "allow-observe-runtime-capabilities", + "allow-probe-runtime-readiness", + "allow-cancel-runtime-reads", "allow-get-stats", "allow-cancel-stats-reads", "allow-desktop-state", diff --git a/desktop/src-tauri/examples/export_ipc.rs b/desktop/src-tauri/examples/export_ipc.rs index 4ca05a9945..08ae2cdec7 100644 --- a/desktop/src-tauri/examples/export_ipc.rs +++ b/desktop/src-tauri/examples/export_ipc.rs @@ -23,7 +23,7 @@ use powercontext_desktop::{ profiles::{Authentication, CredentialState, ProfileInput, ProfileView}, session::{ ActiveView, CheckReport, CompatibilityProfile, DesktopState, Fact, MemoryContext, - WriteOutcome, WriteRecord, WriteStatus, + RuntimeCapabilities, RuntimeFact, WriteOutcome, WriteRecord, WriteStatus, }, }, credentials::{CredentialWriteReceipt, CredentialWriteRequest, StorageChoice}, @@ -118,6 +118,8 @@ fn main() { ProfileInput::decl(&config), CredentialState::decl(&config), Fact::::decl(&config), + RuntimeFact::::decl(&config), + RuntimeCapabilities::decl(&config), DiscoveryStatus::decl(&config), CompatibilityReason::decl(&config), CompatibilityStatus::decl(&config), diff --git a/desktop/src-tauri/examples/server_probe.rs b/desktop/src-tauri/examples/server_probe.rs index 55e09e9fa7..205f921ee1 100644 --- a/desktop/src-tauri/examples/server_probe.rs +++ b/desktop/src-tauri/examples/server_probe.rs @@ -187,6 +187,18 @@ async fn main() { .any(|report| report.connection_id == profile) ); let generation = manager.check(&profile, true).await.unwrap().generation; + // Check the real Runtime endpoints before selecting a Scope. No generation model is configured. + let live = manager.observe_runtime_liveness(generation).await.unwrap(); + assert_eq!(live.fact.value.unwrap().status, "ok"); + let capabilities = manager + .observe_runtime_capabilities(generation) + .await + .unwrap(); + let runtime_capabilities = capabilities.fact.value.unwrap(); + assert!(!runtime_capabilities.memory_extraction); + assert!(runtime_capabilities.extraction.is_some()); + let readiness = manager.probe_runtime_readiness(generation).await.unwrap(); + assert!(readiness.fact.value.is_some()); let generation = manager .select_scope(generation, &fixture.scope_id) .await diff --git a/desktop/src-tauri/src/commands.rs b/desktop/src-tauri/src/commands.rs index c0838f9e00..2851e0d587 100644 --- a/desktop/src-tauri/src/commands.rs +++ b/desktop/src-tauri/src/commands.rs @@ -193,6 +193,55 @@ pub async fn local_diagnostics( } } +#[tauri::command] +pub async fn observe_runtime_liveness( + window: tauri::WebviewWindow, + state: tauri::State<'_, HostState>, + generation: u32, +) -> Result< + crate::connections::session::RuntimeFact, + ApiFailure, +> { + manager(&window, &state)? + .observe_runtime_liveness(generation) + .await +} +#[tauri::command] +pub async fn observe_runtime_capabilities( + window: tauri::WebviewWindow, + state: tauri::State<'_, HostState>, + generation: u32, +) -> Result< + crate::connections::session::RuntimeFact, + ApiFailure, +> { + manager(&window, &state)? + .observe_runtime_capabilities(generation) + .await +} +#[tauri::command] +pub async fn probe_runtime_readiness( + window: tauri::WebviewWindow, + state: tauri::State<'_, HostState>, + generation: u32, +) -> Result< + crate::connections::session::RuntimeFact, + ApiFailure, +> { + manager(&window, &state)? + .probe_runtime_readiness(generation) + .await +} +#[tauri::command] +pub fn cancel_runtime_reads( + window: tauri::WebviewWindow, + state: tauri::State<'_, HostState>, + generation: u32, +) -> Result<(), ApiFailure> { + manager(&window, &state)? + .cancel_runtime_reads(generation) + .map_err(Into::into) +} #[tauri::command] pub async fn get_stats( window: tauri::WebviewWindow, diff --git a/desktop/src-tauri/src/connections/session.rs b/desktop/src-tauri/src/connections/session.rs index c7ef709b94..a35ee83626 100644 --- a/desktop/src-tauri/src/connections/session.rs +++ b/desktop/src-tauri/src/connections/session.rs @@ -33,6 +33,9 @@ use std::{ use tokio::sync::watch; use ts_rs::TS; +mod runtime; +pub use runtime::{RuntimeCapabilities, RuntimeFact}; + #[derive(Clone, Serialize, TS)] pub struct Fact { pub value: Option, @@ -172,6 +175,9 @@ pub struct ConnectionManager { writes: tokio::sync::Semaphore, memory_reads: watch::Sender, stats_reads: watch::Sender, + runtime_reads: watch::Sender, + runtime_slot: tokio::sync::Semaphore, + runtime_liveness_slot: tokio::sync::Semaphore, } impl ConnectionManager { pub fn new(profiles: ProfileRepository) -> Self { @@ -216,6 +222,9 @@ impl ConnectionManager { writes: tokio::sync::Semaphore::new(1), memory_reads: watch::channel(0).0, stats_reads: watch::channel(0).0, + runtime_reads: watch::channel(0).0, + runtime_slot: tokio::sync::Semaphore::new(1), + runtime_liveness_slot: tokio::sync::Semaphore::new(1), } } fn lock(&self) -> Result, SafeError> { diff --git a/desktop/src-tauri/src/connections/session/runtime.rs b/desktop/src-tauri/src/connections/session/runtime.rs new file mode 100644 index 0000000000..76f9148abe --- /dev/null +++ b/desktop/src-tauri/src/connections/session/runtime.rs @@ -0,0 +1,234 @@ +/* + * 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. + */ + +//! Read-only Runtime observations scoped to an already activated connection. +use super::{ConnectionManager, Fact}; +use crate::{ + error::SafeError, + transport::{ApiFailure, ServerApi, wire::*}, +}; +use serde::Serialize; +use std::{future::Future, sync::Arc}; +use tokio::sync::watch; +use ts_rs::TS; + +#[derive(Serialize, TS)] +#[serde(rename_all = "camelCase")] +pub struct RuntimeFact { + pub checked_at: u64, + pub fact: Fact, +} +impl From> for RuntimeFact { + fn from(result: Result) -> Self { + Self { + checked_at: std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs(), + fact: result.into(), + } + } +} +#[derive(Serialize, TS)] +pub struct RuntimeCapabilities { + pub memory_extraction: bool, + pub handoff_generation: bool, + pub search_modes: Vec, + pub extraction: Option, +} + +impl From for RuntimeCapabilities { + fn from(value: Capabilities) -> Self { + let extraction = value.extraction.map(|mut extraction| { + if let Some(failure) = extraction.observation.last_failure.as_mut() + && !matches!( + failure.code.as_str(), + "model_configuration_error" + | "model_timeout" + | "model_unavailable" + | "invalid_model_output" + | "worker_timeout" + | "worker_crash" + | "invalid_worker_result" + | "missing_durable_acknowledgement" + | "supervisor_failed" + | "lease_renewal_failed" + | "scope_discovery_failed" + | "processing_failed" + ) + { + failure.code = "unknown".into(); + } + extraction + }); + Self { + memory_extraction: value.memory_extraction, + handoff_generation: value.handoff_generation, + search_modes: value.search_modes, + extraction, + } + } +} +struct RuntimeTarget { + api: Arc, + server_id: Option, + identity: Option, + anonymous_access: bool, + compatibility_verified: bool, + readiness_qualified: bool, + changed: watch::Receiver, + cancelled: watch::Receiver, +} +impl ConnectionManager { + fn runtime_target(&self, generation: u32) -> Result { + let inner = self.lock()?; + if generation != inner.generation { + return Err(SafeError::StaleContext); + } + let active = inner.active.as_ref().ok_or(SafeError::NotConnected)?; + let report = &active.view.report; + Ok(RuntimeTarget { + api: active.api.clone(), + server_id: report + .discovery + .as_ref() + .and_then(|d| d.server.as_ref()) + .map(|server| server.server_id.clone()), + identity: report.identity.value.clone(), + anonymous_access: report.anonymous_access, + compatibility_verified: report.compatibility_verified, + readiness_qualified: report + .supported_operations + .iter() + .any(|op| op == "get_readiness"), + changed: self.changed.subscribe(), + cancelled: self.runtime_reads.subscribe(), + }) + } + + pub fn cancel_runtime_reads(&self, generation: u32) -> Result<(), SafeError> { + let inner = self.lock()?; + if generation != inner.generation { + return Err(SafeError::StaleContext); + } + self.runtime_reads + .send_modify(|revision| *revision = revision.wrapping_add(1)); + Ok(()) + } + + // Unlike business revalidation on this baseline, observation never calls readiness + // to recheck anonymous access and never invalidates independently authorized work. + async fn runtime_identity(target: &RuntimeTarget) -> Result<(), ApiFailure> { + Self::deployment_unchanged(&target.api, target.server_id.as_deref()).await?; + if target.server_id.is_none() && !target.compatibility_verified { + return Err(SafeError::CompatibilityUnverified.into()); + } + match target.api.principal().await { + Ok(identity) if Some(&identity) == target.identity.as_ref() => Ok(()), + Err(error) if target.anonymous_access && error.code == SafeError::RuntimeNotReady => { + Ok(()) + } + Err(error) => Err(error), + Ok(_) => Err(SafeError::StaleContext.into()), + } + } + + async fn runtime_budget( + work: impl Future>, + ) -> Result { + tokio::time::timeout(std::time::Duration::from_secs(25), work) + .await + .map_err(|_| SafeError::Timeout)? + } + + pub async fn observe_runtime_liveness( + &self, + generation: u32, + ) -> Result, ApiFailure> { + let target = self.runtime_target(generation)?; + let (mut changed, mut cancelled) = (target.changed.clone(), target.cancelled.clone()); + let _permit = self + .runtime_liveness_slot + .try_acquire() + .map_err(|_| SafeError::Busy)?; + Self::cancellable( + &mut changed, + Self::cancellable(&mut cancelled, async { Ok(target.api.live().await.into()) }), + ) + .await + } + + pub async fn observe_runtime_capabilities( + &self, + generation: u32, + ) -> Result, ApiFailure> { + let target = self.runtime_target(generation)?; + let (mut changed, mut cancelled) = (target.changed.clone(), target.cancelled.clone()); + let _permit = self + .runtime_slot + .try_acquire() + .map_err(|_| SafeError::Busy)?; + Self::cancellable( + &mut changed, + Self::cancellable(&mut cancelled, async { + Ok(Self::runtime_budget(async { + Self::runtime_identity(&target).await?; + let result = target.api.capabilities().await?; + Self::runtime_identity(&target).await?; + Ok(result.into()) + }) + .await + .into()) + }), + ) + .await + } + + pub async fn probe_runtime_readiness( + &self, + generation: u32, + ) -> Result, ApiFailure> { + let target = self.runtime_target(generation)?; + let (mut changed, mut cancelled) = (target.changed.clone(), target.cancelled.clone()); + let _permit = self + .runtime_slot + .try_acquire() + .map_err(|_| SafeError::Busy)?; + Self::cancellable( + &mut changed, + Self::cancellable(&mut cancelled, async { + let result = Self::runtime_budget(async { + Self::runtime_identity(&target).await?; + if !target.readiness_qualified { + return Err(SafeError::CompatibilityUnverified.into()); + } + // The health URL is public. Desktop requires current observation access + // before sending a request that can exercise configured dependencies. + target.api.capabilities().await?; + Self::runtime_identity(&target).await?; + let result = RuntimeFact::from( + target.api.readiness().await.map(|response| response.status), + ); + Self::runtime_identity(&target).await?; + Ok(result) + }) + .await?; + Ok(result) + }), + ) + .await + } +} diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index 56c8ae224f..a4cb897abf 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -56,6 +56,10 @@ pub fn run() { commands::search_memory, commands::atomic_memory_entry, commands::cancel_memory_reads, + commands::observe_runtime_liveness, + commands::observe_runtime_capabilities, + commands::probe_runtime_readiness, + commands::cancel_runtime_reads, commands::get_stats, commands::cancel_stats_reads, commands::desktop_state, diff --git a/desktop/src-tauri/tests/deployment_identity.rs b/desktop/src-tauri/tests/deployment_identity.rs index f9038ef919..79cb835508 100644 --- a/desktop/src-tauri/tests/deployment_identity.rs +++ b/desktop/src-tauri/tests/deployment_identity.rs @@ -36,6 +36,7 @@ use tokio::{ struct State { discovery: (u16, Value), + capabilities: (u16, Value), principal: Option, pause: Option, requests: Vec, @@ -82,6 +83,7 @@ impl Fixture { let endpoint = format!("http://{}", listener.local_addr().unwrap()); let state = Arc::new(Mutex::new(State { discovery: (200, discovery("server-a")), + capabilities: (403, json!({})), principal: authenticated.then(|| "same-principal".into()), pause: None, requests: vec![], @@ -145,7 +147,7 @@ impl Fixture { json!({"status":"ready","checks":{ "access_mode":if state.principal.is_some() {"enforced"} else {"disabled"}}}), ), - "/v1/capabilities" => (403, json!({})), + "/v1/capabilities" => state.capabilities.clone(), "/v1/scopes/default" | "/v1/scopes/scope-a" => (200, scope()), path if path.starts_with("/v1/scopes?") => { (200, json!({"items":[scope()],"next_cursor":"old-cursor"})) @@ -770,3 +772,164 @@ async fn principal_revocation_while_reading_discards_private_results() { ); fixture.assert_disconnected(generation); } + +fn runtime_capabilities() -> Value { + json!({ + "source_types": ["private-type"], + "artifact_families": ["private-family"], + "memory_extraction": false, + "handoff_generation": false, + "search_modes": ["fts"], + "context_versions": [], + "extraction": { + "configuration": "unconfigured", + "background": {"location": "none", "state": "stopped"}, + "observation": { + "status": "observed", + "since": "2026-10-01T00:00:00Z", + "last_failure": {"code": "private detail", "stage": "worker", "occurred_at": "2026-10-01T01:00:00Z"} + } + } + }) +} + +#[tokio::test] +async fn runtime_refresh_never_probes_and_observation_denial_preserves_memory_access() { + let fixture = Fixture::new(false).await; + let generation = fixture.select().await; + fixture.state.lock().unwrap().requests.clear(); + let liveness = fixture + .manager + .observe_runtime_liveness(generation) + .await + .unwrap(); + let report = fixture + .manager + .observe_runtime_capabilities(generation) + .await + .unwrap(); + assert!(liveness.fact.value.is_some()); + assert_eq!(report.fact.error.unwrap().code, SafeError::Forbidden); + let denied = fixture + .manager + .probe_runtime_readiness(generation) + .await + .err() + .unwrap(); + assert_eq!(denied.code, SafeError::Forbidden); + assert!( + !fixture + .state + .lock() + .unwrap() + .requests + .iter() + .any(|path| path == "/health/ready") + ); + assert_eq!(fixture.manager.state().unwrap().generation, generation); + assert_eq!( + fixture + .manager + .search_memory(generation, "fixture") + .await + .unwrap() + .hits + .len(), + 1 + ); +} + +#[tokio::test] +async fn runtime_projects_only_its_fields_and_probes_only_on_explicit_action() { + let fixture = Fixture::new(true).await; + let generation = fixture.activate().await; + { + let mut state = fixture.state.lock().unwrap(); + state.requests.clear(); + state.capabilities = (200, runtime_capabilities()); + } + let report = fixture + .manager + .observe_runtime_capabilities(generation) + .await + .unwrap(); + let value = serde_json::to_value(report.fact.value.unwrap()).unwrap(); + assert_eq!( + value["extraction"]["observation"]["last_failure"]["code"], + "unknown" + ); + assert_eq!(value["search_modes"], json!(["fts"])); + for excluded in ["private-type", "private-family", "private detail"] { + assert!(!value.to_string().contains(excluded)); + } + assert!( + !fixture + .state + .lock() + .unwrap() + .requests + .iter() + .any(|path| path == "/health/ready") + ); + let readiness = fixture + .manager + .probe_runtime_readiness(generation) + .await + .unwrap(); + assert_eq!(serde_json::to_value(readiness.fact.value).unwrap(), "ready"); + assert_eq!( + fixture + .state + .lock() + .unwrap() + .requests + .iter() + .filter(|path| *path == "/health/ready") + .count(), + 1 + ); + assert_eq!(fixture.manager.state().unwrap().generation, generation); +} + +#[tokio::test] +async fn runtime_drops_late_results_on_replacement_disconnect_or_cancel() { + for transition in ["replacement", "disconnect", "cancel"] { + let fixture = Fixture::new(true).await; + let generation = fixture.activate().await; + { + let mut state = fixture.state.lock().unwrap(); + state.capabilities = (200, runtime_capabilities()); + state.pause = Some("/v1/capabilities".into()); + } + let manager = fixture.manager.clone(); + let task = + tokio::spawn(async move { manager.observe_runtime_capabilities(generation).await }); + fixture.entered().await; + match transition { + "replacement" => { + fixture.replace(); + fixture.release.notify_one(); + let report = timeout(Duration::from_secs(2), task) + .await + .unwrap() + .unwrap() + .unwrap(); + assert_eq!(report.fact.error.unwrap().code, SafeError::StaleContext); + assert!(report.fact.value.is_none()); + } + "disconnect" | "cancel" => { + if transition == "disconnect" { + fixture.manager.disconnect().unwrap(); + } else { + fixture.manager.cancel_runtime_reads(generation).unwrap(); + } + let result = timeout(Duration::from_secs(2), task) + .await + .unwrap() + .unwrap(); + assert_eq!(result.err().unwrap().code, SafeError::StaleContext); + } + _ => unreachable!(), + } + } +} diff --git a/desktop/src-tauri/tests/ipc.rs b/desktop/src-tauri/tests/ipc.rs index 52e4e4032d..bc294269e3 100644 --- a/desktop/src-tauri/tests/ipc.rs +++ b/desktop/src-tauri/tests/ipc.rs @@ -271,7 +271,7 @@ fi } #[test] -fn memory_commands_reject_untrusted_callers_before_accessing_the_connection() { +fn connection_commands_reject_untrusted_callers_before_accessing_the_connection() { use powercontext_desktop::{commands, error::SafeError}; use tauri::Manager; let app = mock_builder() @@ -284,6 +284,10 @@ fn memory_commands_reject_untrusted_callers_before_accessing_the_connection() { commands::atomic_memory_entry, commands::cancel_memory_reads, commands::get_stats, + commands::observe_runtime_liveness, + commands::observe_runtime_capabilities, + commands::probe_runtime_readiness, + commands::cancel_runtime_reads, commands::cancel_stats_reads ]) .build(test_context()) @@ -301,6 +305,10 @@ fn memory_commands_reject_untrusted_callers_before_accessing_the_connection() { "atomic_memory_entry", "cancel_memory_reads", "get_stats", + "observe_runtime_liveness", + "observe_runtime_capabilities", + "probe_runtime_readiness", + "cancel_runtime_reads", "cancel_stats_reads", ] { for (label, origin) in [ diff --git a/desktop/src-tauri/tests/macos_diagnostics.rs b/desktop/src-tauri/tests/macos_diagnostics.rs index 549f64c5cb..fe29c2169a 100644 --- a/desktop/src-tauri/tests/macos_diagnostics.rs +++ b/desktop/src-tauri/tests/macos_diagnostics.rs @@ -262,3 +262,38 @@ fi SafeError::StaleContext ); } + +#[tokio::test] +#[ignore = "requires a trusted, installed PowerContext CLI path"] +async fn registered_powercontext_cli_returns_service_facts() { + let executable = std::path::PathBuf::from( + std::env::var_os("POWERCONTEXT_DIAGNOSTIC_TEST_CLI").expect("set trusted CLI path"), + ); + let directory = tempfile::tempdir().unwrap(); + let registration = directory.path().join("diagnostic-cli.json"); + let digest = format!("{:x}", Sha256::digest(std::fs::read(&executable).unwrap())); + std::fs::write( + ®istration, + serde_json::to_vec(&serde_json::json!({ + "executable": executable, + "sha256": digest, + "source": "explicit_local_installation" + })) + .unwrap(), + ) + .unwrap(); + let report = LocalDiagnostics::new(registration) + .run(DiagnosticKind::Service) + .await + .unwrap(); + for fact in [ + "registration", + "manager_ownership", + "manager", + "server_liveness", + ] { + assert!(report.items.iter().any(|item| item.field == fact), "{fact}"); + } + assert!(report.cli_version.is_some()); + assert!(report.hosts.is_empty()); +} diff --git a/desktop/ui/src/app/App.tsx b/desktop/ui/src/app/App.tsx index 712deaf01e..ffb47e9bd0 100644 --- a/desktop/ui/src/app/App.tsx +++ b/desktop/ui/src/app/App.tsx @@ -43,6 +43,7 @@ import { Diagnostics } from "./Diagnostics"; import { Connections } from "./Connections"; import { Scopes } from "./Scopes"; import { Observation, type ObservationTab } from "./Observation"; +import { Runtime } from "./Runtime"; import { SessionView } from "./SessionView"; import { connectionMessages } from "./connection-messages"; import { PageFeedback, ErrorFeedback } from "./PageFeedback"; @@ -422,6 +423,16 @@ export function App() { tab={observationTab} onTabChange={setObservationTab} panels={{ + runtime: ( + + navigate("settings", { section: "local-server" }) + } + /> + ), inventory: ( = { hooks: ["事件钩子", "Hooks"], mcp: ["MCP", "MCP"], }; -export function Diagnostics({ language }: { language: Language }) { +export function Diagnostics({ + language, + serviceOnly = false, +}: { + language: Language; + serviceOnly?: boolean; +}) { const zh = language === "zh"; const t = messages[language]; const [busy, setBusy] = useState(false); @@ -80,12 +86,18 @@ export function Diagnostics({ language }: { language: Language }) { const code = typeof error === "string" ? error : null; return (
-

{zh ? "本地诊断" : "Local diagnostics"}

-

- {zh - ? "仅检查此电脑,与活动连接的远程状态分开。集成检查可能启动短暂的 Agent 辅助进程;不代表实际记忆写入或读取已通过。" - : "Checks this computer independently of the active remote connection. Integration checks may start temporary Agent helper processes; they do not prove actual memory capture or recall."} -

+ {serviceOnly ? ( +

{zh ? "登记的本机 CLI" : "Registered local CLI"}

+ ) : ( +

{zh ? "本地诊断" : "Local diagnostics"}

+ )} + {!serviceOnly && ( +

+ {zh + ? "仅检查此电脑,与活动连接的远程状态分开。集成检查可能启动短暂的 Agent 辅助进程;不代表实际记忆写入或读取已通过。" + : "Checks this computer independently of the active remote connection. Integration checks may start temporary Agent helper processes; they do not prove actual memory capture or recall."} +

+ )}

{zh ? "需要先按使用文档登记可信的本机 CLI。缺少 CLI 不影响远程业务。" @@ -95,9 +107,11 @@ export function Diagnostics({ language }: { language: Language }) { - + {!serviceOnly && ( + + )} {busy && ( string; + +const statuses: Record = { + ready: ["Ready", "就绪"], + auto: ["Automatic", "自动"], + fts: ["Full text", "全文"], + vector: ["Vector", "向量"], + hybrid: ["Hybrid", "混合"], + inference: ["Inference", "模型推理"], + worker: ["Worker", "工作进程"], + flush: ["Explicit processing", "显式处理"], + supervisor: ["Supervisor", "监督进程"], + lease_renewal: ["Lease renewal", "租约续期"], + scope_discovery: ["Scope discovery", "Scope 发现"], + model_configuration_error: ["Model configuration error", "模型配置错误"], + model_timeout: ["Model timed out", "模型超时"], + model_unavailable: ["Model unavailable", "模型不可用"], + invalid_model_output: ["Invalid model output", "模型输出无效"], + worker_timeout: ["Worker timed out", "工作进程超时"], + worker_crash: ["Worker crashed", "工作进程崩溃"], + invalid_worker_result: ["Invalid worker result", "工作进程结果无效"], + missing_durable_acknowledgement: [ + "Missing durable acknowledgement", + "缺少持久化确认", + ], + supervisor_failed: ["Supervisor failed", "监督进程失败"], + lease_renewal_failed: ["Lease renewal failed", "租约续期失败"], + scope_discovery_failed: ["Scope discovery failed", "Scope 发现失败"], + processing_failed: ["Processing failed", "处理失败"], + degraded: ["Degraded", "部分可用"], + not_ready: ["Not ready", "未就绪"], + configured: ["Configured", "已配置"], + unconfigured: ["Unconfigured", "未配置"], + unknown: ["Unknown", "未知"], + local: ["Inside this Server", "此 Server 内"], + external: ["External", "外部"], + none: ["None", "无"], + leader: ["Leader", "主节点"], + standby: ["Standby", "备用节点"], + running: ["Running", "运行中"], + stopped: ["Stopped", "已停止"], + observed: ["Outcome recorded", "已记录结果"], + unverified: ["No outcome observed", "尚无观察结果"], + supported: ["Supported", "支持"], + unsupported: ["Unsupported", "不支持"], + installed: ["Registered", "已注册"], + not_installed: ["Not registered", "未注册"], + invalid: ["Invalid", "无效"], + current: ["Current", "有效"], + stale: ["Out of date", "已过期"], + missing_executable: ["Executable missing", "缺少可执行文件"], + present: ["Present", "存在"], + owned: ["Owned by PowerContext", "属于 PowerContext"], + foreign: ["Foreign or unowned", "不属于此安装"], + not_loaded: ["Not loaded", "未加载"], + active: ["Active", "活动"], + inactive: ["Inactive", "未运行"], + failed: ["Failed", "失败"], + live: ["Responding", "有响应"], + unreachable: ["Unreachable", "无法访问"], + valid: ["Valid", "有效"], + missing_or_invalid: ["Missing or invalid", "缺失或无效"], + manual_stopped: ["Stopped manually", "已手动停止"], + start_failed: ["Start failed", "启动失败"], + configuration_switching: ["Changing configuration", "正在切换配置"], + manual_switching: ["Manual switch in progress", "正在手动切换"], + runtime_repair: ["Runtime repair", "运行时修复"], + stopping: ["Stopping", "正在停止"], + switching: ["Switching", "正在切换"], + database_ready: ["Database prepared", "数据库已准备"], + accepted: ["Accepted", "已受理"], + registration_dispatching: [ + "Dispatching service registration", + "正在请求注册服务", + ], + service: ["Service operation", "服务操作"], + preparing: ["Preparing", "正在准备"], + prepared: ["Prepared", "已准备"], + publishing: ["Publishing resources", "正在发布资源"], + resources_published: ["Resources published", "资源已发布"], + configuration_prepared: ["Configuration prepared", "配置已准备"], + registering: ["Registering service", "正在注册服务"], + complete: ["Complete", "已完成"], +}; + +function label(t: Text, value: string | null | undefined) { + return t(...(statuses[value ?? "unknown"] ?? statuses.unknown)); +} + +function Time({ value, t }: { value: number | string; t: Text }) { + const date = new Date(typeof value === "number" ? value * 1000 : value); + if (!Number.isFinite(date.getTime())) return t("Unknown time", "时间未知"); + const utc = date.toISOString(); + return ; +} + +function Rows({ rows }: { rows: [string, ReactNode][] }) { + return ( +

+ {rows.map(([name, value]) => ( +
+
{name}
+
{value}
+
+ ))} +
+ ); +} + +function Processing({ + capabilities, + t, +}: { + capabilities: RuntimeCapabilities; + t: Text; +}) { + const extraction = capabilities.extraction; + const yesNo = (value?: boolean | null) => + value == null + ? t("Unknown", "未知") + : value + ? t("Yes", "是") + : t("No", "否"); + return ( + <> + label(t, mode)) + .join(" / ") + : t("None reported", "未报告"), + ], + ]} + /> +

{t("Memory extraction observations", "记忆提取观察")}

+

+ {t( + "Covers this Server process and its child Memory workers since startup. External workers, per-Scope progress, backlog and ETA are outside this view.", + "覆盖此 Server 进程自启动以来及其记忆子进程的观察。此处不覆盖外部执行器、单个 Scope 进度、积压或预计完成时间。", + )} +

+ {extraction ? ( + <> + , + ], + [ + t("Window coverage", "窗口内记录"), + label(t, extraction.observation.status), + ], + [ + t("Last observed success", "最近观察到的成功"), + extraction.observation.last_success_at ? ( +