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
|
- 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
2
Cargo.lock
generated
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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" }
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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");
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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");
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue