kin/web/api.py

624 lines
20 KiB
Python
Raw Normal View History

"""
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
@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:
raise HTTPException(400, "Nothing to update. Provide execution_mode or autocommit_enabled.")
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)
models.update_project(conn, project_id, **fields)
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 = 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.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")
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}
@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")