kin: KIN-071 Добавить тип проекта: development / operations / research. Для operations: вместо path к локальной папке — ssh-доступ (host, user, key, proxy or jump). При создании operations-проекта запускается sysadmin-агент который подключается по SSH, обходит сервер, составляет карту: какие сервисы запущены (docker ps, systemctl), какие конфиги где лежат, какие порты открыты, какие версии. Результат сохраняется в decisions и modules как база знаний по серверу. Код не хранится локально — агенты работают через SSH. PM для operations вызывает sysadmin/debugger, не architect/frontend_dev.

This commit is contained in:
Gros Frumos 2026-03-16 09:17:42 +02:00
parent d9172fc17c
commit 75fee86110
4 changed files with 371 additions and 0 deletions

View file

@ -1265,3 +1265,71 @@ def test_kin016_pipeline_blocked_agent_stops_next_steps_integration(client):
assert items[0]["task_id"] == "P1-001"
assert items[0]["reason"] == "no repo access"
assert items[0]["agent_role"] == "debugger"
# ---------------------------------------------------------------------------
# KIN-071: project_type и SSH-поля в API
# ---------------------------------------------------------------------------
def test_create_operations_project_with_ssh_fields(client):
"""KIN-071: POST /api/projects с project_type=operations и SSH-полями возвращает 200."""
r = client.post("/api/projects", json={
"id": "srv1",
"name": "My Server",
"path": "",
"project_type": "operations",
"ssh_host": "10.0.0.1",
"ssh_user": "root",
"ssh_key_path": "~/.ssh/id_rsa",
"ssh_proxy_jump": "jumpt",
})
assert r.status_code == 200
data = r.json()
assert data["project_type"] == "operations"
assert data["ssh_host"] == "10.0.0.1"
assert data["ssh_user"] == "root"
assert data["ssh_key_path"] == "~/.ssh/id_rsa"
assert data["ssh_proxy_jump"] == "jumpt"
def test_create_project_invalid_type_returns_400(client):
"""KIN-071: POST /api/projects с недопустимым project_type → 400."""
r = client.post("/api/projects", json={
"id": "bad",
"name": "Bad",
"path": "/bad",
"project_type": "legacy",
})
assert r.status_code == 400
def test_patch_project_invalid_type_returns_400(client):
"""KIN-071: PATCH /api/projects/{id} с недопустимым project_type → 400."""
r = client.patch("/api/projects/p1", json={"project_type": "invalid_type"})
assert r.status_code == 400
def test_create_operations_project_without_ssh_host_allowed(client):
"""KIN-071: API не валидирует ssh_host на стороне бэкенда — проект создаётся без него."""
r = client.post("/api/projects", json={
"id": "srv2",
"name": "Server No SSH",
"path": "",
"project_type": "operations",
})
assert r.status_code == 200
assert r.json()["project_type"] == "operations"
assert r.json()["ssh_host"] is None
def test_create_research_project_type_accepted(client):
"""KIN-071: project_type=research принимается API."""
r = client.post("/api/projects", json={
"id": "res1",
"name": "Research Project",
"path": "/research",
"project_type": "research",
})
assert r.status_code == 200
assert r.json()["project_type"] == "research"

View file

@ -345,3 +345,64 @@ class TestOperationsProject:
ctx = build_context(ops_conn, "SRV-001", "sysadmin", "srv")
prompt = format_prompt(ctx, "sysadmin", "You are sysadmin.")
assert "Project type: operations" in prompt
# ---------------------------------------------------------------------------
# KIN-071: PM routing — operations project routes PM to infra_* pipelines
# ---------------------------------------------------------------------------
class TestPMRoutingOperations:
"""PM-контекст для operations-проекта должен содержать infra-маршруты,
не включающие architect/frontend_dev."""
@pytest.fixture
def ops_conn(self):
c = init_db(":memory:")
models.create_project(
c, "srv", "My Server", "",
project_type="operations",
ssh_host="10.0.0.1",
ssh_user="root",
)
models.create_task(c, "SRV-001", "srv", "Scan server")
yield c
c.close()
def test_pm_context_has_operations_project_type(self, ops_conn):
"""PM получает project_type=operations в контексте проекта."""
ctx = build_context(ops_conn, "SRV-001", "pm", "srv")
assert ctx["project"]["project_type"] == "operations"
def test_pm_context_has_infra_scan_route(self, ops_conn):
"""PM-контекст содержит маршрут infra_scan из specialists.yaml."""
ctx = build_context(ops_conn, "SRV-001", "pm", "srv")
assert "infra_scan" in ctx["routes"]
def test_pm_context_has_infra_debug_route(self, ops_conn):
"""PM-контекст содержит маршрут infra_debug из specialists.yaml."""
ctx = build_context(ops_conn, "SRV-001", "pm", "srv")
assert "infra_debug" in ctx["routes"]
def test_infra_scan_route_uses_sysadmin(self, ops_conn):
"""infra_scan маршрут включает sysadmin в шагах."""
ctx = build_context(ops_conn, "SRV-001", "pm", "srv")
steps = ctx["routes"]["infra_scan"]["steps"]
assert "sysadmin" in steps
def test_infra_scan_route_excludes_architect(self, ops_conn):
"""infra_scan маршрут не назначает architect."""
ctx = build_context(ops_conn, "SRV-001", "pm", "srv")
steps = ctx["routes"]["infra_scan"]["steps"]
assert "architect" not in steps
def test_infra_scan_route_excludes_frontend_dev(self, ops_conn):
"""infra_scan маршрут не назначает frontend_dev."""
ctx = build_context(ops_conn, "SRV-001", "pm", "srv")
steps = ctx["routes"]["infra_scan"]["steps"]
assert "frontend_dev" not in steps
def test_format_prompt_pm_operations_project_type_label(self, ops_conn):
"""format_prompt для PM с operations-проектом содержит 'Project type: operations'."""
ctx = build_context(ops_conn, "SRV-001", "pm", "srv")
prompt = format_prompt(ctx, "pm", "You are PM.")
assert "Project type: operations" in prompt

