kin: KIN-083 Healthcheck claude CLI auth: перед запуском pipeline проверять что claude залогинен (быстрый claude -p 'ok' --output-format json, проверить is_error и 'Not logged in'). Если не залогинен — не запускать pipeline, а показать ошибку 'Claude CLI requires login' в GUI с инструкцией.

This commit is contained in:
Gros Frumos 2026-03-16 15:48:09 +02:00
parent a80679ae72
commit bfc8f1c0bb
18 changed files with 1390 additions and 57 deletions

View file

@ -540,6 +540,7 @@ class TestKIN052RebuildFrontendCommand:
"""Хук должен сохраняться в файловой БД и быть доступен после пересоздания соединения.
Симулирует рестарт: создаём хук, закрываем соединение, открываем новое хук на месте.
Используем проект НЕ 'kin', чтобы _seed_default_hooks не мигрировал хук.
"""
with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f:
db_path = f.name
@ -547,16 +548,17 @@ class TestKIN052RebuildFrontendCommand:
# Первое соединение — создаём проект и хук
conn1 = init_db(db_path)
from core import models as _models
_models.create_project(conn1, "kin", "Kin", "/projects/kin", tech_stack=["vue3"])
cmd = "cd /Users/grosfrumos/projects/kin/web/frontend && npm run build"
hook = create_hook(conn1, "kin", "rebuild-frontend", "pipeline_completed", cmd,
_models.create_project(conn1, "kin-test", "KinTest", "/projects/kin-test",
tech_stack=["vue3"])
cmd = "cd /projects/kin-test/web/frontend && npm run build"
hook = create_hook(conn1, "kin-test", "rebuild-frontend", "pipeline_completed", cmd,
trigger_module_path=None)
hook_id = hook["id"]
conn1.close()
# Второе соединение — «рестарт», хук должен быть на месте
conn2 = init_db(db_path)
hooks = get_hooks(conn2, "kin", event="pipeline_completed", enabled_only=True)
hooks = get_hooks(conn2, "kin-test", event="pipeline_completed", enabled_only=True)
conn2.close()
assert len(hooks) == 1, "После пересоздания соединения хук должен оставаться в БД"
@ -595,6 +597,7 @@ class TestKIN053SeedDefaultHooks:
"""_seed_default_hooks создаёт rebuild-frontend хук при наличии проекта 'kin'.
Порядок: init_db create_project('kin') повторный init_db хук есть.
KIN-003: команда теперь scripts/rebuild-frontend.sh, не cd && npm run build.
"""
with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f:
db_path = f.name
@ -609,28 +612,33 @@ class TestKIN053SeedDefaultHooks:
assert len(hooks) == 1
assert hooks[0]["name"] == "rebuild-frontend"
assert "npm run build" in hooks[0]["command"]
assert "web/frontend" in hooks[0]["command"]
assert "rebuild-frontend.sh" in hooks[0]["command"]
finally:
os.unlink(db_path)
def test_seed_hook_has_correct_command(self):
"""Команда хука — точная строка с cd && npm run build."""
"""Команда хука использует динамический путь из projects.path (KIN-BIZ-004).
KIN-003: хук мигрирован на скрипт scripts/rebuild-frontend.sh
с trigger_module_path='web/frontend/*' для точного git-фильтра.
KIN-BIZ-004: путь берётся из projects.path, не захардкожен.
"""
project_path = "/projects/kin"
with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f:
db_path = f.name
try:
conn1 = init_db(db_path)
models.create_project(conn1, "kin", "Kin", "/projects/kin")
models.create_project(conn1, "kin", "Kin", project_path)
conn1.close()
conn2 = init_db(db_path)
hooks = get_hooks(conn2, "kin", event="pipeline_completed", enabled_only=False)
conn2.close()
assert hooks[0]["command"] == (
"cd /Users/grosfrumos/projects/kin/web/frontend && npm run build"
)
assert hooks[0]["trigger_module_path"] is None
assert hooks[0]["command"] == f"{project_path}/scripts/rebuild-frontend.sh"
assert hooks[0]["trigger_module_path"] == "web/frontend/*"
assert hooks[0]["working_dir"] == project_path
assert hooks[0]["timeout_seconds"] == 300
finally:
os.unlink(db_path)
@ -672,3 +680,225 @@ class TestKIN053SeedDefaultHooks:
assert other_hooks == []
finally:
os.unlink(db_path)
def test_seed_hook_migration_updates_existing_hook(self):
"""_seed_default_hooks мигрирует существующий хук используя динамический путь (KIN-BIZ-004).
Если rebuild-frontend уже существует со старой командой (cd && npm run build),
повторный init_db должен обновить его на scripts/rebuild-frontend.sh
с путём из projects.path.
"""
project_path = "/projects/kin"
with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f:
db_path = f.name
try:
conn1 = init_db(db_path)
models.create_project(conn1, "kin", "Kin", project_path)
# Вставляем старый хук вручную (имитация состояния до KIN-003)
old_cmd = f"cd {project_path}/web/frontend && npm run build"
conn1.execute(
"""INSERT INTO hooks (project_id, name, event, trigger_module_path, command,
working_dir, timeout_seconds, enabled)
VALUES ('kin', 'rebuild-frontend', 'pipeline_completed',
NULL, ?, NULL, 120, 1)""",
(old_cmd,),
)
conn1.commit()
conn1.close()
# Повторный init_db запускает _seed_default_hooks с миграцией
conn2 = init_db(db_path)
hooks = get_hooks(conn2, "kin", event="pipeline_completed", enabled_only=False)
conn2.close()
assert len(hooks) == 1
assert hooks[0]["command"] == f"{project_path}/scripts/rebuild-frontend.sh"
assert hooks[0]["trigger_module_path"] == "web/frontend/*"
assert hooks[0]["working_dir"] == project_path
assert hooks[0]["timeout_seconds"] == 300
finally:
os.unlink(db_path)
def test_seed_hook_uses_dynamic_path_not_hardcoded(self):
"""Команда хука содержит путь из projects.path, а не захардкоженный /Users/grosfrumos/... (KIN-BIZ-004).
Создаём проект с нестандартным путём и проверяем,
что хук использует именно этот путь.
"""
custom_path = "/srv/custom/kin-deployment"
with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f:
db_path = f.name
try:
conn1 = init_db(db_path)
models.create_project(conn1, "kin", "Kin", custom_path)
conn1.close()
conn2 = init_db(db_path)
hooks = get_hooks(conn2, "kin", event="pipeline_completed", enabled_only=False)
conn2.close()
assert len(hooks) == 1
assert hooks[0]["command"] == f"{custom_path}/scripts/rebuild-frontend.sh", (
"Команда должна использовать путь из projects.path, не захардкоженный"
)
assert hooks[0]["working_dir"] == custom_path, (
"working_dir должен совпадать с projects.path"
)
assert "/Users/grosfrumos" not in hooks[0]["command"], (
"Захардкоженный путь /Users/grosfrumos не должен присутствовать в команде"
)
finally:
os.unlink(db_path)
# ---------------------------------------------------------------------------
# KIN-003: changed_files — точный git-фильтр для trigger_module_path
# ---------------------------------------------------------------------------
class TestChangedFilesMatching:
"""Тесты для нового параметра changed_files в run_hooks() (KIN-003).
Когда changed_files передан trigger_module_path матчится по реальным
git-изменённым файлам, а не по task_modules из БД.
"""
def _make_proc(self, returncode=0, stdout="ok", stderr=""):
m = MagicMock()
m.returncode = returncode
m.stdout = stdout
m.stderr = stderr
return m
@pytest.fixture
def frontend_trigger_hook(self, conn):
"""Хук с trigger_module_path='web/frontend/*'."""
return create_hook(
conn, "vdol", "rebuild-frontend", "pipeline_completed",
"scripts/rebuild-frontend.sh",
trigger_module_path="web/frontend/*",
working_dir="/tmp",
)
@patch("core.hooks.subprocess.run")
def test_hook_fires_when_frontend_file_in_changed_files(
self, mock_run, conn, frontend_trigger_hook
):
"""Хук срабатывает, если среди changed_files есть файл в web/frontend/."""
mock_run.return_value = self._make_proc()
results = run_hooks(
conn, "vdol", "VDOL-001",
event="pipeline_completed",
task_modules=[],
changed_files=["web/frontend/App.vue", "core/models.py"],
)
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_frontend_file_in_changed_files(
self, mock_run, conn, frontend_trigger_hook
):
"""Хук НЕ срабатывает, если changed_files не содержит web/frontend/* файлов."""
mock_run.return_value = self._make_proc()
results = run_hooks(
conn, "vdol", "VDOL-001",
event="pipeline_completed",
task_modules=[],
changed_files=["core/models.py", "web/api.py", "agents/runner.py"],
)
assert len(results) == 0
mock_run.assert_not_called()
@patch("core.hooks.subprocess.run")
def test_hook_skipped_when_changed_files_is_empty_list(
self, mock_run, conn, frontend_trigger_hook
):
"""Пустой changed_files [] — хук с trigger_module_path не срабатывает."""
mock_run.return_value = self._make_proc()
results = run_hooks(
conn, "vdol", "VDOL-001",
event="pipeline_completed",
task_modules=[{"path": "web/frontend/App.vue", "name": "App"}],
changed_files=[], # git говорит: ничего не изменилось
)
assert len(results) == 0
mock_run.assert_not_called()
@patch("core.hooks.subprocess.run")
def test_changed_files_overrides_task_modules_match(
self, mock_run, conn, frontend_trigger_hook
):
"""Если changed_files передан, task_modules игнорируется для фильтрации.
task_modules содержит frontend-файл, но changed_files нет.
Хук не должен сработать: changed_files имеет приоритет.
"""
mock_run.return_value = self._make_proc()
results = run_hooks(
conn, "vdol", "VDOL-001",
event="pipeline_completed",
task_modules=[{"path": "web/frontend/App.vue", "name": "App"}],
changed_files=["core/models.py"], # нет frontend-файлов
)
assert len(results) == 0, (
"changed_files должен иметь приоритет над task_modules"
)
mock_run.assert_not_called()
@patch("core.hooks.subprocess.run")
def test_fallback_to_task_modules_when_changed_files_is_none(
self, mock_run, conn, frontend_trigger_hook
):
"""Если changed_files=None — используется старое поведение через task_modules."""
mock_run.return_value = self._make_proc()
results = run_hooks(
conn, "vdol", "VDOL-001",
event="pipeline_completed",
task_modules=[{"path": "web/frontend/App.vue", "name": "App"}],
changed_files=None, # не передан — fallback
)
assert len(results) == 1
assert results[0].name == "rebuild-frontend"
mock_run.assert_called_once()
@patch("core.hooks.subprocess.run")
def test_hook_without_trigger_fires_regardless_of_changed_files(
self, mock_run, conn
):
"""Хук без trigger_module_path всегда срабатывает, даже если changed_files=[].
Используется для хуков, которые должны запускаться после каждого pipeline.
"""
mock_run.return_value = self._make_proc()
create_hook(
conn, "vdol", "always-run", "pipeline_completed",
"echo always",
trigger_module_path=None,
working_dir="/tmp",
)
results = run_hooks(
conn, "vdol", "VDOL-001",
event="pipeline_completed",
task_modules=[],
changed_files=[], # пусто — но хук без фильтра всегда запустится
)
assert len(results) == 1
assert results[0].name == "always-run"
mock_run.assert_called_once()
@patch("core.hooks.subprocess.run")
def test_deep_frontend_path_matches_glob(
self, mock_run, conn, frontend_trigger_hook
):
"""Вложенные пути web/frontend/src/components/Foo.vue матчатся по 'web/frontend/*'."""
mock_run.return_value = self._make_proc()
results = run_hooks(
conn, "vdol", "VDOL-001",
event="pipeline_completed",
task_modules=[],
changed_files=["web/frontend/src/components/TaskCard.vue"],
)
assert len(results) == 1, (
"fnmatch должен рекурсивно матчить 'web/frontend/*' на вложенные пути"
)