1use std::path::PathBuf;
19
20use chrono::Utc;
21use serde_json::{json, Value};
22
23use crate::docker_cleanup::{
24 build_docker_runtime_args, docker_command, docker_networks,
25 get_docker_container_cleanup_policy, start_detached_docker_completion_watcher_with,
26 DockerWatcherOptions,
27};
28use crate::docker_resource_limits::{
29 build_resource_limits_status_line, normalize_resource_limits, read_docker_resource_limits,
30};
31use crate::execution_control::{CommandRunner, SystemCommandRunner};
32use crate::execution_store::{ExecutionRecord, ExecutionStatus, ExecutionStore};
33use crate::isolation::isolation_log::append_log_file;
34use crate::isolation::{run_isolated, IsolationOptions, IsolationResult};
35use crate::output_blocks::escape_for_links_notation;
36use crate::session_probe::{probe_session, SessionProbe, SessionState};
37
38#[derive(Debug, Clone, Copy, PartialEq, Eq)]
40pub enum ResumeMode {
41 DockerStart,
42 DockerSnapshot,
43 Relaunch,
44}
45
46impl ResumeMode {
47 pub fn as_str(self) -> &'static str {
48 match self {
49 ResumeMode::DockerStart => "docker-start",
50 ResumeMode::DockerSnapshot => "docker-snapshot",
51 ResumeMode::Relaunch => "relaunch",
52 }
53 }
54}
55
56#[derive(Debug, Clone, PartialEq, Eq)]
58pub struct ResumeStep {
59 pub command: String,
60 pub args: Vec<String>,
61 pub description: String,
62}
63
64#[derive(Debug, Clone)]
66pub struct ResumePlan {
67 pub mode: ResumeMode,
68 pub backend: String,
69 pub session_name: String,
70 pub new_session_name: Option<String>,
72 pub snapshot_image: Option<String>,
73 pub command: String,
74 pub attempt: u64,
75 pub resource_limits: Vec<String>,
78 pub steps: Vec<ResumeStep>,
79 pub launch_options: Option<IsolationOptions>,
80 pub message: String,
81}
82
83pub struct ExecutionResumeResult {
85 pub success: bool,
86 pub output: Option<String>,
87 pub error: Option<String>,
88}
89
90pub trait ResumeHooks {
95 fn start_watcher(&self, session_name: &str, record: &ExecutionRecord);
97 fn attach_watcher(&self, session_name: &str, record: &ExecutionRecord) -> Result<(), String> {
99 self.start_watcher(session_name, record);
100 Ok(())
101 }
102
103 fn relaunch(&self, backend: &str, command: &str, options: &IsolationOptions)
105 -> IsolationResult;
106
107 fn reconcile(&self, record: &ExecutionRecord) -> ExecutionRecord;
109}
110
111#[derive(Debug, Default)]
113pub struct SystemResumeHooks;
114
115impl ResumeHooks for SystemResumeHooks {
116 fn start_watcher(&self, session_name: &str, record: &ExecutionRecord) {
117 let _ = self.attach_watcher(session_name, record);
118 }
119
120 fn attach_watcher(&self, session_name: &str, record: &ExecutionRecord) -> Result<(), String> {
121 let log_path = (!record.log_path.is_empty()).then(|| PathBuf::from(&record.log_path));
122 start_detached_docker_completion_watcher_with(
123 session_name,
124 get_docker_container_cleanup_policy(&build_launch_options(record)),
125 log_path.as_ref(),
126 Some(record.uuid.as_str()),
129 &DockerWatcherOptions {
130 since: record
131 .attempt
132 .as_ref()
133 .map(|attempt| attempt.started_at.clone()),
134 attempt_number: record.attempt.as_ref().map(|attempt| attempt.number),
135 recover_on_kill: keeps_kill_recovery(session_name, record),
136 },
137 )
138 }
139
140 fn relaunch(
141 &self,
142 backend: &str,
143 command: &str,
144 options: &IsolationOptions,
145 ) -> IsolationResult {
146 run_isolated(backend, command, options)
147 }
148
149 fn reconcile(&self, record: &ExecutionRecord) -> ExecutionRecord {
150 crate::status_formatter::enrich_detached_status(record)
151 }
152}
153
154pub fn keeps_kill_recovery(session_name: &str, record: &ExecutionRecord) -> bool {
158 record.attempt.as_ref().is_none_or(|attempt| {
159 matches!(
160 attempt.mode.as_str(),
161 "docker-start" | "automatic-recovery" | "relaunch"
162 )
163 }) && record_option(record, "sessionName") == Some(session_name)
164 && record
165 .options
166 .get("onKillResume")
167 .is_some_and(|value| value.as_u64().unwrap_or(0) > 0)
168}
169
170fn record_option<'a>(record: &'a ExecutionRecord, key: &str) -> Option<&'a str> {
171 record.options.get(key).and_then(|value| value.as_str())
172}
173
174fn record_flag(record: &ExecutionRecord, key: &str) -> bool {
175 record
176 .options
177 .get(key)
178 .and_then(|value| value.as_bool())
179 .unwrap_or(false)
180}
181
182fn record_strings(record: &ExecutionRecord, key: &str) -> Vec<String> {
183 record
184 .options
185 .get(key)
186 .and_then(|value| value.as_array())
187 .map(|items| {
188 items
189 .iter()
190 .filter_map(|item| item.as_str().map(str::to_string))
191 .collect()
192 })
193 .unwrap_or_default()
194}
195
196pub fn build_snapshot_image_name(session_name: &str, attempt: u64) -> String {
200 let mut sanitized = String::new();
201 let mut pending_dash = false;
202 for ch in session_name.to_lowercase().chars() {
203 if ch.is_ascii_lowercase() || ch.is_ascii_digit() || ch == '.' || ch == '_' || ch == '-' {
204 if pending_dash {
205 sanitized.push('-');
206 pending_dash = false;
207 }
208 sanitized.push(ch);
209 } else if !sanitized.is_empty() || !pending_dash {
210 pending_dash = true;
211 }
212 }
213 if pending_dash {
214 sanitized.push('-');
215 }
216 let sanitized = sanitized.trim_start_matches(['-', '.']).to_string();
217 let sanitized = if sanitized.is_empty() {
218 "session".to_string()
219 } else {
220 sanitized
221 };
222 format!("start-command-resume/{}:{}", sanitized, attempt)
223}
224
225pub fn build_resumed_session_name(session_name: &str, attempt: u64) -> String {
227 format!("{}-resume-{}", session_name, attempt)
228}
229
230pub fn build_launch_options(record: &ExecutionRecord) -> IsolationOptions {
233 let networks = record_strings(record, "networks");
234 IsolationOptions {
235 session: record_option(record, "sessionName").map(str::to_string),
236 image: record_option(record, "image").map(str::to_string),
237 volumes: record_strings(record, "volumes"),
238 mounts: record_strings(record, "mounts"),
239 env: record_strings(record, "env"),
240 privileged: record_flag(record, "privileged"),
241 network: networks.first().cloned(),
242 networks,
243 network_aliases: record_strings(record, "networkAliases"),
244 resource_limits: normalize_resource_limits(record.options.get("resourceLimits")),
246 on_kill_resume: None,
247 recovery_command: None,
248 endpoint: record_option(record, "endpoint").map(str::to_string),
249 detached: true,
250 user: record_option(record, "user").map(str::to_string),
251 keep_alive: record_flag(record, "keepAlive"),
252 auto_remove_docker_container: record_flag(record, "autoRemoveDockerContainer"),
253 always_cleanup_container: record_flag(record, "alwaysCleanupContainer"),
254 keep_container: record_flag(record, "keepContainer"),
255 keep_container_on_fail: record_flag(record, "keepContainerOnFail"),
256 shell: record_option(record, "shell").unwrap_or("auto").to_string(),
257 log_path: (!record.log_path.is_empty()).then(|| PathBuf::from(&record.log_path)),
259 execution_id: Some(record.uuid.clone()),
262 defer_completion_watcher: true,
263 }
264}
265
266fn build_snapshot_start_steps(
274 docker: &str,
275 container_args: Vec<String>,
276 extra_networks: &[String],
277 new_session_name: &str,
278 snapshot_image: &str,
279) -> Vec<ResumeStep> {
280 if extra_networks.is_empty() {
281 let mut args = vec!["run".to_string(), "-d".to_string()];
282 args.extend(container_args);
283 return vec![ResumeStep {
284 command: docker.to_string(),
285 args,
286 description: format!("Run the new command in {}", new_session_name),
287 }];
288 }
289
290 let mut create_args = vec!["create".to_string()];
291 create_args.extend(container_args);
292 let mut steps = vec![ResumeStep {
293 command: docker.to_string(),
294 args: create_args,
295 description: format!("Create {} from {}", new_session_name, snapshot_image),
296 }];
297 for network in extra_networks {
298 steps.push(ResumeStep {
299 command: docker.to_string(),
300 args: vec![
301 "network".to_string(),
302 "connect".to_string(),
303 network.clone(),
304 new_session_name.to_string(),
305 ],
306 description: format!("Connect {} to network {}", new_session_name, network),
307 });
308 }
309 steps.push(ResumeStep {
310 command: docker.to_string(),
311 args: vec!["start".to_string(), new_session_name.to_string()],
312 description: format!("Run the new command in {}", new_session_name),
313 });
314 steps
315}
316
317fn docker_snapshot_plan(
318 record: &ExecutionRecord,
319 backend: &str,
320 session_name: &str,
321 command: &str,
322 attempt: u64,
323 live_resource_limits: Option<Vec<String>>,
324) -> ResumePlan {
325 let snapshot_image = build_snapshot_image_name(session_name, attempt);
326 let new_session_name = build_resumed_session_name(session_name, attempt);
327 let docker = docker_command().to_string_lossy().to_string();
328
329 let mut launch_options = build_launch_options(record);
330 if let Some(limits) = live_resource_limits {
333 launch_options.resource_limits = limits;
334 }
335 let mut container_args = vec!["--name".to_string(), new_session_name.clone()];
336 if let Some(user) = record_option(record, "user") {
337 container_args.push("--user".to_string());
338 container_args.push(user.to_string());
339 }
340 container_args.extend(
341 build_docker_runtime_args(&launch_options)
342 .into_iter()
343 .map(str::to_string),
344 );
345 container_args.push(snapshot_image.clone());
346 container_args.push("sh".to_string());
347 container_args.push("-c".to_string());
348 container_args.push(command.to_string());
349
350 let mut steps = vec![ResumeStep {
351 command: docker.clone(),
352 args: vec![
353 "commit".to_string(),
354 session_name.to_string(),
355 snapshot_image.clone(),
356 ],
357 description: format!("Snapshot container {} as {}", session_name, snapshot_image),
358 }];
359 steps.extend(build_snapshot_start_steps(
360 &docker,
361 container_args,
362 &docker_networks(&launch_options)
363 .into_iter()
364 .skip(1)
365 .map(str::to_string)
366 .collect::<Vec<_>>(),
367 &new_session_name,
368 &snapshot_image,
369 ));
370
371 ResumePlan {
372 mode: ResumeMode::DockerSnapshot,
373 backend: backend.to_string(),
374 session_name: session_name.to_string(),
375 new_session_name: Some(new_session_name.clone()),
376 snapshot_image: Some(snapshot_image.clone()),
377 command: command.to_string(),
378 attempt,
379 resource_limits: launch_options.resource_limits.clone(),
380 steps,
381 launch_options: None,
382 message: format!(
383 "Resumed session in new container {} from snapshot of {}",
384 new_session_name, session_name
385 ),
386 }
387}
388
389pub fn build_resume_plan(
391 record: &ExecutionRecord,
392 new_command: Option<&str>,
393 probe: &SessionProbe,
394) -> Result<ResumePlan, String> {
395 build_resume_plan_with_limits(record, new_command, probe, None)
396}
397
398pub fn build_resume_plan_with_limits(
401 record: &ExecutionRecord,
402 new_command: Option<&str>,
403 probe: &SessionProbe,
404 live_resource_limits: Option<Vec<String>>,
405) -> Result<ResumePlan, String> {
406 let session_name = record_option(record, "sessionName")
407 .ok_or_else(|| "Execution record does not contain an isolation session name.".to_string())?
408 .to_string();
409
410 if record_option(record, "isolationMode") != Some("detached") {
411 return Err("Only detached isolated executions can be resumed.".to_string());
412 }
413
414 if probe.alive {
415 return Err(format!(
416 "Session \"{}\" is still running. Use `$ --attach {}` to re-enter it, or `$ --stop {}` first.",
417 session_name, record.uuid, record.uuid
418 ));
419 }
420
421 let backend = record_option(record, "isolated")
422 .unwrap_or("unknown")
423 .to_string();
424 let command = new_command.unwrap_or(record.command.as_str()).to_string();
425 if command.is_empty() {
426 return Err(format!(
427 "Execution \"{}\" has no stored command to resume.",
428 record.uuid
429 ));
430 }
431
432 let attempt = record
433 .options
434 .get("resumeCount")
435 .and_then(|value| value.as_u64())
436 .unwrap_or(0)
437 + 1;
438
439 if backend == "docker" && probe.state == SessionState::Stopped {
440 return Ok(match new_command {
441 None => ResumePlan {
442 mode: ResumeMode::DockerStart,
443 backend,
444 new_session_name: None,
445 snapshot_image: None,
446 command,
447 attempt,
448 resource_limits: Vec::new(),
449 steps: vec![ResumeStep {
450 command: docker_command().to_string_lossy().to_string(),
451 args: vec!["start".to_string(), session_name.clone()],
452 description: format!("Start stopped container {}", session_name),
453 }],
454 launch_options: None,
455 message: format!("Resumed detached docker container: {}", session_name),
456 session_name,
457 },
458 Some(new_command) => docker_snapshot_plan(
459 record,
460 &backend,
461 &session_name,
462 new_command,
463 attempt,
464 live_resource_limits,
465 ),
466 });
467 }
468
469 Ok(ResumePlan {
470 mode: ResumeMode::Relaunch,
471 message: format!("Relaunched {} session: {}", backend, session_name),
472 backend,
473 new_session_name: None,
474 snapshot_image: None,
475 command,
476 attempt,
477 resource_limits: Vec::new(),
478 steps: Vec::new(),
479 launch_options: Some(build_launch_options(record)),
480 session_name,
481 })
482}
483
484pub struct ResumeResultFields<'a> {
486 pub identifier: &'a str,
487 pub uuid: &'a str,
488 pub mode: ResumeMode,
489 pub backend: &'a str,
490 pub session_name: &'a str,
491 pub previous_session_name: Option<&'a str>,
492 pub snapshot_image: Option<&'a str>,
493 pub resource_limits: &'a [String],
495 pub command: &'a str,
496 pub message: &'a str,
497}
498
499pub fn format_resume_result_as_links_notation(result: &ResumeResultFields) -> String {
501 let mut lines = vec![
502 "executionResume".to_string(),
503 format!(
504 " identifier {}",
505 escape_for_links_notation(result.identifier)
506 ),
507 format!(" uuid {}", escape_for_links_notation(result.uuid)),
508 format!(" mode {}", escape_for_links_notation(result.mode.as_str())),
509 format!(" backend {}", escape_for_links_notation(result.backend)),
510 format!(
511 " sessionName {}",
512 escape_for_links_notation(result.session_name)
513 ),
514 ];
515 if let Some(previous) = result.previous_session_name {
516 lines.push(format!(
517 " previousSessionName {}",
518 escape_for_links_notation(previous)
519 ));
520 }
521 if let Some(image) = result.snapshot_image {
522 lines.push(format!(
523 " snapshotImage {}",
524 escape_for_links_notation(image)
525 ));
526 }
527 if !result.resource_limits.is_empty() {
528 lines.push(format!(
529 " resourceLimits {}",
530 escape_for_links_notation(&result.resource_limits.join(" "))
531 ));
532 }
533 lines.push(format!(
534 " command {}",
535 escape_for_links_notation(result.command)
536 ));
537 lines.push(format!(
538 " message {}",
539 escape_for_links_notation(result.message)
540 ));
541 lines.join("\n")
542}
543
544pub fn format_resume_result(result: &ResumeResultFields, output_format: Option<&str>) -> String {
546 match output_format {
547 Some("json") => {
548 let mut value = json!({
549 "identifier": result.identifier,
550 "uuid": result.uuid,
551 "mode": result.mode.as_str(),
552 "backend": result.backend,
553 "sessionName": result.session_name,
554 "previousSessionName": result.previous_session_name,
555 "snapshotImage": result.snapshot_image,
556 "resourceLimits": (!result.resource_limits.is_empty())
557 .then_some(result.resource_limits),
558 "command": result.command,
559 "message": result.message,
560 });
561 if let Value::Object(ref mut map) = value {
562 map.retain(|_, entry| !entry.is_null());
563 }
564 serde_json::to_string_pretty(&value).unwrap_or_else(|_| "{}".to_string())
565 }
566 Some("text") => {
567 let mut lines = vec![
568 format!("Resume Mode: {}", result.mode.as_str()),
569 format!("UUID: {}", result.uuid),
570 format!("Backend: {}", result.backend),
571 format!("Session Name: {}", result.session_name),
572 format!("Command: {}", result.command),
573 ];
574 if !result.resource_limits.is_empty() {
575 lines.push(format!(
576 "Resource Limits: {}",
577 result.resource_limits.join(" ")
578 ));
579 }
580 lines.push(result.message.to_string());
581 lines.join("\n")
582 }
583 _ => format_resume_result_as_links_notation(result),
584 }
585}
586
587pub fn apply_resume_to_record(
590 record: &mut ExecutionRecord,
591 plan: &ResumePlan,
592 container_id: Option<&str>,
593) {
594 let attempt = crate::execution_attempt::create_attempt(
595 record,
596 plan.mode.as_str(),
597 active_session_name(plan),
598 );
599 crate::execution_attempt::archive_attempt(record);
600 record.attempt = Some(attempt);
601 record
602 .options
603 .insert("resumeCount".to_string(), json!(plan.attempt));
604 record.options.insert(
605 "resumedAt".to_string(),
606 json!(record.attempt.as_ref().unwrap().started_at),
607 );
608 record.options.remove("stopRequestedAt");
610 if !plan.resource_limits.is_empty() {
611 record
612 .options
613 .insert("resourceLimits".to_string(), json!(plan.resource_limits));
614 }
615
616 if let Some(new_session_name) = &plan.new_session_name {
617 let mut history = record
618 .options
619 .get("sessionNameHistory")
620 .and_then(|value| value.as_array())
621 .cloned()
622 .unwrap_or_default();
623 if let Some(previous) = record.options.get("sessionName").cloned() {
624 history.push(previous);
625 }
626 record
627 .options
628 .insert("sessionNameHistory".to_string(), Value::Array(history));
629 record
630 .options
631 .insert("sessionName".to_string(), json!(new_session_name));
632 }
633 if let Some(snapshot_image) = &plan.snapshot_image {
634 record
635 .options
636 .insert("image".to_string(), json!(snapshot_image));
637 }
638 if let Some(container_id) = container_id {
639 record
640 .options
641 .insert("containerId".to_string(), json!(container_id));
642 }
643
644 record.command = plan.command.clone();
645 record.status = ExecutionStatus::Executing;
646 record.exit_code = None;
647 record.end_time = None;
648 record.exit_reason = None;
649 record.oom_killed = None;
650}
651
652fn active_session_name(plan: &ResumePlan) -> &str {
653 plan.new_session_name
654 .as_deref()
655 .unwrap_or(&plan.session_name)
656}
657
658pub fn resume_execution(
660 store: Option<&ExecutionStore>,
661 identifier: &str,
662 command: Option<&str>,
663 output_format: Option<&str>,
664) -> ExecutionResumeResult {
665 resume_execution_with(
666 store,
667 identifier,
668 command,
669 output_format,
670 &SystemCommandRunner,
671 &SystemResumeHooks,
672 )
673}
674
675pub fn resume_execution_with<R: CommandRunner, H: ResumeHooks>(
678 store: Option<&ExecutionStore>,
679 identifier: &str,
680 command: Option<&str>,
681 output_format: Option<&str>,
682 runner: &R,
683 hooks: &H,
684) -> ExecutionResumeResult {
685 let Some(store) = store else {
686 return ExecutionResumeResult {
687 success: false,
688 output: None,
689 error: Some("Execution tracking is disabled.".to_string()),
690 };
691 };
692
693 let Some(mut record) = store.get(identifier) else {
694 return ExecutionResumeResult {
695 success: false,
696 output: None,
697 error: Some(format!(
698 "No execution found with UUID or session name: {}",
699 identifier
700 )),
701 };
702 };
703
704 let probe = probe_session(&record, runner);
705 let live_resource_limits = match (command, record_option(&record, "sessionName")) {
708 (Some(_), Some(session_name))
709 if record_option(&record, "isolated") == Some("docker")
710 && !probe.alive
711 && probe.state == SessionState::Stopped =>
712 {
713 read_docker_resource_limits(session_name, runner)
714 }
715 _ => None,
716 };
717 let plan = match build_resume_plan_with_limits(&record, command, &probe, live_resource_limits) {
718 Ok(plan) => plan,
719 Err(error) => {
720 return ExecutionResumeResult {
721 success: false,
722 output: None,
723 error: Some(error),
724 }
725 }
726 };
727
728 let mut container_id: Option<String> = None;
729 let mut attempt = crate::execution_attempt::create_attempt(
730 &record,
731 plan.mode.as_str(),
732 active_session_name(&plan),
733 );
734 let mut lifecycle_record = record.clone();
735 lifecycle_record.attempt = Some(attempt.clone());
736 crate::execution_attempt::append_lifecycle(&lifecycle_record, "resume-started", json!({}));
737
738 if plan.mode == ResumeMode::Relaunch {
739 let launch_options = plan.launch_options.clone().unwrap_or_default();
740 let launch_result = hooks.relaunch(&plan.backend, &plan.command, &launch_options);
741 if !launch_result.success {
742 crate::execution_attempt::append_lifecycle(
743 &lifecycle_record,
744 "launch-failed",
745 json!({ "error": launch_result.message }),
746 );
747 return ExecutionResumeResult {
748 success: false,
749 output: None,
750 error: Some(format!(
751 "Failed to relaunch {} session \"{}\": {}",
752 plan.backend, plan.session_name, launch_result.message
753 )),
754 };
755 }
756 container_id = launch_result.container_id;
757 } else {
758 for step in &plan.steps {
759 let result = runner.run(&step.command, &step.args);
760 if !result.success {
761 let detail = if !result.stderr.trim().is_empty() {
762 result.stderr.trim().to_string()
763 } else {
764 result.error.clone().unwrap_or_else(|| {
765 format!(
766 "exit code {}",
767 result
768 .status
769 .map(|code| code.to_string())
770 .unwrap_or_else(|| "unknown".to_string())
771 )
772 })
773 };
774 crate::execution_attempt::append_lifecycle(
775 &lifecycle_record,
776 "launch-failed",
777 json!({ "error": detail }),
778 );
779 return ExecutionResumeResult {
780 success: false,
781 output: None,
782 error: Some(format!(
783 "Failed to resume {} session \"{}\": {}",
784 plan.backend, plan.session_name, detail
785 )),
786 };
787 }
788 let stdout = result.stdout.trim().to_string();
789 if !stdout.is_empty() {
790 container_id = Some(stdout);
791 }
792 }
793
794 if let Some(line) = build_resource_limits_status_line(&plan.resource_limits) {
795 if !record.log_path.is_empty() {
796 append_log_file(&PathBuf::from(&record.log_path), &format!("{}\n", line));
797 }
798 }
799 }
800
801 let previous_session_name = plan
802 .new_session_name
803 .as_ref()
804 .map(|_| plan.session_name.clone());
805 apply_resume_to_record(&mut record, &plan, container_id.as_deref());
806 attempt.launch_accepted_at = Some(Utc::now().to_rfc3339());
807 record
808 .options
809 .insert("resumedAt".into(), json!(attempt.started_at));
810 record.attempt = Some(attempt.clone());
811 if let Err(error) = store.save(&record) {
812 return ExecutionResumeResult {
813 success: false,
814 output: None,
815 error: Some(error),
816 };
817 }
818 crate::execution_attempt::append_lifecycle(&record, "launch-accepted", json!({}));
819 if plan.backend == "docker" {
820 let attachment = hooks.attach_watcher(active_session_name(&plan), &record);
821 let fields = match &attachment {
822 Ok(()) => json!({"watcherAttachedAt": Utc::now().to_rfc3339()}),
823 Err(error) => json!({"watcherError": error}),
824 };
825 match store.patch_attempt(&record.uuid, attempt.number, fields) {
826 Ok(Some(current)) => crate::execution_attempt::append_lifecycle(
827 ¤t,
828 if attachment.is_ok() {
829 "watcher-attached"
830 } else {
831 "watcher-attachment-failed"
832 },
833 json!({"error": attachment.as_ref().err()}),
834 ),
835 Ok(None) => {}
836 Err(error) => {
837 return ExecutionResumeResult {
838 success: false,
839 output: None,
840 error: Some(error),
841 }
842 }
843 }
844 if let Err(error) = attachment {
845 return ExecutionResumeResult {
846 success: false,
847 output: None,
848 error: Some(format!(
849 "Launch accepted, but completion watcher attachment failed: {}",
850 error
851 )),
852 };
853 }
854 }
855
856 let session_name = record_option(&record, "sessionName")
857 .unwrap_or(&plan.session_name)
858 .to_string();
859
860 ExecutionResumeResult {
861 success: true,
862 output: Some(format_resume_result(
863 &ResumeResultFields {
864 identifier,
865 uuid: &record.uuid,
866 mode: plan.mode,
867 backend: &plan.backend,
868 session_name: &session_name,
869 previous_session_name: previous_session_name.as_deref(),
870 snapshot_image: plan.snapshot_image.as_deref(),
871 resource_limits: &plan.resource_limits,
872 command: &plan.command,
873 message: &plan.message,
874 },
875 output_format,
876 )),
877 error: None,
878 }
879}
880
881#[cfg(test)]
882#[path = "execution_resume_cases.rs"]
883mod tests;