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            // The resumed session gets the same record, so the watcher that
110            // outlives this process finalizes it too (issue #170.1).
111            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
155/// Build the docker image name used to snapshot a container before running a
156/// new command in it. Docker repository names must be lowercase and limited to
157/// `[a-z0-9._-]`, so session names are sanitized.
158pub 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
184/// Build the container name for a snapshot-based resume.
185pub fn build_resumed_session_name(session_name: &str, attempt: u64) -> String {
186    format!("{}-resume-{}", session_name, attempt)
187}
188
189/// Rebuild the isolation options stored on a record so the command can be
190/// launched again with the same configuration.
191pub 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        // Append to the same log so one logical session keeps one gap-free record.
213        log_path: (!record.log_path.is_empty()).then(|| PathBuf::from(&record.log_path)),
214        // The relaunched session keeps writing to this record, so its watcher
215        // is the one that must mark it terminal (issue #170.1).
216        execution_id: Some(record.uuid.clone()),
217    }
218}
219
220/// Build the steps that start a snapshot-derived container.
221///
222/// `docker run` can only join one network, so a session that was launched on
223/// several networks is rebuilt the same way `run_isolated` builds it: create
224/// the container, connect the additional networks, then start it. Dropping to a
225/// single `docker run` when there is nothing extra to connect keeps the common
226/// case to one command.
227fn 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
336/// Decide how a stored execution should be resumed.
337pub 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
413/// Fields reported after a resume attempt.
414pub 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
426/// Format a resume result as links notation.
427pub 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
465/// Format a resume result in the requested output format.
466pub 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
498/// Apply the resume outcome to the stored record, keeping the original UUID so
499/// one logical session stays addressable across restarts.
500pub 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
554/// Resume a tracked execution by UUID or session name.
555pub 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
571/// Resume with an injectable command runner and hooks, so tests never touch
572/// docker.
573pub 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        // The completion watcher died with the previous run (or with the
661        // supervisor), so a new one must follow the resumed container.
662        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;