kin: KIN-059 Workflow new_project с выбором команды. При создании нового проекта через GUI или CLI директор описывает проект свободным текстом и выбирает галочками какие этапы research нужны: ☐ Business analyst (бизнес-модель, аудитория, монетизация) ☐ Market researcher (конкуренты, ниша, отзывы, сильные/слабые стороны) ☐ Legal researcher (юрисдикция, лицензии, KYC/AML, GDPR) ☐ Tech researcher (API, ограничения, стоимость, альтернативы) ☐ UX designer (анализ UX конкурентов, user journey, wireframes) ☐ Marketer (стратегия продвижения, SEO, conversion-паттерны) ☐ Architect (blueprint на основе одобренных research'ей) — всегда последний Architect включается автоматически если выбран хотя бы один researcher. Каждый выбранный этап — отдельная задача на review. Директор одобряет, отклоняет, или просит доисследовать (Revise). Следующий этап только после approve предыдущего. GUI: форма 'New Project' с описанием + чекбоксы ролей + кнопка 'Start Research'. CLI: kin new-project 'описание' --roles 'business,market,tech,architect'
This commit is contained in:
parent
75fee86110
commit
4188384f1b
7 changed files with 820 additions and 9 deletions
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
314
tests/test_api_phases.py
Normal file
314
tests/test_api_phases.py
Normal file
|
|
@ -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
|
||||
369
tests/test_phases.py
Normal file
369
tests/test_phases.py
Normal file
|
|
@ -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"]
|
||||
63
web/api.py
63
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
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -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<Phase>(`/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`, {}),
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
<script setup lang="ts">
|
||||
import { ref, onMounted, computed, watch } from 'vue'
|
||||
import { ref, onMounted, onUnmounted, computed, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { api, type ProjectDetail, type AuditResult, type Phase } from '../api'
|
||||
import Badge from '../components/Badge.vue'
|
||||
|
|
@ -28,12 +28,32 @@ const rejectPhaseId = ref<number | null>(null)
|
|||
const rejectReason = ref('')
|
||||
const rejectError = ref('')
|
||||
const rejectSaving = ref(false)
|
||||
const startPhaseSaving = ref(false)
|
||||
const approvePhaseSaving = ref(false)
|
||||
let phasePollTimer: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
function checkAndPollPhases() {
|
||||
const hasRunning = phases.value.some(ph => ph.task?.status === 'in_progress')
|
||||
if (hasRunning && !phasePollTimer) {
|
||||
phasePollTimer = setInterval(async () => {
|
||||
phases.value = await api.getPhases(props.id).catch(() => phases.value)
|
||||
if (!phases.value.some(ph => ph.task?.status === 'in_progress')) {
|
||||
clearInterval(phasePollTimer!)
|
||||
phasePollTimer = null
|
||||
}
|
||||
}, 5000)
|
||||
} else if (!hasRunning && phasePollTimer) {
|
||||
clearInterval(phasePollTimer)
|
||||
phasePollTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
async function loadPhases() {
|
||||
phasesLoading.value = true
|
||||
phaseError.value = ''
|
||||
try {
|
||||
phases.value = await api.getPhases(props.id)
|
||||
checkAndPollPhases()
|
||||
} catch (e: any) {
|
||||
phaseError.value = e.message
|
||||
} finally {
|
||||
|
|
@ -42,11 +62,27 @@ async function loadPhases() {
|
|||
}
|
||||
|
||||
async function approvePhase(phaseId: number) {
|
||||
approvePhaseSaving.value = true
|
||||
try {
|
||||
await api.approvePhase(phaseId)
|
||||
await loadPhases()
|
||||
} catch (e: any) {
|
||||
phaseError.value = e.message
|
||||
} finally {
|
||||
approvePhaseSaving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function startPhase() {
|
||||
startPhaseSaving.value = true
|
||||
phaseError.value = ''
|
||||
try {
|
||||
await api.startPhase(props.id)
|
||||
await loadPhases()
|
||||
} catch (e: any) {
|
||||
phaseError.value = e.message
|
||||
} finally {
|
||||
startPhaseSaving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -242,6 +278,10 @@ onMounted(async () => {
|
|||
await loadPhases()
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (phasePollTimer) { clearInterval(phasePollTimer); phasePollTimer = null }
|
||||
})
|
||||
|
||||
const taskCategories = computed(() => {
|
||||
if (!project.value) return []
|
||||
const cats = new Set(project.value.tasks.map(t => t.category).filter(Boolean) as string[])
|
||||
|
|
@ -584,23 +624,37 @@ async function addDecision() {
|
|||
class="text-xs text-blue-400 hover:text-blue-300 no-underline">
|
||||
{{ ph.task_id }}
|
||||
</router-link>
|
||||
<template v-if="ph.status === 'active'">
|
||||
<button @click="approvePhase(ph.id)"
|
||||
class="px-2 py-0.5 text-xs bg-green-900/40 text-green-400 border border-green-800 rounded hover:bg-green-900">
|
||||
Approve
|
||||
<!-- Running indicator -->
|
||||
<span v-if="ph.task?.status === 'in_progress'"
|
||||
class="inline-block w-2 h-2 bg-blue-500 rounded-full animate-pulse"
|
||||
title="Agent running..."></span>
|
||||
<!-- Start Research button: phase ready but task not started yet -->
|
||||
<button
|
||||
v-if="(ph.status === 'active' || ph.status === 'revising') && ph.task?.status === 'pending'"
|
||||
@click="startPhase"
|
||||
:disabled="startPhaseSaving"
|
||||
class="px-2 py-0.5 text-xs bg-blue-900/40 text-blue-400 border border-blue-800 rounded hover:bg-blue-900 disabled:opacity-50">
|
||||
{{ startPhaseSaving ? 'Starting...' : (ph.status === 'revising' ? 'Re-run' : 'Start Research') }}
|
||||
</button>
|
||||
<!-- Approve/Revise/Reject: only when agent submitted work for review (decision #78) -->
|
||||
<template v-if="(ph.status === 'active' || ph.status === 'revising') && ph.task?.status === 'review'">
|
||||
<button @click="approvePhase(ph.id)" :disabled="approvePhaseSaving"
|
||||
class="px-2 py-0.5 text-xs bg-green-900/40 text-green-400 border border-green-800 rounded hover:bg-green-900 disabled:opacity-50">
|
||||
{{ approvePhaseSaving ? '...' : 'Approve' }}
|
||||
</button>
|
||||
<button @click="openRevise(ph.id)"
|
||||
class="px-2 py-0.5 text-xs bg-yellow-900/40 text-yellow-400 border border-yellow-800 rounded hover:bg-yellow-900">
|
||||
<button @click="openRevise(ph.id)" :disabled="approvePhaseSaving"
|
||||
class="px-2 py-0.5 text-xs bg-yellow-900/40 text-yellow-400 border border-yellow-800 rounded hover:bg-yellow-900 disabled:opacity-50">
|
||||
Revise
|
||||
</button>
|
||||
<button @click="openReject(ph.id)"
|
||||
class="px-2 py-0.5 text-xs bg-red-900/40 text-red-400 border border-red-800 rounded hover:bg-red-900">
|
||||
<button @click="openReject(ph.id)" :disabled="approvePhaseSaving"
|
||||
class="px-2 py-0.5 text-xs bg-red-900/40 text-red-400 border border-red-800 rounded hover:bg-red-900 disabled:opacity-50">
|
||||
Reject
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="ph.task?.title" class="mt-1 text-xs text-gray-500 ml-7">{{ ph.task.title }}</div>
|
||||
<div v-if="ph.revise_comment" class="mt-1 text-xs text-yellow-600 ml-7">➤ {{ ph.revise_comment }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue