diff --git a/src/app.rs b/src/app.rs index 9a701c5..da138c1 100644 --- a/src/app.rs +++ b/src/app.rs @@ -121,6 +121,23 @@ impl App { self.theme.set(theme); } + /// Directory of the event journal (the state directory). + pub fn events_dir(&self) -> PathBuf { + self.paths + .state_file + .parent() + .map(|p| p.to_path_buf()) + .unwrap_or_else(|| self.paths.state_file.clone()) + } + + /// Append an event to the journal; failures are never fatal (logged at + /// verbose level only). + pub fn emit(&self, event: &crate::events::Event) { + if let Err(e) = crate::events::append(&self.events_dir(), event) { + self.log.verbose(&format!("cannot write event journal: {e:#}")); + } + } + /// Ask the user for confirmation on stderr. Accepts y/yes/o/oui. pub fn confirm(&self, question: &str) -> Result { if self.cli.yes { diff --git a/src/commands/doctor_cmd.rs b/src/commands/doctor_cmd.rs index f50adc7..7afcd06 100644 --- a/src/commands/doctor_cmd.rs +++ b/src/commands/doctor_cmd.rs @@ -2,6 +2,7 @@ use super::*; use crate::doctor::{self, Status}; +use crate::events::{Event, EventKind}; use crate::output::{boxed_table, print_json, status_style}; use crate::runner::SystemRunner; use anyhow::Result; @@ -22,6 +23,12 @@ pub fn run(app: &App, fix: bool) -> Result { // The doctor report probes real tools, even under --dry-run. let runner = SystemRunner::new(false, false, &app.log); let report = doctor::run(app, &runner); + if !app.dry_run() { + app.emit( + &Event::now(EventKind::Doctor) + .with_exit_code(if report.ok() { 0 } else { 1 }), + ); + } if app.json() { print_json(&report.checks); return Ok(if report.ok() { 0 } else { 1 }); diff --git a/src/commands/install_cmd.rs b/src/commands/install_cmd.rs index 8ac9d23..10e52ee 100644 --- a/src/commands/install_cmd.rs +++ b/src/commands/install_cmd.rs @@ -2,6 +2,7 @@ //! installer, and record the installation in the state database. use super::*; +use crate::events::{Event, EventKind}; use crate::installers; use crate::runner::SystemRunner; use anyhow::{anyhow, bail, Result}; @@ -137,6 +138,11 @@ pub fn run(app: &App, agent_name: &str, method_sel: Option<&str>, force: bool) - let entry = installers::make_entry(agent, method, &outcome, app); let version = entry.version.clone().unwrap_or_else(|| "unknown".to_string()); app.state.set(&entry)?; + app.emit( + &Event::now(EventKind::Install) + .with_agent(agent.name.clone()) + .with_args(vec![format!("method={}", method.kind)]), + ); app.log.success(&format!( "installed {} (v{version}) in {}", agent.title(), diff --git a/src/commands/run_cmd.rs b/src/commands/run_cmd.rs index 82382dc..cc7ab0c 100644 --- a/src/commands/run_cmd.rs +++ b/src/commands/run_cmd.rs @@ -2,6 +2,7 @@ use super::*; use crate::cli::StartArgs; +use crate::events::{self, Event, EventKind}; use crate::process; use anyhow::{bail, Context, Result}; use std::collections::BTreeMap; @@ -51,6 +52,15 @@ fn start_one( agent.name )); } + app.emit( + &Event::now(EventKind::Start) + .with_agent(agent.name.clone()) + .with_pid(pid) + .with_session(crate::repl::session_id()) + .with_cwd(cwd_string()) + .with_args(exec.args.clone()) + .with_env_keys(events::env_keys(&exec.env)), + ); let log = process::agent_log_path(app, &agent.name); app.log.success(&format!( "{} started (pid {pid}); log: {}", @@ -70,6 +80,7 @@ fn start_one( )); return Ok(0); } + let started = std::time::Instant::now(); let (prog, prefix) = crate::runner::resolve_program(&exec.program); let mut full_args = prefix; full_args.extend(exec.args.iter().cloned()); @@ -78,7 +89,17 @@ fn start_one( .envs(&exec.env) .status() .with_context(|| format!("failed to run {}", exec.program))?; - Ok(status.code().unwrap_or(1)) + let code = status.code().unwrap_or(1); + app.emit( + &Event::now(EventKind::Run) + .with_agent(agent.name.clone()) + .with_cwd(cwd_string()) + .with_args(exec.args.clone()) + .with_env_keys(events::env_keys(&exec.env)) + .with_exit_code(code) + .with_duration(started.elapsed().as_secs()), + ); + Ok(code) } } @@ -123,11 +144,27 @@ fn stop_one(app: &App, agent: &AgentDef, force: bool, timeout: u64) -> Result Result { )); return Ok(0); } + let started = std::time::Instant::now(); let (prog, prefix) = crate::runner::resolve_program(&exec.program); let mut full_args = prefix; full_args.extend(exec.args.iter().cloned()); @@ -183,5 +221,30 @@ pub fn run(app: &App, target: &str, extra: &[OsString]) -> Result { .envs(&exec.env) .status() .with_context(|| format!("failed to run {}", exec.program))?; - Ok(status.code().unwrap_or(1)) + let code = status.code().unwrap_or(1); + app.emit( + &Event::now(EventKind::Run) + .with_agent(agent.name.clone()) + .with_cwd(cwd_string()) + .with_args(exec.args.clone()) + .with_env_keys(events::env_keys(&exec.env)) + .with_exit_code(code) + .with_duration(started.elapsed().as_secs()), + ); + Ok(code) +} + +/// Current directory as a string (None when unavailable). +fn cwd_string() -> Option { + std::env::current_dir().ok().map(|p| p.display().to_string()) +} + +/// Seconds elapsed since an RFC 3339 timestamp (None when unparsable). +fn seconds_since(ts: Option<&str>) -> Option { + let t = chrono::DateTime::parse_from_rfc3339(ts?).ok()?; + let now = chrono::Utc::now(); + let secs = now + .signed_duration_since(t.with_timezone(&chrono::Utc)) + .num_seconds(); + Some(secs.max(0) as u64) } diff --git a/src/commands/uninstall_cmd.rs b/src/commands/uninstall_cmd.rs index a09e19a..a48ef8f 100644 --- a/src/commands/uninstall_cmd.rs +++ b/src/commands/uninstall_cmd.rs @@ -5,6 +5,7 @@ //! executables found on PATH otherwise. use super::*; +use crate::events::{Event, EventKind}; use anyhow::{Context, Result}; use std::path::PathBuf; use std::process::Command as ProcessCommand; @@ -57,6 +58,7 @@ pub fn run(app: &App, agent_name: &str, purge: bool) -> Result { remove_from_user_config(app, &agent.name)?; } + app.emit(&Event::now(EventKind::Uninstall).with_agent(agent.name.clone())); app.log.success(&format!( "uninstalled {} (files removed{})", agent.title(), @@ -178,6 +180,11 @@ fn uninstall_external(app: &App, agent: &AgentDef) -> Result { .and_then(|t| which::which(t).ok()) .is_none(); if gone { + app.emit( + &Event::now(EventKind::Uninstall) + .with_agent(agent.name.clone()) + .with_reason("external"), + ); app.log .success(&format!("uninstalled {} (external)", agent.title())); } else { diff --git a/src/commands/update_cmd.rs b/src/commands/update_cmd.rs index f5fd114..dc6bab2 100644 --- a/src/commands/update_cmd.rs +++ b/src/commands/update_cmd.rs @@ -2,6 +2,7 @@ //! reinstall when an update exists. use super::*; +use crate::events::{Event, EventKind}; use crate::installers; use crate::runner::{Runner, SystemRunner}; use anyhow::{bail, Result}; @@ -96,6 +97,11 @@ fn update_one(app: &App, runner: &dyn Runner, name: &str) -> Result { new_entry.updated_at = Some(installers::now_rfc3339()); let version = new_entry.version.clone().unwrap_or_else(|| "unknown".to_string()); app.state.set(&new_entry)?; + app.emit( + &Event::now(EventKind::Update) + .with_agent(agent.name.clone()) + .with_args(vec![format!("method={}", method.kind)]), + ); app.log.success(&format!( "updated {} to v{version}", agent.title() diff --git a/src/events.rs b/src/events.rs index 51fb2ba..979c4d7 100644 --- a/src/events.rs +++ b/src/events.rs @@ -90,8 +90,8 @@ impl Event { self } - pub fn with_cwd(mut self, cwd: impl Into) -> Self { - self.cwd = Some(cwd.into()); + pub fn with_cwd(mut self, cwd: Option) -> Self { + self.cwd = cwd; self } @@ -209,6 +209,46 @@ pub fn read_events(dir: &Path, limit: usize) -> Vec { out } +/// Append one event as a single JSON line to the journal file for the +/// event's month (created under `dir`). Returns the file written to. +/// Retries briefly when the file is locked (Windows). +pub fn append(dir: &Path, event: &Event) -> anyhow::Result { + let path = dir.join(file_name_for_month(&month_of(&event.ts))); + append_to(&path, event)?; + Ok(path) +} + +/// Append one event to a specific journal file. +pub fn append_to(path: &Path, event: &Event) -> anyhow::Result<()> { + if let Some(parent) = path.parent() { + let _ = std::fs::create_dir_all(parent); + } + let line = serde_json::to_string(event)?; + let mut last_err: Option = None; + for attempt in 0..5 { + match std::fs::OpenOptions::new().create(true).append(true).open(path) { + Ok(mut f) => { + f.write_all(line.as_bytes())?; + f.write_all(b"\n")?; + return Ok(()); + } + Err(e) => { + last_err = Some(e); + if attempt < 4 { + std::thread::sleep(std::time::Duration::from_millis(20 * (attempt + 1))); + } + } + } + } + Err(anyhow::anyhow!( + "cannot append to journal {}: {}", + path.display(), + last_err + .map(|e| e.to_string()) + .unwrap_or_else(|| "unknown error".to_string()) + )) +} + /// Append-only journal living in a directory (the state directory). pub struct EventLog { dir: PathBuf, @@ -247,41 +287,11 @@ impl EventLog { } /// Append one event as a single JSON line; returns the file written to. - /// Retries briefly when the file is locked (Windows). pub fn append(&mut self, event: &Event) -> anyhow::Result { let month = month_of(&event.ts); let path = self.file_for(&month); - if let Some(parent) = path.parent() { - let _ = std::fs::create_dir_all(parent); - } - let line = serde_json::to_string(event)?; - let mut last_err: Option = None; - for attempt in 0..5 { - match std::fs::OpenOptions::new() - .create(true) - .append(true) - .open(&path) - { - Ok(mut f) => { - f.write_all(line.as_bytes())?; - f.write_all(b"\n")?; - return Ok(path); - } - Err(e) => { - last_err = Some(e); - if attempt < 4 { - std::thread::sleep(std::time::Duration::from_millis(20 * (attempt + 1))); - } - } - } - } - Err(anyhow::anyhow!( - "cannot append to journal {}: {}", - path.display(), - last_err - .map(|e| e.to_string()) - .unwrap_or_else(|| "unknown error".to_string()) - )) + append_to(&path, event)?; + Ok(path) } } diff --git a/src/repl.rs b/src/repl.rs index e2c4c8a..db81523 100644 --- a/src/repl.rs +++ b/src/repl.rs @@ -15,6 +15,7 @@ use crate::app::App; use crate::cli::{Command, StartArgs}; use crate::commands; +use crate::events::{Event, EventKind}; use crate::nav; use crate::ps; use crate::tables::DataTable; @@ -884,6 +885,11 @@ pub fn run(app: &App) -> Result { } let mut session = ShellSession::start(&app.config); let sid = session_id(); + app.emit( + &Event::now(EventKind::Repl) + .with_session(sid.clone()) + .with_cwd(Some(current_dir_string())), + ); print!("{}", banner(app, &session, &sid)); match run_with_editor(app, &mut session) { Ok(code) => Ok(code), @@ -1379,11 +1385,19 @@ fn eval_pipeline(app: &App, last: &mut Option, line: &str) -> Result< /// Run one system command through the active shell (inherited stdio). fn run_system(app: &App, session: &ShellSession, line: &str) -> Result<()> { app.log.cmd(&format!("{}: {line}", session.current.name)); + let started = std::time::Instant::now(); let code = session.run(line)?; if code != 0 { app.log .error(&format!("command exited with code {code}")); } + app.emit( + &Event::now(EventKind::Shell) + .with_args(vec![line.to_string()]) + .with_exit_code(code) + .with_duration(started.elapsed().as_secs()) + .with_cwd(Some(current_dir_string())), + ); Ok(()) } diff --git a/tests/events_test.rs b/tests/events_test.rs index 6fb9fc0..567b0c6 100644 --- a/tests/events_test.rs +++ b/tests/events_test.rs @@ -29,7 +29,7 @@ fn append_then_read_back_through_read_events() { log.append( &Event::now(EventKind::Run) .with_agent("jcode") - .with_cwd("/tmp/demo") + .with_cwd(Some("/tmp/demo".to_string())) .with_exit_code(0), ) .unwrap(); diff --git a/tests/instrumentation_test.rs b/tests/instrumentation_test.rs new file mode 100644 index 0000000..9e3add6 --- /dev/null +++ b/tests/instrumentation_test.rs @@ -0,0 +1,130 @@ +//! Instrumentation: every real action appends one event to the journal; +//! dry runs write nothing. Uses the same isolated-App pattern as the other +//! integration tests (no user config or state leaks in). + +mod common; + +use agent_manager::app::App; +use agent_manager::cli::{Cli, StartArgs}; +use agent_manager::commands::run_cmd; +use agent_manager::events::{self, Event, EventKind}; +use agent_manager::state::InstalledEntry; +use clap::Parser; +use std::ffi::OsString; +use std::path::PathBuf; +use std::sync::atomic::{AtomicU32, Ordering}; + +static COUNTER: AtomicU32 = AtomicU32::new(0); + +/// Build an isolated App whose catalog contains the given agent definition. +fn app_with_agent(tag: &str, args: &[&str], agent_yaml: &str) -> App { + let id = COUNTER.fetch_add(1, Ordering::SeqCst); + let dir = std::env::temp_dir().join(format!("am-instr-{tag}-{}-{id}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let cfg = common::write_config(&dir, agent_yaml); + let mut full = vec!["am".to_string(), "--config".to_string(), cfg.display().to_string()]; + full.extend(args.iter().map(|s| s.to_string())); + let cli = Cli::parse_from(full); + let mut app = App::from_cli(cli).expect("app should build"); + common::isolate(&mut app, &dir); + app +} + +fn events(app: &App) -> Vec { + events::read_events(&app.events_dir(), 0) +} + +const ECHO_AGENT: &str = r#" +agents: + - name: echo-agent + install: { type: binary, url: "https://example.com/echo.zip" } + run: sh + args: ["-c", "echo hi"] +"#; + +#[test] +fn run_emits_one_run_event_with_exit_code() { + let app = app_with_agent("run", &["run", "echo-agent"], ECHO_AGENT); + let code = run_cmd::run(&app, "echo-agent", &[OsString::from("hello")]).unwrap(); + assert_eq!(code, 0); + let evs = events(&app); + assert_eq!(evs.len(), 1, "exactly one event expected, got {:?}", evs); + assert_eq!(evs[0].kind, EventKind::Run); + assert_eq!(evs[0].agent.as_deref(), Some("echo-agent")); + assert_eq!(evs[0].exit_code, Some(0)); + assert!(evs[0].duration_s.is_some()); +} + +#[test] +fn dry_run_writes_no_event() { + let app = app_with_agent("dry", &["run", "echo-agent", "--dry-run"], ECHO_AGENT); + let code = run_cmd::run(&app, "echo-agent", &[]).unwrap(); + assert_eq!(code, 0); + assert!(events(&app).is_empty(), "dry-run must not append events"); + assert!(events::journal_files(&app.events_dir()).is_empty()); +} + +fn sleeper_agent() -> String { + let (run, args) = if cfg!(windows) { + ("ping", r#"["-n", "60", "127.0.0.1"]"#) + } else { + ("sleep", r#"["60"]"#) + }; + format!( + r#" +agents: + - name: sleeper + install: {{ type: binary, url: "https://example.com/sleeper.zip" }} + run: {run} + args: {args} +"# + ) +} + +#[test] +fn start_and_stop_emit_a_matching_pair() { + let app = app_with_agent("startstop", &["start", "sleeper", "--background"], &sleeper_agent()); + // The agent must be "managed" for the PID to be recorded and stoppable. + let entry = InstalledEntry { + name: "sleeper".to_string(), + version: None, + method: "binary".to_string(), + install_dir: app.paths.install_dir.display().to_string(), + bins: vec![], + run: "sleeper".to_string(), + installed_at: "2026-01-01T00:00:00Z".to_string(), + updated_at: None, + pid: None, + started_at: None, + }; + app.state.set(&entry).unwrap(); + + let opts = StartArgs { + agent: "sleeper".to_string(), + background: true, + ..Default::default() + }; + let code = run_cmd::start(&app, &opts).unwrap(); + assert_eq!(code, 0); + + let stop_code = run_cmd::stop(&app, "sleeper", false, None).unwrap(); + assert_eq!(stop_code, 0); + + let evs = events(&app); + assert_eq!(evs.len(), 2, "expected Start + Stop, got {:?}", evs); + assert_eq!(evs[0].kind, EventKind::Start); + assert_eq!(evs[0].agent.as_deref(), Some("sleeper")); + assert!(evs[0].pid.is_some()); + assert!(evs[0].session.is_some(), "start events carry a session id"); + assert_eq!(evs[1].kind, EventKind::Stop); + assert_eq!(evs[1].pid, evs[0].pid); + assert_eq!(evs[1].exit_code, Some(0)); + assert!(evs[1].duration_s.is_some(), "stop events carry the duration"); +} + +#[test] +fn fixture_path_is_reachable() { + // Guard: the shared fixture used by events_test stays parsable here too. + let p = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/events-sample.jsonl"); + assert_eq!(events::parse_file(&p).len(), 2); +}