From bb2f439302917c13ae1b6dd1c108542d9dd23f96 Mon Sep 17 00:00:00 2001 From: Miguel Palhas Date: Mon, 24 Aug 2026 11:20:00 +0100 Subject: [PATCH] feat(maestro): kill outcome payload and force query in protocol DELETE /api/sessions/:id gains a force query param (#81). A forced kill answers 200 with KillOutcome, naming what was retained and why; the unforced kill keeps its 204. DaemonClient::kill_session takes the flag. --- crates/maestro-protocol/src/client.rs | 22 +++++++++++++++----- crates/maestro-protocol/src/lib.rs | 29 +++++++++++++++++++++++++++ crates/maestro-protocol/src/routes.rs | 4 ++++ 3 files changed, 50 insertions(+), 5 deletions(-) diff --git a/crates/maestro-protocol/src/client.rs b/crates/maestro-protocol/src/client.rs index 7d03e38..c4bfce4 100644 --- a/crates/maestro-protocol/src/client.rs +++ b/crates/maestro-protocol/src/client.rs @@ -16,7 +16,7 @@ use uuid::Uuid; use crate::routes::{self, UrlError}; use crate::{ ApiError, BranchInfo, CreateSessionRequest, DaemonInfo, DirListing, ErrorInfo, GitRoot, - Project, RecentDirs, Session, + KillOutcome, Project, RecentDirs, Session, }; /// Time budget for establishing a TCP connection to the daemon. @@ -114,12 +114,24 @@ impl DaemonClient { self.post(routes::SESSIONS, req).await } - pub async fn kill_session(&self, id: Uuid) -> Result<(), ClientError> { + /// Kill a session. With `force`, the daemon overrides the kill refusal + /// (#79) and retains the worktree instead of removing it (#81); the + /// outcome then names what was kept and where. + pub async fn kill_session(&self, id: Uuid, force: bool) -> Result { + let query: &[(&str, &str)] = if force { + &[(routes::FORCE_QUERY, "true")] + } else { + &[] + }; let resp = self - .send(Method::DELETE, &routes::session(id), &[], None::<&()>) + .send(Method::DELETE, &routes::session(id), query, None::<&()>) .await?; - check(resp).await?; - Ok(()) + let resp = check(resp).await?; + // An unforced kill keeps its historical `204 No Content`. + if resp.status() == reqwest::StatusCode::NO_CONTENT { + return Ok(KillOutcome { retained: None }); + } + json(resp).await } pub async fn daemon_info(&self) -> Result { diff --git a/crates/maestro-protocol/src/lib.rs b/crates/maestro-protocol/src/lib.rs index ba4ab02..3b88d6d 100644 --- a/crates/maestro-protocol/src/lib.rs +++ b/crates/maestro-protocol/src/lib.rs @@ -195,6 +195,19 @@ pub struct RetainedWorktree { pub in_the_way: InTheWay, } +/// Body of a successful forced `DELETE /api/sessions/:id?force=true` (#81). +/// A forced kill ends the session and retains its worktree instead of +/// removing it; this names what was kept, where, and why, so a client can say +/// where the work went rather than leaving the operator to wonder. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct KillOutcome { + /// Set when the worktree was retained with reason `force-killed`; `None` + /// when there was nothing to retain — no worktree at all, or one whose + /// guard was clear, which a forced kill removes as usual. + #[serde(default)] + pub retained: Option, +} + impl std::fmt::Display for InTheWay { /// "3 uncommitted files, 2 unpushed commits" — zero parts are omitted; /// all-zero renders "no unsaved work". This is the phrasing every client @@ -679,6 +692,22 @@ mod tests { ); } + #[test] + fn kill_outcome_round_trips_a_retained_worktree() { + let outcome = KillOutcome { + retained: Some(sample_retained_worktree()), + }; + assert_eq!(outcome, round_trip(&outcome)); + } + + /// A kill with nothing to retain — no worktree, or a clean one removed as + /// usual — decodes from an absent field. + #[test] + fn kill_outcome_defaults_to_nothing_retained() { + let outcome: KillOutcome = serde_json::from_value(serde_json::json!({})).unwrap(); + assert_eq!(outcome.retained, None); + } + #[test] fn catalog_event_round_trips_and_tags() { let session = sample_session(); diff --git a/crates/maestro-protocol/src/routes.rs b/crates/maestro-protocol/src/routes.rs index 7878813..7b3e024 100644 --- a/crates/maestro-protocol/src/routes.rs +++ b/crates/maestro-protocol/src/routes.rs @@ -38,6 +38,10 @@ pub const PATH_QUERY: &str = "path"; /// Name of the query parameter [`NAMES`] takes. pub const COUNT_QUERY: &str = "count"; +/// Name of the boolean query parameter `DELETE /api/sessions/:id` takes (#81). +/// `force=true` overrides the kill refusal and retains the worktree. +pub const FORCE_QUERY: &str = "force"; + /// `DELETE /api/sessions/:id`. pub fn session(id: Uuid) -> String { format!("{SESSIONS}/{id}")