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

View File

@@ -0,0 +1 @@

37
backend/app/api/auth.py Normal file
View File

@@ -0,0 +1,37 @@
"""Authentication endpoints."""
from __future__ import annotations
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi.security import OAuth2PasswordRequestForm
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.deps import get_current_user
from app.core.security import create_access_token, verify_password
from app.database import get_db
from app.models.user import User
from app.schemas import Token, UserOut
router = APIRouter(prefix="/api/auth", tags=["auth"])
@router.post("/login", response_model=Token)
async def login(
form: OAuth2PasswordRequestForm = Depends(),
db: AsyncSession = Depends(get_db),
):
result = await db.execute(select(User).where(User.email == form.username))
user = result.scalar_one_or_none()
if not user or not verify_password(form.password, user.hashed_password):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED, detail="Incorrect email or password"
)
if not user.is_active:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="User is disabled")
token = create_access_token(subject=user.email, role=user.role.value)
return Token(access_token=token, role=user.role, email=user.email)
@router.get("/me", response_model=UserOut)
async def me(user: User = Depends(get_current_user)):
return user

View File

@@ -0,0 +1,54 @@
"""API balance / consumption endpoints."""
from __future__ import annotations
from fastapi import APIRouter, Depends
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.deps import require_readonly, require_support
from app.database import get_db
from app.models.balance import BalanceSnapshot
from app.models.user import User
from app.schemas import BalanceOut
from app.services import balance as balance_service
router = APIRouter(prefix="/api/balance", tags=["balance"])
async def _latest_snapshot(db: AsyncSession, provider: str) -> BalanceSnapshot | None:
"""Prefer the newest successful snapshot; fall back to latest error row."""
res = await db.execute(
select(BalanceSnapshot)
.where(BalanceSnapshot.provider == provider)
.where(BalanceSnapshot.error.is_(None))
.where(BalanceSnapshot.remaining.is_not(None))
.order_by(BalanceSnapshot.created_at.desc())
.limit(1)
)
snap = res.scalar_one_or_none()
if snap:
return snap
res = await db.execute(
select(BalanceSnapshot)
.where(BalanceSnapshot.provider == provider)
.order_by(BalanceSnapshot.created_at.desc())
.limit(1)
)
return res.scalar_one_or_none()
@router.get("", response_model=list[BalanceOut])
async def latest_balances(db: AsyncSession = Depends(get_db), _: User = Depends(require_readonly)):
"""Latest snapshot per provider."""
out: list[BalanceSnapshot] = []
for provider in ("openrouter", "deepseek"):
snap = await _latest_snapshot(db, provider)
if snap:
out.append(snap)
return out
@router.post("/refresh", response_model=list[BalanceOut])
async def refresh_balances(db: AsyncSession = Depends(get_db), _: User = Depends(require_support)):
await balance_service.poll_once()
return await latest_balances(db) # type: ignore[arg-type]

View File

@@ -0,0 +1,43 @@
"""Onboard device updates (credentials / port tweaks)."""
from __future__ import annotations
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.crypto import encrypt_secret
from app.core.deps import require_readonly, require_support
from app.database import get_db
from app.models.inventory import Device
from app.models.user import User
from app.schemas import DeviceOut, DeviceUpdate
from app.services.vessel_devices import device_to_out
router = APIRouter(prefix="/api/devices", tags=["devices"])
@router.get("", response_model=list[DeviceOut])
async def list_devices(db: AsyncSession = Depends(get_db), _: User = Depends(require_readonly)):
res = await db.execute(select(Device).order_by(Device.vessel_id, Device.name))
return [device_to_out(d) for d in res.scalars().all()]
@router.patch("/{device_id}", response_model=DeviceOut)
async def update_device(
device_id: int,
body: DeviceUpdate,
db: AsyncSession = Depends(get_db),
_: User = Depends(require_support),
):
res = await db.execute(select(Device).where(Device.id == device_id))
device = res.scalar_one_or_none()
if not device:
raise HTTPException(status_code=404, detail="Device not found")
data = body.model_dump(exclude_unset=True)
if "secret" in data:
device.secret_enc = encrypt_secret(data.pop("secret"))
for k, v in data.items():
setattr(device, k, v)
await db.commit()
await db.refresh(device)
return device_to_out(device)

