use anyhow::{bail, Context, Result};
use std::path::Path;
use super::io::*;
use super::types::*;
use crate::commands::kickoff::tmux_session_name;
use crate::sync::SyncManager;
pub fn status(crosslink_dir: &Path, json: bool) -> Result<()> {
let sync = SyncManager::new(crosslink_dir)?;
if !sync.is_initialized() {
bail!("Hub cache not initialized. Run `crosslink sync` first.");
}
let ctx = resolve_swarm(&sync)?;
let plan: SwarmPlan = read_hub_json(&sync, &ctx.plan_path())
.context("No swarm plan found. Run `crosslink swarm init --doc <file>` first.")?;
let root = crosslink_dir
.parent()
.ok_or_else(|| anyhow::anyhow!("Cannot determine repo root"))?;
if json {
let mut phases_json = Vec::new();
for phase_name in &plan.phases {
let phase_file = ctx.phase_path(phase_name);
let phase: PhaseDefinition = match read_hub_json(&sync, &phase_file) {
Ok(p) => p,
Err(_) => continue,
};
let resolved = resolve_agents(&phase, root);
phases_json.push(serde_json::json!({
"name": phase.name,
"status": phase.status,
"gate": phase.gate,
"depends_on": phase.depends_on,
"agents": resolved,
}));
}
let output = serde_json::json!({
"title": plan.title,
"phases": phases_json,
});
println!("{}", serde_json::to_string_pretty(&output)?);
return Ok(());
}
println!("Swarm: {}", plan.title);
println!();
for phase_name in &plan.phases {
let phase_file = ctx.phase_path(phase_name);
let Ok(phase) = read_hub_json::<PhaseDefinition>(&sync, &phase_file) else {
println!(" {phase_name} (definition missing)");
continue;
};
let resolved = resolve_agents(&phase, root);
let total = resolved.len();
let merged = resolved
.iter()
.filter(|a| a.defined_status == AgentStatus::Merged)
.count();
let completed = resolved
.iter()
.filter(|a| {
a.defined_status == AgentStatus::Completed
|| a.live_status == "DONE"
|| a.live_status == "completed"
})
.count();
let failed = resolved
.iter()
.filter(|a| {
a.defined_status == AgentStatus::Failed
|| a.live_status == "FAILED"
|| a.live_status == "failed"
})
.count();
let gate_info = phase.gate.as_ref().map_or_else(String::new, |gate| {
if gate.status == "passed" {
let tests = gate
.tests_total
.map(|t| format!(", {t} tests"))
.unwrap_or_default();
format!(", gate passed{tests}")
} else {
format!(", gate {}", gate.status)
}
});
let extra = if completed > 0 || failed > 0 {
let mut parts = Vec::new();
if completed > 0 {
parts.push(format!("{completed} completed"));
}
if failed > 0 {
parts.push(format!("{failed} failed"));
}
format!(", {}", parts.join(", "))
} else {
String::new()
};
println!(
"{} ({}): {}/{} agents merged{}{}",
phase.name, phase.status, merged, total, extra, gate_info
);
for agent in &resolved {
let icon = match agent.live_status.as_str() {
"DONE" | "completed" | "merged" => "\u{2713}",
"FAILED" | "failed" => "\u{2717}",
s if s.starts_with("running") => "\u{25cf}",
"planned" => " ",
_ => "\u{23f8}",
};
let status_display = if agent.defined_status == AgentStatus::Merged {
"merged".to_string()
} else if agent.live_status != format!("{}", agent.defined_status) {
agent.live_status.clone()
} else {
format!("{}", agent.defined_status)
};
let issue_info = agent
.issue_id
.map(|id| format!(" (#{id})"))
.unwrap_or_default();
println!(
" {} {:<30} {:<12}{}",
icon, agent.slug, status_display, issue_info
);
if !agent.description.is_empty() {
println!(" {}", agent.description);
}
}
println!();
}
let mut active_phase_slug: Option<String> = None;
let mut completed_phases = 0;
let mut has_failed = false;
let mut has_running = false;
let mut has_planned = false;
let mut has_ready_to_merge = false;
let mut all_merged = true;
for phase_name in &plan.phases {
let phase_file = ctx.phase_path(phase_name);
let phase: PhaseDefinition = match read_hub_json(&sync, &phase_file) {
Ok(p) => p,
Err(_) => continue,
};
if phase.status == PhaseStatus::Completed {
completed_phases += 1;
continue;
}
if active_phase_slug.is_none() {
active_phase_slug = Some(slugify_phase(phase_name));
let resolved = resolve_agents(&phase, root);
for agent in &resolved {
match agent.live_status.as_str() {
"FAILED" | "failed" => has_failed = true,
s if s.starts_with("running") => has_running = true,
"planned" => {
if agent.defined_status == AgentStatus::Planned {
has_planned = true;
}
}
"DONE" | "completed" => {
if agent.defined_status != AgentStatus::Merged {
has_ready_to_merge = true;
all_merged = false;
}
}
_ => {
all_merged = false;
}
}
if agent.defined_status != AgentStatus::Merged
&& agent.defined_status != AgentStatus::Completed
{
all_merged = false;
}
}
}
}
if let Some(slug) = active_phase_slug {
println!("Next steps:");
if has_planned {
println!(" crosslink swarm launch {slug}");
}
if has_failed {
println!(" crosslink swarm launch {slug} --retry-failed");
}
if has_running {
println!(" (waiting for running agents to complete)");
}
if has_ready_to_merge {
println!(" (merge completed agents, then gate)");
}
if all_merged && !has_running && !has_planned {
println!(" crosslink swarm gate {slug}");
println!(" crosslink swarm checkpoint {slug}");
}
} else if completed_phases == plan.phases.len() {
println!(
"All phases completed. Run `crosslink swarm archive` to archive and start a new swarm."
);
}
Ok(())
}
pub(super) fn resolve_agents(phase: &PhaseDefinition, repo_root: &Path) -> Vec<ResolvedAgent> {
phase
.agents
.iter()
.map(|agent| {
let live_status = probe_agent_status(repo_root, &agent.slug);
ResolvedAgent {
slug: agent.slug.clone(),
description: agent.description.clone(),
issue_id: agent.issue_id,
defined_status: agent.status.clone(),
live_status,
branch: agent.branch.clone(),
}
})
.collect()
}
pub(super) fn probe_agent_status(repo_root: &Path, slug: &str) -> String {
let worktree = repo_root.join(".worktrees").join(slug);
if !worktree.exists() {
if is_branch_merged(repo_root, slug) {
return "completed (merged)".to_string();
}
if branch_exists(repo_root, slug) {
return "completed (worktree removed)".to_string();
}
return "planned".to_string();
}
let status_file = worktree.join(".kickoff-status");
if status_file.exists() {
if let Ok(content) = std::fs::read_to_string(&status_file) {
let s = content.trim().to_string();
if !s.is_empty() {
return s;
}
}
}
let session_name = tmux_session_name(slug);
let tmux_alive = std::process::Command::new("tmux")
.args(["has-session", "-t", &session_name])
.output()
.is_ok_and(|o| o.status.success());
if tmux_alive {
return "running (tmux)".to_string();
}
if let Ok(output) = std::process::Command::new("tmux")
.args(["list-sessions", "-F", concat!("#", "{session_name}")])
.output()
{
if output.status.success() {
let sessions = String::from_utf8_lossy(&output.stdout);
for session in sessions.lines() {
if session.contains(slug) {
return format!("running (tmux: {session})");
}
}
}
}
"failed (session died)".to_string()
}
pub(super) fn is_branch_merged(repo_root: &Path, slug: &str) -> bool {
for branch in &[slug.to_string(), format!("swarm/{slug}")] {
let output = std::process::Command::new("git")
.current_dir(repo_root)
.args(["branch", "--merged", "HEAD", "--list", branch])
.output();
if let Ok(out) = output {
if out.status.success() && !out.stdout.is_empty() {
let branches = String::from_utf8_lossy(&out.stdout);
if branches.lines().any(|l| l.trim() == *branch) {
return true;
}
}
}
}
false
}
pub(super) fn branch_exists(repo_root: &Path, slug: &str) -> bool {
for branch in &[slug.to_string(), format!("swarm/{slug}")] {
let output = std::process::Command::new("git")
.current_dir(repo_root)
.args(["rev-parse", "--verify", branch])
.output();
if let Ok(out) = output {
if out.status.success() {
return true;
}
}
}
false
}