trusty-mpm 1.8.2

trusty-mpm: unified multi-agent orchestration platform (core, daemon, CLI, TUI, Telegram)
//! Managed session-manager methods for [`DaemonClient`].
//!
//! Why: the `/api/v1/sessions/managed/*` route family is the convergence target
//! for every managed-session UI — the `tm` CLI today, STUI (#1272) and TELUI
//! (#1433) next. Before this module each surface hand-rolled `reqwest` calls and
//! ad-hoc response structs; gathering them as typed [`DaemonClient`] methods wires
//! each endpoint exactly once. They live in a sibling file (not `mod.rs`) so the
//! 463-SLOC client `mod.rs` stays under the 500-SLOC cap; this file can reach the
//! `base`/`http` fields because they are `pub(in crate::client::http_client)`.
//! What: one async method per managed endpoint, each building the URL from
//! [`DaemonClient::base`], sending via the shared `reqwest::Client`, checking the
//! status, and deserializing the typed wire DTO from `super::types`.
//! Test: `cargo test -p trusty-mpm` (`managed_*` round-trip tests in `tests.rs`)
//! covers wire-shape deserialization; live HTTP is exercised by the daemon's
//! `tests/session_manager_mvp.rs` integration suite.

use anyhow::Context;

use super::DaemonClient;
use super::config;
use super::error::response_or_body_error;
use super::types::{
    FleetByProjectWireResponse, FleetProjectGroupWire, ManagedActivityResponse,
    ManagedAdoptRequest, ManagedAdoptResponse, ManagedAnswerRequest, ManagedAnswerResponse,
    ManagedAttachCmdResponse, ManagedDecommissionOutcome, ManagedListResponse,
    ManagedSendInputRequest, ManagedSendInputResponse, ManagedSessionSummary, ManagedSpawnRequest,
    ManagedSpawnResponse,
};

impl DaemonClient {
    /// List every managed session via `GET /api/v1/sessions/managed`.
    ///
    /// Why: the managed-session list view and the id-or-name resolver both read
    /// the full session list from this one call.
    /// What: GETs the managed-list endpoint and returns the `sessions` array.
    /// Test: `managed_list_response_deserializes` covers the wire shape; live
    /// HTTP via `tests/session_manager_mvp.rs`.
    pub async fn list_managed_sessions(&self) -> anyhow::Result<Vec<ManagedSessionSummary>> {
        let url = "/api/v1/sessions/managed".to_string();
        let body: ManagedListResponse = self
            .get(&url)
            .send()
            .await?
            .error_for_status()?
            .json()
            .await?;
        Ok(body.sessions)
    }

    /// Fetch managed sessions grouped by project via `GET /api/v1/sessions/managed/fleet`.
    ///
    /// Why: the fleet-by-project view groups live sessions under their owning
    /// project so the Telegram `/fleet` command and the TUI can render a
    /// per-project breakdown without re-implementing the resolver logic on the
    /// client side.
    /// What: GETs the fleet endpoint and returns the `projects` array as a vec
    /// of `(project_name, repo_url, sessions)` tuples via [`FleetProjectGroupWire`].
    /// Test: `managed_fleet_response_deserializes` in `tests.rs`; live HTTP via
    /// `fleet_route_groups_by_project` in `tests/session_manager_mvp.rs`.
    pub async fn fleet_managed_sessions(&self) -> anyhow::Result<Vec<FleetProjectGroupWire>> {
        let url = "/api/v1/sessions/managed/fleet".to_string();
        let body: FleetByProjectWireResponse = self
            .get(&url)
            .send()
            .await?
            .error_for_status()?
            .json()
            .await?;
        Ok(body.projects)
    }

    /// Fetch one managed session via `GET /api/v1/sessions/managed/{id}`.
    ///
    /// Why: a detail view needs a single session without listing every one.
    /// What: GETs the per-session endpoint and returns the [`ManagedSessionSummary`];
    /// a 404 (and any other non-success status) surfaces as an `Err`.
    /// Test: live HTTP via `tests/session_manager_mvp.rs`.
    pub async fn get_managed_session(&self, id: &str) -> anyhow::Result<ManagedSessionSummary> {
        let url = format!("/api/v1/sessions/managed/{id}");
        let summary = self
            .get(&url)
            .send()
            .await?
            .error_for_status()?
            .json()
            .await?;
        Ok(summary)
    }

