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
//! SESSCTL command handlers for the `tm sessctl` subcommand group (WI-2 #1593).
//!
//! Why: Phase 2 of epic #1590 wires the `tm sessctl` CLI surface to the daemon
//! HTTP API (`/api/v1/control/sessions/*`) instead of embedding a local
//! `SessionRegistry`. Each handler is a thin HTTP client wrapper so all session
//! state lives in the single shared daemon registry.
//! What: five async handler functions (`run`, `connect`, `stop`, `auth`, `list`)
//! plus a `dispatch` entry point that routes `SessctlAction` variants.
//! Test: `cli_parses_sessctl_*` in tests.rs; HTTP round-trip integration tests
//! require the daemon to be running.
use std::path::PathBuf;
use crate::cli::SessctlAction;
/// Percent-encode a session ID for safe inclusion in a URL path segment.
///
/// Why: session IDs derive from project IDs which may contain characters that
/// need encoding in a URL path (e.g. `/`, `?`, `#`, spaces). Using
/// `reqwest::Url` path-segment API guarantees correct RFC 3986 encoding without
/// adding an extra dependency.
/// What: parses `base_url`, appends the static path segments and then the
/// encoded `session_id` as the final dynamic segment, and returns the full URL.
/// Test: this is a pure function; callers (`sessctl_connect`, `sessctl_stop`,
/// `sessctl_auth`) pass it simple ASCII IDs in unit tests; the encoding is
/// verified by reqwest's own test suite.
fn session_url(base_url: &str, session_id: &str, suffix: &str) -> anyhow::Result<String> {
let mut url = reqwest::Url::parse(base_url)
.map_err(|e| anyhow::anyhow!("invalid daemon URL '{base_url}': {e}"))?;
{
let mut segs = url
.path_segments_mut()
.map_err(|_| anyhow::anyhow!("daemon URL cannot-be-a-base: {base_url}"))?;
segs.extend(["api", "v1", "control", "sessions"]);
segs.push(session_id); // reqwest Url percent-encodes each segment
if !suffix.is_empty() {
segs.push(suffix);
}
}
Ok(url.to_string())
}
/// Dispatch a `SessctlAction` to the appropriate HTTP handler.
///
/// Why: keeps `main.rs` thin โ one match arm dispatches every sessctl verb.
/// What: matches each `SessctlAction` variant and calls the corresponding
/// handler function.
/// Test: parse round-trips in `tests.rs`; live integration tests require daemon.
pub(crate) async fn dispatch(
client: &reqwest::Client,
url: &str,
action: SessctlAction,
) -> anyhow::Result<()> {
match action {
SessctlAction::Run {
project_id,
tmux,
prompt_file,
workdir,
} => sessctl_run(client, url, project_id, tmux, prompt_file, workdir).await,
SessctlAction::Connect { session_id } => sessctl_connect(client, url, session_id).await,
SessctlAction::Stop { session_id, force } => {
sessctl_stop(client, url, session_id, force).await
}
SessctlAction::Auth { session_id } => sessctl_auth(client, url, session_id).await,
SessctlAction::List { project, format } => {
sessctl_list(client, url, project.as_deref(), &format).await
}
}
}
/// POST /api/v1/control/sessions/run โ spawn a SESSCTL session via the daemon.
///
/// Why: delegates session spawning to the daemon registry so the CLI does not
/// manage actor state directly (Phase 2 requirement of #1593).
/// What: resolves the workdir (falling back to cwd when unset), POSTs the
/// spawn request, and prints the allocated session ID.
/// Test: `cli_parses_sessctl_run`; live test requires daemon.
async fn sessctl_run(
client: &reqwest::Client,
url: &str,
project_id: String,
use_tmux: bool,
prompt_file: Option<String>,
workdir: Option<String>,
) -> anyhow::Result<()> {
let resolved_workdir = workdir
.map(PathBuf::from)
.or_else(|| resolve_workdir_from_daemon(&project_id))
.unwrap_or_else(|| std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")));
let backend = if use_tmux { "tmux" } else { "stream-json" };
let body = serde_json::json!({
"project_id": project_id,
"workdir": resolved_workdir.to_string_lossy(),
"backend": backend,
"prompt_file": prompt_file,
});
let resp = client
.post(format!("{url}/api/v1/control/sessions/run"))
.json(&body)
.send()
.await?;
if !resp.status().is_success() {
let status = resp.status();
let text = resp.text().await.unwrap_or_default();
anyhow::bail!("daemon returned {status}: {text}");
}
let parsed: serde_json::Value = resp.json().await?;
let session_id = parsed["session_id"]
.as_str()
.ok_or_else(|| anyhow::anyhow!("daemon response missing session_id"))?;
println!("{session_id}");
Ok(())
}
/// POST /api/v1/control/sessions/{id}/connect โ stream SSE events from a session.
///
/// Why: `tm sessctl connect <id>` lets an operator observe a running session in
/// real-time. Acquiring the write-lock makes the caller the active writer.
/// What: streams the SSE response body line by line and prints each event to
/// stdout until the stream ends or is interrupted.
/// Test: `cli_parses_sessctl_connect`; live test requires daemon.
async fn sessctl_connect(
client: &reqwest::Client,
url: &str,
session_id: String,
) -> anyhow::Result<()> {
let endpoint = session_url(url, &session_id, "connect")?;
let resp = client.post(&endpoint).send().await?;
if !resp.status().is_success() {
let status = resp.status();
let text = resp.text().await.unwrap_or_default();
anyhow::bail!("daemon returned {status}: {text}");
}
// Drain the SSE stream and print each data line.
let mut bytes_stream = resp.bytes_stream();
use futures::StreamExt as _;
while let Some(chunk) = bytes_stream.next().await {
let chunk = chunk?;
let text = String::from_utf8_lossy(&chunk);
for line in text.lines() {
if let Some(data) = line.strip_prefix("data: ") {
println!("{data}");
}
}
}
Ok(())
}
/// POST /api/v1/control/sessions/{id}/stop โ send a stop command to a session.
///
/// Why: mirrors `tm session stop` for the SESSCTL surface.
/// What: POSTs with `?force=<bool>` and prints a confirmation.
/// Test: `cli_parses_sessctl_stop`; live test requires daemon.
async fn sessctl_stop(
client: &reqwest::Client,
url: &str,
session_id: String,
force: bool,
) -> anyhow::Result<()> {
let endpoint = session_url(url, &session_id, "stop")?;
let resp = client
.post(&endpoint)
.query(&[("force", force.to_string().as_str())])
.send()
.await?;
if !resp.status().is_success() {
let status = resp.status();
let text = resp.text().await.unwrap_or_default();
anyhow::bail!("daemon returned {status}: {text}");
}
let mode = if force { "force-stop" } else { "stop" };
eprintln!("sent {mode} to session {session_id}");
Ok(())
}
/// GET /api/v1/control/sessions/{id}/auth โ poll the auth state for a session.
///
/// Why: lets the operator detect an `awaiting-auth` condition without attaching
/// to the SSE stream.
/// What: GETs the auth endpoint and prints the state label.
/// Test: `cli_parses_sessctl_auth`; live test requires daemon.
async fn sessctl_auth(
client: &reqwest::Client,
url: &str,
session_id: String,
) -> anyhow::Result<()> {
let endpoint = session_url(url, &session_id, "auth")?;
let resp = client.get(&endpoint).send().await?;
if !resp.status().is_success() {
let status = resp.status();
let text = resp.text().await.unwrap_or_default();
anyhow::bail!("daemon returned {status}: {text}");
}
let parsed: serde_json::Value = resp.json().await?;
let state = parsed["state"].as_str().unwrap_or("unknown");
let awaiting = parsed["awaiting_auth"].as_bool().unwrap_or(false);
println!("session {session_id}: state={state} awaiting_auth={awaiting}");
Ok(())
}
/// GET /api/v1/control/sessions โ list SESSCTL sessions.
///
/// Why: `tm sessctl list` gives operators a snapshot of every live control-plane
/// session without requiring access to the daemon process.
/// What: GETs the sessions list (with optional `?project=` filter using reqwest's
/// query builder so project names with special characters are properly encoded)
/// and renders as a table or raw JSON.
/// Test: `cli_parses_sessctl_list`; live test requires daemon.
async fn sessctl_list(
client: &reqwest::Client,
url: &str,
project: Option<&str>,
format: &str,
) -> anyhow::Result<()> {
let endpoint = format!("{url}/api/v1/control/sessions");
let mut req = client.get(&endpoint);
if let Some(proj) = project {
req = req.query(&[("project", proj)]);
}
let resp = req.send().await?;
if !resp.status().is_success() {
let status = resp.status();
let text = resp.text().await.unwrap_or_default();
anyhow::bail!("daemon returned {status}: {text}");
}
let parsed: serde_json::Value = resp.json().await?;
if format == "json" {
println!("{}", serde_json::to_string_pretty(&parsed)?);
return Ok(());
}
// Table format โ shows core fields; use --format json for the full ยง4.5 schema.
let sessions = parsed["sessions"]
.as_array()
.map(|a| a.as_slice())
.unwrap_or(&[]);
if sessions.is_empty() {
eprintln!("no sessions");
return Ok(());
}
println!(
"{:<30} {:<20} {:<12} {:<22} {:>8} {:<5} {:<40}",
"SESSION_ID", "PROJECT", "BACKEND", "STATE", "UPTIME", "RST", "LAST_SUMMARY"
);
for s in sessions {
let sid = s["session_id"].as_str().unwrap_or("-");
let proj = s["project_id"].as_str().unwrap_or("-");
let backend = s["backend"].as_str().unwrap_or("-");
let state = s["state"].as_str().unwrap_or("-");
let uptime = s["uptime_secs"].as_u64().unwrap_or(0);
let restart_count = s["restart_count"].as_u64().unwrap_or(0);
// Truncate last_summary to 40 Unicode scalar values with ellipsis to
// keep the table row aligned regardless of summary length (review bug #5).
// Known cosmetic limitation: CJK characters and emoji consume 2 display
// columns each, so a summary containing them may visually exceed 40
// columns. The `unicode-width` crate would fix this, but it is not a
// current workspace dependency; adding it for a cosmetic CLI improvement
// is not justified โ document here instead.
let raw_summary = s["last_summary"].as_str().unwrap_or("-");
let last_summary: String = if raw_summary.chars().count() > 40 {
let truncated: String = raw_summary.chars().take(37).collect();
format!("{truncated}...")
} else {
raw_summary.to_owned()
};
println!(
"{:<30} {:<20} {:<12} {:<22} {:>7}s {:<5} {:<40}",
sid, proj, backend, state, uptime, restart_count, last_summary
);
// Show pending decision inline if present.
if let Some(pd) = s["pending_decision"].as_str() {
let default = s["proposed_default"].as_str().unwrap_or("?");
println!(" pending: {pd} [default: {default}]");
}
}
Ok(())
}
/// Attempt to resolve a project's workdir from the daemon registry (best-effort).
///
/// Why: when `--workdir` is omitted the daemon may know the project's working
/// directory from its project registry. This preserves the lookup seam for WI-3
/// without taking `client`/`url` parameters that cannot be used yet.
/// What: currently returns `None` (fallback to cwd) with an explicit stderr
/// warning so the operator knows why cwd was chosen. A full HTTP lookup is
/// deferred to WI-3 when the project registry HTTP endpoint is wired.
/// Test: callers fall back to `std::env::current_dir()` when this returns
/// `None`; the warning message is observable in integration tests.
fn resolve_workdir_from_daemon(project_id: &str) -> Option<PathBuf> {
// TODO(#1593/WI-3): implement daemon project-workdir lookup once the
// project registry HTTP endpoint is wired. For now, fall back to cwd.
eprintln!(
"warning: no --workdir given and daemon workdir lookup is not yet wired \
(WI-3); using current directory as workdir for project '{project_id}'"
);
None
}