Add local file conflict metadata

This commit is contained in:
Eric Wendland 2026-05-18 03:57:26 +02:00
commit 20c88af800
13 changed files with 673 additions and 15 deletions

View file

@ -121,8 +121,9 @@ Roadmap items should be actionable and checkable:
- The CAS crate can build deterministic tree objects for local file trees and - 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 store those manifests as CAS blobs. The daemon can register and scan local
file roots, reporting create/update/delete/rename changes without writing back file roots, reporting create/update/delete/rename changes without writing back
to the working tree. Cross-node file sync and conflict handling are still to the working tree. Durable local file-conflict records can be listed and
roadmap work. 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 - DB resources can be registered locally and report local-only status plus a
read-only SQLite schema summary/hash and `crsql_changes` metadata when read-only SQLite schema summary/hash and `crsql_changes` metadata when
present. The DB crate and daemon can extract typed read-only `crsql_changes` present. The DB crate and daemon can extract typed read-only `crsql_changes`

2
Cargo.lock generated
View file

@ -1047,6 +1047,7 @@ dependencies = [
"geth-control", "geth-control",
"geth-node", "geth-node",
"geth-store", "geth-store",
"geth-types",
"rusqlite", "rusqlite",
"tempfile", "tempfile",
"tokio", "tokio",
@ -1083,6 +1084,7 @@ version = "0.1.0"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"clap", "clap",
"geth-cas",
"geth-config", "geth-config",
"geth-control", "geth-control",
"geth-node", "geth-node",

View file

@ -89,6 +89,8 @@ The bootstrap implementation provides:
`cleanup`, `list` `cleanup`, `list`
- local CAS tree objects describe file trees and are stored as CAS blobs - 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-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 <name> <path>` and - local DB resource registration: `geth db add <name> <path>` and
`geth db status <name>` with schema and `crsql_changes` metadata; the DB `geth db status <name>` with schema and `crsql_changes` metadata; the DB
crate and daemon can extract typed local `crsql_changes` batches through crate and daemon can extract typed local `crsql_changes` batches through

View file

@ -21,6 +21,12 @@ pub enum CasError {
NonRelativeTreePath(String), NonRelativeTreePath(String),
#[error("invalid file root name: {0}")] #[error("invalid file root name: {0}")]
InvalidFileRootName(String), 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)] #[derive(Clone, Debug)]
@ -96,6 +102,108 @@ pub enum FileRootChange {
Renamed { from: String, to: String }, 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<BlobHash>,
pub local_tree: Option<BlobHash>,
pub remote_tree: Option<BlobHash>,
pub detail: String,
pub resolution: Option<FileConflictResolution>,
pub resolution_note: Option<String>,
pub created_at_ms: i64,
pub resolved_at_ms: Option<i64>,
}
#[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<Self, CasError> {
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<Self, CasError> {
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<Self, CasError> {
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 { impl LocalCas {
#[must_use] #[must_use]
pub fn new(root: impl Into<PathBuf>) -> Self { pub fn new(root: impl Into<PathBuf>) -> Self {

View file

@ -12,4 +12,5 @@ serde_json.workspace = true
tokio.workspace = true tokio.workspace = true
geth-config = { path = "../geth-config" } geth-config = { path = "../geth-config" }
geth-control = { path = "../geth-control" } geth-control = { path = "../geth-control" }
geth-cas = { path = "../geth-cas" }
geth-node = { path = "../geth-node" } geth-node = { path = "../geth-node" }

View file

@ -220,6 +220,10 @@ pub enum CasCommand {
#[command(subcommand)] #[command(subcommand)]
command: CasRootCommand, command: CasRootCommand,
}, },
Conflict {
#[command(subcommand)]
command: CasConflictCommand,
},
} }
#[derive(Debug, Subcommand)] #[derive(Debug, Subcommand)]
@ -229,6 +233,33 @@ pub enum CasRootCommand {
Scan { name: String }, 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<String>,
#[arg(long)]
local_tree: Option<String>,
#[arg(long)]
remote_tree: Option<String>,
},
List {
#[arg(long)]
root: Option<String>,
},
Resolve {
conflict_id: String,
resolution: String,
#[arg(long)]
note: Option<String>,
},
}
#[derive(Debug, Subcommand)] #[derive(Debug, Subcommand)]
pub enum KvCommand { pub enum KvCommand {
Create { Create {
@ -480,6 +511,35 @@ fn request_for_command(command: Command) -> Result<ControlRequest> {
CasRootCommand::List => ControlRequest::CasRootList, CasRootCommand::List => ControlRequest::CasRootList,
CasRootCommand::Scan { name } => ControlRequest::CasRootScan { name }, 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 { Command::Kv { command } => match command {
KvCommand::Create { name } => ControlRequest::KvCreate { name }, KvCommand::Create { name } => ControlRequest::KvCreate { name },
@ -744,6 +804,30 @@ fn print_response(response: ControlResponse, json: bool) -> Result<()> {
} }
println!("note: {}", scan.note); 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) => { ControlResponse::KeychainStatus(status) => {
println!("initialized: {}", status.initialized); println!("initialized: {}", status.initialized);
println!("admin_keys: {}", status.admin_keys); println!("admin_keys: {}", status.admin_keys);
@ -1091,6 +1175,30 @@ fn print_service_report(report: ServiceReport, json: bool) -> Result<()> {
Ok(()) 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 { fn shell_quote_command(command: &[String]) -> String {
command command
.iter() .iter()

View file

@ -1,5 +1,5 @@
use geth_auth::{AuthExplanation, AuthOp}; use geth_auth::{AuthExplanation, AuthOp};
use geth_cas::{FileRoot, FileRootScan}; use geth_cas::{FileConflict, FileRoot, FileRootScan};
use geth_db::{CrSqliteChangeBatch, DbResource}; use geth_db::{CrSqliteChangeBatch, DbResource};
use geth_document::{DocumentResource, DocumentState}; use geth_document::{DocumentResource, DocumentState};
use geth_keychain::KeychainOp; use geth_keychain::KeychainOp;
@ -56,6 +56,23 @@ pub enum ControlRequest {
CasRootScan { CasRootScan {
name: String, name: String,
}, },
CasConflictRecord {
root: String,
path: String,
kind: String,
detail: String,
base_tree: Option<BlobHash>,
local_tree: Option<BlobHash>,
remote_tree: Option<BlobHash>,
},
CasConflictList {
root: Option<String>,
},
CasConflictResolve {
conflict_id: String,
resolution: String,
note: Option<String>,
},
KeychainInit { KeychainInit {
admin_key_path: Option<PathBuf>, admin_key_path: Option<PathBuf>,
}, },
@ -227,6 +244,15 @@ pub enum ControlResponse {
CasRootScanned { CasRootScanned {
scan: FileRootScan, scan: FileRootScan,
}, },
CasConflictRecorded {
conflict: FileConflict,
},
CasConflictList {
conflicts: Vec<FileConflict>,
},
CasConflictResolved {
conflict: FileConflict,
},
KeychainStatus(KeychainStatusResponse), KeychainStatus(KeychainStatusResponse),
KeychainInitialized { KeychainInitialized {
ops: Vec<KeychainOp>, ops: Vec<KeychainOp>,
@ -472,5 +498,15 @@ mod tests {
decode_request(&encode_request(&request).expect("encode")).expect("decode"), decode_request(&encode_request(&request).expect("encode")).expect("decode"),
request 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
);
} }
} }

View file

@ -1,7 +1,10 @@
pub mod service; pub mod service;
use geth_auth::{AuthExplanation, AuthOp, AuthOpKind}; 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_config::{GethConfig, GethPaths, RelayMode};
use geth_control::{ use geth_control::{
CasBlob, ControlRequest, ControlResponse, KeychainStatusResponse, NodeIdResponse, 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, cert_request_id, certificate_id, openssh_krl_spec, revocation_id, ssh_public_key_fingerprint,
}; };
use geth_store::{ use geth_store::{
Store, StoredAuthOp, StoredDbResource, StoredDocumentResource, StoredFileRoot, Store, StoredAuthOp, StoredDbResource, StoredDocumentResource, StoredFileConflict,
StoredKeychainOp, StoredKvEntry, StoredKvStore, StoredResource, StoredResourceSecret, StoredFileRoot, StoredKeychainOp, StoredKvEntry, StoredKvStore, StoredResource,
StoredSshCertRequest, StoredSshCertificate, StoredSshRevocation, StoredResourceSecret, StoredSshCertRequest, StoredSshCertificate, StoredSshRevocation,
}; };
use geth_types::{ use geth_types::{
AuthOpId, Capability, KeyId, NodeId, PrincipalId, ResourceId, ResourceKind, ResourceName, 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::<Result<Vec<_>, _>>()?;
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 } => { ControlRequest::KeychainInit { admin_key_path } => {
let mut ops = Vec::new(); let mut ops = Vec::new();
let created_at = UnixMillis(geth_store::now_ms()); 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<FileConflict, NodeError> {
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> { fn ensure_resource_exists(store: &Store, resource_id: &str) -> Result<(), NodeError> {
if store if store
.list_resources()? .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( fn generated_auth_op_id(
kind: &str, kind: &str,
resource: &str, resource: &str,

View file

@ -133,6 +133,22 @@ impl Store {
latest_tree_json TEXT, latest_tree_json TEXT,
updated_at_ms INTEGER NOT NULL 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 ( CREATE TABLE IF NOT EXISTS peer_cards (
peer_id TEXT PRIMARY KEY, peer_id TEXT PRIMARY KEY,
card_json TEXT NOT NULL, card_json TEXT NOT NULL,
@ -450,6 +466,78 @@ impl Store {
.map_err(StoreError::from) .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<Option<StoredFileConflict>, 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<Vec<StoredFileConflict>, 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::<Result<Vec<_>, _>>()
.map_err(StoreError::from)
}
pub fn insert_resource_secret(&self, secret: &StoredResourceSecret) -> Result<(), StoreError> { pub fn insert_resource_secret(&self, secret: &StoredResourceSecret) -> Result<(), StoreError> {
self.conn.execute( self.conn.execute(
r#"INSERT OR REPLACE INTO resource_secrets( 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<StoredFileConflict, rusqlite::Error> {
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)] #[derive(Clone, Debug, PartialEq, Eq)]
pub struct StoredResource { pub struct StoredResource {
pub resource_id: String, pub resource_id: String,
@ -908,6 +1017,24 @@ pub struct StoredFileRoot {
pub updated_at_ms: i64, 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<String>,
pub local_tree_hash: Option<String>,
pub remote_tree_hash: Option<String>,
pub detail: String,
pub resolution: Option<String>,
pub resolution_note: Option<String>,
pub created_at_ms: i64,
pub resolved_at_ms: Option<i64>,
}
#[derive(Clone, Debug, PartialEq, Eq)] #[derive(Clone, Debug, PartialEq, Eq)]
pub struct StoredResourceSecret { pub struct StoredResourceSecret {
pub secret_id: String, pub secret_id: String,
@ -1204,6 +1331,62 @@ mod tests {
assert_eq!(store.list_file_roots().expect("list roots"), vec![root]); 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] #[test]
fn kv_store_and_entries_roundtrip() { fn kv_store_and_entries_roundtrip() {
let store = Store::open_memory().expect("open"); let store = Store::open_memory().expect("open");

View file

@ -21,5 +21,6 @@ geth-config = { path = "../geth-config" }
geth-control = { path = "../geth-control" } geth-control = { path = "../geth-control" }
geth-node = { path = "../geth-node" } geth-node = { path = "../geth-node" }
geth-store = { path = "../geth-store" } geth-store = { path = "../geth-store" }
geth-types = { path = "../geth-types" }
rusqlite.workspace = true rusqlite.workspace = true
tempfile.workspace = true tempfile.workspace = true

View file

@ -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] #[test]
fn cas_cleanup_removes_unpinned_and_retains_pinned_blobs() { fn cas_cleanup_removes_unpinned_and_retains_pinned_blobs() {
let home = tempfile::tempdir().expect("tempdir"); let home = tempfile::tempdir().expect("tempdir");

View file

@ -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 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 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 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, scans are local metadata only and never overwrite the working tree. The daemon
providers, encrypted blobs, richer cache policies, cross-node file roots, and also has durable local file-conflict records with explicit resolution choices;
sync conflict handling are future work. 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 `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 local-only sync status plus a read-only SQLite schema summary/hash. It also

View file

@ -361,11 +361,16 @@ and future group key evolution.
recorded future sync decision. recorded future sync decision.
- `[ ]` File roots can be synced across nodes. - `[ ]` File roots can be synced across nodes.
- `[ ]` Conflict handling. - `[~]` Conflict handling.
Acceptance criteria: Acceptance criteria:
- Conflicts are represented as durable metadata. - `[x]` Conflicts are represented as durable metadata.
- CLI can list conflicts and choose a resolution. - `[x]` CLI/control can record, list, and choose a resolution for local
- Tests cover concurrent edit, delete/edit, and rename conflicts. 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. - `[ ]` Keyhive-like convergent capabilities.
Acceptance criteria: Acceptance criteria: