File Coverage

lib/NBI/Launcher.pm
Criterion Covered Total %
statement 209 234 89.3
branch 71 114 62.2
condition 61 107 57.0
subroutine 18 19 94.7
pod 9 9 100.0
total 368 483 76.1


line stmt bran cond sub pod time code
1             package NBI::Launcher;
2             #ABSTRACT: Base class for nbilaunch tool wrappers
3             #
4             # NBI::Launcher - Declarative base class for HPC tool launchers.
5             #
6             # DESCRIPTION:
7             # Every tool wrapper inherits from this class. Subclasses provide:
8             # - A constructor that calls SUPER::new() with the launcher spec
9             # - make_command(%args) - the tool invocation string (only override needed
10             # for most tools)
11             #
12             # The base class provides everything else:
13             # - Spec storage and introspection (arg_spec, input_mode, sample_name)
14             # - Input / param / output validation
15             # - Shell script generation (generate_script)
16             # - NBI::Job construction and NBI::Manifest creation (build)
17             #
18             # RELATIONSHIPS:
19             # - Subclasses live in NBI::Launcher::*, ./launchers/, or ~/.nbi/launchers/.
20             # - build() returns (NBI::Job, NBI::Manifest) - consumed by bin/nbilaunch.
21             # - NBI::Pipeline wraps multiple NBI::Job objects for multi-step launchers.
22             #
23              
24 2     2   270809 use 5.012;
  2         8  
25 2     2   9 use strict;
  2         3  
  2         70  
26 2     2   15 use warnings;
  2         3  
  2         141  
27 2     2   12 use Carp qw(confess croak);
  2         3  
  2         151  
28 2     2   18 use File::Basename qw(basename);
  2         3  
  2         137  
29 2     2   10 use Cwd qw(realpath);
  2         2  
  2         127  
30 2     2   1092 use POSIX qw(strftime);
  2         14237  
  2         10  
