diff --git a/crates/heph-core/src/sqlite/apply.rs b/crates/heph-core/src/sqlite/apply.rs index e4eb9ba..3ab052c 100644 --- a/crates/heph-core/src/sqlite/apply.rs +++ b/crates/heph-core/src/sqlite/apply.rs @@ -22,9 +22,9 @@ use serde_json::Value; use super::{absorb_remote_hlc, new_id, nodes, ops}; use crate::crdt; -use crate::error::Result; +use crate::error::{Error, Result}; use crate::hlc::Hlc; -use crate::model::Conflict; +use crate::model::{Conflict, TaskState}; use crate::oplog::{op_type, Op}; /// Open conflicts for `owner`, newest first. @@ -49,14 +49,71 @@ pub(super) fn list_conflicts(conn: &Connection, owner: &str) -> Result>>()?) } -/// Settle a conflict. v1 records the user's choice by marking it resolved; the -/// LWW winner is already materialized (choosing the loser's value is a -/// follow-up — see [[design]]). -pub(super) fn resolve_conflict(conn: &Connection, id: &str, _choice: &str) -> Result<()> { - conn.execute( +/// Settle a conflict by the user's choice (`"local"`/`"remote"`): the chosen +/// value is **applied** to the task and recorded as a new `task.set` op (so +/// peers converge on the decision), then the row is marked resolved. The LWW +/// winner may have been either side, so the chosen value is written +/// unconditionally — a no-op write when it already matches. +pub(super) fn resolve_conflict( + conn: &mut Connection, + owner: &str, + now: i64, + id: &str, + choice: &str, +) -> Result<()> { + let row: Option<(String, String, Option, Option)> = conn + .query_row( + "SELECT node_id, field, local_val, remote_val + FROM conflicts WHERE id = ?1 AND status = 'open'", + [id], + |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?)), + ) + .optional()?; + let Some((node_id, field, local_val, remote_val)) = row else { + return Err(Error::InvalidArg(format!("no open conflict {id}"))); + }; + let chosen = match choice { + "local" => local_val, + "remote" => remote_val, + other => { + return Err(Error::InvalidArg(format!( + "choice must be \"local\" or \"remote\", got {other:?}" + ))) + } + }; + + let tx = conn.transaction()?; + match field.as_str() { + "do_date" => { + let v: Option = + chosen.as_deref().map(str::parse).transpose().map_err(|_| { + Error::Integrity(format!("conflict {id}: do_date not an integer")) + })?; + tx.execute( + "UPDATE tasks SET do_date = ?1 WHERE node_id = ?2", + (v, &node_id), + )?; + } + "state" => { + let v = chosen.as_deref().unwrap_or("outstanding"); + TaskState::parse(v)?; // validate before writing the raw string + tx.execute( + "UPDATE tasks SET state = ?1 WHERE node_id = ?2", + (v, &node_id), + )?; + } + other => { + return Err(Error::Integrity(format!( + "conflict {id} has unknown field {other:?}" + ))) + } + } + super::tasks::record_set(&tx, owner, now, &node_id)?; + tx.execute( "UPDATE conflicts SET status = 'resolved' WHERE id = ?1", [id], )?; + tx.commit()?; Ok(()) } diff --git a/crates/heph-core/src/sqlite/mod.rs b/crates/heph-core/src/sqlite/mod.rs index acaa0cc..e841d21 100644 --- a/crates/heph-core/src/sqlite/mod.rs +++ b/crates/heph-core/src/sqlite/mod.rs @@ -490,7 +490,9 @@ impl Store for LocalStore { } fn conflicts_resolve(&mut self, id: &str, choice: &str) -> Result<()> { - apply::resolve_conflict(&self.conn, id, choice) + let now = self.clock.now_ms(); + let owner = self.owner_id.clone(); + apply::resolve_conflict(&mut self.conn, &owner, now, id, choice) } } diff --git a/crates/heph-core/src/sqlite/tasks.rs b/crates/heph-core/src/sqlite/tasks.rs index 16f04a9..8fdcd6f 100644 --- a/crates/heph-core/src/sqlite/tasks.rs +++ b/crates/heph-core/src/sqlite/tasks.rs @@ -33,7 +33,7 @@ fn scalar_payload(t: &Task) -> serde_json::Value { /// Bump the task node's hlc/modified_at and record a `task.set` op snapshotting /// the task's current scalars (LWW unit, tech-spec §12). -fn record_set(conn: &Connection, owner: &str, now: i64, node_id: &str) -> Result<()> { +pub(super) fn record_set(conn: &Connection, owner: &str, now: i64, node_id: &str) -> Result<()> { let task = require(conn, node_id)?; let hlc = next_hlc(conn, now)?; conn.execute( diff --git a/crates/heph-core/src/store.rs b/crates/heph-core/src/store.rs index 52928df..e3be18a 100644 --- a/crates/heph-core/src/store.rs +++ b/crates/heph-core/src/store.rs @@ -257,6 +257,8 @@ pub trait Store { /// Open merge conflicts surfaced for the user (`heph conflicts`). fn conflicts_list(&self) -> Result>; - /// Settle a conflict by the user's choice (`"local"`/`"remote"`). + /// Settle a conflict by the user's choice (`"local"`/`"remote"`): the + /// chosen value is applied to the task and recorded as a new op, so peers + /// converge on the decision; the conflict row is marked resolved. fn conflicts_resolve(&mut self, id: &str, choice: &str) -> Result<()>; } diff --git a/crates/heph-core/tests/convergence.rs b/crates/heph-core/tests/convergence.rs index 2bf85dc..41c15dd 100644 --- a/crates/heph-core/tests/convergence.rs +++ b/crates/heph-core/tests/convergence.rs @@ -329,3 +329,72 @@ fn concurrent_tombstone_and_restore_converge_to_the_later_op() { assert!(!c.get_node(&m.id).unwrap().unwrap().tombstoned); assert!(!d.get_node(&m.id).unwrap().unwrap().tombstoned); } + +#[test] +fn resolving_a_conflict_applies_the_chosen_value_and_propagates() { + let (mut a, ca) = replica(1000); + let (mut b, cb) = replica(1000); + let task = a + .create_task(NewTask { + title: "Water plants".into(), + do_date: Some(5_000), + ..Default::default() + }) + .unwrap(); + sync_one_way(&a, &mut b, None); + + // Divergent offline do_dates; B's is later → wins LWW on both sides. + ca.set(2000); + a.set_task_schedule( + &task.node_id, + heph_core::SchedulePatch { + do_date: Some(Some(7_000)), + ..Default::default() + }, + ) + .unwrap(); + cb.set(3000); + b.set_task_schedule( + &task.node_id, + heph_core::SchedulePatch { + do_date: Some(Some(9_000)), + ..Default::default() + }, + ) + .unwrap(); + sync_one_way(&a, &mut b, None); + sync_one_way(&b, &mut a, None); + assert_eq!( + a.get_task(&task.node_id).unwrap().unwrap().do_date, + Some(9_000) + ); + + // A resolves its conflict keeping the LOCAL (losing) value: the choice is + // applied, not just recorded… + let conflict = a + .conflicts_list() + .unwrap() + .into_iter() + .find(|c| c.field == "do_date") + .expect("A recorded a do_date conflict"); + ca.set(4000); + a.conflicts_resolve(&conflict.id, "local").unwrap(); + assert_eq!( + a.get_task(&task.node_id).unwrap().unwrap().do_date, + Some(7_000) + ); + assert!( + a.conflicts_list() + .unwrap() + .iter() + .all(|c| c.id != conflict.id), + "resolved conflict no longer listed" + ); + + // …and the decision is an op, so B converges to it. + sync_one_way(&a, &mut b, None); + assert_eq!( + b.get_task(&task.node_id).unwrap().unwrap().do_date, + Some(7_000) + ); +} diff --git a/crates/heph/src/main.rs b/crates/heph/src/main.rs index 471b2a2..4ec55e4 100644 --- a/crates/heph/src/main.rs +++ b/crates/heph/src/main.rs @@ -281,6 +281,12 @@ enum NodeAction { /// Node id. id: String, }, + /// Restore (un-tombstone) a node — the undo of `rm`. A restored task gets + /// its canonical-context doc back too. + Restore { + /// Node id. + id: String, + }, } #[derive(Subcommand, Debug)] @@ -308,6 +314,17 @@ enum ProjectAction { }, /// List all projects. List, + /// Re-parent a project: move it under another project, or to the root. + Move { + /// Project to move (name; fuzzy like `--project` elsewhere). + name: String, + /// New parent project name. + #[arg(long, conflicts_with = "root")] + parent: Option, + /// Detach to the root (no parent). + #[arg(long)] + root: bool, + }, } #[derive(Subcommand, Debug)] @@ -635,6 +652,16 @@ fn main() -> Result<()> { if node.get("kind").and_then(Value::as_str) == Some("task") { let task = client.call("task.get", json!({ "id": id }))?; println!("task: {}", serde_json::to_string_pretty(&task)?); + // A task node's own `body` is always null — the real content + // lives in its canonical-context doc, so show that too. + if let Ok(doc_id) = canonical_context_id(&mut client, &id) { + let body = context_body(&mut client, &doc_id)?; + if body.trim().is_empty() { + println!("context ({doc_id}): (empty)"); + } else { + println!("context ({doc_id}):\n{}", body.trim_end()); + } + } } } Command::Log { id, text, n } => { @@ -715,6 +742,10 @@ fn main() -> Result<()> { client.call("node.tombstone", json!({ "id": id }))?; println!("Tombstoned {id}"); } + NodeAction::Restore { id } => { + client.call("node.restore", json!({ "id": id }))?; + println!("Restored {id}"); + } }, Command::Get { id } => { let result = client.call("node.get", json!({ "id": id }))?; @@ -788,6 +819,26 @@ fn main() -> Result<()> { println!("{} {}", n.id, n.title); } } + ProjectAction::Move { name, parent, root } => { + let id = resolve_project(&mut client, Some(&name))? + .with_context(|| format!("no project named {name:?}"))?; + let parent_id = match (&parent, root) { + (Some(p), false) => Some( + resolve_project(&mut client, Some(p))? + .with_context(|| format!("no parent project named {p:?}"))?, + ), + (None, true) => None, + _ => bail!("pass exactly one of --parent or --root"), + }; + client.call( + "project.reparent", + json!({ "id": id, "parent_id": parent_id }), + )?; + match parent { + Some(p) => println!("Moved {name} under {p}"), + None => println!("Moved {name} to the root"), + } + } }, Command::Tag { action } => match action { TagAction::Add { node, tag } => { diff --git a/crates/heph/tests/cli.rs b/crates/heph/tests/cli.rs index 67ea3b0..e197a66 100644 --- a/crates/heph/tests/cli.rs +++ b/crates/heph/tests/cli.rs @@ -249,3 +249,78 @@ fn export_writes_markdown_files() { let text = std::fs::read_to_string(docs[0].path()).unwrap(); assert!(text.contains("title: \"Roof log\""), "{text}"); } + +#[test] +fn node_rm_then_restore_round_trips() { + let (socket, _dir) = spawn_daemon(); + let (out, ok) = heph(&socket, &["task", "Doomed", "--attention", "red"]); + assert!(ok, "{out}"); + let id = out + .split_whitespace() + .find(|w| w.len() == 26) // the ULID in "Created task …" + .expect("task id in output") + .to_string(); + + let (out, ok) = heph(&socket, &["node", "rm", &id]); + assert!(ok, "{out}"); + let (out, _) = heph(&socket, &["list"]); + assert!( + !out.contains("Doomed"), + "tombstoned task still listed: {out}" + ); + + let (out, ok) = heph(&socket, &["node", "restore", &id]); + assert!(ok, "{out}"); + assert!(out.contains("Restored")); + let (out, _) = heph(&socket, &["list"]); + assert!(out.contains("Doomed"), "restored task missing: {out}"); +} + +#[test] +fn project_move_reparents_and_detaches() { + let (socket, _dir) = spawn_daemon(); + let (out, ok) = heph(&socket, &["project", "add", "Coding"]); + assert!(ok, "{out}"); + let (out, ok) = heph(&socket, &["project", "add", "Hephaestus"]); + assert!(ok, "{out}"); + + let (out, ok) = heph( + &socket, + &["project", "move", "Hephaestus", "--parent", "Coding"], + ); + assert!(ok, "{out}"); + assert!(out.contains("under Coding"), "{out}"); + + // A cycle is rejected with a real error. + let (out, ok) = heph( + &socket, + &["project", "move", "Coding", "--parent", "Hephaestus"], + ); + assert!(!ok, "cycle unexpectedly allowed: {out}"); + + let (out, ok) = heph(&socket, &["project", "move", "Hephaestus", "--root"]); + assert!(ok, "{out}"); + assert!(out.contains("to the root"), "{out}"); +} + +#[test] +fn show_on_a_task_prints_its_context_doc_body() { + let (socket, _dir) = spawn_daemon(); + let (out, ok) = heph(&socket, &["task", "Fix roof"]); + assert!(ok, "{out}"); + let id = out + .split_whitespace() + .find(|w| w.len() == 26) + .expect("task id in output") + .to_string(); + let (out, ok) = heph(&socket, &["context", &id, "--body", "buy shingles first"]); + assert!(ok, "{out}"); + + let (out, ok) = heph(&socket, &["show", &id]); + assert!(ok, "{out}"); + assert!(out.contains("\"kind\": \"task\""), "{out}"); + assert!( + out.contains("buy shingles"), + "show hid the context body: {out}" + ); +}