File Coverage

blib/lib/TaskForest.pm
Criterion Covered Total %
statement 83 280 29.6
branch 12 86 13.9
condition 1 33 3.0
subroutine 15 18 83.3
pod 0 6 0.0
total 111 423 26.2


line stmt bran cond sub pod time code
1             ################################################################################
2             #
3             # $Id: TaskForest.pm 290 2010-03-23 00:00:10Z aijaz $
4             #
5             # This is the primary class of this application. Version infromation
6             # is taken from this file.
7             #
8             ################################################################################
9              
10             package TaskForest;
11 87     87   867902 use strict;
  87         214  
  87         10814  
12 87     87   910 use warnings;
  87         177  
  87         3548  
13 87     87   88343 use POSIX (":sys_wait_h", "strftime");
  87         777150  
  87         781  
14 87     87   114195 use Data::Dumper;
  87         8970  
  87         9642  
15 87     87   113379 use TaskForest::Family;
  87         405  
  87         4266  
16 87     87   1265 use TaskForest::Options;
  87         202  
  87         2429  
17 87     87   60742 use TaskForest::Logs qw /$log/;
  87         291  
  87         14774  
18 87     87   631 use File::Basename;
  87         148  
  87         6290  
19 87     87   1632 use Carp;
  87         195  
  87         5621  
20 87     87   539 use TaskForest::LocalTime;
  87         155  
  87         3352  
21              
22             BEGIN {
23 87     87   440 use vars qw($VERSION);
  87         156  
  87         3689  
24 87     87   354323 $VERSION = '1.37';
25             }
26              
27              
28             ################################################################################
29             #
30             # Name : The constructor
31             # Usage : my $tf = TaskForest->new();
32             # Purpose : Gets required and optional parameters from command line, or the
33             # environment, if required parameters are missing from the command
34             # line.
35             # Returns : Self
36             # Argument : If you pass a hash of parameters and values, they are inserted
37             # into the environment (as if they were always in %ENV)
38             # Throws :
39             #
40             ################################################################################
41             #
42             sub new {
43 72     72 0 6045 my ($class, %parameters) = @_;
44              
45 72   33     874 my $self = bless ({}, ref ($class) || $class);
46              
47 72 50       371 if (%parameters) {
48 0         0 foreach my $p (keys %parameters) {
49 0 0       0 next unless $p =~ /^TF_([A-Z_]+)$/;
50             # untaint
51 0         0 $parameters{$p} =~ s/[^a-z0-9_\/:\.]//ig;
52 0         0 $ENV{$p} = $parameters{$p};
53             }
54             }
55              
56             # Get Options
57 72         581 $self->{options} = &TaskForest::Options::getOptions();
58              
59 72         404 return $self;
60             }
61              
62              
63              
64              
65             ################################################################################
66             #
67             # Name : runMainLoop
68             # Usage : $tf->runMainLoop();
69             # Purpose : This function loops until end_time (23:55) by default. In each
70             # loop it examines all the Family files and sees if there are any
71             # jobs that need to be run. Because of this, any changes
72             # made to any of the family files will take effect on the
73             # iteration of the loop. By default the system sleeps 60
74             # seconds at the end of each loop.
75             # Returns : Nothing
76             # Argument :
77             # Throws :
78             #
79             ################################################################################
80             #
81             sub runMainLoop {
82 88     88 0 110099 my $self = shift;
83             # We don't want to have to process zombie child processes
84             #
85 88         2495 $SIG{CHLD} = 'IGNORE';
86              
87              
88 88         2448 my $end_time = $self->{options}->{end_time};
89 88         651 $end_time =~ /(\d\d)(\d\d)/;
90 88         792 my $end_time_in_seconds = $1 * 3600 + $2 * 60;
91 88         323 my $wait_time = $self->{options}->{wait_time};
92              
93 88         197 my $rerun = 0;
94 88         197 my $RELOAD = 1;
95              
96 88         643 $self->{options} = &TaskForest::Options::getOptions($rerun); $rerun = 1;
  88         790  
97 88         899 &TaskForest::Logs::init("New Loop");
98            
99 88         213 while (1) {
100            
101             # get a fresh list of all family files
102             #
103 88         733 my @families = $self->globFamilyFiles($self->{options}->{family_dir});
104            
105            
106 88         262 foreach my $family_name (@families) {
107             # create a new family object. It is possible that this
108             # family will never need to be run today. That is yet to
109             # be determined.
110             #
111 102         3592 my ($name) = $family_name =~ /$self->{options}->{family_dir}\/(.*)/;
112 102         2040 my $family = TaskForest::Family->new(name => $name);
113              
114 102 50       515 if (!defined $family) {
115             # there was a syntax error
116            
117             }
118              
119             # If there aren't any jobs in the family, we really don't
120             # need to try.
121             #
122 102 50       436 next unless $family->{jobs}; # no jobs to run today
123              
124 102 50       628 print Dumper($family) if $self->{options}->{verbose};
125              
126             # The cycle method gets the current status and runs any
127             # jobs that are ready to be run.
128             #
129 102         805 $log->info("Calling cycle from runMainLoop");
130 102         83695 $family->cycle();
131             }
132              
133             # The once_only option is good when testing.
134             #
135 37 50       1106 if ($self->{options}->{once_only}) {
136 37         1667 last;
137             }
138            
139 0         0 my ($sec,$min,$hour,$mday,$mon,$year,$wday,$yday,$isdst) = &TaskForest::LocalTime::lt();
140 0         0 my $now = sprintf("%02d%02d", $hour, $min);
141 0 0       0 print "It is $now, the time to end is $end_time\n" if $self->{options}->{verbose};
142 0         0 my $now_plus_wait = $hour * 3600 + $min * 60 + $wait_time;
143 0 0       0 if ( $now_plus_wait >= $end_time_in_seconds) {
144 0         0 $log->info("In $wait_time seconds it will be past $end_time. Exiting loop.");
145 0         0 last;
146             }
147 0         0 $log->info("After $wait_time seconds, $now_plus_wait < $end_time_in_seconds. Sleeping $wait_time");
148 0         0 sleep $wait_time; # by default: 60s
149              
150 0         0 &TaskForest::Logs::resetLogs();
151 0         0 $self->{options} = &TaskForest::Options::getOptions($rerun);
152             # &TaskForest::LogDir::getLogDir($self->{options}->{log_dir}, $RELOAD);
153 0         0 &TaskForest::Logs::init("New Loop");
154            
155             }
156            
157             }
158              
159              
160             ################################################################################
161             #
162             # Name : globFamilyFiles
163             # usage : $tf->globFamilyFiles();
164             # Purpose : Find all family files given the rules of what's a valid file name
165             # and what file names are to be ignored
166             # Returns : An array of file names
167             # Argument : The family directory to be searched
168             # Throws :
169             #
170             ################################################################################
171             #
172             sub globFamilyFiles {
173 88     88 0 245 my ($self, $dir) = @_;
174              
175 88         315 my $glob_string = "$dir/*";
176 88         25563 my @all_files = glob($glob_string);
177 88         313 my @families = ();
178              
179 88         269 my @ignore_regexes = ();
180 88 100       662 if (ref($self->{options}->{ignore_regex}) eq 'ARRAY') {
    50          
181 85         168 @ignore_regexes = @{$self->{options}->{ignore_regex}};
  85         419  
182             }
183             elsif ($self->{options}->{ignore_regex}) {
184 3         12 @ignore_regexes = ($self->{options}->{ignore_regex});
185             }
186            
187              
188 88         258 my @regexes = map { qr/$_/ } @ignore_regexes;
  189         3385  
189            
190 88         910 foreach my $file (@all_files) {
191 130         8090 my $basename = basename($file);
192 130 100       828 if ($basename =~ /[^a-zA-Z0-9_]/) {
193 24         45 next;
194             }
195 106         396 my $ok = 1;
196 106         244 foreach my $regex (@regexes) {
197 222 50       1249 if ($basename =~ /$regex/) {
198 0         0 $ok = 0;
199 0         0 last;
200             }
201             }
202 106 50       480 if ($ok) {
203 106         382 push (@families, $file);
204             }
205             }
206              
207 88         724 return @families;
208             }
209              
210             ################################################################################
211             #
212             # Name : status
213             # usage : $tf->status();
214             # Purpose : This function determines the status of all jobs that have run
215             # today, as well as the the status of jobs that have not
216             # yet run (are in the "Waiting" or "Ready" state.
217             # If the --collapse option is given, pending repeat
218             # jobs are not displayed.
219             # Returns : A data structure representing all the jobs
220             # Argument : data-only - If this is true, then nothing is printed.
221             # Throws :
222             #
223             ################################################################################
224             #
225             sub status {
226 0     0 0   my ($self, $data_only) = @_;
227              
228              
229             #my $log_dir = &TaskForest::LogDir::getLogDir($self->{options}->{log_dir}, 'reload');
230            
231 0           my ($sec,$min,$hour,$mday,$mon,$year,$wday,$yday,$isdst) = &TaskForest::LocalTime::lt();
232 0           my $log_date = sprintf("%4d%02d%02d", $year, $mon, $mday);
233            
234             # get a fresh list of all family files
235             #
236 0           my @families = $self->globFamilyFiles($self->{options}->{family_dir});
237              
238 0           my $display_hash = { all_jobs => [], Success => [], Failure => [], Ready => [], Waiting => [], Running => []};
239              
240 0           my $tz_for_family = {};
241              
242 0           foreach my $family_name (sort @families) {
243             # create a new Family object
244             #
245 0           my ($name) = $family_name =~ /$self->{options}->{family_dir}\/(.*)/;
246 0           my $family = TaskForest::Family->new(name => $name);
247              
248 0           $tz_for_family->{$name} = $family->{tz};
249            
250 0 0         next unless $family->{jobs}; # no jobs to run today
251              
252             # get the status of any jobs that may have already run (or
253             # failed) today.
254             #
255 0           $family->getCurrent();
256              
257             # display the family
258             #
259 0           $family->display($display_hash);
260             }
261              
262 0           foreach my $job (@{$display_hash->{Ready}}, @{$display_hash->{Waiting}}, @{$display_hash->{TokenWait}}, @{$display_hash->{Hold}}) {
  0            
  0            
  0            
  0            
263 0           $job->{actual_start} = $job->{stop} = "--:--";
264 0           $job->{rc} = '-';
265 0           $job->{has_actual_start} = $job->{has_stop} = $job->{has_rc} = 0;
266 0           $job->{log_date} = sprintf("%4d%02d%02d", $year, $mon, $mday);
267             }
268              
269 0           foreach my $job (@{$display_hash->{Success}}, @{$display_hash->{Failure}}, @{$display_hash->{Running}}) {
  0            
  0            
  0            
270 0           my $dt = DateTime->from_epoch( epoch => $job->{actual_start} );
271 0           $dt->set_time_zone($job->{tz});
272 0           $job->{actual_start_epoch} = $job->{actual_start};
273 0           $job->{actual_start} = sprintf("%02d:%02d", $dt->hour, $dt->minute);
274 0           $job->{has_actual_start} = 1;
275 0           $job->{has_rc} = 1;
276              
277 0 0 0       if (($job->{stop}) && ($job->{status} ne "Running")) {
278 0           $dt = DateTime->from_epoch( epoch => $job->{stop} );
279 0           $dt->set_time_zone($job->{tz});
280 0           $job->{stop} = sprintf("%02d:%02d", $dt->hour, $dt->minute);
281 0           $job->{has_stop} = 1;
282 0 0         if ($job->{status} eq 'Success') {
283 0           $job->{is_success} = 1;
284             }
285             else {
286 0           $job->{is_success} = 0;
287             }
288             }
289             else {
290 0           $job->{stop} = '--:--';
291 0           $job->{rc} = '-';
292 0           $job->{has_stop} = $job->{has_rc} = 0;
293             }
294             }
295              
296 0           $self->getUnaccountedForJobs($display_hash);
297              
298 0           map { ($_->{base_name}) = $_->{name} =~ /([^\-]+)/; } @{$display_hash->{all_jobs}};
  0            
  0            
299              
300 0 0 0       my @sorted = sort {
    0 0        
      0        
301            
302 0           $a->{family_name} cmp $b->{family_name} # family first
303             ||
304             $a->{base_name} cmp $b->{base_name} # base name
305             ||
306             $b->{has_actual_start} <=> $a->{has_actual_start} # REady and Waiting after Success or Failed
307             ||
308              
309             # after this point they're either both run or both not run
310            
311             (($a->{has_actual_start}) ? ($a->{actual_start} cmp $b->{actual_start}) : # Actual start if possible (if both have started ELSE BOTH HAVE FAILED, THEN:
312             $a->{start} cmp $b->{start}) # Waiting after Ready
313             ||
314             $a->{name} cmp $b->{name} # Job Name
315            
316              
317            
318            
319 0           } @{$display_hash->{all_jobs}};
320              
321 0           my $oe = 'odd';
322 0           my $log_dir;
323 0           foreach my $job (@sorted) {
324 0 0         $job->{oe} = $oe = (($oe eq 'odd') ? 'even' : 'odd');
325 0           $job->{has_output_file} = 0;
326 0 0         if ($job->{has_actual_start}) {
327 0           $job->{output_file} = "$job->{family_name}.$job->{name}.$job->{pid}.$job->{actual_start_epoch}.stdout";
328 0 0         if ($job->{log_dir}) {
329 0           $log_dir = $job->{log_dir}; # from getUnaccountedForJobs
330             }
331             else {
332 0           $log_dir = &TaskForest::LogDir::getLogDir($self->{options}->{log_dir}, $tz_for_family->{$job->{family_name}});
333             }
334 0 0         if (-e "$log_dir/$job->{output_file}") {
335 0           $job->{has_output_file} = 1;
336             }
337 0           $job->{log_date} = substr($log_dir, -8);
338             }
339             else {
340 0 0         if ($job->{log_dir}) {
341 0           $log_dir = $job->{log_dir}; # from getUnaccountedForJobs
342             }
343             else {
344 0           $log_dir = &TaskForest::LogDir::getLogDir($self->{options}->{log_dir}, $tz_for_family->{$job->{family_name}});
345             }
346 0           $job->{log_date} = substr($log_dir, -8);
347             }
348 0 0         $job->{is_waiting} = ($job->{status} eq 'Waiting') ? 1 : 0;
349             }
350              
351            
352 0           $display_hash->{all_jobs} = \@sorted;
353              
354 0 0         return $display_hash if $data_only;
355              
356             ## ########################################
357            
358 0           my $max_len_name = 0;
359 0           my $max_len_tz = 0;
360 0           foreach my $job (@{$display_hash->{all_jobs}}) {
  0            
361 0           my $l = length($job->{full_name} = "$job->{family_name}::$job->{name}");
362 0 0         if ($l > $max_len_name) { $max_len_name = $l; }
  0            
363            
364 0           $l = length($job->{tz});
365 0 0         if ($l > $max_len_tz) { $max_len_tz = $l; }
  0            
366              
367             }
368              
369 0           my $format = "%-${max_len_name}s %-7s %6s %-${max_len_tz}s %-5s %-6s %-5s\n";
370 0           printf($format, '', '', 'Return', 'Time', 'Sched', 'Actual', 'Stop');
371 0           printf($format, 'Job', 'Status', 'Code', 'Zone', 'Start', 'Start', 'Time');
372 0           print "\n";
373            
374 0           my $collapse = $self->{options}->{collapse};
375            
376 0           foreach my $job (@{$display_hash->{all_jobs}}) {
  0            
377 0 0 0       if ($collapse and
      0        
378             $job->{name} =~ /--Repeat/ and
379             $job->{status} eq 'Waiting') {
380 0           next; # don't print every waiting repeat job
381             }
382 0           printf($format,
383             $job->{full_name},
384             $job->{status},
385             $job->{rc},
386             $job->{tz},
387             $job->{start},
388             $job->{actual_start},
389             $job->{stop});
390             }
391            
392             }
393              
394              
395              
396             ################################################################################
397             #
398             # Name : hist_status
399             # usage : $tf->status();
400             # Purpose : This function determines the status of all jobs that have run
401             # for a particular day. If the --collapse option is given,
402             # pending repeat jobs are not displayed.
403             # Returns : A data structure representing all the jobs
404             # Argument : data-only - If this is true, then nothing is printed.
405             # Throws :
406             #
407             ################################################################################
408             #
409             sub hist_status {
410 0     0 0   my ($self, $date, $data_only) = @_;
411 0           my $log_dir = $self->{options}->{log_dir}."/$date";
412              
413 0           my $display_hash = { all_jobs => [], Success => [], Failure => [], Ready => [], Waiting => [], };
414 0           $self->getUnaccountedForJobs($display_hash, $date);
415              
416 0           map { ($_->{base_name}) = $_->{name} =~ /([^\-]+)/; } @{$display_hash->{all_jobs}};
  0            
  0            
417              
418 0 0 0       my @sorted = sort {
      0        
419 0           $a->{family_name} cmp $b->{family_name} # family first
420             ||
421             $a->{base_name} cmp $b->{base_name} # base name
422             ||
423             $a->{actual_start} cmp $b->{actual_start} # start_time
424             ||
425             $a->{name} cmp $b->{name} # Job Name
426 0           } @{$display_hash->{all_jobs}};
427            
428 0           my $oe = 'odd';
429 0           foreach my $job (@sorted) {
430 0 0         $job->{oe} = $oe = (($oe eq 'odd') ? 'even' : 'odd');
431 0           $job->{has_output_file} = 0;
432 0           $job->{output_file} = "$job->{family_name}.$job->{name}.$job->{pid}.$job->{actual_start_epoch}.stdout";
433 0 0         if (-e "$log_dir/$job->{output_file}") {
434 0           $job->{has_output_file} = 1;
435 0           $job->{log_dir} = $log_dir;
436             }
437 0           $job->{log_date} = $date;
438 0 0         $job->{is_waiting} = ($job->{status} eq 'Waiting') ? 1 : 0;
439             }
440              
441 0           $display_hash->{all_jobs} = \@sorted;
442              
443 0 0         return $display_hash if $data_only;
444              
445 0           my $max_len_name = 0;
446 0           my $max_len_tz = 0;
447 0           foreach my $job (@{$display_hash->{all_jobs}}) {
  0            
448 0           my $l = length($job->{full_name} = "$job->{family_name}::$job->{name}");
449 0 0         if ($l > $max_len_name) { $max_len_name = $l; }
  0            
450            
451 0           $l = length($job->{tz});
452 0 0         if ($l > $max_len_tz) { $max_len_tz = $l; }
  0            
453              
454             }
455              
456 0           my $format = "%-${max_len_name}s %-7s %6s %-${max_len_tz}s %-5s %-6s %-5s\n";
457 0           printf($format, '', '', 'Return', 'Time', 'Sched', 'Actual', 'Stop');
458 0           printf($format, 'Job', 'Status', 'Code', 'Zone', 'Start', 'Start', 'Time');
459 0           print "\n";
460            
461 0           my $collapse = $self->{options}->{collapse};
462            
463 0           foreach my $job (@{$display_hash->{all_jobs}}) {
  0            
464 0 0 0       if ($collapse and
      0        
465             $job->{name} =~ /--Repeat/ and
466             $job->{status} eq 'Waiting') {
467 0           next; # don't print every waiting repeat job
468             }
469 0           printf($format,
470             $job->{full_name},
471             $job->{status},
472             $job->{rc},
473             $job->{tz},
474             $job->{start},
475             $job->{actual_start},
476             $job->{stop});
477            
478             }
479             }
480            
481              
482             ################################################################################
483             #
484             # Name : getUnaccountedForJobs
485             # usage : $tf->getUnaccountedForJobs($display_hash, "YYYYMMDD");
486             # Purpose : This function browses a log directory for a particular date
487             # and populates the input variable $display_hash with data
488             # about each job that ran that day.
489             # Returns : None
490             # Argument : $display_hash - the hash that will contain the data for
491             # all the jobs.
492             # $date - the date for which you want job data
493             # Throws : "Cannot open file"
494             #
495             ################################################################################
496             #
497             sub getUnaccountedForJobs {
498 0     0 0   my ($self, $display_hash, $date) = @_;
499 0           my $log_dir;
500              
501 0 0         unless ($date) {
502 0           my ($sec,$min,$hour,$mday,$mon,$year,$wday,$yday,$isdst) = &TaskForest::LocalTime::lt();
503 0           $date = sprintf("%4d%02d%02d", $year, $mon, $mday);
504             }
505 0           $log_dir = $self->{options}->{log_dir}."/$date";
506 0 0         return unless -d $log_dir;
507            
508 0           my $seen = {};
509 0           foreach my $job (@{$display_hash->{Success}}, @{$display_hash->{Failure}}) {
  0            
  0            
510 0           $seen->{"$job->{family_name}.$job->{name}"} = 1;
511             }
512            
513             # readdir
514 0           my $glob_string = "$log_dir/*.[01]";
515 0           my @files = glob($glob_string);
516              
517 0           my $new = [];
518 0           my $file_name;
519 0           my %valid_fields = ( actual_start => 1, pid => 1, stop => 1, rc => 1, );
520 0           foreach my $file (@files) {
521 0           my ($family_name, $job_name, $status) = $file =~ /$log_dir\/([^\.]+)\.([^\.]+)\.([01])/;
522 0           my $full_name = "$family_name.$job_name";
523 0 0         next if $seen->{$full_name}; # don't update $seen, because we want to show every job that ran.
524              
525 0 0         my $job = { family_name => $family_name,
526             name => $job_name,
527             full_name => $full_name,
528             start => '--:--',
529             status => ($status) ? 'Failure' : 'Success' }; # just a hash, not an object, since this is only used for display
530              
531             # read the pid file
532 0           substr($file, -1, 1) = 'pid';
533 0 0         open(F, $file) || croak "cannot open $file to read job data";
534 0           while () {
535 0           chomp;
536 0           my ($k, $v) = /([^:]+): (.*)/;
537 0           $v =~ s/[^a-z0-9_ ,.\-]/_/ig;
538 0 0         if ($valid_fields{$k}) {
539 0           $job->{$k} = $v;
540             }
541             }
542 0           close F;
543              
544 0           my $tz = $self->{options}->{default_time_zone};
545 0           $job->{actual_start_epoch} = $job->{actual_start};
546 0           my $dt = DateTime->from_epoch( epoch => $job->{actual_start} );
547 0           $dt->set_time_zone($tz);
548 0           $job->{actual_start} = sprintf("%02d:%02d", $dt->hour, $dt->minute);
549 0           $job->{actual_start_dt} = sprintf("%d/%02d/%02d %02d:%02d", $dt->year, $dt->month, $dt->day, $dt->hour, $dt->minute); #sprintf("%02d:%02d", $dt->hour, $dt->minute);
550 0           $dt = DateTime->from_epoch( epoch => $job->{stop} );
551 0           $dt->set_time_zone($tz);
552 0           $job->{stop} = sprintf("%02d:%02d", $dt->hour, $dt->minute);
553 0           $job->{stop_dt} = sprintf("%d/%02d/%02d %02d:%02d", $dt->year, $dt->month, $dt->day, $dt->hour, $dt->minute); #sprintf("%02d:%02d", $dt->hour, $dt->minute);
554 0           $job->{has_actual_start} = $job->{has_stop} = $job->{has_rc} = 1;
555 0           $job->{tz} = $tz;
556 0           $job->{log_dir} = $log_dir;
557              
558 0 0         $job->{is_success} = ($job->{status} eq 'Success') ? 1 : 0;
559            
560              
561 0           push (@{$display_hash->{all_jobs}}, $job);
  0            
562             }
563             }
564              
565              
566              
567             #################### main pod documentation begin ###################
568              
569             =head1 NAME
570              
571             TaskForest - A simple but expressive job scheduler that allows you to chain jobs/tasks and create time dependencies. Uses text config files to specify task dependencies.
572              
573             =head1 VERSION
574              
575             This is version 1.37.
576              
577             =head1 EXECUTIVE SUMMARY
578              
579             With the TaskForest Job Scheduler you can:
580              
581             =over 4
582              
583             =item *
584              
585             schedule jobs run at predetermined times
586              
587             =item *
588              
589             have jobs be dependent on each other
590              
591             =item *
592              
593             rerun failed jobs
594              
595             =item *
596              
597             mark jobs as succeeded or failed
598              
599             =item *
600              
601             put jobs on hold and release the holds
602              
603             =item *
604              
605             release all dependencies on a job
606              
607             =item *
608              
609             check the status of all jobs scheduled to run today
610              
611             =item *
612              
613             interact with the included web service using your own client code
614              
615             =item *
616              
617             interact with the included web server using your default browser
618              
619             =item *
620              
621             express the relationships between jobs using a simple text-based format (a big advantage if you like using 'grep')
622              
623             =back
624              
625             =head1 SYNOPSIS
626              
627             Over the years TaskForest has migrated from a collection of simple
628             perl modules to a full-fledged system. I have found that putting the
629             documentation in a single POD is getting much more difficult. You can
630             now find the latest documetation on the TaskForest website located at
631              
632             http://www.taskforest.com
633              
634             If you run the included web server, you will also find a complete copy
635             of the documentation on the included web site.
636              
637             =head1 BUGS
638              
639             For an up-to-date bug listing and to submit a bug report, please
640             send an email to the TaskForest Discussion Mailing List at
641             "taskforest-discuss at lists dot sourceforge dot net"
642              
643             =head1 SUPPORT
644              
645             For support, please visit our website at http://www.taskforest.com/ or
646             send an email to the TaskForest Discussion Mailing List at
647             "taskforest-discuss at lists dot sourceforge dot net"
648              
649             =head1 AUTHORS
650              
651             Aijaz A. Ansari
652             http://www.taskforest.com/
653              
654             The following developers have graciously contributed patches to enhance TaskForest:
655              
656             =over 4
657              
658             =item *
659              
660             Steve Hulet
661              
662             =back
663              
664             Please see the 'Changes' file for details. If you have contributed
665             code, and your name is not on the above list, please accept my
666             apologies and let me know, so that I may give you credit.
667              
668             If you're using this program, I would love to hear from you. Please
669             send an email to the TaskForest Discussion Mailing List at
670             "taskforest-discuss at lists dot sourceforge dot net" and let me know
671             what you think of it.
672              
673             =head1 ACKNOWLEDGEMENTS
674              
675             Many thanks to the following for their help and support:
676              
677             =over 4
678              
679             =item *
680              
681             SourceForge
682              
683             =item *
684              
685             Rosco Rouse
686              
687             =item *
688              
689             Svetlana Lemeshov
690              
691             =item *
692              
693             Teresia Arthur
694              
695             =item *
696              
697             Steve Hulet
698              
699             =back
700              
701             I would also like to thank Randal L. Schwartz for teaching the readers of
702             the Feb 1999 issue of Web Techniques how to write a pre-forking web
703             server, the code upon which the TaskForest Web server is built.
704              
705             I would also like to thank the fine developers at Yahoo! for providing
706             yui to the open source community.
707              
708             =head1 COPYRIGHT
709              
710             This program is free software; you can redistribute it and/or modify
711             it under the same terms as Perl itself - specifically, the Artistic
712             License.
713              
714             The full text of the license can be found in the
715             LICENSE file included with this module.
716              
717             =head1 SEE ALSO
718              
719             perl(1).
720              
721             =cut
722              
723             #################### main pod documentation end ###################
724              
725              
726             1;
727             # The preceding line will help the module return a true value
728