@remit/web-client 0.0.270 → 0.0.272

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,4 +1,3 @@
1
- import { mailboxOperationsListMailboxesQueryKey } from "@remit/api-http-client/@tanstack/react-query.gen.ts";
2
1
  import {
3
2
  messageBulkOperationsDeleteMessages,
4
3
  messageBulkOperationsMoveMessages,
@@ -6,30 +5,22 @@ import {
6
5
  threadOperationsSearchThreads,
7
6
  } from "@remit/api-http-client/sdk.gen.ts";
8
7
  import type { ThreadOperationsSearchThreadsData } from "@remit/api-http-client/types.gen.ts";
9
- import { useQueryClient } from "@tanstack/react-query";
10
8
  import { useCallback, useEffect, useRef, useState } from "react";
11
- import { useRoleAppointmentPrompt } from "@/components/mail/RoleAppointmentPromptProvider";
9
+ import {
10
+ type BulkRunRequest,
11
+ type BulkRunSource,
12
+ useBulkRun,
13
+ } from "@/components/mail/BulkRunProvider";
12
14
  import { useErrorBanners } from "@/components/ui/ErrorBannerProvider";
13
15
  import { buildMutationErrorBanner } from "@/components/ui/error-banners";
14
- import { isFolderRoleRefusal } from "@/components/ui/folder-role-refusal";
15
- import {
16
- bulkActionFailureDetail,
17
- bulkActionFailureTitle,
18
- } from "@/lib/bulk-action-copy";
19
- import {
20
- type ApplyBatch,
21
- type BulkActionProgress,
22
- type BulkActionTarget,
23
- type BulkRunOutcome,
24
- type FetchIdsPage,
25
- honestProgress,
26
- runChunkedAction,
27
- runPredicateAction,
16
+ import type {
17
+ ApplyBatch,
18
+ BulkActionProgress,
19
+ BulkActionTarget,
20
+ BulkRunStart,
21
+ EscalatedAction,
22
+ FetchIdsPage,
28
23
  } from "@/lib/bulk-actions";
29
- import {
30
- invalidateThreadListQueries,
31
- threadListCacheKeys,
32
- } from "@/lib/thread-list-cache";
33
24
 
34
25
  /** The predicate a search-scoped run re-issues on every page — the same
35
26
  * filters the visible list is searching with, minus pagination/count knobs. */
@@ -45,16 +36,6 @@ export type EscalationSearchQuery = Pick<
45
36
  | "category"
46
37
  >;
47
38
 
48
- /**
49
- * What a bulk run applies to every batch it reaches (#114). Delete, move and
50
- * mark-read differ only in the bulk call they issue and the caches that call
51
- * invalidates; the paging, chunking, progress and cancellation are the same.
52
- */
53
- export type EscalatedAction =
54
- | { kind: "delete" }
55
- | { kind: "move"; destinationMailboxId: string }
56
- | { kind: "markRead" };
57
-
58
39
  /** Page size for the execution loop. Set to the write side's own 100-id cap so
59
40
  * an execution page IS a write chunk — no in-memory accumulation step between
60
41
  * reading ids and sending them. */
@@ -67,6 +48,8 @@ export type EscalationPhase =
67
48
 
68
49
  interface UseEscalatedActionsOptions {
69
50
  mailboxId: string;
51
+ /** The mailbox in the user's words, for a commit refused while this runs. */
52
+ mailboxLabel?: string;
70
53
  /** Owning account, forwarded to the unseen-count invalidation on completion. */
71
54
  accountId?: string;
72
55
  /** Disables escalation entirely (e.g. not searching). Resets any in-flight
@@ -76,6 +59,12 @@ interface UseEscalatedActionsOptions {
76
59
  * changes (a different search is a different question). */
77
60
  predicateKey: string;
78
61
  searchQuery: EscalationSearchQuery;
62
+ /**
63
+ * States how a run ended, for a user who is no longer looking at any screen
64
+ * that could show it. Handed to the run's owner rather than called here, so
65
+ * an ending that lands after this hook unmounted is still said (#112).
66
+ */
67
+ reportEnding?: BulkRunRequest["reportEnding"];
79
68
  }
80
69
 
81
70
  export interface UseEscalatedActionsResult {
@@ -84,11 +73,14 @@ export interface UseEscalatedActionsResult {
84
73
  * selection to that predicate once it answers. */
85
74
  escalate: () => void;
86
75
  /**
87
- * Stop whatever's running — the count or an action. The request in flight is
88
- * aborted and nothing further leaves (#113); a delete the server has already
89
- * accepted still applies, so the batch on the wire is what a stop is worth.
90
- * A no-op when nothing is running. The only thing that ends a run in
91
- * flight: leaving the selection, the wizard or the search does not.
76
+ * Stop whatever this mailbox has running — its count, and the run when the
77
+ * run is its own. The request in flight is aborted and nothing further leaves
78
+ * (#113); a delete the server has already accepted still applies, so the
79
+ * batch on the wire is what a stop is worth. A no-op when nothing is running.
80
+ * Cancelling a count here never reaches a run in another mailbox: that run is
81
+ * not what this screen is offering to stop, and ending it would be silent.
82
+ * The only thing that ends a run in flight: leaving the selection, the
83
+ * wizard, the search or the mailbox does not.
92
84
  */
93
85
  stop: () => void;
94
86
  /**
@@ -99,7 +91,7 @@ export interface UseEscalatedActionsResult {
99
91
  */
100
92
  clear: () => void;
101
93
  /** True while a chunked run (bounded->100 ids, or the escalated predicate)
102
- * is in flight. */
94
+ * over this mailbox is in flight, whichever screen started it. */
103
95
  isRunning: boolean;
104
96
  /** The action currently in flight, for status and progress wording. */
105
97
  runningAction: EscalatedAction | undefined;
@@ -112,61 +104,44 @@ export interface UseEscalatedActionsResult {
112
104
  * as one batch the endpoint refuses whole (#872). Resolves once the run ends
113
105
  * for any reason — cancelled, errored, or complete — with a
114
106
  * `done`/`failedIds` outcome the caller reads to decide what is still
115
- * outstanding.
116
- * Infrastructure failures are reported through the app's existing
117
- * escalation seam (`pushError`, which itself escalates a 5xx/exception to
118
- * the fatal overlay) — not swallowed here.
107
+ * outstanding, or with the refusal of a run that never started because
108
+ * another one is still going.
109
+ *
110
+ * The run itself belongs to `BulkRunProvider`, which outlives every screen
111
+ * that can show it: the caches, the refusal replay, the failure banner and
112
+ * the ending are its, so none of them are lost when the surface that started
113
+ * the run goes (#112).
119
114
  */
120
115
  runAction: (
121
116
  action: EscalatedAction,
122
117
  targets?: readonly BulkActionTarget[],
123
- ) => Promise<BulkRunOutcome>;
118
+ claimEnding?: BulkRunRequest["claimEnding"],
119
+ ) => Promise<BulkRunStart>;
124
120
  }
125
121
 
126
- /**
127
- * The mailboxes whose cached listings a bulk run affects: the mailbox it ran
128
- * over, plus a move's destination, which gains the messages the source loses.
129
- */
130
- export const mailboxesTouchedBy = (
131
- action: EscalatedAction,
132
- mailboxId: string,
133
- ): string[] =>
134
- action.kind === "move"
135
- ? [mailboxId, action.destinationMailboxId]
136
- : [mailboxId];
137
-
138
122
  export const useEscalatedActions = ({
139
123
  mailboxId,
124
+ mailboxLabel,
140
125
  accountId,
141
126
  enabled,
142
127
  predicateKey,
143
128
  searchQuery,
129
+ reportEnding,
144
130
  }: UseEscalatedActionsOptions): UseEscalatedActionsResult => {
145
131
  const [phase, setPhase] = useState<EscalationPhase>({ kind: "idle" });
146
- const [runningAction, setRunningAction] = useState<
147
- EscalatedAction | undefined
148
- >(undefined);
149
- const [progress, setProgress] = useState<BulkActionProgress | undefined>(
150
- undefined,
151
- );
152
- // Cancellation is the signal every call the run makes is issued under, so a
153
- // stop ends the request on the wire instead of waiting for it to come back
154
- // (#113). Replaced, never reused, at the start of each count and each run.
155
- const abortRef = useRef<AbortController | undefined>(undefined);
156
- // True from the moment a run starts until its outcome is in hand. A run is
157
- // mail already leaving the mailbox, so nothing that merely changes what the
158
- // list is showing gets to end it — only `stop`.
159
- const runningRef = useRef(false);
160
- const queryClient = useQueryClient();
132
+ // The count's own signal, which is not the run's: leaving the search ends a
133
+ // count, and never a run (#112).
134
+ const countAbortRef = useRef<AbortController | undefined>(undefined);
135
+ const { run, start, stop: stopRun } = useBulkRun();
161
136
  const { pushError } = useErrorBanners();
162
- const { requestAppointment } = useRoleAppointmentPrompt();
163
- // The run replays itself once a folder is appointed, so it needs a handle on
164
- // itself that does not make `runAction` its own dependency.
165
- const runRef = useRef<UseEscalatedActionsResult["runAction"]>(async () => ({
166
- done: 0,
167
- failedIds: [],
168
- cancelled: false,
169
- }));
137
+
138
+ // The live run, when it is this mailbox's. A run is mail leaving one mailbox,
139
+ // so it reports on that mailbox's list — not on whichever list is on screen.
140
+ const activeRun = run?.mailboxId === mailboxId ? run : undefined;
141
+ const isRunning = activeRun !== undefined;
142
+ // Read by callbacks that must not close over a stale render's answer.
143
+ const isRunningRef = useRef(false);
144
+ isRunningRef.current = isRunning;
170
145
 
171
146
  // A different search (or leaving search/desktop) makes any in-flight
172
147
  // escalation meaningless — it would otherwise keep counting or offering to
@@ -175,7 +150,7 @@ export const useEscalatedActions = ({
175
150
  // against and reports what it reached, wherever the list moved on to.
176
151
  // biome-ignore lint/correctness/useExhaustiveDependencies: enabled/predicateKey are trigger-only — the reset itself reads neither.
177
152
  useEffect(() => {
178
- if (!runningRef.current) abortRef.current?.abort();
153
+ countAbortRef.current?.abort();
179
154
  setPhase({ kind: "idle" });
180
155
  }, [enabled, predicateKey]);
181
156
 
@@ -252,36 +227,9 @@ export const useEscalatedActions = ({
252
227
  [],
253
228
  );
254
229
 
255
- /**
256
- * The unseen counts a run moved, per account. A cross-account selection has
257
- * no single owning account — the surface leaves the option undefined exactly
258
- * then — so the run's own targets are what name the accounts to refresh.
259
- */
260
- const invalidateAfterRun = useCallback(
261
- (action: EscalatedAction, targets: readonly BulkActionTarget[]) => {
262
- invalidateThreadListQueries(
263
- queryClient,
264
- threadListCacheKeys(mailboxesTouchedBy(action, mailboxId)),
265
- );
266
- const touched = new Set<string>();
267
- if (accountId) touched.add(accountId);
268
- for (const target of targets) {
269
- if (target.accountId) touched.add(target.accountId);
270
- }
271
- for (const touchedAccountId of touched) {
272
- queryClient.invalidateQueries({
273
- queryKey: mailboxOperationsListMailboxesQueryKey({
274
- path: { accountId: touchedAccountId },
275
- }),
276
- });
277
- }
278
- },
279
- [queryClient, mailboxId, accountId],
280
- );
281
-
282
230
  const escalate = useCallback(() => {
283
231
  const controller = new AbortController();
284
- abortRef.current = controller;
232
+ countAbortRef.current = controller;
285
233
  setPhase({ kind: "counting" });
286
234
  fetchMatchCount(controller.signal).then(
287
235
  (total) => {
@@ -311,12 +259,13 @@ export const useEscalatedActions = ({
311
259
  }, [fetchMatchCount, pushError]);
312
260
 
313
261
  const stop = useCallback(() => {
314
- abortRef.current?.abort();
315
- }, []);
262
+ countAbortRef.current?.abort();
263
+ if (isRunningRef.current) stopRun(mailboxId);
264
+ }, [stopRun, mailboxId]);
316
265
 
317
266
  const clear = useCallback(() => {
318
- if (runningRef.current) return;
319
- abortRef.current?.abort();
267
+ if (isRunningRef.current) return;
268
+ countAbortRef.current?.abort();
320
269
  setPhase({ kind: "idle" });
321
270
  }, []);
322
271
 
@@ -324,105 +273,62 @@ export const useEscalatedActions = ({
324
273
  async (
325
274
  action: EscalatedAction,
326
275
  targets?: readonly BulkActionTarget[],
327
- ): Promise<BulkRunOutcome> => {
328
- const controller = new AbortController();
329
- abortRef.current = controller;
330
- runningRef.current = true;
331
- setRunningAction(action);
276
+ claimEnding?: BulkRunRequest["claimEnding"],
277
+ ): Promise<BulkRunStart> => {
332
278
  // Read before the run clears the phase below, so a refusal can say how
333
279
  // many messages the appointment's replay is about.
334
280
  const matched =
335
281
  targets?.length ?? (phase.kind === "escalated" ? phase.total : 0);
336
- // `honestProgress` widens `total` if `done` overtakes it (#109) — the
337
- // predicate can match more by the time the run pages it than the count
338
- // saw, and the bar must never show more done than out of.
339
- const onProgress = (next: BulkActionProgress) =>
340
- setProgress(honestProgress(next));
341
- const applyBatch = applyBatchFor(action);
342
282
  // The predicate as it read when the run was confirmed. The run outlives
343
283
  // the screen that started it, so reading the live query on every page
344
284
  // would let a search typed afterwards redirect what is being deleted.
345
- const runPages = fetchPagesOf(searchQueryRef.current);
285
+ const source: BulkRunSource =
286
+ targets !== undefined
287
+ ? { kind: "targets", targets }
288
+ : {
289
+ kind: "predicate",
290
+ fetchPage: fetchPagesOf(searchQueryRef.current),
291
+ };
346
292
 
347
- // The one invariant nothing may lose: while `runningRef` is up, both
348
- // `clear` and the reset effect stand down, so a run that never marked
349
- // itself finished would leave an escalated selection nobody can leave.
350
- let outcome: BulkRunOutcome;
351
- try {
352
- outcome =
353
- targets !== undefined
354
- ? await runChunkedAction(
355
- targets,
356
- applyBatch,
357
- onProgress,
358
- controller.signal,
359
- )
360
- : await runPredicateAction(
361
- runPages,
362
- phase.kind === "escalated" ? phase.total : 0,
363
- applyBatch,
364
- onProgress,
365
- controller.signal,
366
- );
367
- } finally {
368
- runningRef.current = false;
369
- }
370
-
371
- setRunningAction(undefined);
372
- setProgress(undefined);
373
- setPhase({ kind: "idle" });
293
+ const started = await start({
294
+ action,
295
+ mailboxId,
296
+ mailboxLabel,
297
+ accountId,
298
+ matched,
299
+ source,
300
+ applyBatch: applyBatchFor(action),
301
+ reportEnding,
302
+ claimEnding,
303
+ });
374
304
 
375
- // A provenance refusal is answered by the appointment prompt here exactly
376
- // as it is on the single-row path (#887, #876). Left to the banner it
377
- // arrives as the API's own sentence — "Appoint one under Settings ›
378
- // Folder roles" — which asks the user to reassemble a select-all they
379
- // have already made.
380
- const refusal = outcome.error
381
- ? isFolderRoleRefusal(outcome.error)
382
- : undefined;
383
- if (refusal) {
384
- requestAppointment({
385
- accountId: refusal.accountId,
386
- role: refusal.role,
387
- reason: refusal.reason,
388
- action: { kind: "delete", count: matched },
389
- onAppointed: async () => {
390
- await runRef.current(action, targets);
391
- },
392
- });
393
- } else if (outcome.error) {
394
- pushError(
395
- buildMutationErrorBanner(
396
- bulkActionFailureTitle(action.kind, outcome.done),
397
- bulkActionFailureDetail(action.kind),
398
- outcome.error,
399
- ),
400
- );
401
- }
402
- if (outcome.done > 0) {
403
- invalidateAfterRun(action, targets ?? []);
404
- }
405
- return outcome;
305
+ // The escalated selection was what the run was confirmed from, and the
306
+ // run has now happened to it. A refused commit leaves it standing: the
307
+ // selection is still the question, and stopping the other run is what
308
+ // makes it answerable.
309
+ if (started.kind === "ran") setPhase({ kind: "idle" });
310
+ return started;
406
311
  },
407
312
  [
408
313
  applyBatchFor,
409
314
  fetchPagesOf,
410
315
  phase,
411
- pushError,
412
- invalidateAfterRun,
413
- requestAppointment,
316
+ mailboxId,
317
+ mailboxLabel,
318
+ accountId,
319
+ reportEnding,
320
+ start,
414
321
  ],
415
322
  );
416
- runRef.current = runAction;
417
323
 
418
324
  return {
419
325
  phase,
420
326
  escalate,
421
327
  stop,
422
328
  clear,
423
- isRunning: runningAction !== undefined,
424
- runningAction,
425
- progress,
329
+ isRunning,
330
+ runningAction: activeRun?.action,
331
+ progress: activeRun?.progress,
426
332
  runAction,
427
333
  };
428
334
  };
@@ -115,6 +115,27 @@ export const bulkActionFailureTitle = (
115
115
  export const bulkActionFailureDetail = (kind: BulkActionKind): string =>
116
116
  failureDetail[kind];
117
117
 
118
+ const busyPhrase: Record<BulkActionKind, (count: string) => string> = {
119
+ delete: (count) => `A delete of ${count} messages`,
120
+ move: (count) => `A move of ${count} messages`,
121
+ markRead: (count) => `Marking ${count} messages as read`,
122
+ };
123
+
124
+ /**
125
+ * The commit that could not start, because the one still going owns the bar and
126
+ * the Stop it would need. It names the run in flight and where it is, so the
127
+ * user knows which screen to end it from rather than pressing a commit that
128
+ * silently does nothing.
129
+ */
130
+ export const bulkRunBusyRefusal = (
131
+ kind: BulkActionKind,
132
+ matched: number,
133
+ mailboxLabel: string | undefined,
134
+ ): string =>
135
+ `${busyPhrase[kind](formatNumber(matched))}${
136
+ mailboxLabel ? ` in ${mailboxLabel}` : ""
137
+ } is still running — stop it first.`;
138
+
118
139
  /** Progress-bar tone: only delete is destructive. */
119
140
  export const bulkActionProgressTone = (
120
141
  kind: BulkActionKind,
@@ -34,6 +34,28 @@
34
34
 
35
35
  export const BULK_ACTION_CHUNK_SIZE = 100;
36
36
 
37
+ /**
38
+ * What a bulk run applies to every batch it reaches (#114). Delete, move and
39
+ * mark-read differ only in the bulk call they issue and the caches that call
40
+ * invalidates; the paging, chunking, progress and cancellation are the same.
41
+ */
42
+ export type EscalatedAction =
43
+ | { kind: "delete" }
44
+ | { kind: "move"; destinationMailboxId: string }
45
+ | { kind: "markRead" };
46
+
47
+ /**
48
+ * The mailboxes whose cached listings a bulk run affects: the mailbox it ran
49
+ * over, plus a move's destination, which gains the messages the source loses.
50
+ */
51
+ export const mailboxesTouchedBy = (
52
+ action: EscalatedAction,
53
+ mailboxId: string,
54
+ ): string[] =>
55
+ action.kind === "move"
56
+ ? [mailboxId, action.destinationMailboxId]
57
+ : [mailboxId];
58
+
37
59
  /** For a run with no Stop control to wire a signal to. */
38
60
  const NEVER_ABORTED = new AbortController().signal;
39
61
 
@@ -323,3 +345,13 @@ export interface BulkRunOutcome {
323
345
  cancelled: boolean;
324
346
  error?: unknown;
325
347
  }
348
+
349
+ /**
350
+ * What asking for a run got: the run itself, or the reason there is none. One
351
+ * run at a time is the design — a second one would page a second predicate
352
+ * behind the same bar and the same Stop — so a commit pressed while one is
353
+ * going is answered with what is going rather than with a run that replaces it.
354
+ */
355
+ export type BulkRunStart =
356
+ | { kind: "ran"; outcome: BulkRunOutcome }
357
+ | { kind: "refused"; reason: string };
@@ -9,6 +9,7 @@ import {
9
9
  isNetworkError,
10
10
  isNotFound,
11
11
  isServerError,
12
+ restartExpectedMeta,
12
13
  shouldEscalate,
13
14
  softErrorStatuses,
14
15
  } from "./error-classifier";
@@ -366,6 +367,13 @@ describe("meta.softErrorStatuses (one outcome owned, the rest not)", () => {
366
367
  );
367
368
  });
368
369
 
370
+ it("does not open the restart gap for a call site that never claimed one", () => {
371
+ assert.equal(
372
+ shouldEscalate(new ApiError("bad gateway", 502), ownsNotFound, "nobody"),
373
+ true,
374
+ );
375
+ });
376
+
369
377
  it("names the record's absence, whichever client raised it", () => {
370
378
  assert.equal(isNotFound(new ApiError("gone", 404)), true);
371
379
  assert.equal(isNotFound({ status: 404 }), true);
@@ -373,3 +381,54 @@ describe("meta.softErrorStatuses (one outcome owned, the rest not)", () => {
373
381
  assert.equal(isNotFound(new TypeError("x is undefined")), false);
374
382
  });
375
383
  });
384
+
385
+ /**
386
+ * A self-update stops and starts the backend, so for those seconds the proxy in
387
+ * front of it has nothing to dial. That gap belongs to the surface that asked
388
+ * for the restart; every other 5xx, on that call site and on any other, is still
389
+ * the API reporting itself broken (#468).
390
+ */
391
+ describe("meta.restartExpected (the gap in a restart this call site asked for)", () => {
392
+ it("does NOT escalate the gateway statuses answered while the backend is down", () => {
393
+ for (const status of [502, 503, 504]) {
394
+ assert.equal(
395
+ shouldEscalate(
396
+ new ApiError("gateway", status),
397
+ restartExpectedMeta,
398
+ "nobody",
399
+ ),
400
+ false,
401
+ `a ${status} inside the restart window belongs to the update surface`,
402
+ );
403
+ }
404
+ });
405
+
406
+ it("escalates a 500 — a server that answered is up, and up and broken is not designed", () => {
407
+ assert.equal(
408
+ shouldEscalate(new ApiError("boom", 500), restartExpectedMeta, "nobody"),
409
+ true,
410
+ );
411
+ });
412
+
413
+ it("escalates the same 502 without the claim, however soft the call site is", () => {
414
+ assert.equal(
415
+ shouldEscalate(new ApiError("gateway", 502), undefined, "nobody"),
416
+ true,
417
+ );
418
+ assert.equal(
419
+ shouldEscalate(new ApiError("gateway", 502), { softError: true }, "user"),
420
+ true,
421
+ );
422
+ });
423
+
424
+ it("escalates a client bug raised inside the window", () => {
425
+ assert.equal(
426
+ shouldEscalate(
427
+ new TypeError("x is undefined"),
428
+ restartExpectedMeta,
429
+ "nobody",
430
+ ),
431
+ true,
432
+ );
433
+ });
434
+ });
@@ -143,6 +143,33 @@ const isSoftErrorMeta = (
143
143
  return status !== undefined && owned.includes(status);
144
144
  };
145
145
 
146
+ /**
147
+ * The statuses answered for the seconds a service is not there. Every `/api/*`
148
+ * request is proxied to apisix, which stays up while the backend behind it
149
+ * restarts and refuses in milliseconds — the 29ms 502 in #468, not a dial Caddy
150
+ * waited out. 503 and 504 are the same gap through a hop that answers
151
+ * differently.
152
+ */
153
+ const RESTART_GAP_STATUSES = [502, 503, 504];
154
+
155
+ /**
156
+ * The gap in a restart this call site asked for. A self-update stops and starts
157
+ * the backend on purpose, so the poll watching that run meets a proxy with
158
+ * nothing behind it — the designed shape of the operation the user pressed, not
159
+ * our API answering "I'm broken".
160
+ *
161
+ * A 500 is not in it: a server that answered is a server that is up, and up and
162
+ * broken is never designed.
163
+ */
164
+ const isDesignedRestartGap = (
165
+ error: unknown,
166
+ meta: Record<string, unknown> | undefined,
167
+ ): boolean => {
168
+ if (meta?.restartExpected !== true) return false;
169
+ const status = getErrorStatus(error);
170
+ return status !== undefined && RESTART_GAP_STATUSES.includes(status);
171
+ };
172
+
146
173
  /**
147
174
  * The single fail-fast decision: should this error escalate to the full-screen
148
175
  * fatal overlay? Default is YES — a non-2xx must never silently vanish.
@@ -151,7 +178,10 @@ const isSoftErrorMeta = (
151
178
  * 1. DEFAULT = escalate.
152
179
  * 2. A 5xx (500–599) ALWAYS escalates — no opt-out, even on a background
153
180
  * refetch, even when the call site marked `meta.softError`. Our API
154
- * answered "I'm broken"; that is never benign.
181
+ * answered "I'm broken"; that is never benign. The single exception is a
182
+ * 502/503/504 on a call site that declared it is polling across a restart it
183
+ * asked for (`meta.restartExpected`, #468): there the backend being gone is
184
+ * the operation working, and that call site renders the wait itself.
155
185
  * 3. A client-side exception ALWAYS escalates, on the same terms. It is our
156
186
  * bug; there is nothing for the user to retry and nothing to dismiss.
157
187
  * 4. A 401 on a request the user is waiting on ALWAYS escalates, on the same
@@ -173,7 +203,7 @@ export const shouldEscalate = (
173
203
  meta?: Record<string, unknown>,
174
204
  awaiting: Awaiting = "nobody",
175
205
  ): boolean => {
176
- if (isServerError(error)) return true;
206
+ if (isServerError(error)) return !isDesignedRestartGap(error, meta);
177
207
  if (isAbortError(error)) return false;
178
208
  if (isNetworkError(error)) return false;
179
209
  if (awaiting === "user" && isUnauthenticated(error)) return true;
@@ -209,6 +239,22 @@ export const softErrorStatuses = (
209
239
  ...statuses: number[]
210
240
  ): { softErrorStatuses: number[] } => ({ softErrorStatuses: statuses });
211
241
 
242
+ /**
243
+ * The one opt-out from rule 2, for a request watching a restart the run it is
244
+ * following performs. Set it only while a run is known to be in flight — the
245
+ * update poll carries it while it holds a run it started or the server's last
246
+ * answer reported one going — because a 502 outside that window is the API being
247
+ * broken, which is exactly what rule 2 is for.
248
+ *
249
+ * A call site that sets this owns the whole wait, so the window outlives the
250
+ * budget: the give-up screen keeps polling a server it has stopped expecting,
251
+ * and a fatal page thrown over that verdict would replace the one screen that
252
+ * names the run and the log to read. A 500 from that same poll still escalates.
253
+ */
254
+ export const restartExpectedMeta: { restartExpected: true } = {
255
+ restartExpected: true,
256
+ };
257
+
212
258
  /** The record is not there. */
213
259
  export const isNotFound = (error: unknown): boolean =>
214
260
  getErrorStatus(error) === 404;
@@ -72,6 +72,7 @@ function input(overrides: Partial<DeriveInput> = {}): DeriveInput {
72
72
  dismissedRunId: null,
73
73
  checkPress: null,
74
74
  checkFailure: null,
75
+ sighting: null,
75
76
  now: NOW,
76
77
  ...overrides,
77
78
  };