Add local file conflict metadata
This commit is contained in:
parent
e1f36ffafb
commit
20c88af800
13 changed files with 673 additions and 15 deletions
|
|
@ -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`
|
||||
|
|
|
|||
2
Cargo.lock
generated
2
Cargo.lock
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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 <name> <path>` and
|
||||
`geth db status <name>` with schema and `crsql_changes` metadata; the DB
|
||||
crate and daemon can extract typed local `crsql_changes` batches through
|
||||
|
|
|
|||
|
|
@ -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<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 {
|
||||
#[must_use]
|
||||
pub fn new(root: impl Into<PathBuf>) -> Self {
|
||||
|
|
|
|||
|
|
@ -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" }
|
||||
|
|
|
|||
|
|
@ -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<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)]
|
||||
pub enum KvCommand {
|
||||
Create {
|
||||
|
|
@ -480,6 +511,35 @@ fn request_for_command(command: Command) -> Result<ControlRequest> {
|
|||
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()
|
||||
|
|
|
|||
|
|
@ -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<BlobHash>,
|
||||
local_tree: Option<BlobHash>,
|
||||
remote_tree: Option<BlobHash>,
|
||||
},
|
||||
CasConflictList {
|
||||
root: Option<String>,
|
||||
},
|
||||
CasConflictResolve {
|
||||
conflict_id: String,
|
||||
resolution: String,
|
||||
note: Option<String>,
|
||||
},
|
||||
KeychainInit {
|
||||
admin_key_path: Option<PathBuf>,
|
||||
},
|
||||
|
|
@ -227,6 +244,15 @@ pub enum ControlResponse {
|
|||
CasRootScanned {
|
||||
scan: FileRootScan,
|
||||
},
|
||||
CasConflictRecorded {
|
||||
conflict: FileConflict,
|
||||
},
|
||||
CasConflictList {
|
||||
conflicts: Vec<FileConflict>,
|
||||
},
|
||||
CasConflictResolved {
|
||||
conflict: FileConflict,
|
||||
},
|
||||
KeychainStatus(KeychainStatusResponse),
|
||||
KeychainInitialized {
|
||||
ops: Vec<KeychainOp>,
|
||||
|
|
@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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::<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 } => {
|
||||
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<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> {
|
||||
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,
|
||||
|
|
|
|||
|
|
@ -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<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> {
|
||||
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<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)]
|
||||
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<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)]
|
||||
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");
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
Loading…
Reference in a new issue