dashboard-nanobot/backend/services/bot_management_service.py

340 lines
14 KiB
Python
Raw Normal View History

2026-03-31 04:31:47 +00:00
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 (
2026-04-04 16:29:37 +00:00
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,
2026-04-13 12:07:07 +00:00
write_bot_resource_limits,
2026-03-31 04:31:47 +00:00
)
from services.cache_service import _cache_key_bot_detail, _cache_key_bots_list, _invalidate_bot_detail_cache
from services.platform_service import record_activity_event
2026-04-04 16:29:37 +00:00
from services.provider_service import get_provider_defaults
2026-03-31 04:31:47 +00:00
from services.template_service import get_agent_md_templates
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 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}")
2026-04-04 16:29:37 +00:00
normalized_env_params = normalize_bot_env_params(payload.env_params)
2026-03-31 04:31:47 +00:00
try:
2026-04-04 16:29:37 +00:00
normalized_env_params["TZ"] = normalize_bot_system_timezone(payload.system_timezone)
2026-03-31 04:31:47 +00:00
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
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),
)
template_defaults = get_agent_md_templates()
2026-04-04 16:29:37 +00:00
resource_limits = normalize_bot_resource_limits(payload.cpu_cores, payload.memory_mb, payload.storage_gb)
2026-03-31 04:31:47 +00:00
try:
session.add(bot)
session.flush()
2026-04-04 16:29:37 +00:00
write_bot_env_params(normalized_bot_id, normalized_env_params)
2026-04-13 12:07:07 +00:00
write_bot_resource_limits(
normalized_bot_id,
resource_limits["cpu_cores"],
resource_limits["memory_mb"],
resource_limits["storage_gb"],
)
2026-04-04 16:29:37 +00:00
sync_bot_workspace_channels(
2026-03-31 04:31:47 +00:00
session,
normalized_bot_id,
2026-04-04 16:29:37 +00:00
channels_override=normalize_initial_bot_channels(normalized_bot_id, payload.channels),
2026-03-31 04:31:47 +00:00
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": payload.llm_provider,
"llm_model": payload.llm_model,
"api_key": payload.api_key,
"api_base": payload.api_base or "",
"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"],
"system_prompt": payload.system_prompt or payload.soul_md or template_defaults.get("soul_md", ""),
"soul_md": payload.soul_md or payload.system_prompt or template_defaults.get("soul_md", ""),
"agents_md": payload.agents_md or template_defaults.get("agents_md", ""),
"user_md": payload.user_md or template_defaults.get("user_md", ""),
"tools_md": payload.tools_md or template_defaults.get("tools_md", ""),
"identity_md": payload.identity_md or template_defaults.get("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)
2026-04-04 16:29:37 +00:00
return serialize_bot_detail(bot)
2026-03-31 04:31:47 +00:00
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
2026-03-31 06:04:34 +00:00
bots = session.exec(
select(BotInstance).order_by(BotInstance.created_at.desc(), BotInstance.id.asc())
).all()
2026-03-31 04:31:47 +00:00
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)
2026-04-04 16:29:37 +00:00
rows = [serialize_bot_list_entry(bot) for bot in bots]
2026-03-31 04:31:47 +00:00
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")
2026-04-04 16:29:37 +00:00
row = serialize_bot_detail(bot)
2026-03-31 04:31:47 +00:00
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:
2026-04-04 16:29:37 +00:00
normalized_system_timezone = normalize_bot_system_timezone(system_timezone)
2026-03-31 04:31:47 +00:00
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",
"system_prompt",
}
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"):
if text_field in runtime_overrides:
text = str(runtime_overrides.get(text_field) or "").strip()
if not text:
runtime_overrides.pop(text_field, None)
else:
runtime_overrides[text_field] = text
if "api_base" in runtime_overrides:
runtime_overrides["api_base"] = str(runtime_overrides.get("api_base") or "").strip()
if "system_prompt" in runtime_overrides and "soul_md" not in runtime_overrides:
runtime_overrides["soul_md"] = runtime_overrides["system_prompt"]
if "soul_md" in runtime_overrides and "system_prompt" not in runtime_overrides:
runtime_overrides["system_prompt"] = runtime_overrides["soul_md"]
if {"cpu_cores", "memory_mb", "storage_gb"} & set(runtime_overrides.keys()):
runtime_overrides.update(
2026-04-04 16:29:37 +00:00
normalize_bot_resource_limits(
2026-03-31 04:31:47 +00:00
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:
2026-04-04 16:29:37 +00:00
next_env_params = resolve_bot_runtime_env_params(bot_id)
2026-03-31 04:31:47 +00:00
if env_params is not None:
2026-04-04 16:29:37 +00:00
next_env_params = normalize_bot_env_params(env_params)
2026-03-31 04:31:47 +00:00
if normalized_system_timezone is not None:
next_env_params["TZ"] = normalized_system_timezone
2026-04-04 16:29:37 +00:00
write_bot_env_params(bot_id, next_env_params)
2026-03-31 04:31:47 +00:00
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"))
2026-04-04 16:29:37 +00:00
sync_bot_workspace_channels(
2026-03-31 04:31:47 +00:00
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)
2026-04-04 16:29:37 +00:00
return serialize_bot_detail(bot)