generated from eblume/project-template
Phase 1: v1 prototype #1
10 changed files with 249 additions and 8 deletions
feat(core): task.set_project — move-to-project with OR-set link semantics (§8.1)
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) <noreply@anthropic.com>
commit
df7f43788b
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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<Task> {
|
||||
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,
|
||||
|
|
|
|||
|
|
@ -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<Task> {
|
||||
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` =
|
||||
|
|
|
|||
|
|
@ -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<Task>;
|
||||
|
||||
/// 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<Task>;
|
||||
|
||||
/// 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
|
||||
|
|
|
|||
|
|
@ -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<String> {
|
||||
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();
|
||||
|
|
|
|||
|
|
@ -122,7 +122,7 @@ enum Command {
|
|||
/// Set attention: white|orange|red|blue.
|
||||
#[arg(short = 'a', long)]
|
||||
attention: Option<String>,
|
||||
/// 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<String>,
|
||||
},
|
||||
|
|
@ -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}");
|
||||
|
|
|
|||
|
|
@ -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<Task> {
|
||||
self.call_as(
|
||||
"task.set_project",
|
||||
json!({ "id": node_id, "project_id": project_id }),
|
||||
)
|
||||
}
|
||||
|
||||
fn promote(
|
||||
&mut self,
|
||||
container_id: &str,
|
||||
|
|
|
|||
|
|
@ -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<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct PromoteParams {
|
||||
container_id: String,
|
||||
|
|
@ -273,6 +281,10 @@ pub fn dispatch(store: &mut dyn Store, method: &str, params: Value) -> Result<Va
|
|||
let patch: SchedulePatch = parse(params)?;
|
||||
json!(store.set_task_schedule(&id.id, patch)?)
|
||||
}
|
||||
"task.set_project" => {
|
||||
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)?)
|
||||
|
|
|
|||
|
|
@ -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<String> {
|
||||
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();
|
||||
|
|
|
|||
|
|
@ -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 <name>` 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 <name>` 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 <task> --project <name>` now routes through it (fixing a bug where re-filing piled on a duplicate link), and `--project none` unfiles the task.
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue