Removes the retained-worktree store end to end: the daemon's state-dir store, GET/DELETE /api/worktrees, `maestro worktrees list|reclaim`, the TUI overlay behind `w`, and the web `#/worktrees` view and header link. The kill guard stays. A forced kill and a dirty natural exit now leave the worktree on disk untouched instead of parking it in a store, so removing one is an ordinary `git worktree remove`. A pristine tree is still removed on exit. DELETE /api/sessions/:id always answers 204. The header's debug button also goes; Ctrl+Shift+D and `?hud` still toggle the HUD. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
261 lines
8.8 KiB
Rust
261 lines
8.8 KiB
Rust
//! `maestro list / spawn / kill` — daemon HTTP calls + console formatting.
|
|
|
|
use std::collections::HashMap;
|
|
use std::io::Write;
|
|
|
|
use maestro_protocol::display::abbreviate_dir;
|
|
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<(), 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} {:<40} {}",
|
|
"ID", "STATUS", "ACTIVITY", "DIR", "COMMAND", "BLOCKED"
|
|
)
|
|
.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",
|
|
ActivityState::Error => "error",
|
|
};
|
|
// 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(),
|
|
};
|
|
// #83: what an unforced kill would be refused for, in InTheWay's own
|
|
// words; blank while nothing blocks (or hasn't been probed yet).
|
|
let blocked = blocked_label(s);
|
|
writeln!(
|
|
out,
|
|
" {:<36} {:<8} {:<9} {:<28} {:<40} {blocked}",
|
|
s.id, status, activity, dir, command
|
|
)
|
|
.ok();
|
|
}
|
|
}
|
|
|
|
/// The blocked-work indicator (#83), phrased by [`InTheWay`]'s `Display`.
|
|
/// Empty unless something actually blocks — "no unsaved work" would just be
|
|
/// noise on every row.
|
|
pub(crate) fn blocked_label(s: &Session) -> String {
|
|
s.blocked_work
|
|
.filter(|b| !b.is_clear())
|
|
.map(|b| b.to_string())
|
|
.unwrap_or_default()
|
|
}
|
|
|
|
#[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(),
|
|
activity_detail: None,
|
|
last_activity_at: chrono::Utc::now(),
|
|
activity_since: chrono::Utc::now(),
|
|
current_cwd: None,
|
|
blocked_work: 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");
|
|
}
|
|
|
|
/// #83: a blocked session shows what an unforced kill would be refused
|
|
/// for, in `InTheWay`'s own words; a clear or unprobed one shows nothing.
|
|
#[test]
|
|
fn shows_the_blocked_work_summary_only_when_something_blocks() {
|
|
use maestro_protocol::InTheWay;
|
|
|
|
let mut blocked = session("/tmp", "claude", 1);
|
|
blocked.blocked_work = Some(InTheWay {
|
|
uncommitted_files: 3,
|
|
unpushed_commits: 2,
|
|
..InTheWay::default()
|
|
});
|
|
let mut clear = session("/tmp", "bash", 2);
|
|
clear.blocked_work = Some(InTheWay::default());
|
|
let unprobed = session("/tmp", "zsh", 3);
|
|
|
|
let mut buf = Vec::new();
|
|
super::print_session_table(&mut buf, &[blocked, clear, unprobed], &info(""));
|
|
let out = String::from_utf8(buf).unwrap();
|
|
assert!(
|
|
out.contains("3 uncommitted files, 2 unpushed commits"),
|
|
"{out}"
|
|
);
|
|
// Clear and unprobed rows stay blank in the BLOCKED column.
|
|
let lines: Vec<&str> = out.lines().collect();
|
|
let header = lines.iter().position(|l| l.contains("BLOCKED")).unwrap();
|
|
for line in &lines[header + 1..] {
|
|
if !line.contains("uncommitted files") {
|
|
assert!(!line.contains("unsaved work"), "{line}");
|
|
}
|
|
}
|
|
}
|
|
}
|