fix(settings): the plugin catalog is fetched by a scheduled task, never by a page load
CI / lint (push) Successful in 2s
CI / integration (push) Successful in 55s
CI / unit (push) Successful in 1m27s
CI / publish (push) Successful in 1m32s

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>
This commit is contained in:
2026-10-08 10:09:52 -04:00
co-authored by Claude Opus 5.5
parent 5f04f6af66
commit de3958a362
5 changed files with 201 additions and 88 deletions
+38 -46
View File
@@ -7,6 +7,7 @@ from quart import Blueprint, current_app, render_template, request, redirect, ur
from steward.auth.middleware import require_role
from steward.core.audit import audit_request, log_audit
from steward.core.plugin_index import plugin_repos, repo_urls
from steward.core.logging_setup import LEVELS as LOG_LEVELS, set_log_level
from steward.models.users import UserRole
from steward.core.settings import (
@@ -606,28 +607,12 @@ async def send_report_now():
# ── Plugin repository helpers ─────────────────────────────────────────────────
def _get_plugin_repos(settings: dict) -> list[dict]:
"""Return the list of plugin repositories 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
# Migrate legacy single URL
legacy = settings.get("plugins.index_url", "").strip()
if legacy:
return [{"name": "Default", "url": legacy}]
return []
async def _save_plugin_repos(db, repos: list[dict]) -> None:
await set_setting(db, "plugins.repositories", repos)
async def _plugin_repos_partial(settings: dict):
repos = _get_plugin_repos(settings)
repos = plugin_repos(settings)
return await render_template("settings/_plugin_repos.html", repos=repos)
@@ -693,7 +678,7 @@ async def plugins():
for p in discovered:
p["_kind"] = p.get("kind") or (
"capability" if p["_dir"] in CAPABILITY_PLUGINS else "integration")
repos = _get_plugin_repos(settings)
repos = plugin_repos(settings)
# Config left behind by plugins that are no longer installed. Surfaced for an
# explicit operator decision rather than cleaned up automatically: an external
# plugin can be missing because /data/plugins isn't mounted or an install
@@ -810,7 +795,7 @@ async def plugin_repos_add():
form = await request.form
async with current_app.db_sessionmaker() as db:
settings = await get_all_settings(db)
repos = _get_plugin_repos(settings)
repos = plugin_repos(settings)
url = form.get("url", "").strip()
repo_name = form.get("name", "").strip() or url
if url:
@@ -828,7 +813,7 @@ async def plugin_repos_add():
async def plugin_repos_remove(idx: int):
async with current_app.db_sessionmaker() as db:
settings = await get_all_settings(db)
repos = _get_plugin_repos(settings)
repos = plugin_repos(settings)
if 0 <= idx < len(repos):
repos.pop(idx)
async with current_app.db_sessionmaker() as db:
@@ -845,7 +830,7 @@ async def plugin_repos_save(idx: int):
form = await request.form
async with current_app.db_sessionmaker() as db:
settings = await get_all_settings(db)
repos = _get_plugin_repos(settings)
repos = plugin_repos(settings)
if 0 <= idx < len(repos):
repos[idx] = {
"name": form.get("name", "").strip() or repos[idx]["name"],
@@ -864,41 +849,49 @@ async def plugin_repos_save(idx: int):
@settings_bp.get("/plugins/catalog/")
@require_role(UserRole.admin)
async def plugins_catalog():
"""HTMX partial: list plugins available in the remote catalog."""
"""HTMX partial: the plugin catalog as the scheduled plugin_catalog_refresh
task last fetched it. A page load never reaches a repository (Background
Jobs ruling, 2026-10-08); Refresh does, because the operator asked."""
return await _catalog_partial(fetch=False)
@settings_bp.post("/plugins/catalog/refresh")
@require_role(UserRole.admin)
async def plugins_catalog_refresh():
"""HTMX partial: fetch every configured repository now, then the catalog."""
return await _catalog_partial(fetch=True)
async def _catalog_partial(fetch: bool):
from steward.core.plugin_index import cached_catalog, fetch_catalog
from steward.core.plugin_manager import _LOADED_PLUGINS
async with current_app.db_sessionmaker() as db:
settings = await get_all_settings(db)
repos = _get_plugin_repos(settings)
repo_urls = [r["url"] for r in repos if r.get("url")]
if not repo_urls:
urls = repo_urls(settings)
if not urls:
return await render_template(
"settings/_catalog_partial.html",
catalog=[],
installed_names=set(),
loaded_names=set(),
catalog=[], installed_names=set(), installed_versions={}, loaded_names=set(),
error="No plugin repositories configured.",
)
from steward.core.plugin_index import fetch_catalog
from steward.core.plugin_manager import _LOADED_PLUGINS
force = request.args.get("refresh") == "1"
try:
catalog = await fetch_catalog(repo_urls, force=force)
error = None if catalog else "Catalog is empty or could not be fetched."
except Exception as exc:
catalog = []
error = str(exc)
if fetch:
await fetch_catalog(urls)
catalog, missing = cached_catalog(urls)
error = None
if missing:
error = (f"Not fetched yet: {', '.join(missing)}. Steward fetches each repository within a "
"minute of starting or of a change here, then hourly; Refresh fetches now.")
elif not catalog:
error = "The configured repositories list no plugins."
discovered = _discover_plugins()
installed_names = {p["_dir"] for p in discovered}
installed_versions = {p["_dir"]: p.get("version", "0.0.0") for p in discovered}
return await render_template(
"settings/_catalog_partial.html",
catalog=catalog,
installed_names=installed_names,
installed_versions=installed_versions,
installed_names={p["_dir"] for p in discovered},
installed_versions={p["_dir"]: p.get("version", "0.0.0") for p in discovered},
loaded_names=_LOADED_PLUGINS,
error=error,
)
@@ -920,8 +913,7 @@ async def install_plugin(name: str):
async with current_app.db_sessionmaker() as db:
settings = await get_all_settings(db)
repo_urls = [r["url"] for r in _get_plugin_repos(settings) if r.get("url")]
entry = next((p for p in await fetch_catalog(repo_urls) if p.name == name), None)
entry = next((p for p in await fetch_catalog(repo_urls(settings)) if p.name == name), None)
if entry is None:
return await render_template(
"settings/_install_result.html",