    /// Spawn a managed session via `POST /api/v1/sessions/managed`.
    ///
    /// Why: provision an isolated workspace and start a harness in it. The
    /// server handler runs `inproject::ensure_base_clone` +
    /// `create_session_worktree` SYNCHRONOUSLY inside the request (fetch,
    /// agent/skill deploy) for both the clone-based and in-project spawn
    /// paths — a first spawn against a newly-registered project can easily
    /// exceed [`config::DEFAULT_REQUEST_TIMEOUT`]'s 10s client-level default.
    /// Applying that default here would hard-fail the client mid-provision
    /// while the daemon keeps working, orphaning a session the caller
    /// believes failed, so this call opts into the longer
    /// [`config::PROVISION_REQUEST_TIMEOUT`] instead (issue #2471 review
    /// follow-up).
    /// What: POSTs `req` with the provision-scoped request timeout and
    /// returns the daemon's [`ManagedSpawnResponse`]. A non-success status
    /// goes through [`response_or_body_error`] (#2457 follow-up to #2496's
    /// call-site sweep, which explicitly deferred `managed.rs`) so the
    /// daemon's rejection reason — e.g. `DeliverableNotFound`'s "deliverable
    /// not found: {id}", or `ProjectNotFoundForRepoUrl`'s message naming the
    /// offending repo_url — survives instead of being discarded down to a
    /// bare "404 Not Found" by `error_for_status`.
    /// Test: live HTTP via `tests/session_manager_mvp.rs`;
    /// `config::tests::default_client_uses_default_bounds` pins the constant;
    /// `spawn_managed_session_surfaces_daemon_error_body` in `tests.rs`.
    pub async fn spawn_managed_session(
        &self,
        req: &ManagedSpawnRequest,
    ) -> anyhow::Result<ManagedSpawnResponse> {
        let url = "/api/v1/sessions/managed".to_string();
        let resp = self
            .post(&url)
            .json(req)
            .timeout(config::PROVISION_REQUEST_TIMEOUT)
            .send()
            .await?;
        response_or_body_error(resp).await?.json().await
    }

    /// Adopt an EXISTING tmux session via `POST /api/v1/sessions/managed/adopt`
    /// (#1433).
    ///
    /// Why: register a durable managed record for a pane the operator already has,
    /// so it can be driven through the full managed surface. Distinct from the
    /// stateless `POST /tmux/adopt` snapshot endpoint.
    /// What: POSTs `req` and returns the daemon's [`ManagedAdoptResponse`]. The
    /// daemon answers typed 404 (no such pane) / 409 (already adopted) / 400 (bad
    /// runtime) errors; rather than swallow them, a non-success status is flattened
    /// into an `anyhow` error carrying the HTTP status and the response body so the
    /// caller can surface both.
    /// Test: `managed_adopt_request_serializes` /
    /// `managed_adopt_response_deserializes` cover the wire shapes; live HTTP via
    /// `tests/session_manager_mvp.rs`.
    pub async fn adopt_managed_session(
        &self,
        req: &ManagedAdoptRequest,
    ) -> anyhow::Result<ManagedAdoptResponse> {
        let url = "/api/v1/sessions/managed/adopt".to_string();
        let resp = self.post(&url).json(req).send().await?;
        let status = resp.status();
        if !status.is_success() {
            let body = resp.text().await.unwrap_or_default();
            anyhow::bail!("adopt failed (HTTP {status}): {body}");
        }
        let adopted = resp.json().await.context("decoding adopt response body")?;
        Ok(adopted)
    }

    /// Inject text into a session's pane via `POST .../{id}/send`.
    ///
    /// Why: drive a harness with a prompt without attaching to tmux.
    /// What: POSTs `{ text }` and returns the [`ManagedSendInputResponse`].
    /// Test: live HTTP via `tests/session_manager_mvp.rs`.
    pub async fn send_to_managed_session(
        &self,
        id: &str,
        text: &str,
    ) -> anyhow::Result<ManagedSendInputResponse> {
        let url = format!("/api/v1/sessions/managed/{id}/send");
        let resp = self
            .post(&url)
            .json(&ManagedSendInputRequest {
                text: text.to_string(),
            })
            .send()
            .await?
            .error_for_status()?
            .json()
            .await?;
        Ok(resp)
    }

    /// Answer a pending decision via `POST .../{id}/answer`.
    ///
    /// Why: unblock a harness waiting on an operator decision.
    /// What: POSTs `{ answer }` and returns the [`ManagedAnswerResponse`].
    /// Test: live HTTP via `tests/session_manager_mvp.rs`.
    pub async fn answer_managed_session(
        &self,
        id: &str,
        answer: &str,
    ) -> anyhow::Result<ManagedAnswerResponse> {
        let url = format!("/api/v1/sessions/managed/{id}/answer");
        let resp = self
            .post(&url)
            .json(&ManagedAnswerRequest {
                answer: answer.to_string(),
            })
            .send()
            .await?
            .error_for_status()?
            .json()
            .await?;
        Ok(resp)
    }

    /// Inspect a session's activity via `GET .../{id}/activity`.
    ///
    /// Why: see what a session is doing without attaching; the raw pane is always
    /// returned so the caller can reason over it even without an LLM key.
    /// What: GETs the activity endpoint and returns the [`ManagedActivityResponse`].
    /// Test: live HTTP via `tests/session_manager_mvp.rs`.
    pub async fn managed_session_activity(
        &self,
        id: &str,
    ) -> anyhow::Result<ManagedActivityResponse> {
        let url = format!("/api/v1/sessions/managed/{id}/activity");
        let resp = self
            .get(&url)
            .send()
            .await?
            .error_for_status()?
            .json()
            .await?;
        Ok(resp)
    }

    /// Fetch the tmux attach command via `GET .../{id}/attach-cmd`.
    ///
    /// Why: an operator needs the exact `tmux attach` invocation to take over.
    /// What: GETs the attach-cmd endpoint and returns the [`ManagedAttachCmdResponse`].
    /// Test: live HTTP via `tests/session_manager_mvp.rs`.
    pub async fn managed_session_attach_cmd(
        &self,
        id: &str,
    ) -> anyhow::Result<ManagedAttachCmdResponse> {
        let url = format!("/api/v1/sessions/managed/{id}/attach-cmd");
        let resp = self
            .get(&url)
            .send()
            .await?
            .error_for_status()?
            .json()
            .await?;
        Ok(resp)
    }

    /// Stop a session's runtime via `POST .../{id}/runtime-stop`.
    ///
    /// Why: a session endures beyond its runtime; this kills only the tmux session
    /// and harness process, preserving the workspace for a later resume.
    /// What: POSTs to the runtime-stop endpoint and returns the updated summary.
    /// Test: live HTTP via `tests/session_manager_mvp.rs`.
    pub async fn runtime_stop_managed_session(
        &self,
        id: &str,
    ) -> anyhow::Result<ManagedSessionSummary> {
        let url = format!("/api/v1/sessions/managed/{id}/runtime-stop");
        let resp = self
            .post(&url)
            .send()
            .await?
            .error_for_status()?
            .json()
            .await?;
        Ok(resp)
    }

    /// Resume a stopped session via `POST .../{id}/resume`.
    ///
    /// Why: after a runtime-stop the workspace is still on disk; resume re-spawns
    /// the runtime there without re-cloning.
    /// What: POSTs to the resume endpoint and returns the updated summary. The
    /// daemon answers with typed 404/409/500 errors; rather than swallow them,
    /// a non-success status is flattened into an `anyhow` error that carries the
    /// HTTP status and the response body text so the caller can surface both.
    /// Test: live HTTP via `tests/session_manager_mvp.rs`.
    pub async fn resume_managed_session(&self, id: &str) -> anyhow::Result<ManagedSessionSummary> {
        let url = format!("/api/v1/sessions/managed/{id}/resume");
        let resp = self.post(&url).send().await?;
        let status = resp.status();
        if !status.is_success() {
            let body = resp.text().await.unwrap_or_default();
            anyhow::bail!("resume failed (HTTP {status}): {body}");
        }
        let summary = resp.json().await.context("decoding resume response body")?;
        Ok(summary)
    }

