- Added configuration options for requiring human approval before applying LLM-generated MCP patches. - Updated Docker setup to include skills directory. - Integrated skills management into the backend, allowing for procedural guides and skill matching. - Refactored database initialization to apply Alembic migrations. - Enhanced task approval process to handle MCP patch applications with optional approval. - Introduced new schemas for skills and updated existing APIs to support skills functionality. This commit lays the groundwork for improved agent capabilities and better management of MCP development processes.
480 lines
17 KiB
Python
480 lines
17 KiB
Python
"""Task creation, listing, approvals, and live event streaming."""
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import json
|
|
|
|
from fastapi import APIRouter, Depends, HTTPException, WebSocket, WebSocketDisconnect, status
|
|
from sqlalchemy import select
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
from sqlalchemy.orm import selectinload
|
|
|
|
from app.core.deps import require_readonly, require_support
|
|
from app.core.security import decode_access_token
|
|
from app.database import SessionLocal, get_db
|
|
from app.models.enums import ApprovalStatus, TaskStatus
|
|
from app.models.task import ApprovalRequest, Task
|
|
from app.models.user import User
|
|
from app.schemas import (
|
|
ApprovalDecision,
|
|
ApprovalOut,
|
|
TaskCreate,
|
|
TaskContinue,
|
|
TaskDetail,
|
|
TaskEscalateLlm,
|
|
TaskEventOut,
|
|
TaskOverviewOut,
|
|
TaskOut,
|
|
TaskPreviewIn,
|
|
TaskPreviewOut,
|
|
)
|
|
from app.services.audit import record_audit
|
|
from app.services.events import enqueue_task, publish_event, subscribe_events
|
|
from app.inventory_catalog import catalog_by_key
|
|
from app.models.inventory import Vessel
|
|
from app.services.task_present import (
|
|
task_to_detail,
|
|
task_to_out,
|
|
tasks_to_out_list,
|
|
tasks_to_overview_list,
|
|
vessel_name_map,
|
|
)
|
|
from app.services.task_runtime import CANCELLABLE_STATUSES, clear_task_cancel, request_task_cancel
|
|
from app.services.troubleshooting_rules import select_devices_for_issue
|
|
|
|
router = APIRouter(prefix="/api/tasks", tags=["tasks"])
|
|
|
|
EVENT_REPLAY_LIMIT = 400
|
|
|
|
CHECK_LABELS = {
|
|
"proxmox": ["VM and LXC status", "Guest configuration", "Host health"],
|
|
"pfsense": ["System status", "Interfaces", "Gateways", "Firewall/NAT when requested"],
|
|
"docker_vm": ["Container status", "Host resources"],
|
|
"asterisk_geneseasx": ["Asterisk health", "SIP registrations", "Channels"],
|
|
"asterisk_satbox": ["Asterisk health", "SIP registrations"],
|
|
"fortigate": ["Firewall status"],
|
|
"fortiswitch": ["Switch status"],
|
|
}
|
|
|
|
|
|
def _preview_device_dict(device) -> dict:
|
|
key = device.catalog_key or device.device_type.value
|
|
entry = catalog_by_key().get(device.catalog_key or "")
|
|
return {
|
|
"id": device.id,
|
|
"name": device.name,
|
|
"catalog_key": key,
|
|
"device_type": device.device_type.value,
|
|
"label": entry.label if entry else key.replace("_", " ").title(),
|
|
}
|
|
|
|
|
|
def _checks_for_device(key: str | None) -> list[str]:
|
|
return CHECK_LABELS.get(key or "", ["Health and status checks"])
|
|
|
|
|
|
@router.get("", response_model=list[TaskOut])
|
|
async def list_tasks(
|
|
limit: int = 100,
|
|
offset: int = 0,
|
|
db: AsyncSession = Depends(get_db),
|
|
_: User = Depends(require_readonly),
|
|
):
|
|
limit = max(1, min(limit, 500))
|
|
offset = max(0, offset)
|
|
res = await db.execute(
|
|
select(Task).order_by(Task.id.desc()).limit(limit).offset(offset)
|
|
)
|
|
return await tasks_to_out_list(db, list(res.scalars().all()))
|
|
|
|
|
|
@router.get("/overview", response_model=list[TaskOverviewOut])
|
|
async def task_overview(
|
|
limit: int = 50,
|
|
db: AsyncSession = Depends(get_db),
|
|
_: User = Depends(require_readonly),
|
|
):
|
|
"""Compact task data for dashboard cards. No raw diagnostic payloads."""
|
|
limit = max(1, min(limit, 200))
|
|
res = await db.execute(select(Task).order_by(Task.id.desc()).limit(limit))
|
|
return await tasks_to_overview_list(db, list(res.scalars().all()))
|
|
|
|
|
|
@router.post("/preview", response_model=TaskPreviewOut)
|
|
async def preview_task_scope(
|
|
body: TaskPreviewIn,
|
|
db: AsyncSession = Depends(get_db),
|
|
_: User = Depends(require_readonly),
|
|
):
|
|
"""Preview the devices/check groups that task creation will select."""
|
|
res = await db.execute(
|
|
select(Vessel).options(selectinload(Vessel.devices)).where(Vessel.id == body.vessel_id)
|
|
)
|
|
vessel = res.scalar_one_or_none()
|
|
if not vessel:
|
|
raise HTTPException(status_code=404, detail="Vessel not found")
|
|
device_dicts = [_preview_device_dict(d) for d in vessel.devices]
|
|
selected, rule = select_devices_for_issue(device_dicts, body.issue, title=body.title)
|
|
warning = None
|
|
if not selected:
|
|
warning = "No onboard device matched this issue. Mention a device type or ask for a full health check."
|
|
return TaskPreviewOut(
|
|
vessel_id=vessel.id,
|
|
vessel_name=vessel.name,
|
|
devices=[
|
|
{
|
|
"catalog_key": d.get("catalog_key") or d.get("device_type") or "device",
|
|
"name": d.get("name") or d.get("catalog_key") or "device",
|
|
"label": d.get("label") or (d.get("catalog_key") or "device").replace("_", " ").title(),
|
|
"checks": _checks_for_device(d.get("catalog_key")),
|
|
}
|
|
for d in selected
|
|
],
|
|
rule_id=rule.id if rule else None,
|
|
rule_name=rule.name if rule else None,
|
|
severity=rule.severity if rule else None,
|
|
hints=rule.hints if rule else [],
|
|
warning=warning,
|
|
)
|
|
|
|
|
|
@router.post("", response_model=TaskOut, status_code=status.HTTP_201_CREATED)
|
|
async def create_task(
|
|
body: TaskCreate,
|
|
db: AsyncSession = Depends(get_db),
|
|
user: User = Depends(require_support),
|
|
):
|
|
if not body.vessel_id:
|
|
raise HTTPException(status_code=400, detail="vessel_id is required — pick a vessel; the agent selects devices from the issue")
|
|
task = Task(
|
|
title=body.title,
|
|
issue=body.issue,
|
|
details=body.details,
|
|
device_id=None,
|
|
vessel_id=body.vessel_id,
|
|
created_by_id=user.id,
|
|
status=TaskStatus.queued,
|
|
)
|
|
db.add(task)
|
|
await db.commit()
|
|
await db.refresh(task)
|
|
await enqueue_task(task.id)
|
|
await publish_event(task.id, "log", "Task queued.", persist=True)
|
|
await record_audit(db, user, "task.create", "task", task.id, {"title": task.title})
|
|
vmap = await vessel_name_map(db, {task.vessel_id} if task.vessel_id else set())
|
|
return task_to_out(task, vessel_name=vmap.get(task.vessel_id) if task.vessel_id else None)
|
|
|
|
|
|
@router.post("/{task_id}/cancel", response_model=TaskOut)
|
|
async def cancel_task(
|
|
task_id: int,
|
|
db: AsyncSession = Depends(get_db),
|
|
user: User = Depends(require_support),
|
|
):
|
|
"""Stop a queued or running task. In-flight work is interrupted at the next checkpoint."""
|
|
res = await db.execute(select(Task).where(Task.id == task_id))
|
|
task = res.scalar_one_or_none()
|
|
if not task:
|
|
raise HTTPException(status_code=404, detail="Task not found")
|
|
if task.status.value not in CANCELLABLE_STATUSES:
|
|
raise HTTPException(
|
|
status_code=400,
|
|
detail=f"Cannot stop a task with status '{task.status.value}'",
|
|
)
|
|
|
|
await request_task_cancel(task_id)
|
|
task.status = TaskStatus.cancelled
|
|
task.error = None
|
|
await db.commit()
|
|
await db.refresh(task)
|
|
await publish_event(
|
|
task_id,
|
|
"cancelled",
|
|
f"Task stopped by {user.email}.",
|
|
{"requested_by": user.email},
|
|
persist=True,
|
|
)
|
|
await record_audit(db, user, "task.cancel", "task", task.id)
|
|
vmap = await vessel_name_map(db, {task.vessel_id} if task.vessel_id else set())
|
|
return task_to_out(task, vessel_name=vmap.get(task.vessel_id) if task.vessel_id else None)
|
|
|
|
|
|
@router.post("/{task_id}/retry", response_model=TaskOut)
|
|
async def retry_task(
|
|
task_id: int,
|
|
db: AsyncSession = Depends(get_db),
|
|
user: User = Depends(require_support),
|
|
):
|
|
res = await db.execute(select(Task).where(Task.id == task_id))
|
|
task = res.scalar_one_or_none()
|
|
if not task:
|
|
raise HTTPException(status_code=404, detail="Task not found")
|
|
if task.status in (TaskStatus.running, TaskStatus.queued):
|
|
raise HTTPException(status_code=400, detail="Task is already running")
|
|
await clear_task_cancel(task_id)
|
|
task.status = TaskStatus.queued
|
|
task.error = None
|
|
task.report = None
|
|
task.details = {}
|
|
await db.commit()
|
|
await db.refresh(task)
|
|
await enqueue_task(task.id)
|
|
await publish_event(task_id, "log", "Task re-queued from scratch (run history cleared).", persist=True)
|
|
await record_audit(db, user, "task.retry", "task", task.id)
|
|
return task
|
|
|
|
|
|
@router.post("/{task_id}/continue", response_model=TaskOut)
|
|
async def continue_task(
|
|
task_id: int,
|
|
body: TaskContinue,
|
|
db: AsyncSession = Depends(get_db),
|
|
user: User = Depends(require_support),
|
|
):
|
|
"""Run another investigation pass on the same task with optional follow-up instructions."""
|
|
res = await db.execute(select(Task).where(Task.id == task_id))
|
|
task = res.scalar_one_or_none()
|
|
if not task:
|
|
raise HTTPException(status_code=404, detail="Task not found")
|
|
if task.status in (TaskStatus.running, TaskStatus.queued):
|
|
raise HTTPException(status_code=400, detail="Task is already running")
|
|
if not body.follow_up.strip():
|
|
raise HTTPException(status_code=400, detail="follow_up is required — describe what to check next")
|
|
|
|
await clear_task_cancel(task_id)
|
|
details = dict(task.details or {})
|
|
details["_continue"] = {
|
|
"follow_up": body.follow_up.strip(),
|
|
"prior_report": task.report,
|
|
}
|
|
task.details = details
|
|
task.status = TaskStatus.queued
|
|
task.error = None
|
|
await db.commit()
|
|
await db.refresh(task)
|
|
await enqueue_task(task.id)
|
|
await publish_event(
|
|
task_id,
|
|
"log",
|
|
f"Task queued for follow-up run: {body.follow_up.strip()[:200]}",
|
|
persist=True,
|
|
)
|
|
await record_audit(db, user, "task.continue", "task", task.id, {"follow_up": body.follow_up[:500]})
|
|
return task
|
|
|
|
|
|
@router.post("/{task_id}/escalate-llm", response_model=TaskOut)
|
|
async def escalate_task_llm(
|
|
task_id: int,
|
|
body: TaskEscalateLlm,
|
|
db: AsyncSession = Depends(get_db),
|
|
user: User = Depends(require_support),
|
|
):
|
|
"""Request cloud API for reasoning — skips remaining local LLM tiers when possible."""
|
|
from app.services.task_runtime import set_task_min_tier
|
|
|
|
res = await db.execute(select(Task).where(Task.id == task_id))
|
|
task = res.scalar_one_or_none()
|
|
if not task:
|
|
raise HTTPException(status_code=404, detail="Task not found")
|
|
if task.status != TaskStatus.running:
|
|
raise HTTPException(status_code=400, detail="Task is not running")
|
|
|
|
await set_task_min_tier(task_id, body.tier)
|
|
label = "Economy" if body.tier == "economy" else "Premium"
|
|
await publish_event(
|
|
task_id,
|
|
"log",
|
|
f"{label} cloud API requested by {user.email} — will skip local LLM for remaining reasoning steps.",
|
|
{"min_tier": body.tier, "requested_by": user.email},
|
|
persist=True,
|
|
)
|
|
await record_audit(db, user, "task.escalate_llm", "task", task.id, {"tier": body.tier})
|
|
await db.refresh(task)
|
|
return task
|
|
|
|
|
|
@router.get("/{task_id}", response_model=TaskDetail)
|
|
async def get_task(
|
|
task_id: int, db: AsyncSession = Depends(get_db), _: User = Depends(require_readonly)
|
|
):
|
|
res = await db.execute(
|
|
select(Task).options(selectinload(Task.events)).where(Task.id == task_id)
|
|
)
|
|
task = res.scalar_one_or_none()
|
|
if not task:
|
|
raise HTTPException(status_code=404, detail="Task not found")
|
|
vmap = await vessel_name_map(db, {task.vessel_id} if task.vessel_id else set())
|
|
return task_to_detail(task, vessel_name=vmap.get(task.vessel_id) if task.vessel_id else None)
|
|
|
|
|
|
@router.get("/{task_id}/events", response_model=list[TaskEventOut])
|
|
async def get_task_events(
|
|
task_id: int, db: AsyncSession = Depends(get_db), _: User = Depends(require_readonly)
|
|
):
|
|
res = await db.execute(
|
|
select(Task).options(selectinload(Task.events)).where(Task.id == task_id)
|
|
)
|
|
task = res.scalar_one_or_none()
|
|
if not task:
|
|
raise HTTPException(status_code=404, detail="Task not found")
|
|
return task.events
|
|
|
|
|
|
@router.get("/{task_id}/approvals", response_model=list[ApprovalOut])
|
|
async def get_approvals(
|
|
task_id: int, db: AsyncSession = Depends(get_db), _: User = Depends(require_readonly)
|
|
):
|
|
res = await db.execute(select(ApprovalRequest).where(ApprovalRequest.task_id == task_id))
|
|
return res.scalars().all()
|
|
|
|
|
|
@router.post("/{task_id}/approvals/{approval_id}", response_model=ApprovalOut)
|
|
async def decide_approval(
|
|
task_id: int,
|
|
approval_id: int,
|
|
body: ApprovalDecision,
|
|
db: AsyncSession = Depends(get_db),
|
|
user: User = Depends(require_support),
|
|
):
|
|
res = await db.execute(
|
|
select(ApprovalRequest).where(
|
|
ApprovalRequest.id == approval_id, ApprovalRequest.task_id == task_id
|
|
)
|
|
)
|
|
approval = res.scalar_one_or_none()
|
|
if not approval:
|
|
raise HTTPException(status_code=404, detail="Approval not found")
|
|
from datetime import datetime, timezone
|
|
|
|
approval.status = ApprovalStatus.approved if body.approve else ApprovalStatus.rejected
|
|
approval.decided_by_id = user.id
|
|
approval.decided_at = datetime.now(timezone.utc)
|
|
|
|
if body.approve and approval.tool_name == "mcp_apply_patch" and approval.tool_args:
|
|
from app.mcp_manager.commands import enqueue_mcp_command
|
|
|
|
args = approval.tool_args
|
|
result = await enqueue_mcp_command(
|
|
"apply_patch",
|
|
server=args.get("server"),
|
|
timeout_seconds=180,
|
|
task_id=task_id,
|
|
tool_args=args,
|
|
)
|
|
if not result.get("ok"):
|
|
raise HTTPException(
|
|
status_code=502,
|
|
detail=result.get("error", "MCP patch apply failed"),
|
|
)
|
|
await publish_event(
|
|
task_id,
|
|
"mcp_dev",
|
|
result.get("summary") or f"MCP patch applied to {args.get('server')}",
|
|
{"kind": "mcp_apply_patch", **result},
|
|
)
|
|
elif body.approve and approval.tool_name == "mcp_git_push" and approval.tool_args:
|
|
from app.services import mcp_workspace
|
|
|
|
args = approval.tool_args
|
|
push = await mcp_workspace.commit_and_push(
|
|
args.get("server", ""),
|
|
args.get("commit_message", "agentic-os: MCP update"),
|
|
args.get("files") or [],
|
|
)
|
|
if not push.get("ok"):
|
|
if push.get("runtime_only"):
|
|
raise HTTPException(
|
|
status_code=400,
|
|
detail=(
|
|
push.get("error")
|
|
or "Changes exist only in the running MCP — no git repo to push."
|
|
),
|
|
)
|
|
raise HTTPException(
|
|
status_code=502,
|
|
detail=push.get("error", "Git push failed"),
|
|
)
|
|
note = push.get("note") or ""
|
|
msg = f"MCP pushed to Gitea: {args.get('server')}"
|
|
if push.get("commit"):
|
|
msg += f" ({push.get('commit')})"
|
|
elif note:
|
|
msg += f" — {note}"
|
|
await publish_event(
|
|
task_id,
|
|
"mcp_dev",
|
|
msg,
|
|
push,
|
|
)
|
|
|
|
await db.commit()
|
|
await db.refresh(approval)
|
|
await record_audit(
|
|
db, user, "approval.decide", "approval", approval.id, {"approve": body.approve}
|
|
)
|
|
await publish_event(
|
|
task_id,
|
|
"approval",
|
|
f"Approval {approval.id} {'approved' if body.approve else 'rejected'} by {user.email}",
|
|
{"approval_id": approval.id, "approve": body.approve},
|
|
)
|
|
|
|
# If no more pending approvals, settle task status
|
|
pend = await db.execute(
|
|
select(ApprovalRequest).where(
|
|
ApprovalRequest.task_id == task_id, ApprovalRequest.status == ApprovalStatus.pending
|
|
)
|
|
)
|
|
if pend.scalars().first() is None:
|
|
tres = await db.execute(select(Task).where(Task.id == task_id))
|
|
task = tres.scalar_one_or_none()
|
|
if task and task.status == TaskStatus.waiting_approval:
|
|
task.status = TaskStatus.succeeded
|
|
await db.commit()
|
|
return approval
|
|
|
|
|
|
@router.websocket("/{task_id}/stream")
|
|
async def stream_task(websocket: WebSocket, task_id: int):
|
|
"""Live event stream. Authenticate via ?token=<jwt> query param."""
|
|
token = websocket.query_params.get("token")
|
|
payload = decode_access_token(token) if token else None
|
|
if not payload:
|
|
await websocket.close(code=4401)
|
|
return
|
|
await websocket.accept()
|
|
|
|
# Replay the most recent persisted events first (bounded), oldest-first.
|
|
async with SessionLocal() as db:
|
|
from app.models.task import TaskEvent
|
|
|
|
res = await db.execute(
|
|
select(TaskEvent)
|
|
.where(TaskEvent.task_id == task_id)
|
|
.order_by(TaskEvent.id.desc())
|
|
.limit(EVENT_REPLAY_LIMIT)
|
|
)
|
|
recent = list(reversed(res.scalars().all()))
|
|
for ev in recent:
|
|
await websocket.send_text(
|
|
json.dumps(
|
|
{
|
|
"task_id": task_id,
|
|
"kind": ev.kind,
|
|
"message": ev.message,
|
|
"payload": ev.payload,
|
|
"ts": ev.created_at.isoformat(),
|
|
"replay": True,
|
|
}
|
|
)
|
|
)
|
|
|
|
try:
|
|
async for event in subscribe_events(task_id):
|
|
await websocket.send_text(json.dumps(event))
|
|
except WebSocketDisconnect:
|
|
pass
|
|
except asyncio.CancelledError:
|
|
pass
|