Files
FabledSteward/steward/core/plugin_index.py
T
bvandeusenandClaude Opus 5.5 de3958a362
CI / lint (push) Successful in 2s
CI / integration (push) Successful in 55s
CI / unit (push) Successful in 1m27s
CI / publish (push) Successful in 1m32s
fix(settings): the plugin catalog is fetched by a scheduled task, never by a page load
The operator's ruling (Background Jobs, 2026-10-08): a Steward page reads
stored records; the sources are contacted by scheduled collection. An audit
of every GET handler, HTMX fragment, widget poll and MCP read tool found one
page load that reached out: Settings -> Plugins' catalog fragment fetched
each plugin repository (5 min cache, 10 s timeout each, retried on every
load when unreachable).

plugin_catalog_refresh ticks every minute and fetches a repository whose
copy is missing or older than an hour, waiting 5 min after a failure;
saving repositories clears the cache, so the next tick fetches. The
fragment only reads the cache and says which repositories are not fetched
yet. Refresh is now a POST that fetches at once, as does install.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 10:09:52 -04:00

134 lines
5.0 KiB
Python

# steward/core/plugin_index.py
"""Remote plugin catalog: fetch, parse, and cache the plugin index.yaml."""
from __future__ import annotations
import logging
import time
from typing import Any
logger = logging.getLogger(__name__)
# The catalog is fetched by the scheduled plugin_catalog_refresh task, never by
# a page load (Background Jobs ruling, 2026-10-08): a repository's copy is
# fetched when it has none or it is older than CATALOG_MAX_AGE, and a repository
# that failed is tried again after RETRY_AFTER. Refresh on Settings → Plugins and
# installing a plugin fetch at once, because the operator asked.
CATALOG_MAX_AGE = 3600 # seconds
RETRY_AFTER = 300 # seconds
# url → (raw index, fetched_at monotonic); url → failed_at monotonic
_url_cache: dict[str, tuple[dict, float]] = {}
_failed_at: dict[str, float] = {}
class CatalogPlugin:
"""One entry from the remote plugin index."""
__slots__ = (
"name", "version", "description", "author",
"min_app_version", "download_url", "checksum_sha256",
"repository_url", "homepage", "license", "tags",
)
def __init__(self, data: dict[str, Any]) -> None:
self.name: str = data.get("name", "")
self.version: str = data.get("version", "0.0.0")
self.description: str = data.get("description", "")
self.author: str = data.get("author", "")
self.min_app_version: str | None = data.get("min_app_version")
self.download_url: str = data.get("download_url", "")
self.checksum_sha256: str = data.get("checksum_sha256", "")
self.repository_url: str = data.get("repository_url", "")
self.homepage: str = data.get("homepage", "")
self.license: str = data.get("license", "")
self.tags: list[str] = data.get("tags", [])
def plugin_repos(settings: dict) -> list[dict]:
"""The configured plugin repositories, [{"name", "url"}], from settings.
Migrates the legacy plugins.index_url single-URL field to the list format
on first access if plugins.repositories is not yet set.
"""
repos = settings.get("plugins.repositories")
if repos and isinstance(repos, list):
return repos
legacy = settings.get("plugins.index_url", "").strip()
if legacy:
return [{"name": "Default", "url": legacy}]
return []
def repo_urls(settings: dict) -> list[str]:
return [r["url"].strip() for r in plugin_repos(settings) if (r.get("url") or "").strip()]
def _plugins(raw: dict) -> list[CatalogPlugin]:
return [CatalogPlugin(p) for p in raw.get("plugins", [])]
async def _fetch_one(url: str) -> bool:
"""Fetch one index URL into the cache. True when it was fetched."""
import httpx
import yaml
try:
async with httpx.AsyncClient(timeout=10) as client:
resp = await client.get(url)
resp.raise_for_status()
raw = yaml.safe_load(resp.text) or {}
except Exception:
logger.exception("Failed to fetch plugin catalog from %s", url)
_failed_at[url] = time.monotonic()
return False
_url_cache[url] = (raw, time.monotonic())
_failed_at.pop(url, None)
logger.info("Plugin catalog fetched from %s: %d plugin(s)", url, len(raw.get("plugins", [])))
return True
def _merge(urls: list[str]) -> list[CatalogPlugin]:
"""Plugins from the cached copies, deduplicated by name: the first
repository in the list wins."""
seen: dict[str, CatalogPlugin] = {}
for url in urls:
if url in _url_cache:
for plugin in _plugins(_url_cache[url][0]):
seen.setdefault(plugin.name, plugin)
return list(seen.values())
def cached_catalog(urls: list[str]) -> tuple[list[CatalogPlugin], list[str]]:
"""(the merged catalog from what is cached, the URLs with no copy yet).
Reads the cache only: this is what a page shows."""
return _merge(urls), [u for u in urls if u not in _url_cache]
async def fetch_catalog(urls: list[str]) -> list[CatalogPlugin]:
"""Fetch every URL now, then the merged catalog (a failed URL keeps its
last copy). For an operator's Refresh or install, not for a page load."""
for url in urls:
await _fetch_one(url)
return _merge(urls)
async def refresh_due(urls: list[str], now: float | None = None) -> int:
"""The scheduled task's tick: fetch each URL with no copy, or one older
than CATALOG_MAX_AGE, unless it failed within RETRY_AFTER. Returns how
many were fetched."""
now = time.monotonic() if now is None else now
fetched = 0
for url in urls:
cached = _url_cache.get(url)
if cached and now - cached[1] < CATALOG_MAX_AGE:
continue
if url in _failed_at and now - _failed_at[url] < RETRY_AFTER:
continue
fetched += await _fetch_one(url)
return fetched
def clear_catalog_cache() -> None:
"""Forget every cached copy and failure, so the next tick of the scheduled
task fetches the repositories again (after they are edited)."""
_url_cache.clear()
_failed_at.clear()