feat: add post-pipeline hooks (KIN-003)
- core/hooks.py: HookRunner с CRUD, run_hooks(), _execute_hook(), логированием - core/db.py: новые таблицы hooks и hook_logs в схеме - agents/runner.py: вызов run_hooks() после завершения pipeline - tests/test_hooks.py: 23 теста (CRUD, fnmatch-матчинг, выполнение, таймаут) Хуки запускаются синхронно после update_task(status="review"). Ошибка хука логируется, не блокирует пайплайн. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
bf38532f59
commit
d311c2fb66
4 changed files with 534 additions and 0 deletions
29
core/db.py
29
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,
|
||||
|
|
|
|||
224
core/hooks.py
Normal file
224
core/hooks.py
Normal file
|
|
@ -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()
|
||||
Loading…
Add table
Add a link
Reference in a new issue