85
backend/app/api/llm.py Normal file
View File

@@ -0,0 +1,85 @@
"""LLM routing configuration API."""
from __future__ import annotations
from fastapi import APIRouter, Depends, HTTPException
from app.core.deps import require_admin, require_readonly
from app.models.user import User
from app.schemas import LlmRoutingConfigIn, LlmRoutingConfigOut, ProviderModelsOut
from app.services.model_catalog import catalog_by_tier, get_entry
from app.services.model_config import (
LlmRoutingConfig,
available_backends,
get_routing_config,
save_routing_config,
)
from app.services.model_discovery import list_all_provider_models
router = APIRouter(prefix="/api/llm", tags=["llm"])
ROUTING_STRATEGY = (
"Local Ollama first for triage and reasoning. On escalation, cloud providers are tried "
"in order: Gemini API → DeepSeek → OpenRouter. Within each cost tier (economy, premium), "
"each provider is attempted before moving to the next tier."
)
_MODEL_ID_FIELDS = (
"local_model_id",
"economy_gemini_id",
"economy_deepseek_id",
"economy_openrouter_id",
"premium_gemini_id",
"premium_deepseek_id",
"premium_openrouter_id",
)
@router.get("/routing", response_model=LlmRoutingConfigOut)
async def get_llm_routing(_: User = Depends(require_readonly)):
cfg = await get_routing_config()
return LlmRoutingConfigOut(
config=cfg.to_dict(),
catalog=catalog_by_tier(),
available_backends=available_backends(),
strategy=ROUTING_STRATEGY,
)
@router.get("/models/available", response_model=ProviderModelsOut)
async def get_available_models(_: User = Depends(require_readonly)):
"""Live model lists from Ollama, Gemini, DeepSeek, and OpenRouter APIs."""
data = await list_all_provider_models()
return ProviderModelsOut(providers=data["providers"], routing_order=data["routing_order"])
@router.put("/routing", response_model=LlmRoutingConfigOut)
async def update_llm_routing(body: LlmRoutingConfigIn, _: User = Depends(require_admin)):
current = await get_routing_config()
data = body.model_dump(exclude_unset=True)
for field in (
*_MODEL_ID_FIELDS,
"auto_escalate",
"max_tier",
):
if field in data and data[field] is not None:
setattr(current, field, data[field])
if body.cloud_provider_order is not None:
current.cloud_provider_order = body.cloud_provider_order
for fid in _MODEL_ID_FIELDS:
val = getattr(current, fid)
if val and not get_entry(val):
raise HTTPException(status_code=400, detail=f"Unknown model id: {val}")
if current.max_tier not in ("local", "economy", "premium"):
raise HTTPException(status_code=400, detail="max_tier must be local, economy, or premium")
await save_routing_config(current)
return LlmRoutingConfigOut(
config=current.to_dict(),
catalog=catalog_by_tier(),
available_backends=available_backends(),
strategy=ROUTING_STRATEGY,
)

117
backend/app/api/mcp.py Normal file
View File

