Add controlled coding agent automation
Some checks failed
CI / test (push) Has been cancelled

This commit is contained in:
2026-08-21 22:49:49 +02:00
parent 9bac41f071
commit bfa5cdcd3e
25 changed files with 1263 additions and 1 deletions

8
.dockerignore Normal file
View File

@@ -0,0 +1,8 @@
.git
.env
.venv
.docker-tmp
__pycache__
.pytest_cache
.ruff_cache
workspaces

27
.env.example Normal file
View File

@@ -0,0 +1,27 @@
FAILURE_DB_HOST=host.docker.internal
FAILURE_DB_PORT=33060
FAILURE_DB_NAME=laravel_ai_chat
FAILURE_DB_USER=laravel
FAILURE_DB_PASSWORD=
SOURCE_REPOSITORY=https://gitea.tisinfra.de/christian.meyer/lai.git
SOURCE_BRANCH=main
GITEA_URL=https://gitea.tisinfra.de
GITEA_OWNER=christian.meyer
GITEA_REPOSITORY=lai
GITEA_USERNAME=automation-bot
GITEA_TOKEN=
# dry-run, pr, direct-main
AUTOMATION_MODE=dry-run
ALLOW_DIRECT_MAIN=false
AGENT_COMMAND=aider
AGENT_MODEL=mistral/mistral-medium-latest
MISTRAL_API_KEY=
POLL_SECONDS=60
STALE_CLAIM_MINUTES=90
MAX_CHANGED_FILES=12
MAX_CHANGED_LINES=800
TEST_COMMANDS=composer install --no-interaction --prefer-dist;;php artisan test

18
.gitea/workflows/ci.yml Normal file
View File

@@ -0,0 +1,18 @@
name: CI
on:
push:
pull_request:
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- run: python -m pip install ".[dev]"
- run: ruff check src tests
- run: pytest -q
- run: docker build -t lai-automation:test .

9
.gitignore vendored Normal file
View File

@@ -0,0 +1,9 @@
.env
.venv/
__pycache__/
.pytest_cache/
.ruff_cache/
.docker-tmp/
.docker-buildx/
*.pyc
workspaces/

18
Dockerfile Normal file
View File

@@ -0,0 +1,18 @@
FROM python:3.12-slim
RUN apt-get update \
&& apt-get install -y --no-install-recommends \
composer git openssh-client php-cli php-curl php-mbstring php-mysql php-xml php-zip unzip \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY pyproject.toml README.md ./
COPY src ./src
RUN pip install --no-cache-dir ".[agent]"
RUN useradd --create-home --uid 10001 coder \
&& mkdir -p /workspaces \
&& chmod 700 /home/coder
ENTRYPOINT ["lai-agent"]
CMD ["worker"]

112
README.md
View File

