WIP: feat(docker): re-attachable update jobs, honest status, and real progress #8

Closed
thisilike wants to merge 2 commits from feat/docker-jobs-and-status into feat/core-ui-for-docker
Owner

Third of four; stacked on #3.

An update was not addressable. Progress went out on an at-most-once stream and nowhere else, so dismissing the modal, switching tabs or reloading lost it — and the "done" that ends a run can simply not arrive. The server keeps each run in a registry and answers /jobs and /jobs/:project; the pages re-attach to what is already running. One primitive (job_follow.svelte.ts) does that for all three of them.

Compose progress was appended verbatim. --ansi never makes compose print one line per redraw frame — ~3000 for a 3 GB pull. compose_progress.ts parses those back into the keyed entity events the redraw was expressing, so the UI can draw bars, rates and an aggregate.

A stopped container was reported as a fault. A one-shot that exits 0 by design is finished, not broken; marking it red meant any stack with an init container never looked healthy. The same rule rolls up to a stack health dot.

Verified in the rig: a stack update start-to-finish with keyed progress rows, completion, and the spinner clearing; 104 module tests.

Third of four; stacked on #3. **An update was not addressable.** Progress went out on an at-most-once stream and nowhere else, so dismissing the modal, switching tabs or reloading lost it — and the "done" that ends a run can simply not arrive. The server keeps each run in a registry and answers `/jobs` and `/jobs/:project`; the pages re-attach to what is already running. One primitive (`job_follow.svelte.ts`) does that for all three of them. **Compose progress was appended verbatim.** `--ansi never` makes compose print one line per redraw frame — ~3000 for a 3 GB pull. `compose_progress.ts` parses those back into the keyed entity events the redraw was expressing, so the UI can draw bars, rates and an aggregate. **A stopped container was reported as a fault.** A one-shot that exits 0 by design is finished, not broken; marking it red meant any stack with an init container never looked healthy. The same rule rolls up to a stack health dot. Verified in the rig: a stack update start-to-finish with keyed progress rows, completion, and the spinner clearing; 104 module tests.
Three things the module got wrong, all of which show up the moment an update
takes longer than a click.

**An update was not addressable.** Progress went out on an at-most-once
stream and nowhere else, so dismissing the modal, switching tabs or reloading
lost it — and the "done" that ends a run can simply not arrive. The server
now keeps each run in a registry with its progress and log, hands out a job
per project atomically, and answers `/jobs` and `/jobs/:project`; the pages
re-attach to what is already running instead of guessing. One primitive
(`job_follow.svelte.ts`) does that for all three of them.

**Compose progress was appended verbatim.** `--ansi never` makes compose
print one line per redraw frame — about 3000 for a 3 GB pull — which is why
the log was unreadable. `compose_progress.ts` parses those frames back into
the keyed entity events the redraw was expressing, so a layer replaces its
own row and the UI can draw bars, rates and an aggregate. Byte totals only
exist in `--progress json`, which is probed for and used where available.

**A stopped container was reported as a fault.** A one-shot that exits 0 by
design — a migration, a seeder — is finished, not broken, and marking it red
meant any stack with an init container never looked healthy. `status.ts`
identifies one by what compose says about it (a sibling waiting with
`service_completed_successfully`), and the same rule now rolls up to a stack
health dot on the card and the detail page.

The pin work rides on top of this and lands separately.
Six defects from a review of c5a8c5c, all in the new job machinery.

`claimed` was a latch, not a window. It marks the gap between our own POST
and the server registering the run, so that a 404 in that gap reads as
"not yet" rather than "over" — but nothing reliably closed it. `adopt()`
left it set when the id was null, and the container page never calls
`adopt` at all. Sleep a laptop past the finished-job TTL, lose the `done`
event to at-most-once delivery, and every later sync 404s without ever
clearing: modal spinning, button disabled, until a reload. It now expires
30 s after start() — a round trip's worth, since every update route calls
jobs.start() before it responds — and adopt() closes it whatever the id.

The container page called job.start() before its POST, and start() resets
the feed. If the page had adopted a stack-wide run via sync, a 409 (the
depends_on ack, or "update already running") wiped that run's hydrated
rows and left accepts() rejecting its events. It only starts, clears,
opens the modal and writes "failed to start" into the feed when the run
is its own; otherwise the failure goes to actionError alone.

merge() dropped `phase` on terminal frames, which arrive without one, so
a finished layer went phase-less and the pre-rollup fallback in
downloadLayers() counted its extract (uncompressed) bytes as a download —
the compressed/uncompressed mixing PullPhase warns about. The client
reducer is the documented mirror of that function and had the same bug;
fixing only one side would have been worse than neither, because sync()
re-hydrates every 15 s and completed layers would have flipped between
the two readings, jumping the byte pair and the per-layer bar colour.

hydrate() zeroed the monotonic pull-fraction guard on every call, and
sync() calls it every 15 s while a job runs, so "never decreases within
one run" held for 15 s at a time. It takes the run id now and only starts
over for a different run — reset() and start() still clear it outright.

/jobs and /jobs/:project are both admin-only, and a viewer's page asked
on mount, on every docker:stacks push, and every backstop tick, forever.
The first 403 stops the asking. That latch lives in JobFollow rather than
the page, so sync() is covered for every caller, not just the two here.

Also drops three unused imports from mod.ts (ContainerInfo,
fetchRemoteDigest, composeConfigArgs), which were the file's five lint
errors. composeConfigArgs is not dead: its callers are the pin route on
feat/docker-pinned-versions, one branch up. Rebasing that branch will
conflict in this import block — take its side.
Author
Owner

Superseded by #10, which merges this branch together with the rest of the stack and resolves the collisions between them. Closing rather than merging in place: replaying these merges one PR at a time would reproduce the same conflicts and lose the resolutions. The branch is untouched.

Superseded by #10, which merges this branch together with the rest of the stack and resolves the collisions between them. Closing rather than merging in place: replaying these merges one PR at a time would reproduce the same conflicts and lose the resolutions. The branch is untouched.
thisilike closed this pull request 2026-08-09 23:33:05 +02:00

Pull request closed

Sign in to join this conversation.
No reviewers
No labels
No milestone
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
OpsDeck/core!8
No description provided.