@@ -0,0 +1,117 @@
"""MCP server status panel and maintenance."""
from __future__ import annotations
import json
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.deps import require_admin, require_readonly
from app.database import get_db
from app.mcp_manager.commands import enqueue_mcp_command, enqueue_mcp_command_async
from app.mcp_manager.manager import MCP_STATUS_KEY
from app.models.enums import TaskStatus
from app.models.task import Task
from app.models.user import User
from app.schemas import McpCommandResult, McpDevelopRequest, McpDevelopResult, McpServerOut
from app.services.audit import record_audit
from app.services.events import get_redis, publish_event
router = APIRouter(prefix="/api/mcp", tags=["mcp"])
@router.get("/servers", response_model=list[McpServerOut])
async def list_mcp_servers(_: User = Depends(require_readonly)):
raw = await get_redis().get(MCP_STATUS_KEY)
if not raw:
return []
data = json.loads(raw)
return [McpServerOut(**s) for s in data]
@router.post("/reload-all", response_model=McpCommandResult)
async def reload_all_mcps(_: User = Depends(require_admin)):
result = await enqueue_mcp_command("reload_all")
if not result.get("ok"):
raise HTTPException(status_code=502, detail=result.get("error", "Reload failed"))
return McpCommandResult(**result)
@router.post("/servers/{name}/reload", response_model=McpCommandResult)
async def reload_mcp_server(name: str, _: User = Depends(require_admin)):
result = await enqueue_mcp_command("reload", server=name)
if not result.get("ok"):
raise HTTPException(status_code=502, detail=result.get("error", "Reload failed"))
return McpCommandResult(**result)
@router.post("/servers/{name}/upgrade", response_model=McpCommandResult)
async def upgrade_mcp_server(name: str, _: User = Depends(require_admin)):
"""Git pull (or re-sync local copy) and restart the MCP server."""
result = await enqueue_mcp_command("upgrade", server=name)
if not result.get("ok"):
raise HTTPException(status_code=502, detail=result.get("error", "Upgrade failed"))
return McpCommandResult(**result)
@router.post("/servers/{name}/develop", response_model=McpDevelopResult)
async def develop_mcp_server(
name: str,
body: McpDevelopRequest,
db: AsyncSession = Depends(get_db),
user: User = Depends(require_admin),
):
"""Research and patch an MCP server; progress is streamed on a monitor task."""
task_id = body.task_id
if task_id:
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")
task.status = TaskStatus.running
task.error = None
await db.commit()
else:
task = Task(
title=f"MCP develop: {name}",
issue=body.issue.strip(),
details={"kind": "mcp_develop", "mcp_server": name, "tool": body.tool},
status=TaskStatus.running,
created_by_id=user.id,
)
db.add(task)
await db.commit()
await db.refresh(task)
task_id = task.id
await publish_event(
task_id,
"log",
f"MCP develop queued for {name}",
{"server": name, "kind": "mcp_develop"},
persist=True,
)
await enqueue_mcp_command_async(
"develop",
server=name,
issue=body.issue.strip(),
tool=body.tool,
task_id=task_id,
push=body.push,
title=f"MCP develop: {name}",
)
await record_audit(
db,
user,
"mcp.develop",
"mcp",
task_id,
{"server": name, "issue": body.issue[:500]},
)
return McpDevelopResult(
ok=True,
task_id=task_id,
summary=f"Development started — open task #{task_id} to watch live progress.",
)

69
backend/app/api/rules.py Normal file
View File

@@ -0,0 +1,69 @@
"""Editable troubleshooting rules API."""
from __future__ import annotations
from fastapi import APIRouter, Depends, HTTPException
from app.core.deps import require_admin, require_readonly
from app.models.user import User
from app.schemas import TroubleshootingRulesOut, TroubleshootingRulesUpdate
from app.services.audit import record_audit
from app.services.troubleshooting_rules import (
load_rules,
match_rule,
read_rules_raw,
save_rules_raw,
)
from app.database import get_db
from sqlalchemy.ext.asyncio import AsyncSession
router = APIRouter(prefix="/api/rules", tags=["rules"])
@router.get("/troubleshooting", response_model=TroubleshootingRulesOut)
async def get_troubleshooting_rules(_: User = Depends(require_readonly)):
data = load_rules() # mtime-cached; reloads only when the file changes
return TroubleshootingRulesOut(
yaml=read_rules_raw(),
parsed=data,
path_hint="rules/troubleshooting.yaml",
)
@router.put("/troubleshooting", response_model=TroubleshootingRulesOut)
async def update_troubleshooting_rules(
body: TroubleshootingRulesUpdate,
db: AsyncSession = Depends(get_db),
user: User = Depends(require_admin),
):
try:
parsed = save_rules_raw(body.yaml)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
except Exception as exc: # noqa: BLE001
raise HTTPException(status_code=400, detail=f"Invalid rules: {exc}") from exc
await record_audit(
db,
user,
"rules.troubleshooting.update",
"rules",
0,
{"bytes": len(body.yaml)},
)
return TroubleshootingRulesOut(
yaml=read_rules_raw(),
parsed=parsed,
path_hint="rules/troubleshooting.yaml",
)
@router.post("/troubleshooting/preview")
async def preview_rule_match(
body: dict,
_: User = Depends(require_readonly),
):
"""Preview which rule and devices match a title + issue (for the Rules UI)."""
title = str(body.get("title") or "")
issue = str(body.get("issue") or "")
matched = match_rule(title, issue)
return {"matched": matched.as_dict() if matched else None}

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

