visualproof 0.2.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (96) hide show
  1. package/README.md +88 -33
  2. package/dist/assets/tips/phone-fingers.light.mp4 +0 -0
  3. package/dist/assets/tips/phone-fingers.light.webp +0 -0
  4. package/dist/assets/tips/tour-agent.webp +0 -0
  5. package/dist/assets/tips/tour-arrange.webp +0 -0
  6. package/dist/assets/tips/tour-artwork.webp +0 -0
  7. package/dist/assets/tips/tour-checks.webp +0 -0
  8. package/dist/assets/tips/tour-export.webp +0 -0
  9. package/dist/assets/tips/tour-keys.webp +0 -0
  10. package/dist/assets/tips/tour-library.webp +0 -0
  11. package/dist/assets/tips/tour-project.webp +0 -0
  12. package/dist/assets/tips/tour-review.webp +0 -0
  13. package/dist/assets/tips/tour-share.webp +0 -0
  14. package/dist/assets/tips/tour-source.webp +0 -0
  15. package/dist/assets/tips/tour-target.webp +0 -0
  16. package/dist/assets/tips/tour-view.webp +0 -0
  17. package/dist/assets/tips/tour.json +289 -0
  18. package/dist/assets/tips/tour.webp +0 -0
  19. package/dist/assets/webfonts/LICENSE-Noto.txt +95 -0
  20. package/dist/assets/webfonts/metrics.json +1 -0
  21. package/dist/assets/webfonts/ttf/jost-400-normal.ttf +0 -0
  22. package/dist/assets/webfonts/ttf/jost-500-normal.ttf +0 -0
  23. package/dist/assets/webfonts/ttf/noto-sans-math.ttf +0 -0
  24. package/dist/assets/webfonts/ttf/noto-sans.ttf +0 -0
  25. package/dist/assets/webfonts/ttf/source-sans-3-400-italic.ttf +0 -0
  26. package/dist/assets/webfonts/ttf/source-sans-3-400-normal.ttf +0 -0
  27. package/dist/assets/webfonts/ttf/source-sans-3-500-normal.ttf +0 -0
  28. package/dist/assets/webfonts/ttf/source-sans-3-600-normal.ttf +0 -0
  29. package/dist/assets/webfonts/ttf/source-sans-3-700-normal.ttf +0 -0
  30. package/dist/assets/webfonts/ttf/source-serif-4-400-italic.ttf +0 -0
  31. package/dist/assets/webfonts/ttf/source-serif-4-600-italic.ttf +0 -0
  32. package/dist/mcp/visualproof-mcp.mjs +3 -1
  33. package/dist/server/agent.js +94 -91
  34. package/dist/server/agentdocs.js +1 -1
  35. package/dist/server/artcmd.js +335 -0
  36. package/dist/server/children.js +85 -0
  37. package/dist/server/doctor.js +105 -42
  38. package/dist/server/events.js +10 -0
  39. package/dist/server/export.js +11 -4
  40. package/dist/server/extract.js +3 -2
  41. package/dist/server/figures.js +3 -2
  42. package/dist/server/film.js +380 -0
  43. package/dist/server/history.js +11 -1
  44. package/dist/server/hover.js +8 -5
  45. package/dist/server/live.js +72 -9
  46. package/dist/server/main.js +352 -23
  47. package/dist/server/mcptools.js +4 -1
  48. package/dist/server/media.js +114 -0
  49. package/dist/server/pipeline.js +21 -10
  50. package/dist/server/poppler.js +3 -2
  51. package/dist/server/readonlygate.js +4 -4
  52. package/dist/server/review.js +4 -1
  53. package/dist/server/reviewapi.js +35 -1
  54. package/dist/server/snapshot.js +10 -1
  55. package/dist/server/sourcestore.js +3 -2
  56. package/dist/server/status.js +3 -2
  57. package/dist/server/supervise.js +219 -0
  58. package/dist/server/updates.js +951 -0
  59. package/dist/server/usage.js +3 -1
  60. package/dist/server/version.js +183 -0
  61. package/dist/server/view.js +0 -7
  62. package/dist/server/views.js +24 -0
  63. package/dist/shared/artfonts.js +81 -0
  64. package/dist/shared/artrecipe.js +316 -0
  65. package/dist/shared/collab.js +7 -2
  66. package/dist/shared/declfind.js +95 -0
  67. package/dist/shared/lostfound.js +1 -0
  68. package/dist/shared/pastgraph.js +6 -6
  69. package/dist/shared/review.js +62 -5
  70. package/dist/shared/timeline.js +5 -0
  71. package/dist/web/bundle/app.js +3006 -413
  72. package/dist/web/bundle/edges.js +1 -1
  73. package/dist/web/index.html +13 -1
  74. package/dist/web/src/agentmark.js +14 -0
  75. package/dist/web/src/artwork.js +1000 -0
  76. package/dist/web/src/bundles.js +151 -0
  77. package/dist/web/src/canvas.js +3716 -0
  78. package/dist/web/src/drag.js +61 -0
  79. package/dist/web/src/edgequeue.js +204 -0
  80. package/dist/web/src/gl/gl.js +123 -0
  81. package/dist/web/src/gl/renderer.js +451 -0
  82. package/dist/web/src/halo.js +95 -0
  83. package/dist/web/src/highlight.js +108 -0
  84. package/dist/web/src/overview.js +393 -0
  85. package/dist/web/src/reviewqueue.js +250 -0
  86. package/dist/web/src/selection.js +272 -0
  87. package/dist/web/src/status.js +80 -0
  88. package/dist/web/src/visitors.js +318 -0
  89. package/dist/web/src/visits.js +174 -0
  90. package/dist/web/style.css +196 -1
  91. package/docs/agent-api/README.md +1 -1
  92. package/docs/agent-api/export.md +41 -2
  93. package/docs/agent-api/history.md +5 -3
  94. package/docs/agent-api/mcp.md +1 -1
  95. package/docs/agent-api/reading.md +2 -1
  96. package/package.json +5 -1
