Add web GUI: FastAPI API + Vue 3 frontend with dark theme
API (web/api.py):
GET /api/projects, /api/projects/{id}, /api/tasks/{id}
GET /api/decisions?project=X, /api/cost?days=7, /api/support/tickets
POST /api/projects, /api/tasks, /api/decisions, /api/bootstrap
CORS for localhost:5173, all queries via models.py
Frontend (web/frontend/):
Vue 3 + TypeScript + Vite + Tailwind CSS v3
Dashboard: project cards with task counters, cost, status badges
ProjectView: tabs for Tasks/Decisions/Modules with filters
Modals: Add Project, Add Task, Add Decision, Bootstrap
Dark theme, monospace font, minimal clean design
Startup:
API: cd web && uvicorn api:app --reload --port 8420
Web: cd web/frontend && npm install && npm run dev
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 13:50:15 +02:00
|
|
|
|
"""
|
|
|
|
|
|
Kin Web API — FastAPI backend reading ~/.kin/kin.db via core.models.
|
|
|
|
|
|
Run: uvicorn web.api:app --reload --port 8420
|
|
|
|
|
|
"""
|
|
|
|
|
|
|
2026-03-17 16:36:52 +02:00
|
|
|
|
import glob as _glob
|
2026-03-16 06:59:46 +02:00
|
|
|
|
import logging
|
2026-03-16 20:39:17 +02:00
|
|
|
|
import mimetypes
|
2026-03-16 06:59:46 +02:00
|
|
|
|
import shutil
|
2026-03-17 18:33:03 +02:00
|
|
|
|
import sqlite3
|
Add task detail view, pipeline visualization, approve/reject workflow
API (web/api.py) — 5 new endpoints:
GET /api/tasks/{id}/pipeline — agent_logs as pipeline steps
GET /api/tasks/{id}/full — task + steps + related decisions
POST /api/tasks/{id}/approve — mark done, optionally add decision
POST /api/tasks/{id}/reject — return to pending with reason
POST /api/tasks/{id}/run — launch pipeline in background (202)
Frontend:
TaskDetail (/task/:id) — full task page with:
- Pipeline graph: role cards with icons, arrows, status colors
- Click step → expand output (pre-formatted, JSON detected)
- Action bar: Approve (with optional decision), Reject, Run Pipeline
- Polling for live pipeline updates
Dashboard: review_tasks badge ("awaiting review" in yellow)
ProjectView: task rows are now clickable links to /task/:id
Runner: output_summary no longer truncated (full output for GUI).
Models: get_project_summary includes review_tasks count.
13 new API tests, 105 total, all passing. Frontend builds clean.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 14:32:29 +02:00
|
|
|
|
import subprocess
|
Add web GUI: FastAPI API + Vue 3 frontend with dark theme
API (web/api.py):
GET /api/projects, /api/projects/{id}, /api/tasks/{id}
GET /api/decisions?project=X, /api/cost?days=7, /api/support/tickets
POST /api/projects, /api/tasks, /api/decisions, /api/bootstrap
CORS for localhost:5173, all queries via models.py
Frontend (web/frontend/):
Vue 3 + TypeScript + Vite + Tailwind CSS v3
Dashboard: project cards with task counters, cost, status badges
ProjectView: tabs for Tasks/Decisions/Modules with filters
Modals: Add Project, Add Task, Add Decision, Bootstrap
Dark theme, monospace font, minimal clean design
Startup:
API: cd web && uvicorn api:app --reload --port 8420
Web: cd web/frontend && npm install && npm run dev
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 13:50:15 +02:00
|
|
|
|
import sys
|
2026-03-17 18:29:32 +02:00
|
|
|
|
import time
|
Add web GUI: FastAPI API + Vue 3 frontend with dark theme
API (web/api.py):
GET /api/projects, /api/projects/{id}, /api/tasks/{id}
GET /api/decisions?project=X, /api/cost?days=7, /api/support/tickets
POST /api/projects, /api/tasks, /api/decisions, /api/bootstrap
CORS for localhost:5173, all queries via models.py
Frontend (web/frontend/):
Vue 3 + TypeScript + Vite + Tailwind CSS v3
Dashboard: project cards with task counters, cost, status badges
ProjectView: tabs for Tasks/Decisions/Modules with filters
Modals: Add Project, Add Task, Add Decision, Bootstrap
Dark theme, monospace font, minimal clean design
Startup:
API: cd web && uvicorn api:app --reload --port 8420
Web: cd web/frontend && npm install && npm run dev
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 13:50:15 +02:00
|
|
|
|
from pathlib import Path
|
|
|
|
|
|
|
|
|
|
|
|
# Ensure project root on sys.path
|
|
|
|
|
|
sys.path.insert(0, str(Path(__file__).parent.parent))
|
|
|
|
|
|
|
2026-03-16 20:39:17 +02:00
|
|
|
|
from fastapi import FastAPI, File, HTTPException, Query, UploadFile
|
Add web GUI: FastAPI API + Vue 3 frontend with dark theme
API (web/api.py):
GET /api/projects, /api/projects/{id}, /api/tasks/{id}
GET /api/decisions?project=X, /api/cost?days=7, /api/support/tickets
POST /api/projects, /api/tasks, /api/decisions, /api/bootstrap
CORS for localhost:5173, all queries via models.py
Frontend (web/frontend/):
Vue 3 + TypeScript + Vite + Tailwind CSS v3
Dashboard: project cards with task counters, cost, status badges
ProjectView: tabs for Tasks/Decisions/Modules with filters
Modals: Add Project, Add Task, Add Decision, Bootstrap
Dark theme, monospace font, minimal clean design
Startup:
API: cd web && uvicorn api:app --reload --port 8420
Web: cd web/frontend && npm install && npm run dev
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 13:50:15 +02:00
|
|
|
|
from fastapi.middleware.cors import CORSMiddleware
|
2026-03-16 17:44:49 +02:00
|
|
|
|
from fastapi.responses import JSONResponse, FileResponse, Response
|
2026-03-15 17:11:38 +02:00
|
|
|
|
from fastapi.staticfiles import StaticFiles
|
2026-03-16 09:43:26 +02:00
|
|
|
|
from pydantic import BaseModel, model_validator
|
Add web GUI: FastAPI API + Vue 3 frontend with dark theme
API (web/api.py):
GET /api/projects, /api/projects/{id}, /api/tasks/{id}
GET /api/decisions?project=X, /api/cost?days=7, /api/support/tickets
POST /api/projects, /api/tasks, /api/decisions, /api/bootstrap
CORS for localhost:5173, all queries via models.py
Frontend (web/frontend/):
Vue 3 + TypeScript + Vite + Tailwind CSS v3
Dashboard: project cards with task counters, cost, status badges
ProjectView: tabs for Tasks/Decisions/Modules with filters
Modals: Add Project, Add Task, Add Decision, Bootstrap
Dark theme, monospace font, minimal clean design
Startup:
API: cd web && uvicorn api:app --reload --port 8420
Web: cd web/frontend && npm install && npm run dev
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 13:50:15 +02:00
|
|
|
|
|
|
|
|
|
|
from core.db import init_db
|
|
|
|
|
|
from core import models
|
2026-03-16 08:21:13 +02:00
|
|
|
|
from core.models import VALID_COMPLETION_MODES, TASK_CATEGORIES
|
Add web GUI: FastAPI API + Vue 3 frontend with dark theme
API (web/api.py):
GET /api/projects, /api/projects/{id}, /api/tasks/{id}
GET /api/decisions?project=X, /api/cost?days=7, /api/support/tickets
POST /api/projects, /api/tasks, /api/decisions, /api/bootstrap
CORS for localhost:5173, all queries via models.py
Frontend (web/frontend/):
Vue 3 + TypeScript + Vite + Tailwind CSS v3
Dashboard: project cards with task counters, cost, status badges
ProjectView: tabs for Tasks/Decisions/Modules with filters
Modals: Add Project, Add Task, Add Decision, Bootstrap
Dark theme, monospace font, minimal clean design
Startup:
API: cd web && uvicorn api:app --reload --port 8420
Web: cd web/frontend && npm install && npm run dev
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 13:50:15 +02:00
|
|
|
|
from agents.bootstrap import (
|
|
|
|
|
|
detect_tech_stack, detect_modules, extract_decisions_from_claude_md,
|
|
|
|
|
|
find_vault_root, scan_obsidian, save_to_db,
|
|
|
|
|
|
)
|
2026-03-17 18:29:32 +02:00
|
|
|
|
from core.deploy import VALID_RUNTIMES, deploy_with_dependents
|
Add web GUI: FastAPI API + Vue 3 frontend with dark theme
API (web/api.py):
GET /api/projects, /api/projects/{id}, /api/tasks/{id}
GET /api/decisions?project=X, /api/cost?days=7, /api/support/tickets
POST /api/projects, /api/tasks, /api/decisions, /api/bootstrap
CORS for localhost:5173, all queries via models.py
Frontend (web/frontend/):
Vue 3 + TypeScript + Vite + Tailwind CSS v3
Dashboard: project cards with task counters, cost, status badges
ProjectView: tabs for Tasks/Decisions/Modules with filters
Modals: Add Project, Add Task, Add Decision, Bootstrap
Dark theme, monospace font, minimal clean design
Startup:
API: cd web && uvicorn api:app --reload --port 8420
Web: cd web/frontend && npm install && npm run dev
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 13:50:15 +02:00
|
|
|
|
|
|
|
|
|
|
DB_PATH = Path.home() / ".kin" / "kin.db"
|
|
|
|
|
|
|
2026-03-16 06:59:46 +02:00
|
|
|
|
_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()
|
|
|
|
|
|
|
Add web GUI: FastAPI API + Vue 3 frontend with dark theme
API (web/api.py):
GET /api/projects, /api/projects/{id}, /api/tasks/{id}
GET /api/decisions?project=X, /api/cost?days=7, /api/support/tickets
POST /api/projects, /api/tasks, /api/decisions, /api/bootstrap
CORS for localhost:5173, all queries via models.py
Frontend (web/frontend/):
Vue 3 + TypeScript + Vite + Tailwind CSS v3
Dashboard: project cards with task counters, cost, status badges
ProjectView: tabs for Tasks/Decisions/Modules with filters
Modals: Add Project, Add Task, Add Decision, Bootstrap
Dark theme, monospace font, minimal clean design
Startup:
API: cd web && uvicorn api:app --reload --port 8420
Web: cd web/frontend && npm install && npm run dev
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 13:50:15 +02:00
|
|
|
|
app = FastAPI(title="Kin API", version="0.1.0")
|
|
|
|
|
|
|
2026-03-17 15:59:43 +02:00
|
|
|
|
# Start pipeline watchdog (KIN-099): detects dead subprocess PIDs every 30s
|
|
|
|
|
|
from core.watchdog import start_watchdog as _start_watchdog
|
|
|
|
|
|
_start_watchdog(DB_PATH)
|
|
|
|
|
|
|
Add web GUI: FastAPI API + Vue 3 frontend with dark theme
API (web/api.py):
GET /api/projects, /api/projects/{id}, /api/tasks/{id}
GET /api/decisions?project=X, /api/cost?days=7, /api/support/tickets
POST /api/projects, /api/tasks, /api/decisions, /api/bootstrap
CORS for localhost:5173, all queries via models.py
Frontend (web/frontend/):
Vue 3 + TypeScript + Vite + Tailwind CSS v3
Dashboard: project cards with task counters, cost, status badges
ProjectView: tabs for Tasks/Decisions/Modules with filters
Modals: Add Project, Add Task, Add Decision, Bootstrap
Dark theme, monospace font, minimal clean design
Startup:
API: cd web && uvicorn api:app --reload --port 8420
Web: cd web/frontend && npm install && npm run dev
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 13:50:15 +02:00
|
|
|
|
app.add_middleware(
|
|
|
|
|
|
CORSMiddleware,
|
2026-03-15 17:11:38 +02:00
|
|
|
|
allow_origins=["*"],
|
Add web GUI: FastAPI API + Vue 3 frontend with dark theme
API (web/api.py):
GET /api/projects, /api/projects/{id}, /api/tasks/{id}
GET /api/decisions?project=X, /api/cost?days=7, /api/support/tickets
POST /api/projects, /api/tasks, /api/decisions, /api/bootstrap
CORS for localhost:5173, all queries via models.py
Frontend (web/frontend/):
Vue 3 + TypeScript + Vite + Tailwind CSS v3
Dashboard: project cards with task counters, cost, status badges
ProjectView: tabs for Tasks/Decisions/Modules with filters
Modals: Add Project, Add Task, Add Decision, Bootstrap
Dark theme, monospace font, minimal clean design
Startup:
API: cd web && uvicorn api:app --reload --port 8420
Web: cd web/frontend && npm install && npm run dev
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 13:50:15 +02:00
|
|
|
|
allow_methods=["*"],
|
|
|
|
|
|
allow_headers=["*"],
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def get_conn():
|
|
|
|
|
|
return init_db(DB_PATH)
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-03-16 22:35:31 +02:00
|
|
|
|
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()
|
2026-03-17 16:36:52 +02:00
|
|
|
|
if 'SSH_AUTH_SOCK' not in env:
|
|
|
|
|
|
_socks = _glob.glob('/private/tmp/com.apple.launchd.*/Listeners')
|
|
|
|
|
|
if _socks:
|
|
|
|
|
|
env['SSH_AUTH_SOCK'] = _socks[0]
|
2026-03-16 22:35:31 +02:00
|
|
|
|
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)
|
|
|
|
|
|
|
|
|
|
|
|
|
Add web GUI: FastAPI API + Vue 3 frontend with dark theme
API (web/api.py):
GET /api/projects, /api/projects/{id}, /api/tasks/{id}
GET /api/decisions?project=X, /api/cost?days=7, /api/support/tickets
POST /api/projects, /api/tasks, /api/decisions, /api/bootstrap
CORS for localhost:5173, all queries via models.py
Frontend (web/frontend/):
Vue 3 + TypeScript + Vite + Tailwind CSS v3
Dashboard: project cards with task counters, cost, status badges
ProjectView: tabs for Tasks/Decisions/Modules with filters
Modals: Add Project, Add Task, Add Decision, Bootstrap
Dark theme, monospace font, minimal clean design
Startup:
API: cd web && uvicorn api:app --reload --port 8420
Web: cd web/frontend && npm install && npm run dev
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 13:50:15 +02:00
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
# 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
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-03-16 09:13:34 +02:00
|
|
|
|
class NewProjectCreate(BaseModel):
|
|
|
|
|
|
id: str
|
|
|
|
|
|
name: str
|
2026-03-16 09:47:56 +02:00
|
|
|
|
path: str | None = None
|
2026-03-16 09:13:34 +02:00
|
|
|
|
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
|
|
|
|
|
|
|
|
|
|
|
|
|
Add web GUI: FastAPI API + Vue 3 frontend with dark theme
API (web/api.py):
GET /api/projects, /api/projects/{id}, /api/tasks/{id}
GET /api/decisions?project=X, /api/cost?days=7, /api/support/tickets
POST /api/projects, /api/tasks, /api/decisions, /api/bootstrap
CORS for localhost:5173, all queries via models.py
Frontend (web/frontend/):
Vue 3 + TypeScript + Vite + Tailwind CSS v3
Dashboard: project cards with task counters, cost, status badges
ProjectView: tabs for Tasks/Decisions/Modules with filters
Modals: Add Project, Add Task, Add Decision, Bootstrap
Dark theme, monospace font, minimal clean design
Startup:
API: cd web && uvicorn api:app --reload --port 8420
Web: cd web/frontend && npm install && npm run dev
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 13:50:15 +02:00
|
|
|
|
@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}
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-03-16 09:13:34 +02:00
|
|
|
|
VALID_PROJECT_TYPES = {"development", "operations", "research"}
|
|
|
|
|
|
|
|
|
|
|
|
|
Add web GUI: FastAPI API + Vue 3 frontend with dark theme
API (web/api.py):
GET /api/projects, /api/projects/{id}, /api/tasks/{id}
GET /api/decisions?project=X, /api/cost?days=7, /api/support/tickets
POST /api/projects, /api/tasks, /api/decisions, /api/bootstrap
CORS for localhost:5173, all queries via models.py
Frontend (web/frontend/):
Vue 3 + TypeScript + Vite + Tailwind CSS v3
Dashboard: project cards with task counters, cost, status badges
ProjectView: tabs for Tasks/Decisions/Modules with filters
Modals: Add Project, Add Task, Add Decision, Bootstrap
Dark theme, monospace font, minimal clean design
Startup:
API: cd web && uvicorn api:app --reload --port 8420
Web: cd web/frontend && npm install && npm run dev
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 13:50:15 +02:00
|
|
|
|
class ProjectCreate(BaseModel):
|
|
|
|
|
|
id: str
|
|
|
|
|
|
name: str
|
2026-03-16 09:47:56 +02:00
|
|
|
|
path: str | None = None
|
Add web GUI: FastAPI API + Vue 3 frontend with dark theme
API (web/api.py):
GET /api/projects, /api/projects/{id}, /api/tasks/{id}
GET /api/decisions?project=X, /api/cost?days=7, /api/support/tickets
POST /api/projects, /api/tasks, /api/decisions, /api/bootstrap
CORS for localhost:5173, all queries via models.py
Frontend (web/frontend/):
Vue 3 + TypeScript + Vite + Tailwind CSS v3
Dashboard: project cards with task counters, cost, status badges
ProjectView: tabs for Tasks/Decisions/Modules with filters
Modals: Add Project, Add Task, Add Decision, Bootstrap
Dark theme, monospace font, minimal clean design
Startup:
API: cd web && uvicorn api:app --reload --port 8420
Web: cd web/frontend && npm install && npm run dev
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 13:50:15 +02:00
|
|
|
|
tech_stack: list[str] | None = None
|
|
|
|
|
|
status: str = "active"
|
|
|
|
|
|
priority: int = 5
|
2026-03-16 09:13:34 +02:00
|
|
|
|
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
|
2026-03-17 19:30:15 +02:00
|
|
|
|
test_command: str | None = None
|
Add web GUI: FastAPI API + Vue 3 frontend with dark theme
API (web/api.py):
GET /api/projects, /api/projects/{id}, /api/tasks/{id}
GET /api/decisions?project=X, /api/cost?days=7, /api/support/tickets
POST /api/projects, /api/tasks, /api/decisions, /api/bootstrap
CORS for localhost:5173, all queries via models.py
Frontend (web/frontend/):
Vue 3 + TypeScript + Vite + Tailwind CSS v3
Dashboard: project cards with task counters, cost, status badges
ProjectView: tabs for Tasks/Decisions/Modules with filters
Modals: Add Project, Add Task, Add Decision, Bootstrap
Dark theme, monospace font, minimal clean design
Startup:
API: cd web && uvicorn api:app --reload --port 8420
Web: cd web/frontend && npm install && npm run dev
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 13:50:15 +02:00
|
|
|
|
|
2026-03-16 09:43:26 +02:00
|
|
|
|
@model_validator(mode="after")
|
2026-03-16 09:47:56 +02:00
|
|
|
|
def validate_fields(self) -> "ProjectCreate":
|
2026-03-16 09:43:26 +02:00
|
|
|
|
if self.project_type == "operations" and not self.ssh_host:
|
|
|
|
|
|
raise ValueError("ssh_host is required for operations projects")
|
2026-03-16 09:47:56 +02:00
|
|
|
|
if self.project_type != "operations" and not self.path:
|
|
|
|
|
|
raise ValueError("path is required for non-operations projects")
|
2026-03-16 09:43:26 +02:00
|
|
|
|
return self
|
|
|
|
|
|
|
Add web GUI: FastAPI API + Vue 3 frontend with dark theme
API (web/api.py):
GET /api/projects, /api/projects/{id}, /api/tasks/{id}
GET /api/decisions?project=X, /api/cost?days=7, /api/support/tickets
POST /api/projects, /api/tasks, /api/decisions, /api/bootstrap
CORS for localhost:5173, all queries via models.py
Frontend (web/frontend/):
Vue 3 + TypeScript + Vite + Tailwind CSS v3
Dashboard: project cards with task counters, cost, status badges
ProjectView: tabs for Tasks/Decisions/Modules with filters
Modals: Add Project, Add Task, Add Decision, Bootstrap
Dark theme, monospace font, minimal clean design
Startup:
API: cd web && uvicorn api:app --reload --port 8420
Web: cd web/frontend && npm install && npm run dev
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 13:50:15 +02:00
|
|
|
|
|
2026-03-15 20:02:01 +02:00
|
|
|
|
class ProjectPatch(BaseModel):
|
2026-03-16 07:06:34 +02:00
|
|
|
|
execution_mode: str | None = None
|
|
|
|
|
|
autocommit_enabled: bool | None = None
|
2026-03-16 22:35:31 +02:00
|
|
|
|
auto_test_enabled: bool | None = None
|
2026-03-17 20:18:51 +02:00
|
|
|
|
worktrees_enabled: bool | None = None
|
2026-03-16 07:14:32 +02:00
|
|
|
|
obsidian_vault_path: str | None = None
|
2026-03-16 08:21:13 +02:00
|
|
|
|
deploy_command: str | None = None
|
2026-03-17 17:26:31 +02:00
|
|
|
|
deploy_host: str | None = None
|
|
|
|
|
|
deploy_path: str | None = None
|
|
|
|
|
|
deploy_runtime: str | None = None
|
|
|
|
|
|
deploy_restart_cmd: str | None = None
|
2026-03-17 15:40:31 +02:00
|
|
|
|
test_command: str | None = None
|
2026-03-16 09:13:34 +02:00
|
|
|
|
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
|
2026-03-15 20:02:01 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@app.patch("/api/projects/{project_id}")
|
|
|
|
|
|
def patch_project(project_id: str, body: ProjectPatch):
|
2026-03-16 09:13:34 +02:00
|
|
|
|
has_any = any([
|
|
|
|
|
|
body.execution_mode, body.autocommit_enabled is not None,
|
2026-03-17 20:18:51 +02:00
|
|
|
|
body.auto_test_enabled is not None, body.worktrees_enabled is not None,
|
2026-03-16 09:13:34 +02:00
|
|
|
|
body.obsidian_vault_path, body.deploy_command is not None,
|
2026-03-17 17:26:31 +02:00
|
|
|
|
body.deploy_host is not None, body.deploy_path is not None,
|
|
|
|
|
|
body.deploy_runtime is not None, body.deploy_restart_cmd is not None,
|
2026-03-17 15:40:31 +02:00
|
|
|
|
body.test_command is not None,
|
2026-03-16 09:13:34 +02:00
|
|
|
|
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.")
|
2026-03-16 07:06:34 +02:00
|
|
|
|
if body.execution_mode is not None and body.execution_mode not in VALID_EXECUTION_MODES:
|
2026-03-15 20:02:01 +02:00
|
|
|
|
raise HTTPException(400, f"Invalid execution_mode '{body.execution_mode}'. Must be one of: {', '.join(VALID_EXECUTION_MODES)}")
|
2026-03-16 09:13:34 +02:00
|
|
|
|
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)}")
|
2026-03-17 18:29:32 +02:00
|
|
|
|
if body.deploy_runtime is not None and body.deploy_runtime != "" and body.deploy_runtime not in VALID_RUNTIMES:
|
|
|
|
|
|
raise HTTPException(400, f"Invalid deploy_runtime '{body.deploy_runtime}'. Must be one of: {', '.join(sorted(VALID_RUNTIMES))}")
|
2026-03-15 20:02:01 +02:00
|
|
|
|
conn = get_conn()
|
|
|
|
|
|
p = models.get_project(conn, project_id)
|
|
|
|
|
|
if not p:
|
|
|
|
|
|
conn.close()
|
|
|
|
|
|
raise HTTPException(404, f"Project '{project_id}' not found")
|
2026-03-16 07:06:34 +02:00
|
|
|
|
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)
|
2026-03-16 22:35:31 +02:00
|
|
|
|
if body.auto_test_enabled is not None:
|
|
|
|
|
|
fields["auto_test_enabled"] = int(body.auto_test_enabled)
|
2026-03-17 20:18:51 +02:00
|
|
|
|
if body.worktrees_enabled is not None:
|
|
|
|
|
|
fields["worktrees_enabled"] = int(body.worktrees_enabled)
|
2026-03-16 07:14:32 +02:00
|
|
|
|
if body.obsidian_vault_path is not None:
|
|
|
|
|
|
fields["obsidian_vault_path"] = body.obsidian_vault_path
|
2026-03-16 08:21:13 +02:00
|
|
|
|
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
|
2026-03-17 17:26:31 +02:00
|
|
|
|
if body.deploy_host is not None:
|
|
|
|
|
|
fields["deploy_host"] = None if body.deploy_host == "" else body.deploy_host
|
|
|
|
|
|
if body.deploy_path is not None:
|
|
|
|
|
|
fields["deploy_path"] = None if body.deploy_path == "" else body.deploy_path
|
|
|
|
|
|
if body.deploy_runtime is not None:
|
|
|
|
|
|
fields["deploy_runtime"] = None if body.deploy_runtime == "" else body.deploy_runtime
|
|
|
|
|
|
if body.deploy_restart_cmd is not None:
|
|
|
|
|
|
fields["deploy_restart_cmd"] = None if body.deploy_restart_cmd == "" else body.deploy_restart_cmd
|
2026-03-17 15:40:31 +02:00
|
|
|
|
if body.test_command is not None:
|
2026-03-17 19:30:15 +02:00
|
|
|
|
# Empty string = sentinel for enabling auto-detect (stores NULL)
|
|
|
|
|
|
fields["test_command"] = None if body.test_command == "" else body.test_command
|
2026-03-16 09:13:34 +02:00
|
|
|
|
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
|
2026-03-16 07:06:34 +02:00
|
|
|
|
models.update_project(conn, project_id, **fields)
|
2026-03-15 20:02:01 +02:00
|
|
|
|
p = models.get_project(conn, project_id)
|
|
|
|
|
|
conn.close()
|
|
|
|
|
|
return p
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-03-16 17:44:49 +02:00
|
|
|
|
@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)
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-03-16 07:14:32 +02:00
|
|
|
|
@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
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-03-16 08:21:13 +02:00
|
|
|
|
@app.post("/api/projects/{project_id}/deploy")
|
|
|
|
|
|
def deploy_project(project_id: str):
|
2026-03-17 17:26:31 +02:00
|
|
|
|
"""Deploy a project using structured runtime steps or legacy deploy_command.
|
|
|
|
|
|
|
|
|
|
|
|
New-style (deploy_runtime set): uses core/deploy.py — structured steps,
|
|
|
|
|
|
cascades to dependent projects via project_links.
|
|
|
|
|
|
Legacy fallback (only deploy_command set): runs deploy_command via shell.
|
2026-03-16 08:21:13 +02:00
|
|
|
|
|
2026-03-17 17:26:31 +02:00
|
|
|
|
WARNING: shell=True — deploy commands are admin-only, set in Settings by the project owner.
|
2026-03-16 08:21:13 +02:00
|
|
|
|
"""
|
|
|
|
|
|
conn = get_conn()
|
|
|
|
|
|
p = models.get_project(conn, project_id)
|
|
|
|
|
|
if not p:
|
2026-03-17 17:26:31 +02:00
|
|
|
|
conn.close()
|
2026-03-16 08:21:13 +02:00
|
|
|
|
raise HTTPException(404, f"Project '{project_id}' not found")
|
2026-03-17 17:26:31 +02:00
|
|
|
|
|
|
|
|
|
|
deploy_runtime = p.get("deploy_runtime")
|
2026-03-16 08:21:13 +02:00
|
|
|
|
deploy_command = p.get("deploy_command")
|
2026-03-17 17:26:31 +02:00
|
|
|
|
|
|
|
|
|
|
if not deploy_runtime and not deploy_command:
|
|
|
|
|
|
conn.close()
|
|
|
|
|
|
raise HTTPException(400, "Neither deploy_runtime nor deploy_command is set for this project")
|
|
|
|
|
|
|
|
|
|
|
|
if deploy_runtime:
|
|
|
|
|
|
# New structured deploy with dependency cascade
|
|
|
|
|
|
try:
|
|
|
|
|
|
result = deploy_with_dependents(conn, project_id)
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
|
conn.close()
|
|
|
|
|
|
raise HTTPException(500, f"Deploy failed: {e}")
|
|
|
|
|
|
conn.close()
|
|
|
|
|
|
return result
|
|
|
|
|
|
|
|
|
|
|
|
# Legacy fallback: run deploy_command via shell
|
|
|
|
|
|
conn.close()
|
2026-03-16 08:21:13 +02:00
|
|
|
|
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,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
Add web GUI: FastAPI API + Vue 3 frontend with dark theme
API (web/api.py):
GET /api/projects, /api/projects/{id}, /api/tasks/{id}
GET /api/decisions?project=X, /api/cost?days=7, /api/support/tickets
POST /api/projects, /api/tasks, /api/decisions, /api/bootstrap
CORS for localhost:5173, all queries via models.py
Frontend (web/frontend/):
Vue 3 + TypeScript + Vite + Tailwind CSS v3
Dashboard: project cards with task counters, cost, status badges
ProjectView: tabs for Tasks/Decisions/Modules with filters
Modals: Add Project, Add Task, Add Decision, Bootstrap
Dark theme, monospace font, minimal clean design
Startup:
API: cd web && uvicorn api:app --reload --port 8420
Web: cd web/frontend && npm install && npm run dev
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 13:50:15 +02:00
|
|
|
|
@app.post("/api/projects")
|
|
|
|
|
|
def create_project(body: ProjectCreate):
|
2026-03-16 09:13:34 +02:00
|
|
|
|
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)}")
|
Add web GUI: FastAPI API + Vue 3 frontend with dark theme
API (web/api.py):
GET /api/projects, /api/projects/{id}, /api/tasks/{id}
GET /api/decisions?project=X, /api/cost?days=7, /api/support/tickets
POST /api/projects, /api/tasks, /api/decisions, /api/bootstrap
CORS for localhost:5173, all queries via models.py
Frontend (web/frontend/):
Vue 3 + TypeScript + Vite + Tailwind CSS v3
Dashboard: project cards with task counters, cost, status badges
ProjectView: tabs for Tasks/Decisions/Modules with filters
Modals: Add Project, Add Task, Add Decision, Bootstrap
Dark theme, monospace font, minimal clean design
Startup:
API: cd web && uvicorn api:app --reload --port 8420
Web: cd web/frontend && npm install && npm run dev
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 13:50:15 +02:00
|
|
|
|
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,
|
2026-03-16 09:13:34 +02:00
|
|
|
|
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,
|
Add web GUI: FastAPI API + Vue 3 frontend with dark theme
API (web/api.py):
GET /api/projects, /api/projects/{id}, /api/tasks/{id}
GET /api/decisions?project=X, /api/cost?days=7, /api/support/tickets
POST /api/projects, /api/tasks, /api/decisions, /api/bootstrap
CORS for localhost:5173, all queries via models.py
Frontend (web/frontend/):
Vue 3 + TypeScript + Vite + Tailwind CSS v3
Dashboard: project cards with task counters, cost, status badges
ProjectView: tabs for Tasks/Decisions/Modules with filters
Modals: Add Project, Add Task, Add Decision, Bootstrap
Dark theme, monospace font, minimal clean design
Startup:
API: cd web && uvicorn api:app --reload --port 8420
Web: cd web/frontend && npm install && npm run dev
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 13:50:15 +02:00
|
|
|
|
)
|
2026-03-17 19:30:15 +02:00
|
|
|
|
if body.test_command is not None:
|
2026-03-17 15:40:31 +02:00
|
|
|
|
p = models.update_project(conn, body.id, test_command=body.test_command)
|
Add web GUI: FastAPI API + Vue 3 frontend with dark theme
API (web/api.py):
GET /api/projects, /api/projects/{id}, /api/tasks/{id}
GET /api/decisions?project=X, /api/cost?days=7, /api/support/tickets
POST /api/projects, /api/tasks, /api/decisions, /api/bootstrap
CORS for localhost:5173, all queries via models.py
Frontend (web/frontend/):
Vue 3 + TypeScript + Vite + Tailwind CSS v3
Dashboard: project cards with task counters, cost, status badges
ProjectView: tabs for Tasks/Decisions/Modules with filters
Modals: Add Project, Add Task, Add Decision, Bootstrap
Dark theme, monospace font, minimal clean design
Startup:
API: cd web && uvicorn api:app --reload --port 8420
Web: cd web/frontend && npm install && npm run dev
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 13:50:15 +02:00
|
|
|
|
conn.close()
|
|
|
|
|
|
return p
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-03-17 17:26:31 +02:00
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
# Project Links (KIN-079)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
class ProjectLinkCreate(BaseModel):
|
|
|
|
|
|
from_project: str
|
|
|
|
|
|
to_project: str
|
|
|
|
|
|
type: str
|
|
|
|
|
|
description: str | None = None
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-03-17 18:23:48 +02:00
|
|
|
|
@app.post("/api/project-links", status_code=201)
|
2026-03-17 17:26:31 +02:00
|
|
|
|
def create_project_link(body: ProjectLinkCreate):
|
|
|
|
|
|
"""Create a project dependency link."""
|
|
|
|
|
|
conn = get_conn()
|
|
|
|
|
|
if not models.get_project(conn, body.from_project):
|
|
|
|
|
|
conn.close()
|
|
|
|
|
|
raise HTTPException(404, f"Project '{body.from_project}' not found")
|
|
|
|
|
|
if not models.get_project(conn, body.to_project):
|
|
|
|
|
|
conn.close()
|
|
|
|
|
|
raise HTTPException(404, f"Project '{body.to_project}' not found")
|
2026-03-17 18:33:03 +02:00
|
|
|
|
try:
|
|
|
|
|
|
link = models.create_project_link(
|
|
|
|
|
|
conn, body.from_project, body.to_project, body.type, body.description
|
|
|
|
|
|
)
|
|
|
|
|
|
except sqlite3.IntegrityError:
|
|
|
|
|
|
conn.close()
|
|
|
|
|
|
raise HTTPException(409, "Link already exists")
|
2026-03-17 17:26:31 +02:00
|
|
|
|
conn.close()
|
|
|
|
|
|
return link
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@app.get("/api/projects/{project_id}/links")
|
|
|
|
|
|
def get_project_links(project_id: str):
|
|
|
|
|
|
"""Get all project links where project is from or to."""
|
|
|
|
|
|
conn = get_conn()
|
|
|
|
|
|
if not models.get_project(conn, project_id):
|
|
|
|
|
|
conn.close()
|
|
|
|
|
|
raise HTTPException(404, f"Project '{project_id}' not found")
|
|
|
|
|
|
links = models.get_project_links(conn, project_id)
|
|
|
|
|
|
conn.close()
|
|
|
|
|
|
return links
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@app.delete("/api/project-links/{link_id}", status_code=204)
|
|
|
|
|
|
def delete_project_link(link_id: int):
|
|
|
|
|
|
"""Delete a project link by id."""
|
|
|
|
|
|
conn = get_conn()
|
|
|
|
|
|
deleted = models.delete_project_link(conn, link_id)
|
|
|
|
|
|
conn.close()
|
|
|
|
|
|
if not deleted:
|
|
|
|
|
|
raise HTTPException(404, f"Link {link_id} not found")
|
|
|
|
|
|
return Response(status_code=204)
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-03-16 09:13:34 +02:00
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
# 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))
|
2026-03-16 09:43:26 +02:00
|
|
|
|
# Mark the phase's task as done for consistency
|
|
|
|
|
|
if phase.get("task_id"):
|
|
|
|
|
|
models.update_task(conn, phase["task_id"], status="done")
|
2026-03-16 09:13:34 +02:00
|
|
|
|
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
|
|
|
|
|
|
|
|
|
|
|
|
|
kin: KIN-059 Workflow new_project с выбором команды. При создании нового проекта через GUI или CLI директор описывает проект свободным текстом и выбирает галочками какие этапы research нужны: ☐ Business analyst (бизнес-модель, аудитория, монетизация) ☐ Market researcher (конкуренты, ниша, отзывы, сильные/слабые стороны) ☐ Legal researcher (юрисдикция, лицензии, KYC/AML, GDPR) ☐ Tech researcher (API, ограничения, стоимость, альтернативы) ☐ UX designer (анализ UX конкурентов, user journey, wireframes) ☐ Marketer (стратегия продвижения, SEO, conversion-паттерны) ☐ Architect (blueprint на основе одобренных research'ей) — всегда последний Architect включается автоматически если выбран хотя бы один researcher. Каждый выбранный этап — отдельная задача на review. Директор одобряет, отклоняет, или просит доисследовать (Revise). Следующий этап только после approve предыдущего. GUI: форма 'New Project' с описанием + чекбоксы ролей + кнопка 'Start Research'. CLI: kin new-project 'описание' --roles 'business,market,tech,architect'
2026-03-16 09:30:00 +02:00
|
|
|
|
@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.
|
|
|
|
|
|
"""
|
2026-03-16 15:48:09 +02:00
|
|
|
|
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",
|
|
|
|
|
|
})
|
|
|
|
|
|
|
kin: KIN-059 Workflow new_project с выбором команды. При создании нового проекта через GUI или CLI директор описывает проект свободным текстом и выбирает галочками какие этапы research нужны: ☐ Business analyst (бизнес-модель, аудитория, монетизация) ☐ Market researcher (конкуренты, ниша, отзывы, сильные/слабые стороны) ☐ Legal researcher (юрисдикция, лицензии, KYC/AML, GDPR) ☐ Tech researcher (API, ограничения, стоимость, альтернативы) ☐ UX designer (анализ UX конкурентов, user journey, wireframes) ☐ Marketer (стратегия продвижения, SEO, conversion-паттерны) ☐ Architect (blueprint на основе одобренных research'ей) — всегда последний Architect включается автоматически если выбран хотя бы один researcher. Каждый выбранный этап — отдельная задача на review. Директор одобряет, отклоняет, или просит доисследовать (Revise). Следующий этап только после approve предыдущего. GUI: форма 'New Project' с описанием + чекбоксы ролей + кнопка 'Start Research'. CLI: kin new-project 'описание' --roles 'business,market,tech,architect'
2026-03-16 09:30:00 +02:00
|
|
|
|
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
|
2026-03-16 15:48:09 +02:00
|
|
|
|
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)
|
kin: KIN-059 Workflow new_project с выбором команды. При создании нового проекта через GUI или CLI директор описывает проект свободным текстом и выбирает галочками какие этапы research нужны: ☐ Business analyst (бизнес-модель, аудитория, монетизация) ☐ Market researcher (конкуренты, ниша, отзывы, сильные/слабые стороны) ☐ Legal researcher (юрисдикция, лицензии, KYC/AML, GDPR) ☐ Tech researcher (API, ограничения, стоимость, альтернативы) ☐ UX designer (анализ UX конкурентов, user journey, wireframes) ☐ Marketer (стратегия продвижения, SEO, conversion-паттерны) ☐ Architect (blueprint на основе одобренных research'ей) — всегда последний Architect включается автоматически если выбран хотя бы один researcher. Каждый выбранный этап — отдельная задача на review. Директор одобряет, отклоняет, или просит доисследовать (Revise). Следующий этап только после approve предыдущего. GUI: форма 'New Project' с описанием + чекбоксы ролей + кнопка 'Start Research'. CLI: kin new-project 'описание' --roles 'business,market,tech,architect'
2026-03-16 09:30:00 +02:00
|
|
|
|
|
|
|
|
|
|
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,
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
Add web GUI: FastAPI API + Vue 3 frontend with dark theme
API (web/api.py):
GET /api/projects, /api/projects/{id}, /api/tasks/{id}
GET /api/decisions?project=X, /api/cost?days=7, /api/support/tickets
POST /api/projects, /api/tasks, /api/decisions, /api/bootstrap
CORS for localhost:5173, all queries via models.py
Frontend (web/frontend/):
Vue 3 + TypeScript + Vite + Tailwind CSS v3
Dashboard: project cards with task counters, cost, status badges
ProjectView: tabs for Tasks/Decisions/Modules with filters
Modals: Add Project, Add Task, Add Decision, Bootstrap
Dark theme, monospace font, minimal clean design
Startup:
API: cd web && uvicorn api:app --reload --port 8420
Web: cd web/frontend && npm install && npm run dev
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 13:50:15 +02:00
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
# Tasks
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
|
2026-03-18 15:45:43 +02:00
|
|
|
|
@app.get("/api/tasks")
|
|
|
|
|
|
def list_tasks(
|
|
|
|
|
|
status: str | None = Query(default=None),
|
|
|
|
|
|
limit: int = Query(default=20, ge=1, le=500),
|
|
|
|
|
|
sort: str = Query(default="updated_at"),
|
|
|
|
|
|
project_id: str | None = Query(default=None),
|
2026-03-18 20:55:35 +02:00
|
|
|
|
parent_task_id: str | None = Query(default=None),
|
2026-03-18 15:45:43 +02:00
|
|
|
|
):
|
2026-03-18 20:55:35 +02:00
|
|
|
|
"""List tasks with optional filters. sort defaults to updated_at desc.
|
|
|
|
|
|
|
|
|
|
|
|
parent_task_id: filter by parent task. Use '__none__' to get root tasks only.
|
|
|
|
|
|
"""
|
2026-03-18 15:45:43 +02:00
|
|
|
|
from core.models import VALID_TASK_SORT_FIELDS
|
|
|
|
|
|
conn = get_conn()
|
|
|
|
|
|
tasks = models.list_tasks(
|
|
|
|
|
|
conn,
|
|
|
|
|
|
project_id=project_id,
|
|
|
|
|
|
status=status,
|
2026-03-18 20:55:35 +02:00
|
|
|
|
parent_task_id=parent_task_id,
|
2026-03-18 15:45:43 +02:00
|
|
|
|
limit=limit,
|
|
|
|
|
|
sort=sort if sort in VALID_TASK_SORT_FIELDS else "updated_at",
|
|
|
|
|
|
sort_dir="desc",
|
|
|
|
|
|
)
|
|
|
|
|
|
conn.close()
|
|
|
|
|
|
return tasks
|
|
|
|
|
|
|
|
|
|
|
|
|
Add web GUI: FastAPI API + Vue 3 frontend with dark theme
API (web/api.py):
GET /api/projects, /api/projects/{id}, /api/tasks/{id}
GET /api/decisions?project=X, /api/cost?days=7, /api/support/tickets
POST /api/projects, /api/tasks, /api/decisions, /api/bootstrap
CORS for localhost:5173, all queries via models.py
Frontend (web/frontend/):
Vue 3 + TypeScript + Vite + Tailwind CSS v3
Dashboard: project cards with task counters, cost, status badges
ProjectView: tabs for Tasks/Decisions/Modules with filters
Modals: Add Project, Add Task, Add Decision, Bootstrap
Dark theme, monospace font, minimal clean design
Startup:
API: cd web && uvicorn api:app --reload --port 8420
Web: cd web/frontend && npm install && npm run dev
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 13:50:15 +02:00
|
|
|
|
@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
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-03-18 20:55:35 +02:00
|
|
|
|
@app.get("/api/tasks/{task_id}/children")
|
|
|
|
|
|
def get_task_children(task_id: str):
|
|
|
|
|
|
"""Get direct child tasks of a given task."""
|
|
|
|
|
|
conn = get_conn()
|
|
|
|
|
|
t = models.get_task(conn, task_id)
|
|
|
|
|
|
if not t:
|
|
|
|
|
|
conn.close()
|
|
|
|
|
|
raise HTTPException(404, f"Task '{task_id}' not found")
|
|
|
|
|
|
children = models.get_children(conn, task_id)
|
|
|
|
|
|
conn.close()
|
|
|
|
|
|
return children
|
|
|
|
|
|
|
|
|
|
|
|
|
Add web GUI: FastAPI API + Vue 3 frontend with dark theme
API (web/api.py):
GET /api/projects, /api/projects/{id}, /api/tasks/{id}
GET /api/decisions?project=X, /api/cost?days=7, /api/support/tickets
POST /api/projects, /api/tasks, /api/decisions, /api/bootstrap
CORS for localhost:5173, all queries via models.py
Frontend (web/frontend/):
Vue 3 + TypeScript + Vite + Tailwind CSS v3
Dashboard: project cards with task counters, cost, status badges
ProjectView: tabs for Tasks/Decisions/Modules with filters
Modals: Add Project, Add Task, Add Decision, Bootstrap
Dark theme, monospace font, minimal clean design
Startup:
API: cd web && uvicorn api:app --reload --port 8420
Web: cd web/frontend && npm install && npm run dev
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 13:50:15 +02:00
|
|
|
|
class TaskCreate(BaseModel):
|
|
|
|
|
|
project_id: str
|
|
|
|
|
|
title: str
|
|
|
|
|
|
priority: int = 5
|
|
|
|
|
|
route_type: str | None = None
|
2026-03-16 08:21:13 +02:00
|
|
|
|
category: str | None = None
|
2026-03-16 09:58:51 +02:00
|
|
|
|
acceptance_criteria: str | None = None
|
2026-03-16 22:35:31 +02:00
|
|
|
|
labels: list[str] | None = None
|
2026-03-18 21:02:34 +02:00
|
|
|
|
parent_task_id: str | None = None
|
Add web GUI: FastAPI API + Vue 3 frontend with dark theme
API (web/api.py):
GET /api/projects, /api/projects/{id}, /api/tasks/{id}
GET /api/decisions?project=X, /api/cost?days=7, /api/support/tickets
POST /api/projects, /api/tasks, /api/decisions, /api/bootstrap
CORS for localhost:5173, all queries via models.py
Frontend (web/frontend/):
Vue 3 + TypeScript + Vite + Tailwind CSS v3
Dashboard: project cards with task counters, cost, status badges
ProjectView: tabs for Tasks/Decisions/Modules with filters
Modals: Add Project, Add Task, Add Decision, Bootstrap
Dark theme, monospace font, minimal clean design
Startup:
API: cd web && uvicorn api:app --reload --port 8420
Web: cd web/frontend && npm install && npm run dev
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 13:50:15 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@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")
|
2026-03-16 08:21:13 +02:00
|
|
|
|
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)
|
Add web GUI: FastAPI API + Vue 3 frontend with dark theme
API (web/api.py):
GET /api/projects, /api/projects/{id}, /api/tasks/{id}
GET /api/decisions?project=X, /api/cost?days=7, /api/support/tickets
POST /api/projects, /api/tasks, /api/decisions, /api/bootstrap
CORS for localhost:5173, all queries via models.py
Frontend (web/frontend/):
Vue 3 + TypeScript + Vite + Tailwind CSS v3
Dashboard: project cards with task counters, cost, status badges
ProjectView: tabs for Tasks/Decisions/Modules with filters
Modals: Add Project, Add Task, Add Decision, Bootstrap
Dark theme, monospace font, minimal clean design
Startup:
API: cd web && uvicorn api:app --reload --port 8420
Web: cd web/frontend && npm install && npm run dev
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 13:50:15 +02:00
|
|
|
|
brief = {"route_type": body.route_type} if body.route_type else None
|
|
|
|
|
|
t = models.create_task(conn, task_id, body.project_id, body.title,
|
2026-03-16 09:58:51 +02:00
|
|
|
|
priority=body.priority, brief=brief, category=category,
|
2026-03-16 22:35:31 +02:00
|
|
|
|
acceptance_criteria=body.acceptance_criteria,
|
2026-03-18 21:02:34 +02:00
|
|
|
|
labels=body.labels,
|
|
|
|
|
|
parent_task_id=body.parent_task_id)
|
Add web GUI: FastAPI API + Vue 3 frontend with dark theme
API (web/api.py):
GET /api/projects, /api/projects/{id}, /api/tasks/{id}
GET /api/decisions?project=X, /api/cost?days=7, /api/support/tickets
POST /api/projects, /api/tasks, /api/decisions, /api/bootstrap
CORS for localhost:5173, all queries via models.py
Frontend (web/frontend/):
Vue 3 + TypeScript + Vite + Tailwind CSS v3
Dashboard: project cards with task counters, cost, status badges
ProjectView: tabs for Tasks/Decisions/Modules with filters
Modals: Add Project, Add Task, Add Decision, Bootstrap
Dark theme, monospace font, minimal clean design
Startup:
API: cd web && uvicorn api:app --reload --port 8420
Web: cd web/frontend && npm install && npm run dev
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 13:50:15 +02:00
|
|
|
|
conn.close()
|
2026-03-16 22:35:31 +02:00
|
|
|
|
|
|
|
|
|
|
# Auto-trigger: if task has 'auto' label, launch pipeline in background
|
|
|
|
|
|
if body.labels and "auto" in body.labels:
|
|
|
|
|
|
_launch_pipeline_subprocess(task_id)
|
|
|
|
|
|
|
Add web GUI: FastAPI API + Vue 3 frontend with dark theme
API (web/api.py):
GET /api/projects, /api/projects/{id}, /api/tasks/{id}
GET /api/decisions?project=X, /api/cost?days=7, /api/support/tickets
POST /api/projects, /api/tasks, /api/decisions, /api/bootstrap
CORS for localhost:5173, all queries via models.py
Frontend (web/frontend/):
Vue 3 + TypeScript + Vite + Tailwind CSS v3
Dashboard: project cards with task counters, cost, status badges
ProjectView: tabs for Tasks/Decisions/Modules with filters
Modals: Add Project, Add Task, Add Decision, Bootstrap
Dark theme, monospace font, minimal clean design
Startup:
API: cd web && uvicorn api:app --reload --port 8420
Web: cd web/frontend && npm install && npm run dev
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 13:50:15 +02:00
|
|
|
|
return t
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-03-16 07:13:32 +02:00
|
|
|
|
VALID_ROUTE_TYPES = {"debug", "feature", "refactor", "hotfix"}
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-03-15 18:17:57 +02:00
|
|
|
|
class TaskPatch(BaseModel):
|
2026-03-15 20:02:01 +02:00
|
|
|
|
status: str | None = None
|
|
|
|
|
|
execution_mode: str | None = None
|
2026-03-16 07:13:32 +02:00
|
|
|
|
priority: int | None = None
|
|
|
|
|
|
route_type: str | None = None
|
|
|
|
|
|
title: str | None = None
|
|
|
|
|
|
brief_text: str | None = None
|
2026-03-16 09:58:51 +02:00
|
|
|
|
acceptance_criteria: str | None = None
|
2026-03-15 18:17:57 +02:00
|
|
|
|
|
|
|
|
|
|
|
2026-03-15 23:22:49 +02:00
|
|
|
|
VALID_STATUSES = set(models.VALID_TASK_STATUSES)
|
2026-03-16 06:59:46 +02:00
|
|
|
|
VALID_EXECUTION_MODES = VALID_COMPLETION_MODES
|
2026-03-15 18:17:57 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@app.patch("/api/tasks/{task_id}")
|
|
|
|
|
|
def patch_task(task_id: str, body: TaskPatch):
|
2026-03-15 20:02:01 +02:00
|
|
|
|
if body.status is not None and body.status not in VALID_STATUSES:
|
2026-03-15 18:17:57 +02:00
|
|
|
|
raise HTTPException(400, f"Invalid status '{body.status}'. Must be one of: {', '.join(VALID_STATUSES)}")
|
2026-03-15 20:02:01 +02:00
|
|
|
|
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)}")
|
2026-03-16 07:13:32 +02:00
|
|
|
|
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")
|
2026-03-16 09:58:51 +02:00
|
|
|
|
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])
|
2026-03-16 07:13:32 +02:00
|
|
|
|
if all_none:
|
|
|
|
|
|
raise HTTPException(400, "Nothing to update.")
|
2026-03-15 18:17:57 +02:00
|
|
|
|
conn = get_conn()
|
|
|
|
|
|
t = models.get_task(conn, task_id)
|
|
|
|
|
|
if not t:
|
|
|
|
|
|
conn.close()
|
|
|
|
|
|
raise HTTPException(404, f"Task '{task_id}' not found")
|
2026-03-15 20:02:01 +02:00
|
|
|
|
fields = {}
|
|
|
|
|
|
if body.status is not None:
|
|
|
|
|
|
fields["status"] = body.status
|
|
|
|
|
|
if body.execution_mode is not None:
|
|
|
|
|
|
fields["execution_mode"] = body.execution_mode
|
2026-03-16 07:13:32 +02:00
|
|
|
|
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
|
2026-03-16 09:58:51 +02:00
|
|
|
|
if body.acceptance_criteria is not None:
|
|
|
|
|
|
fields["acceptance_criteria"] = body.acceptance_criteria
|
2026-03-15 20:02:01 +02:00
|
|
|
|
models.update_task(conn, task_id, **fields)
|
2026-03-15 18:17:57 +02:00
|
|
|
|
t = models.get_task(conn, task_id)
|
|
|
|
|
|
conn.close()
|
|
|
|
|
|
return t
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-03-17 17:26:31 +02:00
|
|
|
|
@app.get("/api/pipelines/{pipeline_id}/logs")
|
|
|
|
|
|
def get_pipeline_logs(pipeline_id: int, since_id: int = 0):
|
2026-03-17 18:30:26 +02:00
|
|
|
|
"""Get pipeline log entries after since_id (for live console polling).
|
|
|
|
|
|
Returns [] if pipeline does not exist — consistent empty response for log collections.
|
|
|
|
|
|
"""
|
2026-03-17 17:26:31 +02:00
|
|
|
|
conn = get_conn()
|
|
|
|
|
|
logs = models.get_pipeline_logs(conn, pipeline_id, since_id=since_id)
|
|
|
|
|
|
conn.close()
|
|
|
|
|
|
return logs
|
|
|
|
|
|
|
|
|
|
|
|
|
Add task detail view, pipeline visualization, approve/reject workflow
API (web/api.py) — 5 new endpoints:
GET /api/tasks/{id}/pipeline — agent_logs as pipeline steps
GET /api/tasks/{id}/full — task + steps + related decisions
POST /api/tasks/{id}/approve — mark done, optionally add decision
POST /api/tasks/{id}/reject — return to pending with reason
POST /api/tasks/{id}/run — launch pipeline in background (202)
Frontend:
TaskDetail (/task/:id) — full task page with:
- Pipeline graph: role cards with icons, arrows, status colors
- Click step → expand output (pre-formatted, JSON detected)
- Action bar: Approve (with optional decision), Reject, Run Pipeline
- Polling for live pipeline updates
Dashboard: review_tasks badge ("awaiting review" in yellow)
ProjectView: task rows are now clickable links to /task/:id
Runner: output_summary no longer truncated (full output for GUI).
Models: get_project_summary includes review_tasks count.
13 new API tests, 105 total, all passing. Frontend builds clean.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 14:32:29 +02:00
|
|
|
|
@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]
|
2026-03-16 08:21:13 +02:00
|
|
|
|
p = models.get_project(conn, t["project_id"])
|
|
|
|
|
|
project_deploy_command = p.get("deploy_command") if p else None
|
2026-03-17 18:29:43 +02:00
|
|
|
|
project_deploy_host = p.get("deploy_host") if p else None
|
|
|
|
|
|
project_deploy_path = p.get("deploy_path") if p else None
|
|
|
|
|
|
project_deploy_runtime = p.get("deploy_runtime") if p else None
|
Add task detail view, pipeline visualization, approve/reject workflow
API (web/api.py) — 5 new endpoints:
GET /api/tasks/{id}/pipeline — agent_logs as pipeline steps
GET /api/tasks/{id}/full — task + steps + related decisions
POST /api/tasks/{id}/approve — mark done, optionally add decision
POST /api/tasks/{id}/reject — return to pending with reason
POST /api/tasks/{id}/run — launch pipeline in background (202)
Frontend:
TaskDetail (/task/:id) — full task page with:
- Pipeline graph: role cards with icons, arrows, status colors
- Click step → expand output (pre-formatted, JSON detected)
- Action bar: Approve (with optional decision), Reject, Run Pipeline
- Polling for live pipeline updates
Dashboard: review_tasks badge ("awaiting review" in yellow)
ProjectView: task rows are now clickable links to /task/:id
Runner: output_summary no longer truncated (full output for GUI).
Models: get_project_summary includes review_tasks count.
13 new API tests, 105 total, all passing. Frontend builds clean.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 14:32:29 +02:00
|
|
|
|
conn.close()
|
2026-03-17 18:29:43 +02:00
|
|
|
|
return {
|
|
|
|
|
|
**t,
|
|
|
|
|
|
"pipeline_steps": steps,
|
|
|
|
|
|
"related_decisions": task_decisions,
|
|
|
|
|
|
"project_deploy_command": project_deploy_command,
|
|
|
|
|
|
"project_deploy_host": project_deploy_host,
|
|
|
|
|
|
"project_deploy_path": project_deploy_path,
|
|
|
|
|
|
"project_deploy_runtime": project_deploy_runtime,
|
|
|
|
|
|
}
|
Add task detail view, pipeline visualization, approve/reject workflow
API (web/api.py) — 5 new endpoints:
GET /api/tasks/{id}/pipeline — agent_logs as pipeline steps
GET /api/tasks/{id}/full — task + steps + related decisions
POST /api/tasks/{id}/approve — mark done, optionally add decision
POST /api/tasks/{id}/reject — return to pending with reason
POST /api/tasks/{id}/run — launch pipeline in background (202)
Frontend:
TaskDetail (/task/:id) — full task page with:
- Pipeline graph: role cards with icons, arrows, status colors
- Click step → expand output (pre-formatted, JSON detected)
- Action bar: Approve (with optional decision), Reject, Run Pipeline
- Polling for live pipeline updates
Dashboard: review_tasks badge ("awaiting review" in yellow)
ProjectView: task rows are now clickable links to /task/:id
Runner: output_summary no longer truncated (full output for GUI).
Models: get_project_summary includes review_tasks count.
13 new API tests, 105 total, all passing. Frontend builds clean.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 14:32:29 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TaskApprove(BaseModel):
|
|
|
|
|
|
decision_title: str | None = None
|
|
|
|
|
|
decision_description: str | None = None
|
|
|
|
|
|
decision_type: str = "decision"
|
Add follow-up task generation on approve
When approving a task, PM agent analyzes pipeline output and creates
follow-up tasks automatically (e.g. security audit → 8 fix tasks).
core/followup.py:
generate_followups() — collects pipeline output, runs followup agent,
parses JSON task list, creates tasks with parent_task_id linkage.
Handles: bare arrays, {tasks:[...]} wrappers, invalid JSON, empty.
agents/prompts/followup.md — PM prompt for analyzing results and
creating actionable follow-up tasks with priority from severity.
CLI: kin approve <task_id> [--followup] [--decision "text"]
API: POST /api/tasks/{id}/approve {create_followups: true}
Returns {status, decision, followup_tasks: [...]}
Frontend (TaskDetail approve modal):
- Checkbox "Create follow-up tasks" (default ON)
- Loading state during generation
- Results view: list of created tasks with links to /task/:id
ProjectView: tasks show "from VDOL-001" for follow-ups.
13 new tests (followup), 125 total, all passing.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 15:02:58 +02:00
|
|
|
|
create_followups: bool = False
|
Add task detail view, pipeline visualization, approve/reject workflow
API (web/api.py) — 5 new endpoints:
GET /api/tasks/{id}/pipeline — agent_logs as pipeline steps
GET /api/tasks/{id}/full — task + steps + related decisions
POST /api/tasks/{id}/approve — mark done, optionally add decision
POST /api/tasks/{id}/reject — return to pending with reason
POST /api/tasks/{id}/run — launch pipeline in background (202)
Frontend:
TaskDetail (/task/:id) — full task page with:
- Pipeline graph: role cards with icons, arrows, status colors
- Click step → expand output (pre-formatted, JSON detected)
- Action bar: Approve (with optional decision), Reject, Run Pipeline
- Polling for live pipeline updates
Dashboard: review_tasks badge ("awaiting review" in yellow)
ProjectView: task rows are now clickable links to /task/:id
Runner: output_summary no longer truncated (full output for GUI).
Models: get_project_summary includes review_tasks count.
13 new API tests, 105 total, all passing. Frontend builds clean.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 14:32:29 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@app.post("/api/tasks/{task_id}/approve")
|
|
|
|
|
|
def approve_task(task_id: str, body: TaskApprove | None = None):
|
Add follow-up task generation on approve
When approving a task, PM agent analyzes pipeline output and creates
follow-up tasks automatically (e.g. security audit → 8 fix tasks).
core/followup.py:
generate_followups() — collects pipeline output, runs followup agent,
parses JSON task list, creates tasks with parent_task_id linkage.
Handles: bare arrays, {tasks:[...]} wrappers, invalid JSON, empty.
agents/prompts/followup.md — PM prompt for analyzing results and
creating actionable follow-up tasks with priority from severity.
CLI: kin approve <task_id> [--followup] [--decision "text"]
API: POST /api/tasks/{id}/approve {create_followups: true}
Returns {status, decision, followup_tasks: [...]}
Frontend (TaskDetail approve modal):
- Checkbox "Create follow-up tasks" (default ON)
- Loading state during generation
- Results view: list of created tasks with links to /task/:id
ProjectView: tasks show "from VDOL-001" for follow-ups.
13 new tests (followup), 125 total, all passing.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 15:02:58 +02:00
|
|
|
|
"""Approve a task: set status=done, optionally add decision and create follow-ups."""
|
|
|
|
|
|
from core.followup import generate_followups
|
|
|
|
|
|
|
Add task detail view, pipeline visualization, approve/reject workflow
API (web/api.py) — 5 new endpoints:
GET /api/tasks/{id}/pipeline — agent_logs as pipeline steps
GET /api/tasks/{id}/full — task + steps + related decisions
POST /api/tasks/{id}/approve — mark done, optionally add decision
POST /api/tasks/{id}/reject — return to pending with reason
POST /api/tasks/{id}/run — launch pipeline in background (202)
Frontend:
TaskDetail (/task/:id) — full task page with:
- Pipeline graph: role cards with icons, arrows, status colors
- Click step → expand output (pre-formatted, JSON detected)
- Action bar: Approve (with optional decision), Reject, Run Pipeline
- Polling for live pipeline updates
Dashboard: review_tasks badge ("awaiting review" in yellow)
ProjectView: task rows are now clickable links to /task/:id
Runner: output_summary no longer truncated (full output for GUI).
Models: get_project_summary includes review_tasks count.
13 new API tests, 105 total, all passing. Frontend builds clean.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 14:32:29 +02:00
|
|
|
|
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")
|
2026-03-15 23:22:49 +02:00
|
|
|
|
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
|
2026-03-16 09:43:26 +02:00
|
|
|
|
|
|
|
|
|
|
# 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
|
|
|
|
|
|
|
Add task detail view, pipeline visualization, approve/reject workflow
API (web/api.py) — 5 new endpoints:
GET /api/tasks/{id}/pipeline — agent_logs as pipeline steps
GET /api/tasks/{id}/full — task + steps + related decisions
POST /api/tasks/{id}/approve — mark done, optionally add decision
POST /api/tasks/{id}/reject — return to pending with reason
POST /api/tasks/{id}/run — launch pipeline in background (202)
Frontend:
TaskDetail (/task/:id) — full task page with:
- Pipeline graph: role cards with icons, arrows, status colors
- Click step → expand output (pre-formatted, JSON detected)
- Action bar: Approve (with optional decision), Reject, Run Pipeline
- Polling for live pipeline updates
Dashboard: review_tasks badge ("awaiting review" in yellow)
ProjectView: task rows are now clickable links to /task/:id
Runner: output_summary no longer truncated (full output for GUI).
Models: get_project_summary includes review_tasks count.
13 new API tests, 105 total, all passing. Frontend builds clean.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 14:32:29 +02:00
|
|
|
|
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,
|
|
|
|
|
|
)
|
Add follow-up task generation on approve
When approving a task, PM agent analyzes pipeline output and creates
follow-up tasks automatically (e.g. security audit → 8 fix tasks).
core/followup.py:
generate_followups() — collects pipeline output, runs followup agent,
parses JSON task list, creates tasks with parent_task_id linkage.
Handles: bare arrays, {tasks:[...]} wrappers, invalid JSON, empty.
agents/prompts/followup.md — PM prompt for analyzing results and
creating actionable follow-up tasks with priority from severity.
CLI: kin approve <task_id> [--followup] [--decision "text"]
API: POST /api/tasks/{id}/approve {create_followups: true}
Returns {status, decision, followup_tasks: [...]}
Frontend (TaskDetail approve modal):
- Checkbox "Create follow-up tasks" (default ON)
- Loading state during generation
- Results view: list of created tasks with links to /task/:id
ProjectView: tasks show "from VDOL-001" for follow-ups.
13 new tests (followup), 125 total, all passing.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 15:02:58 +02:00
|
|
|
|
followup_tasks = []
|
2026-03-15 15:16:48 +02:00
|
|
|
|
pending_actions = []
|
Add follow-up task generation on approve
When approving a task, PM agent analyzes pipeline output and creates
follow-up tasks automatically (e.g. security audit → 8 fix tasks).
core/followup.py:
generate_followups() — collects pipeline output, runs followup agent,
parses JSON task list, creates tasks with parent_task_id linkage.
Handles: bare arrays, {tasks:[...]} wrappers, invalid JSON, empty.
agents/prompts/followup.md — PM prompt for analyzing results and
creating actionable follow-up tasks with priority from severity.
CLI: kin approve <task_id> [--followup] [--decision "text"]
API: POST /api/tasks/{id}/approve {create_followups: true}
Returns {status, decision, followup_tasks: [...]}
Frontend (TaskDetail approve modal):
- Checkbox "Create follow-up tasks" (default ON)
- Loading state during generation
- Results view: list of created tasks with links to /task/:id
ProjectView: tasks show "from VDOL-001" for follow-ups.
13 new tests (followup), 125 total, all passing.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 15:02:58 +02:00
|
|
|
|
if body and body.create_followups:
|
2026-03-15 15:16:48 +02:00
|
|
|
|
result = generate_followups(conn, task_id)
|
|
|
|
|
|
followup_tasks = result["created"]
|
|
|
|
|
|
pending_actions = result["pending_actions"]
|
Add task detail view, pipeline visualization, approve/reject workflow
API (web/api.py) — 5 new endpoints:
GET /api/tasks/{id}/pipeline — agent_logs as pipeline steps
GET /api/tasks/{id}/full — task + steps + related decisions
POST /api/tasks/{id}/approve — mark done, optionally add decision
POST /api/tasks/{id}/reject — return to pending with reason
POST /api/tasks/{id}/run — launch pipeline in background (202)
Frontend:
TaskDetail (/task/:id) — full task page with:
- Pipeline graph: role cards with icons, arrows, status colors
- Click step → expand output (pre-formatted, JSON detected)
- Action bar: Approve (with optional decision), Reject, Run Pipeline
- Polling for live pipeline updates
Dashboard: review_tasks badge ("awaiting review" in yellow)
ProjectView: task rows are now clickable links to /task/:id
Runner: output_summary no longer truncated (full output for GUI).
Models: get_project_summary includes review_tasks count.
13 new API tests, 105 total, all passing. Frontend builds clean.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 14:32:29 +02:00
|
|
|
|
conn.close()
|
Add follow-up task generation on approve
When approving a task, PM agent analyzes pipeline output and creates
follow-up tasks automatically (e.g. security audit → 8 fix tasks).
core/followup.py:
generate_followups() — collects pipeline output, runs followup agent,
parses JSON task list, creates tasks with parent_task_id linkage.
Handles: bare arrays, {tasks:[...]} wrappers, invalid JSON, empty.
agents/prompts/followup.md — PM prompt for analyzing results and
creating actionable follow-up tasks with priority from severity.
CLI: kin approve <task_id> [--followup] [--decision "text"]
API: POST /api/tasks/{id}/approve {create_followups: true}
Returns {status, decision, followup_tasks: [...]}
Frontend (TaskDetail approve modal):
- Checkbox "Create follow-up tasks" (default ON)
- Loading state during generation
- Results view: list of created tasks with links to /task/:id
ProjectView: tasks show "from VDOL-001" for follow-ups.
13 new tests (followup), 125 total, all passing.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 15:02:58 +02:00
|
|
|
|
return {
|
|
|
|
|
|
"status": "done",
|
|
|
|
|
|
"decision": decision,
|
|
|
|
|
|
"followup_tasks": followup_tasks,
|
2026-03-15 15:16:48 +02:00
|
|
|
|
"needs_decision": len(pending_actions) > 0,
|
|
|
|
|
|
"pending_actions": pending_actions,
|
2026-03-16 09:43:26 +02:00
|
|
|
|
"phase": phase_result,
|
Add follow-up task generation on approve
When approving a task, PM agent analyzes pipeline output and creates
follow-up tasks automatically (e.g. security audit → 8 fix tasks).
core/followup.py:
generate_followups() — collects pipeline output, runs followup agent,
parses JSON task list, creates tasks with parent_task_id linkage.
Handles: bare arrays, {tasks:[...]} wrappers, invalid JSON, empty.
agents/prompts/followup.md — PM prompt for analyzing results and
creating actionable follow-up tasks with priority from severity.
CLI: kin approve <task_id> [--followup] [--decision "text"]
API: POST /api/tasks/{id}/approve {create_followups: true}
Returns {status, decision, followup_tasks: [...]}
Frontend (TaskDetail approve modal):
- Checkbox "Create follow-up tasks" (default ON)
- Loading state during generation
- Results view: list of created tasks with links to /task/:id
ProjectView: tasks show "from VDOL-001" for follow-ups.
13 new tests (followup), 125 total, all passing.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 15:02:58 +02:00
|
|
|
|
}
|
Add task detail view, pipeline visualization, approve/reject workflow
API (web/api.py) — 5 new endpoints:
GET /api/tasks/{id}/pipeline — agent_logs as pipeline steps
GET /api/tasks/{id}/full — task + steps + related decisions
POST /api/tasks/{id}/approve — mark done, optionally add decision
POST /api/tasks/{id}/reject — return to pending with reason
POST /api/tasks/{id}/run — launch pipeline in background (202)
Frontend:
TaskDetail (/task/:id) — full task page with:
- Pipeline graph: role cards with icons, arrows, status colors
- Click step → expand output (pre-formatted, JSON detected)
- Action bar: Approve (with optional decision), Reject, Run Pipeline
- Polling for live pipeline updates
Dashboard: review_tasks badge ("awaiting review" in yellow)
ProjectView: task rows are now clickable links to /task/:id
Runner: output_summary no longer truncated (full output for GUI).
Models: get_project_summary includes review_tasks count.
13 new API tests, 105 total, all passing. Frontend builds clean.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 14:32:29 +02:00
|
|
|
|
|
|
|
|
|
|
|
2026-03-17 21:25:12 +02:00
|
|
|
|
class FollowupBody(BaseModel):
|
|
|
|
|
|
dry_run: bool = False
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@app.post("/api/tasks/{task_id}/followup")
|
|
|
|
|
|
def followup_task(task_id: str, body: FollowupBody | None = None):
|
|
|
|
|
|
"""Generate follow-up tasks for a blocked or completed task."""
|
|
|
|
|
|
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")
|
|
|
|
|
|
dry_run = body.dry_run if body else False
|
|
|
|
|
|
result = generate_followups(conn, task_id, dry_run=dry_run)
|
|
|
|
|
|
conn.close()
|
|
|
|
|
|
return {
|
|
|
|
|
|
"created": result["created"],
|
|
|
|
|
|
"pending_actions": result["pending_actions"],
|
|
|
|
|
|
"needs_decision": len(result["pending_actions"]) > 0,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-03-15 15:16:48 +02:00
|
|
|
|
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}
|
|
|
|
|
|
|
|
|
|
|
|
|
Add task detail view, pipeline visualization, approve/reject workflow
API (web/api.py) — 5 new endpoints:
GET /api/tasks/{id}/pipeline — agent_logs as pipeline steps
GET /api/tasks/{id}/full — task + steps + related decisions
POST /api/tasks/{id}/approve — mark done, optionally add decision
POST /api/tasks/{id}/reject — return to pending with reason
POST /api/tasks/{id}/run — launch pipeline in background (202)
Frontend:
TaskDetail (/task/:id) — full task page with:
- Pipeline graph: role cards with icons, arrows, status colors
- Click step → expand output (pre-formatted, JSON detected)
- Action bar: Approve (with optional decision), Reject, Run Pipeline
- Polling for live pipeline updates
Dashboard: review_tasks badge ("awaiting review" in yellow)
ProjectView: task rows are now clickable links to /task/:id
Runner: output_summary no longer truncated (full output for GUI).
Models: get_project_summary includes review_tasks count.
13 new API tests, 105 total, all passing. Frontend builds clean.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 14:32:29 +02:00
|
|
|
|
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}
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-03-16 22:35:31 +02:00
|
|
|
|
_MAX_REVISE_COUNT = 5
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-03-16 07:13:32 +02:00
|
|
|
|
class TaskRevise(BaseModel):
|
|
|
|
|
|
comment: str
|
2026-03-16 22:35:31 +02:00
|
|
|
|
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
|
2026-03-16 07:13:32 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@app.post("/api/tasks/{task_id}/revise")
|
|
|
|
|
|
def revise_task(task_id: str, body: TaskRevise):
|
2026-03-16 22:35:31 +02:00
|
|
|
|
"""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")
|
|
|
|
|
|
|
2026-03-16 07:13:32 +02:00
|
|
|
|
conn = get_conn()
|
|
|
|
|
|
t = models.get_task(conn, task_id)
|
|
|
|
|
|
if not t:
|
|
|
|
|
|
conn.close()
|
|
|
|
|
|
raise HTTPException(404, f"Task '{task_id}' not found")
|
2026-03-16 22:35:31 +02:00
|
|
|
|
|
|
|
|
|
|
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
|
|
|
|
|
|
|
2026-03-18 22:11:14 +02:00
|
|
|
|
# KIN-128: On 2nd+ revision, inject analyst as first step for fresh perspective.
|
|
|
|
|
|
# Guard: skip if analyst is already the first step (idempotent), or if steps is None.
|
|
|
|
|
|
if revise_count >= 2 and steps and (not steps or steps[0].get("role") != "analyst"):
|
|
|
|
|
|
analyst_step = {
|
|
|
|
|
|
"role": "analyst",
|
|
|
|
|
|
"model": "sonnet",
|
|
|
|
|
|
"brief": (
|
|
|
|
|
|
f"Задача вернулась на ревизию №{revise_count}. "
|
|
|
|
|
|
"Проведи свежий анализ причин провала предыдущих попыток "
|
|
|
|
|
|
"и предложи другой подход."
|
|
|
|
|
|
),
|
|
|
|
|
|
}
|
|
|
|
|
|
steps = [analyst_step] + list(steps)
|
|
|
|
|
|
|
2026-03-16 07:13:32 +02:00
|
|
|
|
conn.close()
|
2026-03-16 22:35:31 +02:00
|
|
|
|
|
|
|
|
|
|
# Launch pipeline in background subprocess
|
|
|
|
|
|
_launch_pipeline_subprocess(task_id)
|
|
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
|
"status": "in_progress",
|
|
|
|
|
|
"comment": body.comment,
|
|
|
|
|
|
"revise_count": revise_count,
|
|
|
|
|
|
"pipeline_steps": steps,
|
|
|
|
|
|
}
|
2026-03-16 07:13:32 +02:00
|
|
|
|
|
|
|
|
|
|
|
2026-03-15 15:29:05 +02:00
|
|
|
|
@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}
|
|
|
|
|
|
|
|
|
|
|
|
|
Add task detail view, pipeline visualization, approve/reject workflow
API (web/api.py) — 5 new endpoints:
GET /api/tasks/{id}/pipeline — agent_logs as pipeline steps
GET /api/tasks/{id}/full — task + steps + related decisions
POST /api/tasks/{id}/approve — mark done, optionally add decision
POST /api/tasks/{id}/reject — return to pending with reason
POST /api/tasks/{id}/run — launch pipeline in background (202)
Frontend:
TaskDetail (/task/:id) — full task page with:
- Pipeline graph: role cards with icons, arrows, status colors
- Click step → expand output (pre-formatted, JSON detected)
- Action bar: Approve (with optional decision), Reject, Run Pipeline
- Polling for live pipeline updates
Dashboard: review_tasks badge ("awaiting review" in yellow)
ProjectView: task rows are now clickable links to /task/:id
Runner: output_summary no longer truncated (full output for GUI).
Models: get_project_summary includes review_tasks count.
13 new API tests, 105 total, all passing. Frontend builds clean.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 14:32:29 +02:00
|
|
|
|
@app.post("/api/tasks/{task_id}/run")
|
2026-03-15 23:22:49 +02:00
|
|
|
|
def run_task(task_id: str):
|
Add task detail view, pipeline visualization, approve/reject workflow
API (web/api.py) — 5 new endpoints:
GET /api/tasks/{id}/pipeline — agent_logs as pipeline steps
GET /api/tasks/{id}/full — task + steps + related decisions
POST /api/tasks/{id}/approve — mark done, optionally add decision
POST /api/tasks/{id}/reject — return to pending with reason
POST /api/tasks/{id}/run — launch pipeline in background (202)
Frontend:
TaskDetail (/task/:id) — full task page with:
- Pipeline graph: role cards with icons, arrows, status colors
- Click step → expand output (pre-formatted, JSON detected)
- Action bar: Approve (with optional decision), Reject, Run Pipeline
- Polling for live pipeline updates
Dashboard: review_tasks badge ("awaiting review" in yellow)
ProjectView: task rows are now clickable links to /task/:id
Runner: output_summary no longer truncated (full output for GUI).
Models: get_project_summary includes review_tasks count.
13 new API tests, 105 total, all passing. Frontend builds clean.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 14:32:29 +02:00
|
|
|
|
"""Launch pipeline for a task in background. Returns 202."""
|
2026-03-16 15:48:09 +02:00
|
|
|
|
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",
|
|
|
|
|
|
})
|
|
|
|
|
|
|
Add task detail view, pipeline visualization, approve/reject workflow
API (web/api.py) — 5 new endpoints:
GET /api/tasks/{id}/pipeline — agent_logs as pipeline steps
GET /api/tasks/{id}/full — task + steps + related decisions
POST /api/tasks/{id}/approve — mark done, optionally add decision
POST /api/tasks/{id}/reject — return to pending with reason
POST /api/tasks/{id}/run — launch pipeline in background (202)
Frontend:
TaskDetail (/task/:id) — full task page with:
- Pipeline graph: role cards with icons, arrows, status colors
- Click step → expand output (pre-formatted, JSON detected)
- Action bar: Approve (with optional decision), Reject, Run Pipeline
- Polling for live pipeline updates
Dashboard: review_tasks badge ("awaiting review" in yellow)
ProjectView: task rows are now clickable links to /task/:id
Runner: output_summary no longer truncated (full output for GUI).
Models: get_project_summary includes review_tasks count.
13 new API tests, 105 total, all passing. Frontend builds clean.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 14:32:29 +02:00
|
|
|
|
conn = get_conn()
|
|
|
|
|
|
t = models.get_task(conn, task_id)
|
|
|
|
|
|
if not t:
|
|
|
|
|
|
conn.close()
|
|
|
|
|
|
raise HTTPException(404, f"Task '{task_id}' not found")
|
2026-03-16 19:26:51 +02:00
|
|
|
|
if t.get("status") == "in_progress":
|
|
|
|
|
|
conn.close()
|
|
|
|
|
|
return JSONResponse({"error": "task_already_running"}, status_code=409)
|
2026-03-15 15:29:05 +02:00
|
|
|
|
# Set task to in_progress immediately so UI updates
|
|
|
|
|
|
models.update_task(conn, task_id, status="in_progress")
|
Add task detail view, pipeline visualization, approve/reject workflow
API (web/api.py) — 5 new endpoints:
GET /api/tasks/{id}/pipeline — agent_logs as pipeline steps
GET /api/tasks/{id}/full — task + steps + related decisions
POST /api/tasks/{id}/approve — mark done, optionally add decision
POST /api/tasks/{id}/reject — return to pending with reason
POST /api/tasks/{id}/run — launch pipeline in background (202)
Frontend:
TaskDetail (/task/:id) — full task page with:
- Pipeline graph: role cards with icons, arrows, status colors
- Click step → expand output (pre-formatted, JSON detected)
- Action bar: Approve (with optional decision), Reject, Run Pipeline
- Polling for live pipeline updates
Dashboard: review_tasks badge ("awaiting review" in yellow)
ProjectView: task rows are now clickable links to /task/:id
Runner: output_summary no longer truncated (full output for GUI).
Models: get_project_summary includes review_tasks count.
13 new API tests, 105 total, all passing. Frontend builds clean.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 14:32:29 +02:00
|
|
|
|
conn.close()
|
|
|
|
|
|
# Launch kin run in background subprocess
|
|
|
|
|
|
kin_root = Path(__file__).parent.parent
|
2026-03-15 17:35:08 +02:00
|
|
|
|
cmd = [sys.executable, "-m", "cli.main", "--db", str(DB_PATH),
|
|
|
|
|
|
"run", task_id]
|
2026-03-15 23:22:49 +02:00
|
|
|
|
cmd.append("--allow-write") # always required: subprocess runs non-interactively (stdin=DEVNULL)
|
2026-03-15 17:35:08 +02:00
|
|
|
|
|
|
|
|
|
|
import os
|
|
|
|
|
|
env = os.environ.copy()
|
|
|
|
|
|
env["KIN_NONINTERACTIVE"] = "1"
|
|
|
|
|
|
|
2026-03-15 15:29:05 +02:00
|
|
|
|
try:
|
|
|
|
|
|
proc = subprocess.Popen(
|
2026-03-15 17:35:08 +02:00
|
|
|
|
cmd,
|
2026-03-15 15:29:05 +02:00
|
|
|
|
cwd=str(kin_root),
|
|
|
|
|
|
stdout=subprocess.DEVNULL,
|
2026-03-16 06:59:46 +02:00
|
|
|
|
stderr=subprocess.DEVNULL,
|
2026-03-15 17:35:08 +02:00
|
|
|
|
stdin=subprocess.DEVNULL,
|
|
|
|
|
|
env=env,
|
2026-03-15 15:29:05 +02:00
|
|
|
|
)
|
|
|
|
|
|
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}")
|
Add task detail view, pipeline visualization, approve/reject workflow
API (web/api.py) — 5 new endpoints:
GET /api/tasks/{id}/pipeline — agent_logs as pipeline steps
GET /api/tasks/{id}/full — task + steps + related decisions
POST /api/tasks/{id}/approve — mark done, optionally add decision
POST /api/tasks/{id}/reject — return to pending with reason
POST /api/tasks/{id}/run — launch pipeline in background (202)
Frontend:
TaskDetail (/task/:id) — full task page with:
- Pipeline graph: role cards with icons, arrows, status colors
- Click step → expand output (pre-formatted, JSON detected)
- Action bar: Approve (with optional decision), Reject, Run Pipeline
- Polling for live pipeline updates
Dashboard: review_tasks badge ("awaiting review" in yellow)
ProjectView: task rows are now clickable links to /task/:id
Runner: output_summary no longer truncated (full output for GUI).
Models: get_project_summary includes review_tasks count.
13 new API tests, 105 total, all passing. Frontend builds clean.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 14:32:29 +02:00
|
|
|
|
return JSONResponse({"status": "started", "task_id": task_id}, status_code=202)
|
|
|
|
|
|
|
|
|
|
|
|
|
Add web GUI: FastAPI API + Vue 3 frontend with dark theme
API (web/api.py):
GET /api/projects, /api/projects/{id}, /api/tasks/{id}
GET /api/decisions?project=X, /api/cost?days=7, /api/support/tickets
POST /api/projects, /api/tasks, /api/decisions, /api/bootstrap
CORS for localhost:5173, all queries via models.py
Frontend (web/frontend/):
Vue 3 + TypeScript + Vite + Tailwind CSS v3
Dashboard: project cards with task counters, cost, status badges
ProjectView: tabs for Tasks/Decisions/Modules with filters
Modals: Add Project, Add Task, Add Decision, Bootstrap
Dark theme, monospace font, minimal clean design
Startup:
API: cd web && uvicorn api:app --reload --port 8420
Web: cd web/frontend && npm install && npm run dev
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 13:50:15 +02:00
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
# 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
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-03-15 23:22:49 +02:00
|
|
|
|
@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}
|
|
|
|
|
|
|
|
|
|
|
|
|
Add web GUI: FastAPI API + Vue 3 frontend with dark theme
API (web/api.py):
GET /api/projects, /api/projects/{id}, /api/tasks/{id}
GET /api/decisions?project=X, /api/cost?days=7, /api/support/tickets
POST /api/projects, /api/tasks, /api/decisions, /api/bootstrap
CORS for localhost:5173, all queries via models.py
Frontend (web/frontend/):
Vue 3 + TypeScript + Vite + Tailwind CSS v3
Dashboard: project cards with task counters, cost, status badges
ProjectView: tabs for Tasks/Decisions/Modules with filters
Modals: Add Project, Add Task, Add Decision, Bootstrap
Dark theme, monospace font, minimal clean design
Startup:
API: cd web && uvicorn api:app --reload --port 8420
Web: cd web/frontend && npm install && npm run dev
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 13:50:15 +02:00
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
# 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
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-03-15 17:44:16 +02:00
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
# 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")
|
2026-03-15 18:00:39 +02:00
|
|
|
|
result = run_audit(conn, project_id, noninteractive=True, auto_apply=False)
|
2026-03-15 17:44:16 +02:00
|
|
|
|
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)}
|
|
|
|
|
|
|
|
|
|
|
|
|
Add web GUI: FastAPI API + Vue 3 frontend with dark theme
API (web/api.py):
GET /api/projects, /api/projects/{id}, /api/tasks/{id}
GET /api/decisions?project=X, /api/cost?days=7, /api/support/tickets
POST /api/projects, /api/tasks, /api/decisions, /api/bootstrap
CORS for localhost:5173, all queries via models.py
Frontend (web/frontend/):
Vue 3 + TypeScript + Vite + Tailwind CSS v3
Dashboard: project cards with task counters, cost, status badges
ProjectView: tabs for Tasks/Decisions/Modules with filters
Modals: Add Project, Add Task, Add Decision, Bootstrap
Dark theme, monospace font, minimal clean design
Startup:
API: cd web && uvicorn api:app --reload --port 8420
Web: cd web/frontend && npm install && npm run dev
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 13:50:15 +02:00
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
# 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
|
|
|
|
|
|
|
2026-03-16 15:48:09 +02:00
|
|
|
|
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}")
|
Add web GUI: FastAPI API + Vue 3 frontend with dark theme
API (web/api.py):
GET /api/projects, /api/projects/{id}, /api/tasks/{id}
GET /api/decisions?project=X, /api/cost?days=7, /api/support/tickets
POST /api/projects, /api/tasks, /api/decisions, /api/bootstrap
CORS for localhost:5173, all queries via models.py
Frontend (web/frontend/):
Vue 3 + TypeScript + Vite + Tailwind CSS v3
Dashboard: project cards with task counters, cost, status badges
ProjectView: tabs for Tasks/Decisions/Modules with filters
Modals: Add Project, Add Task, Add Decision, Bootstrap
Dark theme, monospace font, minimal clean design
Startup:
API: cd web && uvicorn api:app --reload --port 8420
Web: cd web/frontend && npm install && npm run dev
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 13:50:15 +02:00
|
|
|
|
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", [])),
|
|
|
|
|
|
}
|
2026-03-15 17:11:38 +02:00
|
|
|
|
|
|
|
|
|
|
|
2026-03-16 19:26:51 +02:00
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
# Environments (KIN-087)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
|
2026-03-16 19:27:55 +02:00
|
|
|
|
VALID_AUTH_TYPES = {"password", "key", "ssh_key"}
|
2026-03-16 19:26:51 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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"],
|
2026-03-16 20:55:01 +02:00
|
|
|
|
# auth_value is decrypted plaintext (get_environment decrypts via _decrypt_auth).
|
|
|
|
|
|
# Stored in tasks.brief — treat as sensitive.
|
2026-03-16 19:26:51 +02:00
|
|
|
|
"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")
|
2026-03-16 19:27:55 +02:00
|
|
|
|
if "KIN_SECRET_KEY" in str(e):
|
|
|
|
|
|
raise HTTPException(503, "Server misconfiguration: KIN_SECRET_KEY is not set. Contact admin.")
|
2026-03-16 20:58:44 +02:00
|
|
|
|
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")
|
2026-03-16 19:26:51 +02:00
|
|
|
|
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)
|
|
|
|
|
|
|
2026-03-16 19:41:38 +02:00
|
|
|
|
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.")
|
2026-03-16 20:58:44 +02:00
|
|
|
|
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")
|
2026-03-16 19:41:38 +02:00
|
|
|
|
raise HTTPException(500, str(e))
|
2026-03-16 19:26:51 +02:00
|
|
|
|
|
|
|
|
|
|
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")
|
2026-03-16 20:55:01 +02:00
|
|
|
|
# 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:
|
2026-03-16 19:26:51 +02:00
|
|
|
|
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."""
|
2026-03-16 20:55:01 +02:00
|
|
|
|
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.")
|
2026-03-16 19:26:51 +02:00
|
|
|
|
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)
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-03-16 09:13:34 +02:00
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
# 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,
|
2026-03-16 09:43:26 +02:00
|
|
|
|
the reason, the pipeline step, and whether a Telegram alert was sent.
|
|
|
|
|
|
Intended for GUI polling (5s interval).
|
2026-03-16 09:13:34 +02:00
|
|
|
|
"""
|
|
|
|
|
|
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"),
|
2026-03-16 09:43:26 +02:00
|
|
|
|
"telegram_sent": bool(t.get("telegram_sent")),
|
2026-03-16 09:13:34 +02:00
|
|
|
|
})
|
|
|
|
|
|
return notifications
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-03-16 20:39:17 +02:00
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
# 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"],
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-03-16 19:26:51 +02:00
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
# 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,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-03-15 17:11:38 +02:00
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
# SPA static files (AFTER all /api/ routes)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
DIST = Path(__file__).parent / "frontend" / "dist"
|
|
|
|
|
|
|
2026-03-18 21:46:06 +02:00
|
|
|
|
if (DIST / "assets").exists():
|
|
|
|
|
|
app.mount("/assets", StaticFiles(directory=str(DIST / "assets")), name="assets")
|
2026-03-15 17:11:38 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@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")
|