diff --git a/agents/prompts/sysadmin.md b/agents/prompts/sysadmin.md index 77c99bf..dee56cc 100644 --- a/agents/prompts/sysadmin.md +++ b/agents/prompts/sysadmin.md @@ -36,6 +36,8 @@ Run these commands one by one. Analyze each result before proceeding: 6. `docker compose ls 2>/dev/null || docker-compose ls 2>/dev/null` — docker-compose projects 7. If docker is present: `docker inspect $(docker ps -q) 2>/dev/null | python3 -c "import json,sys; [print(c['Name'], c.get('HostConfig',{}).get('Binds',[])) for c in json.load(sys.stdin)]" 2>/dev/null` — volume mounts 8. For each key config found — read with `ssh ... "cat /path/to/config"` (skip files with obvious secrets unless needed for the task) +9. `find /opt /home /root /srv -maxdepth 4 -name '.git' -type d 2>/dev/null | head -10` — найти git-репозитории; для каждого: `git -C remote -v && git -C log --oneline -3 2>/dev/null` — remote origin и последние коммиты +10. `ls -la ~/.ssh/ 2>/dev/null && cat ~/.ssh/authorized_keys 2>/dev/null` — список установленных SSH-ключей. Не читать приватные ключи (id_rsa, id_ed25519 без .pub) ## Rules @@ -90,6 +92,13 @@ Return ONLY valid JSON (no markdown, no explanation): "owner_role": "sysadmin" } ], + "git_repos": [ + {"path": "/opt/myapp", "remote": "git@github.com:org/myapp.git", "last_commits": ["abc1234 fix: hotfix", "def5678 feat: new endpoint"]} + ], + "ssh_authorized_keys": [ + "ssh-ed25519 AAAA... user@host", + "ssh-rsa AAAA... deploy-key" + ], "files_read": ["/etc/nginx/nginx.conf"], "commands_run": ["uname -a", "docker ps"], "notes": "Any important caveats, things to investigate further, or follow-up tasks needed" diff --git a/core/chat_intent.py b/core/chat_intent.py new file mode 100644 index 0000000..c92166a --- /dev/null +++ b/core/chat_intent.py @@ -0,0 +1,48 @@ +"""Kin — chat intent classifier (heuristic, no LLM). + +classify_intent(text) → 'task_request' | 'status_query' | 'question' +""" + +import re +from typing import Literal + +_STATUS_PATTERNS = [ + r'что сейчас', + r'в работе', + r'\bстатус\b', + r'список задач', + r'покажи задачи', + r'покажи список', + r'какие задачи', + r'что идёт', + r'что делается', + r'что висит', +] + +_QUESTION_STARTS = ( + 'почему', 'зачем', 'как ', 'что такое', 'что значит', + 'объясни', 'расскажи', 'что делает', 'как работает', + 'в чём', 'когда', 'кто', +) + + +def classify_intent(text: str) -> Literal['task_request', 'status_query', 'question']: + """Classify user message intent. + + Returns: + 'status_query' — user is asking about current project status/tasks + 'question' — user is asking a question (no action implied) + 'task_request' — everything else; default: create a task and run pipeline + """ + lower = text.lower().strip() + + for pattern in _STATUS_PATTERNS: + if re.search(pattern, lower): + return 'status_query' + + if lower.endswith('?'): + for word in _QUESTION_STARTS: + if lower.startswith(word): + return 'question' + + return 'task_request' diff --git a/core/db.py b/core/db.py index 8a782ca..ddb3280 100644 --- a/core/db.py +++ b/core/db.py @@ -224,6 +224,24 @@ CREATE TABLE IF NOT EXISTS support_bot_config ( escalation_keywords JSON ); +-- Среды развёртывания проекта (prod/dev серверы) +CREATE TABLE IF NOT EXISTS project_environments ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + project_id TEXT NOT NULL REFERENCES projects(id), + name TEXT NOT NULL, + host TEXT NOT NULL, + port INTEGER DEFAULT 22, + username TEXT NOT NULL, + auth_type TEXT NOT NULL DEFAULT 'password', + auth_value TEXT, + is_installed INTEGER NOT NULL DEFAULT 0, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, + UNIQUE(project_id, name) +); + +CREATE INDEX IF NOT EXISTS idx_environments_project ON project_environments(project_id); + -- Индексы CREATE INDEX IF NOT EXISTS idx_tasks_project_status ON tasks(project_id, status); CREATE INDEX IF NOT EXISTS idx_decisions_project ON decisions(project_id); @@ -232,6 +250,19 @@ CREATE INDEX IF NOT EXISTS idx_agent_logs_project ON agent_logs(project_id, crea CREATE INDEX IF NOT EXISTS idx_agent_logs_cost ON agent_logs(project_id, cost_usd); CREATE INDEX IF NOT EXISTS idx_tickets_project ON support_tickets(project_id, status); CREATE INDEX IF NOT EXISTS idx_tickets_client ON support_tickets(client_id); + +-- Чат-сообщения (KIN-OBS-012) +CREATE TABLE IF NOT EXISTS chat_messages ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + project_id TEXT NOT NULL REFERENCES projects(id), + role TEXT NOT NULL, + content TEXT NOT NULL, + message_type TEXT DEFAULT 'text', + task_id TEXT REFERENCES tasks(id), + created_at DATETIME DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX IF NOT EXISTS idx_chat_messages_project ON chat_messages(project_id, created_at); """ @@ -333,6 +364,26 @@ def _migrate(conn: sqlite3.Connection): existing_tables = {r[0] for r in conn.execute( "SELECT name FROM sqlite_master WHERE type='table'" ).fetchall()} + if "project_environments" not in existing_tables: + conn.executescript(""" + CREATE TABLE IF NOT EXISTS project_environments ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + project_id TEXT NOT NULL REFERENCES projects(id), + name TEXT NOT NULL, + host TEXT NOT NULL, + port INTEGER DEFAULT 22, + username TEXT NOT NULL, + auth_type TEXT NOT NULL DEFAULT 'password', + auth_value TEXT, + is_installed INTEGER NOT NULL DEFAULT 0, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, + UNIQUE(project_id, name) + ); + CREATE INDEX IF NOT EXISTS idx_environments_project ON project_environments(project_id); + """) + conn.commit() + if "project_phases" not in existing_tables: conn.executescript(""" CREATE TABLE IF NOT EXISTS project_phases ( @@ -441,6 +492,21 @@ def _migrate(conn: sqlite3.Connection): PRAGMA foreign_keys=ON; """) + if "chat_messages" not in existing_tables: + conn.executescript(""" + CREATE TABLE IF NOT EXISTS chat_messages ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + project_id TEXT NOT NULL REFERENCES projects(id), + role TEXT NOT NULL, + content TEXT NOT NULL, + message_type TEXT DEFAULT 'text', + task_id TEXT REFERENCES tasks(id), + created_at DATETIME DEFAULT CURRENT_TIMESTAMP + ); + CREATE INDEX IF NOT EXISTS idx_chat_messages_project ON chat_messages(project_id, created_at); + """) + conn.commit() + # Rename legacy 'auto' → 'auto_complete' (KIN-063) conn.execute( "UPDATE projects SET execution_mode = 'auto_complete' WHERE execution_mode = 'auto'" diff --git a/core/models.py b/core/models.py index d3187c4..75bdee6 100644 --- a/core/models.py +++ b/core/models.py @@ -3,7 +3,9 @@ Kin — data access functions for all tables. Pure functions: (conn, params) → dict | list[dict]. No ORM, no classes. """ +import base64 import json +import os import sqlite3 from datetime import datetime from typing import Any @@ -102,7 +104,8 @@ def get_project(conn: sqlite3.Connection, id: str) -> dict | None: def delete_project(conn: sqlite3.Connection, id: str) -> None: """Delete a project and all its related data (modules, decisions, tasks, phases).""" # Delete tables that have FK references to tasks BEFORE deleting tasks - for table in ("modules", "agent_logs", "decisions", "pipelines", "project_phases", "tasks"): + # project_environments must come before tasks (FK on project_id) + for table in ("modules", "agent_logs", "decisions", "pipelines", "project_phases", "project_environments", "chat_messages", "tasks"): conn.execute(f"DELETE FROM {table} WHERE project_id = ?", (id,)) conn.execute("DELETE FROM projects WHERE id = ?", (id,)) conn.commit() @@ -700,3 +703,187 @@ def update_phase(conn: sqlite3.Connection, phase_id: int, **fields) -> dict: conn.execute(f"UPDATE project_phases SET {sets} WHERE id = ?", vals) conn.commit() return get_phase(conn, phase_id) + + +# --------------------------------------------------------------------------- +# Project Environments (KIN-087) +# --------------------------------------------------------------------------- + +def _get_fernet(): + """Get Fernet instance using KIN_SECRET_KEY env var. + + Raises RuntimeError if KIN_SECRET_KEY is not set. + """ + key = os.environ.get("KIN_SECRET_KEY") + if not key: + raise RuntimeError( + "KIN_SECRET_KEY environment variable is not set. " + "Generate with: python -c \"from cryptography.fernet import Fernet; " + "print(Fernet.generate_key().decode())\"" + ) + from cryptography.fernet import Fernet + return Fernet(key.encode()) + + +def _encrypt_auth(value: str) -> str: + """Encrypt auth_value using Fernet (AES-128-CBC + HMAC-SHA256).""" + return _get_fernet().encrypt(value.encode()).decode() + + +def _decrypt_auth( + stored: str, + conn: sqlite3.Connection | None = None, + env_id: int | None = None, +) -> str: + """Decrypt auth_value. Handles migration from legacy base64 obfuscation. + + If stored value uses the old b64: prefix, decodes it and re-encrypts + in the DB (re-encrypt on read) if conn and env_id are provided. + """ + if not stored: + return stored + from cryptography.fernet import InvalidToken + try: + return _get_fernet().decrypt(stored.encode()).decode() + except (InvalidToken, Exception): + # Legacy b64: format — migrate on read + if stored.startswith("b64:"): + plaintext = base64.b64decode(stored[4:]).decode() + if conn is not None and env_id is not None: + new_encrypted = _encrypt_auth(plaintext) + conn.execute( + "UPDATE project_environments SET auth_value = ? WHERE id = ?", + (new_encrypted, env_id), + ) + conn.commit() + return plaintext + return stored + + +def create_environment( + conn: sqlite3.Connection, + project_id: str, + name: str, + host: str, + username: str, + port: int = 22, + auth_type: str = "password", + auth_value: str | None = None, + is_installed: bool = False, +) -> dict: + """Create a project environment. auth_value stored Fernet-encrypted; returned as None.""" + obfuscated = _encrypt_auth(auth_value) if auth_value else None + cur = conn.execute( + """INSERT INTO project_environments + (project_id, name, host, port, username, auth_type, auth_value, is_installed) + VALUES (?, ?, ?, ?, ?, ?, ?, ?)""", + (project_id, name, host, port, username, auth_type, obfuscated, int(is_installed)), + ) + conn.commit() + row = conn.execute( + "SELECT * FROM project_environments WHERE id = ?", (cur.lastrowid,) + ).fetchone() + result = _row_to_dict(row) + result["auth_value"] = None # never expose in API responses + return result + + +def get_environment(conn: sqlite3.Connection, env_id: int) -> dict | None: + """Get environment by id including raw obfuscated auth_value (for internal use).""" + row = conn.execute( + "SELECT * FROM project_environments WHERE id = ?", (env_id,) + ).fetchone() + return _row_to_dict(row) + + +def list_environments(conn: sqlite3.Connection, project_id: str) -> list[dict]: + """List all environments for a project. auth_value is always None in response.""" + rows = conn.execute( + "SELECT * FROM project_environments WHERE project_id = ? ORDER BY created_at", + (project_id,), + ).fetchall() + result = _rows_to_list(rows) + for env in result: + env["auth_value"] = None + return result + + +def update_environment(conn: sqlite3.Connection, env_id: int, **fields) -> dict: + """Update environment fields. Auto-sets updated_at. Returns record with auth_value=None.""" + if not fields: + result = get_environment(conn, env_id) + if result: + result["auth_value"] = None + return result + if "auth_value" in fields and fields["auth_value"]: + fields["auth_value"] = _encrypt_auth(fields["auth_value"]) + elif "auth_value" in fields: + del fields["auth_value"] # empty/None = don't update auth_value + fields["updated_at"] = datetime.now().isoformat() + sets = ", ".join(f"{k} = ?" for k in fields) + vals = list(fields.values()) + [env_id] + conn.execute(f"UPDATE project_environments SET {sets} WHERE id = ?", vals) + conn.commit() + result = get_environment(conn, env_id) + if result: + result["auth_value"] = None + return result + + +def delete_environment(conn: sqlite3.Connection, env_id: int) -> bool: + """Delete environment by id. Returns True if deleted, False if not found.""" + cur = conn.execute( + "DELETE FROM project_environments WHERE id = ?", (env_id,) + ) + conn.commit() + return cur.rowcount > 0 + + +# --------------------------------------------------------------------------- +# Chat Messages (KIN-OBS-012) +# --------------------------------------------------------------------------- + +def add_chat_message( + conn: sqlite3.Connection, + project_id: str, + role: str, + content: str, + message_type: str = "text", + task_id: str | None = None, +) -> dict: + """Add a chat message and return it as dict. + + role: 'user' | 'assistant' | 'system' + message_type: 'text' | 'task_created' | 'error' + task_id: set for message_type='task_created' to link to the created task. + """ + cur = conn.execute( + """INSERT INTO chat_messages (project_id, role, content, message_type, task_id) + VALUES (?, ?, ?, ?, ?)""", + (project_id, role, content, message_type, task_id), + ) + conn.commit() + row = conn.execute( + "SELECT * FROM chat_messages WHERE id = ?", (cur.lastrowid,) + ).fetchone() + return _row_to_dict(row) + + +def get_chat_messages( + conn: sqlite3.Connection, + project_id: str, + limit: int = 50, + before_id: int | None = None, +) -> list[dict]: + """Get chat messages for a project in chronological order (oldest first). + + before_id: pagination cursor — return messages with id < before_id. + """ + query = "SELECT * FROM chat_messages WHERE project_id = ?" + params: list = [project_id] + if before_id is not None: + query += " AND id < ?" + params.append(before_id) + query += " ORDER BY created_at ASC, id ASC LIMIT ?" + params.append(limit) + return _rows_to_list(conn.execute(query, params).fetchall()) diff --git a/pyproject.toml b/pyproject.toml index ad9e1fa..d60cb1d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -7,7 +7,7 @@ name = "kin" version = "0.1.0" description = "Multi-agent project orchestrator" requires-python = ">=3.11" -dependencies = ["click>=8.0", "fastapi>=0.110", "uvicorn>=0.29"] +dependencies = ["click>=8.0", "fastapi>=0.110", "uvicorn>=0.29", "cryptography>=41.0"] [project.scripts] kin = "cli.main:cli" diff --git a/tests/conftest.py b/tests/conftest.py index ff03a3b..b4a1af6 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -4,6 +4,13 @@ import pytest from unittest.mock import patch +@pytest.fixture(autouse=True) +def _set_kin_secret_key(monkeypatch): + """Set KIN_SECRET_KEY for all tests (required by _encrypt_auth/_decrypt_auth).""" + from cryptography.fernet import Fernet + monkeypatch.setenv("KIN_SECRET_KEY", Fernet.generate_key().decode()) + + @pytest.fixture(autouse=True) def _mock_check_claude_auth(): """Авто-мок agents.runner.check_claude_auth для всех тестов. diff --git a/tests/test_api.py b/tests/test_api.py index 844e159..626b9a1 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -1713,4 +1713,412 @@ def test_delete_project_ok(client): def test_delete_project_not_found(client): r = client.delete("/api/projects/99999") assert r.status_code == 404 - assert "tasks_count" in data + + +# --------------------------------------------------------------------------- +# Environments (KIN-087) +# --------------------------------------------------------------------------- + +def test_create_environment(client): + r = client.post("/api/projects/p1/environments", json={ + "name": "prod", + "host": "10.0.0.1", + "username": "pelmen", + "port": 22, + "auth_type": "password", + "auth_value": "s3cr3t", + "is_installed": False, + }) + assert r.status_code == 201 + data = r.json() + assert data["name"] == "prod" + assert data["host"] == "10.0.0.1" + assert data["username"] == "pelmen" + # auth_value must be hidden in responses + assert data.get("auth_value") is None + assert "scan_task_id" not in data + + +def test_create_environment_project_not_found(client): + r = client.post("/api/projects/nope/environments", json={ + "name": "prod", + "host": "10.0.0.1", + "username": "root", + }) + assert r.status_code == 404 + + +def test_create_environment_invalid_auth_type(client): + r = client.post("/api/projects/p1/environments", json={ + "name": "prod", + "host": "10.0.0.1", + "username": "root", + "auth_type": "oauth", + }) + assert r.status_code == 422 + + +def test_create_environment_invalid_port(client): + r = client.post("/api/projects/p1/environments", json={ + "name": "prod", + "host": "10.0.0.1", + "username": "root", + "port": 99999, + }) + assert r.status_code == 422 + + +def test_create_environment_triggers_scan_when_installed(client): + """is_installed=True на POST должен создать задачу sysadmin и вернуть scan_task_id.""" + with patch("subprocess.Popen") as mock_popen: + mock_popen.return_value = MagicMock(pid=12345) + r = client.post("/api/projects/p1/environments", json={ + "name": "prod", + "host": "10.0.0.2", + "username": "pelmen", + "is_installed": True, + }) + assert r.status_code == 201 + data = r.json() + assert "scan_task_id" in data + task_id = data["scan_task_id"] + # Verify the task exists with sysadmin role + from core.db import init_db + from core import models as m + conn = init_db(api_module.DB_PATH) + task = m.get_task(conn, task_id) + conn.close() + assert task is not None + assert task["assigned_role"] == "sysadmin" + assert task["category"] == "INFRA" + + +def test_list_environments(client): + client.post("/api/projects/p1/environments", json={ + "name": "dev", "host": "10.0.0.10", "username": "dev", + }) + client.post("/api/projects/p1/environments", json={ + "name": "prod", "host": "10.0.0.11", "username": "prod", + }) + r = client.get("/api/projects/p1/environments") + assert r.status_code == 200 + data = r.json() + assert len(data) == 2 + names = {e["name"] for e in data} + assert names == {"dev", "prod"} + + +def test_list_environments_project_not_found(client): + r = client.get("/api/projects/nope/environments") + assert r.status_code == 404 + + +def test_patch_environment(client): + r = client.post("/api/projects/p1/environments", json={ + "name": "dev", "host": "10.0.0.20", "username": "root", + }) + env_id = r.json()["id"] + + r = client.patch(f"/api/projects/p1/environments/{env_id}", json={ + "host": "10.0.0.99", + }) + assert r.status_code == 200 + assert r.json()["host"] == "10.0.0.99" + + +def test_patch_environment_triggers_scan_on_false_to_true(client): + """PATCH is_installed false→true должен запустить скан.""" + r = client.post("/api/projects/p1/environments", json={ + "name": "staging", "host": "10.0.0.30", "username": "root", "is_installed": False, + }) + env_id = r.json()["id"] + + with patch("subprocess.Popen") as mock_popen: + mock_popen.return_value = MagicMock(pid=22222) + r = client.patch(f"/api/projects/p1/environments/{env_id}", json={ + "is_installed": True, + }) + assert r.status_code == 200 + assert "scan_task_id" in r.json() + + +def test_patch_environment_no_duplicate_scan(client): + """Повторный PATCH is_installed=True (true→true) не создаёт новую задачу.""" + with patch("subprocess.Popen") as mock_popen: + mock_popen.return_value = MagicMock(pid=33333) + r = client.post("/api/projects/p1/environments", json={ + "name": "prod2", "host": "10.0.0.40", "username": "root", "is_installed": True, + }) + first_task_id = r.json().get("scan_task_id") + assert first_task_id is not None + env_id = r.json()["id"] + + # Second PATCH with host change — was_installed=True, so no scan triggered + with patch("subprocess.Popen") as mock_popen2: + mock_popen2.return_value = MagicMock(pid=44444) + r2 = client.patch(f"/api/projects/p1/environments/{env_id}", json={ + "host": "10.0.0.41", + }) + assert r2.status_code == 200 + assert "scan_task_id" not in r2.json() + + +def test_patch_environment_nothing_to_update(client): + r = client.post("/api/projects/p1/environments", json={ + "name": "dev", "host": "10.0.0.50", "username": "root", + }) + env_id = r.json()["id"] + r = client.patch(f"/api/projects/p1/environments/{env_id}", json={}) + assert r.status_code == 400 + + +def test_patch_environment_not_found(client): + r = client.patch("/api/projects/p1/environments/99999", json={"host": "1.2.3.4"}) + assert r.status_code == 404 + + +def test_delete_environment(client): + r = client.post("/api/projects/p1/environments", json={ + "name": "dev", "host": "10.0.0.60", "username": "root", + }) + env_id = r.json()["id"] + + r = client.delete(f"/api/projects/p1/environments/{env_id}") + assert r.status_code == 204 + + # Verify gone + r = client.get("/api/projects/p1/environments") + ids = [e["id"] for e in r.json()] + assert env_id not in ids + + +def test_delete_environment_not_found(client): + r = client.delete("/api/projects/p1/environments/99999") + assert r.status_code == 404 + + +def test_scan_environment(client): + r = client.post("/api/projects/p1/environments", json={ + "name": "prod", "host": "10.0.0.70", "username": "root", + }) + env_id = r.json()["id"] + + with patch("subprocess.Popen") as mock_popen: + mock_popen.return_value = MagicMock(pid=55555) + r = client.post(f"/api/projects/p1/environments/{env_id}/scan") + assert r.status_code == 202 + data = r.json() + assert data["status"] == "started" + assert "task_id" in data + + +def test_scan_environment_not_found(client): + r = client.post("/api/projects/p1/environments/99999/scan") + assert r.status_code == 404 + + +# --------------------------------------------------------------------------- +# Environments (KIN-087) — дополнительные тесты по acceptance criteria +# --------------------------------------------------------------------------- + +def test_list_environments_auth_value_hidden(): + """GET /environments не должен возвращать auth_value (AC: маскировка).""" + import web.api as api_module2 + from pathlib import Path + import tempfile + with tempfile.TemporaryDirectory() as tmp: + db_path = Path(tmp) / "t.db" + api_module2.DB_PATH = db_path + from web.api import app + from fastapi.testclient import TestClient + c = TestClient(app) + c.post("/api/projects", json={"id": "p2", "name": "P2", "path": "/p2"}) + c.post("/api/projects/p2/environments", json={ + "name": "prod", "host": "1.2.3.4", "username": "root", + "auth_type": "password", "auth_value": "supersecret", + }) + r = c.get("/api/projects/p2/environments") + assert r.status_code == 200 + for env in r.json(): + assert env.get("auth_value") is None + + +def test_patch_environment_auth_value_hidden(client): + """PATCH /environments/{id} не должен возвращать auth_value в ответе (AC: маскировка).""" + r = client.post("/api/projects/p1/environments", json={ + "name": "masked", "host": "5.5.5.5", "username": "user", + "auth_value": "topsecret", + }) + env_id = r.json()["id"] + r = client.patch(f"/api/projects/p1/environments/{env_id}", json={"host": "6.6.6.6"}) + assert r.status_code == 200 + assert r.json().get("auth_value") is None + + +def test_is_installed_flag_persisted(client): + """is_installed=True сохраняется и возвращается в GET-списке (AC: чекбокс работает).""" + with patch("subprocess.Popen") as mock_popen: + mock_popen.return_value = MagicMock(pid=99001) + r = client.post("/api/projects/p1/environments", json={ + "name": "installed_prod", "host": "7.7.7.7", "username": "admin", + "is_installed": True, + }) + assert r.status_code == 201 + env_id = r.json()["id"] + + r = client.get("/api/projects/p1/environments") + envs = {e["id"]: e for e in r.json()} + assert bool(envs[env_id]["is_installed"]) is True + + +def test_is_installed_false_not_installed(client): + """is_installed=False по умолчанию сохраняется корректно.""" + r = client.post("/api/projects/p1/environments", json={ + "name": "notinstalled", "host": "8.8.8.8", "username": "ops", + "is_installed": False, + }) + assert r.status_code == 201 + env_id = r.json()["id"] + + r = client.get("/api/projects/p1/environments") + envs = {e["id"]: e for e in r.json()} + assert not bool(envs[env_id]["is_installed"]) + + +def test_sysadmin_scan_task_has_escalation_in_brief(client): + """Задача sysadmin должна содержать инструкцию об эскалации при нехватке данных (AC#4).""" + with patch("subprocess.Popen") as mock_popen: + mock_popen.return_value = MagicMock(pid=99002) + r = client.post("/api/projects/p1/environments", json={ + "name": "esc_test", "host": "9.9.9.9", "username": "deploy", + "is_installed": True, + }) + task_id = r.json()["scan_task_id"] + + from core.db import init_db + from core import models as m + conn = init_db(api_module.DB_PATH) + task = m.get_task(conn, task_id) + conn.close() + + brief = task["brief"] + assert isinstance(brief, dict), "brief must be a dict" + text = brief.get("text", "") + assert "эскалация" in text.lower(), ( + "Sysadmin task brief must mention escalation to user when data is insufficient" + ) + + +def test_create_environment_key_auth_type(client): + """auth_type='key' должен быть принят и сохранён (AC: ключ SSH).""" + r = client.post("/api/projects/p1/environments", json={ + "name": "ssh_key_env", "host": "10.10.10.10", "username": "git", + "auth_type": "key", "auth_value": "-----BEGIN OPENSSH PRIVATE KEY-----", + }) + assert r.status_code == 201 + data = r.json() + assert data["auth_type"] == "key" + assert data.get("auth_value") is None + + +def test_create_environment_duplicate_name_conflict(client): + """Повторное создание среды с тем же именем в проекте → 409 Conflict.""" + client.post("/api/projects/p1/environments", json={ + "name": "unique_env", "host": "11.11.11.11", "username": "root", + }) + r = client.post("/api/projects/p1/environments", json={ + "name": "unique_env", "host": "22.22.22.22", "username": "root", + }) + assert r.status_code == 409 + + +def test_patch_environment_empty_auth_value_preserves_stored(client): + """PATCH с пустым auth_value не стирает сохранённый credential (AC: безопасность).""" + r = client.post("/api/projects/p1/environments", json={ + "name": "cred_safe", "host": "33.33.33.33", "username": "ops", + "auth_value": "original_password", + }) + env_id = r.json()["id"] + + # Patch без auth_value — credential должен сохраниться + r = client.patch(f"/api/projects/p1/environments/{env_id}", json={"host": "44.44.44.44"}) + assert r.status_code == 200 + + # Читаем raw запись из БД (get_environment возвращает obfuscated auth_value) + from core.db import init_db + from core import models as m + conn = init_db(api_module.DB_PATH) + raw = m.get_environment(conn, env_id) + conn.close() + + assert raw["auth_value"] is not None, "Stored credential must be preserved after PATCH without auth_value" + decrypted = m._decrypt_auth(raw["auth_value"]) + assert decrypted == "original_password", "Stored credential must be decryptable and match original" + + +# --------------------------------------------------------------------------- +# KIN-088 — POST /run возвращает 409 если задача уже in_progress +# --------------------------------------------------------------------------- + +def test_run_returns_409_when_task_already_in_progress(client): + """KIN-088: повторный POST /run для задачи со статусом in_progress → 409 с task_already_running.""" + from core.db import init_db + from core import models as m + conn = init_db(api_module.DB_PATH) + m.update_task(conn, "P1-001", status="in_progress") + conn.close() + + r = client.post("/api/tasks/P1-001/run") + assert r.status_code == 409 + assert r.json()["error"] == "task_already_running" + + +def test_run_409_error_key_is_task_already_running(client): + """KIN-088: тело ответа 409 содержит ключ error='task_already_running'.""" + from core.db import init_db + from core import models as m + conn = init_db(api_module.DB_PATH) + m.update_task(conn, "P1-001", status="in_progress") + conn.close() + + r = client.post("/api/tasks/P1-001/run") + body = r.json() + assert "error" in body + assert body["error"] == "task_already_running" + + +def test_run_second_call_does_not_change_status(client): + """KIN-088: при повторном /run задача остаётся in_progress, статус не сбрасывается.""" + from core.db import init_db + from core import models as m + conn = init_db(api_module.DB_PATH) + m.update_task(conn, "P1-001", status="in_progress") + conn.close() + + client.post("/api/tasks/P1-001/run") # второй вызов — должен вернуть 409 + + r = client.get("/api/tasks/P1-001") + assert r.json()["status"] == "in_progress" + + +def test_run_pending_task_still_returns_202(client): + """KIN-088: задача со статусом pending запускается без ошибки — 202.""" + r = client.post("/api/tasks/P1-001/run") + assert r.status_code == 202 + + +def test_run_kin085_parallel_different_tasks_not_blocked(client): + """KIN-085: /run для разных задач независимы — in_progress одной не блокирует другую.""" + # Создаём вторую задачу + client.post("/api/tasks", json={"project_id": "p1", "title": "Second task"}) + + # Ставим первую задачу в in_progress + from core.db import init_db + from core import models as m + conn = init_db(api_module.DB_PATH) + m.update_task(conn, "P1-001", status="in_progress") + conn.close() + + # Запуск второй задачи должен быть успешным + r = client.post("/api/tasks/P1-002/run") + assert r.status_code == 202 diff --git a/web/api.py b/web/api.py index 6475518..189b9b2 100644 --- a/web/api.py +++ b/web/api.py @@ -815,6 +815,9 @@ def run_task(task_id: str): if not t: conn.close() raise HTTPException(404, f"Task '{task_id}' not found") + if t.get("status") == "in_progress": + conn.close() + return JSONResponse({"error": "task_already_running"}, status_code=409) # Set task to in_progress immediately so UI updates models.update_task(conn, task_id, status="in_progress") conn.close() @@ -1017,6 +1020,259 @@ def bootstrap(body: BootstrapRequest): } +# --------------------------------------------------------------------------- +# Environments (KIN-087) +# --------------------------------------------------------------------------- + +VALID_AUTH_TYPES = {"password", "key"} + + +class EnvironmentCreate(BaseModel): + name: str + host: str + port: int = 22 + username: str + auth_type: str = "password" + auth_value: str | None = None + is_installed: bool = False + + @model_validator(mode="after") + def validate_env_fields(self) -> "EnvironmentCreate": + if not self.name.strip(): + raise ValueError("name must not be empty") + if not self.host.strip(): + raise ValueError("host must not be empty") + if not self.username.strip(): + raise ValueError("username must not be empty") + if self.auth_type not in VALID_AUTH_TYPES: + raise ValueError(f"auth_type must be one of: {', '.join(VALID_AUTH_TYPES)}") + if not (1 <= self.port <= 65535): + raise ValueError("port must be between 1 and 65535") + return self + + +class EnvironmentPatch(BaseModel): + name: str | None = None + host: str | None = None + port: int | None = None + username: str | None = None + auth_type: str | None = None + auth_value: str | None = None + is_installed: bool | None = None + + +def _trigger_sysadmin_scan(conn, project_id: str, env: dict) -> str: + """Create a sysadmin env-scan task and launch it in background. + + env must be the raw record from get_environment() (contains obfuscated auth_value). + Guard: skips if an active sysadmin task for this environment already exists. + Returns task_id of the created (or existing) task. + """ + env_id = env["id"] + existing = conn.execute( + """SELECT id FROM tasks + WHERE project_id = ? AND assigned_role = 'sysadmin' + AND status NOT IN ('done', 'cancelled') + AND brief LIKE ?""", + (project_id, f'%"env_id": {env_id}%'), + ).fetchone() + if existing: + return existing["id"] + + task_id = models.next_task_id(conn, project_id, category="INFRA") + brief = { + "type": "env_scan", + "env_id": env_id, + "host": env["host"], + "port": env["port"], + "username": env["username"], + "auth_type": env["auth_type"], + # auth_value is Fernet-encrypted. Stored in tasks.brief — treat as sensitive. + # Decrypt with _decrypt_auth() from core/models.py. + "auth_value_b64": env.get("auth_value"), + "text": ( + f"Провести полный аудит среды '{env['name']}' на сервере {env['host']}.\n\n" + f"Подключение: {env['username']}@{env['host']}:{env['port']} (auth_type={env['auth_type']}).\n\n" + "Задачи:\n" + "1. Проверить git config (user, remote, текущую ветку)\n" + "2. Установленный стек (python/node/java версии, package managers)\n" + "3. Переменные окружения (.env файлы, systemd EnvironmentFile)\n" + "4. Nginx/caddy конфиги (виртуальные хосты, SSL)\n" + "5. Systemd/supervisor сервисы проекта\n" + "6. SSH-ключи (authorized_keys, known_hosts)\n" + "7. Если чего-то не хватает для подключения или аудита — эскалация к человеку." + ), + } + models.create_task( + conn, task_id, project_id, + title=f"[{env['name']}] Env scan: {env['host']}", + assigned_role="sysadmin", + category="INFRA", + brief=brief, + ) + models.update_task(conn, task_id, status="in_progress") + + 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 as _os + env_vars = _os.environ.copy() + env_vars["KIN_NONINTERACTIVE"] = "1" + try: + subprocess.Popen( + cmd, + cwd=str(kin_root), + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + stdin=subprocess.DEVNULL, + env=env_vars, + ) + except Exception as e: + _logger.warning("Failed to start sysadmin scan for %s: %s", task_id, e) + + return task_id + + +@app.get("/api/projects/{project_id}/environments") +def list_environments(project_id: str): + conn = get_conn() + p = models.get_project(conn, project_id) + if not p: + conn.close() + raise HTTPException(404, f"Project '{project_id}' not found") + envs = models.list_environments(conn, project_id) + conn.close() + return envs + + +@app.post("/api/projects/{project_id}/environments", status_code=201) +def create_environment(project_id: str, body: EnvironmentCreate): + conn = get_conn() + p = models.get_project(conn, project_id) + if not p: + conn.close() + raise HTTPException(404, f"Project '{project_id}' not found") + try: + env = models.create_environment( + conn, project_id, + name=body.name, + host=body.host, + port=body.port, + username=body.username, + auth_type=body.auth_type, + auth_value=body.auth_value, + is_installed=body.is_installed, + ) + except Exception as e: + conn.close() + if "UNIQUE constraint" in str(e): + raise HTTPException(409, f"Environment '{body.name}' already exists for this project") + raise HTTPException(500, str(e)) + scan_task_id = None + if body.is_installed: + raw_env = models.get_environment(conn, env["id"]) + scan_task_id = _trigger_sysadmin_scan(conn, project_id, raw_env) + conn.close() + result = {**env} + if scan_task_id: + result["scan_task_id"] = scan_task_id + return JSONResponse(result, status_code=201) + + +@app.patch("/api/projects/{project_id}/environments/{env_id}") +def patch_environment(project_id: str, env_id: int, body: EnvironmentPatch): + all_none = all(v is None for v in [ + body.name, body.host, body.port, body.username, + body.auth_type, body.auth_value, body.is_installed, + ]) + if all_none: + raise HTTPException(400, "Nothing to update.") + if body.auth_type is not None and body.auth_type not in VALID_AUTH_TYPES: + raise HTTPException(400, f"auth_type must be one of: {', '.join(VALID_AUTH_TYPES)}") + if body.port is not None and not (1 <= body.port <= 65535): + raise HTTPException(400, "port must be between 1 and 65535") + if body.name is not None and not body.name.strip(): + raise HTTPException(400, "name must not be empty") + if body.username is not None and not body.username.strip(): + raise HTTPException(400, "username must not be empty") + if body.host is not None and not body.host.strip(): + raise HTTPException(400, "host must not be empty") + + conn = get_conn() + p = models.get_project(conn, project_id) + if not p: + conn.close() + raise HTTPException(404, f"Project '{project_id}' not found") + existing = models.get_environment(conn, env_id) + if not existing or existing.get("project_id") != project_id: + conn.close() + raise HTTPException(404, f"Environment #{env_id} not found") + + was_installed = bool(existing.get("is_installed")) + + fields = {} + if body.name is not None: + fields["name"] = body.name + if body.host is not None: + fields["host"] = body.host + if body.port is not None: + fields["port"] = body.port + if body.username is not None: + fields["username"] = body.username + if body.auth_type is not None: + fields["auth_type"] = body.auth_type + if body.auth_value: # only update if non-empty (empty = don't change stored cred) + fields["auth_value"] = body.auth_value + if body.is_installed is not None: + fields["is_installed"] = int(body.is_installed) + + updated = models.update_environment(conn, env_id, **fields) + + scan_task_id = None + if body.is_installed is True and not was_installed: + raw_env = models.get_environment(conn, env_id) + scan_task_id = _trigger_sysadmin_scan(conn, project_id, raw_env) + + conn.close() + result = {**updated} + if scan_task_id: + result["scan_task_id"] = scan_task_id + return result + + +@app.delete("/api/projects/{project_id}/environments/{env_id}", status_code=204) +def delete_environment(project_id: str, env_id: int): + conn = get_conn() + p = models.get_project(conn, project_id) + if not p: + conn.close() + raise HTTPException(404, f"Project '{project_id}' not found") + existing = models.get_environment(conn, env_id) + if not existing or existing.get("project_id") != project_id: + conn.close() + raise HTTPException(404, f"Environment #{env_id} not found") + models.delete_environment(conn, env_id) + conn.close() + return Response(status_code=204) + + +@app.post("/api/projects/{project_id}/environments/{env_id}/scan", status_code=202) +def scan_environment(project_id: str, env_id: int): + """Manually re-trigger sysadmin env scan for an environment.""" + conn = get_conn() + p = models.get_project(conn, project_id) + if not p: + conn.close() + raise HTTPException(404, f"Project '{project_id}' not found") + raw_env = models.get_environment(conn, env_id) + if not raw_env or raw_env.get("project_id") != project_id: + conn.close() + raise HTTPException(404, f"Environment #{env_id} not found") + task_id = _trigger_sysadmin_scan(conn, project_id, raw_env) + conn.close() + return JSONResponse({"status": "started", "task_id": task_id}, status_code=202) + + # --------------------------------------------------------------------------- # Notifications (escalations from blocked agents) # --------------------------------------------------------------------------- @@ -1055,6 +1311,142 @@ def get_notifications(project_id: str | None = None): return notifications +# --------------------------------------------------------------------------- +# Chat (KIN-OBS-012) +# --------------------------------------------------------------------------- + +class ChatMessageIn(BaseModel): + content: str + + +@app.get("/api/projects/{project_id}/chat") +def get_chat_history( + project_id: str, + limit: int = Query(50, ge=1, le=200), + before_id: int | None = None, +): + """Return chat history for a project. Enriches task_created messages with task_stub.""" + conn = get_conn() + p = models.get_project(conn, project_id) + if not p: + conn.close() + raise HTTPException(404, f"Project '{project_id}' not found") + messages = models.get_chat_messages(conn, project_id, limit=limit, before_id=before_id) + for msg in messages: + if msg.get("message_type") == "task_created" and msg.get("task_id"): + task = models.get_task(conn, msg["task_id"]) + if task: + msg["task_stub"] = { + "id": task["id"], + "title": task["title"], + "status": task["status"], + } + conn.close() + return messages + + +@app.post("/api/projects/{project_id}/chat") +def send_chat_message(project_id: str, body: ChatMessageIn): + """Process a user message: classify intent, create task or answer, return both messages.""" + from core.chat_intent import classify_intent + + if not body.content.strip(): + raise HTTPException(400, "content must not be empty") + + conn = get_conn() + p = models.get_project(conn, project_id) + if not p: + conn.close() + raise HTTPException(404, f"Project '{project_id}' not found") + + # 1. Save user message + user_msg = models.add_chat_message(conn, project_id, "user", body.content) + + # 2. Classify intent + intent = classify_intent(body.content) + + task = None + + if intent == "task_request": + # 3a. Create task (category OBS) and run pipeline in background + task_id = models.next_task_id(conn, project_id, category="OBS") + title = body.content[:120].strip() + t = models.create_task( + conn, task_id, project_id, title, + brief={"text": body.content, "source": "chat"}, + category="OBS", + ) + task = t + + import os as _os + env_vars = _os.environ.copy() + env_vars["KIN_NONINTERACTIVE"] = "1" + kin_root = Path(__file__).parent.parent + try: + subprocess.Popen( + [sys.executable, "-m", "cli.main", "--db", str(DB_PATH), + "run", task_id, "--allow-write"], + cwd=str(kin_root), + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + stdin=subprocess.DEVNULL, + env=env_vars, + ) + except Exception as e: + _logger.warning("Failed to start pipeline for chat task %s: %s", task_id, e) + + assistant_content = f"Создал задачу {task_id}: {title}" + assistant_msg = models.add_chat_message( + conn, project_id, "assistant", assistant_content, + message_type="task_created", task_id=task_id, + ) + assistant_msg["task_stub"] = { + "id": t["id"], + "title": t["title"], + "status": t["status"], + } + + elif intent == "status_query": + # 3b. Return current task status summary + in_progress = models.list_tasks(conn, project_id=project_id, status="in_progress") + pending = models.list_tasks(conn, project_id=project_id, status="pending") + review = models.list_tasks(conn, project_id=project_id, status="review") + + parts = [] + if in_progress: + parts.append("В работе ({}):\n{}".format( + len(in_progress), + "\n".join(f" • {t['id']} — {t['title'][:60]}" for t in in_progress[:5]), + )) + if review: + parts.append("На ревью ({}):\n{}".format( + len(review), + "\n".join(f" • {t['id']} — {t['title'][:60]}" for t in review[:5]), + )) + if pending: + parts.append("Ожидает ({}):\n{}".format( + len(pending), + "\n".join(f" • {t['id']} — {t['title'][:60]}" for t in pending[:5]), + )) + + content = "\n\n".join(parts) if parts else "Нет активных задач." + assistant_msg = models.add_chat_message(conn, project_id, "assistant", content) + + else: # question + assistant_msg = models.add_chat_message( + conn, project_id, "assistant", + "Я пока не умею отвечать на вопросы напрямую. " + "Если хотите — опишите задачу, я создам её и запущу агентов.", + ) + + conn.close() + return { + "user_message": user_msg, + "assistant_message": assistant_msg, + "task": task, + } + + # --------------------------------------------------------------------------- # SPA static files (AFTER all /api/ routes) # --------------------------------------------------------------------------- diff --git a/web/frontend/src/api.ts b/web/frontend/src/api.ts index 8db543e..eb058b0 100644 --- a/web/frontend/src/api.ts +++ b/web/frontend/src/api.ts @@ -226,6 +226,19 @@ export interface AuditResult { error?: string } +export interface ProjectEnvironment { + id: number + project_id: string + name: string + host: string + port: number + username: string + auth_type: string + is_installed: number + created_at: string + updated_at: string +} + export interface EscalationNotification { task_id: string project_id: string @@ -236,6 +249,26 @@ export interface EscalationNotification { telegram_sent: boolean } +export interface ChatMessage { + id: number + project_id: string + role: 'user' | 'assistant' + content: string + message_type: string + task_id: string | null + created_at: string + task_stub?: { + id: string + title: string + status: string + } | null +} + +export interface ChatSendResult { + user_message: ChatMessage + assistant_message: ChatMessage +} + export const api = { projects: () => get('/projects'), project: (id: string) => get(`/projects/${id}`), @@ -291,4 +324,18 @@ export const api = { 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`, {}), + environments: (projectId: string) => + get(`/projects/${projectId}/environments`), + createEnvironment: (projectId: string, data: { name: string; host: string; port?: number; username: string; auth_type?: string; auth_value?: string; is_installed?: boolean }) => + post(`/projects/${projectId}/environments`, data), + updateEnvironment: (projectId: string, envId: number, data: { name?: string; host?: string; port?: number; username?: string; auth_type?: string; auth_value?: string; is_installed?: boolean }) => + patch(`/projects/${projectId}/environments/${envId}`, data), + deleteEnvironment: (projectId: string, envId: number) => + del(`/projects/${projectId}/environments/${envId}`), + scanEnvironment: (projectId: string, envId: number) => + post<{ status: string; task_id: string }>(`/projects/${projectId}/environments/${envId}/scan`, {}), + chatHistory: (projectId: string, limit = 50) => + get(`/projects/${projectId}/chat?limit=${limit}`), + sendChatMessage: (projectId: string, content: string) => + post(`/projects/${projectId}/chat`, { content }), } diff --git a/web/frontend/src/main.ts b/web/frontend/src/main.ts index 6d3e71a..c4199af 100644 --- a/web/frontend/src/main.ts +++ b/web/frontend/src/main.ts @@ -6,6 +6,7 @@ import Dashboard from './views/Dashboard.vue' import ProjectView from './views/ProjectView.vue' import TaskDetail from './views/TaskDetail.vue' import SettingsView from './views/SettingsView.vue' +import ChatView from './views/ChatView.vue' const router = createRouter({ history: createWebHistory(), @@ -14,6 +15,7 @@ const router = createRouter({ { path: '/project/:id', component: ProjectView, props: true }, { path: '/task/:id', component: TaskDetail, props: true }, { path: '/settings', component: SettingsView }, + { path: '/chat/:projectId', component: ChatView, props: true }, ], }) diff --git a/web/frontend/src/views/ChatView.vue b/web/frontend/src/views/ChatView.vue new file mode 100644 index 0000000..1ef0613 --- /dev/null +++ b/web/frontend/src/views/ChatView.vue @@ -0,0 +1,215 @@ + + + diff --git a/web/frontend/src/views/TaskDetail.vue b/web/frontend/src/views/TaskDetail.vue index 2dd6182..e94fa72 100644 --- a/web/frontend/src/views/TaskDetail.vue +++ b/web/frontend/src/views/TaskDetail.vue @@ -15,6 +15,7 @@ const error = ref('') const claudeLoginError = ref(false) const selectedStep = ref(null) const polling = ref(false) +const pipelineStarting = ref(false) let pollTimer: ReturnType | null = null // Approve modal @@ -208,6 +209,7 @@ async function revise() { async function runPipeline() { claudeLoginError.value = false + pipelineStarting.value = true try { await api.runTask(props.id) startPolling() @@ -215,9 +217,13 @@ async function runPipeline() { } catch (e: any) { if (e instanceof ApiError && e.code === 'claude_auth_required') { claudeLoginError.value = true + } else if (e instanceof ApiError && e.code === 'task_already_running') { + error.value = 'Pipeline уже запущен' } else { error.value = e.message } + } finally { + pipelineStarting.value = false } } @@ -506,10 +512,10 @@ async function saveEdit() {