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>
This commit is contained in:
+38
-46
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user