hephaestus/crates/heph-core/src/sqlite/tasks.rs
Erich Blume 7f63f926d0
Some checks failed
Build / validate (pull_request) Failing after 3s
heph-core: "what is next?" ranking (tech-spec §7)
Slice 4 — the flagship Tactical blank-slate engine. Pure and
clock-injected, two stages:

- Candidacy filter: committed ∧ outstanding ∧ ¬tombstoned ∧ ≠blue ∧
  actionable (do_date NULL or ≤ now) ∧ in scope. do_date is used ONLY
  here — a boolean "can I do this now?" gate, never urgency.
- Order: an ordered list of named Dimensions applied lexicographically
  (PastLateOn → LateOverdueAmount → Attention band → CreatedAt FIFO),
  with node_id as final tiebreak for a total order. Reorder RANKING in
  one place to retune. late_on is the sole urgency signal (global tier);
  age never becomes urgency. blue hidden; red always shown past limit.

Storage `Store::next` loads candidates via a SQL join (project +
canonical-context links) and runs the pure engine with the store clock.

13 table-driven unit cases + 3 proptests (antisymmetry, sorted output
fully ordered, equality ⇒ identity) + 2 end-to-end. 38 tests green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 19:07:16 -07:00

189 lines
6.2 KiB
Rust

//! `tasks` table operations.
//!
//! A committed task is a `task` node plus a `tasks` row. On creation it also
//! gets a canonical context `doc` and a `canonical-context` link (tech-spec §6).
use rusqlite::{Connection, OptionalExtension, Row};
use super::{links, nodes};
use crate::error::{Error, Result};
use crate::model::{Attention, LinkType, NewTask, NodeKind, Task, TaskState};
use crate::ranking::{self, RankedTask};
fn from_row(row: &Row) -> rusqlite::Result<Task> {
let attention = match row.get::<_, Option<String>>("attention")? {
Some(s) => Some(
Attention::parse(&s)
.map_err(|e| rusqlite::Error::ToSqlConversionFailure(Box::new(e)))?,
),
None => None,
};
Ok(Task {
node_id: row.get("node_id")?,
attention,
do_date: row.get("do_date")?,
late_on: row.get("late_on")?,
state: TaskState::parse(&row.get::<_, String>("state")?)
.map_err(|e| rusqlite::Error::ToSqlConversionFailure(Box::new(e)))?,
recurrence: row.get("recurrence")?,
})
}
const COLUMNS: &str = "node_id, attention, do_date, late_on, state, recurrence";
/// Create a committed task: its task node, the `tasks` row, the canonical
/// context doc, the `canonical-context` link, and (if given) an `in-project`
/// link — all in one transaction.
pub(super) fn create(conn: &mut Connection, owner: &str, now: i64, input: NewTask) -> Result<Task> {
let task = Task {
node_id: String::new(), // filled below
attention: input.attention,
do_date: input.do_date,
late_on: input.late_on,
state: TaskState::Outstanding,
recurrence: input.recurrence,
};
let tx = conn.transaction()?;
let task_node = nodes::build(owner, now, NodeKind::Task, input.title.clone(), None);
nodes::insert(&tx, &task_node)?;
tx.execute(
"INSERT INTO tasks (node_id, attention, do_date, late_on, state, recurrence)
VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
(
&task_node.id,
task.attention.map(|a| a.as_str()),
task.do_date,
task.late_on,
task.state.as_str(),
&task.recurrence,
),
)?;
// The canonical context doc (the task's jumping-off point / checklist body).
let doc = nodes::build(
owner,
now,
NodeKind::Doc,
input.title.clone(),
Some(String::new()),
);
nodes::insert(&tx, &doc)?;
links::add(&tx, now, &task_node.id, &doc.id, LinkType::CanonicalContext)?;
if let Some(project_id) = &input.project_id {
links::add(&tx, now, &task_node.id, project_id, LinkType::InProject)?;
}
tx.commit()?;
Ok(Task {
node_id: task_node.id,
..task
})
}
/// Fetch a task by node id.
pub(super) fn get(conn: &Connection, node_id: &str) -> Result<Option<Task>> {
let task = conn
.query_row(
&format!("SELECT {COLUMNS} FROM tasks WHERE node_id = ?1"),
[node_id],
from_row,
)
.optional()?;
Ok(task)
}
fn require(conn: &Connection, node_id: &str) -> Result<Task> {
get(conn, node_id)?.ok_or_else(|| Error::NodeNotFound(node_id.to_string()))
}
/// Set a task's lifecycle state.
pub(super) fn set_state(
conn: &Connection,
now: i64,
node_id: &str,
state: TaskState,
) -> Result<Task> {
let updated = conn.execute(
"UPDATE tasks SET state = ?1 WHERE node_id = ?2",
(state.as_str(), node_id),
)?;
if updated == 0 {
return Err(Error::NodeNotFound(node_id.to_string()));
}
nodes::touch(conn, now, node_id)?;
require(conn, node_id)
}
/// The Tactical "what is next?" ranking for `owner` at `now` (tech-spec §7).
pub(super) fn next(
conn: &Connection,
owner: &str,
now: i64,
scope: Option<&str>,
limit: usize,
) -> Result<Vec<RankedTask>> {
let candidates = load_candidates(conn, owner)?;
Ok(ranking::rank(candidates, now, scope, limit))
}
/// 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>> {
let sql = "
SELECT n.id, n.title, n.created_at, n.tombstoned,
t.attention, t.do_date, t.late_on, t.state,
(SELECT dst_id FROM links
WHERE src_id = n.id AND type = 'in-project' AND tombstoned = 0
ORDER BY created_at, id LIMIT 1) AS project_id,
(SELECT dst_id FROM links
WHERE src_id = n.id AND type = 'canonical-context' AND tombstoned = 0
ORDER BY created_at, id LIMIT 1) AS ctx_id
FROM tasks t JOIN nodes n ON n.id = t.node_id
WHERE n.owner_id = ?1 AND n.tombstoned = 0";
let mut stmt = conn.prepare(sql)?;
let rows = stmt.query_map([owner], |row| {
let attention = match row.get::<_, Option<String>>("attention")? {
Some(s) => Some(
Attention::parse(&s)
.map_err(|e| rusqlite::Error::ToSqlConversionFailure(Box::new(e)))?,
),
None => None,
};
Ok(RankedTask {
node_id: row.get("id")?,
title: row.get("title")?,
attention,
do_date: row.get("do_date")?,
late_on: row.get("late_on")?,
state: TaskState::parse(&row.get::<_, String>("state")?)
.map_err(|e| rusqlite::Error::ToSqlConversionFailure(Box::new(e)))?,
tombstoned: row.get::<_, i64>("tombstoned")? != 0,
project_id: row.get("project_id")?,
canonical_context_id: row.get("ctx_id")?,
created_at: row.get("created_at")?,
})
})?;
Ok(rows.collect::<rusqlite::Result<Vec<_>>>()?)
}
/// Set a task's attention-state.
pub(super) fn set_attention(
conn: &Connection,
now: i64,
node_id: &str,
attention: Attention,
) -> Result<Task> {
let updated = conn.execute(
"UPDATE tasks SET attention = ?1 WHERE node_id = ?2",
(attention.as_str(), node_id),
)?;
if updated == 0 {
return Err(Error::NodeNotFound(node_id.to_string()));
}
nodes::touch(conn, now, node_id)?;
require(conn, node_id)
}