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