This repository has been archived on 2026-10-08. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
maestro/crates/maestro-cli/src/sessions.rs
T
Miguel Palhas 31702e03e3 refactor(maestro): drop cleanup_on_kill from the wire and the CLI
Nothing has honoured WorktreeSpec.cleanup_on_kill since #56, and after
#79 and #81 survival is decided by what is in a worktree, not by what
the spawn asked for. A field that lies is worse than a missing one.

Removes the field from the protocol, the --cleanup-on-kill flag from
the CLI, and the tests asserting its default.
2026-08-24 12:08:41 +01:00

228 lines
7.3 KiB
Rust

//! `maestro list / spawn / kill` — daemon HTTP calls + console formatting.
use std::collections::HashMap;
use std::io::Write;
use maestro_protocol::{
ActivityState, ClientError, CreateSessionRequest, DaemonClient, DaemonInfo, Session,
SessionStatus, WorktreeSpec,
};
use serde_json::Value;
use crate::cli::{KillArgs, ListArgs, SpawnArgs};
/// `maestro list`
pub async fn run_list(
client: &DaemonClient,
args: &ListArgs,
out: &mut impl Write,
) -> Result<(), ClientError> {
let sessions = client.list_sessions().await?;
let info = client.daemon_info().await.unwrap_or_default();
if args.json {
let body = serde_json::json!({ "sessions": sessions });
writeln!(out, "{}", serde_json::to_string_pretty(&body)?).ok();
return Ok(());
}
print_session_table(out, &sessions, &info);
Ok(())
}
/// `maestro spawn`
pub async fn run_spawn(client: &DaemonClient, args: SpawnArgs) -> Result<Session, ClientError> {
let mut env: HashMap<String, String> = HashMap::new();
for (k, v) in args.env.iter() {
env.insert(k.clone(), v.clone());
}
let metadata: HashMap<String, Value> = HashMap::new();
let worktree = match (args.worktree_from.as_ref(), args.branch.as_ref()) {
(Some(repo), Some(branch)) => Some(WorktreeSpec {
base_repo: repo.clone(),
branch: branch.clone(),
create_branch: !args.existing_branch,
}),
_ => None,
};
let body = CreateSessionRequest {
cwd: args.cwd.clone(),
command: args.command.clone(),
env,
metadata,
worktree,
};
client.create_session(&body).await
}
/// `maestro kill`. A forced kill (#81) retains the guarded worktree instead
/// of removing it; the outcome names what was kept so dispatch can say where
/// the work went.
pub async fn run_kill(
client: &DaemonClient,
args: &KillArgs,
) -> Result<maestro_protocol::KillOutcome, ClientError> {
client.kill_session(args.session_id, args.force).await
}
#[allow(clippy::write_literal)]
fn print_session_table(out: &mut impl Write, sessions: &[Session], info: &DaemonInfo) {
if sessions.is_empty() {
writeln!(out, "no sessions").ok();
return;
}
let mut sorted: Vec<&Session> = sessions.iter().collect();
sorted.sort_by_key(|s| s.created_at);
if !info.hostname.is_empty() {
let warn = if maestro_protocol::is_compatible(&info.version) {
""
} else {
" (protocol mismatch)"
};
let version = if info.version.is_empty() {
String::new()
} else {
format!(" maestro {}{warn}", info.version)
};
writeln!(out, "\n{}{version}", info.hostname).ok();
}
writeln!(
out,
" {:<36} {:<8} {:<9} {:<28} {}",
"ID", "STATUS", "ACTIVITY", "DIR", "COMMAND"
)
.ok();
for s in sorted {
// The pane's live cwd if observed, else the spawn dir, home-abbreviated.
let dir = abbreviate_dir(s.current_cwd.as_deref().unwrap_or(&s.cwd), &info.home_dir);
let status = match s.status {
SessionStatus::Running => "running",
SessionStatus::Dead => "dead",
};
let activity = match s.activity {
ActivityState::Active => "active",
ActivityState::Idle => "idle",
ActivityState::AwaitingInput => "awaiting",
};
// Show the live foreground command alongside the spawn command when
// the daemon has observed one that differs (NAP-340).
let command = match &s.foreground {
Some(fg) if fg != &s.command => format!("{} [{}]", s.command, fg),
_ => s.command.clone(),
};
writeln!(
out,
" {:<36} {:<8} {:<9} {:<28} {command}",
s.id, status, activity, dir
)
.ok();
}
}
/// Render a path with the daemon's home dir collapsed to `~`. Mirrors the web
/// UI's abbreviation so CLI and browser show the same directory shorthand.
pub(crate) fn abbreviate_dir(path: &std::path::Path, home: &str) -> String {
let p = path.to_string_lossy();
if !home.is_empty() {
if p == home {
return "~".into();
}
if let Some(rest) = p.strip_prefix(&format!("{home}/")) {
return format!("~/{rest}");
}
}
p.into_owned()
}
#[cfg(test)]
mod tests {
use std::collections::HashMap;
use std::path::PathBuf;
use chrono::{TimeZone, Utc};
use maestro_protocol::{ActivityState, Session, SessionStatus};
use uuid::Uuid;
use super::DaemonInfo;
fn session(cwd: &str, command: &str, id: u128) -> Session {
Session {
id: Uuid::from_u128(id),
cwd: PathBuf::from(cwd),
command: command.into(),
env: HashMap::new(),
metadata: HashMap::new(),
worktree: None,
status: SessionStatus::Running,
created_at: Utc.with_ymd_and_hms(2026, 5, 25, 12, 0, 0).unwrap(),
foreground: None,
activity: Default::default(),
last_activity_at: chrono::Utc::now(),
current_cwd: None,
}
}
fn info(home: &str) -> DaemonInfo {
DaemonInfo {
hostname: "naps-laptop".into(),
home_dir: home.into(),
..Default::default()
}
}
#[test]
fn formats_table() {
let sessions = vec![
session("/home/naps/maestro", "claude", 1),
session("/srv/work/api", "bash", 2),
];
let mut buf = Vec::new();
super::print_session_table(&mut buf, &sessions, &info("/home/naps"));
let s = String::from_utf8(buf).unwrap();
assert!(s.contains("naps-laptop"), "{s}");
// Home dir collapses to ~ in the DIR column.
assert!(s.contains("~/maestro"), "{s}");
// Outside the home dir → path shown verbatim.
assert!(s.contains("/srv/work/api"), "{s}");
assert!(s.contains("running"), "{s}");
assert!(s.contains("ACTIVITY"), "{s}");
assert!(s.contains("DIR"), "{s}");
assert!(s.contains("idle"), "{s}");
}
#[test]
fn live_cwd_overrides_spawn_dir_in_table() {
let mut s = session("/home/naps/maestro", "bash", 1);
s.current_cwd = Some(PathBuf::from("/home/naps/maestro/crates/maestro-cli"));
let mut buf = Vec::new();
super::print_session_table(&mut buf, &[s], &info("/home/naps"));
let out = String::from_utf8(buf).unwrap();
assert!(out.contains("~/maestro/crates/maestro-cli"), "{out}");
}
#[test]
fn shows_activity_and_foreground() {
let mut s1 = session("/tmp", "bash", 1);
s1.activity = ActivityState::AwaitingInput;
s1.foreground = Some("claude".into()); // differs from spawn command
let mut buf = Vec::new();
super::print_session_table(&mut buf, &[s1], &info(""));
let out = String::from_utf8(buf).unwrap();
assert!(out.contains("awaiting"), "{out}");
// Live foreground surfaces next to the spawn command.
assert!(out.contains("bash [claude]"), "{out}");
}
#[test]
fn empty_sessions() {
let mut buf = Vec::new();
super::print_session_table(&mut buf, &[], &info(""));
assert_eq!(String::from_utf8(buf).unwrap(), "no sessions\n");
}
}