zynk 0.3.1

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; when set (or .zynk/zynk.db exists) status is projected into SQLite (ADR 027)"
    )]
    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: file-first, then synchronous DB projection (presence-gated). The
    // status file is the durable record; a projection failure (or a
    // non-canonical timestamp) is an infrastructure soft-degrade — warn and
    // continue; `db import` reconciles.
    if let Some(db_path) = crate::db::resolve_projection_db(args.db.as_deref(), args.no_db) {
        match crate::timestamp::canonicalize(&timestamp) {
            Some(canonical) => {
                let status = crate::db::ImportedStatus {
                    session_id: args.session_id.clone(),
                    last_update: canonical,
                    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(),
                };
                if let Err(error) = crate::db::project_status(&db_path, &args.root, &status) {
                    eprintln!(
                        "warning: DB projection skipped (status file written): {}",
                        error.message
                    );
                }
            }
            None => eprintln!(
                "warning: DB projection skipped (non-canonical status timestamp '{timestamp}'); status file written"
            ),
        }
    }
    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,
    )
}