fix(sync): 完成 S-03 冲突收敛
This commit is contained in:
@@ -24,4 +24,4 @@ python scripts/phase3-production-acceptance.py `
|
||||
|
||||
报告目录包含 `summary.json`、`case-manifest.json`、`junit.xml`、`cases/<ID>.json` 和脱敏的 `logs/<ID>.log`。摘要记录 commit、各锁文件 SHA-256、配置摘要与已提供安装产物摘要。日志将仓库、数据根、报告根、用户主目录和配置声明的秘密值替换为占位符,并限制为 10 MiB。报告目录必须为空,避免单例复跑覆盖原始证据。
|
||||
|
||||
当前 runner 与失败闭合行为已实现,A-02/A-03 Sidecar、B-01/B-02 凭据、D-01 扩展包以及 S-01/S-02 Sync 客户端 driver 已登记;其余 23 个生产验收 ID 尚未登记,运行时会生成 `NOT_IMPLEMENTED` 证据并退出 1。这用于阻止误报,不是这些用例的验收通过。
|
||||
当前 runner 与失败闭合行为已实现,A-02/A-03 Sidecar、B-01/B-02 凭据、D-01 扩展包以及 S-01/S-02/S-03 Sync 客户端 driver 已登记;其余 22 个生产验收 ID 尚未登记,运行时会生成 `NOT_IMPLEMENTED` 证据并退出 1。这用于阻止误报,不是这些用例的验收通过。
|
||||
|
||||
@@ -293,7 +293,8 @@ impl Workspace {
|
||||
return Ok(true);
|
||||
}
|
||||
let local_path = self.path_for_id(&revision.file_id).ok();
|
||||
let path = local_path.as_deref().unwrap_or(&revision.path);
|
||||
let conflict_path = self.sync_conflict_local_path(&revision)?;
|
||||
let path = conflict_path.as_str();
|
||||
let local = self.resolve(path)?;
|
||||
let current = if local.is_file() {
|
||||
crate::payloads::hash_file(&local)?
|
||||
@@ -309,10 +310,14 @@ impl Workspace {
|
||||
|r| r.get(0),
|
||||
)
|
||||
.optional()?;
|
||||
let target_collision = local_path
|
||||
.as_deref()
|
||||
.is_some_and(|previous| previous != revision.path)
|
||||
&& self.resolve(&revision.path)?.exists();
|
||||
let conflict = queued
|
||||
|| (local_path.is_none() && local.exists())
|
||||
|| (local_path.is_some() && head.as_deref() != Some(current.as_str()))
|
||||
|| (path != revision.path && self.resolve(&revision.path)?.exists());
|
||||
|| target_collision;
|
||||
if conflict {
|
||||
self.sync_preserve_conflict(binding, &revision, path)?;
|
||||
return Ok(true);
|
||||
@@ -366,11 +371,10 @@ impl Workspace {
|
||||
})?
|
||||
.collect::<std::result::Result<Vec<_>, _>>()?;
|
||||
rows.into_iter().map(|(sequence,file_id,local_path,local_hash,remote)| {
|
||||
let remote: Value = serde_json::from_str(&remote).map_err(|_| HostError::new("SYNC_RESPONSE_INVALID"))?;
|
||||
let current_path=self.path_for_id(&file_id).unwrap_or_else(|_|local_path.clone());
|
||||
let source=self.resolve(¤t_path)?;
|
||||
let revision: RemoteRevision = serde_json::from_str(&remote).map_err(|_| HostError::new("SYNC_RESPONSE_INVALID"))?;
|
||||
let source=self.resolve(&local_path)?;
|
||||
let current_hash=if source.is_file() {crate::payloads::hash_file(&source)?} else {String::new()};
|
||||
Ok(serde_json::json!({"sequence":sequence,"file_id":file_id,"local_path":local_path,"local_hash":local_hash,"current_path":current_path,"current_hash":current_hash,"remote":remote}))
|
||||
Ok(serde_json::json!({"sequence":sequence,"file_id":file_id,"local_path":local_path,"local_hash":local_hash,"current_path":local_path,"current_hash":current_hash,"remote":revision}))
|
||||
}).collect()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,21 @@ use std::fs;
|
||||
use uuid::Uuid;
|
||||
|
||||
impl Workspace {
|
||||
pub(crate) fn sync_conflict_local_path(&self, revision: &RemoteRevision) -> Result<String> {
|
||||
let identity_path = self.path_for_id(&revision.file_id).ok();
|
||||
let destination_occupied = self
|
||||
.entry(&revision.path)?
|
||||
.is_some_and(|entry| !entry.deleted && entry.file_id != revision.file_id);
|
||||
if destination_occupied
|
||||
&& identity_path
|
||||
.as_deref()
|
||||
.is_some_and(|path| path != revision.path)
|
||||
{
|
||||
return Ok(revision.path.clone());
|
||||
}
|
||||
Ok(identity_path.unwrap_or_else(|| revision.path.clone()))
|
||||
}
|
||||
|
||||
pub fn sync_resolve(
|
||||
&mut self,
|
||||
binding: &str,
|
||||
@@ -46,10 +61,8 @@ impl Workspace {
|
||||
return Err(HostError::new("SYNC_RESOLUTION_CHANGED"));
|
||||
}
|
||||
} else {
|
||||
let (path,remote): (String,String) = self.db.query_row("SELECT local_path,remote FROM sync_conflicts WHERE binding=?1 AND sequence=?2 AND state='open'",params![binding,sequence],|r| Ok((r.get(0)?,r.get(1)?)))?;
|
||||
let revision: RemoteRevision = serde_json::from_str(&remote)
|
||||
.map_err(|_| HostError::new("SYNC_RESPONSE_INVALID"))?;
|
||||
let path = self.path_for_id(&revision.file_id).unwrap_or(path);
|
||||
let stored_path: String = self.db.query_row("SELECT local_path FROM sync_conflicts WHERE binding=?1 AND sequence=?2 AND state='open'",params![binding,sequence],|r| r.get(0))?;
|
||||
let path = stored_path;
|
||||
let source = self.resolve(&path)?;
|
||||
let current = if source.is_file() {
|
||||
self.sync_store_file(&source)?
|
||||
@@ -78,7 +91,7 @@ impl Workspace {
|
||||
return Err(HostError::new("SYNC_RESOLUTION_INVALID"));
|
||||
}
|
||||
self.db.execute(
|
||||
"INSERT INTO sync_resolutions VALUES (?1,?2,?3,?4,?5,?6,?7,?8,'pending')",
|
||||
"INSERT INTO sync_resolutions (binding,sequence,choice,destination,expected,operation_id,rename_id,copy_id,state,retire_id,restore_id) VALUES (?1,?2,?3,?4,?5,?6,?7,?8,'pending',?9,?10)",
|
||||
params![
|
||||
binding,
|
||||
sequence,
|
||||
@@ -87,6 +100,8 @@ impl Workspace {
|
||||
expected,
|
||||
Uuid::new_v4().to_string(),
|
||||
Uuid::new_v4().to_string(),
|
||||
Uuid::new_v4().to_string(),
|
||||
Uuid::new_v4().to_string(),
|
||||
Uuid::new_v4().to_string()
|
||||
],
|
||||
)?;
|
||||
@@ -109,7 +124,22 @@ impl Workspace {
|
||||
}
|
||||
fn sync_apply_resolution(&mut self, binding: &str, sequence: i64) -> Result<()> {
|
||||
self.check_binding(binding)?;
|
||||
let (choice,destination,expected,operation,rename,copy,state): (String,String,String,String,String,String,String) = self.db.query_row("SELECT choice,destination,expected,operation_id,rename_id,copy_id,state FROM sync_resolutions WHERE binding=?1 AND sequence=?2",params![binding,sequence],|r| Ok((r.get(0)?,r.get(1)?,r.get(2)?,r.get(3)?,r.get(4)?,r.get(5)?,r.get(6)?)))?;
|
||||
let (choice,destination,expected,operation,rename,copy,state,mut retire,mut restore): (String,String,String,String,String,String,String,String,String) = self.db.query_row("SELECT choice,destination,expected,operation_id,rename_id,copy_id,state,retire_id,restore_id FROM sync_resolutions WHERE binding=?1 AND sequence=?2",params![binding,sequence],|r| Ok((r.get(0)?,r.get(1)?,r.get(2)?,r.get(3)?,r.get(4)?,r.get(5)?,r.get(6)?,r.get(7)?,r.get(8)?)))?;
|
||||
if retire.is_empty() || restore.is_empty() {
|
||||
retire = Uuid::new_v4().to_string();
|
||||
restore = Uuid::new_v4().to_string();
|
||||
self.db.execute(
|
||||
"UPDATE sync_resolutions SET retire_id=CASE WHEN retire_id='' THEN ?3 ELSE retire_id END,restore_id=CASE WHEN restore_id='' THEN ?4 ELSE restore_id END WHERE binding=?1 AND sequence=?2",
|
||||
params![binding, sequence, retire, restore],
|
||||
)?;
|
||||
let ids: (String, String) = self.db.query_row(
|
||||
"SELECT retire_id,restore_id FROM sync_resolutions WHERE binding=?1 AND sequence=?2",
|
||||
params![binding, sequence],
|
||||
|row| Ok((row.get(0)?, row.get(1)?)),
|
||||
)?;
|
||||
retire = ids.0;
|
||||
restore = ids.1;
|
||||
}
|
||||
if state == "completed" {
|
||||
return Ok(());
|
||||
}
|
||||
@@ -131,21 +161,26 @@ impl Workspace {
|
||||
if head != sequence {
|
||||
return Err(HostError::new("SYNC_CONFLICT_CHANGED"));
|
||||
}
|
||||
let path = self.path_for_id(&revision.file_id).unwrap_or(stored_path);
|
||||
// local_path is frozen when the conflict is recorded. Recomputing it from
|
||||
// file identity after a partial resolution can select a different file.
|
||||
let path = stored_path;
|
||||
if !self
|
||||
.operation(&operation)?
|
||||
.is_some_and(|value| value["state"] == "committed")
|
||||
{
|
||||
let source = self.resolve(&path)?;
|
||||
let mut current = if source.is_file() {
|
||||
crate::payloads::hash_file(&source)?
|
||||
let conflict_source = self.resolve(&path)?;
|
||||
let current = if conflict_source.is_file() {
|
||||
crate::payloads::hash_file(&conflict_source)?
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
let retired = self.operation(&rename)?.is_some_and(|value| {
|
||||
let target_retired = self.operation(&retire)?.is_some_and(|value| {
|
||||
value["state"] == "committed" && value["result"]["deleted"] == true
|
||||
});
|
||||
if current != if retired { "" } else { &expected } {
|
||||
let source_renamed = self
|
||||
.operation(&rename)?
|
||||
.is_some_and(|value| value["state"] == "committed");
|
||||
if current != expected && !target_retired && !source_renamed {
|
||||
return Err(HostError::new("REVISION_CONFLICT"));
|
||||
}
|
||||
if choice == "copy" {
|
||||
@@ -159,21 +194,60 @@ impl Workspace {
|
||||
None,
|
||||
)?;
|
||||
}
|
||||
if self
|
||||
.entry(&path)?
|
||||
.is_some_and(|entry| !entry.deleted && entry.file_id != revision.file_id)
|
||||
let target_collision = target_retired
|
||||
|| (path == revision.path
|
||||
&& self
|
||||
.entry(&path)?
|
||||
.is_some_and(|entry| !entry.deleted && entry.file_id != revision.file_id));
|
||||
if target_collision && !target_retired {
|
||||
self.mutate_with_origin("delete", &path, "", ¤t, &retire, "remote")?;
|
||||
}
|
||||
let source_path = self
|
||||
.path_for_id(&revision.file_id)
|
||||
.unwrap_or_else(|_| revision.path.clone());
|
||||
let source = self.resolve(&source_path)?;
|
||||
let mut source_hash = if source.is_file() {
|
||||
crate::payloads::hash_file(&source)?
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
if target_collision
|
||||
&& source_path != revision.path
|
||||
&& !source_hash.is_empty()
|
||||
&& !source_renamed
|
||||
{
|
||||
self.mutate_with_origin("delete", &path, "", ¤t, &rename, "remote")?;
|
||||
current.clear();
|
||||
// The deleted target still owns its unique database path until the
|
||||
// final identity-aware write retires that tombstone. Retire the
|
||||
// incoming identity at its old path, then resurrect it at the
|
||||
// target with the frozen remote or chosen-local bytes.
|
||||
self.mutate_with_origin(
|
||||
"delete",
|
||||
&source_path,
|
||||
"",
|
||||
&source_hash,
|
||||
&rename,
|
||||
"remote",
|
||||
)?;
|
||||
source_hash.clear();
|
||||
}
|
||||
if choice == "local" {
|
||||
if expected.is_empty() {
|
||||
self.sync_delete_intent(&revision, &operation)?;
|
||||
} else {
|
||||
let destination = if target_collision {
|
||||
revision.path.as_str()
|
||||
} else {
|
||||
path.as_str()
|
||||
};
|
||||
let destination_hash = if target_collision {
|
||||
source_hash.as_str()
|
||||
} else {
|
||||
current.as_str()
|
||||
};
|
||||
let size = fs::metadata(self.sync_spool(&expected)?)?.len();
|
||||
self.write_spooled_with_identity(
|
||||
&path,
|
||||
¤t,
|
||||
destination,
|
||||
destination_hash,
|
||||
(&expected, size),
|
||||
"local",
|
||||
&operation,
|
||||
@@ -181,19 +255,31 @@ impl Workspace {
|
||||
)?;
|
||||
}
|
||||
} else if revision.operation == "delete" {
|
||||
if !current.is_empty() {
|
||||
self.mutate_with_origin("delete", &path, "", ¤t, &operation, "remote")?;
|
||||
if !source_hash.is_empty() {
|
||||
self.mutate_with_origin(
|
||||
"delete",
|
||||
&source_path,
|
||||
"",
|
||||
&source_hash,
|
||||
&operation,
|
||||
"remote",
|
||||
)?;
|
||||
}
|
||||
} else {
|
||||
if path != revision.path && !current.is_empty() {
|
||||
if !target_collision
|
||||
&& source_path != revision.path
|
||||
&& !source_hash.is_empty()
|
||||
&& !source_renamed
|
||||
{
|
||||
self.mutate_with_origin(
|
||||
"rename",
|
||||
&path,
|
||||
&source_path,
|
||||
&revision.path,
|
||||
¤t,
|
||||
&source_hash,
|
||||
&rename,
|
||||
"remote",
|
||||
)?;
|
||||
source_hash = crate::payloads::hash_file(&self.resolve(&revision.path)?)?;
|
||||
}
|
||||
let digest = revision
|
||||
.hash
|
||||
@@ -201,7 +287,7 @@ impl Workspace {
|
||||
.ok_or_else(|| HostError::new("SYNC_RESPONSE_INVALID"))?;
|
||||
self.write_spooled_with_identity(
|
||||
&revision.path,
|
||||
¤t,
|
||||
&source_hash,
|
||||
(digest, revision.size as u64),
|
||||
"remote",
|
||||
&operation,
|
||||
@@ -209,21 +295,56 @@ impl Workspace {
|
||||
)?;
|
||||
}
|
||||
}
|
||||
let retired = self.operation(&rename)?.and_then(|value| {
|
||||
let retired = self.operation(&retire)?.and_then(|value| {
|
||||
(value["state"] == "committed" && value["result"]["deleted"] == true)
|
||||
.then(|| value["result"]["file_id"].as_str().map(str::to_owned))
|
||||
.flatten()
|
||||
});
|
||||
let mut restored_retired = false;
|
||||
if let Some(retired_id) = retired.as_deref().filter(|id| *id != revision.file_id) {
|
||||
let remote_head: Option<(String, String)> = self
|
||||
.db
|
||||
.query_row(
|
||||
"SELECT path,hash FROM sync_heads WHERE binding=?1 AND file_id=?2 AND hash!=''",
|
||||
params![binding, retired_id],
|
||||
|row| Ok((row.get(0)?, row.get(1)?)),
|
||||
)
|
||||
.optional()?;
|
||||
if let Some((head_path, head_hash)) =
|
||||
remote_head.filter(|(head_path, _)| head_path != &revision.path)
|
||||
{
|
||||
if !self
|
||||
.operation(&restore)?
|
||||
.is_some_and(|value| value["state"] == "committed")
|
||||
{
|
||||
if self.resolve(&head_path)?.exists() {
|
||||
return Err(HostError::new("REVISION_CONFLICT"));
|
||||
}
|
||||
let size = fs::metadata(self.sync_spool(&head_hash)?)?.len();
|
||||
self.write_spooled_with_identity(
|
||||
&head_path,
|
||||
"",
|
||||
(&head_hash, size),
|
||||
"remote",
|
||||
&restore,
|
||||
Some(retired_id),
|
||||
)?;
|
||||
}
|
||||
restored_retired = true;
|
||||
}
|
||||
}
|
||||
let tx = self.db.transaction()?;
|
||||
if let Some(retired) = retired.filter(|id| id != &revision.file_id) {
|
||||
tx.execute(
|
||||
"UPDATE file_aliases SET file_id=?1 WHERE file_id=?2",
|
||||
params![revision.file_id, retired],
|
||||
)?;
|
||||
tx.execute(
|
||||
"INSERT OR REPLACE INTO file_aliases VALUES (?1,?2)",
|
||||
params![retired, revision.file_id],
|
||||
)?;
|
||||
if !restored_retired {
|
||||
tx.execute(
|
||||
"UPDATE file_aliases SET file_id=?1 WHERE file_id=?2",
|
||||
params![revision.file_id, retired],
|
||||
)?;
|
||||
tx.execute(
|
||||
"INSERT OR REPLACE INTO file_aliases VALUES (?1,?2)",
|
||||
params![retired, revision.file_id],
|
||||
)?;
|
||||
}
|
||||
tx.execute("UPDATE outbox SET state='archived' WHERE file_id=?1 AND state IN ('pending','queued')",[&retired])?;
|
||||
tx.execute("UPDATE sync_jobs SET state='archived' WHERE binding=?1 AND file_id=?2 AND state!='acked'",params![binding,retired])?;
|
||||
}
|
||||
@@ -504,7 +625,7 @@ mod tests {
|
||||
let copy = Uuid::new_v4().to_string();
|
||||
ws.db
|
||||
.execute(
|
||||
"INSERT INTO sync_resolutions VALUES (?1,2,?2,?3,?4,?5,?6,?7,'pending')",
|
||||
"INSERT INTO sync_resolutions (binding,sequence,choice,destination,expected,operation_id,rename_id,copy_id,state,retire_id,restore_id) VALUES (?1,2,?2,?3,?4,?5,?6,?7,'pending',?8,?9)",
|
||||
params![
|
||||
binding.id,
|
||||
choice,
|
||||
@@ -512,7 +633,9 @@ mod tests {
|
||||
local.hash,
|
||||
operation,
|
||||
Uuid::new_v4().to_string(),
|
||||
copy
|
||||
copy,
|
||||
Uuid::new_v4().to_string(),
|
||||
Uuid::new_v4().to_string()
|
||||
],
|
||||
)
|
||||
.unwrap();
|
||||
@@ -686,12 +809,13 @@ mod tests {
|
||||
let operation = Uuid::new_v4().to_string();
|
||||
let rename = Uuid::new_v4().to_string();
|
||||
let copy = Uuid::new_v4().to_string();
|
||||
ws.db.execute("INSERT INTO sync_resolutions VALUES (?1,1,?2,?3,?4,?5,?6,?7,'pending')",params![binding.id,choice,if choice=="copy" {"copy.md"} else {""},local.hash,operation,rename,copy]).unwrap();
|
||||
let retire = Uuid::new_v4().to_string();
|
||||
ws.db.execute("INSERT INTO sync_resolutions (binding,sequence,choice,destination,expected,operation_id,rename_id,copy_id,state,retire_id,restore_id) VALUES (?1,1,?2,?3,?4,?5,?6,?7,'pending',?8,?9)",params![binding.id,choice,if choice=="copy" {"copy.md"} else {""},local.hash,operation,rename,copy,retire,Uuid::new_v4().to_string()]).unwrap();
|
||||
if choice == "copy" {
|
||||
ws.write_operation("copy.md", "", b"local", "local", ©)
|
||||
.unwrap();
|
||||
}
|
||||
ws.mutate_with_origin("delete", "a.md", "", &local.hash, &rename, "remote")
|
||||
ws.mutate_with_origin("delete", "a.md", "", &local.hash, &retire, "remote")
|
||||
.unwrap();
|
||||
drop(ws);
|
||||
ws = Workspace::open(root.path()).unwrap();
|
||||
@@ -725,4 +849,93 @@ mod tests {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn same_target_renames_preserve_the_occupant_for_all_choices_twenty_rounds() {
|
||||
for round in 0..20 {
|
||||
let choice = ["local", "remote", "copy"][round % 3];
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
let mut ws = Workspace::open(root.path()).unwrap();
|
||||
let binding = ws
|
||||
.sync_bind_download("https://sync.example", "remote-vault", "account")
|
||||
.unwrap();
|
||||
let mut left = RemoteRevision {
|
||||
vault_id: "remote-vault".into(),
|
||||
sequence: 1,
|
||||
file_id: Uuid::new_v4().to_string(),
|
||||
base_revision: 0,
|
||||
path: "left.md".into(),
|
||||
operation: "put".into(),
|
||||
hash: Some(ws.sync_store_bytes(b"left-content").unwrap()),
|
||||
size: 12,
|
||||
operation_id: Uuid::new_v4().to_string(),
|
||||
};
|
||||
let right = RemoteRevision {
|
||||
vault_id: "remote-vault".into(),
|
||||
sequence: 2,
|
||||
file_id: Uuid::new_v4().to_string(),
|
||||
base_revision: 0,
|
||||
path: "right.md".into(),
|
||||
operation: "put".into(),
|
||||
hash: Some(ws.sync_store_bytes(b"right-content").unwrap()),
|
||||
size: 13,
|
||||
operation_id: Uuid::new_v4().to_string(),
|
||||
};
|
||||
receive(&mut ws, &binding.id, &left);
|
||||
receive(&mut ws, &binding.id, &right);
|
||||
let right_hash = right.hash.as_deref().unwrap();
|
||||
ws.rename("right.md", "target.md", right_hash).unwrap();
|
||||
|
||||
left.sequence = 3;
|
||||
left.base_revision = 1;
|
||||
left.path = "target.md".into();
|
||||
left.operation_id = Uuid::new_v4().to_string();
|
||||
receive(&mut ws, &binding.id, &left);
|
||||
|
||||
let conflict = ws.sync_conflicts(&binding.id).unwrap().remove(0);
|
||||
assert_eq!(conflict["current_path"], "target.md");
|
||||
assert_eq!(conflict["current_hash"], right_hash);
|
||||
assert!(ws.sync_spool(right_hash).unwrap().is_file());
|
||||
assert!(ws
|
||||
.sync_spool(left.hash.as_deref().unwrap())
|
||||
.unwrap()
|
||||
.is_file());
|
||||
let copy = format!("copies/target-{round}.md");
|
||||
ws.sync_resolve(
|
||||
&binding.id,
|
||||
3,
|
||||
choice,
|
||||
if choice == "copy" { © } else { "" },
|
||||
right_hash,
|
||||
)
|
||||
.unwrap();
|
||||
drop(ws);
|
||||
|
||||
let mut ws = Workspace::open(root.path()).unwrap();
|
||||
ws.sync_resume_resolutions(&binding.id).unwrap();
|
||||
assert!(ws.sync_conflicts(&binding.id).unwrap().is_empty());
|
||||
assert!(!root.path().join("left.md").exists());
|
||||
let target = ws.read("target.md").unwrap();
|
||||
assert_eq!(target.entry.file_id, left.file_id);
|
||||
assert_eq!(
|
||||
target.content,
|
||||
if choice == "local" {
|
||||
"right-content"
|
||||
} else {
|
||||
"left-content"
|
||||
}
|
||||
);
|
||||
let restored = ws.read("right.md").unwrap();
|
||||
assert_eq!(restored.entry.file_id, right.file_id);
|
||||
assert_eq!(restored.content, "right-content");
|
||||
if choice == "copy" {
|
||||
assert_eq!(ws.read(©).unwrap().content, "right-content");
|
||||
}
|
||||
ws.sync_capture(&binding.id).unwrap();
|
||||
assert_eq!(
|
||||
ws.pending_count().unwrap(),
|
||||
if choice == "remote" { 0 } else { 1 }
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -176,7 +176,7 @@ mod tests {
|
||||
ws.db
|
||||
.query_row("PRAGMA user_version", [], |r| r.get::<_, i64>(0))
|
||||
.unwrap(),
|
||||
11
|
||||
12
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
|
||||
@@ -136,10 +136,10 @@ impl Workspace {
|
||||
let db = Connection::open(db_path)?;
|
||||
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 > 11 {
|
||||
if version > 12 {
|
||||
return Err(HostError::new("SCHEMA_INCOMPATIBLE"));
|
||||
}
|
||||
if (1..11).contains(&version) {
|
||||
if (1..12).contains(&version) {
|
||||
// Independent, complete SQLite backup before the schema ownership change.
|
||||
let backup = managed.join(format!("host-schema{version}-{}.sqlite3", Uuid::new_v4()));
|
||||
db.execute("VACUUM INTO ?1", [backup.to_string_lossy().as_ref()])?;
|
||||
@@ -167,7 +167,7 @@ impl Workspace {
|
||||
CREATE TABLE IF NOT EXISTS sync_retry (binding TEXT PRIMARY KEY,error TEXT,failures INTEGER NOT NULL,retry_at INTEGER,halted INTEGER NOT NULL);
|
||||
CREATE TABLE IF NOT EXISTS sync_preferences (binding TEXT PRIMARY KEY,paused INTEGER NOT NULL DEFAULT 0);
|
||||
CREATE TABLE IF NOT EXISTS sync_optional_scope (id INTEGER PRIMARY KEY CHECK(id=1),persona INTEGER NOT NULL CHECK(persona IN (0,1)),layout INTEGER NOT NULL CHECK(layout IN (0,1)));
|
||||
CREATE TABLE IF NOT EXISTS sync_resolutions (binding TEXT NOT NULL,sequence INTEGER NOT NULL,choice TEXT NOT NULL,destination TEXT NOT NULL,expected TEXT NOT NULL,operation_id TEXT NOT NULL,rename_id TEXT NOT NULL,copy_id TEXT NOT NULL,state TEXT NOT NULL,PRIMARY KEY(binding,sequence));")?;
|
||||
CREATE TABLE IF NOT EXISTS sync_resolutions (binding TEXT NOT NULL,sequence INTEGER NOT NULL,choice TEXT NOT NULL,destination TEXT NOT NULL,expected TEXT NOT NULL,operation_id TEXT NOT NULL,rename_id TEXT NOT NULL,copy_id TEXT NOT NULL,state TEXT NOT NULL,retire_id TEXT NOT NULL,restore_id TEXT NOT NULL,PRIMARY KEY(binding,sequence));")?;
|
||||
let has_origin: bool = db.query_row(
|
||||
"SELECT EXISTS(SELECT 1 FROM pragma_table_info('file_ops') WHERE name='origin')",
|
||||
[],
|
||||
@@ -179,10 +179,32 @@ impl Workspace {
|
||||
[],
|
||||
)?;
|
||||
}
|
||||
let has_retire_id: bool = db.query_row(
|
||||
"SELECT EXISTS(SELECT 1 FROM pragma_table_info('sync_resolutions') WHERE name='retire_id')",
|
||||
[],
|
||||
|r| r.get(0),
|
||||
)?;
|
||||
if !has_retire_id {
|
||||
db.execute(
|
||||
"ALTER TABLE sync_resolutions ADD COLUMN retire_id TEXT NOT NULL DEFAULT ''",
|
||||
[],
|
||||
)?;
|
||||
}
|
||||
let has_restore_id: bool = db.query_row(
|
||||
"SELECT EXISTS(SELECT 1 FROM pragma_table_info('sync_resolutions') WHERE name='restore_id')",
|
||||
[],
|
||||
|r| r.get(0),
|
||||
)?;
|
||||
if !has_restore_id {
|
||||
db.execute(
|
||||
"ALTER TABLE sync_resolutions ADD COLUMN restore_id TEXT NOT NULL DEFAULT ''",
|
||||
[],
|
||||
)?;
|
||||
}
|
||||
if version < 7 {
|
||||
db.execute_batch("INSERT OR IGNORE INTO sync_observed SELECT f.id,COALESCE((SELECT o.path FROM outbox o WHERE o.file_id=f.id AND o.state IN ('pending','queued') ORDER BY rowid DESC LIMIT 1),(SELECT h.path FROM sync_heads h JOIN sync_bindings b ON h.binding=b.id WHERE h.file_id=f.id AND b.state='active'),f.path),COALESCE((SELECT o.hash FROM outbox o WHERE o.file_id=f.id AND o.state IN ('pending','queued') ORDER BY rowid DESC LIMIT 1),(SELECT h.hash FROM sync_heads h JOIN sync_bindings b ON h.binding=b.id WHERE h.file_id=f.id AND b.state='active'),f.hash),f.deleted FROM files f;")?;
|
||||
}
|
||||
db.execute_batch("UPDATE sync_attempts SET outcome=CASE WHEN EXISTS(SELECT 1 FROM sync_jobs j WHERE j.binding=sync_attempts.binding AND j.operation_id=sync_attempts.operation_id AND j.state='acked') THEN 'succeeded' ELSE 'interrupted' END WHERE outcome='running'; PRAGMA user_version=11; COMMIT;")?;
|
||||
db.execute_batch("UPDATE sync_attempts SET outcome=CASE WHEN EXISTS(SELECT 1 FROM sync_jobs j WHERE j.binding=sync_attempts.binding AND j.operation_id=sync_attempts.operation_id AND j.state='acked') THEN 'succeeded' ELSE 'interrupted' END WHERE outcome='running'; PRAGMA user_version=12; COMMIT;")?;
|
||||
let vault_id: String = db
|
||||
.query_row("SELECT id FROM identity", [], |r| r.get(0))
|
||||
.optional()?
|
||||
@@ -1375,6 +1397,56 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn schema_eleven_upgrade_adds_durable_conflict_operation_ids() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let ws = Workspace::open(dir.path()).unwrap();
|
||||
ws.db
|
||||
.execute_batch(
|
||||
"DROP TABLE sync_resolutions;
|
||||
CREATE TABLE sync_resolutions (binding TEXT NOT NULL,sequence INTEGER NOT NULL,choice TEXT NOT NULL,destination TEXT NOT NULL,expected TEXT NOT NULL,operation_id TEXT NOT NULL,rename_id TEXT NOT NULL,copy_id TEXT NOT NULL,state TEXT NOT NULL,PRIMARY KEY(binding,sequence));
|
||||
PRAGMA user_version=11;",
|
||||
)
|
||||
.unwrap();
|
||||
drop(ws);
|
||||
|
||||
let ws = Workspace::open(dir.path()).unwrap();
|
||||
assert_eq!(
|
||||
ws.db
|
||||
.query_row("PRAGMA user_version", [], |row| row.get::<_, i64>(0))
|
||||
.unwrap(),
|
||||
12
|
||||
);
|
||||
for column in ["retire_id", "restore_id"] {
|
||||
assert!(ws
|
||||
.db
|
||||
.query_row(
|
||||
"SELECT EXISTS(SELECT 1 FROM pragma_table_info('sync_resolutions') WHERE name=?1)",
|
||||
[column],
|
||||
|row| row.get::<_, bool>(0),
|
||||
)
|
||||
.unwrap());
|
||||
}
|
||||
let backup = fs::read_dir(dir.path().join(".ainote"))
|
||||
.unwrap()
|
||||
.filter_map(|entry| entry.ok())
|
||||
.find(|entry| {
|
||||
entry
|
||||
.file_name()
|
||||
.to_string_lossy()
|
||||
.starts_with("host-schema11-")
|
||||
})
|
||||
.unwrap();
|
||||
let old = Connection::open(backup.path()).unwrap();
|
||||
assert!(!old
|
||||
.query_row(
|
||||
"SELECT EXISTS(SELECT 1 FROM pragma_table_info('sync_resolutions') WHERE name='retire_id')",
|
||||
[],
|
||||
|row| row.get::<_, bool>(0),
|
||||
)
|
||||
.unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unsafe_paths_external_change_and_remote_origin() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
|
||||
@@ -0,0 +1,508 @@
|
||||
#![cfg(feature = "desktop")]
|
||||
|
||||
use notesagent_host::{
|
||||
sync_client::SyncClient,
|
||||
sync_state::Binding,
|
||||
workspace::{hash, Workspace},
|
||||
};
|
||||
use serde_json::{json, Value};
|
||||
use std::{
|
||||
io::{BufRead, BufReader},
|
||||
path::Path,
|
||||
process::{Child, Command, Stdio},
|
||||
sync::{Arc, Mutex},
|
||||
};
|
||||
use zeroize::Zeroizing;
|
||||
|
||||
struct Server(Child);
|
||||
|
||||
impl Drop for Server {
|
||||
fn drop(&mut self) {
|
||||
self.0.stdin.take();
|
||||
let _ = self.0.kill();
|
||||
let _ = self.0.wait();
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
enum ConflictKind {
|
||||
SameEdit,
|
||||
EditDelete,
|
||||
EditRename,
|
||||
SameTargetRename,
|
||||
HistoryRestore,
|
||||
}
|
||||
|
||||
impl ConflictKind {
|
||||
fn name(self) -> &'static str {
|
||||
match self {
|
||||
Self::SameEdit => "same-edit",
|
||||
Self::EditDelete => "edit-delete",
|
||||
Self::EditRename => "edit-rename",
|
||||
Self::SameTargetRename => "same-target-rename",
|
||||
Self::HistoryRestore => "history-restore",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn push_all(client: &SyncClient, workspace: &Arc<Mutex<Workspace>>, binding: &Binding) {
|
||||
while client.push_one(workspace, binding).await.unwrap() {}
|
||||
}
|
||||
|
||||
async fn pull_all(client: &SyncClient, workspace: &Arc<Mutex<Workspace>>, binding: &Binding) {
|
||||
while client.pull_page(workspace, binding).await.unwrap() > 0 {}
|
||||
}
|
||||
|
||||
struct SyncPair<'a> {
|
||||
client_a: &'a SyncClient,
|
||||
client_b: &'a SyncClient,
|
||||
workspace_a: &'a Arc<Mutex<Workspace>>,
|
||||
workspace_b: &'a Arc<Mutex<Workspace>>,
|
||||
binding_a: &'a Binding,
|
||||
binding_b: &'a Binding,
|
||||
}
|
||||
|
||||
impl SyncPair<'_> {
|
||||
async fn resolve_copy_and_converge(
|
||||
&self,
|
||||
copy_path: &str,
|
||||
local_content: &str,
|
||||
main_path: &str,
|
||||
remote_content: Option<&str>,
|
||||
) {
|
||||
let conflict = self
|
||||
.workspace_b
|
||||
.lock()
|
||||
.unwrap()
|
||||
.sync_conflicts(&self.binding_b.id)
|
||||
.unwrap()
|
||||
.remove(0);
|
||||
let local_hash = hash(local_content.as_bytes());
|
||||
assert_eq!(conflict["current_hash"], local_hash);
|
||||
{
|
||||
let mut ws = self.workspace_b.lock().unwrap();
|
||||
assert!(ws.sync_spool(&local_hash).unwrap().is_file());
|
||||
if let Some(remote_hash) = conflict["remote"]["hash"].as_str() {
|
||||
assert!(ws.sync_spool(remote_hash).unwrap().is_file());
|
||||
}
|
||||
ws.sync_resolve(
|
||||
&self.binding_b.id,
|
||||
conflict["sequence"].as_i64().unwrap(),
|
||||
"copy",
|
||||
copy_path,
|
||||
&local_hash,
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
push_all(self.client_b, self.workspace_b, self.binding_b).await;
|
||||
pull_all(self.client_a, self.workspace_a, self.binding_a).await;
|
||||
pull_all(self.client_b, self.workspace_b, self.binding_b).await;
|
||||
|
||||
let copy_a = self.workspace_a.lock().unwrap().read(copy_path).unwrap();
|
||||
let copy_b = self.workspace_b.lock().unwrap().read(copy_path).unwrap();
|
||||
assert_eq!(copy_a.content, local_content);
|
||||
assert_eq!(copy_a.entry.hash, copy_b.entry.hash);
|
||||
assert_eq!(copy_a.entry.file_id, copy_b.entry.file_id);
|
||||
for workspace in [self.workspace_a, self.workspace_b] {
|
||||
let mut ws = workspace.lock().unwrap();
|
||||
if let Some(remote_content) = remote_content {
|
||||
assert_eq!(ws.read(main_path).unwrap().content, remote_content);
|
||||
} else {
|
||||
assert!(!ws.root.join(main_path).exists());
|
||||
}
|
||||
}
|
||||
assert!(self
|
||||
.workspace_a
|
||||
.lock()
|
||||
.unwrap()
|
||||
.sync_conflicts(&self.binding_a.id)
|
||||
.unwrap()
|
||||
.is_empty());
|
||||
assert!(self
|
||||
.workspace_b
|
||||
.lock()
|
||||
.unwrap()
|
||||
.sync_conflicts(&self.binding_b.id)
|
||||
.unwrap()
|
||||
.is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn s03_actual_service_converges_five_conflict_classes_twenty_rounds() {
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
std::fs::write(root.path().join(".opennexus-test"), b"fixture").unwrap();
|
||||
let service = Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../../server sync")
|
||||
.canonicalize()
|
||||
.unwrap();
|
||||
let python = service.join(if cfg!(windows) {
|
||||
".venv/Scripts/python.exe"
|
||||
} else {
|
||||
".venv/bin/python"
|
||||
});
|
||||
let mut server = Server(
|
||||
Command::new(python)
|
||||
.args(["-m", "tests.host_fixture"])
|
||||
.arg(root.path())
|
||||
.current_dir(service)
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::null())
|
||||
.spawn()
|
||||
.unwrap(),
|
||||
);
|
||||
let mut line = String::new();
|
||||
BufReader::new(server.0.stdout.take().unwrap())
|
||||
.read_line(&mut line)
|
||||
.unwrap();
|
||||
let ready: Value = serde_json::from_str(&line).unwrap();
|
||||
let endpoint = format!("http://127.0.0.1:{}", ready["port"]);
|
||||
let public = SyncClient::new(&endpoint, Zeroizing::new(String::new()), true).unwrap();
|
||||
public.handshake().await.unwrap();
|
||||
let session_a = public
|
||||
.login(
|
||||
"rust-fixture",
|
||||
Zeroizing::new("controlled-fixture-password".into()),
|
||||
"S-03 A",
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let session_b = public
|
||||
.login(
|
||||
"rust-fixture",
|
||||
Zeroizing::new("controlled-fixture-password".into()),
|
||||
"S-03 B",
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let client_a = SyncClient::new(
|
||||
&endpoint,
|
||||
Zeroizing::new(session_a.access_token.clone()),
|
||||
true,
|
||||
)
|
||||
.unwrap();
|
||||
let client_b = SyncClient::new(
|
||||
&endpoint,
|
||||
Zeroizing::new(session_b.access_token.clone()),
|
||||
true,
|
||||
)
|
||||
.unwrap();
|
||||
let vault = client_a
|
||||
.json(
|
||||
reqwest::Method::POST,
|
||||
"sync/v1/vaults",
|
||||
Some(json!({"name":"S-03 matrix"})),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let remote = vault["vault_id"].as_str().unwrap();
|
||||
client_a.verify_empty(remote).await.unwrap();
|
||||
|
||||
let root_a = tempfile::tempdir().unwrap();
|
||||
let root_b = tempfile::tempdir().unwrap();
|
||||
let workspace_a = Arc::new(Mutex::new(Workspace::open(root_a.path()).unwrap()));
|
||||
let workspace_b = Arc::new(Mutex::new(Workspace::open(root_b.path()).unwrap()));
|
||||
workspace_a
|
||||
.lock()
|
||||
.unwrap()
|
||||
.write("first-bind.md", "", b"local snapshot", "local")
|
||||
.unwrap();
|
||||
let binding_a = workspace_a
|
||||
.lock()
|
||||
.unwrap()
|
||||
.sync_bind_empty(&endpoint, remote, "rust-fixture")
|
||||
.unwrap();
|
||||
let first_job = workspace_a
|
||||
.lock()
|
||||
.unwrap()
|
||||
.sync_next(&binding_a.id)
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(first_job.operation, "put");
|
||||
let before = client_a
|
||||
.json(
|
||||
reqwest::Method::GET,
|
||||
&format!("sync/v1/vaults/{remote}/changes"),
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(before["boundary"], 0);
|
||||
assert!(before["items"].as_array().unwrap().is_empty());
|
||||
push_all(&client_a, &workspace_a, &binding_a).await;
|
||||
let after = client_a
|
||||
.json(
|
||||
reqwest::Method::GET,
|
||||
&format!("sync/v1/vaults/{remote}/changes"),
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
after["items"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.filter(|revision| revision["operation"] == "delete")
|
||||
.count(),
|
||||
0
|
||||
);
|
||||
let binding_b = workspace_b
|
||||
.lock()
|
||||
.unwrap()
|
||||
.sync_bind_download(&endpoint, remote, "rust-fixture")
|
||||
.unwrap();
|
||||
pull_all(&client_b, &workspace_b, &binding_b).await;
|
||||
let pair = SyncPair {
|
||||
client_a: &client_a,
|
||||
client_b: &client_b,
|
||||
workspace_a: &workspace_a,
|
||||
workspace_b: &workspace_b,
|
||||
binding_a: &binding_a,
|
||||
binding_b: &binding_b,
|
||||
};
|
||||
|
||||
let kinds = [
|
||||
ConflictKind::SameEdit,
|
||||
ConflictKind::EditDelete,
|
||||
ConflictKind::EditRename,
|
||||
ConflictKind::SameTargetRename,
|
||||
ConflictKind::HistoryRestore,
|
||||
];
|
||||
for kind in kinds {
|
||||
for round in 0..20 {
|
||||
let stem = format!("matrix/{}-{round}", kind.name());
|
||||
let source = format!("{stem}-source.md");
|
||||
let target = match kind {
|
||||
ConflictKind::EditRename | ConflictKind::SameTargetRename => {
|
||||
format!("{stem}-target.md")
|
||||
}
|
||||
_ => source.clone(),
|
||||
};
|
||||
let right = format!("{stem}-right.md");
|
||||
let (base, local_content, remote_content) = match kind {
|
||||
ConflictKind::SameEdit => (
|
||||
"base".to_owned(),
|
||||
format!("local-edit-{round}"),
|
||||
Some(format!("remote-edit-{round}")),
|
||||
),
|
||||
ConflictKind::EditDelete => {
|
||||
("base".to_owned(), format!("local-survivor-{round}"), None)
|
||||
}
|
||||
ConflictKind::EditRename => (
|
||||
"rename-base".to_owned(),
|
||||
format!("edited-before-rename-{round}"),
|
||||
Some("rename-base".to_owned()),
|
||||
),
|
||||
ConflictKind::SameTargetRename => (
|
||||
"left-content".to_owned(),
|
||||
"right-content".to_owned(),
|
||||
Some("left-content".to_owned()),
|
||||
),
|
||||
ConflictKind::HistoryRestore => (
|
||||
"historical".to_owned(),
|
||||
format!("local-history-{round}"),
|
||||
Some(format!("remote-history-{round}")),
|
||||
),
|
||||
};
|
||||
workspace_a
|
||||
.lock()
|
||||
.unwrap()
|
||||
.write(&source, "", base.as_bytes(), "local")
|
||||
.unwrap();
|
||||
if matches!(kind, ConflictKind::SameTargetRename) {
|
||||
workspace_a
|
||||
.lock()
|
||||
.unwrap()
|
||||
.write(&right, "", local_content.as_bytes(), "local")
|
||||
.unwrap();
|
||||
}
|
||||
push_all(&client_a, &workspace_a, &binding_a).await;
|
||||
pull_all(&client_b, &workspace_b, &binding_b).await;
|
||||
|
||||
if matches!(kind, ConflictKind::HistoryRestore) {
|
||||
{
|
||||
let mut ws = workspace_a.lock().unwrap();
|
||||
let current = ws.read(&source).unwrap();
|
||||
ws.delete(&source, ¤t.entry.hash).unwrap();
|
||||
}
|
||||
push_all(&client_a, &workspace_a, &binding_a).await;
|
||||
pull_all(&client_b, &workspace_b, &binding_b).await;
|
||||
}
|
||||
|
||||
match kind {
|
||||
ConflictKind::SameEdit => {
|
||||
for (workspace, content) in [
|
||||
(&workspace_a, remote_content.as_deref().unwrap()),
|
||||
(&workspace_b, local_content.as_str()),
|
||||
] {
|
||||
let mut ws = workspace.lock().unwrap();
|
||||
let current = ws.read(&source).unwrap();
|
||||
ws.write(&source, ¤t.entry.hash, content.as_bytes(), "local")
|
||||
.unwrap();
|
||||
}
|
||||
}
|
||||
ConflictKind::EditDelete => {
|
||||
{
|
||||
let mut ws = workspace_a.lock().unwrap();
|
||||
let current = ws.read(&source).unwrap();
|
||||
ws.delete(&source, ¤t.entry.hash).unwrap();
|
||||
}
|
||||
{
|
||||
let mut ws = workspace_b.lock().unwrap();
|
||||
let current = ws.read(&source).unwrap();
|
||||
ws.write(
|
||||
&source,
|
||||
¤t.entry.hash,
|
||||
local_content.as_bytes(),
|
||||
"local",
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
}
|
||||
ConflictKind::EditRename => {
|
||||
{
|
||||
let mut ws = workspace_a.lock().unwrap();
|
||||
let current = ws.read(&source).unwrap();
|
||||
ws.rename(&source, &target, ¤t.entry.hash).unwrap();
|
||||
}
|
||||
{
|
||||
let mut ws = workspace_b.lock().unwrap();
|
||||
let current = ws.read(&source).unwrap();
|
||||
ws.write(
|
||||
&source,
|
||||
¤t.entry.hash,
|
||||
local_content.as_bytes(),
|
||||
"local",
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
}
|
||||
ConflictKind::SameTargetRename => {
|
||||
{
|
||||
let mut ws = workspace_a.lock().unwrap();
|
||||
let current = ws.read(&source).unwrap();
|
||||
ws.rename(&source, &target, ¤t.entry.hash).unwrap();
|
||||
}
|
||||
{
|
||||
let mut ws = workspace_b.lock().unwrap();
|
||||
let current = ws.read(&right).unwrap();
|
||||
ws.rename(&right, &target, ¤t.entry.hash).unwrap();
|
||||
}
|
||||
}
|
||||
ConflictKind::HistoryRestore => {
|
||||
workspace_a
|
||||
.lock()
|
||||
.unwrap()
|
||||
.write(
|
||||
&source,
|
||||
"",
|
||||
remote_content.as_deref().unwrap().as_bytes(),
|
||||
"local",
|
||||
)
|
||||
.unwrap();
|
||||
workspace_b
|
||||
.lock()
|
||||
.unwrap()
|
||||
.write(&source, "", local_content.as_bytes(), "local")
|
||||
.unwrap();
|
||||
}
|
||||
}
|
||||
push_all(&client_a, &workspace_a, &binding_a).await;
|
||||
pull_all(&client_b, &workspace_b, &binding_b).await;
|
||||
let copy = format!("copies/{}-{round}.md", kind.name());
|
||||
pair.resolve_copy_and_converge(
|
||||
©,
|
||||
&local_content,
|
||||
&target,
|
||||
remote_content.as_deref(),
|
||||
)
|
||||
.await;
|
||||
if matches!(
|
||||
kind,
|
||||
ConflictKind::EditRename | ConflictKind::SameTargetRename
|
||||
) {
|
||||
assert!(!workspace_a.lock().unwrap().root.join(&source).exists());
|
||||
assert!(!workspace_b.lock().unwrap().root.join(&source).exists());
|
||||
}
|
||||
if matches!(kind, ConflictKind::SameTargetRename) {
|
||||
let right_a = workspace_a.lock().unwrap().read(&right).unwrap();
|
||||
let right_b = workspace_b.lock().unwrap().read(&right).unwrap();
|
||||
assert_eq!(right_a.content, "right-content");
|
||||
assert_eq!(right_a.entry.hash, right_b.entry.hash);
|
||||
assert_eq!(right_a.entry.file_id, right_b.entry.file_id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let rebound_vault = client_a
|
||||
.json(
|
||||
reqwest::Method::POST,
|
||||
"sync/v1/vaults",
|
||||
Some(json!({"name":"S-03 rebind"})),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let rebound_remote = rebound_vault["vault_id"].as_str().unwrap();
|
||||
let rebound_root = tempfile::tempdir().unwrap();
|
||||
let rebound_ws = Arc::new(Mutex::new(Workspace::open(rebound_root.path()).unwrap()));
|
||||
rebound_ws
|
||||
.lock()
|
||||
.unwrap()
|
||||
.write("rebound.md", "", b"current", "local")
|
||||
.unwrap();
|
||||
let old_binding = rebound_ws
|
||||
.lock()
|
||||
.unwrap()
|
||||
.sync_bind_empty(&endpoint, rebound_remote, "rust-fixture")
|
||||
.unwrap();
|
||||
let old_operation = rebound_ws
|
||||
.lock()
|
||||
.unwrap()
|
||||
.sync_next(&old_binding.id)
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
.operation_id;
|
||||
rebound_ws
|
||||
.lock()
|
||||
.unwrap()
|
||||
.sync_unbind(&old_binding.id)
|
||||
.unwrap();
|
||||
let new_binding = rebound_ws
|
||||
.lock()
|
||||
.unwrap()
|
||||
.sync_bind_empty(&endpoint, rebound_remote, "rust-fixture")
|
||||
.unwrap();
|
||||
let new_operation = rebound_ws
|
||||
.lock()
|
||||
.unwrap()
|
||||
.sync_next(&new_binding.id)
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
.operation_id;
|
||||
assert_ne!(old_operation, new_operation);
|
||||
assert_eq!(
|
||||
client_a
|
||||
.push_one(&rebound_ws, &old_binding)
|
||||
.await
|
||||
.unwrap_err()
|
||||
.code,
|
||||
"SYNC_BINDING_CHANGED"
|
||||
);
|
||||
push_all(&client_a, &rebound_ws, &new_binding).await;
|
||||
let rebound_changes = client_a
|
||||
.json(
|
||||
reqwest::Method::GET,
|
||||
&format!("sync/v1/vaults/{rebound_remote}/changes"),
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let rebound_items = rebound_changes["items"].as_array().unwrap();
|
||||
assert_eq!(rebound_items.len(), 1);
|
||||
assert_eq!(rebound_items[0]["operation_id"], new_operation);
|
||||
assert!(rebound_items
|
||||
.iter()
|
||||
.all(|revision| revision["operation_id"] != old_operation));
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
"""S-03 conflict matrix, first-bind, and rebind isolation acceptance driver."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
MANIFEST = ROOT / "frontend" / "src-tauri" / "Cargo.toml"
|
||||
SERVICE_TEST = "s03_actual_service_converges_five_conflict_classes_twenty_rounds"
|
||||
TARGET_RENAME_TEST = (
|
||||
"sync_resolution::tests::same_target_renames_preserve_the_occupant_for_all_choices_twenty_rounds"
|
||||
)
|
||||
|
||||
|
||||
def sha256(path: Path) -> str:
|
||||
digest = hashlib.sha256()
|
||||
with path.open("rb") as source:
|
||||
for chunk in iter(lambda: source.read(1024 * 1024), b""):
|
||||
digest.update(chunk)
|
||||
return digest.hexdigest()
|
||||
|
||||
|
||||
def run_exact(command: list[str]) -> bool:
|
||||
completed = subprocess.run(command, cwd=ROOT, capture_output=True, text=True, check=False)
|
||||
print(completed.stdout, end="")
|
||||
print(completed.stderr, end="")
|
||||
return completed.returncode == 0 and "1 passed; 0 failed" in completed.stdout
|
||||
|
||||
|
||||
def result(case_id: str, status: str, reason: str, assertions: list[dict]) -> dict:
|
||||
evidence = f"cargo exact tests {SERVICE_TEST} and {TARGET_RENAME_TEST}"
|
||||
for assertion in assertions:
|
||||
assertion.update({"status": status, "evidence": evidence})
|
||||
files = []
|
||||
for relative in (
|
||||
"frontend/src-tauri/src/workspace.rs",
|
||||
"frontend/src-tauri/src/sync_inbox.rs",
|
||||
"frontend/src-tauri/src/sync_resolution.rs",
|
||||
"frontend/src-tauri/tests/sync_conflicts.rs",
|
||||
"server sync/tests/host_fixture.py",
|
||||
):
|
||||
files.append({"path": relative, "sha256": sha256(ROOT / relative)})
|
||||
return {
|
||||
"schema": 1,
|
||||
"case_id": case_id,
|
||||
"status": status,
|
||||
"reason": reason,
|
||||
"assertions": assertions,
|
||||
"metrics": {"peak_rss_bytes": None, "max_process_count": None, "denied_access_count": None},
|
||||
"files": files,
|
||||
"revisions": [
|
||||
{
|
||||
"scope": "actual two-client HTTP conflict matrix",
|
||||
"classes": 5,
|
||||
"rounds_per_class": 20,
|
||||
"resolution": "copy",
|
||||
},
|
||||
{
|
||||
"scope": "same-target rename durable choices",
|
||||
"rounds": 20,
|
||||
"choices": ["local", "remote", "copy"],
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--config", required=True)
|
||||
parser.add_argument("--output", required=True)
|
||||
args = parser.parse_args()
|
||||
case_id = os.environ.get("OPENNEXUS_ACCEPTANCE_CASE_ID", "")
|
||||
output = Path(args.output)
|
||||
output.parent.mkdir(parents=True, exist_ok=True)
|
||||
assertions = [
|
||||
{"name": "same-edit conflicts preserve both contents and converge for twenty rounds"},
|
||||
{"name": "edit-delete conflicts preserve edited bytes and converge for twenty rounds"},
|
||||
{"name": "edit-rename conflicts preserve both contents and converge for twenty rounds"},
|
||||
{"name": "same-target renames restore the displaced server head and converge for twenty rounds"},
|
||||
{"name": "history restores preserve both versions and converge for twenty rounds"},
|
||||
{"name": "first binding to an empty Vault emits zero server delete revisions"},
|
||||
{"name": "unbind and rebind reject and never transmit the archived operation ID"},
|
||||
{"name": "all same-target choices remain complete after Workspace reopen"},
|
||||
]
|
||||
cargo = shutil.which(os.environ.get("CARGO", "cargo"))
|
||||
passed = case_id == "S-03" and cargo is not None
|
||||
if passed:
|
||||
passed = run_exact(
|
||||
[
|
||||
cargo,
|
||||
"test",
|
||||
"--manifest-path",
|
||||
str(MANIFEST),
|
||||
"--locked",
|
||||
"--features",
|
||||
"desktop",
|
||||
"--test",
|
||||
"sync_conflicts",
|
||||
SERVICE_TEST,
|
||||
"--",
|
||||
"--exact",
|
||||
"--nocapture",
|
||||
]
|
||||
)
|
||||
passed = run_exact(
|
||||
[
|
||||
cargo,
|
||||
"test",
|
||||
"--manifest-path",
|
||||
str(MANIFEST),
|
||||
"--locked",
|
||||
"--lib",
|
||||
TARGET_RENAME_TEST,
|
||||
"--",
|
||||
"--exact",
|
||||
"--nocapture",
|
||||
]
|
||||
) and passed
|
||||
payload = result(
|
||||
case_id,
|
||||
"PASSED" if passed else "FAILED",
|
||||
"" if passed else "An exact S-03 conflict, convergence, or binding-isolation oracle failed.",
|
||||
assertions,
|
||||
)
|
||||
output.write_text(json.dumps(payload, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
|
||||
return 0 if passed else 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -67,6 +67,11 @@ CASE_DRIVERS: dict[str, dict[str, Any]] = {
|
||||
"timeout_seconds": 900,
|
||||
"required_metrics": (),
|
||||
},
|
||||
"S-03": {
|
||||
"driver": "scripts/acceptance_cases/s03_sync_client.py",
|
||||
"timeout_seconds": 900,
|
||||
"required_metrics": (),
|
||||
},
|
||||
}
|
||||
ENV_NAME = re.compile(r"[A-Z][A-Z0-9_]{2,127}")
|
||||
RUN_ID = re.compile(r"[A-Za-z0-9][A-Za-z0-9._-]{2,63}")
|
||||
|
||||
Reference in New Issue
Block a user