feat: 完成 OpenNexus 第三阶段核心功能与生产化基础 #45

Merged
Kronecker merged 151 commits from feat/phase3-completion into main 2026-09-10 01:16:31 +08:00
12 changed files with 207 additions and 25 deletions
Showing only changes of commit 9106ac5123 - Show all commits
@@ -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 恢复、安装执行编排与沙箱仍未完成;整体目标继续进行。
+3
View File
@@ -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",
+4 -1
View File
@@ -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"
]
}
@@ -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"]
@@ -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"]
@@ -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"]
+53 -4
View File
@@ -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<Value, String> {
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(
let receipt = tauri::async_runtime::block_on(lease.run(async {
checkpoint()?;
store
.stage_online_checked(
&request.operation_id,
&request.source,
&request.release,
))
.map_err(|e| e.code)?;
|| {
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<String, String> {
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<Value, String> {
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::*;
+59 -2
View File
@@ -606,6 +606,17 @@ impl ExtensionStore {
source_url: &str,
release: &Release,
) -> Result<Receipt> {
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<Receipt> {
checkpoint()?;
Uuid::parse_str(operation).map_err(|_| HostError::new("OPERATION_ID_INVALID"))?;
let origin = source(source_url)?;
let trusted = self
@@ -637,7 +648,8 @@ impl ExtensionStore {
let archive = client
.download(&checked, release, &trusted.public_key)
.await?;
self.stage(Stage {
self.stage_inner(
Stage {
operation_id: operation,
source: &origin,
release,
@@ -649,7 +661,9 @@ impl ExtensionStore {
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<Option<Receipt>> {
Uuid::parse_str(operation).map_err(|_| HostError::new("OPERATION_ID_INVALID"))?;
let json: Option<String> = 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<Vec<u8>> {
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()
{
+4
View File
@@ -28,6 +28,7 @@ struct Host {
requests: Requests,
extensions: Arc<Mutex<Option<notesagent_host::extension_store::ExtensionStore>>>,
extension_reviews: extension_commands::Reviews,
extension_requests: Requests,
sync: Arc<sync_commands::Runtime>,
workspace: Arc<Mutex<Option<Workspace>>>,
recent: Mutex<Option<RecentVaultStore>>,
@@ -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,
@@ -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() {
+13 -2
View File
@@ -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' })
})
+14 -1
View File
@@ -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<CommunityRelease>
delete release.release_id; delete release.withdrawn; delete release.download_path
await invoke('extension_stage', { request: { operation_id: crypto.randomUUID(), source: source.url, release } })
const operationId = crypto.randomUUID()
const requestId = await invoke<string>('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)