generated from eblume/project-template
All checks were successful
Build / validate (pull_request) Successful in 6m11s
A spoke could be silently failing to sync (expired token → 401, or hub unreachable) with the only signal buried in the daemon log. Now: - hephd tracks SyncHealth (last attempt/success time, last error, auth-failure flag) from the background sync loop and sync.now, classifying a 401 as an auth failure. sync.status returns it plus the pending merge-conflict count. - heph-tui shows a live status-line indicator (spoke only): '⟳ <age>' since the last good sync, red '⚠ auth' when re-login is needed, '⚠ offline' when the hub is unreachable, and '⚠ N conflicts' when conflicts are pending. The event loop polls on a 2s tick so the age advances and failures appear while idle. - docs: recommended Authentik access/refresh token validity to stop frequent re-logins (with the iOS PWA localStorage-eviction caveat). Closes the 'Add hub connection status to heph-tui' and 'Spoke sync health: surface unhealthy state instead of silent 401 spam' backlog items. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
287 lines
11 KiB
Rust
287 lines
11 KiB
Rust
//! The data seam between the TUI and the daemon (tech-spec §8.1).
|
|
//!
|
|
//! [`Backend`] is the small set of reads/writes the agenda needs; the App is
|
|
//! generic over it, so navigation/triage logic is unit-testable against a fake
|
|
//! and the real surface ([`ClientBackend`]) just forwards to the `hephd` unix
|
|
//! socket — the TUI never touches SQLite, same as `heph.nvim`.
|
|
|
|
use anyhow::{Context, Result};
|
|
use heph_core::{Attention, ListFilter, RankedTask, SchedulePatch};
|
|
use hephd::Client;
|
|
use serde_json::{json, Value};
|
|
|
|
/// A project node, as the sidebar lists it. Re-exported from `hephd::quickadd`
|
|
/// so the sidebar and the shared quick-add parser speak the same type.
|
|
pub use hephd::quickadd::Project;
|
|
|
|
/// A full-text search result row.
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub struct SearchHit {
|
|
pub id: String,
|
|
pub title: String,
|
|
pub kind: String,
|
|
}
|
|
|
|
/// Sync health for the status line (the `sync.status` RPC). On a standalone
|
|
/// instance `hub_url` is `None` and `health` is absent; the conflict count is
|
|
/// always present.
|
|
#[derive(Debug, Clone, Default, PartialEq, Eq, serde::Deserialize)]
|
|
pub struct SyncStatus {
|
|
/// The hub this device syncs with, or `None` if standalone (no indicator).
|
|
pub hub_url: Option<String>,
|
|
/// Pending merge conflicts awaiting resolution.
|
|
#[serde(default)]
|
|
pub conflicts: usize,
|
|
/// Observed health of the background sync loop (spoke only).
|
|
#[serde(default)]
|
|
pub health: Option<SyncHealth>,
|
|
}
|
|
|
|
/// The spoke's observed sync health (mirrors `hephd`'s `SyncHealth`).
|
|
#[derive(Debug, Clone, Default, PartialEq, Eq, serde::Deserialize)]
|
|
pub struct SyncHealth {
|
|
/// Epoch ms of the last successful exchange ("last synced"), if any.
|
|
pub last_success_ms: Option<i64>,
|
|
/// Epoch ms of the last attempt (success or failure), if any.
|
|
pub last_attempt_ms: Option<i64>,
|
|
/// The last error message, cleared on the next success.
|
|
pub last_error: Option<String>,
|
|
/// Whether the most recent attempt failed authentication (needs re-login).
|
|
#[serde(default)]
|
|
pub auth_failure: bool,
|
|
}
|
|
|
|
/// Everything the agenda surface asks of the daemon.
|
|
pub trait Backend {
|
|
/// All project nodes (for the sidebar), title-sorted.
|
|
fn projects(&mut self) -> Result<Vec<Project>>;
|
|
/// Projects enriched with parent + direct outstanding-task count, for the
|
|
/// indented, counted sidebar tree (§8.1). The default derives a flat list
|
|
/// from [`projects`](Self::projects); the real backend forwards the
|
|
/// dedicated `project.overview` RPC.
|
|
fn project_overview(&mut self) -> Result<Vec<heph_core::ProjectOverview>> {
|
|
Ok(self
|
|
.projects()?
|
|
.into_iter()
|
|
.map(|p| heph_core::ProjectOverview {
|
|
id: p.id,
|
|
title: p.title,
|
|
parent_id: None,
|
|
outstanding: 0,
|
|
})
|
|
.collect())
|
|
}
|
|
/// Run a built-in filter view (`tom|ondeck|chores|work|tasks`, §8.2).
|
|
fn view(&mut self, name: &str) -> Result<Vec<RankedTask>>;
|
|
/// Run a raw [`ListFilter`] (used for per-project scope).
|
|
fn list(&mut self, filter: &ListFilter) -> Result<Vec<RankedTask>>;
|
|
/// A node's markdown body (the canonical-context doc preview). Empty if the
|
|
/// node is bodiless or missing.
|
|
fn node_body(&mut self, id: &str) -> Result<String>;
|
|
/// The last `n` log lines for a task (the resumption breadcrumb).
|
|
fn log_tail(&mut self, task_id: &str, n: usize) -> Result<Vec<String>>;
|
|
/// Full-text search over titles + bodies (FTS5), best-match first.
|
|
fn search(&mut self, query: &str) -> Result<Vec<SearchHit>>;
|
|
/// A task's canonical-context doc id (where its description/checklist live),
|
|
/// for opening a task search-hit at the useful node. `None` if it has none.
|
|
fn context_of(&mut self, task_id: &str) -> Result<Option<String>>;
|
|
/// Sync health for the status line. The default is a standalone instance
|
|
/// (no hub, no conflicts); the real backend forwards `sync.status`.
|
|
fn sync_status(&mut self) -> Result<SyncStatus> {
|
|
Ok(SyncStatus::default())
|
|
}
|
|
|
|
// --- triage mutations (T2) ---
|
|
|
|
/// Set a task's lifecycle state (`done` rolls a recurring task forward).
|
|
fn set_state(&mut self, task_id: &str, state: &str) -> Result<()>;
|
|
/// Skip a recurring task to its next occurrence (no completion logged).
|
|
fn skip(&mut self, task_id: &str) -> Result<()>;
|
|
/// Tombstone (soft-delete) a task node — removes it from every view,
|
|
/// including recurring roll-forward. Distinct from `done`/`dropped`.
|
|
fn tombstone(&mut self, node_id: &str) -> Result<()>;
|
|
/// Set a task's attention band.
|
|
fn set_attention(&mut self, task_id: &str, attention: Attention) -> Result<()>;
|
|
/// Patch a task's schedule (do-date / late-on / recurrence), §6 double-option.
|
|
fn set_schedule(&mut self, task_id: &str, patch: SchedulePatch) -> Result<()>;
|
|
/// Re-file a task under a project (or unfile it when `project_id` is `None`).
|
|
fn set_project(&mut self, task_id: &str, project_id: Option<&str>) -> Result<()>;
|
|
/// Capture a committed task; returns its node id.
|
|
fn create_task(
|
|
&mut self,
|
|
title: &str,
|
|
attention: Option<Attention>,
|
|
do_date: Option<i64>,
|
|
recurrence: Option<&str>,
|
|
project_id: Option<&str>,
|
|
) -> Result<String>;
|
|
/// Create a new project node; returns its node id.
|
|
fn create_project(&mut self, name: &str) -> Result<String>;
|
|
/// Delete a project: unfile its tasks (→ Inbox), then tombstone the project.
|
|
fn delete_project(&mut self, project_id: &str) -> Result<()>;
|
|
}
|
|
|
|
/// The real backend: a thin client of the `hephd` unix socket.
|
|
pub struct ClientBackend {
|
|
client: Client,
|
|
}
|
|
|
|
impl ClientBackend {
|
|
pub fn new(client: Client) -> Self {
|
|
Self { client }
|
|
}
|
|
|
|
fn call(&mut self, method: &str, params: Value) -> Result<Value> {
|
|
self.client
|
|
.call(method, params)
|
|
.with_context(|| format!("rpc {method}"))
|
|
}
|
|
}
|
|
|
|
impl Backend for ClientBackend {
|
|
fn projects(&mut self) -> Result<Vec<Project>> {
|
|
let v = self.call("node.list", json!({ "kind": "project" }))?;
|
|
let nodes: Vec<heph_core::Node> = serde_json::from_value(v)?;
|
|
let mut projects: Vec<Project> = nodes
|
|
.into_iter()
|
|
.map(|n| Project {
|
|
id: n.id,
|
|
title: n.title,
|
|
})
|
|
.collect();
|
|
projects.sort_by(|a, b| a.title.cmp(&b.title));
|
|
Ok(projects)
|
|
}
|
|
|
|
fn project_overview(&mut self) -> Result<Vec<heph_core::ProjectOverview>> {
|
|
let v = self.call("project.overview", json!({}))?;
|
|
Ok(serde_json::from_value(v)?)
|
|
}
|
|
|
|
fn view(&mut self, name: &str) -> Result<Vec<RankedTask>> {
|
|
let v = self.call("view", json!({ "name": name }))?;
|
|
Ok(serde_json::from_value(v)?)
|
|
}
|
|
|
|
fn list(&mut self, filter: &ListFilter) -> Result<Vec<RankedTask>> {
|
|
let v = self.call("list", json!(filter))?;
|
|
Ok(serde_json::from_value(v)?)
|
|
}
|
|
|
|
fn node_body(&mut self, id: &str) -> Result<String> {
|
|
let v = self.call("node.get", json!({ "id": id }))?;
|
|
if v.is_null() {
|
|
return Ok(String::new());
|
|
}
|
|
let node: heph_core::Node = serde_json::from_value(v)?;
|
|
Ok(node.body.unwrap_or_default())
|
|
}
|
|
|
|
fn log_tail(&mut self, task_id: &str, n: usize) -> Result<Vec<String>> {
|
|
let v = self.call("log.tail", json!({ "task_id": task_id, "n": n }))?;
|
|
Ok(serde_json::from_value(v)?)
|
|
}
|
|
|
|
fn search(&mut self, query: &str) -> Result<Vec<SearchHit>> {
|
|
let v = self.call("search", json!({ "query": query }))?;
|
|
let nodes: Vec<heph_core::Node> = serde_json::from_value(v)?;
|
|
Ok(nodes
|
|
.into_iter()
|
|
.map(|n| SearchHit {
|
|
id: n.id,
|
|
title: n.title,
|
|
kind: n.kind.as_str().to_string(),
|
|
})
|
|
.collect())
|
|
}
|
|
|
|
fn context_of(&mut self, task_id: &str) -> Result<Option<String>> {
|
|
let v = self.call("links.outgoing", json!({ "id": task_id }))?;
|
|
let links: Vec<heph_core::Link> = serde_json::from_value(v)?;
|
|
Ok(links
|
|
.into_iter()
|
|
.find(|l| l.link_type == heph_core::LinkType::CanonicalContext)
|
|
.map(|l| l.dst_id))
|
|
}
|
|
|
|
fn sync_status(&mut self) -> Result<SyncStatus> {
|
|
let v = self.call("sync.status", json!({}))?;
|
|
Ok(serde_json::from_value(v)?)
|
|
}
|
|
|
|
fn set_state(&mut self, task_id: &str, state: &str) -> Result<()> {
|
|
self.call("task.set_state", json!({ "id": task_id, "state": state }))?;
|
|
Ok(())
|
|
}
|
|
|
|
fn skip(&mut self, task_id: &str) -> Result<()> {
|
|
self.call("task.skip", json!({ "id": task_id }))?;
|
|
Ok(())
|
|
}
|
|
|
|
fn tombstone(&mut self, node_id: &str) -> Result<()> {
|
|
self.call("node.tombstone", json!({ "id": node_id }))?;
|
|
Ok(())
|
|
}
|
|
|
|
fn set_attention(&mut self, task_id: &str, attention: Attention) -> Result<()> {
|
|
self.call(
|
|
"task.set_attention",
|
|
json!({ "id": task_id, "attention": attention }),
|
|
)?;
|
|
Ok(())
|
|
}
|
|
|
|
fn set_schedule(&mut self, task_id: &str, patch: SchedulePatch) -> Result<()> {
|
|
let mut params = json!({ "id": task_id });
|
|
let p = serde_json::to_value(&patch)?;
|
|
if let Value::Object(map) = p {
|
|
for (k, val) in map {
|
|
params[k] = val;
|
|
}
|
|
}
|
|
self.call("task.set_schedule", params)?;
|
|
Ok(())
|
|
}
|
|
|
|
fn set_project(&mut self, task_id: &str, project_id: Option<&str>) -> Result<()> {
|
|
self.call(
|
|
"task.set_project",
|
|
json!({ "id": task_id, "project_id": project_id }),
|
|
)?;
|
|
Ok(())
|
|
}
|
|
|
|
fn create_task(
|
|
&mut self,
|
|
title: &str,
|
|
attention: Option<Attention>,
|
|
do_date: Option<i64>,
|
|
recurrence: Option<&str>,
|
|
project_id: Option<&str>,
|
|
) -> Result<String> {
|
|
let v = self.call(
|
|
"task.create",
|
|
json!({
|
|
"title": title,
|
|
"attention": attention,
|
|
"do_date": do_date,
|
|
"recurrence": recurrence,
|
|
"project_id": project_id,
|
|
}),
|
|
)?;
|
|
let task: heph_core::Task = serde_json::from_value(v)?;
|
|
Ok(task.node_id)
|
|
}
|
|
|
|
fn create_project(&mut self, name: &str) -> Result<String> {
|
|
let v = self.call("node.create", json!({ "kind": "project", "title": name }))?;
|
|
let node: heph_core::Node = serde_json::from_value(v)?;
|
|
Ok(node.id)
|
|
}
|
|
|
|
fn delete_project(&mut self, project_id: &str) -> Result<()> {
|
|
self.call("project.delete", json!({ "id": project_id }))?;
|
|
Ok(())
|
|
}
|
|
}
|