feat: actionable conflicts, node restore, project move, task-aware show

- conflicts.resolve now applies the chosen value (local/remote) as a new
  task.set op — peers converge on the decision — instead of only marking
  the row resolved.
- heph node restore <id> — undo of node rm.
- heph project move <name> --parent <p>|--root — post-creation
  re-parenting from the CLI.
- heph show on a task prints its canonical-context doc body (the task
  node's own body is always null and was hiding the real content).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Erich Blume 2026-06-09 11:03:13 -07:00
commit 3db026f6e5
7 changed files with 266 additions and 10 deletions

View file

@ -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<Vec<Confl
Ok(rows.collect::<rusqlite::Result<Vec<_>>>()?)
}
/// 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<String>, Option<String>)> = 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<i64> =
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(())
}

View file

@ -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)
}
}

View file

@ -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(

View file

@ -257,6 +257,8 @@ pub trait Store {
/// Open merge conflicts surfaced for the user (`heph conflicts`).
fn conflicts_list(&self) -> Result<Vec<Conflict>>;
/// 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<()>;
}

View file

@ -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)
);
}

View file

@ -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<String>,
/// 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 <project> 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 } => {

View file

@ -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 <id> …"
.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}"
);
}