papilot 0.8.6 → 0.8.7

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 (44) hide show
  1. package/dist/assets/index-BIc9Mnim.css +1 -0
  2. package/dist/assets/index-MvQvvv3I.js +267 -0
  3. package/dist/assets/{math-C7ENJr-a.js → math-CBzEH7Y2.js} +1 -1
  4. package/dist/assets/{pdf-view-B2xQuHmI.js → pdf-view-ByftRBhD.js} +2 -2
  5. package/dist/index.html +2 -2
  6. package/engine/dist/web/assets/{FullPaper-DlgZ1MOz.js → FullPaper-DWwiT43p.js} +2 -2
  7. package/engine/dist/web/assets/{index-BCgVChtV.js → index-DBYJK8Uy.js} +4 -4
  8. package/engine/dist/web/assets/{index-D3_ZFdT8.css → index-DGkq8xQx.css} +1 -1
  9. package/engine/dist/web/index.html +2 -2
  10. package/engine/src/cli.ts +3 -0
  11. package/engine/src/client.ts +24 -7
  12. package/engine/src/core/build.ts +357 -93
  13. package/engine/src/core/changes.ts +39 -10
  14. package/engine/src/core/config.ts +2 -2
  15. package/engine/src/core/current-commit.ts +34 -34
  16. package/engine/src/core/delivery.ts +83 -30
  17. package/engine/src/core/files.ts +19 -0
  18. package/engine/src/core/git.ts +283 -92
  19. package/engine/src/core/process.ts +124 -11
  20. package/engine/src/core/publish.ts +59 -50
  21. package/engine/src/core/reconcile.ts +23 -21
  22. package/engine/src/core/review.ts +43 -24
  23. package/engine/src/core/tasks.ts +85 -28
  24. package/engine/src/core/view.ts +8 -8
  25. package/engine/src/server.ts +62 -6
  26. package/engine/src/types.ts +8 -0
  27. package/engine/src/web/FullPaper.tsx +122 -34
  28. package/engine/src/web/review.tsx +4 -1
  29. package/engine/src/web/style.css +24 -0
  30. package/package.json +1 -1
  31. package/src/server/agents.ts +3 -0
  32. package/src/server/annotated-pdf.ts +392 -0
  33. package/src/server/annotation-placement.ts +18 -3
  34. package/src/server/app.ts +68 -10
  35. package/src/server/changes.ts +12 -7
  36. package/src/server/cursor.ts +1 -0
  37. package/src/server/manuscript.ts +218 -56
  38. package/src/server/pdf.ts +176 -23
  39. package/src/server/references.ts +10 -2
  40. package/src/server/review.ts +6 -2
  41. package/src/server/start.ts +4 -1
  42. package/src/server/workspace.ts +18 -0
  43. package/dist/assets/index-Dm7AYM9e.js +0 -267
  44. package/dist/assets/index-PFQDdu53.css +0 -1
package/src/server/pdf.ts CHANGED
@@ -1,27 +1,180 @@
1
+ import fs from 'node:fs';
1
2
  import path from 'node:path';
2
3
  import { pathToFileURL } from 'node:url';
3
4
  import { ENGINE } from './paths.ts';
