oxo-flow-cli 0.25.0

CLI for the oxo-flow bioinformatics pipeline engine
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
//! Background execution (`run --background` / `resume --background`,
//! issue #158 idea 1).
//!
//! The foreground invocation never executes the workflow: it spawns a
//! DETACHED child that re-runs the same binary with the same argv minus
//! `--background` (every other flag passes through verbatim), writes the
//! child's pid to `<workdir>/.oxo-flow/background.pid`, redirects the
//! child's stdout+stderr to the run log (the tracing tee already writes
//! there; the redirect captures anything non-tracing), prints a one-line
//! summary on stderr, and exits 0. The child then runs the normal flow —
//! checkpoint, workdir lock, and resume semantics are unchanged, so
//! monitoring works through `oxo-flow status`, the run log, and report
//! snapshots exactly as for a foreground run.

use anyhow::{Context as _, Result};
use oxo_flow_core::executor::{CheckpointState, WorkdirLock};
use std::ffi::{OsStr, OsString};
use std::fs::{self, OpenOptions};
use std::io;
use std::path::{Path, PathBuf};
use std::process::{Child, Command, Stdio};

/// Build the detached child's argv from the foreground process's full
/// argument list (`std::env::args_os()`, `argv[0]` included): drop `argv[0]`
/// (the spawn replaces it with [`std::env::current_exe`]) and every exact
/// `--background` token. Exact-token removal is safe because this helper
/// only runs after clap parsed `background = true` — the flag was consumed
/// as a flag, never as another option's value — so the remainder re-parses
/// to the identical command line minus the flag.
pub fn strip_background_flag(args: impl IntoIterator<Item = OsString>) -> Vec<OsString> {
    args.into_iter()
        .skip(1) // argv[0]: replaced by current_exe() in spawn_detached
        .filter(|arg| arg.as_os_str() != OsStr::new("--background"))
        .collect()
}

/// Resolve the run-log path exactly like `run_command` (issue #136 fix 4):
/// an absolute `--log-file` passes through, a relative one resolves against
/// the workdir, and the default is `<workdir>/.oxo-flow/logs/oxo-flow.log`.
pub fn resolve_log_path(workdir: &Path, log_file: Option<&Path>) -> PathBuf {
    match log_file {
        Some(p) if p.is_absolute() => p.to_path_buf(),
        Some(p) => workdir.join(p),
        None => workdir.join(".oxo-flow/logs/oxo-flow.log"),
    }
}

/// Effective workdir for a background `run`, mirroring `run_command`'s
/// resolution (issue #68): explicit `--workdir` wins; repository runs
/// (nextflow-style URLs) execute from the current directory; otherwise the
/// workflow's own directory. `--bundle` without `--workdir` is refused: the
/// bundle's extracted directory is created per-process, so the foreground
/// invocation cannot predict where the child will run.
pub fn background_workdir_for_run(
    workflow: Option<&Path>,
    workdir: Option<&Path>,
    bundle: Option<&Path>,
) -> Result<PathBuf> {
    if let Some(wd) = workdir {
        return Ok(wd.to_path_buf());
    }
    if bundle.is_some() {
        anyhow::bail!(
            "run --background with --bundle requires an explicit --workdir: the bundle's \
             extracted directory is created per-process and cannot be predicted from the \
             foreground invocation.\n\
             Use: oxo-flow run --bundle <bundle> --workdir <dir> --yes --background"
        );
    }
    let wf = match workflow {
        Some(w) => w.to_path_buf(),
        None => crate::commands::resolve_workflow(None)?,
    };
    if crate::commands::pull::classify_run_source(&wf.to_string_lossy()).is_some() {
        Ok(std::env::current_dir()?)
    } else {
        Ok(oxo_flow_core::parent_dir(&wf).to_path_buf())
    }
}

