kin: KIN-BIZ-006 Проверить промпт sysadmin.md на поддержку сценария env_scan
This commit is contained in:
parent
531275e4ce
commit
a58578bb9d
14 changed files with 1619 additions and 13 deletions
|
|
@ -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
|
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
|
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)
|
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 <path> remote -v && git -C <path> 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
|
## Rules
|
||||||
|
|
||||||
|
|
@ -90,6 +92,13 @@ Return ONLY valid JSON (no markdown, no explanation):
|
||||||
"owner_role": "sysadmin"
|
"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"],
|
"files_read": ["/etc/nginx/nginx.conf"],
|
||||||
"commands_run": ["uname -a", "docker ps"],
|
"commands_run": ["uname -a", "docker ps"],
|
||||||
"notes": "Any important caveats, things to investigate further, or follow-up tasks needed"
|
"notes": "Any important caveats, things to investigate further, or follow-up tasks needed"
|
||||||
|
|
|
||||||
48
core/chat_intent.py
Normal file
48
core/chat_intent.py
Normal file
|
|
@ -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'
|
||||||
66
core/db.py
66
core/db.py
|
|
@ -224,6 +224,24 @@ CREATE TABLE IF NOT EXISTS support_bot_config (
|
||||||
escalation_keywords JSON
|
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_tasks_project_status ON tasks(project_id, status);
|
||||||
CREATE INDEX IF NOT EXISTS idx_decisions_project ON decisions(project_id);
|
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_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_project ON support_tickets(project_id, status);
|
||||||
CREATE INDEX IF NOT EXISTS idx_tickets_client ON support_tickets(client_id);
|
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(
|
existing_tables = {r[0] for r in conn.execute(
|
||||||
"SELECT name FROM sqlite_master WHERE type='table'"
|
"SELECT name FROM sqlite_master WHERE type='table'"
|
||||||
).fetchall()}
|
).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:
|
if "project_phases" not in existing_tables:
|
||||||
conn.executescript("""
|
conn.executescript("""
|
||||||
CREATE TABLE IF NOT EXISTS project_phases (
|
CREATE TABLE IF NOT EXISTS project_phases (
|
||||||
|
|
@ -441,6 +492,21 @@ def _migrate(conn: sqlite3.Connection):
|
||||||
PRAGMA foreign_keys=ON;
|
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)
|
# Rename legacy 'auto' → 'auto_complete' (KIN-063)
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"UPDATE projects SET execution_mode = 'auto_complete' WHERE execution_mode = 'auto'"
|
"UPDATE projects SET execution_mode = 'auto_complete' WHERE execution_mode = 'auto'"
|
||||||
|
|
|
||||||
189
core/models.py
189
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.
|
Pure functions: (conn, params) → dict | list[dict]. No ORM, no classes.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import base64
|
||||||
import json
|
import json
|
||||||
|
import os
|
||||||
import sqlite3
|
import sqlite3
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import Any
|
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:
|
def delete_project(conn: sqlite3.Connection, id: str) -> None:
|
||||||
"""Delete a project and all its related data (modules, decisions, tasks, phases)."""
|
"""Delete a project and all its related data (modules, decisions, tasks, phases)."""
|
||||||
# Delete tables that have FK references to tasks BEFORE deleting tasks
|
# 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(f"DELETE FROM {table} WHERE project_id = ?", (id,))
|
||||||
conn.execute("DELETE FROM projects WHERE id = ?", (id,))
|
conn.execute("DELETE FROM projects WHERE id = ?", (id,))
|
||||||
conn.commit()
|
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.execute(f"UPDATE project_phases SET {sets} WHERE id = ?", vals)
|
||||||
conn.commit()
|
conn.commit()
|
||||||
return get_phase(conn, phase_id)
|
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())
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@ name = "kin"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
description = "Multi-agent project orchestrator"
|
description = "Multi-agent project orchestrator"
|
||||||
requires-python = ">=3.11"
|
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]
|
[project.scripts]
|
||||||
kin = "cli.main:cli"
|
kin = "cli.main:cli"
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,13 @@ import pytest
|
||||||
from unittest.mock import patch
|
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)
|
@pytest.fixture(autouse=True)
|
||||||
def _mock_check_claude_auth():
|
def _mock_check_claude_auth():
|
||||||
"""Авто-мок agents.runner.check_claude_auth для всех тестов.
|
"""Авто-мок agents.runner.check_claude_auth для всех тестов.
|
||||||
|
|
|
||||||
|
|
@ -1713,4 +1713,412 @@ def test_delete_project_ok(client):
|
||||||
def test_delete_project_not_found(client):
|
def test_delete_project_not_found(client):
|
||||||
r = client.delete("/api/projects/99999")
|
r = client.delete("/api/projects/99999")
|
||||||
assert r.status_code == 404
|
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
|
||||||
|
|
|
||||||
392
web/api.py
392
web/api.py
|
|
@ -815,6 +815,9 @@ def run_task(task_id: str):
|
||||||
if not t:
|
if not t:
|
||||||
conn.close()
|
conn.close()
|
||||||
raise HTTPException(404, f"Task '{task_id}' not found")
|
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
|
# Set task to in_progress immediately so UI updates
|
||||||
models.update_task(conn, task_id, status="in_progress")
|
models.update_task(conn, task_id, status="in_progress")
|
||||||
conn.close()
|
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)
|
# Notifications (escalations from blocked agents)
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
@ -1055,6 +1311,142 @@ def get_notifications(project_id: str | None = None):
|
||||||
return notifications
|
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)
|
# SPA static files (AFTER all /api/ routes)
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
|
@ -226,6 +226,19 @@ export interface AuditResult {
|
||||||
error?: string
|
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 {
|
export interface EscalationNotification {
|
||||||
task_id: string
|
task_id: string
|
||||||
project_id: string
|
project_id: string
|
||||||
|
|
@ -236,6 +249,26 @@ export interface EscalationNotification {
|
||||||
telegram_sent: boolean
|
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 = {
|
export const api = {
|
||||||
projects: () => get<Project[]>('/projects'),
|
projects: () => get<Project[]>('/projects'),
|
||||||
project: (id: string) => get<ProjectDetail>(`/projects/${id}`),
|
project: (id: string) => get<ProjectDetail>(`/projects/${id}`),
|
||||||
|
|
@ -291,4 +324,18 @@ export const api = {
|
||||||
post<{ phase: Phase; new_task: Task }>(`/phases/${phaseId}/revise`, { comment }),
|
post<{ phase: Phase; new_task: Task }>(`/phases/${phaseId}/revise`, { comment }),
|
||||||
startPhase: (projectId: string) =>
|
startPhase: (projectId: string) =>
|
||||||
post<{ status: string; phase_id: number; task_id: string }>(`/projects/${projectId}/phases/start`, {}),
|
post<{ status: string; phase_id: number; task_id: string }>(`/projects/${projectId}/phases/start`, {}),
|
||||||
|
environments: (projectId: string) =>
|
||||||
|
get<ProjectEnvironment[]>(`/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<ProjectEnvironment & { scan_task_id?: string }>(`/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<ProjectEnvironment & { scan_task_id?: string }>(`/projects/${projectId}/environments/${envId}`, data),
|
||||||
|
deleteEnvironment: (projectId: string, envId: number) =>
|
||||||
|
del<void>(`/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<ChatMessage[]>(`/projects/${projectId}/chat?limit=${limit}`),
|
||||||
|
sendChatMessage: (projectId: string, content: string) =>
|
||||||
|
post<ChatSendResult>(`/projects/${projectId}/chat`, { content }),
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ import Dashboard from './views/Dashboard.vue'
|
||||||
import ProjectView from './views/ProjectView.vue'
|
import ProjectView from './views/ProjectView.vue'
|
||||||
import TaskDetail from './views/TaskDetail.vue'
|
import TaskDetail from './views/TaskDetail.vue'
|
||||||
import SettingsView from './views/SettingsView.vue'
|
import SettingsView from './views/SettingsView.vue'
|
||||||
|
import ChatView from './views/ChatView.vue'
|
||||||
|
|
||||||
const router = createRouter({
|
const router = createRouter({
|
||||||
history: createWebHistory(),
|
history: createWebHistory(),
|
||||||
|
|
@ -14,6 +15,7 @@ const router = createRouter({
|
||||||
{ path: '/project/:id', component: ProjectView, props: true },
|
{ path: '/project/:id', component: ProjectView, props: true },
|
||||||
{ path: '/task/:id', component: TaskDetail, props: true },
|
{ path: '/task/:id', component: TaskDetail, props: true },
|
||||||
{ path: '/settings', component: SettingsView },
|
{ path: '/settings', component: SettingsView },
|
||||||
|
{ path: '/chat/:projectId', component: ChatView, props: true },
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
|
||||||
215
web/frontend/src/views/ChatView.vue
Normal file
215
web/frontend/src/views/ChatView.vue
Normal file
|
|
@ -0,0 +1,215 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, watch, nextTick, onUnmounted } from 'vue'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
import { api, ApiError, type ChatMessage } from '../api'
|
||||||
|
import Badge from '../components/Badge.vue'
|
||||||
|
|
||||||
|
const props = defineProps<{ projectId: string }>()
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
|
const messages = ref<ChatMessage[]>([])
|
||||||
|
const input = ref('')
|
||||||
|
const sending = ref(false)
|
||||||
|
const loading = ref(true)
|
||||||
|
const error = ref('')
|
||||||
|
const projectName = ref('')
|
||||||
|
const messagesEl = ref<HTMLElement | null>(null)
|
||||||
|
|
||||||
|
let pollTimer: ReturnType<typeof setInterval> | null = null
|
||||||
|
|
||||||
|
function stopPoll() {
|
||||||
|
if (pollTimer) {
|
||||||
|
clearInterval(pollTimer)
|
||||||
|
pollTimer = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasRunningTasks(msgs: ChatMessage[]) {
|
||||||
|
return msgs.some(
|
||||||
|
m => m.task_stub?.status === 'in_progress' || m.task_stub?.status === 'pending'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function checkAndPoll() {
|
||||||
|
stopPoll()
|
||||||
|
if (!hasRunningTasks(messages.value)) return
|
||||||
|
pollTimer = setInterval(async () => {
|
||||||
|
try {
|
||||||
|
const updated = await api.chatHistory(props.projectId)
|
||||||
|
messages.value = updated
|
||||||
|
if (!hasRunningTasks(updated)) stopPoll()
|
||||||
|
} catch {}
|
||||||
|
}, 3000)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
stopPoll()
|
||||||
|
loading.value = true
|
||||||
|
error.value = ''
|
||||||
|
try {
|
||||||
|
const [msgs, project] = await Promise.all([
|
||||||
|
api.chatHistory(props.projectId),
|
||||||
|
api.project(props.projectId),
|
||||||
|
])
|
||||||
|
messages.value = msgs
|
||||||
|
projectName.value = project.name
|
||||||
|
} catch (e: any) {
|
||||||
|
if (e instanceof ApiError && e.message.includes('not found')) {
|
||||||
|
router.push('/')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
error.value = e.message
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
await nextTick()
|
||||||
|
scrollToBottom()
|
||||||
|
checkAndPoll()
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(() => props.projectId, () => {
|
||||||
|
messages.value = []
|
||||||
|
input.value = ''
|
||||||
|
error.value = ''
|
||||||
|
projectName.value = ''
|
||||||
|
loading.value = true
|
||||||
|
load()
|
||||||
|
}, { immediate: true })
|
||||||
|
|
||||||
|
onUnmounted(stopPoll)
|
||||||
|
|
||||||
|
async function send() {
|
||||||
|
const text = input.value.trim()
|
||||||
|
if (!text || sending.value) return
|
||||||
|
sending.value = true
|
||||||
|
error.value = ''
|
||||||
|
try {
|
||||||
|
const result = await api.sendChatMessage(props.projectId, text)
|
||||||
|
input.value = ''
|
||||||
|
messages.value.push(result.user_message, result.assistant_message)
|
||||||
|
await nextTick()
|
||||||
|
scrollToBottom()
|
||||||
|
checkAndPoll()
|
||||||
|
} catch (e: any) {
|
||||||
|
error.value = e.message
|
||||||
|
} finally {
|
||||||
|
sending.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onKeydown(e: KeyboardEvent) {
|
||||||
|
if (e.key === 'Enter' && !e.shiftKey) {
|
||||||
|
e.preventDefault()
|
||||||
|
send()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function scrollToBottom() {
|
||||||
|
if (messagesEl.value) {
|
||||||
|
messagesEl.value.scrollTop = messagesEl.value.scrollHeight
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function taskStatusColor(status: string): string {
|
||||||
|
const map: Record<string, string> = {
|
||||||
|
done: 'green',
|
||||||
|
in_progress: 'blue',
|
||||||
|
review: 'yellow',
|
||||||
|
blocked: 'red',
|
||||||
|
pending: 'gray',
|
||||||
|
cancelled: 'gray',
|
||||||
|
}
|
||||||
|
return map[status] ?? 'gray'
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTime(dt: string) {
|
||||||
|
return new Date(dt).toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit' })
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="flex flex-col h-[calc(100vh-112px)]">
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="flex items-center gap-3 pb-4 border-b border-gray-800">
|
||||||
|
<router-link
|
||||||
|
:to="`/project/${projectId}`"
|
||||||
|
class="text-gray-400 hover:text-gray-200 text-sm no-underline"
|
||||||
|
>← Проект</router-link>
|
||||||
|
<span class="text-gray-600">|</span>
|
||||||
|
<h1 class="text-base font-semibold text-gray-100">
|
||||||
|
{{ projectName || projectId }}
|
||||||
|
</h1>
|
||||||
|
<span class="text-xs text-gray-500 ml-1">— чат</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Error -->
|
||||||
|
<div v-if="error" class="mt-3 text-sm text-red-400 bg-red-900/20 border border-red-800 rounded px-3 py-2">
|
||||||
|
{{ error }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Loading -->
|
||||||
|
<div v-if="loading" class="flex-1 flex items-center justify-center">
|
||||||
|
<span class="text-gray-500 text-sm">Загрузка...</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Messages -->
|
||||||
|
<div
|
||||||
|
v-else
|
||||||
|
ref="messagesEl"
|
||||||
|
class="flex-1 overflow-y-auto py-4 flex flex-col gap-3 min-h-0"
|
||||||
|
>
|
||||||
|
<div v-if="messages.length === 0" class="text-center text-gray-500 text-sm mt-8">
|
||||||
|
Опишите задачу или спросите о статусе проекта
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-for="msg in messages"
|
||||||
|
:key="msg.id"
|
||||||
|
class="flex"
|
||||||
|
:class="msg.role === 'user' ? 'justify-end' : 'justify-start'"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="max-w-[70%] rounded-2xl px-4 py-2.5"
|
||||||
|
:class="msg.role === 'user'
|
||||||
|
? 'bg-indigo-900/60 border border-indigo-700/50 text-gray-100 rounded-br-sm'
|
||||||
|
: 'bg-gray-800/70 border border-gray-700/50 text-gray-200 rounded-bl-sm'"
|
||||||
|
>
|
||||||
|
<p class="text-sm whitespace-pre-wrap break-words">{{ msg.content }}</p>
|
||||||
|
|
||||||
|
<!-- Task stub for task_created messages -->
|
||||||
|
<div
|
||||||
|
v-if="msg.message_type === 'task_created' && msg.task_stub"
|
||||||
|
class="mt-2 pt-2 border-t border-gray-700/40 flex items-center gap-2"
|
||||||
|
>
|
||||||
|
<router-link
|
||||||
|
:to="`/task/${msg.task_stub.id}`"
|
||||||
|
class="text-xs text-indigo-400 hover:text-indigo-300 no-underline font-mono"
|
||||||
|
>{{ msg.task_stub.id }}</router-link>
|
||||||
|
<Badge :color="taskStatusColor(msg.task_stub.status)" :text="msg.task_stub.status" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="text-xs mt-1.5 text-gray-500">{{ formatTime(msg.created_at) }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Input -->
|
||||||
|
<div class="pt-3 border-t border-gray-800 flex gap-2 items-end">
|
||||||
|
<textarea
|
||||||
|
v-model="input"
|
||||||
|
:disabled="sending || loading"
|
||||||
|
placeholder="Опишите задачу или вопрос... (Enter — отправить, Shift+Enter — перенос)"
|
||||||
|
rows="2"
|
||||||
|
class="flex-1 bg-gray-800/60 border border-gray-700 rounded-xl px-4 py-2.5 text-sm text-gray-100 placeholder-gray-500 resize-none focus:outline-none focus:border-indigo-600 disabled:opacity-50"
|
||||||
|
@keydown="onKeydown"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
:disabled="sending || loading || !input.trim()"
|
||||||
|
class="px-4 py-2.5 bg-indigo-600 hover:bg-indigo-500 disabled:opacity-40 disabled:cursor-not-allowed text-white text-sm rounded-xl font-medium transition-colors"
|
||||||
|
@click="send"
|
||||||
|
>
|
||||||
|
{{ sending ? '...' : 'Отправить' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted, onUnmounted, computed, watch } from 'vue'
|
import { ref, onMounted, onUnmounted, computed, watch } from 'vue'
|
||||||
import { useRoute, useRouter } from 'vue-router'
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
import { api, ApiError, type ProjectDetail, type AuditResult, type Phase, type Task } from '../api'
|
import { api, ApiError, type ProjectDetail, type AuditResult, type Phase, type Task, type ProjectEnvironment } from '../api'
|
||||||
import Badge from '../components/Badge.vue'
|
import Badge from '../components/Badge.vue'
|
||||||
import Modal from '../components/Modal.vue'
|
import Modal from '../components/Modal.vue'
|
||||||
|
|
||||||
|
|
@ -12,7 +12,7 @@ const router = useRouter()
|
||||||
const project = ref<ProjectDetail | null>(null)
|
const project = ref<ProjectDetail | null>(null)
|
||||||
const loading = ref(true)
|
const loading = ref(true)
|
||||||
const error = ref('')
|
const error = ref('')
|
||||||
const activeTab = ref<'tasks' | 'phases' | 'decisions' | 'modules' | 'kanban'>('tasks')
|
const activeTab = ref<'tasks' | 'phases' | 'decisions' | 'modules' | 'kanban' | 'environments'>('tasks')
|
||||||
|
|
||||||
// Phases
|
// Phases
|
||||||
const phases = ref<Phase[]>([])
|
const phases = ref<Phase[]>([])
|
||||||
|
|
@ -246,6 +246,83 @@ async function applyAudit() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Environments
|
||||||
|
const environments = ref<ProjectEnvironment[]>([])
|
||||||
|
const envsLoading = ref(false)
|
||||||
|
const envsError = ref('')
|
||||||
|
const showEnvModal = ref(false)
|
||||||
|
const editingEnv = ref<ProjectEnvironment | null>(null)
|
||||||
|
const envForm = ref({ name: 'prod', host: '', port: 22, username: '', auth_type: 'password', auth_value: '', is_installed: false })
|
||||||
|
const envFormError = ref('')
|
||||||
|
const envSaving = ref(false)
|
||||||
|
const scanTaskId = ref<string | null>(null)
|
||||||
|
const showScanBanner = ref(false)
|
||||||
|
|
||||||
|
async function loadEnvironments() {
|
||||||
|
envsLoading.value = true
|
||||||
|
envsError.value = ''
|
||||||
|
try {
|
||||||
|
environments.value = await api.environments(props.id)
|
||||||
|
} catch (e: any) {
|
||||||
|
envsError.value = e.message
|
||||||
|
} finally {
|
||||||
|
envsLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function openEnvModal(env?: ProjectEnvironment) {
|
||||||
|
editingEnv.value = env || null
|
||||||
|
if (env) {
|
||||||
|
envForm.value = { name: env.name, host: env.host, port: env.port, username: env.username, auth_type: env.auth_type, auth_value: '', is_installed: !!env.is_installed }
|
||||||
|
} else {
|
||||||
|
envForm.value = { name: 'prod', host: '', port: 22, username: '', auth_type: 'password', auth_value: '', is_installed: false }
|
||||||
|
}
|
||||||
|
envFormError.value = ''
|
||||||
|
showEnvModal.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitEnv() {
|
||||||
|
envFormError.value = ''
|
||||||
|
envSaving.value = true
|
||||||
|
try {
|
||||||
|
const payload = {
|
||||||
|
name: envForm.value.name,
|
||||||
|
host: envForm.value.host,
|
||||||
|
port: envForm.value.port,
|
||||||
|
username: envForm.value.username,
|
||||||
|
auth_type: envForm.value.auth_type,
|
||||||
|
auth_value: envForm.value.auth_value || undefined,
|
||||||
|
is_installed: envForm.value.is_installed,
|
||||||
|
}
|
||||||
|
let res: ProjectEnvironment & { scan_task_id?: string }
|
||||||
|
if (editingEnv.value) {
|
||||||
|
res = await api.updateEnvironment(props.id, editingEnv.value.id, payload)
|
||||||
|
} else {
|
||||||
|
res = await api.createEnvironment(props.id, payload)
|
||||||
|
}
|
||||||
|
showEnvModal.value = false
|
||||||
|
await loadEnvironments()
|
||||||
|
if (res.scan_task_id) {
|
||||||
|
scanTaskId.value = res.scan_task_id
|
||||||
|
showScanBanner.value = true
|
||||||
|
}
|
||||||
|
} catch (e: any) {
|
||||||
|
envFormError.value = e.message
|
||||||
|
} finally {
|
||||||
|
envSaving.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteEnv(envId: number) {
|
||||||
|
if (!confirm('Удалить среду?')) return
|
||||||
|
try {
|
||||||
|
await api.deleteEnvironment(props.id, envId)
|
||||||
|
await loadEnvironments()
|
||||||
|
} catch (e: any) {
|
||||||
|
envsError.value = e.message
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Add task modal
|
// Add task modal
|
||||||
const TASK_CATEGORIES = ['SEC', 'UI', 'API', 'INFRA', 'BIZ', 'DB', 'ARCH', 'TEST', 'PERF', 'DOCS', 'FIX', 'OBS']
|
const TASK_CATEGORIES = ['SEC', 'UI', 'API', 'INFRA', 'BIZ', 'DB', 'ARCH', 'TEST', 'PERF', 'DOCS', 'FIX', 'OBS']
|
||||||
const CATEGORY_COLORS: Record<string, string> = {
|
const CATEGORY_COLORS: Record<string, string> = {
|
||||||
|
|
@ -280,6 +357,9 @@ watch(selectedStatuses, (val) => {
|
||||||
|
|
||||||
watch(() => props.id, () => {
|
watch(() => props.id, () => {
|
||||||
taskSearch.value = ''
|
taskSearch.value = ''
|
||||||
|
environments.value = []
|
||||||
|
showScanBanner.value = false
|
||||||
|
scanTaskId.value = null
|
||||||
})
|
})
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
|
|
@ -287,6 +367,7 @@ onMounted(async () => {
|
||||||
loadMode()
|
loadMode()
|
||||||
loadAutocommit()
|
loadAutocommit()
|
||||||
await loadPhases()
|
await loadPhases()
|
||||||
|
await loadEnvironments()
|
||||||
})
|
})
|
||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
|
|
@ -384,17 +465,26 @@ async function addTask() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const runningTaskId = ref<string | null>(null)
|
||||||
|
|
||||||
async function runTask(taskId: string, event: Event) {
|
async function runTask(taskId: string, event: Event) {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
event.stopPropagation()
|
event.stopPropagation()
|
||||||
if (!confirm(`Run pipeline for ${taskId}?`)) return
|
if (!confirm(`Run pipeline for ${taskId}?`)) return
|
||||||
|
runningTaskId.value = taskId
|
||||||
try {
|
try {
|
||||||
await api.runTask(taskId)
|
await api.runTask(taskId)
|
||||||
await load()
|
await load()
|
||||||
if (activeTab.value === 'kanban') checkAndPollKanban()
|
if (activeTab.value === 'kanban') checkAndPollKanban()
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
|
if (e instanceof ApiError && e.code === 'task_already_running') {
|
||||||
|
error.value = 'Pipeline уже запущен'
|
||||||
|
} else {
|
||||||
error.value = e.message
|
error.value = e.message
|
||||||
}
|
}
|
||||||
|
} finally {
|
||||||
|
runningTaskId.value = null
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function patchTaskField(taskId: string, data: { priority?: number; route_type?: string }) {
|
async function patchTaskField(taskId: string, data: { priority?: number; route_type?: string }) {
|
||||||
|
|
@ -518,6 +608,8 @@ async function addDecision() {
|
||||||
<div class="mb-6">
|
<div class="mb-6">
|
||||||
<div class="flex items-center gap-2 mb-1">
|
<div class="flex items-center gap-2 mb-1">
|
||||||
<router-link to="/" class="text-gray-600 hover:text-gray-400 text-sm no-underline">← back</router-link>
|
<router-link to="/" class="text-gray-600 hover:text-gray-400 text-sm no-underline">← back</router-link>
|
||||||
|
<span class="text-gray-700">|</span>
|
||||||
|
<router-link :to="`/chat/${project.id}`" class="text-indigo-500 hover:text-indigo-400 text-sm no-underline">Чат</router-link>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-3 mb-2">
|
<div class="flex items-center gap-3 mb-2">
|
||||||
<h1 class="text-xl font-bold text-gray-100">{{ project.id }}</h1>
|
<h1 class="text-xl font-bold text-gray-100">{{ project.id }}</h1>
|
||||||
|
|
@ -545,18 +637,19 @@ async function addDecision() {
|
||||||
|
|
||||||
<!-- Tabs -->
|
<!-- Tabs -->
|
||||||
<div class="flex gap-1 mb-4 border-b border-gray-800">
|
<div class="flex gap-1 mb-4 border-b border-gray-800">
|
||||||
<button v-for="tab in (['tasks', 'phases', 'decisions', 'modules', 'kanban'] as const)" :key="tab"
|
<button v-for="tab in (['tasks', 'phases', 'decisions', 'modules', 'kanban', 'environments'] as const)" :key="tab"
|
||||||
@click="activeTab = tab"
|
@click="activeTab = tab"
|
||||||
class="px-4 py-2 text-sm border-b-2 transition-colors"
|
class="px-4 py-2 text-sm border-b-2 transition-colors"
|
||||||
:class="activeTab === tab
|
:class="activeTab === tab
|
||||||
? 'text-gray-200 border-blue-500'
|
? 'text-gray-200 border-blue-500'
|
||||||
: 'text-gray-500 border-transparent hover:text-gray-300'">
|
: 'text-gray-500 border-transparent hover:text-gray-300'">
|
||||||
{{ tab === 'kanban' ? 'Kanban' : tab.charAt(0).toUpperCase() + tab.slice(1) }}
|
{{ tab === 'kanban' ? 'Kanban' : tab === 'environments' ? 'Среды' : tab.charAt(0).toUpperCase() + tab.slice(1) }}
|
||||||
<span class="text-xs text-gray-600 ml-1">
|
<span class="text-xs text-gray-600 ml-1">
|
||||||
{{ tab === 'tasks' ? project.tasks.length
|
{{ tab === 'tasks' ? project.tasks.length
|
||||||
: tab === 'phases' ? phases.length
|
: tab === 'phases' ? phases.length
|
||||||
: tab === 'decisions' ? project.decisions.length
|
: tab === 'decisions' ? project.decisions.length
|
||||||
: tab === 'modules' ? project.modules.length
|
: tab === 'modules' ? project.modules.length
|
||||||
|
: tab === 'environments' ? environments.length
|
||||||
: project.tasks.length }}
|
: project.tasks.length }}
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
@ -697,8 +790,12 @@ async function addDecision() {
|
||||||
</select>
|
</select>
|
||||||
<button v-if="t.status === 'pending'"
|
<button v-if="t.status === 'pending'"
|
||||||
@click="runTask(t.id, $event)"
|
@click="runTask(t.id, $event)"
|
||||||
class="px-2 py-0.5 bg-blue-900/40 text-blue-400 border border-blue-800 rounded hover:bg-blue-900 text-[10px]"
|
:disabled="runningTaskId === t.id"
|
||||||
title="Run pipeline">▶</button>
|
class="px-2 py-0.5 bg-blue-900/40 text-blue-400 border border-blue-800 rounded hover:bg-blue-900 text-[10px] disabled:opacity-50"
|
||||||
|
title="Run pipeline">
|
||||||
|
<span v-if="runningTaskId === t.id" class="inline-block w-2 h-2 border border-blue-400 border-t-transparent rounded-full animate-spin"></span>
|
||||||
|
<span v-else>▶</span>
|
||||||
|
</button>
|
||||||
<span v-if="t.status === 'in_progress'"
|
<span v-if="t.status === 'in_progress'"
|
||||||
class="inline-block w-2 h-2 bg-blue-500 rounded-full animate-pulse" title="Running"></span>
|
class="inline-block w-2 h-2 bg-blue-500 rounded-full animate-pulse" title="Running"></span>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -939,6 +1036,61 @@ async function addDecision() {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Environments Tab -->
|
||||||
|
<div v-if="activeTab === 'environments'">
|
||||||
|
<!-- Scan started banner -->
|
||||||
|
<div v-if="showScanBanner" class="mb-4 px-4 py-3 border border-blue-700 bg-blue-950/30 rounded flex items-start justify-between gap-2">
|
||||||
|
<div>
|
||||||
|
<p class="text-sm font-semibold text-blue-300">🔍 Запускаем сканирование среды...</p>
|
||||||
|
<p class="text-xs text-blue-200/70 mt-1">Создана задача сисадмина:
|
||||||
|
<router-link v-if="scanTaskId" :to="`/task/${scanTaskId}`" class="text-blue-400 hover:text-blue-300 no-underline">{{ scanTaskId }}</router-link>
|
||||||
|
</p>
|
||||||
|
<p class="text-xs text-gray-500 mt-1">Агент опишет среду, установленное ПО и настроенный git. При нехватке данных — эскалация к вам.</p>
|
||||||
|
</div>
|
||||||
|
<button @click="showScanBanner = false" class="text-gray-600 hover:text-gray-400 bg-transparent border-none cursor-pointer text-xs shrink-0">✕</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center justify-between mb-3">
|
||||||
|
<span class="text-xs text-gray-500">Серверные окружения проекта</span>
|
||||||
|
<button @click="openEnvModal()"
|
||||||
|
class="px-3 py-1 text-xs bg-gray-800 text-gray-300 border border-gray-700 rounded hover:bg-gray-700">
|
||||||
|
+ Среда
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p v-if="envsLoading" class="text-gray-500 text-sm">Загрузка...</p>
|
||||||
|
<p v-else-if="envsError" class="text-red-400 text-sm">{{ envsError }}</p>
|
||||||
|
<div v-else-if="environments.length === 0" class="text-gray-600 text-sm">Нет сред. Добавьте сервер для развёртывания.</div>
|
||||||
|
<div v-else class="space-y-2">
|
||||||
|
<div v-for="env in environments" :key="env.id"
|
||||||
|
class="px-4 py-3 border border-gray-800 rounded hover:border-gray-700">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<span class="text-sm font-medium text-gray-200">{{ env.name }}</span>
|
||||||
|
<span class="px-1.5 py-0.5 text-[10px] rounded border"
|
||||||
|
:class="env.is_installed ? 'bg-green-900/30 text-green-400 border-green-800' : 'bg-gray-800 text-gray-500 border-gray-700'">
|
||||||
|
{{ env.is_installed ? '✓ установлен' : 'не установлен' }}
|
||||||
|
</span>
|
||||||
|
<span class="px-1.5 py-0.5 text-[10px] bg-gray-800 text-gray-500 border border-gray-700 rounded">{{ env.auth_type }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<button @click="openEnvModal(env)" title="Редактировать"
|
||||||
|
class="px-2 py-0.5 text-xs bg-gray-800 text-gray-400 border border-gray-700 rounded hover:bg-gray-700 hover:text-gray-200">
|
||||||
|
✎
|
||||||
|
</button>
|
||||||
|
<button @click="deleteEnv(env.id)" title="Удалить"
|
||||||
|
class="px-2 py-0.5 text-xs bg-gray-800 text-red-500 border border-gray-700 rounded hover:bg-red-950/30 hover:border-red-800">
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="mt-1 text-xs text-gray-500 flex gap-3 flex-wrap">
|
||||||
|
<span><span class="text-gray-600">host:</span> <span class="text-orange-400">{{ env.username }}@{{ env.host }}:{{ env.port }}</span></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Add Task Modal -->
|
<!-- Add Task Modal -->
|
||||||
<Modal v-if="showAddTask" title="Add Task" @close="showAddTask = false">
|
<Modal v-if="showAddTask" title="Add Task" @close="showAddTask = false">
|
||||||
<form @submit.prevent="addTask" class="space-y-3">
|
<form @submit.prevent="addTask" class="space-y-3">
|
||||||
|
|
@ -1000,6 +1152,72 @@ async function addDecision() {
|
||||||
</form>
|
</form>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
|
<!-- Environment Modal -->
|
||||||
|
<Modal v-if="showEnvModal" :title="editingEnv ? 'Редактировать среду' : 'Добавить среду'" @close="showEnvModal = false">
|
||||||
|
<form @submit.prevent="submitEnv" class="space-y-3">
|
||||||
|
<div>
|
||||||
|
<label class="block text-xs text-gray-500 mb-1">Название</label>
|
||||||
|
<select v-model="envForm.name"
|
||||||
|
class="w-full bg-gray-800 border border-gray-700 rounded px-3 py-2 text-sm text-gray-300">
|
||||||
|
<option value="prod">prod</option>
|
||||||
|
<option value="dev">dev</option>
|
||||||
|
<option value="staging">staging</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<div class="flex-1">
|
||||||
|
<label class="block text-xs text-gray-500 mb-1">Host (IP или домен)</label>
|
||||||
|
<input v-model="envForm.host" placeholder="10.0.0.1" required
|
||||||
|
class="w-full bg-gray-800 border border-gray-700 rounded px-3 py-2 text-sm text-gray-200 placeholder-gray-600" />
|
||||||
|
</div>
|
||||||
|
<div class="w-24">
|
||||||
|
<label class="block text-xs text-gray-500 mb-1">Port</label>
|
||||||
|
<input v-model.number="envForm.port" type="number" min="1" max="65535"
|
||||||
|
class="w-full bg-gray-800 border border-gray-700 rounded px-3 py-2 text-sm text-gray-200" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-xs text-gray-500 mb-1">Login</label>
|
||||||
|
<input v-model="envForm.username" placeholder="root" required
|
||||||
|
class="w-full bg-gray-800 border border-gray-700 rounded px-3 py-2 text-sm text-gray-200 placeholder-gray-600" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-xs text-gray-500 mb-2">Тип авторизации</label>
|
||||||
|
<div class="flex gap-4">
|
||||||
|
<label class="flex items-center gap-1.5 text-sm text-gray-300 cursor-pointer">
|
||||||
|
<input type="radio" v-model="envForm.auth_type" value="password" class="accent-blue-500" />
|
||||||
|
Пароль
|
||||||
|
</label>
|
||||||
|
<label class="flex items-center gap-1.5 text-sm text-gray-300 cursor-pointer">
|
||||||
|
<input type="radio" v-model="envForm.auth_type" value="key" class="accent-blue-500" />
|
||||||
|
SSH ключ
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-xs text-gray-500 mb-1">{{ envForm.auth_type === 'key' ? 'SSH ключ (private key)' : 'Пароль' }}</label>
|
||||||
|
<textarea v-if="envForm.auth_type === 'key'" v-model="envForm.auth_value" rows="4"
|
||||||
|
placeholder="-----BEGIN OPENSSH PRIVATE KEY----- ..."
|
||||||
|
class="w-full bg-gray-800 border border-gray-700 rounded px-3 py-2 text-xs text-gray-200 placeholder-gray-600 resize-y font-mono"></textarea>
|
||||||
|
<input v-else v-model="envForm.auth_value" type="password"
|
||||||
|
:placeholder="editingEnv ? 'Оставьте пустым, чтобы не менять' : 'Пароль'"
|
||||||
|
class="w-full bg-gray-800 border border-gray-700 rounded px-3 py-2 text-sm text-gray-200 placeholder-gray-600" />
|
||||||
|
</div>
|
||||||
|
<label class="flex items-center gap-2 cursor-pointer select-none">
|
||||||
|
<input type="checkbox" v-model="envForm.is_installed" class="accent-blue-500" />
|
||||||
|
<span class="text-sm text-gray-300">☑ Проект уже установлен на сервере</span>
|
||||||
|
</label>
|
||||||
|
<div v-if="envForm.is_installed" class="px-3 py-2 border border-blue-800 bg-blue-950/20 rounded text-xs text-blue-300">
|
||||||
|
После сохранения будет запущен агент-сисадмин для сканирования среды.
|
||||||
|
</div>
|
||||||
|
<p v-if="envFormError" class="text-red-400 text-xs">{{ envFormError }}</p>
|
||||||
|
<button type="submit" :disabled="envSaving"
|
||||||
|
class="w-full py-2 bg-blue-900/50 text-blue-400 border border-blue-800 rounded text-sm hover:bg-blue-900 disabled:opacity-50">
|
||||||
|
{{ envSaving ? 'Сохраняем...' : editingEnv ? 'Сохранить' : 'Добавить' }}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
<!-- Audit Modal -->
|
<!-- Audit Modal -->
|
||||||
<Modal v-if="showAuditModal && auditResult" title="Backlog Audit Results" @close="showAuditModal = false">
|
<Modal v-if="showAuditModal && auditResult" title="Backlog Audit Results" @close="showAuditModal = false">
|
||||||
<div v-if="!auditResult.success" class="text-red-400 text-sm">
|
<div v-if="!auditResult.success" class="text-red-400 text-sm">
|
||||||
|
|
|
||||||
|
|
@ -141,6 +141,7 @@ async function runSync(projectId: string) {
|
||||||
<div v-for="err in syncResults[project.id]!.errors" :key="err" class="text-red-400">{{ err }}</div>
|
<div v-for="err in syncResults[project.id]!.errors" :key="err" class="text-red-400">{{ err }}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@ const error = ref('')
|
||||||
const claudeLoginError = ref(false)
|
const claudeLoginError = ref(false)
|
||||||
const selectedStep = ref<PipelineStep | null>(null)
|
const selectedStep = ref<PipelineStep | null>(null)
|
||||||
const polling = ref(false)
|
const polling = ref(false)
|
||||||
|
const pipelineStarting = ref(false)
|
||||||
let pollTimer: ReturnType<typeof setInterval> | null = null
|
let pollTimer: ReturnType<typeof setInterval> | null = null
|
||||||
|
|
||||||
// Approve modal
|
// Approve modal
|
||||||
|
|
@ -208,6 +209,7 @@ async function revise() {
|
||||||
|
|
||||||
async function runPipeline() {
|
async function runPipeline() {
|
||||||
claudeLoginError.value = false
|
claudeLoginError.value = false
|
||||||
|
pipelineStarting.value = true
|
||||||
try {
|
try {
|
||||||
await api.runTask(props.id)
|
await api.runTask(props.id)
|
||||||
startPolling()
|
startPolling()
|
||||||
|
|
@ -215,9 +217,13 @@ async function runPipeline() {
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
if (e instanceof ApiError && e.code === 'claude_auth_required') {
|
if (e instanceof ApiError && e.code === 'claude_auth_required') {
|
||||||
claudeLoginError.value = true
|
claudeLoginError.value = true
|
||||||
|
} else if (e instanceof ApiError && e.code === 'task_already_running') {
|
||||||
|
error.value = 'Pipeline уже запущен'
|
||||||
} else {
|
} else {
|
||||||
error.value = e.message
|
error.value = e.message
|
||||||
}
|
}
|
||||||
|
} finally {
|
||||||
|
pipelineStarting.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -506,10 +512,10 @@ async function saveEdit() {
|
||||||
</button>
|
</button>
|
||||||
<button v-if="task.status === 'pending' || task.status === 'blocked'"
|
<button v-if="task.status === 'pending' || task.status === 'blocked'"
|
||||||
@click="runPipeline"
|
@click="runPipeline"
|
||||||
:disabled="polling"
|
:disabled="polling || pipelineStarting"
|
||||||
class="px-4 py-2 text-sm bg-blue-900/50 text-blue-400 border border-blue-800 rounded hover:bg-blue-900 disabled:opacity-50">
|
class="px-4 py-2 text-sm bg-blue-900/50 text-blue-400 border border-blue-800 rounded hover:bg-blue-900 disabled:opacity-50">
|
||||||
<span v-if="polling" class="inline-block w-3 h-3 border-2 border-blue-400 border-t-transparent rounded-full animate-spin mr-1"></span>
|
<span v-if="polling || pipelineStarting" class="inline-block w-3 h-3 border-2 border-blue-400 border-t-transparent rounded-full animate-spin mr-1"></span>
|
||||||
{{ polling ? 'Pipeline running...' : '▶ Run Pipeline' }}
|
{{ (polling || pipelineStarting) ? 'Pipeline running...' : '▶ Run Pipeline' }}
|
||||||
</button>
|
</button>
|
||||||
<button v-if="isManualEscalation && task.status !== 'done' && task.status !== 'cancelled'"
|
<button v-if="isManualEscalation && task.status !== 'done' && task.status !== 'cancelled'"
|
||||||
@click="resolveManually"
|
@click="resolveManually"
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue