266 lines
8.4 KiB
Rust
266 lines
8.4 KiB
Rust
//! 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;
|
|
use std::path::PathBuf;
|
|
|
|
#[derive(Serialize)]
|
|
pub struct SessionRow {
|
|
pub id: String,
|
|
pub kind: String,
|
|
pub agent: Option<String>,
|
|
pub status: String,
|
|
pub pid: Option<u32>,
|
|
pub cwd: Option<String>,
|
|
pub project: Option<String>,
|
|
pub started_at: String,
|
|
pub ended_at: Option<String>,
|
|
pub duration_s: Option<u64>,
|
|
pub exit_code: Option<i32>,
|
|
pub 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(),
|
|
}
|
|
}
|
|
|
|
/// Rows for the web API (issue #54): same shape as `am sessions --json`.
|
|
/// Unlike `run`, it never reconciles the registry (the web is read-only).
|
|
pub fn list_rows(
|
|
app: &App,
|
|
agent: Option<&str>,
|
|
project: Option<&str>,
|
|
status: Option<&str>,
|
|
) -> Result<Vec<SessionRow>> {
|
|
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));
|
|
Ok(rows)
|
|
}
|
|
|
|
pub fn run(
|
|
app: &App,
|
|
agent: Option<&str>,
|
|
project: Option<&str>,
|
|
status: Option<&str>,
|
|
show: Option<&str>,
|
|
resume: Option<&str>,
|
|
export: Option<&str>,
|
|
output: Option<&str>,
|
|
retention: Option<u64>,
|
|
) -> Result<i32> {
|
|
if let Some(id) = export {
|
|
return export_one(app, id, output);
|
|
}
|
|
if let Some(days) = retention {
|
|
return purge(app, days);
|
|
}
|
|
if let Some(id) = show {
|
|
return show_one(app, id);
|
|
}
|
|
if let Some(id) = resume {
|
|
return resume_session(app, id);
|
|
}
|
|
sessions::reconcile(app)?;
|
|
let rows = list_rows(app, agent, project, status)?;
|
|
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)
|
|
}
|
|
|
|
/// Resume a finished session: relaunch the agent in the background with
|
|
/// its recorded arguments and working directory (issue #32).
|
|
fn resume_session(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 r.kind != "agent" {
|
|
bail!("session '{id}' is a REPL session — start it again with 'am'");
|
|
}
|
|
if r.status == "running" {
|
|
app.log.info(&format!("session '{id}' is still running"));
|
|
return Ok(0);
|
|
}
|
|
let Some(agent_name) = r.agent.clone() else {
|
|
bail!("session '{id}' has no agent");
|
|
};
|
|
require_agent(app, &agent_name)?;
|
|
if app.dry_run() {
|
|
app.log.dry(format!(
|
|
"would resume session {id}: start {} --background with {} arg(s)",
|
|
agent_name,
|
|
r.args.len()
|
|
));
|
|
return Ok(0);
|
|
}
|
|
let cwd = r.cwd.as_deref().map(std::path::Path::new);
|
|
let opts = crate::cli::StartArgs {
|
|
agent: Some(agent_name.clone()),
|
|
background: true,
|
|
args: r.args.clone(),
|
|
..Default::default()
|
|
};
|
|
app.log.info(&format!(
|
|
"resuming session {id}: {} ({} arg(s))",
|
|
agent_name,
|
|
r.args.len()
|
|
));
|
|
crate::commands::run_cmd::start_with_cwd(app, &opts, cwd)
|
|
}
|
|
|
|
/// "20260815_143926_a1b2c3" -> "20260815_143926"
|
|
fn short_id(id: &str) -> String {
|
|
id.chars().take(15).collect()
|
|
}
|
|
|
|
/// Issue #53: 'am sessions export <id> [--output <file>]' — write a
|
|
/// complete, self-contained JSON export (metadata, commands, log excerpt)
|
|
/// plus a human-readable rendering. Default destination:
|
|
/// ./am-session-<id>.json in the current directory.
|
|
fn export_one(app: &App, id: &str, output: Option<&str>) -> Result<i32> {
|
|
let export = sessions::export_session(app, id)?;
|
|
let dest = match output {
|
|
Some(o) => PathBuf::from(o),
|
|
None => std::env::current_dir()
|
|
.unwrap_or_else(|_| PathBuf::from("."))
|
|
.join(format!("am-session-{}.json", export.session.id)),
|
|
};
|
|
let text = serde_json::to_string_pretty(&export)?;
|
|
std::fs::write(&dest, text).map_err(|e| {
|
|
anyhow::anyhow!("cannot write export {}: {e}", dest.display())
|
|
})?;
|
|
app.log.success(&format!(
|
|
"exported session {} ({} command(s), {} log bytes) to {}",
|
|
export.session.id,
|
|
export.commands.len(),
|
|
export.log_excerpt.len(),
|
|
dest.display()
|
|
));
|
|
app.log.info(&format!(
|
|
"JSON complet + rendu lisible ('readable') — compatible archivage manuel / am import"
|
|
));
|
|
Ok(0)
|
|
}
|
|
|
|
/// Issue #53: 'am sessions --retention <days>' — purge finished sessions
|
|
/// older than the threshold. The primary event journal is untouched; the
|
|
/// purge is journaled as EventKind::Prune.
|
|
fn purge(app: &App, days: u64) -> Result<i32> {
|
|
if days == 0 {
|
|
bail!("retention must be at least 1 day");
|
|
}
|
|
if app.dry_run() {
|
|
app.log
|
|
.dry(format!("would purge sessions finished more than {days} days ago"));
|
|
return Ok(0);
|
|
}
|
|
let purged = sessions::purge_expired(app, days)?;
|
|
match purged {
|
|
0 => app.log.info(&format!(
|
|
"no session finished more than {days} days ago — nothing to purge"
|
|
)),
|
|
n => app.log.success(&format!("purged {n} session(s) older than {days} days")),
|
|
}
|
|
Ok(0)
|
|
}
|
|
|
|
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)
|
|
}
|
|
}
|