feat(inbox): operator task dashboard, agent assign API, rank/estimate, timer, PWA #1

Closed
opened 2026-09-04 08:35:30 +00:00 by PlasticDigits · 1 comment
Owner

Summary

The operator product is a phone-narrow Cursor chat relay. It is not an inbox for human work that other agents assign, rank, time, and complete.

Bundle (do not split):

  1. Postgres as the relayer store (replace file-backed rusqlite).
  2. Human task inbox — one operator; kinds for secrets setup (not a vault), accounts, deploys, config, and chain scripts.
  3. Assigner API — scoped keys so Cursor CLI agents and other bots can create work without executor or controller powers.
  4. Rank / estimate pass — existing executor path writes priority + duration hint; do not pin a model.
  5. Start-task timer — server clock; persist elapsed on complete.
  6. Dense desktop board + installable PWA — full viewport on large screens; Add to Home Screen on Android.

Chat (/chat) stays for Cursor CLI threads. After login, the default surface is the inbox.

Related: keep PLAN.md single-admin design. Do not implement this in the Forgejo control-plane repo (that product is HMAC/gates, not a UI).

Current codebase

Relayer is SQLite + command relay, not a task store

crates/relayer is Axum: auth, devices, POST/GET /api/commands, executor-only PATCH, repos, file proxy, /ws. Persistence is rusqlite (crates/relayer/Cargo.toml, crates/relayer/src/db/mod.rs). Migrations live in migrations/ and run from crates/relayer/src/db/migrations.rs.

Config::from_env maps DATABASE_PATH or DATABASE_URL to a filesystem path (trim_start_matches("sqlite:"), default ./data/relayer.db). There is no Postgres driver, no sqlx, no task table.

Schema today: single admin row, devices (executor | controller), commands (pending/running/done/failed + Cursor models + cursor_chat_id), repos, bootstrap/registration tables. Status machine is Cursor job status, not human work.

Agents cannot assign human work

POST /api/commands requires a controller JWT. That enqueues a Cursor CLI run on the desktop executor and broadcasts command_new (crates/relayer/src/api/routes.rs, docs/WEBSOCKET_PROTOCOL.md).

EXECUTOR_API_KEY can PATCH command output, sync repos/models, and answer file read/search. There is no third principal that may only create operator tasks. Bots that reused the executor key would inherit file and status write. Bots that reused the human JWT would be the operator.

Input cap is 4KB. No idempotency key. No task kinds, priority, estimate, or timer columns.

UI is a capped chat column

apps/web/src/App.tsx routes / → /chat when a JWT exists. Chat.tsx subtitle is Compact mobile control plane. .mobile-frame is max-width: 470px (930px from sm). Login, setup, and add-device use the same frame.

No inbox route, no Start control, no live elapsed clock, no Web App Manifest, no service worker (apps/web/index.html is a Vite shell + vite.svg). package.json has no Vitest/Playwright.

Hardcoded translator/workload defaults in Chat are composer-1.5. Ranking in this issue must not add another pin; use GET /api/models like Chat already does when the list is non-empty.

Auth stays single-human MFA

Setup: bootstrap device key → username/password → TOTP (PLAN.md §10, security_reviews/SECURITY_REVIEW_2025-02.md). JWT + optional refresh grace. Auth routes are rate-limited; other APIs are not. Device key + JWT live in localStorage (apps/web/src/stores/auth.ts).

Existing tests that must stay green: commands_update_rejects_controller_jwt, commands_update_accepts_executor_api_key, relayer db/auth unit tests, shared serde roundtrips.

Why the work is needed

  1. Wrong object. Commands are “run Cursor in a repo.” Operator work (password-manager setup, account provisioning, deploy/config checklists, chain scripts the human runs) is a different lifecycle: assigned → ranked → started → timed → done.
  2. No safe ingest. Other agents have no scoped way to file that work. Sharing the executor key or the human session is over-privilege.
  3. No ordering or duration. The operator cannot see what to do next or how long a card took. Rank/estimate must be stored fields, not chat folklore.
  4. Layout fights the job. A 470px column wastes desktop. Android install needs a real PWA, not “open Chrome.”
  5. Store mismatch. The requested stack is Rust + Postgres + TypeScript. File SQLite cannot be the system of record for a multi-writer assign API (agents + UI + rank pass + timer).

This is inbox + assign API + rank fields + timer + PWA + Postgres. It is not a secrets vault, not a deploy orchestrator, not a chain RPC runner, and not a new host/SKU/model choice.

