feat(core): tags — canonical tag nodes + OR-set tagging (§4, §8.3)
Some checks failed
Build / validate (pull_request) Failing after 4m35s

A tag is a `tag`-kind node with a deterministic id in (owner, name)
(`tag:<owner>:<name>`, like the journal), so a name is one canonical tag
shared across nodes and replicas converge with no duplicates. Tagging is
an OR-set `tagged` link (mirroring in-project):

- heph-core: `nodes::open_or_create_tag` (bodyless, deterministic id),
  `tags::{add,remove,of}`, and `Store::{add_tag,remove_tag,tags_of}`.
  Enumerate all tags via the existing `list_nodes(Tag)`.
- hephd: `tag.add`/`tag.remove`/`tag.list` RPCs + RemoteStore forwarding.
- heph: `heph tag add|rm|list` (a node's tags, or every tag).

Names are trimmed; canonical case/spelling normalization is deferred to
the zk import. Unblocks the `tags:` line of the frontmatter surface.
Tests: core add/dedupe/remove/canonical-id/trim/missing-node + a socket
add/list/enumerate/remove test.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Erich Blume 2026-06-03 11:18:51 -07:00
commit 4cdf0de64c
11 changed files with 344 additions and 1 deletions

View file

@ -17,6 +17,7 @@ mod migrations;
mod nodes;
mod ops;
mod syncstate;
mod tags;
mod tasks;
pub use migrations::latest_version;
@ -306,6 +307,20 @@ impl Store for LocalStore {
links::backlinks(&self.conn, id)
}
fn add_tag(&mut self, node_id: &str, tag: &str) -> Result<Node> {
let now = self.clock.now_ms();
tags::add(&mut self.conn, &self.owner_id, now, node_id, tag)
}
fn remove_tag(&mut self, node_id: &str, tag: &str) -> Result<()> {
let now = self.clock.now_ms();
tags::remove(&mut self.conn, &self.owner_id, now, node_id, tag)
}
fn tags_of(&self, node_id: &str) -> Result<Vec<String>> {
tags::of(&self.conn, node_id)
}
fn log_append(&mut self, task_id: &str, text: &str) -> Result<()> {
let now = self.clock.now_ms();
let tx = self.conn.transaction()?;

View file

@ -216,6 +216,46 @@ pub(super) fn open_or_create_journal(
Ok(node)
}
/// Open (creating if absent) the `tag`-kind node named `name`. Like the journal
/// (§3.1), its id is **deterministic** in `(owner, name)`, so the same name is
/// one canonical tag and independent offline taggings converge. Tags are
/// bodyless. `name` must be non-empty (callers trim first).
pub(super) fn open_or_create_tag(
conn: &Connection,
owner: &str,
now: i64,
name: &str,
) -> Result<Node> {
if name.is_empty() {
return Err(Error::Integrity("tag name must not be empty".into()));
}
let id = deterministic_id(owner, NodeKind::Tag, name);
if let Some(existing) = get(conn, &id)? {
return Ok(existing);
}
let node = Node {
id,
owner_id: owner.to_string(),
kind: NodeKind::Tag,
title: name.to_string(),
body: None,
created_at: now,
modified_at: now,
hlc: next_hlc(conn, now)?,
tombstoned: false,
};
insert(conn, &node)?;
ops::record(
conn,
owner,
&node.hlc,
op_type::NODE_CREATE,
&node.id,
create_payload(&node, None),
)?;
Ok(node)
}
fn is_iso_date(s: &str) -> bool {
let b = s.as_bytes();
b.len() == 10

View file

@ -0,0 +1,73 @@
//! Tag operations (tech-spec §4, §8.3). A tag is a `tag`-kind node with a
//! deterministic id (so a name is one canonical tag, §3.1); tagging is an
//! **OR-set** `tagged` link from the tagged node to the tag node — mirroring
//! `in-project`. Names are trimmed; case is preserved (a canonical
//! normalization is deferred to the zk import, [[design]]).
use rusqlite::Connection;
use super::{links, nodes};
use crate::error::{Error, Result};
use crate::model::{Link, LinkType, Node};
/// Tag `node_id` with `name`, creating the canonical tag node on first use.
/// Idempotent: a node already carrying the tag is left unchanged. Returns the
/// tag node. Errors if the target node is missing or the name is blank.
pub(super) fn add(
conn: &mut Connection,
owner: &str,
now: i64,
node_id: &str,
name: &str,
) -> Result<Node> {
let name = name.trim();
if name.is_empty() {
return Err(Error::Integrity("tag name must not be empty".into()));
}
if nodes::get(conn, node_id)?.is_none() {
return Err(Error::NodeNotFound(node_id.into()));
}
let tx = conn.transaction()?;
let tag = nodes::open_or_create_tag(&tx, owner, now, name)?;
let already = links::outgoing(&tx, node_id)?
.iter()
.any(|l: &Link| l.link_type == LinkType::Tagged && l.dst_id == tag.id);
if !already {
links::add(&tx, owner, now, node_id, &tag.id, LinkType::Tagged)?;
}
tx.commit()?;
Ok(tag)
}
/// Untag `node_id` — tombstone its `tagged` link(s) to the `name` tag. A no-op
/// if the node isn't tagged with it. The tag node itself persists.
pub(super) fn remove(
conn: &mut Connection,
owner: &str,
now: i64,
node_id: &str,
name: &str,
) -> Result<()> {
let name = name.trim();
let tag_id = crate::model::deterministic_id(owner, crate::model::NodeKind::Tag, name);
let tx = conn.transaction()?;
for link in links::outgoing(&tx, node_id)? {
if link.link_type == LinkType::Tagged && link.dst_id == tag_id {
links::tombstone(&tx, owner, now, &link.id)?;
}
}
tx.commit()?;
Ok(())
}
/// The tag names on `node_id`, sorted (tombstoned tags excluded).
pub(super) fn of(conn: &Connection, node_id: &str) -> Result<Vec<String>> {
let mut stmt = conn.prepare(
"SELECT n.title FROM links l JOIN nodes n ON n.id = l.dst_id
WHERE l.src_id = ?1 AND l.type = 'tagged' AND l.tombstoned = 0 AND n.tombstoned = 0
ORDER BY n.title",
)?;
let rows = stmt.query_map([node_id], |r| r.get::<_, String>(0))?;
Ok(rows.collect::<rusqlite::Result<Vec<_>>>()?)
}

View file

@ -141,6 +141,22 @@ pub trait Store {
/// All non-tombstoned links pointing at `id` (backlinks).
fn backlinks(&self, id: &str) -> Result<Vec<Link>>;
// --- tags (tech-spec §4, §8.3) ---
/// Tag `node_id` with `tag` (trimmed), creating the canonical `tag`-kind
/// node on first use — its id is deterministic in `(owner, name)`, so a
/// name is one shared tag and replicas converge. OR-set `tagged` link;
/// idempotent. Returns the tag node. Errors on a missing node or blank name.
fn add_tag(&mut self, node_id: &str, tag: &str) -> Result<Node>;
/// Remove `tag` from `node_id` (tombstone the `tagged` link); a no-op if it
/// isn't tagged. The tag node itself persists.
fn remove_tag(&mut self, node_id: &str, tag: &str) -> Result<()>;
/// The tag names on `node_id`, sorted. (Enumerate *all* tags via
/// [`Store::list_nodes`] with [`NodeKind::Tag`].)
fn tags_of(&self, node_id: &str) -> Result<Vec<String>>;
// --- per-task log ([[design]] §6.4) ---
/// Append a line to a task's append-only log (creating the log on first

View file

@ -0,0 +1,66 @@
//! Public-API tests for tags (tech-spec §4, §8.3): a tag is a `tag`-kind node,
//! tagging is an OR-set `tagged` link, and tag node ids are **deterministic in
//! (owner, name)** so the same name is one canonical tag (and replicas converge).
use heph_core::{FixedClock, LocalStore, NewNode, NodeKind, Store};
fn store() -> LocalStore {
LocalStore::open_in_memory(Box::new(FixedClock(1_700_000_000_000))).unwrap()
}
#[test]
fn add_lists_dedupes_and_removes_tags() {
let mut s = store();
let doc = s.create_node(NewNode::doc("Roof notes", "")).unwrap();
// Add two tags; `tags_of` returns them sorted by name.
s.add_tag(&doc.id, "house").unwrap();
s.add_tag(&doc.id, "urgent").unwrap();
assert_eq!(s.tags_of(&doc.id).unwrap(), vec!["house", "urgent"]);
// Re-adding a tag is idempotent — no duplicate `tagged` link.
s.add_tag(&doc.id, "house").unwrap();
assert_eq!(s.tags_of(&doc.id).unwrap(), vec!["house", "urgent"]);
// Removing one leaves the other.
s.remove_tag(&doc.id, "house").unwrap();
assert_eq!(s.tags_of(&doc.id).unwrap(), vec!["urgent"]);
// Removing a tag the node doesn't have is a harmless no-op.
s.remove_tag(&doc.id, "house").unwrap();
assert_eq!(s.tags_of(&doc.id).unwrap(), vec!["urgent"]);
}
#[test]
fn a_tag_name_is_one_canonical_node_shared_across_nodes() {
let mut s = store();
let a = s.create_node(NewNode::doc("A", "")).unwrap();
let b = s.create_node(NewNode::doc("B", "")).unwrap();
let ta = s.add_tag(&a.id, "shared").unwrap();
let tb = s.add_tag(&b.id, "shared").unwrap();
assert_eq!(ta.id, tb.id, "same name → one canonical tag node");
assert_eq!(ta.kind, NodeKind::Tag);
// The tag is enumerable, and there is exactly one for the name.
let tags = s.list_nodes(Some(NodeKind::Tag)).unwrap();
assert_eq!(tags.len(), 1);
assert_eq!(tags[0].title, "shared");
}
#[test]
fn add_tag_trims_and_rejects_blank_or_missing_node() {
let mut s = store();
let d = s.create_node(NewNode::doc("D", "")).unwrap();
// Leading/trailing whitespace is trimmed (so " house " == "house").
s.add_tag(&d.id, " house ").unwrap();
assert_eq!(s.tags_of(&d.id).unwrap(), vec!["house"]);
assert!(s.add_tag(&d.id, " ").is_err(), "blank tag rejected");
assert!(
s.add_tag("nope", "x").is_err(),
"missing target node rejected"
);
}

View file

@ -209,6 +209,11 @@ enum Command {
#[command(subcommand)]
action: ProjectAction,
},
/// Tag operations: add/remove a tag on a node, or list tags.
Tag {
#[command(subcommand)]
action: TagAction,
},
/// Force a sync cycle (or show sync status with --status).
Sync {
/// Show status instead of syncing.
@ -284,6 +289,29 @@ enum ProjectAction {
List,
}
#[derive(Subcommand, Debug)]
enum TagAction {
/// Tag a node.
Add {
/// Node id to tag.
node: String,
/// Tag name.
tag: String,
},
/// Remove a tag from a node.
Rm {
/// Node id.
node: String,
/// Tag name.
tag: String,
},
/// List a node's tags, or every tag in the store (with no node).
List {
/// Node id (omit to list all tags).
node: Option<String>,
},
}
#[derive(Subcommand, Debug)]
enum ConflictAction {
/// Resolve a conflict by choosing the local or remote value.
@ -638,6 +666,36 @@ fn main() -> Result<()> {
}
}
},
Command::Tag { action } => match action {
TagAction::Add { node, tag } => {
let result = client.call("tag.add", json!({ "node_id": node, "tag": tag }))?;
let t: Node = serde_json::from_value(result)?;
println!("{node} tagged #{}", t.title);
}
TagAction::Rm { node, tag } => {
client.call("tag.remove", json!({ "node_id": node, "tag": tag }))?;
println!("{node} untagged #{tag}");
}
TagAction::List { node } => {
let tags: Vec<String> = match node {
Some(node) => {
let result = client.call("tag.list", json!({ "node_id": node }))?;
serde_json::from_value(result)?
}
None => {
let result = client.call("node.list", json!({ "kind": "tag" }))?;
let nodes: Vec<Node> = serde_json::from_value(result)?;
nodes.into_iter().map(|n| n.title).collect()
}
};
if tags.is_empty() {
println!("(no tags)");
}
for t in &tags {
println!("#{t}");
}
}
},
Command::Sync { status } => {
let method = if status { "sync.status" } else { "sync.now" };
let result = client.call(method, json!({}))?;

View file

@ -235,6 +235,19 @@ impl Store for RemoteStore {
self.call_as("links.backlinks", json!({ "id": id }))
}
fn add_tag(&mut self, node_id: &str, tag: &str) -> Result<Node> {
self.call_as("tag.add", json!({ "node_id": node_id, "tag": tag }))
}
fn remove_tag(&mut self, node_id: &str, tag: &str) -> Result<()> {
self.call("tag.remove", json!({ "node_id": node_id, "tag": tag }))
.map(|_| ())
}
fn tags_of(&self, node_id: &str) -> Result<Vec<String>> {
self.call_as("tag.list", json!({ "node_id": node_id }))
}
fn log_append(&mut self, task_id: &str, text: &str) -> Result<()> {
self.call("log.append", json!({ "task_id": task_id, "text": text }))
.map(|_| ())

View file

@ -143,6 +143,14 @@ struct SetAttentionParams {
attention: Attention,
}
#[derive(Deserialize)]
struct TagParams {
node_id: String,
/// The tag name. Unused by `tag.list` (which sends only `node_id`).
#[serde(default)]
tag: String,
}
#[derive(Deserialize)]
struct SetProjectParams {
id: String,
@ -326,6 +334,19 @@ pub fn dispatch(store: &mut dyn Store, method: &str, params: Value) -> Result<Va
let p: LinkParams = parse(params)?;
json!(store.backlinks(&p.id)?)
}
"tag.add" => {
let p: TagParams = parse(params)?;
json!(store.add_tag(&p.node_id, &p.tag)?)
}
"tag.remove" => {
let p: TagParams = parse(params)?;
store.remove_tag(&p.node_id, &p.tag)?;
json!({ "ok": true })
}
"tag.list" => {
let p: TagParams = parse(params)?;
json!(store.tags_of(&p.node_id)?)
}
"log.append" => {
let p: LogAppendParams = parse(params)?;
store.log_append(&p.task_id, &p.text)?;

View file

@ -227,6 +227,46 @@ fn task_set_project_moves_and_unfiles_over_socket() {
);
}
#[test]
fn tag_add_list_remove_over_socket() {
let (socket, _dir) = spawn_daemon();
let mut c = client(&socket);
let doc = c
.call(
"node.create",
json!({ "kind": "doc", "title": "Roof notes" }),
)
.unwrap();
let id = doc["id"].as_str().unwrap().to_string();
// Add two tags (trimmed); tag.list returns them sorted.
c.call("tag.add", json!({ "node_id": id, "tag": "house" }))
.unwrap();
c.call("tag.add", json!({ "node_id": id, "tag": " urgent " }))
.unwrap();
let tags = c.call("tag.list", json!({ "node_id": id })).unwrap();
assert_eq!(tags, json!(["house", "urgent"]));
// The canonical tag set is enumerable via node.list kind=tag.
let all = c.call("node.list", json!({ "kind": "tag" })).unwrap();
let names: Vec<&str> = all
.as_array()
.unwrap()
.iter()
.map(|n| n["title"].as_str().unwrap())
.collect();
assert_eq!(names, vec!["house", "urgent"]);
// Remove one.
c.call("tag.remove", json!({ "node_id": id, "tag": "house" }))
.unwrap();
assert_eq!(
c.call("tag.list", json!({ "node_id": id })).unwrap(),
json!(["urgent"])
);
}
#[test]
fn promote_context_item_over_socket() {
let (socket, _dir) = spawn_daemon();

View file

@ -28,3 +28,4 @@ Begin the v1 prototype (Phase 1, tech-spec §11.1), built in TDD slices:
- `heph-tui` task-list visuals (§8.1): each row now leads with an attention **flag** (`⚑`, colored red/orange/blue; blank for white) and a **project-colored bullet** — the bullet's color is derived stably from the project id (so it survives projects being added/removed), letting you scan a mixed list by project at a glance. The list also grows a **scrollbar** and keeps the selected task scrolled into view when there are more tasks than fit.
- `heph-tui` sort toggle (§8.1): **`s`** flips the task list between two orders — **default** (attention → most-overdue → project → creation) and **by-project** (grouped under dimmed `──── Project ────` separators, then the same sub-order). The view's filter still applies first. (To free `s`, **skip** moved to **`S`**.)
- `heph.nvim` task-view rows (§8): `:Heph next`/`:Heph list` rows now show a compact **do/late date chip** (and a recurrence `↻`), so you can see scheduling at a glance; `<CR>` still jumps to a task's context doc.
- Tags (§4, §8.3): nodes can now be **tagged**. A tag is a `tag`-kind node whose id is deterministic in `(owner, name)`, so the same name is **one canonical tag** shared across everything it's applied to (and replicas converge — no duplicate tags). Tagging is an OR-set link, so adding/removing is idempotent and merge-safe. Surfaced as `tag.add`/`tag.remove`/`tag.list` RPCs and `heph tag add|rm|list` (list a node's tags, or every tag with no node). Tag names are trimmed; a canonical case/spelling normalization is deferred to the future zk import. This is the groundwork for the `tags:` line of the upcoming frontmatter edit surface.

View file

@ -451,7 +451,7 @@ See [[design]] §5§7 for the constraints later phases impose on present choi
- ✅ **(e) scrollbar — DONE:** the task list grows a `ratatui` `Scrollbar` (tracking the selection) when content overflows; a `ListState` selection keeps the highlighted row scrolled into view.
- ✅ **(b) sort toggle `s` — DONE:** **default**: attention (red→orange→white→blue) → days-overdue (descending; no-date = 0) → project (name) → `created_at` (FIFO). **project mode**: project is primary, with dimmed **`──── Name ────` separators** riding atop each group's first task (the cursor only lands on real tasks). View filtering always runs **before** the sort. (`skip` moved to `S` to free `s`.)
- ✅ **nvim task-navigation polish (§8) — DONE:** `:Heph next`/`list` rows now carry a compact **do/late date chip** (and a recurrence `↻`); `<CR>` already jumps to a row's canonical-context doc (read/navigate, not field-edit).
3. **Tags (§4, §8.3) — promoted from deferred:** `NodeKind::Tag` exists but has no machinery. Add **tag-as-node + an OR-set tag link** (mirroring `in-project`) + `tag.add`/`tag.remove` RPCs + enumeration. Prerequisite for the `tags:` line of the frontmatter surface (§8.3) and the eventual zk import ([[design]]); the goal is **one canonical tag set** across all of heph.
3. **Tags (§4, §8.3) — DONE:** a tag is a `tag`-kind node whose id is **deterministic in `(owner, name)`** (`tag:<owner>:<name>`, like the journal), so a name is one canonical tag and replicas converge — no duplicate tag nodes. Tagging is an **OR-set `tagged` link** (mirroring `in-project`): `Store::add_tag` (get-or-create the tag node, idempotent link), `remove_tag` (tombstone the link), `tags_of` (sorted names); enumerate all tags via `list_nodes(Tag)`. RPCs `tag.add`/`tag.remove`/`tag.list` (+ RemoteStore forward); CLI `heph tag add|rm|list`. Names are trimmed, case preserved (canonical normalization deferred to the zk import). Unblocks the `tags:` line of the frontmatter surface (§8.3) and the eventual zk import; inline `#hashtags` remain a heph.nvim concern (§8.3).
4. ⏳ **YAML frontmatter as an edit surface (§8.3) — docs-first C1:** generated-on-read, stripped-and-ignored-on-write in `heph-core`; `heph.nvim` diffs it into structured RPCs. See §8.3.
5. ⏳ **Wiki-links by node id (§8.4) — docs-first C1 (maybe C2):** canonical `[[NODEID]]` at rest, expanded/concealed for display; a `[[` picker; no name-links in the DB. Includes a one-time body fixup. See §8.4.
6. ⏳ **`heph.nvim` slice 11d (§6/§8) — DEFERRED, post-parity:** daemon **server-push** notification framing (no-`id` lines on the socket; the client read-loop already demuxes them) + the **dirty-buffer reconcile** (the §8 "known-hard" case) + the "update-arrives-while-open" e2e (§9).