From 6ec536b0bbc9d2fe535cce046e44fe3a1e573d7b Mon Sep 17 00:00:00 2001 From: jackie-cqz <2557911191@qq.com> Date: Wed, 28 Oct 2026 16:05:45 +0800 Subject: [PATCH 1/6] fix(desktop): isolate contexts when Server identity changes --- desktop/README.md | 6 +- desktop/README.zh.md | 6 +- desktop/SECURITY.md | 2 +- desktop/VALIDATION.md | 32 ++ desktop/src-tauri/examples/server_probe.rs | 12 +- desktop/src-tauri/src/connections/session.rs | 138 ++++- .../src-tauri/tests/deployment_identity.rs | 542 ++++++++++++++++++ desktop/ui/src/app/MemoryWorkspace.tsx | 27 +- desktop/ui/src/generated/ipc.ts | 2 +- desktop/ui/tests/memory.test.tsx | 60 +- 10 files changed, 754 insertions(+), 73 deletions(-) create mode 100644 desktop/src-tauri/tests/deployment_identity.rs diff --git a/desktop/README.md b/desktop/README.md index 296547a9c1..85b60d2022 100644 --- a/desktop/README.md +++ b/desktop/README.md @@ -102,7 +102,11 @@ Connection checks call the public `get_server_info` operation through the existi Qualification still requires an explicitly selected native record matching the bundled contract. A discovery 404 permits that record's independently qualified legacy operations; 401, 403, 503, malformed responses and incompatible products/required majors do not select a legacy fallback. Missing feature groups, unsupported feature versions and absent operation IDs restrict the affected operations. Exact Artifact reads require their own qualification; the `memory.explicit` group does not establish that support. Discovery metadata never grants authorization or attests a remote binary. -Connection checks and anonymous identity revalidation still perform readiness probes. The observed `server_id` is recorded with the check; deployment-aware context fencing and explicit readiness separation are not established by this discovery result. +Connection checks bind the observed `server_id` to the Principal and recheck the deployment after collecting connection facts. Scope listing/default lookup/selection and Memory search/exact reads revalidate deployment and Principal before dispatch and before returning private results. A changed identity, failed discovery, or disappearance of discovery clears the active context, invalidating Scope selections, cursors and pending reads. Reconnect explicitly to establish a new context. Package version changes alone do not change deployment identity. + +Submitted writes retain their original endpoint, `serverId` (null for qualified legacy Servers), Principal, Scope and generation. If the post-write identity check fails, a successful HTTP reply is treated as an unknown outcome: private bodies/references are withheld and the write is never automatically replayed. The original deployment is shown in write recovery feedback. A late write cannot invalidate a newly activated context. Qualified legacy sessions require discovery to remain 404; discovery appearing requires explicit rechecking rather than silently acquiring a deployment identity. + +These checks observe identity around requests; the public mutation contract has no conditional Server-identity precondition. They cannot atomically prevent an endpoint from changing between identity verification and dispatch, or detect a replacement that changes back between observations. Legacy Servers have no durable deployment fence. Discovery does not attest binary identity. Connection checks and anonymous identity revalidation still perform readiness probes; explicit readiness separation remains pending. Connections displays discovery separately from the compatibility verdict, with guidance for denied or temporarily unavailable discovery and the reason for an incompatible product or major version. A failed discovery check keeps business operations unavailable; it does not remove the selected qualification record. After correcting the connection or service, explicitly check the connection again to reassess operation availability. Only a discovery 404 permits the qualified legacy policy described above. diff --git a/desktop/README.zh.md b/desktop/README.zh.md index 026cc31f72..441d216e89 100644 --- a/desktop/README.zh.md +++ b/desktop/README.zh.md @@ -80,7 +80,11 @@ pnpm --dir desktop desktop:dev 操作资格仍要求明确选择与当前契约匹配的原生验证记录。仅发现接口的 404 允许沿用该记录已独立验证的旧版操作;401、403、503、无效响应、不兼容产品或必要 major 版本不会触发旧版回退。缺失功能组、不支持的功能版本和缺失 operation ID 分别限制受影响的操作。Artifact 精确读取需要独立资格,不能从 `memory.explicit` 推断。发现元数据不授予权限,也不证明远端二进制身份。 -当前连接检查和匿名身份复核仍会调用 readiness。检查结果记录观察到的 `server_id`;这不代表已经实现部署身份隔离或显式 readiness 分离。 +连接检查将观察到的 `server_id` 与 Principal 绑定,并在收集连接事实后再次核对部署。Scope 列举、默认范围查找、范围选择,以及记忆搜索和精确读取,均在发送请求前和返回私有结果前复核部署及 Principal。身份变化、发现失败或发现接口消失时,会清除活动上下文,使 Scope 选择、游标和进行中的读取失效;须明确重新连接以建立新上下文。仅软件包版本变化不视为部署变化。 + +已提交写入保留原始端点、`serverId`(已验证旧版 Server 为 null)、Principal、Scope 和代次。写入后的身份复核失败时,即使 HTTP 返回成功也按结果未知处理:不返回私有正文或引用,不自动重放。写入恢复反馈显示原部署;旧写入晚到不会使新激活的上下文失效。已验证旧版会话要求发现接口持续返回 404;发现接口出现后须明确重新检查,不能静默获得部署身份。 + +这些检查观察请求前后的身份;公开写入契约没有以 Server 身份为条件的前置约束,因此不能原子性阻止验证与发送之间的端点替换,也不能发现两次观察之间发生后又恢复的替换。旧版 Server 没有持久部署隔离保障;发现信息也不证明二进制身份。连接检查和匿名身份复核仍会调用 readiness,显式 readiness 分离尚未完成。 连接页分别展示发现状态和兼容性结论,说明访问被拒绝、暂时不可用时的恢复方法,以及产品或 major 版本不兼容的原因。发现检查失败时业务操作保持不可用,但不会删除已选择的资格记录。修正连接或服务后,须明确重新检查连接,重新判定操作可用性。仅发现接口的 404 适用上述已验证旧版策略。 diff --git a/desktop/SECURITY.md b/desktop/SECURITY.md index 9ae502eb6c..b9cfe1d1fc 100644 --- a/desktop/SECURITY.md +++ b/desktop/SECURITY.md @@ -16,7 +16,7 @@ Native code creates credential IDs and binds them to an exact endpoint/trust con Native reqwest uses Windows system trust through native-tls/Schannel, plus an optional connection-local PEM CA (maximum 64 KiB). Hostname/certificate validation remains enabled. Automatic proxy inheritance and redirects are disabled. HTTP requires loopback. Userinfo, query, fragment, control characters, backslashes and dot segments are rejected before URL normalization. UTF-8 and percent-encoded base paths are accepted only after rejecting encoded separators, dot segments, control characters and ambiguous double encoding. Public operation paths come from the generated OpenAPI manifest and preserve the base prefix. -Typed adapters separate liveness, readiness, identity and capabilities. Native context generations cancel old reads on connection, identity and Scope changes; a separate query generation cancels superseded Scope searches. Business operations require explicit qualified compatibility and actual Server authorization. The real-Server fixture exercises native memory adapters; product Memory commands remain unavailable until immutable write context and unknown-result handling are implemented. No automatic write retry or persistent queue exists. +Typed adapters separate liveness, readiness, identity and capabilities. Native context generations cancel old reads on connection, identity and Scope changes; a separate query generation cancels superseded Scope searches. Business operations require explicit qualified compatibility and actual Server authorization. Scope and Memory reads validate both deployment and Principal before dispatch and after the response. Observed identity loss clears the active context. Dispatched writes keep immutable endpoint/deployment/Principal/Scope metadata; failed post-write verification makes successful HTTP outcomes unknown without returning private content or automatically replaying. Late writes cannot clear a newer context. Qualified legacy Servers retain a null deployment identity and require discovery to remain 404. Identity observations do not supply an atomic Server-side conditional write fence or binary attestation. No automatic write retry or persistent queue exists. ## Budgets and error projection diff --git a/desktop/VALIDATION.md b/desktop/VALIDATION.md index 5f98d5c2ba..4995732d52 100644 --- a/desktop/VALIDATION.md +++ b/desktop/VALIDATION.md @@ -20,6 +20,38 @@ cargo test --locked --manifest-path desktop/src-tauri/Cargo.toml [Desktop CI](../.github/workflows/desktop.yml) also builds a Server wheel, runs real Server and CLI tests, checks Windows credentials and process containment, builds the installer, and exercises installation, the installed UI and uninstallation. Consult the PR checks for the exact commit being reviewed. Reports, logs and screenshots are generated under ignored `.artifacts/` and uploaded as Actions artifacts; do not copy them into the repository. +## Deployment context validation + +On 2026-10-28, Windows 10 x64 / Python 3.11.15 / Rust 1.95.0 ran the deployment-identity +working tree based on `aedf7beb1ecff2ea1fade6ccdd8e4bf6a298f799`. +All 70 native tests and 117 frontend tests passed, together with Clippy, Rust formatting, +IPC drift, TypeScript, frontend lint and the production build. Nine native HTTP regression tests +cover deployment replacement with an unchanged Principal, late Scope/Memory results, activation +races, Principal changes during reads, qualified legacy transitions and dispatched writes. +Replacement scenarios use synthetic TCP responses; they do not establish real deployment replacement +or installed-platform acceptance. The bilingual unknown-write regression checks the original Server ID, +preserved draft and explicit retry confirmation. + +The real SQLite Server harness passed anonymous loopback, static Bearer, HTTPS with an explicit CA +and base path, and an enforced Provider. It exercised native save/search/exact reads, original write +deployment metadata, uncertain writes without replay, Principal changes/revocation and independent +Server survival. Exact inputs: + +- Server sources: `aedf7beb1ecff2ea1fade6ccdd8e4bf6a298f799`. +- Wheel: `powercontext-1.4.2.dev36+gaedf7beb1.d20261028-py3-none-any.whl`; + SHA-256 `fde108a81a872a6228aad54832f2cd8f899a53bb4a7ff5c321871891fdbc5b68`. +- Normalized OpenAPI SHA-256: `c12901b88b8e958c9cd63469298b9a04b9d5e2b0b4564d83b50f7ae95542519a`. +- Debug `server_probe.exe` SHA-256: `a56d4db19650459c53094cd1a09fddfc40b6daa98f90dcf5f7d0c5514481390a`. + +This run used the explicit disposable `desktop-ci-fixture` qualification, not a new production +catalog record. The report records a dirty Desktop working tree; historical qualification records +are unchanged. Native Manager p95 round trips on these small local fixtures were 24.86–53.66 ms for +save, 19.77–37.51 ms for search and 17.64–36.04 ms for exact reads, including identity revalidation. +These are observations, not approved budgets, UI measurements or remote-network guarantees. +Readiness separation, signed Windows 11/macOS packages and installed acceptance remain open. +Repository file hooks passed; default Windows `ty check` reports existing POSIX-only attributes in +unchanged Python files. `ty check --python-platform linux` passed. + ## Native discovery acceptance The native `get_server_info` adapter and full real-Server harness were exercised on Windows x64 on 2026-10-23 with Rust 1.95.0 and Python 3.11.15. The Server sources, project configuration and lock matched baseline commit `c535d816`; the wheel was built at Desktop commit `f2b442fc41f724e206a50f2415a03476abdcdf76` with an unrelated dirty documentation tree. The wheel `powercontext-1.4.1.dev138+gf2b442fc4.d20261023-py3-none-any.whl` had SHA-256 `677812c860dd6f12866afa3b6b3e2e0529a6d37b427187c0ce4f3cd732349551`; the normalized contract digest was `5878999ce9e239d6642e4da858074b281ed465629432c578b3584eec47cbce6c`. All four isolated SQLite Server configurations passed: anonymous loopback, static Bearer, HTTPS with an explicit CA and reverse-proxy base path, and an enforced authentication Provider. Checks covered discovery, Scope selection, Memory save/search/exact revision reads, Principal changes, same-Principal revocation, uncertain writes without replay, pagination and independent Server survival. The Server environment included the declared `jieba==0.42.1` dependency. Results are recorded under ignored `.artifacts/real-server.json`. diff --git a/desktop/src-tauri/examples/server_probe.rs b/desktop/src-tauri/examples/server_probe.rs index 31fb37bb5d..42e3149917 100644 --- a/desktop/src-tauri/examples/server_probe.rs +++ b/desktop/src-tauri/examples/server_probe.rs @@ -74,10 +74,10 @@ async fn main() { fixture.token.take(), ) .unwrap(); - assert!(matches!( - api.server_info().await.unwrap(), - DiscoveryDocument::Server(_) - )); + let DiscoveryDocument::Server(server) = api.server_info().await.unwrap() else { + panic!("expected PowerContext discovery"); + }; + let server_id = server.server_id; if args .get(2) .is_some_and(|argument| argument == "discovery-only") @@ -166,6 +166,10 @@ async fn main() { let expected = format!("{keyword} 中文 café\nSecond line."); let saved = manager.remember(generation, &submitted).await.unwrap(); assert_eq!(saved.record.status, WriteStatus::Succeeded); + assert_eq!( + saved.record.context.server_id.as_deref(), + Some(server_id.as_str()) + ); let result = saved.result.unwrap(); assert_eq!( saved.record.references, diff --git a/desktop/src-tauri/src/connections/session.rs b/desktop/src-tauri/src/connections/session.rs index 67224edbba..9ec1e1450f 100644 --- a/desktop/src-tauri/src/connections/session.rs +++ b/desktop/src-tauri/src/connections/session.rs @@ -15,7 +15,7 @@ */ //! Native-owned connection and Scope context. Viewing a profile never activates it. -use super::discovery::DiscoveryResult; +use super::discovery::{DiscoveryResult, DiscoveryStatus}; use super::profiles::{ProfileInput, ProfileRepository, ProfileView}; use crate::{ error::SafeError, @@ -100,6 +100,7 @@ pub struct DesktopState { pub struct MemoryContext { pub connection_id: String, pub endpoint: String, + pub server_id: Option, pub principal: Option, pub generation: u32, pub scope_id: String, @@ -143,6 +144,7 @@ impl Drop for WriteGuard<'_> { } struct ReadSnapshot { api: Arc, + server_id: Option, identity: Option, cancelled: watch::Receiver, } @@ -346,6 +348,17 @@ impl ConnectionManager { }); let discovery = DiscoveryResult::assess(discovery, compatible); let supported_operations = discovery.supported_operations(); + if !supported_operations.is_empty() { + // Do not activate a Principal observed across a deployment replacement. + Self::deployment_unchanged( + &api, + discovery + .server + .as_ref() + .map(|server| server.server_id.as_str()), + ) + .await?; + } Ok(CheckReport { anonymous_access, connection_id: id.into(), @@ -406,14 +419,46 @@ impl ConnectionManager { } Ok(ReadSnapshot { api: active.api.clone(), + server_id: active + .view + .report + .discovery + .as_ref() + .and_then(|discovery| discovery.server.as_ref()) + .map(|server| server.server_id.clone()), identity: principal, cancelled: self.changed.subscribe(), }) } + async fn deployment_unchanged( + api: &ServerApi, + original: Option<&str>, + ) -> Result<(), ApiFailure> { + // Re-observation never creates qualification or silently upgrades a legacy session. + let current = DiscoveryResult::assess(api.server_info().await, None); + let unchanged = match current.status { + DiscoveryStatus::Supported => original.is_some_and(|id| { + current + .server + .as_ref() + .is_some_and(|server| server.server_id == id) + }), + DiscoveryStatus::Missing => original.is_none(), + _ => false, + }; + if unchanged { + Ok(()) + } else { + Err(current + .error + .unwrap_or_else(|| SafeError::StaleContext.into())) + } + } async fn identity_unchanged( &self, api: &ServerApi, original: &Option, + server_id: Option<&str>, generation: u32, ) -> Result<(), ApiFailure> { let current = api.principal().await; @@ -428,8 +473,16 @@ impl ConnectionManager { .await .is_ok_and(|r| r.checks.get("access_mode").is_some_and(|v| v == "disabled")) }; - if unchanged { - return Ok(()); + let result = if unchanged { + Self::deployment_unchanged(api, server_id).await + } else { + match current { + Err(error) => Err(error), + Ok(_) => Err(SafeError::StaleContext.into()), + } + }; + if result.is_ok() { + return result; } let mut inner = self.lock()?; if inner.generation == generation { @@ -438,10 +491,7 @@ impl ConnectionManager { } self.invalidate(&mut inner)?; } - match current { - Err(error) => Err(error), - Ok(_) => Err(SafeError::StaleContext.into()), - } + result } fn begin_scope_query(&self, generation: u32) -> Result, SafeError> { let inner = self.lock()?; @@ -470,6 +520,7 @@ impl ConnectionManager { ) -> Result { let ReadSnapshot { api, + server_id, identity, mut cancelled, } = self.snapshot(generation, "list_scopes")?; @@ -477,8 +528,12 @@ impl ConnectionManager { Self::cancellable( &mut cancelled, Self::cancellable(&mut query_cancelled, async { - self.identity_unchanged(&api, &identity, generation).await?; - api.scopes(query, cursor).await + self.identity_unchanged(&api, &identity, server_id.as_deref(), generation) + .await?; + let result = api.scopes(query, cursor).await; + self.identity_unchanged(&api, &identity, server_id.as_deref(), generation) + .await?; + result }), ) .await @@ -486,6 +541,7 @@ impl ConnectionManager { pub async fn default_scope(&self, generation: u32) -> Result { let ReadSnapshot { api, + server_id, identity, mut cancelled, } = self.snapshot(generation, "get_default_scope")?; @@ -493,8 +549,12 @@ impl ConnectionManager { Self::cancellable( &mut cancelled, Self::cancellable(&mut query_cancelled, async { - self.identity_unchanged(&api, &identity, generation).await?; - api.default_scope().await + self.identity_unchanged(&api, &identity, server_id.as_deref(), generation) + .await?; + let result = api.default_scope().await; + self.identity_unchanged(&api, &identity, server_id.as_deref(), generation) + .await?; + result }), ) .await @@ -506,12 +566,17 @@ impl ConnectionManager { ) -> Result { let ReadSnapshot { api, + server_id, identity, mut cancelled, } = self.snapshot(generation, "get_scope")?; let scope = Self::cancellable(&mut cancelled, async { - self.identity_unchanged(&api, &identity, generation).await?; - api.scope(id).await + self.identity_unchanged(&api, &identity, server_id.as_deref(), generation) + .await?; + let result = api.scope(id).await; + self.identity_unchanged(&api, &identity, server_id.as_deref(), generation) + .await?; + result }) .await?; let mut inner = self.lock()?; @@ -551,10 +616,12 @@ impl ConnectionManager { .identity .as_ref() .map(|value| value.principal.clone()); + let server_id = snapshot.server_id.clone(); Ok(( snapshot, MemoryContext { endpoint, + server_id, principal, connection_id: active.view.connection_id.clone(), generation, @@ -586,6 +653,7 @@ impl ConnectionManager { let ( ReadSnapshot { api, + server_id, identity, mut cancelled, }, @@ -595,8 +663,12 @@ impl ConnectionManager { Self::cancellable( &mut cancelled, Self::cancellable(&mut query_cancelled, async { - self.identity_unchanged(&api, &identity, generation).await?; - api.search(&context.scope_id, query).await + self.identity_unchanged(&api, &identity, server_id.as_deref(), generation) + .await?; + let result = api.search(&context.scope_id, query).await; + self.identity_unchanged(&api, &identity, server_id.as_deref(), generation) + .await?; + result }), ) .await @@ -609,6 +681,7 @@ impl ConnectionManager { let ( ReadSnapshot { api, + server_id, identity, mut cancelled, }, @@ -618,8 +691,12 @@ impl ConnectionManager { Self::cancellable( &mut cancelled, Self::cancellable(&mut query_cancelled, async { - self.identity_unchanged(&api, &identity, generation).await?; - api.atomic_entry(&context.scope_id, artifact).await + self.identity_unchanged(&api, &identity, server_id.as_deref(), generation) + .await?; + let result = api.atomic_entry(&context.scope_id, artifact).await; + self.identity_unchanged(&api, &identity, server_id.as_deref(), generation) + .await?; + result }), ) .await @@ -630,6 +707,7 @@ impl ConnectionManager { let ( ReadSnapshot { api, + server_id, identity, mut cancelled, }, @@ -637,7 +715,7 @@ impl ConnectionManager { ) = self.memory_snapshot(generation, "remember_memory")?; Self::cancellable( &mut cancelled, - self.identity_unchanged(&api, &identity, generation), + self.identity_unchanged(&api, &identity, server_id.as_deref(), generation), ) .await?; let mut record = WriteRecord { @@ -660,15 +738,25 @@ impl ConnectionManager { armed: true, }; let response = api.remember(&record.context.scope_id, text).await; + let identity_result = self + .identity_unchanged(&api, &identity, server_id.as_deref(), generation) + .await; let result = match response { Ok(value) => { - record.status = WriteStatus::Succeeded; - record.references = value - .records - .iter() - .map(|record| record.artifact.clone()) - .collect(); - Some(value) + if let Err(error) = identity_result { + // A successful HTTP reply cannot establish which deployment accepted the write. + record.status = WriteStatus::Unknown; + record.error = Some(error); + None + } else { + record.status = WriteStatus::Succeeded; + record.references = value + .records + .iter() + .map(|record| record.artifact.clone()) + .collect(); + Some(value) + } } Err(error) => { record.status = if error.dispatched diff --git a/desktop/src-tauri/tests/deployment_identity.rs b/desktop/src-tauri/tests/deployment_identity.rs new file mode 100644 index 0000000000..2667b3f717 --- /dev/null +++ b/desktop/src-tauri/tests/deployment_identity.rs @@ -0,0 +1,542 @@ +/* + * 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, WriteStatus}, + }, + credentials::NativeVault, + error::SafeError, + transport::wire::ArtifactReference, +}; +use serde_json::{Value, json}; +use std::sync::{Arc, Mutex}; +use tokio::{ + io::{AsyncReadExt, AsyncWriteExt}, + net::TcpListener, + sync::Notify, + time::{Duration, timeout}, +}; + +struct State { + discovery: (u16, Value), + principal: Option, + pause: Option, + requests: Vec, +} +struct Fixture { + manager: Arc, + id: String, + state: Arc>, + started: Arc, + release: Arc, + server: tokio::task::JoinHandle<()>, + _directory: tempfile::TempDir, +} +impl Drop for Fixture { + fn drop(&mut self) { + self.server.abort(); + self.release.notify_waiters(); + } +} +fn discovery(id: &str) -> Value { + json!({"schema_version":{"major":1,"minor":0},"product":"powercontext","server_id":id, + "package_version":"synthetic","api_contract_version":{"major":1,"minor":4},"feature_contracts":{ + "access.principal":{"version":{"major":1,"minor":0},"operations":["get_access_principal"]}, + "scope.selection":{"version":{"major":1,"minor":0},"operations":["list_scopes","get_scope","get_default_scope"]}, + "memory.explicit":{"version":{"major":1,"minor":0},"operations":["remember_memory","search_memory"]}}}) +} +fn scope() -> Value { + json!({"scope_id":"scope-a","title":"Private scope","summary":"","context_references":[], + "external_references":[],"version":1}) +} +fn reference() -> ArtifactReference { + serde_json::from_value(json!({"family":"atomic-memory","artifact_id":"atomic-a","revision":1})) + .unwrap() +} +fn record() -> Value { + json!({"artifact":reference(),"kind":"note","text":"private result","state":"active", + "state_version":1,"merged_into_id":null}) +} +impl Fixture { + async fn new(authenticated: bool) -> Self { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let endpoint = format!("http://{}", listener.local_addr().unwrap()); + let state = Arc::new(Mutex::new(State { + discovery: (200, discovery("server-a")), + principal: authenticated.then(|| "same-principal".into()), + pause: None, + requests: vec![], + })); + let started = Arc::new(Notify::new()); + let release = Arc::new(Notify::new()); + let (responses, entered, resume) = (state.clone(), started.clone(), release.clone()); + let server = tokio::spawn(async move { + loop { + let (mut socket, _) = listener.accept().await.unwrap(); + let (responses, entered, resume) = + (responses.clone(), entered.clone(), resume.clone()); + tokio::spawn(async move { + let mut bytes = vec![]; + loop { + let mut chunk = [0; 4096]; + let count = socket.read(&mut chunk).await.unwrap(); + if count == 0 { + return; + } + bytes.extend_from_slice(&chunk[..count]); + 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_ascii_lowercase() + .strip_prefix("content-length: ") + .map(|value| value.parse::().unwrap()) + }) + .unwrap_or(0); + if bytes.len() >= end + 4 + length { + break; + } + } + } + let path = String::from_utf8_lossy(&bytes) + .split_whitespace() + .nth(1) + .unwrap() + .to_owned(); + let (status, value, pause) = { + let mut state = responses.lock().unwrap(); + state.requests.push(path.clone()); + let (status, value) = match path.as_str() { + "/v1/server-info" => state.discovery.clone(), + "/v1/access/me" => match &state.principal { + Some(id) => ( + 200, + json!({"principal":{"type":"service","id":id},"mode":"enforced", + "resource_kinds":[],"artifact_families":[],"provider_capabilities":{ + "safe_resource_filtering":true,"multi_requirement_check":true,"relationship_management":false, + "group_subjects":false,"multi_principal":false,"max_direct_resource_keys":100}}), + ), + None => (503, json!({"error":{"code":"runtime_not_ready"}})), + }, + "/health/ready" => ( + 200, + json!({"status":"ready","checks":{ + "access_mode":if state.principal.is_some() {"enforced"} else {"disabled"}}}), + ), + "/v1/capabilities" => (403, json!({})), + "/v1/scopes/default" | "/v1/scopes/scope-a" => (200, scope()), + path if path.starts_with("/v1/scopes?") => { + (200, json!({"items":[scope()],"next_cursor":"old-cursor"})) + } + "/v1/memory/search" => ( + 200, + json!({"mode":"text","hits":[{"memory":record(),"score":1,"matched_by":["text"]}]}), + ), + "/v1/memory/remember" => { + (200, json!({"changed":true,"records":[record()]})) + } + path if path.contains("/revisions/") => ( + 200, + json!({"scope_id":"scope-a","family":"atomic-memory", + "artifact_id":"atomic-a","revision":1,"content":{"schema":"powercontext.atomic-memory.v1", + "kind":"note","text":"private result"},"sources":[],"artifacts":[], + "content_digest":format!("sha256:{}", "0".repeat(64))}), + ), + _ => (200, json!({"status":"ok"})), + }; + ( + status, + value, + state + .pause + .as_ref() + .is_some_and(|prefix| path.starts_with(prefix)), + ) + }; + if pause { + entered.notify_one(); + resume.notified().await; + } + 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 directory = tempfile::tempdir().unwrap(); + let manager = Arc::new(common::connection_manager( + ProfileRepository::open( + directory.path().join("profiles.json"), + Arc::new(NativeVault), + ) + .unwrap(), + )); + let saved = manager.save_profile(serde_json::from_value(json!({"id":null,"revision":null,"name":"Synthetic", + "endpoint":endpoint,"authentication":if authenticated {"bearer"} else {"unauthenticated_loopback"}, + "caPem":null,"compatibility":common::PROFILE_ID,"keepCredential":false, + "credential":if authenticated {json!({"secret":"synthetic-token","storage":"session_only"})} else {Value::Null}})).unwrap()).unwrap(); + Self { + manager, + id: saved.profiles[0].id.clone(), + state, + started, + release, + server, + _directory: directory, + } + } + async fn activate(&self) -> u32 { + let state = self.manager.check(&self.id, true).await.unwrap(); + assert!(state.active.unwrap().report.compatibility_verified); + state.generation + } + async fn select(&self) -> u32 { + self.manager + .select_scope(self.activate().await, "scope-a") + .await + .unwrap() + .generation + } + fn replace(&self) { + self.state.lock().unwrap().discovery = (200, discovery("server-b")); + } + async fn entered(&self) { + timeout(Duration::from_secs(5), self.started.notified()) + .await + .unwrap(); + } + fn assert_disconnected(&self, previous: u32) { + let state = self.manager.state().unwrap(); + assert!(state.active.is_none()); + assert!(state.generation > previous); + assert!( + state + .reports + .iter() + .all(|report| report.connection_id != self.id) + ); + } +} + +#[tokio::test] +async fn replacement_with_the_same_principal_blocks_reads_and_writes_before_dispatch() { + for authenticated in [false, true] { + for write in [false, true] { + let fixture = Fixture::new(authenticated).await; + let generation = fixture.select().await; + fixture.state.lock().unwrap().requests.clear(); + fixture.replace(); + let error = if write { + fixture + .manager + .remember(generation, "must not reach server-b") + .await + .map(|_| ()) + } else { + fixture + .manager + .scopes(generation, "", Some("old-cursor")) + .await + .map(|_| ()) + } + .err() + .unwrap(); + assert_eq!(error.code, SafeError::StaleContext); + fixture.assert_disconnected(generation); + assert!(fixture.manager.state().unwrap().last_write.is_none()); + assert!( + !fixture + .state + .lock() + .unwrap() + .requests + .iter() + .any(|path| path.contains("/memory/") || path.starts_with("/v1/scopes")) + ); + assert!( + fixture + .manager + .scopes(generation, "", Some("old-cursor")) + .await + .is_err() + ); + // Only an explicit check can establish the replacement's context. + let generation = fixture.activate().await; + assert!( + fixture + .manager + .state() + .unwrap() + .active + .unwrap() + .scope + .is_none() + ); + assert!(fixture.manager.scopes(generation, "", None).await.is_ok()); + } + } +} + +#[tokio::test] +async fn late_private_reads_are_rejected_when_the_deployment_changes_during_the_request() { + for path in [ + "/v1/scopes?", + "/v1/scopes/default", + "/v1/scopes/scope-a", + "/v1/memory/search", + "/v1/scopes/scope-a/artifacts/", + ] { + let fixture = Fixture::new(true).await; + let generation = fixture.select().await; + fixture.state.lock().unwrap().pause = Some(path.into()); + let manager = fixture.manager.clone(); + let read = tokio::spawn(async move { + match path { + "/v1/scopes?" => manager.scopes(generation, "", None).await.map(|_| ()), + "/v1/scopes/default" => manager.default_scope(generation).await.map(|_| ()), + "/v1/scopes/scope-a" => manager + .select_scope(generation, "scope-a") + .await + .map(|_| ()), + "/v1/memory/search" => manager + .search_memory(generation, "private") + .await + .map(|_| ()), + _ => manager + .atomic_memory_entry(generation, &reference()) + .await + .map(|_| ()), + } + }); + fixture.entered().await; + fixture.replace(); + fixture.release.notify_one(); + let error = timeout(Duration::from_secs(5), read) + .await + .unwrap() + .unwrap() + .err() + .unwrap(); + assert_eq!(error.code, SafeError::StaleContext, "{path}"); + fixture.assert_disconnected(generation); + } +} + +#[tokio::test] +async fn failed_discovery_cannot_downgrade_an_active_deployment_to_legacy() { + for status in [404, 401, 403, 503] { + let fixture = Fixture::new(false).await; + let generation = fixture.activate().await; + fixture.state.lock().unwrap().discovery = (status, json!({})); + assert!(fixture.manager.scopes(generation, "", None).await.is_err()); + fixture.assert_disconnected(generation); + } +} + +#[tokio::test] +async fn a_legacy_session_requires_explicit_recheck_when_discovery_appears() { + let fixture = Fixture::new(false).await; + fixture.state.lock().unwrap().discovery = (404, json!({})); + let generation = fixture.activate().await; + assert!(fixture.manager.scopes(generation, "", None).await.is_ok()); + fixture.replace(); + assert_eq!( + fixture + .manager + .scopes(generation, "", None) + .await + .err() + .unwrap() + .code, + SafeError::StaleContext + ); + fixture.assert_disconnected(generation); +} + +#[tokio::test] +async fn unchanged_identity_allows_the_complete_flow_and_keeps_the_write_target() { + for authenticated in [false, true] { + let fixture = Fixture::new(authenticated).await; + let generation = fixture.select().await; + // A package upgrade is not a deployment replacement. + fixture.state.lock().unwrap().discovery.1["package_version"] = json!("new-package"); + assert!(fixture.manager.scopes(generation, "", None).await.is_ok()); + assert_eq!( + fixture + .manager + .default_scope(generation) + .await + .unwrap() + .scope_id, + "scope-a" + ); + assert_eq!( + fixture + .manager + .search_memory(generation, "private") + .await + .unwrap() + .hits + .len(), + 1 + ); + assert_eq!( + fixture + .manager + .atomic_memory_entry(generation, &reference()) + .await + .unwrap() + .text, + "private result" + ); + let outcome = fixture.manager.remember(generation, "note").await.unwrap(); + assert_eq!(outcome.record.status, WriteStatus::Succeeded); + assert_eq!( + serde_json::to_value(&outcome.record.context).unwrap()["serverId"], + "server-a" + ); + assert_eq!(outcome.record.references, vec![reference()]); + assert!(outcome.result.is_some()); + assert_eq!(fixture.manager.state().unwrap().generation, generation); + } +} + +#[tokio::test] +async fn replacement_during_a_write_preserves_an_unknown_original_target_without_replay() { + let fixture = Fixture::new(true).await; + let generation = fixture.select().await; + fixture.state.lock().unwrap().pause = Some("/v1/memory/remember".into()); + let manager = fixture.manager.clone(); + let write = + tokio::spawn(async move { manager.remember(generation, "private submitted note").await }); + fixture.entered().await; + fixture.replace(); + fixture.release.notify_one(); + let outcome = timeout(Duration::from_secs(5), write) + .await + .unwrap() + .unwrap() + .unwrap(); + assert_eq!(outcome.record.status, WriteStatus::Unknown); + assert_eq!(outcome.record.error.unwrap().code, SafeError::StaleContext); + assert!(outcome.result.is_none()); + assert!(outcome.record.references.is_empty()); + let state = fixture.manager.state().unwrap(); + let original = serde_json::to_value(state.last_write.unwrap()).unwrap(); + assert_eq!(original["context"]["serverId"], "server-a"); + assert_eq!(original["context"]["scopeId"], "scope-a"); + assert!(!original.to_string().contains("private submitted note")); + fixture.assert_disconnected(generation); + assert_eq!( + fixture + .state + .lock() + .unwrap() + .requests + .iter() + .filter(|path| *path == "/v1/memory/remember") + .count(), + 1 + ); +} + +#[tokio::test] +async fn an_old_write_cannot_disconnect_a_newly_activated_deployment() { + let fixture = Fixture::new(true).await; + let generation = fixture.select().await; + fixture.state.lock().unwrap().pause = Some("/v1/memory/remember".into()); + let manager = fixture.manager.clone(); + let write = tokio::spawn(async move { manager.remember(generation, "note").await }); + fixture.entered().await; + fixture.replace(); + let replacement = fixture.activate().await; + fixture.release.notify_one(); + let outcome = timeout(Duration::from_secs(5), write) + .await + .unwrap() + .unwrap() + .unwrap(); + assert_eq!(outcome.record.status, WriteStatus::Unknown); + assert!(outcome.result.is_none()); + let state = fixture.manager.state().unwrap(); + assert_eq!(state.generation, replacement); + assert_eq!( + state + .active + .unwrap() + .report + .discovery + .unwrap() + .server + .unwrap() + .server_id, + "server-b" + ); + assert_eq!( + serde_json::to_value(state.last_write.unwrap()).unwrap()["context"]["serverId"], + "server-a" + ); +} + +#[tokio::test] +async fn replacement_during_activation_does_not_mix_deployment_and_principal_facts() { + let fixture = Fixture::new(true).await; + fixture.state.lock().unwrap().pause = Some("/v1/access/me".into()); + let (manager, id) = (fixture.manager.clone(), fixture.id.clone()); + let check = tokio::spawn(async move { manager.check(&id, true).await }); + fixture.entered().await; + fixture.replace(); + fixture.release.notify_one(); + let error = timeout(Duration::from_secs(5), check) + .await + .unwrap() + .unwrap() + .err() + .unwrap(); + assert_eq!(error.code, SafeError::StaleContext); + assert!(fixture.manager.state().unwrap().active.is_none()); +} + +#[tokio::test] +async fn principal_revocation_while_reading_discards_private_results() { + let fixture = Fixture::new(true).await; + let generation = fixture.select().await; + fixture.state.lock().unwrap().pause = Some("/v1/memory/search".into()); + let manager = fixture.manager.clone(); + let read = tokio::spawn(async move { manager.search_memory(generation, "private").await }); + fixture.entered().await; + fixture.state.lock().unwrap().principal = Some("different-principal".into()); + fixture.release.notify_one(); + assert_eq!( + timeout(Duration::from_secs(5), read) + .await + .unwrap() + .unwrap() + .err() + .unwrap() + .code, + SafeError::StaleContext + ); + fixture.assert_disconnected(generation); +} diff --git a/desktop/ui/src/app/MemoryWorkspace.tsx b/desktop/ui/src/app/MemoryWorkspace.tsx index 1921a60e64..bfce32d15e 100644 --- a/desktop/ui/src/app/MemoryWorkspace.tsx +++ b/desktop/ui/src/app/MemoryWorkspace.tsx @@ -21,6 +21,7 @@ import type { ArtifactReference, AtomicMemoryRecord, MemorySearchHit, + MemoryContext, WriteOutcome, } from "../generated/ipc"; import { desktopApi } from "../shared/ipc"; @@ -43,6 +44,15 @@ type Props = { }; export const textBytes = (value: string) => new TextEncoder().encode(value).length; +function WriteTarget({ context }: { context: MemoryContext }) { + return ( + + {context.endpoint} · {context.serverId && <>{context.serverId} · } + {context.scopeId} + + ); +} + type NoteFormProps = Props & { onReadSaved: ( artifact: ArtifactReference, @@ -222,11 +232,7 @@ export function NoteForm({ title={t.failed} >

- {t.original}:{" "} - - {outcome.record.context.endpoint} ·{" "} - {outcome.record.context.scopeId} - + {t.original}:

) : ( @@ -255,11 +261,7 @@ export function NoteForm({ > {outcome.record.status === "unknown" && (

- {t.original}:{" "} - - {outcome.record.context.endpoint} ·{" "} - {outcome.record.context.scopeId} - + {t.original}:

)} @@ -474,10 +476,7 @@ export function MemoryWorkspace(props: Props) { record.context.generation !== active?.generation) && (

- {t.original}:{" "} - - {record.context.endpoint} · {record.context.scopeId} - + {t.original}:

{record.status === "unknown" diff --git a/desktop/ui/src/generated/ipc.ts b/desktop/ui/src/generated/ipc.ts index 6cb3161ac3..bbb9267b6e 100644 --- a/desktop/ui/src/generated/ipc.ts +++ b/desktop/ui/src/generated/ipc.ts @@ -37,7 +37,7 @@ export type DiscoveryResult = { status: DiscoveryStatus, server: ServerInfo | nu export type CheckReport = { connectionId: string, revision: number, checkedAt: number, liveness: Fact, readiness: Fact, identity: Fact, capabilities: Fact, compatibilityVerified: boolean, anonymousAccess: boolean, supportedOperations: Array, discovery?: DiscoveryResult, }; export type CompatibilityProfile = { id: string, serverCommit: string, contractSha256: string, artifactSha256: string, operations: Array, evidence: string, }; export type ActiveView = { connectionId: string, generation: number, report: CheckReport, scope: ScopeDescriptor | null, }; -export type MemoryContext = { connectionId: string, endpoint: string, principal: AccessPrincipal | null, generation: number, scopeId: string, }; +export type MemoryContext = { connectionId: string, endpoint: string, serverId: string | null, principal: AccessPrincipal | null, generation: number, scopeId: string, }; export type WriteStatus = "pending" | "succeeded" | "failed" | "unknown"; export type WriteRecord = { operationId: string, context: MemoryContext, status: WriteStatus, references: Array, error: ApiFailure | null, }; export type WriteOutcome = { record: WriteRecord, result: MemorySaveResponse | null, }; diff --git a/desktop/ui/tests/memory.test.tsx b/desktop/ui/tests/memory.test.tsx index 55a404a882..61ef8007ab 100644 --- a/desktop/ui/tests/memory.test.tsx +++ b/desktop/ui/tests/memory.test.tsx @@ -346,6 +346,7 @@ function outcome(status: "succeeded" | "unknown" | "failed"): WriteOutcome { context: { connectionId: "connection-a", endpoint: "http://127.0.0.1:8000", + serverId: "server-a", principal: null, generation: 1, scopeId: "scope-a", @@ -357,12 +358,12 @@ function outcome(status: "succeeded" | "unknown" | "failed"): WriteOutcome { result: status === "succeeded" ? { changed: false, records: [] } : null, }; } -function form() { +function form(language: "en" | "zh" = "en") { vi.mocked(desktopApi.state).mockResolvedValue(state); render( { - const user = userEvent.setup(); - form(); - vi.mocked(desktopApi.remember).mockResolvedValue(outcome("unknown")); - await user.type(screen.getByLabelText("Memory text"), "preserve me"); - await user.click(screen.getByRole("button", { name: "Save" })); - expect(screen.getByRole("alert").textContent).toContain("outcome is unknown"); - expect(screen.getByRole("alert").textContent).toContain( - "http://127.0.0.1:8000 · scope-a", - ); - expect( - (screen.getByLabelText("Memory text") as HTMLTextAreaElement).value, - ).toBe("preserve me"); - expect(desktopApi.remember).toHaveBeenCalledTimes(1); +test.each(["en", "zh"] as const)( + "%s unknown save retains its draft and original deployment without replay", + async (language) => { + const user = userEvent.setup(); + form(language); + const label = language === "en" ? "Memory text" : "记忆内容"; + const save = language === "en" ? "Save" : "保存"; + vi.mocked(desktopApi.remember).mockResolvedValue(outcome("unknown")); + await user.type(screen.getByLabelText(label), "preserve me"); + await user.click(screen.getByRole("button", { name: save })); + expect(screen.getByRole("alert").textContent).toContain( + language === "en" ? "outcome is unknown" : "未知", + ); + expect(screen.getByRole("alert").textContent).toContain( + "http://127.0.0.1:8000 · server-a · scope-a", + ); + expect((screen.getByLabelText(label) as HTMLTextAreaElement).value).toBe( + "preserve me", + ); + expect(desktopApi.remember).toHaveBeenCalledTimes(1); - await user.click(screen.getByRole("button", { name: "Save" })); - await answerConfirmation(user, false); - expect(desktopApi.remember).toHaveBeenCalledTimes(1); - expect( - (screen.getByLabelText("Memory text") as HTMLTextAreaElement).value, - ).toBe("preserve me"); - await user.click(screen.getByRole("button", { name: "Save" })); - await answerConfirmation(user, true); - expect(desktopApi.remember).toHaveBeenCalledTimes(2); -}); + await user.click(screen.getByRole("button", { name: save })); + await answerConfirmation(user, false); + expect(desktopApi.remember).toHaveBeenCalledTimes(1); + expect((screen.getByLabelText(label) as HTMLTextAreaElement).value).toBe( + "preserve me", + ); + await user.click(screen.getByRole("button", { name: save })); + await answerConfirmation(user, true); + expect(desktopApi.remember).toHaveBeenCalledTimes(2); + }, +); test.each(["forbidden", "network"])( "a save completing after a %s search failure keeps its receipt without restoring private bodies", async (code) => { From 4bc80aac1b50816248daf6d217676ac3ecfb839c Mon Sep 17 00:00:00 2001 From: jackie-cqz <2557911191@qq.com> Date: Thu, 29 Oct 2026 21:10:18 +0800 Subject: [PATCH 2/6] test(desktop): confirm uncertain draft discard in installed workflow --- desktop/tests/installed_workflow.py | 11 +++++++++ desktop/ui/tests/memory.test.tsx | 35 +++++++++++++++++++++++++++++ 2 files changed, 46 insertions(+) diff --git a/desktop/tests/installed_workflow.py b/desktop/tests/installed_workflow.py index 96ee1212bd..62a7b5a869 100644 --- a/desktop/tests/installed_workflow.py +++ b/desktop/tests/installed_workflow.py @@ -249,6 +249,7 @@ def exercise_memory(client: httpx.Client, prefix: str) -> dict[str, object]: "rawUtf8BudgetBoundary": True, "emptyAndCappedSearchPresentation": True, "committedLostResponseUnknownWithoutReplay": True, + "unknownDraftNavigationDiscardConfirmed": True, "lateSavePreservesNewScopeDraft": True, } @@ -351,6 +352,16 @@ def exercise_unknown_write(page: InstalledPage) -> None: if len(hits) != 1 or search_reference(hits[0]) != reference or counter.read_text(encoding="utf-8") != "1": raise HarnessFailure("installed_unknown_write_replayed_or_missing") page.clear_note() + # Clearing the editable form does not discard the retained submitted + # input. Finish recovery explicitly before the next Server scenario. + page.button("连接") + page.confirm("丢弃尚未保存的输入?", accept=False) # noqa: RUF001 - exact localized UI + page.element("//main//h1[normalize-space(.)='记忆']") + page.button("连接") + page.confirm("丢弃尚未保存的输入?", accept=True) # noqa: RUF001 - exact localized UI + page.field("连接名称") + if counter.read_text(encoding="utf-8") != "1": + raise HarnessFailure("installed_unknown_navigation_replayed_write") def exercise_late_save(page: InstalledPage) -> None: diff --git a/desktop/ui/tests/memory.test.tsx b/desktop/ui/tests/memory.test.tsx index 9ac29b0097..04266041a9 100644 --- a/desktop/ui/tests/memory.test.tsx +++ b/desktop/ui/tests/memory.test.tsx @@ -447,6 +447,41 @@ test.each(["en", "zh"] as const)( expect(desktopApi.remember).toHaveBeenCalledTimes(1); }, ); +test("leaving a cleared unknown-write form explicitly discards retained input without clearing the receipt", async () => { + const user = userEvent.setup(); + vi.mocked(desktopApi.state).mockResolvedValue(state); + render(); + await user.click(screen.getByRole("button", { name: "记忆" })); + const unknown = outcome("unknown"); + vi.mocked(desktopApi.remember).mockResolvedValueOnce(unknown); + vi.mocked(desktopApi.state).mockResolvedValue({ + ...state, + lastWrite: unknown.record, + }); + await user.type( + screen.getByLabelText("记忆内容"), + "uncertain submitted input", + ); + await user.click(screen.getByRole("button", { name: "保存" })); + await screen.findAllByText(/提交结果未知。/); + await user.clear(screen.getByLabelText("记忆内容")); + await user.click(screen.getByRole("button", { name: "连接" })); + await answerConfirmation(user, false); + expect(screen.getByRole("heading", { level: 1, name: "记忆" })).toBeTruthy(); + await user.click(screen.getByRole("button", { name: "连接" })); + await answerConfirmation(user, true); + expect(await screen.findByLabelText("连接名称")).toBeTruthy(); + await user.click(screen.getByRole("button", { name: "记忆" })); + expect(screen.queryByRole("alertdialog")).toBeNull(); + await user.type(screen.getByLabelText("记忆内容"), "next explicit write"); + await user.click(screen.getByRole("button", { name: "保存" })); + expect(screen.getByRole("alertdialog").textContent).toContain( + "再次保存可能产生重复记录", + ); + await answerConfirmation(user, false); + expect(desktopApi.remember).toHaveBeenCalledTimes(1); +}); + function form(language: "en" | "zh" = "en") { vi.mocked(desktopApi.state).mockResolvedValue(state); render( From a79313a094959c43bb066c15a47ea69dc050c7de Mon Sep 17 00:00:00 2001 From: jackie-cqz <2557911191@qq.com> Date: Sat, 31 Oct 2026 12:04:00 +0800 Subject: [PATCH 3/6] test(ci): align migration and asynchronous acceptance checks --- .../test_mysql_vector_admission.py | 2 +- tests/e2e/test_worker_logging.py | 24 ++--- tests/e2e/test_zcode_service_chain.py | 94 +++++++++++++++---- 3 files changed, 89 insertions(+), 31 deletions(-) diff --git a/tests/builtin/persistence/test_mysql_vector_admission.py b/tests/builtin/persistence/test_mysql_vector_admission.py index 14a92a7721..d4651cdd6b 100644 --- a/tests/builtin/persistence/test_mysql_vector_admission.py +++ b/tests/builtin/persistence/test_mysql_vector_admission.py @@ -69,7 +69,7 @@ def runner(selected=bundle, configured=configuration): monkeypatch.setattr(migration, "mysql_inventory", lambda _connection: copy.deepcopy(inventory)) monkeypatch.setattr(mysql_schema, "mysql_inventory", lambda _connection: copy.deepcopy(inventory)) - def run(operation, *, writable=False): + def run(operation, *, writable=False, allow_missing_database=False): assert not writable, "Admission must fail before requesting a write connection" engine = create_engine(f"sqlite:///{database}") try: diff --git a/tests/e2e/test_worker_logging.py b/tests/e2e/test_worker_logging.py index 11613e841e..c8ffd47704 100644 --- a/tests/e2e/test_worker_logging.py +++ b/tests/e2e/test_worker_logging.py @@ -23,6 +23,7 @@ import multiprocessing import subprocess import sys +import threading import time from collections.abc import Callable from dataclasses import dataclass @@ -755,10 +756,18 @@ async def run() -> None: def test_server_composition_initializes_logging_in_the_real_family_worker( capfd, tmp_path, monkeypatch, file_logging ) -> None: + completed = threading.Event() + + def observe_completion(record: logging.LogRecord) -> bool: + if getattr(record, "event", None) in {"artifact_processing.worker.acknowledged", "artifact_processing.failed"}: + completed.set() + return True + parent_logger = logging.getLogger("powercontext") handler = logging.StreamHandler(sys.stdout) handler.setFormatter(JsonFormatter()) handler.addFilter(OperationalContextFilter(role="all")) + handler.addFilter(observe_completion) monkeypatch.setattr(parent_logger, "handlers", [handler]) monkeypatch.setattr(parent_logger, "level", logging.INFO) monkeypatch.setattr(parent_logger, "propagate", False) @@ -771,23 +780,16 @@ def test_server_composition_initializes_logging_in_the_real_family_worker( mcp=McpConfig(enabled=False), ) ) - records: list[dict[str, Any]] = [] with TestClient(app) as client: scope_id = client.get("/v1/scopes/default").json()["scope_id"] response = client.post( "/v1/sources/content", json={"scope_id": scope_id, "source_id": "logging-source", "content": _PRIVATE} ) assert response.status_code == 202 - deadline = time.monotonic() + 30 - while time.monotonic() < deadline: - records.extend(_json_records(capfd.readouterr().out)) - if any( - record.get("event") in {"artifact_processing.worker.acknowledged", "artifact_processing.failed"} - for record in records - ): - break - time.sleep(0.05) - records.extend(_json_records(capfd.readouterr().out)) + # Do not reset/truncate fd capture while spawned processes still write + # through inherited descriptors. Observe completion without consuming it. + assert completed.wait(timeout=60), "The real family worker must report completion" + records = _json_records(capfd.readouterr().out) worker_records = ( _json_records((tmp_path / "logs" / "all.log").read_text(encoding="utf-8")) if file_logging else records ) diff --git a/tests/e2e/test_zcode_service_chain.py b/tests/e2e/test_zcode_service_chain.py index 3136026eaa..8203a09b7f 100644 --- a/tests/e2e/test_zcode_service_chain.py +++ b/tests/e2e/test_zcode_service_chain.py @@ -16,6 +16,7 @@ from __future__ import annotations +import asyncio import json import os import shutil @@ -91,7 +92,24 @@ def _invoke_hook( return json.loads(result.stdout) -def test_zcode_source_is_processed_and_recalled_in_a_new_session(tmp_path: Path, monkeypatch) -> None: +def _wait_for_processed_memory(client: httpx.Client, scope_id: str, text: str) -> dict[str, Any]: + deadline = time.monotonic() + 30 + while time.monotonic() < deadline: + response = client.post("/v1/memory/entries/list", json={"scope_id": scope_id}) + response.raise_for_status() + items = response.json()["entries"] + assert all(entry["artifact"]["family"] == "atomic-memory" for entry in items) + generated = next((entry for entry in items if text in entry["text"]), None) + if generated is not None: + return generated + time.sleep(0.05) + pytest.fail("The original flush must commit the captured source") + + +@pytest.mark.parametrize("slow_flush_receipt", [False, True]) +def test_zcode_source_is_processed_and_recalled_in_a_new_session( + tmp_path: Path, monkeypatch, slow_flush_receipt +) -> None: monkeypatch.setenv("ZCODE_PLUGIN_DATA", str(tmp_path / "plugin-data")) node = shutil.which("node") if node is None: @@ -102,6 +120,21 @@ def test_zcode_source_is_processed_and_recalled_in_a_new_session(tmp_path: Path, ), candidate_pipeline=atomic_memory_pipeline(DeterministicSourcePipeline()), ) + flush_requests = 0 + + @app.middleware("http") + async def observe_flush(request, call_next): + nonlocal flush_requests + is_flush = request.url.path == "/v1/memory/flush" + if is_flush: + flush_requests += 1 + response = await call_next(request) + if is_flush and slow_flush_receipt: + # Processing commits in the real Server, but the response arrives + # after Stop's fixed 800 ms request budget. + await asyncio.sleep(1.2) + return response + with socket.socket() as socket_probe: socket_probe.bind(("127.0.0.1", 0)) port = socket_probe.getsockname()[1] @@ -198,19 +231,21 @@ def run_scope(action: str, *extra: str) -> dict[str, Any]: remaining = 5 - time.time() % 5 if remaining < 1.5: time.sleep(remaining + 0.05) + stop_input = json.dumps({ + "hookEventName": "Stop", + "cwd": str(HOOK.parents[5]), + "sessionId": "old-session", + "stopHookActive": False, + }) + stop_environment = { + **environment, + "POWERCONTEXT_ZCODE_SCOPE_ID": scope_id, + "POWERCONTEXT_ZCODE_BOUNDARY_FLUSH": "true", + } stopped = subprocess.run( [node, str(HOOK.parent / "stop.mjs")], - input=json.dumps({ - "hookEventName": "Stop", - "cwd": str(HOOK.parents[5]), - "sessionId": "old-session", - "stopHookActive": False, - }), - env={ - **environment, - "POWERCONTEXT_ZCODE_SCOPE_ID": scope_id, - "POWERCONTEXT_ZCODE_BOUNDARY_FLUSH": "true", - }, + input=stop_input, + env=stop_environment, text=True, capture_output=True, check=False, @@ -239,13 +274,34 @@ def run_scope(action: str, *extra: str) -> dict[str, Any]: ) assert status.returncode == 0, status.stdout + status.stderr result = json.loads(status.stdout) - assert result["observation"]["stages"]["flush"]["state"] == "cursor_reached", result - assert result["pending"]["scopes"] == [] - entries = client.post("/v1/memory/entries/list", json={"scope_id": scope_id}) - entries.raise_for_status() - assert entries.json()["entries"] - assert all(entry["artifact"]["family"] == "atomic-memory" for entry in entries.json()["entries"]) - generated = next(entry for entry in entries.json()["entries"] if "teal-731" in entry["text"]) + flush = result["observation"]["stages"]["flush"] + assert flush["state"] in {"cursor_reached", "unknown"}, result + if slow_flush_receipt: + assert flush["state"] == "unknown", result + if flush["state"] == "unknown": + assert flush["code"] == "timeout", result + pending = result["pending"]["scopes"] + assert len(pending) == 1 and pending[0]["scope_id"] == scope_id, result + assert pending[0]["unknown_flush_paused"] is True, result + assert pending[0]["receipt_count"] == 1, result + assert pending[0]["target_position"] == flush["source_position"], result + retried = subprocess.run( + stopped.args, + input=stop_input, + env=stop_environment, + text=True, + capture_output=True, + check=False, + timeout=2, + ) + assert retried.returncode == 0 and json.loads(retried.stdout) == {}, retried.stderr + assert "unknown_flush_paused" in retried.stderr + else: + assert result["pending"]["scopes"] == [] + # Stop is time-bounded, not a guarantee of completed processing. + # Verify committed memory independently, without replaying the flush. + generated = _wait_for_processed_memory(client, scope_id, "teal-731") + assert flush_requests == 1, "An unknown Stop must never replay its mutation automatically" captured = client.get(f"/v1/scopes/{scope_id}/sources").json()["items"] reference = generated["artifact"] revision = client.get( From db05cf462b68061370f171b06d63e692c4d4b806 Mon Sep 17 00:00:00 2001 From: jackie-cqz <2557911191@qq.com> Date: Sat, 31 Oct 2026 12:04:01 +0800 Subject: [PATCH 4/6] fix(desktop): preserve rejected input after automatic disconnect --- desktop/README.md | 2 +- desktop/README.zh.md | 2 +- desktop/SECURITY.md | 2 +- desktop/VALIDATION.md | 22 +++++++ .../src-tauri/tests/deployment_identity.rs | 64 +++++++++++++++++++ desktop/ui/src/app/App.tsx | 10 +-- desktop/ui/src/app/MemoryWorkspace.tsx | 10 +-- desktop/ui/tests/memory.test.tsx | 37 ++++++++++- 8 files changed, 128 insertions(+), 21 deletions(-) diff --git a/desktop/README.md b/desktop/README.md index ce08bdcd9f..ef0808bc35 100644 --- a/desktop/README.md +++ b/desktop/README.md @@ -116,7 +116,7 @@ Connection checks bind the observed `server_id` to the Principal and recheck the Submitted writes retain their original endpoint, `serverId` (null for qualified legacy Servers), Principal, Scope and generation. If the post-write identity check fails, a successful HTTP reply is treated as an unknown outcome: private bodies/references are withheld and the write is never automatically replayed. The original deployment is shown in write recovery feedback. The submitted input survives that disconnection as a read-only recovery draft in the current window, with explicit copy/discard actions; it is never inserted into a new target's form or persisted. Closing the window loses it. Confirmed navigation discards it, and a late response cannot restore an explicitly discarded draft. A late write cannot invalidate a newly activated context. Qualified legacy sessions require discovery to remain 404; discovery appearing requires explicit rechecking rather than silently acquiring a deployment identity. A failed connection recheck removes the previous cached report instead of leaving old verification visible. -Desktop retains each submitted input in application memory from the start of the native call, independently of the form. An automatic disconnection during a concurrent read therefore leaves a read-only copy even while the write is pending. Recovery distinguishes pending, unknown and attempts without a receipt. A retry does not discard earlier uncertain input, including when that retry fails before its mutation reaches the Server. Each attempt has its own local identity: a conclusive receipt clears only its input, and explicit discard removes only the selected copy (or all copies when confirming navigation). Late receipts cannot restore discarded inputs or clear another attempt. Inputs are limited to 8192 UTF-8 bytes per attempt; copies remain only for unresolved attempts until explicit discard or window close. They contain user input and observed original-target metadata, not returned private Server bodies, and are never replayed or persisted. +Desktop retains each submitted input in application memory from the start of the native call, independently of the form. An automatic disconnection during a concurrent read therefore leaves a read-only copy even while the write is pending. Recovery distinguishes pending, unknown, rejected and attempts without a receipt. A retry does not discard earlier uncertain input, including when that retry fails before its mutation reaches the Server. Each attempt has its own local identity: only a successful receipt clears its input, rejected saves keep their text for explicit recovery, and explicit discard removes only the selected copy (or all copies when confirming navigation). Late receipts cannot restore discarded inputs or clear another attempt. Inputs are limited to 8192 UTF-8 bytes per attempt; copies remain for pending, uncertain or rejected attempts until success, explicit discard or window close. They contain user input and observed original-target metadata, not returned private Server bodies, and are never replayed or persisted. These checks observe identity around requests; the public mutation contract has no conditional Server-identity precondition. They cannot atomically prevent an endpoint from changing between identity verification and dispatch, or detect a replacement that changes back between observations. Legacy Servers have no durable deployment fence. Discovery does not attest binary identity. Connection checks and anonymous identity revalidation still perform readiness probes; explicit readiness separation remains pending. diff --git a/desktop/README.zh.md b/desktop/README.zh.md index 58f4204e72..fb1105e1e2 100644 --- a/desktop/README.zh.md +++ b/desktop/README.zh.md @@ -94,7 +94,7 @@ pnpm --dir desktop desktop:dev 已提交写入保留原始端点、`serverId`(已验证旧版 Server 为 null)、Principal、Scope 和代次。写入后的身份复核失败时,即使 HTTP 返回成功也按结果未知处理:不返回私有正文或引用,不自动重放。写入恢复反馈显示原部署;断连后原始输入在当前窗口内作为只读恢复草稿保留,可明确复制或丢弃,不会填入新目标的表单,也不持久化;关闭窗口会丢失草稿。确认离开页面会丢弃草稿,晚到响应不能恢复已明确丢弃的输入。旧写入晚到不会使新激活的上下文失效。已验证旧版会话要求发现接口持续返回 404;发现接口出现后须明确重新检查,不能静默获得部署身份。连接复查失败会移除旧缓存报告,避免继续显示过期的验证结果。 -应用从调用原生提交开始,在内存中独立保留每次输入,不依赖表单是否仍存在。因此,并发读取触发自动断连时,即使写入还在等待回执,仍有只读副本可恢复。恢复界面区分进行中、结果未知和未取得回执的尝试。重试不会丢弃先前的未知输入,包括重试的写请求尚未发出就失败的情况。每次尝试有独立的本地标识:确定性回执只清理该次输入,明确丢弃只移除所选副本,确认离开页面则丢弃所有副本;晚到回执不能恢复已丢弃输入或清除另一尝试。每次输入最多 8192 UTF-8 字节;只有尚未确定结果的尝试会保留副本,直到明确丢弃或关闭窗口。副本仅含用户输入及观察到的原目标元数据,不含 Server 返回的私有正文,不重放,也不持久化。 +应用从调用原生提交开始,在内存中独立保留每次输入,不依赖表单是否仍存在。因此,并发读取触发自动断连时,即使写入还在等待回执,仍有只读副本可恢复。恢复界面区分进行中、结果未知、保存被拒绝和未取得回执的尝试。重试不会丢弃先前的未知输入,包括重试的写请求尚未发出就失败的情况。每次尝试有独立的本地标识:成功回执只清理该次输入,保存被拒绝时仍保留正文供明确恢复,明确丢弃只移除所选副本,确认离开页面则丢弃所有副本;晚到回执不能恢复已丢弃输入或清除另一尝试。每次输入最多 8192 UTF-8 字节;尚未成功保存的尝试会保留副本,直到成功、明确丢弃或关闭窗口。副本仅含用户输入及观察到的原目标元数据,不含 Server 返回的私有正文,不重放,也不持久化。 这些检查观察请求前后的身份;公开写入契约没有以 Server 身份为条件的前置约束,因此不能原子性阻止验证与发送之间的端点替换,也不能发现两次观察之间发生后又恢复的替换。旧版 Server 没有持久部署隔离保障;发现信息也不证明二进制身份。连接检查和匿名身份复核仍会调用 readiness,显式 readiness 分离尚未完成。 diff --git a/desktop/SECURITY.md b/desktop/SECURITY.md index 9110fda727..23b2f1fe4e 100644 --- a/desktop/SECURITY.md +++ b/desktop/SECURITY.md @@ -18,7 +18,7 @@ Native reqwest uses Windows system trust through native-tls/Schannel, plus an op Typed adapters separate liveness, readiness, identity and capabilities. Native context generations cancel old reads on connection, identity and Scope changes; a separate query generation cancels superseded Scope searches. Business operations require explicit qualified compatibility and actual Server authorization. Scope and Memory reads validate both deployment and Principal before dispatch and after the response. Observed identity loss clears the active context. Dispatched writes keep immutable endpoint/deployment/Principal/Scope metadata; failed post-write verification makes successful HTTP outcomes unknown without returning private content or automatically replaying. Late writes cannot clear a newer context. Qualified legacy Servers retain a null deployment identity and require discovery to remain 404. Identity observations do not supply an atomic Server-side conditional write fence or binary attestation. No automatic write retry or persistent queue exists. -The renderer retains submitted input in window-local memory independently of form lifetime. Automatic disconnection does not discard it, and a retry cannot remove an earlier unresolved attempt. Recovery copies contain only user input and original-target metadata, never returned private Server bodies. Each copy is at most 8192 UTF-8 bytes plus metadata; retention grows with unresolved explicit user submissions. Conclusive receipts remove their own copy; users can explicitly discard individual copies or all copies through navigation confirmation. Discarded identities cannot be revived by late receipts. Window close loses all copies. There is no disk persistence, background replay or export through session diagnostics. +The renderer retains submitted input in window-local memory independently of form lifetime. Automatic disconnection does not discard it, and a retry cannot remove an earlier unresolved attempt. Recovery copies contain only user input and original-target metadata, never returned private Server bodies. Each copy is at most 8192 UTF-8 bytes plus metadata; retention grows with explicit user submissions awaiting success, recovery or discard. Successful receipts remove their own copy; rejected saves retain input for explicit recovery or discard; users can explicitly discard individual copies or all copies through navigation confirmation. Discarded identities cannot be revived by late receipts. Window close loses all copies. There is no disk persistence, background replay or export through session diagnostics. ## Budgets and error projection diff --git a/desktop/VALIDATION.md b/desktop/VALIDATION.md index 821df32b64..944e6cd715 100644 --- a/desktop/VALIDATION.md +++ b/desktop/VALIDATION.md @@ -65,6 +65,28 @@ the write finishes, individual discard, late unknown receipts after discard, and without restoring private results. TCP fixtures exercise the native Manager; jsdom tests mock IPC. These checks do not establish installed WebView or live external Server acceptance of the new paths. +## Rejected submission recovery and Python CI checks + +On 2026-10-31, Windows 10 x64 checks reproduce a paused save returning 403 after a concurrent +read disconnects the context. The native Manager retains the failed receipt and original target +without replay; the application keeps the rejected text available for explicit copy/discard. +English and Chinese App regressions verify copy, cancelled discard and explicit discard of rejected input. +All 90 native suite cases and 148 frontend tests pass, together with TypeScript, lint, production build, +Rust formatting, Clippy and IPC drift. Proxy isolation also reruns in a spawned process. +All 14 deployment-identity cases pass with real loopback sockets and synthetic HTTP responses; +App state-refresh tests use mocked IPC and do not qualify installed WebView behavior. + +Python 3.11.15 checks pass 38 cases across the affected MySQL admission/runner, worker logging +and ZCode service-chain modules; 57 runner cases are skipped by their platform/backend guards. +The admission fixture accepts the maintenance connection's missing-database keyword while +retaining its read-only assertion. The real worker test waits for a completion observation +before consuming captured descriptors. ZCode checks use Node 24.22.1 with a real HTTP Server +and SQLite, including a response deliberately delayed past Stop's unchanged 800 ms request +budget: the result remains unknown, another Stop does not replay the mutation, and independent +reads verify committed memory, exact provenance and recall in a new session. A normal response +also exercised the cursor-reached path. These local checks do not establish Linux/Python matrix +success; consult the new CI run for those results. + ## Native session observation checks Windows x64 checks for the native session collector use Rust 1.95.0, Node 24.14.1 and pnpm 11.13.1. diff --git a/desktop/src-tauri/tests/deployment_identity.rs b/desktop/src-tauri/tests/deployment_identity.rs index 834892c58e..f9038ef919 100644 --- a/desktop/src-tauri/tests/deployment_identity.rs +++ b/desktop/src-tauri/tests/deployment_identity.rs @@ -40,6 +40,7 @@ struct State { pause: Option, requests: Vec, lose_write_response: bool, + reject_write: bool, } struct Fixture { manager: Arc, @@ -85,6 +86,7 @@ impl Fixture { pause: None, requests: vec![], lose_write_response: false, + reject_write: false, })); let started = Arc::new(Notify::new()); let release = Arc::new(Notify::new()); @@ -152,6 +154,9 @@ impl Fixture { 200, json!({"mode":"text","hits":[{"memory":record(),"score":1,"matched_by":["text"]}]}), ), + "/v1/memory/remember" if state.reject_write => { + (403, json!({"error":{"code":"forbidden"}})) + } "/v1/memory/remember" => { (200, json!({"changed":true,"records":[record()]})) } @@ -684,6 +689,65 @@ async fn concurrent_read_disconnect_keeps_the_pending_write_until_its_unknown_re ); } +#[tokio::test] +async fn rejected_write_after_read_disconnection_retains_its_failed_original_receipt() { + let fixture = Fixture::new(true).await; + let generation = fixture.select().await; + { + let mut state = fixture.state.lock().unwrap(); + state.pause = Some("/v1/memory/remember".into()); + state.reject_write = true; + } + let manager = fixture.manager.clone(); + let write = tokio::spawn(async move { + manager + .remember(generation, "rejected original input") + .await + }); + fixture.entered().await; + fixture.state.lock().unwrap().discovery = (503, json!({"error":{"code":"runtime_not_ready"}})); + assert!( + fixture + .manager + .search_memory(generation, "query") + .await + .is_err() + ); + fixture.assert_disconnected(generation); + let pending = fixture.manager.state().unwrap().last_write.unwrap(); + assert_eq!(pending.status, WriteStatus::Pending); + fixture.release.notify_one(); + let result = timeout(Duration::from_secs(5), write) + .await + .unwrap() + .unwrap() + .unwrap(); + assert_eq!(result.record.status, WriteStatus::Failed); + assert_eq!( + result.record.error.as_ref().unwrap().code, + SafeError::Forbidden + ); + assert_eq!(result.record.operation_id, pending.operation_id); + assert_eq!(result.record.context.server_id.as_deref(), Some("server-a")); + assert_eq!(result.record.context.scope_id, "scope-a"); + assert!(result.result.is_none()); + assert!(result.record.references.is_empty()); + let retained = fixture.manager.state().unwrap().last_write.unwrap(); + assert_eq!(retained.operation_id, pending.operation_id); + assert_eq!(retained.status, WriteStatus::Failed); + assert_eq!( + fixture + .state + .lock() + .unwrap() + .requests + .iter() + .filter(|path| *path == "/v1/memory/remember") + .count(), + 1 + ); +} + #[tokio::test] async fn principal_revocation_while_reading_discards_private_results() { let fixture = Fixture::new(true).await; diff --git a/desktop/ui/src/app/App.tsx b/desktop/ui/src/app/App.tsx index 321ab2cc6b..31a9ac5c21 100644 --- a/desktop/ui/src/app/App.tsx +++ b/desktop/ui/src/app/App.tsx @@ -79,18 +79,12 @@ export function App() { setSubmittedDrafts((current) => current.flatMap((draft) => { if (draft.id !== id) return [draft]; - if (record?.status === "succeeded" || record?.status === "failed") - return []; + if (record?.status === "succeeded") return []; return [ { ...draft, target: record?.context ?? draft.target, - phase: - record?.status === "unknown" - ? "unknown" - : record - ? "pending" - : "unconfirmed", + phase: record?.status ?? "unconfirmed", }, ]; }), diff --git a/desktop/ui/src/app/MemoryWorkspace.tsx b/desktop/ui/src/app/MemoryWorkspace.tsx index 46359acf68..9fb84d8a65 100644 --- a/desktop/ui/src/app/MemoryWorkspace.tsx +++ b/desktop/ui/src/app/MemoryWorkspace.tsx @@ -53,7 +53,7 @@ export type SubmittedDraft = { id: number; text: string; target: SubmittedTarget; - phase: "pending" | "unknown" | "unconfirmed"; + phase: "pending" | "unknown" | "unconfirmed" | "failed"; }; export const textBytes = (value: string) => new TextEncoder().encode(value).length; @@ -90,13 +90,7 @@ export function SubmittedDraftRecovery({

{confirmation}

{t.recovery}

-

- {draft.phase === "pending" - ? t.pending - : draft.phase === "unknown" - ? t.unknown - : t.unconfirmed} -

+

{t[draft.phase]}

{t.recoveryHint}

{t.original}: diff --git a/desktop/ui/tests/memory.test.tsx b/desktop/ui/tests/memory.test.tsx index 469f6f821b..46741500b9 100644 --- a/desktop/ui/tests/memory.test.tsx +++ b/desktop/ui/tests/memory.test.tsx @@ -537,6 +537,8 @@ test.each([ ["zh", "discard"], ["en", "success"], ["zh", "success"], + ["en", "failed"], + ["zh", "failed"], ] as const)( "%s retains an in-flight submission across read disconnection and honors %s", async (language, action) => { @@ -604,7 +606,18 @@ test.each([ ); await answerConfirmation(user, true); } - const result = action === "success" ? makeSaved(1).outcome : unknown; + const result = + action === "success" + ? makeSaved(1).outcome + : action === "failed" + ? outcome("failed") + : unknown; + if (action === "failed") + result.record.error = { + code: "forbidden", + requestId: null, + dispatched: true, + }; vi.mocked(desktopApi.state).mockResolvedValue({ ...state, generation: 2, @@ -614,7 +627,7 @@ test.each([ await act(async () => { finish(result); }); - if (action === "keep") { + if (action === "keep" || action === "failed") { const copy = screen.getByRole("textbox", { name: label, }) as HTMLTextAreaElement; @@ -625,6 +638,26 @@ test.each([ name: language === "en" ? "Submitted draft recovery" : "提交草稿恢复", }).textContent, ).toContain("server-a"); + if (action === "failed") { + const discard = screen.getByRole("button", { + name: language === "en" ? "Discard submitted text" : "丢弃提交文本", + }); + await user.click(discard); + await answerConfirmation(user, false); + expect(copy.value).toBe("in-flight original input"); + const clipboard = vi + .spyOn(navigator.clipboard, "writeText") + .mockResolvedValue(); + await user.click( + screen.getByRole("button", { + name: language === "en" ? "Copy text" : "复制正文", + }), + ); + expect(clipboard).toHaveBeenCalledWith("in-flight original input"); + await user.click(discard); + await answerConfirmation(user, true); + expect(screen.queryByRole("textbox", { name: label })).toBeNull(); + } } else { expect(screen.queryByRole("textbox", { name: label })).toBeNull(); } From ee4024726e76ee73e3dab77eaa112e7e1db62a3f Mon Sep 17 00:00:00 2001 From: jackie-cqz <2557911191@qq.com> Date: Sat, 31 Oct 2026 19:57:36 +0800 Subject: [PATCH 5/6] test(code): select database in publication fixtures --- tests/builtin/test_code_publication.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/builtin/test_code_publication.py b/tests/builtin/test_code_publication.py index 078203a5c7..f97d158449 100644 --- a/tests/builtin/test_code_publication.py +++ b/tests/builtin/test_code_publication.py @@ -308,7 +308,7 @@ def cursor(self) -> Cursor: return Cursor() monkeypatch.setattr(pymysql, "connect", lambda **options: Connection()) - store = SeekDBGraphStore(tmp_path / "seekdb", {}) + store = SeekDBGraphStore(tmp_path / "seekdb", {}, database="test") store.remove_generation("a" * 32, "binding", time.monotonic() + 30) @@ -490,7 +490,7 @@ def cursor(self) -> Cursor: return Cursor() monkeypatch.setattr(pymysql, "connect", lambda **options: Connection()) - store = SeekDBGraphStore(tmp_path / "seekdb", {}) + store = SeekDBGraphStore(tmp_path / "seekdb", {}, database="test") with pytest.raises(CodeError): store.remove_generation("not-hex", "binding", time.monotonic() + 30) @@ -538,7 +538,7 @@ def cursor(self) -> Cursor: return Cursor() monkeypatch.setattr(pymysql, "connect", lambda **options: Connection()) - store = SeekDBGraphStore(tmp_path / "seekdb", {}) + store = SeekDBGraphStore(tmp_path / "seekdb", {}, database="test") store.remove_generation("b" * 32, "binding", time.monotonic() + 30) From 11eb64466f6e922161b8ef4fef6662283257fd72 Mon Sep 17 00:00:00 2001 From: jackie-cqz <2557911191@qq.com> Date: Sat, 31 Oct 2026 20:16:13 +0800 Subject: [PATCH 6/6] ci(desktop): retry transient macOS disk image creation failures --- .github/workflows/desktop.yml | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/.github/workflows/desktop.yml b/.github/workflows/desktop.yml index eb719dd4f5..4fe4c9c728 100644 --- a/.github/workflows/desktop.yml +++ b/.github/workflows/desktop.yml @@ -184,15 +184,28 @@ jobs: - name: Build unsigned macOS app and DMG run: | mkdir -p .artifacts - set -o pipefail - pnpm tauri build --ci --verbose -- --locked 2>&1 | tee .artifacts/macos-build.log + set -euo pipefail + : > .artifacts/macos-build.log + for attempt in 1 2 3; do + attempt_log=".artifacts/macos-build-attempt-${attempt}.log" + if pnpm tauri build --ci --verbose -- --locked 2>&1 | tee "$attempt_log" | tee -a .artifacts/macos-build.log; then + break + else + build_status=$? + fi + if [ "$attempt" -eq 3 ] || ! grep -Fq 'hdiutil: create failed - Resource busy' "$attempt_log"; then + exit "$build_status" + fi + echo "::warning::DMG creation found a busy resource; retrying after $((attempt * 5)) seconds (attempt $attempt of 3)." + sleep "$((attempt * 5))" + done - name: Upload failed macOS build diagnostics if: failure() uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7 with: name: desktop-macos-${{ matrix.runner }}-build-diagnostics path: | - desktop/.artifacts/macos-build.log + desktop/.artifacts/macos-build*.log desktop/src-tauri/target/release/bundle/dmg/bundle_dmg.sh if-no-files-found: warn - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7