events : instrumentation des commandes (issue #3) #18
+17
@@ -121,6 +121,23 @@ impl App {
|
|||||||
self.theme.set(theme);
|
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.
|
/// Ask the user for confirmation on stderr. Accepts y/yes/o/oui.
|
||||||
pub fn confirm(&self, question: &str) -> Result<bool> {
|
pub fn confirm(&self, question: &str) -> Result<bool> {
|
||||||
if self.cli.yes {
|
if self.cli.yes {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
use super::*;
|
use super::*;
|
||||||
use crate::doctor::{self, Status};
|
use crate::doctor::{self, Status};
|
||||||
|
use crate::events::{Event, EventKind};
|
||||||
use crate::output::{boxed_table, print_json, status_style};
|
use crate::output::{boxed_table, print_json, status_style};
|
||||||
use crate::runner::SystemRunner;
|
use crate::runner::SystemRunner;
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
@@ -22,6 +23,12 @@ pub fn run(app: &App, fix: bool) -> Result<i32> {
|
|||||||
// The doctor report probes real tools, even under --dry-run.
|
// The doctor report probes real tools, even under --dry-run.
|
||||||
let runner = SystemRunner::new(false, false, &app.log);
|
let runner = SystemRunner::new(false, false, &app.log);
|
||||||
let report = doctor::run(app, &runner);
|
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() {
|
if app.json() {
|
||||||
print_json(&report.checks);
|
print_json(&report.checks);
|
||||||
return Ok(if report.ok() { 0 } else { 1 });
|
return Ok(if report.ok() { 0 } else { 1 });
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
//! installer, and record the installation in the state database.
|
//! installer, and record the installation in the state database.
|
||||||
|
|
||||||
use super::*;
|
use super::*;
|
||||||
|
use crate::events::{Event, EventKind};
|
||||||
use crate::installers;
|
use crate::installers;
|
||||||
use crate::runner::SystemRunner;
|
use crate::runner::SystemRunner;
|
||||||
use anyhow::{anyhow, bail, Result};
|
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 entry = installers::make_entry(agent, method, &outcome, app);
|
||||||
let version = entry.version.clone().unwrap_or_else(|| "unknown".to_string());
|
let version = entry.version.clone().unwrap_or_else(|| "unknown".to_string());
|
||||||
app.state.set(&entry)?;
|
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!(
|
app.log.success(&format!(
|
||||||
"installed {} (v{version}) in {}",
|
"installed {} (v{version}) in {}",
|
||||||
agent.title(),
|
agent.title(),
|
||||||
|
|||||||
+65
-2
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
use super::*;
|
use super::*;
|
||||||
use crate::cli::StartArgs;
|
use crate::cli::StartArgs;
|
||||||
|
use crate::events::{self, Event, EventKind};
|
||||||
use crate::process;
|
use crate::process;
|
||||||
use anyhow::{bail, Context, Result};
|
use anyhow::{bail, Context, Result};
|
||||||
use std::collections::BTreeMap;
|
use std::collections::BTreeMap;
|
||||||
@@ -51,6 +52,15 @@ fn start_one(
|
|||||||
agent.name
|
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);
|
let log = process::agent_log_path(app, &agent.name);
|
||||||
app.log.success(&format!(
|
app.log.success(&format!(
|
||||||
"{} started (pid {pid}); log: {}",
|
"{} started (pid {pid}); log: {}",
|
||||||
@@ -70,6 +80,7 @@ fn start_one(
|
|||||||
));
|
));
|
||||||
return Ok(0);
|
return Ok(0);
|
||||||
}
|
}
|
||||||
|
let started = std::time::Instant::now();
|
||||||
let (prog, prefix) = crate::runner::resolve_program(&exec.program);
|
let (prog, prefix) = crate::runner::resolve_program(&exec.program);
|
||||||
let mut full_args = prefix;
|
let mut full_args = prefix;
|
||||||
full_args.extend(exec.args.iter().cloned());
|
full_args.extend(exec.args.iter().cloned());
|
||||||
@@ -78,7 +89,17 @@ fn start_one(
|
|||||||
.envs(&exec.env)
|
.envs(&exec.env)
|
||||||
.status()
|
.status()
|
||||||
.with_context(|| format!("failed to run {}", exec.program))?;
|
.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<i3
|
|||||||
"stale PID {pid} for '{}' (process no longer running); cleaning up",
|
"stale PID {pid} for '{}' (process no longer running); cleaning up",
|
||||||
agent.name
|
agent.name
|
||||||
));
|
));
|
||||||
|
app.emit(
|
||||||
|
&Event::now(EventKind::Stop)
|
||||||
|
.with_agent(agent.name.clone())
|
||||||
|
.with_pid(pid)
|
||||||
|
.with_reason("stale"),
|
||||||
|
);
|
||||||
app.state.update_pid(&agent.name, None, None)?;
|
app.state.update_pid(&agent.name, None, None)?;
|
||||||
return Ok(0);
|
return Ok(0);
|
||||||
}
|
}
|
||||||
app.log.info(&format!("stopping '{}' (pid {pid})", agent.name));
|
app.log.info(&format!("stopping '{}' (pid {pid})", agent.name));
|
||||||
let stopped = process::stop_pid(pid, force, timeout);
|
let stopped = process::stop_pid(pid, force, timeout);
|
||||||
|
let duration = seconds_since(entry.started_at.as_deref());
|
||||||
|
let mut ev = Event::now(EventKind::Stop)
|
||||||
|
.with_agent(agent.name.clone())
|
||||||
|
.with_pid(pid)
|
||||||
|
.with_reason(if force { "force" } else { "signal" })
|
||||||
|
.with_exit_code(if stopped { 0 } else { 1 });
|
||||||
|
if let Some(d) = duration {
|
||||||
|
ev = ev.with_duration(d);
|
||||||
|
}
|
||||||
|
app.emit(&ev);
|
||||||
app.state.update_pid(&agent.name, None, None)?;
|
app.state.update_pid(&agent.name, None, None)?;
|
||||||
if stopped {
|
if stopped {
|
||||||
app.log.success(&format!("{} stopped", agent.title()));
|
app.log.success(&format!("{} stopped", agent.title()));
|
||||||
@@ -175,6 +212,7 @@ pub fn run(app: &App, target: &str, extra: &[OsString]) -> Result<i32> {
|
|||||||
));
|
));
|
||||||
return Ok(0);
|
return Ok(0);
|
||||||
}
|
}
|
||||||
|
let started = std::time::Instant::now();
|
||||||
let (prog, prefix) = crate::runner::resolve_program(&exec.program);
|
let (prog, prefix) = crate::runner::resolve_program(&exec.program);
|
||||||
let mut full_args = prefix;
|
let mut full_args = prefix;
|
||||||
full_args.extend(exec.args.iter().cloned());
|
full_args.extend(exec.args.iter().cloned());
|
||||||
@@ -183,5 +221,30 @@ pub fn run(app: &App, target: &str, extra: &[OsString]) -> Result<i32> {
|
|||||||
.envs(&exec.env)
|
.envs(&exec.env)
|
||||||
.status()
|
.status()
|
||||||
.with_context(|| format!("failed to run {}", exec.program))?;
|
.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<String> {
|
||||||
|
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<u64> {
|
||||||
|
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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
//! executables found on PATH otherwise.
|
//! executables found on PATH otherwise.
|
||||||
|
|
||||||
use super::*;
|
use super::*;
|
||||||
|
use crate::events::{Event, EventKind};
|
||||||
use anyhow::{Context, Result};
|
use anyhow::{Context, Result};
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
use std::process::Command as ProcessCommand;
|
use std::process::Command as ProcessCommand;
|
||||||
@@ -57,6 +58,7 @@ pub fn run(app: &App, agent_name: &str, purge: bool) -> Result<i32> {
|
|||||||
remove_from_user_config(app, &agent.name)?;
|
remove_from_user_config(app, &agent.name)?;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
app.emit(&Event::now(EventKind::Uninstall).with_agent(agent.name.clone()));
|
||||||
app.log.success(&format!(
|
app.log.success(&format!(
|
||||||
"uninstalled {} (files removed{})",
|
"uninstalled {} (files removed{})",
|
||||||
agent.title(),
|
agent.title(),
|
||||||
@@ -178,6 +180,11 @@ fn uninstall_external(app: &App, agent: &AgentDef) -> Result<i32> {
|
|||||||
.and_then(|t| which::which(t).ok())
|
.and_then(|t| which::which(t).ok())
|
||||||
.is_none();
|
.is_none();
|
||||||
if gone {
|
if gone {
|
||||||
|
app.emit(
|
||||||
|
&Event::now(EventKind::Uninstall)
|
||||||
|
.with_agent(agent.name.clone())
|
||||||
|
.with_reason("external"),
|
||||||
|
);
|
||||||
app.log
|
app.log
|
||||||
.success(&format!("uninstalled {} (external)", agent.title()));
|
.success(&format!("uninstalled {} (external)", agent.title()));
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
//! reinstall when an update exists.
|
//! reinstall when an update exists.
|
||||||
|
|
||||||
use super::*;
|
use super::*;
|
||||||
|
use crate::events::{Event, EventKind};
|
||||||
use crate::installers;
|
use crate::installers;
|
||||||
use crate::runner::{Runner, SystemRunner};
|
use crate::runner::{Runner, SystemRunner};
|
||||||
use anyhow::{bail, Result};
|
use anyhow::{bail, Result};
|
||||||
@@ -96,6 +97,11 @@ fn update_one(app: &App, runner: &dyn Runner, name: &str) -> Result<i32> {
|
|||||||
new_entry.updated_at = Some(installers::now_rfc3339());
|
new_entry.updated_at = Some(installers::now_rfc3339());
|
||||||
let version = new_entry.version.clone().unwrap_or_else(|| "unknown".to_string());
|
let version = new_entry.version.clone().unwrap_or_else(|| "unknown".to_string());
|
||||||
app.state.set(&new_entry)?;
|
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!(
|
app.log.success(&format!(
|
||||||
"updated {} to v{version}",
|
"updated {} to v{version}",
|
||||||
agent.title()
|
agent.title()
|
||||||
|
|||||||
+44
-34
@@ -90,8 +90,8 @@ impl Event {
|
|||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn with_cwd(mut self, cwd: impl Into<String>) -> Self {
|
pub fn with_cwd(mut self, cwd: Option<String>) -> Self {
|
||||||
self.cwd = Some(cwd.into());
|
self.cwd = cwd;
|
||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -209,6 +209,46 @@ pub fn read_events(dir: &Path, limit: usize) -> Vec<Event> {
|
|||||||
out
|
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<PathBuf> {
|
||||||
|
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<std::io::Error> = 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).
|
/// Append-only journal living in a directory (the state directory).
|
||||||
pub struct EventLog {
|
pub struct EventLog {
|
||||||
dir: PathBuf,
|
dir: PathBuf,
|
||||||
@@ -247,41 +287,11 @@ impl EventLog {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Append one event as a single JSON line; returns the file written to.
|
/// 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<PathBuf> {
|
pub fn append(&mut self, event: &Event) -> anyhow::Result<PathBuf> {
|
||||||
let month = month_of(&event.ts);
|
let month = month_of(&event.ts);
|
||||||
let path = self.file_for(&month);
|
let path = self.file_for(&month);
|
||||||
if let Some(parent) = path.parent() {
|
append_to(&path, event)?;
|
||||||
let _ = std::fs::create_dir_all(parent);
|
Ok(path)
|
||||||
}
|
|
||||||
let line = serde_json::to_string(event)?;
|
|
||||||
let mut last_err: Option<std::io::Error> = 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())
|
|
||||||
))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+14
@@ -15,6 +15,7 @@
|
|||||||
use crate::app::App;
|
use crate::app::App;
|
||||||
use crate::cli::{Command, StartArgs};
|
use crate::cli::{Command, StartArgs};
|
||||||
use crate::commands;
|
use crate::commands;
|
||||||
|
use crate::events::{Event, EventKind};
|
||||||
use crate::nav;
|
use crate::nav;
|
||||||
use crate::ps;
|
use crate::ps;
|
||||||
use crate::tables::DataTable;
|
use crate::tables::DataTable;
|
||||||
@@ -884,6 +885,11 @@ pub fn run(app: &App) -> Result<i32> {
|
|||||||
}
|
}
|
||||||
let mut session = ShellSession::start(&app.config);
|
let mut session = ShellSession::start(&app.config);
|
||||||
let sid = session_id();
|
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));
|
print!("{}", banner(app, &session, &sid));
|
||||||
match run_with_editor(app, &mut session) {
|
match run_with_editor(app, &mut session) {
|
||||||
Ok(code) => Ok(code),
|
Ok(code) => Ok(code),
|
||||||
@@ -1379,11 +1385,19 @@ fn eval_pipeline(app: &App, last: &mut Option<DataTable>, line: &str) -> Result<
|
|||||||
/// Run one system command through the active shell (inherited stdio).
|
/// Run one system command through the active shell (inherited stdio).
|
||||||
fn run_system(app: &App, session: &ShellSession, line: &str) -> Result<()> {
|
fn run_system(app: &App, session: &ShellSession, line: &str) -> Result<()> {
|
||||||
app.log.cmd(&format!("{}: {line}", session.current.name));
|
app.log.cmd(&format!("{}: {line}", session.current.name));
|
||||||
|
let started = std::time::Instant::now();
|
||||||
let code = session.run(line)?;
|
let code = session.run(line)?;
|
||||||
if code != 0 {
|
if code != 0 {
|
||||||
app.log
|
app.log
|
||||||
.error(&format!("command exited with code {code}"));
|
.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(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ fn append_then_read_back_through_read_events() {
|
|||||||
log.append(
|
log.append(
|
||||||
&Event::now(EventKind::Run)
|
&Event::now(EventKind::Run)
|
||||||
.with_agent("jcode")
|
.with_agent("jcode")
|
||||||
.with_cwd("/tmp/demo")
|
.with_cwd(Some("/tmp/demo".to_string()))
|
||||||
.with_exit_code(0),
|
.with_exit_code(0),
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|||||||
@@ -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<Event> {
|
||||||
|
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);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user