From bfa5cdcd3e8ef1ee1c0687950e4cf63577a2735e Mon Sep 17 00:00:00 2001 From: Christian Meyer Date: Fri, 21 Aug 2026 22:49:49 +0200 Subject: [PATCH] Add controlled coding agent automation --- .dockerignore | 8 ++ .env.example | 27 +++++++ .gitea/workflows/ci.yml | 18 +++++ .gitignore | 9 +++ Dockerfile | 18 +++++ README.md | 112 +++++++++++++++++++++++++- docker-compose.yml | 31 +++++++ pyproject.toml | 32 ++++++++ src/lai_automation/__init__.py | 1 + src/lai_automation/agent.py | 62 ++++++++++++++ src/lai_automation/cli.py | 36 +++++++++ src/lai_automation/config.py | 89 ++++++++++++++++++++ src/lai_automation/database.py | 107 ++++++++++++++++++++++++ src/lai_automation/git.py | 143 +++++++++++++++++++++++++++++++++ src/lai_automation/gitea.py | 46 +++++++++++ src/lai_automation/models.py | 37 +++++++++ src/lai_automation/policy.py | 65 +++++++++++++++ src/lai_automation/prompt.py | 63 +++++++++++++++ src/lai_automation/worker.py | 133 ++++++++++++++++++++++++++++++ tests/test_config.py | 47 +++++++++++ tests/test_git.py | 28 +++++++ tests/test_gitea.py | 27 +++++++ tests/test_policy.py | 41 ++++++++++ tests/test_prompt.py | 47 +++++++++++ tests/test_worker.py | 37 +++++++++ 25 files changed, 1263 insertions(+), 1 deletion(-) create mode 100644 .dockerignore create mode 100644 .env.example create mode 100644 .gitea/workflows/ci.yml create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 docker-compose.yml create mode 100644 pyproject.toml create mode 100644 src/lai_automation/__init__.py create mode 100644 src/lai_automation/agent.py create mode 100644 src/lai_automation/cli.py create mode 100644 src/lai_automation/config.py create mode 100644 src/lai_automation/database.py create mode 100644 src/lai_automation/git.py create mode 100644 src/lai_automation/gitea.py create mode 100644 src/lai_automation/models.py create mode 100644 src/lai_automation/policy.py create mode 100644 src/lai_automation/prompt.py create mode 100644 src/lai_automation/worker.py create mode 100644 tests/test_config.py create mode 100644 tests/test_git.py create mode 100644 tests/test_gitea.py create mode 100644 tests/test_policy.py create mode 100644 tests/test_prompt.py create mode 100644 tests/test_worker.py diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..e913f96 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,8 @@ +.git +.env +.venv +.docker-tmp +__pycache__ +.pytest_cache +.ruff_cache +workspaces diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..753fe27 --- /dev/null +++ b/.env.example @@ -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 diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml new file mode 100644 index 0000000..58fa6bf --- /dev/null +++ b/.gitea/workflows/ci.yml @@ -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 . diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a6928d8 --- /dev/null +++ b/.gitignore @@ -0,0 +1,9 @@ +.env +.venv/ +__pycache__/ +.pytest_cache/ +.ruff_cache/ +.docker-tmp/ +.docker-buildx/ +*.pyc +workspaces/ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..b7ac518 --- /dev/null +++ b/Dockerfile @@ -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"] diff --git a/README.md b/README.md index 7a7ae78..95747e9 100644 --- a/README.md +++ b/README.md @@ -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` diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..e01f958 --- /dev/null +++ b/docker-compose.yml @@ -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: diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..4c6a342 --- /dev/null +++ b/pyproject.toml @@ -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" diff --git a/src/lai_automation/__init__.py b/src/lai_automation/__init__.py new file mode 100644 index 0000000..3dc1f76 --- /dev/null +++ b/src/lai_automation/__init__.py @@ -0,0 +1 @@ +__version__ = "0.1.0" diff --git a/src/lai_automation/agent.py b/src/lai_automation/agent.py new file mode 100644 index 0000000..02547c5 --- /dev/null +++ b/src/lai_automation/agent.py @@ -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:] diff --git a/src/lai_automation/cli.py b/src/lai_automation/cli.py new file mode 100644 index 0000000..bff5253 --- /dev/null +++ b/src/lai_automation/cli.py @@ -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() diff --git a/src/lai_automation/config.py b/src/lai_automation/config.py new file mode 100644 index 0000000..59f7a76 --- /dev/null +++ b/src/lai_automation/config.py @@ -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") diff --git a/src/lai_automation/database.py b/src/lai_automation/database.py new file mode 100644 index 0000000..6e59aeb --- /dev/null +++ b/src/lai_automation/database.py @@ -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 diff --git a/src/lai_automation/git.py b/src/lai_automation/git.py new file mode 100644 index 0000000..6af40d7 --- /dev/null +++ b/src/lai_automation/git.py @@ -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) diff --git a/src/lai_automation/gitea.py b/src/lai_automation/gitea.py new file mode 100644 index 0000000..02e037f --- /dev/null +++ b/src/lai_automation/gitea.py @@ -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 diff --git a/src/lai_automation/models.py b/src/lai_automation/models.py new file mode 100644 index 0000000..7162518 --- /dev/null +++ b/src/lai_automation/models.py @@ -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, + ) diff --git a/src/lai_automation/policy.py b/src/lai_automation/policy.py new file mode 100644 index 0000000..7e96b2b --- /dev/null +++ b/src/lai_automation/policy.py @@ -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)) diff --git a/src/lai_automation/prompt.py b/src/lai_automation/prompt.py new file mode 100644 index 0000000..961e2b4 --- /dev/null +++ b/src/lai_automation/prompt.py @@ -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. +""" diff --git a/src/lai_automation/worker.py b/src/lai_automation/worker.py new file mode 100644 index 0000000..4be56d1 --- /dev/null +++ b/src/lai_automation/worker.py @@ -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. +""" diff --git a/tests/test_config.py b/tests/test_config.py new file mode 100644 index 0000000..df8690f --- /dev/null +++ b/tests/test_config.py @@ -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() diff --git a/tests/test_git.py b/tests/test_git.py new file mode 100644 index 0000000..f8dbe32 --- /dev/null +++ b/tests/test_git.py @@ -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(" 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") \ No newline at end of file diff --git a/tests/test_policy.py b/tests/test_policy.py new file mode 100644 index 0000000..9972bc5 --- /dev/null +++ b/tests/test_policy.py @@ -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 diff --git a/tests/test_prompt.py b/tests/test_prompt.py new file mode 100644 index 0000000..265cde5 --- /dev/null +++ b/tests/test_prompt.py @@ -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 diff --git a/tests/test_worker.py b/tests/test_worker.py new file mode 100644 index 0000000..084bb51 --- /dev/null +++ b/tests/test_worker.py @@ -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()