mirror of
https://github.com/0xWheatyz/handler.git
synced 2026-08-30 12:06:24 +00:00
feat(mvp): Phase 1 control layer + API vertical slice
Implements the Phase 1 MVP from the README: a stateless control layer + HTTP API over a centralized database, with hook-enforced test/push gates. - DB layer: SQLAlchemy Core, one schema rendering both Postgres (BIGSERIAL / TIMESTAMPTZ / JSONB) and SQLite (INTEGER PK / TEXT / JSON) via portable types; native ON CONFLICT DO UPDATE checkmark upsert on both dialects. - Alembic dual-dialect migrations (render_as_batch for SQLite); tests run a real `alembic upgrade head`. - FastAPI: projects/agents/checkmark/log/answer/resume + shared log/context routes, single global bearer token, higher-trust token gating shared-context writes, project isolation on every route. - Hooks (`python -m handler.hooks <event>`): Stop test gate (block on red), PreToolUse AskUserQuestion defer + `git push` gate (tests then throwaway build), Notification generic webhook (no-op without WEBHOOK_URL). Identity via env injected at spawn; verify is the mock seam. - Control CLI: spawn/list/attach/kill, hard `.mise.toml [tasks.test]` gate, generated per-agent settings.json, identity + DATABASE_URL injected via tmux; tmux is the mock seam. - 45 tests (SQLite), ruff clean. Live claude/tmux/mise spawning deferred behind the mocked seams. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01W5ZuS5pV1NS6eKsRZHXonY
This commit is contained in:
@@ -0,0 +1,107 @@
|
||||
"""Shared fixtures. Everything runs on a fresh SQLite file per test, materialized via
|
||||
a *real* ``alembic upgrade head`` — so the migration path itself is under test, not
|
||||
just ``create_all``. No live claude/tmux/mise is ever touched: the three seams
|
||||
(``control.tmux``, ``hooks.verify``, ``control.spawn.resume``) are faked.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from alembic import command
|
||||
from alembic.config import Config
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
def _reset_caches() -> None:
|
||||
from handler import config
|
||||
from handler.db import engine
|
||||
|
||||
config.get_settings.cache_clear()
|
||||
engine.get_engine.cache_clear()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def env(tmp_path, monkeypatch):
|
||||
"""Point every entrypoint at a fresh SQLite db + a known token, migrated."""
|
||||
db_path = tmp_path / "handler.db"
|
||||
url = f"sqlite:///{db_path}"
|
||||
monkeypatch.setenv("DATABASE_URL", url)
|
||||
monkeypatch.setenv("AUTH_TOKEN", "test-token")
|
||||
monkeypatch.setenv("SHARED_CONTEXT_WRITE_TOKEN", "shared-token")
|
||||
monkeypatch.setenv("PROJECTS_ROOT", str(tmp_path / "projects"))
|
||||
monkeypatch.delenv("WEBHOOK_URL", raising=False)
|
||||
_reset_caches()
|
||||
|
||||
cfg = Config(str(REPO_ROOT / "alembic.ini"))
|
||||
cfg.set_main_option("script_location", str(REPO_ROOT / "src" / "handler" / "migrations"))
|
||||
command.upgrade(cfg, "head")
|
||||
|
||||
yield {"url": url, "token": "test-token", "shared_token": "shared-token", "tmp": tmp_path}
|
||||
|
||||
_reset_caches()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def engine(env):
|
||||
from handler.db.engine import get_engine
|
||||
|
||||
return get_engine()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def conn(engine):
|
||||
with engine.begin() as c:
|
||||
yield c
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client(env):
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from handler.api.app import create_app
|
||||
|
||||
return TestClient(create_app())
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def auth(env):
|
||||
return {"Authorization": f"Bearer {env['token']}"}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def fake_tmux(monkeypatch):
|
||||
"""Record tmux calls instead of spawning; report sessions as live by default."""
|
||||
calls: dict[str, list] = {"new_session": [], "kill_session": [], "send_keys": []}
|
||||
live: set[str] = set()
|
||||
|
||||
from handler.control import tmux
|
||||
|
||||
def new_session(name, cwd, command, env):
|
||||
calls["new_session"].append(
|
||||
{"name": name, "cwd": cwd, "command": command, "env": env}
|
||||
)
|
||||
live.add(name)
|
||||
|
||||
def has_session(name):
|
||||
return name in live
|
||||
|
||||
def kill_session(name):
|
||||
calls["kill_session"].append(name)
|
||||
live.discard(name)
|
||||
|
||||
def send_keys(name, keys):
|
||||
calls["send_keys"].append({"name": name, "keys": keys})
|
||||
|
||||
def list_sessions():
|
||||
return list(live)
|
||||
|
||||
monkeypatch.setattr(tmux, "new_session", new_session)
|
||||
monkeypatch.setattr(tmux, "has_session", has_session)
|
||||
monkeypatch.setattr(tmux, "kill_session", kill_session)
|
||||
monkeypatch.setattr(tmux, "send_keys", send_keys)
|
||||
monkeypatch.setattr(tmux, "list_sessions", list_sessions)
|
||||
|
||||
return {"calls": calls, "live": live}
|
||||
@@ -0,0 +1,20 @@
|
||||
"""Bearer auth on every route."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
def test_missing_token_is_401(client):
|
||||
assert client.get("/projects").status_code == 401
|
||||
|
||||
|
||||
def test_wrong_token_is_401(client):
|
||||
r = client.get("/projects", headers={"Authorization": "Bearer nope"})
|
||||
assert r.status_code == 401
|
||||
|
||||
|
||||
def test_valid_token_is_200(client, auth):
|
||||
assert client.get("/projects", headers=auth).status_code == 200
|
||||
|
||||
|
||||
def test_health_needs_no_auth(client):
|
||||
assert client.get("/health").status_code == 200
|
||||
@@ -0,0 +1,76 @@
|
||||
"""Answer + resume routes, including the mocked control seam."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from handler.control import spawn
|
||||
from handler.db import repository as repo
|
||||
from handler.db.engine import get_engine
|
||||
|
||||
|
||||
def _seed_agent_with_question(env):
|
||||
"""Seed a project + agent + an open question directly in the DB."""
|
||||
with get_engine().begin() as conn:
|
||||
repo.create_project(conn, "proj", "/tmp/proj")
|
||||
a = repo.create_agent(conn, "proj", "api", "/tmp/proj/api", status="paused_for_input")
|
||||
log_id = repo.insert_log_entry(
|
||||
conn, a["id"], status="paused_for_input", question="Which DB?"
|
||||
)
|
||||
return a, log_id
|
||||
|
||||
|
||||
def test_answer_backfills_latest_open_question(client, auth, env):
|
||||
_seed_agent_with_question(env)
|
||||
r = client.post(
|
||||
"/projects/proj/agents/api/answer",
|
||||
json={"answer": "Postgres"},
|
||||
headers=auth,
|
||||
)
|
||||
assert r.status_code == 200
|
||||
assert r.json()["answered"] is True
|
||||
|
||||
with get_engine().begin() as conn:
|
||||
a = repo.get_agent_by_name(conn, "proj", "api")
|
||||
assert repo.get_log(conn, a["id"])[0]["answer"] == "Postgres"
|
||||
|
||||
|
||||
def test_answer_with_no_open_question_is_404(client, auth, env):
|
||||
with get_engine().begin() as conn:
|
||||
repo.create_project(conn, "proj", "/tmp/proj")
|
||||
repo.create_agent(conn, "proj", "api", "/tmp/proj/api")
|
||||
r = client.post(
|
||||
"/projects/proj/agents/api/answer", json={"answer": "x"}, headers=auth
|
||||
)
|
||||
assert r.status_code == 404
|
||||
|
||||
|
||||
def test_resume_calls_control_seam(client, auth, env, monkeypatch):
|
||||
_seed_agent_with_question(env)
|
||||
client.post(
|
||||
"/projects/proj/agents/api/answer", json={"answer": "Postgres"}, headers=auth
|
||||
)
|
||||
|
||||
calls = []
|
||||
|
||||
def fake_resume(agent, answer):
|
||||
calls.append((agent["name"], answer))
|
||||
return True, "delivered"
|
||||
|
||||
monkeypatch.setattr(spawn, "resume", fake_resume)
|
||||
|
||||
r = client.post("/projects/proj/agents/api/resume", json={}, headers=auth)
|
||||
assert r.status_code == 200
|
||||
assert r.json()["resumed"] is True
|
||||
assert calls == [("api", "Postgres")]
|
||||
|
||||
with get_engine().begin() as conn:
|
||||
a = repo.get_agent_by_name(conn, "proj", "api")
|
||||
assert a["status"] == "working"
|
||||
|
||||
|
||||
def test_resume_without_answer_is_400(client, auth, env, monkeypatch):
|
||||
with get_engine().begin() as conn:
|
||||
repo.create_project(conn, "proj", "/tmp/proj")
|
||||
repo.create_agent(conn, "proj", "api", "/tmp/proj/api")
|
||||
monkeypatch.setattr(spawn, "resume", lambda a, ans: (True, "x"))
|
||||
r = client.post("/projects/proj/agents/api/resume", json={}, headers=auth)
|
||||
assert r.status_code == 400
|
||||
@@ -0,0 +1,64 @@
|
||||
"""Project + agent routes, and project isolation (README 3.4)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
def _mk_project(client, auth, pid="proj", root="/tmp/proj"):
|
||||
return client.post("/projects", json={"id": pid, "root_dir": root}, headers=auth)
|
||||
|
||||
|
||||
def test_create_and_list_project(client, auth):
|
||||
r = _mk_project(client, auth)
|
||||
assert r.status_code == 201
|
||||
assert r.json()["id"] == "proj"
|
||||
listing = client.get("/projects", headers=auth).json()
|
||||
assert [p["id"] for p in listing] == ["proj"]
|
||||
|
||||
|
||||
def test_duplicate_project_conflicts(client, auth):
|
||||
_mk_project(client, auth)
|
||||
assert _mk_project(client, auth).status_code == 409
|
||||
|
||||
|
||||
def test_create_and_list_agent(client, auth):
|
||||
_mk_project(client, auth)
|
||||
r = client.post(
|
||||
"/projects/proj/agents",
|
||||
json={"name": "api", "working_dir": "/tmp/proj/api"},
|
||||
headers=auth,
|
||||
)
|
||||
assert r.status_code == 201
|
||||
agents = client.get("/projects/proj/agents", headers=auth).json()
|
||||
assert [a["name"] for a in agents] == ["api"]
|
||||
|
||||
|
||||
def test_agent_under_missing_project_is_404(client, auth):
|
||||
r = client.get("/projects/ghost/agents", headers=auth)
|
||||
assert r.status_code == 404
|
||||
|
||||
|
||||
def test_project_isolation_same_agent_name(client, auth):
|
||||
# Two projects can each have an agent named "api"; neither leaks into the other.
|
||||
_mk_project(client, auth, "a", "/tmp/a")
|
||||
_mk_project(client, auth, "b", "/tmp/b")
|
||||
client.post(
|
||||
"/projects/a/agents",
|
||||
json={"name": "api", "working_dir": "/tmp/a/api"},
|
||||
headers=auth,
|
||||
)
|
||||
a_agents = client.get("/projects/a/agents", headers=auth).json()
|
||||
b_agents = client.get("/projects/b/agents", headers=auth).json()
|
||||
assert [x["name"] for x in a_agents] == ["api"]
|
||||
assert b_agents == []
|
||||
# The agent is invisible under project b.
|
||||
assert client.get("/projects/b/agents/api/checkmark", headers=auth).status_code == 404
|
||||
|
||||
|
||||
def test_checkmark_404_before_any_checkpoint(client, auth):
|
||||
_mk_project(client, auth)
|
||||
client.post(
|
||||
"/projects/proj/agents",
|
||||
json={"name": "api", "working_dir": "/tmp/proj/api"},
|
||||
headers=auth,
|
||||
)
|
||||
assert client.get("/projects/proj/agents/api/checkmark", headers=auth).status_code == 404
|
||||
@@ -0,0 +1,40 @@
|
||||
"""Shared-context + shared-log endpoints and the write-token gate."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from handler.db import repository as repo
|
||||
from handler.db.engine import get_engine
|
||||
|
||||
|
||||
def test_put_shared_context_requires_write_token(client, auth, env):
|
||||
# The normal token is not enough to write shared context.
|
||||
r = client.put("/shared/context/schema_version", json={"value": "v3"}, headers=auth)
|
||||
assert r.status_code == 403
|
||||
|
||||
write_headers = {"Authorization": f"Bearer {env['shared_token']}"}
|
||||
r = client.put(
|
||||
"/shared/context/schema_version", json={"value": "v3"}, headers=write_headers
|
||||
)
|
||||
assert r.status_code == 200
|
||||
assert r.json()["value"] == "v3"
|
||||
|
||||
|
||||
def test_read_shared_context_uses_normal_token(client, auth, env):
|
||||
write_headers = {"Authorization": f"Bearer {env['shared_token']}"}
|
||||
client.put("/shared/context/k", json={"value": "v"}, headers=write_headers)
|
||||
|
||||
assert client.get("/shared/context", headers=auth).status_code == 200
|
||||
assert client.get("/shared/context/k", headers=auth).json()["value"] == "v"
|
||||
assert client.get("/shared/context/missing", headers=auth).status_code == 404
|
||||
|
||||
|
||||
def test_shared_log_returns_only_global(client, auth, env):
|
||||
with get_engine().begin() as conn:
|
||||
repo.create_project(conn, "p", "/tmp/p")
|
||||
a = repo.create_agent(conn, "p", "a", "/tmp/p/a")
|
||||
repo.insert_log_entry(conn, a["id"], status="working", summary="private")
|
||||
repo.insert_log_entry(
|
||||
conn, a["id"], status="working", summary="global-note", visibility="global"
|
||||
)
|
||||
entries = client.get("/shared/log", headers=auth).json()
|
||||
assert [e["summary"] for e in entries] == ["global-note"]
|
||||
@@ -0,0 +1,91 @@
|
||||
"""Control-layer spawn: the hard test-task gate, settings generation, identity env."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
import pytest
|
||||
|
||||
from handler.control import spawn
|
||||
from handler.db import repository as repo
|
||||
from handler.db.engine import get_engine
|
||||
|
||||
|
||||
def _register_project(root):
|
||||
with get_engine().begin() as conn:
|
||||
repo.create_project(conn, "proj", str(root))
|
||||
|
||||
|
||||
def _write_mise(root, with_test=True):
|
||||
root.mkdir(parents=True, exist_ok=True)
|
||||
body = "[tasks.lint]\nrun = 'ruff check .'\n"
|
||||
if with_test:
|
||||
body = "[tasks.test]\nrun = 'pytest'\n" + body
|
||||
(root / ".mise.toml").write_text(body)
|
||||
|
||||
|
||||
def test_spawn_refuses_without_test_task(env, fake_tmux):
|
||||
root = env["tmp"] / "proj"
|
||||
_write_mise(root, with_test=False)
|
||||
_register_project(root)
|
||||
with pytest.raises(spawn.SpawnError, match="no \\[tasks.test\\]"):
|
||||
spawn.spawn("proj", "api")
|
||||
assert fake_tmux["calls"]["new_session"] == []
|
||||
|
||||
|
||||
def test_spawn_refuses_without_mise_file(env, fake_tmux):
|
||||
root = env["tmp"] / "proj"
|
||||
root.mkdir(parents=True, exist_ok=True)
|
||||
_register_project(root)
|
||||
with pytest.raises(spawn.SpawnError, match="no .mise.toml"):
|
||||
spawn.spawn("proj", "api")
|
||||
|
||||
|
||||
def test_spawn_creates_agent_settings_and_session(env, fake_tmux):
|
||||
root = env["tmp"] / "proj"
|
||||
_write_mise(root, with_test=True)
|
||||
_register_project(root)
|
||||
|
||||
agent = spawn.spawn("proj", "api", task="build the thing")
|
||||
|
||||
# Agent row created.
|
||||
with get_engine().begin() as conn:
|
||||
assert repo.get_agent_by_name(conn, "proj", "api")["id"] == agent["id"]
|
||||
|
||||
# settings.json wires all four hook events.
|
||||
settings = json.loads((root / ".claude" / "settings.json").read_text())
|
||||
assert set(settings["hooks"]) == {"Stop", "SessionEnd", "PreToolUse", "Notification"}
|
||||
pre = settings["hooks"]["PreToolUse"][0]
|
||||
assert pre["matcher"] == "AskUserQuestion|Bash"
|
||||
assert "handler.hooks pre_tool_use" in pre["hooks"][0]["command"]
|
||||
|
||||
# tmux session named project__agent, with identity + DATABASE_URL in env.
|
||||
call = fake_tmux["calls"]["new_session"][0]
|
||||
assert call["name"] == "proj__api"
|
||||
assert call["env"]["HANDLER_PROJECT_ID"] == "proj"
|
||||
assert call["env"]["HANDLER_AGENT_NAME"] == "api"
|
||||
assert call["env"]["HANDLER_AGENT_ID"] == str(agent["id"])
|
||||
assert call["env"]["DATABASE_URL"] == env["url"]
|
||||
|
||||
|
||||
def test_kill_sets_done_and_kills_session(env, fake_tmux):
|
||||
root = env["tmp"] / "proj"
|
||||
_write_mise(root, with_test=True)
|
||||
_register_project(root)
|
||||
spawn.spawn("proj", "api")
|
||||
|
||||
spawn.kill("proj", "api")
|
||||
assert "proj__api" in fake_tmux["calls"]["kill_session"]
|
||||
with get_engine().begin() as conn:
|
||||
assert repo.get_agent_by_name(conn, "proj", "api")["status"] == "done"
|
||||
|
||||
|
||||
def test_resume_sends_answer_to_live_session(env, fake_tmux):
|
||||
root = env["tmp"] / "proj"
|
||||
_write_mise(root, with_test=True)
|
||||
_register_project(root)
|
||||
agent = spawn.spawn("proj", "api")
|
||||
|
||||
ok, detail = spawn.resume(agent, "use Postgres")
|
||||
assert ok is True
|
||||
assert fake_tmux["calls"]["send_keys"][0] == {"name": "proj__api", "keys": "use Postgres"}
|
||||
@@ -0,0 +1,39 @@
|
||||
"""Portable types round-trip correctly on SQLite (aware datetimes, JSON lists)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from handler.db import repository as repo
|
||||
|
||||
|
||||
def _seed_agent(conn):
|
||||
repo.create_project(conn, "p", "/tmp/p")
|
||||
return repo.create_agent(conn, "p", "a", "/tmp/p/a")
|
||||
|
||||
|
||||
def test_timestamp_roundtrips_as_utc_aware(conn):
|
||||
agent = _seed_agent(conn)
|
||||
ts = datetime(2026, 7, 7, 12, 30, tzinfo=UTC)
|
||||
repo.upsert_checkmark_row(conn, agent["id"], checkpoint_at=ts, status="working")
|
||||
row = repo.get_checkmark(conn, agent["id"])
|
||||
assert row["checkpoint_at"] == ts
|
||||
assert row["checkpoint_at"].tzinfo is not None
|
||||
|
||||
|
||||
def test_naive_timestamp_is_normalized_to_utc(conn):
|
||||
agent = _seed_agent(conn)
|
||||
naive = datetime(2026, 7, 7, 12, 30) # no tzinfo
|
||||
repo.upsert_checkmark_row(conn, agent["id"], checkpoint_at=naive, status="working")
|
||||
row = repo.get_checkmark(conn, agent["id"])
|
||||
assert row["checkpoint_at"] == naive.replace(tzinfo=UTC)
|
||||
|
||||
|
||||
def test_json_list_roundtrips(conn):
|
||||
agent = _seed_agent(conn)
|
||||
steps = ["write tests", "wire the poller", "document the token flow"]
|
||||
repo.upsert_checkmark_row(
|
||||
conn, agent["id"], status="working", next_steps=steps
|
||||
)
|
||||
row = repo.get_checkmark(conn, agent["id"])
|
||||
assert row["next_steps"] == steps
|
||||
@@ -0,0 +1,52 @@
|
||||
"""The highest-value DB test: the checkmark upsert overwrites in place (ON CONFLICT DO
|
||||
UPDATE), keeping a single row with preserved identity — never delete+reinsert.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from sqlalchemy import func, select
|
||||
|
||||
from handler.db import repository as repo
|
||||
from handler.db.tables import checkmarks
|
||||
|
||||
|
||||
def _seed_agent(conn):
|
||||
repo.create_project(conn, "p", "/tmp/p")
|
||||
return repo.create_agent(conn, "p", "a", "/tmp/p/a")
|
||||
|
||||
|
||||
def test_upsert_overwrites_single_row(conn):
|
||||
agent = _seed_agent(conn)
|
||||
|
||||
repo.upsert_checkmark_row(
|
||||
conn,
|
||||
agent["id"],
|
||||
status="working",
|
||||
where_it_stopped="first stop",
|
||||
tests_status="unknown",
|
||||
)
|
||||
repo.upsert_checkmark_row(
|
||||
conn,
|
||||
agent["id"],
|
||||
status="done",
|
||||
where_it_stopped="second stop",
|
||||
tests_status="pass",
|
||||
)
|
||||
|
||||
count = conn.execute(select(func.count()).select_from(checkmarks)).scalar_one()
|
||||
assert count == 1
|
||||
|
||||
row = repo.get_checkmark(conn, agent["id"])
|
||||
assert row["status"] == "done"
|
||||
assert row["where_it_stopped"] == "second stop"
|
||||
assert row["tests_status"] == "pass"
|
||||
assert row["agent_id"] == agent["id"]
|
||||
|
||||
|
||||
def test_upsert_only_touches_supplied_columns_via_defaults(conn):
|
||||
agent = _seed_agent(conn)
|
||||
repo.upsert_checkmark_row(conn, agent["id"], status="working")
|
||||
row = repo.get_checkmark(conn, agent["id"])
|
||||
# Unsupplied gate columns fall back to their schema defaults.
|
||||
assert row["tests_status"] == "unknown"
|
||||
assert row["build_status"] == "unknown"
|
||||
@@ -0,0 +1,65 @@
|
||||
"""Stop / SessionEnd checkpoint gate."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from handler.db import repository as repo
|
||||
from handler.hooks import checkpoint, verify
|
||||
from handler.hooks.context import HookInput, Identity
|
||||
|
||||
|
||||
def _seed(conn):
|
||||
repo.create_project(conn, "p", "/tmp/p")
|
||||
a = repo.create_agent(conn, "p", "a", "/tmp/p/a")
|
||||
return Identity(a["id"], "p", "a", "/tmp/p/a")
|
||||
|
||||
|
||||
def test_stop_blocks_on_failing_tests(conn, monkeypatch):
|
||||
ident = _seed(conn)
|
||||
monkeypatch.setattr(verify, "run_test", lambda cwd: (False, "1 failed"))
|
||||
|
||||
result = checkpoint.handle_stop(conn, ident, HookInput({"session_id": "s1"}, "stop"))
|
||||
assert result["decision"] == "block"
|
||||
assert "test gate failed" in result["reason"]
|
||||
|
||||
cm = repo.get_checkmark(conn, ident.agent_id)
|
||||
assert cm["tests_status"] == "fail"
|
||||
assert cm["status"] == "blocked"
|
||||
# A blocked turn never records "done".
|
||||
assert repo.get_agent_by_name(conn, "p", "a")["status"] == "blocked"
|
||||
|
||||
|
||||
def test_stop_allows_done_on_passing_tests(conn, monkeypatch):
|
||||
ident = _seed(conn)
|
||||
monkeypatch.setattr(verify, "run_test", lambda cwd: (True, "ok"))
|
||||
|
||||
result = checkpoint.handle_stop(conn, ident, HookInput({"session_id": "s1"}, "stop"))
|
||||
assert result == {} # no block
|
||||
|
||||
cm = repo.get_checkmark(conn, ident.agent_id)
|
||||
assert cm["tests_status"] == "pass"
|
||||
assert cm["status"] == "done"
|
||||
assert cm["log_entry_id"] is not None
|
||||
|
||||
|
||||
def test_stop_does_not_reblock_when_already_active(conn, monkeypatch):
|
||||
ident = _seed(conn)
|
||||
monkeypatch.setattr(verify, "run_test", lambda cwd: (False, "still failing"))
|
||||
hi = HookInput({"session_id": "s1", "stop_hook_active": True}, "stop")
|
||||
result = checkpoint.handle_stop(conn, ident, hi)
|
||||
assert result == {} # recorded, but not an infinite block
|
||||
assert repo.get_checkmark(conn, ident.agent_id)["tests_status"] == "fail"
|
||||
|
||||
|
||||
def test_session_end_records_without_gate(conn, monkeypatch):
|
||||
ident = _seed(conn)
|
||||
# Even if tests would fail, SessionEnd must not run the gate or block.
|
||||
monkeypatch.setattr(
|
||||
verify, "run_test", lambda cwd: (_ for _ in ()).throw(AssertionError("gate ran"))
|
||||
)
|
||||
result = checkpoint.handle_session_end(
|
||||
conn, ident, HookInput({"reason": "clear"}, "session_end")
|
||||
)
|
||||
assert result == {}
|
||||
assert repo.get_checkmark(conn, ident.agent_id)["where_it_stopped"].startswith(
|
||||
"session ended"
|
||||
)
|
||||
@@ -0,0 +1,41 @@
|
||||
"""The `python -m handler.hooks <event>` dispatch: stdin parsing + identity from env."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
|
||||
from handler.db import repository as repo
|
||||
from handler.db.engine import get_engine
|
||||
from handler.hooks import __main__ as hook_main
|
||||
from handler.hooks import verify
|
||||
|
||||
|
||||
def _seed(env):
|
||||
with get_engine().begin() as conn:
|
||||
repo.create_project(conn, "p", "/tmp/p")
|
||||
return repo.create_agent(conn, "p", "a", "/tmp/p/a")
|
||||
|
||||
|
||||
def test_dispatch_stop_via_stdin_and_env(env, monkeypatch, capsys):
|
||||
agent = _seed(env)
|
||||
monkeypatch.setenv("HANDLER_AGENT_ID", str(agent["id"]))
|
||||
monkeypatch.setenv("HANDLER_PROJECT_ID", "p")
|
||||
monkeypatch.setenv("HANDLER_AGENT_NAME", "a")
|
||||
monkeypatch.setattr(verify, "run_test", lambda cwd: (True, "ok"))
|
||||
monkeypatch.setattr("sys.stdin", io.StringIO('{"session_id": "s1"}'))
|
||||
|
||||
rc = hook_main.main(["stop"])
|
||||
assert rc == 0
|
||||
|
||||
with get_engine().begin() as conn:
|
||||
assert repo.get_checkmark(conn, agent["id"])["tests_status"] == "pass"
|
||||
|
||||
|
||||
def test_dispatch_unknown_event_is_usage_error(env):
|
||||
assert hook_main.main(["frobnicate"]) == 2
|
||||
|
||||
|
||||
def test_dispatch_unresolvable_identity_returns_1(env, monkeypatch):
|
||||
monkeypatch.delenv("HANDLER_AGENT_ID", raising=False)
|
||||
monkeypatch.setattr("sys.stdin", io.StringIO('{"cwd": "/nowhere"}'))
|
||||
assert hook_main.main(["stop"]) == 1
|
||||
@@ -0,0 +1,79 @@
|
||||
"""PreToolUse: AskUserQuestion defer + git-push gate."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from handler.db import repository as repo
|
||||
from handler.hooks import gate, verify
|
||||
from handler.hooks.context import HookInput, Identity
|
||||
|
||||
|
||||
def _seed(conn):
|
||||
repo.create_project(conn, "p", "/tmp/p")
|
||||
a = repo.create_agent(conn, "p", "a", "/tmp/p/a")
|
||||
return Identity(a["id"], "p", "a", "/tmp/p/a")
|
||||
|
||||
|
||||
def _decision(result):
|
||||
return result["hookSpecificOutput"]["permissionDecision"]
|
||||
|
||||
|
||||
def test_ask_user_question_is_deferred(conn):
|
||||
ident = _seed(conn)
|
||||
hi = HookInput(
|
||||
{
|
||||
"tool_name": "AskUserQuestion",
|
||||
"tool_input": {"questions": [{"question": "Which DB?"}]},
|
||||
"session_id": "s1",
|
||||
},
|
||||
"pre_tool_use",
|
||||
)
|
||||
result = gate.handle_ask_user_question(conn, ident, hi)
|
||||
assert _decision(result) == "deny"
|
||||
|
||||
cm = repo.get_checkmark(conn, ident.agent_id)
|
||||
assert cm["status"] == "paused_for_input"
|
||||
assert cm["open_question"] == "Which DB?"
|
||||
assert repo.get_latest_open_question(conn, ident.agent_id)["question"] == "Which DB?"
|
||||
|
||||
|
||||
def test_git_push_denied_when_tests_fail(conn, monkeypatch):
|
||||
ident = _seed(conn)
|
||||
monkeypatch.setattr(verify, "run_test", lambda cwd: (False, "1 failed"))
|
||||
# Build must not even run when tests fail (cheap check first).
|
||||
monkeypatch.setattr(
|
||||
verify, "run_build", lambda cwd: (_ for _ in ()).throw(AssertionError("built"))
|
||||
)
|
||||
hi = HookInput(
|
||||
{"tool_name": "Bash", "tool_input": {"command": "git push origin main"}},
|
||||
"pre_tool_use",
|
||||
)
|
||||
result = gate.handle_git_push(conn, ident, hi)
|
||||
assert _decision(result) == "deny"
|
||||
assert repo.get_checkmark(conn, ident.agent_id)["tests_status"] == "fail"
|
||||
|
||||
|
||||
def test_git_push_denied_when_build_fails(conn, monkeypatch):
|
||||
ident = _seed(conn)
|
||||
monkeypatch.setattr(verify, "run_test", lambda cwd: (True, "ok"))
|
||||
monkeypatch.setattr(verify, "run_build", lambda cwd: (False, "COPY failed"))
|
||||
hi = HookInput({"tool_name": "Bash", "tool_input": {"command": "git push"}}, "pre_tool_use")
|
||||
result = gate.handle_git_push(conn, ident, hi)
|
||||
assert _decision(result) == "deny"
|
||||
cm = repo.get_checkmark(conn, ident.agent_id)
|
||||
assert cm["tests_status"] == "pass"
|
||||
assert cm["build_status"] == "fail"
|
||||
|
||||
|
||||
def test_git_push_allowed_when_both_pass(conn, monkeypatch):
|
||||
ident = _seed(conn)
|
||||
monkeypatch.setattr(verify, "run_test", lambda cwd: (True, "ok"))
|
||||
monkeypatch.setattr(verify, "run_build", lambda cwd: (True, "built"))
|
||||
hi = HookInput({"tool_name": "Bash", "tool_input": {"command": "git push"}}, "pre_tool_use")
|
||||
result = gate.handle_git_push(conn, ident, hi)
|
||||
assert _decision(result) == "allow"
|
||||
|
||||
|
||||
def test_non_push_bash_is_ignored(conn):
|
||||
ident = _seed(conn)
|
||||
hi = HookInput({"tool_name": "Bash", "tool_input": {"command": "ls -la"}}, "pre_tool_use")
|
||||
assert gate.handle(conn, ident, hi) == {}
|
||||
@@ -0,0 +1,46 @@
|
||||
"""Notification hook: webhook POST only when WEBHOOK_URL is set; log always written."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import httpx
|
||||
import respx
|
||||
|
||||
from handler.db import repository as repo
|
||||
from handler.hooks import notify
|
||||
from handler.hooks.context import HookInput, Identity
|
||||
|
||||
|
||||
def _seed(conn):
|
||||
repo.create_project(conn, "p", "/tmp/p")
|
||||
a = repo.create_agent(conn, "p", "a", "/tmp/p/a")
|
||||
return Identity(a["id"], "p", "a", "/tmp/p/a")
|
||||
|
||||
|
||||
def test_notify_noop_without_webhook(conn, env):
|
||||
ident = _seed(conn)
|
||||
hi = HookInput({"message": "needs input", "session_id": "s1"}, "notification")
|
||||
# WEBHOOK_URL is unset in the env fixture -> no HTTP call, but the log is recorded.
|
||||
notify.handle(conn, ident, hi)
|
||||
assert "notification: needs input" in repo.get_log(conn, ident.agent_id)[0]["summary"]
|
||||
|
||||
|
||||
@respx.mock
|
||||
def test_notify_posts_when_webhook_set(conn, env, monkeypatch):
|
||||
monkeypatch.setenv("WEBHOOK_URL", "https://ntfy.example/topic")
|
||||
from handler import config
|
||||
|
||||
config.get_settings.cache_clear()
|
||||
|
||||
route = respx.post("https://ntfy.example/topic").mock(return_value=httpx.Response(200))
|
||||
ident = _seed(conn)
|
||||
hi = HookInput({"message": "hello", "session_id": "s1"}, "notification")
|
||||
notify.handle(conn, ident, hi)
|
||||
|
||||
assert route.called
|
||||
sent = route.calls[0].request
|
||||
import json
|
||||
|
||||
body = json.loads(sent.content)
|
||||
assert body["project"] == "p"
|
||||
assert body["agent"] == "a"
|
||||
assert body["message"] == "hello"
|
||||
@@ -0,0 +1,54 @@
|
||||
"""DAL read/write functions and the answer backfill."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from handler.db import repository as repo
|
||||
|
||||
|
||||
def test_project_and_agent_crud(conn):
|
||||
repo.create_project(conn, "proj", "/tmp/proj", git_remote="git@x:proj.git")
|
||||
assert repo.get_project(conn, "proj")["root_dir"] == "/tmp/proj"
|
||||
assert [p["id"] for p in repo.list_projects(conn)] == ["proj"]
|
||||
|
||||
a = repo.create_agent(conn, "proj", "api", "/tmp/proj/api")
|
||||
assert a["status"] == "working"
|
||||
assert repo.get_agent_by_name(conn, "proj", "api")["id"] == a["id"]
|
||||
assert repo.get_agent_by_name(conn, "proj", "missing") is None
|
||||
|
||||
|
||||
def test_log_insert_and_answer_backfill(conn):
|
||||
repo.create_project(conn, "p", "/tmp/p")
|
||||
a = repo.create_agent(conn, "p", "a", "/tmp/p/a")
|
||||
|
||||
log_id = repo.insert_log_entry(
|
||||
conn, a["id"], status="paused_for_input", question="Which DB?"
|
||||
)
|
||||
open_q = repo.get_latest_open_question(conn, a["id"])
|
||||
assert open_q["id"] == log_id
|
||||
|
||||
assert repo.update_log_answer(conn, log_id, "Postgres") is True
|
||||
# Once answered, it is no longer an open question.
|
||||
assert repo.get_latest_open_question(conn, a["id"]) is None
|
||||
assert repo.get_log(conn, a["id"])[0]["answer"] == "Postgres"
|
||||
|
||||
|
||||
def test_shared_context_upsert(conn):
|
||||
repo.create_project(conn, "p", "/tmp/p")
|
||||
a = repo.create_agent(conn, "p", "a", "/tmp/p/a")
|
||||
|
||||
repo.set_shared_context(conn, "staging_url", "https://a", a["id"])
|
||||
assert repo.get_shared_context_key(conn, "staging_url")["value"] == "https://a"
|
||||
repo.set_shared_context(conn, "staging_url", "https://b", a["id"])
|
||||
assert repo.get_shared_context_key(conn, "staging_url")["value"] == "https://b"
|
||||
assert len(repo.get_shared_context(conn)) == 1
|
||||
|
||||
|
||||
def test_shared_log_only_global(conn):
|
||||
repo.create_project(conn, "p", "/tmp/p")
|
||||
a = repo.create_agent(conn, "p", "a", "/tmp/p/a")
|
||||
repo.insert_log_entry(conn, a["id"], status="working", summary="private")
|
||||
repo.insert_log_entry(
|
||||
conn, a["id"], status="working", summary="shared", visibility="global"
|
||||
)
|
||||
shared = repo.get_shared_log(conn)
|
||||
assert [e["summary"] for e in shared] == ["shared"]
|
||||
Reference in New Issue
Block a user