Constraints / guardrails

  • Single operator. Keep one admin row. No multi-tenant, no extra human roles.
  • Do not pin models, images, SKUs, or hosts. Rank/estimate uses the operator-selected model from the existing cached GET /api/models list (same pattern as Chat). If the list is empty, fail the rank job closed; do not hardcode a model id. Do not add cloud instance types or container images in this ticket.
  • Dedicated Postgres DSN. Relayer reads a product-specific env (e.g. RELAYER_DATABASE_URL) that must be postgres:// / postgresql://. Stop treating generic DATABASE_URL as a sqlite file path. Do not connect to another product’s database. Tests may use a local Postgres via sqlx; production placement is out of scope.
  • Three principals, fail-closed.
    • Controller JWT — human UI: list/get tasks, start/complete/cancel, create assigner tokens, trigger rank, existing chat.
    • Assigner token (hashed at rest, dpa_ prefix or equivalent) — POST tasks + GET own/created tasks only. Never start/complete, never PATCH /api/commands, never file read/search, never repo sync, never WS as executor.
    • Executor key — unchanged Cursor/file path; may PATCH rank fields only on tasks in queued/ranked when the rank command id matches. Cannot start the human timer.
  • Commands ≠ tasks. Do not overload commands rows as human work. New tasks (and task_time_spans if needed). A rank pass may create a command that reads task summaries and writes priority/estimate; it does not mark the human task done.
  • Timer source of truth is the server. POST .../start sets started_at (timestamptz). POST .../complete sets completed_at and duration_ms from server timestamps. Ignore client-supplied elapsed. At most one in_progress task. Second start → 409. Reload resumes from started_at. Completing a non-running task → 409.
  • Not a vault. Kind secrets means “human must configure a secret elsewhere.” Reject create/update bodies that look like PEM, seed phrases, BEGIN OPENSSH, AKIA, ghp_, xox, postgres://user:pass@, or Bearer eyJ. Never persist raw secret material. Never log assigner tokens after mint. Hash tokens (bcrypt/argon2, same family as device keys).
  • Not an executor for ops. Kinds deploy, config, chain_script, account are checklists for the human. This API does not SSH, apply infra, sign txs, or run chain RPCs.
  • Idempotency. Idempotency-Key (UUID or 8–128 char token) on assigner POST /api/tasks. Same key + same principal → same task id. Different body → 409.
  • Caps. Title ≤ 200 chars, body ≤ 8KiB, tags ≤ 16 × 32 chars. Kind enum closed: secrets | account | deploy | config | chain_script | other. Priority 1–9 (1 = do first) after rank; unranked = null. Estimate is integer minutes 1..=480 or null.
  • Injection / XSS. API is JSON only. UI: no dangerouslySetInnerHTML for task body; markdown optional only if sanitized (reuse Chat markdown path or plain text). WS payload via serde structs, not string concat.
  • PWA. Manifest display: standalone, start_url the inbox (authenticated app still hits /login if no JWT). Service worker: static shell only. Never cache /api or /ws. Never put JWT/device key in Cache Storage. theme-color from existing CSS variables.
  • Desktop. From lg (or equivalent): inbox uses the full viewport (no .mobile-frame max-width). Queue + now/detail panes. Touch targets ≥ 44px on coarse pointers. Auth pages may stay narrow.
  • Rate limit assigner POST (burst + refill). Do not leave ingest unbounded.
  • CORS remains allowlist. Assigner is a bearer API; browsers are not the client.
  • Existing security findings. Do not regress JWT-cannot-PATCH-commands. Do not store assigner plaintext in localStorage. Mint shows the token once. Device-key-in-localStorage is a known medium; do not make it worse by persisting assigner keys in the SPA.
  • Do not add Telegram, extra humans, or a public unauthenticated board.

Relevant files

