1use super::*;
3
4pub fn resume_execution(
6 store: Option<&ExecutionStore>,
7 identifier: &str,
8 command: Option<&str>,
9 output_format: Option<&str>,
10) -> ExecutionResumeResult {
11 resume_execution_with(
12 store,
13 identifier,
14 command,
15 output_format,
16 &SystemCommandRunner,
17 &SystemResumeHooks,
18 )
19}
20
21pub fn resume_execution_with<R: CommandRunner, H: ResumeHooks>(
24 store: Option<&ExecutionStore>,
25 identifier: &str,
26 command: Option<&str>,
27 output_format: Option<&str>,
28 runner: &R,
29 hooks: &H,
30) -> ExecutionResumeResult {
31 resume_execution_with_resources(
32 store,
33 identifier,
34 command,
35 output_format,
36 runner,
37 hooks,
38 &json!({}),
39 )
40}
41
42pub fn resume_execution_with_options(
43 store: Option<&ExecutionStore>,
44 identifier: &str,
45 command: Option<&str>,
46 output_format: Option<&str>,
47 options: &crate::args_parser::WrapperOptions,
48) -> ExecutionResumeResult {
49 let mut overrides = crate::docker_resource_options::specs(options);
50 overrides["removeOriginal"] = json!(options.remove_original);
51 overrides["labels"] = json!(options.labels);
52 resume_execution_with_resources(
53 store,
54 identifier,
55 command,
56 output_format,
57 &SystemCommandRunner,
58 &SystemResumeHooks,
59 &overrides,
60 )
61}
62
63#[allow(clippy::too_many_arguments)]
64pub fn resume_execution_with_resources<R: CommandRunner, H: ResumeHooks>(
65 store: Option<&ExecutionStore>,
66 identifier: &str,
67 command: Option<&str>,
68 output_format: Option<&str>,
69 runner: &R,
70 hooks: &H,
71 overrides: &Value,
72) -> ExecutionResumeResult {
73 let Some(store) = store else {
74 return ExecutionResumeResult {
75 success: false,
76 output: None,
77 error: Some("Execution tracking is disabled.".to_string()),
78 };
79 };
80
81 let Some(mut record) = store.get(identifier) else {
82 return ExecutionResumeResult {
83 success: false,
84 output: None,
85 error: Some(format!(
86 "No execution found with UUID or session name: {}",
87 identifier
88 )),
89 };
90 };
91
92 if crate::launch_owner::has_active_launch(&record) {
93 return ExecutionResumeResult {
94 success: false,
95 output: None,
96 error: Some("An execution launch is already reserved.".into()),
97 };
98 }
99 let previous = record.clone();
100 let probe = probe_session(&record, runner);
101 let resources = crate::resume_resources::prepare(&record, &probe, overrides, runner);
102 let (limits, resolved, old_memory) = match resources {
103 Ok(result) => result,
104 Err(error) => {
105 return ExecutionResumeResult {
106 success: false,
107 output: None,
108 error: Some(error),
109 }
110 }
111 };
112 let mut planning_record = record.clone();
113 if let Some(labels) = overrides
114 .get("labels")
115 .and_then(Value::as_array)
116 .filter(|labels| !labels.is_empty())
117 {
118 let mut merged = record_strings(&record, "labels");
119 for label in labels.iter().filter_map(Value::as_str) {
120 let key = label.split('=').next().unwrap_or(label);
121 if let Some(index) = merged
122 .iter()
123 .position(|existing| existing.split('=').next() == Some(key))
124 {
125 merged[index] = label.to_string();
126 } else {
127 merged.push(label.to_string());
128 }
129 }
130 planning_record
131 .options
132 .insert("labels".into(), json!(merged));
133 }
134 if planning_record.options.get("labels") != record.options.get("labels") {
135 if record_option(&record, "isolated") != Some("docker") {
136 return ExecutionResumeResult {
137 success: false,
138 output: None,
139 error: Some("Docker labels require a Docker execution when resuming.".into()),
140 };
141 }
142 planning_record
143 .options
144 .insert("forceSnapshot".into(), json!(true));
145 }
146 if let Some(limits) = &limits {
147 planning_record
148 .options
149 .insert("resourceLimits".into(), json!(limits));
150 }
151 let mut plan =
152 match build_resume_plan_with_limits(&planning_record, command, &probe, limits.clone()) {
153 Ok(plan) => plan,
154 Err(error) => {
155 return ExecutionResumeResult {
156 success: false,
157 output: None,
158 error: Some(error),
159 }
160 }
161 };
162 if let Some(limits) = limits {
163 plan.resource_limits = limits.clone();
164 let changed = ["memory", "memorySwap", "cpus"]
165 .iter()
166 .any(|k| overrides.get(k).and_then(Value::as_str).is_some())
167 || record.options.contains_key("cpuPenaltyConfig");
168 if plan.mode == ResumeMode::DockerStart && changed {
169 let mut args = vec!["update".into()];
170 args.extend(limits.into_iter().filter(|s| {
171 [
172 "--memory=",
173 "--memory-swap=",
174 "--cpus=",
175 "--cpu-quota=",
176 "--cpu-period=",
177 ]
178 .iter()
179 .any(|f| s.starts_with(f))
180 }));
181 args.push(plan.session_name.clone());
182 plan.steps.insert(
183 0,
184 ResumeStep {
185 command: docker_command().to_string_lossy().into(),
186 args,
187 description: "Apply resume limits before start".into(),
188 },
189 );
190 }
191 }
192 let mut container_id: Option<String> = None;
193 let mut attempt = crate::execution_attempt::create_attempt(
194 &record,
195 plan.mode.as_str(),
196 active_session_name(&plan),
197 );
198 let mut lifecycle_record = record.clone();
199 lifecycle_record.attempt = Some(attempt.clone());
200 crate::execution_attempt::append_lifecycle(&lifecycle_record, "resume-started", json!({}));
201 apply_resume_to_record(&mut record, &plan, None);
202 if let Some(labels) = planning_record.options.get("labels") {
203 record.options.insert("labels".into(), labels.clone());
204 }
205 record.attempt = Some(attempt.clone());
206 if let Some(resolved) = resolved {
207 record.options.insert("resolvedLimits".into(), resolved);
208 }
209 let mut requested = record
210 .options
211 .get("resourceLimitSpecs")
212 .filter(|value| value.is_object())
213 .cloned()
214 .unwrap_or_else(|| json!({}));
215 for k in ["memory", "memorySwap", "cpus"] {
216 if let Some(value) = overrides.get(k).filter(|v| v.is_string()) {
217 requested[k] = value.clone();
218 }
219 }
220 record
221 .options
222 .insert("resourceLimitSpecs".into(), requested);
223 if record.options.contains_key("cpuPenaltyConfig")
224 && overrides.get("cpus").and_then(Value::as_str).is_some()
225 {
226 record
227 .options
228 .insert("baseResourceLimits".into(), json!(&plan.resource_limits));
229 }
230 crate::launch_owner::mark_launch(&mut record);
231 if let Err(error) = store.reserve_launch(&record, &previous) {
232 return ExecutionResumeResult {
233 success: false,
234 output: None,
235 error: Some(format!("Launch reservation failed: {}", error)),
236 };
237 }
238
239 if let Some(spec) = overrides.get("memory").and_then(Value::as_str) {
240 let new = record
241 .options
242 .get("resolvedLimits")
243 .and_then(|v| v.get("memory"))
244 .cloned()
245 .unwrap_or(Value::Null);
246 if !record.log_path.is_empty() {
247 append_log_file(
248 &PathBuf::from(&record.log_path),
249 &format!(
250 "Resume memory limit: {} -> {} ({})\n",
251 old_memory, new, spec
252 ),
253 );
254 }
255 }
256
257 if plan.mode == ResumeMode::Relaunch {
258 let launch_options = plan.launch_options.clone().unwrap_or_default();
259 let launch_result = hooks.relaunch(&plan.backend, &plan.command, &launch_options);
260 if !launch_result.success {
261 let _ = store.save(&previous);
262 crate::execution_attempt::append_lifecycle(
263 &lifecycle_record,
264 "launch-failed",
265 json!({ "error": launch_result.message }),
266 );
267 return ExecutionResumeResult {
268 success: false,
269 output: None,
270 error: Some(format!(
271 "Failed to relaunch {} session \"{}\": {}",
272 plan.backend, plan.session_name, launch_result.message
273 )),
274 };
275 }
276 container_id = launch_result.container_id;
277 } else {
278 let mut snapshot_created = false;
279 let mut derived_created = false;
280 let handoff = plan.mode == ResumeMode::DockerStart
281 && command.is_some()
282 && record_flag(&previous, "commandHandoff");
283 let launch_result = (|| -> Result<(), String> {
284 let _snapshot_lock = if plan.mode == ResumeMode::DockerSnapshot {
285 Some(crate::docker_snapshot_safety::acquire_snapshot_lock()?)
286 } else {
287 None
288 };
289 if plan.mode == ResumeMode::DockerSnapshot {
290 let message =
291 crate::docker_snapshot_safety::preflight_snapshot(&plan.session_name, runner)?;
292 plan.message.push_str(&format!("\n{}", message));
293 if !record.log_path.is_empty() {
294 append_log_file(&PathBuf::from(&record.log_path), &format!("{}\n", message));
295 }
296 }
297 if handoff {
298 let copied = crate::docker_command_handoff::write_command_handoff(
299 &plan.session_name,
300 &plan.command,
301 record_option(&previous, "shell").unwrap_or("auto"),
302 record_flag(&previous, "keepAlive"),
303 runner,
304 )?;
305 if !copied.success {
306 return Err(if copied.stderr.trim().is_empty() {
307 copied.error.unwrap_or_else(|| {
308 "Could not copy replacement command into the stopped container".into()
309 })
310 } else {
311 copied.stderr.trim().into()
312 });
313 }
314 }
315 for step in &plan.steps {
316 let result = runner.run(&step.command, &step.args);
317 if !result.success {
318 let detail = if !result.stderr.trim().is_empty() {
319 result.stderr.trim().to_string()
320 } else {
321 result.error.clone().unwrap_or_else(|| {
322 format!(
323 "exit code {}",
324 result
325 .status
326 .map(|code| code.to_string())
327 .unwrap_or_else(|| "unknown".to_string())
328 )
329 })
330 };
331 return Err(detail);
332 }
333 if step.args[0] == "commit" {
334 snapshot_created = true;
335 }
336 if step.args[0] == "create" {
337 derived_created = true;
338 }
339 let stdout = result.stdout.trim().to_string();
340 if !stdout.is_empty() && matches!(step.args[0].as_str(), "run" | "create") {
341 container_id = Some(stdout);
342 }
343 }
344 if plan.mode == ResumeMode::DockerSnapshot
345 && overrides.get("removeOriginal").and_then(Value::as_bool) == Some(true)
346 {
347 let running = runner.run(
348 &docker_command().to_string_lossy(),
349 &[
350 "inspect".into(),
351 "-f".into(),
352 "{{.State.Running}}".into(),
353 active_session_name(&plan).into(),
354 ],
355 );
356 let note = if running.success && running.stdout.trim() == "true" {
357 let removed = runner.run(
358 &docker_command().to_string_lossy(),
359 &["rm".into(), plan.session_name.clone()],
360 );
361 if removed.success {
362 format!("Stopped original container removed: {}", plan.session_name)
363 } else {
364 format!("Original container retained: {}", removed.stderr.trim())
365 }
366 } else {
367 "Original container retained: the snapshot container is not running.".into()
368 };
369 plan.message.push_str(&format!("\n{}", note));
370 if !record.log_path.is_empty() {
371 append_log_file(&PathBuf::from(&record.log_path), &format!("{}\n", note));
372 }
373 }
374 Ok(())
375 })();
376 if let Err(detail) = launch_result {
377 if derived_created {
378 runner.run(
379 &docker_command().to_string_lossy(),
380 &["rm".into(), active_session_name(&plan).into()],
381 );
382 }
383 if snapshot_created {
384 runner.run(
385 &docker_command().to_string_lossy(),
386 &["rmi".into(), plan.snapshot_image.clone().unwrap()],
387 );
388 }
389 if handoff {
390 let _ = crate::docker_command_handoff::write_command_handoff(
391 &plan.session_name,
392 &previous.command,
393 record_option(&previous, "shell").unwrap_or("auto"),
394 record_flag(&previous, "keepAlive"),
395 runner,
396 );
397 }
398 let _ = store.save(&previous);
399 crate::execution_attempt::append_lifecycle(
400 &lifecycle_record,
401 "launch-failed",
402 json!({ "error": detail }),
403 );
404 return ExecutionResumeResult {
405 success: false,
406 output: None,
407 error: Some(format!(
408 "Failed to resume {} session \"{}\": {}",
409 plan.backend, plan.session_name, detail
410 )),
411 };
412 }
413
414 if let Some(line) = build_resource_limits_status_line(&plan.resource_limits) {
415 if !record.log_path.is_empty() {
416 append_log_file(&PathBuf::from(&record.log_path), &format!("{}\n", line));
417 }
418 }
419 }
420
421 let previous_session_name = plan
422 .new_session_name
423 .as_ref()
424 .map(|_| plan.session_name.clone());
425 if let Some(id) = container_id.as_deref() {
426 record.options.insert("containerId".into(), json!(id));
427 }
428 record.options.insert("launchPending".into(), json!(false));
429 attempt.launch_accepted_at = Some(Utc::now().to_rfc3339());
430 record
431 .options
432 .insert("resumedAt".into(), json!(attempt.started_at));
433 record.attempt = Some(attempt.clone());
434 if let Err(error) = store.save(&record) {
435 if plan.backend == "docker" {
436 let _ = hooks.attach_watcher(active_session_name(&plan), &record);
437 }
438 let stopped = plan.backend == "docker"
439 && runner
440 .run(
441 &docker_command().to_string_lossy(),
442 &["stop".into(), active_session_name(&plan).into()],
443 )
444 .success;
445 if stopped {
446 record.status = ExecutionStatus::Executed;
447 record.exit_code = Some(-1);
448 record.end_time = Some(Utc::now().to_rfc3339());
449 record.options.insert("launchPending".into(), json!(false));
450 let _ = store.save(&record);
451 }
452 return ExecutionResumeResult {
453 success: false,
454 output: None,
455 error: Some(
456 json!({"code": "LAUNCH_PERSISTENCE_FAILED", "uuid": record.uuid,
457 "containerName": active_session_name(&plan), "running": !stopped,
458 "error": error})
459 .to_string(),
460 ),
461 };
462 }
463 crate::execution_attempt::append_lifecycle(&record, "launch-accepted", json!({}));
464 if plan.backend == "docker" {
465 let attachment = hooks.attach_watcher(active_session_name(&plan), &record);
466 let fields = match &attachment {
467 Ok(()) => json!({"watcherAttachedAt": Utc::now().to_rfc3339()}),
468 Err(error) => json!({"watcherError": error}),
469 };
470 match store.patch_attempt(&record.uuid, attempt.number, fields) {
471 Ok(Some(current)) => crate::execution_attempt::append_lifecycle(
472 ¤t,
473 if attachment.is_ok() {
474 "watcher-attached"
475 } else {
476 "watcher-attachment-failed"
477 },
478 json!({"error": attachment.as_ref().err()}),
479 ),
480 Ok(None) => {}
481 Err(error) => {
482 return ExecutionResumeResult {
483 success: false,
484 output: None,
485 error: Some(error),
486 }
487 }
488 }
489 if let Err(error) = attachment {
490 return ExecutionResumeResult {
491 success: false,
492 output: None,
493 error: Some(format!(
494 "Launch accepted, but completion watcher attachment failed: {}",
495 error
496 )),
497 };
498 }
499 }
500
501 let session_name = record_option(&record, "sessionName")
502 .unwrap_or(&plan.session_name)
503 .to_string();
504
505 ExecutionResumeResult {
506 success: true,
507 output: Some(format_resume_result(
508 &ResumeResultFields {
509 identifier,
510 uuid: &record.uuid,
511 mode: plan.mode,
512 backend: &plan.backend,
513 session_name: &session_name,
514 previous_session_name: previous_session_name.as_deref(),
515 snapshot_image: plan.snapshot_image.as_deref(),
516 resource_limits: &plan.resource_limits,
517 command: &plan.command,
518 message: &plan.message,
519 },
520 output_format,
521 )),
522 error: None,
523 }
524}
525
526#[cfg(test)]
527#[path = "execution_resume_cases.rs"]
528mod tests;