/// Effective workdir for a background `resume`, mirroring `resume_command`:
/// explicit `--workdir` > the workdir the checkpoint records > the recorded
/// workflow's directory.
pub fn background_workdir_for_resume(checkpoint: &Path, workdir: Option<&Path>) -> Result<PathBuf> {
    if let Some(wd) = workdir {
        return Ok(wd.to_path_buf());
    }
    let state = CheckpointState::load_from_file(checkpoint)?;
    if let Some(wd) = state.workdir {
        return Ok(PathBuf::from(wd));
    }
    if let Some(wf) = state.workflow_path {
        return Ok(oxo_flow_core::parent_dir(Path::new(&wf)).to_path_buf());
    }
    anyhow::bail!(
        "cannot resolve a working directory for the background resume: the checkpoint \
         records neither a workdir nor a workflow path"
    )
}

/// Environment variable carrying the pid-file path from the foreground
/// launcher to the detached child, so the child can remove it when the run
/// finishes (a leftover `.oxo-flow/background.pid` names a process that is
/// already gone, audit finding).
pub const PID_FILE_ENV: &str = "OXO_FLOW_BACKGROUND_PID_FILE";

/// Removes the background pid file when the detached child finishes.
///
/// Armed only through [`PID_FILE_ENV`] (set by [`launch_in_background`]), so
/// a foreground run gets an inert guard. Only the process the file names may
/// remove it: a second background launch may have overwritten the file with
/// its own pid in the meantime.
pub struct BackgroundPidFileGuard(Option<PathBuf>);

impl BackgroundPidFileGuard {
    pub fn from_env() -> Self {
        Self::new(std::env::var_os(PID_FILE_ENV).map(PathBuf::from))
    }

    fn new(path: Option<PathBuf>) -> Self {
        Self(path)
    }
}

impl Drop for BackgroundPidFileGuard {
    fn drop(&mut self) {
        let Some(path) = &self.0 else {
            return;
        };
        let pid = std::process::id().to_string();
        if fs::read_to_string(path).ok().as_deref().map(str::trim) != Some(pid.as_str()) {
            return;
        }
        if let Err(e) = fs::remove_file(path) {
            tracing::warn!(error = %e, path = %path.display(), "failed to remove the background pid file");
        }
    }
}

/// Spawn the detached child: same binary, `args`, stdout+stderr redirected
/// to the run log. Unix: the child gets its own process group, so it
/// survives the terminal's SIGHUP (and Ctrl-C's SIGINT targets the
/// foreground group, not this child). Windows: CREATE_NEW_PROCESS_GROUP |
/// DETACHED_PROCESS. The log is opened in append mode — the child's
/// `activate_run_log` truncates it and the tracing tee writes the header;
/// the inherited descriptors capture anything the tee does not.
///
/// `pid_file` is handed to the child via [`PID_FILE_ENV`] so the child can
/// remove it on exit.
fn spawn_detached(args: &[OsString], log_path: &Path, pid_file: &Path) -> io::Result<Child> {
    // The child's `activate_run_log` creates parent directories for the log;
    // the parent must do the same before it can open the redirect target.
    if let Some(parent) = log_path.parent() {
        fs::create_dir_all(parent)?;
    }
    let log = OpenOptions::new()
        .create(true)
        .append(true)
        .open(log_path)?;
    let mut command = Command::new(std::env::current_exe()?);
    command
        .args(args)
        // The child must NOT arm the tracing tee: its stderr already lands
        // in this log, and a second writer would duplicate every event
        // (issue #194 A3). The flag routes the child to the redirect-only
        // path in `run_command`.
        .env("OXO_FLOW_STDERR_ALREADY_REDIRECTED", "1")
        .env(PID_FILE_ENV, pid_file)
        .stdout(Stdio::from(log.try_clone()?))
        .stderr(Stdio::from(log));
    #[cfg(unix)]
    {
        use std::os::unix::process::CommandExt;
        command.process_group(0);
    }
    #[cfg(windows)]
    {
        use std::os::windows::process::CommandExt;
        const CREATE_NEW_PROCESS_GROUP: u32 = 0x0000_0200;
        const DETACHED_PROCESS: u32 = 0x0000_0008;
        command.creation_flags(CREATE_NEW_PROCESS_GROUP | DETACHED_PROCESS);
    }
    command.spawn()
}

/// Write the child's pid to `<workdir>/.oxo-flow/background.pid`. The
/// `.oxo-flow` directory is created here so the pid file's placement never
/// depends on the child acquiring the workdir lock first.
fn write_pid_file(path: &Path, pid: u32) -> io::Result<()> {
    if let Some(dir) = path.parent() {
        fs::create_dir_all(dir)?;
    }
    fs::write(path, format!("{pid}\n"))
}

