Rewrite Agent CI around a durable state machine #4
Generated
+5
@@ -0,0 +1,5 @@
|
|||||||
|
# Default ignored files
|
||||||
|
/shelf/
|
||||||
|
/workspace.xml
|
||||||
|
# Editor-based HTTP Client requests
|
||||||
|
/httpRequests/
|
||||||
Generated
+11
@@ -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>
|
||||||
Generated
+4
@@ -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
@@ -0,0 +1,6 @@
|
|||||||
|
<component name="InspectionProjectProfileManager">
|
||||||
|
<settings>
|
||||||
|
<option name="USE_PROJECT_PROFILE" value="false" />
|
||||||
|
<version value="1.0" />
|
||||||
|
</settings>
|
||||||
|
</component>
|
||||||
Generated
+8
@@ -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>
|
||||||
Generated
+23
@@ -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
@@ -0,0 +1,6 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="VcsDirectoryMappings">
|
||||||
|
<mapping directory="" vcs="Git" />
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
@@ -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.
|
||||||
|
|||||||
@@ -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,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()
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
"""HTTP application and routes."""
|
||||||
@@ -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()
|
||||||
@@ -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)]
|
||||||
@@ -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)
|
||||||
@@ -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
|
||||||
@@ -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 "",
|
|
||||||
)
|
|
||||||
@@ -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()
|
|
||||||
@@ -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
|
||||||
|
)
|
||||||
@@ -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),
|
||||||
|
)
|
||||||
@@ -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)
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
"""Service configuration."""
|
||||||
@@ -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
|
||||||
|
|
||||||
@@ -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
|
||||||
@@ -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 +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)
|
|
||||||
|
|
||||||
|
|||||||
@@ -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"
|
||||||
@@ -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]
|
|
||||||
@@ -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(
|
||||||
|
|||||||
@@ -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
@@ -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"):
|
||||||
|
|||||||
@@ -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,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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,
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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 = {
|
||||||
|
|||||||
@@ -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,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:
|
||||||
|
|||||||
@@ -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
@@ -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),
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user