    /// Decommission a session via `POST .../{id}/decommission`.
    ///
    /// Why: the only operation that permanently removes the workspace directory;
    /// it is terminal — no resume is possible afterward. Decommission does NOT
    /// always remove the workspace, so the caller needs the daemon's verdict to
    /// report the outcome honestly — decoding this body into
    /// [`ManagedSessionSummary`] silently dropped it and made every CLI
    /// decommission claim removal (#5899).
    /// What: POSTs to the decommission endpoint and returns the whole response —
    /// tombstone summary plus `workspace_removed` / `workspace_path_was` — as a
    /// [`ManagedDecommissionOutcome`]. A 404 becomes an `Err` naming the id, so
    /// every decommission caller inherits one "missing session is a hard failure"
    /// contract instead of restating it (#5913). A 409 (#7877 — the #3764
    /// shared-workspace guard, or the #3649 worktree-owner gate) becomes an
    /// `Err` carrying the daemon's REASON TEXT: `error_for_status` alone throws
    /// the body away and leaves the CLI printing "HTTP status client error (409
    /// Conflict) for url …", which tells the operator nothing about which
    /// sibling session blocks the teardown.
    /// Test: `decommission_conflict_surfaces_the_guard_reason`,
    /// `executor_decommission_reports_daemon_workspace_verdict` (real
    /// daemon round-trip), `decommission_outcome_round_trips_daemon_response`,
    /// `session_decommission_not_found_errors`;
    /// live HTTP via `tests/session_manager_mvp.rs`.
    pub async fn decommission_managed_session(
        &self,
        id: &str,
    ) -> anyhow::Result<ManagedDecommissionOutcome> {
        self.decommission_managed_session_with(id, false).await
    }

    /// [`decommission_managed_session`](Self::decommission_managed_session),
    /// with `force` sent as `?force=true` (#7660).
    ///
    /// Test: `session_decommission_routed_force_removes_a_provisioning_only_worktree`
    /// (sends `force=true`), `session_decommission_routed_fails_naming_why_the_workspace_was_kept`.
    pub async fn decommission_managed_session_with(
        &self,
        id: &str,
        force: bool,
    ) -> anyhow::Result<ManagedDecommissionOutcome> {
        let url = format!("/api/v1/sessions/managed/{id}/decommission");
        let mut req = self.post(&url);
        if force {
            // #6288: typed, so the socket's params struct decodes it.
            req = req.query(&[("force", true)]);
        }
        let resp = req.send().await?;
        // #5913: name the 404 here, once. `prune.rs`'s sweep records the resulting
        // `Err` as a failed row (#2457), and that fail-closed behaviour must not
        // depend on which entry point issued the request.
        if resp.status() == reqwest::StatusCode::NOT_FOUND {
            anyhow::bail!("managed session '{id}' not found");
        }
        // #7877: a guard refusal's reason is the whole point of the response.
        if resp.status() == reqwest::StatusCode::CONFLICT {
            let reason = resp.text().await.unwrap_or_default();
            let reason = reason.trim();
            if reason.is_empty() {
                anyhow::bail!("managed session '{id}' cannot be decommissioned right now");
            }
            anyhow::bail!("{reason}");
        }
        let outcome = resp
            .error_for_status()?
            .json()
            .await
            .context("decoding decommission response body")?;
        Ok(outcome)
    }

    /// Tombstone a managed session's RECORD only, leaving its workspace and
    /// runtime untouched (#7275).
    ///
    /// Why: post-merge cleanup ends the session claim on a worktree it is about
    /// to remove, and must do so without killing a process it does not own —
    /// `SessionManager::decommission_record_only` is the daemon-side path with
    /// exactly that guarantee, reached by `?record_only=true`. Cleanup runs
    /// from the CLI, so it needs that path over HTTP.
    /// What: the same endpoint as
    /// [`decommission_managed_session`](Self::decommission_managed_session)
    /// with `record_only=true`, returning the daemon's verdict so the caller
    /// can check `workspace_removed` is `false` — the one observable that
    /// distinguishes a genuine record-only tombstone from a daemon too old to
    /// read the query parameter at all.
    ///
    /// `bin/tm/commands/session_picker_prune::decommission_dead_record` builds
    /// its own request rather than calling this: its three-way outcome, in
    /// which an UNPARSABLE body arms an hour-long stale-daemon sentinel, is
    /// auto-prune policy rather than a transport concern, and collapsing it
    /// into this function's `Err` would remove that protection.
    /// Test: `cleanup_ends_a_session_claim_before_removing_the_worktree` covers
    /// the decision this feeds against a fake; the wire shape is shared with
    /// `decommission_outcome_round_trips_daemon_response`.
    pub async fn decommission_managed_session_record_only(
        &self,
        id: &str,
    ) -> anyhow::Result<ManagedDecommissionOutcome> {
        let url = format!("/api/v1/sessions/managed/{id}/decommission");
        let resp = self
            .post(&url)
            .query(&[("record_only", true)])
            .send()
            .await?;
        if resp.status() == reqwest::StatusCode::NOT_FOUND {
            anyhow::bail!("managed session '{id}' not found");
        }
        let outcome = resp
            .error_for_status()?
            .json()
            .await
            .context("decoding record-only decommission response body")?;
        Ok(outcome)
    }
}