visualproof 0.2.0 → 0.4.0

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.
Files changed (96) hide show
  1. package/README.md +88 -33
  2. package/dist/assets/tips/phone-fingers.light.mp4 +0 -0
  3. package/dist/assets/tips/phone-fingers.light.webp +0 -0
  4. package/dist/assets/tips/tour-agent.webp +0 -0
  5. package/dist/assets/tips/tour-arrange.webp +0 -0
  6. package/dist/assets/tips/tour-artwork.webp +0 -0
  7. package/dist/assets/tips/tour-checks.webp +0 -0
  8. package/dist/assets/tips/tour-export.webp +0 -0
  9. package/dist/assets/tips/tour-keys.webp +0 -0
  10. package/dist/assets/tips/tour-library.webp +0 -0
  11. package/dist/assets/tips/tour-project.webp +0 -0
  12. package/dist/assets/tips/tour-review.webp +0 -0
  13. package/dist/assets/tips/tour-share.webp +0 -0
  14. package/dist/assets/tips/tour-source.webp +0 -0
  15. package/dist/assets/tips/tour-target.webp +0 -0
  16. package/dist/assets/tips/tour-view.webp +0 -0
  17. package/dist/assets/tips/tour.json +289 -0
  18. package/dist/assets/tips/tour.webp +0 -0
  19. package/dist/assets/webfonts/LICENSE-Noto.txt +95 -0
  20. package/dist/assets/webfonts/metrics.json +1 -0
  21. package/dist/assets/webfonts/ttf/jost-400-normal.ttf +0 -0
  22. package/dist/assets/webfonts/ttf/jost-500-normal.ttf +0 -0
  23. package/dist/assets/webfonts/ttf/noto-sans-math.ttf +0 -0
  24. package/dist/assets/webfonts/ttf/noto-sans.ttf +0 -0
  25. package/dist/assets/webfonts/ttf/source-sans-3-400-italic.ttf +0 -0
  26. package/dist/assets/webfonts/ttf/source-sans-3-400-normal.ttf +0 -0
  27. package/dist/assets/webfonts/ttf/source-sans-3-500-normal.ttf +0 -0
  28. package/dist/assets/webfonts/ttf/source-sans-3-600-normal.ttf +0 -0
  29. package/dist/assets/webfonts/ttf/source-sans-3-700-normal.ttf +0 -0
  30. package/dist/assets/webfonts/ttf/source-serif-4-400-italic.ttf +0 -0
  31. package/dist/assets/webfonts/ttf/source-serif-4-600-italic.ttf +0 -0
  32. package/dist/mcp/visualproof-mcp.mjs +3 -1
  33. package/dist/server/agent.js +94 -91
  34. package/dist/server/agentdocs.js +1 -1
  35. package/dist/server/artcmd.js +335 -0
  36. package/dist/server/children.js +85 -0
  37. package/dist/server/doctor.js +105 -42
  38. package/dist/server/events.js +10 -0
  39. package/dist/server/export.js +11 -4
  40. package/dist/server/extract.js +3 -2
  41. package/dist/server/figures.js +3 -2
  42. package/dist/server/film.js +380 -0
  43. package/dist/server/history.js +11 -1
  44. package/dist/server/hover.js +8 -5
  45. package/dist/server/live.js +72 -9
  46. package/dist/server/main.js +352 -23
  47. package/dist/server/mcptools.js +4 -1
  48. package/dist/server/media.js +114 -0
  49. package/dist/server/pipeline.js +21 -10
  50. package/dist/server/poppler.js +3 -2
  51. package/dist/server/readonlygate.js +4 -4
  52. package/dist/server/review.js +4 -1
  53. package/dist/server/reviewapi.js +35 -1
  54. package/dist/server/snapshot.js +10 -1
  55. package/dist/server/sourcestore.js +3 -2
  56. package/dist/server/status.js +3 -2
  57. package/dist/server/supervise.js +219 -0
  58. package/dist/server/updates.js +951 -0
  59. package/dist/server/usage.js +3 -1
  60. package/dist/server/version.js +183 -0
  61. package/dist/server/view.js +0 -7
  62. package/dist/server/views.js +24 -0
  63. package/dist/shared/artfonts.js +81 -0
  64. package/dist/shared/artrecipe.js +316 -0
  65. package/dist/shared/collab.js +7 -2
  66. package/dist/shared/declfind.js +95 -0
  67. package/dist/shared/lostfound.js +1 -0
  68. package/dist/shared/pastgraph.js +6 -6
  69. package/dist/shared/review.js +62 -5
  70. package/dist/shared/timeline.js +5 -0
  71. package/dist/web/bundle/app.js +3006 -413
  72. package/dist/web/bundle/edges.js +1 -1
  73. package/dist/web/index.html +13 -1
  74. package/dist/web/src/agentmark.js +14 -0
  75. package/dist/web/src/artwork.js +1000 -0
  76. package/dist/web/src/bundles.js +151 -0
  77. package/dist/web/src/canvas.js +3716 -0
  78. package/dist/web/src/drag.js +61 -0
  79. package/dist/web/src/edgequeue.js +204 -0
  80. package/dist/web/src/gl/gl.js +123 -0
  81. package/dist/web/src/gl/renderer.js +451 -0
  82. package/dist/web/src/halo.js +95 -0
  83. package/dist/web/src/highlight.js +108 -0
  84. package/dist/web/src/overview.js +393 -0
  85. package/dist/web/src/reviewqueue.js +250 -0
  86. package/dist/web/src/selection.js +272 -0
  87. package/dist/web/src/status.js +80 -0
  88. package/dist/web/src/visitors.js +318 -0
  89. package/dist/web/src/visits.js +174 -0
  90. package/dist/web/style.css +196 -1
  91. package/docs/agent-api/README.md +1 -1
  92. package/docs/agent-api/export.md +41 -2
  93. package/docs/agent-api/history.md +5 -3
  94. package/docs/agent-api/mcp.md +1 -1
  95. package/docs/agent-api/reading.md +2 -1
  96. package/package.json +5 -1
