Formats
The two files this crate writes and reads back, and the progress lines an op
streams on its stdout. This is their full spec. A change is additive: a new
field is optional, an old reader ignores it, and v stays 1.
| § | what | where |
|---|---|---|
| §1 | a job record | <jobs>/<pid>-<started>.json |
| §2 | an app heartbeat | <jobs>/apps/<pid>-<started>.json |
| §3 | an op’s progress lines | one JSON object per line on the op’s stdout |
<jobs> is the job folder:
PITO_JOBS_DIRwhen it is set;- else
$XDG_RUNTIME_DIR/pito-jobs; - else, on macOS only,
$TMPDIR/pito-jobs(the per-user temporary folder); - else there is none, and nothing is written.
PITO_JOBS=off turns every write off, so a tool’s tests write nothing.
1. Job record
Section titled “1. Job record”A job is one invocation of a tool: one render, one encode, one long run.
Where: only in RAM, or the closest thing the OS offers. A tool writes one
file per job in <jobs>/, named <pid>-<started>.json.
$XDG_RUNTIME_DIR is cleared at logout and reboot. Tools never write a
history file; a reader that wants history keeps its own.
The file is rewritten atomically (write a temporary file in the same folder,
then rename), at most once a second, and stays under 4 KiB, with label and
detail truncated if they need to be:
{"v":1,"id":"desk:1432:1790000000123","host":"desk","pid":1432,"pid_start":98231, "tool":"render","app":"trailer","label":"trailer-01", "detail":"player @49fe383 1080p→720p web", "invoker":"agent","parent":null,"state":"running","stage":"trace", "progress":{"done":120,"total":600,"unit":"frames"},"rate":23.4, "pgpu":{"pid":1440,"as":"render"},"device":"HIP: AMD Radeon RX 9060 XT", "out":"/srv/renders/trailer-01", "started":1790000000123,"updated":1790000060123,"ended":null,"exit":null,"error":null}id: host, pid, and start time in ms. Unique across hosts and pid reuse.tool: the program at work.app: an optional short key for the app or project being worked on, or null.parent: the id of the job that started this one, or null. The crate handsPITO_JOB_IDto children throughchild_envand reads it on start, so a nested job links itself, and a reader can show child jobs under their parent.state:running,done,failedorkilled. A tool that knows it’s waiting may saywaiting, but it doesn’t have to.stage: optional, a stable identifier so a reader can group and time stages across jobs:build,queue,record,trace,replay,derive,master,encode. Counts go inprogress, not in the name (encodewith2/5 outputs, notencode 2/5). An optionalstage_notecarries the specifics (dark,hevc 1080p@60).progressresets when the stage changes, and itsunitmay change with it:framesfor a clip,samplesfor a still,outputsfor an encode of several files,secondsfor a real-time stage like a terminal recording (no rate there, since it runs at 1.0 by construction).totalis an upper bound: a stage may end with done below total, and the stage change or the finish closes it.stages: optional, the names of the tool’s stages in order, each astageidentifier (["probe","encode","mux"]). Absent until the tool knows them, which may be after the record started and its first stage runs, and it may change, like a plan that grows amux. At most 16 names, each capped likestage; a record that would pass 4 KiB loses the field first. A reader draws a stage bar from it with no list of tool names of its own. A record withoutstagesdraws its current stage alone.label: what the job is, as a reader shows it, written by the tool as it should read (trailer-01,bake courtyard).rate: computed by the crate as progress over the last 5 s or the last two progress updates, whichever is longer, per second inprogress.unit. A fast unit like samples stays responsive, a slow one like 4K frames (seconds each) stays steady, and idle stretches don’t spike it. A tool may override it. It’s absent when meaningless, like a single still.pgpu: optional, for a tool whose GPU work runs under a GPU queue (PITO MD’s is pgpu; the field keeps that name).pgpu.pidis the pid of the queue’s process running the tool’s current GPU step, andasthe tool’s name there;pgpuis null while no such process exists, such as during a build stage. A tool sets and clears it at any time. A running job with nopgpuis running on the CPU. A reader may join the pid with the queue’s own status for waiting and queue position. Inside a queued job a tool has no queue child of its own, and the crate defaults the pid fromGPU_QUEUE_PIDwhen the queue sets it.device: optional, the device the work runs on, likeHIP: AMD Radeon RX 9060 XTorCPU. A reader may count anything butCPUas GPU. It may arrive late, with the first finished render.out: optional, the absolute path of the result’s folder or manifest.error: optional, one line, truncated, beside a non-zeroexit.invoker: optional, who started the tool, fromPITO_INVOKERwhen the caller sets it.pid_start: the process’s start time (on Linux, field 22 of/proc/self/stat), so a reused pid is never mistaken for the job.updated: when the record was last written. The rate is live only while this is recent; after a few seconds a reader shows it as stale.- Times are Unix milliseconds, measured on the host where the job runs.
Ending: the tool writes its final state (done or failed, with
ended and exit) and leaves the file for a reader, which may archive it
and remove it. A finished file left alone stays until the folder is cleared
at logout or reboot. A reader treats a running record as killed when its
pid is gone or its start time no longer matches pid_start. Tools often end
with process::exit or _exit, which run no destructors, so every exit path
must call finish(code) or fail(code, error) explicitly. Drop is only a
last resort: a job dropped unfinished is written as failed with exit code
1.
The crate does all of this for Rust tools, with no background thread of its
own for a job (the heartbeat keeps one, §2) and a small dependency list:
serde, serde_json and, on unix, libc:
let job = pito_jobs::start("render", Some("trailer"), "trailer-01") .detail("player @49fe383 1080p→720p web");job.stage("build", None, "steps");job.pgpu(Some(queue_child.id()));job.stage("trace", Some(600), "frames");job.progress(120);job.finish(0);job.stages(&["probe", "encode", "mux"]) writes the stages field now, a
no-op after finish, like stage_note. A tool may call it once it has
probed and planned, while probe already runs:
let job = pito_jobs::start("encode", None, "clip.mov");job.stage("probe", None, "files");job.stages(&["probe", "encode", "mux"]);job.stage("encode", Some(1), "outputs");progress is cheap enough to call every frame, since it writes at most once
a second. The crate is best-effort: a write error or a missing job folder
never fails or slows the tool. job.stage(…) starts a new stage and resets
its progress.
2. App heartbeat: a running app’s own fps
Section titled “2. App heartbeat: a running app’s own fps”A job ends. A running app (a terminal UI, a desktop window, a service) doesn’t, and its speed is how smoothly it draws. A monitor can measure CPU, memory, GPU and VRAM from outside, over the app’s process tree; only the app itself knows its fps.
While it runs, the app keeps one file, <jobs>/apps/<pid>-<started>.json,
rewritten atomically at most once a second:
{"v":1,"id":"desk:5120:1790000000123","host":"desk","pid":5120,"pid_start":98231, "app":"notes","version":"0.12.0","surface":"tui","ride":false, "fps":118.6,"frame_ms":{"p50":1.9,"p99":6.8,"max":9.1},"gpu_ms":{"p50":7.8,"p99":8.3,"max":8.9}, "interval_ms":{"p50":8.3,"p99":9.4,"max":17.2},"missed":1,"target_fps":120,"pacing":"on_change","idle":false,"detail":"converging 128/512 spp","ride_set":null, "started":1790000000123,"updated":1790000061123}app: the app’s short key. A reader may sum every file with the sameappinto one row.surface:tui,desk(a GUI window) orservice. A service is a detached daemon that never callsframe; it reports only that it’s alive, so a monitor can add its usage to its app’s row.ride: true whenRIDE_APPis set and not empty, andride_setcarriesRIDE_SET. A test or fixture runner (PITO MD’s is prun) sets them before it drives the app, and children inherit them, so a reader can tell a test run from real use.fps: frames drawn over the last second. An app that draws only on change reports a low fps while idle, and that’s correct:idleis true when nothing was drawn in the last second.frame_ms: p50, p99 and max of the CPU time spent producing a frame.interval_ms: p50, p99 and max of the time between presents, which the crate measures from its own timestamps. Stutter shows here even when the work per frame is small.missed: intervals longer than 1.5 × the target frame budget, counted only on acontinuoussurface. An on-change surface idling between inputs misses nothing.- The percentiles cover the last 5 s, so a 60 fps window has about 300
samples.
fpscovers the last second. gpu_ms: optional, p50, p99 and max of GPU time per frame, from the app’s GPU timestamps. A GPU-bound surface keeps its CPU time tiny while fps drops, so this is the number that explains it.detail: optional, one short line the app chooses, such asconverging 128/512 sppfor a progressive tracer.- Optional and additive:
frame_ms,gpu_msandinterval_mscarryp95besidep50,p99andmax, computed from the same samples;size:[w, h], the render size in pixels;renderer: which renderer drew, such asliveortrace;gpu_pass_ms: each GPU pass’s rolling mean in ms, such as{"shadow":1.4,"opaque":3.9,"taa":0.9,"post":1.1}; at most 16 passes, names up to 24 characters. An older beat without them still reads; a field the app never sets is left out.
target_fps: the surface’s target: 60 for a desk and 120 for a TUI, unless the app says otherwise.pacing:continuousoron_change, set by the app. A continuous surface is judged by its fps againsttarget_fps. An on-change surface (a TUI that draws on input and on a 250 ms tick sits near 4 fps) is judged by how fast it answers, itsframe_msp99, never by fps.frame_idle()counts a tick-only redraw without holding it against fps.- Staleness and pid reuse work as in §1 (
updated,pid_start). The crate removes the file on a clean exit, and a reader ignores a file whose process is gone.
The crate’s API is a few calls, cheap enough for every frame:
let live = pito_jobs::live("notes", env!("CARGO_PKG_VERSION"), pito_jobs::Surface::Tui, pito_jobs::Pacing::OnChange);live.frame(frame_time);live.frame_with(cpu_time, Some(gpu_time));live.detail("converging 128/512 spp");live.frame_idle();live.size(3840, 2160);live.renderer("live");live.passes(&[("shadow", 1.4), ("opaque", 3.9), ("taa", 0.9), ("post", 1.1)]);size, renderer and passes only store their value; the writer thread
puts the latest into the next once-a-second beat. An app calls them when the
value changes or once a second, never per frame.
Call frame once per presented frame: after each draw in a TUI, and on the
redraw path in a window. Headless modes and tests don’t call it.
Opt-in: the heartbeat runs only where a reader is installed:
PITO_JOBS_DIR is set, or the folder <data>/pito/apps/ exists, where
<data> is $XDG_DATA_HOME, else ~/.local/share on Linux and other
unixes and ~/Library/Application Support on macOS. The monitor’s install
creates that folder. Anywhere else, such as an end user’s machine running a
shipped app, live returns an inert handle, with no thread and no file.
PITO_JOBS=off and PITO_JOBS_DIR work as in §1. frame never allocates
and never locks: it writes a timestamp into a fixed ring. The once-a-second
file write runs on one small low-priority thread that live starts, never
on the draw thread. The crate is best-effort and never fails the draw loop.
A Service handle writes its file without frame calls.
3. Progress lines: an op’s --progress json
Section titled “3. Progress lines: an op’s --progress json”A job record (§1) is a job’s state, rewritten in place for whoever looks. A
progress line is the event form of the same thing, streamed to the one
caller that started the op and reads its stdout as it goes: an installer’s
install or update, a deploy, a release, run with --progress json
and read by a TUI’s activity band or a script. pci, PITO MD’s installer, is
one producer.
With --progress json, stdout is one JSON object per line, UTF-8, ended by
\n and flushed per line, so a reader sees each event as it happens. The
human lines go to stderr. An op’s lines are events, then one closing end
line:
{"v":1,"op":"update","app":"notes","ref":null,"stage":"resolve","state":"start","fraction":null,"msg":"resolving notes","at":1791036610138}{"v":1,"op":"update","app":"notes","ref":"v0.18.9","stage":"queue","state":"progress","fraction":0.333,"msg":"waiting: 3rd in line","at":1791036640000,"id":"1m411knjtny"}{"v":1,"op":"update","app":"notes","ref":"v0.18.9","stage":"build","state":"progress","fraction":0.42,"msg":"Compiling 34/80","at":1791036700000,"id":"1m411knjtny"}{"v":1,"op":"update","app":"notes","ref":"v0.18.9","state":"end","ok":true,"msg":"notes 0.18.9 installed","at":1791036835863,"id":"1m411knjtny"}v: 1. A reader skips a line with any otherv, or none.op: what runs, the producer’s word:install,update,deploy,release.app: the short key of the app the op works on, as in §1. In a batch every line carries its own op’sapp.ref: what the op runs, a tag or a commit, or null until the producer knows it.stage: the current step, a stable identifier as in §1, never carrying a count. Stages differ per op and are the producer’s; an install might runresolve, clone, verify, queue, build, check, record. A reader draws a stage it has no list for as it reads.state:start,progress,doneorfail. A stage starts, may report progress, and endsdoneorfail; the next stage’sstartmay follow at once. A reader keeps a line with any other word and treats it as unknown.fraction: 0 to 1, honest or null: the producer sends null rather than guess, and it may fall (a place in a queue falls as the task moves up). The writer rounds it to three decimals; a reader clamps one outside 0 to 1.msg: one short line, or null.at: when the line was written, Unix milliseconds on the host that runs the op.id: the producer’s id for the op, present once there is one; a line before that has noidfield.
The end line closes the op: {"v":1,"op","app","ref","state":"end","ok", "msg","at","id"}. ok says whether the op as a whole succeeded, msg why
in one line. A producer may add its own fields to the end line (an archive
op’s result, what an uninstall left behind); a reader that knows them reads
them and any other ignores them. After its end line an op writes nothing
more.
A batch (several ops after one approval) interleaves its ops’ lines, each
op with its own end line, and closes with one summary line:
{"v":1,"op":"batch","state":"summary","ok","results":[{"op","app","ref", "ok","msg"}],"msg","at"}. ok is true when every op succeeded, and msg
is null, or why the batch ran nothing.
Beside a job record (§1), the same words mean the same things:
| §3 line | §1 record |
|---|---|
op |
tool, the program at work |
app |
app |
stage |
stage |
fraction |
progress.done / progress.total, null while total is unknown |
msg |
detail and stage_note |
at |
updated |
state fail, end ok false |
state failed, error |
id, the op’s |
id, the job’s: each names its own thing, never the other |
The crate reads and writes them in its op module. op::Line::parse reads
one line into an Event, an End or a Summary, and returns nothing for a
line that is not JSON, has no v of 1 or misses what its kind needs (an
event’s stage); a missing optional field takes its default and an unknown
one is ignored, except that End::extra keeps the end line’s own fields.
op::read reads a stream and skips what does not parse, such as an older
producer’s human lines. op::Writer writes one op’s lines, stamps v,
op, app, ref, id and at, writes each line whole and flushes it,
and writes no event or end after its end line; Writer::line writes a line
built by hand, such as an end line with extra or a batch’s summary.
Progress::fraction gives a record’s fraction the same way, so one bar
draws both:
let mut out = pito_jobs::op::Writer::new(std::io::stdout(), "update", "notes");out.event("resolve", pito_jobs::op::State::Start, None, Some("resolving notes"))?;out.reference("v0.18.9");out.id("1m411knjtny");out.event("build", pito_jobs::op::State::Progress, Some(0.42), Some("Compiling 34/80"))?;out.end(true, "notes 0.18.9 installed")?;
for line in pito_jobs::op::read(child_stdout) { match line { pito_jobs::op::Line::Event(event) => draw(&event.app, &event.stage, event.fraction), pito_jobs::op::Line::End(end) => finish(&end.app, end.ok, &end.msg), pito_jobs::op::Line::Summary(summary) => report(&summary.results), }}