mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-09-06 17:47:14 +00:00
193 lines
7.7 KiB
Python
193 lines
7.7 KiB
Python
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import os
|
|
|
|
from langbot_plugin.skill_store import (
|
|
SkillRevisionMismatchError,
|
|
SkillStore,
|
|
skill_namespace,
|
|
)
|
|
|
|
from ..api.http.context import ExecutionContext
|
|
from ..api.http.service.tenant import TenantContext, require_workspace_uuid
|
|
from ..utils.bounded_executor import blocking_work_scope, run_blocking_atomic
|
|
|
|
|
|
class SkillRepository:
|
|
"""Async, Workspace-scoped adapter around the SDK SkillStore."""
|
|
|
|
def __init__(self, ap) -> None:
|
|
self.ap = ap
|
|
config = getattr(getattr(ap, 'instance_config', None), 'data', {}) or {}
|
|
self._local_config = (config.get('box') or {}).get('local') or {}
|
|
self._store = SkillStore(self._skills_root())
|
|
self._lock = asyncio.Lock()
|
|
|
|
def _host_root(self) -> str:
|
|
configured = str(self._local_config.get('host_root') or './data/box').strip()
|
|
return os.path.realpath(os.path.abspath(os.path.expanduser(configured)))
|
|
|
|
def _skills_root(self) -> str:
|
|
configured = str(self._local_config.get('skills_root') or 'skills').strip()
|
|
if not os.path.isabs(configured):
|
|
configured = os.path.join(self._host_root(), configured)
|
|
return os.path.realpath(os.path.abspath(os.path.expanduser(configured)))
|
|
|
|
def _default_workspace(self) -> str:
|
|
configured = str(self._local_config.get('default_workspace') or '').strip()
|
|
if not configured:
|
|
configured = os.path.join(self._host_root(), 'default')
|
|
elif not os.path.isabs(configured):
|
|
configured = os.path.join(self._host_root(), configured)
|
|
return os.path.realpath(os.path.abspath(os.path.expanduser(configured)))
|
|
|
|
@staticmethod
|
|
def _execution_context(context: TenantContext) -> ExecutionContext:
|
|
workspace_uuid = require_workspace_uuid(context)
|
|
instance_uuid = str(getattr(context, 'instance_uuid', '') or '').strip()
|
|
generation = getattr(context, 'placement_generation', None)
|
|
if not instance_uuid:
|
|
raise ValueError('Skill operations require an explicit instance UUID')
|
|
if isinstance(generation, bool) or not isinstance(generation, int) or generation <= 0:
|
|
raise ValueError('Skill operations require a positive placement generation')
|
|
return ExecutionContext(
|
|
instance_uuid=instance_uuid,
|
|
workspace_uuid=workspace_uuid,
|
|
placement_generation=generation,
|
|
bot_uuid=getattr(context, 'bot_uuid', None),
|
|
pipeline_uuid=getattr(context, 'pipeline_uuid', None),
|
|
query_uuid=getattr(context, 'query_uuid', None),
|
|
entitlement_revision=getattr(context, 'entitlement_revision', 0),
|
|
)
|
|
|
|
@classmethod
|
|
def _namespace(cls, context: TenantContext) -> str:
|
|
execution_context = cls._execution_context(context)
|
|
return skill_namespace(
|
|
execution_context.instance_uuid,
|
|
execution_context.workspace_uuid,
|
|
)
|
|
|
|
async def _validated_execution_context(self, context: TenantContext) -> ExecutionContext:
|
|
execution_context = self._execution_context(context)
|
|
binding = await self.ap.workspace_service.get_execution_binding(
|
|
execution_context.workspace_uuid,
|
|
expected_generation=execution_context.placement_generation,
|
|
)
|
|
if (
|
|
binding.instance_uuid != execution_context.instance_uuid
|
|
or str(getattr(binding, 'workspace_uuid', '') or '') != execution_context.workspace_uuid
|
|
or getattr(binding, 'placement_generation', None) != execution_context.placement_generation
|
|
):
|
|
raise ValueError('Skill execution context belongs to a stale Workspace placement')
|
|
return execution_context
|
|
|
|
def _workspace_root(self, namespace: str) -> str:
|
|
return os.path.join(self._default_workspace(), 'tenants', namespace)
|
|
|
|
async def _call(self, context: TenantContext, method_name: str, *args, **kwargs):
|
|
execution_context = await self._validated_execution_context(context)
|
|
namespace = self._namespace(execution_context)
|
|
|
|
def invoke():
|
|
method = getattr(self._store.scoped(namespace), method_name)
|
|
return method(*args, **kwargs)
|
|
|
|
async with self._lock:
|
|
with blocking_work_scope(f'skill:{namespace}'):
|
|
return await run_blocking_atomic(invoke)
|
|
|
|
async def list_skills(self, context: TenantContext) -> list[dict]:
|
|
return await self._call(context, 'list_skills')
|
|
|
|
async def get_skill(self, context: TenantContext, name: str, *, snapshot: bool = False) -> dict | None:
|
|
return await self._call(context, 'get_skill_snapshot' if snapshot else 'get_skill', name)
|
|
|
|
async def create_skill(self, context: TenantContext, skill: dict) -> dict:
|
|
return await self._call(context, 'create_skill', skill)
|
|
|
|
async def import_skill_directory(self, context: TenantContext, path: str, skill: dict) -> dict:
|
|
namespace = self._namespace(context)
|
|
return await self._call(
|
|
context,
|
|
'import_skill_directory',
|
|
path,
|
|
skill,
|
|
source_root=self._workspace_root(namespace),
|
|
)
|
|
|
|
async def update_skill(self, context: TenantContext, name: str, skill: dict) -> dict:
|
|
return await self._call(context, 'update_skill', name, skill)
|
|
|
|
async def delete_skill(self, context: TenantContext, name: str) -> None:
|
|
await self._call(context, 'delete_skill', name)
|
|
|
|
async def scan_skill_directory(self, context: TenantContext, path: str) -> dict:
|
|
namespace = self._namespace(context)
|
|
return await self._call(
|
|
context,
|
|
'scan_import_directory',
|
|
path,
|
|
source_root=self._workspace_root(namespace),
|
|
)
|
|
|
|
async def list_skill_files(
|
|
self,
|
|
context: TenantContext,
|
|
name: str,
|
|
path: str = '.',
|
|
include_hidden: bool = False,
|
|
max_entries: int = 200,
|
|
) -> dict:
|
|
return await self._call(context, 'list_skill_files', name, path, include_hidden, max_entries)
|
|
|
|
async def read_skill_file(self, context: TenantContext, name: str, path: str) -> dict:
|
|
return await self._call(context, 'read_skill_file', name, path)
|
|
|
|
async def list_skill_resources(
|
|
self,
|
|
context: TenantContext,
|
|
name: str,
|
|
path: str = '.',
|
|
*,
|
|
expected_revision: str | None = None,
|
|
) -> dict:
|
|
return await self._call(
|
|
context,
|
|
'list_skill_resources',
|
|
name,
|
|
path,
|
|
False,
|
|
200,
|
|
expected_revision=expected_revision,
|
|
)
|
|
|
|
async def read_skill_resource(
|
|
self,
|
|
context: TenantContext,
|
|
name: str,
|
|
path: str,
|
|
*,
|
|
expected_revision: str | None = None,
|
|
) -> dict:
|
|
return await self._call(
|
|
context,
|
|
'read_skill_resource',
|
|
name,
|
|
path,
|
|
expected_revision=expected_revision,
|
|
)
|
|
|
|
async def write_skill_file(self, context: TenantContext, name: str, path: str, content: str) -> dict:
|
|
return await self._call(context, 'write_skill_file', name, path, content)
|
|
|
|
async def preview_skill_zip(self, context: TenantContext, file_bytes: bytes, filename: str, **kwargs) -> list[dict]:
|
|
return await self._call(context, 'preview_zip_upload', file_bytes=file_bytes, filename=filename, **kwargs)
|
|
|
|
async def install_skill_zip(self, context: TenantContext, file_bytes: bytes, filename: str, **kwargs) -> list[dict]:
|
|
return await self._call(context, 'install_zip_upload', file_bytes=file_bytes, filename=filename, **kwargs)
|
|
|
|
|
|
__all__ = ['SkillRepository', 'SkillRevisionMismatchError']
|