From eba0e19ec9b1a7336516bf4e930db444e3950477 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 7 Jul 2026 18:16:54 +0000 Subject: [PATCH] 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 `): 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 Claude-Session: https://claude.ai/code/session_01W5ZuS5pV1NS6eKsRZHXonY --- .env.example | 25 +++ .mise.toml | 16 ++ alembic.ini | 41 ++++ pyproject.toml | 47 ++++ src/handler/__init__.py | 8 + src/handler/api/__init__.py | 1 + src/handler/api/app.py | 32 +++ src/handler/api/deps.py | 64 ++++++ src/handler/api/routes/__init__.py | 1 + src/handler/api/routes/agents.py | 78 +++++++ src/handler/api/routes/common.py | 25 +++ src/handler/api/routes/interaction.py | 86 ++++++++ src/handler/api/routes/projects.py | 34 +++ src/handler/api/routes/shared.py | 51 +++++ src/handler/api/schemas.py | 112 ++++++++++ src/handler/config.py | 48 ++++ src/handler/control/__init__.py | 3 + src/handler/control/cli.py | 108 +++++++++ src/handler/control/settings_gen.py | 49 +++++ src/handler/control/spawn.py | 117 ++++++++++ src/handler/control/tmux.py | 65 ++++++ src/handler/control/worktree.py | 55 +++++ src/handler/db/__init__.py | 1 + src/handler/db/engine.py | 49 +++++ src/handler/db/repository.py | 206 ++++++++++++++++++ src/handler/db/tables.py | 115 ++++++++++ src/handler/db/types.py | 58 +++++ src/handler/db/upsert.py | 38 ++++ src/handler/hooks/__init__.py | 7 + src/handler/hooks/__main__.py | 46 ++++ src/handler/hooks/checkpoint.py | 95 ++++++++ src/handler/hooks/context.py | 97 +++++++++ src/handler/hooks/gate.py | 135 ++++++++++++ src/handler/hooks/notify.py | 46 ++++ src/handler/hooks/verify.py | 44 ++++ src/handler/migrations/env.py | 70 ++++++ src/handler/migrations/script.py.mako | 25 +++ .../migrations/versions/0001_initial.py | 110 ++++++++++ tests/conftest.py | 107 +++++++++ tests/test_api_auth.py | 20 ++ tests/test_api_interaction.py | 76 +++++++ tests/test_api_projects_agents.py | 64 ++++++ tests/test_api_shared.py | 40 ++++ tests/test_control_spawn.py | 91 ++++++++ tests/test_db_types.py | 39 ++++ tests/test_db_upsert.py | 52 +++++ tests/test_hook_checkpoint.py | 65 ++++++ tests/test_hook_dispatch.py | 41 ++++ tests/test_hook_gate.py | 79 +++++++ tests/test_hook_notify.py | 46 ++++ tests/test_repository.py | 54 +++++ 51 files changed, 2982 insertions(+) create mode 100644 .env.example create mode 100644 .mise.toml create mode 100644 alembic.ini create mode 100644 pyproject.toml create mode 100644 src/handler/__init__.py create mode 100644 src/handler/api/__init__.py create mode 100644 src/handler/api/app.py create mode 100644 src/handler/api/deps.py create mode 100644 src/handler/api/routes/__init__.py create mode 100644 src/handler/api/routes/agents.py create mode 100644 src/handler/api/routes/common.py create mode 100644 src/handler/api/routes/interaction.py create mode 100644 src/handler/api/routes/projects.py create mode 100644 src/handler/api/routes/shared.py create mode 100644 src/handler/api/schemas.py create mode 100644 src/handler/config.py create mode 100644 src/handler/control/__init__.py create mode 100644 src/handler/control/cli.py create mode 100644 src/handler/control/settings_gen.py create mode 100644 src/handler/control/spawn.py create mode 100644 src/handler/control/tmux.py create mode 100644 src/handler/control/worktree.py create mode 100644 src/handler/db/__init__.py create mode 100644 src/handler/db/engine.py create mode 100644 src/handler/db/repository.py create mode 100644 src/handler/db/tables.py create mode 100644 src/handler/db/types.py create mode 100644 src/handler/db/upsert.py create mode 100644 src/handler/hooks/__init__.py create mode 100644 src/handler/hooks/__main__.py create mode 100644 src/handler/hooks/checkpoint.py create mode 100644 src/handler/hooks/context.py create mode 100644 src/handler/hooks/gate.py create mode 100644 src/handler/hooks/notify.py create mode 100644 src/handler/hooks/verify.py create mode 100644 src/handler/migrations/env.py create mode 100644 src/handler/migrations/script.py.mako create mode 100644 src/handler/migrations/versions/0001_initial.py create mode 100644 tests/conftest.py create mode 100644 tests/test_api_auth.py create mode 100644 tests/test_api_interaction.py create mode 100644 tests/test_api_projects_agents.py create mode 100644 tests/test_api_shared.py create mode 100644 tests/test_control_spawn.py create mode 100644 tests/test_db_types.py create mode 100644 tests/test_db_upsert.py create mode 100644 tests/test_hook_checkpoint.py create mode 100644 tests/test_hook_dispatch.py create mode 100644 tests/test_hook_gate.py create mode 100644 tests/test_hook_notify.py create mode 100644 tests/test_repository.py diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..6713097 --- /dev/null +++ b/.env.example @@ -0,0 +1,25 @@ +# Handler configuration — copy to .env and fill in. Never commit real secrets. + +# Database. SQLite fallback (single-node) or Postgres (centralized, default for real deploys). +# SQLite: sqlite:////absolute/path/to/handler.db +# Postgres: postgresql+psycopg://user:pass@host:5432/handler +DATABASE_URL=sqlite:////var/lib/handler/handler.db + +# Single global bearer token gating every API route. Required for the API to start. +AUTH_TOKEN=change-me-to-a-long-random-string + +# Optional higher-trust token gating PUT /shared/context/:key. +# Falls back to AUTH_TOKEN if unset. +# SHARED_CONTEXT_WRITE_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 + +# Base directory under which per-project roots and agent worktrees live (isolation). +PROJECTS_ROOT=/var/lib/handler/projects + +# Binary overrides (defaults shown). Point at fakes in tests/CI. +# CLAUDE_BIN=claude +# MISE_BIN=mise +# TMUX_BIN=tmux diff --git a/.mise.toml b/.mise.toml new file mode 100644 index 0000000..69e15f2 --- /dev/null +++ b/.mise.toml @@ -0,0 +1,16 @@ +# Handler dogfoods its own gate: this repo defines the canonical `test` task the +# control layer's Stop hook enforces. Any project Handler manages carries one of these. +[tools] +python = "3.11" + +[tasks.test] +description = "Run the test suite" +run = "pytest" + +[tasks.lint] +description = "Lint the codebase" +run = "ruff check ." + +[tasks.verify] +description = "Lint then test" +depends = ["lint", "test"] diff --git a/alembic.ini b/alembic.ini new file mode 100644 index 0000000..41bbbf9 --- /dev/null +++ b/alembic.ini @@ -0,0 +1,41 @@ +# Alembic config. The database URL is injected from handler.config in env.py, +# so one config serves both the Postgres and SQLite backends — set DATABASE_URL +# and run `alembic upgrade head`. +[alembic] +script_location = src/handler/migrations +prepend_sys_path = src +# sqlalchemy.url is intentionally left blank; env.py fills it from Settings. + +[loggers] +keys = root,sqlalchemy,alembic + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = WARN +handlers = console +qualname = + +[logger_sqlalchemy] +level = WARN +handlers = +qualname = sqlalchemy.engine + +[logger_alembic] +level = INFO +handlers = +qualname = alembic + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = NOTSET +formatter = generic + +[formatter_generic] +format = %(levelname)-5.5s [%(name)s] %(message)s +datefmt = %H:%M:%S diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..5c5bc3b --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,47 @@ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "handler" +version = "0.1.0" +description = "Remote control wrapper for Claude Code agents across isolated projects" +readme = "README.md" +requires-python = ">=3.11" +license = { text = "MIT" } +dependencies = [ + "fastapi>=0.115,<0.116", + "uvicorn[standard]>=0.34,<0.35", + "sqlalchemy>=2.0,<2.1", + "alembic>=1.14,<1.15", + "psycopg[binary]>=3.2,<3.3", + "pydantic>=2.10,<3.0", + "pydantic-settings>=2.7,<3.0", + "httpx>=0.28,<0.29", +] + +[project.optional-dependencies] +dev = [ + "pytest>=8.3,<9.0", + "respx>=0.22,<0.23", + "ruff>=0.9,<0.10", +] + +[project.scripts] +handler = "handler.control.cli:main" + +[tool.hatch.build.targets.wheel] +packages = ["src/handler"] + +[tool.pytest.ini_options] +testpaths = ["tests"] +pythonpath = ["src"] + +[tool.ruff] +src = ["src", "tests"] +line-length = 100 + +[tool.ruff.lint] +select = ["E", "F", "I", "W", "UP", "B"] +# B008: FastAPI's Depends()/Query() in argument defaults is the framework's idiom. +ignore = ["B008"] diff --git a/src/handler/__init__.py b/src/handler/__init__.py new file mode 100644 index 0000000..5032914 --- /dev/null +++ b/src/handler/__init__.py @@ -0,0 +1,8 @@ +"""Handler — remote control wrapper for Claude Code agents. + +See README.md for the full design. Phase 1 (this package) is the control layer + +API: a centralized database, a stateless HTTP read API, a tmux + ``claude`` control +layer as the sole writer, and hook-enforced test/push gates. +""" + +__version__ = "0.1.0" diff --git a/src/handler/api/__init__.py b/src/handler/api/__init__.py new file mode 100644 index 0000000..cbdb02c --- /dev/null +++ b/src/handler/api/__init__.py @@ -0,0 +1 @@ +"""HTTP API — the read layer over the same database (plus the answer backfill).""" diff --git a/src/handler/api/app.py b/src/handler/api/app.py new file mode 100644 index 0000000..fd19174 --- /dev/null +++ b/src/handler/api/app.py @@ -0,0 +1,32 @@ +"""FastAPI application factory. + +Run with: ``uvicorn handler.api.app:create_app --factory``. The UI and any future +integration are just clients of this — same contract as ``curl``. +""" + +from __future__ import annotations + +from fastapi import FastAPI + +from .routes import agents, interaction, projects, shared + + +def create_app() -> FastAPI: + app = FastAPI( + title="Handler API", + version="0.1.0", + summary="Read layer over the Handler control database.", + ) + + @app.get("/health", tags=["meta"]) + def health() -> dict: + return {"status": "ok"} + + app.include_router(projects.router) + app.include_router(agents.router) + app.include_router(interaction.router) + app.include_router(shared.router) + return app + + +app = create_app() diff --git a/src/handler/api/deps.py b/src/handler/api/deps.py new file mode 100644 index 0000000..73831e2 --- /dev/null +++ b/src/handler/api/deps.py @@ -0,0 +1,64 @@ +"""Shared dependencies: bearer auth and a per-request DB connection. + +Auth is a single global token (README 3.3), compared in constant time. Shared-context +writes may require a separate higher-trust token (README 3.4), falling back to the +global token when unset. +""" + +from __future__ import annotations + +import secrets +from collections.abc import Iterator + +from fastapi import Depends, HTTPException, status +from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer +from sqlalchemy import Connection + +from ..config import Settings, get_settings +from ..db.engine import connection + +_bearer = HTTPBearer(auto_error=False) + + +def db_conn() -> Iterator[Connection]: + with connection() as conn: + yield conn + + +def _check(provided: str | None, expected: str) -> bool: + if not expected or not provided: + return False + return secrets.compare_digest(provided, expected) + + +def require_auth( + creds: HTTPAuthorizationCredentials | None = Depends(_bearer), + 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 + ) + if not valid: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="invalid or missing bearer token", + headers={"WWW-Authenticate": "Bearer"}, + ) + + +def require_shared_write( + creds: HTTPAuthorizationCredentials | None = Depends(_bearer), + settings: Settings = Depends(get_settings), +) -> None: + """Gate for shared_context writes — the one table every project implicitly trusts.""" + token = creds.credentials if creds else None + if not _check(token, settings.effective_shared_write_token): + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="shared-context write requires the shared-context write token", + headers={"WWW-Authenticate": "Bearer"}, + ) diff --git a/src/handler/api/routes/__init__.py b/src/handler/api/routes/__init__.py new file mode 100644 index 0000000..fb0a2f8 --- /dev/null +++ b/src/handler/api/routes/__init__.py @@ -0,0 +1 @@ +"""API route modules.""" diff --git a/src/handler/api/routes/agents.py b/src/handler/api/routes/agents.py new file mode 100644 index 0000000..2fc27d0 --- /dev/null +++ b/src/handler/api/routes/agents.py @@ -0,0 +1,78 @@ +"""Agent listing/registration and the read views (checkmark, log). + +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. +""" + +from __future__ import annotations + +from fastapi import APIRouter, Depends, HTTPException, Query, status +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 .common import resolve_agent + +router = APIRouter( + prefix="/projects/{project}/agents", + tags=["agents"], + 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[AgentOut]) +def list_agents(project: str, conn: Connection = Depends(db_conn)) -> list[dict]: + _require_project(conn, project) + return repo.list_agents(conn, project) + + +@router.post("", response_model=AgentOut, status_code=status.HTTP_201_CREATED) +def create_agent(project: str, body: AgentIn, conn: Connection = Depends(db_conn)) -> dict: + _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}' exists in project '{project}'", + ) + try: + return repo.create_agent( + conn, + project_id=project, + name=body.name, + working_dir=body.working_dir, + status=body.status, + ) + except IntegrityError as exc: # pragma: no cover - guarded above + raise HTTPException(status.HTTP_409_CONFLICT, detail="agent exists") from exc + + +@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) + checkmark = repo.get_checkmark(conn, agent["id"]) + if checkmark is None: + raise HTTPException( + status.HTTP_404_NOT_FOUND, + detail=f"agent '{name}' has no checkmark yet", + ) + return checkmark + + +@router.get("/{name}/log", response_model=list[LogEntryOut]) +def get_log( + project: str, + name: str, + limit: int = Query(100, ge=1, le=500), + offset: int = Query(0, ge=0), + conn: Connection = Depends(db_conn), +) -> list[dict]: + agent = resolve_agent(conn, project, name) + return repo.get_log(conn, agent["id"], limit=limit, offset=offset) diff --git a/src/handler/api/routes/common.py b/src/handler/api/routes/common.py new file mode 100644 index 0000000..75a3187 --- /dev/null +++ b/src/handler/api/routes/common.py @@ -0,0 +1,25 @@ +"""Small route helpers shared across agent-scoped endpoints.""" + +from __future__ import annotations + +from fastapi import HTTPException, status +from sqlalchemy import Connection + +from ...db import repository as repo + + +def resolve_agent(conn: Connection, project: str, name: str) -> dict: + """Fetch an agent by ``(project, name)`` or 404. + + Enforces project isolation (README 3.4): the lookup is always project-scoped, so + there is no path that returns another project's agent by accident. + """ + if repo.get_project(conn, project) is None: + raise HTTPException(status.HTTP_404_NOT_FOUND, detail=f"project '{project}' not found") + agent = repo.get_agent_by_name(conn, project, name) + if agent is None: + raise HTTPException( + status.HTTP_404_NOT_FOUND, + detail=f"agent '{name}' not found in project '{project}'", + ) + return agent diff --git a/src/handler/api/routes/interaction.py b/src/handler/api/routes/interaction.py new file mode 100644 index 0000000..d55cd1a --- /dev/null +++ b/src/handler/api/routes/interaction.py @@ -0,0 +1,86 @@ +"""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. +""" + +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 .common import resolve_agent + +router = APIRouter( + prefix="/projects/{project}/agents/{name}", + tags=["interaction"], + dependencies=[Depends(require_auth)], +) + + +@router.post("/answer", response_model=AnswerOut) +def answer( + project: str, + name: str, + body: AnswerIn, + conn: Connection = Depends(db_conn), +) -> AnswerOut: + agent = resolve_agent(conn, project, name) + + if body.log_entry_id is not None: + log_entry_id = body.log_entry_id + else: + open_q = repo.get_latest_open_question(conn, agent["id"]) + if open_q is None: + raise HTTPException( + status.HTTP_404_NOT_FOUND, + detail="no open question to answer; pass log_entry_id explicitly", + ) + log_entry_id = open_q["id"] + + updated = repo.update_log_answer(conn, log_entry_id, body.answer) + if not updated: + raise HTTPException(status.HTTP_404_NOT_FOUND, detail="log entry not found") + + # The question is answered but the agent is not resumed yet; leave status as + # paused_for_input until /resume actually feeds it back. + return AnswerOut(log_entry_id=log_entry_id, answered=True) + + +@router.post("/resume", response_model=ResumeOut) +def resume( + project: str, + name: str, + body: ResumeIn, + conn: Connection = Depends(db_conn), +) -> ResumeOut: + agent = resolve_agent(conn, project, name) + + answer_text = body.answer + if answer_text is None: + open_q = repo.get_latest_open_question(conn, agent["id"]) + # The just-answered question no longer counts as open, so pull the most recent + # answered entry if no explicit answer was supplied. + if open_q is not None and open_q.get("answer"): + answer_text = open_q["answer"] + else: + recent = repo.get_log(conn, agent["id"], limit=1) + if recent and recent[0].get("answer"): + answer_text = recent[0]["answer"] + if not answer_text: + raise HTTPException( + status.HTTP_400_BAD_REQUEST, + 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) diff --git a/src/handler/api/routes/projects.py b/src/handler/api/routes/projects.py new file mode 100644 index 0000000..7db585d --- /dev/null +++ b/src/handler/api/routes/projects.py @@ -0,0 +1,34 @@ +"""Project registration + listing (control-plane; the process spawn is the CLI's job).""" + +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_auth +from ..schemas import ProjectIn, ProjectOut + +router = APIRouter(prefix="/projects", tags=["projects"], dependencies=[Depends(require_auth)]) + + +@router.get("", response_model=list[ProjectOut]) +def list_projects(conn: Connection = Depends(db_conn)) -> list[dict]: + return repo.list_projects(conn) + + +@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: + raise HTTPException(status.HTTP_409_CONFLICT, detail=f"project '{body.id}' exists") + try: + return repo.create_project( + conn, + project_id=body.id, + root_dir=body.root_dir, + git_remote=body.git_remote, + credential_ref=body.credential_ref, + ) + except IntegrityError as exc: # pragma: no cover - guarded above + raise HTTPException(status.HTTP_409_CONFLICT, detail="project exists") from exc diff --git a/src/handler/api/routes/shared.py b/src/handler/api/routes/shared.py new file mode 100644 index 0000000..931414d --- /dev/null +++ b/src/handler/api/routes/shared.py @@ -0,0 +1,51 @@ +"""The two explicit cross-project paths (README 3.4): the global log feed and the +shared-context key/value store. Reads use the normal token; writing a shared-context +key requires the higher-trust token. +""" + +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_auth, require_shared_write +from ..schemas import LogEntryOut, SharedContextIn, SharedContextOut + +router = APIRouter(prefix="/shared", tags=["shared"], dependencies=[Depends(require_auth)]) + + +@router.get("/log", response_model=list[LogEntryOut]) +def shared_log( + limit: int = Query(100, ge=1, le=500), + offset: int = Query(0, ge=0), + conn: Connection = Depends(db_conn), +) -> list[dict]: + """Only entries explicitly marked ``global`` — a deliberate opt-in feed.""" + return repo.get_shared_log(conn, limit=limit, offset=offset) + + +@router.get("/context", response_model=list[SharedContextOut]) +def shared_context(conn: Connection = Depends(db_conn)) -> list[dict]: + return repo.get_shared_context(conn) + + +@router.get("/context/{key}", response_model=SharedContextOut) +def shared_context_key(key: str, conn: Connection = Depends(db_conn)) -> dict: + row = repo.get_shared_context_key(conn, key) + if row is None: + raise HTTPException(status.HTTP_404_NOT_FOUND, detail=f"key '{key}' not set") + return row + + +@router.put( + "/context/{key}", + response_model=SharedContextOut, + dependencies=[Depends(require_shared_write)], +) +def put_shared_context( + key: str, + body: SharedContextIn, + conn: Connection = Depends(db_conn), +) -> dict: + return repo.set_shared_context(conn, key, body.value, agent_id=None) diff --git a/src/handler/api/schemas.py b/src/handler/api/schemas.py new file mode 100644 index 0000000..74f54de --- /dev/null +++ b/src/handler/api/schemas.py @@ -0,0 +1,112 @@ +"""Pydantic request/response models. ``from_attributes`` lets us hand a DB row +mapping straight in; timestamps serialize as ISO-8601. +""" + +from __future__ import annotations + +from datetime import datetime + +from pydantic import BaseModel, ConfigDict + + +class ProjectIn(BaseModel): + id: str + root_dir: str + git_remote: str | None = None + credential_ref: str | None = None + + +class ProjectOut(BaseModel): + model_config = ConfigDict(from_attributes=True) + + id: str + root_dir: str + git_remote: str | None = None + credential_ref: str | None = None + created_at: datetime + + +class AgentIn(BaseModel): + name: str + working_dir: str + status: str = "working" + + +class AgentOut(BaseModel): + model_config = ConfigDict(from_attributes=True) + + id: int + project_id: str + name: str + working_dir: str + status: str + created_at: datetime + + +class CheckmarkOut(BaseModel): + model_config = ConfigDict(from_attributes=True) + + agent_id: int + checkpoint_at: datetime + status: str + where_it_stopped: str | None = None + next_steps: list[str] | None = None + open_question: str | None = None + log_entry_id: int | None = None + tests_status: str + tested_at: datetime | None = None + build_status: str + built_at: datetime | None = None + + +class LogEntryOut(BaseModel): + model_config = ConfigDict(from_attributes=True) + + id: int + agent_id: int + created_at: datetime + session_id: str | None = None + status: str + summary: str | None = None + decisions: str | None = None + question: str | None = None + answer: str | None = None + visibility: str + push_sha: str | None = None + ci_status: str + ci_checked_at: datetime | None = None + + +class AnswerIn(BaseModel): + answer: str + # If omitted, the answer targets the agent's latest open question. + log_entry_id: int | None = None + + +class AnswerOut(BaseModel): + log_entry_id: int + answered: bool + + +class ResumeIn(BaseModel): + # Optional explicit answer to feed back; if omitted, the stored answer is used. + answer: str | None = None + + +class ResumeOut(BaseModel): + agent: str + resumed: bool + detail: str + + +class SharedContextIn(BaseModel): + value: str + + +class SharedContextOut(BaseModel): + model_config = ConfigDict(from_attributes=True) + + key: str + value: str + set_by_agent_id: int | None = None + updated_at: datetime diff --git a/src/handler/config.py b/src/handler/config.py new file mode 100644 index 0000000..d9fe355 --- /dev/null +++ b/src/handler/config.py @@ -0,0 +1,48 @@ +"""Single source of env-driven configuration. + +Every entrypoint — the API app, the control CLI, and each hook subprocess — reads +the same :class:`Settings`. A spawned agent's hooks reach the same database purely +by inheriting ``DATABASE_URL`` in their environment (see ``control.spawn``). +""" + +from __future__ import annotations + +from functools import lru_cache + +from pydantic_settings import BaseSettings, SettingsConfigDict + + +class Settings(BaseSettings): + model_config = SettingsConfigDict(env_file=".env", extra="ignore") + + # Datastore. Drives dialect selection everywhere; nothing else branches on + # "is it sqlite" except db.upsert. + database_url: str = "sqlite:///./handler.db" + + # The single global bearer token gating every API route (README 3.3). + auth_token: str = "" + + # Optional higher-trust token for PUT /shared/context/:key. Falls back to + # auth_token when unset (README 3.4 open question, resolved to "gate it"). + shared_context_write_token: str | None = None + + # Optional generic webhook target for the Notification hook. No-op when unset. + webhook_url: str | None = None + + # Base directory under which per-project roots / agent worktrees live. + projects_root: str = "./projects" + + # Binary overrides so tests/CI can point at fakes. + claude_bin: str = "claude" + mise_bin: str = "mise" + tmux_bin: str = "tmux" + + @property + def effective_shared_write_token(self) -> str: + """Token required to write shared_context; defaults to the global token.""" + return self.shared_context_write_token or self.auth_token + + +@lru_cache +def get_settings() -> Settings: + return Settings() diff --git a/src/handler/control/__init__.py b/src/handler/control/__init__.py new file mode 100644 index 0000000..005fee0 --- /dev/null +++ b/src/handler/control/__init__.py @@ -0,0 +1,3 @@ +"""Control layer — the CLI wrapper, the only thing that spawns agents and writes +their rows. Stateless: all state goes straight to the database. +""" diff --git a/src/handler/control/cli.py b/src/handler/control/cli.py new file mode 100644 index 0000000..dcb4888 --- /dev/null +++ b/src/handler/control/cli.py @@ -0,0 +1,108 @@ +"""``handler`` CLI — spawn/list/attach/kill. + +The DB is the source of truth for what agents exist; tmux is cross-checked for +liveness. All commands are project-namespaced. +""" + +from __future__ import annotations + +import argparse +import os +import sys + +from ..db import repository as repo +from ..db.engine import connection +from . import spawn, tmux + + +def _cmd_spawn(args: argparse.Namespace) -> int: + try: + agent = spawn.spawn( + args.project, + args.name, + subdir=args.dir, + worktree_branch=args.worktree, + task=args.task, + ) + except spawn.SpawnError as exc: + print(f"error: {exc}", file=sys.stderr) + return 1 + print(f"spawned agent '{agent['name']}' (id={agent['id']}) in project '{args.project}'") + print(f" working_dir: {agent['working_dir']}") + print(f" tmux session: {tmux.session_name(args.project, args.name)}") + return 0 + + +def _cmd_list(args: argparse.Namespace) -> int: + live = set(tmux.list_sessions()) + with connection() as conn: + if args.project: + projects = [args.project] if repo.get_project(conn, args.project) else [] + else: + projects = [p["id"] for p in repo.list_projects(conn)] + for project_id in projects: + for agent in repo.list_agents(conn, project_id): + session = tmux.session_name(project_id, agent["name"]) + alive = "live" if session in live else "-" + print(f"{project_id}/{agent['name']}\t{agent['status']}\t{alive}\t{session}") + return 0 + + +def _cmd_attach(args: argparse.Namespace) -> int: + session = tmux.session_name(args.project, args.name) + if not tmux.has_session(session): + print(f"error: no live session '{session}'", file=sys.stderr) + return 1 + # Replace this process with an interactive tmux attach. + os.execvp("tmux", ["tmux", "attach", "-t", session]) + return 0 # pragma: no cover - execvp does not return + + +def _cmd_kill(args: argparse.Namespace) -> int: + try: + spawn.kill(args.project, args.name) + except spawn.SpawnError as exc: + print(f"error: {exc}", file=sys.stderr) + return 1 + print(f"killed '{args.project}/{args.name}'") + return 0 + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(prog="handler", description="Handler control layer") + sub = parser.add_subparsers(dest="command", required=True) + + p_spawn = sub.add_parser("spawn", help="spawn an agent") + p_spawn.add_argument("--project", required=True) + p_spawn.add_argument("--name", required=True) + group = p_spawn.add_mutually_exclusive_group() + group.add_argument("--worktree", metavar="BRANCH", help="git worktree on BRANCH") + group.add_argument("--dir", metavar="SUBDIR", help="subdirectory under project root") + p_spawn.add_argument("--task", help="initial task/prompt for the agent") + p_spawn.set_defaults(func=_cmd_spawn) + + p_list = sub.add_parser("list", help="list agents") + p_list.add_argument("--project", help="limit to one project") + p_list.set_defaults(func=_cmd_list) + + p_attach = sub.add_parser("attach", help="attach to an agent's tmux session") + p_attach.add_argument("--project", required=True) + p_attach.add_argument("--name", required=True) + p_attach.set_defaults(func=_cmd_attach) + + p_kill = sub.add_parser("kill", help="kill an agent's session") + p_kill.add_argument("--project", required=True) + p_kill.add_argument("--name", required=True) + p_kill.set_defaults(func=_cmd_kill) + + return parser + + +def main(argv: list[str] | None = None) -> int: + parser = build_parser() + args = parser.parse_args(argv) + return args.func(args) + + +if __name__ == "__main__": # pragma: no cover + raise SystemExit(main()) diff --git a/src/handler/control/settings_gen.py b/src/handler/control/settings_gen.py new file mode 100644 index 0000000..c268e4a --- /dev/null +++ b/src/handler/control/settings_gen.py @@ -0,0 +1,49 @@ +"""Generate the per-agent Claude Code ``settings.json`` that wires each hook event to +``python -m handler.hooks ``. + +This is the declarative half of hook integration; the imperative half — the agent +identity and ``DATABASE_URL`` — is injected as environment via tmux (see +``control.spawn``), because hook stdin does not carry our identity. +""" + +from __future__ import annotations + +import json +import os +import sys + + +def _hook_command(event: str) -> str: + # Use the exact interpreter the control layer runs under, so the hook resolves the + # same handler package and virtualenv inside the tmux session. + return f"{sys.executable} -m handler.hooks {event}" + + +def build_settings() -> dict: + return { + "hooks": { + "Stop": [{"hooks": [{"type": "command", "command": _hook_command("stop")}]}], + "SessionEnd": [ + {"hooks": [{"type": "command", "command": _hook_command("session_end")}]} + ], + "PreToolUse": [ + { + "matcher": "AskUserQuestion|Bash", + "hooks": [{"type": "command", "command": _hook_command("pre_tool_use")}], + } + ], + "Notification": [ + {"hooks": [{"type": "command", "command": _hook_command("notification")}]} + ], + } + } + + +def write_settings(working_dir: str) -> str: + """Write ``.claude/settings.json`` under the agent's working dir; return its path.""" + claude_dir = os.path.join(working_dir, ".claude") + os.makedirs(claude_dir, exist_ok=True) + path = os.path.join(claude_dir, "settings.json") + with open(path, "w") as fh: + json.dump(build_settings(), fh, indent=2) + return path diff --git a/src/handler/control/spawn.py b/src/handler/control/spawn.py new file mode 100644 index 0000000..473cde3 --- /dev/null +++ b/src/handler/control/spawn.py @@ -0,0 +1,117 @@ +"""Spawn orchestration: the ``.mise.toml`` gate, the agent row, the generated +settings, identity/env injection, and the tmux launch — plus the resume seam the API +calls. + +Order matters: the hard ``test``-task gate is checked *before* any row is written or +process launched, so a project without a canonical test task never gets an agent +(README 3.5, resolved as a hard requirement). +""" + +from __future__ import annotations + +import os +import tomllib + +from ..config import get_settings +from ..db import repository as repo +from ..db.engine import connection +from . import settings_gen, tmux, worktree + + +class SpawnError(Exception): + """Raised when an agent cannot be spawned (missing project, no test task, ...).""" + + +def require_test_task(working_dir: str) -> None: + """Hard gate: refuse to spawn unless ``.mise.toml`` defines ``[tasks.test]``.""" + mise_path = os.path.join(working_dir, ".mise.toml") + if not os.path.exists(mise_path): + raise SpawnError( + f"no .mise.toml in {working_dir}: a project must define a [tasks.test] task " + "before an agent can run against it" + ) + with open(mise_path, "rb") as fh: + data = tomllib.load(fh) + tasks = data.get("tasks", {}) + if "test" not in tasks: + raise SpawnError( + f".mise.toml in {working_dir} has no [tasks.test]: the verification gate " + "requires a canonical test task" + ) + + +def _claude_command(task: str | None, settings_path: str) -> str: + claude = get_settings().claude_bin + argv = [claude, "--settings", settings_path] + if task: + argv.append(_shell_quote(task)) + return " ".join(argv) + + +def _shell_quote(value: str) -> str: + return "'" + value.replace("'", "'\\''") + "'" + + +def spawn( + project_id: str, + name: str, + *, + subdir: str | None = None, + worktree_branch: str | None = None, + task: str | None = None, +) -> dict: + """Create and launch an agent. Returns the agent row.""" + with connection() as conn: + project = repo.get_project(conn, project_id) + if project is None: + raise SpawnError(f"project '{project_id}' not registered") + if repo.get_agent_by_name(conn, project_id, name) is not None: + raise SpawnError(f"agent '{name}' already exists in project '{project_id}'") + + working_dir = worktree.resolve_working_dir( + project["root_dir"], name, subdir=subdir, worktree_branch=worktree_branch + ) + + # Hard gate before any state is written or process launched. + require_test_task(working_dir) + + agent = repo.create_agent( + conn, project_id=project_id, name=name, working_dir=working_dir, status="working" + ) + + settings_path = settings_gen.write_settings(working_dir) + + env = { + "HANDLER_PROJECT_ID": project_id, + "HANDLER_AGENT_NAME": name, + "HANDLER_AGENT_ID": str(agent["id"]), + "DATABASE_URL": get_settings().database_url, + } + session = tmux.session_name(project_id, name) + command = _claude_command(task, settings_path) + tmux.new_session(session, cwd=working_dir, command=command, env=env) + return agent + + +def kill(project_id: str, name: str) -> None: + with connection() as conn: + agent = repo.get_agent_by_name(conn, project_id, name) + if agent is None: + raise SpawnError(f"agent '{name}' not found in project '{project_id}'") + session = tmux.session_name(project_id, name) + if tmux.has_session(session): + tmux.kill_session(session) + repo.set_agent_status(conn, agent["id"], "done") + + +def resume(agent: dict, answer: str) -> tuple[bool, str]: + """Feed an operator's answer back to a live agent. + + The seam the API's ``/resume`` route calls (and the one tests mock). Sends the + answer into the agent's tmux session so the waiting ``claude`` process receives it. + """ + session = tmux.session_name(agent["project_id"], agent["name"]) + if not tmux.has_session(session): + return False, f"no live session '{session}' to resume" + tmux.send_keys(session, answer) + return True, f"answer delivered to session '{session}'" diff --git a/src/handler/control/tmux.py b/src/handler/control/tmux.py new file mode 100644 index 0000000..a216fb2 --- /dev/null +++ b/src/handler/control/tmux.py @@ -0,0 +1,65 @@ +"""Thin tmux wrapper — the single mock seam for spawning. + +Every tmux/claude invocation goes through these functions so tests can substitute a +fake and never touch a real tmux server or ``claude`` binary. +""" + +from __future__ import annotations + +import subprocess + +from ..config import get_settings + + +def session_name(project_id: str, agent_name: str) -> str: + """``project__agent`` with tmux-illegal characters sanitized (README 3.4).""" + safe = f"{project_id}__{agent_name}" + for ch in (".", ":", " "): + safe = safe.replace(ch, "-") + return safe + + +def new_session(name: str, cwd: str, command: str, env: dict[str, str]) -> None: + """Launch a detached tmux session running ``command`` in ``cwd`` with ``env`` set. + + ``tmux -e`` sets session environment, so the ``claude`` process (and therefore its + hooks) inherit the agent identity + ``DATABASE_URL``. + """ + tmux = get_settings().tmux_bin + argv = [tmux, "new-session", "-d", "-s", name, "-c", cwd] + for key, value in env.items(): + argv += ["-e", f"{key}={value}"] + argv.append(command) + subprocess.run(argv, check=True) + + +def has_session(name: str) -> bool: + tmux = get_settings().tmux_bin + result = subprocess.run( + [tmux, "has-session", "-t", name], + capture_output=True, + ) + return result.returncode == 0 + + +def list_sessions() -> list[str]: + tmux = get_settings().tmux_bin + result = subprocess.run( + [tmux, "list-sessions", "-F", "#{session_name}"], + capture_output=True, + text=True, + ) + if result.returncode != 0: + return [] + return [line for line in result.stdout.splitlines() if line] + + +def kill_session(name: str) -> None: + tmux = get_settings().tmux_bin + subprocess.run([tmux, "kill-session", "-t", name], check=True) + + +def send_keys(name: str, keys: str) -> None: + """Send a line of input to a live session (used by the resume seam).""" + tmux = get_settings().tmux_bin + subprocess.run([tmux, "send-keys", "-t", name, keys, "Enter"], check=True) diff --git a/src/handler/control/worktree.py b/src/handler/control/worktree.py new file mode 100644 index 0000000..696026b --- /dev/null +++ b/src/handler/control/worktree.py @@ -0,0 +1,55 @@ +"""Per-agent working directory setup — a subdirectory under the project root, or a +git worktree. The isolation invariant: the resulting path is always under the project +root (README 3.4), never reaching into another project's tree. +""" + +from __future__ import annotations + +import os +import subprocess + + +class IsolationError(Exception): + """Raised when a requested working dir would escape the project root.""" + + +def _under(root: str, path: str) -> bool: + root_abs = os.path.realpath(root) + path_abs = os.path.realpath(path) + return path_abs == root_abs or path_abs.startswith(root_abs + os.sep) + + +def resolve_working_dir( + project_root: str, + agent_name: str, + *, + subdir: str | None = None, + worktree_branch: str | None = None, +) -> str: + """Return (and, for worktrees, create) the agent's working directory. + + - ``subdir``: an existing/created subdirectory under the project root. + - ``worktree_branch``: ``git worktree add / ``. + - neither: the project root itself. + """ + if subdir and worktree_branch: + raise ValueError("pass at most one of subdir / worktree_branch") + + if worktree_branch: + target = os.path.join(project_root, agent_name) + if not _under(project_root, target): + raise IsolationError(f"{target} escapes project root {project_root}") + subprocess.run( + ["git", "-C", project_root, "worktree", "add", target, worktree_branch], + check=True, + ) + return target + + if subdir: + target = os.path.join(project_root, subdir) + if not _under(project_root, target): + raise IsolationError(f"{target} escapes project root {project_root}") + os.makedirs(target, exist_ok=True) + return target + + return project_root diff --git a/src/handler/db/__init__.py b/src/handler/db/__init__.py new file mode 100644 index 0000000..352b654 --- /dev/null +++ b/src/handler/db/__init__.py @@ -0,0 +1 @@ +"""Data-access layer: one schema, two dialects (Postgres + SQLite).""" diff --git a/src/handler/db/engine.py b/src/handler/db/engine.py new file mode 100644 index 0000000..66fb050 --- /dev/null +++ b/src/handler/db/engine.py @@ -0,0 +1,49 @@ +"""Engine construction + connection helper. + +The engine is built from ``Settings.database_url``. For SQLite we register a +connect-time listener issuing ``PRAGMA foreign_keys=ON`` — SQLite leaves FK +enforcement off by default, which would make every FK (including the +checkmarks<->log_entries cycle) cosmetic. +""" + +from __future__ import annotations + +from collections.abc import Iterator +from contextlib import contextmanager +from functools import lru_cache + +from sqlalchemy import Connection, Engine, create_engine, event + +from ..config import get_settings + + +def _make_engine(url: str) -> Engine: + connect_args: dict = {} + if url.startswith("sqlite"): + # Allow use across threads (FastAPI request threads, test client). + connect_args["check_same_thread"] = False + + engine = create_engine(url, connect_args=connect_args, future=True) + + if engine.dialect.name == "sqlite": + + @event.listens_for(engine, "connect") + def _fk_pragma(dbapi_conn, _record): # noqa: ANN001 + cur = dbapi_conn.cursor() + cur.execute("PRAGMA foreign_keys=ON") + cur.close() + + return engine + + +@lru_cache +def get_engine() -> Engine: + return _make_engine(get_settings().database_url) + + +@contextmanager +def connection() -> Iterator[Connection]: + """A transactional connection (commit on success, rollback on error).""" + engine = get_engine() + with engine.begin() as conn: + yield conn diff --git a/src/handler/db/repository.py b/src/handler/db/repository.py new file mode 100644 index 0000000..6ce2cd3 --- /dev/null +++ b/src/handler/db/repository.py @@ -0,0 +1,206 @@ +"""Data-access layer — every read and every write, one statement per function. + +Writer discipline (README 3.2 / 3.3): the backend (control layer + hooks) is the only +thing that writes agent/checkmark/log rows; the API only reads, plus the single +``update_log_answer`` backfill on resume, plus control-plane registration +(``create_project`` / ``create_agent``, which the API mirrors). This is enforced by +import convention — the API package imports only the read functions and +``update_log_answer``; control/hooks import the write functions. A single global token +means we can't enforce it at the DB-permission level, so it is a code-organization +guarantee. + +All functions take a live :class:`~sqlalchemy.Connection`; timestamps are set here as +UTC-aware datetimes rather than relying on server defaults, so SQLite and Postgres +agree on the exact value. +""" + +from __future__ import annotations + +from datetime import UTC, datetime +from typing import Any + +from sqlalchemy import Connection, select + +from .tables import agents, checkmarks, log_entries, projects, shared_context +from .upsert import upsert_checkmark + + +def _now() -> datetime: + return datetime.now(UTC) + + +def _row_to_dict(row) -> dict[str, Any] | None: + return dict(row._mapping) if row is not None else None + + +# --------------------------------------------------------------------------- reads + + +def list_projects(conn: Connection) -> list[dict]: + rows = conn.execute(select(projects).order_by(projects.c.id)).all() + return [dict(r._mapping) for r in rows] + + +def get_project(conn: Connection, project_id: str) -> dict | None: + row = conn.execute(select(projects).where(projects.c.id == project_id)).first() + return _row_to_dict(row) + + +def list_agents(conn: Connection, project_id: str) -> list[dict]: + rows = conn.execute( + select(agents).where(agents.c.project_id == project_id).order_by(agents.c.name) + ).all() + return [dict(r._mapping) for r in rows] + + +def get_agent_by_name(conn: Connection, project_id: str, name: str) -> dict | None: + row = conn.execute( + select(agents).where(agents.c.project_id == project_id, agents.c.name == name) + ).first() + return _row_to_dict(row) + + +def get_checkmark(conn: Connection, agent_id: int) -> dict | None: + row = conn.execute(select(checkmarks).where(checkmarks.c.agent_id == agent_id)).first() + return _row_to_dict(row) + + +def get_log(conn: Connection, agent_id: int, limit: int = 100, offset: int = 0) -> list[dict]: + rows = conn.execute( + select(log_entries) + .where(log_entries.c.agent_id == agent_id) + .order_by(log_entries.c.id.desc()) + .limit(limit) + .offset(offset) + ).all() + return [dict(r._mapping) for r in rows] + + +def get_latest_open_question(conn: Connection, agent_id: int) -> dict | None: + """The most recent log entry that recorded a question and has no answer yet.""" + row = conn.execute( + select(log_entries) + .where( + log_entries.c.agent_id == agent_id, + log_entries.c.question.is_not(None), + log_entries.c.answer.is_(None), + ) + .order_by(log_entries.c.id.desc()) + .limit(1) + ).first() + return _row_to_dict(row) + + +def get_shared_log(conn: Connection, limit: int = 100, offset: int = 0) -> list[dict]: + """Only entries an agent (or the operator) explicitly marked ``global``.""" + rows = conn.execute( + select(log_entries) + .where(log_entries.c.visibility == "global") + .order_by(log_entries.c.id.desc()) + .limit(limit) + .offset(offset) + ).all() + return [dict(r._mapping) for r in rows] + + +def get_shared_context(conn: Connection) -> list[dict]: + rows = conn.execute(select(shared_context).order_by(shared_context.c.key)).all() + return [dict(r._mapping) for r in rows] + + +def get_shared_context_key(conn: Connection, key: str) -> dict | None: + row = conn.execute(select(shared_context).where(shared_context.c.key == key)).first() + return _row_to_dict(row) + + +# -------------------------------------------------------------------------- writes + + +def create_project( + conn: Connection, + project_id: str, + root_dir: str, + git_remote: str | None = None, + credential_ref: str | None = None, +) -> dict: + conn.execute( + projects.insert().values( + id=project_id, + root_dir=root_dir, + git_remote=git_remote, + credential_ref=credential_ref, + created_at=_now(), + ) + ) + return get_project(conn, project_id) + + +def create_agent( + conn: Connection, + project_id: str, + name: str, + working_dir: str, + status: str = "working", +) -> dict: + result = conn.execute( + agents.insert().values( + project_id=project_id, + name=name, + working_dir=working_dir, + status=status, + created_at=_now(), + ) + ) + agent_id = result.inserted_primary_key[0] + row = conn.execute(select(agents).where(agents.c.id == agent_id)).first() + return dict(row._mapping) + + +def set_agent_status(conn: Connection, agent_id: int, status: str) -> None: + conn.execute(agents.update().where(agents.c.id == agent_id).values(status=status)) + + +def insert_log_entry(conn: Connection, agent_id: int, status: str, **fields: Any) -> int: + values = {"agent_id": agent_id, "status": status, "created_at": _now(), **fields} + result = conn.execute(log_entries.insert().values(**values)) + return result.inserted_primary_key[0] + + +def update_log_answer(conn: Connection, log_entry_id: int, answer: str) -> bool: + """The one post-insert mutation on log_entries — the answer backfill on resume.""" + result = conn.execute( + log_entries.update() + .where(log_entries.c.id == log_entry_id) + .values(answer=answer) + ) + return result.rowcount > 0 + + +def upsert_checkmark_row(conn: Connection, agent_id: int, **fields: Any) -> None: + """Overwrite the agent's checkmark (see :func:`db.upsert.upsert_checkmark`).""" + values = {"agent_id": agent_id, **fields} + values.setdefault("checkpoint_at", _now()) + upsert_checkmark(conn, values) + + +def set_shared_context(conn: Connection, key: str, value: str, agent_id: int | None) -> dict: + """Upsert one shared-context key (the one table every project implicitly trusts).""" + dialect = conn.dialect.name + if dialect == "postgresql": + from sqlalchemy.dialects.postgresql import insert as ins + else: + from sqlalchemy.dialects.sqlite import insert as ins + + stmt = ins(shared_context).values( + key=key, value=value, set_by_agent_id=agent_id, updated_at=_now() + ) + stmt = stmt.on_conflict_do_update( + index_elements=["key"], + set_={ + "value": stmt.excluded.value, + "set_by_agent_id": stmt.excluded.set_by_agent_id, + "updated_at": stmt.excluded.updated_at, + }, + ) + conn.execute(stmt) + return get_shared_context_key(conn, key) diff --git a/src/handler/db/tables.py b/src/handler/db/tables.py new file mode 100644 index 0000000..ef27482 --- /dev/null +++ b/src/handler/db/tables.py @@ -0,0 +1,115 @@ +"""The schema — one ``MetaData``, six tables, mapping README section 3.1 exactly. + +SQLAlchemy Core (not the ORM): the workload is a handful of explicit statements, and +Core keeps the same schema rendering correctly on both dialects with no session +lifecycle to manage across the API, CLI, and hook subprocesses. +""" + +from __future__ import annotations + +from sqlalchemy import ( + BigInteger, + CheckConstraint, + Column, + ForeignKey, + MetaData, + String, + Table, + UniqueConstraint, + func, +) + +from .types import PortableBigInt, PortableJSON, PortableTimestamp + +metadata = MetaData() + +# Status vocabularies kept as free TEXT (README uses plain strings, not PG enums, so +# both dialects match). CheckConstraints make the allowed sets explicit and portable. +AGENT_STATUSES = ("working", "paused_for_input", "blocked", "done") +GATE_STATUSES = ("pass", "fail", "unknown") +CI_STATUSES = ("not_applicable", "pending", "pass", "fail") +VISIBILITIES = ("project", "global") + + +def _in(column: str, values: tuple[str, ...]) -> str: + joined = ", ".join(f"'{v}'" for v in values) + return f"{column} IN ({joined})" + + +projects = Table( + "projects", + metadata, + Column("id", String, primary_key=True), # slug, e.g. "leeworks-api" + Column("root_dir", String, nullable=False), + Column("git_remote", String), + # Pointer to a secret (env:VAR / file:/path / cmd:...), never the token — README 3.7. + Column("credential_ref", String), + Column("created_at", PortableTimestamp, nullable=False, server_default=func.now()), +) + +agents = Table( + "agents", + metadata, + Column("id", PortableBigInt, primary_key=True, autoincrement=True), + Column("project_id", String, ForeignKey("projects.id"), nullable=False), + Column("name", String, nullable=False), # unique within a project, not globally + Column("working_dir", String, nullable=False), + Column("status", String, nullable=False), + Column("created_at", PortableTimestamp, nullable=False, server_default=func.now()), + UniqueConstraint("project_id", "name", name="uq_agents_project_name"), + CheckConstraint(_in("status", AGENT_STATUSES), name="ck_agents_status"), +) + +log_entries = Table( + "log_entries", + metadata, + Column("id", PortableBigInt, primary_key=True, autoincrement=True), + Column("agent_id", BigInteger, ForeignKey("agents.id"), nullable=False), + Column("created_at", PortableTimestamp, nullable=False, server_default=func.now()), + Column("session_id", String), + Column("status", String, nullable=False), + Column("summary", String), + Column("decisions", String), + Column("question", String), + Column("answer", String), # filled in on resume; only field ever touched post-insert + Column("visibility", String, nullable=False, server_default="project"), + Column("push_sha", String), # set if this checkpoint pushed; null otherwise + Column("ci_status", String, nullable=False, server_default="not_applicable"), + Column("ci_checked_at", PortableTimestamp), + CheckConstraint(_in("visibility", VISIBILITIES), name="ck_log_visibility"), + CheckConstraint(_in("ci_status", CI_STATUSES), name="ck_log_ci_status"), +) + +checkmarks = Table( + "checkmarks", + metadata, + # agent_id is PK *and* FK: "the small file that gets overwritten," one row per agent. + Column("agent_id", BigInteger, ForeignKey("agents.id"), primary_key=True), + Column("checkpoint_at", PortableTimestamp, nullable=False), + Column("status", String, nullable=False), + Column("where_it_stopped", String), + Column("next_steps", PortableJSON), + Column("open_question", String), + # use_alter breaks the checkmarks <-> log_entries create-order cycle. + Column( + "log_entry_id", + BigInteger, + ForeignKey("log_entries.id", use_alter=True, name="fk_checkmarks_log_entry"), + ), + Column("tests_status", String, nullable=False, server_default="unknown"), + Column("tested_at", PortableTimestamp), + Column("build_status", String, nullable=False, server_default="unknown"), + Column("built_at", PortableTimestamp), + CheckConstraint(_in("status", AGENT_STATUSES), name="ck_checkmarks_status"), + CheckConstraint(_in("tests_status", GATE_STATUSES), name="ck_checkmarks_tests"), + CheckConstraint(_in("build_status", GATE_STATUSES), name="ck_checkmarks_build"), +) + +shared_context = Table( + "shared_context", + metadata, + Column("key", String, primary_key=True), + Column("value", String, nullable=False), + Column("set_by_agent_id", BigInteger, ForeignKey("agents.id")), + Column("updated_at", PortableTimestamp, nullable=False, server_default=func.now()), +) diff --git a/src/handler/db/types.py b/src/handler/db/types.py new file mode 100644 index 0000000..6704cbf --- /dev/null +++ b/src/handler/db/types.py @@ -0,0 +1,58 @@ +"""Portable column types so one schema definition emits the right physical type +on both dialects. + +- ``PortableJSON`` -> JSONB on Postgres, JSON-as-TEXT on SQLite. +- ``PortableTimestamp`` -> TIMESTAMPTZ on Postgres, ISO-8601 TEXT on SQLite, + always UTC-aware in Python. Naive datetimes are normalized to UTC on bind so + the two dialects agree. +""" + +from __future__ import annotations + +from datetime import UTC, datetime + +from sqlalchemy import BigInteger, DateTime, Integer, TypeDecorator +from sqlalchemy.dialects import postgresql +from sqlalchemy.types import JSON + +# JSONB on Postgres, JSON (stored as TEXT, round-tripping dict/list) on SQLite. +PortableJSON = JSON().with_variant(postgresql.JSONB(), "postgresql") + +# BIGSERIAL/BIGINT on Postgres, INTEGER on SQLite. Only ``INTEGER PRIMARY KEY`` aliases +# SQLite's rowid and autoincrements — a bare ``BIGINT PRIMARY KEY`` would be NULLable and +# would not auto-assign. Use this for autoincrementing PKs. +PortableBigInt = BigInteger().with_variant(Integer(), "sqlite") + + +class PortableTimestamp(TypeDecorator): + """A timezone-aware timestamp that behaves identically on PG and SQLite. + + SQLAlchemy stores aware datetimes as ISO-8601 text on SQLite and as + ``TIMESTAMPTZ`` on Postgres. We normalize every bound value to UTC so a naive + datetime never silently becomes local-time on one backend and UTC on the other. + """ + + impl = DateTime(timezone=True) + cache_ok = True + + def load_dialect_impl(self, dialect): + if dialect.name == "postgresql": + return dialect.type_descriptor(postgresql.TIMESTAMP(timezone=True)) + return dialect.type_descriptor(DateTime(timezone=True)) + + def process_bind_param(self, value, dialect): + if value is None: + return None + if not isinstance(value, datetime): + return value + if value.tzinfo is None: + return value.replace(tzinfo=UTC) + return value.astimezone(UTC) + + def process_result_value(self, value, dialect): + if value is None: + return None + if isinstance(value, datetime) and value.tzinfo is None: + # SQLite hands back naive datetimes; they are UTC by our convention. + return value.replace(tzinfo=UTC) + return value diff --git a/src/handler/db/upsert.py b/src/handler/db/upsert.py new file mode 100644 index 0000000..54f9e5c --- /dev/null +++ b/src/handler/db/upsert.py @@ -0,0 +1,38 @@ +"""The one place dialect branching lives: the checkmark upsert. + +``checkmarks`` is a literal upsert keyed by ``agent_id`` — "the small file that gets +overwritten," as a row. We use native ``INSERT ... ON CONFLICT DO UPDATE`` on *both* +dialects (SQLite >= 3.24, from 2018; Python 3.11 bundles far newer). Deliberately not +``INSERT OR REPLACE``: that deletes and reinserts the row, firing FK cascades and +losing row identity. +""" + +from __future__ import annotations + +from sqlalchemy import Connection +from sqlalchemy.dialects.postgresql import insert as pg_insert +from sqlalchemy.dialects.sqlite import insert as sqlite_insert + +from .tables import checkmarks + + +def upsert_checkmark(conn: Connection, values: dict) -> None: + """Insert or overwrite the checkmark for ``values['agent_id']``. + + Every non-PK column present in ``values`` is overwritten on conflict, so a + checkpoint fully replaces the prior small-state record. + """ + if "agent_id" not in values: + raise ValueError("upsert_checkmark requires 'agent_id'") + + dialect = conn.dialect.name + if dialect == "postgresql": + stmt = pg_insert(checkmarks).values(**values) + elif dialect == "sqlite": + stmt = sqlite_insert(checkmarks).values(**values) + else: # pragma: no cover - only two backends are supported + raise RuntimeError(f"unsupported dialect for upsert: {dialect}") + + update_cols = {k: stmt.excluded[k] for k in values if k != "agent_id"} + stmt = stmt.on_conflict_do_update(index_elements=["agent_id"], set_=update_cols) + conn.execute(stmt) diff --git a/src/handler/hooks/__init__.py b/src/handler/hooks/__init__.py new file mode 100644 index 0000000..6d7f937 --- /dev/null +++ b/src/handler/hooks/__init__.py @@ -0,0 +1,7 @@ +"""Claude Code hook entrypoints — the backend's write path. + +Invoked as ``python -m handler.hooks `` from the per-agent settings.json. Each +hook reads the event JSON on stdin, resolves its agent identity from the environment +(injected at spawn), writes checkmark/log rows, and returns the event's response +contract on stdout. +""" diff --git a/src/handler/hooks/__main__.py b/src/handler/hooks/__main__.py new file mode 100644 index 0000000..049ac1d --- /dev/null +++ b/src/handler/hooks/__main__.py @@ -0,0 +1,46 @@ +"""Hook dispatch: ``python -m handler.hooks ``. + +Events: ``stop``, ``session_end``, ``pre_tool_use``, ``notification``. Reads the event +JSON on stdin, resolves the acting agent, dispatches, and exits 0. A resolution failure +or unexpected error exits nonzero with a stderr message but never crashes the agent's +turn in a way that loses data. +""" + +from __future__ import annotations + +import sys + +from ..db.engine import connection +from . import checkpoint, gate, notify +from .context import read_input, resolve_identity + +_EVENTS = {"stop", "session_end", "pre_tool_use", "notification"} + + +def main(argv: list[str] | None = None) -> int: + argv = sys.argv[1:] if argv is None else argv + if not argv or argv[0] not in _EVENTS: + print(f"usage: python -m handler.hooks {{{'|'.join(sorted(_EVENTS))}}}", file=sys.stderr) + return 2 + event = argv[0] + + hook_input = read_input(event) + + with connection() as conn: + ident = resolve_identity(conn, hook_input) + if ident is None: + print("handler hook: could not resolve agent identity", file=sys.stderr) + return 1 + + if event in ("stop", "session_end"): + checkpoint.handle(conn, ident, hook_input) + elif event == "pre_tool_use": + gate.handle(conn, ident, hook_input) + elif event == "notification": + notify.handle(conn, ident, hook_input) + + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/src/handler/hooks/checkpoint.py b/src/handler/hooks/checkpoint.py new file mode 100644 index 0000000..de2dd92 --- /dev/null +++ b/src/handler/hooks/checkpoint.py @@ -0,0 +1,95 @@ +"""Stop / SessionEnd — the checkpoint + verification gate (README 3.5). + +On ``Stop`` the gate runs the project's own ``test`` task and blocks the turn on +failure, so a turn cannot end on a broken suite. The result feeds straight into the +schema: ``status = 'done'`` is only ever recorded alongside a passing test run — not a +claim taken on faith. ``SessionEnd`` cannot be blocked, so it just records a final +checkpoint with the end reason. +""" + +from __future__ import annotations + +from datetime import UTC, datetime + +from sqlalchemy import Connection + +from ..db import repository as repo +from . import verify +from .context import HookInput, Identity, emit + + +def handle_stop(conn: Connection, ident: Identity, hook_input: HookInput) -> dict: + working_dir = ident.working_dir or hook_input.cwd or "." + ok, output = verify.run_test(working_dir) + now = datetime.now(UTC) + + status = "done" if ok else "blocked" + tests_status = "pass" if ok else "fail" + summary = "checkpoint: tests passed" if ok else "checkpoint blocked: tests failed" + + log_id = repo.insert_log_entry( + conn, + agent_id=ident.agent_id, + status=status, + session_id=hook_input.session_id, + summary=summary, + decisions=(output[-4000:] if output else None), + ) + repo.upsert_checkmark_row( + conn, + agent_id=ident.agent_id, + checkpoint_at=now, + status=status, + where_it_stopped=summary, + log_entry_id=log_id, + tests_status=tests_status, + tested_at=now, + ) + repo.set_agent_status(conn, ident.agent_id, status) + + if not ok: + # Guard against an infinite block loop: if we already re-invoked once, record + # the failure but let the turn end rather than blocking forever. + if hook_input.stop_hook_active: + return {} + return { + "decision": "block", + "reason": ( + "The test gate failed; the turn cannot end on a broken suite. " + f"`mise run test` output:\n{output[-4000:]}" + ), + } + return {} + + +def handle_session_end(conn: Connection, ident: Identity, hook_input: HookInput) -> dict: + now = datetime.now(UTC) + reason = hook_input.reason or "session ended" + log_id = repo.insert_log_entry( + conn, + agent_id=ident.agent_id, + status="blocked", + session_id=hook_input.session_id, + summary=f"session ended: {reason}", + ) + # Record the checkpoint but do not run the gate (the session is already ending). + existing = repo.get_checkmark(conn, ident.agent_id) + status = existing["status"] if existing else "blocked" + repo.upsert_checkmark_row( + conn, + agent_id=ident.agent_id, + checkpoint_at=now, + status=status, + where_it_stopped=f"session ended: {reason}", + log_entry_id=log_id, + ) + return {} + + +def handle(conn: Connection, ident: Identity, hook_input: HookInput) -> dict: + if hook_input.event == "session_end": + result = handle_session_end(conn, ident, hook_input) + else: + result = handle_stop(conn, ident, hook_input) + emit(result) + return result diff --git a/src/handler/hooks/context.py b/src/handler/hooks/context.py new file mode 100644 index 0000000..b805ab5 --- /dev/null +++ b/src/handler/hooks/context.py @@ -0,0 +1,97 @@ +"""Hook input parsing + identity resolution. + +Claude Code hook stdin carries the session context (``session_id``, ``cwd``, +``hook_event_name``, per-event extras) but *not* our agent identity — that arrives via +the environment injected at spawn (``HANDLER_AGENT_ID`` etc.). A ``cwd``->working_dir +fallback resolves the agent if the env is somehow missing. +""" + +from __future__ import annotations + +import json +import os +import sys +from dataclasses import dataclass, field +from typing import Any + +from sqlalchemy import Connection, select + +from ..db.tables import agents + + +@dataclass +class HookInput: + raw: dict[str, Any] + event: str + + @property + def session_id(self) -> str | None: + return self.raw.get("session_id") + + @property + def cwd(self) -> str | None: + return self.raw.get("cwd") + + @property + def tool_name(self) -> str | None: + return self.raw.get("tool_name") + + @property + def tool_input(self) -> dict[str, Any]: + return self.raw.get("tool_input") or {} + + @property + def message(self) -> str | None: + return self.raw.get("message") + + @property + def stop_hook_active(self) -> bool: + return bool(self.raw.get("stop_hook_active")) + + @property + def reason(self) -> str | None: + return self.raw.get("reason") + + +@dataclass +class Identity: + agent_id: int + project_id: str + agent_name: str + working_dir: str | None = None + extra: dict = field(default_factory=dict) + + +def read_input(event: str) -> HookInput: + data = sys.stdin.read() + parsed = json.loads(data) if data.strip() else {} + return HookInput(raw=parsed, event=event) + + +def resolve_identity(conn: Connection, hook_input: HookInput) -> Identity | None: + """Resolve the acting agent from env, falling back to cwd->working_dir lookup.""" + agent_id = os.environ.get("HANDLER_AGENT_ID") + project_id = os.environ.get("HANDLER_PROJECT_ID") + agent_name = os.environ.get("HANDLER_AGENT_NAME") + + if agent_id and project_id and agent_name: + row = conn.execute(select(agents).where(agents.c.id == int(agent_id))).first() + working_dir = row._mapping["working_dir"] if row else None + return Identity(int(agent_id), project_id, agent_name, working_dir) + + # Fallback: match by working_dir == cwd. + if hook_input.cwd: + row = conn.execute( + select(agents).where(agents.c.working_dir == hook_input.cwd) + ).first() + if row is not None: + m = row._mapping + return Identity(m["id"], m["project_id"], m["name"], m["working_dir"]) + + return None + + +def emit(payload: dict) -> None: + """Write a JSON hook response to stdout.""" + json.dump(payload, sys.stdout) + sys.stdout.write("\n") diff --git a/src/handler/hooks/gate.py b/src/handler/hooks/gate.py new file mode 100644 index 0000000..25bccf1 --- /dev/null +++ b/src/handler/hooks/gate.py @@ -0,0 +1,135 @@ +"""PreToolUse — defer AskUserQuestion, and gate ``git push`` (README 3.6). + +Claude Code's PreToolUse matcher matches on ``tool_name`` only, so this hook is wired +for ``AskUserQuestion|Bash`` and inspects the command itself to decide what to do: + +- ``AskUserQuestion``: there is no human at the tmux TTY, so the question is *deferred* + — persisted to the log + checkmark and the tool call denied, handing control to the + async answer/resume flow. +- ``Bash`` running ``git push``: run the verification chain (tests first, then the + throwaway image build) and deny the push on the first failure, so a push already + known to fail CI doesn't leave. +""" + +from __future__ import annotations + +import json +import re +from datetime import UTC, datetime + +from sqlalchemy import Connection + +from ..db import repository as repo +from . import verify +from .context import HookInput, Identity, emit + +_GIT_PUSH = re.compile(r"\bgit\s+push\b") + + +def _deny(reason: str) -> dict: + return { + "hookSpecificOutput": { + "hookEventName": "PreToolUse", + "permissionDecision": "deny", + "permissionDecisionReason": reason, + } + } + + +def _allow(reason: str = "") -> dict: + out: dict = { + "hookSpecificOutput": { + "hookEventName": "PreToolUse", + "permissionDecision": "allow", + "permissionDecisionReason": reason, + } + } + return out + + +def _question_text(tool_input: dict) -> str: + """Flatten an AskUserQuestion payload into a human-readable question string.""" + questions = tool_input.get("questions") + if isinstance(questions, list) and questions: + parts = [] + for q in questions: + if isinstance(q, dict) and q.get("question"): + parts.append(str(q["question"])) + if parts: + return "\n".join(parts) + # Fall back to the whole payload so nothing is lost. + return json.dumps(tool_input) + + +def handle_ask_user_question(conn: Connection, ident: Identity, hook_input: HookInput) -> dict: + question = _question_text(hook_input.tool_input) + now = datetime.now(UTC) + log_id = repo.insert_log_entry( + conn, + agent_id=ident.agent_id, + status="paused_for_input", + session_id=hook_input.session_id, + summary="agent asked the operator a question", + question=question, + ) + repo.upsert_checkmark_row( + conn, + agent_id=ident.agent_id, + checkpoint_at=now, + status="paused_for_input", + open_question=question, + log_entry_id=log_id, + ) + repo.set_agent_status(conn, ident.agent_id, "paused_for_input") + return _deny( + "Question deferred to the operator; answer it via the API " + "(POST .../answer then POST .../resume)." + ) + + +def handle_git_push(conn: Connection, ident: Identity, hook_input: HookInput) -> dict: + working_dir = ident.working_dir or hook_input.cwd or "." + now = datetime.now(UTC) + + # Cheap check first: tests. Only on success do we pay for the image build. + tests_ok, tests_out = verify.run_test(working_dir) + if not tests_ok: + repo.upsert_checkmark_row( + conn, + agent_id=ident.agent_id, + checkpoint_at=now, + status="blocked", + tests_status="fail", + tested_at=now, + ) + return _deny(f"Push blocked: tests failed.\n{tests_out[-3000:]}") + + build_ok, build_out = verify.run_build(working_dir) + repo.upsert_checkmark_row( + conn, + agent_id=ident.agent_id, + checkpoint_at=now, + status="working", + tests_status="pass", + tested_at=now, + build_status="pass" if build_ok else "fail", + built_at=now, + ) + if not build_ok: + return _deny(f"Push blocked: image build failed.\n{build_out[-3000:]}") + + return _allow("tests and image build passed") + + +def handle(conn: Connection, ident: Identity, hook_input: HookInput) -> dict: + tool = hook_input.tool_name + if tool == "AskUserQuestion": + result = handle_ask_user_question(conn, ident, hook_input) + elif tool == "Bash" and _GIT_PUSH.search(hook_input.tool_input.get("command", "")): + result = handle_git_push(conn, ident, hook_input) + else: + # Not our concern — stay out of the way, let normal permission flow proceed. + result = {} + if result: + emit(result) + return result diff --git a/src/handler/hooks/notify.py b/src/handler/hooks/notify.py new file mode 100644 index 0000000..7837f62 --- /dev/null +++ b/src/handler/hooks/notify.py @@ -0,0 +1,46 @@ +"""Notification -> generic webhook (README 3.2). + +Fully bring-your-own: if ``WEBHOOK_URL`` is unset the hook is a no-op. The webhook is +never allowed to block the agent — failures are swallowed. A log row is written either +way so the "big log" stays complete. +""" + +from __future__ import annotations + +from datetime import UTC, datetime + +import httpx +from sqlalchemy import Connection + +from ..config import get_settings +from ..db import repository as repo +from .context import HookInput, Identity + + +def handle(conn: Connection, ident: Identity, hook_input: HookInput) -> dict: + message = hook_input.message or "" + repo.insert_log_entry( + conn, + agent_id=ident.agent_id, + status="working", + session_id=hook_input.session_id, + summary=f"notification: {message}"[:2000], + ) + + url = get_settings().webhook_url + if not url: + return {} + + payload = { + "project": ident.project_id, + "agent": ident.agent_name, + "message": message, + "session_id": hook_input.session_id, + "ts": datetime.now(UTC).isoformat(), + } + try: + httpx.post(url, json=payload, timeout=5.0) + except httpx.HTTPError: + # Bring-your-own target; never block the agent on delivery failure. + pass + return {} diff --git a/src/handler/hooks/verify.py b/src/handler/hooks/verify.py new file mode 100644 index 0000000..577c7cb --- /dev/null +++ b/src/handler/hooks/verify.py @@ -0,0 +1,44 @@ +"""The verification helpers — the mock seam for the gates. + +``run_test`` / ``run_build`` shell ``mise run `` in the agent's working dir and +report ``(ok, output)``. Hook decision logic is tested by faking these two functions, +so no live ``mise``/``kaniko`` is needed. A missing ``mise`` is treated as a failure +with a clear reason — the test task is a hard requirement, so a silent skip would +defeat the gate. +""" + +from __future__ import annotations + +import subprocess + +from ..config import get_settings + +_TIMEOUT = 1800 # seconds; long enough for a real suite/build, bounded so a hang fails. + + +def _run_mise_task(task: str, cwd: str) -> tuple[bool, str]: + mise = get_settings().mise_bin + try: + result = subprocess.run( + [mise, "run", task], + cwd=cwd, + capture_output=True, + text=True, + timeout=_TIMEOUT, + ) + except FileNotFoundError: + return False, f"'{mise}' not found: cannot run the '{task}' gate" + except subprocess.TimeoutExpired: + return False, f"'{task}' timed out after {_TIMEOUT}s" + + output = (result.stdout or "") + (result.stderr or "") + return result.returncode == 0, output.strip() + + +def run_test(cwd: str) -> tuple[bool, str]: + return _run_mise_task("test", cwd) + + +def run_build(cwd: str) -> tuple[bool, str]: + """Throwaway image build (kaniko/buildah, no registry) via ``mise run build-image``.""" + return _run_mise_task("build-image", cwd) diff --git a/src/handler/migrations/env.py b/src/handler/migrations/env.py new file mode 100644 index 0000000..ee67c8a --- /dev/null +++ b/src/handler/migrations/env.py @@ -0,0 +1,70 @@ +"""Alembic environment — one config for both dialects. + +The URL comes from ``handler.config.Settings`` (env ``DATABASE_URL``), and +``target_metadata`` is the single schema in ``handler.db.tables``. Because the +columns use ``with_variant`` / dialect-aware types, the same migration script emits +correct DDL for both Postgres and SQLite. ``render_as_batch`` is enabled for SQLite so +any future ``ALTER`` migration works (SQLite can't ALTER most things; batch mode does a +table-copy). +""" + +from __future__ import annotations + +from logging.config import fileConfig + +from alembic import context +from sqlalchemy import engine_from_config, pool + +from handler.config import get_settings +from handler.db.tables import metadata + +config = context.config + +if config.config_file_name is not None: + fileConfig(config.config_file_name) + +config.set_main_option("sqlalchemy.url", get_settings().database_url) + +target_metadata = metadata + + +def _is_sqlite(url: str) -> bool: + return url.startswith("sqlite") + + +def run_migrations_offline() -> None: + url = config.get_main_option("sqlalchemy.url") + context.configure( + url=url, + target_metadata=target_metadata, + literal_binds=True, + dialect_opts={"paramstyle": "named"}, + render_as_batch=_is_sqlite(url or ""), + ) + with context.begin_transaction(): + context.run_migrations() + + +def run_migrations_online() -> None: + connectable = engine_from_config( + config.get_section(config.config_ini_section, {}), + prefix="sqlalchemy.", + poolclass=pool.NullPool, + ) + with connectable.connect() as connection: + is_sqlite = connection.dialect.name == "sqlite" + if is_sqlite: + connection.exec_driver_sql("PRAGMA foreign_keys=ON") + context.configure( + connection=connection, + target_metadata=target_metadata, + render_as_batch=is_sqlite, + ) + with context.begin_transaction(): + context.run_migrations() + + +if context.is_offline_mode(): + run_migrations_offline() +else: + run_migrations_online() diff --git a/src/handler/migrations/script.py.mako b/src/handler/migrations/script.py.mako new file mode 100644 index 0000000..958df87 --- /dev/null +++ b/src/handler/migrations/script.py.mako @@ -0,0 +1,25 @@ +"""${message} + +Revision ID: ${up_revision} +Revises: ${down_revision | comma,n} +Create Date: ${create_date} +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +${imports if imports else ""} + +# revision identifiers, used by Alembic. +revision: str = ${repr(up_revision)} +down_revision: Union[str, None] = ${repr(down_revision)} +branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)} +depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)} + + +def upgrade() -> None: + ${upgrades if upgrades else "pass"} + + +def downgrade() -> None: + ${downgrades if downgrades else "pass"} diff --git a/src/handler/migrations/versions/0001_initial.py b/src/handler/migrations/versions/0001_initial.py new file mode 100644 index 0000000..67c8995 --- /dev/null +++ b/src/handler/migrations/versions/0001_initial.py @@ -0,0 +1,110 @@ +"""initial schema + +Revision ID: 0001_initial +Revises: +Create Date: 2026-07-07 + +Hand-written (not autogenerated) so the create order and FK handling are explicit and +render correctly on both Postgres and SQLite. Tables are created in dependency order — +projects -> agents -> log_entries -> checkmarks -> shared_context — so no forward FK +reference needs deferring. +""" + +from __future__ import annotations + +from collections.abc import Sequence + +import sqlalchemy as sa +from alembic import op + +from handler.db.types import PortableBigInt, PortableJSON, PortableTimestamp + +revision: str = "0001_initial" +down_revision: str | None = None +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + +AGENT_STATUSES = "'working', 'paused_for_input', 'blocked', 'done'" +GATE_STATUSES = "'pass', 'fail', 'unknown'" +CI_STATUSES = "'not_applicable', 'pending', 'pass', 'fail'" +VISIBILITIES = "'project', 'global'" + + +def upgrade() -> None: + op.create_table( + "projects", + sa.Column("id", sa.String(), primary_key=True), + sa.Column("root_dir", sa.String(), nullable=False), + sa.Column("git_remote", sa.String()), + sa.Column("credential_ref", sa.String()), + sa.Column("created_at", PortableTimestamp, nullable=False, server_default=sa.func.now()), + ) + + op.create_table( + "agents", + sa.Column("id", PortableBigInt, primary_key=True, autoincrement=True), + sa.Column("project_id", sa.String(), sa.ForeignKey("projects.id"), nullable=False), + sa.Column("name", sa.String(), nullable=False), + sa.Column("working_dir", sa.String(), nullable=False), + sa.Column("status", sa.String(), nullable=False), + sa.Column("created_at", PortableTimestamp, nullable=False, server_default=sa.func.now()), + sa.UniqueConstraint("project_id", "name", name="uq_agents_project_name"), + sa.CheckConstraint(f"status IN ({AGENT_STATUSES})", name="ck_agents_status"), + ) + + op.create_table( + "log_entries", + sa.Column("id", PortableBigInt, primary_key=True, autoincrement=True), + sa.Column("agent_id", sa.BigInteger(), sa.ForeignKey("agents.id"), nullable=False), + sa.Column("created_at", PortableTimestamp, nullable=False, server_default=sa.func.now()), + sa.Column("session_id", sa.String()), + sa.Column("status", sa.String(), nullable=False), + sa.Column("summary", sa.String()), + sa.Column("decisions", sa.String()), + sa.Column("question", sa.String()), + sa.Column("answer", sa.String()), + sa.Column("visibility", sa.String(), nullable=False, server_default="project"), + sa.Column("push_sha", sa.String()), + sa.Column("ci_status", sa.String(), nullable=False, server_default="not_applicable"), + sa.Column("ci_checked_at", PortableTimestamp), + sa.CheckConstraint(f"visibility IN ({VISIBILITIES})", name="ck_log_visibility"), + sa.CheckConstraint(f"ci_status IN ({CI_STATUSES})", name="ck_log_ci_status"), + ) + + op.create_table( + "checkmarks", + sa.Column("agent_id", sa.BigInteger(), sa.ForeignKey("agents.id"), primary_key=True), + sa.Column("checkpoint_at", PortableTimestamp, nullable=False), + sa.Column("status", sa.String(), nullable=False), + sa.Column("where_it_stopped", sa.String()), + sa.Column("next_steps", PortableJSON), + sa.Column("open_question", sa.String()), + sa.Column( + "log_entry_id", + sa.BigInteger(), + sa.ForeignKey("log_entries.id", name="fk_checkmarks_log_entry"), + ), + sa.Column("tests_status", sa.String(), nullable=False, server_default="unknown"), + sa.Column("tested_at", PortableTimestamp), + sa.Column("build_status", sa.String(), nullable=False, server_default="unknown"), + sa.Column("built_at", PortableTimestamp), + sa.CheckConstraint(f"status IN ({AGENT_STATUSES})", name="ck_checkmarks_status"), + sa.CheckConstraint(f"tests_status IN ({GATE_STATUSES})", name="ck_checkmarks_tests"), + sa.CheckConstraint(f"build_status IN ({GATE_STATUSES})", name="ck_checkmarks_build"), + ) + + op.create_table( + "shared_context", + sa.Column("key", sa.String(), primary_key=True), + sa.Column("value", sa.String(), nullable=False), + sa.Column("set_by_agent_id", sa.BigInteger(), sa.ForeignKey("agents.id")), + sa.Column("updated_at", PortableTimestamp, nullable=False, server_default=sa.func.now()), + ) + + +def downgrade() -> None: + op.drop_table("shared_context") + op.drop_table("checkmarks") + op.drop_table("log_entries") + op.drop_table("agents") + op.drop_table("projects") diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..5e22256 --- /dev/null +++ b/tests/conftest.py @@ -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} diff --git a/tests/test_api_auth.py b/tests/test_api_auth.py new file mode 100644 index 0000000..d10e248 --- /dev/null +++ b/tests/test_api_auth.py @@ -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 diff --git a/tests/test_api_interaction.py b/tests/test_api_interaction.py new file mode 100644 index 0000000..20b482e --- /dev/null +++ b/tests/test_api_interaction.py @@ -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 diff --git a/tests/test_api_projects_agents.py b/tests/test_api_projects_agents.py new file mode 100644 index 0000000..8abf6ef --- /dev/null +++ b/tests/test_api_projects_agents.py @@ -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 diff --git a/tests/test_api_shared.py b/tests/test_api_shared.py new file mode 100644 index 0000000..5abd08c --- /dev/null +++ b/tests/test_api_shared.py @@ -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"] diff --git a/tests/test_control_spawn.py b/tests/test_control_spawn.py new file mode 100644 index 0000000..4a01739 --- /dev/null +++ b/tests/test_control_spawn.py @@ -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"} diff --git a/tests/test_db_types.py b/tests/test_db_types.py new file mode 100644 index 0000000..446cbc2 --- /dev/null +++ b/tests/test_db_types.py @@ -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 diff --git a/tests/test_db_upsert.py b/tests/test_db_upsert.py new file mode 100644 index 0000000..953aa80 --- /dev/null +++ b/tests/test_db_upsert.py @@ -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" diff --git a/tests/test_hook_checkpoint.py b/tests/test_hook_checkpoint.py new file mode 100644 index 0000000..2cc13b2 --- /dev/null +++ b/tests/test_hook_checkpoint.py @@ -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" + ) diff --git a/tests/test_hook_dispatch.py b/tests/test_hook_dispatch.py new file mode 100644 index 0000000..ea2af2f --- /dev/null +++ b/tests/test_hook_dispatch.py @@ -0,0 +1,41 @@ +"""The `python -m handler.hooks ` 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 diff --git a/tests/test_hook_gate.py b/tests/test_hook_gate.py new file mode 100644 index 0000000..a0d76bc --- /dev/null +++ b/tests/test_hook_gate.py @@ -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) == {} diff --git a/tests/test_hook_notify.py b/tests/test_hook_notify.py new file mode 100644 index 0000000..3ee271b --- /dev/null +++ b/tests/test_hook_notify.py @@ -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" diff --git a/tests/test_repository.py b/tests/test_repository.py new file mode 100644 index 0000000..ccf9cfb --- /dev/null +++ b/tests/test_repository.py @@ -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"]