@@ -19,6 +19,7 @@
19
19
  // with it in the cache and in the sources' zip; their names say what they are drawn from, so the
20
20
  // .tex names them too.
21
21
  import { spawn, spawnSync } from "node:child_process";
22
+ import { childEnv, DETACH, end, grouped } from "./children.js";
22
23
  import { createHash } from "node:crypto";
23
24
  import { cpSync, existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, statSync, symlinkSync, writeFileSync } from "node:fs";
24
25
  import { join, resolve } from "node:path";
@@ -58,6 +59,11 @@ export class Exporter {
58
59
  this.maxCached = o.maxCached ?? 20;
59
60
  this.concurrent = o.concurrent ?? 2;
60
61
  }
62
+ /** Whether a document is being typeset, or waits its turn: no moment to restart in (UpdateSpec
63
+ * decision 6). */
64
+ get busy() {
65
+ return this.running > 0 || this.queue.length > 0;
66
+ }
61
67
  /** Whether XeLaTeX can be run here: asked once. */
62
68
  available() {
63
69
  if (this.found === null) {
@@ -260,16 +266,17 @@ export class Exporter {
260
266
  return new Promise((ok) => {
261
267
  if (timeoutMs <= 0)
262
268
  return ok("timeout");
263
- const p = spawn(this.xelatex, ["-no-shell-escape", "-halt-on-error", "-interaction=nonstopmode", "doc.tex"], {
269
+ const p = grouped(spawn(this.xelatex, ["-no-shell-escape", "-halt-on-error", "-interaction=nonstopmode", "doc.tex"], {
264
270
  cwd,
265
271
  // TeX's paranoid file access; and a log whose lines are not wrapped, so that it reads back
266
- env: { ...process.env, openin_any: "p", openout_any: "p", shell_escape: "f", max_print_line: "10000" },
272
+ env: childEnv({ openin_any: "p", openout_any: "p", shell_escape: "f", max_print_line: "10000" }),
267
273
  stdio: ["ignore", "ignore", "ignore"],
268
- });
274
+ detached: DETACH,
275
+ }));
269
276
  let killed = false;
270
277
  const timer = setTimeout(() => {
271
278
  killed = true;
272
- p.kill("SIGKILL");
279
+ end(p, "SIGKILL");
273
280
  }, timeoutMs);
274
281
  p.on("error", () => {
275
282
  clearTimeout(timer);
@@ -1,6 +1,7 @@
1
1
  // Run the Lean extractor against a project, or reuse its last result when nothing it reads has
2
2
  // changed.
3
3
  import { spawn } from "node:child_process";
4
+ import { childEnv, DETACH, grouped } from "./children.js";
4
5
  import { createHash } from "node:crypto";
5
6
  import { existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, renameSync, rmSync, statSync, unlinkSync, writeFileSync } from "node:fs";
6
7
  import { dirname, join, relative } from "node:path";
@@ -70,7 +71,7 @@ export function leanProgram(prog, toolchain, leanRoot, log) {
70
71
  writeFileSync(join(work, "lean-toolchain"), toolchain + "\n");
71
72
  writeFileSync(join(work, "lakefile.toml"), `name = "${prog.exe.replace(/-/g, "")}"\n\n[[lean_exe]]\nname = "${prog.exe}"\nroot = "${prog.root}"\nsupportInterpreter = true\n`);
72
73
  log(`building ${prog.exe} for ${toolchain}…`);
73
- const child = spawn("lake", ["build", prog.exe], { cwd: work, stdio: ["ignore", "pipe", "pipe"] });
74
+ const child = grouped(spawn("lake", ["build", prog.exe], { cwd: work, stdio: ["ignore", "pipe", "pipe"], detached: DETACH, env: childEnv() }));
74
75
  let tail = "";
75
76
  child.stdout.on("data", (b) => (tail = (tail + b).slice(-2000)));
76
77
  child.stderr.on("data", (b) => (tail = (tail + b).slice(-2000)));
@@ -111,7 +112,7 @@ function runExtractor(p, out, opts, log) {
111
112
  const args = opts.bin
112
113
  ? ["env", opts.bin, out, p.libDir, ...p.roots, ...tail_]
113
114
  : ["env", "lean", "--run", EXTRACTOR, out, p.libDir, ...p.roots, ...tail_];
114
- const child = spawn("lake", args, { cwd: p.root, stdio: ["ignore", "pipe", "pipe"] });
115
+ const child = grouped(spawn("lake", args, { cwd: p.root, stdio: ["ignore", "pipe", "pipe"], detached: DETACH, env: childEnv() }));
115
116
  let tail = "";
116
117
  const onData = (buf) => {
117
118
  const text = buf.toString();
@@ -8,6 +8,7 @@
8
8
  // server lacks, an image no tool here can turn into a PNG — prints as a reference instead, and the
9
9
  // export's notes say so.
10
10
  import { spawn, spawnSync } from "node:child_process";
11
+ import { childEnv, DETACH, end, grouped } from "./children.js";
11
12
  import { closeSync, mkdtempSync, openSync, readFileSync, readSync, rmSync } from "node:fs";
12
13
  import { tmpdir } from "node:os";
13
14
  import { join } from "node:path";
@@ -112,8 +113,8 @@ async function toPng(path, type) {
112
113
  const out = join(dir, "out.png");
113
114
  try {
114
115
  await new Promise((ok, no) => {
115
- const p = spawn(t, ["-limit", "memory", "256MiB", "-limit", "disk", "1GiB", `${type}:${path}[0]`, "-depth", "8", `png:${out}`], { stdio: ["ignore", "ignore", "ignore"] });
116
- const timer = setTimeout(() => p.kill("SIGKILL"), CONVERT_MS);
116
+ const p = grouped(spawn(t, ["-limit", "memory", "256MiB", "-limit", "disk", "1GiB", `${type}:${path}[0]`, "-depth", "8", `png:${out}`], { stdio: ["ignore", "ignore", "ignore"], detached: DETACH, env: childEnv() }));
117
+ const timer = setTimeout(() => end(p, "SIGKILL"), CONVERT_MS);
117
118
  p.on("error", (e) => {
118
119
  clearTimeout(timer);
119
120
  no(e);
@@ -0,0 +1,380 @@
1
+ // The tips' tour film (UpdateSpec decision 14, the owner's of 2026-10-17): 3½–4 minutes at
2
+ // 2560×1600, too large for the package and for git, so it is an asset of the repository's GitHub
3
+ // prerelease `tour-1`, and the package pins it (assets/tips/tour.json: its file, sha256 and size).
4
+ // The server fetches it the first time a page asks for it (GET /tips/tour.mp4), one download for
5
+ // every request, checks its size and sha256, keeps it in the caches' root, and serves it from there
6
+ // with byte ranges: no reader's browser reaches GitHub. Under `--updates off` it is never fetched,
7
+ // and the page shows the tour's stills and captions alone.
8
+ //
9
+ // <cache root>/media/ the user's alone (0700), as versions/ is
10
+ // <cache root>/media/<sha256>.mp4 the film, checked, moved into place once whole
11
+ // <cache root>/media/.<sha256>.tmp-<pid> a download under way (that process's)
12
+ // <cache root>/media/pin-<pid> the sha256 of the film a living server pins: kept
13
+ //
14
+ // Servers of different versions share the default cache root, each pinning its own film: a server
15
+ // keeps the film it pins and those living servers' pins name, and removes the rest (an earlier
16
+ // cut, a patch's film replaced) and the downloads of servers that died midway — as it starts, and
17
+ // once its film is kept or confirmed; never while it downloads.
18
+ //
19
+ // VISUALPROOF_TOUR_FILM names a local file served in its place (a film being made: the checkout
20
+ // that makes it); VISUALPROOF_MEDIA_BASE another address to fetch it from, honoured only where it is
21
+ // a loopback address (the tests' asset server).
22
+ import { createHash } from "node:crypto";
23
+ import { chmodSync, createReadStream, createWriteStream, existsSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, statSync, unlinkSync, writeFileSync } from "node:fs";
24
+ import { dirname, join } from "node:path";
25
+ import { WEBFONTS } from "./pipeline.js";
26
+ import { alive, private_ } from "./updates.js";
27
+ /** where the film is published: the repository's prerelease `tour-1` */
28
+ export const FILM_BASE = "https://github.com/LucianoXu/VisualProof/releases/download/tour-1/";
29
+ /** the hosts a fetch of the film may be redirected to: GitHub's own, which keeps a release's
30
+ * assets on its asset hosts (UpdateSpec decision 14) */
31
+ export const FILM_HOSTS = new Set(["github.com", "objects.githubusercontent.com", "release-assets.githubusercontent.com", "github-releases.githubusercontent.com"]);
32
+ /** The film the package pins: `film` of assets/tips/tour.json, or null. */
33
+ export function filmPin(file = join(dirname(WEBFONTS), "tips", "tour.json")) {
34
+ try {
35
+ const f = JSON.parse(readFileSync(file, "utf8"))?.film;
36
+ if (!f || typeof f.file !== "string" || !/^[\w.-]+\.mp4$/.test(f.file) || typeof f.sha256 !== "string" || !/^[0-9a-f]{64}$/.test(f.sha256) || !Number.isSafeInteger(f.size) || f.size <= 0)
37
+ return null;
38
+ return f;
39
+ }
40
+ catch {
41
+ return null;
42
+ }
43
+ }
44
+ const LOOPBACK = /^https?:\/\/(127\.0\.0\.1|localhost|\[::1\])(:\d+)?\//;
45
+ /** The address the film is fetched from: VISUALPROOF_MEDIA_BASE where it is a loopback address (a
46
+ * test's asset server), else the release's. */
47
+ export function filmBase(env = process.env) {
48
+ const b = env.VISUALPROOF_MEDIA_BASE;
49
+ return b && LOOPBACK.test(b.endsWith("/") ? b : `${b}/`) ? (b.endsWith("/") ? b : `${b}/`) : FILM_BASE;
50
+ }
51
+ /** a download that got nothing for this long is given up */
52
+ const IDLE_MS = 60_000;
53
+ /** nor may a download take longer than this, whatever comes */
54
+ const WHOLE_MS = 30 * 60_000;
55
+ /** tried again after this, where it could not be had */
56
+ export const RETRY_MS = 3600_000;
57
+ /** and after this where what came was not the film (another size, another hash) */
58
+ export const MISMATCH_RETRY_MS = 24 * 3600_000;
59
+ /** Whether `url` is one a fetch of the film may go to: https on GitHub's hosts, or, where the film
60
+ * is fetched from a loopback address (a test's), that address's host. */
61
+ function allowed(url, base) {
62
+ if (LOOPBACK.test(base.href))
63
+ return url.host === base.host;
64
+ return url.protocol === "https:" && FILM_HOSTS.has(url.hostname);
65
+ }
66
+ /** The tour film, fetched once on demand, checked, kept and served (UpdateSpec decision 14). */
67
+ export class Film {
68
+ o;
69
+ now;
70
+ base;
71
+ dir;
72
+ /** the one download (or the one check of what is kept) under way, which every request shares */
73
+ work = null;
74
+ failure = null;
75
+ /** checked in this process: what is kept is hashed once, then trusted */
76
+ checked = false;
77
+ constructor(o) {
78
+ this.o = o;
79
+ this.now = o.now ?? Date.now;
80
+ this.base = o.base ?? filmBase();
81
+ this.dir = join(o.cacheRoot, "media");
82
+ }
83
+ /** the film kept, where it is */
84
+ get kept() {
85
+ return this.o.pin ? join(this.dir, `${this.o.pin.sha256}.mp4`) : null;
86
+ }
87
+ status() {
88
+ const pin = this.o.pin;
89
+ if (this.o.local)
90
+ return { state: "local" };
91
+ if (!pin)
92
+ return { state: "none" };
93
+ const of = { sha256: pin.sha256, size: pin.size };
94
+ if (this.o.mode === "off")
95
+ return { state: "off", ...of };
96
+ if (this.work)
97
+ return { state: "fetching", ...of };
98
+ if (this.checked)
99
+ return { state: "cached", ...of };
100
+ if (this.failure && this.now() < this.failure.until)
101
+ return { state: "unavailable", ...of, why: this.failure.why, retry: new Date(this.failure.until).toISOString() };
102
+ return { state: "waiting", ...of };
103
+ }
104
+ /** The film's file, checked; null where there is none to serve now — `--updates off`, none
105
+ * pinned, being fetched, or unavailable for a while. The first ask starts the one download (or
106
+ * the one check of a film kept before), in the background: it outlives the request that started
107
+ * it, and later asks share it. */
108
+ async file() {
109
+ if (this.o.local)
110
+ return existsSync(this.o.local) ? this.o.local : null;
111
+ const pin = this.o.pin;
112
+ const kept = this.kept;
113
+ if (!pin || !kept || this.o.mode === "off")
114
+ return null;
115
+ if (this.checked && existsSync(kept))
116
+ return kept;
117
+ this.checked = false;
118
+ if (this.work)
119
+ return null;
120
+ if (this.failure && this.now() < this.failure.until)
121
+ return null;
122
+ this.work = this.get(pin, kept).finally(() => {
123
+ this.work = null;
124
+ // the film kept or confirmed: what no living server pins goes
125
+ if (this.checked)
126
+ this.prune();
127
+ });
128
+ return null;
129
+ }
130
+ /** Begin, as the server serves: the film it pins marked as in use while it lives (`pin-<pid>`), and
131
+ * what no living server pins removed. Nothing under `--updates off` (no film is served), or
132
+ * without a pin. */
133
+ start() {
134
+ const pin = this.o.pin;
135
+ if (!pin || this.o.mode === "off" || this.ensure())
136
+ return;
137
+ const mark = join(this.dir, `pin-${process.pid}`);
138
+ try {
139
+ writeFileSync(mark, `${pin.sha256}\n`);
140
+ process.once("exit", () => {
141
+ try {
142
+ unlinkSync(mark);
143
+ }
144
+ catch {
145
+ // gone already
146
+ }
147
+ });
148
+ }
149
+ catch {
150
+ // unmarked: another server's prune may remove the film, which is fetched again
151
+ }
152
+ this.prune();
153
+ }
154
+ /** Remove the films no living server pins — this one's own pin kept, and every `pin-<pid>` of a
155
+ * living process' — the pins of the dead, and the downloads of servers that died midway. Never
156
+ * while this server downloads, nor in a folder another may write. */
157
+ prune() {
158
+ if (this.work || !private_(this.dir))
159
+ return;
160
+ let names = [];
161
+ try {
162
+ names = readdirSync(this.dir);
163
+ }
164
+ catch {
165
+ return;
166
+ }
167
+ const keep = new Set(this.o.pin ? [this.o.pin.sha256] : []);
168
+ for (const n of names) {
169
+ const m = /^pin-(\d+)$/.exec(n);
170
+ if (!m)
171
+ continue;
172
+ if (!alive(Number(m[1]))) {
173
+ rmSync(join(this.dir, n), { force: true });
174
+ continue;
175
+ }
176
+ try {
177
+ const sha = readFileSync(join(this.dir, n), "utf8").trim();
178
+ if (/^[0-9a-f]{64}$/.test(sha))
179
+ keep.add(sha);
180
+ }
181
+ catch {
182
+ // gone meanwhile
183
+ }
184
+ }
185
+ for (const n of names) {
186
+ const film = /^([0-9a-f]{64})\.mp4$/.exec(n);
187
+ if (film && !keep.has(film[1])) {
188
+ rmSync(join(this.dir, n), { force: true });
189
+ this.o.log?.(`tour film: ${n} removed (no living server pins it)`);
190
+ }
191
+ }
192
+ this.sweep();
193
+ }
194
+ /** The download or check under way, done (for a test, and for whoever waits on it). */
195
+ async settled() {
196
+ await this.work;
197
+ }
198
+ fail(why, ms) {
199
+ this.failure = { why, until: this.now() + ms };
200
+ this.o.log?.(`tour film: ${why}; tried again ${ms >= MISMATCH_RETRY_MS ? "in a day" : "in an hour"}`);
201
+ }
202
+ /** What is kept, checked once; else the film fetched, checked and moved into place. */
203
+ async get(pin, kept) {
204
+ const bad = this.ensure();
205
+ if (bad)
206
+ return this.fail(bad, RETRY_MS);
207
+ if (existsSync(kept)) {
208
+ if (statSync(kept).size === pin.size && (await sha256Of(kept)) === pin.sha256) {
209
+ this.checked = true;
210
+ return;
211
+ }
212
+ rmSync(kept, { force: true });
213
+ }
214
+ this.sweep();
215
+ const tmp = join(this.dir, `.${pin.sha256}.tmp-${process.pid}`);
216
+ try {
217
+ const why = await this.download(pin, tmp);
218
+ if (why) {
219
+ rmSync(tmp, { force: true });
220
+ return this.fail(why.text, why.mismatch ? MISMATCH_RETRY_MS : RETRY_MS);
221
+ }
222
+ renameSync(tmp, kept);
223
+ this.checked = true;
224
+ this.failure = null;
225
+ this.o.log?.(`tour film: ${pin.file} fetched and checked (${(pin.size / 1e6).toFixed(1)} MB)`);
226
+ }
227
+ catch (e) {
228
+ rmSync(tmp, { force: true });
229
+ this.fail(`it could not be kept: ${e instanceof Error ? e.message : String(e)}`, RETRY_MS);
230
+ }
231
+ }
232
+ /** The folder made where it is not, the user's alone (0700); why it cannot be used, else null. */
233
+ ensure() {
234
+ try {
235
+ if (!existsSync(this.dir)) {
236
+ mkdirSync(this.dir, { recursive: true, mode: 0o700 });
237
+ chmodSync(this.dir, 0o700);
238
+ }
239
+ }
240
+ catch (e) {
241
+ return `${this.dir} cannot be made: ${e.message}`;
242
+ }
243
+ return private_(this.dir) ? null : `${this.dir} is not this user's alone (another owns it, or others may write it)`;
244
+ }
245
+ /** Remove the downloads left by servers that died midway. */
246
+ sweep() {
247
+ let names = [];
248
+ try {
249
+ names = readdirSync(this.dir);
250
+ }
251
+ catch {
252
+ return;
253
+ }
254
+ for (const n of names) {
255
+ const m = /^\.[0-9a-f]{64}\.tmp-(\d+)$/.exec(n);
256
+ if (m && !alive(Number(m[1])))
257
+ rmSync(join(this.dir, n), { force: true });
258
+ }
259
+ }
260
+ /** Fetch the pinned file into `tmp`: redirects followed only to where the film may be (allowed),
261
+ * at most five; no more bytes than the pin's size; given up after a minute with nothing or half
262
+ * an hour in all; the size and sha256 checked. Why not, else null. */
263
+ async download(pin, tmp) {
264
+ const get = this.o.fetch ?? fetch;
265
+ const base = new URL(this.base);
266
+ let url = new URL(pin.file, base);
267
+ const stop = new AbortController();
268
+ const whole = setTimeout(() => stop.abort(new Error("it took more than half an hour")), WHOLE_MS);
269
+ let idle = null;
270
+ const quiet = () => {
271
+ if (idle)
272
+ clearTimeout(idle);
273
+ idle = setTimeout(() => stop.abort(new Error("nothing came for a minute")), IDLE_MS);
274
+ };
275
+ try {
276
+ quiet();
277
+ let res = null;
278
+ for (let hops = 0;; hops++) {
279
+ if (!allowed(url, base))
280
+ return { text: `it was sent to ${url.origin}, which is not where the film is kept: not followed` };
281
+ res = await get(url, { redirect: "manual", signal: stop.signal, headers: { "user-agent": "VisualProof (the tips' tour film)" } });
282
+ if (res.status < 300 || res.status >= 400)
283
+ break;
284
+ const to = res.headers.get("location");
285
+ await res.body?.cancel();
286
+ if (!to || hops >= 5)
287
+ return { text: `${hops >= 5 ? "too many redirects" : "a redirect without an address"} at ${url.origin}` };
288
+ url = new URL(to, url);
289
+ }
290
+ if (!res.ok || !res.body)
291
+ return { text: `${url.origin} answered ${res.status}` };
292
+ const length = Number(res.headers.get("content-length"));
293
+ if (Number.isFinite(length) && length > 0 && length !== pin.size) {
294
+ await res.body.cancel();
295
+ return { text: `it is ${length} bytes, not the ${pin.size} pinned`, mismatch: true };
296
+ }
297
+ const hash = createHash("sha256");
298
+ let got = 0;
299
+ const out = createWriteStream(tmp, { mode: 0o600 });
300
+ const reader = res.body.getReader();
301
+ try {
302
+ for (;;) {
303
+ const { done, value } = await reader.read();
304
+ if (done)
305
+ break;
306
+ quiet();
307
+ got += value.byteLength;
308
+ // never more than the pin's size on the disk
309
+ if (got > pin.size) {
310
+ await reader.cancel();
311
+ return { text: `more than the ${pin.size} bytes pinned came`, mismatch: true };
312
+ }
313
+ hash.update(value);
314
+ if (!out.write(value))
315
+ await new Promise((ok) => out.once("drain", () => ok()));
316
+ }
317
+ }
318
+ finally {
319
+ await new Promise((ok) => out.end(ok));
320
+ }
321
+ if (got !== pin.size)
322
+ return { text: `${got} bytes came, not the ${pin.size} pinned`, mismatch: true };
323
+ const sum = hash.digest("hex");
324
+ if (sum !== pin.sha256)
325
+ return { text: `its sha256 is ${sum.slice(0, 12)}…, not the ${pin.sha256.slice(0, 12)}… pinned`, mismatch: true };
326
+ return null;
327
+ }
328
+ catch (e) {
329
+ const why = stop.signal.aborted && stop.signal.reason instanceof Error ? stop.signal.reason.message : e instanceof Error ? e.message : String(e);
330
+ return { text: `it could not be fetched: ${why}` };
331
+ }
332
+ finally {
333
+ clearTimeout(whole);
334
+ if (idle)
335
+ clearTimeout(idle);
336
+ }
337
+ }
338
+ }
339
+ function sha256Of(file) {
340
+ return new Promise((ok, no) => {
341
+ const h = createHash("sha256");
342
+ createReadStream(file)
343
+ .on("data", (b) => h.update(b))
344
+ .on("end", () => ok(h.digest("hex")))
345
+ .on("error", no);
346
+ });
347
+ }
348
+ // ---------------------------------------------------------------- the server's one film
349
+ let film = null;
350
+ /** Set the server's film up (server/main.ts, as it serves): its cache, its mode, the package's pin,
351
+ * VISUALPROOF_TOUR_FILM. */
352
+ export function useFilm(o) {
353
+ film = new Film({ ...o, pin: o.pin === undefined ? filmPin() : o.pin, local: o.local === undefined ? process.env.VISUALPROOF_TOUR_FILM || null : o.local });
354
+ film.start();
355
+ return film;
356
+ }
357
+ /** The film's file to serve, or null (filmStatus says why): what GET /tips/tour.mp4 serves. */
358
+ export function filmFile() {
359
+ return film ? film.file() : Promise.resolve(null);
360
+ }
361
+ export function filmStatus() {
362
+ return film ? film.status() : { state: "none" };
363
+ }
364
+ /** GET /tips/tour.mp4, whole (UpdateSpec decision 14): the film with byte ranges (`send`, the media
365
+ * route's), or 503 with Retry-After while it is fetched (the first ask starts it), or 404 where
366
+ * there is none to serve (`--updates off`, none pinned, unavailable for a while): the page shows
367
+ * the stills and captions then. */
368
+ export async function serveFilm(req, res, send) {
369
+ const file = await filmFile();
370
+ if (file)
371
+ return send(req, res, file);
372
+ const st = filmStatus();
373
+ const head = { "content-type": "application/json", "cache-control": "no-store" };
374
+ if (st.state === "fetching" || st.state === "waiting") {
375
+ res.writeHead(503, { ...head, "retry-after": "10" });
376
+ return void res.end(JSON.stringify({ film: st.state === "waiting" ? "fetching" : st.state }));
377
+ }
378
+ res.writeHead(404, head);
379
+ res.end(JSON.stringify({ film: st.state, ...(st.why ? { why: st.why } : {}) }));
380
+ }
@@ -28,6 +28,7 @@
28
28
  // deleted.
29
29
  import { createHash } from "node:crypto";
30
30
  import { spawnSync } from "node:child_process";
31
+ import { childEnv } from "./children.js";
31
32
  import { appendFileSync, existsSync, mkdirSync, readdirSync, readFileSync, renameSync, statSync, writeFileSync } from "node:fs";
32
33
  import { join } from "node:path";
33
34
  import { gunzipSync, gzipSync } from "node:zlib";
@@ -58,7 +59,7 @@ export function buildTime(p) {
58
59
  }
59
60
  /** Git, read-only and taking no lock: its answer, or null where git or the repository is not there. */
60
61
  function git(root, ...args) {
61
- const r = spawnSync("git", ["-C", root, ...args], { encoding: "utf8", timeout: 5000, env: { ...process.env, GIT_OPTIONAL_LOCKS: "0" }, stdio: ["ignore", "pipe", "ignore"] });
62
+ const r = spawnSync("git", ["-C", root, ...args], { encoding: "utf8", timeout: 5000, env: childEnv({ GIT_OPTIONAL_LOCKS: "0" }), stdio: ["ignore", "pipe", "ignore"] });
62
63
  return r.status === 0 ? r.stdout.trim() : null;
63
64
  }
64
65
  /** The commit checked out now, and whether a `.lean` file differs from it. */
@@ -302,6 +303,14 @@ export class History {
302
303
  get(id) {
303
304
  return this.infos.get(id) ?? null;
304
305
  }
306
+ /** The VisualProof version that read the snapshot read last, where one says it. */
307
+ lastVersion() {
308
+ let last = null;
309
+ for (const i of this.infos.values())
310
+ if (i.tool && (!last || i.read > last.read))
311
+ last = i;
312
+ return last?.tool ?? null;
313
+ }
305
314
  /** The latest build's snapshot. */
306
315
  latest() {
307
316
  const l = this.list();
@@ -408,6 +417,7 @@ export class History {
408
417
  ...(o.imported || same?.imported ? { imported: true } : {}),
409
418
  ...(same?.renames ? { renames: same.renames } : {}),
410
419
  ...(texts ? { sources: texts } : {}),
420
+ ...(o.tool ? { tool: o.tool } : {}),
411
421
  };
412
422
  this.save(info);
413
423
  this.readOf(id).add(o.fingerprint);
@@ -23,6 +23,7 @@
23
23
  // with why; a question Lean does not answer in REQUEST_MS is answered that it did not; and what
24
24
  // Lean is doing is part of /api/status (`state`).
25
25
  import { spawn, spawnSync } from "node:child_process";
26
+ import { childEnv, DETACH, end, grouped } from "./children.js";
26
27
  import { existsSync, mkdirSync } from "node:fs";
27
28
  import { join } from "node:path";
28
29
  import { pathToFileURL } from "node:url";
@@ -94,9 +95,9 @@ export class LeanHover {
94
95
  if (this.env)
95
96
  return this.env;
96
97
  const root = this.project.root;
97
- const which = spawnSync("elan", ["which", "lean"], { cwd: root, encoding: "utf8", timeout: 60000 });
98
+ const which = spawnSync("elan", ["which", "lean"], { cwd: root, encoding: "utf8", timeout: 60000, env: childEnv() });
98
99
  const lean = which.status === 0 && which.stdout.trim() ? which.stdout.trim() : "lean";
99
- const env = spawnSync("lake", ["env", "printenv", "LEAN_PATH"], { cwd: root, encoding: "utf8", timeout: 120000 });
100
+ const env = spawnSync("lake", ["env", "printenv", "LEAN_PATH"], { cwd: root, encoding: "utf8", timeout: 120000, env: childEnv() });
100
101
  if (env.status !== 0)
101
102
  throw new Error(`lake env failed: ${(env.stderr || env.stdout || "").trim().slice(0, 300)}`);
102
103
  this.env = { lean, leanPath: env.stdout.trim() };
@@ -109,7 +110,8 @@ export class LeanHover {
109
110
  const { lean, leanPath } = this.resolveEnv();
110
111
  if (!existsSync(this.dir))
111
112
  mkdirSync(this.dir, { recursive: true });
112
- const p = spawn(lean, ["--server"], { cwd: this.dir, env: { ...process.env, LEAN_PATH: leanPath }, stdio: ["pipe", "pipe", "pipe"] });
113
+ // a group of its own, which stopping ends with its workers (server/children.ts)
114
+ const p = grouped(spawn(lean, ["--server"], { cwd: this.dir, env: childEnv({ LEAN_PATH: leanPath }), stdio: ["pipe", "pipe", "pipe"], detached: DETACH }));
113
115
  this.proc = p;
114
116
  // what Lean writes is read here, in a callback no request waits on: nothing it writes may
115
117
  // throw out of it, or the server stops (read() skips what it cannot read)
@@ -141,7 +143,7 @@ export class LeanHover {
141
143
  if (init.error) {
142
144
  const why = this.failure ?? init.error.message;
143
145
  this.reset();
144
- p.kill();
146
+ end(p);
145
147
  throw new Error(why);
146
148
  }
147
149
  this.notify("initialized", {});
@@ -586,7 +588,8 @@ export class LeanHover {
586
588
  this.idle = null;
587
589
  const p = this.proc;
588
590
  this.reset();
589
- p?.kill();
591
+ if (p)
592
+ end(p);
590
593
  }
591
594
  }
592
595
  /** Null after `ms`: what a question for Lean is raced against. */