dashboard-nanobot/backend/services/bot_management_service.py

340 lines
14 KiB
Python

import os
import re
import shutil
from typing import Any, Dict, List, Optional
from fastapi import HTTPException
from sqlmodel import Session, select
from core.cache import cache
from core.docker_instance import docker_manager
from core.settings import BOTS_WORKSPACE_ROOT
from models.bot import BotInstance, NanobotImage
from schemas.bot import BotCreateRequest, BotUpdateRequest
from services.bot_service import (
normalize_initial_bot_channels,
normalize_bot_system_timezone,
resolve_bot_runtime_env_params,
serialize_bot_detail,
serialize_bot_list_entry,
sync_bot_workspace_channels,
)
from services.bot_storage_service import (
normalize_bot_env_params,
normalize_bot_resource_limits,
write_bot_env_params,
write_bot_resource_limits,
)
from services.cache_service import _cache_key_bot_detail, _cache_key_bots_list, _invalidate_bot_detail_cache
from services.platform_activity_service import record_activity_event
BOT_ID_PATTERN = re.compile(r"^[A-Za-z0-9_]+$")
MANAGED_WORKSPACE_FILENAMES = ("AGENTS.md", "SOUL.md", "USER.md", "TOOLS.md", "IDENTITY.md")
def _managed_bot_file_paths(bot_id: str) -> Dict[str, str]:
bot_root = os.path.join(BOTS_WORKSPACE_ROOT, bot_id, ".nanobot")
workspace_root = os.path.join(bot_root, "workspace")
paths = {
"config": os.path.join(bot_root, "config.json"),
"env": os.path.join(bot_root, "env.json"),
"resources": os.path.join(bot_root, "resources.json"),
}
for filename in MANAGED_WORKSPACE_FILENAMES:
paths[f"workspace:{filename}"] = os.path.join(workspace_root, filename)
return paths
def _snapshot_managed_bot_files(bot_id: str) -> Dict[str, Optional[bytes]]:
snapshot: Dict[str, Optional[bytes]] = {}
for key, path in _managed_bot_file_paths(bot_id).items():
if os.path.isfile(path):
with open(path, "rb") as file:
snapshot[key] = file.read()
else:
snapshot[key] = None
return snapshot
def _restore_managed_bot_files(bot_id: str, snapshot: Dict[str, Optional[bytes]]) -> None:
for key, path in _managed_bot_file_paths(bot_id).items():
payload = snapshot.get(key)
if payload is None:
if os.path.exists(path):
os.remove(path)
continue
os.makedirs(os.path.dirname(path), exist_ok=True)
tmp_path = f"{path}.tmp"
with open(tmp_path, "wb") as file:
file.write(payload)
os.replace(tmp_path, path)
def _cleanup_bot_workspace_root(bot_id: str) -> None:
bot_root = os.path.join(BOTS_WORKSPACE_ROOT, bot_id)
if os.path.isdir(bot_root):
shutil.rmtree(bot_root, ignore_errors=True)
def _require_runtime_text(raw: Any, *, field: str) -> str:
value = str(raw if raw is not None else "").strip()
if not value:
raise HTTPException(status_code=400, detail=f"{field} is required")
return value
def create_bot_record(session: Session, *, payload: BotCreateRequest) -> Dict[str, Any]:
normalized_bot_id = str(payload.id or "").strip()
if not normalized_bot_id:
raise HTTPException(status_code=400, detail="Bot ID is required")
if not BOT_ID_PATTERN.fullmatch(normalized_bot_id):
raise HTTPException(status_code=400, detail="Bot ID can only contain letters, numbers, and underscores")
if session.get(BotInstance, normalized_bot_id):
raise HTTPException(status_code=409, detail=f"Bot ID already exists: {normalized_bot_id}")
image_row = session.get(NanobotImage, payload.image_tag)
if not image_row:
raise HTTPException(status_code=400, detail=f"Image not registered in DB: {payload.image_tag}")
if image_row.status != "READY":
raise HTTPException(status_code=400, detail=f"Image status is not READY: {payload.image_tag} ({image_row.status})")
if not docker_manager.has_image(payload.image_tag):
raise HTTPException(status_code=400, detail=f"Docker image not found locally: {payload.image_tag}")
normalized_env_params = normalize_bot_env_params(payload.env_params)
try:
normalized_env_params["TZ"] = normalize_bot_system_timezone(payload.system_timezone)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
llm_provider = _require_runtime_text(payload.llm_provider, field="llm_provider")
llm_model = _require_runtime_text(payload.llm_model, field="llm_model")
api_key = _require_runtime_text(payload.api_key, field="api_key")
api_base = _require_runtime_text(payload.api_base, field="api_base")
bot = BotInstance(
id=normalized_bot_id,
name=payload.name,
enabled=bool(payload.enabled) if payload.enabled is not None else True,
access_password=str(payload.access_password or ""),
image_tag=payload.image_tag,
workspace_dir=os.path.join(BOTS_WORKSPACE_ROOT, normalized_bot_id),
)
resource_limits = normalize_bot_resource_limits(payload.cpu_cores, payload.memory_mb, payload.storage_gb)
try:
session.add(bot)
session.flush()
write_bot_env_params(normalized_bot_id, normalized_env_params)
write_bot_resource_limits(
normalized_bot_id,
resource_limits["cpu_cores"],
resource_limits["memory_mb"],
resource_limits["storage_gb"],
)
sync_bot_workspace_channels(
session,
normalized_bot_id,
channels_override=normalize_initial_bot_channels(normalized_bot_id, payload.channels),
global_delivery_override={
"sendProgress": bool(payload.send_progress) if payload.send_progress is not None else False,
"sendToolHints": bool(payload.send_tool_hints) if payload.send_tool_hints is not None else False,
},
runtime_overrides={
"llm_provider": llm_provider,
"llm_model": llm_model,
"api_key": api_key,
"api_base": api_base,
"temperature": payload.temperature,
"top_p": payload.top_p,
"max_tokens": payload.max_tokens,
"cpu_cores": resource_limits["cpu_cores"],
"memory_mb": resource_limits["memory_mb"],
"storage_gb": resource_limits["storage_gb"],
"soul_md": payload.soul_md,
"agents_md": payload.agents_md,
"user_md": payload.user_md,
"tools_md": payload.tools_md,
"identity_md": payload.identity_md,
"send_progress": bool(payload.send_progress) if payload.send_progress is not None else False,
"send_tool_hints": bool(payload.send_tool_hints) if payload.send_tool_hints is not None else False,
},
)
record_activity_event(
session,
normalized_bot_id,
"bot_created",
channel="system",
detail=f"Bot {normalized_bot_id} created",
metadata={"image_tag": payload.image_tag},
)
session.commit()
session.refresh(bot)
except Exception:
session.rollback()
_cleanup_bot_workspace_root(normalized_bot_id)
raise
_invalidate_bot_detail_cache(normalized_bot_id)
return serialize_bot_detail(bot)
def list_bots_with_cache(session: Session) -> List[Dict[str, Any]]:
cached = cache.get_json(_cache_key_bots_list())
if isinstance(cached, list):
return cached
bots = session.exec(
select(BotInstance).order_by(BotInstance.created_at.desc(), BotInstance.id.asc())
).all()
dirty = False
for bot in bots:
actual_status = docker_manager.get_bot_status(bot.id)
if bot.docker_status != actual_status:
bot.docker_status = actual_status
if actual_status != "RUNNING" and str(bot.current_state or "").upper() not in {"ERROR"}:
bot.current_state = "IDLE"
session.add(bot)
dirty = True
if dirty:
session.commit()
for bot in bots:
session.refresh(bot)
rows = [serialize_bot_list_entry(bot) for bot in bots]
cache.set_json(_cache_key_bots_list(), rows, ttl=30)
return rows
def get_bot_detail_cached(session: Session, *, bot_id: str) -> Dict[str, Any]:
cached = cache.get_json(_cache_key_bot_detail(bot_id))
if isinstance(cached, dict):
return cached
bot = session.get(BotInstance, bot_id)
if not bot:
raise HTTPException(status_code=404, detail="Bot not found")
row = serialize_bot_detail(bot)
cache.set_json(_cache_key_bot_detail(bot_id), row, ttl=30)
return row
def authenticate_bot_page_access(session: Session, *, bot_id: str, password: str) -> Dict[str, Any]:
bot = session.get(BotInstance, bot_id)
if not bot:
raise HTTPException(status_code=404, detail="Bot not found")
configured = str(bot.access_password or "").strip()
if not configured:
return {"ok": True, "enabled": False, "bot_id": bot_id}
candidate = str(password or "").strip()
if not candidate:
raise HTTPException(status_code=401, detail="Bot access password required")
if candidate != configured:
raise HTTPException(status_code=401, detail="Invalid bot access password")
return {"ok": True, "enabled": True, "bot_id": bot_id}
def update_bot_record(session: Session, *, bot_id: str, payload: BotUpdateRequest) -> Dict[str, Any]:
bot = session.get(BotInstance, bot_id)
if not bot:
raise HTTPException(status_code=404, detail="Bot not found")
managed_file_snapshot = _snapshot_managed_bot_files(bot_id)
update_data = payload.model_dump(exclude_unset=True)
if "image_tag" in update_data and update_data["image_tag"]:
image_tag = str(update_data["image_tag"]).strip()
image_row = session.get(NanobotImage, image_tag)
if not image_row:
raise HTTPException(status_code=400, detail=f"Image not registered in DB: {image_tag}")
if image_row.status != "READY":
raise HTTPException(status_code=400, detail=f"Image status is not READY: {image_tag} ({image_row.status})")
if not docker_manager.has_image(image_tag):
raise HTTPException(status_code=400, detail=f"Docker image not found locally: {image_tag}")
env_params = update_data.pop("env_params", None) if isinstance(update_data, dict) else None
system_timezone = update_data.pop("system_timezone", None) if isinstance(update_data, dict) else None
normalized_system_timezone: Optional[str] = None
if system_timezone is not None:
try:
normalized_system_timezone = normalize_bot_system_timezone(system_timezone)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
runtime_fields = {
"llm_provider",
"llm_model",
"api_key",
"api_base",
"temperature",
"top_p",
"max_tokens",
"cpu_cores",
"memory_mb",
"storage_gb",
"soul_md",
"agents_md",
"user_md",
"tools_md",
"identity_md",
"send_progress",
"send_tool_hints",
}
runtime_overrides: Dict[str, Any] = {}
update_data.pop("tools_config", None) if isinstance(update_data, dict) else None
for field in runtime_fields:
if field in update_data:
runtime_overrides[field] = update_data.pop(field)
for text_field in ("llm_provider", "llm_model", "api_key", "api_base"):
if text_field in runtime_overrides:
runtime_overrides[text_field] = _require_runtime_text(
runtime_overrides.get(text_field),
field=text_field,
)
if {"cpu_cores", "memory_mb", "storage_gb"} & set(runtime_overrides.keys()):
runtime_overrides.update(
normalize_bot_resource_limits(
runtime_overrides.get("cpu_cores"),
runtime_overrides.get("memory_mb"),
runtime_overrides.get("storage_gb"),
)
)
for key, value in update_data.items():
if key in {"name", "image_tag", "access_password", "enabled"}:
setattr(bot, key, value)
try:
session.add(bot)
session.flush()
if env_params is not None or normalized_system_timezone is not None:
next_env_params = resolve_bot_runtime_env_params(bot_id)
if env_params is not None:
next_env_params = normalize_bot_env_params(env_params)
if normalized_system_timezone is not None:
next_env_params["TZ"] = normalized_system_timezone
write_bot_env_params(bot_id, next_env_params)
global_delivery_override: Optional[Dict[str, Any]] = None
if "send_progress" in runtime_overrides or "send_tool_hints" in runtime_overrides:
global_delivery_override = {}
if "send_progress" in runtime_overrides:
global_delivery_override["sendProgress"] = bool(runtime_overrides.get("send_progress"))
if "send_tool_hints" in runtime_overrides:
global_delivery_override["sendToolHints"] = bool(runtime_overrides.get("send_tool_hints"))
sync_bot_workspace_channels(
session,
bot_id,
runtime_overrides=runtime_overrides if runtime_overrides else None,
global_delivery_override=global_delivery_override,
)
session.commit()
session.refresh(bot)
except Exception:
session.rollback()
_restore_managed_bot_files(bot_id, managed_file_snapshot)
refreshed_bot = session.get(BotInstance, bot_id)
if refreshed_bot:
session.refresh(refreshed_bot)
bot = refreshed_bot
raise
_invalidate_bot_detail_cache(bot_id)
return serialize_bot_detail(bot)