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:
parent
d9172fc17c
commit
75fee86110
4 changed files with 371 additions and 0 deletions
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
132
tests/test_db.py
Normal 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()
|
||||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue