diff --git a/src/cli/daemon.rs b/src/cli/daemon.rs index f6b0a74e..3c008be4 100644 --- a/src/cli/daemon.rs +++ b/src/cli/daemon.rs @@ -14,6 +14,13 @@ pub enum DaemonSubcommand { Status, Enable, Disable, + /// Print the current daemon session's stdout+stderr (bridge activity, + /// dashboard startup, etc). Truncated fresh on every start/restart. + Logs { + /// Keep printing new lines as the daemon writes them. + #[arg(short, long)] + follow: bool, + }, } impl DaemonArgs { @@ -25,6 +32,7 @@ impl DaemonArgs { DaemonSubcommand::Status => cmd_status(), DaemonSubcommand::Enable => cmd_enable(), DaemonSubcommand::Disable => cmd_disable(), + DaemonSubcommand::Logs { follow } => cmd_logs(follow), } } } @@ -70,6 +78,38 @@ fn cmd_status() { } } +fn cmd_logs(follow: bool) { + use std::io::{Read, Write}; + let path = crate::daemon::daemon_log_path(); + let mut file = match std::fs::File::open(&path) { + Ok(f) => f, + Err(e) => { + eprintln!("error: {}: {e}", path.display()); + std::process::exit(1); + } + }; + let mut buf = String::new(); + let _ = file.read_to_string(&mut buf); + print!("{buf}"); + let _ = std::io::stdout().flush(); + if !follow { + return; + } + // Simplest portable tail -f: re-read from the current position on the + // same handle, which reflects append-mode writes made by a different + // process to the same file. Not robust to the daemon restarting mid-tail + // (a fresh log truncates the same path) -- good enough for `-f` used + // interactively, same tradeoff `agentflare work`'s own log tailing makes. + loop { + std::thread::sleep(std::time::Duration::from_millis(500)); + let mut chunk = String::new(); + if file.read_to_string(&mut chunk).is_ok() && !chunk.is_empty() { + print!("{chunk}"); + let _ = std::io::stdout().flush(); + } + } +} + fn cmd_enable() { match crate::daemon_autostart::install() { Ok(()) => println!("autostart enabled"), diff --git a/src/daemon.rs b/src/daemon.rs index 81811df8..29c8976e 100644 --- a/src/daemon.rs +++ b/src/daemon.rs @@ -10,6 +10,16 @@ pub fn daemon_pid_path() -> PathBuf { .unwrap_or_else(|| std::env::temp_dir().join("agentflare-daemon.pid")) } +/// stdout+stderr of the current daemon session — truncated fresh on every +/// `start`/`restart` (see `spawn_detached`), same lifetime as the runtime +/// dir it lives in. Not a rotated history: `agentflare daemon logs` is for +/// seeing what THIS run of the daemon is doing, not an audit trail. +pub fn daemon_log_path() -> PathBuf { + dirs::runtime_dir() + .map(|d| d.join("agentflare").join("daemon.log")) + .unwrap_or_else(|| std::env::temp_dir().join("agentflare-daemon.log")) +} + pub fn daemon_start_lock_path() -> PathBuf { dirs::runtime_dir() .map(|d| d.join("agentflare").join("daemon.start.lock")) @@ -122,12 +132,17 @@ pub fn start_daemon() -> Result { } let binary = std::env::current_exe().map_err(|e| format!("current_exe: {e}"))?; + let log_path = daemon_log_path(); + if let Some(parent) = log_path.parent() { + std::fs::create_dir_all(parent).map_err(|e| format!("create log dir {parent:?}: {e}"))?; + } // Must match the `ExecStart`/`ProgramArguments` invocation the installed // systemd/launchd units use (see `daemon_autostart.rs`) — both spawn // `serve --_foreground-daemon`, not just the bare flag. let _pid = process::spawn_detached( &binary.to_string_lossy(), &["serve", "--_foreground-daemon"], + Some(&log_path), )?; for _ in 0..20 { diff --git a/src/ipc/process.rs b/src/ipc/process.rs index 080da8a2..ad464c59 100644 --- a/src/ipc/process.rs +++ b/src/ipc/process.rs @@ -27,11 +27,35 @@ pub fn is_alive(pid: u32) -> bool { } } -pub fn spawn_detached(binary: &str, args: &[&str]) -> Result { +/// Spawns `binary` detached from the calling process. `log_path`, if given, +/// is truncated and used for both the child's stdout and stderr — `None` +/// discards them, same as before this parameter existed. +pub fn spawn_detached( + binary: &str, + args: &[&str], + log_path: Option<&std::path::Path>, +) -> Result { + // One shared `File` cloned for both streams: two independent + // `File::create` handles to the same path would each get their own + // write cursor at 0 and clobber each other instead of interleaving, + // the same reason a shell needs `2>&1` rather than two redirects. + let (out, err) = match log_path { + Some(p) => { + let f = std::fs::File::create(p) + .map_err(|e| format!("open log file {}: {e}", p.display()))?; + let f2 = f + .try_clone() + .map_err(|e| format!("open log file {}: {e}", p.display()))?; + (std::process::Stdio::from(f), std::process::Stdio::from(f2)) + } + None => (std::process::Stdio::null(), std::process::Stdio::null()), + }; #[cfg(windows)] { let mut cmd = std::process::Command::new(binary); cmd.args(args); + cmd.stdout(out); + cmd.stderr(err); cmd.creation_flags( windows_sys::Win32::System::Threading::CREATE_NEW_PROCESS_GROUP | windows_sys::Win32::System::Threading::DETACHED_PROCESS, @@ -44,8 +68,8 @@ pub fn spawn_detached(binary: &str, args: &[&str]) -> Result { let child = std::process::Command::new(binary) .args(args) .stdin(std::process::Stdio::null()) - .stdout(std::process::Stdio::null()) - .stderr(std::process::Stdio::null()) + .stdout(out) + .stderr(err) .spawn() .map_err(|e| format!("spawn: {e}"))?; Ok(child.id())