heph-core: Organizational list, health, journal (§6, §7)

Round out the local query surface ahead of the distributed layer:

- list(scope, attention, include_blue): the Organizational survey —
  outstanding committed tasks incl. backlog, with project/attention
  filters and an include_blue toggle.
- health(): working-set tensions surfaced honestly — orange / active
  (white+orange+red) / on-deck (blue) counts; conflict_count + sync_status
  reserved for sync.
- journal.open_or_create(date): deterministic id in (owner, ISO-date)
  (§3.1) so offline replicas converge; idempotent; rejects non-ISO dates.
- Exposed over RPC (list / health / journal.open_or_create).

6 integration tests; 76 total green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Erich Blume 2026-05-31 20:40:33 -07:00
commit d749c2a428
8 changed files with 328 additions and 5 deletions

View file

@ -22,7 +22,10 @@ pub use clock::{Clock, FixedClock};
pub use error::{Error, Result};
pub use export::{render as render_export, ExportFile, NodeExport};
pub use extract::{extract, ContextItem, Extraction};
pub use model::{Attention, Link, LinkType, NewNode, NewTask, Node, NodeKind, Task, TaskState};
pub use model::{
deterministic_id, Attention, Health, Link, LinkType, NewNode, NewTask, Node, NodeKind, Task,
TaskState,
};
pub use ranking::{rank, Dimension, RankedTask, RANKING};
pub use recurrence::{next_occurrence, reset_checkboxes};
pub use sqlite::LocalStore;

View file

@ -248,6 +248,29 @@ pub struct NewTask {
pub project_id: Option<String>,
}
/// Working-set health — the §6.2 tensions, surfaced honestly (tech-spec §7).
/// Never masks overload nor manufactures calm.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Health {
/// Outstanding `orange` tasks (target ≤ 6).
pub orange_count: usize,
/// Outstanding white+orange+red — the working set (target ≤ ~30).
pub active_count: usize,
/// Outstanding `blue` tasks — on-deck/backlog (target < 100).
pub on_deck_count: usize,
/// Open merge conflicts (0 until sync lands).
pub conflict_count: usize,
/// Sync indicator (`"local"` until sync lands).
pub sync_status: String,
}
/// Deterministic id for key-unique kinds (`journal`/`tag`) so two offline
/// replicas that independently create the same logical singleton converge
/// (tech-spec §3.1, [[design]] §3.1). Content nodes use random ULIDs instead.
pub fn deterministic_id(owner_id: &str, kind: NodeKind, key: &str) -> String {
format!("{}:{owner_id}:{key}", kind.as_str())
}
/// Input for creating a node.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct NewNode {

View file

