oxo-flow-cli 0.29.1

CLI for the oxo-flow bioinformatics pipeline engine
//! `resume` command execution: loads checkpoint state and re-enters the run loop.

use super::*;
use crate::commands::print_banner;
use anyhow::{Context, Result};
use colored::Colorize;
use oxo_flow_core::config::WorkflowConfig;
use oxo_flow_core::executor::checkpoint::CheckpointState;
use std::path::PathBuf;

#[allow(clippy::too_many_arguments)]
pub async fn resume_command(
    checkpoint: PathBuf,
    jobs: usize,
    ai_recover: bool,
    ai_max_retries: Option<u32>,
    keep_going: bool,
    timeout: String,
    workdir: Option<PathBuf>,
    no_report_snapshot: bool,
) -> Result<()> {
    // resume does not produce structured JSON output
    print_banner();

    // Load checkpoint state
    let state = CheckpointState::load_from_file(&checkpoint)
        .with_context(|| {
            format!(
                "failed to load checkpoint from '{}'.\n  \
             Check that the file exists and is a valid checkpoint (JSON format).",
                checkpoint.display()
            )
        })
        .map_err(|e| {
            // Same friendly hint `status` gives for the identical mistake
            // (audit #276 P4-4): passing a workflow file where a checkpoint
            // belongs should name the confusion, not just the JSON parse error.
            if checkpoint.extension().is_some_and(|ext| ext == "oxoflow") {
                anyhow::anyhow!(
                    "{e}\n  '{}' appears to be a workflow file, not a checkpoint.\n  \
                 The 'resume' command expects a checkpoint file (e.g., .oxo-flow/checkpoint.json).",
                    checkpoint.display()
                )
            } else {
                e
            }
        })?;

    // Get workflow path from checkpoint
    let workflow_path = match &state.workflow_path {
        Some(p) => PathBuf::from(p),
        None => {
            anyhow::bail!(
                "Checkpoint does not contain a workflow reference.\n  \
                 The checkpoint at '{}' was generated by an older version of oxo-flow.\n  \
                 To resume manually, run: oxo-flow run <workflow.oxoflow>\n  \
                 (oxo-flow run automatically resumes from the checkpoint)",
                checkpoint.display()
            );
        }
    };

    if !workflow_path.exists() {
        anyhow::bail!(
            "Workflow file '{}' referenced by checkpoint no longer exists.\n  \
             The workflow may have been moved or deleted.",
            workflow_path.display()
        );
    }

    let completed = state.completed_rules.len();
    // Issue #690: don't count stale when-gated entries as failures in the
    // resume banner either — they are skips, and the executor will re-judge
    // their gate on this resume anyway.
    let (failed, skipped_when_false) =
        reconcile_failed_rules(&state.failed_rules, &state.when_verdicts);
    let failed = failed.len();

    eprintln!(
        "{} Resuming workflow '{}'",
        "Resume:".bold().cyan(),
        workflow_path.display()
    );
    eprintln!("  Checkpoint: {}", checkpoint.display());
    // Remaining = the rules this resume will actually execute: everything
    // the checkpoint has not completed (failed rules are retried). The
    // checkpoint records only what ran, so the workflow supplies the total
    // (its template count — wildcards expand later); if it cannot be read
    // here, `run_command` reports that error with full context a moment
    // later and the failed count is the fallback. The old arithmetic
    // reduced to `failed` (audit finding).
    let remaining = WorkflowConfig::from_file(&workflow_path)
        .map(|cfg| cfg.rules.len().saturating_sub(completed))
        .unwrap_or(failed);
    eprintln!("  State: {completed} completed, {failed} failed, {remaining} remaining");
    if !skipped_when_false.is_empty() {
        eprintln!(
            "  Note: {} {} skipped by their `when` condition (recorded false); the gate is re-judged on this resume.",
            skipped_when_false.len(),
            if skipped_when_false.len() == 1 {
                "rule was"
            } else {
                "rules were"
            }
        );
    }
    // Issue #685: entries persisting in the running set mean the prior run
    // died mid-flight; run_command clears them (with a warning) on start.
    if !state.running_rules().is_empty() {
        eprintln!(
            "  Note: {} {} still running when the previous run died; they will re-run.",
            state.running_rules().len(),
            if state.running_rules().len() == 1 {
                "rule was"
            } else {
                "rules were"
            }
        );
    }

    if completed == 0 && failed == 0 {
        eprintln!(
            "  {} No rules have been executed yet. Use 'oxo-flow run' instead.",
            "Note:".yellow()
        );
        return Ok(());
    }

    // Re-run the workflow — the checkpoint with completed rules will cause
    // already-finished rules to be skipped automatically
    eprintln!();
    eprintln!(
        "{} Launching executor with {}...",
        "Info:".bold().cyan(),
        oxo_flow_core::plural(jobs, "parallel job", "parallel jobs")
    );

    // Workdir precedence (issue #68): explicit --workdir > the directory the
    // original run recorded in the checkpoint > the workflow's directory.
    let effective_workdir = workdir.or_else(|| state.workdir.clone().map(PathBuf::from));
    eprintln!(
        "  Workdir: {}",
        effective_workdir
            .as_deref()
            .map(|p| p.display().to_string())
            .unwrap_or_else(|| "(workflow directory)".to_string())
    );

    run_command(RunRequest {
        workflow: Some(workflow_path),
        jobs,
        keep_going,
        // recorded by the original run
        workdir: effective_workdir,
        log_file: None,
        target: Vec::new(),
        module: Vec::new(),
        retry: 0,
        timeout,
        // user can re-run with --resume-failed in 'run'
        resume_failed: false,
        profile: None,
        max_threads: 0,
        max_memory: 0,
        skip_env_setup: false,
        // resume: refs already built
        skip_ref_build: true,
        cache_dir: None,
        // checkpoint already has checksums
        provenance: false,
        // resume defaults to human-readable
        json: false,
        // resume reuses checkpoint state
        cli_args: Vec::new(),
        ai_recover,
        ai_max_retries,
        // resume restores checkpoint state as-is
        samples_filter: Vec::new(),
        // resume skips completed rules by design
        rerun: false,
        no_report_snapshot,
        // cluster queue cap — resume keeps the profile's
        max_submitted: None,
        // #538: the user's checkpoint path wins over the workdir-derived
        // default — the banner's "Resuming… N completed" must be the truth.
        checkpoint_override: Some(checkpoint),
        // resume honors the protection gate
        allow_protected_drift: false,
        // resume never re-derives a from-rule closure: it continues the
        // recorded checkpoint's pending set exactly as-is.
        from_rule: None,
        // resume re-solves degraded environments like a fresh run would
        strict_envs: false,
    })
    .await
}