Skip to content

feat(dashboard): enqueue ad-hoc tasks from the dashboard - #422

Open
danii1 wants to merge 2 commits into
mainfrom
feat/dashboard-delegate-task
Open

danii1 wants to merge 2 commits into
mainfrom
feat/dashboard-delegate-task

Conversation

@danii1

@danii1 danii1 commented Nov 14, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Lets users delegate work from the local observability dashboard: a New task button in the top bar opens a shadcn/ui dialog that enqueues an ad-hoc task, the dashboard equivalent of devintern worker add.

Builds on #423 (fix/dashboard-csrf-host-guard, now merged; this PR has been retargeted to main). This adds a powerful write endpoint (it queues agent work that can push branches and open PRs), so it ships behind #423's central Host/Origin/Content-Type guard on /api requests. It will conflict with #421 in dashboard-server.ts, dashboard-ui/src/lib/api.ts, and docs/code/dashboard.md; whichever of the two merges second rebases. #421 will be rebased on top of this PR to reuse the shared shadcn components added here (dialog, field, input, textarea, label, checkbox) and drop its hand-written dialog.

Endpoint: POST /api/tasks

Request (JSON): { repo, title, body?, idempotencyKey? }

Status When
201 New task enqueued
200 An active (pending/running) task with the same idempotencyKey was reused (deduplicated: true)
400 Invalid JSON, empty title, body > 20,000 chars, unknown repo (error lists valid [[repos]] names)
409 No usable workspace (missing/invalid workspace.toml, or no repos), e.g. a standalone legacy dashboard
403 / 415 From #423's guard: missing or cross-origin Origin (403), non-JSON Content-Type (415)
500 Prompt file / queue row could not be written (error carries the cause)

Success body matches worker add --json: { id, taskKey, repo, status, deduplicated }. The row goes into the same workspace queue DB the dashboard already uses for ad-hoc retries (DashboardData.getInjectedTaskStore()), with the prompt under <workspace>/injected/. Nothing is spawned and no worker/daemon is started; the running worker drains it on its next poll. Rows are recorded with actor local:dashboard. Entitlement is not re-checked per request: both dashboard entry points enforce the automation license at startup and the worker re-checks before running.

Server routing

The non-GET /api routes (retry, run now, new task) moved into a routeWrite helper inside startDashboardServer, which runs after the guard. Adding the route inline pushed the fetch handler over the lint complexity limit.

Shared refactor

The core enqueue moved from add-command.ts into packages/code/src/lib/workspace/injected-enqueue.ts (enqueueInjectedTask, plus MAX_INJECTED_BODY_CHARS, slugifyTitle, deriveInjectedTaskKey, buildInjectedTaskMarkdown): active-only idempotency dedupe, owner-only prompt file, task-key race retry, cleanup of a losing prompt. runWorkerAdd now calls it and keeps only CLI output/wait handling, with no behavior change (existing worker-add-command tests pass unchanged apart from the import path). The new module does not pull license-check/bootstrap into the dashboard server.

UI

  • Entry point: a New task button at the right of the top bar (App.tsx) opens NewTaskDialog (packages/dashboard-ui/src/components/NewTaskDialog.tsx). There is no route; the earlier #/tasks/new page and NEW_TASK_HASH are gone.
  • shadcn components added with bunx shadcn@latest add dialog field input textarea label checkbox (radix-mira style), plus separator, which field depends on. The existing button.tsx was kept. Changes to the generated files:
    • The CLI wrote import { cn } from "cn" and added an unrelated cn npm package. The imports now use @/lib/utils, and the package.json/bun.lock change was reverted.
    • import * as React became named type imports (import/no-namespace), and inline type specifiers moved to top-level type imports.
    • FieldError keys its items by message instead of index (react/no-array-index-key).
    • --popover/--popover-foreground tokens were added to styles.css for the dialog surface.
    • checkbox is unused here; it is for feat(dashboard): report a bug from a run #421.
  • Form (Field/Label/Input/Textarea plus the existing Select): repo preselected when there is one; title; Markdown description in a fixed-height textarea that scrolls inside the dialog (sm:max-w-2xl, capped at the viewport height); live n / 20,000 counter (submit disabled and counter flagged when over); quiet-hours / --create-pr notice.
  • Keyboard: the title is focused on open; Enter in the title does not submit; Cmd/Ctrl+Enter in the description does.
  • Draft protection: with a title or description typed, outside clicks are ignored, and Esc, Cancel, or the close button ask before discarding (window.confirm). A clean dialog closes normally. While submitting, the fields, Cancel, and close are disabled and Esc/outside clicks do nothing.
  • Idempotency: one crypto.randomUUID() key per dialog session. The session is mounted only while open, so reopening, or Create another task, starts a fresh draft and key.
  • Result inside the dialog: task key, id, status, Already queued when deduplicated, View runs, and Open run #N once /api/runs?taskKey=… returns the run.

