use std::path::PathBuf;
use crate::cli::SessctlAction;
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); if !suffix.is_empty() {
segs.push(suffix);
}
}
Ok(url.to_string())
}
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
}
}
}
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(())
}
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}");
}
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(())
}
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(())
}
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(())
}
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(());
}
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);
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
);
if let Some(pd) = s["pending_decision"].as_str() {
let default = s["proposed_default"].as_str().unwrap_or("?");
println!(" pending: {pd} [default: {default}]");
}
}
Ok(())
}
fn resolve_workdir_from_daemon(project_id: &str) -> Option<PathBuf> {
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
}