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

20
tests/conftest.py Normal file
View file

@ -0,0 +1,20 @@
"""Shared pytest fixtures for Kin test suite."""
import pytest
from unittest.mock import patch
@pytest.fixture(autouse=True)
def _mock_check_claude_auth():
"""Авто-мок agents.runner.check_claude_auth для всех тестов.
run_pipeline() вызывает check_claude_auth() перед запуском агентов.
Без мока тесты, использующие side_effect-очереди для subprocess.run,
ломаются: первый вызов (auth-check) потребляет элемент очереди.
Тесты TestCheckClaudeAuth (test_runner.py) НЕ затрагиваются:
они вызывают check_claude_auth через напрямую импортированную ссылку
(bound at module load time), а не через agents.runner.check_claude_auth.
"""
with patch("agents.runner.check_claude_auth"):
yield

View file

@ -2,11 +2,13 @@
import pytest
from pathlib import Path
from unittest.mock import patch, MagicMock
from fastapi.testclient import TestClient
# Patch DB_PATH before importing app
import web.api as api_module
@pytest.fixture
def client(tmp_path):
db_path = tmp_path / "test.db"
@ -224,6 +226,30 @@ def test_run_not_found(client):
assert r.status_code == 404
def test_run_returns_503_when_claude_not_authenticated(client):
"""KIN-083: /run возвращает 503 с claude_auth_required если claude не залогинен."""
from agents.runner import ClaudeAuthError
with patch("agents.runner.check_claude_auth", side_effect=ClaudeAuthError("Claude CLI requires login. Run: claude login")):
r = client.post("/api/tasks/P1-001/run")
assert r.status_code == 503
body = r.json()
assert body["detail"]["error"] == "claude_auth_required"
assert body["detail"]["instructions"] == "Run: claude login"
assert "login" in body["detail"]["message"].lower()
def test_start_phase_returns_503_when_claude_not_authenticated(client):
"""KIN-083: /phases/start возвращает 503 с claude_auth_required если claude не залогинен."""
from agents.runner import ClaudeAuthError
with patch("agents.runner.check_claude_auth", side_effect=ClaudeAuthError("Claude CLI requires login. Run: claude login")):
r = client.post("/api/projects/p1/phases/start")
assert r.status_code == 503
body = r.json()
assert body["detail"]["error"] == "claude_auth_required"
assert body["detail"]["instructions"] == "Run: claude login"
assert "login" in body["detail"]["message"].lower()
def test_run_kin_038_without_allow_write(client):
"""Регрессионный тест KIN-038: allow_write удалён из схемы,
эндпоинт принимает запросы с пустым телом без этого параметра."""
@ -1583,3 +1609,89 @@ def test_kin_arch_003_deploy_operations_project_null_path_uses_cwd_none(client):
assert call_kwargs.get("cwd") is None, (
"KIN-ARCH-003: для operations-проектов без path, cwd должен быть None"
)
# ---------------------------------------------------------------------------
# Bootstrap endpoint — KIN-081
# ---------------------------------------------------------------------------
@pytest.fixture
def bootstrap_client(tmp_path):
"""TestClient без seed-данных, с отдельным DB_PATH."""
db_path = tmp_path / "bs_test.db"
api_module.DB_PATH = db_path
from web.api import app
return TestClient(app), tmp_path
def test_bootstrap_endpoint_invalid_path_returns_400(bootstrap_client):
"""KIN-081: bootstrap возвращает 400 если путь не существует."""
client, _ = bootstrap_client
r = client.post("/api/bootstrap", json={
"id": "newproj", "name": "New Project", "path": "/nonexistent/path/that/does/not/exist"
})
assert r.status_code == 400
assert "not a directory" in r.json()["detail"].lower()
def test_bootstrap_endpoint_duplicate_id_returns_409(bootstrap_client, tmp_path):
"""KIN-081: bootstrap возвращает 409 если проект с таким ID уже существует."""
client, _ = bootstrap_client
proj_dir = tmp_path / "myproj"
proj_dir.mkdir()
# Create project first
client.post("/api/projects", json={"id": "existing", "name": "Existing", "path": str(proj_dir)})
# Try bootstrap with same ID
r = client.post("/api/bootstrap", json={
"id": "existing", "name": "Same ID", "path": str(proj_dir)
})
assert r.status_code == 409
assert "already exists" in r.json()["detail"]
def test_bootstrap_endpoint_rollback_on_save_error(bootstrap_client, tmp_path):
"""KIN-081: при ошибке в save_to_db проект удаляется (rollback), возвращается 500."""
client, _ = bootstrap_client
proj_dir = tmp_path / "rollbackproj"
proj_dir.mkdir()
from core.db import init_db
from core import models as _models
def _save_create_then_fail(conn, project_id, name, path, *args, **kwargs):
# Simulate partial write: project row created, then error
_models.create_project(conn, project_id, name, path)
raise RuntimeError("simulated DB error after project created")
with patch("web.api.save_to_db", side_effect=_save_create_then_fail):
r = client.post("/api/bootstrap", json={
"id": "rollbackproj", "name": "Rollback Test", "path": str(proj_dir)
})
assert r.status_code == 500
assert "Bootstrap failed" in r.json()["detail"]
# Project must NOT remain in DB (rollback was executed)
conn = init_db(api_module.DB_PATH)
assert _models.get_project(conn, "rollbackproj") is None
conn.close()
def test_bootstrap_endpoint_success(bootstrap_client, tmp_path):
"""KIN-081: успешный bootstrap возвращает 200 с project и counts."""
client, _ = bootstrap_client
proj_dir = tmp_path / "goodproj"
proj_dir.mkdir()
(proj_dir / "requirements.txt").write_text("fastapi\n")
with patch("web.api.find_vault_root", return_value=None):
r = client.post("/api/bootstrap", json={
"id": "goodproj", "name": "Good Project", "path": str(proj_dir)
})
assert r.status_code == 200
data = r.json()
assert data["project"]["id"] == "goodproj"
assert "modules_count" in data
assert "decisions_count" in data
assert "tasks_count" in data

