CLAUDE.md asserted "single-user is a hard invariant, not a stage" while users held six rows and role_capabilities held grants. Every doc that repeated it is corrected here, in prose and in the code comments that carried the same claim. The accurate statement is narrower: one owner who bypasses every check, other accounts holding only what their role is granted, and a set of capabilities — terminal, chat, files, tasks, items, desktop, browser — that are structurally ungrantable because they execute as the owner's OS user. TODO.md gains a Multi-user section for what the read turned up: no way to create a second account, dashboards.id colliding across users, authorize.ts untested, pty/vault/opencode taking no identity, Radicale still owner_only. claude-sidecar-isolation.md's open question is answered rather than left open — the per-email spawn model is dead weight, because chat is an execution capability and no second account can ever reach it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
7.5 KiB
Jobs Unification — making every task a background job
Status: shipped, except push notifications. Phases 1–3 (data model, executor, queue, REST API and
the master-detail /jobs screen) are done and live. Phase 4 is the only open item, and is the reason
this file still exists. Email sync is deliberately NOT part of this any more — it moved into the email
sidecar with its own scheduling, so it does not appear in the Jobs list.
Goal: every task run (script, pipeline, later agentic) becomes a persisted, background job — created over REST, streamed live over WebSocket, resumable/attachable, visible on desktop and phone, and ending in a push notification. Replaces today's ephemeral script-task WebSocket path.
Context: jobs belong to the owner. Not because the platform is single-user — it stopped being that
on 2026-08-07 — but because tasks is an execution capability: running a job means running a script
as the owner's OS user, so it can never be granted to a member. "Is anything running?" is therefore
still a global check, and the conclusion below is unchanged even though the premise was rewritten.
Favor power-user affordances over guardrails.
Current state (baseline)
- Pipeline tasks already are jobs:
pipeline_jobs(Postgres) +pipeline-job-manager(in-memory live registry, viewer attach/replay buffer, 3s progress flush, finalize, restart-reconciliation) +/jobslist (JobsScreen) +/jobs/:iddetail (JobDetail, live-attach or persisted). WS at/api/tasks/pipeline/ws. - Script tasks (convert-video, edit-audio, …) run via
task-executor.tsover/api/tasks/run/ws— ephemeral, zero persistence, dies with the tab. ModalTaskRunnerModal(ScriptRunner branch). - A separate file-backed queue engine exists at
src/servers/queue/(lane-serial, retries, crash-resume) wired only to email sync. NOT reused here — one Postgres jobs model instead.
Decisions (locked)
- Concurrency: idle → single
Run→/jobs/:id. Something running →Queue+ a redRun(force concurrent). Run → start now →/jobs/:id. Queue → appendpending→/jobslist. Backend (global): FIFOpendingqueue; on any finalize with 0 running, promote the oldest pending. Force-Run bypasses the queue. Queue backlog survives restart (promoteNext on startup). - Modal → page: replace
TaskRunnerModalwith a/jobs/newroute. Right-click task action navigates there instead of mounting the modal. One code path, phone-friendly. - Deep-link: query params, not hash —
/jobs/new?task=convert-video&root=home&path=<dir>&entry=<name>. - Phone: already has a file browser, so it can pick a target path and POST a job. No blocker.
inlinetasks stay in the modal. A top-levelinline: trueinTASK.mdmarks quick/interactive tasks that run in the modal (ephemeral) instead of as a job. Default = job. Currently flagged:remove-jelly-meta,analyze-video,clean-playlist-files. So the modal is not retired — it's the inline path; the shared input form serves both the modal and/jobs/new. The flag is plumbed through the parser + task list/detail endpoints (task.inline), so the FileBrowser menu can branch.
Plan
Phase 1 — Unified jobs backend
- 1a. Data model. Add
mode(pipeline|script|agentic, defaultpipeline) +exit_code(int) to the jobs table. Log atDATA_PATH/jobs/<id>.log(derived from id). Table/symbol renamepipeline_jobs→jobsis deferred as a cosmetic cleanup — add columns first, keep it working. - 1b. Execution. Generalize the job manager:
startJobtakesmodeand dispatches —pipeline→ existingexecutePipeline;script→ newexecuteScript(ports task-executor'smaterializeScript/buildInputEnv/bwrap sandbox/killTree/keepalive, but emits job events + appends stdout/stderr to the log file, finalizes withexit_code). Reuses the manager's viewer/broadcast/replay machinery. - 1c. Scheduler.
runningCount()+ FIFOpendingpromotion on finalize;promoteNext()on startup so a queued backlog resumes.
Phase 2 — REST job API (decouples creation from the socket; enables the phone)
POST /jobs {taskDirName, inputs, cwd, action}→{jobId}(create + start/queue, background).GET /jobs(+?live=1),GET /jobs/:id,GET /jobs/:id/log?offset=,POST /jobs/:id/stop.- Consolidate the two WebSockets into one
/api/tasks/jobs/wsdoing only attach/stop/list.
Phase 3 — Frontend
/jobs/new→NewJobScreen: reads query params, renders the input UI lifted fromTaskRunnerModal(TaskInputForm+ per-group config + folder probing). Run/Queue per the concurrency UX.JobDetailgains a script branch (terminal output: live attach, or from log when idle). RetireTaskRunnerModal/TaskRunnerDialog/useTaskRunner. Header running-jobs indicator.
Phase 4 — Notifications (later)
- One
notifyJobDone(job)hook at finalize → push to the phone app.
Progress
- 1a data model —
mode+exit_codecolumns (schema + applied to DB) - 1b executeScript + manager dispatch —
execute-script.ts(spawn/sandbox/killTree port, log file, abort poll, returns exitCode),process-tree.ts(shared killTree),pipeline-job-managernow dispatches bymodeand finalizes script jobs by exit code. Compiles; runtime-untested until a REST caller + restart exist. - 1c scheduler / queue —
enqueueJob(action)(start now / queue behind running),promoteNext()on finalize + startup,getOldestPendingJob,markInterruptedJobsnow running-only (pending queue survives restart).startJobkept as aenqueueJob(...,'start')wrapper. - 2 REST job API —
POST /jobs(create script|pipeline, action start/queue),GET /jobs(+?live=1, now returns mode/exitCode/isLive),GET /jobs/:id,GET /jobs/:id/log?offset=,POST /jobs/:id/stop. Router mounted at/jobsand/pipeline-jobs. Needs a restart to deploy; then curl/phone-testable. WS consolidation still pending (old/api/tasks/run/ws+/api/tasks/pipeline/wsstill live). - 3 frontend — master-detail
/jobs, modal-as-creator, split list, header badges. Done.- 3a jobs UI — master-detail
JobsPage(like/chat):WorkspaceLayoutwith a list panel (left, pollsGET /jobs, highlights active) + a detail panel (right) that branches bymode—ScriptJobDetailterminal (polls log + status, Stop) orPipelineJobDetail. One page serves both/jobsand/jobs/:id; list click navigates. Replaced the old separate list/detail pages. inlineflag plumbed (parser + list/detail endpoints); 3 quick tasks flagged.- 3b/3c task→job via the modal (pragmatic reuse). The task modal is now the job creator: on Run,
an inline task runs ephemerally in-modal; a non-inline task
POST /jobs(start) → navigates to/jobs/:id. When a job is already running, a red "Run now" + a "Queue" button (queue →/jobs). Reuses the modal's per-group input UI in place — no separate/jobs/newpage or FileBrowser change needed. (A standalone deep-linkable/jobs/newis deferred; the phone creates jobs directly viaPOST /jobs.) - 3d header job indicators —
JobsIndicator(two always-present badges next to RescanButton + UserMenu): running (→ running job's/jobs/:id) + queued (→/jobs), pollingGET /jobs/counts→{ running, runningJobId, queued }every 3s; dim at 0.
- 3a jobs UI — master-detail
- 4 push notifications