Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
"""Shared loading and caching for the public extensions marketplace."""

import json
from pathlib import Path
from threading import Lock
from time import monotonic

from pydantic import ValidationError

from openhands.sdk.logger import get_logger
from openhands.sdk.marketplace import Marketplace
from openhands.sdk.skills.skill import (
DEFAULT_MARKETPLACE_PATH,
PUBLIC_SKILLS_REF,
PUBLIC_SKILLS_REPO,
)
from openhands.sdk.skills.utils import (
get_skills_cache_dir,
update_skills_repository,
)
from openhands.sdk.utils.path import to_posix_path


logger = get_logger(__name__)

_MARKETPLACE_TTL_SECONDS = 300
_marketplace_cache: dict[str, tuple[float, Marketplace]] = {}
_marketplace_cache_lock = Lock()


def load_marketplace_snapshot(
marketplace_path: str = DEFAULT_MARKETPLACE_PATH,
) -> Marketplace | None:
"""Return a successfully loaded marketplace, reusing it within the TTL."""
now = monotonic()
cached = _marketplace_cache.get(marketplace_path)
if cached is not None and now - cached[0] < _MARKETPLACE_TTL_SECONDS:
return cached[1]

with _marketplace_cache_lock:
now = monotonic()
cached = _marketplace_cache.get(marketplace_path)
if cached is not None and now - cached[0] < _MARKETPLACE_TTL_SECONDS:
return cached[1]

cache_dir = get_skills_cache_dir()
repo_path = update_skills_repository(
PUBLIC_SKILLS_REPO, PUBLIC_SKILLS_REF, cache_dir
)
if repo_path is None:
logger.warning("Failed to access public extensions repository")
return None

marketplace = _load_marketplace(repo_path, marketplace_path)
if marketplace is not None:
_marketplace_cache[marketplace_path] = (monotonic(), marketplace)
return marketplace


def _load_marketplace(repo_path: Path, marketplace_path: str) -> Marketplace | None:
try:
return Marketplace.load(repo_path)
except (FileNotFoundError, ValueError) as discovery_error:
marketplace_file = repo_path / marketplace_path
if not marketplace_file.exists():
logger.warning(
f"Failed to load marketplace via manifest discovery "
f"({discovery_error}); fallback file not found: {marketplace_file}"
)
return None

try:
with open(marketplace_file, encoding="utf-8") as file:
data = json.load(file)
return Marketplace.model_validate(
{**data, "path": to_posix_path(repo_path)}
)
except (json.JSONDecodeError, ValidationError, OSError) as fallback_error:
logger.warning(
f"Failed to load marketplace: {discovery_error}, {fallback_error}"
)
return None
104 changes: 9 additions & 95 deletions openhands-agent-server/openhands/agent_server/plugins_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,13 +13,12 @@
front-end can drive both *attach* and *install* and show install state.
"""

import json
import os
from pathlib import Path
from time import monotonic

from pydantic import BaseModel, ValidationError
from pydantic import BaseModel

from openhands.agent_server.marketplace_snapshot import load_marketplace_snapshot
from openhands.sdk.logger import get_logger
from openhands.sdk.marketplace import Marketplace
from openhands.sdk.plugin import (
Expand All @@ -33,15 +32,7 @@
uninstall_plugin,
update_plugin,
)
from openhands.sdk.skills.skill import (
DEFAULT_MARKETPLACE_PATH,
PUBLIC_SKILLS_REF,
PUBLIC_SKILLS_REPO,
)
from openhands.sdk.skills.utils import (
get_skills_cache_dir,
update_skills_repository,
)
from openhands.sdk.skills.skill import DEFAULT_MARKETPLACE_PATH
from openhands.sdk.utils.path import to_posix_path


Expand Down Expand Up @@ -227,26 +218,6 @@ class MarketplacePluginInfo(BaseModel):
files: list[str] | None = None


# ---------------------------------------------------------------------------
# Marketplace catalog cache
# ---------------------------------------------------------------------------
# Mirrors the skills marketplace cache: each call would otherwise trigger a git
# fetch (network-bound, multiple seconds). A short TTL avoids that on every tab
# open. Only the catalog structure is cached; ``installed`` is always derived
# fresh from the local FS. Unlike the skills cache, an *empty* result (e.g. a
# transient fetch failure) is NOT cached, so one flaky fetch does not blank the
# catalog for the whole TTL.
#
# Cached entries are full MarketplacePluginInfo objects (including contents)
# stored with ``installed=False``; serving stamps the fresh installed flag via
# a shallow ``model_copy``, so the ``skills``/``files`` lists are shared across
# requests — they are never mutated after construction.
#
# Type: (timestamp, catalog entries with installed=False) or None
_plugin_catalog_cache: tuple[float, list[MarketplacePluginInfo]] | None = None
_PLUGIN_CATALOG_TTL_SECONDS = 300 # 5 minutes


def service_get_plugins_marketplace_catalog(
marketplace_path: str = DEFAULT_MARKETPLACE_PATH,
installed_dir: Path | None = None,
Expand All @@ -257,9 +228,8 @@ def service_get_plugins_marketplace_catalog(
true plugins (source under ``./plugins/``), and enriches each with its
attachable coordinates and installation status.

The catalog structure is cached for ``_PLUGIN_CATALOG_TTL_SECONDS`` to avoid
a git fetch on every call. The ``installed`` field is always resolved fresh
from the local FS.
The shared marketplace snapshot is cached, while the ``installed`` field is
always resolved fresh from the local FS.

Args:
marketplace_path: Relative path to the marketplace JSON file.
Expand All @@ -269,20 +239,8 @@ def service_get_plugins_marketplace_catalog(
Returns:
List of MarketplacePluginInfo with plugin details and install status.
"""
global _plugin_catalog_cache

