""" 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 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=["http://localhost:5173", "http://127.0.0.1:5173"], 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 @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 @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" @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 a decision.""" 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, ) conn.close() return {"status": "done", "decision": decision} 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.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") conn.close() # Launch kin run in background subprocess kin_root = Path(__file__).parent.parent subprocess.Popen( [sys.executable, "-m", "cli.main", "run", task_id, "--db", str(DB_PATH)], cwd=str(kin_root), stdout=subprocess.DEVNULL, ) 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 # --------------------------------------------------------------------------- # 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", [])), }