diff --git a/agents/runner.py b/agents/runner.py index c3ed92a..7f0520c 100644 --- a/agents/runner.py +++ b/agents/runner.py @@ -13,6 +13,7 @@ from typing import Any from core import models from core.context_builder import build_context, format_prompt +from core.hooks import run_hooks def run_agent( @@ -465,6 +466,11 @@ def run_pipeline( ) models.update_task(conn, task_id, status="review") + # Run post-pipeline hooks (failures don't affect pipeline status) + task_modules = models.get_modules(conn, project_id) + run_hooks(conn, project_id, task_id, + event="pipeline_completed", task_modules=task_modules) + return { "success": True, "steps_completed": len(steps), diff --git a/core/db.py b/core/db.py index 284c66c..f3f26bc 100644 --- a/core/db.py +++ b/core/db.py @@ -103,6 +103,35 @@ CREATE TABLE IF NOT EXISTS pipelines ( completed_at DATETIME ); +-- Post-pipeline хуки +CREATE TABLE IF NOT EXISTS hooks ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + project_id TEXT NOT NULL REFERENCES projects(id), + name TEXT NOT NULL, + event TEXT NOT NULL, + trigger_module_path TEXT, + trigger_module_type TEXT, + command TEXT NOT NULL, + working_dir TEXT, + timeout_seconds INTEGER DEFAULT 120, + enabled INTEGER DEFAULT 1, + created_at TEXT DEFAULT (datetime('now')) +); + +-- Лог выполнений хуков +CREATE TABLE IF NOT EXISTS hook_logs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + hook_id INTEGER NOT NULL REFERENCES hooks(id), + project_id TEXT NOT NULL REFERENCES projects(id), + task_id TEXT, + success INTEGER NOT NULL, + exit_code INTEGER, + output TEXT, + error TEXT, + duration_seconds REAL, + created_at TEXT DEFAULT (datetime('now')) +); + -- Кросс-проектные зависимости CREATE TABLE IF NOT EXISTS project_links ( id INTEGER PRIMARY KEY AUTOINCREMENT, diff --git a/core/hooks.py b/core/hooks.py new file mode 100644 index 0000000..1b9775b --- /dev/null +++ b/core/hooks.py @@ -0,0 +1,224 @@ +""" +Kin — post-pipeline hooks. +Runs configured commands (e.g. npm run build) after pipeline completion. +""" + +import fnmatch +import sqlite3 +import subprocess +import time +from dataclasses import dataclass +from typing import Any + + +@dataclass +class HookResult: + hook_id: int + name: str + success: bool + exit_code: int + output: str + error: str + duration_seconds: float + + +# --------------------------------------------------------------------------- +# CRUD +# --------------------------------------------------------------------------- + +def create_hook( + conn: sqlite3.Connection, + project_id: str, + name: str, + event: str, + command: str, + trigger_module_path: str | None = None, + trigger_module_type: str | None = None, + working_dir: str | None = None, + timeout_seconds: int = 120, +) -> dict: + """Create a hook and return it as dict.""" + cur = conn.execute( + """INSERT INTO hooks (project_id, name, event, trigger_module_path, + trigger_module_type, command, working_dir, timeout_seconds) + VALUES (?, ?, ?, ?, ?, ?, ?, ?)""", + (project_id, name, event, trigger_module_path, trigger_module_type, + command, working_dir, timeout_seconds), + ) + conn.commit() + return _get_hook(conn, cur.lastrowid) + + +def get_hooks( + conn: sqlite3.Connection, + project_id: str, + event: str | None = None, + enabled_only: bool = True, +) -> list[dict]: + """Get hooks for a project, optionally filtered by event.""" + query = "SELECT * FROM hooks WHERE project_id = ?" + params: list[Any] = [project_id] + if event: + query += " AND event = ?" + params.append(event) + if enabled_only: + query += " AND enabled = 1" + query += " ORDER BY id" + rows = conn.execute(query, params).fetchall() + return [dict(r) for r in rows] + + +def update_hook(conn: sqlite3.Connection, hook_id: int, **kwargs) -> None: + """Update hook fields.""" + if not kwargs: + return + sets = ", ".join(f"{k} = ?" for k in kwargs) + vals = list(kwargs.values()) + [hook_id] + conn.execute(f"UPDATE hooks SET {sets} WHERE id = ?", vals) + conn.commit() + + +def delete_hook(conn: sqlite3.Connection, hook_id: int) -> None: + """Delete a hook by id.""" + conn.execute("DELETE FROM hooks WHERE id = ?", (hook_id,)) + conn.commit() + + +def get_hook_logs( + conn: sqlite3.Connection, + project_id: str | None = None, + hook_id: int | None = None, + limit: int = 50, +) -> list[dict]: + """Get hook execution logs.""" + query = "SELECT * FROM hook_logs WHERE 1=1" + params: list[Any] = [] + if project_id: + query += " AND project_id = ?" + params.append(project_id) + if hook_id is not None: + query += " AND hook_id = ?" + params.append(hook_id) + query += " ORDER BY created_at DESC LIMIT ?" + params.append(limit) + rows = conn.execute(query, params).fetchall() + return [dict(r) for r in rows] + + +# --------------------------------------------------------------------------- +# Execution +# --------------------------------------------------------------------------- + +def run_hooks( + conn: sqlite3.Connection, + project_id: str, + task_id: str | None, + event: str, + task_modules: list[dict], +) -> list[HookResult]: + """Run matching hooks for the given event and module list. + + Never raises — hook failures are logged but don't affect the pipeline. + """ + hooks = get_hooks(conn, project_id, event=event) + results = [] + for hook in hooks: + if hook["trigger_module_path"] is not None: + pattern = hook["trigger_module_path"] + matched = any( + fnmatch.fnmatch(m.get("path", ""), pattern) + for m in task_modules + ) + if not matched: + continue + + result = _execute_hook(conn, hook, project_id, task_id) + results.append(result) + return results + + +# --------------------------------------------------------------------------- +# Internal helpers +# --------------------------------------------------------------------------- + +def _get_hook(conn: sqlite3.Connection, hook_id: int) -> dict: + row = conn.execute("SELECT * FROM hooks WHERE id = ?", (hook_id,)).fetchone() + return dict(row) if row else {} + + +def _execute_hook( + conn: sqlite3.Connection, + hook: dict, + project_id: str, + task_id: str | None, +) -> HookResult: + """Run a single hook command and log the result.""" + start = time.monotonic() + output = "" + error = "" + exit_code = -1 + success = False + + try: + proc = subprocess.run( + hook["command"], + shell=True, + cwd=hook.get("working_dir") or None, + capture_output=True, + text=True, + timeout=hook.get("timeout_seconds") or 120, + ) + output = proc.stdout or "" + error = proc.stderr or "" + exit_code = proc.returncode + success = exit_code == 0 + except subprocess.TimeoutExpired: + error = f"Hook timed out after {hook.get('timeout_seconds', 120)}s" + exit_code = 124 + except Exception as e: + error = str(e) + exit_code = -1 + + duration = time.monotonic() - start + _log_hook_run( + conn, + hook_id=hook["id"], + project_id=project_id, + task_id=task_id, + success=success, + exit_code=exit_code, + output=output, + error=error, + duration_seconds=duration, + ) + + return HookResult( + hook_id=hook["id"], + name=hook["name"], + success=success, + exit_code=exit_code, + output=output, + error=error, + duration_seconds=duration, + ) + + +def _log_hook_run( + conn: sqlite3.Connection, + hook_id: int, + project_id: str, + task_id: str | None, + success: bool, + exit_code: int, + output: str, + error: str, + duration_seconds: float, +) -> None: + conn.execute( + """INSERT INTO hook_logs (hook_id, project_id, task_id, success, + exit_code, output, error, duration_seconds) + VALUES (?, ?, ?, ?, ?, ?, ?, ?)""", + (hook_id, project_id, task_id, int(success), exit_code, + output, error, duration_seconds), + ) + conn.commit() diff --git a/tests/test_hooks.py b/tests/test_hooks.py new file mode 100644 index 0000000..2778ee0 --- /dev/null +++ b/tests/test_hooks.py @@ -0,0 +1,275 @@ +"""Tests for core/hooks.py — post-pipeline hook execution.""" + +import subprocess +import pytest +from unittest.mock import patch, MagicMock + +from core.db import init_db +from core import models +from core.hooks import ( + create_hook, get_hooks, update_hook, delete_hook, + run_hooks, get_hook_logs, HookResult, +) + + +@pytest.fixture +def conn(): + c = init_db(":memory:") + models.create_project(c, "vdol", "ВДОЛЬ", "~/projects/vdolipoperek", + tech_stack=["vue3"]) + models.create_task(c, "VDOL-001", "vdol", "Fix bug") + yield c + c.close() + + +@pytest.fixture +def frontend_hook(conn): + return create_hook( + conn, + project_id="vdol", + name="rebuild-frontend", + event="pipeline_completed", + command="npm run build", + trigger_module_path="web/frontend/*", + working_dir="/tmp", + timeout_seconds=60, + ) + + +# --------------------------------------------------------------------------- +# CRUD +# --------------------------------------------------------------------------- + +class TestCrud: + def test_create_hook(self, conn): + hook = create_hook(conn, "vdol", "my-hook", "pipeline_completed", "make build") + assert hook["id"] is not None + assert hook["project_id"] == "vdol" + assert hook["name"] == "my-hook" + assert hook["command"] == "make build" + assert hook["enabled"] == 1 + + def test_get_hooks_by_project(self, conn, frontend_hook): + hooks = get_hooks(conn, "vdol") + assert len(hooks) == 1 + assert hooks[0]["name"] == "rebuild-frontend" + + def test_get_hooks_filter_by_event(self, conn, frontend_hook): + create_hook(conn, "vdol", "other", "step_completed", "echo done") + hooks = get_hooks(conn, "vdol", event="pipeline_completed") + assert len(hooks) == 1 + assert hooks[0]["name"] == "rebuild-frontend" + + def test_get_hooks_disabled_excluded(self, conn, frontend_hook): + update_hook(conn, frontend_hook["id"], enabled=0) + hooks = get_hooks(conn, "vdol", enabled_only=True) + assert len(hooks) == 0 + + def test_get_hooks_disabled_included_when_flag_off(self, conn, frontend_hook): + update_hook(conn, frontend_hook["id"], enabled=0) + hooks = get_hooks(conn, "vdol", enabled_only=False) + assert len(hooks) == 1 + + def test_update_hook(self, conn, frontend_hook): + update_hook(conn, frontend_hook["id"], command="npm run build:prod", timeout_seconds=180) + hooks = get_hooks(conn, "vdol", enabled_only=False) + assert hooks[0]["command"] == "npm run build:prod" + assert hooks[0]["timeout_seconds"] == 180 + + def test_delete_hook(self, conn, frontend_hook): + delete_hook(conn, frontend_hook["id"]) + hooks = get_hooks(conn, "vdol", enabled_only=False) + assert len(hooks) == 0 + + def test_get_hooks_wrong_project(self, conn, frontend_hook): + hooks = get_hooks(conn, "nonexistent") + assert hooks == [] + + +# --------------------------------------------------------------------------- +# Module matching (fnmatch) +# --------------------------------------------------------------------------- + +class TestModuleMatching: + def _make_proc(self, returncode=0, stdout="ok", stderr=""): + m = MagicMock() + m.returncode = returncode + m.stdout = stdout + m.stderr = stderr + return m + + @patch("core.hooks.subprocess.run") + def test_hook_runs_when_module_matches(self, mock_run, conn, frontend_hook): + mock_run.return_value = self._make_proc() + modules = [{"path": "web/frontend/App.vue", "name": "App"}] + results = run_hooks(conn, "vdol", "VDOL-001", + event="pipeline_completed", task_modules=modules) + assert len(results) == 1 + assert results[0].name == "rebuild-frontend" + mock_run.assert_called_once() + + @patch("core.hooks.subprocess.run") + def test_hook_skipped_when_no_module_matches(self, mock_run, conn, frontend_hook): + mock_run.return_value = self._make_proc() + modules = [{"path": "core/models.py", "name": "models"}] + results = run_hooks(conn, "vdol", "VDOL-001", + event="pipeline_completed", task_modules=modules) + assert len(results) == 0 + mock_run.assert_not_called() + + @patch("core.hooks.subprocess.run") + def test_hook_runs_without_module_filter(self, mock_run, conn): + mock_run.return_value = self._make_proc() + create_hook(conn, "vdol", "always-run", "pipeline_completed", "echo done") + results = run_hooks(conn, "vdol", "VDOL-001", + event="pipeline_completed", task_modules=[]) + assert len(results) == 1 + + @patch("core.hooks.subprocess.run") + def test_hook_skipped_on_wrong_event(self, mock_run, conn, frontend_hook): + mock_run.return_value = self._make_proc() + modules = [{"path": "web/frontend/App.vue", "name": "App"}] + results = run_hooks(conn, "vdol", "VDOL-001", + event="step_completed", task_modules=modules) + assert len(results) == 0 + + @patch("core.hooks.subprocess.run") + def test_hook_skipped_when_disabled(self, mock_run, conn, frontend_hook): + update_hook(conn, frontend_hook["id"], enabled=0) + mock_run.return_value = self._make_proc() + modules = [{"path": "web/frontend/App.vue", "name": "App"}] + results = run_hooks(conn, "vdol", "VDOL-001", + event="pipeline_completed", task_modules=modules) + assert len(results) == 0 + + +# --------------------------------------------------------------------------- +# Execution and logging +# --------------------------------------------------------------------------- + +class TestExecution: + def _make_proc(self, returncode=0, stdout="built!", stderr=""): + m = MagicMock() + m.returncode = returncode + m.stdout = stdout + m.stderr = stderr + return m + + @patch("core.hooks.subprocess.run") + def test_successful_hook_result(self, mock_run, conn, frontend_hook): + mock_run.return_value = self._make_proc(returncode=0, stdout="built!") + modules = [{"path": "web/frontend/index.ts", "name": "index"}] + results = run_hooks(conn, "vdol", "VDOL-001", + event="pipeline_completed", task_modules=modules) + r = results[0] + assert r.success is True + assert r.exit_code == 0 + assert r.output == "built!" + + @patch("core.hooks.subprocess.run") + def test_failed_hook_result(self, mock_run, conn, frontend_hook): + mock_run.return_value = self._make_proc(returncode=1, stderr="Module not found") + modules = [{"path": "web/frontend/index.ts", "name": "index"}] + results = run_hooks(conn, "vdol", "VDOL-001", + event="pipeline_completed", task_modules=modules) + r = results[0] + assert r.success is False + assert r.exit_code == 1 + assert "Module not found" in r.error + + @patch("core.hooks.subprocess.run") + def test_hook_run_logged_to_db(self, mock_run, conn, frontend_hook): + mock_run.return_value = self._make_proc(returncode=0, stdout="ok") + modules = [{"path": "web/frontend/App.vue", "name": "App"}] + run_hooks(conn, "vdol", "VDOL-001", + event="pipeline_completed", task_modules=modules) + + logs = get_hook_logs(conn, project_id="vdol") + assert len(logs) == 1 + assert logs[0]["hook_id"] == frontend_hook["id"] + assert logs[0]["task_id"] == "VDOL-001" + assert logs[0]["success"] == 1 + assert logs[0]["exit_code"] == 0 + assert logs[0]["output"] == "ok" + + @patch("core.hooks.subprocess.run") + def test_failed_hook_logged_to_db(self, mock_run, conn, frontend_hook): + mock_run.return_value = self._make_proc(returncode=2, stderr="error!") + modules = [{"path": "web/frontend/App.vue", "name": "App"}] + run_hooks(conn, "vdol", "VDOL-001", + event="pipeline_completed", task_modules=modules) + + logs = get_hook_logs(conn, project_id="vdol") + assert logs[0]["success"] == 0 + assert logs[0]["exit_code"] == 2 + assert "error!" in logs[0]["error"] + + @patch("core.hooks.subprocess.run") + def test_timeout_handled_gracefully(self, mock_run, conn, frontend_hook): + mock_run.side_effect = subprocess.TimeoutExpired(cmd="npm run build", timeout=60) + modules = [{"path": "web/frontend/App.vue", "name": "App"}] + # Must not raise + results = run_hooks(conn, "vdol", "VDOL-001", + event="pipeline_completed", task_modules=modules) + r = results[0] + assert r.success is False + assert r.exit_code == 124 + assert "timed out" in r.error + + logs = get_hook_logs(conn, project_id="vdol") + assert logs[0]["success"] == 0 + + @patch("core.hooks.subprocess.run") + def test_exception_handled_gracefully(self, mock_run, conn, frontend_hook): + mock_run.side_effect = OSError("npm not found") + modules = [{"path": "web/frontend/App.vue", "name": "App"}] + results = run_hooks(conn, "vdol", "VDOL-001", + event="pipeline_completed", task_modules=modules) + r = results[0] + assert r.success is False + assert "npm not found" in r.error + + @patch("core.hooks.subprocess.run") + def test_command_uses_working_dir(self, mock_run, conn, frontend_hook): + mock_run.return_value = self._make_proc() + modules = [{"path": "web/frontend/App.vue", "name": "App"}] + run_hooks(conn, "vdol", "VDOL-001", + event="pipeline_completed", task_modules=modules) + call_kwargs = mock_run.call_args[1] + assert call_kwargs["cwd"] == "/tmp" + + @patch("core.hooks.subprocess.run") + def test_shell_true_used(self, mock_run, conn, frontend_hook): + mock_run.return_value = self._make_proc() + modules = [{"path": "web/frontend/App.vue", "name": "App"}] + run_hooks(conn, "vdol", "VDOL-001", + event="pipeline_completed", task_modules=modules) + call_kwargs = mock_run.call_args[1] + assert call_kwargs["shell"] is True + + +# --------------------------------------------------------------------------- +# get_hook_logs filters +# --------------------------------------------------------------------------- + +class TestGetHookLogs: + @patch("core.hooks.subprocess.run") + def test_filter_by_hook_id(self, mock_run, conn, frontend_hook): + mock_run.return_value = MagicMock(returncode=0, stdout="ok", stderr="") + hook2 = create_hook(conn, "vdol", "second", "pipeline_completed", "echo 2") + modules = [{"path": "web/frontend/App.vue", "name": "App"}] + run_hooks(conn, "vdol", "VDOL-001", + event="pipeline_completed", task_modules=modules) + + logs = get_hook_logs(conn, hook_id=frontend_hook["id"]) + assert all(l["hook_id"] == frontend_hook["id"] for l in logs) + + @patch("core.hooks.subprocess.run") + def test_limit_respected(self, mock_run, conn, frontend_hook): + mock_run.return_value = MagicMock(returncode=0, stdout="ok", stderr="") + modules = [{"path": "web/frontend/App.vue", "name": "App"}] + for _ in range(5): + run_hooks(conn, "vdol", "VDOL-001", + event="pipeline_completed", task_modules=modules) + logs = get_hook_logs(conn, project_id="vdol", limit=3) + assert len(logs) == 3