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:
parent
a80679ae72
commit
bfc8f1c0bb
18 changed files with 1390 additions and 57 deletions
|
|
@ -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 без исключений
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue