From 11aa25c9f47eb9269494e03f6b31630c18a9c9c2 Mon Sep 17 00:00:00 2001 From: Erich Blume Date: Sat, 6 Jun 2026 10:19:11 -0700 Subject: [PATCH 1/3] feat(heph-tui,hephd): surface sync health (last-sync age, conflicts, auth failure) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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): '⟳ ' 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) --- crates/heph-tui/src/app.rs | 15 ++- crates/heph-tui/src/backend.rs | 39 ++++++ crates/heph-tui/src/fmt.rs | 33 +++++ crates/heph-tui/src/main.rs | 16 ++- crates/heph-tui/src/ui.rs | 136 +++++++++++++++++++- crates/heph-tui/tests/agenda.rs | 6 + crates/hephd/src/server.rs | 100 +++++++++++++- docs/changelog.d/tui-sync-health.doc.md | 1 + docs/changelog.d/tui-sync-health.feature.md | 1 + docs/how-to/host-heph-pwa.md | 5 + docs/how-to/set-up-sync-hub.md | 28 +++- 11 files changed, 364 insertions(+), 16 deletions(-) create mode 100644 docs/changelog.d/tui-sync-health.doc.md create mode 100644 docs/changelog.d/tui-sync-health.feature.md diff --git a/crates/heph-tui/src/app.rs b/crates/heph-tui/src/app.rs index 3ced067..e60a969 100644 --- a/crates/heph-tui/src/app.rs +++ b/crates/heph-tui/src/app.rs @@ -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 { undo_stack: Vec, redo_stack: Vec, 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 App { 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) { diff --git a/crates/heph-tui/src/backend.rs b/crates/heph-tui/src/backend.rs index 88beaaa..a52fd90 100644 --- a/crates/heph-tui/src/backend.rs +++ b/crates/heph-tui/src/backend.rs @@ -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, + /// Pending merge conflicts awaiting resolution. + #[serde(default)] + pub conflicts: usize, + /// Observed health of the background sync loop (spoke only). + #[serde(default)] + pub health: Option, +} + +/// 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, + /// Epoch ms of the last attempt (success or failure), if any. + pub last_attempt_ms: Option, + /// The last error message, cleared on the next success. + pub last_error: Option, + /// 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>; + /// 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 { + 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 { + 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(()) diff --git a/crates/heph-tui/src/fmt.rs b/crates/heph-tui/src/fmt.rs index 3fc7373..2383a7e 100644 --- a/crates/heph-tui/src/fmt.rs +++ b/crates/heph-tui/src/fmt.rs @@ -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, 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); diff --git a/crates/heph-tui/src/main.rs b/crates/heph-tui/src/main.rs index 27be96e..b672d7b 100644 --- a/crates/heph-tui/src/main.rs +++ b/crates/heph-tui/src/main.rs @@ -61,14 +61,22 @@ fn run( mut app: App, 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(()); diff --git a/crates/heph-tui/src/ui.rs b/crates/heph-tui/src/ui.rs index e6043b8..b457818 100644 --- a/crates/heph-tui/src/ui.rs +++ b/crates/heph-tui/src/ui.rs @@ -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(frame: &mut Frame, app: &App, 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, `⟳ ` 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> { + 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"); + } } diff --git a/crates/heph-tui/tests/agenda.rs b/crates/heph-tui/tests/agenda.rs index 917f602..84ca739 100644 --- a/crates/heph-tui/tests/agenda.rs +++ b/crates/heph-tui/tests/agenda.rs @@ -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] diff --git a/crates/hephd/src/server.rs b/crates/hephd/src/server.rs index 59826ac..30c5d5a 100644 --- a/crates/hephd/src/server.rs +++ b/crates/hephd/src/server.rs @@ -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, + /// When we last completed one without error (the "last synced" time). + last_success_ms: Option, + /// The last error message, cleared on the next success. + last_error: Option, + /// 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, /// Opt-in self-update config (`Some` ⇒ enabled, tech-spec self-update card). self_update: Option, + /// Live sync health, shared between the background loop and `sync.status`. + sync_health: Arc>, +} + +/// 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::() + .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>, result: &Result) { + 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 { }); }; 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 { } } -/// `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 { + // 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 { 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, + })) } diff --git a/docs/changelog.d/tui-sync-health.doc.md b/docs/changelog.d/tui-sync-health.doc.md new file mode 100644 index 0000000..1176653 --- /dev/null +++ b/docs/changelog.d/tui-sync-health.doc.md @@ -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. diff --git a/docs/changelog.d/tui-sync-health.feature.md b/docs/changelog.d/tui-sync-health.feature.md new file mode 100644 index 0000000..1225721 --- /dev/null +++ b/docs/changelog.d/tui-sync-health.feature.md @@ -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. diff --git a/docs/how-to/host-heph-pwa.md b/docs/how-to/host-heph-pwa.md index 63b3d76..dc22359 100644 --- a/docs/how-to/host-heph-pwa.md +++ b/docs/how-to/host-heph-pwa.md @@ -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 diff --git a/docs/how-to/set-up-sync-hub.md b/docs/how-to/set-up-sync-hub.md index a0f7706..a5b56ea 100644 --- a/docs/how-to/set-up-sync-hub.md +++ b/docs/how-to/set-up-sync-hub.md @@ -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, 24–48h 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) From 1a8752f124a3842c2fb7aef754b017b01fba6bff Mon Sep 17 00:00:00 2001 From: Forgejo Actions Date: Sat, 6 Jun 2026 11:03:45 -0700 Subject: [PATCH 2/3] Update changelog for v1.2.3 [skip ci] --- CHANGELOG.md | 11 +++++++++++ docs/changelog.d/tui-sync-health.doc.md | 1 - docs/changelog.d/tui-sync-health.feature.md | 1 - 3 files changed, 11 insertions(+), 2 deletions(-) delete mode 100644 docs/changelog.d/tui-sync-health.doc.md delete mode 100644 docs/changelog.d/tui-sync-health.feature.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 309e228..9799e3f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,17 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). +## [v1.2.3] - 2026-06-06 + +### Features + +- 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. + +### Documentation + +- [[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. + + ## [v1.2.2] - 2026-06-06 ### Features diff --git a/docs/changelog.d/tui-sync-health.doc.md b/docs/changelog.d/tui-sync-health.doc.md deleted file mode 100644 index 1176653..0000000 --- a/docs/changelog.d/tui-sync-health.doc.md +++ /dev/null @@ -1 +0,0 @@ -[[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. diff --git a/docs/changelog.d/tui-sync-health.feature.md b/docs/changelog.d/tui-sync-health.feature.md deleted file mode 100644 index 1225721..0000000 --- a/docs/changelog.d/tui-sync-health.feature.md +++ /dev/null @@ -1 +0,0 @@ -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. From c9bb2cbe648b27789f24ae89a2fabe5282c54149 Mon Sep 17 00:00:00 2001 From: Erich Blume Date: Sat, 6 Jun 2026 11:24:09 -0700 Subject: [PATCH 3/3] feat(heph-tui): show sync age in seconds under a minute MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The background sync loop runs every 30s, so the last-sync age never crossed the 60s 'just now' threshold — the chip always read 'just now', which also masked the first missed sync (age 30-60s looked identical to a fresh one). Show seconds under a minute ('⟳ 26s') so the chip is a visible heartbeat and a stalled sync surfaces ~30s sooner. Co-Authored-By: Claude Opus 4.8 (1M context) --- crates/heph-tui/src/fmt.rs | 17 ++++++++++------- crates/heph-tui/src/ui.rs | 7 ++----- docs/changelog.d/+sync-age-seconds.feature.md | 1 + 3 files changed, 13 insertions(+), 12 deletions(-) create mode 100644 docs/changelog.d/+sync-age-seconds.feature.md diff --git a/crates/heph-tui/src/fmt.rs b/crates/heph-tui/src/fmt.rs index 2383a7e..8c49ac9 100644 --- a/crates/heph-tui/src/fmt.rs +++ b/crates/heph-tui/src/fmt.rs @@ -30,13 +30,15 @@ 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. +/// A compact "how long ago" for the sync indicator: `Ns` under a minute, then +/// `Nm` / `Nh` / `Nd`. Second-granularity under a minute makes the chip a visible +/// heartbeat (the sync loop runs every 30s) and surfaces a missed beat as the age +/// climbing, rather than hiding under a flat "just now". 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() + format!("{secs}s") } else if secs < 3_600 { format!("{}m", secs / 60) } else if secs < 86_400 { @@ -126,13 +128,14 @@ mod tests { #[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), "0s"); + assert_eq!(fmt_age(now, now - 30_000), "30s"); + assert_eq!(fmt_age(now, now - 59_000), "59s"); 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"); + assert_eq!(fmt_age(now, now + 10_000), "0s"); } #[test] diff --git a/crates/heph-tui/src/ui.rs b/crates/heph-tui/src/ui.rs index b457818..6e15453 100644 --- a/crates/heph-tui/src/ui.rs +++ b/crates/heph-tui/src/ui.rs @@ -661,10 +661,7 @@ mod tests { 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"); + assert_eq!(render(&spoke(h.clone(), 1), NOW), "⟳ 0s ⚠ 1 conflict"); + assert_eq!(render(&spoke(h, 3), NOW), "⟳ 0s ⚠ 3 conflicts"); } } diff --git a/docs/changelog.d/+sync-age-seconds.feature.md b/docs/changelog.d/+sync-age-seconds.feature.md new file mode 100644 index 0000000..cf453c2 --- /dev/null +++ b/docs/changelog.d/+sync-age-seconds.feature.md @@ -0,0 +1 @@ +heph-tui's sync indicator now shows the last-sync age in seconds under a minute (`⟳ 26s`) instead of a flat `just now`, so the chip reads as a live heartbeat and a missed sync (the loop runs every 30s) shows up as the age climbing.