now = monotonic()
if (
_plugin_catalog_cache is not None
and now - _plugin_catalog_cache[0] < _PLUGIN_CATALOG_TTL_SECONDS
):
entries = _plugin_catalog_cache[1]
else:
entries = _fetch_plugin_catalog_entries(marketplace_path)
# Only cache non-empty results so a transient fetch failure does not
# blank the catalog for the whole TTL.
if entries:
_plugin_catalog_cache = (now, entries)
marketplace = load_marketplace_snapshot(marketplace_path)
entries = _plugin_catalog_entries(marketplace) if marketplace is not None else []

# Always-fresh installed check — local FS scan, not a network call.
installed_names = {
Expand All @@ -309,53 +267,9 @@ def _is_true_plugin(raw_source: object) -> bool:
return subpath.startswith(_PLUGINS_SUBPATH_PREFIX)


def _fetch_plugin_catalog_entries(
marketplace_path: str,
def _plugin_catalog_entries(
marketplace: Marketplace,
) -> list[MarketplacePluginInfo]:
"""Fetch the marketplace and keep only true plugins.

Slow path: git fetch + read the marketplace JSON. Returns catalog entries
with ``installed=False`` (the caller stamps the fresh value), enriched with
local contents (``path``/``skills``/``files``) when an entry resolves to a
directory inside the local clone. Returns an empty list on error.
"""
cache_dir = get_skills_cache_dir()
repo_path = update_skills_repository(
PUBLIC_SKILLS_REPO, PUBLIC_SKILLS_REF, cache_dir
)

if repo_path is None:
logger.warning("Failed to access public extensions repository")
return []

# Primary loader: ``Marketplace.load`` discovers the manifest in
# ``.plugin/`` or ``.claude-plugin/`` — the real OpenHands/extensions layout,
# where ``.plugin/marketplace.json`` points at the published catalog. We must
# NOT gate on ``marketplace_path`` (``marketplaces/default.json``) existing
# first: that file is absent in the current extensions repo, so an early
# return there would blank the catalog even though the manifest is present.
# The explicit ``marketplace_path`` file is only a fallback for layouts that
# ship it instead of a ``.plugin/`` manifest.
try:
marketplace = Marketplace.load(repo_path)
except (FileNotFoundError, ValueError) as e:
marketplace_file = repo_path / marketplace_path
if not marketplace_file.exists():
logger.warning(
f"Failed to load marketplace via manifest discovery ({e}); "
f"fallback file not found: {marketplace_file}"
)
return []
try:
with open(marketplace_file, encoding="utf-8") as f:
data = json.load(f)
marketplace = Marketplace.model_validate(
{**data, "path": to_posix_path(repo_path)}
)
except (json.JSONDecodeError, ValidationError, OSError) as e2:
logger.warning(f"Failed to load marketplace: {e}, {e2}")
return []

entries: list[MarketplacePluginInfo] = []
for plugin in marketplace.plugins:
if not _is_true_plugin(plugin.source):
Expand Down
78 changes: 7 additions & 71 deletions openhands-agent-server/openhands/agent_server/skills_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,16 +14,15 @@
sandbox < registered marketplace/public < user < org < project
"""

import json
import shutil
import subprocess
import tempfile
from dataclasses import dataclass
from pathlib import Path
from time import monotonic

from pydantic import BaseModel, ValidationError
from pydantic import BaseModel

from openhands.agent_server.marketplace_snapshot import load_marketplace_snapshot
from openhands.sdk.logger import get_logger
from openhands.sdk.marketplace import Marketplace
from openhands.sdk.marketplace.registration import MarketplaceRegistration
Expand Down Expand Up @@ -54,7 +53,6 @@
update_skills_repository,
)
from openhands.sdk.utils import sanitized_env
from openhands.sdk.utils.path import to_posix_path


logger = get_logger(__name__)
Expand Down Expand Up @@ -638,26 +636,7 @@ class MarketplaceSkillInfo(BaseModel):
installed: bool


# ---------------------------------------------------------------------------
# Marketplace catalog cache
# ---------------------------------------------------------------------------
# Each call to service_get_marketplace_catalog triggers a git fetch via
# update_skills_repository, which is a network-bound operation that takes
# multiple seconds. A short TTL cache avoids that hit on every tab open.
#
# Only the catalog structure (name, description, source) is cached; the
# `installed` field is always derived fresh from the local FS so that
# install/uninstall actions are reflected immediately.
#
# Thread safety: concurrent cache misses (cold start or TTL expiry) may
# trigger parallel git fetches, but each fetch is idempotent and produces
# the same result (last writer wins). For this low-traffic endpoint the
# thundering-herd risk is acceptable without an explicit lock.
#
# Type: (timestamp, list-of-(name, description, source)) or None
_CatalogEntry = tuple[str, str | None, str]
_catalog_cache: tuple[float, list[_CatalogEntry]] | None = None
_CATALOG_TTL_SECONDS = 300 # 5 minutes


def service_get_marketplace_catalog(
Expand All @@ -669,9 +648,8 @@ def service_get_marketplace_catalog(
Loads the marketplace JSON from the public extensions repository and
enriches each entry with installation status.

The catalog structure (name, description, source) is cached for
_CATALOG_TTL_SECONDS to avoid a git fetch on every call. The
``installed`` field is always resolved fresh from the local FS.
The shared marketplace snapshot is cached to avoid a git fetch on every
call. The ``installed`` field is always resolved fresh from the local FS.

Args:
marketplace_path: Relative path to marketplace JSON file.
Expand All @@ -682,14 +660,8 @@ def service_get_marketplace_catalog(
Returns:
List of MarketplaceSkillInfo with skill details and installation status.
"""
global _catalog_cache

now = monotonic()
if _catalog_cache is not None and now - _catalog_cache[0] < _CATALOG_TTL_SECONDS:
entries = _catalog_cache[1]
else:
entries = _fetch_catalog_entries(marketplace_path)
_catalog_cache = (now, entries)
marketplace = load_marketplace_snapshot(marketplace_path)
entries = _catalog_entries(marketplace) if marketplace is not None else []

# Always-fresh installed check — local FS scan, not a network call.
installed_names = {
Expand All @@ -703,43 +675,7 @@ def service_get_marketplace_catalog(
]


def _fetch_catalog_entries(marketplace_path: str) -> list[_CatalogEntry]:
"""Fetch marketplace catalog entries from the public extensions repository.

This is the slow path: it does a git fetch + reads the marketplace JSON.
Results are cached by the caller.

Returns:
List of (name, description, source) tuples, or an empty list on error.
"""
cache_dir = get_skills_cache_dir()
repo_path = update_skills_repository(
PUBLIC_SKILLS_REPO, PUBLIC_SKILLS_REF, cache_dir
)

if repo_path is None:
logger.warning("Failed to access public skills repository")
return []

marketplace_file = repo_path / marketplace_path
if not marketplace_file.exists():
logger.warning(f"Marketplace file not found: {marketplace_file}")
return []

try:
marketplace = Marketplace.load(repo_path)
except (FileNotFoundError, ValueError) as e:
# Fallback to loading from specific path
try:
with open(marketplace_file, encoding="utf-8") as f:
data = json.load(f)
marketplace = Marketplace.model_validate(
{**data, "path": to_posix_path(repo_path)}
)
except (json.JSONDecodeError, ValidationError, OSError) as e2:
logger.warning(f"Failed to load marketplace: {e}, {e2}")
return []

def _catalog_entries(marketplace: Marketplace) -> list[_CatalogEntry]:
# Build catalog from plugins and skills.
# Plugins take priority: if a name appears in both plugins and skills,
# the plugin version is used (since plugins are added first).
Expand Down
Loading