From df7f43788bc183c15ef9d1df8d48a1eb5dfb6712 Mon Sep 17 00:00:00 2001 From: Erich Blume Date: Wed, 3 Jun 2026 10:35:16 -0700 Subject: [PATCH] =?UTF-8?q?feat(core):=20task.set=5Fproject=20=E2=80=94=20?= =?UTF-8?q?move-to-project=20with=20OR-set=20link=20semantics=20(=C2=A78.1?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add `Store::set_task_project` (heph-core + RemoteStore) and the `task.set_project` RPC: tombstone the task's existing `in-project` link(s) and add a new one (or none, to unfile). A given project id must name a live project-kind node, else InvalidArg/NodeNotFound. Route `heph edit --project` through it, fixing a duplicate-link bug (the old path added an in-project link without removing the prior one); `--project none` now unfiles. Factor a `links::tombstone` helper out of `sync_wiki_links`. Tests: core move/unfile/reject + a duplicate-link regression; a socket dispatch test. The TUI `m` gesture follows in the next commit. Co-Authored-By: Claude Opus 4.8 (1M context) --- crates/heph-core/src/sqlite/links.rs | 13 ++- crates/heph-core/src/sqlite/mod.rs | 5 ++ crates/heph-core/src/sqlite/tasks.rs | 34 ++++++++ crates/heph-core/src/store.rs | 6 ++ crates/heph-core/tests/tasks_and_links.rs | 99 +++++++++++++++++++++++ crates/heph/src/main.rs | 15 +++- crates/hephd/src/remote.rs | 7 ++ crates/hephd/src/rpc.rs | 12 +++ crates/hephd/tests/rpc_socket.rs | 63 +++++++++++++++ docs/changelog.d/v1-prototype.feature.md | 3 +- 10 files changed, 249 insertions(+), 8 deletions(-) diff --git a/crates/heph-core/src/sqlite/links.rs b/crates/heph-core/src/sqlite/links.rs index 26c78d9..ab20b15 100644 --- a/crates/heph-core/src/sqlite/links.rs +++ b/crates/heph-core/src/sqlite/links.rs @@ -70,6 +70,15 @@ pub(super) fn add( Ok(link) } +/// Tombstone a single link by id, recording the OR-set `link.remove` op. +/// Monotonic: re-tombstoning an already-dead link is a harmless no-op write. +pub(super) fn tombstone(conn: &Connection, owner: &str, now: i64, link_id: &str) -> Result<()> { + conn.execute("UPDATE links SET tombstoned = 1 WHERE id = ?1", [link_id])?; + let hlc = next_hlc(conn, now)?; + ops::record(conn, owner, &hlc, op_type::LINK_REMOVE, link_id, json!({}))?; + Ok(()) +} + /// The destination of the first non-tombstoned link of `link_type` out of /// `src_id`, if any (e.g. a task's canonical-context doc or its log doc). pub(super) fn first_dst( @@ -144,9 +153,7 @@ pub(super) fn sync_wiki_links( // Tombstone links whose target is no longer referenced. for (link_id, dst) in &existing { if !desired_set.contains(dst) { - conn.execute("UPDATE links SET tombstoned = 1 WHERE id = ?1", [link_id])?; - let hlc = next_hlc(conn, now)?; - ops::record(conn, owner, &hlc, op_type::LINK_REMOVE, link_id, json!({}))?; + tombstone(conn, owner, now, link_id)?; } } // Add links for newly-referenced targets. diff --git a/crates/heph-core/src/sqlite/mod.rs b/crates/heph-core/src/sqlite/mod.rs index 04f7fa6..630ba0e 100644 --- a/crates/heph-core/src/sqlite/mod.rs +++ b/crates/heph-core/src/sqlite/mod.rs @@ -237,6 +237,11 @@ impl Store for LocalStore { tasks::set_schedule(&self.conn, &self.owner_id, now, node_id, patch) } + fn set_task_project(&mut self, node_id: &str, project_id: Option<&str>) -> Result { + let now = self.clock.now_ms(); + tasks::set_project(&mut self.conn, &self.owner_id, now, node_id, project_id) + } + fn promote( &mut self, container_id: &str, diff --git a/crates/heph-core/src/sqlite/tasks.rs b/crates/heph-core/src/sqlite/tasks.rs index f751539..197fc45 100644 --- a/crates/heph-core/src/sqlite/tasks.rs +++ b/crates/heph-core/src/sqlite/tasks.rs @@ -535,6 +535,40 @@ pub(super) fn set_attention( require(conn, node_id) } +/// Re-file a task under `project_id`, or unfile it entirely when `None` +/// (tech-spec §8.1 move-to-project). OR-set link semantics: tombstone the +/// task's existing `in-project` links, then add a fresh one if a project is +/// given. A given `project_id` must name a live `project`-kind node. Records +/// only link ops (no task-scalar change), all in one transaction. +pub(super) fn set_project( + conn: &mut Connection, + owner: &str, + now: i64, + node_id: &str, + project_id: Option<&str>, +) -> Result { + require(conn, node_id)?; // task must exist + if let Some(pid) = project_id { + let project = nodes::get(conn, pid)?.ok_or_else(|| Error::NodeNotFound(pid.into()))?; + if project.tombstoned || project.kind != NodeKind::Project { + return Err(Error::InvalidArg(format!("{pid} is not a project node"))); + } + } + + let tx = conn.transaction()?; + for link in links::outgoing(&tx, node_id)? { + if link.link_type == LinkType::InProject { + links::tombstone(&tx, owner, now, &link.id)?; + } + } + if let Some(pid) = project_id { + links::add(&tx, owner, now, node_id, pid, LinkType::InProject)?; + } + tx.commit()?; + + require(conn, node_id) +} + /// Apply a partial schedule update (do-date / late-on / recurrence) — the /// "reschedule" path (tech-spec §6). Reads the current row, overlays the /// present `patch` fields (a double-option per field: absent = leave, `null` = diff --git a/crates/heph-core/src/store.rs b/crates/heph-core/src/store.rs index fbd6aeb..5044b15 100644 --- a/crates/heph-core/src/store.rs +++ b/crates/heph-core/src/store.rs @@ -83,6 +83,12 @@ pub trait Store { /// set. This is the "reschedule" path (the scalars with no dedicated setter). fn set_task_schedule(&mut self, node_id: &str, patch: SchedulePatch) -> Result; + /// Re-file a task under a project (or unfile it when `project_id` is + /// `None`) — the move-to-project path (tech-spec §8.1). OR-set link + /// semantics: the old `in-project` link is tombstoned and a new one added. + /// A given `project_id` must name a live `project`-kind node. + fn set_task_project(&mut self, node_id: &str, project_id: Option<&str>) -> Result; + /// Promote a `- [ ]` context-item line in `container_id`'s body into a /// committed task, rewriting that source line into a `[[link]]` to the new /// task (Fork A, tech-spec §4.3, §6). `item_ref` is the 1-based index of the diff --git a/crates/heph-core/tests/tasks_and_links.rs b/crates/heph-core/tests/tasks_and_links.rs index c089311..3c97c91 100644 --- a/crates/heph-core/tests/tasks_and_links.rs +++ b/crates/heph-core/tests/tasks_and_links.rs @@ -213,6 +213,105 @@ fn project_link_is_created_when_given() { assert!(has_project); } +#[test] +fn set_project_moves_then_unfiles_and_rejects_non_projects() { + let mut s = store(); + let project = |s: &mut LocalStore, title: &str| { + s.create_node(NewNode { + kind: NodeKind::Project, + title: title.into(), + body: None, + }) + .unwrap() + .id + }; + let chores = project(&mut s, "Chores"); + let garden = project(&mut s, "Garden"); + + let task = s + .create_task(NewTask { + title: "Water the beds".into(), + project_id: Some(chores.clone()), + ..Default::default() + }) + .unwrap(); + let id = task.node_id; + + // The active `in-project` destinations of the task. + let projects_of = |s: &mut LocalStore| -> Vec { + s.outgoing_links(&id) + .unwrap() + .into_iter() + .filter(|l| l.link_type == LinkType::InProject) + .map(|l| l.dst_id) + .collect() + }; + + // Move Chores → Garden: exactly one active link, now to Garden. + s.set_task_project(&id, Some(&garden)).unwrap(); + assert_eq!(projects_of(&mut s), vec![garden.clone()]); + + // Unfile (None): no active in-project links remain. + s.set_task_project(&id, None).unwrap(); + assert!(projects_of(&mut s).is_empty()); + + // A non-project destination is rejected (and nothing is filed). + let doc = s.create_node(NewNode::doc("Just a note", "")).unwrap(); + assert!(s.set_task_project(&id, Some(&doc.id)).is_err()); + assert!(projects_of(&mut s).is_empty()); + + // A missing project id is rejected too. + assert!(s.set_task_project(&id, Some("does-not-exist")).is_err()); +} + +/// Regression: re-filing a task must never accumulate duplicate `in-project` +/// links. The old CLI `edit --project` path added a link without tombstoning +/// the previous one, so a task could end up filed under two (or N) projects at +/// once. `set_task_project` replaces, so the active count stays exactly one — +/// even when re-filing to the *same* project repeatedly. +#[test] +fn set_project_never_accumulates_duplicate_links() { + let mut s = store(); + let project = |s: &mut LocalStore, title: &str| { + s.create_node(NewNode { + kind: NodeKind::Project, + title: title.into(), + body: None, + }) + .unwrap() + .id + }; + let chores = project(&mut s, "Chores"); + let garden = project(&mut s, "Garden"); + + let id = s + .create_task(NewTask { + title: "Mulch".into(), + project_id: Some(chores.clone()), + ..Default::default() + }) + .unwrap() + .node_id; + + let active_in_project = |s: &mut LocalStore| -> usize { + s.outgoing_links(&id) + .unwrap() + .iter() + .filter(|l| l.link_type == LinkType::InProject) + .count() + }; + + // Re-file to the same project several times, then to a different one. + for target in [&chores, &chores, &garden, &garden] { + s.set_task_project(&id, Some(target)).unwrap(); + assert_eq!( + active_in_project(&mut s), + 1, + "exactly one active project link" + ); + } +} + #[test] fn updating_a_body_materializes_resolved_wiki_links() { let mut s = store(); diff --git a/crates/heph/src/main.rs b/crates/heph/src/main.rs index a383a0b..53de382 100644 --- a/crates/heph/src/main.rs +++ b/crates/heph/src/main.rs @@ -122,7 +122,7 @@ enum Command { /// Set attention: white|orange|red|blue. #[arg(short = 'a', long)] attention: Option, - /// File under a project (by name; adds an in-project link). + /// Re-file under a project (by name); `none` unfiles the task. #[arg(long)] project: Option, }, @@ -473,10 +473,17 @@ fn main() -> Result<()> { if let Some(a) = attention { client.call("task.set_attention", json!({ "id": id, "attention": a }))?; } - if let Some(pid) = resolve_project(&mut client, project.as_deref())? { + if let Some(spec) = project.as_deref() { + // Re-file (or unfile with `none`) via the move-to-project path, + // which tombstones the old `in-project` link rather than piling + // a second one on top of it. + let project_id = match spec { + "none" | "clear" => None, + name => resolve_project(&mut client, Some(name))?, + }; client.call( - "links.add", - json!({ "src": id, "dst": pid, "link_type": "in-project" }), + "task.set_project", + json!({ "id": id, "project_id": project_id }), )?; } println!("Edited task {id}"); diff --git a/crates/hephd/src/remote.rs b/crates/hephd/src/remote.rs index 959e6a3..952982b 100644 --- a/crates/hephd/src/remote.rs +++ b/crates/hephd/src/remote.rs @@ -167,6 +167,13 @@ impl Store for RemoteStore { self.call_as("task.set_schedule", params) } + fn set_task_project(&mut self, node_id: &str, project_id: Option<&str>) -> Result { + self.call_as( + "task.set_project", + json!({ "id": node_id, "project_id": project_id }), + ) + } + fn promote( &mut self, container_id: &str, diff --git a/crates/hephd/src/rpc.rs b/crates/hephd/src/rpc.rs index fa7d160..00ba9af 100644 --- a/crates/hephd/src/rpc.rs +++ b/crates/hephd/src/rpc.rs @@ -143,6 +143,14 @@ struct SetAttentionParams { attention: Attention, } +#[derive(Deserialize)] +struct SetProjectParams { + id: String, + /// Target project node id; `null`/absent unfiles the task. + #[serde(default)] + project_id: Option, +} + #[derive(Deserialize)] struct PromoteParams { container_id: String, @@ -273,6 +281,10 @@ pub fn dispatch(store: &mut dyn Store, method: &str, params: Value) -> Result { + let p: SetProjectParams = parse(params)?; + json!(store.set_task_project(&p.id, p.project_id.as_deref())?) + } "task.skip" => { let p: IdParam = parse(params)?; json!(store.skip_recurrence(&p.id)?) diff --git a/crates/hephd/tests/rpc_socket.rs b/crates/hephd/tests/rpc_socket.rs index 1c5d122..d9a188d 100644 --- a/crates/hephd/tests/rpc_socket.rs +++ b/crates/hephd/tests/rpc_socket.rs @@ -164,6 +164,69 @@ fn task_set_schedule_patches_over_socket() { assert!(got["recurrence"].is_null()); } +#[test] +fn task_set_project_moves_and_unfiles_over_socket() { + let (socket, _dir) = spawn_daemon(); + let mut c = client(&socket); + + let mk_project = |c: &mut Client, title: &str| -> String { + c.call("node.create", json!({ "kind": "project", "title": title })) + .unwrap()["id"] + .as_str() + .unwrap() + .to_string() + }; + let chores = mk_project(&mut c, "Chores"); + let garden = mk_project(&mut c, "Garden"); + + let task = c + .call( + "task.create", + json!({ "title": "Water the beds", "project_id": chores }), + ) + .unwrap(); + let id = task["node_id"].as_str().unwrap().to_string(); + + // Active in-project destinations of the task. + let projects_of = |c: &mut Client| -> Vec { + c.call("links.outgoing", json!({ "id": id })) + .unwrap() + .as_array() + .unwrap() + .iter() + .filter(|l| l["link_type"] == "in-project") + .map(|l| l["dst_id"].as_str().unwrap().to_string()) + .collect() + }; + + // Move Chores → Garden: exactly one active link, to Garden. + c.call( + "task.set_project", + json!({ "id": id, "project_id": garden }), + ) + .unwrap(); + assert_eq!(projects_of(&mut c), vec![garden.clone()]); + + // Unfile (null project): no active in-project links. + c.call("task.set_project", json!({ "id": id, "project_id": null })) + .unwrap(); + assert!(projects_of(&mut c).is_empty()); + + // A non-project destination is rejected. + let doc = c + .call("node.create", json!({ "kind": "doc", "title": "Note" })) + .unwrap(); + let doc_id = doc["id"].as_str().unwrap(); + assert!( + c.call( + "task.set_project", + json!({ "id": id, "project_id": doc_id }) + ) + .is_err(), + "filing under a non-project node must error" + ); +} + #[test] fn promote_context_item_over_socket() { let (socket, _dir) = spawn_daemon(); diff --git a/docs/changelog.d/v1-prototype.feature.md b/docs/changelog.d/v1-prototype.feature.md index bb97724..eef0a10 100644 --- a/docs/changelog.d/v1-prototype.feature.md +++ b/docs/changelog.d/v1-prototype.feature.md @@ -23,4 +23,5 @@ Begin the v1 prototype (Phase 1, tech-spec §11.1), built in TDD slices: - CLI as a complete task surface (§1, §6.2.1): `heph` now implements the entire daemon API and is the task capture/scripting surface. Structured fields are flags with **human dates** (`--do-date tomorrow|+3d|fri|YYYY-MM-DD`, shown back compactly in `next`/`list`) and **recurrence** (`--recur` presets/natural-language like "every 3 days", or a raw `--rrule`). New verbs: `list`, `done`/`drop`/`skip`, `attention`, `edit` (reschedule do-date/late-on/recurrence, re-attention, re-file — backed by the new `task.set_schedule` RPC), `promote`, `show`, `log` (append or tail), `health`, `node update`/`rm`, `resolve`, `links`/`backlinks`, `link add`, `project add [--parent]`, `sync [--status]`, `conflicts [resolve]`. Projects are referenced by name. Date/recurrence parsing is unit-tested; the new verbs have real-socket process tests. - Daemon lifecycle is now an explicit OS service, and all surfaces are connect-only (no more auto-spawn). `heph daemon start/stop/restart/status/uninstall` idempotently manages a launchd agent (macOS) or systemd user service (Linux) that runs `hephd` on your default store; `heph.nvim` no longer spawns or supervises a daemon — it just connects and points you at `heph daemon start` if none is running. Rationale: once the CLI became a first-class surface, a daemon owned by one surface couldn't be shared (see [[run-the-daemon]], [[design]] §4). - Filter views (§8.2) — saved agenda slices, so the agenda isn't one flat list. `heph view ` runs a built-in view (`tom` Top of Mind, `ondeck` On Deck, `chores`, `work` Work Tasks, `tasks`) seeded from the owner's Todoist filter queries; `heph view` with no name lists them, and `:Heph view ` does the same in Neovim. Under the hood, `list` now takes a `ListFilter` predicate-as-data (attention include/exclude sets, project-subtree scope, project exclusions, an actionable do-date gate), and views resolve project names to ids and expand each to its `parent`-link subtree. The Schedule view is intentionally omitted (time-of-day isn't modeled on date-grained do-dates). -- `heph-tui` (§8.1) — a terminal task agenda/triage UI, the primary surface for working a large task set (the §6.2.1 Todoist study showed triage, not single edits, dominates). A `ratatui` app, thin client of the daemon socket. Three panes: a sidebar of the five filter views + your projects, an attention-colored task list with compact human do/late dates, and a preview of the highlighted task's context doc + recent log. Triage from the keyboard: `a` add (guided title → attention → do-date, filed under the selected project), `x` done, `s` skip, `d` drop, `A` cycle attention, `b` push to On Deck, `e` reschedule the do-date; `o` opens the task's context doc in your nvim (live, via heph.nvim) and returns. `j/k` move, `Tab`/`h`/`l` switch panes, `r` refresh, `q` quit. Run it with `heph-tui` (honors `--socket` / `$HEPH_SOCKET`). `a` is a Todoist-style single-line quick-add: `Buy milk tomorrow p2 #Work every week` parses into title + attention (p1–p4) + do-date + recurrence + project (multi-word project names match greedily; an unresolved `#tag` just stays in the title). `/` runs a full-text search whose results overlay the task list; Enter opens a hit (a task at its context doc) in nvim. Re-filing a task to a different project is the one remaining capture gap. +- `heph-tui` (§8.1) — a terminal task agenda/triage UI, the primary surface for working a large task set (the §6.2.1 Todoist study showed triage, not single edits, dominates). A `ratatui` app, thin client of the daemon socket. Three panes: a sidebar of the five filter views + your projects, an attention-colored task list with compact human do/late dates, and a preview of the highlighted task's context doc + recent log. Triage from the keyboard: `a` add (guided title → attention → do-date, filed under the selected project), `x` done, `s` skip, `d` drop, `A` cycle attention, `b` push to On Deck, `e` reschedule the do-date; `o` opens the task's context doc in your nvim (live, via heph.nvim) and returns. `j/k` move, `Tab`/`h`/`l` switch panes, `r` refresh, `q` quit. Run it with `heph-tui` (honors `--socket` / `$HEPH_SOCKET`). `a` is a Todoist-style single-line quick-add: `Buy milk tomorrow p2 #Work every week` parses into title + attention (p1–p4) + do-date + recurrence + project (multi-word project names match greedily; an unresolved `#tag` just stays in the title). `/` runs a full-text search whose results overlay the task list; Enter opens a hit (a task at its context doc) in nvim. +- Move-to-project (§8.1): a new `task.set_project` RPC re-files a task under another project (or unfiles it) with OR-set link semantics — the old `in-project` link is tombstoned and a new one added, so a task is never filed under two projects at once. `heph edit --project ` now routes through it (fixing a bug where re-filing piled on a duplicate link), and `--project none` unfiles the task.