diff --git a/.codegraph/.gitignore b/.codegraph/.gitignore new file mode 100644 index 0000000..d20c0fe --- /dev/null +++ b/.codegraph/.gitignore @@ -0,0 +1,5 @@ +# CodeGraph data files — local to each machine, not for committing. +# Ignore everything in .codegraph/ except this file itself, so transient +# files (the database, daemon.pid, sockets, logs) never show up in git. +* +!.gitignore diff --git a/.env.example b/.env.example index 88c6b67..b99be15 100644 --- a/.env.example +++ b/.env.example @@ -1,10 +1,10 @@ GITEA_NETWORK=gitea +AGENTCI_IMAGE=git.krtss.de/stanponomarev/agentci:latest AGENTCI_GITEA_URL=http://gitea:3000 AGENTCI_BOT_USERNAME=agentci AGENTCI_BOT_NAME=Agent CI AGENTCI_BOT_EMAIL=agentci@localhost OPENCODE_SERVER_USERNAME=opencode -AGENTCI_OPENCODE_VERSION=^1 AGENTCI_PLAN_MODEL=openai/gpt-5.6-sol AGENTCI_PLAN_VARIANT= AGENTCI_IMPLEMENT_MODEL=openai/gpt-5.6-sol @@ -24,4 +24,3 @@ AGENTCI_INSTALL_SCRIPTS= AGENTCI_INSTALL_SCRIPT_TIMEOUT_SECONDS=900 AGENTCI_PYTHON_VERSION=3.13 AGENTCI_DOTNET_CHANNEL=10.0 -CODEGRAPH_VERSION=1.3.1 diff --git a/.gitea/workflows/publish-image.yaml b/.gitea/workflows/publish-image.yaml new file mode 100644 index 0000000..7621a7d --- /dev/null +++ b/.gitea/workflows/publish-image.yaml @@ -0,0 +1,41 @@ +name: Publish container image + +on: + push: + branches: + - master + +env: + REGISTRY: git.krtss.de + IMAGE_NAME: git.krtss.de/stanponomarev/agentci + +jobs: + publish: + name: Build and push + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Log in to Gitea registry + uses: docker/login-action@v3 + with: + registry: ${{ env.REGISTRY }} + username: ${{ secrets.REGISTRY_USERNAME }} + password: ${{ secrets.REGISTRY_TOKEN }} + + - name: Build and push image + uses: docker/build-push-action@v6 + with: + context: . + push: true + pull: true + tags: | + ${{ env.IMAGE_NAME }}:latest + ${{ env.IMAGE_NAME }}:${{ gitea.sha }} + labels: | + org.opencontainers.image.revision=${{ gitea.sha }} + org.opencontainers.image.source=${{ gitea.server_url }}/${{ gitea.repository }} diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 0000000..b58b603 --- /dev/null +++ b/.idea/.gitignore @@ -0,0 +1,5 @@ +# Default ignored files +/shelf/ +/workspace.xml +# Editor-based HTTP Client requests +/httpRequests/ diff --git a/.idea/agentci.iml b/.idea/agentci.iml new file mode 100644 index 0000000..34264c8 --- /dev/null +++ b/.idea/agentci.iml @@ -0,0 +1,11 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/.idea/encodings.xml b/.idea/encodings.xml new file mode 100644 index 0000000..df87cf9 --- /dev/null +++ b/.idea/encodings.xml @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/.idea/inspectionProfiles/profiles_settings.xml b/.idea/inspectionProfiles/profiles_settings.xml new file mode 100644 index 0000000..105ce2d --- /dev/null +++ b/.idea/inspectionProfiles/profiles_settings.xml @@ -0,0 +1,6 @@ + + + + \ No newline at end of file diff --git a/.idea/modules.xml b/.idea/modules.xml new file mode 100644 index 0000000..f6b3aba --- /dev/null +++ b/.idea/modules.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/.idea/pyLspTools.xml b/.idea/pyLspTools.xml new file mode 100644 index 0000000..28adef1 --- /dev/null +++ b/.idea/pyLspTools.xml @@ -0,0 +1,23 @@ + + + + + + \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml new file mode 100644 index 0000000..35eb1dd --- /dev/null +++ b/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..c1371d6 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,132 @@ +# Agent CI repository instructions + +## Scope + +- These instructions apply to the whole repository. A nested `AGENTS.md` adds or overrides + instructions for its subtree; `opencode/AGENTS.md` contains runtime-specific guidance. +- Agent CI is a private Gitea webhook service that turns issue and pull-request comments into + durable OpenCode workflows. Read `README.md` before changing command behavior, persistence, + recovery, deployment, or the security boundary. +- Keep changes focused. Preserve unrelated work in a dirty worktree and do not rewrite code outside + the requested change merely for consistency. + +## Repository map + +- `src/agentci/engine/`: immutable domain models and events, the pure reducer, SQLite persistence, + task claiming, and the `JobRun` event interface. +- `src/agentci/application/`: runtime composition, durable control/job queues, task handlers, + recovery, and comment reconciliation. +- `src/agentci/workflows/`: planning, implementation, review, and pull-request orchestration. +- `src/agentci/integrations/`: Gitea, Git, OpenCode, CodeGraph, and development external effects. +- `src/agentci/api/`: FastAPI construction, lifespan, dependencies, errors, and HTTP routes. +- `src/agentci/config/` and `src/agentci/observability/`: settings and structured logging. +- `src/agentci/prompts/` and `src/agentci/prompts/schemas/`: model prompts and structured-output + contracts; keep these concerns outside Python orchestration. +- `src/agentci/migrations/`: ordered SQLite migrations. +- `tests/`: pytest suite, generally organized by module or behavior. +- `compose.yaml`, `Dockerfile`, `scripts/`, `install-scripts/`, and `opencode/`: deployment and + trusted runtime configuration. + +## Setup and commands + +Use Python 3.13 or newer and `uv`. Run commands from the repository root. + +```sh +uv sync +``` + +Run the narrowest relevant test while iterating: + +```sh +uv run pytest tests/test_.py +uv run pytest tests/test_.py::test_ +uv run pytest -k '' +``` + +Run the standard Python checks before completion: + +```sh +uv run ruff check . +uv run pyright +uv run pytest +``` + +Coverage is diagnostic and has no required threshold: + +```sh +uv run pytest --cov=agentci --cov-branch +``` + +For deployment-related changes, also run: + +```sh +docker compose config +``` + +Run `docker compose build` when changing dependencies, the image, runtime scripts, installers, or +OpenCode configuration. It may require network access and takes longer than the normal checks. + +## Engineering conventions + +- Target Python 3.13, keep lines at or below 100 characters, and follow the Ruff and Pyright settings + in `pyproject.toml`. Use type annotations and existing modern Python patterns. +- Keep jobs and workflows immutable. Domain snapshots use frozen dataclasses; create updated values + rather than mutating state in place. +- Keep `engine/reducer.py` pure: no I/O, clocks, logging, or external calls. Express state changes as + events and task requests. +- Apply state transitions, event persistence, and resulting task creation atomically through + `Repository`. The `jobs` table is the authoritative snapshot; timestamps are storage metadata. +- Keep side effects in the worker, workflows, or top-level integration modules. Pass workflow + dependencies explicitly through `WorkflowServices` and progress through `JobRun`. +- Preserve webhook/event idempotency, per-target FIFO execution, bounded unrelated concurrency, and + deterministic comment reconciliation. +- Preserve restart semantics: queued work may resume, but an active partially executed model turn is + failed and its sessions are aborted rather than replayed. +- Use structured logging fields such as `operation`, `job_id`, `stage`, and task identifiers. Never + log credentials, secret contents, authorization headers, or private prompt data. +- Add a new numbered migration for schema changes. Never edit a migration that may already have been + applied. +- Keep prompts and JSON schemas synchronized. Add or update tests when changing either contract. +- Declare dependencies in `pyproject.toml` and let `uv` update `uv.lock`; do not edit the lockfile by + hand. + +## Testing conventions + +- Add regression tests for behavior changes, especially reducer transitions, persistence and + idempotency, restart recovery, queue ordering, webhook security, and integration error handling. +- Prefer behavior-oriented test names, table-driven `pytest.mark.parametrize` cases, `tmp_path` for + filesystem/database isolation, and fake clients or `httpx.MockTransport` for external services. +- Async tests run with `asyncio_mode = "auto"`; do not add an asyncio marker solely to make a test + asynchronous. +- Assert externally meaningful state, emitted events/tasks, ordering, rendered comments, and safe + error text rather than private implementation details. +- Do not make the default unit suite depend on live Gitea, OpenCode, provider credentials, Docker, + or network access. + +## Security and operational boundaries + +- Never commit or expose `.env`, `secrets/`, tokens, passwords, provider credentials, runtime + databases, cloned private repositories, or Docker volume contents. Do not send secrets or private + repository content to external search or research services. +- Preserve webhook HMAC verification, bot-comment filtering, requester write-permission checks, and + secret-file loading. +- OpenCode is not an OS sandbox. Do not weaken its permissions, enable repository-local configuration + or external skills, expose its server to the host, add privileged/capability settings, or add + writable host mounts without an explicit security review. +- `install-scripts/` is trusted operator code. Keep scripts idempotent, path-safe, and compatible with + the sanitized environment documented in `install-scripts/README.md`; never pass Agent CI or Gitea + credentials to them. +- Git pushes performed by Agent CI must remain non-forcing. +- Do not run provider authentication, start/restart deployment services, modify production data, or + perform other live operations unless the user explicitly requests it. +- Do not edit or commit generated/local state in `.venv/`, `.pytest_cache/`, `.ruff_cache/`, + `.codegraph/`, `__pycache__/`, `dist/`, `data/`, or `secrets/`. + +## Completion expectations + +- Run focused tests first, then all applicable standard checks. If a check cannot run, report the + exact command and reason. +- Update `README.md`, `.env.example`, and relevant operational documentation when changing commands, + configuration, deployment, recovery behavior, or security assumptions. +- Summarize behavior changes, validation performed, and any migration, compatibility, or security + implications in the final response or pull-request description. diff --git a/README.md b/README.md index babdeed..eae0930 100644 --- a/README.md +++ b/README.md @@ -4,6 +4,23 @@ Agent CI is a private Gitea webhook host that turns issue and pull-request comme OpenCode planning and implementation workflows. Docker Compose runs the webhook worker and a private OpenCode server on the same Docker network as Gitea. +## Architecture + +The service has one durable execution path: + +```text +webhook -> repository -> reducer -> durable task -> worker -> workflow -> integration +``` + +`engine/reducer.py` is the pure job state machine. `engine/repository.py` applies its transitions +atomically to SQLite and persists the resulting tasks. `application/worker/` executes those tasks +and passes an explicit `JobRun` into the functions under `workflows/`. Modules under +`integrations/` own external effects, while `api/` contains the FastAPI host and routes. + +Jobs and workflows are immutable snapshots. Workflows return their final comment body directly; +progress and resource links are emitted as state-machine events through `JobRun`. The `jobs` table +is the authoritative state snapshot, while `job_events` provides durable idempotency and audit data. + ## Commands | Location | Command | Behavior | @@ -29,10 +46,11 @@ recreated. OpenCode models. 3. Create `secrets/gitea_token`, `secrets/webhook_secret`, and `secrets/opencode_server_password`. Use high-entropy values for both secret/password files. -4. Build the image: +4. Log in to the Gitea container registry with a personal access token, then pull the image: ```sh - docker compose build + docker login git.krtss.de + docker compose pull ``` 5. Authenticate the configured OpenCode providers before starting the persistent server: @@ -58,6 +76,22 @@ deployment, run the one-off `auth login` command above and then `docker compose healthy and every configured model exists, supports tool calls, accepts its configured variant, and has a connected provider. The worker leaves jobs queued while the runtime is unavailable. +### Image publishing + +Every push to `master` runs `.gitea/workflows/publish-image.yaml` and publishes the image as both +`git.krtss.de/stanponomarev/agentci:latest` and +`git.krtss.de/stanponomarev/agentci:`. Configure these repository Actions secrets +before the first run: + +| Secret | Value | +| --- | --- | +| `REGISTRY_USERNAME` | Username that owns the package or can write packages for the owner. | +| `REGISTRY_TOKEN` | Personal access token with package write permission. | + +The automatic Gitea Actions token cannot publish packages. Compose uses `latest` by default; set +`AGENTCI_IMAGE=git.krtss.de/stanponomarev/agentci:` in `.env` to deploy an immutable +revision. + ## OpenCode Models use OpenCode's `provider/model` format. Planning, implementation, and research can use @@ -67,10 +101,11 @@ configures the research subagent and defaults to `high`. `AGENTCI_EXPLORE_MODEL` `AGENTCI_EXPLORE_VARIANT` configure OpenCode's explore agent and default to `openai/gpt-5.6-luna` with `low`. -`AGENTCI_OPENCODE_VERSION` controls the npm version or range installed into the image and defaults -to `^1`. The build verifies that the resolved version is still OpenCode 1.x and prints it. Docker -may reuse the cached installation layer until the configured version or build inputs change. -Runtime auto-update is disabled so an image cannot cross into OpenCode 2.x after it is built. +The `AGENTCI_OPENCODE_VERSION` Docker build argument controls the npm version or range installed into +the image and defaults to `^1`. The build verifies that the resolved version is still OpenCode 1.x +and prints it. Docker may reuse the cached installation layer until the configured version or build +inputs change. Runtime auto-update is disabled so an image cannot cross into OpenCode 2.x after it +is built. The trusted configuration is `opencode/opencode.json`. OpenCode's default global and built-in-agent permission policies remain in effect; Agent CI does not replace them with an allow-all policy. @@ -153,9 +188,11 @@ uv sync uv run ruff check . uv run pyright uv run pytest +uv run pytest --cov=agentci --cov-branch docker compose config -docker compose build ``` -The tests fail if any tracked Python file exceeds 250 lines. Prompts and JSON schemas live outside -Python so orchestration modules remain small and readable. +The coverage command is an opt-in diagnostic report; the regular test run remains the default and +coverage percentage is not used as a pass threshold. + +Prompts and JSON schemas live outside Python so orchestration remains focused on execution flow. diff --git a/compose.yaml b/compose.yaml index 2762d9c..f1a451e 100644 --- a/compose.yaml +++ b/compose.yaml @@ -1,12 +1,6 @@ services: agentci: - image: agentci:local - build: - context: . - args: - AGENTCI_OPENCODE_VERSION: ${AGENTCI_OPENCODE_VERSION:-^1} - CODEGRAPH_VERSION: ${CODEGRAPH_VERSION:-1.3.1} - TEA_VERSION: ${TEA_VERSION:-0.14.2} + image: ${AGENTCI_IMAGE:-git.krtss.de/stanponomarev/agentci:latest} restart: unless-stopped depends_on: opencode: @@ -39,7 +33,7 @@ services: - webhook_secret - opencode_server_password volumes: - - agentci_data:/var/lib/agentci + - ./data/agentci:/var/lib/agentci - ./install-scripts:/etc/agentci/install-scripts:ro tmpfs: - /run/agentci:mode=1777 @@ -50,7 +44,7 @@ services: - agentci_control opencode: - image: agentci:local + image: ${AGENTCI_IMAGE:-git.krtss.de/stanponomarev/agentci:latest} command: ["opencode", "serve", "--hostname", "0.0.0.0", "--port", "4096"] restart: unless-stopped environment: @@ -81,8 +75,8 @@ services: - gitea_token - opencode_server_password volumes: - - agentci_data:/var/lib/agentci - - opencode_home:/var/lib/opencode + - ./data/agentci:/var/lib/agentci + - ./data/opencode:/var/lib/opencode tmpfs: - /run/agentci:mode=1777 expose: @@ -105,10 +99,6 @@ secrets: opencode_server_password: file: ./secrets/opencode_server_password -volumes: - agentci_data: - opencode_home: - networks: gitea: external: true diff --git a/pyproject.toml b/pyproject.toml index 8503baa..c28c83a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -20,7 +20,7 @@ dev = [ "pyright>=1.1.403", "pytest>=8.4,<9", "pytest-asyncio>=1.1,<2", - "respx>=0.22,<1", + "pytest-cov>=6,<8", "ruff>=0.12,<1", ] diff --git a/src/agentci/__init__.py b/src/agentci/__init__.py index 80392d7..e1f9488 100644 --- a/src/agentci/__init__.py +++ b/src/agentci/__init__.py @@ -1,3 +1 @@ """Gitea-triggered OpenCode workflow host.""" - -__version__ = "0.1.0" diff --git a/src/agentci/__main__.py b/src/agentci/__main__.py index 5945a05..03fc141 100644 --- a/src/agentci/__main__.py +++ b/src/agentci/__main__.py @@ -2,8 +2,8 @@ from __future__ import annotations import uvicorn -from agentci.app import create_app -from agentci.config import Settings +from agentci.api.app import create_app +from agentci.config.settings import Settings def main() -> None: @@ -19,4 +19,3 @@ def main() -> None: if __name__ == "__main__": main() - diff --git a/src/agentci/adapters/__init__.py b/src/agentci/adapters/__init__.py deleted file mode 100644 index 5461621..0000000 --- a/src/agentci/adapters/__init__.py +++ /dev/null @@ -1,2 +0,0 @@ -"""External-system adapters.""" - diff --git a/src/agentci/adapters/database.py b/src/agentci/adapters/database.py deleted file mode 100644 index 4c858d8..0000000 --- a/src/agentci/adapters/database.py +++ /dev/null @@ -1,75 +0,0 @@ -from __future__ import annotations - -import logging -import sqlite3 -from collections.abc import Callable -from datetime import UTC, datetime -from pathlib import Path -from typing import TypeVar - -T = TypeVar("T") -log = logging.getLogger(__name__) - - -def now() -> str: - return datetime.now(UTC).isoformat() - - -class Database: - def __init__(self, database_path: Path, migrations_dir: Path) -> None: - self.database_path = database_path - self.migrations_dir = migrations_dir - - async def initialize(self) -> None: - log.info("database initialization started", extra={"operation": "database.initialize"}) - self.database_path.parent.mkdir(parents=True, exist_ok=True) - try: - await self._run(self._initialize_sync) - except Exception: - log.exception( - "database initialization failed", extra={"operation": "database.initialize"} - ) - raise - log.info("database initialization completed", extra={"operation": "database.initialize"}) - - def _connect(self) -> sqlite3.Connection: - connection = sqlite3.connect(self.database_path, timeout=30) - connection.row_factory = sqlite3.Row - connection.execute("PRAGMA journal_mode=WAL") - connection.execute("PRAGMA foreign_keys=ON") - return connection - - def _initialize_sync(self, connection: sqlite3.Connection) -> None: - connection.execute( - "CREATE TABLE IF NOT EXISTS schema_migrations " - "(version INTEGER PRIMARY KEY, applied_at TEXT NOT NULL)" - ) - applied = { - row[0] for row in connection.execute("SELECT version FROM schema_migrations") - } - for path in sorted(self.migrations_dir.glob("*.sql")): - version = int(path.name.split("_", 1)[0]) - if version in applied: - continue - connection.executescript(path.read_text()) - connection.execute( - "INSERT OR IGNORE INTO schema_migrations VALUES (?, ?)", - (version, now()), - ) - - async def _update(self, table: str, row_id: str, updates: dict[str, object]) -> None: - if not updates: - return - columns = ", ".join(f"{column}=?" for column in updates) - values = [*updates.values(), row_id] - await self._run( - lambda connection: connection.execute( - f"UPDATE {table} SET {columns} WHERE id=?", values - ) - ) - - async def _run(self, operation: Callable[[sqlite3.Connection], T]) -> T: - # Operations are deliberately tiny and serialized by the single worker. - # Avoid a thread pool so SQLite transactions retain deterministic ordering. - with self._connect() as connection: - return operation(connection) diff --git a/src/agentci/adapters/job_store.py b/src/agentci/adapters/job_store.py deleted file mode 100644 index 8f38e26..0000000 --- a/src/agentci/adapters/job_store.py +++ /dev/null @@ -1,212 +0,0 @@ -from __future__ import annotations - -import sqlite3 -from dataclasses import dataclass -from datetime import UTC, datetime, timedelta - -from agentci.adapters.database import Database, now -from agentci.adapters.state_persistence import ( - insert_event as _insert_event, -) -from agentci.adapters.state_persistence import ( - insert_state as _insert_state, -) -from agentci.adapters.state_persistence import ( - insert_tasks as _insert_tasks, -) -from agentci.adapters.state_persistence import ( - insert_workflow as _insert_workflow, -) -from agentci.adapters.state_persistence import ( - optional_state as _optional_state, -) -from agentci.adapters.state_persistence import ( - replace_state as _replace_state, -) -from agentci.adapters.state_persistence import ( - state as _state, -) -from agentci.adapters.state_persistence import ( - state_from_row as _state_from_row, -) -from agentci.domain.events import CommandReceived, JobEvent, WorkflowCreated -from agentci.domain.models import CommandEvent -from agentci.domain.state_machine import JobState, next_state - - -@dataclass(frozen=True) -class EvolveResult: - state: JobState - duplicate: bool - - -@dataclass(frozen=True) -class ListenerTask: - id: int - job_id: str - source_event_id: str - listener: str - queue: str - attempts: int - - -class JobStore(Database): - async def receive( - self, event_id: str, job_id: str, incoming: CommandEvent - ) -> EvolveResult: - def operation(connection: sqlite3.Connection) -> EvolveResult: - connection.execute("BEGIN IMMEDIATE") - duplicate = connection.execute( - "SELECT job_id FROM job_events WHERE event_id=?", (event_id,) - ).fetchone() - if duplicate: - state = _state(connection, duplicate["job_id"]) - connection.commit() - return EvolveResult(state, True) - sequence = connection.execute( - "SELECT COALESCE(MAX(receive_sequence), 0) + 1 FROM jobs" - ).fetchone()[0] - event = CommandReceived( - job_id=job_id, - delivery_id=incoming.delivery_id, - receive_sequence=sequence, - command_body=incoming.body, - target_key=incoming.target_key, - repo_owner=incoming.repo_owner, - repo_name=incoming.repo_name, - issue_number=incoming.issue_number, - pr_number=incoming.pr_number, - requester=incoming.requester, - comment_id=incoming.comment_id, - ) - transition = next_state(None, event) - timestamp = now() - _insert_event(connection, event_id, event, timestamp) - _insert_state(connection, transition.state, timestamp) - _insert_tasks(connection, event_id, transition, timestamp) - connection.commit() - return EvolveResult(transition.state, False) - - return await self._run(operation) - - async def evolve(self, event_id: str, event: JobEvent) -> EvolveResult: - def operation(connection: sqlite3.Connection) -> EvolveResult: - connection.execute("BEGIN IMMEDIATE") - if connection.execute( - "SELECT 1 FROM job_events WHERE event_id=?", (event_id,) - ).fetchone(): - state = _state(connection, event.job_id) - connection.commit() - return EvolveResult(state, True) - current = _state(connection, event.job_id) - transition = next_state(current, event) - timestamp = now() - _insert_event(connection, event_id, event, timestamp) - if isinstance(event, WorkflowCreated): - _insert_workflow(connection, event, timestamp) - _replace_state(connection, transition.state, current, timestamp) - _insert_tasks(connection, event_id, transition, timestamp) - connection.commit() - return EvolveResult(transition.state, False) - - return await self._run(operation) - - async def get_job_state(self, job_id: str) -> JobState | None: - return await self._run(lambda connection: _optional_state(connection, job_id)) - - async def claim_task(self, queue: str) -> ListenerTask | None: - return await self._run(lambda connection: _claim_task(connection, queue)) - - async def complete_task(self, task_id: int) -> None: - await self._run( - lambda connection: connection.execute( - "UPDATE listener_tasks SET status='completed', finished_at=? WHERE id=?", - (now(), task_id), - ) - ) - - async def retry_task(self, task_id: int, attempts: int, error: str) -> None: - delay = min(2 ** min(attempts, 8), 300) - available = (datetime.now(UTC) + timedelta(seconds=delay)).isoformat() - await self._run( - lambda connection: connection.execute( - "UPDATE listener_tasks SET status='pending', available_at=?, error=? WHERE id=?", - (available, error[:1000], task_id), - ) - ) - - async def running_job_states(self) -> list[JobState]: - return await self._run( - lambda connection: [ - _state_from_row(row) - for row in connection.execute("SELECT * FROM jobs WHERE status='running'") - ] - ) - - async def recover_tasks(self) -> None: - def recover(connection: sqlite3.Connection) -> None: - with connection: - connection.execute( - """UPDATE listener_tasks SET status='pending', started_at=NULL - WHERE status='running' AND queue='control'""" - ) - connection.execute( - """UPDATE listener_tasks SET status='pending', started_at=NULL - WHERE status='running' AND listener='execute' AND job_id IN - (SELECT id FROM jobs WHERE status='queued')""" - ) - connection.execute( - """UPDATE listener_tasks SET status='failed', finished_at=?, - error='Service restarted after execution began' - WHERE status='running' AND listener='execute' AND job_id IN - (SELECT id FROM jobs WHERE status<>'queued')""", - (now(),), - ) - - await self._run(recover) - - async def operational_comment_ids(self, owner: str, repo: str, issue: int) -> set[int]: - return await self._run( - lambda connection: { - value - for row in connection.execute( - "SELECT accepted_comment_id, started_comment_id FROM jobs " - "WHERE repo_owner=? AND repo_name=? AND issue_number=?", - (owner, repo, issue), - ) - for value in row - if value is not None - } - ) - - -def _claim_task(connection: sqlite3.Connection, queue: str) -> ListenerTask | None: - connection.execute("BEGIN IMMEDIATE") - fifo = "" - if queue == "jobs": - fifo = """AND NOT EXISTS ( - SELECT 1 FROM jobs earlier WHERE earlier.receive_sequence < j.receive_sequence - AND earlier.target_key = j.target_key - AND earlier.status IN ('received', 'queued', 'running'))""" - row = connection.execute( - f"""SELECT t.* FROM listener_tasks t JOIN jobs j ON j.id=t.job_id - WHERE t.queue=? AND t.status='pending' AND t.available_at<=? {fifo} - ORDER BY {"j.receive_sequence" if queue == "jobs" else "t.id"} LIMIT 1""", - (queue, now()), - ).fetchone() - if row is None: - connection.commit() - return None - changed = connection.execute( - "UPDATE listener_tasks SET status='running', started_at=?, attempts=attempts+1 " - "WHERE id=? AND status='pending'", - (now(), row["id"]), - ) - if changed.rowcount != 1: - connection.rollback() - return None - connection.commit() - return ListenerTask( - row["id"], row["job_id"], row["source_event_id"], row["listener"], - row["queue"], row["attempts"] + 1, - ) diff --git a/src/agentci/adapters/opencode_support.py b/src/agentci/adapters/opencode_support.py deleted file mode 100644 index 6e17904..0000000 --- a/src/agentci/adapters/opencode_support.py +++ /dev/null @@ -1,77 +0,0 @@ -import json -from pathlib import Path -from time import monotonic -from typing import Any - - -def model_parts(model: str) -> tuple[str, str]: - provider, separator, model_id = model.partition("/") - if not separator or not provider or not model_id: - raise ValueError(f"OpenCode model must use provider/model format: {model}") - return provider, model_id - - -def error_message(error: object) -> str | None: - if not error: - return None - if isinstance(error, dict): - return str(error.get("name") or error.get("message") or error) - return str(error) - - -def elapsed_ms(started: float) -> int: - return round((monotonic() - started) * 1000) - - -def directory_headers(workspace: Path) -> dict[str, str]: - return {"X-Opencode-Directory": str(workspace.resolve())} - - -def load_schema(schemas_dir: Path, name: str) -> dict[str, Any]: - try: - value = json.loads((schemas_dir / name).read_text()) - except (OSError, ValueError) as exc: - raise ValueError(f"Cannot load result schema {name}: {exc}") from exc - if not isinstance(value, dict): - raise ValueError(f"Result schema {name} is not a JSON object") - return value - - -def api_contract_ready(document: object) -> bool: - if not isinstance(document, dict) or not isinstance(document.get("paths"), dict): - return False - paths = document["paths"] - fixed = {"/global/health": "get", "/provider": "get", "/session": "post"} - if any(method not in paths.get(path, {}) for path, method in fixed.items()): - return False - session_paths = [path for path in paths if path.startswith("/session/{")] - has_message = any( - path.endswith("/message") and "post" in paths[path] for path in session_paths - ) - has_abort = any(path.endswith("/abort") and "post" in paths[path] for path in session_paths) - return has_message and has_abort - - -def models_ready( - payload: object, requirements: set[tuple[str, str, str | None]] -) -> bool: - if not isinstance(payload, dict): - return False - connected = set(payload.get("connected", [])) - providers = { - item.get("id"): item - for item in payload.get("all", []) - if isinstance(item, dict) and isinstance(item.get("models"), dict) - } - for provider_id, model_id, variant in requirements: - provider = providers.get(provider_id) - if provider_id not in connected or not isinstance(provider, dict): - return False - model: Any = provider["models"].get(model_id) - if not isinstance(model, dict) or model.get("status") == "deprecated": - return False - if model.get("capabilities", {}).get("toolcall") is not True: - return False - if variant and variant not in model.get("variants", {}): - return False - return True diff --git a/src/agentci/adapters/state_persistence.py b/src/agentci/adapters/state_persistence.py deleted file mode 100644 index cea99ec..0000000 --- a/src/agentci/adapters/state_persistence.py +++ /dev/null @@ -1,115 +0,0 @@ -from __future__ import annotations - -import sqlite3 - -from agentci.domain.events import JobEvent, WorkflowCreated -from agentci.domain.models import JobKind, JobStatus -from agentci.domain.state_machine import JobState, Transition - - -def insert_event(connection: sqlite3.Connection, event_id: str, event: JobEvent, ts: str) -> None: - connection.execute( - "INSERT INTO job_events VALUES (?, ?, ?, ?, ?)", - (event_id, event.job_id, event.type, event.model_dump_json(), ts), - ) - - -def insert_tasks( - connection: sqlite3.Connection, event_id: str, transition: Transition, timestamp: str -) -> None: - for ordinal, notification in enumerate(transition.notifications): - connection.execute( - "INSERT INTO listener_tasks(job_id, source_event_id, ordinal, listener, queue, " - "available_at, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)", - ( - transition.state.id, - event_id, - ordinal, - notification.listener, - notification.queue, - timestamp, - timestamp, - ), - ) - - -def _values(state: JobState) -> tuple[object, ...]: - return ( - state.id, state.kind, state.target_key, state.repo_owner, state.repo_name, - state.issue_number, state.pr_number, state.requester, state.message, state.comment_id, - state.delivery_id, state.receive_sequence, state.command_body, state.workflow_id, - state.status, state.stage, state.error, state.runtime_session_id, - state.accepted_comment_id, state.comment_body, - ) - - -def insert_state(connection: sqlite3.Connection, state: JobState, timestamp: str) -> None: - connection.execute( - """INSERT INTO jobs(id, kind, target_key, repo_owner, repo_name, issue_number, - pr_number, requester, message, comment_id, delivery_id, receive_sequence, command_body, - workflow_id, status, stage, error, runtime_session_id, accepted_comment_id, - comment_body, created_at) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""", - (*_values(state), timestamp), - ) - - -def replace_state( - connection: sqlite3.Connection, state: JobState, previous: JobState, timestamp: str -) -> None: - started = ( - timestamp - if previous.status is JobStatus.QUEUED and state.status is JobStatus.RUNNING - else None - ) - terminal = {JobStatus.SUCCEEDED, JobStatus.REJECTED, JobStatus.FAILED} - finished = timestamp if previous.status not in terminal and state.status in terminal else None - connection.execute( - """UPDATE jobs SET kind=?, target_key=?, repo_owner=?, repo_name=?, issue_number=?, - pr_number=?, requester=?, message=?, comment_id=?, delivery_id=?, receive_sequence=?, - command_body=?, workflow_id=?, status=?, stage=?, error=?, runtime_session_id=?, - accepted_comment_id=?, comment_body=?, started_at=COALESCE(started_at, ?), - finished_at=COALESCE(finished_at, ?) WHERE id=?""", - (*_values(state)[1:], started, finished, state.id), - ) - - -def optional_state(connection: sqlite3.Connection, job_id: str) -> JobState | None: - row = connection.execute("SELECT * FROM jobs WHERE id=?", (job_id,)).fetchone() - return state_from_row(row) if row else None - - -def state(connection: sqlite3.Connection, job_id: str) -> JobState: - value = optional_state(connection, job_id) - if value is None: - raise KeyError(f"Unknown job {job_id}") - return value - - -def state_from_row(row: sqlite3.Row) -> JobState: - return JobState( - id=row["id"], kind=JobKind(row["kind"]) if row["kind"] else None, - target_key=row["target_key"], repo_owner=row["repo_owner"], repo_name=row["repo_name"], - issue_number=row["issue_number"], pr_number=row["pr_number"], requester=row["requester"], - message=row["message"], comment_id=row["comment_id"], delivery_id=row["delivery_id"], - receive_sequence=row["receive_sequence"], command_body=row["command_body"], - workflow_id=row["workflow_id"], status=JobStatus(row["status"]), stage=row["stage"], - error=row["error"], runtime_session_id=row["runtime_session_id"], - accepted_comment_id=row["accepted_comment_id"], comment_body=row["comment_body"], - ) - - -def insert_workflow(connection: sqlite3.Connection, event: WorkflowCreated, ts: str) -> None: - workflow = event.workflow - connection.execute( - """INSERT INTO workflows(id, kind, repo_owner, repo_name, issue_number, pr_number, - base_sha, branch, workspace_path, primary_session_id, reviewer_session_id, artifact, - review_json, status, runtime, created_at, updated_at) - VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""", - ( - workflow.id, workflow.kind, workflow.repo_owner, workflow.repo_name, - workflow.issue_number, workflow.pr_number, workflow.base_sha, workflow.branch, - str(workflow.workspace_path), workflow.primary_session_id, - workflow.reviewer_session_id, workflow.artifact, workflow.review_json, - workflow.status, workflow.runtime, ts, ts, - ), - ) diff --git a/src/agentci/adapters/storage.py b/src/agentci/adapters/storage.py deleted file mode 100644 index 88089b5..0000000 --- a/src/agentci/adapters/storage.py +++ /dev/null @@ -1,7 +0,0 @@ -from agentci.adapters.job_store import JobStore -from agentci.adapters.workflow_store import WorkflowStore - - -class Storage(JobStore, WorkflowStore): - """Combined durable job and workflow repository.""" - diff --git a/src/agentci/adapters/workflow_store.py b/src/agentci/adapters/workflow_store.py deleted file mode 100644 index 3037fad..0000000 --- a/src/agentci/adapters/workflow_store.py +++ /dev/null @@ -1,156 +0,0 @@ -from __future__ import annotations - -import sqlite3 -from pathlib import Path - -from agentci.adapters.database import Database, now -from agentci.domain.models import Workflow, WorkflowKind, WorkflowStatus - - -class WorkflowStore(Database): - async def get_workflow(self, workflow_id: str) -> Workflow | None: - return await self._run( - lambda connection: workflow_from_row( - connection.execute( - "SELECT * FROM workflows WHERE id=?", (workflow_id,) - ).fetchone() - ) - ) - - async def create_workflow(self, workflow: Workflow) -> None: - timestamp = now() - await self._run( - lambda connection: connection.execute( - """ - INSERT INTO workflows ( - id, kind, repo_owner, repo_name, issue_number, pr_number, - base_sha, branch, workspace_path, primary_session_id, - reviewer_session_id, artifact, review_json, status, runtime, - created_at, updated_at - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - """, - ( - workflow.id, - workflow.kind, - workflow.repo_owner, - workflow.repo_name, - workflow.issue_number, - workflow.pr_number, - workflow.base_sha, - workflow.branch, - str(workflow.workspace_path), - workflow.primary_session_id, - workflow.reviewer_session_id, - workflow.artifact, - workflow.review_json, - workflow.status, - workflow.runtime, - timestamp, - timestamp, - ), - ) - ) - - async def update_workflow(self, workflow: Workflow) -> None: - await self._update( - "workflows", - workflow.id, - { - "pr_number": workflow.pr_number, - "branch": workflow.branch, - "primary_session_id": workflow.primary_session_id, - "reviewer_session_id": workflow.reviewer_session_id, - "artifact": workflow.artifact, - "review_json": workflow.review_json, - "status": workflow.status, - "updated_at": now(), - }, - ) - - async def latest_workflow( - self, - owner: str, - repo: str, - issue: int, - kind: WorkflowKind, - ) -> Workflow | None: - return await self._run( - lambda connection: workflow_from_row( - connection.execute( - """ - SELECT * FROM workflows - WHERE repo_owner=? AND repo_name=? AND issue_number=? - AND kind=? AND status=? - ORDER BY created_at DESC LIMIT 1 - """, - (owner, repo, issue, kind, WorkflowStatus.COMPLETED), - ).fetchone() - ) - ) - - async def workflow_for_pr(self, owner: str, repo: str, pr: int) -> Workflow | None: - return await self._run( - lambda connection: workflow_from_row( - connection.execute( - """ - SELECT * FROM workflows - WHERE repo_owner=? AND repo_name=? AND pr_number=? AND kind=? - ORDER BY created_at DESC LIMIT 1 - """, - (owner, repo, pr, WorkflowKind.IMPLEMENT), - ).fetchone() - ) - ) - - async def implementation_workflows( - self, owner: str, repo: str, issue: int - ) -> list[Workflow]: - return await self._run( - lambda connection: [ - item - for row in connection.execute( - """ - SELECT * FROM workflows - WHERE repo_owner=? AND repo_name=? AND issue_number=? AND kind=? - AND pr_number IS NOT NULL - ORDER BY created_at DESC - """, - (owner, repo, issue, WorkflowKind.IMPLEMENT), - ) - if (item := workflow_from_row(row)) is not None - ] - ) - - async def fail_job_workflow(self, job_id: str) -> None: - await self._run( - lambda connection: connection.execute( - """ - UPDATE workflows SET status=?, updated_at=? - WHERE id=(SELECT workflow_id FROM jobs WHERE id=?) - AND status=? - """, - (WorkflowStatus.FAILED, now(), job_id, WorkflowStatus.ACTIVE), - ) - ) - - -def workflow_from_row(row: sqlite3.Row | None) -> Workflow | None: - if row is None: - return None - return Workflow( - id=row["id"], - kind=WorkflowKind(row["kind"]), - repo_owner=row["repo_owner"], - repo_name=row["repo_name"], - issue_number=row["issue_number"], - pr_number=row["pr_number"], - base_sha=row["base_sha"], - runtime=row["runtime"], - branch=row["branch"], - workspace_path=Path(row["workspace_path"]), - primary_session_id=row["primary_session_id"], - reviewer_session_id=row["reviewer_session_id"], - artifact=row["artifact"], - review_json=row["review_json"], - status=WorkflowStatus(row["status"]), - ) diff --git a/src/agentci/api/__init__.py b/src/agentci/api/__init__.py index 94d8bb0..f6c85c6 100644 --- a/src/agentci/api/__init__.py +++ b/src/agentci/api/__init__.py @@ -1,2 +1 @@ -"""HTTP API.""" - +"""HTTP application and routes.""" diff --git a/src/agentci/api/app.py b/src/agentci/api/app.py new file mode 100644 index 0000000..a2cad02 --- /dev/null +++ b/src/agentci/api/app.py @@ -0,0 +1,25 @@ +from __future__ import annotations + +from fastapi import FastAPI + +from agentci.api.errors import register_error_handlers +from agentci.api.lifespan import create_lifespan +from agentci.api.routes.health import router as health_router +from agentci.api.routes.webhook import router as webhook_router +from agentci.config.settings import Settings + + +def create_app(settings: Settings | None = None) -> FastAPI: + selected_settings = settings or Settings() + app = FastAPI( + title="Agent CI", + version="0.1.0", + lifespan=create_lifespan(selected_settings), + ) + app.include_router(health_router) + app.include_router(webhook_router) + register_error_handlers(app) + return app + + +app = create_app() diff --git a/src/agentci/api/dependencies.py b/src/agentci/api/dependencies.py new file mode 100644 index 0000000..875e883 --- /dev/null +++ b/src/agentci/api/dependencies.py @@ -0,0 +1,14 @@ +from __future__ import annotations + +from typing import Annotated + +from fastapi import Depends, Request + +from agentci.application.runtime import Runtime + + +def get_runtime(request: Request) -> Runtime: + return request.app.state.runtime + + +type RuntimeDependency = Annotated[Runtime, Depends(get_runtime)] diff --git a/src/agentci/api/errors.py b/src/agentci/api/errors.py new file mode 100644 index 0000000..189456a --- /dev/null +++ b/src/agentci/api/errors.py @@ -0,0 +1,29 @@ +from __future__ import annotations + +import logging + +from fastapi import FastAPI, Request +from fastapi.responses import JSONResponse + +log = logging.getLogger(__name__) + + +async def unhandled_error(request: Request, exc: Exception) -> JSONResponse: + log.exception( + "unhandled HTTP request failure", + extra={ + "operation": "http.request", + "method": request.method, + "path": request.url.path, + "error_message": str(exc), + "status_code": 500, + }, + ) + return JSONResponse( + status_code=500, + content={"detail": "Internal server error. See service logs for diagnostics."}, + ) + + +def register_error_handlers(app: FastAPI) -> None: + app.exception_handler(Exception)(unhandled_error) diff --git a/src/agentci/api/lifespan.py b/src/agentci/api/lifespan.py new file mode 100644 index 0000000..f7e066e --- /dev/null +++ b/src/agentci/api/lifespan.py @@ -0,0 +1,45 @@ +from __future__ import annotations + +import asyncio +import logging +from collections.abc import AsyncGenerator, Callable +from contextlib import AbstractAsyncContextManager, asynccontextmanager, suppress + +from fastapi import FastAPI + +from agentci.application.runtime import build_runtime +from agentci.config.settings import Settings +from agentci.observability.logging import configure_logging + +log = logging.getLogger(__name__) +Lifespan = Callable[[FastAPI], AbstractAsyncContextManager[None]] + + +def create_lifespan(settings: Settings) -> Lifespan: + @asynccontextmanager + async def lifespan(app: FastAPI) -> AsyncGenerator[None]: + configure_logging() + log.info("service startup started", extra={"operation": "service.startup"}) + try: + runtime = await build_runtime(settings) + except Exception: + log.exception("service startup failed", extra={"operation": "service.startup"}) + raise + app.state.runtime = runtime + stop = asyncio.Event() + worker_task = asyncio.create_task(runtime.worker.run(stop), name="agentci-worker") + try: + log.info("service startup completed", extra={"operation": "service.startup"}) + yield + finally: + log.info("service shutdown started", extra={"operation": "service.shutdown"}) + stop.set() + worker_task.cancel() + try: + with suppress(asyncio.CancelledError): + await worker_task + finally: + await runtime.close() + log.info("service shutdown completed", extra={"operation": "service.shutdown"}) + + return lifespan diff --git a/src/agentci/api/routes/__init__.py b/src/agentci/api/routes/__init__.py new file mode 100644 index 0000000..7595942 --- /dev/null +++ b/src/agentci/api/routes/__init__.py @@ -0,0 +1 @@ +"""FastAPI route modules.""" diff --git a/src/agentci/api/health.py b/src/agentci/api/routes/health.py similarity index 61% rename from src/agentci/api/health.py rename to src/agentci/api/routes/health.py index 796ae0d..5b5eb0d 100644 --- a/src/agentci/api/health.py +++ b/src/agentci/api/routes/health.py @@ -1,6 +1,8 @@ from __future__ import annotations -from fastapi import APIRouter, Request, Response, status +from fastapi import APIRouter, Response, status + +from agentci.api.dependencies import RuntimeDependency router = APIRouter() @@ -11,8 +13,8 @@ async def live() -> dict[str, str]: @router.get("/health/ready") -async def ready(request: Request, response: Response) -> dict[str, str]: - if not await request.app.state.container.opencode.ready(): +async def ready(runtime: RuntimeDependency, response: Response) -> dict[str, str]: + if not await runtime.opencode.ready(): response.status_code = status.HTTP_503_SERVICE_UNAVAILABLE return {"status": "not-ready", "reason": "opencode provider is not connected"} return {"status": "ready"} diff --git a/src/agentci/api/webhook.py b/src/agentci/api/routes/webhook.py similarity index 52% rename from src/agentci/api/webhook.py rename to src/agentci/api/routes/webhook.py index 2e7bdbb..d08cbbf 100644 --- a/src/agentci/api/webhook.py +++ b/src/agentci/api/routes/webhook.py @@ -1,30 +1,28 @@ from __future__ import annotations -import hashlib -import hmac import json import logging -from typing import Any from fastapi import APIRouter, HTTPException, Request, Response, status -from agentci.domain.models import CommandEvent +from agentci.api.dependencies import RuntimeDependency +from agentci.application.runtime import Runtime +from agentci.engine.model import IncomingCommand +from agentci.integrations.gitea.webhooks import ( + SUPPORTED_EVENTS, + incoming_command_from_payload, + valid_signature, +) router = APIRouter() log = logging.getLogger(__name__) -SUPPORTED_EVENTS = { - "issue_comment", - "pull_request_comment", - "pull_request_review_comment", -} @router.post("/webhooks/gitea") -async def webhook(request: Request) -> Response: - container = request.app.state.container +async def webhook(request: Request, runtime: RuntimeDependency) -> Response: body = await request.body() signature = request.headers.get("X-Gitea-Signature", "") - if not valid_signature(container.settings.webhook_secret, body, signature): + if not valid_signature(runtime.settings.webhook_secret, body, signature): log.warning( "webhook signature rejected", extra={"operation": "webhook.verify", "path": request.url.path}, @@ -41,29 +39,26 @@ async def webhook(request: Request) -> Response: return Response(status_code=status.HTTP_204_NO_CONTENT) try: payload = json.loads(body) - event = _event_from_payload( + if not isinstance(payload, dict): + raise ValueError("Webhook payload must be a JSON object") + event = incoming_command_from_payload( request.headers.get("X-Gitea-Delivery", ""), payload, ) - except (KeyError, TypeError, ValueError, json.JSONDecodeError) as exc: + except (KeyError, TypeError, ValueError) as exc: log.warning( "webhook payload rejected", extra={"operation": "webhook.parse", "stage": event_name}, exc_info=exc, ) raise HTTPException(status.HTTP_400_BAD_REQUEST, "Invalid webhook payload") from exc - if event is None or event.requester.casefold() == container.settings.bot_username.casefold(): + if event is None or event.requester.casefold() == runtime.settings.bot_username.casefold(): log.info("webhook ignored", extra={"operation": "webhook.filter", "stage": event_name}) return Response(status_code=status.HTTP_204_NO_CONTENT) - return await _handle_command(container, event) + return await _handle_command(runtime, event) -def valid_signature(secret: bytes, body: bytes, signature: str) -> bool: - expected = hmac.new(secret, body, hashlib.sha256).hexdigest() - return bool(signature) and hmac.compare_digest(expected, signature) - - -async def _handle_command(container: Any, event: CommandEvent) -> Response: +async def _handle_command(runtime: Runtime, event: IncomingCommand) -> Response: extra = {"operation": "command.handle", "target": event.target_key} if not event.body.strip().startswith("/agent"): return Response(status_code=status.HTTP_204_NO_CONTENT) @@ -71,45 +66,19 @@ async def _handle_command(container: Any, event: CommandEvent) -> Response: raise HTTPException(status.HTTP_400_BAD_REQUEST, "Missing X-Gitea-Delivery") log.info("agent command received", extra=extra) try: - result = await container.state_machine.receive(event) + result = await runtime.repository.accept(event) except Exception: log.exception("could not persist command", extra=extra) raise if result.duplicate: - log.info("duplicate command ignored", extra={**extra, "job_id": result.state.id}) + log.info("duplicate command ignored", extra={**extra, "job_id": result.job.id}) return Response(status_code=status.HTTP_200_OK) log.info( "agent command persisted", extra={ **extra, - "job_id": result.state.id, - "receive_sequence": result.state.receive_sequence, + "job_id": result.job.id, + "receive_sequence": result.job.receive_sequence, }, ) return Response(status_code=status.HTTP_202_ACCEPTED) - - -def _event_from_payload(delivery_id: str, payload: dict[str, Any]) -> CommandEvent | None: - if payload.get("action") != "created": - return None - comment = payload["comment"] - repository = payload["repository"] - owner = repository["owner"] - owner_name = owner.get("login") or owner.get("username") or owner["name"] - pull = payload.get("pull_request") - is_pull = bool(payload.get("is_pull") or pull) - issue = payload.get("issue") - target = pull or issue - if target is None: - raise ValueError("Comment payload has no issue or pull request") - number = int(target["number"]) - return CommandEvent( - delivery_id=delivery_id, - comment_id=int(comment["id"]), - repo_owner=owner_name, - repo_name=repository["name"], - issue_number=number, - pr_number=number if is_pull else None, - requester=comment["user"]["login"], - body=comment.get("body") or "", - ) diff --git a/src/agentci/app.py b/src/agentci/app.py deleted file mode 100644 index fa2f292..0000000 --- a/src/agentci/app.py +++ /dev/null @@ -1,71 +0,0 @@ -from __future__ import annotations - -import asyncio -import logging -from collections.abc import AsyncIterator -from contextlib import asynccontextmanager, suppress - -from fastapi import FastAPI, Request -from fastapi.responses import JSONResponse - -from agentci.api.health import router as health_router -from agentci.api.webhook import router as webhook_router -from agentci.config import Settings -from agentci.container import build_container -from agentci.logging import configure_logging - -log = logging.getLogger(__name__) - -def create_app(settings: Settings | None = None) -> FastAPI: - selected_settings = settings or Settings() - - @asynccontextmanager - async def lifespan(app: FastAPI) -> AsyncIterator[None]: - configure_logging() - log.info("service startup started", extra={"operation": "service.startup"}) - try: - container = await build_container(selected_settings) - except Exception: - log.exception("service startup failed", extra={"operation": "service.startup"}) - raise - app.state.container = container - stop = asyncio.Event() - worker_task = asyncio.create_task(container.worker.run(stop), name="agentci-worker") - try: - log.info("service startup completed", extra={"operation": "service.startup"}) - yield - finally: - log.info("service shutdown started", extra={"operation": "service.shutdown"}) - stop.set() - worker_task.cancel() - try: - with suppress(asyncio.CancelledError): - await worker_task - finally: - await container.close() - log.info("service shutdown completed", extra={"operation": "service.shutdown"}) - - app = FastAPI(title="Agent CI", version="0.1.0", lifespan=lifespan) - app.include_router(health_router) - app.include_router(webhook_router) - - @app.exception_handler(Exception) - async def unhandled_error(request: Request, exc: Exception) -> JSONResponse: - log.exception( - "unhandled HTTP request failure", - extra={ - "operation": "http.request", - "method": request.method, - "path": request.url.path, - "status_code": 500, - }, - ) - return JSONResponse( - status_code=500, - content={"detail": "Internal server error. See service logs for diagnostics."}, - ) - - return app - - -app = create_app() diff --git a/src/agentci/application/__init__.py b/src/agentci/application/__init__.py new file mode 100644 index 0000000..858c2a1 --- /dev/null +++ b/src/agentci/application/__init__.py @@ -0,0 +1 @@ +"""Application composition and durable orchestration.""" diff --git a/src/agentci/application/runtime.py b/src/agentci/application/runtime.py new file mode 100644 index 0000000..f2f29af --- /dev/null +++ b/src/agentci/application/runtime.py @@ -0,0 +1,95 @@ +from __future__ import annotations + +import logging +from dataclasses import dataclass + +from agentci.application.worker.runner import Worker +from agentci.config.settings import Settings +from agentci.engine.repository import Repository +from agentci.integrations.development import DevelopmentEnvironment +from agentci.integrations.git import Git +from agentci.integrations.gitea.client import Gitea +from agentci.integrations.opencode.client import OpenCode +from agentci.prompts.library import PromptLibrary +from agentci.workflows.services import WorkflowServices + +log = logging.getLogger(__name__) + +@dataclass(frozen=True) +class Runtime: + settings: Settings + repository: Repository + gitea: Gitea + git: Git + opencode: OpenCode + worker: Worker + + async def close(self) -> None: + log.info("runtime shutdown started", extra={"operation": "runtime.close"}) + try: + await self.opencode.close() + finally: + await self.gitea.close() + log.info("runtime shutdown completed", extra={"operation": "runtime.close"}) + + +async def build_runtime(settings: Settings) -> Runtime: + log.info("runtime initialization started", extra={"operation": "runtime.build"}) + settings.data_dir.mkdir(parents=True, exist_ok=True) + settings.workspaces_dir.mkdir(parents=True, exist_ok=True) + repository = Repository(settings.database_path) + await repository.initialize() + gitea = Gitea(settings.gitea_url, settings.gitea_token) + git = Git( + gitea_url=settings.gitea_url, + username=settings.bot_username, + token=settings.gitea_token, + askpass_path=settings.askpass_path, + commit_name=settings.bot_name, + commit_email=settings.bot_email, + ) + prompts = PromptLibrary() + opencode = OpenCode( + base_url=settings.opencode_url, + username=settings.opencode_server_username, + password=settings.opencode_server_password, + schemas_dir=prompts.schemas_dir, + health_directory=settings.workspaces_dir, + required_models=( + (settings.plan_model, settings.plan_variant), + (settings.implement_model, settings.implement_variant), + (settings.explore_model, settings.explore_variant), + (settings.research_model, settings.research_variant), + ), + timeout_seconds=settings.turn_timeout_seconds, + ) + development = DevelopmentEnvironment( + scripts=settings.install_scripts, + scripts_dir=settings.install_scripts_dir, + tools_dir=settings.dev_tools_dir, + timeout_seconds=settings.install_script_timeout_seconds, + python_version=settings.python_version, + dotnet_channel=settings.dotnet_channel, + ) + services = WorkflowServices( + settings=settings, + repository=repository, + gitea=gitea, + git=git, + opencode=opencode, + prompts=prompts, + development=development, + ) + worker = Worker( + repository=repository, + gitea=gitea, + opencode=opencode, + services=services, + poll_seconds=settings.worker_poll_seconds, + max_concurrent_jobs=settings.max_concurrent_jobs, + workspaces_dir=settings.workspaces_dir, + bot_username=settings.bot_username, + ) + runtime = Runtime(settings, repository, gitea, git, opencode, worker) + log.info("runtime initialization completed", extra={"operation": "runtime.build"}) + return runtime diff --git a/src/agentci/application/worker/__init__.py b/src/agentci/application/worker/__init__.py new file mode 100644 index 0000000..36a41e6 --- /dev/null +++ b/src/agentci/application/worker/__init__.py @@ -0,0 +1 @@ +"""Durable task runner and task handlers.""" diff --git a/src/agentci/application/worker/authorization.py b/src/agentci/application/worker/authorization.py new file mode 100644 index 0000000..4ab08fd --- /dev/null +++ b/src/agentci/application/worker/authorization.py @@ -0,0 +1,17 @@ +from __future__ import annotations + +from agentci.engine.events import PermissionDenied, PermissionGranted +from agentci.engine.model import Job, JobStatus, Task +from agentci.engine.repository import Repository +from agentci.integrations.gitea.client import Gitea + + +async def authorize_job( + *, task: Task, job: Job, repository: Repository, gitea: Gitea +) -> None: + if job.status is not JobStatus.RECEIVED: + return + permitted = await gitea.has_write_permission(job.repo_owner, job.repo_name, job.requester) + event = PermissionGranted(job_id=job.id) if permitted else PermissionDenied(job_id=job.id) + outcome = "permission-granted" if permitted else "permission-denied" + await repository.apply(f"task:{task.id}:{outcome}", event) diff --git a/src/agentci/application/worker/comments.py b/src/agentci/application/worker/comments.py new file mode 100644 index 0000000..c8ed505 --- /dev/null +++ b/src/agentci/application/worker/comments.py @@ -0,0 +1,48 @@ +from __future__ import annotations + +from agentci.engine.events import CommentLinked +from agentci.engine.model import Job, Task +from agentci.engine.reducer import render_job_comment +from agentci.engine.repository import Repository +from agentci.integrations.gitea.client import Gitea + + +async def reconcile_comment( + *, + task: Task, + job: Job, + repository: Repository, + gitea: Gitea, + bot_username: str, +) -> None: + latest = await repository.get_job(job.id) + if latest is None: + return + body = render_job_comment(latest) + comment_id = latest.accepted_comment_id + if comment_id is not None and await gitea.update_comment( + latest.repo_owner, latest.repo_name, comment_id, body + ): + return + marker = f"" + matches = sorted( + comment.id + for comment in await gitea.issue_comments( + latest.repo_owner, latest.repo_name, latest.issue_number + ) + if comment.body.startswith(marker) + and comment.author.casefold() == bot_username.casefold() + ) + if matches: + comment_id = matches[0] + else: + comment_id = await gitea.create_comment( + latest.repo_owner, latest.repo_name, latest.issue_number, body + ) + await repository.apply( + f"task:{task.id}:comment:{comment_id}", + CommentLinked(job_id=latest.id, comment_id=comment_id), + ) + await gitea.update_comment( + latest.repo_owner, latest.repo_name, comment_id, body + ) diff --git a/src/agentci/application/worker/errors.py b/src/agentci/application/worker/errors.py new file mode 100644 index 0000000..e8d236b --- /dev/null +++ b/src/agentci/application/worker/errors.py @@ -0,0 +1,6 @@ +from __future__ import annotations + + +def safe_error(error: Exception) -> str: + message = " ".join(str(error).split()) + return f"{type(error).__name__}: {message}"[:1000] diff --git a/src/agentci/application/worker/execution.py b/src/agentci/application/worker/execution.py new file mode 100644 index 0000000..b0e73b7 --- /dev/null +++ b/src/agentci/application/worker/execution.py @@ -0,0 +1,47 @@ +from __future__ import annotations + +from agentci.application.worker.errors import safe_error +from agentci.engine.events import JobCompleted, JobFailed, JobStarted, ServiceRestarted +from agentci.engine.events import JobRejected as RejectedEvent +from agentci.engine.model import Job, JobStatus, Task +from agentci.engine.repository import Repository +from agentci.engine.run import JobRun +from agentci.workflows.dispatch import dispatch +from agentci.workflows.render import JobRejected +from agentci.workflows.services import WorkflowServices + + +async def execute_job( + *, task: Task, job: Job, repository: Repository, services: WorkflowServices +) -> None: + if job.status is JobStatus.RUNNING: + await repository.apply( + f"task:{task.id}:interrupted", ServiceRestarted(job_id=job.id) + ) + return + if job.status is not JobStatus.QUEUED: + return + result = await repository.apply(f"task:{task.id}:started", JobStarted(job_id=job.id)) + running = result.job + run = JobRun(repository, job.id, task.id) + try: + body = await dispatch(running, run, services) + except JobRejected as exc: + await repository.apply( + f"task:{task.id}:rejected", RejectedEvent(job_id=job.id, reason=str(exc)) + ) + except Exception as exc: + latest = await repository.get_job(job.id) + stage = latest.stage if latest else running.stage + await repository.apply( + f"task:{task.id}:failed", + JobFailed(job_id=job.id, error=safe_error(exc), stage=stage), + ) + else: + await repository.apply( + f"task:{task.id}:completed", + JobCompleted( + job_id=job.id, + comment_body=body or "Agent job completed.", + ), + ) diff --git a/src/agentci/application/worker/recovery.py b/src/agentci/application/worker/recovery.py new file mode 100644 index 0000000..7c7d32b --- /dev/null +++ b/src/agentci/application/worker/recovery.py @@ -0,0 +1,13 @@ +from __future__ import annotations + +from agentci.engine.events import ServiceRestarted +from agentci.engine.repository import Repository + + +async def recover_jobs(repository: Repository) -> None: + await repository.recover_tasks() + for job in await repository.running_jobs(): + await repository.apply( + f"recovery:{job.id}:service-restarted", + ServiceRestarted(job_id=job.id), + ) diff --git a/src/agentci/application/worker/runner.py b/src/agentci/application/worker/runner.py new file mode 100644 index 0000000..73d2e2e --- /dev/null +++ b/src/agentci/application/worker/runner.py @@ -0,0 +1,134 @@ +from __future__ import annotations + +import asyncio +import logging +from contextlib import suppress +from pathlib import Path + +from agentci.application.worker.authorization import authorize_job +from agentci.application.worker.comments import reconcile_comment +from agentci.application.worker.errors import safe_error +from agentci.application.worker.execution import execute_job +from agentci.application.worker.recovery import recover_jobs +from agentci.application.worker.sessions import abort_job_sessions +from agentci.engine.model import Job, QueueName, Task, TaskKind +from agentci.engine.repository import Repository +from agentci.integrations.gitea.client import Gitea +from agentci.integrations.opencode.client import OpenCode +from agentci.workflows.services import WorkflowServices + +log = logging.getLogger(__name__) + + +class Worker: + def __init__( + self, + *, + repository: Repository, + gitea: Gitea, + opencode: OpenCode, + services: WorkflowServices, + poll_seconds: float, + max_concurrent_jobs: int, + workspaces_dir: Path, + bot_username: str, + ) -> None: + self.repository = repository + self.gitea = gitea + self.opencode = opencode + self.services = services + self.poll_seconds = poll_seconds + self.max_concurrent_jobs = max_concurrent_jobs + self.workspaces_dir = workspaces_dir + self.bot_username = bot_username + + async def run(self, stop: asyncio.Event) -> None: + await self._recover() + await asyncio.gather( + self._loop(QueueName.CONTROL, stop), + *(self._loop(QueueName.JOBS, stop) for _ in range(self.max_concurrent_jobs)), + ) + + async def _loop(self, queue: QueueName, stop: asyncio.Event) -> None: + while not stop.is_set(): + if queue is QueueName.JOBS and not await self.opencode.ready(): + await self._wait(stop) + continue + task = await self.repository.claim_task(queue) + if task is None: + await self._wait(stop) + continue + try: + await self._handle(task) + except asyncio.CancelledError: + raise + except Exception as exc: + log.exception( + "listener failed", + extra={ + "task_id": task.id, + "listener": task.kind.value, + "queue": queue.value, + }, + ) + await self.repository.retry_task(task.id, task.attempts, safe_error(exc)) + else: + await self.repository.complete_task(task.id) + + async def _handle(self, task: Task) -> None: + job = await self.repository.get_job(task.job_id) + if job is None: + return + match task.kind: + case TaskKind.AUTHORIZE: + await self._authorize(task, job) + case TaskKind.EXECUTE: + await self._execute(task, job) + case TaskKind.RECONCILE_COMMENT: + await self._reconcile(task, job) + case TaskKind.FAIL_WORKFLOW: + await self.repository.fail_job_workflow(job.id) + case TaskKind.ABORT_SESSIONS: + await self._abort_job_sessions(job) + case _: + raise RuntimeError(f"Unknown task kind {task.kind}") + + async def _authorize(self, task: Task, job: Job) -> None: + await authorize_job( + task=task, + job=job, + repository=self.repository, + gitea=self.gitea, + ) + + async def _execute(self, task: Task, job: Job) -> None: + await execute_job( + task=task, + job=job, + repository=self.repository, + services=self.services, + ) + + async def _reconcile(self, task: Task, job: Job) -> None: + await reconcile_comment( + task=task, + job=job, + repository=self.repository, + gitea=self.gitea, + bot_username=self.bot_username, + ) + + async def _recover(self) -> None: + await recover_jobs(self.repository) + + async def _abort_job_sessions(self, job: Job) -> None: + await abort_job_sessions( + job=job, + repository=self.repository, + opencode=self.opencode, + workspaces_dir=self.workspaces_dir, + ) + + async def _wait(self, stop: asyncio.Event) -> None: + with suppress(TimeoutError): + await asyncio.wait_for(stop.wait(), timeout=self.poll_seconds) diff --git a/src/agentci/application/worker/sessions.py b/src/agentci/application/worker/sessions.py new file mode 100644 index 0000000..47f367c --- /dev/null +++ b/src/agentci/application/worker/sessions.py @@ -0,0 +1,30 @@ +from __future__ import annotations + +from pathlib import Path + +from agentci.engine.model import Job +from agentci.engine.repository import Repository +from agentci.integrations.opencode.client import OpenCode + + +async def abort_job_sessions( + *, + job: Job, + repository: Repository, + opencode: OpenCode, + workspaces_dir: Path, +) -> None: + sessions: set[tuple[str, Path]] = set() + workflow = await repository.get_workflow(job.workflow_id) if job.workflow_id else None + if workflow: + sessions.update( + (session, workflow.workspace_path) + for session in (workflow.primary_session_id, workflow.reviewer_session_id) + if session + ) + elif job.runtime_session_id: + sessions.add( + (job.runtime_session_id, workspaces_dir / f"fix-{job.id}" / "repo") + ) + for session, workspace in sessions: + await opencode.abort(session, workspace) diff --git a/src/agentci/config/__init__.py b/src/agentci/config/__init__.py new file mode 100644 index 0000000..30025d0 --- /dev/null +++ b/src/agentci/config/__init__.py @@ -0,0 +1 @@ +"""Service configuration.""" diff --git a/src/agentci/config.py b/src/agentci/config/settings.py similarity index 100% rename from src/agentci/config.py rename to src/agentci/config/settings.py diff --git a/src/agentci/container.py b/src/agentci/container.py deleted file mode 100644 index 09f502d..0000000 --- a/src/agentci/container.py +++ /dev/null @@ -1,104 +0,0 @@ -from __future__ import annotations - -import logging -from dataclasses import dataclass -from pathlib import Path - -from agentci.adapters.development import DevelopmentEnvironment -from agentci.adapters.git import GitClient -from agentci.adapters.gitea import GiteaClient -from agentci.adapters.opencode import OpenCodeClient -from agentci.adapters.storage import Storage -from agentci.config import Settings -from agentci.prompts import PromptLibrary -from agentci.state_machine import StateMachine -from agentci.worker import Worker -from agentci.workflows.common import Dependencies -from agentci.workflows.context import ContextBuilder -from agentci.workflows.dispatcher import Dispatcher - -log = logging.getLogger(__name__) - -@dataclass(frozen=True) -class Container: - settings: Settings - storage: Storage - gitea: GiteaClient - git: GitClient - opencode: OpenCodeClient - state_machine: StateMachine - worker: Worker - - async def close(self) -> None: - log.info("container shutdown started", extra={"operation": "container.close"}) - await self.opencode.close() - await self.gitea.close() - log.info("container shutdown completed", extra={"operation": "container.close"}) - - -async def build_container(settings: Settings) -> Container: - log.info("container initialization started", extra={"operation": "container.build"}) - package_dir = Path(__file__).parent - settings.data_dir.mkdir(parents=True, exist_ok=True) - settings.workspaces_dir.mkdir(parents=True, exist_ok=True) - storage = Storage(settings.database_path, package_dir / "migrations") - await storage.initialize() - state_machine = StateMachine(storage) - gitea = GiteaClient(settings.gitea_url, settings.gitea_token) - git = GitClient( - gitea_url=settings.gitea_url, - username=settings.bot_username, - token=settings.gitea_token, - askpass_path=settings.askpass_path, - commit_name=settings.bot_name, - commit_email=settings.bot_email, - ) - opencode = OpenCodeClient( - base_url=settings.opencode_url, - username=settings.opencode_server_username, - password=settings.opencode_server_password, - schemas_dir=package_dir / "prompts" / "schemas", - health_directory=settings.workspaces_dir, - required_models=( - (settings.plan_model, settings.plan_variant), - (settings.implement_model, settings.implement_variant), - (settings.explore_model, settings.explore_variant), - (settings.research_model, settings.research_variant), - ), - timeout_seconds=settings.turn_timeout_seconds, - ) - prompts = PromptLibrary() - context = ContextBuilder(gitea, storage) - development = DevelopmentEnvironment( - scripts=settings.install_scripts, - scripts_dir=settings.install_scripts_dir, - tools_dir=settings.dev_tools_dir, - timeout_seconds=settings.install_script_timeout_seconds, - python_version=settings.python_version, - dotnet_channel=settings.dotnet_channel, - ) - dependencies = Dependencies( - settings=settings, - storage=storage, - gitea=gitea, - git=git, - opencode=opencode, - prompts=prompts, - context=context, - development=development, - ) - dispatcher = Dispatcher(dependencies) - worker = Worker( - storage=storage, - state_machine=state_machine, - gitea=gitea, - opencode=opencode, - dispatcher=dispatcher, - poll_seconds=settings.worker_poll_seconds, - max_concurrent_jobs=settings.max_concurrent_jobs, - workspaces_dir=settings.workspaces_dir, - bot_username=settings.bot_username, - ) - container = Container(settings, storage, gitea, git, opencode, state_machine, worker) - log.info("container initialization completed", extra={"operation": "container.build"}) - return container diff --git a/src/agentci/domain/__init__.py b/src/agentci/domain/__init__.py deleted file mode 100644 index 3ed7f96..0000000 --- a/src/agentci/domain/__init__.py +++ /dev/null @@ -1,2 +0,0 @@ -"""Domain types and policies.""" - diff --git a/src/agentci/domain/state_machine.py b/src/agentci/domain/state_machine.py deleted file mode 100644 index 9c6fd5d..0000000 --- a/src/agentci/domain/state_machine.py +++ /dev/null @@ -1,189 +0,0 @@ -from __future__ import annotations - -from dataclasses import dataclass, replace - -from agentci.domain.commands import CommandError, parse_command, resolve_job_kind -from agentci.domain.events import ( - CommandReceived, - CommentLinked, - JobCompleted, - JobEvent, - JobFailed, - JobProgress, - JobRejected, - JobStarted, - PermissionDenied, - PermissionGranted, - RuntimeSessionLinked, - ServiceRestarted, - WorkflowCreated, - WorkflowLinked, -) -from agentci.domain.models import JobKind, JobStatus - - -class InvalidTransition(ValueError): - pass - - -@dataclass(frozen=True) -class Notification: - listener: str - queue: str = "control" - - -@dataclass(frozen=True) -class JobState: - id: str - target_key: str - repo_owner: str - repo_name: str - issue_number: int - pr_number: int | None - requester: str - comment_id: int - delivery_id: str - receive_sequence: int - command_body: str - kind: JobKind | None = None - message: str | None = None - status: JobStatus = JobStatus.RECEIVED - stage: str = "received" - error: str | None = None - workflow_id: str | None = None - runtime_session_id: str | None = None - accepted_comment_id: int | None = None - comment_body: str | None = None - - @property - def is_pull_request(self) -> bool: - return self.pr_number is not None - - -@dataclass(frozen=True) -class Transition: - state: JobState - notifications: tuple[Notification, ...] = () - - -RECONCILE = Notification("reconcile_comment") - - -def next_state(state: JobState | None, event: JobEvent) -> Transition: - if state is None: - if not isinstance(event, CommandReceived): - raise InvalidTransition("Only CommandReceived can create a job") - created = JobState( - id=event.job_id, - target_key=event.target_key, - repo_owner=event.repo_owner, - repo_name=event.repo_name, - issue_number=event.issue_number, - pr_number=event.pr_number, - requester=event.requester, - comment_id=event.comment_id, - delivery_id=event.delivery_id, - receive_sequence=event.receive_sequence, - command_body=event.command_body, - ) - return Transition(created, (Notification("authorize"),)) - if event.job_id != state.id: - raise InvalidTransition("Event job ID does not match state") - if isinstance(event, CommentLinked): - return Transition(replace(state, accepted_comment_id=event.comment_id)) - if isinstance(event, ServiceRestarted): - if state.status is not JobStatus.RUNNING: - return Transition(state) - failed = replace( - state, - status=JobStatus.FAILED, - stage="interrupted", - error="Service restarted during an active OpenCode turn", - ) - listeners = [Notification("abort_sessions"), RECONCILE] - if state.workflow_id: - listeners.insert(1, Notification("fail_workflow")) - return Transition(failed, tuple(listeners)) - if state.status is JobStatus.RECEIVED: - return _received(state, event) - if state.status is JobStatus.QUEUED and isinstance(event, JobStarted): - return Transition( - replace(state, status=JobStatus.RUNNING, stage="starting"), (RECONCILE,) - ) - if state.status is JobStatus.RUNNING: - return _running(state, event) - raise InvalidTransition(f"{event.type} is invalid while job is {state.status}") - - -def _received(state: JobState, event: JobEvent) -> Transition: - if isinstance(event, PermissionDenied): - reason = "Agent command rejected: repository write permission is required." - return Transition( - replace(state, status=JobStatus.REJECTED, stage="rejected", error=reason), - (RECONCILE,), - ) - if not isinstance(event, PermissionGranted): - raise InvalidTransition(f"{event.type} is invalid while job is received") - try: - command = parse_command(state.command_body) - if command is None: - raise CommandError("Invalid agent command.") - kind = resolve_job_kind(command, is_pull_request=state.is_pull_request) - except CommandError as exc: - return Transition( - replace(state, status=JobStatus.REJECTED, stage="rejected", error=str(exc)), - (RECONCILE,), - ) - queued = replace( - state, - kind=kind, - message=command.message, - status=JobStatus.QUEUED, - stage="queued", - ) - return Transition(queued, (Notification("execute", "jobs"), RECONCILE)) - - -def _running(state: JobState, event: JobEvent) -> Transition: - if isinstance(event, JobProgress): - return Transition(replace(state, stage=event.stage)) - if isinstance(event, WorkflowCreated): - return Transition(replace(state, workflow_id=event.workflow.id, stage=event.stage)) - if isinstance(event, WorkflowLinked): - return Transition(replace(state, workflow_id=event.workflow_id, stage=event.stage)) - if isinstance(event, RuntimeSessionLinked): - return Transition(replace(state, runtime_session_id=event.session_id)) - if isinstance(event, JobCompleted): - return Transition( - replace( - state, - status=JobStatus.SUCCEEDED, - stage="completed", - comment_body=event.comment_body, - ), - (RECONCILE,), - ) - if isinstance(event, (JobRejected, JobFailed)): - rejected = isinstance(event, JobRejected) - error = event.reason if rejected else event.error - stage = "rejected" if rejected else event.stage - status = JobStatus.REJECTED if rejected else JobStatus.FAILED - listeners = [RECONCILE] - if state.workflow_id: - listeners.append(Notification("fail_workflow")) - return Transition(replace(state, status=status, stage=stage, error=error), tuple(listeners)) - raise InvalidTransition(f"{event.type} is invalid while job is running") - - -def render_job_comment(state: JobState) -> str: - marker = f"" - if state.status is JobStatus.SUCCEEDED and state.comment_body: - body = state.comment_body - elif state.status is JobStatus.REJECTED: - body = f"Agent job `{state.id}` was rejected: {state.error}" - elif state.status is JobStatus.FAILED: - body = f"Agent job `{state.id}` failed during `{state.stage}`: {state.error}" - else: - kind = state.kind.value if state.kind else "command" - body = f"Agent job `{state.id}` {state.status.value} (`{kind}`; stage: `{state.stage}`)." - return f"{marker}\n{body}" diff --git a/src/agentci/engine/__init__.py b/src/agentci/engine/__init__.py new file mode 100644 index 0000000..d83cb44 --- /dev/null +++ b/src/agentci/engine/__init__.py @@ -0,0 +1 @@ +"""Durable job and workflow engine.""" diff --git a/src/agentci/engine/_sqlite.py b/src/agentci/engine/_sqlite.py new file mode 100644 index 0000000..a1c78c9 --- /dev/null +++ b/src/agentci/engine/_sqlite.py @@ -0,0 +1,271 @@ +from __future__ import annotations + +import sqlite3 +from datetime import UTC, datetime +from pathlib import Path + +from agentci.engine.events import JobEvent +from agentci.engine.model import ( + Job, + JobKind, + JobStatus, + QueueName, + Task, + TaskKind, + Workflow, + WorkflowKind, + WorkflowStatus, +) +from agentci.engine.reducer import Transition + + +def now() -> str: + return datetime.now(UTC).isoformat() + + +def connect(path: Path) -> sqlite3.Connection: + connection = sqlite3.connect(path, timeout=30) + try: + connection.row_factory = sqlite3.Row + connection.execute("PRAGMA journal_mode=WAL") + connection.execute("PRAGMA foreign_keys=ON") + except Exception: + connection.close() + raise + return connection + + +def initialize(connection: sqlite3.Connection, migrations_dir: Path) -> None: + connection.execute( + "CREATE TABLE IF NOT EXISTS schema_migrations " + "(version INTEGER PRIMARY KEY, applied_at TEXT NOT NULL)" + ) + applied = {row[0] for row in connection.execute("SELECT version FROM schema_migrations")} + for path in sorted(migrations_dir.glob("*.sql")): + version = int(path.name.split("_", 1)[0]) + if version in applied: + continue + connection.executescript(path.read_text()) + connection.execute( + "INSERT OR IGNORE INTO schema_migrations VALUES (?, ?)", + (version, now()), + ) + + +def insert_event( + connection: sqlite3.Connection, + event_id: str, + event: JobEvent, + timestamp: str, +) -> None: + connection.execute( + "INSERT INTO job_events VALUES (?, ?, ?, ?, ?)", + (event_id, event.job_id, event.type, event.model_dump_json(), timestamp), + ) + + +def insert_tasks( + connection: sqlite3.Connection, + event_id: str, + transition: Transition, + timestamp: str, +) -> None: + for ordinal, task in enumerate(transition.tasks): + connection.execute( + "INSERT INTO listener_tasks(job_id, source_event_id, ordinal, listener, queue, " + "available_at, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)", + ( + transition.job.id, + event_id, + ordinal, + task.kind.value, + task.queue.value, + timestamp, + timestamp, + ), + ) + + +def insert_job(connection: sqlite3.Connection, job: Job, timestamp: str) -> None: + connection.execute( + """INSERT INTO jobs(id, kind, target_key, repo_owner, repo_name, issue_number, + pr_number, requester, message, comment_id, delivery_id, receive_sequence, command_body, + workflow_id, status, stage, error, runtime_session_id, accepted_comment_id, + comment_body, created_at) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""", + (*_job_values(job), timestamp), + ) + + +def replace_job( + connection: sqlite3.Connection, + job: Job, + previous: Job, + timestamp: str, +) -> None: + started = ( + timestamp + if previous.status is JobStatus.QUEUED and job.status is JobStatus.RUNNING + else None + ) + terminal = {JobStatus.SUCCEEDED, JobStatus.REJECTED, JobStatus.FAILED} + finished = timestamp if previous.status not in terminal and job.status in terminal else None + connection.execute( + """UPDATE jobs SET kind=?, target_key=?, repo_owner=?, repo_name=?, issue_number=?, + pr_number=?, requester=?, message=?, comment_id=?, delivery_id=?, receive_sequence=?, + command_body=?, workflow_id=?, status=?, stage=?, error=?, runtime_session_id=?, + accepted_comment_id=?, comment_body=?, started_at=COALESCE(started_at, ?), + finished_at=COALESCE(finished_at, ?) WHERE id=?""", + (*_job_values(job)[1:], started, finished, job.id), + ) + + +def optional_job(connection: sqlite3.Connection, job_id: str) -> Job | None: + row = connection.execute("SELECT * FROM jobs WHERE id=?", (job_id,)).fetchone() + return job_from_row(row) if row else None + + +def required_job(connection: sqlite3.Connection, job_id: str) -> Job: + job = optional_job(connection, job_id) + if job is None: + raise KeyError(f"Unknown job {job_id}") + return job + + +def job_from_row(row: sqlite3.Row) -> Job: + return Job( + id=row["id"], + kind=JobKind(row["kind"]) if row["kind"] else None, + target_key=row["target_key"], + repo_owner=row["repo_owner"], + repo_name=row["repo_name"], + issue_number=row["issue_number"], + pr_number=row["pr_number"], + requester=row["requester"], + message=row["message"], + comment_id=row["comment_id"], + delivery_id=row["delivery_id"], + receive_sequence=row["receive_sequence"], + command_body=row["command_body"], + workflow_id=row["workflow_id"], + status=JobStatus(row["status"]), + stage=row["stage"], + error=row["error"], + runtime_session_id=row["runtime_session_id"], + accepted_comment_id=row["accepted_comment_id"], + comment_body=row["comment_body"], + ) + + +def insert_workflow( + connection: sqlite3.Connection, + workflow: Workflow, + timestamp: str, +) -> None: + connection.execute( + """INSERT INTO workflows(id, kind, repo_owner, repo_name, issue_number, pr_number, + base_sha, branch, workspace_path, primary_session_id, reviewer_session_id, artifact, + review_json, status, runtime, created_at, updated_at) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""", + ( + workflow.id, + workflow.kind.value, + workflow.repo_owner, + workflow.repo_name, + workflow.issue_number, + workflow.pr_number, + workflow.base_sha, + workflow.branch, + str(workflow.workspace_path), + workflow.primary_session_id, + workflow.reviewer_session_id, + workflow.artifact, + workflow.review_json, + workflow.status.value, + workflow.runtime, + timestamp, + timestamp, + ), + ) + + +def workflow_from_row(row: sqlite3.Row | None) -> Workflow | None: + if row is None: + return None + return Workflow( + id=row["id"], + kind=WorkflowKind(row["kind"]), + repo_owner=row["repo_owner"], + repo_name=row["repo_name"], + issue_number=row["issue_number"], + pr_number=row["pr_number"], + base_sha=row["base_sha"], + runtime=row["runtime"], + branch=row["branch"], + workspace_path=Path(row["workspace_path"]), + primary_session_id=row["primary_session_id"], + reviewer_session_id=row["reviewer_session_id"], + artifact=row["artifact"], + review_json=row["review_json"], + status=WorkflowStatus(row["status"]), + ) + + +def claim_task(connection: sqlite3.Connection, queue: str) -> Task | None: + connection.execute("BEGIN IMMEDIATE") + fifo = "" + if queue == QueueName.JOBS.value: + fifo = """AND NOT EXISTS ( + SELECT 1 FROM jobs earlier WHERE earlier.receive_sequence < j.receive_sequence + AND earlier.target_key = j.target_key + AND earlier.status IN ('received', 'queued', 'running'))""" + row = connection.execute( + f"""SELECT t.* FROM listener_tasks t JOIN jobs j ON j.id=t.job_id + WHERE t.queue=? AND t.status='pending' AND t.available_at<=? {fifo} + ORDER BY {"j.receive_sequence" if queue == QueueName.JOBS.value else "t.id"} LIMIT 1""", + (queue, now()), + ).fetchone() + if row is None: + connection.commit() + return None + changed = connection.execute( + "UPDATE listener_tasks SET status='running', started_at=?, attempts=attempts+1 " + "WHERE id=? AND status='pending'", + (now(), row["id"]), + ) + if changed.rowcount != 1: + connection.rollback() + return None + connection.commit() + return Task( + id=row["id"], + job_id=row["job_id"], + source_event_id=row["source_event_id"], + kind=TaskKind(row["listener"]), + queue=QueueName(row["queue"]), + attempts=row["attempts"] + 1, + ) + + +def _job_values(job: Job) -> tuple[object, ...]: + return ( + job.id, + job.kind.value if job.kind else None, + job.target_key, + job.repo_owner, + job.repo_name, + job.issue_number, + job.pr_number, + job.requester, + job.message, + job.comment_id, + job.delivery_id, + job.receive_sequence, + job.command_body, + job.workflow_id, + job.status.value, + job.stage, + job.error, + job.runtime_session_id, + job.accepted_comment_id, + job.comment_body, + ) diff --git a/src/agentci/domain/commands.py b/src/agentci/engine/commands.py similarity index 95% rename from src/agentci/domain/commands.py rename to src/agentci/engine/commands.py index 877d1eb..154bee2 100644 --- a/src/agentci/domain/commands.py +++ b/src/agentci/engine/commands.py @@ -2,7 +2,7 @@ from __future__ import annotations import re -from agentci.domain.models import CommandName, JobKind, ParsedCommand +from agentci.engine.model import CommandName, JobKind, ParsedCommand COMMAND_RE = re.compile(r"^/agent[ \t]+([a-z]+)(?:[ \t\r\n]+([\s\S]*))?$") diff --git a/src/agentci/domain/events.py b/src/agentci/engine/events.py similarity index 97% rename from src/agentci/domain/events.py rename to src/agentci/engine/events.py index 9bd22c1..e880be6 100644 --- a/src/agentci/domain/events.py +++ b/src/agentci/engine/events.py @@ -4,7 +4,7 @@ from typing import Annotated, Literal from pydantic import BaseModel, ConfigDict, Field -from agentci.domain.models import Workflow +from agentci.engine.model import Workflow class Event(BaseModel): diff --git a/src/agentci/domain/models.py b/src/agentci/engine/model.py similarity index 67% rename from src/agentci/domain/models.py rename to src/agentci/engine/model.py index 809e387..01e9f51 100644 --- a/src/agentci/domain/models.py +++ b/src/agentci/engine/model.py @@ -4,8 +4,6 @@ from dataclasses import dataclass from enum import StrEnum from pathlib import Path -from pydantic import BaseModel, Field - class CommandName(StrEnum): PLAN = "plan" @@ -44,43 +42,17 @@ class WorkflowStatus(StrEnum): FAILED = "failed" -class ReviewSeverity(StrEnum): - BLOCKING = "blocking" - MAJOR = "major" - MINOR = "minor" +class TaskKind(StrEnum): + AUTHORIZE = "authorize" + EXECUTE = "execute" + RECONCILE_COMMENT = "reconcile_comment" + FAIL_WORKFLOW = "fail_workflow" + ABORT_SESSIONS = "abort_sessions" -class PlanArtifact(BaseModel): - plan_markdown: str = Field(min_length=1) - - -class DiscussionReply(BaseModel): - markdown: str = Field(min_length=1) - - -class AgentResult(BaseModel): - summary_markdown: str = Field(min_length=1) - tests: list[str] = Field(default_factory=list) - - -class ReviewFinding(BaseModel): - severity: ReviewSeverity - title: str - detail: str - location: str | None = None - recommendation: str - - -class ReviewReport(BaseModel): - summary: str - findings: list[ReviewFinding] = Field(default_factory=list) - - @property - def has_serious_findings(self) -> bool: - return any( - finding.severity in {ReviewSeverity.BLOCKING, ReviewSeverity.MAJOR} - for finding in self.findings - ) +class QueueName(StrEnum): + CONTROL = "control" + JOBS = "jobs" @dataclass(frozen=True) @@ -90,7 +62,7 @@ class ParsedCommand: @dataclass(frozen=True) -class CommandEvent: +class IncomingCommand: delivery_id: str comment_id: int repo_owner: str @@ -110,26 +82,35 @@ class CommandEvent: return f"{self.repo_owner}/{self.repo_name}:{target}" -@dataclass +@dataclass(frozen=True) class Job: id: str - kind: JobKind target_key: str repo_owner: str repo_name: str issue_number: int pr_number: int | None requester: str - message: str comment_id: int + delivery_id: str + receive_sequence: int + command_body: str + kind: JobKind | None = None + message: str | None = None + status: JobStatus = JobStatus.RECEIVED + stage: str = "received" + error: str | None = None workflow_id: str | None = None - status: JobStatus = JobStatus.QUEUED - stage: str = "queued" - accepted_comment_id: int | None = None runtime_session_id: str | None = None + accepted_comment_id: int | None = None + comment_body: str | None = None + + @property + def is_pull_request(self) -> bool: + return self.pr_number is not None -@dataclass +@dataclass(frozen=True) class Workflow: id: str kind: WorkflowKind @@ -146,3 +127,19 @@ class Workflow: artifact: str | None = None review_json: str | None = None status: WorkflowStatus = WorkflowStatus.ACTIVE + + +@dataclass(frozen=True) +class TaskRequest: + kind: TaskKind + queue: QueueName + + +@dataclass(frozen=True) +class Task: + id: int + job_id: str + source_event_id: str + kind: TaskKind + queue: QueueName + attempts: int diff --git a/src/agentci/engine/reducer.py b/src/agentci/engine/reducer.py new file mode 100644 index 0000000..743feb3 --- /dev/null +++ b/src/agentci/engine/reducer.py @@ -0,0 +1,164 @@ +from __future__ import annotations + +from dataclasses import dataclass, replace + +from agentci.engine.commands import CommandError, parse_command, resolve_job_kind +from agentci.engine.events import ( + CommandReceived, + CommentLinked, + JobCompleted, + JobEvent, + JobFailed, + JobProgress, + JobRejected, + JobStarted, + PermissionDenied, + PermissionGranted, + RuntimeSessionLinked, + ServiceRestarted, + WorkflowCreated, + WorkflowLinked, +) +from agentci.engine.model import Job, JobStatus, QueueName, TaskKind, TaskRequest + + +class InvalidTransition(ValueError): + pass + + +@dataclass(frozen=True) +class Transition: + job: Job + tasks: tuple[TaskRequest, ...] = () + + +RECONCILE = TaskRequest(TaskKind.RECONCILE_COMMENT, QueueName.CONTROL) + + +def reduce_job(current: Job | None, event: JobEvent) -> Transition: + if current is None: + if not isinstance(event, CommandReceived): + raise InvalidTransition("Only CommandReceived can create a job") + job = Job( + id=event.job_id, + target_key=event.target_key, + repo_owner=event.repo_owner, + repo_name=event.repo_name, + issue_number=event.issue_number, + pr_number=event.pr_number, + requester=event.requester, + comment_id=event.comment_id, + delivery_id=event.delivery_id, + receive_sequence=event.receive_sequence, + command_body=event.command_body, + ) + task = TaskRequest(TaskKind.AUTHORIZE, QueueName.CONTROL) + return Transition(job, (task,)) + if event.job_id != current.id: + raise InvalidTransition("Event job ID does not match state") + if isinstance(event, CommentLinked): + return Transition(replace(current, accepted_comment_id=event.comment_id)) + if isinstance(event, ServiceRestarted): + if current.status is not JobStatus.RUNNING: + return Transition(current) + failed = replace( + current, + status=JobStatus.FAILED, + stage="interrupted", + error="Service restarted during an active OpenCode turn", + ) + tasks = [TaskRequest(TaskKind.ABORT_SESSIONS, QueueName.CONTROL), RECONCILE] + if current.workflow_id: + tasks.insert(1, TaskRequest(TaskKind.FAIL_WORKFLOW, QueueName.CONTROL)) + return Transition(failed, tuple(tasks)) + if current.status is JobStatus.RECEIVED: + return _received(current, event) + if current.status is JobStatus.QUEUED and isinstance(event, JobStarted): + return Transition( + replace(current, status=JobStatus.RUNNING, stage="starting"), + (RECONCILE,), + ) + if current.status is JobStatus.RUNNING: + return _running(current, event) + raise InvalidTransition(f"{event.type} is invalid while job is {current.status}") + + +def _received(current: Job, event: JobEvent) -> Transition: + if isinstance(event, PermissionDenied): + reason = "Agent command rejected: repository write permission is required." + return Transition( + replace(current, status=JobStatus.REJECTED, stage="rejected", error=reason), + (RECONCILE,), + ) + if not isinstance(event, PermissionGranted): + raise InvalidTransition(f"{event.type} is invalid while job is received") + try: + command = parse_command(current.command_body) + if command is None: + raise CommandError("Invalid agent command.") + kind = resolve_job_kind(command, is_pull_request=current.is_pull_request) + except CommandError as exc: + return Transition( + replace(current, status=JobStatus.REJECTED, stage="rejected", error=str(exc)), + (RECONCILE,), + ) + queued = replace( + current, + kind=kind, + message=command.message, + status=JobStatus.QUEUED, + stage="queued", + ) + tasks = ( + TaskRequest(TaskKind.EXECUTE, QueueName.JOBS), + RECONCILE, + ) + return Transition(queued, tasks) + + +def _running(current: Job, event: JobEvent) -> Transition: + if isinstance(event, JobProgress): + return Transition(replace(current, stage=event.stage)) + if isinstance(event, WorkflowCreated): + return Transition(replace(current, workflow_id=event.workflow.id, stage=event.stage)) + if isinstance(event, WorkflowLinked): + return Transition(replace(current, workflow_id=event.workflow_id, stage=event.stage)) + if isinstance(event, RuntimeSessionLinked): + return Transition(replace(current, runtime_session_id=event.session_id)) + if isinstance(event, JobCompleted): + return Transition( + replace( + current, + status=JobStatus.SUCCEEDED, + stage="completed", + comment_body=event.comment_body, + ), + (RECONCILE,), + ) + if isinstance(event, (JobRejected, JobFailed)): + rejected = isinstance(event, JobRejected) + error = event.reason if rejected else event.error + stage = "rejected" if rejected else event.stage + status = JobStatus.REJECTED if rejected else JobStatus.FAILED + tasks = [RECONCILE] + if current.workflow_id: + tasks.append(TaskRequest(TaskKind.FAIL_WORKFLOW, QueueName.CONTROL)) + return Transition( + replace(current, status=status, stage=stage, error=error), + tuple(tasks), + ) + raise InvalidTransition(f"{event.type} is invalid while job is running") + + +def render_job_comment(job: Job) -> str: + marker = f"" + if job.status is JobStatus.SUCCEEDED and job.comment_body: + body = job.comment_body + elif job.status is JobStatus.REJECTED: + body = f"Agent job `{job.id}` was rejected: {job.error}" + elif job.status is JobStatus.FAILED: + body = f"Agent job `{job.id}` failed during `{job.stage}`: {job.error}" + else: + kind = job.kind.value if job.kind else "command" + body = f"Agent job `{job.id}` {job.status.value} (`{kind}`; stage: `{job.stage}`)." + return f"{marker}\n{body}" diff --git a/src/agentci/engine/repository.py b/src/agentci/engine/repository.py new file mode 100644 index 0000000..5866655 --- /dev/null +++ b/src/agentci/engine/repository.py @@ -0,0 +1,288 @@ +from __future__ import annotations + +import logging +import sqlite3 +from collections.abc import Callable +from dataclasses import dataclass +from datetime import UTC, datetime, timedelta +from pathlib import Path +from typing import TypeVar +from uuid import UUID, uuid5 + +from agentci.engine import _sqlite +from agentci.engine.events import CommandReceived, JobEvent, WorkflowCreated +from agentci.engine.model import ( + IncomingCommand, + Job, + QueueName, + Task, + Workflow, + WorkflowKind, + WorkflowStatus, +) +from agentci.engine.reducer import reduce_job + +T = TypeVar("T") +log = logging.getLogger(__name__) +JOB_NAMESPACE = UUID("59565f0f-f17d-4b80-bfba-7ef1fbfd38eb") + + +@dataclass(frozen=True) +class ApplyResult: + job: Job + duplicate: bool + + +class Repository: + def __init__(self, database_path: Path, migrations_dir: Path | None = None) -> None: + self.database_path = database_path + self.migrations_dir = migrations_dir or Path(__file__).parent.parent / "migrations" + + async def initialize(self) -> None: + log.info("database initialization started", extra={"operation": "database.initialize"}) + self.database_path.parent.mkdir(parents=True, exist_ok=True) + try: + await self._run(lambda connection: _sqlite.initialize(connection, self.migrations_dir)) + except Exception: + log.exception( + "database initialization failed", + extra={"operation": "database.initialize"}, + ) + raise + log.info("database initialization completed", extra={"operation": "database.initialize"}) + + async def accept(self, command: IncomingCommand) -> ApplyResult: + job_id = str(uuid5(JOB_NAMESPACE, command.delivery_id)) + event_id = f"delivery:{command.delivery_id}" + + def operation(connection: sqlite3.Connection) -> ApplyResult: + connection.execute("BEGIN IMMEDIATE") + duplicate = connection.execute( + "SELECT job_id FROM job_events WHERE event_id=?", (event_id,) + ).fetchone() + if duplicate: + job = _sqlite.required_job(connection, duplicate["job_id"]) + connection.commit() + return ApplyResult(job, True) + sequence = connection.execute( + "SELECT COALESCE(MAX(receive_sequence), 0) + 1 FROM jobs" + ).fetchone()[0] + event = CommandReceived( + job_id=job_id, + delivery_id=command.delivery_id, + receive_sequence=sequence, + command_body=command.body, + target_key=command.target_key, + repo_owner=command.repo_owner, + repo_name=command.repo_name, + issue_number=command.issue_number, + pr_number=command.pr_number, + requester=command.requester, + comment_id=command.comment_id, + ) + transition = reduce_job(None, event) + timestamp = _sqlite.now() + _sqlite.insert_event(connection, event_id, event, timestamp) + _sqlite.insert_job(connection, transition.job, timestamp) + _sqlite.insert_tasks(connection, event_id, transition, timestamp) + connection.commit() + return ApplyResult(transition.job, False) + + return await self._run(operation) + + async def apply(self, event_id: str, event: JobEvent) -> ApplyResult: + def operation(connection: sqlite3.Connection) -> ApplyResult: + connection.execute("BEGIN IMMEDIATE") + duplicate = connection.execute( + "SELECT job_id FROM job_events WHERE event_id=?", (event_id,) + ).fetchone() + if duplicate: + existing_job_id = duplicate["job_id"] + if existing_job_id != event.job_id: + raise RuntimeError( + f"Event ID {event_id!r} belongs to job {existing_job_id!r}, " + f"not {event.job_id!r}" + ) + job = _sqlite.required_job(connection, existing_job_id) + connection.commit() + return ApplyResult(job, True) + current = _sqlite.required_job(connection, event.job_id) + transition = reduce_job(current, event) + timestamp = _sqlite.now() + _sqlite.insert_event(connection, event_id, event, timestamp) + if isinstance(event, WorkflowCreated): + _sqlite.insert_workflow(connection, event.workflow, timestamp) + _sqlite.replace_job(connection, transition.job, current, timestamp) + _sqlite.insert_tasks(connection, event_id, transition, timestamp) + connection.commit() + return ApplyResult(transition.job, False) + + return await self._run(operation) + + async def get_job(self, job_id: str) -> Job | None: + return await self._run(lambda connection: _sqlite.optional_job(connection, job_id)) + + async def claim_task(self, queue: QueueName | str) -> Task | None: + queue_name = queue.value if isinstance(queue, QueueName) else queue + return await self._run(lambda connection: _sqlite.claim_task(connection, queue_name)) + + async def complete_task(self, task_id: int) -> None: + await self._run( + lambda connection: connection.execute( + "UPDATE listener_tasks SET status='completed', finished_at=? WHERE id=?", + (_sqlite.now(), task_id), + ) + ) + + async def retry_task(self, task_id: int, attempts: int, error: str) -> None: + delay = min(2 ** min(attempts, 8), 300) + available = (datetime.now(UTC) + timedelta(seconds=delay)).isoformat() + await self._run( + lambda connection: connection.execute( + "UPDATE listener_tasks SET status='pending', available_at=?, error=? WHERE id=?", + (available, error[:1000], task_id), + ) + ) + + async def recover_tasks(self) -> None: + def operation(connection: sqlite3.Connection) -> None: + with connection: + connection.execute( + """UPDATE listener_tasks SET status='pending', started_at=NULL + WHERE status='running' AND queue='control'""" + ) + connection.execute( + """UPDATE listener_tasks SET status='pending', started_at=NULL + WHERE status='running' AND listener='execute' AND job_id IN + (SELECT id FROM jobs WHERE status='queued')""" + ) + connection.execute( + """UPDATE listener_tasks SET status='failed', finished_at=?, + error='Service restarted after execution began' + WHERE status='running' AND listener='execute' AND job_id IN + (SELECT id FROM jobs WHERE status<>'queued')""", + (_sqlite.now(),), + ) + + await self._run(operation) + + async def running_jobs(self) -> list[Job]: + return await self._run( + lambda connection: [ + _sqlite.job_from_row(row) + for row in connection.execute("SELECT * FROM jobs WHERE status='running'") + ] + ) + + async def operational_comment_ids(self, owner: str, repo: str, issue: int) -> set[int]: + return await self._run( + lambda connection: { + value + for row in connection.execute( + "SELECT accepted_comment_id, started_comment_id FROM jobs " + "WHERE repo_owner=? AND repo_name=? AND issue_number=?", + (owner, repo, issue), + ) + for value in row + if value is not None + } + ) + + async def get_workflow(self, workflow_id: str) -> Workflow | None: + return await self._run( + lambda connection: _sqlite.workflow_from_row( + connection.execute("SELECT * FROM workflows WHERE id=?", (workflow_id,)).fetchone() + ) + ) + + async def save_workflow(self, workflow: Workflow) -> None: + def operation(connection: sqlite3.Connection) -> None: + updated = connection.execute( + """UPDATE workflows SET pr_number=?, branch=?, primary_session_id=?, + reviewer_session_id=?, artifact=?, review_json=?, status=?, updated_at=? + WHERE id=?""", + ( + workflow.pr_number, + workflow.branch, + workflow.primary_session_id, + workflow.reviewer_session_id, + workflow.artifact, + workflow.review_json, + workflow.status.value, + _sqlite.now(), + workflow.id, + ), + ) + if updated.rowcount != 1: + raise KeyError(f"Unknown workflow {workflow.id}") + + await self._run(operation) + + async def latest_workflow( + self, + owner: str, + repo: str, + issue: int, + kind: WorkflowKind, + ) -> Workflow | None: + return await self._run( + lambda connection: _sqlite.workflow_from_row( + connection.execute( + """SELECT * FROM workflows + WHERE repo_owner=? AND repo_name=? AND issue_number=? + AND kind=? AND status=? + ORDER BY created_at DESC LIMIT 1""", + (owner, repo, issue, kind.value, WorkflowStatus.COMPLETED.value), + ).fetchone() + ) + ) + + async def workflow_for_pr(self, owner: str, repo: str, pr: int) -> Workflow | None: + return await self._run( + lambda connection: _sqlite.workflow_from_row( + connection.execute( + """SELECT * FROM workflows + WHERE repo_owner=? AND repo_name=? AND pr_number=? AND kind=? + ORDER BY created_at DESC LIMIT 1""", + (owner, repo, pr, WorkflowKind.IMPLEMENT.value), + ).fetchone() + ) + ) + + async def implementation_workflows(self, owner: str, repo: str, issue: int) -> list[Workflow]: + return await self._run( + lambda connection: [ + workflow + for row in connection.execute( + """SELECT * FROM workflows + WHERE repo_owner=? AND repo_name=? AND issue_number=? AND kind=? + AND pr_number IS NOT NULL + ORDER BY created_at DESC""", + (owner, repo, issue, WorkflowKind.IMPLEMENT.value), + ) + if (workflow := _sqlite.workflow_from_row(row)) is not None + ] + ) + + async def fail_job_workflow(self, job_id: str) -> None: + await self._run( + lambda connection: connection.execute( + """UPDATE workflows SET status=?, updated_at=? + WHERE id=(SELECT workflow_id FROM jobs WHERE id=?) + AND status=?""", + ( + WorkflowStatus.FAILED.value, + _sqlite.now(), + job_id, + WorkflowStatus.ACTIVE.value, + ), + ) + ) + + async def _run(self, operation: Callable[[sqlite3.Connection], T]) -> T: + connection = _sqlite.connect(self.database_path) + try: + with connection: + return operation(connection) + finally: + connection.close() diff --git a/src/agentci/engine/run.py b/src/agentci/engine/run.py new file mode 100644 index 0000000..9799fec --- /dev/null +++ b/src/agentci/engine/run.py @@ -0,0 +1,39 @@ +from __future__ import annotations + +from agentci.engine.events import ( + JobProgress, + RuntimeSessionLinked, + WorkflowCreated, + WorkflowLinked, +) +from agentci.engine.model import Workflow +from agentci.engine.repository import Repository + +type ReportEvent = JobProgress | RuntimeSessionLinked | WorkflowCreated | WorkflowLinked + + +class JobRun: + def __init__(self, repository: Repository, job_id: str, task_id: int) -> None: + self.repository = repository + self.job_id = job_id + self.task_id = task_id + self._sequence = 0 + + async def stage(self, stage: str) -> None: + await self._emit(JobProgress(job_id=self.job_id, stage=stage)) + + async def create_workflow(self, workflow: Workflow, stage: str) -> None: + await self._emit(WorkflowCreated(job_id=self.job_id, workflow=workflow, stage=stage)) + + async def link_workflow(self, workflow_id: str, stage: str) -> None: + await self._emit( + WorkflowLinked(job_id=self.job_id, workflow_id=workflow_id, stage=stage) + ) + + async def link_session(self, session_id: str) -> None: + await self._emit(RuntimeSessionLinked(job_id=self.job_id, session_id=session_id)) + + async def _emit(self, event: ReportEvent) -> None: + self._sequence += 1 + event_id = f"task:{self.task_id}:report:{self._sequence}" + await self.repository.apply(event_id, event) diff --git a/src/agentci/integrations/__init__.py b/src/agentci/integrations/__init__.py new file mode 100644 index 0000000..8761bc1 --- /dev/null +++ b/src/agentci/integrations/__init__.py @@ -0,0 +1 @@ +"""External service and process integrations.""" diff --git a/src/agentci/adapters/codegraph.py b/src/agentci/integrations/codegraph.py similarity index 99% rename from src/agentci/adapters/codegraph.py rename to src/agentci/integrations/codegraph.py index bcdcf23..4a3471f 100644 --- a/src/agentci/adapters/codegraph.py +++ b/src/agentci/integrations/codegraph.py @@ -13,7 +13,7 @@ class CodeGraphError(RuntimeError): pass -class CodeGraphClient: +class CodeGraph: async def prepare(self, workspace: Path) -> None: self._exclude_index(workspace) index = workspace / ".codegraph" / "codegraph.db" diff --git a/src/agentci/adapters/development.py b/src/agentci/integrations/development.py similarity index 100% rename from src/agentci/adapters/development.py rename to src/agentci/integrations/development.py diff --git a/src/agentci/adapters/git.py b/src/agentci/integrations/git.py similarity index 92% rename from src/agentci/adapters/git.py rename to src/agentci/integrations/git.py index 8bd442d..d2929f3 100644 --- a/src/agentci/adapters/git.py +++ b/src/agentci/integrations/git.py @@ -13,7 +13,7 @@ class GitError(RuntimeError): pass -class GitClient: +class Git: def __init__( self, *, @@ -31,9 +31,6 @@ class GitClient: self.commit_name = commit_name self.commit_email = commit_email - def clone_url(self, owner: str, repo: str) -> str: - return f"{self.gitea_url}/{owner}/{repo}.git" - async def clone( self, owner: str, @@ -47,7 +44,7 @@ class GitClient: "--branch", branch, "--single-branch", - self.clone_url(owner, repo), + f"{self.gitea_url}/{owner}/{repo}.git", str(destination), cwd=destination.parent, authenticated=True, @@ -85,6 +82,7 @@ class GitClient: "-m", message, cwd=workspace, + command_name="commit", ) return await self.current_sha(workspace) @@ -101,8 +99,10 @@ class GitClient: *args: str, cwd: Path, authenticated: bool = False, + command_name: str | None = None, ) -> str: - operation = f"git.{args[0]}" + command_name = command_name or args[0] + operation = f"git.{command_name}" started = monotonic() log.info("git step started", extra={"operation": operation}) environment = os.environ.copy() @@ -130,14 +130,14 @@ class GitClient: "git step could not start", extra={"operation": operation, "duration_ms": _elapsed_ms(started)}, ) - raise GitError(f"Could not run git {args[0]}: {exc}") from exc + raise GitError(f"Could not run git {command_name}: {exc}") from exc if process.returncode: detail = stderr.decode(errors="replace").strip() log.error( "git step failed", extra={"operation": operation, "duration_ms": _elapsed_ms(started)}, ) - raise GitError(f"git {args[0]} failed: {detail[-1000:]}") + raise GitError(f"git {command_name} failed: {detail[-1000:]}") log.info( "git step completed", extra={"operation": operation, "duration_ms": _elapsed_ms(started)}, diff --git a/src/agentci/integrations/gitea/__init__.py b/src/agentci/integrations/gitea/__init__.py new file mode 100644 index 0000000..4ce406f --- /dev/null +++ b/src/agentci/integrations/gitea/__init__.py @@ -0,0 +1 @@ +"""Gitea API and webhook integration.""" diff --git a/src/agentci/adapters/gitea.py b/src/agentci/integrations/gitea/client.py similarity index 93% rename from src/agentci/adapters/gitea.py rename to src/agentci/integrations/gitea/client.py index e2b3572..001922c 100644 --- a/src/agentci/adapters/gitea.py +++ b/src/agentci/integrations/gitea/client.py @@ -7,12 +7,7 @@ from typing import Any import httpx -from agentci.adapters.gitea_models import ( - CommentInfo, - IssueInfo, - PullRequestInfo, - RepositoryInfo, -) +from agentci.integrations.gitea.models import CommentInfo, IssueInfo, PullRequestInfo log = logging.getLogger(__name__) @@ -21,14 +16,22 @@ class GiteaError(RuntimeError): pass -class GiteaClient: - def __init__(self, base_url: str, token: str, *, retries: int = 3) -> None: +class Gitea: + def __init__( + self, + base_url: str, + token: str, + *, + retries: int = 3, + transport: httpx.AsyncBaseTransport | None = None, + ) -> None: self.base_url = base_url.rstrip("/") self.retries = retries self.client = httpx.AsyncClient( base_url=f"{self.base_url}/api/v1", headers={"Authorization": f"token {token}", "Accept": "application/json"}, timeout=30, + transport=transport, ) async def close(self) -> None: @@ -41,14 +44,9 @@ class GiteaClient: permission = str(response.json().get("permission", "")).lower() return permission in {"write", "admin", "owner"} - async def repository(self, owner: str, repo: str) -> RepositoryInfo: + async def default_branch(self, owner: str, repo: str) -> str: data = (await self._request("GET", f"/repos/{owner}/{repo}")).json() - return RepositoryInfo( - owner=owner, - name=repo, - full_name=data.get("full_name", f"{owner}/{repo}"), - default_branch=data["default_branch"], - ) + return str(data["default_branch"]) async def issue(self, owner: str, repo: str, number: int) -> IssueInfo: data = (await self._request("GET", f"/repos/{owner}/{repo}/issues/{number}")).json() diff --git a/src/agentci/adapters/gitea_models.py b/src/agentci/integrations/gitea/models.py similarity index 83% rename from src/agentci/adapters/gitea_models.py rename to src/agentci/integrations/gitea/models.py index 2cb8bf6..2b4b9b6 100644 --- a/src/agentci/adapters/gitea_models.py +++ b/src/agentci/integrations/gitea/models.py @@ -3,14 +3,6 @@ from __future__ import annotations from dataclasses import dataclass -@dataclass(frozen=True) -class RepositoryInfo: - owner: str - name: str - full_name: str - default_branch: str - - @dataclass(frozen=True) class IssueInfo: number: int @@ -43,4 +35,3 @@ class PullRequestInfo: @property def is_open(self) -> bool: return self.state == "open" and not self.merged - diff --git a/src/agentci/integrations/gitea/webhooks.py b/src/agentci/integrations/gitea/webhooks.py new file mode 100644 index 0000000..4fee9a4 --- /dev/null +++ b/src/agentci/integrations/gitea/webhooks.py @@ -0,0 +1,46 @@ +from __future__ import annotations + +import hashlib +import hmac +from typing import Any + +from agentci.engine.model import IncomingCommand + +SUPPORTED_EVENTS = { + "issue_comment", + "pull_request_comment", + "pull_request_review_comment", +} + + +def valid_signature(secret: bytes, body: bytes, signature: str) -> bool: + expected = hmac.new(secret, body, hashlib.sha256).hexdigest() + return bool(signature) and hmac.compare_digest(expected, signature) + + +def incoming_command_from_payload( + delivery_id: str, payload: dict[str, Any] +) -> IncomingCommand | None: + if payload.get("action") != "created": + return None + comment = payload["comment"] + repository = payload["repository"] + owner = repository["owner"] + owner_name = owner.get("login") or owner.get("username") or owner["name"] + pull = payload.get("pull_request") + is_pull = bool(payload.get("is_pull") or pull) + issue = payload.get("issue") + target = pull or issue + if target is None: + raise ValueError("Comment payload has no issue or pull request") + number = int(target["number"]) + return IncomingCommand( + delivery_id=delivery_id, + comment_id=int(comment["id"]), + repo_owner=owner_name, + repo_name=repository["name"], + issue_number=number, + pr_number=number if is_pull else None, + requester=comment["user"]["login"], + body=comment.get("body") or "", + ) diff --git a/src/agentci/integrations/opencode/__init__.py b/src/agentci/integrations/opencode/__init__.py new file mode 100644 index 0000000..7334d1d --- /dev/null +++ b/src/agentci/integrations/opencode/__init__.py @@ -0,0 +1 @@ +"""OpenCode API integration.""" diff --git a/src/agentci/adapters/opencode.py b/src/agentci/integrations/opencode/client.py similarity index 80% rename from src/agentci/adapters/opencode.py rename to src/agentci/integrations/opencode/client.py index 98081dd..4ff1a0b 100644 --- a/src/agentci/adapters/opencode.py +++ b/src/agentci/integrations/opencode/client.py @@ -2,6 +2,7 @@ from __future__ import annotations import asyncio import logging +from contextlib import suppress from pathlib import Path from time import monotonic from typing import Any, TypeVar @@ -9,16 +10,9 @@ from typing import Any, TypeVar import httpx from pydantic import BaseModel, ValidationError -from agentci.adapters.codegraph import CodeGraphClient -from agentci.adapters.opencode_support import ( - api_contract_ready, - directory_headers, - elapsed_ms, - error_message, - load_schema, - model_parts, - models_ready, -) +from agentci.integrations.codegraph import CodeGraph +from agentci.integrations.opencode.readiness import api_contract_ready, models_ready +from agentci.integrations.opencode.schemas import load_schema T = TypeVar("T", bound=BaseModel) log = logging.getLogger(__name__) @@ -28,7 +22,7 @@ class OpenCodeError(RuntimeError): pass -class OpenCodeClient: +class OpenCode: def __init__( self, *, @@ -39,7 +33,7 @@ class OpenCodeClient: health_directory: Path, required_models: tuple[tuple[str, str | None], ...], timeout_seconds: int, - codegraph: CodeGraphClient | None = None, + codegraph: CodeGraph | None = None, transport: httpx.AsyncBaseTransport | None = None, ) -> None: self.schemas_dir = schemas_dir @@ -48,8 +42,9 @@ class OpenCodeClient: (*model_parts(model), variant) for model, variant in required_models } self._contract_valid: bool | None = None + self._readiness_task: asyncio.Task[bool] | None = None self.timeout_seconds = timeout_seconds - self.codegraph = codegraph or CodeGraphClient() + self.codegraph = codegraph or CodeGraph() self._active_sessions: dict[str, Path] = {} self.client = httpx.AsyncClient( base_url=base_url.rstrip("/"), @@ -59,11 +54,27 @@ class OpenCodeClient: ) async def close(self) -> None: + if self._readiness_task is not None and not self._readiness_task.done(): + self._readiness_task.cancel() + with suppress(asyncio.CancelledError): + await self._readiness_task + self._readiness_task = None for session_id, workspace in tuple(self._active_sessions.items()): await self.abort(session_id, workspace, best_effort=True) await self.client.aclose() async def ready(self) -> bool: + task = self._readiness_task + if task is None: + task = asyncio.create_task(self._check_ready()) + self._readiness_task = task + try: + return await asyncio.shield(task) + finally: + if task.done() and self._readiness_task is task: + self._readiness_task = None + + async def _check_ready(self) -> bool: try: health = await self.client.get("/global/health", timeout=10) health.raise_for_status() @@ -85,28 +96,6 @@ class OpenCodeClient: except (httpx.HTTPError, TypeError, ValueError): return False - async def start( - self, - *, - workspace: Path, - prompt: str, - model: str, - variant: str | None, - schema_name: str, - result_type: type[T], - ) -> tuple[str, T]: - session_id = await self.create_session(workspace, schema_name) - result = await self.resume( - session_id=session_id, - workspace=workspace, - prompt=prompt, - model=model, - variant=variant, - schema_name=schema_name, - result_type=result_type, - ) - return session_id, result - async def create_session(self, workspace: Path, title: str) -> str: response = await self._request( "POST", @@ -143,7 +132,10 @@ class OpenCodeClient: result_type=result_type, ) except (asyncio.CancelledError, OpenCodeError): - await asyncio.shield(self.abort(session_id, workspace)) + try: + await asyncio.shield(self.abort(session_id, workspace)) + except OpenCodeError as exc: + log.warning("OpenCode failed session could not be aborted", exc_info=exc) raise finally: self._active_sessions.pop(session_id, None) @@ -186,7 +178,6 @@ class OpenCodeClient: json=payload, ) except httpx.TimeoutException as exc: - await self.abort(session_id, workspace) message = f"OpenCode turn exceeded {self.timeout_seconds} seconds" raise OpenCodeError(message) from exc info = response.get("info") @@ -213,7 +204,10 @@ class OpenCodeClient: raise OpenCodeError("OpenCode did not return a valid result") async def _request( - self, method: str, path: str, *, + self, + method: str, + path: str, + *, workspace: Path, json: dict[str, Any] | None = None, ) -> dict[str, Any]: @@ -248,3 +242,27 @@ class OpenCodeClient: log.warning("OpenCode session could not be aborted", exc_info=exc) return raise OpenCodeError(f"OpenCode session {session_id} could not be aborted") from exc + + +def model_parts(model: str) -> tuple[str, str]: + provider, separator, model_id = model.partition("/") + if not separator or not provider or not model_id: + raise ValueError(f"OpenCode model must use provider/model format: {model}") + return provider, model_id + + +def error_message(error: object) -> str | None: + if not error: + return None + if isinstance(error, dict): + return str(error.get("name") or error.get("message") or error) + return str(error) + + +def elapsed_ms(started: float) -> int: + return round((monotonic() - started) * 1000) + + +def directory_headers(workspace: Path) -> dict[str, str]: + return {"X-Opencode-Directory": str(workspace.resolve())} + diff --git a/src/agentci/integrations/opencode/readiness.py b/src/agentci/integrations/opencode/readiness.py new file mode 100644 index 0000000..4f63a59 --- /dev/null +++ b/src/agentci/integrations/opencode/readiness.py @@ -0,0 +1,58 @@ +from __future__ import annotations + +from typing import Any + + +def api_contract_ready(document: object) -> bool: + if not isinstance(document, dict) or not isinstance(document.get("paths"), dict): + return False + paths = document["paths"] + fixed = {"/global/health": "get", "/provider": "get", "/session": "post"} + for path, method in fixed.items(): + operations = paths.get(path) + if not isinstance(operations, dict) or method not in operations: + return False + session_paths = [ + path + for path, operations in paths.items() + if isinstance(path, str) and isinstance(operations, dict) and path.startswith("/session/{") + ] + has_message = any(path.endswith("/message") and "post" in paths[path] for path in session_paths) + has_abort = any(path.endswith("/abort") and "post" in paths[path] for path in session_paths) + return has_message and has_abort + + +def models_ready(payload: object, requirements: set[tuple[str, str, str | None]]) -> bool: + if not isinstance(payload, dict): + return False + connected_value = payload.get("connected") + provider_values = payload.get("all") + if not isinstance(connected_value, list) or not all( + isinstance(item, str) for item in connected_value + ): + return False + if not isinstance(provider_values, list): + return False + connected = set(connected_value) + providers: dict[str, dict[str, Any]] = {} + for item in provider_values: + if not isinstance(item, dict): + continue + provider_id = item.get("id") + if isinstance(provider_id, str) and isinstance(item.get("models"), dict): + providers[provider_id] = item + for provider_id, model_id, variant in requirements: + provider = providers.get(provider_id) + if provider_id not in connected or not isinstance(provider, dict): + return False + model: Any = provider["models"].get(model_id) + if not isinstance(model, dict) or model.get("status") == "deprecated": + return False + capabilities = model.get("capabilities") + if not isinstance(capabilities, dict) or capabilities.get("toolcall") is not True: + return False + if variant: + variants = model.get("variants") + if not isinstance(variants, dict) or variant not in variants: + return False + return True diff --git a/src/agentci/integrations/opencode/schemas.py b/src/agentci/integrations/opencode/schemas.py new file mode 100644 index 0000000..6995cb5 --- /dev/null +++ b/src/agentci/integrations/opencode/schemas.py @@ -0,0 +1,15 @@ +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any + + +def load_schema(schemas_dir: Path, name: str) -> dict[str, Any]: + try: + value = json.loads((schemas_dir / name).read_text()) + except (OSError, ValueError) as exc: + raise ValueError(f"Cannot load result schema {name}: {exc}") from exc + if not isinstance(value, dict): + raise ValueError(f"Result schema {name} is not a JSON object") + return value diff --git a/src/agentci/observability/__init__.py b/src/agentci/observability/__init__.py new file mode 100644 index 0000000..f5912d1 --- /dev/null +++ b/src/agentci/observability/__init__.py @@ -0,0 +1 @@ +"""Service observability configuration.""" diff --git a/src/agentci/logging.py b/src/agentci/observability/logging.py similarity index 78% rename from src/agentci/logging.py rename to src/agentci/observability/logging.py index c432bca..c1dd836 100644 --- a/src/agentci/logging.py +++ b/src/agentci/observability/logging.py @@ -15,6 +15,7 @@ LOG_FIELDS = ( "method", "path", "status_code", + "error_message", "attempt", "item_count", "duration_ms", @@ -37,9 +38,14 @@ class JsonFormatter(logging.Formatter): try: return json.dumps(payload, ensure_ascii=False) except (TypeError, ValueError): - payload["message"] = "Log record could not be serialized" - payload["exception"] = traceback.format_exc() - return json.dumps(payload, ensure_ascii=False) + fallback = { + "timestamp": payload["timestamp"], + "level": payload["level"], + "logger": payload["logger"], + "message": "Log record could not be serialized", + "exception": traceback.format_exc(), + } + return json.dumps(fallback, ensure_ascii=False) def configure_logging() -> None: diff --git a/src/agentci/prompts/__init__.py b/src/agentci/prompts/__init__.py index 83d790f..077a3fe 100644 --- a/src/agentci/prompts/__init__.py +++ b/src/agentci/prompts/__init__.py @@ -1,14 +1 @@ -from __future__ import annotations - -from pathlib import Path -from string import Template - - -class PromptLibrary: - def __init__(self, directory: Path | None = None) -> None: - self.directory = directory or Path(__file__).parent - - def render(self, name: str, **values: str) -> str: - template = Template((self.directory / f"{name}.md").read_text()) - return template.substitute(values) - +"""Prompt templates and structured-output contracts.""" diff --git a/src/agentci/prompts/library.py b/src/agentci/prompts/library.py new file mode 100644 index 0000000..63143e6 --- /dev/null +++ b/src/agentci/prompts/library.py @@ -0,0 +1,17 @@ +from __future__ import annotations + +from pathlib import Path +from string import Template + + +class PromptLibrary: + def __init__(self, directory: Path | None = None) -> None: + self.directory = directory or Path(__file__).parent + + def render(self, name: str, **values: str) -> str: + template = Template((self.directory / f"{name}.md").read_text()) + return template.substitute(values) + + @property + def schemas_dir(self) -> Path: + return self.directory / "schemas" diff --git a/src/agentci/reporting.py b/src/agentci/reporting.py deleted file mode 100644 index c424b5b..0000000 --- a/src/agentci/reporting.py +++ /dev/null @@ -1,80 +0,0 @@ -from __future__ import annotations - -from contextvars import ContextVar, Token - -from agentci.domain.events import ( - JobProgress, - RuntimeSessionLinked, - WorkflowCreated, - WorkflowLinked, -) -from agentci.domain.models import Workflow -from agentci.state_machine import StateMachine - - -class NullReporter: - final_body: str | None = None - - async def progress(self, _stage: str) -> None: - pass - - async def create_workflow(self, _workflow: Workflow, _stage: str) -> None: - pass - - async def link_workflow(self, _workflow_id: str, _stage: str) -> None: - pass - - async def link_runtime_session(self, _session_id: str) -> None: - pass - - def finish(self, body: str) -> None: - self.final_body = body - - -_current: ContextVar[JobReporter | NullReporter | None] = ContextVar( - "job_reporter", default=None -) - - -class JobReporter: - def __init__(self, host: StateMachine, job_id: str, task_id: int) -> None: - self.host = host - self.job_id = job_id - self.task_id = task_id - self.sequence = 0 - self.final_body: str | None = None - - async def progress(self, stage: str) -> None: - await self._emit(JobProgress(job_id=self.job_id, stage=stage)) - - async def create_workflow(self, workflow: Workflow, stage: str) -> None: - await self._emit(WorkflowCreated(job_id=self.job_id, workflow=workflow, stage=stage)) - - async def link_workflow(self, workflow_id: str, stage: str) -> None: - await self._emit( - WorkflowLinked(job_id=self.job_id, workflow_id=workflow_id, stage=stage) - ) - - async def link_runtime_session(self, session_id: str) -> None: - await self._emit(RuntimeSessionLinked(job_id=self.job_id, session_id=session_id)) - - def finish(self, body: str) -> None: - self.final_body = body - - async def _emit(self, event) -> None: - self.sequence += 1 - await self.host.evolve(f"task:{self.task_id}:report:{self.sequence}", event) - - -def bind_reporter( - reporter: JobReporter, -) -> Token[JobReporter | NullReporter | None]: - return _current.set(reporter) - - -def reset_reporter(token: Token[JobReporter | NullReporter | None]) -> None: - _current.reset(token) - - -def reporter() -> JobReporter | NullReporter: - return _current.get() or NullReporter() diff --git a/src/agentci/state_machine.py b/src/agentci/state_machine.py deleted file mode 100644 index c6d67d0..0000000 --- a/src/agentci/state_machine.py +++ /dev/null @@ -1,33 +0,0 @@ -from __future__ import annotations - -from dataclasses import dataclass -from uuid import UUID, uuid5 - -from agentci.adapters.job_store import EvolveResult, JobStore -from agentci.domain.events import JobEvent -from agentci.domain.models import CommandEvent -from agentci.domain.state_machine import JobState - -JOB_NAMESPACE = UUID("59565f0f-f17d-4b80-bfba-7ef1fbfd38eb") - - -@dataclass(frozen=True) -class ReceiveResult: - state: JobState - duplicate: bool - - -class StateMachine: - def __init__(self, store: JobStore) -> None: - self.store = store - - async def receive(self, incoming: CommandEvent) -> ReceiveResult: - job_id = str(uuid5(JOB_NAMESPACE, incoming.delivery_id)) - result = await self.store.receive(f"delivery:{incoming.delivery_id}", job_id, incoming) - return ReceiveResult(result.state, result.duplicate) - - async def evolve(self, event_id: str, event: JobEvent) -> EvolveResult: - return await self.store.evolve(event_id, event) - - async def get(self, job_id: str) -> JobState | None: - return await self.store.get_job_state(job_id) diff --git a/src/agentci/worker.py b/src/agentci/worker.py deleted file mode 100644 index b8d1be9..0000000 --- a/src/agentci/worker.py +++ /dev/null @@ -1,220 +0,0 @@ -from __future__ import annotations - -import asyncio -import logging -from contextlib import suppress -from pathlib import Path - -from agentci.adapters.gitea import GiteaClient -from agentci.adapters.job_store import ListenerTask -from agentci.adapters.opencode import OpenCodeClient -from agentci.adapters.storage import Storage -from agentci.domain.events import ( - CommentLinked, - JobCompleted, - JobFailed, - JobStarted, - PermissionDenied, - PermissionGranted, - ServiceRestarted, -) -from agentci.domain.events import ( - JobRejected as RejectedEvent, -) -from agentci.domain.models import JobStatus -from agentci.domain.state_machine import JobState, render_job_comment -from agentci.reporting import JobReporter, bind_reporter, reset_reporter -from agentci.state_machine import StateMachine -from agentci.workflows.common import JobRejected -from agentci.workflows.dispatcher import Dispatcher - -log = logging.getLogger(__name__) - - -class Worker: - def __init__( - self, - *, - storage: Storage, - state_machine: StateMachine, - gitea: GiteaClient, - opencode: OpenCodeClient, - dispatcher: Dispatcher, - poll_seconds: float, - max_concurrent_jobs: int, - workspaces_dir: Path, - bot_username: str, - ) -> None: - self.storage = storage - self.host = state_machine - self.gitea = gitea - self.opencode = opencode - self.dispatcher = dispatcher - self.poll_seconds = poll_seconds - self.max_concurrent_jobs = max_concurrent_jobs - self.workspaces_dir = workspaces_dir - self.bot_username = bot_username - - async def run(self, stop: asyncio.Event) -> None: - await self._recover() - await asyncio.gather( - self._loop("control", stop), - *(self._loop("jobs", stop) for _ in range(self.max_concurrent_jobs)), - ) - - async def _loop(self, queue: str, stop: asyncio.Event) -> None: - while not stop.is_set(): - if queue == "jobs" and not await self.opencode.ready(): - await self._wait(stop) - continue - task = await self.storage.claim_task(queue) - if task is None: - await self._wait(stop) - continue - try: - await self._handle(task) - except asyncio.CancelledError: - raise - except Exception as exc: - log.exception( - "listener failed", - extra={"task_id": task.id, "listener": task.listener, "queue": queue}, - ) - await self.storage.retry_task(task.id, task.attempts, _safe_error(exc)) - else: - await self.storage.complete_task(task.id) - - async def _handle(self, task: ListenerTask) -> None: - state = await self.host.get(task.job_id) - if state is None: - return - if task.listener == "authorize": - await self._authorize(task, state) - elif task.listener == "execute": - await self._execute(task, state) - elif task.listener == "reconcile_comment": - await self._reconcile(task, state) - elif task.listener == "fail_workflow": - await self.storage.fail_job_workflow(state.id) - elif task.listener == "abort_sessions": - await self._abort_job_sessions(state) - else: - raise RuntimeError(f"Unknown listener {task.listener}") - - async def _authorize(self, task: ListenerTask, state: JobState) -> None: - if state.status is not JobStatus.RECEIVED: - return - permitted = await self.gitea.has_write_permission( - state.repo_owner, state.repo_name, state.requester - ) - event = ( - PermissionGranted(job_id=state.id) - if permitted - else PermissionDenied(job_id=state.id) - ) - outcome = "permission-granted" if permitted else "permission-denied" - await self.host.evolve(f"task:{task.id}:{outcome}", event) - - async def _execute(self, task: ListenerTask, state: JobState) -> None: - if state.status is JobStatus.RUNNING: - await self.host.evolve( - f"task:{task.id}:interrupted", ServiceRestarted(job_id=state.id) - ) - return - if state.status is not JobStatus.QUEUED: - return - result = await self.host.evolve( - f"task:{task.id}:started", JobStarted(job_id=state.id) - ) - running = result.state - reporter = JobReporter(self.host, state.id, task.id) - token = bind_reporter(reporter) - try: - await self.dispatcher.dispatch(running) - except JobRejected as exc: - await self.host.evolve( - f"task:{task.id}:rejected", RejectedEvent(job_id=state.id, reason=str(exc)) - ) - except Exception as exc: - latest = await self.host.get(state.id) - stage = latest.stage if latest else running.stage - await self.host.evolve( - f"task:{task.id}:failed", - JobFailed(job_id=state.id, error=_safe_error(exc), stage=stage), - ) - else: - await self.host.evolve( - f"task:{task.id}:completed", - JobCompleted( - job_id=state.id, - comment_body=reporter.final_body or "Agent job completed.", - ), - ) - finally: - reset_reporter(token) - - async def _reconcile(self, task: ListenerTask, state: JobState) -> None: - latest = await self.host.get(state.id) - if latest is None: - return - body = render_job_comment(latest) - comment_id = latest.accepted_comment_id - if comment_id is not None and await self.gitea.update_comment( - latest.repo_owner, latest.repo_name, comment_id, body - ): - return - marker = f"" - matches = sorted( - comment.id - for comment in await self.gitea.issue_comments( - latest.repo_owner, latest.repo_name, latest.issue_number - ) - if comment.body.startswith(marker) - and comment.author.casefold() == self.bot_username.casefold() - ) - if matches: - comment_id = matches[0] - else: - comment_id = await self.gitea.create_comment( - latest.repo_owner, latest.repo_name, latest.issue_number, body - ) - await self.host.evolve( - f"task:{task.id}:comment:{comment_id}", - CommentLinked(job_id=latest.id, comment_id=comment_id), - ) - await self.gitea.update_comment( - latest.repo_owner, latest.repo_name, comment_id, body - ) - - async def _recover(self) -> None: - await self.storage.recover_tasks() - for state in await self.storage.running_job_states(): - await self.host.evolve( - f"recovery:{state.id}:service-restarted", - ServiceRestarted(job_id=state.id), - ) - - async def _abort_job_sessions(self, state: JobState) -> None: - sessions: set[tuple[str, Path]] = set() - workflow = await self.storage.get_workflow(state.workflow_id) if state.workflow_id else None - if workflow: - sessions.update( - (session, workflow.workspace_path) - for session in (workflow.primary_session_id, workflow.reviewer_session_id) - if session - ) - elif state.runtime_session_id: - sessions.add( - (state.runtime_session_id, self.workspaces_dir / f"fix-{state.id}" / "repo") - ) - for session, workspace in sessions: - await self.opencode.abort(session, workspace) - - async def _wait(self, stop: asyncio.Event) -> None: - with suppress(TimeoutError): - await asyncio.wait_for(stop.wait(), timeout=self.poll_seconds) - - -def _safe_error(error: Exception) -> str: - message = " ".join(str(error).split()) - return f"{type(error).__name__}: {message}"[:1000] diff --git a/src/agentci/workflows/__init__.py b/src/agentci/workflows/__init__.py index 9416311..6fb09d6 100644 --- a/src/agentci/workflows/__init__.py +++ b/src/agentci/workflows/__init__.py @@ -1 +1 @@ -"""OpenCode workflow orchestration.""" +"""Functional workflow orchestration.""" diff --git a/src/agentci/workflows/change_set.py b/src/agentci/workflows/change_set.py deleted file mode 100644 index 7f5bd84..0000000 --- a/src/agentci/workflows/change_set.py +++ /dev/null @@ -1,57 +0,0 @@ -from __future__ import annotations - -from pathlib import Path - -from agentci.domain.models import AgentResult, Job -from agentci.reporting import reporter -from agentci.workflows.common import Dependencies, JobRejected - - -class ChangeSet: - def __init__(self, dependencies: Dependencies) -> None: - self.deps = dependencies - - async def commit_and_push( - self, - job: Job, - workspace: Path, - branch: str, - result: AgentResult, - *, - set_upstream: bool, - commit_prefix: str, - ) -> str: - await reporter().progress("validating changes") - if not await self.deps.git.has_changes(workspace): - raise JobRejected("OpenCode completed without producing any file changes.") - await self.deps.git.diff_check(workspace) - title = _commit_title(result.summary_markdown) - await reporter().progress("committing changes") - sha = await self.deps.git.commit(workspace, f"{commit_prefix}: {title}") - await reporter().progress("pushing changes") - await self.deps.git.push(workspace, branch, set_upstream=set_upstream) - return sha - - -def pull_request_body(issue_number: int, result: AgentResult) -> str: - tests = "\n".join(f"- {item}" for item in result.tests) or "- Not reported" - return ( - f"Closes #{issue_number}\n\n" - f"## Implementation\n\n{result.summary_markdown}\n\n" - f"## Validation\n\n{tests}\n\n" - "_Created by Agent CI._" - ) - - -def result_comment(result: AgentResult, *, sha: str | None = None) -> str: - tests = "\n".join(f"- {item}" for item in result.tests) or "- Not reported" - commit = f"\n\nCommit: `{sha}`" if sha else "" - return f"## Agent result\n\n{result.summary_markdown}\n\n## Validation\n\n{tests}{commit}" - - -def _commit_title(markdown: str) -> str: - for line in markdown.splitlines(): - value = line.strip().lstrip("#").strip() - if value: - return value[:72] - return "apply requested changes" diff --git a/src/agentci/workflows/code_review.py b/src/agentci/workflows/code_review.py deleted file mode 100644 index ef0c736..0000000 --- a/src/agentci/workflows/code_review.py +++ /dev/null @@ -1,101 +0,0 @@ -from __future__ import annotations - -from agentci.domain.models import AgentResult, Job, ReviewReport, Workflow -from agentci.reporting import reporter -from agentci.workflows.common import ( - Dependencies, - report_for_prompt, - report_json, - required_session, -) - - -class CodeReviewLoop: - def __init__(self, dependencies: Dependencies) -> None: - self.deps = dependencies - - async def run( - self, - job: Job, - workflow: Workflow, - issue_context: str, - plan: str, - result: AgentResult, - ) -> tuple[AgentResult, ReviewReport]: - report = ReviewReport(summary="", findings=[]) - for round_index in range(self.deps.settings.implement_review_rounds): - await reporter().progress( - f"reviewing implementation {round_index + 1}/" - f"{self.deps.settings.implement_review_rounds}" - ) - report = await self.once( - workflow, - issue_context=issue_context, - plan=plan, - pull_context=( - "The proposed pull request is the current uncommitted working-tree diff. " - "Review only that diff." - ), - ) - workflow.artifact = result.model_dump_json() - workflow.review_json = report_json(report) - await self.deps.storage.update_workflow(workflow) - if not report.has_serious_findings: - break - if round_index == self.deps.settings.implement_review_rounds - 1: - break - prompt = self.deps.prompts.render( - "implementation_revision", - review=report_for_prompt(workflow.review_json), - development_environment=self.deps.development.description, - ) - result = await self.deps.opencode.resume( - session_id=required_session(workflow.primary_session_id), - prompt=prompt, - model=self.deps.settings.implement_model, - variant=self.deps.settings.implement_variant, - workspace=workflow.workspace_path, - schema_name="agent_result.json", - result_type=AgentResult, - ) - return result, report - - async def once( - self, - workflow: Workflow, - *, - issue_context: str, - plan: str, - pull_context: str, - ) -> ReviewReport: - prompt = self.deps.prompts.render( - "implementation_review", - issue_context=issue_context, - artifact=plan, - pull_context=pull_context, - ) - if workflow.reviewer_session_id: - return await self.deps.opencode.resume( - session_id=workflow.reviewer_session_id, - prompt=prompt, - model=self.deps.settings.implement_model, - variant=self.deps.settings.implement_variant, - workspace=workflow.workspace_path, - schema_name="review.json", - result_type=ReviewReport, - ) - session_id = await self.deps.opencode.create_session( - workflow.workspace_path, "implementation-review" - ) - workflow.reviewer_session_id = session_id - await self.deps.storage.update_workflow(workflow) - report = await self.deps.opencode.resume( - session_id=session_id, - workspace=workflow.workspace_path, - prompt=prompt, - model=self.deps.settings.implement_model, - variant=self.deps.settings.implement_variant, - schema_name="review.json", - result_type=ReviewReport, - ) - return report diff --git a/src/agentci/workflows/common.py b/src/agentci/workflows/common.py deleted file mode 100644 index a8b4e97..0000000 --- a/src/agentci/workflows/common.py +++ /dev/null @@ -1,76 +0,0 @@ -from __future__ import annotations - -import json -from dataclasses import dataclass - -from agentci.adapters.development import DevelopmentEnvironment -from agentci.adapters.git import GitClient -from agentci.adapters.gitea import GiteaClient -from agentci.adapters.opencode import OpenCodeClient -from agentci.adapters.storage import Storage -from agentci.config import Settings -from agentci.domain.models import ReviewReport -from agentci.prompts import PromptLibrary -from agentci.reporting import reporter -from agentci.workflows.context import ContextBuilder - - -class JobRejected(RuntimeError): - """A safe, expected workflow rejection to publish to the requester.""" - - -def required_session(value: str | None) -> str: - if value is None: - raise RuntimeError("Expected a persisted OpenCode session ID") - return value - - -@dataclass(frozen=True) -class Dependencies: - settings: Settings - storage: Storage - gitea: GiteaClient - git: GitClient - opencode: OpenCodeClient - prompts: PromptLibrary - context: ContextBuilder - development: DevelopmentEnvironment - - -def review_markdown(report: ReviewReport) -> str: - if not report.findings: - return "" - lines = ["## Remaining review findings", "", report.summary] - for finding in report.findings: - location = f" — `{finding.location}`" if finding.location else "" - lines.extend( - [ - "", - f"### {finding.severity.value.upper()}: {finding.title}{location}", - finding.detail, - "", - f"Recommendation: {finding.recommendation}", - ] - ) - return "\n".join(lines) - - -def report_json(report: ReviewReport) -> str: - return report.model_dump_json() - - -def report_for_prompt(report_json_value: str | None) -> str: - if not report_json_value: - return "(none)" - try: - return json.dumps(json.loads(report_json_value), indent=2) - except json.JSONDecodeError: - return report_json_value - - -def agent_comment(kind: str, workflow_id: str, body: str) -> str: - return f"\n{body}" - - -async def finish_job(body: str) -> None: - reporter().finish(body) diff --git a/src/agentci/workflows/context.py b/src/agentci/workflows/context.py index 9f17666..8b8f571 100644 --- a/src/agentci/workflows/context.py +++ b/src/agentci/workflows/context.py @@ -1,81 +1,104 @@ from __future__ import annotations +import asyncio from typing import Any -from agentci.adapters.gitea import GiteaClient -from agentci.adapters.gitea_models import CommentInfo, PullRequestInfo -from agentci.adapters.storage import Storage +from agentci.engine.repository import Repository +from agentci.integrations.gitea.client import Gitea +from agentci.integrations.gitea.models import CommentInfo, PullRequestInfo -class ContextBuilder: - def __init__(self, gitea: GiteaClient, storage: Storage) -> None: - self.gitea = gitea - self.storage = storage +async def build_issue_context( + gitea: Gitea, + repository: Repository, + owner: str, + repo: str, + number: int, +) -> str: + issue, comments, operational = await asyncio.gather( + gitea.issue(owner, repo, number), + gitea.issue_comments(owner, repo, number), + repository.operational_comment_ids(owner, repo, number), + ) + discussion = "\n\n".join( + _format_comment(comment) for comment in comments if comment.id not in operational + ) + return ( + f"Repository: {owner}/{repo}\n" + f"Issue: #{number} \u2014 {issue.title}\n" + f"State: {issue.state}\n\n" + f"## Issue body\n{issue.body or '(empty)'}\n\n" + f"## Discussion\n{discussion or '(none)'}" + ) - async def issue_context(self, owner: str, repo: str, number: int) -> str: - issue = await self.gitea.issue(owner, repo, number) - comments = await self.gitea.issue_comments(owner, repo, number) - operational = await self.storage.operational_comment_ids(owner, repo, number) - discussion = "\n\n".join( - _format_comment(comment) for comment in comments if comment.id not in operational + +async def build_pull_request_context( + gitea: Gitea, + owner: str, + repo: str, + number: int, +) -> tuple[PullRequestInfo, str]: + pull, timeline, reviews, commits = await asyncio.gather( + gitea.pull_request(owner, repo, number), + gitea.issue_comments(owner, repo, number), + gitea.pull_reviews(owner, repo, number), + gitea.pull_commits(owner, repo, number), + ) + review_text = await _format_reviews(gitea, owner, repo, number, reviews) + timeline_text = "\n\n".join(_format_comment(item) for item in timeline) + commit_text = "\n".join( + f"- {item.get('sha', '')[:12]} {item.get('commit', {}).get('message', '')}" + for item in commits + ) + context = ( + f"Repository: {owner}/{repo}\n" + f"Pull request: #{number} \u2014 {pull.title}\n" + f"State: {pull.state}; merged: {pull.merged}\n" + f"Base: {pull.base_branch}; head: {pull.head_owner}/{pull.head_repo}:" + f"{pull.head_branch} @ {pull.head_sha}\n\n" + f"## Pull request body\n{pull.body or '(empty)'}\n\n" + f"## Commits\n{commit_text or '(none)'}\n\n" + f"## Timeline discussion\n{timeline_text or '(none)'}\n\n" + f"## Formal and inline reviews\n{review_text or '(none)'}" + ) + return pull, context + + +async def _format_reviews( + gitea: Gitea, + owner: str, + repo: str, + number: int, + reviews: list[dict[str, Any]], +) -> str: + details = [ + ( + int(review["id"]), + review.get("user", {}).get("login", "unknown"), + review.get("state", "unknown"), + review.get("body") or "(empty)", ) - return ( - f"Repository: {owner}/{repo}\n" - f"Issue: #{number} — {issue.title}\n" - f"State: {issue.state}\n\n" - f"## Issue body\n{issue.body or '(empty)'}\n\n" - f"## Discussion\n{discussion or '(none)'}" + for review in reviews + ] + comment_groups = await asyncio.gather( + *( + gitea.review_comments(owner, repo, number, review_id) + for review_id, _, _, _ in details ) - - async def pull_request_context( - self, owner: str, repo: str, number: int - ) -> tuple[PullRequestInfo, str]: - pull = await self.gitea.pull_request(owner, repo, number) - timeline = await self.gitea.issue_comments(owner, repo, number) - reviews = await self.gitea.pull_reviews(owner, repo, number) - commits = await self.gitea.pull_commits(owner, repo, number) - review_text = await self._format_reviews(owner, repo, number, reviews) - timeline_text = "\n\n".join(_format_comment(item) for item in timeline) - commit_text = "\n".join( - f"- {item.get('sha', '')[:12]} {item.get('commit', {}).get('message', '')}" - for item in commits - ) - context = ( - f"Repository: {owner}/{repo}\n" - f"Pull request: #{number} — {pull.title}\n" - f"State: {pull.state}; merged: {pull.merged}\n" - f"Base: {pull.base_branch}; head: {pull.head_owner}/{pull.head_repo}:" - f"{pull.head_branch} @ {pull.head_sha}\n\n" - f"## Pull request body\n{pull.body or '(empty)'}\n\n" - f"## Commits\n{commit_text or '(none)'}\n\n" - f"## Timeline discussion\n{timeline_text or '(none)'}\n\n" - f"## Formal and inline reviews\n{review_text or '(none)'}" - ) - return pull, context - - async def _format_reviews( - self, - owner: str, - repo: str, - number: int, - reviews: list[dict[str, Any]], - ) -> str: - sections: list[str] = [] - for review in reviews: - review_id = int(review["id"]) - author = review.get("user", {}).get("login", "unknown") - state = review.get("state", "unknown") - body = review.get("body") or "(empty)" - lines = [f"### Review {review_id} by {author} ({state})\n{body}"] - for comment in await self.gitea.review_comments(owner, repo, number, review_id): - path = comment.get("path") or "unknown file" - line = comment.get("new_position") or comment.get("old_position") or "?" - text = comment.get("body") or "" - lines.append(f"- `{path}:{line}`: {text}") - sections.append("\n".join(lines)) - return "\n\n".join(sections) + ) + sections: list[str] = [] + for (review_id, author, state, body), comments in zip( + details, comment_groups, strict=True + ): + lines = [f"### Review {review_id} by {author} ({state})\n{body}"] + for comment in comments: + path = comment.get("path") or "unknown file" + line = comment.get("new_position") or comment.get("old_position") or "?" + text = comment.get("body") or "" + lines.append(f"- `{path}:{line}`: {text}") + sections.append("\n".join(lines)) + return "\n\n".join(sections) def _format_comment(comment: CommentInfo) -> str: return f"### {comment.author} at {comment.created_at}\n{comment.body}" - diff --git a/src/agentci/workflows/dispatch.py b/src/agentci/workflows/dispatch.py new file mode 100644 index 0000000..fda4c26 --- /dev/null +++ b/src/agentci/workflows/dispatch.py @@ -0,0 +1,45 @@ +from __future__ import annotations + +import logging + +from agentci.engine.model import Job, JobKind +from agentci.engine.run import JobRun +from agentci.workflows.implementation import implement +from agentci.workflows.plan import create_plan, discuss_plan, iterate_plan +from agentci.workflows.pull_request import fix_pull_request, iterate_implementation +from agentci.workflows.services import WorkflowServices + +log = logging.getLogger(__name__) + + +async def dispatch(job: Job, run: JobRun, services: WorkflowServices) -> str: + if job.kind is None: + raise RuntimeError("Cannot dispatch an unparsed command") + extra = { + "operation": "workflow.dispatch", + "job_id": job.id, + "target": job.target_key, + "stage": job.kind.value, + } + log.info("workflow dispatch started", extra=extra) + try: + match job.kind: + case JobKind.PLAN: + body = await create_plan(job, run, services) + case JobKind.DISCUSS: + body = await discuss_plan(job, run, services) + case JobKind.ITERATE_PLAN: + body = await iterate_plan(job, run, services) + case JobKind.IMPLEMENT: + body = await implement(job, run, services) + case JobKind.ITERATE_IMPLEMENT: + body = await iterate_implementation(job, run, services) + case JobKind.FIX: + body = await fix_pull_request(job, run, services) + case _: + raise KeyError(job.kind) + except Exception: + log.exception("workflow dispatch failed", extra=extra) + raise + log.info("workflow dispatch completed", extra=extra) + return body diff --git a/src/agentci/workflows/dispatcher.py b/src/agentci/workflows/dispatcher.py deleted file mode 100644 index 5990e91..0000000 --- a/src/agentci/workflows/dispatcher.py +++ /dev/null @@ -1,44 +0,0 @@ -from __future__ import annotations - -import logging -from typing import Any - -from agentci.domain.models import JobKind -from agentci.domain.state_machine import JobState -from agentci.workflows.common import Dependencies -from agentci.workflows.implement import ImplementWorkflow -from agentci.workflows.plan import PlanWorkflow -from agentci.workflows.pull_request import PullRequestWorkflow - -log = logging.getLogger(__name__) - - -class Dispatcher: - def __init__(self, dependencies: Dependencies) -> None: - plan = PlanWorkflow(dependencies) - pull_request = PullRequestWorkflow(dependencies) - self.handlers: dict[JobKind, Any] = { - JobKind.PLAN: plan.plan, - JobKind.DISCUSS: plan.discuss, - JobKind.ITERATE_PLAN: plan.iterate, - JobKind.IMPLEMENT: ImplementWorkflow(dependencies).run, - JobKind.ITERATE_IMPLEMENT: pull_request.iterate, - JobKind.FIX: pull_request.fix, - } - - async def dispatch(self, job: JobState) -> None: - if job.kind is None: - raise RuntimeError("Cannot dispatch an unparsed command") - extra = { - "operation": "workflow.dispatch", - "job_id": job.id, - "target": job.target_key, - "stage": job.kind.value, - } - log.info("workflow dispatch started", extra=extra) - try: - await self.handlers[job.kind](job) - except Exception: - log.exception("workflow dispatch failed", extra=extra) - raise - log.info("workflow dispatch completed", extra=extra) diff --git a/src/agentci/workflows/implement.py b/src/agentci/workflows/implement.py deleted file mode 100644 index e5782a3..0000000 --- a/src/agentci/workflows/implement.py +++ /dev/null @@ -1,148 +0,0 @@ -from __future__ import annotations - -from uuid import uuid4 - -from agentci.domain.models import ( - AgentResult, - Job, - Workflow, - WorkflowKind, - WorkflowStatus, -) -from agentci.reporting import reporter -from agentci.workflows.change_set import ChangeSet, pull_request_body, result_comment -from agentci.workflows.code_review import CodeReviewLoop -from agentci.workflows.common import ( - Dependencies, - JobRejected, - agent_comment, - finish_job, - report_json, - review_markdown, -) - - -class ImplementWorkflow: - def __init__(self, dependencies: Dependencies) -> None: - self.deps = dependencies - self.review = CodeReviewLoop(dependencies) - self.changes = ChangeSet(dependencies) - - async def run(self, job: Job) -> None: - await self._reject_duplicate(job) - repository = await self.deps.gitea.repository(job.repo_owner, job.repo_name) - issue = await self.deps.gitea.issue(job.repo_owner, job.repo_name, job.issue_number) - workflow_id = str(uuid4()) - branch = ( - f"{self.deps.settings.branch_prefix}/issue-{job.issue_number}-" - f"{workflow_id[:8]}" - ) - workspace = self.deps.settings.workspaces_dir / workflow_id / "repo" - await reporter().progress("cloning") - base_sha = await self.deps.git.clone( - job.repo_owner, - job.repo_name, - repository.default_branch, - workspace, - ) - await self.deps.git.create_branch(workspace, branch) - workflow = Workflow( - id=workflow_id, - kind=WorkflowKind.IMPLEMENT, - repo_owner=job.repo_owner, - repo_name=job.repo_name, - issue_number=job.issue_number, - workspace_path=workspace, - base_sha=base_sha, - branch=branch, - ) - await reporter().create_workflow(workflow, "installing development environment") - await self.deps.development.prepare(workspace) - await reporter().progress("implementing") - context = await self.deps.context.issue_context( - job.repo_owner, job.repo_name, job.issue_number - ) - plan = await self.deps.storage.latest_workflow( - job.repo_owner, job.repo_name, job.issue_number, WorkflowKind.PLAN - ) - prompt = self.deps.prompts.render( - "implement_initial", - context=context, - artifact=plan.artifact if plan and plan.artifact else "(no canonical plan)", - request=job.message or "(no additional request)", - development_environment=self.deps.development.description, - ) - session_id = await self.deps.opencode.create_session(workspace, "implementation") - workflow.primary_session_id = session_id - await self.deps.storage.update_workflow(workflow) - await reporter().link_runtime_session(session_id) - result = await self.deps.opencode.resume( - session_id=session_id, - workspace=workspace, - prompt=prompt, - model=self.deps.settings.implement_model, - variant=self.deps.settings.implement_variant, - schema_name="agent_result.json", - result_type=AgentResult, - ) - workflow.artifact = result.model_dump_json() - await self.deps.storage.update_workflow(workflow) - result, report = await self.review.run( - job, - workflow, - context, - plan.artifact if plan and plan.artifact else "(no canonical plan)", - result, - ) - sha = await self.changes.commit_and_push( - job, - workspace, - branch, - result, - set_upstream=True, - commit_prefix="agent", - ) - await reporter().progress("creating pull request") - pull = await self.deps.gitea.create_pull_request( - job.repo_owner, - job.repo_name, - title=f"Agent: {issue.title}", - body=pull_request_body(job.issue_number, result), - head=branch, - base=repository.default_branch, - ) - workflow.pr_number = pull.number - workflow.artifact = result.model_dump_json() - workflow.review_json = report_json(report) - workflow.status = WorkflowStatus.COMPLETED - await self.deps.storage.update_workflow(workflow) - pull_url = ( - f"{self.deps.settings.gitea_url}/{job.repo_owner}/" - f"{job.repo_name}/pulls/{pull.number}" - ) - body = f"Pull request created: {pull_url}\n\n{result_comment(result, sha=sha)}" - body = agent_comment("implementation", workflow.id, body) - remaining = review_markdown(report) - if remaining: - body = f"{body}\n\n{remaining}" - await finish_job(body) - - async def _reject_duplicate(self, job: Job) -> None: - workflows = await self.deps.storage.implementation_workflows( - job.repo_owner, job.repo_name, job.issue_number - ) - for workflow in workflows: - if workflow.pr_number is None: - continue - pull = await self.deps.gitea.pull_request( - job.repo_owner, job.repo_name, workflow.pr_number - ) - if pull.is_open: - raise JobRejected( - f"Agent PR #{pull.number} is already open. Use `/agent iterate` " - "on that pull request." - ) - if pull.merged: - raise JobRejected( - f"Agent PR #{pull.number} has already been merged for this issue." - ) diff --git a/src/agentci/workflows/implementation.py b/src/agentci/workflows/implementation.py new file mode 100644 index 0000000..ae9c4e0 --- /dev/null +++ b/src/agentci/workflows/implementation.py @@ -0,0 +1,177 @@ +from __future__ import annotations + +from dataclasses import replace +from pathlib import Path +from uuid import uuid4 + +from agentci.engine.model import Job, Workflow, WorkflowKind, WorkflowStatus +from agentci.engine.run import JobRun +from agentci.workflows.context import build_issue_context +from agentci.workflows.model import AgentResult +from agentci.workflows.render import ( + JobRejected, + commit_title, + final_comment, + pull_request_body, + result_comment, +) +from agentci.workflows.review import review_implementation_loop +from agentci.workflows.services import WorkflowServices + + +async def implement(job: Job, run: JobRun, services: WorkflowServices) -> str: + await _reject_duplicate(job, services) + default_branch = await services.gitea.default_branch(job.repo_owner, job.repo_name) + issue = await services.gitea.issue(job.repo_owner, job.repo_name, job.issue_number) + workflow_id = str(uuid4()) + branch = ( + f"{services.settings.branch_prefix}/issue-{job.issue_number}-" + f"{workflow_id[:8]}" + ) + workspace = services.settings.workspaces_dir / workflow_id / "repo" + + await run.stage("cloning") + base_sha = await services.git.clone( + job.repo_owner, + job.repo_name, + default_branch, + workspace, + ) + await services.git.create_branch(workspace, branch) + workflow = Workflow( + id=workflow_id, + kind=WorkflowKind.IMPLEMENT, + repo_owner=job.repo_owner, + repo_name=job.repo_name, + issue_number=job.issue_number, + workspace_path=workspace, + base_sha=base_sha, + branch=branch, + ) + await run.create_workflow(workflow, "installing development environment") + await services.development.prepare(workspace) + + await run.stage("implementing") + context = await build_issue_context( + services.gitea, + services.repository, + job.repo_owner, + job.repo_name, + job.issue_number, + ) + plan = await services.repository.latest_workflow( + job.repo_owner, + job.repo_name, + job.issue_number, + WorkflowKind.PLAN, + ) + plan_artifact = plan.artifact if plan and plan.artifact else "(no canonical plan)" + prompt = services.prompts.render( + "implement_initial", + context=context, + artifact=plan_artifact, + request=job.message or "(no additional request)", + development_environment=services.development.description, + ) + session_id = await services.opencode.create_session(workspace, "implementation") + workflow = replace(workflow, primary_session_id=session_id) + await services.repository.save_workflow(workflow) + await run.link_session(session_id) + + result = await services.opencode.resume( + session_id=session_id, + workspace=workspace, + prompt=prompt, + model=services.settings.implement_model, + variant=services.settings.implement_variant, + schema_name="agent_result.json", + result_type=AgentResult, + ) + workflow = replace(workflow, artifact=result.model_dump_json()) + await services.repository.save_workflow(workflow) + + workflow, result, report = await review_implementation_loop( + workflow, + context, + plan_artifact, + result, + run, + services, + ) + sha = await commit_and_push( + run, + services, + workspace, + branch, + result, + set_upstream=True, + commit_prefix="agent", + ) + + await run.stage("creating pull request") + pull = await services.gitea.create_pull_request( + job.repo_owner, + job.repo_name, + title=f"Agent: {issue.title}", + body=pull_request_body(job.issue_number, result), + head=branch, + base=default_branch, + ) + workflow = replace( + workflow, + pr_number=pull.number, + artifact=result.model_dump_json(), + review_json=report.model_dump_json(), + status=WorkflowStatus.COMPLETED, + ) + await services.repository.save_workflow(workflow) + + pull_url = ( + f"{services.settings.gitea_url}/{job.repo_owner}/" + f"{job.repo_name}/pulls/{pull.number}" + ) + body = f"Pull request created: {pull_url}\n\n{result_comment(result, sha=sha)}" + return final_comment("implementation", workflow.id, body, report) + + +async def commit_and_push( + run: JobRun, + services: WorkflowServices, + workspace: Path, + branch: str, + result: AgentResult, + *, + set_upstream: bool, + commit_prefix: str, +) -> str: + await run.stage("validating changes") + if not await services.git.has_changes(workspace): + raise JobRejected("OpenCode completed without producing any file changes.") + await services.git.diff_check(workspace) + title = commit_title(result.summary_markdown) + await run.stage("committing changes") + sha = await services.git.commit(workspace, f"{commit_prefix}: {title}") + await run.stage("pushing changes") + await services.git.push(workspace, branch, set_upstream=set_upstream) + return sha + + +async def _reject_duplicate(job: Job, services: WorkflowServices) -> None: + workflows = await services.repository.implementation_workflows( + job.repo_owner, job.repo_name, job.issue_number + ) + for workflow in workflows: + if workflow.pr_number is None: + continue + pull = await services.gitea.pull_request( + job.repo_owner, job.repo_name, workflow.pr_number + ) + if pull.is_open: + raise JobRejected( + f"Agent PR #{pull.number} is already open. Use `/agent iterate` " + "on that pull request." + ) + if pull.merged: + raise JobRejected( + f"Agent PR #{pull.number} has already been merged for this issue." + ) diff --git a/src/agentci/workflows/model.py b/src/agentci/workflows/model.py new file mode 100644 index 0000000..8e2fd55 --- /dev/null +++ b/src/agentci/workflows/model.py @@ -0,0 +1,44 @@ +from __future__ import annotations + +from enum import StrEnum + +from pydantic import BaseModel, Field + + +class ReviewSeverity(StrEnum): + BLOCKING = "blocking" + MAJOR = "major" + MINOR = "minor" + + +class PlanArtifact(BaseModel): + plan_markdown: str = Field(min_length=1) + + +class DiscussionReply(BaseModel): + markdown: str = Field(min_length=1) + + +class AgentResult(BaseModel): + summary_markdown: str = Field(min_length=1) + tests: list[str] = Field(default_factory=list) + + +class ReviewFinding(BaseModel): + severity: ReviewSeverity + title: str + detail: str + location: str | None = None + recommendation: str + + +class ReviewReport(BaseModel): + summary: str + findings: list[ReviewFinding] = Field(default_factory=list) + + @property + def has_serious_findings(self) -> bool: + return any( + finding.severity in {ReviewSeverity.BLOCKING, ReviewSeverity.MAJOR} + for finding in self.findings + ) diff --git a/src/agentci/workflows/plan.py b/src/agentci/workflows/plan.py index d5a5179..84c3083 100644 --- a/src/agentci/workflows/plan.py +++ b/src/agentci/workflows/plan.py @@ -1,235 +1,189 @@ from __future__ import annotations +from dataclasses import replace from uuid import uuid4 -from agentci.domain.models import ( - DiscussionReply, +from agentci.engine.model import ( Job, - PlanArtifact, - ReviewReport, Workflow, WorkflowKind, WorkflowStatus, ) -from agentci.reporting import reporter -from agentci.workflows.common import ( - Dependencies, +from agentci.engine.run import JobRun +from agentci.workflows.context import build_issue_context +from agentci.workflows.model import DiscussionReply, PlanArtifact +from agentci.workflows.render import ( JobRejected, agent_comment, - finish_job, + final_comment, report_for_prompt, - report_json, - required_session, - review_markdown, ) +from agentci.workflows.review import review_plan_loop, review_plan_once +from agentci.workflows.services import WorkflowServices -class PlanWorkflow: - def __init__(self, dependencies: Dependencies) -> None: - self.deps = dependencies +async def create_plan(job: Job, run: JobRun, services: WorkflowServices) -> str: + default_branch = await services.gitea.default_branch(job.repo_owner, job.repo_name) + workflow_id = str(uuid4()) + workspace = services.settings.workspaces_dir / workflow_id / "repo" - async def plan(self, job: Job) -> None: - repository = await self.deps.gitea.repository(job.repo_owner, job.repo_name) - workflow_id = str(uuid4()) - workspace = self.deps.settings.workspaces_dir / workflow_id / "repo" - await reporter().progress("cloning") - base_sha = await self.deps.git.clone( - job.repo_owner, - job.repo_name, - repository.default_branch, - workspace, - ) - workflow = Workflow( - id=workflow_id, - kind=WorkflowKind.PLAN, - repo_owner=job.repo_owner, - repo_name=job.repo_name, - issue_number=job.issue_number, - workspace_path=workspace, - base_sha=base_sha, - ) - await reporter().create_workflow(workflow, "planning") - context = await self.deps.context.issue_context( - job.repo_owner, job.repo_name, job.issue_number - ) - prompt = self.deps.prompts.render( - "plan_initial", - context=context, - request=job.message or "(no additional request)", - ) - session_id = await self.deps.opencode.create_session(workspace, "plan") - workflow.primary_session_id = session_id - await self.deps.storage.update_workflow(workflow) - await reporter().link_runtime_session(session_id) - artifact = await self.deps.opencode.resume( - session_id=session_id, - workspace=workspace, - prompt=prompt, - model=self.deps.settings.plan_model, - variant=self.deps.settings.plan_variant, - schema_name="plan.json", - result_type=PlanArtifact, - ) - workflow.artifact = artifact.plan_markdown - await self.deps.storage.update_workflow(workflow) - report = await self._review_loop(job, workflow, context, artifact) - await self._finish(job, workflow, artifact, report) + await run.stage("cloning") + base_sha = await services.git.clone( + job.repo_owner, + job.repo_name, + default_branch, + workspace, + ) + workflow = Workflow( + id=workflow_id, + kind=WorkflowKind.PLAN, + repo_owner=job.repo_owner, + repo_name=job.repo_name, + issue_number=job.issue_number, + workspace_path=workspace, + base_sha=base_sha, + ) + await run.create_workflow(workflow, "planning") - async def discuss(self, job: Job) -> None: - workflow = await self._latest_plan(job) - if workflow.runtime != "opencode": + context = await build_issue_context( + services.gitea, + services.repository, + job.repo_owner, + job.repo_name, + job.issue_number, + ) + prompt = services.prompts.render( + "plan_initial", + context=context, + request=job.message or "(no additional request)", + ) + session_id = await services.opencode.create_session(workspace, "plan") + workflow = replace(workflow, primary_session_id=session_id) + await services.repository.save_workflow(workflow) + await run.link_session(session_id) + + artifact = await services.opencode.resume( + session_id=session_id, + workspace=workspace, + prompt=prompt, + model=services.settings.plan_model, + variant=services.settings.plan_variant, + schema_name="plan.json", + result_type=PlanArtifact, + ) + workflow = replace(workflow, artifact=artifact.plan_markdown) + await services.repository.save_workflow(workflow) + + workflow, artifact, report = await review_plan_loop( + workflow, context, artifact, run, services + ) + workflow = replace( + workflow, + artifact=artifact.plan_markdown, + review_json=report.model_dump_json(), + status=WorkflowStatus.COMPLETED, + ) + await services.repository.save_workflow(workflow) + return final_comment("plan", workflow.id, artifact.plan_markdown, report) + + +async def discuss_plan(job: Job, run: JobRun, services: WorkflowServices) -> str: + workflow = await _latest_plan(job, services) + if workflow.runtime != "opencode": + raise JobRejected( + "The latest plan predates OpenCode and cannot be resumed; " + "start a new `/agent plan`." + ) + if not workflow.primary_session_id or not workflow.artifact: + raise JobRejected("The latest plan cannot be resumed; start a new `/agent plan`.") + + await run.link_workflow(workflow.id, "discussing") + prompt = services.prompts.render( + "discuss", artifact=workflow.artifact, message=job.message or "" + ) + reply = await services.opencode.resume( + session_id=workflow.primary_session_id, + prompt=prompt, + model=services.settings.plan_model, + variant=services.settings.plan_variant, + workspace=workflow.workspace_path, + schema_name="discussion.json", + result_type=DiscussionReply, + ) + return agent_comment("discussion", workflow.id, reply.markdown) + + +async def iterate_plan(job: Job, run: JobRun, services: WorkflowServices) -> str: + await _reject_if_active_or_merged_pr(job, services) + workflow = await _latest_plan(job, services) + if workflow.runtime != "opencode": + raise JobRejected("The latest plan predates OpenCode; start a new plan.") + if not workflow.primary_session_id or not workflow.reviewer_session_id: + raise JobRejected("The latest plan is missing resumable sessions; start a new plan.") + if not workflow.artifact: + raise JobRejected("The latest plan has no saved artifact.") + + await run.link_workflow(workflow.id, "iterating plan") + context = await build_issue_context( + services.gitea, + services.repository, + job.repo_owner, + job.repo_name, + job.issue_number, + ) + prompt = services.prompts.render( + "plan_iterate", + context=context, + artifact=workflow.artifact, + review=report_for_prompt(workflow.review_json), + message=job.message or "(refine using the latest discussion and prior review)", + ) + artifact = await services.opencode.resume( + session_id=workflow.primary_session_id, + prompt=prompt, + model=services.settings.plan_model, + variant=services.settings.plan_variant, + workspace=workflow.workspace_path, + schema_name="plan.json", + result_type=PlanArtifact, + ) + workflow, report = await review_plan_once(workflow, context, artifact, services) + workflow = replace( + workflow, + artifact=artifact.plan_markdown, + review_json=report.model_dump_json(), + status=WorkflowStatus.COMPLETED, + ) + await services.repository.save_workflow(workflow) + return final_comment("plan", workflow.id, artifact.plan_markdown, report) + + +async def _latest_plan(job: Job, services: WorkflowServices) -> Workflow: + workflow = await services.repository.latest_workflow( + job.repo_owner, + job.repo_name, + job.issue_number, + WorkflowKind.PLAN, + ) + if workflow is None: + raise JobRejected("No completed plan exists. Start with `/agent plan`.") + return workflow + + +async def _reject_if_active_or_merged_pr( + job: Job, services: WorkflowServices +) -> None: + workflows = await services.repository.implementation_workflows( + job.repo_owner, job.repo_name, job.issue_number + ) + for workflow in workflows: + if workflow.pr_number is None: + continue + pull = await services.gitea.pull_request( + job.repo_owner, job.repo_name, workflow.pr_number + ) + if pull.is_open or pull.merged: raise JobRejected( - "The latest plan predates OpenCode and cannot be resumed; " - "start a new `/agent plan`." + f"Issue plan iteration is disabled because agent PR #{pull.number} " + "is open or merged. Iterate an open implementation on its PR." ) - if not workflow.primary_session_id or not workflow.artifact: - raise JobRejected("The latest plan cannot be resumed; start a new `/agent plan`.") - await reporter().link_workflow(workflow.id, "discussing") - prompt = self.deps.prompts.render( - "discuss", artifact=workflow.artifact, message=job.message - ) - reply = await self.deps.opencode.resume( - session_id=workflow.primary_session_id, - prompt=prompt, - model=self.deps.settings.plan_model, - variant=self.deps.settings.plan_variant, - workspace=workflow.workspace_path, - schema_name="discussion.json", - result_type=DiscussionReply, - ) - await finish_job(agent_comment("discussion", workflow.id, reply.markdown)) - - async def iterate(self, job: Job) -> None: - await self._reject_if_active_or_merged_pr(job) - workflow = await self._latest_plan(job) - if workflow.runtime != "opencode": - raise JobRejected("The latest plan predates OpenCode; start a new plan.") - if not workflow.primary_session_id or not workflow.reviewer_session_id: - raise JobRejected("The latest plan is missing resumable sessions; start a new plan.") - if not workflow.artifact: - raise JobRejected("The latest plan has no saved artifact.") - await reporter().link_workflow(workflow.id, "iterating plan") - context = await self.deps.context.issue_context( - job.repo_owner, job.repo_name, job.issue_number - ) - prompt = self.deps.prompts.render( - "plan_iterate", - context=context, - artifact=workflow.artifact, - review=report_for_prompt(workflow.review_json), - message=job.message or "(refine using the latest discussion and prior review)", - ) - artifact = await self.deps.opencode.resume( - session_id=workflow.primary_session_id, - prompt=prompt, - model=self.deps.settings.plan_model, - variant=self.deps.settings.plan_variant, - workspace=workflow.workspace_path, - schema_name="plan.json", - result_type=PlanArtifact, - ) - report = await self._review(workflow, context, artifact) - await self._finish(job, workflow, artifact, report) - - async def _review_loop( - self, job: Job, workflow: Workflow, context: str, artifact: PlanArtifact - ) -> ReviewReport: - report = ReviewReport(summary="", findings=[]) - for round_index in range(self.deps.settings.plan_review_rounds): - await reporter().progress( - f"reviewing plan {round_index + 1}/{self.deps.settings.plan_review_rounds}" - ) - report = await self._review(workflow, context, artifact) - workflow.artifact = artifact.plan_markdown - workflow.review_json = report_json(report) - await self.deps.storage.update_workflow(workflow) - if not report.has_serious_findings: - break - if round_index == self.deps.settings.plan_review_rounds - 1: - break - prompt = self.deps.prompts.render( - "plan_revision", - artifact=artifact.plan_markdown, - review=report_for_prompt(workflow.review_json), - ) - artifact = await self.deps.opencode.resume( - session_id=required_session(workflow.primary_session_id), - prompt=prompt, - model=self.deps.settings.plan_model, - variant=self.deps.settings.plan_variant, - workspace=workflow.workspace_path, - schema_name="plan.json", - result_type=PlanArtifact, - ) - return report - - async def _review( - self, workflow: Workflow, context: str, artifact: PlanArtifact - ) -> ReviewReport: - prompt = self.deps.prompts.render( - "plan_review", context=context, artifact=artifact.plan_markdown - ) - if workflow.reviewer_session_id: - return await self.deps.opencode.resume( - session_id=workflow.reviewer_session_id, - prompt=prompt, - model=self.deps.settings.plan_model, - variant=self.deps.settings.plan_variant, - workspace=workflow.workspace_path, - schema_name="review.json", - result_type=ReviewReport, - ) - session_id = await self.deps.opencode.create_session(workflow.workspace_path, "plan-review") - workflow.reviewer_session_id = session_id - await self.deps.storage.update_workflow(workflow) - report = await self.deps.opencode.resume( - session_id=session_id, - workspace=workflow.workspace_path, - prompt=prompt, - model=self.deps.settings.plan_model, - variant=self.deps.settings.plan_variant, - schema_name="review.json", - result_type=ReviewReport, - ) - return report - - async def _finish( - self, - job: Job, - workflow: Workflow, - artifact: PlanArtifact, - report: ReviewReport, - ) -> None: - workflow.artifact = artifact.plan_markdown - workflow.review_json = report_json(report) - workflow.status = WorkflowStatus.COMPLETED - await self.deps.storage.update_workflow(workflow) - body = agent_comment("plan", workflow.id, artifact.plan_markdown) - remaining = review_markdown(report) - if remaining: - body = f"{body}\n\n{remaining}" - await finish_job(body) - - async def _latest_plan(self, job: Job) -> Workflow: - workflow = await self.deps.storage.latest_workflow( - job.repo_owner, job.repo_name, job.issue_number, WorkflowKind.PLAN - ) - if workflow is None: - raise JobRejected("No completed plan exists. Start with `/agent plan`.") - return workflow - - async def _reject_if_active_or_merged_pr(self, job: Job) -> None: - workflows = await self.deps.storage.implementation_workflows( - job.repo_owner, job.repo_name, job.issue_number - ) - for workflow in workflows: - if workflow.pr_number is None: - continue - pull = await self.deps.gitea.pull_request( - job.repo_owner, job.repo_name, workflow.pr_number - ) - if pull.is_open or pull.merged: - raise JobRejected( - f"Issue plan iteration is disabled because agent PR #{pull.number} " - "is open or merged. Iterate an open implementation on its PR." - ) diff --git a/src/agentci/workflows/pull_request.py b/src/agentci/workflows/pull_request.py index cf65fc8..125558e 100644 --- a/src/agentci/workflows/pull_request.py +++ b/src/agentci/workflows/pull_request.py @@ -1,142 +1,160 @@ from __future__ import annotations -from agentci.domain.models import AgentResult, Job, WorkflowKind, WorkflowStatus -from agentci.reporting import reporter -from agentci.workflows.change_set import ChangeSet, result_comment -from agentci.workflows.code_review import CodeReviewLoop -from agentci.workflows.common import ( - Dependencies, +from dataclasses import replace + +from agentci.engine.model import Job, WorkflowKind, WorkflowStatus +from agentci.engine.run import JobRun +from agentci.workflows.context import ( + build_issue_context, + build_pull_request_context, +) +from agentci.workflows.implementation import commit_and_push +from agentci.workflows.model import AgentResult +from agentci.workflows.render import ( JobRejected, agent_comment, - finish_job, + final_comment, report_for_prompt, - report_json, - review_markdown, + result_comment, ) +from agentci.workflows.review import review_implementation_once +from agentci.workflows.services import WorkflowServices -class PullRequestWorkflow: - def __init__(self, dependencies: Dependencies) -> None: - self.deps = dependencies - self.review = CodeReviewLoop(dependencies) - self.changes = ChangeSet(dependencies) +async def iterate_implementation( + job: Job, run: JobRun, services: WorkflowServices +) -> str: + pull_number = _pull_number(job) + workflow = await services.repository.workflow_for_pr( + job.repo_owner, job.repo_name, pull_number + ) + if workflow is None or workflow.status is not WorkflowStatus.COMPLETED: + raise JobRejected( + "This is not an open agent-created implementation PR. Use `/agent fix`." + ) + if workflow.runtime != "opencode": + raise JobRejected("The implementation predates OpenCode and cannot be resumed.") + if not workflow.primary_session_id or not workflow.reviewer_session_id: + raise JobRejected("The implementation sessions cannot be resumed.") - async def iterate(self, job: Job) -> None: - pull_number = _pull_number(job) - workflow = await self.deps.storage.workflow_for_pr( - job.repo_owner, job.repo_name, pull_number - ) - if workflow is None or workflow.status is not WorkflowStatus.COMPLETED: - raise JobRejected( - "This is not an open agent-created implementation PR. Use `/agent fix`." - ) - if workflow.runtime != "opencode": - raise JobRejected("The implementation predates OpenCode and cannot be resumed.") - if not workflow.primary_session_id or not workflow.reviewer_session_id: - raise JobRejected("The implementation sessions cannot be resumed.") - pull, context = await self.deps.context.pull_request_context( - job.repo_owner, job.repo_name, pull_number - ) - if not pull.is_open: - raise JobRejected("Implementation iteration requires an open pull request.") - if workflow.branch != pull.head_branch: - raise JobRejected("The pull request head branch no longer matches its workflow.") - await reporter().link_workflow(workflow.id, "synchronizing branch") - await self.deps.git.sync_branch(workflow.workspace_path, pull.head_branch) - await reporter().progress("installing development environment") - await self.deps.development.prepare(workflow.workspace_path) - await reporter().progress("implementing iteration") - prompt = self.deps.prompts.render( - "implementation_iterate", - context=context, - review=report_for_prompt(workflow.review_json), - message=job.message or "(perform one additional reviewed refinement)", - development_environment=self.deps.development.description, - ) - result = await self.deps.opencode.resume( - session_id=workflow.primary_session_id, - prompt=prompt, - model=self.deps.settings.implement_model, - variant=self.deps.settings.implement_variant, - workspace=workflow.workspace_path, - schema_name="agent_result.json", - result_type=AgentResult, - ) - issue_context = await self.deps.context.issue_context( - job.repo_owner, job.repo_name, workflow.issue_number - ) - plan = await self.deps.storage.latest_workflow( - job.repo_owner, job.repo_name, workflow.issue_number, WorkflowKind.PLAN - ) - report = await self.review.once( - workflow, - issue_context=issue_context, - plan=plan.artifact if plan and plan.artifact else "(no canonical plan)", - pull_context=context, - ) - sha = await self.changes.commit_and_push( - job, - workflow.workspace_path, - pull.head_branch, - result, - set_upstream=False, - commit_prefix="agent iterate", - ) - workflow.artifact = result.model_dump_json() - workflow.review_json = report_json(report) - await self.deps.storage.update_workflow(workflow) - body = agent_comment( - "iteration", workflow.id, result_comment(result, sha=sha) - ) - remaining = review_markdown(report) - if remaining: - body = f"{body}\n\n{remaining}" - await finish_job(body) + pull, context = await build_pull_request_context( + services.gitea, job.repo_owner, job.repo_name, pull_number + ) + if not pull.is_open: + raise JobRejected("Implementation iteration requires an open pull request.") + if workflow.branch != pull.head_branch: + raise JobRejected("The pull request head branch no longer matches its workflow.") - async def fix(self, job: Job) -> None: - pull_number = _pull_number(job) - pull, context = await self.deps.context.pull_request_context( - job.repo_owner, job.repo_name, pull_number - ) - if not pull.is_open: - raise JobRejected("Fixes require an open pull request.") - workspace = self.deps.settings.workspaces_dir / f"fix-{job.id}" / "repo" - await reporter().progress("cloning pull request") - await self.deps.git.clone( - pull.head_owner, - pull.head_repo, - pull.head_branch, - workspace, - ) - await reporter().progress("installing development environment") - await self.deps.development.prepare(workspace) - prompt = self.deps.prompts.render( - "fix", - context=context, - message=job.message or "(address the pull request feedback)", - development_environment=self.deps.development.description, - ) - await reporter().progress("fixing") - session_id = await self.deps.opencode.create_session(workspace, "fix") - await reporter().link_runtime_session(session_id) - result = await self.deps.opencode.resume( - session_id=session_id, - workspace=workspace, - prompt=prompt, - model=self.deps.settings.implement_model, - variant=self.deps.settings.implement_variant, - schema_name="agent_result.json", - result_type=AgentResult, - ) - sha = await self.changes.commit_and_push( - job, - workspace, - pull.head_branch, - result, - set_upstream=False, - commit_prefix="agent fix", - ) - await finish_job(agent_comment("fix", job.id, result_comment(result, sha=sha))) + await run.link_workflow(workflow.id, "synchronizing branch") + await services.git.sync_branch(workflow.workspace_path, pull.head_branch) + await run.stage("installing development environment") + await services.development.prepare(workflow.workspace_path) + await run.stage("implementing iteration") + prompt = services.prompts.render( + "implementation_iterate", + context=context, + review=report_for_prompt(workflow.review_json), + message=job.message or "(perform one additional reviewed refinement)", + development_environment=services.development.description, + ) + result = await services.opencode.resume( + session_id=workflow.primary_session_id, + prompt=prompt, + model=services.settings.implement_model, + variant=services.settings.implement_variant, + workspace=workflow.workspace_path, + schema_name="agent_result.json", + result_type=AgentResult, + ) + + issue_context = await build_issue_context( + services.gitea, + services.repository, + job.repo_owner, + job.repo_name, + workflow.issue_number, + ) + plan = await services.repository.latest_workflow( + job.repo_owner, + job.repo_name, + workflow.issue_number, + WorkflowKind.PLAN, + ) + workflow, report = await review_implementation_once( + workflow, + issue_context=issue_context, + plan=plan.artifact if plan and plan.artifact else "(no canonical plan)", + pull_context=context, + services=services, + ) + sha = await commit_and_push( + run, + services, + workflow.workspace_path, + pull.head_branch, + result, + set_upstream=False, + commit_prefix="agent iterate", + ) + workflow = replace( + workflow, + artifact=result.model_dump_json(), + review_json=report.model_dump_json(), + ) + await services.repository.save_workflow(workflow) + return final_comment( + "iteration", workflow.id, result_comment(result, sha=sha), report + ) + + +async def fix_pull_request( + job: Job, run: JobRun, services: WorkflowServices +) -> str: + pull_number = _pull_number(job) + pull, context = await build_pull_request_context( + services.gitea, job.repo_owner, job.repo_name, pull_number + ) + if not pull.is_open: + raise JobRejected("Fixes require an open pull request.") + + workspace = services.settings.workspaces_dir / f"fix-{job.id}" / "repo" + await run.stage("cloning pull request") + await services.git.clone( + pull.head_owner, + pull.head_repo, + pull.head_branch, + workspace, + ) + await run.stage("installing development environment") + await services.development.prepare(workspace) + prompt = services.prompts.render( + "fix", + context=context, + message=job.message or "(address the pull request feedback)", + development_environment=services.development.description, + ) + await run.stage("fixing") + session_id = await services.opencode.create_session(workspace, "fix") + await run.link_session(session_id) + result = await services.opencode.resume( + session_id=session_id, + workspace=workspace, + prompt=prompt, + model=services.settings.implement_model, + variant=services.settings.implement_variant, + schema_name="agent_result.json", + result_type=AgentResult, + ) + sha = await commit_and_push( + run, + services, + workspace, + pull.head_branch, + result, + set_upstream=False, + commit_prefix="agent fix", + ) + return agent_comment("fix", job.id, result_comment(result, sha=sha)) def _pull_number(job: Job) -> int: diff --git a/src/agentci/workflows/render.py b/src/agentci/workflows/render.py new file mode 100644 index 0000000..78d8cfb --- /dev/null +++ b/src/agentci/workflows/render.py @@ -0,0 +1,81 @@ +from __future__ import annotations + +import json + +from agentci.workflows.model import AgentResult, ReviewReport + + +class JobRejected(RuntimeError): + """A safe, expected workflow rejection to publish to the requester.""" + + +def required_session(value: str | None) -> str: + if value is None: + raise RuntimeError("Expected a persisted OpenCode session ID") + return value + + +def review_markdown(report: ReviewReport) -> str: + if not report.findings: + return "" + lines = ["## Remaining review findings", "", report.summary] + for finding in report.findings: + location = f" \u2014 `{finding.location}`" if finding.location else "" + lines.extend( + [ + "", + f"### {finding.severity.value.upper()}: {finding.title}{location}", + finding.detail, + "", + f"Recommendation: {finding.recommendation}", + ] + ) + return "\n".join(lines) + + +def report_for_prompt(stored_review: str | None) -> str: + if not stored_review: + return "(none)" + try: + return json.dumps(json.loads(stored_review), indent=2) + except json.JSONDecodeError: + return stored_review + + +def agent_comment(kind: str, workflow_id: str, body: str) -> str: + return f"\n{body}" + + +def final_comment( + kind: str, + workflow_id: str, + body: str, + report: ReviewReport | None = None, +) -> str: + rendered = agent_comment(kind, workflow_id, body) + remaining = review_markdown(report) if report else "" + return f"{rendered}\n\n{remaining}" if remaining else rendered + + +def pull_request_body(issue_number: int, result: AgentResult) -> str: + tests = "\n".join(f"- {item}" for item in result.tests) or "- Not reported" + return ( + f"Closes #{issue_number}\n\n" + f"## Implementation\n\n{result.summary_markdown}\n\n" + f"## Validation\n\n{tests}\n\n" + "_Created by Agent CI._" + ) + + +def result_comment(result: AgentResult, *, sha: str | None = None) -> str: + tests = "\n".join(f"- {item}" for item in result.tests) or "- Not reported" + commit = f"\n\nCommit: `{sha}`" if sha else "" + return f"## Agent result\n\n{result.summary_markdown}\n\n## Validation\n\n{tests}{commit}" + + +def commit_title(markdown: str) -> str: + for line in markdown.splitlines(): + value = line.strip().lstrip("#").strip() + if value: + return value[:72] + return "apply requested changes" diff --git a/src/agentci/workflows/review.py b/src/agentci/workflows/review.py new file mode 100644 index 0000000..8765408 --- /dev/null +++ b/src/agentci/workflows/review.py @@ -0,0 +1,187 @@ +from __future__ import annotations + +from dataclasses import replace + +from agentci.engine.model import Workflow +from agentci.engine.run import JobRun +from agentci.workflows.model import AgentResult, PlanArtifact, ReviewReport +from agentci.workflows.render import report_for_prompt, required_session +from agentci.workflows.services import WorkflowServices + + +async def review_plan_loop( + workflow: Workflow, + context: str, + artifact: PlanArtifact, + run: JobRun, + services: WorkflowServices, +) -> tuple[Workflow, PlanArtifact, ReviewReport]: + report = ReviewReport(summary="", findings=[]) + for round_index in range(services.settings.plan_review_rounds): + await run.stage( + f"reviewing plan {round_index + 1}/{services.settings.plan_review_rounds}" + ) + workflow, report = await review_plan_once( + workflow, context, artifact, services + ) + workflow = replace( + workflow, + artifact=artifact.plan_markdown, + review_json=report.model_dump_json(), + ) + await services.repository.save_workflow(workflow) + if not report.has_serious_findings: + break + if round_index == services.settings.plan_review_rounds - 1: + break + prompt = services.prompts.render( + "plan_revision", + artifact=artifact.plan_markdown, + review=report_for_prompt(workflow.review_json), + ) + artifact = await services.opencode.resume( + session_id=required_session(workflow.primary_session_id), + prompt=prompt, + model=services.settings.plan_model, + variant=services.settings.plan_variant, + workspace=workflow.workspace_path, + schema_name="plan.json", + result_type=PlanArtifact, + ) + workflow = replace(workflow, artifact=artifact.plan_markdown) + await services.repository.save_workflow(workflow) + return workflow, artifact, report + + +async def review_plan_once( + workflow: Workflow, + context: str, + artifact: PlanArtifact, + services: WorkflowServices, +) -> tuple[Workflow, ReviewReport]: + prompt = services.prompts.render( + "plan_review", context=context, artifact=artifact.plan_markdown + ) + if workflow.reviewer_session_id: + report = await services.opencode.resume( + session_id=workflow.reviewer_session_id, + prompt=prompt, + model=services.settings.plan_model, + variant=services.settings.plan_variant, + workspace=workflow.workspace_path, + schema_name="review.json", + result_type=ReviewReport, + ) + return workflow, report + + session_id = await services.opencode.create_session( + workflow.workspace_path, "plan-review" + ) + workflow = replace(workflow, reviewer_session_id=session_id) + await services.repository.save_workflow(workflow) + report = await services.opencode.resume( + session_id=session_id, + workspace=workflow.workspace_path, + prompt=prompt, + model=services.settings.plan_model, + variant=services.settings.plan_variant, + schema_name="review.json", + result_type=ReviewReport, + ) + return workflow, report + + +async def review_implementation_loop( + workflow: Workflow, + issue_context: str, + plan: str, + result: AgentResult, + run: JobRun, + services: WorkflowServices, +) -> tuple[Workflow, AgentResult, ReviewReport]: + report = ReviewReport(summary="", findings=[]) + for round_index in range(services.settings.implement_review_rounds): + await run.stage( + f"reviewing implementation {round_index + 1}/" + f"{services.settings.implement_review_rounds}" + ) + workflow, report = await review_implementation_once( + workflow, + issue_context=issue_context, + plan=plan, + pull_context=( + "The proposed pull request is the current uncommitted working-tree diff. " + "Review only that diff." + ), + services=services, + ) + workflow = replace( + workflow, + artifact=result.model_dump_json(), + review_json=report.model_dump_json(), + ) + await services.repository.save_workflow(workflow) + if not report.has_serious_findings: + break + if round_index == services.settings.implement_review_rounds - 1: + break + prompt = services.prompts.render( + "implementation_revision", + review=report_for_prompt(workflow.review_json), + development_environment=services.development.description, + ) + result = await services.opencode.resume( + session_id=required_session(workflow.primary_session_id), + prompt=prompt, + model=services.settings.implement_model, + variant=services.settings.implement_variant, + workspace=workflow.workspace_path, + schema_name="agent_result.json", + result_type=AgentResult, + ) + workflow = replace(workflow, artifact=result.model_dump_json()) + await services.repository.save_workflow(workflow) + return workflow, result, report + + +async def review_implementation_once( + workflow: Workflow, + *, + issue_context: str, + plan: str, + pull_context: str, + services: WorkflowServices, +) -> tuple[Workflow, ReviewReport]: + prompt = services.prompts.render( + "implementation_review", + issue_context=issue_context, + artifact=plan, + pull_context=pull_context, + ) + if workflow.reviewer_session_id: + report = await services.opencode.resume( + session_id=workflow.reviewer_session_id, + prompt=prompt, + model=services.settings.implement_model, + variant=services.settings.implement_variant, + workspace=workflow.workspace_path, + schema_name="review.json", + result_type=ReviewReport, + ) + return workflow, report + + session_id = await services.opencode.create_session( + workflow.workspace_path, "implementation-review" + ) + workflow = replace(workflow, reviewer_session_id=session_id) + await services.repository.save_workflow(workflow) + report = await services.opencode.resume( + session_id=session_id, + workspace=workflow.workspace_path, + prompt=prompt, + model=services.settings.implement_model, + variant=services.settings.implement_variant, + schema_name="review.json", + result_type=ReviewReport, + ) + return workflow, report diff --git a/src/agentci/workflows/services.py b/src/agentci/workflows/services.py new file mode 100644 index 0000000..f73f2b8 --- /dev/null +++ b/src/agentci/workflows/services.py @@ -0,0 +1,22 @@ +from __future__ import annotations + +from dataclasses import dataclass + +from agentci.config.settings import Settings +from agentci.engine.repository import Repository +from agentci.integrations.development import DevelopmentEnvironment +from agentci.integrations.git import Git +from agentci.integrations.gitea.client import Gitea +from agentci.integrations.opencode.client import OpenCode +from agentci.prompts.library import PromptLibrary + + +@dataclass(frozen=True) +class WorkflowServices: + settings: Settings + repository: Repository + gitea: Gitea + git: Git + opencode: OpenCode + prompts: PromptLibrary + development: DevelopmentEnvironment diff --git a/tests/test_app.py b/tests/test_app.py new file mode 100644 index 0000000..50ca4e0 --- /dev/null +++ b/tests/test_app.py @@ -0,0 +1,130 @@ +import asyncio +from types import SimpleNamespace + +import pytest +from httpx import ASGITransport, AsyncClient + +import agentci.api.app as app_module +import agentci.api.lifespan as lifespan_module + + +class BlockingWorker: + def __init__(self, events: list[str]) -> None: + self.events = events + self.started = asyncio.Event() + + async def run(self, stop: asyncio.Event) -> None: + self.events.append("worker-started") + self.started.set() + try: + await stop.wait() + except asyncio.CancelledError: + self.events.append(f"worker-cancelled:{stop.is_set()}") + raise + + +class FailingWorker: + def __init__(self) -> None: + self.started = asyncio.Event() + + async def run(self, _stop: asyncio.Event) -> None: + self.started.set() + raise RuntimeError("worker failed") + + +class FakeRuntime: + def __init__(self, worker: object, events: list[str]) -> None: + self.worker = worker + self.events = events + self.closed = False + + async def close(self) -> None: + self.events.append("runtime-closed") + self.closed = True + + +async def test_lifespan_starts_worker_cancels_it_and_closes_runtime( + monkeypatch: pytest.MonkeyPatch, +) -> None: + events: list[str] = [] + worker = BlockingWorker(events) + runtime = FakeRuntime(worker, events) + selected_settings = SimpleNamespace(name="selected") + built_with: list[object] = [] + configured: list[bool] = [] + + async def build(settings: object) -> FakeRuntime: + built_with.append(settings) + return runtime + + monkeypatch.setattr(lifespan_module, "build_runtime", build) + monkeypatch.setattr(lifespan_module, "configure_logging", lambda: configured.append(True)) + application = app_module.create_app(selected_settings) # type: ignore[arg-type] + + async with application.router.lifespan_context(application): + await worker.started.wait() + assert application.state.runtime is runtime + assert not runtime.closed + + assert built_with == [selected_settings] + assert configured == [True] + assert events == ["worker-started", "worker-cancelled:True", "runtime-closed"] + + +async def test_lifespan_closes_runtime_when_worker_task_fails( + monkeypatch: pytest.MonkeyPatch, +) -> None: + events: list[str] = [] + worker = FailingWorker() + runtime = FakeRuntime(worker, events) + + async def build(_settings: object) -> FakeRuntime: + return runtime + + monkeypatch.setattr(lifespan_module, "build_runtime", build) + monkeypatch.setattr(lifespan_module, "configure_logging", lambda: None) + application = app_module.create_app(SimpleNamespace()) # type: ignore[arg-type] + + with pytest.raises(RuntimeError, match="worker failed"): + async with application.router.lifespan_context(application): + await worker.started.wait() + + assert runtime.closed + assert events == ["runtime-closed"] + + +async def test_lifespan_propagates_runtime_startup_failure_without_starting_worker( + monkeypatch: pytest.MonkeyPatch, +) -> None: + configured: list[bool] = [] + + async def fail_build(_settings: object) -> None: + raise RuntimeError("database unavailable") + + monkeypatch.setattr(lifespan_module, "build_runtime", fail_build) + monkeypatch.setattr(lifespan_module, "configure_logging", lambda: configured.append(True)) + application = app_module.create_app(SimpleNamespace()) # type: ignore[arg-type] + + with pytest.raises(RuntimeError, match="database unavailable"): + async with application.router.lifespan_context(application): + pytest.fail("startup failure must prevent serving requests") + + assert configured == [True] + assert not hasattr(application.state, "runtime") + + +async def test_global_exception_handler_returns_safe_json() -> None: + application = app_module.create_app(SimpleNamespace()) # type: ignore[arg-type] + + @application.get("/explode") + async def explode() -> None: + raise RuntimeError("sensitive provider detail") + + async with AsyncClient( + transport=ASGITransport(app=application, raise_app_exceptions=False), + base_url="http://test", + ) as client: + response = await client.get("/explode") + + assert response.status_code == 500 + assert response.json() == {"detail": "Internal server error. See service logs for diagnostics."} diff --git a/tests/test_code_review.py b/tests/test_code_review.py index 019e3f0..ac0fe62 100644 --- a/tests/test_code_review.py +++ b/tests/test_code_review.py @@ -1,22 +1,30 @@ from pathlib import Path from types import SimpleNamespace +from typing import Any, cast -from agentci.domain.models import ( +from pydantic import BaseModel + +from agentci.engine.model import Workflow, WorkflowKind +from agentci.engine.run import JobRun +from agentci.workflows.model import ( AgentResult, - Job, - JobKind, + PlanArtifact, ReviewFinding, ReviewReport, ReviewSeverity, - Workflow, - WorkflowKind, ) -from agentci.workflows.code_review import CodeReviewLoop +from agentci.workflows.review import ( + review_implementation_loop, + review_implementation_once, + review_plan_loop, + review_plan_once, +) +from agentci.workflows.services import WorkflowServices -def serious_report() -> ReviewReport: +def serious_report(summary: str = "Needs work") -> ReviewReport: return ReviewReport( - summary="Needs work", + summary=summary, findings=[ ReviewFinding( severity=ReviewSeverity.MAJOR, @@ -28,101 +36,352 @@ def serious_report() -> ReviewReport: ) -class FakeOpenCode: - def __init__(self, reports: list[ReviewReport]) -> None: - self.reports = iter(reports) - self.reviews = 0 - self.revisions = 0 - - async def create_session(self, *_args): - return "reviewer" - - async def resume(self, **kwargs): - if kwargs["result_type"] is ReviewReport: - self.reviews += 1 - return next(self.reports) - self.revisions += 1 - return AgentResult(summary_markdown=f"revision {self.revisions}", tests=[]) +def clean_report() -> ReviewReport: + return ReviewReport(summary="Ready", findings=[]) -class FakeStorage: - async def update_job(self, *_args, **_kwargs): - return None - - async def update_workflow(self, *_args, **_kwargs): - return None - - -class FakePrompts: - def render(self, name, **_kwargs): - return name - - -def objects(rounds: int, reports: list[ReviewReport]): - opencode = FakeOpenCode(reports) - settings = SimpleNamespace( - implement_review_rounds=rounds, - implement_model="model", - implement_variant="high", +def minor_report() -> ReviewReport: + return ReviewReport( + summary="Optional improvement", + findings=[ + ReviewFinding( + severity=ReviewSeverity.MINOR, + title="Clarify wording", + detail="The wording could be clearer.", + recommendation="Tighten it when convenient.", + ) + ], ) - deps = SimpleNamespace( - settings=settings, - opencode=opencode, - storage=FakeStorage(), - prompts=FakePrompts(), - development=SimpleNamespace(description="python"), - ) - workflow = Workflow( + + +class RecordingOpenCode: + def __init__(self, responses: list[BaseModel]) -> None: + self.responses = list(responses) + self.created_sessions: list[tuple[Path, str]] = [] + self.resume_calls: list[dict[str, Any]] = [] + + async def create_session(self, workspace: Path, title: str) -> str: + self.created_sessions.append((workspace, title)) + return f"{title}-session" + + async def resume(self, **values: Any) -> BaseModel: + self.resume_calls.append(values) + response = self.responses.pop(0) + assert isinstance(response, values["result_type"]) + return response + + +class RecordingRepository: + def __init__(self) -> None: + self.saved_workflows: list[Workflow] = [] + + async def save_workflow(self, workflow: Workflow) -> None: + self.saved_workflows.append(workflow) + + +class RecordingRun(JobRun): + def __init__(self) -> None: + self.stages: list[str] = [] + + async def stage(self, stage: str) -> None: + self.stages.append(stage) + + +class RecordingPrompts: + def __init__(self) -> None: + self.calls: list[tuple[str, dict[str, str]]] = [] + + def render(self, name: str, **values: str) -> str: + self.calls.append((name, values)) + return f"rendered {name}" + + +def workflow(*, reviewer_session_id: str | None = None) -> Workflow: + return Workflow( id="flow", kind=WorkflowKind.IMPLEMENT, repo_owner="org", repo_name="repo", issue_number=1, - workspace_path=Path("."), + workspace_path=Path("/workspace/repo"), base_sha="abc", - primary_session_id="primary", + primary_session_id="primary-session", + reviewer_session_id=reviewer_session_id, ) - job = Job( - id="job", - kind=JobKind.IMPLEMENT, - target_key="target", - repo_owner="org", - repo_name="repo", - issue_number=1, - pr_number=None, - requester="alice", - message="", - comment_id=1, - ) - return CodeReviewLoop(deps), opencode, workflow, job # type: ignore[arg-type] -async def test_stops_after_clean_second_review() -> None: - clean = ReviewReport(summary="Ready", findings=[]) - loop, opencode, workflow, job = objects(4, [serious_report(), clean]) - _, report = await loop.run( - job, - workflow, +def objects( + responses: list[BaseModel], + *, + plan_rounds: int = 4, + implementation_rounds: int = 3, +) -> tuple[ + RecordingOpenCode, + RecordingRepository, + RecordingPrompts, + WorkflowServices, +]: + opencode = RecordingOpenCode(responses) + repository = RecordingRepository() + prompts = RecordingPrompts() + services = cast( + WorkflowServices, + SimpleNamespace( + settings=SimpleNamespace( + plan_review_rounds=plan_rounds, + plan_model="provider/plan", + plan_variant="high", + implement_review_rounds=implementation_rounds, + implement_model="provider/implement", + implement_variant="high", + ), + opencode=opencode, + repository=repository, + prompts=prompts, + development=SimpleNamespace(description="Python 3.13"), + ), + ) + return opencode, repository, prompts, services + + +async def test_implementation_loop_persists_reviewed_revision_and_stops_clean() -> None: + revised = AgentResult(summary_markdown="revision 1", tests=["pytest: passed"]) + opencode, repository, prompts, services = objects( + [serious_report(), revised, clean_report()], implementation_rounds=4 + ) + run = RecordingRun() + original = workflow() + + updated, result, report = await review_implementation_loop( + original, "issue context", "canonical plan", AgentResult(summary_markdown="initial", tests=[]), + run, + services, ) + + assert result == revised + assert report == clean_report() + assert original.reviewer_session_id is None + assert updated.reviewer_session_id == "implementation-review-session" + assert updated.artifact == revised.model_dump_json() + assert updated.review_json == clean_report().model_dump_json() + assert repository.saved_workflows[-1] == updated + assert repository.saved_workflows[0].reviewer_session_id == ("implementation-review-session") + assert run.stages == [ + "reviewing implementation 1/4", + "reviewing implementation 2/4", + ] + assert opencode.created_sessions == [(original.workspace_path, "implementation-review")] + assert [call["session_id"] for call in opencode.resume_calls] == [ + "implementation-review-session", + "primary-session", + "implementation-review-session", + ] + assert [call["result_type"] for call in opencode.resume_calls] == [ + ReviewReport, + AgentResult, + ReviewReport, + ] + assert [name for name, _ in prompts.calls] == [ + "implementation_review", + "implementation_revision", + "implementation_review", + ] + assert opencode.responses == [] + + +async def test_implementation_loop_never_makes_unreviewed_final_revision() -> None: + final_report = serious_report("Still failing after the last review") + opencode, repository, _, services = objects( + [ + serious_report("round 1"), + AgentResult(summary_markdown="revision 1", tests=[]), + serious_report("round 2"), + AgentResult(summary_markdown="revision 2", tests=[]), + final_report, + ], + implementation_rounds=3, + ) + run = RecordingRun() + + updated, result, report = await review_implementation_loop( + workflow(), + "issue context", + "canonical plan", + AgentResult(summary_markdown="initial", tests=[]), + run, + services, + ) + + assert result.summary_markdown == "revision 2" + assert report is final_report + assert updated.artifact == result.model_dump_json() + assert updated.review_json == final_report.model_dump_json() + assert repository.saved_workflows[-1] == updated + assert [call["result_type"] for call in opencode.resume_calls].count(ReviewReport) == 3 + assert [call["result_type"] for call in opencode.resume_calls].count(AgentResult) == 2 + assert run.stages == [ + "reviewing implementation 1/3", + "reviewing implementation 2/3", + "reviewing implementation 3/3", + ] + assert opencode.responses == [] + + +async def test_implementation_review_once_reuses_existing_reviewer_session() -> None: + opencode, repository, prompts, services = objects([clean_report()]) + existing = workflow(reviewer_session_id="existing-reviewer") + + updated, report = await review_implementation_once( + existing, + issue_context="issue context", + plan="canonical plan", + pull_context="pull request context", + services=services, + ) + + assert updated is existing + assert report == clean_report() + assert opencode.created_sessions == [] + assert repository.saved_workflows == [] + assert opencode.resume_calls == [ + { + "session_id": "existing-reviewer", + "prompt": "rendered implementation_review", + "model": "provider/implement", + "variant": "high", + "workspace": existing.workspace_path, + "schema_name": "review.json", + "result_type": ReviewReport, + } + ] + assert prompts.calls == [ + ( + "implementation_review", + { + "issue_context": "issue context", + "artifact": "canonical plan", + "pull_context": "pull request context", + }, + ) + ] + + +async def test_plan_loop_revises_serious_finding_then_persists_clean_result() -> None: + revised = PlanArtifact(plan_markdown="Revised plan") + opencode, repository, prompts, services = objects( + [serious_report(), revised, clean_report()], plan_rounds=4 + ) + run = RecordingRun() + original = workflow() + initial = PlanArtifact(plan_markdown="Initial plan") + + updated, artifact, report = await review_plan_loop( + original, "issue context", initial, run, services + ) + + assert artifact is revised + assert report == clean_report() + assert updated.reviewer_session_id == "plan-review-session" + assert updated.artifact == "Revised plan" + assert updated.review_json == clean_report().model_dump_json() + assert repository.saved_workflows[-1] == updated + assert run.stages == ["reviewing plan 1/4", "reviewing plan 2/4"] + assert [call["session_id"] for call in opencode.resume_calls] == [ + "plan-review-session", + "primary-session", + "plan-review-session", + ] + assert [name for name, _ in prompts.calls] == [ + "plan_review", + "plan_revision", + "plan_review", + ] + assert opencode.responses == [] + + +async def test_plan_loop_stops_at_round_boundary_without_unreviewed_revision() -> None: + final_report = serious_report("round 2") + opencode, repository, _, services = objects( + [ + serious_report("round 1"), + PlanArtifact(plan_markdown="Only revision"), + final_report, + ], + plan_rounds=2, + ) + run = RecordingRun() + + updated, artifact, report = await review_plan_loop( + workflow(), + "issue context", + PlanArtifact(plan_markdown="Initial plan"), + run, + services, + ) + + assert artifact.plan_markdown == "Only revision" + assert report is final_report + assert updated.artifact == "Only revision" + assert updated.review_json == final_report.model_dump_json() + assert repository.saved_workflows[-1] == updated + assert [call["result_type"] for call in opencode.resume_calls] == [ + ReviewReport, + PlanArtifact, + ReviewReport, + ] + assert run.stages == ["reviewing plan 1/2", "reviewing plan 2/2"] + assert opencode.responses == [] + + +async def test_plan_review_once_creates_and_persists_reviewer_session() -> None: + opencode, repository, prompts, services = objects([clean_report()]) + original = workflow() + + updated, report = await review_plan_once( + original, + "issue context", + PlanArtifact(plan_markdown="Plan body"), + services, + ) + + assert report == clean_report() + assert updated is not original + assert original.reviewer_session_id is None + assert updated.reviewer_session_id == "plan-review-session" + assert repository.saved_workflows == [updated] + assert opencode.created_sessions == [(original.workspace_path, "plan-review")] + assert opencode.resume_calls[0]["session_id"] == "plan-review-session" + assert opencode.resume_calls[0]["result_type"] is ReviewReport + assert prompts.calls == [ + ( + "plan_review", + {"context": "issue context", "artifact": "Plan body"}, + ) + ] + + +async def test_minor_findings_end_review_loop_without_revision() -> None: + opencode, repository, _, services = objects([minor_report()], implementation_rounds=5) + run = RecordingRun() + initial = AgentResult(summary_markdown="initial", tests=[]) + + updated, result, report = await review_implementation_loop( + workflow(), + "issue context", + "canonical plan", + initial, + run, + services, + ) + + assert result is initial + assert report == minor_report() assert not report.has_serious_findings - assert opencode.reviews == 2 - assert opencode.revisions == 1 - - -async def test_does_not_make_unreviewed_final_revision() -> None: - loop, opencode, workflow, job = objects( - 3, [serious_report(), serious_report(), serious_report()] - ) - _, report = await loop.run( - job, - workflow, - "issue context", - "canonical plan", - AgentResult(summary_markdown="initial", tests=[]), - ) - assert report.has_serious_findings - assert opencode.reviews == 3 - assert opencode.revisions == 2 + assert updated.artifact == initial.model_dump_json() + assert updated.review_json == minor_report().model_dump_json() + assert repository.saved_workflows[-1] == updated + assert [call["result_type"] for call in opencode.resume_calls] == [ReviewReport] + assert run.stages == ["reviewing implementation 1/5"] diff --git a/tests/test_codegraph.py b/tests/test_codegraph.py index d8f063d..7d866b7 100644 --- a/tests/test_codegraph.py +++ b/tests/test_codegraph.py @@ -1,13 +1,17 @@ from pathlib import Path -from agentci.adapters.codegraph import CodeGraphClient +import pytest + +from agentci.integrations.codegraph import CodeGraph, CodeGraphError class FakeProcess: - returncode = 0 + def __init__(self, returncode: int = 0, stderr: bytes = b"") -> None: + self.returncode = returncode + self.stderr = stderr async def communicate(self) -> tuple[bytes, bytes]: - return b"", b"" + return b"", self.stderr async def test_initializes_incomplete_index_and_excludes_it_from_git( @@ -23,11 +27,11 @@ async def test_initializes_incomplete_index_and_excludes_it_from_git( return FakeProcess() monkeypatch.setattr( - "agentci.adapters.codegraph.asyncio.create_subprocess_exec", + "agentci.integrations.codegraph.asyncio.create_subprocess_exec", create_subprocess_exec, ) - await CodeGraphClient().prepare(workspace) + await CodeGraph().prepare(workspace) assert calls == [("codegraph", "init", str(workspace))] assert (workspace / ".git" / "info" / "exclude").read_text() == ".codegraph/\n" @@ -49,11 +53,86 @@ async def test_syncs_an_existing_index_without_duplicating_exclude( return FakeProcess() monkeypatch.setattr( - "agentci.adapters.codegraph.asyncio.create_subprocess_exec", + "agentci.integrations.codegraph.asyncio.create_subprocess_exec", create_subprocess_exec, ) - await CodeGraphClient().prepare(workspace) + await CodeGraph().prepare(workspace) assert calls == [("codegraph", "sync", str(workspace))] assert exclude.read_text() == "# local excludes\n.codegraph/\n" + + +@pytest.mark.parametrize( + ("initial", "expected"), + [ + ("*.pyc", "*.pyc\n.codegraph/\n"), + ("# .codegraph/ is documented here\n", "# .codegraph/ is documented here\n.codegraph/\n"), + (" .codegraph/ \n", " .codegraph/ \n"), + ], +) +async def test_handles_exclude_file_boundaries( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + initial: str, + expected: str, +) -> None: + workspace = tmp_path / "repo" + exclude = workspace / ".git" / "info" / "exclude" + exclude.parent.mkdir(parents=True) + exclude.write_text(initial) + + async def create_subprocess_exec(*_args, **_kwargs): + return FakeProcess() + + monkeypatch.setattr( + "agentci.integrations.codegraph.asyncio.create_subprocess_exec", + create_subprocess_exec, + ) + + await CodeGraph().prepare(workspace) + + assert exclude.read_text() == expected + + +async def test_reports_missing_executable(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + workspace = tmp_path / "repo" + workspace.mkdir() + + async def create_subprocess_exec(*_args, **_kwargs): + raise FileNotFoundError(2, "No such file or directory", "codegraph") + + monkeypatch.setattr( + "agentci.integrations.codegraph.asyncio.create_subprocess_exec", + create_subprocess_exec, + ) + + with pytest.raises(CodeGraphError, match="Could not run CodeGraph:.*codegraph") as raised: + await CodeGraph().prepare(workspace) + + assert isinstance(raised.value.__cause__, FileNotFoundError) + assert (workspace / ".git" / "info" / "exclude").read_text() == ".codegraph/\n" + + +async def test_reports_nonzero_exit_with_bounded_non_utf8_stderr( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + workspace = tmp_path / "repo" + workspace.mkdir() + stderr = b"discarded-prefix" + (b"x" * 1200) + b"\xff useful-tail" + + async def create_subprocess_exec(*_args, **_kwargs): + return FakeProcess(returncode=7, stderr=stderr) + + monkeypatch.setattr( + "agentci.integrations.codegraph.asyncio.create_subprocess_exec", + create_subprocess_exec, + ) + + with pytest.raises(CodeGraphError, match="codegraph init failed") as raised: + await CodeGraph().prepare(workspace) + + message = str(raised.value) + assert "discarded-prefix" not in message + assert "useful-tail" in message + assert len(message.removeprefix("codegraph init failed: ")) == 1000 diff --git a/tests/test_commands.py b/tests/test_commands.py index 1dc320c..343dd8a 100644 --- a/tests/test_commands.py +++ b/tests/test_commands.py @@ -1,7 +1,7 @@ import pytest -from agentci.domain.commands import CommandError, parse_command, resolve_job_kind -from agentci.domain.models import CommandName, JobKind +from agentci.engine.commands import CommandError, parse_command, resolve_job_kind +from agentci.engine.model import CommandName, JobKind def test_ignores_non_commands() -> None: @@ -14,8 +14,7 @@ def test_all_commands_accept_messages_after_any_number_of_lines( name: CommandName, line_breaks: int ) -> None: command = parse_command( - f"/agent {name.value}{'\n' * line_breaks}" - "focus on the API\nand add tests" + f"/agent {name.value}{'\n' * line_breaks}focus on the API\nand add tests" ) assert command is not None assert command.name is name @@ -26,10 +25,7 @@ def test_all_commands_accept_messages_after_any_number_of_lines( def test_all_commands_accept_crlf_separated_multiline_messages( name: CommandName, ) -> None: - command = parse_command( - f"/agent {name.value}\r\n\r\n\r\n" - "focus on the API\r\nand add tests" - ) + command = parse_command(f"/agent {name.value}\r\n\r\n\r\nfocus on the API\r\nand add tests") assert command is not None assert command.name is name assert command.message == "focus on the API\r\nand add tests" diff --git a/tests/test_config.py b/tests/test_config.py index 91c7107..9ba8381 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -1,61 +1,150 @@ +import os +from pathlib import Path + import pytest from pydantic import ValidationError -from agentci.config import Settings +from agentci.config.settings import Settings + + +@pytest.fixture(autouse=True) +def isolate_agentci_environment(monkeypatch: pytest.MonkeyPatch) -> None: + for name in tuple(os.environ): + if name.startswith("AGENTCI_"): + monkeypatch.delenv(name) + + +def settings(**overrides: object) -> Settings: + return Settings(_env_file=None, **overrides) # type: ignore[arg-type,call-arg] + + +def test_reads_values_from_an_isolated_environment(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("AGENTCI_INSTALL_SCRIPTS", "python,dotnet") + monkeypatch.setenv("AGENTCI_MAX_CONCURRENT_JOBS", "7") + + value = settings() + + assert value.install_scripts == ["python", "dotnet"] + assert value.max_concurrent_jobs == 7 + + +@pytest.mark.parametrize("field", ["gitea_url", "opencode_url"]) +def test_normalizes_service_urls(field: str) -> None: + value = settings(**{field: "https://service.example/base///"}) + + assert getattr(value, field) == "https://service.example/base" + + +@pytest.mark.parametrize( + ("field", "boundaries"), + [ + ("plan_review_rounds", (1, 20)), + ("implement_review_rounds", (1, 20)), + ("turn_timeout_seconds", (60,)), + ("install_script_timeout_seconds", (1,)), + ("worker_poll_seconds", (0.1,)), + ("max_concurrent_jobs", (1, 32)), + ], +) +def test_accepts_documented_numeric_boundaries( + field: str, boundaries: tuple[int | float, ...] +) -> None: + for boundary in boundaries: + assert getattr(settings(**{field: boundary}), field) == boundary + + +@pytest.mark.parametrize( + ("field", "value"), + [ + ("plan_review_rounds", 0), + ("plan_review_rounds", 21), + ("implement_review_rounds", 0), + ("implement_review_rounds", 21), + ("turn_timeout_seconds", 59), + ("install_script_timeout_seconds", 0), + ("worker_poll_seconds", 0.09), + ("max_concurrent_jobs", 0), + ("max_concurrent_jobs", 33), + ], +) +def test_rejects_values_outside_numeric_boundaries(field: str, value: int | float) -> None: + with pytest.raises(ValidationError): + settings(**{field: value}) + + +def test_reads_and_strips_secret_files(tmp_path: Path) -> None: + token_file = tmp_path / "token" + webhook_file = tmp_path / "webhook" + password_file = tmp_path / "password" + token_file.write_text(" gitea-token\n") + webhook_file.write_text(" webhook-secret \n") + password_file.write_text("opencode-password\n") + value = settings( + gitea_token_file=token_file, + webhook_secret_file=webhook_file, + opencode_server_password_file=password_file, + ) + + assert value.gitea_token == "gitea-token" + assert value.webhook_secret == b"webhook-secret" + assert value.opencode_server_password == "opencode-password" + + +def test_missing_secret_file_has_actionable_error(tmp_path: Path) -> None: + missing = tmp_path / "missing-token" + value = settings(gitea_token_file=missing) + + with pytest.raises(RuntimeError, match="Cannot read Gitea token") as raised: + _ = value.gitea_token + + assert str(missing) in str(raised.value) + + +def test_empty_secret_file_is_rejected(tmp_path: Path) -> None: + empty = tmp_path / "webhook" + empty.write_text(" \n") + value = settings(webhook_secret_file=empty) + + with pytest.raises(RuntimeError, match="webhook secret file") as raised: + _ = value.webhook_secret + + assert str(empty) in str(raised.value) + + +def test_derived_state_paths_follow_data_directory(tmp_path: Path) -> None: + data_dir = tmp_path / "state" + value = settings(data_dir=data_dir) + + assert value.database_path == data_dir / "agentci.sqlite3" + assert value.workspaces_dir == data_dir / "workspaces" def test_parses_comma_delimited_install_scripts() -> None: - settings = Settings( - _env_file=None, # type: ignore[call-arg] - install_scripts=" python, dotnet, company-tools, ", - ) + value = settings(install_scripts=" python, dotnet, company-tools, ") - assert settings.install_scripts == ["python", "dotnet", "company-tools"] + assert value.install_scripts == ["python", "dotnet", "company-tools"] @pytest.mark.parametrize("value", ["../script", "tools/setup", "python,python"]) def test_rejects_unsafe_or_duplicate_install_scripts(value: str) -> None: with pytest.raises(ValidationError): - Settings(_env_file=None, install_scripts=value) # type: ignore[call-arg] + settings(install_scripts=value) -def test_empty_install_scripts_disable_setup() -> None: - settings = Settings( - _env_file=None, # type: ignore[call-arg] - install_scripts="", +@pytest.mark.parametrize("value", ["", None, []]) +def test_empty_install_scripts_disable_setup(value: object) -> None: + assert settings(install_scripts=value).install_scripts == [] + + +def test_agent_defaults_select_expected_capacity_and_research_models() -> None: + value = settings() + + assert value.max_concurrent_jobs == 2 + assert (value.explore_model, value.explore_variant) == ( + "openai/gpt-5.6-luna", + "low", ) - assert settings.install_scripts == [] - - -def test_defaults_research_variant_to_high(monkeypatch) -> None: - monkeypatch.delenv("AGENTCI_RESEARCH_VARIANT", raising=False) - settings = Settings(_env_file=None) # type: ignore[call-arg] - assert settings.research_variant == "high" - - -def test_defaults_explore_agent_to_luna_low() -> None: - settings = Settings(_env_file=None) # type: ignore[call-arg] - assert settings.explore_model == "openai/gpt-5.6-luna" - assert settings.explore_variant == "low" - - -def test_defaults_to_two_concurrent_jobs() -> None: - settings = Settings(_env_file=None) # type: ignore[call-arg] - assert settings.max_concurrent_jobs == 2 - - -@pytest.mark.parametrize("value", [0, 33]) -def test_rejects_unsafe_job_concurrency(value: int) -> None: - with pytest.raises(ValidationError): - Settings(_env_file=None, max_concurrent_jobs=value) # type: ignore[call-arg] - - -def test_reads_comma_delimited_install_scripts_from_environment(monkeypatch) -> None: - monkeypatch.setenv("AGENTCI_INSTALL_SCRIPTS", "python,dotnet") - - settings = Settings(_env_file=None) # type: ignore[call-arg] - - assert settings.install_scripts == ["python", "dotnet"] + assert value.research_variant == "high" @pytest.mark.parametrize( @@ -63,4 +152,4 @@ def test_reads_comma_delimited_install_scripts_from_environment(monkeypatch) -> ) def test_requires_provider_qualified_opencode_models(field: str) -> None: with pytest.raises(ValidationError, match="provider/model"): - Settings(_env_file=None, **{field: "model-only"}) # type: ignore[call-arg] + settings(**{field: "model-only"}) diff --git a/tests/test_context.py b/tests/test_context.py index 9f0ed94..f308952 100644 --- a/tests/test_context.py +++ b/tests/test_context.py @@ -1,8 +1,12 @@ -from agentci.adapters.gitea_models import CommentInfo, IssueInfo -from agentci.workflows.context import ContextBuilder +from typing import cast + +from agentci.engine.repository import Repository +from agentci.integrations.gitea.client import Gitea +from agentci.integrations.gitea.models import CommentInfo, IssueInfo, PullRequestInfo +from agentci.workflows.context import build_issue_context, build_pull_request_context -class FakeGitea: +class FakeIssueGitea: async def issue(self, *_args): return IssueInfo(number=2, title="Broken widget", body="It fails.", state="open") @@ -13,15 +17,199 @@ class FakeGitea: ] -class FakeStorage: +class FakeRepository: async def operational_comment_ids(self, *_args): return {2} +class FakePullRequestGitea: + async def pull_request(self, *_args): + return PullRequestInfo( + number=3, + title="Fix widget", + body="Fixes the failure.", + state="open", + merged=False, + base_branch="main", + head_branch="fix-widget", + head_sha="abcdef1234567890", + head_owner="alice", + head_repo="repo", + ) + + async def issue_comments(self, *_args): + return [CommentInfo(3, "bob", "Please add a test.", "2026-01-03")] + + async def pull_reviews(self, *_args): + return [ + { + "id": 4, + "user": {"login": "carol"}, + "state": "REQUEST_CHANGES", + "body": "One issue remains.", + } + ] + + async def review_comments(self, *_args): + return [ + { + "path": "src/widget.py", + "new_position": 12, + "body": "Handle the empty value.", + } + ] + + async def pull_commits(self, *_args): + return [{"sha": "abcdef1234567890", "commit": {"message": "Fix widget"}}] + + +class EmptyIssueGitea: + async def issue(self, *_args): + return IssueInfo(number=5, title="Empty issue", body="", state="open") + + async def issue_comments(self, *_args): + return [] + + +class EmptyRepository: + async def operational_comment_ids(self, *_args): + return set() + + +class EmptyPullRequestGitea: + async def pull_request(self, *_args): + return PullRequestInfo( + number=6, + title="Empty pull request", + body="", + state="open", + merged=False, + base_branch="main", + head_branch="empty", + head_sha="1234567890abcdef", + head_owner="alice", + head_repo="repo", + ) + + async def issue_comments(self, *_args): + return [] + + async def pull_reviews(self, *_args): + return [] + + async def pull_commits(self, *_args): + return [] + + async def review_comments(self, *_args): + raise AssertionError("review comments should not be requested without reviews") + + +class OrderedPullRequestGitea(FakePullRequestGitea): + async def issue_comments(self, *_args): + return [ + CommentInfo(1, "alice", "First timeline comment", "2026-01-01"), + CommentInfo(2, "bob", "Second timeline comment", "2026-01-02"), + ] + + async def pull_reviews(self, *_args): + return [ + { + "id": 10, + "user": {"login": "carol"}, + "state": "REQUEST_CHANGES", + "body": "First formal review", + }, + { + "id": 11, + "user": {"login": "dave"}, + "state": "APPROVED", + "body": "Second formal review", + }, + ] + + async def review_comments(self, *_args): + review_id = _args[-1] + if review_id == 10: + return [ + { + "path": "src/old.py", + "new_position": None, + "old_position": 21, + "body": "Old-side position", + } + ] + return [ + { + "path": "src/new.py", + "new_position": 34, + "body": "New-side position", + } + ] + + async def pull_commits(self, *_args): + return [ + {"sha": "111111111111aaaa", "commit": {"message": "First commit"}}, + {"sha": "222222222222bbbb", "commit": {"message": "Second commit"}}, + ] + + async def test_issue_context_excludes_operational_comments() -> None: - builder = ContextBuilder(FakeGitea(), FakeStorage()) # type: ignore[arg-type] - context = await builder.issue_context("org", "repo", 2) + context = await build_issue_context( + cast(Gitea, FakeIssueGitea()), + cast(Repository, FakeRepository()), + "org", + "repo", + 2, + ) + assert "Broken widget" in context assert "Details" in context assert "Agent job queued" not in context + +async def test_pull_request_context_includes_feedback_and_commits() -> None: + pull, context = await build_pull_request_context( + cast(Gitea, FakePullRequestGitea()), "org", "repo", 3 + ) + + assert pull.number == 3 + assert "Please add a test." in context + assert "One issue remains." in context + assert "`src/widget.py:12`: Handle the empty value." in context + assert "abcdef123456 Fix widget" in context + + +async def test_issue_context_labels_empty_body_and_discussion() -> None: + context = await build_issue_context( + cast(Gitea, EmptyIssueGitea()), + cast(Repository, EmptyRepository()), + "org", + "repo", + 5, + ) + + assert "## Issue body\n(empty)" in context + assert "## Discussion\n(none)" in context + + +async def test_pull_request_context_labels_empty_sections() -> None: + _, context = await build_pull_request_context( + cast(Gitea, EmptyPullRequestGitea()), "org", "repo", 6 + ) + + assert "## Pull request body\n(empty)" in context + assert "## Commits\n(none)" in context + assert "## Timeline discussion\n(none)" in context + assert "## Formal and inline reviews\n(none)" in context + + +async def test_pull_request_context_preserves_source_order_and_positions() -> None: + _, context = await build_pull_request_context( + cast(Gitea, OrderedPullRequestGitea()), "org", "repo", 3 + ) + + assert context.index("First timeline comment") < context.index("Second timeline comment") + assert context.index("111111111111 First commit") < context.index("222222222222 Second commit") + assert context.index("Review 10 by carol") < context.index("Review 11 by dave") + assert "`src/old.py:21`: Old-side position" in context + assert "`src/new.py:34`: New-side position" in context diff --git a/tests/test_development.py b/tests/test_development.py index d6b665b..0ca47ea 100644 --- a/tests/test_development.py +++ b/tests/test_development.py @@ -3,7 +3,7 @@ from pathlib import Path import pytest -from agentci.adapters.development import ( +from agentci.integrations.development import ( DevelopmentEnvironment, DevelopmentEnvironmentError, ) @@ -37,7 +37,7 @@ async def test_runs_custom_scripts_in_order_with_sanitized_environment( development = environment(tmp_path, ["first", "second"]) script( development.scripts_dir / "first", - "printf 'first:%s:%s\\n' \"$DEV_TOOLS_DIR\" \"${AGENTCI_SECRET-unset}\" >> order", + 'printf \'first:%s:%s\\n\' "$DEV_TOOLS_DIR" "${AGENTCI_SECRET-unset}" >> order', ) script(development.scripts_dir / "second", "printf 'second\\n' >> order") monkeypatch.setenv("AGENTCI_SECRET", "must-not-leak") @@ -112,6 +112,76 @@ async def test_reports_script_failure_output(tmp_path) -> None: await development.prepare(workspace) +async def test_reports_missing_install_script(tmp_path: Path) -> None: + workspace = tmp_path / "workspace" + workspace.mkdir() + development = environment(tmp_path, ["missing"]) + + with pytest.raises( + DevelopmentEnvironmentError, + match=r"Install script 'missing' was not found", + ): + await development.prepare(workspace) + + +async def test_stops_before_second_script_after_failure(tmp_path: Path) -> None: + workspace = tmp_path / "workspace" + workspace.mkdir() + development = environment(tmp_path, ["first", "second"]) + script(development.scripts_dir / "first", "printf 'first\n' > first-ran; exit 3") + script(development.scripts_dir / "second", "printf 'second\n' > second-ran") + + with pytest.raises(DevelopmentEnvironmentError, match="exited with 3"): + await development.prepare(workspace) + + assert (workspace / "first-ran").read_text() == "first\n" + assert not (workspace / "second-ran").exists() + + +async def test_failure_output_keeps_only_bounded_tail(tmp_path: Path) -> None: + workspace = tmp_path / "workspace" + workspace.mkdir() + development = environment(tmp_path, ["verbose"]) + script( + development.scripts_dir / "verbose", + "printf 'discarded-prefix' >&2; " + 'i=0; while [ "$i" -lt 2100 ]; do printf x >&2; i=$((i + 1)); done; ' + "printf 'useful-tail' >&2; exit 9", + ) + + with pytest.raises(DevelopmentEnvironmentError) as raised: + await development.prepare(workspace) + + message = str(raised.value) + assert "discarded-prefix" not in message + assert message.endswith("useful-tail") + + +async def test_wraps_subprocess_start_error( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + workspace = tmp_path / "workspace" + workspace.mkdir() + development = environment(tmp_path, ["broken"]) + script(development.scripts_dir / "broken", "true") + + async def create_subprocess_exec(*_args, **_kwargs): + raise OSError("exec unavailable") + + monkeypatch.setattr( + "agentci.integrations.development.asyncio.create_subprocess_exec", + create_subprocess_exec, + ) + + with pytest.raises( + DevelopmentEnvironmentError, + match="Could not run install script 'broken': exec unavailable", + ) as raised: + await development.prepare(workspace) + + assert isinstance(raised.value.__cause__, OSError) + + async def test_times_out_install_script(tmp_path) -> None: workspace = tmp_path / "workspace" workspace.mkdir() diff --git a/tests/test_git.py b/tests/test_git.py new file mode 100644 index 0000000..63354b5 --- /dev/null +++ b/tests/test_git.py @@ -0,0 +1,247 @@ +import asyncio +from collections.abc import Sequence +from pathlib import Path +from typing import Any + +import pytest + +from agentci.integrations.git import Git, GitError + + +class FakeProcess: + def __init__( + self, + stdout: bytes = b"", + stderr: bytes = b"", + returncode: int = 0, + ) -> None: + self.stdout = stdout + self.stderr = stderr + self.returncode = returncode + + async def communicate(self) -> tuple[bytes, bytes]: + return self.stdout, self.stderr + + +class SubprocessRecorder: + def __init__(self, outcomes: Sequence[FakeProcess | OSError]) -> None: + self.outcomes = list(outcomes) + self.calls: list[tuple[tuple[Any, ...], dict[str, Any]]] = [] + + async def __call__(self, *args: Any, **kwargs: Any) -> FakeProcess: + self.calls.append((args, kwargs)) + outcome = self.outcomes.pop(0) + if isinstance(outcome, OSError): + raise outcome + return outcome + + @property + def commands(self) -> list[tuple[Any, ...]]: + return [args for args, _ in self.calls] + + +def git_client(tmp_path: Path) -> Git: + return Git( + gitea_url="https://git.example.test/", + username="agent-user", + token="secret-token", + askpass_path=tmp_path / "askpass.sh", + commit_name="Agent CI", + commit_email="agent@example.test", + ) + + +def install_recorder( + monkeypatch: pytest.MonkeyPatch, + outcomes: Sequence[FakeProcess | OSError], +) -> SubprocessRecorder: + recorder = SubprocessRecorder(outcomes) + monkeypatch.setattr(asyncio, "create_subprocess_exec", recorder) + return recorder + + +async def test_clone_uses_authenticated_remote_and_returns_head( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + for name in ( + "GIT_ASKPASS", + "GIT_TERMINAL_PROMPT", + "AGENTCI_GIT_USERNAME", + "AGENTCI_GIT_PASSWORD", + ): + monkeypatch.delenv(name, raising=False) + recorder = install_recorder( + monkeypatch, + [FakeProcess(), FakeProcess(stdout=b"abc123\n")], + ) + destination = tmp_path / "workspaces" / "repo" + + sha = await git_client(tmp_path).clone("org", "repo", "main", destination) + + assert sha == "abc123" + assert destination.parent.is_dir() + assert recorder.commands == [ + ( + "git", + "clone", + "--branch", + "main", + "--single-branch", + "https://git.example.test/org/repo.git", + str(destination), + ), + ("git", "rev-parse", "HEAD"), + ] + clone_kwargs = recorder.calls[0][1] + assert clone_kwargs["cwd"] == destination.parent + assert clone_kwargs["stdout"] is asyncio.subprocess.PIPE + assert clone_kwargs["stderr"] is asyncio.subprocess.PIPE + assert ( + clone_kwargs["env"] + | { + "GIT_ASKPASS": str(tmp_path / "askpass.sh"), + "GIT_TERMINAL_PROMPT": "0", + "AGENTCI_GIT_USERNAME": "agent-user", + "AGENTCI_GIT_PASSWORD": "secret-token", + } + == clone_kwargs["env"] + ) + current_sha_environment = recorder.calls[1][1]["env"] + assert "AGENTCI_GIT_PASSWORD" not in current_sha_environment + + +async def test_sync_branch_resets_and_cleans_before_returning_sha( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + recorder = install_recorder( + monkeypatch, + [FakeProcess(), FakeProcess(), FakeProcess(), FakeProcess(stdout=b"new-sha\n")], + ) + workspace = tmp_path / "repo" + + sha = await git_client(tmp_path).sync_branch(workspace, "feature") + + assert sha == "new-sha" + assert recorder.commands == [ + ( + "git", + "fetch", + "origin", + "refs/heads/feature:refs/remotes/origin/feature", + ), + ("git", "reset", "--hard", "origin/feature"), + ("git", "clean", "-fd"), + ("git", "rev-parse", "HEAD"), + ] + assert all(call[1]["cwd"] == workspace for call in recorder.calls) + assert recorder.calls[0][1]["env"]["AGENTCI_GIT_PASSWORD"] == "secret-token" + + +async def test_branch_status_and_diff_commands( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + recorder = install_recorder( + monkeypatch, + [FakeProcess(), FakeProcess(stdout=b" M src/app.py\n"), FakeProcess()], + ) + workspace = tmp_path / "repo" + git = git_client(tmp_path) + + await git.create_branch(workspace, "agent/issue-1") + changed = await git.has_changes(workspace) + await git.diff_check(workspace) + + assert changed is True + assert recorder.commands == [ + ("git", "switch", "-c", "agent/issue-1"), + ("git", "status", "--porcelain"), + ("git", "diff", "--check"), + ] + + +async def test_has_changes_is_false_for_clean_status( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + install_recorder(monkeypatch, [FakeProcess(stdout=b"\n")]) + + assert await git_client(tmp_path).has_changes(tmp_path / "repo") is False + + +async def test_commit_stages_all_changes_sets_identity_and_returns_sha( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + recorder = install_recorder( + monkeypatch, + [FakeProcess(), FakeProcess(), FakeProcess(stdout=b"commit-sha\n")], + ) + workspace = tmp_path / "repo" + + sha = await git_client(tmp_path).commit(workspace, "agent: Fix widget") + + assert sha == "commit-sha" + assert recorder.commands == [ + ("git", "add", "-A"), + ( + "git", + "-c", + "user.name=Agent CI", + "-c", + "user.email=agent@example.test", + "commit", + "-m", + "agent: Fix widget", + ), + ("git", "rev-parse", "HEAD"), + ] + + +async def test_push_command_supports_initial_and_existing_branches( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + recorder = install_recorder(monkeypatch, [FakeProcess(), FakeProcess()]) + workspace = tmp_path / "repo" + git = git_client(tmp_path) + + await git.push(workspace, "agent/new", set_upstream=True) + await git.push(workspace, "agent/existing") + + assert recorder.commands == [ + ("git", "push", "--set-upstream", "origin", "agent/new"), + ("git", "push", "origin", "HEAD:agent/existing"), + ] + assert all(call[1]["env"]["GIT_TERMINAL_PROMPT"] == "0" for call in recorder.calls) + + +async def test_git_translates_process_start_failure( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + install_recorder(monkeypatch, [OSError("git executable missing")]) + + with pytest.raises(GitError, match="Could not run git rev-parse") as error: + await git_client(tmp_path).current_sha(tmp_path / "repo") + + assert isinstance(error.value.__cause__, OSError) + + +async def test_git_translates_nonzero_exit_and_stderr( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + install_recorder( + monkeypatch, + [FakeProcess(stderr=b"fatal: invalid diff\n", returncode=2)], + ) + + with pytest.raises(GitError, match="git diff failed: fatal: invalid diff"): + await git_client(tmp_path).diff_check(tmp_path / "repo") + + +async def test_commit_failure_identifies_commit_operation( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + install_recorder( + monkeypatch, + [FakeProcess(), FakeProcess(stderr=b"nothing to commit\n", returncode=1)], + ) + + with pytest.raises(GitError, match="git commit failed: nothing to commit"): + await git_client(tmp_path).commit(tmp_path / "repo", "agent: change") diff --git a/tests/test_gitea.py b/tests/test_gitea.py index bb55e38..15488a4 100644 --- a/tests/test_gitea.py +++ b/tests/test_gitea.py @@ -1,22 +1,345 @@ import json +from collections.abc import AsyncGenerator, Callable, Coroutine +from contextlib import asynccontextmanager import httpx -import respx +import pytest -from agentci.adapters.gitea import GiteaClient +from agentci.integrations.gitea.client import Gitea, GiteaError +from agentci.integrations.gitea.models import CommentInfo, IssueInfo, PullRequestInfo + +Handler = Callable[[httpx.Request], Coroutine[None, None, httpx.Response]] -@respx.mock -async def test_updates_issue_comment_by_id() -> None: - route = respx.patch( - "https://gitea.example/api/v1/repos/org/repo/issues/comments/17" - ).mock(return_value=httpx.Response(200, json={"id": 17})) - client = GiteaClient("https://gitea.example", "secret") - +@asynccontextmanager +async def gitea_client(handler: Handler, *, retries: int = 3) -> AsyncGenerator[Gitea]: + client = Gitea( + "https://gitea.example/", + "secret", + retries=retries, + transport=httpx.MockTransport(handler), + ) try: - await client.update_comment("org", "repo", 17, "updated status") + yield client finally: await client.close() - assert route.called - assert json.loads(route.calls[0].request.content) == {"body": "updated status"} + +async def test_sends_authenticated_json_request_contract() -> None: + requests: list[httpx.Request] = [] + + async def handler(request: httpx.Request) -> httpx.Response: + requests.append(request) + return httpx.Response(200, json={"id": 17}) + + async with gitea_client(handler) as client: + assert await client.update_comment("org", "repo", 17, "updated status") + + assert len(requests) == 1 + request = requests[0] + assert request.method == "PATCH" + assert request.url == httpx.URL( + "https://gitea.example/api/v1/repos/org/repo/issues/comments/17" + ) + assert request.headers["authorization"] == "token secret" + assert request.headers["accept"] == "application/json" + assert request.headers["content-type"] == "application/json" + assert json.loads(request.content) == {"body": "updated status"} + + +@pytest.mark.parametrize( + ("permission", "expected"), + [("write", True), ("ADMIN", True), ("owner", True), ("read", False), (None, False)], +) +async def test_maps_repository_permissions(permission: str | None, expected: bool) -> None: + async def handler(request: httpx.Request) -> httpx.Response: + assert request.url.path == "/api/v1/repos/org/repo/collaborators/alice/permission" + return httpx.Response(200, json={"permission": permission}) + + async with gitea_client(handler) as client: + assert await client.has_write_permission("org", "repo", "alice") is expected + + +async def test_maps_issue_and_pull_request_responses() -> None: + async def handler(request: httpx.Request) -> httpx.Response: + match request.url.path: + case "/api/v1/repos/org/repo": + return httpx.Response(200, json={"default_branch": "trunk"}) + case "/api/v1/repos/org/repo/issues/12": + return httpx.Response( + 200, + json={"title": "Issue title", "body": None, "state": "open"}, + ) + case "/api/v1/repos/org/repo/pulls/8": + return httpx.Response( + 200, + json={ + "title": "Pull title", + "body": None, + "state": "open", + "merged": False, + "base": {"ref": "trunk"}, + "head": { + "ref": "feature", + "sha": "abc123", + "repo": {"owner": {"login": "fork-owner"}, "name": "fork"}, + }, + }, + ) + raise AssertionError(f"unexpected request: {request.url}") + + async with gitea_client(handler) as client: + branch = await client.default_branch("org", "repo") + issue = await client.issue("org", "repo", 12) + pull = await client.pull_request("org", "repo", 8) + + assert branch == "trunk" + assert issue == IssueInfo(number=12, title="Issue title", body="", state="open") + assert pull == PullRequestInfo( + number=8, + title="Pull title", + body="", + state="open", + merged=False, + base_branch="trunk", + head_branch="feature", + head_sha="abc123", + head_owner="fork-owner", + head_repo="fork", + ) + assert pull.is_open + + +async def test_creates_comment_and_pull_request_with_expected_payloads() -> None: + requests: list[httpx.Request] = [] + + async def handler(request: httpx.Request) -> httpx.Response: + requests.append(request) + if request.url.path.endswith("/issues/4/comments"): + return httpx.Response(201, json={"id": "23"}) + if request.method == "POST" and request.url.path.endswith("/pulls"): + return httpx.Response(201, json={"number": 9}) + if request.method == "GET" and request.url.path.endswith("/pulls/9"): + return httpx.Response( + 200, + json={ + "title": "Implement it", + "body": "Details", + "state": "open", + "merged": False, + "base": {"ref": "main"}, + "head": { + "ref": "agent/work", + "sha": "def456", + "repo": {"owner": {"login": "org"}, "name": "repo"}, + }, + }, + ) + raise AssertionError(f"unexpected request: {request.method} {request.url}") + + async with gitea_client(handler) as client: + comment_id = await client.create_comment("org", "repo", 4, "Working") + pull = await client.create_pull_request( + "org", + "repo", + title="Implement it", + body="Details", + head="agent/work", + base="main", + ) + + assert comment_id == 23 + assert pull.number == 9 + assert [(request.method, request.url.path) for request in requests] == [ + ("POST", "/api/v1/repos/org/repo/issues/4/comments"), + ("POST", "/api/v1/repos/org/repo/pulls"), + ("GET", "/api/v1/repos/org/repo/pulls/9"), + ] + assert json.loads(requests[0].content) == {"body": "Working"} + assert json.loads(requests[1].content) == { + "title": "Implement it", + "body": "Details", + "head": "agent/work", + "base": "main", + } + + +async def test_maps_allowed_not_found_responses_without_retry() -> None: + paths: list[str] = [] + + async def handler(request: httpx.Request) -> httpx.Response: + paths.append(request.url.path) + return httpx.Response(404) + + async with gitea_client(handler) as client: + updated = await client.update_comment("org", "repo", 99, "missing") + comments = await client.review_comments("org", "repo", 7, 3) + + assert not updated + assert comments == [] + assert paths == [ + "/api/v1/repos/org/repo/issues/comments/99", + "/api/v1/repos/org/repo/pulls/7/reviews/3/comments", + ] + + +@pytest.mark.parametrize(("first_page_size", "expected_pages"), [(0, [1]), (49, [1]), (50, [1, 2])]) +async def test_pagination_stops_only_after_a_short_page( + first_page_size: int, expected_pages: list[int] +) -> None: + pages: list[int] = [] + + async def handler(request: httpx.Request) -> httpx.Response: + assert request.url.path == "/api/v1/repos/org/repo/issues/6/comments" + assert request.url.params["limit"] == "50" + page = int(request.url.params["page"]) + pages.append(page) + size = first_page_size if page == 1 else 1 + offset = 0 if page == 1 else 50 + return httpx.Response( + 200, + json=[ + { + "id": offset + index + 1, + "user": {"login": f"user-{offset + index + 1}"}, + "body": None, + "created_at": None, + } + for index in range(size) + ], + ) + + async with gitea_client(handler) as client: + comments = await client.issue_comments("org", "repo", 6) + + expected_count = first_page_size + (1 if first_page_size == 50 else 0) + assert pages == expected_pages + assert len(comments) == expected_count + if comments: + assert comments[0] == CommentInfo(id=1, author="user-1", body="", created_at="") + assert comments[-1].id == expected_count + + +@pytest.mark.parametrize("status", [400, 401, 403, 404, 422]) +async def test_nonretryable_status_fails_once(status: int, monkeypatch: pytest.MonkeyPatch) -> None: + attempts = 0 + sleeps: list[int] = [] + + async def handler(_request: httpx.Request) -> httpx.Response: + nonlocal attempts + attempts += 1 + return httpx.Response(status) + + async def sleep(delay: int) -> None: + sleeps.append(delay) + + monkeypatch.setattr("agentci.integrations.gitea.client.asyncio.sleep", sleep) + async with gitea_client(handler) as client: + with pytest.raises( + GiteaError, + match=rf"Gitea returned {status} for GET /repos/org/repo", + ): + await client.default_branch("org", "repo") + + assert attempts == 1 + assert sleeps == [] + + +@pytest.mark.parametrize("status", [429, 500, 502, 503, 504]) +async def test_retryable_status_recovers_after_backoff( + status: int, monkeypatch: pytest.MonkeyPatch +) -> None: + attempts = 0 + sleeps: list[int] = [] + + async def handler(_request: httpx.Request) -> httpx.Response: + nonlocal attempts + attempts += 1 + if attempts == 1: + return httpx.Response(status) + return httpx.Response(200, json={"default_branch": "main"}) + + async def sleep(delay: int) -> None: + sleeps.append(delay) + + monkeypatch.setattr("agentci.integrations.gitea.client.asyncio.sleep", sleep) + async with gitea_client(handler) as client: + assert await client.default_branch("org", "repo") == "main" + + assert attempts == 2 + assert sleeps == [1] + + +async def test_retryable_status_exhaustion_uses_exponential_backoff( + monkeypatch: pytest.MonkeyPatch, +) -> None: + attempts = 0 + sleeps: list[int] = [] + + async def handler(_request: httpx.Request) -> httpx.Response: + nonlocal attempts + attempts += 1 + return httpx.Response(503) + + async def sleep(delay: int) -> None: + sleeps.append(delay) + + monkeypatch.setattr("agentci.integrations.gitea.client.asyncio.sleep", sleep) + async with gitea_client(handler) as client: + with pytest.raises( + GiteaError, + match="Gitea remained unavailable for GET /repos/org/repo", + ): + await client.default_branch("org", "repo") + + assert attempts == 3 + assert sleeps == [1, 2] + + +async def test_transport_failure_retries_and_recovers(monkeypatch: pytest.MonkeyPatch) -> None: + attempts = 0 + sleeps: list[int] = [] + + async def handler(request: httpx.Request) -> httpx.Response: + nonlocal attempts + attempts += 1 + if attempts < 3: + raise httpx.ConnectError("connection refused", request=request) + return httpx.Response(200, json={"default_branch": "main"}) + + async def sleep(delay: int) -> None: + sleeps.append(delay) + + monkeypatch.setattr("agentci.integrations.gitea.client.asyncio.sleep", sleep) + async with gitea_client(handler) as client: + assert await client.default_branch("org", "repo") == "main" + + assert attempts == 3 + assert sleeps == [1, 2] + + +async def test_transport_failure_exhaustion_preserves_cause( + monkeypatch: pytest.MonkeyPatch, +) -> None: + attempts = 0 + sleeps: list[int] = [] + + async def handler(request: httpx.Request) -> httpx.Response: + nonlocal attempts + attempts += 1 + raise httpx.ConnectError("connection refused", request=request) + + async def sleep(delay: int) -> None: + sleeps.append(delay) + + monkeypatch.setattr("agentci.integrations.gitea.client.asyncio.sleep", sleep) + async with gitea_client(handler, retries=2) as client: + with pytest.raises( + GiteaError, + match="Gitea request failed: GET /repos/org/repo", + ) as raised: + await client.default_branch("org", "repo") + + assert isinstance(raised.value.__cause__, httpx.ConnectError) + assert attempts == 2 + assert sleeps == [1] diff --git a/tests/test_health.py b/tests/test_health.py new file mode 100644 index 0000000..82e2c60 --- /dev/null +++ b/tests/test_health.py @@ -0,0 +1,77 @@ +from types import SimpleNamespace + +import pytest +from fastapi import FastAPI +from httpx import ASGITransport, AsyncClient, Response + +from agentci.api.routes.health import router + + +class Provider: + def __init__(self, result: bool | Exception) -> None: + self.result = result + self.calls = 0 + + async def ready(self) -> bool: + self.calls += 1 + if isinstance(self.result, Exception): + raise self.result + return self.result + + +def application(provider: Provider | None = None) -> FastAPI: + app = FastAPI() + app.include_router(router) + if provider is not None: + app.state.runtime = SimpleNamespace(opencode=provider) + return app + + +async def get(app: FastAPI, path: str) -> Response: + async with AsyncClient( + transport=ASGITransport(app=app, raise_app_exceptions=False), + base_url="http://test", + ) as client: + return await client.get(path) + + +async def test_liveness_does_not_depend_on_runtime_providers() -> None: + response = await get(application(), "/health/live") + + assert response.status_code == 200 + assert response.json() == {"status": "live"} + + +@pytest.mark.parametrize( + ("provider_ready", "status_code", "payload"), + [ + (True, 200, {"status": "ready"}), + ( + False, + 503, + { + "status": "not-ready", + "reason": "opencode provider is not connected", + }, + ), + ], +) +async def test_readiness_reflects_provider_state( + provider_ready: bool, status_code: int, payload: dict[str, str] +) -> None: + provider = Provider(provider_ready) + + response = await get(application(provider), "/health/ready") + + assert response.status_code == status_code + assert response.json() == payload + assert provider.calls == 1 + + +async def test_readiness_provider_error_is_server_failure() -> None: + provider = Provider(RuntimeError("provider check failed")) + + response = await get(application(provider), "/health/ready") + + assert response.status_code == 500 + assert provider.calls == 1 diff --git a/tests/test_implementation_setup.py b/tests/test_implementation_setup.py deleted file mode 100644 index fd4e4c2..0000000 --- a/tests/test_implementation_setup.py +++ /dev/null @@ -1,143 +0,0 @@ -from pathlib import Path -from types import SimpleNamespace - -import pytest - -from agentci.adapters.gitea_models import IssueInfo, PullRequestInfo, RepositoryInfo -from agentci.domain.models import Job, JobKind, Workflow, WorkflowKind, WorkflowStatus -from agentci.workflows.implement import ImplementWorkflow -from agentci.workflows.pull_request import PullRequestWorkflow - - -class SetupReached(RuntimeError): - pass - - -class FakeDevelopment: - description = "python" - - def __init__(self, events: list[str]) -> None: - self.events = events - - async def prepare(self, _workspace: Path) -> None: - self.events.append("prepare") - raise SetupReached - - -class FakeGit: - def __init__(self, events: list[str]) -> None: - self.events = events - - async def clone(self, *_args) -> str: - self.events.append("clone") - return "base-sha" - - async def create_branch(self, *_args) -> None: - self.events.append("create branch") - - async def sync_branch(self, *_args) -> None: - self.events.append("sync branch") - - -class FakeStorage: - def __init__(self, workflow: Workflow | None = None) -> None: - self.workflow = workflow - - async def implementation_workflows(self, *_args): - return [] - - async def create_workflow(self, _workflow) -> None: - return None - - async def update_job(self, *_args, **_kwargs) -> None: - return None - - async def workflow_for_pr(self, *_args): - return self.workflow - - -class FakeContext: - def __init__(self, pull: PullRequestInfo) -> None: - self.pull = pull - - async def pull_request_context(self, *_args): - return self.pull, "context" - - -class FakeGitea: - async def repository(self, *_args) -> RepositoryInfo: - return RepositoryInfo("org", "repo", "org/repo", "main") - - async def issue(self, *_args) -> IssueInfo: - return IssueInfo(1, "Issue", "Body", "open") - - -def job(kind: JobKind, *, pr_number: int | None = None) -> Job: - return Job( - id="job", - kind=kind, - target_key="org/repo:target", - repo_owner="org", - repo_name="repo", - issue_number=1, - pr_number=pr_number, - requester="alice", - message="", - comment_id=1, - ) - - -def pull() -> PullRequestInfo: - return PullRequestInfo(2, "PR", "Body", "open", False, "main", "agent", "sha", "org", "repo") - - -async def test_initial_implementation_prepares_after_clone_and_branch(tmp_path) -> None: - events: list[str] = [] - settings = SimpleNamespace(branch_prefix="agent", workspaces_dir=tmp_path) - deps = SimpleNamespace( - settings=settings, - storage=FakeStorage(), - gitea=FakeGitea(), - git=FakeGit(events), - development=FakeDevelopment(events), - ) - - with pytest.raises(SetupReached): - await ImplementWorkflow(deps).run(job(JobKind.IMPLEMENT)) # type: ignore[arg-type] - - assert events == ["clone", "create branch", "prepare"] - - -@pytest.mark.parametrize("operation", ["iterate", "fix"]) -async def test_pull_request_implementation_prepares_after_checkout( - tmp_path, operation: str -) -> None: - events: list[str] = [] - existing = Workflow( - id="flow", - kind=WorkflowKind.IMPLEMENT, - repo_owner="org", - repo_name="repo", - issue_number=1, - workspace_path=tmp_path / "repo", - base_sha="base", - branch="agent", - primary_session_id="primary", - reviewer_session_id="reviewer", - status=WorkflowStatus.COMPLETED, - ) - settings = SimpleNamespace(workspaces_dir=tmp_path) - deps = SimpleNamespace( - settings=settings, - storage=FakeStorage(existing), - context=FakeContext(pull()), - git=FakeGit(events), - development=FakeDevelopment(events), - ) - workflow = PullRequestWorkflow(deps) # type: ignore[arg-type] - - with pytest.raises(SetupReached): - await getattr(workflow, operation)(job(JobKind.FIX, pr_number=2)) - - expected_checkout = "sync branch" if operation == "iterate" else "clone" - assert events == [expected_checkout, "prepare"] diff --git a/tests/test_install_scripts.py b/tests/test_install_scripts.py index b1be54f..b453830 100644 --- a/tests/test_install_scripts.py +++ b/tests/test_install_scripts.py @@ -1,12 +1,154 @@ +import os +import subprocess from pathlib import Path +ROOT = Path(__file__).parents[1] -def test_dotnet_wrapper_uses_persistent_runtime_directories() -> None: - root = Path(__file__).parents[1] - script = (root / "install-scripts" / "dotnet").read_text() - assert "export DOTNET_ROOT=" in script - assert "/tmp/agentci-dotnet" not in script - assert "DEV_TOOLS_DIR/runtime/dotnet" in script - assert "NUGET_PACKAGES" in script - assert 'export HOME="$DOTNET_CLI_HOME"' in script +def executable(path: Path, content: str) -> None: + path.write_text(content) + path.chmod(0o755) + + +def test_python_installer_configures_uv_and_stable_python_links(tmp_path: Path) -> None: + fake_bin = tmp_path / "fake-bin" + fake_bin.mkdir() + tools = tmp_path / "tools" + uv_log = tmp_path / "uv.log" + executable( + fake_bin / "uv", + """#!/bin/sh +set -eu +{ + printf 'UV_NO_CONFIG=%s\n' "$UV_NO_CONFIG" + printf 'UV_PYTHON_INSTALL_DIR=%s\n' "$UV_PYTHON_INSTALL_DIR" + printf 'UV_PYTHON_BIN_DIR=%s\n' "$UV_PYTHON_BIN_DIR" + printf 'UV_PYTHON_INSTALL_BIN=%s\n' "$UV_PYTHON_INSTALL_BIN" + printf 'args=%s %s %s\n' "$1" "$2" "$3" +} > "$FAKE_UV_LOG" +minor=$(printf '%s' "$3" | cut -d. -f1,2) +mkdir -p "$UV_PYTHON_INSTALL_DIR" "$UV_PYTHON_BIN_DIR" +cat > "$UV_PYTHON_BIN_DIR/python$minor" <<'PYTHON' +#!/bin/sh +printf '%s\n' 'Python fake' +PYTHON +chmod 0755 "$UV_PYTHON_BIN_DIR/python$minor" +""", + ) + environment = { + **os.environ, + "PATH": f"{fake_bin}:{os.environ['PATH']}", + "DEV_TOOLS_DIR": str(tools), + "PYTHON_VERSION": "3.13.7", + "FAKE_UV_LOG": str(uv_log), + } + + result = subprocess.run( + ["/bin/sh", str(ROOT / "install-scripts" / "python")], + env=environment, + capture_output=True, + text=True, + timeout=10, + check=False, + ) + + assert result.returncode == 0, result.stderr + assert result.stdout == "Python fake\n" + assert uv_log.read_text().splitlines() == [ + "UV_NO_CONFIG=1", + f"UV_PYTHON_INSTALL_DIR={tools / 'python'}", + f"UV_PYTHON_BIN_DIR={tools / 'bin'}", + "UV_PYTHON_INSTALL_BIN=1", + "args=python install 3.13.7", + ] + assert (tools / "bin" / "python").readlink() == Path("python3.13") + assert (tools / "bin" / "python3").readlink() == Path("python3.13") + + +def test_dotnet_installer_builds_persistent_runtime_wrapper_without_network( + tmp_path: Path, +) -> None: + fake_bin = tmp_path / "fake-bin" + fake_bin.mkdir() + tools = tmp_path / "tools" + installer_log = tmp_path / "installer.log" + dotnet_log = tmp_path / "dotnet.log" + executable( + fake_bin / "python3", + """#!/bin/sh +set -eu +[ "$1" = - ] +cat > "$2" <<'INSTALLER' +#!/bin/sh +set -eu +printf '%s\n' "$@" > "$FAKE_INSTALLER_LOG" +install_dir= +while [ "$#" -gt 0 ]; do + case "$1" in + --install-dir) install_dir=$2; shift 2 ;; + *) shift ;; + esac +done +mkdir -p "$install_dir" +cat > "$install_dir/dotnet" <<'DOTNET' +#!/bin/sh +set -eu +printf '%s|%s|%s|%s|%s|%s\n' \ + "$*" "$DOTNET_ROOT" "$DOTNET_CLI_HOME" "$NUGET_PACKAGES" \ + "$NUGET_HTTP_CACHE_PATH" "$HOME" >> "$FAKE_DOTNET_LOG" +DOTNET +chmod 0755 "$install_dir/dotnet" +INSTALLER +""", + ) + environment = { + **os.environ, + "PATH": f"{fake_bin}:{os.environ['PATH']}", + "DEV_TOOLS_DIR": str(tools), + "DOTNET_CHANNEL": "10.0", + "FAKE_INSTALLER_LOG": str(installer_log), + "FAKE_DOTNET_LOG": str(dotnet_log), + } + + result = subprocess.run( + ["/bin/sh", str(ROOT / "install-scripts" / "dotnet")], + env=environment, + capture_output=True, + text=True, + timeout=10, + check=False, + ) + + assert result.returncode == 0, result.stderr + assert installer_log.read_text().splitlines() == [ + "--channel", + "10.0", + "--install-dir", + str(tools / "dotnet"), + "--no-path", + ] + wrapper = tools / "bin" / "dotnet" + assert os.access(wrapper, os.X_OK) + + wrapped = subprocess.run( + [wrapper, "--version"], + env=environment, + capture_output=True, + text=True, + timeout=10, + check=False, + ) + + assert wrapped.returncode == 0, wrapped.stderr + calls = dotnet_log.read_text().splitlines() + assert calls[0].split("|")[0] == "--info" + assert calls[1].split("|") == [ + "--version", + str(tools / "dotnet"), + str(tools / "runtime" / "dotnet" / "home"), + str(tools / "runtime" / "dotnet" / "nuget" / "packages"), + str(tools / "runtime" / "dotnet" / "nuget" / "http-cache"), + str(tools / "runtime" / "dotnet" / "home"), + ] + assert (tools / "runtime" / "dotnet" / "home").is_dir() + assert (tools / "runtime" / "dotnet" / "nuget" / "packages").is_dir() diff --git a/tests/test_job_run.py b/tests/test_job_run.py new file mode 100644 index 0000000..648c0e8 --- /dev/null +++ b/tests/test_job_run.py @@ -0,0 +1,75 @@ +from pathlib import Path + +import pytest + +from agentci.engine.events import ( + JobProgress, + RuntimeSessionLinked, + WorkflowCreated, + WorkflowLinked, +) +from agentci.engine.model import Workflow, WorkflowKind +from agentci.engine.run import JobRun + + +class RecordingRepository: + def __init__(self, *, fail_event_ids: set[str] | None = None) -> None: + self.events: list[tuple[str, object]] = [] + self.fail_event_ids = fail_event_ids or set() + + async def apply(self, event_id: str, event: object) -> None: + self.events.append((event_id, event)) + if event_id in self.fail_event_ids: + raise RuntimeError("write failed") + + +async def test_job_run_emits_each_report_payload_with_monotonic_ids() -> None: + repository = RecordingRepository() + run = JobRun(repository, "job-1", 10) # type: ignore[arg-type] + workflow = Workflow( + id="workflow-1", + kind=WorkflowKind.PLAN, + repo_owner="alice", + repo_name="repo", + issue_number=3, + workspace_path=Path("/work/repo"), + base_sha="abc", + ) + + await run.stage("cloning") + await run.create_workflow(workflow, "planning") + await run.link_workflow("workflow-2", "discussing") + await run.link_session("session-1") + + assert repository.events == [ + ("task:10:report:1", JobProgress(job_id="job-1", stage="cloning")), + ( + "task:10:report:2", + WorkflowCreated(job_id="job-1", workflow=workflow, stage="planning"), + ), + ( + "task:10:report:3", + WorkflowLinked(job_id="job-1", workflow_id="workflow-2", stage="discussing"), + ), + ( + "task:10:report:4", + RuntimeSessionLinked(job_id="job-1", session_id="session-1"), + ), + ] + + +async def test_failed_report_consumes_only_its_run_sequence_number() -> None: + repository = RecordingRepository(fail_event_ids={"task:10:report:1"}) + first = JobRun(repository, "job-1", 10) # type: ignore[arg-type] + second = JobRun(repository, "job-2", 20) # type: ignore[arg-type] + + with pytest.raises(RuntimeError, match="write failed"): + await first.stage("cloning") + await second.stage("fixing") + await first.link_session("session-1") + + assert [event_id for event_id, _ in repository.events] == [ + "task:10:report:1", + "task:20:report:1", + "task:10:report:2", + ] diff --git a/tests/test_line_limits.py b/tests/test_line_limits.py deleted file mode 100644 index 101196d..0000000 --- a/tests/test_line_limits.py +++ /dev/null @@ -1,13 +0,0 @@ -from pathlib import Path - - -def test_python_files_are_at_most_250_lines() -> None: - root = Path(__file__).parents[1] - files = [*root.glob("src/**/*.py"), *root.glob("tests/**/*.py")] - oversized = { - str(path.relative_to(root)): len(path.read_text().splitlines()) - for path in files - if len(path.read_text().splitlines()) > 250 - } - assert oversized == {} - diff --git a/tests/test_logging.py b/tests/test_logging.py index 3170700..3101391 100644 --- a/tests/test_logging.py +++ b/tests/test_logging.py @@ -1,10 +1,106 @@ +import json import logging +import sys +from collections.abc import Iterator +from datetime import UTC, datetime -from agentci.logging import configure_logging +import pytest + +from agentci.observability.logging import JsonFormatter, configure_logging -def test_suppresses_http_client_request_logs() -> None: +@pytest.fixture(autouse=True) +def restore_logging_state() -> Iterator[None]: + root = logging.getLogger() + original_handlers = root.handlers[:] + original_level = root.level + client_levels = {name: logging.getLogger(name).level for name in ("httpx", "httpcore")} + try: + yield + finally: + root.handlers[:] = original_handlers + root.setLevel(original_level) + for name, level in client_levels.items(): + logging.getLogger(name).setLevel(level) + + +def record(*, message: str = "processed job") -> logging.LogRecord: + return logging.LogRecord( + name="agentci.test", + level=logging.INFO, + pathname=__file__, + lineno=1, + msg=message, + args=(), + exc_info=None, + ) + + +def test_json_formatter_emits_stable_structured_contract() -> None: + value = record() + value.__dict__.update( + operation="worker.execute", + job_id="job-1", + duration_ms=0, + unapproved_secret="not-for-output", + ) + + payload = json.loads(JsonFormatter().format(value)) + + assert payload == { + "timestamp": payload["timestamp"], + "level": "INFO", + "logger": "agentci.test", + "message": "processed job", + "operation": "worker.execute", + "job_id": "job-1", + "duration_ms": 0, + } + timestamp = datetime.fromisoformat(payload["timestamp"]) + assert timestamp.tzinfo == UTC + + +def test_json_formatter_includes_exception_traceback() -> None: + try: + raise RuntimeError("provider unavailable") + except RuntimeError: + value = record(message="request failed") + value.exc_info = sys.exc_info() + + payload = json.loads(JsonFormatter().format(value)) + + assert payload["message"] == "request failed" + assert "RuntimeError: provider unavailable" in payload["exception"] + + +def test_json_formatter_survives_unserializable_context() -> None: + value = record() + value.__dict__["operation"] = object() + + payload = json.loads(JsonFormatter().format(value)) + + assert payload["level"] == "INFO" + assert payload["logger"] == "agentci.test" + assert payload["message"] == "Log record could not be serialized" + assert "TypeError" in payload["exception"] + + +def test_repeated_configuration_replaces_handlers_without_duplicate_output( + capsys: pytest.CaptureFixture[str], +) -> None: + configure_logging() configure_logging() + root = logging.getLogger() + assert root.level == logging.INFO + assert len(root.handlers) == 1 + assert isinstance(root.handlers[0].formatter, JsonFormatter) assert logging.getLogger("httpx").level == logging.WARNING assert logging.getLogger("httpcore").level == logging.WARNING + + logging.getLogger("agentci.contract").info( + "configured", extra={"operation": "logging.configure"} + ) + lines = capsys.readouterr().err.splitlines() + assert len(lines) == 1 + assert json.loads(lines[0])["operation"] == "logging.configure" diff --git a/tests/test_opencode.py b/tests/test_opencode.py index 3df054c..7370ce8 100644 --- a/tests/test_opencode.py +++ b/tests/test_opencode.py @@ -5,8 +5,8 @@ from pathlib import Path import httpx import pytest -from agentci.adapters.opencode import OpenCodeClient, OpenCodeError -from agentci.domain.models import AgentResult +from agentci.integrations.opencode.client import OpenCode, OpenCodeError +from agentci.workflows.model import AgentResult API_DOCUMENT = { "paths": { @@ -42,9 +42,9 @@ class FakeCodeGraph: self.prepared.append(workspace) -def client(tmp_path: Path, handler, codegraph: FakeCodeGraph | None = None) -> OpenCodeClient: +def client(tmp_path: Path, handler, codegraph: FakeCodeGraph | None = None) -> OpenCode: selected_codegraph = codegraph or FakeCodeGraph() - return OpenCodeClient( + return OpenCode( base_url="http://opencode:4096", username="opencode", password="server-secret", @@ -74,20 +74,154 @@ async def test_ready_requires_healthy_server_and_connected_provider(tmp_path: Pa await value.close() -async def test_ready_rejects_missing_provider(tmp_path: Path) -> None: +async def test_concurrent_readiness_checks_share_one_probe(tmp_path: Path) -> None: + health_started = asyncio.Event() + release_health = asyncio.Event() + paths: list[str] = [] + async def handler(request: httpx.Request) -> httpx.Response: + paths.append(request.url.path) if request.url.path == "/global/health": + health_started.set() + await release_health.wait() return httpx.Response(200, json={"healthy": True, "version": "1.18.4"}) if request.url.path == "/doc": return httpx.Response(200, json=API_DOCUMENT) - return httpx.Response(200, json={**PROVIDERS, "connected": ["anthropic"]}) + return httpx.Response(200, json=PROVIDERS) + + value = client(tmp_path, handler) + first = asyncio.create_task(value.ready()) + await health_started.wait() + second = asyncio.create_task(value.ready()) + release_health.set() + + assert await asyncio.gather(first, second) == [True, True] + assert paths == ["/global/health", "/doc", "/provider"] + await value.close() + + +@pytest.mark.parametrize( + ("health", "document", "providers", "expected_paths"), + [ + ( + {"healthy": False, "version": "1.18.4"}, + API_DOCUMENT, + PROVIDERS, + ["/global/health"], + ), + ( + {"healthy": True, "version": "2.0.0"}, + API_DOCUMENT, + PROVIDERS, + ["/global/health"], + ), + ( + {"healthy": True, "version": "1.18.4"}, + {"paths": {}}, + PROVIDERS, + ["/global/health", "/doc"], + ), + ( + {"healthy": True, "version": "1.18.4"}, + API_DOCUMENT, + {**PROVIDERS, "connected": ["anthropic"]}, + ["/global/health", "/doc", "/provider"], + ), + ( + {"healthy": True, "version": "1.18.4"}, + API_DOCUMENT, + { + "connected": ["openai"], + "all": [ + { + "id": "openai", + "models": { + "model": { + "status": "active", + "capabilities": {"toolcall": False}, + "variants": {"high": {}}, + } + }, + } + ], + }, + ["/global/health", "/doc", "/provider"], + ), + ], +) +async def test_ready_rejects_incomplete_runtime_matrix( + tmp_path: Path, + health: object, + document: object, + providers: object, + expected_paths: list[str], +) -> None: + paths: list[str] = [] + + async def handler(request: httpx.Request) -> httpx.Response: + paths.append(request.url.path) + if request.url.path == "/global/health": + return httpx.Response(200, json=health) + if request.url.path == "/doc": + return httpx.Response(200, json=document) + return httpx.Response(200, json=providers) + + value = client(tmp_path, handler) + assert not await value.ready() + assert paths == expected_paths + await value.close() + + +@pytest.mark.parametrize("response_kind", ["http_error", "invalid_json"]) +async def test_ready_converts_probe_errors_to_not_ready(tmp_path: Path, response_kind: str) -> None: + async def handler(_request: httpx.Request) -> httpx.Response: + if response_kind == "http_error": + return httpx.Response(503) + return httpx.Response(200, content=b"not-json") value = client(tmp_path, handler) assert not await value.ready() await value.close() -async def test_starts_structured_session_in_workspace(tmp_path: Path) -> None: +@pytest.mark.parametrize( + ("response_kind", "message"), + [ + ("invalid_json", "OpenCode request failed: POST /session"), + ("array", "OpenCode returned an invalid response for POST /session"), + ("http_error", "OpenCode request failed: POST /session: gateway detail"), + ], +) +async def test_create_session_reports_malformed_and_error_responses( + tmp_path: Path, response_kind: str, message: str +) -> None: + async def handler(_request: httpx.Request) -> httpx.Response: + if response_kind == "invalid_json": + return httpx.Response(200, content=b"not-json") + if response_kind == "array": + return httpx.Response(200, json=[]) + return httpx.Response(502, text="gateway detail") + + value = client(tmp_path, handler) + with pytest.raises(OpenCodeError, match=message): + await value.create_session(tmp_path, "agent_result.json") + await value.close() + + +@pytest.mark.parametrize("payload", [{}, {"id": ""}, {"id": 42}]) +async def test_create_session_requires_nonempty_string_id( + tmp_path: Path, payload: dict[str, object] +) -> None: + async def handler(_request: httpx.Request) -> httpx.Response: + return httpx.Response(200, json=payload) + + value = client(tmp_path, handler) + with pytest.raises(OpenCodeError, match="did not return a session ID"): + await value.create_session(tmp_path, "agent_result.json") + await value.close() + + +async def test_creates_and_resumes_structured_session_in_workspace(tmp_path: Path) -> None: workspace = tmp_path / "workspace" workspace.mkdir() codegraph = FakeCodeGraph() @@ -117,7 +251,9 @@ async def test_starts_structured_session_in_workspace(tmp_path: Path) -> None: ) value = client(tmp_path, handler, codegraph) - session_id, result = await value.start( + session_id = await value.create_session(workspace, "agent_result.json") + result = await value.resume( + session_id=session_id, workspace=workspace, prompt="implement", model="openai/model", @@ -145,9 +281,7 @@ async def test_retries_invalid_structured_result_on_same_session(tmp_path: Path) body = json.loads(request.content) prompts.append(body["parts"][0]["text"]) if len(prompts) == 1: - return httpx.Response( - 200, json={"info": {"error": {"name": "StructuredOutputError"}}} - ) + return httpx.Response(200, json={"info": {"error": {"name": "StructuredOutputError"}}}) return httpx.Response( 200, json={ @@ -173,15 +307,160 @@ async def test_retries_invalid_structured_result_on_same_session(tmp_path: Path) await value.close() +async def test_structured_validation_retry_exhaustion_reports_final_error( + tmp_path: Path, +) -> None: + workspace = tmp_path / "workspace" + workspace.mkdir() + prompts: list[str] = [] + paths: list[str] = [] + + async def handler(request: httpx.Request) -> httpx.Response: + paths.append(request.url.path) + if request.url.path.endswith("/abort"): + return httpx.Response(200, json=True) + body = json.loads(request.content) + prompts.append(body["parts"][0]["text"]) + return httpx.Response( + 200, + json={ + "info": {"structured": {"summary_markdown": "", "tests": "invalid"}}, + "parts": [], + }, + ) + + value = client(tmp_path, handler) + with pytest.raises( + OpenCodeError, + match="structured result failed validation", + ): + await value.resume( + session_id="ses_existing", + workspace=workspace, + prompt="continue", + model="openai/model", + variant=None, + schema_name="agent_result.json", + result_type=AgentResult, + ) + + assert len(prompts) == 2 + assert prompts[0] == "continue" + assert "without repeating repository work" in prompts[1] + assert paths[-1] == "/session/ses_existing/abort" + await value.close() + + +async def test_cleanup_failure_does_not_mask_turn_error(tmp_path: Path) -> None: + workspace = tmp_path / "workspace" + workspace.mkdir() + paths: list[str] = [] + + async def handler(request: httpx.Request) -> httpx.Response: + paths.append(request.url.path) + if request.url.path.endswith("/abort"): + return httpx.Response(500, text="abort failed") + return httpx.Response(200, json={"unexpected": True}) + + value = client(tmp_path, handler) + with pytest.raises( + OpenCodeError, + match="OpenCode response did not include assistant metadata", + ): + await value.resume( + session_id="ses_existing", + workspace=workspace, + prompt="continue", + model="openai/model", + variant=None, + schema_name="agent_result.json", + result_type=AgentResult, + ) + + assert paths == [ + "/session/ses_existing/message", + "/session/ses_existing/abort", + ] + await value.close() + + +async def test_close_aborts_an_active_session(tmp_path: Path) -> None: + workspace = tmp_path / "workspace" + workspace.mkdir() + message_started = asyncio.Event() + release_message = asyncio.Event() + paths: list[str] = [] + + async def handler(request: httpx.Request) -> httpx.Response: + paths.append(request.url.path) + if request.url.path.endswith("/abort"): + assert request.headers["x-opencode-directory"] == str(workspace.resolve()) + return httpx.Response(200, json=True) + message_started.set() + await release_message.wait() + return httpx.Response( + 200, + json={ + "info": {"structured": {"summary_markdown": "finished", "tests": []}}, + "parts": [], + }, + ) + + value = client(tmp_path, handler) + turn = asyncio.create_task( + value.resume( + session_id="ses_active", + workspace=workspace, + prompt="continue", + model="openai/model", + variant=None, + schema_name="agent_result.json", + result_type=AgentResult, + ) + ) + await message_started.wait() + + await value.close() + release_message.set() + result = await turn + + assert result.summary_markdown == "finished" + assert paths == ["/session/ses_active/message", "/session/ses_active/abort"] + assert value.client.is_closed + + +async def test_close_continues_after_active_session_abort_failure(tmp_path: Path) -> None: + paths: list[str] = [] + + async def handler(request: httpx.Request) -> httpx.Response: + paths.append(request.url.path) + if "/failed/" in request.url.path: + return httpx.Response(503) + return httpx.Response(200, json=True) + + value = client(tmp_path, handler) + value._active_sessions.update( + { + "failed": tmp_path / "first", + "succeeds": tmp_path / "second", + } + ) + + await value.close() + + assert paths == ["/session/failed/abort", "/session/succeeds/abort"] + assert value.client.is_closed + + async def test_aborts_timed_out_session(tmp_path: Path) -> None: workspace = tmp_path / "workspace" workspace.mkdir() - aborted = False + abort_count = 0 async def handler(request: httpx.Request) -> httpx.Response: - nonlocal aborted + nonlocal abort_count if request.url.path.endswith("/abort"): - aborted = True + abort_count += 1 return httpx.Response(200, json=True) raise httpx.ReadTimeout("slow", request=request) @@ -197,7 +476,7 @@ async def test_aborts_timed_out_session(tmp_path: Path) -> None: result_type=AgentResult, ) - assert aborted + assert abort_count == 1 await value.close() diff --git a/tests/test_opencode_abort.py b/tests/test_opencode_abort.py index d584216..c35106c 100644 --- a/tests/test_opencode_abort.py +++ b/tests/test_opencode_abort.py @@ -3,11 +3,11 @@ from pathlib import Path import httpx import pytest -from agentci.adapters.opencode import OpenCodeClient, OpenCodeError +from agentci.integrations.opencode.client import OpenCode, OpenCodeError -def client(tmp_path: Path, status: int) -> OpenCodeClient: - return OpenCodeClient( +def client(tmp_path: Path, status: int) -> OpenCode: + return OpenCode( base_url="http://opencode:4096", username="opencode", password="secret", @@ -32,3 +32,50 @@ async def test_abort_failure_is_visible_for_retry(tmp_path: Path, status: int) - with pytest.raises(OpenCodeError): await value.abort("session", tmp_path) await value.close() + + +async def test_abort_sends_authenticated_workspace_request(tmp_path: Path) -> None: + requests: list[httpx.Request] = [] + + async def handler(request: httpx.Request) -> httpx.Response: + requests.append(request) + return httpx.Response(204) + + value = OpenCode( + base_url="http://opencode:4096/", + username="opencode", + password="secret", + schemas_dir=tmp_path, + health_directory=tmp_path, + required_models=(), + timeout_seconds=60, + transport=httpx.MockTransport(handler), + ) + await value.abort("ses_123", tmp_path) + await value.close() + + assert len(requests) == 1 + request = requests[0] + assert request.method == "POST" + assert request.url == httpx.URL("http://opencode:4096/session/ses_123/abort") + assert request.headers["authorization"].startswith("Basic ") + assert request.headers["x-opencode-directory"] == str(tmp_path) + + +async def test_best_effort_abort_suppresses_transport_failure(tmp_path: Path) -> None: + async def handler(request: httpx.Request) -> httpx.Response: + raise httpx.ConnectError("connection refused", request=request) + + value = OpenCode( + base_url="http://opencode:4096", + username="opencode", + password="secret", + schemas_dir=tmp_path, + health_directory=tmp_path, + required_models=(), + timeout_seconds=60, + transport=httpx.MockTransport(handler), + ) + + await value.abort("session", tmp_path, best_effort=True) + await value.close() diff --git a/tests/test_opencode_deployment.py b/tests/test_opencode_deployment.py index 9d8208a..8aedb1b 100644 --- a/tests/test_opencode_deployment.py +++ b/tests/test_opencode_deployment.py @@ -1,10 +1,81 @@ import json +import shutil +import subprocess from pathlib import Path +import pytest + +ROOT = Path(__file__).parents[1] + + +def _indent(line: str) -> int: + return len(line) - len(line.lstrip()) + + +def _service(name: str) -> list[str]: + lines = (ROOT / "compose.yaml").read_text().splitlines() + marker = f" {name}:" + start = lines.index(marker) + 1 + end = next( + ( + index + for index in range(start, len(lines)) + if lines[index].strip() and _indent(lines[index]) <= 2 + ), + len(lines), + ) + return lines[start:end] + + +def _section(lines: list[str], name: str, *, indent: int = 4) -> list[str]: + marker = f"{' ' * indent}{name}:" + start = lines.index(marker) + 1 + end = next( + ( + index + for index in range(start, len(lines)) + if lines[index].strip() and _indent(lines[index]) <= indent + ), + len(lines), + ) + return lines[start:end] + + +def _value(value: str) -> str: + value = value.strip() + if len(value) >= 2 and value[0] == value[-1] == '"': + return str(json.loads(value)) + if len(value) >= 2 and value[0] == value[-1] == "'": + return value[1:-1] + return value + + +def _mapping(lines: list[str], name: str, *, indent: int = 4) -> dict[str, str]: + entries = _section(lines, name, indent=indent) + result: dict[str, str] = {} + for line in entries: + if _indent(line) != indent + 2 or line.lstrip().startswith("-"): + continue + key, separator, value = line.strip().partition(":") + if separator: + result[key] = _value(value) + return result + + +def _sequence(lines: list[str], name: str, *, indent: int = 4) -> list[str]: + entries = _section(lines, name, indent=indent) + prefix = f"{' ' * (indent + 2)}- " + return [_value(line.removeprefix(prefix)) for line in entries if line.startswith(prefix)] + + +def _scalar(lines: list[str], name: str, *, indent: int = 4) -> str: + prefix = f"{' ' * indent}{name}:" + line = next(line for line in lines if line.startswith(prefix)) + return _value(line.removeprefix(prefix)) + def test_config_preserves_builtin_permissions_and_restricts_research() -> None: - root = Path(__file__).parents[1] - config = json.loads((root / "opencode" / "opencode.json").read_text()) + config = json.loads((ROOT / "opencode" / "opencode.json").read_text()) assert "permission" not in config assert all(name not in config["agent"] for name in ("build", "plan", "general")) @@ -22,22 +93,113 @@ def test_config_preserves_builtin_permissions_and_restricts_research() -> None: assert config["agent"]["research"]["variant"] == "{env:AGENTCI_RESEARCH_VARIANT}" -def test_compose_removes_codex_sandbox_exceptions() -> None: - root = Path(__file__).parents[1] - compose = (root / "compose.yaml").read_text() - dockerfile = (root / "Dockerfile").read_text() +def test_compose_services_have_expected_runtime_contract() -> None: + agentci = _service("agentci") + opencode = _service("opencode") + agentci_environment = _mapping(agentci, "environment") + opencode_environment = _mapping(opencode, "environment") - for forbidden in ("cap_add", "seccomp=unconfined", "apparmor=unconfined", "bubblewrap"): + assert agentci_environment["AGENTCI_OPENCODE_URL"] == "http://opencode:4096" + assert agentci_environment["AGENTCI_EXPLORE_VARIANT"] == ("${AGENTCI_EXPLORE_VARIANT:-low}") + assert agentci_environment["AGENTCI_RESEARCH_VARIANT"] == ("${AGENTCI_RESEARCH_VARIANT:-high}") + assert opencode_environment["HOME"] == "/etc/opencode/home" + assert opencode_environment["OPENCODE_DISABLE_EXTERNAL_SKILLS"] == "1" + assert opencode_environment["OPENCODE_ENABLE_EXA"] == "1" + assert "OPENCODE_DISABLE_DEFAULT_PLUGINS" not in opencode_environment + + assert _sequence(agentci, "volumes") == [ + "agentci_data:/var/lib/agentci", + "./install-scripts:/etc/agentci/install-scripts:ro", + ] + assert _sequence(opencode, "volumes") == [ + "agentci_data:/var/lib/agentci", + "opencode_home:/var/lib/opencode", + ] + assert _sequence(agentci, "tmpfs") == ["/run/agentci:mode=1777"] + assert _sequence(opencode, "tmpfs") == ["/run/agentci:mode=1777"] + assert json.loads(_scalar(opencode, "command")) == [ + "opencode", + "serve", + "--hostname", + "0.0.0.0", + "--port", + "4096", + ] + + image = "${AGENTCI_IMAGE:-git.krtss.de/stanponomarev/agentci:latest}" + assert _scalar(agentci, "image") == image + assert _scalar(opencode, "image") == image + assert all(line.strip() != "build:" for line in [*agentci, *opencode]) + + dependency = _section(_section(agentci, "depends_on"), "opencode", indent=6) + assert _mapping([" dependency:", *dependency], "dependency", indent=6) == { + "condition": "service_healthy" + } + healthcheck = _mapping(opencode, "healthcheck") + health_command = json.loads(healthcheck["test"]) + assert health_command[0] == "CMD-SHELL" + assert "/global/health" in health_command[1] + assert "OPENCODE_SERVER_PASSWORD_FILE" in health_command[1] + + +def test_compose_has_no_sandbox_security_exceptions() -> None: + compose = (ROOT / "compose.yaml").read_text() + + for forbidden in ( + "cap_add:", + "security_opt:", + "privileged:", + "seccomp=unconfined", + "apparmor=unconfined", + "bubblewrap", + ): assert forbidden not in compose - assert "no_cache" not in compose - assert "opencode_home:/var/lib/opencode" in compose - assert "HOME: /etc/opencode/home" in compose - assert "OPENCODE_DISABLE_EXTERNAL_SKILLS" in compose - assert "OPENCODE_DISABLE_DEFAULT_PLUGINS" not in compose - assert 'OPENCODE_ENABLE_EXA: "1"' in compose - assert "AGENTCI_EXPLORE_VARIANT: ${AGENTCI_EXPLORE_VARIANT:-low}" in compose - assert "AGENTCI_RESEARCH_VARIANT: ${AGENTCI_RESEARCH_VARIANT:-high}" in compose - assert compose.count("/run/agentci:mode=1777") == 2 - assert "AGENTCI_OPENCODE_VERSION: ${AGENTCI_OPENCODE_VERSION:-^1}" in compose - assert "ARG AGENTCI_OPENCODE_VERSION=^1" in dockerfile - assert '"opencode-ai@${AGENTCI_OPENCODE_VERSION}"' in dockerfile + + +def test_gitea_workflow_publishes_master_images() -> None: + lines = (ROOT / ".gitea" / "workflows" / "publish-image.yaml").read_text().splitlines() + push = _section(_section(lines, "on", indent=0), "push", indent=2) + + assert _sequence(push, "branches", indent=4) == ["master"] + assert _mapping(lines, "env", indent=0)["IMAGE_NAME"] == ( + "git.krtss.de/stanponomarev/agentci" + ) + assert " ${{ env.IMAGE_NAME }}:latest" in lines + assert " ${{ env.IMAGE_NAME }}:${{ gitea.sha }}" in lines + assert " username: ${{ secrets.REGISTRY_USERNAME }}" in lines + assert " password: ${{ secrets.REGISTRY_TOKEN }}" in lines + + +def test_container_pins_opencode_major_version_contract() -> None: + lines = [line.strip() for line in (ROOT / "Dockerfile").read_text().splitlines()] + build_arguments = {line.removeprefix("ARG ") for line in lines if line.startswith("ARG ")} + + assert "AGENTCI_OPENCODE_VERSION=^1" in build_arguments + assert any( + line.rstrip("\\").strip() == '"opencode-ai@${AGENTCI_OPENCODE_VERSION}"' for line in lines + ) + + +def test_compose_document_validates_when_compose_cli_is_available() -> None: + docker = shutil.which("docker") + if docker is None: + pytest.skip("Docker Compose CLI is not installed") + probe = subprocess.run( + [docker, "compose", "version"], + cwd=ROOT, + capture_output=True, + text=True, + check=False, + ) + if probe.returncode: + pytest.skip("Docker Compose provider is not available") + + result = subprocess.run( + [docker, "compose", "config", "--quiet"], + cwd=ROOT, + capture_output=True, + text=True, + check=False, + ) + + assert result.returncode == 0, result.stderr diff --git a/tests/test_opencode_support.py b/tests/test_opencode_support.py index 759cebe..e5f05ae 100644 --- a/tests/test_opencode_support.py +++ b/tests/test_opencode_support.py @@ -1,4 +1,6 @@ -from agentci.adapters.opencode_support import api_contract_ready, models_ready +import pytest + +from agentci.integrations.opencode.readiness import api_contract_ready, models_ready def test_api_contract_requires_session_message_and_abort_routes() -> None: @@ -17,7 +19,124 @@ def test_api_contract_requires_session_message_and_abort_routes() -> None: assert not api_contract_ready(valid) -def test_model_readiness_requires_tools_and_configured_variant() -> None: +@pytest.mark.parametrize( + "document", + [ + None, + [], + {}, + {"paths": []}, + { + "paths": { + "/global/health": None, + "/provider": {"get": {}}, + "/session": {"post": {}}, + } + }, + { + "paths": { + "/global/health": {"get": {}}, + "/provider": {"get": {}}, + "/session": {"post": {}}, + 7: {"post": {}}, + } + }, + ], +) +def test_api_contract_rejects_malformed_documents(document: object) -> None: + assert not api_contract_ready(document) + + +def provider_payload( + *, + connected: object = None, + status: str = "active", + toolcall: object = True, + variants: object = None, +) -> dict[str, object]: + selected_connected = ["openai"] if connected is None else connected + selected_variants = {"high": {}} if variants is None else variants + return { + "connected": selected_connected, + "all": [ + { + "id": "openai", + "models": { + "model": { + "status": status, + "capabilities": {"toolcall": toolcall}, + "variants": selected_variants, + } + }, + } + ], + } + + +@pytest.mark.parametrize( + ("payload", "requirement", "expected"), + [ + (provider_payload(), ("openai", "model", "high"), True), + (provider_payload(), ("openai", "model", None), True), + (provider_payload(connected=("anthropic",)), ("openai", "model", "high"), False), + (provider_payload(status="deprecated"), ("openai", "model", "high"), False), + (provider_payload(toolcall=False), ("openai", "model", "high"), False), + (provider_payload(variants={}), ("openai", "model", "high"), False), + (provider_payload(), ("openai", "missing", "high"), False), + ], +) +def test_model_readiness_matrix( + payload: object, requirement: tuple[str, str, str | None], expected: bool +) -> None: + assert models_ready(payload, {requirement}) is expected + + +@pytest.mark.parametrize( + "payload", + [ + None, + [], + {}, + {"connected": None, "all": []}, + {"connected": ["openai"], "all": None}, + {"connected": ["openai"], "all": [{"id": [], "models": {}}]}, + { + "connected": ["openai"], + "all": [ + { + "id": "openai", + "models": { + "model": { + "status": "active", + "capabilities": None, + "variants": {"high": {}}, + } + }, + } + ], + }, + { + "connected": ["openai"], + "all": [ + { + "id": "openai", + "models": { + "model": { + "status": "active", + "capabilities": {"toolcall": True}, + "variants": None, + } + }, + } + ], + }, + ], +) +def test_model_readiness_rejects_malformed_payloads(payload: object) -> None: + assert not models_ready(payload, {("openai", "model", "high")}) + + +def test_model_readiness_requires_all_configured_models() -> None: payload = { "connected": ["openai"], "all": [ @@ -34,7 +153,10 @@ def test_model_readiness_requires_tools_and_configured_variant() -> None: ], } - assert models_ready(payload, {("openai", "model", "high")}) - assert not models_ready(payload, {("openai", "model", "missing")}) - payload["all"][0]["models"]["model"]["capabilities"]["toolcall"] = False - assert not models_ready(payload, {("openai", "model", "high")}) + assert not models_ready( + payload, + { + ("openai", "model", "high"), + ("openai", "other", None), + }, + ) diff --git a/tests/test_prompts.py b/tests/test_prompts.py new file mode 100644 index 0000000..56884e3 --- /dev/null +++ b/tests/test_prompts.py @@ -0,0 +1,109 @@ +from pathlib import Path +from string import Template + +import pytest +from pydantic import BaseModel + +import agentci.prompts +from agentci.integrations.opencode.schemas import load_schema +from agentci.prompts.library import PromptLibrary +from agentci.workflows.model import ( + AgentResult, + DiscussionReply, + PlanArtifact, + ReviewFinding, + ReviewReport, +) + +PROMPTS = { + "plan_initial": {"context", "request"}, + "plan_review": {"context", "artifact"}, + "plan_revision": {"artifact", "review"}, + "discuss": {"artifact", "message"}, + "plan_iterate": {"context", "artifact", "review", "message"}, + "implement_initial": { + "context", + "artifact", + "request", + "development_environment", + }, + "implementation_review": {"issue_context", "artifact", "pull_context"}, + "implementation_revision": {"review", "development_environment"}, + "implementation_iterate": { + "context", + "review", + "message", + "development_environment", + }, + "fix": {"context", "message", "development_environment"}, +} + +SCHEMAS: dict[str, tuple[type[BaseModel], set[str]]] = { + "plan.json": (PlanArtifact, {"plan_markdown"}), + "discussion.json": (DiscussionReply, {"markdown"}), + "agent_result.json": (AgentResult, {"summary_markdown", "tests"}), + "review.json": (ReviewReport, {"summary", "findings"}), +} + + +def prompt_directory() -> Path: + module_file = agentci.prompts.__file__ + assert module_file is not None + return Path(module_file).parent + + +@pytest.mark.parametrize(("name", "identifiers"), PROMPTS.items()) +def test_referenced_prompt_loads_and_renders(name: str, identifiers: set[str]) -> None: + directory = prompt_directory() + source = (directory / f"{name}.md").read_text() + values = {identifier: f"<{identifier}-value>" for identifier in identifiers} + + assert set(Template(source).get_identifiers()) == identifiers + rendered = PromptLibrary(directory).render(name, **values) + for value in values.values(): + assert value in rendered + + +def test_prompt_resource_set_matches_workflow_references() -> None: + names = {path.stem for path in prompt_directory().glob("*.md")} + + assert names == set(PROMPTS) + + +def test_prompt_render_rejects_missing_template_value() -> None: + with pytest.raises(KeyError, match="request"): + PromptLibrary(prompt_directory()).render("plan_initial", context="context") + + +@pytest.mark.parametrize(("name", "contract"), SCHEMAS.items()) +def test_referenced_schema_matches_result_model( + name: str, contract: tuple[type[BaseModel], set[str]] +) -> None: + model, required = contract + schema = load_schema(prompt_directory() / "schemas", name) + + assert schema["type"] == "object" + assert schema["additionalProperties"] is False + assert set(schema["properties"]) == set(model.model_fields) + assert set(schema["required"]) == required + + +def test_review_finding_schema_matches_model_and_severity_values() -> None: + schema = load_schema(prompt_directory() / "schemas", "review.json") + finding = schema["properties"]["findings"]["items"] + + assert set(finding["properties"]) == set(ReviewFinding.model_fields) + assert set(finding["required"]) == set(ReviewFinding.model_fields) + assert finding["properties"]["severity"]["enum"] == [ + "blocking", + "major", + "minor", + ] + assert finding["additionalProperties"] is False + + +def test_schema_resource_set_matches_workflow_references() -> None: + schema_directory = prompt_directory() / "schemas" + names = {path.name for path in schema_directory.glob("*.json")} + + assert names == set(SCHEMAS) diff --git a/tests/test_reducer.py b/tests/test_reducer.py new file mode 100644 index 0000000..f101151 --- /dev/null +++ b/tests/test_reducer.py @@ -0,0 +1,414 @@ +from dataclasses import FrozenInstanceError, replace +from pathlib import Path + +import pytest + +from agentci.engine.events import ( + CommandReceived, + CommentLinked, + JobCompleted, + JobFailed, + JobProgress, + JobRejected, + JobStarted, + PermissionDenied, + PermissionGranted, + RuntimeSessionLinked, + ServiceRestarted, + WorkflowCreated, + WorkflowLinked, +) +from agentci.engine.model import ( + Job, + JobKind, + JobStatus, + QueueName, + TaskKind, + TaskRequest, + Workflow, + WorkflowKind, +) +from agentci.engine.reducer import InvalidTransition, Transition, reduce_job, render_job_comment + + +def received(body: str = "/agent plan message", *, pr_number: int | None = None) -> Job: + return reduce_job( + None, + CommandReceived( + job_id="job", + delivery_id="delivery", + receive_sequence=1, + command_body=body, + target_key="org/repo:pr:8" if pr_number else "org/repo:issue:1", + repo_owner="org", + repo_name="repo", + issue_number=1, + pr_number=pr_number, + requester="alice", + comment_id=4, + ), + ).job + + +def queued(body: str = "/agent plan message", *, pr_number: int | None = None) -> Job: + state = received(body, pr_number=pr_number) + return reduce_job(state, PermissionGranted(job_id=state.id)).job + + +def running(*, workflow_id: str | None = None) -> Job: + state = queued() + state = reduce_job(state, JobStarted(job_id=state.id)).job + return replace(state, workflow_id=workflow_id) + + +def task_order(transition: Transition) -> list[tuple[TaskKind, QueueName]]: + return [(task.kind, task.queue) for task in transition.tasks] + + +def test_command_received_preserves_input_and_requests_authorization() -> None: + transition = reduce_job( + None, + CommandReceived( + job_id="job-7", + delivery_id="delivery-7", + receive_sequence=7, + command_body="/agent fix race", + target_key="org/repo:pr:8", + repo_owner="org", + repo_name="repo", + issue_number=1, + pr_number=8, + requester="alice", + comment_id=4, + ), + ) + + assert transition == Transition( + Job( + id="job-7", + target_key="org/repo:pr:8", + repo_owner="org", + repo_name="repo", + issue_number=1, + pr_number=8, + requester="alice", + comment_id=4, + delivery_id="delivery-7", + receive_sequence=7, + command_body="/agent fix race", + ), + (TaskRequest(TaskKind.AUTHORIZE, QueueName.CONTROL),), + ) + + +@pytest.mark.parametrize( + ("body", "pr_number", "expected_kind", "expected_message"), + [ + ("/agent plan write tests", None, JobKind.PLAN, "write tests"), + ("/agent iterate refine", None, JobKind.ITERATE_PLAN, "refine"), + ("/agent iterate address review", 8, JobKind.ITERATE_IMPLEMENT, "address review"), + ], +) +def test_permission_granted_queues_the_resolved_command( + body: str, + pr_number: int | None, + expected_kind: JobKind, + expected_message: str, +) -> None: + transition = reduce_job( + received(body, pr_number=pr_number), + PermissionGranted(job_id="job"), + ) + + assert ( + transition.job.status, + transition.job.stage, + transition.job.kind, + transition.job.message, + task_order(transition), + ) == ( + JobStatus.QUEUED, + "queued", + expected_kind, + expected_message, + [ + (TaskKind.EXECUTE, QueueName.JOBS), + (TaskKind.RECONCILE_COMMENT, QueueName.CONTROL), + ], + ) + + +@pytest.mark.parametrize( + ("event", "expected_error"), + [ + (PermissionDenied(job_id="job"), "repository write permission is required"), + (PermissionGranted(job_id="job"), "Unknown agent command"), + ], +) +def test_permission_rejection_is_terminal_and_reconciled( + event: PermissionDenied | PermissionGranted, + expected_error: str, +) -> None: + state = received("/agent nonsense") if isinstance(event, PermissionGranted) else received() + transition = reduce_job(state, event) + + assert ( + transition.job.status, + transition.job.stage, + expected_error in (transition.job.error or ""), + task_order(transition), + ) == ( + JobStatus.REJECTED, + "rejected", + True, + [(TaskKind.RECONCILE_COMMENT, QueueName.CONTROL)], + ) + + +def test_start_moves_a_queued_job_to_running_and_reconciles() -> None: + transition = reduce_job(queued(), JobStarted(job_id="job")) + + assert ( + transition.job.status, + transition.job.stage, + task_order(transition), + ) == ( + JobStatus.RUNNING, + "starting", + [(TaskKind.RECONCILE_COMMENT, QueueName.CONTROL)], + ) + + +@pytest.mark.parametrize( + ("event", "changes"), + [ + (JobProgress(job_id="job", stage="cloning"), {"stage": "cloning"}), + ( + WorkflowCreated( + job_id="job", + workflow=Workflow( + id="created-workflow", + kind=WorkflowKind.PLAN, + repo_owner="org", + repo_name="repo", + issue_number=1, + workspace_path=Path("/work/repo"), + base_sha="abc", + ), + stage="planning", + ), + {"workflow_id": "created-workflow", "stage": "planning"}, + ), + ( + WorkflowLinked(job_id="job", workflow_id="linked-workflow", stage="discussing"), + {"workflow_id": "linked-workflow", "stage": "discussing"}, + ), + ( + RuntimeSessionLinked(job_id="job", session_id="session-1"), + {"runtime_session_id": "session-1"}, + ), + ], +) +def test_running_progress_and_links_update_only_reported_fields( + event: JobProgress | WorkflowCreated | WorkflowLinked | RuntimeSessionLinked, + changes: dict[str, object], +) -> None: + state = running() + transition = reduce_job(state, event) + + assert transition == Transition(replace(state, **changes)) + + +def test_completion_persists_the_result_and_reconciles() -> None: + transition = reduce_job(running(), JobCompleted(job_id="job", comment_body="# Result")) + + assert ( + transition.job.status, + transition.job.stage, + transition.job.comment_body, + task_order(transition), + ) == ( + JobStatus.SUCCEEDED, + "completed", + "# Result", + [(TaskKind.RECONCILE_COMMENT, QueueName.CONTROL)], + ) + + +@pytest.mark.parametrize( + ("event", "workflow_id", "expected_status", "expected_stage", "expected_error", "tasks"), + [ + ( + JobFailed(job_id="job", stage="testing", error="tests failed"), + None, + JobStatus.FAILED, + "testing", + "tests failed", + [(TaskKind.RECONCILE_COMMENT, QueueName.CONTROL)], + ), + ( + JobFailed(job_id="job", stage="testing", error="tests failed"), + "workflow", + JobStatus.FAILED, + "testing", + "tests failed", + [ + (TaskKind.RECONCILE_COMMENT, QueueName.CONTROL), + (TaskKind.FAIL_WORKFLOW, QueueName.CONTROL), + ], + ), + ( + JobRejected(job_id="job", reason="no plan exists"), + "workflow", + JobStatus.REJECTED, + "rejected", + "no plan exists", + [ + (TaskKind.RECONCILE_COMMENT, QueueName.CONTROL), + (TaskKind.FAIL_WORKFLOW, QueueName.CONTROL), + ], + ), + ], +) +def test_running_failure_and_rejection_are_terminal_with_ordered_cleanup( + event: JobFailed | JobRejected, + workflow_id: str | None, + expected_status: JobStatus, + expected_stage: str, + expected_error: str, + tasks: list[tuple[TaskKind, QueueName]], +) -> None: + transition = reduce_job(running(workflow_id=workflow_id), event) + + assert ( + transition.job.status, + transition.job.stage, + transition.job.error, + task_order(transition), + ) == (expected_status, expected_stage, expected_error, tasks) + + +@pytest.mark.parametrize( + ("workflow_id", "tasks"), + [ + ( + None, + [ + (TaskKind.ABORT_SESSIONS, QueueName.CONTROL), + (TaskKind.RECONCILE_COMMENT, QueueName.CONTROL), + ], + ), + ( + "workflow", + [ + (TaskKind.ABORT_SESSIONS, QueueName.CONTROL), + (TaskKind.FAIL_WORKFLOW, QueueName.CONTROL), + (TaskKind.RECONCILE_COMMENT, QueueName.CONTROL), + ], + ), + ], +) +def test_restart_fails_a_running_job_with_ordered_cleanup( + workflow_id: str | None, + tasks: list[tuple[TaskKind, QueueName]], +) -> None: + transition = reduce_job(running(workflow_id=workflow_id), ServiceRestarted(job_id="job")) + + assert ( + transition.job.status, + transition.job.stage, + transition.job.error, + task_order(transition), + ) == ( + JobStatus.FAILED, + "interrupted", + "Service restarted during an active OpenCode turn", + tasks, + ) + + +def test_restart_is_a_noop_after_a_job_is_terminal() -> None: + completed = reduce_job(running(), JobCompleted(job_id="job", comment_body="ok")).job + + assert reduce_job(completed, ServiceRestarted(job_id="job")) == Transition(completed) + + +def test_comment_link_is_allowed_after_a_job_is_terminal() -> None: + completed = reduce_job(running(), JobCompleted(job_id="job", comment_body="ok")).job + + assert reduce_job(completed, CommentLinked(job_id="job", comment_id=9)).job == replace( + completed, accepted_comment_id=9 + ) + + +@pytest.mark.parametrize( + ("state", "event"), + [ + (received(), JobStarted(job_id="job")), + (queued(), PermissionGranted(job_id="job")), + (running(), PermissionGranted(job_id="job")), + ( + reduce_job(running(), JobCompleted(job_id="job", comment_body="ok")).job, + JobStarted(job_id="job"), + ), + ], + ids=["received", "queued", "running", "terminal"], +) +def test_events_invalid_for_the_current_status_are_rejected( + state: Job, + event: JobStarted | PermissionGranted, +) -> None: + with pytest.raises(InvalidTransition, match="invalid while job"): + reduce_job(state, event) + + +def test_only_command_received_can_create_state() -> None: + with pytest.raises(InvalidTransition, match="Only CommandReceived"): + reduce_job(None, PermissionGranted(job_id="job")) + + +def test_event_job_id_must_match_state() -> None: + with pytest.raises(InvalidTransition, match="job ID does not match"): + reduce_job(received(), PermissionDenied(job_id="another-job")) + + +@pytest.mark.parametrize( + ("state", "expected"), + [ + ( + received(), + "\nAgent job `job` received (`command`; stage: `received`).", + ), + ( + queued(), + "\nAgent job `job` queued (`plan`; stage: `queued`).", + ), + ( + replace( + running(), + status=JobStatus.SUCCEEDED, + stage="completed", + comment_body="# Done", + ), + "\n# Done", + ), + ( + replace(received(), status=JobStatus.REJECTED, stage="rejected", error="not allowed"), + "\nAgent job `job` was rejected: not allowed", + ), + ( + replace(running(), status=JobStatus.FAILED, stage="testing", error="failed"), + "\nAgent job `job` failed during `testing`: failed", + ), + ], + ids=["received", "queued", "succeeded", "rejected", "failed"], +) +def test_render_comment_describes_each_job_outcome(state: Job, expected: str) -> None: + assert render_job_comment(state) == expected + + +def test_job_state_is_immutable() -> None: + state = received() + + with pytest.raises(FrozenInstanceError): + state.stage = "changed" # type: ignore[misc] diff --git a/tests/test_repository.py b/tests/test_repository.py new file mode 100644 index 0000000..2594248 --- /dev/null +++ b/tests/test_repository.py @@ -0,0 +1,462 @@ +import sqlite3 +from contextlib import closing +from dataclasses import replace +from pathlib import Path +from typing import cast + +import pytest + +from agentci.engine import _sqlite +from agentci.engine.events import ( + CommentLinked, + JobCompleted, + JobStarted, + PermissionGranted, + WorkflowCreated, +) +from agentci.engine.model import ( + IncomingCommand, + Job, + QueueName, + TaskKind, + TaskRequest, + Workflow, + WorkflowKind, + WorkflowStatus, +) +from agentci.engine.reducer import Transition +from agentci.engine.repository import Repository + +MIGRATIONS = Path(__file__).parents[1] / "src" / "agentci" / "migrations" + + +class TrackingConnection: + def __init__(self) -> None: + self.closed = False + + def __enter__(self) -> "TrackingConnection": + return self + + def __exit__(self, *_args: object) -> None: + return None + + def close(self) -> None: + self.closed = True + + +class FailingSetupConnection: + def __init__(self) -> None: + self.closed = False + self.row_factory: object | None = None + + def execute(self, _statement: str) -> None: + raise sqlite3.OperationalError("pragma failed") + + def close(self) -> None: + self.closed = True + + +@pytest.fixture +async def repository(tmp_path: Path) -> Repository: + value = Repository(tmp_path / "state.sqlite3", MIGRATIONS) + await value.initialize() + return value + + +def command( + delivery: str, + body: str = "/agent plan", + *, + issue: int = 3, + pr: int | None = None, +) -> IncomingCommand: + return IncomingCommand( + delivery_id=delivery, + comment_id=int(delivery.rsplit("-", 1)[-1]), + repo_owner="alice", + repo_name="repo", + issue_number=issue, + pr_number=pr, + requester="alice", + body=body, + ) + + +async def create_running_job(repository: Repository, delivery: str, *, issue: int = 3) -> Job: + job = (await repository.accept(command(delivery, issue=issue))).job + job = (await repository.apply(f"{delivery}:grant", PermissionGranted(job_id=job.id))).job + return (await repository.apply(f"{delivery}:start", JobStarted(job_id=job.id))).job + + +async def test_repository_closes_connections_after_success_and_failure( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + connections = [TrackingConnection(), TrackingConnection()] + monkeypatch.setattr( + _sqlite, + "connect", + lambda _path: cast(sqlite3.Connection, connections.pop(0)), + ) + repository = Repository(tmp_path / "state.sqlite3", MIGRATIONS) + successful = cast(TrackingConnection, await repository._run(lambda connection: connection)) + + def fail(_connection: sqlite3.Connection) -> None: + raise RuntimeError("operation failed") + + failing = connections[0] + with pytest.raises(RuntimeError, match="operation failed"): + await repository._run(fail) + + assert successful.closed + assert failing.closed + + +def test_sqlite_setup_closes_connection_after_pragma_failure( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + connection = FailingSetupConnection() + monkeypatch.setattr( + _sqlite.sqlite3, + "connect", + lambda *_args, **_kwargs: cast(sqlite3.Connection, connection), + ) + + with pytest.raises(sqlite3.OperationalError, match="pragma failed"): + _sqlite.connect(tmp_path / "state.sqlite3") + + assert connection.closed + + +def build_workflow( + workflow_id: str, + workspace: Path, + *, + kind: WorkflowKind = WorkflowKind.PLAN, + owner: str = "alice", + repo: str = "repo", + issue: int = 3, + pr: int | None = None, + status: WorkflowStatus = WorkflowStatus.ACTIVE, +) -> Workflow: + return Workflow( + id=workflow_id, + kind=kind, + repo_owner=owner, + repo_name=repo, + issue_number=issue, + pr_number=pr, + workspace_path=workspace / workflow_id, + base_sha=f"base-{workflow_id}", + status=status, + ) + + +async def attach_workflow( + repository: Repository, + workspace: Path, + *, + delivery: str, + workflow_id: str, + status: WorkflowStatus = WorkflowStatus.ACTIVE, +) -> tuple[Job, Workflow]: + job = await create_running_job(repository, delivery) + workflow = build_workflow(workflow_id, workspace, status=status) + result = await repository.apply( + f"{workflow_id}:created", + WorkflowCreated(job_id=job.id, workflow=workflow, stage="planning"), + ) + return result.job, workflow + + +def insert_workflow( + connection: sqlite3.Connection, + workflow: Workflow, + created_at: str, +) -> None: + connection.execute( + """INSERT INTO workflows( + id, kind, repo_owner, repo_name, issue_number, pr_number, base_sha, branch, + workspace_path, primary_session_id, reviewer_session_id, artifact, review_json, + status, runtime, created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""", + ( + workflow.id, + workflow.kind.value, + workflow.repo_owner, + workflow.repo_name, + workflow.issue_number, + workflow.pr_number, + workflow.base_sha, + workflow.branch, + str(workflow.workspace_path), + workflow.primary_session_id, + workflow.reviewer_session_id, + workflow.artifact, + workflow.review_json, + workflow.status.value, + workflow.runtime, + created_at, + created_at, + ), + ) + + +async def test_receive_is_idempotent_without_consuming_sequence( + repository: Repository, +) -> None: + first = await repository.accept(command("delivery-1")) + duplicate = await repository.accept(command("delivery-1")) + second = await repository.accept(command("delivery-2")) + + assert ( + first.duplicate, + duplicate.duplicate, + duplicate.job.id, + second.job.receive_sequence, + ) == (False, True, first.job.id, first.job.receive_sequence + 1) + + +async def test_repository_stamps_start_and_completion_once( + repository: Repository, + monkeypatch: pytest.MonkeyPatch, +) -> None: + clock = {"now": "2026-02-01T00:00:00+00:00"} + monkeypatch.setattr(_sqlite, "now", lambda: clock["now"]) + job = (await repository.accept(command("delivery-1"))).job + job = (await repository.apply("grant", PermissionGranted(job_id=job.id))).job + clock["now"] = "2026-02-01T00:01:00+00:00" + job = (await repository.apply("start", JobStarted(job_id=job.id))).job + clock["now"] = "2026-02-01T00:02:00+00:00" + job = (await repository.apply("complete", JobCompleted(job_id=job.id, comment_body="done"))).job + clock["now"] = "2026-02-01T00:03:00+00:00" + await repository.apply("comment", CommentLinked(job_id=job.id, comment_id=99)) + + with closing(sqlite3.connect(repository.database_path)) as connection, connection: + timestamps = connection.execute( + "SELECT started_at, finished_at FROM jobs WHERE id=?", (job.id,) + ).fetchone() + + assert timestamps == ( + "2026-02-01T00:01:00+00:00", + "2026-02-01T00:02:00+00:00", + ) + + +async def test_apply_rolls_back_event_job_workflow_and_task_after_mid_apply_failure( + repository: Repository, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + job = await create_running_job(repository, "delivery-1") + workflow = build_workflow("workflow-rollback", tmp_path) + original_insert_tasks = _sqlite.insert_tasks + + def fail_after_task_write( + connection: sqlite3.Connection, + event_id: str, + transition: Transition, + timestamp: str, + ) -> None: + forced_task = TaskRequest(TaskKind.RECONCILE_COMMENT, QueueName.CONTROL) + original_insert_tasks( + connection, + event_id, + replace(transition, tasks=(forced_task,)), + timestamp, + ) + raise RuntimeError("injected task persistence failure") + + monkeypatch.setattr(_sqlite, "insert_tasks", fail_after_task_write) + + with pytest.raises(RuntimeError, match="injected task persistence failure"): + await repository.apply( + "workflow-rollback:created", + WorkflowCreated(job_id=job.id, workflow=workflow, stage="planning"), + ) + + persisted_job = await repository.get_job(job.id) + persisted_workflow = await repository.get_workflow(workflow.id) + with closing(sqlite3.connect(repository.database_path)) as connection, connection: + event_count = connection.execute( + "SELECT COUNT(*) FROM job_events WHERE event_id=?", + ("workflow-rollback:created",), + ).fetchone() + task_count = connection.execute( + "SELECT COUNT(*) FROM listener_tasks WHERE source_event_id=?", + ("workflow-rollback:created",), + ).fetchone() + + assert (persisted_job, persisted_workflow, event_count, task_count) == ( + job, + None, + (0,), + (0,), + ) + + +async def test_duplicate_event_application_does_not_duplicate_tasks( + repository: Repository, +) -> None: + job = (await repository.accept(command("delivery-1"))).job + applied = await repository.apply("permission", PermissionGranted(job_id=job.id)) + duplicate = await repository.apply("permission", PermissionGranted(job_id=job.id)) + + with closing(sqlite3.connect(repository.database_path)) as connection, connection: + tasks = connection.execute( + """SELECT ordinal, listener, queue FROM listener_tasks + WHERE source_event_id=? ORDER BY ordinal""", + ("permission",), + ).fetchall() + + assert (duplicate.duplicate, duplicate.job, tasks) == ( + True, + applied.job, + [(0, "execute", "jobs"), (1, "reconcile_comment", "control")], + ) + + +async def test_workflow_creation_and_job_link_commit_together( + repository: Repository, + tmp_path: Path, +) -> None: + job, workflow = await attach_workflow( + repository, + tmp_path, + delivery="delivery-1", + workflow_id="workflow-atomic", + ) + + assert (job.workflow_id, await repository.get_workflow(workflow.id)) == ( + workflow.id, + workflow, + ) + + +async def test_save_workflow_updates_the_mutable_snapshot_and_timestamp( + repository: Repository, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + _, workflow = await attach_workflow( + repository, + tmp_path, + delivery="delivery-1", + workflow_id="workflow-update", + ) + updated = replace( + workflow, + pr_number=17, + branch="agent/updated", + primary_session_id="primary", + reviewer_session_id="reviewer", + artifact="# Updated plan", + review_json='{"verdict":"approved"}', + status=WorkflowStatus.COMPLETED, + ) + monkeypatch.setattr(_sqlite, "now", lambda: "2026-02-02T00:00:00+00:00") + + await repository.save_workflow(updated) + + with closing(sqlite3.connect(repository.database_path)) as connection, connection: + updated_at = connection.execute( + "SELECT updated_at FROM workflows WHERE id=?", (workflow.id,) + ).fetchone() + assert (await repository.get_workflow(workflow.id), updated_at) == ( + updated, + ("2026-02-02T00:00:00+00:00",), + ) + + +async def test_saving_unknown_workflow_fails(repository: Repository, tmp_path: Path) -> None: + workflow = build_workflow("missing", tmp_path) + + with pytest.raises(KeyError, match="Unknown workflow"): + await repository.save_workflow(workflow) + + +async def test_fail_job_workflow_fails_only_active_workflows( + repository: Repository, + tmp_path: Path, +) -> None: + active_job, active = await attach_workflow( + repository, + tmp_path, + delivery="delivery-1", + workflow_id="active-workflow", + ) + completed_job, completed = await attach_workflow( + repository, + tmp_path, + delivery="delivery-2", + workflow_id="completed-workflow", + status=WorkflowStatus.COMPLETED, + ) + + await repository.fail_job_workflow(active_job.id) + await repository.fail_job_workflow(completed_job.id) + + assert ( + (await repository.get_workflow(active.id)).status, # type: ignore[union-attr] + (await repository.get_workflow(completed.id)).status, # type: ignore[union-attr] + ) == (WorkflowStatus.FAILED, WorkflowStatus.COMPLETED) + + +async def test_latest_workflow_returns_newest_completed_match( + repository: Repository, + tmp_path: Path, +) -> None: + older = build_workflow("plan-older", tmp_path, status=WorkflowStatus.COMPLETED) + newest = build_workflow("plan-newest", tmp_path, status=WorkflowStatus.COMPLETED) + active = build_workflow("plan-active", tmp_path) + wrong_issue = build_workflow( + "plan-wrong-issue", tmp_path, issue=4, status=WorkflowStatus.COMPLETED + ) + with closing(sqlite3.connect(repository.database_path)) as connection, connection: + insert_workflow(connection, older, "2026-01-01T00:00:00+00:00") + insert_workflow(connection, newest, "2026-01-02T00:00:00+00:00") + insert_workflow(connection, active, "2026-01-03T00:00:00+00:00") + insert_workflow(connection, wrong_issue, "2026-01-04T00:00:00+00:00") + + assert await repository.latest_workflow("alice", "repo", 3, WorkflowKind.PLAN) == newest + + +async def test_workflow_for_pr_returns_newest_implementation_match( + repository: Repository, + tmp_path: Path, +) -> None: + older = build_workflow("implementation-older", tmp_path, kind=WorkflowKind.IMPLEMENT, pr=17) + newest = build_workflow("implementation-newest", tmp_path, kind=WorkflowKind.IMPLEMENT, pr=17) + plan = build_workflow("plan-same-pr", tmp_path, pr=17) + wrong_repo = build_workflow( + "implementation-wrong-repo", + tmp_path, + kind=WorkflowKind.IMPLEMENT, + repo="another", + pr=17, + ) + with closing(sqlite3.connect(repository.database_path)) as connection, connection: + insert_workflow(connection, older, "2026-01-01T00:00:00+00:00") + insert_workflow(connection, newest, "2026-01-02T00:00:00+00:00") + insert_workflow(connection, plan, "2026-01-03T00:00:00+00:00") + insert_workflow(connection, wrong_repo, "2026-01-04T00:00:00+00:00") + + assert await repository.workflow_for_pr("alice", "repo", 17) == newest + + +async def test_implementation_workflows_are_newest_first_and_require_a_pr( + repository: Repository, + tmp_path: Path, +) -> None: + older = build_workflow("implementation-older", tmp_path, kind=WorkflowKind.IMPLEMENT, pr=17) + newest = build_workflow("implementation-newest", tmp_path, kind=WorkflowKind.IMPLEMENT, pr=18) + no_pr = build_workflow("implementation-no-pr", tmp_path, kind=WorkflowKind.IMPLEMENT) + plan = build_workflow("plan-with-pr", tmp_path, pr=19) + with closing(sqlite3.connect(repository.database_path)) as connection, connection: + insert_workflow(connection, older, "2026-01-01T00:00:00+00:00") + insert_workflow(connection, newest, "2026-01-02T00:00:00+00:00") + insert_workflow(connection, no_pr, "2026-01-03T00:00:00+00:00") + insert_workflow(connection, plan, "2026-01-04T00:00:00+00:00") + + assert await repository.implementation_workflows("alice", "repo", 3) == [newest, older] diff --git a/tests/test_repository_concurrency.py b/tests/test_repository_concurrency.py new file mode 100644 index 0000000..cb3bed1 --- /dev/null +++ b/tests/test_repository_concurrency.py @@ -0,0 +1,123 @@ +import asyncio +import sqlite3 +from contextlib import closing +from pathlib import Path + +import pytest + +from agentci.engine.events import PermissionDenied, PermissionGranted +from agentci.engine.model import IncomingCommand, QueueName, TaskKind +from agentci.engine.repository import Repository + +MIGRATIONS = Path(__file__).parents[1] / "src" / "agentci" / "migrations" + + +@pytest.fixture +async def repository(tmp_path: Path) -> Repository: + value = Repository(tmp_path / "state.sqlite3", MIGRATIONS) + await value.initialize() + return value + + +def command(delivery: str, *, issue: int = 3) -> IncomingCommand: + return IncomingCommand( + delivery_id=delivery, + comment_id=int(delivery.rsplit("-", 1)[-1]), + repo_owner="alice", + repo_name="repo", + issue_number=issue, + pr_number=None, + requester="alice", + body="/agent plan", + ) + + +async def test_concurrent_duplicate_accepts_create_one_job_and_event( + repository: Repository, +) -> None: + results = await asyncio.gather( + repository.accept(command("delivery-1")), + repository.accept(command("delivery-1")), + ) + + assert sorted(result.duplicate for result in results) == [False, True] + assert len({result.job.id for result in results}) == 1 + with closing(sqlite3.connect(repository.database_path)) as connection, connection: + job_count = connection.execute("SELECT COUNT(*) FROM jobs").fetchone() + event_count = connection.execute("SELECT COUNT(*) FROM job_events").fetchone() + assert job_count == (1,) + assert event_count == (1,) + + second = await repository.accept(command("delivery-2")) + assert second.job.receive_sequence == results[0].job.receive_sequence + 1 + + +async def test_received_job_blocks_later_execute_task_for_same_target( + repository: Repository, +) -> None: + first = (await repository.accept(command("delivery-1"))).job + second = (await repository.accept(command("delivery-2"))).job + await repository.apply("grant-2", PermissionGranted(job_id=second.id)) + + assert await repository.claim_task(QueueName.JOBS) is None + + await repository.apply("deny-1", PermissionDenied(job_id=first.id)) + task = await repository.claim_task(QueueName.JOBS) + assert task is not None + assert task.job_id == second.id + assert task.kind is TaskKind.EXECUTE + + +async def test_received_job_does_not_block_a_different_target( + repository: Repository, +) -> None: + await repository.accept(command("delivery-1", issue=3)) + second = (await repository.accept(command("delivery-2", issue=4))).job + await repository.apply("grant-2", PermissionGranted(job_id=second.id)) + + task = await repository.claim_task(QueueName.JOBS) + assert task is not None + assert task.job_id == second.id + + +async def test_claims_multiple_eligible_targets_without_duplicates( + repository: Repository, +) -> None: + first = (await repository.accept(command("delivery-1", issue=3))).job + second = (await repository.accept(command("delivery-2", issue=4))).job + await repository.apply("grant-1", PermissionGranted(job_id=first.id)) + await repository.apply("grant-2", PermissionGranted(job_id=second.id)) + + claimed = [ + await repository.claim_task(QueueName.JOBS), + await repository.claim_task(QueueName.JOBS), + ] + + assert [task.job_id for task in claimed if task is not None] == [first.id, second.id] + assert await repository.claim_task(QueueName.JOBS) is None + + +async def test_concurrent_claims_do_not_duplicate_task(repository: Repository) -> None: + await repository.accept(command("delivery-1")) + + claims = await asyncio.gather( + repository.claim_task(QueueName.CONTROL), + repository.claim_task(QueueName.CONTROL), + ) + + claimed = [task for task in claims if task is not None] + assert len(claimed) == 1 + assert claimed[0].kind is TaskKind.AUTHORIZE + assert claimed[0].queue is QueueName.CONTROL + assert await repository.claim_task(QueueName.CONTROL) is None + + +async def test_duplicate_event_id_cannot_be_reused_for_another_job( + repository: Repository, +) -> None: + first = (await repository.accept(command("delivery-1", issue=3))).job + second = (await repository.accept(command("delivery-2", issue=4))).job + await repository.apply("permission", PermissionGranted(job_id=first.id)) + + with pytest.raises(RuntimeError): + await repository.apply("permission", PermissionGranted(job_id=second.id)) diff --git a/tests/test_repository_migration.py b/tests/test_repository_migration.py new file mode 100644 index 0000000..25abb26 --- /dev/null +++ b/tests/test_repository_migration.py @@ -0,0 +1,413 @@ +import sqlite3 +from contextlib import closing +from pathlib import Path + +import pytest + +from agentci.engine.model import JobKind, JobStatus, Workflow, WorkflowKind, WorkflowStatus +from agentci.engine.repository import Repository + +MIGRATIONS = Path(__file__).parents[1] / "src" / "agentci" / "migrations" +PRE_V3_CREATED_AT = "2026-01-01T00:00:00+00:00" + + +def create_schema_v2_database(database_path: Path, workspace: Path) -> None: + with closing(sqlite3.connect(database_path)) as connection, connection: + connection.executescript((MIGRATIONS / "001_initial.sql").read_text()) + connection.execute( + "INSERT INTO schema_migrations(version, applied_at) VALUES (?, ?)", + (1, "2025-11-01T00:00:00+00:00"), + ) + connection.executescript((MIGRATIONS / "002_opencode_sessions.sql").read_text()) + connection.execute( + "INSERT INTO schema_migrations(version, applied_at) VALUES (?, ?)", + (2, "2025-12-01T00:00:00+00:00"), + ) + connection.executemany( + "INSERT INTO deliveries(delivery_id, comment_id, received_at) VALUES (?, ?, ?)", + [ + ("delivery-queued", 102, "2025-12-31T23:59:58+00:00"), + ("delivery-unrelated", 999, "2025-12-31T23:59:59+00:00"), + ], + ) + connection.executemany( + """INSERT INTO workflows( + id, kind, repo_owner, repo_name, issue_number, pr_number, base_sha, branch, + workspace_path, primary_session_id, reviewer_session_id, artifact, review_json, + status, created_at, updated_at, runtime + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""", + [ + ( + "pre-v3-plan-workflow", + "plan", + "alice", + "repo", + 7, + None, + "plan-base", + "agent/plan", + str(workspace / "plan"), + "plan-primary", + "plan-reviewer", + "# Existing plan", + '{"verdict":"approved"}', + "active", + "2025-12-30T00:00:00+00:00", + "2025-12-31T00:00:00+00:00", + "opencode", + ), + ( + "pre-v3-implementation-workflow", + "implement", + "alice", + "repo", + 8, + 44, + "implementation-base", + "agent/implementation", + str(workspace / "implementation"), + "implementation-primary", + "implementation-reviewer", + "# Existing implementation", + '{"verdict":"changes_requested"}', + "completed", + "2025-12-29T00:00:00+00:00", + "2025-12-31T12:00:00+00:00", + "codex", + ), + ], + ) + connection.executemany( + """INSERT INTO jobs( + id, kind, target_key, repo_owner, repo_name, issue_number, pr_number, + requester, message, comment_id, workflow_id, status, stage, error, + accepted_comment_id, started_comment_id, created_at, started_at, finished_at, + runtime_session_id + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""", + [ + ( + "pre-v3-job-a-terminal", + "iterate_implement", + "alice/repo:pr:44", + "alice", + "repo", + 8, + 44, + "bob", + "address review", + 101, + "pre-v3-implementation-workflow", + "succeeded", + "completed", + None, + 201, + 202, + PRE_V3_CREATED_AT, + "2026-01-01T00:01:00+00:00", + "2026-01-01T00:02:00+00:00", + "implementation-runtime", + ), + ( + "pre-v3-job-b-queued", + "iterate_plan", + "alice/repo:issue:7", + "alice", + "repo", + 7, + None, + "alice", + "refine plan", + 102, + "pre-v3-plan-workflow", + "queued", + "queued", + None, + 203, + 204, + PRE_V3_CREATED_AT, + None, + None, + "plan-runtime", + ), + ], + ) + + +@pytest.fixture +async def migrated_pre_v3_repository(tmp_path: Path) -> Repository: + database_path = tmp_path / "schema-v2.sqlite3" + create_schema_v2_database(database_path, tmp_path / "workspaces") + repository = Repository(database_path, MIGRATIONS) + await repository.initialize() + return repository + + +async def test_schema_v2_migration_reconstructs_identity_order_and_iterate_commands( + migrated_pre_v3_repository: Repository, +) -> None: + with ( + closing(sqlite3.connect(migrated_pre_v3_repository.database_path)) as connection, + connection, + ): + migrated = connection.execute( + "SELECT id, delivery_id, receive_sequence, command_body FROM jobs " + "ORDER BY receive_sequence" + ).fetchall() + + assert migrated == [ + ( + "pre-v3-job-a-terminal", + "legacy:pre-v3-job-a-terminal", + 1, + "/agent iterate address review", + ), + ("pre-v3-job-b-queued", "delivery-queued", 2, "/agent iterate refine plan"), + ] + + +async def test_schema_v2_migration_preserves_pre_v3_job_fields( + migrated_pre_v3_repository: Repository, +) -> None: + terminal = await migrated_pre_v3_repository.get_job("pre-v3-job-a-terminal") + queued = await migrated_pre_v3_repository.get_job("pre-v3-job-b-queued") + with ( + closing(sqlite3.connect(migrated_pre_v3_repository.database_path)) as connection, + connection, + ): + storage_fields = connection.execute( + """SELECT id, started_comment_id, created_at, started_at, finished_at + FROM jobs ORDER BY receive_sequence""" + ).fetchall() + + assert ( + terminal + and ( + terminal.kind, + terminal.target_key, + terminal.repo_owner, + terminal.repo_name, + terminal.issue_number, + terminal.pr_number, + terminal.requester, + terminal.message, + terminal.comment_id, + terminal.workflow_id, + terminal.status, + terminal.stage, + terminal.error, + terminal.runtime_session_id, + terminal.accepted_comment_id, + terminal.comment_body, + ), + queued + and ( + queued.kind, + queued.target_key, + queued.requester, + queued.message, + queued.comment_id, + queued.workflow_id, + queued.status, + queued.stage, + queued.runtime_session_id, + queued.accepted_comment_id, + ), + storage_fields, + ) == ( + ( + JobKind.ITERATE_IMPLEMENT, + "alice/repo:pr:44", + "alice", + "repo", + 8, + 44, + "bob", + "address review", + 101, + "pre-v3-implementation-workflow", + JobStatus.SUCCEEDED, + "completed", + None, + "implementation-runtime", + 201, + None, + ), + ( + JobKind.ITERATE_PLAN, + "alice/repo:issue:7", + "alice", + "refine plan", + 102, + "pre-v3-plan-workflow", + JobStatus.QUEUED, + "queued", + "plan-runtime", + 203, + ), + [ + ( + "pre-v3-job-a-terminal", + 202, + PRE_V3_CREATED_AT, + "2026-01-01T00:01:00+00:00", + "2026-01-01T00:02:00+00:00", + ), + ("pre-v3-job-b-queued", 204, PRE_V3_CREATED_AT, None, None), + ], + ) + + +async def test_schema_v2_migration_preserves_pre_v3_workflows( + migrated_pre_v3_repository: Repository, + tmp_path: Path, +) -> None: + plan = await migrated_pre_v3_repository.get_workflow("pre-v3-plan-workflow") + implementation = await migrated_pre_v3_repository.get_workflow("pre-v3-implementation-workflow") + with ( + closing(sqlite3.connect(migrated_pre_v3_repository.database_path)) as connection, + connection, + ): + timestamps = connection.execute( + "SELECT id, created_at, updated_at FROM workflows ORDER BY id" + ).fetchall() + + assert ((plan, implementation), timestamps) == ( + ( + Workflow( + id="pre-v3-plan-workflow", + kind=WorkflowKind.PLAN, + repo_owner="alice", + repo_name="repo", + issue_number=7, + workspace_path=tmp_path / "workspaces" / "plan", + base_sha="plan-base", + runtime="opencode", + branch="agent/plan", + primary_session_id="plan-primary", + reviewer_session_id="plan-reviewer", + artifact="# Existing plan", + review_json='{"verdict":"approved"}', + status=WorkflowStatus.ACTIVE, + ), + Workflow( + id="pre-v3-implementation-workflow", + kind=WorkflowKind.IMPLEMENT, + repo_owner="alice", + repo_name="repo", + issue_number=8, + pr_number=44, + workspace_path=tmp_path / "workspaces" / "implementation", + base_sha="implementation-base", + runtime="codex", + branch="agent/implementation", + primary_session_id="implementation-primary", + reviewer_session_id="implementation-reviewer", + artifact="# Existing implementation", + review_json='{"verdict":"changes_requested"}', + status=WorkflowStatus.COMPLETED, + ), + ), + [ + ( + "pre-v3-implementation-workflow", + "2025-12-29T00:00:00+00:00", + "2025-12-31T12:00:00+00:00", + ), + ( + "pre-v3-plan-workflow", + "2025-12-30T00:00:00+00:00", + "2025-12-31T00:00:00+00:00", + ), + ], + ) + + +async def test_schema_v2_migration_creates_audit_events_and_only_queued_tasks( + migrated_pre_v3_repository: Repository, +) -> None: + with ( + closing(sqlite3.connect(migrated_pre_v3_repository.database_path)) as connection, + connection, + ): + events = connection.execute( + """SELECT event_id, job_id, event_type, payload_json, created_at + FROM job_events ORDER BY job_id""" + ).fetchall() + tasks = connection.execute( + """SELECT job_id, source_event_id, ordinal, listener, queue, status, + available_at, created_at + FROM listener_tasks ORDER BY ordinal""" + ).fetchall() + + assert (events, tasks) == ( + [ + ( + "delivery:legacy:pre-v3-job-a-terminal", + "pre-v3-job-a-terminal", + "legacy", + "{}", + PRE_V3_CREATED_AT, + ), + ( + "delivery:delivery-queued", + "pre-v3-job-b-queued", + "legacy", + "{}", + PRE_V3_CREATED_AT, + ), + ], + [ + ( + "pre-v3-job-b-queued", + "delivery:delivery-queued", + 0, + "execute", + "jobs", + "pending", + PRE_V3_CREATED_AT, + PRE_V3_CREATED_AT, + ), + ( + "pre-v3-job-b-queued", + "delivery:delivery-queued", + 1, + "reconcile_comment", + "control", + "pending", + PRE_V3_CREATED_AT, + PRE_V3_CREATED_AT, + ), + ], + ) + + +def durable_snapshot(database_path: Path) -> tuple[object, ...]: + with closing(sqlite3.connect(database_path)) as connection, connection: + return ( + connection.execute("SELECT version FROM schema_migrations ORDER BY version").fetchall(), + connection.execute( + "SELECT id, delivery_id, receive_sequence, command_body FROM jobs ORDER BY id" + ).fetchall(), + connection.execute( + """SELECT event_id, job_id, event_type, payload_json + FROM job_events ORDER BY event_id""" + ).fetchall(), + connection.execute( + """SELECT job_id, source_event_id, ordinal, listener, queue, status + FROM listener_tasks ORDER BY id""" + ).fetchall(), + ) + + +async def test_reopening_migrated_schema_v2_database_is_idempotent( + migrated_pre_v3_repository: Repository, +) -> None: + before = durable_snapshot(migrated_pre_v3_repository.database_path) + reopened = Repository(migrated_pre_v3_repository.database_path, MIGRATIONS) + + await reopened.initialize() + + assert (before[0], durable_snapshot(reopened.database_path)) == ( + [(1,), (2,), (3,)], + before, + ) diff --git a/tests/test_repository_tasks.py b/tests/test_repository_tasks.py new file mode 100644 index 0000000..1ff1917 --- /dev/null +++ b/tests/test_repository_tasks.py @@ -0,0 +1,193 @@ +import sqlite3 +from contextlib import closing +from datetime import UTC, datetime, timedelta +from pathlib import Path + +import pytest + +from agentci.engine import _sqlite +from agentci.engine import repository as repository_module +from agentci.engine.events import JobStarted, PermissionGranted +from agentci.engine.model import IncomingCommand, QueueName, Task, TaskKind +from agentci.engine.repository import Repository + +MIGRATIONS = Path(__file__).parents[1] / "src" / "agentci" / "migrations" + + +@pytest.fixture +async def repository(tmp_path: Path) -> Repository: + value = Repository(tmp_path / "state.sqlite3", MIGRATIONS) + await value.initialize() + return value + + +def command(delivery: str, *, issue: int = 3) -> IncomingCommand: + return IncomingCommand( + delivery_id=delivery, + comment_id=int(delivery.rsplit("-", 1)[-1]), + repo_owner="alice", + repo_name="repo", + issue_number=issue, + pr_number=None, + requester="alice", + body="/agent plan", + ) + + +def task_storage(repository: Repository, task_id: int) -> tuple[object, ...]: + with closing(sqlite3.connect(repository.database_path)) as connection, connection: + row = connection.execute( + """SELECT status, attempts, available_at, error, created_at, started_at, finished_at + FROM listener_tasks WHERE id=?""", + (task_id,), + ).fetchone() + assert row is not None + return row + + +async def test_claim_and_complete_record_attempt_and_lifecycle_timestamps( + repository: Repository, + monkeypatch: pytest.MonkeyPatch, +) -> None: + clock = {"now": "2026-02-01T00:00:00+00:00"} + monkeypatch.setattr(_sqlite, "now", lambda: clock["now"]) + job = (await repository.accept(command("delivery-1"))).job + clock["now"] = "2026-02-01T00:01:00+00:00" + + task = await repository.claim_task(QueueName.CONTROL) + assert task is not None + clock["now"] = "2026-02-01T00:02:00+00:00" + await repository.complete_task(task.id) + + assert (task, task_storage(repository, task.id)) == ( + Task( + id=task.id, + job_id=job.id, + source_event_id="delivery:delivery-1", + kind=TaskKind.AUTHORIZE, + queue=QueueName.CONTROL, + attempts=1, + ), + ( + "completed", + 1, + "2026-02-01T00:00:00+00:00", + None, + "2026-02-01T00:00:00+00:00", + "2026-02-01T00:01:00+00:00", + "2026-02-01T00:02:00+00:00", + ), + ) + + +@pytest.mark.parametrize(("attempts", "delay_seconds"), [(1, 2), (9, 256)]) +async def test_retry_uses_bounded_backoff_and_preserves_attempt_history_until_due( + repository: Repository, + monkeypatch: pytest.MonkeyPatch, + attempts: int, + delay_seconds: int, +) -> None: + clock = {"now": "2026-02-01T00:00:00+00:00"} + monkeypatch.setattr(_sqlite, "now", lambda: clock["now"]) + await repository.accept(command("delivery-1")) + task = await repository.claim_task(QueueName.CONTROL) + assert task is not None + with closing(sqlite3.connect(repository.database_path)) as connection, connection: + connection.execute("UPDATE listener_tasks SET attempts=? WHERE id=?", (attempts, task.id)) + + retry_time = datetime(2026, 2, 1, 1, tzinfo=UTC) + available_at = retry_time + timedelta(seconds=delay_seconds) + + class FixedDateTime: + @staticmethod + def now(_timezone: object) -> datetime: + return retry_time + + monkeypatch.setattr(repository_module, "datetime", FixedDateTime) + error = "failure: " + "x" * 1100 + await repository.retry_task(task.id, attempts, error) + pending_storage = task_storage(repository, task.id) + clock["now"] = (available_at - timedelta(microseconds=1)).isoformat() + early_claim = await repository.claim_task(QueueName.CONTROL) + clock["now"] = available_at.isoformat() + due_claim = await repository.claim_task(QueueName.CONTROL) + + assert ( + pending_storage, + early_claim, + due_claim and due_claim.attempts, + ) == ( + ( + "pending", + attempts, + available_at.isoformat(), + error[:1000], + "2026-02-01T00:00:00+00:00", + "2026-02-01T00:00:00+00:00", + None, + ), + None, + attempts + 1, + ) + + +async def test_recovery_requeues_control_and_unstarted_execution_but_fails_started_execution( + repository: Repository, + monkeypatch: pytest.MonkeyPatch, +) -> None: + clock = {"now": "2026-02-01T00:00:00+00:00"} + monkeypatch.setattr(_sqlite, "now", lambda: clock["now"]) + + await repository.accept(command("delivery-1", issue=1)) + control_task = await repository.claim_task(QueueName.CONTROL) + assert control_task is not None + + queued_job = (await repository.accept(command("delivery-2", issue=2))).job + await repository.apply("queued:grant", PermissionGranted(job_id=queued_job.id)) + queued_execute = await repository.claim_task(QueueName.JOBS) + assert queued_execute is not None + + running_job = (await repository.accept(command("delivery-3", issue=3))).job + await repository.apply("running:grant", PermissionGranted(job_id=running_job.id)) + running_execute = await repository.claim_task(QueueName.JOBS) + assert running_execute is not None + await repository.apply("running:start", JobStarted(job_id=running_job.id)) + + clock["now"] = "2026-02-01T01:00:00+00:00" + await repository.recover_tasks() + + assert ( + task_storage(repository, control_task.id), + task_storage(repository, queued_execute.id), + task_storage(repository, running_execute.id), + [job.id for job in await repository.running_jobs()], + ) == ( + ( + "pending", + 1, + "2026-02-01T00:00:00+00:00", + None, + "2026-02-01T00:00:00+00:00", + None, + None, + ), + ( + "pending", + 1, + "2026-02-01T00:00:00+00:00", + None, + "2026-02-01T00:00:00+00:00", + None, + None, + ), + ( + "failed", + 1, + "2026-02-01T00:00:00+00:00", + "Service restarted after execution began", + "2026-02-01T00:00:00+00:00", + "2026-02-01T00:00:00+00:00", + "2026-02-01T01:00:00+00:00", + ), + [running_job.id], + ) diff --git a/tests/test_runtime.py b/tests/test_runtime.py new file mode 100644 index 0000000..d6dcfc3 --- /dev/null +++ b/tests/test_runtime.py @@ -0,0 +1,174 @@ +from pathlib import Path +from types import SimpleNamespace +from unittest.mock import AsyncMock, Mock + +import pytest + +import agentci.application.runtime as runtime_module +from agentci.application.runtime import Runtime +from agentci.config.settings import Settings + + +class ClosingClient: + def __init__(self, name: str, events: list[str], error: Exception | None = None) -> None: + self.name = name + self.events = events + self.error = error + + async def close(self) -> None: + self.events.append(self.name) + if self.error is not None: + raise self.error + + +def runtime(opencode: object, gitea: object) -> Runtime: + return Runtime( + settings=SimpleNamespace(), # type: ignore[arg-type] + repository=SimpleNamespace(), # type: ignore[arg-type] + gitea=gitea, # type: ignore[arg-type] + git=SimpleNamespace(), # type: ignore[arg-type] + opencode=opencode, # type: ignore[arg-type] + worker=SimpleNamespace(), # type: ignore[arg-type] + ) + + +async def test_close_releases_provider_clients_in_order() -> None: + events: list[str] = [] + value = runtime(ClosingClient("opencode", events), ClosingClient("gitea", events)) + + await value.close() + + assert events == ["opencode", "gitea"] + + +async def test_close_still_releases_gitea_when_opencode_close_fails() -> None: + events: list[str] = [] + value = runtime( + ClosingClient("opencode", events, RuntimeError("opencode close failed")), + ClosingClient("gitea", events), + ) + + with pytest.raises(RuntimeError, match="opencode close failed"): + await value.close() + + assert events == ["opencode", "gitea"] + + +async def test_close_propagates_gitea_close_failure_after_opencode_closes() -> None: + events: list[str] = [] + value = runtime( + ClosingClient("opencode", events), + ClosingClient("gitea", events, RuntimeError("gitea close failed")), + ) + + with pytest.raises(RuntimeError, match="gitea close failed"): + await value.close() + + assert events == ["opencode", "gitea"] + + +async def test_build_runtime_wires_components_without_starting_real_clients( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + token_file = tmp_path / "gitea-token" + password_file = tmp_path / "opencode-password" + token_file.write_text("token\n") + password_file.write_text("password\n") + settings = Settings( + _env_file=None, # type: ignore[call-arg] + data_dir=tmp_path / "data", + gitea_url="https://gitea.example/", + gitea_token_file=token_file, + opencode_url="https://opencode.example/", + opencode_server_password_file=password_file, + install_scripts=["python"], + max_concurrent_jobs=4, + ) + initialize = AsyncMock() + repository = SimpleNamespace(initialize=initialize) + gitea = SimpleNamespace() + git = SimpleNamespace() + opencode = SimpleNamespace() + development = SimpleNamespace() + prompts = SimpleNamespace(schemas_dir=tmp_path / "schemas") + services = SimpleNamespace() + worker = SimpleNamespace() + repository_constructor = Mock(return_value=repository) + gitea_constructor = Mock(return_value=gitea) + git_constructor = Mock(return_value=git) + opencode_constructor = Mock(return_value=opencode) + development_constructor = Mock(return_value=development) + prompt_constructor = Mock(return_value=prompts) + services_constructor = Mock(return_value=services) + worker_constructor = Mock(return_value=worker) + monkeypatch.setattr(runtime_module, "Repository", repository_constructor) + monkeypatch.setattr(runtime_module, "Gitea", gitea_constructor) + monkeypatch.setattr(runtime_module, "Git", git_constructor) + monkeypatch.setattr(runtime_module, "OpenCode", opencode_constructor) + monkeypatch.setattr(runtime_module, "DevelopmentEnvironment", development_constructor) + monkeypatch.setattr(runtime_module, "PromptLibrary", prompt_constructor) + monkeypatch.setattr(runtime_module, "WorkflowServices", services_constructor) + monkeypatch.setattr(runtime_module, "Worker", worker_constructor) + + built = await runtime_module.build_runtime(settings) + + repository_constructor.assert_called_once_with(settings.database_path) + initialize.assert_awaited_once_with() + gitea_constructor.assert_called_once_with("https://gitea.example", "token") + git_constructor.assert_called_once_with( + gitea_url="https://gitea.example", + username=settings.bot_username, + token="token", + askpass_path=settings.askpass_path, + commit_name=settings.bot_name, + commit_email=settings.bot_email, + ) + opencode_constructor.assert_called_once_with( + base_url="https://opencode.example", + username=settings.opencode_server_username, + password="password", + schemas_dir=prompts.schemas_dir, + health_directory=settings.workspaces_dir, + required_models=( + (settings.plan_model, settings.plan_variant), + (settings.implement_model, settings.implement_variant), + (settings.explore_model, settings.explore_variant), + (settings.research_model, settings.research_variant), + ), + timeout_seconds=settings.turn_timeout_seconds, + ) + development_constructor.assert_called_once_with( + scripts=["python"], + scripts_dir=settings.install_scripts_dir, + tools_dir=settings.dev_tools_dir, + timeout_seconds=settings.install_script_timeout_seconds, + python_version=settings.python_version, + dotnet_channel=settings.dotnet_channel, + ) + prompt_constructor.assert_called_once_with() + services_constructor.assert_called_once_with( + settings=settings, + repository=repository, + gitea=gitea, + git=git, + opencode=opencode, + prompts=prompts, + development=development, + ) + worker_constructor.assert_called_once_with( + repository=repository, + gitea=gitea, + opencode=opencode, + services=services, + poll_seconds=settings.worker_poll_seconds, + max_concurrent_jobs=4, + workspaces_dir=settings.workspaces_dir, + bot_username=settings.bot_username, + ) + assert settings.data_dir.is_dir() + assert settings.workspaces_dir.is_dir() + assert built.repository is repository + assert built.gitea is gitea + assert built.git is git + assert built.opencode is opencode + assert built.worker is worker diff --git a/tests/test_runtime_scripts.py b/tests/test_runtime_scripts.py new file mode 100644 index 0000000..70e62ef --- /dev/null +++ b/tests/test_runtime_scripts.py @@ -0,0 +1,89 @@ +import json +import os +import stat +import subprocess +from pathlib import Path + +import pytest + +ROOT = Path(__file__).parents[1] + + +def test_entrypoint_loads_secrets_writes_tea_login_and_executes_command( + tmp_path: Path, +) -> None: + password = tmp_path / "opencode-password" + password.write_text("server-secret") + token = tmp_path / "gitea-token" + token.write_text("gitea-secret\n") + config_home = tmp_path / "config" + environment = { + **os.environ, + "OPENCODE_SERVER_PASSWORD_FILE": str(password), + "AGENTCI_GITEA_TOKEN_FILE": str(token), + "AGENTCI_TEA_CONFIG_HOME": str(config_home), + "AGENTCI_GITEA_URL": "https://gitea.example", + } + + result = subprocess.run( + [ + "/bin/sh", + str(ROOT / "scripts" / "entrypoint.sh"), + "/bin/sh", + "-c", + 'printf "%s\\n" "$OPENCODE_SERVER_PASSWORD"; printf "arg=%s\\n" "$@"', + "child", + "first", + "second value", + ], + env=environment, + capture_output=True, + text=True, + timeout=10, + check=False, + ) + + assert result.returncode == 0, result.stderr + assert result.stdout.splitlines() == [ + "server-secret", + "arg=first", + "arg=second value", + ] + config_path = config_home / "tea" / "config.yml" + assert json.loads(config_path.read_text()) == { + "logins": [ + { + "name": "agentci", + "url": "https://gitea.example", + "token": "gitea-secret", + "default": True, + "version_check": False, + } + ], + "preferences": {}, + } + assert stat.S_IMODE(config_path.stat().st_mode) == 0o600 + + +@pytest.mark.parametrize( + ("prompt", "expected"), + [ + ("Username for 'https://gitea.example':", "agentci-bot\n"), + ("Password for 'https://agentci@gitea.example':", "token-secret\n"), + ], +) +def test_gitea_askpass_selects_credential_for_prompt(prompt: str, expected: str) -> None: + result = subprocess.run( + ["/bin/sh", str(ROOT / "scripts" / "gitea-askpass.sh"), prompt], + env={ + "AGENTCI_GIT_USERNAME": "agentci-bot", + "AGENTCI_GIT_PASSWORD": "token-secret", + }, + capture_output=True, + text=True, + timeout=10, + check=False, + ) + + assert result.returncode == 0, result.stderr + assert result.stdout == expected diff --git a/tests/test_state_machine.py b/tests/test_state_machine.py deleted file mode 100644 index 2670a77..0000000 --- a/tests/test_state_machine.py +++ /dev/null @@ -1,75 +0,0 @@ -from dataclasses import FrozenInstanceError - -import pytest - -from agentci.domain.events import ( - CommandReceived, - CommentLinked, - JobCompleted, - JobStarted, - PermissionGranted, - ServiceRestarted, -) -from agentci.domain.models import JobKind, JobStatus -from agentci.domain.state_machine import InvalidTransition, next_state, render_job_comment - - -def received(body: str = "/agent plan message"): - return next_state( - None, - CommandReceived( - job_id="job", - delivery_id="delivery", - receive_sequence=1, - command_body=body, - target_key="org/repo:issue:1", - repo_owner="org", - repo_name="repo", - issue_number=1, - pr_number=None, - requester="alice", - comment_id=4, - ), - ) - - -def test_permission_parses_and_queues_execution() -> None: - transition = next_state(received().state, PermissionGranted(job_id="job")) - assert transition.state.status is JobStatus.QUEUED - assert transition.state.kind is JobKind.PLAN - assert transition.state.message == "message" - assert [(item.listener, item.queue) for item in transition.notifications] == [ - ("execute", "jobs"), - ("reconcile_comment", "control"), - ] - - -def test_invalid_syntax_is_rejected_after_permission() -> None: - transition = next_state(received("/agent nonsense").state, PermissionGranted(job_id="job")) - assert transition.state.status is JobStatus.REJECTED - assert "Unknown" in (transition.state.error or "") - - -def test_running_completion_and_restart_are_explicit() -> None: - queued = next_state(received().state, PermissionGranted(job_id="job")).state - running = next_state(queued, JobStarted(job_id="job")).state - completed = next_state(running, JobCompleted(job_id="job", comment_body="# Result")).state - assert completed.status is JobStatus.SUCCEEDED - assert "# Result" in render_job_comment(completed) - assert next_state(completed, ServiceRestarted(job_id="job")).state == completed - - -def test_comment_link_is_allowed_on_terminal_state() -> None: - queued = next_state(received().state, PermissionGranted(job_id="job")).state - running = next_state(queued, JobStarted(job_id="job")).state - completed = next_state(running, JobCompleted(job_id="job", comment_body="ok")).state - linked = next_state(completed, CommentLinked(job_id="job", comment_id=9)).state - assert linked.accepted_comment_id == 9 - - -def test_state_is_immutable_and_invalid_transitions_fail() -> None: - state = received().state - with pytest.raises(FrozenInstanceError): - state.stage = "changed" # type: ignore[misc] - with pytest.raises(InvalidTransition): - next_state(state, JobStarted(job_id="job")) diff --git a/tests/test_storage.py b/tests/test_storage.py deleted file mode 100644 index 15b9a8f..0000000 --- a/tests/test_storage.py +++ /dev/null @@ -1,156 +0,0 @@ -import sqlite3 -from pathlib import Path - -import pytest - -from agentci.adapters.storage import Storage -from agentci.domain.events import ( - JobStarted, - PermissionDenied, - PermissionGranted, - WorkflowCreated, -) -from agentci.domain.models import CommandEvent, Workflow, WorkflowKind, WorkflowStatus -from agentci.state_machine import StateMachine - - -@pytest.fixture -async def storage(tmp_path: Path) -> Storage: - migrations = Path(__file__).parents[1] / "src" / "agentci" / "migrations" - value = Storage(tmp_path / "state.sqlite3", migrations) - await value.initialize() - return value - - -def command( - delivery: str, - body: str = "/agent plan", - *, - issue: int = 3, - pr: int | None = None, -) -> CommandEvent: - return CommandEvent( - delivery_id=delivery, - comment_id=int(delivery.rsplit("-", 1)[-1]), - repo_owner="alice", - repo_name="repo", - issue_number=issue, - pr_number=pr, - requester="alice", - body=body, - ) - - -async def test_receive_is_idempotent_without_consuming_sequence(storage: Storage) -> None: - host = StateMachine(storage) - first = await host.receive(command("delivery-1")) - duplicate = await host.receive(command("delivery-1")) - second = await host.receive(command("delivery-2")) - - assert not first.duplicate - assert duplicate.duplicate - assert duplicate.state.id == first.state.id - assert second.state.receive_sequence == first.state.receive_sequence + 1 - - -async def test_received_job_blocks_later_execute_task_for_same_target( - storage: Storage, -) -> None: - host = StateMachine(storage) - first = (await host.receive(command("delivery-1"))).state - second = (await host.receive(command("delivery-2"))).state - await host.evolve("grant-2", PermissionGranted(job_id=second.id)) - - assert await storage.claim_task("jobs") is None - - await host.evolve("deny-1", PermissionDenied(job_id=first.id)) - task = await storage.claim_task("jobs") - assert task is not None - assert task.job_id == second.id - - -async def test_received_job_does_not_block_a_different_target(storage: Storage) -> None: - host = StateMachine(storage) - await host.receive(command("delivery-1", issue=3)) - second = (await host.receive(command("delivery-2", issue=4))).state - await host.evolve("grant-2", PermissionGranted(job_id=second.id)) - - task = await storage.claim_task("jobs") - - assert task is not None - assert task.job_id == second.id - - -async def test_claims_multiple_eligible_targets_without_duplicates(storage: Storage) -> None: - host = StateMachine(storage) - first = (await host.receive(command("delivery-1", issue=3))).state - second = (await host.receive(command("delivery-2", issue=4))).state - await host.evolve("grant-1", PermissionGranted(job_id=first.id)) - await host.evolve("grant-2", PermissionGranted(job_id=second.id)) - - claimed = [await storage.claim_task("jobs"), await storage.claim_task("jobs")] - - assert [task.job_id for task in claimed if task is not None] == [first.id, second.id] - assert await storage.claim_task("jobs") is None - - -async def test_started_and_finished_timestamps_are_owned_by_store(storage: Storage) -> None: - host = StateMachine(storage) - state = (await host.receive(command("delivery-1"))).state - state = (await host.evolve("grant", PermissionGranted(job_id=state.id))).state - state = (await host.evolve("start", JobStarted(job_id=state.id))).state - with sqlite3.connect(storage.database_path) as connection: - row = connection.execute( - "SELECT started_at, finished_at FROM jobs WHERE id=?", (state.id,) - ).fetchone() - assert row is not None - assert row[0] is not None - assert row[1] is None - - -async def test_workflow_queries_and_completed_protection(storage: Storage, tmp_path: Path) -> None: - workflow = Workflow( - id="workflow-1", - kind=WorkflowKind.PLAN, - repo_owner="alice", - repo_name="repo", - issue_number=3, - workspace_path=tmp_path / "repo", - base_sha="abc", - artifact="# Plan", - status=WorkflowStatus.COMPLETED, - ) - await storage.create_workflow(workflow) - await storage.fail_job_workflow("missing-job") - loaded = await storage.latest_workflow("alice", "repo", 3, WorkflowKind.PLAN) - assert loaded is not None - assert loaded.status is WorkflowStatus.COMPLETED - - -async def test_workflow_creation_and_job_link_are_atomic(storage: Storage, tmp_path: Path) -> None: - host = StateMachine(storage) - state = (await host.receive(command("delivery-1"))).state - state = (await host.evolve("grant", PermissionGranted(job_id=state.id))).state - state = (await host.evolve("start", JobStarted(job_id=state.id))).state - workflow = Workflow( - id="workflow-atomic", - kind=WorkflowKind.PLAN, - repo_owner="alice", - repo_name="repo", - issue_number=3, - workspace_path=tmp_path / "repo", - base_sha="abc", - ) - result = await host.evolve( - "workflow-created", - WorkflowCreated(job_id=state.id, workflow=workflow, stage="planning"), - ) - assert result.state.workflow_id == workflow.id - assert await storage.get_workflow(workflow.id) is not None - - -async def test_schema_has_receive_sequence_and_no_version(storage: Storage) -> None: - with sqlite3.connect(storage.database_path) as connection: - columns = {row[1] for row in connection.execute("PRAGMA table_info(jobs)")} - assert "receive_sequence" in columns - assert "version" not in columns diff --git a/tests/test_webhook.py b/tests/test_webhook.py index 4673375..aede8f3 100644 --- a/tests/test_webhook.py +++ b/tests/test_webhook.py @@ -1,28 +1,35 @@ import hashlib import hmac +import json from types import SimpleNamespace import pytest -from fastapi import HTTPException +from fastapi import FastAPI +from httpx import ASGITransport, AsyncClient, Response -from agentci.api.webhook import _event_from_payload, _handle_command, valid_signature +from agentci.api.routes.webhook import router +from agentci.engine.model import IncomingCommand +from agentci.integrations.gitea.webhooks import ( + incoming_command_from_payload, + valid_signature, +) -class FakeHost: - def __init__(self, duplicate: bool = False) -> None: - self.events = [] +class FakeRepository: + def __init__(self, *, duplicate: bool = False) -> None: + self.accepted: list[IncomingCommand] = [] self.duplicate = duplicate - async def receive(self, event): - self.events.append(event) - state = SimpleNamespace(id="job", receive_sequence=1) - return SimpleNamespace(state=state, duplicate=self.duplicate) + async def accept(self, event: IncomingCommand) -> SimpleNamespace: + self.accepted.append(event) + job = SimpleNamespace(id="job", receive_sequence=1) + return SimpleNamespace(job=job, duplicate=self.duplicate) -def payload(body: str, *, is_pull: bool = False) -> dict: +def payload(body: str, *, is_pull: bool = False, requester: str = "alice") -> dict: value = { "action": "created", - "comment": {"id": 8, "body": body, "user": {"login": "alice"}}, + "comment": {"id": 8, "body": body, "user": {"login": requester}}, "repository": {"name": "repo", "owner": {"login": "org"}}, "issue": {"number": 4}, "is_pull": is_pull, @@ -32,40 +39,149 @@ def payload(body: str, *, is_pull: bool = False) -> dict: return value -async def test_command_is_forwarded_without_parsing() -> None: - host = FakeHost() - event = _event_from_payload( - "delivery", payload("/agent iterate\n\nkeep raw body", is_pull=True) +def encoded(value: object) -> bytes: + return json.dumps(value).encode() + + +def sign(body: bytes, secret: bytes = b"secret") -> str: + return hmac.new(secret, body, hashlib.sha256).hexdigest() + + +async def post_webhook( + repository: FakeRepository, + body: bytes, + *, + event: str = "issue_comment", + delivery: str | None = "delivery", + signature: str | None = None, + bot_username: str = "agentci", +) -> Response: + app = FastAPI() + app.include_router(router) + app.state.runtime = SimpleNamespace( + repository=repository, + settings=SimpleNamespace(webhook_secret=b"secret", bot_username=bot_username), ) - assert event is not None - response = await _handle_command(SimpleNamespace(state_machine=host), event) + headers = { + "X-Gitea-Event-Type": event, + "X-Gitea-Signature": sign(body) if signature is None else signature, + } + if delivery is not None: + headers["X-Gitea-Delivery"] = delivery + async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: + return await client.post("/webhooks/gitea", content=body, headers=headers) + + +async def test_signed_supported_command_is_accepted_once_with_raw_body() -> None: + repository = FakeRepository() + body = encoded(payload("/agent iterate\n\nkeep raw body", is_pull=True)) + + response = await post_webhook(repository, body) + assert response.status_code == 202 - assert host.events[0].body == "/agent iterate\n\nkeep raw body" + assert len(repository.accepted) == 1 + event = repository.accepted[0] + assert event.delivery_id == "delivery" + assert event.target_key == "org/repo:pr:4" + assert event.requester == "alice" + assert event.body == "/agent iterate\n\nkeep raw body" -async def test_duplicate_returns_200() -> None: - event = _event_from_payload("delivery", payload("/agent plan")) - assert event is not None - response = await _handle_command(SimpleNamespace(state_machine=FakeHost(True)), event) - assert response.status_code == 200 +@pytest.mark.parametrize("event", ["issue_comment", "push"]) +async def test_invalid_signature_precedes_parsing_and_event_filtering(event: str) -> None: + repository = FakeRepository() + + response = await post_webhook(repository, b"not-json", event=event, signature="invalid") + + assert response.status_code == 401 + assert response.json() == {"detail": "Invalid webhook signature"} + assert repository.accepted == [] -async def test_missing_delivery_is_rejected() -> None: - event = _event_from_payload("", payload("/agent plan")) - assert event is not None - with pytest.raises(HTTPException) as raised: - await _handle_command(SimpleNamespace(state_machine=FakeHost()), event) - assert raised.value.status_code == 400 +async def test_signed_unsupported_event_is_ignored_without_parsing() -> None: + repository = FakeRepository() + response = await post_webhook(repository, b"not-json", event="push") -async def test_non_command_is_ignored() -> None: - event = _event_from_payload("delivery", payload("ordinary discussion")) - assert event is not None - response = await _handle_command(SimpleNamespace(state_machine=FakeHost()), event) assert response.status_code == 204 + assert repository.accepted == [] -def test_signature_validation() -> None: - signature = hmac.new(b"secret", b"{}", hashlib.sha256).hexdigest() +@pytest.mark.parametrize( + ("command", "requester"), + [("ordinary discussion", "alice"), ("/agent plan", "AgentCI")], +) +async def test_non_command_and_bot_comment_are_ignored(command: str, requester: str) -> None: + repository = FakeRepository() + + response = await post_webhook(repository, encoded(payload(command, requester=requester))) + + assert response.status_code == 204 + assert repository.accepted == [] + + +async def test_duplicate_delivery_returns_ok() -> None: + repository = FakeRepository(duplicate=True) + + response = await post_webhook(repository, encoded(payload("/agent plan"))) + + assert response.status_code == 200 + assert len(repository.accepted) == 1 + + +@pytest.mark.parametrize( + "body", + [ + b"{", + encoded([]), + encoded({"action": "created"}), + encoded({"action": "created", "comment": {}}), + ], +) +async def test_malformed_json_and_payload_contract_return_bad_request(body: bytes) -> None: + repository = FakeRepository() + + response = await post_webhook(repository, body) + + assert response.status_code == 400 + assert response.json() == {"detail": "Invalid webhook payload"} + assert repository.accepted == [] + + +async def test_command_requires_delivery_header() -> None: + repository = FakeRepository() + + response = await post_webhook(repository, encoded(payload("/agent plan")), delivery=None) + + assert response.status_code == 400 + assert response.json() == {"detail": "Missing X-Gitea-Delivery"} + assert repository.accepted == [] + + +async def test_non_created_comment_is_ignored_without_nested_payload() -> None: + repository = FakeRepository() + + response = await post_webhook(repository, encoded({"action": "edited"})) + + assert response.status_code == 204 + assert repository.accepted == [] + + +def test_payload_parser_supports_owner_username_and_pull_request() -> None: + value = payload("/agent plan", is_pull=True) + value["repository"]["owner"] = {"username": "fallback-owner"} + + event = incoming_command_from_payload("delivery", value) + + assert event is not None + assert event.repo_owner == "fallback-owner" + assert event.issue_number == 4 + assert event.pr_number == 4 + + +def test_signature_validation_requires_exact_nonempty_digest() -> None: + signature = sign(b"{}") + assert valid_signature(b"secret", b"{}", signature) + assert not valid_signature(b"secret", b"{}", "") assert not valid_signature(b"secret", b"{}", "bad") diff --git a/tests/test_worker.py b/tests/test_worker.py index 5bf2446..7b59346 100644 --- a/tests/test_worker.py +++ b/tests/test_worker.py @@ -1,44 +1,587 @@ import asyncio +from dataclasses import replace from pathlib import Path from types import SimpleNamespace -from typing import cast -from agentci.domain.models import Workflow, WorkflowKind -from agentci.domain.state_machine import JobState -from agentci.worker import Worker, _safe_error +import pytest + +from agentci.application.worker.errors import safe_error +from agentci.application.worker.runner import Worker +from agentci.engine.events import ( + CommentLinked, + JobCompleted, + JobFailed, + JobStarted, + PermissionDenied, + PermissionGranted, + ServiceRestarted, +) +from agentci.engine.events import ( + JobRejected as RejectedEvent, +) +from agentci.engine.model import ( + IncomingCommand, + Job, + JobKind, + JobStatus, + QueueName, + Task, + TaskKind, + Workflow, + WorkflowKind, +) +from agentci.engine.reducer import render_job_comment +from agentci.engine.repository import Repository +from agentci.integrations.gitea.models import CommentInfo +from agentci.workflows.render import JobRejected + +MIGRATIONS = Path(__file__).parents[1] / "src" / "agentci" / "migrations" -class FakeStorage: - def __init__(self, workflow=None) -> None: +class FakeRepository: + def __init__( + self, + value: Job | None = None, + *, + workflow: Workflow | None = None, + ) -> None: + self.job = value self.workflow = workflow + self.tasks: list[Task] = [] + self.claimed_queues: list[QueueName] = [] + self.completed: list[int] = [] + self.retried: list[tuple[int, int, str]] = [] + self.events: list[tuple[str, object]] = [] + self.operations: list[str] = [] + self.running: list[Job] = [] + self.failed_workflows: list[str] = [] + self.complete_stop: asyncio.Event | None = None - async def get_workflow(self, _workflow_id): + async def claim_task(self, queue: QueueName) -> Task | None: + self.claimed_queues.append(queue) + return self.tasks.pop(0) if self.tasks else None + + async def complete_task(self, task_id: int) -> None: + self.completed.append(task_id) + if self.complete_stop is not None: + self.complete_stop.set() + + async def retry_task(self, task_id: int, attempts: int, error: str) -> None: + self.retried.append((task_id, attempts, error)) + + async def get_job(self, _job_id: str) -> Job | None: + return self.job + + async def apply(self, event_id: str, event: object) -> SimpleNamespace: + self.operations.append(f"apply:{event_id}") + self.events.append((event_id, event)) + if isinstance(event, JobStarted) and self.job is not None: + self.job = replace(self.job, status=JobStatus.RUNNING, stage="starting") + elif isinstance(event, CommentLinked) and self.job is not None: + self.job = replace(self.job, accepted_comment_id=event.comment_id) + return SimpleNamespace(job=self.job) + + async def recover_tasks(self) -> None: + self.operations.append("recover_tasks") + + async def running_jobs(self) -> list[Job]: + self.operations.append("running_jobs") + return self.running + + async def get_workflow(self, _workflow_id: str) -> Workflow | None: return self.workflow + async def fail_job_workflow(self, job_id: str) -> None: + self.failed_workflows.append(job_id) + + +class FakeGitea: + def __init__(self, *, permitted: bool = True) -> None: + self.permitted = permitted + self.permission_calls: list[tuple[str, str, str]] = [] + self.update_results: list[bool] = [True] + self.update_calls: list[tuple[str, str, int, str]] = [] + self.comments: list[CommentInfo] = [] + self.issue_comment_calls: list[tuple[str, str, int]] = [] + self.created_comment_id = 42 + self.create_calls: list[tuple[str, str, int, str]] = [] + + async def has_write_permission(self, owner: str, repo: str, user: str) -> bool: + self.permission_calls.append((owner, repo, user)) + return self.permitted + + async def update_comment(self, owner: str, repo: str, comment_id: int, body: str) -> bool: + self.update_calls.append((owner, repo, comment_id, body)) + if len(self.update_results) > 1: + return self.update_results.pop(0) + return self.update_results[0] + + async def issue_comments(self, owner: str, repo: str, issue: int) -> list[CommentInfo]: + self.issue_comment_calls.append((owner, repo, issue)) + return self.comments + + async def create_comment(self, owner: str, repo: str, issue: int, body: str) -> int: + self.create_calls.append((owner, repo, issue, body)) + return self.created_comment_id + class FakeOpenCode: - def __init__(self) -> None: - self.aborted = set() + def __init__(self, ready: list[bool] | None = None) -> None: + self.ready_results = ready or [True] + self.ready_calls = 0 + self.aborted: list[tuple[str, Path]] = [] - async def abort(self, session_id, workspace): - self.aborted.add((session_id, workspace)) + async def ready(self) -> bool: + self.ready_calls += 1 + if len(self.ready_results) > 1: + return self.ready_results.pop(0) + return self.ready_results[0] + + async def abort(self, session_id: str, workspace: Path) -> None: + self.aborted.append((session_id, workspace)) -def worker(tmp_path: Path, storage: FakeStorage, opencode: FakeOpenCode) -> Worker: +def job( + *, + job_id: str = "job", + status: JobStatus = JobStatus.RECEIVED, + workflow_id: str | None = None, + session_id: str | None = None, + accepted_comment_id: int | None = None, +) -> Job: + return Job( + id=job_id, + target_key="org/repo:issue:1", + repo_owner="org", + repo_name="repo", + issue_number=1, + pr_number=None, + requester="alice", + comment_id=1, + delivery_id=f"delivery-{job_id}", + receive_sequence=1, + command_body="/agent plan", + kind=JobKind.PLAN if status is not JobStatus.RECEIVED else None, + status=status, + stage=status.value, + workflow_id=workflow_id, + runtime_session_id=session_id, + accepted_comment_id=accepted_comment_id, + ) + + +def task( + kind: TaskKind = TaskKind.EXECUTE, + queue: QueueName = QueueName.JOBS, + *, + task_id: int = 7, +) -> Task: + return Task(task_id, "job", "source", kind, queue, 2) + + +def make_worker( + tmp_path: Path, + repository: FakeRepository | Repository, + *, + gitea: FakeGitea | None = None, + opencode: FakeOpenCode | None = None, + max_concurrent_jobs: int = 2, +) -> Worker: return Worker( - storage=storage, # type: ignore[arg-type] - state_machine=SimpleNamespace(), # type: ignore[arg-type] - gitea=SimpleNamespace(), # type: ignore[arg-type] - opencode=opencode, # type: ignore[arg-type] - dispatcher=SimpleNamespace(), # type: ignore[arg-type] + repository=repository, # type: ignore[arg-type] + gitea=gitea or FakeGitea(), # type: ignore[arg-type] + opencode=opencode or FakeOpenCode(), # type: ignore[arg-type] + services=SimpleNamespace(), # type: ignore[arg-type] poll_seconds=1, - max_concurrent_jobs=2, + max_concurrent_jobs=max_concurrent_jobs, workspaces_dir=tmp_path, bot_username="agentci", ) -async def test_abort_collects_all_workflow_sessions(tmp_path: Path) -> None: +async def test_loop_completes_successful_task( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + repository = FakeRepository() + queued_task = task(queue=QueueName.CONTROL) + repository.tasks = [queued_task] + stop = asyncio.Event() + handled: list[Task] = [] + value = make_worker(tmp_path, repository) + + async def handle(current: Task) -> None: + handled.append(current) + stop.set() + + monkeypatch.setattr(value, "_handle", handle) + + await value._loop(QueueName.CONTROL, stop) + + assert handled == [queued_task] + assert repository.completed == [queued_task.id] + assert repository.retried == [] + + +async def test_loop_retries_failed_task(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + repository = FakeRepository() + queued_task = task(queue=QueueName.CONTROL) + repository.tasks = [queued_task] + stop = asyncio.Event() + value = make_worker(tmp_path, repository) + + async def fail(_current: Task) -> None: + stop.set() + raise RuntimeError("provider\nfailed") + + monkeypatch.setattr(value, "_handle", fail) + + await value._loop(QueueName.CONTROL, stop) + + assert repository.completed == [] + assert repository.retried == [ + (queued_task.id, queued_task.attempts, "RuntimeError: provider failed") + ] + + +async def test_loop_propagates_cancellation_without_rescheduling( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + repository = FakeRepository() + queued_task = task(queue=QueueName.CONTROL) + repository.tasks = [queued_task] + stop = asyncio.Event() + value = make_worker(tmp_path, repository) + + async def cancel(_current: Task) -> None: + stop.set() + raise asyncio.CancelledError + + monkeypatch.setattr(value, "_handle", cancel) + + with pytest.raises(asyncio.CancelledError): + await value._loop(QueueName.CONTROL, stop) + + assert repository.completed == [] + assert repository.retried == [] + + +async def test_loop_completes_task_whose_job_no_longer_exists(tmp_path: Path) -> None: + repository = FakeRepository(None) + missing_job_task = task(queue=QueueName.CONTROL) + repository.tasks = [missing_job_task] + stop = asyncio.Event() + repository.complete_stop = stop + + await make_worker(tmp_path, repository)._loop(QueueName.CONTROL, stop) + + assert repository.completed == [missing_job_task.id] + assert repository.retried == [] + + +async def test_jobs_queue_waits_for_provider_readiness( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + repository = FakeRepository() + queued_task = task() + repository.tasks = [queued_task] + opencode = FakeOpenCode([False, True]) + stop = asyncio.Event() + waits: list[asyncio.Event] = [] + value = make_worker(tmp_path, repository, opencode=opencode) + + async def wait(current_stop: asyncio.Event) -> None: + waits.append(current_stop) + + async def handle(_current: Task) -> None: + stop.set() + + monkeypatch.setattr(value, "_wait", wait) + monkeypatch.setattr(value, "_handle", handle) + + await value._loop(QueueName.JOBS, stop) + + assert opencode.ready_calls == 2 + assert waits == [stop] + assert repository.claimed_queues == [QueueName.JOBS] + assert repository.completed == [queued_task.id] + + +async def test_run_recovers_before_starting_configured_consumers( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + value = make_worker(tmp_path, FakeRepository(), max_concurrent_jobs=3) + calls: list[str | QueueName] = [] + + async def recover() -> None: + calls.append("recover") + + async def loop(queue: QueueName, _stop: asyncio.Event) -> None: + calls.append(queue) + + monkeypatch.setattr(value, "_recover", recover) + monkeypatch.setattr(value, "_loop", loop) + + await value.run(asyncio.Event()) + + assert calls[0] == "recover" + assert calls.count(QueueName.CONTROL) == 1 + assert calls.count(QueueName.JOBS) == 3 + + +@pytest.mark.parametrize( + ("permitted", "event_type", "suffix"), + [ + (True, PermissionGranted, "permission-granted"), + (False, PermissionDenied, "permission-denied"), + ], +) +async def test_authorize_records_permission_outcome( + tmp_path: Path, + permitted: bool, + event_type: type[PermissionGranted] | type[PermissionDenied], + suffix: str, +) -> None: + current = job() + repository = FakeRepository(current) + gitea = FakeGitea(permitted=permitted) + authorize = task(TaskKind.AUTHORIZE, QueueName.CONTROL) + + await make_worker(tmp_path, repository, gitea=gitea)._authorize(authorize, current) + + assert gitea.permission_calls == [("org", "repo", "alice")] + assert repository.events[0][0] == f"task:{authorize.id}:{suffix}" + assert isinstance(repository.events[0][1], event_type) + + +async def test_authorize_ignores_job_after_received_status(tmp_path: Path) -> None: + current = job(status=JobStatus.QUEUED) + repository = FakeRepository(current) + gitea = FakeGitea() + + await make_worker(tmp_path, repository, gitea=gitea)._authorize( + task(TaskKind.AUTHORIZE, QueueName.CONTROL), current + ) + + assert gitea.permission_calls == [] + assert repository.events == [] + + +async def test_authorize_integrates_with_real_repository( + tmp_path: Path, +) -> None: + repository = Repository(tmp_path / "state.sqlite3", MIGRATIONS) + await repository.initialize() + accepted = await repository.accept( + IncomingCommand( + delivery_id="delivery-real", + comment_id=1, + repo_owner="org", + repo_name="repo", + issue_number=1, + pr_number=None, + requester="alice", + body="/agent plan write tests", + ) + ) + authorize = await repository.claim_task(QueueName.CONTROL) + assert authorize is not None + assert authorize.kind is TaskKind.AUTHORIZE + + await make_worker(tmp_path, repository)._handle(authorize) + + persisted = await repository.get_job(accepted.job.id) + assert persisted is not None + assert persisted.status is JobStatus.QUEUED + assert persisted.kind is JobKind.PLAN + assert persisted.message == "write tests" + execute = await repository.claim_task(QueueName.JOBS) + assert execute is not None + assert execute.kind is TaskKind.EXECUTE + + +async def test_execute_completes_with_workflow_comment( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + current = job(status=JobStatus.QUEUED) + repository = FakeRepository(current) + value = make_worker(tmp_path, repository) + + async def dispatch(_job: Job, _run: object, _services: object) -> str: + return "final workflow body" + + monkeypatch.setattr("agentci.application.worker.execution.dispatch", dispatch) + execute = task() + + await value._execute(execute, current) + + assert [event_id for event_id, _ in repository.events] == [ + f"task:{execute.id}:started", + f"task:{execute.id}:completed", + ] + assert isinstance(repository.events[0][1], JobStarted) + completed = repository.events[1][1] + assert isinstance(completed, JobCompleted) + assert completed.comment_body == "final workflow body" + + +async def test_execute_records_expected_rejection( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + current = job(status=JobStatus.QUEUED) + repository = FakeRepository(current) + + async def dispatch(_job: Job, _run: object, _services: object) -> str: + raise JobRejected("pull request is closed") + + monkeypatch.setattr("agentci.application.worker.execution.dispatch", dispatch) + + await make_worker(tmp_path, repository)._execute(task(), current) + + rejected = repository.events[-1][1] + assert isinstance(rejected, RejectedEvent) + assert rejected.reason == "pull request is closed" + + +async def test_execute_records_failure_at_latest_persisted_stage( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + current = job(status=JobStatus.QUEUED) + repository = FakeRepository(current) + + async def dispatch(_job: Job, _run: object, _services: object) -> str: + assert repository.job is not None + repository.job = replace(repository.job, stage="cloning") + raise RuntimeError("provider\nfailed") + + monkeypatch.setattr("agentci.application.worker.execution.dispatch", dispatch) + + await make_worker(tmp_path, repository)._execute(task(), current) + + failed = repository.events[-1][1] + assert isinstance(failed, JobFailed) + assert failed.stage == "cloning" + assert failed.error == "RuntimeError: provider failed" + + +async def test_execute_marks_running_job_interrupted_after_restart( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + current = job(status=JobStatus.RUNNING) + repository = FakeRepository(current) + + async def unexpected_dispatch(_job: Job, _run: object, _services: object) -> str: + pytest.fail("running jobs must not be dispatched again") + + monkeypatch.setattr("agentci.application.worker.execution.dispatch", unexpected_dispatch) + execute = task() + + await make_worker(tmp_path, repository)._execute(execute, current) + + assert repository.events[0][0] == f"task:{execute.id}:interrupted" + assert isinstance(repository.events[0][1], ServiceRestarted) + + +@pytest.mark.parametrize("status", [JobStatus.RECEIVED, JobStatus.SUCCEEDED]) +async def test_execute_ignores_ineligible_status(tmp_path: Path, status: JobStatus) -> None: + current = job(status=status) + repository = FakeRepository(current) + + await make_worker(tmp_path, repository)._execute(task(), current) + + assert repository.events == [] + + +async def test_reconcile_updates_linked_comment_in_place(tmp_path: Path) -> None: + current = job(status=JobStatus.QUEUED, accepted_comment_id=19) + repository = FakeRepository(current) + gitea = FakeGitea() + + await make_worker(tmp_path, repository, gitea=gitea)._reconcile( + task(TaskKind.RECONCILE_COMMENT, QueueName.CONTROL), current + ) + + assert gitea.update_calls == [("org", "repo", 19, render_job_comment(current))] + assert gitea.issue_comment_calls == [] + assert gitea.create_calls == [] + assert repository.events == [] + + +async def test_reconcile_finds_oldest_matching_bot_comment_and_links_it( + tmp_path: Path, +) -> None: + current = job(status=JobStatus.QUEUED, accepted_comment_id=99) + marker = f"" + repository = FakeRepository(current) + gitea = FakeGitea() + gitea.update_results = [False, True] + gitea.comments = [ + CommentInfo(8, "agentci", f"{marker}\nnewer", ""), + CommentInfo(3, "AgentCI", f"{marker}\nolder", ""), + CommentInfo(1, "alice", f"{marker}\nnot the bot", ""), + CommentInfo(2, "agentci", f"prefix {marker}", ""), + ] + reconcile = task(TaskKind.RECONCILE_COMMENT, QueueName.CONTROL) + + await make_worker(tmp_path, repository, gitea=gitea)._reconcile(reconcile, current) + + assert gitea.issue_comment_calls == [("org", "repo", 1)] + assert gitea.create_calls == [] + assert [call[2] for call in gitea.update_calls] == [99, 3] + assert repository.events[0][0] == f"task:{reconcile.id}:comment:3" + linked = repository.events[0][1] + assert isinstance(linked, CommentLinked) + assert linked.comment_id == 3 + + +async def test_reconcile_creates_links_and_populates_missing_comment(tmp_path: Path) -> None: + current = job(status=JobStatus.SUCCEEDED) + repository = FakeRepository(current) + gitea = FakeGitea() + gitea.comments = [CommentInfo(1, "alice", "unrelated", "")] + reconcile = task(TaskKind.RECONCILE_COMMENT, QueueName.CONTROL) + + await make_worker(tmp_path, repository, gitea=gitea)._reconcile(reconcile, current) + + body = render_job_comment(current) + assert gitea.create_calls == [("org", "repo", 1, body)] + assert gitea.update_calls == [("org", "repo", 42, body)] + assert repository.events[0][0] == f"task:{reconcile.id}:comment:42" + linked = repository.events[0][1] + assert isinstance(linked, CommentLinked) + assert linked.comment_id == 42 + + +async def test_recovery_resets_tasks_before_interrupting_running_jobs(tmp_path: Path) -> None: + repository = FakeRepository() + repository.running = [ + job(job_id="first", status=JobStatus.RUNNING), + job(job_id="second", status=JobStatus.RUNNING), + ] + + await make_worker(tmp_path, repository)._recover() + + assert repository.operations == [ + "recover_tasks", + "running_jobs", + "apply:recovery:first:service-restarted", + "apply:recovery:second:service-restarted", + ] + assert all(isinstance(event, ServiceRestarted) for _, event in repository.events) + + +@pytest.mark.parametrize( + ("primary", "reviewer", "expected"), + [ + ("primary", "reviewer", {"primary", "reviewer"}), + ("same-session", "same-session", {"same-session"}), + ], +) +async def test_abort_collects_and_deduplicates_workflow_sessions( + tmp_path: Path, + primary: str, + reviewer: str, + expected: set[str], +) -> None: workspace = tmp_path / "workflow" / "repo" workflow = Workflow( id="flow", @@ -48,46 +591,59 @@ async def test_abort_collects_all_workflow_sessions(tmp_path: Path) -> None: issue_number=1, workspace_path=workspace, base_sha="base", - primary_session_id="primary", - reviewer_session_id="reviewer", + primary_session_id=primary, + reviewer_session_id=reviewer, ) opencode = FakeOpenCode() - state = SimpleNamespace(workflow_id="flow", runtime_session_id=None, id="job") - await worker(tmp_path, FakeStorage(workflow), opencode)._abort_job_sessions( - cast(JobState, state) - ) - assert opencode.aborted == {("primary", workspace), ("reviewer", workspace)} + + await make_worker( + tmp_path, FakeRepository(workflow=workflow), opencode=opencode + )._abort_job_sessions(job(workflow_id="flow")) + + assert set(opencode.aborted) == {(session, workspace) for session in expected} -async def test_abort_uses_one_shot_fix_workspace(tmp_path: Path) -> None: +async def test_abort_uses_one_shot_workspace_without_workflow(tmp_path: Path) -> None: opencode = FakeOpenCode() - state = SimpleNamespace(workflow_id=None, runtime_session_id="session", id="job") - await worker(tmp_path, FakeStorage(), opencode)._abort_job_sessions( - cast(JobState, state) + + await make_worker(tmp_path, FakeRepository(), opencode=opencode)._abort_job_sessions( + job(workflow_id="missing", session_id="session") ) - assert opencode.aborted == {("session", tmp_path / "fix-job" / "repo")} + + assert opencode.aborted == [("session", tmp_path / "fix-job" / "repo")] -async def test_run_starts_configured_job_consumers(tmp_path: Path, monkeypatch) -> None: - value = worker(tmp_path, FakeStorage(), FakeOpenCode()) - queues = [] +async def test_abort_does_not_mix_one_shot_session_into_existing_workflow( + tmp_path: Path, +) -> None: + workflow = Workflow( + id="flow", + kind=WorkflowKind.IMPLEMENT, + repo_owner="org", + repo_name="repo", + issue_number=1, + workspace_path=tmp_path / "workflow" / "repo", + base_sha="base", + ) + opencode = FakeOpenCode() - async def recover() -> None: - pass + await make_worker( + tmp_path, FakeRepository(workflow=workflow), opencode=opencode + )._abort_job_sessions(job(workflow_id="flow", session_id="one-shot")) - async def loop(queue, _stop) -> None: - queues.append(queue) + assert opencode.aborted == [] - monkeypatch.setattr(value, "_recover", recover) - monkeypatch.setattr(value, "_loop", loop) - await value.run(asyncio.Event()) +async def test_abort_without_persisted_sessions_is_noop(tmp_path: Path) -> None: + opencode = FakeOpenCode() - assert queues.count("control") == 1 - assert queues.count("jobs") == 2 + await make_worker(tmp_path, FakeRepository(), opencode=opencode)._abort_job_sessions(job()) + + assert opencode.aborted == [] def test_safe_error_is_single_line_and_bounded() -> None: - value = _safe_error(RuntimeError("bad\n" + "x" * 2000)) + value = safe_error(RuntimeError("bad\n" + "x" * 2000)) + assert "\n" not in value assert len(value) == 1000 diff --git a/tests/test_workflow_dispatch.py b/tests/test_workflow_dispatch.py new file mode 100644 index 0000000..392c0f8 --- /dev/null +++ b/tests/test_workflow_dispatch.py @@ -0,0 +1,93 @@ +from dataclasses import replace +from enum import StrEnum +from typing import cast + +import pytest + +from agentci.engine.model import Job, JobKind +from agentci.engine.run import JobRun +from agentci.workflows import dispatch as dispatch_module +from agentci.workflows.services import WorkflowServices + +ROUTES = [ + (JobKind.PLAN, "create_plan"), + (JobKind.DISCUSS, "discuss_plan"), + (JobKind.ITERATE_PLAN, "iterate_plan"), + (JobKind.IMPLEMENT, "implement"), + (JobKind.ITERATE_IMPLEMENT, "iterate_implementation"), + (JobKind.FIX, "fix_pull_request"), +] + + +def make_job(kind: JobKind | None) -> Job: + return Job( + id="job-1", + kind=kind, + target_key="org/repo:issue:7", + repo_owner="org", + repo_name="repo", + issue_number=7, + pr_number=None, + requester="alice", + comment_id=11, + delivery_id="delivery-1", + receive_sequence=1, + command_body="/agent plan", + message="request", + ) + + +@pytest.mark.parametrize(("kind", "expected_route"), ROUTES) +async def test_dispatch_routes_every_job_kind_and_returns_body( + monkeypatch: pytest.MonkeyPatch, + kind: JobKind, + expected_route: str, +) -> None: + calls: list[tuple[str, Job]] = [] + + def route(name: str): + async def invoke(job: Job, _run: JobRun, _services: WorkflowServices) -> str: + calls.append((name, job)) + return f"body from {name}" + + return invoke + + for _, name in ROUTES: + monkeypatch.setattr(dispatch_module, name, route(name)) + + job = make_job(kind) + body = await dispatch_module.dispatch( + job, + cast(JobRun, object()), + cast(WorkflowServices, object()), + ) + + assert body == f"body from {expected_route}" + assert calls == [(expected_route, job)] + + +async def test_dispatch_rejects_unparsed_command() -> None: + with pytest.raises(RuntimeError, match="Cannot dispatch an unparsed command"): + await dispatch_module.dispatch( + make_job(None), + cast(JobRun, object()), + cast(WorkflowServices, object()), + ) + + +class UnsupportedKind(StrEnum): + UNKNOWN = "unknown" + + +async def test_dispatch_rejects_unsupported_kind() -> None: + unsupported = UnsupportedKind.UNKNOWN + job = replace(make_job(JobKind.PLAN), kind=cast(JobKind, unsupported)) + + with pytest.raises(KeyError) as error: + await dispatch_module.dispatch( + job, + cast(JobRun, object()), + cast(WorkflowServices, object()), + ) + + assert error.value.args == (unsupported,) diff --git a/tests/test_workflow_implementation.py b/tests/test_workflow_implementation.py new file mode 100644 index 0000000..2c82cde --- /dev/null +++ b/tests/test_workflow_implementation.py @@ -0,0 +1,392 @@ +import json +from pathlib import Path +from types import SimpleNamespace +from typing import Any, cast + +import pytest + +from agentci.engine.model import Job, JobKind, Workflow, WorkflowKind, WorkflowStatus +from agentci.engine.run import JobRun +from agentci.integrations.gitea.models import CommentInfo, IssueInfo, PullRequestInfo +from agentci.workflows.implementation import implement +from agentci.workflows.model import AgentResult, ReviewReport +from agentci.workflows.render import JobRejected +from agentci.workflows.services import WorkflowServices + + +class RecordingRun(JobRun): + def __init__(self) -> None: + self.stages: list[str] = [] + self.created_workflows: list[tuple[Workflow, str]] = [] + self.linked_sessions: list[str] = [] + + async def stage(self, stage: str) -> None: + self.stages.append(stage) + + async def create_workflow(self, workflow: Workflow, stage: str) -> None: + self.created_workflows.append((workflow, stage)) + + async def link_session(self, session_id: str) -> None: + self.linked_sessions.append(session_id) + + +class FakeRepository: + def __init__( + self, + *, + implementations: list[Workflow] | None = None, + plan: Workflow | None = None, + ) -> None: + self.implementations = implementations or [] + self.plan = plan + self.saved_workflows: list[Workflow] = [] + + async def implementation_workflows(self, *_args: object) -> list[Workflow]: + return self.implementations + + async def latest_workflow(self, *_args: object) -> Workflow | None: + return self.plan + + async def operational_comment_ids(self, *_args: object) -> set[int]: + return {99} + + async def save_workflow(self, workflow: Workflow) -> None: + self.saved_workflows.append(workflow) + + +class FakeGitea: + def __init__( + self, + *, + existing_pulls: dict[int, PullRequestInfo] | None = None, + ) -> None: + self.existing_pulls = existing_pulls or {} + self.created_pulls: list[tuple[str, str, dict[str, str]]] = [] + self.default_branch_calls: list[tuple[str, str]] = [] + + async def default_branch(self, owner: str, repo: str) -> str: + self.default_branch_calls.append((owner, repo)) + return "main" + + async def issue(self, *_args: object) -> IssueInfo: + return IssueInfo(7, "Fix widget", "The widget is broken.", "open") + + async def issue_comments(self, *_args: object) -> list[CommentInfo]: + return [ + CommentInfo(12, "alice", "Please cover empty input.", "2026-07-01"), + CommentInfo(99, "agentci", "Job queued", "2026-07-02"), + ] + + async def pull_request(self, _owner: str, _repo: str, number: int) -> PullRequestInfo: + return self.existing_pulls[number] + + async def create_pull_request(self, owner: str, repo: str, **values: str) -> PullRequestInfo: + self.created_pulls.append((owner, repo, values)) + return pull(number=42, branch=values["head"]) + + +class RecordingGit: + def __init__(self, *, changed: bool = True) -> None: + self.changed = changed + self.calls: list[tuple[Any, ...]] = [] + + async def clone(self, owner: str, repo: str, branch: str, destination: Path) -> str: + self.calls.append(("clone", owner, repo, branch, destination)) + return "base-sha" + + async def create_branch(self, workspace: Path, branch: str) -> None: + self.calls.append(("create_branch", workspace, branch)) + + async def has_changes(self, workspace: Path) -> bool: + self.calls.append(("has_changes", workspace)) + return self.changed + + async def diff_check(self, workspace: Path) -> None: + self.calls.append(("diff_check", workspace)) + + async def commit(self, workspace: Path, message: str) -> str: + self.calls.append(("commit", workspace, message)) + return "commit-sha" + + async def push(self, workspace: Path, branch: str, *, set_upstream: bool = False) -> None: + self.calls.append(("push", workspace, branch, set_upstream)) + + +class RecordingDevelopment: + description = "Python 3.13" + + def __init__(self) -> None: + self.workspaces: list[Path] = [] + + async def prepare(self, workspace: Path) -> None: + self.workspaces.append(workspace) + + +class RecordingPrompts: + def __init__(self) -> None: + self.calls: list[tuple[str, dict[str, str]]] = [] + + def render(self, name: str, **values: str) -> str: + self.calls.append((name, values)) + return f"rendered {name}" + + +class RecordingOpenCode: + def __init__(self, result: AgentResult, report: ReviewReport) -> None: + self.result = result + self.report = report + self.created_sessions: list[tuple[Path, str]] = [] + self.resume_calls: list[dict[str, Any]] = [] + + async def create_session(self, workspace: Path, title: str) -> str: + self.created_sessions.append((workspace, title)) + return f"{title}-session" + + async def resume(self, **values: Any) -> AgentResult | ReviewReport: + self.resume_calls.append(values) + if values["result_type"] is AgentResult: + return self.result + assert values["result_type"] is ReviewReport + return self.report + + +def job() -> Job: + return Job( + id="job-1", + kind=JobKind.IMPLEMENT, + target_key="org/repo:issue:7", + repo_owner="org", + repo_name="repo", + issue_number=7, + pr_number=None, + requester="alice", + comment_id=10, + delivery_id="delivery-1", + receive_sequence=1, + command_body="/agent implement", + message="Keep the change focused.", + ) + + +def workflow(*, pr_number: int | None = None) -> Workflow: + return Workflow( + id=f"old-{pr_number}", + kind=WorkflowKind.IMPLEMENT, + repo_owner="org", + repo_name="repo", + issue_number=7, + workspace_path=Path("/old/repo"), + base_sha="old-sha", + pr_number=pr_number, + status=WorkflowStatus.COMPLETED, + ) + + +def pull( + *, + number: int = 8, + state: str = "open", + merged: bool = False, + branch: str = "agent/old", +) -> PullRequestInfo: + return PullRequestInfo( + number=number, + title="Existing PR", + body="Body", + state=state, + merged=merged, + base_branch="main", + head_branch=branch, + head_sha="head-sha", + head_owner="org", + head_repo="repo", + ) + + +def make_services( + tmp_path: Path, + *, + changed: bool = True, + implementations: list[Workflow] | None = None, + existing_pulls: dict[int, PullRequestInfo] | None = None, + plan: Workflow | None = None, +) -> tuple[ + WorkflowServices, + FakeRepository, + FakeGitea, + RecordingGit, + RecordingDevelopment, + RecordingPrompts, + RecordingOpenCode, +]: + repository = FakeRepository(implementations=implementations, plan=plan) + gitea = FakeGitea(existing_pulls=existing_pulls) + git = RecordingGit(changed=changed) + development = RecordingDevelopment() + prompts = RecordingPrompts() + opencode = RecordingOpenCode( + AgentResult( + summary_markdown="# Implement widget\n\nHandled empty input.", + tests=["pytest: passed"], + ), + ReviewReport(summary="Ready", findings=[]), + ) + services = cast( + WorkflowServices, + SimpleNamespace( + settings=SimpleNamespace( + branch_prefix="agent", + workspaces_dir=tmp_path / "workspaces", + gitea_url="https://git.example.test", + implement_model="provider/model", + implement_variant="high", + implement_review_rounds=2, + ), + repository=repository, + gitea=gitea, + git=git, + opencode=opencode, + prompts=prompts, + development=development, + ), + ) + return services, repository, gitea, git, development, prompts, opencode + + +async def test_initial_implementation_completes_review_commit_push_and_pr( + tmp_path: Path, +) -> None: + stale = workflow(pr_number=8) + services, repository, gitea, git, development, prompts, opencode = make_services( + tmp_path, + implementations=[stale], + existing_pulls={8: pull(state="closed")}, + ) + run = RecordingRun() + + body = await implement(job(), run, services) + + assert len(run.created_workflows) == 1 + created, created_stage = run.created_workflows[0] + assert created_stage == "installing development environment" + assert created.status is WorkflowStatus.ACTIVE + assert created.branch == f"agent/issue-7-{created.id[:8]}" + assert created.workspace_path == tmp_path / "workspaces" / created.id / "repo" + assert created.base_sha == "base-sha" + assert development.workspaces == [created.workspace_path] + assert run.linked_sessions == ["implementation-session"] + assert opencode.created_sessions == [ + (created.workspace_path, "implementation"), + (created.workspace_path, "implementation-review"), + ] + assert [call["result_type"] for call in opencode.resume_calls] == [ + AgentResult, + ReviewReport, + ] + + initial_prompt = prompts.calls[0] + assert initial_prompt[0] == "implement_initial" + assert initial_prompt[1]["artifact"] == "(no canonical plan)" + assert initial_prompt[1]["request"] == "Keep the change focused." + assert "Please cover empty input." in initial_prompt[1]["context"] + assert "Job queued" not in initial_prompt[1]["context"] + + assert git.calls == [ + ("clone", "org", "repo", "main", created.workspace_path), + ("create_branch", created.workspace_path, created.branch), + ("has_changes", created.workspace_path), + ("diff_check", created.workspace_path), + ("commit", created.workspace_path, "agent: Implement widget"), + ("push", created.workspace_path, created.branch, True), + ] + assert gitea.created_pulls == [ + ( + "org", + "repo", + { + "title": "Agent: Fix widget", + "body": ( + "Closes #7\n\n" + "## Implementation\n\n# Implement widget\n\nHandled empty input.\n\n" + "## Validation\n\n- pytest: passed\n\n_Created by Agent CI._" + ), + "head": created.branch, + "base": "main", + }, + ) + ] + + completed = repository.saved_workflows[-1] + assert completed.id == created.id + assert completed.status is WorkflowStatus.COMPLETED + assert completed.pr_number == 42 + assert completed.primary_session_id == "implementation-session" + assert completed.reviewer_session_id == "implementation-review-session" + assert AgentResult.model_validate_json(completed.artifact or "").tests == ["pytest: passed"] + assert json.loads(completed.review_json or "") == { + "summary": "Ready", + "findings": [], + } + assert body == ( + f"\n" + "Pull request created: https://git.example.test/org/repo/pulls/42\n\n" + "## Agent result\n\n# Implement widget\n\nHandled empty input.\n\n" + "## Validation\n\n- pytest: passed\n\nCommit: `commit-sha`" + ) + + +async def test_initial_implementation_rejects_clean_worktree_before_commit_or_pr( + tmp_path: Path, +) -> None: + services, repository, gitea, git, _, _, _ = make_services(tmp_path, changed=False) + run = RecordingRun() + + with pytest.raises( + JobRejected, + match="OpenCode completed without producing any file changes", + ): + await implement(job(), run, services) + + assert [call[0] for call in git.calls] == [ + "clone", + "create_branch", + "has_changes", + ] + assert gitea.created_pulls == [] + assert repository.saved_workflows + assert all(item.status is WorkflowStatus.ACTIVE for item in repository.saved_workflows) + assert "creating pull request" not in run.stages + + +@pytest.mark.parametrize( + ("existing_pull", "message"), + [ + ( + pull(state="open"), + "Agent PR #8 is already open. Use `/agent iterate` on that pull request.", + ), + ( + pull(state="closed", merged=True), + "Agent PR #8 has already been merged for this issue.", + ), + ], +) +async def test_initial_implementation_rejects_duplicate_agent_pr_before_clone( + tmp_path: Path, + existing_pull: PullRequestInfo, + message: str, +) -> None: + services, _, gitea, git, development, _, opencode = make_services( + tmp_path, + implementations=[workflow(pr_number=8)], + existing_pulls={8: existing_pull}, + ) + + with pytest.raises(JobRejected) as error: + await implement(job(), RecordingRun(), services) + + assert str(error.value) == message + assert git.calls == [] + assert development.workspaces == [] + assert opencode.resume_calls == [] + assert gitea.default_branch_calls == [] diff --git a/tests/test_workflow_plan.py b/tests/test_workflow_plan.py new file mode 100644 index 0000000..e0cbc79 --- /dev/null +++ b/tests/test_workflow_plan.py @@ -0,0 +1,439 @@ +import json +from pathlib import Path +from types import SimpleNamespace +from typing import Any, cast + +import pytest +from pydantic import BaseModel + +from agentci.engine.model import Job, JobKind, Workflow, WorkflowKind, WorkflowStatus +from agentci.engine.run import JobRun +from agentci.integrations.gitea.models import CommentInfo, IssueInfo, PullRequestInfo +from agentci.workflows.model import DiscussionReply, PlanArtifact, ReviewReport +from agentci.workflows.plan import create_plan, discuss_plan, iterate_plan +from agentci.workflows.render import JobRejected +from agentci.workflows.services import WorkflowServices + + +class RecordingRun(JobRun): + def __init__(self) -> None: + self.stages: list[str] = [] + self.created_workflows: list[tuple[Workflow, str]] = [] + self.linked_workflows: list[tuple[str, str]] = [] + self.linked_sessions: list[str] = [] + + async def stage(self, stage: str) -> None: + self.stages.append(stage) + + async def create_workflow(self, workflow: Workflow, stage: str) -> None: + self.created_workflows.append((workflow, stage)) + + async def link_workflow(self, workflow_id: str, stage: str) -> None: + self.linked_workflows.append((workflow_id, stage)) + + async def link_session(self, session_id: str) -> None: + self.linked_sessions.append(session_id) + + +class FakeRepository: + def __init__( + self, + *, + latest: Workflow | None = None, + implementations: list[Workflow] | None = None, + ) -> None: + self.latest = latest + self.implementations = implementations or [] + self.saved_workflows: list[Workflow] = [] + self.latest_calls = 0 + + async def latest_workflow(self, *_args: object) -> Workflow | None: + self.latest_calls += 1 + return self.latest + + async def implementation_workflows(self, *_args: object) -> list[Workflow]: + return self.implementations + + async def operational_comment_ids(self, *_args: object) -> set[int]: + return {91} + + async def save_workflow(self, workflow: Workflow) -> None: + self.saved_workflows.append(workflow) + self.latest = workflow + + +class FakeGitea: + def __init__(self, pulls: dict[int, PullRequestInfo] | None = None) -> None: + self.pulls = pulls or {} + self.default_branch_calls: list[tuple[str, str]] = [] + self.pull_calls: list[int] = [] + + async def default_branch(self, owner: str, repo: str) -> str: + self.default_branch_calls.append((owner, repo)) + return "trunk" + + async def issue(self, *_args: object) -> IssueInfo: + return IssueInfo(3, "Plan feature", "Build the feature.", "open") + + async def issue_comments(self, *_args: object) -> list[CommentInfo]: + return [ + CommentInfo(4, "alice", "Use the existing API.", "2026-07-01"), + CommentInfo(91, "agentci", "Job queued", "2026-07-02"), + ] + + async def pull_request(self, _owner: str, _repo: str, number: int) -> PullRequestInfo: + self.pull_calls.append(number) + return self.pulls[number] + + +class RecordingGit: + def __init__(self) -> None: + self.clone_calls: list[tuple[str, str, str, Path]] = [] + + async def clone(self, owner: str, repo: str, branch: str, destination: Path) -> str: + self.clone_calls.append((owner, repo, branch, destination)) + return "base-sha" + + +class RecordingPrompts: + def __init__(self) -> None: + self.calls: list[tuple[str, dict[str, str]]] = [] + + def render(self, name: str, **values: str) -> str: + self.calls.append((name, values)) + return f"rendered {name}" + + +class RecordingOpenCode: + def __init__(self, responses: list[BaseModel] | None = None) -> None: + self.responses = list(responses or []) + self.created_sessions: list[tuple[Path, str]] = [] + self.resume_calls: list[dict[str, Any]] = [] + + async def create_session(self, workspace: Path, title: str) -> str: + self.created_sessions.append((workspace, title)) + return f"{title}-session" + + async def resume(self, **values: Any) -> BaseModel: + self.resume_calls.append(values) + response = self.responses.pop(0) + assert isinstance(response, values["result_type"]) + return response + + +def job(kind: JobKind, *, message: str | None = "Please be specific.") -> Job: + return Job( + id="job-plan", + kind=kind, + target_key="org/repo:issue:3", + repo_owner="org", + repo_name="repo", + issue_number=3, + pr_number=None, + requester="alice", + comment_id=5, + delivery_id="delivery-plan", + receive_sequence=1, + command_body="/agent plan", + message=message, + ) + + +def plan_workflow( + *, + runtime: str = "opencode", + primary_session_id: str | None = "primary-session", + reviewer_session_id: str | None = "reviewer-session", + artifact: str | None = "Original plan", +) -> Workflow: + return Workflow( + id="plan-flow", + kind=WorkflowKind.PLAN, + repo_owner="org", + repo_name="repo", + issue_number=3, + workspace_path=Path("/workspace/plan"), + base_sha="base-sha", + runtime=runtime, + primary_session_id=primary_session_id, + reviewer_session_id=reviewer_session_id, + artifact=artifact, + review_json='{"summary":"Prior","findings":[]}', + status=WorkflowStatus.COMPLETED, + ) + + +def implementation_workflow(pr_number: int | None = 9) -> Workflow: + return Workflow( + id="implementation-flow", + kind=WorkflowKind.IMPLEMENT, + repo_owner="org", + repo_name="repo", + issue_number=3, + workspace_path=Path("/workspace/implementation"), + base_sha="base", + pr_number=pr_number, + status=WorkflowStatus.COMPLETED, + ) + + +def pull(*, state: str = "open", merged: bool = False) -> PullRequestInfo: + return PullRequestInfo( + number=9, + title="Agent implementation", + body="Body", + state=state, + merged=merged, + base_branch="trunk", + head_branch="agent/feature", + head_sha="head", + head_owner="org", + head_repo="repo", + ) + + +def make_services( + tmp_path: Path, + *, + latest: Workflow | None = None, + implementations: list[Workflow] | None = None, + pulls: dict[int, PullRequestInfo] | None = None, + responses: list[BaseModel] | None = None, +) -> tuple[ + WorkflowServices, + FakeRepository, + FakeGitea, + RecordingGit, + RecordingPrompts, + RecordingOpenCode, +]: + repository = FakeRepository(latest=latest, implementations=implementations) + gitea = FakeGitea(pulls) + git = RecordingGit() + prompts = RecordingPrompts() + opencode = RecordingOpenCode(responses) + services = cast( + WorkflowServices, + SimpleNamespace( + settings=SimpleNamespace( + workspaces_dir=tmp_path / "workspaces", + plan_model="provider/model", + plan_variant="high", + plan_review_rounds=3, + ), + repository=repository, + gitea=gitea, + git=git, + prompts=prompts, + opencode=opencode, + ), + ) + return services, repository, gitea, git, prompts, opencode + + +async def test_create_plan_completes_primary_and_independent_review( + tmp_path: Path, +) -> None: + services, repository, gitea, git, prompts, opencode = make_services( + tmp_path, + responses=[ + PlanArtifact(plan_markdown="# Complete plan"), + ReviewReport(summary="Ready", findings=[]), + ], + ) + run = RecordingRun() + + body = await create_plan(job(JobKind.PLAN), run, services) + + created, stage = run.created_workflows[0] + assert stage == "planning" + assert created.kind is WorkflowKind.PLAN + assert created.workspace_path == tmp_path / "workspaces" / created.id / "repo" + assert git.clone_calls == [("org", "repo", "trunk", created.workspace_path)] + assert gitea.default_branch_calls == [("org", "repo")] + assert run.linked_sessions == ["plan-session"] + assert opencode.created_sessions == [ + (created.workspace_path, "plan"), + (created.workspace_path, "plan-review"), + ] + assert [call["result_type"] for call in opencode.resume_calls] == [ + PlanArtifact, + ReviewReport, + ] + assert prompts.calls[0][0] == "plan_initial" + assert prompts.calls[0][1]["request"] == "Please be specific." + assert "Use the existing API." in prompts.calls[0][1]["context"] + assert "Job queued" not in prompts.calls[0][1]["context"] + + completed = repository.saved_workflows[-1] + assert completed.status is WorkflowStatus.COMPLETED + assert completed.primary_session_id == "plan-session" + assert completed.reviewer_session_id == "plan-review-session" + assert completed.artifact == "# Complete plan" + assert json.loads(completed.review_json or "") == { + "summary": "Ready", + "findings": [], + } + assert body == f"\n# Complete plan" + + +async def test_discuss_plan_resumes_primary_session_without_replacing_artifact( + tmp_path: Path, +) -> None: + existing = plan_workflow() + services, repository, _, _, prompts, opencode = make_services( + tmp_path, + latest=existing, + responses=[DiscussionReply(markdown="The API remains compatible.")], + ) + run = RecordingRun() + + body = await discuss_plan(job(JobKind.DISCUSS), run, services) + + assert run.linked_workflows == [(existing.id, "discussing")] + assert opencode.created_sessions == [] + assert opencode.resume_calls[0]["session_id"] == "primary-session" + assert opencode.resume_calls[0]["schema_name"] == "discussion.json" + assert prompts.calls == [ + ( + "discuss", + {"artifact": "Original plan", "message": "Please be specific."}, + ) + ] + assert repository.saved_workflows == [] + assert body == ("\nThe API remains compatible.") + + +@pytest.mark.parametrize( + ("latest", "message"), + [ + (None, "No completed plan exists. Start with `/agent plan`."), + ( + plan_workflow(runtime="codex"), + "The latest plan predates OpenCode and cannot be resumed; start a new `/agent plan`.", + ), + ( + plan_workflow(primary_session_id=None), + "The latest plan cannot be resumed; start a new `/agent plan`.", + ), + ( + plan_workflow(artifact=None), + "The latest plan cannot be resumed; start a new `/agent plan`.", + ), + ], +) +async def test_discuss_plan_rejects_missing_or_incompatible_plan( + tmp_path: Path, + latest: Workflow | None, + message: str, +) -> None: + services, _, _, _, _, opencode = make_services(tmp_path, latest=latest) + + with pytest.raises(JobRejected) as error: + await discuss_plan(job(JobKind.DISCUSS), RecordingRun(), services) + + assert str(error.value) == message + assert opencode.resume_calls == [] + + +async def test_iterate_plan_revises_then_reuses_reviewer_session( + tmp_path: Path, +) -> None: + existing = plan_workflow() + services, repository, gitea, _, prompts, opencode = make_services( + tmp_path, + latest=existing, + implementations=[implementation_workflow(), implementation_workflow(None)], + pulls={9: pull(state="closed")}, + responses=[ + PlanArtifact(plan_markdown="# Revised plan"), + ReviewReport(summary="Ready", findings=[]), + ], + ) + run = RecordingRun() + + body = await iterate_plan(job(JobKind.ITERATE_PLAN, message=None), run, services) + + assert gitea.pull_calls == [9] + assert run.linked_workflows == [(existing.id, "iterating plan")] + assert opencode.created_sessions == [] + assert [call["session_id"] for call in opencode.resume_calls] == [ + "primary-session", + "reviewer-session", + ] + assert [call["result_type"] for call in opencode.resume_calls] == [ + PlanArtifact, + ReviewReport, + ] + iterate_prompt = prompts.calls[0] + assert iterate_prompt[0] == "plan_iterate" + assert iterate_prompt[1]["artifact"] == "Original plan" + assert iterate_prompt[1]["message"] == ("(refine using the latest discussion and prior review)") + assert json.loads(iterate_prompt[1]["review"]) == { + "summary": "Prior", + "findings": [], + } + completed = repository.saved_workflows[-1] + assert completed.artifact == "# Revised plan" + assert completed.status is WorkflowStatus.COMPLETED + assert body == "\n# Revised plan" + + +@pytest.mark.parametrize( + ("latest", "message"), + [ + (None, "No completed plan exists. Start with `/agent plan`."), + ( + plan_workflow(runtime="codex"), + "The latest plan predates OpenCode; start a new plan.", + ), + ( + plan_workflow(primary_session_id=None), + "The latest plan is missing resumable sessions; start a new plan.", + ), + ( + plan_workflow(reviewer_session_id=None), + "The latest plan is missing resumable sessions; start a new plan.", + ), + (plan_workflow(artifact=None), "The latest plan has no saved artifact."), + ], +) +async def test_iterate_plan_rejects_missing_or_incompatible_plan( + tmp_path: Path, + latest: Workflow | None, + message: str, +) -> None: + services, repository, _, _, _, opencode = make_services(tmp_path, latest=latest) + + with pytest.raises(JobRejected) as error: + await iterate_plan(job(JobKind.ITERATE_PLAN), RecordingRun(), services) + + assert str(error.value) == message + assert repository.saved_workflows == [] + assert opencode.resume_calls == [] + + +@pytest.mark.parametrize( + "blocking_pull", + [pull(state="open"), pull(state="closed", merged=True)], + ids=["open", "merged"], +) +async def test_iterate_plan_rejects_after_agent_pr_is_open_or_merged( + tmp_path: Path, blocking_pull: PullRequestInfo +) -> None: + services, repository, _, _, _, opencode = make_services( + tmp_path, + latest=plan_workflow(), + implementations=[implementation_workflow()], + pulls={9: blocking_pull}, + ) + + with pytest.raises(JobRejected) as error: + await iterate_plan(job(JobKind.ITERATE_PLAN), RecordingRun(), services) + + assert str(error.value) == ( + "Issue plan iteration is disabled because agent PR #9 is open or merged. " + "Iterate an open implementation on its PR." + ) + assert repository.latest_calls == 0 + assert opencode.resume_calls == [] diff --git a/tests/test_workflow_pull_request.py b/tests/test_workflow_pull_request.py new file mode 100644 index 0000000..8f34e51 --- /dev/null +++ b/tests/test_workflow_pull_request.py @@ -0,0 +1,502 @@ +import json +from pathlib import Path +from types import SimpleNamespace +from typing import Any, cast + +import pytest +from pydantic import BaseModel + +from agentci.engine.model import Job, JobKind, Workflow, WorkflowKind, WorkflowStatus +from agentci.engine.run import JobRun +from agentci.integrations.gitea.models import CommentInfo, IssueInfo, PullRequestInfo +from agentci.workflows.model import AgentResult, ReviewReport +from agentci.workflows.pull_request import fix_pull_request, iterate_implementation +from agentci.workflows.render import JobRejected +from agentci.workflows.services import WorkflowServices + + +class RecordingRun(JobRun): + def __init__(self) -> None: + self.stages: list[str] = [] + self.linked_workflows: list[tuple[str, str]] = [] + self.linked_sessions: list[str] = [] + + async def stage(self, stage: str) -> None: + self.stages.append(stage) + + async def link_workflow(self, workflow_id: str, stage: str) -> None: + self.linked_workflows.append((workflow_id, stage)) + + async def link_session(self, session_id: str) -> None: + self.linked_sessions.append(session_id) + + +class FakeRepository: + def __init__( + self, + *, + workflow: Workflow | None = None, + plan: Workflow | None = None, + ) -> None: + self.workflow = workflow + self.plan = plan + self.saved_workflows: list[Workflow] = [] + + async def workflow_for_pr(self, *_args: object) -> Workflow | None: + return self.workflow + + async def latest_workflow(self, *_args: object) -> Workflow | None: + return self.plan + + async def operational_comment_ids(self, *_args: object) -> set[int]: + return set() + + async def save_workflow(self, workflow: Workflow) -> None: + self.workflow = workflow + self.saved_workflows.append(workflow) + + +class FakeGitea: + def __init__(self, pull: PullRequestInfo) -> None: + self.pull = pull + self.pull_calls: list[tuple[str, str, int]] = [] + + async def pull_request(self, owner: str, repo: str, number: int) -> PullRequestInfo: + self.pull_calls.append((owner, repo, number)) + return self.pull + + async def issue_comments(self, *_args: object) -> list[CommentInfo]: + return [CommentInfo(1, "alice", "Please add coverage.", "2026-07-01")] + + async def pull_reviews(self, *_args: object) -> list[dict[str, Any]]: + return [ + { + "id": 3, + "user": {"login": "bob"}, + "state": "REQUEST_CHANGES", + "body": "Handle empty input.", + } + ] + + async def review_comments(self, *_args: object) -> list[dict[str, Any]]: + return [{"path": "src/widget.py", "new_position": 8, "body": "Add a guard."}] + + async def pull_commits(self, *_args: object) -> list[dict[str, Any]]: + return [{"sha": "abcdef1234567890", "commit": {"message": "Initial change"}}] + + async def issue(self, *_args: object) -> IssueInfo: + return IssueInfo(7, "Fix widget", "The widget is broken.", "open") + + +class RecordingGit: + def __init__(self, *, changed: bool = True) -> None: + self.changed = changed + self.calls: list[tuple[Any, ...]] = [] + + async def clone(self, owner: str, repo: str, branch: str, destination: Path) -> str: + self.calls.append(("clone", owner, repo, branch, destination)) + return "head-sha" + + async def sync_branch(self, workspace: Path, branch: str) -> str: + self.calls.append(("sync_branch", workspace, branch)) + return "head-sha" + + async def has_changes(self, workspace: Path) -> bool: + self.calls.append(("has_changes", workspace)) + return self.changed + + async def diff_check(self, workspace: Path) -> None: + self.calls.append(("diff_check", workspace)) + + async def commit(self, workspace: Path, message: str) -> str: + self.calls.append(("commit", workspace, message)) + return "new-sha" + + async def push(self, workspace: Path, branch: str, *, set_upstream: bool = False) -> None: + self.calls.append(("push", workspace, branch, set_upstream)) + + +class RecordingDevelopment: + description = "Python 3.13" + + def __init__(self) -> None: + self.workspaces: list[Path] = [] + + async def prepare(self, workspace: Path) -> None: + self.workspaces.append(workspace) + + +class RecordingPrompts: + def __init__(self) -> None: + self.calls: list[tuple[str, dict[str, str]]] = [] + + def render(self, name: str, **values: str) -> str: + self.calls.append((name, values)) + return f"rendered {name}" + + +class RecordingOpenCode: + def __init__(self, responses: list[BaseModel] | None = None) -> None: + self.responses = list(responses or []) + self.created_sessions: list[tuple[Path, str]] = [] + self.resume_calls: list[dict[str, Any]] = [] + + async def create_session(self, workspace: Path, title: str) -> str: + self.created_sessions.append((workspace, title)) + return f"{title}-session" + + async def resume(self, **values: Any) -> BaseModel: + self.resume_calls.append(values) + response = self.responses.pop(0) + assert isinstance(response, values["result_type"]) + return response + + +def job(kind: JobKind, *, pr_number: int | None = 12) -> Job: + return Job( + id="job-pr", + kind=kind, + target_key="org/repo:pr:12", + repo_owner="org", + repo_name="repo", + issue_number=7, + pr_number=pr_number, + requester="alice", + comment_id=9, + delivery_id="delivery-pr", + receive_sequence=1, + command_body="/agent iterate", + message="Handle the review.", + ) + + +def implementation_workflow( + *, + status: WorkflowStatus = WorkflowStatus.COMPLETED, + runtime: str = "opencode", + branch: str | None = "agent/issue-7", + primary_session_id: str | None = "primary-session", + reviewer_session_id: str | None = "reviewer-session", +) -> Workflow: + return Workflow( + id="implementation-flow", + kind=WorkflowKind.IMPLEMENT, + repo_owner="org", + repo_name="repo", + issue_number=7, + workspace_path=Path("/workspace/implementation"), + base_sha="base-sha", + runtime=runtime, + branch=branch, + pr_number=12, + primary_session_id=primary_session_id, + reviewer_session_id=reviewer_session_id, + artifact='{"summary_markdown":"Prior","tests":[]}', + review_json='{"summary":"Prior review","findings":[]}', + status=status, + ) + + +def plan_workflow() -> Workflow: + return Workflow( + id="plan-flow", + kind=WorkflowKind.PLAN, + repo_owner="org", + repo_name="repo", + issue_number=7, + workspace_path=Path("/workspace/plan"), + base_sha="base", + artifact="Canonical plan", + status=WorkflowStatus.COMPLETED, + ) + + +def pull( + *, + state: str = "open", + merged: bool = False, + branch: str = "agent/issue-7", +) -> PullRequestInfo: + return PullRequestInfo( + number=12, + title="Fix widget", + body="Implementation body", + state=state, + merged=merged, + base_branch="main", + head_branch=branch, + head_sha="abcdef1234567890", + head_owner="contributor", + head_repo="fork", + ) + + +def make_services( + tmp_path: Path, + *, + workflow: Workflow | None = None, + pull_info: PullRequestInfo | None = None, + plan: Workflow | None = None, + changed: bool = True, + responses: list[BaseModel] | None = None, +) -> tuple[ + WorkflowServices, + FakeRepository, + FakeGitea, + RecordingGit, + RecordingDevelopment, + RecordingPrompts, + RecordingOpenCode, +]: + repository = FakeRepository(workflow=workflow, plan=plan) + gitea = FakeGitea(pull_info or pull()) + git = RecordingGit(changed=changed) + development = RecordingDevelopment() + prompts = RecordingPrompts() + opencode = RecordingOpenCode(responses) + services = cast( + WorkflowServices, + SimpleNamespace( + settings=SimpleNamespace( + workspaces_dir=tmp_path / "workspaces", + implement_model="provider/model", + implement_variant="high", + ), + repository=repository, + gitea=gitea, + git=git, + development=development, + prompts=prompts, + opencode=opencode, + ), + ) + return services, repository, gitea, git, development, prompts, opencode + + +def result(summary: str = "# Refine widget") -> AgentResult: + return AgentResult(summary_markdown=summary, tests=["pytest: passed"]) + + +async def test_iterate_implementation_reuses_sessions_reviews_commits_and_pushes( + tmp_path: Path, +) -> None: + existing = implementation_workflow() + services, repository, _, git, development, prompts, opencode = make_services( + tmp_path, + workflow=existing, + plan=plan_workflow(), + responses=[result(), ReviewReport(summary="Ready", findings=[])], + ) + run = RecordingRun() + + body = await iterate_implementation(job(JobKind.ITERATE_IMPLEMENT), run, services) + + assert run.linked_workflows == [(existing.id, "synchronizing branch")] + assert development.workspaces == [existing.workspace_path] + assert opencode.created_sessions == [] + assert [call["session_id"] for call in opencode.resume_calls] == [ + "primary-session", + "reviewer-session", + ] + assert [call["result_type"] for call in opencode.resume_calls] == [ + AgentResult, + ReviewReport, + ] + iterate_prompt = prompts.calls[0] + assert iterate_prompt[0] == "implementation_iterate" + assert iterate_prompt[1]["message"] == "Handle the review." + assert "Handle empty input." in iterate_prompt[1]["context"] + review_prompt = prompts.calls[1] + assert review_prompt[0] == "implementation_review" + assert review_prompt[1]["artifact"] == "Canonical plan" + assert "The widget is broken." in review_prompt[1]["issue_context"] + + assert git.calls == [ + ("sync_branch", existing.workspace_path, "agent/issue-7"), + ("has_changes", existing.workspace_path), + ("diff_check", existing.workspace_path), + ("commit", existing.workspace_path, "agent iterate: Refine widget"), + ("push", existing.workspace_path, "agent/issue-7", False), + ] + saved = repository.saved_workflows[-1] + assert AgentResult.model_validate_json(saved.artifact or "") == result() + assert json.loads(saved.review_json or "") == { + "summary": "Ready", + "findings": [], + } + assert body == ( + "\n" + "## Agent result\n\n# Refine widget\n\n" + "## Validation\n\n- pytest: passed\n\nCommit: `new-sha`" + ) + + +@pytest.mark.parametrize( + ("pr_number", "existing", "message"), + [ + (None, implementation_workflow(), "This command requires a pull request."), + ( + 12, + None, + "This is not an open agent-created implementation PR. Use `/agent fix`.", + ), + ( + 12, + implementation_workflow(status=WorkflowStatus.ACTIVE), + "This is not an open agent-created implementation PR. Use `/agent fix`.", + ), + ( + 12, + implementation_workflow(runtime="codex"), + "The implementation predates OpenCode and cannot be resumed.", + ), + ( + 12, + implementation_workflow(primary_session_id=None), + "The implementation sessions cannot be resumed.", + ), + ( + 12, + implementation_workflow(reviewer_session_id=None), + "The implementation sessions cannot be resumed.", + ), + ], +) +async def test_iterate_implementation_rejects_missing_stale_or_incompatible_workflow( + tmp_path: Path, + pr_number: int | None, + existing: Workflow | None, + message: str, +) -> None: + services, repository, gitea, git, development, _, opencode = make_services( + tmp_path, workflow=existing + ) + + with pytest.raises(JobRejected) as error: + await iterate_implementation( + job(JobKind.ITERATE_IMPLEMENT, pr_number=pr_number), + RecordingRun(), + services, + ) + + assert str(error.value) == message + assert repository.saved_workflows == [] + assert gitea.pull_calls == [] + assert git.calls == [] + assert development.workspaces == [] + assert opencode.resume_calls == [] + + +@pytest.mark.parametrize( + ("pull_info", "existing", "message"), + [ + ( + pull(state="closed"), + implementation_workflow(), + "Implementation iteration requires an open pull request.", + ), + ( + pull(branch="renamed-branch"), + implementation_workflow(), + "The pull request head branch no longer matches its workflow.", + ), + ], + ids=["closed", "branch-mismatch"], +) +async def test_iterate_implementation_rejects_closed_or_stale_branch_before_checkout( + tmp_path: Path, + pull_info: PullRequestInfo, + existing: Workflow, + message: str, +) -> None: + services, repository, _, git, development, _, opencode = make_services( + tmp_path, workflow=existing, pull_info=pull_info + ) + + with pytest.raises(JobRejected) as error: + await iterate_implementation(job(JobKind.ITERATE_IMPLEMENT), RecordingRun(), services) + + assert str(error.value) == message + assert repository.saved_workflows == [] + assert git.calls == [] + assert development.workspaces == [] + assert opencode.resume_calls == [] + + +async def test_fix_pull_request_clones_head_runs_agent_commits_and_pushes( + tmp_path: Path, +) -> None: + services, repository, _, git, development, prompts, opencode = make_services( + tmp_path, + responses=[result("Fix empty input")], + ) + run = RecordingRun() + + body = await fix_pull_request(job(JobKind.FIX), run, services) + + workspace = tmp_path / "workspaces" / "fix-job-pr" / "repo" + assert git.calls == [ + ("clone", "contributor", "fork", "agent/issue-7", workspace), + ("has_changes", workspace), + ("diff_check", workspace), + ("commit", workspace, "agent fix: Fix empty input"), + ("push", workspace, "agent/issue-7", False), + ] + assert development.workspaces == [workspace] + assert opencode.created_sessions == [(workspace, "fix")] + assert run.linked_sessions == ["fix-session"] + assert opencode.resume_calls[0]["session_id"] == "fix-session" + assert opencode.resume_calls[0]["result_type"] is AgentResult + assert prompts.calls[0][0] == "fix" + assert "`src/widget.py:8`: Add a guard." in prompts.calls[0][1]["context"] + assert repository.saved_workflows == [] + assert body == ( + "\n" + "## Agent result\n\nFix empty input\n\n" + "## Validation\n\n- pytest: passed\n\nCommit: `new-sha`" + ) + + +async def test_fix_pull_request_rejects_clean_worktree_before_push( + tmp_path: Path, +) -> None: + services, _, _, git, _, _, _ = make_services( + tmp_path, + changed=False, + responses=[result("No changes required")], + ) + + with pytest.raises( + JobRejected, + match="OpenCode completed without producing any file changes", + ): + await fix_pull_request(job(JobKind.FIX), RecordingRun(), services) + + assert [call[0] for call in git.calls] == ["clone", "has_changes"] + + +@pytest.mark.parametrize( + ("pr_number", "pull_info", "message"), + [ + (None, pull(), "This command requires a pull request."), + (12, pull(state="closed"), "Fixes require an open pull request."), + (12, pull(state="closed", merged=True), "Fixes require an open pull request."), + ], + ids=["missing-pr", "closed", "merged"], +) +async def test_fix_pull_request_requires_open_pr_before_clone( + tmp_path: Path, + pr_number: int | None, + pull_info: PullRequestInfo, + message: str, +) -> None: + services, _, gitea, git, development, _, opencode = make_services(tmp_path, pull_info=pull_info) + + with pytest.raises(JobRejected) as error: + await fix_pull_request(job(JobKind.FIX, pr_number=pr_number), RecordingRun(), services) + + assert str(error.value) == message + assert git.calls == [] + assert development.workspaces == [] + assert opencode.resume_calls == [] + assert gitea.pull_calls == ([] if pr_number is None else [("org", "repo", 12)]) diff --git a/tests/test_workflow_render.py b/tests/test_workflow_render.py new file mode 100644 index 0000000..0b05ed6 --- /dev/null +++ b/tests/test_workflow_render.py @@ -0,0 +1,117 @@ +import json + +import pytest + +from agentci.workflows.model import ( + AgentResult, + ReviewFinding, + ReviewReport, + ReviewSeverity, +) +from agentci.workflows.render import ( + agent_comment, + commit_title, + final_comment, + pull_request_body, + report_for_prompt, + required_session, + result_comment, + review_markdown, +) + + +def report() -> ReviewReport: + return ReviewReport( + summary="One issue remains.", + findings=[ + ReviewFinding( + severity=ReviewSeverity.MAJOR, + title="Missing validation", + detail="The empty input is not checked.", + location="src/widget.py:12", + recommendation="Reject empty input.", + ) + ], + ) + + +def test_review_markdown_renders_user_visible_finding() -> None: + assert review_markdown(report()) == ( + "## Remaining review findings\n\n" + "One issue remains.\n\n" + "### MAJOR: Missing validation \u2014 `src/widget.py:12`\n" + "The empty input is not checked.\n\n" + "Recommendation: Reject empty input." + ) + assert review_markdown(ReviewReport(summary="Ready", findings=[])) == "" + + +def test_agent_and_final_comments_preserve_protocol_marker() -> None: + assert agent_comment("plan", "flow-1", "Plan body") == ( + "\nPlan body" + ) + assert final_comment("plan", "flow-1", "Plan body", report()) == ( + f"\nPlan body\n\n{review_markdown(report())}" + ) + assert ( + final_comment("plan", "flow-1", "Plan body", ReviewReport(summary="Ready", findings=[])) + == "\nPlan body" + ) + + +def test_pull_request_body_and_result_comment_render_validation() -> None: + result = AgentResult( + summary_markdown="Implemented the widget fix.", + tests=["pytest: passed", "ruff: passed"], + ) + + assert pull_request_body(17, result) == ( + "Closes #17\n\n" + "## Implementation\n\nImplemented the widget fix.\n\n" + "## Validation\n\n- pytest: passed\n- ruff: passed\n\n" + "_Created by Agent CI._" + ) + assert result_comment(result, sha="abc123") == ( + "## Agent result\n\nImplemented the widget fix.\n\n" + "## Validation\n\n- pytest: passed\n- ruff: passed\n\n" + "Commit: `abc123`" + ) + + +def test_renderers_report_missing_validation() -> None: + result = AgentResult(summary_markdown="Applied the change.", tests=[]) + + assert "## Validation\n\n- Not reported" in pull_request_body(2, result) + assert result_comment(result) == ( + "## Agent result\n\nApplied the change.\n\n## Validation\n\n- Not reported" + ) + + +@pytest.mark.parametrize( + ("markdown", "expected"), + [ + ("# Fix widget\n\nDetails", "Fix widget"), + ("\n## Trim heading \n", "Trim heading"), + ("\n\t\n", "apply requested changes"), + ("x" * 80, "x" * 72), + ], +) +def test_commit_title_uses_first_content_line(markdown: str, expected: str) -> None: + assert commit_title(markdown) == expected + + +def test_report_for_prompt_formats_json_and_preserves_unstructured_text() -> None: + stored = '{"summary":"Ready","findings":[]}' + + formatted = report_for_prompt(stored) + + assert json.loads(formatted) == {"summary": "Ready", "findings": []} + assert formatted == '{\n "summary": "Ready",\n "findings": []\n}' + assert report_for_prompt("plain text review") == "plain text review" + assert report_for_prompt(None) == "(none)" + + +def test_required_session_returns_value_or_fails_fast() -> None: + assert required_session("session-1") == "session-1" + with pytest.raises(RuntimeError, match="Expected a persisted OpenCode session ID"): + required_session(None) diff --git a/uv.lock b/uv.lock index 68cedb4..68371f5 100644 --- a/uv.lock +++ b/uv.lock @@ -19,7 +19,7 @@ dev = [ { name = "pyright" }, { name = "pytest" }, { name = "pytest-asyncio" }, - { name = "respx" }, + { name = "pytest-cov" }, { name = "ruff" }, ] @@ -37,7 +37,7 @@ dev = [ { name = "pyright", specifier = ">=1.1.403" }, { name = "pytest", specifier = ">=8.4,<9" }, { name = "pytest-asyncio", specifier = ">=1.1,<2" }, - { name = "respx", specifier = ">=0.22,<1" }, + { name = "pytest-cov", specifier = ">=6,<8" }, { name = "ruff", specifier = ">=0.12,<1" }, ] @@ -101,6 +101,60 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, ] +[[package]] +name = "coverage" +version = "7.15.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/76/d0/55fe630f4cf94e3fcba868240fad8c8cdd1f764e2a932f8926347e6ec4cd/coverage-7.15.2.tar.gz", hash = "sha256:3df60dc267f0a2ca23cb7a9ab1109c62b9335ffbf519fcfe167157c28c09b81d", size = 927741, upload-time = "2026-07-15T18:56:19.558Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fc/d5/f8c838e6b7282976f7c918884b792df7a0c42c5bba5d99c60ad2d221d56d/coverage-7.15.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:1121caa19159a38b5463eaae4b1e1fde81e525b15ecc5e000cd5b1a108f743a8", size = 221606, upload-time = "2026-07-15T18:54:45.448Z" }, + { url = "https://files.pythonhosted.org/packages/bf/37/97c926376364f66298cc44893b89cdf17b8bc406376497c4061ae4b8a8ff/coverage-7.15.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a300c6934e0989c327b9e8a1e110329da4641149f872bbe9f70168be66da76c1", size = 221982, upload-time = "2026-07-15T18:54:47.341Z" }, + { url = "https://files.pythonhosted.org/packages/b7/30/a36050a6e83c2135ee0776f452ca3948224befc6d7f26acecc082d0c106a/coverage-7.15.2-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:2617f8799d268fabdeef42a7e89ac3a23e1deee9025427db2df970f99a89a578", size = 252972, upload-time = "2026-07-15T18:54:49.2Z" }, + { url = "https://files.pythonhosted.org/packages/31/d3/06b5f1daf95f0f15ab05bd75f26ba5f3c8b33d0bb72f3aaa3cf41d1bad3a/coverage-7.15.2-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:7dc2950a2992cd676d35c20ae63522836deeb034f08874699d14068710af3dc1", size = 255569, upload-time = "2026-07-15T18:54:51.098Z" }, + { url = "https://files.pythonhosted.org/packages/81/1c/9afb3f8de2b8d36960391c48559a2e3ff96594b58099f115921549ea8d0d/coverage-7.15.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9e36686f7a442185db2400b3df171aac520869faf9deb59df687d28659eda2a6", size = 256806, upload-time = "2026-07-15T18:54:53.145Z" }, + { url = "https://files.pythonhosted.org/packages/64/d8/b989f96061a5e32d82fddd1b1b9ff48a7c8f8ae7606f0e80fd9de54b1e33/coverage-7.15.2-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7d29ca7bd67af6e12e74632d65f026eabc1364da5c254494cd914446a28a3ef7", size = 258936, upload-time = "2026-07-15T18:54:55.015Z" }, + { url = "https://files.pythonhosted.org/packages/b8/fa/f99771f5110457c7b511c1935ca49ddf288218eaa84322e028b9334146ae/coverage-7.15.2-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:db9c8438057e5b0f6a22a0af99c0c1d26b57fbbdbd1be5861ddb8f897fcc3a2d", size = 253178, upload-time = "2026-07-15T18:54:57.527Z" }, + { url = "https://files.pythonhosted.org/packages/f6/96/c098a6044d119c751ceede7be91035fa8310170ec24a6523aff72f0a5793/coverage-7.15.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:63022c4c8dec1d0342f05c3ede99842fe3d007689acc45e86f123a1746e4a026", size = 254934, upload-time = "2026-07-15T18:54:59.41Z" }, + { url = "https://files.pythonhosted.org/packages/b2/a2/1457b3a7a50c8d77500103b97a046db863e2f59a1cf6d2f814595f349885/coverage-7.15.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:6c0be82b4d4aa5b2704e08518e2252f3e3d110164bcca826816801052e48a7aa", size = 252898, upload-time = "2026-07-15T18:55:01.338Z" }, + { url = "https://files.pythonhosted.org/packages/6c/0e/76958874c471ecfcdde0d2b2747bb2c61bdbf34a40636f4ce9db9923e643/coverage-7.15.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:4510fb9cdf6bb02dfa6af0be4a534b8102d086e22e4a33f8836df663da3d660d", size = 257056, upload-time = "2026-07-15T18:55:03.243Z" }, + { url = "https://files.pythonhosted.org/packages/7c/7c/3d7c4e3bf58baa40327dc7edc2272b17cf02299366d52763db1b0ca1556a/coverage-7.15.2-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:42ec3d989421b174a2ab607c1539f24127ad362757b7f1c0c0d7a2993f7eb37b", size = 252718, upload-time = "2026-07-15T18:55:05.029Z" }, + { url = "https://files.pythonhosted.org/packages/c8/b8/1cecffed9ce14fb25be9ba42d37b6bb61485c9a3ddd43cd3dde36b6087d8/coverage-7.15.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e8f91bce78e32343af184c3b7fa28fcf5a9e2641f4b6623d392038f804939188", size = 254490, upload-time = "2026-07-15T18:55:06.889Z" }, + { url = "https://files.pythonhosted.org/packages/6c/2c/42984561bc7f4c045dca67516a0c50ee5ef8d84352dbeb5559dc86c4823e/coverage-7.15.2-cp313-cp313-win32.whl", hash = "sha256:434e68d531858205895eb0d74b73d20b84260de426387d53c422a5acda2cf050", size = 223647, upload-time = "2026-07-15T18:55:08.941Z" }, + { url = "https://files.pythonhosted.org/packages/41/9f/39c7c9245efc583beddf89a87683574e663ed93637f3afb6cd7b88405676/coverage-7.15.2-cp313-cp313-win_amd64.whl", hash = "sha256:26c3b04a6377fd7c09800921fa934e3a17c0020439cd59df73e73ae1d4b6a78c", size = 224190, upload-time = "2026-07-15T18:55:10.789Z" }, + { url = "https://files.pythonhosted.org/packages/c7/de/3a2883cf8a213659280ef4b403059e17a9acaeb7fc7fd4105e1226ff2e6d/coverage-7.15.2-cp313-cp313-win_arm64.whl", hash = "sha256:3ed010aa1b69cda8e827aabfca9866216c980e2dca82ab9a78c5f83689964c8b", size = 223583, upload-time = "2026-07-15T18:55:12.678Z" }, + { url = "https://files.pythonhosted.org/packages/81/5f/aed265fd7a3551a394f36dfe41868aee709b7f95db4052205b4ad1563ac3/coverage-7.15.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:40f633c5c5fc783732f6312280122e859538fa24461235597c13d803ea9a108a", size = 221650, upload-time = "2026-07-15T18:55:14.527Z" }, + { url = "https://files.pythonhosted.org/packages/6b/2c/222ba12a545189017120f8eddfc1a0bd4616b47d5d4a8d99421edb2fe4c6/coverage-7.15.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:075560438765b7a2ef43bf7aa7758661b53d889df47f062a31bda6c1ade553a2", size = 221988, upload-time = "2026-07-15T18:55:16.674Z" }, + { url = "https://files.pythonhosted.org/packages/aa/38/304b5877ab46e6c290b4292cfcf3fe28245f0e5597cad7f6acc91fc7e0a4/coverage-7.15.2-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:25fd15dd40a0a2c51a500d664ca29053c09c3259d998407bf982b6e114696138", size = 253029, upload-time = "2026-07-15T18:55:18.856Z" }, + { url = "https://files.pythonhosted.org/packages/6c/58/821b533b8db9e44cf1d8a97bd525149ced40dde1d0093da02cb78e715244/coverage-7.15.2-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b9a6367e4aff723e8ee8190836836124284e8fcd4265e307c844010cfa074f3f", size = 255536, upload-time = "2026-07-15T18:55:21.027Z" }, + { url = "https://files.pythonhosted.org/packages/f1/f2/7aa06604c389d32ea7f0a6a988359a7eafc3cd3f8e7bc2e88cd2fdf0b877/coverage-7.15.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9854ca62c152874b2060772503535be2e8f53f70b8aaa7686b094888d872f984", size = 256881, upload-time = "2026-07-15T18:55:23.125Z" }, + { url = "https://files.pythonhosted.org/packages/a2/4f/1ef342339c7916d0096bc5888cc0f653882cc7bc8f897d5cb89143287c9b/coverage-7.15.2-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:913b6c56e110da40e035bbd168353bf7aaa2544a5eaccea5d98a4629aac156c7", size = 259196, upload-time = "2026-07-15T18:55:25.099Z" }, + { url = "https://files.pythonhosted.org/packages/fe/f4/7ed055d7a9c5ec13b161773a115a5ccc6b0081d568c31fad830806306cc7/coverage-7.15.2-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:aaccad4129d735a8a4d526f26929894c9a4e8ef7034566f210b176749d6906e3", size = 253036, upload-time = "2026-07-15T18:55:27.018Z" }, + { url = "https://files.pythonhosted.org/packages/14/79/ea82cca18c242a3a38b6c017da39726aa62dcb64aa635abf79b92009975c/coverage-7.15.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a164b50081fc7357331c4024ef4d17b78ba325f8380d05f5a69599a7e05257ee", size = 254887, upload-time = "2026-07-15T18:55:29.084Z" }, + { url = "https://files.pythonhosted.org/packages/a4/ba/a136db3c0d9562b00e10b72540dbf3a33cd3bc5b95060c9308e247494623/coverage-7.15.2-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:bfd341ccf78128e72c094bc70cc25b3ef309c33c7c2c66ba3ed4309549e02de1", size = 252852, upload-time = "2026-07-15T18:55:31.184Z" }, + { url = "https://files.pythonhosted.org/packages/17/17/ea334246b16b7d059953fad6fdefa11e33c68efbd3fe37b1098120a1fac2/coverage-7.15.2-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:1473b3ba8e7ee0f076117b1a72c23f579a2b9e2bb742f48a8d86ea27ca93f91a", size = 257128, upload-time = "2026-07-15T18:55:33.163Z" }, + { url = "https://files.pythonhosted.org/packages/ed/c3/074fb66d46d607855f710876b117cbda562c5ab08363528e78820449f937/coverage-7.15.2-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:17c432b5f73ad52ef46fb06019f6fa7c66ce381961cf0f7dfd1d3a4bd3a98145", size = 252668, upload-time = "2026-07-15T18:55:35.063Z" }, + { url = "https://files.pythonhosted.org/packages/e1/c1/f620850ada9b36435921c9a3a8057013422b1d964eb4bf37fe138724d192/coverage-7.15.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:77f0ef5011df53a4bd1b35211ab122287f8d9b8d7aa1c4553e5c2deb24b1d446", size = 254325, upload-time = "2026-07-15T18:55:37.125Z" }, + { url = "https://files.pythonhosted.org/packages/cc/31/a729ca3689404493af82ef8e6ff70bd88bdda8da89aeef6ca9b387aeb2b4/coverage-7.15.2-cp314-cp314-win32.whl", hash = "sha256:f653e5d7248c1191ec988a85c72edeab46c3ff44f90639a4ed4874ec0be90243", size = 223844, upload-time = "2026-07-15T18:55:39.078Z" }, + { url = "https://files.pythonhosted.org/packages/c6/83/5d809dc808fb1698c671f3e372259bb9158e64b7ea526fc6ab7de64de9fe/coverage-7.15.2-cp314-cp314-win_amd64.whl", hash = "sha256:9911f31aad8906abe337c271343485cf20df5e70df5d2f57f9f136e7b55f26bc", size = 224331, upload-time = "2026-07-15T18:55:41.346Z" }, + { url = "https://files.pythonhosted.org/packages/16/4e/35e488548e952795829e129995c4174df33bf432b591d1aa42c8d9e4e7ad/coverage-7.15.2-cp314-cp314-win_arm64.whl", hash = "sha256:e38def96ad59853824c97953fdcd2c320a84ba3ce99b417db78af8bb6c3db635", size = 223760, upload-time = "2026-07-15T18:55:43.518Z" }, + { url = "https://files.pythonhosted.org/packages/ed/49/dd2c86cd6374038f6e415fb5bfb86db5218553209c081384a020369dee79/coverage-7.15.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:835ec4e20b45f0a7f63ed78f94065aca00de033403df8377bfe8b9c6abc0a7be", size = 222384, upload-time = "2026-07-15T18:55:45.569Z" }, + { url = "https://files.pythonhosted.org/packages/d3/74/173ff17a1c0808e5a438f549f6f145d5ac7528f2791310b63523e3200ac7/coverage-7.15.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7466cc7ab6dc0db871d264bf99e8779f0917ee63d40730af0552f71535a6e072", size = 222647, upload-time = "2026-07-15T18:55:47.544Z" }, + { url = "https://files.pythonhosted.org/packages/84/f8/b8cba872162356fb44ac79c10309d987206a4461e32072fc29228dad7331/coverage-7.15.2-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:e370c12133095ff18432de8c044962be85a5a96d90c6fcbce8e17e76236d2328", size = 264013, upload-time = "2026-07-15T18:55:49.768Z" }, + { url = "https://files.pythonhosted.org/packages/ee/67/a807a7586d0b8cae485308ddd55756f0806c92f8e0b411bacbf23c48edf3/coverage-7.15.2-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:fe41909c9515c3bfdb5f02c4d1f857dba322d9a9a1178069b91eea77889df63a", size = 266135, upload-time = "2026-07-15T18:55:51.941Z" }, + { url = "https://files.pythonhosted.org/packages/ce/67/cd78771dc985f7e4ebdcc82b1a96d9a932af9e806f01f2f91a89f4c72e80/coverage-7.15.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6aa28cfb6488e5453b5b762d65f73aa586380f6693a04d58078ce228a29b06c0", size = 268555, upload-time = "2026-07-15T18:55:54.065Z" }, + { url = "https://files.pythonhosted.org/packages/18/3e/10134cf81275188c58568f324fc74aedff32c63ca4d5bbc513a91944a6f0/coverage-7.15.2-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:bcc0aae933921d03096f53b0b03eeb702129fd406dee59f08d2efacc68681fa5", size = 269674, upload-time = "2026-07-15T18:55:56.066Z" }, + { url = "https://files.pythonhosted.org/packages/75/4a/771b77de446cba985dc414bbc5844bd21604da05dbc044286df8318a48a7/coverage-7.15.2-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7c63387e21ab21f512c69c9756a8c7dadd322c7275edb064064433c9a09c3743", size = 263101, upload-time = "2026-07-15T18:55:58.107Z" }, + { url = "https://files.pythonhosted.org/packages/5f/b5/70a7011da15f4071943361183aefa27847f3e3aec4fd335f1cb3d3a622b1/coverage-7.15.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:0e55510bc98ae943cece9e667a6c0fe94c6a92913720dea34243657a17993d0c", size = 266007, upload-time = "2026-07-15T18:56:00.468Z" }, + { url = "https://files.pythonhosted.org/packages/b4/0d/f9547e804ce7ad49646ffeffac26699510efbe6c0f751b66fdc960c4e825/coverage-7.15.2-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:2ff08701be2d1556fc78b326c80a3e8042da09352ecb3819105f8e386c8a3071", size = 263611, upload-time = "2026-07-15T18:56:02.615Z" }, + { url = "https://files.pythonhosted.org/packages/ac/59/f576a396659c0efd351f5c1544f67c3560e89c7761cabf7f65e412beeda5/coverage-7.15.2-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:38c9518b7103826c403a461544e3c2e77151e8676d06eaed85911a97e962584a", size = 267344, upload-time = "2026-07-15T18:56:04.622Z" }, + { url = "https://files.pythonhosted.org/packages/7c/5d/c2e4fce3579c0cb635024293f1a32bbe26df101b3e3a69f22243d1352b6c/coverage-7.15.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:dee88b1ed88587abd8c0269a1fc1f4cc77f7750d1dfde2869e2a123af420e67d", size = 262456, upload-time = "2026-07-15T18:56:06.641Z" }, + { url = "https://files.pythonhosted.org/packages/bb/dd/956287d69436b66094bc4b57ac2da71e43bfd2a5524e958900b9f582fcf8/coverage-7.15.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:2fbeeeecea279727f8ac16c8e1133ddfeee793e985c86ae343d6a5ce744eef8c", size = 264771, upload-time = "2026-07-15T18:56:08.795Z" }, + { url = "https://files.pythonhosted.org/packages/2c/5a/6f979530c2734c575de77cf58f5f28d51f7123a94b5030fd9156fe5f363c/coverage-7.15.2-cp314-cp314t-win32.whl", hash = "sha256:cb0fddaa6884be6aae36ced9544b5e90f7d5f03845a2853bf47a14953a4e8688", size = 224151, upload-time = "2026-07-15T18:56:10.856Z" }, + { url = "https://files.pythonhosted.org/packages/54/7e/27f6b2a74d484742f4017553e710b01e396b23d809df3e95ca0bb9a2824b/coverage-7.15.2-cp314-cp314t-win_amd64.whl", hash = "sha256:77f091ea3a9cc611cd29f433565476bc1936c084ac8eee00ea0e7e70c27e4199", size = 224981, upload-time = "2026-07-15T18:56:12.928Z" }, + { url = "https://files.pythonhosted.org/packages/b1/48/284863423aa474240f6842bd00d680da22f4e6ea2e466618ef7c9c9e69a9/coverage-7.15.2-cp314-cp314t-win_arm64.whl", hash = "sha256:6fc448c377d6eeb00a47c673494bd9bae29280ca53987e1869e67ebedfe20658", size = 224294, upload-time = "2026-07-15T18:56:15.156Z" }, + { url = "https://files.pythonhosted.org/packages/ec/82/32e3bd191d498e64f6f911ad55d14006a0861e54869d2d32452326399e65/coverage-7.15.2-py3-none-any.whl", hash = "sha256:eb6bcae8d1a9d305351ecb108232441d11c5cfe9de840a04388ba5d2db8d735c", size = 213375, upload-time = "2026-07-15T18:56:17.305Z" }, +] + [[package]] name = "fastapi" version = "0.139.2" @@ -363,6 +417,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/03/e2/08a497ef684b88559c9cc5f4ad53a37e7b99e727094a86d6ea32536d5d3c/pytest_asyncio-1.4.0-py3-none-any.whl", hash = "sha256:933ca923a23075a87fb7070c0ec272a6848489824d887c85c812670932835aa1", size = 16930, upload-time = "2026-05-26T09:56:02.576Z" }, ] +[[package]] +name = "pytest-cov" +version = "7.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "coverage" }, + { name = "pluggy" }, + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/51/a849f96e117386044471c8ec2bd6cfebacda285da9525c9106aeb28da671/pytest_cov-7.1.0.tar.gz", hash = "sha256:30674f2b5f6351aa09702a9c8c364f6a01c27aae0c1366ae8016160d1efc56b2", size = 55592, upload-time = "2026-03-21T20:11:16.284Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9d/7a/d968e294073affff457b041c2be9868a40c1c71f4a35fcc1e45e5493067b/pytest_cov-7.1.0-py3-none-any.whl", hash = "sha256:a0461110b7865f9a271aa1b51e516c9a95de9d696734a2f71e3e78f46e1d4678", size = 22876, upload-time = "2026-03-21T20:11:14.438Z" }, +] + [[package]] name = "python-dotenv" version = "1.2.2" @@ -408,18 +476,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, ] -[[package]] -name = "respx" -version = "0.23.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "httpx" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/43/98/4e55c9c486404ec12373708d015ebce157966965a5ebe7f28ff2c784d41b/respx-0.23.1.tar.gz", hash = "sha256:242dcc6ce6b5b9bf621f5870c82a63997e8e82bc7c947f9ffe272b8f3dd5a780", size = 29243, upload-time = "2026-04-08T14:37:16.008Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/1d/4a/221da6ca167db45693d8d26c7dc79ccfc978a440251bf6721c9aaf251ac0/respx-0.23.1-py2.py3-none-any.whl", hash = "sha256:b18004b029935384bccfa6d7d9d74b4ec9af73a081cc28600fffc0447f4b8c1a", size = 25557, upload-time = "2026-04-08T14:37:14.613Z" }, -] - [[package]] name = "ruff" version = "0.15.22"