use std::time::{Duration, Instant};
use anyhow::Context as _;
use serde::Deserialize;
use super::first_run::needs_first_run_clone;
use super::tmux_attach::AttachOutcome;
const POLL_INTERVAL: Duration = Duration::from_millis(600);
const PROVISION_DEADLINE: Duration = Duration::from_secs(30 * 60);
#[derive(Debug, Default, Deserialize)]
struct SpawnAck {
#[serde(default)]
id: String,
#[serde(default)]
name: String,
#[serde(default)]
state: String,
}
#[derive(Debug, Default, Deserialize)]
struct ProvisionStatus {
#[serde(default)]
state: String,
#[serde(default)]
label: Option<String>,
#[serde(default)]
detail: Option<String>,
#[serde(default)]
name: Option<String>,
#[serde(default)]
session_id: Option<String>,
#[serde(default)]
error: Option<String>,
}
pub(crate) fn spawn_progress_message(first_run: Option<&(String, std::path::PathBuf)>) -> String {
match first_run {
Some((proj, path)) => format!(
"tm: first run for {proj} — cloning into {}, this may take a few minutes…",
path.display()
),
None => "tm: launching new session…".to_string(),
}
}
fn provisioning_message(label: Option<&str>, detail: Option<&str>) -> String {
match (label, detail) {
(Some(l), Some(d)) => format!("tm: provisioning workspace: {l} — {d}…"),
(Some(l), None) => format!("tm: provisioning workspace: {l}…"),
(None, _) => "tm: provisioning workspace…".to_string(),
}
}
pub(crate) async fn launch_new_session_and_attach(
client: &reqwest::Client,
url: &str,
repo_url: &str,
) -> anyhow::Result<AttachOutcome> {
let first_run = needs_first_run_clone(repo_url);
let progress_output = trusty_progress::Output::to_stderr();
let spinner = trusty_progress::ProgressHandle::spinner(
&progress_output,
spawn_progress_message(first_run.as_ref()),
);
let send_result = client
.post(format!("{url}/api/v1/sessions/managed"))
.json(&serde_json::json!({
"repo_url": repo_url,
"ref": "HEAD",
"task": "",
"force_new": true,
"background": true,
}))
.send()
.await;
let resp = match send_result {
Ok(resp) => resp,
Err(err) => {
spinner.finish_and_clear();
return Err(err).context("managed spawn POST failed");
}
};
if !resp.status().is_success() {
let status = resp.status();
spinner.finish_and_clear();
anyhow::bail!("daemon returned {status} for managed spawn");
}
let ack: SpawnAck = match resp.json().await {
Ok(body) => body,
Err(err) => {
spinner.finish_and_clear();
return Err(err).context("failed to parse managed spawn response");
}
};
if ack.state != "provisioning" && !ack.name.is_empty() {
spinner.finish_and_clear();
if first_run.is_some() {
eprintln!("tm: clone complete — workspace ready");
}
eprintln!("tm: session '{}' created ({})", ack.name, ack.state);
return crate::commands::tmux_attach::tmux_attach(&ack.name);
}
if ack.id.is_empty() {
spinner.finish_and_clear();
anyhow::bail!("daemon accepted the spawn but returned no job id to poll");
}
let name = match poll_until_ready(client, url, &ack.id, &spinner).await {
Ok(name) => name,
Err(err) => {
spinner.finish_and_clear();
return Err(err);
}
};
spinner.finish_and_clear();
if first_run.is_some() {
eprintln!("tm: clone complete — workspace ready");
}
eprintln!("tm: session '{name}' created (active)");
crate::commands::tmux_attach::tmux_attach(&name)
}
async fn poll_until_ready(
client: &reqwest::Client,
url: &str,
id: &str,
spinner: &trusty_progress::ProgressHandle,
) -> anyhow::Result<String> {
let status_url = format!("{url}/api/v1/sessions/managed/{id}/provision-status");
let start = Instant::now();
loop {
if start.elapsed() > PROVISION_DEADLINE {
anyhow::bail!(
"provisioning did not finish within {} minutes; it may still be running on the \
daemon — check `tm sessions ls`",
PROVISION_DEADLINE.as_secs() / 60
);
}
match client.get(&status_url).send().await {
Ok(resp) if resp.status().is_success() => {
let status: ProvisionStatus = resp.json().await.unwrap_or_default();
match status.state.as_str() {
"ready" => {
return status
.name
.or(status.session_id)
.filter(|s| !s.is_empty())
.context("daemon reported ready but returned no session name");
}
"failed" => {
anyhow::bail!(
"provisioning failed: {}",
status.error.as_deref().unwrap_or("unknown error")
);
}
_ => {
spinner.set_message(provisioning_message(
status.label.as_deref(),
status.detail.as_deref(),
));
}
}
}
_ => {}
}
tokio::time::sleep(POLL_INTERVAL).await;
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn spawn_progress_message_first_run() {
let msg = spawn_progress_message(Some(&(
"acme/widgets".to_string(),
std::path::PathBuf::from("/tmp/ws"),
)));
assert!(msg.contains("first run for acme/widgets"));
assert!(msg.contains("/tmp/ws"));
}
#[test]
fn spawn_progress_message_reuse() {
let msg = spawn_progress_message(None);
assert_eq!(msg, "tm: launching new session…");
}
#[test]
fn provisioning_message_label_only() {
assert_eq!(
provisioning_message(Some("Cloning repository"), None),
"tm: provisioning workspace: Cloning repository…"
);
}
#[test]
fn provisioning_message_label_and_detail() {
assert_eq!(
provisioning_message(Some("Cloning repository"), Some("Receiving objects: 63%")),
"tm: provisioning workspace: Cloning repository — Receiving objects: 63%…"
);
}
#[test]
fn provisioning_message_no_label() {
assert_eq!(
provisioning_message(None, Some("ignored")),
"tm: provisioning workspace…"
);
}
}