View file

@ -179,12 +179,13 @@ class TestAutoApprove:
class TestAutoRerunOnPermissionDenied:
"""Runner повторяет шаг при permission issues, останавливается по лимиту (1 retry)."""
@patch("agents.runner._get_changed_files", return_value=[])
@patch("agents.runner._run_autocommit")
@patch("agents.runner._run_learning_extraction")
@patch("core.followup.generate_followups")
@patch("agents.runner.run_hooks")
@patch("agents.runner.subprocess.run")
def test_auto_mode_retries_on_permission_error(self, mock_run, mock_hooks, mock_followup, mock_learn, mock_autocommit, conn):
def test_auto_mode_retries_on_permission_error(self, mock_run, mock_hooks, mock_followup, mock_learn, mock_autocommit, mock_changed_files, conn):
"""Auto-режим: при permission denied runner делает 1 retry с allow_write=True."""
mock_run.side_effect = [
_mock_permission_denied(), # 1-й вызов: permission error
@ -261,12 +262,13 @@ class TestAutoRerunOnPermissionDenied:
task = models.get_task(conn, "VDOL-001")
assert task["status"] == "blocked"
@patch("agents.runner._get_changed_files", return_value=[])
@patch("agents.runner._run_autocommit")
@patch("agents.runner._run_learning_extraction")
@patch("core.followup.generate_followups")
@patch("agents.runner.run_hooks")
@patch("agents.runner.subprocess.run")
def test_subsequent_steps_use_allow_write_after_retry(self, mock_run, mock_hooks, mock_followup, mock_learn, mock_autocommit, conn):
def test_subsequent_steps_use_allow_write_after_retry(self, mock_run, mock_hooks, mock_followup, mock_learn, mock_autocommit, mock_changed_files, conn):
"""После успешного retry все следующие шаги тоже используют allow_write."""
mock_run.side_effect = [
_mock_permission_denied(), # Шаг 1: permission error

View file

@ -114,6 +114,26 @@ def test_detect_modules_empty(tmp_path):
assert detect_modules(tmp_path) == []
def test_detect_modules_deduplication_by_name(tmp_path):
"""KIN-081: detect_modules дедуплицирует по имени (не по имени+путь).
Если два разных scan_dir дают одноимённые модули (например, frontend/src/components
и backend/src/components), результат содержит только первый.
Это соответствует UNIQUE constraint (project_id, name) в таблице modules.
"""
fe_comp = tmp_path / "frontend" / "src" / "components"
fe_comp.mkdir(parents=True)
(fe_comp / "App.vue").write_text("<template></template>")
be_comp = tmp_path / "backend" / "src" / "components"
be_comp.mkdir(parents=True)
(be_comp / "Service.ts").write_text("export class Service {}")
modules = detect_modules(tmp_path)
names = [m["name"] for m in modules]
assert names.count("components") == 1
def test_detect_modules_backend_pg(tmp_path):
"""Test detection in backend-pg/src/ pattern (like vdolipoperek)."""
src = tmp_path / "backend-pg" / "src" / "services"

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/*' на вложенные пути"
)

View file

@ -280,6 +280,87 @@ def test_add_and_get_modules(conn):
assert len(mods) == 1
def test_add_module_created_true_for_new_module(conn):
"""KIN-081: add_module возвращает _created=True для нового модуля (INSERT)."""
models.create_project(conn, "p1", "P1", "/p1")
m = models.add_module(conn, "p1", "api", "backend", "src/api/")
assert m["_created"] is True
assert m["name"] == "api"
def test_add_module_created_false_for_duplicate_name(conn):
"""KIN-081: add_module возвращает _created=False при дублировании по имени (INSERT OR IGNORE).
UNIQUE constraint (project_id, name). Второй INSERT с тем же name игнорируется,
возвращается существующая запись с _created=False.
"""
models.create_project(conn, "p1", "P1", "/p1")
m1 = models.add_module(conn, "p1", "api", "backend", "src/api/")
assert m1["_created"] is True
# Same name, different path — should be ignored
m2 = models.add_module(conn, "p1", "api", "frontend", "src/api-v2/")
assert m2["_created"] is False
assert m2["name"] == "api"
# Only one module in DB
assert len(models.get_modules(conn, "p1")) == 1
def test_add_module_duplicate_returns_original_row(conn):
"""KIN-081: при дублировании add_module возвращает оригинальную запись (не новые данные)."""
models.create_project(conn, "p1", "P1", "/p1")
m1 = models.add_module(conn, "p1", "api", "backend", "src/api/",
description="original desc")
m2 = models.add_module(conn, "p1", "api", "frontend", "src/api-v2/",
description="new desc")
# Should return original row, not updated one
assert m2["type"] == "backend"
assert m2["description"] == "original desc"
assert m2["id"] == m1["id"]
def test_add_module_same_name_different_projects_are_independent(conn):
"""KIN-081: два проекта могут иметь одноимённые модули — UNIQUE per project_id."""
models.create_project(conn, "p1", "P1", "/p1")
models.create_project(conn, "p2", "P2", "/p2")
m1 = models.add_module(conn, "p1", "api", "backend", "src/api/")
m2 = models.add_module(conn, "p2", "api", "backend", "src/api/")
assert m1["_created"] is True
assert m2["_created"] is True
assert m1["id"] != m2["id"]
# -- delete_project --
def test_delete_project_removes_project_record(conn):
"""KIN-081: delete_project удаляет запись из таблицы projects."""
models.create_project(conn, "p1", "P1", "/p1")
assert models.get_project(conn, "p1") is not None
models.delete_project(conn, "p1")
assert models.get_project(conn, "p1") is None
def test_delete_project_cascades_to_related_tables(conn):
"""KIN-081: delete_project удаляет связанные modules, decisions, tasks, agent_logs."""
models.create_project(conn, "p1", "P1", "/p1")
models.add_module(conn, "p1", "api", "backend", "src/api/")
models.add_decision(conn, "p1", "gotcha", "Bug X", "desc")
models.create_task(conn, "P1-001", "p1", "Task")
models.log_agent_run(conn, "p1", "developer", "implement", task_id="P1-001")
models.delete_project(conn, "p1")
assert conn.execute("SELECT COUNT(*) FROM modules WHERE project_id='p1'").fetchone()[0] == 0
assert conn.execute("SELECT COUNT(*) FROM decisions WHERE project_id='p1'").fetchone()[0] == 0
assert conn.execute("SELECT COUNT(*) FROM tasks WHERE project_id='p1'").fetchone()[0] == 0
assert conn.execute("SELECT COUNT(*) FROM agent_logs WHERE project_id='p1'").fetchone()[0] == 0
def test_delete_project_nonexistent_does_not_raise(conn):
"""KIN-081: delete_project на несуществующий проект не бросает исключение."""
models.delete_project(conn, "nonexistent")
# -- Agent Logs --
def test_log_agent_run(conn):

View file

@ -9,7 +9,8 @@ from core import models
from agents.runner import (
run_agent, run_pipeline, run_audit, _try_parse_json, _run_learning_extraction,
_build_claude_env, _resolve_claude_cmd, _EXTRA_PATH_DIRS, _run_autocommit,
_parse_agent_blocked,
_parse_agent_blocked, _get_changed_files, _save_sysadmin_output,
check_claude_auth, ClaudeAuthError,
)
@ -400,10 +401,11 @@ class TestAutoMode:
class TestRetryOnPermissionError:
@patch("agents.runner._run_autocommit")
@patch("agents.runner._run_learning_extraction")
@patch("agents.runner._get_changed_files") # KIN-003: prevents git subprocess calls
@patch("core.followup.generate_followups")
@patch("agents.runner.run_hooks")
@patch("agents.runner.subprocess.run")
def test_retry_on_permission_error_auto_mode(self, mock_run, mock_hooks, mock_followup, mock_learn, mock_autocommit, conn):
def test_retry_on_permission_error_auto_mode(self, mock_run, mock_hooks, mock_followup, mock_get_files, mock_learn, mock_autocommit, conn):
"""Auto mode: retry при permission error должен срабатывать."""
permission_fail = _mock_claude_failure("permission denied: cannot write file")
retry_success = _mock_claude_success({"result": "fixed"})
@ -412,6 +414,7 @@ class TestRetryOnPermissionError:
mock_hooks.return_value = []
mock_followup.return_value = {"created": [], "pending_actions": []}
mock_learn.return_value = {"added": 0, "skipped": 0}
mock_get_files.return_value = []
models.update_project(conn, "vdol", execution_mode="auto_complete")
steps = [{"role": "debugger", "brief": "find"}]
@ -2026,3 +2029,366 @@ class TestSaveSysadminOutput:
}
result = _save_sysadmin_output(ops_conn, "srv", "SRV-001", {"raw_output": json.dumps(output)})
assert result["modules_added"] == 0
# ---------------------------------------------------------------------------
# KIN-003: _get_changed_files — вычисление изменённых git-файлов
# ---------------------------------------------------------------------------
class TestGetChangedFiles:
"""Тесты для _get_changed_files(project_path) из agents/runner.py (KIN-003)."""
@patch("agents.runner.subprocess.run")
def test_returns_files_from_git_diff(self, mock_run):
"""Возвращает список файлов из git diff --name-only."""
proc = MagicMock()
proc.returncode = 0
proc.stdout = "web/frontend/App.vue\ncore/models.py\n"
mock_run.return_value = proc
result = _get_changed_files("/tmp/fake-project")
assert isinstance(result, list)
assert "web/frontend/App.vue" in result
assert "core/models.py" in result
@patch("agents.runner.subprocess.run")
def test_returns_empty_list_on_exception(self, mock_run):
"""При ошибке git (не найден, не репозиторий) возвращает []."""
mock_run.side_effect = Exception("git not found")
result = _get_changed_files("/tmp/fake-project")
assert result == []
@patch("agents.runner.subprocess.run")
def test_deduplicates_files_from_multiple_git_commands(self, mock_run):
"""Один файл из нескольких git-команд появляется в результате только один раз."""
proc = MagicMock()
proc.returncode = 0
proc.stdout = "web/frontend/App.vue\n"
mock_run.return_value = proc # все 3 git-команды возвращают одно и то же
result = _get_changed_files("/tmp/fake-project")
assert result.count("web/frontend/App.vue") == 1, (
"Дубликаты из разных git-команд должны дедуплицироваться"
)
@patch("agents.runner.subprocess.run")
def test_combines_files_from_different_git_commands(self, mock_run):
"""Файлы из трёх разных git-команд объединяются в один список."""
mock_run.side_effect = [
MagicMock(returncode=0, stdout="web/frontend/App.vue\n"),
MagicMock(returncode=0, stdout="core/models.py\n"),
MagicMock(returncode=0, stdout="agents/runner.py\n"),
]
result = _get_changed_files("/tmp/fake-project")
assert "web/frontend/App.vue" in result
assert "core/models.py" in result
assert "agents/runner.py" in result
@patch("agents.runner.subprocess.run")
def test_skips_failed_git_command_and_continues(self, mock_run):
"""Упавшая git-команда (returncode != 0) не блокирует остальные."""
fail_proc = MagicMock(returncode=1, stdout="")
success_proc = MagicMock(returncode=0, stdout="core/models.py\n")
mock_run.side_effect = [fail_proc, success_proc, fail_proc]
result = _get_changed_files("/tmp/fake-project")
assert "core/models.py" in result
@patch("agents.runner.subprocess.run")
def test_strips_whitespace_from_file_paths(self, mock_run):
"""Пробелы и переносы вокруг имён файлов обрезаются."""
proc = MagicMock()
proc.returncode = 0
proc.stdout = " web/frontend/App.vue \n core/models.py \n"
mock_run.return_value = proc
result = _get_changed_files("/tmp/fake-project")
assert "web/frontend/App.vue" in result
assert "core/models.py" in result
assert " web/frontend/App.vue " not in result
# ---------------------------------------------------------------------------
# KIN-003: run_pipeline — передача changed_files в run_hooks
# ---------------------------------------------------------------------------
class TestPipelineChangedFiles:
"""Интеграционные тесты: pipeline вычисляет changed_files и передаёт в run_hooks."""
@patch("agents.runner._get_changed_files")
@patch("agents.runner.run_hooks")
@patch("agents.runner.subprocess.run")
def test_pipeline_passes_changed_files_to_run_hooks(
self, mock_run, mock_hooks, mock_get_files
):
"""run_pipeline передаёт changed_files в run_hooks(event='pipeline_completed').
Используем проект с path='/tmp' (реальная директория), чтобы
_get_changed_files был вызван.
"""
c = init_db(":memory:")
models.create_project(c, "kin-tmp", "KinTmp", "/tmp", tech_stack=["vue3"])
models.create_task(c, "KT-001", "kin-tmp", "Fix bug")
mock_run.return_value = _mock_claude_success({"result": "done"})
mock_hooks.return_value = []
mock_get_files.return_value = ["web/frontend/App.vue", "core/models.py"]
steps = [{"role": "debugger", "brief": "find bug"}]
result = run_pipeline(c, "KT-001", steps)
c.close()
assert result["success"] is True
mock_get_files.assert_called_once_with("/tmp")
# pipeline_completed call должен содержать changed_files
pipeline_calls = [
call for call in mock_hooks.call_args_list
if call.kwargs.get("event") == "pipeline_completed"
]
assert len(pipeline_calls) >= 1
kw = pipeline_calls[0].kwargs
assert kw.get("changed_files") == ["web/frontend/App.vue", "core/models.py"]
@patch("agents.runner._run_autocommit")
@patch("core.hooks.subprocess.run")
@patch("agents.runner._run_claude")
def test_pipeline_completes_when_frontend_hook_build_fails(
self, mock_run_claude, mock_hook_run, mock_autocommit
):
"""Ошибка сборки фронтенда (exitcode=1) не роняет pipeline (AC #3 KIN-003).
Хук выполняется и возвращает failure, но pipeline.status = 'completed'
и результат run_pipeline['success'] = True.
Примечание: патчим _run_claude (не subprocess.run) чтобы не конфликтовать
с core.hooks.subprocess.run оба ссылаются на один и тот же subprocess.run.
"""
from core.hooks import create_hook
c = init_db(":memory:")
models.create_project(c, "kin-build", "KinBuild", "/tmp", tech_stack=["vue3"])
models.create_task(c, "KB-001", "kin-build", "Add feature")
create_hook(
c, "kin-build", "rebuild-frontend", "pipeline_completed",
"/tmp/rebuild.sh",
trigger_module_path=None,
working_dir="/tmp",
)
mock_run_claude.return_value = {
"output": "done", "returncode": 0, "error": None,
"empty_output": False, "tokens_used": None, "cost_usd": None,
}
# npm run build завершается с ошибкой
fail_proc = MagicMock()
fail_proc.returncode = 1
fail_proc.stdout = ""
fail_proc.stderr = "Error: Cannot find module './App'"
mock_hook_run.return_value = fail_proc
steps = [{"role": "tester", "brief": "test feature"}]
result = run_pipeline(c, "KB-001", steps)
assert result["success"] is True, (
"Ошибка сборки хука не должна ронять pipeline"
)
pipe = c.execute(
"SELECT status FROM pipelines WHERE task_id='KB-001'"
).fetchone()
assert pipe["status"] == "completed"
c.close()
@patch("agents.runner._run_autocommit")
@patch("agents.runner.subprocess.run")
def test_pipeline_changed_files_is_none_when_project_path_missing(
self, mock_run, mock_autocommit, conn
):
"""Если путь проекта не существует, changed_files=None передаётся в run_hooks.
Хуки по-прежнему запускаются, но без git-фильтра (task_modules fallback).
"""
# vdol path = ~/projects/vdolipoperek (не существует в CI)
# Хук без trigger_module_path должен сработать
from core.hooks import create_hook, get_hook_logs
create_hook(conn, "vdol", "always", "pipeline_completed",
"echo ok", trigger_module_path=None, working_dir="/tmp")
mock_run.return_value = _mock_claude_success({"result": "done"})
build_proc = MagicMock(returncode=0, stdout="ok", stderr="")
with patch("core.hooks.subprocess.run", return_value=build_proc):
steps = [{"role": "tester", "brief": "test"}]
result = run_pipeline(conn, "VDOL-001", steps)
assert result["success"] is True
# Хук без фильтра должен был выполниться
logs = get_hook_logs(conn, project_id="vdol")
assert len(logs) >= 1
# ---------------------------------------------------------------------------
# _save_sysadmin_output — KIN-081
# ---------------------------------------------------------------------------
class TestSaveSysadminOutput:
def test_modules_added_count_for_new_modules(self, conn):
"""KIN-081: _save_sysadmin_output считает modules_added правильно через _created."""
result = {
"raw_output": json.dumps({
"modules": [
{"name": "nginx", "type": "infra", "path": "/etc/nginx",
"description": "Web server"},
{"name": "postgres", "type": "infra", "path": "/var/lib/postgresql",
"description": "Database"},
],
"decisions": [],
})
}
counts = _save_sysadmin_output(conn, "vdol", "VDOL-001", result)
assert counts["modules_added"] == 2
assert counts["modules_skipped"] == 0
def test_modules_skipped_count_for_duplicate_names(self, conn):
"""KIN-081: повторный вызов с теми же модулями: added=0, skipped=2."""
raw = json.dumps({
"modules": [
{"name": "nginx", "type": "infra", "path": "/etc/nginx"},
{"name": "postgres", "type": "infra", "path": "/var/lib/postgresql"},
],
"decisions": [],
})
result = {"raw_output": raw}
# First call — adds
_save_sysadmin_output(conn, "vdol", "VDOL-001", result)
# Second call — all duplicates
counts = _save_sysadmin_output(conn, "vdol", "VDOL-001", result)
assert counts["modules_added"] == 0
assert counts["modules_skipped"] == 2
def test_empty_output_returns_zeros(self, conn):
"""_save_sysadmin_output с не-JSON строкой возвращает нули."""
counts = _save_sysadmin_output(conn, "vdol", "VDOL-001",
{"raw_output": "Agent completed the task."})
assert counts == {
"decisions_added": 0, "decisions_skipped": 0,
"modules_added": 0, "modules_skipped": 0,
}
def test_decisions_added_and_skipped(self, conn):
"""_save_sysadmin_output дедуплицирует decisions через add_decision_if_new."""
raw = json.dumps({
"modules": [],
"decisions": [
{"type": "convention", "title": "Use WAL mode",
"description": "PRAGMA journal_mode=WAL for SQLite"},
],
})
result = {"raw_output": raw}
counts1 = _save_sysadmin_output(conn, "vdol", "VDOL-001", result)
assert counts1["decisions_added"] == 1
assert counts1["decisions_skipped"] == 0
counts2 = _save_sysadmin_output(conn, "vdol", "VDOL-001", result)
assert counts2["decisions_added"] == 0
assert counts2["decisions_skipped"] == 1
# ---------------------------------------------------------------------------
# check_claude_auth
# ---------------------------------------------------------------------------
class TestCheckClaudeAuth:
"""Tests for check_claude_auth() — Claude CLI login healthcheck."""
@patch("agents.runner.subprocess.run")
def test_ok_when_returncode_zero(self, mock_run):
"""Не бросает исключение при returncode=0 и корректном JSON."""
mock = MagicMock()
mock.stdout = json.dumps({"result": "ok"})
mock.stderr = ""
mock.returncode = 0
mock_run.return_value = mock
check_claude_auth() # должна вернуть None без исключений
@patch("agents.runner.subprocess.run")
def test_not_logged_in_via_string_in_stdout(self, mock_run):
"""Бросает ClaudeAuthError при 'Not logged in' в stdout."""
mock = MagicMock()
mock.stdout = "Not logged in"
mock.stderr = ""
mock.returncode = 1
mock_run.return_value = mock
with pytest.raises(ClaudeAuthError) as exc_info:
check_claude_auth()
assert "login" in str(exc_info.value).lower()
@patch("agents.runner.subprocess.run")
def test_not_logged_in_case_insensitive(self, mock_run):
"""Бросает ClaudeAuthError при 'not logged in' в любом регистре."""
mock = MagicMock()
mock.stdout = ""
mock.stderr = "Error: NOT LOGGED IN to Claude"
mock.returncode = 1
mock_run.return_value = mock
with pytest.raises(ClaudeAuthError):
check_claude_auth()
@patch("agents.runner.subprocess.run")
def test_not_logged_in_via_string_in_stderr(self, mock_run):
"""Бросает ClaudeAuthError при 'Not logged in' в stderr."""
mock = MagicMock()
mock.stdout = ""
mock.stderr = "Error: Not logged in to Claude"
mock.returncode = 1
mock_run.return_value = mock
with pytest.raises(ClaudeAuthError):
check_claude_auth()
@patch("agents.runner.subprocess.run")
def test_not_logged_in_via_nonzero_returncode(self, mock_run):
"""Бросает ClaudeAuthError при ненулевом returncode (без 'Not logged in' текста)."""
mock = MagicMock()
mock.stdout = ""
mock.stderr = "Some other error"
mock.returncode = 1
mock_run.return_value = mock
with pytest.raises(ClaudeAuthError):
check_claude_auth()
@patch("agents.runner.subprocess.run")
def test_not_logged_in_via_is_error_in_json(self, mock_run):
"""Бросает ClaudeAuthError при is_error=true в JSON даже с returncode=0."""
mock = MagicMock()
mock.stdout = json.dumps({"is_error": True, "result": "authentication required"})
mock.stderr = ""
mock.returncode = 0
mock_run.return_value = mock
with pytest.raises(ClaudeAuthError):
check_claude_auth()
@patch("agents.runner.subprocess.run", side_effect=FileNotFoundError)
def test_raises_when_cli_not_found(self, mock_run):
"""При FileNotFoundError бросает ClaudeAuthError с понятным сообщением."""
with pytest.raises(ClaudeAuthError) as exc_info:
check_claude_auth()
assert "PATH" in str(exc_info.value) or "not found" in str(exc_info.value).lower()
@patch("agents.runner.subprocess.run", side_effect=subprocess.TimeoutExpired(cmd="claude", timeout=10))
def test_ok_when_timeout(self, mock_run):
"""При TimeoutExpired не бросает исключение (не блокируем на timeout)."""
check_claude_auth() # должна вернуть None без исключений