kin: KIN-016 Агенты должны уметь говорить 'не могу'. Если агент не может выполнить задачу (нет доступа, не понимает, выходит за компетенцию) — он должен вернуть status: blocked с причиной, а не пытаться угадывать. PM при получении blocked от агента — эскалирует к человеку через GUI (уведомление) и Telegram (когда будет).
This commit is contained in:
parent
a605e9d110
commit
d9172fc17c
35 changed files with 2375 additions and 23 deletions
212
web/api.py
212
web/api.py
|
|
@ -112,6 +112,44 @@ def list_projects(status: str | None = None):
|
|||
return summary
|
||||
|
||||
|
||||
class NewProjectCreate(BaseModel):
|
||||
id: str
|
||||
name: str
|
||||
path: str
|
||||
description: str
|
||||
roles: list[str]
|
||||
tech_stack: list[str] | None = None
|
||||
priority: int = 5
|
||||
language: str = "ru"
|
||||
|
||||
|
||||
@app.post("/api/projects/new")
|
||||
def new_project_with_phases(body: NewProjectCreate):
|
||||
"""Create project + sequential research phases (KIN-059)."""
|
||||
from core.phases import create_project_with_phases, validate_roles
|
||||
clean_roles = validate_roles(body.roles)
|
||||
if not clean_roles:
|
||||
raise HTTPException(400, "At least one research role must be selected (excluding architect)")
|
||||
conn = get_conn()
|
||||
if models.get_project(conn, body.id):
|
||||
conn.close()
|
||||
raise HTTPException(409, f"Project '{body.id}' already exists")
|
||||
try:
|
||||
result = create_project_with_phases(
|
||||
conn, body.id, body.name, body.path,
|
||||
description=body.description,
|
||||
selected_roles=clean_roles,
|
||||
tech_stack=body.tech_stack,
|
||||
priority=body.priority,
|
||||
language=body.language,
|
||||
)
|
||||
except ValueError as e:
|
||||
conn.close()
|
||||
raise HTTPException(400, str(e))
|
||||
conn.close()
|
||||
return result
|
||||
|
||||
|
||||
@app.get("/api/projects/{project_id}")
|
||||
def get_project(project_id: str):
|
||||
conn = get_conn()
|
||||
|
|
@ -126,13 +164,21 @@ def get_project(project_id: str):
|
|||
return {**p, "tasks": tasks, "modules": mods, "decisions": decisions}
|
||||
|
||||
|
||||
VALID_PROJECT_TYPES = {"development", "operations", "research"}
|
||||
|
||||
|
||||
class ProjectCreate(BaseModel):
|
||||
id: str
|
||||
name: str
|
||||
path: str
|
||||
path: str = ""
|
||||
tech_stack: list[str] | None = None
|
||||
status: str = "active"
|
||||
priority: int = 5
|
||||
project_type: str = "development"
|
||||
ssh_host: str | None = None
|
||||
ssh_user: str | None = None
|
||||
ssh_key_path: str | None = None
|
||||
ssh_proxy_jump: str | None = None
|
||||
|
||||
|
||||
class ProjectPatch(BaseModel):
|
||||
|
|
@ -140,14 +186,28 @@ class ProjectPatch(BaseModel):
|
|||
autocommit_enabled: bool | None = None
|
||||
obsidian_vault_path: str | None = None
|
||||
deploy_command: str | None = None
|
||||
project_type: str | None = None
|
||||
ssh_host: str | None = None
|
||||
ssh_user: str | None = None
|
||||
ssh_key_path: str | None = None
|
||||
ssh_proxy_jump: str | None = None
|
||||
|
||||
|
||||
@app.patch("/api/projects/{project_id}")
|
||||
def patch_project(project_id: str, body: ProjectPatch):
|
||||
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.")
|
||||
has_any = any([
|
||||
body.execution_mode, body.autocommit_enabled is not None,
|
||||
body.obsidian_vault_path, body.deploy_command is not None,
|
||||
body.project_type, body.ssh_host is not None,
|
||||
body.ssh_user is not None, body.ssh_key_path is not None,
|
||||
body.ssh_proxy_jump is not None,
|
||||
])
|
||||
if not has_any:
|
||||
raise HTTPException(400, "Nothing to update.")
|
||||
if body.execution_mode is not None and body.execution_mode not in VALID_EXECUTION_MODES:
|
||||
raise HTTPException(400, f"Invalid execution_mode '{body.execution_mode}'. Must be one of: {', '.join(VALID_EXECUTION_MODES)}")
|
||||
if body.project_type is not None and body.project_type not in VALID_PROJECT_TYPES:
|
||||
raise HTTPException(400, f"Invalid project_type '{body.project_type}'. Must be one of: {', '.join(VALID_PROJECT_TYPES)}")
|
||||
conn = get_conn()
|
||||
p = models.get_project(conn, project_id)
|
||||
if not p:
|
||||
|
|
@ -163,6 +223,16 @@ def patch_project(project_id: str, body: ProjectPatch):
|
|||
if body.deploy_command is not None:
|
||||
# Empty string = sentinel for clearing (decision #68)
|
||||
fields["deploy_command"] = None if body.deploy_command == "" else body.deploy_command
|
||||
if body.project_type is not None:
|
||||
fields["project_type"] = body.project_type
|
||||
if body.ssh_host is not None:
|
||||
fields["ssh_host"] = body.ssh_host
|
||||
if body.ssh_user is not None:
|
||||
fields["ssh_user"] = body.ssh_user
|
||||
if body.ssh_key_path is not None:
|
||||
fields["ssh_key_path"] = body.ssh_key_path
|
||||
if body.ssh_proxy_jump is not None:
|
||||
fields["ssh_proxy_jump"] = body.ssh_proxy_jump
|
||||
models.update_project(conn, project_id, **fields)
|
||||
p = models.get_project(conn, project_id)
|
||||
conn.close()
|
||||
|
|
@ -229,6 +299,8 @@ def deploy_project(project_id: str):
|
|||
|
||||
@app.post("/api/projects")
|
||||
def create_project(body: ProjectCreate):
|
||||
if body.project_type not in VALID_PROJECT_TYPES:
|
||||
raise HTTPException(400, f"Invalid project_type '{body.project_type}'. Must be one of: {', '.join(VALID_PROJECT_TYPES)}")
|
||||
conn = get_conn()
|
||||
if models.get_project(conn, body.id):
|
||||
conn.close()
|
||||
|
|
@ -236,11 +308,105 @@ def create_project(body: ProjectCreate):
|
|||
p = models.create_project(
|
||||
conn, body.id, body.name, body.path,
|
||||
tech_stack=body.tech_stack, status=body.status, priority=body.priority,
|
||||
project_type=body.project_type,
|
||||
ssh_host=body.ssh_host,
|
||||
ssh_user=body.ssh_user,
|
||||
ssh_key_path=body.ssh_key_path,
|
||||
ssh_proxy_jump=body.ssh_proxy_jump,
|
||||
)
|
||||
conn.close()
|
||||
return p
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Phases (KIN-059)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@app.get("/api/projects/{project_id}/phases")
|
||||
def get_project_phases(project_id: str):
|
||||
"""List research phases for a project, with task data joined."""
|
||||
conn = get_conn()
|
||||
p = models.get_project(conn, project_id)
|
||||
if not p:
|
||||
conn.close()
|
||||
raise HTTPException(404, f"Project '{project_id}' not found")
|
||||
phases = models.list_phases(conn, project_id)
|
||||
result = []
|
||||
for phase in phases:
|
||||
task = models.get_task(conn, phase["task_id"]) if phase.get("task_id") else None
|
||||
result.append({**phase, "task": task})
|
||||
conn.close()
|
||||
return result
|
||||
|
||||
|
||||
class PhaseApprove(BaseModel):
|
||||
comment: str | None = None
|
||||
|
||||
|
||||
class PhaseReject(BaseModel):
|
||||
reason: str
|
||||
|
||||
|
||||
class PhaseRevise(BaseModel):
|
||||
comment: str
|
||||
|
||||
|
||||
@app.post("/api/phases/{phase_id}/approve")
|
||||
def approve_phase(phase_id: int, body: PhaseApprove | None = None):
|
||||
"""Approve a research phase and activate the next one."""
|
||||
from core.phases import approve_phase as _approve
|
||||
conn = get_conn()
|
||||
phase = models.get_phase(conn, phase_id)
|
||||
if not phase:
|
||||
conn.close()
|
||||
raise HTTPException(404, f"Phase {phase_id} not found")
|
||||
try:
|
||||
result = _approve(conn, phase_id)
|
||||
except ValueError as e:
|
||||
conn.close()
|
||||
raise HTTPException(400, str(e))
|
||||
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
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tasks
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
@ -716,6 +882,46 @@ def bootstrap(body: BootstrapRequest):
|
|||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Notifications (escalations from blocked agents)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@app.get("/api/notifications")
|
||||
def get_notifications(project_id: str | None = None):
|
||||
"""Return tasks with status='blocked' as escalation notifications.
|
||||
|
||||
Each item includes task details, the agent role that blocked it,
|
||||
the reason, and the pipeline step. Intended for GUI polling (5s interval).
|
||||
|
||||
TODO: Telegram — send notification on new escalation (telegram_sent: false placeholder).
|
||||
"""
|
||||
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"),
|
||||
# TODO: Telegram — set to True once notification is sent via Telegram bot
|
||||
"telegram_sent": False,
|
||||
})
|
||||
return notifications
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# SPA static files (AFTER all /api/ routes)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue