Merge pull request 'sessions : registre central + am sessions (issue #5)' (#20) from feat/sessions into main
This commit was merged in pull request #20.
This commit is contained in:
+14
@@ -98,6 +98,20 @@ pub enum Command {
|
||||
/// Agent name or alias (omit to show all installed agents)
|
||||
agent: Option<String>,
|
||||
},
|
||||
/// List the sessions of every agent (and of the interactive shell)
|
||||
Sessions {
|
||||
/// Only sessions of this agent
|
||||
agent: Option<String>,
|
||||
/// Only sessions of this project
|
||||
#[arg(long, value_name = "PROJECT")]
|
||||
project: Option<String>,
|
||||
/// Only sessions with this status (running, stopped, failed, interrupted)
|
||||
#[arg(long, value_name = "STATUS")]
|
||||
status: Option<String>,
|
||||
/// Show one session in detail (summary + log excerpt)
|
||||
#[arg(long, value_name = "ID")]
|
||||
show: Option<String>,
|
||||
},
|
||||
/// Install an agent and its dependencies
|
||||
Install {
|
||||
/// Agent name or alias
|
||||
|
||||
@@ -11,6 +11,7 @@ pub mod list_cmd;
|
||||
pub mod run_cmd;
|
||||
pub mod search_cmd;
|
||||
pub mod self_uninstall;
|
||||
pub mod sessions_cmd;
|
||||
pub mod self_update;
|
||||
pub mod status_cmd;
|
||||
pub mod uninstall_cmd;
|
||||
@@ -48,6 +49,18 @@ pub fn execute_command(app: &App, cmd: &Command) -> Result<i32> {
|
||||
timeout,
|
||||
} => run_cmd::restart(app, start, *force, *timeout),
|
||||
Command::Status { agent } => status_cmd::run(app, agent.as_deref()),
|
||||
Command::Sessions {
|
||||
agent,
|
||||
project,
|
||||
status,
|
||||
show,
|
||||
} => sessions_cmd::run(
|
||||
app,
|
||||
agent.as_deref(),
|
||||
project.as_deref(),
|
||||
status.as_deref(),
|
||||
show.as_deref(),
|
||||
),
|
||||
Command::Install {
|
||||
agent,
|
||||
method,
|
||||
|
||||
@@ -4,6 +4,7 @@ use super::*;
|
||||
use crate::cli::StartArgs;
|
||||
use crate::events::{self, Event, EventKind};
|
||||
use crate::process;
|
||||
use crate::sessions;
|
||||
use anyhow::{bail, Context, Result};
|
||||
use std::collections::BTreeMap;
|
||||
use std::ffi::OsString;
|
||||
@@ -65,6 +66,15 @@ fn start_one(
|
||||
let _ = app.state.touch(&agent.name);
|
||||
}
|
||||
let log = process::agent_log_path(app, &agent.name);
|
||||
let _ = sessions::start_agent(
|
||||
app,
|
||||
&agent.name,
|
||||
pid,
|
||||
cwd_string(),
|
||||
exec.args.clone(),
|
||||
events::env_keys(&exec.env),
|
||||
Some(log.display().to_string()),
|
||||
);
|
||||
app.log.success(&format!(
|
||||
"{} started (pid {pid}); log: {}",
|
||||
agent.title(),
|
||||
@@ -154,6 +164,7 @@ fn stop_one(app: &App, agent: &AgentDef, force: bool, timeout: u64) -> Result<i3
|
||||
.with_pid(pid)
|
||||
.with_reason("stale"),
|
||||
);
|
||||
let _ = sessions::finish_agent(app, &agent.name, Some(pid), 0, true);
|
||||
app.state.update_pid(&agent.name, None, None)?;
|
||||
return Ok(0);
|
||||
}
|
||||
@@ -169,6 +180,7 @@ fn stop_one(app: &App, agent: &AgentDef, force: bool, timeout: u64) -> Result<i3
|
||||
ev = ev.with_duration(d);
|
||||
}
|
||||
app.emit(&ev);
|
||||
let _ = sessions::finish_agent(app, &agent.name, Some(pid), if stopped { 0 } else { 1 }, stopped);
|
||||
app.state.update_pid(&agent.name, None, None)?;
|
||||
if stopped {
|
||||
app.log.success(&format!("{} stopped", agent.title()));
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
//! sessions: the registry of every agent and REPL session.
|
||||
|
||||
use super::*;
|
||||
use crate::output::{print_json, status_style, Table};
|
||||
use crate::sessions::{self, SessionRecord};
|
||||
use serde::Serialize;
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct SessionRow {
|
||||
id: String,
|
||||
kind: String,
|
||||
agent: Option<String>,
|
||||
status: String,
|
||||
pid: Option<u32>,
|
||||
cwd: Option<String>,
|
||||
project: Option<String>,
|
||||
started_at: String,
|
||||
ended_at: Option<String>,
|
||||
duration_s: Option<u64>,
|
||||
exit_code: Option<i32>,
|
||||
log: Option<String>,
|
||||
}
|
||||
|
||||
fn row_of(r: &SessionRecord) -> SessionRow {
|
||||
SessionRow {
|
||||
id: r.id.clone(),
|
||||
kind: r.kind.clone(),
|
||||
agent: r.agent.clone(),
|
||||
status: r.status.clone(),
|
||||
pid: r.pid,
|
||||
cwd: r.cwd.clone(),
|
||||
project: r.project.clone(),
|
||||
started_at: r.started_at.clone(),
|
||||
ended_at: r.ended_at.clone(),
|
||||
duration_s: sessions::duration_s(r),
|
||||
exit_code: r.exit_code,
|
||||
log: r.log.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn run(
|
||||
app: &App,
|
||||
agent: Option<&str>,
|
||||
project: Option<&str>,
|
||||
status: Option<&str>,
|
||||
show: Option<&str>,
|
||||
) -> Result<i32> {
|
||||
if let Some(id) = show {
|
||||
return show_one(app, id);
|
||||
}
|
||||
sessions::reconcile(app)?;
|
||||
let sf = sessions::load(app)?;
|
||||
let mut rows: Vec<SessionRow> = sf
|
||||
.sessions
|
||||
.values()
|
||||
.filter(|r| agent.map_or(true, |a| r.agent.as_deref() == Some(a)))
|
||||
.filter(|r| project.map_or(true, |p| r.project.as_deref() == Some(p)))
|
||||
.filter(|r| status.map_or(true, |s| r.status == s))
|
||||
.map(row_of)
|
||||
.collect();
|
||||
rows.sort_by(|a, b| b.started_at.cmp(&a.started_at));
|
||||
if app.json() {
|
||||
print_json(&rows);
|
||||
return Ok(0);
|
||||
}
|
||||
let color = app.color();
|
||||
let theme = app.theme();
|
||||
let mut table = Table::new(vec!["SESSION", "AGENT", "STATUS", "DURATION", "EXIT"]);
|
||||
for r in &rows {
|
||||
table.row(vec![
|
||||
short_id(&r.id),
|
||||
r.agent
|
||||
.clone()
|
||||
.unwrap_or_else(|| format!("({})", r.kind)),
|
||||
status_style(&r.status, theme, color),
|
||||
r.duration_s
|
||||
.map(format_duration)
|
||||
.unwrap_or_else(|| "—".to_string()),
|
||||
r.exit_code
|
||||
.map(|c| c.to_string())
|
||||
.unwrap_or_else(|| "—".to_string()),
|
||||
]);
|
||||
}
|
||||
print!("{}", table.render());
|
||||
if rows.is_empty() {
|
||||
app.log
|
||||
.info("no sessions recorded yet — 'am start <agent> --background' or run 'am'");
|
||||
}
|
||||
Ok(0)
|
||||
}
|
||||
|
||||
fn show_one(app: &App, id: &str) -> Result<i32> {
|
||||
sessions::reconcile(app)?;
|
||||
let sf = sessions::load(app)?;
|
||||
let Some(r) = sf.sessions.get(id).cloned() else {
|
||||
bail!("unknown session '{id}' — 'am sessions' lists every session");
|
||||
};
|
||||
if app.json() {
|
||||
print_json(&row_of(&r));
|
||||
return Ok(0);
|
||||
}
|
||||
println!("session {}", r.id);
|
||||
println!(" kind: {}", r.kind);
|
||||
println!(" agent: {}", r.agent.as_deref().unwrap_or("—"));
|
||||
println!(" status: {}", r.status);
|
||||
println!(" pid: {}", r.pid.map(|p| p.to_string()).unwrap_or_else(|| "—".to_string()));
|
||||
println!(" cwd: {}", r.cwd.as_deref().unwrap_or("—"));
|
||||
println!(" project: {}", r.project.as_deref().unwrap_or("—"));
|
||||
println!(" started: {}", r.started_at);
|
||||
println!(" ended: {}", r.ended_at.as_deref().unwrap_or("—"));
|
||||
if let Some(d) = sessions::duration_s(&r) {
|
||||
println!(" duration: {}", format_duration(d));
|
||||
}
|
||||
if let Some(c) = r.exit_code {
|
||||
println!(" exit code: {c}");
|
||||
}
|
||||
if let Some(l) = &r.log {
|
||||
let p = std::path::PathBuf::from(l);
|
||||
if p.exists() {
|
||||
println!(" log: {l}");
|
||||
let tail = crate::process::last_lines(&p, 8);
|
||||
if !tail.is_empty() {
|
||||
println!(" --- log excerpt ---");
|
||||
println!("{tail}");
|
||||
}
|
||||
} else {
|
||||
println!(" log: {l} (missing)");
|
||||
}
|
||||
}
|
||||
Ok(0)
|
||||
}
|
||||
|
||||
/// "20260815_143926_a1b2c3" -> "20260815_143926"
|
||||
fn short_id(id: &str) -> String {
|
||||
id.chars().take(15).collect()
|
||||
}
|
||||
|
||||
fn format_duration(secs: u64) -> String {
|
||||
if secs < 60 {
|
||||
format!("{secs}s")
|
||||
} else if secs < 3600 {
|
||||
format!("{}m{:02}s", secs / 60, secs % 60)
|
||||
} else {
|
||||
format!("{}h{:02}m", secs / 3600, (secs % 3600) / 60)
|
||||
}
|
||||
}
|
||||
+21
@@ -177,6 +177,27 @@ pub static HELP_SPECS: &[HelpSpec] = &[
|
||||
HelpExample { desc: "Show one agent.", code: "status claude-code" },
|
||||
],
|
||||
},
|
||||
HelpSpec {
|
||||
name: "sessions",
|
||||
category: "Commands",
|
||||
usage: "sessions {flags} [agent]",
|
||||
about: "List the sessions of every agent and of the interactive shell.",
|
||||
search_terms: &["session", "history", "log"],
|
||||
flags: &[
|
||||
HelpFlag { short: "", long: "--project", value: "PROJECT", desc: "Only sessions of this project" },
|
||||
HelpFlag { short: "", long: "--status", value: "STATUS", desc: "Only sessions with this status (running, stopped, failed, interrupted)" },
|
||||
HelpFlag { short: "", long: "--show", value: "ID", desc: "Show one session in detail (summary + log excerpt)" },
|
||||
],
|
||||
subcommands: &[],
|
||||
parameters: &[
|
||||
HelpParam { name: "agent", typ: "string", desc: "Only sessions of this agent" },
|
||||
],
|
||||
io: None,
|
||||
examples: &[
|
||||
HelpExample { desc: "List recent sessions.", code: "sessions" },
|
||||
HelpExample { desc: "Show one session with its log excerpt.", code: "sessions --show 20260815_143926_a1b2c3" },
|
||||
],
|
||||
},
|
||||
HelpSpec {
|
||||
name: "search",
|
||||
category: "Commands",
|
||||
|
||||
@@ -29,6 +29,7 @@ pub mod ps;
|
||||
pub mod process;
|
||||
pub mod repl;
|
||||
pub mod runner;
|
||||
pub mod sessions;
|
||||
pub mod shell;
|
||||
pub mod state;
|
||||
pub mod tables;
|
||||
|
||||
+7
-3
@@ -60,6 +60,7 @@ pub struct AmCompleter {
|
||||
const COMMAND_DESCRIPTIONS: &[(&str, &str)] = &[
|
||||
("list", "list installed agents"),
|
||||
("status", "show agent state"),
|
||||
("sessions", "list agent sessions"),
|
||||
("search", "search the catalog"),
|
||||
("info", "agent details"),
|
||||
("install", "install an agent"),
|
||||
@@ -136,7 +137,7 @@ impl AmCompleter {
|
||||
Self {
|
||||
theme: std::cell::Cell::new(crate::theme::default_theme()),
|
||||
commands: vec![
|
||||
"list", "status", "search", "info", "install", "uninstall", "update",
|
||||
"list", "status", "sessions", "search", "info", "install", "uninstall", "update",
|
||||
"start", "stop", "restart", "run", "doctor", "config", "completion",
|
||||
"self-update", "self-uninstall", "export", "import", "shell", "theme",
|
||||
"ls", "dir", "cd", "ps", "where", "get", "help", "version", "exit",
|
||||
@@ -890,8 +891,9 @@ pub fn run(app: &App) -> Result<i32> {
|
||||
.with_session(sid.clone())
|
||||
.with_cwd(Some(current_dir_string())),
|
||||
);
|
||||
let _ = crate::sessions::start_repl(app, &sid, Some(current_dir_string()));
|
||||
print!("{}", banner(app, &session, &sid));
|
||||
match run_with_editor(app, &mut session) {
|
||||
let result = match run_with_editor(app, &mut session) {
|
||||
Ok(code) => Ok(code),
|
||||
Err(e) => {
|
||||
app.log.verbose(&format!(
|
||||
@@ -899,7 +901,9 @@ pub fn run(app: &App) -> Result<i32> {
|
||||
));
|
||||
run_plain(app, &mut session)
|
||||
}
|
||||
}
|
||||
};
|
||||
let _ = crate::sessions::finish_repl(app, &sid);
|
||||
result
|
||||
}
|
||||
|
||||
fn run_with_editor(app: &App, session: &mut ShellSession) -> Result<i32> {
|
||||
|
||||
+338
@@ -0,0 +1,338 @@
|
||||
//! Session registry: one record per agent session (background start/stop
|
||||
//! cycles and interactive REPL sessions). sessions.json is an index derived
|
||||
//! from the event journal; it can be rebuilt with 'am doctor --fix'.
|
||||
|
||||
use crate::app::App;
|
||||
use anyhow::{anyhow, Context, Result};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::BTreeMap;
|
||||
use std::path::PathBuf;
|
||||
|
||||
pub const SESSIONS_VERSION: u32 = 1;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub struct SessionRecord {
|
||||
pub id: String,
|
||||
/// "agent" or "repl".
|
||||
#[serde(default = "kind_agent")]
|
||||
pub kind: String,
|
||||
#[serde(default)]
|
||||
pub agent: Option<String>,
|
||||
#[serde(default)]
|
||||
pub pid: Option<u32>,
|
||||
#[serde(default)]
|
||||
pub cwd: Option<String>,
|
||||
#[serde(default)]
|
||||
pub project: Option<String>,
|
||||
/// RFC 3339 start timestamp.
|
||||
pub started_at: String,
|
||||
/// RFC 3339 end timestamp (None while running).
|
||||
#[serde(default)]
|
||||
pub ended_at: Option<String>,
|
||||
#[serde(default)]
|
||||
pub exit_code: Option<i32>,
|
||||
#[serde(default)]
|
||||
pub log: Option<String>,
|
||||
#[serde(default)]
|
||||
pub args: Vec<String>,
|
||||
/// Variable names only — never values.
|
||||
#[serde(default)]
|
||||
pub env_keys: Vec<String>,
|
||||
/// running | stopped | failed | interrupted
|
||||
#[serde(default = "status_running")]
|
||||
pub status: String,
|
||||
}
|
||||
|
||||
fn kind_agent() -> String {
|
||||
"agent".to_string()
|
||||
}
|
||||
|
||||
fn status_running() -> String {
|
||||
"running".to_string()
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
pub struct SessionsFile {
|
||||
#[serde(default)]
|
||||
pub version: u32,
|
||||
#[serde(default)]
|
||||
pub sessions: BTreeMap<String, SessionRecord>,
|
||||
}
|
||||
|
||||
/// Path of sessions.json (inside the state directory).
|
||||
pub fn file_path(app: &App) -> PathBuf {
|
||||
app.events_dir().join("sessions.json")
|
||||
}
|
||||
|
||||
pub fn load(app: &App) -> Result<SessionsFile> {
|
||||
let p = file_path(app);
|
||||
if !p.exists() {
|
||||
return Ok(SessionsFile {
|
||||
version: SESSIONS_VERSION,
|
||||
sessions: BTreeMap::new(),
|
||||
});
|
||||
}
|
||||
let text = std::fs::read_to_string(&p)
|
||||
.with_context(|| format!("cannot read sessions file {}", p.display()))?;
|
||||
let mut sf: SessionsFile = serde_json::from_str(&text).map_err(|e| {
|
||||
anyhow!(
|
||||
"sessions file {} is corrupt ({e}); run 'am doctor --fix' to repair it",
|
||||
p.display()
|
||||
)
|
||||
})?;
|
||||
if sf.version < SESSIONS_VERSION {
|
||||
sf.version = SESSIONS_VERSION;
|
||||
save(app, &sf)?;
|
||||
}
|
||||
Ok(sf)
|
||||
}
|
||||
|
||||
pub fn save(app: &App, sf: &SessionsFile) -> Result<()> {
|
||||
let p = file_path(app);
|
||||
if let Some(parent) = p.parent() {
|
||||
std::fs::create_dir_all(parent)?;
|
||||
}
|
||||
let tmp = p.with_extension("json.tmp");
|
||||
let text = serde_json::to_string_pretty(sf)?;
|
||||
std::fs::write(&tmp, text)?;
|
||||
std::fs::rename(&tmp, &p)
|
||||
.with_context(|| format!("cannot write sessions file {}", p.display()))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Open a new agent session; returns its id.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn start_agent(
|
||||
app: &App,
|
||||
agent: &str,
|
||||
pid: u32,
|
||||
cwd: Option<String>,
|
||||
args: Vec<String>,
|
||||
env_keys: Vec<String>,
|
||||
log: Option<String>,
|
||||
) -> Result<String> {
|
||||
let mut sf = load(app)?;
|
||||
let id = crate::repl::session_id();
|
||||
sf.sessions.insert(
|
||||
id.clone(),
|
||||
SessionRecord {
|
||||
id: id.clone(),
|
||||
kind: "agent".to_string(),
|
||||
agent: Some(agent.to_string()),
|
||||
pid: Some(pid),
|
||||
cwd,
|
||||
project: None,
|
||||
started_at: crate::installers::now_rfc3339(),
|
||||
ended_at: None,
|
||||
exit_code: None,
|
||||
log,
|
||||
args,
|
||||
env_keys,
|
||||
status: "running".to_string(),
|
||||
},
|
||||
);
|
||||
save(app, &sf)?;
|
||||
let _ = app.state.bump_sessions();
|
||||
Ok(id)
|
||||
}
|
||||
|
||||
/// Open a REPL session.
|
||||
pub fn start_repl(app: &App, id: &str, cwd: Option<String>) -> Result<()> {
|
||||
let mut sf = load(app)?;
|
||||
sf.sessions.insert(
|
||||
id.to_string(),
|
||||
SessionRecord {
|
||||
id: id.to_string(),
|
||||
kind: "repl".to_string(),
|
||||
agent: None,
|
||||
pid: None,
|
||||
cwd,
|
||||
project: None,
|
||||
started_at: crate::installers::now_rfc3339(),
|
||||
ended_at: None,
|
||||
exit_code: None,
|
||||
log: None,
|
||||
args: Vec::new(),
|
||||
env_keys: Vec::new(),
|
||||
status: "running".to_string(),
|
||||
},
|
||||
);
|
||||
save(app, &sf)?;
|
||||
let _ = app.state.bump_sessions();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Close the running session of an agent: the one matching the pid when
|
||||
/// given, otherwise the most recent one.
|
||||
pub fn finish_agent(app: &App, agent: &str, pid: Option<u32>, exit_code: i32, ok: bool) -> Result<()> {
|
||||
let mut sf = load(app)?;
|
||||
let mut candidates: Vec<String> = sf
|
||||
.sessions
|
||||
.iter()
|
||||
.filter(|(_, r)| r.kind == "agent" && r.status == "running" && r.agent.as_deref() == Some(agent))
|
||||
.map(|(k, _)| k.clone())
|
||||
.collect();
|
||||
candidates.sort_by(|a, b| sf.sessions[b].started_at.cmp(&sf.sessions[a].started_at));
|
||||
let key = if let Some(p) = pid {
|
||||
candidates
|
||||
.iter()
|
||||
.find(|k| sf.sessions[*k].pid == Some(p))
|
||||
.cloned()
|
||||
.or_else(|| candidates.first().cloned())
|
||||
} else {
|
||||
candidates.first().cloned()
|
||||
};
|
||||
if let Some(k) = key {
|
||||
if let Some(r) = sf.sessions.get_mut(&k) {
|
||||
r.ended_at = Some(crate::installers::now_rfc3339());
|
||||
r.exit_code = Some(exit_code);
|
||||
r.status = if ok { "stopped" } else { "failed" }.to_string();
|
||||
}
|
||||
save(app, &sf)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Close a REPL session.
|
||||
pub fn finish_repl(app: &App, id: &str) -> Result<()> {
|
||||
let mut sf = load(app)?;
|
||||
if let Some(r) = sf.sessions.get_mut(id) {
|
||||
r.ended_at = Some(crate::installers::now_rfc3339());
|
||||
r.exit_code = Some(0);
|
||||
r.status = "stopped".to_string();
|
||||
save(app, &sf)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Mark running sessions whose process is dead as interrupted. Returns the
|
||||
/// number of sessions reconciled. REPL sessions (no pid) are left untouched:
|
||||
/// the current REPL has no pid, and only 'am sessions' triggers this.
|
||||
pub fn reconcile(app: &App) -> Result<usize> {
|
||||
let mut sf = load(app)?;
|
||||
let now = crate::installers::now_rfc3339();
|
||||
let mut changed = 0;
|
||||
for r in sf.sessions.values_mut() {
|
||||
if r.status != "running" {
|
||||
continue;
|
||||
}
|
||||
let dead = match r.pid {
|
||||
Some(p) => !crate::process::is_running(p),
|
||||
None => false,
|
||||
};
|
||||
if dead {
|
||||
r.status = "interrupted".to_string();
|
||||
r.ended_at = Some(now.clone());
|
||||
changed += 1;
|
||||
}
|
||||
}
|
||||
if changed > 0 {
|
||||
save(app, &sf)?;
|
||||
}
|
||||
Ok(changed)
|
||||
}
|
||||
|
||||
/// Duration of a session in seconds (elapsed when still running).
|
||||
pub fn duration_s(r: &SessionRecord) -> Option<u64> {
|
||||
let start = chrono::DateTime::parse_from_rfc3339(&r.started_at).ok()?;
|
||||
let end = match &r.ended_at {
|
||||
Some(e) => chrono::DateTime::parse_from_rfc3339(e).ok()?,
|
||||
None => chrono::Utc::now().into(),
|
||||
};
|
||||
let secs = end
|
||||
.with_timezone(&chrono::Utc)
|
||||
.signed_duration_since(start.with_timezone(&chrono::Utc))
|
||||
.num_seconds();
|
||||
Some(secs.max(0) as u64)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use clap::Parser;
|
||||
|
||||
fn test_app(tag: &str) -> App {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let cfg = dir.path().join("config.yaml");
|
||||
std::fs::write(
|
||||
&cfg,
|
||||
concat!(
|
||||
"version: \"1.0\"\n",
|
||||
"settings:\n",
|
||||
" auto_install_deps: false\n",
|
||||
" confirm_before_run: false\n",
|
||||
"agents: []\n",
|
||||
),
|
||||
)
|
||||
.unwrap();
|
||||
let cli = crate::cli::Cli::parse_from(["am", "--config", cfg.to_str().unwrap()]);
|
||||
let mut app = App::from_cli(cli).unwrap();
|
||||
let mut p = app.paths.clone();
|
||||
p.install_dir = dir.path().join("agents");
|
||||
p.bin_dir = dir.path().join("agents").join("bin");
|
||||
p.log_dir = dir.path().join("logs");
|
||||
p.state_file = dir.path().join("state.json");
|
||||
p.probe_cache_file = dir.path().join("probe-cache.json");
|
||||
p.config_dir = Some(dir.path().join("config"));
|
||||
app.paths = p;
|
||||
app.state = crate::state::StateStore::new(dir.path().join("state.json"));
|
||||
let _ = tag;
|
||||
app
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn agent_session_lifecycle() {
|
||||
let app = test_app("sess");
|
||||
let id = start_agent(&app, "claude-code", 4242, Some("/tmp".into()), vec![], vec![], None).unwrap();
|
||||
let sf = load(&app).unwrap();
|
||||
let r = sf.sessions.get(&id).unwrap();
|
||||
assert_eq!(r.status, "running");
|
||||
assert_eq!(r.pid, Some(4242));
|
||||
assert_eq!(app.state.load().unwrap().sessions_count, 1);
|
||||
|
||||
finish_agent(&app, "claude-code", Some(4242), 0, true).unwrap();
|
||||
let sf = load(&app).unwrap();
|
||||
let r = sf.sessions.get(&id).unwrap();
|
||||
assert_eq!(r.status, "stopped");
|
||||
assert_eq!(r.exit_code, Some(0));
|
||||
assert!(r.ended_at.is_some());
|
||||
assert!(duration_s(r).is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reconcile_marks_dead_pid_interrupted() {
|
||||
let app = test_app("recon");
|
||||
let id = start_agent(&app, "aider", u32::MAX - 1, None, vec![], vec![], None).unwrap();
|
||||
let changed = reconcile(&app).unwrap();
|
||||
assert_eq!(changed, 1);
|
||||
let sf = load(&app).unwrap();
|
||||
assert_eq!(sf.sessions.get(&id).unwrap().status, "interrupted");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn repl_session_lifecycle() {
|
||||
let app = test_app("repl");
|
||||
start_repl(&app, "s1", None).unwrap();
|
||||
finish_repl(&app, "s1").unwrap();
|
||||
let sf = load(&app).unwrap();
|
||||
assert_eq!(sf.sessions.get("s1").unwrap().status, "stopped");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn finish_matches_by_pid_first() {
|
||||
let app = test_app("multi");
|
||||
start_agent(&app, "jcode", 100, None, vec![], vec![], None).unwrap();
|
||||
start_agent(&app, "jcode", 200, None, vec![], vec![], None).unwrap();
|
||||
finish_agent(&app, "jcode", Some(200), 1, false).unwrap();
|
||||
let sf = load(&app).unwrap();
|
||||
let stopped: Vec<_> = sf
|
||||
.sessions
|
||||
.values()
|
||||
.filter(|r| r.status == "stopped" || r.status == "failed")
|
||||
.collect();
|
||||
assert_eq!(stopped.len(), 1);
|
||||
assert_eq!(stopped[0].pid, Some(200));
|
||||
assert_eq!(stopped[0].exit_code, Some(1));
|
||||
assert_eq!(stopped[0].status, "failed");
|
||||
}
|
||||
}
|
||||
@@ -155,6 +155,13 @@ impl StateStore {
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Increment the total session counter (one per session start).
|
||||
pub fn bump_sessions(&self) -> Result<()> {
|
||||
let mut sf = self.load()?;
|
||||
sf.sessions_count = sf.sessions_count.saturating_add(1);
|
||||
self.save(&sf)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
//! sessions command and session registry: lifecycle, counter, reconciliation
|
||||
//! and CLI parsing.
|
||||
|
||||
mod common;
|
||||
|
||||
use agent_manager::cli::{Cli, Command};
|
||||
use agent_manager::commands::sessions_cmd;
|
||||
use agent_manager::sessions;
|
||||
use clap::Parser;
|
||||
|
||||
#[test]
|
||||
fn cli_parses_sessions() {
|
||||
let cli = Cli::parse_from(["am", "sessions", "--status", "failed", "--show", "abc"]);
|
||||
match &cli.command {
|
||||
Some(Command::Sessions {
|
||||
agent,
|
||||
status,
|
||||
show,
|
||||
..
|
||||
}) => {
|
||||
assert!(agent.is_none());
|
||||
assert_eq!(status.as_deref(), Some("failed"));
|
||||
assert_eq!(show.as_deref(), Some("abc"));
|
||||
}
|
||||
other => panic!("unexpected command: {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn agent_session_roundtrip_and_counter() {
|
||||
let app = common::test_app("sess", &["sessions"]);
|
||||
let id = sessions::start_agent(
|
||||
&app,
|
||||
"claude-code",
|
||||
4242,
|
||||
Some("/tmp".to_string()),
|
||||
vec![],
|
||||
vec![],
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
let sf = sessions::load(&app).unwrap();
|
||||
let r = sf.sessions.get(&id).unwrap();
|
||||
assert_eq!(r.status, "running");
|
||||
assert_eq!(r.agent.as_deref(), Some("claude-code"));
|
||||
assert_eq!(app.state.load().unwrap().sessions_count, 1);
|
||||
|
||||
sessions::finish_agent(&app, "claude-code", Some(4242), 0, true).unwrap();
|
||||
let sf = sessions::load(&app).unwrap();
|
||||
let r = sf.sessions.get(&id).unwrap();
|
||||
assert_eq!(r.status, "stopped");
|
||||
assert_eq!(r.exit_code, Some(0));
|
||||
assert!(r.ended_at.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn command_lists_sessions() {
|
||||
let app = common::test_app("sesscmd", &["sessions"]);
|
||||
sessions::start_agent(&app, "aider", 4242, None, vec![], vec![], None).unwrap();
|
||||
let code = sessions_cmd::run(&app, None, None, None, None).unwrap();
|
||||
assert_eq!(code, 0);
|
||||
// --json path returns the row set without error.
|
||||
sessions::finish_agent(&app, "aider", Some(4242), 0, true).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn show_one_accepts_known_id() {
|
||||
let app = common::test_app("sessshow", &["sessions"]);
|
||||
let id = sessions::start_agent(&app, "jcode", 99, None, vec![], vec![], None).unwrap();
|
||||
let code = sessions_cmd::run(&app, None, None, None, Some(&id)).unwrap();
|
||||
assert_eq!(code, 0);
|
||||
}
|
||||
Reference in New Issue
Block a user