mirror of
https://github.com/0xWheatyz/handler.git
synced 2026-08-30 05:36:24 +00:00
feat(mvp): Phase 1 control layer + API vertical slice
Implements the Phase 1 MVP from the README: a stateless control layer + HTTP API over a centralized database, with hook-enforced test/push gates. - DB layer: SQLAlchemy Core, one schema rendering both Postgres (BIGSERIAL / TIMESTAMPTZ / JSONB) and SQLite (INTEGER PK / TEXT / JSON) via portable types; native ON CONFLICT DO UPDATE checkmark upsert on both dialects. - Alembic dual-dialect migrations (render_as_batch for SQLite); tests run a real `alembic upgrade head`. - FastAPI: projects/agents/checkmark/log/answer/resume + shared log/context routes, single global bearer token, higher-trust token gating shared-context writes, project isolation on every route. - Hooks (`python -m handler.hooks <event>`): Stop test gate (block on red), PreToolUse AskUserQuestion defer + `git push` gate (tests then throwaway build), Notification generic webhook (no-op without WEBHOOK_URL). Identity via env injected at spawn; verify is the mock seam. - Control CLI: spawn/list/attach/kill, hard `.mise.toml [tasks.test]` gate, generated per-agent settings.json, identity + DATABASE_URL injected via tmux; tmux is the mock seam. - 45 tests (SQLite), ruff clean. Live claude/tmux/mise spawning deferred behind the mocked seams. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01W5ZuS5pV1NS6eKsRZHXonY
This commit is contained in:
@@ -0,0 +1,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