Skip to content

feat(web): per-project worker image dashboard control (MNG-1699)#1468

Merged
aaight merged 2 commits into
devfrom
feature/mng-1699-dashboard-worker-image-control
Jun 26, 2026
Merged

feat(web): per-project worker image dashboard control (MNG-1699)#1468
aaight merged 2 commits into
devfrom
feature/mng-1699-dashboard-worker-image-control

Conversation

@aaight

@aaight aaight commented Jun 26, 2026

Copy link
Copy Markdown
Collaborator

Summary

Final plan (4/4) of spec 022 per-project worker image (MNG-1699). Adds the dashboard control on top of the already-merged backend (plans 1–3) and the operator walkthrough docs — completing the feature end-to-end across CLI, API, and dashboard.

A superadmin can now set/clear a project's worker image from Project → Settings → General and watch the router-side validation lifecycle live.

Issue: https://linear.app/issue/MNG-1699

What changed

  • web/src/components/projects/project-worker-image.tsx (new) — a Worker Image card, superadmin-gated (self-hides for everyone else, mirroring the backend projects.update gate):
    • Text input whose placeholder is the global default from projects.defaults.
    • Setprojects.update({ workerImage }); Clearprojects.update({ workerImage: null }) (reverts to the global default). Clear only shows when an image is configured.
    • Live status from the per-project columns: Verifying… spinner while pending (the project query polls every 5 s via refetchInterval, the same approach as the run-status pages), Verified — pinned to sha256:… with the digest, or Validation failed: <reason>.
    • On mutation success, invalidates projects.getById + projects.listFull.
    • Exports a pure workerImagePollInterval(status) helper (poll while pending, stop otherwise) so the polling rule is unit-tested deterministically.
  • web/src/components/projects/project-general-form.tsx — renders <ProjectWorkerImage projectId={project.id} /> as a standalone card (it owns its own mutation, so it sits outside the form's Save/Reset).
  • docs/getting-started.mdPer-Project Worker Image (Advanced) operator walkthrough: derive an image FROM the Cascade worker base (hard/soft runtime requirements + non-root node user), make it available in both the registry-backed and self-hosted/local topologies, set it from the dashboard (or CLI), and confirm verified; plus the deliberately-broken-image → failed check.
  • CHANGELOG.md — Unreleased entry.
  • vitest.config.ts — dedupe @tanstack/react-query to the single (web-workspace) copy, mirroring the existing @trpc/client dedupe. Without it a vi.mock('@tanstack/react-query') resolved from a tests/ file misses the component's web-resolved import and the real useQueryClient runs against a second React copy (Cannot read properties of null (useContext)). Test-only; no production-bundle impact.

Tests

  • tests/unit/web/project-worker-image.test.ts (new, jsdom + @testing-library/react, react-query/tRPC mocked) covers every TDD case: placeholder = global default when unset; Set calls projects.update with the reference; Clear calls it with null; pending shows Verifying… and the poll predicate returns the interval; verified shows the digest; failed shows the reason; the control is hidden for a non-superadmin.
  • The test is authored as .test.ts (rendering via createElement, matching the repo's existing web-component test convention) rather than .test.tsx, so it is picked up by the existing tests/unit/web/**/*.test.ts glob with no test-runner JSX-transform changes.

Verification

  • npm test — 10490 passed, 0 failed (full unit suite).
  • npm run typecheck (root) ✓ · cd web && npm run build (tsc -b && vite build) ✓.
  • npx biome check on all changed files ✓.
  • Docs-drift + repo-hygiene guards ✓.

Scope notes

🕵️ 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

APPROVE — clean, well-scoped final plan (4/4) for spec 022. The new ProjectWorkerImage card correctly wires set/clear to projects.update, surfaces the pending → verified → failed lifecycle with 5s polling, and self-hides for non-superadmins. I verified the backend contract, the polling/rules-of-hooks correctness, the test, the typecheck, and the test-only vitest alias.

What I verified

  • Backend contract is real: projects.getById returns all four worker-image columns (getProjectFull selects *, serializeProject passes them through); projects.defaults exposes the global workerImage; auth.me returns role. The component's WorkerImageProject cast matches the persisted column names.
  • Superadmin gate is defense-in-depth, not the only control: the UI if (!isSuperadmin) return null mirrors the backend processWorkerImageChange, which throws FORBIDDEN for non-superadmins — so the hidden control can't be bypassed via a crafted request.
  • Polling correctness: refetchInterval: (query) => … signature and the 5000ms cadence match the existing run-status pages (routes/runs/$runId.tsx); all hooks run before the early return null, so rules-of-hooks hold.
  • vitest alias is safe: @tanstack/react-query exists only in web/node_modules, so the new dedupe alias just pins the single real copy. The other react-query web test (stats-page.test.ts) and the new test both pass.
  • Build/test: new test passes (7/7), cd web && npx tsc -b passes, CI 7/7 green.
  • Placement: the card sits outside </form> with type="button" controls — no accidental form submission, consistent with it owning its own mutation.

All MNG-1699 acceptance criteria (placeholder=global default, set/clear → projects.update, lifecycle badges, superadmin-only, docs walkthrough) are addressed.

Minor observations (non-blocking, no action required)

  • The verified badge renders the stored digest verbatim, which is the full repo@sha256:… reference (per the router validator's resolveDigestFromRepoDigests), not a bare sha256:…. That's more informative than the docs/CHANGELOG shorthand "pinned to sha256:…" — flagging only so the slightly longer real-world string isn't a surprise.
  • For a non-superadmin the getById/defaults queries still fire before the early return (the component renders nothing). Cost is negligible — getById dedupes with the parent, defaults is public + staleTime: Infinity, and refetchInterval returns false so there's no ongoing polling. enabled: isSuperadmin would trim it but isn't worth a change.

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

@zbigniewsobiecki

Copy link
Copy Markdown
Member

@aaight address conflicts

Resolve CHANGELOG.md conflict by keeping both Unreleased entries
(MNG-1699 dashboard worker image + MNG-1701 setup timeout). The
project-general-form.tsx changes from both branches auto-merged cleanly.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@aaight

aaight commented Jun 26, 2026

Copy link
Copy Markdown
Collaborator Author

✅ Merged origin/dev into the branch and resolved the conflicts.

What conflicted: only CHANGELOG.md — both dev (MNG-1701 setup-timeout Unreleased entry) and this PR (MNG-1699 worker-image dashboard entry) added a bullet at the top of the ### Added section. Resolved by keeping both entries (MNG-1699 first, MNG-1701 second).

Auto-merged cleanly: web/src/components/projects/project-general-form.tsx was touched by both branches but git combined them without conflict — the merged file retains both dev's setupTimeoutMs field and this PR's <ProjectWorkerImage /> card. Migrations are sequential (dev's 0058_add_setup_timeout_ms.sql sits after this stream's 0057_project_worker_image.sql); journal is valid.

Verification: npm run typecheck ✓ · cd web && tsc -b ✓ · project-worker-image.test.ts 7/7 ✓.

Pushed as merge commit 627d381d.

🕵️ 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

APPROVE — clean, well-scoped final plan (4/4) of spec 022. The new ProjectWorkerImage card wires set/clear to projects.update, surfaces the pending → verified → failed lifecycle with 5s polling, and self-hides for non-superadmins. I verified the backend contract, polling/rules-of-hooks correctness, the test, the web typecheck, and the test-only vitest alias.

What I verified

  • Backend contract is real: projects.getById returns all four worker-image columns (getProjectFull does db.select() → every column; serializeProject only renames agentEngineSettings), projects.defaults exposes the global workerImage (routerConfig.workerImage), and auth.me returns role. The WorkerImageProject cast matches the persisted column names.
  • Superadmin gate is defense-in-depth, not the only control: the UI if (!isSuperadmin) return null mirrors the server — processWorkerImageChange throws FORBIDDEN for any non-superadmin actor on projects.update, so hiding the control isn't bypassable by a crafted request.
  • Polling correctness: the refetchInterval: (query) => … signature + 5000ms cadence match the run-status pages (routes/runs/$runId.tsx); all hooks run before the early return null, so rules-of-hooks hold. Sharing the projects.getById key with the parent route observer is fine — react-query polls at the shortest active interval and keeps both in sync.
  • Placement: the card sits outside </form> (form closes at line 427, card at 458) and both controls are type="button" — no accidental form submission.
  • vitest alias is safe: @tanstack/react-query exists only under web/node_modules, so the new dedupe just pins the single real copy; it can't affect backend bundles (frontend-only dep). Both react-query web tests pass (project-worker-image + stats-page, 16/16).
  • Build/docs: cd web && npx tsc -b passes; the CLI flags the docs reference (--worker-image, --clear-worker-image, projects show) exist; CI 7/7 green.

Minor observations (non-blocking, no action required)

  • The inline useMutation re-implements the exact projects.getById + projects.listFull invalidation already encapsulated by the shared useProjectUpdate(projectId) hook (whose doc comment says it exists "to ensure consistent cache invalidation and UX behaviour"). Composing that hook and passing per-call onSuccess/onError would avoid a second copy of the invalidation rule — purely a reuse nicety.
  • The verified badge renders the stored digest verbatim, which is the full repo@sha256:… launch reference (per the router validator's resolveDigestFromRepoDigests), not the bare sha256:… shorthand in the docs/CHANGELOG — more informative, just slightly longer than the copy implies.

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


const [ref, setRef] = useState('');

const mutation = useMutation({

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: this useMutation duplicates the projects.getById + projects.listFull invalidation already provided by the shared useProjectUpdate(projectId) hook (web/src/components/projects/use-project-update.ts), whose doc comment says it exists "to ensure consistent cache invalidation and UX behaviour." You could call const mutation = useProjectUpdate(projectId) and pass the worker-image-specific onSuccess/onError (toasts, setRef('')) per .mutate(vars, { ... }) call, keeping the invalidation rule in one place. Functionally identical today — the keys match — so this is just a reuse nicety, not a defect.

@aaight aaight merged commit 3a6e97c into dev Jun 26, 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