Settle a timed-out assessment refresh instead of calling it failed

The force regenerate aborts its request at 45 s while the server's
inline generation keeps running and usually writes the fresh briefing
moments later. The old handling fired an error toast at the abort and
re-read the old cache once; with a one hour staleTime and focus
refetch off, nothing ever converged, so the page claimed failure while
holding a finished assessment at arm's length.

A timeout now enters a settling state: the hook remembers the cachedAt
baseline it was showing and polls the read path on the existing
bounded cadence (25 s, up to 10 attempts) until it can prove an
outcome. Cache advanced past the baseline: the fresh payload lands and
the success toast fires. A failure marker newer than the baseline, or
the attempt cap: an honest error. While settling, the strip's button
stays busy and says the assessment is still being prepared; no toast
fires until the outcome is proven. Both poll reasons live in the
nextAdvisorPollInterval seam, the verdict in resolveAdvisorSettle, the
toast mapping in regenerateToastKind; all three are pure and pinned by
unit tests. The contract is documented at both ends (hook and generate
route). New copy in all six locales.

Refs #786
This commit is contained in:
Marc-André Bombeck committed 2026-08-13 12:24:46 +02:00
1 parent 86a3d74187
commit 45dc93932a
13 files changed
+498 -24

No files matched your search

@@ -87,7 +87,7 @@ beforeEach(() => {
describe("POST /api/documents/inbound/reindex", () => {
it("returns the remaining un-indexed count as a NUMBER, never a boolean", async () => {
const res = await POST(req() as never, {} as never);
const res = await POST(req() as never);
expect(res.status).toBe(200);
const body = await res.json();
expect(typeof body.data.enqueued).toBe("number");
@@ -98,14 +98,14 @@ describe("POST /api/documents/inbound/reindex", () => {
vi.mocked(enqueueContentIndexBackfill).mockResolvedValue({
enqueued: false,
});
const res = await POST(req() as never, {} as never);
const res = await POST(req() as never);
expect(res.status).toBe(200);
const body = await res.json();
expect(body.data.enqueued).toBe(0);
});
it("scopes both counts to the caller's LIVE documents (the gauge's own figures)", async () => {
await POST(req() as never, {} as never);
await POST(req() as never);
expect(prisma.inboundDocument.count).toHaveBeenCalledWith({
where: { userId: "user-1", deletedAt: null },
});
+13
View File
@@ -1,3 +1,16 @@
/**
* Refs #786 — async-truth contract, server end (the client end lives in
* `src/components/insights/use-insights-advisor.ts`, `AdvisorSettleState`):
* the force POST generates INLINE and, on success, writes the fresh briefing
* to the user cache (`insightsCachedAt` advances) — even when the client has
* long since aborted its 45 s request. A failed inline generation writes a
* failure marker the GET reports as `generationFailed`. The client relies on
* exactly those two signals to settle a timed-out regenerate: it polls the
* GET until `cachedAt` advances past its baseline (success) or a NEWER
* failure marker appears (honest error). Renaming or short-circuiting either
* write breaks that convergence — the settling seam tests pin the client
* half, this comment is the server half of the contract.
*/
import { prisma } from "@/lib/db";
import { auditLog } from "@/lib/auth/audit";
import {