""" Kin Web API — FastAPI backend reading ~/.kin/kin.db via core.models. Run: uvicorn web.api:app --reload --port 8420 """ 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 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" 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 @app.patch("/api/projects/{project_id}") def patch_project(project_id: str, body: ProjectPatch): if 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") models.update_project(conn, project_id, execution_mode=body.execution_mode) p = models.get_project(conn, project_id) conn.close() return p @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 class TaskPatch(BaseModel): status: str | None = None execution_mode: str | None = None VALID_STATUSES = {"pending", "in_progress", "review", "done", "blocked", "cancelled"} VALID_EXECUTION_MODES = {"auto", "review"} @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.status is None and body.execution_mode is None: raise HTTPException(400, "Nothing to update. Provide status or execution_mode.") 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 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") 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} @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} class TaskRun(BaseModel): allow_write: bool = False @app.post("/api/tasks/{task_id}/run") def run_task(task_id: str, body: TaskRun | None = None): """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] if body and body.allow_write: cmd.append("--allow-write") import os env = os.environ.copy() env["KIN_NONINTERACTIVE"] = "1" try: proc = subprocess.Popen( cmd, cwd=str(kin_root), stdout=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 # --------------------------------------------------------------------------- # 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")