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"));
}
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, ×tamp, &events)).map_err(|error| {
CliError::failure(format!(
"failed to write {}: {error}",
status_path.display()
))
})?;
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(×tamp)
.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,
)
}