diff --git a/.env.example b/.env.example index bc68e3b..9555d02 100644 --- a/.env.example +++ b/.env.example @@ -12,6 +12,12 @@ AUTH_TOKEN=change-me-to-a-long-random-string # Falls back to AUTH_TOKEN if unset. # SHARED_CONTEXT_WRITE_TOKEN= +# Optional admin token gating the web control surface: enqueuing control commands +# (spawn/kill/resume/approve/reject/forge-init/poll-ci), project CRUD, forge-host CRUD, +# and credential-pointer edits. Falls back to AUTH_TOKEN if unset. Give operators this +# token in the dashboard to unlock management actions. +# ADMIN_TOKEN= + # Optional generic webhook target for the Notification hook (ntfy, Pushover, Slack, ...). # Fully bring-your-own; the Notification hook is a no-op when unset. # WEBHOOK_URL=https://ntfy.sh/my-topic diff --git a/Dockerfile.control b/Dockerfile.control index bdb2389..4077c92 100644 --- a/Dockerfile.control +++ b/Dockerfile.control @@ -59,7 +59,8 @@ VOLUME /var/lib/handler HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \ CMD handler list >/dev/null 2>&1 || exit 1 -# Default to the CI poller — the one long-running control process. Override the command -# for one-shot control operations, e.g. `docker compose run --rm control handler list`. +# Default to the worker: it drains the control-command queue the API enqueues +# (spawn/kill/resume/approve/…) and sweeps CI on an interval (subsuming `poll-ci --watch`). +# Override for one-shot control operations, e.g. `docker compose run --rm control handler list`. ENTRYPOINT ["docker-entrypoint.sh"] -CMD ["handler", "poll-ci", "--watch"] +CMD ["handler", "worker"] diff --git a/README.md b/README.md index 90675e4..a013270 100644 --- a/README.md +++ b/README.md @@ -101,6 +101,7 @@ Configuration is entirely environment-driven (see [`.env.example`](.env.example) | `DATABASE_URL` | `sqlite:////abs/path.db` or `postgresql+psycopg://…` | `sqlite:///./handler.db` | | `AUTH_TOKEN` | Global bearer token gating every API route | *(required for the API)* | | `SHARED_CONTEXT_WRITE_TOKEN` | Higher-trust token gating `PUT /shared/context/:key` | falls back to `AUTH_TOKEN` | +| `ADMIN_TOKEN` | Gates the web control surface (enqueue commands, project/host CRUD, credential edits) | falls back to `AUTH_TOKEN` | | `WEBHOOK_URL` | Generic target for the `Notification` hook (ntfy, Slack, …) | unset → no-op | | `PROJECTS_ROOT` | Base dir for per-project roots / worktrees | `./projects` | | `CLAUDE_BIN` / `MISE_BIN` / `TMUX_BIN` / `FORGE_BIN` / `GIT_BIN` | Binary overrides | `claude` / `mise` / `tmux` / `forge` / `git` | @@ -151,27 +152,71 @@ the `/var/lib/handler` data volume: | Image | Dockerfile | Runs | Workflow | |---|---|---|---| | `ghcr.io/0xwheatyz/handler` | [`Dockerfile`](Dockerfile) | the API (`uvicorn`) — also applies migrations on start | [`docker.yml`](.github/workflows/docker.yml) | -| `ghcr.io/0xwheatyz/handler/control` | [`Dockerfile.control`](Dockerfile.control) | the control layer (`handler poll-ci --watch`) | [`docker-control.yml`](.github/workflows/docker-control.yml) | +| `ghcr.io/0xwheatyz/handler/control` | [`Dockerfile.control`](Dockerfile.control) | the control worker (`handler worker`) | [`docker-control.yml`](.github/workflows/docker-control.yml) | The control image bakes in `git` + `tmux`; the `claude` and `forge` binaries are bring-your-own (layer or mount them in for live agent spawning — the CI poller degrades -gracefully without `forge`). +gracefully without `forge`). The **worker** drains the control-command queue the API +enqueues (spawn/kill/resume/approve/reject/forge-init/poll-ci) and sweeps CI on an interval +(subsuming `poll-ci --watch`), so the whole system is drivable from the dashboard — see +[Web management](#web-management). [`docker-compose.yml`](docker-compose.yml) wires both up with Postgres. The API owns migrations, so the control service runs with `RUN_MIGRATIONS=false` and waits for the API: ```bash export AUTH_TOKEN="$(openssl rand -hex 32)" -docker compose up -d # db + api + control (CI poller) +export ADMIN_TOKEN="$(openssl rand -hex 32)" # unlocks management actions in the dashboard +docker compose up -d # db + api + control (worker) # One-shot control commands run against the same image: docker compose run --rm control handler list docker compose run --rm control handler spawn --project leeworks-api --name junior --task "…" ``` +## Web management + +The dashboard (and the API under it) manages everything — git credentials & hosts, +projects, agents, and approvals — without dropping to the CLI. Because the API and control +layer are **separate containers** (the API has no `git`/`tmux`/`claude` and doesn't own the +tmux sessions), the API can't run control actions directly. Instead it **enqueues a command** +and the worker in the control container executes it and writes the result back: + +``` + Dashboard ──HTTP──▶ API (read + enqueue) Control container + │ writes a `commands` row │ worker: claim → dispatch → result + ▼ ▼ + ┌─────────────── shared database ───────────────┐ + │ projects agents approvals commands hosts │ + └────────────────────────────────────────────────┘ +``` + +What the dashboard can now do (all state-changing actions require `ADMIN_TOKEN`): + +- **Projects** — create / edit / delete (`root_dir`, `git_remote`, `credential_ref`). +- **Agents** — spawn (name, role, worktree/subdir, task) and kill via the queue; delete the + row; plus the existing checkmark / log / answer-resume views. +- **Approvals** — record an operator verdict per branch (approve/reject); the deploy gate + treats an operator verdict as a genuine second party (no self-approval). +- **Forge hosts** — a registry mapping a host to the token env var to inject at spawn, so + self-hosted forges work without a code change (the built-in host map is the fallback). +- **Credentials** — manage a project's `credential_ref` **pointer**. The DB still never + stores a raw token: web-settable schemes are `env:` / `file:` / `db:` (the `cmd:` scheme + is CLI-only, since it would run an arbitrary command in the control container). `db:` is + reserved for a future encrypted secret store. +- **Activity** — every enqueued command with its status (queued → running → done/failed) — + the audit log of what the dashboard triggered. The UI polls `GET /commands/{id}` for + live status. + +The command queue is exposed over HTTP as `POST …/agents/spawn`, `POST …/agents/{n}/kill`, +`POST …/approvals`, `POST …/forge-init`, `POST …/poll-ci`, and `GET /commands[/{id}]`; +hosts as `/hosts`; project mutation as `PATCH`/`DELETE /projects/{id}`. Run the worker with +`handler worker` (the control image's default command). + ## Control CLI -The `handler` command manages agent processes (the write side): +The `handler` command manages agent processes directly (an alternative to the queue, for +operators at a shell): ```bash handler spawn --project leeworks-api --name junior --role junior --worktree feat/auth --task "add login" diff --git a/docker-compose.yml b/docker-compose.yml index 1714a1b..73df539 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -22,12 +22,14 @@ services: condition: service_healthy restart: unless-stopped - # Control layer: the `handler` CLI running the CI poller loop. Shares the database and - # the handler-data volume with the API. It waits for the API (which owns migrations), - # so RUN_MIGRATIONS is off here to avoid a startup race. Run one-shot control commands - # against the same image with, e.g., `docker compose run --rm control handler list`. - # Live agent spawning also needs `git`/`tmux` (baked in) plus bring-your-own - # `claude`/`forge` binaries — layer or mount those in. + # Control layer: the `handler` worker. Drains the control-command queue the API enqueues + # (spawn/kill/resume/approve/reject/forge-init/poll-ci) and sweeps CI on an interval. + # Shares the database and the handler-data volume with the API. It waits for the API + # (which owns migrations), so RUN_MIGRATIONS is off here to avoid a startup race. Run + # one-shot control commands against the same image with, e.g., + # `docker compose run --rm control handler list`. Live agent spawning also needs + # `git`/`tmux` (baked in) plus bring-your-own `claude`/`forge` binaries — layer or mount + # those in. control: image: ghcr.io/0xwheatyz/handler/control:latest build: diff --git a/src/handler/api/app.py b/src/handler/api/app.py index a75a55d..cdfcda2 100644 --- a/src/handler/api/app.py +++ b/src/handler/api/app.py @@ -15,7 +15,7 @@ from fastapi.responses import FileResponse from fastapi.staticfiles import StaticFiles from ..config import get_settings -from .routes import agents, interaction, projects, shared +from .routes import agents, approvals, commands, hosts, interaction, projects, shared _STATIC_DIR = Path(__file__).parent / "static" @@ -36,6 +36,9 @@ def create_app() -> FastAPI: app.include_router(projects.router) app.include_router(agents.router) app.include_router(interaction.router) + app.include_router(approvals.router) + app.include_router(commands.router) + app.include_router(hosts.router) app.include_router(shared.router) # Optional CORS, only for operators who host the UI on a different origin than the @@ -46,7 +49,7 @@ def create_app() -> FastAPI: app.add_middleware( CORSMiddleware, allow_origins=settings.cors_origin_list, - allow_methods=["GET", "POST", "PUT"], + allow_methods=["GET", "POST", "PUT", "PATCH", "DELETE"], allow_headers=["Authorization", "Content-Type"], ) diff --git a/src/handler/api/deps.py b/src/handler/api/deps.py index 73831e2..c2b33d2 100644 --- a/src/handler/api/deps.py +++ b/src/handler/api/deps.py @@ -36,11 +36,13 @@ def require_auth( settings: Settings = Depends(get_settings), ) -> None: token = creds.credentials if creds else None - # The shared-context write token is higher-trust, so it also grants normal access; - # a single request carries one bearer, and it should never be rejected for being the - # more privileged one. - valid = _check(token, settings.auth_token) or _check( - token, settings.effective_shared_write_token + # The shared-context write and admin tokens are higher-trust, so they also grant + # normal access; a single request carries one bearer, and it should never be rejected + # for being the more privileged one. + valid = ( + _check(token, settings.auth_token) + or _check(token, settings.effective_shared_write_token) + or _check(token, settings.effective_admin_token) ) if not valid: raise HTTPException( @@ -62,3 +64,19 @@ def require_shared_write( detail="shared-context write requires the shared-context write token", headers={"WWW-Authenticate": "Bearer"}, ) + + +def require_admin( + creds: HTTPAuthorizationCredentials | None = Depends(_bearer), + settings: Settings = Depends(get_settings), +) -> None: + """Gate for the web control surface: enqueuing control commands, project/host CRUD, + and credential-pointer edits. Requires specifically the admin token (which defaults to + the global token when ADMIN_TOKEN is unset).""" + token = creds.credentials if creds else None + if not _check(token, settings.effective_admin_token): + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="this action requires the admin token", + headers={"WWW-Authenticate": "Bearer"}, + ) diff --git a/src/handler/api/routes/agents.py b/src/handler/api/routes/agents.py index 2fc27d0..7de33e3 100644 --- a/src/handler/api/routes/agents.py +++ b/src/handler/api/routes/agents.py @@ -1,8 +1,9 @@ -"""Agent listing/registration and the read views (checkmark, log). +"""Agent listing/registration, read views (checkmark, log), and lifecycle actions. -The agent *row* is registered here (the API mirror listed in README 3.3); the agent -*process* is spawned by the control CLI. All routes are nested under -``/projects/{project}`` so nothing crosses a project boundary. +The agent *row* is registered here; the agent *process* (tmux + claude) is created by the +control worker, so ``spawn``/``kill`` enqueue a command (admin-gated) rather than acting +in-process — the API container has no tmux/git/claude and does not own the sessions. All +routes are nested under ``/projects/{project}`` so nothing crosses a project boundary. """ from __future__ import annotations @@ -12,8 +13,8 @@ from sqlalchemy import Connection from sqlalchemy.exc import IntegrityError from ...db import repository as repo -from ..deps import db_conn, require_auth -from ..schemas import AgentIn, AgentOut, CheckmarkOut, LogEntryOut +from ..deps import db_conn, require_admin, require_auth +from ..schemas import AgentIn, AgentOut, CheckmarkOut, CommandOut, LogEntryOut, SpawnIn from .common import resolve_agent router = APIRouter( @@ -49,11 +50,58 @@ def create_agent(project: str, body: AgentIn, conn: Connection = Depends(db_conn name=body.name, working_dir=body.working_dir, status=body.status, + role=body.role, ) except IntegrityError as exc: # pragma: no cover - guarded above raise HTTPException(status.HTTP_409_CONFLICT, detail="agent exists") from exc +@router.post( + "/spawn", + response_model=CommandOut, + status_code=status.HTTP_202_ACCEPTED, + dependencies=[Depends(require_admin)], +) +def enqueue_spawn(project: str, body: SpawnIn, conn: Connection = Depends(db_conn)) -> dict: + """Enqueue a spawn; the worker creates the agent row + tmux session and reports back.""" + _require_project(conn, project) + if repo.get_agent_by_name(conn, project, body.name) is not None: + raise HTTPException( + status.HTTP_409_CONFLICT, + detail=f"agent '{body.name}' already exists in project '{project}'", + ) + payload = body.model_dump(exclude={"name"}, exclude_none=True) + return repo.enqueue_command( + conn, + "spawn", + project_id=project, + agent_name=body.name, + payload=payload, + requested_by="operator:web", + ) + + +@router.post( + "/{name}/kill", + response_model=CommandOut, + status_code=status.HTTP_202_ACCEPTED, + dependencies=[Depends(require_admin)], +) +def enqueue_kill(project: str, name: str, conn: Connection = Depends(db_conn)) -> dict: + resolve_agent(conn, project, name) + return repo.enqueue_command( + conn, "kill", project_id=project, agent_name=name, requested_by="operator:web" + ) + + +@router.delete("/{name}", dependencies=[Depends(require_admin)]) +def delete_agent(project: str, name: str, conn: Connection = Depends(db_conn)) -> dict: + """Remove the agent row (does not kill a live session — kill first).""" + resolve_agent(conn, project, name) + repo.delete_agent(conn, project, name) + return {"deleted": name} + + @router.get("/{name}/checkmark", response_model=CheckmarkOut) def get_checkmark(project: str, name: str, conn: Connection = Depends(db_conn)) -> dict: agent = resolve_agent(conn, project, name) diff --git a/src/handler/api/routes/approvals.py b/src/handler/api/routes/approvals.py new file mode 100644 index 0000000..5aa607b --- /dev/null +++ b/src/handler/api/routes/approvals.py @@ -0,0 +1,65 @@ +"""Branch approvals — read the standing verdicts, enqueue new ones. + +Recording a verdict resolves the reviewed HEAD sha (which requires the working tree in the +control container), so ``POST`` enqueues an ``approve``/``reject`` command for the worker. +Operator verdicts set ``actor='operator:web'`` and no acting agent, which the deploy gate +treats as a genuine second party (satisfying the "no self-approval" rule). +""" + +from __future__ import annotations + +from fastapi import APIRouter, Depends, HTTPException, Query, status +from sqlalchemy import Connection + +from ...db import repository as repo +from ..deps import db_conn, require_admin, require_auth +from ..schemas import ApprovalIn, ApprovalOut, CommandOut + +router = APIRouter( + prefix="/projects/{project}/approvals", + tags=["approvals"], + dependencies=[Depends(require_auth)], +) + + +def _require_project(conn: Connection, project: str) -> None: + if repo.get_project(conn, project) is None: + raise HTTPException(status.HTTP_404_NOT_FOUND, detail=f"project '{project}' not found") + + +@router.get("", response_model=list[ApprovalOut]) +def list_approvals( + project: str, + branch: str | None = Query(None), + conn: Connection = Depends(db_conn), +) -> list[dict]: + _require_project(conn, project) + return repo.list_approvals(conn, project, branch=branch) + + +@router.post( + "", + response_model=CommandOut, + status_code=status.HTTP_202_ACCEPTED, + dependencies=[Depends(require_admin)], +) +def enqueue_approval( + project: str, body: ApprovalIn, conn: Connection = Depends(db_conn) +) -> dict: + _require_project(conn, project) + payload = { + "branch": body.branch, + "sha": body.sha, + "pr": body.pr, + "note": body.note, + } + # Verdict ('approved'/'rejected') -> command type ('approve'/'reject'). + command_type = "approve" if body.status == "approved" else "reject" + return repo.enqueue_command( + conn, + command_type, + project_id=project, + agent_name=body.agent_name, + payload={k: v for k, v in payload.items() if v is not None}, + requested_by="operator:web", + ) diff --git a/src/handler/api/routes/commands.py b/src/handler/api/routes/commands.py new file mode 100644 index 0000000..697f2c9 --- /dev/null +++ b/src/handler/api/routes/commands.py @@ -0,0 +1,47 @@ +"""The command queue's read surface + the global poll-ci enqueue. + +Every control action the dashboard triggers becomes a ``commands`` row; these routes let +the UI poll a command's status (queued -> running -> done/failed) and show an activity log. +Enqueuing project-scoped actions lives with those resources (agents/projects/approvals); +the one non-scoped action, a global CI sweep, is enqueued here. +""" + +from __future__ import annotations + +from fastapi import APIRouter, Depends, HTTPException, Query, status +from sqlalchemy import Connection + +from ...db import repository as repo +from ..deps import db_conn, require_admin, require_auth +from ..schemas import CommandOut + +router = APIRouter(tags=["commands"], dependencies=[Depends(require_auth)]) + + +@router.get("/commands", response_model=list[CommandOut]) +def list_commands( + project: str | None = Query(None), + limit: int = Query(100, ge=1, le=500), + offset: int = Query(0, ge=0), + conn: Connection = Depends(db_conn), +) -> list[dict]: + return repo.list_commands(conn, project_id=project, limit=limit, offset=offset) + + +@router.get("/commands/{command_id}", response_model=CommandOut) +def get_command(command_id: int, conn: Connection = Depends(db_conn)) -> dict: + command = repo.get_command(conn, command_id) + if command is None: + raise HTTPException(status.HTTP_404_NOT_FOUND, detail=f"command {command_id} not found") + return command + + +@router.post( + "/poll-ci", + response_model=CommandOut, + status_code=status.HTTP_202_ACCEPTED, + dependencies=[Depends(require_admin)], +) +def enqueue_global_poll_ci(conn: Connection = Depends(db_conn)) -> dict: + """Enqueue a CI sweep across every project (per-project sweep is on the project route).""" + return repo.enqueue_command(conn, "poll_ci", requested_by="operator:web") diff --git a/src/handler/api/routes/hosts.py b/src/handler/api/routes/hosts.py new file mode 100644 index 0000000..92d4d02 --- /dev/null +++ b/src/handler/api/routes/hosts.py @@ -0,0 +1,70 @@ +"""Forge-host registry — the web-managed replacement for the hardcoded host->token-env map. + +Registering a host lets ``control.credentials`` inject the right per-host token env var +(and scope the git credential helper) for self-hosted forges without a code change. The +registry only holds the env-var *name* and metadata — never a secret (secrets stay behind +``credential_ref`` pointers). Reads take the normal token; writes take the admin token. +""" + +from __future__ import annotations + +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy import Connection +from sqlalchemy.exc import IntegrityError + +from ...db import repository as repo +from ..deps import db_conn, require_admin, require_auth +from ..schemas import HostIn, HostOut, HostUpdateIn + +router = APIRouter(prefix="/hosts", tags=["hosts"], dependencies=[Depends(require_auth)]) + + +def _get_or_404(conn: Connection, hostname: str) -> dict: + host = repo.get_host(conn, hostname) + if host is None: + raise HTTPException(status.HTTP_404_NOT_FOUND, detail=f"host '{hostname}' not found") + return host + + +@router.get("", response_model=list[HostOut]) +def list_hosts(conn: Connection = Depends(db_conn)) -> list[dict]: + return repo.list_hosts(conn) + + +@router.get("/{hostname}", response_model=HostOut) +def get_host(hostname: str, conn: Connection = Depends(db_conn)) -> dict: + return _get_or_404(conn, hostname) + + +@router.post( + "", response_model=HostOut, status_code=status.HTTP_201_CREATED, + dependencies=[Depends(require_admin)], +) +def create_host(body: HostIn, conn: Connection = Depends(db_conn)) -> dict: + if repo.get_host(conn, body.hostname) is not None: + raise HTTPException(status.HTTP_409_CONFLICT, detail=f"host '{body.hostname}' exists") + try: + return repo.create_host( + conn, + hostname=body.hostname, + forge_type=body.forge_type, + token_env_var=body.token_env_var, + base_url=body.base_url, + ) + except IntegrityError as exc: # pragma: no cover - guarded above + raise HTTPException(status.HTTP_409_CONFLICT, detail="host exists") from exc + + +@router.patch("/{hostname}", response_model=HostOut, dependencies=[Depends(require_admin)]) +def update_host( + hostname: str, body: HostUpdateIn, conn: Connection = Depends(db_conn) +) -> dict: + _get_or_404(conn, hostname) + return repo.update_host(conn, hostname, **body.model_dump(exclude_unset=True)) + + +@router.delete("/{hostname}", dependencies=[Depends(require_admin)]) +def delete_host(hostname: str, conn: Connection = Depends(db_conn)) -> dict: + _get_or_404(conn, hostname) + repo.delete_host(conn, hostname) + return {"deleted": hostname} diff --git a/src/handler/api/routes/interaction.py b/src/handler/api/routes/interaction.py index d55cd1a..922d2ce 100644 --- a/src/handler/api/routes/interaction.py +++ b/src/handler/api/routes/interaction.py @@ -1,10 +1,11 @@ """Answer + resume — the async replacement for a human sitting at the tmux TTY. -``answer`` writes the operator's reply into the log entry that recorded the question -(the sole API mutation of ``log_entries``). ``resume`` then feeds that answer back to -the agent via ``claude --resume``, routed through the control-layer seam so it stays -mockable and the API/control boundary is explicit. They are two endpoints (README 3.3) -so the operator can answer many questions, then resume. +``answer`` writes the operator's reply into the log entry that recorded the question (the +sole API mutation of ``log_entries``). ``resume`` then enqueues a ``resume`` command: the +control worker — which runs in the control container where the agent's tmux session +actually lives — feeds the answer back via ``claude --resume``. (Doing this in-process in +the API container would fail after the API/control split, since the session isn't here.) +They are two endpoints (README 3.3) so the operator can answer many questions, then resume. """ from __future__ import annotations @@ -12,10 +13,9 @@ from __future__ import annotations from fastapi import APIRouter, Depends, HTTPException, status from sqlalchemy import Connection -from ...control import spawn from ...db import repository as repo -from ..deps import db_conn, require_auth -from ..schemas import AnswerIn, AnswerOut, ResumeIn, ResumeOut +from ..deps import db_conn, require_admin, require_auth +from ..schemas import AnswerIn, AnswerOut, CommandOut, ResumeIn from .common import resolve_agent router = APIRouter( @@ -54,15 +54,21 @@ def answer( return AnswerOut(log_entry_id=log_entry_id, answered=True) -@router.post("/resume", response_model=ResumeOut) +@router.post( + "/resume", + response_model=CommandOut, + status_code=status.HTTP_202_ACCEPTED, + dependencies=[Depends(require_admin)], +) def resume( project: str, name: str, body: ResumeIn, conn: Connection = Depends(db_conn), -) -> ResumeOut: +) -> dict: agent = resolve_agent(conn, project, name) + # Resolve the answer to feed back here (the API has the log); the worker just delivers. answer_text = body.answer if answer_text is None: open_q = repo.get_latest_open_question(conn, agent["id"]) @@ -80,7 +86,11 @@ def resume( detail="no answer available to resume with; answer first or pass one", ) - ok, detail = spawn.resume(agent, answer_text) - if ok: - repo.set_agent_status(conn, agent["id"], "working") - return ResumeOut(agent=name, resumed=ok, detail=detail) + return repo.enqueue_command( + conn, + "resume", + project_id=project, + agent_name=name, + payload={"answer": answer_text}, + requested_by="operator:web", + ) diff --git a/src/handler/api/routes/projects.py b/src/handler/api/routes/projects.py index 7db585d..6a0d50b 100644 --- a/src/handler/api/routes/projects.py +++ b/src/handler/api/routes/projects.py @@ -1,4 +1,9 @@ -"""Project registration + listing (control-plane; the process spawn is the CLI's job).""" +"""Project CRUD + project-scoped control actions. + +Reads and row registration take the normal token; edits/deletes and the enqueue actions +(forge-init, poll-ci) take the admin token. The agent *process* work (spawn/kill) lives in +``agents.py``; here we cover the project itself and the two project-wide control actions. +""" from __future__ import annotations @@ -7,17 +12,29 @@ from sqlalchemy import Connection from sqlalchemy.exc import IntegrityError from ...db import repository as repo -from ..deps import db_conn, require_auth -from ..schemas import ProjectIn, ProjectOut +from ..deps import db_conn, require_admin, require_auth +from ..schemas import CommandOut, ProjectIn, ProjectOut, ProjectUpdateIn router = APIRouter(prefix="/projects", tags=["projects"], dependencies=[Depends(require_auth)]) +def _get_or_404(conn: Connection, project_id: str) -> dict: + project = repo.get_project(conn, project_id) + if project is None: + raise HTTPException(status.HTTP_404_NOT_FOUND, detail=f"project '{project_id}' not found") + return project + + @router.get("", response_model=list[ProjectOut]) def list_projects(conn: Connection = Depends(db_conn)) -> list[dict]: return repo.list_projects(conn) +@router.get("/{project_id}", response_model=ProjectOut) +def get_project(project_id: str, conn: Connection = Depends(db_conn)) -> dict: + return _get_or_404(conn, project_id) + + @router.post("", response_model=ProjectOut, status_code=status.HTTP_201_CREATED) def create_project(body: ProjectIn, conn: Connection = Depends(db_conn)) -> dict: if repo.get_project(conn, body.id) is not None: @@ -32,3 +49,51 @@ def create_project(body: ProjectIn, conn: Connection = Depends(db_conn)) -> dict ) except IntegrityError as exc: # pragma: no cover - guarded above raise HTTPException(status.HTTP_409_CONFLICT, detail="project exists") from exc + + +@router.patch("/{project_id}", response_model=ProjectOut, dependencies=[Depends(require_admin)]) +def update_project( + project_id: str, body: ProjectUpdateIn, conn: Connection = Depends(db_conn) +) -> dict: + _get_or_404(conn, project_id) + fields = body.model_dump(exclude_unset=True) + return repo.update_project(conn, project_id, **fields) + + +@router.delete("/{project_id}", dependencies=[Depends(require_admin)]) +def delete_project(project_id: str, conn: Connection = Depends(db_conn)) -> dict: + _get_or_404(conn, project_id) + repo.delete_project(conn, project_id) + return {"deleted": project_id} + + +@router.post( + "/{project_id}/forge-init", + response_model=CommandOut, + status_code=status.HTTP_202_ACCEPTED, + dependencies=[Depends(require_admin)], +) +def enqueue_forge_init( + project_id: str, no_commit: bool = False, conn: Connection = Depends(db_conn) +) -> dict: + _get_or_404(conn, project_id) + return repo.enqueue_command( + conn, + "forge_init", + project_id=project_id, + payload={"no_commit": no_commit}, + requested_by="operator:web", + ) + + +@router.post( + "/{project_id}/poll-ci", + response_model=CommandOut, + status_code=status.HTTP_202_ACCEPTED, + dependencies=[Depends(require_admin)], +) +def enqueue_poll_ci(project_id: str, conn: Connection = Depends(db_conn)) -> dict: + _get_or_404(conn, project_id) + return repo.enqueue_command( + conn, "poll_ci", project_id=project_id, requested_by="operator:web" + ) diff --git a/src/handler/api/schemas.py b/src/handler/api/schemas.py index 74f54de..2fe28b0 100644 --- a/src/handler/api/schemas.py +++ b/src/handler/api/schemas.py @@ -5,8 +5,33 @@ mapping straight in; timestamps serialize as ISO-8601. from __future__ import annotations from datetime import datetime +from typing import Literal -from pydantic import BaseModel, ConfigDict +from pydantic import BaseModel, ConfigDict, field_validator + +# Roles + forge families mirrored from db.tables; Literal gives clean 422s on bad input. +Role = Literal["junior", "senior", "deploy"] +ForgeType = Literal["github", "gitlab", "gitea", "forgejo", "bitbucket"] + +# credential_ref schemes an operator may set over the web. ``cmd:`` is intentionally +# excluded — it would run an arbitrary command in the control container at spawn — so the +# API rejects it even though the CLI/DB path still allows it. +_WEB_CREDENTIAL_SCHEMES = {"env", "file", "db"} + + +def _validate_web_credential_ref(value: str | None) -> str | None: + if value is None: + return None + value = value.strip() + if not value: + return None + scheme = value.split(":", 1)[0] + if scheme not in _WEB_CREDENTIAL_SCHEMES: + raise ValueError( + f"credential_ref scheme '{scheme}' is not allowed from the API; " + "use env:, file:, or db: (cmd: is CLI-only for safety)" + ) + return value class ProjectIn(BaseModel): @@ -15,6 +40,24 @@ class ProjectIn(BaseModel): git_remote: str | None = None credential_ref: str | None = None + @field_validator("credential_ref") + @classmethod + def _check_credential_ref(cls, v: str | None) -> str | None: + return _validate_web_credential_ref(v) + + +class ProjectUpdateIn(BaseModel): + """Editable project columns; omit a field to leave it unchanged.""" + + root_dir: str | None = None + git_remote: str | None = None + credential_ref: str | None = None + + @field_validator("credential_ref") + @classmethod + def _check_credential_ref(cls, v: str | None) -> str | None: + return _validate_web_credential_ref(v) + class ProjectOut(BaseModel): model_config = ConfigDict(from_attributes=True) @@ -30,6 +73,7 @@ class AgentIn(BaseModel): name: str working_dir: str status: str = "working" + role: Role | None = None class AgentOut(BaseModel): @@ -40,6 +84,83 @@ class AgentOut(BaseModel): name: str working_dir: str status: str + role: Role | None = None + created_at: datetime + + +class SpawnIn(BaseModel): + """Enqueue a spawn: the worker creates the agent row + tmux process in the control + container. ``worktree`` and ``subdir`` are mutually exclusive (worktree wins if both).""" + + name: str + role: Role | None = None + worktree: str | None = None + subdir: str | None = None + task: str | None = None + + +class CommandOut(BaseModel): + model_config = ConfigDict(from_attributes=True) + + id: int + project_id: str | None = None + agent_name: str | None = None + type: str + payload: dict | None = None + status: str + result: dict | None = None + error: str | None = None + requested_by: str | None = None + claimed_by: str | None = None + created_at: datetime + claimed_at: datetime | None = None + finished_at: datetime | None = None + + +class HostIn(BaseModel): + hostname: str + forge_type: ForgeType + token_env_var: str | None = None + base_url: str | None = None + + +class HostUpdateIn(BaseModel): + forge_type: ForgeType | None = None + token_env_var: str | None = None + base_url: str | None = None + + +class HostOut(BaseModel): + model_config = ConfigDict(from_attributes=True) + + hostname: str + forge_type: str + token_env_var: str | None = None + base_url: str | None = None + created_at: datetime + + +class ApprovalIn(BaseModel): + branch: str + status: Literal["approved", "rejected"] = "approved" + agent_name: str | None = None # read HEAD from this agent's working dir when no sha + sha: str | None = None + pr: str | None = None + note: str | None = None + + +class ApprovalOut(BaseModel): + model_config = ConfigDict(from_attributes=True) + + id: int + project_id: str + branch: str + approved_sha: str | None = None + pr_ref: str | None = None + status: str + approved_by_agent_id: int | None = None + actor: str | None = None + note: str | None = None created_at: datetime diff --git a/src/handler/api/static/app.js b/src/handler/api/static/app.js index 4ba3a6b..7602aff 100644 --- a/src/handler/api/static/app.js +++ b/src/handler/api/static/app.js @@ -6,6 +6,10 @@ * * Security: every value from the API is rendered with Alpine `x-text` (textContent) * in index.html — never x-html — so agent-authored strings can't inject markup. + * + * Control actions (spawn/kill/resume/approve/…) are async: the API enqueues a command + * and the control worker executes it. enqueueAndTrack() posts the command, then polls + * GET /commands/{id} until it reaches done/failed, surfacing the result in a banner. */ const TOKEN_KEY = "handler_token"; @@ -35,16 +39,25 @@ function app() { logLimit: LOG_LIMIT, logOffset: 0, shared: { log: [], context: [] }, + approvals: [], + hosts: [], + commands: [], - // --- answer form --- + // --- forms --- answerText: "", answerBusy: false, answerMsg: "", answerError: false, + spawnForm: { name: "", role: "", placement: "worktree", worktree: "", subdir: "", task: "" }, + approvalForm: { branch: "", status: "approved", agent_name: "", sha: "", note: "" }, + projectForm: { id: "", root_dir: "", git_remote: "", credential_ref: "", _editing: false }, + hostForm: { hostname: "", forge_type: "github", token_env_var: "", base_url: "", _editing: false }, + sharedForm: { key: "", value: "" }, // --- ui --- tab: "agents", lastError: "", + cmd: { text: "", error: false, busy: false }, _poll: null, get selectedAgent() { @@ -120,7 +133,10 @@ function app() { if (!res.ok) { let detail = `${res.status}`; try { - detail = (await res.json()).detail || detail; + const body = await res.json(); + detail = body.detail || detail; + // Pydantic 422 returns a list of validation errors. + if (Array.isArray(detail)) detail = detail.map((d) => d.msg || JSON.stringify(d)).join("; "); } catch (_) {} const err = new Error(detail); err.status = res.status; @@ -130,6 +146,50 @@ function app() { return res.json(); }, + _sleep(ms) { + return new Promise((r) => setTimeout(r, ms)); + }, + + /* Post a control action, then poll its command to a terminal state. */ + async enqueueAndTrack(path, body, label) { + this.cmd = { text: `${label}: queued…`, error: false, busy: true }; + try { + const command = await this.api(path, { method: "POST", body }); + return await this._trackCommand(command.id, label); + } catch (e) { + if (e instanceof AuthError) return null; + this.cmd = { text: `${label} failed: ${e.message}`, error: true, busy: false }; + return null; + } + }, + + async _trackCommand(id, label) { + for (let i = 0; i < 40; i++) { + let c; + try { + c = await this.api(`/commands/${id}`); + } catch (e) { + if (e instanceof AuthError) return null; + this.cmd = { text: `${label}: ${e.message}`, error: true, busy: false }; + return null; + } + if (c.status === "done" || c.status === "failed") { + const ok = c.status === "done"; + const detail = c.error || (c.result ? JSON.stringify(c.result) : ""); + this.cmd = { + text: `${label} ${ok ? "done" : "failed"}${detail ? " — " + detail : ""}`, + error: !ok, + busy: false, + }; + return c; + } + this.cmd = { text: `${label}: ${c.status}…`, error: false, busy: true }; + await this._sleep(600); + } + this.cmd = { text: `${label}: still running (see Activity). Is the worker up?`, error: false, busy: false }; + return null; + }, + // --- lifecycle --- async start() { await this.loadProjects(); @@ -154,10 +214,15 @@ function app() { /* One poll cycle for whatever view is active. Swallows AuthError (already handled). */ async tick() { try { - if (this.tab === "shared") { - await this.loadShared(); + if (this.tab === "shared") return await this.loadShared(); + if (this.tab === "activity") return await this.loadCommands(); + if (this.tab === "hosts") return await this.loadHosts(); + if (this.tab === "projects") return await this.loadProjects(); + if (this.tab === "approvals") { + if (this.selectedProjectId) await this.loadApprovals(); return; } + // agents tab if (this.selectedProjectId) await this.loadAgents(); if (this.selectedAgentName) { await this.loadCheckmark(); @@ -172,6 +237,12 @@ function app() { this.tick(); }, + switchTab(tab) { + this.tab = tab; + this.cmd = { text: "", error: false, busy: false }; + this.tick(); + }, + // --- projects --- async loadProjects() { try { @@ -190,14 +261,61 @@ function app() { this.log = []; this.logOffset = 0; await this.loadAgents(); + if (this.tab === "approvals") await this.loadApprovals(); + }, + + resetProjectForm() { + this.projectForm = { id: "", root_dir: "", git_remote: "", credential_ref: "", _editing: false }; + }, + editProject(p) { + this.projectForm = { + id: p.id, + root_dir: p.root_dir, + git_remote: p.git_remote || "", + credential_ref: p.credential_ref || "", + _editing: true, + }; + }, + async saveProject() { + const f = this.projectForm; + const body = { + root_dir: f.root_dir.trim(), + git_remote: f.git_remote.trim() || null, + credential_ref: f.credential_ref.trim() || null, + }; + try { + if (f._editing) { + await this.api(`/projects/${encodeURIComponent(f.id)}`, { method: "PATCH", body }); + this.cmd = { text: `project '${f.id}' updated`, error: false, busy: false }; + } else { + await this.api("/projects", { method: "POST", body: { id: f.id.trim(), ...body } }); + this.cmd = { text: `project '${f.id}' created`, error: false, busy: false }; + } + this.resetProjectForm(); + await this.loadProjects(); + } catch (e) { + if (e instanceof AuthError) return; + this.cmd = { text: e.message, error: true, busy: false }; + } + }, + async deleteProject(id) { + if (!confirm(`Delete project '${id}'? Its agents/log rows go with it.`)) return; + try { + await this.api(`/projects/${encodeURIComponent(id)}`, { method: "DELETE" }); + this.cmd = { text: `project '${id}' deleted`, error: false, busy: false }; + if (this.selectedProjectId === id) this.selectedProjectId = ""; + await this.loadProjects(); + } catch (e) { + if (e instanceof AuthError) return; + this.cmd = { text: e.message, error: true, busy: false }; + } }, // --- agents --- async loadAgents() { const p = this.selectedProjectId; if (!p) return; - const agents = await this.api(`/projects/${encodeURIComponent(p)}/agents`); - this.agents = agents; + this.agents = await this.api(`/projects/${encodeURIComponent(p)}/agents`); this.lastError = ""; }, @@ -215,6 +333,39 @@ function app() { return `/projects/${encodeURIComponent(this.selectedProjectId)}/agents/${encodeURIComponent(this.selectedAgentName)}`; }, + async spawnAgent() { + const f = this.spawnForm; + const body = { name: f.name.trim(), role: f.role || null, task: f.task.trim() || null }; + if (f.placement === "worktree" && f.worktree.trim()) body.worktree = f.worktree.trim(); + if (f.placement === "subdir" && f.subdir.trim()) body.subdir = f.subdir.trim(); + const p = encodeURIComponent(this.selectedProjectId); + const final = await this.enqueueAndTrack(`/projects/${p}/agents/spawn`, body, `spawn ${body.name}`); + if (final && final.status === "done") { + this.spawnForm = { name: "", role: "", placement: "worktree", worktree: "", subdir: "", task: "" }; + } + await this.loadAgents(); + }, + + async killAgent(name) { + const p = encodeURIComponent(this.selectedProjectId); + await this.enqueueAndTrack(`/projects/${p}/agents/${encodeURIComponent(name)}/kill`, undefined, `kill ${name}`); + await this.loadAgents(); + }, + + async deleteAgent(name) { + if (!confirm(`Delete the agent row '${name}'? (Kill the session first if live.)`)) return; + const p = encodeURIComponent(this.selectedProjectId); + try { + await this.api(`/projects/${p}/agents/${encodeURIComponent(name)}`, { method: "DELETE" }); + this.cmd = { text: `agent row '${name}' deleted`, error: false, busy: false }; + if (this.selectedAgentName === name) this.selectedAgentName = null; + await this.loadAgents(); + } catch (e) { + if (e instanceof AuthError) return; + this.cmd = { text: e.message, error: true, busy: false }; + } + }, + async loadCheckmark() { try { this.checkmark = await this.api(`${this._agentPath()}/checkmark`); @@ -259,17 +410,12 @@ function app() { try { await this.api(`${this._agentPath()}/answer`, { method: "POST", body: { answer: text } }); if (resume) { - const r = await this.api(`${this._agentPath()}/resume`, { method: "POST", body: { answer: text } }); - if (r.resumed) { - this.answerMsg = "Answered and resumed."; - this.answerText = ""; - await this.tick(); // flip the badge to working without waiting a full interval - } else { - this.answerError = true; - this.answerMsg = `Answer saved, but resume failed: ${r.detail || "unknown error"}`; - await this.loadCheckmark(); - await this.loadLog(); - } + this.answerMsg = "Answer saved; resume enqueued."; + this.answerText = ""; + await this.enqueueAndTrack(`${this._agentPath()}/resume`, { answer: text }, "resume"); + await this.loadAgents(); + await this.loadCheckmark(); + await this.loadLog(); } else { this.answerMsg = "Answer saved (agent still paused)."; this.answerText = ""; @@ -285,12 +431,104 @@ function app() { } }, - // --- shared tab --- - switchToShared() { - this.tab = "shared"; - this.loadShared(); + // --- approvals --- + async loadApprovals() { + if (!this.selectedProjectId) { + this.approvals = []; + return; + } + try { + this.approvals = await this.api(`/projects/${encodeURIComponent(this.selectedProjectId)}/approvals`); + this.lastError = ""; + } catch (e) { + if (!(e instanceof AuthError)) this.lastError = e.message; + } + }, + async submitApproval() { + const f = this.approvalForm; + const body = { + branch: f.branch.trim(), + status: f.status, + agent_name: f.agent_name.trim() || null, + sha: f.sha.trim() || null, + note: f.note.trim() || null, + }; + const p = encodeURIComponent(this.selectedProjectId); + await this.enqueueAndTrack(`/projects/${p}/approvals`, body, `${f.status} ${f.branch}`); + this.approvalForm = { branch: "", status: "approved", agent_name: "", sha: "", note: "" }; + await this.loadApprovals(); }, + // --- hosts --- + async loadHosts() { + try { + this.hosts = await this.api("/hosts"); + this.lastError = ""; + } catch (e) { + if (!(e instanceof AuthError)) this.lastError = e.message; + } + }, + resetHostForm() { + this.hostForm = { hostname: "", forge_type: "github", token_env_var: "", base_url: "", _editing: false }; + }, + editHost(h) { + this.hostForm = { + hostname: h.hostname, + forge_type: h.forge_type, + token_env_var: h.token_env_var || "", + base_url: h.base_url || "", + _editing: true, + }; + }, + async saveHost() { + const f = this.hostForm; + const body = { + forge_type: f.forge_type, + token_env_var: f.token_env_var.trim() || null, + base_url: f.base_url.trim() || null, + }; + try { + if (f._editing) { + await this.api(`/hosts/${encodeURIComponent(f.hostname)}`, { method: "PATCH", body }); + this.cmd = { text: `host '${f.hostname}' updated`, error: false, busy: false }; + } else { + await this.api("/hosts", { method: "POST", body: { hostname: f.hostname.trim(), ...body } }); + this.cmd = { text: `host '${f.hostname}' created`, error: false, busy: false }; + } + this.resetHostForm(); + await this.loadHosts(); + } catch (e) { + if (e instanceof AuthError) return; + this.cmd = { text: e.message, error: true, busy: false }; + } + }, + async deleteHost(hostname) { + if (!confirm(`Delete host '${hostname}'?`)) return; + try { + await this.api(`/hosts/${encodeURIComponent(hostname)}`, { method: "DELETE" }); + this.cmd = { text: `host '${hostname}' deleted`, error: false, busy: false }; + await this.loadHosts(); + } catch (e) { + if (e instanceof AuthError) return; + this.cmd = { text: e.message, error: true, busy: false }; + } + }, + + // --- activity / commands --- + async loadCommands() { + try { + this.commands = await this.api("/commands?limit=50"); + this.lastError = ""; + } catch (e) { + if (!(e instanceof AuthError)) this.lastError = e.message; + } + }, + async pollCiGlobal() { + await this.enqueueAndTrack("/poll-ci", undefined, "poll-ci (all projects)"); + await this.loadCommands(); + }, + + // --- shared tab --- async loadShared() { try { const [log, context] = await Promise.all([ @@ -303,6 +541,20 @@ function app() { if (!(e instanceof AuthError)) this.lastError = e.message; } }, + async setSharedContext() { + const key = this.sharedForm.key.trim(); + const value = this.sharedForm.value.trim(); + if (!key || !value) return; + try { + await this.api(`/shared/context/${encodeURIComponent(key)}`, { method: "PUT", body: { value } }); + this.cmd = { text: `shared context '${key}' set`, error: false, busy: false }; + this.sharedForm = { key: "", value: "" }; + await this.loadShared(); + } catch (e) { + if (e instanceof AuthError) return; + this.cmd = { text: e.message, error: true, busy: false }; + } + }, // --- rendering helpers --- badgeClass(kind, value) { diff --git a/src/handler/api/static/index.html b/src/handler/api/static/index.html index abe784f..e9a7eff 100644 --- a/src/handler/api/static/index.html +++ b/src/handler/api/static/index.html @@ -16,8 +16,9 @@