Skip to main content

start_command/
execution_resume.rs

1//! Resume tracked detached executions (issue #162).
2//!
3//! `--resume <id>` restarts a stored execution, and `--resume <id> -- <command>`
4//! runs a *different* command against the same container filesystem. Both keep
5//! the original execution UUID so `--status`, `--list` and `--upload-log` keep
6//! addressing one logical session across restarts.
7//!
8//! Three strategies, chosen from the probed session state:
9//! - `DockerStart`: the container still exists and the stored command is re-run
10//!   by `docker start` (its original entrypoint).
11//! - `DockerSnapshot`: the container still exists but a new command was given,
12//!   so its filesystem is committed to an image and a derived container runs
13//!   the new command. This avoids `docker start -ai`, which would re-run the
14//!   original entrypoint from scratch.
15//! - `Relaunch`: nothing is left of the session, so the command is launched
16//!   again through the stored isolation options.
17
18use 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/// Strategies `build_resume_plan` can pick.
34#[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/// One command run while carrying out a resume plan.
52#[derive(Debug, Clone, PartialEq, Eq)]
53pub struct ResumeStep {
54    pub command: String,
55    pub args: Vec<String>,
56    pub description: String,
57}
58
59/// How a stored execution will be resumed.
60#[derive(Debug, Clone)]
61pub struct ResumePlan {
62    pub mode: ResumeMode,
63    pub backend: String,
64    pub session_name: String,
65    /// Set only when a snapshot-derived container replaces the original one.
66    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
75/// Result of `resume_execution`.
76pub struct ExecutionResumeResult {
77    pub success: bool,
78    pub output: Option<String>,
79    pub error: Option<String>,
80}
81
82/// Side effects a resume performs beyond running plan steps.
83///
84/// Injectable so tests can exercise the full resume flow without spawning a
85/// completion watcher, launching an isolation backend, or reading log files.
86pub trait ResumeHooks {
87    /// Attach a fresh completion watcher to a resumed docker session.
88    fn start_watcher(&self, session_name: &str, record: &ExecutionRecord);
89
90    /// Launch the command again through its isolation backend.
91    fn relaunch(&self, backend: &str, command: &str, options: &IsolationOptions)
92        -> IsolationResult;
93
94    /// Resolve the terminal result of a session that ended unsupervised.
95    fn reconcile(&self, record: &ExecutionRecord) -> ExecutionRecord;
96}
97
98/// The real side effects: docker watcher, isolation backends, log evidence.
99#[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
152/// Build the docker image name used to snapshot a container before running a
153/// new command in it. Docker repository names must be lowercase and limited to
154/// `[a-z0-9._-]`, so session names are sanitized.
155pub 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
181/// Build the container name for a snapshot-based resume.
182pub fn build_resumed_session_name(session_name: &str, attempt: u64) -> String {
183    format!("{}-resume-{}", session_name, attempt)
184}
185
186/// Rebuild the isolation options stored on a record so the command can be
187/// launched again with the same configuration.
188pub 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        // Append to the same log so one logical session keeps one gap-free record.
210        log_path: (!record.log_path.is_empty()).then(|| PathBuf::from(&record.log_path)),
211    }
212}
213
214/// Build the steps that start a snapshot-derived container.
215///
216/// `docker run` can only join one network, so a session that was launched on
217/// several networks is rebuilt the same way `run_isolated` builds it: create
218/// the container, connect the additional networks, then start it. Dropping to a
219/// single `docker run` when there is nothing extra to connect keeps the common
220/// case to one command.
221fn 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
330/// Decide how a stored execution should be resumed.
331pub 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
407/// Fields reported after a resume attempt.
408pub 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
420/// Format a resume result as links notation.
421pub 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
459/// Format a resume result in the requested output format.
460pub 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
492/// Apply the resume outcome to the stored record, keeping the original UUID so
493/// one logical session stays addressable across restarts.
494pub 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
548/// Resume a tracked execution by UUID or session name.
549pub 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
565/// Resume with an injectable command runner and hooks, so tests never touch
566/// docker.
567pub 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        // The completion watcher died with the previous run (or with the
655        // supervisor), so a new one must follow the resumed container.
656        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;