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/logs inside 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

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) and failed (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 becomes generated_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) is just 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_at and mtime are seconds since 1970.

  • age is generated_at minus mtime, in seconds, so you do not need your own clock. It is 0 (not negative) if the log's time is in the future.

  • size is in bytes.

  • logs lists the newest run first, and each run's logs by name.

  • totals counts the runs, logs and bytes in logs. With older_than, only the selected logs are counted.

  • prune is null for a listing. After a prune it is an object with removed, freed and failed; each failed run has run and error. 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_than is in seconds. Two days is 2 * 24 * 60 * 60, not 2.

  • prune returns a list. my $text = $logs->prune gives the last value (the hash), not the text. Write my ($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.