132
tests/test_db.py Normal file
View file

@ -0,0 +1,132 @@
"""Tests for core/db.py — schema and migration (KIN-071)."""
import sqlite3
import pytest
from core.db import init_db, _migrate
@pytest.fixture
def conn():
c = init_db(db_path=":memory:")
yield c
c.close()
def _cols(conn, table: str) -> set[str]:
"""Return set of column names for a table."""
return {row["name"] for row in conn.execute(f"PRAGMA table_info({table})").fetchall()}
# ---------------------------------------------------------------------------
# Schema: новые колонки KIN-071 присутствуют при свежей инициализации
# ---------------------------------------------------------------------------
class TestProjectsSchemaKin071:
"""PRAGMA table_info(projects) должен содержать новые KIN-071 колонки."""
def test_schema_has_project_type_column(self, conn):
assert "project_type" in _cols(conn, "projects")
def test_schema_has_ssh_host_column(self, conn):
assert "ssh_host" in _cols(conn, "projects")
def test_schema_has_ssh_user_column(self, conn):
assert "ssh_user" in _cols(conn, "projects")
def test_schema_has_ssh_key_path_column(self, conn):
assert "ssh_key_path" in _cols(conn, "projects")
def test_schema_has_ssh_proxy_jump_column(self, conn):
assert "ssh_proxy_jump" in _cols(conn, "projects")
def test_schema_has_description_column(self, conn):
assert "description" in _cols(conn, "projects")
def test_project_type_defaults_to_development(self, conn):
"""INSERT без project_type → значение по умолчанию 'development'."""
conn.execute(
"INSERT INTO projects (id, name, path) VALUES ('t1', 'T', '/t')"
)
conn.commit()
row = conn.execute(
"SELECT project_type FROM projects WHERE id='t1'"
).fetchone()
assert row["project_type"] == "development"
def test_ssh_fields_default_to_null(self, conn):
"""SSH-поля по умолчанию NULL."""
conn.execute(
"INSERT INTO projects (id, name, path) VALUES ('t2', 'T', '/t')"
)
conn.commit()
row = conn.execute(
"SELECT ssh_host, ssh_user, ssh_key_path, ssh_proxy_jump FROM projects WHERE id='t2'"
).fetchone()
assert row["ssh_host"] is None
assert row["ssh_user"] is None
assert row["ssh_key_path"] is None
assert row["ssh_proxy_jump"] is None
# ---------------------------------------------------------------------------
# Migration: _migrate добавляет KIN-071 колонки в старую схему (без них)
# ---------------------------------------------------------------------------
def _old_schema_conn() -> sqlite3.Connection:
"""Создаёт соединение с минимальной 'старой' схемой без KIN-071 колонок."""
conn = sqlite3.connect(":memory:")
conn.row_factory = sqlite3.Row
conn.executescript("""
CREATE TABLE projects (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
path TEXT NOT NULL,
status TEXT DEFAULT 'active',
language TEXT DEFAULT 'ru',
execution_mode TEXT NOT NULL DEFAULT 'review'
);
CREATE TABLE tasks (
id TEXT PRIMARY KEY,
project_id TEXT NOT NULL,
title TEXT NOT NULL,
status TEXT DEFAULT 'pending',
execution_mode TEXT
);
""")
conn.commit()
return conn
def test_migrate_adds_project_type_to_old_schema():
"""_migrate добавляет project_type в старую схему без этой колонки."""
conn = _old_schema_conn()
_migrate(conn)
assert "project_type" in _cols(conn, "projects")
conn.close()
def test_migrate_adds_ssh_host_to_old_schema():
"""_migrate добавляет ssh_host в старую схему."""
conn = _old_schema_conn()
_migrate(conn)
assert "ssh_host" in _cols(conn, "projects")
conn.close()
def test_migrate_adds_all_ssh_columns_to_old_schema():
"""_migrate добавляет все SSH-колонки разом в старую схему."""
conn = _old_schema_conn()
_migrate(conn)
cols = _cols(conn, "projects")
assert {"ssh_host", "ssh_user", "ssh_key_path", "ssh_proxy_jump", "description"}.issubset(cols)
conn.close()
def test_migrate_is_idempotent():
"""Повторный вызов _migrate не ломает схему."""
conn = init_db(":memory:")
before = _cols(conn, "projects")
_migrate(conn)
after = _cols(conn, "projects")
assert before == after
conn.close()

