Skip to content

Commit 439d05b

Browse files
authored
Keep the body a task edit replaced, and refuse to blank one by accident (#111)
`voro set --body-file` swapped a task's whole brief for whatever the file held, in place, with nothing left behind — the one field an edit destroys outright and the one whose loss cannot be reconstructed from state elsewhere. Working mote #286 an agent ran `set 284 --body-file /dev/null` meaning a no-op and wiped #284's body; the events table stores only kind/detail transitions, so the text was unrecoverable and had to be rewritten by hand from the parent task's context. Every edit that changes a non-empty body now records the text it replaced as a `body` event on the append-only log. It rides `Store::update_task`, so it covers all three writers — the CLI flag, a refine agent, and the TUI editor — and only fires on a real change, since every `set` passes through there. That detail is bulk kept for recovery rather than reading, so `show` and the TUI history fold it to a marker naming the event that holds it (`replaced body kept (37 lines) — voro show 62 --event 512`) instead of unrolling a superseded brief into the log, and `voro show <id> --event <event-id>` prints one event's detail alone and undecorated, so recovery is a redirect back through `set --body-file` rather than a verb of its own. A replacement that would leave a non-empty body empty is refused unless `--allow-empty` says so: nothing legitimate reads as "blank the brief", and the way one actually arrives is a slip. Emptying an already-empty body destroys nothing and passes unremarked. Beside the replacing pair sits an additive one, `--append-body`/`--append-body-file`, which adds after a blank line — the "record a finding on the task" case that was what the agent in the incident was actually trying to do. DESIGN.md §8 gains the paragraph, beside the summary's, and the skill's CLI reference names both new flags.
1 parent c986aa4 commit 439d05b

6 files changed

Lines changed: 283 additions & 18 deletions

File tree

CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1616
A pair of tasks carrying two edges keeps the one not named, so an edge
1717
authored by mistake no longer has to be removed with raw SQL; dropping a
1818
blocker reconciles readiness as any other blocker edit does.
19+
- **Body edits are recoverable and guarded.** Every edit that changes a
20+
non-empty task body records the text it replaced as a `body` event, so a
21+
rewrite is no longer irreversible; `voro show <id> --event <event-id>` prints
22+
that text back, ready to redirect into `set --body-file`. A replacement that
23+
would leave the body empty is refused unless `--allow-empty` is given, and
24+
`voro set --append-body[-file]` adds to a body instead of replacing it.
1925
- **Document links**: register the plan or design doc a body of work derives
2026
from with `voro doc add <project> <path-or-url>`, and link it to the tasks it
2127
spawned (`voro doc link/unlink`, or `--doc` on `voro add`/`voro set`). `voro

crates/voro-core/src/store.rs

Lines changed: 60 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -867,7 +867,8 @@ impl Store {
867867
});
868868
}
869869
}
870-
self.conn.execute(
870+
let tx = self.conn.transaction()?;
871+
tx.execute(
871872
"UPDATE tasks SET title = ?1, body = ?2, priority = ?3, agent = ?4, human = ?5,
872873
deep = ?6
873874
WHERE id = ?7",
@@ -881,6 +882,14 @@ impl Store {
881882
id
882883
],
883884
)?;
885+
// A body edit overwrites the task's whole brief in place, so the log
886+
// keeps the text it replaced (DESIGN.md §8) — the append-only audit
887+
// covering the one field whose loss cannot be reconstructed from state.
888+
// Only a real change is logged, since every `set` lands here.
889+
if edit.body != current.body && !current.body.is_empty() {
890+
log_event(&tx, id, "body", Some(&current.body))?;
891+
}
892+
tx.commit()?;
884893
self.task(id)
885894
}
886895

@@ -1774,6 +1783,56 @@ mod tests {
17741783
assert!(human.human);
17751784
}
17761785

1786+
/// The body is the one field an edit overwrites wholesale, so the log keeps
1787+
/// what each edit replaced (DESIGN.md §8) — and only that, since every `set`
1788+
/// passes through here whether or not it touched the body.
1789+
#[test]
1790+
fn update_task_logs_the_body_it_replaced_and_nothing_else() {
1791+
let (mut s, p) = human_fixture();
1792+
let task = s.create_task(new_with(p, None, false)).unwrap();
1793+
1794+
// an empty body destroys nothing on its way out
1795+
let write = TaskEdit {
1796+
body: "the brief".into(),
1797+
..edit_of(&task, None, false)
1798+
};
1799+
let task = s.update_task(task.id, write).unwrap();
1800+
assert!(
1801+
!s.events_for(task.id)
1802+
.unwrap()
1803+
.iter()
1804+
.any(|e| e.kind == "body")
1805+
);
1806+
1807+
// an edit that leaves the body alone logs nothing either
1808+
let retitle = TaskEdit {
1809+
title: "renamed".into(),
1810+
..edit_of(&task, None, false)
1811+
};
1812+
let task = s.update_task(task.id, retitle).unwrap();
1813+
assert!(
1814+
!s.events_for(task.id)
1815+
.unwrap()
1816+
.iter()
1817+
.any(|e| e.kind == "body")
1818+
);
1819+
1820+
let rewrite = TaskEdit {
1821+
body: "a rewrite".into(),
1822+
..edit_of(&task, None, false)
1823+
};
1824+
let task = s.update_task(task.id, rewrite).unwrap();
1825+
assert_eq!(task.body, "a rewrite");
1826+
let logged: Vec<String> = s
1827+
.events_for(task.id)
1828+
.unwrap()
1829+
.into_iter()
1830+
.filter(|e| e.kind == "body")
1831+
.map(|e| e.detail.unwrap_or_default())
1832+
.collect();
1833+
assert_eq!(logged, vec!["the brief".to_string()]);
1834+
}
1835+
17771836
#[test]
17781837
fn update_task_guards_the_agent_human_exclusivity_both_ways() {
17791838
let (mut s, p) = human_fixture();

crates/voro/src/cli.rs

Lines changed: 165 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ use std::fmt::Write as _;
99
use clap::{Args, Parser, Subcommand, ValueEnum};
1010

1111
use voro_core::{
12-
Action, AgentsConfig, DepKind, Doc, NewTask, PrRef, Priority, Project, QueueRow, Repo,
12+
Action, AgentsConfig, DepKind, Doc, Event, NewTask, PrRef, Priority, Project, QueueRow, Repo,
1313
ReviewAction, ReviewMedium, Store, Task, TaskEdit, TaskState, Triage, WipGate, scheduler,
1414
};
1515

@@ -100,11 +100,18 @@ tasks
100100
discovered-from that task (dispatch renders
101101
the flag with the running task's id)
102102
set <task-id> [--title T] [--priority 0-3] [--agent NAME | --no-agent]
103-
[--body TEXT | --body-file PATH] [--blocked-by IDS] [--blocks IDS]
104-
[--unlink KIND:ID] [--pr URL | --no-pr] [--branch NAME | --no-branch]
103+
[--body TEXT | --body-file PATH] [--append-body TEXT | --append-body-file PATH]
104+
[--allow-empty] [--blocked-by IDS] [--blocks IDS] [--unlink KIND:ID]
105+
[--pr URL | --no-pr] [--branch NAME | --no-branch]
105106
[--human | --no-human] [--deep | --no-deep]
106107
[--summary TEXT | --summary-file PATH]
107108
[--repo NAME | --no-repo] [--doc DOCS | --no-doc]
109+
--body replaces the whole body; --append-body
110+
adds to what is there, after a blank line.
111+
A replacement that would leave the body empty
112+
is refused unless --allow-empty; either way
113+
the replaced text is kept on the event log
114+
(`show` names the event to recover it from)
108115
--blocked-by replaces this task's own
109116
blocker list; --blocks adds this task as a
110117
blocker of each listed task
@@ -128,7 +135,11 @@ tasks
128135
--doc replaces the task's whole document list
129136
(`voro doc link` adds one without listing the
130137
rest); --no-doc clears it
131-
show <task-id> full task: body, docs, deps, events
138+
show <task-id> [--event EVENT-ID]
139+
full task: body, docs, deps, events. --event
140+
prints one event's recorded detail and nothing
141+
else, which is how a replaced body comes back:
142+
`voro show 62 --event 512 > body.md`
132143
list [--state STATE] [--project P] [--doc DOC]
133144
--doc answers 'which tasks derive from this
134145
plan?'
@@ -261,6 +272,8 @@ enum Verb {
261272
Set(SetArgs),
262273
Show {
263274
task_id: i64,
275+
#[arg(long, value_name = "EVENT-ID")]
276+
event: Option<i64>,
264277
},
265278
List(ListArgs),
266279
Inbox,
@@ -480,6 +493,12 @@ struct SetArgs {
480493
body: Option<String>,
481494
#[arg(long, conflicts_with = "body")]
482495
body_file: Option<String>,
496+
#[arg(long, conflicts_with_all = ["body", "body_file"])]
497+
append_body: Option<String>,
498+
#[arg(long, conflicts_with_all = ["body", "body_file", "append_body"])]
499+
append_body_file: Option<String>,
500+
#[arg(long)]
501+
allow_empty: bool,
483502
#[arg(long)]
484503
blocked_by: Option<String>,
485504
#[arg(long)]
@@ -624,7 +643,10 @@ pub fn run(store: &mut Store, args: Vec<String>, ctx: &DispatchCtx) -> Result<St
624643
Verb::Add(args) => add_verb(store, args),
625644
Verb::Propose(args) => propose_verb(store, args),
626645
Verb::Set(args) => set_verb(store, args),
627-
Verb::Show { task_id } => show_verb(store, task_id),
646+
Verb::Show { task_id, event } => match event {
647+
Some(event_id) => show_event(store, task_id, event_id),
648+
None => show_verb(store, task_id),
649+
},
628650
Verb::List(args) => list_verb(store, &args),
629651
Verb::Inbox => inbox_verb(store, ctx),
630652
Verb::Next => next_verb(store),
@@ -759,6 +781,59 @@ fn text_or_file(text: Option<String>, path: Option<String>) -> Result<Option<Str
759781
}
760782
}
761783

784+
/// The body a `set` lands on (DESIGN.md §8). `--body`/`--body-file` replace it
785+
/// wholesale; `--append-body`/`--append-body-file` add to what is already there
786+
/// after a blank line, which is the "record a finding on the task" case that
787+
/// otherwise gets spelled as a replacement and loses the brief. A replacement
788+
/// that would leave a non-empty body empty is refused unless `--allow-empty`,
789+
/// since nothing legitimate reads as "blank the brief" and the commonest way to
790+
/// arrive at one is a slip. Emptying an already-empty body destroys nothing and
791+
/// is left alone.
792+
fn set_body(current: &str, args: &mut SetArgs, id: i64) -> Result<String, String> {
793+
if let Some(added) = text_or_file(args.append_body.take(), args.append_body_file.take())? {
794+
return Ok(appended_body(current, &added));
795+
}
796+
let Some(replacement) = text_or_file(args.body.take(), args.body_file.take())? else {
797+
return Ok(current.to_string());
798+
};
799+
if replacement.trim().is_empty() && !current.trim().is_empty() && !args.allow_empty {
800+
return Err(format!(
801+
"refusing to empty the body of task {id} ({} lines) — pass --allow-empty if you mean it",
802+
current.lines().count()
803+
));
804+
}
805+
Ok(replacement)
806+
}
807+
808+
/// An addition to an existing body, separated from it by one blank line. An
809+
/// empty body takes the addition as-is, so the first append reads like a write.
810+
fn appended_body(current: &str, added: &str) -> String {
811+
let base = current.trim_end();
812+
if base.is_empty() {
813+
return added.to_string();
814+
}
815+
format!("{base}\n\n{}", added.trim_start_matches('\n'))
816+
}
817+
818+
/// How an event's recorded detail reads in a history listing. Everything is its
819+
/// own detail except a `body` event, whose detail is the whole text a body edit
820+
/// replaced (DESIGN.md §8) — bulk kept for recovery, not for reading, so the
821+
/// line says what is there and how to get it back instead of unrolling it.
822+
pub(crate) fn event_detail(event: &Event) -> String {
823+
let detail = event.detail.clone().unwrap_or_default();
824+
if event.kind != "body" {
825+
return detail;
826+
}
827+
let lines = detail.lines().count();
828+
match event.task_id {
829+
Some(task_id) => format!(
830+
"replaced body kept ({lines} lines) — voro show {task_id} --event {}",
831+
event.id
832+
),
833+
None => format!("replaced body kept ({lines} lines)"),
834+
}
835+
}
836+
762837
/// Free-text positionals (a title, an answer, rejection feedback) arrive as
763838
/// the words the shell split them into; join them back and refuse emptiness.
764839
fn joined(words: &[String], what: &str) -> Result<String, String> {
@@ -1338,9 +1413,10 @@ fn propose_verb(store: &mut Store, args: ProposeArgs) -> Result<String, String>
13381413
Ok(out)
13391414
}
13401415

1341-
fn set_verb(store: &mut Store, args: SetArgs) -> Result<String, String> {
1416+
fn set_verb(store: &mut Store, mut args: SetArgs) -> Result<String, String> {
13421417
let id = args.task_id;
13431418
let current = store.task(id).map_err(|e| e.to_string())?;
1419+
let body = set_body(&current.body, &mut args, id)?;
13441420
let agent = if args.no_agent {
13451421
None
13461422
} else {
@@ -1358,7 +1434,7 @@ fn set_verb(store: &mut Store, args: SetArgs) -> Result<String, String> {
13581434
};
13591435
let edit = TaskEdit {
13601436
title: args.title.unwrap_or(current.title),
1361-
body: text_or_file(args.body, args.body_file)?.unwrap_or(current.body),
1437+
body,
13621438
priority: args.priority.unwrap_or(current.priority),
13631439
agent,
13641440
human,
@@ -1661,18 +1737,24 @@ fn show_verb(store: &mut Store, id: i64) -> Result<String, String> {
16611737
}
16621738
writeln!(out, "\nevents:").unwrap();
16631739
for e in store.events_for(id).map_err(|e| e.to_string())? {
1664-
writeln!(
1665-
out,
1666-
" {} {} {}",
1667-
e.at,
1668-
e.kind,
1669-
e.detail.unwrap_or_default()
1670-
)
1671-
.unwrap();
1740+
writeln!(out, " {} {} {}", e.at, e.kind, event_detail(&e)).unwrap();
16721741
}
16731742
Ok(out)
16741743
}
16751744

1745+
/// One event's recorded detail, alone and undecorated, so it can be redirected
1746+
/// straight into a file: `voro show 62 --event 512 > body.md` is how the body a
1747+
/// `set` replaced comes back (DESIGN.md §8). The event must belong to the task
1748+
/// named, so a mistyped id reads as an error rather than another task's text.
1749+
fn show_event(store: &mut Store, task_id: i64, event_id: i64) -> Result<String, String> {
1750+
let events = store.events_for(task_id).map_err(|e| e.to_string())?;
1751+
let event = events
1752+
.iter()
1753+
.find(|e| e.id == event_id)
1754+
.ok_or_else(|| format!("task {task_id} has no event {event_id}"))?;
1755+
Ok(event.detail.clone().unwrap_or_default())
1756+
}
1757+
16761758
fn list_verb(store: &mut Store, args: &ListArgs) -> Result<String, String> {
16771759
let state_filter = match &args.state {
16781760
Some(raw) => Some(TaskState::parse(raw).map_err(|e| e.to_string())?),
@@ -2602,6 +2684,74 @@ mod tests {
26022684
assert!(s.refined_flag(1).unwrap());
26032685
}
26042686

2687+
/// A body replacement is destructive, so the two guards of DESIGN.md §8
2688+
/// hold: one that would leave the body empty is refused outright, and the
2689+
/// text any accepted replacement discards is recoverable from the log.
2690+
#[test]
2691+
fn emptying_a_body_is_refused_and_a_replaced_one_is_recoverable() {
2692+
let mut s = store();
2693+
ok(&mut s, &["project", "add", "demo", "/tmp"]);
2694+
ok(
2695+
&mut s,
2696+
&["add", "demo", "An idea", "--body", "the brief\nline two"],
2697+
);
2698+
2699+
let e = err(&mut s, &["set", "1", "--body", ""]);
2700+
assert!(e.contains("--allow-empty"), "{e}");
2701+
assert_eq!(s.task(1).unwrap().body, "the brief\nline two");
2702+
2703+
ok(&mut s, &["set", "1", "--body", "a rewrite"]);
2704+
assert_eq!(s.task(1).unwrap().body, "a rewrite");
2705+
2706+
// The history says a body was replaced and names the event to get it
2707+
// back, rather than unrolling the old text into the listing.
2708+
let out = ok(&mut s, &["show", "1"]);
2709+
assert!(out.contains("replaced body kept (2 lines)"), "{out}");
2710+
assert!(!out.contains("line two"), "{out}");
2711+
2712+
let event = out
2713+
.lines()
2714+
.find(|l| l.contains("--event"))
2715+
.and_then(|l| l.rsplit(' ').next().map(str::to_string))
2716+
.expect("the body event names its own id");
2717+
assert_eq!(
2718+
ok(&mut s, &["show", "1", "--event", &event]),
2719+
"the brief\nline two"
2720+
);
2721+
assert!(err(&mut s, &["show", "1", "--event", "999"]).contains("no event 999"));
2722+
2723+
// Emptying it is allowed once said explicitly — and still recoverable.
2724+
ok(&mut s, &["set", "1", "--body", "", "--allow-empty"]);
2725+
assert_eq!(s.task(1).unwrap().body, "");
2726+
}
2727+
2728+
/// `--append-body-file` is the additive spelling for the common "record a
2729+
/// finding on the task" case, which otherwise gets written as a replacement
2730+
/// and takes the brief with it (DESIGN.md §8).
2731+
#[test]
2732+
fn append_body_adds_to_the_brief_instead_of_replacing_it() {
2733+
let mut s = store();
2734+
ok(&mut s, &["project", "add", "demo", "/tmp"]);
2735+
ok(&mut s, &["add", "demo", "An idea", "--body", "the brief\n"]);
2736+
2737+
let path = std::env::temp_dir().join(format!("voro-append-{}.md", std::process::id()));
2738+
std::fs::write(&path, "a finding\n").unwrap();
2739+
ok(
2740+
&mut s,
2741+
&["set", "1", "--append-body-file", path.to_str().unwrap()],
2742+
);
2743+
std::fs::remove_file(&path).unwrap();
2744+
assert_eq!(s.task(1).unwrap().body, "the brief\n\na finding\n");
2745+
2746+
ok(&mut s, &["set", "1", "--append-body", "another"]);
2747+
assert_eq!(s.task(1).unwrap().body, "the brief\n\na finding\n\nanother");
2748+
2749+
// Replacement and addition are different intents, not two spellings of
2750+
// one, so asking for both at once is a parse error rather than a race.
2751+
let e = err(&mut s, &["set", "1", "--body", "x", "--append-body", "y"]);
2752+
assert!(e.contains("cannot be used with"), "{e}");
2753+
}
2754+
26052755
/// The inbox renders each row's next-action verb in place of the state,
26062756
/// mirroring the TUI queue — both from `Task::next_action()` (DESIGN.md §3).
26072757
#[test]

0 commit comments

Comments
 (0)