From 01b269e2b8cf4c462cd9d388895417a9e9f2596f Mon Sep 17 00:00:00 2001 From: Gros Frumos Date: Sun, 15 Mar 2026 19:17:42 +0200 Subject: [PATCH] feat(KIN-010): implement rebuild-frontend post-pipeline hook MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- agents/runner.py | 7 +- cli/main.py | 130 ++++++++++++++++++++++++++ scripts/rebuild-frontend.sh | 38 ++++++++ tests/test_cli.py | 31 ++++++ tests/test_no_connection_artifacts.py | 112 ++++++++++++++++++++++ tests/test_runner.py | 39 ++++++++ 6 files changed, 355 insertions(+), 2 deletions(-) create mode 100755 scripts/rebuild-frontend.sh create mode 100644 tests/test_no_connection_artifacts.py diff --git a/agents/runner.py b/agents/runner.py index 7f0520c..6ae013a 100644 --- a/agents/runner.py +++ b/agents/runner.py @@ -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) - run_hooks(conn, project_id, task_id, - event="pipeline_completed", task_modules=task_modules) + 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, diff --git a/cli/main.py b/cli/main.py index 3395d92..f11f82d 100644 --- a/cli/main.py +++ b/cli/main.py @@ -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: /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 # =========================================================================== diff --git a/scripts/rebuild-frontend.sh b/scripts/rebuild-frontend.sh new file mode 100755 index 0000000..19b9ea6 --- /dev/null +++ b/scripts/rebuild-frontend.sh @@ -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 + +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 diff --git a/tests/test_cli.py b/tests/test_cli.py index 2ac77aa..f056f6d 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -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 diff --git a/tests/test_no_connection_artifacts.py b/tests/test_no_connection_artifacts.py new file mode 100644 index 0000000..569de53 --- /dev/null +++ b/tests/test_no_connection_artifacts.py @@ -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)) создаст файл с именем ''. + Этот тест перехватывает вызов и проверяет тип аргумента напрямую. + """ + 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) передаётся вместо пути к файлу — это создаёт файл-артефакт!" + ) diff --git a/tests/test_runner.py b/tests/test_runner.py index 20e966e..e05da75 100644 --- a/tests/test_runner.py +++ b/tests/test_runner.py @@ -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