""" Kin Web API — FastAPI backend reading ~/.kin/kin.db via core.models. Run: uvicorn web.api:app --reload --port 8420 """ import logging 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, HTTPException, Query from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import JSONResponse, FileResponse from fastapi.staticfiles import StaticFiles from pydantic import BaseModel from core.db import init_db from core import models from core.models import VALID_COMPLETION_MODES 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") app.add_middleware( CORSMiddleware, allow_origins=["*"], allow_methods=["*"], allow_headers=["*"], ) def get_conn(): return init_db(DB_PATH) # --------------------------------------------------------------------------- # 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 @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} class ProjectCreate(BaseModel): id: str name: str path: str tech_stack: list[str] | None = None status: str = "active" priority: int = 5 class ProjectPatch(BaseModel): execution_mode: str | None = None autocommit_enabled: bool | None = None obsidian_vault_path: str | None = None @app.patch("/api/projects/{project_id}") def patch_project(project_id: str, body: ProjectPatch): if body.execution_mode is None and body.autocommit_enabled is None and body.obsidian_vault_path is None: raise HTTPException(400, "Nothing to update. Provide execution_mode, autocommit_enabled, or obsidian_vault_path.") 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)}") 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.obsidian_vault_path is not None: fields["obsidian_vault_path"] = body.obsidian_vault_path models.update_project(conn, project_id, **fields) p = models.get_project(conn, project_id) conn.close() return p @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") def create_project(body: ProjectCreate): 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, ) conn.close() return p # --------------------------------------------------------------------------- # 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 @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") # Auto-generate task ID existing = models.list_tasks(conn, project_id=body.project_id) prefix = body.project_id.upper() max_num = 0 for t in existing: if t["id"].startswith(prefix + "-"): try: num = int(t["id"].split("-", 1)[1]) max_num = max(max_num, num) except ValueError: pass task_id = f"{prefix}-{max_num + 1:03d}" 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) conn.close() 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 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]) 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 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] conn.close() return {**t, "pipeline_steps": steps, "related_decisions": task_decisions} 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 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, } 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} class TaskRevise(BaseModel): comment: str @app.post("/api/tasks/{task_id}/revise") def revise_task(task_id: str, body: TaskRevise): """Revise a task: return to in_progress with director's comment for the agent.""" 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="in_progress", revise_comment=body.comment) conn.close() return {"status": "in_progress", "comment": body.comment} @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.""" conn = get_conn() t = models.get_task(conn, task_id) if not t: conn.close() raise HTTPException(404, f"Task '{task_id}' not found") # 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 save_to_db(conn, body.id, body.name, str(project_path), tech_stack, modules, decisions, obsidian) 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", [])), } # --------------------------------------------------------------------------- # 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")