85
backend/app/api/users.py Normal file
View File

@@ -0,0 +1,85 @@
"""User management (admin only)."""
from __future__ import annotations
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.deps import require_admin
from app.core.security import hash_password
from app.database import get_db
from app.models.user import User
from app.schemas import UserCreate, UserOut, UserUpdate
from app.services.audit import record_audit
router = APIRouter(prefix="/api/users", tags=["users"])
@router.get("", response_model=list[UserOut])
async def list_users(db: AsyncSession = Depends(get_db), _: User = Depends(require_admin)):
res = await db.execute(select(User).order_by(User.id))
return res.scalars().all()
@router.post("", response_model=UserOut, status_code=status.HTTP_201_CREATED)
async def create_user(
body: UserCreate,
db: AsyncSession = Depends(get_db),
admin: User = Depends(require_admin),
):
exists = await db.execute(select(User).where(User.email == body.email))
if exists.scalar_one_or_none():
raise HTTPException(status_code=409, detail="Email already registered")
user = User(
email=body.email,
full_name=body.full_name,
hashed_password=hash_password(body.password),
role=body.role,
)
db.add(user)
await db.commit()
await db.refresh(user)
await record_audit(db, admin, "user.create", "user", user.id, {"email": user.email, "role": user.role.value})
return user
@router.patch("/{user_id}", response_model=UserOut)
async def update_user(
user_id: int,
body: UserUpdate,
db: AsyncSession = Depends(get_db),
admin: User = Depends(require_admin),
):
res = await db.execute(select(User).where(User.id == user_id))
user = res.scalar_one_or_none()
if not user:
raise HTTPException(status_code=404, detail="User not found")
if body.full_name is not None:
user.full_name = body.full_name
if body.role is not None:
user.role = body.role
if body.is_active is not None:
user.is_active = body.is_active
if body.password:
user.hashed_password = hash_password(body.password)
await db.commit()
await db.refresh(user)
await record_audit(db, admin, "user.update", "user", user.id)
return user
@router.delete("/{user_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_user(
user_id: int,
db: AsyncSession = Depends(get_db),
admin: User = Depends(require_admin),
):
if user_id == admin.id:
raise HTTPException(status_code=400, detail="Cannot delete yourself")
res = await db.execute(select(User).where(User.id == user_id))
user = res.scalar_one_or_none()
if not user:
raise HTTPException(status_code=404, detail="User not found")
await db.delete(user)
await db.commit()
await record_audit(db, admin, "user.delete", "user", user_id)

160
backend/app/api/vessels.py Normal file
View File

