feat: 补齐扩展事务与撤销生命周期
This commit is contained in:
@@ -146,6 +146,108 @@ pub async fn extension_install_preview(
|
||||
.map_err(|_| "EXTENSION_PREVIEW_FAILED".to_string())?
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct InstallConfirmation {
|
||||
request_id: String,
|
||||
operation_id: String,
|
||||
fingerprint: String,
|
||||
root_key: String,
|
||||
vault_id: String,
|
||||
configurations: std::collections::BTreeMap<String, Value>,
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn extension_install_confirm(
|
||||
window: WebviewWindow,
|
||||
host: State<'_, Host>,
|
||||
request: InstallConfirmation,
|
||||
) -> Result<Value, String> {
|
||||
main_window(&window)?;
|
||||
let mut lease = host.extension_requests.claim(&request.request_id)?;
|
||||
let checkpoint = lease.checkpoint();
|
||||
let workspace = host.workspace.clone();
|
||||
let extensions = host.extensions.clone();
|
||||
tauri::async_runtime::spawn_blocking(move || {
|
||||
let workspace = workspace.lock().map_err(|_| "HOST_BUSY")?;
|
||||
if workspace.as_ref().ok_or("VAULT_NOT_OPEN")?.vault_id != request.vault_id {
|
||||
return Err("VAULT_CHANGED".into());
|
||||
}
|
||||
let install = InstallRequest {
|
||||
root_key: request.root_key,
|
||||
vault_id: request.vault_id,
|
||||
app_version: env!("CARGO_PKG_VERSION").into(),
|
||||
platform: std::env::consts::OS.into(),
|
||||
architecture: std::env::consts::ARCH.into(),
|
||||
configurations: request.configurations,
|
||||
};
|
||||
let mut store = extensions.lock().map_err(|_| "HOST_BUSY")?;
|
||||
let receipt = tauri::async_runtime::block_on(lease.run(async {
|
||||
checkpoint()?;
|
||||
store
|
||||
.as_mut()
|
||||
.ok_or("EXTENSIONS_NOT_READY")?
|
||||
.install_confirmed(&request.operation_id, &install, &request.fingerprint)
|
||||
.await
|
||||
.map_err(|error| error.code)
|
||||
}))?;
|
||||
serde_json::to_value(receipt).map_err(|_| "EXTENSION_INSTALL_FAILED".into())
|
||||
})
|
||||
.await
|
||||
.map_err(|_| "EXTENSION_INSTALL_FAILED".to_string())?
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn extension_install_rollback(
|
||||
window: WebviewWindow,
|
||||
host: State<'_, Host>,
|
||||
operation_id: String,
|
||||
installed_operation_id: String,
|
||||
vault_id: String,
|
||||
) -> Result<Value, String> {
|
||||
main_window(&window)?;
|
||||
let workspace = host.workspace.clone();
|
||||
let extensions = host.extensions.clone();
|
||||
tauri::async_runtime::spawn_blocking(move || {
|
||||
let workspace = workspace.lock().map_err(|_| "HOST_BUSY")?;
|
||||
if workspace.as_ref().ok_or("VAULT_NOT_OPEN")?.vault_id != vault_id {
|
||||
return Err("VAULT_CHANGED".into());
|
||||
}
|
||||
let mut store = extensions.lock().map_err(|_| "HOST_BUSY")?;
|
||||
let store = store.as_mut().ok_or("EXTENSIONS_NOT_READY")?;
|
||||
let changes = store
|
||||
.rollback_changes(&installed_operation_id)
|
||||
.map_err(|error| error.code)?;
|
||||
let receipt =
|
||||
tauri::async_runtime::block_on(store.switch_online(&operation_id, &vault_id, &changes))
|
||||
.map_err(|error| error.code)?;
|
||||
serde_json::to_value(receipt).map_err(|_| "EXTENSION_ROLLBACK_FAILED".into())
|
||||
})
|
||||
.await
|
||||
.map_err(|_| "EXTENSION_ROLLBACK_FAILED".to_string())?
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn extension_uninstall(
|
||||
window: WebviewWindow,
|
||||
host: State<'_, Host>,
|
||||
operation_id: String,
|
||||
slot: String,
|
||||
expected_revision: String,
|
||||
) -> Result<Value, String> {
|
||||
main_window(&window)?;
|
||||
host.extension_authority.revoke();
|
||||
#[cfg(windows)]
|
||||
host.extension_instances
|
||||
.lock()
|
||||
.map_err(|_| "HOST_BUSY")?
|
||||
.stop_all_and_join();
|
||||
let receipt = store(&host, |s| {
|
||||
s.uninstall_active(&operation_id, &slot, &expected_revision)
|
||||
})?;
|
||||
serde_json::to_value(receipt).map_err(|_| "EXTENSION_UNINSTALL_FAILED".into())
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct StageRequest {
|
||||
|
||||
@@ -372,14 +372,24 @@ impl Registry {
|
||||
entry.endpoint.stop();
|
||||
}
|
||||
}
|
||||
}
|
||||
impl Drop for Registry {
|
||||
fn drop(&mut self) {
|
||||
|
||||
/// 请求停止并等待所有实例释放工具、进程、容器和包 ACL。
|
||||
pub fn stop_all_and_join(&mut self) {
|
||||
self.stop_all();
|
||||
for (_, entry) in std::mem::take(&mut self.entries) {
|
||||
let _ = entry.worker.join();
|
||||
}
|
||||
}
|
||||
|
||||
pub fn active_count(&mut self) -> usize {
|
||||
self.reap();
|
||||
self.entries.len()
|
||||
}
|
||||
}
|
||||
impl Drop for Registry {
|
||||
fn drop(&mut self) {
|
||||
self.stop_all_and_join();
|
||||
}
|
||||
}
|
||||
fn run(spec: LaunchSpec, control: &Control, receiver: Receiver<Command>) -> Result<()> {
|
||||
if control.stop.load(Ordering::Acquire) {
|
||||
@@ -470,6 +480,9 @@ fn run_with_access(
|
||||
.status
|
||||
.compare_exchange(0, 1, Ordering::AcqRel, Ordering::Acquire);
|
||||
while !control.stop.load(Ordering::Acquire) {
|
||||
// 撤销、锁定和工作区切换必须在空闲实例上也能生效,不能等待下一次工具调用。
|
||||
spec.authority
|
||||
.verify(&spec.permit, &spec.claims, now_ms()?)?;
|
||||
session.drain_pending()?;
|
||||
if session.take_tools_changed() {
|
||||
control
|
||||
@@ -525,6 +538,7 @@ mod tests {
|
||||
fn native_worker_routes_reviews_cancels_calls_and_reaps_generations() {
|
||||
native_worker_lifecycle(false);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "real background MCP CPU/memory/process exhaustion and restart; run explicitly"]
|
||||
fn native_resource_failures_are_reaped_and_replacements_can_start() {
|
||||
@@ -653,17 +667,23 @@ mod tests {
|
||||
.code,
|
||||
"EXTENSION_CALL_REVIEW_UNKNOWN"
|
||||
);
|
||||
endpoint.stop();
|
||||
// 空闲实例也必须在许可撤销后自行退出并清空工具注册。
|
||||
authority.revoke();
|
||||
wait_for(|| {
|
||||
registry.reap();
|
||||
registry.entries.is_empty()
|
||||
});
|
||||
assert_eq!(
|
||||
endpoint.snapshot().status,
|
||||
Status::Stopped,
|
||||
Status::Failed,
|
||||
"{:?}",
|
||||
endpoint.snapshot().error
|
||||
);
|
||||
assert_eq!(endpoint.snapshot().tool_count, 0);
|
||||
assert_eq!(
|
||||
endpoint.snapshot().error.as_deref(),
|
||||
Some("EXTENSION_PERMIT_REVOKED")
|
||||
);
|
||||
assert!(endpoint.review("echo".into(), json!({})).is_err());
|
||||
assert_eq!(
|
||||
endpoint
|
||||
|
||||
@@ -288,10 +288,10 @@ impl ExtensionStore {
|
||||
let mut db = Connection::open(database)?;
|
||||
db.execute_batch("PRAGMA journal_mode=WAL; PRAGMA synchronous=FULL;")?;
|
||||
let version: i64 = db.query_row("PRAGMA user_version", [], |r| r.get(0))?;
|
||||
if version > 6 {
|
||||
if version > 7 {
|
||||
return Err(HostError::new("EXTENSION_SCHEMA_INCOMPATIBLE"));
|
||||
}
|
||||
if (1..6).contains(&version) {
|
||||
if (1..7).contains(&version) {
|
||||
let backup = root.join(format!(
|
||||
"extensions.schema{version}.{}.sqlite3",
|
||||
Uuid::new_v4()
|
||||
@@ -308,8 +308,9 @@ impl ExtensionStore {
|
||||
CREATE TABLE IF NOT EXISTS extension_trust(source TEXT NOT NULL,namespace TEXT NOT NULL,key_id TEXT NOT NULL,setting TEXT NOT NULL,revision TEXT NOT NULL,PRIMARY KEY(source,namespace,key_id));
|
||||
CREATE TABLE IF NOT EXISTS extension_blocks(identity TEXT PRIMARY KEY,reason TEXT NOT NULL);
|
||||
CREATE TABLE IF NOT EXISTS extension_confirmations(operation_id TEXT PRIMARY KEY,request_hash TEXT NOT NULL,review_hash TEXT NOT NULL,changes TEXT NOT NULL);
|
||||
CREATE TABLE IF NOT EXISTS extension_uninstalls(id TEXT PRIMARY KEY,slot TEXT NOT NULL,expected_revision TEXT NOT NULL,state TEXT NOT NULL);
|
||||
CREATE TABLE IF NOT EXISTS legacy_installations(kind TEXT NOT NULL,package_id TEXT NOT NULL,source_path TEXT NOT NULL,expected_digest TEXT NOT NULL,observed_digest TEXT,ownership TEXT NOT NULL,state TEXT NOT NULL,enabled INTEGER NOT NULL CHECK(enabled=0),permissions TEXT NOT NULL CHECK(permissions='[]'),source_db_digest TEXT NOT NULL,PRIMARY KEY(kind,package_id));
|
||||
PRAGMA user_version=6; COMMIT;")?;
|
||||
PRAGMA user_version=7; COMMIT;")?;
|
||||
crate::extension_transaction::recover(&mut db)?;
|
||||
Ok(Self {
|
||||
root,
|
||||
@@ -849,6 +850,22 @@ impl ExtensionStore {
|
||||
) -> Result<Option<crate::extension_transaction::Active>> {
|
||||
crate::extension_transaction::active(&self.db, slot)
|
||||
}
|
||||
|
||||
pub fn rollback_changes(
|
||||
&self,
|
||||
operation: &str,
|
||||
) -> Result<Vec<crate::extension_transaction::Change>> {
|
||||
crate::extension_transaction::rollback_changes(&self.db, operation)
|
||||
}
|
||||
|
||||
pub fn uninstall_active(
|
||||
&mut self,
|
||||
operation: &str,
|
||||
slot: &str,
|
||||
expected_revision: &str,
|
||||
) -> Result<crate::extension_transaction::Receipt> {
|
||||
crate::extension_transaction::uninstall(&mut self.db, operation, slot, expected_revision)
|
||||
}
|
||||
/// 准备经过验证的暂存包。调用者提供当前的签名者/撤销策略;持久准备不会在重放时绕过该策略。
|
||||
pub fn prepare(
|
||||
&mut self,
|
||||
|
||||
@@ -31,7 +31,8 @@ pub struct Receipt {
|
||||
}
|
||||
pub fn schema(db: &Connection) -> Result<()> {
|
||||
db.execute_batch("CREATE TABLE IF NOT EXISTS extension_active(slot TEXT PRIMARY KEY,target TEXT NOT NULL,revision TEXT NOT NULL,pending_operation TEXT);
|
||||
CREATE TABLE IF NOT EXISTS extension_transactions(id TEXT PRIMARY KEY,fingerprint TEXT NOT NULL,before_state TEXT NOT NULL,after_state TEXT NOT NULL,state TEXT NOT NULL);")?;
|
||||
CREATE TABLE IF NOT EXISTS extension_transactions(id TEXT PRIMARY KEY,fingerprint TEXT NOT NULL,before_state TEXT NOT NULL,after_state TEXT NOT NULL,state TEXT NOT NULL);
|
||||
CREATE TABLE IF NOT EXISTS extension_uninstalls(id TEXT PRIMARY KEY,slot TEXT NOT NULL,expected_revision TEXT NOT NULL,state TEXT NOT NULL);")?;
|
||||
Ok(())
|
||||
}
|
||||
pub fn active(db: &Connection, slot: &str) -> Result<Option<Active>> {
|
||||
@@ -209,6 +210,95 @@ pub fn recover(db: &mut Connection) -> Result<usize> {
|
||||
Ok(ids.len())
|
||||
}
|
||||
|
||||
/// 为已完成的升级生成回滚变更;回滚本身仍以新的 operation_id 执行和记录。
|
||||
pub fn rollback_changes(db: &Connection, operation: &str) -> Result<Vec<Change>> {
|
||||
let (before, after, state): (String, String, String) = db
|
||||
.query_row(
|
||||
"SELECT before_state,after_state,state FROM extension_transactions WHERE id=?1",
|
||||
[operation],
|
||||
|row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)),
|
||||
)
|
||||
.map_err(|_| HostError::new("EXTENSION_ROLLBACK_UNKNOWN"))?;
|
||||
if state != "complete" {
|
||||
return Err(HostError::new("EXTENSION_ROLLBACK_INVALID"));
|
||||
}
|
||||
let old: Vec<Option<Active>> =
|
||||
serde_json::from_str(&before).map_err(|_| HostError::new("EXTENSION_STORE_CORRUPT"))?;
|
||||
let installed: Vec<Change> =
|
||||
serde_json::from_str(&after).map_err(|_| HostError::new("EXTENSION_STORE_CORRUPT"))?;
|
||||
if old.len() != installed.len() || old.iter().any(Option::is_none) {
|
||||
return Err(HostError::new("EXTENSION_ROLLBACK_INVALID"));
|
||||
}
|
||||
old.into_iter()
|
||||
.zip(installed)
|
||||
.map(|(previous, installed)| {
|
||||
let previous = previous.unwrap();
|
||||
let current = active(db, &installed.target.slot)?
|
||||
.ok_or_else(|| HostError::new("EXTENSION_INSTALL_CONFLICT"))?;
|
||||
if current.pending_operation.is_some() || current.target != installed.target {
|
||||
return Err(HostError::new("EXTENSION_INSTALL_CONFLICT"));
|
||||
}
|
||||
Ok(Change {
|
||||
target: previous.target,
|
||||
expected_revision: Some(current.revision),
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// 实例停止后原子移除活动指针。保留已验证对象,以便审计或显式回滚;不会触碰外部目录。
|
||||
pub fn uninstall(
|
||||
db: &mut Connection,
|
||||
operation: &str,
|
||||
slot: &str,
|
||||
expected_revision: &str,
|
||||
) -> Result<Receipt> {
|
||||
if uuid::Uuid::parse_str(operation).is_err()
|
||||
|| slot.len() != 64
|
||||
|| expected_revision.len() != 64
|
||||
|| [slot, expected_revision].iter().any(|value| {
|
||||
!value
|
||||
.bytes()
|
||||
.all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b))
|
||||
})
|
||||
{
|
||||
return Err(HostError::new("EXTENSION_TRANSACTION_INVALID"));
|
||||
}
|
||||
db.execute_batch("CREATE TABLE IF NOT EXISTS extension_uninstalls(id TEXT PRIMARY KEY,slot TEXT NOT NULL,expected_revision TEXT NOT NULL,state TEXT NOT NULL);")?;
|
||||
let transaction = db.transaction()?;
|
||||
let prior: Option<(String, String, String)> = transaction
|
||||
.query_row(
|
||||
"SELECT slot,expected_revision,state FROM extension_uninstalls WHERE id=?1",
|
||||
[operation],
|
||||
|row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)),
|
||||
)
|
||||
.optional()?;
|
||||
if let Some((old_slot, old_revision, state)) = prior {
|
||||
if old_slot != slot || old_revision != expected_revision {
|
||||
return Err(HostError::new("OPERATION_REUSED"));
|
||||
}
|
||||
return Ok(Receipt {
|
||||
operation_id: operation.into(),
|
||||
state,
|
||||
});
|
||||
}
|
||||
let current =
|
||||
active(&transaction, slot)?.ok_or_else(|| HostError::new("EXTENSION_INSTALL_CONFLICT"))?;
|
||||
if current.pending_operation.is_some() || current.revision != expected_revision {
|
||||
return Err(HostError::new("EXTENSION_INSTALL_CONFLICT"));
|
||||
}
|
||||
transaction.execute(
|
||||
"INSERT INTO extension_uninstalls VALUES (?1,?2,?3,'complete')",
|
||||
params![operation, slot, expected_revision],
|
||||
)?;
|
||||
transaction.execute("DELETE FROM extension_active WHERE slot=?1", [slot])?;
|
||||
transaction.commit()?;
|
||||
Ok(Receipt {
|
||||
operation_id: operation.into(),
|
||||
state: "complete".into(),
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -272,6 +362,50 @@ mod tests {
|
||||
assert!(item.pending_operation.is_none());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn completed_upgrade_can_rollback_then_uninstall_idempotently() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let mut db = open(&temp.path().join("state.sqlite3"));
|
||||
let initial = change('a', 1, None);
|
||||
let install = uuid::Uuid::new_v4().to_string();
|
||||
switch(&mut db, &install, std::slice::from_ref(&initial)).unwrap();
|
||||
finish(&mut db, &install, true).unwrap();
|
||||
|
||||
let installed = active(&db, &initial.target.slot).unwrap().unwrap();
|
||||
let upgrade = change('a', 2, Some(installed.revision));
|
||||
let upgrade_id = uuid::Uuid::new_v4().to_string();
|
||||
switch(&mut db, &upgrade_id, &[upgrade]).unwrap();
|
||||
finish(&mut db, &upgrade_id, true).unwrap();
|
||||
|
||||
let rollback = rollback_changes(&db, &upgrade_id).unwrap();
|
||||
let rollback_id = uuid::Uuid::new_v4().to_string();
|
||||
switch(&mut db, &rollback_id, &rollback).unwrap();
|
||||
finish(&mut db, &rollback_id, true).unwrap();
|
||||
let restored = active(&db, &initial.target.slot).unwrap().unwrap();
|
||||
assert_eq!(restored.target, initial.target);
|
||||
|
||||
let uninstall_id = uuid::Uuid::new_v4().to_string();
|
||||
let receipt = uninstall(
|
||||
&mut db,
|
||||
&uninstall_id,
|
||||
&restored.target.slot,
|
||||
&restored.revision,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(receipt.state, "complete");
|
||||
assert_eq!(
|
||||
uninstall(
|
||||
&mut db,
|
||||
&uninstall_id,
|
||||
&restored.target.slot,
|
||||
&restored.revision,
|
||||
)
|
||||
.unwrap(),
|
||||
receipt
|
||||
);
|
||||
assert!(active(&db, &restored.target.slot).unwrap().is_none());
|
||||
}
|
||||
#[test]
|
||||
fn group_switch_crashes_recover_matching_packages_and_configuration() {
|
||||
for boundary in ["journal_recorded", "pointer_recorded", "switch_committed"] {
|
||||
|
||||
@@ -31,6 +31,8 @@ struct Host {
|
||||
extension_reviews: extension_commands::Reviews,
|
||||
extension_requests: Requests,
|
||||
extension_authority: notesagent_host::extension_permit::Authority,
|
||||
#[cfg(windows)]
|
||||
extension_instances: Mutex<notesagent_host::extension_instance::Registry>,
|
||||
credential_signal: std::sync::OnceLock<Arc<std::sync::atomic::AtomicU64>>,
|
||||
sync: Arc<sync_commands::Runtime>,
|
||||
workspace: Arc<Mutex<Option<Workspace>>>,
|
||||
@@ -45,12 +47,20 @@ struct Host {
|
||||
impl Host {
|
||||
fn replace_workspace(&self, active: &mut Option<Workspace>, next: Option<Workspace>) {
|
||||
self.extension_authority.revoke();
|
||||
#[cfg(windows)]
|
||||
if let Ok(mut instances) = self.extension_instances.lock() {
|
||||
instances.stop_all_and_join();
|
||||
}
|
||||
self.sync.cancel();
|
||||
*active = next;
|
||||
}
|
||||
fn lock_credentials(&self) -> Result<(), String> {
|
||||
// 这些不等待进行中解锁/KDF 或凭证操作。
|
||||
self.extension_authority.revoke();
|
||||
#[cfg(windows)]
|
||||
if let Ok(mut instances) = self.extension_instances.lock() {
|
||||
instances.stop_all_and_join();
|
||||
}
|
||||
if let Some(signal) = self.credential_signal.get() {
|
||||
signal.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
|
||||
}
|
||||
@@ -1064,6 +1074,9 @@ fn main() {
|
||||
extension_trust_confirm,
|
||||
extension_trust_confirm_group,
|
||||
extension_install_preview,
|
||||
extension_install_confirm,
|
||||
extension_install_rollback,
|
||||
extension_uninstall,
|
||||
extension_stage,
|
||||
extension_stage_prepare,
|
||||
extension_stage_cancel,
|
||||
|
||||
Reference in New Issue
Block a user