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

@ -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 без исключений