mirror of
https://github.com/0xWheatyz/handler.git
synced 2026-08-30 11:26:25 +00:00
4f05d09c2b
Make credentials/hosts, projects, agents, and approvals manageable from the dashboard. The API and control layer are separate containers, so the API can't run control actions directly (no git/tmux/claude, doesn't own the tmux sessions). Instead the API enqueues a command and a worker in the control container executes it and writes the result back. Data model (migration 0003): - `commands` queue/audit table; `forge_hosts` registry; `approvals` gains a nullable approver id + `actor` so operator verdicts are first-class. Control worker: - `control/worker.py` claims commands and dispatches to the existing control functions (spawn/kill/resume/record_approval/write_skills/poller.sweep), plus a periodic CI sweep. New `handler worker` CLI subcommand; it becomes the control image's default command (subsumes `poll-ci --watch`). API: - `require_admin` gate + `ADMIN_TOKEN`; project GET/PATCH/DELETE; agent spawn/kill/delete; resume now enqueues (fixes a cross-container bug where the API tried to send tmux keys to a session in the control container); new approvals/commands/hosts routes; forge-init and poll-ci enqueue endpoints. Credentials/hosts: - host->token-env lookup consults the `forge_hosts` registry first (built-in map is the fallback); `resolve()` refactored to a scheme dispatch reserving `db:` for a future encrypted store. Web input restricts credential_ref to env:/file:/db: (cmd: stays CLI-only — it would run arbitrary commands). Dashboard: - New tabs for projects, agents (spawn/kill with live command-status polling), approvals, hosts, and an activity/audit view; shared context is now writable. Tests: +33 (queue atomicity, worker dispatch, CRUD, hosts, admin gating, cmd: rejection, host-aware credentials, and an API->queue->worker->spawn end-to-end). README gains a Web management section. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CrhrBToauu4L2qG6jdnuFP
86 lines
3.4 KiB
Python
86 lines
3.4 KiB
Python
"""DAL for web management: project/agent mutation, the command queue, and hosts."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from handler.db import repository as repo
|
|
|
|
|
|
def test_update_and_delete_project(conn):
|
|
repo.create_project(conn, "p", "/tmp/p", git_remote="https://github.com/me/p.git")
|
|
updated = repo.update_project(conn, "p", git_remote="https://gitea.corp/me/p.git",
|
|
credential_ref="env:TOK")
|
|
assert updated["git_remote"] == "https://gitea.corp/me/p.git"
|
|
assert updated["credential_ref"] == "env:TOK"
|
|
# An unknown field is ignored, not applied.
|
|
repo.update_project(conn, "p", nonsense="x")
|
|
assert repo.delete_project(conn, "p") is True
|
|
assert repo.get_project(conn, "p") is None
|
|
|
|
|
|
def test_delete_agent_row(conn):
|
|
repo.create_project(conn, "p", "/tmp/p")
|
|
repo.create_agent(conn, "p", "api", "/tmp/p/api")
|
|
assert repo.delete_agent(conn, "p", "api") is True
|
|
assert repo.get_agent_by_name(conn, "p", "api") is None
|
|
|
|
|
|
def test_enqueue_get_and_list_command(conn):
|
|
repo.create_project(conn, "p", "/tmp/p")
|
|
cmd = repo.enqueue_command(
|
|
conn, "spawn", project_id="p", agent_name="junior",
|
|
payload={"role": "junior"}, requested_by="operator:web",
|
|
)
|
|
assert cmd["status"] == "queued"
|
|
assert cmd["type"] == "spawn"
|
|
assert cmd["payload"] == {"role": "junior"}
|
|
assert repo.get_command(conn, cmd["id"])["agent_name"] == "junior"
|
|
assert [c["id"] for c in repo.list_commands(conn, project_id="p")] == [cmd["id"]]
|
|
|
|
|
|
def test_claim_is_atomic_and_fifo(conn):
|
|
repo.enqueue_command(conn, "poll_ci")
|
|
second = repo.enqueue_command(conn, "poll_ci")
|
|
|
|
first_claim = repo.claim_next_command(conn, "worker-1")
|
|
assert first_claim["status"] == "running"
|
|
assert first_claim["claimed_by"] == "worker-1"
|
|
|
|
# Oldest-first: the second claim gets the later row, never the same one twice.
|
|
second_claim = repo.claim_next_command(conn, "worker-2")
|
|
assert second_claim["id"] == second["id"]
|
|
assert second_claim["id"] != first_claim["id"]
|
|
|
|
# Queue drained -> None.
|
|
assert repo.claim_next_command(conn, "worker-3") is None
|
|
|
|
|
|
def test_finish_command_records_result(conn):
|
|
cmd = repo.enqueue_command(conn, "poll_ci")
|
|
repo.claim_next_command(conn, "w")
|
|
repo.finish_command(conn, cmd["id"], "done", result={"checked": 3})
|
|
done = repo.get_command(conn, cmd["id"])
|
|
assert done["status"] == "done"
|
|
assert done["result"] == {"checked": 3}
|
|
assert done["finished_at"] is not None
|
|
|
|
|
|
def test_hosts_crud(conn):
|
|
created = repo.create_host(conn, "git.corp", "gitea", token_env_var="GITEA_TOKEN")
|
|
assert created["forge_type"] == "gitea"
|
|
assert repo.get_host(conn, "git.corp")["token_env_var"] == "GITEA_TOKEN"
|
|
repo.update_host(conn, "git.corp", base_url="https://git.corp")
|
|
assert repo.get_host(conn, "git.corp")["base_url"] == "https://git.corp"
|
|
assert [h["hostname"] for h in repo.list_hosts(conn)] == ["git.corp"]
|
|
assert repo.delete_host(conn, "git.corp") is True
|
|
assert repo.get_host(conn, "git.corp") is None
|
|
|
|
|
|
def test_operator_approval_has_no_agent_and_lists(conn):
|
|
repo.create_project(conn, "p", "/tmp/p")
|
|
ap = repo.record_approval(conn, "p", "feat/x", "approved", actor="operator:web")
|
|
assert ap["approved_by_agent_id"] is None
|
|
assert ap["actor"] == "operator:web"
|
|
listed = repo.list_approvals(conn, "p")
|
|
assert [a["id"] for a in listed] == [ap["id"]]
|
|
assert repo.list_approvals(conn, "p", branch="other") == []
|