reorg files

This commit is contained in:
2026-07-22 23:49:44 +02:00
parent 98ac4abca1
commit 0526406472
69 changed files with 760 additions and 539 deletions
+5
View File
@@ -0,0 +1,5 @@
# Default ignored files
/shelf/
/workspace.xml
# Editor-based HTTP Client requests
/httpRequests/
+11
View File
@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="UTF-8"?>
<module external.system.id="pyproject.toml" type="PYTHON_MODULE" version="4">
<component name="NewModuleRootManager">
<content url="file://$MODULE_DIR$">
<sourceFolder url="file://$MODULE_DIR$/src" isTestSource="false" />
<excludeFolder url="file://$MODULE_DIR$/.venv" />
</content>
<orderEntry type="jdk" jdkName="~/repos/agentci/.venv" jdkType="Python SDK" />
<orderEntry type="sourceFolder" forTests="false" />
</component>
</module>
+4
View File
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="Encoding" addBOMForNewFiles="with BOM under Windows, with no BOM otherwise" />
</project>
+6
View File
@@ -0,0 +1,6 @@
<component name="InspectionProjectProfileManager">
<settings>
<option name="USE_PROJECT_PROFILE" value="false" />
<version value="1.0" />
</settings>
</component>
+8
View File
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectModuleManager">
<modules>
<module fileurl="file://$PROJECT_DIR$/.idea/agentci.iml" filepath="$PROJECT_DIR$/.idea/agentci.iml" />
</modules>
</component>
</project>
+23
View File
@@ -0,0 +1,23 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="PyToolsState">
<option name="tools">
<map>
<entry key="pyright">
<value>
<ToolEntry>
<option name="enabled" value="true" />
</ToolEntry>
</value>
</entry>
<entry key="ruff">
<value>
<ToolEntry>
<option name="enabled" value="true" />
</ToolEntry>
</value>
</entry>
</map>
</option>
</component>
</project>
Generated
+6
View File
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="" vcs="Git" />
</component>
</project>
+5 -5
View File
@@ -14,12 +14,12 @@
- `src/agentci/engine/`: immutable domain models and events, the pure reducer, SQLite persistence, - `src/agentci/engine/`: immutable domain models and events, the pure reducer, SQLite persistence,
task claiming, and the `JobRun` event interface. task claiming, and the `JobRun` event interface.
- `src/agentci/worker.py`: durable control/job queues, authorization, execution, recovery, and - `src/agentci/application/`: runtime composition, durable control/job queues, task handlers,
comment reconciliation. recovery, and comment reconciliation.
- `src/agentci/workflows/`: planning, implementation, review, and pull-request orchestration. - `src/agentci/workflows/`: planning, implementation, review, and pull-request orchestration.
- `src/agentci/{gitea,git,opencode,codegraph,development}.py`: external-effect boundaries. - `src/agentci/integrations/`: Gitea, Git, OpenCode, CodeGraph, and development external effects.
- `src/agentci/{app,runtime,config,webhook,health}.py`: application lifecycle, configuration, and - `src/agentci/api/`: FastAPI construction, lifespan, dependencies, errors, and HTTP routes.
HTTP entry points. - `src/agentci/config/` and `src/agentci/observability/`: settings and structured logging.
- `src/agentci/prompts/` and `src/agentci/prompts/schemas/`: model prompts and structured-output - `src/agentci/prompts/` and `src/agentci/prompts/schemas/`: model prompts and structured-output
contracts; keep these concerns outside Python orchestration. contracts; keep these concerns outside Python orchestration.
- `src/agentci/migrations/`: ordered SQLite migrations. - `src/agentci/migrations/`: ordered SQLite migrations.
+3 -3
View File
@@ -13,9 +13,9 @@ webhook -> repository -> reducer -> durable task -> worker -> workflow -> integr
``` ```
`engine/reducer.py` is the pure job state machine. `engine/repository.py` applies its transitions `engine/reducer.py` is the pure job state machine. `engine/repository.py` applies its transitions
atomically to SQLite and persists the resulting tasks. `worker.py` executes those tasks and passes atomically to SQLite and persists the resulting tasks. `application/worker/` executes those tasks
an explicit `JobRun` into the functions under `workflows/`. The top-level Gitea, Git, OpenCode, and passes an explicit `JobRun` into the functions under `workflows/`. Modules under
development, and CodeGraph modules own external effects. `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; 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 progress and resource links are emitted as state-machine events through `JobRun`. The `jobs` table
+2 -3
View File
@@ -2,8 +2,8 @@ from __future__ import annotations
import uvicorn import uvicorn
from agentci.app import create_app from agentci.api.app import create_app
from agentci.config import Settings from agentci.config.settings import Settings
def main() -> None: def main() -> None:
@@ -19,4 +19,3 @@ def main() -> None:
if __name__ == "__main__": if __name__ == "__main__":
main() main()
+1
View File
@@ -0,0 +1 @@
"""HTTP application and routes."""
+25
View File
@@ -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()
+14
View File
@@ -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)]
+29
View File
@@ -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)
+45
View File
@@ -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
+1
View File
@@ -0,0 +1 @@
"""FastAPI route modules."""
@@ -1,8 +1,8 @@
from __future__ import annotations from __future__ import annotations
from fastapi import APIRouter, Request, Response, status from fastapi import APIRouter, Response, status
from agentci.runtime import Runtime from agentci.api.dependencies import RuntimeDependency
router = APIRouter() router = APIRouter()
@@ -13,8 +13,7 @@ async def live() -> dict[str, str]:
@router.get("/health/ready") @router.get("/health/ready")
async def ready(request: Request, response: Response) -> dict[str, str]: async def ready(runtime: RuntimeDependency, response: Response) -> dict[str, str]:
runtime: Runtime = request.app.state.runtime
if not await runtime.opencode.ready(): if not await runtime.opencode.ready():
response.status_code = status.HTTP_503_SERVICE_UNAVAILABLE response.status_code = status.HTTP_503_SERVICE_UNAVAILABLE
return {"status": "not-ready", "reason": "opencode provider is not connected"} return {"status": "not-ready", "reason": "opencode provider is not connected"}
@@ -1,28 +1,25 @@
from __future__ import annotations from __future__ import annotations
import hashlib
import hmac
import json import json
import logging import logging
from typing import Any
from fastapi import APIRouter, HTTPException, Request, Response, status from fastapi import APIRouter, HTTPException, Request, Response, status
from agentci.api.dependencies import RuntimeDependency
from agentci.application.runtime import Runtime
from agentci.engine.model import IncomingCommand from agentci.engine.model import IncomingCommand
from agentci.runtime import Runtime from agentci.integrations.gitea.webhooks import (
SUPPORTED_EVENTS,
incoming_command_from_payload,
valid_signature,
)
router = APIRouter() router = APIRouter()
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
SUPPORTED_EVENTS = {
"issue_comment",
"pull_request_comment",
"pull_request_review_comment",
}
@router.post("/webhooks/gitea") @router.post("/webhooks/gitea")
async def webhook(request: Request) -> Response: async def webhook(request: Request, runtime: RuntimeDependency) -> Response:
runtime: Runtime = request.app.state.runtime
body = await request.body() body = await request.body()
signature = request.headers.get("X-Gitea-Signature", "") signature = request.headers.get("X-Gitea-Signature", "")
if not valid_signature(runtime.settings.webhook_secret, body, signature): if not valid_signature(runtime.settings.webhook_secret, body, signature):
@@ -44,7 +41,7 @@ async def webhook(request: Request) -> Response:
payload = json.loads(body) payload = json.loads(body)
if not isinstance(payload, dict): if not isinstance(payload, dict):
raise ValueError("Webhook payload must be a JSON object") raise ValueError("Webhook payload must be a JSON object")
event = _event_from_payload( event = incoming_command_from_payload(
request.headers.get("X-Gitea-Delivery", ""), request.headers.get("X-Gitea-Delivery", ""),
payload, payload,
) )
@@ -61,11 +58,6 @@ async def webhook(request: Request) -> Response:
return await _handle_command(runtime, 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(runtime: Runtime, event: IncomingCommand) -> Response: async def _handle_command(runtime: Runtime, event: IncomingCommand) -> Response:
extra = {"operation": "command.handle", "target": event.target_key} extra = {"operation": "command.handle", "target": event.target_key}
if not event.body.strip().startswith("/agent"): if not event.body.strip().startswith("/agent"):
@@ -90,29 +82,3 @@ async def _handle_command(runtime: Runtime, event: IncomingCommand) -> Response:
}, },
) )
return Response(status_code=status.HTTP_202_ACCEPTED) return Response(status_code=status.HTTP_202_ACCEPTED)
def _event_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 "",
)
-71
View File
@@ -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.config import Settings
from agentci.health import router as health_router
from agentci.logging import configure_logging
from agentci.runtime import build_runtime
from agentci.webhook import router as webhook_router
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:
runtime = await build_runtime(selected_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"})
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()
+1
View File
@@ -0,0 +1 @@
"""Application composition and durable orchestration."""
@@ -2,16 +2,15 @@ from __future__ import annotations
import logging import logging
from dataclasses import dataclass from dataclasses import dataclass
from pathlib import Path
from agentci.config import Settings from agentci.application.worker.runner import Worker
from agentci.development import DevelopmentEnvironment from agentci.config.settings import Settings
from agentci.engine.repository import Repository from agentci.engine.repository import Repository
from agentci.git import Git from agentci.integrations.development import DevelopmentEnvironment
from agentci.gitea import Gitea from agentci.integrations.git import Git
from agentci.opencode import OpenCode from agentci.integrations.gitea.client import Gitea
from agentci.prompts import PromptLibrary from agentci.integrations.opencode.client import OpenCode
from agentci.worker import Worker from agentci.prompts.library import PromptLibrary
from agentci.workflows.services import WorkflowServices from agentci.workflows.services import WorkflowServices
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
@@ -36,10 +35,9 @@ class Runtime:
async def build_runtime(settings: Settings) -> Runtime: async def build_runtime(settings: Settings) -> Runtime:
log.info("runtime initialization started", extra={"operation": "runtime.build"}) log.info("runtime initialization started", extra={"operation": "runtime.build"})
package_dir = Path(__file__).parent
settings.data_dir.mkdir(parents=True, exist_ok=True) settings.data_dir.mkdir(parents=True, exist_ok=True)
settings.workspaces_dir.mkdir(parents=True, exist_ok=True) settings.workspaces_dir.mkdir(parents=True, exist_ok=True)
repository = Repository(settings.database_path, package_dir / "migrations") repository = Repository(settings.database_path)
await repository.initialize() await repository.initialize()
gitea = Gitea(settings.gitea_url, settings.gitea_token) gitea = Gitea(settings.gitea_url, settings.gitea_token)
git = Git( git = Git(
@@ -50,11 +48,12 @@ async def build_runtime(settings: Settings) -> Runtime:
commit_name=settings.bot_name, commit_name=settings.bot_name,
commit_email=settings.bot_email, commit_email=settings.bot_email,
) )
prompts = PromptLibrary()
opencode = OpenCode( opencode = OpenCode(
base_url=settings.opencode_url, base_url=settings.opencode_url,
username=settings.opencode_server_username, username=settings.opencode_server_username,
password=settings.opencode_server_password, password=settings.opencode_server_password,
schemas_dir=package_dir / "prompts" / "schemas", schemas_dir=prompts.schemas_dir,
health_directory=settings.workspaces_dir, health_directory=settings.workspaces_dir,
required_models=( required_models=(
(settings.plan_model, settings.plan_variant), (settings.plan_model, settings.plan_variant),
@@ -78,7 +77,7 @@ async def build_runtime(settings: Settings) -> Runtime:
gitea=gitea, gitea=gitea,
git=git, git=git,
opencode=opencode, opencode=opencode,
prompts=PromptLibrary(), prompts=prompts,
development=development, development=development,
) )
worker = Worker( worker = Worker(
@@ -0,0 +1 @@
"""Durable task runner and task handlers."""
@@ -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)
@@ -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"<!-- agentci:job id={latest.id} -->"
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
)
+6
View File
@@ -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]
@@ -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.",
),
)
@@ -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),
)
+134
View File
@@ -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)
@@ -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)
+1
View File
@@ -0,0 +1 @@
"""Service configuration."""
+1
View File
@@ -0,0 +1 @@
"""External service and process integrations."""
@@ -0,0 +1 @@
"""Gitea API and webhook integration."""
@@ -2,49 +2,16 @@ from __future__ import annotations
import asyncio import asyncio
import logging import logging
from dataclasses import dataclass
from time import monotonic from time import monotonic
from typing import Any from typing import Any
import httpx import httpx
from agentci.integrations.gitea.models import CommentInfo, IssueInfo, PullRequestInfo
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
@dataclass(frozen=True)
class IssueInfo:
number: int
title: str
body: str
state: str
@dataclass(frozen=True)
class CommentInfo:
id: int
author: str
body: str
created_at: str
@dataclass(frozen=True)
class PullRequestInfo:
number: int
title: str
body: str
state: str
merged: bool
base_branch: str
head_branch: str
head_sha: str
head_owner: str
head_repo: str
@property
def is_open(self) -> bool:
return self.state == "open" and not self.merged
class GiteaError(RuntimeError): class GiteaError(RuntimeError):
pass pass
+37
View File
@@ -0,0 +1,37 @@
from __future__ import annotations
from dataclasses import dataclass
@dataclass(frozen=True)
class IssueInfo:
number: int
title: str
body: str
state: str
@dataclass(frozen=True)
class CommentInfo:
id: int
author: str
body: str
created_at: str
@dataclass(frozen=True)
class PullRequestInfo:
number: int
title: str
body: str
state: str
merged: bool
base_branch: str
head_branch: str
head_sha: str
head_owner: str
head_repo: str
@property
def is_open(self) -> bool:
return self.state == "open" and not self.merged
@@ -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 "",
)
@@ -0,0 +1 @@
"""OpenCode API integration."""
@@ -1,7 +1,6 @@
from __future__ import annotations from __future__ import annotations
import asyncio import asyncio
import json
import logging import logging
from contextlib import suppress from contextlib import suppress
from pathlib import Path from pathlib import Path
@@ -11,7 +10,9 @@ from typing import Any, TypeVar
import httpx import httpx
from pydantic import BaseModel, ValidationError from pydantic import BaseModel, ValidationError
from agentci.codegraph import CodeGraph 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) T = TypeVar("T", bound=BaseModel)
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
@@ -265,67 +266,3 @@ def elapsed_ms(started: float) -> int:
def directory_headers(workspace: Path) -> dict[str, str]: def directory_headers(workspace: Path) -> dict[str, str]:
return {"X-Opencode-Directory": str(workspace.resolve())} 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"}
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
@@ -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
@@ -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
+1
View File
@@ -0,0 +1 @@
"""Service observability configuration."""
@@ -15,6 +15,7 @@ LOG_FIELDS = (
"method", "method",
"path", "path",
"status_code", "status_code",
"error_message",
"attempt", "attempt",
"item_count", "item_count",
"duration_ms", "duration_ms",
+1 -14
View File
@@ -1,14 +1 @@
from __future__ import annotations """Prompt templates and structured-output contracts."""
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)
+17
View File
@@ -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"
-221
View File
@@ -1,221 +0,0 @@
from __future__ import annotations
import asyncio
import logging
from contextlib import suppress
from pathlib import Path
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 Job, JobStatus, QueueName, Task, TaskKind
from agentci.engine.reducer import render_job_comment
from agentci.engine.repository import Repository
from agentci.engine.run import JobRun
from agentci.gitea import Gitea
from agentci.opencode import OpenCode
from agentci.workflows.dispatch import dispatch
from agentci.workflows.render import JobRejected
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:
if job.status is not JobStatus.RECEIVED:
return
permitted = await self.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 self.repository.apply(f"task:{task.id}:{outcome}", event)
async def _execute(self, task: Task, job: Job) -> None:
if job.status is JobStatus.RUNNING:
await self.repository.apply(
f"task:{task.id}:interrupted", ServiceRestarted(job_id=job.id)
)
return
if job.status is not JobStatus.QUEUED:
return
result = await self.repository.apply(
f"task:{task.id}:started", JobStarted(job_id=job.id)
)
running = result.job
run = JobRun(self.repository, job.id, task.id)
try:
body = await dispatch(running, run, self.services)
except JobRejected as exc:
await self.repository.apply(
f"task:{task.id}:rejected", RejectedEvent(job_id=job.id, reason=str(exc))
)
except Exception as exc:
latest = await self.repository.get_job(job.id)
stage = latest.stage if latest else running.stage
await self.repository.apply(
f"task:{task.id}:failed",
JobFailed(job_id=job.id, error=_safe_error(exc), stage=stage),
)
else:
await self.repository.apply(
f"task:{task.id}:completed",
JobCompleted(
job_id=job.id,
comment_body=body or "Agent job completed.",
),
)
async def _reconcile(self, task: Task, job: Job) -> None:
latest = await self.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 self.gitea.update_comment(
latest.repo_owner, latest.repo_name, comment_id, body
):
return
marker = f"<!-- agentci:job id={latest.id} -->"
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.repository.apply(
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.repository.recover_tasks()
for job in await self.repository.running_jobs():
await self.repository.apply(
f"recovery:{job.id}:service-restarted",
ServiceRestarted(job_id=job.id),
)
async def _abort_job_sessions(self, job: Job) -> None:
sessions: set[tuple[str, Path]] = set()
workflow = (
await self.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, self.workspaces_dir / f"fix-{job.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]
+2 -1
View File
@@ -4,7 +4,8 @@ import asyncio
from typing import Any from typing import Any
from agentci.engine.repository import Repository from agentci.engine.repository import Repository
from agentci.gitea import CommentInfo, Gitea, PullRequestInfo from agentci.integrations.gitea.client import Gitea
from agentci.integrations.gitea.models import CommentInfo, PullRequestInfo
async def build_issue_context( async def build_issue_context(
+6 -6
View File
@@ -2,13 +2,13 @@ from __future__ import annotations
from dataclasses import dataclass from dataclasses import dataclass
from agentci.config import Settings from agentci.config.settings import Settings
from agentci.development import DevelopmentEnvironment
from agentci.engine.repository import Repository from agentci.engine.repository import Repository
from agentci.git import Git from agentci.integrations.development import DevelopmentEnvironment
from agentci.gitea import Gitea from agentci.integrations.git import Git
from agentci.opencode import OpenCode from agentci.integrations.gitea.client import Gitea
from agentci.prompts import PromptLibrary from agentci.integrations.opencode.client import OpenCode
from agentci.prompts.library import PromptLibrary
@dataclass(frozen=True) @dataclass(frozen=True)
+8 -7
View File
@@ -4,7 +4,8 @@ from types import SimpleNamespace
import pytest import pytest
from httpx import ASGITransport, AsyncClient from httpx import ASGITransport, AsyncClient
import agentci.app as app_module import agentci.api.app as app_module
import agentci.api.lifespan as lifespan_module
class BlockingWorker: class BlockingWorker:
@@ -56,8 +57,8 @@ async def test_lifespan_starts_worker_cancels_it_and_closes_runtime(
built_with.append(settings) built_with.append(settings)
return runtime return runtime
monkeypatch.setattr(app_module, "build_runtime", build) monkeypatch.setattr(lifespan_module, "build_runtime", build)
monkeypatch.setattr(app_module, "configure_logging", lambda: configured.append(True)) monkeypatch.setattr(lifespan_module, "configure_logging", lambda: configured.append(True))
application = app_module.create_app(selected_settings) # type: ignore[arg-type] application = app_module.create_app(selected_settings) # type: ignore[arg-type]
async with application.router.lifespan_context(application): async with application.router.lifespan_context(application):
@@ -80,8 +81,8 @@ async def test_lifespan_closes_runtime_when_worker_task_fails(
async def build(_settings: object) -> FakeRuntime: async def build(_settings: object) -> FakeRuntime:
return runtime return runtime
monkeypatch.setattr(app_module, "build_runtime", build) monkeypatch.setattr(lifespan_module, "build_runtime", build)
monkeypatch.setattr(app_module, "configure_logging", lambda: None) monkeypatch.setattr(lifespan_module, "configure_logging", lambda: None)
application = app_module.create_app(SimpleNamespace()) # type: ignore[arg-type] application = app_module.create_app(SimpleNamespace()) # type: ignore[arg-type]
with pytest.raises(RuntimeError, match="worker failed"): with pytest.raises(RuntimeError, match="worker failed"):
@@ -100,8 +101,8 @@ async def test_lifespan_propagates_runtime_startup_failure_without_starting_work
async def fail_build(_settings: object) -> None: async def fail_build(_settings: object) -> None:
raise RuntimeError("database unavailable") raise RuntimeError("database unavailable")
monkeypatch.setattr(app_module, "build_runtime", fail_build) monkeypatch.setattr(lifespan_module, "build_runtime", fail_build)
monkeypatch.setattr(app_module, "configure_logging", lambda: configured.append(True)) monkeypatch.setattr(lifespan_module, "configure_logging", lambda: configured.append(True))
application = app_module.create_app(SimpleNamespace()) # type: ignore[arg-type] application = app_module.create_app(SimpleNamespace()) # type: ignore[arg-type]
with pytest.raises(RuntimeError, match="database unavailable"): with pytest.raises(RuntimeError, match="database unavailable"):
+6 -6
View File
@@ -2,7 +2,7 @@ from pathlib import Path
import pytest import pytest
from agentci.codegraph import CodeGraph, CodeGraphError from agentci.integrations.codegraph import CodeGraph, CodeGraphError
class FakeProcess: class FakeProcess:
@@ -27,7 +27,7 @@ async def test_initializes_incomplete_index_and_excludes_it_from_git(
return FakeProcess() return FakeProcess()
monkeypatch.setattr( monkeypatch.setattr(
"agentci.codegraph.asyncio.create_subprocess_exec", "agentci.integrations.codegraph.asyncio.create_subprocess_exec",
create_subprocess_exec, create_subprocess_exec,
) )
@@ -53,7 +53,7 @@ async def test_syncs_an_existing_index_without_duplicating_exclude(
return FakeProcess() return FakeProcess()
monkeypatch.setattr( monkeypatch.setattr(
"agentci.codegraph.asyncio.create_subprocess_exec", "agentci.integrations.codegraph.asyncio.create_subprocess_exec",
create_subprocess_exec, create_subprocess_exec,
) )
@@ -86,7 +86,7 @@ async def test_handles_exclude_file_boundaries(
return FakeProcess() return FakeProcess()
monkeypatch.setattr( monkeypatch.setattr(
"agentci.codegraph.asyncio.create_subprocess_exec", "agentci.integrations.codegraph.asyncio.create_subprocess_exec",
create_subprocess_exec, create_subprocess_exec,
) )
@@ -103,7 +103,7 @@ async def test_reports_missing_executable(tmp_path: Path, monkeypatch: pytest.Mo
raise FileNotFoundError(2, "No such file or directory", "codegraph") raise FileNotFoundError(2, "No such file or directory", "codegraph")
monkeypatch.setattr( monkeypatch.setattr(
"agentci.codegraph.asyncio.create_subprocess_exec", "agentci.integrations.codegraph.asyncio.create_subprocess_exec",
create_subprocess_exec, create_subprocess_exec,
) )
@@ -125,7 +125,7 @@ async def test_reports_nonzero_exit_with_bounded_non_utf8_stderr(
return FakeProcess(returncode=7, stderr=stderr) return FakeProcess(returncode=7, stderr=stderr)
monkeypatch.setattr( monkeypatch.setattr(
"agentci.codegraph.asyncio.create_subprocess_exec", "agentci.integrations.codegraph.asyncio.create_subprocess_exec",
create_subprocess_exec, create_subprocess_exec,
) )
+1 -1
View File
@@ -4,7 +4,7 @@ from pathlib import Path
import pytest import pytest
from pydantic import ValidationError from pydantic import ValidationError
from agentci.config import Settings from agentci.config.settings import Settings
@pytest.fixture(autouse=True) @pytest.fixture(autouse=True)
+2 -1
View File
@@ -1,7 +1,8 @@
from typing import cast from typing import cast
from agentci.engine.repository import Repository from agentci.engine.repository import Repository
from agentci.gitea import CommentInfo, Gitea, IssueInfo, PullRequestInfo 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 from agentci.workflows.context import build_issue_context, build_pull_request_context
+2 -2
View File
@@ -3,7 +3,7 @@ from pathlib import Path
import pytest import pytest
from agentci.development import ( from agentci.integrations.development import (
DevelopmentEnvironment, DevelopmentEnvironment,
DevelopmentEnvironmentError, DevelopmentEnvironmentError,
) )
@@ -169,7 +169,7 @@ async def test_wraps_subprocess_start_error(
raise OSError("exec unavailable") raise OSError("exec unavailable")
monkeypatch.setattr( monkeypatch.setattr(
"agentci.development.asyncio.create_subprocess_exec", "agentci.integrations.development.asyncio.create_subprocess_exec",
create_subprocess_exec, create_subprocess_exec,
) )
+1 -1
View File
@@ -5,7 +5,7 @@ from typing import Any
import pytest import pytest
from agentci.git import Git, GitError from agentci.integrations.git import Git, GitError
class FakeProcess: class FakeProcess:
+9 -8
View File
@@ -1,17 +1,18 @@
import json import json
from collections.abc import AsyncIterator, Callable, Coroutine from collections.abc import AsyncGenerator, Callable, Coroutine
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
import httpx import httpx
import pytest import pytest
from agentci.gitea import CommentInfo, Gitea, GiteaError, IssueInfo, PullRequestInfo 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]] Handler = Callable[[httpx.Request], Coroutine[None, None, httpx.Response]]
@asynccontextmanager @asynccontextmanager
async def gitea_client(handler: Handler, *, retries: int = 3) -> AsyncIterator[Gitea]: async def gitea_client(handler: Handler, *, retries: int = 3) -> AsyncGenerator[Gitea]:
client = Gitea( client = Gitea(
"https://gitea.example/", "https://gitea.example/",
"secret", "secret",
@@ -232,7 +233,7 @@ async def test_nonretryable_status_fails_once(status: int, monkeypatch: pytest.M
async def sleep(delay: int) -> None: async def sleep(delay: int) -> None:
sleeps.append(delay) sleeps.append(delay)
monkeypatch.setattr("agentci.gitea.asyncio.sleep", sleep) monkeypatch.setattr("agentci.integrations.gitea.client.asyncio.sleep", sleep)
async with gitea_client(handler) as client: async with gitea_client(handler) as client:
with pytest.raises( with pytest.raises(
GiteaError, GiteaError,
@@ -261,7 +262,7 @@ async def test_retryable_status_recovers_after_backoff(
async def sleep(delay: int) -> None: async def sleep(delay: int) -> None:
sleeps.append(delay) sleeps.append(delay)
monkeypatch.setattr("agentci.gitea.asyncio.sleep", sleep) monkeypatch.setattr("agentci.integrations.gitea.client.asyncio.sleep", sleep)
async with gitea_client(handler) as client: async with gitea_client(handler) as client:
assert await client.default_branch("org", "repo") == "main" assert await client.default_branch("org", "repo") == "main"
@@ -283,7 +284,7 @@ async def test_retryable_status_exhaustion_uses_exponential_backoff(
async def sleep(delay: int) -> None: async def sleep(delay: int) -> None:
sleeps.append(delay) sleeps.append(delay)
monkeypatch.setattr("agentci.gitea.asyncio.sleep", sleep) monkeypatch.setattr("agentci.integrations.gitea.client.asyncio.sleep", sleep)
async with gitea_client(handler) as client: async with gitea_client(handler) as client:
with pytest.raises( with pytest.raises(
GiteaError, GiteaError,
@@ -309,7 +310,7 @@ async def test_transport_failure_retries_and_recovers(monkeypatch: pytest.Monkey
async def sleep(delay: int) -> None: async def sleep(delay: int) -> None:
sleeps.append(delay) sleeps.append(delay)
monkeypatch.setattr("agentci.gitea.asyncio.sleep", sleep) monkeypatch.setattr("agentci.integrations.gitea.client.asyncio.sleep", sleep)
async with gitea_client(handler) as client: async with gitea_client(handler) as client:
assert await client.default_branch("org", "repo") == "main" assert await client.default_branch("org", "repo") == "main"
@@ -331,7 +332,7 @@ async def test_transport_failure_exhaustion_preserves_cause(
async def sleep(delay: int) -> None: async def sleep(delay: int) -> None:
sleeps.append(delay) sleeps.append(delay)
monkeypatch.setattr("agentci.gitea.asyncio.sleep", sleep) monkeypatch.setattr("agentci.integrations.gitea.client.asyncio.sleep", sleep)
async with gitea_client(handler, retries=2) as client: async with gitea_client(handler, retries=2) as client:
with pytest.raises( with pytest.raises(
GiteaError, GiteaError,
+1 -1
View File
@@ -4,7 +4,7 @@ import pytest
from fastapi import FastAPI from fastapi import FastAPI
from httpx import ASGITransport, AsyncClient, Response from httpx import ASGITransport, AsyncClient, Response
from agentci.health import router from agentci.api.routes.health import router
class Provider: class Provider:
+1 -1
View File
@@ -6,7 +6,7 @@ from datetime import UTC, datetime
import pytest import pytest
from agentci.logging import JsonFormatter, configure_logging from agentci.observability.logging import JsonFormatter, configure_logging
@pytest.fixture(autouse=True) @pytest.fixture(autouse=True)
+1 -1
View File
@@ -5,7 +5,7 @@ from pathlib import Path
import httpx import httpx
import pytest import pytest
from agentci.opencode import OpenCode, OpenCodeError from agentci.integrations.opencode.client import OpenCode, OpenCodeError
from agentci.workflows.model import AgentResult from agentci.workflows.model import AgentResult
API_DOCUMENT = { API_DOCUMENT = {
+1 -1
View File
@@ -3,7 +3,7 @@ from pathlib import Path
import httpx import httpx
import pytest import pytest
from agentci.opencode import OpenCode, OpenCodeError from agentci.integrations.opencode.client import OpenCode, OpenCodeError
def client(tmp_path: Path, status: int) -> OpenCode: def client(tmp_path: Path, status: int) -> OpenCode:
+1 -1
View File
@@ -1,6 +1,6 @@
import pytest import pytest
from agentci.opencode import api_contract_ready, models_ready from agentci.integrations.opencode.readiness import api_contract_ready, models_ready
def test_api_contract_requires_session_message_and_abort_routes() -> None: def test_api_contract_requires_session_message_and_abort_routes() -> None:
+2 -2
View File
@@ -5,8 +5,8 @@ import pytest
from pydantic import BaseModel from pydantic import BaseModel
import agentci.prompts import agentci.prompts
from agentci.opencode import load_schema from agentci.integrations.opencode.schemas import load_schema
from agentci.prompts import PromptLibrary from agentci.prompts.library import PromptLibrary
from agentci.workflows.model import ( from agentci.workflows.model import (
AgentResult, AgentResult,
DiscussionReply, DiscussionReply,
+6 -10
View File
@@ -4,9 +4,9 @@ from unittest.mock import AsyncMock, Mock
import pytest import pytest
import agentci.runtime as runtime_module import agentci.application.runtime as runtime_module
from agentci.config import Settings from agentci.application.runtime import Runtime
from agentci.runtime import Runtime from agentci.config.settings import Settings
class ClosingClient: class ClosingClient:
@@ -90,7 +90,7 @@ async def test_build_runtime_wires_components_without_starting_real_clients(
git = SimpleNamespace() git = SimpleNamespace()
opencode = SimpleNamespace() opencode = SimpleNamespace()
development = SimpleNamespace() development = SimpleNamespace()
prompts = SimpleNamespace() prompts = SimpleNamespace(schemas_dir=tmp_path / "schemas")
services = SimpleNamespace() services = SimpleNamespace()
worker = SimpleNamespace() worker = SimpleNamespace()
repository_constructor = Mock(return_value=repository) repository_constructor = Mock(return_value=repository)
@@ -112,11 +112,7 @@ async def test_build_runtime_wires_components_without_starting_real_clients(
built = await runtime_module.build_runtime(settings) built = await runtime_module.build_runtime(settings)
assert runtime_module.__file__ is not None repository_constructor.assert_called_once_with(settings.database_path)
package_dir = Path(runtime_module.__file__).parent
repository_constructor.assert_called_once_with(
settings.database_path, package_dir / "migrations"
)
initialize.assert_awaited_once_with() initialize.assert_awaited_once_with()
gitea_constructor.assert_called_once_with("https://gitea.example", "token") gitea_constructor.assert_called_once_with("https://gitea.example", "token")
git_constructor.assert_called_once_with( git_constructor.assert_called_once_with(
@@ -131,7 +127,7 @@ async def test_build_runtime_wires_components_without_starting_real_clients(
base_url="https://opencode.example", base_url="https://opencode.example",
username=settings.opencode_server_username, username=settings.opencode_server_username,
password="password", password="password",
schemas_dir=package_dir / "prompts" / "schemas", schemas_dir=prompts.schemas_dir,
health_directory=settings.workspaces_dir, health_directory=settings.workspaces_dir,
required_models=( required_models=(
(settings.plan_model, settings.plan_variant), (settings.plan_model, settings.plan_variant),
+6 -2
View File
@@ -7,8 +7,12 @@ import pytest
from fastapi import FastAPI from fastapi import FastAPI
from httpx import ASGITransport, AsyncClient, Response from httpx import ASGITransport, AsyncClient, Response
from agentci.api.routes.webhook import router
from agentci.engine.model import IncomingCommand from agentci.engine.model import IncomingCommand
from agentci.webhook import _event_from_payload, router, valid_signature from agentci.integrations.gitea.webhooks import (
incoming_command_from_payload,
valid_signature,
)
class FakeRepository: class FakeRepository:
@@ -167,7 +171,7 @@ def test_payload_parser_supports_owner_username_and_pull_request() -> None:
value = payload("/agent plan", is_pull=True) value = payload("/agent plan", is_pull=True)
value["repository"]["owner"] = {"username": "fallback-owner"} value["repository"]["owner"] = {"username": "fallback-owner"}
event = _event_from_payload("delivery", value) event = incoming_command_from_payload("delivery", value)
assert event is not None assert event is not None
assert event.repo_owner == "fallback-owner" assert event.repo_owner == "fallback-owner"
+8 -7
View File
@@ -5,6 +5,8 @@ from types import SimpleNamespace
import pytest import pytest
from agentci.application.worker.errors import safe_error
from agentci.application.worker.runner import Worker
from agentci.engine.events import ( from agentci.engine.events import (
CommentLinked, CommentLinked,
JobCompleted, JobCompleted,
@@ -30,8 +32,7 @@ from agentci.engine.model import (
) )
from agentci.engine.reducer import render_job_comment from agentci.engine.reducer import render_job_comment
from agentci.engine.repository import Repository from agentci.engine.repository import Repository
from agentci.gitea import CommentInfo from agentci.integrations.gitea.models import CommentInfo
from agentci.worker import Worker, _safe_error
from agentci.workflows.render import JobRejected from agentci.workflows.render import JobRejected
MIGRATIONS = Path(__file__).parents[1] / "src" / "agentci" / "migrations" MIGRATIONS = Path(__file__).parents[1] / "src" / "agentci" / "migrations"
@@ -408,7 +409,7 @@ async def test_execute_completes_with_workflow_comment(
async def dispatch(_job: Job, _run: object, _services: object) -> str: async def dispatch(_job: Job, _run: object, _services: object) -> str:
return "final workflow body" return "final workflow body"
monkeypatch.setattr("agentci.worker.dispatch", dispatch) monkeypatch.setattr("agentci.application.worker.execution.dispatch", dispatch)
execute = task() execute = task()
await value._execute(execute, current) await value._execute(execute, current)
@@ -432,7 +433,7 @@ async def test_execute_records_expected_rejection(
async def dispatch(_job: Job, _run: object, _services: object) -> str: async def dispatch(_job: Job, _run: object, _services: object) -> str:
raise JobRejected("pull request is closed") raise JobRejected("pull request is closed")
monkeypatch.setattr("agentci.worker.dispatch", dispatch) monkeypatch.setattr("agentci.application.worker.execution.dispatch", dispatch)
await make_worker(tmp_path, repository)._execute(task(), current) await make_worker(tmp_path, repository)._execute(task(), current)
@@ -452,7 +453,7 @@ async def test_execute_records_failure_at_latest_persisted_stage(
repository.job = replace(repository.job, stage="cloning") repository.job = replace(repository.job, stage="cloning")
raise RuntimeError("provider\nfailed") raise RuntimeError("provider\nfailed")
monkeypatch.setattr("agentci.worker.dispatch", dispatch) monkeypatch.setattr("agentci.application.worker.execution.dispatch", dispatch)
await make_worker(tmp_path, repository)._execute(task(), current) await make_worker(tmp_path, repository)._execute(task(), current)
@@ -471,7 +472,7 @@ async def test_execute_marks_running_job_interrupted_after_restart(
async def unexpected_dispatch(_job: Job, _run: object, _services: object) -> str: async def unexpected_dispatch(_job: Job, _run: object, _services: object) -> str:
pytest.fail("running jobs must not be dispatched again") pytest.fail("running jobs must not be dispatched again")
monkeypatch.setattr("agentci.worker.dispatch", unexpected_dispatch) monkeypatch.setattr("agentci.application.worker.execution.dispatch", unexpected_dispatch)
execute = task() execute = task()
await make_worker(tmp_path, repository)._execute(execute, current) await make_worker(tmp_path, repository)._execute(execute, current)
@@ -642,7 +643,7 @@ async def test_abort_without_persisted_sessions_is_noop(tmp_path: Path) -> None:
def test_safe_error_is_single_line_and_bounded() -> None: 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 "\n" not in value
assert len(value) == 1000 assert len(value) == 1000
+1 -1
View File
@@ -7,7 +7,7 @@ import pytest
from agentci.engine.model import Job, JobKind, Workflow, WorkflowKind, WorkflowStatus from agentci.engine.model import Job, JobKind, Workflow, WorkflowKind, WorkflowStatus
from agentci.engine.run import JobRun from agentci.engine.run import JobRun
from agentci.gitea import CommentInfo, IssueInfo, PullRequestInfo from agentci.integrations.gitea.models import CommentInfo, IssueInfo, PullRequestInfo
from agentci.workflows.implementation import implement from agentci.workflows.implementation import implement
from agentci.workflows.model import AgentResult, ReviewReport from agentci.workflows.model import AgentResult, ReviewReport
from agentci.workflows.render import JobRejected from agentci.workflows.render import JobRejected
+1 -1
View File
@@ -8,7 +8,7 @@ from pydantic import BaseModel
from agentci.engine.model import Job, JobKind, Workflow, WorkflowKind, WorkflowStatus from agentci.engine.model import Job, JobKind, Workflow, WorkflowKind, WorkflowStatus
from agentci.engine.run import JobRun from agentci.engine.run import JobRun
from agentci.gitea import CommentInfo, IssueInfo, PullRequestInfo from agentci.integrations.gitea.models import CommentInfo, IssueInfo, PullRequestInfo
from agentci.workflows.model import DiscussionReply, PlanArtifact, ReviewReport from agentci.workflows.model import DiscussionReply, PlanArtifact, ReviewReport
from agentci.workflows.plan import create_plan, discuss_plan, iterate_plan from agentci.workflows.plan import create_plan, discuss_plan, iterate_plan
from agentci.workflows.render import JobRejected from agentci.workflows.render import JobRejected
+1 -1
View File
@@ -8,7 +8,7 @@ from pydantic import BaseModel
from agentci.engine.model import Job, JobKind, Workflow, WorkflowKind, WorkflowStatus from agentci.engine.model import Job, JobKind, Workflow, WorkflowKind, WorkflowStatus
from agentci.engine.run import JobRun from agentci.engine.run import JobRun
from agentci.gitea import CommentInfo, IssueInfo, PullRequestInfo from agentci.integrations.gitea.models import CommentInfo, IssueInfo, PullRequestInfo
from agentci.workflows.model import AgentResult, ReviewReport from agentci.workflows.model import AgentResult, ReviewReport
from agentci.workflows.pull_request import fix_pull_request, iterate_implementation from agentci.workflows.pull_request import fix_pull_request, iterate_implementation
from agentci.workflows.render import JobRejected from agentci.workflows.render import JobRejected