Skip to content

feat(web): build a project worker image from a Dockerfile via the dashboard (MNG-1725)#1476

Merged
zbigniewsobiecki merged 3 commits into
devfrom
feature/mng-1725-worker-dockerfile-dashboard-docs
Jul 2, 2026
Merged

feat(web): build a project worker image from a Dockerfile via the dashboard (MNG-1725)#1476
zbigniewsobiecki merged 3 commits into
devfrom
feature/mng-1725-worker-dockerfile-dashboard-docs

Conversation

@aaight

@aaight aaight commented Jul 2, 2026

Copy link
Copy Markdown
Collaborator

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-worker base), on top of the now-curl-testable backend from tickets 1–4.

Issue: https://linear.app/mongrel/issue/MNG-1725

Touches web/ and docs/ only — no backend behavior changed.

What changed

web/src/components/projects/project-worker-image.tsx

  • Image-source selector (NativeSelect) driven by the client-derived workerImageSourceGlobal 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).
  • Dockerfile source renders the shared ui/textarea.tsx for the extra layers + Set / Clear wired to projects.update({ workerDockerfile }) (Clear sends null), plus a Rebuild button (Dockerfile source only) calling projects.rebuildWorkerImage.
  • WorkerImageStatusBadge now 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.
  • Polling now covers the whole lifecycle: workerImagePollInterval(status, buildStatus?) keeps polling (WORKER_IMAGE_POLL_MS) while status === 'pending' || status === 'building' || buildStatus === 'building', and stops once settled. The optional second arg keeps the spec-022 single-status callers behaving exactly as before.
  • The whole card stays hidden for non-superadmins (unchanged gate). The spec-022 referenced-image control is unchanged behaviorally — it is just shown when the Referenced image source is selected.

Tests

  • New 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-build failed badge; 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 derives default, 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 pinned FROM), mutual exclusivity with a referenced image, the single-daemon constraint, and the --dockerfile-file / --rebuild-worker-image / --clear-dockerfile CLI equivalents.
  • docs/getting-started.md: an "Alternative: supply a Dockerfile and let Cascade build it" walkthrough beside the existing FROM cascade-worker example — 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 check on changed/new files: green.

🤖 Generated with Claude Code

🕵️ claude-code · claude-opus-4-8 · run details

@nhopeatall nhopeatall left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 every getById field the UI reads (workerDockerfile, workerImageBuildStatus, workerImageStatus, workerImageDigest, workerImageError) all exist and match the call sites.
  • The client-side deriveWorkerImageSource precedence (dockerfile > reference > default) is byte-for-byte the same as the backend configMapper.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 the Validation failedFailed wording 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 / 462Rebuild 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 while dockerfileDraft differs 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' && (

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 nhopeatall left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 every getById field the UI reads (workerImage, workerImageDigest, workerImageStatus, workerImageError, workerDockerfile, workerImageBuildStatus) exist and match the call sites. getProjectFull selects * and serializeProject spreads it, so the two new columns are surfaced.
  • Client deriveWorkerImageSource (dockerfile > reference > default) is byte-identical to backend configMapper.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 in cli/dashboard/projects/update.ts); the "CASCADE prepends FROM … drops back to the non-root node user" claim matches composeDockerfile (FROMUSER 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 currentImage is null only because backend mutual exclusivity nulls workerImage on a dockerfile-sourced project — the persisted Dockerfile is still driving every run. Consider gating that copy on persistedSource === '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}` : ''}.`}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 nhopeatall left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 every getById field the UI reads (workerImage, workerImageDigest, workerImageStatus, workerImageError, workerDockerfile, workerImageBuildStatus) all exist and match the call sites. getProjectFull does select() (all columns) and serializeProject spreads it, so the two new columns are surfaced.
  • Client deriveWorkerImageSource (dockerfile > reference > default) is logically identical to backend configMapper.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 shared CrossSourceOverrideNotice (the "false default" fix from the prior review rounds), and the superadmin gate all hold.
  • Docs are accurate: --dockerfile-file / --clear-dockerfile / --rebuild-worker-image exist in cli/dashboard/projects/update.ts; the "CASCADE prepends the pinned FROM … drops back to the non-root node user" claim matches composeDockerfile (FROMUSER root → content → USER node).
  • 25 unit tests pass locally; web tsc -b green; 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-verified project hits the backend's idempotent no-op path (processWorkerDockerfileChange returns null when contentHash === 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…');

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@zbigniewsobiecki zbigniewsobiecki merged commit c36ce89 into dev Jul 2, 2026
9 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants