dashboard-nanobot/backend/services/skill_service.py

899 lines
37 KiB
Python

import json
import logging
import os
import re
import shutil
import tempfile
import zipfile
from datetime import datetime
from typing import Any, Callable, Dict, List, Optional
from fastapi import HTTPException, UploadFile
from sqlmodel import Session, select
from clients.edge.errors import log_edge_failure
from core.settings import BOTS_WORKSPACE_ROOT, DATA_ROOT
from models.bot import BotInstance
from models.skill import BotSkillInstall, SkillMarketItem
from services.platform_settings_service import get_platform_settings_snapshot
EdgeStateContextResolver = Callable[[str], Optional[tuple[Any, Optional[str], str]]]
class SkillService:
def _require_bot(self, *, session: Session, bot_id: str) -> BotInstance:
bot = session.get(BotInstance, bot_id)
if not bot:
raise HTTPException(status_code=404, detail="Bot not found")
return bot
def _workspace_root(self, bot_id: str) -> str:
return os.path.abspath(os.path.join(BOTS_WORKSPACE_ROOT, bot_id, ".nanobot", "workspace"))
def _skills_root(self, bot_id: str) -> str:
return os.path.join(self._workspace_root(bot_id), "skills")
def _skill_market_root(self) -> str:
return os.path.abspath(os.path.join(DATA_ROOT, "skills"))
def _is_valid_top_level_skill_name(self, name: str) -> bool:
text = str(name or "").strip()
if not text:
return False
if "/" in text or "\\" in text:
return False
if text in {".", ".."}:
return False
return True
def _read_skill_description(self, entry_path: str) -> str:
candidates: List[str] = []
if os.path.isdir(entry_path):
candidates = [
os.path.join(entry_path, "SKILL.md"),
os.path.join(entry_path, "skill.md"),
os.path.join(entry_path, "README.md"),
os.path.join(entry_path, "readme.md"),
]
elif entry_path.lower().endswith(".md"):
candidates = [entry_path]
for candidate in candidates:
if not os.path.isfile(candidate):
continue
try:
with open(candidate, "r", encoding="utf-8") as file:
for line in file:
text = line.strip()
if text and not text.startswith("#"):
return text[:240]
except Exception:
continue
return ""
def list_workspace_skills(
self,
*,
bot_id: str,
resolve_edge_state_context: EdgeStateContextResolver,
logger: logging.Logger,
) -> List[Dict[str, Any]]:
edge_context = resolve_edge_state_context(bot_id)
if edge_context is not None:
client, workspace_root, node_id = edge_context
try:
payload = client.list_tree(
bot_id=bot_id,
path="skills",
recursive=False,
workspace_root=workspace_root,
)
except Exception as exc:
log_edge_failure(
logger,
key=f"skills-list:{node_id}:{bot_id}",
exc=exc,
message=f"Failed to list skills from edge workspace for bot_id={bot_id}",
)
return []
rows: List[Dict[str, Any]] = []
for entry in list(payload.get("entries") or []):
if not isinstance(entry, dict):
continue
name = str(entry.get("name") or "").strip()
if not name or name.startswith("."):
continue
if not self._is_valid_top_level_skill_name(name):
continue
entry_type = str(entry.get("type") or "").strip().lower()
if entry_type not in {"dir", "file"}:
continue
mtime = str(entry.get("mtime") or "").strip() or (datetime.utcnow().isoformat() + "Z")
size = entry.get("size")
rows.append(
{
"id": name,
"name": name,
"type": entry_type,
"path": f"skills/{name}",
"size": int(size) if isinstance(size, (int, float)) and entry_type == "file" else None,
"mtime": mtime,
"description": "",
}
)
rows.sort(key=lambda row: (row.get("type") != "dir", str(row.get("name") or "").lower()))
return rows
root = self._skills_root(bot_id)
if not os.path.isdir(root):
return []
rows: List[Dict[str, Any]] = []
names = sorted(os.listdir(root), key=lambda name: (not os.path.isdir(os.path.join(root, name)), name.lower()))
for name in names:
if not name or name.startswith("."):
continue
if not self._is_valid_top_level_skill_name(name):
continue
abs_path = os.path.join(root, name)
if not os.path.exists(abs_path):
continue
stat = os.stat(abs_path)
rows.append(
{
"id": name,
"name": name,
"type": "dir" if os.path.isdir(abs_path) else "file",
"path": f"skills/{name}",
"size": stat.st_size if os.path.isfile(abs_path) else None,
"mtime": datetime.utcfromtimestamp(stat.st_mtime).isoformat() + "Z",
"description": self._read_skill_description(abs_path),
}
)
return rows
def list_workspace_skills_for_bot(
self,
*,
session: Session,
bot_id: str,
resolve_edge_state_context: EdgeStateContextResolver,
logger: logging.Logger,
) -> List[Dict[str, Any]]:
self._require_bot(session=session, bot_id=bot_id)
return self.list_workspace_skills(
bot_id=bot_id,
resolve_edge_state_context=resolve_edge_state_context,
logger=logger,
)
def _parse_json_string_list(self, raw: Any) -> List[str]:
if not raw:
return []
try:
data = json.loads(str(raw))
except Exception:
return []
if not isinstance(data, list):
return []
rows: List[str] = []
for item in data:
text = str(item or "").strip()
if text and text not in rows:
rows.append(text)
return rows
def _is_ignored_skill_zip_top_level(self, name: str) -> bool:
text = str(name or "").strip()
if not text:
return True
lowered = text.lower()
if lowered == "__macosx":
return True
if text.startswith("."):
return True
return False
def _read_description_from_text(self, raw: str) -> str:
for line in str(raw or "").splitlines():
text = line.strip()
if text and not text.startswith("#"):
return text[:240]
return ""
def _extract_skill_zip_summary(self, zip_path: str) -> Dict[str, Any]:
entry_names: List[str] = []
description = ""
with zipfile.ZipFile(zip_path) as archive:
members = archive.infolist()
file_members = [member for member in members if not member.is_dir()]
for member in file_members:
raw_name = str(member.filename or "").replace("\\", "/").lstrip("/")
if not raw_name:
continue
first = raw_name.split("/", 1)[0].strip()
if self._is_ignored_skill_zip_top_level(first):
continue
if self._is_valid_top_level_skill_name(first) and first not in entry_names:
entry_names.append(first)
candidates = sorted(
[
str(member.filename or "").replace("\\", "/").lstrip("/")
for member in file_members
if str(member.filename or "").replace("\\", "/").rsplit("/", 1)[-1].lower() in {"skill.md", "readme.md"}
],
key=lambda value: (value.count("/"), value.lower()),
)
for candidate in candidates:
try:
with archive.open(candidate, "r") as file:
preview = file.read(4096).decode("utf-8", errors="ignore")
description = self._read_description_from_text(preview)
if description:
break
except Exception:
continue
return {
"entry_names": entry_names,
"description": description,
}
def _sanitize_skill_market_key(self, raw: Any) -> str:
value = str(raw or "").strip().lower()
value = re.sub(r"[^a-z0-9._-]+", "-", value)
value = re.sub(r"-{2,}", "-", value).strip("._-")
return value[:120]
def _sanitize_zip_filename(self, raw: Any) -> str:
filename = os.path.basename(str(raw or "").strip())
if not filename:
return ""
filename = filename.replace("\\", "/").rsplit("/", 1)[-1]
stem, ext = os.path.splitext(filename)
safe_stem = re.sub(r"[^A-Za-z0-9._-]+", "-", stem).strip("._-")
if not safe_stem:
safe_stem = "skill-package"
safe_ext = ".zip" if ext.lower() == ".zip" else ""
return f"{safe_stem[:180]}{safe_ext}"
def _resolve_unique_skill_market_key(self, session: Session, preferred_key: str, exclude_id: Optional[int] = None) -> str:
base_key = self._sanitize_skill_market_key(preferred_key) or "skill"
candidate = base_key
counter = 2
while True:
stmt = select(SkillMarketItem).where(SkillMarketItem.skill_key == candidate)
rows = session.exec(stmt).all()
conflict = next((row for row in rows if exclude_id is None or row.id != exclude_id), None)
if not conflict:
return candidate
candidate = f"{base_key}-{counter}"
counter += 1
def _resolve_unique_skill_market_zip_filename(
self,
session: Session,
filename: str,
*,
exclude_filename: Optional[str] = None,
exclude_id: Optional[int] = None,
) -> str:
root = self._skill_market_root()
os.makedirs(root, exist_ok=True)
safe_name = self._sanitize_zip_filename(filename)
if not safe_name.lower().endswith(".zip"):
raise HTTPException(status_code=400, detail="Only .zip skill package is supported")
candidate = safe_name
stem, ext = os.path.splitext(safe_name)
counter = 2
while True:
file_conflict = os.path.exists(os.path.join(root, candidate)) and candidate != str(exclude_filename or "").strip()
rows = session.exec(select(SkillMarketItem).where(SkillMarketItem.zip_filename == candidate)).all()
db_conflict = next((row for row in rows if exclude_id is None or row.id != exclude_id), None)
if not file_conflict and not db_conflict:
return candidate
candidate = f"{stem}-{counter}{ext}"
counter += 1
async def _store_skill_market_zip_upload(
self,
session: Session,
upload: UploadFile,
*,
exclude_filename: Optional[str] = None,
exclude_id: Optional[int] = None,
) -> Dict[str, Any]:
root = self._skill_market_root()
os.makedirs(root, exist_ok=True)
incoming_name = self._sanitize_zip_filename(upload.filename or "")
if not incoming_name.lower().endswith(".zip"):
raise HTTPException(status_code=400, detail="Only .zip skill package is supported")
target_filename = self._resolve_unique_skill_market_zip_filename(
session,
incoming_name,
exclude_filename=exclude_filename,
exclude_id=exclude_id,
)
max_bytes = get_platform_settings_snapshot().upload_max_mb * 1024 * 1024
total_size = 0
tmp_path: Optional[str] = None
try:
with tempfile.NamedTemporaryFile(prefix=".skill_market_", suffix=".zip", dir=root, delete=False) as tmp_zip:
tmp_path = tmp_zip.name
while True:
chunk = await upload.read(1024 * 1024)
if not chunk:
break
total_size += len(chunk)
if total_size > max_bytes:
raise HTTPException(
status_code=413,
detail=f"Zip package too large (max {max_bytes // (1024 * 1024)}MB)",
)
tmp_zip.write(chunk)
if total_size == 0:
raise HTTPException(status_code=400, detail="Zip package is empty")
summary = self._extract_skill_zip_summary(tmp_path)
if not summary["entry_names"]:
raise HTTPException(status_code=400, detail="Zip package has no valid skill entries")
final_path = os.path.join(root, target_filename)
os.replace(tmp_path, final_path)
tmp_path = None
return {
"zip_filename": target_filename,
"zip_size_bytes": total_size,
"entry_names": summary["entry_names"],
"description": summary["description"],
}
except zipfile.BadZipFile as exc:
raise HTTPException(status_code=400, detail="Invalid zip file") from exc
finally:
await upload.close()
if tmp_path and os.path.exists(tmp_path):
os.remove(tmp_path)
def serialize_skill_market_item(
self,
item: SkillMarketItem,
*,
install_count: int = 0,
install_row: Optional[BotSkillInstall] = None,
workspace_installed: Optional[bool] = None,
installed_entries: Optional[List[str]] = None,
) -> Dict[str, Any]:
zip_path = os.path.join(self._skill_market_root(), str(item.zip_filename or ""))
entry_names = self._parse_json_string_list(item.entry_names_json)
payload = {
"id": item.id,
"skill_key": item.skill_key,
"display_name": item.display_name or item.skill_key,
"description": item.description or "",
"zip_filename": item.zip_filename,
"zip_size_bytes": int(item.zip_size_bytes or 0),
"entry_names": entry_names,
"entry_count": len(entry_names),
"zip_exists": os.path.isfile(zip_path),
"install_count": int(install_count or 0),
"created_at": item.created_at.isoformat() + "Z" if item.created_at else None,
"updated_at": item.updated_at.isoformat() + "Z" if item.updated_at else None,
}
if install_row is not None:
resolved_entries = installed_entries if installed_entries is not None else self._parse_json_string_list(install_row.installed_entries_json)
resolved_installed = workspace_installed if workspace_installed is not None else install_row.status == "INSTALLED"
payload.update(
{
"installed": resolved_installed,
"install_status": install_row.status,
"installed_at": install_row.installed_at.isoformat() + "Z" if install_row.installed_at else None,
"installed_entries": resolved_entries,
"install_error": install_row.last_error,
}
)
return payload
def list_market_items(self, *, session: Session) -> List[Dict[str, Any]]:
items = session.exec(select(SkillMarketItem).order_by(SkillMarketItem.display_name, SkillMarketItem.id)).all()
installs = session.exec(select(BotSkillInstall)).all()
install_count_by_skill: Dict[int, int] = {}
for row in installs:
skill_id = int(row.skill_market_item_id or 0)
if skill_id <= 0 or row.status != "INSTALLED":
continue
install_count_by_skill[skill_id] = install_count_by_skill.get(skill_id, 0) + 1
return [
self.serialize_skill_market_item(item, install_count=install_count_by_skill.get(int(item.id or 0), 0))
for item in items
]
async def create_market_item(
self,
*,
session: Session,
skill_key: str,
display_name: str,
description: str,
file: UploadFile,
) -> Dict[str, Any]:
upload_meta = await self._store_skill_market_zip_upload(session, file)
try:
preferred_key = skill_key or display_name or os.path.splitext(upload_meta["zip_filename"])[0]
next_key = self._resolve_unique_skill_market_key(session, preferred_key)
item = SkillMarketItem(
skill_key=next_key,
display_name=str(display_name or next_key).strip() or next_key,
description=str(description or upload_meta["description"] or "").strip(),
zip_filename=upload_meta["zip_filename"],
zip_size_bytes=int(upload_meta["zip_size_bytes"] or 0),
entry_names_json=json.dumps(upload_meta["entry_names"], ensure_ascii=False),
)
session.add(item)
session.commit()
session.refresh(item)
return self.serialize_skill_market_item(item, install_count=0)
except Exception:
target_path = os.path.join(self._skill_market_root(), upload_meta["zip_filename"])
if os.path.exists(target_path):
os.remove(target_path)
raise
async def update_market_item(
self,
*,
session: Session,
skill_id: int,
skill_key: str,
display_name: str,
description: str,
file: Optional[UploadFile],
) -> Dict[str, Any]:
item = session.get(SkillMarketItem, skill_id)
if not item:
raise HTTPException(status_code=404, detail="Skill market item not found")
old_filename = str(item.zip_filename or "").strip()
upload_meta: Optional[Dict[str, Any]] = None
if file is not None:
upload_meta = await self._store_skill_market_zip_upload(
session,
file,
exclude_filename=old_filename or None,
exclude_id=item.id,
)
next_key = self._resolve_unique_skill_market_key(
session,
skill_key or item.skill_key or display_name or os.path.splitext(upload_meta["zip_filename"] if upload_meta else old_filename)[0],
exclude_id=item.id,
)
item.skill_key = next_key
item.display_name = str(display_name or item.display_name or next_key).strip() or next_key
item.description = str(description or (upload_meta["description"] if upload_meta else item.description) or "").strip()
item.updated_at = datetime.utcnow()
if upload_meta:
item.zip_filename = upload_meta["zip_filename"]
item.zip_size_bytes = int(upload_meta["zip_size_bytes"] or 0)
item.entry_names_json = json.dumps(upload_meta["entry_names"], ensure_ascii=False)
session.add(item)
session.commit()
session.refresh(item)
if upload_meta and old_filename and old_filename != upload_meta["zip_filename"]:
old_path = os.path.join(self._skill_market_root(), old_filename)
if os.path.exists(old_path):
os.remove(old_path)
installs = session.exec(select(BotSkillInstall).where(BotSkillInstall.skill_market_item_id == skill_id)).all()
install_count = sum(1 for row in installs if row.status == "INSTALLED")
return self.serialize_skill_market_item(item, install_count=install_count)
def delete_market_item(self, *, session: Session, skill_id: int) -> Dict[str, Any]:
item = session.get(SkillMarketItem, skill_id)
if not item:
raise HTTPException(status_code=404, detail="Skill market item not found")
zip_filename = str(item.zip_filename or "").strip()
installs = session.exec(select(BotSkillInstall).where(BotSkillInstall.skill_market_item_id == skill_id)).all()
for row in installs:
session.delete(row)
session.delete(item)
session.commit()
if zip_filename:
zip_path = os.path.join(self._skill_market_root(), zip_filename)
if os.path.exists(zip_path):
os.remove(zip_path)
return {"status": "deleted", "id": skill_id}
def list_bot_market_items(
self,
*,
bot_id: str,
session: Session,
resolve_edge_state_context: EdgeStateContextResolver,
logger: logging.Logger,
) -> List[Dict[str, Any]]:
items = session.exec(select(SkillMarketItem).order_by(SkillMarketItem.display_name, SkillMarketItem.id)).all()
install_rows = session.exec(select(BotSkillInstall).where(BotSkillInstall.bot_id == bot_id)).all()
install_lookup = {int(row.skill_market_item_id): row for row in install_rows}
all_install_rows = session.exec(select(BotSkillInstall)).all()
install_count_by_skill: Dict[int, int] = {}
for row in all_install_rows:
skill_id = int(row.skill_market_item_id or 0)
if skill_id <= 0 or row.status != "INSTALLED":
continue
install_count_by_skill[skill_id] = install_count_by_skill.get(skill_id, 0) + 1
workspace_skill_names = {
str(row.get("name") or "").strip()
for row in self.list_workspace_skills(bot_id=bot_id, resolve_edge_state_context=resolve_edge_state_context, logger=logger)
}
return [
self.serialize_skill_market_item(
item,
install_count=install_count_by_skill.get(int(item.id or 0), 0),
install_row=install_lookup.get(int(item.id or 0)),
workspace_installed=(
None
if install_lookup.get(int(item.id or 0)) is None
else (
install_lookup[int(item.id or 0)].status == "INSTALLED"
and all(
name in workspace_skill_names
for name in self._parse_json_string_list(install_lookup[int(item.id or 0)].installed_entries_json)
)
)
),
installed_entries=(
None
if install_lookup.get(int(item.id or 0)) is None
else self._parse_json_string_list(install_lookup[int(item.id or 0)].installed_entries_json)
),
)
for item in items
]
def list_bot_market_items_for_bot(
self,
*,
session: Session,
bot_id: str,
resolve_edge_state_context: EdgeStateContextResolver,
logger: logging.Logger,
) -> List[Dict[str, Any]]:
self._require_bot(session=session, bot_id=bot_id)
return self.list_bot_market_items(
bot_id=bot_id,
session=session,
resolve_edge_state_context=resolve_edge_state_context,
logger=logger,
)
def _install_skill_zip_into_workspace(
self,
*,
bot_id: str,
zip_path: str,
resolve_edge_state_context: EdgeStateContextResolver,
logger: logging.Logger,
) -> Dict[str, Any]:
try:
archive = zipfile.ZipFile(zip_path)
except Exception as exc:
raise HTTPException(status_code=400, detail="Invalid zip file") from exc
edge_context = resolve_edge_state_context(bot_id)
skills_root = self._skills_root(bot_id)
installed: List[str] = []
with archive:
members = archive.infolist()
file_members = [member for member in members if not member.is_dir()]
if not file_members:
raise HTTPException(status_code=400, detail="Zip package has no files")
top_names: List[str] = []
for member in file_members:
raw_name = str(member.filename or "").replace("\\", "/").lstrip("/")
if not raw_name:
continue
first = raw_name.split("/", 1)[0].strip()
if self._is_ignored_skill_zip_top_level(first):
continue
if not self._is_valid_top_level_skill_name(first):
raise HTTPException(status_code=400, detail=f"Invalid skill entry name in zip: {first}")
if first not in top_names:
top_names.append(first)
if not top_names:
raise HTTPException(status_code=400, detail="Zip package has no valid skill entries")
if edge_context is not None:
existing_names = {
str(item.get("name") or "").strip()
for item in self.list_workspace_skills(
bot_id=bot_id,
resolve_edge_state_context=resolve_edge_state_context,
logger=logger,
)
if isinstance(item, dict)
}
conflicts = [name for name in top_names if name in existing_names]
else:
os.makedirs(skills_root, exist_ok=True)
conflicts = [name for name in top_names if os.path.exists(os.path.join(skills_root, name))]
if conflicts:
raise HTTPException(status_code=400, detail=f"Skill already exists: {', '.join(conflicts)}")
temp_dir_root = skills_root if edge_context is None else None
with tempfile.TemporaryDirectory(prefix=".skill_upload_", dir=temp_dir_root) as tmp_dir:
tmp_root = os.path.abspath(tmp_dir)
for member in members:
raw_name = str(member.filename or "").replace("\\", "/").lstrip("/")
if not raw_name:
continue
target = os.path.abspath(os.path.join(tmp_root, raw_name))
if os.path.commonpath([tmp_root, target]) != tmp_root:
raise HTTPException(status_code=400, detail=f"Unsafe zip entry path: {raw_name}")
if member.is_dir():
os.makedirs(target, exist_ok=True)
continue
os.makedirs(os.path.dirname(target), exist_ok=True)
with archive.open(member, "r") as source, open(target, "wb") as dest:
shutil.copyfileobj(source, dest)
if edge_context is not None:
client, workspace_root, _node_id = edge_context
upload_groups: Dict[str, List[str]] = {}
for name in top_names:
src = os.path.join(tmp_root, name)
if not os.path.exists(src):
continue
if os.path.isfile(src):
upload_groups.setdefault("skills", []).append(src)
installed.append(name)
continue
for walk_root, _dirs, files in os.walk(src):
for filename in files:
local_path = os.path.join(walk_root, filename)
relative_path = os.path.relpath(local_path, tmp_root).replace("\\", "/")
relative_dir = os.path.dirname(relative_path).strip("/")
target_dir = f"skills/{relative_dir}" if relative_dir else "skills"
upload_groups.setdefault(target_dir, []).append(local_path)
installed.append(name)
for target_dir, local_paths in upload_groups.items():
client.upload_local_files(
bot_id=bot_id,
local_paths=local_paths,
path=target_dir,
workspace_root=workspace_root,
)
else:
for name in top_names:
src = os.path.join(tmp_root, name)
dst = os.path.join(skills_root, name)
if not os.path.exists(src):
continue
shutil.move(src, dst)
installed.append(name)
if not installed:
raise HTTPException(status_code=400, detail="No skill entries installed from zip")
return {
"installed": installed,
"skills": self.list_workspace_skills(
bot_id=bot_id,
resolve_edge_state_context=resolve_edge_state_context,
logger=logger,
),
}
def install_market_item_for_bot(
self,
*,
bot_id: str,
skill_id: int,
session: Session,
resolve_edge_state_context: EdgeStateContextResolver,
logger: logging.Logger,
) -> Dict[str, Any]:
item = session.get(SkillMarketItem, skill_id)
if not item:
raise HTTPException(status_code=404, detail="Skill market item not found")
zip_path = os.path.join(self._skill_market_root(), str(item.zip_filename or ""))
if not os.path.isfile(zip_path):
raise HTTPException(status_code=404, detail="Skill zip package not found")
install_row = session.exec(
select(BotSkillInstall).where(
BotSkillInstall.bot_id == bot_id,
BotSkillInstall.skill_market_item_id == skill_id,
)
).first()
try:
install_result = self._install_skill_zip_into_workspace(
bot_id=bot_id,
zip_path=zip_path,
resolve_edge_state_context=resolve_edge_state_context,
logger=logger,
)
now = datetime.utcnow()
if not install_row:
install_row = BotSkillInstall(
bot_id=bot_id,
skill_market_item_id=skill_id,
)
install_row.installed_entries_json = json.dumps(install_result["installed"], ensure_ascii=False)
install_row.source_zip_filename = str(item.zip_filename or "")
install_row.status = "INSTALLED"
install_row.last_error = None
install_row.installed_at = now
install_row.updated_at = now
session.add(install_row)
session.commit()
session.refresh(install_row)
return {
"status": "installed",
"bot_id": bot_id,
"skill_market_item_id": skill_id,
"installed": install_result["installed"],
"skills": install_result["skills"],
"market_item": self.serialize_skill_market_item(item, install_count=0, install_row=install_row),
}
except HTTPException as exc:
now = datetime.utcnow()
if not install_row:
install_row = BotSkillInstall(
bot_id=bot_id,
skill_market_item_id=skill_id,
installed_at=now,
)
install_row.source_zip_filename = str(item.zip_filename or "")
install_row.status = "FAILED"
install_row.last_error = str(exc.detail or "Install failed")
install_row.updated_at = now
session.add(install_row)
session.commit()
raise
except Exception as exc:
now = datetime.utcnow()
if not install_row:
install_row = BotSkillInstall(
bot_id=bot_id,
skill_market_item_id=skill_id,
installed_at=now,
)
install_row.source_zip_filename = str(item.zip_filename or "")
install_row.status = "FAILED"
install_row.last_error = str(exc or "Install failed")[:1000]
install_row.updated_at = now
session.add(install_row)
session.commit()
raise HTTPException(status_code=500, detail="Skill install failed unexpectedly") from exc
def install_market_item_for_bot_checked(
self,
*,
session: Session,
bot_id: str,
skill_id: int,
resolve_edge_state_context: EdgeStateContextResolver,
logger: logging.Logger,
) -> Dict[str, Any]:
self._require_bot(session=session, bot_id=bot_id)
return self.install_market_item_for_bot(
bot_id=bot_id,
skill_id=skill_id,
session=session,
resolve_edge_state_context=resolve_edge_state_context,
logger=logger,
)
async def upload_bot_skill_zip(
self,
*,
bot_id: str,
file: UploadFile,
resolve_edge_state_context: EdgeStateContextResolver,
logger: logging.Logger,
) -> Dict[str, Any]:
tmp_zip_path: Optional[str] = None
try:
with tempfile.NamedTemporaryFile(prefix=".skill_upload_", suffix=".zip", delete=False) as tmp_zip:
tmp_zip_path = tmp_zip.name
filename = str(file.filename or "").strip()
if not filename.lower().endswith(".zip"):
raise HTTPException(status_code=400, detail="Only .zip skill package is supported")
max_bytes = get_platform_settings_snapshot().upload_max_mb * 1024 * 1024
total_size = 0
while True:
chunk = await file.read(1024 * 1024)
if not chunk:
break
total_size += len(chunk)
if total_size > max_bytes:
raise HTTPException(
status_code=413,
detail=f"Zip package too large (max {max_bytes // (1024 * 1024)}MB)",
)
tmp_zip.write(chunk)
if total_size == 0:
raise HTTPException(status_code=400, detail="Zip package is empty")
finally:
await file.close()
try:
install_result = self._install_skill_zip_into_workspace(
bot_id=bot_id,
zip_path=tmp_zip_path,
resolve_edge_state_context=resolve_edge_state_context,
logger=logger,
)
finally:
if tmp_zip_path and os.path.exists(tmp_zip_path):
os.remove(tmp_zip_path)
return {
"status": "installed",
"bot_id": bot_id,
"installed": install_result["installed"],
"skills": install_result["skills"],
}
async def upload_bot_skill_zip_for_bot(
self,
*,
session: Session,
bot_id: str,
file: UploadFile,
resolve_edge_state_context: EdgeStateContextResolver,
logger: logging.Logger,
) -> Dict[str, Any]:
self._require_bot(session=session, bot_id=bot_id)
return await self.upload_bot_skill_zip(
bot_id=bot_id,
file=file,
resolve_edge_state_context=resolve_edge_state_context,
logger=logger,
)
def delete_workspace_skill(
self,
*,
bot_id: str,
skill_name: str,
resolve_edge_state_context: EdgeStateContextResolver,
) -> Dict[str, Any]:
if resolve_edge_state_context(bot_id) is not None:
raise HTTPException(
status_code=400,
detail="Edge bot skill delete is disabled here. Use edge workspace file management.",
)
name = str(skill_name or "").strip()
if not self._is_valid_top_level_skill_name(name):
raise HTTPException(status_code=400, detail="Invalid skill name")
root = self._skills_root(bot_id)
target = os.path.abspath(os.path.join(root, name))
if os.path.commonpath([os.path.abspath(root), target]) != os.path.abspath(root):
raise HTTPException(status_code=400, detail="Invalid skill path")
if not os.path.exists(target):
raise HTTPException(status_code=404, detail="Skill not found in workspace")
if os.path.isdir(target):
shutil.rmtree(target, ignore_errors=False)
else:
os.remove(target)
return {"status": "deleted", "bot_id": bot_id, "skill": name}
def delete_workspace_skill_for_bot(
self,
*,
session: Session,
bot_id: str,
skill_name: str,
resolve_edge_state_context: EdgeStateContextResolver,
) -> Dict[str, Any]:
self._require_bot(session=session, bot_id=bot_id)
return self.delete_workspace_skill(
bot_id=bot_id,
skill_name=skill_name,
resolve_edge_state_context=resolve_edge_state_context,
)