visualproof 0.3.0 → 0.4.1

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 (49) hide show
  1. package/README.md +52 -11
  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/mcp/visualproof-mcp.mjs +3 -1
  20. package/dist/server/agent.js +10 -5
  21. package/dist/server/children.js +85 -0
  22. package/dist/server/doctor.js +105 -42
  23. package/dist/server/events.js +10 -0
  24. package/dist/server/export.js +11 -4
  25. package/dist/server/extract.js +3 -2
  26. package/dist/server/figures.js +3 -2
  27. package/dist/server/film.js +380 -0
  28. package/dist/server/history.js +11 -1
  29. package/dist/server/hover.js +8 -5
  30. package/dist/server/live.js +66 -9
  31. package/dist/server/main.js +217 -16
  32. package/dist/server/media.js +114 -0
  33. package/dist/server/pipeline.js +6 -1
  34. package/dist/server/poppler.js +3 -2
  35. package/dist/server/readonlygate.js +4 -4
  36. package/dist/server/sourcestore.js +3 -2
  37. package/dist/server/status.js +3 -2
  38. package/dist/server/supervise.js +219 -0
  39. package/dist/server/updates.js +951 -0
  40. package/dist/server/usage.js +3 -1
  41. package/dist/server/version.js +183 -0
  42. package/dist/shared/timeline.js +5 -0
  43. package/dist/web/bundle/app.js +2299 -255
  44. package/dist/web/index.html +13 -1
  45. package/dist/web/src/canvas.js +22 -0
  46. package/dist/web/style.css +189 -1
  47. package/docs/agent-api/export.md +2 -2
  48. package/docs/agent-api/history.md +3 -2
  49. package/package.json +3 -1
@@ -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. */
@@ -39,6 +39,8 @@ import { join } from "node:path";
39
39
  import { PROJECT, viewTitle } from "../shared/views.js";
40
40
  import { boardSet } from "../shared/board.js";
41
41
  import { derivesOn, existsIn } from "../shared/noteplace.js";
42
+ import { packageVersion } from "./version.js";
43
+ import { withoutReasons } from "../shared/timeline.js";
42
44
  const TICK = 1000;
43
45
  /** outputs quiet this long, and no compile running: the build is over */
44
46
  const SETTLE = 2500;
@@ -58,8 +60,6 @@ export const MAX_BUFFERED = 4 << 20;
58
60
  const PRESENCE_EVERY = 500;
59
61
  /** The graph's modules and their files (meta.modules), per graph read (agentviews.ts, graphOf). */
60
62
  const moduleFiles = new WeakMap();
