@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 +1 -1
- package/src/components/mail/SelectionWizardHost.tsx +4 -1
- package/src/components/mail/organize-run-state.test.ts +18 -0
- package/src/components/mail/organize-run-state.ts +5 -0
- package/src/components/self-update/SelfUpdateOverlay.tsx +2 -2
- package/src/hooks/use-system-update.test.ts +179 -92
- package/src/hooks/use-system-update.ts +16 -20
- package/src/hooks/useOrganizeJob.render.test.ts +52 -4
- package/src/hooks/useOrganizeJob.ts +23 -8
- package/src/lib/self-update-state.test.ts +24 -65
- package/src/lib/self-update-state.ts +66 -165
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@remit/web-client",
|
|
3
|
-
"version": "0.0.
|
|
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:
|
|
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,
|
|
8
|
-
*
|
|
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:
|
|
5
|
-
*
|
|
6
|
-
*
|
|
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
|
-
|
|
21
|
-
|
|
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
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
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
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
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
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
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
|
-
|
|
237
|
-
|
|
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
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
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
|
|
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
|
-
|
|
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>(
|
|
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 {
|
|
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
|
-
|
|
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
|
|
3
|
-
* the same fact (#526): a create that never returned a job id,
|
|
4
|
-
*
|
|
5
|
-
*
|
|
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
|
|
27
|
-
* that never returned an id means nothing was started;
|
|
28
|
-
*
|
|
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)
|
|
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
|
-
|
|
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(
|
|
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 {
|
|
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
|
|
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(
|
|
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
|
|
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
|
-
|
|
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
|
|
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, "
|
|
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("
|
|
485
|
+
test("the server's own account of the run it accepted carries the phase", () => {
|
|
498
486
|
const result = deriveUpdateSurface(
|
|
499
|
-
input({
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
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
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
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
|
|
7
|
-
*
|
|
8
|
-
*
|
|
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
|
-
* -
|
|
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
|
|
14
|
-
* came back", a state that never claims
|
|
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
|
|
46
|
-
*
|
|
47
|
-
* it
|
|
48
|
-
* server
|
|
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
|
|
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
|
|
331
|
-
*
|
|
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
|
-
*
|
|
361
|
-
* the server, in which case the caller renders the
|
|
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
|
-
//
|
|
416
|
-
//
|
|
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
|
-
|
|
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
|
-
//
|
|
464
|
-
|
|
465
|
-
|
|
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
|
-
{
|
|
477
|
-
|
|
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
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
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
|
-
|
|
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
|
-
|
|
515
|
-
|
|
516
|
-
|
|
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
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
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
|
-
|
|
549
|
-
|
|
550
|
-
|
|
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
|
-
|
|
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
|
-
|
|
582
|
-
return {
|
|
583
|
-
surface: display.surface,
|
|
584
|
-
clearStoredRun: display.clearStoredRun,
|
|
585
|
-
releaseHeld: false,
|
|
586
|
-
};
|
|
487
|
+
return { surface: displayFromData(input), releaseHeld: false };
|
|
587
488
|
}
|