runx-cli 0.6.19

Cargo-installed launcher for the runx governed agent workflow CLI.
Documentation
use std::collections::BTreeMap;
use std::env;
use std::ffi::OsString;
use std::io::{self, Write};
use std::path::{Path, PathBuf};
use std::process::ExitCode;

use runx_runtime::journal::list_local_history;
use runx_runtime::{
    LocalReceiptStore, ReceiptPathInputs, RuntimeReceiptConfig, resolve_receipt_path,
};

use crate::skill::{SkillAction, SkillPlan};

#[derive(Debug, PartialEq, Eq)]
pub struct ResumePlan {
    pub run_id: String,
    pub answers_path: PathBuf,
    pub receipt_dir: Option<PathBuf>,
    pub json: bool,
}

pub(crate) struct SkillResumeCommand<'a> {
    pub(crate) skill_ref: Option<&'a str>,
    pub(crate) run_id: &'a str,
    pub(crate) selected_runner: Option<&'a str>,
    pub(crate) receipt_dir: Option<&'a Path>,
    pub(crate) answers_path: Option<&'a Path>,
}

pub fn parse_resume_plan(args: &[OsString]) -> Result<ResumePlan, String> {
    if args.first().and_then(|arg| arg.to_str()) != Some("resume") {
        return Err("internal error: resume dispatcher received non-resume command".to_owned());
    }
    let mut receipt_dir = None;
    let mut json = false;
    let mut positionals = Vec::new();
    let mut index = 1;
    while index < args.len() {
        let token = string_arg(args, index)?;
        match token.as_str() {
            "--json" | "-j" => {
                json = true;
                index += 1;
            }
            "--non-interactive" => {
                index += 1;
            }
            value if value.starts_with("--receipt-dir=") => {
                receipt_dir = Some(PathBuf::from(value.trim_start_matches("--receipt-dir=")));
                index += 1;
            }
            value if value.starts_with("--receipts=") => {
                receipt_dir = Some(PathBuf::from(value.trim_start_matches("--receipts=")));
                index += 1;
            }
            value if value.starts_with("-R=") => {
                receipt_dir = Some(PathBuf::from(value.trim_start_matches("-R=")));
                index += 1;
            }
            "--receipt-dir" | "--receipts" | "-R" => {
                index += 1;
                receipt_dir = Some(PathBuf::from(string_arg(args, index)?));
                index += 1;
            }
            value if value.starts_with('-') => {
                return Err(format!("unknown runx resume option {value}"));
            }
            value => {
                positionals.push(value.to_owned());
                index += 1;
            }
        }
    }
    if positionals.len() != 2 {
        return Err("runx resume requires <run-id> <answers.json>".to_owned());
    }
    Ok(ResumePlan {
        run_id: positionals.remove(0),
        answers_path: PathBuf::from(positionals.remove(0)),
        receipt_dir,
        json,
    })
}

pub fn run_native_resume(plan: ResumePlan) -> ExitCode {
    let cwd = env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
    let env = crate::cli_io::env_map();
    let receipt_config = RuntimeReceiptConfig::default();
    let resolved = resolve_receipt_path(ReceiptPathInputs {
        explicit_dir: plan.receipt_dir.as_deref(),
        runtime_config: Some(&receipt_config),
        env: &env,
        cwd: &cwd,
    });
    let store = LocalReceiptStore::new(&resolved.path);
    let history = match list_local_history(
        &store,
        &resolved.workspace_base,
        &resolved.project_runx_dir,
        &Default::default(),
    ) {
        Ok(history) => history,
        Err(error) => {
            return write_resume_failure(
                &format!("could not read receipt history: {error}"),
                plan.json,
                1,
            );
        }
    };
    let Some(pending) = history
        .pending_runs
        .iter()
        .find(|pending| pending.id == plan.run_id)
    else {
        return write_resume_failure(
            &format!("no pending run found for {}", plan.run_id),
            plan.json,
            1,
        );
    };
    let Some(skill_ref) = pending.resume_skill_ref.as_deref() else {
        return write_resume_failure(
            "pending run does not record a resume skill ref; rerun the original skill manually",
            plan.json,
            1,
        );
    };
    let skill_plan = SkillPlan {
        action: SkillAction::Run,
        skill_path: PathBuf::from(skill_ref),
        runner: pending.selected_runner.clone(),
        receipt_dir: plan.receipt_dir,
        run_id: Some(plan.run_id),
        answers: Some(plan.answers_path),
        registry: None,
        expected_digest: None,
        json: plan.json,
        inputs: BTreeMap::new(),
        local_credential: None,
    };
    crate::skill::run_native_skill(skill_plan)
}

pub(crate) fn render_skill_resume_command(command: SkillResumeCommand<'_>) -> String {
    let mut parts = vec![
        "runx".to_owned(),
        "resume".to_owned(),
        shell_token(command.run_id),
    ];
    parts.push(shell_token(
        &command
            .answers_path
            .map_or_else(|| "answers.json".into(), Path::to_string_lossy),
    ));
    if let Some(receipt_dir) = command.receipt_dir {
        parts.push("--receipt-dir".to_owned());
        parts.push(shell_token(&receipt_dir.to_string_lossy()));
    }
    let _legacy_context = (
        command.skill_ref,
        command.selected_runner.and_then(non_empty),
    );
    parts.join(" ")
}

fn non_empty(value: &str) -> Option<&str> {
    let value = value.trim();
    (!value.is_empty()).then_some(value)
}

fn shell_token(value: &str) -> String {
    if value.is_empty() {
        return "''".to_owned();
    }
    if value.chars().all(|character| {
        character.is_ascii_alphanumeric() || matches!(character, '/' | '.' | '_' | '-' | ':' | '@')
    }) {
        return value.to_owned();
    }
    format!("'{}'", value.replace('\'', "'\\''"))
}

fn string_arg(args: &[OsString], index: usize) -> Result<String, String> {
    args.get(index)
        .ok_or_else(|| "missing value for runx resume argument".to_owned())?
        .to_str()
        .map(ToOwned::to_owned)
        .ok_or_else(|| "runx resume arguments must be UTF-8".to_owned())
}

fn write_resume_failure(message: &str, json: bool, exit_code: u8) -> ExitCode {
    if json {
        return crate::cli_io::write_stdout_code(
            &crate::router::json_failure_output(message, "resume_error"),
            exit_code,
        );
    }
    let _ignored = writeln!(io::stderr(), "runx: {message}");
    ExitCode::from(exit_code)
}

#[cfg(test)]
mod tests {
    use std::path::Path;

    use super::{SkillResumeCommand, render_skill_resume_command};

    #[test]
    fn resume_command_quotes_operator_supplied_tokens() {
        let command = render_skill_resume_command(SkillResumeCommand {
            skill_ref: Some("skills/support reply"),
            run_id: "run abc",
            selected_runner: Some("agent task"),
            receipt_dir: Some(Path::new("custom receipts")),
            answers_path: Some(Path::new("my answers.json")),
        });

        assert_eq!(
            command,
            "runx resume 'run abc' 'my answers.json' --receipt-dir 'custom receipts'"
        );
    }

    #[test]
    fn resume_command_uses_safe_defaults_when_metadata_is_missing() {
        let command = render_skill_resume_command(SkillResumeCommand {
            skill_ref: None,
            run_id: "rx_123",
            selected_runner: None,
            receipt_dir: None,
            answers_path: None,
        });

        assert_eq!(command, "runx resume rx_123 answers.json");
    }
}