visualproof 0.0.0-stage → 0.1.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 (128) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +99 -2
  3. package/dist/assets/fonts/JuliaMono-Regular.ttf +0 -0
  4. package/dist/assets/fonts/JuliaMono-RegularItalic.ttf +0 -0
  5. package/dist/assets/fonts/JuliaMono-SemiBold.ttf +0 -0
  6. package/dist/assets/fonts/LICENSE-JuliaMono.txt +93 -0
  7. package/dist/assets/latex/visualproof.sty +308 -0
  8. package/dist/extractor/Extract.lean +1180 -0
  9. package/dist/extractor/Usage.lean +900 -0
  10. package/dist/mcp/visualproof-mcp.mjs +476 -0
  11. package/dist/server/access.js +95 -0
  12. package/dist/server/agent.js +3235 -0
  13. package/dist/server/agentdocs.js +35 -0
  14. package/dist/server/agentlibrary.js +202 -0
  15. package/dist/server/agentnotes.js +701 -0
  16. package/dist/server/agentviews.js +703 -0
  17. package/dist/server/askfollow.js +286 -0
  18. package/dist/server/boardspots.js +159 -0
  19. package/dist/server/dirs.js +175 -0
  20. package/dist/server/doctor.js +186 -0
  21. package/dist/server/events.js +198 -0
  22. package/dist/server/export.js +420 -0
  23. package/dist/server/extract.js +204 -0
  24. package/dist/server/fetchurl.js +321 -0
  25. package/dist/server/figures.js +158 -0
  26. package/dist/server/history.js +741 -0
  27. package/dist/server/historyapi.js +304 -0
  28. package/dist/server/hover.js +595 -0
  29. package/dist/server/identity.js +114 -0
  30. package/dist/server/invites.js +241 -0
  31. package/dist/server/layoutthread.js +51 -0
  32. package/dist/server/library.js +600 -0
  33. package/dist/server/libraryroute.js +211 -0
  34. package/dist/server/live.js +1101 -0
  35. package/dist/server/lostfound.js +199 -0
  36. package/dist/server/main.js +1394 -0
  37. package/dist/server/mcptools.js +497 -0
  38. package/dist/server/pastdoc.js +73 -0
  39. package/dist/server/pipeline.js +156 -0
  40. package/dist/server/poppler.js +251 -0
  41. package/dist/server/presence.js +252 -0
  42. package/dist/server/project.js +222 -0
  43. package/dist/server/prompt.js +53 -0
  44. package/dist/server/readonly.js +152 -0
  45. package/dist/server/readonlygate.js +123 -0
  46. package/dist/server/renames.js +268 -0
  47. package/dist/server/review.js +334 -0
  48. package/dist/server/reviewapi.js +188 -0
  49. package/dist/server/snapshot.js +49 -0
  50. package/dist/server/sourcestore.js +473 -0
  51. package/dist/server/status.js +360 -0
  52. package/dist/server/talk.js +476 -0
  53. package/dist/server/tokens.js +221 -0
  54. package/dist/server/usage.js +501 -0
  55. package/dist/server/versions.js +82 -0
  56. package/dist/server/view.js +157 -0
  57. package/dist/server/views.js +1167 -0
  58. package/dist/shared/askfollow.js +46 -0
  59. package/dist/shared/board.js +332 -0
  60. package/dist/shared/build.js +2 -0
  61. package/dist/shared/collab.js +148 -0
  62. package/dist/shared/comment.js +285 -0
  63. package/dist/shared/context.js +157 -0
  64. package/dist/shared/doc/authors.js +98 -0
  65. package/dist/shared/doc/build.js +358 -0
  66. package/dist/shared/doc/latex.js +455 -0
  67. package/dist/shared/doc/markdown.js +159 -0
  68. package/dist/shared/doc/model.js +37 -0
  69. package/dist/shared/doc/order.js +107 -0
  70. package/dist/shared/doc/proof.js +272 -0
  71. package/dist/shared/doc/sections.js +93 -0
  72. package/dist/shared/doc/settings.js +75 -0
  73. package/dist/shared/edgecodec.js +102 -0
  74. package/dist/shared/editor.js +64 -0
  75. package/dist/shared/excerpt.js +87 -0
  76. package/dist/shared/graph.js +16 -0
  77. package/dist/shared/items.js +92 -0
  78. package/dist/shared/keys.js +203 -0
  79. package/dist/shared/layout.js +926 -0
  80. package/dist/shared/lexer.js +130 -0
  81. package/dist/shared/library.js +711 -0
  82. package/dist/shared/lostfound.js +150 -0
  83. package/dist/shared/moves.js +243 -0
  84. package/dist/shared/notecard.js +295 -0
  85. package/dist/shared/noteplace.js +505 -0
  86. package/dist/shared/palette.js +46 -0
  87. package/dist/shared/pastgraph.js +95 -0
  88. package/dist/shared/places.js +80 -0
  89. package/dist/shared/projectnotes.js +244 -0
  90. package/dist/shared/query.js +810 -0
  91. package/dist/shared/review.js +642 -0
  92. package/dist/shared/reviewqueue.js +333 -0
  93. package/dist/shared/ring.js +366 -0
  94. package/dist/shared/scope.js +255 -0
  95. package/dist/shared/scopeedit.js +81 -0
  96. package/dist/shared/search.js +191 -0
  97. package/dist/shared/separate.js +178 -0
  98. package/dist/shared/sharesnap.js +280 -0
  99. package/dist/shared/sticky.js +501 -0
  100. package/dist/shared/timeline.js +73 -0
  101. package/dist/shared/titles.js +298 -0
  102. package/dist/shared/viewgraph.js +139 -0
  103. package/dist/shared/views.js +859 -0
  104. package/dist/web/bundle/app.js +48733 -0
  105. package/dist/web/bundle/edges.js +409 -0
  106. package/dist/web/bundle/layout.js +1010 -0
  107. package/dist/web/bundle/pdfworker.js +48322 -0
  108. package/dist/web/index.html +239 -0
  109. package/dist/web/src/motion.js +430 -0
  110. package/dist/web/src/past.js +224 -0
  111. package/dist/web/src/scene.js +787 -0
  112. package/dist/web/style.css +2312 -0
  113. package/docs/agent-api/README.md +91 -0
  114. package/docs/agent-api/advice.md +108 -0
  115. package/docs/agent-api/export.md +120 -0
  116. package/docs/agent-api/following.md +140 -0
  117. package/docs/agent-api/history.md +142 -0
  118. package/docs/agent-api/library.md +65 -0
  119. package/docs/agent-api/mcp.md +104 -0
  120. package/docs/agent-api/notes.md +93 -0
  121. package/docs/agent-api/reading.md +166 -0
  122. package/docs/agent-api/requests.md +94 -0
  123. package/docs/agent-api/talking.md +100 -0
  124. package/docs/agent-api/tcb.md +135 -0
  125. package/docs/agent-api/views.md +124 -0
  126. package/docs/agent-api/words.md +56 -0
  127. package/package.json +39 -4
  128. package/test/toolchains.json +5 -0
