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}