zynk 0.2.0

Portable protocol and helper CLI for multi-agent collaboration.
use crate::profile::load_profile;
use crate::{CliError, CliResult};
use chrono::{SecondsFormat, Utc};
use clap::Args;
use std::fs;
use std::path::PathBuf;

#[derive(Debug, Args)]
pub struct StatusArgs {
    #[arg(long)]
    pub profile: Option<PathBuf>,
    #[arg(
        long,
        default_value = "outputs",
        help = "runtime outputs root; pass <project>/outputs to write <project>/outputs/sessions/..."
    )]
    pub root: PathBuf,
    #[arg(long)]
    pub session_id: String,
    #[arg(long)]
    pub timestamp: Option<String>,
    #[arg(long)]
    pub phase: String,
    #[arg(long)]
    pub mode: String,
    #[arg(long)]
    pub artifact_ref: String,
    #[arg(long)]
    pub lead_agent: String,
    #[arg(long)]
    pub status: String,
    #[arg(long, help = "completed_since_last_update value.")]
    pub completed: String,
    #[arg(long)]
    pub in_progress: String,
    #[arg(long)]
    pub next_action: String,
    #[arg(long, default_value = "none")]
    pub blockers: String,
    #[arg(long, default_value = "none")]
    pub asks_for_zevs: String,
    #[arg(long, default_value = "none")]
    pub risk: String,
    #[arg(long, default_value = "unknown")]
    pub expected_wait: String,
    #[arg(long)]
    pub event: Option<String>,
    #[arg(long, default_value_t = 10)]
    pub max_events: usize,
}

pub fn run(args: StatusArgs) -> CliResult<()> {
    let profile = load_profile(args.profile.as_deref())?;
    if !profile
        .operator_interface
        .workflow_status_enum
        .contains(&args.status)
    {
        return Err(CliError::usage(format!(
            "invalid choice: {:?} (choose from {})",
            args.status,
            profile.operator_interface.workflow_status_enum.join(", ")
        )));
    }
    if args.max_events < 1 {
        return Err(CliError::usage("--max-events must be >= 1"));
    }

    let timestamp = args
        .timestamp
        .clone()
        .unwrap_or_else(|| Utc::now().to_rfc3339_opts(SecondsFormat::Secs, false));
    let status_path = args
        .root
        .join("sessions")
        .join(&args.session_id)
        .join("status.md");
    let existing_events = read_existing_events(&status_path)?;
    let current_event = args
        .event
        .clone()
        .unwrap_or_else(|| format!("status={}; next={}", args.status, args.next_action));
    let mut events = vec![format!("{timestamp} - {current_event}")];
    events.extend(existing_events);
    events.truncate(args.max_events);

    if let Some(parent) = status_path.parent() {
        fs::create_dir_all(parent).map_err(|error| {
            CliError::failure(format!("failed to create {}: {error}", parent.display()))
        })?;
    }
    fs::write(&status_path, render_status(&args, &timestamp, &events)).map_err(|error| {
        CliError::failure(format!(
            "failed to write {}: {error}",
            status_path.display()
        ))
    })?;
    println!("{}", status_path.display());
    Ok(())
}

fn read_existing_events(path: &PathBuf) -> CliResult<Vec<String>> {
    if !path.exists() {
        return Ok(Vec::new());
    }
    let content = fs::read_to_string(path).map_err(|error| {
        CliError::failure(format!("failed to read {}: {error}", path.display()))
    })?;
    let Some((_, events_text)) = content.split_once("## Rolling Events") else {
        return Ok(Vec::new());
    };
    Ok(events_text
        .lines()
        .filter_map(|line| {
            let line = line.trim();
            let (prefix, entry) = line.split_once(". ")?;
            prefix.parse::<usize>().ok()?;
            Some(entry.to_string())
        })
        .collect())
}

fn render_status(args: &StatusArgs, timestamp: &str, events: &[String]) -> String {
    let event_lines = if events.is_empty() {
        "No events recorded.".to_string()
    } else {
        events
            .iter()
            .enumerate()
            .map(|(index, event)| format!("{}. {event}", index + 1))
            .collect::<Vec<_>>()
            .join("\n")
    };

    format!(
        "# Session Status: {session_id}\n\n\
session_id: {session_id}\n\
last_update: {timestamp}\n\
lead_agent: {lead_agent}\n\
status: {status}\n\n\
## Current State\n\n\
- phase: {phase}\n\
- mode: {mode}\n\
- artifact_ref: {artifact_ref}\n\
- completed_since_last_update: {completed}\n\
- in_progress: {in_progress}\n\
- next_action: {next_action}\n\
- blockers: {blockers}\n\
- asks_for_Zevs: {asks_for_zevs}\n\
- risk_or_residual_uncertainty: {risk}\n\
- expected_wait: {expected_wait}\n\n\
## Rolling Events\n\n\
{event_lines}\n",
        session_id = args.session_id,
        lead_agent = args.lead_agent,
        status = args.status,
        phase = args.phase,
        mode = args.mode,
        artifact_ref = args.artifact_ref,
        completed = args.completed,
        in_progress = args.in_progress,
        next_action = args.next_action,
        blockers = args.blockers,
        asks_for_zevs = args.asks_for_zevs,
        risk = args.risk,
        expected_wait = args.expected_wait,
    )
}