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

@ -3,7 +3,9 @@ Kin — data access functions for all tables.
Pure functions: (conn, params) dict | list[dict]. No ORM, no classes.
"""
import base64
import json
import os
import sqlite3
from datetime import datetime
from typing import Any
@ -102,7 +104,8 @@ def get_project(conn: sqlite3.Connection, id: str) -> dict | None:
def delete_project(conn: sqlite3.Connection, id: str) -> None:
"""Delete a project and all its related data (modules, decisions, tasks, phases)."""
# Delete tables that have FK references to tasks BEFORE deleting tasks
for table in ("modules", "agent_logs", "decisions", "pipelines", "project_phases", "tasks"):
# project_environments must come before tasks (FK on project_id)
for table in ("modules", "agent_logs", "decisions", "pipelines", "project_phases", "project_environments", "chat_messages", "tasks"):
conn.execute(f"DELETE FROM {table} WHERE project_id = ?", (id,))
conn.execute("DELETE FROM projects WHERE id = ?", (id,))
conn.commit()
@ -700,3 +703,187 @@ def update_phase(conn: sqlite3.Connection, phase_id: int, **fields) -> dict:
conn.execute(f"UPDATE project_phases SET {sets} WHERE id = ?", vals)
conn.commit()
return get_phase(conn, phase_id)
# ---------------------------------------------------------------------------
# Project Environments (KIN-087)
# ---------------------------------------------------------------------------
def _get_fernet():
"""Get Fernet instance using KIN_SECRET_KEY env var.
Raises RuntimeError if KIN_SECRET_KEY is not set.
"""
key = os.environ.get("KIN_SECRET_KEY")
if not key:
raise RuntimeError(
"KIN_SECRET_KEY environment variable is not set. "
"Generate with: python -c \"from cryptography.fernet import Fernet; "
"print(Fernet.generate_key().decode())\""
)
from cryptography.fernet import Fernet
return Fernet(key.encode())
def _encrypt_auth(value: str) -> str:
"""Encrypt auth_value using Fernet (AES-128-CBC + HMAC-SHA256)."""
return _get_fernet().encrypt(value.encode()).decode()
def _decrypt_auth(
stored: str,
conn: sqlite3.Connection | None = None,
env_id: int | None = None,
) -> str:
"""Decrypt auth_value. Handles migration from legacy base64 obfuscation.
If stored value uses the old b64: prefix, decodes it and re-encrypts
in the DB (re-encrypt on read) if conn and env_id are provided.
"""
if not stored:
return stored
from cryptography.fernet import InvalidToken
try:
return _get_fernet().decrypt(stored.encode()).decode()
except (InvalidToken, Exception):
# Legacy b64: format — migrate on read
if stored.startswith("b64:"):
plaintext = base64.b64decode(stored[4:]).decode()
if conn is not None and env_id is not None:
new_encrypted = _encrypt_auth(plaintext)
conn.execute(
"UPDATE project_environments SET auth_value = ? WHERE id = ?",
(new_encrypted, env_id),
)
conn.commit()
return plaintext
return stored
def create_environment(
conn: sqlite3.Connection,
project_id: str,
name: str,
host: str,
username: str,
port: int = 22,
auth_type: str = "password",
auth_value: str | None = None,
is_installed: bool = False,
) -> dict:
"""Create a project environment. auth_value stored Fernet-encrypted; returned as None."""
obfuscated = _encrypt_auth(auth_value) if auth_value else None
cur = conn.execute(
"""INSERT INTO project_environments
(project_id, name, host, port, username, auth_type, auth_value, is_installed)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)""",
(project_id, name, host, port, username, auth_type, obfuscated, int(is_installed)),
)
conn.commit()
row = conn.execute(
"SELECT * FROM project_environments WHERE id = ?", (cur.lastrowid,)
).fetchone()
result = _row_to_dict(row)
result["auth_value"] = None # never expose in API responses
return result
def get_environment(conn: sqlite3.Connection, env_id: int) -> dict | None:
"""Get environment by id including raw obfuscated auth_value (for internal use)."""
row = conn.execute(
"SELECT * FROM project_environments WHERE id = ?", (env_id,)
).fetchone()
return _row_to_dict(row)
def list_environments(conn: sqlite3.Connection, project_id: str) -> list[dict]:
"""List all environments for a project. auth_value is always None in response."""
rows = conn.execute(
"SELECT * FROM project_environments WHERE project_id = ? ORDER BY created_at",
(project_id,),
).fetchall()
result = _rows_to_list(rows)
for env in result:
env["auth_value"] = None
return result
def update_environment(conn: sqlite3.Connection, env_id: int, **fields) -> dict:
"""Update environment fields. Auto-sets updated_at. Returns record with auth_value=None."""
if not fields:
result = get_environment(conn, env_id)
if result:
result["auth_value"] = None
return result
if "auth_value" in fields and fields["auth_value"]:
fields["auth_value"] = _encrypt_auth(fields["auth_value"])
elif "auth_value" in fields:
del fields["auth_value"] # empty/None = don't update auth_value
fields["updated_at"] = datetime.now().isoformat()
sets = ", ".join(f"{k} = ?" for k in fields)
vals = list(fields.values()) + [env_id]
conn.execute(f"UPDATE project_environments SET {sets} WHERE id = ?", vals)
conn.commit()
result = get_environment(conn, env_id)
if result:
result["auth_value"] = None
return result
def delete_environment(conn: sqlite3.Connection, env_id: int) -> bool:
"""Delete environment by id. Returns True if deleted, False if not found."""
cur = conn.execute(
"DELETE FROM project_environments WHERE id = ?", (env_id,)
)
conn.commit()
return cur.rowcount > 0
# ---------------------------------------------------------------------------
# Chat Messages (KIN-OBS-012)
# ---------------------------------------------------------------------------
def add_chat_message(
conn: sqlite3.Connection,
project_id: str,
role: str,
content: str,
message_type: str = "text",
task_id: str | None = None,
) -> dict:
"""Add a chat message and return it as dict.
role: 'user' | 'assistant' | 'system'
message_type: 'text' | 'task_created' | 'error'
task_id: set for message_type='task_created' to link to the created task.
"""
cur = conn.execute(
"""INSERT INTO chat_messages (project_id, role, content, message_type, task_id)
VALUES (?, ?, ?, ?, ?)""",
(project_id, role, content, message_type, task_id),
)
conn.commit()
row = conn.execute(
"SELECT * FROM chat_messages WHERE id = ?", (cur.lastrowid,)
).fetchone()
return _row_to_dict(row)
def get_chat_messages(
conn: sqlite3.Connection,
project_id: str,
limit: int = 50,
before_id: int | None = None,
) -> list[dict]:
"""Get chat messages for a project in chronological order (oldest first).
before_id: pagination cursor return messages with id < before_id.
"""
query = "SELECT * FROM chat_messages WHERE project_id = ?"
params: list = [project_id]
if before_id is not None:
query += " AND id < ?"
params.append(before_id)
query += " ORDER BY created_at ASC, id ASC LIMIT ?"
params.append(limit)
return _rows_to_list(conn.execute(query, params).fetchall())