Skip to main content

runx_cli/
resume.rs

1use std::collections::BTreeMap;
2use std::env;
3use std::ffi::OsString;
4use std::io::{self, Write};
5use std::path::{Path, PathBuf};
6use std::process::ExitCode;
7
8use runx_runtime::journal::list_local_history;
9use runx_runtime::{
10    LocalReceiptStore, ReceiptPathInputs, RuntimeReceiptConfig, resolve_receipt_path,
11};
12
13use crate::skill::{SkillAction, SkillPlan};
14
15#[derive(Debug, PartialEq, Eq)]
16pub struct ResumePlan {
17    pub run_id: String,
18    pub answers_path: PathBuf,
19    pub receipt_dir: Option<PathBuf>,
20    pub json: bool,
21}
22
23pub(crate) struct SkillResumeCommand<'a> {
24    pub(crate) skill_ref: Option<&'a str>,
25    pub(crate) run_id: &'a str,
26    pub(crate) selected_runner: Option<&'a str>,
27    pub(crate) receipt_dir: Option<&'a Path>,
28    pub(crate) answers_path: Option<&'a Path>,
29}
30
31pub fn parse_resume_plan(args: &[OsString]) -> Result<ResumePlan, String> {
32    if args.first().and_then(|arg| arg.to_str()) != Some("resume") {
33        return Err("internal error: resume dispatcher received non-resume command".to_owned());
34    }
35    let mut receipt_dir = None;
36    let mut json = false;
37    let mut positionals = Vec::new();
38    let mut index = 1;
39    while index < args.len() {
40        let token = string_arg(args, index)?;
41        match token.as_str() {
42            "--json" | "-j" => {
43                json = true;
44                index += 1;
45            }
46            "--non-interactive" => {
47                index += 1;
48            }
49            value if value.starts_with("--receipt-dir=") => {
50                receipt_dir = Some(PathBuf::from(value.trim_start_matches("--receipt-dir=")));
51                index += 1;
52            }
53            value if value.starts_with("--receipts=") => {
54                receipt_dir = Some(PathBuf::from(value.trim_start_matches("--receipts=")));
55                index += 1;
56            }
57            value if value.starts_with("-R=") => {
58                receipt_dir = Some(PathBuf::from(value.trim_start_matches("-R=")));
59                index += 1;
60            }
61            "--receipt-dir" | "--receipts" | "-R" => {
62                index += 1;
63                receipt_dir = Some(PathBuf::from(string_arg(args, index)?));
64                index += 1;
65            }
66            value if value.starts_with('-') => {
67                return Err(format!("unknown runx resume option {value}"));
68            }
69            value => {
70                positionals.push(value.to_owned());
71                index += 1;
72            }
73        }
74    }
75    if positionals.len() != 2 {
76        return Err("runx resume requires <run-id> <answers.json>".to_owned());
77    }
78    Ok(ResumePlan {
79        run_id: positionals.remove(0),
80        answers_path: PathBuf::from(positionals.remove(0)),
81        receipt_dir,
82        json,
83    })
84}
85
86pub fn run_native_resume(plan: ResumePlan) -> ExitCode {
87    let cwd = env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
88    let env = crate::cli_io::env_map();
89    let receipt_config = RuntimeReceiptConfig::default();
90    let resolved = resolve_receipt_path(ReceiptPathInputs {
91        explicit_dir: plan.receipt_dir.as_deref(),
92        runtime_config: Some(&receipt_config),
93        env: &env,
94        cwd: &cwd,
95    });
96    let store = LocalReceiptStore::new(&resolved.path);
97    let history = match list_local_history(
98        &store,
99        &resolved.workspace_base,
100        &resolved.project_runx_dir,
101        &Default::default(),
102    ) {
103        Ok(history) => history,
104        Err(error) => {
105            return write_resume_failure(
106                &format!("could not read receipt history: {error}"),
107                plan.json,
108                1,
109            );
110        }
111    };
112    let Some(pending) = history
113        .pending_runs
114        .iter()
115        .find(|pending| pending.id == plan.run_id)
116    else {
117        return write_resume_failure(
118            &format!("no pending run found for {}", plan.run_id),
119            plan.json,
120            1,
121        );
122    };
123    let Some(skill_ref) = pending.resume_skill_ref.as_deref() else {
124        return write_resume_failure(
125            "pending run does not record a resume skill ref; rerun the original skill manually",
126            plan.json,
127            1,
128        );
129    };
130    let skill_plan = SkillPlan {
131        action: SkillAction::Run,
132        skill_path: PathBuf::from(skill_ref),
133        runner: pending.selected_runner.clone(),
134        receipt_dir: plan.receipt_dir,
135        run_id: Some(plan.run_id),
136        answers: Some(plan.answers_path),
137        registry: None,
138        expected_digest: None,
139        json: plan.json,
140        inputs: BTreeMap::new(),
141        local_credential: None,
142    };
143    crate::skill::run_native_skill(skill_plan)
144}
145
146pub(crate) fn render_skill_resume_command(command: SkillResumeCommand<'_>) -> String {
147    let mut parts = vec![
148        "runx".to_owned(),
149        "resume".to_owned(),
150        shell_token(command.run_id),
151    ];
152    parts.push(shell_token(
153        &command
154            .answers_path
155            .map_or_else(|| "answers.json".into(), Path::to_string_lossy),
156    ));
157    if let Some(receipt_dir) = command.receipt_dir {
158        parts.push("--receipt-dir".to_owned());
159        parts.push(shell_token(&receipt_dir.to_string_lossy()));
160    }
161    let _legacy_context = (
162        command.skill_ref,
163        command.selected_runner.and_then(non_empty),
164    );
165    parts.join(" ")
166}
167
168fn non_empty(value: &str) -> Option<&str> {
169    let value = value.trim();
170    (!value.is_empty()).then_some(value)
171}
172
173fn shell_token(value: &str) -> String {
174    if value.is_empty() {
175        return "''".to_owned();
176    }
177    if value.chars().all(|character| {
178        character.is_ascii_alphanumeric() || matches!(character, '/' | '.' | '_' | '-' | ':' | '@')
179    }) {
180        return value.to_owned();
181    }
182    format!("'{}'", value.replace('\'', "'\\''"))
183}
184
185fn string_arg(args: &[OsString], index: usize) -> Result<String, String> {
186    args.get(index)
187        .ok_or_else(|| "missing value for runx resume argument".to_owned())?
188        .to_str()
189        .map(ToOwned::to_owned)
190        .ok_or_else(|| "runx resume arguments must be UTF-8".to_owned())
191}
192
193fn write_resume_failure(message: &str, json: bool, exit_code: u8) -> ExitCode {
194    if json {
195        return crate::cli_io::write_stdout_code(
196            &crate::router::json_failure_output(message, "resume_error"),
197            exit_code,
198        );
199    }
200    let _ignored = writeln!(io::stderr(), "runx: {message}");
201    ExitCode::from(exit_code)
202}
203
204#[cfg(test)]
205mod tests {
206    use std::path::Path;
207
208    use super::{SkillResumeCommand, render_skill_resume_command};
209
210    #[test]
211    fn resume_command_quotes_operator_supplied_tokens() {
212        let command = render_skill_resume_command(SkillResumeCommand {
213            skill_ref: Some("skills/support reply"),
214            run_id: "run abc",
215            selected_runner: Some("agent task"),
216            receipt_dir: Some(Path::new("custom receipts")),
217            answers_path: Some(Path::new("my answers.json")),
218        });
219
220        assert_eq!(
221            command,
222            "runx resume 'run abc' 'my answers.json' --receipt-dir 'custom receipts'"
223        );
224    }
225
226    #[test]
227    fn resume_command_uses_safe_defaults_when_metadata_is_missing() {
228        let command = render_skill_resume_command(SkillResumeCommand {
229            skill_ref: None,
230            run_id: "rx_123",
231            selected_runner: None,
232            receipt_dir: None,
233            answers_path: None,
234        });
235
236        assert_eq!(command, "runx resume rx_123 answers.json");
237    }
238}