From 4188384f1b5c24c7e29b99ab0ed8959188a26f0b Mon Sep 17 00:00:00 2001 From: Gros Frumos Date: Mon, 16 Mar 2026 09:30:00 +0200 Subject: [PATCH] =?UTF-8?q?kin:=20KIN-059=20Workflow=20new=5Fproject=20?= =?UTF-8?q?=D1=81=20=D0=B2=D1=8B=D0=B1=D0=BE=D1=80=D0=BE=D0=BC=20=D0=BA?= =?UTF-8?q?=D0=BE=D0=BC=D0=B0=D0=BD=D0=B4=D1=8B.=20=D0=9F=D1=80=D0=B8=20?= =?UTF-8?q?=D1=81=D0=BE=D0=B7=D0=B4=D0=B0=D0=BD=D0=B8=D0=B8=20=D0=BD=D0=BE?= =?UTF-8?q?=D0=B2=D0=BE=D0=B3=D0=BE=20=D0=BF=D1=80=D0=BE=D0=B5=D0=BA=D1=82?= =?UTF-8?q?=D0=B0=20=D1=87=D0=B5=D1=80=D0=B5=D0=B7=20GUI=20=D0=B8=D0=BB?= =?UTF-8?q?=D0=B8=20CLI=20=D0=B4=D0=B8=D1=80=D0=B5=D0=BA=D1=82=D0=BE=D1=80?= =?UTF-8?q?=20=D0=BE=D0=BF=D0=B8=D1=81=D1=8B=D0=B2=D0=B0=D0=B5=D1=82=20?= =?UTF-8?q?=D0=BF=D1=80=D0=BE=D0=B5=D0=BA=D1=82=20=D1=81=D0=B2=D0=BE=D0=B1?= =?UTF-8?q?=D0=BE=D0=B4=D0=BD=D1=8B=D0=BC=20=D1=82=D0=B5=D0=BA=D1=81=D1=82?= =?UTF-8?q?=D0=BE=D0=BC=20=D0=B8=20=D0=B2=D1=8B=D0=B1=D0=B8=D1=80=D0=B0?= =?UTF-8?q?=D0=B5=D1=82=20=D0=B3=D0=B0=D0=BB=D0=BE=D1=87=D0=BA=D0=B0=D0=BC?= =?UTF-8?q?=D0=B8=20=D0=BA=D0=B0=D0=BA=D0=B8=D0=B5=20=D1=8D=D1=82=D0=B0?= =?UTF-8?q?=D0=BF=D1=8B=20research=20=D0=BD=D1=83=D0=B6=D0=BD=D1=8B:=20?= =?UTF-8?q?=E2=98=90=20Business=20analyst=20(=D0=B1=D0=B8=D0=B7=D0=BD?= =?UTF-8?q?=D0=B5=D1=81-=D0=BC=D0=BE=D0=B4=D0=B5=D0=BB=D1=8C,=20=D0=B0?= =?UTF-8?q?=D1=83=D0=B4=D0=B8=D1=82=D0=BE=D1=80=D0=B8=D1=8F,=20=D0=BC?= =?UTF-8?q?=D0=BE=D0=BD=D0=B5=D1=82=D0=B8=D0=B7=D0=B0=D1=86=D0=B8=D1=8F)?= =?UTF-8?q?=20=E2=98=90=20Market=20researcher=20(=D0=BA=D0=BE=D0=BD=D0=BA?= =?UTF-8?q?=D1=83=D1=80=D0=B5=D0=BD=D1=82=D1=8B,=20=D0=BD=D0=B8=D1=88?= =?UTF-8?q?=D0=B0,=20=D0=BE=D1=82=D0=B7=D1=8B=D0=B2=D1=8B,=20=D1=81=D0=B8?= =?UTF-8?q?=D0=BB=D1=8C=D0=BD=D1=8B=D0=B5/=D1=81=D0=BB=D0=B0=D0=B1=D1=8B?= =?UTF-8?q?=D0=B5=20=D1=81=D1=82=D0=BE=D1=80=D0=BE=D0=BD=D1=8B)=20?= =?UTF-8?q?=E2=98=90=20Legal=20researcher=20(=D1=8E=D1=80=D0=B8=D1=81?= =?UTF-8?q?=D0=B4=D0=B8=D0=BA=D1=86=D0=B8=D1=8F,=20=D0=BB=D0=B8=D1=86?= =?UTF-8?q?=D0=B5=D0=BD=D0=B7=D0=B8=D0=B8,=20KYC/AML,=20GDPR)=20=E2=98=90?= =?UTF-8?q?=20Tech=20researcher=20(API,=20=D0=BE=D0=B3=D1=80=D0=B0=D0=BD?= =?UTF-8?q?=D0=B8=D1=87=D0=B5=D0=BD=D0=B8=D1=8F,=20=D1=81=D1=82=D0=BE?= =?UTF-8?q?=D0=B8=D0=BC=D0=BE=D1=81=D1=82=D1=8C,=20=D0=B0=D0=BB=D1=8C?= =?UTF-8?q?=D1=82=D0=B5=D1=80=D0=BD=D0=B0=D1=82=D0=B8=D0=B2=D1=8B)=20?= =?UTF-8?q?=E2=98=90=20UX=20designer=20(=D0=B0=D0=BD=D0=B0=D0=BB=D0=B8?= =?UTF-8?q?=D0=B7=20UX=20=D0=BA=D0=BE=D0=BD=D0=BA=D1=83=D1=80=D0=B5=D0=BD?= =?UTF-8?q?=D1=82=D0=BE=D0=B2,=20user=20journey,=20wireframes)=20=E2=98=90?= =?UTF-8?q?=20Marketer=20(=D1=81=D1=82=D1=80=D0=B0=D1=82=D0=B5=D0=B3=D0=B8?= =?UTF-8?q?=D1=8F=20=D0=BF=D1=80=D0=BE=D0=B4=D0=B2=D0=B8=D0=B6=D0=B5=D0=BD?= =?UTF-8?q?=D0=B8=D1=8F,=20SEO,=20conversion-=D0=BF=D0=B0=D1=82=D1=82?= =?UTF-8?q?=D0=B5=D1=80=D0=BD=D1=8B)=20=E2=98=90=20Architect=20(blueprint?= =?UTF-8?q?=20=D0=BD=D0=B0=20=D0=BE=D1=81=D0=BD=D0=BE=D0=B2=D0=B5=20=D0=BE?= =?UTF-8?q?=D0=B4=D0=BE=D0=B1=D1=80=D0=B5=D0=BD=D0=BD=D1=8B=D1=85=20resear?= =?UTF-8?q?ch'=D0=B5=D0=B9)=20=E2=80=94=20=D0=B2=D1=81=D0=B5=D0=B3=D0=B4?= =?UTF-8?q?=D0=B0=20=D0=BF=D0=BE=D1=81=D0=BB=D0=B5=D0=B4=D0=BD=D0=B8=D0=B9?= =?UTF-8?q?=20Architect=20=D0=B2=D0=BA=D0=BB=D1=8E=D1=87=D0=B0=D0=B5=D1=82?= =?UTF-8?q?=D1=81=D1=8F=20=D0=B0=D0=B2=D1=82=D0=BE=D0=BC=D0=B0=D1=82=D0=B8?= =?UTF-8?q?=D1=87=D0=B5=D1=81=D0=BA=D0=B8=20=D0=B5=D1=81=D0=BB=D0=B8=20?= =?UTF-8?q?=D0=B2=D1=8B=D0=B1=D1=80=D0=B0=D0=BD=20=D1=85=D0=BE=D1=82=D1=8F?= =?UTF-8?q?=20=D0=B1=D1=8B=20=D0=BE=D0=B4=D0=B8=D0=BD=20researcher.=20?= =?UTF-8?q?=D0=9A=D0=B0=D0=B6=D0=B4=D1=8B=D0=B9=20=D0=B2=D1=8B=D0=B1=D1=80?= =?UTF-8?q?=D0=B0=D0=BD=D0=BD=D1=8B=D0=B9=20=D1=8D=D1=82=D0=B0=D0=BF=20?= =?UTF-8?q?=E2=80=94=20=D0=BE=D1=82=D0=B4=D0=B5=D0=BB=D1=8C=D0=BD=D0=B0?= =?UTF-8?q?=D1=8F=20=D0=B7=D0=B0=D0=B4=D0=B0=D1=87=D0=B0=20=D0=BD=D0=B0=20?= =?UTF-8?q?review.=20=D0=94=D0=B8=D1=80=D0=B5=D0=BA=D1=82=D0=BE=D1=80=20?= =?UTF-8?q?=D0=BE=D0=B4=D0=BE=D0=B1=D1=80=D1=8F=D0=B5=D1=82,=20=D0=BE?= =?UTF-8?q?=D1=82=D0=BA=D0=BB=D0=BE=D0=BD=D1=8F=D0=B5=D1=82,=20=D0=B8?= =?UTF-8?q?=D0=BB=D0=B8=20=D0=BF=D1=80=D0=BE=D1=81=D0=B8=D1=82=20=D0=B4?= =?UTF-8?q?=D0=BE=D0=B8=D1=81=D1=81=D0=BB=D0=B5=D0=B4=D0=BE=D0=B2=D0=B0?= =?UTF-8?q?=D1=82=D1=8C=20(Revise).=20=D0=A1=D0=BB=D0=B5=D0=B4=D1=83=D1=8E?= =?UTF-8?q?=D1=89=D0=B8=D0=B9=20=D1=8D=D1=82=D0=B0=D0=BF=20=D1=82=D0=BE?= =?UTF-8?q?=D0=BB=D1=8C=D0=BA=D0=BE=20=D0=BF=D0=BE=D1=81=D0=BB=D0=B5=20app?= =?UTF-8?q?rove=20=D0=BF=D1=80=D0=B5=D0=B4=D1=8B=D0=B4=D1=83=D1=89=D0=B5?= =?UTF-8?q?=D0=B3=D0=BE.=20GUI:=20=D1=84=D0=BE=D1=80=D0=BC=D0=B0=20'New=20?= =?UTF-8?q?Project'=20=D1=81=20=D0=BE=D0=BF=D0=B8=D1=81=D0=B0=D0=BD=D0=B8?= =?UTF-8?q?=D0=B5=D0=BC=20+=20=D1=87=D0=B5=D0=BA=D0=B1=D0=BE=D0=BA=D1=81?= =?UTF-8?q?=D1=8B=20=D1=80=D0=BE=D0=BB=D0=B5=D0=B9=20+=20=D0=BA=D0=BD?= =?UTF-8?q?=D0=BE=D0=BF=D0=BA=D0=B0=20'Start=20Research'.=20CLI:=20kin=20n?= =?UTF-8?q?ew-project=20'=D0=BE=D0=BF=D0=B8=D1=81=D0=B0=D0=BD=D0=B8=D0=B5'?= =?UTF-8?q?=20--roles=20'business,market,tech,architect'?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- core/db.py | 7 + core/phases.py | 1 + tests/test_api_phases.py | 314 +++++++++++++++++++++ tests/test_phases.py | 369 +++++++++++++++++++++++++ web/api.py | 63 +++++ web/frontend/src/api.ts | 3 + web/frontend/src/views/ProjectView.vue | 72 ++++- 7 files changed, 820 insertions(+), 9 deletions(-) create mode 100644 tests/test_api_phases.py create mode 100644 tests/test_phases.py diff --git a/core/db.py b/core/db.py index abc0526..61e7afc 100644 --- a/core/db.py +++ b/core/db.py @@ -113,6 +113,7 @@ CREATE TABLE IF NOT EXISTS project_phases ( status TEXT DEFAULT 'pending', task_id TEXT REFERENCES tasks(id), revise_count INTEGER DEFAULT 0, + revise_comment TEXT, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ); @@ -336,6 +337,12 @@ def _migrate(conn: sqlite3.Connection): CREATE INDEX IF NOT EXISTS idx_phases_project ON project_phases(project_id, phase_order); """) conn.commit() + # Migrate project_phases columns (table may already exist without revise_comment) + phase_cols = {r[1] for r in conn.execute("PRAGMA table_info(project_phases)").fetchall()} + if "revise_comment" not in phase_cols: + conn.execute("ALTER TABLE project_phases ADD COLUMN revise_comment TEXT") + conn.commit() + if "audit_log" not in existing_tables: conn.executescript(""" CREATE TABLE IF NOT EXISTS audit_log ( diff --git a/core/phases.py b/core/phases.py index 1e11958..3288ac7 100644 --- a/core/phases.py +++ b/core/phases.py @@ -204,5 +204,6 @@ def revise_phase(conn: sqlite3.Connection, phase_id: int, comment: str) -> dict: status="revising", task_id=new_task["id"], revise_count=new_revise_count, + revise_comment=comment, ) return {"phase": updated, "new_task": new_task} diff --git a/tests/test_api_phases.py b/tests/test_api_phases.py new file mode 100644 index 0000000..8c479d7 --- /dev/null +++ b/tests/test_api_phases.py @@ -0,0 +1,314 @@ +"""Tests for web/api.py — Phase endpoints (KIN-059). + +Covers: + - POST /api/projects/new — создание проекта с фазами + - GET /api/projects/{id}/phases — список фаз с joined task + - POST /api/phases/{id}/approve — approve фазы + - POST /api/phases/{id}/reject — reject фазы + - POST /api/phases/{id}/revise — revise фазы + - POST /api/projects/{id}/phases/start — запуск агента для активной фазы +""" + +from unittest.mock import MagicMock, patch + +import pytest +from fastapi.testclient import TestClient + +import web.api as api_module + + +@pytest.fixture +def client(tmp_path): + """KIN-059: TestClient с изолированной временной БД.""" + db_path = tmp_path / "test.db" + api_module.DB_PATH = db_path + from web.api import app + return TestClient(app) + + +@pytest.fixture +def client_with_phases(client): + """KIN-059: клиент с уже созданным проектом + фазами.""" + r = client.post("/api/projects/new", json={ + "id": "proj1", + "name": "Test Project", + "path": "/tmp/proj1", + "description": "Описание тестового проекта", + "roles": ["business_analyst"], + }) + assert r.status_code == 200 + return client + + +# --------------------------------------------------------------------------- +# POST /api/projects/new +# --------------------------------------------------------------------------- + + +def test_post_projects_new_creates_project_and_phases(client): + """KIN-059: POST /api/projects/new создаёт проект с фазами (researcher + architect).""" + r = client.post("/api/projects/new", json={ + "id": "p1", + "name": "My Project", + "path": "/tmp/p1", + "description": "Описание", + "roles": ["tech_researcher"], + }) + assert r.status_code == 200 + data = r.json() + assert data["project"]["id"] == "p1" + # tech_researcher + architect = 2 фазы + assert len(data["phases"]) == 2 + phase_roles = [ph["role"] for ph in data["phases"]] + assert "architect" in phase_roles + assert phase_roles[-1] == "architect" + + +def test_post_projects_new_no_roles_returns_400(client): + """KIN-059: POST /api/projects/new без ролей возвращает 400.""" + r = client.post("/api/projects/new", json={ + "id": "p1", + "name": "P1", + "path": "/tmp/p1", + "description": "Desc", + "roles": [], + }) + assert r.status_code == 400 + + +def test_post_projects_new_only_architect_returns_400(client): + """KIN-059: только architect в roles → 400 (architect не researcher).""" + r = client.post("/api/projects/new", json={ + "id": "p1", + "name": "P1", + "path": "/tmp/p1", + "description": "Desc", + "roles": ["architect"], + }) + assert r.status_code == 400 + + +def test_post_projects_new_duplicate_id_returns_409(client): + """KIN-059: повторное создание проекта с тем же id → 409.""" + payload = { + "id": "dup", + "name": "Dup", + "path": "/tmp/dup", + "description": "Desc", + "roles": ["marketer"], + } + client.post("/api/projects/new", json=payload) + r = client.post("/api/projects/new", json=payload) + assert r.status_code == 409 + + +def test_post_projects_new_first_phase_is_active(client): + """KIN-059: первая фаза созданного проекта сразу имеет status=active.""" + r = client.post("/api/projects/new", json={ + "id": "p1", + "name": "P1", + "path": "/tmp/p1", + "description": "Desc", + "roles": ["market_researcher", "tech_researcher"], + }) + assert r.status_code == 200 + first_phase = r.json()["phases"][0] + assert first_phase["status"] == "active" + + +# --------------------------------------------------------------------------- +# GET /api/projects/{project_id}/phases +# --------------------------------------------------------------------------- + + +def test_get_project_phases_returns_phases_with_task(client_with_phases): + """KIN-059: GET /api/projects/{id}/phases возвращает фазы с joined полем task.""" + r = client_with_phases.get("/api/projects/proj1/phases") + assert r.status_code == 200 + phases = r.json() + assert len(phases) >= 1 + # Активная первая фаза должна иметь task + active = next((ph for ph in phases if ph["status"] == "active"), None) + assert active is not None + assert active["task"] is not None + + +def test_get_project_phases_project_not_found_returns_404(client): + """KIN-059: GET /api/projects/missing/phases → 404.""" + r = client.get("/api/projects/missing/phases") + assert r.status_code == 404 + + +# --------------------------------------------------------------------------- +# POST /api/phases/{phase_id}/approve +# --------------------------------------------------------------------------- + + +def _get_first_active_phase_id(client, project_id: str) -> int: + """Вспомогательная: получить id первой активной фазы.""" + phases = client.get(f"/api/projects/{project_id}/phases").json() + active = next(ph for ph in phases if ph["status"] == "active") + return active["id"] + + +def test_approve_phase_returns_200_and_activates_next(client_with_phases): + """KIN-059: POST /api/phases/{id}/approve → 200, следующая фаза активируется.""" + phase_id = _get_first_active_phase_id(client_with_phases, "proj1") + r = client_with_phases.post(f"/api/phases/{phase_id}/approve", json={}) + assert r.status_code == 200 + data = r.json() + assert data["phase"]["status"] == "approved" + # Следующая фаза (architect) активирована + assert data["next_phase"] is not None + assert data["next_phase"]["status"] == "active" + + +def test_approve_phase_not_found_returns_404(client): + """KIN-059: approve несуществующей фазы → 404.""" + r = client.post("/api/phases/9999/approve", json={}) + assert r.status_code == 404 + + +def test_approve_phase_not_active_returns_400(client): + """KIN-059: approve pending-фазы → 400 (фаза не active).""" + # Создаём проект с двумя researcher-ролями + client.post("/api/projects/new", json={ + "id": "p2", + "name": "P2", + "path": "/tmp/p2", + "description": "Desc", + "roles": ["market_researcher", "tech_researcher"], + }) + phases = client.get("/api/projects/p2/phases").json() + # Вторая фаза pending + pending = next(ph for ph in phases if ph["status"] == "pending") + r = client.post(f"/api/phases/{pending['id']}/approve", json={}) + assert r.status_code == 400 + + +# --------------------------------------------------------------------------- +# POST /api/phases/{phase_id}/reject +# --------------------------------------------------------------------------- + + +def test_reject_phase_returns_200(client_with_phases): + """KIN-059: POST /api/phases/{id}/reject → 200, status=rejected.""" + phase_id = _get_first_active_phase_id(client_with_phases, "proj1") + r = client_with_phases.post(f"/api/phases/{phase_id}/reject", json={"reason": "Не актуально"}) + assert r.status_code == 200 + assert r.json()["status"] == "rejected" + + +def test_reject_phase_not_found_returns_404(client): + """KIN-059: reject несуществующей фазы → 404.""" + r = client.post("/api/phases/9999/reject", json={"reason": "test"}) + assert r.status_code == 404 + + +def test_reject_phase_not_active_returns_400(client): + """KIN-059: reject pending-фазы → 400.""" + client.post("/api/projects/new", json={ + "id": "p3", + "name": "P3", + "path": "/tmp/p3", + "description": "Desc", + "roles": ["legal_researcher", "ux_designer"], + }) + phases = client.get("/api/projects/p3/phases").json() + pending = next(ph for ph in phases if ph["status"] == "pending") + r = client.post(f"/api/phases/{pending['id']}/reject", json={"reason": "test"}) + assert r.status_code == 400 + + +# --------------------------------------------------------------------------- +# POST /api/phases/{phase_id}/revise +# --------------------------------------------------------------------------- + + +def test_revise_phase_returns_200_and_creates_new_task(client_with_phases): + """KIN-059: POST /api/phases/{id}/revise → 200, создаётся новая задача.""" + phase_id = _get_first_active_phase_id(client_with_phases, "proj1") + r = client_with_phases.post( + f"/api/phases/{phase_id}/revise", + json={"comment": "Добавь детали по монетизации"}, + ) + assert r.status_code == 200 + data = r.json() + assert data["phase"]["status"] == "revising" + assert data["new_task"] is not None + assert data["new_task"]["brief"]["revise_comment"] == "Добавь детали по монетизации" + + +def test_revise_phase_empty_comment_returns_400(client_with_phases): + """KIN-059: revise с пустым комментарием → 400.""" + phase_id = _get_first_active_phase_id(client_with_phases, "proj1") + r = client_with_phases.post(f"/api/phases/{phase_id}/revise", json={"comment": " "}) + assert r.status_code == 400 + + +def test_revise_phase_not_found_returns_404(client): + """KIN-059: revise несуществующей фазы → 404.""" + r = client.post("/api/phases/9999/revise", json={"comment": "test"}) + assert r.status_code == 404 + + +def test_revise_phase_not_active_returns_400(client): + """KIN-059: revise pending-фазы → 400.""" + client.post("/api/projects/new", json={ + "id": "p4", + "name": "P4", + "path": "/tmp/p4", + "description": "Desc", + "roles": ["marketer", "ux_designer"], + }) + phases = client.get("/api/projects/p4/phases").json() + pending = next(ph for ph in phases if ph["status"] == "pending") + r = client.post(f"/api/phases/{pending['id']}/revise", json={"comment": "test"}) + assert r.status_code == 400 + + +# --------------------------------------------------------------------------- +# POST /api/projects/{project_id}/phases/start +# --------------------------------------------------------------------------- + + +def test_start_phase_returns_202_and_starts_agent(client_with_phases): + """KIN-059: POST /api/projects/{id}/phases/start → 202, агент запускается в фоне.""" + with patch("subprocess.Popen") as mock_popen: + mock_proc = MagicMock() + mock_proc.pid = 12345 + mock_popen.return_value = mock_proc + + r = client_with_phases.post("/api/projects/proj1/phases/start") + + assert r.status_code == 202 + data = r.json() + assert data["status"] == "started" + assert "phase_id" in data + assert "task_id" in data + mock_popen.assert_called_once() + + +def test_start_phase_task_set_to_in_progress(client_with_phases): + """KIN-059: start устанавливает task.status=in_progress перед запуском агента.""" + with patch("subprocess.Popen") as mock_popen: + mock_popen.return_value = MagicMock(pid=1) + r = client_with_phases.post("/api/projects/proj1/phases/start") + task_id = r.json()["task_id"] + + task = client_with_phases.get(f"/api/tasks/{task_id}").json() + assert task["status"] == "in_progress" + + +def test_start_phase_no_active_phase_returns_404(client): + """KIN-059: start без активной/revising фазы → 404.""" + # Проект без фаз (обычный проект через /api/projects) + client.post("/api/projects", json={"id": "plain", "name": "Plain", "path": "/tmp/plain"}) + r = client.post("/api/projects/plain/phases/start") + assert r.status_code == 404 + + +def test_start_phase_project_not_found_returns_404(client): + """KIN-059: start для несуществующего проекта → 404.""" + r = client.post("/api/projects/missing/phases/start") + assert r.status_code == 404 diff --git a/tests/test_phases.py b/tests/test_phases.py new file mode 100644 index 0000000..84ab948 --- /dev/null +++ b/tests/test_phases.py @@ -0,0 +1,369 @@ +"""Tests for core/phases.py — Research Phase Pipeline (KIN-059). + +Covers: + - validate_roles: фильтрация, дедубликация, удаление architect + - build_phase_order: канонический порядок + auto-architect + - create_project_with_phases: создание + первая фаза active + - approve_phase: переход статусов, активация следующей, sequential enforcement + - reject_phase: статус rejected, защита от неактивных фаз + - revise_phase: цикл revise→running, счётчик, сохранение комментария +""" + +import pytest +from core.db import init_db +from core import models +from core.phases import ( + RESEARCH_ROLES, + approve_phase, + build_phase_order, + create_project_with_phases, + reject_phase, + revise_phase, + validate_roles, +) + + +@pytest.fixture +def conn(): + """KIN-059: изолированная in-memory БД для каждого теста.""" + c = init_db(db_path=":memory:") + yield c + c.close() + + +# --------------------------------------------------------------------------- +# validate_roles +# --------------------------------------------------------------------------- + + +def test_validate_roles_filters_unknown_roles(): + """KIN-059: неизвестные роли отфильтровываются из списка.""" + result = validate_roles(["business_analyst", "wizard", "ghost"]) + assert result == ["business_analyst"] + + +def test_validate_roles_strips_architect(): + """KIN-059: architect убирается из входных ролей — добавляется автоматически позже.""" + result = validate_roles(["architect", "tech_researcher"]) + assert "architect" not in result + assert "tech_researcher" in result + + +def test_validate_roles_deduplicates(): + """KIN-059: дублирующиеся роли удаляются, остаётся одна копия.""" + result = validate_roles(["business_analyst", "business_analyst", "tech_researcher"]) + assert result.count("business_analyst") == 1 + + +def test_validate_roles_empty_input_returns_empty(): + """KIN-059: пустой список ролей → пустой результат.""" + assert validate_roles([]) == [] + + +def test_validate_roles_only_architect_returns_empty(): + """KIN-059: только architect во входе → пустой результат (architect не researcher).""" + assert validate_roles(["architect"]) == [] + + +def test_validate_roles_strips_and_lowercases(): + """KIN-059: роли нормализуются: trim + lowercase.""" + result = validate_roles([" Tech_Researcher ", "MARKETER"]) + assert "tech_researcher" in result + assert "marketer" in result + + +# --------------------------------------------------------------------------- +# build_phase_order +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize("roles,expected", [ + ( + ["business_analyst"], + ["business_analyst", "architect"], + ), + ( + ["tech_researcher"], + ["tech_researcher", "architect"], + ), + ( + ["marketer", "business_analyst"], + ["business_analyst", "marketer", "architect"], + ), + ( + ["ux_designer", "market_researcher", "tech_researcher"], + ["market_researcher", "tech_researcher", "ux_designer", "architect"], + ), +]) +def test_build_phase_order_canonical_order_and_appends_architect(roles, expected): + """KIN-059: роли сортируются в канонический порядок, architect добавляется последним.""" + assert build_phase_order(roles) == expected + + +def test_build_phase_order_no_architect_if_no_researcher(): + """KIN-059: architect не добавляется если нет ни одного researcher.""" + result = build_phase_order([]) + assert result == [] + assert "architect" not in result + + +def test_build_phase_order_architect_always_last(): + """KIN-059: architect всегда последний независимо от набора ролей.""" + result = build_phase_order(["marketer", "legal_researcher", "business_analyst"]) + assert result[-1] == "architect" + + +# --------------------------------------------------------------------------- +# create_project_with_phases +# --------------------------------------------------------------------------- + + +def test_create_project_with_phases_creates_project_and_phases(conn): + """KIN-059: создание проекта с researcher-ролями создаёт и проект, и записи фаз.""" + result = create_project_with_phases( + conn, "proj1", "Project 1", "/path", + description="Тестовый проект", selected_roles=["business_analyst"], + ) + assert result["project"]["id"] == "proj1" + # business_analyst + architect = 2 фазы + assert len(result["phases"]) == 2 + + +def test_create_project_with_phases_first_phase_is_active(conn): + """KIN-059: первая фаза сразу переходит в status=active и получает task_id.""" + result = create_project_with_phases( + conn, "proj1", "P1", "/path", + description="Desc", selected_roles=["tech_researcher"], + ) + first = result["phases"][0] + assert first["status"] == "active" + assert first["task_id"] is not None + + +def test_create_project_with_phases_other_phases_remain_pending(conn): + """KIN-059: все фазы кроме первой остаются pending — не активируются без approve.""" + result = create_project_with_phases( + conn, "proj1", "P1", "/path", + description="Desc", selected_roles=["market_researcher", "tech_researcher"], + ) + # market_researcher, tech_researcher, architect → 3 фазы + for phase in result["phases"][1:]: + assert phase["status"] == "pending" + + +def test_create_project_with_phases_raises_if_no_roles(conn): + """KIN-059: ValueError при попытке создать проект без researcher-ролей.""" + with pytest.raises(ValueError, match="[Aa]t least one research role"): + create_project_with_phases( + conn, "proj1", "P1", "/path", + description="Desc", selected_roles=[], + ) + + +def test_create_project_with_phases_architect_auto_added_last(conn): + """KIN-059: architect автоматически добавляется последним без явного указания.""" + result = create_project_with_phases( + conn, "proj1", "P1", "/path", + description="Desc", selected_roles=["business_analyst"], + ) + roles = [ph["role"] for ph in result["phases"]] + assert "architect" in roles + assert roles[-1] == "architect" + + +@pytest.mark.parametrize("roles", [ + ["business_analyst"], + ["market_researcher", "tech_researcher"], + ["legal_researcher", "ux_designer", "marketer"], + ["business_analyst", "market_researcher", "legal_researcher", + "tech_researcher", "ux_designer", "marketer"], +]) +def test_create_project_with_phases_architect_added_for_any_combination(conn, roles): + """KIN-059: architect добавляется при любом наборе researcher-ролей.""" + result = create_project_with_phases( + conn, "proj1", "P1", "/path", + description="Desc", selected_roles=roles, + ) + phase_roles = [ph["role"] for ph in result["phases"]] + assert "architect" in phase_roles + assert phase_roles[-1] == "architect" + + +# --------------------------------------------------------------------------- +# approve_phase +# --------------------------------------------------------------------------- + + +def test_approve_phase_sets_status_approved(conn): + """KIN-059: approve_phase устанавливает status=approved для текущей фазы.""" + result = create_project_with_phases( + conn, "proj1", "P1", "/path", + description="Desc", selected_roles=["business_analyst"], + ) + phase_id = result["phases"][0]["id"] + out = approve_phase(conn, phase_id) + assert out["phase"]["status"] == "approved" + + +def test_approve_phase_activates_next_phase(conn): + """KIN-059: следующая фаза активируется только после approve предыдущей.""" + result = create_project_with_phases( + conn, "proj1", "P1", "/path", + description="Desc", selected_roles=["business_analyst"], + ) + first_phase_id = result["phases"][0]["id"] + out = approve_phase(conn, first_phase_id) + next_phase = out["next_phase"] + assert next_phase is not None + assert next_phase["status"] == "active" + assert next_phase["role"] == "architect" + + +def test_approve_phase_last_returns_no_next(conn): + """KIN-059: approve последней фазы возвращает next_phase=None (workflow завершён).""" + result = create_project_with_phases( + conn, "proj1", "P1", "/path", + description="Desc", selected_roles=["business_analyst"], + ) + # Approve business_analyst → architect активируется + first_id = result["phases"][0]["id"] + mid = approve_phase(conn, first_id) + architect_id = mid["next_phase"]["id"] + # Approve architect → no next + final = approve_phase(conn, architect_id) + assert final["next_phase"] is None + + +def test_approve_phase_not_active_raises(conn): + """KIN-059: approve фазы в статусе != active бросает ValueError.""" + models.create_project(conn, "proj1", "P1", "/path", description="Desc") + phase = models.create_phase(conn, "proj1", "business_analyst", 0) + # Фаза в статусе pending, не active + with pytest.raises(ValueError, match="not active"): + approve_phase(conn, phase["id"]) + + +def test_pending_phase_not_started_without_approve(conn): + """KIN-059: следующая фаза не стартует без approve предыдущей (нет автоактивации).""" + result = create_project_with_phases( + conn, "proj1", "P1", "/path", + description="Desc", selected_roles=["market_researcher", "tech_researcher"], + ) + # Вторая фаза (tech_researcher) должна оставаться pending + second_phase = result["phases"][1] + assert second_phase["status"] == "pending" + assert second_phase["task_id"] is None + + +# --------------------------------------------------------------------------- +# reject_phase +# --------------------------------------------------------------------------- + + +def test_reject_phase_sets_status_rejected(conn): + """KIN-059: reject_phase устанавливает status=rejected для фазы.""" + result = create_project_with_phases( + conn, "proj1", "P1", "/path", + description="Desc", selected_roles=["tech_researcher"], + ) + phase_id = result["phases"][0]["id"] + out = reject_phase(conn, phase_id, reason="Не релевантно") + assert out["status"] == "rejected" + + +def test_reject_phase_not_active_raises(conn): + """KIN-059: reject_phase для pending-фазы бросает ValueError.""" + models.create_project(conn, "proj1", "P1", "/path", description="Desc") + phase = models.create_phase(conn, "proj1", "tech_researcher", 0) + with pytest.raises(ValueError, match="not active"): + reject_phase(conn, phase["id"], reason="test") + + +# --------------------------------------------------------------------------- +# revise_phase +# --------------------------------------------------------------------------- + + +def test_revise_phase_sets_status_revising(conn): + """KIN-059: revise_phase устанавливает статус revising для фазы.""" + result = create_project_with_phases( + conn, "proj1", "P1", "/path", + description="Desc", selected_roles=["ux_designer"], + ) + phase_id = result["phases"][0]["id"] + out = revise_phase(conn, phase_id, comment="Нужно больше деталей") + assert out["phase"]["status"] == "revising" + + +def test_revise_phase_creates_new_task_with_comment(conn): + """KIN-059: revise_phase создаёт новую задачу с revise_comment в brief.""" + result = create_project_with_phases( + conn, "proj1", "P1", "/path", + description="Desc", selected_roles=["marketer"], + ) + phase_id = result["phases"][0]["id"] + comment = "Добавь анализ конкурентов" + out = revise_phase(conn, phase_id, comment=comment) + new_task = out["new_task"] + assert new_task is not None + assert new_task["brief"]["revise_comment"] == comment + + +def test_revise_phase_increments_revise_count(conn): + """KIN-059: revise_phase увеличивает счётчик revise_count с каждым вызовом.""" + result = create_project_with_phases( + conn, "proj1", "P1", "/path", + description="Desc", selected_roles=["marketer"], + ) + phase_id = result["phases"][0]["id"] + out1 = revise_phase(conn, phase_id, comment="Первая ревизия") + assert out1["phase"]["revise_count"] == 1 + out2 = revise_phase(conn, phase_id, comment="Вторая ревизия") + assert out2["phase"]["revise_count"] == 2 + + +def test_revise_phase_saves_comment_on_phase(conn): + """KIN-059: revise_phase сохраняет комментарий в поле revise_comment фазы.""" + result = create_project_with_phases( + conn, "proj1", "P1", "/path", + description="Desc", selected_roles=["business_analyst"], + ) + phase_id = result["phases"][0]["id"] + comment = "Уточни целевую аудиторию" + out = revise_phase(conn, phase_id, comment=comment) + assert out["phase"]["revise_comment"] == comment + + +def test_revise_phase_pending_raises(conn): + """KIN-059: revise_phase для pending-фазы бросает ValueError.""" + models.create_project(conn, "proj1", "P1", "/path", description="Desc") + phase = models.create_phase(conn, "proj1", "marketer", 0) + with pytest.raises(ValueError, match="cannot be revised"): + revise_phase(conn, phase["id"], comment="test") + + +def test_revise_phase_revising_status_allows_another_revise(conn): + """KIN-059: фаза в статусе revising допускает повторный вызов revise (цикл).""" + result = create_project_with_phases( + conn, "proj1", "P1", "/path", + description="Desc", selected_roles=["business_analyst"], + ) + phase_id = result["phases"][0]["id"] + revise_phase(conn, phase_id, comment="Первая ревизия") + # Фаза теперь revising — повторный revise должен проходить + out = revise_phase(conn, phase_id, comment="Вторая ревизия") + assert out["phase"]["revise_count"] == 2 + + +def test_revise_phase_updates_task_id_to_new_task(conn): + """KIN-059: после revise phase.task_id указывает на новую задачу.""" + result = create_project_with_phases( + conn, "proj1", "P1", "/path", + description="Desc", selected_roles=["market_researcher"], + ) + phase = result["phases"][0] + original_task_id = phase["task_id"] + out = revise_phase(conn, phase["id"], comment="Пересмотреть") + new_task_id = out["phase"]["task_id"] + assert new_task_id != original_task_id + assert new_task_id == out["new_task"]["id"] diff --git a/web/api.py b/web/api.py index 8e0666d..10fb519 100644 --- a/web/api.py +++ b/web/api.py @@ -407,6 +407,69 @@ def revise_phase(phase_id: int, body: PhaseRevise): return result +@app.post("/api/projects/{project_id}/phases/start") +def start_project_phase(project_id: str): + """Launch agent for the current active/revising phase in background. Returns 202. + + Finds the first phase with status 'active' or 'revising', sets its task to + in_progress, spawns a background subprocess (same as /api/tasks/{id}/run), + and returns immediately so the HTTP request doesn't block on agent execution. + """ + conn = get_conn() + p = models.get_project(conn, project_id) + if not p: + conn.close() + raise HTTPException(404, f"Project '{project_id}' not found") + + phases = models.list_phases(conn, project_id) + active_phase = next( + (ph for ph in phases if ph["status"] in ("active", "revising")), None + ) + if not active_phase: + conn.close() + raise HTTPException(404, f"No active or revising phase for project '{project_id}'") + + task_id = active_phase.get("task_id") + if not task_id: + conn.close() + raise HTTPException(400, f"Phase {active_phase['id']} has no task assigned") + + t = models.get_task(conn, task_id) + if not t: + conn.close() + raise HTTPException(404, f"Task '{task_id}' not found") + + models.update_task(conn, task_id, status="in_progress") + conn.close() + + kin_root = Path(__file__).parent.parent + cmd = [sys.executable, "-m", "cli.main", "--db", str(DB_PATH), "run", task_id] + cmd.append("--allow-write") + + import os + env = os.environ.copy() + env["KIN_NONINTERACTIVE"] = "1" + + try: + proc = subprocess.Popen( + cmd, + cwd=str(kin_root), + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + stdin=subprocess.DEVNULL, + env=env, + ) + _logger.info("Phase agent started for task %s (phase %d), pid=%d", + task_id, active_phase["id"], proc.pid) + except Exception as e: + raise HTTPException(500, f"Failed to start phase agent: {e}") + + return JSONResponse( + {"status": "started", "phase_id": active_phase["id"], "task_id": task_id}, + status_code=202, + ) + + # --------------------------------------------------------------------------- # Tasks # --------------------------------------------------------------------------- diff --git a/web/frontend/src/api.ts b/web/frontend/src/api.ts index b888a7e..6b398f6 100644 --- a/web/frontend/src/api.ts +++ b/web/frontend/src/api.ts @@ -162,6 +162,7 @@ export interface Phase { status: string task_id: string | null revise_count: number + revise_comment: string | null created_at: string updated_at: string task?: Task | null @@ -264,4 +265,6 @@ export const api = { post(`/phases/${phaseId}/reject`, { reason }), revisePhase: (phaseId: number, comment: string) => post<{ phase: Phase; new_task: Task }>(`/phases/${phaseId}/revise`, { comment }), + startPhase: (projectId: string) => + post<{ status: string; phase_id: number; task_id: string }>(`/projects/${projectId}/phases/start`, {}), } diff --git a/web/frontend/src/views/ProjectView.vue b/web/frontend/src/views/ProjectView.vue index 93b4d1c..ade2343 100644 --- a/web/frontend/src/views/ProjectView.vue +++ b/web/frontend/src/views/ProjectView.vue @@ -1,5 +1,5 @@