Initial commit: Agentic OS troubleshooting platform

Self-hosted, Docker-based agentic troubleshooting platform: FastAPI backend +
LangGraph agent, Next.js UI, tiered LLM routing (local Ollama -> Gemini ->
DeepSeek -> OpenRouter), MCP server manager, encrypted device credentials,
RBAC, audit log, project-memory + Obsidian integrations, and editable
troubleshooting decision rules tuned for the GeneseasX vessel stack.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-14 22:11:07 +03:00
commit 6185b9b85a
126 changed files with 14565 additions and 0 deletions

457
backend/app/api/tasks.py Normal file
View File

@@ -0,0 +1,457 @@
"""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_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