Deployment

This commit is contained in:
Zakhar
2026-09-25 19:44:48 +03:00
parent 596adfdc09
commit 066bfb3c0b
24 changed files with 472 additions and 151 deletions
+16
View File
@@ -0,0 +1,16 @@
.aiassistant
.env
.git/
.gitignore
.idea/
.vscode/
.venv/
venv/
projects-data
CHANGELOG.md
__pycache__
deployment
projects
test_main.http
role_id
wrapped_secret_id
+2
View File
@@ -1,2 +1,4 @@
.env
projects
role_id
wrapped_secret_id
+2 -1
View File
@@ -6,8 +6,9 @@
<sourceFolder url="file://$MODULE_DIR$/agents/issue_triage/tests" isTestSource="true" />
<excludeFolder url="file://$MODULE_DIR$/.venv" />
<excludeFolder url="file://$MODULE_DIR$/projects/agents" />
<excludeFolder url="file://$MODULE_DIR$/venv" />
</content>
<orderEntry type="jdk" jdkName="Python 3.14 (Agents)" jdkType="Python SDK" />
<orderEntry type="jdk" jdkName="Python 3.10 (agents) (2)" jdkType="Python SDK" />
<orderEntry type="sourceFolder" forTests="false" />
</component>
</module>
+1 -1
View File
@@ -3,5 +3,5 @@
<component name="Black">
<option name="sdkName" value="Python 3.14 (Agents)" />
</component>
<component name="ProjectRootManager" version="2" project-jdk-name="Python 3.14 (Agents)" project-jdk-type="Python SDK" />
<component name="ProjectRootManager" version="2" project-jdk-name="Python 3.10 (agents) (2)" project-jdk-type="Python SDK" />
</project>
+9
View File
@@ -0,0 +1,9 @@
### Сборка
```shell
docker build -t triage_agent -f deployment/Dockerfile .
```
### Запуск
```shell
./deployment/generate-wrapped-secret-id.sh && docker compose -f deployment/docker-compose.yml up --build
```
+2 -2
View File
@@ -751,7 +751,7 @@ def analyze_repository(inp: AnalysisInput, run_ctx: RunContext) -> AnalysisResul
return AnalysisResult(success=True, graph=graph, res_all=res_all, leaf_clusters=leaf_clusters)
def to_networkx(dep_graph: "DependencyGraph", include_external: bool = False) -> nx.DiGraph[DependencyNode]:
def to_networkx(dep_graph: "DependencyGraph", include_external: bool = False) -> "nx.DiGraph[DependencyNode]":
"""
Convert a DependencyGraph into a networkx DiGraph.
@@ -792,7 +792,7 @@ COLS = [
def analyze(
G: nx.DiGraph[DependencyNode],
G: "nx.DiGraph[DependencyNode]",
graph: DependencyGraph, # NOTE: currently unused in the body
label: str = "",
) -> AnalyzeResult:
+3 -1
View File
@@ -13,8 +13,10 @@ import sys
from pathlib import Path
from typing import List, Dict, Any
import requests
from playwright.sync_api import sync_playwright
from agents.architect_ts.analyzer import CodeAnalyzer
from agents.coder.CoderAgent import CoderAgent
from agents.solution_architect.PlannerAgent import PlannerAgent
from agents.tester_web.TesterAgent import TesterAgent
# ---------- Конфигурация ----------
CONFIG_FILE = "config.json"
+6
View File
@@ -1,3 +1,9 @@
import os
from typing import List
from agents.codebase_analyst.start import Project
class CoderAgent:
"""Агент-исполнитель: выполняет подзадачу, генерирует код и применяет изменения."""
+1 -1
View File
@@ -108,7 +108,7 @@ class AgentState(BaseModel):
}
@classmethod
def from_dict(cls, data: Dict[str, Any], state_file: Path) -> AgentState:
def from_dict(cls, data: Dict[str, Any], state_file: Path) -> "AgentState":
known = {
"schema_version", "issue_id", "agent_id", "state", "updated_at",
"last_processed_comment_id", "pending_question_comment_id",
+2 -2
View File
@@ -14,7 +14,7 @@ from agents.issue_triage.triage import render_validation_failure, render_plan_co
from agents.registry import agent, AgentRegistry, Capability
from common.llm_client import LLMClient
from common.youtrack_mcp_client import IssueNotFound, YouTrackMCPClient
from contracts.IssueContext import IssueContext, IssueComment
from contracts.IssueContext import IssueContext
logger = logging.getLogger("triage_agent")
logger.setLevel(logging.DEBUG)
@@ -23,7 +23,7 @@ logger.setLevel(logging.DEBUG)
class TriageContext:
issue: IssueContext
registry: AgentRegistry
triage_agent: IssueTriageAgent
triage_agent: "IssueTriageAgent"
async def run_triage(ctx: TriageContext) -> TriageMarker:
@@ -1,3 +1,9 @@
import json
from typing import List, Dict
from agents.codebase_analyst.start import Project
class PlannerAgent:
"""Агент-планировщик: анализирует проект и создаёт план подзадач."""
+5
View File
@@ -1,3 +1,8 @@
import time
from typing import List, Dict, Any
from playwright.sync_api import sync_playwright
from agents.codebase_analyst.start import Project
+82
View File
@@ -0,0 +1,82 @@
import asyncio
import hashlib
import logging
import os
import time
from pathlib import Path
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
ENV_PATH = Path(os.environ.get("VAULT_ENV_PATH", "/vault-secrets/.env"))
MAX_WAIT_SECONDS = 30
WATCH_INTERVAL_SECONDS = 5
def _file_hash() -> str | None:
if not ENV_PATH.exists():
return None
try:
return hashlib.sha256(ENV_PATH.read_bytes()).hexdigest()
except OSError:
return None
def _parse_env_file(path: Path) -> dict[str, str]:
result: dict[str, str] = {}
with path.open() as f:
for line in f:
line = line.strip()
if not line or line.startswith("#") or "=" not in line:
continue
key, _, value = line.partition("=")
result[key.strip()] = value.strip().strip('"').strip("'")
return result
def wait_for_env_file(timeout: int = MAX_WAIT_SECONDS) -> None:
"""Блокирующее ожидание появления .env (для entrypoint до старта uvicorn)."""
start = time.time()
while not ENV_PATH.exists():
if time.time() - start > timeout:
raise RuntimeError(
f"Vault Agent did not render {ENV_PATH} within {timeout}s"
)
logger.info("Waiting for %s ...", ENV_PATH)
time.sleep(0.5)
time.sleep(1.5)
def load_env(force: bool = False) -> bool:
if not ENV_PATH.exists():
return False
try:
values = _parse_env_file(ENV_PATH)
except OSError as e:
logger.warning("Failed to read %s: %s", ENV_PATH, e)
return False
for key, value in values.items():
if force or key not in os.environ:
os.environ[key] = value
global _last_hash
_last_hash = _file_hash()
return True
def reload_if_changed() -> bool:
current = _file_hash()
if current is None or current == _last_hash:
return False
logger.info("Detected change in %s, reloading secrets", ENV_PATH)
return load_env(force=True)
async def watch_env_file(interval: int = WATCH_INTERVAL_SECONDS) -> None:
while True:
try:
reload_if_changed()
except Exception:
logger.exception("env watcher failed")
await asyncio.sleep(interval)
+107
View File
@@ -0,0 +1,107 @@
import asyncio
import logging
from typing import Optional
import httpx
from agents.issue_triage.IssueTriageAgent import IssueTriageAgent
from agents.issue_triage.context_builder import YouTrackContextBuilder
from agents.registry import AgentRegistry
from common.llm_client import LLMClient
from common.youtrack_mcp_client import YouTrackMCPClient
logger = logging.getLogger(__name__)
class AgentSingleton:
"""
Синглтон для IssueTriageAgent и его зависимостей.
Потокобезопасен через asyncio.Lock — инициализация не гоняется.
"""
_instance: Optional["AgentSingleton"] = None
_lock = asyncio.Lock()
def __init__(self) -> None:
self._agent: Optional[IssueTriageAgent] = None
self._youtrack_mcp: Optional[YouTrackMCPClient] = None
self._http_for_attachments: Optional[httpx.AsyncClient] = None
self._initialized = False
@classmethod
async def get(cls) -> "AgentSingleton":
if cls._instance is None:
async with cls._lock:
if cls._instance is None:
cls._instance = cls()
return cls._instance
async def ensure_initialized(self) -> None:
"""Инициализация при первом обращении или после reset()."""
if self._initialized:
return
async with self._lock:
if self._initialized:
return
await self._initialize()
self._initialized = True
async def _initialize(self) -> None:
from main import env, env_optional # локальный импорт, чтобы не плодить циклы
logger.info("Initializing AgentSingleton (or re-initializing)")
self._youtrack_mcp = await YouTrackMCPClient(
env("YOUTRACK_MCP_SERVER"),
env("YOUTRACK_MCP_TOKEN"),
).connect()
self._http_for_attachments = httpx.AsyncClient(
headers={"Authorization": f"Bearer {env('YOUTRACK_MCP_TOKEN')}"},
proxy=env_optional("HTTPS_PROXY"),
timeout=httpx.Timeout(30.0, connect=10.0, read=60.0),
follow_redirects=True,
)
self._agent = IssueTriageAgent(
YouTrackContextBuilder(self._youtrack_mcp, self._http_for_attachments),
LLMClient(
base_url=env("LLM_ADDRESS"),
api_key=env("LLM_API_KEY"),
model=env("LLM_MODEL"),
),
AgentRegistry(),
self._youtrack_mcp,
)
@property
def agent(self) -> IssueTriageAgent:
if self._agent is None:
raise RuntimeError("AgentSingleton not initialized. Call ensure_initialized() first.")
return self._agent
@property
def youtrack_mcp(self) -> YouTrackMCPClient:
if self._youtrack_mcp is None:
raise RuntimeError("AgentSingleton not initialized.")
return self._youtrack_mcp
async def reset(self) -> None:
"""Закрывает старые ресурсы и помечает синглтон как неинициализированный."""
async with self._lock:
logger.info("Resetting AgentSingleton (secrets rotated?)")
if self._http_for_attachments is not None:
await self._http_for_attachments.aclose()
if self._youtrack_mcp is not None:
await self._youtrack_mcp.close()
self._agent = None
self._youtrack_mcp = None
self._http_for_attachments = None
self._initialized = False
@classmethod
async def destroy(cls) -> None:
"""Полное уничтожение синглтона — для shutdown."""
if cls._instance is not None:
await cls._instance.reset()
cls._instance = None
+9 -8
View File
@@ -7,6 +7,15 @@ from pydantic import BaseModel
from contracts.ProjectContext import ProjectContext
class IssueComment(BaseModel):
id: str
author: str
body: str
created_at: int
is_agent: bool = False
marker: dict[str, str] = field(default_factory=dict)
@dataclass
class IssueContext:
issue_id: str
@@ -49,11 +58,3 @@ class IssueContext:
parts.append(f"- @{c.author} ({c.created_at}): {c.body}")
return "\n".join(parts)
class IssueComment(BaseModel):
id: str
author: str
body: str
created_at: int
is_agent: bool = False
marker: dict[str, str] = field(default_factory=dict)
+2 -2
View File
@@ -1,7 +1,7 @@
import uuid
from dataclasses import dataclass, field
from enum import Enum
from typing import Optional
from typing import Optional, Literal
from contracts.base import BaseContract
@@ -58,7 +58,7 @@ class Task(BaseContract):
lines = [
f"# Task: {self.description}",
f"# Before start you must ensure that another tasks have been done: {', '.join(self.depends_on)}" if self.depends_on else "",
f"# Acceptance criteria: \n{"\n - ".join(self.acceptance_criteria)}",
f"# Acceptance criteria: \n{"\n - ".join(self.acceptance_criteria)}"
]
return "\n".join(lines)
+18
View File
@@ -0,0 +1,18 @@
FROM python:3.13-slim-trixie AS builder
WORKDIR /build
RUN apt-get update && apt-get install -y --no-install-recommends \
gcc \
&& rm -rf /var/lib/apt/lists/*
COPY requirements.txt .
RUN pip install --no-cache-dir --target=/app/deps -r requirements.txt
### Runtime
FROM gcr.io/distroless/python3-debian13:nonroot
WORKDIR /app
COPY --from=builder /app/deps /app/deps
ENV PYTHONPATH="/app/deps"
ENV PYTHONUNBUFFERED=1
COPY . .
USER nonroot
EXPOSE 8000
CMD ["entrypoint.py"]
+29
View File
@@ -0,0 +1,29 @@
pid_file = "/tmp/pidfile"
vault {
address = "https://10.6.2.1:8200"
tls_skip_verify = true
retry {
num_retries = 5
}
}
auto_auth {
method "approle" {
mount_path = "auth/approle"
config = {
role_id_file_path = "/vault-auth/role_id"
secret_id_file_path = "/vault-auth/wrapped_secret_id"
secret_id_response_wrapping_path = "auth/approle/role/triage_agent/secret-id"
remove_secret_id_file_after_reading = false
}
}
# sink не нужен, если приложению не требуется сам токен Vault
}
template {
source = "/vault-template/template.ctmpl"
destination = "/vault-secrets/.env"
error_on_missing_key = true
perms = "0644"
}
+48
View File
@@ -0,0 +1,48 @@
volumes:
vault-secrets:
driver_opts:
type: tmpfs
device: tmpfs
o: "size=1m,mode=0777,uid=65532,gid=65532"
projects-data:
services:
vault-agent:
image: hashicorp/vault:1.17
command: agent -config=/vault-agent/agent.hcl
cap_add:
- IPC_LOCK
volumes:
- ./agent.hcl:/vault-agent/agent.hcl:ro
- ./env_template.ctmpl:/vault-template/template.ctmpl:ro
- ./../role_id:/vault-auth/role_id:ro
- ./../wrapped_secret_id:/vault-auth/wrapped_secret_id:ro
- vault-secrets:/vault-secrets
extra_hosts:
- "host.docker.internal:host-gateway"
networks:
- agent-net
api:
build:
context: ..
dockerfile: deployment/Dockerfile
ports:
- "8173:8000"
group_add:
- "100"
volumes:
- ./../projects-data:/projects
- vault-secrets:/vault-secrets
depends_on:
vault-agent:
condition: service_started
networks:
- agent-net
networks:
agent-net:
driver: bridge
ipam:
config:
- subnet: 10.6.7.0/24
+10
View File
@@ -0,0 +1,10 @@
{{ with secret "internal_secrets/data/agents" }}
YOUTRACK_MCP_SERVER={{ .Data.data.youtrack_mcp_server }}
YOUTRACK_MCP_TOKEN={{ .Data.data.youtrack_agent_mcp_token }}
YOUTRACK_TRIAGE_AUTHOR={{ .Data.data.youtrack_triage_author }}
LLM_ADDRESS={{ .Data.data.llm_address }}
LLM_API_KEY={{ .Data.data.llm_api_key }}
LLM_MODEL={{ .Data.data.llm_model }}
{{ end }}
+36
View File
@@ -0,0 +1,36 @@
#!/bin/bash
set -euo pipefail
: '
vault write auth/approle/role/triage_agent \
token_policies="agent_policy" \
token_ttl=1h \
token_max_ttl=4h \
secret_id_ttl=24h \
token_bound_cidrs="10.6.0.0/16"
'
VAULT_ADDR="https://10.6.2.1:8200"
if [ -z "${VAULT_TOKEN:-}" ]; then
read -r -s -p "VAULT_TOKEN: " VAULT_TOKEN
echo
fi
TARGET_DIR=${1:-'.'}
ROLE_NAME="triage_agent"
WRAP_TTL="600s"
CONFIG=$(mktemp)
chmod 600 "$CONFIG"
trap 'rm -f "$CONFIG"' EXIT
{
printf 'header = "X-Vault-Token: %s"\n' "$VAULT_TOKEN"
printf 'header = "X-Vault-Wrap-TTL: %s"\n' "$WRAP_TTL"
} > "$CONFIG"
curl -s -X POST -k -K "$CONFIG" \
"$VAULT_ADDR/v1/auth/approle/role/${ROLE_NAME}/secret-id" \
| jq -r '.wrap_info.token // .errors[0]' | tr -d '\n' > wrapped_secret_id
+17
View File
@@ -0,0 +1,17 @@
import logging
import sys
from common.hot_env import wait_for_env_file, load_env
logging.basicConfig(level=logging.INFO)
if __name__ == "__main__":
try:
wait_for_env_file()
load_env(force=True)
except RuntimeError as e:
print(f"FATAL: {e}", file=sys.stderr)
sys.exit(1)
import uvicorn
uvicorn.run("main:app", host="0.0.0.0", port=8000, log_level="info")
+44 -33
View File
@@ -2,23 +2,35 @@ import logging
import os
from contextlib import asynccontextmanager
import httpx
from dotenv import load_dotenv
from fastapi import FastAPI, HTTPException
from fastapi import FastAPI, HTTPException, Depends
from agents.coders.BaseCoder import CoderAgent
from agents.issue_triage.IssueTriageAgent import IssueTriageAgent
from agents.issue_triage.context_builder import YouTrackContextBuilder
from agents.registry import AgentRegistry
from common.gitea_mcp_client import GiteaMCPClient
from common.hot_env import load_env, reload_if_changed
from common.llm_client import LLMClient
from common.singleton import AgentSingleton
from common.youtrack_mcp_client import YouTrackMCPClient, IssueNotFound
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
load_dotenv()
_last_hash: str | None = None
if not load_env(force=False):
logger.warning("No .env file found at startup, will rely on os.environ")
def env_optional(key: str, default: str | None = None) -> str:
value = os.environ.get(key)
if value is None:
return default
return value
def env(key: str) -> str:
reload_if_changed()
value = os.environ.get(key)
if not value:
raise RuntimeError(f"Missing required env var: {key}")
@@ -26,42 +38,43 @@ def env(key: str) -> str:
@asynccontextmanager
async def lifespan(app: FastAPI):
import asyncio
singleton = await AgentSingleton.get()
await singleton.ensure_initialized()
async def watch_and_reset():
while True:
try:
if reload_if_changed():
await singleton.reset()
await singleton.ensure_initialized()
except Exception:
logger.exception("watch_and_reset failed")
await asyncio.sleep(5)
watcher = asyncio.create_task(watch_and_reset())
app.state.youtrack_mcp = await YouTrackMCPClient(
str(os.getenv('YOUTRACK_MCP_SERVER')),
str(os.getenv('YOUTRACK_MCP_TOKEN')),
).connect()
#
#
# res = await mcp.call_tool(
# "add_issue_comment",
# {"issueId": "ARCH-229", "text": "test"}
# )
# logger.info(res)
http_for_attachments = httpx.AsyncClient(
headers={"Authorization": f"Bearer {env('YOUTRACK_MCP_TOKEN')}"},
proxy=env("HTTPS_PROXY"),
timeout=httpx.Timeout(30.0, connect=10.0, read=60.0),
follow_redirects=True,
)
app.state.issue_reader_agent = IssueTriageAgent(
YouTrackContextBuilder(app.state.youtrack_mcp, http_for_attachments),
LLMClient(
base_url=env("LLM_ADDRESS"),
api_key=env("LLM_API_KEY"),
model=env("LLM_MODEL"),
),
AgentRegistry(),
app.state.youtrack_mcp
)
yield
# ---- shutdown ----
watcher.cancel()
await app.state.youtrack_mcp.close()
app = FastAPI(lifespan=lifespan)
async def get_agent() -> IssueTriageAgent:
singleton = await AgentSingleton.get()
await singleton.ensure_initialized()
return singleton.agent
@app.get("/")
async def root():
return {"message": "Hello World"}
@@ -73,8 +86,7 @@ async def say_hello(name: str):
@app.get("/consume_task")
async def decomposing_issue(issue: str):
agent: IssueTriageAgent = app.state.issue_reader_agent
async def decomposing_issue(issue: str, agent: IssueTriageAgent = Depends(get_agent)):
try:
logger.info(f"Received an issue: {issue}")
plan = await agent.plan_issue(issue)
@@ -89,8 +101,7 @@ async def decomposing_issue(issue: str):
return {"error": str(e)}
@app.get("/code_issue")
async def code_issue(issue: str):
issue_agent: IssueTriageAgent = app.state.issue_reader_agent
async def code_issue(issue: str, issue_agent: IssueTriageAgent = Depends(get_agent)):
gitea_mcp_client = GiteaMCPClient()
coder_agent: CoderAgent = CoderAgent(
str(os.getenv("CODER_ID")),
+14 -99
View File
@@ -1,103 +1,18 @@
a2wsgi==1.10.10
adbc_driver_manager==1.12.0
adbc_driver_postgresql==1.12.0
adbc_driver_sqlite==1.12.0
aiohttp==3.14.3
AppKit==0.2.8
atheris==3.1.0
beautifulsoup4==4.15.0
botocore==1.43.102
brotli==1.2.0
brotlicffi==1.2.0.2
cffi==2.1.1
chardet==7.6.0
checks==0.2
ConfigParser==7.2.0
curio==1.6
cycler==0.12.1
Cython==3.3.0
docutils==0.23
email_validator==2.3.0
eval_type_backport==0.4.0
exceptiongroup==1.3.1
fastapi_cli==0.0.32
fastparquet==2026.5.0
filelock==4.0.3
Foundation==0.1.0a0.dev1
fqdn==1.5.1
fsspec==2026.9.0
gunicorn==26.2.0
h2==4.4.1
HTMLParser==0.0.2
httptools==0.8.0
httpx2_jsfetch==1.0
hypothesis==6.168.1
importlib_resources==7.1.0
ipython==9.17.1
ipywidgets==8.1.9
isoduration==20.11.0
itsdangerous==2.2.0
Jinja2==3.1.6
jnius==1.1.0
jsonpath_ng==1.8.0
jsonpointer==3.1.1
keyring==25.7.0
lxml==6.1.3
MarkupSafe==3.0.3
matplotlib==3.11.2
mtrand==0.1
numexpr==2.14.2
odfpy==1.4.1
openpyxl==3.1.5
outcome==1.3.0.post0
Pillow==12.3.0
protobuf==7.36.2
psutil==7.2.2
pydantic_extra_types==2.11.1
pydot==4.0.1
pygraphviz==2.0.2
PyInstaller==6.22.3
pyodide==0.0.2
pyOpenSSL==26.4.0
pyperf==2.10.0
PyQt4==4.11.4
PyQt5==5.15.11
pytest==9.1.1
pytest_run_parallel==0.10.0
python_bcrypt==0.3.2
python_calamine==0.8.2
pyxlsb==1.0.10
qtpy==2.4.3
redis==8.1.0
rfc3339_validator==0.1.4
rfc3986_validator==0.1.1
rfc3987==1.3.8
rfc3987_syntax==1.1.0
s3fs==2026.9.0
scipy==1.18.1
scipy_doctest==2.2.0
setuptools==84.0.0
simplejson==4.1.2
sounddevice==0.5.6
Sphinx==9.1.0
SQLAlchemy==2.1.0
sympy==1.14.0
threadpoolctl==3.7.0
toml==0.10.2
tomlkit==0.15.1
traitlets==5.16.1
fastapi==0.141.1
httpx==0.28.1
mcp==2.2.0
mcp_types==2.2.0
networkx==3.7
openai==3.19.2
pandas==3.0.6
playwright==1.63.0
pydantic==2.13.5
python-dotenv==1.2.3
PyYAML==6.0.3
Requests==2.34.2
tree_sitter==0.26.0
tree_sitter_html==0.23.2
tree_sitter_javascript==0.25.0
tree_sitter_python==0.25.0
tree_sitter_typescript==0.23.2
typer==0.27.2
urllib3_secure_extra==0.1.0
watchfiles==1.3.0
webcolors==25.10.0
winloop==0.6.4
wsproto==1.3.2
xlrd==2.0.2
xlsxwriter==3.2.9
xmlrpclib==1.0.1
zstandard==0.25.0
zttp==0.0.34
uvicorn==0.54.0