fix(sync): 端到端强制人设与布局显式选择加入

This commit is contained in:
2026-09-09 07:27:50 +08:00
parent e788609de7
commit 3a344d35bd
15 changed files with 341 additions and 21 deletions
+1
View File
@@ -891,6 +891,7 @@ fn main() {
sync_unbind,
sync_pause,
sync_status,
sync_set_scope,
sync_resolve,
sync_logout,
sync_run,
+6 -2
View File
@@ -374,7 +374,9 @@ impl SyncClient {
if let Some(initial) = workspace.access(|ws| ws.sync_initial_pending(&binding.id))? {
let count = initial.len().min(100);
for revision in initial.iter().take(count) {
if revision.operation == "put" {
if revision.operation == "put"
&& workspace.access(|ws| ws.sync_path_enabled(&revision.path))?
{
self.download(workspace, binding, revision).await?;
}
workspace.access(|ws| {
@@ -423,7 +425,9 @@ impl SyncClient {
if revision.sequence != cursor + index as i64 + 1 || revision.sequence > end {
return Err(SyncError::new("SYNC_RESPONSE_INVALID"));
}
if revision.operation == "put" {
if revision.operation == "put"
&& workspace.access(|ws| ws.sync_path_enabled(&revision.path))?
{
self.download(workspace, binding, &revision).await?;
}
workspace.access(|ws| {
+18 -2
View File
@@ -249,6 +249,21 @@ pub fn sync_pause(host: State<'_, Host>, binding_id: String, paused: bool) -> Re
Ok(())
}
#[tauri::command]
pub fn sync_set_scope(
host: State<'_, Host>,
vault_id: String,
scope: notesagent_host::sync_scope::OptionalScope,
) -> Result<(), String> {
with_workspace(&host, |ws| {
if ws.vault_id != vault_id {
return Err(notesagent_host::workspace::HostError::new(
"VAULT_PERMISSION_CHANGED",
));
}
ws.sync_set_optional_scope(scope)
})
}
#[tauri::command]
pub fn sync_status(host: State<'_, Host>) -> Result<Value, String> {
let snapshot = with_workspace(&host, |ws| {
let binding = ws.sync_binding()?;
@@ -263,6 +278,7 @@ pub fn sync_status(host: State<'_, Host>) -> Result<Value, String> {
.transpose()?
.unwrap_or_default();
Ok((
ws.sync_optional_scope()?,
ws.vault_id.clone(),
binding.clone(),
paused,
@@ -280,7 +296,7 @@ pub fn sync_status(host: State<'_, Host>) -> Result<Value, String> {
.unwrap_or_default(),
))
})?;
let (vault_id, binding, paused, conflicts, pending, attempts, retry) = snapshot;
let (optional_scope, vault_id, binding, paused, conflicts, pending, attempts, retry) = snapshot;
let credential_state = if let Some(b) = &binding {
sync_auth::available(&host.credentials, &b.endpoint, &b.account)
.map(|exists| {
@@ -298,7 +314,7 @@ pub fn sync_status(host: State<'_, Host>) -> Result<Value, String> {
let statuses = host.sync.status.lock().map_err(|_| "HOST_BUSY")?;
let status = binding.as_ref().and_then(|b| statuses.get(&b.id));
Ok(
json!({"vault_id":vault_id,"binding":binding,"paused":paused,"pending":pending,"conflicts":conflicts,"credential_state":credential_state,"running":status.is_some_and(|s|s.running),"error":retry.error,"retry_in":retry.retry_at.map(|_| retry.remaining(now())),"failures":retry.failures,"halted":retry.halted,"attempts":attempts}),
json!({"optional_scope":optional_scope,"vault_id":vault_id,"binding":binding,"paused":paused,"pending":pending,"conflicts":conflicts,"credential_state":credential_state,"running":status.is_some_and(|s|s.running),"error":retry.error,"retry_in":retry.retry_at.map(|_| retry.remaining(now())),"failures":retry.failures,"halted":retry.halted,"attempts":attempts}),
)
}
#[tauri::command]
+5 -2
View File
@@ -75,7 +75,7 @@ impl Workspace {
let resolved = ws.resolve(&path)?;
if item.file_type()?.is_dir() {
walk(ws, &resolved, paths)?;
} else if allowed(&path) && item.file_type()?.is_file() {
} else if ws.sync_path_enabled(&path)? && item.file_type()?.is_file() {
paths.push(path);
}
}
@@ -137,7 +137,10 @@ impl Workspace {
rows
};
for (file_id, path) in previous {
if seen.contains(&file_id) || !allowed(&path) || self.resolve(&path)?.exists() {
if seen.contains(&file_id)
|| !self.sync_path_enabled(&path)?
|| self.resolve(&path)?.exists()
{
continue;
}
let operation = Uuid::new_v4().to_string();
+8 -2
View File
@@ -145,8 +145,10 @@ impl Workspace {
{
return Err(HostError::new("SYNC_CURSOR_INVALID"));
}
if let Some(digest) = &revision.hash {
crate::payloads::verify(&self.sync_spool(digest)?, digest, revision.size as u64)?;
if self.sync_path_enabled(&revision.path)? {
if let Some(digest) = &revision.hash {
crate::payloads::verify(&self.sync_spool(digest)?, digest, revision.size as u64)?;
}
}
let encoded =
serde_json::to_string(revision).map_err(|_| HostError::new("SYNC_RESPONSE_INVALID"))?;
@@ -254,6 +256,10 @@ impl Workspace {
};
let revision: RemoteRevision =
serde_json::from_str(&encoded).map_err(|_| HostError::new("SYNC_RESPONSE_INVALID"))?;
if !self.sync_path_enabled(&revision.path)? {
self.sync_finish(binding, &revision, "excluded")?;
return Ok(true);
}
let own: Option<Job> = self.db.query_row("SELECT binding,operation_id,file_id,path,hash,size,operation,state,base_revision,upload_id FROM sync_jobs WHERE binding=?1 AND operation_id=?2", params![binding,revision.operation_id], |r| {
Ok(Job { binding:r.get(0)?,operation_id:r.get(1)?,file_id:r.get(2)?,path:r.get(3)?,hash:r.get(4)?,size:r.get(5)?,operation:r.get(6)?,state:r.get(7)?,base_revision:r.get(8)?,upload_id:r.get(9)? })
}).optional()?;
+11 -3
View File
@@ -72,7 +72,7 @@ impl Workspace {
if item.sequence > snapshot.boundary {
return Err(HostError::new("SYNC_RESPONSE_INVALID"));
}
if item.operation == "put" {
if item.operation == "put" && self.sync_path_enabled(&item.path)? {
paths.insert(item.path.clone(), "download");
}
}
@@ -93,8 +93,16 @@ impl Workspace {
);
}
let fingerprint = hash(
&serde_json::to_vec(&(&self.vault_id, endpoint, remote, account, &local, snapshot))
.map_err(|_| HostError::new("SYNC_RESPONSE_INVALID"))?,
&serde_json::to_vec(&(
&self.vault_id,
endpoint,
remote,
account,
&local,
snapshot,
self.sync_optional_scope()?,
))
.map_err(|_| HostError::new("SYNC_RESPONSE_INVALID"))?,
);
Ok(Preview {
fingerprint,
+21
View File
@@ -18,6 +18,19 @@ impl Workspace {
expected: &str,
) -> Result<()> {
self.check_binding(binding)?;
let scope_path: Option<String> = self
.db
.query_row(
"SELECT local_path FROM sync_conflicts WHERE binding=?1 AND sequence=?2",
params![binding, sequence],
|row| row.get(0),
)
.optional()?;
if let Some(path) = scope_path {
if !self.sync_path_enabled(&path)? {
return Err(HostError::new("SYNC_SCOPE_DISABLED"));
}
}
if !matches!(choice, "local" | "remote" | "copy") {
return Err(HostError::new("SYNC_RESOLUTION_INVALID"));
}
@@ -107,6 +120,9 @@ impl Workspace {
)?;
let revision: RemoteRevision =
serde_json::from_str(&remote).map_err(|_| HostError::new("SYNC_RESPONSE_INVALID"))?;
if !self.sync_path_enabled(&revision.path)? {
return Ok(());
}
let head: i64 = self.db.query_row(
"SELECT revision FROM sync_heads WHERE binding=?1 AND file_id=?2",
params![binding, revision.file_id],
@@ -279,6 +295,11 @@ mod tests {
fn invalid_record_copy_destination_does_not_freeze_conflict_decision() {
let root = tempfile::tempdir().unwrap();
let mut ws = Workspace::open(root.path()).unwrap();
ws.sync_set_optional_scope(crate::sync_scope::OptionalScope {
persona: true,
layout: true,
})
.unwrap();
let binding = ws
.sync_bind_download("https://sync.example", "remote-vault", "account")
.unwrap();
+102
View File
@@ -21,6 +21,10 @@ impl OptionalScope {
}
impl Workspace {
pub fn sync_path_enabled(&self, path: &str) -> Result<bool> {
Ok(crate::sync_discovery::allowed(path) && self.sync_optional_scope()?.includes(path))
}
pub fn sync_optional_scope(&self) -> Result<OptionalScope> {
Ok(self
.db
@@ -54,6 +58,104 @@ impl Workspace {
mod tests {
use super::*;
#[test]
fn old_queued_optional_jobs_cannot_bypass_missing_consent() {
let root = tempfile::tempdir().unwrap();
let mut ws = Workspace::open(root.path()).unwrap();
ws.sync_set_optional_scope(OptionalScope {
persona: true,
layout: false,
})
.unwrap();
let data = serde_json::to_vec(&serde_json::json!({"schema":1,"kind":"persona","id":"default","data":{"version":0,"name":"local","system_prompt":"private","dialogue_pairs":[]}})).unwrap();
let path = "opennexus-records/v1/persona/default.json";
ws.write(path, "", &data, "local").unwrap();
let binding = ws
.sync_bind_empty("https://sync.example", "remote", "account")
.unwrap();
let job = ws.sync_next(&binding.id).unwrap().unwrap();
// Models a pre-scope database's pending job after schema migration.
ws.db
.execute("DELETE FROM sync_optional_scope", [])
.unwrap();
assert_eq!(
ws.sync_commit_payload(&job).unwrap_err().code,
"SYNC_SCOPE_DISABLED"
);
assert!(ws.sync_next(&binding.id).unwrap().is_none());
ws.write("note.md", "", b"still synchronized", "local")
.unwrap();
ws.sync_capture(&binding.id).unwrap();
assert_eq!(ws.sync_next(&binding.id).unwrap().unwrap().path, "note.md");
assert_eq!(std::fs::read(root.path().join(path)).unwrap(), data);
}
#[test]
fn excluded_records_neither_upload_nor_require_object_bytes_to_advance_cursor() {
use crate::sync_inbox::RemoteRevision;
let root = tempfile::tempdir().unwrap();
let mut ws = Workspace::open(root.path()).unwrap();
let path = "opennexus-records/v1/persona/default.json";
let local = serde_json::to_vec(&serde_json::json!({"schema":1,"kind":"persona","id":"default","data":{"version":0,"name":"Local only","system_prompt":"private","dialogue_pairs":[]}})).unwrap();
ws.write(path, "", &local, "local").unwrap();
let binding = ws
.sync_bind_download("https://sync.example", "remote", "account")
.unwrap();
ws.sync_capture(&binding.id).unwrap();
assert!(ws.sync_next(&binding.id).unwrap().is_none());
let revision = RemoteRevision {
vault_id: "remote".into(),
sequence: 1,
file_id: uuid::Uuid::new_v4().to_string(),
base_revision: 0,
path: path.into(),
operation: "put".into(),
hash: Some("a".repeat(64)),
size: 123,
operation_id: uuid::Uuid::new_v4().to_string(),
};
ws.sync_set_boundary(&binding.id, 1).unwrap();
ws.sync_stage(&binding.id, &revision).unwrap();
assert!(ws.sync_apply_pending(&binding.id).unwrap());
assert_eq!(ws.sync_binding().unwrap().unwrap().cursor, 1);
assert_eq!(std::fs::read(root.path().join(path)).unwrap(), local);
assert!(!ws
.sync_spool(revision.hash.as_ref().unwrap())
.unwrap()
.exists());
assert!(ws.sync_conflicts(&binding.id).unwrap().is_empty());
ws.sync_unbind(&binding.id).unwrap();
let snapshot = crate::sync_initial::Snapshot {
boundary: 1,
items: vec![revision],
};
let before = ws
.sync_preview("https://sync.example", "remote", "account", &snapshot)
.unwrap();
assert!(before.items.is_empty());
ws.sync_set_optional_scope(OptionalScope {
persona: true,
layout: false,
})
.unwrap();
assert_eq!(
ws.sync_bind_initial(
"https://sync.example",
"remote",
"account",
&snapshot,
&before.fingerprint
)
.err()
.unwrap()
.code,
"SYNC_PREVIEW_CHANGED"
);
let after = ws
.sync_preview("https://sync.example", "remote", "account", &snapshot)
.unwrap();
assert_eq!(after.items.len(), 1);
assert_eq!(after.items[0].action, "conflict");
}
#[test]
fn schema_ten_upgrade_preserves_vault_and_does_not_infer_consent() {
let root = tempfile::tempdir().unwrap();
+23 -8
View File
@@ -163,7 +163,7 @@ impl Workspace {
let Some((operation_id, file_id, path, digest, operation, content)) = pending else {
break;
};
if !crate::sync_discovery::allowed(&path) {
if !self.sync_path_enabled(&path)? {
self.db.execute(
"UPDATE outbox SET state='excluded' WHERE operation_id=?1",
[&operation_id],
@@ -201,19 +201,34 @@ impl Workspace {
}
pub fn sync_next(&self, binding: &str) -> Result<Option<Job>> {
self.check_binding(binding)?;
let job=self.db.query_row("SELECT binding,operation_id,file_id,path,hash,size,operation,state,base_revision,upload_id FROM sync_jobs WHERE binding=?1 AND state NOT IN ('acked','archived','conflict') AND NOT EXISTS (SELECT 1 FROM sync_conflicts c WHERE c.binding=sync_jobs.binding AND (c.file_id=sync_jobs.file_id OR c.local_path=sync_jobs.path) AND c.state='open') ORDER BY rowid LIMIT 1", [binding], |r| {
loop {
let job=self.db.query_row("SELECT binding,operation_id,file_id,path,hash,size,operation,state,base_revision,upload_id FROM sync_jobs WHERE binding=?1 AND state NOT IN ('acked','archived','conflict') AND NOT EXISTS (SELECT 1 FROM sync_conflicts c WHERE c.binding=sync_jobs.binding AND (c.file_id=sync_jobs.file_id OR c.local_path=sync_jobs.path) AND c.state='open') ORDER BY rowid LIMIT 1", [binding], |r| {
Ok(Job { binding:r.get(0)?,operation_id:r.get(1)?,file_id:r.get(2)?,path:r.get(3)?,hash:r.get(4)?,size:r.get(5)?,operation:r.get(6)?,state:r.get(7)?,base_revision:r.get(8)?,upload_id:r.get(9)? })
}).optional()?;
if job
.as_ref()
.is_some_and(|job| !crate::sync_discovery::allowed(&job.path))
{
return Err(HostError::new("SYNC_CLASS_UNSUPPORTED"));
if job
.as_ref()
.is_some_and(|job| !crate::sync_discovery::allowed(&job.path))
{
return Err(HostError::new("SYNC_CLASS_UNSUPPORTED"));
}
if let Some(ref excluded) = job {
if !self.sync_path_enabled(&excluded.path)? {
self.db.execute("UPDATE sync_jobs SET state='archived' WHERE binding=?1 AND operation_id=?2",params![binding,excluded.operation_id])?;
self.db.execute(
"UPDATE outbox SET state='excluded' WHERE operation_id=?1",
[&excluded.operation_id],
)?;
continue;
}
}
return Ok(job);
}
Ok(job)
}
pub(crate) fn check_job(&self, job: &Job) -> Result<()> {
self.check_binding(&job.binding)?;
if !self.sync_path_enabled(&job.path)? {
return Err(HostError::new("SYNC_SCOPE_DISABLED"));
}
let state: String = self.db.query_row(
"SELECT state FROM sync_jobs WHERE binding=?1 AND operation_id=?2",
params![job.binding, job.operation_id],
+96
View File
@@ -75,6 +75,14 @@ async fn actual_service_accepts_ordered_push_and_repeat_commit_without_duplicate
client.verify_empty(remote).await.unwrap();
let local = tempfile::tempdir().unwrap();
let workspace = Arc::new(Mutex::new(Workspace::open(local.path()).unwrap()));
workspace
.lock()
.unwrap()
.sync_set_optional_scope(notesagent_host::sync_scope::OptionalScope {
persona: true,
layout: true,
})
.unwrap();
let binding = {
let mut ws = workspace.lock().unwrap();
let mut digest = String::new();
@@ -152,6 +160,14 @@ async fn actual_service_accepts_ordered_push_and_repeat_commit_without_duplicate
.unwrap();
let root_b = tempfile::tempdir().unwrap();
let workspace_b = Arc::new(Mutex::new(Workspace::open(root_b.path()).unwrap()));
workspace_b
.lock()
.unwrap()
.sync_set_optional_scope(notesagent_host::sync_scope::OptionalScope {
persona: true,
layout: true,
})
.unwrap();
let binding_b = workspace_b
.lock()
.unwrap()
@@ -660,6 +676,86 @@ async fn actual_service_accepts_ordered_push_and_repeat_commit_without_duplicate
);
}
// A default-off device consumes history metadata without downloading either
// optional record. Rebinding after opt-in must fetch the already-seen heads.
let excluded_root = tempfile::tempdir().unwrap();
let excluded_ws = Arc::new(Mutex::new(Workspace::open(excluded_root.path()).unwrap()));
let excluded_binding = excluded_ws
.lock()
.unwrap()
.sync_bind_download(&endpoint, remote, "rust-fixture")
.unwrap();
while client_b
.pull_page(&excluded_ws, &excluded_binding)
.await
.unwrap()
> 0
{}
for (kind, id) in [("persona", "default"), ("layout", "sidebars")] {
let original = workspace
.lock()
.unwrap()
.record_get_kind(kind, id)
.unwrap()
.unwrap();
let mut excluded = excluded_ws.lock().unwrap();
assert!(excluded.record_get_kind(kind, id).unwrap().is_none());
assert!(!excluded
.sync_spool(original["hash"].as_str().unwrap())
.unwrap()
.exists());
}
assert!(!client_b
.push_one(&excluded_ws, &excluded_binding)
.await
.unwrap());
{
let mut excluded = excluded_ws.lock().unwrap();
excluded.sync_unbind(&excluded_binding.id).unwrap();
excluded
.sync_set_optional_scope(notesagent_host::sync_scope::OptionalScope {
persona: true,
layout: true,
})
.unwrap();
}
let snapshot = client_b.snapshot(remote).await.unwrap();
let opted_binding = {
let mut excluded = excluded_ws.lock().unwrap();
let preview = excluded
.sync_preview(&endpoint, remote, "rust-fixture", &snapshot)
.unwrap();
excluded
.sync_bind_initial(
&endpoint,
remote,
"rust-fixture",
&snapshot,
&preview.fingerprint,
)
.unwrap()
};
while client_b
.pull_page(&excluded_ws, &opted_binding)
.await
.unwrap()
> 0
{}
for (kind, id) in [("persona", "default"), ("layout", "sidebars")] {
assert_eq!(
excluded_ws
.lock()
.unwrap()
.record_get_kind(kind, id)
.unwrap(),
workspace.lock().unwrap().record_get_kind(kind, id).unwrap()
);
}
assert!(!client_b
.push_one(&excluded_ws, &opted_binding)
.await
.unwrap());
// Kill the actual client process after each durable 10 MiB server offset,
// before its response reaches the client. The next process must query offset.
use sha2::{Digest, Sha256};