diff --git a/apps/desktop/README.md b/apps/desktop/README.md index ee79f2c2b..62530fab9 100644 --- a/apps/desktop/README.md +++ b/apps/desktop/README.md @@ -215,9 +215,67 @@ observations, not locks against concurrent same-user changes. ## What v0 does not do yet -The Activity tab, CPU and memory, always-on, broader daemon controls and -upgrades come in later units. The design and the CLI/daemon requests they need -are in `DESIGN.md` (PR #800). +The Activity tab, CPU and memory, and upgrades come in later units. The design +and the CLI/daemon requests they need are in `DESIGN.md` (PR #800). + +## Daemon and always-on preview (DESIGN §5–6) + +The daemon card observes `hack daemon status --json`, retaining a valid raw report +on exit 1. Missing or malformed status is **unknown**; an omitted +`service.installed` is not evidence that the login service is absent. Version +mismatch comes from the read-only socket `/v1/status`, compared with the CLI; +the socket is the absolute `socket_path` in the CLI's own status answer, never a +guessed path. Daemon reads use the same pinned CLI and allowlisted environment +as project reads. +Explicit R8 `service.unit_stale: true` shows an outdated login service; missing +R8 metadata never triggers that diagnosis. + +Start, Restart and Install login service run today's `hack daemon +start|restart|install --no-interactive` as the current user, through setup's +checked CLI and execution context (never the app's own environment). Install +re-checks its guards and a fresh `daemon status` immediately before running. +Without a checked setup report no daemon action runs. They require no +terminal adapter and never request a password. Only one daemon action runs at a +time, including its fresh read after command exit. Exit 0 alone does not mean +running or installed. Failure keeps observed state and an error; timeout shows +unknown until another read. Missing CLI disables actions. Reads have deadlines; +actions have a two-minute deadline. The app never edits units, signals a daemon, +or starts one during observation. Only the CLI owns those effects. + +Setup Run, the daemon card and recovery share the same machine-action gate and +fixed CLI adapter. Project actions and terminal launches check that gate; daemon +actions also check setup's launch gate. Each result refreshes ordered project +status and setup (queuing a fresh setup check if an older check is in flight). +The existing setup terminal adapter remains owned by setup. + +Always-on consumes proposed project `autostart` and global `autostart_global` +fields when present, including manual-stop pause, failure count/limit/services, +global pause and login progress. Missing fields remain unknown. All always-on +switches are read-only: live R12a command capability is not established, and +current payloads lack R12b metadata. No proposed autostart command, local policy, +persistence or retry loop is implemented. No login service additionally explains +`hack daemon install`; missing `service_ready` never enables a switch. + +Additional fixtures: `daemon-not-installed`, `daemon-stopped`, `daemon-starting`, +`daemon-incompatible`, `daemon-stale`, `daemon-stale-unit`, `daemon-unknown`, +`always-off`, `always-on`, `always-paused`, `always-attention`, +`always-global-paused`, `always-login`, `always-no-login`, +`always-command-failed` (failure presentation only; no command runs). +Fixture buttons never mutate a live system. To capture one fixture in both themes: + +```sh +cargo run -p hack-desktop --features screenshots -- \ + --fixtures daemon-stale-unit --screenshots /tmp/daemon-shots +``` + +The daemon card is independent of project reads, so a project error does not +hide its observed status. Automatic daemon reads run at most every ten seconds; +post-action reads are immediate. Existing project polling remains unchanged. +R8 and R12 remain CLI/daemon work outside this desktop-only slice. This Linux +preview does not qualify native macOS service behavior or a shipped artifact. + +Fixture views: [daemon repair (light)](screenshots/daemon-stale-unit-light.png) +and [always-on needs attention (dark)](screenshots/always-attention-dark.png). ## Setup @@ -258,7 +316,7 @@ Needs attention. Missing service-manager availability never permits Install. Start and Install, and the project pane's Start/Restart hackd recovery, pass the selected CLI as the CLI's self-invocation selector (`HACK_MCP_COMMAND`), so the daemon they start or install runs that CLI, not whatever `hack` is first on PATH. Only the fixed `daemon start` and supported `daemon install` actions offer **Run**, with -bounded, memory-only output followed by fresh checks. Install requires explicit +bounded, memory-only exit/error feedback followed by fresh checks. Install requires explicit `service.available: true`, an ordinary home, and no launchd/systemd-disable guard; otherwise the card explains why and links to documentation. Availability is checked again immediately before install; an unavailable or unconfirmed manager is refused diff --git a/apps/desktop/app/src/screenshots.rs b/apps/desktop/app/src/screenshots.rs index 1016afa88..3502faaf6 100644 --- a/apps/desktop/app/src/screenshots.rs +++ b/apps/desktop/app/src/screenshots.rs @@ -15,7 +15,7 @@ use hack_desktop_core::logs::fixtures::LogsFixture; use crate::view::HackApp; -pub fn run(dir: &Path, fixture: Option) { +pub fn run(dir: &Path, selected: Option) { std::fs::create_dir_all(dir).expect("create screenshot dir"); let mut cx = HeadlessAppContext::with_platform( gpui_kit::platform::current_platform(true).text_system(), @@ -35,7 +35,11 @@ pub fn run(dir: &Path, fixture: Option) { shots.push((l.name(), base, Some(l))); } for mode in [ThemeMode::Light, ThemeMode::Dark] { - for name in hack_desktop_core::setup_fixtures::NAMES { + for name in hack_desktop_core::setup_fixtures::NAMES + .iter() + .copied() + .filter(|_| selected.is_none()) + { cx.update(|cx| Theme::change(mode, None, cx)); let mut opts = crate::window_options(); opts.focus = false; @@ -83,7 +87,7 @@ pub fn run(dir: &Path, fixture: Option) { } for &(name, f, logs) in shots .iter() - .filter(|(_, f, logs)| fixture.is_none_or(|selected| selected == *f && logs.is_none())) + .filter(|(_, f, logs)| selected.is_none_or(|s| s == *f && logs.is_none())) { cx.update(|cx| Theme::change(mode, None, cx)); let opts = WindowOptions { diff --git a/apps/desktop/app/src/setup_controller.rs b/apps/desktop/app/src/setup_controller.rs index f8d047bd5..8e6820a03 100644 --- a/apps/desktop/app/src/setup_controller.rs +++ b/apps/desktop/app/src/setup_controller.rs @@ -16,7 +16,7 @@ impl HackApp { self.setup.refresh_again = true; return; } - if self.setup.action_busy || self.recovery_busy { + if self.setup.action_busy || self.daemon.busy.is_some() { return; } let generation = self.setup.begin(); @@ -64,7 +64,7 @@ impl HackApp { if self.setup.report.cli_ready() && !self.setup.loading && !self.setup.action_busy - && !self.recovery_busy + && !self.daemon.busy.is_some() { self.pane = Pane::Project; cx.notify(); @@ -93,25 +93,13 @@ impl HackApp { cx.notify(); return; } - self.setup.result = None; - self.setup.action_busy = true; - let source = self.source.clone(); - let checked = self.setup.report.clone(); - cx.spawn(async move |this, cx| { - let result = cx - .background_executor() - .spawn(async move { source.setup_action(action, &checked) }) - .await; - let _ = this.update(cx, |this, cx| { - this.setup.action_busy = false; - this.setup.result = Some(result); - this.setup.pending_fix = true; - this.refresh_now_async(cx); - this.load_setup(cx); - }); - }) - .detach(); - cx.notify(); + self.run_daemon_action( + match action { + hack_desktop_core::setup::SafeAction::StartDaemon => DaemonAction::Start, + hack_desktop_core::setup::SafeAction::InstallDaemon => DaemonAction::Install, + }, + cx, + ); } pub fn open_setup_terminal(&mut self, args: Vec, cx: &mut Context) { use hack_desktop_core::terminal::{Platform, Script, SystemLauncher, linux_adapter}; diff --git a/apps/desktop/app/src/view.rs b/apps/desktop/app/src/view.rs index d21425519..2703336f4 100644 --- a/apps/desktop/app/src/view.rs +++ b/apps/desktop/app/src/view.rs @@ -21,6 +21,7 @@ use gpui_kit::{ AnyElement, Context, FontWeight, Hsla, IntoElement, ParentElement, Render, SharedString, Styled, Window, div, prelude::*, px, }; +use hack_desktop_core::daemon_state::{DaemonAction, DaemonModel, always_on}; use hack_desktop_core::doctor::{DoctorPane, Finish, TargetKey, should_auto_refresh}; use hack_desktop_core::models::LogEntry; use hack_desktop_core::source::{ @@ -43,6 +44,9 @@ const TAIL_LINES: u32 = 20; pub struct HackApp { pub(crate) model: AppModel, + daemon: DaemonModel, + daemon_epoch: u64, + daemon_last_read: Option, pub(crate) source: Arc, pub(crate) logs: crate::logs_view::LogsPane, tab: usize, @@ -59,8 +63,6 @@ pub struct HackApp { /// runs wait for it. window_visible: bool, _visibility: Option, - recovery_busy: bool, - recovery_error: Option, /// When each project's running action started, for the slow-action /// warning. Display only: the app never terminates a lifecycle command. action_started: std::collections::HashMap, @@ -77,6 +79,9 @@ impl HackApp { let logs = crate::logs_view::LogsPane::new(window, cx); let mut this = Self { model: AppModel::new(), + daemon: DaemonModel::default(), + daemon_epoch: 0, + daemon_last_read: None, source, logs, tab: 0, @@ -91,8 +96,6 @@ impl HackApp { tail: Ok(Vec::new()), follow: true, live, - recovery_busy: false, - recovery_error: None, action_started: std::collections::HashMap::new(), }; if live { @@ -124,6 +127,7 @@ impl HackApp { /// Synchronous refresh, for fixtures and screenshots. pub fn refresh_now(&mut self) { + self.daemon.observed = self.source.daemon_observation(); let snap = self.source.snapshot(); self.model.apply(Event::Refreshed(snap)); self.tail = self.selected_root().map_or_else( @@ -426,9 +430,39 @@ impl HackApp { async move { source.snapshot() } }) .await; + let daemon_epoch = this + .update(cx, |this, _| { + (this.window_visible + && this.daemon.busy.is_none() + && this + .daemon_last_read + .is_none_or(|last| last.elapsed() >= Duration::from_secs(10))) + .then_some(this.daemon_epoch) + }) + .ok() + .flatten(); + let daemon = if let Some(epoch) = daemon_epoch { + let source = source.clone(); + Some(( + epoch, + cx.background_executor() + .spawn(async move { source.daemon_observation() }) + .await, + )) + } else { + None + }; let root = this .update(cx, |this, cx| { + if this.daemon.busy.is_none() + && let Some((epoch, o)) = daemon + && epoch == this.daemon_epoch + { + this.daemon.observed = o; + this.daemon_last_read = Some(std::time::Instant::now()); + } if !this.apply_refresh(token, snap, cx) { + cx.notify(); return None; } (this.follow && this.tab == 0) @@ -472,7 +506,7 @@ impl HackApp { let Some(root) = row.repo_root.clone().map(PathBuf::from) else { return; }; - if !self.model.can_start(&project) { + if self.daemon.observed.cli_missing || !self.model.can_start(&project) { return; } self.model.apply(Event::ActionStarted { @@ -509,52 +543,154 @@ impl HackApp { .detach(); } - fn refresh_now_async(&mut self, cx: &mut Context) { + fn run_daemon_action(&mut self, action: DaemonAction, cx: &mut Context) { + if !self.live + || self.action_busy() + || self.model.cli_missing() + || (!self.setup.report.cards.is_empty() && !self.setup.report.cli_ready()) + || !self.daemon.begin_machine( + action, + self.model.daemon_recovery(), + Some(&self.setup.report), + ) + { + return; + } + self.daemon_epoch += 1; + // Invalidate project reads started before this machine action. + self.model.begin_refresh(); + cx.notify(); let source = self.source.clone(); - let token = self.model.begin_refresh(); + // Card and Setup Run actions use setup's checked CLI and context. + let checked = self.setup.report.clone(); cx.spawn(async move |this, cx| { + let action_source = source.clone(); + let result = cx + .background_executor() + .spawn(async move { action_source.daemon_action(action, &checked) }) + .await; + let _ = this.update(cx, |this, cx| { + this.daemon.finished(&result); + // Keep this receipt through the automatic recheck below, as + // setup's own action path does, so a failure stays readable. + this.setup.pending_fix = true; + this.setup.result = Some(match &result { + Ok(()) => "Daemon command exited successfully. Checking fresh status.".into(), + Err(e) => format!("Daemon command failed: {e}. Checking fresh status."), + }); + cx.notify(); + }); + // This read starts only after the action process and output have settled. + let fresh = cx + .background_executor() + .spawn(async move { source.daemon_observation() }) + .await; + let Ok(token) = this.update(cx, |this, _| { + this.daemon.refreshed(fresh); + this.daemon.busy = Some(action); + this.daemon_last_read = Some(std::time::Instant::now()); + this.model.begin_refresh() + }) else { + return; + }; + let source = this.update(cx, |this, _| this.source.clone()).ok(); + let Some(source) = source else { + return; + }; let snap = cx .background_executor() .spawn(async move { source.snapshot() }) .await; - let _ = this.update(cx, |this, cx| this.apply_refresh(token, snap, cx)); + let _ = this.update(cx, |this, cx| { + this.apply_refresh(token, snap, cx); + this.daemon.busy = None; + this.load_setup(cx); + cx.notify(); + }); }) .detach(); } - /// Shared by setup, recovery and the daemon/upgrade machine controls. - pub(crate) fn action_busy(&self) -> bool { - self.machine_action_busy() || self.model.has_in_flight() - } - pub(crate) fn machine_action_busy(&self) -> bool { - self.recovery_busy || self.setup.action_busy + fn daemon_panel(&self, cx: &mut Context) -> impl IntoElement { + let t = cx.theme().clone(); + let mut buttons = h_flex().gap_2(); + for action in self.daemon.observed.actions() { + buttons = buttons.child( + Button::new(action.verb()) + .label(action.label()) + .small() + .outline() + .disabled( + !self.live + || self.action_busy() + || self.model.cli_missing() + || (!self.setup.report.cards.is_empty() + && !self.setup.report.cli_ready()), + ) + .on_click( + cx.listener(move |this, _, _, cx| this.run_daemon_action(action, cx)), + ), + ); + } + v_flex() + .px_5() + .py_2() + .gap_1() + .border_b_1() + .border_color(t.border) + .text_sm() + .child( + h_flex() + .gap_2() + .child(dot(tone_color(self.daemon.observed.tone(), cx))) + .child( + div() + .text_color(tone_color(self.daemon.observed.tone(), cx)) + .child(self.daemon.observed.label()), + ) + .child(div().flex_1()) + .child(buttons), + ) + .children(self.daemon.busy.map(|a| { + div() + .text_color(t.info) + .child(format!("{}… checking fresh status after exit", a.label())) + })) + .children( + self.daemon + .observed + .detail() + .map(|s| div().text_xs().text_color(t.muted_foreground).child(s)), + ) + .children(self.daemon.action_error.clone().map(|e| { + div() + .text_xs() + .text_color(t.danger) + .child(format!("Last daemon action failed: {e}")) + })) } - fn recover_daemon(&mut self, action: DaemonRecovery, cx: &mut Context) { - if self.action_busy() || !self.live { - return; - } - self.recovery_busy = true; - self.recovery_error = None; + fn refresh_now_async(&mut self, cx: &mut Context) { let source = self.source.clone(); - cx.notify(); + let token = self.model.begin_refresh(); cx.spawn(async move |this, cx| { - let result = cx + let snap = cx .background_executor() - .spawn(async move { source.recover_daemon(action) }) + .spawn(async move { source.snapshot() }) .await; - let _ = this.update(cx, |this, cx| { - this.recovery_busy = false; - this.recovery_error = result.err(); - // A command exit is not evidence that a compatible daemon - // is ready. Only the next ordered snapshot can restore rows. - this.refresh_now_async(cx); - this.load_setup(cx); - cx.notify(); - }); + let _ = this.update(cx, |this, cx| this.apply_refresh(token, snap, cx)); }) .detach(); } + + /// One gate for machine effects (setup actions and daemon card actions). + pub(crate) fn machine_action_busy(&self) -> bool { + self.daemon.busy.is_some() || self.setup.action_busy + } + /// Machine and project effects exclude each other. + pub(crate) fn action_busy(&self) -> bool { + self.machine_action_busy() || self.model.has_in_flight() + } } fn tone_color(tone: Tone, cx: &gpui_kit::App) -> Hsla { @@ -799,7 +935,12 @@ impl HackApp { .child(b.text()), ); } - if let Some(action) = self.model.daemon_recovery() { + if let Some(action) = self.model.daemon_recovery() + && !self.daemon.observed.actions().contains(&match action { + DaemonRecovery::Start => DaemonAction::Start, + DaemonRecovery::Restart => DaemonAction::Restart, + }) + { col = col.child( h_flex() .px_4() @@ -809,14 +950,25 @@ impl HackApp { Button::new("recover-hackd") .label(action.label()) .small() - .disabled(self.action_busy()) - .loading(self.recovery_busy) - .on_click( - cx.listener(move |this, _, _, cx| this.recover_daemon(action, cx)), - ), + .disabled( + !self.live + || self.action_busy() + || self.daemon.observed.cli_missing, + ) + .loading(self.daemon.busy.is_some()) + .on_click(cx.listener(move |this, _, _, cx| { + this.run_daemon_action( + match action { + DaemonRecovery::Start => DaemonAction::Start, + DaemonRecovery::Restart => DaemonAction::Restart, + }, + cx, + ) + })), ) .children( - self.recovery_error + self.daemon + .action_error .clone() .map(|e| div().text_sm().text_color(t.danger).child(e)), ), @@ -835,7 +987,7 @@ impl HackApp { let mut b = Button::new(a.label()) .label(a.label()) .small() - .disabled(d.busy || self.machine_action_busy()); + .disabled(d.busy || self.machine_action_busy() || self.daemon.observed.cli_missing); b = match a { Action::Start => b.primary(), Action::Stop => b.outline(), @@ -881,13 +1033,41 @@ impl HackApp { .child(div().flex_1()) .child( Switch::new("always-on") - .label("Always on") + .label( + if self + .model + .selected_row() + .and_then(|r| r.autostart.as_ref()) + .and_then(|a| a.enabled) + .is_none() + { + "Always on · unknown" + } else { + "Always on" + }, + ) .checked(d.always_on) .small() .disabled(true) .tooltip("Always-on controls are not available yet"), ), ) + .child( + div() + .text_xs() + .text_color(t.muted_foreground) + .child(always_on( + self.model.selected_row().and_then(|r| r.autostart.as_ref()), + self.model.always_on_global(), + &self.daemon.observed, + )), + ) + .children(self.source.always_on_error().map(|error| { + div() + .text_xs() + .text_color(t.danger) + .child(format!("Last always-on command failed: {error}")) + })) .children(reason_line(&d.state, d.last_error.as_deref(), cx)) .children(self.doctor_link(&d.name, cx)) .children( @@ -1200,19 +1380,20 @@ impl HackApp { } else { ("Docker ok", t.success) }; + let daemon = if list .banners .iter() .any(|b| matches!(b, hack_desktop_core::viewmodel::Banner::DaemonOff)) { - ("hackd off", t.muted_foreground) + ("hackd off".to_owned(), t.muted_foreground) } else if list.banners.iter().any(|b| { matches!( b, hack_desktop_core::viewmodel::Banner::DaemonMismatch { .. } ) }) { - ("hackd version mismatch", t.warning) + ("hackd version mismatch".to_owned(), t.warning) } else if stale || list.banners.iter().any(|b| { matches!( @@ -1221,10 +1402,14 @@ impl HackApp { ) }) { - ("hackd unknown", t.muted_foreground) + ("hackd unknown".to_owned(), t.muted_foreground) } else { - ("hackd on", t.success) + ( + self.daemon.observed.label(), + tone_color(self.daemon.observed.tone(), cx), + ) }; + h_flex() .h(px(26.)) .px_3() @@ -1403,6 +1588,7 @@ impl Render for HackApp { .min_w_0() .when(self.pane != Pane::Setup, |el| { el.child(self.banners(&list, cx)) + .child(self.daemon_panel(cx)) }) .child(main), ), diff --git a/apps/desktop/core/src/daemon.rs b/apps/desktop/core/src/daemon.rs index 04d91dea3..5a30625d9 100644 --- a/apps/desktop/core/src/daemon.rs +++ b/apps/desktop/core/src/daemon.rs @@ -80,7 +80,13 @@ impl DaemonClient { /// # Errors /// Any [`DaemonError`]. pub fn status(&self) -> Result { - self.get_json("/v1/status") + let s: DaemonApiStatus = self.get_json("/v1/status")?; + if s.status.is_empty() || s.version.is_empty() { + return Err(DaemonError::BadResponse( + "status and version are required".into(), + )); + } + Ok(s) } /// # Errors @@ -98,8 +104,7 @@ impl DaemonClient { check_socket_path_len(&self.socket)?; let uid = current_uid(); check_private_dir(&self.socket, uid)?; - let mut s = UnixStream::connect(&self.socket) - .map_err(|e| DaemonError::Unreachable(e.to_string()))?; + let mut s = connect_bounded(&self.socket, self.timeout)?; match peer_uid(&s) { Some(p) if p == uid => {} Some(p) => { @@ -117,13 +122,187 @@ impl DaemonClient { ); s.write_all(req.as_bytes()).map_err(io_err)?; let mut raw = Vec::new(); - s.take(MAX_BODY as u64 + 64 * 1024) - .read_to_end(&mut raw) - .map_err(io_err)?; + let deadline = std::time::Instant::now() + self.timeout; + let limit = MAX_BODY + 64 * 1024; + let mut buf = [0; 8192]; + loop { + let left = deadline.saturating_duration_since(std::time::Instant::now()); + if left.is_zero() { + return Err(DaemonError::Timeout); + } + // Wait with poll rather than SO_RCVTIMEO: macOS rejects setting a + // receive timeout (EINVAL) once the peer has closed, which is the + // normal end of an HTTP/1.1 response here. + if !readable_within(&s, left)? { + return Err(DaemonError::Timeout); + } + let n = match s.read(&mut buf) { + Err(e) if e.kind() == std::io::ErrorKind::Interrupted => continue, + result => result.map_err(io_err)?, + }; + if n == 0 { + break; + } + if raw.len() + n > limit { + return Err(DaemonError::BadResponse("response too large".into())); + } + raw.extend_from_slice(&buf[..n]); + } parse_http(&raw) } } +/// Whether `stream` has data, end of file or an error within `timeout`. +fn readable_within(stream: &UnixStream, timeout: Duration) -> Result { + use std::os::fd::AsRawFd; + let deadline = std::time::Instant::now() + timeout; + loop { + let left = deadline.saturating_duration_since(std::time::Instant::now()); + if left.is_zero() { + return Ok(false); + } + let mut poll = libc::pollfd { + fd: stream.as_raw_fd(), + events: libc::POLLIN, + revents: 0, + }; + // SAFETY: poll points to one live initialized pollfd for this call. + let ready = unsafe { + libc::poll( + &mut poll, + 1, + left.as_millis().max(1).min(i32::MAX as u128) as i32, + ) + }; + match ready { + 0 => return Ok(false), + n if n > 0 => return Ok(true), + _ => { + let e = std::io::Error::last_os_error(); + if e.kind() != std::io::ErrorKind::Interrupted { + return Err(io_err(e)); + } + } + } + } +} + +/// A full listen backlog must not make an observe-only read block forever. +fn connect_bounded(path: &Path, timeout: Duration) -> Result { + use std::os::fd::{AsRawFd, FromRawFd, OwnedFd}; + use std::os::unix::ffi::OsStrExt; + let bytes = path.as_os_str().as_bytes(); + if bytes.contains(&0) { + return Err(DaemonError::BadResponse("socket path contains NUL".into())); + } + // SAFETY: socket has no pointer arguments; an owned descriptor closes on + // every error path. sockaddr_un is zero-initialized and its path length + // was checked by the caller before this helper. + #[cfg(target_os = "linux")] + let kind = libc::SOCK_STREAM | libc::SOCK_CLOEXEC; + #[cfg(not(target_os = "linux"))] + let kind = libc::SOCK_STREAM; + // SAFETY: socket arguments contain no pointers. Linux sets close-on-exec + // atomically; other platforms set it before the descriptor is used below. + let raw = unsafe { libc::socket(libc::AF_UNIX, kind, 0) }; + if raw < 0 { + return Err(io_err(std::io::Error::last_os_error())); + } + // SAFETY: successful socket() returned a fresh descriptor we own. + let fd = unsafe { OwnedFd::from_raw_fd(raw) }; + // SAFETY: zero is valid for sockaddr_un; no references/pointers inside. + let mut addr: libc::sockaddr_un = unsafe { std::mem::zeroed() }; + if bytes.len() >= addr.sun_path.len() { + return Err(DaemonError::BadResponse("socket path too long".into())); + } + addr.sun_family = libc::AF_UNIX as _; + for (dst, src) in addr.sun_path.iter_mut().zip(bytes) { + *dst = *src as _; + } + let len = std::mem::offset_of!(libc::sockaddr_un, sun_path) + bytes.len() + 1; + #[cfg(any(target_os = "macos", target_os = "freebsd", target_os = "openbsd"))] + { + addr.sun_len = len as _; + } + // SAFETY: descriptor is owned/live, addr is initialized for len bytes; + // fcntl changes only this task's new socket, never a shared descriptor. + let result = unsafe { + if libc::fcntl(raw, libc::F_SETFD, libc::FD_CLOEXEC) < 0 + || libc::fcntl(raw, libc::F_SETFL, libc::O_NONBLOCK) < 0 + { + return Err(io_err(std::io::Error::last_os_error())); + } + libc::connect( + raw, + std::ptr::from_ref(&addr).cast(), + len as libc::socklen_t, + ) + }; + if result < 0 { + let e = std::io::Error::last_os_error(); + // Unix-domain nonblocking connect reports EAGAIN for a full queue; + // there is no connection in progress to wait for in that case. + if e.raw_os_error() == Some(libc::EAGAIN) { + return Err(DaemonError::Timeout); + } + if e.raw_os_error() != Some(libc::EINPROGRESS) { + return Err(io_err(e)); + } + let deadline = std::time::Instant::now() + timeout; + loop { + let left = deadline.saturating_duration_since(std::time::Instant::now()); + if left.is_zero() { + return Err(DaemonError::Timeout); + } + let mut poll = libc::pollfd { + fd: fd.as_raw_fd(), + events: libc::POLLOUT, + revents: 0, + }; + // SAFETY: poll points to one live initialized pollfd for this call. + let ready = unsafe { + libc::poll( + &mut poll, + 1, + left.as_millis().max(1).min(i32::MAX as u128) as i32, + ) + }; + if ready == 0 { + return Err(DaemonError::Timeout); + } + if ready < 0 { + let e = std::io::Error::last_os_error(); + if e.kind() == std::io::ErrorKind::Interrupted { + continue; + } + return Err(io_err(e)); + } + let mut error: libc::c_int = 0; + let mut size = std::mem::size_of_val(&error) as libc::socklen_t; + // SAFETY: output pointers name writable integers of the declared size. + if unsafe { + libc::getsockopt( + raw, + libc::SOL_SOCKET, + libc::SO_ERROR, + std::ptr::from_mut(&mut error).cast(), + &mut size, + ) + } < 0 + { + return Err(io_err(std::io::Error::last_os_error())); + } + if error != 0 { + return Err(io_err(std::io::Error::from_raw_os_error(error))); + } + break; + } + } + let stream = UnixStream::from(fd); + stream.set_nonblocking(false).map_err(io_err)?; + Ok(stream) +} + /// Longest Unix socket path, in bytes, this platform accepts /// (`sockaddr_un.sun_path` minus the terminating NUL). #[cfg(any(target_os = "macos", target_os = "freebsd", target_os = "openbsd"))] @@ -337,6 +516,57 @@ mod tests { (sock, h) } + #[test] + fn slow_drip_cannot_extend_the_read_deadline() { + let dir = short_socket_dir(); + let socket = dir.join("slow.sock"); + let listener = UnixListener::bind(&socket).unwrap(); + let server = thread::spawn(move || { + let (mut stream, _) = listener.accept().unwrap(); + let mut request = [0; 1024]; + assert!(stream.read(&mut request).unwrap() > 0); + for _ in 0..20 { + if stream.write_all(b"x").is_err() { + break; + } + thread::sleep(Duration::from_millis(10)); + } + std::fs::remove_dir_all(dir).unwrap(); + }); + let start = std::time::Instant::now(); + let result = DaemonClient::new(socket, Duration::from_millis(50)).status(); + assert!(matches!(result, Err(DaemonError::Timeout)), "{result:?}"); + assert!(start.elapsed() < Duration::from_secs(2)); + server.join().unwrap(); + } + + #[cfg(target_os = "linux")] + #[test] + fn a_full_socket_backlog_does_not_block_observation() { + use std::os::fd::AsRawFd; + let dir = short_socket_dir(); + let socket = dir.join("full.sock"); + let listener = UnixListener::bind(&socket).unwrap(); + // SAFETY: this test exclusively owns the listener; change its backlog. + assert_eq!(unsafe { libc::listen(listener.as_raw_fd(), 0) }, 0); + let _pending = UnixStream::connect(&socket).unwrap(); + let start = std::time::Instant::now(); + let result = DaemonClient::new(socket, Duration::from_millis(50)).status(); + assert!(matches!(result, Err(DaemonError::Timeout)), "{result:?}"); + assert!(start.elapsed() < Duration::from_secs(2)); + std::fs::remove_dir_all(dir).unwrap(); + } + + #[test] + fn empty_socket_status_is_unknown() { + let (sock, h) = serve_once(b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\n{}".to_vec()); + assert!(matches!( + DaemonClient::new(sock, Duration::from_secs(1)).status(), + Err(DaemonError::BadResponse(_)) + )); + h.join().unwrap(); + } + #[test] fn reads_status_with_content_length() { let (sock, h) = serve_once( diff --git a/apps/desktop/core/src/daemon_state.rs b/apps/desktop/core/src/daemon_state.rs new file mode 100644 index 000000000..e7a0d7f1c --- /dev/null +++ b/apps/desktop/core/src/daemon_state.rs @@ -0,0 +1,740 @@ +//! Observations and explicit CLI actions for DESIGN sections 5 and 6. +//! This module never owns a daemon process, login unit or always-on policy. +use crate::daemon::DaemonClient; +use crate::models::{Autostart, AutostartGlobal, DaemonStatus}; +use crate::runner::{CliRunner, RunError}; +use std::time::Duration; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum DaemonAction { + Start, + Restart, + Install, +} +impl DaemonAction { + #[must_use] + pub fn verb(self) -> &'static str { + match self { + Self::Start => "start", + Self::Restart => "restart", + Self::Install => "install", + } + } + #[must_use] + pub fn label(self) -> &'static str { + match self { + Self::Start => "Start hackd", + Self::Restart => "Restart hackd", + Self::Install => "Install login service", + } + } +} + +#[derive(Debug, Clone, Default)] +pub struct Observation { + pub cli_version: Option, + pub status: Option, + pub socket_version: Option, + pub error: Option, + pub cli_missing: bool, +} +impl Observation { + #[must_use] + pub fn tone(&self) -> crate::state::Tone { + use crate::state::Tone; + if self + .status + .as_ref() + .and_then(|s| s.service.as_ref()) + .is_some_and(|s| s.unit_stale == Some(true)) + || matches!((&self.cli_version, &self.socket_version), (Some(c), Some(d)) if c != d) + { + return Tone::Warn; + } + match self.status.as_ref().map(|s| s.status.as_str()) { + Some("running") if self.error.is_none() => Tone::Ok, + Some("starting") => Tone::Busy, + Some("incompatible" | "stale") => Tone::Warn, + _ => Tone::Idle, + } + } + + #[must_use] + pub fn label(&self) -> String { + if self + .status + .as_ref() + .and_then(|s| s.service.as_ref()) + .is_some_and(|s| s.unit_stale == Some(true)) + { + return "Login service is out of date".into(); + } + if let (Some(cli), Some(daemon)) = (&self.cli_version, &self.socket_version) + && cli != daemon + { + return format!("hackd v{daemon}, CLI v{cli}"); + } + let Some(s) = &self.status else { + return "hackd status unknown".into(); + }; + if s.stale == Some(true) { + return "hackd stopped unexpectedly".into(); + } + match s.status.as_str() { + "running" => "hackd running".into(), + "starting" => "hackd starting…".into(), + "incompatible" => "hackd incompatible".into(), + "stopped" => match s.service.as_ref().and_then(|s| s.installed) { + Some(true) => "hackd stopped (login service installed)".into(), + Some(false) => "hackd off (login service not installed)".into(), + None => "hackd stopped (login service unknown)".into(), + }, + _ => "hackd status unknown".into(), + } + } + #[must_use] + pub fn actions(&self) -> Vec { + if self.cli_missing || self.cli_version.is_none() { + return vec![]; + } + let mut actions = vec![]; + let mismatch = + matches!((&self.cli_version, &self.socket_version), (Some(c), Some(d)) if c != d); + if mismatch { + actions.push(DaemonAction::Restart); + } + let Some(s) = &self.status else { + return actions; + }; + if s.running == Some(false) && matches!(s.status.as_str(), "stopped" | "stale") { + actions.push(DaemonAction::Start); + } + if s.status == "incompatible" && !mismatch { + actions.push(DaemonAction::Restart); + } + if s.service + .as_ref() + .is_some_and(|s| s.installed == Some(false) || s.unit_stale == Some(true)) + { + actions.push(DaemonAction::Install); + } + actions + } + #[must_use] + pub fn detail(&self) -> Option { + let mut parts = self.error.clone().into_iter().collect::>(); + if let Some(s) = &self.status { + parts.extend(s.issue.clone()); + parts.extend(s.stale_reason.clone()); + if let Some(unit) = &s.service { + parts.extend(unit.unit_stale_reason.clone()); + parts.extend(unit.next_step.clone()); + } + if let Some(next) = &s.next_step + && !parts.contains(next) + { + parts.push(next.clone()); + } + } + (!parts.is_empty()).then(|| parts.join(" · ")) + } +} + +/// The daemon client named by a CLI `daemon status --json` answer: only an +/// absolute `socket_path` counts; nothing is guessed from the environment. +#[must_use] +pub fn socket_from_status(status: &serde_json::Value) -> Option { + let path = std::path::Path::new(status.get("socket_path")?.as_str()?); + path.is_absolute() + .then(|| DaemonClient::new(path.to_path_buf(), Duration::from_secs(3))) +} + +/// Read raw JSON even on exit 1: an unhealthy report is still evidence. +/// `socket` is an injected client; otherwise `discover` derives one from the +/// CLI's own `daemon status --json` answer (its absolute `socket_path`). +#[must_use] +pub fn observe( + runner: &CliRunner, + socket: Option<&DaemonClient>, + discover: impl Fn(&serde_json::Value) -> Option, +) -> Observation { + let mut discovered = None; + let mut o = Observation::default(); + match runner.version(Duration::from_secs(5)) { + Ok(v) => o.cli_version = Some(v), + Err(e) => { + o.cli_missing = e == RunError::NotFound; + o.error = Some(e.to_string()); + return o; + } + } + // An automatic read: never start Docker or unchecked tools (see + // `with_status_tools`). + let read = crate::source::with_status_tools(runner, |runner| { + runner.run_raw( + &["daemon", "status", "--json", "--no-interactive"], + None, + Duration::from_secs(15), + ) + }) + .unwrap_or_else(|why| Err(RunError::Spawn(why))); + match read { + Ok((out, _, Some(0 | 1))) => match serde_json::from_str::(&out) + .map_err(|e| e.to_string()) + .and_then(|value| { + discovered = discover(&value); + DaemonStatus::from_value(value) + }) { + Ok(s) => o.status = Some(s), + Err(e) => o.error = Some(e), + }, + Ok((_, _, code)) => o.error = Some(format!("daemon status exited with {code:?}")), + Err(e) => { + if e == RunError::NotFound { + o.cli_missing = true; + o.cli_version = None; + } + o.error = Some(e.to_string()); + } + } + if let Some(socket) = socket.or(discovered.as_ref()) { + match socket.status() { + Ok(s) if !s.version.is_empty() => o.socket_version = Some(s.version), + Ok(_) => o.error = Some("socket status has no version".into()), + Err(e) => { + if o.status.as_ref().is_some_and(|s| s.running == Some(true)) { + o.error = Some(e.to_string()); + } + } + } + } + o +} + +/// One non-admin command. Its exit is not an observed state. +/// # Errors +/// Spawn, exit or timeout failure; caller must obtain a fresh observation. +pub fn act(runner: &CliRunner, action: DaemonAction) -> Result<(), RunError> { + let (stdout, stderr, code) = runner.run_raw( + &["daemon", action.verb(), "--no-interactive"], + None, + Duration::from_secs(120), + )?; + if code != Some(0) { + // The CLI's fallback logger writes diagnostics to stdout: keep a + // bounded tail of both streams so the failure reason stays visible. + let detail = [stderr.as_str(), stdout.as_str()] + .into_iter() + .map(|s| tail(s.trim(), 2048)) + .filter(|s| !s.is_empty()) + .collect::>() + .join("\n"); + return Err(RunError::Exit { + code, + stderr: detail, + }); + } + Ok(()) +} + +/// The last `max` bytes of `s`, cut on a character boundary. +fn tail(s: &str, max: usize) -> &str { + let mut start = s.len().saturating_sub(max); + while !s.is_char_boundary(start) { + start += 1; + } + &s[start..] +} + +#[derive(Debug, Clone, Default)] +pub struct DaemonModel { + pub observed: Observation, + pub busy: Option, + pub action_error: Option, +} +impl DaemonModel { + /// Reject overlap and unavailable actions at the effect boundary. + pub fn begin(&mut self, action: DaemonAction) -> bool { + self.begin_with_recovery(action, None) + } + /// The project reader can prove a recovery action before daemon status + /// answers. Both affordances use this same gate through their fresh reads. + pub fn begin_with_recovery( + &mut self, + action: DaemonAction, + recovery: Option, + ) -> bool { + self.begin_machine(action, recovery, None) + } + + /// Setup Run, recovery and card clicks share this admission gate. + /// Setup may authorize only one of its fixed non-admin remedies. + pub fn begin_machine( + &mut self, + action: DaemonAction, + recovery: Option, + setup: Option<&crate::setup::Report>, + ) -> bool { + let setup_allows = setup.is_some_and(|report| { + report.cli_ready() + && report.cards.iter().any(|card| { + card.status != crate::setup::Status::Passed + && matches!( + (&card.remedy, action), + ( + crate::setup::Remedy::Run(crate::setup::SafeAction::StartDaemon), + DaemonAction::Start + ) | ( + crate::setup::Remedy::Run(crate::setup::SafeAction::InstallDaemon), + DaemonAction::Install + ) + ) + }) + }); + let recovery_action = recovery.map(|r| match r { + crate::source::DaemonRecovery::Start => DaemonAction::Start, + crate::source::DaemonRecovery::Restart => DaemonAction::Restart, + }); + if self.busy.is_some() + || self.observed.cli_missing + || (!self.observed.actions().contains(&action) + && recovery_action != Some(action) + && !setup_allows) + { + return false; + } + self.busy = Some(action); + self.action_error = None; + true + } + /// Failure retains evidence; timeout discards it until a fresh read. + pub fn finished(&mut self, result: &Result<(), RunError>) { + if let Err(e) = result { + self.action_error = Some(e.to_string()); + if e == &RunError::NotFound { + self.observed.cli_missing = true; + } + if matches!(e, RunError::Timeout { .. }) { + self.observed.status = None; + self.observed.socket_version = None; + } + } + } + /// Keep the gate held through the post-action read. Failed reads cannot + /// turn a successful command into a claimed running/stopped state. + pub fn refreshed(&mut self, observation: Observation) { + if observation.status.is_none() && self.action_error.is_some() { + self.observed.error = observation.error; + self.observed.cli_missing = observation.cli_missing; + } else { + self.observed = observation; + } + self.busy = None; + } +} + +/// R12 is proposed: consume observations, never invent a command or policy. +#[must_use] +pub fn always_on( + a: Option<&Autostart>, + global: Option<&AutostartGlobal>, + daemon: &Observation, +) -> String { + let mut lines = vec![]; + if let Some(a) = a { + lines.push(match a.state.as_str() { + "off" => "Always-on off".into(), + "on" => "Always-on on".into(), + "paused" if a.paused_reason.as_deref() == Some("manual_stop") => { + "Paused until you start it".into() + } + "paused" => "Always-on paused".into(), + "starting" => "Always-on starting…".into(), + "needs_attention" => format!( + "Always-on stopped trying after {} failures (limit: {}). Failed services: {}", + a.failures + .count + .map_or_else(|| "unknown".into(), |n| n.to_string()), + a.max_failures + .map_or_else(|| "unknown".into(), |n| n.to_string()), + a.failures + .failed_services + .as_ref() + .map_or_else(|| "unknown".into(), |s| s.join(", ")) + ), + _ => "Always-on state unknown: the CLI does not report it yet".into(), + }); + } else { + lines.push("Always-on state unknown: the CLI does not report it yet".into()); + } + if let Some(g) = global { + if g.paused.is_none() { + lines.push("Global pause unknown: the CLI does not report it yet".into()); + } + if g.paused == Some(true) { + lines.push("Always-on paused for all projects".into()); + } + if let Some(login) = &g.login_run + && login.in_progress == Some(true) + { + lines.push(match (&login.started, &login.pending, &login.failed) { + (Some(s), Some(p), Some(f)) => format!( + "At login: started {}, pending {}, failed {}", + s.len(), + p.len(), + f.len() + ), + _ => "At login: starting projects (progress unknown)".into(), + }); + } + } + if daemon + .status + .as_ref() + .and_then(|s| s.service.as_ref()) + .and_then(|s| s.installed) + == Some(false) + { + lines.push("Needs hack daemon install".into()); + } else if global.and_then(|g| g.service_ready) != Some(true) { + lines.push("Login service readiness unknown or unavailable".into()); + } + lines.push("Read-only: the app cannot change always-on yet; no always-on commands run".into()); + lines.join(" · ") +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn failed_actions_keep_stdout_diagnostics() { + use std::os::unix::fs::PermissionsExt; + let dir = std::env::temp_dir().join(format!( + "daemon-act-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + std::fs::create_dir(&dir).unwrap(); + let program = dir.join("hack"); + std::fs::write( + &program, + "#!/bin/sh\nprintf 'Could not start hackd: example reason\\n'\nexit 1\n", + ) + .unwrap(); + std::fs::set_permissions(&program, std::fs::Permissions::from_mode(0o700)).unwrap(); + let error = act(&CliRunner::new(&program), DaemonAction::Start).unwrap_err(); + assert!( + error + .to_string() + .contains("Could not start hackd: example reason"), + "{error}" + ); + let _ = std::fs::remove_dir_all(dir); + } + use serde_json::json; + #[test] + fn setup_remedies_share_machine_admission_and_require_cli_evidence() { + use crate::setup::{Card, Remedy, Report, SafeAction, Status}; + let mut report = Report { + cards: vec![ + Card { + id: "cli".into(), + title: "CLI".into(), + status: Status::Passed, + detail: String::new(), + remedy: Remedy::Doc(String::new()), + }, + Card { + id: "login".into(), + title: "Login service".into(), + status: Status::NeedsAttention, + detail: String::new(), + remedy: Remedy::Run(SafeAction::InstallDaemon), + }, + ], + ..Default::default() + }; + let mut model = DaemonModel::default(); + assert!(!model.begin_machine(DaemonAction::Restart, None, Some(&report))); + assert!(model.begin_machine(DaemonAction::Install, None, Some(&report))); + assert!(!model.begin_with_recovery( + DaemonAction::Start, + Some(crate::source::DaemonRecovery::Start) + )); + model.refreshed(Observation::default()); + report.cards[0].status = Status::Unknown; + assert!(!model.begin_machine(DaemonAction::Install, None, Some(&report))); + } + + #[test] + fn reachable_api_without_tracked_pid_keeps_service_evidence() { + let status = DaemonStatus::from_value(json!({"status":"running", "running":false, "api_ok":true, "service":{"installed":true}})).unwrap(); + let o = Observation { + cli_version: Some("5.0.0".into()), + socket_version: Some("5.0.0".into()), + status: Some(status), + ..Default::default() + }; + assert_eq!(o.label(), "hackd running"); + assert_eq!( + o.status + .as_ref() + .unwrap() + .service + .as_ref() + .unwrap() + .installed, + Some(true) + ); + assert!(o.actions().is_empty()); + } + + #[test] + fn recovery_and_machine_card_share_one_gate() { + use crate::source::DaemonRecovery; + let mut model = DaemonModel::default(); + assert!(model.begin_with_recovery(DaemonAction::Start, Some(DaemonRecovery::Start))); + assert!(!model.begin_with_recovery(DaemonAction::Restart, Some(DaemonRecovery::Restart))); + assert!(!model.begin(DaemonAction::Install)); + model.finished(&Ok(())); + assert!(!model.begin_with_recovery(DaemonAction::Start, Some(DaemonRecovery::Start))); + model.refreshed(Observation::default()); + model.observed.cli_missing = true; + assert!(!model.begin_with_recovery(DaemonAction::Start, Some(DaemonRecovery::Start))); + } + + #[test] + fn every_daemon_fixture_and_missing_always_on_metadata() { + use crate::fixtures::{Fixture, daemon_observation}; + for f in Fixture::ALL { + let o = daemon_observation(f); + if f == Fixture::CliMissing { + assert!(o.actions().is_empty()); + } + } + let stale = daemon_observation(Fixture::DaemonStaleUnit); + assert!(stale.label().contains("out of date")); + assert!(stale.actions().contains(&DaemonAction::Install)); + let mismatch = daemon_observation(Fixture::DaemonMismatch); + assert!(mismatch.actions().contains(&DaemonAction::Restart)); + assert!(mismatch.label().contains("4.3.0")); + assert!( + daemon_observation(Fixture::DaemonStarting) + .actions() + .is_empty() + ); + let unknown = always_on(None, None, &Observation::default()); + assert!(unknown.contains("unknown")); + assert!(unknown.contains("Read-only")); + // Visible text never names internal design request ids. + assert!(!unknown.contains("R12"), "{unknown}"); + let partial: Autostart = + serde_json::from_value(json!({"state":"needs_attention"})).unwrap(); + let text = always_on(Some(&partial), None, &Observation::default()); + assert!(text.contains("unknown failures")); + assert!(text.contains("services: unknown")); + for (f, expected) in [ + (Fixture::AlwaysOff, "off"), + (Fixture::AlwaysOn, "on"), + (Fixture::AlwaysPaused, "Paused until you start it"), + (Fixture::AlwaysAttention, "3 failures (limit: 3)"), + (Fixture::AlwaysGlobalPaused, "paused for all"), + (Fixture::AlwaysLogin, "started 2, pending 2"), + (Fixture::AlwaysNoLogin, "Needs hack daemon install"), + ] { + let p = crate::fixtures::project(f); + let text = always_on( + p.projects[0].autostart.as_ref(), + p.autostart_global.as_ref(), + &daemon_observation(f), + ); + assert!(text.contains(expected), "{f:?}: {text}"); + } + } + + #[test] + fn fake_cli_retains_exit_one_report_and_reads_after_command_exit() { + use std::os::unix::fs::PermissionsExt; + let dir = std::env::temp_dir().join(format!( + "desktop-daemon-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + std::fs::create_dir(&dir).unwrap(); + let program = dir.join("hack"); + std::fs::write( + &program, + r#"#!/bin/sh +[ "$HACK_DAEMON_AUTOSTART_DISABLE" = 1 ] || exit 9 +[ "$HOME" = "$HACK_HOME" ] || exit 9 +case "$1 $2" in + 'version --json') echo '{"ok":true,"data":{"schema":"hack.version.v1","version":"5.0.0"}}' ;; + 'daemon status') + if [ -f "$HACK_HOME/started" ]; then + echo '{"status":"running","running":true,"service":{"installed":true}}' + else + printf '{"status":"stopped","running":false,"service":{},"socket_path":"%s"}\n' "$HACK_TEST_SOCKET"; exit 1 + fi ;; + 'daemon start') [ "$3" = --no-interactive ] || exit 8; : > "$HACK_HOME/started" ;; + *) exit 7 ;; +esac +"#, + ) + .unwrap(); + std::fs::set_permissions(&program, std::fs::Permissions::from_mode(0o700)).unwrap(); + let runner = CliRunner::new(program) + .with_env("HOME", &dir.to_string_lossy()) + .with_env("HACK_HOME", &dir.to_string_lossy()) + .with_env("PATH", &dir.to_string_lossy()) + .with_env("HACK_DAEMON_AUTOSTART_DISABLE", "1"); + // Inject a private socket; never consult the user's daemon. + let socket_dir = std::path::PathBuf::from(format!( + "/tmp/dda-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .subsec_nanos() + )); + std::fs::create_dir(&socket_dir).unwrap(); + std::fs::set_permissions(&socket_dir, std::fs::Permissions::from_mode(0o700)).unwrap(); + let sock = socket_dir.join("status.sock"); + let listener = std::os::unix::net::UnixListener::bind(&sock).unwrap(); + let server = std::thread::spawn(move || { + use std::io::{Read, Write}; + let (mut stream, _) = listener.accept().unwrap(); + stream + .set_read_timeout(Some(Duration::from_secs(2))) + .unwrap(); + let mut buf = [0; 1024]; + let n = stream.read(&mut buf).unwrap(); + assert!( + std::str::from_utf8(&buf[..n]) + .unwrap() + .starts_with("GET /v1/status ") + ); + let body = r#"{"status":"ok","version":"4.3.0"}"#; + write!( + stream, + "HTTP/1.1 200 OK\r\nContent-Length: {}\r\n\r\n{body}", + body.len() + ) + .unwrap(); + }); + // No injected client: the socket comes from the CLI's socket_path. + let mismatched = observe( + &runner + .clone() + .with_env("HACK_TEST_SOCKET", &sock.to_string_lossy()), + None, + socket_from_status, + ); + assert_eq!(mismatched.socket_version.as_deref(), Some("4.3.0")); + assert!(mismatched.actions().contains(&DaemonAction::Restart)); + server.join().unwrap(); + std::fs::remove_dir_all(socket_dir).unwrap(); + let initial = observe(&runner, None, socket_from_status); + assert!( + initial.socket_version.is_none(), + "relative socket_path is ignored" + ); + assert_eq!(initial.status.as_ref().unwrap().running, Some(false)); + assert_eq!( + initial + .status + .as_ref() + .unwrap() + .service + .as_ref() + .unwrap() + .installed, + None + ); + let mut m = DaemonModel { + observed: initial, + ..Default::default() + }; + assert!(m.begin(DaemonAction::Start)); + let result = act(&runner, DaemonAction::Start); + assert_eq!(result, Ok(())); + m.finished(&result); + assert_eq!(m.observed.status.as_ref().unwrap().running, Some(false)); + m.refreshed(observe(&runner, None, socket_from_status)); + assert_eq!(m.observed.status.as_ref().unwrap().running, Some(true)); + assert!(m.busy.is_none()); + std::fs::remove_dir_all(dir).unwrap(); + } + + #[test] + fn missing_is_not_false_and_stale_unit_requires_evidence() { + assert!(DaemonStatus::from_value(json!({})).is_err()); + assert!(DaemonStatus::from_value(json!({"status":"stopped", "running":true})).is_err()); + let s = DaemonStatus::from_value(json!({"status":"stopped", "running":false,"service":{}})) + .unwrap(); + assert_eq!(s.service.as_ref().unwrap().installed, None); + let o = Observation { + cli_version: Some("5".into()), + status: Some(s), + ..Default::default() + }; + assert!(!o.actions().contains(&DaemonAction::Install)); + assert!(o.label().contains("unknown")); + assert!( + DaemonStatus::from_value( + json!({"status":"stopped","running":false,"service":{"installed":"false"}}) + ) + .is_err() + ); + let unfamiliar = Observation { + status: Some( + DaemonStatus::from_value(json!({"status":"future","running":false})).unwrap(), + ), + ..o + }; + assert!(unfamiliar.actions().is_empty()); + } + #[test] + fn single_action_failure_and_timeout() { + let mut m = DaemonModel { + observed: Observation { + cli_version: Some("5".into()), + status: Some( + DaemonStatus::from_value(json!({"status":"stopped","running":false})).unwrap(), + ), + ..Default::default() + }, + ..Default::default() + }; + assert!(m.begin(DaemonAction::Start)); + assert!(!m.begin(DaemonAction::Start)); + m.finished(&Err(RunError::Exit { + code: Some(1), + stderr: "failed".into(), + })); + assert!(m.observed.label().contains("stopped")); + assert!(m.action_error.is_some()); + m.refreshed(Observation { + error: Some("read failed".into()), + ..Default::default() + }); + assert!(m.observed.status.is_some()); + assert!(m.begin(DaemonAction::Start)); + m.finished(&Err(RunError::Timeout { + after: Duration::from_secs(1), + })); + assert!(m.observed.status.is_none()); + assert!(m.busy.is_some()); + m.refreshed(Observation { + error: Some("read failed".into()), + ..Default::default() + }); + assert!(m.observed.status.is_none()); + } +} diff --git a/apps/desktop/core/src/fixtures.rs b/apps/desktop/core/src/fixtures.rs index f4262fc27..89d726c5e 100644 --- a/apps/desktop/core/src/fixtures.rs +++ b/apps/desktop/core/src/fixtures.rs @@ -15,6 +15,21 @@ use crate::state::{Action, LocalContext}; /// Every designed state that can be shown with `--fixtures `. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Fixture { + DaemonNotInstalled, + DaemonStopped, + DaemonStarting, + DaemonIncompatible, + DaemonStale, + DaemonStaleUnit, + DaemonUnknown, + AlwaysOff, + AlwaysOn, + AlwaysPaused, + AlwaysAttention, + AlwaysGlobalPaused, + AlwaysLogin, + AlwaysNoLogin, + AlwaysCommandFailed, EnvLoading, EnvError, EnvNormal, @@ -52,7 +67,22 @@ pub enum Fixture { } impl Fixture { - pub const ALL: [Self; 25] = [ + pub const ALL: [Self; 40] = [ + Self::DaemonNotInstalled, + Self::DaemonStopped, + Self::DaemonStarting, + Self::DaemonIncompatible, + Self::DaemonStale, + Self::DaemonStaleUnit, + Self::DaemonUnknown, + Self::AlwaysOff, + Self::AlwaysOn, + Self::AlwaysPaused, + Self::AlwaysAttention, + Self::AlwaysGlobalPaused, + Self::AlwaysLogin, + Self::AlwaysNoLogin, + Self::AlwaysCommandFailed, Self::EnvLoading, Self::EnvError, Self::EnvNormal, @@ -83,6 +113,21 @@ impl Fixture { #[must_use] pub fn name(self) -> &'static str { match self { + Self::DaemonNotInstalled => "daemon-not-installed", + Self::DaemonStopped => "daemon-stopped", + Self::DaemonStarting => "daemon-starting", + Self::DaemonIncompatible => "daemon-incompatible", + Self::DaemonStale => "daemon-stale", + Self::DaemonStaleUnit => "daemon-stale-unit", + Self::DaemonUnknown => "daemon-unknown", + Self::AlwaysOff => "always-off", + Self::AlwaysOn => "always-on", + Self::AlwaysPaused => "always-paused", + Self::AlwaysAttention => "always-attention", + Self::AlwaysGlobalPaused => "always-global-paused", + Self::AlwaysLogin => "always-login", + Self::AlwaysNoLogin => "always-no-login", + Self::AlwaysCommandFailed => "always-command-failed", Self::EnvLoading => "env-loading", Self::EnvError => "env-error", Self::EnvNormal => "env-normal", @@ -213,7 +258,7 @@ fn simple(name: &str, status: &str, backend: &str, always_on: bool) -> ProjectRo }, }), autostart: always_on.then(|| Autostart { - enabled: true, + enabled: Some(true), state: "on".into(), ..Autostart::default() }), @@ -250,13 +295,14 @@ pub fn project(f: Fixture) -> ProjectsPayload { Fixture::NeedsAttention => { let mut r = storefront(vec![], "stopped"); r.autostart = Some(Autostart { - enabled: true, + enabled: Some(true), state: "needs_attention".into(), + max_failures: Some(3), paused_reason: None, failures: AutostartFailures { - count: 3, + count: Some(3), last_at: Some("2026-01-01T09:00:00Z".into()), - failed_services: vec!["api".into()], + failed_services: Some(vec!["api".into()]), last_error_code: Some("E_STARTUP_INCOMPLETE".into()), }, }); @@ -269,6 +315,31 @@ pub fn project(f: Fixture) -> ProjectsPayload { } _ => storefront(all_up(), "running"), }; + let mut main = main; + if f.name().starts_with("always-") { + main.autostart = Some(Autostart { + enabled: Some(f != Fixture::AlwaysOff), + state: match f { + Fixture::AlwaysOff => "off", + Fixture::AlwaysPaused => "paused", + Fixture::AlwaysAttention => "needs_attention", + Fixture::AlwaysLogin => "starting", + _ => "on", + } + .into(), + paused_reason: (f == Fixture::AlwaysPaused).then(|| "manual_stop".into()), + max_failures: Some(3), + failures: AutostartFailures { + count: Some(if f == Fixture::AlwaysAttention { 3 } else { 0 }), + failed_services: Some(if f == Fixture::AlwaysAttention { + vec!["api".into(), "worker".into()] + } else { + vec![] + }), + ..Default::default() + }, + }); + } let mut projects = vec![main]; projects.extend(others()); if f == Fixture::NeedsAttention { @@ -297,6 +368,20 @@ pub fn project(f: Fixture) -> ProjectsPayload { runtime_error: (f == Fixture::EngineDown) .then(|| "Cannot connect to the Docker daemon".into()), projects, + autostart_global: f + .name() + .starts_with("always-") + .then(|| crate::models::AutostartGlobal { + paused: Some(f == Fixture::AlwaysGlobalPaused), + service_ready: Some(f != Fixture::AlwaysNoLogin), + login_run: Some(crate::models::LoginRun { + in_progress: Some(f == Fixture::AlwaysLogin), + pending: Some(vec!["storefront".into(), "notes".into()]), + started: Some(vec!["docs-site".into(), "billing-api".into()]), + failed: Some(vec![]), + ..Default::default() + }), + }), } } @@ -454,6 +539,60 @@ pub fn log_tail(f: Fixture) -> Vec { } } +/// Pure fixture observation: no process or socket access. +#[must_use] +pub fn daemon_observation(f: Fixture) -> crate::daemon_state::Observation { + use crate::daemon_state::Observation; + use serde_json::json; + if f == Fixture::CliMissing { + return Observation { + cli_missing: true, + ..Default::default() + }; + } + if f == Fixture::DaemonUnknown { + return Observation { + cli_version: Some(CLI_VERSION.into()), + error: Some("Could not read daemon status".into()), + ..Default::default() + }; + } + let status = match f { + Fixture::DaemonDown | Fixture::DaemonNotInstalled | Fixture::AlwaysNoLogin => { + json!({"status":"stopped","running":false,"service":{"installed":false}}) + } + Fixture::DaemonStopped => { + json!({"status":"stopped","running":false,"service":{"installed":true}}) + } + Fixture::DaemonStarting => { + json!({"status":"starting","running":false,"issue":"Waiting for status socket"}) + } + Fixture::DaemonIncompatible => { + json!({"status":"incompatible","running":true,"issue":"API incompatible; restart with current CLI"}) + } + Fixture::DaemonStale => { + json!({"status":"stale","running":false,"stale":true,"stale_reason":"pid_not_running"}) + } + Fixture::DaemonStaleUnit => { + json!({"status":"running","running":true,"service":{"installed":true,"unit_stale":true,"unit_stale_reason":"binary_missing","next_step":"hack daemon install"}}) + } + _ => json!({"status":"running","running":true,"service":{"installed":true}}), + }; + Observation { + cli_version: Some(CLI_VERSION.into()), + status: Some(crate::models::DaemonStatus::from_value(status).expect("fixture status")), + socket_version: Some( + if f == Fixture::DaemonMismatch { + "4.3.0" + } else { + CLI_VERSION + } + .into(), + ), + ..Default::default() + } +} + /// Fixtures that open the Doctor pane instead of a project. #[must_use] pub fn opens_doctor(f: Fixture) -> bool { diff --git a/apps/desktop/core/src/lib.rs b/apps/desktop/core/src/lib.rs index 52d4911a9..73c19c039 100644 --- a/apps/desktop/core/src/lib.rs +++ b/apps/desktop/core/src/lib.rs @@ -28,8 +28,8 @@ pub mod state; pub mod terminal; pub mod viewmodel; +pub mod daemon_state; pub mod env; - pub mod execution; mod setup_login; diff --git a/apps/desktop/core/src/models.rs b/apps/desktop/core/src/models.rs index d8aafb9d2..492325a16 100644 --- a/apps/desktop/core/src/models.rs +++ b/apps/desktop/core/src/models.rs @@ -30,6 +30,7 @@ pub struct ProjectsPayload { pub runtime_error: Option, #[serde(deserialize_with = "null_default")] pub projects: Vec, + pub autostart_global: Option, } impl ProjectsPayload { @@ -198,7 +199,8 @@ impl Container { #[derive(Debug, Clone, Default, PartialEq, Deserialize, Serialize)] #[serde(default)] pub struct Autostart { - pub enabled: bool, + pub enabled: Option, + pub max_failures: Option, /// `off | on | paused | starting | needs_attention`. #[serde(deserialize_with = "null_default")] pub state: String, @@ -209,9 +211,9 @@ pub struct Autostart { #[derive(Debug, Clone, Default, PartialEq, Deserialize, Serialize)] #[serde(default)] pub struct AutostartFailures { - pub count: u32, + pub count: Option, pub last_at: Option, - pub failed_services: Vec, + pub failed_services: Option>, pub last_error_code: Option, } @@ -234,10 +236,10 @@ pub struct DaemonStatus { /// `running | starting | incompatible | stale | stopped`. #[serde(deserialize_with = "null_default")] pub status: String, - pub running: bool, - pub api_ok: bool, + pub running: Option, + pub api_ok: Option, pub api_compatible: Option, - pub stale: bool, + pub stale: Option, pub stale_reason: Option, pub issue: Option, pub next_step: Option, @@ -250,10 +252,12 @@ pub struct DaemonStatus { pub struct DaemonService { /// `launchd | systemd`. pub manager: Option, - pub available: bool, - pub installed: bool, - pub enabled: bool, - pub active: bool, + pub available: Option, + pub installed: Option, + pub enabled: Option, + pub active: Option, + pub unit_stale: Option, + pub unit_stale_reason: Option, pub summary: Option, pub next_step: Option, } @@ -367,6 +371,44 @@ impl CliError { } } +/// Proposed R12b metadata. Missing fields remain unknown. +#[derive(Debug, Clone, Default, PartialEq, Deserialize, Serialize)] +#[serde(default)] +pub struct AutostartGlobal { + pub paused: Option, + pub service_ready: Option, + pub login_run: Option, +} + +#[derive(Debug, Clone, Default, PartialEq, Deserialize, Serialize)] +#[serde(default)] +pub struct LoginRun { + pub in_progress: Option, + pub started_at: Option, + pub pending: Option>, + pub started: Option>, + pub failed: Option>, +} + +impl DaemonStatus { + /// Require an explicit observation, not an empty object or an envelope. + /// Missing service installation metadata stays unknown. + /// # Errors + /// A malformed or contradictory observation. + pub fn from_value(value: serde_json::Value) -> Result { + let status: Self = serde_json::from_value(value).map_err(|e| e.to_string())?; + if status.status.is_empty() || status.running.is_none() { + return Err("daemon status requires status and running".into()); + } + // `running` is tracked-PID liveness; `status: running` is API + // reachability. The API can run with a missing/stale PID file. + if matches!(status.status.as_str(), "stopped" | "stale") && status.running != Some(false) { + return Err("contradictory daemon observation".into()); + } + Ok(status) + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/apps/desktop/core/src/setup_io.rs b/apps/desktop/core/src/setup_io.rs index 658970735..cabb12faf 100644 --- a/apps/desktop/core/src/setup_io.rs +++ b/apps/desktop/core/src/setup_io.rs @@ -1,5 +1,6 @@ //! Setup I/O, separate from the pure evidence model. No observer starts a service. use crate::{ + runner::RunError, setup::{self, Report, SafeAction}, source::{DoctorTarget, LiveSource}, }; @@ -159,30 +160,7 @@ pub fn run_safe(action: SafeAction, checked: &Report) -> String { { return Err("Action is not supported by the checked setup report".into()); } - let context = checked - .context - .as_ref() - .ok_or("Execution context has not been checked")?; - if action == SafeAction::InstallDaemon - && let Some(why) = context.install_refusal() - { - return Err(why.into()); - } - let program = checked - .cli_program - .as_deref() - .ok_or("CLI has not been checked")?; - // The CLI starts or installs the daemon by invoking itself; without - // this selector it would prefer whatever `hack` is first on PATH. - let runner = context - .runner(program)? - .with_env("HACK_MCP_COMMAND", program); - if action == SafeAction::InstallDaemon { - let status = crate::source::read_daemon_status(&runner, Duration::from_secs(5))?; - if let Some(why) = setup::installation_refusal(Some(&status), Some(context)) { - return Err(format!("{why}; no install was attempted")); - } - } + let runner = checked_runner(checked, action == SafeAction::InstallDaemon)?; let mut args = action.args().to_vec(); args.push("--no-interactive"); runner @@ -198,6 +176,55 @@ pub fn run_safe(action: SafeAction, checked: &Report) -> String { Err(e) => format!("No supported action completed: {e}. Check again."), } } +/// The checked report's pinned CLI under its allowlisted execution context. +/// For install, the context's guards and a fresh `daemon status` must still +/// allow installation at effect time. +fn checked_runner(checked: &Report, install: bool) -> Result { + let context = checked + .context + .as_ref() + .ok_or("Execution context has not been checked")?; + if install && let Some(why) = context.install_refusal() { + return Err(why.into()); + } + let program = checked + .cli_program + .as_deref() + .ok_or("CLI has not been checked")?; + // The CLI starts or installs the daemon by invoking itself; without this + // selector it would prefer whatever `hack` is first on PATH. + let runner = context + .runner(program)? + .with_env("HACK_MCP_COMMAND", program); + if install { + let status = crate::source::read_daemon_status(&runner, Duration::from_secs(5))?; + if let Some(why) = setup::installation_refusal(Some(&status), Some(context)) { + return Err(format!("{why}; no install was attempted")); + } + } + Ok(runner) +} + +/// Daemon card and Setup Run actions: the same checked CLI and context as +/// setup checks, never the app's own runner or inherited environment. +/// +/// # Errors +/// No checked context or CLI, a refused install, or the command's failure. +pub fn run_daemon_checked( + action: crate::daemon_state::DaemonAction, + checked: &Report, +) -> Result<(), RunError> { + if !checked.cli_ready() { + return Err(RunError::Spawn("Setup has not checked the CLI".into())); + } + let runner = checked_runner( + checked, + action == crate::daemon_state::DaemonAction::Install, + ) + .map_err(RunError::Spawn)?; + crate::daemon_state::act(&runner, action) +} + fn bounded(s: &str) -> String { s.chars().take(2048).collect() } diff --git a/apps/desktop/core/src/source.rs b/apps/desktop/core/src/source.rs index 35dd3adc5..0458ea559 100644 --- a/apps/desktop/core/src/source.rs +++ b/apps/desktop/core/src/source.rs @@ -154,8 +154,8 @@ pub trait Source: Send + Sync { /// Explicit user recovery only; never called by a poll. /// # Errors /// Failed or unavailable daemon command. - fn recover_daemon(&self, _action: DaemonRecovery) -> Result<(), String> { - Err("Daemon actions are unavailable for this source".into()) + fn recover_daemon(&self, _action: DaemonRecovery) -> Result<(), RunError> { + Err(RunError::NotFound) } /// # Errors /// A [`SourceError`] when no usable snapshot could be read. @@ -173,6 +173,27 @@ pub trait Source: Send + Sync { action: Action, ) -> Result; + /// Session error fixture; no local always-on policy is inferred. + fn always_on_error(&self) -> Option { + None + } + + /// Observe daemon state without starting anything. + fn daemon_observation(&self) -> crate::daemon_state::Observation { + crate::daemon_state::Observation::default() + } + /// Explicit non-admin action; no terminal adapter is involved. Runs only + /// through `checked`'s pinned CLI and allowlisted execution context. + /// # Errors + /// CLI missing, no checked context, refused install or command failure. + fn daemon_action( + &self, + _action: crate::daemon_state::DaemonAction, + _checked: &crate::setup::Report, + ) -> Result<(), RunError> { + Err(RunError::NotFound) + } + /// Last log lines for a project (no follow). /// /// # Errors @@ -855,16 +876,24 @@ impl LiveSource { &self, status: Option<&serde_json::Value>, ) -> Option { - self.daemon.clone().or_else(|| { - let path = Path::new(status?.get("socket_path")?.as_str()?); - if !path.is_absolute() { - return None; - } - Some(DaemonClient::new( - path.to_path_buf(), - Duration::from_secs(3), - )) - }) + self.daemon + .clone() + .or_else(|| crate::daemon_state::socket_from_status(status?)) + } + + /// Reads use the resolved CLI pinned by absolute path under the + /// allowlisted execution context, never the app's whole environment. + fn read_runner(&self) -> Result { + let program = self.runner.resolve_program()?; + let context = + crate::execution::ExecutionContext::capture(&self.runner).map_err(RunError::Spawn)?; + context + .runner( + program + .to_str() + .ok_or_else(|| RunError::Spawn("CLI path is not UTF-8".into()))?, + ) + .map_err(RunError::Spawn) } fn runner_for(&self, native: bool) -> CliRunner { @@ -955,51 +984,25 @@ impl Source for LiveSource { } } - fn recover_daemon(&self, action: DaemonRecovery) -> Result<(), String> { + fn recover_daemon(&self, action: DaemonRecovery) -> Result<(), RunError> { // The CLI starts the daemon by invoking itself and would otherwise // prefer whatever `hack` is first on PATH: pin the selected one. let program = self .runner - .resolve_program() - .map_err(|e| e.to_string())? + .resolve_program()? .into_os_string() .into_string() - .map_err(|_| "CLI path is not UTF-8".to_owned())?; - let (_, stderr, code) = self - .runner - .clone() - .with_env("HACK_MCP_COMMAND", &program) - .run_raw( - &["daemon", action.command()], - None, - Duration::from_secs(120), - ) - .map_err(|e| e.to_string())?; - if code == Some(0) { - Ok(()) - } else { - let exit = code.map_or_else( - || "was killed by a signal".into(), - |n| format!("exited with code {n}"), - ); - Err(format!( - "Could not {} hackd: command {exit}. {}", - action.command(), - stderr.trim() - )) - } + .map_err(|_| RunError::Spawn("CLI path is not UTF-8".into()))?; + crate::daemon_state::act( + &self.runner.clone().with_env("HACK_MCP_COMMAND", &program), + match action { + DaemonRecovery::Start => crate::daemon_state::DaemonAction::Start, + DaemonRecovery::Restart => crate::daemon_state::DaemonAction::Restart, + }, + ) } fn snapshot(&self) -> Result { - let program = self.runner.resolve_program()?; - let context = - crate::execution::ExecutionContext::capture(&self.runner).map_err(RunError::Spawn)?; - let runner = context - .runner( - program - .to_str() - .ok_or_else(|| RunError::Spawn("CLI path is not UTF-8".into()))?, - ) - .map_err(RunError::Spawn)?; + let runner = self.read_runner()?; let cli_version = runner.version(Duration::from_secs(5))?; let raw = if self.daemon.is_none() { read_daemon_status(&runner, Duration::from_secs(5)).ok() @@ -1044,6 +1047,32 @@ impl Source for LiveSource { }) } + fn daemon_observation(&self) -> crate::daemon_state::Observation { + // The same pinned CLI and allowlisted context as `snapshot`. Without + // an injected test client, the socket comes from the CLI's own + // `daemon status` answer. + let runner = match self.read_runner() { + Ok(runner) => runner, + Err(e) => { + return crate::daemon_state::Observation { + cli_missing: e == RunError::NotFound, + error: Some(e.to_string()), + ..Default::default() + }; + } + }; + crate::daemon_state::observe(&runner, self.daemon.as_ref(), |status| { + self.effective_daemon(Some(status)) + }) + } + fn daemon_action( + &self, + action: crate::daemon_state::DaemonAction, + checked: &crate::setup::Report, + ) -> Result<(), RunError> { + crate::setup_io::run_daemon_checked(action, checked) + } + fn act( &self, repo_root: &Path, @@ -1319,6 +1348,15 @@ impl Source for FixtureSource { fixtures::snapshot(self.0) } + fn always_on_error(&self) -> Option { + (self.0 == Fixture::AlwaysCommandFailed) + .then(|| "E_COMMAND_FAILED (fixture; observed switch retained)".into()) + } + + fn daemon_observation(&self) -> crate::daemon_state::Observation { + fixtures::daemon_observation(self.0) + } + fn doctor_runs(&self, _projects: &[DoctorTarget]) -> Vec { fixtures::doctor_runs(self.0) } diff --git a/apps/desktop/core/src/state.rs b/apps/desktop/core/src/state.rs index 1cabbae13..3428921d2 100644 --- a/apps/desktop/core/src/state.rs +++ b/apps/desktop/core/src/state.rs @@ -262,7 +262,9 @@ pub fn derive(row: &ProjectRow, local: &LocalContext) -> ProjectState { return ProjectState::NeedsAttention { reason: format!( "Always-on stopped trying after {} failures", - a.failures.count + a.failures + .count + .map_or_else(|| "unknown".into(), |n| n.to_string()) ), }; } diff --git a/apps/desktop/core/src/viewmodel.rs b/apps/desktop/core/src/viewmodel.rs index 8dc947ae4..43907aab2 100644 --- a/apps/desktop/core/src/viewmodel.rs +++ b/apps/desktop/core/src/viewmodel.rs @@ -137,6 +137,16 @@ pub struct AppModel { } impl AppModel { + #[must_use] + pub fn cli_missing(&self) -> bool { + matches!(self.last_error, Some(SourceError::CliMissing)) + } + + #[must_use] + pub fn always_on_global(&self) -> Option<&crate::models::AutostartGlobal> { + self.snapshot.as_ref()?.projects.autostart_global.as_ref() + } + #[must_use] pub fn new() -> Self { Self::default() @@ -394,7 +404,10 @@ impl AppModel { p, self.local.get(&p.name).unwrap_or(&LocalContext::default()), ), - always_on: p.autostart.as_ref().is_some_and(|a| a.enabled), + always_on: p + .autostart + .as_ref() + .is_some_and(|a| a.enabled == Some(true)), selected: Some(&p.name) == self.selected.as_ref(), name: p.name.clone(), }; @@ -455,7 +468,10 @@ impl AppModel { row.backend.clone().unwrap_or_else(|| "compose".into()) }, url: row.dev_host.as_ref().map(|h| format!("https://{h}")), - always_on: row.autostart.as_ref().is_some_and(|a| a.enabled), + always_on: row + .autostart + .as_ref() + .is_some_and(|a| a.enabled == Some(true)), actions: if row.is_native() || self.last_error.is_some() { vec![] } else { diff --git a/apps/desktop/core/tests/dump_state.rs b/apps/desktop/core/tests/dump_state.rs index 70202f948..3c1c2514e 100644 --- a/apps/desktop/core/tests/dump_state.rs +++ b/apps/desktop/core/tests/dump_state.rs @@ -197,18 +197,21 @@ fn daemon_recovery_runs_only_the_requested_command_and_checks_its_exit() { source.recover_daemon(DaemonRecovery::Start).unwrap(); assert_eq!( std::fs::read_to_string(home.join("called")).unwrap(), - "daemon start" + "daemon start --no-interactive" ); // The daemon the CLI starts runs the selected CLI, not PATH's `hack`. assert_eq!( std::fs::read_to_string(home.join("self")).unwrap(), hack.to_str().unwrap() ); - let error = source.recover_daemon(DaemonRecovery::Restart).unwrap_err(); + let error = source + .recover_daemon(DaemonRecovery::Restart) + .unwrap_err() + .to_string(); assert!(error.contains("daemon could not restart")); assert_eq!( std::fs::read_to_string(home.join("called")).unwrap(), - "daemon restart" + "daemon restart --no-interactive" ); std::fs::remove_dir_all(home).unwrap(); } diff --git a/apps/desktop/core/tests/setup_io.rs b/apps/desktop/core/tests/setup_io.rs index 32c1925a2..619adb359 100644 --- a/apps/desktop/core/tests/setup_io.rs +++ b/apps/desktop/core/tests/setup_io.rs @@ -430,3 +430,48 @@ fn version_timeout_is_unknown_without_forcing_a_missing_cli_gate() { "version --json" ); } +#[test] +fn daemon_card_actions_use_only_the_checked_cli_and_context() { + use hack_desktop_core::{ + daemon_state::DaemonAction, setup::Report, setup_io::run_daemon_checked, + }; + let t = Tree::new(); + let program = t.0.join("fake-hack"); + std::fs::write(&program, r#"#!/bin/sh +[ "$HACK_DAEMON_AUTOSTART_DISABLE" = 1 ] || exit 99 +printf '%s unlisted=%s\n' "$*" "${UNLISTED-unset}" >> "$HOME/calls" +case "$*" in +'version --json') printf '{"ok":true,"data":{"schema":"hack.version.v1","version":"5.0.0"}}\n';; +'daemon status --json') printf '{"running":false,"service":{"installed":false}}';; +'doctor --json') printf '{"ok":true,"data":{"checks":[{"id":"linux dns route","status":"ok"}]}}'; exit 1;; +'daemon start --no-interactive') printf 'accepted';; +*) exit 98;; +esac +"#).unwrap(); + std::fs::set_permissions(&program, std::fs::Permissions::from_mode(0o700)).unwrap(); + warm_version(&program, &t.0); + let _ = std::fs::remove_file(t.0.join("calls")); + // Without a checked report nothing runs. + assert!(run_daemon_checked(DaemonAction::Start, &Report::default()).is_err()); + assert!(!t.0.join("calls").exists()); + let source = LiveSource { + runner: CliRunner::new(&program) + .with_env("UNLISTED", "app-only") + .with_env("HOME", t.0.to_str().unwrap()) + .with_env("HACK_HOME", t.0.join("hack").to_str().unwrap()) + .with_term_grace(Duration::from_millis(20)), + daemon: None, + }; + let checked = source.setup(); + assert!(checked.cli_ready()); + run_daemon_checked(DaemonAction::Start, &checked).unwrap(); + let calls = std::fs::read_to_string(t.0.join("calls")).unwrap(); + assert!( + calls.contains("daemon start --no-interactive unlisted=unset"), + "{calls}" + ); + assert!( + !calls.contains("app-only"), + "inherited variables never reach the CLI" + ); +} diff --git a/apps/desktop/screenshots/always-attention-dark.png b/apps/desktop/screenshots/always-attention-dark.png new file mode 100644 index 000000000..d417b6809 Binary files /dev/null and b/apps/desktop/screenshots/always-attention-dark.png differ diff --git a/apps/desktop/screenshots/daemon-stale-unit-light.png b/apps/desktop/screenshots/daemon-stale-unit-light.png new file mode 100644 index 000000000..60dd0ba38 Binary files /dev/null and b/apps/desktop/screenshots/daemon-stale-unit-light.png differ