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
98 fn relaunch(&self, backend: &str, command: &str, options: &IsolationOptions)
100 -> IsolationResult;
101
102 fn reconcile(&self, record: &ExecutionRecord) -> ExecutionRecord;
104}
105
106#[derive(Debug, Default)]
108pub struct SystemResumeHooks;
109
110impl ResumeHooks for SystemResumeHooks {
111 fn start_watcher(&self, session_name: &str, record: &ExecutionRecord) {
112 let log_path = (!record.log_path.is_empty()).then(|| PathBuf::from(&record.log_path));
113 start_detached_docker_completion_watcher_with(
114 session_name,
115 get_docker_container_cleanup_policy(&build_launch_options(record)),
116 log_path.as_ref(),
117 Some(record.uuid.as_str()),
120 &DockerWatcherOptions {
121 since: None,
122 recover_on_kill: keeps_kill_recovery(session_name, record),
123 },
124 );
125 }
126
127 fn relaunch(
128 &self,
129 backend: &str,
130 command: &str,
131 options: &IsolationOptions,
132 ) -> IsolationResult {
133 run_isolated(backend, command, options)
134 }
135
136 fn reconcile(&self, record: &ExecutionRecord) -> ExecutionRecord {
137 crate::status_formatter::enrich_detached_status(record)
138 }
139}
140
141pub fn keeps_kill_recovery(session_name: &str, record: &ExecutionRecord) -> bool {
145 record_option(record, "sessionName") == Some(session_name)
146 && record
147 .options
148 .get("onKillResume")
149 .is_some_and(|value| value.as_u64().unwrap_or(0) > 0)
150}
151
152fn record_option<'a>(record: &'a ExecutionRecord, key: &str) -> Option<&'a str> {
153 record.options.get(key).and_then(|value| value.as_str())
154}
155
156fn record_flag(record: &ExecutionRecord, key: &str) -> bool {
157 record
158 .options
159 .get(key)
160 .and_then(|value| value.as_bool())
161 .unwrap_or(false)
162}
163
164fn record_strings(record: &ExecutionRecord, key: &str) -> Vec<String> {
165 record
166 .options
167 .get(key)
168 .and_then(|value| value.as_array())
169 .map(|items| {
170 items
171 .iter()
172 .filter_map(|item| item.as_str().map(str::to_string))
173 .collect()
174 })
175 .unwrap_or_default()
176}
177
178pub fn build_snapshot_image_name(session_name: &str, attempt: u64) -> String {
182 let mut sanitized = String::new();
183 let mut pending_dash = false;
184 for ch in session_name.to_lowercase().chars() {
185 if ch.is_ascii_lowercase() || ch.is_ascii_digit() || ch == '.' || ch == '_' || ch == '-' {
186 if pending_dash {
187 sanitized.push('-');
188 pending_dash = false;
189 }
190 sanitized.push(ch);
191 } else if !sanitized.is_empty() || !pending_dash {
192 pending_dash = true;
193 }
194 }
195 if pending_dash {
196 sanitized.push('-');
197 }
198 let sanitized = sanitized.trim_start_matches(['-', '.']).to_string();
199 let sanitized = if sanitized.is_empty() {
200 "session".to_string()
201 } else {
202 sanitized
203 };
204 format!("start-command-resume/{}:{}", sanitized, attempt)
205}
206
207pub fn build_resumed_session_name(session_name: &str, attempt: u64) -> String {
209 format!("{}-resume-{}", session_name, attempt)
210}
211
212pub fn build_launch_options(record: &ExecutionRecord) -> IsolationOptions {
215 let networks = record_strings(record, "networks");
216 IsolationOptions {
217 session: record_option(record, "sessionName").map(str::to_string),
218 image: record_option(record, "image").map(str::to_string),
219 volumes: record_strings(record, "volumes"),
220 mounts: record_strings(record, "mounts"),
221 env: record_strings(record, "env"),
222 privileged: record_flag(record, "privileged"),
223 network: networks.first().cloned(),
224 networks,
225 network_aliases: record_strings(record, "networkAliases"),
226 resource_limits: normalize_resource_limits(record.options.get("resourceLimits")),
228 on_kill_resume: None,
229 recovery_command: None,
230 endpoint: record_option(record, "endpoint").map(str::to_string),
231 detached: true,
232 user: record_option(record, "user").map(str::to_string),
233 keep_alive: record_flag(record, "keepAlive"),
234 auto_remove_docker_container: record_flag(record, "autoRemoveDockerContainer"),
235 always_cleanup_container: record_flag(record, "alwaysCleanupContainer"),
236 keep_container: record_flag(record, "keepContainer"),
237 keep_container_on_fail: record_flag(record, "keepContainerOnFail"),
238 shell: record_option(record, "shell").unwrap_or("auto").to_string(),
239 log_path: (!record.log_path.is_empty()).then(|| PathBuf::from(&record.log_path)),
241 execution_id: Some(record.uuid.clone()),
244 }
245}
246
247fn build_snapshot_start_steps(
255 docker: &str,
256 container_args: Vec<String>,
257 extra_networks: &[String],
258 new_session_name: &str,
259 snapshot_image: &str,
260) -> Vec<ResumeStep> {
261 if extra_networks.is_empty() {
262 let mut args = vec!["run".to_string(), "-d".to_string()];
263 args.extend(container_args);
264 return vec![ResumeStep {
265 command: docker.to_string(),
266 args,
267 description: format!("Run the new command in {}", new_session_name),
268 }];
269 }
270
271 let mut create_args = vec!["create".to_string()];
272 create_args.extend(container_args);
273 let mut steps = vec![ResumeStep {
274 command: docker.to_string(),
275 args: create_args,
276 description: format!("Create {} from {}", new_session_name, snapshot_image),
277 }];
278 for network in extra_networks {
279 steps.push(ResumeStep {
280 command: docker.to_string(),
281 args: vec![
282 "network".to_string(),
283 "connect".to_string(),
284 network.clone(),
285 new_session_name.to_string(),
286 ],
287 description: format!("Connect {} to network {}", new_session_name, network),
288 });
289 }
290 steps.push(ResumeStep {
291 command: docker.to_string(),
292 args: vec!["start".to_string(), new_session_name.to_string()],
293 description: format!("Run the new command in {}", new_session_name),
294 });
295 steps
296}
297
298fn docker_snapshot_plan(
299 record: &ExecutionRecord,
300 backend: &str,
301 session_name: &str,
302 command: &str,
303 attempt: u64,
304 live_resource_limits: Option<Vec<String>>,
305) -> ResumePlan {
306 let snapshot_image = build_snapshot_image_name(session_name, attempt);
307 let new_session_name = build_resumed_session_name(session_name, attempt);
308 let docker = docker_command().to_string_lossy().to_string();
309
310 let mut launch_options = build_launch_options(record);
311 if let Some(limits) = live_resource_limits {
314 launch_options.resource_limits = limits;
315 }
316 let mut container_args = vec!["--name".to_string(), new_session_name.clone()];
317 if let Some(user) = record_option(record, "user") {
318 container_args.push("--user".to_string());
319 container_args.push(user.to_string());
320 }
321 container_args.extend(
322 build_docker_runtime_args(&launch_options)
323 .into_iter()
324 .map(str::to_string),
325 );
326 container_args.push(snapshot_image.clone());
327 container_args.push("sh".to_string());
328 container_args.push("-c".to_string());
329 container_args.push(command.to_string());
330
331 let mut steps = vec![ResumeStep {
332 command: docker.clone(),
333 args: vec![
334 "commit".to_string(),
335 session_name.to_string(),
336 snapshot_image.clone(),
337 ],
338 description: format!("Snapshot container {} as {}", session_name, snapshot_image),
339 }];
340 steps.extend(build_snapshot_start_steps(
341 &docker,
342 container_args,
343 &docker_networks(&launch_options)
344 .into_iter()
345 .skip(1)
346 .map(str::to_string)
347 .collect::<Vec<_>>(),
348 &new_session_name,
349 &snapshot_image,
350 ));
351
352 ResumePlan {
353 mode: ResumeMode::DockerSnapshot,
354 backend: backend.to_string(),
355 session_name: session_name.to_string(),
356 new_session_name: Some(new_session_name.clone()),
357 snapshot_image: Some(snapshot_image.clone()),
358 command: command.to_string(),
359 attempt,
360 resource_limits: launch_options.resource_limits.clone(),
361 steps,
362 launch_options: None,
363 message: format!(
364 "Resumed session in new container {} from snapshot of {}",
365 new_session_name, session_name
366 ),
367 }
368}
369
370pub fn build_resume_plan(
372 record: &ExecutionRecord,
373 new_command: Option<&str>,
374 probe: &SessionProbe,
375) -> Result<ResumePlan, String> {
376 build_resume_plan_with_limits(record, new_command, probe, None)
377}
378
379pub fn build_resume_plan_with_limits(
382 record: &ExecutionRecord,
383 new_command: Option<&str>,
384 probe: &SessionProbe,
385 live_resource_limits: Option<Vec<String>>,
386) -> Result<ResumePlan, String> {
387 let session_name = record_option(record, "sessionName")
388 .ok_or_else(|| "Execution record does not contain an isolation session name.".to_string())?
389 .to_string();
390
391 if record_option(record, "isolationMode") != Some("detached") {
392 return Err("Only detached isolated executions can be resumed.".to_string());
393 }
394
395 if probe.alive {
396 return Err(format!(
397 "Session \"{}\" is still running. Use `$ --attach {}` to re-enter it, or `$ --stop {}` first.",
398 session_name, record.uuid, record.uuid
399 ));
400 }
401
402 let backend = record_option(record, "isolated")
403 .unwrap_or("unknown")
404 .to_string();
405 let command = new_command.unwrap_or(record.command.as_str()).to_string();
406 if command.is_empty() {
407 return Err(format!(
408 "Execution \"{}\" has no stored command to resume.",
409 record.uuid
410 ));
411 }
412
413 let attempt = record
414 .options
415 .get("resumeCount")
416 .and_then(|value| value.as_u64())
417 .unwrap_or(0)
418 + 1;
419
420 if backend == "docker" && probe.state == SessionState::Stopped {
421 return Ok(match new_command {
422 None => ResumePlan {
423 mode: ResumeMode::DockerStart,
424 backend,
425 new_session_name: None,
426 snapshot_image: None,
427 command,
428 attempt,
429 resource_limits: Vec::new(),
430 steps: vec![ResumeStep {
431 command: docker_command().to_string_lossy().to_string(),
432 args: vec!["start".to_string(), session_name.clone()],
433 description: format!("Start stopped container {}", session_name),
434 }],
435 launch_options: None,
436 message: format!("Resumed detached docker container: {}", session_name),
437 session_name,
438 },
439 Some(new_command) => docker_snapshot_plan(
440 record,
441 &backend,
442 &session_name,
443 new_command,
444 attempt,
445 live_resource_limits,
446 ),
447 });
448 }
449
450 Ok(ResumePlan {
451 mode: ResumeMode::Relaunch,
452 message: format!("Relaunched {} session: {}", backend, session_name),
453 backend,
454 new_session_name: None,
455 snapshot_image: None,
456 command,
457 attempt,
458 resource_limits: Vec::new(),
459 steps: Vec::new(),
460 launch_options: Some(build_launch_options(record)),
461 session_name,
462 })
463}
464
465pub struct ResumeResultFields<'a> {
467 pub identifier: &'a str,
468 pub uuid: &'a str,
469 pub mode: ResumeMode,
470 pub backend: &'a str,
471 pub session_name: &'a str,
472 pub previous_session_name: Option<&'a str>,
473 pub snapshot_image: Option<&'a str>,
474 pub resource_limits: &'a [String],
476 pub command: &'a str,
477 pub message: &'a str,
478}
479
480pub fn format_resume_result_as_links_notation(result: &ResumeResultFields) -> String {
482 let mut lines = vec![
483 "executionResume".to_string(),
484 format!(
485 " identifier {}",
486 escape_for_links_notation(result.identifier)
487 ),
488 format!(" uuid {}", escape_for_links_notation(result.uuid)),
489 format!(" mode {}", escape_for_links_notation(result.mode.as_str())),
490 format!(" backend {}", escape_for_links_notation(result.backend)),
491 format!(
492 " sessionName {}",
493 escape_for_links_notation(result.session_name)
494 ),
495 ];
496 if let Some(previous) = result.previous_session_name {
497 lines.push(format!(
498 " previousSessionName {}",
499 escape_for_links_notation(previous)
500 ));
501 }
502 if let Some(image) = result.snapshot_image {
503 lines.push(format!(
504 " snapshotImage {}",
505 escape_for_links_notation(image)
506 ));
507 }
508 if !result.resource_limits.is_empty() {
509 lines.push(format!(
510 " resourceLimits {}",
511 escape_for_links_notation(&result.resource_limits.join(" "))
512 ));
513 }
514 lines.push(format!(
515 " command {}",
516 escape_for_links_notation(result.command)
517 ));
518 lines.push(format!(
519 " message {}",
520 escape_for_links_notation(result.message)
521 ));
522 lines.join("\n")
523}
524
525pub fn format_resume_result(result: &ResumeResultFields, output_format: Option<&str>) -> String {
527 match output_format {
528 Some("json") => {
529 let mut value = json!({
530 "identifier": result.identifier,
531 "uuid": result.uuid,
532 "mode": result.mode.as_str(),
533 "backend": result.backend,
534 "sessionName": result.session_name,
535 "previousSessionName": result.previous_session_name,
536 "snapshotImage": result.snapshot_image,
537 "resourceLimits": (!result.resource_limits.is_empty())
538 .then_some(result.resource_limits),
539 "command": result.command,
540 "message": result.message,
541 });
542 if let Value::Object(ref mut map) = value {
543 map.retain(|_, entry| !entry.is_null());
544 }
545 serde_json::to_string_pretty(&value).unwrap_or_else(|_| "{}".to_string())
546 }
547 Some("text") => {
548 let mut lines = vec![
549 format!("Resume Mode: {}", result.mode.as_str()),
550 format!("UUID: {}", result.uuid),
551 format!("Backend: {}", result.backend),
552 format!("Session Name: {}", result.session_name),
553 format!("Command: {}", result.command),
554 ];
555 if !result.resource_limits.is_empty() {
556 lines.push(format!(
557 "Resource Limits: {}",
558 result.resource_limits.join(" ")
559 ));
560 }
561 lines.push(result.message.to_string());
562 lines.join("\n")
563 }
564 _ => format_resume_result_as_links_notation(result),
565 }
566}
567
568pub fn apply_resume_to_record(
571 record: &mut ExecutionRecord,
572 plan: &ResumePlan,
573 container_id: Option<&str>,
574) {
575 record
576 .options
577 .insert("resumeCount".to_string(), json!(plan.attempt));
578 record
579 .options
580 .insert("resumedAt".to_string(), json!(Utc::now().to_rfc3339()));
581 record.options.remove("stopRequestedAt");
583 if !plan.resource_limits.is_empty() {
584 record
585 .options
586 .insert("resourceLimits".to_string(), json!(plan.resource_limits));
587 }
588
589 if let Some(new_session_name) = &plan.new_session_name {
590 let mut history = record
591 .options
592 .get("sessionNameHistory")
593 .and_then(|value| value.as_array())
594 .cloned()
595 .unwrap_or_default();
596 if let Some(previous) = record.options.get("sessionName").cloned() {
597 history.push(previous);
598 }
599 record
600 .options
601 .insert("sessionNameHistory".to_string(), Value::Array(history));
602 record
603 .options
604 .insert("sessionName".to_string(), json!(new_session_name));
605 }
606 if let Some(snapshot_image) = &plan.snapshot_image {
607 record
608 .options
609 .insert("image".to_string(), json!(snapshot_image));
610 }
611 if let Some(container_id) = container_id {
612 record
613 .options
614 .insert("containerId".to_string(), json!(container_id));
615 }
616
617 record.command = plan.command.clone();
618 record.status = ExecutionStatus::Executing;
619 record.exit_code = None;
620 record.end_time = None;
621 record.exit_reason = None;
622 record.oom_killed = None;
623}
624
625fn active_session_name(plan: &ResumePlan) -> &str {
626 plan.new_session_name
627 .as_deref()
628 .unwrap_or(&plan.session_name)
629}
630
631pub fn resume_execution(
633 store: Option<&ExecutionStore>,
634 identifier: &str,
635 command: Option<&str>,
636 output_format: Option<&str>,
637) -> ExecutionResumeResult {
638 resume_execution_with(
639 store,
640 identifier,
641 command,
642 output_format,
643 &SystemCommandRunner,
644 &SystemResumeHooks,
645 )
646}
647
648pub fn resume_execution_with<R: CommandRunner, H: ResumeHooks>(
651 store: Option<&ExecutionStore>,
652 identifier: &str,
653 command: Option<&str>,
654 output_format: Option<&str>,
655 runner: &R,
656 hooks: &H,
657) -> ExecutionResumeResult {
658 let Some(store) = store else {
659 return ExecutionResumeResult {
660 success: false,
661 output: None,
662 error: Some("Execution tracking is disabled.".to_string()),
663 };
664 };
665
666 let Some(mut record) = store.get(identifier) else {
667 return ExecutionResumeResult {
668 success: false,
669 output: None,
670 error: Some(format!(
671 "No execution found with UUID or session name: {}",
672 identifier
673 )),
674 };
675 };
676
677 let probe = probe_session(&record, runner);
678 let live_resource_limits = match (command, record_option(&record, "sessionName")) {
681 (Some(_), Some(session_name))
682 if record_option(&record, "isolated") == Some("docker")
683 && !probe.alive
684 && probe.state == SessionState::Stopped =>
685 {
686 read_docker_resource_limits(session_name, runner)
687 }
688 _ => None,
689 };
690 let plan = match build_resume_plan_with_limits(&record, command, &probe, live_resource_limits) {
691 Ok(plan) => plan,
692 Err(error) => {
693 return ExecutionResumeResult {
694 success: false,
695 output: None,
696 error: Some(error),
697 }
698 }
699 };
700
701 let mut container_id: Option<String> = None;
702
703 if plan.mode == ResumeMode::Relaunch {
704 let launch_options = plan.launch_options.clone().unwrap_or_default();
705 let launch_result = hooks.relaunch(&plan.backend, &plan.command, &launch_options);
706 if !launch_result.success {
707 return ExecutionResumeResult {
708 success: false,
709 output: None,
710 error: Some(format!(
711 "Failed to relaunch {} session \"{}\": {}",
712 plan.backend, plan.session_name, launch_result.message
713 )),
714 };
715 }
716 container_id = launch_result.container_id;
717 } else {
718 for step in &plan.steps {
719 let result = runner.run(&step.command, &step.args);
720 if !result.success {
721 let detail = if !result.stderr.trim().is_empty() {
722 result.stderr.trim().to_string()
723 } else {
724 result.error.clone().unwrap_or_else(|| {
725 format!(
726 "exit code {}",
727 result
728 .status
729 .map(|code| code.to_string())
730 .unwrap_or_else(|| "unknown".to_string())
731 )
732 })
733 };
734 return ExecutionResumeResult {
735 success: false,
736 output: None,
737 error: Some(format!(
738 "Failed to resume {} session \"{}\": {}",
739 plan.backend, plan.session_name, detail
740 )),
741 };
742 }
743 let stdout = result.stdout.trim().to_string();
744 if !stdout.is_empty() {
745 container_id = Some(stdout);
746 }
747 }
748
749 if let Some(line) = build_resource_limits_status_line(&plan.resource_limits) {
750 if !record.log_path.is_empty() {
751 append_log_file(&PathBuf::from(&record.log_path), &format!("{}\n", line));
752 }
753 }
754
755 hooks.start_watcher(active_session_name(&plan), &record);
758 }
759
760 let previous_session_name = plan
761 .new_session_name
762 .as_ref()
763 .map(|_| plan.session_name.clone());
764 apply_resume_to_record(&mut record, &plan, container_id.as_deref());
765 if let Err(error) = store.save(&record) {
766 return ExecutionResumeResult {
767 success: false,
768 output: None,
769 error: Some(error),
770 };
771 }
772
773 let session_name = record_option(&record, "sessionName")
774 .unwrap_or(&plan.session_name)
775 .to_string();
776
777 ExecutionResumeResult {
778 success: true,
779 output: Some(format_resume_result(
780 &ResumeResultFields {
781 identifier,
782 uuid: &record.uuid,
783 mode: plan.mode,
784 backend: &plan.backend,
785 session_name: &session_name,
786 previous_session_name: previous_session_name.as_deref(),
787 snapshot_image: plan.snapshot_image.as_deref(),
788 resource_limits: &plan.resource_limits,
789 command: &plan.command,
790 message: &plan.message,
791 },
792 output_format,
793 )),
794 error: None,
795 }
796}
797
798#[cfg(test)]
799#[path = "execution_resume_cases.rs"]
800mod tests;