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
@@ -2,12 +2,15 @@
2
2
  // visualproof serve <path> — read a built Lean project and serve its declaration graph
3
3
  // visualproof export <path> — the same page, frozen into one HTML file
4
4
  import { historyAnswer } from "./historyapi.js";
5
+ import { gitSource } from "./history.js";
5
6
  import { createHash } from "node:crypto";
6
- import { existsSync, mkdirSync, readFileSync, statSync } from "node:fs";
7
+ import { existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
7
8
  import { createServer } from "node:http";
8
- import { extname, join, resolve } from "node:path";
9
+ import { basename, dirname, extname, join, resolve } from "node:path";
10
+ import { tmpdir } from "node:os";
9
11
  import { gunzipSync, gzipSync } from "node:zlib";
10
12
  import { WEB, WEBFONTS, bundleDir, bundleDirIn, ensureBundle, prepare, useBundleDir } from "./pipeline.js";
13
+ import { makeArtwork, pngOf, projectView } from "./artcmd.js";
11
14
  import { SiteBusy, cacheHome, dataHome, lockSite, openSite, projectDirs } from "./dirs.js";
12
15
  import { ProjectError, openProject, targetsInWords } from "./project.js";
13
16
  import { Live } from "./live.js";
@@ -15,7 +18,7 @@ import { ReviewStore } from "./review.js";
15
18
  import { isDeclName, LeanHover } from "./hover.js";
16
19
  import { Identities, layoutAuthor } from "./identity.js";
17
20
  import { LAYOUT_VERSION } from "../shared/graph.js";
18
- import { MAX_MEMORY_BYTES, Views } from "./views.js";
21
+ import { MAX_MEMORY_BYTES, readProjectView, Views } from "./views.js";
19
22
  import { cleanView, defaultAgentName, validSession, withoutArtwork } from "../shared/collab.js";
20
23
  import { writeSnapshot } from "./snapshot.js";
21
24
  import { lakeTextHash } from "./status.js";
@@ -36,6 +39,12 @@ import { UPLOAD_PATH } from "./agentlibrary.js";
36
39
  import { DEFAULT_LIBRARY_LIMIT, validSha } from "../shared/library.js";
37
40
  import { doctor, packageVersion, printReport } from "./doctor.js";
38
41
  import { READONLY_PATH } from "./readonly.js";
42
+ import { sendMedia, TIP_PATH, TIPS_DIR, tipClips, tourData, tourVtt } from "./media.js";
43
+ import { withoutReasons } from "../shared/timeline.js";
44
+ import { FROM_PACKAGE, PACKAGE_ROOT, UPDATE_MODES, Updates } from "./updates.js";
45
+ import { RESTART_STATUS, supervise } from "./supervise.js";
46
+ import { stopChildren } from "./children.js";
47
+ import { filmStatus, serveFilm, useFilm } from "./film.js";
39
48
  import { finalAnswer, readOnlyAllows, ReadOnlyBounds } from "./readonlygate.js";
40
49
  /** The usage, to stderr with status 2 for a command line it cannot read; asked for (`--help`),
41
50
  * to stdout with status 0. */
@@ -44,10 +53,12 @@ function usage(code = 2) {
44
53
  [--site dir] [--cache dir] [--graph saved.json]
45
54
  [--identity-header X-Forwarded-User] [--editor URL-template]
46
55
  [--agents read|advise|full] [--access-key-file file] [--public] [--jobs ${DEFAULT_JOBS}]
47
- [--public-url https://host] [--library-limit GB]
56
+ [--public-url https://host] [--library-limit GB] [--updates auto|notify|off]
48
57
  visualproof purge-file <lean-project> <sha256> [--site dir] [--cache dir]
49
58
  visualproof export <lean-project> -o page.html [--label text] [--root Name]... [--graph saved.json]
50
59
  [--jobs ${DEFAULT_JOBS}] [--site dir] [--cache dir]
60
+ visualproof artwork <lean-project> --recipe recipe.json [-o name] [--png] [--svg] [--root Name]...
61
+ [--site dir] [--cache dir] [--jobs ${DEFAULT_JOBS}] [--screen 1920x1200] [--graph saved.json]
51
62
  visualproof doctor [<lean-project>] [--root Name]... [--site dir] [--cache dir]
52
63
  visualproof --version
53
64
 
@@ -65,13 +76,27 @@ function usage(code = 2) {
65
76
  --cache the root of the caches, which a rebuild makes again: the project's (extractions, the
66
77
  usage pass's results, prepared views, typeset documents, Lean's language server's
67
78
  folder) in projects/<project>-<hash>/, the Lean programs compiled per toolchain in
68
- lean/, the page's bundle in web/. Default: ${cacheHome()}
79
+ lean/, the page's bundle in web/, VisualProof's own releases in updates.json and the
80
+ patches downloaded in versions/ (code the servers run: keep the root yours alone),
81
+ and the tips' tour film in media/.
82
+ Default: ${cacheHome()}
69
83
  (XDG_DATA_HOME and XDG_CACHE_HOME move both homes, on any platform)
70
84
 
71
85
  --graph use a saved extraction (a projects/*/extract/*.json of the cache) instead of reading
72
86
  the build; the project path still supplies the sources
73
87
  export writes one self-contained HTML file: the page, its data and the sources, frozen
74
88
 
89
+ artwork makes the picture an artwork recipe says (the agents' API's, docs/agent-api/export.md:
90
+ the size, the look, names by a rule, the targets, the selection), as the page's
91
+ artwork mode makes it, without a browser: an SVG and a PNG (--png or --svg for one),
92
+ named by -o (a name, or a path without its extension), else the recipe's title, else
93
+ the project. With --site, the board as that site keeps it (its layout, pins and folds)
94
+ and its review's targets; without, the board as a new site would lay it out, and the
95
+ targets the recipe names. --screen: the screen the board is drawn on, as a reader's
96
+ (default 1920x1200, at least 640x480): how large names are in the picture. It reads
97
+ the site, never writes it; it writes the files and the cache's extraction. Anything
98
+ that fails ends it with status 1. Notes are not drawn yet.
99
+
75
100
  --identity-header behind a proxy that authenticates (Tailscale, oauth2-proxy, …): take each
76
101
  reader's name from this header instead of the name the page gives. Only use it when
77
102
  the proxy is the only way to reach the server: the header is believed as it comes.
@@ -111,6 +136,16 @@ function usage(code = 2) {
111
136
  --public-url the address people reach this server at, when it is not the one their browser
112
137
  sends (a proxy in front): the prompt Share makes for an agent tells it this address.
113
138
 
139
+ --updates what the server does with VisualProof's own releases, asked of the npm registry
140
+ (through npm) at start and once a day: auto (the default) — a patch of the version it
141
+ runs (0.4.x) is downloaded into the cache and taken at a quiet moment, nobody on a page,
142
+ open pages reloading where they were; a newer version (0.5.0) is told of, in the log
143
+ and the help, with the command that installs it, after which the server restarts into
144
+ it by itself; notify — told of, never downloaded; off — never asked (and no
145
+ supervisor process), and the tips' tour film never fetched: the server reaches the
146
+ network for nothing of its own. VISUALPROOF_UPDATES says the same. From a checkout,
147
+ never asked.
148
+
114
149
  --library-limit the most the project's library of papers and images may take, in GB (default
115
150
  ${DEFAULT_LIBRARY_LIMIT / 1024 ** 3}); its files are kept in the site directory, by their hash.
116
151
 
@@ -131,9 +166,14 @@ function parseArgs(argv) {
131
166
  console.log(packageVersion());
132
167
  process.exit(0);
133
168
  }
134
- if (cmd !== "serve" && cmd !== "export" && cmd !== "purge-file" && cmd !== "doctor")
169
+ if (cmd !== "serve" && cmd !== "export" && cmd !== "purge-file" && cmd !== "doctor" && cmd !== "artwork")
135
170
  usage();
136
- const o = { cmd, path: "", roots: [], port: 4173, host: "127.0.0.1", site: null, cache: null, graph: null, out: null, label: "VisualProof", identityHeader: null, editor: null, agents: "full", accessKeyFile: null, jobs: DEFAULT_JOBS, publicUrl: null, libraryLimit: DEFAULT_LIBRARY_LIMIT, sha: null, publicRead: false };
171
+ const fromEnv = process.env.VISUALPROOF_UPDATES;
172
+ if (cmd === "serve" && fromEnv !== undefined && !UPDATE_MODES.includes(fromEnv)) {
173
+ console.error(`visualproof: VISUALPROOF_UPDATES=${fromEnv}: say auto, notify or off`);
174
+ process.exit(2);
175
+ }
176
+ const o = { cmd, path: "", roots: [], port: 4173, host: "127.0.0.1", site: null, cache: null, graph: null, out: null, label: "VisualProof", identityHeader: null, editor: null, agents: "full", accessKeyFile: null, jobs: DEFAULT_JOBS, publicUrl: null, libraryLimit: DEFAULT_LIBRARY_LIMIT, sha: null, publicRead: false, recipe: null, png: false, svg: false, screen: { w: 1920, h: 1200 }, updates: UPDATE_MODES.includes(fromEnv) ? fromEnv : "auto" };
137
177
  for (let i = 0; i < rest.length; i++) {
138
178
  const a = rest[i];
139
179
  const val = () => rest[++i] ?? usage();
@@ -161,6 +201,18 @@ function parseArgs(argv) {
161
201
  o.accessKeyFile = resolve(val());
162
202
  else if (a === "--public")
163
203
  o.publicRead = true;
204
+ else if (a === "--recipe" && cmd === "artwork")
205
+ o.recipe = resolve(val());
206
+ else if (a === "--png" && cmd === "artwork")
207
+ o.png = true;
208
+ else if (a === "--svg" && cmd === "artwork")
209
+ o.svg = true;
210
+ else if (a === "--screen" && cmd === "artwork") {
211
+ const m = /^(\d+)x(\d+)$/.exec(val());
212
+ if (!m || +m[1] < 640 || +m[2] < 480 || +m[1] > 10000 || +m[2] > 10000)
213
+ usage();
214
+ o.screen = { w: +m[1], h: +m[2] };
215
+ }
164
216
  else if (a === "--public-url") {
165
217
  const u = val();
166
218
  if (!/^https?:\/\/[^\s/?#]+\/?$/.test(u)) {
@@ -175,6 +227,12 @@ function parseArgs(argv) {
175
227
  usage();
176
228
  o.jobs = n;
177
229
  }
230
+ else if (a === "--updates" && cmd === "serve") {
231
+ const mode = val();
232
+ if (!UPDATE_MODES.includes(mode))
233
+ usage();
234
+ o.updates = mode;
235
+ }
178
236
  else if (a === "--agents") {
179
237
  const level = val();
180
238
  if (!AGENT_LEVELS.includes(level))
@@ -196,7 +254,7 @@ function parseArgs(argv) {
196
254
  else
197
255
  usage();
198
256
  }
199
- if ((!o.path && cmd !== "doctor") || (cmd === "export" && !o.out))
257
+ if ((!o.path && cmd !== "doctor") || (cmd === "export" && !o.out) || (cmd === "artwork" && !o.recipe))
200
258
  usage();
201
259
  // read-only for whoever comes, beside those the key or a link lets in: without a key, everyone is in
202
260
  // already; behind a sign-in, there is nobody else
@@ -221,6 +279,11 @@ export class App {
221
279
  live;
222
280
  hover;
223
281
  ids;
282
+ /** how long a read of the project's git (GET /api/git) is kept, whoever asks: git is run at most
283
+ * once in that time, so what readers cost the server is bounded (CollabSpec decision 11); 0 in
284
+ * the tests */
285
+ gitFreshMs = 3000;
286
+ gitRead = null;
224
287
  /** the agents' API (server/agent.ts), and what agents may change */
225
288
  agents;
226
289
  /** who may come in at all (server/access.ts); null: anyone who reaches the port */
@@ -237,6 +300,9 @@ export class App {
237
300
  readOnlyOf = new WeakMap();
238
301
  /** what read-only readers may cost the server: Lean's answers, their documents */
239
302
  bounds;
303
+ /** VisualProof's own releases as this server follows them (server/updates.ts); null: the
304
+ * version alone is told (a test's server) */
305
+ updates = null;
240
306
  constructor(live, hover, ids, agents, access, exporter = null, publicUrl = null, publicRead = false) {
241
307
  this.live = live;
242
308
  this.hover = hover;
@@ -253,6 +319,11 @@ export class App {
253
319
  this.publicRead = publicRead && access !== null;
254
320
  this.bounds = new ReadOnlyBounds((id) => this.exporter?.job(id)?.state === "running");
255
321
  }
322
+ /** Whether a request is told all of the server's own releases (UpdateSpec decision 11): someone
323
+ * let in fully; not a read-only reader, nor an agent. */
324
+ toldAll(req) {
325
+ return this.readOnlyOf.get(req) === undefined && this.ids.kind(req) !== "agent";
326
+ }
256
327
  /** The exported document (ExportSpec decisions 5–16): POST /api/export starts typesetting the
257
328
  * document of a view, `{view, settings, version?}` (a build the Time Machine shows the view at;
258
329
  * left out, the view's own), and answers with its job at once; the page asks how the
@@ -825,6 +896,23 @@ export class App {
825
896
  // the fonts an artwork is drawn in (ExportSpec decision 19)
826
897
  if (FONT_PATH.test(path))
827
898
  return sendFile(req, res, join(WEBFONTS, path.slice("/fonts/".length)));
899
+ // the tips' clips and their key frames (GuideSpec decision 6), and which there are
900
+ if (path === "/tips/index.json")
901
+ return sendJson(res, { clips: tipClips() });
902
+ // the tour film, fetched by this server on its first play and kept checked in the cache
903
+ // (server/film.ts, UpdateSpec decision 14): else 503 while it comes, or 404, and the page shows
904
+ // its stills and captions; and its captions as WebVTT, for assistive technology
905
+ if (path === "/tips/tour.mp4" && !refused.has(res))
906
+ return void serveFilm(req, res, sendMedia);
907
+ if (path === "/tips/tour.en.vtt") {
908
+ const vtt = tourVtt(tourData());
909
+ if (vtt === null)
910
+ return sendJson(res, { error: "no captions" }, 404);
911
+ res.writeHead(200, { "content-type": "text/vtt; charset=utf-8", "cache-control": "no-cache" });
912
+ return void res.end(vtt);
913
+ }
914
+ if (TIP_PATH.test(path) && !refused.has(res))
915
+ return sendMedia(req, res, join(TIPS_DIR, path.slice("/tips/".length)));
828
916
  // the page, and its workers: the one that lays out the Time Machine's past graphs
829
917
  // (web/src/layoutworker.ts), the one that chooses the overview's edges (web/src/edgeworker.ts),
830
918
  // and pdf.js's, which reads the library's PDFs for the reference window (AttachmentSpec decision 11)
@@ -833,8 +921,19 @@ export class App {
833
921
  if (path.startsWith("/pdfjs/"))
834
922
  return pdfjsAsset(res, path);
835
923
  // with Lean's language server as it is (server/hover.ts): what is open, what crashed
836
- if (path === "/api/status")
837
- return sendJson(res, { ...live.status(), lean: this.hover.state() });
924
+ // a read-only reader is told how the server is, not its log nor where its review is kept
925
+ if (path === "/api/status") {
926
+ const st = { ...live.status(), lean: this.hover.state() };
927
+ if (this.readOnlyOf.get(req) !== undefined) {
928
+ delete st.log;
929
+ st.review = { ...st.review, folder: undefined };
930
+ }
931
+ return sendJson(res, st);
932
+ }
933
+ // VisualProof's own version, and what of its releases those let in fully are told (UpdateSpec
934
+ // decision 11): a read-only reader and an agent are told the version alone
935
+ if (path === "/api/updates")
936
+ return sendJson(res, this.updates?.status(this.toldAll(req)) ?? { running: packageVersion() });
838
937
  // the link that lets someone in, for a page that shares where it is (web/src/share.ts); a
839
938
  // request gets here only once it is in
840
939
  // a read-only reader shares the link they came by, or the address alone (--public)
@@ -954,12 +1053,14 @@ export class App {
954
1053
  }
955
1054
  if (path === "/api/events") {
956
1055
  const session = url.searchParams.get("session");
957
- return live.subscribe(res, validSession(session) ? session : null, this.ids.person(req)?.id ?? null, reader ?? null);
1056
+ return live.subscribe(res, validSession(session) ? session : null, this.ids.person(req)?.id ?? null, reader ?? null, this.toldAll(req));
958
1057
  }
959
1058
  // what happened (CollabSpec decision 5): what the server stored; the review's own history the
960
1059
  // page reads from the review
961
- if (path === "/api/activity")
962
- return sendJson(res, { events: live.timeline.events.slice(-2000) });
1060
+ if (path === "/api/activity") {
1061
+ const events = live.timeline.events.slice(-2000);
1062
+ return sendJson(res, { events: this.toldAll(req) ? events : events.map(withoutReasons) });
1063
+ }
963
1064
  // a layout batch an agent made, undone (AgentSpec decision 4): from a page's Activity, by anyone;
964
1065
  // by a private reader, without their name (CollabSpec decision 8)
965
1066
  if (path === "/api/layout/undo" && req.method === "POST") {
@@ -976,14 +1077,23 @@ export class App {
976
1077
  const a = this.agents.undo(me, agent, r.body?.id);
977
1078
  return sendJson(res, a.body, a.status);
978
1079
  }
1080
+ // the project's git source and commit as git says them now, not as the build was read (an
1081
+ // artwork's foot, ExportSpec decision 19; the owner's decision of 2026-10-15)
1082
+ if (path === "/api/git") {
1083
+ if (!this.gitRead || Date.now() - this.gitRead.at >= this.gitFreshMs)
1084
+ this.gitRead = { git: gitSource(live.project.root), at: Date.now() };
1085
+ return sendJson(res, { git: this.gitRead.git });
1086
+ }
979
1087
  if (path === "/api/meta" || path === "/api/edges") {
980
1088
  const g = live.graph;
981
1089
  if (!g)
982
1090
  return sendJson(res, { error: "not ready" }, 503);
983
1091
  // a page asks for the version it was told about; an older one is gone
984
1092
  const want = url.searchParams.get("v");
985
- if (want && Number(want) !== g.version)
986
- return sendJson(res, { error: "superseded", version: g.version }, 409);
1093
+ // of this server's: one restarted since counts its versions again (UpdateSpec decision 8)
1094
+ const of = url.searchParams.get("i");
1095
+ if ((want && Number(want) !== g.version) || (of && of !== live.instance))
1096
+ return sendJson(res, { error: "superseded", version: g.version, instance: live.instance }, 409);
987
1097
  // a page asks for the edges packed (shared/edgecodec.ts; `pack=2` with the grammar site, a
988
1098
  // page from before it `pack=1`, which takes grammar for a proof); an agent gets them as they
989
1099
  // are
@@ -1287,15 +1397,30 @@ function packedGz(edgesGz, format) {
1287
1397
  }
1288
1398
  return p;
1289
1399
  }
1400
+ /** What a stop signal does to a server: end it at once, until it can stop gracefully (serve). */
1401
+ let stopNow = () => process.exit(0);
1290
1402
  async function main() {
1291
1403
  const o = parseArgs(process.argv.slice(2));
1404
+ // an installed package's server runs under the supervisor, which stays while it restarts into
1405
+ // another version (UpdateSpec decision 5); `--updates off`, and a checkout, run it as they are
1406
+ if (o.cmd === "serve" && FROM_PACKAGE && o.updates !== "off" && process.env.VISUALPROOF_SUPERVISED !== "1") {
1407
+ const site = projectDirs(resolve(o.path), o.site, o.cache).site;
1408
+ process.exit(await supervise({ installedRoot: PACKAGE_ROOT, cacheRoot: o.cache ? resolve(o.cache) : cacheHome(), argv: process.argv.slice(2), site, mode: o.updates }));
1409
+ }
1410
+ // a stop asked before the server can stop gracefully (it reads the project, takes the site) ends
1411
+ // it at once, with status 0; once it can, the graceful stop does (`stopNow`, below)
1412
+ if (o.cmd === "serve")
1413
+ for (const sig of ["SIGINT", "SIGTERM", "SIGHUP"])
1414
+ process.on(sig, () => stopNow());
1292
1415
  if (o.cmd === "doctor") {
1293
1416
  const report = doctor({ path: o.path || null, roots: o.roots, site: o.site, cache: o.cache });
1294
1417
  console.log(printReport(report, o.path || null));
1295
1418
  process.exit(report.checks.some((c) => c.level === "fail") ? 1 : 0);
1296
1419
  }
1297
- // a live server outlives any one failed task: log it and keep serving
1298
- process.on("unhandledRejection", (e) => console.error("unhandled:", e instanceof Error ? e.message : e));
1420
+ // a live server outlives any one failed task: log it and keep serving (a picture is no server:
1421
+ // whatever fails ends it, with status 1)
1422
+ if (o.cmd !== "artwork")
1423
+ process.on("unhandledRejection", (e) => console.error("unhandled:", e instanceof Error ? e.message : e));
1299
1424
  let project;
1300
1425
  try {
1301
1426
  project = openProject(o.path, o.roots, o.graph === null);
@@ -1303,10 +1428,13 @@ async function main() {
1303
1428
  catch (e) {
1304
1429
  if (e instanceof ProjectError) {
1305
1430
  console.error(`visualproof: ${e.message}`);
1306
- process.exit(1);
1431
+ return endNotTheCode(1);
1307
1432
  }
1308
1433
  throw e;
1309
1434
  }
1435
+ // a picture: the site read, never made, locked or written (ExportSpec decision 21)
1436
+ if (o.cmd === "artwork")
1437
+ return artworkCommand(project, o);
1310
1438
  const dirs = projectDirs(project.root, o.site, o.cache);
1311
1439
  openSite(dirs.site);
1312
1440
  mkdirSync(dirs.cache, { recursive: true });
@@ -1320,7 +1448,7 @@ async function main() {
1320
1448
  if (!(e instanceof SiteBusy))
1321
1449
  throw e;
1322
1450
  console.error(`visualproof: ${e.message}`);
1323
- process.exit(1);
1451
+ return endNotTheCode(1);
1324
1452
  }
1325
1453
  }
1326
1454
  useBundleDir(bundleDirIn(o.cache ? resolve(o.cache) : cacheHome()));
@@ -1373,7 +1501,7 @@ async function main() {
1373
1501
  }
1374
1502
  catch (e) {
1375
1503
  console.error(`visualproof: --access-key-file: ${e instanceof Error ? e.message : String(e)}`);
1376
- process.exit(2);
1504
+ return endNotTheCode(2);
1377
1505
  }
1378
1506
  process.on("exit", () => {
1379
1507
  hover.stop();
@@ -1384,10 +1512,77 @@ async function main() {
1384
1512
  live.invites.close();
1385
1513
  live.readOnly.close();
1386
1514
  });
1387
- for (const sig of ["SIGINT", "SIGTERM"])
1388
- process.on(sig, () => process.exit(0));
1389
- const app = new App(live, hover, ids, new AgentApi(live, hover, o.agents), key ? new Access(key.key) : null, new Exporter(dirs.cache), o.publicUrl, o.publicRead);
1515
+ const exporter = new Exporter(dirs.cache);
1516
+ const app = new App(live, hover, ids, new AgentApi(live, hover, o.agents), key ? new Access(key.key) : null, exporter, o.publicUrl, o.publicRead);
1390
1517
  const server = createServer((req, res) => app.handle(req, res));
1518
+ const version = packageVersion();
1519
+ // run by the supervisor, which starts it again in another version (UpdateSpec decision 5)
1520
+ const supervised = typeof process.send === "function" && process.env.VISUALPROOF_SUPERVISED === "1";
1521
+ /** Stop (UpdateSpec decision 9): no new requests, the pages told why and their streams ended, the
1522
+ * programs the server runs stopped, then the process ends: status 0, or RESTART_STATUS for the
1523
+ * supervisor to start it again in another version. A second signal meanwhile changes nothing;
1524
+ * whatever still waits, it ends within 5 s. */
1525
+ let stopping = false;
1526
+ /** the status the process ends with: a stop asked during a restart makes it a stop (0), so that
1527
+ * the supervisor does not start it again */
1528
+ let exitStatus = 0;
1529
+ const stop = (status, why) => {
1530
+ if (stopping) {
1531
+ if ("stopped" in why)
1532
+ exitStatus = 0;
1533
+ return;
1534
+ }
1535
+ stopping = true;
1536
+ exitStatus = status;
1537
+ updates.stop();
1538
+ server.close();
1539
+ live.end(why);
1540
+ hover.stop();
1541
+ live.usage.cancel();
1542
+ server.closeIdleConnections();
1543
+ setTimeout(() => process.exit(exitStatus), 5000);
1544
+ void stopChildren(2000).then(() => {
1545
+ server.closeAllConnections();
1546
+ process.exit(exitStatus);
1547
+ });
1548
+ };
1549
+ const cacheRoot = o.cache ? resolve(o.cache) : cacheHome();
1550
+ const updates = new Updates({
1551
+ mode: o.updates,
1552
+ cacheRoot,
1553
+ canRestart: supervised && FROM_PACKAGE,
1554
+ check: FROM_PACKAGE,
1555
+ checkout: !FROM_PACKAGE,
1556
+ // nobody on a page, nothing running that a restart would cut short (UpdateSpec decision 6)
1557
+ quiet: () => live.pages === 0 && !live.busy && !exporter.busy,
1558
+ restart: (to) => {
1559
+ process.send?.({ vp: "restart", to });
1560
+ stop(RESTART_STATUS, { update: { from: version, to } });
1561
+ },
1562
+ // the server's console alone: the log pages read (GET /api/status) is no place for paths and
1563
+ // commands of this machine
1564
+ log: (l) => console.log(l),
1565
+ changed: () => live.updatesChanged(),
1566
+ running: version,
1567
+ // the supervisor's word, believed only from it
1568
+ installedRoot: (supervised && process.env.VISUALPROOF_INSTALLED) || PACKAGE_ROOT,
1569
+ site: dirs.site,
1570
+ // what the supervisor set aside without a record of it (a full disk), believed only from it
1571
+ also: new Set(supervised ? unrecordedOf(process.env.VISUALPROOF_UNRECORDED) : []),
1572
+ film: () => filmStatus(),
1573
+ });
1574
+ // the tips' tour film, fetched on its first play (UpdateSpec decision 14; never under --updates off)
1575
+ useFilm({ cacheRoot, mode: o.updates, log: (l) => console.log(l) });
1576
+ live.updateStatus = (full) => updates.status(full);
1577
+ app.updates = updates;
1578
+ stopNow = () => stop(0, { stopped: true });
1579
+ // the supervisor went: this server goes with it rather than serve on alone
1580
+ if (supervised)
1581
+ process.on("disconnect", () => stop(0, { stopped: true }));
1582
+ server.on("error", (e) => {
1583
+ console.error(`visualproof: ${e.code === "EADDRINUSE" ? `${o.host}:${o.port} is in use already: give --port another` : e.message}`);
1584
+ return endNotTheCode(1);
1585
+ });
1391
1586
  server.listen(o.port, o.host, () => {
1392
1587
  console.log(`VisualProof — ${project.name} (${targetsInWords(project)})`);
1393
1588
  console.log(` http://${o.host}:${o.port}/`);
@@ -1398,9 +1593,143 @@ async function main() {
1398
1593
  console.log(` agents: ${o.agents} — their API is http://${o.host}:${o.port}/api/agent`);
1399
1594
  console.log(` usage pass: ${o.graph ? "none for a saved graph" : o.jobs > 0 ? `${o.jobs} module(s) at a time` : "off (--jobs 0)"}`);
1400
1595
  console.log(` access: ${key ? `by its link only, /k/<the key in ${o.accessKeyFile}>${key.made ? " (a new key was made there)" : ""}${o.publicRead ? "; anyone else read-only (--public)" : ""}` : "anyone who reaches the port"}`);
1596
+ console.log(` version: ${version}; updates ${updatesInWords(o.updates, version, supervised)}`);
1597
+ recordVersion(live, version, supervised);
1598
+ process.send?.({ vp: "listening", version });
1599
+ updates.start();
1401
1600
  });
1402
1601
  live.start();
1403
1602
  }
1603
+ /** What the server does with its releases, for its first lines (UpdateSpec decisions 2–4, 13). */
1604
+ function updatesInWords(mode, version, supervised) {
1605
+ if (mode === "off")
1606
+ return "off (--updates off): the registry is never asked";
1607
+ if (!FROM_PACKAGE)
1608
+ return "none from a checkout";
1609
+ const minor = version.split(".").slice(0, 2).join(".");
1610
+ if (mode === "notify")
1611
+ return `notify — newer versions told of, never downloaded${supervised ? "; one installed meanwhile is restarted into" : ""}`;
1612
+ return supervised ? `auto — a patch of ${minor} taken by itself at a quiet moment; a newer version told of` : "auto, but without the supervisor: newer versions told of";
1613
+ }
1614
+ /** The versions the supervisor says it set aside without a record (VISUALPROOF_UNRECORDED). */
1615
+ function unrecordedOf(text) {
1616
+ try {
1617
+ const v = JSON.parse(text ?? "[]");
1618
+ return Array.isArray(v) ? v.filter((x) => typeof x === "string") : [];
1619
+ }
1620
+ catch {
1621
+ return [];
1622
+ }
1623
+ }
1624
+ /** The version that serves the site, recorded in the timeline where it is another than the last one
1625
+ * recorded (UpdateSpec decision 10), with what the supervisor set aside before this one started. */
1626
+ function recordVersion(live, version, supervised) {
1627
+ let setAside;
1628
+ try {
1629
+ const raw = JSON.parse((supervised && process.env.VISUALPROOF_SET_ASIDE) || "null");
1630
+ if (raw && typeof raw.version === "string" && typeof raw.why === "string")
1631
+ setAside = { version: raw.version, why: raw.why };
1632
+ }
1633
+ catch {
1634
+ // nothing set aside that can be read
1635
+ }
1636
+ const was = live.timeline.lastVersion() ?? live.history?.lastVersion() ?? null;
1637
+ if (was !== version || setAside)
1638
+ live.timeline.add({ kind: "tool", from: was, to: version, ...(setAside ? { setAside } : {}) });
1639
+ }
1640
+ /** End the server with `status`, the supervisor told first that what ends it is not its code (the
1641
+ * site is busy, the port is taken, the project cannot be read): a patch that ends so is not set
1642
+ * aside (UpdateSpec decision 7). The message is sent before the process ends, not lost with it. */
1643
+ function endNotTheCode(status) {
1644
+ process.exitCode = status;
1645
+ if (process.send)
1646
+ process.send({ vp: "environment" }, () => process.exit(status));
1647
+ else
1648
+ process.exit(status);
1649
+ }
1650
+ /** The artwork command (ExportSpec decision 21): the build read as an export reads it, the picture a
1651
+ * recipe says made of it (server/artcmd.ts), its files written; what it named said as the agents'
1652
+ * API says it; problems said, and exit status 1. */
1653
+ async function artworkCommand(project, o) {
1654
+ const fail = (why) => {
1655
+ console.error(`visualproof artwork: ${why}`);
1656
+ process.exit(1);
1657
+ };
1658
+ try {
1659
+ await artwork(project, o, fail);
1660
+ }
1661
+ catch (e) {
1662
+ fail(e instanceof Error ? e.message : String(e));
1663
+ }
1664
+ }
1665
+ async function artwork(project, o, fail) {
1666
+ const dirs = projectDirs(project.root, o.site, o.cache);
1667
+ const site = o.site ? dirs.site : null;
1668
+ if (site && !existsSync(site))
1669
+ fail(`there is no site at ${site}`);
1670
+ let recipe;
1671
+ try {
1672
+ recipe = JSON.parse(readFileSync(o.recipe, "utf8"));
1673
+ }
1674
+ catch (e) {
1675
+ fail(`the recipe ${o.recipe} cannot be read: ${e instanceof Error ? e.message : String(e)}`);
1676
+ }
1677
+ // -o: a name, or a path without its extension, in a folder that is there
1678
+ const outBase = o.out ? o.out.replace(/\.(png|svg)$/i, "") : null;
1679
+ const dir = outBase ? dirname(outBase) : process.cwd();
1680
+ if (!existsSync(dir))
1681
+ fail(`there is no folder ${dir} to write the files in`);
1682
+ mkdirSync(dirs.cache, { recursive: true });
1683
+ const log = (l) => console.log(l);
1684
+ let ex = o.graph ? undefined : await extract(project, dirs, log);
1685
+ if (ex && o.jobs > 0) {
1686
+ const pass = new UsagePass(project, dirs, o.jobs, log);
1687
+ const plan = pass.plan(ex.graph);
1688
+ if (plan.todo.length)
1689
+ log(`usage pass: elaborating ${plan.todo.length} of ${plan.modules.length} modules, ${o.jobs} at a time`);
1690
+ const results = await pass.run(plan);
1691
+ if (results) {
1692
+ ex = mergeUsage(ex, plan, results, pass.status.failed, pass.status.ms ?? 0);
1693
+ saveMerged(dirs.cache, ex);
1694
+ }
1695
+ }
1696
+ // the site's layout memory read and never written, nor the views laid out written to the cache;
1697
+ // without a site, none read: the board as a new site lays it out
1698
+ const prep = await prepare(project, dirs, o.graph, log, { ...(ex ? { extraction: ex } : {}), readOnlySite: true, ...(site ? {} : { freshLayout: true }) });
1699
+ const kept = site ? readProjectView(site, LAYOUT_VERSION) : { view: null };
1700
+ if (kept.why)
1701
+ console.error(`visualproof artwork: the site's views cannot be read (${kept.why}): the Project view as a new site has it`);
1702
+ const review = site ? new ReviewStore(project.root, site).data : null;
1703
+ const a = makeArtwork(prep, review, projectView(kept.view), { recipe, screen: o.screen, name: outBase ? basename(outBase) : null, root: project.root });
1704
+ if (!("svg" in a)) {
1705
+ for (const p of a.problems)
1706
+ console.error(`visualproof artwork: ${p}`);
1707
+ process.exit(1);
1708
+ }
1709
+ const list = (xs) => (xs.length ? xs.join(", ") : "none");
1710
+ console.log(`names written: ${list(a.names.written)}`);
1711
+ if (a.names.ruledSkipped.length)
1712
+ console.log(`names a rule chose, left out where they would cover another: ${list(a.names.ruledSkipped)}`);
1713
+ if (a.names.left)
1714
+ console.log(`names or targets left out for room: ${a.names.left}`);
1715
+ console.log(`targets marked: ${list(a.targets)}`);
1716
+ console.log(`selection: ${a.selection ? `${a.selection.mode} of ${a.selection.name}` : "none"}`);
1717
+ if (a.notesLeftOut)
1718
+ console.log(`notes: ${a.notesLeftOut} on the board, not drawn (the command draws no notes yet)`);
1719
+ const both = !o.png && !o.svg;
1720
+ const mb = (n) => `${(n / 1e6).toFixed(1)} MB`;
1721
+ if (both || o.svg) {
1722
+ const file = join(dir, a.file("svg"));
1723
+ writeFileSync(file, a.svg);
1724
+ console.log(`wrote ${file} (${mb(Buffer.byteLength(a.svg))})`);
1725
+ }
1726
+ if (both || o.png) {
1727
+ const png = await pngOf(a.svg);
1728
+ const file = join(dir, a.file("png"));
1729
+ writeFileSync(file, png);
1730
+ console.log(`wrote ${file} (${mb(png.length)}, ${a.look.w} × ${a.look.h})`);
1731
+ }
1732
+ }
1404
1733
  // run as the command (`node src/server/main.ts`, the `visualproof` bin), not when a test imports App
1405
1734
  if (import.meta.main)
1406
1735
  void main();
@@ -373,12 +373,13 @@ export const TOOLS = [
373
373
  },
374
374
  {
375
375
  name: "export",
376
- about: "A view's document — every declaration of the project its board holds, in its sections and order: its model as JSON, or typeset as a PDF and saved on your machine.",
376
+ about: "A view's document — every declaration of the project its board holds, in its sections and order: its model as JSON, or typeset as a PDF and saved on your machine. Or a picture of the board: a link that opens it in the artwork mode as a recipe says.",
377
377
  actions: {
378
378
  model: { method: "GET", path: "/api/agent/export", says: "the document's model", args: ["view", "settings", "version"] },
379
379
  typeset: { method: "POST", path: "/api/agent/export", says: "start typesetting: a job", args: ["view", "settings", "version"] },
380
380
  job: { method: "GET", path: "/api/agent/export/job", says: "how the job is doing", args: ["id"] },
381
381
  file: { method: "GET", path: "/api/agent/export/file", saves: true, says: "a file of it saved on your machine", args: ["id", "what", "saveTo"] },
382
+ artwork: { method: "POST", path: "/api/agent/artwork", says: "a link to a picture of the board, as a recipe says", args: ["recipe", "save"] },
382
383
  },
383
384
  args: {
384
385
  view: str("the view's id: a target's Key view is open on key(<target>)"),
@@ -387,6 +388,8 @@ export const TOOLS = [
387
388
  id: str("the job's id"),
388
389
  what: str("pdf (default), tex, sty or zip", ["pdf", "tex", "sty", "zip"]),
389
390
  saveTo: SAVE_TO,
391
+ recipe: { type: "object", description: "the picture: size, orientation, style, theme, names (e.g. {clusters: 1}), targets, selection {name, mode}, title, subtitle, edges… (docs: export)" },
392
+ save: str("png, svg or png,svg: saved by the page once ready"),
390
393
  },
391
394
  },
392
395
  ];