kin: KIN-BIZ-006 Проверить промпт sysadmin.md на поддержку сценария env_scan

This commit is contained in:
Gros Frumos 2026-03-16 19:26:51 +02:00
parent 531275e4ce
commit a58578bb9d
14 changed files with 1619 additions and 13 deletions

View file

@ -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)
# ---------------------------------------------------------------------------