NAME
App::Actions::Logs - list and remove the job logs that action-run keeps
SYNOPSIS
List the kept logs (this is action-run logs):
use App::Actions::Logs;
my $logs = App::Actions::Logs->new;
print $logs->report;
Remove every kept log (action-run logs --prune):
my ($text, $status, $pruned) = $logs->prune;
print $text;
exit $status;
Remove only logs older than two days:
my ($text, $status) = $logs->prune(older_than => 2 * 24 * 60 * 60);
Get the listing as data for a program (action-run logs --json):
use JSON::PP;
my $document = $logs->document;
print JSON::PP->new->canonical->encode($document), "\n";
Use another directory:
my $logs = App::Actions::Logs->new(directory => '/var/tmp/my-logs');
DESCRIPTION
When action-run runs several jobs at the same time, each job writes its output to its own log file. After the run, these files are copied to a log directory, one folder per run, so you can read a failure (or attach it to a bug report) after the containers are gone. This module lists those files and removes them when you ask.
The log directory is:
ACTION_RUN_LOG_DIR, if that environment variable is set and not empty;otherwise
action-run/logsinside your cache directory:$XDG_CACHE_HOME, or ~/.cache on Unix, or%LOCALAPPDATA%on Windows.
Only names that action-run writes are touched. A run folder must be named like 20261021-121355-6ad8e4c30f83 (date, time, hexadecimal number), and a log like job-2-test.log. Symbolic links are skipped. So if the log directory is shared with other files, they are never listed or removed.
METHODS
default_directory
my $directory = App::Actions::Logs::default_directory();
Returns the log directory described above. The runner uses the same function, so the runs and this module always agree.
ARGUMENTS
None.
RETURNS
A path. It may not exist yet.
SIDE EFFECTS
Reads ACTION_RUN_LOG_DIR and the cache directory variables.
API SPECIFICATION
INPUT
{}
OUTPUT
{ type => 'string', min => 1 }
MESSAGES
None.
new
my $logs = App::Actions::Logs->new(directory => $path);
Makes an object for one log directory. Nothing is read yet.
ARGUMENTS
directory- optional. Default "default_directory".
RETURNS
The new object.
SIDE EFFECTS
None.
API SPECIFICATION
INPUT
{ directory => { type => 'string', optional => 1 } }
OUTPUT
{ type => 'object', isa => 'App::Actions::Logs' }
MESSAGES
None.
directory
my $path = $logs->directory;
Returns the log directory this object reads.
API SPECIFICATION
INPUT
{}
OUTPUT
{ type => 'string', min => 1 }
MESSAGES
None.
entries
my $runs = $logs->entries;
Reads the log directory.
ARGUMENTS
None.
RETURNS
An array reference of runs, newest first. Each run is a hash reference: name (the folder name), directory (its full path) and logs. logs is an array reference of hash references, sorted by name, each with name, path, size (bytes) and mtime (when it was last written, in seconds since 1970). A run with no logs is not listed. If the log directory does not exist, the list is empty.
SIDE EFFECTS
Reads the file system.
EXAMPLE
for my $run (@{ $logs->entries }) {
printf "%s: %d logs\n", $run->{name}, scalar @{ $run->{logs} };
}
API SPECIFICATION
INPUT
{}
OUTPUT
{ type => 'arrayref' }
MESSAGES
+--------------------------------------------+-------------------------+----------------------+
| Exception (croak) | Meaning | What to do |
+--------------------------------------------+-------------------------+----------------------+
| cannot read the log directory 'DIR': ERR | the folder exists but | fix its permissions |
| | cannot be read | |
+--------------------------------------------+-------------------------+----------------------+
report
print $logs->report(%options);
Makes the text that action-run logs prints: one row per log (run, file, size and age), then the number of logs and the space they take.
ARGUMENTS
older_than- optional. Seconds. List only logs at least this old.now- optional. The current time, in seconds since 1970. For tests.
RETURNS
The text, ending with a newline.
SIDE EFFECTS
Reads the file system.
EXAMPLE
print $logs->report(older_than => 7 * 24 * 60 * 60); # logs older than a week
API SPECIFICATION
INPUT
{
older_than => { type => 'integer', min => 0, optional => 1 },
now => { type => 'integer', optional => 1 },
}
OUTPUT
{ type => 'string', min => 1 }
MESSAGES
As for "entries". The text says No job logs have been kept yet. or No job logs are older than ... when there is nothing to list.
prune
my ($text, $status, $pruned) = $logs->prune(%options);
Removes the selected logs. A run folder is removed too when it becomes empty.
ARGUMENTS
older_than- optional. Seconds. Remove only logs at least this old. Without it, every log is removed.now- optional. The current time, for tests.
RETURNS
A list of three values:
- 1. the text to print;
- 2. the exit status: 0 if every selected log was removed, 1 if one could not be (the others are still removed);
- 3. a hash reference for the JSON document:
removed(how many logs),freed(bytes) andfailed(an array of{ run, error }).
SIDE EFFECTS
Deletes files and empty folders. A folder that still holds other files stays.
EXAMPLE
my ($text, $status, $pruned) = $logs->prune(older_than => 86_400);
my $document = $logs->document(prune => $pruned);
API SPECIFICATION
INPUT
{
older_than => { type => 'integer', min => 0, optional => 1 },
now => { type => 'integer', optional => 1 },
}
OUTPUT
{ type => 'array', min => 3, max => 3 } # (string, 0 or 1, hashref)
MESSAGES
+----------------------------------------------+------------------------------+---------------------+
| Text | Meaning | What to do |
+----------------------------------------------+------------------------------+---------------------+
| Removed N logs, SIZE freed. | success | |
| Could not remove N runs: | a file could not be deleted; | fix permissions and |
| RUN: cannot remove 'PATH': ERR | status 1 | prune again |
+----------------------------------------------+------------------------------+---------------------+
document
my $document = $logs->document(%options);
Returns the listing as data: the document action-run logs --json prints.
ARGUMENTS
older_than- optional. As for "report".prune- optional. The third value "prune" returned, to describe a prune that just happened.now- optional. The current time. It becomesgenerated_at.
RETURNS
A hash reference, described in "JSON OUTPUT".
SIDE EFFECTS
Reads the file system. Checks the document against "json_schema".
API SPECIFICATION
INPUT
{
older_than => { type => 'integer', min => 0, optional => 1 },
prune => { type => 'hashref', optional => 1 },
now => { type => 'integer', optional => 1 },
}
OUTPUT
{ type => 'hashref' }
MESSAGES
+-------------------------------------------+------------------------------+------------------+
| Exception | Meaning | What to do |
+-------------------------------------------+------------------------------+------------------+
| App::Actions::Schema::Invalid: the | a bug: the document does not | report it; exit |
| action-run logs --json document does not | match its schema | status 70 |
| match App::Actions::Logs->json_schema: ...| | |
+-------------------------------------------+------------------------------+------------------+
And the messages of "entries".
json_schema
my $schema = App::Actions::Logs->json_schema;
Returns the JSON schema of the logs --json document, as a new hash reference. action-run schema logs prints the same schema.
API SPECIFICATION
INPUT
{}
OUTPUT
{ type => 'hashref' }
MESSAGES
None.
json_schema_version
my $version = App::Actions::Logs->json_schema_version;
Returns the version of the document's shape, which the document also carries as schema_version. It goes up whenever the shape changes.
API SPECIFICATION
INPUT
{}
OUTPUT
{ type => 'integer', min => 1 }
MESSAGES
None.
format_age
my $words = App::Actions::Logs::format_age($seconds);
Says how old something is, in words: just now, 3 minutes ago, 2 hours ago, 5 days ago, 2 months ago or 1 year ago. It uses the largest whole unit, so 90 minutes is 1 hour ago.
ARGUMENTS
$seconds- the age. A negative age (a clock ahead) isjust now.
RETURNS
The words.
SIDE EFFECTS
None.
API SPECIFICATION
INPUT
{ seconds => { type => 'integer' } }
OUTPUT
{ type => 'string', min => 1 }
MESSAGES
None.
JSON OUTPUT
"document" returns one object. Every key is always there:
{
"schema_version": 3,
"generated_at": 1792540920,
"logs_directory": "/home/me/.cache/action-run/logs",
"logs": [
{ "run": "20261021-121355-6ad8e4c30f83", "name": "job-1-test_n_one.log",
"path": "/home/me/.cache/action-run/logs/20261021-121355-6ad8e4c30f83/job-1-test_n_one.log",
"size": 1229, "mtime": 1792540800, "age": 120 }
],
"totals": { "runs": 1, "logs": 1, "size": 1229 },
"prune": null
}
generated_atandmtimeare seconds since 1970.ageisgenerated_atminusmtime, in seconds, so you do not need your own clock. It is 0 (not negative) if the log's time is in the future.sizeis in bytes.logslists the newest run first, and each run's logs by name.totalscounts the runs, logs and bytes inlogs. Witholder_than, only the selected logs are counted.pruneisnullfor a listing. After a prune it is an object withremoved,freedandfailed; each failed run hasrunanderror. The document is printed even when a removal failed, and the exit status is then 1.
action-run schema logs prints the schema. The examples in t/fixtures/logs.json, logs-empty.json, logs-prune.json and logs-prune-failed.json are checked against it by t/schema.t.
COMMON PITFALLS
Only parallel runs keep logs. A run of one job at a time prints to the terminal and keeps nothing here.
older_thanis in seconds. Two days is2 * 24 * 60 * 60, not2.prunereturns a list.my $text = $logs->prunegives the last value (the hash), not the text. Writemy ($text, $status, $data) = ....Renamed files are invisible. If you rename a log or a run folder, it no longer matches the names this module looks for, so it is neither listed nor removed.
Pruning does not ask. Without
older_than, it removes every log. Call "report" first to see what will go.A failed removal does not stop the prune. Check the status: 1 means some logs are still there.
ENCODING
Run and log names are ASCII by construction (job names are made safe before they are used in file names). The log directory path may contain any characters your system allows, including non-ASCII and emoji; it is used and reported as bytes. The log files themselves are read only for their size and time, never for their content.
AUTHOR
Nigel Horne, njh@nigelhorne.com
FORMAL SPECIFICATION
This section describes each routine in the Z notation, a precise mathematical language for describing programs. You do not need to read it to use the module: everything here is also said in plain words in the sections above.
How to read it: a box names a routine. The lines above the middle rule declare the values it works with. A name ending in ? is an input, and a name ending in ! is an output. ΞFileSystem means the routine changes no file. The lines below the rule are always true when the routine returns.
[PATH, NAME]
RUNNAME == { n : NAME | n matches \d{8}-\d{6}-[0-9a-fA-F]+ }
LOGNAME == { n : NAME | n matches job-\d+(-[A-Za-z0-9_.-]+)?\.log }
Log ≙ [ name : LOGNAME; path : PATH; size, mtime : ℕ ]
Run ≙ [ name : RUNNAME; directory : PATH; logs : seq₁ Log ]
┌─ Logs ────────────────────────────────────────────
│ directory : PATH
└───────────────────────────────────────────────────
selected(older, now) = { r : entries • r with logs restricted to
{ l | older = ⊥ ∨ now − l.mtime ≥ older } } \ { runs with no logs }
default_directory
┌─ DefaultDirectory ────────────────────────────────
│ r! : PATH
├───────────────────────────────────────────────────
│ ENV.ACTION_RUN_LOG_DIR ¬in; {⊥, ⟨⟩} ⇒ r! = ENV.ACTION_RUN_LOG_DIR
│ otherwise r! = cacheBase ⁀ "/action-run/logs"
└───────────────────────────────────────────────────
new
┌─ New ─────────────────────────────────────────────
│ Logs'
│ directory? : optional PATH
├───────────────────────────────────────────────────
│ directory' = if directory? ∈ {⊥, ⟨⟩} then default_directory else directory?
└───────────────────────────────────────────────────
directory
┌─ Directory ───────────────────────────────────────
│ ΞLogs
│ r! : PATH
├───────────────────────────────────────────────────
│ r! = directory
└───────────────────────────────────────────────────
entries
┌─ Entries ─────────────────────────────────────────
│ ΞLogs ∧ ΞFileSystem
│ r! : seq Run
├───────────────────────────────────────────────────
│ directory ¬in; DIRS ⇒ r! = ⟨⟩
│ ∀ r ∈ ran r! • r.name ∈ RUNNAME ∧ r.directory is a real directory
│ ∧ ∀ l ∈ ran r.logs • l.name ∈ LOGNAME ∧ l.path is a real file
│ r! is sorted by name, descending; each r.logs by name, ascending
│ unreadable directory ⇒ raises CannotRead(directory)
└───────────────────────────────────────────────────
report
┌─ Report ──────────────────────────────────────────
│ ΞLogs ∧ ΞFileSystem
│ older? : optional ℕ
│ now? : optional ℕ
│ r! : TEXT
├───────────────────────────────────────────────────
│ one row in r! for each log of selected(older?, now?)
│ selected = ⟨⟩ ⇒ r! says that nothing is kept or nothing is old enough
└───────────────────────────────────────────────────
prune
┌─ Prune ───────────────────────────────────────────
│ ΔFileSystem
│ older? : optional ℕ
│ r! : TEXT × {0, 1} × DATA
├───────────────────────────────────────────────────
│ S = selected(older?, now)
│ ∀ r ∈ S • (∀ l ∈ ran r.logs • l.path ¬in; files') ∨ r ∈ failed
│ ∀ r ∈ S • r.directory empty after removal ⇒ r.directory ¬in; dirs'
│ files not in S are unchanged
│ second(r!) = 0 ⇔ failed = ∅
│ third(r!).removed = Σ { #r.logs | r ∈ S \ failed }
└───────────────────────────────────────────────────
document
┌─ Document ────────────────────────────────────────
│ ΞLogs ∧ ΞFileSystem
│ older?, now? : optional ℕ
│ prune? : optional DATA
│ d! : DATA
├───────────────────────────────────────────────────
│ d!.generated_at = now?
│ d!.logs = ⟨ l with run, age = max(0, now? − l.mtime) |
│ l ∈ logs of selected(older?, now?) ⟩
│ d!.totals = (#selected, #d!.logs, Σ size)
│ d!.prune = prune?
│ matches(d!, json_schema) ∨ raises Invalid
└───────────────────────────────────────────────────
json_schema
┌─ JsonSchema ──────────────────────────────────────
│ r! : SCHEMA
├───────────────────────────────────────────────────
│ r! = decode(JSON_SCHEMA) ∧ r! is a new object
└───────────────────────────────────────────────────
json_schema_version
┌─ JsonSchemaVersion ───────────────────────────────
│ r! : ℕ₁
├───────────────────────────────────────────────────
│ r! = JSON_SCHEMA_VERSION
└───────────────────────────────────────────────────
format_age
┌─ FormatAge ───────────────────────────────────────
│ seconds? : ℤ
│ r! : TEXT
├───────────────────────────────────────────────────
│ r! = App::Actions::Cache::format_age(seconds?)
└───────────────────────────────────────────────────
STATE DIAGRAM
The object holds only a directory name. The states below are the states of one kept log file, from the run that writes it to the prune that removes it:
parallel run of action-run
|
| job writes its output; the run copies it to
| LOGDIR/<date>-<time>-<hex>/job-N-<name>.log
v
+------------------+ entries / report / document +------------------+
| KEPT |-------------------------------->| LISTED |
| (on disk) |<--------------------------------| (nothing |
+------------------+ (no change) | changes) |
| | +------------------+
| | prune(older_than => N) and the log is newer than N
| +----------------------------------------> stays KEPT
|
| prune(), or prune(older_than => N) and the log is at least N old
v
+------------------+ unlink fails +-------------------------------+
| REMOVING |------------------->| FAILED: stays on disk; its run |
+------------------+ | is in "failed"; status 1 |
| +-------------------------------+
| unlink succeeds
v
+------------------+ run folder now empty +------------------+
| REMOVED |-------------------------->| run folder |
| (counted in | | removed too |
| removed/freed)| folder holds other files+------------------+
+------------------+--------------------------> folder stays
LICENSE AND COPYRIGHT
Copyright 2026 Nigel Horne.
This program is released under the GNU General Public License, version 2. See the LICENSE file that comes with this distribution. If you use it, please let me know.