diff --git a/CHANGELOG.md b/CHANGELOG.md index 493e6af..9799e3f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,35 +12,6 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). -## [v1.4.2] - 2026-06-09 - -### Features - -- Attention is now set directly instead of cycled, and surfaces it as `a1`–`a4` (a1=red, a2=orange, a3=white, a4=blue) rather than the colour words. In heph-tui press `a` then `1`–`4` to set a band (the old `A` cycle and `b` push-to-blue are retired; quick-add moves to `n`); heph-quickadd and the PWA show the same `a1`–`a4` labels, and the PWA's Attn action now pops a band picker. Quick-add inline syntax changes from `p1`–`p4` to `a1`–`a4` across every capture surface. The `heph` CLI's `-a/--attention` flag now accepts `a1`–`a4`, a bare `1`–`4`, or a colour word (`red`/`orange`/`white`/`blue`). The colour mappings are unchanged. - - -## [v1.4.1] - 2026-06-08 - -### Bug Fixes - -- The `heph` CLI and `heph-tui` now survive a daemon restart. Previously the unix-socket client connected once and never reconnected, so an opt-in self-update or `heph daemon restart` left every subsequent call failing — `heph-tui` would sit on errors until relaunched. The client now reconnects on a dropped socket: a request that never went out is retried transparently, while a reply lost mid-request is surfaced (not silently retried) so a mutation is never double-applied. A long-running TUI self-heals on its next refresh tick. -- Quick-add popover (⌘'): hand keyboard focus back to the previously active app when it hides, and stop the (now invisible) overlay from intercepting clicks where it used to sit. - - -## [v1.4.0] - 2026-06-08 - -### Features - -- Spoke auth failures now tell you how to recover. When a refresh token is rejected or the hub returns 401, `hephd` records the real cause plus the exact `heph auth login --hub-url … --issuer … --client-id …` command (keyed to this spoke's hub) in its sync health. A new `heph auth status` prints that health and the re-login command, `heph sync --status`'s `last_error` carries it, and `heph-tui`'s status line points at it with a `⚠ auth · heph auth status` chip. -- `heph daemon start`/`restart` can now bake the daemon's full runtime config into the managed service — `--mode`, `--hub-url`, `--http-addr`, `--oidc-issuer`/`--oidc-audience`/`--oidc-client-id`, and `--self-update-interval-secs` (previously only the bare `--self-update` bool was wired). Regenerating preserves whatever is already baked into the on-disk plist/unit, so a bare `start`/`restart` no longer silently drops spoke/hub or self-update config. -- 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. - -### Bug Fixes - -- hephd no longer reports a rejected OAuth refresh as "identity provider unreachable". A reachable IdP that returns an HTTP error (e.g. `400 invalid_grant` once a refresh token expires/rotates) is now surfaced as a *rejection* — `identity provider rejected the request: HTTP 400 (invalid_grant): …` — with the OAuth error body, distinct from a genuine transport failure. This stops the wording from misdirecting incident response toward the network when the real fix is re-authentication. -- `heph daemon restart` on macOS no longer intermittently fails with `launchctl bootstrap failed: 5: Input/output error`. The old code bootstrapped immediately after `bootout`, racing launchd's asynchronous teardown; it now waits for the service to fully unload and retries the bootstrap. When the plist is unchanged (e.g. a plain binary upgrade) it uses `launchctl kickstart -k` to restart the loaded job atomically, sidestepping the bootout→bootstrap dance entirely. - - ## [v1.2.3] - 2026-06-06 ### Features diff --git a/Cargo.lock b/Cargo.lock index cc9b3a6..be8f974 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2237,8 +2237,6 @@ dependencies = [ "heph-core", "hephd", "libc", - "objc2 0.6.4", - "objc2-app-kit 0.3.2", "serde_json", "winit", ] diff --git a/crates/heph-core/src/model.rs b/crates/heph-core/src/model.rs index b2efb2c..783f4cf 100644 --- a/crates/heph-core/src/model.rs +++ b/crates/heph-core/src/model.rs @@ -106,36 +106,6 @@ impl Attention { other => return Err(Error::Integrity(format!("unknown attention: {other}"))), }) } - - /// The UI nomenclature (`a1`..`a4`), ordered by intensity — surfaces show - /// these instead of the colour words. The colour *mapping* is unchanged: - /// a1 = red, a2 = orange, a3 = white, a4 = blue. - pub fn ui_label(self) -> &'static str { - match self { - Attention::Red => "a1", - Attention::Orange => "a2", - Attention::White => "a3", - Attention::Blue => "a4", - } - } - - /// Parse a *user-facing* attention input: the `a1`..`a4` label, a bare digit - /// `1`..`4`, or a colour word (`red`/`orange`/`white`/`blue`). Surfaces - /// accept any of these; the colour mapping matches [`Attention::ui_label`]. - /// Use this for human input; [`Attention::parse`] is the strict storage form. - pub fn parse_input(s: &str) -> Result { - Ok(match s.trim().to_ascii_lowercase().as_str() { - "1" | "a1" | "red" => Attention::Red, - "2" | "a2" | "orange" => Attention::Orange, - "3" | "a3" | "white" => Attention::White, - "4" | "a4" | "blue" => Attention::Blue, - other => { - return Err(Error::Integrity(format!( - "unknown attention: {other} (use a1-a4, 1-4, or red/orange/white/blue)" - ))) - } - }) - } } /// A committed task's lifecycle state (tech-spec §4.3). `done` and `dropped` @@ -428,29 +398,3 @@ impl NewNode { } } } - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn parse_input_accepts_labels_digits_and_colours() { - for (inputs, want) in [ - (["a1", "1", "red"], Attention::Red), - (["a2", "2", "orange"], Attention::Orange), - (["a3", "3", "white"], Attention::White), - (["a4", "4", "blue"], Attention::Blue), - ] { - for s in inputs { - assert_eq!(Attention::parse_input(s).unwrap(), want, "input {s:?}"); - } - } - // Case-insensitive and whitespace-tolerant. - assert_eq!(Attention::parse_input(" A1 ").unwrap(), Attention::Red); - assert_eq!(Attention::parse_input("RED").unwrap(), Attention::Red); - // The a-label maps to its colour, and round-trips back to the label. - assert_eq!(Attention::Red.ui_label(), "a1"); - assert!(Attention::parse_input("p1").is_err()); - assert!(Attention::parse_input("5").is_err()); - } -} diff --git a/crates/heph-quickadd/Cargo.toml b/crates/heph-quickadd/Cargo.toml index 57bbb98..5b1889b 100644 --- a/crates/heph-quickadd/Cargo.toml +++ b/crates/heph-quickadd/Cargo.toml @@ -19,16 +19,7 @@ global-hotkey = "0.8" # macOS-only: winit for the accessory-mode activation policy (no Dock icon), # pinned to the same minor eframe carries so cargo unifies to one winit; libc -# for getppid() (orphan detection — self-exit when the supervising daemon dies); -# objc2 + objc2-app-kit to hand keyboard focus back to the previously active app -# when the popover hides (NSApplication.hide:/unhide:). Pinned to the 0.6/0.3 -# line global-hotkey already pulls in, so cargo unifies to one copy. +# for getppid() (orphan detection — self-exit when the supervising daemon dies). [target.'cfg(target_os = "macos")'.dependencies] winit = "0.30" libc = "0.2" -objc2 = "0.6" -objc2-app-kit = { version = "0.3", default-features = false, features = [ - "std", - "NSApplication", - "NSResponder", -] } diff --git a/crates/heph-quickadd/src/app.rs b/crates/heph-quickadd/src/app.rs index 707c66a..b08bf03 100644 --- a/crates/heph-quickadd/src/app.rs +++ b/crates/heph-quickadd/src/app.rs @@ -43,46 +43,46 @@ const HINT_DELAY: f64 = 2.0; /// `#project`). Unresolved `#tags` just stay in the title, so these are safe even /// though they reference projects a given store may not have. const HINTS: &[&str] = &[ - "Water plants tomorrow a2 #Chores every 3 days", - "Call the dentist fri a1", + "Water plants tomorrow p2 #Chores every 3 days", + "Call the dentist fri p1", "Email Sarah the report today", "Buy milk #Errands", - "Renew passport +30d a2", - "Review pull requests a4 #Work", + "Renew passport +30d p2", + "Review pull requests p3 #Work", "Take out recycling every other wed", - "Pay rent every 1st a1", + "Pay rent every 1st p1", "Stretch every day", "Submit timesheet every friday #Work", "Water the garden every 2 days", - "Back up the laptop every week a4", - "Book flights +1w a2 #Travel", - "Doctor appointment 2026-07-15 a1", + "Back up the laptop every week p3", + "Book flights +1w p2 #Travel", + "Doctor appointment 2026-07-15 p1", "Read a chapter today #Reading", "Standup notes every weekday #Work", "Change the air filter every 3 months", - "File taxes every April 15 a1", + "File taxes every April 15 p1", "Clean the gutters every 6 months #Home", - "Wish Mom happy birthday every May 4 a1", + "Wish Mom happy birthday every May 4 p1", "Vacuum the house every saturday #Chores", "Replace toothbrush every 3 months", - "Prep slides for monday a2 #Work", + "Prep slides for monday p2 #Work", "Walk the dog every day", - "Refill prescription every 30 days a2 #Health", + "Refill prescription every 30 days p2 #Health", "Grocery run +2d #Errands", "Mow the lawn every week #Home", - "Schedule a 1:1 with Alex thu a4 #Work", - "Send the invoice every 15th a2", + "Schedule a 1:1 with Alex thu p3 #Work", + "Send the invoice every 15th p2", "Defrost the freezer every 6 months", - "Update the resume +14d a4", + "Update the resume +14d p3", "Check smoke detectors every 6 months #Home", "Plan the sprint every other monday #Work", "Order coffee beans every 2 weeks", - "Call grandma every sunday a2", + "Call grandma every sunday p2", "Rotate the car tires every 6 months #Car", - "Weekly review every friday a2", + "Weekly review every friday p2", "Pick up dry cleaning tomorrow #Errands", - "Pay the credit card every 28th a1", - "Tidy the inbox every day a3", + "Pay the credit card every 28th p1", + "Tidy the inbox every day p4", ]; /// Pick a hint pseudo-randomly, never the same one twice in a row. No `rand` @@ -226,9 +226,6 @@ impl QuickAdd { } fn show(&mut self, ctx: &egui::Context) { - // Undo the app-level hide from the previous `hide()` so we can take focus - // again (no-op the first time / off macOS). - app_take_focus(); self.visible = true; self.focus_pending = true; self.current_hint = random_hint(self.current_hint); @@ -259,13 +256,6 @@ impl QuickAdd { ctx.send_viewport_cmd(egui::ViewportCommand::InnerSize(egui::vec2(WIN_W, BASE_H))); self.win_h_applied = BASE_H; } - // Hand keyboard focus back to the app underneath us. winit's - // `Visible(false)` alone leaves *us* the active application, so focus - // never returns and the borderless always-on-top overlay can keep eating - // clicks where it used to sit. `NSApplication.hide:` orders our windows - // fully out and activates the next app in line — exactly the one the user - // was in (no-op off macOS). - app_yield_focus(); } /// Optimistic submit: hide now, create in the background. @@ -550,14 +540,18 @@ impl QuickAdd { let mut any = false; if let Some(att) = parsed.attention { - // a1–a4 nomenclature; the colour mapping is unchanged. - let color = match att { - heph_core::Attention::Red => egui::Color32::from_rgb(0xe0, 0x6c, 0x60), - heph_core::Attention::Orange => egui::Color32::from_rgb(0xe5, 0xc0, 0x7b), - heph_core::Attention::Blue => egui::Color32::from_rgb(0x61, 0xaf, 0xef), - heph_core::Attention::White => egui::Color32::from_gray(200), + let (label, color) = match att { + heph_core::Attention::Red => { + ("⚑ red", egui::Color32::from_rgb(0xe0, 0x6c, 0x60)) + } + heph_core::Attention::Orange => { + ("⚑ orange", egui::Color32::from_rgb(0xe5, 0xc0, 0x7b)) + } + heph_core::Attention::Blue => { + ("⚑ blue", egui::Color32::from_rgb(0x61, 0xaf, 0xef)) + } + heph_core::Attention::White => ("⚑ white", egui::Color32::from_gray(200)), }; - let label = format!("⚑ {}", att.ui_label()); ui.label(egui::RichText::new(label).color(color).size(LABEL_SIZE)); any = true; } @@ -593,7 +587,7 @@ impl QuickAdd { if !any { ui.label( - egui::RichText::new("type a1–a4 · #project · a date · every …") + egui::RichText::new("type p1–p4 · #project · a date · every …") .color(egui::Color32::from_gray(140)) .size(LABEL_SIZE), ); @@ -602,39 +596,6 @@ impl QuickAdd { } } -/// Hide the popover at the *application* level so macOS hands keyboard focus -/// back to the previously active app. `NSApplication.hide:` orders all our -/// windows out and activates the next app in line — the one the user was in — -/// which a plain winit `Visible(false)` does not do. No-op off macOS. -#[cfg(target_os = "macos")] -fn app_yield_focus() { - use objc2::MainThreadMarker; - use objc2_app_kit::NSApplication; - // eframe's `update` runs on the main thread, so this marker is always Some. - if let Some(mtm) = MainThreadMarker::new() { - NSApplication::sharedApplication(mtm).hide(None); - } -} - -#[cfg(not(target_os = "macos"))] -fn app_yield_focus() {} - -/// Undo [`app_yield_focus`]: clear the app-level hidden flag before re-showing, -/// so the window the viewport `Focus` command then makes key actually appears. -/// (`unhide:` also re-activates us; the per-window `Focus`/`Visible` viewport -/// commands do the rest.) No-op off macOS. -#[cfg(target_os = "macos")] -fn app_take_focus() { - use objc2::MainThreadMarker; - use objc2_app_kit::NSApplication; - if let Some(mtm) = MainThreadMarker::new() { - NSApplication::sharedApplication(mtm).unhide(None); - } -} - -#[cfg(not(target_os = "macos"))] -fn app_take_focus() {} - /// The current parent process id, for orphan detection. `None` off macOS (where /// hephd does not supervise a helper — there is no Aqua session to inherit). fn current_parent_pid() -> Option { diff --git a/crates/heph-quickadd/src/main.rs b/crates/heph-quickadd/src/main.rs index 83763d1..2e70b81 100644 --- a/crates/heph-quickadd/src/main.rs +++ b/crates/heph-quickadd/src/main.rs @@ -1,7 +1,7 @@ //! `heph-quickadd` — the global quick-capture popover (tech-spec §8). //! //! A tiny always-warm egui agent: ⌘' shows a single-line capture field that -//! parses Todoist-style inline syntax (`a2 #Chores tomorrow every 3 days`) and +//! parses Todoist-style inline syntax (`p2 #Chores tomorrow every 3 days`) and //! creates a task over the `hephd` unix socket. It is **supervised by hephd** //! (spawned in local mode on macOS), so the user installs/manages exactly one //! service — there is no separate launch agent. diff --git a/crates/heph-tui/src/app.rs b/crates/heph-tui/src/app.rs index 51276ea..e60a969 100644 --- a/crates/heph-tui/src/app.rs +++ b/crates/heph-tui/src/app.rs @@ -289,16 +289,15 @@ fn fuzzy_match(query: &str, cand: &str) -> bool { true } -/// Map an attention-chord digit (`1`..`4`) to its band, ordered by intensity: -/// 1 = a1 (red), 2 = a2 (orange), 3 = a3 (white), 4 = a4 (blue). Any other -/// character is not an attention key. -pub fn attention_for_digit(c: char) -> Option { - match c { - '1' => Some(Attention::Red), - '2' => Some(Attention::Orange), - '3' => Some(Attention::White), - '4' => Some(Attention::Blue), - _ => None, +/// The attention cycle for the `A` gesture: default → top-of-mind → consequence +/// → on-deck → back. Mirrors the §6.2 white/orange/red/blue progression. +pub fn next_attention(current: Option) -> Attention { + match current { + Some(Attention::White) => Attention::Orange, + Some(Attention::Orange) => Attention::Red, + Some(Attention::Red) => Attention::Blue, + Some(Attention::Blue) => Attention::White, + None => Attention::White, } } @@ -430,9 +429,6 @@ pub struct App { pub search: Option, /// When `Some`, a delete is awaiting y/N confirmation. pub pending_delete: Option, - /// When `true`, an attention chord is in progress: `a` was pressed and the - /// next `1`..`4` sets the highlighted task's band (any other key cancels). - pub pending_attention: bool, /// Reversible triage history (`u` undoes, Ctrl-z redoes). undo_stack: Vec, redo_stack: Vec, @@ -475,7 +471,6 @@ impl App { sort_mode: SortMode::Default, search: None, pending_delete: None, - pending_attention: false, undo_stack: Vec::new(), redo_stack: Vec::new(), status: String::new(), @@ -727,46 +722,26 @@ impl App { self.mutate(format!("skipped: {}", t.title), |b| b.skip(&t.node_id)); } - /// Begin an attention chord: arm `pending_attention` so the next `1`..`4` - /// sets the highlighted task's band directly (§6.2). No-op (with a hint) if - /// nothing is highlighted. The chord replaces the old `A` cycle / `b` blue - /// gestures — picking a band directly never makes the task vanish out of - /// reach the way cycling past blue did. - pub fn begin_attention(&mut self) { - if self.selected_task().is_none() { - return; - } - self.pending_attention = true; - self.status = "attention: 1=a1 2=a2 3=a3 4=a4 (esc cancels)".into(); - } - - /// Resolve an armed attention chord with the pressed key. `1`..`4` set the - /// band; anything else cancels. Returns whether the key was consumed. - pub fn resolve_attention(&mut self, c: char) { - self.pending_attention = false; - let Some(att) = attention_for_digit(c) else { - self.status = "attention: cancelled".into(); - return; - }; - self.set_attention_selected(att); - } - - /// Cancel an armed attention chord (e.g. on Esc / focus change). - pub fn cancel_attention(&mut self) { - if self.pending_attention { - self.pending_attention = false; - self.status = "attention: cancelled".into(); - } - } - - /// Set the highlighted task's attention band directly (the `a`+digit chord). - pub fn set_attention_selected(&mut self, att: Attention) { + /// Cycle the highlighted task's attention band (§6.2 white→orange→red→blue). + pub fn cycle_attention_selected(&mut self) { let Some(t) = self.selected_task().cloned() else { return; }; - self.push_undo((&t).into(), TriageAction::Attention(att)); - self.mutate(format!("{}: {}", att.ui_label(), t.title), |b| { - b.set_attention(&t.node_id, att) + let next = next_attention(t.attention); + self.push_undo((&t).into(), TriageAction::Attention(next)); + self.mutate(format!("{}: {}", next.as_str(), t.title), |b| { + b.set_attention(&t.node_id, next) + }); + } + + /// Push the highlighted task to On Deck (blue) — the pressure-relief valve. + pub fn push_to_blue_selected(&mut self) { + let Some(t) = self.selected_task().cloned() else { + return; + }; + self.push_undo((&t).into(), TriageAction::Attention(Attention::Blue)); + self.mutate(format!("→ on deck: {}", t.title), |b| { + b.set_attention(&t.node_id, Attention::Blue) }); } diff --git a/crates/heph-tui/src/main.rs b/crates/heph-tui/src/main.rs index 34648fa..b672d7b 100644 --- a/crates/heph-tui/src/main.rs +++ b/crates/heph-tui/src/main.rs @@ -119,16 +119,6 @@ fn handle_key(app: &mut App, key: KeyEvent) -> Option app.resolve_attention(c), - _ => app.cancel_attention(), - } - return None; - } - // While collecting input, all keys go to the prompt. if matches!(app.mode, Mode::Input(_)) { match key.code { @@ -189,7 +179,7 @@ fn handle_key(app: &mut App, key: KeyEvent) -> Option app.focus_tasks(), // Enter: drill sidebar→tasks, or open the selected task's context in nvim. KeyCode::Enter => return app.enter().map(Action::EditContext), - KeyCode::Char('n') => app.begin_add(), + KeyCode::Char('a') => app.begin_add(), KeyCode::Char('/') => app.begin_search(), KeyCode::Char('s') => app.toggle_sort(), KeyCode::Char('u') => app.undo(), @@ -201,7 +191,8 @@ fn handle_key(app: &mut App, key: KeyEvent) -> Option app.complete_selected(), KeyCode::Char('d') => app.drop_selected(), KeyCode::Char('S') => app.skip_selected(), - KeyCode::Char('a') => app.begin_attention(), + KeyCode::Char('A') => app.cycle_attention_selected(), + KeyCode::Char('b') => app.push_to_blue_selected(), KeyCode::Char('e') => app.begin_reschedule(), KeyCode::Char('m') => app.begin_move(), KeyCode::Char('D') => app.begin_delete(), diff --git a/crates/heph-tui/src/ui.rs b/crates/heph-tui/src/ui.rs index bcd885e..6e15453 100644 --- a/crates/heph-tui/src/ui.rs +++ b/crates/heph-tui/src/ui.rs @@ -19,11 +19,11 @@ 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 = - " j/k move ⏎ edit n add x done d drop S skip e date a 1-4 attn m move D del u undo / search q quit"; + " j/k move ⏎ edit x done d drop S skip e date A attn b→blue m move D del u undo / search q quit"; // Sidebar gestures: navigation + per-project actions (no task triage here). const SIDEBAR_HINTS: &str = - " j/k move ⏎ open n add D del-project u undo s sort / search Tab tasks q quit"; + " j/k move ⏎ open a add D del-project u undo s sort / search Tab tasks q quit"; const SEARCH_HINTS: &str = " j/k move Enter open Esc exit search"; @@ -570,9 +570,7 @@ fn sync_indicator(sync: &SyncStatus, now: i64) -> Vec> { let health = sync.health.clone().unwrap_or_default(); let mut spans = vec![if health.auth_failure { - // Point at the recovery command — `heph auth status` prints the exact - // `heph auth login …` to run (the full command is too long for the bar). - Span::styled("⚠ auth · heph auth status", red) + 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() { @@ -641,7 +639,7 @@ mod tests { }, 0, ); - assert_eq!(render(&auth, NOW), "⚠ auth · heph auth status"); + assert_eq!(render(&auth, NOW), "⚠ auth"); // Errored with no prior success → offline. let offline = spoke( diff --git a/crates/heph-tui/tests/agenda.rs b/crates/heph-tui/tests/agenda.rs index 81b32d8..84ca739 100644 --- a/crates/heph-tui/tests/agenda.rs +++ b/crates/heph-tui/tests/agenda.rs @@ -175,8 +175,8 @@ fn quick_add_captures_a_task_that_appears_in_the_view() { assert!(app.tasks.is_empty()); app.begin_add(); - // Single-line NL: a1 → red, so it lands in Top of Mind (the default view). - type_and_submit(&mut app, "Call the plumber a1"); + // Single-line NL: p1 → red, so it lands in Top of Mind (the default view). + type_and_submit(&mut app, "Call the plumber p1"); assert!(app.status.contains("added"), "status: {}", app.status); assert!( @@ -304,11 +304,7 @@ fn pushing_to_blue_moves_a_task_out_of_top_of_mind() { let mut app = App::new(ClientBackend::new(client(&socket))).unwrap(); assert_eq!(app.tasks.len(), 1); - // `a` then `4` sets a4 (blue) directly — the chord that replaced push-to-blue. - app.begin_attention(); - assert!(app.pending_attention); - app.resolve_attention('4'); - assert!(!app.pending_attention); + app.push_to_blue_selected(); assert!(app.tasks.is_empty(), "blue task should leave Top of Mind"); // It now appears under On Deck (the last of the five views). diff --git a/crates/heph-tui/tests/navigation.rs b/crates/heph-tui/tests/navigation.rs index 83ec24e..1d1e7b5 100644 --- a/crates/heph-tui/tests/navigation.rs +++ b/crates/heph-tui/tests/navigation.rs @@ -218,14 +218,13 @@ fn move_task_clamps_at_the_ends() { } #[test] -fn attention_digits_map_by_intensity() { - use heph_tui::app::attention_for_digit; - assert_eq!(attention_for_digit('1'), Some(Attention::Red)); - assert_eq!(attention_for_digit('2'), Some(Attention::Orange)); - assert_eq!(attention_for_digit('3'), Some(Attention::White)); - assert_eq!(attention_for_digit('4'), Some(Attention::Blue)); - assert_eq!(attention_for_digit('5'), None); - assert_eq!(attention_for_digit('a'), None); +fn attention_cycles_white_orange_red_blue() { + use heph_tui::app::next_attention; + assert_eq!(next_attention(Some(Attention::White)), Attention::Orange); + assert_eq!(next_attention(Some(Attention::Orange)), Attention::Red); + assert_eq!(next_attention(Some(Attention::Red)), Attention::Blue); + assert_eq!(next_attention(Some(Attention::Blue)), Attention::White); + assert_eq!(next_attention(None), Attention::White); } fn type_and_submit(app: &mut App, s: &str) { @@ -249,12 +248,12 @@ fn quick_add_files_under_the_current_project_when_no_tag_given() { assert_eq!(app.task_pane_title(), "Camano"); app.begin_add(); - type_and_submit(&mut app, "Fix the dock a2"); + type_and_submit(&mut app, "Fix the dock p2"); let created = &rec.borrow().created; assert_eq!(created.len(), 1); assert_eq!(created[0].0, "Fix the dock"); - assert_eq!(created[0].1, Some(Attention::Orange)); // a2 + assert_eq!(created[0].1, Some(Attention::Orange)); // p2 assert_eq!(created[0].2, None); // no do-date assert_eq!(created[0].3, None); // no recurrence assert_eq!(created[0].4.as_deref(), Some("p1")); // current project (Camano) diff --git a/crates/heph/src/main.rs b/crates/heph/src/main.rs index 471b2a2..c327f1d 100644 --- a/crates/heph/src/main.rs +++ b/crates/heph/src/main.rs @@ -12,7 +12,7 @@ use anyhow::{bail, Context, Result}; use clap::{Parser, Subcommand}; use serde_json::{json, Value}; -use heph_core::{Attention, Node, RankedTask, Task}; +use heph_core::{Node, RankedTask, Task}; use hephd::{datespec, default_socket_path, Client, DeviceFlow, KeyringTokenStore, TokenStore}; mod service; @@ -43,7 +43,7 @@ enum Command { Task { /// The task title. title: String, - /// Attention: a1|a2|a3|a4 (or 1-4, or red|orange|white|blue). + /// Attention-state: white|orange|red|blue. #[arg(short = 'a', long)] attention: Option, /// Do-date (earliest-actionable): today|tomorrow|+3d|fri|YYYY-MM-DD. @@ -71,7 +71,7 @@ enum Command { /// Restrict to a project by NAME (subtree-expanded). e.g. --project Hephaestus. #[arg(long)] project: Option, - /// Only this attention: a1|a2|a3|a4 (or 1-4, or red|orange|white|blue). + /// Only this attention-state: white|orange|red|blue. #[arg(short = 'a', long)] attention: Option, /// Hide on-deck (blue) items. @@ -105,7 +105,7 @@ enum Command { Attention { /// Task node id. id: String, - /// a1|a2|a3|a4 (or 1-4, or red|orange|white|blue). + /// white|orange|red|blue. attention: String, }, /// Reschedule a task: change do-date / late-on / recurrence (use `none` to @@ -125,7 +125,7 @@ enum Command { /// A raw RRULE or `none`. #[arg(long)] rrule: Option, - /// Set attention: a1|a2|a3|a4 (or 1-4, or red|orange|white|blue). + /// Set attention: white|orange|red|blue. #[arg(short = 'a', long)] attention: Option, /// Re-file under a project (by name); `none` unfiles the task. @@ -138,7 +138,7 @@ enum Command { container_id: String, /// 1-based index of the context item to promote (document order). item_ref: usize, - /// Attention for the new task: a1|a2|a3|a4 (or 1-4, or red|orange|white|blue). + /// Attention for the new task: white|orange|red|blue. #[arg(short = 'a', long)] attention: Option, /// Project name to file the new task under. @@ -344,7 +344,7 @@ enum ConflictAction { }, } -#[derive(Subcommand, Debug, Clone)] +#[derive(Subcommand, Debug)] enum AuthAction { /// Log in via the device-code flow; caches the bearer token for hub sync. Login { @@ -367,9 +367,6 @@ enum AuthAction { #[arg(long)] hub_url: String, }, - /// Show this spoke's auth health and, if re-auth is needed, the exact - /// `heph auth login` command to run. Queries the daemon. - Status, } /// Run the device-code flow (or clear a token) — no daemon needed. @@ -399,63 +396,10 @@ fn run_auth(action: AuthAction) -> Result<()> { KeyringTokenStore::new(hub_url.as_str()).clear()?; println!("Logged out of {hub_url}."); } - AuthAction::Status => unreachable!("auth status is handled via the daemon"), } Ok(()) } -/// Render `heph auth status` from a `sync.status` RPC response: hub/issuer/client -/// id, whether auth is healthy or needs re-login, and — when it does — the exact -/// command to run (built daemon-side, keyed under the right hub URL). -fn print_auth_status(status: &Value) { - let Some(hub) = status.get("hub_url").and_then(Value::as_str) else { - println!("This instance is standalone (no hub configured); auth does not apply."); - return; - }; - let auth = status.get("auth"); - let issuer = auth.and_then(|a| a.get("issuer")).and_then(Value::as_str); - let client_id = auth - .and_then(|a| a.get("client_id")) - .and_then(Value::as_str); - let health = status.get("health"); - let auth_failure = health - .and_then(|h| h.get("auth_failure")) - .and_then(Value::as_bool) - .unwrap_or(false); - let last_error = health - .and_then(|h| h.get("last_error")) - .and_then(Value::as_str); - let last_success = health - .and_then(|h| h.get("last_success_ms")) - .and_then(Value::as_i64); - - println!("hub : {hub}"); - if let Some(iss) = issuer { - println!("issuer : {iss}"); - } - if let Some(cid) = client_id { - println!("client id : {cid}"); - } - println!( - "auth : {}", - if auth_failure { - "FAILED — re-authentication required" - } else if last_success.is_some() { - "ok" - } else { - "unknown (no successful sync yet)" - } - ); - if let Some(err) = last_error { - println!("last error : {err}"); - } - if auth_failure { - if let Some(cmd) = status.get("reauth_command").and_then(Value::as_str) { - println!("\nTo re-authenticate, run:\n {cmd}"); - } - } -} - fn main() -> Result<()> { let cli = Cli::parse(); @@ -463,13 +407,9 @@ fn main() -> Result<()> { if let Command::Daemon { action } = &cli.command { return service::run(action); } - // `auth login`/`logout` run locally (device-code flow + keyring); they need - // no daemon. `auth status` reads live sync health, so it falls through to the - // connected path below. - if let Command::Auth { action } = &cli.command { - if !matches!(action, AuthAction::Status) { - return run_auth(action.clone()); - } + // `auth` runs locally (device-code flow + keyring); it needs no daemon. + if let Command::Auth { action } = cli.command { + return run_auth(action); } let socket = cli.socket.unwrap_or_else(default_socket_path); @@ -489,7 +429,6 @@ fn main() -> Result<()> { recur, rrule, } => { - let attention = norm_attention(attention)?; let recurrence = recurrence_value(recur.as_deref(), rrule.as_deref())?; let project_id = resolve_project(&mut client, project.as_deref())?; let result = client.call( @@ -516,7 +455,6 @@ fn main() -> Result<()> { // `list` takes a ListFilter (tech-spec §8.2). Map the flags: a single // `--scope` id or `--project` NAME (resolved + subtree-expanded by the // daemon), a single `--attention` whitelist, and `--no-blue`. - let attention = norm_attention(attention)?; let mut filter = json!({}); if let Some(s) = scope { filter["scope"] = json!([s]); @@ -560,12 +498,11 @@ fn main() -> Result<()> { println!("Skipped occurrence of {id}"); } Command::Attention { id, attention } => { - let att = Attention::parse_input(&attention)?; client.call( "task.set_attention", - json!({ "id": id, "attention": att.as_str() }), + json!({ "id": id, "attention": attention }), )?; - println!("{id} attention → {} ({})", att.ui_label(), att.as_str()); + println!("{id} attention → {attention}"); } Command::Edit { id, @@ -591,7 +528,7 @@ fn main() -> Result<()> { if patch.len() > 1 { client.call("task.set_schedule", Value::Object(patch))?; } - if let Some(a) = norm_attention(attention)? { + if let Some(a) = attention { client.call("task.set_attention", json!({ "id": id, "attention": a }))?; } if let Some(spec) = project.as_deref() { @@ -615,7 +552,6 @@ fn main() -> Result<()> { attention, project, } => { - let attention = norm_attention(attention)?; let project_id = resolve_project(&mut client, project.as_deref())?; let result = client.call( "task.promote", @@ -854,28 +790,13 @@ fn main() -> Result<()> { let n = result.as_u64().unwrap_or(0); println!("Rewrote legacy [[Name]] links to [[id]] in {n} node(s)."); } - Command::Auth { - action: AuthAction::Status, - } => { - let result = client.call("sync.status", json!({}))?; - print_auth_status(&result); - } - Command::Auth { .. } => unreachable!("auth login/logout handled before connecting"), + Command::Auth { .. } => unreachable!("auth is handled before connecting"), Command::Daemon { .. } => unreachable!("daemon is handled before connecting"), } Ok(()) } /// Parse an optional human date into epoch-ms JSON (for `task.create`). -/// Normalize a user-facing `--attention` value to its storage colour string. -/// Accepts the `a1`..`a4` labels, a bare digit `1`..`4`, or a colour word -/// (`red`/`orange`/`white`/`blue`). `None` passes through unchanged. -fn norm_attention(a: Option) -> Result> { - a.map(|s| Attention::parse_input(&s).map(|att| att.as_str().to_string())) - .transpose() - .map_err(Into::into) -} - fn opt_date_ms(spec: Option<&str>) -> Result> { spec.map(datespec::parse_date_ms).transpose() } diff --git a/crates/heph/src/service.rs b/crates/heph/src/service.rs index 0b8928b..1c90924 100644 --- a/crates/heph/src/service.rs +++ b/crates/heph/src/service.rs @@ -13,7 +13,6 @@ use std::path::{Path, PathBuf}; use std::process::Command; -use std::time::{Duration, Instant}; use anyhow::{bail, Context, Result}; use clap::{Args, Subcommand}; @@ -495,51 +494,6 @@ fn launchd_loaded(domain_target: &str) -> bool { .unwrap_or(false) } -/// Block until `target` is no longer loaded, up to `timeout`. `launchctl bootout` -/// is asynchronous in effect — it requests teardown and returns, but launchd may -/// still be killing/reaping the job and removing its label from the domain. -/// Bootstrapping while the label lingers fails with a generic `5: Input/output -/// error`, so we wait for the label to actually disappear before re-bootstrapping. -fn wait_until_unloaded(target: &str, timeout: Duration) { - let start = Instant::now(); - while launchd_loaded(target) { - if start.elapsed() >= timeout { - break; // fall through; bootstrap's own retry covers the residual window - } - std::thread::sleep(Duration::from_millis(100)); - } -} - -/// Bootstrap the service, retrying briefly. Even once the old instance is gone, -/// launchd can momentarily return EIO while the domain settles, so a couple of -/// short retries make `start`/`restart` reliable instead of intermittently failing. -fn launchd_bootstrap(domain: &str, plist: &str) -> Result<()> { - let mut last = String::new(); - for attempt in 0..5 { - if attempt > 0 { - std::thread::sleep(Duration::from_millis(200)); - } - let (ok, err) = run_cmd("launchctl", &["bootstrap", domain, plist])?; - if ok { - return Ok(()); - } - last = err; - } - bail!("launchctl bootstrap failed: {}", last.trim()); -} - -/// Restart an already-loaded job in place (kills it, then launchd's KeepAlive — -/// `-k` forces the kill). This restarts the *loaded* job definition, so it does -/// not pick up an edited plist — callers use it only when the on-disk plist is -/// unchanged, where it sidesteps the bootout→bootstrap race entirely. -fn launchd_kickstart(target: &str) -> Result<()> { - let (ok, err) = run_cmd("launchctl", &["kickstart", "-k", target])?; - if !ok { - bail!("launchctl kickstart failed: {}", err.trim()); - } - Ok(()) -} - fn launchd(action: &DaemonAction, p: &Paths) -> Result<()> { let plist = launchd_plist_path()?; let uid = uid()?; @@ -558,7 +512,10 @@ fn launchd(action: &DaemonAction, p: &Paths) -> Result<()> { if launchd_loaded(&target) { println!("heph daemon already running ({LABEL})."); } else { - launchd_bootstrap(&domain, &plist_str(&plist)?)?; + let (ok, err) = run_cmd("launchctl", &["bootstrap", &domain, &plist_str(&plist)?])?; + if !ok { + bail!("launchctl bootstrap failed: {}", err.trim()); + } println!("heph daemon started ({LABEL})."); } } @@ -570,24 +527,14 @@ fn launchd(action: &DaemonAction, p: &Paths) -> Result<()> { let cfg = args .to_config() .fill_from(existing_config(&plist, &Manager::Launchd)); - let changed = write_if_changed( + write_if_changed( &plist, &launchd_plist(&p.hephd, &p.db, &p.socket, &p.log, &cfg), )?; - if !launchd_loaded(&target) { - // Not currently loaded — nothing to tear down, just bring it up. - launchd_bootstrap(&domain, &plist_str(&plist)?)?; - } else if changed { - // The plist changed, so launchd must re-read it: a full reload is - // required. bootout is async, so wait for the label to clear - // before bootstrapping (and bootstrap retries the residual EIO). - let _ = run_cmd("launchctl", &["bootout", &target])?; - wait_until_unloaded(&target, Duration::from_secs(5)); - launchd_bootstrap(&domain, &plist_str(&plist)?)?; - } else { - // Same definition (e.g. binary upgraded in place) — restart the - // loaded job atomically, sidestepping the bootout→bootstrap race. - launchd_kickstart(&target)?; + let _ = run_cmd("launchctl", &["bootout", &target])?; + let (ok, err) = run_cmd("launchctl", &["bootstrap", &domain, &plist_str(&plist)?])?; + if !ok { + bail!("launchctl bootstrap failed: {}", err.trim()); } println!("heph daemon restarted ({LABEL})."); } diff --git a/crates/hephd/src/auth.rs b/crates/hephd/src/auth.rs index 6b80e95..c601d90 100644 --- a/crates/hephd/src/auth.rs +++ b/crates/hephd/src/auth.rs @@ -38,45 +38,9 @@ pub enum AuthError { /// The token was present but failed validation. #[error("invalid token: {0}")] Invalid(String), - /// The identity provider could not be reached at all (DNS, TLS, connection - /// refused, timeout) — a transport failure, distinct from a rejection. + /// The identity provider could not be reached to fetch keys. #[error("identity provider unreachable: {0}")] - Unreachable(String), - /// The identity provider *was* reached but returned an HTTP error response — - /// e.g. `400 invalid_grant` on a refresh, meaning the token was rejected - /// (expired/rotated/session-invalidated), not that the IdP was down. The - /// distinction matters: "unreachable" sends debugging toward the network; - /// this points at the token/authorization. - #[error("identity provider rejected the request: {0}")] - Rejected(String), - /// Some other failure in the auth path that is neither a transport failure - /// nor an HTTP rejection — a malformed/unparseable IdP response, or a local - /// credential-store (keyring) error. Kept distinct so neither is mislabeled - /// as "unreachable". - #[error("auth error: {0}")] - Other(String), -} - -impl AuthError { - /// Build a [`AuthError::Rejected`] from an HTTP status and the OAuth error - /// body (RFC 6749 §5.2), e.g. `HTTP 400 (invalid_grant): Token is expired`. - pub fn rejected(status: u16, error: Option<&str>, description: Option<&str>) -> AuthError { - let mut msg = format!("HTTP {status}"); - if let Some(e) = error.filter(|e| !e.is_empty()) { - msg.push_str(&format!(" ({e})")); - } - if let Some(d) = description.filter(|d| !d.is_empty()) { - msg.push_str(&format!(": {d}")); - } - AuthError::Rejected(msg) - } - - /// Whether this is an authorization-level rejection (the IdP refused the - /// grant) rather than a transport failure — i.e. re-authentication is the - /// likely fix, not network troubleshooting. - pub fn is_rejection(&self) -> bool { - matches!(self, AuthError::Rejected(_)) - } + Provider(String), } /// Verifies a bearer token and returns its [`Claims`]. A trait so the hub can be @@ -128,13 +92,16 @@ impl OidcVerifier { .http .get(url) .call() - .map_err(|e| AuthError::Unreachable(e.to_string()))?; + .map_err(|e| AuthError::Provider(e.to_string()))?; if !resp.status().is_success() { - return Err(AuthError::rejected(resp.status().as_u16(), None, None)); + return Err(AuthError::Provider(format!( + "{url} returned {}", + resp.status() + ))); } resp.body_mut() .read_json() - .map_err(|e| AuthError::Unreachable(e.to_string())) + .map_err(|e| AuthError::Provider(e.to_string())) } /// Resolve the JWKS URI from the provider's discovery document. @@ -202,38 +169,3 @@ impl TokenVerifier for OidcVerifier { Some((&self.issuer, &self.audience)) } } - -#[cfg(test)] -mod tests { - use super::AuthError; - - #[test] - fn rejected_formats_status_error_and_description() { - let e = AuthError::rejected(400, Some("invalid_grant"), Some("Token is not active")); - assert!(e.is_rejection()); - assert_eq!( - e.to_string(), - "identity provider rejected the request: HTTP 400 (invalid_grant): Token is not active" - ); - } - - #[test] - fn rejected_omits_absent_or_empty_oauth_fields() { - // No OAuth body (e.g. a bare 503) → just the status. - assert_eq!( - AuthError::rejected(503, None, None).to_string(), - "identity provider rejected the request: HTTP 503" - ); - // Empty strings are treated as absent, not rendered as "()" / ": ". - assert_eq!( - AuthError::rejected(400, Some(""), Some("")).to_string(), - "identity provider rejected the request: HTTP 400" - ); - } - - #[test] - fn unreachable_is_not_a_rejection() { - assert!(!AuthError::Unreachable("connection refused".into()).is_rejection()); - assert!(!AuthError::Other("keyring locked".into()).is_rejection()); - } -} diff --git a/crates/hephd/src/client.rs b/crates/hephd/src/client.rs index 8a2bd5d..c3c008b 100644 --- a/crates/hephd/src/client.rs +++ b/crates/hephd/src/client.rs @@ -2,145 +2,59 @@ //! //! Used by the `heph` CLI and by tests. Surfaces never touch SQLite directly //! (tech-spec §3) — they go through the daemon socket, which this wraps. -//! -//! The connection self-heals across daemon restarts (opt-in self-update, `heph -//! daemon restart`): a [`call`](Client::call) that finds the socket dropped -//! reconnects. It only auto-retries when the request provably never reached the -//! daemon (a write-side failure); a reply lost *after* sending is surfaced -//! rather than retried, so a mutation is never silently double-applied. use std::io::{BufRead, BufReader, Write}; use std::os::unix::net::UnixStream; -use std::path::{Path, PathBuf}; +use std::path::Path; -use anyhow::{anyhow, Context, Result}; +use anyhow::{bail, Context, Result}; use serde_json::{json, Value}; use crate::rpc::Response; /// A connected client. One request/response per [`call`](Client::call). pub struct Client { - socket_path: PathBuf, reader: BufReader, writer: UnixStream, next_id: u64, } -/// How a single request/response exchange failed — drives the retry decision. -enum ExchangeError { - /// The request could not be written (broken pipe, reset): it never reached - /// the daemon, so retrying on a fresh connection is safe. - Send(anyhow::Error), - /// The request was sent but no reply came back (the daemon closed mid-flight, - /// e.g. it restarted): it may or may not have applied — do not retry. - Recv(anyhow::Error), - /// A well-formed RPC-level error (or an unparseable reply): the connection is - /// fine; nothing to reconnect. - Rpc(anyhow::Error), -} - -impl ExchangeError { - fn into_inner(self) -> anyhow::Error { - match self { - ExchangeError::Send(e) | ExchangeError::Recv(e) | ExchangeError::Rpc(e) => e, - } - } -} - impl Client { /// Connect to a daemon listening at `socket_path`. pub fn connect(socket_path: &Path) -> Result { - let (reader, writer) = Self::open(socket_path)?; + let stream = UnixStream::connect(socket_path) + .with_context(|| format!("connecting to hephd at {}", socket_path.display()))?; + let reader = BufReader::new(stream.try_clone()?); Ok(Client { - socket_path: socket_path.to_path_buf(), reader, - writer, + writer: stream, next_id: 1, }) } - /// Open a fresh reader/writer pair on the socket. - fn open(socket_path: &Path) -> Result<(BufReader, UnixStream)> { - let stream = UnixStream::connect(socket_path) - .with_context(|| format!("connecting to hephd at {}", socket_path.display()))?; - let reader = BufReader::new(stream.try_clone()?); - Ok((reader, stream)) - } - - /// Re-establish the connection (after the daemon restarted and dropped it). - fn reconnect(&mut self) -> Result<()> { - let (reader, writer) = Self::open(&self.socket_path)?; - self.reader = reader; - self.writer = writer; - Ok(()) - } - /// Call `method` with `params`, returning the `result` value (or an error /// carrying the RPC error's code and message). - /// - /// If the daemon has restarted and dropped the socket, this reconnects: it - /// retries transparently when the request never went out, and otherwise - /// reconnects for the next call while surfacing an error for this one (so a - /// mutation whose reply was lost is not silently re-applied). pub fn call(&mut self, method: &str, params: Value) -> Result { let id = self.next_id; self.next_id += 1; + let mut line = serde_json::to_string(&json!({ "id": id, "method": method, "params": params, }))?; line.push('\n'); - - match self.exchange(&line) { - Ok(v) => Ok(v), - Err(ExchangeError::Rpc(e)) => Err(e), - Err(ExchangeError::Send(_)) => { - // The request never reached the daemon — reconnect and retry once. - self.reconnect() - .context("hephd connection lost and reconnect failed")?; - self.exchange(&line) - .map_err(ExchangeError::into_inner) - .with_context(|| format!("retrying `{method}` after reconnect")) - } - Err(ExchangeError::Recv(e)) => { - // Sent but no reply: the daemon likely restarted mid-request. Don't - // retry (a mutation may have applied); reconnect for next time and - // surface this one. - let _ = self.reconnect(); - Err(e).context( - "hephd closed the connection mid-request (it likely restarted); \ - reconnected — re-run the action if it didn't take effect", - ) - } - } - } - - /// One request/response over the current connection, classifying failures. - fn exchange(&mut self, line: &str) -> std::result::Result { - self.writer - .write_all(line.as_bytes()) - .map_err(|e| ExchangeError::Send(e.into()))?; - self.writer - .flush() - .map_err(|e| ExchangeError::Send(e.into()))?; + self.writer.write_all(line.as_bytes())?; + self.writer.flush()?; let mut response_line = String::new(); - let read = self - .reader - .read_line(&mut response_line) - .map_err(|e| ExchangeError::Recv(e.into()))?; + let read = self.reader.read_line(&mut response_line)?; if read == 0 { - return Err(ExchangeError::Recv(anyhow!("hephd closed the connection"))); + bail!("hephd closed the connection"); } - let response: Response = - serde_json::from_str(&response_line).map_err(|e| ExchangeError::Rpc(e.into()))?; + let response: Response = serde_json::from_str(&response_line)?; if let Some(err) = response.error { - return Err(ExchangeError::Rpc(anyhow!( - "rpc error {}: {}", - err.code, - err.message - ))); + bail!("rpc error {}: {}", err.code, err.message); } Ok(response.result.unwrap_or(Value::Null)) } diff --git a/crates/hephd/src/oauth.rs b/crates/hephd/src/oauth.rs index 4af704f..53ee5f0 100644 --- a/crates/hephd/src/oauth.rs +++ b/crates/hephd/src/oauth.rs @@ -109,7 +109,7 @@ impl KeyringTokenStore { } }); keyring_core::Entry::new(&self.service, &self.account) - .map_err(|e| AuthError::Other(e.to_string())) + .map_err(|e| AuthError::Provider(e.to_string())) } } @@ -119,16 +119,16 @@ impl TokenStore for KeyringTokenStore { serde_json::from_str(&secret).ok() } fn save(&self, token: &StoredToken) -> Result<(), AuthError> { - let json = serde_json::to_string(token).map_err(|e| AuthError::Other(e.to_string()))?; + let json = serde_json::to_string(token).map_err(|e| AuthError::Provider(e.to_string()))?; self.entry()? .set_password(&json) - .map_err(|e| AuthError::Other(e.to_string())) + .map_err(|e| AuthError::Provider(e.to_string())) } fn clear(&self) -> Result<(), AuthError> { match self.entry()?.delete_credential() { Ok(()) => Ok(()), Err(keyring_core::Error::NoEntry) => Ok(()), - Err(e) => Err(AuthError::Other(e.to_string())), + Err(e) => Err(AuthError::Provider(e.to_string())), } } } @@ -187,9 +187,6 @@ impl TokenResponse { #[derive(Debug, Deserialize)] struct TokenErrorBody { error: String, - /// Human-readable detail the provider may include (RFC 6749 §5.2). - #[serde(default)] - error_description: Option, } /// Drives the OAuth 2.0 device-code flow against one provider. @@ -211,14 +208,17 @@ impl DeviceFlow { let mut resp = http .get(&url) .call() - .map_err(|e| AuthError::Unreachable(e.to_string()))?; + .map_err(|e| AuthError::Provider(e.to_string()))?; if !resp.status().is_success() { - return Err(AuthError::rejected(resp.status().as_u16(), None, None)); + return Err(AuthError::Provider(format!( + "discovery returned {}", + resp.status() + ))); } let doc: DiscoveryDoc = resp .body_mut() .read_json() - .map_err(|e| AuthError::Other(e.to_string()))?; + .map_err(|e| AuthError::Provider(e.to_string()))?; Ok(DeviceFlow { client_id: client_id.to_string(), http, @@ -233,13 +233,16 @@ impl DeviceFlow { .http .post(&self.device_authorization_endpoint) .send_form([("client_id", self.client_id.as_str()), ("scope", scope)]) - .map_err(|e| AuthError::Unreachable(e.to_string()))?; + .map_err(|e| AuthError::Provider(e.to_string()))?; if !resp.status().is_success() { - return Err(AuthError::rejected(resp.status().as_u16(), None, None)); + return Err(AuthError::Provider(format!( + "device authorization returned {}", + resp.status() + ))); } resp.body_mut() .read_json() - .map_err(|e| AuthError::Other(e.to_string())) + .map_err(|e| AuthError::Provider(e.to_string())) } /// Poll the token endpoint until the user authorizes, the code expires, or @@ -264,13 +267,13 @@ impl DeviceFlow { ("device_code", auth.device_code.as_str()), ("client_id", self.client_id.as_str()), ]) - .map_err(|e| AuthError::Unreachable(e.to_string()))?; + .map_err(|e| AuthError::Provider(e.to_string()))?; if response.status().is_success() { let token: TokenResponse = response .body_mut() .read_json() - .map_err(|e| AuthError::Other(e.to_string()))?; + .map_err(|e| AuthError::Provider(e.to_string()))?; return Ok(token.into_stored()); } @@ -278,7 +281,7 @@ impl DeviceFlow { let body: TokenErrorBody = response .body_mut() .read_json() - .map_err(|e| AuthError::Other(e.to_string()))?; + .map_err(|e| AuthError::Provider(e.to_string()))?; match body.error.as_str() { "authorization_pending" => {} "slow_down" => interval += 5, @@ -298,24 +301,17 @@ impl DeviceFlow { ("refresh_token", refresh_token), ("client_id", self.client_id.as_str()), ]) - .map_err(|e| AuthError::Unreachable(e.to_string()))?; + .map_err(|e| AuthError::Provider(e.to_string()))?; if !response.status().is_success() { - // The IdP was reached and refused the grant (typically a `400 - // invalid_grant` once the refresh token is expired/rotated). Report - // it as a *rejection* with the OAuth error body — not "unreachable", - // which would misdirect debugging toward the network. - let status = response.status().as_u16(); - let body = response.body_mut().read_json::().ok(); - return Err(AuthError::rejected( - status, - body.as_ref().map(|b| b.error.as_str()), - body.as_ref().and_then(|b| b.error_description.as_deref()), - )); + return Err(AuthError::Provider(format!( + "token refresh returned {}", + response.status() + ))); } let mut token: StoredToken = response .body_mut() .read_json::() - .map_err(|e| AuthError::Other(e.to_string()))? + .map_err(|e| AuthError::Provider(e.to_string()))? .into_stored(); // Providers may omit the refresh token on refresh — keep the old one. if token.refresh_token.is_none() { diff --git a/crates/hephd/src/quickadd.rs b/crates/hephd/src/quickadd.rs index 826639c..a6fccf6 100644 --- a/crates/hephd/src/quickadd.rs +++ b/crates/hephd/src/quickadd.rs @@ -1,13 +1,12 @@ //! Single-line natural-language quick-add (tech-spec §8.1) — Todoist-style -//! capture: `Water plants tomorrow a2 #Chores every 3 days`. +//! capture: `Water plants tomorrow p2 #Chores every 3 days`. //! //! Pure and deterministic: `today` and the known projects are passed in, so the //! whole parser is unit-testable. Recognized inline tokens are extracted and the //! remainder is the title (order preserved). The recognized forms mirror the //! owner's Todoist usage ([[design]] §6.2.1): //! -//! - **Attention** `a1`..`a4` → attention band, ordered by intensity -//! (a1 red, a2 orange, a3 white, a4 blue). +//! - **Priority** `p1`..`p4` → attention (p1 red, p2 orange, p3 blue, p4 white). //! - **Project** `#Name` — resolved against existing projects, greedily matching //! multi-word titles (`#Camano Chores`). An unresolved `#tag` is left in the //! title verbatim (no surprise project creation). @@ -41,13 +40,12 @@ pub struct Parsed { pub project_id: Option, } -/// `a1`..`a4` → attention band, ordered by intensity (a1 = most urgent). -fn attention_token(token: &str) -> Option { +fn priority_attention(token: &str) -> Option { match token.to_ascii_lowercase().as_str() { - "a1" => Some(Attention::Red), - "a2" => Some(Attention::Orange), - "a3" => Some(Attention::White), - "a4" => Some(Attention::Blue), + "p1" => Some(Attention::Red), + "p2" => Some(Attention::Orange), + "p3" => Some(Attention::Blue), + "p4" => Some(Attention::White), _ => None, } } @@ -64,7 +62,7 @@ pub fn parse(input: &str, today: NaiveDate, projects: &[Project]) -> Parsed { while i < tokens.len() { let tok = &tokens[i]; - if let Some(a) = attention_token(tok) { + if let Some(a) = priority_attention(tok) { out.attention = Some(a); i += 1; continue; @@ -172,20 +170,12 @@ mod tests { } #[test] - fn attention_token_maps_to_attention() { - assert_eq!(p("Email boss a1").attention, Some(Attention::Red)); - assert_eq!(p("Email boss a2").attention, Some(Attention::Orange)); - assert_eq!(p("Email boss a3").attention, Some(Attention::White)); - assert_eq!(p("Email boss a4").attention, Some(Attention::Blue)); - assert_eq!(p("Email boss a1").title, "Email boss"); - } - - #[test] - fn old_priority_tokens_are_no_longer_recognized() { - // p1..p4 are retired in favour of a1..a4 — they stay in the title. - let r = p("Email boss p1"); - assert_eq!(r.attention, None); - assert_eq!(r.title, "Email boss p1"); + fn priority_maps_to_attention() { + assert_eq!(p("Email boss p1").attention, Some(Attention::Red)); + assert_eq!(p("Email boss p2").attention, Some(Attention::Orange)); + assert_eq!(p("Email boss p3").attention, Some(Attention::Blue)); + assert_eq!(p("Email boss p4").attention, Some(Attention::White)); + assert_eq!(p("Email boss p1").title, "Email boss"); } #[test] @@ -225,7 +215,7 @@ mod tests { #[test] fn everything_at_once() { - let r = p("Plan trip a2 friday #Work every week"); + let r = p("Plan trip p2 friday #Work every week"); assert_eq!(r.title, "Plan trip"); assert_eq!(r.attention, Some(Attention::Orange)); assert_eq!(r.do_date, Some(ms(2026, 6, 5))); // the coming Friday diff --git a/crates/hephd/src/server.rs b/crates/hephd/src/server.rs index 89dee78..30c5d5a 100644 --- a/crates/hephd/src/server.rs +++ b/crates/hephd/src/server.rs @@ -20,7 +20,6 @@ use tokio::net::{UnixListener, UnixStream}; use heph_core::Store; -use crate::auth::AuthError; use crate::oauth::{self, TokenStore}; use crate::rpc::{self, Request, Response, RpcError, INTERNAL_ERROR, PARSE_ERROR}; use crate::selfupdate::{self, SelfUpdateConfig}; @@ -81,25 +80,10 @@ fn is_auth_error(e: &anyhow::Error) -> bool { .is_some_and(|s| s == reqwest::StatusCode::UNAUTHORIZED) } -/// The exact `heph auth login …` command that re-authenticates this spoke, built -/// from the hub URL + issuer + client id the daemon is configured with — so the -/// surfaced error tells the user *what to run*, not just that auth failed. -/// `None` for an unauthenticated / standalone instance. The hub-URL string must -/// match what the credential store is keyed under, which is exactly `hub_url`. -fn reauth_command(hub_url: Option<&str>, auth: Option<&SpokeAuth>) -> Option { - let (hub, auth) = (hub_url?, auth?); - Some(format!( - "heph auth login --hub-url {hub} --issuer {} --client-id {}", - auth.issuer, auth.client_id - )) -} - -/// Fold one exchange outcome into the shared [`SyncHealth`]. On an auth failure -/// (a 401 from the hub) the recorded error carries the actionable re-login -/// command, so `heph sync --status` / `heph auth status` / the TUI show the fix. -fn record_sync_outcome(ctx: &Ctx, result: &Result) { +/// Fold one exchange outcome into the shared [`SyncHealth`]. +fn record_sync_outcome(health: &Arc>, result: &Result) { let now = now_ms(); - let mut h = ctx.sync_health.lock().expect("sync_health mutex poisoned"); + let mut h = health.lock().expect("sync_health mutex poisoned"); h.last_attempt_ms = Some(now); match result { Ok(_) => { @@ -108,67 +92,28 @@ fn record_sync_outcome(ctx: &Ctx, result: &Result) { h.auth_failure = false; } Err(e) => { - let auth_failure = is_auth_error(e); - h.auth_failure = auth_failure; - h.last_error = Some(annotate_reauth( - e.to_string(), - auth_failure, - ctx.hub_url.as_deref(), - ctx.auth.as_ref(), - )); + h.auth_failure = is_auth_error(e); + h.last_error = Some(e.to_string()); } } } -/// Record a failure to obtain a bearer token (the refresh step, before any hub -/// request). A *rejection* (the IdP refused the refresh) is an auth failure and -/// gets the re-login hint; a transport failure stays a transient error. Surfacing -/// this here means `last_error` reflects the real cause (e.g. `invalid_grant`) -/// instead of only the downstream 401 on `/sync/pull`. -fn record_bearer_failure(ctx: &Ctx, err: &AuthError) { - let now = now_ms(); - let auth_failure = err.is_rejection(); - let mut h = ctx.sync_health.lock().expect("sync_health mutex poisoned"); - h.last_attempt_ms = Some(now); - h.auth_failure = auth_failure; - h.last_error = Some(annotate_reauth( - format!("could not obtain bearer token: {err}"), - auth_failure, - ctx.hub_url.as_deref(), - ctx.auth.as_ref(), - )); -} - -/// Append the actionable re-login command to `msg` when this is an auth failure -/// and the spoke has auth configured. -fn annotate_reauth( - msg: String, - auth_failure: bool, - hub_url: Option<&str>, - auth: Option<&SpokeAuth>, -) -> String { - match reauth_command(hub_url, auth) { - Some(cmd) if auth_failure => format!("{msg} — re-authenticate: {cmd}"), - _ => msg, - } -} - impl Ctx { - /// The current bearer token for hub sync (refreshing if expired). `Ok(None)` - /// means this spoke has no auth configured / no token stored (it syncs - /// unauthenticated); `Err` means token acquisition genuinely failed (the - /// caller records it and skips the attempt rather than 401ing the hub). - async fn bearer(&self) -> Result, AuthError> { - let Some(auth) = self.auth.clone() else { - return Ok(None); - }; - match tokio::task::spawn_blocking(move || { + /// The current bearer token for hub sync (refreshing if expired), or `None` + /// if this spoke has no auth configured / no usable token. + async fn bearer(&self) -> Option { + let auth = self.auth.clone()?; + let result = tokio::task::spawn_blocking(move || { oauth::current_bearer(auth.store.as_ref(), &auth.issuer, &auth.client_id) }) - .await - { - Ok(res) => res, - Err(_join) => Ok(None), // the blocking task panicked; treat as no token + .await; + match result { + Ok(Ok(token)) => token, + Ok(Err(e)) => { + tracing::warn!("could not obtain bearer token: {e}"); + None + } + Err(_) => None, } } } @@ -278,20 +223,10 @@ impl Daemon { let mut tick = tokio::time::interval(interval); loop { tick.tick().await; - let bearer = match ctx.bearer().await { - Ok(b) => b, - Err(e) => { - // Couldn't get a token — record the real cause (e.g. a - // rejected refresh) and skip; sending an unauthenticated - // request would only 401 and mask it. - record_bearer_failure(&ctx, &e); - tracing::warn!("background sync: could not obtain bearer token: {e}"); - continue; - } - }; + let bearer = ctx.bearer().await; let result = sync::sync_once(ctx.store.clone(), &hub, &ctx.http, bearer.as_deref()).await; - record_sync_outcome(&ctx, &result); + record_sync_outcome(&ctx.sync_health, &result); match result { Ok(report) => tracing::debug!(?report, "background sync"), Err(e) => tracing::warn!("background sync failed: {e}"), @@ -386,25 +321,9 @@ async fn sync_now(ctx: &Ctx) -> Result { message: "no hub_url configured; this instance is standalone".into(), }); }; - let bearer = match ctx.bearer().await { - Ok(b) => b, - Err(e) => { - // Token acquisition failed — record the real cause (with a re-login - // hint when it's a rejection) and surface it instead of a downstream 401. - record_bearer_failure(ctx, &e); - return Err(RpcError { - code: INTERNAL_ERROR, - message: annotate_reauth( - format!("sync failed: could not obtain bearer token: {e}"), - e.is_rejection(), - ctx.hub_url.as_deref(), - ctx.auth.as_ref(), - ), - }); - } - }; + let bearer = ctx.bearer().await; let result = sync::sync_once(ctx.store.clone(), &hub_url, &ctx.http, bearer.as_deref()).await; - record_sync_outcome(ctx, &result); + record_sync_outcome(&ctx.sync_health, &result); match result { Ok(report) => Ok(json!(report)), Err(e) => Err(RpcError { @@ -455,22 +374,10 @@ async fn sync_status(ctx: &Ctx) -> Result { .expect("sync_health mutex poisoned") .clone(); - // Non-secret OIDC params (issuer/client-id) + the exact re-login command, so - // `heph auth status` can show the fix without reconstructing it client-side - // (and keyed under the right hub URL — see the per-URL token-keying gotcha). - let auth = ctx.auth.as_ref().map(|a| { - json!({ - "issuer": a.issuer, - "client_id": a.client_id, - }) - }); - Ok(json!({ "hub_url": hub_url, "cursors": cursors, "conflicts": conflicts, "health": health, - "auth": auth, - "reauth_command": reauth_command(Some(&hub_url), ctx.auth.as_ref()), })) } diff --git a/crates/hephd/src/sync.rs b/crates/hephd/src/sync.rs index 9beac05..bfaa323 100644 --- a/crates/hephd/src/sync.rs +++ b/crates/hephd/src/sync.rs @@ -261,14 +261,8 @@ async fn require_auth( .await .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)? .map_err(|e| match e { - // The token itself is missing/bad → tell the client it's unauthorized. - AuthError::Missing | AuthError::Invalid(_) => StatusCode::UNAUTHORIZED, - // We couldn't reach/process the IdP to fetch verification keys — a - // transient hub-side problem, not the client's token. Ask them to - // retry rather than claiming their token is invalid. - AuthError::Unreachable(_) | AuthError::Rejected(_) | AuthError::Other(_) => { - StatusCode::SERVICE_UNAVAILABLE - } + AuthError::Provider(_) => StatusCode::SERVICE_UNAVAILABLE, + _ => StatusCode::UNAUTHORIZED, })?; // Multi-tenancy seam: resolve the token's identity to the owner it may act diff --git a/crates/hephd/tests/client_reconnect.rs b/crates/hephd/tests/client_reconnect.rs deleted file mode 100644 index a4d0074..0000000 --- a/crates/hephd/tests/client_reconnect.rs +++ /dev/null @@ -1,96 +0,0 @@ -//! [`Client`] survives the daemon dropping the socket (opt-in self-update, `heph -//! daemon restart`). A mock daemon serves exactly one request per connection -//! then closes it, forcing the client to reconnect — without auto-reconnect, -//! every call after the first would fail forever. - -use std::io::{BufRead, BufReader, Write}; -use std::os::unix::net::UnixListener; -use std::path::PathBuf; -use std::sync::atomic::{AtomicUsize, Ordering}; -use std::sync::Arc; -use std::thread; -use std::time::Duration; - -use hephd::Client; -use serde_json::{json, Value}; - -/// A mock daemon that handles ONE request per connection then closes it, looping -/// to accept the next connection. `served` counts total requests answered. -fn spawn_one_shot_daemon(socket: PathBuf, served: Arc) { - thread::spawn(move || { - let listener = UnixListener::bind(&socket).unwrap(); - for conn in listener.incoming() { - let Ok(mut stream) = conn else { continue }; - let mut reader = BufReader::new(stream.try_clone().unwrap()); - let mut line = String::new(); - if reader.read_line(&mut line).unwrap_or(0) == 0 { - continue; // client opened then went away; wait for the next one - } - let req: Value = serde_json::from_str(&line).unwrap(); - let n = served.fetch_add(1, Ordering::SeqCst) + 1; - let mut out = serde_json::to_string(&json!({ - "id": req["id"], - "result": { "served": n }, - })) - .unwrap(); - out.push('\n'); - let _ = stream.write_all(out.as_bytes()); - let _ = stream.flush(); - // `stream` drops here → the connection closes after one request. - } - }); -} - -fn wait_for(socket: &std::path::Path) { - for _ in 0..400 { - if socket.exists() { - return; - } - thread::sleep(Duration::from_millis(5)); - } - panic!("mock daemon socket never appeared"); -} - -#[test] -fn client_reconnects_after_the_daemon_drops_the_socket() { - let dir = tempfile::tempdir().unwrap(); - let socket = dir.path().join("d.sock"); - let served = Arc::new(AtomicUsize::new(0)); - spawn_one_shot_daemon(socket.clone(), served.clone()); - wait_for(&socket); - - let mut c = Client::connect(&socket).unwrap(); - - // First call works on the initial connection. - let r1 = c.call("ping", json!({})).unwrap(); - assert_eq!(r1["served"], 1); - - // The daemon has now closed that connection. With reconnect, the client - // recovers within a call or two (depending on whether the dead socket fails - // on write or on read); without it, every further call would fail forever. - let mut recovered = None; - for _ in 0..2 { - if let Ok(v) = c.call("ping", json!({})) { - recovered = Some(v); - break; - } - } - let r = recovered.expect("client should reconnect after the socket was dropped"); - // The recovered call was served exactly once on the new connection — no - // double-serve from a spurious retry. - assert_eq!(r["served"], 2); - assert_eq!(served.load(Ordering::SeqCst), 2); - - // And it keeps working across subsequent drops. - let r3 = { - let mut got = None; - for _ in 0..2 { - if let Ok(v) = c.call("ping", json!({})) { - got = Some(v); - break; - } - } - got.expect("client should keep reconnecting") - }; - assert_eq!(r3["served"], 3); -} diff --git a/crates/hephd/tests/oauth.rs b/crates/hephd/tests/oauth.rs index 0a1c709..f61c872 100644 --- a/crates/hephd/tests/oauth.rs +++ b/crates/hephd/tests/oauth.rs @@ -90,25 +90,11 @@ async fn token(State(s): State, Form(form): Form { - // A rotated/expired refresh token is refused with `400 invalid_grant` - // (RFC 6749 §5.2) — the case that used to be mislabeled "unreachable". - if form.get("refresh_token").map(String::as_str) == Some("refresh-expired") { - return ( - StatusCode::BAD_REQUEST, - Json(json!({ - "error": "invalid_grant", - "error_description": "Token is not active", - })), - ) - .into_response(); - } - Json(json!({ - "access_token": "access-2", - "expires_in": 3600, - })) - .into_response() - } + Some("refresh_token") => Json(json!({ + "access_token": "access-2", + "expires_in": 3600, + })) + .into_response(), _ => ( StatusCode::BAD_REQUEST, Json(json!({ "error": "unsupported_grant_type" })), @@ -143,48 +129,6 @@ fn refresh_keeps_the_old_refresh_token_when_omitted() { assert_eq!(refreshed.refresh_token.as_deref(), Some("refresh-1")); } -#[test] -fn refresh_rejected_by_idp_is_a_rejection_not_unreachable() { - let issuer = start_idp(); - let flow = DeviceFlow::discover(&issuer, "heph-cli").unwrap(); - let err = flow.refresh("refresh-expired").unwrap_err(); - // The whole point of the fix: a reachable IdP that returns 400 is a - // *rejection*, carrying the OAuth error body — not "unreachable". - assert!(err.is_rejection(), "expected a rejection, got: {err}"); - let msg = err.to_string(); - assert!( - msg.contains("rejected"), - "message should say rejected: {msg}" - ); - assert!( - msg.contains("invalid_grant"), - "should include the OAuth error: {msg}" - ); - assert!( - msg.contains("Token is not active"), - "should include error_description: {msg}" - ); - assert!( - !msg.contains("unreachable"), - "must NOT claim the IdP was unreachable: {msg}" - ); -} - -#[test] -fn discovery_against_a_dead_idp_is_unreachable_not_a_rejection() { - use hephd::AuthError; - // Port 1 refuses the connection → a genuine transport failure. - let err = match DeviceFlow::discover("http://127.0.0.1:1/application/o/heph/", "heph-cli") { - Ok(_) => panic!("discovery should fail against a dead IdP"), - Err(e) => e, - }; - assert!( - matches!(err, AuthError::Unreachable(_)), - "a connection failure must be Unreachable, got: {err}" - ); - assert!(!err.is_rejection()); -} - #[test] fn memory_token_store_round_trips_and_reports_expiry() { let store = MemoryTokenStore::default(); 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. diff --git a/docs/changelog.d/daemon-self-update-interval.feature.md b/docs/changelog.d/daemon-self-update-interval.feature.md new file mode 100644 index 0000000..b5ec9b8 --- /dev/null +++ b/docs/changelog.d/daemon-self-update-interval.feature.md @@ -0,0 +1 @@ +`heph daemon start`/`restart` can now bake the daemon's full runtime config into the managed service — `--mode`, `--hub-url`, `--http-addr`, `--oidc-issuer`/`--oidc-audience`/`--oidc-client-id`, and `--self-update-interval-secs` (previously only the bare `--self-update` bool was wired). Regenerating preserves whatever is already baked into the on-disk plist/unit, so a bare `start`/`restart` no longer silently drops spoke/hub or self-update config. diff --git a/docs/how-to/heph-pwa.md b/docs/how-to/heph-pwa.md index ab72be3..2a158e9 100644 --- a/docs/how-to/heph-pwa.md +++ b/docs/how-to/heph-pwa.md @@ -71,7 +71,7 @@ into preview chips before you submit: | Token | Example | Effect | |-------|---------|--------| -| `a1`–`a4` | `a1` | attention band by intensity: a1=red, a2=orange, a3=white, a4=blue | +| `p1`–`p4` | `p1` | attention: red / orange / blue / white | | `#Project` | `#Camano Chores` | file under a project (greedy multi-word match) | | date | `today` `tomorrow` `+3d` `fri` `2026-07-01` | do-date | | `every …` | `every 3 days` `every other wed` `every workday` | recurrence (RRULE) | @@ -96,9 +96,8 @@ platform. A server-side transcription proxy could be added later if needed.) ## Triage Tap a task to expand its actions, mirroring the TUI keys: **Done** (`x`), -**Drop** (`d`), **Skip** (`S`, recurring only), **Attn** (pick a band a1–a4, -the TUI's `a` then a digit), **Date** (reschedule, `e`), **Move** (project -picker, `m`), **Delete** +**Drop** (`d`), **Skip** (`S`, recurring only), **Attn** (cycle attention, `A`), +**Date** (reschedule, `e`), **Move** (project picker, `m`), **Delete** (tombstone, `D`). Done/Drop show an **Undo**. The expanded view also shows the task's canonical-context body + recent log tail (read-only). diff --git a/docs/how-to/run-the-daemon.md b/docs/how-to/run-the-daemon.md index 545b3be..cb9e56d 100644 --- a/docs/how-to/run-the-daemon.md +++ b/docs/how-to/run-the-daemon.md @@ -86,14 +86,6 @@ still the old binary until you restart it: heph daemon restart ``` -A restart (or an opt-in self-update) drops the daemon's unix socket out from -under any connected surface. The CLI and `heph-tui` **reconnect automatically**: -a read transparently retries on a fresh connection, and a long-running TUI -self-heals on its next tick — so a daemon restart no longer leaves the agenda -view stuck on errors. (A mutating action whose reply is lost mid-restart reports -"reconnected — re-run the action if it didn't take effect" rather than risk -applying twice.) - ## Self-update (opt-in) `hephd` can keep itself current: `heph daemon start --self-update` generates a diff --git a/docs/how-to/set-up-sync-hub.md b/docs/how-to/set-up-sync-hub.md index 4d654a9..a5b56ea 100644 --- a/docs/how-to/set-up-sync-hub.md +++ b/docs/how-to/set-up-sync-hub.md @@ -130,41 +130,19 @@ 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. -### When sync stops authenticating - -A spoke's refresh token can expire or be rotated (e.g. the IdP session lapses). -The spoke then can't refresh on its own and needs a re-login — but this is -**visible, not silent**: - -- `heph-tui` shows a red `⚠ auth · heph auth status` chip in the status line. -- `heph auth status` prints the auth health and the **exact** re-login command, - pre-filled with this spoke's hub URL / issuer / client id: - - ```bash - heph auth status - ``` - -- `heph sync --status`'s `last_error` names the real cause — a refresh - *rejection* (e.g. `HTTP 400 (invalid_grant)`), not a misleading "identity - provider unreachable" — and carries the same `heph auth login …` hint. - -Run the printed `heph auth login …` command to restore sync. - ## Current gaps (finalized by the blumeops deployment) -The flag-level flow above works today; one enabler makes it a clean, managed +The flag-level flow above works today; two enablers make it a clean, managed deployment rather than a hand-run process — tracked in the `Hephaestus` project: +- **`heph daemon` only generates a `--mode local` service** (no `--hub-url` / + `--oidc-*`). So for now the hub and the spoke config are expressed as `hephd` + flags (run directly, or via the blumeops-managed systemd unit), not via + `heph daemon start`. - **Path A seeding is manual** (copy the store + reset the device origin). A small enabler — seed a hub from a snapshot with a fresh origin, or `hephd --owner-id` — would make this one step. -> `heph daemon start`/`restart` can now bake the spoke/hub config (`--hub-url`, -> `--mode server`, `--http-addr`, `--oidc-*`) into the generated service (see -> [[run-the-daemon]]). The canonical hub on `indri` is still provisioned via the -> blumeops-managed systemd unit by deployment choice, not because `heph daemon` -> can't express it. - ## Related - [[run-the-daemon]] — manage the local daemon as an OS service diff --git a/heph-pwa/src/app.js b/heph-pwa/src/app.js index 70ee947..4452c89 100644 --- a/heph-pwa/src/app.js +++ b/heph-pwa/src/app.js @@ -1,6 +1,6 @@ // heph-pwa — a mobile-first browser mirror of heph-tui. Browse the built-in // views and projects, triage tasks, and (the primary use case) capture new -// tasks fast with the same quick-add syntax as the TUI's `n` / Cmd-' popover. +// tasks fast with the same quick-add syntax as the TUI's `a` / Cmd-' popover. // // Online-only thin client: every action is an RPC to the configured hub (see // rpc.js). Context/KB is read-only here (no nvim editing surface). @@ -10,12 +10,11 @@ import * as oauth from "./oauth.js"; import { parse as quickParse } from "./quickadd.js"; import { today, parseDate, toEpochMs, humanizeRecurrence } from "./datespec.js"; import { - ATTENTION_BANDS, ATTENTION_COLORS, - attentionLabel, fmtRelative, hasFlag, isOverdue, + nextAttention, projectColor, } from "./fmt.js"; @@ -232,7 +231,7 @@ function taskDetail(t) { actionBtn("✓ Done", () => triage(t, "done")), actionBtn("⤓ Drop", () => triage(t, "dropped")), t.recurrence && actionBtn("↻ Skip", () => doSkip(t)), - actionBtn("⚑ Attn", () => openAttention(t)), + actionBtn("⚑ Attn", () => cycleAttention(t)), actionBtn("📅 Date", () => openReschedule(t)), actionBtn("📁 Move", () => openMove(t)), actionBtn("🗑 Delete", () => doDelete(t), "danger"), @@ -354,7 +353,7 @@ function openQuickAdd() { const input = h("input", { class: "qa-input", type: "text", - placeholder: "Buy milk tomorrow a2 #Work every week", + placeholder: "Buy milk tomorrow p2 #Work every week", autocomplete: "off", autocapitalize: "sentences", enterkeyhint: "done", @@ -365,12 +364,12 @@ function openQuickAdd() { const parsed = quickParse(input.value, today(), state.projects); preview.innerHTML = ""; if (!input.value.trim()) { - preview.append(h("span", { class: "qa-hint" }, "a1–a4 · #Project · today/+3d/fri · every week")); + preview.append(h("span", { class: "qa-hint" }, "p1–p4 · #Project · today/+3d/fri · every week")); return; } preview.append(h("span", { class: "qa-title" }, parsed.title || "(no title)")); if (parsed.attention) { - preview.append(h("span", { class: "qa-tag", style: `color:${ATTENTION_COLORS[parsed.attention]}` }, "⚑ " + attentionLabel(parsed.attention))); + preview.append(h("span", { class: "qa-tag", style: `color:${ATTENTION_COLORS[parsed.attention]}` }, "⚑ " + parsed.attention)); } if (parsed.doDate != null) preview.append(h("span", { class: "qa-tag" }, "📅 " + fmtRelative(parsed.doDate))); if (parsed.projectId) preview.append(h("span", { class: "qa-tag" }, "📁 " + projectTitle(parsed.projectId))); @@ -611,22 +610,11 @@ async function doSkip(t) { } } -// Pick an attention band directly (a1–a4) rather than cycling — cycling could -// skip past the band you wanted, and pushing to a4 (blue) used to drop the task -// out of the view you were on with no way back. Mirrors the TUI's `a`+digit chord. -function openAttention(t) { - const list = h("div", { class: "picker-list" }); - for (const band of ATTENTION_BANDS) { - list.append(pickerItem(attentionLabel(band), () => setAttention(t, band), ATTENTION_COLORS[band])); - } - openModal(h("div", { class: "qa" }, h("div", { class: "modal-title" }, `Attention for "${t.title}"`), list)); -} - -async function setAttention(t, band) { - closeModal(); +async function cycleAttention(t) { + const next = nextAttention(t.attention); try { - await state.client.setAttention(t.node_id, band); - toast(`Attention: ${attentionLabel(band)}`); + await state.client.setAttention(t.node_id, next); + toast(`Attention: ${next}`); reload(); } catch (e) { toast(`Failed: ${e.message}`); diff --git a/heph-pwa/src/fmt.js b/heph-pwa/src/fmt.js index 9e84ddc..a3b98ad 100644 --- a/heph-pwa/src/fmt.js +++ b/heph-pwa/src/fmt.js @@ -9,16 +9,15 @@ export const ATTENTION_COLORS = { white: "var(--att-white)", }; -/** - * The attention bands a user can pick, in `a1`..`a4` order (by intensity). - * Each entry is the storage color string; the label is its index + 1. - */ -export const ATTENTION_BANDS = ["red", "orange", "white", "blue"]; +/** The cycle order used by the attention toggle (matches the TUI's `A` key). */ +export const ATTENTION_CYCLE = [null, "white", "orange", "red", "blue"]; -/** Attention color string → its `a1`..`a4` UI label (or "" if unset). */ -export function attentionLabel(att) { - const i = ATTENTION_BANDS.indexOf(att); - return i < 0 ? "" : `a${i + 1}`; +/** Next attention in the cycle: none → white → orange → red → blue → white. */ +export function nextAttention(att) { + const i = ATTENTION_CYCLE.indexOf(att ?? null); + // After blue (last), wrap to white (index 1), not back to none. + const next = i < 0 ? 1 : (i + 1) % ATTENTION_CYCLE.length; + return ATTENTION_CYCLE[next === 0 ? 1 : next] ?? "white"; } /** Whether an attention band shows a flag glyph (red/orange/blue; not white). */ diff --git a/heph-pwa/src/quickadd.js b/heph-pwa/src/quickadd.js index 3149064..b0e5c4d 100644 --- a/heph-pwa/src/quickadd.js +++ b/heph-pwa/src/quickadd.js @@ -1,11 +1,10 @@ // Single-line natural-language quick-add — a faithful JS port of hephd's // `quickadd.rs` (tech-spec §8.1). Todoist-style capture: -// `Water plants tomorrow a2 #Chores every 3 days` +// `Water plants tomorrow p2 #Chores every 3 days` // // Recognized inline tokens are extracted and the remainder is the title (order // preserved). This mirrors the owner's Todoist usage ([[design]] §6.2.1): -// - Attention a1..a4 → attention band, ordered by intensity -// (a1 red, a2 orange, a3 white, a4 blue) +// - Priority p1..p4 → attention (p1 red, p2 orange, p3 blue, p4 white) // - Project #Name → resolved against existing projects, greedily matching // multi-word titles (#Camano Chores). Unresolved #tags // stay in the title verbatim (no surprise project). @@ -14,13 +13,13 @@ import { parseDate, toEpochMs, parseRecurrenceOrNull } from "./datespec.js"; -/** a1..a4 → attention color string (matching the RPC serialization), or null. */ -function attentionToken(token) { +/** p1..p4 → attention color string (matching the RPC serialization), or null. */ +function priorityAttention(token) { switch (token.toLowerCase()) { - case "a1": return "red"; - case "a2": return "orange"; - case "a3": return "white"; - case "a4": return "blue"; + case "p1": return "red"; + case "p2": return "orange"; + case "p3": return "blue"; + case "p4": return "white"; default: return null; } } @@ -77,7 +76,7 @@ export function parse(input, todayDate, projects = []) { while (i < tokens.length) { const tok = tokens[i]; - const att = attentionToken(tok); + const att = priorityAttention(tok); if (att !== null) { out.attention = att; i += 1; diff --git a/heph-pwa/sw.js b/heph-pwa/sw.js index 5990857..5793eab 100644 --- a/heph-pwa/sw.js +++ b/heph-pwa/sw.js @@ -1,7 +1,7 @@ // Service worker: cache the app shell so heph launches offline. Data is never // cached — every /rpc call must hit the live hub (and POSTs aren't cacheable // anyway). Bump CACHE when shell assets change to evict the old set. -const CACHE = "heph-pwa-v5"; +const CACHE = "heph-pwa-v4"; const SHELL = [ "./", "./index.html", diff --git a/heph-pwa/test/parsers.test.mjs b/heph-pwa/test/parsers.test.mjs index a6695fd..cd984fc 100644 --- a/heph-pwa/test/parsers.test.mjs +++ b/heph-pwa/test/parsers.test.mjs @@ -134,19 +134,12 @@ test("plain title", () => { assert.equal(r.projectId, null); }); -test("attention token maps to attention", () => { - assert.equal(p("Email boss a1").attention, "red"); - assert.equal(p("Email boss a2").attention, "orange"); - assert.equal(p("Email boss a3").attention, "white"); - assert.equal(p("Email boss a4").attention, "blue"); - assert.equal(p("Email boss a1").title, "Email boss"); -}); - -test("old priority tokens are no longer recognized", () => { - // p1..p4 are retired in favour of a1..a4 — they stay in the title. - const r = p("Email boss p1"); - assert.equal(r.attention, null); - assert.equal(r.title, "Email boss p1"); +test("priority maps to attention", () => { + assert.equal(p("Email boss p1").attention, "red"); + assert.equal(p("Email boss p2").attention, "orange"); + assert.equal(p("Email boss p3").attention, "blue"); + assert.equal(p("Email boss p4").attention, "white"); + assert.equal(p("Email boss p1").title, "Email boss"); }); test("relative date is extracted", () => { @@ -176,7 +169,7 @@ test("recurrence phrase is extracted", () => { }); test("everything at once", () => { - const r = p("Plan trip a2 friday #Work every week"); + const r = p("Plan trip p2 friday #Work every week"); assert.equal(r.title, "Plan trip"); assert.equal(r.attention, "orange"); assert.equal(r.doDate, ms(2026, 6, 5));