kin/web/api.py
2026-03-17 16:36:52 +02:00

1689 lines
59 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""
Kin Web API — FastAPI backend reading ~/.kin/kin.db via core.models.
Run: uvicorn web.api:app --reload --port 8420
"""
import glob as _glob
import logging
import mimetypes
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, File, HTTPException, Query, UploadFile
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse, FileResponse, Response
from fastapi.staticfiles import StaticFiles
from pydantic import BaseModel, model_validator
from core.db import init_db
from core import models
from core.models import VALID_COMPLETION_MODES, TASK_CATEGORIES
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")
# Start pipeline watchdog (KIN-099): detects dead subprocess PIDs every 30s
from core.watchdog import start_watchdog as _start_watchdog
_start_watchdog(DB_PATH)
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_methods=["*"],
allow_headers=["*"],
)
def get_conn():
return init_db(DB_PATH)
def _launch_pipeline_subprocess(task_id: str) -> None:
"""Spawn `cli.main run {task_id}` in a detached background subprocess.
Used by auto-trigger (label 'auto') and revise endpoint.
Never raises — subprocess errors are logged only.
"""
import os
kin_root = Path(__file__).parent.parent
cmd = [sys.executable, "-m", "cli.main", "--db", str(DB_PATH), "run", task_id]
cmd.append("--allow-write")
env = os.environ.copy()
if 'SSH_AUTH_SOCK' not in env:
_socks = _glob.glob('/private/tmp/com.apple.launchd.*/Listeners')
if _socks:
env['SSH_AUTH_SOCK'] = _socks[0]
env["KIN_NONINTERACTIVE"] = "1"
try:
proc = subprocess.Popen(
cmd,
cwd=str(kin_root),
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
stdin=subprocess.DEVNULL,
env=env,
)
_logger.info("Auto-triggered pipeline for %s, pid=%d", task_id, proc.pid)
except Exception as exc:
_logger.warning("Failed to launch pipeline for %s: %s", task_id, exc)
# ---------------------------------------------------------------------------
# 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
class NewProjectCreate(BaseModel):
id: str
name: str
path: str | None = None
description: str
roles: list[str]
tech_stack: list[str] | None = None
priority: int = 5
language: str = "ru"
@app.post("/api/projects/new")
def new_project_with_phases(body: NewProjectCreate):
"""Create project + sequential research phases (KIN-059)."""
from core.phases import create_project_with_phases, validate_roles
clean_roles = validate_roles(body.roles)
if not clean_roles:
raise HTTPException(400, "At least one research role must be selected (excluding architect)")
conn = get_conn()
if models.get_project(conn, body.id):
conn.close()
raise HTTPException(409, f"Project '{body.id}' already exists")
try:
result = create_project_with_phases(
conn, body.id, body.name, body.path,
description=body.description,
selected_roles=clean_roles,
tech_stack=body.tech_stack,
priority=body.priority,
language=body.language,
)
except ValueError as e:
conn.close()
raise HTTPException(400, str(e))
conn.close()
return result
@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}
VALID_PROJECT_TYPES = {"development", "operations", "research"}
class ProjectCreate(BaseModel):
id: str
name: str
path: str | None = None
tech_stack: list[str] | None = None
status: str = "active"
priority: int = 5
project_type: str = "development"
ssh_host: str | None = None
ssh_user: str | None = None
ssh_key_path: str | None = None
ssh_proxy_jump: str | None = None
test_command: str = 'make test'
@model_validator(mode="after")
def validate_fields(self) -> "ProjectCreate":
if self.project_type == "operations" and not self.ssh_host:
raise ValueError("ssh_host is required for operations projects")
if self.project_type != "operations" and not self.path:
raise ValueError("path is required for non-operations projects")
return self
class ProjectPatch(BaseModel):
execution_mode: str | None = None
autocommit_enabled: bool | None = None
auto_test_enabled: bool | None = None
obsidian_vault_path: str | None = None
deploy_command: str | None = None
test_command: str | None = None
project_type: str | None = None
ssh_host: str | None = None
ssh_user: str | None = None
ssh_key_path: str | None = None
ssh_proxy_jump: str | None = None
@app.patch("/api/projects/{project_id}")
def patch_project(project_id: str, body: ProjectPatch):
has_any = any([
body.execution_mode, body.autocommit_enabled is not None,
body.auto_test_enabled is not None,
body.obsidian_vault_path, body.deploy_command is not None,
body.test_command is not None,
body.project_type, body.ssh_host is not None,
body.ssh_user is not None, body.ssh_key_path is not None,
body.ssh_proxy_jump is not None,
])
if not has_any:
raise HTTPException(400, "Nothing to update.")
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.project_type is not None and body.project_type not in VALID_PROJECT_TYPES:
raise HTTPException(400, f"Invalid project_type '{body.project_type}'. Must be one of: {', '.join(VALID_PROJECT_TYPES)}")
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.auto_test_enabled is not None:
fields["auto_test_enabled"] = int(body.auto_test_enabled)
if body.obsidian_vault_path is not None:
fields["obsidian_vault_path"] = body.obsidian_vault_path
if body.deploy_command is not None:
# Empty string = sentinel for clearing (decision #68)
fields["deploy_command"] = None if body.deploy_command == "" else body.deploy_command
if body.test_command is not None:
fields["test_command"] = body.test_command
if body.project_type is not None:
fields["project_type"] = body.project_type
if body.ssh_host is not None:
fields["ssh_host"] = body.ssh_host
if body.ssh_user is not None:
fields["ssh_user"] = body.ssh_user
if body.ssh_key_path is not None:
fields["ssh_key_path"] = body.ssh_key_path
if body.ssh_proxy_jump is not None:
fields["ssh_proxy_jump"] = body.ssh_proxy_jump
models.update_project(conn, project_id, **fields)
p = models.get_project(conn, project_id)
conn.close()
return p
@app.delete("/api/projects/{project_id}", status_code=204)
def delete_project(project_id: str):
"""Delete a project and all its related data (tasks, decisions, phases, logs)."""
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.delete_project(conn, project_id)
conn.close()
return Response(status_code=204)
@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/{project_id}/deploy")
def deploy_project(project_id: str):
"""Execute deploy_command for a project. Returns stdout/stderr/exit_code.
# WARNING: shell=True — deploy_command is admin-only, set in Settings by the project owner.
"""
import time
conn = get_conn()
p = models.get_project(conn, project_id)
conn.close()
if not p:
raise HTTPException(404, f"Project '{project_id}' not found")
deploy_command = p.get("deploy_command")
if not deploy_command:
raise HTTPException(400, "deploy_command not set for this project")
cwd = p.get("path") or None
start = time.monotonic()
try:
result = subprocess.run(
deploy_command,
shell=True, # WARNING: shell=True — command is admin-only
cwd=cwd,
capture_output=True,
text=True,
timeout=60,
)
except subprocess.TimeoutExpired:
raise HTTPException(504, "Deploy command timed out after 60 seconds")
except Exception as e:
raise HTTPException(500, f"Deploy failed: {e}")
duration = round(time.monotonic() - start, 2)
return {
"success": result.returncode == 0,
"exit_code": result.returncode,
"stdout": result.stdout,
"stderr": result.stderr,
"duration_seconds": duration,
}
@app.post("/api/projects")
def create_project(body: ProjectCreate):
if body.project_type not in VALID_PROJECT_TYPES:
raise HTTPException(400, f"Invalid project_type '{body.project_type}'. Must be one of: {', '.join(VALID_PROJECT_TYPES)}")
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,
project_type=body.project_type,
ssh_host=body.ssh_host,
ssh_user=body.ssh_user,
ssh_key_path=body.ssh_key_path,
ssh_proxy_jump=body.ssh_proxy_jump,
)
if body.test_command != "make test":
p = models.update_project(conn, body.id, test_command=body.test_command)
conn.close()
return p
# ---------------------------------------------------------------------------
# Phases (KIN-059)
# ---------------------------------------------------------------------------
@app.get("/api/projects/{project_id}/phases")
def get_project_phases(project_id: str):
"""List research phases for a project, with task data joined."""
conn = get_conn()
p = models.get_project(conn, project_id)
if not p:
conn.close()
raise HTTPException(404, f"Project '{project_id}' not found")
phases = models.list_phases(conn, project_id)
result = []
for phase in phases:
task = models.get_task(conn, phase["task_id"]) if phase.get("task_id") else None
result.append({**phase, "task": task})
conn.close()
return result
class PhaseApprove(BaseModel):
comment: str | None = None
class PhaseReject(BaseModel):
reason: str
class PhaseRevise(BaseModel):
comment: str
@app.post("/api/phases/{phase_id}/approve")
def approve_phase(phase_id: int, body: PhaseApprove | None = None):
"""Approve a research phase and activate the next one."""
from core.phases import approve_phase as _approve
conn = get_conn()
phase = models.get_phase(conn, phase_id)
if not phase:
conn.close()
raise HTTPException(404, f"Phase {phase_id} not found")
try:
result = _approve(conn, phase_id)
except ValueError as e:
conn.close()
raise HTTPException(400, str(e))
# Mark the phase's task as done for consistency
if phase.get("task_id"):
models.update_task(conn, phase["task_id"], status="done")
conn.close()
return result
@app.post("/api/phases/{phase_id}/reject")
def reject_phase(phase_id: int, body: PhaseReject):
"""Reject a research phase."""
from core.phases import reject_phase as _reject
conn = get_conn()
phase = models.get_phase(conn, phase_id)
if not phase:
conn.close()
raise HTTPException(404, f"Phase {phase_id} not found")
try:
result = _reject(conn, phase_id, body.reason)
except ValueError as e:
conn.close()
raise HTTPException(400, str(e))
conn.close()
return result
@app.post("/api/phases/{phase_id}/revise")
def revise_phase(phase_id: int, body: PhaseRevise):
"""Request revision for a research phase."""
from core.phases import revise_phase as _revise
if not body.comment.strip():
raise HTTPException(400, "comment is required")
conn = get_conn()
phase = models.get_phase(conn, phase_id)
if not phase:
conn.close()
raise HTTPException(404, f"Phase {phase_id} not found")
try:
result = _revise(conn, phase_id, body.comment)
except ValueError as e:
conn.close()
raise HTTPException(400, str(e))
conn.close()
return result
@app.post("/api/projects/{project_id}/phases/start")
def start_project_phase(project_id: str):
"""Launch agent for the current active/revising phase in background. Returns 202.
Finds the first phase with status 'active' or 'revising', sets its task to
in_progress, spawns a background subprocess (same as /api/tasks/{id}/run),
and returns immediately so the HTTP request doesn't block on agent execution.
"""
from agents.runner import check_claude_auth, ClaudeAuthError
try:
check_claude_auth()
except ClaudeAuthError:
raise HTTPException(503, detail={
"error": "claude_auth_required",
"message": "Claude CLI requires login",
"instructions": "Run: claude login",
})
conn = get_conn()
p = models.get_project(conn, project_id)
if not p:
conn.close()
raise HTTPException(404, f"Project '{project_id}' not found")
phases = models.list_phases(conn, project_id)
active_phase = next(
(ph for ph in phases if ph["status"] in ("active", "revising")), None
)
if not active_phase:
conn.close()
raise HTTPException(404, f"No active or revising phase for project '{project_id}'")
task_id = active_phase.get("task_id")
if not task_id:
conn.close()
raise HTTPException(400, f"Phase {active_phase['id']} has no task assigned")
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")
conn.close()
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,
)
_logger.info("Phase agent started for task %s (phase %d), pid=%d",
task_id, active_phase["id"], proc.pid)
except Exception as e:
raise HTTPException(500, f"Failed to start phase agent: {e}")
return JSONResponse(
{"status": "started", "phase_id": active_phase["id"], "task_id": task_id},
status_code=202,
)
# ---------------------------------------------------------------------------
# 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
category: str | None = None
acceptance_criteria: str | None = None
labels: list[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")
category = None
if body.category:
category = body.category.upper()
if category not in TASK_CATEGORIES:
conn.close()
raise HTTPException(400, f"Invalid category '{category}'. Must be one of: {', '.join(TASK_CATEGORIES)}")
task_id = models.next_task_id(conn, body.project_id, category=category)
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, category=category,
acceptance_criteria=body.acceptance_criteria,
labels=body.labels)
conn.close()
# Auto-trigger: if task has 'auto' label, launch pipeline in background
if body.labels and "auto" in body.labels:
_launch_pipeline_subprocess(task_id)
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
acceptance_criteria: 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, body.acceptance_criteria])
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
if body.acceptance_criteria is not None:
fields["acceptance_criteria"] = body.acceptance_criteria
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]
p = models.get_project(conn, t["project_id"])
project_deploy_command = p.get("deploy_command") if p else None
conn.close()
return {**t, "pipeline_steps": steps, "related_decisions": task_decisions, "project_deploy_command": project_deploy_command}
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
# Advance phase state machine if this task belongs to an active phase
phase_result = None
phase_row = conn.execute(
"SELECT * FROM project_phases WHERE task_id = ?", (task_id,)
).fetchone()
if phase_row:
phase = dict(phase_row)
if phase.get("status") == "active":
from core.phases import approve_phase as _approve_phase
try:
phase_result = _approve_phase(conn, phase["id"])
except ValueError:
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,
"phase": phase_result,
}
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}
_MAX_REVISE_COUNT = 5
class TaskRevise(BaseModel):
comment: str
steps: list[dict] | None = None # override pipeline steps (optional)
target_role: str | None = None # if set, re-run only [target_role, reviewer] instead of full pipeline
@app.post("/api/tasks/{task_id}/revise")
def revise_task(task_id: str, body: TaskRevise):
"""Revise a task: update comment, increment revise_count, and re-run pipeline."""
if not body.comment.strip():
raise HTTPException(400, "comment must not be empty")
conn = get_conn()
t = models.get_task(conn, task_id)
if not t:
conn.close()
raise HTTPException(404, f"Task '{task_id}' not found")
revise_count = (t.get("revise_count") or 0) + 1
if revise_count > _MAX_REVISE_COUNT:
conn.close()
raise HTTPException(400, f"Max revisions ({_MAX_REVISE_COUNT}) reached for this task")
models.update_task(
conn, task_id,
status="in_progress",
revise_comment=body.comment,
revise_count=revise_count,
revise_target_role=body.target_role,
)
# Resolve steps: explicit > target_role shortcut > last pipeline steps
steps = body.steps
if not steps:
if body.target_role:
steps = [{"role": body.target_role}, {"role": "reviewer"}]
else:
row = conn.execute(
"SELECT steps FROM pipelines WHERE task_id = ? ORDER BY id DESC LIMIT 1",
(task_id,),
).fetchone()
if row:
import json as _json
raw = row["steps"]
steps = _json.loads(raw) if isinstance(raw, str) else raw
conn.close()
# Launch pipeline in background subprocess
_launch_pipeline_subprocess(task_id)
return {
"status": "in_progress",
"comment": body.comment,
"revise_count": revise_count,
"pipeline_steps": steps,
}
@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."""
from agents.runner import check_claude_auth, ClaudeAuthError
try:
check_claude_auth()
except ClaudeAuthError:
raise HTTPException(503, detail={
"error": "claude_auth_required",
"message": "Claude CLI requires login",
"instructions": "Run: claude login",
})
conn = get_conn()
t = models.get_task(conn, task_id)
if not t:
conn.close()
raise HTTPException(404, f"Task '{task_id}' not found")
if t.get("status") == "in_progress":
conn.close()
return JSONResponse({"error": "task_already_running"}, status_code=409)
# 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
try:
save_to_db(conn, body.id, body.name, str(project_path),
tech_stack, modules, decisions, obsidian)
except Exception as e:
if models.get_project(conn, body.id):
models.delete_project(conn, body.id)
conn.close()
raise HTTPException(500, f"Bootstrap failed: {e}")
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", [])),
}
# ---------------------------------------------------------------------------
# Environments (KIN-087)
# ---------------------------------------------------------------------------
VALID_AUTH_TYPES = {"password", "key", "ssh_key"}
class EnvironmentCreate(BaseModel):
name: str
host: str
port: int = 22
username: str
auth_type: str = "password"
auth_value: str | None = None
is_installed: bool = False
@model_validator(mode="after")
def validate_env_fields(self) -> "EnvironmentCreate":
if not self.name.strip():
raise ValueError("name must not be empty")
if not self.host.strip():
raise ValueError("host must not be empty")
if not self.username.strip():
raise ValueError("username must not be empty")
if self.auth_type not in VALID_AUTH_TYPES:
raise ValueError(f"auth_type must be one of: {', '.join(VALID_AUTH_TYPES)}")
if not (1 <= self.port <= 65535):
raise ValueError("port must be between 1 and 65535")
return self
class EnvironmentPatch(BaseModel):
name: str | None = None
host: str | None = None
port: int | None = None
username: str | None = None
auth_type: str | None = None
auth_value: str | None = None
is_installed: bool | None = None
def _trigger_sysadmin_scan(conn, project_id: str, env: dict) -> str:
"""Create a sysadmin env-scan task and launch it in background.
env must be the raw record from get_environment() (contains obfuscated auth_value).
Guard: skips if an active sysadmin task for this environment already exists.
Returns task_id of the created (or existing) task.
"""
env_id = env["id"]
existing = conn.execute(
"""SELECT id FROM tasks
WHERE project_id = ? AND assigned_role = 'sysadmin'
AND status NOT IN ('done', 'cancelled')
AND brief LIKE ?""",
(project_id, f'%"env_id": {env_id}%'),
).fetchone()
if existing:
return existing["id"]
task_id = models.next_task_id(conn, project_id, category="INFRA")
brief = {
"type": "env_scan",
"env_id": env_id,
"host": env["host"],
"port": env["port"],
"username": env["username"],
"auth_type": env["auth_type"],
# auth_value is decrypted plaintext (get_environment decrypts via _decrypt_auth).
# Stored in tasks.brief — treat as sensitive.
"auth_value_b64": env.get("auth_value"),
"text": (
f"Провести полный аудит среды '{env['name']}' на сервере {env['host']}.\n\n"
f"Подключение: {env['username']}@{env['host']}:{env['port']} (auth_type={env['auth_type']}).\n\n"
"Задачи:\n"
"1. Проверить git config (user, remote, текущую ветку)\n"
"2. Установленный стек (python/node/java версии, package managers)\n"
"3. Переменные окружения (.env файлы, systemd EnvironmentFile)\n"
"4. Nginx/caddy конфиги (виртуальные хосты, SSL)\n"
"5. Systemd/supervisor сервисы проекта\n"
"6. SSH-ключи (authorized_keys, known_hosts)\n"
"7. Если чего-то не хватает для подключения или аудита — эскалация к человеку."
),
}
models.create_task(
conn, task_id, project_id,
title=f"[{env['name']}] Env scan: {env['host']}",
assigned_role="sysadmin",
category="INFRA",
brief=brief,
)
models.update_task(conn, task_id, status="in_progress")
kin_root = Path(__file__).parent.parent
cmd = [sys.executable, "-m", "cli.main", "--db", str(DB_PATH), "run", task_id]
cmd.append("--allow-write")
import os as _os
env_vars = _os.environ.copy()
env_vars["KIN_NONINTERACTIVE"] = "1"
try:
subprocess.Popen(
cmd,
cwd=str(kin_root),
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
stdin=subprocess.DEVNULL,
env=env_vars,
)
except Exception as e:
_logger.warning("Failed to start sysadmin scan for %s: %s", task_id, e)
return task_id
@app.get("/api/projects/{project_id}/environments")
def list_environments(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")
envs = models.list_environments(conn, project_id)
conn.close()
return envs
@app.post("/api/projects/{project_id}/environments", status_code=201)
def create_environment(project_id: str, body: EnvironmentCreate):
conn = get_conn()
p = models.get_project(conn, project_id)
if not p:
conn.close()
raise HTTPException(404, f"Project '{project_id}' not found")
try:
env = models.create_environment(
conn, project_id,
name=body.name,
host=body.host,
port=body.port,
username=body.username,
auth_type=body.auth_type,
auth_value=body.auth_value,
is_installed=body.is_installed,
)
except Exception as e:
conn.close()
if "UNIQUE constraint" in str(e):
raise HTTPException(409, f"Environment '{body.name}' already exists for this project")
if "KIN_SECRET_KEY" in str(e):
raise HTTPException(503, "Server misconfiguration: KIN_SECRET_KEY is not set. Contact admin.")
if isinstance(e, ModuleNotFoundError) or "cryptography" in str(e) or "No module named" in str(e):
raise HTTPException(503, "Server misconfiguration: cryptography package not installed. Run: python3.11 -m pip install cryptography")
raise HTTPException(500, str(e))
scan_task_id = None
if body.is_installed:
raw_env = models.get_environment(conn, env["id"])
scan_task_id = _trigger_sysadmin_scan(conn, project_id, raw_env)
conn.close()
result = {**env}
if scan_task_id:
result["scan_task_id"] = scan_task_id
return JSONResponse(result, status_code=201)
@app.patch("/api/projects/{project_id}/environments/{env_id}")
def patch_environment(project_id: str, env_id: int, body: EnvironmentPatch):
all_none = all(v is None for v in [
body.name, body.host, body.port, body.username,
body.auth_type, body.auth_value, body.is_installed,
])
if all_none:
raise HTTPException(400, "Nothing to update.")
if body.auth_type is not None and body.auth_type not in VALID_AUTH_TYPES:
raise HTTPException(400, f"auth_type must be one of: {', '.join(VALID_AUTH_TYPES)}")
if body.port is not None and not (1 <= body.port <= 65535):
raise HTTPException(400, "port must be between 1 and 65535")
if body.name is not None and not body.name.strip():
raise HTTPException(400, "name must not be empty")
if body.username is not None and not body.username.strip():
raise HTTPException(400, "username must not be empty")
if body.host is not None and not body.host.strip():
raise HTTPException(400, "host must not be empty")
conn = get_conn()
p = models.get_project(conn, project_id)
if not p:
conn.close()
raise HTTPException(404, f"Project '{project_id}' not found")
existing = models.get_environment(conn, env_id)
if not existing or existing.get("project_id") != project_id:
conn.close()
raise HTTPException(404, f"Environment #{env_id} not found")
was_installed = bool(existing.get("is_installed"))
fields = {}
if body.name is not None:
fields["name"] = body.name
if body.host is not None:
fields["host"] = body.host
if body.port is not None:
fields["port"] = body.port
if body.username is not None:
fields["username"] = body.username
if body.auth_type is not None:
fields["auth_type"] = body.auth_type
if body.auth_value: # only update if non-empty (empty = don't change stored cred)
fields["auth_value"] = body.auth_value
if body.is_installed is not None:
fields["is_installed"] = int(body.is_installed)
try:
updated = models.update_environment(conn, env_id, **fields)
except Exception as e:
conn.close()
if "UNIQUE constraint" in str(e):
raise HTTPException(409, f"Environment name already exists for this project")
if "KIN_SECRET_KEY" in str(e):
raise HTTPException(503, "Server misconfiguration: KIN_SECRET_KEY is not set. Contact admin.")
if isinstance(e, ModuleNotFoundError) or "cryptography" in str(e) or "No module named" in str(e):
raise HTTPException(503, "Server misconfiguration: cryptography package not installed. Run: python3.11 -m pip install cryptography")
raise HTTPException(500, str(e))
scan_task_id = None
if body.is_installed is True and not was_installed:
raw_env = models.get_environment(conn, env_id)
scan_task_id = _trigger_sysadmin_scan(conn, project_id, raw_env)
conn.close()
result = {**updated}
if scan_task_id:
result["scan_task_id"] = scan_task_id
return result
@app.delete("/api/projects/{project_id}/environments/{env_id}", status_code=204)
def delete_environment(project_id: str, env_id: int):
conn = get_conn()
p = models.get_project(conn, project_id)
if not p:
conn.close()
raise HTTPException(404, f"Project '{project_id}' not found")
# Check existence directly — no decryption needed for delete
row = conn.execute(
"SELECT project_id FROM project_environments WHERE id = ?", (env_id,)
).fetchone()
if not row or dict(row)["project_id"] != project_id:
conn.close()
raise HTTPException(404, f"Environment #{env_id} not found")
models.delete_environment(conn, env_id)
conn.close()
return Response(status_code=204)
@app.post("/api/projects/{project_id}/environments/{env_id}/scan", status_code=202)
def scan_environment(project_id: str, env_id: int):
"""Manually re-trigger sysadmin env scan for an environment."""
import os as _os
if not _os.environ.get("KIN_SECRET_KEY"):
raise HTTPException(503, "Server misconfiguration: KIN_SECRET_KEY is not set. Contact admin.")
conn = get_conn()
p = models.get_project(conn, project_id)
if not p:
conn.close()
raise HTTPException(404, f"Project '{project_id}' not found")
raw_env = models.get_environment(conn, env_id)
if not raw_env or raw_env.get("project_id") != project_id:
conn.close()
raise HTTPException(404, f"Environment #{env_id} not found")
task_id = _trigger_sysadmin_scan(conn, project_id, raw_env)
conn.close()
return JSONResponse({"status": "started", "task_id": task_id}, status_code=202)
# ---------------------------------------------------------------------------
# Notifications (escalations from blocked agents)
# ---------------------------------------------------------------------------
@app.get("/api/notifications")
def get_notifications(project_id: str | None = None):
"""Return tasks with status='blocked' as escalation notifications.
Each item includes task details, the agent role that blocked it,
the reason, the pipeline step, and whether a Telegram alert was sent.
Intended for GUI polling (5s interval).
"""
conn = get_conn()
query = "SELECT * FROM tasks WHERE status = 'blocked'"
params: list = []
if project_id:
query += " AND project_id = ?"
params.append(project_id)
query += " ORDER BY blocked_at DESC, updated_at DESC"
rows = conn.execute(query, params).fetchall()
conn.close()
notifications = []
for row in rows:
t = dict(row)
notifications.append({
"task_id": t["id"],
"project_id": t["project_id"],
"title": t.get("title"),
"agent_role": t.get("blocked_agent_role"),
"reason": t.get("blocked_reason"),
"pipeline_step": t.get("blocked_pipeline_step"),
"blocked_at": t.get("blocked_at") or t.get("updated_at"),
"telegram_sent": bool(t.get("telegram_sent")),
})
return notifications
# ---------------------------------------------------------------------------
# Attachments (KIN-090)
# ---------------------------------------------------------------------------
_MAX_ATTACHMENT_SIZE = 10 * 1024 * 1024 # 10 MB
def _attachment_dir(project_path: Path, task_id: str) -> Path:
"""Return (and create) {project_path}/.kin/attachments/{task_id}/."""
d = project_path / ".kin" / "attachments" / task_id
d.mkdir(parents=True, exist_ok=True)
return d
@app.post("/api/tasks/{task_id}/attachments", status_code=201)
async def upload_attachment(task_id: str, file: UploadFile = File(...)):
conn = get_conn()
t = models.get_task(conn, task_id)
if not t:
conn.close()
raise HTTPException(404, f"Task '{task_id}' not found")
p = models.get_project(conn, t["project_id"])
if not p or not p.get("path"):
conn.close()
raise HTTPException(400, "Attachments not supported for operations projects")
# Sanitize filename: strip directory components
safe_name = Path(file.filename or "upload").name
if not safe_name:
conn.close()
raise HTTPException(400, "Invalid filename")
att_dir = _attachment_dir(Path(p["path"]), task_id)
dest = att_dir / safe_name
# Path traversal guard
if not dest.is_relative_to(att_dir):
conn.close()
raise HTTPException(400, "Invalid filename")
# Read with size limit
content = await file.read(_MAX_ATTACHMENT_SIZE + 1)
if len(content) > _MAX_ATTACHMENT_SIZE:
conn.close()
raise HTTPException(413, f"File too large. Maximum size is {_MAX_ATTACHMENT_SIZE // (1024*1024)} MB")
dest.write_bytes(content)
mime_type = mimetypes.guess_type(safe_name)[0] or "application/octet-stream"
attachment = models.create_attachment(
conn, task_id,
filename=safe_name,
path=str(dest),
mime_type=mime_type,
size=len(content),
)
conn.close()
return JSONResponse(attachment, status_code=201)
@app.get("/api/tasks/{task_id}/attachments")
def list_task_attachments(task_id: str):
conn = get_conn()
t = models.get_task(conn, task_id)
if not t:
conn.close()
raise HTTPException(404, f"Task '{task_id}' not found")
attachments = models.list_attachments(conn, task_id)
conn.close()
return attachments
@app.delete("/api/tasks/{task_id}/attachments/{attachment_id}", status_code=204)
def delete_task_attachment(task_id: str, attachment_id: int):
conn = get_conn()
att = models.get_attachment(conn, attachment_id)
if not att or att["task_id"] != task_id:
conn.close()
raise HTTPException(404, f"Attachment #{attachment_id} not found")
# Delete file from disk
try:
Path(att["path"]).unlink(missing_ok=True)
except Exception:
pass
models.delete_attachment(conn, attachment_id)
conn.close()
return Response(status_code=204)
@app.get("/api/attachments/{attachment_id}/file")
def get_attachment_file(attachment_id: int):
conn = get_conn()
att = models.get_attachment(conn, attachment_id)
conn.close()
if not att:
raise HTTPException(404, f"Attachment #{attachment_id} not found")
file_path = Path(att["path"])
if not file_path.exists():
raise HTTPException(404, "Attachment file not found on disk")
return FileResponse(
str(file_path),
media_type=att["mime_type"],
filename=att["filename"],
)
# ---------------------------------------------------------------------------
# Chat (KIN-OBS-012)
# ---------------------------------------------------------------------------
class ChatMessageIn(BaseModel):
content: str
@app.get("/api/projects/{project_id}/chat")
def get_chat_history(
project_id: str,
limit: int = Query(50, ge=1, le=200),
before_id: int | None = None,
):
"""Return chat history for a project. Enriches task_created messages with task_stub."""
conn = get_conn()
p = models.get_project(conn, project_id)
if not p:
conn.close()
raise HTTPException(404, f"Project '{project_id}' not found")
messages = models.get_chat_messages(conn, project_id, limit=limit, before_id=before_id)
for msg in messages:
if msg.get("message_type") == "task_created" and msg.get("task_id"):
task = models.get_task(conn, msg["task_id"])
if task:
msg["task_stub"] = {
"id": task["id"],
"title": task["title"],
"status": task["status"],
}
conn.close()
return messages
@app.post("/api/projects/{project_id}/chat")
def send_chat_message(project_id: str, body: ChatMessageIn):
"""Process a user message: classify intent, create task or answer, return both messages."""
from core.chat_intent import classify_intent
if not body.content.strip():
raise HTTPException(400, "content must not be empty")
conn = get_conn()
p = models.get_project(conn, project_id)
if not p:
conn.close()
raise HTTPException(404, f"Project '{project_id}' not found")
# 1. Save user message
user_msg = models.add_chat_message(conn, project_id, "user", body.content)
# 2. Classify intent
intent = classify_intent(body.content)
task = None
if intent == "task_request":
# 3a. Create task (category OBS) and run pipeline in background
task_id = models.next_task_id(conn, project_id, category="OBS")
title = body.content[:120].strip()
t = models.create_task(
conn, task_id, project_id, title,
brief={"text": body.content, "source": "chat"},
category="OBS",
)
task = t
import os as _os
env_vars = _os.environ.copy()
env_vars["KIN_NONINTERACTIVE"] = "1"
kin_root = Path(__file__).parent.parent
try:
subprocess.Popen(
[sys.executable, "-m", "cli.main", "--db", str(DB_PATH),
"run", task_id, "--allow-write"],
cwd=str(kin_root),
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
stdin=subprocess.DEVNULL,
env=env_vars,
)
except Exception as e:
_logger.warning("Failed to start pipeline for chat task %s: %s", task_id, e)
assistant_content = f"Создал задачу {task_id}: {title}"
assistant_msg = models.add_chat_message(
conn, project_id, "assistant", assistant_content,
message_type="task_created", task_id=task_id,
)
assistant_msg["task_stub"] = {
"id": t["id"],
"title": t["title"],
"status": t["status"],
}
elif intent == "status_query":
# 3b. Return current task status summary
in_progress = models.list_tasks(conn, project_id=project_id, status="in_progress")
pending = models.list_tasks(conn, project_id=project_id, status="pending")
review = models.list_tasks(conn, project_id=project_id, status="review")
parts = []
if in_progress:
parts.append("В работе ({}):\n{}".format(
len(in_progress),
"\n".join(f"{t['id']}{t['title'][:60]}" for t in in_progress[:5]),
))
if review:
parts.append("На ревью ({}):\n{}".format(
len(review),
"\n".join(f"{t['id']}{t['title'][:60]}" for t in review[:5]),
))
if pending:
parts.append("Ожидает ({}):\n{}".format(
len(pending),
"\n".join(f"{t['id']}{t['title'][:60]}" for t in pending[:5]),
))
content = "\n\n".join(parts) if parts else "Нет активных задач."
assistant_msg = models.add_chat_message(conn, project_id, "assistant", content)
else: # question
assistant_msg = models.add_chat_message(
conn, project_id, "assistant",
"Я пока не умею отвечать на вопросы напрямую. "
"Если хотите — опишите задачу, я создам её и запущу агентов.",
)
conn.close()
return {
"user_message": user_msg,
"assistant_message": assistant_msg,
"task": task,
}
# ---------------------------------------------------------------------------
# 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")