31              
32             $NBI::Launcher::VERSION = $NBI::Slurm::VERSION;
33              
34             # ── Constructor ───────────────────────────────────────────────────────────────
35             # All parameters are named (not the -param style used by NBI::Opts/NBI::Job).
36             #
37             # Required: name, activate, inputs, outputs, outdir
38             # Optional: description, version, slurm_defaults, params, scratch, success_check
39             sub new {
40 10     10 1 3707 my ($class, %args) = @_;
41              
42             # ── Validate activate spec ────────────────────────────────────────────────
43             my $activate = $args{activate}
44 10 100       207 or confess "ERROR NBI::Launcher: 'activate' is required\n";
45 9 50       31 ref $activate eq 'HASH'
46             or confess "ERROR NBI::Launcher: 'activate' must be a hashref\n";
47 9         50 my @act_keys = keys %$activate;
48             confess "ERROR NBI::Launcher: 'activate' must have exactly one key (module|singularity|conda), got: @act_keys\n"
49 9 100 100     303 unless @act_keys == 1 && grep { /^(module|singularity|conda)$/ } @act_keys;
  8         256  
50              
51             # ── Validate slurm_defaults ───────────────────────────────────────────────
52 7   100     30 my $slurm_defaults = $args{slurm_defaults} // {};
53 7         118 for my $k (keys %$slurm_defaults) {
54 8 100       294 confess "ERROR NBI::Launcher: unknown slurm_defaults key '$k'\n"
55             unless $k =~ /^(queue|threads|memory|runtime)$/;
56             }
57              
58 6         21 my $self = bless {}, $class;
59              
60 6 100       212 $self->{name} = $args{name} or confess "ERROR NBI::Launcher: 'name' is required\n";
61 5   100     20 $self->{description} = $args{description} // '';
62 5   100     38 $self->{version} = $args{version} // 'unknown';
63 5         10 $self->{activate} = $activate;
64             $self->{slurm_defaults} = {
65 5         23 queue => 'qib-short',
66             threads => 1,
67             memory => 4, # GB
68             runtime => '01:00:00',
69             %$slurm_defaults, # caller overrides
70             };
71 5   50     17 $self->{inputs} = $args{inputs} // [];
72 5   50     18 $self->{params} = $args{params} // [];
73 5   50     15 $self->{outputs} = $args{outputs} // [];
74 5   50     11 $self->{outdir} = $args{outdir} // { flag => '--outdir', short => '-o', required => 1 };
75 5   100     25 $self->{scratch} = $args{scratch} // { use_tmpdir => 0, cleanup_on_failure => 0 };
76 5         10 $self->{success_check} = $args{success_check}; # optional coderef
77              
78 5         30 return $self;
79             }
80              
81             # ── activation_lines() ────────────────────────────────────────────────────────
82             # Returns the shell snippet that loads the tool environment.
83             # For singularity: returns "" because the prefix goes into make_command()
84             # via singularity_prefix().
85             sub activation_lines {
86 5     5 1 20 my ($self) = @_;
87 5         10 my ($type, $value) = each %{ $self->{activate} };
  5         17  
88              
89 5 100       25 if ($type eq 'module') {
    100          
    50          
90 3         18 return "module load $value\n";
91             } elsif ($type eq 'conda') {
92             # Use 'source activate' for broadest HPC compatibility.
93             # On systems with conda >= 4.4, 'conda activate' also works.
94 1         8 return "source activate $value\n";
95             } elsif ($type eq 'singularity') {
96             # Singularity prefix is applied per-command in make_command()
97             # via singularity_prefix(). Nothing needed here.
98 1         8 return '';
99             }
100 0         0 return '';
101             }
102              
103             # ── singularity_prefix() ──────────────────────────────────────────────────────
104             # Helper for make_command() overrides: returns "singularity exec $image "
105             # when the launcher uses singularity activation, empty string otherwise.
106             # Subclass make_command() calls this and prepends it to the tool invocation.
107             sub singularity_prefix {
108 2     2 1 7 my ($self) = @_;
109 2         5 my $img = $self->{activate}{singularity};
110 2 100       14 return defined $img ? "singularity exec $img " : '';
111             }
112              
113             # ── sample_name(%args) ────────────────────────────────────────────────────────
114             # Derives the sample name from the first file-type required input (usually r1).
115             # Strips known FASTQ extensions in order: .gz .fastq .fq _R1 _R2 _1 _2
116             # Override with --sample-name on the nbilaunch command line.
117             sub sample_name {
118 6     6 1 25 my ($self, %args) = @_;
119              
120             # Explicit override takes priority
121 6 100       26 return $args{sample_name} if defined $args{sample_name};
122              
123             # Find the first required file-type input
124 5         9 my $source;
125 5         8 for my $inp (@{ $self->{inputs} }) {
  5         15  
126 5 50 50     67 if (($inp->{type} // '') eq 'file' && ($inp->{required} // 0)) {
      50        
      33        
127 5         15 $source = $args{ $inp->{name} };
128 5 50       16 last if defined $source;
129             }
130             }
131 5 50       15 confess "ERROR NBI::Launcher ($self->{name}): cannot derive sample name - no file input found\n"
132             unless defined $source;
133              
134 5         236 my $name = basename($source);
135             # Strip extensions from right to left
136 5         45 $name =~ s/\.gz$//i;
137 5         23 $name =~ s/\.(fastq|fq)$//i;
138 5         16 $name =~ s/_R[12]$//;
139 5         11 $name =~ s/_[12]$//;
140 5         34 return $name;
141             }
142              
143             # ── input_mode(%args) ────────────────────────────────────────────────────────
144             # Returns "paired" if both r1 and r2 are defined, "single" otherwise.
145             # Subclasses may override for tools with different pairing logic.
146             sub input_mode {
147 2     2 1 788 my ($self, %args) = @_;
148 2 100 66     30 return (defined $args{r1} && defined $args{r2}) ? 'paired' : 'single';
149             }
150              
151             # ── arg_spec() ────────────────────────────────────────────────────────────────
152             # Returns the full CLI surface of this launcher for nbilaunch to use when
153             # generating --help text and parsing command-line arguments.
154             # slurm_sync params are excluded (they are derived, not user-settable).
155             sub arg_spec {
156 2     2 1 954 my ($self) = @_;
157             return {
158             name => $self->{name},
159             description => $self->{description},
160             version => $self->{version},
161             activate => $self->{activate},
162 4         12 inputs => [ grep { !$_->{slurm_sync} } @{ $self->{inputs} } ],
  2         7  
163 5         24 params => [ grep { !$_->{slurm_sync} } @{ $self->{params} } ],
  2         5  
164             outputs => $self->{outputs},
165             outdir => $self->{outdir},
166             slurm_defaults => $self->{slurm_defaults},
167 2         7 };
168             }
169              
170             # ── validate(%args) ───────────────────────────────────────────────────────────
171             # Dies with a helpful message if required inputs/params are missing or if
172             # file/dir values do not exist on disk.
173             sub validate {
174 7     7 1 5600 my ($self, %args) = @_;
175              
176             # Check inputs
177 7         14 for my $inp (@{ $self->{inputs} }) {
  7         20  
178 12 50       37 next if $inp->{slurm_sync};
179 12         19 my $name = $inp->{name};
180 12         20 my $val = $args{$name};
181              
182 12 100 100     47 if ($inp->{required} && !defined $val) {
183 1         267 confess "ERROR NBI::Launcher ($self->{name}): missing required input '--$name'\n";
184             }
185 11 100       26 next unless defined $val;
186              
187 6   50     16 my $type = $inp->{type} // 'string';
188 6 100 66     309 if ($type eq 'file' && !-f $val) {
    50 33        
189 1         215 confess "ERROR NBI::Launcher ($self->{name}): input '$name' - file not found: $val\n";
190             } elsif ($type eq 'dir' && !-d $val) {
191 0         0 confess "ERROR NBI::Launcher ($self->{name}): input '$name' - directory not found: $val\n";
192             }
193             }
194              
195             # Check params (with default_env and default fallback)
196 5         8 for my $p (@{ $self->{params} }) {
  5         35  
197 11 100       28 next if $p->{slurm_sync};
198 7         9 my $name = $p->{name};
199 7         15 my $val = $args{$name};
200              
201             # Try default_env, then default
202 7 100 100     27 if (!defined $val && $p->{default_env}) {
203 4         9 $val = $ENV{ $p->{default_env} };
204             }
205 7   66     17 $val //= $p->{default};
206              
207 7 50 66     21 if ($p->{required} && !defined $val) {
208 0         0 confess "ERROR NBI::Launcher ($self->{name}): missing required param '--$name'\n";
209             }
210 7 50       14 next unless defined $val;
211              
212 7   50     13 my $type = $p->{type} // 'string';
213 7 50 33     338 if ($type eq 'int' && $val !~ /^\d+$/) {
    50 66        
    50 33        
    100 100        
214 0         0 confess "ERROR NBI::Launcher ($self->{name}): param '$name' must be an integer, got: $val\n";
215             } elsif ($type eq 'float' && $val !~ /^[\d.]+$/) {
216 0         0 confess "ERROR NBI::Launcher ($self->{name}): param '$name' must be a number, got: $val\n";
217             } elsif ($type eq 'file' && !-f $val) {
218 0         0 confess "ERROR NBI::Launcher ($self->{name}): param '$name' - file not found: $val\n";
219             } elsif ($type eq 'dir' && !-d $val) {
220 1         300 confess "ERROR NBI::Launcher ($self->{name}): param '$name' - directory not found: $val\n";
221             }
222             }
223              
224             # Check outdir is provided
225 4 100 66     27 if ($self->{outdir}{required} && !defined $args{outdir}) {
226 1         219 confess "ERROR NBI::Launcher ($self->{name}): missing required '--outdir'\n";
227             }
228              
229 3         13 return 1;
230             }
231              
232             # ── make_command(%args) ───────────────────────────────────────────────────────
233             # Returns the tool invocation string to embed in the job script.
234             # Subclasses SHOULD override this method.
235             # The default implementation raises an error - every launcher needs a command.
236             #
237             # %args contains all resolved inputs, params, and derived keys:
238             # $args{sample} - derived sample name
239             # $args{threads} - from slurm_sync or slurm_defaults
240             # For scratch paths, use literal \$SCRATCH (shell variable).
241             sub make_command {
242 0     0   0 my ($self, %args) = @_;
243 0         0 confess "ERROR NBI::Launcher ($self->{name}): make_command() not implemented\n";
244             }
245              
246             # ── generate_script(%args) ───────────────────────────────────────────────────
247             # Assembles the script body (everything after the #SBATCH header generated by
248             # NBI::Opts->header()). Returns a single string.
249             #
250             # Sections (in order):
251             # 1. set -euo pipefail + metadata comment
252             # 2. Manifest update shell functions + ERR trap
253             # 3. Activation (module load / conda / empty for singularity)
254             # 4. SAMPLE, OUTDIR, SCRATCH variables
255             # 5. EXIT trap (scratch cleanup)
256             # 6. Tool command from make_command()
257             # 7. Required-output validation
258             # 8. Promote from scratch to outdir + success update
259             sub generate_script {
260 2     2 1 460 my ($self, %args) = @_;
261              
262 2         11 my $tool = $self->{name};
263 2         10 my $version = $self->{version};
264 2 50       12 my $sample = $args{sample} or confess "generate_script: 'sample' required\n";
265 2 50       9 my $outdir = $args{outdir} or confess "generate_script: 'outdir' required\n";
266 2   33     7 my $manifest_rel = $args{manifest_path} // ".nbilaunch/$sample.manifest.json";
267 2         88 my $submitted_at = strftime("%Y-%m-%dT%H:%M:%SZ", gmtime());
268              
269 2         7 my $launcher_class = ref($self);
270 2   50     25 my $nbi_version = $NBI::Slurm::VERSION // 'unknown';
271 2   50     10 my $nbi_l_version = $NBI::Launcher::VERSION // '0.1.0';
272              
273             # ── Resolve %args for make_command ───────────────────────────────────────
274             # Apply default_env / default fallbacks for params before calling make_command
275 2         14 my %cmd_args = %args;
276 2         5 for my $p (@{ $self->{params} }) {
  2         13  
277 5         8 my $name = $p->{name};
278 5 50       32 next if defined $cmd_args{$name};
279 0 0 0     0 if ($p->{default_env} && defined $ENV{ $p->{default_env} }) {
    0          
280 0         0 $cmd_args{$name} = $ENV{ $p->{default_env} };
281             } elsif (defined $p->{default}) {
282 0         0 $cmd_args{$name} = $p->{default};
283             }
284             }
285              
286 2         28 my $tool_command = $self->make_command(%cmd_args);
287              
288             # ── Output validation checks ──────────────────────────────────────────────
289 2         7 my $validation = '';
290 2         4 for my $out (@{ $self->{outputs} }) {
  2         5  
291 3 100       28 next unless $out->{required};
292 2   50     7 my $pat = $out->{pattern} // next;
293             # Substitute {sample} with shell variable reference
294 2         11 (my $shell_pat = $pat) =~ s/\{sample\}/\${SAMPLE}/g;
295 2         8 $validation .= <<" BASH";
296             if [[ ! -s "\$SCRATCH/$shell_pat" ]]; then
297             echo "[nbilaunch] ERROR: required output not found or empty: $shell_pat" >&2
298             exit 1
299             fi
300             BASH
301             }
302              
303 2         10 my $activation = $self->activation_lines();
304              
305             # ── Scratch setup ─────────────────────────────────────────────────────────
306             # Priority: explicit scratch_dir arg > $TMPDIR (use_tmpdir) > /tmp
307 2   50     9 my $use_tmpdir = $self->{scratch}{use_tmpdir} // 0;
308             my $scratch_base = defined $args{scratch_dir} ? $args{scratch_dir}
309 2 50       9 : $use_tmpdir ? '${TMPDIR:-/tmp}'
    50          
310             : '/tmp';
311 2         5 my $scratch_init = qq{SCRATCH=\$(mktemp -d "$scratch_base/${tool}_XXXXXXXX")};
312              
313             # ── Assemble script ───────────────────────────────────────────────────────
314 2         3 my $sep = '# ' . '─' x 74;
315              
316 2         56 my $script = <<"SCRIPT";
317             set -euo pipefail
318              
319             $sep
320             # Generated by nbilaunch v${nbi_l_version} / NBI::Slurm v${nbi_version}
321             # Tool: $tool v$version
322             # Launcher: $launcher_class
323             # Submitted: $submitted_at
324             # Manifest: $manifest_rel
325             $sep
326              
327             $sep
328             # Runtime variables - defined first so traps and manifest can reference them
329             SAMPLE="$sample"
330             OUTDIR="\$(realpath "$outdir" 2>/dev/null || echo "$outdir")"
331             $scratch_init
332             MANIFEST="\$OUTDIR/.nbilaunch/$sample.manifest.json"
333             $sep
334              
335             $sep
336             # Manifest update - called by ERR trap (failure) and at end (success).
337             # Uses a perl one-liner so there is no jq dependency on the HPC.
338             _nbi_manifest_update() {
339             local status="\$1" exit_code="\$2" completed_at
340             completed_at=\$(date -u +"%Y-%m-%dT%H:%M:%SZ")
341             perl -i -0777 -pe "
342             s{\\\"status\\\":\\\\s*\\\"[^\\\"]+\\\"}{\\\"status\\\": \\\"\$status\\\"};
343             s{\\\"exit_code\\\":\\\\s*null}{\\\"exit_code\\\": \$exit_code};
344             s{\\\"completed_at\\\":\\\\s*null}{\\\"completed_at\\\": \\\"\$completed_at\\\"};
345             " "\$MANIFEST"
346             }
347              
348             trap '_nbi_manifest_update failure \$?' ERR
349             $sep
350              
351             $sep
352             # Scratch cleanup on exit (runs even on success - scratch is empty after mv)
353             trap 'rm -rf "\$SCRATCH"' EXIT
354             $sep
355              
356             SCRIPT
357              
358             # Activation section (empty for singularity)
359 2 50       7 if ($activation) {
360 2         17 $script .= <<"SCRIPT";
361             $sep
362             # Environment activation
363             $activation$sep
364              
365             SCRIPT
366             }
367              
368 2         8 $script .= <<"SCRIPT";
369              
370             $sep
371             # Tool command
372             $tool_command
373             $sep
374              
375             SCRIPT
376              
377 2 50       6 if ($validation) {
378 2         43 $script .= <<"SCRIPT";
379             $sep
380             # Output validation
381             ${validation}$sep
382              
383             SCRIPT
384             }
385              
386 2         11 $script .= <<"SCRIPT";
387             $sep
388             # Promote outputs from scratch to outdir and record success
389             mkdir -p "\$OUTDIR" "\$OUTDIR/.nbilaunch"
390             mv "\$SCRATCH"/* "\$OUTDIR"/
391             _nbi_manifest_update success 0
392             echo "[nbilaunch] Done. Outputs in: \$OUTDIR"
393             $sep
394             SCRIPT
395              
396 2         22 return $script;
397             }
398              
399             # ── _resolve_args(%args) ──────────────────────────────────────────────────────
400             # Internal: apply default_env and default fallbacks, inject slurm_sync values,
401             # and resolve absolute paths. Returns the resolved %args hash.
402             sub _resolve_args {
403 1     1   6 my ($self, %args) = @_;
404              
405             # Apply param defaults
406 1         2 for my $p (@{ $self->{params} }) {
  1         2  
407 3         4 my $name = $p->{name};
408 3 100       6 next if defined $args{$name};
409 2 100 66     9 if ($p->{default_env} && defined $ENV{ $p->{default_env} }) {
    50          
410 1         3 $args{$name} = $ENV{ $p->{default_env} };
411             } elsif (defined $p->{default}) {
412 1         1 $args{$name} = $p->{default};
413             }
414             }
415              
416             # Resolve absolute paths for file/dir inputs and params
417 1         2 for my $spec (@{ $self->{inputs} }, @{ $self->{params} }) {
  1         2  
  1         2  
418 5         7 my $name = $spec->{name};
419 5 100       9 next unless defined $args{$name};
420 4   50     5 my $type = $spec->{type} // '';
421 4 100 100     12 if ($type eq 'file' || $type eq 'dir') {
422 2   33     38 $args{$name} = realpath($args{$name}) // $args{$name};
423             }
424             }
425              
426             # Absolute outdir
427 1 50       4 if (defined $args{outdir}) {
428             # realpath requires the path to exist; use abs_path fallback
429 1         2 my $abs = eval { realpath($args{outdir}) };
  1         51  
430 1 50       4 $args{outdir} = $abs if defined $abs;
431             }
432              
433 1         8 return %args;
434             }
435              
436             # ── _runtime_to_hours($str) ───────────────────────────────────────────────────
437             # Convert HH:MM:SS or simple hour strings to decimal hours for NBI::Opts.
438             sub _runtime_to_hours {
439 1     1   2 my ($rt) = @_;
440 1 50       8 if ($rt =~ /^(\d+):(\d+):(\d+)$/) {
441 1         7 return $1 + $2 / 60 + $3 / 3600;
442             }
443 0 0       0 if ($rt =~ /^(\d+):(\d+)$/) {
444 0         0 return $1 + $2 / 60;
445             }
446 0 0       0 if ($rt =~ /^(\d+)$/) {
447 0         0 return $1; # bare integer = hours
448             }
449             # Fall back: try NBI::Opts internal parser pattern
450 0         0 my $hours = 0;
451 0         0 my $upper = uc $rt;
452 0         0 while ($upper =~ /(\d+)([DHMS])/g) {
453 0         0 my ($v, $u) = ($1, $2);
454 0 0       0 $hours += $v * 24 if $u eq 'D';
455 0 0       0 $hours += $v if $u eq 'H';
456 0 0       0 $hours += $v / 60 if $u eq 'M';
457 0 0       0 $hours += $v / 3600 if $u eq 'S';
458             }
459 0   0     0 return $hours || 1;
460             }
461              
462             # ── build(%args) ─────────────────────────────────────────────────────────────
463             # The main entry point called by bin/nbilaunch.
464             #
465             # Validates inputs, resolves defaults, builds NBI::Job and NBI::Manifest.
466             # Returns a two-element list: ($job, $manifest).
467             #
468             # %args keys:
469             # All inputs/params by name (from nbilaunch arg parsing)
470             # outdir - output directory (required)
471             # sample_name - optional override for derived sample name
472             # slurm_queue - override slurm_defaults{queue}
473             # slurm_threads - override slurm_defaults{threads}
474             # slurm_memory - override slurm_defaults{memory} (GB)
475             # slurm_runtime - override slurm_defaults{runtime} (HH:MM:SS or hours)
476             sub build {
477 1     1 1 4 my ($self, %args) = @_;
478              
479 1         6 require NBI::Job;
480 1         2 require NBI::Opts;
481 1         565 require NBI::Manifest;
482              
483             # ── Resolve Slurm resource values ─────────────────────────────────────────
484 1   33     5 my $queue = $args{slurm_queue} // $self->{slurm_defaults}{queue};
485 1   33     3 my $threads = $args{slurm_threads} // $self->{slurm_defaults}{threads};
486 1   33     5 my $mem_gb = $args{slurm_memory} // $self->{slurm_defaults}{memory};
487 1   33     6 my $runtime = $args{slurm_runtime} // $self->{slurm_defaults}{runtime};
488              
489             # Inject slurm_sync params (e.g. threads param mirrors Slurm --cpus)
490 1         1 for my $p (@{ $self->{params} }) {
  1         4  
491 3 100       6 next unless $p->{slurm_sync};
492 1 50       3 if ($p->{slurm_sync} eq 'threads') {
    0          
493 1         4 $args{ $p->{name} } = $threads;
494             } elsif ($p->{slurm_sync} eq 'memory') {
495 0         0 $args{ $p->{name} } = $mem_gb;
496             }
497             }
498              
499             # ── Resolve and validate args ─────────────────────────────────────────────
500 1         8 %args = $self->_resolve_args(%args);
501 1         6 $self->validate(%args);
502              
503             # ── Derive sample name ────────────────────────────────────────────────────
504 1         7 my $sample = $self->sample_name(%args);
505 1         3 $args{sample} = $sample;
506              
507             # ── Paths ─────────────────────────────────────────────────────────────────
508 1         2 my $outdir = $args{outdir};
509 1         6 my $nbi_dir = "$outdir/.nbilaunch";
510 1         4 my $job_name = "$self->{name}_$sample";
511 1         3 my $manifest_path = "$nbi_dir/$sample.manifest.json";
512 1         2 my $script_rel = ".nbilaunch/${job_name}.script.sh";
513              
514 1         3 $args{manifest_path} = $manifest_path;
515              
516             # ── Generate script body ──────────────────────────────────────────────────
517 1         11 my $script_body = $self->generate_script(%args);
518              
519             # ── Build NBI::Opts ───────────────────────────────────────────────────────
520 1         3 my $hours = _runtime_to_hours($runtime);
521 1         1 my $mem_mb = $mem_gb * 1024;
522              
523 1         8 my $opts = NBI::Opts->new(
524             -queue => $queue,
525             -threads => $threads,
526             -memory => $mem_mb,
527             -time => $hours,
528             -tmpdir => $nbi_dir,
529             );
530              
531             # ── Build NBI::Job ────────────────────────────────────────────────────────
532 1         7 my $job = NBI::Job->new(
533             -name => $job_name,
534             -command => $script_body,
535             -opts => $opts,
536             );
537              
538             # Log/err go to provenance directory; %j is expanded by Slurm to the job ID
539 1         5 $job->outputfile = "$nbi_dir/${job_name}.%j.log";
540 1         11 $job->errorfile = "$nbi_dir/${job_name}.%j.err";
541              
542             # ── Collect resolved inputs/params/outputs for manifest ───────────────────
543 1         1 my %inp_snapshot;
544 1         2 for my $inp (@{ $self->{inputs} }) {
  1         2  
545             $inp_snapshot{ $inp->{name} } = $args{ $inp->{name} }
546 2 100       20 if defined $args{ $inp->{name} };
547             }
548              
549 1         1 my %par_snapshot;
550 1         2 for my $p (@{ $self->{params} }) {
  1         1  
551             $par_snapshot{ $p->{name} } = $args{ $p->{name} }
552 3 50       9 if defined $args{ $p->{name} };
553             }
554              
555 1         1 my %out_snapshot;
556 1         2 for my $out (@{ $self->{outputs} }) {
  1         1  
557 2   50     5 my $pat = $out->{pattern} // next;
558 2         6 (my $filename = $pat) =~ s/\{sample\}/$sample/g;
559 2         10 $out_snapshot{ $out->{name} } = $filename;
560             }
561              
562             # ── Build manifest ────────────────────────────────────────────────────────
563             my $manifest = NBI::Manifest->new(
564             tool => $self->{name},
565             tool_version => $self->{version},
566 1         7 sample => $sample,
567             outdir => $outdir,
568             inputs => \%inp_snapshot,
569             params => \%par_snapshot,
570             outputs => \%out_snapshot,
571             slurm_queue => $queue,
572             slurm_cpus => $threads,
573             slurm_mem_gb => $mem_gb,
574             script => $script_rel,
575             status => 'submitted',
576             );
577              
578 1         9 return ($job, $manifest);
579             }
580              
581             1;
582              
583             __END__