@graphty/visual-review 0.2.1 → 0.2.2

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.
@@ -15,18 +15,60 @@
15
15
  * iPad) keeps one request open. POST /api/finish starts it and GET /api/finish-status reports its
16
16
  * step, then its result or error, so a reload finds the running Finish. The job is also written
17
17
  * to `<tmp>/state/finish.json`, so a server restarted during a Finish says it was interrupted.
18
+ *
19
+ * Passkeys: once any key is known (passkeys.json on the fetched default branch, or one this server
20
+ * registered whose pull request is not merged yet, `<tmp>/state/passkeys-pending.json`), Finish
21
+ * needs the owner's approval. A pending key approves only while the default branch holds none: once
22
+ * it does, only its keys count, as in the gate, so a key registered later (by anyone who can reach
23
+ * this page) approves nothing until its pull request merges. POST /api/finish-prepare builds the
24
+ * record and returns its hash as the WebAuthn challenge; POST /api/finish takes the approval,
25
+ * checks it, and commits exactly that record. The server decides whether approval is required, so
26
+ * leaving it out never skips it. The rpId is the host name of the page's own origin.
27
+ *
28
+ * The list of targets is cached: `GET /api/prs?cached=1` answers at once with the cache, its age,
29
+ * the progress of a refresh, why the last one failed and any network retry under way, and
30
+ * `?refresh=1` starts one in the background, so the page never waits on GitHub to change screens.
31
+ * The list is also kept in `<tmp>/state/list.json`, so a restarted server shows it at once and
32
+ * refreshes it behind. With `warm` the server refreshes at start and every two minutes, so the
33
+ * captures of a CI run that just finished are downloaded before the owner opens them. A target
34
+ * whose captures are still downloading is listed with `downloading: true` and its progress, and
35
+ * rebuilt in the cache when they land; `GET /api/pr/...` of a project still downloading answers
36
+ * 202 and moves its download to the front of the queue. What GitHub says about a finished run
37
+ * (its jobs and artifacts) is asked once and kept beside its downloads. Grid tiles load
38
+ * thumbnails (`GET /api/thumb/...`), scaled down in worker threads as soon as a capture lands and
39
+ * kept on disk.
18
40
  */
19
41
 
20
42
  import { createHash, randomBytes, timingSafeEqual } from "node:crypto";
