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