/// Spawn the detached child, record its pid, print the one-line summary on
/// stderr, and return. The caller exits 0; the child runs the normal flow.
pub fn launch_in_background(args: &[OsString], workdir: &Path, log_path: &Path) -> Result<()> {
    // Fail fast instead of reporting "started" for a child that would
    // immediately die on the workdir lock (issue #70). Advisory: the child
    // still enforces the lock itself.
    if WorkdirLock::is_locked(workdir) {
        anyhow::bail!(
            "another run is already active in {} — the background run would fail on the \
             workdir lock ({}); wait for it to finish or stop it first",
            workdir.display(),
            workdir.join(".oxo-flow/lock").display()
        );
    }
    let pid_file = workdir.join(".oxo-flow").join("background.pid");
    let child = spawn_detached(args, log_path, &pid_file).with_context(|| {
        format!(
            "failed to spawn the background process (log: {})",
            log_path.display()
        )
    })?;
    let pid = child.id();
    write_pid_file(&pid_file, pid)?;
    let checkpoint = workdir.join(".oxo-flow/checkpoint.json");
    eprintln!(
        "started in background (pid {pid}) · log: {} · monitor: oxo-flow status {} · stop: kill {pid}",
        log_path.display(),
        checkpoint.display()
    );
    tracing::info!(
        pid,
        pid_file = %pid_file.display(),
        log = %log_path.display(),
        "run detached into background"
    );
    Ok(())
}

#[cfg(test)]
mod tests {
    use super::*;

    fn os(args: &[&str]) -> Vec<OsString> {
        args.iter().map(OsString::from).collect()
    }

    #[test]
    fn strip_background_flag_drops_argv0_and_flag_keeps_rest() {
        // Full args_os() shape: argv[0] first, the flag anywhere after.
        let args = os(&[
            "/usr/bin/oxo-flow",
            "run",
            "--background",
            "wf.oxoflow",
            "-j",
            "4",
        ]);
        assert_eq!(
            strip_background_flag(args),
            os(&["run", "wf.oxoflow", "-j", "4"])
        );
    }

    #[test]
    fn strip_background_flag_handles_leading_global_flags() {
        let args = os(&[
            "/usr/bin/oxo-flow",
            "--json",
            "run",
            "wf.oxoflow",
            "--background",
        ]);
        assert_eq!(
            strip_background_flag(args),
            os(&["--json", "run", "wf.oxoflow"])
        );
    }

    #[test]
    fn strip_background_flag_keeps_values_that_merely_contain_the_token() {
        // A config override value that contains "--background" is NOT the
        // flag and must survive (the flag itself was already consumed by
        // clap as a flag for the helper to be called at all).
        let args = os(&[
            "/usr/bin/oxo-flow",
            "run",
            "wf.oxoflow",
            "TAG=--background",
            "--background",
            "NAME=x",
        ]);
        assert_eq!(
            strip_background_flag(args),
            os(&["run", "wf.oxoflow", "TAG=--background", "NAME=x"])
        );
    }

    #[test]
    fn pid_file_guard_removes_only_its_own_pid_file() {
        let dir = tempfile::tempdir().unwrap();
        let mine = dir.path().join("background.pid");
        fs::write(&mine, format!("{}\n", std::process::id())).unwrap();
        drop(BackgroundPidFileGuard::new(Some(mine.clone())));
        assert!(!mine.exists(), "the guard must remove the pid file it owns");

        // A newer background launch may have replaced the pid file — the
        // older child must leave it alone.
        let other = dir.path().join("other.pid");
        fs::write(&other, "999999999\n").unwrap();
        drop(BackgroundPidFileGuard::new(Some(other.clone())));
        assert!(other.exists(), "another process's pid file must survive");
    }

    #[test]
    fn resolve_log_path_defaults_under_workdir() {
        let dir = tempfile::tempdir().unwrap();
        assert_eq!(
            resolve_log_path(dir.path(), None),
            dir.path().join(".oxo-flow/logs/oxo-flow.log")
        );
    }

