Compare commits

..

No commits in common. "main" and "feature/daemon-self-update-interval" have entirely different histories.

31 changed files with 232 additions and 1009 deletions

View file

@ -12,35 +12,6 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
<!-- towncrier release notes start --> <!-- towncrier release notes start -->
## [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 ## [v1.2.3] - 2026-06-06
### Features ### Features

2
Cargo.lock generated
View file

@ -2237,8 +2237,6 @@ dependencies = [
"heph-core", "heph-core",
"hephd", "hephd",
"libc", "libc",
"objc2 0.6.4",
"objc2-app-kit 0.3.2",
"serde_json", "serde_json",
"winit", "winit",
] ]

View file

@ -106,36 +106,6 @@ impl Attention {
other => return Err(Error::Integrity(format!("unknown attention: {other}"))), 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<Attention> {
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` /// 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());
}
}

View file

@ -19,16 +19,7 @@ global-hotkey = "0.8"
# macOS-only: winit for the accessory-mode activation policy (no Dock icon), # 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 # 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); # 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.
[target.'cfg(target_os = "macos")'.dependencies] [target.'cfg(target_os = "macos")'.dependencies]
winit = "0.30" winit = "0.30"
libc = "0.2" libc = "0.2"
objc2 = "0.6"
objc2-app-kit = { version = "0.3", default-features = false, features = [
"std",
"NSApplication",
"NSResponder",
] }

View file

@ -43,46 +43,46 @@ const HINT_DELAY: f64 = 2.0;
/// `#project`). Unresolved `#tags` just stay in the title, so these are safe even /// `#project`). Unresolved `#tags` just stay in the title, so these are safe even
/// though they reference projects a given store may not have. /// though they reference projects a given store may not have.
const HINTS: &[&str] = &[ const HINTS: &[&str] = &[
"Water plants tomorrow a2 #Chores every 3 days", "Water plants tomorrow p2 #Chores every 3 days",
"Call the dentist fri a1", "Call the dentist fri p1",
"Email Sarah the report today", "Email Sarah the report today",
"Buy milk #Errands", "Buy milk #Errands",
"Renew passport +30d a2", "Renew passport +30d p2",
"Review pull requests a4 #Work", "Review pull requests p3 #Work",
"Take out recycling every other wed", "Take out recycling every other wed",
"Pay rent every 1st a1", "Pay rent every 1st p1",
"Stretch every day", "Stretch every day",
"Submit timesheet every friday #Work", "Submit timesheet every friday #Work",
"Water the garden every 2 days", "Water the garden every 2 days",
"Back up the laptop every week a4", "Back up the laptop every week p3",
"Book flights +1w a2 #Travel", "Book flights +1w p2 #Travel",
"Doctor appointment 2026-07-15 a1", "Doctor appointment 2026-07-15 p1",
"Read a chapter today #Reading", "Read a chapter today #Reading",
"Standup notes every weekday #Work", "Standup notes every weekday #Work",
"Change the air filter every 3 months", "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", "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", "Vacuum the house every saturday #Chores",
"Replace toothbrush every 3 months", "Replace toothbrush every 3 months",
"Prep slides for monday a2 #Work", "Prep slides for monday p2 #Work",
"Walk the dog every day", "Walk the dog every day",
"Refill prescription every 30 days a2 #Health", "Refill prescription every 30 days p2 #Health",
"Grocery run +2d #Errands", "Grocery run +2d #Errands",
"Mow the lawn every week #Home", "Mow the lawn every week #Home",
"Schedule a 1:1 with Alex thu a4 #Work", "Schedule a 1:1 with Alex thu p3 #Work",
"Send the invoice every 15th a2", "Send the invoice every 15th p2",
"Defrost the freezer every 6 months", "Defrost the freezer every 6 months",
"Update the resume +14d a4", "Update the resume +14d p3",
"Check smoke detectors every 6 months #Home", "Check smoke detectors every 6 months #Home",
"Plan the sprint every other monday #Work", "Plan the sprint every other monday #Work",
"Order coffee beans every 2 weeks", "Order coffee beans every 2 weeks",
"Call grandma every sunday a2", "Call grandma every sunday p2",
"Rotate the car tires every 6 months #Car", "Rotate the car tires every 6 months #Car",
"Weekly review every friday a2", "Weekly review every friday p2",
"Pick up dry cleaning tomorrow #Errands", "Pick up dry cleaning tomorrow #Errands",
"Pay the credit card every 28th a1", "Pay the credit card every 28th p1",
"Tidy the inbox every day a3", "Tidy the inbox every day p4",
]; ];
/// Pick a hint pseudo-randomly, never the same one twice in a row. No `rand` /// 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) { 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.visible = true;
self.focus_pending = true; self.focus_pending = true;
self.current_hint = random_hint(self.current_hint); 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))); ctx.send_viewport_cmd(egui::ViewportCommand::InnerSize(egui::vec2(WIN_W, BASE_H)));
self.win_h_applied = 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. /// Optimistic submit: hide now, create in the background.
@ -550,14 +540,18 @@ impl QuickAdd {
let mut any = false; let mut any = false;
if let Some(att) = parsed.attention { if let Some(att) = parsed.attention {
// a1a4 nomenclature; the colour mapping is unchanged. let (label, color) = match att {
let color = match att { heph_core::Attention::Red => {
heph_core::Attention::Red => egui::Color32::from_rgb(0xe0, 0x6c, 0x60), ("⚑ 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::Orange => {
heph_core::Attention::White => egui::Color32::from_gray(200), ("⚑ 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)); ui.label(egui::RichText::new(label).color(color).size(LABEL_SIZE));
any = true; any = true;
} }
@ -593,7 +587,7 @@ impl QuickAdd {
if !any { if !any {
ui.label( ui.label(
egui::RichText::new("type a1a4 · #project · a date · every …") egui::RichText::new("type p1p4 · #project · a date · every …")
.color(egui::Color32::from_gray(140)) .color(egui::Color32::from_gray(140))
.size(LABEL_SIZE), .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 /// 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). /// hephd does not supervise a helper — there is no Aqua session to inherit).
fn current_parent_pid() -> Option<i32> { fn current_parent_pid() -> Option<i32> {

View file

@ -1,7 +1,7 @@
//! `heph-quickadd` — the global quick-capture popover (tech-spec §8). //! `heph-quickadd` — the global quick-capture popover (tech-spec §8).
//! //!
//! A tiny always-warm egui agent: ⌘' shows a single-line capture field that //! 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** //! 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 //! (spawned in local mode on macOS), so the user installs/manages exactly one
//! service — there is no separate launch agent. //! service — there is no separate launch agent.

View file

@ -289,16 +289,15 @@ fn fuzzy_match(query: &str, cand: &str) -> bool {
true true
} }
/// Map an attention-chord digit (`1`..`4`) to its band, ordered by intensity: /// The attention cycle for the `A` gesture: default → top-of-mind → consequence
/// 1 = a1 (red), 2 = a2 (orange), 3 = a3 (white), 4 = a4 (blue). Any other /// → on-deck → back. Mirrors the §6.2 white/orange/red/blue progression.
/// character is not an attention key. pub fn next_attention(current: Option<Attention>) -> Attention {
pub fn attention_for_digit(c: char) -> Option<Attention> { match current {
match c { Some(Attention::White) => Attention::Orange,
'1' => Some(Attention::Red), Some(Attention::Orange) => Attention::Red,
'2' => Some(Attention::Orange), Some(Attention::Red) => Attention::Blue,
'3' => Some(Attention::White), Some(Attention::Blue) => Attention::White,
'4' => Some(Attention::Blue), None => Attention::White,
_ => None,
} }
} }
@ -430,9 +429,6 @@ pub struct App<B: Backend> {
pub search: Option<SearchView>, pub search: Option<SearchView>,
/// When `Some`, a delete is awaiting y/N confirmation. /// When `Some`, a delete is awaiting y/N confirmation.
pub pending_delete: Option<PendingDelete>, pub pending_delete: Option<PendingDelete>,
/// 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). /// Reversible triage history (`u` undoes, Ctrl-z redoes).
undo_stack: Vec<UndoEntry>, undo_stack: Vec<UndoEntry>,
redo_stack: Vec<UndoEntry>, redo_stack: Vec<UndoEntry>,
@ -475,7 +471,6 @@ impl<B: Backend> App<B> {
sort_mode: SortMode::Default, sort_mode: SortMode::Default,
search: None, search: None,
pending_delete: None, pending_delete: None,
pending_attention: false,
undo_stack: Vec::new(), undo_stack: Vec::new(),
redo_stack: Vec::new(), redo_stack: Vec::new(),
status: String::new(), status: String::new(),
@ -727,46 +722,26 @@ impl<B: Backend> App<B> {
self.mutate(format!("skipped: {}", t.title), |b| b.skip(&t.node_id)); self.mutate(format!("skipped: {}", t.title), |b| b.skip(&t.node_id));
} }
/// Begin an attention chord: arm `pending_attention` so the next `1`..`4` /// Cycle the highlighted task's attention band (§6.2 white→orange→red→blue).
/// sets the highlighted task's band directly (§6.2). No-op (with a hint) if pub fn cycle_attention_selected(&mut self) {
/// 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) {
let Some(t) = self.selected_task().cloned() else { let Some(t) = self.selected_task().cloned() else {
return; return;
}; };
self.push_undo((&t).into(), TriageAction::Attention(att)); let next = next_attention(t.attention);
self.mutate(format!("{}: {}", att.ui_label(), t.title), |b| { self.push_undo((&t).into(), TriageAction::Attention(next));
b.set_attention(&t.node_id, att) 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)
}); });
} }

View file

@ -119,16 +119,6 @@ fn handle_key<B: heph_tui::Backend>(app: &mut App<B>, key: KeyEvent) -> Option<A
return None; return None;
} }
// An armed attention chord (`a` then a digit) captures the next key: `1`..`4`
// set the band, anything else cancels.
if app.pending_attention {
match key.code {
KeyCode::Char(c) => app.resolve_attention(c),
_ => app.cancel_attention(),
}
return None;
}
// While collecting input, all keys go to the prompt. // While collecting input, all keys go to the prompt.
if matches!(app.mode, Mode::Input(_)) { if matches!(app.mode, Mode::Input(_)) {
match key.code { match key.code {
@ -189,7 +179,7 @@ fn handle_key<B: heph_tui::Backend>(app: &mut App<B>, key: KeyEvent) -> Option<A
KeyCode::Char('l') | KeyCode::Right => app.focus_tasks(), KeyCode::Char('l') | KeyCode::Right => app.focus_tasks(),
// Enter: drill sidebar→tasks, or open the selected task's context in nvim. // Enter: drill sidebar→tasks, or open the selected task's context in nvim.
KeyCode::Enter => return app.enter().map(Action::EditContext), 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('/') => app.begin_search(),
KeyCode::Char('s') => app.toggle_sort(), KeyCode::Char('s') => app.toggle_sort(),
KeyCode::Char('u') => app.undo(), KeyCode::Char('u') => app.undo(),
@ -201,7 +191,8 @@ fn handle_key<B: heph_tui::Backend>(app: &mut App<B>, key: KeyEvent) -> Option<A
KeyCode::Char('x') => app.complete_selected(), KeyCode::Char('x') => app.complete_selected(),
KeyCode::Char('d') => app.drop_selected(), KeyCode::Char('d') => app.drop_selected(),
KeyCode::Char('S') => app.skip_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('e') => app.begin_reschedule(),
KeyCode::Char('m') => app.begin_move(), KeyCode::Char('m') => app.begin_move(),
KeyCode::Char('D') => app.begin_delete(), KeyCode::Char('D') => app.begin_delete(),

View file

@ -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). // Task-pane gestures (the focused pane shows its own hints, §8.1).
const HINTS: &str = 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). // Sidebar gestures: navigation + per-project actions (no task triage here).
const SIDEBAR_HINTS: &str = 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"; const SEARCH_HINTS: &str = " j/k move Enter open Esc exit search";
@ -570,9 +570,7 @@ fn sync_indicator(sync: &SyncStatus, now: i64) -> Vec<Span<'static>> {
let health = sync.health.clone().unwrap_or_default(); let health = sync.health.clone().unwrap_or_default();
let mut spans = vec![if health.auth_failure { let mut spans = vec![if health.auth_failure {
// Point at the recovery command — `heph auth status` prints the exact Span::styled("⚠ auth", red)
// `heph auth login …` to run (the full command is too long for the bar).
Span::styled("⚠ auth · heph auth status", red)
} else if let Some(ts) = health.last_success_ms { } else if let Some(ts) = health.last_success_ms {
Span::styled(format!("{}", fmt_age(now, ts)), dim) Span::styled(format!("{}", fmt_age(now, ts)), dim)
} else if health.last_error.is_some() { } else if health.last_error.is_some() {
@ -641,7 +639,7 @@ mod tests {
}, },
0, 0,
); );
assert_eq!(render(&auth, NOW), "⚠ auth · heph auth status"); assert_eq!(render(&auth, NOW), "⚠ auth");
// Errored with no prior success → offline. // Errored with no prior success → offline.
let offline = spoke( let offline = spoke(

View file

@ -175,8 +175,8 @@ fn quick_add_captures_a_task_that_appears_in_the_view() {
assert!(app.tasks.is_empty()); assert!(app.tasks.is_empty());
app.begin_add(); app.begin_add();
// Single-line NL: a1 → red, so it lands in Top of Mind (the default view). // Single-line NL: p1 → red, so it lands in Top of Mind (the default view).
type_and_submit(&mut app, "Call the plumber a1"); type_and_submit(&mut app, "Call the plumber p1");
assert!(app.status.contains("added"), "status: {}", app.status); assert!(app.status.contains("added"), "status: {}", app.status);
assert!( 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(); let mut app = App::new(ClientBackend::new(client(&socket))).unwrap();
assert_eq!(app.tasks.len(), 1); assert_eq!(app.tasks.len(), 1);
// `a` then `4` sets a4 (blue) directly — the chord that replaced push-to-blue. app.push_to_blue_selected();
app.begin_attention();
assert!(app.pending_attention);
app.resolve_attention('4');
assert!(!app.pending_attention);
assert!(app.tasks.is_empty(), "blue task should leave Top of Mind"); assert!(app.tasks.is_empty(), "blue task should leave Top of Mind");
// It now appears under On Deck (the last of the five views). // It now appears under On Deck (the last of the five views).

View file

@ -218,14 +218,13 @@ fn move_task_clamps_at_the_ends() {
} }
#[test] #[test]
fn attention_digits_map_by_intensity() { fn attention_cycles_white_orange_red_blue() {
use heph_tui::app::attention_for_digit; use heph_tui::app::next_attention;
assert_eq!(attention_for_digit('1'), Some(Attention::Red)); assert_eq!(next_attention(Some(Attention::White)), Attention::Orange);
assert_eq!(attention_for_digit('2'), Some(Attention::Orange)); assert_eq!(next_attention(Some(Attention::Orange)), Attention::Red);
assert_eq!(attention_for_digit('3'), Some(Attention::White)); assert_eq!(next_attention(Some(Attention::Red)), Attention::Blue);
assert_eq!(attention_for_digit('4'), Some(Attention::Blue)); assert_eq!(next_attention(Some(Attention::Blue)), Attention::White);
assert_eq!(attention_for_digit('5'), None); assert_eq!(next_attention(None), Attention::White);
assert_eq!(attention_for_digit('a'), None);
} }
fn type_and_submit<B: Backend>(app: &mut App<B>, s: &str) { fn type_and_submit<B: Backend>(app: &mut App<B>, 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"); assert_eq!(app.task_pane_title(), "Camano");
app.begin_add(); 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; let created = &rec.borrow().created;
assert_eq!(created.len(), 1); assert_eq!(created.len(), 1);
assert_eq!(created[0].0, "Fix the dock"); 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].2, None); // no do-date
assert_eq!(created[0].3, None); // no recurrence assert_eq!(created[0].3, None); // no recurrence
assert_eq!(created[0].4.as_deref(), Some("p1")); // current project (Camano) assert_eq!(created[0].4.as_deref(), Some("p1")); // current project (Camano)

View file

@ -12,7 +12,7 @@ use anyhow::{bail, Context, Result};
use clap::{Parser, Subcommand}; use clap::{Parser, Subcommand};
use serde_json::{json, Value}; 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}; use hephd::{datespec, default_socket_path, Client, DeviceFlow, KeyringTokenStore, TokenStore};
mod service; mod service;
@ -43,7 +43,7 @@ enum Command {
Task { Task {
/// The task title. /// The task title.
title: String, 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)] #[arg(short = 'a', long)]
attention: Option<String>, attention: Option<String>,
/// Do-date (earliest-actionable): today|tomorrow|+3d|fri|YYYY-MM-DD. /// 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. /// Restrict to a project by NAME (subtree-expanded). e.g. --project Hephaestus.
#[arg(long)] #[arg(long)]
project: Option<String>, project: Option<String>,
/// 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)] #[arg(short = 'a', long)]
attention: Option<String>, attention: Option<String>,
/// Hide on-deck (blue) items. /// Hide on-deck (blue) items.
@ -105,7 +105,7 @@ enum Command {
Attention { Attention {
/// Task node id. /// Task node id.
id: String, id: String,
/// a1|a2|a3|a4 (or 1-4, or red|orange|white|blue). /// white|orange|red|blue.
attention: String, attention: String,
}, },
/// Reschedule a task: change do-date / late-on / recurrence (use `none` to /// Reschedule a task: change do-date / late-on / recurrence (use `none` to
@ -125,7 +125,7 @@ enum Command {
/// A raw RRULE or `none`. /// A raw RRULE or `none`.
#[arg(long)] #[arg(long)]
rrule: Option<String>, rrule: Option<String>,
/// Set attention: a1|a2|a3|a4 (or 1-4, or red|orange|white|blue). /// Set attention: white|orange|red|blue.
#[arg(short = 'a', long)] #[arg(short = 'a', long)]
attention: Option<String>, attention: Option<String>,
/// Re-file under a project (by name); `none` unfiles the task. /// Re-file under a project (by name); `none` unfiles the task.
@ -138,7 +138,7 @@ enum Command {
container_id: String, container_id: String,
/// 1-based index of the context item to promote (document order). /// 1-based index of the context item to promote (document order).
item_ref: usize, 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)] #[arg(short = 'a', long)]
attention: Option<String>, attention: Option<String>,
/// Project name to file the new task under. /// Project name to file the new task under.
@ -344,7 +344,7 @@ enum ConflictAction {
}, },
} }
#[derive(Subcommand, Debug, Clone)] #[derive(Subcommand, Debug)]
enum AuthAction { enum AuthAction {
/// Log in via the device-code flow; caches the bearer token for hub sync. /// Log in via the device-code flow; caches the bearer token for hub sync.
Login { Login {
@ -367,9 +367,6 @@ enum AuthAction {
#[arg(long)] #[arg(long)]
hub_url: String, 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. /// 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()?; KeyringTokenStore::new(hub_url.as_str()).clear()?;
println!("Logged out of {hub_url}."); println!("Logged out of {hub_url}.");
} }
AuthAction::Status => unreachable!("auth status is handled via the daemon"),
} }
Ok(()) 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<()> { fn main() -> Result<()> {
let cli = Cli::parse(); let cli = Cli::parse();
@ -463,13 +407,9 @@ fn main() -> Result<()> {
if let Command::Daemon { action } = &cli.command { if let Command::Daemon { action } = &cli.command {
return service::run(action); return service::run(action);
} }
// `auth login`/`logout` run locally (device-code flow + keyring); they need // `auth` runs locally (device-code flow + keyring); it needs no daemon.
// no daemon. `auth status` reads live sync health, so it falls through to the if let Command::Auth { action } = cli.command {
// connected path below. return run_auth(action);
if let Command::Auth { action } = &cli.command {
if !matches!(action, AuthAction::Status) {
return run_auth(action.clone());
}
} }
let socket = cli.socket.unwrap_or_else(default_socket_path); let socket = cli.socket.unwrap_or_else(default_socket_path);
@ -489,7 +429,6 @@ fn main() -> Result<()> {
recur, recur,
rrule, rrule,
} => { } => {
let attention = norm_attention(attention)?;
let recurrence = recurrence_value(recur.as_deref(), rrule.as_deref())?; let recurrence = recurrence_value(recur.as_deref(), rrule.as_deref())?;
let project_id = resolve_project(&mut client, project.as_deref())?; let project_id = resolve_project(&mut client, project.as_deref())?;
let result = client.call( let result = client.call(
@ -516,7 +455,6 @@ fn main() -> Result<()> {
// `list` takes a ListFilter (tech-spec §8.2). Map the flags: a single // `list` takes a ListFilter (tech-spec §8.2). Map the flags: a single
// `--scope` id or `--project` NAME (resolved + subtree-expanded by the // `--scope` id or `--project` NAME (resolved + subtree-expanded by the
// daemon), a single `--attention` whitelist, and `--no-blue`. // daemon), a single `--attention` whitelist, and `--no-blue`.
let attention = norm_attention(attention)?;
let mut filter = json!({}); let mut filter = json!({});
if let Some(s) = scope { if let Some(s) = scope {
filter["scope"] = json!([s]); filter["scope"] = json!([s]);
@ -560,12 +498,11 @@ fn main() -> Result<()> {
println!("Skipped occurrence of {id}"); println!("Skipped occurrence of {id}");
} }
Command::Attention { id, attention } => { Command::Attention { id, attention } => {
let att = Attention::parse_input(&attention)?;
client.call( client.call(
"task.set_attention", "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 { Command::Edit {
id, id,
@ -591,7 +528,7 @@ fn main() -> Result<()> {
if patch.len() > 1 { if patch.len() > 1 {
client.call("task.set_schedule", Value::Object(patch))?; 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 }))?; client.call("task.set_attention", json!({ "id": id, "attention": a }))?;
} }
if let Some(spec) = project.as_deref() { if let Some(spec) = project.as_deref() {
@ -615,7 +552,6 @@ fn main() -> Result<()> {
attention, attention,
project, project,
} => { } => {
let attention = norm_attention(attention)?;
let project_id = resolve_project(&mut client, project.as_deref())?; let project_id = resolve_project(&mut client, project.as_deref())?;
let result = client.call( let result = client.call(
"task.promote", "task.promote",
@ -854,28 +790,13 @@ fn main() -> Result<()> {
let n = result.as_u64().unwrap_or(0); let n = result.as_u64().unwrap_or(0);
println!("Rewrote legacy [[Name]] links to [[id]] in {n} node(s)."); println!("Rewrote legacy [[Name]] links to [[id]] in {n} node(s).");
} }
Command::Auth { Command::Auth { .. } => unreachable!("auth is handled before connecting"),
action: AuthAction::Status,
} => {
let result = client.call("sync.status", json!({}))?;
print_auth_status(&result);
}
Command::Auth { .. } => unreachable!("auth login/logout handled before connecting"),
Command::Daemon { .. } => unreachable!("daemon is handled before connecting"), Command::Daemon { .. } => unreachable!("daemon is handled before connecting"),
} }
Ok(()) Ok(())
} }
/// Parse an optional human date into epoch-ms JSON (for `task.create`). /// 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<String>) -> Result<Option<String>> {
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<Option<i64>> { fn opt_date_ms(spec: Option<&str>) -> Result<Option<i64>> {
spec.map(datespec::parse_date_ms).transpose() spec.map(datespec::parse_date_ms).transpose()
} }

View file

@ -13,7 +13,6 @@
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::process::Command; use std::process::Command;
use std::time::{Duration, Instant};
use anyhow::{bail, Context, Result}; use anyhow::{bail, Context, Result};
use clap::{Args, Subcommand}; use clap::{Args, Subcommand};
@ -495,51 +494,6 @@ fn launchd_loaded(domain_target: &str) -> bool {
.unwrap_or(false) .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<()> { fn launchd(action: &DaemonAction, p: &Paths) -> Result<()> {
let plist = launchd_plist_path()?; let plist = launchd_plist_path()?;
let uid = uid()?; let uid = uid()?;
@ -558,7 +512,10 @@ fn launchd(action: &DaemonAction, p: &Paths) -> Result<()> {
if launchd_loaded(&target) { if launchd_loaded(&target) {
println!("heph daemon already running ({LABEL})."); println!("heph daemon already running ({LABEL}).");
} else { } 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})."); println!("heph daemon started ({LABEL}).");
} }
} }
@ -570,24 +527,14 @@ fn launchd(action: &DaemonAction, p: &Paths) -> Result<()> {
let cfg = args let cfg = args
.to_config() .to_config()
.fill_from(existing_config(&plist, &Manager::Launchd)); .fill_from(existing_config(&plist, &Manager::Launchd));
let changed = write_if_changed( write_if_changed(
&plist, &plist,
&launchd_plist(&p.hephd, &p.db, &p.socket, &p.log, &cfg), &launchd_plist(&p.hephd, &p.db, &p.socket, &p.log, &cfg),
)?; )?;
if !launchd_loaded(&target) { let _ = run_cmd("launchctl", &["bootout", &target])?;
// Not currently loaded — nothing to tear down, just bring it up. let (ok, err) = run_cmd("launchctl", &["bootstrap", &domain, &plist_str(&plist)?])?;
launchd_bootstrap(&domain, &plist_str(&plist)?)?; if !ok {
} else if changed { bail!("launchctl bootstrap failed: {}", err.trim());
// 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)?;
} }
println!("heph daemon restarted ({LABEL})."); println!("heph daemon restarted ({LABEL}).");
} }

View file

@ -38,45 +38,9 @@ pub enum AuthError {
/// The token was present but failed validation. /// The token was present but failed validation.
#[error("invalid token: {0}")] #[error("invalid token: {0}")]
Invalid(String), Invalid(String),
/// The identity provider could not be reached at all (DNS, TLS, connection /// The identity provider could not be reached to fetch keys.
/// refused, timeout) — a transport failure, distinct from a rejection.
#[error("identity provider unreachable: {0}")] #[error("identity provider unreachable: {0}")]
Unreachable(String), Provider(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(_))
}
} }
/// Verifies a bearer token and returns its [`Claims`]. A trait so the hub can be /// Verifies a bearer token and returns its [`Claims`]. A trait so the hub can be
@ -128,13 +92,16 @@ impl OidcVerifier {
.http .http
.get(url) .get(url)
.call() .call()
.map_err(|e| AuthError::Unreachable(e.to_string()))?; .map_err(|e| AuthError::Provider(e.to_string()))?;
if !resp.status().is_success() { 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() resp.body_mut()
.read_json() .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. /// Resolve the JWKS URI from the provider's discovery document.
@ -202,38 +169,3 @@ impl TokenVerifier for OidcVerifier {
Some((&self.issuer, &self.audience)) 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());
}
}

View file

@ -2,145 +2,59 @@
//! //!
//! Used by the `heph` CLI and by tests. Surfaces never touch SQLite directly //! 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. //! (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::io::{BufRead, BufReader, Write};
use std::os::unix::net::UnixStream; 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 serde_json::{json, Value};
use crate::rpc::Response; use crate::rpc::Response;
/// A connected client. One request/response per [`call`](Client::call). /// A connected client. One request/response per [`call`](Client::call).
pub struct Client { pub struct Client {
socket_path: PathBuf,
reader: BufReader<UnixStream>, reader: BufReader<UnixStream>,
writer: UnixStream, writer: UnixStream,
next_id: u64, 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 { impl Client {
/// Connect to a daemon listening at `socket_path`. /// Connect to a daemon listening at `socket_path`.
pub fn connect(socket_path: &Path) -> Result<Client> { pub fn connect(socket_path: &Path) -> Result<Client> {
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 { Ok(Client {
socket_path: socket_path.to_path_buf(),
reader, reader,
writer, writer: stream,
next_id: 1, next_id: 1,
}) })
} }
/// Open a fresh reader/writer pair on the socket.
fn open(socket_path: &Path) -> Result<(BufReader<UnixStream>, 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 /// Call `method` with `params`, returning the `result` value (or an error
/// carrying the RPC error's code and message). /// 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<Value> { pub fn call(&mut self, method: &str, params: Value) -> Result<Value> {
let id = self.next_id; let id = self.next_id;
self.next_id += 1; self.next_id += 1;
let mut line = serde_json::to_string(&json!({ let mut line = serde_json::to_string(&json!({
"id": id, "id": id,
"method": method, "method": method,
"params": params, "params": params,
}))?; }))?;
line.push('\n'); line.push('\n');
self.writer.write_all(line.as_bytes())?;
match self.exchange(&line) { self.writer.flush()?;
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<Value, ExchangeError> {
self.writer
.write_all(line.as_bytes())
.map_err(|e| ExchangeError::Send(e.into()))?;
self.writer
.flush()
.map_err(|e| ExchangeError::Send(e.into()))?;
let mut response_line = String::new(); let mut response_line = String::new();
let read = self let read = self.reader.read_line(&mut response_line)?;
.reader
.read_line(&mut response_line)
.map_err(|e| ExchangeError::Recv(e.into()))?;
if read == 0 { if read == 0 {
return Err(ExchangeError::Recv(anyhow!("hephd closed the connection"))); bail!("hephd closed the connection");
} }
let response: Response = let response: Response = serde_json::from_str(&response_line)?;
serde_json::from_str(&response_line).map_err(|e| ExchangeError::Rpc(e.into()))?;
if let Some(err) = response.error { if let Some(err) = response.error {
return Err(ExchangeError::Rpc(anyhow!( bail!("rpc error {}: {}", err.code, err.message);
"rpc error {}: {}",
err.code,
err.message
)));
} }
Ok(response.result.unwrap_or(Value::Null)) Ok(response.result.unwrap_or(Value::Null))
} }

View file

@ -109,7 +109,7 @@ impl KeyringTokenStore {
} }
}); });
keyring_core::Entry::new(&self.service, &self.account) 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() serde_json::from_str(&secret).ok()
} }
fn save(&self, token: &StoredToken) -> Result<(), AuthError> { 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()? self.entry()?
.set_password(&json) .set_password(&json)
.map_err(|e| AuthError::Other(e.to_string())) .map_err(|e| AuthError::Provider(e.to_string()))
} }
fn clear(&self) -> Result<(), AuthError> { fn clear(&self) -> Result<(), AuthError> {
match self.entry()?.delete_credential() { match self.entry()?.delete_credential() {
Ok(()) => Ok(()), Ok(()) => Ok(()),
Err(keyring_core::Error::NoEntry) => 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)] #[derive(Debug, Deserialize)]
struct TokenErrorBody { struct TokenErrorBody {
error: String, error: String,
/// Human-readable detail the provider may include (RFC 6749 §5.2).
#[serde(default)]
error_description: Option<String>,
} }
/// Drives the OAuth 2.0 device-code flow against one provider. /// Drives the OAuth 2.0 device-code flow against one provider.
@ -211,14 +208,17 @@ impl DeviceFlow {
let mut resp = http let mut resp = http
.get(&url) .get(&url)
.call() .call()
.map_err(|e| AuthError::Unreachable(e.to_string()))?; .map_err(|e| AuthError::Provider(e.to_string()))?;
if !resp.status().is_success() { 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 let doc: DiscoveryDoc = resp
.body_mut() .body_mut()
.read_json() .read_json()
.map_err(|e| AuthError::Other(e.to_string()))?; .map_err(|e| AuthError::Provider(e.to_string()))?;
Ok(DeviceFlow { Ok(DeviceFlow {
client_id: client_id.to_string(), client_id: client_id.to_string(),
http, http,
@ -233,13 +233,16 @@ impl DeviceFlow {
.http .http
.post(&self.device_authorization_endpoint) .post(&self.device_authorization_endpoint)
.send_form([("client_id", self.client_id.as_str()), ("scope", scope)]) .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() { 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() resp.body_mut()
.read_json() .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 /// 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()), ("device_code", auth.device_code.as_str()),
("client_id", self.client_id.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() { if response.status().is_success() {
let token: TokenResponse = response let token: TokenResponse = response
.body_mut() .body_mut()
.read_json() .read_json()
.map_err(|e| AuthError::Other(e.to_string()))?; .map_err(|e| AuthError::Provider(e.to_string()))?;
return Ok(token.into_stored()); return Ok(token.into_stored());
} }
@ -278,7 +281,7 @@ impl DeviceFlow {
let body: TokenErrorBody = response let body: TokenErrorBody = response
.body_mut() .body_mut()
.read_json() .read_json()
.map_err(|e| AuthError::Other(e.to_string()))?; .map_err(|e| AuthError::Provider(e.to_string()))?;
match body.error.as_str() { match body.error.as_str() {
"authorization_pending" => {} "authorization_pending" => {}
"slow_down" => interval += 5, "slow_down" => interval += 5,
@ -298,24 +301,17 @@ impl DeviceFlow {
("refresh_token", refresh_token), ("refresh_token", refresh_token),
("client_id", self.client_id.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() { if !response.status().is_success() {
// The IdP was reached and refused the grant (typically a `400 return Err(AuthError::Provider(format!(
// invalid_grant` once the refresh token is expired/rotated). Report "token refresh returned {}",
// it as a *rejection* with the OAuth error body — not "unreachable", response.status()
// which would misdirect debugging toward the network. )));
let status = response.status().as_u16();
let body = response.body_mut().read_json::<TokenErrorBody>().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()),
));
} }
let mut token: StoredToken = response let mut token: StoredToken = response
.body_mut() .body_mut()
.read_json::<TokenResponse>() .read_json::<TokenResponse>()
.map_err(|e| AuthError::Other(e.to_string()))? .map_err(|e| AuthError::Provider(e.to_string()))?
.into_stored(); .into_stored();
// Providers may omit the refresh token on refresh — keep the old one. // Providers may omit the refresh token on refresh — keep the old one.
if token.refresh_token.is_none() { if token.refresh_token.is_none() {

View file

@ -1,13 +1,12 @@
//! Single-line natural-language quick-add (tech-spec §8.1) — Todoist-style //! 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 //! 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 //! whole parser is unit-testable. Recognized inline tokens are extracted and the
//! remainder is the title (order preserved). The recognized forms mirror the //! remainder is the title (order preserved). The recognized forms mirror the
//! owner's Todoist usage ([[design]] §6.2.1): //! owner's Todoist usage ([[design]] §6.2.1):
//! //!
//! - **Attention** `a1`..`a4` → attention band, ordered by intensity //! - **Priority** `p1`..`p4` → attention (p1 red, p2 orange, p3 blue, p4 white).
//! (a1 red, a2 orange, a3 white, a4 blue).
//! - **Project** `#Name` — resolved against existing projects, greedily matching //! - **Project** `#Name` — resolved against existing projects, greedily matching
//! multi-word titles (`#Camano Chores`). An unresolved `#tag` is left in the //! multi-word titles (`#Camano Chores`). An unresolved `#tag` is left in the
//! title verbatim (no surprise project creation). //! title verbatim (no surprise project creation).
@ -41,13 +40,12 @@ pub struct Parsed {
pub project_id: Option<String>, pub project_id: Option<String>,
} }
/// `a1`..`a4` → attention band, ordered by intensity (a1 = most urgent). fn priority_attention(token: &str) -> Option<Attention> {
fn attention_token(token: &str) -> Option<Attention> {
match token.to_ascii_lowercase().as_str() { match token.to_ascii_lowercase().as_str() {
"a1" => Some(Attention::Red), "p1" => Some(Attention::Red),
"a2" => Some(Attention::Orange), "p2" => Some(Attention::Orange),
"a3" => Some(Attention::White), "p3" => Some(Attention::Blue),
"a4" => Some(Attention::Blue), "p4" => Some(Attention::White),
_ => None, _ => None,
} }
} }
@ -64,7 +62,7 @@ pub fn parse(input: &str, today: NaiveDate, projects: &[Project]) -> Parsed {
while i < tokens.len() { while i < tokens.len() {
let tok = &tokens[i]; let tok = &tokens[i];
if let Some(a) = attention_token(tok) { if let Some(a) = priority_attention(tok) {
out.attention = Some(a); out.attention = Some(a);
i += 1; i += 1;
continue; continue;
@ -172,20 +170,12 @@ mod tests {
} }
#[test] #[test]
fn attention_token_maps_to_attention() { fn priority_maps_to_attention() {
assert_eq!(p("Email boss a1").attention, Some(Attention::Red)); assert_eq!(p("Email boss p1").attention, Some(Attention::Red));
assert_eq!(p("Email boss a2").attention, Some(Attention::Orange)); assert_eq!(p("Email boss p2").attention, Some(Attention::Orange));
assert_eq!(p("Email boss a3").attention, Some(Attention::White)); assert_eq!(p("Email boss p3").attention, Some(Attention::Blue));
assert_eq!(p("Email boss a4").attention, Some(Attention::Blue)); assert_eq!(p("Email boss p4").attention, Some(Attention::White));
assert_eq!(p("Email boss a1").title, "Email boss"); assert_eq!(p("Email boss p1").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");
} }
#[test] #[test]
@ -225,7 +215,7 @@ mod tests {
#[test] #[test]
fn everything_at_once() { 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.title, "Plan trip");
assert_eq!(r.attention, Some(Attention::Orange)); assert_eq!(r.attention, Some(Attention::Orange));
assert_eq!(r.do_date, Some(ms(2026, 6, 5))); // the coming Friday assert_eq!(r.do_date, Some(ms(2026, 6, 5))); // the coming Friday

View file

@ -20,7 +20,6 @@ use tokio::net::{UnixListener, UnixStream};
use heph_core::Store; use heph_core::Store;
use crate::auth::AuthError;
use crate::oauth::{self, TokenStore}; use crate::oauth::{self, TokenStore};
use crate::rpc::{self, Request, Response, RpcError, INTERNAL_ERROR, PARSE_ERROR}; use crate::rpc::{self, Request, Response, RpcError, INTERNAL_ERROR, PARSE_ERROR};
use crate::selfupdate::{self, SelfUpdateConfig}; 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) .is_some_and(|s| s == reqwest::StatusCode::UNAUTHORIZED)
} }
/// The exact `heph auth login …` command that re-authenticates this spoke, built /// Fold one exchange outcome into the shared [`SyncHealth`].
/// from the hub URL + issuer + client id the daemon is configured with — so the fn record_sync_outcome(health: &Arc<Mutex<SyncHealth>>, result: &Result<sync::SyncReport>) {
/// 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<String> {
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<sync::SyncReport>) {
let now = now_ms(); 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); h.last_attempt_ms = Some(now);
match result { match result {
Ok(_) => { Ok(_) => {
@ -108,67 +92,28 @@ fn record_sync_outcome(ctx: &Ctx, result: &Result<sync::SyncReport>) {
h.auth_failure = false; h.auth_failure = false;
} }
Err(e) => { Err(e) => {
let auth_failure = is_auth_error(e); h.auth_failure = is_auth_error(e);
h.auth_failure = auth_failure; h.last_error = Some(e.to_string());
h.last_error = Some(annotate_reauth(
e.to_string(),
auth_failure,
ctx.hub_url.as_deref(),
ctx.auth.as_ref(),
));
} }
} }
} }
/// 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 { impl Ctx {
/// The current bearer token for hub sync (refreshing if expired). `Ok(None)` /// The current bearer token for hub sync (refreshing if expired), or `None`
/// means this spoke has no auth configured / no token stored (it syncs /// if this spoke has no auth configured / no usable token.
/// unauthenticated); `Err` means token acquisition genuinely failed (the async fn bearer(&self) -> Option<String> {
/// caller records it and skips the attempt rather than 401ing the hub). let auth = self.auth.clone()?;
async fn bearer(&self) -> Result<Option<String>, AuthError> { let result = tokio::task::spawn_blocking(move || {
let Some(auth) = self.auth.clone() else {
return Ok(None);
};
match tokio::task::spawn_blocking(move || {
oauth::current_bearer(auth.store.as_ref(), &auth.issuer, &auth.client_id) oauth::current_bearer(auth.store.as_ref(), &auth.issuer, &auth.client_id)
}) })
.await .await;
{ match result {
Ok(res) => res, Ok(Ok(token)) => token,
Err(_join) => Ok(None), // the blocking task panicked; treat as no 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); let mut tick = tokio::time::interval(interval);
loop { loop {
tick.tick().await; tick.tick().await;
let bearer = match ctx.bearer().await { let bearer = 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 result = let result =
sync::sync_once(ctx.store.clone(), &hub, &ctx.http, bearer.as_deref()).await; 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 { 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}"),
@ -386,25 +321,9 @@ async fn sync_now(ctx: &Ctx) -> Result<Value, RpcError> {
message: "no hub_url configured; this instance is standalone".into(), message: "no hub_url configured; this instance is standalone".into(),
}); });
}; };
let bearer = match ctx.bearer().await { let bearer = 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 result = 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, &result); record_sync_outcome(&ctx.sync_health, &result);
match result { match result {
Ok(report) => Ok(json!(report)), Ok(report) => Ok(json!(report)),
Err(e) => Err(RpcError { Err(e) => Err(RpcError {
@ -455,22 +374,10 @@ async fn sync_status(ctx: &Ctx) -> Result<Value, RpcError> {
.expect("sync_health mutex poisoned") .expect("sync_health mutex poisoned")
.clone(); .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!({ Ok(json!({
"hub_url": hub_url, "hub_url": hub_url,
"cursors": cursors, "cursors": cursors,
"conflicts": conflicts, "conflicts": conflicts,
"health": health, "health": health,
"auth": auth,
"reauth_command": reauth_command(Some(&hub_url), ctx.auth.as_ref()),
})) }))
} }

View file

@ -261,14 +261,8 @@ async fn require_auth(
.await .await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)? .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?
.map_err(|e| match e { .map_err(|e| match e {
// The token itself is missing/bad → tell the client it's unauthorized. AuthError::Provider(_) => StatusCode::SERVICE_UNAVAILABLE,
AuthError::Missing | AuthError::Invalid(_) => StatusCode::UNAUTHORIZED, _ => 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
}
})?; })?;
// Multi-tenancy seam: resolve the token's identity to the owner it may act // Multi-tenancy seam: resolve the token's identity to the owner it may act

View file

@ -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<AtomicUsize>) {
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);
}

View file

@ -90,25 +90,11 @@ async fn token(State(s): State<IdpState>, Form(form): Form<HashMap<String, Strin
})) }))
.into_response() .into_response()
} }
Some("refresh_token") => { Some("refresh_token") => Json(json!({
// A rotated/expired refresh token is refused with `400 invalid_grant` "access_token": "access-2",
// (RFC 6749 §5.2) — the case that used to be mislabeled "unreachable". "expires_in": 3600,
if form.get("refresh_token").map(String::as_str) == Some("refresh-expired") { }))
return ( .into_response(),
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()
}
_ => ( _ => (
StatusCode::BAD_REQUEST, StatusCode::BAD_REQUEST,
Json(json!({ "error": "unsupported_grant_type" })), 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")); 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] #[test]
fn memory_token_store_round_trips_and_reports_expiry() { fn memory_token_store_round_trips_and_reports_expiry() {
let store = MemoryTokenStore::default(); let store = MemoryTokenStore::default();

View 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.

View file

@ -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.

View file

@ -71,7 +71,7 @@ into preview chips before you submit:
| Token | Example | Effect | | 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) | | `#Project` | `#Camano Chores` | file under a project (greedy multi-word match) |
| date | `today` `tomorrow` `+3d` `fri` `2026-07-01` | do-date | | date | `today` `tomorrow` `+3d` `fri` `2026-07-01` | do-date |
| `every …` | `every 3 days` `every other wed` `every workday` | recurrence (RRULE) | | `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 ## Triage
Tap a task to expand its actions, mirroring the TUI keys: **Done** (`x`), Tap a task to expand its actions, mirroring the TUI keys: **Done** (`x`),
**Drop** (`d`), **Skip** (`S`, recurring only), **Attn** (pick a band a1a4, **Drop** (`d`), **Skip** (`S`, recurring only), **Attn** (cycle attention, `A`),
the TUI's `a` then a digit), **Date** (reschedule, `e`), **Move** (project **Date** (reschedule, `e`), **Move** (project picker, `m`), **Delete**
picker, `m`), **Delete**
(tombstone, `D`). Done/Drop show an **Undo**. The expanded view also shows the (tombstone, `D`). Done/Drop show an **Undo**. The expanded view also shows the
task's canonical-context body + recent log tail (read-only). task's canonical-context body + recent log tail (read-only).

View file

@ -86,14 +86,6 @@ still the old binary until you restart it:
heph daemon restart 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) ## Self-update (opt-in)
`hephd` can keep itself current: `heph daemon start --self-update` generates a `hephd` can keep itself current: `heph daemon start --self-update` generates a

View file

@ -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. 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) ## 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: 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 - **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 small enabler — seed a hub from a snapshot with a fresh origin, or
`hephd --owner-id` — would make this one step. `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 ## Related
- [[run-the-daemon]] — manage the local daemon as an OS service - [[run-the-daemon]] — manage the local daemon as an OS service

View file

@ -1,6 +1,6 @@
// heph-pwa — a mobile-first browser mirror of heph-tui. Browse the built-in // 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 // 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 // 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). // 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 { parse as quickParse } from "./quickadd.js";
import { today, parseDate, toEpochMs, humanizeRecurrence } from "./datespec.js"; import { today, parseDate, toEpochMs, humanizeRecurrence } from "./datespec.js";
import { import {
ATTENTION_BANDS,
ATTENTION_COLORS, ATTENTION_COLORS,
attentionLabel,
fmtRelative, fmtRelative,
hasFlag, hasFlag,
isOverdue, isOverdue,
nextAttention,
projectColor, projectColor,
} from "./fmt.js"; } from "./fmt.js";
@ -232,7 +231,7 @@ function taskDetail(t) {
actionBtn("✓ Done", () => triage(t, "done")), actionBtn("✓ Done", () => triage(t, "done")),
actionBtn("⤓ Drop", () => triage(t, "dropped")), actionBtn("⤓ Drop", () => triage(t, "dropped")),
t.recurrence && actionBtn("↻ Skip", () => doSkip(t)), t.recurrence && actionBtn("↻ Skip", () => doSkip(t)),
actionBtn("⚑ Attn", () => openAttention(t)), actionBtn("⚑ Attn", () => cycleAttention(t)),
actionBtn("📅 Date", () => openReschedule(t)), actionBtn("📅 Date", () => openReschedule(t)),
actionBtn("📁 Move", () => openMove(t)), actionBtn("📁 Move", () => openMove(t)),
actionBtn("🗑 Delete", () => doDelete(t), "danger"), actionBtn("🗑 Delete", () => doDelete(t), "danger"),
@ -354,7 +353,7 @@ function openQuickAdd() {
const input = h("input", { const input = h("input", {
class: "qa-input", class: "qa-input",
type: "text", type: "text",
placeholder: "Buy milk tomorrow a2 #Work every week", placeholder: "Buy milk tomorrow p2 #Work every week",
autocomplete: "off", autocomplete: "off",
autocapitalize: "sentences", autocapitalize: "sentences",
enterkeyhint: "done", enterkeyhint: "done",
@ -365,12 +364,12 @@ function openQuickAdd() {
const parsed = quickParse(input.value, today(), state.projects); const parsed = quickParse(input.value, today(), state.projects);
preview.innerHTML = ""; preview.innerHTML = "";
if (!input.value.trim()) { if (!input.value.trim()) {
preview.append(h("span", { class: "qa-hint" }, "a1a4 · #Project · today/+3d/fri · every week")); preview.append(h("span", { class: "qa-hint" }, "p1p4 · #Project · today/+3d/fri · every week"));
return; return;
} }
preview.append(h("span", { class: "qa-title" }, parsed.title || "(no title)")); preview.append(h("span", { class: "qa-title" }, parsed.title || "(no title)"));
if (parsed.attention) { 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.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))); 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 (a1a4) rather than cycling — cycling could async function cycleAttention(t) {
// skip past the band you wanted, and pushing to a4 (blue) used to drop the task const next = nextAttention(t.attention);
// 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();
try { try {
await state.client.setAttention(t.node_id, band); await state.client.setAttention(t.node_id, next);
toast(`Attention: ${attentionLabel(band)}`); toast(`Attention: ${next}`);
reload(); reload();
} catch (e) { } catch (e) {
toast(`Failed: ${e.message}`); toast(`Failed: ${e.message}`);

View file

@ -9,16 +9,15 @@ export const ATTENTION_COLORS = {
white: "var(--att-white)", white: "var(--att-white)",
}; };
/** /** The cycle order used by the attention toggle (matches the TUI's `A` key). */
* The attention bands a user can pick, in `a1`..`a4` order (by intensity). export const ATTENTION_CYCLE = [null, "white", "orange", "red", "blue"];
* Each entry is the storage color string; the label is its index + 1.
*/
export const ATTENTION_BANDS = ["red", "orange", "white", "blue"];
/** Attention color string → its `a1`..`a4` UI label (or "" if unset). */ /** Next attention in the cycle: none → white → orange → red → blue → white. */
export function attentionLabel(att) { export function nextAttention(att) {
const i = ATTENTION_BANDS.indexOf(att); const i = ATTENTION_CYCLE.indexOf(att ?? null);
return i < 0 ? "" : `a${i + 1}`; // 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). */ /** Whether an attention band shows a flag glyph (red/orange/blue; not white). */

View file

@ -1,11 +1,10 @@
// Single-line natural-language quick-add — a faithful JS port of hephd's // Single-line natural-language quick-add — a faithful JS port of hephd's
// `quickadd.rs` (tech-spec §8.1). Todoist-style capture: // `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 // Recognized inline tokens are extracted and the remainder is the title (order
// preserved). This mirrors the owner's Todoist usage ([[design]] §6.2.1): // preserved). This mirrors the owner's Todoist usage ([[design]] §6.2.1):
// - Attention a1..a4 → attention band, ordered by intensity // - Priority p1..p4 → attention (p1 red, p2 orange, p3 blue, p4 white)
// (a1 red, a2 orange, a3 white, a4 blue)
// - Project #Name → resolved against existing projects, greedily matching // - Project #Name → resolved against existing projects, greedily matching
// multi-word titles (#Camano Chores). Unresolved #tags // multi-word titles (#Camano Chores). Unresolved #tags
// stay in the title verbatim (no surprise project). // stay in the title verbatim (no surprise project).
@ -14,13 +13,13 @@
import { parseDate, toEpochMs, parseRecurrenceOrNull } from "./datespec.js"; import { parseDate, toEpochMs, parseRecurrenceOrNull } from "./datespec.js";
/** a1..a4 → attention color string (matching the RPC serialization), or null. */ /** p1..p4 → attention color string (matching the RPC serialization), or null. */
function attentionToken(token) { function priorityAttention(token) {
switch (token.toLowerCase()) { switch (token.toLowerCase()) {
case "a1": return "red"; case "p1": return "red";
case "a2": return "orange"; case "p2": return "orange";
case "a3": return "white"; case "p3": return "blue";
case "a4": return "blue"; case "p4": return "white";
default: return null; default: return null;
} }
} }
@ -77,7 +76,7 @@ export function parse(input, todayDate, projects = []) {
while (i < tokens.length) { while (i < tokens.length) {
const tok = tokens[i]; const tok = tokens[i];
const att = attentionToken(tok); const att = priorityAttention(tok);
if (att !== null) { if (att !== null) {
out.attention = att; out.attention = att;
i += 1; i += 1;

View file

@ -1,7 +1,7 @@
// Service worker: cache the app shell so heph launches offline. Data is never // 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 // 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. // anyway). Bump CACHE when shell assets change to evict the old set.
const CACHE = "heph-pwa-v5"; const CACHE = "heph-pwa-v4";
const SHELL = [ const SHELL = [
"./", "./",
"./index.html", "./index.html",

View file

@ -134,19 +134,12 @@ test("plain title", () => {
assert.equal(r.projectId, null); assert.equal(r.projectId, null);
}); });
test("attention token maps to attention", () => { test("priority maps to attention", () => {
assert.equal(p("Email boss a1").attention, "red"); assert.equal(p("Email boss p1").attention, "red");
assert.equal(p("Email boss a2").attention, "orange"); assert.equal(p("Email boss p2").attention, "orange");
assert.equal(p("Email boss a3").attention, "white"); assert.equal(p("Email boss p3").attention, "blue");
assert.equal(p("Email boss a4").attention, "blue"); assert.equal(p("Email boss p4").attention, "white");
assert.equal(p("Email boss a1").title, "Email boss"); assert.equal(p("Email boss p1").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("relative date is extracted", () => { test("relative date is extracted", () => {
@ -176,7 +169,7 @@ test("recurrence phrase is extracted", () => {
}); });
test("everything at once", () => { 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.title, "Plan trip");
assert.equal(r.attention, "orange"); assert.equal(r.attention, "orange");
assert.equal(r.doDate, ms(2026, 6, 5)); assert.equal(r.doDate, ms(2026, 6, 5));