@ -25,7 +25,7 @@ use ulid::Ulid;
use crate::clock::Clock;
use crate::error::Result;
use crate::model::{Attention, Link, LinkType, NewNode, NewTask, Node, Task, TaskState};
use crate::model::{Attention, Health, Link, LinkType, NewNode, NewTask, Node, Task, TaskState};
use crate::ranking::RankedTask;
use crate::store::Store;
@ -154,6 +154,24 @@ impl Store for LocalStore {
tasks::next(&self.conn, &self.owner_id, now, scope, limit)
}
fn list(
&self,
scope: Option<&str>,
attention: Option<Attention>,
include_blue: bool,
) -> Result<Vec<Task>> {
tasks::list(&self.conn, &self.owner_id, scope, attention, include_blue)
}
fn health(&self) -> Result<Health> {
tasks::health(&self.conn, &self.owner_id)
}
fn journal_open_or_create(&mut self, date: &str) -> Result<Node> {
let now = self.clock.now_ms();
nodes::open_or_create_journal(&self.conn, &self.owner_id, now, date)
}
fn add_link(&mut self, src_id: &str, dst_id: &str, link_type: LinkType) -> Result<Link> {
let now = self.clock.now_ms();
links::add(&self.conn, now, src_id, dst_id, link_type)

View file

@ -4,7 +4,7 @@ use rusqlite::{Connection, OptionalExtension, Row};
use super::{hlc_for, links, new_id};
use crate::error::{Error, Result};
use crate::model::{NewNode, Node, NodeKind};
use crate::model::{deterministic_id, NewNode, Node, NodeKind};
/// The `nodes` columns in a fixed order, shared by every SELECT here.
pub(super) const COLUMNS: &str =
@ -74,6 +74,50 @@ pub(super) fn create(conn: &Connection, owner: &str, now: i64, input: NewNode) -
Ok(node)
}
/// Open today's (or `date`'s) journal node, creating it if absent. The id is
/// **deterministic** in `(owner, date)` so independent offline creations
/// converge (tech-spec §3.1). `date` must be an ISO `YYYY-MM-DD`.
pub(super) fn open_or_create_journal(
conn: &Connection,
owner: &str,
now: i64,
date: &str,
) -> Result<Node> {
if !is_iso_date(date) {
return Err(Error::Integrity(format!(
"journal date must be YYYY-MM-DD, got {date:?}"
)));
}
let id = deterministic_id(owner, NodeKind::Journal, date);
if let Some(existing) = get(conn, &id)? {
return Ok(existing);
}
let node = Node {
id,
owner_id: owner.to_string(),
kind: NodeKind::Journal,
title: date.to_string(),
body: Some(String::new()),
created_at: now,
modified_at: now,
hlc: hlc_for(now),
tombstoned: false,
};
insert(conn, &node)?;
Ok(node)
}
fn is_iso_date(s: &str) -> bool {
let b = s.as_bytes();
b.len() == 10
&& b[4] == b'-'
&& b[7] == b'-'
&& b.iter().enumerate().all(|(i, c)| match i {
4 | 7 => *c == b'-',
_ => c.is_ascii_digit(),
})
}
/// Fetch a node by id (tombstoned rows included).
pub(super) fn get(conn: &Connection, id: &str) -> Result<Option<Node>> {
let node = conn

View file

@ -7,7 +7,7 @@ use rusqlite::{Connection, OptionalExtension, Row};
use super::{hlc_for, links, log, nodes};
use crate::error::{Error, Result};
use crate::model::{Attention, LinkType, NewTask, NodeKind, Task, TaskState};
use crate::model::{Attention, Health, LinkType, NewTask, NodeKind, Task, TaskState};
use crate::ranking::{self, RankedTask};
use crate::recurrence;
@ -215,6 +215,85 @@ pub(super) fn next(
Ok(ranking::rank(candidates, now, scope, limit))
}
/// Enumerate outstanding committed tasks for the Organizational view (the whole
/// set incl. backlog, tech-spec §6). Optional `scope` (project) and `attention`
/// filters; `include_blue` keeps on-deck items (default true for `list`).
pub(super) fn list(
conn: &Connection,
owner: &str,
scope: Option<&str>,
attention: Option<Attention>,
include_blue: bool,
) -> Result<Vec<Task>> {
let sql = "
SELECT t.node_id, t.attention, t.do_date, t.late_on, t.state, t.recurrence,
(SELECT dst_id FROM links
WHERE src_id = t.node_id AND type = 'in-project' AND tombstoned = 0
ORDER BY created_at, id LIMIT 1) AS project_id
FROM tasks t JOIN nodes n ON n.id = t.node_id
WHERE n.owner_id = ?1 AND n.tombstoned = 0 AND t.state = 'outstanding'
ORDER BY n.created_at, n.id";
let mut stmt = conn.prepare(sql)?;
let rows = stmt.query_map([owner], |row| {
let task = from_row(row)?;
let project: Option<String> = row.get("project_id")?;
Ok((task, project))
})?;
let mut out = Vec::new();
for row in rows {
let (task, project) = row?;
if let Some(s) = scope {
if project.as_deref() != Some(s) {
continue;
}
}
if let Some(a) = attention {
if task.attention != Some(a) {
continue;
}
}
if !include_blue && task.attention == Some(Attention::Blue) {
continue;
}
out.push(task);
}
Ok(out)
}
/// Working-set health counts (tech-spec §7) — surfaced honestly.
pub(super) fn health(conn: &Connection, owner: &str) -> Result<Health> {
let mut stmt = conn.prepare(
"SELECT t.attention FROM tasks t JOIN nodes n ON n.id = t.node_id
WHERE n.owner_id = ?1 AND n.tombstoned = 0 AND t.state = 'outstanding'",
)?;
let attentions: Vec<Option<String>> = stmt
.query_map([owner], |r| r.get(0))?
.collect::<rusqlite::Result<Vec<_>>>()?;
let mut orange_count = 0;
let mut active_count = 0;
let mut on_deck_count = 0;
for a in attentions.iter().flatten() {
match a.as_str() {
"orange" => {
orange_count += 1;
active_count += 1;
}
"red" | "white" => active_count += 1,
"blue" => on_deck_count += 1,
_ => {}
}
}
Ok(Health {
orange_count,
active_count,
on_deck_count,
conflict_count: 0,
sync_status: "local".to_string(),
})
}
/// Load every non-tombstoned committed task for `owner` as a ranking candidate,
/// joining in its project and canonical-context link targets.
fn load_candidates(conn: &Connection, owner: &str) -> Result<Vec<RankedTask>> {

View file

@ -5,7 +5,7 @@
//! `RemoteStore`) is configuration. This trait is the seam.
use crate::error::Result;
use crate::model::{Attention, Link, LinkType, NewNode, NewTask, Node, Task, TaskState};
use crate::model::{Attention, Health, Link, LinkType, NewNode, NewTask, Node, Task, TaskState};
use crate::ranking::RankedTask;
/// A backend that can store and retrieve nodes, tasks, and links.
@ -63,6 +63,22 @@ pub trait Store {
/// node id; `red` items always appear regardless of `limit`.
fn next(&self, scope: Option<&str>, limit: usize) -> Result<Vec<RankedTask>>;
/// Enumerate outstanding committed tasks for the Organizational view — the
/// whole set incl. backlog (tech-spec §6). `include_blue` keeps on-deck.
fn list(
&self,
scope: Option<&str>,
attention: Option<Attention>,
include_blue: bool,
) -> Result<Vec<Task>>;
/// Working-set health — orange/active/on-deck/conflict counts (tech-spec §7).
fn health(&self) -> Result<Health>;
/// Open (creating if absent) the journal node for an ISO `date`. The id is
/// deterministic in `(owner, date)` so offline replicas converge (§3.1).
fn journal_open_or_create(&mut self, date: &str) -> Result<Node>;
// --- links ---
/// Add a typed link between two nodes.

View file

@ -0,0 +1,111 @@
//! list / health / journal — the Organizational + working-set surface (§6, §7).
use heph_core::{Attention, FixedClock, LocalStore, NewTask, Store, TaskState};
fn store() -> LocalStore {
LocalStore::open_in_memory(Box::new(FixedClock(1_700_000_000_000))).unwrap()
}
fn task(s: &mut LocalStore, title: &str, attention: Attention) -> String {
s.create_task(NewTask {
title: title.into(),
attention: Some(attention),
..Default::default()
})
.unwrap()
.node_id
}
#[test]
fn list_enumerates_outstanding_including_blue_by_default() {
let mut s = store();
task(&mut s, "white", Attention::White);
task(&mut s, "blue", Attention::Blue);
let done = task(&mut s, "done", Attention::Red);
s.set_task_state(&done, TaskState::Done).unwrap();
// Default list: outstanding only, blue included; done excluded.
let all = s.list(None, None, true).unwrap();
let titles: Vec<_> = all.iter().map(|t| t.attention.unwrap()).collect::<Vec<_>>();
assert_eq!(all.len(), 2);
assert!(titles.contains(&Attention::White));
assert!(titles.contains(&Attention::Blue));
}
#[test]
fn list_can_exclude_blue_and_filter_by_attention() {
let mut s = store();
task(&mut s, "white", Attention::White);
task(&mut s, "blue", Attention::Blue);
task(&mut s, "orange1", Attention::Orange);
task(&mut s, "orange2", Attention::Orange);
assert_eq!(s.list(None, None, false).unwrap().len(), 3); // blue excluded
assert_eq!(
s.list(None, Some(Attention::Orange), true).unwrap().len(),
2
);
}
#[test]
fn list_scopes_to_a_project() {
let mut s = store();
let project = s
.create_node(heph_core::NewNode {
kind: heph_core::NodeKind::Project,
title: "Work".into(),
body: None,
})
.unwrap();
s.create_task(NewTask {
title: "work task".into(),
attention: Some(Attention::White),
project_id: Some(project.id.clone()),
..Default::default()
})
.unwrap();
task(&mut s, "life task", Attention::White);
let scoped = s.list(Some(&project.id), None, true).unwrap();
assert_eq!(scoped.len(), 1);
}
#[test]
fn health_counts_the_working_set_honestly() {
let mut s = store();
task(&mut s, "r", Attention::Red);
task(&mut s, "o1", Attention::Orange);
task(&mut s, "o2", Attention::Orange);
task(&mut s, "w", Attention::White);
task(&mut s, "b1", Attention::Blue);
task(&mut s, "b2", Attention::Blue);
let h = s.health().unwrap();
assert_eq!(h.orange_count, 2);
assert_eq!(h.active_count, 4); // red + 2 orange + white
assert_eq!(h.on_deck_count, 2); // 2 blue
assert_eq!(h.conflict_count, 0);
assert_eq!(h.sync_status, "local");
}
#[test]
fn journal_open_or_create_is_idempotent_with_deterministic_id() {
let mut s = store();
let a = s.journal_open_or_create("2026-05-31").unwrap();
let b = s.journal_open_or_create("2026-05-31").unwrap();
assert_eq!(a.id, b.id);
assert_eq!(a.kind, heph_core::NodeKind::Journal);
assert_eq!(a.title, "2026-05-31");
// The id is deterministic in (owner, date).
assert_eq!(
a.id,
heph_core::deterministic_id(s.owner_id(), heph_core::NodeKind::Journal, "2026-05-31")
);
}
#[test]
fn journal_rejects_non_iso_dates() {
let mut s = store();
assert!(s.journal_open_or_create("May 31").is_err());
assert!(s.journal_open_or_create("2026-5-1").is_err());
}

View file

@ -138,6 +138,26 @@ struct NextParams {
limit: Option<usize>,
}
#[derive(Deserialize)]
struct ListParams {
#[serde(default)]
scope: Option<String>,
#[serde(default)]
attention: Option<Attention>,
/// Keep on-deck (blue) items; defaults to true for the survey view.
#[serde(default = "default_true")]
include_blue: bool,
}
fn default_true() -> bool {
true
}
#[derive(Deserialize)]
struct JournalParams {
date: String,
}
#[derive(Deserialize)]
struct LinkParams {
id: String,
@ -207,6 +227,15 @@ pub fn dispatch(store: &mut dyn Store, method: &str, params: Value) -> Result<Va
let p: NextParams = parse(params)?;
json!(store.next(p.scope.as_deref(), p.limit.unwrap_or(DEFAULT_LIMIT))?)
}
"list" => {
let p: ListParams = parse(params)?;
json!(store.list(p.scope.as_deref(), p.attention, p.include_blue)?)
}
"health" => json!(store.health()?),
"journal.open_or_create" => {
let p: JournalParams = parse(params)?;
json!(store.journal_open_or_create(&p.date)?)
}
"links.outgoing" => {
let p: LinkParams = parse(params)?;
json!(store.outgoing_links(&p.id)?)