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,
26};
27use crate::execution_control::{CommandRunner, SystemCommandRunner};
28use crate::execution_store::{ExecutionRecord, ExecutionStatus, ExecutionStore};
29use crate::isolation::{run_isolated, IsolationOptions, IsolationResult};
30use crate::output_blocks::escape_for_links_notation;
31use crate::session_probe::{probe_session, SessionProbe, SessionState};
32
33#[derive(Debug, Clone, Copy, PartialEq, Eq)]
35pub enum ResumeMode {
36 DockerStart,
37 DockerSnapshot,
38 Relaunch,
39}
40
41impl ResumeMode {
42 pub fn as_str(self) -> &'static str {
43 match self {
44 ResumeMode::DockerStart => "docker-start",
45 ResumeMode::DockerSnapshot => "docker-snapshot",
46 ResumeMode::Relaunch => "relaunch",
47 }
48 }
49}
50
51#[derive(Debug, Clone, PartialEq, Eq)]
53pub struct ResumeStep {
54 pub command: String,
55 pub args: Vec<String>,
56 pub description: String,
57}
58
59#[derive(Debug, Clone)]
61pub struct ResumePlan {
62 pub mode: ResumeMode,
63 pub backend: String,
64 pub session_name: String,
65 pub new_session_name: Option<String>,
67 pub snapshot_image: Option<String>,
68 pub command: String,
69 pub attempt: u64,
70 pub steps: Vec<ResumeStep>,
71 pub launch_options: Option<IsolationOptions>,
72 pub message: String,
73}
74
75pub struct ExecutionResumeResult {
77 pub success: bool,
78 pub output: Option<String>,
79 pub error: Option<String>,
80}
81
82pub trait ResumeHooks {
87 fn start_watcher(&self, session_name: &str, record: &ExecutionRecord);
89
90 fn relaunch(&self, backend: &str, command: &str, options: &IsolationOptions)
92 -> IsolationResult;
93
94 fn reconcile(&self, record: &ExecutionRecord) -> ExecutionRecord;
96}
97
98#[derive(Debug, Default)]
100pub struct SystemResumeHooks;
101
102impl ResumeHooks for SystemResumeHooks {
103 fn start_watcher(&self, session_name: &str, record: &ExecutionRecord) {
104 let log_path = (!record.log_path.is_empty()).then(|| PathBuf::from(&record.log_path));
105 start_detached_docker_completion_watcher(
106 session_name,
107 get_docker_container_cleanup_policy(&build_launch_options(record)),
108 log_path.as_ref(),
109 Some(record.uuid.as_str()),
112 );
113 }
114
115 fn relaunch(
116 &self,
117 backend: &str,
118 command: &str,
119 options: &IsolationOptions,
120 ) -> IsolationResult {
121 run_isolated(backend, command, options)
122 }
123
124 fn reconcile(&self, record: &ExecutionRecord) -> ExecutionRecord {
125 crate::status_formatter::enrich_detached_status(record)
126 }
127}
128
129fn record_option<'a>(record: &'a ExecutionRecord, key: &str) -> Option<&'a str> {
130 record.options.get(key).and_then(|value| value.as_str())
131}
132
133fn record_flag(record: &ExecutionRecord, key: &str) -> bool {
134 record
135 .options
136 .get(key)
137 .and_then(|value| value.as_bool())
138 .unwrap_or(false)
139}
140
141fn record_strings(record: &ExecutionRecord, key: &str) -> Vec<String> {
142 record
143 .options
144 .get(key)
145 .and_then(|value| value.as_array())
146 .map(|items| {
147 items
148 .iter()
149 .filter_map(|item| item.as_str().map(str::to_string))
150 .collect()
151 })
152 .unwrap_or_default()
153}
154
155pub fn build_snapshot_image_name(session_name: &str, attempt: u64) -> String {
159 let mut sanitized = String::new();
160 let mut pending_dash = false;
161 for ch in session_name.to_lowercase().chars() {
162 if ch.is_ascii_lowercase() || ch.is_ascii_digit() || ch == '.' || ch == '_' || ch == '-' {
163 if pending_dash {
164 sanitized.push('-');
165 pending_dash = false;
166 }
167 sanitized.push(ch);
168 } else if !sanitized.is_empty() || !pending_dash {
169 pending_dash = true;
170 }
171 }
172 if pending_dash {
173 sanitized.push('-');
174 }
175 let sanitized = sanitized.trim_start_matches(['-', '.']).to_string();
176 let sanitized = if sanitized.is_empty() {
177 "session".to_string()
178 } else {
179 sanitized
180 };
181 format!("start-command-resume/{}:{}", sanitized, attempt)
182}
183
184pub fn build_resumed_session_name(session_name: &str, attempt: u64) -> String {
186 format!("{}-resume-{}", session_name, attempt)
187}
188
189pub fn build_launch_options(record: &ExecutionRecord) -> IsolationOptions {
192 let networks = record_strings(record, "networks");
193 IsolationOptions {
194 session: record_option(record, "sessionName").map(str::to_string),
195 image: record_option(record, "image").map(str::to_string),
196 volumes: record_strings(record, "volumes"),
197 mounts: record_strings(record, "mounts"),
198 env: record_strings(record, "env"),
199 privileged: record_flag(record, "privileged"),
200 network: networks.first().cloned(),
201 networks,
202 network_aliases: record_strings(record, "networkAliases"),
203 endpoint: record_option(record, "endpoint").map(str::to_string),
204 detached: true,
205 user: record_option(record, "user").map(str::to_string),
206 keep_alive: record_flag(record, "keepAlive"),
207 auto_remove_docker_container: record_flag(record, "autoRemoveDockerContainer"),
208 always_cleanup_container: record_flag(record, "alwaysCleanupContainer"),
209 keep_container: record_flag(record, "keepContainer"),
210 keep_container_on_fail: record_flag(record, "keepContainerOnFail"),
211 shell: record_option(record, "shell").unwrap_or("auto").to_string(),
212 log_path: (!record.log_path.is_empty()).then(|| PathBuf::from(&record.log_path)),
214 execution_id: Some(record.uuid.clone()),
217 }
218}
219
220fn build_snapshot_start_steps(
228 docker: &str,
229 container_args: Vec<String>,
230 extra_networks: &[String],
231 new_session_name: &str,
232 snapshot_image: &str,
233) -> Vec<ResumeStep> {
234 if extra_networks.is_empty() {
235 let mut args = vec!["run".to_string(), "-d".to_string()];
236 args.extend(container_args);
237 return vec![ResumeStep {
238 command: docker.to_string(),
239 args,
240 description: format!("Run the new command in {}", new_session_name),
241 }];
242 }
243
244 let mut create_args = vec!["create".to_string()];
245 create_args.extend(container_args);
246 let mut steps = vec![ResumeStep {
247 command: docker.to_string(),
248 args: create_args,
249 description: format!("Create {} from {}", new_session_name, snapshot_image),
250 }];
251 for network in extra_networks {
252 steps.push(ResumeStep {
253 command: docker.to_string(),
254 args: vec![
255 "network".to_string(),
256 "connect".to_string(),
257 network.clone(),
258 new_session_name.to_string(),
259 ],
260 description: format!("Connect {} to network {}", new_session_name, network),
261 });
262 }
263 steps.push(ResumeStep {
264 command: docker.to_string(),
265 args: vec!["start".to_string(), new_session_name.to_string()],
266 description: format!("Run the new command in {}", new_session_name),
267 });
268 steps
269}
270
271fn docker_snapshot_plan(
272 record: &ExecutionRecord,
273 backend: &str,
274 session_name: &str,
275 command: &str,
276 attempt: u64,
277) -> ResumePlan {
278 let snapshot_image = build_snapshot_image_name(session_name, attempt);
279 let new_session_name = build_resumed_session_name(session_name, attempt);
280 let docker = docker_command().to_string_lossy().to_string();
281
282 let launch_options = build_launch_options(record);
283 let mut container_args = vec!["--name".to_string(), new_session_name.clone()];
284 if let Some(user) = record_option(record, "user") {
285 container_args.push("--user".to_string());
286 container_args.push(user.to_string());
287 }
288 container_args.extend(
289 build_docker_runtime_args(&launch_options)
290 .into_iter()
291 .map(str::to_string),
292 );
293 container_args.push(snapshot_image.clone());
294 container_args.push("sh".to_string());
295 container_args.push("-c".to_string());
296 container_args.push(command.to_string());
297
298 let mut steps = vec![ResumeStep {
299 command: docker.clone(),
300 args: vec![
301 "commit".to_string(),
302 session_name.to_string(),
303 snapshot_image.clone(),
304 ],
305 description: format!("Snapshot container {} as {}", session_name, snapshot_image),
306 }];
307 steps.extend(build_snapshot_start_steps(
308 &docker,
309 container_args,
310 &docker_networks(&launch_options)
311 .into_iter()
312 .skip(1)
313 .map(str::to_string)
314 .collect::<Vec<_>>(),
315 &new_session_name,
316 &snapshot_image,
317 ));
318
319 ResumePlan {
320 mode: ResumeMode::DockerSnapshot,
321 backend: backend.to_string(),
322 session_name: session_name.to_string(),
323 new_session_name: Some(new_session_name.clone()),
324 snapshot_image: Some(snapshot_image.clone()),
325 command: command.to_string(),
326 attempt,
327 steps,
328 launch_options: None,
329 message: format!(
330 "Resumed session in new container {} from snapshot of {}",
331 new_session_name, session_name
332 ),
333 }
334}
335
336pub fn build_resume_plan(
338 record: &ExecutionRecord,
339 new_command: Option<&str>,
340 probe: &SessionProbe,
341) -> Result<ResumePlan, String> {
342 let session_name = record_option(record, "sessionName")
343 .ok_or_else(|| "Execution record does not contain an isolation session name.".to_string())?
344 .to_string();
345
346 if record_option(record, "isolationMode") != Some("detached") {
347 return Err("Only detached isolated executions can be resumed.".to_string());
348 }
349
350 if probe.alive {
351 return Err(format!(
352 "Session \"{}\" is still running. Use `$ --attach {}` to re-enter it, or `$ --stop {}` first.",
353 session_name, record.uuid, record.uuid
354 ));
355 }
356
357 let backend = record_option(record, "isolated")
358 .unwrap_or("unknown")
359 .to_string();
360 let command = new_command.unwrap_or(record.command.as_str()).to_string();
361 if command.is_empty() {
362 return Err(format!(
363 "Execution \"{}\" has no stored command to resume.",
364 record.uuid
365 ));
366 }
367
368 let attempt = record
369 .options
370 .get("resumeCount")
371 .and_then(|value| value.as_u64())
372 .unwrap_or(0)
373 + 1;
374
375 if backend == "docker" && probe.state == SessionState::Stopped {
376 return Ok(match new_command {
377 None => ResumePlan {
378 mode: ResumeMode::DockerStart,
379 backend,
380 new_session_name: None,
381 snapshot_image: None,
382 command,
383 attempt,
384 steps: vec![ResumeStep {
385 command: docker_command().to_string_lossy().to_string(),
386 args: vec!["start".to_string(), session_name.clone()],
387 description: format!("Start stopped container {}", session_name),
388 }],
389 launch_options: None,
390 message: format!("Resumed detached docker container: {}", session_name),
391 session_name,
392 },
393 Some(new_command) => {
394 docker_snapshot_plan(record, &backend, &session_name, new_command, attempt)
395 }
396 });
397 }
398
399 Ok(ResumePlan {
400 mode: ResumeMode::Relaunch,
401 message: format!("Relaunched {} session: {}", backend, session_name),
402 backend,
403 new_session_name: None,
404 snapshot_image: None,
405 command,
406 attempt,
407 steps: Vec::new(),
408 launch_options: Some(build_launch_options(record)),
409 session_name,
410 })
411}
412
413pub struct ResumeResultFields<'a> {
415 pub identifier: &'a str,
416 pub uuid: &'a str,
417 pub mode: ResumeMode,
418 pub backend: &'a str,
419 pub session_name: &'a str,
420 pub previous_session_name: Option<&'a str>,
421 pub snapshot_image: Option<&'a str>,
422 pub command: &'a str,
423 pub message: &'a str,
424}
425
426pub fn format_resume_result_as_links_notation(result: &ResumeResultFields) -> String {
428 let mut lines = vec![
429 "executionResume".to_string(),
430 format!(
431 " identifier {}",
432 escape_for_links_notation(result.identifier)
433 ),
434 format!(" uuid {}", escape_for_links_notation(result.uuid)),
435 format!(" mode {}", escape_for_links_notation(result.mode.as_str())),
436 format!(" backend {}", escape_for_links_notation(result.backend)),
437 format!(
438 " sessionName {}",
439 escape_for_links_notation(result.session_name)
440 ),
441 ];
442 if let Some(previous) = result.previous_session_name {
443 lines.push(format!(
444 " previousSessionName {}",
445 escape_for_links_notation(previous)
446 ));
447 }
448 if let Some(image) = result.snapshot_image {
449 lines.push(format!(
450 " snapshotImage {}",
451 escape_for_links_notation(image)
452 ));
453 }
454 lines.push(format!(
455 " command {}",
456 escape_for_links_notation(result.command)
457 ));
458 lines.push(format!(
459 " message {}",
460 escape_for_links_notation(result.message)
461 ));
462 lines.join("\n")
463}
464
465pub fn format_resume_result(result: &ResumeResultFields, output_format: Option<&str>) -> String {
467 match output_format {
468 Some("json") => {
469 let mut value = json!({
470 "identifier": result.identifier,
471 "uuid": result.uuid,
472 "mode": result.mode.as_str(),
473 "backend": result.backend,
474 "sessionName": result.session_name,
475 "previousSessionName": result.previous_session_name,
476 "snapshotImage": result.snapshot_image,
477 "command": result.command,
478 "message": result.message,
479 });
480 if let Value::Object(ref mut map) = value {
481 map.retain(|_, entry| !entry.is_null());
482 }
483 serde_json::to_string_pretty(&value).unwrap_or_else(|_| "{}".to_string())
484 }
485 Some("text") => [
486 format!("Resume Mode: {}", result.mode.as_str()),
487 format!("UUID: {}", result.uuid),
488 format!("Backend: {}", result.backend),
489 format!("Session Name: {}", result.session_name),
490 format!("Command: {}", result.command),
491 result.message.to_string(),
492 ]
493 .join("\n"),
494 _ => format_resume_result_as_links_notation(result),
495 }
496}
497
498pub fn apply_resume_to_record(
501 record: &mut ExecutionRecord,
502 plan: &ResumePlan,
503 container_id: Option<&str>,
504) {
505 record
506 .options
507 .insert("resumeCount".to_string(), json!(plan.attempt));
508 record
509 .options
510 .insert("resumedAt".to_string(), json!(Utc::now().to_rfc3339()));
511
512 if let Some(new_session_name) = &plan.new_session_name {
513 let mut history = record
514 .options
515 .get("sessionNameHistory")
516 .and_then(|value| value.as_array())
517 .cloned()
518 .unwrap_or_default();
519 if let Some(previous) = record.options.get("sessionName").cloned() {
520 history.push(previous);
521 }
522 record
523 .options
524 .insert("sessionNameHistory".to_string(), Value::Array(history));
525 record
526 .options
527 .insert("sessionName".to_string(), json!(new_session_name));
528 }
529 if let Some(snapshot_image) = &plan.snapshot_image {
530 record
531 .options
532 .insert("image".to_string(), json!(snapshot_image));
533 }
534 if let Some(container_id) = container_id {
535 record
536 .options
537 .insert("containerId".to_string(), json!(container_id));
538 }
539
540 record.command = plan.command.clone();
541 record.status = ExecutionStatus::Executing;
542 record.exit_code = None;
543 record.end_time = None;
544 record.exit_reason = None;
545 record.oom_killed = None;
546}
547
548fn active_session_name(plan: &ResumePlan) -> &str {
549 plan.new_session_name
550 .as_deref()
551 .unwrap_or(&plan.session_name)
552}
553
554pub fn resume_execution(
556 store: Option<&ExecutionStore>,
557 identifier: &str,
558 command: Option<&str>,
559 output_format: Option<&str>,
560) -> ExecutionResumeResult {
561 resume_execution_with(
562 store,
563 identifier,
564 command,
565 output_format,
566 &SystemCommandRunner,
567 &SystemResumeHooks,
568 )
569}
570
571pub fn resume_execution_with<R: CommandRunner, H: ResumeHooks>(
574 store: Option<&ExecutionStore>,
575 identifier: &str,
576 command: Option<&str>,
577 output_format: Option<&str>,
578 runner: &R,
579 hooks: &H,
580) -> ExecutionResumeResult {
581 let Some(store) = store else {
582 return ExecutionResumeResult {
583 success: false,
584 output: None,
585 error: Some("Execution tracking is disabled.".to_string()),
586 };
587 };
588
589 let Some(mut record) = store.get(identifier) else {
590 return ExecutionResumeResult {
591 success: false,
592 output: None,
593 error: Some(format!(
594 "No execution found with UUID or session name: {}",
595 identifier
596 )),
597 };
598 };
599
600 let probe = probe_session(&record, runner);
601 let plan = match build_resume_plan(&record, command, &probe) {
602 Ok(plan) => plan,
603 Err(error) => {
604 return ExecutionResumeResult {
605 success: false,
606 output: None,
607 error: Some(error),
608 }
609 }
610 };
611
612 let mut container_id: Option<String> = None;
613
614 if plan.mode == ResumeMode::Relaunch {
615 let launch_options = plan.launch_options.clone().unwrap_or_default();
616 let launch_result = hooks.relaunch(&plan.backend, &plan.command, &launch_options);
617 if !launch_result.success {
618 return ExecutionResumeResult {
619 success: false,
620 output: None,
621 error: Some(format!(
622 "Failed to relaunch {} session \"{}\": {}",
623 plan.backend, plan.session_name, launch_result.message
624 )),
625 };
626 }
627 container_id = launch_result.container_id;
628 } else {
629 for step in &plan.steps {
630 let result = runner.run(&step.command, &step.args);
631 if !result.success {
632 let detail = if !result.stderr.trim().is_empty() {
633 result.stderr.trim().to_string()
634 } else {
635 result.error.clone().unwrap_or_else(|| {
636 format!(
637 "exit code {}",
638 result
639 .status
640 .map(|code| code.to_string())
641 .unwrap_or_else(|| "unknown".to_string())
642 )
643 })
644 };
645 return ExecutionResumeResult {
646 success: false,
647 output: None,
648 error: Some(format!(
649 "Failed to resume {} session \"{}\": {}",
650 plan.backend, plan.session_name, detail
651 )),
652 };
653 }
654 let stdout = result.stdout.trim().to_string();
655 if !stdout.is_empty() {
656 container_id = Some(stdout);
657 }
658 }
659
660 hooks.start_watcher(active_session_name(&plan), &record);
663 }
664
665 let previous_session_name = plan
666 .new_session_name
667 .as_ref()
668 .map(|_| plan.session_name.clone());
669 apply_resume_to_record(&mut record, &plan, container_id.as_deref());
670 if let Err(error) = store.save(&record) {
671 return ExecutionResumeResult {
672 success: false,
673 output: None,
674 error: Some(error),
675 };
676 }
677
678 let session_name = record_option(&record, "sessionName")
679 .unwrap_or(&plan.session_name)
680 .to_string();
681
682 ExecutionResumeResult {
683 success: true,
684 output: Some(format_resume_result(
685 &ResumeResultFields {
686 identifier,
687 uuid: &record.uuid,
688 mode: plan.mode,
689 backend: &plan.backend,
690 session_name: &session_name,
691 previous_session_name: previous_session_name.as_deref(),
692 snapshot_image: plan.snapshot_image.as_deref(),
693 command: &plan.command,
694 message: &plan.message,
695 },
696 output_format,
697 )),
698 error: None,
699 }
700}
701
702#[cfg(test)]
703#[path = "execution_resume_cases.rs"]
704mod tests;