@@ -1,2 +1,112 @@
# lai_automatisierung
# LAI Automatisierung
Kontrollierter Coding-Agent fuer Failure Reports aus
[`lai`](https://gitea.tisinfra.de/christian.meyer/lai). Der Orchestrator verwendet
[Aider](https://aider.chat/) headless als austauschbaren Coding-Kern. Git-, Gitea-,
Policy- und Freigabelogik bleiben in diesem Projekt.
## Warum Aider?
- scriptbarer Einmal-Auftrag mit `--message-file`
- gute Git- und Repository-Map-Unterstuetzung
- Mistral ueber LiteLLM nutzbar
- automatische Commits koennen deaktiviert werden
- deutlich kleinerer Betriebsumfang als OpenHands
OpenHands bleibt eine moegliche spaetere Alternative fuer komplexere, mehrstufige
Agentenlaeufe. Sein Headless-Modus arbeitet jedoch immer mit `always-approve`; deshalb
muessen auch dort unsere externen Policy-Gates erhalten bleiben.
## Ablauf
```text
failure_reports(open)
-> transaktionales Claiming (SKIP LOCKED)
-> anonymisierter Agentenauftrag
-> frischer Clone von lai.git
-> Aider-Aenderung ohne Commit
-> Pfad-, Groessen- und Sicherheitsgates
-> Tests
-> Commit
-> PR oder kontrollierter Push auf main
-> failure_reports(resolved/agent_failed)
```
Der Coding-Agent erhaelt keinen Zugang zur ERP- oder Kundendatenbank. Rohdaten und
`.env`-Dateien sind ausgeschlossen. Repository-Pushes verwenden `GIT_ASKPASS`; Tokens
werden nicht in Remote-URLs oder Prozessargumenten gespeichert. Der Orchestrator laeuft
als privilegierter Containerprozess, startet Aider aber als separaten Benutzer `coder`.
Dieser Unterprozess erhaelt nur Modell-Credentials, nicht die Failure-DB- oder
Gitea-Zugangsdaten.
Laravel-Tests laufen mit PHP und Composer direkt im isolierten Worker. Der Docker-Socket
des Hosts wird nicht eingebunden. Abgebrochene Claims werden nach standardmaessig 90
Minuten wieder freigegeben; `STALE_CLAIM_MINUTES` passt dieses Intervall an.
## Schnellstart
```bash
cp .env.example .env
# FAILURE_DB_PASSWORD und bei Bedarf MISTRAL_API_KEY eintragen
docker compose build
docker compose run --rm automation check
docker compose run --rm automation once
```
Der Default `AUTOMATION_MODE=dry-run` claimt einen offenen Report, erzeugt den
anonymisierten Auftrag und setzt ihn danach wieder auf `open`. Es wird weder ein Agent
gestartet noch gepusht.
## PR-Modus
```dotenv
AUTOMATION_MODE=pr
GITEA_USERNAME=automation-bot
GITEA_TOKEN=...
MISTRAL_API_KEY=...
```
Der Bot benoetigt Schreibzugriff auf `christian.meyer/lai` und die Berechtigung,
Branches sowie Pull Requests anzulegen. Jeder Lauf erstellt
`ai/failure-{report_id}` und einen PR gegen `main`.
## Direkter main-Modus
```dotenv
AUTOMATION_MODE=direct-main
ALLOW_DIRECT_MAIN=true
GITEA_TOKEN=...
```
Der zweite Schalter ist ein Kill-Switch. In diesem Modus sind Migrationen, Routes,
Auth, Middleware, PermissionService und SQLValidatorService zusaetzlich blockiert.
Branch Protection muss den Bot fuer direkte Pushes explizit zulassen. PR-Modus bleibt
die empfohlene Betriebsart.
## Modelle
Default:
```dotenv
AGENT_MODEL=mistral/mistral-medium-latest
```
Das Chat-Modell und Coding-Modell sind bewusst getrennt. Fuer Coding-Agenten sollte ein
agentisch/coding-optimiertes Modell verwendet werden. Ein Wechsel zu OpenHands oder
einem anderen Backend erfordert nur einen neuen Adapter neben `AiderAgent`.
## Tests
```bash
python -m venv .venv
. .venv/bin/activate
pip install ".[dev]"
ruff check src tests
pytest -q
```
## Repositorys
- Chat: `https://gitea.tisinfra.de/christian.meyer/lai.git`
- Automatisierung: `https://gitea.tisinfra.de/christian.meyer/lai_automatisierung.git`

31
docker-compose.yml Normal file
View File

@@ -0,0 +1,31 @@
services:
automation:
build: .
restart: unless-stopped
environment:
FAILURE_DB_HOST: ${FAILURE_DB_HOST:-host.docker.internal}
FAILURE_DB_PORT: ${FAILURE_DB_PORT:-33060}
FAILURE_DB_NAME: ${FAILURE_DB_NAME:-laravel_ai_chat}
FAILURE_DB_USER: ${FAILURE_DB_USER:-laravel}
FAILURE_DB_PASSWORD: ${FAILURE_DB_PASSWORD:?FAILURE_DB_PASSWORD must be set}
SOURCE_REPOSITORY: ${SOURCE_REPOSITORY:-https://gitea.tisinfra.de/christian.meyer/lai.git}
SOURCE_BRANCH: ${SOURCE_BRANCH:-main}
AUTOMATION_MODE: ${AUTOMATION_MODE:-dry-run}
ALLOW_DIRECT_MAIN: ${ALLOW_DIRECT_MAIN:-false}
GITEA_URL: ${GITEA_URL:-https://gitea.tisinfra.de}
GITEA_OWNER: ${GITEA_OWNER:-christian.meyer}
GITEA_REPOSITORY: ${GITEA_REPOSITORY:-lai}
GITEA_USERNAME: ${GITEA_USERNAME:-automation-bot}
GITEA_TOKEN: ${GITEA_TOKEN:-}
AGENT_MODEL: ${AGENT_MODEL:-mistral/mistral-medium-latest}
MISTRAL_API_KEY: ${MISTRAL_API_KEY:-}
POLL_SECONDS: ${POLL_SECONDS:-60}
STALE_CLAIM_MINUTES: ${STALE_CLAIM_MINUTES:-90}
TEST_COMMANDS: ${TEST_COMMANDS:-composer install --no-interaction --prefer-dist;;php artisan test}
extra_hosts:
- host.docker.internal:host-gateway
volumes:
- automation-workspaces:/workspaces
volumes:
automation-workspaces:

32
pyproject.toml Normal file
View File

@@ -0,0 +1,32 @@
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[project]
name = "lai-automation"
version = "0.1.0"
description = "Controlled coding-agent automation for Laravel AI failure reports"
readme = "README.md"
requires-python = ">=3.12"
dependencies = [
"PyMySQL>=1.1,<2",
"requests>=2.32,<3",
]
[project.optional-dependencies]
dev = ["pytest>=8.3,<9", "ruff>=0.9,<1"]
agent = ["aider-chat>=0.86,<1"]
[project.scripts]
lai-agent = "lai_automation.cli:main"
[tool.hatch.build.targets.wheel]
packages = ["src/lai_automation"]
[tool.pytest.ini_options]
pythonpath = ["src"]
testpaths = ["tests"]
[tool.ruff]
line-length = 100
target-version = "py312"

View File

@@ -0,0 +1 @@
__version__ = "0.1.0"

View File

@@ -0,0 +1,62 @@
from __future__ import annotations
import os
import pwd
import subprocess
from pathlib import Path
from .config import Settings
class AiderAgent:
def __init__(self, settings: Settings) -> None:
self.settings = settings
def run(self, workspace: Path, task: str) -> str:
task_file = workspace / ".git" / "failure-task.txt"
task_file.write_text(task)
task_file.chmod(0o600)
command = [
self.settings.agent_command,
"--model",
self.settings.agent_model,
"--message-file",
str(task_file),
"--yes",
"--no-auto-commits",
"--no-dirty-commits",
"--no-stream",
]
coder = pwd.getpwnam("coder")
os.chown(task_file, coder.pw_uid, coder.pw_gid)
for path in workspace.rglob("*"):
if ".git" not in path.parts:
os.chown(path, coder.pw_uid, coder.pw_gid, follow_symlinks=False)
os.chown(workspace, coder.pw_uid, coder.pw_gid, follow_symlinks=False)
environment = {
"HOME": coder.pw_dir,
"PATH": os.environ.get("PATH", "/usr/local/bin:/usr/bin:/bin"),
}
for name in ("MISTRAL_API_KEY", "OPENAI_API_KEY", "ANTHROPIC_API_KEY"):
if os.getenv(name):
environment[name] = os.environ[name]
def drop_privileges() -> None:
os.setgroups([])
os.setgid(coder.pw_gid)
os.setuid(coder.pw_uid)
result = subprocess.run(
command,
cwd=workspace,
env=environment,
text=True,
capture_output=True,
check=False,
preexec_fn=drop_privileges,
timeout=3600,
)
if result.returncode != 0:
raise RuntimeError(f"Aider failed ({result.returncode}): {result.stderr[-4000:]}")
return result.stdout[-12_000:]

36
src/lai_automation/cli.py Normal file
View File

@@ -0,0 +1,36 @@
from __future__ import annotations
import argparse
import logging
from .config import Settings
from .worker import AutomationWorker
def main() -> None:
parser = argparse.ArgumentParser(description="LAI controlled coding-agent automation")
parser.add_argument("command", choices=["once", "worker", "check"])
args = parser.parse_args()
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s %(levelname)s %(name)s %(message)s",
)
settings = Settings.from_env()
if args.command == "check":
print(f"mode={settings.mode}")
print(f"source={settings.source_repository}")
print(f"model={settings.agent_model}")
print(f"direct_main_enabled={settings.allow_direct_main}")
return
worker = AutomationWorker(settings)
if args.command == "once":
worker.run_once()
else:
worker.run_forever()
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,89 @@
from __future__ import annotations
import os
from dataclasses import dataclass
from pathlib import Path
def _bool(name: str, default: bool = False) -> bool:
return os.getenv(name, str(default)).lower() in {"1", "true", "yes", "on"}
def _commands(value: str) -> tuple[str, ...]:
return tuple(command.strip() for command in value.split(";;") if command.strip())
@dataclass(frozen=True)
class Settings:
database_host: str
database_port: int
database_name: str
database_user: str
database_password: str
source_repository: str
source_branch: str
workspace_root: Path
mode: str
allow_direct_main: bool
gitea_url: str
gitea_owner: str
gitea_repository: str
gitea_token: str
gitea_username: str
agent_command: str
agent_model: str
test_commands: tuple[str, ...]
poll_seconds: int
max_changed_files: int
max_changed_lines: int
stale_claim_minutes: int
dry_run: bool
@classmethod
def from_env(cls) -> Settings:
mode = os.getenv("AUTOMATION_MODE", "dry-run").lower()
if mode not in {"dry-run", "pr", "direct-main"}:
raise ValueError("AUTOMATION_MODE must be dry-run, pr, or direct-main")
settings = cls(
database_host=os.getenv("FAILURE_DB_HOST", "host.docker.internal"),
database_port=int(os.getenv("FAILURE_DB_PORT", "33060")),
database_name=os.getenv("FAILURE_DB_NAME", "laravel_ai_chat"),
database_user=os.getenv("FAILURE_DB_USER", "laravel"),
database_password=os.getenv("FAILURE_DB_PASSWORD", ""),
source_repository=os.getenv(
"SOURCE_REPOSITORY", "https://gitea.tisinfra.de/christian.meyer/lai.git"
),
source_branch=os.getenv("SOURCE_BRANCH", "main"),
workspace_root=Path(os.getenv("WORKSPACE_ROOT", "/workspaces")),
mode=mode,
allow_direct_main=_bool("ALLOW_DIRECT_MAIN"),
gitea_url=os.getenv("GITEA_URL", "https://gitea.tisinfra.de"),
gitea_owner=os.getenv("GITEA_OWNER", "christian.meyer"),
gitea_repository=os.getenv("GITEA_REPOSITORY", "lai"),
gitea_token=os.getenv("GITEA_TOKEN", ""),
gitea_username=os.getenv("GITEA_USERNAME", "automation-bot"),
agent_command=os.getenv("AGENT_COMMAND", "aider"),
agent_model=os.getenv("AGENT_MODEL", "mistral/mistral-medium-latest"),
test_commands=_commands(
os.getenv(
"TEST_COMMANDS",
"composer install --no-interaction --prefer-dist;;php artisan test",
)
),
poll_seconds=int(os.getenv("POLL_SECONDS", "60")),
max_changed_files=int(os.getenv("MAX_CHANGED_FILES", "12")),
max_changed_lines=int(os.getenv("MAX_CHANGED_LINES", "800")),
stale_claim_minutes=int(os.getenv("STALE_CLAIM_MINUTES", "90")),
dry_run=mode == "dry-run",
)
settings.validate()
return settings
def validate(self) -> None:
if self.mode == "direct-main" and not self.allow_direct_main:
raise ValueError("direct-main requires ALLOW_DIRECT_MAIN=true")
if self.mode in {"pr", "direct-main"} and not self.gitea_token:
raise ValueError(f"{self.mode} requires GITEA_TOKEN")
if not self.database_password:
raise ValueError("FAILURE_DB_PASSWORD is required")

View File

@@ -0,0 +1,107 @@
from __future__ import annotations
import json
from collections.abc import Iterator
from contextlib import contextmanager
import pymysql
from pymysql.cursors import DictCursor
from .config import Settings
from .models import FailureReport
class FailureRepository:
def __init__(self, settings: Settings) -> None:
self.settings = settings
@contextmanager
def connection(self) -> Iterator[pymysql.Connection]:
connection = pymysql.connect(
host=self.settings.database_host,
port=self.settings.database_port,
user=self.settings.database_user,
password=self.settings.database_password,
database=self.settings.database_name,
cursorclass=DictCursor,
autocommit=False,
connect_timeout=10,
read_timeout=30,
write_timeout=30,
)
try:
yield connection
finally:
connection.close()
def claim_next(self) -> FailureReport | None:
with self.connection() as connection, connection.cursor() as cursor:
try:
cursor.execute(
"""
UPDATE failure_reports
SET status = 'open', updated_at = NOW()
WHERE status = 'agent_running'
AND updated_at < DATE_SUB(NOW(), INTERVAL %s MINUTE)
""",
(self.settings.stale_claim_minutes,),
)
connection.commit()
cursor.execute("SET TRANSACTION ISOLATION LEVEL READ COMMITTED")
cursor.execute("START TRANSACTION")
except Exception:
connection.rollback()
raise
cursor.execute(
"""
SELECT * FROM failure_reports
WHERE status = 'open'
ORDER BY id
LIMIT 1
FOR UPDATE SKIP LOCKED
"""
)
row = cursor.fetchone()
if not row:
connection.commit()
return None
cursor.execute(
"UPDATE failure_reports SET status = 'agent_running', updated_at = NOW() WHERE id = %s",
(row["id"],),
)
connection.commit()
return FailureReport.from_row(row)
def release(self, report_id: int, reason: str) -> None:
self._finish(report_id, "open", {"last_dry_run": reason})
def complete(self, report_id: int, metadata: dict) -> None:
self._finish(report_id, "resolved", metadata)
def fail(self, report_id: int, error: str, metadata: dict | None = None) -> None:
payload = {"agent_error": error, **(metadata or {})}
self._finish(report_id, "agent_failed", payload)
def _finish(self, report_id: int, status: str, additions: dict) -> None:
with self.connection() as connection, connection.cursor() as cursor:
try:
cursor.execute("SELECT context FROM failure_reports WHERE id = %s", (report_id,))
row = cursor.fetchone() or {}
context = row.get("context") or {}
if isinstance(context, str):
context = json.loads(context)
context.update(additions)
cursor.execute(
"""
UPDATE failure_reports
SET status = %s, context = %s, updated_at = NOW()
WHERE id = %s
""",
(status, json.dumps(context, ensure_ascii=True), report_id),
)
connection.commit()
except Exception as exception:
connection.rollback()
raise RuntimeError(
f"Could not finalize failure report {report_id} as {status}"
) from exception

143
src/lai_automation/git.py Normal file
View File

@@ -0,0 +1,143 @@
from __future__ import annotations
import os
import pwd
import shlex
import shutil
import subprocess
from dataclasses import dataclass
from pathlib import Path
from urllib.parse import urlparse
from .config import Settings
@dataclass(frozen=True)
class ChangeSummary:
files: tuple[str, ...]
added_lines: int
deleted_lines: int
@property
def changed_lines(self) -> int:
return self.added_lines + self.deleted_lines
class GitWorkspace:
def __init__(self, settings: Settings, report_id: int) -> None:
self.settings = settings
self.path = settings.workspace_root / f"failure-{report_id}"
self.branch = f"ai/failure-{report_id}"
def prepare(self) -> None:
repository = urlparse(self.settings.source_repository)
gitea = urlparse(self.settings.gitea_url)
if repository.scheme != "https" or repository.hostname != gitea.hostname:
raise ValueError("SOURCE_REPOSITORY must use HTTPS on the configured Gitea host")
shutil.rmtree(self.path, ignore_errors=True)
self.path.parent.mkdir(parents=True, exist_ok=True)
self._git("clone", "--branch", self.settings.source_branch, "--single-branch", self.settings.source_repository, str(self.path), cwd=self.path.parent)
self._git("checkout", "-b", self.branch)
def run(
self,
command: str,
check: bool = True,
username: str | None = None,
) -> subprocess.CompletedProcess[str]:
environment = None
drop_privileges = None
if username:
account = pwd.getpwnam(username)
environment = {
"HOME": account.pw_dir,
"PATH": os.environ.get("PATH", "/usr/local/bin:/usr/bin:/bin"),
}
def drop_privileges() -> None:
os.setgroups([])
os.setgid(account.pw_gid)
os.setuid(account.pw_uid)
return subprocess.run(
shlex.split(command),
cwd=self.path,
env=environment,
text=True,
capture_output=True,
check=check,
timeout=1800,
preexec_fn=drop_privileges,
)
def summary(self) -> ChangeSummary:
self._git("add", "-N", "--all")
output = self._git("diff", "--numstat").stdout
files: list[str] = []
added = deleted = 0
for line in output.splitlines():
add, remove, filename = line.split("\t", 2)
files.append(filename)
added += int(add) if add.isdigit() else 0
deleted += int(remove) if remove.isdigit() else 0
return ChangeSummary(tuple(files), added, deleted)
def commit(self, report_id: int) -> str:
self._git("add", "--all")
self._git("commit", "-m", f"Fix failure report #{report_id}")
return self._git("rev-parse", "HEAD").stdout.strip()
def push_branch(self) -> None:
self._git_authenticated("push", "origin", f"HEAD:{self.branch}")
def push_main(self) -> None:
self._git_authenticated("push", "origin", f"HEAD:{self.settings.source_branch}")
def cleanup(self) -> None:
if self.path.exists():
shutil.rmtree(self.path)
def _git(self, *args: str, cwd: Path | None = None) -> subprocess.CompletedProcess[str]:
return subprocess.run(
["git", *args],
cwd=cwd or self.path,
text=True,
capture_output=True,
check=True,
timeout=300,
)
def _git_authenticated(self, *args: str) -> subprocess.CompletedProcess[str]:
askpass = self.path / ".git" / "lai-askpass.sh"
username_file = self.path / ".git" / "lai-bot-user"
token_file = self.path / ".git" / "lai-bot-token"
try:
username_file.write_text(self.settings.gitea_username)
token_file.write_text(self.settings.gitea_token)
username_file.chmod(0o600)
token_file.chmod(0o600)
askpass.write_text(
"#!/bin/sh\ncase \"$1\" in *Username*) cat \"$GIT_BOT_USER_FILE\";; *) cat \"$GIT_BOT_TOKEN_FILE\";; esac\n"
)
askpass.chmod(0o700)
env = {
"HOME": os.environ.get("HOME", "/root"),
"PATH": os.environ.get("PATH", "/usr/bin:/bin"),
"GIT_ASKPASS": str(askpass),
"GIT_TERMINAL_PROMPT": "0",
"GIT_BOT_USER_FILE": str(username_file),
"GIT_BOT_TOKEN_FILE": str(token_file),
}
return subprocess.run(
["git", *args],
cwd=self.path,
env=env,
text=True,
capture_output=True,
check=True,
timeout=300,
)
finally:
askpass.unlink(missing_ok=True)
username_file.unlink(missing_ok=True)
token_file.unlink(missing_ok=True)

View File

@@ -0,0 +1,46 @@
from __future__ import annotations
import requests
from requests.adapters import HTTPAdapter
from urllib3.util.retry import Retry
from .config import Settings
class GiteaClient:
def __init__(self, settings: Settings) -> None:
self.settings = settings
self.session = requests.Session()
self.session.headers.update({
"Authorization": f"token {settings.gitea_token}",
"Accept": "application/json",
})
retry = Retry(
total=3,
backoff_factor=1,
status_forcelist=(429, 500, 502, 503, 504),
allowed_methods=("POST",),
)
self.session.mount("https://", HTTPAdapter(max_retries=retry))
def create_pull_request(self, branch: str, title: str, body: str) -> str:
url = (
f"{self.settings.gitea_url.rstrip('/')}/api/v1/repos/"
f"{self.settings.gitea_owner}/{self.settings.gitea_repository}/pulls"
)
response = self.session.post(
url,
json={
"base": self.settings.source_branch,
"head": branch,
"title": title,
"body": body,
},
timeout=30,
)
response.raise_for_status()
payload = response.json()
pull_request_url = payload.get("html_url") or payload.get("url")
if not pull_request_url:
raise ValueError("Gitea response did not contain a pull request URL")
return pull_request_url

View File

@@ -0,0 +1,37 @@
from __future__ import annotations
import json
from dataclasses import dataclass
from typing import Any
@dataclass(frozen=True)
class FailureReport:
id: int
chat_message_id: int | None
source: str
category: str
user_feedback: str | None
prompt: str | None
response: str | None
sql_query: str | None
error_message: str | None
context: dict[str, Any]
@classmethod
def from_row(cls, row: dict[str, Any]) -> FailureReport:
context = row.get("context") or {}
if isinstance(context, str):
context = json.loads(context)
return cls(
id=row["id"],
chat_message_id=row.get("chat_message_id"),
source=row["source"],
category=row["category"],
user_feedback=row.get("user_feedback"),
prompt=row.get("prompt"),
response=row.get("response"),
sql_query=row.get("sql_query"),
error_message=row.get("error_message"),
context=context,
)

View File

@@ -0,0 +1,65 @@
from __future__ import annotations
from dataclasses import dataclass
from pathlib import Path, PurePosixPath
from .config import Settings
from .git import ChangeSummary
_ALWAYS_DENIED = (
".env",
"vendor/",
"storage/",
"bootstrap/cache/",
"docker/",
"docker-compose",
".gitea/",
".github/",
)
_DIRECT_MAIN_DENIED = (
"database/migrations/",
"app/Http/Middleware/",
"app/Models/User.php",
"app/Services/PermissionService.php",
"app/Services/SQLValidatorService.php",
"config/auth.php",
"config/database.php",
"routes/",
)
@dataclass(frozen=True)
class PolicyResult:
allowed: bool
errors: tuple[str, ...]
def evaluate_changes(
summary: ChangeSummary,
settings: Settings,
workspace: Path | None = None,
) -> PolicyResult:
errors: list[str] = []
if not summary.files:
errors.append("Agent produced no file changes")
if len(summary.files) > settings.max_changed_files:
errors.append(f"Changed {len(summary.files)} files; limit is {settings.max_changed_files}")
if summary.changed_lines > settings.max_changed_lines:
errors.append(f"Changed {summary.changed_lines} lines; limit is {settings.max_changed_lines}")
for filename in summary.files:
if PurePosixPath(filename).is_absolute() or ".." in PurePosixPath(filename).parts:
errors.append(f"Invalid changed path: {filename}")
continue
if workspace and (workspace / filename).is_symlink():
errors.append(f"Symbolic links are not allowed: {filename}")
continue
if any(filename == prefix or filename.startswith(prefix) for prefix in _ALWAYS_DENIED):
errors.append(f"Denied path changed: {filename}")
if settings.mode == "direct-main" and any(
filename == prefix or filename.startswith(prefix) for prefix in _DIRECT_MAIN_DENIED
):
errors.append(f"Direct-main denied path changed: {filename}")
return PolicyResult(not errors, tuple(errors))

View File

@@ -0,0 +1,63 @@
from __future__ import annotations
import re
from .models import FailureReport
_SECRET_PATTERNS = (
(
re.compile(r"(?i)\b(?:mysql|mariadb|postgres(?:ql)?|sqlserver)://[^\s]+"),
"[DATABASE_URL]",
),
(re.compile(r"(?i)(password|passwd|secret|token|api[_ -]?key)\s*[:=]\s*\S+"), r"\1=[REDACTED]"),
(re.compile(r"\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\b"), "[EMAIL]"),
(re.compile(r"\b(?:\d{1,3}\.){3}\d{1,3}\b"), "[IP]"),
(re.compile(r"(?i)bearer\s+[A-Za-z0-9._~+/=-]+"), "Bearer [REDACTED]"),
(re.compile(r"\bAKIA[0-9A-Z]{16}\b"), "[AWS_KEY]"),
(re.compile(r"-----BEGIN [A-Z ]+KEY-----[\s\S]+?-----END [A-Z ]+KEY-----"), "[PRIVATE_KEY]"),
(re.compile(r"\b[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\b"), "[JWT]"),
)
def redact(value: str | None) -> str:
result = value or ""
for pattern, replacement in _SECRET_PATTERNS:
result = pattern.sub(replacement, result)
return result[:12_000]
def build_task(report: FailureReport) -> str:
prompt = redact(report.prompt)
feedback = redact(report.user_feedback)
error = redact(report.error_message)
sql = redact(report.sql_query)
return f"""You are fixing failure report #{report.id} in the Laravel AI Chat repository.
Rules:
- First add a regression test that reproduces the reported behavior.
- Make the smallest production change that fixes the root cause.
- Preserve read-only SQL validation and tenant isolation.
- Never read or modify .env files, credentials, deployment secrets, or production data.
- Do not weaken tests, authentication, authorization, SQL validation, or safety checks.
- Do not commit or push; the orchestrator owns Git publication.
Failure category: {report.category}
Source: {report.source}
Model: {redact(str(report.context.get('model', 'unknown')))}
Database type: {redact(str(report.context.get('database_type', 'unknown')))}
User request:
{prompt or '[not available]'}
User feedback:
{feedback or '[not available]'}
Observed error:
{error or '[not available]'}
Generated SQL, if relevant:
{sql or '[not available]'}
Run focused tests while working. Leave the repository with the fix and regression test applied.
"""

View File

@@ -0,0 +1,133 @@
from __future__ import annotations
import logging
import signal
import threading
from dataclasses import asdict
from .agent import AiderAgent
from .config import Settings
from .database import FailureRepository
from .git import GitWorkspace
from .gitea import GiteaClient
from .models import FailureReport
from .policy import evaluate_changes
from .prompt import build_task
LOG = logging.getLogger(__name__)
class AutomationWorker:
def __init__(self, settings: Settings) -> None:
self.settings = settings
self.reports = FailureRepository(settings)
self.agent = AiderAgent(settings)
self.gitea = GiteaClient(settings)
self.stop_event = threading.Event()
def run_once(self) -> bool:
report = self.reports.claim_next()
if not report:
LOG.info("No open failure reports")
return False
LOG.info("Claimed failure report %s", report.id)
if self.settings.dry_run:
task = build_task(report)
LOG.info("Dry-run task for report %s:\n%s", report.id, task)
self.reports.release(report.id, "Task generated successfully; no agent executed")
return True
workspace = GitWorkspace(self.settings, report.id)
metadata: dict = {"mode": self.settings.mode, "model": self.settings.agent_model}
try:
workspace.prepare()
task = build_task(report)
metadata["agent_output"] = self.agent.run(workspace.path, task)
summary = workspace.summary()
metadata["changes"] = asdict(summary)
policy = evaluate_changes(summary, self.settings, workspace.path)
if not policy.allowed:
raise RuntimeError("Policy rejected changes: " + "; ".join(policy.errors))
metadata["tests"] = self._run_tests(workspace)
summary = workspace.summary()
metadata["changes"] = asdict(summary)
policy = evaluate_changes(summary, self.settings, workspace.path)
if not policy.allowed:
raise RuntimeError(
"Policy rejected final changes: " + "; ".join(policy.errors)
)
commit = workspace.commit(report.id)
metadata["commit"] = commit
if self.settings.mode == "pr":
workspace.push_branch()
metadata["pull_request"] = self.gitea.create_pull_request(
workspace.branch,
f"Fix failure report #{report.id}",
self._pull_request_body(report, summary, metadata["tests"]),
)
elif self.settings.mode == "direct-main":
workspace.push_main()
metadata["direct_main"] = True
self.reports.complete(report.id, metadata)
LOG.info("Completed failure report %s", report.id)
return True
except Exception as exception:
LOG.exception("Failure report %s failed", report.id)
self.reports.fail(report.id, str(exception), metadata)
return True
finally:
try:
workspace.cleanup()
except OSError:
LOG.exception("Could not clean workspace for report %s", report.id)
def run_forever(self) -> None:
signal.signal(signal.SIGTERM, self._stop)
signal.signal(signal.SIGINT, self._stop)
while not self.stop_event.is_set():
self.run_once()
self.stop_event.wait(self.settings.poll_seconds)
def _stop(self, signum: int, _frame) -> None:
LOG.info("Received signal %s; stopping after the current report", signum)
self.stop_event.set()
def _run_tests(self, workspace: GitWorkspace) -> list[dict]:
results = []
for command in self.settings.test_commands:
result = workspace.run(command, check=False, username="coder")
results.append({
"command": command,
"returncode": result.returncode,
"stdout": result.stdout[-6000:],
"stderr": result.stderr[-6000:],
})
if result.returncode != 0:
raise RuntimeError(f"Test command failed: {command}\n{result.stderr[-2000:]}")
return results
@staticmethod
def _pull_request_body(report: FailureReport, summary, tests: list[dict]) -> str:
checks = "\n".join(
f"- {'PASS' if item['returncode'] == 0 else 'FAIL'} `{item['command']}`"
for item in tests
)
files = "\n".join(f"- `{filename}`" for filename in summary.files)
return f"""Automated fix for internal failure report #{report.id}.
## Scope
{files}
## Validation
{checks}
## Risk controls
- No production database access was provided to the coding agent.
- Secret and deployment paths are blocked by policy.
- Human review is required before merge.
"""

47
tests/test_config.py Normal file
View File

@@ -0,0 +1,47 @@
from pathlib import Path
import pytest
from lai_automation.config import Settings
def settings(**overrides) -> Settings:
values = {
"database_host": "db",
"database_port": 3306,
"database_name": "lai",
"database_user": "worker",
"database_password": "secret",
"source_repository": "https://example.test/lai.git",
"source_branch": "main",
"workspace_root": Path("/tmp/workspaces"),
"mode": "dry-run",
"allow_direct_main": False,
"gitea_url": "https://example.test",
"gitea_owner": "owner",
"gitea_repository": "lai",
"gitea_token": "",
"gitea_username": "bot",
"agent_command": "aider",
"agent_model": "mistral/test",
"test_commands": ("php artisan test",),
"poll_seconds": 60,
"max_changed_files": 12,
"max_changed_lines": 800,
"stale_claim_minutes": 90,
"dry_run": True,
}
values.update(overrides)
return Settings(**values)
def test_direct_main_requires_explicit_kill_switch() -> None:
config = settings(mode="direct-main", dry_run=False, gitea_token="token")
with pytest.raises(ValueError, match="ALLOW_DIRECT_MAIN"):
config.validate()
def test_pr_requires_gitea_token() -> None:
config = settings(mode="pr", dry_run=False)
with pytest.raises(ValueError, match="GITEA_TOKEN"):
config.validate()

28
tests/test_git.py Normal file
View File

@@ -0,0 +1,28 @@
import subprocess
from test_config import settings
from lai_automation.git import GitWorkspace
def test_test_command_does_not_interpret_shell_metacharacters(tmp_path) -> None:
workspace = GitWorkspace(settings(workspace_root=tmp_path), 1)
workspace.path.mkdir()
result = workspace.run("printf safe ; touch injected", check=False)
assert result.stdout == "safe"
assert result.returncode != 0
assert not (workspace.path / "injected").exists()
def test_summary_includes_untracked_files(tmp_path) -> None:
workspace = GitWorkspace(settings(workspace_root=tmp_path), 2)
workspace.path.mkdir()
subprocess.run(["git", "init"], cwd=workspace.path, check=True, capture_output=True)
(workspace.path / "new.php").write_text("<?php\n")
summary = workspace.summary()
assert summary.files == ("new.php",)
assert summary.added_lines == 1

27
tests/test_gitea.py Normal file
View File

@@ -0,0 +1,27 @@
from unittest.mock import Mock
import pytest
from test_config import settings
from lai_automation.gitea import GiteaClient
def test_create_pull_request_requires_url() -> None:
client = GiteaClient(settings(gitea_token="token"))
response = Mock()
response.json.return_value = {"number": 12}
client.session.post = Mock(return_value=response)
with pytest.raises(ValueError, match="pull request URL"):
client.create_pull_request("ai/failure-1", "Fix", "Body")
def test_create_pull_request_returns_html_url() -> None:
client = GiteaClient(settings(gitea_token="token"))
response = Mock()
response.json.return_value = {"html_url": "https://example.test/owner/lai/pulls/12"}
client.session.post = Mock(return_value=response)
result = client.create_pull_request("ai/failure-1", "Fix", "Body")
assert result.endswith("/pulls/12")

41
tests/test_policy.py Normal file
View File

@@ -0,0 +1,41 @@
from dataclasses import replace
from test_config import settings
from lai_automation.git import ChangeSummary
from lai_automation.policy import evaluate_changes
def test_policy_accepts_small_application_change() -> None:
result = evaluate_changes(
ChangeSummary(("app/Services/Example.php", "tests/Feature/ExampleTest.php"), 40, 5),
settings(),
)
assert result.allowed
def test_policy_rejects_secrets_and_vendor_paths() -> None:
result = evaluate_changes(ChangeSummary((".env", "vendor/foo.php"), 2, 0), settings())
assert not result.allowed
assert len(result.errors) == 2
def test_direct_main_blocks_validator_and_migrations() -> None:
config = replace(
settings(),
mode="direct-main",
dry_run=False,
allow_direct_main=True,
gitea_token="token",
)
result = evaluate_changes(
ChangeSummary(("app/Services/SQLValidatorService.php", "database/migrations/new.php"), 10, 2),
config,
)
assert not result.allowed
def test_policy_rejects_symlinks(tmp_path) -> None:
(tmp_path / "link.php").symlink_to("/etc/passwd")
result = evaluate_changes(ChangeSummary(("link.php",), 1, 0), settings(), tmp_path)
assert not result.allowed

47
tests/test_prompt.py Normal file
View File

@@ -0,0 +1,47 @@
from lai_automation.models import FailureReport
from lai_automation.prompt import build_task, redact
def test_redact_removes_common_secrets_and_personal_markers() -> None:
value = "password=hunter2 token: abc123 admin@example.com 10.2.3.4 Bearer secret.jwt"
result = redact(value)
assert "hunter2" not in result
assert "abc123" not in result
assert "admin@example.com" not in result
assert "10.2.3.4" not in result
assert "secret.jwt" not in result
def test_task_contains_constraints_and_redacted_report() -> None:
report = FailureReport(
id=7,
chat_message_id=91,
source="user_feedback",
category="incorrect_answer",
user_feedback="password=secret-value",
prompt="List locations for admin@example.com",
response=None,
sql_query="SELECT * FROM company",
error_message=None,
context={"model": "mistral", "database_type": "customer"},
)
task = build_task(report)
assert "failure report #7" in task
assert "secret-value" not in task
assert "admin@example.com" not in task
assert "Do not commit or push" in task
def test_redact_removes_extended_credentials() -> None:
private_key = "-----BEGIN PRIVATE KEY-----\nprivate-material\n-----END PRIVATE KEY-----"
value = (
"AKIAIOSFODNN7EXAMPLE "
"eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjM0In0.signature "
"mysql://admin:database-password@db.internal/lai "
f"{private_key}"
)
result = redact(value)
assert "AKIAIOSFODNN7EXAMPLE" not in result
assert "eyJhbGci" not in result
assert "database-password" not in result
assert "private-material" not in result

37
tests/test_worker.py Normal file
View File

@@ -0,0 +1,37 @@
from unittest.mock import Mock
from test_config import settings
from lai_automation.models import FailureReport
from lai_automation.worker import AutomationWorker
def test_dry_run_generates_task_without_agent_or_git() -> None:
report = FailureReport(
id=11,
chat_message_id=1,
source="automatic",
category="sql_execution",
user_feedback=None,
prompt="List locations",
response=None,
sql_query=None,
error_message="Unknown column",
context={},
)
worker = AutomationWorker(settings())
worker.reports = Mock()
worker.reports.claim_next.return_value = report
assert worker.run_once()
worker.reports.release.assert_called_once()
worker.agent = Mock()
worker.agent.run.assert_not_called()
def test_stop_signal_interrupts_worker_wait() -> None:
worker = AutomationWorker(settings())
worker._stop(15, None)
assert worker.stop_event.is_set()