papilot 0.8.4 → 0.8.5

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.
@@ -9,7 +9,7 @@
9
9
  rel="icon"
10
10
  href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Crect width='16' height='16' rx='3' fill='%232b6e5c'/%3E%3Cpath d='M4 8.5l2.5 2.5L12 5' stroke='white' stroke-width='2' fill='none'/%3E%3C/svg%3E"
11
11
  />
12
- <script type="module" crossorigin src="/assets/index-C_KwLmXQ.js"></script>
12
+ <script type="module" crossorigin src="/assets/index-BCgVChtV.js"></script>
13
13
  <link rel="stylesheet" crossorigin href="/assets/index-D3_ZFdT8.css">
14
14
  </head>
15
15
  <body>
@@ -157,6 +157,15 @@ export async function connect(
157
157
  // Match the canonical repository path reported by the server (e.g. macOS /var).
158
158
  root = fs.realpathSync(root);
159
159
  const home = serverHome(root);
160
+ // A reader neither starts nor upgrades the server. Do not wait behind an
161
+ // upgrader holding start.lock while the job we need to inspect is building.
162
+ if (!start) {
163
+ const last = readJson<{ port: number } | null>(path.join(home, "last.json"), null);
164
+ const info = readJson<Connection | null>(path.join(home, "connection.json"), null)
165
+ ?? (last ? { url: `http://127.0.0.1:${last.port}`, port: last.port, pid: 0 } : null);
166
+ const answer = await probe(root, info);
167
+ return answer ? new Client(info!.url, answer.csrf) : null;
168
+ }
160
169
  return withLock(path.join(home, "start.lock"), async () => {
161
170
  const last = readJson<{ port: number } | null>(
162
171
  path.join(home, "last.json"),
@@ -171,12 +180,10 @@ export async function connect(
171
180
  let answer = await probe(root, info);
172
181
  if (
173
182
  answer &&
174
- (!start ||
175
- (answer.version === codeVersion() &&
176
- answer.environment === environmentVersion()))
183
+ answer.version === codeVersion() &&
184
+ answer.environment === environmentVersion()
177
185
  )
178
186
  return new Client(info!.url, answer.csrf);
179
- if (!start) return null;
180
187
  if (answer && info) {
181
188
  const deadline = Date.now() + 900000;
182
189
  while (answer?.busy && Date.now() < deadline) {
@@ -199,8 +199,22 @@ export class Publisher {
199
199
  }),
200
200
  );
201
201
  }
202
- submit(name: string) {
202
+ submit(name: string, requestId?: string) {
203
203
  this.app.tasks.get(name);
204
+ return this.app.store.transaction(() => this.enqueue(name, requestId));
205
+ }
206
+ private enqueue(name: string, requestId?: string) {
207
+ if (requestId) {
208
+ const existing = this.app.store.one<Job>("SELECT jobs.* FROM jobs JOIN build_requests ON jobs.id=build_requests.job WHERE build_requests.id=?", requestId);
209
+ if (existing) {
210
+ if (existing.task !== name) throw new Error("This build request belongs to another task.");
211
+ return existing;
212
+ }
213
+ }
214
+ const remember = (job: Job) => {
215
+ if (requestId) this.app.store.execute("INSERT INTO build_requests(id,task,job) VALUES(?,?,?)", requestId, name, job.id);
216
+ return job;
217
+ };
204
218
  if (this.stopping)
205
219
  throw new Error(
206
220
  "The review server is stopping. Publish again after it restarts.",
@@ -208,7 +222,7 @@ export class Publisher {
208
222
  const current = this.pending.get(name);
209
223
  if (current) {
210
224
  const job = this.job(current);
211
- if (job.status === "queued") return job;
225
+ if (job.status === "queued") return remember(job);
212
226
  }
213
227
  const id = randomBytes(8).toString("hex");
214
228
  this.app.store.execute(
@@ -220,7 +234,7 @@ export class Publisher {
220
234
  this.pending.set(name, id);
221
235
  if (!this.running.has(name)) this.queue.push(id);
222
236
  queueMicrotask(() => this.drain());
223
- return this.job(id);
237
+ return remember(this.job(id));
224
238
  }
225
239
  job(id: string) {
226
240
  const job = this.app.store.one<Job>("SELECT * FROM jobs WHERE id=?", id);
@@ -10,6 +10,7 @@ CREATE TABLE IF NOT EXISTS actions(seq INTEGER PRIMARY KEY AUTOINCREMENT,id TEXT
10
10
  CREATE TABLE IF NOT EXISTS writes(action TEXT NOT NULL,phase TEXT NOT NULL,path TEXT NOT NULL,expected BLOB,target BLOB,PRIMARY KEY(action,phase,path));
11
11
  CREATE TABLE IF NOT EXISTS messages(id INTEGER PRIMARY KEY AUTOINCREMENT,task TEXT NOT NULL,round INTEGER,kind TEXT NOT NULL,action TEXT,body TEXT NOT NULL DEFAULT '',data TEXT NOT NULL DEFAULT '{}',created REAL NOT NULL);
12
12
  CREATE TABLE IF NOT EXISTS jobs(id TEXT PRIMARY KEY,task TEXT NOT NULL,status TEXT NOT NULL,phase TEXT NOT NULL DEFAULT '',result TEXT NOT NULL DEFAULT '{}',created REAL NOT NULL,finished REAL);
13
+ CREATE TABLE IF NOT EXISTS build_requests(id TEXT PRIMARY KEY,task TEXT NOT NULL,job TEXT NOT NULL);
13
14
  CREATE INDEX IF NOT EXISTS actions_by_task ON actions(task,round);
14
15
  CREATE INDEX IF NOT EXISTS messages_by_task ON messages(task,id);
15
16
  CREATE INDEX IF NOT EXISTS jobs_by_task ON jobs(task,created);`;
@@ -331,11 +331,12 @@ export function createServer(app: App) {
331
331
  );
332
332
  },
333
333
  );
334
- server.post<{ Params: { name: string } }>(
334
+ server.post<{ Params: { name: string }; Body: { requestId?: string } }>(
335
335
  "/api/tasks/:name/publish",
336
+ { schema: { body: { ...objectSchema, properties: { requestId: { type: "string", pattern: "^[a-zA-Z0-9-]{1,80}$" } } } } },
336
337
  (request) => {
337
338
  app.delivery.assertReady(request.params.name);
338
- const job = app.publisher.submit(request.params.name);
339
+ const job = app.publisher.submit(request.params.name, request.body?.requestId);
339
340
  return { job: job.id, status: job.status };
340
341
  },
341
342
  );
@@ -171,7 +171,12 @@ export function ReviewApp({
171
171
  const paper = useRef<HTMLElement>(null),
172
172
  epoch = useRef(0),
173
173
  scrollUntil = useRef(0),
174
+ // How far into the current change the author had read, restored once
175
+ // when the review reopens on it in the same view.
176
+ returning = useRef<{ index: number; offset: number; paper: boolean } | null>(null),
174
177
  snapshot = useRef({ state, detail, busy, composer, current });
178
+ // Where each review was left; a host's uncommitted changes are kept per paper.
179
+ const placeKey = `latex-review:${changes ? `changes:${location.pathname}` : task}`;
175
180
  const [shortcuts, setShortcuts] = useState(readShortcuts);
176
181
  // The whole paper with the changes in place, or the changed sections only.
177
182
  // A host may fix the view in the address (Papilot's manuscript chooses its
@@ -339,8 +344,9 @@ export function ReviewApp({
339
344
  }
340
345
  function select(index: number, scroll = true) {
341
346
  if (index < 0 || index >= stops.length) return;
347
+ if (scroll) returning.current = null;
342
348
  setCurrent(index);
343
- store(`latex-review:${task}`, {
349
+ store(placeKey, {
344
350
  round: detail?.round,
345
351
  stop: stops[index].key,
346
352
  });
@@ -355,15 +361,34 @@ export function ReviewApp({
355
361
  });
356
362
  }
357
363
  }
358
- function reveal(article: HTMLElement, behavior: ScrollBehavior = "instant") {
359
- const root = paper.current;
360
- if (!root) return;
364
+ // How far the paper is scrolled past the top of a change; 0 when revealed.
365
+ function past(article: HTMLElement) {
366
+ const root = paper.current!;
361
367
  // scrollIntoView also scrolls ancestors across the embedding iframe.
362
368
  // Navigation belongs to the paper, independently of the host's Chat.
363
369
  const padding = parseFloat(getComputedStyle(root).scrollPaddingTop) || 0;
364
370
  const margin = parseFloat(getComputedStyle(article).scrollMarginTop) || 0;
365
- root.scrollTo({ top: root.scrollTop + article.getBoundingClientRect().top - root.getBoundingClientRect().top - padding - margin, behavior });
371
+ return root.getBoundingClientRect().top + padding + margin - article.getBoundingClientRect().top;
366
372
  }
373
+ function reveal(article: HTMLElement, behavior: ScrollBehavior = "instant") {
374
+ const root = paper.current;
375
+ if (!root) return;
376
+ const index = Number(article.dataset.stop), back = returning.current;
377
+ let offset = 0;
378
+ if (back && back.index === index && back.paper === paperView) { offset = back.offset; returning.current = null; }
379
+ root.scrollTo({ top: root.scrollTop - past(article) + offset, behavior });
380
+ }
381
+ // The place within the current change is kept as the author reads on.
382
+ function remember(index: number) {
383
+ const article = paper.current?.querySelector<HTMLElement>(`[data-stop="${index}"]`);
384
+ if (!article || !stops[index]) return;
385
+ store(placeKey, { round: detail?.round, stop: stops[index].key, offset: Math.round(past(article)), paper: paperView });
386
+ }
387
+ // Reading into the next change makes it current, which resizes its header;
388
+ // measure the place again once it has, unless a reveal is on its way.
389
+ useEffect(() => {
390
+ if (current >= 0 && !returning.current && Date.now() >= scrollUntil.current && paper.current?.scrollTop) remember(current);
391
+ }, [current]);
367
392
  useEffect(() => {
368
393
  let cancelled = false,
369
394
  timer: ReturnType<typeof setTimeout>;
@@ -401,8 +426,8 @@ export function ReviewApp({
401
426
  )
402
427
  return;
403
428
  const nextStops = buildStops(loaded),
404
- saved = readStorage<{ round?: number; stop?: string }>(
405
- `latex-review:${task}`,
429
+ saved = readStorage<{ round?: number; stop?: string; offset?: number; paper?: boolean }>(
430
+ placeKey,
406
431
  {},
407
432
  );
408
433
  let index =
@@ -416,6 +441,10 @@ export function ReviewApp({
416
441
  index = nextPending(nextStops, loadedState.decisions, index);
417
442
  if (linkedChange >= 0 && nextStops[linkedChange]) index = linkedChange;
418
443
  if (index < 0) index = pinned && nextStops.length ? 0 : -1;
444
+ returning.current =
445
+ !previous.detail && index >= 0 && saved.round === target && nextStops[index].key === saved.stop && saved.offset
446
+ ? { index, offset: saved.offset, paper: Boolean(saved.paper) }
447
+ : null;
419
448
  if (
420
449
  previous.detail &&
421
450
  loaded.round !== previous.detail.round &&
@@ -676,6 +705,7 @@ export function ReviewApp({
676
705
  });
677
706
  const index = visibleStop(regions, current, box.top + 60, box.bottom - 100);
678
707
  if (index !== current) select(index, false);
708
+ if (index >= 0) remember(index);
679
709
  }
680
710
  const actionBar = (index: number) => {
681
711
  if (index !== current || !state) return null;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "papilot",
3
- "version": "0.8.4",
3
+ "version": "0.8.5",
4
4
  "description": "A Cursor-like workspace for your papers: annotate and review PDFs directly without ever touching LaTeX.",
5
5
  "keywords": [
6
6
  "latex",
@@ -71,7 +71,7 @@
71
71
  "doctor": "node bin/papilot.mjs doctor",
72
72
  "service:install": "tsx scripts/service.ts install",
73
73
  "service:stop": "tsx scripts/service.ts stop",
74
- "test:ui": "npm run build && node scripts/check-ui.mjs && node scripts/check-task-model.mjs && node scripts/check-appearance.mjs && node scripts/check-updates.mjs && node scripts/check-papers.mjs && node scripts/check-imports.mjs && node scripts/check-compilation.mjs && node scripts/check-build-problems.mjs && node scripts/check-review-notes.mjs && node --import tsx scripts/check-note-groups.mjs && node scripts/check-note-selection.mjs && node scripts/check-ai-review.mjs && node --import tsx scripts/check-note-results.mjs && node --import tsx scripts/check-review-flow.mjs && node scripts/check-skills.mjs && node --import tsx scripts/check-skill-library.mjs && node scripts/check-attachments.mjs && node --import tsx scripts/check-accounts.mjs",
74
+ "test:ui": "npm run build && node scripts/check-ui.mjs && node scripts/check-task-model.mjs && node scripts/check-appearance.mjs && node scripts/check-updates.mjs && node scripts/check-papers.mjs && node scripts/check-imports.mjs && node scripts/check-compilation.mjs && node scripts/check-build-problems.mjs && node scripts/check-review-notes.mjs && node --import tsx scripts/check-note-groups.mjs && node scripts/check-note-selection.mjs && node scripts/check-ai-review.mjs && node --import tsx scripts/check-note-results.mjs && node --import tsx scripts/check-review-flow.mjs && node scripts/check-skills.mjs && node --import tsx scripts/check-skill-library.mjs && node scripts/check-attachments.mjs && node --import tsx scripts/check-accounts.mjs && node --import tsx scripts/check-places.mjs && node scripts/check-pdf-words.mjs",
75
75
  "site": "node scripts/build-site.mjs",
76
76
  "test:site": "npm run site && node scripts/check-site.mjs",
77
77
  "prepack": "npm run build && npm run setup:review",
@@ -126,10 +126,11 @@ export function claudeConfigured(env: NodeJS.ProcessEnv = process.env) {
126
126
  // Agent SDKs carry large native parts, so an installed Papilot fetches one
127
127
  // into the data folder the first time its agent can be used. ready() runs
128
128
  // before the download and throws when the agent is not configured.
129
+ const PACKAGES = path.join(DATA, 'agents');
129
130
  export async function loadPackage(name: string, version: string, label: string, ready: () => void = () => {}): Promise<any> {
130
131
  try { return await import(name); }
131
132
  catch (error) { if ((error as NodeJS.ErrnoException).code !== 'ERR_MODULE_NOT_FOUND') throw error; }
132
- const prefix = path.join(DATA, 'agents'), resolve = () => createRequire(path.join(prefix, 'package.json')).resolve(name);
133
+ const prefix = PACKAGES, resolve = () => createRequire(path.join(prefix, 'package.json')).resolve(name);
133
134
  let entry: string;
134
135
  try { entry = resolve(); } catch {
135
136
  ready();
@@ -139,6 +140,14 @@ export async function loadPackage(name: string, version: string, label: string,
139
140
  }
140
141
  return import(pathToFileURL(entry).href);
141
142
  }
143
+ // The file a package loaded from: Papilot's own dependencies, or the copy
144
+ // fetched on first use.
145
+ export function packageFile(name: string) {
146
+ for (const from of [import.meta.url, path.join(PACKAGES, 'package.json')]) {
147
+ try { return createRequire(from).resolve(name); } catch { /* Not installed there. */ }
148
+ }
149
+ return null;
150
+ }
142
151
 
143
152
  let claude: Promise<typeof import('@anthropic-ai/claude-agent-sdk')['query']> | null = null;
144
153
  // The Claude Agent SDK carries Claude Code. GLM runs on it too.
package/src/server/app.ts CHANGED
@@ -12,7 +12,7 @@ import { reviewClient, allowedReviewRoute, rewriteReviewHtml, rewriteReviewJson,
12
12
  import { fullPaperPdf } from './pdf.ts';
13
13
  import { type ChangeAction, Changes } from './changes.ts';
14
14
  import { bibliography, confirmEntry } from './references.ts';
15
- import { type Manuscripts, annotationTask, gitAbortMerge, gitCommit, gitDiff, gitFinishMerge, gitIgnore, gitRemote, gitStage, gitStatus } from './manuscript.ts';
15
+ import { type Manuscripts, annotationTask, gitAbortMerge, gitCommit, gitDiff, gitDiscard, gitFinishMerge, gitIgnore, gitRemote, gitStage, gitStatus } from './manuscript.ts';
16
16
  import { gitOverview } from './git-overview.ts';
17
17
  import { findSkill, listedSkill, loadSkills } from './skills.ts';
18
18
  import { registerSkillRoutes } from './skill-routes.ts';
@@ -315,6 +315,11 @@ export async function buildApp(options: AppOptions) {
315
315
  const project = store.project(request.params.id), b = request.body as any;
316
316
  return serialGit(project.id, async () => ({ git: await gitStage(project.path, paths(b.paths), b.stage !== false) }));
317
317
  });
318
+ // The Git panel confirms before discarding; this cannot be undone.
319
+ app.post<{ Params: { id: string } }>('/api/projects/:id/git/discard', project(), request => {
320
+ const project = store.project(request.params.id), b = request.body as any;
321
+ return serialGit(project.id, async () => ({ git: await gitDiscard(project.path, paths(b.paths)) }));
322
+ });
318
323
  // An agent writes a commit message for the chosen files, or a fresh one
319
324
  // instead of the message it already wrote for them; nothing is written.
320
325
  app.post<{ Params: { id: string } }>('/api/projects/:id/git/message', project(), request => {
@@ -1,7 +1,7 @@
1
1
  import fs from 'node:fs';
2
2
  import os from 'node:os';
3
3
  import path from 'node:path';
4
- import { Adapter, loadPackage, toolItem, type Item, type Thread, type Turn } from './agents.ts';
4
+ import { Adapter, loadPackage, packageFile, toolItem, type Item, type Thread, type Turn } from './agents.ts';
5
5
  import { imageType } from './attachments.ts';
6
6
  import { DATA } from './paths.ts';
7
7
 
@@ -27,10 +27,29 @@ export function cursorConfigured(env: NodeJS.ProcessEnv = process.env) {
27
27
  let cursor: Promise<CursorSdk> | null = null;
28
28
  export function loadCursor() {
29
29
  cursor ??= loadPackage('@cursor/sdk', CURSOR_SDK_VERSION, 'the Cursor SDK for Cursor tasks', () => { if (!cursorConfigured()) throw new Error(CURSOR_KEY); })
30
+ .then(sdk => { const file = packageFile('@cursor/sdk'); return file ? helpersBesideSdk(sdk, file) : sdk; })
30
31
  .catch(error => { cursor = null; throw error; });
31
32
  return cursor;
32
33
  }
33
34
 
35
+ // The SDK looks for its platform helpers (the cursorsandbox binary, rg and the
36
+ // shell parser) in node_modules folders above the running script, not beside
37
+ // itself, and loads them as a run starts. The copy Papilot fetches on first
38
+ // use sits in the data folder, outside that search, so every sandboxed run
39
+ // failed as "not supported in this environment". While a run starts, the
40
+ // search begins at the SDK's own file.
41
+ let starting = 0, script: string | undefined;
42
+ export function helpersBesideSdk(sdk: CursorSdk, file: string): CursorSdk {
43
+ const fromSdk = async <T>(start: () => Promise<T>) => {
44
+ if (starting++ === 0) { script = process.argv[1]; process.argv[1] = file; }
45
+ try { return await start(); } finally { if (--starting === 0) process.argv[1] = script as string; }
46
+ };
47
+ const wrap = (agent: CursorAgentHandle): CursorAgentHandle => ({
48
+ agentId: agent.agentId, close: () => agent.close(), send: (message, options) => fromSdk(() => agent.send(message, options)),
49
+ });
50
+ return { Cursor: sdk.Cursor, Agent: { create: async options => wrap(await sdk.Agent.create(options)), resume: async (id, options) => wrap(await sdk.Agent.resume(id, options)) } };
51
+ }
52
+
34
53
  // Effort is a model parameter in Cursor's catalog.
35
54
  const effortParameter = (model?: CursorModel) => model?.parameters?.find(parameter => /effort|reasoning/i.test(parameter.id));
36
55
 
@@ -50,11 +50,15 @@ export async function gitOverview(root: string, tasks: Pick<Task, 'id' | 'worktr
50
50
  }
51
51
  }));
52
52
  worktrees.sort((a, b) => Number(b.current) - Number(a.current));
53
- const refs = new Map<string, string[]>();
54
- const refOutput = await git(root, ['for-each-ref', '--format=%(objectname)%00%(refname:short)%00%(*objectname)', 'refs/heads', 'refs/remotes', 'refs/tags']);
53
+ const refs = new Map<string, { refs: string[]; remotes: string[]; tags: string[] }>();
54
+ const refOutput = await git(root, ['for-each-ref', '--format=%(objectname)%00%(refname)%00%(refname:short)%00%(*objectname)', 'refs/heads', 'refs/remotes', 'refs/tags']);
55
55
  for (const line of refOutput.trim().split('\n').filter(Boolean)) {
56
- const [object, name, peeled] = line.split('\0'), hash = peeled || object;
57
- refs.set(hash, [...(refs.get(hash) || []), name]);
56
+ const [object, full, name, peeled] = line.split('\0'), hash = peeled || object;
57
+ // A remote's HEAD only names its default branch, which is listed anyway.
58
+ if (full.startsWith('refs/remotes/') && full.endsWith('/HEAD')) continue;
59
+ const entry = refs.get(hash) || { refs: [], remotes: [], tags: [] };
60
+ (full.startsWith('refs/heads/') ? entry.refs : full.startsWith('refs/remotes/') ? entry.remotes : entry.tags).push(name);
61
+ refs.set(hash, entry);
58
62
  }
59
63
  // Include detached worktree tips, which --branches alone would miss.
60
64
  const heads = [...new Set(worktrees.flatMap(tree => tree.head ? [tree.head] : []))];
@@ -63,7 +67,7 @@ export async function gitOverview(root: string, tasks: Pick<Task, 'id' | 'worktr
63
67
  const log = await git(root, ['log', '--topo-order', `-${limit + 1}`, '--format=%H%x00%P%x00%s%x00%an%x00%ct', '--branches', '--remotes', '--tags', ...heads, '--']);
64
68
  const commits: GitGraphCommit[] = log.trim().split('\n').filter(Boolean).map(line => {
65
69
  const [hash, parents, subject, author, date] = line.split('\0');
66
- return { hash, parents: parents ? parents.split(' ') : [], subject, author, date: Number(date) * 1000, refs: refs.get(hash) || [] };
70
+ return { hash, parents: parents ? parents.split(' ') : [], subject, author, date: Number(date) * 1000, refs: [], remotes: [], tags: [], ...refs.get(hash) };
67
71
  });
68
72
  return { worktrees, commits: commits.slice(0, limit), truncated: commits.length > limit };
69
73
  }
@@ -47,8 +47,9 @@ export function parseStatus(output: string, numstat: string): Omit<GitStatus, 'h
47
47
  // A rename or copy is followed by its original path.
48
48
  const from = kind === '2' ? fields[++i] : undefined;
49
49
  const code = kind === 'u' ? 'U' : xy[1] !== '.' ? xy[1] : xy[0];
50
- // X is the index against HEAD, Y the working tree against the index.
51
- status.files.push({ path: file, status: code, staged: xy[0] !== '.', unstaged: xy[1] !== '.', lines: lines.get(file), ...(from ? { from } : {}) });
50
+ // X is the index against HEAD, Y the working tree against the index. The
51
+ // staged list shows X where it differs, such as a new file edited again.
52
+ status.files.push({ path: file, status: code, staged: xy[0] !== '.', unstaged: xy[1] !== '.', lines: lines.get(file), ...(from ? { from } : {}), ...(xy[0] !== '.' && xy[0] !== code ? { index: xy[0] } : {}) });
52
53
  }
53
54
  }
54
55
  return status;
@@ -147,6 +148,24 @@ export async function gitStage(root: string, paths: string[], stage: boolean) {
147
148
  return gitStatus(root);
148
149
  }
149
150
 
151
+ // Discard what the Changes list shows for these files, as an editor's source
152
+ // control does: a tracked file returns to its staged version, or the last
153
+ // commit, and an untracked file is deleted. Staged changes stay.
154
+ export async function gitDiscard(root: string, paths: string[]) {
155
+ const status = await gitStatus(root);
156
+ if (status.merging) throw new Error('Finish or abort the merge before discarding changes.');
157
+ const files = status.files.filter(item => paths.includes(item.path) && item.unstaged);
158
+ if (!files.length) throw new Error('Select at least one changed file.');
159
+ const tracked = files.filter(item => item.status !== '?').map(item => item.path);
160
+ if (tracked.length) await gitAction(root, ['checkout', '--', ...tracked]);
161
+ for (const item of files.filter(item => item.status === '?')) {
162
+ const file = path.resolve(root, item.path);
163
+ if (!file.startsWith(root + path.sep)) throw new Error('Invalid file path.');
164
+ fs.rmSync(file, { force: true });
165
+ }
166
+ return gitStatus(root);
167
+ }
168
+
150
169
  // Commit what is staged, or with paths, exactly those files as they are on
151
170
  // disk; changes staged for other files then stay staged and out of the commit.
152
171
  export async function gitCommit(root: string, message: string, paths: string[] | null) {
@@ -4,12 +4,17 @@ 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) => Promise<ReviewClient> }> | null = null;
8
- export function reviewClient(root: string): Promise<ReviewClient> {
7
+ let modulePromise: Promise<{ connect: (root: string, start?: boolean) => Promise<ReviewClient | null> }> | null = null;
8
+ export function reviewClient(root: string, reuse = false): Promise<ReviewClient> {
9
9
  modulePromise ??= import(pathToFileURL(path.join(ENGINE, 'src/client.ts')).href);
10
- return modulePromise!.then(module => module.connect(root));
10
+ // 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))!);
11
12
  }
12
13
  export const taskRoute = (name: string) => `/api/tasks/${encodeURIComponent(name)}`;
14
+ export function reviewConnectionError(error: unknown): boolean {
15
+ if (!(error instanceof Error)) return false;
16
+ return ['AbortError', 'TimeoutError'].includes(error.name) || /fetch failed|ECONNREFUSED|ECONNRESET|socket hang up|The review server failed to start/.test(error.message);
17
+ }
13
18
 
14
19
  export function confirmedReviewResponse(status: number, value: unknown, task: string): boolean {
15
20
  if (!value || typeof value !== 'object') return false;
@@ -21,8 +21,13 @@ const accounts = new Accounts(store, ACCOUNT_ORIGIN || null);
21
21
  const manuscripts = new Manuscripts(), changes = new Changes();
22
22
  const desktop = process.env.PAPILOT_DESKTOP === '1';
23
23
  if (desktop) await desktopDemo(ROOT, DATA, store, workspace, manuscripts).catch(error => console.error('Could not prepare the demo paper:', error));
24
- // Desktop releases are complete installers; npm updates belong to the web server.
25
- const updates = new Updates({ root: ROOT, data: DATA, version: VERSION, port: PORT, desktop,
24
+ // A packaged desktop app downloads only Papilot's own code built for its
25
+ // installer (see desktop/runtime.ts) and asks the app to switch to it. npm
26
+ // updates belong to the web server.
27
+ const bundleRoot = desktop && process.send ? process.env.PAPILOT_DESKTOP_BUNDLE : undefined, fingerprint = process.env.PAPILOT_DESKTOP_FINGERPRINT || '';
28
+ const bundle = bundleRoot && fingerprint ? { root: bundleRoot, version: readJson(path.join(bundleRoot, 'package.json'))?.version || '', fingerprint } : undefined;
29
+ const updates = new Updates({ root: ROOT, data: DATA, version: VERSION, port: PORT, desktop, bundle,
30
+ ...(bundle ? { restart: async (root: string, version: string) => { process.send!({ type: 'papilot:activate', root, version }); } } : {}),
26
31
  busy: () => workspace.busyForUpdate || manuscripts.busyForUpdate || changes.busyForUpdate, shutdown: () => shutdown() });
27
32
  const activation = desktop ? null : readJson(path.join(DATA, 'updates/active.json'));
28
33
  const updateInstance = typeof activation?.root === 'string' && fs.existsSync(activation.root) && fs.realpathSync(activation.root) === fs.realpathSync(ROOT) ? activation.id : undefined;
@@ -46,7 +51,9 @@ console.log(`Papilot is running at ${localUrl}${PUBLIC_ORIGIN ? `\nRemote: ${PUB
46
51
  if (!process.env.PAPILOT_UPDATE_PROBE) { void workspace.connect(); updates.start(); }
47
52
  // Recompute cached feedback counts, including decisions from before this restart.
48
53
  for (const task of store.tasks()) {
49
- if (task.status === 'building') {
54
+ if (task.pendingBuild || (task.review?.job?.id && (task.status === 'building' || (task.status === 'failed' && task.error === 'fetch failed')))) {
55
+ void workspace.resumeBuild(task.id);
56
+ } else if (task.status === 'building') {
50
57
  store.updateTask(task.id, { status: 'interrupted', error: 'The server restarted during the PDF build. Check the review and rebuild it if needed.' });
51
58
  void workspace.refresh(task.id);
52
59
  } else if (task.review?.round && task.status !== 'deleting') void workspace.refresh(task.id);
@@ -2,6 +2,7 @@ import fs from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import net from 'node:net';
4
4
  import { execFile, spawn } from 'node:child_process';
5
+ import { createHash } from 'node:crypto';
5
6
  import { promisify } from 'node:util';
6
7
  import { createRequire } from 'node:module';
7
8
  import { devRevision, readJson, stableVersion } from '../../bin/installation.mjs';
@@ -102,6 +103,62 @@ async function installDev(data: string, version: string) {
102
103
  } catch (error) { fs.rmSync(stage, { recursive: true, force: true }); throw error; }
103
104
  finally { fs.closeSync(log); }
104
105
  }
106
+ const INSTALL_FAILED = 'The new version could not be installed. Check the connection and available disk space, then retry.';
107
+ // Release assets redirect from github.com to GitHub's download hosts. Each hop
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) {
110
+ const source = process.env.PAPILOT_DESKTOP_UPDATE_SOURCE ? new URL(process.env.PAPILOT_DESKTOP_UPDATE_SOURCE).origin : null;
111
+ const trusted = (address: URL) => address.origin === source
112
+ || address.protocol === 'https:' && (address.hostname === 'github.com' || address.hostname.endsWith('.githubusercontent.com'));
113
+ let address = new URL(url);
114
+ for (let hop = 0; hop < 5; hop++) {
115
+ if (!trusted(address)) throw new Error(INSTALL_FAILED);
116
+ const response = await fetcher(address.href, { signal: AbortSignal.timeout(300000), redirect: 'manual', headers: { Accept: 'application/octet-stream', 'User-Agent': 'Papilot' } });
117
+ const location = response.headers.get('location');
118
+ if (response.status >= 300 && response.status < 400 && location) { address = new URL(location, address); continue; }
119
+ if (!response.ok || Number(response.headers.get('content-length') || 0) > limit) throw new Error(INSTALL_FAILED);
120
+ const bytes = Buffer.from(await response.arrayBuffer());
121
+ if (bytes.length > limit) throw new Error(INSTALL_FAILED);
122
+ return bytes;
123
+ }
124
+ throw new Error(INSTALL_FAILED);
125
+ }
126
+ export interface DesktopLayer { url: string; size: number; sha256: string }
127
+ // As in desktop/runtime.ts: a layer runs on the installer's dependencies.
128
+ function linkBundle(root: string, bundle: string) {
129
+ for (const folder of ['node_modules', 'engine/node_modules']) {
130
+ const target = path.join(bundle, folder), link = path.join(root, folder);
131
+ if (!fs.existsSync(target) || !fs.existsSync(path.dirname(link))) continue;
132
+ fs.rmSync(link, { recursive: true, force: true });
133
+ fs.symlinkSync(target, link, 'dir');
134
+ }
135
+ }
136
+ // The desktop app downloads only Papilot's own code, built by the release
137
+ // workflow for this installer's shell, and checks that it starts. The app then
138
+ // switches to it (desktop/runtime.ts).
139
+ export async function installLayer(data: string, version: string, layer: DesktopLayer, bundle: { root: string; fingerprint: string }, fetcher = fetch) {
140
+ const layers = path.join(data, 'updates/desktop/layers'); fs.mkdirSync(layers, { recursive: true, mode: 0o700 });
141
+ const stage = fs.mkdtempSync(path.join(layers, version + '-')), root = path.join(stage, 'papilot'), archive = path.join(stage, 'layer.tgz');
142
+ const log = fs.openSync(path.join(data, 'updates/desktop/install.log'), 'a', 0o600);
143
+ try {
144
+ const bytes = await download(layer.url, fetcher, layer.size);
145
+ if (bytes.length !== layer.size || createHash('sha256').update(bytes).digest('hex') !== layer.sha256) throw new Error(INSTALL_FAILED);
146
+ fs.writeFileSync(archive, bytes, { mode: 0o600 });
147
+ fs.mkdirSync(root);
148
+ await promisify(execFile)('tar', ['-xzf', archive, '-C', root, '--strip-components=1'], { timeout: 120000 });
149
+ fs.rmSync(archive);
150
+ const pkg = readJson(path.join(root, 'package.json')), shell = readJson(path.join(root, 'desktop-layer.json'));
151
+ if (pkg?.name !== 'papilot' || pkg.version !== version || shell?.version !== version) throw new Error('The downloaded package does not match the requested release.');
152
+ if (shell.fingerprint !== bundle.fingerprint) throw new Error('Download desktop updates from GitHub Releases.');
153
+ for (const file of ['dist/index.html', 'src/server/start.ts', 'src/server/main.ts', 'engine/package.json']) {
154
+ if (!fs.existsSync(path.join(root, file))) throw new Error('The downloaded release is incomplete.');
155
+ }
156
+ linkBundle(root, bundle.root);
157
+ await probeInstallation(root, version, stage, log);
158
+ return root;
159
+ } catch (error) { fs.rmSync(stage, { recursive: true, force: true }); throw error; }
160
+ finally { fs.closeSync(log); }
161
+ }
105
162
  export async function installRelease(data: string, version: string) {
106
163
  if (process.env.PAPILOT_DESKTOP === '1') throw new Error('Download desktop updates from GitHub Releases.');
107
164
  if (devRevision(version)) return installDev(data, version);