feat(web): build a project worker image from a Dockerfile via the dashboard (MNG-1725)#1476
Conversation
…hboard (MNG-1725)
nhopeatall
left a comment
There was a problem hiding this comment.
Summary
COMMENT — a solid, well-tested frontend + docs change that completes the worker-Dockerfile feature. I verified it against the backend contracts and it holds up; one non-blocking UX inconsistency is worth a look.
Verification performed (all pass):
projects.update({ workerDockerfile }),projects.rebuildWorkerImage({ projectId }), and everygetByIdfield the UI reads (workerDockerfile,workerImageBuildStatus,workerImageStatus,workerImageDigest,workerImageError) all exist and match the call sites.- The client-side
deriveWorkerImageSourceprecedence (dockerfile>reference>default) is byte-for-byte the same as the backendconfigMapper.deriveWorkerImageSource, so the mirrored derivation won't drift silently. - The active-image vs. build-attempt status split, the no-strand "Verified · last rebuild failed" rendering, the full-lifecycle poll predicate, source-scoped mutual exclusivity, and the superadmin gate all trace correctly.
- Docs reference real CLI flags (
--dockerfile-file,--clear-dockerfile,--rebuild-worker-image), and theValidation failed→Failedwording change doesn't break the spec-022 test (/failed/i).
All MNG-1725 acceptance criteria are met.
Code Issues
Should Fix (non-blocking)
- web/src/components/projects/project-worker-image.tsx:353 — Selecting Global default while a reference/Dockerfile override is still persisted shows "Using the global default worker image…" without clearing anything — the backend override stays active and runs keep using it. The obvious "revert to default" path is therefore a no-op that misreports state. See inline suggestion.
Nitpick
- project-worker-image.tsx:316 / 462 — Rebuild rebuilds the persisted Dockerfile (
owned.workerDockerfile), so unsaved textarea edits are silently ignored while the draft stays visible in the box. This matches the documented "rebuild … without editing the content", but an operator who just edited the textarea and clicks Rebuild may expect their edits to build. Consider disabling Rebuild whiledockerfileDraftdiffers from the persisted content, or adding a hint.
🕵️ claude-code · claude-opus-4-8 · run details
| {currentImage && ( | ||
| <WorkerImageStatusBadge status={status} digest={digest} errorReason={errorReason} /> | ||
| {/* Global default — no per-project override configured. */} | ||
| {selectedSource === 'default' && ( |
There was a problem hiding this comment.
When an override is actually persisted (persistedSource is reference/dockerfile), manually picking Global default here renders "Using the global default worker image…" even though nothing was cleared — the backend still holds the reference/Dockerfile and every run keeps using it. This branch has no Clear action, so the seemingly-obvious "revert to default" gesture is a no-op that misreports state, which cuts against this ticket's accurate-status theme.
The genuine revert (the Clear button under the Referenced-image / Dockerfile source) works correctly. Consider gating the copy on persistedSource === 'default': when an override is still configured, tell the operator to Clear it from its source tab (or surface a Clear action here) instead of unconditionally claiming the default is in use.
There was a problem hiding this comment.
Fixed in d881d3b. The "Global default" branch is now gated on persistedSource === 'default', so it only claims the default is in use when nothing is actually persisted.
When an override is still configured (persistedSource is reference/dockerfile), the card no longer misreports state — it renders an amber notice ("A Dockerfile / referenced image override is still configured, so every run keeps using it. Clear it to revert to the global default…") plus a Clear override button that performs the genuine revert, routing to handleClearDockerfile / handleClearReference based on the persisted source. After the clear succeeds the project refetches, persistedSource becomes default, and the accurate "Using the global default…" copy returns.
Covered by two new tests: one asserts the default view does not claim the default while a Dockerfile override is persisted (and that Clear override clears workerDockerfile), and one covers the persisted-reference case clearing workerImage.
…ed edits Address review feedback on the Worker Image card (MNG-1725): - Selecting "Global default" while a reference/Dockerfile override is still persisted no longer falsely claims the default is in use. When an override is configured, report the true state and surface a "Clear override" action that performs the real revert; only show "Using the global default…" when nothing is persisted. - Disable "Rebuild" while the Dockerfile textarea diverges from the saved content (Rebuild acts on the persisted Dockerfile), with a title tooltip and a visible hint directing the operator to Set first. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
nhopeatall
left a comment
There was a problem hiding this comment.
Summary
COMMENT — a well-tested frontend + docs change completing the worker-Dockerfile feature (spec 023, 5/5). I traced it end-to-end against the backend contracts from tickets 1–4 and it holds up. One minor, non-blocking messaging inconsistency mirrors a bug class this PR already fixes for the Global-default view.
Verification performed (all pass):
projects.update({ workerDockerfile }),projects.rebuildWorkerImage({ projectId })(both superadmin-gated), and everygetByIdfield the UI reads (workerImage,workerImageDigest,workerImageStatus,workerImageError,workerDockerfile,workerImageBuildStatus) exist and match the call sites.getProjectFullselects*andserializeProjectspreads it, so the two new columns are surfaced.- Client
deriveWorkerImageSource(dockerfile>reference>default) is byte-identical to backendconfigMapper.deriveWorkerImageSource, so the mirrored derivation won't drift. - The active-image vs. build-attempt status split, the no-strand "Verified · last rebuild failed" rendering, the full-lifecycle poll predicate (
pending/building/buildStatus==='building'), source-scoped mutual exclusivity, and the superadmin gate all trace correctly. - Docs reference real CLI flags (
--dockerfile-file,--clear-dockerfile,--rebuild-worker-image— all present incli/dashboard/projects/update.ts); the "CASCADE prependsFROM… drops back to the non-rootnodeuser" claim matchescomposeDockerfile(FROM→USER root→ content →USER node).
All MNG-1725 acceptance criteria are met, and the two modified spec-022 tests correctly adapt to the new selector default (unset → default, so they pick "Referenced image" first).
Code Issues
Should Fix (non-blocking)
- web/src/components/projects/project-worker-image.tsx:407 — The Referenced-image view reintroduces the exact "false default" state this PR fixes for the Global-default view. Selecting Referenced image while a Dockerfile override is still persisted renders "Unset — using the global default…", but
currentImageisnullonly because backend mutual exclusivity nullsworkerImageon a dockerfile-sourced project — the persisted Dockerfile is still driving every run. Consider gating that copy onpersistedSource === 'default'(or surfacing the same amber "override still configured" note) so it doesn't misreport while a cross-source override is active. Symmetric note: the Dockerfile view doesn't indicate a persisted reference is still active either, though it at least makes no false claim.
🕵️ claude-code · claude-opus-4-8 · run details
| <p className="text-xs text-muted-foreground"> | ||
| {currentImage | ||
| ? `Configured: ${currentImage}` | ||
| : `Unset — using the global default${globalDefault ? `: ${globalDefault}` : ''}.`} |
There was a problem hiding this comment.
Same "false default" class the PR fixes for the Global-default view, but reachable here: when a Dockerfile override is persisted and the operator switches the source selector to Referenced image, currentImage is null (backend mutual exclusivity nulls workerImage for a dockerfile-sourced project), so this renders "Unset — using the global default…" even though the persisted Dockerfile is still driving every run. Consider gating this copy on persistedSource === 'default', or reusing the amber "override is still configured" note, so it doesn't misreport while a cross-source override is active.
There was a problem hiding this comment.
Fixed in 20474e1. Gated the "Unset — using the global default…" copy on persistedSource === 'default', so the Referenced-image view no longer misreports while a Dockerfile override is still persisted (backend mutual exclusivity nulls workerImage, but the Dockerfile still drives every run).
When a cross-source override is active, the view now surfaces the same amber "override is still configured" notice plus a Clear override button that performs the genuine revert — routed to the persisted source — exactly the treatment the Global-default view already uses.
I also addressed the symmetric note: the Dockerfile view now shows the notice when a referenced image is still persisted, instead of an empty textarea that makes no claim. The notice is extracted into a shared CrossSourceOverrideNotice component reused by all three views (default / reference / dockerfile).
Covered by two new tests in project-worker-dockerfile.test.ts: the Referenced-image view no longer claims the default while a Dockerfile is persisted (and Clear override clears workerDockerfile), and the Dockerfile view surfaces the active reference override (Clear override clears workerImage).
…image override is persisted The Referenced-image view rendered "Unset — using the global default…" whenever workerImage was null — but backend mutual exclusivity nulls workerImage on a Dockerfile-sourced project, so the persisted Dockerfile was still driving every run while the UI falsely claimed the default. Gate the "Unset — using the global default" copy on persistedSource === 'default' and, when a cross-source override is active, surface the same amber "override is still configured" notice (with a Clear override action routed to the persisted source) that the Global-default view already shows. Add the symmetric notice to the Dockerfile view when a referenced image is still persisted. Extracted the notice into a shared CrossSourceOverrideNotice component reused by all three views. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
nhopeatall
left a comment
There was a problem hiding this comment.
Summary
APPROVE — a clean, well-tested frontend + docs change completing the worker-Dockerfile feature (spec 023, 5/5). Web + docs only; no backend behavior changed, as claimed. I traced it end-to-end against the tickets 1–4 backend contracts and it holds up.
Verification performed (all pass):
projects.update({ workerDockerfile })(z.string().nullish()),projects.rebuildWorkerImage({ projectId })(superadmin-gated), and everygetByIdfield the UI reads (workerImage,workerImageDigest,workerImageStatus,workerImageError,workerDockerfile,workerImageBuildStatus) all exist and match the call sites.getProjectFulldoesselect()(all columns) andserializeProjectspreads it, so the two new columns are surfaced.- Client
deriveWorkerImageSource(dockerfile>reference>default) is logically identical to backendconfigMapper.deriveWorkerImageSource. - Every status/build-status/poll combination traces correctly against the backend writers: first build →
status:'building'(spinner + poll); rebuild →status:'verified'preserved +buildStatus:'building'(no-strand + poll); failed rebuild on a verified pin →status:'verified'+buildStatus:'failed'("Verified · last rebuild failed", poll stops); first-build failure →status:'failed'. The active-image vs. build-attempt split, source-scoped mutual exclusivity, the sharedCrossSourceOverrideNotice(the "false default" fix from the prior review rounds), and the superadmin gate all hold. - Docs are accurate:
--dockerfile-file/--clear-dockerfile/--rebuild-worker-imageexist incli/dashboard/projects/update.ts; the "CASCADE prepends the pinnedFROM… drops back to the non-rootnodeuser" claim matchescomposeDockerfile(FROM→USER root→ content →USER node). - 25 unit tests pass locally;
webtsc -bgreen; CI 7/7 green.
All MNG-1725 acceptance criteria are met.
Code Issues
Nitpick (non-blocking)
- project-worker-image.tsx:344 — Clicking Set on the Dockerfile view without editing an already-
verifiedproject hits the backend's idempotent no-op path (processWorkerDockerfileChangereturnsnullwhencontentHash === existing.workerImageBuildHash && priorVerified— no columns written, no build enqueued), yet the client unconditionally toasts "Dockerfile saved — building the worker image on the router…". The Set button is enabled whenever the textarea is non-empty (!dockerfileValue.trim()), so this is reachable with zero edits. Minor/cosmetic: the toast implies a build started when none did. Rebuild is the intended "no-edit" action, so this is an edge case — worth a look but not blocking.
🕵️ claude-code · claude-opus-4-8 · run details
| { | ||
| onSuccess: () => { | ||
| setDockerfileDraft(null); | ||
| toast.success('Dockerfile saved — building the worker image on the router…'); |
There was a problem hiding this comment.
Non-blocking nitpick: when the saved Dockerfile is re-Set unchanged on an already-verified project, the backend processWorkerDockerfileChange returns null (idempotent no-op — no build enqueued), but this onSuccess still toasts "Dockerfile saved — building the worker image on the router…", implying a build started when none did. Since Set is enabled whenever the textarea is non-empty, this is reachable with zero edits. Consider suppressing/rewording the toast when the content is unchanged from the persisted value (!hasUnsavedDockerfileEdits), or leaving the no-edit path to the Rebuild button.
Worker Dockerfile 5/5 — dashboard control + operator docs
Final ticket of the worker-Dockerfile sequence (1 schema · 2 spawn resolution · 3 build engine · 4 set surfaces · 5 this). Extends the existing project-settings Worker Image control so a superadmin can build a project's worker image from operator-supplied extra layers (CASCADE supplies the pinned
FROM cascade-workerbase), on top of the now-curl-testable backend from tickets 1–4.Issue: https://linear.app/mongrel/issue/MNG-1725
What changed
web/src/components/projects/project-worker-image.tsxNativeSelect) driven by the client-derivedworkerImageSource— Global default / Referenced image / Dockerfile. Selecting one source hides the other's control, matching the backend mutual-exclusivity invariant (a project has one effective image source).ui/textarea.tsxfor the extra layers + Set / Clear wired toprojects.update({ workerDockerfile })(Clear sendsnull), plus a Rebuild button (Dockerfile source only) callingprojects.rebuildWorkerImage.WorkerImageStatusBadgenow separates the active image (workerImageStatus: pending / building / verified / failed) from the most recent build attempt (workerImageBuildStatus: building / failed). A rebuild that fails while a verified pin exists reads "Verified — pinned to … · last rebuild failed:<reason>" (the project stays runnable), not a misleading top-level "Failed". A first build renders a Building… spinner.workerImagePollInterval(status, buildStatus?)keeps polling (WORKER_IMAGE_POLL_MS) whilestatus === 'pending' || status === 'building' || buildStatus === 'building', and stops once settled. The optional second arg keeps the spec-022 single-status callers behaving exactly as before.Tests
tests/unit/web/project-worker-dockerfile.test.ts(12 tests): Dockerfile textarea renders + reference input hidden; Set →projects.update({ workerDockerfile }); Clear →null; Building… spinner for a first build; the "Verified · last rebuild failed" split vs. a first-buildfailedbadge; the poll predicate across all in-flight/settled states; Rebuild →projects.rebuildWorkerImage; superadmin gate;deriveWorkerImageSource.tests/unit/web/project-worker-image.test.ts: the two unset-project cases now pick the Referenced image source first (an unset project derivesdefault, which shows an info note instead of the reference input). All spec-022 reference assertions are otherwise unchanged.Docs
README.md(worker-image section): the Dockerfile-source option (supply only the extra layers; CASCADE supplies the pinnedFROM), mutual exclusivity with a referenced image, the single-daemon constraint, and the--dockerfile-file/--rebuild-worker-image/--clear-dockerfileCLI equivalents.docs/getting-started.md: an "Alternative: supply a Dockerfile and let Cascade build it" walkthrough beside the existingFROM cascade-workerexample — write extra layers → save (dashboard/CLI/API) → watch Building… → Verified (or read a Failed reason) → Rebuild to pick up a refreshed base, plus the single-daemon note.CHANGELOG.md: user-visible dashboard-control entry under Unreleased → Added.Testing
npm test(all 4 unit projects): 10,662 passed, 33 skipped.npm run typecheck(root) +cd web && npx tsc -b: green.npm run build(root) +cd web && npm run build: green.biome checkon changed/new files: green.🤖 Generated with Claude Code
🕵️ claude-code · claude-opus-4-8 · run details