Logs & retention
Every time a task runs, RunWisp writes its stdout and stderr to a file on disk. You can watch the output stream in live, scroll back through old runs, or download the whole thing as one file. The database keeps the metadata — exit code, how long it took, when it started — and the actual log bytes sit on disk, one file per run.
Where logs live
Section titled “Where logs live”{data_dir}/logs/{task-name}/{YYYYMMDD}_{HHMMSS}_{run-id-suffix}.logdata_dir is the directory you pass via --data (default .runwisp).
Each run gets its own file, named with the start time plus a snippet
of the run’s ULID so files sort chronologically and never collide.
Task names get sanitised — anything that isn’t [a-zA-Z0-9_-] turns
into _. The timestamp is always UTC, so the filenames stay
predictable even if you move the host between timezones or hit a DST
shift.
Stdout and stderr are interleaved in capture order — what you see in
the file is exactly what the script wrote, in exactly the order it
wrote it. The one twist is progress bars and other in-place redraws,
which RunWisp interprets so they land as clean lines rather than raw
\r/escape bytes — see Progress bars & live redraws.
When retention deletes a run, the daemon takes the log file with it, plus the little helper files next to it, plus the parent directory if that was the last thing in it. A run row never points at a missing log, and a log file never lingers without a row to match it.
Rotation: log_max_size and log_on_full
Section titled “Rotation: log_max_size and log_on_full”There’s a ceiling on how big a single run’s log can grow, so one runaway script can’t fill the disk in a single tick.
[tasks.bulky-job]cron = "0 4 * * *"run = "/usr/local/bin/bulky.sh"log_max_size = "50MB"log_on_full = "drop_old"log_max_size is the per-run ceiling. Default is 100MB. You can
write it as b, kb, mb, gb, or tb (case doesn’t matter); a
bare number means bytes.
log_on_full decides what happens when output hits that ceiling:
| Value | What it does |
|---|---|
drop_old | The default. Renames the current log to .prev and starts a fresh file. You keep the tail — which is usually where the failure shows up. |
drop_new | Stops writing, but lets the process keep running. Pick this when the start of the log is what you actually care about (a startup banner, the preamble of a long batch) and the rest is just repetition. |
kill_task | Cancels the run and kills the process. The run ends with reason log_overflow — still treated as a failure for retries and notifications, so you can see what happened without opening the log. |
Whichever way it goes, the daemon drops a marker line into the log at the cut-off point. You can skim the file and immediately see where the limit hit. Nothing gets dropped silently.
log_on_full does double duty: it also decides what happens when
[storage] min_free_space trips mid-run. kill_task cancels the
run. drop_new and drop_old quietly stop writing — and the daemon
fires a log.disk_pressure notification so you find out before you
go looking for output that isn’t there. More on that in
storage configuration.
Retention: keep_runs and keep_for
Section titled “Retention: keep_runs and keep_for”Retention decides how long old runs hang around before getting swept up — both the row in the database and the log file on disk. You can set a count, an age, or both. If you set both, whichever rule cuts first wins.
[tasks.metrics]cron = "* * * * *"run = "/usr/local/bin/metrics.sh"keep_runs = 500keep_for = "7d"keep_runs is the count: the N newest runs survive, anything older
gets pruned. It has to be a positive integer, and there’s a hard
ceiling of one million per task. Zero means “inherit from
[defaults]” — set a negative value and the daemon refuses to start.
keep_for is the age: anything whose created_at is older than that
duration gets pruned. You can use days and weeks here too —
"7d", "2w", "36h", "30m" all work. Zero and negative
durations are rejected.
Each task is on its own — retention runs independently for every
task, using that task’s settings (or whatever it inherits from
[defaults]). Leave either field out and it falls back to
[defaults]; set both for a hard floor and a hard ceiling.
Cleanup runs in the background on a timer, so you might briefly see a
few extra rows over the keep_runs mark between sweeps. The
daemon-wide storage cap protects in-progress runs, but the per-task
keep_runs and keep_for limits apply to all runs regardless of
status — a long-running task that hasn’t finished can still age off
when the limit says it’s time. When retention does kick in, the
SQLite row and the log file (plus its sidecar files) all go together.
Live streaming
Section titled “Live streaming”Both the Web UI and the TUI tail running logs in real time — new output lands in the viewer within milliseconds of the process printing it. When you open the view, it first replays whatever’s already on disk, then switches over to live, so you never miss the opening lines.
Scrolling back through a finished run feels the same way. The viewer seeks straight to the byte you asked for instead of re-reading from the top, so even a multi-megabyte log opens instantly.
The on-disk file is always the canonical copy. The live stream is just a fast preview of those same bytes.
Progress bars & live redraws
Section titled “Progress bars & live redraws”Lots of tools paint progress in place — a download bar that rewrites
itself with \r, or something like docker pull redrawing a stack of
per-layer bars with ANSI cursor moves. RunWisp understands those
sequences instead of storing the raw control bytes, so you get the
finished picture rather than a smear of \r and escape codes.
Here’s how it shakes out:
- On disk, a
\rprogress bar collapses to its final frame — one line,build: 100%, not every percentage it passed through. A multi-line redraw settles to the last frame it drew. The log stays clean and greppable. - Live, the Web UI and TUI show the active region updating in
place — the bar animates as the process writes it, then freezes as
the committed line once it’s done. A partial line (an
echo -nwithout its newline yet) shows up right away instead of hiding until the newline lands.
A few honest limitations, none of which lose you any output:
- RunWisp captures stdout and stderr as plain pipes, not a PTY. Many tools deliberately turn off their fancy progress rendering when they detect they’re not attached to a real terminal — those just print plain lines, which is fine. We faithfully render the ones that emit ANSI regardless.
- Because the two streams are separate, a cursor move on one can’t reach across to reposition the other. Each stream redraws within its own region.
- If the daemon is hard-killed (
kill -9) mid-redraw, the still- animating frame that hadn’t been finalized yet is lost — bounded to that one live region. Everything already committed is on disk as always.
Rewinding the frames
Section titled “Rewinding the frames”Collapsing a bar to its final frame keeps the log clean, but sometimes
the frames are the story — what the bar looked like just before it
stalled, or which layer was still downloading when a docker pull
wedged. So RunWisp keeps a short, sampled history of the frames a
redraw passed through and lets you scrub back to them after it settles.
A settled redraw line is marked as clickable. In the Web UI, click
it and the prior frames unfold right underneath; click again to fold
them away. In the TUI, step between marked lines with [
and ] and press enter to open a viewer. Over REST,
GET /api/tasks/{name}/runs/{id}/log/line/{n}/history returns the
frames for line n (multi-line redraws come back as whole frames, not
per-row). Lines without history return an empty list.
This is deliberately a supplementary convenience, with honest bounds:
- Frames are sampled over time (roughly a few per second), not captured on every byte — an instant burst that never actually animated collapses to nothing, and very fast sub-sample animation is caught coarsely. The captured span is also capped per redraw, thinned evenly when it would overflow, so a long-running bar keeps its whole arc rather than just the tail.
- History lives in a best-effort sidecar container (see below), never in
the
.log. Losing it — rotation,kill -9, retention — never costs you any committed output; the durable log is untouched.
Downloading
Section titled “Downloading”When you want the whole log as one file, you can grab it.
Hit Download on the run detail panel and the whole log lands in your Downloads folder.
Press d on the run detail view. On a desktop your browser jumps straight to the download. Over SSH the URL ends up on your clipboard, or a modal pops up that you can copy from.
GET /api/tasks/{name}/runs/{id}/log/raw returns the same bytes — handy when you want to
pipe them into curl or another shell tool.
If a log rotated partway through the run, the download still gives you everything: the rotated-out chunk and the current chunk come down stitched together, not just the tail.
Full-text search
Section titled “Full-text search”Need to find a line across every run of a task? RunWisp just greps the files. No background index, no extra disk usage, nothing to wait on after a restart. The trade-off is honest: the bigger the haystack you ask it to read, the longer the query takes, so results come back in pages with a cursor instead of all at once.
Open a task and unfold Search logs above the run detail. The buttons next to the input toggle case sensitivity and regex. Click a result and the log viewer scrolls to that line, then pulses a quick highlight so you can spot it.
From the exec view (or with any task selected), press /. Same case/regex toggles as the Web UI. Hit Enter on a result and the log pane jumps to that line.
GET /api/tasks/{name}/log/search?q=.... Results come back
newest-run-first with a next_cursor to page through. The
flags you’ll probably want: regex=true (RE2 syntax),
case=true, run_id=<ulid> to scope to a single run,
limit=<1-1000> (default 200), and cursor=<token> to pick
up where the last page left off.
A single scan walks up to 50 of the most recent runs for that task. It bails out as soon as it has enough hits, hands back a cursor for the rest, and skips soft-deleted runs (same visibility rules as everywhere else in the API).
If your query is malformed — unclosed regex group, empty q, that
kind of thing — you’ll get a 400 straight away, before any
scanning starts.
The sidecar container (.log.meta)
Section titled “The sidecar container (.log.meta)”Each .log has one companion file: a hidden .<name>.log.meta
container. It’s tucked behind a leading dot on purpose — a plain ls
of a log directory shows only the .log files, never a pile of helper
files. The container bundles everything that isn’t log output itself:
the line and timestamp indexes that make seeking fast, the rotation
bookkeeping that keeps line numbers continuous, and the sampled
frame history for any in-place redraws.
RunWisp creates and maintains it automatically — you never need to touch it, and deleting it won’t free up any meaningful disk space. Don’t delete it while a run is still going. When retention removes a run, the container goes with the parent log automatically.
It’s all best-effort: the indexes are rebuildable and the frame
history is safe to lose, so a kill -9, a rotation, or a torn write
never costs you any committed output — the durable .log is untouched.
A short run that never drew a progress bar just gets a tiny container
holding its final metadata. (While a log is mid-rotation you may also
briefly see a hidden .<name>.log.prev — the rotated-away chunk —
which is cleaned up when the run ends.)
Crash safety
Section titled “Crash safety”When a run finishes — or when the daemon shuts down gracefully — the log gets flushed and closed properly.
If the daemon dies mid-write (power yanked, kill -9), the log file
is not truncated. Whatever made it to disk stays there, and the
viewer is fine with a half-written last line. When the daemon comes
back up, the run gets marked crashed
(see scheduling: on startup)
and its log is left exactly as it was — those final lines are usually
the most useful thing you’ve got for figuring out what went wrong.