View file

@ -1916,3 +1916,113 @@ class TestSaveSysadminOutput:
from agents.runner import _save_sysadmin_output
result = _save_sysadmin_output(ops_conn, "srv", "SRV-001", {"raw_output": ""})
assert result["decisions_added"] == 0
def test_full_sysadmin_output_format_saves_docker_and_systemctl_as_decisions(self, ops_conn):
"""KIN-071: полный формат вывода sysadmin (docker ps + systemctl) → decisions + modules."""
from agents.runner import _save_sysadmin_output
# Симуляция реального вывода sysadmin-агента после docker ps и systemctl
output = {
"status": "done",
"summary": "Ubuntu 22.04, nginx + postgres + app in docker",
"os": "Ubuntu 22.04 LTS, kernel 5.15.0",
"services": [
{"name": "nginx", "type": "systemd", "status": "running", "note": "web proxy"},
{"name": "myapp", "type": "docker", "image": "myapp:1.2.3", "ports": ["80:8080"]},
{"name": "postgres", "type": "docker", "image": "postgres:15", "ports": ["5432:5432"]},
],
"open_ports": [
{"port": 80, "proto": "tcp", "process": "nginx"},
{"port": 5432, "proto": "tcp", "process": "postgres"},
],
"decisions": [
{
"type": "gotcha",
"title": "nginx proxies to docker app on 8080",
"description": "nginx.conf proxy_pass http://localhost:8080",
"tags": ["nginx", "docker"],
},
{
"type": "decision",
"title": "postgres data on /var/lib/postgresql",
"description": "Volume mount /var/lib/postgresql/data persists DB",
"tags": ["postgres", "storage"],
},
],
"modules": [
{
"name": "nginx",
"type": "service",
"path": "/etc/nginx",
"description": "Reverse proxy",
"owner_role": "sysadmin",
},
{
"name": "myapp",
"type": "docker",
"path": "/opt/myapp",
"description": "Main application container",
},
{
"name": "postgres",
"type": "docker",
"path": "/var/lib/postgresql",
"description": "Database",
},
],
}
result = _save_sysadmin_output(ops_conn, "srv", "SRV-001", {"raw_output": json.dumps(output)})
assert result["decisions_added"] == 2
assert result["modules_added"] == 3
decisions = models.get_decisions(ops_conn, "srv")
d_titles = {d["title"] for d in decisions}
assert "nginx proxies to docker app on 8080" in d_titles
assert "postgres data on /var/lib/postgresql" in d_titles
modules = models.get_modules(ops_conn, "srv")
m_names = {m["name"] for m in modules}
assert {"nginx", "myapp", "postgres"} == m_names
def test_invalid_decision_type_normalized_to_decision(self, ops_conn):
"""KIN-071: тип 'workaround' не входит в VALID_DECISION_TYPES → нормализуется в 'decision'."""
from agents.runner import _save_sysadmin_output
output = {
"decisions": [
{
"type": "workaround",
"title": "Use /proc/net for port list",
"description": "ss not installed, fallback to /proc/net/tcp",
"tags": ["networking"],
},
],
"modules": [],
}
_save_sysadmin_output(ops_conn, "srv", "SRV-001", {"raw_output": json.dumps(output)})
decisions = models.get_decisions(ops_conn, "srv")
assert len(decisions) == 1
assert decisions[0]["type"] == "decision"
def test_decision_missing_title_skipped(self, ops_conn):
"""KIN-071: decision без title пропускается."""
from agents.runner import _save_sysadmin_output
output = {
"decisions": [
{"type": "gotcha", "title": "", "description": "Something"},
],
"modules": [],
}
result = _save_sysadmin_output(ops_conn, "srv", "SRV-001", {"raw_output": json.dumps(output)})
assert result["decisions_added"] == 0
def test_module_missing_name_skipped(self, ops_conn):
"""KIN-071: module без name пропускается."""
from agents.runner import _save_sysadmin_output
output = {
"decisions": [],
"modules": [
{"name": "", "type": "service", "path": "/etc/something"},
],
}
result = _save_sysadmin_output(ops_conn, "srv", "SRV-001", {"raw_output": json.dumps(output)})
assert result["modules_added"] == 0