diff --git a/src-tauri/src/agent.rs b/src-tauri/src/agent.rs index f359322..fc91b22 100644 --- a/src-tauri/src/agent.rs +++ b/src-tauri/src/agent.rs @@ -4,6 +4,12 @@ //! the web UI run here, in this computer's projects. It is the CLI's own service loop, unchanged; //! the app only starts it, restarts it when it fails, and stops it (SIGTERM first, so a turn in //! progress ends cleanly) when the agent is switched off or the app quits. +//! +//! What it shows is the CLI's own word: a process that runs is not an agent that is linked. While +//! it runs, the state is what the service writes to its `service.json` (connecting, linked, +//! waiting and why, stopped and why); before that, the line it printed; when it ends, its last +//! line. A service the CLI says cannot go on (a refused token, no login) is not restarted every +//! second: it waits until linking again starts it. use std::fs::{File, OpenOptions}; use std::path::PathBuf; @@ -20,6 +26,8 @@ use crate::cli; const RESTART_FOR_UPDATE: i32 = 75; const BACKOFF: Duration = Duration::from_secs(10); const STOP_GRACE: Duration = Duration::from_secs(15); +/// How long to wait before trying again after the CLI said trying again will not help. +const REFUSED_WAIT: Duration = Duration::from_secs(300); #[derive(Debug, Clone, PartialEq, Serialize)] #[serde(tag = "state", content = "detail", rename_all = "snake_case")] @@ -28,25 +36,83 @@ pub enum State { Off, /// This computer is not signed in to the server yet. NotLinked, + /// The process is started; the CLI has not said anything of its link yet. Starting, + /// The CLI is dialling the server. + Connecting, + /// The CLI says it is linked: agent chats can use this computer. Running, - /// Another `lembas service` on this computer holds the link (its pid). - Elsewhere(u32), - /// It ended with an error; trying again shortly. + /// Not linked right now, and trying again: the CLI's own reason (a terminal holds the link, + /// the server cannot be reached, the link dropped). + Waiting(String), + /// The CLI will not try again by itself: the server refused this computer's token, or the + /// login is missing. Linking again is the way out. + Refused(String), + /// Another `lembas service` on this computer holds the link. + Elsewhere(Elsewhere), + /// It ended with an error; trying again shortly. The CLI's last words, where it said any. Failed(String), } +/// A `lembas service` this app did not start, and what it says of its link. +#[derive(Debug, Clone, PartialEq, Serialize)] +pub struct Elsewhere { + pub pid: u32, + /// It says it is linked. + pub linked: bool, + /// Why not, when it is not (its own words; empty while it has said nothing). + pub why: String, +} + impl State { pub fn label(&self) -> String { match self { State::Off => "Agent: off".into(), State::NotLinked => "Agent: this computer is not linked".into(), State::Starting => "Agent: starting…".into(), + State::Connecting => "Agent: connecting…".into(), State::Running => "Agent: running".into(), - State::Elsewhere(pid) => format!("Agent: running as a service (pid {pid})"), + State::Waiting(why) => format!("Agent: waiting — {why}"), + State::Refused(why) => format!("Agent: refused — {why}"), + State::Elsewhere(e) if e.linked => { + format!("Agent: running as a service (pid {})", e.pid) + } + State::Elsewhere(e) => format!("Agent: a service (pid {}) is not linked", e.pid), State::Failed(why) => format!("Agent: stopped — {why}"), } } + + /// Whether this is a state the CLI's own status file decides, while the process runs. + fn from_cli(&self) -> bool { + matches!( + self, + State::Starting | State::Connecting | State::Running | State::Waiting(_) + ) + } +} + +/// What the CLI's status says, as a state of the app's. `before`: the state shown now, so that a +/// new dial after a failed one keeps the reason it is waiting (the CLI writes `connecting` at +/// every try, `waiting` between them) instead of flickering between the two. +pub fn from_link(link: &cli::Link, before: &State) -> State { + let why = link.detail.clone().unwrap_or_default(); + match link.state.as_str() { + "linked" => State::Running, + "connecting" => match before { + State::Waiting(w) if link.attempts > 1 => State::Waiting(w.clone()), + _ => State::Connecting, + }, + "waiting" => State::Waiting(why), + "stopped" => State::Refused(why), + _ => State::Starting, + } +} + +/// What a running process has said before its status file says anything: the one line the +/// CLI's service writes while a terminal on this computer holds the link for `/remote`. +fn from_output(line: &str) -> Option { + line.contains("holds the link") + .then(|| State::Waiting(line.trim().to_string())) } type Notify = Arc; @@ -124,15 +190,33 @@ impl Agent { continue; } if let Some(pid) = cli::running_service().filter(|p| Some(*p) != self.child_pid()) { - if !self.set(generation, State::Elsewhere(pid)) { + // Not ours, but this computer's agent all the same: say what it says. + let link = cli::link_of(pid); + let linked = link.as_ref().is_some_and(|l| l.state == "linked"); + let why = link + .map(|l| match l.detail { + Some(d) if !d.is_empty() => d, + _ => l.state, + }) + .unwrap_or_default(); + if !self.set( + generation, + State::Elsewhere(Elsewhere { + pid, + linked, + why: if linked { String::new() } else { why }, + }), + ) { return; } - self.pause(generation, BACKOFF); + self.pause(generation, Duration::from_secs(3)); continue; } if !self.set(generation, State::Starting) { return; } + // Where this run's output starts in the log: its last line is its reason. + let from = std::fs::metadata(&self.log).map(|m| m.len()).unwrap_or(0); let child = match self.spawn() { Ok(c) => c, Err(e) => { @@ -146,6 +230,7 @@ impl Agent { continue; } }; + let pid = child.id(); { let mut g = self.inner.lock().unwrap(); if g.generation != generation { @@ -155,23 +240,39 @@ impl Agent { } g.child = Some(child); } - self.set(generation, State::Running); - let code = self.wait_child(generation); + let code = self.wait_child(generation, pid, from); let mut g = self.inner.lock().unwrap(); if g.generation != generation { return; } g.child = None; drop(g); + let said = last_line(&self.log, from); match code { Some(RESTART_FOR_UPDATE) => continue, Some(0) => { - // It ended by itself without an error: nothing to do but start it again. - self.pause(generation, Duration::from_secs(1)); + // It ended by itself without an error: the CLI's word for "starting again + // will not help" -- the server refused the token, or there is no login. + // Waiting a long while (linking again starts it at once). + let why = cli::link_of(pid) + .filter(|l| l.state == "stopped") + .and_then(|l| l.detail) + .or_else(|| said.map(|s| strip_prefix(&s))); + match why { + Some(why) => { + if !self.set(generation, State::Refused(why)) { + return; + } + self.pause(generation, REFUSED_WAIT); + } + None => self.pause(generation, Duration::from_secs(1)), + } } other => { - let why = other.map_or("ended by a signal".to_string(), |c| { - format!("exit status {c}") + let why = said.map(|s| strip_prefix(&s)).unwrap_or_else(|| { + other.map_or("ended by a signal".to_string(), |c| { + format!("exit status {c}") + }) }); if !self.set(generation, State::Failed(why)) { return; @@ -217,7 +318,11 @@ impl Agent { } /// Poll the child until it exits (its exit code), or until this supervisor is replaced. - fn wait_child(&self, generation: u64) -> Option { + /// Meanwhile the state shown is what the CLI says of its link: its status file once it has + /// written one, before that the line it printed (a terminal holding the link). + fn wait_child(&self, generation: u64, pid: u32, from: u64) -> Option { + // Every other poll (once a second) the status is read again. + let mut read = true; loop { { let mut g = self.inner.lock().unwrap(); @@ -230,6 +335,21 @@ impl Agent { Some(Err(_)) | None => return None, } } + if read { + let now = self.state(); + if now.from_cli() { + let next = match cli::link_of(pid) { + Some(link) => from_link(&link, &now), + None => last_line(&self.log, from) + .and_then(|l| from_output(&l)) + .unwrap_or(State::Starting), + }; + if !self.set(generation, next) { + return None; + } + } + } + read = !read; thread::sleep(Duration::from_millis(500)); } } @@ -283,3 +403,99 @@ fn terminate(mut child: Child) { let _ = child.kill(); let _ = child.wait(); } + +/// The last line the log holds after `from` (this run's output), if it wrote any. Only the end of +/// the file is read. +fn last_line(log: &std::path::Path, from: u64) -> Option { + use std::io::{Read, Seek, SeekFrom}; + let mut f = File::open(log).ok()?; + let len = f.metadata().ok()?.len(); + let start = from.max(len.saturating_sub(4096)); + if start >= len { + return None; + } + f.seek(SeekFrom::Start(start)).ok()?; + let mut buf = Vec::new(); + f.take(len - start).read_to_end(&mut buf).ok()?; + String::from_utf8_lossy(&buf) + .lines() + .rev() + .map(str::trim) + .find(|l| !l.is_empty()) + .map(String::from) +} + +/// `lembas service: not logged in` → `not logged in`: the CLI's error line without its name. +fn strip_prefix(line: &str) -> String { + line.strip_prefix("lembas service: ") + .unwrap_or(line) + .to_string() +} + +#[cfg(test)] +mod tests { + use super::*; + + fn link(state: &str, detail: Option<&str>, attempts: u32) -> cli::Link { + cli::Link { + state: state.into(), + detail: detail.map(String::from), + pid: 1, + attempts, + } + } + + #[test] + fn the_state_shown_is_the_clis_word_for_its_link() { + let s = State::Starting; + assert_eq!(from_link(&link("linked", None, 1), &s), State::Running); + assert_eq!( + from_link(&link("connecting", None, 1), &s), + State::Connecting + ); + let waiting = from_link( + &link("waiting", Some("could not reach the instance"), 1), + &s, + ); + assert_eq!( + waiting, + State::Waiting("could not reach the instance".into()) + ); + // The next dial keeps the reason it is waiting, rather than flicker. + assert_eq!(from_link(&link("connecting", None, 2), &waiting), waiting); + assert_eq!( + from_link(&link("stopped", Some("refused"), 2), &waiting), + State::Refused("refused".into()) + ); + // Not running is never shown as running. + for state in ["connecting", "waiting", "stopped", "?"] { + assert_ne!(from_link(&link(state, None, 1), &s), State::Running); + } + } + + #[test] + fn a_terminal_holding_the_link_is_said() { + assert_eq!( + from_output("a terminal here holds the link (/remote); waiting for it to close"), + Some(State::Waiting( + "a terminal here holds the link (/remote); waiting for it to close".into() + )) + ); + assert_eq!(from_output("linked"), None); + } + + #[test] + fn the_last_line_is_this_runs_own() { + let p = std::env::temp_dir().join(format!("llembas-agent-log-{}", std::process::id())); + std::fs::write(&p, "old run\nlinked\n").unwrap(); + let from = std::fs::metadata(&p).unwrap().len(); + assert_eq!(last_line(&p, from), None); + let mut f = OpenOptions::new().append(true).open(&p).unwrap(); + std::io::Write::write_all(&mut f, b"connecting\nlembas service: not logged in\n\n") + .unwrap(); + let said = last_line(&p, from).unwrap(); + assert_eq!(said, "lembas service: not logged in"); + assert_eq!(strip_prefix(&said), "not logged in"); + std::fs::remove_file(&p).unwrap(); + } +} diff --git a/src-tauri/src/cli.rs b/src-tauri/src/cli.rs index 95956ab..4979653 100644 --- a/src-tauri/src/cli.rs +++ b/src-tauri/src/cli.rs @@ -8,6 +8,8 @@ use std::path::{Path, PathBuf}; use std::sync::OnceLock; +use serde::Deserialize; + static BUNDLED: OnceLock = OnceLock::new(); /// Where the bundle put the CLI: the app's resource directory (`/usr/lib/llembas-desktop/` from @@ -108,6 +110,32 @@ fn running_in(state: &Path) -> Option { (pid != 0 && alive(pid)).then_some(pid) } +/// What a running `lembas service run` says of its link to the server, as it writes it to +/// `service.json` in its state directory at every change: `connecting`, `linked`, `waiting` +/// (not linked now, trying again; `detail` says why) or `stopped` (it will not try again; +/// `detail` says why -- the server refused the token, or speaks another protocol). `pid` is the +/// process that wrote it: a file left by an earlier run is not this one's word. +#[derive(Clone, Debug, PartialEq, Deserialize)] +pub struct Link { + pub state: String, + #[serde(default)] + pub detail: Option, + #[serde(default)] + pub pid: u32, + #[serde(default)] + pub attempts: u32, +} + +/// The link status the CLI's service wrote last, if it was `pid` that wrote it. +pub fn link_of(pid: u32) -> Option { + link_in(&state_dir()).filter(|l| l.pid == pid) +} + +fn link_in(state: &Path) -> Option { + let text = std::fs::read_to_string(state.join("service.json")).ok()?; + serde_json::from_str(&text).ok() +} + /// This computer's host name: what the CLI sent the server as `device_host` at login /// (`os.hostname()`, the kernel's node name), so the server can tell which device is this one. #[cfg(unix)] @@ -166,6 +194,33 @@ mod tests { fs::remove_dir_all(&d).unwrap(); } + #[test] + fn the_link_status_is_read_as_the_service_writes_it() { + let d = scratch("link"); + assert_eq!(link_in(&d), None); + // As `lembas service run` writes it (src/service.ts): LinkStatus and its pid. + fs::write( + d.join("service.json"), + r#"{"state":"waiting","instance":"https://ai.example","since":"2026-01-01T00:00:00.000Z","detail":"could not reach the instance; trying again in 4s","attempts":3,"pid":4242}"#, + ) + .unwrap(); + let l = link_in(&d).unwrap(); + assert_eq!(l.state, "waiting"); + assert_eq!(l.pid, 4242); + assert_eq!(l.attempts, 3); + assert!(l.detail.unwrap().starts_with("could not reach")); + // `linked` carries no detail. + fs::write( + d.join("service.json"), + r#"{"state":"linked","instance":"x","since":"y","attempts":1,"pid":7}"#, + ) + .unwrap(); + assert_eq!(link_in(&d).unwrap().detail, None); + fs::write(d.join("service.json"), "not json").unwrap(); + assert_eq!(link_in(&d), None); + fs::remove_dir_all(&d).unwrap(); + } + #[cfg(unix)] #[test] fn a_lock_counts_only_while_its_process_lives() { diff --git a/ui/js/settings.js b/ui/js/settings.js index aaae043..8095cbf 100644 --- a/ui/js/settings.js +++ b/ui/js/settings.js @@ -9,8 +9,10 @@ const AGENT = { off: () => [t("The agent is off."), ""], not_linked: () => [t("This computer is not linked to the server yet."), ""], starting: () => [t("The agent is starting…"), ""], + connecting: () => [t("The agent is connecting to the server…"), ""], running: () => [t("The agent is running: agent chats can use this computer."), "is-ok"], - elsewhere: (pid) => [t("A LLeMbas CLI service already runs here (pid %(pid)s); it is this computer's agent.", { pid }), "is-ok"], + waiting: (why) => [t("The agent is not connected: %(why)s. It keeps trying.", { why }), "is-error"], + refused: (why) => [t("The agent will not connect: %(why)s. Link this computer again.", { why }), "is-error"], failed: (why) => [t("The agent stopped: %(why)s. Trying again shortly.", { why }), "is-error"], } @@ -18,10 +20,18 @@ let folderError = "" let linking = null // { user_code, uri } while the computer is being linked let linkError = "" +/** The agent's state in a sentence, and its class: the CLI's word for its link. */ export function agentWords(s) { + const a = s.agent const noFolders = s.remote && !(s.remote.enabled && s.remote.roots.length) - if (s.agent.state === "running" && noFolders) return [t("The agent is connected, but may open no folder yet: add one below."), "is-error"] - return (AGENT[s.agent.state] ?? AGENT.off)(s.agent.detail) + if (a.state === "elsewhere") { + const pid = a.detail?.pid + if (!a.detail?.linked) return [t("A LLeMbas CLI service already runs here (pid %(pid)s), but is not connected: %(why)s", { pid, why: a.detail?.why || "…" }), "is-error"] + if (noFolders) return [t("The agent is connected, but may open no folder yet: add one below."), "is-error"] + return [t("A LLeMbas CLI service already runs here (pid %(pid)s); it is this computer's agent.", { pid }), "is-ok"] + } + if (a.state === "running" && noFolders) return [t("The agent is connected, but may open no folder yet: add one below."), "is-error"] + return (AGENT[a.state] ?? AGENT.off)(a.detail || "…") } function section(id, title, ...body) { diff --git a/ui/js/titlebar.js b/ui/js/titlebar.js index 21014c6..04e31af 100644 --- a/ui/js/titlebar.js +++ b/ui/js/titlebar.js @@ -9,12 +9,35 @@ const WORDS = { off: () => t("agent off"), not_linked: () => t("not linked"), starting: () => t("agent starting…"), + connecting: () => t("agent connecting…"), running: () => t("agent running"), + waiting: () => t("agent waiting"), + refused: () => t("agent refused"), elsewhere: () => t("agent running (service)"), + elsewhere_waiting: () => t("agent waiting"), failed: () => t("agent stopped"), no_folders: () => t("agent: no folders"), } +/** + * The pill's state: what the CLI says of its link, not whether a process runs. Linked but allowed + * into no folder is said too -- a green light would say it works when it cannot. + */ +export function pillState(s) { + const a = s.agent + const noFolders = s.remote && !(s.remote.enabled && s.remote.roots.length) + const linked = a.state === "running" || (a.state === "elsewhere" && a.detail?.linked) + if (linked && noFolders) return "no_folders" + if (a.state === "elsewhere" && !a.detail?.linked) return "elsewhere_waiting" + return a.state +} + +/** Why the agent is not working, in the CLI's words, when it said. */ +export function agentWhy(a) { + if (a.state === "elsewhere") return a.detail?.why || "" + return typeof a.detail === "string" ? a.detail : "" +} + export function renderBar(s) { let host = "" try { @@ -22,12 +45,11 @@ export function renderBar(s) { } catch {} $("host").textContent = host $("host").title = s.server || "" - // Connected but allowed into no folder: a green light would say it works when it cannot. - const noFolders = s.remote && !(s.remote.enabled && s.remote.roots.length) - const state = s.agent.state === "running" && noFolders ? "no_folders" : s.agent.state + const state = pillState(s) + const reason = agentWhy(s.agent) $("agent").dataset.state = state $("agent-text").textContent = WORDS[state] ? WORDS[state]() : state - $("agent").title = state === "failed" ? t("Agent stopped: %(why)s", { why: s.agent.detail }) : t("Agent") + $("agent").title = reason ? t("Agent: %(why)s", { why: reason }) : t("Agent") $("update").hidden = !s.update $("update").textContent = s.update ? t("update %(version)s", { version: s.update }) : t("update") $("update").title = s.update ? t("LLeMbas %(version)s is available (this is %(current)s)", { version: s.update, current: s.version }) : ""