papilot 0.8.5 → 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 (46) 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-DHvVou9A.js → math-CBzEH7Y2.js} +1 -1
  4. package/dist/assets/{pdf-view-AYTS7kdA.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 +367 -92
  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/update-install.ts +5 -1
  43. package/src/server/updates.ts +24 -49
  44. package/src/server/workspace.ts +18 -0
  45. package/dist/assets/index-DSWizoaI.js +0 -267
  46. 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
  }
@@ -106,7 +106,10 @@ async function installDev(data: string, version: string) {
106
106
  const INSTALL_FAILED = 'The new version could not be installed. Check the connection and available disk space, then retry.';
107
107
  // Release assets redirect from github.com to GitHub's download hosts. Each hop
108
108
  // stays on HTTPS and on those hosts (or on the smoke test's local source).
109
- export async function download(url: string, fetcher: typeof fetch, limit: number) {
109
+ // With missing, a file that does not exist resolves to null.
110
+ export async function download(url: string, fetcher: typeof fetch, limit: number, missing: true): Promise<Buffer | null>;
111
+ export async function download(url: string, fetcher: typeof fetch, limit: number): Promise<Buffer>;
112
+ export async function download(url: string, fetcher: typeof fetch, limit: number, missing = false): Promise<Buffer | null> {
110
113
  const source = process.env.PAPILOT_DESKTOP_UPDATE_SOURCE ? new URL(process.env.PAPILOT_DESKTOP_UPDATE_SOURCE).origin : null;
111
114
  const trusted = (address: URL) => address.origin === source
112
115
  || address.protocol === 'https:' && (address.hostname === 'github.com' || address.hostname.endsWith('.githubusercontent.com'));
@@ -116,6 +119,7 @@ export async function download(url: string, fetcher: typeof fetch, limit: number
116
119
  const response = await fetcher(address.href, { signal: AbortSignal.timeout(300000), redirect: 'manual', headers: { Accept: 'application/octet-stream', 'User-Agent': 'Papilot' } });
117
120
  const location = response.headers.get('location');
118
121
  if (response.status >= 300 && response.status < 400 && location) { address = new URL(location, address); continue; }
122
+ if (missing && response.status === 404) return null;
119
123
  if (!response.ok || Number(response.headers.get('content-length') || 0) > limit) throw new Error(INSTALL_FAILED);
120
124
  const bytes = Buffer.from(await response.arrayBuffer());
121
125
  if (bytes.length > limit) throw new Error(INSTALL_FAILED);
@@ -8,9 +8,8 @@ import type { UpdateChannel, UpdateStatus } from '../shared.ts';
8
8
 
9
9
  const activePhases = new Set(['installing', 'waiting', 'restarting']);
10
10
  export const alive = (pid: number) => { try { process.kill(pid, 0); return true; } catch { return false; } };
11
- async function releaseInfo(url: string, fetcher: typeof fetch, limit = 256000, missing?: null) {
11
+ async function releaseInfo(url: string, fetcher: typeof fetch, limit = 256000) {
12
12
  const response = await fetcher(url, { signal: AbortSignal.timeout(10000), redirect: 'error', headers: { Accept: 'application/json', 'User-Agent': 'Papilot' } });
13
- if (response.status === 404 && missing === null) return null;
14
13
  if (!response.ok) throw new Error('Could not check for updates.');
15
14
  const text = await response.text();
16
15
  if (text.length > limit) throw new Error('Invalid release information.');
@@ -34,53 +33,29 @@ export const latestRelease = async (channel: UpdateChannel = 'stable', fetcher =
34
33
  export const DESKTOP_RELEASES = 'https://github.com/PapilotAI/papilot/releases';
35
34
  // Nightly builds of main replace the assets of this one prerelease.
36
35
  export const DESKTOP_DEV_TAG = 'desktop-dev';
37
- const DESKTOP_API = 'https://api.github.com/repos/PapilotAI/papilot';
38
- // The smoke test serves releases locally.
39
- const desktopApi = () => process.env.PAPILOT_DESKTOP_UPDATE_SOURCE ? process.env.PAPILOT_DESKTOP_UPDATE_SOURCE.replace(/\/+$/, '') + '/repos/PapilotAI/papilot' : DESKTOP_API;
36
+ // Release downloads, not the rate-limited REST API. The smoke test serves
37
+ // them locally.
38
+ const desktopDownloads = () => (process.env.PAPILOT_DESKTOP_UPDATE_SOURCE ? process.env.PAPILOT_DESKTOP_UPDATE_SOURCE.replace(/\/+$/, '') + '/PapilotAI/papilot/releases' : DESKTOP_RELEASES);
40
39
  export interface DesktopRelease { version: string; downloadUrl: string; layer?: DesktopLayer }
41
- const asset = (release: any, name: unknown) => release.assets.find((asset: any) => asset.name === name && asset.state === 'uploaded' && asset.size > 0 && typeof asset.browser_download_url === 'string');
42
- // desktop.json (written by scripts/desktop-layer.mjs) names the release's
43
- // version, its update layer and the installer shell that layer runs on.
44
- async function desktopManifest(release: any, fetcher: typeof fetch) {
45
- const listed = asset(release, 'desktop.json');
46
- if (!listed) return null;
47
- try { return JSON.parse((await download(listed.browser_download_url, fetcher, 64 * 1024)).toString('utf8')); }
48
- catch { throw new Error('Could not check for updates.'); }
49
- }
50
- function desktopLayer(release: any, manifest: any, version: string, fingerprint: string): DesktopLayer | undefined {
51
- const layer = manifest?.layer, file = layer && asset(release, layer.name);
52
- if (!fingerprint || manifest?.format !== 1 || manifest.version !== version || manifest.fingerprint !== fingerprint || !file || file.size !== layer.size || !/^[a-f0-9]{64}$/.test(layer.sha256)) return undefined;
53
- return { url: file.browser_download_url, size: layer.size, sha256: layer.sha256 };
54
- }
55
- // Stable: npm can be published before desktop builds finish. Only advertise
56
- // releases with an uploaded installer for this OS and CPU, and link to that
57
- // exact release. Dev: the latest nightly build of main. Either includes the update layer
58
- // when it was built for this installer's shell (fingerprint).
59
- export async function latestDesktopRelease(platform = process.platform, arch = process.arch, fetcher = fetch, channel: UpdateChannel = 'stable', fingerprint = ''): Promise<DesktopRelease | null> {
60
- if (channel === 'dev') {
61
- const release = await releaseInfo(`${desktopApi()}/releases/tags/${DESKTOP_DEV_TAG}`, fetcher, 4 * 1024 * 1024, null);
62
- if (!release) return null;
63
- if (!Array.isArray(release.assets) || release.draft !== false) throw new Error('Invalid release information.');
64
- const manifest = await desktopManifest(release, fetcher);
65
- if (!manifest) return null;
66
- if (!devRevision(manifest.version)) throw new Error('Invalid release information.');
67
- const layer = desktopLayer(release, manifest, manifest.version, fingerprint);
68
- return { version: manifest.version, downloadUrl: `${DESKTOP_RELEASES}/tag/${DESKTOP_DEV_TAG}`, ...(layer ? { layer } : {}) };
69
- }
70
- const releases = await releaseInfo(`${desktopApi()}/releases?per_page=100`, fetcher, 4 * 1024 * 1024);
71
- if (!Array.isArray(releases)) throw new Error('Invalid release information.');
72
- let latest: { version: string; downloadUrl: string; release: any } | null = null;
73
- for (const release of releases) {
74
- const version = typeof release.tag_name === 'string' ? release.tag_name.replace(/^v/, '') : '';
75
- if (release.draft !== false || release.prerelease !== false || !stableVersion(version) || !Array.isArray(release.assets)) continue;
76
- const names = platform === 'darwin' && ['arm64', 'x64'].includes(arch) ? [`Papilot-${version}-mac-${arch}.dmg`, `Papilot-${version}-mac-${arch}.zip`]
77
- : platform === 'linux' && ['arm64', 'x64'].includes(arch) ? [`Papilot-${version}-linux-${arch === 'x64' ? 'x86_64' : arch}.AppImage`, `Papilot-${version}-linux-${arch === 'x64' ? 'amd64' : arch}.deb`] : [];
78
- if (!release.assets.some((asset: any) => asset.state === 'uploaded' && asset.size > 0 && names.includes(asset.name))) continue;
79
- if (!latest || newerVersion(version, latest.version)) latest = { version, downloadUrl: `${DESKTOP_RELEASES}/tag/${release.tag_name}`, release };
80
- }
81
- if (!latest) return null;
82
- const layer = fingerprint ? desktopLayer(latest.release, await desktopManifest(latest.release, fetcher), latest.version, fingerprint) : undefined;
83
- return { version: latest.version, downloadUrl: latest.downloadUrl, ...(layer ? { layer } : {}) };
40
+ // desktop.json (written by scripts/desktop-layer.mjs, uploaded after every
41
+ // other asset of its release) names the version, its update layer and the
42
+ // installer shell that layer runs on. Stable: the latest release. Dev: the
43
+ // latest nightly build of main. The layer is offered only to that shell.
44
+ export async function latestDesktopRelease(fetcher = fetch, channel: UpdateChannel = 'stable', fingerprint = ''): Promise<DesktopRelease | null> {
45
+ const folder = channel === 'dev' ? `download/${DESKTOP_DEV_TAG}` : 'latest/download';
46
+ let manifest: any;
47
+ try {
48
+ const bytes = await download(`${desktopDownloads()}/${folder}/desktop.json`, fetcher, 64 * 1024, true);
49
+ if (!bytes) return null;
50
+ manifest = JSON.parse(bytes.toString('utf8'));
51
+ } catch { throw new Error('Could not check for updates.'); }
52
+ const version = manifest?.version;
53
+ if (manifest?.format !== 1 || (channel === 'dev' ? !devRevision(version) : !stableVersion(version))) throw new Error('Invalid release information.');
54
+ const tag = channel === 'dev' ? DESKTOP_DEV_TAG : `v${version}`, layer = manifest.layer;
55
+ const usable = fingerprint && manifest.fingerprint === fingerprint && typeof layer?.name === 'string' && /^papilot-app-[\w.-]+\.tgz$/.test(layer.name)
56
+ && Number.isSafeInteger(layer.size) && layer.size > 0 && /^[a-f0-9]{64}$/.test(layer.sha256);
57
+ return { version, downloadUrl: `${DESKTOP_RELEASES}/tag/${tag}`,
58
+ ...(usable ? { layer: { url: `${desktopDownloads()}/download/${tag}/${layer.name}`, size: layer.size, sha256: layer.sha256 } } : {}) };
84
59
  }
85
60
  const validTarget = (version: unknown, channel: UpdateChannel): version is string => channel === 'dev' ? Boolean(devRevision(version)) : stableVersion(version);
86
61
  const available = (next: string, current: string, channel: UpdateChannel) => validTarget(next, channel)
@@ -197,7 +172,7 @@ export class Updates {
197
172
  return this.checking;
198
173
  }
199
174
  private desktopRelease(channel: UpdateChannel) {
200
- return (this.options.latestDesktop || (channel => latestDesktopRelease(process.platform, process.arch, fetch, channel, this.options.bundle?.fingerprint)))(channel);
175
+ return (this.options.latestDesktop || (channel => latestDesktopRelease(fetch, channel, this.options.bundle?.fingerprint)))(channel);
201
176
  }
202
177
  // A retry after a restart finds the release again; the check that offered it
203
178
  // happened in the previous process.
@@ -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)) {