@@ -0,0 +1,114 @@
1
+ // What the sheet's tips play (GuideSpec decision 6): the tour film's data, assets/tips/tour.json (the
2
+ // film pinned by its sha256, its chapters, its captions), its poster tour.webp and each chapter's
3
+ // still tour-<chapter>.webp; its captions as WebVTT, made from tour.json; the film itself,
4
+ // /tips/tour.mp4, which is not in the package (server/film.ts fetches it); and the phone's gestures
5
+ // (phone-fingers.light.mp4). Served as they are (they are compressed already) and with byte
6
+ // ranges, which Safari needs to play a video at all; and which clips there are.
7
+ import { closeSync, createReadStream, fstatSync, openSync, readdirSync, readFileSync } from "node:fs";
8
+ import { dirname, join } from "node:path";
9
+ import { pipeline } from "node:stream";
10
+ import { WEBFONTS } from "./pipeline.js";
11
+ /** where the clips are: beside the artworks' fonts, in the checkout's src/assets or the package's dist/assets */
12
+ export const TIPS_DIR = join(dirname(WEBFONTS), "tips");
13
+ /** a clip's or its key frame's address, the film's data, poster and stills */
14
+ export const TIP_PATH = /^\/tips\/(?:[a-z0-9]+(?:-[a-z0-9]+)*\.(?:light|dark)\.(?:mp4|webp)|tour(?:-[a-z0-9]+)?\.webp|tour\.json)$/;
15
+ /** A time of a WebVTT cue: hh:mm:ss.mmm. */
16
+ function vttTime(t) {
17
+ const ms = Math.max(0, Math.round(t * 1000));
18
+ const p = (n, w = 2) => String(n).padStart(w, "0");
19
+ return `${p(Math.floor(ms / 3_600_000))}:${p(Math.floor(ms / 60_000) % 60)}:${p(Math.floor(ms / 1000) % 60)}.${p(ms % 1000, 3)}`;
20
+ }
21
+ /** The film's captions as WebVTT, for assistive technology (the page draws its own from
22
+ * tour.json): each cue in order, its text on one line, nothing in it read as markup. Null where
23
+ * tour.json says none. */
24
+ export function tourVtt(raw) {
25
+ const caps = raw?.captions;
26
+ if (!Array.isArray(caps))
27
+ return null;
28
+ const cues = caps.filter((c) => !!c && typeof c.start === "number" && typeof c.end === "number" && c.end > c.start && typeof c.text === "string");
29
+ const text = (t) => t.replace(/[\r\n]+/g, " ").replace(/-->/g, "→").replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
30
+ return "WEBVTT\n\n" + cues.map((c) => `${vttTime(c.start)} --> ${vttTime(c.end)}\n${text(c.text)}\n`).join("\n");
31
+ }
32
+ /** tour.json as the package holds it; null where there is none. */
33
+ export function tourData(dir = TIPS_DIR) {
34
+ try {
35
+ return JSON.parse(readFileSync(join(dir, "tour.json"), "utf8"));
36
+ }
37
+ catch {
38
+ return null;
39
+ }
40
+ }
41
+ const TYPES = { mp4: "video/mp4", webp: "image/webp", json: "application/json" };
42
+ /** The clips there are: those recorded in the light theme (the film is light only). */
43
+ export function tipClips(dir = TIPS_DIR) {
44
+ let files;
45
+ try {
46
+ files = readdirSync(dir);
47
+ }
48
+ catch {
49
+ return [];
50
+ }
51
+ return files.flatMap((f) => {
52
+ const m = /^([a-z0-9]+(?:-[a-z0-9]+)*)\.light\.mp4$/.exec(f);
53
+ return m ? [m[1]] : [];
54
+ });
55
+ }
56
+ /** Whether the request already has the content `etag` names. */
57
+ function fresh(req, etag) {
58
+ const have = req.headers["if-none-match"];
59
+ return !!have && have.split(",").some((t) => t.trim().replace(/^W\//, "") === etag);
60
+ }
61
+ function answer(res, code, headers = {}) {
62
+ res.writeHead(code, headers);
63
+ res.end();
64
+ }
65
+ /** A clip or a key frame: the whole file, or the range asked for (206), or what cannot be given
66
+ * (416). The file is opened before anything is answered, so that one that cannot be read is a
67
+ * 404 rather than a cut connection; it is closed however the answer ends — sent, refused, or the
68
+ * reader gone in the middle of it. */
69
+ export function sendMedia(req, res, file) {
70
+ let fd;
71
+ try {
72
+ fd = openSync(file, "r");
73
+ }
74
+ catch {
75
+ return answer(res, 404, { "content-type": "application/json", "cache-control": "no-store" });
76
+ }
77
+ let size, mtime;
78
+ try {
79
+ ({ size, mtimeMs: mtime } = fstatSync(fd));
80
+ }
81
+ catch {
82
+ closeSync(fd);
83
+ return answer(res, 404);
84
+ }
85
+ const etag = `"${size.toString(36)}-${Math.round(mtime).toString(36)}"`;
86
+ const head = { "content-type": TYPES[file.slice(file.lastIndexOf(".") + 1)] ?? "application/octet-stream", "cache-control": "no-cache", etag, "accept-ranges": "bytes" };
87
+ const done = (code, headers) => {
88
+ closeSync(fd);
89
+ answer(res, code, headers);
90
+ };
91
+ if (fresh(req, etag))
92
+ return done(304, head);
93
+ const m = /^bytes=(\d*)-(\d*)$/.exec(String(req.headers.range ?? "").trim());
94
+ let start = 0, end = size - 1;
95
+ if (m && (m[1] || m[2])) {
96
+ // from a byte to another (or to the end), or the last so many
97
+ if (m[1])
98
+ [start, end] = [Number(m[1]), m[2] ? Math.min(Number(m[2]), size - 1) : size - 1];
99
+ else
100
+ start = Math.max(0, size - Number(m[2]));
101
+ if (start > end)
102
+ return done(416, { "content-range": `bytes */${size}` });
103
+ res.writeHead(206, { ...head, "content-range": `bytes ${start}-${end}/${size}`, "content-length": end - start + 1 });
104
+ }
105
+ else
106
+ res.writeHead(200, { ...head, "content-length": size });
107
+ if (req.method === "HEAD" || size === 0) {
108
+ closeSync(fd);
109
+ return void res.end();
110
+ }
111
+ // the stream owns the descriptor from here: pipeline destroys it, and so closes the file, when
112
+ // the answer is sent, fails, or the reader goes away
113
+ pipeline(createReadStream(file, { fd, start, end, autoClose: true }), res, () => { });
114
+ }
@@ -11,6 +11,7 @@ import { cachedExtraction, extract, loadSaved, prune } from "./extract.js";
11
11
  import { memoryOf } from "../shared/layout.js";
12
12
  import { buildViewOffThread } from "./view.js";
13
13
  import { cacheHome } from "./dirs.js";
14
+ import { packageVersion } from "./version.js";
14
15
  /** the tool's own folder, `src/`; the repository is above it */
15
16
  export const SRC = resolve(dirname(fileURLToPath(import.meta.url)), "..");
16
17
  /** the fonts the page draws an artwork's names and title in (ExportSpec decision 19): Source Sans 3
@@ -41,8 +42,9 @@ export function bundleDir() {
41
42
  * views from an older server are rebuilt rather than served. 7: a notation unit's statement is
42
43
  * its command's text. 8: notation units carry their Lean constant, display form, what they
43
44
  * interpret and their rules. 9: a node's hash is its version, beside the kernel's
44
- * (server/versions.ts). 10: the project's git source and commit (server/history.ts, gitSource). */
45
- const VIEW_VERSION = 10;
45
+ * (server/versions.ts). 10: the project's git source and commit (server/history.ts, gitSource). 11:
46
+ * not them any more: they are asked when wanted (GET /api/git), not kept with the view. */
47
+ const VIEW_VERSION = 11;
46
48
  function loadMemory(siteDir) {
47
49
  try {
48
50
  const mem = JSON.parse(readFileSync(join(siteDir, "layout-memory.json"), "utf8"));
@@ -63,7 +65,9 @@ export async function prepare(project, dirs, saved, log, opts = {}) {
63
65
  // `reuse`: lay out an extraction already held, without reading the build (which may be
64
66
  // half-written right now); `extraction`: one in hand (the usage pass's, merged); `provisional`:
65
67
  // the graph waits for its usage pass; `completes`: the fingerprint of the build's extraction that
66
- // this graph, the usage pass's merged, completes
68
+ // this graph, the usage pass's merged, completes; `readOnlySite`: the site's layout memory read,
69
+ // never written, and the view laid out not kept in the cache either, which a server would take for
70
+ // its site's (the artwork command, ExportSpec decision 21)
67
71
  const held = opts.extraction ?? (opts.reuse ? cachedExtraction(dirs.cache, opts.reuse) : null);
68
72
  if (opts.reuse && !held)
69
73
  throw new Error("the graph on screen is no longer cached; it is laid out afresh at the next build");
@@ -72,7 +76,6 @@ export async function prepare(project, dirs, saved, log, opts = {}) {
72
76
  log(ex.cached ? `extraction ${ex.fingerprint} reused` : `extraction ${ex.fingerprint} done`);
73
77
  const usage = ex.graph.usage ?? null;
74
78
  const viewDir = join(dirs.cache, "view");
75
- mkdirSync(viewDir, { recursive: true });
76
79
  const metaFile = join(viewDir, `${ex.fingerprint}-v${VIEW_VERSION}.meta.json.gz`);
77
80
  const edgeFile = join(viewDir, `${ex.fingerprint}-v${VIEW_VERSION}.edges.bin.gz`);
78
81
  const memory = opts.freshLayout ? null : loadMemory(dirs.site);
@@ -87,13 +90,17 @@ export async function prepare(project, dirs, saved, log, opts = {}) {
87
90
  const completing = memory !== null && opts.completes !== undefined && memory.fingerprint === opts.completes && memory.before !== undefined;
88
91
  const against = completing ? (memory.before ?? null) : (memory?.layouts ?? null);
89
92
  const view = await buildViewOffThread(project, ex, log, against);
90
- saveMemory(dirs.site, ex.fingerprint, view.meta, opts.provisional ? against : undefined);
93
+ if (!opts.readOnlySite)
94
+ saveMemory(dirs.site, ex.fingerprint, view.meta, opts.provisional ? against : undefined);
91
95
  const metaGz = gzipSync(JSON.stringify(view.meta));
92
96
  const edgesGz = gzipSync(Buffer.from(view.edges.buffer, view.edges.byteOffset, view.edges.byteLength));
93
- writeFileSync(metaFile, metaGz);
94
- writeFileSync(edgeFile, edgesGz);
95
- prune(viewDir, /\.meta\.json\.gz$/, 3);
96
- prune(viewDir, /\.edges\.bin\.gz$/, 3);
97
+ if (!opts.readOnlySite) {
98
+ mkdirSync(viewDir, { recursive: true });
99
+ writeFileSync(metaFile, metaGz);
100
+ writeFileSync(edgeFile, edgesGz);
101
+ prune(viewDir, /\.meta\.json\.gz$/, 3);
102
+ prune(viewDir, /\.edges\.bin\.gz$/, 3);
103
+ }
97
104
  return { fingerprint: ex.fingerprint, cached: ex.cached, metaGz, edgesGz, usage };
98
105
  }
99
106
  function readdirRec(dir) {
@@ -118,7 +125,9 @@ export async function ensureBundle() {
118
125
  const dist = bundleDir();
119
126
  const out = join(dist, "app.js");
120
127
  let newest = 0;
121
- for (const f of [...readdirRec(join(WEB, "src")), ...readdirRec(join(SRC, "shared"))])
128
+ // package.json too: the page is bundled with its version (UpdateSpec decision 8), which a page
129
+ // compares with its server's — a bundle of another version would have every new tab reload
130
+ for (const f of [...readdirRec(join(WEB, "src")), ...readdirRec(join(SRC, "shared")), join(REPO, "package.json")])
122
131
  newest = Math.max(newest, statSync(f).mtimeMs);
123
132
  const beside = ["layout.js", "edges.js", "pdfworker.js"].every((f) => existsSync(join(dist, f)));
124
133
  if (existsSync(out) && beside && statSync(out).mtimeMs >= newest)
@@ -152,6 +161,8 @@ export async function bundlePage(outdir, sourcemap = true) {
152
161
  // the owner reads in Safari: what Safari 16 lacks of the syntax (pdf.js's class static blocks; a
153
162
  // regular expression's lookbehind, read at run time instead) is lowered
154
163
  target: ["es2022", "safari16"],
164
+ // the version the page is of, which it compares with its server's (UpdateSpec decision 8)
165
+ define: { __VP_VERSION__: JSON.stringify(packageVersion()) },
155
166
  sourcemap,
156
167
  outdir,
157
168
  logLevel: "warning",
@@ -10,6 +10,7 @@
10
10
  // input: the tools are run without a shell, on a path of the server's own, with a time limit, and at
11
11
  // most two at once.
12
12
  import { execFile } from "node:child_process";
13
+ import { tracked } from "./children.js";
13
14
  import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
14
15
  import { join } from "node:path";
15
16
  import { gunzipSync, gzipSync } from "node:zlib";
@@ -31,12 +32,12 @@ async function run(cmd, args, ms) {
31
32
  running++;
32
33
  try {
33
34
  return await new Promise((ok, no) => {
34
- execFile(cmd, args, { timeout: ms, maxBuffer: MAX_OUT, encoding: "buffer", windowsHide: true }, (err, out, errOut) => {
35
+ tracked(execFile(cmd, args, { timeout: ms, maxBuffer: MAX_OUT, encoding: "buffer", windowsHide: true }, (err, out, errOut) => {
35
36
  if (err)
36
37
  no(new Error(`${cmd} failed: ${(errOut?.toString("utf8").trim().split("\n").pop() ?? "") || err.message}`));
37
38
  else
38
39
  ok(out);
39
- });
40
+ }));
40
41
  });
41
42
  }
42
43
  finally {
@@ -6,13 +6,13 @@
6
6
  export function readOnlyAllows(method, path) {
7
7
  const read = method === "GET" || method === "HEAD";
8
8
  if (read) {
9
- // the page and its files
10
- if (path === "/" || path === "/index.html" || path === "/style.css" || /^\/(app|layout|edges|pdfworker)\.js(\.map)?$/.test(path) || path.startsWith("/pdfjs/") || path.startsWith("/fonts/"))
9
+ // the page and its files: its fonts, the tips' clips (GuideSpec decision 6)
10
+ if (path === "/" || path === "/index.html" || path === "/style.css" || /^\/(app|layout|edges|pdfworker)\.js(\.map)?$/.test(path) || path.startsWith("/pdfjs/") || path.startsWith("/fonts/") || path.startsWith("/tips/"))
11
11
  return true;
12
12
  // the graph, its sources and Lean's answers, the review, what happened, the views, the history,
13
13
  // the library, the printed document's state and files, who the reader is and the link they
14
- // came by, and the event stream every change comes by
15
- if (["/api/status", "/api/meta", "/api/edges", "/api/source", "/api/review", "/api/activity", "/api/identity", "/api/share"].includes(path))
14
+ // came by, VisualProof's version, and the event stream every change comes by
15
+ if (["/api/status", "/api/updates", "/api/meta", "/api/edges", "/api/git", "/api/source", "/api/review", "/api/activity", "/api/identity", "/api/share"].includes(path))
16
16
  return true;
17
17
  // a stream is opened by GET alone: a HEAD would take one of their places for nothing
18
18
  if (path === "/api/events")
@@ -263,7 +263,10 @@ export class ReviewStore {
263
263
  this.refresh();
264
264
  const file = logFileName(name, false, person);
265
265
  const now = Object.hasOwn(this.data.people, person) ? this.data.people[person].name : null;
266
- if (!this.files.has(file) || now === name.trim())
266
+ // one who has no log yet, but whom opinions were recorded for (ReviewSpec decision 8), is
267
+ // given one by the rename, so that those follow it
268
+ const spokenFor = Object.values(this.data.opinions).some((ops) => ops.some((o) => o.person === person && o.recordedBy));
269
+ if ((!this.files.has(file) && !spokenFor) || now === name.trim())
267
270
  return { change: null, unchanged: true };
268
271
  return this.write(file, () => nameEvent(person, name, this.nextTime()));
269
272
  }
@@ -17,6 +17,7 @@
17
17
  // Every refusal is an answer: a status, and words that say what to do instead.
18
18
  import { readFileSync } from "node:fs";
19
19
  import { join } from "node:path";
20
+ import { MAX_NAME, sameName, validName } from "../shared/collab.js";
20
21
  import { exactContext, judgedText } from "../shared/context.js";
21
22
  import { MAX_TEXT, STANDARD_AXIOMS, validPrefix } from "../shared/review.js";
22
23
  import { reviewSet } from "../shared/reviewqueue.js";
@@ -102,6 +103,9 @@ export class ReviewApi {
102
103
  return answer(refuse(400, "The comment is longer than 10,000 characters.", "Say it shorter."));
103
104
  if (b.verdict === "comment" && !comment)
104
105
  return answer(refuse(400, "A comment needs text.", "Write it in the box, then Comment."));
106
+ const voice = this.voice(b.for, who);
107
+ if ("status" in voice)
108
+ return answer(voice);
105
109
  const v = this.graph();
106
110
  if ("status" in v)
107
111
  return answer(v);
@@ -109,7 +113,7 @@ export class ReviewApi {
109
113
  if ("status" in x)
110
114
  return answer(x);
111
115
  // an agent's opinion is advice, whatever its level (AgentSpec decision 2)
112
- return this.record({ op: "opinion", name: x.name, by, verdict: b.verdict, ...(comment ? { comment } : {}), ...(x.hash ? { hash: x.hash } : {}), ...(x.text !== undefined ? { text: x.text } : x.statement !== undefined ? { statement: x.statement } : {}), ...agent }, who);
116
+ return this.record({ op: "opinion", name: x.name, by, verdict: b.verdict, ...(comment ? { comment } : {}), ...(x.hash ? { hash: x.hash } : {}), ...(x.text !== undefined ? { text: x.text } : x.statement !== undefined ? { statement: x.statement } : {}), ...voice, ...agent }, who);
113
117
  }
114
118
  case "target": {
115
119
  if (typeof b.on !== "boolean")
@@ -173,6 +177,36 @@ export class ReviewApi {
173
177
  return answer(refuse(400, "op is opinion, target, trust or key.", "Send {op, …}."));
174
178
  }
175
179
  }
180
+ /** Whose an opinion is to be (ReviewSpec decision 8): the asker's own where `asked` is absent or
181
+ * names them; else another person's, recorded for them — by their link's id where `asked` is an
182
+ * invited person's name, a guest's as the logs write it — and never an agent's, nor by an agent. */
183
+ voice(asked, who) {
184
+ if (asked === undefined)
185
+ return {};
186
+ if (who.agent)
187
+ return refuse(403, "An agent records no opinion for anyone else.", "Leave out for: your opinion is your own, and advice.");
188
+ if (typeof asked !== "string" || !validName(asked.trim()))
189
+ return refuse(400, `for is the name of the person the opinion is recorded for: 1–${MAX_NAME} characters, no line breaks.`, "Send {op: \"opinion\", name, verdict, for: \"<their name>\"}.");
190
+ const name = asked.trim();
191
+ const held = this.world.invited?.(name);
192
+ // one's own name, or one's own link: one's own opinion
193
+ if (held ? held.id === who.person : sameName(name, who.name.trim()))
194
+ return {};
195
+ // the writers the logs name, people's and agents', in any case
196
+ const d = this.world.review.data;
197
+ const writers = [
198
+ ...Object.values(d.opinions).flatMap((ops) => ops.map((o) => ({ by: o.by, agent: !!o.agent, person: !!o.person }))),
199
+ ...Object.values(d.targets).map((t) => ({ by: t.by, agent: !!t.agent, person: !!t.person })),
200
+ ...Object.values(d.keys).flatMap((marks) => Object.values(marks).map((m) => ({ by: m.by, agent: !!m.agent, person: !!m.person }))),
201
+ ];
202
+ if (writers.some((w) => w.agent && sameName(w.by, name)))
203
+ return refuse(400, `${name} is an agent: nobody records an opinion for an agent.`, "Record it for a person, or as your own.");
204
+ if (held)
205
+ return { for: held.name, forPerson: held.id };
206
+ // a guest already in the logs, as they write their name: "bob" is Bob
207
+ const guest = writers.find((w) => !w.agent && !w.person && sameName(w.by, name));
208
+ return { for: guest?.by ?? name };
209
+ }
176
210
  /** Append a writer's event to their log; the answer carries the change, which the page applies
177
211
  * at once, and the event as written. */
178
212
  record(op, who) {
@@ -4,11 +4,19 @@ import { readFileSync, writeFileSync } from "node:fs";
4
4
  import { join } from "node:path";
5
5
  import { gunzipSync, gzipSync } from "node:zlib";
6
6
  import { WEB, WEBFONTS, bundleDir } from "./pipeline.js";
7
+ import { gitSource } from "./history.js";
7
8
  import { lakeTextHash } from "./status.js";
8
9
  /** Text that sits inside a <script> element must not close it. */
9
10
  function scriptSafe(s) {
10
11
  return s.replace(/<\/script/gi, "<\\/script");
11
12
  }
13
+ /** A snapshot's graph: the view's, with the project's git source and commit as the snapshot is
14
+ * taken, since a snapshot has no server to ask (GET /api/git); as it was where the project is no
15
+ * git repository. */
16
+ export function withGit(root, meta, metaGz) {
17
+ const git = gitSource(root);
18
+ return git ? gzipSync(JSON.stringify({ ...meta, project: { ...meta.project, git } })) : metaGz;
19
+ }
12
20
  export function writeSnapshot(project, prep, out, label, review, views) {
13
21
  const meta = JSON.parse(gunzipSync(prep.metaGz).toString("utf8"));
14
22
  // the sources a card can show: every project module's file, with the usage pass's contexts where
@@ -28,6 +36,7 @@ export function writeSnapshot(project, prep, out, label, review, views) {
28
36
  }
29
37
  }
30
38
  const b64 = (buf) => buf.toString("base64");
39
+ const metaGz = withGit(project.root, meta, prep.metaGz);
31
40
  // the artwork's fonts within the page, which has no server to ask for them
32
41
  const css = readFileSync(join(WEB, "style.css"), "utf8").replace(/url\("\/fonts\/([a-z0-9-]+\.woff2)"\)/g, (_, f) => `url("data:font/woff2;base64,${b64(readFileSync(join(WEBFONTS, f)))}")`);
33
42
  const js = readFileSync(join(bundleDir(), "app.js"), "utf8").replace(/\n\/\/# sourceMappingURL=.*$/m, "");
@@ -35,7 +44,7 @@ export function writeSnapshot(project, prep, out, label, review, views) {
35
44
  const html = readFileSync(join(WEB, "index.html"), "utf8")
36
45
  .replace(`<link rel="stylesheet" href="/style.css">`, () => `<style>\n${css}\n</style>`)
37
46
  .replace(`<script type="module" src="/app.js"></script>`, () => `<script id="vp-static" type="application/json">${scriptSafe(JSON.stringify(info))}</script>\n` +
38
- `<script id="vp-meta" type="application/octet-stream">${b64(prep.metaGz)}</script>\n` +
47
+ `<script id="vp-meta" type="application/octet-stream">${b64(metaGz)}</script>\n` +
39
48
  `<script id="vp-edges" type="application/octet-stream">${b64(prep.edgesGz)}</script>\n` +
40
49
  `<script id="vp-sources" type="application/octet-stream">${b64(gzipSync(JSON.stringify(sources)))}</script>\n` +
41
50
  // the review as it stood: shown, not changed (ReviewSpec decision 7)
@@ -24,6 +24,7 @@
24
24
  // read-only: the build's commit, the commits made on top of it, the working tree and the blobs kept
25
25
  // already. Nothing is ever written into the project or its `.git`.
26
26
  import { spawn, spawnSync } from "node:child_process";
27
+ import { childEnv, tracked } from "./children.js";
27
28
  import { createHash } from "node:crypto";
28
29
  import { existsSync, mkdirSync, readdirSync, readFileSync, renameSync, statSync, writeFileSync } from "node:fs";
29
30
  import { join } from "node:path";
@@ -156,13 +157,13 @@ const DECLARES = /^\s*(?:@\[[^\]]*\]\s*)*(?:(?:private|protected|noncomputable|p
156
157
  // ------------------------------------------------------------------------------------------- git
157
158
  /** Git, read-only and taking no lock, run from the project's folder: its output, or null. */
158
159
  function gitSync(root, args) {
159
- const r = spawnSync("git", ["-C", root, ...args], { encoding: "utf8", timeout: 10_000, maxBuffer: 256 << 20, env: { ...process.env, GIT_OPTIONAL_LOCKS: "0" }, stdio: ["ignore", "pipe", "ignore"] });
160
+ const r = spawnSync("git", ["-C", root, ...args], { encoding: "utf8", timeout: 10_000, maxBuffer: 256 << 20, env: childEnv({ GIT_OPTIONAL_LOCKS: "0" }), stdio: ["ignore", "pipe", "ignore"] });
160
161
  return r.status === 0 ? r.stdout : null;
161
162
  }
162
163
  /** The same, off the event loop, with `input` on its standard input: its output as bytes, or null. */
163
164
  function git(root, args, input) {
164
165
  return new Promise((ok) => {
165
- const p = spawn("git", ["-C", root, ...args], { env: { ...process.env, GIT_OPTIONAL_LOCKS: "0" }, stdio: ["pipe", "pipe", "ignore"] });
166
+ const p = tracked(spawn("git", ["-C", root, ...args], { env: childEnv({ GIT_OPTIONAL_LOCKS: "0" }), stdio: ["pipe", "pipe", "ignore"] }));
166
167
  const out = [];
167
168
  p.stdout.on("data", (b) => out.push(b));
168
169
  p.on("error", () => ok(null));
@@ -10,6 +10,7 @@
10
10
  // - the trace records the hash of the source it was built from. Lake rebuilds when that hash
11
11
  // changes, not when the mtime does, so "stale" here compares the same hash (`lakeTextHash`).
12
12
  import { execFile } from "node:child_process";
13
+ import { tracked } from "./children.js";
13
14
  import { existsSync, readFileSync, statSync } from "node:fs";
14
15
  import { join } from "node:path";
15
16
  const IMPORT_RE = /^\s*(?:public\s+|private\s+)?(?:meta\s+)?import\s+(?:all\s+)?([^\s-]+)/;
@@ -84,7 +85,7 @@ export function scanModules(p) {
84
85
  /** Modules a lean process is compiling right now, for this project's build directory. */
85
86
  export function compiling(p) {
86
87
  return new Promise((resolve) => {
87
- execFile("ps", ["axo", "command"], { maxBuffer: 16 << 20 }, (err, stdout) => {
88
+ tracked(execFile("ps", ["axo", "command"], { maxBuffer: 16 << 20 }, (err, stdout) => {
88
89
  const out = new Set();
89
90
  if (err)
90
91
  return resolve(out);
@@ -99,7 +100,7 @@ export function compiling(p) {
99
100
  out.add(path.split("/").join("."));
100
101
  }
101
102
  resolve(out);
102
- });
103
+ }));
103
104
  });
104
105
  }
105
106
  const M = 0xc6a4a7935bd1e995n;
@@ -0,0 +1,219 @@
1
+ // The supervisor (UpdateSpec decision 5): `visualproof serve` from an installed package runs the
2
+ // server as a child of this small process, which stays while the server restarts into another
3
+ // version. It runs the newest of the installed package and the patches of its minor downloaded
4
+ // into the cache (server/updates.ts, codeToRun), passes signals on, and ends with the server's
5
+ // status — except where the server asks to be started again (status 75, a restart into another
6
+ // version) or where a version downloaded fails as it starts: that one is set aside, and the one
7
+ // before it started again (decision 7).
8
+ //
9
+ // The child is told, by its environment, that it runs supervised (VISUALPROOF_SUPERVISED), where the
10
+ // installed package is (VISUALPROOF_INSTALLED: the one it watches for an `npm install`), and what was
11
+ // set aside before it started (VISUALPROOF_SET_ASIDE), which it records in the timeline. It says over
12
+ // IPC when it listens (`{vp: "listening", version}`), so that a version which stops before then is
13
+ // known to have failed, and when what ends it is not its code (`{vp: "environment"}`: the site is
14
+ // busy, the port taken).
15
+ import { spawn } from "node:child_process";
16
+ import { constants as osConstants } from "node:os";
17
+ import { codeToRun, Versions } from "./updates.js";
18
+ /** the status a server ends with to be started again, in another version */
19
+ export const RESTART_STATUS = 75;
20
+ /** a version downloaded that ends within this long of starting is set aside */
21
+ export const FAILS_WITHIN = 10 * 60_000;
22
+ /** more starts than this in RESTART_WINDOW, and the supervisor gives up */
23
+ const MAX_STARTS = 6;
24
+ const RESTART_WINDOW = 10 * 60_000;
25
+ const SIGNALS = ["SIGINT", "SIGTERM", "SIGHUP"];
26
+ const defaultRun = (main, argv, env) => spawn(process.execPath, [main, ...argv], { stdio: ["inherit", "inherit", "inherit", "ipc"], env });
27
+ /** The status a process ended by `signal` is said to end with, as shells say it: 128 + its number. */
28
+ export function signalStatus(signal) {
29
+ return 128 + (osConstants.signals[signal] ?? 0);
30
+ }
31
+ /** How a version's failure counts (UpdateSpec decision 7): a SIGKILL never (the kernel's when
32
+ * memory runs out, an operator's kill -9); a SIGABRT — V8's when the heap reaches its limit — only
33
+ * before the version served: a version that runs out of memory as it starts, where the one before
34
+ * it did not, is the version's fault; one that runs out once it serves may be a reader's request.
35
+ * Either way it is started again (a version killed at every start ends in the too-many-starts stop
36
+ * below, which pauses it). */
37
+ function resource(signal, listened) {
38
+ return signal === "SIGKILL" || (signal === "SIGABRT" && listened);
39
+ }
40
+ /** failures soon after it served are counted for a day after the last one */
41
+ const STRIKES_FOR = 24 * 3600_000;
42
+ /** Run the server until it ends for good; the status it ended with — 0 once a stop was asked,
43
+ * whatever the server does meanwhile. A version from the cache is set aside (UpdateSpec decision 7)
44
+ * where it fails before it serves (for every site: it is about the version), or twice soon after it
45
+ * served (for this site: it may be about its project; a second site's sets it aside for all);
46
+ * where the version started after one was set aside fails as soon, the fault was not the first
47
+ * one's: it is taken back (what was recorded kept), this site's patches up to it are paused — it
48
+ * runs the installed package until a newer patch is out — and the supervisor ends. So does it
49
+ * after too many starts, pausing the patch it was starting. */
50
+ export function supervise(o) {
51
+ const log = o.log ?? ((l) => console.error(`visualproof: ${l}`));
52
+ const run = o.run ?? defaultRun;
53
+ const now = o.now ?? Date.now;
54
+ const site = o.site ?? null;
55
+ const versions = new Versions(o.cacheRoot);
56
+ const starts = [];
57
+ const backoff = o.backoff ?? ((n) => Math.min(300_000, 5000 * 2 ** (n - 1)));
58
+ /** kills in a row that do not count (out of memory): each start after one waits longer, and is
59
+ * not one of the starts that make the supervisor give up */
60
+ let kills = 0;
61
+ /** failures soon after it served, by version, and when the last one was */
62
+ const strikes = new Map();
63
+ /** versions set aside whose record could not be written (a full disk): set aside all the same */
64
+ const unrecorded = new Set();
65
+ let child = null;
66
+ let stopping = false;
67
+ /** the version set aside last, which the next child is told of */
68
+ let setAside = null;
69
+ const forward = (sig) => {
70
+ stopping = true;
71
+ child?.kill(sig);
72
+ };
73
+ for (const s of SIGNALS)
74
+ process.on(s, forward);
75
+ return new Promise((resolve) => {
76
+ let ended = false;
77
+ const done = (status) => {
78
+ if (ended)
79
+ return;
80
+ ended = true;
81
+ for (const s of SIGNALS)
82
+ process.off(s, forward);
83
+ // a stop was asked: whatever the server did meanwhile (a restart, a signal before its own
84
+ // handlers), the stop is what happened
85
+ resolve(stopping ? 0 : status);
86
+ };
87
+ const pause = (v, why) => {
88
+ if (!site)
89
+ return;
90
+ if (versions.pause(site, v, why))
91
+ log(`this site's patches up to ${v} are not taken until a newer one is out (${why}): the installed package runs`);
92
+ else
93
+ log(`this site's patches could not be paused (${versions.root} cannot be written): ${why}`);
94
+ };
95
+ const start = (uncounted = false) => {
96
+ if (stopping)
97
+ return done(0);
98
+ const t = now();
99
+ if (!uncounted)
100
+ starts.push(t);
101
+ while (starts.length && t - starts[0] > RESTART_WINDOW)
102
+ starts.shift();
103
+ let code = codeToRun(o.installedRoot, versions, { mode: o.mode, site, also: unrecorded });
104
+ // marked before it is started, so that no prune removes it meanwhile; chosen again where one
105
+ // did just before the mark
106
+ if (code.cached) {
107
+ versions.markRunning(code.version, process.pid);
108
+ if (!versions.ready().includes(code.version)) {
109
+ versions.unmarkRunning(code.version, process.pid);
110
+ code = codeToRun(o.installedRoot, versions, { mode: o.mode, site, also: unrecorded });
111
+ if (code.cached)
112
+ versions.markRunning(code.version, process.pid);
113
+ }
114
+ }
115
+ if (starts.length > MAX_STARTS) {
116
+ log(`the server was started ${starts.length} times in ten minutes: giving up`);
117
+ if (code.cached) {
118
+ versions.unmarkRunning(code.version, process.pid);
119
+ pause(code.version, `VisualProof ${code.version} was started ${starts.length} times in ten minutes`);
120
+ }
121
+ return done(1);
122
+ }
123
+ const env = { ...process.env, VISUALPROOF_SUPERVISED: "1", VISUALPROOF_INSTALLED: o.installedRoot };
124
+ // what it set aside without a record (a full disk): the server never restarts into it
125
+ if (unrecorded.size)
126
+ env.VISUALPROOF_UNRECORDED = JSON.stringify([...unrecorded]);
127
+ else
128
+ delete env.VISUALPROOF_UNRECORDED;
129
+ // the version set aside just before this one started: told to it (the timeline), and taken
130
+ // back if this one fails as soon
131
+ const after = setAside;
132
+ if (after)
133
+ env.VISUALPROOF_SET_ASIDE = JSON.stringify(after);
134
+ else
135
+ delete env.VISUALPROOF_SET_ASIDE;
136
+ setAside = null;
137
+ let listened = null;
138
+ // what ended it is not its code (the site busy, the port taken): never set aside for that
139
+ let environment = false;
140
+ const c = run(code.main, o.argv, env);
141
+ child = c;
142
+ // a version of the cache's, which no other server removes while this one runs it: marked as
143
+ // it starts, by its process
144
+ const pid = c.pid;
145
+ if (code.cached) {
146
+ if (pid)
147
+ versions.markRunning(code.version, pid);
148
+ versions.unmarkRunning(code.version, process.pid);
149
+ }
150
+ c.on("message", (m) => {
151
+ const said = m && typeof m === "object" ? m.vp : null;
152
+ if (said === "listening") {
153
+ listened = now();
154
+ kills = 0;
155
+ }
156
+ else if (said === "environment")
157
+ environment = true;
158
+ });
159
+ c.once("error", (e) => {
160
+ log(`the server could not be started: ${e.message}`);
161
+ done(1);
162
+ });
163
+ c.once("exit", (status, signal) => {
164
+ child = null;
165
+ if (code.cached && pid)
166
+ versions.unmarkRunning(code.version, pid);
167
+ const ends = status ?? (signal ? signalStatus(signal) : 1);
168
+ if (stopping)
169
+ return done(0);
170
+ if (status === RESTART_STATUS)
171
+ return start();
172
+ if (status === 0 || environment)
173
+ return done(ends);
174
+ const how = signal ? `was ended by ${signal}` : `exited with status ${status}`;
175
+ const soon = listened === null || now() - listened < FAILS_WITHIN;
176
+ const counts = !resource(signal, listened !== null);
177
+ if (after && soon && counts) {
178
+ // the version before it fails as soon: the fault was not the one set aside's
179
+ versions.unmarkBad(after.version);
180
+ unrecorded.delete(after.version);
181
+ log(`VisualProof ${code.version} ${how} as well: ${after.version} is taken back from those set aside (the fault is not its); stopping`);
182
+ pause(after.version, `${after.version}, and the version before it, failed as they started`);
183
+ return done(ends);
184
+ }
185
+ if (!code.cached || !soon)
186
+ return done(ends);
187
+ const aside = (why, where) => {
188
+ if (!versions.markBad(code.version, why, where)) {
189
+ unrecorded.add(code.version);
190
+ log(`VisualProof ${code.version} ${why}: set aside, though ${versions.root} could not record it (a full disk?)`);
191
+ }
192
+ else
193
+ log(`VisualProof ${code.version} ${why}: set aside${where ? " for this site" : ""}; starting the version before it`);
194
+ setAside = { version: code.version, why };
195
+ start();
196
+ };
197
+ if (!counts) {
198
+ kills++;
199
+ const wait = backoff(kills);
200
+ log(`VisualProof ${code.version} ${how}${listened === null ? " as it started" : " soon after it started"} (out of memory?): started again${wait ? ` in ${Math.round(wait / 1000)} s` : ""}`);
201
+ if (!wait)
202
+ return start(true);
203
+ setTimeout(() => start(true), wait);
204
+ return;
205
+ }
206
+ if (listened === null)
207
+ return aside(`${how} before it served`, null);
208
+ const was = strikes.get(code.version);
209
+ const n = was && now() - was.at < STRIKES_FOR ? was.n + 1 : 1;
210
+ strikes.set(code.version, { n, at: now() });
211
+ if (n >= 2)
212
+ return aside(`${how} twice soon after it started`, site ?? "");
213
+ log(`VisualProof ${code.version} ${how} soon after it started: started again`);
214
+ start();
215
+ });
216
+ };
217
+ start();
218
+ });
219
+ }