From 20c88af800fd1a9514acd2347615a02e751b6f96 Mon Sep 17 00:00:00 2001 From: Eric Wendland Date: Mon, 18 May 2026 03:57:26 +0200 Subject: [PATCH] Add local file conflict metadata --- AGENTS.md | 5 +- Cargo.lock | 2 + README.md | 2 + crates/geth-cas/src/lib.rs | 108 +++++++++++++++++++ crates/geth-cli/Cargo.toml | 1 + crates/geth-cli/src/lib.rs | 108 +++++++++++++++++++ crates/geth-control/src/lib.rs | 38 ++++++- crates/geth-node/src/lib.rs | 116 ++++++++++++++++++++- crates/geth-store/src/lib.rs | 183 +++++++++++++++++++++++++++++++++ crates/geth/Cargo.toml | 1 + crates/geth/tests/bootstrap.rs | 100 ++++++++++++++++++ docs/architecture.md | 9 +- docs/roadmap.md | 13 ++- 13 files changed, 672 insertions(+), 14 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 6b33654..db4c4cb 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -121,8 +121,9 @@ Roadmap items should be actionable and checkable: - The CAS crate can build deterministic tree objects for local file trees and store those manifests as CAS blobs. The daemon can register and scan local file roots, reporting create/update/delete/rename changes without writing back - to the working tree. Cross-node file sync and conflict handling are still - roadmap work. + to the working tree. Durable local file-conflict records can be listed and + resolved manually; automatic cross-node conflict detection and file sync are + still roadmap work. - DB resources can be registered locally and report local-only status plus a read-only SQLite schema summary/hash and `crsql_changes` metadata when present. The DB crate and daemon can extract typed read-only `crsql_changes` diff --git a/Cargo.lock b/Cargo.lock index 16a94cb..429b2fb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1047,6 +1047,7 @@ dependencies = [ "geth-control", "geth-node", "geth-store", + "geth-types", "rusqlite", "tempfile", "tokio", @@ -1083,6 +1084,7 @@ version = "0.1.0" dependencies = [ "anyhow", "clap", + "geth-cas", "geth-config", "geth-control", "geth-node", diff --git a/README.md b/README.md index 3fe3b56..95f4677 100644 --- a/README.md +++ b/README.md @@ -89,6 +89,8 @@ The bootstrap implementation provides: `cleanup`, `list` - local CAS tree objects describe file trees and are stored as CAS blobs - local file-root commands: `geth cas root add/list/scan` +- local file conflict metadata commands: + `geth cas conflict record/list/resolve` - local DB resource registration: `geth db add ` and `geth db status ` with schema and `crsql_changes` metadata; the DB crate and daemon can extract typed local `crsql_changes` batches through diff --git a/crates/geth-cas/src/lib.rs b/crates/geth-cas/src/lib.rs index d7c04e0..4b87bdf 100644 --- a/crates/geth-cas/src/lib.rs +++ b/crates/geth-cas/src/lib.rs @@ -21,6 +21,12 @@ pub enum CasError { NonRelativeTreePath(String), #[error("invalid file root name: {0}")] InvalidFileRootName(String), + #[error("invalid file conflict kind: {0}")] + InvalidFileConflictKind(String), + #[error("invalid file conflict resolution: {0}")] + InvalidFileConflictResolution(String), + #[error("invalid file conflict status: {0}")] + InvalidFileConflictStatus(String), } #[derive(Clone, Debug)] @@ -96,6 +102,108 @@ pub enum FileRootChange { Renamed { from: String, to: String }, } +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct FileConflict { + pub id: String, + pub root: String, + pub resource: String, + pub path: String, + pub kind: FileConflictKind, + pub status: FileConflictStatus, + pub base_tree: Option, + pub local_tree: Option, + pub remote_tree: Option, + pub detail: String, + pub resolution: Option, + pub resolution_note: Option, + pub created_at_ms: i64, + pub resolved_at_ms: Option, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub enum FileConflictKind { + ConcurrentEdit, + DeleteEdit, + Rename, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub enum FileConflictStatus { + Open, + Resolved, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub enum FileConflictResolution { + KeepLocal, + AcceptRemote, + KeepBoth, + Manual, +} + +impl FileConflictKind { + pub fn parse(value: &str) -> Result { + match value { + "concurrent-edit" => Ok(Self::ConcurrentEdit), + "delete-edit" => Ok(Self::DeleteEdit), + "rename" => Ok(Self::Rename), + _ => Err(CasError::InvalidFileConflictKind(value.to_owned())), + } + } + + #[must_use] + pub fn as_str(&self) -> &'static str { + match self { + Self::ConcurrentEdit => "concurrent-edit", + Self::DeleteEdit => "delete-edit", + Self::Rename => "rename", + } + } +} + +impl FileConflictResolution { + pub fn parse(value: &str) -> Result { + match value { + "keep-local" => Ok(Self::KeepLocal), + "accept-remote" => Ok(Self::AcceptRemote), + "keep-both" => Ok(Self::KeepBoth), + "manual" => Ok(Self::Manual), + _ => Err(CasError::InvalidFileConflictResolution(value.to_owned())), + } + } + + #[must_use] + pub fn as_str(&self) -> &'static str { + match self { + Self::KeepLocal => "keep-local", + Self::AcceptRemote => "accept-remote", + Self::KeepBoth => "keep-both", + Self::Manual => "manual", + } + } +} + +impl FileConflictStatus { + pub fn parse(value: &str) -> Result { + match value { + "open" => Ok(Self::Open), + "resolved" => Ok(Self::Resolved), + _ => Err(CasError::InvalidFileConflictStatus(value.to_owned())), + } + } + + #[must_use] + pub fn as_str(&self) -> &'static str { + match self { + Self::Open => "open", + Self::Resolved => "resolved", + } + } +} + impl LocalCas { #[must_use] pub fn new(root: impl Into) -> Self { diff --git a/crates/geth-cli/Cargo.toml b/crates/geth-cli/Cargo.toml index 1fadfe9..c2a0abf 100644 --- a/crates/geth-cli/Cargo.toml +++ b/crates/geth-cli/Cargo.toml @@ -12,4 +12,5 @@ serde_json.workspace = true tokio.workspace = true geth-config = { path = "../geth-config" } geth-control = { path = "../geth-control" } +geth-cas = { path = "../geth-cas" } geth-node = { path = "../geth-node" } diff --git a/crates/geth-cli/src/lib.rs b/crates/geth-cli/src/lib.rs index 9ce78d6..84248ed 100644 --- a/crates/geth-cli/src/lib.rs +++ b/crates/geth-cli/src/lib.rs @@ -220,6 +220,10 @@ pub enum CasCommand { #[command(subcommand)] command: CasRootCommand, }, + Conflict { + #[command(subcommand)] + command: CasConflictCommand, + }, } #[derive(Debug, Subcommand)] @@ -229,6 +233,33 @@ pub enum CasRootCommand { Scan { name: String }, } +#[derive(Debug, Subcommand)] +pub enum CasConflictCommand { + Record { + root: String, + path: String, + kind: String, + #[arg(long)] + detail: String, + #[arg(long)] + base_tree: Option, + #[arg(long)] + local_tree: Option, + #[arg(long)] + remote_tree: Option, + }, + List { + #[arg(long)] + root: Option, + }, + Resolve { + conflict_id: String, + resolution: String, + #[arg(long)] + note: Option, + }, +} + #[derive(Debug, Subcommand)] pub enum KvCommand { Create { @@ -480,6 +511,35 @@ fn request_for_command(command: Command) -> Result { CasRootCommand::List => ControlRequest::CasRootList, CasRootCommand::Scan { name } => ControlRequest::CasRootScan { name }, }, + CasCommand::Conflict { command } => match command { + CasConflictCommand::Record { + root, + path, + kind, + detail, + base_tree, + local_tree, + remote_tree, + } => ControlRequest::CasConflictRecord { + root, + path, + kind, + detail, + base_tree: base_tree.map(Into::into), + local_tree: local_tree.map(Into::into), + remote_tree: remote_tree.map(Into::into), + }, + CasConflictCommand::List { root } => ControlRequest::CasConflictList { root }, + CasConflictCommand::Resolve { + conflict_id, + resolution, + note, + } => ControlRequest::CasConflictResolve { + conflict_id, + resolution, + note, + }, + }, }, Command::Kv { command } => match command { KvCommand::Create { name } => ControlRequest::KvCreate { name }, @@ -744,6 +804,30 @@ fn print_response(response: ControlResponse, json: bool) -> Result<()> { } println!("note: {}", scan.note); } + ControlResponse::CasConflictRecorded { conflict } => { + println!("recorded conflict: {}", conflict.id); + print_file_conflict(&conflict); + } + ControlResponse::CasConflictList { conflicts } => { + if conflicts.is_empty() { + println!("no file conflicts"); + } else { + for conflict in conflicts { + println!( + "{}\t{}\t{}\t{}\t{}", + conflict.id, + conflict.root, + conflict.path, + conflict.kind.as_str(), + conflict.status.as_str() + ); + } + } + } + ControlResponse::CasConflictResolved { conflict } => { + println!("resolved conflict: {}", conflict.id); + print_file_conflict(&conflict); + } ControlResponse::KeychainStatus(status) => { println!("initialized: {}", status.initialized); println!("admin_keys: {}", status.admin_keys); @@ -1091,6 +1175,30 @@ fn print_service_report(report: ServiceReport, json: bool) -> Result<()> { Ok(()) } +fn print_file_conflict(conflict: &geth_cas::FileConflict) { + println!("root: {}", conflict.root); + println!("resource: {}", conflict.resource); + println!("path: {}", conflict.path); + println!("kind: {}", conflict.kind.as_str()); + println!("status: {}", conflict.status.as_str()); + if let Some(hash) = &conflict.base_tree { + println!("base_tree: {hash}"); + } + if let Some(hash) = &conflict.local_tree { + println!("local_tree: {hash}"); + } + if let Some(hash) = &conflict.remote_tree { + println!("remote_tree: {hash}"); + } + println!("detail: {}", conflict.detail); + if let Some(resolution) = &conflict.resolution { + println!("resolution: {}", resolution.as_str()); + } + if let Some(note) = &conflict.resolution_note { + println!("resolution_note: {note}"); + } +} + fn shell_quote_command(command: &[String]) -> String { command .iter() diff --git a/crates/geth-control/src/lib.rs b/crates/geth-control/src/lib.rs index 57baa7f..ea1c79d 100644 --- a/crates/geth-control/src/lib.rs +++ b/crates/geth-control/src/lib.rs @@ -1,5 +1,5 @@ use geth_auth::{AuthExplanation, AuthOp}; -use geth_cas::{FileRoot, FileRootScan}; +use geth_cas::{FileConflict, FileRoot, FileRootScan}; use geth_db::{CrSqliteChangeBatch, DbResource}; use geth_document::{DocumentResource, DocumentState}; use geth_keychain::KeychainOp; @@ -56,6 +56,23 @@ pub enum ControlRequest { CasRootScan { name: String, }, + CasConflictRecord { + root: String, + path: String, + kind: String, + detail: String, + base_tree: Option, + local_tree: Option, + remote_tree: Option, + }, + CasConflictList { + root: Option, + }, + CasConflictResolve { + conflict_id: String, + resolution: String, + note: Option, + }, KeychainInit { admin_key_path: Option, }, @@ -227,6 +244,15 @@ pub enum ControlResponse { CasRootScanned { scan: FileRootScan, }, + CasConflictRecorded { + conflict: FileConflict, + }, + CasConflictList { + conflicts: Vec, + }, + CasConflictResolved { + conflict: FileConflict, + }, KeychainStatus(KeychainStatusResponse), KeychainInitialized { ops: Vec, @@ -472,5 +498,15 @@ mod tests { decode_request(&encode_request(&request).expect("encode")).expect("decode"), request ); + + let request = ControlRequest::CasConflictResolve { + conflict_id: "file-conflict:notes:1".to_owned(), + resolution: "keep-local".to_owned(), + note: Some("local file is authoritative".to_owned()), + }; + assert_eq!( + decode_request(&encode_request(&request).expect("encode")).expect("decode"), + request + ); } } diff --git a/crates/geth-node/src/lib.rs b/crates/geth-node/src/lib.rs index 0c17934..fe9eee3 100644 --- a/crates/geth-node/src/lib.rs +++ b/crates/geth-node/src/lib.rs @@ -1,7 +1,10 @@ pub mod service; use geth_auth::{AuthExplanation, AuthOp, AuthOpKind}; -use geth_cas::{BlobInfoSummary, FileRoot, FileRootScan, LocalCas, hash_path}; +use geth_cas::{ + BlobInfoSummary, FileConflict, FileConflictKind, FileConflictResolution, FileConflictStatus, + FileRoot, FileRootScan, LocalCas, hash_path, +}; use geth_config::{GethConfig, GethPaths, RelayMode}; use geth_control::{ CasBlob, ControlRequest, ControlResponse, KeychainStatusResponse, NodeIdResponse, @@ -23,9 +26,9 @@ use geth_ssh_identity::{ cert_request_id, certificate_id, openssh_krl_spec, revocation_id, ssh_public_key_fingerprint, }; use geth_store::{ - Store, StoredAuthOp, StoredDbResource, StoredDocumentResource, StoredFileRoot, - StoredKeychainOp, StoredKvEntry, StoredKvStore, StoredResource, StoredResourceSecret, - StoredSshCertRequest, StoredSshCertificate, StoredSshRevocation, + Store, StoredAuthOp, StoredDbResource, StoredDocumentResource, StoredFileConflict, + StoredFileRoot, StoredKeychainOp, StoredKvEntry, StoredKvStore, StoredResource, + StoredResourceSecret, StoredSshCertRequest, StoredSshCertificate, StoredSshRevocation, }; use geth_types::{ AuthOpId, Capability, KeyId, NodeId, PrincipalId, ResourceId, ResourceKind, ResourceName, @@ -414,6 +417,81 @@ pub fn handle_request( }, }) } + ControlRequest::CasConflictRecord { + root, + path, + kind, + detail, + base_tree, + local_tree, + remote_tree, + } => { + geth_cas::validate_file_root_name(&root)?; + if let Some(hash) = &base_tree { + geth_cas::validate_hash(hash)?; + } + if let Some(hash) = &local_tree { + geth_cas::validate_hash(hash)?; + } + if let Some(hash) = &remote_tree { + geth_cas::validate_hash(hash)?; + } + let root_record = store + .get_file_root_by_name(&root)? + .ok_or_else(|| NodeError::ResourceNotFound(format!("file-root:{root}")))?; + let kind = FileConflictKind::parse(&kind)?; + let created_at = geth_store::now_ms(); + let conflict_id = generated_file_conflict_id(&root, &path, kind.as_str(), created_at); + let conflict = StoredFileConflict { + conflict_id, + root_name: root, + resource_id: root_record.resource_id, + path, + kind: kind.as_str().to_owned(), + status: FileConflictStatus::Open.as_str().to_owned(), + base_tree_hash: base_tree.map(|hash| hash.to_string()), + local_tree_hash: local_tree.map(|hash| hash.to_string()), + remote_tree_hash: remote_tree.map(|hash| hash.to_string()), + detail, + resolution: None, + resolution_note: None, + created_at_ms: created_at, + resolved_at_ms: None, + }; + store.upsert_file_conflict(&conflict)?; + Ok(ControlResponse::CasConflictRecorded { + conflict: file_conflict_from_stored(conflict)?, + }) + } + ControlRequest::CasConflictList { root } => { + if let Some(root) = root.as_deref() { + geth_cas::validate_file_root_name(root)?; + } + let conflicts = store + .list_file_conflicts(root.as_deref())? + .into_iter() + .map(file_conflict_from_stored) + .collect::, _>>()?; + Ok(ControlResponse::CasConflictList { conflicts }) + } + ControlRequest::CasConflictResolve { + conflict_id, + resolution, + note, + } => { + let resolution = FileConflictResolution::parse(&resolution)?; + let mut conflict = store + .get_file_conflict(&conflict_id)? + .ok_or_else(|| NodeError::ResourceNotFound(conflict_id.clone()))?; + conflict.status = FileConflictStatus::Resolved.as_str().to_owned(); + conflict.resolution = Some(resolution.as_str().to_owned()); + conflict.resolution_note = note; + conflict.resolved_at_ms = Some(geth_store::now_ms()); + store.upsert_file_conflict(&conflict)?; + Ok(ControlResponse::CasConflictResolved { + conflict: file_conflict_from_stored(conflict)?, + }) + } ControlRequest::KeychainInit { admin_key_path } => { let mut ops = Vec::new(); let created_at = UnixMillis(geth_store::now_ms()); @@ -1136,6 +1214,29 @@ fn file_root_from_stored(stored: &StoredFileRoot) -> FileRoot { } } +fn file_conflict_from_stored(stored: StoredFileConflict) -> Result { + Ok(FileConflict { + id: stored.conflict_id, + root: stored.root_name, + resource: stored.resource_id, + path: stored.path, + kind: FileConflictKind::parse(&stored.kind)?, + status: FileConflictStatus::parse(&stored.status)?, + base_tree: stored.base_tree_hash.map(Into::into), + local_tree: stored.local_tree_hash.map(Into::into), + remote_tree: stored.remote_tree_hash.map(Into::into), + detail: stored.detail, + resolution: stored + .resolution + .as_deref() + .map(FileConflictResolution::parse) + .transpose()?, + resolution_note: stored.resolution_note, + created_at_ms: stored.created_at_ms, + resolved_at_ms: stored.resolved_at_ms, + }) +} + fn ensure_resource_exists(store: &Store, resource_id: &str) -> Result<(), NodeError> { if store .list_resources()? @@ -1240,6 +1341,13 @@ fn generated_grant_id(subject: &str, resource: &str, capability: &str) -> String ) } +fn generated_file_conflict_id(root: &str, path: &str, kind: &str, created_at_ms: i64) -> String { + format!( + "file-conflict:{}", + geth_crypto::blake3_hex(format!("{created_at_ms}\0{root}\0{path}\0{kind}").as_bytes()) + ) +} + fn generated_auth_op_id( kind: &str, resource: &str, diff --git a/crates/geth-store/src/lib.rs b/crates/geth-store/src/lib.rs index 184c169..935d338 100644 --- a/crates/geth-store/src/lib.rs +++ b/crates/geth-store/src/lib.rs @@ -133,6 +133,22 @@ impl Store { latest_tree_json TEXT, updated_at_ms INTEGER NOT NULL ); + CREATE TABLE IF NOT EXISTS file_conflicts ( + conflict_id TEXT PRIMARY KEY, + root_name TEXT NOT NULL, + resource_id TEXT NOT NULL, + path TEXT NOT NULL, + kind TEXT NOT NULL, + status TEXT NOT NULL, + base_tree_hash TEXT, + local_tree_hash TEXT, + remote_tree_hash TEXT, + detail TEXT NOT NULL, + resolution TEXT, + resolution_note TEXT, + created_at_ms INTEGER NOT NULL, + resolved_at_ms INTEGER + ); CREATE TABLE IF NOT EXISTS peer_cards ( peer_id TEXT PRIMARY KEY, card_json TEXT NOT NULL, @@ -450,6 +466,78 @@ impl Store { .map_err(StoreError::from) } + pub fn upsert_file_conflict(&self, conflict: &StoredFileConflict) -> Result<(), StoreError> { + self.conn.execute( + r#"INSERT OR REPLACE INTO file_conflicts( + conflict_id, root_name, resource_id, path, kind, status, base_tree_hash, + local_tree_hash, remote_tree_hash, detail, resolution, resolution_note, + created_at_ms, resolved_at_ms + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14)"#, + params![ + conflict.conflict_id, + conflict.root_name, + conflict.resource_id, + conflict.path, + conflict.kind, + conflict.status, + conflict.base_tree_hash, + conflict.local_tree_hash, + conflict.remote_tree_hash, + conflict.detail, + conflict.resolution, + conflict.resolution_note, + conflict.created_at_ms, + conflict.resolved_at_ms + ], + )?; + Ok(()) + } + + pub fn get_file_conflict( + &self, + conflict_id: &str, + ) -> Result, StoreError> { + let mut stmt = self.conn.prepare( + r#"SELECT conflict_id, root_name, resource_id, path, kind, status, base_tree_hash, + local_tree_hash, remote_tree_hash, detail, resolution, resolution_note, + created_at_ms, resolved_at_ms + FROM file_conflicts WHERE conflict_id = ?1"#, + )?; + let mut rows = stmt.query(params![conflict_id])?; + if let Some(row) = rows.next()? { + Ok(Some(stored_file_conflict_from_row(row)?)) + } else { + Ok(None) + } + } + + pub fn list_file_conflicts( + &self, + root_name: Option<&str>, + ) -> Result, StoreError> { + let sql = if root_name.is_some() { + r#"SELECT conflict_id, root_name, resource_id, path, kind, status, base_tree_hash, + local_tree_hash, remote_tree_hash, detail, resolution, resolution_note, + created_at_ms, resolved_at_ms + FROM file_conflicts WHERE root_name = ?1 + ORDER BY status, created_at_ms, conflict_id"# + } else { + r#"SELECT conflict_id, root_name, resource_id, path, kind, status, base_tree_hash, + local_tree_hash, remote_tree_hash, detail, resolution, resolution_note, + created_at_ms, resolved_at_ms + FROM file_conflicts + ORDER BY status, root_name, created_at_ms, conflict_id"# + }; + let mut stmt = self.conn.prepare(sql)?; + let rows = if let Some(root_name) = root_name { + stmt.query_map(params![root_name], stored_file_conflict_from_row)? + } else { + stmt.query_map([], stored_file_conflict_from_row)? + }; + rows.collect::, _>>() + .map_err(StoreError::from) + } + pub fn insert_resource_secret(&self, secret: &StoredResourceSecret) -> Result<(), StoreError> { self.conn.execute( r#"INSERT OR REPLACE INTO resource_secrets( @@ -857,6 +945,27 @@ fn stored_ssh_cert_request_from_row( }) } +fn stored_file_conflict_from_row( + row: &rusqlite::Row<'_>, +) -> Result { + Ok(StoredFileConflict { + conflict_id: row.get(0)?, + root_name: row.get(1)?, + resource_id: row.get(2)?, + path: row.get(3)?, + kind: row.get(4)?, + status: row.get(5)?, + base_tree_hash: row.get(6)?, + local_tree_hash: row.get(7)?, + remote_tree_hash: row.get(8)?, + detail: row.get(9)?, + resolution: row.get(10)?, + resolution_note: row.get(11)?, + created_at_ms: row.get(12)?, + resolved_at_ms: row.get(13)?, + }) +} + #[derive(Clone, Debug, PartialEq, Eq)] pub struct StoredResource { pub resource_id: String, @@ -908,6 +1017,24 @@ pub struct StoredFileRoot { pub updated_at_ms: i64, } +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct StoredFileConflict { + pub conflict_id: String, + pub root_name: String, + pub resource_id: String, + pub path: String, + pub kind: String, + pub status: String, + pub base_tree_hash: Option, + pub local_tree_hash: Option, + pub remote_tree_hash: Option, + pub detail: String, + pub resolution: Option, + pub resolution_note: Option, + pub created_at_ms: i64, + pub resolved_at_ms: Option, +} + #[derive(Clone, Debug, PartialEq, Eq)] pub struct StoredResourceSecret { pub secret_id: String, @@ -1204,6 +1331,62 @@ mod tests { assert_eq!(store.list_file_roots().expect("list roots"), vec![root]); } + #[test] + fn file_conflicts_roundtrip_and_resolve() { + let store = Store::open_memory().expect("open"); + let conflict = StoredFileConflict { + conflict_id: "file-conflict:1".to_owned(), + root_name: "notes".to_owned(), + resource_id: "resource:cas-tree:notes".to_owned(), + path: "notes/todo.md".to_owned(), + kind: "concurrent-edit".to_owned(), + status: "open".to_owned(), + base_tree_hash: None, + local_tree_hash: Some( + "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef".to_owned(), + ), + remote_tree_hash: Some( + "fedcba9876543210fedcba9876543210fedcba9876543210fedcba9876543210".to_owned(), + ), + detail: "local and remote edits touched the same path".to_owned(), + resolution: None, + resolution_note: None, + created_at_ms: 10, + resolved_at_ms: None, + }; + store + .upsert_file_conflict(&conflict) + .expect("insert conflict"); + assert_eq!( + store + .get_file_conflict("file-conflict:1") + .expect("get conflict"), + Some(conflict.clone()) + ); + assert_eq!( + store + .list_file_conflicts(Some("notes")) + .expect("list by root"), + vec![conflict.clone()] + ); + + let resolved = StoredFileConflict { + status: "resolved".to_owned(), + resolution: Some("keep-local".to_owned()), + resolution_note: Some("local file is authoritative".to_owned()), + resolved_at_ms: Some(20), + ..conflict + }; + store + .upsert_file_conflict(&resolved) + .expect("resolve conflict"); + + assert_eq!( + store.list_file_conflicts(None).expect("list all"), + vec![resolved] + ); + } + #[test] fn kv_store_and_entries_roundtrip() { let store = Store::open_memory().expect("open"); diff --git a/crates/geth/Cargo.toml b/crates/geth/Cargo.toml index 1a0b8b8..9450eaa 100644 --- a/crates/geth/Cargo.toml +++ b/crates/geth/Cargo.toml @@ -21,5 +21,6 @@ geth-config = { path = "../geth-config" } geth-control = { path = "../geth-control" } geth-node = { path = "../geth-node" } geth-store = { path = "../geth-store" } +geth-types = { path = "../geth-types" } rusqlite.workspace = true tempfile.workspace = true diff --git a/crates/geth/tests/bootstrap.rs b/crates/geth/tests/bootstrap.rs index cca3051..0f74778 100644 --- a/crates/geth/tests/bootstrap.rs +++ b/crates/geth/tests/bootstrap.rs @@ -247,6 +247,106 @@ fn cas_file_root_add_list_scan_detects_changes() { } } +#[test] +fn cas_file_conflict_record_list_and_resolve() { + let home = tempfile::tempdir().expect("tempdir"); + let paths = geth_config::GethPaths::from_home(home.path()); + let node = geth_node::init_node(&paths).expect("init node"); + let root_path = home.path().join("notes-root"); + std::fs::create_dir_all(&root_path).expect("create root"); + std::fs::write(root_path.join("todo.md"), b"local").expect("write file"); + + geth_node::handle_request( + &node, + geth_control::ControlRequest::CasRootAdd { + name: "notes".to_owned(), + path: root_path, + }, + ) + .expect("add root"); + let local_tree = match geth_node::handle_request( + &node, + geth_control::ControlRequest::CasRootScan { + name: "notes".to_owned(), + }, + ) + .expect("scan root") + { + geth_control::ControlResponse::CasRootScanned { scan } => scan.tree.hash, + other => panic!("unexpected response: {other:?}"), + }; + let remote_tree = geth_types::BlobHash::new( + "fedcba9876543210fedcba9876543210fedcba9876543210fedcba9876543210", + ); + + let response = geth_node::handle_request( + &node, + geth_control::ControlRequest::CasConflictRecord { + root: "notes".to_owned(), + path: "todo.md".to_owned(), + kind: "concurrent-edit".to_owned(), + detail: "local and remote edits touched todo.md".to_owned(), + base_tree: None, + local_tree: Some(local_tree.clone()), + remote_tree: Some(remote_tree.clone()), + }, + ) + .expect("record conflict"); + let conflict_id = match response { + geth_control::ControlResponse::CasConflictRecorded { conflict } => { + assert_eq!(conflict.root, "notes"); + assert_eq!(conflict.path, "todo.md"); + assert_eq!(conflict.kind, geth_cas::FileConflictKind::ConcurrentEdit); + assert_eq!(conflict.status, geth_cas::FileConflictStatus::Open); + assert_eq!(conflict.local_tree, Some(local_tree)); + assert_eq!(conflict.remote_tree, Some(remote_tree)); + conflict.id + } + other => panic!("unexpected response: {other:?}"), + }; + + let response = geth_node::handle_request( + &node, + geth_control::ControlRequest::CasConflictList { + root: Some("notes".to_owned()), + }, + ) + .expect("list conflicts"); + match response { + geth_control::ControlResponse::CasConflictList { conflicts } => { + assert_eq!(conflicts.len(), 1); + assert_eq!(conflicts[0].id, conflict_id); + } + other => panic!("unexpected response: {other:?}"), + } + + let response = geth_node::handle_request( + &node, + geth_control::ControlRequest::CasConflictResolve { + conflict_id: conflict_id.clone(), + resolution: "keep-local".to_owned(), + note: Some("local file is authoritative".to_owned()), + }, + ) + .expect("resolve conflict"); + match response { + geth_control::ControlResponse::CasConflictResolved { conflict } => { + assert_eq!(conflict.id, conflict_id); + assert_eq!(conflict.status, geth_cas::FileConflictStatus::Resolved); + assert_eq!( + conflict.resolution, + Some(geth_cas::FileConflictResolution::KeepLocal) + ); + assert_eq!( + conflict.resolution_note, + Some("local file is authoritative".to_owned()) + ); + assert!(conflict.resolved_at_ms.is_some()); + } + other => panic!("unexpected response: {other:?}"), + } +} + #[test] fn cas_cleanup_removes_unpinned_and_retains_pinned_blobs() { let home = tempfile::tempdir().expect("tempdir"); diff --git a/docs/architecture.md b/docs/architecture.md index 89977d9..cc54b65 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -94,9 +94,12 @@ blobs. The CAS crate can build deterministic tree objects that describe directories, files, executable bits, and file blob hashes; those tree objects are stored as CAS blobs. The daemon can register local file roots and scan them into CAS tree objects while reporting create/update/delete/rename changes. These -scans are local metadata only and never overwrite the working tree. Iroh-blobs, -providers, encrypted blobs, richer cache policies, cross-node file roots, and -sync conflict handling are future work. +scans are local metadata only and never overwrite the working tree. The daemon +also has durable local file-conflict records with explicit resolution choices; +future cross-node file sync will create those records automatically instead of +silently applying ambiguous remote changes. Iroh-blobs, providers, encrypted +blobs, richer cache policies, cross-node file roots, and automatic conflict +detection are future work. `geth-db` currently registers local SQLite paths as DB resources and reports local-only sync status plus a read-only SQLite schema summary/hash. It also diff --git a/docs/roadmap.md b/docs/roadmap.md index a346db7..1926dc3 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -361,11 +361,16 @@ and future group key evolution. recorded future sync decision. - `[ ]` File roots can be synced across nodes. -- `[ ]` Conflict handling. +- `[~]` Conflict handling. Acceptance criteria: - - Conflicts are represented as durable metadata. - - CLI can list conflicts and choose a resolution. - - Tests cover concurrent edit, delete/edit, and rename conflicts. + - `[x]` Conflicts are represented as durable metadata. + - `[x]` CLI/control can record, list, and choose a resolution for local + conflict metadata. + - `[x]` Tests cover local concurrent edit conflict record/list/resolve. + - `[ ]` Future sync records conflicts automatically from base/local/remote + tree comparisons. + - `[ ]` Tests cover automatic concurrent edit, delete/edit, and rename + conflict detection. - `[ ]` Keyhive-like convergent capabilities. Acceptance criteria: