generated from eblume/project-template
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:
parent
8fe11c75cd
commit
3db026f6e5
7 changed files with 266 additions and 10 deletions
|
|
@ -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(())
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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<()>;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 } => {
|
||||
|
|
|
|||
|
|
@ -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}"
|
||||
);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue