zynk 0.7.0

Portable protocol and helper CLI for multi-agent collaboration.
use crate::profile::load_profile;
use crate::{CliError, CliResult};
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,
    #[arg(
        long,
        help = "live DB path; by default the cwd .zynk/zynk.db is auto-created and status is projected into it (ADR 028); --no-db forces file-only"
    )]
    pub db: Option<PathBuf>,
    #[arg(
        long,
        help = "force file-only; skip the DB projection even if a DB exists"
    )]
    pub no_db: bool,
}

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"));
    }

    // ADR 027 C2/R1 P1: real-parser validate --timestamp BEFORE any file write.
    // The file preserves the valid raw form (offsets allowed); the DB projection
    // below canonicalizes it, exactly as `db import` does. Malformed or
    // calendar-invalid values are rejected here.
    if let Some(value) = &args.timestamp {
        if crate::timestamp::canonicalize(value).is_none() {
            return Err(CliError::usage(format!(
                "invalid timestamp: {value} (expected RFC3339, e.g. 2026-05-29T12:00:00Z)"
            )));
        }
    }
    let timestamp = args
        .timestamp
        .clone()
        .unwrap_or_else(crate::timestamp::now_utc_seconds);
    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()
        ))
    })?;

    // ADR 027/028: file-first, then synchronous DB projection. The default DB is
    // auto-created (ADR 028) and a projection failure soft-degrades (file is the
    // durable record); an explicit `--db` hard-fails (file already written, then
    // nonzero exit); `--no-db` skips. The DB stores the canonical timestamp; the
    // file keeps the raw form.
    if let Some((db_path, explicit)) =
        crate::db::resolve_projection_target(args.db.as_deref(), args.no_db).into_path_and_mode()
    {
        let status = crate::db::ImportedStatus {
            session_id: args.session_id.clone(),
            last_update: crate::timestamp::canonicalize(&timestamp)
                .unwrap_or_else(|| timestamp.clone()),
            lead_agent: args.lead_agent.clone(),
            phase: args.phase.clone(),
            mode: args.mode.clone(),
            artifact_ref: args.artifact_ref.clone(),
            workflow_status: args.status.clone(),
            completed_since_last_update: args.completed.clone(),
            in_progress: args.in_progress.clone(),
            next_action: args.next_action.clone(),
            blockers: args.blockers.clone(),
            asks_for_zevs: args.asks_for_zevs.clone(),
            risk_or_residual_uncertainty: args.risk.clone(),
            expected_wait: args.expected_wait.clone(),
            rolling_events: events.clone(),
        };
        let result = crate::db::project_status(&db_path, &args.root, &status);
        if explicit {
            result?;
        } else if let Err(error) = result {
            eprintln!(
                "warning: DB projection skipped (status file written): {}",
                error.message
            );
        }
    }
    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,
    )
}