feat(KIN-010): implement rebuild-frontend post-pipeline hook

- scripts/rebuild-frontend.sh: builds Vue 3 frontend and restarts uvicorn API
- cli/main.py: hook group with add/list/remove/logs/setup commands;
  `hook setup` idempotently registers rebuild-frontend for a project
- agents/runner.py: call run_hooks(event="pipeline_completed") after
  successful pipeline; wrap in try/except so hook errors never block results
- tests: 3 tests for hook_setup CLI + 3 tests for pipeline→hooks integration

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Gros Frumos 2026-03-15 19:17:42 +02:00
parent 6705b302f7
commit 01b269e2b8
6 changed files with 355 additions and 2 deletions

View file

@ -468,8 +468,11 @@ def run_pipeline(
# Run post-pipeline hooks (failures don't affect pipeline status)
task_modules = models.get_modules(conn, project_id)
try:
run_hooks(conn, project_id, task_id,
event="pipeline_completed", task_modules=task_modules)
except Exception:
pass # Hook errors must never block pipeline completion
return {
"success": True,

View file

@ -15,6 +15,7 @@ sys.path.insert(0, str(Path(__file__).parent.parent))
from core.db import init_db
from core import models
from core import hooks as hooks_module
from agents.bootstrap import (
detect_tech_stack, detect_modules, extract_decisions_from_claude_md,
find_vault_root, scan_obsidian, format_preview, save_to_db,
@ -720,6 +721,135 @@ def bootstrap(ctx, path, project_id, name, vault_path, yes):
f"{dec_count} decisions, {task_count} tasks.")
# ===========================================================================
# hook
# ===========================================================================
@cli.group()
def hook():
"""Manage post-pipeline hooks."""
@hook.command("add")
@click.option("--project", "project_id", required=True, help="Project ID")
@click.option("--name", required=True, help="Hook name")
@click.option("--event", required=True, help="Event: pipeline_completed, step_completed")
@click.option("--command", required=True, help="Shell command to run")
@click.option("--module-path", default=None, help="Trigger only when module path matches (fnmatch)")
@click.option("--working-dir", default=None, help="Working directory for the command")
@click.pass_context
def hook_add(ctx, project_id, name, event, command, module_path, working_dir):
"""Add a post-pipeline hook to a project."""
conn = ctx.obj["conn"]
p = models.get_project(conn, project_id)
if not p:
click.echo(f"Project '{project_id}' not found.", err=True)
raise SystemExit(1)
h = hooks_module.create_hook(
conn, project_id, name, event, command,
trigger_module_path=module_path,
working_dir=working_dir,
)
click.echo(f"Created hook: #{h['id']} {h['name']} [{h['event']}] → {h['command']}")
@hook.command("list")
@click.option("--project", "project_id", required=True, help="Project ID")
@click.pass_context
def hook_list(ctx, project_id):
"""List hooks for a project."""
conn = ctx.obj["conn"]
hs = hooks_module.get_hooks(conn, project_id, enabled_only=False)
if not hs:
click.echo("No hooks found.")
return
rows = [
[str(h["id"]), h["name"], h["event"],
h["command"][:40], h.get("trigger_module_path") or "-",
"yes" if h["enabled"] else "no"]
for h in hs
]
click.echo(_table(["ID", "Name", "Event", "Command", "Module", "Enabled"], rows))
@hook.command("remove")
@click.argument("hook_id", type=int)
@click.pass_context
def hook_remove(ctx, hook_id):
"""Remove a hook by ID."""
conn = ctx.obj["conn"]
row = conn.execute("SELECT * FROM hooks WHERE id = ?", (hook_id,)).fetchone()
if not row:
click.echo(f"Hook #{hook_id} not found.", err=True)
raise SystemExit(1)
hooks_module.delete_hook(conn, hook_id)
click.echo(f"Removed hook #{hook_id}.")
@hook.command("logs")
@click.option("--project", "project_id", required=True, help="Project ID")
@click.option("--limit", default=20, help="Number of log entries (default: 20)")
@click.pass_context
def hook_logs(ctx, project_id, limit):
"""Show recent hook execution logs for a project."""
conn = ctx.obj["conn"]
logs = hooks_module.get_hook_logs(conn, project_id=project_id, limit=limit)
if not logs:
click.echo("No hook logs found.")
return
rows = [
[str(l["hook_id"]), l.get("task_id") or "-",
"ok" if l["success"] else "fail",
str(l["exit_code"]),
f"{l['duration_seconds']:.1f}s",
l["created_at"][:19]]
for l in logs
]
click.echo(_table(["Hook", "Task", "Result", "Exit", "Duration", "Time"], rows))
@hook.command("setup")
@click.option("--project", "project_id", required=True, help="Project ID")
@click.option("--scripts-dir", default=None,
help="Directory with hook scripts (default: <kin_root>/scripts)")
@click.pass_context
def hook_setup(ctx, project_id, scripts_dir):
"""Register standard hooks for a project.
Currently registers: rebuild-frontend (fires on web/frontend/* changes).
Idempotent skips hooks that already exist.
"""
conn = ctx.obj["conn"]
p = models.get_project(conn, project_id)
if not p:
click.echo(f"Project '{project_id}' not found.", err=True)
raise SystemExit(1)
if scripts_dir is None:
scripts_dir = str(Path(__file__).parent.parent / "scripts")
existing_names = {h["name"] for h in hooks_module.get_hooks(conn, project_id, enabled_only=False)}
created = []
if "rebuild-frontend" not in existing_names:
rebuild_cmd = str(Path(scripts_dir) / "rebuild-frontend.sh")
hooks_module.create_hook(
conn, project_id,
name="rebuild-frontend",
event="pipeline_completed",
command=rebuild_cmd,
trigger_module_path="web/frontend/*",
working_dir=p.get("path"),
timeout_seconds=300,
)
created.append("rebuild-frontend")
else:
click.echo("Hook 'rebuild-frontend' already exists, skipping.")
if created:
click.echo(f"Registered hooks: {', '.join(created)}")
# ===========================================================================
# Entry point
# ===========================================================================

38
scripts/rebuild-frontend.sh Executable file
View file

@ -0,0 +1,38 @@
#!/usr/bin/env bash
# rebuild-frontend — post-pipeline hook for Kin.
#
# Triggered automatically after pipeline_completed when web/frontend/* modules
# were touched. Builds the Vue 3 frontend and restarts the API server.
#
# Registration (one-time):
# kin hook setup --project <project_id>
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_ROOT="$(dirname "$SCRIPT_DIR")"
FRONTEND_DIR="$PROJECT_ROOT/web/frontend"
echo "[rebuild-frontend] Building frontend in $FRONTEND_DIR ..."
cd "$FRONTEND_DIR"
npm run build
echo "[rebuild-frontend] Build complete."
# Restart API server if it's currently running.
# pgrep returns 1 if no match; || true prevents set -e from exiting.
API_PID=$(pgrep -f "uvicorn web.api" 2>/dev/null || true)
if [ -n "$API_PID" ]; then
echo "[rebuild-frontend] Stopping API server (PID: $API_PID) ..."
kill "$API_PID" 2>/dev/null || true
# Wait for port 8420 to free up (up to 5 s)
for i in $(seq 1 5); do
pgrep -f "uvicorn web.api" > /dev/null 2>&1 || break
sleep 1
done
echo "[rebuild-frontend] Starting API server ..."
cd "$PROJECT_ROOT"
nohup python -m uvicorn web.api:app --port 8420 >> /tmp/kin-api.log 2>&1 &
echo "[rebuild-frontend] API server started (PID: $!)."
else
echo "[rebuild-frontend] API server not running; skipping restart."
fi

View file

@ -321,3 +321,34 @@ def test_hook_logs_empty(runner):
r = invoke(runner, ["hook", "logs", "--project", "p1"])
assert r.exit_code == 0
assert "No hook logs" in r.output
def test_hook_setup_registers_rebuild_frontend(runner, tmp_path):
invoke(runner, ["project", "add", "p1", "P1", "/p1"])
r = invoke(runner, ["hook", "setup", "--project", "p1",
"--scripts-dir", str(tmp_path)])
assert r.exit_code == 0
assert "rebuild-frontend" in r.output
r = invoke(runner, ["hook", "list", "--project", "p1"])
assert r.exit_code == 0
assert "rebuild-frontend" in r.output
assert "web/frontend/*" in r.output
def test_hook_setup_idempotent(runner, tmp_path):
invoke(runner, ["project", "add", "p1", "P1", "/p1"])
invoke(runner, ["hook", "setup", "--project", "p1", "--scripts-dir", str(tmp_path)])
r = invoke(runner, ["hook", "setup", "--project", "p1", "--scripts-dir", str(tmp_path)])
assert r.exit_code == 0
assert "already exists" in r.output
r = invoke(runner, ["hook", "list", "--project", "p1"])
# Only one hook, not duplicated
assert r.output.count("rebuild-frontend") == 1
def test_hook_setup_project_not_found(runner):
r = invoke(runner, ["hook", "setup", "--project", "nope"])
assert r.exit_code == 1
assert "not found" in r.output

View file

@ -0,0 +1,112 @@
"""Regression test — KIN-009.
Проверяет, что в рабочей директории проекта НЕ создаются файлы с именами,
содержащими 'sqlite3.Connection'. Такие артефакты появляются, если путь к БД
формируется передачей объекта sqlite3.Connection вместо строки/Path в
sqlite3.connect().
"""
import os
import sqlite3
from pathlib import Path
import pytest
# Корень проекта — три уровня вверх от этого файла (tests/ → kin/)
PROJECT_ROOT = Path(__file__).parent.parent
def _find_connection_artifacts(search_dir: Path) -> list[Path]:
"""Рекурсивно ищет файлы, чьё имя содержит 'sqlite3.Connection'."""
found = []
try:
for entry in search_dir.rglob("*"):
if entry.is_file() and "sqlite3.Connection" in entry.name:
found.append(entry)
except PermissionError:
pass
return found
# ---------------------------------------------------------------------------
# Тест 1: статическая проверка — артефактов нет прямо сейчас
# ---------------------------------------------------------------------------
def test_no_connection_artifact_files_in_project():
"""В рабочей директории проекта не должно быть файлов с 'sqlite3.Connection' в имени."""
artifacts = _find_connection_artifacts(PROJECT_ROOT)
assert artifacts == [], (
f"Найдены файлы-артефакты sqlite3.Connection:\n"
+ "\n".join(f" {p}" for p in artifacts)
)
def test_no_connection_artifact_files_in_kin_home():
"""В ~/.kin/ тоже не должно быть таких файлов."""
kin_home = Path.home() / ".kin"
if not kin_home.exists():
pytest.skip("~/.kin не существует")
artifacts = _find_connection_artifacts(kin_home)
assert artifacts == [], (
f"Найдены файлы-артефакты sqlite3.Connection в ~/.kin:\n"
+ "\n".join(f" {p}" for p in artifacts)
)
# ---------------------------------------------------------------------------
# Тест 2: динамическая проверка — init_db не создаёт артефактов в tmp_path
# ---------------------------------------------------------------------------
def test_init_db_does_not_create_connection_artifact(tmp_path):
"""init_db() должен создавать файл с нормальным именем, а не 'sqlite3.Connection ...'."""
from core.db import init_db
db_file = tmp_path / "test.db"
conn = init_db(db_file)
conn.close()
artifacts = _find_connection_artifacts(tmp_path)
assert artifacts == [], (
"init_db() создал файл с именем, содержащим 'sqlite3.Connection':\n"
+ "\n".join(f" {p}" for p in artifacts)
)
# Убедимся, что файл БД реально создан с правильным именем
assert db_file.exists(), "Файл БД должен существовать"
# ---------------------------------------------------------------------------
# Тест 3: воспроизводит сценарий утечки — connect(conn) вместо connect(path)
# ---------------------------------------------------------------------------
def test_init_db_passes_string_to_sqlite_connect(tmp_path, monkeypatch):
"""core/db.init_db() должен вызывать sqlite3.connect() со строкой пути, а НЕ с объектом Connection.
Баг-сценарий: если где-то в коде путь к БД перепутан с объектом conn,
sqlite3.connect(str(conn)) создаст файл с именем '<sqlite3.Connection object at 0x...>'.
Этот тест перехватывает вызов и проверяет тип аргумента напрямую.
"""
import core.db as db_module
connect_calls: list = []
real_connect = sqlite3.connect
def mock_connect(database, *args, **kwargs):
connect_calls.append(database)
return real_connect(database, *args, **kwargs)
monkeypatch.setattr(db_module.sqlite3, "connect", mock_connect)
db_file = tmp_path / "test.db"
conn = db_module.init_db(db_file)
conn.close()
assert connect_calls, "sqlite3.connect() должен быть вызван хотя бы один раз"
for call_arg in connect_calls:
assert isinstance(call_arg, str), (
f"sqlite3.connect() получил не строку: {type(call_arg).__name__!r} = {call_arg!r}"
)
assert "sqlite3.Connection" not in call_arg, (
f"sqlite3.connect() получил строку объекта Connection: {call_arg!r}\n"
"Баг: str(conn) передаётся вместо пути к файлу — это создаёт файл-артефакт!"
)

View file

@ -249,6 +249,45 @@ class TestRunPipeline:
assert result["success"] is False
assert "not found" in result["error"]
@patch("agents.runner.run_hooks")
@patch("agents.runner.subprocess.run")
def test_hooks_called_after_successful_pipeline(self, mock_run, mock_hooks, conn):
mock_run.return_value = _mock_claude_success({"result": "done"})
mock_hooks.return_value = []
steps = [{"role": "debugger", "brief": "find"}]
result = run_pipeline(conn, "VDOL-001", steps)
assert result["success"] is True
mock_hooks.assert_called_once()
call_kwargs = mock_hooks.call_args
assert call_kwargs[1].get("event") == "pipeline_completed" or \
call_kwargs[0][3] == "pipeline_completed"
@patch("agents.runner.run_hooks")
@patch("agents.runner.subprocess.run")
def test_hooks_not_called_on_failed_pipeline(self, mock_run, mock_hooks, conn):
mock_run.return_value = _mock_claude_failure("compilation error")
mock_hooks.return_value = []
steps = [{"role": "debugger", "brief": "find"}]
result = run_pipeline(conn, "VDOL-001", steps)
assert result["success"] is False
mock_hooks.assert_not_called()
@patch("agents.runner.run_hooks")
@patch("agents.runner.subprocess.run")
def test_hook_failure_does_not_affect_pipeline_result(self, mock_run, mock_hooks, conn):
mock_run.return_value = _mock_claude_success({"result": "done"})
mock_hooks.side_effect = Exception("hook exploded")
steps = [{"role": "debugger", "brief": "find"}]
# Must not raise — hook failures must not propagate
result = run_pipeline(conn, "VDOL-001", steps)
assert result["success"] is True
# ---------------------------------------------------------------------------
# JSON parsing