Path Why
crates/relayer/src/config.rs Dedicated Postgres DSN; stop sqlite-path DATABASE_URL
crates/relayer/src/db/mod.rs + migrations.rs sqlx/Postgres; new task/token tables
crates/relayer/src/api/routes.rs Task + assigner routes; keep command PATCH executor-only
crates/relayer/src/auth/mod.rs Assigner bearer verify (hashed); do not overload executor key
crates/relayer/src/relay/mod.rs task_new / task_update WS types
crates/relayer/Cargo.toml sqlx + runtime TLS; drop rusqlite as the product store
crates/shared/src/models.rs Task DTOs, kinds, assigner mint, WS payloads
migrations/*.sql New numbered Postgres migrations (do not edit applied sqlite files in place as if they were PG)
docs/MIGRATION_STRATEGY.md Postgres + sqlite import note
docs/WEBSOCKET_PROTOCOL.md Task events; assigner not on /ws as executor
PLAN.md Inbox + assigner + timer; Chat remains secondary
crates/executor/src/* Rank command: fetch open tasks, PATCH rank fields only
apps/web/index.html + new manifest.webmanifest + SW PWA install
apps/web/src/App.tsx Default /inbox; keep /chat
apps/web/src/index.css Desktop full-bleed vs mobile-frame
apps/web/src/pages/Chat.tsx Do not remain the home surface
apps/web/src/api/* Task + assigner-token client
apps/web/vite.config.ts Proxy /api /ws; PWA assets
apps/web/package.json Vitest + Playwright
security_reviews/SECURITY_REVIEW_2025-02.md Assigner threat notes (no secrets in tasks)
  1. sqlx + Postgres for the relayer. One store for admin, devices, commands, tasks, assigner tokens. Numbered SQL migrations. Startup migrate. Optional one-shot importer: if a legacy sqlite file is configured, copy admin/devices/commands/repos then refuse dual-write. Relayer tests use sqlx’s Postgres test harness. Keep Axum surface.

  2. Schema (illustrative):

    • tasks: id, admin_id, assigner_id nullable, kind, title, body, status (queued|ranked|in_progress|done|cancelled), priority nullable, estimate_minutes nullable, started_at, completed_at, duration_ms, rank_command_id nullable, idempotency_key, created_at, updated_at.
    • assigner_tokens: id, admin_id, name, token_hash, created_at, revoked_at, last_used_at.
    • Unique (admin_id, idempotency_key) where key is not null.
    • Partial unique: one in_progress per admin.
  3. HTTP (controller JWT unless noted):

    • POST /api/assigners → { id, name, token } once.
    • DELETE /api/assigners/{id} revoke.
    • GET /api/tasks filter status/kind; default open (not done/cancelled), order priority ASC NULLS LAST, created_at.
    • POST /api/tasks human-created (same validator as assigner).
    • POST /api/tasks/{id}/start|complete|cancel
    • POST /api/tasks/rank → create a command whose prompt is “rank these open tasks”; executor PATCHes priority + estimate only.
    • Assigner: Authorization: Bearer <token> on POST /api/v1/agent/tasks (and GET created). Same JSON body. Rate-limited. OpenAPI snippet in docs/.
  4. Rank pass. Executor already runs agent -p for commands. Add a template the human/rank endpoint uses. Output JSON { "id": "uuid", "priority": 1-9, "estimate_minutes": n }[]. Relayer validates IDs belong to this admin and are not done. Do not execute task bodies. Model = request/workload selection from /api/models, not a constant.

  5. UI. /inbox three zones on desktop: Now (in_progress + running timer from started_at), Queue (ranked then unranked), Done (recent). Detail pane: body, kind, estimate vs actual, Start / Complete. Mobile: list + detail full screen, sticky Start. Nav: Inbox | Chat | Devices. Login subtitle can stay compact; inbox must not.

  6. PWA. Manifest name/short_name, standalone, icons (maskable + any; SVG or PNG in apps/web/public). Document Android: browser menu → Add to Home Screen. No native store.

Acceptance criteria

  • AC1. Relayer boots on Postgres via dedicated DSN; migrations apply; sqlite is not the product store. Generic DATABASE_URL pointing at a file path is not silently used.
  • AC2. Single admin: assigner POST creates a queued task visible on /inbox over WS or refresh. Title/kind/body persisted. Duplicate Idempotency-Key returns the same id.
  • AC3. Assigner token cannot PATCH /api/commands, cannot start/complete, cannot file read. Executor key cannot mint assigners or start the timer. Controller JWT still cannot PATCH commands (403).
  • AC4. Start → in_progress + started_at. Second start (any task) → 409. Complete → done + duration_ms ≈ server elapsed (tolerance 2s in tests). Client duration_ms in the body is ignored.
  • AC5. Rank endpoint enqueues a command; executor writes priority 1–9 and estimate minutes; UI orders by priority. Empty model list → rank fails closed, no invented model id.
  • AC6. Secret-like bodies rejected (400) for all principals. Kind secrets allowed only as instructions without secret material.
  • AC7. Desktop lg+: inbox uses full viewport (no 470px column). Mobile: usable list + Start, 44px targets. /chat still works.
  • AC8. Manifest + SW installable (standalone). SW does not cache /api or /ws. Logged-out install still reaches login.
  • AC9. Docs: assigner curl, kinds, timer rules, PWA install, Postgres DSN name. PLAN updated. Existing command JWT/executor tests green.
  • AC10. After login, / goes to /inbox, not /chat.

Test plan (functional paths)

# Path Expect
T1 Fresh relayer + Postgres migrate Admin setup + login still work
T2 Sqlite importer (if file present) Admin/devices/commands copied; tasks empty
T3 Human POST /api/tasks kind config Row queued; appears in GET
T4 Assigner POST with Idempotency-Key Replay → same id
T5 Assigner POST same key, different body 409
T6 WS task_new to controller Inbox prepends without full reload
T7 Start then Complete after ~1s duration_ms in 500–3000
T8 Start A, Start B B is 409; A still in_progress
T9 Reload during in_progress Clock continues from started_at
T10 Complete queued (never started) 409
T11 Cancel in_progress cancelled; no duration required; slot free
T12 Rank with models cached Priorities 1–9; queue order changes
T13 Rank with empty models Error; tasks unchanged
T14 GET tasks default Excludes done/cancelled unless ?status=
T15 Login → / Redirect /inbox
T16 /chat send command Existing command flow unchanged
T17 Desktop width ≥ 1024 Inbox not clipped to 470px; both panes visible
T18 Mobile width 390 Queue + detail; Start tappable
T19 PWA manifest standalone, start_url inbox, icons
T20 Assigner GET Sees tasks it created; not other assigner’s private notes if we scope that way (else all admin tasks read-only for assigner — pick one, document, test)
T21 Revoke assigner Next POST 401
T22 Title 201 chars / body 8KiB+ 400

Vitest: kind/secret-scanner, duration math, sort order. Relayer: table-driven HTTP (assigner vs JWT vs executor). Playwright (5 workers): login stub or test admin → inbox start/complete; desktop vs mobile viewports; chat still lists threads. PLAYWRIGHT_SKIP_EXECUTOR=1 OK for UI.

Test plan (attack, hack, and abuse)

# Vector Expect
A1 Assigner PATCH /api/commands/{id} forged done 401/403; row unchanged
A2 Assigner POST .../start 403
A3 Executor POST /api/assigners 401/403
A4 Controller JWT command PATCH Still 403
A5 Body contains PEM / mnemonic / postgres://x:y@ 400; no row
A6 Title HTML <img onerror> Stored escaped; no script exec
A7 WS HTML in title Text node / sanitized markdown only
A8 Client complete {duration_ms: 1} after 60s wall Stored ≈ 60000, not 1
A9 Two concurrent starts (race) One in_progress (DB constraint)
A10 Assigner flood POST 429 after burst
A11 Idempotency-Key: ../../etc/passwd Treated as opaque key or 400; no path IO
A12 Kind deploy auto-runs a script Never; status stays queued until human Start
A13 Assigner file read /api/files/read 401/403
A14 Stolen assigner token after revoke 401
A15 Rank PATCH ids from another admin Impossible (single admin) / ignored if extra ids
A16 SW caches GET /api/tasks Must not; authenticated JSON not in Cache Storage
A17 Open redirect in task body URL Rendered as text/link with rel=noopener; no Navigate to it
A18 SQL in title Parameterized query; 400 or stored as text
A19 Bearer executor key on agent task POST Reject (wrong principal)
A20 Missing CORS origin from random site with stolen JWT Browser CORS fail; API still auth-gated

Verification criteria

  • cargo test --workspace green including new task/auth tests and both existing command PATCH tests.
  • sqlx migrate on empty Postgres; setup/login/create-task/start/complete round-trip (script or verify-issue-*).
  • Playwright: desktop inbox full-width; mobile Start; /chat surviving; installability checks (manifest link, display standalone).
  • Manual: mint assigner token → curl POST task → inbox shows it → Start (clock) → Complete (elapsed stored) → rank (if executor up) reorders. Paste a fake PEM → rejected. Android: Add to Home Screen opens standalone login/inbox.
  • Grep: no hardcoded rank model id; no .mobile-frame on inbox at lg; no dangerouslySetInnerHTML on task body.

Out of scope

  • Multi-user / SSO / IdP.
  • Storing or rotating actual secrets (password manager stays elsewhere).
  • Running deploys, Terraform, chain RPCs, or Cursor implement/review/merge from the inbox.
  • Choosing a hosting provider, VM SKU, container image, or a default LLM id.
  • Replacing Chat; session resume beyond existing cursor_chat_id.
  • Offline-first PWA that queues POSTs.
  • Native Android/iOS apps.
  • Migrating this product into the Forgejo control-plane repository.
## Summary The operator product is a **phone-narrow Cursor chat relay**. It is not an inbox for human work that other agents assign, rank, time, and complete. Bundle (do not split): 1. **Postgres** as the relayer store (replace file-backed rusqlite). 2. **Human task inbox** — one operator; kinds for secrets *setup* (not a vault), accounts, deploys, config, and chain scripts. 3. **Assigner API** — scoped keys so Cursor CLI agents and other bots can create work without executor or controller powers. 4. **Rank / estimate pass** — existing executor path writes priority + duration hint; **do not pin a model**. 5. **Start-task timer** — server clock; persist elapsed on complete. 6. **Dense desktop board + installable PWA** — full viewport on large screens; Add to Home Screen on Android. Chat (`/chat`) stays for Cursor CLI threads. After login, the default surface is the inbox. Related: keep [`PLAN.md`](PLAN.md) single-admin design. Do **not** implement this in the Forgejo control-plane repo (that product is HMAC/gates, not a UI). ## Current codebase ### Relayer is SQLite + command relay, not a task store [`crates/relayer`](crates/relayer) is Axum: auth, devices, `POST/GET /api/commands`, executor-only `PATCH`, repos, file proxy, `/ws`. Persistence is **`rusqlite`** ([`crates/relayer/Cargo.toml`](crates/relayer/Cargo.toml), [`crates/relayer/src/db/mod.rs`](crates/relayer/src/db/mod.rs)). Migrations live in [`migrations/`](migrations/) and run from [`crates/relayer/src/db/migrations.rs`](crates/relayer/src/db/migrations.rs). [`Config::from_env`](crates/relayer/src/config.rs) maps `DATABASE_PATH` **or** `DATABASE_URL` to a **filesystem path** (`trim_start_matches("sqlite:")`, default `./data/relayer.db`). There is no Postgres driver, no sqlx, no task table. Schema today: single `admin` row, `devices` (`executor` | `controller`), `commands` (pending/running/done/failed + Cursor models + `cursor_chat_id`), `repos`, bootstrap/registration tables. Status machine is **Cursor job** status, not human work. ### Agents cannot assign human work `POST /api/commands` requires a **controller JWT**. That enqueues a Cursor CLI run on the desktop executor and broadcasts `command_new` ([`crates/relayer/src/api/routes.rs`](crates/relayer/src/api/routes.rs), [`docs/WEBSOCKET_PROTOCOL.md`](docs/WEBSOCKET_PROTOCOL.md)). `EXECUTOR_API_KEY` can `PATCH` command output, sync repos/models, and answer file read/search. There is **no** third principal that may only create operator tasks. Bots that reused the executor key would inherit file and status write. Bots that reused the human JWT would be the operator. Input cap is 4KB. No idempotency key. No task kinds, priority, estimate, or timer columns. ### UI is a capped chat column [`apps/web/src/App.tsx`](apps/web/src/App.tsx) routes `/` → `/chat` when a JWT exists. [`Chat.tsx`](apps/web/src/pages/Chat.tsx) subtitle is **Compact mobile control plane**. [`.mobile-frame`](apps/web/src/index.css) is `max-width: 470px` (930px from `sm`). Login, setup, and add-device use the same frame. No inbox route, no Start control, no live elapsed clock, no Web App Manifest, no service worker ([`apps/web/index.html`](apps/web/index.html) is a Vite shell + `vite.svg`). [`package.json`](apps/web/package.json) has no Vitest/Playwright. Hardcoded translator/workload defaults in Chat are `composer-1.5`. Ranking in this issue **must not** add another pin; use `GET /api/models` like Chat already does when the list is non-empty. ### Auth stays single-human MFA Setup: bootstrap device key → username/password → TOTP ([`PLAN.md`](PLAN.md) §10, [`security_reviews/SECURITY_REVIEW_2025-02.md`](security_reviews/SECURITY_REVIEW_2025-02.md)). JWT + optional refresh grace. Auth routes are rate-limited; other APIs are not. Device key + JWT live in `localStorage` ([`apps/web/src/stores/auth.ts`](apps/web/src/stores/auth.ts)). Existing tests that must stay green: `commands_update_rejects_controller_jwt`, `commands_update_accepts_executor_api_key`, relayer db/auth unit tests, shared serde roundtrips. ## Why the work is needed 1. **Wrong object.** Commands are “run Cursor in a repo.” Operator work (password-manager setup, account provisioning, deploy/config checklists, chain scripts the human runs) is a different lifecycle: assigned → ranked → started → timed → done. 2. **No safe ingest.** Other agents have no scoped way to file that work. Sharing the executor key or the human session is over-privilege. 3. **No ordering or duration.** The operator cannot see what to do next or how long a card took. Rank/estimate must be stored fields, not chat folklore. 4. **Layout fights the job.** A 470px column wastes desktop. Android install needs a real PWA, not “open Chrome.” 5. **Store mismatch.** The requested stack is Rust + Postgres + TypeScript. File SQLite cannot be the system of record for a multi-writer assign API (agents + UI + rank pass + timer). This is **inbox + assign API + rank fields + timer + PWA + Postgres**. It is not a secrets vault, not a deploy orchestrator, not a chain RPC runner, and not a new host/SKU/model choice. ## Constraints / guardrails - **Single operator.** Keep one `admin` row. No multi-tenant, no extra human roles. - **Do not pin models, images, SKUs, or hosts.** Rank/estimate uses the **operator-selected** model from the existing cached `GET /api/models` list (same pattern as Chat). If the list is empty, fail the rank job closed; do not hardcode a model id. Do not add cloud instance types or container images in this ticket. - **Dedicated Postgres DSN.** Relayer reads a **product-specific** env (e.g. `RELAYER_DATABASE_URL`) that must be `postgres://` / `postgresql://`. Stop treating generic `DATABASE_URL` as a sqlite file path. Do not connect to another product’s database. Tests may use a local Postgres via sqlx; production placement is out of scope. - **Three principals, fail-closed.** - **Controller JWT** — human UI: list/get tasks, start/complete/cancel, create assigner tokens, trigger rank, existing chat. - **Assigner token** (hashed at rest, `dpa_` prefix or equivalent) — `POST` tasks + `GET` own/created tasks only. **Never** start/complete, **never** `PATCH /api/commands`, **never** file read/search, **never** repo sync, **never** WS as executor. - **Executor key** — unchanged Cursor/file path; may `PATCH` rank fields **only** on tasks in `queued`/`ranked` when the rank command id matches. Cannot start the human timer. - **Commands ≠ tasks.** Do not overload `commands` rows as human work. New `tasks` (and `task_time_spans` if needed). A rank pass may *create* a command that reads task summaries and writes priority/estimate; it does not mark the human task `done`. - **Timer source of truth is the server.** `POST .../start` sets `started_at` (timestamptz). `POST .../complete` sets `completed_at` and `duration_ms` from server timestamps. Ignore client-supplied elapsed. **At most one** `in_progress` task. Second start → `409`. Reload resumes from `started_at`. Completing a non-running task → `409`. - **Not a vault.** Kind `secrets` means “human must configure a secret elsewhere.” Reject create/update bodies that look like PEM, seed phrases, `BEGIN OPENSSH`, `AKIA`, `ghp_`, `xox`, `postgres://user:pass@`, or `Bearer eyJ`. Never persist raw secret material. Never log assigner tokens after mint. Hash tokens (bcrypt/argon2, same family as device keys). - **Not an executor for ops.** Kinds `deploy`, `config`, `chain_script`, `account` are **checklists for the human**. This API does not SSH, apply infra, sign txs, or run chain RPCs. - **Idempotency.** `Idempotency-Key` (UUID or 8–128 char token) on assigner `POST /api/tasks`. Same key + same principal → same task id. Different body → `409`. - **Caps.** Title ≤ 200 chars, body ≤ 8KiB, tags ≤ 16 × 32 chars. Kind enum closed: `secrets | account | deploy | config | chain_script | other`. Priority 1–9 (1 = do first) after rank; unranked = `null`. Estimate is integer minutes `1..=480` or null. - **Injection / XSS.** API is JSON only. UI: no `dangerouslySetInnerHTML` for task body; markdown optional only if sanitized (reuse Chat markdown path or plain text). WS payload via `serde` structs, not string concat. - **PWA.** Manifest `display: standalone`, `start_url` the inbox (authenticated app still hits `/login` if no JWT). Service worker: **static shell only**. Never cache `/api` or `/ws`. Never put JWT/device key in Cache Storage. `theme-color` from existing CSS variables. - **Desktop.** From `lg` (or equivalent): inbox uses the **full viewport** (no `.mobile-frame` max-width). Queue + now/detail panes. Touch targets ≥ 44px on coarse pointers. Auth pages may stay narrow. - **Rate limit** assigner POST (burst + refill). Do not leave ingest unbounded. - **CORS** remains allowlist. Assigner is a bearer API; browsers are not the client. - **Existing security findings.** Do not regress JWT-cannot-PATCH-commands. Do not store assigner plaintext in `localStorage`. Mint shows the token **once**. Device-key-in-localStorage is a known medium; do not make it worse by persisting assigner keys in the SPA. - **Do not** add Telegram, extra humans, or a public unauthenticated board. ## Relevant files | Path | Why | |------|-----| | `crates/relayer/src/config.rs` | Dedicated Postgres DSN; stop sqlite-path `DATABASE_URL` | | `crates/relayer/src/db/mod.rs` + `migrations.rs` | sqlx/Postgres; new task/token tables | | `crates/relayer/src/api/routes.rs` | Task + assigner routes; keep command PATCH executor-only | | `crates/relayer/src/auth/mod.rs` | Assigner bearer verify (hashed); do not overload executor key | | `crates/relayer/src/relay/mod.rs` | `task_new` / `task_update` WS types | | `crates/relayer/Cargo.toml` | sqlx + runtime TLS; drop rusqlite as the product store | | `crates/shared/src/models.rs` | Task DTOs, kinds, assigner mint, WS payloads | | `migrations/*.sql` | New numbered Postgres migrations (do not edit applied sqlite files in place as if they were PG) | | `docs/MIGRATION_STRATEGY.md` | Postgres + sqlite import note | | `docs/WEBSOCKET_PROTOCOL.md` | Task events; assigner not on `/ws` as executor | | `PLAN.md` | Inbox + assigner + timer; Chat remains secondary | | `crates/executor/src/*` | Rank command: fetch open tasks, PATCH rank fields only | | `apps/web/index.html` + new `manifest.webmanifest` + SW | PWA install | | `apps/web/src/App.tsx` | Default `/inbox`; keep `/chat` | | `apps/web/src/index.css` | Desktop full-bleed vs mobile-frame | | `apps/web/src/pages/Chat.tsx` | Do not remain the home surface | | `apps/web/src/api/*` | Task + assigner-token client | | `apps/web/vite.config.ts` | Proxy `/api` `/ws`; PWA assets | | `apps/web/package.json` | Vitest + Playwright | | `security_reviews/SECURITY_REVIEW_2025-02.md` | Assigner threat notes (no secrets in tasks) | ## Recommended direction 1. **sqlx + Postgres for the relayer.** One store for admin, devices, commands, tasks, assigner tokens. Numbered SQL migrations. Startup migrate. Optional **one-shot** importer: if a legacy sqlite file is configured, copy admin/devices/commands/repos then refuse dual-write. Relayer tests use sqlx’s Postgres test harness. Keep Axum surface. 2. **Schema (illustrative):** - `tasks`: id, admin_id, assigner_id nullable, kind, title, body, status (`queued|ranked|in_progress|done|cancelled`), priority nullable, estimate_minutes nullable, started_at, completed_at, duration_ms, rank_command_id nullable, idempotency_key, created_at, updated_at. - `assigner_tokens`: id, admin_id, name, token_hash, created_at, revoked_at, last_used_at. - Unique (admin_id, idempotency_key) where key is not null. - Partial unique: one `in_progress` per admin. 3. **HTTP (controller JWT unless noted):** - `POST /api/assigners` → `{ id, name, token }` once. - `DELETE /api/assigners/{id}` revoke. - `GET /api/tasks` filter status/kind; default open (not done/cancelled), order priority ASC NULLS LAST, created_at. - `POST /api/tasks` human-created (same validator as assigner). - `POST /api/tasks/{id}/start|complete|cancel` - `POST /api/tasks/rank` → create a **command** whose prompt is “rank these open tasks”; executor PATCHes priority + estimate only. - Assigner: `Authorization: Bearer <token>` on `POST /api/v1/agent/tasks` (and GET created). Same JSON body. Rate-limited. OpenAPI snippet in `docs/`. 4. **Rank pass.** Executor already runs `agent -p` for commands. Add a template the human/rank endpoint uses. Output JSON `{ "id": "uuid", "priority": 1-9, "estimate_minutes": n }[]`. Relayer validates IDs belong to this admin and are not `done`. Do not execute task bodies. Model = request/workload selection from `/api/models`, not a constant. 5. **UI.** `/inbox` three zones on desktop: **Now** (in_progress + running timer from `started_at`), **Queue** (ranked then unranked), **Done** (recent). Detail pane: body, kind, estimate vs actual, Start / Complete. Mobile: list + detail full screen, sticky Start. Nav: Inbox | Chat | Devices. Login subtitle can stay compact; inbox must not. 6. **PWA.** Manifest name/short_name, `standalone`, icons (maskable + any; SVG or PNG in `apps/web/public`). Document Android: browser menu → Add to Home Screen. No native store. ## Acceptance criteria - [ ] **AC1.** Relayer boots on Postgres via dedicated DSN; migrations apply; sqlite is not the product store. Generic `DATABASE_URL` pointing at a file path is not silently used. - [ ] **AC2.** Single admin: assigner `POST` creates a `queued` task visible on `/inbox` over WS or refresh. Title/kind/body persisted. Duplicate `Idempotency-Key` returns the same id. - [ ] **AC3.** Assigner token cannot `PATCH /api/commands`, cannot start/complete, cannot file read. Executor key cannot mint assigners or start the timer. Controller JWT still cannot `PATCH` commands (`403`). - [ ] **AC4.** Start → `in_progress` + `started_at`. Second start (any task) → `409`. Complete → `done` + `duration_ms` ≈ server elapsed (tolerance 2s in tests). Client `duration_ms` in the body is ignored. - [ ] **AC5.** Rank endpoint enqueues a command; executor writes priority 1–9 and estimate minutes; UI orders by priority. Empty model list → rank fails closed, no invented model id. - [ ] **AC6.** Secret-like bodies rejected (`400`) for all principals. Kind `secrets` allowed only as instructions without secret material. - [ ] **AC7.** Desktop `lg+`: inbox uses full viewport (no 470px column). Mobile: usable list + Start, 44px targets. `/chat` still works. - [ ] **AC8.** Manifest + SW installable (standalone). SW does not cache `/api` or `/ws`. Logged-out install still reaches login. - [ ] **AC9.** Docs: assigner curl, kinds, timer rules, PWA install, Postgres DSN name. PLAN updated. Existing command JWT/executor tests green. - [ ] **AC10.** After login, `/` goes to `/inbox`, not `/chat`. ## Test plan (functional paths) | # | Path | Expect | |---|------|--------| | T1 | Fresh relayer + Postgres migrate | Admin setup + login still work | | T2 | Sqlite importer (if file present) | Admin/devices/commands copied; tasks empty | | T3 | Human `POST /api/tasks` kind `config` | Row `queued`; appears in GET | | T4 | Assigner POST with Idempotency-Key | Replay → same id | | T5 | Assigner POST same key, different body | `409` | | T6 | WS `task_new` to controller | Inbox prepends without full reload | | T7 | Start then Complete after ~1s | `duration_ms` in 500–3000 | | T8 | Start A, Start B | B is `409`; A still `in_progress` | | T9 | Reload during in_progress | Clock continues from `started_at` | | T10 | Complete queued (never started) | `409` | | T11 | Cancel in_progress | `cancelled`; no duration required; slot free | | T12 | Rank with models cached | Priorities 1–9; queue order changes | | T13 | Rank with empty models | Error; tasks unchanged | | T14 | GET tasks default | Excludes `done`/`cancelled` unless `?status=` | | T15 | Login → `/` | Redirect `/inbox` | | T16 | `/chat` send command | Existing command flow unchanged | | T17 | Desktop width ≥ 1024 | Inbox not clipped to 470px; both panes visible | | T18 | Mobile width 390 | Queue + detail; Start tappable | | T19 | PWA manifest | `standalone`, start_url inbox, icons | | T20 | Assigner GET | Sees tasks it created; not other assigner’s private notes if we scope that way (else all admin tasks read-only for assigner — pick one, document, test) | | T21 | Revoke assigner | Next POST `401` | | T22 | Title 201 chars / body 8KiB+ | `400` | Vitest: kind/secret-scanner, duration math, sort order. Relayer: table-driven HTTP (assigner vs JWT vs executor). Playwright (5 workers): login stub or test admin → inbox start/complete; desktop vs mobile viewports; chat still lists threads. `PLAYWRIGHT_SKIP_EXECUTOR=1` OK for UI. ## Test plan (attack, hack, and abuse) | # | Vector | Expect | |---|--------|--------| | A1 | Assigner `PATCH /api/commands/{id}` forged done | `401`/`403`; row unchanged | | A2 | Assigner `POST .../start` | `403` | | A3 | Executor `POST /api/assigners` | `401`/`403` | | A4 | Controller JWT command PATCH | Still `403` | | A5 | Body contains PEM / mnemonic / `postgres://x:y@` | `400`; no row | | A6 | Title HTML `<img onerror>` | Stored escaped; no script exec | | A7 | WS HTML in title | Text node / sanitized markdown only | | A8 | Client complete `{duration_ms: 1}` after 60s wall | Stored ≈ 60000, not 1 | | A9 | Two concurrent starts (race) | One `in_progress` (DB constraint) | | A10 | Assigner flood POST | 429 after burst | | A11 | `Idempotency-Key: ../../etc/passwd` | Treated as opaque key or `400`; no path IO | | A12 | Kind `deploy` auto-runs a script | Never; status stays queued until human Start | | A13 | Assigner file read `/api/files/read` | `401`/`403` | | A14 | Stolen assigner token after revoke | `401` | | A15 | Rank PATCH ids from another admin | Impossible (single admin) / ignored if extra ids | | A16 | SW caches `GET /api/tasks` | Must not; authenticated JSON not in Cache Storage | | A17 | Open redirect in task `body` URL | Rendered as text/link with `rel=noopener`; no `Navigate` to it | | A18 | SQL in title | Parameterized query; 400 or stored as text | | A19 | Bearer executor key on agent task POST | Reject (wrong principal) | | A20 | Missing CORS origin from random site with stolen JWT | Browser CORS fail; API still auth-gated | ## Verification criteria - `cargo test --workspace` green including new task/auth tests and **both** existing command PATCH tests. - sqlx migrate on empty Postgres; setup/login/create-task/start/complete round-trip (script or `verify-issue-*`). - Playwright: desktop inbox full-width; mobile Start; `/chat` surviving; installability checks (`manifest` link, `display` standalone). - Manual: mint assigner token → `curl` POST task → inbox shows it → Start (clock) → Complete (elapsed stored) → rank (if executor up) reorders. Paste a fake PEM → rejected. Android: Add to Home Screen opens standalone login/inbox. - Grep: no hardcoded rank model id; no `.mobile-frame` on inbox at `lg`; no `dangerouslySetInnerHTML` on task body. ## Out of scope - Multi-user / SSO / IdP. - Storing or rotating actual secrets (password manager stays elsewhere). - Running deploys, Terraform, chain RPCs, or Cursor implement/review/merge from the inbox. - Choosing a hosting provider, VM SKU, container image, or a default LLM id. - Replacing Chat; session resume beyond existing `cursor_chat_id`. - Offline-first PWA that queues POSTs. - Native Android/iOS apps. - Migrating this product into the Forgejo control-plane repository.

Migrated to PlasticDigits/cl8y-pm#1.

code/dev_pm_agent is archival only; live PM/workflow assignment work continues in PlasticDigits/cl8y-pm.

Migrated to **[`PlasticDigits/cl8y-pm#1`](https://git.cl8y.com/PlasticDigits/cl8y-pm/issues/1)**. `code/dev_pm_agent` is archival only; live PM/workflow assignment work continues in `PlasticDigits/cl8y-pm`.
Sign in to join this conversation.
No milestone
No project
No assignees
2 participants
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
code/dev_pm_agent#1
No description provided.