feat(plugins): discover packages and persist state
This commit is contained in:
@ -8,6 +8,7 @@ from .schema import (
|
||||
BeaverConfig,
|
||||
EmbeddingConfig,
|
||||
MCPServerConfig,
|
||||
PluginsConfig,
|
||||
ProviderConfig,
|
||||
ToolsConfig,
|
||||
)
|
||||
@ -19,6 +20,7 @@ __all__ = [
|
||||
"BeaverConfig",
|
||||
"EmbeddingConfig",
|
||||
"MCPServerConfig",
|
||||
"PluginsConfig",
|
||||
"ProviderConfig",
|
||||
"ToolsConfig",
|
||||
"default_config_path",
|
||||
|
||||
@ -16,6 +16,7 @@ from .schema import (
|
||||
ChannelConfig,
|
||||
EmbeddingConfig,
|
||||
MCPServerConfig,
|
||||
PluginsConfig,
|
||||
ProviderConfig,
|
||||
ToolsConfig,
|
||||
)
|
||||
@ -73,6 +74,7 @@ def load_config(
|
||||
providers=_parse_providers(data.get("providers")),
|
||||
embedding=_parse_embedding(data),
|
||||
tools=_parse_tools(data.get("tools")),
|
||||
plugins=_parse_plugins(data.get("plugins")),
|
||||
authz=_parse_authz(data.get("authz")),
|
||||
channels=_parse_channels(data.get("channels")),
|
||||
backend_identity=_parse_backend_identity(data.get("backend_identity") or data.get("backendIdentity")),
|
||||
@ -188,6 +190,17 @@ def _parse_tools(raw: Any) -> ToolsConfig:
|
||||
)
|
||||
|
||||
|
||||
def _parse_plugins(raw: Any) -> PluginsConfig:
|
||||
data = _as_dict(raw)
|
||||
return PluginsConfig(
|
||||
search_paths=_string_list(data.get("searchPaths") or data.get("search_paths")),
|
||||
auto_sync=_bool(
|
||||
data.get("autoSync") if "autoSync" in data else data.get("auto_sync"),
|
||||
default=True,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _parse_authz(raw: Any) -> AuthzConfig:
|
||||
data = _as_dict(raw)
|
||||
return AuthzConfig(
|
||||
|
||||
@ -81,6 +81,14 @@ class ToolsConfig:
|
||||
mcp_servers: dict[str, MCPServerConfig] = field(default_factory=dict)
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class PluginsConfig:
|
||||
"""Declarative plugin discovery settings."""
|
||||
|
||||
search_paths: list[str] = field(default_factory=list)
|
||||
auto_sync: bool = True
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class AuthzConfig:
|
||||
"""External AuthZ service configuration."""
|
||||
@ -123,6 +131,7 @@ class BeaverConfig:
|
||||
providers: dict[str, ProviderConfig] = field(default_factory=dict)
|
||||
embedding: EmbeddingConfig = field(default_factory=EmbeddingConfig)
|
||||
tools: ToolsConfig = field(default_factory=ToolsConfig)
|
||||
plugins: PluginsConfig = field(default_factory=PluginsConfig)
|
||||
authz: AuthzConfig = field(default_factory=AuthzConfig)
|
||||
channels: dict[str, ChannelConfig] = field(default_factory=dict)
|
||||
backend_identity: BackendIdentityConfig = field(default_factory=BackendIdentityConfig)
|
||||
|
||||
111
app-instance/backend/beaver/foundation/utils/file_lock.py
Normal file
111
app-instance/backend/beaver/foundation/utils/file_lock.py
Normal file
@ -0,0 +1,111 @@
|
||||
"""Cross-process workspace write lock with in-process reentrancy."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from contextlib import contextmanager
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
import os
|
||||
import threading
|
||||
import time
|
||||
from typing import Iterator
|
||||
|
||||
if os.name == "nt": # pragma: no cover - exercised on Windows only
|
||||
import msvcrt
|
||||
else: # pragma: no cover - import branch is platform-specific
|
||||
import fcntl
|
||||
|
||||
|
||||
class WorkspaceWriteLockBusy(RuntimeError):
|
||||
"""Raised when the shared workspace write lock cannot be acquired."""
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class _HeldLock:
|
||||
rlock: threading.RLock
|
||||
handle: object | None = None
|
||||
owner_thread: int | None = None
|
||||
depth: int = 0
|
||||
|
||||
|
||||
_REGISTRY_GUARD = threading.Lock()
|
||||
_HELD_BY_PATH: dict[Path, _HeldLock] = {}
|
||||
|
||||
|
||||
class WorkspaceWriteLock:
|
||||
def __init__(self, workspace: str | Path) -> None:
|
||||
self.workspace = Path(workspace)
|
||||
self.path = self.workspace / ".beaver" / "locks" / "plugin-skill-write.lock"
|
||||
|
||||
@contextmanager
|
||||
def acquire(
|
||||
self,
|
||||
*,
|
||||
timeout_seconds: float | None = None,
|
||||
blocking: bool = True,
|
||||
) -> Iterator[None]:
|
||||
held = self._held_lock()
|
||||
thread_id = threading.get_ident()
|
||||
with held.rlock:
|
||||
if held.owner_thread == thread_id and held.depth > 0:
|
||||
held.depth += 1
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
held.depth -= 1
|
||||
return
|
||||
|
||||
self.path.parent.mkdir(parents=True, exist_ok=True)
|
||||
handle = self.path.open("a+b")
|
||||
try:
|
||||
self._acquire_os_lock(handle, timeout_seconds=timeout_seconds, blocking=blocking)
|
||||
held.handle = handle
|
||||
held.owner_thread = thread_id
|
||||
held.depth = 1
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
held.depth = 0
|
||||
held.owner_thread = None
|
||||
held.handle = None
|
||||
self._release_os_lock(handle)
|
||||
finally:
|
||||
handle.close()
|
||||
|
||||
def _held_lock(self) -> _HeldLock:
|
||||
resolved = self.path.resolve()
|
||||
with _REGISTRY_GUARD:
|
||||
held = _HELD_BY_PATH.get(resolved)
|
||||
if held is None:
|
||||
held = _HeldLock(rlock=threading.RLock())
|
||||
_HELD_BY_PATH[resolved] = held
|
||||
return held
|
||||
|
||||
@staticmethod
|
||||
def _acquire_os_lock(handle: object, *, timeout_seconds: float | None, blocking: bool) -> None:
|
||||
deadline = None if timeout_seconds is None else time.monotonic() + timeout_seconds
|
||||
while True:
|
||||
try:
|
||||
if os.name == "nt": # pragma: no cover
|
||||
mode = msvcrt.LK_LOCK if blocking else msvcrt.LK_NBLCK
|
||||
msvcrt.locking(handle.fileno(), mode, 1) # type: ignore[attr-defined]
|
||||
else:
|
||||
flags = fcntl.LOCK_EX
|
||||
if not blocking:
|
||||
flags |= fcntl.LOCK_NB
|
||||
fcntl.flock(handle.fileno(), flags) # type: ignore[attr-defined]
|
||||
return
|
||||
except (BlockingIOError, OSError):
|
||||
if not blocking:
|
||||
raise WorkspaceWriteLockBusy("plugin_write_busy")
|
||||
if deadline is not None and time.monotonic() >= deadline:
|
||||
raise WorkspaceWriteLockBusy("plugin_write_busy")
|
||||
time.sleep(0.05)
|
||||
|
||||
@staticmethod
|
||||
def _release_os_lock(handle: object) -> None:
|
||||
if os.name == "nt": # pragma: no cover
|
||||
handle.seek(0) # type: ignore[attr-defined]
|
||||
msvcrt.locking(handle.fileno(), msvcrt.LK_UNLCK, 1) # type: ignore[attr-defined]
|
||||
else:
|
||||
fcntl.flock(handle.fileno(), fcntl.LOCK_UN) # type: ignore[attr-defined]
|
||||
Reference in New Issue
Block a user