kin: KIN-049 Кнопка Deploy на странице задачи после approve. Для каждого проекта настраивается deploy-команда (git push, scp, ssh restart). В Settings проекта.

This commit is contained in:
Gros Frumos 2026-03-16 08:21:13 +02:00
parent 860ef3f6c9
commit d50bd703ae
11 changed files with 517 additions and 61 deletions

View file

@ -20,7 +20,7 @@ from pydantic import BaseModel
from core.db import init_db
from core import models
from core.models import VALID_COMPLETION_MODES
from core.models import VALID_COMPLETION_MODES, TASK_CATEGORIES
from agents.bootstrap import (
detect_tech_stack, detect_modules, extract_decisions_from_claude_md,
find_vault_root, scan_obsidian, save_to_db,
@ -139,12 +139,13 @@ class ProjectPatch(BaseModel):
execution_mode: str | None = None
autocommit_enabled: bool | None = None
obsidian_vault_path: str | None = None
deploy_command: str | None = None
@app.patch("/api/projects/{project_id}")
def patch_project(project_id: str, body: ProjectPatch):
if body.execution_mode is None and body.autocommit_enabled is None and body.obsidian_vault_path is None:
raise HTTPException(400, "Nothing to update. Provide execution_mode, autocommit_enabled, or obsidian_vault_path.")
if body.execution_mode is None and body.autocommit_enabled is None and body.obsidian_vault_path is None and body.deploy_command is None:
raise HTTPException(400, "Nothing to update. Provide execution_mode, autocommit_enabled, obsidian_vault_path, or deploy_command.")
if body.execution_mode is not None and body.execution_mode not in VALID_EXECUTION_MODES:
raise HTTPException(400, f"Invalid execution_mode '{body.execution_mode}'. Must be one of: {', '.join(VALID_EXECUTION_MODES)}")
conn = get_conn()
@ -159,6 +160,9 @@ def patch_project(project_id: str, body: ProjectPatch):
fields["autocommit_enabled"] = int(body.autocommit_enabled)
if body.obsidian_vault_path is not None:
fields["obsidian_vault_path"] = body.obsidian_vault_path
if body.deploy_command is not None:
# Empty string = sentinel for clearing (decision #68)
fields["deploy_command"] = None if body.deploy_command == "" else body.deploy_command
models.update_project(conn, project_id, **fields)
p = models.get_project(conn, project_id)
conn.close()
@ -183,6 +187,46 @@ def sync_obsidian_endpoint(project_id: str):
return result
@app.post("/api/projects/{project_id}/deploy")
def deploy_project(project_id: str):
"""Execute deploy_command for a project. Returns stdout/stderr/exit_code.
# WARNING: shell=True — deploy_command is admin-only, set in Settings by the project owner.
"""
import time
conn = get_conn()
p = models.get_project(conn, project_id)
conn.close()
if not p:
raise HTTPException(404, f"Project '{project_id}' not found")
deploy_command = p.get("deploy_command")
if not deploy_command:
raise HTTPException(400, "deploy_command not set for this project")
cwd = p.get("path") or None
start = time.monotonic()
try:
result = subprocess.run(
deploy_command,
shell=True, # WARNING: shell=True — command is admin-only
cwd=cwd,
capture_output=True,
text=True,
timeout=60,
)
except subprocess.TimeoutExpired:
raise HTTPException(504, "Deploy command timed out after 60 seconds")
except Exception as e:
raise HTTPException(500, f"Deploy failed: {e}")
duration = round(time.monotonic() - start, 2)
return {
"success": result.returncode == 0,
"exit_code": result.returncode,
"stdout": result.stdout,
"stderr": result.stderr,
"duration_seconds": duration,
}
@app.post("/api/projects")
def create_project(body: ProjectCreate):
conn = get_conn()
@ -216,6 +260,7 @@ class TaskCreate(BaseModel):
title: str
priority: int = 5
route_type: str | None = None
category: str | None = None
@app.post("/api/tasks")
@ -225,21 +270,16 @@ def create_task(body: TaskCreate):
if not p:
conn.close()
raise HTTPException(404, f"Project '{body.project_id}' not found")
# Auto-generate task ID
existing = models.list_tasks(conn, project_id=body.project_id)
prefix = body.project_id.upper()
max_num = 0
for t in existing:
if t["id"].startswith(prefix + "-"):
try:
num = int(t["id"].split("-", 1)[1])
max_num = max(max_num, num)
except ValueError:
pass
task_id = f"{prefix}-{max_num + 1:03d}"
category = None
if body.category:
category = body.category.upper()
if category not in TASK_CATEGORIES:
conn.close()
raise HTTPException(400, f"Invalid category '{category}'. Must be one of: {', '.join(TASK_CATEGORIES)}")
task_id = models.next_task_id(conn, body.project_id, category=category)
brief = {"route_type": body.route_type} if body.route_type else None
t = models.create_task(conn, task_id, body.project_id, body.title,
priority=body.priority, brief=brief)
priority=body.priority, brief=brief, category=category)
conn.close()
return t
@ -344,8 +384,10 @@ def get_task_full(task_id: str):
decisions = models.get_decisions(conn, t["project_id"])
# Filter to decisions linked to this task
task_decisions = [d for d in decisions if d.get("task_id") == task_id]
p = models.get_project(conn, t["project_id"])
project_deploy_command = p.get("deploy_command") if p else None
conn.close()
return {**t, "pipeline_steps": steps, "related_decisions": task_decisions}
return {**t, "pipeline_steps": steps, "related_decisions": task_decisions, "project_deploy_command": project_deploy_command}
class TaskApprove(BaseModel):