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
+62 -1
View File
@@ -101,7 +101,7 @@ async def test_route_takes_url_and_checksum_from_the_catalog(client):
checksum_sha256="abc")
install = AsyncMock(return_value=(False, "stopped here"))
with patch("steward.settings.routes.get_all_settings", new=AsyncMock(return_value={})), \
patch("steward.settings.routes._get_plugin_repos", return_value=[{"url": "https://catalog/"}]), \
patch("steward.settings.routes.repo_urls", return_value=["https://catalog/"]), \
patch("steward.core.plugin_index.fetch_catalog", new=AsyncMock(return_value=[entry])), \
patch("steward.core.plugin_manager.download_and_install_plugin", new=install), \
patch("steward.settings.routes.log_audit", new=AsyncMock()):
@@ -114,3 +114,64 @@ async def test_route_takes_url_and_checksum_from_the_catalog(client):
resp = await client.post("/settings/plugins/install/other", headers={"X-CSRF-Token": "t"})
assert "No configured plugin repository lists this plugin" in await resp.get_data(as_text=True)
install.assert_not_awaited()
# ── The catalog is fetched by a scheduled task, never by a page load ─────────
# Background Jobs ruling (operator, 2026-10-08): a Steward page reads stored
# records; the sources are contacted by scheduled collection, or because the
# operator asked (Refresh, install).
from steward.core import plugin_index as pi # noqa: E402
@pytest.fixture
def fresh_catalog():
pi.clear_catalog_cache()
yield
pi.clear_catalog_cache()
def _fetcher(fetched, fail=()):
async def fetch(url):
fetched.append(url)
if url in fail:
pi._failed_at[url] = 0.0
return False
pi._url_cache[url] = ({"plugins": [{"name": f"p-{url[-1]}", "version": "1.0"}]}, 0.0)
return True
return fetch
@pytest.mark.asyncio
async def test_the_tick_fetches_what_is_due_and_waits_out_a_failure(fresh_catalog):
fetched = []
with patch.object(pi, "_fetch_one", new=_fetcher(fetched, fail={"https://r/b"})):
assert await pi.refresh_due(["https://r/a", "https://r/b"], now=10.0) == 1
assert fetched == ["https://r/a", "https://r/b"]
fetched.clear()
# a fresh copy is left alone; the failure is not retried before RETRY_AFTER
assert await pi.refresh_due(["https://r/a", "https://r/b"], now=pi.RETRY_AFTER - 1.0) == 0
assert fetched == []
assert await pi.refresh_due(["https://r/a", "https://r/b"], now=pi.RETRY_AFTER + 1.0) == 0
assert fetched == ["https://r/b"]
fetched.clear()
assert await pi.refresh_due(["https://r/a"], now=pi.CATALOG_MAX_AGE + 1.0) == 1
assert fetched == ["https://r/a"]
@pytest.mark.asyncio
async def test_the_catalog_page_reads_the_cache_and_only_refresh_fetches(client, fresh_catalog):
async with client.session_transaction() as sess:
sess.update(user_id="u", user_role="admin", username="admin", _csrf="t")
fetched = []
with patch("steward.settings.routes.get_all_settings", new=AsyncMock(return_value={})), \
patch("steward.settings.routes.repo_urls", return_value=["https://r/a"]), \
patch.object(pi, "_fetch_one", new=_fetcher(fetched)):
page = await (await client.get("/settings/plugins/catalog/")).get_data(as_text=True)
assert fetched == [] and "Not fetched yet: https://r/a" in page
page = await (await client.post("/settings/plugins/catalog/refresh",
headers={"X-CSRF-Token": "t"})).get_data(as_text=True)
assert fetched == ["https://r/a"] and "p-a" in page and "Not fetched yet" not in page
# the next page load shows the stored copy without fetching again
page = await (await client.get("/settings/plugins/catalog/")).get_data(as_text=True)
assert fetched == ["https://r/a"] and "p-a" in page