use crate::{CliError, CliResult};
use chrono::{SecondsFormat, Utc};
use clap::Args;
use std::collections::BTreeMap;
use std::fs;
use std::path::{Path, PathBuf};
#[derive(Debug, Args)]
pub struct DashboardArgs {
#[arg(
long,
default_value = "outputs",
help = "runtime outputs root; reads <root>/sessions/*/status.md"
)]
pub root: PathBuf,
#[arg(long)]
pub output: Option<PathBuf>,
#[arg(long)]
pub timestamp: Option<String>,
}
#[derive(Debug, Default)]
struct SessionStatus {
session_id: String,
last_update: String,
lead_agent: String,
status: String,
phase: String,
mode: String,
artifact_ref: String,
next_action: String,
blockers: String,
asks_for_zevs: String,
risk: String,
expected_wait: String,
status_path: PathBuf,
summary_path: PathBuf,
audit_path: PathBuf,
}
pub fn run(args: DashboardArgs) -> CliResult<()> {
let timestamp = args
.timestamp
.clone()
.unwrap_or_else(|| Utc::now().to_rfc3339_opts(SecondsFormat::Secs, false));
let sessions = read_sessions(&args.root)?;
let output = args
.output
.unwrap_or_else(|| args.root.join("dashboard.md"));
if let Some(parent) = output.parent() {
fs::create_dir_all(parent).map_err(|error| {
CliError::failure(format!("failed to create {}: {error}", parent.display()))
})?;
}
fs::write(&output, render_dashboard(&args.root, ×tamp, &sessions)).map_err(|error| {
CliError::failure(format!("failed to write {}: {error}", output.display()))
})?;
println!("{}", output.display());
Ok(())
}
fn read_sessions(root: &Path) -> CliResult<Vec<SessionStatus>> {
let sessions_dir = root.join("sessions");
if !sessions_dir.exists() {
return Ok(Vec::new());
}
let mut sessions = Vec::new();
for entry in fs::read_dir(&sessions_dir).map_err(|error| {
CliError::failure(format!(
"failed to read {}: {error}",
sessions_dir.display()
))
})? {
let entry = entry
.map_err(|error| CliError::failure(format!("failed to read session entry: {error}")))?;
let status_path = entry.path().join("status.md");
if status_path.exists() {
sessions.push(parse_status_file(root, &status_path)?);
}
}
sessions.sort_by(|left, right| left.session_id.cmp(&right.session_id));
Ok(sessions)
}
fn parse_status_file(root: &Path, status_path: &Path) -> CliResult<SessionStatus> {
let content = fs::read_to_string(status_path).map_err(|error| {
CliError::failure(format!("failed to read {}: {error}", status_path.display()))
})?;
let mut values = BTreeMap::new();
for line in content.lines() {
let line = line.trim();
if let Some((key, value)) = line
.strip_prefix("- ")
.and_then(|line| line.split_once(": "))
{
values.insert(key.to_string(), value.to_string());
} else if let Some((key, value)) = line.split_once(": ") {
values.insert(key.to_string(), value.to_string());
}
}
let session_id = values
.get("session_id")
.cloned()
.or_else(|| {
status_path
.parent()?
.file_name()?
.to_str()
.map(str::to_string)
})
.unwrap_or_else(|| "unknown".to_string());
let session_dir = root.join("sessions").join(&session_id);
Ok(SessionStatus {
session_id,
last_update: value_or_unknown(&values, "last_update"),
lead_agent: value_or_unknown(&values, "lead_agent"),
status: value_or_unknown(&values, "status"),
phase: value_or_unknown(&values, "phase"),
mode: value_or_unknown(&values, "mode"),
artifact_ref: value_or_unknown(&values, "artifact_ref"),
next_action: value_or_unknown(&values, "next_action"),
blockers: value_or_unknown(&values, "blockers"),
asks_for_zevs: value_or_unknown(&values, "asks_for_Zevs"),
risk: value_or_unknown(&values, "risk_or_residual_uncertainty"),
expected_wait: value_or_unknown(&values, "expected_wait"),
status_path: status_path.to_path_buf(),
summary_path: session_dir.join("summary.md"),
audit_path: session_dir.join("audit.md"),
})
}
fn value_or_unknown(values: &BTreeMap<String, String>, key: &str) -> String {
values
.get(key)
.filter(|value| !value.is_empty())
.cloned()
.unwrap_or_else(|| "unknown".to_string())
}
fn render_dashboard(root: &Path, timestamp: &str, sessions: &[SessionStatus]) -> String {
let mut output = String::new();
output.push_str("# Multi-Agent Collaboration Dashboard\n\n");
output.push_str(&format!("last_update: {timestamp}\n"));
output.push_str(&format!("dashboard_scope: {}\n\n", root.display()));
output.push_str("## Active Sessions\n\n");
if sessions.is_empty() {
output.push_str("No active session status files found.\n\n");
} else {
output.push_str("| session_id | lead_agent | status | phase | mode | current_artifact | next_action | last_update |\n");
output.push_str("| --- | --- | --- | --- | --- | --- | --- | --- |\n");
for session in sessions {
output.push_str(&format!(
"| {} | {} | {} | {} | {} | {} | {} | {} |\n",
cell(&session.session_id),
cell(&session.lead_agent),
cell(&session.status),
cell(&session.phase),
cell(&session.mode),
cell(&session.artifact_ref),
cell(&session.next_action),
cell(&session.last_update),
));
}
output.push('\n');
}
output.push_str("## Operator Attention\n\n");
let attention = sessions
.iter()
.filter(|session| needs_attention(session))
.collect::<Vec<_>>();
if attention.is_empty() {
output.push_str("- none\n\n");
} else {
for session in attention {
output.push_str(&format!(
"- `{}`: status={}, ask={}, blockers={}, risk={}, expected_wait={}\n",
session.session_id,
session.status,
session.asks_for_zevs,
session.blockers,
session.risk,
session.expected_wait,
));
}
output.push('\n');
}
output.push_str("## Links\n\n");
if sessions.is_empty() {
output.push_str("- none\n");
} else {
for session in sessions {
output.push_str(&format!(
"- `{}`: [status]({}) | [summary]({}) | [audit]({})\n",
session.session_id,
link(root, &session.status_path),
link(root, &session.summary_path),
link(root, &session.audit_path),
));
}
}
output
}
fn needs_attention(session: &SessionStatus) -> bool {
let asks = session.asks_for_zevs.to_ascii_lowercase();
let blockers = session.blockers.to_ascii_lowercase();
matches!(session.status.as_str(), "blocked" | "waiting-for-operator")
|| !matches!(asks.as_str(), "none" | "unknown")
|| !matches!(blockers.as_str(), "none" | "unknown")
}
fn cell(value: &str) -> String {
value.replace('|', "\\|")
}
fn link(root: &Path, path: &Path) -> String {
path.strip_prefix(root)
.unwrap_or(path)
.display()
.to_string()
}