generated from eblume/project-template
Compare commits
4 commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c9bb2cbe64 | |||
|
|
1a8752f124 | ||
| 02a8dd5180 | |||
| 11aa25c9f4 |
11 changed files with 374 additions and 16 deletions
11
CHANGELOG.md
11
CHANGELOG.md
|
|
@ -12,6 +12,17 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
|
||||||
|
|
||||||
<!-- towncrier release notes start -->
|
<!-- towncrier release notes start -->
|
||||||
|
|
||||||
|
## [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
|
## [v1.2.2] - 2026-06-06
|
||||||
|
|
||||||
### Features
|
### Features
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@ use anyhow::Result;
|
||||||
use chrono::NaiveDate;
|
use chrono::NaiveDate;
|
||||||
use heph_core::{Attention, ProjectOverview, RankedTask, SchedulePatch, TaskState, BUILTIN_VIEWS};
|
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};
|
use crate::fmt::{days_overdue, today_local};
|
||||||
|
|
||||||
/// How the task list is ordered (toggled in the UI, §8.1).
|
/// 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>,
|
undo_stack: Vec<UndoEntry>,
|
||||||
redo_stack: Vec<UndoEntry>,
|
redo_stack: Vec<UndoEntry>,
|
||||||
pub status: String,
|
pub status: String,
|
||||||
|
/// Latest sync health for the status-line indicator (refreshed on a tick).
|
||||||
|
pub sync: SyncStatus,
|
||||||
pub should_quit: bool,
|
pub should_quit: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -472,12 +474,23 @@ impl<B: Backend> App<B> {
|
||||||
undo_stack: Vec::new(),
|
undo_stack: Vec::new(),
|
||||||
redo_stack: Vec::new(),
|
redo_stack: Vec::new(),
|
||||||
status: String::new(),
|
status: String::new(),
|
||||||
|
sync: SyncStatus::default(),
|
||||||
should_quit: false,
|
should_quit: false,
|
||||||
};
|
};
|
||||||
app.reload();
|
app.reload();
|
||||||
|
app.refresh_sync();
|
||||||
Ok(app)
|
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).
|
/// The title shown above the task list (the selected source).
|
||||||
pub fn task_pane_title(&self) -> String {
|
pub fn task_pane_title(&self) -> String {
|
||||||
match self.sidebar.get(self.sidebar_cursor) {
|
match self.sidebar.get(self.sidebar_cursor) {
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,35 @@ pub struct SearchHit {
|
||||||
pub kind: 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.
|
/// Everything the agenda surface asks of the daemon.
|
||||||
pub trait Backend {
|
pub trait Backend {
|
||||||
/// All project nodes (for the sidebar), title-sorted.
|
/// 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),
|
/// 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.
|
/// 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>>;
|
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) ---
|
// --- triage mutations (T2) ---
|
||||||
|
|
||||||
|
|
@ -170,6 +204,11 @@ impl Backend for ClientBackend {
|
||||||
.map(|l| l.dst_id))
|
.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<()> {
|
fn set_state(&mut self, task_id: &str, state: &str) -> Result<()> {
|
||||||
self.call("task.set_state", json!({ "id": task_id, "state": state }))?;
|
self.call("task.set_state", json!({ "id": task_id, "state": state }))?;
|
||||||
Ok(())
|
Ok(())
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,29 @@ pub fn today_local() -> NaiveDate {
|
||||||
Local::now().date_naive()
|
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: `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 {
|
||||||
|
format!("{secs}s")
|
||||||
|
} 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
|
/// 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).
|
/// future-dated). The "how overdue" signal the agenda sort ranks on (§8.1).
|
||||||
pub fn days_overdue(do_date: Option<i64>, today: NaiveDate) -> i64 {
|
pub fn days_overdue(do_date: Option<i64>, today: NaiveDate) -> i64 {
|
||||||
|
|
@ -102,6 +125,19 @@ mod tests {
|
||||||
assert_eq!(fmt_date(ms(day(2027, 1, 1)), today), "2027-01-01");
|
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), "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), "0s");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn project_color_is_stable_distinct_and_neutral_when_absent() {
|
fn project_color_is_stable_distinct_and_neutral_when_absent() {
|
||||||
assert_eq!(project_color(None), Color::DarkGray);
|
assert_eq!(project_color(None), Color::DarkGray);
|
||||||
|
|
|
||||||
|
|
@ -61,14 +61,22 @@ fn run<B: heph_tui::Backend>(
|
||||||
mut app: App<B>,
|
mut app: App<B>,
|
||||||
socket: &std::path::Path,
|
socket: &std::path::Path,
|
||||||
) -> Result<()> {
|
) -> 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 {
|
loop {
|
||||||
terminal.draw(|f| ui::render(f, &app))?;
|
terminal.draw(|f| ui::render(f, &app))?;
|
||||||
if let Event::Key(key) = event::read()? {
|
if event::poll(tick)? {
|
||||||
if key.kind == KeyEventKind::Press {
|
if let Event::Key(key) = event::read()? {
|
||||||
if let Some(action) = handle_key(&mut app, key) {
|
if key.kind == KeyEventKind::Press {
|
||||||
perform(terminal, &mut app, socket, action)?;
|
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 {
|
if app.should_quit {
|
||||||
return Ok(());
|
return Ok(());
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@
|
||||||
|
|
||||||
use heph_core::Attention;
|
use heph_core::Attention;
|
||||||
use ratatui::{
|
use ratatui::{
|
||||||
layout::{Constraint, Direction, Layout, Margin, Rect},
|
layout::{Alignment, Constraint, Direction, Layout, Margin, Rect},
|
||||||
style::{Color, Modifier, Style},
|
style::{Color, Modifier, Style},
|
||||||
text::{Line, Span},
|
text::{Line, Span},
|
||||||
widgets::{
|
widgets::{
|
||||||
|
|
@ -14,8 +14,8 @@ use ratatui::{
|
||||||
};
|
};
|
||||||
|
|
||||||
use crate::app::{App, Focus, InputState, Mode, MoveOption, MoveState, SidebarEntry, SortMode};
|
use crate::app::{App, Focus, InputState, Mode, MoveOption, MoveState, SidebarEntry, SortMode};
|
||||||
use crate::backend::Backend;
|
use crate::backend::{Backend, SyncStatus};
|
||||||
use crate::fmt::{fmt_date, project_color, today_local};
|
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).
|
// Task-pane gestures (the focused pane shows its own hints, §8.1).
|
||||||
const HINTS: &str =
|
const HINTS: &str =
|
||||||
|
|
@ -538,5 +538,130 @@ fn render_status<B: Backend>(frame: &mut Frame, app: &App<B>, area: Rect) {
|
||||||
} else {
|
} else {
|
||||||
Style::default().fg(Color::DarkGray)
|
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), "⟳ 0s ⚠ 1 conflict");
|
||||||
|
assert_eq!(render(&spoke(h, 3), NOW), "⟳ 0s ⚠ 3 conflicts");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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).
|
// 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('⚑'), "attention flag glyph missing:\n{s}");
|
||||||
assert!(s.contains("Preview"), "preview pane 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]
|
#[test]
|
||||||
|
|
|
||||||
|
|
@ -10,9 +10,10 @@
|
||||||
//! ops with the configured hub (tech-spec §6.1, §12).
|
//! ops with the configured hub (tech-spec §6.1, §12).
|
||||||
|
|
||||||
use std::sync::{Arc, Mutex};
|
use std::sync::{Arc, Mutex};
|
||||||
use std::time::Duration;
|
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
||||||
|
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
|
use serde::Serialize;
|
||||||
use serde_json::{json, Value};
|
use serde_json::{json, Value};
|
||||||
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
|
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
|
||||||
use tokio::net::{UnixListener, UnixStream};
|
use tokio::net::{UnixListener, UnixStream};
|
||||||
|
|
@ -32,6 +33,23 @@ struct SpokeAuth {
|
||||||
client_id: String,
|
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.
|
/// The shared, cheaply-cloneable context each connection serves from.
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
struct Ctx {
|
struct Ctx {
|
||||||
|
|
@ -43,6 +61,41 @@ struct Ctx {
|
||||||
auth: Option<SpokeAuth>,
|
auth: Option<SpokeAuth>,
|
||||||
/// Opt-in self-update config (`Some` ⇒ enabled, tech-spec self-update card).
|
/// Opt-in self-update config (`Some` ⇒ enabled, tech-spec self-update card).
|
||||||
self_update: Option<SelfUpdateConfig>,
|
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 {
|
impl Ctx {
|
||||||
|
|
@ -87,6 +140,7 @@ impl Daemon {
|
||||||
.expect("building the daemon HTTP client"),
|
.expect("building the daemon HTTP client"),
|
||||||
auth: None,
|
auth: None,
|
||||||
self_update: None,
|
self_update: None,
|
||||||
|
sync_health: Arc::new(Mutex::new(SyncHealth::default())),
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -170,7 +224,10 @@ impl Daemon {
|
||||||
loop {
|
loop {
|
||||||
tick.tick().await;
|
tick.tick().await;
|
||||||
let bearer = ctx.bearer().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"),
|
Ok(report) => tracing::debug!(?report, "background sync"),
|
||||||
Err(e) => tracing::warn!("background sync failed: {e}"),
|
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;
|
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)),
|
Ok(report) => Ok(json!(report)),
|
||||||
Err(e) => Err(RpcError {
|
Err(e) => Err(RpcError {
|
||||||
code: INTERNAL_ERROR,
|
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> {
|
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 {
|
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 store = ctx.store.clone();
|
||||||
let hub = hub_url.clone();
|
let hub = hub_url.clone();
|
||||||
let cursors = tokio::task::spawn_blocking(move || {
|
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}"),
|
message: format!("sync.status task failed: {e}"),
|
||||||
})?
|
})?
|
||||||
.map_err(RpcError::from)?;
|
.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,
|
||||||
|
}))
|
||||||
}
|
}
|
||||||
|
|
|
||||||
1
docs/changelog.d/+sync-age-seconds.feature.md
Normal file
1
docs/changelog.d/+sync-age-seconds.feature.md
Normal file
|
|
@ -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.
|
||||||
|
|
@ -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
|
(A manual **Bearer token** field remains as a fallback for hubs without
|
||||||
OIDC, or for pasting a one-off token.)
|
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
|
**Prerequisite — register the PWA redirect URI.** Browser PKCE needs the app's
|
||||||
origin registered on the Authentik `heph` provider's **Redirect URIs** (Authentik
|
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
|
also keys token-endpoint CORS off those origins). Add the PWA origin(s) with a
|
||||||
|
|
|
||||||
|
|
@ -51,6 +51,26 @@ need:
|
||||||
- **Issuer** — e.g. `https://authentik.ops.eblu.me/application/o/heph/`
|
- **Issuer** — e.g. `https://authentik.ops.eblu.me/application/o/heph/`
|
||||||
- **Client id** — the device-code client id (this is also the token *audience*).
|
- **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`
|
## 2. Bring up the hub on `indri`
|
||||||
|
|
||||||
**Seed it from `gilbert` (Path A).** Quiesce `gilbert` (`heph daemon stop`),
|
**Seed it from `gilbert` (Path A).** Quiesce `gilbert` (`heph daemon stop`),
|
||||||
|
|
@ -98,10 +118,16 @@ and background-syncs on its interval.
|
||||||
## 4. Verify
|
## 4. Verify
|
||||||
|
|
||||||
```bash
|
```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 # 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.
|
Make a change on `gilbert`, force a sync, and confirm it appears via the hub.
|
||||||
|
|
||||||
## Current gaps (finalized by the blumeops deployment)
|
## Current gaps (finalized by the blumeops deployment)
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue