Skip to content

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.

{data_dir}/logs/{task-name}/{YYYYMMDD}_{HHMMSS}_{run-id-suffix}.log

data_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.

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:

ValueWhat it does
drop_oldThe 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_newStops 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_taskCancels 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 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 = 500
keep_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.

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.

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 \r progress 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 -n without 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.

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.

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.

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.

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.

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.

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.)

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.