Docs

  • docs/code/dashboard.md: new "Delegate a task" section (dialog behavior, endpoint contract, curl example), and the write-endpoint security notes now list POST /api/tasks.
  • docs/code/delegation.md: points at the dashboard alternative; licensing note.

Test plan

  • packages/code/tests/dashboard-tasks.test.ts: success (row + markdown prompt + actor), dedupe (200, single row; new key gives a suffixed task key), unknown repo, oversized body (and exactly-at-cap accepted), no workspace (409, DB not created), invalid workspace (409), write failure (500), server routing with a same-origin Origin (201, malformed JSON 400), and a cross-origin POST that gets 403 and enqueues nothing, and request parsing
  • packages/dashboard-ui/src/components/NewTaskDialog.test.tsx (static markup; the package has no DOM test setup): closed dialog renders nothing and #/tasks/new is no longer a route, draft validation, dirty-draft detection, Cmd/Ctrl+Enter shortcut, per-session keys, notice/fields/counter on the shared field components, disabled submit for incomplete/oversized drafts, inputs and Cancel disabled while submitting, error display, result links (run vs runs list), deduplicated message, createTask sends a same-origin JSON POST and surfaces server errors
  • Full root bun run test (code 2943 pass, dashboard-ui 252 pass, all packages green), root format, lint, typecheck
  • Checked in a real browser against a temp workspace, with the fix(dashboard): reject cross-origin and rebinding requests to the API #423 guard active: the top-bar button opens the dialog with the title focused; Enter in the title does not submit; an outside click keeps a dirty draft; Esc and the close button ask before discarding; a clean dialog closes without asking; picking a repo and pressing Ctrl+Enter enqueued local-fix-the-flaky-login-test and showed the result in the dialog; Create another task reset the form

🤖 Generated with Claude Code

Add POST /api/tasks, the dashboard equivalent of `devintern worker add`,
and a "New task" form in the dashboard header.

- Extract the core enqueue (markdown prompt + durable injected_tasks row,
  active-only idempotency dedupe, task-key race retry) from add-command.ts
  into workspace/injected-enqueue.ts; the CLI and the dashboard share it.
- POST /api/tasks parses { repo, title, body, idempotencyKey? } into a
  DashboardTaskRequest, checks the repo against workspace [[repos]], caps
  the body at 20,000 chars, and returns { id, taskKey, repo, status,
  deduplicated } (201 new, 200 deduplicated, 400 invalid, 409 no usable
  workspace, 500 write failure). It sits behind the central request guard
  (403 cross-origin, 415 non-JSON). No worker is started.
- Move the non-GET /api routes into a routeWrite helper to keep the
  server's fetch handler under the complexity limit.
- UI: #/tasks/new form with repo select, title, Markdown description with
  a character counter, a per-form idempotency key, a quiet-hours/PR notice,
  and a link to the resulting run once the worker records it.
- Docs: dashboard.md "Delegate a task"; delegation.md points at it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@danii1
danii1 force-pushed the feat/dashboard-delegate-task branch from 249a9c4 to 6b9ecab Compare November 14, 2026 07:55
@danii1
danii1 changed the base branch from main to fix/dashboard-csrf-host-guard November 14, 2026 07:55
Base automatically changed from fix/dashboard-csrf-host-guard to main November 14, 2026 08:39
Replace the #/tasks/new page with a dialog opened from the top-bar
"New task" button, built on shadcn/ui components.

- Add dialog, field, input, textarea, label, and checkbox (plus the
  separator that field depends on) with `bunx shadcn add`. Adjusted to the
  repo's lint rules: named React type imports instead of `import * as
  React`, `@/lib/utils` for `cn` (the CLI wrote a bare "cn" import and
  added an unrelated "cn" npm package, which was reverted), a stable key in
  FieldError. checkbox is unused here; #421 will reuse it.
- Add the popover color tokens the dialog surface uses.
- NewTaskDialog: repo preselect, char counter, quiet-hours/--create-pr
  notice, one idempotency key per dialog session (fresh on reopen and on
  "Create another task"), title focused on open, Enter in the title does not
  submit, Cmd/Ctrl+Enter in the description does. Drafts are protected:
  outside clicks are ignored and Esc/Cancel/close ask before discarding.
  Submitting locks the fields and blocks closing. The result (task key,
  status, Already queued, Open run #N, View runs) shows inside the dialog.
- Remove the #/tasks/new route and NEW_TASK_HASH.
- Docs: dashboard.md describes the dialog.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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.

1 participant