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:
parent
6705b302f7
commit
01b269e2b8
6 changed files with 355 additions and 2 deletions
|
|
@ -468,8 +468,11 @@ def run_pipeline(
|
||||||
|
|
||||||
# Run post-pipeline hooks (failures don't affect pipeline status)
|
# Run post-pipeline hooks (failures don't affect pipeline status)
|
||||||
task_modules = models.get_modules(conn, project_id)
|
task_modules = models.get_modules(conn, project_id)
|
||||||
run_hooks(conn, project_id, task_id,
|
try:
|
||||||
event="pipeline_completed", task_modules=task_modules)
|
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 {
|
return {
|
||||||
"success": True,
|
"success": True,
|
||||||
|
|
|
||||||
130
cli/main.py
130
cli/main.py
|
|
@ -15,6 +15,7 @@ sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||||
|
|
||||||
from core.db import init_db
|
from core.db import init_db
|
||||||
from core import models
|
from core import models
|
||||||
|
from core import hooks as hooks_module
|
||||||
from agents.bootstrap import (
|
from agents.bootstrap import (
|
||||||
detect_tech_stack, detect_modules, extract_decisions_from_claude_md,
|
detect_tech_stack, detect_modules, extract_decisions_from_claude_md,
|
||||||
find_vault_root, scan_obsidian, format_preview, save_to_db,
|
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.")
|
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
|
# Entry point
|
||||||
# ===========================================================================
|
# ===========================================================================
|
||||||
|
|
|
||||||
38
scripts/rebuild-frontend.sh
Executable file
38
scripts/rebuild-frontend.sh
Executable 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
|
||||||
|
|
@ -321,3 +321,34 @@ def test_hook_logs_empty(runner):
|
||||||
r = invoke(runner, ["hook", "logs", "--project", "p1"])
|
r = invoke(runner, ["hook", "logs", "--project", "p1"])
|
||||||
assert r.exit_code == 0
|
assert r.exit_code == 0
|
||||||
assert "No hook logs" in r.output
|
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
|
||||||
|
|
|
||||||
112
tests/test_no_connection_artifacts.py
Normal file
112
tests/test_no_connection_artifacts.py
Normal 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) передаётся вместо пути к файлу — это создаёт файл-артефакт!"
|
||||||
|
)
|
||||||
|
|
@ -249,6 +249,45 @@ class TestRunPipeline:
|
||||||
assert result["success"] is False
|
assert result["success"] is False
|
||||||
assert "not found" in result["error"]
|
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
|
# JSON parsing
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue