Mosaic one model, many lenses

ADR 0038 — Task center (/tasks)

  • Status: accepted
  • Date: 2026-10-04
  • Slice: P38 (builder-roadmap: DSL-first, codegen-first, UI-first)

Context

Workflows with a human node pause their run waiting for an answer (the L6 HITL pause/resume seam). The only surfaces for that pause were per-workflow REST endpoints — GET /api/workflows/{slug}/pauses and POST /api/workflows/{slug}/resume/{id} — which (a) require knowing the workflow slug, and (b) have no UI at all: a paused run is invisible outside curl. As more workflows gain human gates, the operator needs one place that answers the question "what is waiting on me?" across the whole app.

Decision

1. GET /api/tasks (shared server codegen)

When any workflow has a human node, the generated server (app and web lens — the same server_rs function) gains

GET /api/tasks → { "tasks": [ { workflow, pause_id, node, question, created_ms }, … ] }

— every paused run across all workflows (oldest first, the BTreeMap key order), each entry naming its workflow so the consumer can link to and resume it. The per-workflow pause/resume endpoints stay unchanged. A pub fn paused_tasks() accessor exposes the same shape to the in-crate (SSR) side; the handler wraps it.

2. The task center page (web codegen)

The web lens gains pages::TasksPage at /tasks (plus a tasks nav entry with a check-square icon), both emitted only when a human node exists:

  • SSR reads crate::server::paused_tasks() synchronously, so the paused table renders on the first paint (the SchedulesPage dual-mode pattern); the client refetches through /api/tasks after hydration.
  • Each task row shows workflow, node, and the rendered question, plus an answer input and a resume button that POSTs {"answer": …} to the workflow's resume mount — the answer is sent as JSON when it parses, as text otherwise, and empty falls back to the server default (true). Success flashes a toast and reloads, so the answered run drops off the list; an error flashes inline.
  • An empty center renders "no runs are waiting on a human answer".

Consequences

  • A paused run is always visible in one page, for every workflow, with a one-click answer path — no slug knowledge, no curl.
  • The task list is process-local (like the pause store itself): it reflects the pauses of the running server instance.
  • The page/endpoint are fail-closed on the same gate as the pause endpoints: no human node → no /api/tasks, no route, no nav entry.
  • The page's SSR HTML is e2e-assertable (GET /tasks → 200 + markup), continuing the P37 pattern for web slices.

Verification

  • Render tests: the route, nav entry, component, and GET /api/tasks are emitted for a workflow with a human node, and absent without one. The existing human_node_emits_pause_resume_infra test now also covers paused_tasks / list_tasks / the route.
  • Full-stack e2e (app block): /api/tasks is empty after a resume, lists the new pause (workflow + pause id) after a run, the answer resumes the run, and the list clears afterward.
  • Full-stack e2e (full_stack_web block): /tasks SSR-renders the empty state, then the paused run (rendered question) with its resume control after a run pauses at the human node.
  • Conformance re-pinned: full-stack only (the only example with a human node) — app + web server.rs (endpoint), web main.rs (route), web pages.rs (nav + page).