4
- import { reviewClient, taskRoute } from './review.ts';
5
-
6
- const pending = new Map<string, Promise<string>>();
7
-
8
- export async function fullPaperPdf(root: string, name: string, round: number, side: 'before' | 'after'): Promise<string> {
9
- const key = JSON.stringify([root, name, round, side]);
10
- const existing = pending.get(key);
11
- if (existing) return existing;
12
- const build = (async () => {
13
- const client = await reviewClient(root);
14
- const detail = await client.get<{ before: string; after: string }>(`${taskRoute(name)}/rounds/${round}`);
15
- const [{ Builder }, { Repo }, { loadProject }] = await Promise.all([
16
- import(pathToFileURL(path.join(ENGINE, 'src/core/build.ts')).href),
17
- import(pathToFileURL(path.join(ENGINE, 'src/core/git.ts')).href),
18
- import(pathToFileURL(path.join(ENGINE, 'src/core/config.ts')).href),
19
- ]);
20
- // A diff can use a partial build; explicitly compile the entire immutable
21
- // round revision here. The engine reuses its normal full-build cache.
22
- const result = await new Builder(loadProject(root), new Repo(root)).build(detail[side], ['full']);
23
- return result.pdf as string;
24
- })();
25
- pending.set(key, build);
26
- try { return await build; } finally { if (pending.get(key) === build) pending.delete(key); }
5
+ import { taskRoute } from './review.ts';
6
+
7
+ type Connect = (root: string) => Promise<{ get<T>(route: string): Promise<T> }>;
8
+
9
+ type Priority = 'foreground' | 'normal' | 'background';
10
+ const RANK: Record<Priority, number> = { background: 0, normal: 1, foreground: 2 };
11
+
12
+ // The complete PDF of one version of a paper, for the review's full paper. A
13
+ // round may compile only the changed chapters; this compiles them all, once.
14
+ // It can start before anyone asks, as soon as a round's new version exists;
15
+ // whoever then waits for it has it compile first and sees each TeX pass.
16
+ interface Paper {
17
+ priority: Priority;
18
+ following: boolean;
19
+ // The passes shown so far, by number, while the PDF compiled.
20
+ passes: Map<number, string>;
21
+ pdf: string | null;
22
+ failure: Error | null;
23
+ done: Promise<void>;
24
+ changed: Set<() => void>;
25
+ }
26
+ const papers = new Map<string, Paper>();
27
+ // Compiled papers are remembered for the passes they showed, a few at a time.
28
+ const REMEMBERED = 32;
29
+
30
+ async function builder(root: string) {
31
+ const [{ Builder }, { Repo }, { loadProject }] = await Promise.all([
32
+ import(pathToFileURL(path.join(ENGINE, 'src/core/build.ts')).href),
33
+ import(pathToFileURL(path.join(ENGINE, 'src/core/git.ts')).href),
34
+ import(pathToFileURL(path.join(ENGINE, 'src/core/config.ts')).href),
35
+ ]);
36
+ return new Builder(loadProject(root), new Repo(root));
37
+ }
38
+
39
+ const notFound = () => Object.assign(new Error('Review resource not found.'), { statusCode: 404 });
40
+ const changed = (paper: Paper) => { for (const notify of [...paper.changed]) notify(); };
41
+
42
+ /** Starts compiling the complete PDF of `revision`, or has the build already
43
+ * under way run at `priority` and, with `follow`, show its passes. */
44
+ export function preparePaper(root: string, revision: string, priority: Priority = 'normal', follow = false): Paper {
45
+ const key = JSON.stringify([root, revision]);
46
+ let paper = papers.get(key);
47
+ const compile = (target: Paper, wanted: Priority, watch: boolean) => builder(root).then(b => b.build(revision, ['full'], null, {
48
+ priority: wanted,
49
+ ...(watch ? {
50
+ pass: (preview: { pass: number; pdf: string }) => {
51
+ if (target.pdf || target.passes.has(preview.pass)) return;
52
+ target.passes.set(preview.pass, preview.pdf);
53
+ changed(target);
54
+ },
55
+ } : {}),
56
+ })) as Promise<{ pdf: string }>;
57
+ if (paper) {
58
+ papers.delete(key);
59
+ papers.set(key, paper);
60
+ if (!paper.pdf && !paper.failure && (RANK[priority] > RANK[paper.priority] || (follow && !paper.following))) {
61
+ if (RANK[priority] > RANK[paper.priority]) paper.priority = priority;
62
+ paper.following ||= follow;
63
+ void compile(paper, paper.priority, follow).catch(() => undefined);
64
+ }
65
+ return paper;
66
+ }
67
+ const created: Paper = { priority, following: follow, passes: new Map(), pdf: null, failure: null, done: Promise.resolve(), changed: new Set() };
68
+ created.done = compile(created, priority, follow).then(
69
+ result => { created.pdf = result.pdf; },
70
+ error => {
71
+ created.failure = error instanceof Error ? error : new Error(String(error));
72
+ // A later request compiles it again.
73
+ if (papers.get(key) === created) papers.delete(key);
74
+ },
75
+ ).finally(() => changed(created));
76
+ papers.set(key, created);
77
+ for (const old of papers.keys()) {
78
+ if (papers.size <= REMEMBERED) break;
79
+ if (papers.get(old)!.pdf) papers.delete(old);
80
+ }
81
+ return created;
82
+ }
83
+
84
+ // A round does not change; what the full paper needs of it is looked up once.
85
+ interface RoundInfo { before: string; after: string; build?: { key: string; targets: string[] } }
86
+ const rounds = new Map<string, Promise<RoundInfo>>();
87
+ function roundInfo(connect: Connect, root: string, name: string, round: number) {
88
+ const key = JSON.stringify([root, name, round]);
89
+ let found = rounds.get(key);
90
+ if (!found) {
91
+ found = connect(root)
92
+ .then(client => client.get<RoundInfo>(`${taskRoute(name)}/rounds/${round}`))
93
+ .then(detail => {
94
+ if (typeof detail?.before !== 'string' || typeof detail.after !== 'string') throw notFound();
95
+ return { before: detail.before, after: detail.after, ...(detail.build ? { build: detail.build } : {}) };
96
+ });
97
+ rounds.set(key, found);
98
+ found.catch(() => rounds.delete(key));
99
+ if (rounds.size > 256) rounds.delete(rounds.keys().next().value!);
100
+ }
101
+ return found;
102
+ }
103
+
104
+ // The round's own PDF when it compiled only the changed chapters: their pages
105
+ // with the changes, exactly where its strips come from, at once.
106
+ async function chaptersPdf(root: string, info: RoundInfo) {
107
+ if (!info.build || !/^[0-9a-f]{32}$/.test(info.build.key) || JSON.stringify(info.build.targets) === '["full"]') return null;
108
+ const { loadProject } = await import(pathToFileURL(path.join(ENGINE, 'src/core/config.ts')).href);
109
+ try {
110
+ const result = JSON.parse(await fs.promises.readFile(path.join(loadProject(root).state, 'cache/builds', info.build.key, 'result.json'), 'utf8')) as { pdf?: unknown };
111
+ if (typeof result.pdf !== 'string') return null;
112
+ await fs.promises.access(result.pdf);
113
+ return result.pdf;
114
+ } catch { return null; }
27
115
  }
116
+
117
+ async function ready(root: string, revision: string, follow = false) {
118
+ for (let attempt = 0; ; attempt++) {
119
+ const paper = preparePaper(root, revision, 'foreground', follow);
120
+ await paper.done;
121
+ if (paper.failure) throw paper.failure;
122
+ // The build cache may have removed an old PDF meanwhile.
123
+ if (attempt || await fs.promises.access(paper.pdf!).then(() => true, () => false)) return paper.pdf!;
124
+ papers.delete(JSON.stringify([root, revision]));
125
+ }
126
+ }
127
+
128
+ /** The complete PDF of a round's version, once it has compiled. */
129
+ export async function fullPaperPdf(connect: Connect, root: string, name: string, round: number, side: 'before' | 'after'): Promise<string> {
130
+ return ready(root, (await roundInfo(connect, root, name, round))[side]);
131
+ }
132
+
133
+ /** What the review's full paper shows next, after version `after`: a later
134
+ * version, `null` once the complete PDF is ready, or nothing when `wait`
135
+ * ends first. Until then it shows the round's changed chapters (version 1
136
+ * when the round compiled only those), then each TeX pass of the whole. */
137
+ export async function nextPaperPass(connect: Connect, root: string, name: string, round: number, after: number, wait = 20000): Promise<{ pass?: number | null }> {
138
+ const info = await roundInfo(connect, root, name, round), chapters = await chaptersPdf(root, info), offset = chapters ? 1 : 0;
139
+ if (chapters && after < 1) {
140
+ // A complete PDF that is already there, or nearly, comes first.
141
+ const paper = preparePaper(root, info.after, 'foreground', true);
142
+ await Promise.race([paper.done, new Promise(resolve => setTimeout(resolve, 300))]);
143
+ if (paper.pdf) return { pass: null };
144
+ return { pass: 1 };
145
+ }
146
+ const next = await nextPass(root, info.after, Math.max(0, after - offset), wait);
147
+ return typeof next.pass === 'number' ? { pass: next.pass + offset } : next;
148
+ }
149
+ export async function nextPass(root: string, revision: string, after: number, wait = 20000): Promise<{ pass?: number | null }> {
150
+ const paper = preparePaper(root, revision, 'foreground', true);
151
+ const deadline = Date.now() + wait;
152
+ for (;;) {
153
+ if (paper.failure) throw paper.failure;
154
+ if (paper.pdf) return { pass: null };
155
+ const newest = Math.max(0, ...paper.passes.keys());
156
+ if (newest > after) return { pass: newest };
157
+ const left = deadline - Date.now();
158
+ if (left <= 0) return {};
159
+ await new Promise<void>(resolve => {
160
+ const done = () => { clearTimeout(timer); paper.changed.delete(done); resolve(); };
161
+ const timer = setTimeout(done, left);
162
+ paper.changed.add(done);
163
+ });
164
+ }
165
+ }
166
+
167
+ /** The PDF of a version that `nextPaperPass` named. */
168
+ export async function paperPassPdf(connect: Connect, root: string, name: string, round: number, pass: number) {
169
+ const info = await roundInfo(connect, root, name, round), chapters = await chaptersPdf(root, info);
170
+ if (chapters && pass === 1) return chapters;
171
+ return passPdf(root, info.after, pass - (chapters ? 1 : 0));
172
+ }
173
+ export function passPdf(root: string, revision: string, pass: number) {
174
+ const pdf = papers.get(JSON.stringify([root, revision]))?.passes.get(pass);
175
+ if (!pdf) throw notFound();
176
+ return pdf;
177
+ }
178
+
179
+ /** The complete PDF of `revision`, once it has compiled. */
180
+ export const paperPdf = ready;
@@ -20,7 +20,7 @@ interface Engine {
20
20
  entryInputs(entries: unknown[]): Input[];
21
21
  ReferenceChecker: new (file: string) => Checker;
22
22
  Confirmations: new (file: string) => Confirmations;
23
- Repo: new (root: string) => { gitDir(): string };
23
+ Repo: new (root: string) => { gitDir(): string; textAsync(args: string[]): Promise<string> };
24
24
  }
25
25
  let loading: Promise<Engine> | undefined;
26
26
  export function engine(): Promise<Engine> {
@@ -38,9 +38,17 @@ export async function checker(file = path.join(DATA, 'references.json')) {
38
38
  }
39
39
  // Confirmations live beside the review engine's state in the repository, so
40
40
  // a task's review, the uncommitted changes and this panel agree.
41
+ const gitDirs = new Map<string, Promise<string>>();
41
42
  export async function confirmations(root: string) {
42
43
  const { Confirmations, Repo } = await engine();
43
- return new Confirmations(path.join(new Repo(root).gitDir(), 'latex-review', 'references-confirmed.json'));
44
+ // The panel polls while a check runs; the Git directory is looked up once.
45
+ let dir = gitDirs.get(root);
46
+ if (!dir) {
47
+ const found = new Repo(root).textAsync(['rev-parse', '--absolute-git-dir']).then(folder => fs.promises.realpath(folder));
48
+ found.catch(() => gitDirs.delete(root));
49
+ gitDirs.set(root, dir = found);
50
+ }
51
+ return new Confirmations(path.join(await dir, 'latex-review', 'references-confirmed.json'));
44
52
  }
45
53
 
46
54
  // A file of the paper, read only from inside its folder.
@@ -4,11 +4,15 @@ import { ENGINE } from './paths.ts';
4
4
  import type { Task } from '../shared.ts';
5
5
 
6
6
  export interface ReviewClient { url: string; token: string; get<T = any>(route: string): Promise<T>; post<T = any>(route: string, body?: unknown, timeout?: number): Promise<T> }
7
- let modulePromise: Promise<{ connect: (root: string, start?: boolean) => Promise<ReviewClient | null> }> | null = null;
7
+ let modulePromise: Promise<{ connect: (root: string, start?: boolean, options?: { owned?: boolean }) => Promise<ReviewClient | null> }> | null = null;
8
+ // The desktop app owns the review servers it starts, so they stop with it,
9
+ // even after a crash. The web server leaves them running to finish builds
10
+ // across a restart.
11
+ const owned = process.env.PAPILOT_DESKTOP === '1';
8
12
  export function reviewClient(root: string, reuse = false): Promise<ReviewClient> {
9
13
  modulePromise ??= import(pathToFileURL(path.join(ENGINE, 'src/client.ts')).href);
10
14
  // Polling an existing job must not replace a healthy server of another version.
11
- return modulePromise!.then(async module => (reuse && await module.connect(root, false)) || (await module.connect(root))!);
15
+ return modulePromise!.then(async module => (reuse && await module.connect(root, false)) || (await module.connect(root, true, { owned }))!);
12
16
  }
13
17
  export const taskRoute = (name: string) => `/api/tasks/${encodeURIComponent(name)}`;
14
18
  export function reviewConnectionError(error: unknown): boolean {
@@ -39,6 +39,9 @@ const localListener = { kind: 'local' as const, port: PORT, publicOrigin: PUBLIC
39
39
  const apps = [await buildApp({ ...shared, listener: localListener })];
40
40
  if (ACCOUNT_ORIGIN) apps.push(await buildApp({ ...shared, listener: { kind: 'account', origin: ACCOUNT_ORIGIN, hsts: accountHsts(ACCOUNT_ORIGIN, PUBLIC_ORIGIN) } }));
41
41
 
42
+ // Agent SDKs and the PDF engine load before the first request, not in the
43
+ // middle of one.
44
+ if (!process.env.PAPILOT_UPDATE_PROBE) await Promise.race([Promise.all([workspace.preload(), manuscripts.warm()]), new Promise(resolve => setTimeout(resolve, 5000).unref())]);
42
45
  const localUrl = await apps[0].listen({ host: '127.0.0.1', port: PORT }).catch(error => {
43
46
  if (!desktop || error.code !== 'EADDRINUSE') throw error;
44
47
  return apps[0].listen({ host: '127.0.0.1', port: 0 });
@@ -62,7 +65,7 @@ for (const task of store.tasks()) {
62
65
  setInterval(() => accounts.purge(), 3600000).unref();
63
66
  let shuttingDown = false;
64
67
  function shutdown() {
65
- if (shuttingDown) return; shuttingDown = true; updates.close(); workspace.close();
68
+ if (shuttingDown) return; shuttingDown = true; updates.close(); workspace.close(); void manuscripts.stop().catch(() => undefined);
66
69
  void Promise.all(apps.map(app => app.close())).finally(() => { store.close(); process.exit(0); });
67
70
  setTimeout(() => process.exit(0), 2500).unref();
68
71
  }
@@ -161,6 +161,16 @@ export class Workspace extends EventEmitter {
161
161
  this.emit('change');
162
162
  }
163
163
  snapshot(): Snapshot { return { version: VERSION, projects: this.store.projects(), tasks: this.store.tasks(), requests: [...this.requests.values()].map(({ rpcId: _, agent: __, ...r }) => r), runtime: this.runtime, runtimes: this.runtimes }; }
164
+ /** Loads the SDKs of the agents this host has before the server answers
165
+ * requests. Evaluating one blocks for up to a few hundred milliseconds,
166
+ * which would otherwise hold up whatever the app asked for while the
167
+ * agents connect. SDKs not installed yet are fetched when first used. */
168
+ async preload() {
169
+ await Promise.all(AGENTS.map(async id => {
170
+ const agent = this.agents[id];
171
+ try { if (agent.preload && (!agent.detect || agent.detect())) await agent.preload(); } catch { /* Connecting reports it. */ }
172
+ }));
173
+ }
164
174
  async connect() {
165
175
  if (this.connecting) return this.connecting;
166
176
  this.connecting = Promise.all(AGENTS.map(id => this.connectAgent(id))).then(() => { this.emit('change'); this.schedule(); }).finally(() => { this.connecting = null; });
@@ -780,6 +790,7 @@ ${this.agentOf(task) === 'antigravity' ? 'Shell commands are not available to yo
780
790
  let pending = this.store.task(taskId).pendingBuild!;
781
791
  let client: ReviewClient | undefined;
782
792
  let reuse = resume;
793
+ let compiling: string | undefined;
783
794
  // Only connection failures are retried. Every submission has a persisted key,
784
795
  // so losing its response cannot submit a second build.
785
796
  const request = async <T>(work: (client: ReviewClient) => Promise<T>, submitting = false): Promise<T> => {
@@ -810,6 +821,11 @@ ${this.agentOf(task) === 'antigravity' ? 'Shell commands are not available to yo
810
821
  this.store.updateTask(taskId, { pendingBuild: pending });
811
822
  }
812
823
  const current = await request(client => client.get(`/api/jobs/${pending.job}`));
824
+ // The round's new version exists; it compiles only some chapters here.
825
+ if (typeof current.revision === 'string' && current.revision !== compiling) {
826
+ compiling = current.revision;
827
+ this.emit('publishing', taskId, compiling);
828
+ }
813
829
  const summary = await request(client => client.get(taskRoute(task.name)));
814
830
  this.store.updateTask(taskId, { review: { ...this.store.task(taskId).review, state: summary.state, round: summary.round, counts: summary.counts, job: { ...current, id: pending.job } } });
815
831
  if (current.status === 'done' || current.status === 'completed' || current.status === 'succeeded') {
@@ -838,6 +854,8 @@ ${this.agentOf(task) === 'antigravity' ? 'Shell commands are not available to yo
838
854
  await delay(1500);
839
855
  }
840
856
  await this.refresh(taskId);
857
+ // What applying the new round will write can now be compiled ahead.
858
+ this.emit('reviewed', taskId);
841
859
  } catch (e) {
842
860
  if (this.closing) return;
843
861
  if (reviewConnectionError(e)) {