""" Kin Web API — FastAPI backend reading ~/.kin/kin.db via core.models. Run: uvicorn web.api:app --reload --port 8420 """ import logging import mimetypes import shutil import subprocess import sys from pathlib import Path # Ensure project root on sys.path sys.path.insert(0, str(Path(__file__).parent.parent)) from fastapi import FastAPI, File, HTTPException, Query, UploadFile from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import JSONResponse, FileResponse, Response from fastapi.staticfiles import StaticFiles from pydantic import BaseModel, model_validator from core.db import init_db from core import models from core.models import VALID_COMPLETION_MODES, TASK_CATEGORIES from agents.bootstrap import ( detect_tech_stack, detect_modules, extract_decisions_from_claude_md, find_vault_root, scan_obsidian, save_to_db, ) DB_PATH = Path.home() / ".kin" / "kin.db" _logger = logging.getLogger("kin") # --------------------------------------------------------------------------- # Startup: verify claude CLI is available in PATH # --------------------------------------------------------------------------- def _check_claude_available() -> None: """Warn at startup if claude CLI cannot be found in PATH. launchctl daemons run with a stripped environment and may not see /opt/homebrew/bin where claude is typically installed. See Decision #28. """ from agents.runner import _build_claude_env # avoid circular import at module level env = _build_claude_env() claude_path = shutil.which("claude", path=env["PATH"]) if claude_path: _logger.info("claude CLI found: %s", claude_path) else: _logger.warning( "WARNING: claude CLI not found in PATH (%s). " "Agent pipelines will fail with returncode 127. " "Fix: add /opt/homebrew/bin to EnvironmentVariables.PATH in " "~/Library/LaunchAgents/com.kin.api.plist and reload with: " "launchctl unload ~/Library/LaunchAgents/com.kin.api.plist && " "launchctl load ~/Library/LaunchAgents/com.kin.api.plist", env.get("PATH", ""), ) def _check_git_available() -> None: """Warn at startup if git cannot be found in PATH. launchctl daemons run with a stripped environment and may not see git in the standard directories. See Decision #28. """ from agents.runner import _build_claude_env # avoid circular import at module level env = _build_claude_env() git_path = shutil.which("git", path=env["PATH"]) if git_path: _logger.info("git found: %s", git_path) else: _logger.warning( "WARNING: git not found in PATH (%s). " "Autocommit will fail silently. " "Fix: add git directory to EnvironmentVariables.PATH in " "~/Library/LaunchAgents/com.kin.api.plist and reload with: " "launchctl unload ~/Library/LaunchAgents/com.kin.api.plist && " "launchctl load ~/Library/LaunchAgents/com.kin.api.plist", env.get("PATH", ""), ) _check_claude_available() _check_git_available() app = FastAPI(title="Kin API", version="0.1.0") # Start pipeline watchdog (KIN-099): detects dead subprocess PIDs every 30s from core.watchdog import start_watchdog as _start_watchdog _start_watchdog(DB_PATH) app.add_middleware( CORSMiddleware, allow_origins=["*"], allow_methods=["*"], allow_headers=["*"], ) def get_conn(): return init_db(DB_PATH) def _launch_pipeline_subprocess(task_id: str) -> None: """Spawn `cli.main run {task_id}` in a detached background subprocess. Used by auto-trigger (label 'auto') and revise endpoint. Never raises — subprocess errors are logged only. """ import os kin_root = Path(__file__).parent.parent cmd = [sys.executable, "-m", "cli.main", "--db", str(DB_PATH), "run", task_id] cmd.append("--allow-write") env = os.environ.copy() env["KIN_NONINTERACTIVE"] = "1" try: proc = subprocess.Popen( cmd, cwd=str(kin_root), stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, stdin=subprocess.DEVNULL, env=env, ) _logger.info("Auto-triggered pipeline for %s, pid=%d", task_id, proc.pid) except Exception as exc: _logger.warning("Failed to launch pipeline for %s: %s", task_id, exc) # --------------------------------------------------------------------------- # Projects # --------------------------------------------------------------------------- @app.get("/api/projects") def list_projects(status: str | None = None): conn = get_conn() summary = models.get_project_summary(conn) if status: summary = [s for s in summary if s["status"] == status] conn.close() return summary class NewProjectCreate(BaseModel): id: str name: str path: str | None = None description: str roles: list[str] tech_stack: list[str] | None = None priority: int = 5 language: str = "ru" @app.post("/api/projects/new") def new_project_with_phases(body: NewProjectCreate): """Create project + sequential research phases (KIN-059).""" from core.phases import create_project_with_phases, validate_roles clean_roles = validate_roles(body.roles) if not clean_roles: raise HTTPException(400, "At least one research role must be selected (excluding architect)") conn = get_conn() if models.get_project(conn, body.id): conn.close() raise HTTPException(409, f"Project '{body.id}' already exists") try: result = create_project_with_phases( conn, body.id, body.name, body.path, description=body.description, selected_roles=clean_roles, tech_stack=body.tech_stack, priority=body.priority, language=body.language, ) except ValueError as e: conn.close() raise HTTPException(400, str(e)) conn.close() return result @app.get("/api/projects/{project_id}") def get_project(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") tasks = models.list_tasks(conn, project_id=project_id) mods = models.get_modules(conn, project_id) decisions = models.get_decisions(conn, project_id) conn.close() return {**p, "tasks": tasks, "modules": mods, "decisions": decisions} VALID_PROJECT_TYPES = {"development", "operations", "research"} class ProjectCreate(BaseModel): id: str name: str path: str | None = None tech_stack: list[str] | None = None status: str = "active" priority: int = 5 project_type: str = "development" ssh_host: str | None = None ssh_user: str | None = None ssh_key_path: str | None = None ssh_proxy_jump: str | None = None test_command: str = 'make test' @model_validator(mode="after") def validate_fields(self) -> "ProjectCreate": if self.project_type == "operations" and not self.ssh_host: raise ValueError("ssh_host is required for operations projects") if self.project_type != "operations" and not self.path: raise ValueError("path is required for non-operations projects") return self class ProjectPatch(BaseModel): execution_mode: str | None = None autocommit_enabled: bool | None = None auto_test_enabled: bool | None = None obsidian_vault_path: str | None = None deploy_command: str | None = None test_command: str | None = None project_type: str | None = None ssh_host: str | None = None ssh_user: str | None = None ssh_key_path: str | None = None ssh_proxy_jump: str | None = None @app.patch("/api/projects/{project_id}") def patch_project(project_id: str, body: ProjectPatch): has_any = any([ body.execution_mode, body.autocommit_enabled is not None, body.auto_test_enabled is not None, body.obsidian_vault_path, body.deploy_command is not None, body.test_command is not None, body.project_type, body.ssh_host is not None, body.ssh_user is not None, body.ssh_key_path is not None, body.ssh_proxy_jump is not None, ]) if not has_any: raise HTTPException(400, "Nothing to update.") if body.execution_mode is not None and body.execution_mode not in VALID_EXECUTION_MODES: raise HTTPException(400, f"Invalid execution_mode '{body.execution_mode}'. Must be one of: {', '.join(VALID_EXECUTION_MODES)}") if body.project_type is not None and body.project_type not in VALID_PROJECT_TYPES: raise HTTPException(400, f"Invalid project_type '{body.project_type}'. Must be one of: {', '.join(VALID_PROJECT_TYPES)}") conn = get_conn() p = models.get_project(conn, project_id) if not p: conn.close() raise HTTPException(404, f"Project '{project_id}' not found") fields = {} if body.execution_mode is not None: fields["execution_mode"] = body.execution_mode if body.autocommit_enabled is not None: fields["autocommit_enabled"] = int(body.autocommit_enabled) if body.auto_test_enabled is not None: fields["auto_test_enabled"] = int(body.auto_test_enabled) if body.obsidian_vault_path is not None: fields["obsidian_vault_path"] = body.obsidian_vault_path if body.deploy_command is not None: # Empty string = sentinel for clearing (decision #68) fields["deploy_command"] = None if body.deploy_command == "" else body.deploy_command if body.test_command is not None: fields["test_command"] = body.test_command if body.project_type is not None: fields["project_type"] = body.project_type if body.ssh_host is not None: fields["ssh_host"] = body.ssh_host if body.ssh_user is not None: fields["ssh_user"] = body.ssh_user if body.ssh_key_path is not None: fields["ssh_key_path"] = body.ssh_key_path if body.ssh_proxy_jump is not None: fields["ssh_proxy_jump"] = body.ssh_proxy_jump models.update_project(conn, project_id, **fields) p = models.get_project(conn, project_id) conn.close() return p @app.delete("/api/projects/{project_id}", status_code=204) def delete_project(project_id: str): """Delete a project and all its related data (tasks, decisions, phases, logs).""" conn = get_conn() p = models.get_project(conn, project_id) if not p: conn.close() raise HTTPException(404, f"Project '{project_id}' not found") models.delete_project(conn, project_id) conn.close() return Response(status_code=204) @app.post("/api/projects/{project_id}/sync/obsidian") def sync_obsidian_endpoint(project_id: str): """Запускает двусторонний Obsidian sync для проекта.""" from core.obsidian_sync import sync_obsidian conn = get_conn() p = models.get_project(conn, project_id) if not p: conn.close() raise HTTPException(404, f"Project '{project_id}' not found") if not p.get("obsidian_vault_path"): conn.close() raise HTTPException(400, "obsidian_vault_path not set for this project") result = sync_obsidian(conn, project_id) conn.close() return result @app.post("/api/projects/{project_id}/deploy") def deploy_project(project_id: str): """Execute deploy_command for a project. Returns stdout/stderr/exit_code. # WARNING: shell=True — deploy_command is admin-only, set in Settings by the project owner. """ import time conn = get_conn() p = models.get_project(conn, project_id) conn.close() if not p: raise HTTPException(404, f"Project '{project_id}' not found") deploy_command = p.get("deploy_command") if not deploy_command: raise HTTPException(400, "deploy_command not set for this project") cwd = p.get("path") or None start = time.monotonic() try: result = subprocess.run( deploy_command, shell=True, # WARNING: shell=True — command is admin-only cwd=cwd, capture_output=True, text=True, timeout=60, ) except subprocess.TimeoutExpired: raise HTTPException(504, "Deploy command timed out after 60 seconds") except Exception as e: raise HTTPException(500, f"Deploy failed: {e}") duration = round(time.monotonic() - start, 2) return { "success": result.returncode == 0, "exit_code": result.returncode, "stdout": result.stdout, "stderr": result.stderr, "duration_seconds": duration, } @app.post("/api/projects") def create_project(body: ProjectCreate): if body.project_type not in VALID_PROJECT_TYPES: raise HTTPException(400, f"Invalid project_type '{body.project_type}'. Must be one of: {', '.join(VALID_PROJECT_TYPES)}") conn = get_conn() if models.get_project(conn, body.id): conn.close() raise HTTPException(409, f"Project '{body.id}' already exists") p = models.create_project( conn, body.id, body.name, body.path, tech_stack=body.tech_stack, status=body.status, priority=body.priority, project_type=body.project_type, ssh_host=body.ssh_host, ssh_user=body.ssh_user, ssh_key_path=body.ssh_key_path, ssh_proxy_jump=body.ssh_proxy_jump, ) if body.test_command != "make test": p = models.update_project(conn, body.id, test_command=body.test_command) conn.close() return p # --------------------------------------------------------------------------- # Phases (KIN-059) # --------------------------------------------------------------------------- @app.get("/api/projects/{project_id}/phases") def get_project_phases(project_id: str): """List research phases for a project, with task data joined.""" conn = get_conn() p = models.get_project(conn, project_id) if not p: conn.close() raise HTTPException(404, f"Project '{project_id}' not found") phases = models.list_phases(conn, project_id) result = [] for phase in phases: task = models.get_task(conn, phase["task_id"]) if phase.get("task_id") else None result.append({**phase, "task": task}) conn.close() return result class PhaseApprove(BaseModel): comment: str | None = None class PhaseReject(BaseModel): reason: str class PhaseRevise(BaseModel): comment: str @app.post("/api/phases/{phase_id}/approve") def approve_phase(phase_id: int, body: PhaseApprove | None = None): """Approve a research phase and activate the next one.""" from core.phases import approve_phase as _approve conn = get_conn() phase = models.get_phase(conn, phase_id) if not phase: conn.close() raise HTTPException(404, f"Phase {phase_id} not found") try: result = _approve(conn, phase_id) except ValueError as e: conn.close() raise HTTPException(400, str(e)) # Mark the phase's task as done for consistency if phase.get("task_id"): models.update_task(conn, phase["task_id"], status="done") conn.close() return result @app.post("/api/phases/{phase_id}/reject") def reject_phase(phase_id: int, body: PhaseReject): """Reject a research phase.""" from core.phases import reject_phase as _reject conn = get_conn() phase = models.get_phase(conn, phase_id) if not phase: conn.close() raise HTTPException(404, f"Phase {phase_id} not found") try: result = _reject(conn, phase_id, body.reason) except ValueError as e: conn.close() raise HTTPException(400, str(e)) conn.close() return result @app.post("/api/phases/{phase_id}/revise") def revise_phase(phase_id: int, body: PhaseRevise): """Request revision for a research phase.""" from core.phases import revise_phase as _revise if not body.comment.strip(): raise HTTPException(400, "comment is required") conn = get_conn() phase = models.get_phase(conn, phase_id) if not phase: conn.close() raise HTTPException(404, f"Phase {phase_id} not found") try: result = _revise(conn, phase_id, body.comment) except ValueError as e: conn.close() raise HTTPException(400, str(e)) conn.close() return result @app.post("/api/projects/{project_id}/phases/start") def start_project_phase(project_id: str): """Launch agent for the current active/revising phase in background. Returns 202. Finds the first phase with status 'active' or 'revising', sets its task to in_progress, spawns a background subprocess (same as /api/tasks/{id}/run), and returns immediately so the HTTP request doesn't block on agent execution. """ from agents.runner import check_claude_auth, ClaudeAuthError try: check_claude_auth() except ClaudeAuthError: raise HTTPException(503, detail={ "error": "claude_auth_required", "message": "Claude CLI requires login", "instructions": "Run: claude login", }) conn = get_conn() p = models.get_project(conn, project_id) if not p: conn.close() raise HTTPException(404, f"Project '{project_id}' not found") phases = models.list_phases(conn, project_id) active_phase = next( (ph for ph in phases if ph["status"] in ("active", "revising")), None ) if not active_phase: conn.close() raise HTTPException(404, f"No active or revising phase for project '{project_id}'") task_id = active_phase.get("task_id") if not task_id: conn.close() raise HTTPException(400, f"Phase {active_phase['id']} has no task assigned") t = models.get_task(conn, task_id) if not t: conn.close() raise HTTPException(404, f"Task '{task_id}' not found") models.update_task(conn, task_id, status="in_progress") conn.close() kin_root = Path(__file__).parent.parent cmd = [sys.executable, "-m", "cli.main", "--db", str(DB_PATH), "run", task_id] cmd.append("--allow-write") # always required: subprocess runs non-interactively (stdin=DEVNULL) import os env = os.environ.copy() env["KIN_NONINTERACTIVE"] = "1" try: proc = subprocess.Popen( cmd, cwd=str(kin_root), stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, stdin=subprocess.DEVNULL, env=env, ) _logger.info("Phase agent started for task %s (phase %d), pid=%d", task_id, active_phase["id"], proc.pid) except Exception as e: raise HTTPException(500, f"Failed to start phase agent: {e}") return JSONResponse( {"status": "started", "phase_id": active_phase["id"], "task_id": task_id}, status_code=202, ) # --------------------------------------------------------------------------- # Tasks # --------------------------------------------------------------------------- @app.get("/api/tasks/{task_id}") def get_task(task_id: str): conn = get_conn() t = models.get_task(conn, task_id) conn.close() if not t: raise HTTPException(404, f"Task '{task_id}' not found") return t class TaskCreate(BaseModel): project_id: str title: str priority: int = 5 route_type: str | None = None category: str | None = None acceptance_criteria: str | None = None labels: list[str] | None = None @app.post("/api/tasks") def create_task(body: TaskCreate): conn = get_conn() p = models.get_project(conn, body.project_id) if not p: conn.close() raise HTTPException(404, f"Project '{body.project_id}' not found") category = None if body.category: category = body.category.upper() if category not in TASK_CATEGORIES: conn.close() raise HTTPException(400, f"Invalid category '{category}'. Must be one of: {', '.join(TASK_CATEGORIES)}") task_id = models.next_task_id(conn, body.project_id, category=category) brief = {"route_type": body.route_type} if body.route_type else None t = models.create_task(conn, task_id, body.project_id, body.title, priority=body.priority, brief=brief, category=category, acceptance_criteria=body.acceptance_criteria, labels=body.labels) conn.close() # Auto-trigger: if task has 'auto' label, launch pipeline in background if body.labels and "auto" in body.labels: _launch_pipeline_subprocess(task_id) return t VALID_ROUTE_TYPES = {"debug", "feature", "refactor", "hotfix"} class TaskPatch(BaseModel): status: str | None = None execution_mode: str | None = None priority: int | None = None route_type: str | None = None title: str | None = None brief_text: str | None = None acceptance_criteria: str | None = None VALID_STATUSES = set(models.VALID_TASK_STATUSES) VALID_EXECUTION_MODES = VALID_COMPLETION_MODES @app.patch("/api/tasks/{task_id}") def patch_task(task_id: str, body: TaskPatch): if body.status is not None and body.status not in VALID_STATUSES: raise HTTPException(400, f"Invalid status '{body.status}'. Must be one of: {', '.join(VALID_STATUSES)}") if body.execution_mode is not None and body.execution_mode not in VALID_EXECUTION_MODES: raise HTTPException(400, f"Invalid execution_mode '{body.execution_mode}'. Must be one of: {', '.join(VALID_EXECUTION_MODES)}") if body.priority is not None and not (1 <= body.priority <= 10): raise HTTPException(400, "priority must be between 1 and 10") if body.route_type is not None and body.route_type and body.route_type not in VALID_ROUTE_TYPES: raise HTTPException(400, f"Invalid route_type '{body.route_type}'. Must be one of: {', '.join(sorted(VALID_ROUTE_TYPES))} or empty string to clear") if body.title is not None and not body.title.strip(): raise HTTPException(400, "title must not be empty") all_none = all(v is None for v in [body.status, body.execution_mode, body.priority, body.route_type, body.title, body.brief_text, body.acceptance_criteria]) if all_none: raise HTTPException(400, "Nothing to update.") conn = get_conn() t = models.get_task(conn, task_id) if not t: conn.close() raise HTTPException(404, f"Task '{task_id}' not found") fields = {} if body.status is not None: fields["status"] = body.status if body.execution_mode is not None: fields["execution_mode"] = body.execution_mode if body.priority is not None: fields["priority"] = body.priority if body.title is not None: fields["title"] = body.title.strip() if body.route_type is not None or body.brief_text is not None: current_brief = t.get("brief") or {} if isinstance(current_brief, str): current_brief = {"text": current_brief} if body.route_type is not None: if body.route_type: current_brief = {**current_brief, "route_type": body.route_type} else: current_brief = {k: v for k, v in current_brief.items() if k != "route_type"} if body.brief_text is not None: current_brief = {**current_brief, "text": body.brief_text} fields["brief"] = current_brief if current_brief else None if body.acceptance_criteria is not None: fields["acceptance_criteria"] = body.acceptance_criteria models.update_task(conn, task_id, **fields) t = models.get_task(conn, task_id) conn.close() return t @app.get("/api/tasks/{task_id}/pipeline") def get_task_pipeline(task_id: str): """Get agent_logs for a task (pipeline steps).""" conn = get_conn() t = models.get_task(conn, task_id) if not t: conn.close() raise HTTPException(404, f"Task '{task_id}' not found") rows = conn.execute( """SELECT id, agent_role, action, output_summary, success, duration_seconds, tokens_used, model, cost_usd, created_at FROM agent_logs WHERE task_id = ? ORDER BY created_at""", (task_id,), ).fetchall() steps = [dict(r) for r in rows] conn.close() return steps @app.get("/api/tasks/{task_id}/full") def get_task_full(task_id: str): """Task + pipeline steps + related decisions.""" conn = get_conn() t = models.get_task(conn, task_id) if not t: conn.close() raise HTTPException(404, f"Task '{task_id}' not found") rows = conn.execute( """SELECT id, agent_role, action, output_summary, success, duration_seconds, tokens_used, model, cost_usd, created_at FROM agent_logs WHERE task_id = ? ORDER BY created_at""", (task_id,), ).fetchall() steps = [dict(r) for r in rows] decisions = models.get_decisions(conn, t["project_id"]) # Filter to decisions linked to this task task_decisions = [d for d in decisions if d.get("task_id") == task_id] p = models.get_project(conn, t["project_id"]) project_deploy_command = p.get("deploy_command") if p else None conn.close() return {**t, "pipeline_steps": steps, "related_decisions": task_decisions, "project_deploy_command": project_deploy_command} class TaskApprove(BaseModel): decision_title: str | None = None decision_description: str | None = None decision_type: str = "decision" create_followups: bool = False @app.post("/api/tasks/{task_id}/approve") def approve_task(task_id: str, body: TaskApprove | None = None): """Approve a task: set status=done, optionally add decision and create follow-ups.""" from core.followup import generate_followups conn = get_conn() t = models.get_task(conn, task_id) if not t: conn.close() raise HTTPException(404, f"Task '{task_id}' not found") models.update_task(conn, task_id, status="done") try: from core.hooks import run_hooks as _run_hooks task_modules = models.get_modules(conn, t["project_id"]) _run_hooks(conn, t["project_id"], task_id, event="task_done", task_modules=task_modules) except Exception: pass # Advance phase state machine if this task belongs to an active phase phase_result = None phase_row = conn.execute( "SELECT * FROM project_phases WHERE task_id = ?", (task_id,) ).fetchone() if phase_row: phase = dict(phase_row) if phase.get("status") == "active": from core.phases import approve_phase as _approve_phase try: phase_result = _approve_phase(conn, phase["id"]) except ValueError: pass decision = None if body and body.decision_title: decision = models.add_decision( conn, t["project_id"], body.decision_type, body.decision_title, body.decision_description or body.decision_title, task_id=task_id, ) followup_tasks = [] pending_actions = [] if body and body.create_followups: result = generate_followups(conn, task_id) followup_tasks = result["created"] pending_actions = result["pending_actions"] conn.close() return { "status": "done", "decision": decision, "followup_tasks": followup_tasks, "needs_decision": len(pending_actions) > 0, "pending_actions": pending_actions, "phase": phase_result, } class ResolveAction(BaseModel): action: dict choice: str # "rerun" | "manual_task" | "skip" @app.post("/api/tasks/{task_id}/resolve") def resolve_action(task_id: str, body: ResolveAction): """Resolve a pending permission action from follow-up generation.""" from core.followup import resolve_pending_action if body.choice not in ("rerun", "manual_task", "skip"): raise HTTPException(400, f"Invalid choice: {body.choice}") conn = get_conn() t = models.get_task(conn, task_id) if not t: conn.close() raise HTTPException(404, f"Task '{task_id}' not found") result = resolve_pending_action(conn, task_id, body.action, body.choice) conn.close() return {"choice": body.choice, "result": result} class TaskReject(BaseModel): reason: str @app.post("/api/tasks/{task_id}/reject") def reject_task(task_id: str, body: TaskReject): """Reject a task: set status=pending with reason in review field.""" conn = get_conn() t = models.get_task(conn, task_id) if not t: conn.close() raise HTTPException(404, f"Task '{task_id}' not found") models.update_task(conn, task_id, status="pending", review={"rejected": body.reason}) conn.close() return {"status": "pending", "reason": body.reason} _MAX_REVISE_COUNT = 5 class TaskRevise(BaseModel): comment: str steps: list[dict] | None = None # override pipeline steps (optional) target_role: str | None = None # if set, re-run only [target_role, reviewer] instead of full pipeline @app.post("/api/tasks/{task_id}/revise") def revise_task(task_id: str, body: TaskRevise): """Revise a task: update comment, increment revise_count, and re-run pipeline.""" if not body.comment.strip(): raise HTTPException(400, "comment must not be empty") conn = get_conn() t = models.get_task(conn, task_id) if not t: conn.close() raise HTTPException(404, f"Task '{task_id}' not found") revise_count = (t.get("revise_count") or 0) + 1 if revise_count > _MAX_REVISE_COUNT: conn.close() raise HTTPException(400, f"Max revisions ({_MAX_REVISE_COUNT}) reached for this task") models.update_task( conn, task_id, status="in_progress", revise_comment=body.comment, revise_count=revise_count, revise_target_role=body.target_role, ) # Resolve steps: explicit > target_role shortcut > last pipeline steps steps = body.steps if not steps: if body.target_role: steps = [{"role": body.target_role}, {"role": "reviewer"}] else: row = conn.execute( "SELECT steps FROM pipelines WHERE task_id = ? ORDER BY id DESC LIMIT 1", (task_id,), ).fetchone() if row: import json as _json raw = row["steps"] steps = _json.loads(raw) if isinstance(raw, str) else raw conn.close() # Launch pipeline in background subprocess _launch_pipeline_subprocess(task_id) return { "status": "in_progress", "comment": body.comment, "revise_count": revise_count, "pipeline_steps": steps, } @app.get("/api/tasks/{task_id}/running") def is_task_running(task_id: str): """Check if task has an active (running) pipeline.""" conn = get_conn() t = models.get_task(conn, task_id) if not t: conn.close() raise HTTPException(404, f"Task '{task_id}' not found") row = conn.execute( "SELECT id, status FROM pipelines WHERE task_id = ? ORDER BY created_at DESC LIMIT 1", (task_id,), ).fetchone() conn.close() if row and row["status"] == "running": return {"running": True, "pipeline_id": row["id"]} return {"running": False} @app.post("/api/tasks/{task_id}/run") def run_task(task_id: str): """Launch pipeline for a task in background. Returns 202.""" from agents.runner import check_claude_auth, ClaudeAuthError try: check_claude_auth() except ClaudeAuthError: raise HTTPException(503, detail={ "error": "claude_auth_required", "message": "Claude CLI requires login", "instructions": "Run: claude login", }) conn = get_conn() t = models.get_task(conn, task_id) 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() # Launch kin run in background subprocess kin_root = Path(__file__).parent.parent cmd = [sys.executable, "-m", "cli.main", "--db", str(DB_PATH), "run", task_id] cmd.append("--allow-write") # always required: subprocess runs non-interactively (stdin=DEVNULL) import os env = os.environ.copy() env["KIN_NONINTERACTIVE"] = "1" try: proc = subprocess.Popen( cmd, cwd=str(kin_root), stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, stdin=subprocess.DEVNULL, env=env, ) import logging logging.getLogger("kin").info(f"Pipeline started for {task_id}, pid={proc.pid}") except Exception as e: raise HTTPException(500, f"Failed to start pipeline: {e}") return JSONResponse({"status": "started", "task_id": task_id}, status_code=202) # --------------------------------------------------------------------------- # Decisions # --------------------------------------------------------------------------- @app.get("/api/decisions") def list_decisions( project: str = Query(...), category: str | None = None, tag: list[str] | None = Query(None), type: list[str] | None = Query(None), ): conn = get_conn() decisions = models.get_decisions( conn, project, category=category, tags=tag, types=type, ) conn.close() return decisions class DecisionCreate(BaseModel): project_id: str type: str title: str description: str category: str | None = None tags: list[str] | None = None task_id: str | None = None @app.post("/api/decisions") def create_decision(body: DecisionCreate): conn = get_conn() p = models.get_project(conn, body.project_id) if not p: conn.close() raise HTTPException(404, f"Project '{body.project_id}' not found") d = models.add_decision( conn, body.project_id, body.type, body.title, body.description, category=body.category, tags=body.tags, task_id=body.task_id, ) conn.close() return d @app.delete("/api/projects/{project_id}/decisions/{decision_id}") def delete_decision(project_id: str, decision_id: int): conn = get_conn() decision = models.get_decision(conn, decision_id) if not decision or decision["project_id"] != project_id: conn.close() raise HTTPException(404, f"Decision #{decision_id} not found") models.delete_decision(conn, decision_id) conn.close() return {"deleted": decision_id} # --------------------------------------------------------------------------- # Cost # --------------------------------------------------------------------------- @app.get("/api/cost") def cost_summary(days: int = 7): conn = get_conn() costs = models.get_cost_summary(conn, days=days) conn.close() return costs # --------------------------------------------------------------------------- # Support # --------------------------------------------------------------------------- @app.get("/api/support/tickets") def list_tickets(project: str | None = None, status: str | None = None): conn = get_conn() tickets = models.list_tickets(conn, project_id=project, status=status) conn.close() return tickets # --------------------------------------------------------------------------- # Audit # --------------------------------------------------------------------------- @app.post("/api/projects/{project_id}/audit") def audit_project(project_id: str): """Run backlog audit — check which pending tasks are already done.""" from agents.runner import run_audit conn = get_conn() p = models.get_project(conn, project_id) if not p: conn.close() raise HTTPException(404, f"Project '{project_id}' not found") result = run_audit(conn, project_id, noninteractive=True, auto_apply=False) conn.close() return result class AuditApply(BaseModel): task_ids: list[str] @app.post("/api/projects/{project_id}/audit/apply") def audit_apply(project_id: str, body: AuditApply): """Mark tasks as done after audit confirmation.""" conn = get_conn() p = models.get_project(conn, project_id) if not p: conn.close() raise HTTPException(404, f"Project '{project_id}' not found") updated = [] for tid in body.task_ids: t = models.get_task(conn, tid) if t and t["project_id"] == project_id: models.update_task(conn, tid, status="done") updated.append(tid) conn.close() return {"updated": updated, "count": len(updated)} # --------------------------------------------------------------------------- # Bootstrap # --------------------------------------------------------------------------- class BootstrapRequest(BaseModel): path: str id: str name: str vault_path: str | None = None @app.post("/api/bootstrap") def bootstrap(body: BootstrapRequest): project_path = Path(body.path).expanduser().resolve() if not project_path.is_dir(): raise HTTPException(400, f"Path '{body.path}' is not a directory") conn = get_conn() if models.get_project(conn, body.id): conn.close() raise HTTPException(409, f"Project '{body.id}' already exists") tech_stack = detect_tech_stack(project_path) modules = detect_modules(project_path) decisions = extract_decisions_from_claude_md(project_path, body.id, body.name) obsidian = None vault_root = find_vault_root(Path(body.vault_path) if body.vault_path else None) if vault_root: dir_name = project_path.name obs = scan_obsidian(vault_root, body.id, body.name, dir_name) if obs["tasks"] or obs["decisions"]: obsidian = obs try: save_to_db(conn, body.id, body.name, str(project_path), tech_stack, modules, decisions, obsidian) except Exception as e: if models.get_project(conn, body.id): models.delete_project(conn, body.id) conn.close() raise HTTPException(500, f"Bootstrap failed: {e}") p = models.get_project(conn, body.id) conn.close() return { "project": p, "modules_count": len(modules), "decisions_count": len(decisions) + len((obsidian or {}).get("decisions", [])), "tasks_count": len((obsidian or {}).get("tasks", [])), } # --------------------------------------------------------------------------- # Environments (KIN-087) # --------------------------------------------------------------------------- VALID_AUTH_TYPES = {"password", "key", "ssh_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 decrypted plaintext (get_environment decrypts via _decrypt_auth). # Stored in tasks.brief — treat as sensitive. "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") if "KIN_SECRET_KEY" in str(e): raise HTTPException(503, "Server misconfiguration: KIN_SECRET_KEY is not set. Contact admin.") if isinstance(e, ModuleNotFoundError) or "cryptography" in str(e) or "No module named" in str(e): raise HTTPException(503, "Server misconfiguration: cryptography package not installed. Run: python3.11 -m pip install cryptography") 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) try: updated = models.update_environment(conn, env_id, **fields) except Exception as e: conn.close() if "UNIQUE constraint" in str(e): raise HTTPException(409, f"Environment name already exists for this project") if "KIN_SECRET_KEY" in str(e): raise HTTPException(503, "Server misconfiguration: KIN_SECRET_KEY is not set. Contact admin.") if isinstance(e, ModuleNotFoundError) or "cryptography" in str(e) or "No module named" in str(e): raise HTTPException(503, "Server misconfiguration: cryptography package not installed. Run: python3.11 -m pip install cryptography") raise HTTPException(500, str(e)) 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") # Check existence directly — no decryption needed for delete row = conn.execute( "SELECT project_id FROM project_environments WHERE id = ?", (env_id,) ).fetchone() if not row or dict(row)["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.""" import os as _os if not _os.environ.get("KIN_SECRET_KEY"): raise HTTPException(503, "Server misconfiguration: KIN_SECRET_KEY is not set. Contact admin.") 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) # --------------------------------------------------------------------------- @app.get("/api/notifications") def get_notifications(project_id: str | None = None): """Return tasks with status='blocked' as escalation notifications. Each item includes task details, the agent role that blocked it, the reason, the pipeline step, and whether a Telegram alert was sent. Intended for GUI polling (5s interval). """ conn = get_conn() query = "SELECT * FROM tasks WHERE status = 'blocked'" params: list = [] if project_id: query += " AND project_id = ?" params.append(project_id) query += " ORDER BY blocked_at DESC, updated_at DESC" rows = conn.execute(query, params).fetchall() conn.close() notifications = [] for row in rows: t = dict(row) notifications.append({ "task_id": t["id"], "project_id": t["project_id"], "title": t.get("title"), "agent_role": t.get("blocked_agent_role"), "reason": t.get("blocked_reason"), "pipeline_step": t.get("blocked_pipeline_step"), "blocked_at": t.get("blocked_at") or t.get("updated_at"), "telegram_sent": bool(t.get("telegram_sent")), }) return notifications # --------------------------------------------------------------------------- # Attachments (KIN-090) # --------------------------------------------------------------------------- _MAX_ATTACHMENT_SIZE = 10 * 1024 * 1024 # 10 MB def _attachment_dir(project_path: Path, task_id: str) -> Path: """Return (and create) {project_path}/.kin/attachments/{task_id}/.""" d = project_path / ".kin" / "attachments" / task_id d.mkdir(parents=True, exist_ok=True) return d @app.post("/api/tasks/{task_id}/attachments", status_code=201) async def upload_attachment(task_id: str, file: UploadFile = File(...)): conn = get_conn() t = models.get_task(conn, task_id) if not t: conn.close() raise HTTPException(404, f"Task '{task_id}' not found") p = models.get_project(conn, t["project_id"]) if not p or not p.get("path"): conn.close() raise HTTPException(400, "Attachments not supported for operations projects") # Sanitize filename: strip directory components safe_name = Path(file.filename or "upload").name if not safe_name: conn.close() raise HTTPException(400, "Invalid filename") att_dir = _attachment_dir(Path(p["path"]), task_id) dest = att_dir / safe_name # Path traversal guard if not dest.is_relative_to(att_dir): conn.close() raise HTTPException(400, "Invalid filename") # Read with size limit content = await file.read(_MAX_ATTACHMENT_SIZE + 1) if len(content) > _MAX_ATTACHMENT_SIZE: conn.close() raise HTTPException(413, f"File too large. Maximum size is {_MAX_ATTACHMENT_SIZE // (1024*1024)} MB") dest.write_bytes(content) mime_type = mimetypes.guess_type(safe_name)[0] or "application/octet-stream" attachment = models.create_attachment( conn, task_id, filename=safe_name, path=str(dest), mime_type=mime_type, size=len(content), ) conn.close() return JSONResponse(attachment, status_code=201) @app.get("/api/tasks/{task_id}/attachments") def list_task_attachments(task_id: str): conn = get_conn() t = models.get_task(conn, task_id) if not t: conn.close() raise HTTPException(404, f"Task '{task_id}' not found") attachments = models.list_attachments(conn, task_id) conn.close() return attachments @app.delete("/api/tasks/{task_id}/attachments/{attachment_id}", status_code=204) def delete_task_attachment(task_id: str, attachment_id: int): conn = get_conn() att = models.get_attachment(conn, attachment_id) if not att or att["task_id"] != task_id: conn.close() raise HTTPException(404, f"Attachment #{attachment_id} not found") # Delete file from disk try: Path(att["path"]).unlink(missing_ok=True) except Exception: pass models.delete_attachment(conn, attachment_id) conn.close() return Response(status_code=204) @app.get("/api/attachments/{attachment_id}/file") def get_attachment_file(attachment_id: int): conn = get_conn() att = models.get_attachment(conn, attachment_id) conn.close() if not att: raise HTTPException(404, f"Attachment #{attachment_id} not found") file_path = Path(att["path"]) if not file_path.exists(): raise HTTPException(404, "Attachment file not found on disk") return FileResponse( str(file_path), media_type=att["mime_type"], filename=att["filename"], ) # --------------------------------------------------------------------------- # 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) # --------------------------------------------------------------------------- DIST = Path(__file__).parent / "frontend" / "dist" app.mount("/assets", StaticFiles(directory=str(DIST / "assets")), name="assets") @app.get("/{path:path}") async def serve_spa(path: str): file = DIST / path if file.exists() and file.is_file(): return FileResponse(file) return FileResponse(DIST / "index.html")