61
- /** What a stream's page alone is told of it (`hello`). */
62
- const helloOf = (st) => ({ id: st.id, token: st.token, ...(st.private ? { private: true } : {}) });
63
63
  export class Live {
64
64
  project;
65
65
  /** the site directory and the project's caches (server/dirs.ts) */
@@ -85,6 +85,15 @@ export class Live {
85
85
  /** review changes not yet sent: those made in one turn of the event loop go out as one */
86
86
  reviewPending = [];
87
87
  version = 0;
88
+ /** this server's process, as its pages know it (UpdateSpec decision 8): the graph's versions are
89
+ * counted again by another */
90
+ instance = randomBytes(6).toString("base64url");
91
+ /** the VisualProof version this server runs, read as it started */
92
+ toolVersion = packageVersion();
93
+ /** what a page is told of VisualProof's releases (server/updates.ts): everything, or the version
94
+ * alone (`full` false: a read-only reader, an agent); null where nobody follows them (a test's
95
+ * server) */
96
+ updateStatus = null;
88
97
  /** the review's logs (ReviewSpec.md, server/review.ts) */
89
98
  review;
90
99
  /** who has a page open (CollabSpec.md) */
@@ -140,7 +149,7 @@ export class Live {
140
149
  this.editor = editor;
141
150
  this.usage = new UsagePass(project, dirs, saved ? 0 : jobs, (l) => this.say(l));
142
151
  this.tracker = new BuildTracker(project);
143
- this.timeline = new Timeline(dirs.site, (e) => this.send("activity", e));
152
+ this.timeline = new Timeline(dirs.site, (e) => this.sendActivity(e));
144
153
  this.tokens = new AgentTokens(dirs.site);
145
154
  this.invites = new Invites(dirs.site);
146
155
  // every page knows the names the people invited by a link go by, so that what each did shows
@@ -304,7 +313,7 @@ export class Live {
304
313
  // a private reader cannot be followed: what they asked is withdrawn
305
314
  if (priv)
306
315
  this.asks.withdraw(s.token);
307
- this.write(s.res, "hello", helloOf(s));
316
+ this.write(s.res, "hello", this.helloOf(s));
308
317
  if (s.session) {
309
318
  if (priv)
310
319
  this.views.hold(s.session, was ? [...(was.view ? [was.view] : []), ...was.tabs] : []);
@@ -660,7 +669,7 @@ export class Live {
660
669
  written = found.written;
661
670
  return found;
662
671
  };
663
- const r = this.history.record(ex.graph, { at: buildTime(this.project), fingerprint: ex.fingerprint, commit, toolchain: this.project.toolchain, missing, sources });
672
+ const r = this.history.record(ex.graph, { at: buildTime(this.project), fingerprint: ex.fingerprint, commit, toolchain: this.project.toolchain, missing, sources, tool: this.toolVersion });
664
673
  if (!r.written)
665
674
  return renamed;
666
675
  const s = r.info;
@@ -956,7 +965,53 @@ export class Live {
956
965
  if (!this.graph)
957
966
  return null;
958
967
  const { prep: _, ...info } = this.graph;
959
- return info;
968
+ return { ...info, instance: this.instance };
969
+ }
970
+ /** What a stream's page alone is told of it (`hello`), and which server it is (UpdateSpec
971
+ * decision 8): its version, and its process. */
972
+ helloOf(st) {
973
+ return { id: st.id, token: st.token, ...(st.private ? { private: true } : {}), server: { version: this.toolVersion, instance: this.instance } };
974
+ }
975
+ /** How many pages are open on the server (their event streams), read-only readers' too. */
976
+ get pages() {
977
+ return this.streams.size;
978
+ }
979
+ /** Whether the server is working on the build (an extraction, a re-layout, the usage pass):
980
+ * no moment to restart in (UpdateSpec decision 6). */
981
+ get busy() {
982
+ return this.extracting || this.usage.status.state === "running";
983
+ }
984
+ /** What happened, to every page: a version set aside, without why, to those not told all
985
+ * (UpdateSpec decision 11). */
986
+ sendActivity(e) {
987
+ if (e.kind !== "tool" || !e.setAside)
988
+ return this.send("activity", e);
989
+ const full = `event: activity\ndata: ${JSON.stringify(e)}\n\n`;
990
+ const slim = `event: activity\ndata: ${JSON.stringify(withoutReasons(e))}\n\n`;
991
+ for (const st of [...this.streams.values()])
992
+ this.push(st, st.toldAll ? full : slim);
993
+ }
994
+ /** What is known of VisualProof's releases changed: each page is told what it may know of it
995
+ * (`toldAll`, as GET /api/updates says it). */
996
+ updatesChanged() {
997
+ const status = this.updateStatus;
998
+ if (!status)
999
+ return;
1000
+ const full = `event: updates\ndata: ${JSON.stringify(status(true))}\n\n`;
1001
+ const slim = `event: updates\ndata: ${JSON.stringify(status(false))}\n\n`;
1002
+ for (const st of [...this.streams.values()])
1003
+ this.push(st, st.toldAll ? full : slim);
1004
+ }
1005
+ /** The server stops (UpdateSpec decision 9): every page is told why — a restart into another
1006
+ * version (`update`), or a stop (`bye`) — and its stream ended, so that it comes back to the
1007
+ * server that answers next. */
1008
+ end(why) {
1009
+ const text = "update" in why ? `event: update\ndata: ${JSON.stringify(why.update)}\n\n` : `event: bye\ndata: ${JSON.stringify({ why: "stopped" })}\n\n`;
1010
+ for (const st of [...this.streams.values()]) {
1011
+ this.streams.delete(st.res);
1012
+ this.byToken.delete(st.token);
1013
+ st.res.end(text);
1014
+ }
960
1015
  }
961
1016
  /** What /api/status says: enough for the loader and for a page that just connected. */
962
1017
  status() {
@@ -981,7 +1036,7 @@ export class Live {
981
1036
  * it), `person` the personal link it came by. The stream is named by the server: the page is
982
1037
  * told its id, which everyone sees, and a secret for its heartbeats (`hello`); it is gone from
983
1038
  * presence when its stream closes. */
984
- subscribe(res, session = null, person = null, readOnly = null) {
1039
+ subscribe(res, session = null, person = null, readOnly = null, toldAll = readOnly === null) {
985
1040
  if (session) {
986
1041
  // a page that opens one more than a page needs (a reload whose old stream lingers) loses
987
1042
  // its oldest
@@ -1001,12 +1056,14 @@ export class Live {
1001
1056
  connection: "keep-alive",
1002
1057
  });
1003
1058
  res.write("retry: 2000\n\n");
1004
- const st = { res, id: randomBytes(9).toString("base64url"), token: randomBytes(24).toString("base64url"), session, private: false, person, readOnly };
1059
+ const st = { res, id: randomBytes(9).toString("base64url"), token: randomBytes(24).toString("base64url"), session, private: false, person, readOnly, toldAll };
1005
1060
  this.streams.set(res, st);
1006
1061
  this.byToken.set(st.token, st);
1007
1062
  this.presence.open(st.id);
1008
1063
  res.on("close", () => this.close(st));
1009
- this.write(res, "hello", helloOf(st));
1064
+ this.write(res, "hello", this.helloOf(st));
1065
+ if (this.updateStatus)
1066
+ this.write(res, "updates", this.updateStatus(st.toldAll));
1010
1067
  this.write(res, "presence", this.presence.list());
1011
1068
  this.write(res, "private", { count: this.presence.privateCount() });
1012
1069
  this.write(res, "people", this.invites.people());