commit 4b6cd617655b4739b09053e9f5dc0a1a26299f34
parent ef05a179874acdf2d101d1ffc346e56fc2d42ffb
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Sat, 26 Sep 2026 04:10:25 -0400
hub: a subs manifest that still fails after its retry never fails its archive (re-read R1)
c3f0e4bd made a non-404 subs error, after its retry, mark the archive
failed. That took a member whose summaries and pages had all loaded out of
the search and /ask over its live-chat index, and the status line then said
it "did not answer". This is reachable on a self-hosted archive: `serve`
answers a missing file with no CORS header, which the browser reports as a
network error rather than a 404.
C2's rule is back: a subs query that still errors after its retry counts
as SETTLED, so the archive is ready without its live chat. Kept from
c3f0e4bd: a 404 resolves to an empty manifest at once, and real errors get
one retry.
e2e (inverted): a 500 gives two subs requests; the chip reads ready with
no Retry, Origin B's result is present, and there is no status line.
Changelog: the "shows as failed" sentence now describes what a reader sees.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Diffstat:
3 files changed, 28 insertions(+), 30 deletions(-)
diff --git a/common/components/SearchDataContext.tsx b/common/components/SearchDataContext.tsx
@@ -348,10 +348,11 @@ export function MultiSiteDataProvider({
// A subs 404 is the same answer — a member with no live chat has no subs
// manifest — so it resolves to an empty one at once. It used to throw, and
// the client's retry held that archive at "loading" for another second. Any
- // OTHER error (network, 5xx) is retried once, then fails the archive like a
- // failed manifest or page: its chip says so and offers Retry. (A member's
- // `/subs/*` is CORS-headed by its _headers, 404s included, so a missing file
- // reads as a 404 here, not as a network error.)
+ // OTHER error (network, 5xx, or a 404 served without CORS, which the browser
+ // reports as a network error — `serve` on a self-hosted archive does that)
+ // is retried once and then counts as SETTLED: the archive is ready without
+ // its live chat. Live chat is the auxiliary layer; its failure never takes an
+ // archive's videos out of the search or makes it read as "did not answer".
const subsQueries = useQueries({
queries: sites.map((s) => ({
queryKey: ["subs-manifest", s.origin],
@@ -412,22 +413,21 @@ export function MultiSiteDataProvider({
const count = m?.data?.totalCount;
const withCount = count === undefined ? base : { ...base, count };
// Subs and posts manifests resolve a 404 to an empty manifest; posts never
- // errors, and a subs error (after its retry) fails the archive below.
- const subs = subsQueries[i];
+ // errors. A subs query that still errors after its retry counts as settled
+ // (the archive is ready without its live chat) and never as a failure.
+ const settled = (q: { isSuccess: boolean; isError: boolean; isFetching: boolean } | undefined) =>
+ !!q && (q.isSuccess || (q.isError && !q.isFetching));
if (
m?.isSuccess &&
pages.length === m.data.pageCount &&
pages.every((p) => p.isSuccess) &&
- subs?.isSuccess &&
- postsQueries[i]?.isSuccess
+ settled(subsQueries[i]) &&
+ settled(postsQueries[i])
) {
return { ...withCount, status: "ready" };
}
- const fetching =
- !!m?.isFetching || pages.some((p) => p.isFetching) || !!subs?.isFetching;
- const failed = m?.isError
- ? m
- : (pages.find((p) => p.isError) ?? (subs?.isError ? subs : undefined));
+ const fetching = !!m?.isFetching || pages.some((p) => p.isFetching);
+ const failed = m?.isError ? m : pages.find((p) => p.isError);
if (failed && !fetching) {
return {
...withCount,
diff --git a/export/CHANGELOG.md b/export/CHANGELOG.md
@@ -3,7 +3,7 @@
## [Unreleased]
- **The hub lists its official instances in the homepage's order, and each wears one colour everywhere.** The cards and the scope chips follow the homepage (most transcripts first) instead of `hub-sites.json`'s alphabetical order. An archive whose site sets no accent wears its homepage card's colour, and now on every surface: its card, its chip's dot, the left edge of its search results and its channel group. Before, it had a card stripe and nothing else. The hub waits for `/hub-summary.json`, which is requested together with the list, before it lists the official instances, so they never reorder a moment later.
- **The hub's Ask AI searches only the archives the scope chips leave in.** An archive switched off on the hub's front page is not fetched on `/ask` either, so the chat answers from what the search showed. It still waits for every archive in scope before it answers. With every archive switched off, the question box is disabled and one line under it says why. Before, a question went out over no records at all.
-- **A member archive with no live chat is ready about a second sooner on the hub.** Its missing subs manifest (a 404) is read as "no live chat" at once, instead of as an error and a second request. A subs manifest that fails for any other reason is asked for once more. If it fails again, the archive shows as failed, with Retry, like any other archive that did not answer. Before, it read as ready, with its live chat silently missing.
+- **A member archive with no live chat is ready about a second sooner on the hub.** Its missing subs manifest (a 404) is read as "no live chat" at once, instead of as an error and a second request. A subs manifest that fails for any other reason is asked for once more. If it fails again, the archive is searched without its live chat, and its chip reads ready.
## [0.9.0] - 2026-09-26
- **A reader picks a ground and an accent; the five theme families are gone.** The header's theme menu has **Base** (System, Light, Sepia, Dark) and **Accent** (Signal, Brass, Vermilion, Violet, Sakura, Blue, Green, with the site's own tagged *default*, and *Site colour* first on a site with a custom hex). The toggle beside it cycles System → Light → Sepia → Dark; on a phone both lists are in the menu sheet. A site opens on the reader's system setting in the accent from its `site.json` (a named id such as `brass`, or a hex), rendered as `<html data-accent>` so it is right before any script runs; the hub opens on Dark in Signal, already in the server markup. A reader's accent is stored only while it differs from the site's. A stored theme from before carries over once (Archive light becomes Sepia) and the old keys are deleted. Type is Archivo, IBM Plex Sans and IBM Plex Mono; status text reads at 4.5:1 on its tinted fill on every ground; charts have fixed colours per ground; the browser bar follows the ground (a light/dark pair for a site, dark for the hub).
diff --git a/export/e2e-hub/federated-search.spec.ts b/export/e2e-hub/federated-search.spec.ts
@@ -10,7 +10,8 @@ import { NO_ARCHIVES_IN_SCOPE } from "../app/ask/hubScopeCopy";
// fast one's results back), and a card naming its source archive in text.
// Release 10 (L1): a member with no live chat is ready on its first subs
// answer (a 404 is an empty manifest), and a subs manifest that cannot be read
-// fails its archive with Retry; the official archives follow the homepage's
+// is retried once and never fails its archive (ready, without live chat); the
+// official archives follow the homepage's
// order (hub-summary.json's) on the cards and the chips, and an archive with no
// accent wears ONE colour — its homepage card's — on its card, its chip and its
// results; /ask searches only the archives the chips leave in, and with every
@@ -406,30 +407,27 @@ test.describe("hub federated search — scope, per-archive state, attribution",
expect(mocks.b.requests).toContain("/summaries/page-0000.json");
});
- test("a subs manifest that cannot be read is retried once, then fails its archive with Retry", async ({
+ test("a subs manifest that cannot be read is retried once and never fails its archive", async ({
page,
}) => {
const mocks = await setup(page);
mocks.b.subs = "error";
await page.goto("/");
- await expect(chip(page, B)).toHaveAttribute("data-status", "failed");
- await expect(
- chip(page, B).getByRole("button", { name: "Retry Origin B" }),
- ).toBeVisible();
- const subsAsks = () =>
- mocks.b.requests.filter((p) => p === "/subs/manifest.json").length;
- expect(subsAsks()).toBe(2);
- await expect(status(page)).toContainText("1 of 2 archives answered.");
- await expect(resultFrom(page, A)).toHaveCount(1);
- await expect(resultFrom(page, B)).toHaveCount(0);
-
- // The member recovers; Retry reads its subs manifest again and it is in.
- mocks.b.subs = "ok";
- await chip(page, B).getByRole("button", { name: "Retry Origin B" }).click();
+ // Live chat is the auxiliary layer: after one retry the error counts as
+ // settled, so Origin B is ready — its videos searched, without live chat —
+ // and nothing says it did not answer.
await expect(chip(page, B)).toHaveAttribute("data-status", "ready");
await expect(resultFrom(page, B)).toHaveCount(1);
+ await expect(resultFrom(page, A)).toHaveCount(1);
+ await expect(
+ chip(page, B).getByRole("button", { name: "Retry Origin B" }),
+ ).toHaveCount(0);
await expect(status(page)).toHaveCount(0);
+ // Ready waits for the retry to settle, so the count is final here.
+ expect(
+ mocks.b.requests.filter((p) => p === "/subs/manifest.json").length,
+ ).toBe(2);
});
test("/ask with every archive switched off says so and cannot ask; with one back on, it asks", async ({