    #[test]
    fn resolve_log_path_joins_relative_log_file_against_workdir() {
        let dir = tempfile::tempdir().unwrap();
        assert_eq!(
            resolve_log_path(dir.path(), Some(Path::new("logs/custom.log"))),
            dir.path().join("logs/custom.log")
        );
    }

    #[test]
    fn resolve_log_path_passes_absolute_log_file_through() {
        let dir = tempfile::tempdir().unwrap();
        assert_eq!(
            resolve_log_path(dir.path(), Some(Path::new("/tmp/abs.log"))),
            PathBuf::from("/tmp/abs.log")
        );
    }

    #[test]
    fn background_workdir_for_run_prefers_explicit_workdir() {
        let wf = Path::new("/data/wf.oxoflow");
        let wd = Path::new("/analysis");
        assert_eq!(
            background_workdir_for_run(Some(wf), Some(wd), None).unwrap(),
            PathBuf::from("/analysis")
        );
    }

    #[test]
    fn background_workdir_for_run_defaults_to_workflow_parent() {
        let wf = Path::new("/data/nested/wf.oxoflow");
        assert_eq!(
            background_workdir_for_run(Some(wf), None, None).unwrap(),
            PathBuf::from("/data/nested")
        );
    }

    #[test]
    fn background_workdir_for_run_uses_cwd_for_repository_runs() {
        let wf = Path::new("gh:owner/pipeline@v1.2.3");
        let cwd = std::env::current_dir().unwrap();
        assert_eq!(
            background_workdir_for_run(Some(wf), None, None).unwrap(),
            cwd
        );
    }

    #[test]
    fn background_workdir_for_run_refuses_bundle_without_workdir() {
        let err = background_workdir_for_run(None, None, Some(Path::new("b.tgz")))
            .unwrap_err()
            .to_string();
        assert!(
            err.contains("--workdir"),
            "bundle+background without --workdir must name the fix: {err}"
        );
    }

    #[test]
    fn background_workdir_for_run_accepts_bundle_with_workdir() {
        let wd = Path::new("/analysis");
        assert_eq!(
            background_workdir_for_run(None, Some(wd), Some(Path::new("b.tgz"))).unwrap(),
            PathBuf::from("/analysis")
        );
    }

    /// `completed_rules`/`failed_rules`/`benchmarks` are required checkpoint
    /// fields (no serde defaults), so every fixture carries them.
    fn checkpoint_with(dir: &Path, extra: &str) -> PathBuf {
        let path = dir.join("checkpoint.json");
        fs::write(
            &path,
            format!(
                r#"{{"completed_rules": [], "failed_rules": [], "benchmarks": {{}}, {extra}}}"#
            ),
        )
        .unwrap();
        path
    }

    #[test]
    fn background_workdir_for_resume_prefers_explicit_workdir() {
        let dir = tempfile::tempdir().unwrap();
        let ck = checkpoint_with(dir.path(), r#""workdir": "/wrong""#);
        assert_eq!(
            background_workdir_for_resume(&ck, Some(Path::new("/right"))).unwrap(),
            PathBuf::from("/right")
        );
    }

    #[test]
    fn background_workdir_for_resume_uses_checkpoint_workdir() {
        let dir = tempfile::tempdir().unwrap();
        let ck = checkpoint_with(dir.path(), r#""workdir": "/recorded/wd""#);
        assert_eq!(
            background_workdir_for_resume(&ck, None).unwrap(),
            PathBuf::from("/recorded/wd")
        );
    }

    #[test]
    fn background_workdir_for_resume_falls_back_to_workflow_parent() {
        let dir = tempfile::tempdir().unwrap();
        let ck = checkpoint_with(dir.path(), r#""workflow_path": "/repo/nested/wf.oxoflow""#);
        assert_eq!(
            background_workdir_for_resume(&ck, None).unwrap(),
            PathBuf::from("/repo/nested")
        );
    }

    #[test]
    fn background_workdir_for_resume_errors_without_recorded_paths() {
        let dir = tempfile::tempdir().unwrap();
        let ck = checkpoint_with(dir.path(), r#""workflow_name": "x""#);
        assert!(
            background_workdir_for_resume(&ck, None).is_err(),
            "a checkpoint with no workdir and no workflow path cannot resolve a workdir"
        );
    }
}