21
43
  import { existsSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
22
44
  import { readFile } from "node:fs/promises";
23
- import { dirname, join } from "node:path";
45
+ import { basename, dirname, join } from "node:path";
24
46
  import { fileURLToPath } from "node:url";
25
47
 
26
- import { AcceptError, behindMaster, cleanReason, decisionProblem, finish } from "./accept.mjs";
27
- import { downloadCaptures, exec, getRun, newestCiRun, openPullRequests, visualJobs } from "./github.mjs";
48
+ import {
49
+ AcceptError,
50
+ behindMaster,
51
+ cleanReason,
52
+ commitStatus,
53
+ decisionProblem,
54
+ finish,
55
+ prepareRecord,
56
+ proposeKey,
57
+ } from "./accept.mjs";
58
+ import { PASSKEYS_FILE, parsePasskeys, recordHash, verifyApproval, verifyRegistration } from "./approval.mjs";
59
+ import {
60
+ downloadCaptures,
61
+ exec,
62
+ getRun,
63
+ hurry,
64
+ newestCiRun,
65
+ openPullRequests,
66
+ retrying,
67
+ visualJobs,
68
+ } from "./github.mjs";
28
69
  import { CONFIG_FILE } from "./config.mjs";
29
70
  import { validateResults } from "./results.mjs";
71
+ import { scaled } from "./thumbs.mjs";
30
72
 
31
73
  const HERE = dirname(fileURLToPath(import.meta.url));
32
74
  const STATIC = {
@@ -34,6 +76,7 @@ const STATIC = {
34
76
  "/review.js": ["../page/review.js", "text/javascript; charset=utf-8"],
35
77
  "/review.css": ["../page/review.css", "text/css; charset=utf-8"],
36
78
  "/pixelmatch.mjs": ["../vendor/pixelmatch.mjs", "text/javascript; charset=utf-8"],
79
+ "/passkey.js": ["../page/passkey.js", "text/javascript; charset=utf-8"],
37
80
  };
38
81
  const HEADERS = {
39
82
  "content-security-policy":
@@ -50,13 +93,25 @@ const HEADERS = {
50
93
  const componentOf = (id) => id.split("--")[0];
51
94
 
52
95
  const REVIEWABLE = new Set(["changed", "moved", "new", "unseeded", "removed", "unstable", "failed"]);
53
- const WRITES = new Set(["decide", "accept-all", "finish"]);
96
+ const WRITES = new Set(["decide", "accept-all", "finish", "finish-prepare", "passkey-challenge", "register"]);
97
+ // A registration challenge is good for one use within this long; a prepared approval for this long.
98
+ const CHALLENGE_MS = 5 * 60000;
99
+ const APPROVAL_MS = 10 * 60000;
54
100
  // How long a page load waits for a run's captures to download before it lists the target as
55
101
  // downloading; the download goes on, and a reload picks it up.
56
102
  const PATIENCE = 1000;
57
103
  // An unknown target id refreshes from GitHub at most this often: a closed pull request's open
58
104
  // grid asks for dozens of images at once.
59
105
  const UNKNOWN_REFRESH = 60000;
106
+ // With `warm`, the list is refreshed this often, so a CI run that finished meanwhile is downloaded
107
+ // before the owner opens it.
108
+ const POLL = 2 * 60000;
109
+
110
+ /**
111
+ * The newest gh call waiting to retry after a network failure, for the page.
112
+ * @returns {{ error: string, attempt: number, of: number, until: number } | null} it, or null
113
+ */
114
+ const networkTrouble = () => [...retrying].at(-1) ?? null;
60
115
 
61
116
  /**
62
117
  * The image a decision was taken on: the capture, or for a removed item its baseline.
@@ -246,6 +301,71 @@ export function createApp({ repo, gh, config, tmp, token, origin, masterRun, res
246
301
  }
247
302
  };
248
303
  let signer = null;
304
+ const rpId = new URL(origin).hostname;
305
+ const pendingFile = join(stateDir, "passkeys-pending.json");
306
+ /** Registration challenges (base64url) and when each expires. */
307
+ const challenges = new Map();
308
+ /**
309
+ * The record each target's Finish was prepared with, waiting for its approval.
310
+ * @type {Map<string, { challenge: string, record: object, work: object, digest: string,
311
+ * expires: number }>}
312
+ */
313
+ const approvals = new Map();
314
+ // Fails closed: only a missing file means no pending key; any other failure throws.
315
+ const readPending = () => {
316
+ let text;
317
+ try {
318
+ text = readFileSync(pendingFile, "utf8");
319
+ } catch (err) {
320
+ if (err.code === "ENOENT") {
321
+ return [];
322
+ }
323
+ throw err;
324
+ }
325
+ try {
326
+ return parsePasskeys(text);
327
+ } catch (err) {
328
+ throw new Error(`${pendingFile} is invalid: ${err.message}`);
329
+ }
330
+ };
331
+ /**
332
+ * The keys an approval may come from: the fetched default branch's passkeys.json, and the
333
+ * keys this server registered that it does not hold yet. A git failure or an invalid file
334
+ * throws: approval is never skipped because a key could not be read. `trusted` is what may
335
+ * approve: the default branch's keys, or the pending ones while it has none.
336
+ * @returns {Promise<{ main: object[], pending: object[], trusted: object[] }>} the keys
337
+ */
338
+ async function knownKeys() {
339
+ const ref = `refs/remotes/origin/${defaultBranch}`;
340
+ const git = (args) => exec("git", args, { cwd: repo });
341
+ const listed = await git(["ls-tree", "--name-only", ref, "--", PASSKEYS_FILE]);
342
+ const text = listed === "" ? null : await git(["show", `${ref}:${PASSKEYS_FILE}`]);
343
+ let main = [];
344
+ try {
345
+ main = text === null ? [] : parsePasskeys(text);
346
+ } catch (err) {
347
+ throw new Error(`${PASSKEYS_FILE} on ${defaultBranch} is invalid: ${err.message}`);
348
+ }
349
+ const ids = new Set(main.map((k) => k.id));
350
+ const pending = readPending().filter((k) => !ids.has(k.id));
351
+ return { main, pending, trusted: main.length > 0 ? main : pending };
352
+ }
353
+ /** The passkey state the targets screen shows, read when the target list refreshes. */
354
+ let passkeyState = { main: [], pending: [], problem: null };
355
+ const readPasskeys = async () => {
356
+ if (results) {
357
+ return;
358
+ }
359
+ try {
360
+ passkeyState = { ...(await knownKeys()), problem: null };
361
+ } catch (err) {
362
+ passkeyState = { main: [], pending: [], problem: err.message };
363
+ }
364
+ };
365
+ /** An open pull request that registers a passkey, waiting for the owner to merge it. */
366
+ let passkeyPr = null;
367
+ /** The passkey pull request this server opened: listed until GitHub's list or the file has it. */
368
+ let passkeyOpened = null;
249
369
  const busy = (t) => finishing && job?.target === t.id;
250
370
  const BUSY = "a Finish is running on this target: wait for it to end";
251
371
 
@@ -341,37 +461,120 @@ export function createApp({ repo, gh, config, tmp, token, origin, masterRun, res
341
461
  // `problem` is what CI said (the job failed, or no artifact); results.json can add its own.
342
462
  async function project(name, dir, problem) {
343
463
  const loaded = dir ? await loadResults(dir) : { results: null, problem: null };
464
+ if (loaded.results) {
465
+ prewarm(dir, loaded.results);
466
+ }
344
467
  return { project: name, dir, results: loaded.results, problem: problem ?? loaded.problem, logUrl: null };
345
468
  }
346
469
 
470
+ /**
471
+ * The gh runner for one finished run: what GitHub says about it (its jobs, its artifacts) no
472
+ * longer changes, so each answer is asked once and kept on disk beside the run's downloads,
473
+ * and pruned with them. A refresh then asks GitHub only for the pull requests and their newest
474
+ * runs. ponytail: an artifact that expires later is still listed as there; its download fails
475
+ * and says so, which only matters for a capture never downloaded in its 90 days.
476
+ * @param {{ id: number, attempt: number }} run the finished run
477
+ * @returns {Function} the gh runner
478
+ */
479
+ const kept = (run) => async (args, input) => {
480
+ if (args[0] !== "api") {
481
+ return gh(args, input);
482
+ }
483
+ const name = createHash("sha256").update(args.join(" ")).digest("hex").slice(0, 16);
484
+ const file = join(tmp, `${run.id}-${run.attempt}`, "gh", `${name}.json`);
485
+ const saved = await readFile(file, "utf8").catch(() => null);
486
+ if (saved !== null) {
487
+ return saved;
488
+ }
489
+ const answer = await gh(args, input);
490
+ try {
491
+ mkdirSync(dirname(file), { recursive: true });
492
+ writeFileSync(`${file}.tmp`, answer);
493
+ renameSync(`${file}.tmp`, file);
494
+ } catch (err) {
495
+ warnOnce(`could not keep GitHub's answers in ${dirname(file)}: ${err.message}`);
496
+ }
497
+ return answer;
498
+ };
499
+
347
500
  async function build(info, run) {
348
- const jobs = await visualJobs(gh, run, run.attempt, names);
501
+ const g = run.status === "completed" ? kept(run) : gh;
502
+ const jobs = await visualJobs(g, run, run.attempt, names);
349
503
  const others = [];
350
- const downloads = downloadCaptures(gh, run, names, tmp, others);
504
+ // Each project's capture as its download lands; `partial` is the target listed while the
505
+ // rest are still downloading, whose rows fill in one by one. `download` counts the
506
+ // artifacts and their bytes, for the page's progress.
507
+ const landed = new Map();
508
+ const download = { done: 0, total: names.length, bytesDone: 0, bytes: null, startedAt: Date.now() };
509
+ let sizes = {};
510
+ let partial = null;
511
+ const fill = async (name) => {
512
+ const p = await project(name, landed.get(name)?.dir, problemOf(name, landed.get(name), run, jobs));
513
+ p.logUrl = jobs[name]?.url ?? run.url;
514
+ p.bytes = sizes[name] ?? null;
515
+ partial.projects[names.indexOf(name)] = p;
516
+ await decorate(partial);
517
+ };
518
+ const downloads = downloadCaptures(
519
+ g,
520
+ run,
521
+ names,
522
+ tmp,
523
+ others,
524
+ (name, got) => {
525
+ landed.set(name, got);
526
+ if (got) {
527
+ download.done++;
528
+ download.bytesDone += got.bytes;
529
+ }
530
+ if (partial) {
531
+ fill(name).catch((err) => console.error(`visual-review: ${name} of ${info.id}: ${err.message}`));
532
+ }
533
+ },
534
+ (planned) => {
535
+ sizes = planned;
536
+ download.total = Object.keys(planned).length;
537
+ download.bytes = Object.values(planned).reduce((a, b) => a + b, 0);
538
+ },
539
+ );
351
540
  downloads.catch(() => {}); // A download still running after PATIENCE fails on a later refresh.
352
541
  const downloaded = await Promise.race([
353
542
  downloads,
354
543
  new Promise((resolve) => setTimeout(resolve, PATIENCE, null).unref()),
355
544
  ]);
356
545
  if (!downloaded) {
357
- return blank(info, "downloading the captures: reload in a moment", run);
546
+ // Listed as downloading, and rebuilt in the cache when the download lands, so the page
547
+ // (which asks again every few seconds) fills the rows in without a refresh.
548
+ downloads
549
+ .then(async () => {
550
+ await refreshing?.catch(() => {});
551
+ const shown = targets.get(info.id);
552
+ if (shown?.downloading && shown.runId === run.id && shown.runAttempt === run.attempt) {
553
+ const built = await build(info, run);
554
+ await decorate(built);
555
+ if (targets.get(info.id) === shown) {
556
+ targets.set(info.id, built);
557
+ save();
558
+ }
559
+ }
560
+ })
561
+ .catch((err) => console.error(`visual-review: rebuilding ${info.id} failed: ${err.message}`));
562
+ partial = blank(info, null, run);
563
+ partial.downloading = true;
564
+ partial.download = download;
565
+ for (const p of partial.projects) {
566
+ p.downloading = true;
567
+ p.bytes = sizes[p.project] ?? null;
568
+ }
569
+ for (const name of landed.keys()) {
570
+ await fill(name);
571
+ }
572
+ return partial;
358
573
  }
359
574
  const list = [];
360
575
  for (const name of names) {
361
- const job = jobs[name];
362
- const got = downloaded[name];
363
- let problem = null;
364
- if (got?.error) {
365
- problem = retryLater("download failed", got.error);
366
- } else if (got?.expired) {
367
- problem = "artifact expired: re-run the visual job";
368
- } else if (!got && run.status !== "completed" && job?.conclusion !== "failure") {
369
- problem = "CI still running; reload when it finishes";
370
- } else if (!got || job?.conclusion === "failure") {
371
- problem = job ? "capture failed" : "no capture";
372
- }
373
- const p = await project(name, got?.dir, problem);
374
- p.logUrl = job?.url ?? run.url;
576
+ const p = await project(name, downloaded[name]?.dir, problemOf(name, downloaded[name], run, jobs));
577
+ p.logUrl = jobs[name]?.url ?? run.url;
375
578
  list.push(p);
376
579
  }
377
580
  const warnings = others.map(
@@ -381,6 +584,24 @@ export function createApp({ repo, gh, config, tmp, token, origin, masterRun, res
381
584
  return { ...info, runId: run.id, runAttempt: run.attempt, runUrl: run.url, projects: list, warnings };
382
585
  }
383
586
 
587
+ // Why a project's capture is missing, from its download and its CI job; null when it is there.
588
+ function problemOf(name, got, run, jobs) {
589
+ const job = jobs[name];
590
+ if (got?.error) {
591
+ return retryLater("download failed", got.error);
592
+ }
593
+ if (got?.expired) {
594
+ return "artifact expired: re-run the visual job";
595
+ }
596
+ if (!got && run.status !== "completed" && job?.conclusion !== "failure") {
597
+ return "CI still running; reload when it finishes";
598
+ }
599
+ if (!got || job?.conclusion === "failure") {
600
+ return job ? "capture failed" : "no capture";
601
+ }
602
+ return null;
603
+ }
604
+
384
605
  const retryLater = (what, message) => `${what}: ${message.split("\n")[0]}; reload the page to retry`;
385
606
 
386
607
  // A target with no capture to show: GitHub would not give it to us (after gh's retries), CI has
@@ -405,11 +626,32 @@ export function createApp({ repo, gh, config, tmp, token, origin, masterRun, res
405
626
  // Concurrent callers (the startup refresh, the page's first request) share one refresh.
406
627
  let refreshing = null;
407
628
  let refreshedAt = 0;
629
+ let loadedOnce = false;
630
+ /** Why the last refresh failed as a whole (no list at all), for the page; null once one works. */
631
+ let failure = null;
632
+ /** What the running refresh is doing, for the page: a step, a count, and when it began. */
633
+ let progress = null;
634
+ const step = (name, done = null, total = null) => {
635
+ if (progress) {
636
+ Object.assign(progress, { step: name, done, total });
637
+ }
638
+ };
408
639
  const refresh = () =>
409
- (refreshing ??= load().finally(() => {
410
- refreshing = null;
411
- refreshedAt = Date.now();
412
- }));
640
+ (refreshing ??= (async () => {
641
+ progress = { step: "starting", done: null, total: null, startedAt: Date.now() };
642
+ try {
643
+ await load();
644
+ loadedOnce = true;
645
+ failure = null;
646
+ } catch (err) {
647
+ failure = err.message.split("\n")[0];
648
+ throw err;
649
+ } finally {
650
+ refreshing = null;
651
+ refreshedAt = Date.now();
652
+ progress = null;
653
+ }
654
+ })());
413
655
 
414
656
  async function load() {
415
657
  const next = new Map();
@@ -442,6 +684,7 @@ export function createApp({ repo, gh, config, tmp, token, origin, masterRun, res
442
684
  // master seed still loads.
443
685
  listWarnings = [];
444
686
  const kept = [...targets.values()].filter((t) => t.pr !== null);
687
+ step("listing pull requests");
445
688
  const prs = await openPullRequests(gh).catch((err) => {
446
689
  if (!masterRun && kept.length === 0) {
447
690
  throw err;
@@ -482,6 +725,8 @@ export function createApp({ repo, gh, config, tmp, token, origin, masterRun, res
482
725
  }
483
726
  })();
484
727
  // Every pull request at once: one after another took about 40 s for 18 of them.
728
+ let found = 0;
729
+ step("finding CI runs", 0, prs.length);
485
730
  const built = await Promise.all(
486
731
  prs.map(async (pr) => {
487
732
  const info = {
@@ -498,12 +743,25 @@ export function createApp({ repo, gh, config, tmp, token, origin, masterRun, res
498
743
  : blank(info, `waiting for CI on ${pr.headSha.slice(0, 10)}`);
499
744
  } catch (err) {
500
745
  return keptOr(info, err);
746
+ } finally {
747
+ step("finding CI runs", ++found, prs.length);
501
748
  }
502
749
  }),
503
750
  );
751
+ step("fetching branches");
504
752
  await fetched;
753
+ await readPasskeys();
754
+ // GitHub's list can lag a pull request just opened: keep it a while, until its key is merged.
755
+ const opened =
756
+ passkeyOpened &&
757
+ Date.now() - passkeyOpened.at < 5 * 60000 &&
758
+ !passkeyState.main.some((k) => k.id === passkeyOpened.id)
759
+ ? passkeyOpened.number
760
+ : null;
761
+ passkeyPr = prs.find((p) => p.branch.startsWith("visual/passkey-"))?.number ?? opened;
505
762
  for (const t of built) next.set(t.id, t);
506
763
  if (masterRun) {
764
+ step(`reading ${defaultBranch}'s CI run`);
507
765
  const info = { id: "master", pr: null, title: defaultBranch, url: null, branch: null };
508
766
  next.set(
509
767
  "master",
@@ -513,34 +771,109 @@ export function createApp({ repo, gh, config, tmp, token, origin, masterRun, res
513
771
  );
514
772
  }
515
773
  }
774
+ step("checking the baselines", 0, next.size);
775
+ let checked = 0;
516
776
  for (const t of next.values()) {
517
- const first = t.projects.find((p) => p.results)?.results;
518
- t.commit = first?.commit ?? null;
519
- t.headSha = first?.headSha ?? null;
520
- const base = t.pr === null ? t.commit : t.headSha;
521
- t.earlier = await earlierAccepts(repo, t.headSha, t.pr, config.baselines);
522
- // null: unknown, when the captured head was never fetched or git fails.
523
- const known =
524
- base !== null &&
525
- (await exec("git", ["cat-file", "-e", `${base}^{commit}`], { cwd: repo }).then(
526
- () => true,
527
- () => false,
528
- ));
529
- t.mergeMasterFirst = false;
530
- for (const p of t.projects) {
531
- if (!t.local && p.results && base) {
532
- const behind = known ? await behindMaster(repo, base, p.project, config).catch(() => null) : null;
533
- if (behind !== false && t.mergeMasterFirst !== true) {
534
- t.mergeMasterFirst = behind;
535
- }
536
- }
537
- }
777
+ await decorate(t);
778
+ step("checking the baselines", ++checked, next.size);
538
779
  }
539
780
  signer = await signingIdentity(repo);
540
781
  targets = next;
782
+ save();
541
783
  prune();
542
784
  }
543
785
 
786
+ const listFile = join(stateDir, "list.json");
787
+ /**
788
+ * Keeps the list of targets on disk, without the captures' results (results.json is read again
789
+ * from each capture's directory), so a restarted server shows it before GitHub answers.
790
+ */
791
+ function save() {
792
+ if (results) {
793
+ return;
794
+ }
795
+ try {
796
+ writeJson(listFile, {
797
+ at: Date.now(),
798
+ targets: [...targets.values()].map((t) => ({
799
+ ...t,
800
+ earlier: [...(t.earlier ?? [])],
801
+ projects: t.projects.map((p) => ({ ...p, results: undefined })),
802
+ })),
803
+ });
804
+ } catch (err) {
805
+ const warning = `could not keep the list of targets (a restart would wait for GitHub): ${err.message}`;
806
+ console.error(`visual-review: ${warning}`);
807
+ if (!listWarnings.includes(warning)) {
808
+ listWarnings.push(warning);
809
+ }
810
+ }
811
+ }
812
+
813
+ /**
814
+ * The list a previous run of this server kept, shown until the first refresh replaces it. A
815
+ * capture whose directory is gone is listed as downloading: that refresh downloads it again.
816
+ */
817
+ async function restore() {
818
+ let saved;
819
+ try {
820
+ saved = JSON.parse(readFileSync(listFile, "utf8"));
821
+ } catch {
822
+ return; // Never saved, or unreadable: the first refresh builds the list.
823
+ }
824
+ try {
825
+ const next = new Map();
826
+ for (const t of saved.targets) {
827
+ const list = [];
828
+ for (const p of t.projects) {
829
+ const there = p.dir && existsSync(join(p.dir, "results.json"));
830
+ const loaded = there ? await project(p.project, p.dir, p.problem) : null;
831
+ list.push(
832
+ p.dir && !loaded?.results
833
+ ? { ...p, dir: null, results: null, problem: null, downloading: true }
834
+ : { ...p, results: loaded?.results ?? null },
835
+ );
836
+ }
837
+ const downloading = t.downloading === true || list.some((p) => p.downloading);
838
+ next.set(t.id, { ...t, earlier: new Map(t.earlier), projects: list, downloading });
839
+ }
840
+ signer = await signingIdentity(repo);
841
+ await readPasskeys();
842
+ if (!loadedOnce) {
843
+ targets = next;
844
+ loadedOnce = true;
845
+ refreshedAt = saved.at;
846
+ }
847
+ } catch (err) {
848
+ console.error(`visual-review: ignoring the kept list in ${listFile}: ${err.message}`);
849
+ }
850
+ }
851
+
852
+ // A target's captured commits, its earlier accepts and whether it is behind the default branch.
853
+ async function decorate(t) {
854
+ const first = t.projects.find((p) => p.results)?.results;
855
+ t.commit = first?.commit ?? null;
856
+ t.headSha = first?.headSha ?? null;
857
+ const base = t.pr === null ? t.commit : t.headSha;
858
+ t.earlier = await earlierAccepts(repo, t.headSha, t.pr, config.baselines);
859
+ // null: unknown, when the captured head was never fetched or git fails.
860
+ const known =
861
+ base !== null &&
862
+ (await exec("git", ["cat-file", "-e", `${base}^{commit}`], { cwd: repo }).then(
863
+ () => true,
864
+ () => false,
865
+ ));
866
+ t.mergeMasterFirst = false;
867
+ for (const p of t.projects) {
868
+ if (!t.local && p.results && base) {
869
+ const behind = known ? await behindMaster(repo, base, p.project, config).catch(() => null) : null;
870
+ if (behind !== false && t.mergeMasterFirst !== true) {
871
+ t.mergeMasterFirst = behind;
872
+ }
873
+ }
874
+ }
875
+ }
876
+
544
877
  // Deletes the downloads of runs no target shows any more (never state/). Not while a Finish
545
878
  // reads its captures, nor after a refresh that could not load some target.
546
879
  function prune() {
@@ -582,9 +915,40 @@ export function createApp({ repo, gh, config, tmp, token, origin, masterRun, res
582
915
  commit: t.commit,
583
916
  headSha: t.headSha,
584
917
  mergeMasterFirst: t.mergeMasterFirst,
918
+ downloading: t.downloading === true,
919
+ // While downloading: how many artifacts and bytes have landed of how many, and since when
920
+ // (server time). `bytes` is null until GitHub has listed the artifacts.
921
+ download: t.downloading
922
+ ? { done: 0, total: names.length, bytesDone: 0, bytes: null, startedAt: null, ...t.download }
923
+ : null,
924
+ defaultBranch,
925
+ // Decisions the next Finish would publish (a reject an earlier Finish posted is not).
926
+ unpublished: [...decided.values()].filter((d) => !d.posted).length,
585
927
  warnings,
586
928
  signer,
587
929
  startCommand,
930
+ // What the targets screen and Finish's header say about the passkey. The Finish routes
931
+ // read the keys again themselves: this is for display only.
932
+ passkey: {
933
+ rpId,
934
+ required: passkeyState.problem !== null || passkeyState.main.length + passkeyState.pending.length > 0,
935
+ keys: [
936
+ ...passkeyState.main.map(({ id, label, rpId: host }) => ({
937
+ id,
938
+ label,
939
+ rpId: host,
940
+ pending: false,
941
+ })),
942
+ ...passkeyState.pending.map(({ id, label, rpId: host }) => ({
943
+ id,
944
+ label,
945
+ rpId: host,
946
+ pending: true,
947
+ })),
948
+ ],
949
+ problem: passkeyState.problem,
950
+ waiting: passkeyPr,
951
+ },
588
952
  projects: t.projects.map((p) => {
589
953
  const counts = {};
590
954
  for (const item of p.results?.items ?? []) {
@@ -596,11 +960,16 @@ export function createApp({ repo, gh, config, tmp, token, origin, masterRun, res
596
960
  return {
597
961
  project: p.project,
598
962
  problem: p.problem,
963
+ downloading: p.downloading === true,
964
+ // The size of the project's artifact, while it downloads.
965
+ bytes: p.downloading ? (p.bytes ?? null) : null,
599
966
  logUrl: p.logUrl,
600
967
  counts,
601
968
  reviewable,
602
969
  decided: mine.length,
603
970
  undecided: reviewable - mine.length,
971
+ // Accepts and exclusions an earlier Finish pushed, waiting for a new CI run.
972
+ finished: mine.filter(([, d]) => d.posted && d.decision !== "reject").length,
604
973
  notOpened: mine.filter(([, d]) => d.bulk).length,
605
974
  acceptable: acceptable(t, p.project),
606
975
  local: p.results?.local ?? null,
@@ -645,21 +1014,244 @@ export function createApp({ repo, gh, config, tmp, token, origin, masterRun, res
645
1014
  : body.runId === undefined || (body.runId === t.runId && body.runAttempt === t.runAttempt);
646
1015
  const CHANGED = "the capture changed since the page loaded it (a new CI run or attempt): reload the page";
647
1016
 
1017
+ /**
1018
+ * The decisions a Finish of `t` would apply now: every one but the rejects an earlier Finish
1019
+ * already posted, in a fixed order, and a digest of them that changes when any of them does.
1020
+ * @param {object} t the target
1021
+ * @returns {{ list: { project: string, file: string, decision: string, reason: string | null }[],
1022
+ * digest: string }} the decisions and their digest
1023
+ */
1024
+ const finishList = (t) => {
1025
+ const list = [...decisionsOf(t)]
1026
+ .filter(([, v]) => !v.posted)
1027
+ .sort(([a], [b]) => (a < b ? -1 : 1))
1028
+ .map(([k, v]) => {
1029
+ const at = k.indexOf("/");
1030
+ return { project: k.slice(0, at), file: k.slice(at + 1), decision: v.decision, reason: v.reason };
1031
+ });
1032
+ return { list, digest: createHash("sha256").update(JSON.stringify(list)).digest("hex") };
1033
+ };
1034
+ const CHANGED_SINCE = "Decisions changed since this sheet opened: check the summary again.";
1035
+
1036
+ // What Finish's sheet states before it runs: what it commits and posts, and the status it sets.
1037
+ // The passkey's challenge comes from POST /api/finish-prepare.
1038
+ const finishPreview = (t) => {
1039
+ const { list, digest } = finishList(t);
1040
+ const s = summary(t);
1041
+ const count = (d) => list.filter((x) => x.decision === d).length;
1042
+ const unloaded = t.projects.filter((p) => !p.results).map((p) => p.project);
1043
+ const undecided = s.projects.reduce((n, p) => n + p.undecided, 0);
1044
+ const notes = list
1045
+ .filter((x) => x.reason !== null && x.decision !== "exclude")
1046
+ .map((x) => ({ decision: x.decision, project: x.project, file: x.file, note: x.reason }));
1047
+ return {
1048
+ accepts: count("accept"),
1049
+ excludes: count("exclude"),
1050
+ rejects: count("reject"),
1051
+ acceptNotes: notes.filter((n) => n.decision === "accept").length,
1052
+ notes,
1053
+ notOpened: s.projects.reduce((n, p) => n + p.notOpened, 0),
1054
+ undecided: s.projects
1055
+ .filter((p) => p.undecided > 0)
1056
+ .map((p) => ({ project: p.project, undecided: p.undecided })),
1057
+ unloaded,
1058
+ status: commitStatus({
1059
+ accepted: count("accept"),
1060
+ rejected: count("reject"),
1061
+ excluded: count("exclude"),
1062
+ undecided,
1063
+ unloaded,
1064
+ }),
1065
+ digest,
1066
+ };
1067
+ };
1068
+
1069
+ /**
1070
+ * What a Finish of this target applies: its decisions (a reject an earlier Finish posted stays
1071
+ * shown as rejected, never posted again), their digest, the captures, and what is left
1072
+ * undecided or unloaded.
1073
+ * @param {object} t the target
1074
+ * @returns {{ list: object[], digest: string, captures: object, undecided: number,
1075
+ * unloaded: string[] }} the work
1076
+ */
1077
+ const finishWork = (t) => ({
1078
+ ...finishList(t),
1079
+ captures: capturesOf(t),
1080
+ undecided: summary(t).projects.reduce((n, p) => n + p.undecided, 0),
1081
+ unloaded: t.projects.filter((p) => !p.results).map((p) => p.project),
1082
+ });
1083
+
1084
+ const capturesOf = (t) =>
1085
+ Object.fromEntries(
1086
+ t.projects.filter((p) => p.results).map((p) => [p.project, { dir: p.dir, results: p.results }]),
1087
+ );
1088
+
1089
+ /** Thumbnails being made, by the file they are kept in, and whether a tile is waiting for one. */
1090
+ const making = new Map();
1091
+
1092
+ /**
1093
+ * An image's thumbnail, made in a worker thread unless it is on disk already. A tile waiting
1094
+ * for one (`urgent`) goes ahead of those made in advance, even of its own made in advance.
1095
+ * @param {{ path: string, hash: string, file: string, dir: string }} where the image
1096
+ * @param {boolean} urgent a tile is waiting for it
1097
+ * @returns {Promise<Array<unknown>>} the answer: the PNG, or why not
1098
+ */
1099
+ function thumbOf(where, urgent) {
1100
+ const kept = join(tmp, "thumbs", `${where.hash}.png`);
1101
+ const was = making.get(kept);
1102
+ if (was && (was.urgent || !urgent)) {
1103
+ return was.done;
1104
+ }
1105
+ const done = (async () => {
1106
+ // Made in advance, it was just found missing: reading here, for a thousand items at once,
1107
+ // would fill Node's file thread pool and stall every other request's file reads.
1108
+ const cached = urgent ? await readFile(kept).catch(() => null) : null;
1109
+ if (cached) {
1110
+ return [200, cached, "image/png"];
1111
+ }
1112
+ let small;
1113
+ try {
1114
+ small = await scaled(async () => {
1115
+ const full = await readImage(where);
1116
+ if (full[0] !== 200) {
1117
+ throw Object.assign(new Error(full[1].error), { answer: full });
1118
+ }
1119
+ return full[1];
1120
+ }, urgent);
1121
+ } catch (err) {
1122
+ if (err.answer) {
1123
+ return err.answer;
1124
+ }
1125
+ throw err;
1126
+ }
1127
+ // Its own temporary name: the advance copy and a tile's may be written at once.
1128
+ const part = `${kept}.${urgent ? "tile" : "ahead"}.tmp`;
1129
+ try {
1130
+ mkdirSync(dirname(kept), { recursive: true });
1131
+ writeFileSync(part, small);
1132
+ renameSync(part, kept);
1133
+ } catch (err) {
1134
+ warnOnce(`could not keep thumbnails in ${dirname(kept)}: ${err.message}`);
1135
+ }
1136
+ return [200, small, "image/png"];
1137
+ })().finally(() => {
1138
+ if (making.get(kept)?.done === done) {
1139
+ making.delete(kept);
1140
+ }
1141
+ });
1142
+ making.set(kept, { urgent, done });
1143
+ return done;
1144
+ }
1145
+
1146
+ /**
1147
+ * Makes, in the background, the grid thumbnail of every item of a capture that needs a
1148
+ * decision, so a grid opens with its tiles ready.
1149
+ * @param {string} dir the capture's directory
1150
+ * @param {{ items: object[] }} r its results.json
1151
+ */
1152
+ function prewarm(dir, r) {
1153
+ for (const item of r.items) {
1154
+ const kind = item.capture ? "capture" : "baseline";
1155
+ const hash = item[kind];
1156
+ if (REVIEWABLE.has(item.status) && hash && !existsSync(join(tmp, "thumbs", `${hash}.png`))) {
1157
+ const own = kind === "capture" || item.baseline === item.capture;
1158
+ const path = own ? join(dir, item.file) : join(dir, "baselines", item.file);
1159
+ thumbOf({ path, hash, file: item.file, dir }, false).catch((err) =>
1160
+ warnOnce(`could not make the thumbnail of ${path}: ${err.message}`),
1161
+ );
1162
+ }
1163
+ }
1164
+ }
1165
+
1166
+ // The unpublished count after a write, so the page's Finish button stays current.
1167
+ const unpublishedOf = (t) => finishList(t).list.length;
1168
+
1169
+ /**
1170
+ * Where a target's image is on this disk, by results.json.
1171
+ * @param {string} id the target
1172
+ * @param {string} name the project
1173
+ * @param {string} kind "capture" or "baseline"
1174
+ * @param {string} file the image's file name
1175
+ * @returns {Promise<Array<unknown> | { path: string, hash: string, file: string, dir: string }>}
1176
+ * where the image is, or the answer to send instead
1177
+ */
1178
+ async function imageOf(id, name, kind, file) {
1179
+ const { t, p } = await projectOf(id, name);
1180
+ if (!t) {
1181
+ return gone(id);
1182
+ }
1183
+ const item = p?.results.items.find((i) => i.file === file);
1184
+ const hash = item && { capture: item.capture, baseline: item.baseline }[kind];
1185
+ if (!hash) {
1186
+ return [404, { error: "no such image" }];
1187
+ }
1188
+ // A moved item's baseline is its capture's bytes, so the artifact holds only the capture.
1189
+ const own = kind === "capture" || item.baseline === item.capture;
1190
+ const path = own ? join(p.dir, file) : join(p.dir, "baselines", file);
1191
+ return { path, hash, file, dir: p.dir };
1192
+ }
1193
+
1194
+ async function readImage({ path, hash, file, dir }) {
1195
+ const bytes = await readFile(path).catch(() => null);
1196
+ if (!bytes || createHash("sha256").update(bytes).digest("hex") !== hash) {
1197
+ // CI hashed the bytes it uploaded, so the copy on this disk is damaged: drop it,
1198
+ // and the next reload downloads it again.
1199
+ console.error(
1200
+ `visual-review: ${path} does not match results.json: downloading it again on the next reload`,
1201
+ );
1202
+ rmSync(join(dir, "results.json"), { force: true });
1203
+ return [409, { error: `${file}: the downloaded copy is damaged; reload the page to download it again` }];
1204
+ }
1205
+ return [200, bytes, "image/png"];
1206
+ }
1207
+
648
1208
  const routes = {
649
- "GET /api/prs": async () => {
650
- await refresh();
651
- return [200, { targets: [...targets.values()].map(summary), warning: listWarnings.join("\n") || null }];
1209
+ "GET /api/prs": async (_, __, query) => {
1210
+ // ?cached=1 answers at once (starting the first load if there is none); ?refresh=1
1211
+ // also starts a refresh. Neither waits for GitHub. After a first load that failed, only
1212
+ // ?refresh=1 (the page's Retry) tries again.
1213
+ await restored;
1214
+ const cachedOnly = query?.get("cached") === "1" || query?.get("refresh") === "1";
1215
+ if (cachedOnly) {
1216
+ if (query.get("refresh") === "1" || (!loadedOnce && failure === null)) {
1217
+ refresh().catch((err) => console.error(`visual-review: refresh failed: ${err.message}`));
1218
+ }
1219
+ } else {
1220
+ await refresh();
1221
+ }
1222
+ return [
1223
+ 200,
1224
+ {
1225
+ targets: loadedOnce || !cachedOnly ? [...targets.values()].map(summary) : null,
1226
+ warning: listWarnings.join("\n") || null,
1227
+ defaultBranch,
1228
+ updatedAt: refreshedAt || null,
1229
+ now: Date.now(),
1230
+ refreshing: progress,
1231
+ // Why the last refresh got no list at all; a gh call waiting to retry.
1232
+ error: failure,
1233
+ network: networkTrouble(),
1234
+ },
1235
+ ];
652
1236
  },
653
- // One target's counts without refetching from GitHub, for Finish's confirmation.
654
- "GET /api/target": async ([id]) => {
1237
+ // One target's counts without refetching from GitHub; with ?finish=1, what Finish would do.
1238
+ "GET /api/target": async ([id], _, query) => {
655
1239
  const t = await targetOf(id);
656
- return t ? [200, summary(t)] : gone(id);
1240
+ if (!t) {
1241
+ return gone(id);
1242
+ }
1243
+ return [200, query?.get("finish") === "1" ? { ...summary(t), finish: finishPreview(t) } : summary(t)];
657
1244
  },
658
1245
  "GET /api/pr": async ([id, name]) => {
659
1246
  const { t, p } = await projectOf(id, name);
660
1247
  if (!t) {
661
1248
  return gone(id);
662
1249
  }
1250
+ if (t.projects.find((x) => x.project === name)?.downloading) {
1251
+ // The reviewer is waiting for this one: it downloads next. The page asks again.
1252
+ hurry((dir) => basename(dir) === name && dir.startsWith(join(tmp, `${t.runId}-`)));
1253
+ return [202, { downloading: true, target: summary(t), network: networkTrouble(), now: Date.now() }];
1254
+ }
663
1255
  if (!p) {
664
1256
  return [404, { error: "no such capture" }];
665
1257
  }
@@ -682,32 +1274,16 @@ export function createApp({ repo, gh, config, tmp, token, origin, masterRun, res
682
1274
  ];
683
1275
  },
684
1276
  "GET /api/img": async ([id, name, kind, file]) => {
685
- const { t, p } = await projectOf(id, name);
686
- if (!t) {
687
- return gone(id);
688
- }
689
- const item = p?.results.items.find((i) => i.file === file);
690
- const hash = item && { capture: item.capture, baseline: item.baseline }[kind];
691
- if (!hash) {
692
- return [404, { error: "no such image" }];
693
- }
694
- // A moved item's baseline is its capture's bytes, so the artifact holds only the capture.
695
- const own = kind === "capture" || item.baseline === item.capture;
696
- const path = own ? join(p.dir, file) : join(p.dir, "baselines", file);
697
- const bytes = await readFile(path).catch(() => null);
698
- if (!bytes || createHash("sha256").update(bytes).digest("hex") !== hash) {
699
- // CI hashed the bytes it uploaded, so the copy on this disk is damaged: drop it,
700
- // and the next reload downloads it again.
701
- console.error(
702
- `visual-review: ${path} does not match results.json: downloading it again on the next reload`,
703
- );
704
- rmSync(join(p.dir, "results.json"), { force: true });
705
- return [
706
- 409,
707
- { error: `${file}: the downloaded copy is damaged; reload the page to download it again` },
708
- ];
1277
+ const where = await imageOf(id, name, kind, file);
1278
+ return Array.isArray(where) ? where : readImage(where);
1279
+ },
1280
+ // The same image scaled down for a grid tile, made once and kept by the image's hash.
1281
+ "GET /api/thumb": async ([id, name, kind, file]) => {
1282
+ const where = await imageOf(id, name, kind, file);
1283
+ if (Array.isArray(where)) {
1284
+ return where;
709
1285
  }
710
- return [200, bytes, "image/png"];
1286
+ return thumbOf(where, true);
711
1287
  },
712
1288
  "POST /api/decide": async (_, body) => {
713
1289
  const { p, t } = await projectOf(String(body.id), body.project);
@@ -729,7 +1305,7 @@ export function createApp({ repo, gh, config, tmp, token, origin, masterRun, res
729
1305
  update(t, (saved) => {
730
1306
  delete saved[key];
731
1307
  });
732
- return [200, { ok: true }];
1308
+ return [200, { ok: true, unpublished: unpublishedOf(t) }];
733
1309
  }
734
1310
  if (!sameCapture(t, body, item)) {
735
1311
  return [409, { error: `${body.file}: ${CHANGED}` }];
@@ -767,7 +1343,7 @@ export function createApp({ repo, gh, config, tmp, token, origin, masterRun, res
767
1343
  update(t, (saved) => {
768
1344
  saved[key] = { decision: body.decision, reason, hash: imageHash(item) };
769
1345
  });
770
- return [200, { ok: true }];
1346
+ return [200, { ok: true, unpublished: unpublishedOf(t) }];
771
1347
  },
772
1348
  "POST /api/accept-all": async (_, body) => {
773
1349
  const { p, t } = await projectOf(String(body.id), body.project);
@@ -804,9 +1380,87 @@ export function createApp({ repo, gh, config, tmp, token, origin, masterRun, res
804
1380
  }
805
1381
  }
806
1382
  });
807
- return [200, { accepted }];
1383
+ return [200, { accepted, unpublished: unpublishedOf(t) }];
808
1384
  },
809
- "POST /api/finish": async (_, body) => {
1385
+ "GET /api/passkeys": async () => {
1386
+ const { main, pending } = await knownKeys();
1387
+ const show = (k, isPending) => ({ id: k.id, rpId: k.rpId, label: k.label ?? null, pending: isPending });
1388
+ return [
1389
+ 200,
1390
+ {
1391
+ rpId,
1392
+ keys: [...main.map((k) => show(k, false)), ...pending.map((k) => show(k, true))],
1393
+ required: main.length + pending.length > 0,
1394
+ },
1395
+ ];
1396
+ },
1397
+ "POST /api/passkey-challenge": async () => {
1398
+ if (results) {
1399
+ return [403, { error: "a local preview registers no passkey" }];
1400
+ }
1401
+ const now = Date.now();
1402
+ for (const [c, expires] of challenges) {
1403
+ if (expires < now) {
1404
+ challenges.delete(c);
1405
+ }
1406
+ }
1407
+ const challenge = randomBytes(32).toString("base64url");
1408
+ challenges.set(challenge, now + CHALLENGE_MS);
1409
+ const { main, pending } = await knownKeys();
1410
+ return [
1411
+ 200,
1412
+ {
1413
+ challenge,
1414
+ userId: randomBytes(16).toString("base64url"),
1415
+ rpId,
1416
+ known: [...main, ...pending].map((k) => k.id),
1417
+ },
1418
+ ];
1419
+ },
1420
+ "POST /api/register": async (_, body) => {
1421
+ const expires = challenges.get(body.challenge);
1422
+ challenges.delete(body.challenge);
1423
+ if (!expires || expires < Date.now()) {
1424
+ return [409, { error: "the registration expired or was already used: press Register passkey again" }];
1425
+ }
1426
+ const why = verifyRegistration(body, { challenge: body.challenge, origin, rpId });
1427
+ if (why) {
1428
+ return [400, { error: `the passkey was not accepted: ${why}` }];
1429
+ }
1430
+ const { main, pending } = await knownKeys();
1431
+ if ([...main, ...pending].some((k) => k.id === body.credentialId)) {
1432
+ return [409, { error: "this passkey is already registered" }];
1433
+ }
1434
+ if (finishing) {
1435
+ return [409, { error: "a Finish is running: register when it ends" }];
1436
+ }
1437
+ const label = typeof body.label === "string" ? body.label.replace(/\s+/g, " ").trim().slice(0, 60) : "";
1438
+ const entry = {
1439
+ id: body.credentialId,
1440
+ publicKey: body.publicKey,
1441
+ rpId,
1442
+ label: label || "passkey",
1443
+ registeredAt: new Date().toISOString(),
1444
+ };
1445
+ let proposed;
1446
+ try {
1447
+ proposed = await proposeKey({ repo, gh, entry, config });
1448
+ } catch (err) {
1449
+ if (err instanceof AcceptError) {
1450
+ return [409, { error: err.message }];
1451
+ }
1452
+ throw err;
1453
+ }
1454
+ writeJson(pendingFile, { version: 1, keys: [...readPending(), entry] });
1455
+ passkeyPr = Number(proposed.pullRequest.split("/").pop()) || passkeyPr;
1456
+ passkeyOpened = { number: passkeyPr, id: entry.id, at: Date.now() };
1457
+ await readPasskeys();
1458
+ console.log(
1459
+ `visual-review: registered passkey ${entry.id}; merge ${proposed.pullRequest} to enforce approvals`,
1460
+ );
1461
+ return [200, { entry, ...proposed }];
1462
+ },
1463
+ "POST /api/finish-prepare": async (_, body) => {
810
1464
  const t = await targetOf(String(body.id));
811
1465
  if (!t) {
812
1466
  return gone(String(body.id));
@@ -817,18 +1471,103 @@ export function createApp({ repo, gh, config, tmp, token, origin, masterRun, res
817
1471
  if (finishing) {
818
1472
  return [409, { error: "a Finish is already running" }];
819
1473
  }
820
- const mine = decisionsOf(t);
821
- // A reject already posted by an earlier Finish stays shown as rejected, not posted again.
822
- const list = [...mine]
823
- .filter(([, v]) => !v.posted)
824
- .map(([k, v]) => {
825
- const at = k.indexOf("/");
826
- return { project: k.slice(0, at), file: k.slice(at + 1), decision: v.decision, reason: v.reason };
1474
+ const { trusted: keys } = await knownKeys();
1475
+ if (keys.length === 0) {
1476
+ approvals.delete(t.id);
1477
+ return [200, { required: false }];
1478
+ }
1479
+ const here = keys.filter((k) => k.rpId === rpId);
1480
+ if (here.length === 0) {
1481
+ const hosts = [...new Set(keys.map((k) => k.rpId))].join(", ");
1482
+ return [
1483
+ 409,
1484
+ {
1485
+ error: `no passkey is registered for ${rpId}: serve this page from the host your passkey is for (${hosts}), or register one here`,
1486
+ },
1487
+ ];
1488
+ }
1489
+ const work = finishWork(t);
1490
+ let prepared;
1491
+ try {
1492
+ prepared = await prepareRecord({
1493
+ repo,
1494
+ target: { pr: t.pr, branch: t.branch },
1495
+ projects: work.captures,
1496
+ decisions: work.list,
1497
+ now: new Date(),
1498
+ config,
827
1499
  });
828
- const captures = Object.fromEntries(
829
- t.projects.filter((p) => p.results).map((p) => [p.project, { dir: p.dir, results: p.results }]),
830
- );
831
- const undecided = summary(t).projects.reduce((n, p) => n + p.undecided, 0);
1500
+ } catch (err) {
1501
+ if (err instanceof AcceptError) {
1502
+ return [409, { error: err.message }];
1503
+ }
1504
+ throw err;
1505
+ }
1506
+ const challenge = recordHash(prepared.record).toString("base64url");
1507
+ approvals.set(t.id, {
1508
+ challenge,
1509
+ record: prepared.record,
1510
+ work,
1511
+ digest: work.digest,
1512
+ expires: Date.now() + APPROVAL_MS,
1513
+ });
1514
+ return [
1515
+ 200,
1516
+ {
1517
+ required: true,
1518
+ challenge,
1519
+ rpId,
1520
+ allowCredentials: here.map((k) => k.id),
1521
+ record: prepared.record,
1522
+ accepts: prepared.accepts,
1523
+ excludes: prepared.excludes,
1524
+ rejects: prepared.rejects,
1525
+ },
1526
+ ];
1527
+ },
1528
+ "POST /api/finish": async (_, body) => {
1529
+ const t = await targetOf(String(body.id));
1530
+ if (!t) {
1531
+ return gone(String(body.id));
1532
+ }
1533
+ if (t.local) {
1534
+ return [403, { error: "a local preview has no Finish" }];
1535
+ }
1536
+ if (finishing) {
1537
+ return [409, { error: "a Finish is already running" }];
1538
+ }
1539
+ let work = finishWork(t);
1540
+ // The page's sheet stated what Finish would do; a decision changed since (in another
1541
+ // tab, say) would make that statement wrong.
1542
+ if (body.digest !== undefined && body.digest !== work.digest) {
1543
+ return [409, { error: CHANGED_SINCE }];
1544
+ }
1545
+ let approval = null;
1546
+ const { main, trusted } = await knownKeys();
1547
+ if (trusted.length > 0) {
1548
+ if (!body.approval) {
1549
+ return [400, { error: "this Finish needs your passkey approval (Face ID): press Finish again" }];
1550
+ }
1551
+ const p = approvals.get(t.id);
1552
+ if (!p || p.challenge !== body.challenge || p.expires < Date.now() || p.digest !== work.digest) {
1553
+ return [
1554
+ 409,
1555
+ {
1556
+ error: "the approval is stale (made on another tab, expired, or the decisions changed): press Finish again",
1557
+ },
1558
+ ];
1559
+ }
1560
+ const record = { ...p.record, approval: body.approval };
1561
+ const why = verifyApproval(record, trusted, { origin });
1562
+ if (why) {
1563
+ return [403, { error: `the approval was refused: ${why}` }];
1564
+ }
1565
+ approvals.delete(t.id);
1566
+ // The decisions the approved record was built from, not a fresh read of the state.
1567
+ work = p.work;
1568
+ approval = { record, pendingKeys: main.length > 0 ? [] : trusted, origin };
1569
+ }
1570
+ const { list, captures, undecided } = work;
832
1571
  finishing = true;
833
1572
  job = {
834
1573
  id: (job?.id ?? 0) + 1,
@@ -852,8 +1591,9 @@ export function createApp({ repo, gh, config, tmp, token, origin, masterRun, res
852
1591
  projects: captures,
853
1592
  decisions: list,
854
1593
  undecided,
855
- unloaded: t.projects.filter((p) => !p.results).map((p) => p.project),
1594
+ unloaded: work.unloaded,
856
1595
  config,
1596
+ ...(approval && { approval, now: new Date(approval.record.reviewedAt) }),
857
1597
  });
858
1598
  return [202, { job }];
859
1599
  },
@@ -869,15 +1609,15 @@ export function createApp({ repo, gh, config, tmp, token, origin, masterRun, res
869
1609
  * @param {object} input finish's input
870
1610
  */
871
1611
  async function runFinish(j, t, sent, input) {
872
- // Drops the accepts and exclusions Finish pushed; rejects stay, keyed by image hash, so an
873
- // unchanged rejected capture on the next CI run still reads as rejected, not undecided.
1612
+ // Marks what Finish published, so no later Finish publishes it again. The accepts and
1613
+ // exclusions it pushed stay shown as finished until a new CI run replaces this capture (their
1614
+ // hash then no longer matches, or the item is unchanged); rejects stay keyed by image hash,
1615
+ // so an unchanged rejected capture on the next CI run still reads as rejected.
874
1616
  const clear = (posted) =>
875
1617
  update(t, (saved) => {
876
1618
  for (const { project, file, decision } of sent) {
877
1619
  const k = `${project}/${file}`;
878
- if (decision !== "reject") {
879
- delete saved[k];
880
- } else if (posted && saved[k]) {
1620
+ if (saved[k] && (decision !== "reject" || posted)) {
881
1621
  saved[k].posted = true;
882
1622
  }
883
1623
  }
@@ -954,9 +1694,14 @@ export function createApp({ repo, gh, config, tmp, token, origin, masterRun, res
954
1694
  return body;
955
1695
  }
956
1696
 
1697
+ // The list a previous run of the server kept: read before any request for the list is answered.
1698
+ const restored = warm && !results ? restore() : Promise.resolve();
957
1699
  if (warm) {
958
- // Download the captures now, so they are on disk before the page first asks.
959
- refresh().catch((err) => console.error(`visual-review: startup refresh failed: ${err.message}`));
1700
+ // Download the captures now, and again every POLL so that a CI run that finished meanwhile
1701
+ // is on disk before the page first asks for it.
1702
+ const failed = (when) => (err) => console.error(`visual-review: ${when} refresh failed: ${err.message}`);
1703
+ restored.then(() => refresh().catch(failed("startup")));
1704
+ setInterval(() => refresh().catch(failed("background")), POLL).unref();
960
1705
  }
961
1706
 
962
1707
  return async (req, res) => {
@@ -982,7 +1727,11 @@ export function createApp({ repo, gh, config, tmp, token, origin, masterRun, res
982
1727
  if (writes && req.headers.origin !== origin) {
983
1728
  return send(res, 403, { error: "foreign origin" });
984
1729
  }
985
- const [status, body, type] = await handler(args, writes ? await readBody(req) : undefined);
1730
+ const [status, body, type] = await handler(
1731
+ args,
1732
+ writes ? await readBody(req) : undefined,
1733
+ url.searchParams,
1734
+ );
986
1735
  return send(res, status, body, type);
987
1736
  } catch (err) {
988
1737
  // A malformed request (bad JSON, bad escape, oversized body) is the client's; the rest,