1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
//! 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)
}
}