Merge pull request 'heph-tui sync health: last-sync age, pending conflicts, auth-failure indicator' (#11) from feature/tui-sync-health into main
All checks were successful
Build / validate (push) Successful in 8m0s

This commit is contained in:
Erich Blume 2026-06-06 11:03:00 -07:00
commit 02a8dd5180
11 changed files with 364 additions and 16 deletions

View file

@ -9,7 +9,7 @@ use anyhow::Result;
use chrono::NaiveDate;
use heph_core::{Attention, ProjectOverview, RankedTask, SchedulePatch, TaskState, BUILTIN_VIEWS};
use crate::backend::{Backend, Project, SearchHit};
use crate::backend::{Backend, Project, SearchHit, SyncStatus};
use crate::fmt::{days_overdue, today_local};
/// How the task list is ordered (toggled in the UI, §8.1).
@ -433,6 +433,8 @@ pub struct App<B: Backend> {
undo_stack: Vec<UndoEntry>,
redo_stack: Vec<UndoEntry>,
pub status: String,
/// Latest sync health for the status-line indicator (refreshed on a tick).
pub sync: SyncStatus,
pub should_quit: bool,
}
@ -472,12 +474,23 @@ impl<B: Backend> App<B> {
undo_stack: Vec::new(),
redo_stack: Vec::new(),
status: String::new(),
sync: SyncStatus::default(),
should_quit: false,
};
app.reload();
app.refresh_sync();
Ok(app)
}
/// Refresh the sync-health snapshot for the status line. Best-effort: a
/// failed read leaves the previous snapshot in place (a stale indicator
/// beats a flicker), so this never disrupts navigation.
pub fn refresh_sync(&mut self) {
if let Ok(status) = self.backend.sync_status() {
self.sync = status;
}
}
/// The title shown above the task list (the selected source).
pub fn task_pane_title(&self) -> String {
match self.sidebar.get(self.sidebar_cursor) {

View file

@ -22,6 +22,35 @@ pub struct SearchHit {
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.
@ -56,6 +85,11 @@ pub trait Backend {
/// 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) ---
@ -170,6 +204,11 @@ impl Backend for ClientBackend {
.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(())

View file

@ -25,6 +25,27 @@ pub fn today_local() -> NaiveDate {
Local::now().date_naive()
}
/// Now, in epoch milliseconds (the reference for [`fmt_age`]).
pub fn now_ms() -> i64 {
Local::now().timestamp_millis()
}
/// A compact "how long ago" for the sync indicator: `just now` under a minute,
/// then `Nm` / `Nh` / `Nd`. Clamped at zero so a little clock skew never shows a
/// negative age.
pub fn fmt_age(now_ms: i64, then_ms: i64) -> String {
let secs = (now_ms - then_ms).max(0) / 1000;
if secs < 60 {
"just now".into()
} else if secs < 3_600 {
format!("{}m", secs / 60)
} else if secs < 86_400 {
format!("{}h", secs / 3_600)
} else {
format!("{}d", secs / 86_400)
}
}
/// How many days past its do-date a task is (0 if not overdue, no do-date, or
/// future-dated). The "how overdue" signal the agenda sort ranks on (§8.1).
pub fn days_overdue(do_date: Option<i64>, today: NaiveDate) -> i64 {
@ -102,6 +123,18 @@ mod tests {
assert_eq!(fmt_date(ms(day(2027, 1, 1)), today), "2027-01-01");
}
#[test]
fn age_is_compact_and_clamped() {
let now = 1_000_000_000_000;
assert_eq!(fmt_age(now, now), "just now");
assert_eq!(fmt_age(now, now - 30_000), "just now");
assert_eq!(fmt_age(now, now - 5 * 60_000), "5m");
assert_eq!(fmt_age(now, now - 3 * 3_600_000), "3h");
assert_eq!(fmt_age(now, now - 2 * 86_400_000), "2d");
// Clock skew (then in the future) never shows a negative age.
assert_eq!(fmt_age(now, now + 10_000), "just now");
}
#[test]
fn project_color_is_stable_distinct_and_neutral_when_absent() {
assert_eq!(project_color(None), Color::DarkGray);

View file

@ -61,14 +61,22 @@ fn run<B: heph_tui::Backend>(
mut app: App<B>,
socket: &std::path::Path,
) -> Result<()> {
// Poll with a timeout so the sync indicator's age advances and a sync
// failure surfaces within a couple of seconds even while the user is idle.
let tick = std::time::Duration::from_secs(2);
loop {
terminal.draw(|f| ui::render(f, &app))?;
if let Event::Key(key) = event::read()? {
if key.kind == KeyEventKind::Press {
if let Some(action) = handle_key(&mut app, key) {
perform(terminal, &mut app, socket, action)?;
if event::poll(tick)? {
if let Event::Key(key) = event::read()? {
if key.kind == KeyEventKind::Press {
if let Some(action) = handle_key(&mut app, key) {
perform(terminal, &mut app, socket, action)?;
}
}
}
} else {
// Idle tick: refresh the sync-health snapshot for the status line.
app.refresh_sync();
}
if app.should_quit {
return Ok(());

View file

@ -3,7 +3,7 @@
use heph_core::Attention;
use ratatui::{
layout::{Constraint, Direction, Layout, Margin, Rect},
layout::{Alignment, Constraint, Direction, Layout, Margin, Rect},
style::{Color, Modifier, Style},
text::{Line, Span},
widgets::{
@ -14,8 +14,8 @@ use ratatui::{
};
use crate::app::{App, Focus, InputState, Mode, MoveOption, MoveState, SidebarEntry, SortMode};
use crate::backend::Backend;
use crate::fmt::{fmt_date, project_color, today_local};
use crate::backend::{Backend, SyncStatus};
use crate::fmt::{fmt_age, fmt_date, now_ms, project_color, today_local};
// Task-pane gestures (the focused pane shows its own hints, §8.1).
const HINTS: &str =
@ -538,5 +538,133 @@ fn render_status<B: Backend>(frame: &mut Frame, app: &App<B>, area: Rect) {
} else {
Style::default().fg(Color::DarkGray)
};
frame.render_widget(Paragraph::new(Line::from(Span::styled(text, style))), area);
let left = Paragraph::new(Line::from(Span::styled(text, style)));
// A right-aligned sync indicator (spoke only); the hints take the rest.
let indicator = sync_indicator(&app.sync, now_ms());
if indicator.is_empty() {
frame.render_widget(left, area);
return;
}
let ind_w: usize = indicator.iter().map(|s| s.content.chars().count()).sum();
let cols =
Layout::horizontal([Constraint::Min(1), Constraint::Length(ind_w as u16 + 1)]).split(area);
frame.render_widget(left, cols[0]);
frame.render_widget(
Paragraph::new(Line::from(indicator)).alignment(Alignment::Right),
cols[1],
);
}
/// The status-line sync indicator (empty on a standalone instance): a sync-state
/// chip — `⚠ auth` when re-login is needed, `⟳ <age>` since the last successful
/// sync, `⚠ offline` when erroring, `⟳ …` before the first sync — plus a
/// conflict chip when any merge conflicts are pending.
fn sync_indicator(sync: &SyncStatus, now: i64) -> Vec<Span<'static>> {
if sync.hub_url.is_none() {
return Vec::new();
}
let dim = Style::default().fg(Color::DarkGray);
let red = Style::default().fg(Color::Red).add_modifier(Modifier::BOLD);
let yellow = Style::default().fg(Color::Yellow);
let health = sync.health.clone().unwrap_or_default();
let mut spans = vec![if health.auth_failure {
Span::styled("⚠ auth", red)
} else if let Some(ts) = health.last_success_ms {
Span::styled(format!("{}", fmt_age(now, ts)), dim)
} else if health.last_error.is_some() {
Span::styled("⚠ offline", yellow)
} else {
Span::styled("⟳ …", dim)
}];
if sync.conflicts > 0 {
let label = if sync.conflicts == 1 {
"1 conflict".to_string()
} else {
format!("{} conflicts", sync.conflicts)
};
spans.push(Span::raw(" "));
spans.push(Span::styled(format!("{label}"), red));
}
spans
}
#[cfg(test)]
mod tests {
use super::*;
use crate::backend::SyncHealth;
fn render(sync: &SyncStatus, now: i64) -> String {
sync_indicator(sync, now)
.iter()
.map(|s| s.content.to_string())
.collect()
}
const NOW: i64 = 1_000_000_000_000;
fn spoke(health: SyncHealth, conflicts: usize) -> SyncStatus {
SyncStatus {
hub_url: Some("http://hub:8787".into()),
conflicts,
health: Some(health),
}
}
#[test]
fn standalone_shows_no_indicator() {
assert!(sync_indicator(&SyncStatus::default(), NOW).is_empty());
}
#[test]
fn indicator_reflects_each_sync_state() {
// Recently synced → a dim age chip.
let ok = spoke(
SyncHealth {
last_success_ms: Some(NOW - 5 * 60_000),
..Default::default()
},
0,
);
assert_eq!(render(&ok, NOW), "⟳ 5m");
// Auth failure wins over age (it's the actionable state).
let auth = spoke(
SyncHealth {
last_success_ms: Some(NOW - 60_000),
auth_failure: true,
..Default::default()
},
0,
);
assert_eq!(render(&auth, NOW), "⚠ auth");
// Errored with no prior success → offline.
let offline = spoke(
SyncHealth {
last_error: Some("error sending request".into()),
..Default::default()
},
0,
);
assert_eq!(render(&offline, NOW), "⚠ offline");
// Before the first sync.
assert_eq!(render(&spoke(SyncHealth::default(), 0), NOW), "⟳ …");
}
#[test]
fn conflicts_chip_appends_and_pluralizes() {
let h = SyncHealth {
last_success_ms: Some(NOW),
..Default::default()
};
assert_eq!(
render(&spoke(h.clone(), 1), NOW),
"⟳ just now ⚠ 1 conflict"
);
assert_eq!(render(&spoke(h, 3), NOW), "⟳ just now ⚠ 3 conflicts");
}
}

View file

@ -98,6 +98,12 @@ fn agenda_renders_views_projects_and_tasks() {
// The red/orange tasks carry a flag glyph in the leading column (§8.1).
assert!(s.contains('⚑'), "attention flag glyph missing:\n{s}");
assert!(s.contains("Preview"), "preview pane missing:\n{s}");
// A standalone daemon (no hub) shows no sync indicator — the `sync.status`
// RPC round-trips and reports `hub_url: null`.
assert!(
!s.contains('⟳'),
"sync indicator should be hidden without a hub:\n{s}"
);
}
#[test]

View file

@ -10,9 +10,10 @@
//! ops with the configured hub (tech-spec §6.1, §12).
use std::sync::{Arc, Mutex};
use std::time::Duration;
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use anyhow::Result;
use serde::Serialize;
use serde_json::{json, Value};
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
use tokio::net::{UnixListener, UnixStream};
@ -32,6 +33,23 @@ struct SpokeAuth {
client_id: String,
}
/// A spoke's observed sync health, updated after every exchange (background loop
/// or manual `sync.now`). Surfaced by `sync.status` so clients can show whether
/// sync is actually working instead of trusting silence (tech-spec §3.1 / the
/// `Spoke sync health` task). All times are epoch ms; `None` means "not yet".
#[derive(Clone, Default, Serialize)]
struct SyncHealth {
/// When we last attempted an exchange.
last_attempt_ms: Option<i64>,
/// When we last completed one without error (the "last synced" time).
last_success_ms: Option<i64>,
/// The last error message, cleared on the next success.
last_error: Option<String>,
/// Whether the most recent attempt failed authentication (a 401) — the
/// "re-auth needed" signal, distinct from a transient network blip.
auth_failure: bool,
}
/// The shared, cheaply-cloneable context each connection serves from.
#[derive(Clone)]
struct Ctx {
@ -43,6 +61,41 @@ struct Ctx {
auth: Option<SpokeAuth>,
/// Opt-in self-update config (`Some` ⇒ enabled, tech-spec self-update card).
self_update: Option<SelfUpdateConfig>,
/// Live sync health, shared between the background loop and `sync.status`.
sync_health: Arc<Mutex<SyncHealth>>,
}
/// Epoch-ms wall clock (the daemon may read it; only `heph-core` is clock-pure).
fn now_ms() -> i64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_millis() as i64)
.unwrap_or(0)
}
/// True if `e` carries an HTTP 401 — i.e. the hub rejected our bearer token.
fn is_auth_error(e: &anyhow::Error) -> bool {
e.downcast_ref::<reqwest::Error>()
.and_then(|re| re.status())
.is_some_and(|s| s == reqwest::StatusCode::UNAUTHORIZED)
}
/// Fold one exchange outcome into the shared [`SyncHealth`].
fn record_sync_outcome(health: &Arc<Mutex<SyncHealth>>, result: &Result<sync::SyncReport>) {
let now = now_ms();
let mut h = health.lock().expect("sync_health mutex poisoned");
h.last_attempt_ms = Some(now);
match result {
Ok(_) => {
h.last_success_ms = Some(now);
h.last_error = None;
h.auth_failure = false;
}
Err(e) => {
h.auth_failure = is_auth_error(e);
h.last_error = Some(e.to_string());
}
}
}
impl Ctx {
@ -87,6 +140,7 @@ impl Daemon {
.expect("building the daemon HTTP client"),
auth: None,
self_update: None,
sync_health: Arc::new(Mutex::new(SyncHealth::default())),
},
}
}
@ -170,7 +224,10 @@ impl Daemon {
loop {
tick.tick().await;
let bearer = ctx.bearer().await;
match sync::sync_once(ctx.store.clone(), &hub, &ctx.http, bearer.as_deref()).await {
let result =
sync::sync_once(ctx.store.clone(), &hub, &ctx.http, bearer.as_deref()).await;
record_sync_outcome(&ctx.sync_health, &result);
match result {
Ok(report) => tracing::debug!(?report, "background sync"),
Err(e) => tracing::warn!("background sync failed: {e}"),
}
@ -265,7 +322,9 @@ async fn sync_now(ctx: &Ctx) -> Result<Value, RpcError> {
});
};
let bearer = ctx.bearer().await;
match sync::sync_once(ctx.store.clone(), &hub_url, &ctx.http, bearer.as_deref()).await {
let result = sync::sync_once(ctx.store.clone(), &hub_url, &ctx.http, bearer.as_deref()).await;
record_sync_outcome(&ctx.sync_health, &result);
match result {
Ok(report) => Ok(json!(report)),
Err(e) => Err(RpcError {
code: INTERNAL_ERROR,
@ -274,11 +333,28 @@ async fn sync_now(ctx: &Ctx) -> Result<Value, RpcError> {
}
}
/// `sync.status` — the hub url and the current per-hub cursors.
/// `sync.status` — the hub url, the current per-hub cursors, the observed sync
/// health (last-success time / last error / auth-failure flag), and the pending
/// merge-conflict count. A spoke that is silently failing is visible here (and,
/// via it, in the TUI status line).
async fn sync_status(ctx: &Ctx) -> Result<Value, RpcError> {
// Conflict count is meaningful even on a hub / standalone instance.
let store = ctx.store.clone();
let conflicts = tokio::task::spawn_blocking(move || {
let guard = store.lock().expect("store mutex poisoned");
guard.conflicts_list().map(|c| c.len())
})
.await
.map_err(|e| RpcError {
code: INTERNAL_ERROR,
message: format!("sync.status task failed: {e}"),
})?
.map_err(RpcError::from)?;
let Some(hub_url) = ctx.hub_url.clone() else {
return Ok(json!({ "hub_url": Value::Null }));
return Ok(json!({ "hub_url": Value::Null, "conflicts": conflicts }));
};
let store = ctx.store.clone();
let hub = hub_url.clone();
let cursors = tokio::task::spawn_blocking(move || {
@ -291,5 +367,17 @@ async fn sync_status(ctx: &Ctx) -> Result<Value, RpcError> {
message: format!("sync.status task failed: {e}"),
})?
.map_err(RpcError::from)?;
Ok(json!({ "hub_url": hub_url, "cursors": cursors }))
let health = ctx
.sync_health
.lock()
.expect("sync_health mutex poisoned")
.clone();
Ok(json!({
"hub_url": hub_url,
"cursors": cursors,
"conflicts": conflicts,
"health": health,
}))
}

View file

@ -0,0 +1 @@
[[set-up-sync-hub]] now documents recommended Authentik token-validity settings (access + refresh token lifetime) to avoid frequent re-logins, with an iOS PWA storage-eviction caveat; [[host-heph-pwa]] points the PWA's login note at it.

View file

@ -0,0 +1 @@
heph-tui's status line now shows a live sync indicator for spokes: how long since the last successful sync (`⟳ 5m`), a red `⚠ auth` when the hub is rejecting the token (re-login needed), `⚠ offline` when the hub is unreachable, and a `⚠ N conflicts` chip when merge conflicts are pending. The daemon tracks this health and exposes it via `sync.status` (also visible in `heph sync --status`), so a silently-broken spoke is obvious at a glance instead of buried in the log.

View file

@ -102,6 +102,11 @@ app defaults its hub URL to its own origin.
(A manual **Bearer token** field remains as a fallback for hubs without
OIDC, or for pasting a one-off token.)
> Re-prompted for login too often? The fix is the Authentik provider's
> **refresh token validity**, not the app — see the token-lifetime note in
> [[set-up-sync-hub]]. (On iOS, Safari may also purge an un-installed PWA's
> storage after ~7 idle days; Add to Home Screen mitigates it.)
**Prerequisite — register the PWA redirect URI.** Browser PKCE needs the app's
origin registered on the Authentik `heph` provider's **Redirect URIs** (Authentik
also keys token-endpoint CORS off those origins). Add the PWA origin(s) with a

View file

@ -51,6 +51,26 @@ need:
- **Issuer** — e.g. `https://authentik.ops.eblu.me/application/o/heph/`
- **Client id** — the device-code client id (this is also the token *audience*).
### Token lifetime (avoid frequent re-logins)
Token lifetimes are set on the Authentik **provider**, not in heph — heph honors
whatever `expires_in` Authentik returns and silently refreshes using the
`offline_access` refresh token (both the CLI/daemon and the PWA do this). To
avoid re-authenticating often, set generous validities on the heph provider:
- **Access token validity** — e.g. `hours=24`. The hub validates `exp` and keeps
no revocation list, so this is the window in which a leaked token stays usable;
on a Tailscale-only hub, 2448h is a reasonable trade.
- **Refresh token validity** — e.g. `days=30`+. This is the setting that stops
the re-logins: while the refresh token is valid, the spoke **and** the PWA
renew silently with no browser round-trip. A short refresh window is the usual
cause of "I have to log in constantly".
> **iOS PWA caveat:** Safari can purge an *un-installed* PWA's `localStorage`
> (where its tokens live) after ~7 idle days regardless of these settings.
> Installing the app to the home screen mitigates it, but expect the occasional
> re-login on iOS.
## 2. Bring up the hub on `indri`
**Seed it from `gilbert` (Path A).** Quiesce `gilbert` (`heph daemon stop`),
@ -98,10 +118,16 @@ and background-syncs on its interval.
## 4. Verify
```bash
heph sync --status # last push/pull cursors, hub url
heph sync --status # hub url, last push/pull cursors, sync health
heph sync # force a cycle now
```
`heph sync --status` also reports **sync health** — the time of the last
successful exchange, any last error, and whether the spoke is currently failing
to authenticate. The same signal is surfaced live in `heph-tui`'s status line
(last-sync age · pending conflicts · an auth-failure flag), so a silently-broken
spoke is visible at a glance rather than buried in the daemon log.
Make a change on `gilbert`, force a sync, and confirm it appears via the hub.
## Current gaps (finalized by the blumeops deployment)