511 lines
16 KiB
Python
511 lines
16 KiB
Python
"""
|
|
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
|
|
|
|
|
|
@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
|
|
|
|
|
|
VALID_STATUSES = {"pending", "in_progress", "review", "done", "blocked", "cancelled"}
|
|
|
|
|
|
@app.patch("/api/tasks/{task_id}")
|
|
def patch_task(task_id: str, body: TaskPatch):
|
|
if body.status not in VALID_STATUSES:
|
|
raise HTTPException(400, f"Invalid status '{body.status}'. Must be one of: {', '.join(VALID_STATUSES)}")
|
|
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=body.status)
|
|
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")
|