@@ -0,0 +1,160 @@
"""Vessel inventory CRUD with onboard device checklist."""
from __future__ import annotations
from fastapi import APIRouter, Depends, HTTPException, 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.crypto import decrypt_secret
from app.database import get_db
from app.inventory_catalog import catalog_as_dicts
from app.models.inventory import Device, Vessel
from app.models.user import User
from app.schemas import CatalogEntryOut, DeviceOut, VesselCreate, VesselDetail, VesselOut, VesselUpdate
from app.services.vessel_devices import build_device, device_to_out, validate_selections
router = APIRouter(prefix="/api/vessels", tags=["vessels"])
@router.get("/catalog", response_model=list[CatalogEntryOut])
async def list_device_catalog(_: User = Depends(require_readonly)):
"""Predefined device/VM slots with default ports for vessel setup."""
return catalog_as_dicts()
@router.get("", response_model=list[VesselDetail])
async def list_vessels(db: AsyncSession = Depends(get_db), _: User = Depends(require_readonly)):
res = await db.execute(
select(Vessel).options(selectinload(Vessel.devices)).order_by(Vessel.name)
)
vessels = res.scalars().all()
out: list[VesselDetail] = []
for vessel in vessels:
detail = VesselDetail.model_validate(vessel)
detail.devices = [device_to_out(d) for d in vessel.devices]
out.append(detail)
return out
@router.get("/{vessel_id}", response_model=VesselDetail)
async def get_vessel(
vessel_id: int, db: AsyncSession = Depends(get_db), _: User = Depends(require_readonly)
):
res = await db.execute(
select(Vessel).options(selectinload(Vessel.devices)).where(Vessel.id == vessel_id)
)
vessel = res.scalar_one_or_none()
if not vessel:
raise HTTPException(status_code=404, detail="Vessel not found")
detail = VesselDetail.model_validate(vessel)
detail.devices = [device_to_out(d) for d in vessel.devices]
return detail
@router.post("", response_model=VesselDetail, status_code=status.HTTP_201_CREATED)
async def create_vessel(
body: VesselCreate,
db: AsyncSession = Depends(get_db),
_: User = Depends(require_support),
):
try:
pairs = validate_selections(body.devices)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
if not body.public_ip:
needs_ip = any(not s.address for s, _ in pairs)
if needs_ip:
raise HTTPException(
status_code=400,
detail="Vessel public IP is required when devices use the shared endpoint",
)
vessel = Vessel(
name=body.name,
site=body.site,
public_ip=body.public_ip,
notes=body.notes,
)
db.add(vessel)
await db.flush()
for sel, entry in pairs:
db.add(build_device(vessel, sel, entry))
await db.commit()
await db.refresh(vessel)
res = await db.execute(
select(Vessel).options(selectinload(Vessel.devices)).where(Vessel.id == vessel.id)
)
saved = res.scalar_one()
detail = VesselDetail.model_validate(saved)
detail.devices = [device_to_out(d) for d in saved.devices]
return detail
@router.patch("/{vessel_id}", response_model=VesselDetail)
async def update_vessel(
vessel_id: int,
body: VesselUpdate,
db: AsyncSession = Depends(get_db),
_: User = Depends(require_support),
):
res = await db.execute(
select(Vessel).options(selectinload(Vessel.devices)).where(Vessel.id == vessel_id)
)
vessel = res.scalar_one_or_none()
if not vessel:
raise HTTPException(status_code=404, detail="Vessel not found")
for field in ("name", "site", "public_ip", "notes"):
val = getattr(body, field)
if val is not None:
setattr(vessel, field, val)
if body.devices is not None:
try:
pairs = validate_selections(body.devices)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
if not vessel.public_ip and any(not s.address for s, _ in pairs):
raise HTTPException(status_code=400, detail="Set vessel public IP or per-device addresses")
# Replace onboard devices from the new checklist
old_by_key = {d.catalog_key: d for d in vessel.devices if d.catalog_key}
for d in list(vessel.devices):
await db.delete(d)
await db.flush()
for sel, entry in pairs:
existing_plain = None
if not sel.secret and sel.catalog_key in old_by_key:
enc = old_by_key[sel.catalog_key].secret_enc
if enc:
existing_plain = decrypt_secret(enc)
db.add(build_device(vessel, sel, entry, existing_secret_plain=existing_plain))
await db.commit()
res = await db.execute(
select(Vessel).options(selectinload(Vessel.devices)).where(Vessel.id == vessel.id)
)
saved = res.scalar_one()
detail = VesselDetail.model_validate(saved)
detail.devices = [device_to_out(d) for d in saved.devices]
return detail
@router.delete("/{vessel_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_vessel(
vessel_id: int,
db: AsyncSession = Depends(get_db),
_: User = Depends(require_support),
):
res = await db.execute(select(Vessel).where(Vessel.id == vessel_id))
vessel = res.scalar_one_or_none()
if not vessel:
raise HTTPException(status_code=404, detail="Vessel not found")
await db.delete(vessel)
await db.commit()