File Coverage: lib/SimpleFlow.pm

# File Coverage

File: | lib/SimpleFlow.pm
Coverage: | 90.4%

line | stmt | bran | cond | sub | time | code
--- | --- | --- | --- | --- | --- | ---
1 |  |  |  |  |  | # ABSTRACT: SimpleFlow - easy, simple workflow manager (and logger); for keeping track of and debugging large and complex shell command workflows
2 | 2 2 2 |  |  | 2 | 2 0 14 | use strict;
3 | 2 2 2 |  |  | 2 | 3 2 33 | use warnings FATAL => 'all';
4 |  |  |  |  |  | require 5.010;
5 | 2 2 2 |  |  | 2 | 2 1 63 | use feature 'say';
6 | 2 2 2 |  |  | 2 | 53 9654 6 | use DDP {output => 'STDOUT', array_max => 10, show_memsize => 1};
7 | 2 2 2 |  |  | 2 | 288 5685 3 | use Devel::Confess 'color';
8 | 2 2 2 |  |  | 2 | 64 0 38 | use Cwd 'getcwd';
9 |  |  |  |  |  | package SimpleFlow;
10 |  |  |  |  |  | our $VERSION = 0.14;
11 | 2 2 2 |  |  | 2 | 2 1 4 | use Time::HiRes;
12 | 2 2 2 |  |  | 2 | 340 5057 74 | use Term::ANSIColor;
13 |  |  |  |  |  | # Windows portability: the legacy Windows console (cmd.exe) prints raw ANSI
14 |  |  |  |  |  | # escape sequences as garbage. Disable colouring there unless a terminal that
15 |  |  |  |  |  | # understands ANSI is in use (Windows Terminal, ConEmu, ANSICON). Unix and
16 |  |  |  |  |  | # modern Windows terminals are left untouched.
17 |  |  |  |  |  | BEGIN {
18 |  |  |  |  |  | $ENV{ANSI_COLORS_DISABLED} = 1
19 |  |  |  |  |  | if $^O eq 'MSWin32'
20 |  |  |  |  |  | && !$ENV{WT_SESSION} # Windows Terminal
21 |  |  |  |  |  | && !$ENV{ConEmuANSI} # ConEmu
22 | 2 | 0 | 20 | 2 | 18 | && !$ENV{ANSICON}; # ANSICON
23 |  |  |  |  |  | }
24 | 2 2 2 |  |  | 2 | 4 1 23 | use Scalar::Util 'openhandle';
25 | 2 2 2 |  |  | 2 | 2 2 5 | use DDP {output => 'STDOUT', array_max => 10, show_memsize => 1};
26 | 2 2 2 |  |  | 2 | 61 1 3 | use Devel::Confess 'color';
27 | 2 2 2 |  |  | 2 | 58 1 19 | use Cwd 'getcwd';
28 | 2 2 2 |  |  | 2 | 2 0 27 | use warnings FATAL => 'all';
29 | 2 2 2 |  |  | 2 | 240 2768 32 | use Capture::Tiny 'capture';
30 | 2 2 2 |  |  | 2 | 3 1 22 | use List::Util 'max';
31 | 2 2 2 |  |  | 2 | 2 2 1476 | use Exporter 'import';
32 |  |  |  |  |  | our @EXPORT = qw(say2 task);
33 |  |  |  |  |  | our @EXPORT_OK = @EXPORT;
34 |
35 |  |  |  |  |  | sub say2 { # say to both command line and log file
36 | 2 |  |  | 2 | 4 | my ($msg, $fh) = @_;
37 | 2 |  |  |  | 6 | my $current_sub = (split(/::/,(caller(0))[3]))[-1]; # https://stackoverflow.com/questions/2559792/how-can-i-get-the-name-of-the-current-subroutine-in-perl
38 | 2 |  |  |  | 30 | my @c = caller;
39 | 2 | 100 |  |  | 21 | if (not openhandle($fh)) {
40 | 1 |  |  |  | 5 | die "the filehandle given to $current_sub with \"$msg\" from $c[1] line $c[2] isn't actually a filehandle";
41 |  |  |  |  |  | }
42 | 1 |  |  |  | 1 | $msg = "\@ $c[1] line $c[2] $msg";
43 | 1 |  |  |  | 2 | say $msg;
44 | 1 |  |  |  | 1 | say $fh $msg;
45 | 1 |  |  |  | 1 | return $msg;
46 |  |  |  |  |  | }
47 |
48 |  |  |  |  |  | sub task {
49 | 32 |  |  | 32 | 99 | my $current_sub = (split(/::/,(caller(0))[3]))[-1];
50 |  |  |  |  |  | # Accept either a single hash ref -- task({ cmd => ... }) -- or a flat
51 |  |  |  |  |  | # key/value list -- task(cmd => ...). A lone non-hashref scalar, or an
52 |  |  |  |  |  | # odd-length list, can't be read either way and is fatal.
53 | 32 |  |  |  | 548 | my $args;
54 | 32 | 100 100 | 67 |  | 181 | if (@_ == 1 && ref $_[0] eq 'HASH') {
55 | 29 |  |  |  | 23 | $args = $_[0];
56 |  |  |  |  |  | } elsif (@_ % 2 == 0) {
57 | 2 |  |  |  | 2 | $args = { @_ };
58 |  |  |  |  |  | } else {
59 | 1 |  |  |  | 4 | die "args to $current_sub must be a hash ref (e.g. $current_sub({ cmd => ... })) or a flat key/value list (e.g. $current_sub(cmd => ...)); got an odd-length list";
60 |  |  |  |  |  | }
61 | 31 |  |  |  | 37 | my @c = caller;
62 | 31 |  |  |  | 316 | my @reqd_args = (
63 |  |  |  |  |  | 'cmd', # the shell command
64 |  |  |  |  |  | );
65 | 31 31 |  |  |  | 45 51 | my @undef_args = grep { !defined $args->{$_}} @reqd_args;
66 | 31 | 100 |  |  | 55 | if (scalar @undef_args > 0) {
67 | 1 |  |  |  | 2 | p @undef_args;
68 | 1 |  |  |  | 1146 | die 'the above args are necessary, but were not defined.';
69 |  |  |  |  |  | }
70 | 30 |  |  |  | 50 | my @defined_args = ( @reqd_args,
71 |  |  |  |  |  | 'die', # die if not successful; 0 or 1
72 |  |  |  |  |  | 'dry.run', # dry run or not
73 |  |  |  |  |  | 'input.files', # check for input files; SCALAR or ARRAY
74 |  |  |  |  |  | 'log.fh',
75 |  |  |  |  |  | 'note', # a note for the log
76 |  |  |  |  |  | 'overwrite', #
77 |  |  |  |  |  | 'output.files' # product files that need to be checked; can be scalar or array
78 |  |  |  |  |  | );
79 | 30 64 64 512 30 |  |  |  | 25 44 47 326 36 | my @bad_args = grep { my $key = $_; not grep {$_ eq $key} @defined_args} keys %{ $args };
80 | 30 | 100 |  |  | 43 | if (scalar @bad_args > 0) {
81 | 1 |  |  |  | 2 | p @bad_args, array_max => scalar @bad_args;
82 | 1 |  |  |  | 1065 | say "the above arguments are not recognized by $current_sub";
83 | 1 |  |  |  | 1 | p @defined_args, array_max => scalar @defined_args;
84 | 1 |  |  |  | 1703 | die "The above args are accepted by $current_sub";
85 |  |  |  |  |  | }
86 | 29 | 100 | 100 |  | 57 | if (
87 |  |  |  |  |  | (defined $args->{'log.fh'}) &&
88 |  |  |  |  |  | (not openhandle($args->{'log.fh'}))
89 |  |  |  |  |  | ) {
90 | 1 |  |  |  | 1 | p $args;
91 | 1 |  |  |  | 1236 | die "the filehandle given to $current_sub isn't actually a filehandle";
92 |  |  |  |  |  | }
93 | 28 |  |  |  | 43 | my (%input_file_size, @existing_files, @output_files, @empty_filenames);
94 | 28 | 100 |  |  | 47 | if (defined $args->{'input.files'}) {
95 | 6 |  |  |  | 5 | my $ref = ref $args->{'input.files'};
96 | 6 |  |  |  | 4 | my @missing_files;
97 | 6 | 100 100 |  |  | 10 | if ($ref eq 'ARRAY') {
98 | 1 2 1 |  |  |  | 1 7 1 | @missing_files = grep {not -f -r $_ } @{ $args->{'input.files'} };
99 | 1 2 1 |  |  |  | 1 4 1 | %input_file_size = map { $_ => -s $_ } @{ $args->{'input.files'} };
100 | 1 2 1 | 50 |  |  | 1 5 0 | @empty_filenames = grep {(defined $_) && (length $_ == 0)} @{ $args->{'input.files'} };
101 |  |  |  |  |  | } elsif ($ref eq '') { # scalar
102 | 4 4 |  |  |  | 6 12 | @missing_files = grep {not -f -r $_ } ($args->{'input.files'});
103 | 4 4 |  |  |  | 4 6 | %input_file_size = map { $_ => -s $_ } ($args->{'input.files'} );
104 | 4 4 | 50 |  |  | 4 13 | @empty_filenames = grep {(defined $_) && (length $_ == 0)} ($args->{'input.files'});
105 |  |  |  |  |  | } else {
106 | 1 |  |  |  | 2 | p $args;
107 | 1 |  |  |  | 4371 | die 'ref type "' . $ref . '" is not allowed for "input.files"';
108 |  |  |  |  |  | }
109 | 5 | 100 |  |  | 6 | if (scalar @missing_files > 0) {
110 | 3 |  |  |  | 10 | say STDERR 'this list of arguments:';
111 | 3 |  |  |  | 5 | p $args;
112 | 3 |  |  |  | 3665 | say STDERR 'Cannot run because these files are either missing or unreadable in: ' . getcwd();
113 | 3 |  |  |  | 9 | p @missing_files;
114 | 3 |  |  |  | 2994 | die 'the above files are missing or are not readable';
115 |  |  |  |  |  | }
116 |  |  |  |  |  | }
117 | 24 | 50 |  |  | 27 | if (scalar @empty_filenames > 0) {
118 | 0 |  |  |  | 0 | p $args;
119 | 0 |  |  |  | 0 | die '0-length filenames are not allowed (found in "input.files")';
120 |  |  |  |  |  | }
121 | 24 |  |  |  | 144 | my $msg = "\@ $c[1] line $c[2] The command is:\n" . colored(['blue on_bright_red'], $args->{cmd});
122 | 24 |  |  |  | 886 | say $msg;
123 | 24 1 | 100 |  |  | 53 4 | say {$args->{'log.fh'}} "\@ $c[1] line $c[2] The command is:\n$args->{cmd}" if defined $args->{'log.fh'};
124 | 24 | 100 |  |  | 39 | if (defined $args->{'output.files'}) { # avoid "uninitialized value" warning
125 | 8 |  |  |  | 13 | my $ref = ref $args->{'output.files'};
126 | 8 | 100 100 |  |  | 19 | if ($ref eq 'ARRAY') {
127 | 1 1 |  |  |  | 0 1 | @output_files = @{ $args->{'output.files'} };
128 |  |  |  |  |  | } elsif ($ref eq '') { # a scalar
129 | 6 |  |  |  | 10 | @output_files = $args->{'output.files'};
130 |  |  |  |  |  | } else {
131 | 1 |  |  |  | 2 | p $args;
132 | 1 |  |  |  | 1402 | die "$ref isn't allowed for \"output.files\"";
133 |  |  |  |  |  | }
134 |  |  |  |  |  | }
135 | 23 7 | 50 |  |  | 26 22 | @empty_filenames = grep {(defined $_) && (length $_ == 0)} @output_files; # 0-length filenames aren't allowed
136 | 23 | 100 |  |  | 27 | if (scalar @empty_filenames > 0) {
137 | 1 |  |  |  | 2 | p $args;
138 | 1 |  |  |  | 1237 | die '0-length filenames are not allowed (found in "output.files"';
139 |  |  |  |  |  | }
140 | 22 | 100 |  |  | 43 | if (scalar @output_files > 0) {
141 | 6 6 |  |  |  | 6 24 | @existing_files = grep {-f $_} @output_files;
142 |  |  |  |  |  | }
143 |  |  |  |  |  | my %r = (
144 |  |  |  |  |  | cmd => $args->{cmd},
145 | 22 |  |  |  | 147 | dir => getcwd(),
146 |  |  |  |  |  | 'source.file' => $c[1],
147 |  |  |  |  |  | 'source.line' => $c[2],
148 |  |  |  |  |  | 'output.files' => [@output_files],
149 |  |  |  |  |  | );
150 | 22 |  | 100 |  | 72 | $r{'die'} = $args->{'die'} // 1; # by default, true
151 | 22 |  | 100 |  | 88 | $r{'dry.run'} = $args->{'dry.run'} // 0; # by default, false
152 | 22 |  | 100 |  | 68 | $r{note} = $args->{note} // '';# by default, false
153 | 22 |  | 100 |  | 56 | $r{overwrite} = $args->{overwrite} // 0; # by default, false
154 | 22 |  |  |  | 19 | $r{'will.do'} = 'yes';
155 | 22 | 100 |  |  | 22 | $r{'will.do'} = 'no: dry run' if $args->{'dry.run'};
156 | 22 |  |  |  | 14 | my $string_max = 0;
157 | 22 | 100 |  |  | 20 | if (defined $args->{'input.files'}) {
158 | 2 |  |  |  | 1 | $r{'input.files'} = $args->{'input.files'};
159 | 2 |  |  |  | 1 | $r{'input.file.size'} = \%input_file_size;
160 |  |  |  |  |  | }
161 | 22 6 |  |  |  | 21 26 | my %output_file_size = map {$_ => -s $_} @output_files;
162 | 22 224 |  |  |  | 35 234 | foreach my $val (grep {ref $r{$_} eq ''} keys %r) {
163 | 199 |  |  |  | 203 | $string_max = max($string_max, length $r{$val});
164 |  |  |  |  |  | }
165 | 22 | 100 | 100 |  | 82 | if ((!$args->{overwrite}) && (scalar @output_files > 0) && (scalar @existing_files == scalar @output_files)) { # this has been done before
166 | 1 |  |  |  | 1 | $r{done} = 'before';
167 | 1 |  |  |  | 1 | $r{'will.do'} = 'no';
168 | 1 |  |  |  | 3 | say colored(['black on_green'], "\"$args->{cmd}\"\n") . ' has been done before';
169 | 1 |  |  |  | 23 | $r{'output.file.size'} = \%output_file_size;
170 | 1 |  |  |  | 2 | $r{duration} = 0;
171 | 1 | 50 |  |  | 2 | p(%r, output => $args->{'log.fh'}, string_max => $string_max) if defined $args->{'log.fh'};
172 | 1 |  |  |  | 1 | p %r, string_max => $string_max;
173 | 1 |  |  |  | 2921 | return \%r;
174 |  |  |  |  |  | } else {
175 | 21 |  |  |  | 22 | $r{done} = 'not yet';
176 |  |  |  |  |  | }
177 | 21 | 100 |  |  | 28 | if ($r{'dry.run'}) {
178 | 2 |  |  |  | 3 | say "\@ $c[1] line $c[2] in $r{dir} the command was going to be:";
179 | 2 |  |  |  | 5 | say colored(['red on_black'], "\"$args->{cmd}\"");
180 | 2 |  |  |  | 42 | say 'But this is a dry run';
181 | 2 |  |  |  | 2 | say '-------------';
182 | 2 |  |  |  | 2 | $r{duration} = 0;
183 | 2 |  |  |  | 6 | return \%r;
184 |  |  |  |  |  | }
185 | 19 |  |  |  | 35 | my $t0 = Time::HiRes::time();
186 | 19 |  |  |  | 12 | my $status;
187 |  |  |  |  |  | ($r{stdout}, $r{stderr}, $status) = capture {
188 | 19 |  |  | 19 | 84412 | system( $args->{cmd} );
189 | 19 |  |  |  | 551 | };
190 | 19 |  |  |  | 9165 | my $t1 = Time::HiRes::time();
191 | 19 |  |  |  | 35 | $r{duration} = $t1-$t0;
192 |  |  |  |  |  | # Decode the raw wait status. On Unix the low 7 bits hold the death
193 |  |  |  |  |  | # signal and the high byte holds the exit code. The signal MUST be read
194 |  |  |  |  |  | # from the raw status *before* shifting -- the old code shifted first and
195 |  |  |  |  |  | # then did ($exit & 127), so $r{signal} was always 0 and could never
196 |  |  |  |  |  | # detect a kill by signal 9/15. Windows has no POSIX signals, and a -1
197 |  |  |  |  |  | # return from system() means the command never launched.
198 | 19 | 50 50 | 33 |  | 108 | if (!defined $status || $status == -1) {
199 | 0 |  |  |  | 0 | $r{'exit'} = -1;
200 | 0 |  |  |  | 0 | $r{signal} = 0;
201 |  |  |  |  |  | } elsif ($^O eq 'MSWin32') {
202 | 0 |  |  |  | 0 | $r{signal} = 0;
203 | 0 |  |  |  | 0 | $r{'exit'} = $status >> 8;
204 |  |  |  |  |  | } else {
205 | 19 |  |  |  | 62 | $r{signal} = $status & 127; # FIX: taken from raw status, not from $exit
206 | 19 |  |  |  | 59 | $r{'exit'} = $status >> 8;
207 |  |  |  |  |  | }
208 | 19 |  |  |  | 43 | foreach my $std ('stderr', 'stdout') {
209 | 38 |  |  |  | 75 | $r{$std} =~ s/\s+$//; # remove trailing whitespace/newline
210 | 38 |  |  |  | 58 | $string_max = max($string_max, length $r{$std});
211 |  |  |  |  |  | }
212 | 19 |  |  |  | 41 | $r{done} = 'now';
213 | 19 |  |  |  | 16 | $r{'will.do'} = 'done';
214 | 19 5 |  |  |  | 27 28 | my @missing_output_files = grep {not -f -r $_} @output_files;
215 | 19 | 100 |  |  | 36 | if (scalar @missing_output_files > 0) {
216 | 1 |  |  |  | 7 | $r{'will.do'} = 'FAILED';
217 | 1 |  |  |  | 9 | say STDERR "this input to $current_sub:";
218 | 1 |  |  |  | 21 | p $args;
219 | 1 0 | 50 |  |  | 3485 0 | say {$args->{'log.fh'}} "this input to $current_sub:" if defined $args->{'log.fh'};
220 | 1 | 50 |  |  | 4 | p($args, output => $args->{'log.fh'}, string_max => $string_max) if defined $args->{'log.fh'};
221 | 1 |  |  |  | 3 | say STDERR 'has these output files missing:';
222 | 1 0 | 50 |  |  | 1 0 | say {$args->{'log.fh'}} 'has these output files missing:' if defined $args->{'log.fh'};
223 | 1 |  |  |  | 3 | p @missing_output_files;
224 | 1 | 50 |  |  | 1804 | p(@missing_output_files, output => $args->{'log.fh'}, string_max => $string_max) if defined $args->{'log.fh'};
225 | 1 |  |  |  | 15 | p %r, string_max => $string_max;
226 | 1 | 50 |  |  | 3980 | p(%r, output => $args->{'log.fh'}, string_max => $string_max) if defined $args->{'log.fh'};
227 | 1 | 50 |  |  | 3 | if ($r{'die'}) { # use the resolved value (defaults to 1), not the raw arg
228 | 0 |  |  |  | 0 | die 'those above files should have been made but are missing';
229 |  |  |  |  |  | } else {
230 | 1 |  |  |  | 2 | say STDERR 'those above files should have been made but are missing';
231 |  |  |  |  |  | }
232 |  |  |  |  |  | }
233 | 19 5 |  |  |  | 23 25 | %output_file_size = map {$_ => -s $_} @output_files;
234 | 19 |  |  |  | 21 | $r{'output.file.size'} = \%output_file_size;
235 |  |  |  |  |  | # p %output_file_size;
236 | 19 5 |  | 100 |  | 19 34 | my @files_with_zero_size = grep { ($output_file_size{$_} // 0) == 0 } @output_files;
237 | 19 | 100 |  |  | 26 | if (scalar @files_with_zero_size > 0) {
238 | 2 |  |  |  | 5 | p @files_with_zero_size;
239 | 2 |  |  |  | 2233 | warn 'the above output files have 0 size.';
240 |  |  |  |  |  | }
241 | 19 | 100 |  |  | 346 | p(%r, output => $args->{'log.fh'}) if defined $args->{'log.fh'};
242 | 19 | 100 | 100 |  | 3540 | if (($r{'die'}) && ($r{'exit'} != 0)) {
243 | 1 |  |  |  | 3 | $r{'will.do'} = 'FAILED';
244 | 1 |  |  |  | 5 | p %r, string_max => $string_max;
245 | 1 |  |  |  | 3297 | die "\"$args->{cmd}\" failed from $c[1] line $c[2]"
246 |  |  |  |  |  | }
247 | 18 |  |  |  | 78 | p %r, string_max => $string_max;
248 | 18 |  |  |  | 93943 | return \%r;
249 |  |  |  |  |  | }
250 |  |  |  |  |  | 1;
251 |
252 |  |  |  |  |  | =encoding utf8
253 |
254 |  |  |  |  |  | A tiny workflow manager and logger for Perl, like SnakeMake or NextFlow, but in pure Perl and aimed at making long, error-prone shell pipelines easy to B<debug> and B<reproduce>.
255 |
256 |  |  |  |  |  | Every step is a single C<task()> call. SimpleFlow checks the inputs before a
257 |  |  |  |  |  | command runs and the outputs after, times the command, captures its C<stdout>,
258 |  |  |  |  |  | C<stderr>, exit code and signal, optionally logs a full structured record, and
259 |  |  |  |  |  | skips work that has already been done.
260 |
261 |  |  |  |  |  | Two subroutines are exported by default: L</"task"> and L</"say2">.
262 |
263 - 650 |  |
    =head1 Install

    With a CPAN client:

     cpanm SimpleFlow

    Or from a checkout:

     perl Makefile.PL
     make
     make test
     make install

    =head1 Synopsis

    The simplest useful case: run a command and confirm it produced its output:

     use SimpleFlow qw(task say2);

     my $t = task({
         cmd            => 'which ls',
         'output.files' => '/tmp/AFK3mnEK8L.log',
     });

    C<task> returns a hash reference describing exactly what happened:

     {
         cmd            "which ls",
         die            1,
         dir            "/home/con/Scripts/SimpleFlow",
         done           "now",
         dry.run        0,
         duration       0.00191903114318848,
         exit           0,
         note           "",
         output.files   [
             [0] "/tmp/AFK3mnEK8L.log"
         ],
         overwrite      1,
         signal         0,
         source.file    "t/01.t",
         source.line    29,
         stderr         "",
         stdout         "/usr/bin/ls",
         will.do        "done"
     }

     > B<Portability note.> SimpleFlow runs whatever shell command you give it via
     > C<system()>, so the I<commands themselves> are your responsibility to keep
     > cross-platform (e.g. C<which ls> is Unix-only). SimpleFlow's own behaviour
     > exit/signal decoding and coloured output is cross-platform; see the
     > L<change log|/"Change log">.

    =head1 C<task>

     my $result = task(\%args);

    Runs one shell command with checking, timing, capture and logging. Takes a
    B<single hash reference>; the only required key is C<cmd>.

    =head2 Arguments

    =begin html

    <table>
    <thead>
    <tr>
      <th>Key</th>
      <th>Type</th>
      <th>Default</th>
      <th>Description</th>
    </tr>
    </thead>
    <tbody>
    <tr>
      <td><code>cmd</code></td>
      <td>scalar</td>
      <td><code>undef</code></td>
      <td><b>Required.</b> The shell command to run.</td>
    </tr>
    <tr>
      <td><code>die</code></td>
      <td>bool (<code>0</code>/<code>1</code>)</td>
      <td><code>1</code></td>
      <td>Die if the command fails (non-zero exit) or an output file is missing. Set to <code>0</code> to warn and continue instead.</td>
    </tr>
    <tr>
      <td><code>dry.run</code></td>
      <td>bool</td>
      <td><code>0</code></td>
      <td>Print the command (and log it) but do not execute it.</td>
    </tr>
    <tr>
      <td><code>input.files</code></td>
      <td>scalar or array</td>
      <td><code>undef</code></td>
      <td>File(s) that must exist and be readable <b>before</b> running; otherwise <code>task</code> dies.</td>
    </tr>
    <tr>
      <td><code>output.files</code></td>
      <td>scalar or array</td>
      <td><code>undef</code></td>
      <td>File(s) expected to exist <b>after</b> running; used both for the missing-output check and for skip detection.</td>
    </tr>
    <tr>
      <td><code>log.fh</code></td>
      <td>open filehandle</td>
      <td><code>undef</code></td>
      <td>If given, the full result record is also written here. Must be a real, open filehandle.</td>
    </tr>
    <tr>
      <td><code>note</code></td>
      <td>scalar</td>
      <td><code>''</code></td>
      <td>Free-text note copied into the result and the log.</td>
    </tr>
    <tr>
      <td><code>overwrite</code></td>
      <td>bool</td>
      <td><code>0</code></td>
      <td>If false and all <code>output.files</code> already exist, the command is skipped. Set true to always run.</td>
    </tr>
    </tbody>
    </table>

    =end html

    Passing an unrecognised key, an empty filename, or a non-filehandle C<log.fh>
    causes C<task> to die: these are usually mistakes worth catching early.

    =head2 Return value

    C<task> always returns a hash reference. The fields below are present after a
    normal run; the L<skip|/"Skipping completed work"> and L<dry-run|/"Dry runs"> paths
    omit the execution-only fields (C<exit>, C<signal>, C<stdout>, C<stderr>).

    =begin html

    <table>
    <thead>
    <tr>
      <th>Field</th>
      <th>Meaning</th>
    </tr>
    </thead>
    <tbody>
    <tr>
      <td><code>cmd</code></td>
      <td>The command that was run.</td>
    </tr>
    <tr>
      <td><code>dir</code></td>
      <td>Working directory at execution time.</td>
    </tr>
    <tr>
      <td><code>done</code></td>
      <td><code>"now"</code> (just ran), <code>"before"</code> (skipped, outputs already existed), or <code>"not yet"</code> (dry run).</td>
    </tr>
    <tr>
      <td><code>will.do</code></td>
      <td><code>"done"</code>, <code>"no"</code> (skipped), <code>"no: dry run"</code>, or <code>"FAILED"</code>.</td>
    </tr>
    <tr>
      <td><code>duration</code></td>
      <td>Wall-clock seconds the command took (<code>0</code> for skips/dry runs).</td>
    </tr>
    <tr>
      <td><code>exit</code></td>
      <td>Exit code of the command (<code>-1</code> if it could not be launched).</td>
    </tr>
    <tr>
      <td><code>signal</code></td>
      <td>Signal number if the command process was killed by a signal, else <code>0</code>. Always <code>0</code> on Windows (no POSIX signals).</td>
    </tr>
    <tr>
      <td><code>stdout</code>, <code>stderr</code></td>
      <td>Captured output, with trailing whitespace stripped.</td>
    </tr>
    <tr>
      <td><code>die</code>, <code>dry.run</code>, <code>overwrite</code>, <code>note</code></td>
      <td>The (defaulted) argument values used.</td>
    </tr>
    <tr>
      <td><code>output.files</code></td>
      <td>Array ref of the output files (a scalar argument is normalised to a one-element array).</td>
    </tr>
    <tr>
      <td><code>output.file.size</code></td>
      <td>Hash of <code>filename => size in bytes</code> for the outputs.</td>
    </tr>
    <tr>
      <td><code>input.files</code></td>
      <td>The input argument, as given (present only if you passed <code>input.files</code>).</td>
    </tr>
    <tr>
      <td><code>input.file.size</code></td>
      <td>Hash of <code>filename => size in bytes</code> for the inputs (present only if you passed <code>input.files</code>).</td>
    </tr>
    <tr>
      <td><code>source.file</code>, <code>source.line</code></td>
      <td>Where in <i>your</i> code the <code>task</code> was called: handy when debugging a long pipeline.</td>
    </tr>
    </tbody>
    </table>

    =end html

    =head2 Skipping completed work

    If C<overwrite> is false (the default) and every file in C<output.files> already
    exists, C<task> does B<not> re-run the command. This makes pipelines
    restartable: re-running the script picks up where it left off.

     open my $log, '>', 'logfile.txt';
     my $t = task({
         cmd            => 'gmx grompp -f em.mdp -c box.gro -p topol.top -o em.tpr',
         'input.files'  => ['em.mdp', 'box.gro', 'topol.top'],
         'output.files' => 'em.tpr',
         'log.fh'       => $log,
     });
     close $log;

    On the first run C<done> is C<"now">; on a re-run (with C<em.tpr> present) C<done>
    is C<"before"> and C<will.do> is C<"no">. Pass C<< overwrite =E<gt> 1 >> to force it.

    =head2 Dry runs

    Useful for inspecting a pipeline without executing anything expensive:

     my $t = task({
         cmd       => 'a long-running, time-consuming command',
         'dry.run' => 1,
         'log.fh'  => $fh,
     });

    The command is printed (and logged) but not run; C<will.do> is C<"no: dry run">.

    =head2 Failure behaviour

    By default (C<< die =E<gt> 1 >>) C<task> dies if the command exits non-zero or if any
    declared C<output.files> are missing afterwards, so a broken step stops the
    pipeline immediately. With C<< die =E<gt> 0 >>, C<task> instead warns and returns its
    result hash (with C<< will.do =E<gt> "FAILED" >>), letting you decide what to do.

    =head2 C<say2>

     say2($message, $filehandle);

    "Say to two places": prints C<$message> to standard output B<and> to the given
    log filehandle, prefixed with the calling file and line number so log entries
    are traceable. The filehandle must be open, or C<say2> dies.

     open my $log, '>', 'run.log';
     say2('starting equilibration', $log);   # -> STDOUT and run.log
     close $log;

    =head1 Dependencies

    Core/runtime modules used by SimpleFlow:

    =over

    =item * L<Capture::Tiny> captures C<stdout>/C<stderr>

    =item * L<Data::Printer> (C<DDP>) pretty result/record printing

    =item * L<Devel::Confess> better backtraces on death

    =item * L<Term::ANSIColor> coloured terminal output

    =item * C<List::Util>, C<Scalar::Util>, C<Time::HiRes>, C<Cwd> core utilities

    =back

    The test suite additionally uses C<Test::More> and
    L<Test::Exception>.

    =head1 Change log

    =head2 0.14 (2026-06-29) (Claude Opus 4.8 helped)

    =head3 C<task>

    =over

    =item * B<New:> accepts a flat key/value list as well as a hash ref â€”
    C<< task(cmd =E<gt> ...) >> and C<< task({ cmd =E<gt> ... }) >> are now equivalent. A lone
    non-hashref scalar or any odd-length argument list is fatal.

    =item * B<Bug fix:> the default C<< die =E<gt> 1 >> was ignored when checking for missing
    C<output.files>. The block tested the raw C<< $args-E<gt>{'die'} >> (undef when the
    caller omitted it) instead of the resolved C<$r{'die'}>, so a command that
    failed to produce its declared outputs only warned instead of dying. Now
    consistent with the exit-code check.

    =item * B<Bug fix:> removed a stray C<)> (and an extraneous leading space) from the
    "command is" line written to the log file; it now matches the on-screen form.

    =item * B<Bug fix:> C<length $_ == 0> could throw a fatal uninitialized-value warning
    (under C<< warnings FATAL =E<gt> 'all' >>) on an undef element of the C<input.files>
    array branch and the C<output.files> empty-name check. Both now guard with
    C<(defined $_) && (length $_ == 0)>, matching the C<input.files> scalar branch.

    =back

    =head2 0.13 (2026-06-11)

    =head3 Fixed (Claude Opus 4.8 helped)

    =over

    =item * B<Exit status and signal are now decoded correctly.> C<task()> previously
    computed the exit code (C<< $status E<gt>E<gt> 8 >>) and I<then> derived the signal as
    C<$exit & 127>. Because the signal lives in the low byte of the raw wait
    status, which C<< E<gt>E<gt> 8 >> discards the C<signal> field was always wrong: a clean
    C<exit 42> was reported as C<signal 42>, and a process actually killed by a
    signal reported C<signal 0>. The signal is now read from the raw status before
    shifting, so C<exit> and C<signal> are independent and accurate.

    =item * B<< No longer dies on a missing output file when C<< die =E<gt> 0 >>. >> The zero-size
    check did C<(-s $file) == 0>, which is C<undef == 0> when a declared output file
    is absent. Under C<< use warnings FATAL =E<gt> 'all' >> that "uninitialized value"
    warning was fatal, so a task that was meant to I<warn> about missing output
    (with C<< die =E<gt> 0 >>) crashed instead. Missing sizes are now treated as C<0>, so
    the task warns and returns its result hash as intended.

    =item * B<< The "already done" result is now logged with its C<duration>. >> In the
    short-circuit path (output files already exist), C<duration> was set I<after>
    the record was written to the log, so the logged hash was missing it; the
    duplicate C<< done =E<gt> 'before' >> assignment was also removed.

    =back

    =head3 Changed / Windows support

    =over

    =item * B<Portable exit-status handling.> Decoding now branches on C<$^O>: Windows has
    no POSIX signals (C<signal> is reported as C<0> there), and a C<system()> that
    fails to launch the command (C<-1>) yields C<< exit =E<gt> -1 >> instead of a garbage
    value from shifting C<-1>.

    =item * B<ANSI colour is disabled on the legacy Windows console.> C<Term::ANSIColor>
    output is suppressed on C<MSWin32> unless an ANSI-capable terminal is detected
    (Windows Terminal, ConEmu, or ANSICON), so C<cmd.exe> no longer prints raw
    escape sequences and redirected logs stay clean. Unix and modern Windows
    terminals are unaffected.

    =back

    =head3 Tests

    =over

    =item * Rewrote C<t/01.t> to be cross-platform: shell commands now invoke the running
    Perl interpreter (C<"$^X" -e ...>) instead of Unix-only tools (C<which>, C<ls>,
    C<ln>, C<cp>), and temp files use the system temp directory instead of a
    hard-coded C</tmp>.

    =item * Added regression tests for both fixed bugs (exit/signal decoding; surviving a
    missing output file with C<< die =E<gt> 0 >>).

    =item * Added coverage for the C<note> field, the C<input.file.size> / C<output.file.size>
    hashes, scalar-vs-array normalisation of C<input.files> / C<output.files>, the
    C<dir> / C<source.file> / C<source.line> metadata, captured C<stdout> / C<stderr>
    (including trailing-whitespace stripping), and argument validation (missing
    C<cmd>, unknown keys, bad C<log.fh>, missing input files).

    =back

    =head2 0.12

    exit code now matches what shell would show it as; signal now appears

    =head2 0.11

    max string length now corresponds to max of output strings, no more truncated output
    added List::Util dependency for string length maxes
    memory size now shows when output
    directory is now output during dry runs