From 9106ac51232cb6ea48ee3d3dc3e6d894c88355ff Mon Sep 17 00:00:00 2001 From: KiriAky 107 Date: Tue, 8 Sep 2026 22:04:46 +0800 Subject: [PATCH] =?UTF-8?q?feat(extensions):=20=E5=8F=96=E6=B6=88=E5=8E=9F?= =?UTF-8?q?=E7=94=9F=E6=9A=82=E5=AD=98=E5=B9=B6=E6=9A=B4=E9=9C=B2=E6=8C=81?= =?UTF-8?q?=E4=B9=85=E6=8F=90=E4=BA=A4=E7=8A=B6=E6=80=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../OpenNexus生产化实施进度-2026-09-08.md | 9 +++ frontend/src-tauri/build.rs | 3 + frontend/src-tauri/capabilities/main.json | 5 +- .../autogenerated/extension_stage_cancel.toml | 11 +++ .../extension_stage_prepare.toml | 11 +++ .../autogenerated/extension_stage_status.toml | 11 +++ frontend/src-tauri/src/extension_commands.rs | 63 +++++++++++++-- frontend/src-tauri/src/extension_store.rs | 81 ++++++++++++++++--- frontend/src-tauri/src/main.rs | 4 + .../src/features/community/CommunityView.vue | 2 +- .../src/services/communityDesktop.spec.ts | 15 +++- frontend/src/services/communityService.ts | 17 +++- 12 files changed, 207 insertions(+), 25 deletions(-) create mode 100644 frontend/src-tauri/permissions/autogenerated/extension_stage_cancel.toml create mode 100644 frontend/src-tauri/permissions/autogenerated/extension_stage_prepare.toml create mode 100644 frontend/src-tauri/permissions/autogenerated/extension_stage_status.toml diff --git a/docs/development/OpenNexus生产化实施进度-2026-09-08.md b/docs/development/OpenNexus生产化实施进度-2026-09-08.md index 53d5192..18e314b 100644 --- a/docs/development/OpenNexus生产化实施进度-2026-09-08.md +++ b/docs/development/OpenNexus生产化实施进度-2026-09-08.md @@ -287,3 +287,12 @@ Core 的独立数据目录目前不等于已授权 Vault。Python 旧笔记写 - Host 下载在后台阻塞任务中执行,持有安装库锁保持串行。当前 UI 取消不取消已派发的原生暂存任务,任务最多受请求超时约束且只产生暂存;后续需接入统一取消/进度流程。 - 32 项扩展回归、Clippy 全目标、6 项前端针对性测试和两套 TypeScript 项目检查通过。Rust 日志 `.build/extension-download-tests.log`;真实 socket 覆盖归档内容错误、无 Content-Length 超长流、302 拒绝和正确 Python 签名归档。 - 桌面文案明确“已校验并暂存,尚未安装或启用”。缺失依赖自动获取、完整安装 UI、停止实例/健康探测、沙箱和真实 HTTPS 部署验收仍待完成,不视为整体生产化完成。 + + +## 增量:原生暂存取消与提交状态查询 + +- 暂存采用独立 Host Requests 预留/领取/取消流程,60 秒总期限,派发前取消不会运行任务;等待安装库锁时也检查取消。网络 future 由 Lease 驱动取消,同步暂存各持久边界复查取消状态。 +- 主窗口新增 prepare/cancel/status 命令及 ACL。客户端在预留后注册取消,覆盖预留响应与派发之间的竞争;取消后查询持久操作回执,若提交先完成则返回“取消前已完成暂存”。同一页面未开始其他任务时保留这条结果。 +- 回执查询不把已提交状态伪装成取消回滚。提交前取消可能留下不可见的孤立归档对象,暂存版本/回执事务回滚;提交后取消保留原回执。未增加对象 GC 或跨重启任务列表。 +- 33 项扩展回归、4 项请求生命周期测试、7 项前端针对性测试、全目标 Clippy 和两套 TypeScript 项目检查通过;日志 `.build/extension-cancel-tests.log`。请求生命周期包含真实 socket 取消关闭测试,新增对象写入后/回执记录后/提交后的取消回执区分。 +- 完整任务进度列表、跨重启 UI 恢复、安装执行编排与沙箱仍未完成;整体目标继续进行。 diff --git a/frontend/src-tauri/build.rs b/frontend/src-tauri/build.rs index 6aa3630..ff862a8 100644 --- a/frontend/src-tauri/build.rs +++ b/frontend/src-tauri/build.rs @@ -20,6 +20,9 @@ fn main() { "extension_trust_confirm", "extension_install_preview", "extension_stage", + "extension_stage_prepare", + "extension_stage_cancel", + "extension_stage_status", "record_get", "record_write", "sync_login", diff --git a/frontend/src-tauri/capabilities/main.json b/frontend/src-tauri/capabilities/main.json index 4c10bb7..2dc5e05 100644 --- a/frontend/src-tauri/capabilities/main.json +++ b/frontend/src-tauri/capabilities/main.json @@ -53,6 +53,9 @@ "allow-extension-trust-review", "allow-extension-trust-confirm", "allow-extension-install-preview", - "allow-extension-stage" + "allow-extension-stage", + "allow-extension-stage-prepare", + "allow-extension-stage-cancel", + "allow-extension-stage-status" ] } diff --git a/frontend/src-tauri/permissions/autogenerated/extension_stage_cancel.toml b/frontend/src-tauri/permissions/autogenerated/extension_stage_cancel.toml new file mode 100644 index 0000000..4a09f0e --- /dev/null +++ b/frontend/src-tauri/permissions/autogenerated/extension_stage_cancel.toml @@ -0,0 +1,11 @@ +# Automatically generated - DO NOT EDIT! + +[[permission]] +identifier = "allow-extension-stage-cancel" +description = "Enables the extension_stage_cancel command without any pre-configured scope." +commands.allow = ["extension_stage_cancel"] + +[[permission]] +identifier = "deny-extension-stage-cancel" +description = "Denies the extension_stage_cancel command without any pre-configured scope." +commands.deny = ["extension_stage_cancel"] diff --git a/frontend/src-tauri/permissions/autogenerated/extension_stage_prepare.toml b/frontend/src-tauri/permissions/autogenerated/extension_stage_prepare.toml new file mode 100644 index 0000000..72c4021 --- /dev/null +++ b/frontend/src-tauri/permissions/autogenerated/extension_stage_prepare.toml @@ -0,0 +1,11 @@ +# Automatically generated - DO NOT EDIT! + +[[permission]] +identifier = "allow-extension-stage-prepare" +description = "Enables the extension_stage_prepare command without any pre-configured scope." +commands.allow = ["extension_stage_prepare"] + +[[permission]] +identifier = "deny-extension-stage-prepare" +description = "Denies the extension_stage_prepare command without any pre-configured scope." +commands.deny = ["extension_stage_prepare"] diff --git a/frontend/src-tauri/permissions/autogenerated/extension_stage_status.toml b/frontend/src-tauri/permissions/autogenerated/extension_stage_status.toml new file mode 100644 index 0000000..aae1b9b --- /dev/null +++ b/frontend/src-tauri/permissions/autogenerated/extension_stage_status.toml @@ -0,0 +1,11 @@ +# Automatically generated - DO NOT EDIT! + +[[permission]] +identifier = "allow-extension-stage-status" +description = "Enables the extension_stage_status command without any pre-configured scope." +commands.allow = ["extension_stage_status"] + +[[permission]] +identifier = "deny-extension-stage-status" +description = "Denies the extension_stage_status command without any pre-configured scope." +commands.deny = ["extension_stage_status"] diff --git a/frontend/src-tauri/src/extension_commands.rs b/frontend/src-tauri/src/extension_commands.rs index 610ff63..807e68c 100644 --- a/frontend/src-tauri/src/extension_commands.rs +++ b/frontend/src-tauri/src/extension_commands.rs @@ -149,6 +149,7 @@ pub async fn extension_install_preview( #[derive(Deserialize)] #[serde(deny_unknown_fields)] pub struct StageRequest { + request_id: String, operation_id: String, source: String, release: notesagent_host::extension_package::Release, @@ -160,22 +161,70 @@ pub async fn extension_stage( request: StageRequest, ) -> Result { main_window(&window)?; + let mut lease = host.extension_requests.claim(&request.request_id)?; + let checkpoint = lease.checkpoint(); let extensions = host.extensions.clone(); tauri::async_runtime::spawn_blocking(move || { - let mut store = extensions.lock().map_err(|_| "HOST_BUSY")?; + let mut store = loop { + checkpoint()?; + match extensions.try_lock() { + Ok(guard) => break guard, + Err(std::sync::TryLockError::Poisoned(_)) => return Err("HOST_BUSY".into()), + Err(std::sync::TryLockError::WouldBlock) => { + std::thread::sleep(Duration::from_millis(10)) + } + } + }; let store = store.as_mut().ok_or("EXTENSIONS_NOT_READY")?; - let receipt = tauri::async_runtime::block_on(store.stage_online( - &request.operation_id, - &request.source, - &request.release, - )) - .map_err(|e| e.code)?; + let receipt = tauri::async_runtime::block_on(lease.run(async { + checkpoint()?; + store + .stage_online_checked( + &request.operation_id, + &request.source, + &request.release, + || { + checkpoint() + .map_err(|code| notesagent_host::workspace::HostError::new(&code)) + }, + ) + .await + .map_err(|e| e.code) + }))?; serde_json::to_value(receipt).map_err(|_| "EXTENSION_STAGE_FAILED".into()) }) .await .map_err(|_| "EXTENSION_STAGE_FAILED".to_string())? } +#[tauri::command] +pub fn extension_stage_prepare( + window: WebviewWindow, + host: State<'_, Host>, +) -> Result { + main_window(&window)?; + host.extension_requests.prepare(60_000) +} +#[tauri::command] +pub fn extension_stage_cancel( + window: WebviewWindow, + host: State<'_, Host>, + request_id: String, +) -> Result<(), String> { + main_window(&window)?; + host.extension_requests.cancel(&request_id) +} +#[tauri::command] +pub fn extension_stage_status( + window: WebviewWindow, + host: State<'_, Host>, + operation_id: String, +) -> Result { + main_window(&window)?; + let receipt = store(&host, |s| s.stage_receipt(&operation_id))?; + serde_json::to_value(receipt).map_err(|_| "EXTENSION_STATUS_FAILED".into()) +} + #[cfg(test)] mod tests { use super::*; diff --git a/frontend/src-tauri/src/extension_store.rs b/frontend/src-tauri/src/extension_store.rs index 92df6e6..f6356e1 100644 --- a/frontend/src-tauri/src/extension_store.rs +++ b/frontend/src-tauri/src/extension_store.rs @@ -606,6 +606,17 @@ impl ExtensionStore { source_url: &str, release: &Release, ) -> Result { + self.stage_online_checked(operation, source_url, release, || Ok(())) + .await + } + pub async fn stage_online_checked( + &mut self, + operation: &str, + source_url: &str, + release: &Release, + checkpoint: impl Fn() -> Result<()>, + ) -> Result { + checkpoint()?; Uuid::parse_str(operation).map_err(|_| HostError::new("OPERATION_ID_INVALID"))?; let origin = source(source_url)?; let trusted = self @@ -637,19 +648,22 @@ impl ExtensionStore { let archive = client .download(&checked, release, &trusted.public_key) .await?; - self.stage(Stage { - operation_id: operation, - source: &origin, - release, - archive: &archive, - withdrawn: false, - signer: Signer { - public_key: &trusted.public_key, - key_id: &trusted.key_id, - namespace: &trusted.namespace, - revoked: false, + self.stage_inner( + Stage { + operation_id: operation, + source: &origin, + release, + archive: &archive, + withdrawn: false, + signer: Signer { + public_key: &trusted.public_key, + key_id: &trusted.key_id, + namespace: &trusted.namespace, + revoked: false, + }, }, - }) + |_| checkpoint(), + ) } /// Online installation gate, using confirmed Host trust settings only. pub async fn switch_online( @@ -1015,6 +1029,21 @@ impl ExtensionStore { crate::payloads::verify(&path, digest, size) .map_err(|_| HostError::new("EXTENSION_STORE_CORRUPT")) } + pub fn stage_receipt(&self, operation: &str) -> Result> { + Uuid::parse_str(operation).map_err(|_| HostError::new("OPERATION_ID_INVALID"))?; + let json: Option = self + .db + .query_row( + "SELECT receipt FROM stage_operations WHERE id=?1", + [operation], + |r| r.get(0), + ) + .optional()?; + json.map(|v| { + serde_json::from_str(&v).map_err(|_| HostError::new("EXTENSION_STORE_CORRUPT")) + }) + .transpose() + } pub fn archive(&self, package_key: &str) -> Result> { let (digest, size): (String, u64) = self.db.query_row( "SELECT archive_hash,size FROM versions WHERE package_key=?1", @@ -1082,6 +1111,34 @@ mod tests { archive, } } + #[test] + fn cancelled_stage_reports_durable_commit_race_truthfully() { + let (release, archive, key) = fixture(); + for boundary in ["object_stored", "receipt_recorded", "committed"] { + let temp = tempfile::tempdir().unwrap(); + let mut store = ExtensionStore::open(temp.path()).unwrap(); + let operation = Uuid::new_v4().to_string(); + assert_eq!( + store + .stage_inner(request(&operation, &release, &archive, &key), |at| { + if at == boundary { + Err(HostError::new("REQUEST_CANCELLED")) + } else { + Ok(()) + } + }) + .unwrap_err() + .code, + "REQUEST_CANCELLED" + ); + drop(store); + let store = ExtensionStore::open(temp.path()).unwrap(); + assert_eq!( + store.stage_receipt(&operation).unwrap().is_some(), + boundary == "committed" + ); + } + } #[tokio::test] async fn installation_preview_binds_target_state_and_rejects_stale_confirmation_before_network() { diff --git a/frontend/src-tauri/src/main.rs b/frontend/src-tauri/src/main.rs index 9fa3373..c5410ba 100644 --- a/frontend/src-tauri/src/main.rs +++ b/frontend/src-tauri/src/main.rs @@ -28,6 +28,7 @@ struct Host { requests: Requests, extensions: Arc>>, extension_reviews: extension_commands::Reviews, + extension_requests: Requests, sync: Arc, workspace: Arc>>, recent: Mutex>, @@ -851,6 +852,9 @@ fn main() { extension_trust_confirm, extension_install_preview, extension_stage, + extension_stage_prepare, + extension_stage_cancel, + extension_stage_status, record_get, record_write, sync_login, diff --git a/frontend/src/features/community/CommunityView.vue b/frontend/src/features/community/CommunityView.vue index 4d4fdf2..c72e62e 100644 --- a/frontend/src/features/community/CommunityView.vue +++ b/frontend/src/features/community/CommunityView.vue @@ -76,7 +76,7 @@ function install() { const selected = detail.value, selectedRegistry = source() void run(async (signal, current) => { const result = await installRelease(selectedRegistry, selected, signal) - if (current()) { notice.value = result; refreshCandidates() } + if (current() || (signal.aborted && controller?.signal === signal)) { notice.value = result; refreshCandidates() } }) } function toggleSource() { diff --git a/frontend/src/services/communityDesktop.spec.ts b/frontend/src/services/communityDesktop.spec.ts index d6d33cb..d9bbe21 100644 --- a/frontend/src/services/communityDesktop.spec.ts +++ b/frontend/src/services/communityDesktop.spec.ts @@ -8,12 +8,12 @@ import type { CommunityRelease, CommunitySource } from '@/contracts/community' afterEach(() => { vi.unstubAllGlobals(); native.invoke.mockReset() }) it('desktop stages through Host without renderer download or legacy installation', async () => { const fetch = vi.fn(); vi.stubGlobal('fetch', fetch) - native.invoke.mockResolvedValue({ state: 'staged' }) + native.invoke.mockImplementation(async command => command === 'extension_stage_prepare' ? 'request-id' : { state: 'staged' }) const source: CommunitySource = { id: 'fixture', url: 'https://catalog.example/', keys: [], enabled: true } const release = vector.release as CommunityRelease expect(await installRelease(source, release)).toContain('尚未安装或启用') expect(fetch).not.toHaveBeenCalled() - const [command, args] = native.invoke.mock.calls[0]! + const [command, args] = native.invoke.mock.calls.find(call => call[0] === 'extension_stage')! expect(command).toBe('extension_stage') expect(args.request.release.signature).toBe(release.signature) expect(args.request.release).not.toHaveProperty('withdrawn') @@ -21,3 +21,14 @@ it('desktop stages through Host without renderer download or legacy installation expect(args.request.release).not.toHaveProperty('release_id') expect(vector.release).toHaveProperty('release_id') }) + +it('cancels a prepared request when abort wins before dispatch', async () => { + const controller = new AbortController() + native.invoke.mockImplementation(async command => { + if (command === 'extension_stage_prepare') { controller.abort(); return 'request-id' } + return null + }) + await expect(installRelease({ id: 'fixture', url: 'https://catalog.example/', keys: [], enabled: true }, vector.release as CommunityRelease, controller.signal)).rejects.toThrow() + expect(native.invoke.mock.calls.some(call => call[0] === 'extension_stage')).toBe(false) + expect(native.invoke).toHaveBeenCalledWith('extension_stage_cancel', { requestId: 'request-id' }) +}) diff --git a/frontend/src/services/communityService.ts b/frontend/src/services/communityService.ts index aac19d0..d671746 100644 --- a/frontend/src/services/communityService.ts +++ b/frontend/src/services/communityService.ts @@ -96,8 +96,21 @@ export async function installRelease(source: CommunitySource, selected: Communit if (!source.enabled || selected.withdrawn) throw new Error('来源已停用或发行已撤回') const release = { ...selected } as Partial delete release.release_id; delete release.withdrawn; delete release.download_path - await invoke('extension_stage', { request: { operation_id: crypto.randomUUID(), source: source.url, release } }) - return '已校验并暂存到桌面安装库,尚未安装或启用' + const operationId = crypto.randomUUID() + const requestId = await invoke('extension_stage_prepare') + const cancel = () => { void invoke('extension_stage_cancel', { requestId }).catch(() => undefined) } + signal?.addEventListener('abort', cancel, { once: true }) + try { + if (signal?.aborted) { cancel(); signal.throwIfAborted() } + await invoke('extension_stage', { request: { request_id: requestId, operation_id: operationId, source: source.url, release } }) + return '已校验并暂存到桌面安装库,尚未安装或启用' + } catch (error) { + if (signal?.aborted) { + const receipt = await invoke<{ state: string } | null>('extension_stage_status', { operationId }).catch(() => null) + if (receipt?.state === 'staged') return '取消前已完成暂存,尚未安装或启用' + } + throw error + } finally { signal?.removeEventListener('abort', cancel); cancel() } } const catalog = await fetchCatalog(source, '', selected.type, signal) const release = catalog.items.find(item => item.release_id === selected.release_id)