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.
- package/LICENSE +202 -0
- package/README.md +99 -2
- package/dist/assets/fonts/JuliaMono-Regular.ttf +0 -0
- package/dist/assets/fonts/JuliaMono-RegularItalic.ttf +0 -0
- package/dist/assets/fonts/JuliaMono-SemiBold.ttf +0 -0
- package/dist/assets/fonts/LICENSE-JuliaMono.txt +93 -0
- package/dist/assets/latex/visualproof.sty +308 -0
- package/dist/extractor/Extract.lean +1180 -0
- package/dist/extractor/Usage.lean +900 -0
- package/dist/mcp/visualproof-mcp.mjs +476 -0
- package/dist/server/access.js +95 -0
- package/dist/server/agent.js +3235 -0
- package/dist/server/agentdocs.js +35 -0
- package/dist/server/agentlibrary.js +202 -0
- package/dist/server/agentnotes.js +701 -0
- package/dist/server/agentviews.js +703 -0
- package/dist/server/askfollow.js +286 -0
- package/dist/server/boardspots.js +159 -0
- package/dist/server/dirs.js +175 -0
- package/dist/server/doctor.js +186 -0
- package/dist/server/events.js +198 -0
- package/dist/server/export.js +420 -0
- package/dist/server/extract.js +204 -0
- package/dist/server/fetchurl.js +321 -0
- package/dist/server/figures.js +158 -0
- package/dist/server/history.js +741 -0
- package/dist/server/historyapi.js +304 -0
- package/dist/server/hover.js +595 -0
- package/dist/server/identity.js +114 -0
- package/dist/server/invites.js +241 -0
- package/dist/server/layoutthread.js +51 -0
- package/dist/server/library.js +600 -0
- package/dist/server/libraryroute.js +211 -0
- package/dist/server/live.js +1101 -0
- package/dist/server/lostfound.js +199 -0
- package/dist/server/main.js +1394 -0
- package/dist/server/mcptools.js +497 -0
- package/dist/server/pastdoc.js +73 -0
- package/dist/server/pipeline.js +156 -0
- package/dist/server/poppler.js +251 -0
- package/dist/server/presence.js +252 -0
- package/dist/server/project.js +222 -0
- package/dist/server/prompt.js +53 -0
- package/dist/server/readonly.js +152 -0
- package/dist/server/readonlygate.js +123 -0
- package/dist/server/renames.js +268 -0
- package/dist/server/review.js +334 -0
- package/dist/server/reviewapi.js +188 -0
- package/dist/server/snapshot.js +49 -0
- package/dist/server/sourcestore.js +473 -0
- package/dist/server/status.js +360 -0
- package/dist/server/talk.js +476 -0
- package/dist/server/tokens.js +221 -0
- package/dist/server/usage.js +501 -0
- package/dist/server/versions.js +82 -0
- package/dist/server/view.js +157 -0
- package/dist/server/views.js +1167 -0
- package/dist/shared/askfollow.js +46 -0
- package/dist/shared/board.js +332 -0
- package/dist/shared/build.js +2 -0
- package/dist/shared/collab.js +148 -0
- package/dist/shared/comment.js +285 -0
- package/dist/shared/context.js +157 -0
- package/dist/shared/doc/authors.js +98 -0
- package/dist/shared/doc/build.js +358 -0
- package/dist/shared/doc/latex.js +455 -0
- package/dist/shared/doc/markdown.js +159 -0
- package/dist/shared/doc/model.js +37 -0
- package/dist/shared/doc/order.js +107 -0
- package/dist/shared/doc/proof.js +272 -0
- package/dist/shared/doc/sections.js +93 -0
- package/dist/shared/doc/settings.js +75 -0
- package/dist/shared/edgecodec.js +102 -0
- package/dist/shared/editor.js +64 -0
- package/dist/shared/excerpt.js +87 -0
- package/dist/shared/graph.js +16 -0
- package/dist/shared/items.js +92 -0
- package/dist/shared/keys.js +203 -0
- package/dist/shared/layout.js +926 -0
- package/dist/shared/lexer.js +130 -0
- package/dist/shared/library.js +711 -0
- package/dist/shared/lostfound.js +150 -0
- package/dist/shared/moves.js +243 -0
- package/dist/shared/notecard.js +295 -0
- package/dist/shared/noteplace.js +505 -0
- package/dist/shared/palette.js +46 -0
- package/dist/shared/pastgraph.js +95 -0
- package/dist/shared/places.js +80 -0
- package/dist/shared/projectnotes.js +244 -0
- package/dist/shared/query.js +810 -0
- package/dist/shared/review.js +642 -0
- package/dist/shared/reviewqueue.js +333 -0
- package/dist/shared/ring.js +366 -0
- package/dist/shared/scope.js +255 -0
- package/dist/shared/scopeedit.js +81 -0
- package/dist/shared/search.js +191 -0
- package/dist/shared/separate.js +178 -0
- package/dist/shared/sharesnap.js +280 -0
- package/dist/shared/sticky.js +501 -0
- package/dist/shared/timeline.js +73 -0
- package/dist/shared/titles.js +298 -0
- package/dist/shared/viewgraph.js +139 -0
- package/dist/shared/views.js +859 -0
- package/dist/web/bundle/app.js +48733 -0
- package/dist/web/bundle/edges.js +409 -0
- package/dist/web/bundle/layout.js +1010 -0
- package/dist/web/bundle/pdfworker.js +48322 -0
- package/dist/web/index.html +239 -0
- package/dist/web/src/motion.js +430 -0
- package/dist/web/src/past.js +224 -0
- package/dist/web/src/scene.js +787 -0
- package/dist/web/style.css +2312 -0
- package/docs/agent-api/README.md +91 -0
- package/docs/agent-api/advice.md +108 -0
- package/docs/agent-api/export.md +120 -0
- package/docs/agent-api/following.md +140 -0
- package/docs/agent-api/history.md +142 -0
- package/docs/agent-api/library.md +65 -0
- package/docs/agent-api/mcp.md +104 -0
- package/docs/agent-api/notes.md +93 -0
- package/docs/agent-api/reading.md +166 -0
- package/docs/agent-api/requests.md +94 -0
- package/docs/agent-api/talking.md +100 -0
- package/docs/agent-api/tcb.md +135 -0
- package/docs/agent-api/views.md +124 -0
- package/docs/agent-api/words.md +56 -0
- package/package.json +39 -4
- 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
|
+
}
|