visualproof 0.3.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 +52 -11
- 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/mcp/visualproof-mcp.mjs +3 -1
- package/dist/server/agent.js +10 -5
- 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 +66 -9
- package/dist/server/main.js +217 -16
- package/dist/server/media.js +114 -0
- package/dist/server/pipeline.js +6 -1
- package/dist/server/poppler.js +3 -2
- package/dist/server/readonlygate.js +4 -4
- 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/shared/timeline.js +5 -0
- package/dist/web/bundle/app.js +2298 -254
- package/dist/web/index.html +13 -1
- package/dist/web/src/canvas.js +22 -0
- package/dist/web/style.css +189 -1
- package/docs/agent-api/export.md +2 -2
- package/docs/agent-api/history.md +3 -2
- 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
|
+
}
|
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. */
|
package/dist/server/live.js
CHANGED
|
@@ -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.
|
|
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());
|