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.
- package/README.md +88 -33
- package/dist/assets/tips/phone-fingers.light.mp4 +0 -0
- package/dist/assets/tips/phone-fingers.light.webp +0 -0
- package/dist/assets/tips/tour-agent.webp +0 -0
- package/dist/assets/tips/tour-arrange.webp +0 -0
- package/dist/assets/tips/tour-artwork.webp +0 -0
- package/dist/assets/tips/tour-checks.webp +0 -0
- package/dist/assets/tips/tour-export.webp +0 -0
- package/dist/assets/tips/tour-keys.webp +0 -0
- package/dist/assets/tips/tour-library.webp +0 -0
- package/dist/assets/tips/tour-project.webp +0 -0
- package/dist/assets/tips/tour-review.webp +0 -0
- package/dist/assets/tips/tour-share.webp +0 -0
- package/dist/assets/tips/tour-source.webp +0 -0
- package/dist/assets/tips/tour-target.webp +0 -0
- package/dist/assets/tips/tour-view.webp +0 -0
- package/dist/assets/tips/tour.json +289 -0
- package/dist/assets/tips/tour.webp +0 -0
- package/dist/assets/webfonts/LICENSE-Noto.txt +95 -0
- package/dist/assets/webfonts/metrics.json +1 -0
- package/dist/assets/webfonts/ttf/jost-400-normal.ttf +0 -0
- package/dist/assets/webfonts/ttf/jost-500-normal.ttf +0 -0
- package/dist/assets/webfonts/ttf/noto-sans-math.ttf +0 -0
- package/dist/assets/webfonts/ttf/noto-sans.ttf +0 -0
- package/dist/assets/webfonts/ttf/source-sans-3-400-italic.ttf +0 -0
- package/dist/assets/webfonts/ttf/source-sans-3-400-normal.ttf +0 -0
- package/dist/assets/webfonts/ttf/source-sans-3-500-normal.ttf +0 -0
- package/dist/assets/webfonts/ttf/source-sans-3-600-normal.ttf +0 -0
- package/dist/assets/webfonts/ttf/source-sans-3-700-normal.ttf +0 -0
- package/dist/assets/webfonts/ttf/source-serif-4-400-italic.ttf +0 -0
- package/dist/assets/webfonts/ttf/source-serif-4-600-italic.ttf +0 -0
- package/dist/mcp/visualproof-mcp.mjs +3 -1
- package/dist/server/agent.js +94 -91
- package/dist/server/agentdocs.js +1 -1
- package/dist/server/artcmd.js +335 -0
- package/dist/server/children.js +85 -0
- package/dist/server/doctor.js +105 -42
- package/dist/server/events.js +10 -0
- package/dist/server/export.js +11 -4
- package/dist/server/extract.js +3 -2
- package/dist/server/figures.js +3 -2
- package/dist/server/film.js +380 -0
- package/dist/server/history.js +11 -1
- package/dist/server/hover.js +8 -5
- package/dist/server/live.js +72 -9
- package/dist/server/main.js +352 -23
- package/dist/server/mcptools.js +4 -1
- package/dist/server/media.js +114 -0
- package/dist/server/pipeline.js +21 -10
- package/dist/server/poppler.js +3 -2
- package/dist/server/readonlygate.js +4 -4
- package/dist/server/review.js +4 -1
- package/dist/server/reviewapi.js +35 -1
- package/dist/server/snapshot.js +10 -1
- package/dist/server/sourcestore.js +3 -2
- package/dist/server/status.js +3 -2
- package/dist/server/supervise.js +219 -0
- package/dist/server/updates.js +951 -0
- package/dist/server/usage.js +3 -1
- package/dist/server/version.js +183 -0
- package/dist/server/view.js +0 -7
- package/dist/server/views.js +24 -0
- package/dist/shared/artfonts.js +81 -0
- package/dist/shared/artrecipe.js +316 -0
- package/dist/shared/collab.js +7 -2
- package/dist/shared/declfind.js +95 -0
- package/dist/shared/lostfound.js +1 -0
- package/dist/shared/pastgraph.js +6 -6
- package/dist/shared/review.js +62 -5
- package/dist/shared/timeline.js +5 -0
- package/dist/web/bundle/app.js +3006 -413
- package/dist/web/bundle/edges.js +1 -1
- package/dist/web/index.html +13 -1
- package/dist/web/src/agentmark.js +14 -0
- package/dist/web/src/artwork.js +1000 -0
- package/dist/web/src/bundles.js +151 -0
- package/dist/web/src/canvas.js +3716 -0
- package/dist/web/src/drag.js +61 -0
- package/dist/web/src/edgequeue.js +204 -0
- package/dist/web/src/gl/gl.js +123 -0
- package/dist/web/src/gl/renderer.js +451 -0
- package/dist/web/src/halo.js +95 -0
- package/dist/web/src/highlight.js +108 -0
- package/dist/web/src/overview.js +393 -0
- package/dist/web/src/reviewqueue.js +250 -0
- package/dist/web/src/selection.js +272 -0
- package/dist/web/src/status.js +80 -0
- package/dist/web/src/visitors.js +318 -0
- package/dist/web/src/visits.js +174 -0
- package/dist/web/style.css +196 -1
- package/docs/agent-api/README.md +1 -1
- package/docs/agent-api/export.md +41 -2
- package/docs/agent-api/history.md +5 -3
- package/docs/agent-api/mcp.md +1 -1
- package/docs/agent-api/reading.md +2 -1
- package/package.json +5 -1
package/dist/server/export.js
CHANGED
|
@@ -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: {
|
|
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
|
|
279
|
+
end(p, "SIGKILL");
|
|
273
280
|
}, timeoutMs);
|
|
274
281
|
p.on("error", () => {
|
|
275
282
|
clearTimeout(timer);
|
package/dist/server/extract.js
CHANGED
|
@@ -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();
|
package/dist/server/figures.js
CHANGED
|
@@ -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
|
|
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
|
+
}
|
package/dist/server/history.js
CHANGED
|
@@ -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: {
|
|
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);
|
package/dist/server/hover.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
|
591
|
+
if (p)
|
|
592
|
+
end(p);
|
|
590
593
|
}
|
|
591
594
|
}
|
|
592
595
|
/** Null after `ms`: what a question for Lean is raced against. */
|