@remit/web-client 0.0.117 → 0.0.119

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@remit/web-client",
3
- "version": "0.0.117",
3
+ "version": "0.0.119",
4
4
  "type": "module",
5
5
  "description": "Remit web client, published as composable primitives — the app shell, auth shells, and runtime config. A distributor imports what it composes and bundles it.",
6
6
  "exports": {
@@ -719,7 +719,10 @@ function SelectionWizardSession({
719
719
  state,
720
720
  matched: progress.matchedCount,
721
721
  applied: progress.appliedCount,
722
- failed: state === "backApplyFailed" ? progress.failedCount : 0,
722
+ failed:
723
+ state === "backApplyFailed" || state === "backApplyRestartFailed"
724
+ ? progress.failedCount
725
+ : 0,
723
726
  failures: [],
724
727
  };
725
728
  },
@@ -59,6 +59,24 @@ describe("organizeRunState", () => {
59
59
  );
60
60
  });
61
61
 
62
+ it("keeps a finished pass's ending when the retry over it could not be started", () => {
63
+ // #552: the retry is a second create, and a create that failed over a pass
64
+ // that already moved mail is not a pass that never ran.
65
+ for (const ruleSaved of [true, false]) {
66
+ assert.equal(
67
+ organizeRunState(
68
+ reading({
69
+ ruleSaved,
70
+ isDone: true,
71
+ failedCount: 84,
72
+ failure: { kind: "restartFailed", error: new Error("offline") },
73
+ }),
74
+ ),
75
+ "backApplyRestartFailed",
76
+ );
77
+ }
78
+ });
79
+
62
80
  it("says nothing happened only when the create itself failed", () => {
63
81
  assert.equal(
64
82
  organizeRunState(
@@ -20,6 +20,10 @@ export interface OrganizeJobReading {
20
20
  * not a job that never started (#526), so what the job is doing is read before
21
21
  * what failed: a dropped poll leaves a running pass running and a finished one
22
22
  * finished, and only a create that never returned an id says nothing happened.
23
+ *
24
+ * A create that failed over a pass that already ran is that same distinction on
25
+ * the create path (#552): the counts of the pass that ran stand, and what failed
26
+ * is the retry.
23
27
  */
24
28
  export const organizeRunState = ({
25
29
  failure,
@@ -29,6 +33,7 @@ export const organizeRunState = ({
29
33
  failedCount,
30
34
  ruleSaved,
31
35
  }: OrganizeJobReading): RunState => {
36
+ if (failure?.kind === "restartFailed") return "backApplyRestartFailed";
32
37
  if (failure?.kind === "startFailed") {
33
38
  return ruleSaved ? "backApplyStartFailed" : "commitFailed";
34
39
  }
@@ -4,8 +4,8 @@
4
4
  * Once consent is given the server is genuinely going away, so a quiet spinner
5
5
  * over a mailbox that cannot load would let a broken system look healthy. This
6
6
  * mounts at the app root and owns the window for the whole apply, and for the
7
- * "server never came back" verdict — from any route, so a reload mid-apply or a
8
- * second tab resumes straight into it.
7
+ * "server never came back" verdict — from any route, and in any tab the server
8
+ * tells about the run.
9
9
  */
10
10
  import {
11
11
  SelfUpdateProgressOverlay,
@@ -1,9 +1,9 @@
1
1
  /**
2
2
  * The self-update hook, wired end to end against the generated client through a
3
3
  * mocked fetch. The pure state machine is covered in `lib/self-update-state.test.ts`;
4
- * these exercise the parts only the live hook has: resuming a persisted run
5
- * across a request that fails, the poll that survives a dead server, and the
6
- * POST that persists a run id the reload will pick up.
4
+ * these exercise the parts only the live hook has: what a page claims before the
5
+ * server has answered, the run this page starts and holds, and the poll that
6
+ * survives a dead server.
7
7
  */
8
8
  import assert from "node:assert/strict";
9
9
  import { afterEach, beforeEach, describe, test } from "node:test";
@@ -17,9 +17,8 @@ import { SelfUpdateOverlay } from "../components/self-update/SelfUpdateOverlay";
17
17
  import { AdvancedNavIcon } from "../components/settings/AdvancedNavIcon";
18
18
  import { SelfUpdatePanel } from "../components/settings/SelfUpdatePanel";
19
19
  import {
20
- loadHeldRun,
21
- SELF_UPDATE_RUN_KEY,
22
- saveHeldRun,
20
+ APPLY_BUDGET_SECONDS,
21
+ NEVER_CAME_BACK_MARGIN_SECONDS,
23
22
  } from "../lib/self-update-state";
24
23
  import { createDomHarness, type DomHarness } from "../test-support/dom";
25
24
  import { type HttpMock, httpError, mockFetch } from "../test-support/http";
@@ -32,19 +31,12 @@ import {
32
31
  let harness: DomHarness | undefined;
33
32
  let http: HttpMock | undefined;
34
33
 
35
- function installMemoryStorage(): void {
36
- const store = new Map<string, string>();
37
- globalThis.localStorage = {
38
- getItem: (k: string) => store.get(k) ?? null,
39
- setItem: (k: string, v: string) => void store.set(k, v),
40
- removeItem: (k: string) => void store.delete(k),
41
- clear: () => store.clear(),
42
- key: () => null,
43
- length: 0,
44
- } as Storage;
45
- }
46
-
47
- beforeEach(installMemoryStorage);
34
+ // The browser global, as the app would find it. Present so that "a reload
35
+ // resumes nothing" is a claim about the app rather than about the harness.
36
+ beforeEach(() => {
37
+ globalThis.localStorage = globalThis.window.localStorage;
38
+ localStorage.clear();
39
+ });
48
40
 
49
41
  afterEach(() => {
50
42
  http?.restore();
@@ -84,6 +76,8 @@ const available: RemitImapSystemUpdateResponse = {
84
76
  };
85
77
 
86
78
  const updateKey = systemOperationsGetSystemUpdateQueryKey();
79
+ const BUDGET_MS =
80
+ (APPLY_BUDGET_SECONDS + NEVER_CAME_BACK_MARGIN_SECONDS) * 1000;
87
81
 
88
82
  async function settle(dom: DomHarness): Promise<void> {
89
83
  for (let attempt = 0; attempt < 40; attempt += 1) {
@@ -105,6 +99,55 @@ async function settle(dom: DomHarness): Promise<void> {
105
99
  }
106
100
  }
107
101
 
102
+ /**
103
+ * Mount the provider with a probe on the hook, so a test can drive `install`
104
+ * and read the surface back. The POST answers with a run in flight, which is
105
+ * what the real seam returns the moment it accepts the request.
106
+ */
107
+ function mountApi(
108
+ getResponse: () => unknown,
109
+ children: ReactNode = null,
110
+ ): { dom: DomHarness; api: () => SelfUpdateApi } {
111
+ http = mockFetch((call) => {
112
+ if (call.path.endsWith("/system/update") && call.method === "GET") {
113
+ return getResponse();
114
+ }
115
+ return {
116
+ currentVersion: "0.9.3",
117
+ check: { status: "ok", updateAvailable: true, latestVersion: "0.9.4" },
118
+ run: run({ outcome: null }),
119
+ };
120
+ });
121
+ let captured: SelfUpdateApi | undefined;
122
+ const Probe = () => {
123
+ captured = useSelfUpdate();
124
+ return null;
125
+ };
126
+ harness = createDomHarness();
127
+ harness.renderApp(
128
+ createElement(SelfUpdateProvider, null, createElement(Probe), children),
129
+ );
130
+ return {
131
+ dom: harness,
132
+ api: () => {
133
+ if (!captured) throw new Error("hook not mounted");
134
+ return captured;
135
+ },
136
+ };
137
+ }
138
+
139
+ async function startUpdate(
140
+ dom: DomHarness,
141
+ api: () => SelfUpdateApi,
142
+ ): Promise<void> {
143
+ await act(async () => {
144
+ api().install("0.9.4");
145
+ await dom.flush();
146
+ await dom.wait(1);
147
+ await dom.flush();
148
+ });
149
+ }
150
+
108
151
  async function renderSurface(
109
152
  getResponse: () => unknown,
110
153
  children: ReactNode,
@@ -209,41 +252,122 @@ describe("SelfUpdateOverlay — the blocking screen", () => {
209
252
  assert.match(dom.html(), /Installing Remit 0\.9\.4/);
210
253
  });
211
254
 
212
- test("a held run resumes into applying when the request fails", async () => {
213
- saveHeldRun({
214
- runId: "upd_1",
215
- attemptedVersion: "0.9.4",
216
- previousVersion: "0.9.3",
217
- startedAt: Date.now() - 20_000,
218
- });
219
- const dom = await renderSurface(() => {
220
- throw new Error("connection refused");
255
+ test("a page that has heard nothing yet says nothing at all", async () => {
256
+ http = mockFetch(() => new Promise(() => {}));
257
+ harness = createDomHarness();
258
+ harness.renderApp(
259
+ createElement(
260
+ SelfUpdateProvider,
261
+ null,
262
+ createElement(SelfUpdateOverlay),
263
+ createElement(SelfUpdatePanel),
264
+ ),
265
+ );
266
+ await harness.flush();
267
+ await harness.wait(1);
268
+ await harness.flush();
269
+
270
+ assert.equal(harness.html(), "");
271
+ });
272
+
273
+ test("the page that asked for the update rides out a failed request", async () => {
274
+ let failing = false;
275
+ const { dom, api } = mountApi(() => {
276
+ if (failing) throw new Error("connection refused");
277
+ return available;
221
278
  }, createElement(SelfUpdateOverlay));
279
+ await settle(dom);
280
+ await startUpdate(dom, api);
281
+
282
+ failing = true;
283
+ await act(async () => {
284
+ api().onRetryConnection();
285
+ await dom.flush();
286
+ await dom.wait(1);
287
+ await dom.flush();
288
+ });
289
+
290
+ const surface = api().surface;
291
+ assert.equal(
292
+ surface.status === "ready" &&
293
+ surface.section.status === "applying" &&
294
+ surface.section.phase,
295
+ "reconnecting",
296
+ );
222
297
  assert.match(dom.html(), /Installing Remit 0\.9\.4/);
223
298
  assert.doesNotMatch(dom.html(), /has not answered since the restart/);
224
299
  });
225
300
 
226
- test("the budget elapsing flips a held run to never-came-back", async () => {
227
- saveHeldRun({
228
- runId: "upd_1",
229
- attemptedVersion: "0.9.4",
230
- previousVersion: "0.9.3",
231
- startedAt: Date.now() - 60 * 60_000,
232
- });
233
- const dom = await renderSurface(() => {
234
- throw new Error("connection refused");
301
+ test("a silence past the apply budget stops claiming an install is running", async () => {
302
+ let failing = false;
303
+ const { dom, api } = mountApi(() => {
304
+ if (failing) throw new Error("connection refused");
305
+ return available;
235
306
  }, createElement(SelfUpdateOverlay));
236
- assert.match(dom.html(), /has not answered since the restart/);
237
- assert.match(dom.html(), /remit logs/);
307
+ await settle(dom);
308
+ await startUpdate(dom, api);
309
+
310
+ failing = true;
311
+ const realNow = Date.now;
312
+ Date.now = () => realNow() + BUDGET_MS + 60_000;
313
+ try {
314
+ await act(async () => {
315
+ api().onRetryConnection();
316
+ await dom.flush();
317
+ await dom.wait(1);
318
+ await dom.flush();
319
+ });
320
+ assert.doesNotMatch(dom.html(), /Installing Remit 0\.9\.4/);
321
+ assert.match(dom.html(), /has not answered since the restart/);
322
+ assert.match(dom.html(), /remit logs/);
323
+ } finally {
324
+ Date.now = realNow;
325
+ }
238
326
  });
239
327
 
240
- test("a poll that never answers gives up at the budget too", async () => {
241
- saveHeldRun({
242
- runId: "upd_1",
243
- attemptedVersion: "0.9.4",
244
- previousVersion: "0.9.3",
245
- startedAt: Date.now() - 60 * 60_000,
328
+ test("a run the server reports finished ends the wait", async () => {
329
+ let finished = false;
330
+ const { dom, api } = mountApi(
331
+ () =>
332
+ finished
333
+ ? {
334
+ currentVersion: "0.9.4",
335
+ check: { status: "ok", updateAvailable: false },
336
+ run: run({ outcome: "succeeded" }),
337
+ }
338
+ : available,
339
+ createElement(SelfUpdateOverlay),
340
+ );
341
+ await settle(dom);
342
+ await startUpdate(dom, api);
343
+
344
+ finished = true;
345
+ await act(async () => {
346
+ api().onRetryConnection();
347
+ await dom.flush();
348
+ await dom.wait(1);
349
+ await dom.flush();
246
350
  });
351
+
352
+ const surface = api().surface;
353
+ assert.equal(
354
+ surface.status === "ready" && surface.section.status,
355
+ "succeeded",
356
+ );
357
+ assert.equal(dom.html(), "");
358
+ });
359
+
360
+ test("a reload during the silence resumes nothing", async () => {
361
+ const { dom, api } = mountApi(
362
+ () => available,
363
+ createElement(SelfUpdateOverlay),
364
+ );
365
+ await settle(dom);
366
+ await startUpdate(dom, api);
367
+ assert.match(dom.html(), /Installing Remit 0\.9\.4/);
368
+
369
+ http?.restore();
370
+ harness?.close();
247
371
  http = mockFetch(() => new Promise(() => {}));
248
372
  harness = createDomHarness();
249
373
  harness.renderApp(
@@ -253,65 +377,28 @@ describe("SelfUpdateOverlay — the blocking screen", () => {
253
377
  await harness.wait(1);
254
378
  await harness.flush();
255
379
 
256
- assert.doesNotMatch(harness.html(), /Installing Remit 0\.9\.4/);
257
- assert.match(harness.html(), /has not answered since the restart/);
258
- assert.match(harness.html(), /remit logs/);
259
- assert.equal(loadHeldRun(), null);
380
+ assert.equal(harness.html(), "");
260
381
  });
261
382
  });
262
383
 
263
384
  describe("useSystemUpdate — actions", () => {
264
- function mountApi(getResponse: () => unknown): {
265
- dom: DomHarness;
266
- api: () => SelfUpdateApi;
267
- } {
268
- http = mockFetch((call) => {
269
- if (call.path.endsWith("/system/update") && call.method === "GET") {
270
- return getResponse();
271
- }
272
- return {
273
- currentVersion: "0.9.3",
274
- check: { status: "ok", updateAvailable: true, latestVersion: "0.9.4" },
275
- run: run({ outcome: null }),
276
- };
277
- });
278
- let captured: SelfUpdateApi | undefined;
279
- const Probe = () => {
280
- captured = useSelfUpdate();
281
- return null;
282
- };
283
- harness = createDomHarness();
284
- harness.renderApp(
285
- createElement(SelfUpdateProvider, null, createElement(Probe)),
286
- );
287
- return {
288
- dom: harness,
289
- api: () => {
290
- if (!captured) throw new Error("hook not mounted");
291
- return captured;
292
- },
293
- };
294
- }
295
-
296
- test("install persists the run id the surface returns", async () => {
385
+ test("install takes the run from the surface and writes nothing down", async () => {
297
386
  const { dom, api } = mountApi(() => available);
298
387
  await settle(dom);
388
+ await startUpdate(dom, api);
299
389
 
300
- await act(async () => {
301
- api().install("0.9.4");
302
- await dom.flush();
303
- await dom.wait(1);
304
- await dom.flush();
305
- });
306
-
307
- const held = loadHeldRun();
308
- assert.equal(held?.runId, "upd_1");
309
- assert.equal(localStorage.getItem(SELF_UPDATE_RUN_KEY) !== null, true);
310
390
  const surface = api().surface;
311
391
  assert.equal(
312
392
  surface.status === "ready" && surface.overlay.kind,
313
393
  "applying",
314
394
  );
395
+ assert.equal(
396
+ surface.status === "ready" &&
397
+ surface.section.status === "applying" &&
398
+ surface.section.runId,
399
+ "upd_1",
400
+ );
401
+ assert.equal(localStorage.length, 0);
315
402
  });
316
403
 
317
404
  test("dismissing a finished result clears the pane", async () => {
@@ -1,14 +1,18 @@
1
1
  /**
2
2
  * The self-update surface, wired to the generated client.
3
3
  *
4
- * A single instance owns the query, the mutation, and the run id the client
5
- * persists across a restart, and hands the folded `SelfUpdateState` plus the
6
- * blocking-overlay state to every consumer through context — so the Advanced
7
- * pane, the root overlay, and the nav dot all read one source of truth. The
8
- * state machine itself lives in `lib/self-update-state.ts`.
4
+ * A single instance owns the query, the mutation, and the run this page asked
5
+ * for, and hands the folded `SelfUpdateState` plus the blocking-overlay state to
6
+ * every consumer through context — so the Advanced pane, the root overlay, and
7
+ * the nav dot all read one source of truth. The state machine itself lives in
8
+ * `lib/self-update-state.ts`.
9
+ *
10
+ * The held run lives here and nowhere else. A restart does not reload the page,
11
+ * so this state spans the whole window; a page that loads afterwards holds
12
+ * nothing and takes the server's answer as it finds it.
9
13
  *
10
14
  * Polling follows the run: every 30 seconds while idle, every 5 seconds while a
11
- * run is in flight or a persisted run is being resumed.
15
+ * run is in flight or this page is waiting on one it started.
12
16
  */
13
17
  import {
14
18
  systemOperationsApplySystemUpdateMutation,
@@ -33,13 +37,11 @@ import {
33
37
  } from "react";
34
38
  import {
35
39
  appliesSchemaMigration,
36
- clearStoredRun,
37
40
  deriveUpdateSurface,
38
41
  type HeldRun,
39
42
  isSurfaceAbsent,
40
- loadHeldRun,
43
+ mapUpdatePhase,
41
44
  releaseFromCheck,
42
- saveHeldRun,
43
45
  type UpdateSurface,
44
46
  } from "@/lib/self-update-state";
45
47
 
@@ -76,7 +78,7 @@ function pollInterval(
76
78
 
77
79
  export function useSystemUpdate(): SelfUpdateApi {
78
80
  const queryClient = useQueryClient();
79
- const [held, setHeld] = useState<HeldRun | null>(() => loadHeldRun());
81
+ const [held, setHeld] = useState<HeldRun | null>(null);
80
82
  const [dismissedRunId, setDismissedRunId] = useState<string | null>(null);
81
83
  const [checkRequested, setCheckRequested] = useState(false);
82
84
 
@@ -112,11 +114,7 @@ export function useSystemUpdate(): SelfUpdateApi {
112
114
  ? derived.surface.section.runId
113
115
  : null;
114
116
 
115
- const { clearStoredRun: shouldClearStored, releaseHeld } = derived;
116
-
117
- useEffect(() => {
118
- if (shouldClearStored) clearStoredRun();
119
- }, [shouldClearStored]);
117
+ const { releaseHeld } = derived;
120
118
 
121
119
  useEffect(() => {
122
120
  if (releaseHeld) setHeld((current) => (current === null ? current : null));
@@ -143,14 +141,13 @@ export function useSystemUpdate(): SelfUpdateApi {
143
141
  onSuccess: (response: RemitImapSystemUpdateResponse) => {
144
142
  const run = response.run;
145
143
  if (run !== null) {
146
- const record: HeldRun = {
144
+ setHeld({
147
145
  runId: run.runId,
148
146
  attemptedVersion: run.targetVersion,
149
147
  previousVersion: run.fromVersion,
148
+ phase: mapUpdatePhase(run.phase),
150
149
  startedAt: Date.now(),
151
- };
152
- saveHeldRun(record);
153
- setHeld(record);
150
+ });
154
151
  setDismissedRunId(null);
155
152
  }
156
153
  queryClient.setQueryData(
@@ -174,7 +171,6 @@ export function useSystemUpdate(): SelfUpdateApi {
174
171
  const onDismissResult = useCallback(() => {
175
172
  if (shownRunIdRef.current !== null)
176
173
  setDismissedRunId(shownRunIdRef.current);
177
- clearStoredRun();
178
174
  setHeld(null);
179
175
  }, []);
180
176
 
@@ -1,8 +1,9 @@
1
1
  /**
2
- * useOrganizeJob — the back-apply job seam. It reports two failures that are not
3
- * the same fact (#526): a create that never returned a job id, and a status poll
4
- * that could not be read over a job the server is already running. Looking at
5
- * that job again is a separate move from starting one.
2
+ * useOrganizeJob — the back-apply job seam. It reports three failures that are
3
+ * not the same fact (#526, #552): a create that never returned a job id, that
4
+ * same create over a pass that already ran, and a status poll that could not be
5
+ * read over a job the server is already running. Looking at that job again is a
6
+ * separate move from starting one.
6
7
  */
7
8
 
8
9
  import assert from "node:assert/strict";
@@ -75,6 +76,36 @@ const startJob = async (status: () => unknown): Promise<void> => {
75
76
  await settle();
76
77
  };
77
78
 
79
+ const COMPLETED_PASS = {
80
+ organizeJobId: JOB,
81
+ state: "Complete",
82
+ matchedCount: 1284,
83
+ appliedCount: 1200,
84
+ failedCount: 84,
85
+ };
86
+
87
+ /** Run one pass to a finish, then answer the next create with `restart`. */
88
+ const restartAfterPass = async (restart: () => unknown): Promise<void> => {
89
+ let created = false;
90
+ http = mockFetch((call) => {
91
+ if (call.method !== "POST") return COMPLETED_PASS;
92
+ if (created) return restart();
93
+ created = true;
94
+ return { organizeJobId: JOB, state: "Pending" };
95
+ });
96
+ harness = createDomHarness();
97
+ harness.renderApp(createElement(Probe));
98
+ await act(async () => {
99
+ current().start(DRAFT);
100
+ });
101
+ await settle();
102
+ assert.equal(current().isDone, true, "the first pass never finished");
103
+ await act(async () => {
104
+ current().start(DRAFT);
105
+ });
106
+ await settle();
107
+ };
108
+
78
109
  const posts = (): number =>
79
110
  (http?.calls ?? []).filter((call) => call.method === "POST").length;
80
111
 
@@ -128,6 +159,23 @@ describe("useOrganizeJob status reporting", () => {
128
159
  assert.equal(current().progress.matchedCount, 1284);
129
160
  });
130
161
 
162
+ it("reads a create that failed over a finished pass as a restart, with that pass's counts", async () => {
163
+ await restartAfterPass(dropped);
164
+ assert.equal(current().failure?.kind, "restartFailed");
165
+ assert.equal(current().progress.matchedCount, 1284);
166
+ assert.equal(current().progress.appliedCount, 1200);
167
+ assert.equal(current().progress.failedCount, 84);
168
+ assert.equal(current().isDone, true);
169
+ });
170
+
171
+ it("reports a restart that is under way as its own pass, not the one before it", async () => {
172
+ await restartAfterPass(() => new Promise<never>(() => {}));
173
+ assert.equal(current().isStarting, true);
174
+ assert.equal(current().isDone, false);
175
+ assert.equal(current().failure, undefined);
176
+ assert.equal(current().progress.matchedCount, 0);
177
+ });
178
+
131
179
  it("stops reporting a job as running once it reaches a terminal state", async () => {
132
180
  await startJob(() => ({
133
181
  organizeJobId: JOB,
@@ -23,20 +23,28 @@ export interface OrganizeJobProgress {
23
23
  }
24
24
 
25
25
  /**
26
- * Why the job is not reporting, which is two separate facts (#526). A create
27
- * that never returned an id means nothing was started; a status read that
28
- * failed means a job is out there and this client cannot see how far it got.
26
+ * Why the job is not reporting, which is three separate facts (#526, #552). A
27
+ * create that never returned an id means nothing was started; the same create
28
+ * over a pass that already ran means that pass stands and only the retry never
29
+ * left; a status read that failed means a job is out there and this client
30
+ * cannot see how far it got.
29
31
  */
30
32
  export interface OrganizeJobFailure {
31
- kind: "startFailed" | "statusUnreadable";
33
+ kind: "startFailed" | "restartFailed" | "statusUnreadable";
32
34
  error: unknown;
33
35
  }
34
36
 
35
37
  const organizeJobFailure = (
36
38
  createError: unknown,
37
39
  statusError: unknown,
40
+ passAlreadyRun: boolean,
38
41
  ): OrganizeJobFailure | undefined => {
39
- if (createError) return { kind: "startFailed", error: createError };
42
+ if (createError) {
43
+ return {
44
+ kind: passAlreadyRun ? "restartFailed" : "startFailed",
45
+ error: createError,
46
+ };
47
+ }
40
48
  if (statusError) return { kind: "statusUnreadable", error: statusError };
41
49
  return undefined;
42
50
  };
@@ -76,7 +84,6 @@ export const useOrganizeJob = (accountId: string | undefined) => {
76
84
  const start = useCallback(
77
85
  (draft: OrganizeDraft) => {
78
86
  if (!accountId) return;
79
- setOrganizeJobId(undefined);
80
87
  createJob({
81
88
  path: { accountId },
82
89
  body: buildOrganizeInput(draft),
@@ -92,7 +99,11 @@ export const useOrganizeJob = (accountId: string | undefined) => {
92
99
  void refetch();
93
100
  }, [refetch]);
94
101
 
95
- const job = jobQuery.data;
102
+ // The last pass this client read. A restart replaces it only once the server
103
+ // hands back a job id: while the create is in flight those counts are not this
104
+ // pass's, and a create that fails leaves them standing (#552).
105
+ const lastPass = jobQuery.data;
106
+ const job = createMutation.isPending ? undefined : lastPass;
96
107
  const state = job?.state ?? createMutation.data?.state;
97
108
  const isDone = isTerminalJobState(job?.state);
98
109
 
@@ -111,6 +122,10 @@ export const useOrganizeJob = (accountId: string | undefined) => {
111
122
  isStarting: createMutation.isPending,
112
123
  isRunning: !!organizeJobId && !isDone,
113
124
  isDone,
114
- failure: organizeJobFailure(createMutation.error, jobQuery.error),
125
+ failure: organizeJobFailure(
126
+ createMutation.error,
127
+ jobQuery.error,
128
+ !!lastPass,
129
+ ),
115
130
  };
116
131
  };
@@ -1,5 +1,5 @@
1
1
  import assert from "node:assert/strict";
2
- import { beforeEach, describe, test } from "node:test";
2
+ import { describe, test } from "node:test";
3
3
  import type {
4
4
  RemitImapSystemUpdateResponse,
5
5
  RemitImapSystemUpdateRun,
@@ -8,17 +8,13 @@ import { ApiError } from "./api";
8
8
  import {
9
9
  APPLY_BUDGET_SECONDS,
10
10
  appliesSchemaMigration,
11
- clearStoredRun,
12
11
  type DeriveInput,
13
12
  deriveUpdateSurface,
14
13
  type HeldRun,
15
14
  isSurfaceAbsent,
16
- loadHeldRun,
17
15
  mapUpdatePhase,
18
16
  NEVER_CAME_BACK_MARGIN_SECONDS,
19
17
  releaseFromCheck,
20
- SELF_UPDATE_RUN_KEY,
21
- saveHeldRun,
22
18
  } from "./self-update-state";
23
19
 
24
20
  const NOW = Date.parse("2026-07-20T12:00:00.000Z");
@@ -60,6 +56,7 @@ function held(overrides: Partial<HeldRun> = {}): HeldRun {
60
56
  runId: "upd_1",
61
57
  attemptedVersion: "0.9.4",
62
58
  previousVersion: "0.9.3",
59
+ phase: "preparing",
63
60
  startedAt: NOW - 10_000,
64
61
  ...overrides,
65
62
  };
@@ -279,7 +276,7 @@ describe("deriveUpdateSurface — check and run stay independent", () => {
279
276
  });
280
277
 
281
278
  describe("deriveUpdateSurface — terminal outcomes", () => {
282
- test("a rolledBack run renders message and command verbatim and clears the id", () => {
279
+ test("a rolledBack run renders message and command verbatim", () => {
283
280
  const result = deriveUpdateSurface(
284
281
  input({
285
282
  data: response({
@@ -291,7 +288,6 @@ describe("deriveUpdateSurface — terminal outcomes", () => {
291
288
  }),
292
289
  }),
293
290
  );
294
- assert.equal(result.clearStoredRun, true);
295
291
  assert.equal(result.surface.status, "ready");
296
292
  if (result.surface.status !== "ready") return;
297
293
  const section = result.surface.section;
@@ -374,7 +370,11 @@ describe("deriveUpdateSurface — a held run across a restart", () => {
374
370
  if (result.surface.status !== "ready") return;
375
371
  assert.equal(result.surface.section.status, "applying");
376
372
  assert.equal(result.surface.overlay.kind, "applying");
377
- assert.equal(result.clearStoredRun, false);
373
+ assert.equal(
374
+ result.surface.overlay.kind === "applying" &&
375
+ result.surface.overlay.phase,
376
+ "reconnecting",
377
+ );
378
378
  });
379
379
 
380
380
  test("a failed request without a held run is a check-level failure", () => {
@@ -402,7 +402,6 @@ describe("deriveUpdateSurface — a held run across a restart", () => {
402
402
  assert.equal(result.surface.status, "ready");
403
403
  if (result.surface.status !== "ready") return;
404
404
  assert.equal(result.surface.overlay.kind, "neverCameBack");
405
- assert.equal(result.clearStoredRun, true);
406
405
  });
407
406
 
408
407
  test("never-came-back never claims the rollback ran", () => {
@@ -449,7 +448,6 @@ describe("deriveUpdateSurface — a held run across a restart", () => {
449
448
  }),
450
449
  );
451
450
  assert.equal(result.releaseHeld, true);
452
- assert.equal(result.clearStoredRun, true);
453
451
  assert.equal(
454
452
  result.surface.status === "ready" && result.surface.section.status,
455
453
  "succeeded",
@@ -470,72 +468,33 @@ describe("deriveUpdateSurface — a held run across a restart", () => {
470
468
  assert.equal(result.releaseHeld, true);
471
469
  });
472
470
 
473
- test("a first poll still in flight keeps applying inside the budget", () => {
471
+ test("a poll with no answer yet claims no phase at all", () => {
474
472
  const result = deriveUpdateSurface(
475
473
  input({ held: held({ startedAt: NOW - 20_000 }) }),
476
474
  );
477
- if (
478
- result.surface.status !== "ready" ||
479
- result.surface.overlay.kind !== "applying"
480
- ) {
481
- assert.fail("expected the applying overlay");
482
- }
483
- assert.equal(result.surface.overlay.phase, "preparing");
484
- assert.equal(result.clearStoredRun, false);
475
+ assert.equal(result.surface.status, "loading");
485
476
  });
486
477
 
487
- test("a poll that never answers gives up at the budget", () => {
478
+ test("a poll that never answers still claims no phase past the budget", () => {
488
479
  const result = deriveUpdateSurface(
489
480
  input({ held: held({ startedAt: NOW - BUDGET_MS - 60_000 }) }),
490
481
  );
491
- assert.equal(result.surface.status, "ready");
492
- if (result.surface.status !== "ready") return;
493
- assert.equal(result.surface.overlay.kind, "neverCameBack");
494
- assert.equal(result.clearStoredRun, true);
482
+ assert.equal(result.surface.status, "loading");
495
483
  });
496
484
 
497
- test("an early server answer with no run yet keeps applying", () => {
485
+ test("the server's own account of the run it accepted carries the phase", () => {
498
486
  const result = deriveUpdateSurface(
499
- input({ data: response({ run: null }), held: held() }),
500
- );
501
- assert.equal(
502
- result.surface.status === "ready" && result.surface.overlay.kind,
503
- "applying",
504
- );
505
- });
506
- });
507
-
508
- function installMemoryStorage(): void {
509
- const store = new Map<string, string>();
510
- globalThis.localStorage = {
511
- getItem: (k: string) => store.get(k) ?? null,
512
- setItem: (k: string, v: string) => void store.set(k, v),
513
- removeItem: (k: string) => void store.delete(k),
514
- clear: () => store.clear(),
515
- key: () => null,
516
- length: 0,
517
- } as Storage;
518
- }
519
-
520
- describe("held-run persistence", () => {
521
- beforeEach(installMemoryStorage);
522
-
523
- test("saves, loads and clears under the documented key", () => {
524
- const record = held();
525
- saveHeldRun(record);
526
- assert.equal(
527
- localStorage.getItem(SELF_UPDATE_RUN_KEY),
528
- JSON.stringify(record),
487
+ input({
488
+ data: response({ run: null }),
489
+ held: held({ phase: "restarting" }),
490
+ }),
529
491
  );
530
- assert.deepEqual(loadHeldRun(), record);
531
- clearStoredRun();
532
- assert.equal(loadHeldRun(), null);
533
- });
534
-
535
- test("ignores a malformed stored value", () => {
536
- localStorage.setItem(SELF_UPDATE_RUN_KEY, "{not json");
537
- assert.equal(loadHeldRun(), null);
538
- localStorage.setItem(SELF_UPDATE_RUN_KEY, JSON.stringify({ runId: 1 }));
539
- assert.equal(loadHeldRun(), null);
492
+ if (
493
+ result.surface.status !== "ready" ||
494
+ result.surface.overlay.kind !== "applying"
495
+ ) {
496
+ assert.fail("expected the applying overlay");
497
+ }
498
+ assert.equal(result.surface.overlay.phase, "restarting");
540
499
  });
541
500
  });
@@ -3,16 +3,19 @@
3
3
  *
4
4
  * `GET /system/update` returns three independent things: the running version,
5
5
  * the outcome of the last manifest check, and the state of the current or last
6
- * run. This module folds that response — plus a run id the client persisted
7
- * before the server restarted, plus the clock — into the single `SelfUpdateState`
8
- * the `@remit/ui` components render, and into the full-window blocking overlay.
6
+ * run. This module folds that response — plus the run this client started and
7
+ * holds in memory, plus the clock — into the single `SelfUpdateState` the
8
+ * `@remit/ui` components render, and into the full-window blocking overlay.
9
9
  *
10
10
  * The design constraints (RFC 037 Interface, issue #135) live here:
11
- * - A held run id turns a failed request into `applying`, never `unreachable`.
11
+ * - The server decides whether an update is running and how far along it is.
12
+ * Every phase shown is one the server reported for this run; an answer the
13
+ * client is still waiting for renders no phase at all.
14
+ * - A held run turns a failed request into `applying`, never `unreachable`.
12
15
  * - Once the apply budget plus a margin has passed with no answer — a failed
13
- * request or one still in flight that silence becomes "the server never
14
- * came back", a state that never claims the rollback ran, because from a
15
- * dead connection the client cannot know.
16
+ * request, or a server answering without accounting for the run that
17
+ * silence becomes "the server never came back", a state that never claims
18
+ * the rollback ran, because from a dead connection the client cannot know.
16
19
  * - The check block and the run block are independent: a check that cannot
17
20
  * reach the update source is a failed check, never a failed update.
18
21
  */
@@ -25,9 +28,6 @@ import type {
25
28
  import type { ReleaseInfo, SelfUpdateState, UpdatePhase } from "@remit/ui";
26
29
  import { getErrorStatus } from "./error-classifier";
27
30
 
28
- /** localStorage key holding the run the client is resuming across a restart. */
29
- export const SELF_UPDATE_RUN_KEY = "remit.self-update.run";
30
-
31
31
  /**
32
32
  * The longest an apply can plausibly take — pull, snapshot, stop, start, gate —
33
33
  * before a silent server is treated as gone rather than still working. A five
@@ -42,15 +42,17 @@ export const FALLBACK_LOGS_COMMAND = "remit logs";
42
42
  const RELEASE_TAG_BASE = "https://github.com/remit-mail/reader/releases/tag/";
43
43
 
44
44
  /**
45
- * The record the client persists the moment it asks for an update, so a reload
46
- * or a second tab can resume watching a run that outlives the page that started
47
- * it. Everything needed to render the blocking screens without a reachable
48
- * server is here.
45
+ * The run this client asked for, kept in memory for as long as the page that
46
+ * asked lives. Nothing is written to storage: a run lasts about a minute and
47
+ * only the server knows how it ends, so a page that was not there for the
48
+ * request starts from the server's answer instead of from a record of its own.
49
49
  */
50
50
  export interface HeldRun {
51
51
  runId: string;
52
52
  attemptedVersion: string;
53
53
  previousVersion: string;
54
+ /** The phase the server reported when it accepted the run. */
55
+ phase: UpdatePhase;
54
56
  /** Epoch millis when the client began holding this run. */
55
57
  startedAt: number;
56
58
  }
@@ -89,9 +91,7 @@ export interface DeriveInput {
89
91
 
90
92
  export interface DeriveResult {
91
93
  surface: UpdateSurface;
92
- /** The persisted resume token should be removed from localStorage. */
93
- clearStoredRun: boolean;
94
- /** The in-memory held run should be dropped — the run is fully resolved. */
94
+ /** The held run should be dropped the server has accounted for it. */
95
95
  releaseHeld: boolean;
96
96
  }
97
97
 
@@ -159,46 +159,6 @@ export function appliesSchemaMigration(
159
159
  return target > current;
160
160
  }
161
161
 
162
- export function loadHeldRun(): HeldRun | null {
163
- try {
164
- const raw = localStorage.getItem(SELF_UPDATE_RUN_KEY);
165
- if (!raw) return null;
166
- const parsed: unknown = JSON.parse(raw);
167
- if (!isHeldRun(parsed)) return null;
168
- return parsed;
169
- } catch {
170
- return null;
171
- }
172
- }
173
-
174
- export function saveHeldRun(run: HeldRun): void {
175
- try {
176
- localStorage.setItem(SELF_UPDATE_RUN_KEY, JSON.stringify(run));
177
- } catch {
178
- // Best effort: private mode or quota. A lost token degrades resume, not
179
- // correctness — the server remains the authority on the run.
180
- }
181
- }
182
-
183
- export function clearStoredRun(): void {
184
- try {
185
- localStorage.removeItem(SELF_UPDATE_RUN_KEY);
186
- } catch {
187
- // Best effort, as above.
188
- }
189
- }
190
-
191
- function isHeldRun(value: unknown): value is HeldRun {
192
- if (!value || typeof value !== "object") return false;
193
- const candidate = value as Record<string, unknown>;
194
- return (
195
- typeof candidate.runId === "string" &&
196
- typeof candidate.attemptedVersion === "string" &&
197
- typeof candidate.previousVersion === "string" &&
198
- typeof candidate.startedAt === "number"
199
- );
200
- }
201
-
202
162
  function budgetLimitSeconds(): number {
203
163
  return APPLY_BUDGET_SECONDS + NEVER_CAME_BACK_MARGIN_SECONDS;
204
164
  }
@@ -316,20 +276,14 @@ function checkSection(
316
276
  function ready(
317
277
  section: SelfUpdateState,
318
278
  overlay: UpdateOverlay,
319
- clearStoredRun: boolean,
320
279
  releaseHeld: boolean,
321
280
  ): DeriveResult {
322
- return {
323
- surface: { status: "ready", section, overlay },
324
- clearStoredRun,
325
- releaseHeld,
326
- };
281
+ return { surface: { status: "ready", section, overlay }, releaseHeld };
327
282
  }
328
283
 
329
284
  /**
330
- * The client gave up waiting. The stored token goes so a reload cannot resume
331
- * the same dead wait, while the in-memory hold stays: the screen has to sit
332
- * still, and its retry has to keep polling, until the server answers for itself.
285
+ * The client gave up waiting. The hold stays: the screen has to sit still, and
286
+ * its retry has to keep polling, until the server answers for itself.
333
287
  */
334
288
  function neverCameBack(held: HeldRun, elapsedSeconds: number): DeriveResult {
335
289
  return {
@@ -350,15 +304,15 @@ function neverCameBack(held: HeldRun, elapsedSeconds: number): DeriveResult {
350
304
  logsCommand: FALLBACK_LOGS_COMMAND,
351
305
  },
352
306
  },
353
- clearStoredRun: true,
354
307
  releaseHeld: false,
355
308
  };
356
309
  }
357
310
 
358
311
  /**
359
312
  * A held run resolves to one of: still applying, gave up ("never came back"),
360
- * recovered but unaccountable, or — returning `null` — resolved terminally on
361
- * the server, in which case the caller renders the outcome from the response.
313
+ * unaccounted for, still waiting on a first answer, or — returning `null` —
314
+ * resolved terminally on the server, in which case the caller renders the
315
+ * outcome from the response.
362
316
  */
363
317
  function deriveHeld(
364
318
  held: HeldRun,
@@ -385,7 +339,6 @@ function deriveHeld(
385
339
  ),
386
340
  { kind: "applying", target: run.targetVersion, phase, elapsedSeconds },
387
341
  false,
388
- false,
389
342
  );
390
343
  }
391
344
 
@@ -408,35 +361,13 @@ function deriveHeld(
408
361
  elapsedSeconds,
409
362
  },
410
363
  false,
411
- false,
412
364
  );
413
365
  }
414
366
 
415
- // No answer yet the resume request is still in flight. A request that is
416
- // pending looks exactly like one that will never settle, so the budget bounds
417
- // this wait too: within it the client stays applying, past it it stops
418
- // claiming an install is running and says what it cannot account for.
367
+ // Nothing has come back yet. Silence is not a phase, so the surface waits
368
+ // rather than describing an install it has heard nothing about.
419
369
  if (data === undefined) {
420
- if (elapsedSeconds > budgetLimitSeconds()) {
421
- return neverCameBack(held, elapsedSeconds);
422
- }
423
- return ready(
424
- applyingSection(
425
- held.runId,
426
- currentVersion,
427
- held.attemptedVersion,
428
- "preparing",
429
- elapsedSeconds,
430
- ),
431
- {
432
- kind: "applying",
433
- target: held.attemptedVersion,
434
- phase: "preparing",
435
- elapsedSeconds,
436
- },
437
- false,
438
- false,
439
- );
370
+ return { surface: { status: "loading" }, releaseHeld: false };
440
371
  }
441
372
 
442
373
  // The server answered, but not with our run. If we have been gone longer than
@@ -455,55 +386,48 @@ function deriveHeld(
455
386
  },
456
387
  overlay: { kind: "none" },
457
388
  },
458
- clearStoredRun: true,
459
389
  releaseHeld: true,
460
390
  };
461
391
  }
462
392
 
463
- // Early: the server is up but has not written our run yet. Still applying.
464
- const phase =
465
- run !== null && run.outcome === null
466
- ? mapUpdatePhase(run.phase)
467
- : "preparing";
393
+ // The updater picks the request up off a control file, so the seam keeps
394
+ // reporting the previous run for a moment. The phase stays the one the server
395
+ // gave when it accepted this run, until the server reports a newer one.
468
396
  return ready(
469
397
  applyingSection(
470
398
  held.runId,
471
399
  currentVersion,
472
400
  held.attemptedVersion,
473
- phase,
401
+ held.phase,
474
402
  elapsedSeconds,
475
403
  ),
476
- { kind: "applying", target: held.attemptedVersion, phase, elapsedSeconds },
477
- false,
404
+ {
405
+ kind: "applying",
406
+ target: held.attemptedVersion,
407
+ phase: held.phase,
408
+ elapsedSeconds,
409
+ },
478
410
  false,
479
411
  );
480
412
  }
481
413
 
482
- function displayFromData(input: DeriveInput): {
483
- surface: UpdateSurface;
484
- clearStoredRun: boolean;
485
- } {
414
+ function displayFromData(input: DeriveInput): UpdateSurface {
486
415
  const { data, isError, isFetching, dismissedRunId, checkRequested, now } =
487
416
  input;
488
417
 
489
418
  if (isError) {
490
419
  return {
491
- surface: {
492
- status: "ready",
493
- section: {
494
- status: "checkFailed",
495
- version: data?.currentVersion ?? "the current version",
496
- reason: "Remit could not reach the update service.",
497
- },
498
- overlay: { kind: "none" },
420
+ status: "ready",
421
+ section: {
422
+ status: "checkFailed",
423
+ version: data?.currentVersion ?? "the current version",
424
+ reason: "Remit could not reach the update service.",
499
425
  },
500
- clearStoredRun: true,
426
+ overlay: { kind: "none" },
501
427
  };
502
428
  }
503
429
 
504
- if (!data) {
505
- return { surface: { status: "loading" }, clearStoredRun: false };
506
- }
430
+ if (!data) return { status: "loading" };
507
431
 
508
432
  const run = data.run;
509
433
  const dismissed =
@@ -511,12 +435,9 @@ function displayFromData(input: DeriveInput): {
511
435
 
512
436
  if (run !== null && !dismissed && run.outcome !== null) {
513
437
  return {
514
- surface: {
515
- status: "ready",
516
- section: terminalSection(data, run, run.outcome),
517
- overlay: { kind: "none" },
518
- },
519
- clearStoredRun: true,
438
+ status: "ready",
439
+ section: terminalSection(data, run, run.outcome),
440
+ overlay: { kind: "none" },
520
441
  };
521
442
  }
522
443
 
@@ -524,33 +445,27 @@ function displayFromData(input: DeriveInput): {
524
445
  const elapsedSeconds = elapsedSince(parseIso(run.startedAt) ?? now, now);
525
446
  const phase = mapUpdatePhase(run.phase);
526
447
  return {
527
- surface: {
528
- status: "ready",
529
- section: applyingSection(
530
- run.runId,
531
- run.fromVersion,
532
- run.targetVersion,
533
- phase,
534
- elapsedSeconds,
535
- ),
536
- overlay: {
537
- kind: "applying",
538
- target: run.targetVersion,
539
- phase,
540
- elapsedSeconds,
541
- },
448
+ status: "ready",
449
+ section: applyingSection(
450
+ run.runId,
451
+ run.fromVersion,
452
+ run.targetVersion,
453
+ phase,
454
+ elapsedSeconds,
455
+ ),
456
+ overlay: {
457
+ kind: "applying",
458
+ target: run.targetVersion,
459
+ phase,
460
+ elapsedSeconds,
542
461
  },
543
- clearStoredRun: false,
544
462
  };
545
463
  }
546
464
 
547
465
  return {
548
- surface: {
549
- status: "ready",
550
- section: checkSection(data, checkRequested && isFetching, now),
551
- overlay: { kind: "none" },
552
- },
553
- clearStoredRun: false,
466
+ status: "ready",
467
+ section: checkSection(data, checkRequested && isFetching, now),
468
+ overlay: { kind: "none" },
554
469
  };
555
470
  }
556
471
 
@@ -559,29 +474,15 @@ export function deriveUpdateSurface(input: DeriveInput): DeriveResult {
559
474
  const run = data?.run ?? null;
560
475
 
561
476
  if (isSurfaceAbsent(error) && !held) {
562
- return {
563
- surface: { status: "absent" },
564
- clearStoredRun: true,
565
- releaseHeld: true,
566
- };
477
+ return { surface: { status: "absent" }, releaseHeld: true };
567
478
  }
568
479
 
569
480
  if (held) {
570
481
  const heldResult = deriveHeld(held, data, run, isError, now);
571
482
  if (heldResult) return heldResult;
572
483
  // Our run resolved terminally — render it from the response and let go.
573
- const resolved = displayFromData(input);
574
- return {
575
- surface: resolved.surface,
576
- clearStoredRun: true,
577
- releaseHeld: true,
578
- };
484
+ return { surface: displayFromData(input), releaseHeld: true };
579
485
  }
580
486
 
581
- const display = displayFromData(input);
582
- return {
583
- surface: display.surface,
584
- clearStoredRun: display.clearStoredRun,
585
- releaseHeld: false,
586
- };
487
+ return { surface: displayFromData(input), releaseHeld: false };
587
488
  }