@@ -0,0 +1,1101 @@
1
+ // The live loop: watch what the owner's builds leave behind, tell open pages at once, and
2
+ // re-read the graph when a build has settled.
3
+ //
4
+ // Two speeds, on purpose. Build status is cheap (a few stats and hashes) and goes out every
5
+ // second it changes, so a page shows "building 12 / 40" while Lean works. The graph is expensive
6
+ // and only meaningful between builds, so it is re-extracted once the outputs stop moving; until
7
+ // then the page keeps the last good graph and says how old it is.
8
+ //
9
+ // The extractor's graph is served as soon as it is ready; the usage pass (server/usage.ts) then
10
+ // elaborates the modules whose result is not cached, and its findings — the notation each unit is
11
+ // written in, the exact contexts — come as the next version of the graph. Where every module's
12
+ // result is cached, the merged graph is the first one served.
13
+ import { randomBytes } from "node:crypto";
14
+ import { prepare } from "./pipeline.js";
15
+ import { extract, loadSaved } from "./extract.js";
16
+ import { DEFAULT_JOBS, UsagePass, mergeUsage, saveMerged } from "./usage.js";
17
+ import { indexSources } from "./project.js";
18
+ import { LAYOUT_VERSION } from "../shared/graph.js";
19
+ import { fileOf, filesOf } from "../shared/library.js";
20
+ import { Timeline } from "./events.js";
21
+ import { agentKey, Presence } from "./presence.js";
22
+ import { AgentTokens } from "./tokens.js";
23
+ import { Invites } from "./invites.js";
24
+ import { ReadOnlyLinks } from "./readonly.js";
25
+ import { Talk } from "./talk.js";
26
+ import { Asks } from "./askfollow.js";
27
+ import { BuildTracker, importable, outputStamp, scanModules, sourceStamp } from "./status.js";
28
+ import { Views } from "./views.js";
29
+ import { graphOf, memoryProblem, titleContext } from "./agentviews.js";
30
+ import { ownName, rebinder, versionBase, versions, withVersions } from "./versions.js";
31
+ import { buildTime, commitNow, History } from "./history.js";
32
+ import { backfillSources, keepSources } from "./sourcestore.js";
33
+ import { findRenames, Renames } from "./renames.js";
34
+ import { carryClusterLinks, Library } from "./library.js";
35
+ import { Archive, archiveKeys, lostAndFound, MAX_ARCHIVE_KEYS } from "./lostfound.js";
36
+ import { identityIn, moveIsIdle } from "../shared/moves.js";
37
+ import { SERVER_WRITER } from "./review.js";
38
+ import { join } from "node:path";
39
+ import { PROJECT, viewTitle } from "../shared/views.js";
40
+ import { boardSet } from "../shared/board.js";
41
+ import { derivesOn, existsIn } from "../shared/noteplace.js";
42
+ const TICK = 1000;
43
+ /** outputs quiet this long, and no compile running: the build is over */
44
+ const SETTLE = 2500;
45
+ const PING = 15000;
46
+ /** Event streams, which every change goes to: at most this many open at once, and this many per
47
+ * page (`session`: a reload, a lost connection coming back), its oldest closed for a new one. */
48
+ export const MAX_STREAMS = 200;
49
+ /** Read-only readers' streams (CollabSpec decision 11): a bound of their own, so that they never
50
+ * take the places of the people working. */
51
+ export const MAX_READONLY_STREAMS = 400;
52
+ export const MAX_STREAMS_PER_SESSION = 4;
53
+ /** what a stream may have waiting to be sent before it is closed: a page that does not read is
54
+ * not kept up with by holding everything for it — it comes back (EventSource reconnects) and is
55
+ * sent the present state afresh */
56
+ export const MAX_BUFFERED = 4 << 20;
57
+ /** the list of who is here goes out at most this often: a burst of heartbeats is one event */
58
+ const PRESENCE_EVERY = 500;
59
+ /** The graph's modules and their files (meta.modules), per graph read (agentviews.ts, graphOf). */
60
+ 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
+ export class Live {
64
+ project;
65
+ /** the site directory and the project's caches (server/dirs.ts) */
66
+ dirs;
67
+ saved;
68
+ tracker;
69
+ mods = new Map();
70
+ lastStamp = "";
71
+ extractedStamp = "";
72
+ quietSince = 0;
73
+ build = null;
74
+ graph = null;
75
+ extracting = false;
76
+ extractError = null;
77
+ log = [];
78
+ /** the open event streams, oldest first, and the same by the secret each page holds */
79
+ streams = new Map();
80
+ byToken = new Map();
81
+ /** streams closed for not reading, since the start */
82
+ dropped = 0;
83
+ presenceTimer = null;
84
+ presenceDirty = false;
85
+ /** review changes not yet sent: those made in one turn of the event loop go out as one */
86
+ reviewPending = [];
87
+ version = 0;
88
+ /** the review's logs (ReviewSpec.md, server/review.ts) */
89
+ review;
90
+ /** who has a page open (CollabSpec.md) */
91
+ presence;
92
+ /** the views, each a shared board (ViewSpec.md) */
93
+ views;
94
+ /** the Project view's pins, with who dropped each, where something still speaks of the one set
95
+ * of shared pins (CollabSpec decision 2; the agents' API) */
96
+ places;
97
+ /** what happened (CollabSpec decision 5) */
98
+ timeline;
99
+ /** the tokens agents were given by Share (AgentSpec decision 7, server/tokens.ts) */
100
+ tokens;
101
+ /** the personal links people were invited by from Share (CollabSpec decision 9, server/invites.ts) */
102
+ invites;
103
+ /** read-only links (CollabSpec decision 11, server/readonly.ts) */
104
+ readOnly;
105
+ /** the conversations with agents, their waits and their states (AgentSpec decision 8, server/talk.ts) */
106
+ talk;
107
+ /** the requests to follow going on (CollabSpec decision 10, server/askfollow.ts): relayed to the
108
+ * pages asked through their streams, and their answers to the page that asked */
109
+ asks;
110
+ /** the "Open in editor" template the server was started with (shared/editor.ts) */
111
+ editor;
112
+ /** the usage pass, `jobs` workers at a time (0: none) */
113
+ usage;
114
+ /** the extraction the usage pass runs for: its findings are merged into that one only */
115
+ passFor = null;
116
+ /** a snapshot of the graph at every build read (HistorySpec.md, server/history.ts); none for a
117
+ * saved graph, which is no build */
118
+ history;
119
+ /** the renames found at each build and those migrated (server/renames.ts) */
120
+ renames;
121
+ /** the library's files, by their hash (AttachmentSpec.md, server/library.ts) */
122
+ library;
123
+ /** what is archived of the lost-and-found (server/lostfound.ts) */
124
+ archive;
125
+ /** the lost-and-found as the pages were last told it: how many entries it lists, and a revision
126
+ * that changes with what it holds (`lost`) */
127
+ lostSent = { rev: 0, count: 0, lost: 0, sig: "" };
128
+ lostTimer = null;
129
+ /** what the versions of the graph on show are made of, and the versions (server/versions.ts) */
130
+ base = null;
131
+ versions = [];
132
+ nodeIndex = new Map();
133
+ /** the declarations outside the project the graph on show refers to: in it too */
134
+ outside = new Set();
135
+ constructor(project, dirs, saved, review, editor = null, jobs = DEFAULT_JOBS) {
136
+ this.project = project;
137
+ this.dirs = dirs;
138
+ this.saved = saved;
139
+ this.review = review;
140
+ this.editor = editor;
141
+ this.usage = new UsagePass(project, dirs, saved ? 0 : jobs, (l) => this.say(l));
142
+ this.tracker = new BuildTracker(project);
143
+ this.timeline = new Timeline(dirs.site, (e) => this.send("activity", e));
144
+ this.tokens = new AgentTokens(dirs.site);
145
+ this.invites = new Invites(dirs.site);
146
+ // every page knows the names the people invited by a link go by, so that what each did shows
147
+ // under the name they go by now; a link revoked cuts its pages off at once, as it does its
148
+ // requests: their streams end, and come back only if the key lets them in
149
+ this.invites.onChanged = () => this.send("people", this.invites.people());
150
+ this.invites.onEnded = (r) => {
151
+ for (const st of [...this.streams.values()])
152
+ if (st.person === r.id)
153
+ this.close(st);
154
+ };
155
+ this.readOnly = new ReadOnlyLinks(dirs.site);
156
+ this.readOnly.onEnded = (l) => {
157
+ for (const st of [...this.streams.values()])
158
+ if (st.readOnly === l.id)
159
+ this.close(st);
160
+ };
161
+ this.talk = new Talk(dirs.site);
162
+ // what changes a view may change the lost-and-found: a pin of a name that is gone, a note
163
+ this.views = new Views(dirs.site, LAYOUT_VERSION, (event, data) => {
164
+ this.send(event, data);
165
+ if (event === "views" || event === "view-ops" || event === "note-ops")
166
+ this.lostChanged();
167
+ }, this.timeline);
168
+ this.views.syncTargets(Object.keys(review.data.targets));
169
+ this.history = saved ? null : new History(dirs.site);
170
+ this.library = new Library(dirs.site, dirs.cache);
171
+ this.renames = this.history ? new Renames(this.history.dir) : null;
172
+ this.archive = this.history ? new Archive(this.history.dir) : null;
173
+ // a view is kept at a past build the history has (HistorySpec decision 19)
174
+ this.views.snapshotAt = (id) => this.history?.get(id)?.at ?? null;
175
+ this.views.titles = () => titleContext(this);
176
+ this.views.latest = () => this.history?.latest()?.id ?? null;
177
+ // what a view is focused on is a target of the review (ViewSpec decision 28)
178
+ this.views.isTarget = (name) => Object.hasOwn(this.review.data.targets, name);
179
+ // what a note carries is a file of the library, whose records are the review's (AttachmentSpec
180
+ // decision 12)
181
+ this.views.carriedFile = (sha) => fileOf(this.review.data, sha);
182
+ // which notes a view's scope gives (the owner's decision of 2026-10-11: items belong to views as
183
+ // declarations do), read on the graph on show: a copy of the Project takes the others as added
184
+ this.views.book.derives = (v) => {
185
+ const G = graphOf(this);
186
+ if (!G)
187
+ return null;
188
+ const set = boardSet(G.g, this.review.data, v.scope).set;
189
+ return derivesOn(v.clustering, G.g.nodes.filter((_, i) => set[i]), existsIn(G.g.nodes), this.review.data.links);
190
+ };
191
+ // every item shows on the Project (the owner's decision of 2026-10-11): once, a note of the
192
+ // project for each file of the library no note carries that was never considered — those added
193
+ // before notes carried files, and those whose records came through the logs since the last start
194
+ const noted = this.views.noteLibraryFiles(filesOf(review.data), SERVER_WRITER.name);
195
+ if (noted)
196
+ this.say(`library: ${noted} file${noted === 1 ? "" : "s"} put on the Project's ring, each carried by a note`);
197
+ // a note's text follows its file's title (the owner's decision of 2026-10-11): the renames made
198
+ // while the server was away, and, the first time, the notes that still say a file's old name
199
+ const followed = this.views.followTitles(review.data, SERVER_WRITER.name);
200
+ if (followed)
201
+ this.say(`library: ${followed} note${followed === 1 ? "" : "s"} set to the title of the file ${followed === 1 ? "it carries" : "each carries"}`);
202
+ // every change of the review, from a page, an agent or the disk, goes to every page
203
+ review.onChange = (c) => this.reviewChanged(c);
204
+ this.views.onProjectRelayout = () => setTimeout(() => void this.relayout(), 50);
205
+ this.places = this.views.places;
206
+ // presence says which views pages have open: one nobody keeps lives while some page has it
207
+ this.presence = new Presence((peers) => {
208
+ this.views.seenPeers(peers);
209
+ this.presenceChanged();
210
+ });
211
+ this.views.peers = () => this.presence.list();
212
+ // every agent with a token in force is listed, in the state its requests show (listening,
213
+ // thinking, working, offline); a token that ended ends its wait and takes its agent off the
214
+ // list at once
215
+ const talkOf = (id) => this.talk.talk(id);
216
+ // read as each agent is first known, before any request of this server's life updates it:
217
+ // whether it was offline while the server was not running
218
+ this.talk.lastUsed = (id) => this.tokens.get(id)?.lastUsed ?? null;
219
+ this.presence.setTokens(this.tokens.inForce(), talkOf);
220
+ this.tokens.onChanged = () => this.presence.setTokens(this.tokens.inForce(), talkOf);
221
+ this.tokens.onEnded = (r) => this.talk.close(r.id, r.revoked ? "revoked" : "expired");
222
+ this.talk.inForce = (id) => this.tokens.inForce().some((r) => r.id === id);
223
+ this.talk.onState = (id, talk) => this.presence.setTalk(id, talk);
224
+ // a new message goes to every page, named by the agent's entry in presence
225
+ this.talk.onMessage = (id, message) => {
226
+ const r = this.tokens.get(id);
227
+ if (r)
228
+ this.send("chat", { agent: agentKey({ name: r.name, token: r.id }), message });
229
+ };
230
+ this.views.pageOf = (token) => (typeof token === "string" ? this.byToken.get(token)?.id ?? null : null);
231
+ // the views a private page holds live while a page of its session is open
232
+ this.views.sessionOpen = (session) => [...this.streams.values()].some((st) => st.session === session);
233
+ this.views.checkMemory = (v, mode, m) => memoryProblem(this, v, mode, m, this.views.pastOf(v));
234
+ // a request to follow reaches each page asked by its own stream, named as presence lists it
235
+ this.asks = new Asks({
236
+ streams: () => {
237
+ const listed = new Map(this.presence.list().map((p) => [p.id, p]));
238
+ // a read-only reader is seen by nobody, and asked by nobody (CollabSpec decision 11)
239
+ return [...this.streams.values()].filter((st) => st.readOnly === null).map((st) => {
240
+ const p = st.private ? undefined : listed.get(st.id);
241
+ return { token: st.token, id: st.id, private: st.private, name: p && !p.agent ? p.name : null, verified: !!p?.verified };
242
+ });
243
+ },
244
+ peers: () => this.presence.list(),
245
+ send: (token, event, data) => {
246
+ const st = this.byToken.get(token);
247
+ if (st)
248
+ this.write(st.res, event, data);
249
+ },
250
+ });
251
+ }
252
+ /** The open event stream whose page holds secret `token`: the id the others know it by, and
253
+ * whether it is private; null for any other. */
254
+ pageStream(token) {
255
+ const st = typeof token === "string" ? this.byToken.get(token) : undefined;
256
+ return st ? { id: st.id, private: st.private } : null;
257
+ }
258
+ /** The list of who is here goes out now, or with the next one if one went out just now: at most
259
+ * one every PRESENCE_EVERY ms, and always the latest. */
260
+ presenceChanged() {
261
+ if (this.presenceTimer) {
262
+ this.presenceDirty = true;
263
+ return;
264
+ }
265
+ this.send("presence", this.presence.list());
266
+ this.send("private", { count: this.presence.privateCount() });
267
+ this.presenceTimer = setTimeout(() => {
268
+ this.presenceTimer = null;
269
+ if (this.presenceDirty) {
270
+ this.presenceDirty = false;
271
+ this.presenceChanged();
272
+ }
273
+ }, PRESENCE_EVERY);
274
+ this.presenceTimer.unref?.();
275
+ }
276
+ /** A page's heartbeat, by the secret its stream was given: false when no open stream has it (a
277
+ * stream that closed, a guess), and nothing changes. A private page's (`priv`, CollabSpec
278
+ * decision 8) is counted and nothing more is kept of it: not its name, not where it is.
279
+ * `verified`: the page of someone invited by a personal link (decision 9). */
280
+ beat(token, name, view, idle, priv = false, verified = false) {
281
+ const s = typeof token === "string" ? this.byToken.get(token) : undefined;
282
+ if (!s)
283
+ return false;
284
+ if (s.private !== priv)
285
+ this.rename(s, priv, name, view, idle, verified);
286
+ else if (priv)
287
+ this.presence.beatPrivate(s.id);
288
+ else
289
+ this.presence.beat(s.id, name, view, idle, false, verified);
290
+ return true;
291
+ }
292
+ /** A page went private, or showed itself again (CollabSpec decision 8): its stream gets a new
293
+ * public id, so that nothing the others were told under one id — where the page was, under its
294
+ * name; the `from` of what it arranged — ties it to what they are told under the other. The page
295
+ * alone is told the new id (`hello`) and, right after, the list and the count as they are now:
296
+ * it counts itself out of the private count by its hello. Going private, the views it had open
297
+ * are held for it (server/views.ts, `hold`), since its heartbeats no longer say them; shown
298
+ * again, they do. */
299
+ rename(s, priv, name, view, idle, verified) {
300
+ const was = this.presence.pageView(s.id);
301
+ const old = s.id;
302
+ s.id = randomBytes(9).toString("base64url");
303
+ s.private = priv;
304
+ // a private reader cannot be followed: what they asked is withdrawn
305
+ if (priv)
306
+ this.asks.withdraw(s.token);
307
+ this.write(s.res, "hello", helloOf(s));
308
+ if (s.session) {
309
+ if (priv)
310
+ this.views.hold(s.session, was ? [...(was.view ? [was.view] : []), ...was.tabs] : []);
311
+ else
312
+ this.views.release(s.session);
313
+ }
314
+ this.presence.renamed(old, s.id, priv, name, view, idle, verified);
315
+ this.write(s.res, "presence", this.presence.list());
316
+ this.write(s.res, "private", { count: this.presence.privateCount() });
317
+ }
318
+ /** The file of a module of the graph on show, relative to the project (meta.modules): the only
319
+ * modules whose source is served or handed to Lean. Undefined when the graph has no such module
320
+ * (or there is no graph yet), null when it has no file. */
321
+ moduleFile(module) {
322
+ const G = graphOf(this);
323
+ if (!G)
324
+ return undefined;
325
+ let files = moduleFiles.get(G.meta);
326
+ if (!files)
327
+ moduleFiles.set(G.meta, (files = new Map(G.meta.modules.map((m) => [m.name, m.file]))));
328
+ return files.get(module);
329
+ }
330
+ /** The Project view's pins changed (the agents' API): every page gets the operations, and who
331
+ * made them. */
332
+ placesChanged(ops, from, by) {
333
+ this.send("view-ops", { id: PROJECT, ops, from, by });
334
+ }
335
+ /** The review changed (server/review.ts tells it): every open page gets what changed — the new
336
+ * events, which it applies to the review it holds (`review-delta`), or, where events came in
337
+ * between, the whole review (`review`). Changes made in one turn (an agent's batch of advice)
338
+ * go out as one. A new target's TCB is where its timeline starts; it has no view until a click
339
+ * gives it one (ViewSpec decision 29). */
340
+ reviewChanged(c) {
341
+ if (!this.reviewPending.length)
342
+ setImmediate(() => this.flushReview());
343
+ this.reviewPending.push(c);
344
+ }
345
+ flushReview() {
346
+ const cs = this.reviewPending;
347
+ this.reviewPending = [];
348
+ if (!cs.length)
349
+ return;
350
+ const last = cs[cs.length - 1];
351
+ const deltas = cs.every((c, k) => c.events !== null && (k === 0 || c.base === cs[k - 1].rev));
352
+ if (deltas)
353
+ this.send("review-delta", { epoch: last.epoch, base: cs[0].base, rev: last.rev, events: cs.flatMap((c) => c.events), problems: last.problems, error: this.review.error });
354
+ else
355
+ this.send("review", this.review.whole());
356
+ this.timeline.reviewChanged(this.review.data);
357
+ // a target a move brought from another name takes that name's view over; a target unmarked is
358
+ // unlinked from its view
359
+ const d = this.review.data;
360
+ this.views.syncTargets(Object.keys(d.targets), (t) => (Object.hasOwn(d.targets, t) ? d.targets[t].was : undefined));
361
+ // a file renamed in the library, here or in a log that came from elsewhere: the notes whose text
362
+ // was its title follow it
363
+ this.views.followTitles(d, SERVER_WRITER.name);
364
+ this.reversion();
365
+ this.lostChanged();
366
+ }
367
+ say(line) {
368
+ this.log.push(line);
369
+ if (this.log.length > 200)
370
+ this.log.shift();
371
+ console.log(line);
372
+ }
373
+ start() {
374
+ // the history begins with the extractions the project's cache holds from before it
375
+ if (this.history?.empty) {
376
+ try {
377
+ const n = this.history.importExtractions(join(this.dirs.cache, "extract"), this.project, (l) => this.say(l));
378
+ if (n)
379
+ this.say(`history: ${n} snapshot(s) made from the extractions kept before the history`);
380
+ }
381
+ catch (e) {
382
+ this.say(`history: the extractions kept before could not be imported: ${e instanceof Error ? e.message : String(e)}`);
383
+ }
384
+ }
385
+ // the source texts of the snapshots made before the history kept them, found once, in the
386
+ // background (HistorySpec decision 22): a snapshot backfilled is not looked at again
387
+ if (this.history)
388
+ void backfillSources(this.history, this.project, this.dirs.cache, (l) => this.say(l)).catch((e) => this.say(`history: the source texts could not be backfilled: ${e instanceof Error ? e.message : String(e)}`));
389
+ // the review's logs are watched whatever the graph comes from: they may change on disk any time
390
+ setInterval(() => this.checkReview(), TICK);
391
+ setInterval(() => {
392
+ for (const st of [...this.streams.values()])
393
+ this.push(st, ": ping\n\n");
394
+ }, PING);
395
+ if (this.saved) {
396
+ // a saved extraction does not change: no watching of the build
397
+ void this.extract(null);
398
+ return;
399
+ }
400
+ const loop = async () => {
401
+ try {
402
+ await this.tick();
403
+ }
404
+ catch (e) {
405
+ this.say(`watch: ${e instanceof Error ? e.message : String(e)}`);
406
+ }
407
+ setTimeout(loop, TICK);
408
+ };
409
+ void loop();
410
+ }
411
+ /** The review's logs changed on disk (by hand, a backup restored): what changed is read, and the
412
+ * store tells the pages. */
413
+ checkReview() {
414
+ try {
415
+ this.review.refresh();
416
+ }
417
+ catch (e) {
418
+ this.say(`review: ${e instanceof Error ? e.message : String(e)}`);
419
+ }
420
+ }
421
+ async tick() {
422
+ const now = Date.now();
423
+ const stampOf = () => `${outputStamp(this.project, this.mods)}#${sourceStamp(this.project, this.mods)}`;
424
+ let st = stampOf();
425
+ if (st !== this.lastStamp || this.mods.size === 0) {
426
+ // sources or outputs moved: files may have appeared, and the import graph changed
427
+ this.project.files = indexSources(this.project.root);
428
+ this.mods = scanModules(this.project);
429
+ st = stampOf();
430
+ }
431
+ // the first look is not a change: a project at rest is read at once
432
+ const moved = st !== this.lastStamp && this.lastStamp !== "";
433
+ this.lastStamp = st;
434
+ if (moved)
435
+ this.quietSince = now;
436
+ const build = await this.tracker.status(this.mods);
437
+ const before = this.build && JSON.stringify(this.build);
438
+ this.build = build;
439
+ if (JSON.stringify(build) !== before) {
440
+ this.send("build", build);
441
+ this.timeline.buildStatus(build);
442
+ }
443
+ const settled = !build.active && now - this.quietSince >= SETTLE;
444
+ if (settled && !this.extracting && st !== this.extractedStamp) {
445
+ this.extractedStamp = st;
446
+ const ok = importable(this.project, this.mods);
447
+ const okSet = new Set(ok);
448
+ const missing = [...this.mods.keys()].filter((m) => !okSet.has(m)).sort();
449
+ if (ok.length === 0) {
450
+ this.extractError = "No part of this build can be read: every file failed or sits on one that did. Showing the last graph that could be read.";
451
+ this.send("extract-error", { error: this.extractError });
452
+ }
453
+ else
454
+ void this.extract(missing.length ? tops(ok, this.mods) : null, missing);
455
+ }
456
+ }
457
+ /** `roots` replaces the project's roots when part of the build cannot be imported. */
458
+ async extract(roots, missing = []) {
459
+ this.extracting = true;
460
+ this.extractError = null;
461
+ this.send("extracting", { missing });
462
+ if (missing.length)
463
+ this.say(`${missing.length} module(s) cannot be imported; reading the rest`);
464
+ try {
465
+ const project = roots ? { ...this.project, roots } : this.project;
466
+ const ex = this.saved ? loadSaved(this.saved) : await extract(project, this.dirs, (l) => this.say(l));
467
+ this.say(ex.cached ? `extraction ${ex.fingerprint} reused` : `extraction ${ex.fingerprint} done`);
468
+ // an edit without a build reads the same extraction again: a pass running for it goes on
469
+ if (this.passFor === ex.fingerprint && this.usage.status.state === "running")
470
+ return;
471
+ // a pass still running is for a build that is over: what it finished stays cached
472
+ this.usage.cancel();
473
+ this.passFor = null;
474
+ if (!this.usage.enabled || ex.graph.usage) {
475
+ await this.install(ex, missing, ex.graph.usage ? "done" : "off");
476
+ return;
477
+ }
478
+ const plan = this.usage.plan(ex.graph);
479
+ if (!plan.todo.length) {
480
+ // every module's result is cached: the merged graph is the one served
481
+ const merged = await this.merge(ex, plan);
482
+ if (merged)
483
+ await this.install(merged, missing, "done", ex.fingerprint);
484
+ return;
485
+ }
486
+ await this.install(ex, missing, "pending");
487
+ this.passFor = ex.fingerprint;
488
+ void this.pass(ex, plan, missing);
489
+ }
490
+ catch (e) {
491
+ this.extractError = e instanceof Error ? e.message : String(e);
492
+ this.say(this.extractError);
493
+ // a build that started underneath us is retried at the next settle; anything else waits
494
+ // for the next change, rather than failing again every few seconds
495
+ if (this.extractError.includes("build is incomplete"))
496
+ this.extractedStamp = "";
497
+ this.send("extract-error", { error: this.extractError });
498
+ }
499
+ finally {
500
+ this.extracting = false;
501
+ this.send("idle", {});
502
+ }
503
+ }
504
+ /** Lay out an extraction and serve it as the next version of the graph, unless it is the one
505
+ * served already. A graph that waits for the usage pass is not compared with the targets' TCBs
506
+ * (events.ts): it lacks the notation, and the next version brings it back. Its layout is
507
+ * provisional: the usage pass's graph, which `completes` that build's extraction, is laid out as
508
+ * the build's own (server/pipeline.ts, prepare). */
509
+ async install(ex, missing, usage, completes) {
510
+ const laid = await prepare(this.project, this.dirs, null, (l) => this.say(l), { extraction: ex, provisional: usage === "pending", completes });
511
+ if (this.graph && this.graph.fingerprint === laid.fingerprint)
512
+ return;
513
+ const failed = ex.graph.usage?.failed.length ?? 0;
514
+ this.base = versionBase(ex.graph);
515
+ this.nodeIndex = new Map(this.base.names.map((n, i) => [n, i]));
516
+ this.outside = new Set(ex.graph.externals.map((e) => e.name));
517
+ // the history first: the renames found against the build before are migrated, so that the
518
+ // versions served are made under them (HistorySpec.md)
519
+ const renamed = this.snapshot(ex, missing);
520
+ const prep = this.versioned(laid);
521
+ this.graph = { version: ++this.version, fingerprint: prep.fingerprint, at: new Date().toISOString(), missing, usage: { state: usage, failed }, prep };
522
+ this.send("graph", this.graphInfo());
523
+ this.lostChanged();
524
+ // opinions recorded under the kernel's hash are bound to the versions of this graph
525
+ this.review.setReading(rebinder((name, kernel) => this.versionOfKernel(name, kernel)));
526
+ this.say(`graph v${this.graph.version} ready${usage === "pending" ? "; notation and contexts follow when the usage pass is done" : ""}`);
527
+ if (usage === "pending")
528
+ return;
529
+ try {
530
+ this.timeline.graphChanged(prep, this.review.data, renamed);
531
+ }
532
+ catch (e) {
533
+ this.say(`timeline: ${e instanceof Error ? e.message : String(e)}`);
534
+ }
535
+ }
536
+ /** The page's graph with its versions made under the renames in force (server/versions.ts): the
537
+ * one laid out where they change nothing in it. */
538
+ versioned(prep) {
539
+ if (!this.base)
540
+ return prep;
541
+ const identity = this.identity();
542
+ this.identityKey = this.identityKeyNow();
543
+ this.versions = versions(this.base, identity);
544
+ const gz = withVersions(prep.metaGz, this.base, identity);
545
+ return gz ? { ...prep, metaGz: gz } : prep;
546
+ }
547
+ /** The renames in force changed (a move made by hand or undone, or one found in the logs): the graph
548
+ * on show is served again with its versions made under them, and opinions recorded under the
549
+ * kernel's hash are bound again. */
550
+ reversion() {
551
+ if (!this.graph || this.identityKeyNow() === this.identityKey)
552
+ return;
553
+ const prep = this.versioned(this.graph.prep);
554
+ this.review.setReading(rebinder((name, kernel) => this.versionOfKernel(name, kernel)));
555
+ if (prep === this.graph.prep)
556
+ return;
557
+ this.graph = { ...this.graph, version: ++this.version, at: new Date().toISOString(), prep };
558
+ this.send("graph", this.graphInfo());
559
+ this.say(`graph v${this.graph.version}: versions made again under the renames in force`);
560
+ }
561
+ /** what the renames in force were when the versions on show were made */
562
+ identityKey = "";
563
+ /** The renames in force, as a key: a snapshot's graph served under them (server/historyapi.ts)
564
+ * is served again when it changes. */
565
+ identityTag() {
566
+ return this.identityKeyNow();
567
+ }
568
+ identityKeyNow() {
569
+ const r = this.review.data.renamed;
570
+ return r ? JSON.stringify(Object.entries(r).sort()) : "";
571
+ }
572
+ /** The pass run, and its results merged into the extraction, filed beside it; null when it
573
+ * was stopped. */
574
+ async merge(ex, plan, onProgress) {
575
+ const results = await this.usage.run(plan, onProgress);
576
+ if (!results)
577
+ return null;
578
+ const st = this.usage.status;
579
+ const merged = mergeUsage(ex, plan, results, st.failed, st.ms ?? 0);
580
+ saveMerged(this.dirs.cache, merged);
581
+ const u = merged.graph.usage;
582
+ this.say(`usage pass: ${plan.modules.length} modules, ${plan.todo.length} elaborated, ${plan.cached.size} reused, ${st.failed.length} failed, ` +
583
+ `${((st.ms ?? 0) / 1000).toFixed(1)} s; ${u.stats.notationEdges} notation edges, ${u.stats.nestedEdges} between notations`);
584
+ for (const f of st.failed.slice(0, 20))
585
+ this.say(` usage pass: ${f.module}: ${f.error}`);
586
+ if (st.failed.length > 20)
587
+ this.say(` usage pass: … and ${st.failed.length - 20} more (GET /api/status)`);
588
+ return merged;
589
+ }
590
+ /** The usage pass, after the extractor's graph is served: its findings are the next version,
591
+ * if no newer build came first. */
592
+ async pass(ex, plan, missing) {
593
+ const t0 = Date.now();
594
+ let next = 0;
595
+ this.say(`usage pass: elaborating ${plan.todo.length} of ${plan.modules.length} modules, ${this.usage.jobs} at a time`);
596
+ const progress = () => {
597
+ const st = this.usage.status;
598
+ if (st.done < next && st.done < st.todo)
599
+ return;
600
+ next = st.done + Math.max(1, Math.ceil(st.todo / 10));
601
+ this.say(`usage pass: ${st.done}/${st.todo} (${Math.round((Date.now() - t0) / 1000)} s)`);
602
+ };
603
+ try {
604
+ const merged = await this.merge(ex, plan, progress);
605
+ // a re-layout going on is let finish; a newer build supersedes the pass
606
+ while (this.extracting && this.passFor === ex.fingerprint)
607
+ await new Promise((ok) => setTimeout(ok, 200));
608
+ if (!merged || this.passFor !== ex.fingerprint)
609
+ return;
610
+ this.passFor = null;
611
+ await this.install(merged, missing, "done", ex.fingerprint);
612
+ }
613
+ catch (e) {
614
+ this.say(`usage pass: ${e instanceof Error ? e.message : String(e)}`);
615
+ }
616
+ }
617
+ /** The version of declaration `name` whose kernel's content hash was `kernel` (server/versions.ts):
618
+ * the one on show, where it has that hash, else the one a snapshot of the history has; null where
619
+ * no graph read has it so. */
620
+ versionOfKernel(name, kernel) {
621
+ const i = this.nodeIndex.get(name);
622
+ if (i !== undefined && this.base?.kernel[i] === kernel)
623
+ return this.versions[i];
624
+ // renamed since, its content the kernel's same (a theorem that does not mention itself)
625
+ const renamed = this.review.data.renamed;
626
+ if (renamed) {
627
+ const ahead = new Map(Object.entries(renamed).map(([to, from]) => [from, to]));
628
+ let n = name;
629
+ for (let k = 0; k < 1000 && ahead.has(n); k++) {
630
+ n = ahead.get(n);
631
+ const j = this.nodeIndex.get(n);
632
+ if (j !== undefined && this.base?.kernel[j] === kernel)
633
+ return this.versions[j];
634
+ }
635
+ }
636
+ return this.history?.versionOfKernel(name, kernel, this.identity()) ?? null;
637
+ }
638
+ /** The identities the renames in force give (server/versions.ts, shared/moves.ts): what versions
639
+ * are made under. */
640
+ identity() {
641
+ const renamed = this.review.data.renamed;
642
+ return renamed ? (u) => identityIn(renamed, u) : ownName;
643
+ }
644
+ /** Keep a snapshot of the graph just read (server/history.ts): a new build's, or its own again;
645
+ * then pair what vanished since the build before with what appeared (server/renames.ts), and
646
+ * migrate the renames of high confidence. The pages are told of a snapshot made or changed
647
+ * (`history`). The renames migrated, old name → new. */
648
+ snapshot(ex, missing) {
649
+ const renamed = new Map();
650
+ if (!this.history || !this.renames)
651
+ return renamed;
652
+ try {
653
+ const t0 = Date.now();
654
+ const commit = commitNow(this.project.root);
655
+ // the texts the build was compiled from, while its files are most likely still those
656
+ // (HistorySpec decision 21): only the files that changed since any build are written
657
+ let written = 0;
658
+ const sources = () => {
659
+ const found = keepSources(this.project, ex.graph.modules.map((m) => m.name), commit?.hash ?? null, this.history.sources);
660
+ written = found.written;
661
+ return found;
662
+ };
663
+ const r = this.history.record(ex.graph, { at: buildTime(this.project), fingerprint: ex.fingerprint, commit, toolchain: this.project.toolchain, missing, sources });
664
+ if (!r.written)
665
+ return renamed;
666
+ const s = r.info;
667
+ if (r.before) {
668
+ const after = this.history.graph(s.id);
669
+ const pairs = findRenames(r.before, after, this.identity());
670
+ this.renames.record(s.id, pairs);
671
+ const found = this.renames.found.get(s.id) ?? [];
672
+ this.history.annotate(s.id, { renames: { high: found.filter((p) => p.confidence === "high").length, medium: found.filter((p) => p.confidence === "medium").length } });
673
+ const high = pairs.filter((p) => p.confidence === "high");
674
+ if (pairs.length)
675
+ this.say(`history: ${high.length} rename(s) found against ${r.before.info.id === s.id ? "this build read before" : `snapshot ${r.before.info.id}`}, ${pairs.length - high.length} suggestion(s)`);
676
+ const n = this.migrate(high, after, s.id);
677
+ if (n)
678
+ this.say(`history: ${n} rename(s) migrated`);
679
+ // the links on a cluster the build renamed as a whole go with it (AttachmentSpec decision 6)
680
+ const links = carryClusterLinks(this.review, this.project.toolchain, r.before, after, new Map(high.map((p) => [p.from, p.to])), s.id, (l) => this.say(l));
681
+ if (links)
682
+ this.say(`history: ${links} link(s) carried to a renamed cluster`);
683
+ for (const p of high)
684
+ renamed.set(p.from, p.to);
685
+ // a project note goes with its cluster where the build moved all of it to another (a folder
686
+ // or a namespace renamed: AttachmentSpec decision 18)
687
+ const carried = this.views.carryNotes(r.before.nodes, after.nodes, renamed, SERVER_WRITER.name);
688
+ if (carried)
689
+ this.say(`history: ${carried} note(s) carried to the cluster their declarations moved to`);
690
+ }
691
+ const t = s.sources;
692
+ const texts = t ? `; ${t.kept} of ${t.of} source texts kept, ${(t.bytes.all / 1024).toFixed(0)} KB, ${(written / 1024).toFixed(0)} KB of it written now` : "";
693
+ this.say(`history: snapshot ${s.id} ${r.fresh ? "of a new build" : "of its build, read again"}: ${s.counts.nodes} declarations in ${(s.bytes.all / 1024).toFixed(0)} KB, ${(s.bytes.new / 1024).toFixed(0)} KB of it written for this build${texts} (${Date.now() - t0} ms)`);
694
+ this.send("history", this.history.get(s.id));
695
+ }
696
+ catch (e) {
697
+ this.say(`history: ${e instanceof Error ? e.message : String(e)}`);
698
+ }
699
+ return renamed;
700
+ }
701
+ /** The renames of high confidence found from snapshot `from` to snapshot `to`, composed: what the
702
+ * Time Machine's diff calls renamed. */
703
+ renamesBetween(from, to) {
704
+ return this.renames && this.history ? this.renames.between(from, to, this.history.list().map((s) => s.id)) : new Map();
705
+ }
706
+ /** The people invited by a personal link, by the name each goes by now (CollabSpec decision 9):
707
+ * what the agents' API names them by, as the pages do (an exported document's authors). */
708
+ people() {
709
+ return this.invites.people();
710
+ }
711
+ /** What the lost-and-found lists of moves (HistorySpec.md): every migration, the server's and
712
+ * those made by hand, undone or not; and the suggestions not acted on — pairs of medium
713
+ * confidence whose old name the graph no longer has and whose new one it has, that no migration
714
+ * in force made. */
715
+ moves() {
716
+ if (!this.renames)
717
+ return { migrations: [], suggestions: [] };
718
+ const done = new Set(this.renames.migrations.filter((m) => !m.undone).map((m) => `${m.from}\u0000${m.to}`));
719
+ const suggestions = [];
720
+ for (const [snap, pairs] of this.renames.found)
721
+ for (const p of pairs)
722
+ if (p.confidence === "medium" && !done.has(`${p.from}\u0000${p.to}`) && !this.nodeIndex.has(p.from) && this.nodeIndex.has(p.to))
723
+ suggestions.push({ ...p, snap });
724
+ return { migrations: this.renames.migrations, suggestions };
725
+ }
726
+ /** Attach to… (HistorySpec.md): what was said of `from`, a declaration the graph no longer has,
727
+ * carried to `to`, one it has, by a person or an agent at level full: a move in their log, and
728
+ * its pins, notes' mentions and scopes moved on the views. It says `to` is `from` renamed where
729
+ * `to` was not there yet in the last build that had `from`. */
730
+ moveByHand(from, to, who) {
731
+ if (!this.graph || !this.renames)
732
+ return { status: 503, body: { error: "The graph has not been read yet." } };
733
+ if (from === to)
734
+ return { status: 400, body: { error: "A move is from one declaration to another." } };
735
+ if (this.nodeIndex.has(from))
736
+ return { status: 409, body: { error: `${from} is in the graph: only what was said of a declaration that is gone is attached to another.` } };
737
+ if (!this.nodeIndex.has(to))
738
+ return { status: 404, body: { error: `There is no declaration ${to} in the graph.` } };
739
+ let rename = false;
740
+ for (const snap of this.history?.list().reverse() ?? []) {
741
+ const g = this.history.graph(snap.id);
742
+ if (!g || !g.nodes.some((n, i) => i < g.projectCount && n.name === from))
743
+ continue;
744
+ rename = !g.nodes.some((n, i) => i < g.projectCount && n.name === to);
745
+ break;
746
+ }
747
+ const d = this.review.data;
748
+ const carried = carriedOf(d, from);
749
+ let review = false;
750
+ if (!moveIsIdle(d, { from, to, on: true, rename: rename || undefined })) {
751
+ const r = this.review.append({ name: who.name, agent: who.agent, person: who.person ?? null }, { op: "move", from, to, on: true, ...(rename ? { rename: true } : {}), by: who.name, ...(who.agent ? { agent: true } : {}), ...(who.person ? { person: who.person } : {}) }, this.project.toolchain);
752
+ if ("status" in r)
753
+ return { status: r.status, body: { error: r.error } };
754
+ review = !!r.change;
755
+ }
756
+ // a target's view goes with it, where it has one, once the move is replayed (flushReview)
757
+ const targetView = carried.targets && this.views.book.viewOf(from) ? 1 : 0;
758
+ const views = this.views.moveDeclaration(from, to, who.name, who.agent);
759
+ if (!review && !views.length)
760
+ return { status: 200, body: { ok: true, changed: false } };
761
+ const m = this.renames.migrated({ at: new Date().toISOString(), from, to, by: who.name, ...(who.agent ? { agent: true } : {}), ...(who.person ? { person: who.person } : {}), rename, review, carried: { ...carried, ...viewsCarried(views), reviewViews: targetView }, views });
762
+ this.lostChanged();
763
+ return { status: 200, body: { ok: true, changed: true, migration: m } };
764
+ }
765
+ /** A migration undone, by a person or an agent at level full: an un-move in their log, which
766
+ * takes back what the move carried, and the views' operations that undo what it did there. */
767
+ unmove(id, who) {
768
+ const m = this.renames?.get(id);
769
+ if (!m)
770
+ return { status: 404, body: { error: `There is no migration ${id}.` } };
771
+ if (m.undone)
772
+ return { status: 409, body: { error: `${id} was undone by ${m.undone.by} at ${m.undone.at}.` } };
773
+ if (m.review) {
774
+ const r = this.review.append({ name: who.name, agent: who.agent, person: who.person ?? null }, { op: "move", from: m.from, to: m.to, on: false, ...(m.rename ? { rename: true } : {}), by: who.name, ...(who.agent ? { agent: true } : {}), ...(who.person ? { person: who.person } : {}) }, this.project.toolchain);
775
+ if ("status" in r)
776
+ return { status: r.status, body: { error: r.error } };
777
+ }
778
+ this.views.unmoveDeclaration(m.from, m.to, m.views, who.name, who.agent, Object.hasOwn(this.review.data.targets, m.from) && !Object.hasOwn(this.review.data.targets, m.to));
779
+ this.renames.undone(id, who.name, who.agent, who.person);
780
+ this.lostChanged();
781
+ return { status: 200, body: { ok: true, migration: m } };
782
+ }
783
+ /** The lost-and-found (HistorySpec decisions 12–14, server/lostfound.ts): what the review and the
784
+ * views hold of names the graph on show does not have, the migrations, what is archived. Empty
785
+ * until a graph is read: before, every name would look gone. */
786
+ lostAndFound() {
787
+ if (!this.graph || !this.history || !this.renames)
788
+ return { lost: [], migrations: [], archived: [], aside: { entries: 0, modules: [] }, count: 0 };
789
+ const moves = this.moves();
790
+ const titles = titleContext(this);
791
+ return lostAndFound({
792
+ review: this.review.data,
793
+ views: this.views.book.list(),
794
+ notes: this.views.book.notes,
795
+ has: (name) => this.nodeIndex.has(name) || this.outside.has(name),
796
+ last: (name) => this.lastHad(name),
797
+ missing: this.graph.missing,
798
+ pinWho: (view, mode, name) => this.views.book.authorOf(view, mode, "node", name),
799
+ title: (v) => viewTitle(v, titles),
800
+ atBuild: (v, name) => {
801
+ const past = this.views.pastOf(v);
802
+ return past !== null && !!this.history?.hadAt(name, past);
803
+ },
804
+ // a link's cluster: a namespace something of the project is under, or a module prefix
805
+ hasCluster: (kind, key) => {
806
+ const under = (s) => s === key || s.startsWith(key + ".");
807
+ return kind === "ns" ? [...this.nodeIndex.keys()].some(under) : [...this.project.files.keys()].some(under);
808
+ },
809
+ migrations: moves.migrations,
810
+ suggestions: moves.suggestions,
811
+ archive: this.archive,
812
+ });
813
+ }
814
+ /** The newest build whose graph had `name` (Show in history), or null. */
815
+ lastHad(name) {
816
+ const l = this.history?.lastHad(name);
817
+ const s = l ? this.history.get(l.id) : null;
818
+ return l && s ? { name, snapshot: s.id, at: s.at, ...(s.approx ? { approx: true } : {}), commit: s.commit, module: l.module } : null;
819
+ }
820
+ /** Archive entries of the lost-and-found, or put them back (`on` false), by a person with a name
821
+ * or an agent at level full: a line each in `history/archive.jsonl` whose state changes. */
822
+ archiveEntries(keys, on, who) {
823
+ if (!this.archive)
824
+ return { status: 404, body: { error: "This server keeps no history, and so no lost-and-found." } };
825
+ if (!keys.length || keys.length > MAX_ARCHIVE_KEYS)
826
+ return { status: 400, body: { error: `Send {key} or {keys}: 1 to ${MAX_ARCHIVE_KEYS} entries' keys.` } };
827
+ const write = archiveKeys(this.lostAndFound(), keys, on, this.archive);
828
+ if (typeof write === "string")
829
+ return { status: 404, body: { error: write } };
830
+ const lines = this.archive.set(write, on, who);
831
+ if (lines.length)
832
+ this.sendLost();
833
+ return { status: 200, body: { ok: true, changed: lines.length > 0, lines } };
834
+ }
835
+ /** Something the lost-and-found reads changed (the review, a view, the graph, a migration): the
836
+ * pages are told once it settles, if what it lists changed. */
837
+ lostChanged() {
838
+ if (this.lostTimer || !this.archive)
839
+ return;
840
+ this.lostTimer = setTimeout(() => {
841
+ this.lostTimer = null;
842
+ this.sendLost();
843
+ }, 250);
844
+ this.lostTimer.unref?.();
845
+ }
846
+ /** Tell the pages how many entries the lost-and-found lists — all, and the lost things among
847
+ * them — with a revision that changes with what it holds, so that a page showing it reads it
848
+ * again (`lost`). Nobody to tell, nothing done. */
849
+ sendLost() {
850
+ // nobody to tell: whoever comes next has it worked out afresh
851
+ if (!this.streams.size) {
852
+ this.lostSent.sig = "";
853
+ return;
854
+ }
855
+ try {
856
+ const a = this.lostAndFound();
857
+ const sig = JSON.stringify([a.lost.map((g) => [g.name, g.suggestions.length, g.entries.map((e) => e.key)]), a.migrations.map((m) => [m.key, !!m.undone]), a.archived.map((x) => x.key), a.aside.entries]);
858
+ if (sig === this.lostSent.sig)
859
+ return;
860
+ this.lostSent = { rev: this.lostSent.rev + 1, count: a.count, lost: a.count - a.migrations.length, sig };
861
+ this.send("lost", lostNews(this.lostSent));
862
+ }
863
+ catch (e) {
864
+ this.say(`lost-and-found: ${e instanceof Error ? e.message : String(e)}`);
865
+ }
866
+ }
867
+ /** Migrate renames of high confidence found at snapshot `snap` (HistorySpec.md): for each, a move
868
+ * in the server's log where something was said of the declaration — opinions, its target mark,
869
+ * key marks — or where a declaration that refers to it has opinions, whose versions its
870
+ * identity keeps; and its pins, notes' mentions and views' scopes moved on the views. Each is
871
+ * recorded with what undoes it. How many carried anything. */
872
+ migrate(pairs, after, snap) {
873
+ if (!pairs.length || !this.renames)
874
+ return 0;
875
+ const d = this.review.data;
876
+ const own = (o, k) => Object.hasOwn(o, k);
877
+ // under its name in this build, or the one it had before it
878
+ const before = new Map(pairs.map((p) => [p.to, p.from]));
879
+ const opined = (n) => own(d.opinions, n) || (before.has(n) && own(d.opinions, before.get(n)));
880
+ // who refers to what, in this build
881
+ const users = new Map();
882
+ for (let i = 0; i < after.projectCount; i++) {
883
+ const u = after.nodes[i];
884
+ for (const r of new Set((u.refs ?? []).map((x) => x.split(" ")[0]))) {
885
+ const xs = users.get(r);
886
+ if (xs)
887
+ xs.push(u.name);
888
+ else
889
+ users.set(r, [u.name]);
890
+ }
891
+ }
892
+ let made = 0;
893
+ for (const p of pairs) {
894
+ const carried = carriedOf(d, p.from);
895
+ const said = carried.opinions + carried.targets + carried.keys + carried.links > 0;
896
+ const matters = said || (users.get(p.to) ?? []).some((u) => u !== p.to && opined(u));
897
+ let review = false;
898
+ if (matters) {
899
+ const r = this.review.append(SERVER_WRITER, { op: "move", from: p.from, to: p.to, on: true, rename: true, auto: p.why, snap, by: SERVER_WRITER.name }, this.project.toolchain);
900
+ if ("status" in r)
901
+ this.say(`history: the move of ${p.from} to ${p.to} could not be written: ${r.error}`);
902
+ else
903
+ review = !!r.change;
904
+ }
905
+ // a target's view goes with it, where it has one, once the move is replayed (flushReview)
906
+ const targetView = carried.targets && this.views.book.viewOf(p.from) ? 1 : 0;
907
+ const views = this.views.moveDeclaration(p.from, p.to, SERVER_WRITER.name);
908
+ if (!review && !views.length)
909
+ continue;
910
+ const v = { ...viewsCarried(views), reviewViews: targetView };
911
+ this.renames.migrated({ at: new Date().toISOString(), from: p.from, to: p.to, snap, by: SERVER_WRITER.name, auto: p.why, rename: true, review, carried: { ...carried, ...v }, views });
912
+ made++;
913
+ }
914
+ return made;
915
+ }
916
+ /** The usage pass's contexts of a module (shared/context.ts), when the graph on show has them. */
917
+ contextsOf(module) {
918
+ return this.graph?.prep.usage?.contexts[module] ?? null;
919
+ }
920
+ /** Lay the graph on screen out from scratch, forgetting where things were. It reuses that
921
+ * graph's extraction: a re-layout never reads the build, which may be half-written. */
922
+ async relayout() {
923
+ if (this.extracting || !this.graph)
924
+ return;
925
+ this.extracting = true;
926
+ this.send("extracting", { missing: this.graph.missing });
927
+ try {
928
+ const laid = await prepare(this.project, this.dirs, this.saved, (l) => this.say(l), {
929
+ freshLayout: true,
930
+ reuse: this.saved ? undefined : this.graph.fingerprint,
931
+ // laid out afresh while the usage pass runs: its graph is laid out afresh too
932
+ provisional: this.graph.usage?.state === "pending",
933
+ });
934
+ const prep = this.versioned(laid);
935
+ this.graph = { ...this.graph, version: ++this.version, at: new Date().toISOString(), prep };
936
+ this.send("graph", this.graphInfo());
937
+ this.say(`graph v${this.graph.version}: laid out afresh`);
938
+ }
939
+ catch (e) {
940
+ const msg = e instanceof Error ? e.message : String(e);
941
+ this.say(`re-layout: ${msg}`);
942
+ this.send("extract-error", { error: msg });
943
+ }
944
+ finally {
945
+ this.extracting = false;
946
+ this.send("idle", {});
947
+ }
948
+ }
949
+ graphInfo() {
950
+ if (!this.graph)
951
+ return null;
952
+ const { prep: _, ...info } = this.graph;
953
+ return info;
954
+ }
955
+ /** What /api/status says: enough for the loader and for a page that just connected. */
956
+ status() {
957
+ return {
958
+ state: this.graph ? "ready" : this.extractError ? "error" : "extracting",
959
+ error: this.graph ? undefined : this.extractError ?? undefined,
960
+ log: this.log,
961
+ extracting: this.extracting,
962
+ extractError: this.extractError,
963
+ graph: this.graphInfo(),
964
+ // the usage pass: how far it is, and the modules it could not elaborate
965
+ usage: this.usage.status,
966
+ build: this.build,
967
+ live: !this.saved,
968
+ editor: this.editor,
969
+ streams: { open: this.streams.size, max: MAX_STREAMS, readOnly: this.readOnlyStreams(), maxReadOnly: MAX_READONLY_STREAMS, closedForNotReading: this.dropped, agentsListening: this.talk.count },
970
+ // the review's folder, and the lines of its logs that cannot be read (server/review.ts)
971
+ review: { folder: this.review.dir, problems: this.review.problems, error: this.review.error },
972
+ };
973
+ }
974
+ /** A page's event stream; `session` is what the page calls itself (its streams are kept few by
975
+ * it), `person` the personal link it came by. The stream is named by the server: the page is
976
+ * told its id, which everyone sees, and a secret for its heartbeats (`hello`); it is gone from
977
+ * presence when its stream closes. */
978
+ subscribe(res, session = null, person = null, readOnly = null) {
979
+ if (session) {
980
+ // a page that opens one more than a page needs (a reload whose old stream lingers) loses
981
+ // its oldest
982
+ const mine = [...this.streams.values()].filter((st) => st.session === session);
983
+ for (const st of mine.slice(0, Math.max(0, mine.length - MAX_STREAMS_PER_SESSION + 1)))
984
+ this.close(st);
985
+ }
986
+ const [n, max] = readOnly === null ? [this.streams.size - this.readOnlyStreams(), MAX_STREAMS] : [this.readOnlyStreams(), MAX_READONLY_STREAMS];
987
+ if (n >= max) {
988
+ res.writeHead(503, { "content-type": "application/json", "cache-control": "no-store", "retry-after": "10" });
989
+ res.end(JSON.stringify({ error: `This server has ${max} pages open${readOnly === null ? "" : " to read only"}, as many as it serves: try again in a while.` }));
990
+ return;
991
+ }
992
+ res.writeHead(200, {
993
+ "content-type": "text/event-stream",
994
+ "cache-control": "no-store",
995
+ connection: "keep-alive",
996
+ });
997
+ res.write("retry: 2000\n\n");
998
+ const st = { res, id: randomBytes(9).toString("base64url"), token: randomBytes(24).toString("base64url"), session, private: false, person, readOnly };
999
+ this.streams.set(res, st);
1000
+ this.byToken.set(st.token, st);
1001
+ this.presence.open(st.id);
1002
+ res.on("close", () => this.close(st));
1003
+ this.write(res, "hello", helloOf(st));
1004
+ this.write(res, "presence", this.presence.list());
1005
+ this.write(res, "private", { count: this.presence.privateCount() });
1006
+ this.write(res, "people", this.invites.people());
1007
+ // the views as they are: a page that was away (a restart, a lost connection) catches up, and
1008
+ // reads again the boards it has open
1009
+ this.write(res, "views", { views: this.views.list(), changes: [], all: true });
1010
+ if (this.build)
1011
+ this.write(res, "build", this.build);
1012
+ // the whole review once; what changes after comes as deltas
1013
+ this.write(res, "review", this.review.whole());
1014
+ const g = this.graphInfo();
1015
+ if (g)
1016
+ this.write(res, "graph", g);
1017
+ if (this.extracting)
1018
+ this.write(res, "extracting", {});
1019
+ // how many entries the lost-and-found lists, as the pages were last told; the first page
1020
+ // to come has it worked out
1021
+ if (this.lostSent.sig)
1022
+ this.write(res, "lost", lostNews(this.lostSent));
1023
+ else
1024
+ this.lostChanged();
1025
+ }
1026
+ write(res, event, data) {
1027
+ const st = this.streams.get(res);
1028
+ if (st)
1029
+ this.push(st, `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`);
1030
+ }
1031
+ /** Every open page gets the event. It is written once and the one text handed to every stream,
1032
+ * so that what waits for slow pages is shared, not copied per page. */
1033
+ send(event, data) {
1034
+ if (!this.streams.size)
1035
+ return;
1036
+ const text = `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`;
1037
+ for (const st of [...this.streams.values()])
1038
+ this.push(st, text);
1039
+ }
1040
+ /** Write to a stream, unless what it has not read yet passed MAX_BUFFERED: then it is closed
1041
+ * instead, and its page reconnects to the present state. */
1042
+ push(st, text) {
1043
+ if (st.res.writableLength > MAX_BUFFERED) {
1044
+ this.dropped++;
1045
+ this.close(st);
1046
+ return;
1047
+ }
1048
+ st.res.write(text);
1049
+ }
1050
+ /** A stream is over: its page leaves the list, its secret means nothing any more. */
1051
+ /** How many read-only readers' streams are open. */
1052
+ readOnlyStreams() {
1053
+ let n = 0;
1054
+ for (const st of this.streams.values())
1055
+ if (st.readOnly !== null)
1056
+ n++;
1057
+ return n;
1058
+ }
1059
+ close(st) {
1060
+ if (this.streams.get(st.res) !== st)
1061
+ return;
1062
+ this.streams.delete(st.res);
1063
+ this.byToken.delete(st.token);
1064
+ this.presence.drop(st.id);
1065
+ // what its page was asked goes unanswered, and what it asked is withdrawn
1066
+ this.asks.closed(st.token);
1067
+ st.res.destroy();
1068
+ }
1069
+ }
1070
+ /** The importable modules nothing else importable imports: importing them imports the rest. */
1071
+ function tops(ok, mods) {
1072
+ const okSet = new Set(ok);
1073
+ const imported = new Set();
1074
+ for (const m of ok)
1075
+ for (const i of mods.get(m)?.imports ?? [])
1076
+ if (okSet.has(i))
1077
+ imported.add(i);
1078
+ return ok.filter((m) => !imported.has(m));
1079
+ }
1080
+ /** What the pages are told of the lost-and-found (`lost`). */
1081
+ const lostNews = (s) => ({ rev: s.rev, count: s.count, lost: s.lost });
1082
+ /** What a move would carry of the review: opinions on it, its target mark, key marks on it (as a
1083
+ * declaration of a target's review, and as a target), the links on it (AttachmentSpec decision 6). */
1084
+ export function carriedOf(d, name) {
1085
+ const own = (o, k) => Object.hasOwn(o, k);
1086
+ const keys = Object.values(d.keys).filter((ks) => own(ks, name)).length + (own(d.keys, name) ? Object.keys(d.keys[name]).length : 0);
1087
+ const links = (d.links ?? []).filter((l) => l.subject === `decl(${name})`).length;
1088
+ return { opinions: own(d.opinions, name) ? d.opinions[name].length : 0, targets: own(d.targets, name) ? 1 : 0, keys, links };
1089
+ }
1090
+ /** What a move carried of the views, counted from what it did to them. */
1091
+ function viewsCarried(views) {
1092
+ let notes = 0, pins = 0, scopes = 0;
1093
+ for (const v of views) {
1094
+ // a note counts once, whether its text named the declaration or it was attached to it, or both
1095
+ notes += new Set(v.ops.filter((o) => o.op === "note-edit" || o.op === "pnote-edit" || o.op === "note-decl" || o.op === "pnote-decl").map((o) => o.id)).size;
1096
+ pins += v.ops.filter((o) => o.op === "pin").length;
1097
+ if (v.scope)
1098
+ scopes++;
1099
+ }
1100
+ return { notes, pins, scopes, reviewViews: 0 };
1101
+ }