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
@@ -1,6 +1,9 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
1
3
  import {
2
4
  spawn,
3
5
  spawnSync,
6
+ type ChildProcess,
4
7
  type SpawnSyncOptionsWithBufferEncoding,
5
8
  } from "node:child_process";
6
9
  export class ProcessError extends Error {
@@ -39,16 +42,92 @@ export function run(
39
42
  status: result.status ?? 1,
40
43
  };
41
44
  }
42
- const running = new Set<ReturnType<typeof spawn>>();
45
+ const running = new Set<ChildProcess>();
46
+ let stopped = false;
47
+ /** Tracks a child that `start` spawns in its own process group (detached),
48
+ * so stopAll() ends it with everything it started. Once the server is
49
+ * stopping nothing new starts: a build must not retry or move on to its next
50
+ * step after its compiler was stopped. */
51
+ export function startTracked<T extends ChildProcess>(start: () => T): T {
52
+ if (stopped) throw new ProcessError("The review server is stopping.");
53
+ const child = start();
54
+ running.add(child);
55
+ child.once("close", () => running.delete(child));
56
+ child.once("error", () => running.delete(child));
57
+ return child;
58
+ }
43
59
  export function stopAll() {
60
+ stopped = true;
44
61
  for (const child of running) {
45
62
  try {
46
- if (child.pid) process.kill(-child.pid, "SIGTERM");
63
+ if (child.pid) {
64
+ process.kill(-child.pid, "SIGTERM");
65
+ // A paused background build receives the signal once it continues.
66
+ process.kill(-child.pid, "SIGCONT");
67
+ }
47
68
  } catch {
48
69
  child.kill();
49
70
  }
50
71
  }
51
72
  }
73
+ /** `run` without blocking the event loop: the same result and errors,
74
+ * delivered when the command exits. Servers call this so that a Git command
75
+ * never holds up the requests they answer meanwhile. */
76
+ export function capture(
77
+ command: string,
78
+ args: string[],
79
+ options: {
80
+ cwd?: string;
81
+ env?: NodeJS.ProcessEnv;
82
+ input?: string | Buffer;
83
+ check?: boolean;
84
+ timeout?: number;
85
+ maxBuffer?: number;
86
+ } = {},
87
+ ) {
88
+ const { check = true, timeout = 300000, maxBuffer = 128 * 1024 * 1024 } = options;
89
+ return new Promise<{ stdout: Buffer; stderr: Buffer; status: number }>((resolve, reject) => {
90
+ let child: ChildProcess;
91
+ try {
92
+ child = startTracked(() =>
93
+ spawn(command, args, { cwd: options.cwd, env: options.env, stdio: [options.input === undefined ? "ignore" : "pipe", "pipe", "pipe"] }),
94
+ );
95
+ } catch (error) {
96
+ reject(error);
97
+ return;
98
+ }
99
+ const out: Buffer[] = [], err: Buffer[] = [];
100
+ let size = 0, failure: Error | null = null;
101
+ const stop = (error: Error) => {
102
+ failure ??= error;
103
+ child.kill("SIGKILL");
104
+ };
105
+ const timer = setTimeout(() => stop(new ProcessError(`${command} timed out`)), timeout);
106
+ const collect = (into: Buffer[]) => (data: Buffer) => {
107
+ size += data.length;
108
+ if (size > maxBuffer) stop(new ProcessError(`${command} produced more output than expected`));
109
+ else into.push(data);
110
+ };
111
+ child.stdout!.on("data", collect(out));
112
+ child.stderr!.on("data", collect(err));
113
+ if (options.input !== undefined) {
114
+ child.stdin!.on("error", () => { /* The exit status reports the failure. */ });
115
+ child.stdin!.end(options.input);
116
+ }
117
+ child.on("error", (error) => {
118
+ clearTimeout(timer);
119
+ reject(failure ?? error);
120
+ });
121
+ child.on("close", (code) => {
122
+ clearTimeout(timer);
123
+ const stdout = Buffer.concat(out), stderr = Buffer.concat(err), status = code ?? 1;
124
+ if (failure) reject(failure);
125
+ else if (check && status !== 0)
126
+ reject(new ProcessError(stderr.toString().trim() || `${command} failed (${status})`, stdout, stderr, status));
127
+ else resolve({ stdout, stderr, status });
128
+ });
129
+ });
130
+ }
52
131
  export function runAsync(
53
132
  command: string,
54
133
  args: string[],
@@ -57,16 +136,20 @@ export function runAsync(
57
136
  env?: NodeJS.ProcessEnv;
58
137
  timeout?: number;
59
138
  output?: (chunk: Buffer) => void;
139
+ /** Receives the process group's leader, e.g. to pause and resume it. */
140
+ started?: (child: ChildProcess) => void;
60
141
  } = {},
61
142
  ) {
62
143
  return new Promise<Buffer>((resolve, reject) => {
63
- const child = spawn(command, args, {
64
- cwd: options.cwd,
65
- env: options.env,
66
- detached: true,
67
- stdio: ["ignore", "pipe", "pipe"],
68
- });
69
- running.add(child);
144
+ const child = startTracked(() =>
145
+ spawn(command, args, {
146
+ cwd: options.cwd,
147
+ env: options.env,
148
+ detached: true,
149
+ stdio: ["ignore", "pipe", "pipe"],
150
+ }),
151
+ );
152
+ options.started?.(child);
70
153
  const chunks: Buffer[] = [];
71
154
  let timedOut = false;
72
155
  const timer = setTimeout(() => {
@@ -85,12 +168,10 @@ export function runAsync(
85
168
  child.stderr.on("data", receive);
86
169
  child.on("error", (error) => {
87
170
  clearTimeout(timer);
88
- running.delete(child);
89
171
  reject(error);
90
172
  });
91
173
  child.on("close", (code) => {
92
174
  clearTimeout(timer);
93
- running.delete(child);
94
175
  const data = Buffer.concat(chunks);
95
176
  if (code === 0 && !timedOut) resolve(data);
96
177
  else
@@ -109,3 +190,35 @@ export function which(command: string): string | null {
109
190
  const found = run("which", [command], { check: false });
110
191
  return found.status === 0 ? found.stdout.toString().trim() : null;
111
192
  }
193
+ // A command's first output line (such as `xelatex --version`), read once per
194
+ // executable file: TeX is not replaced while a server runs, and when it is,
195
+ // the file's identity changes.
196
+ const versions = new Map<string, Promise<string>>();
197
+ export async function commandVersion(command: string, env: NodeJS.ProcessEnv = process.env) {
198
+ const found = await resolveCommand(command, env);
199
+ let identity = command;
200
+ if (found) try {
201
+ const stat = await fs.promises.stat(found);
202
+ identity = `${found}:${stat.ino}:${stat.mtimeMs}:${stat.size}`;
203
+ } catch { /* The command reports its own failure below. */ }
204
+ let version = versions.get(identity);
205
+ if (!version) {
206
+ version = capture(found ?? command, ["--version"], { env, timeout: 60000 }).then((r) => r.stdout.toString().split("\n")[0]);
207
+ versions.set(identity, version);
208
+ version.catch(() => versions.delete(identity));
209
+ }
210
+ return version;
211
+ }
212
+ // The executable file PATH resolves a command to, as a shell would find it.
213
+ async function resolveCommand(command: string, env: NodeJS.ProcessEnv) {
214
+ if (command.includes("/")) return path.resolve(command);
215
+ for (const folder of (env.PATH ?? "").split(path.delimiter)) {
216
+ if (!folder) continue;
217
+ const file = path.join(folder, command);
218
+ try {
219
+ await fs.promises.access(file, fs.constants.X_OK);
220
+ if ((await fs.promises.stat(file)).isFile()) return fs.promises.realpath(file);
221
+ } catch { /* Try the next folder. */ }
222
+ }
223
+ return null;
224
+ }
@@ -3,7 +3,7 @@ import path from "node:path";
3
3
  import { randomBytes } from "node:crypto";
4
4
  import { ROOT, codeVersion } from "../runtime.ts";
5
5
  import { Builder, BuildError } from "./build.ts";
6
- import { diffUnits, scopeViolations, withUnits } from "./changes.ts";
6
+ import { diffUnitsAsync, scopeViolationsAsync, withUnits } from "./changes.ts";
7
7
  import { isFigure } from "./config.ts";
8
8
  import { now, readJson, writeJson } from "./files.ts";
9
9
  import { runAsync } from "./process.ts";
@@ -189,6 +189,10 @@ export class Publisher {
189
189
  running = new Map<string, Promise<void>>();
190
190
  queue: string[] = [];
191
191
  stopping = false;
192
+ // The new version of each running job whose round compiles only some of
193
+ // its chapters: a host may compile the whole paper meanwhile, without
194
+ // waiting for or competing with a build of the same files here.
195
+ private revisions = new Map<string, string>();
192
196
  constructor(public app: App) {
193
197
  app.store.execute(
194
198
  "UPDATE jobs SET status='failed', finished=?, result=? WHERE status IN ('queued','running')",
@@ -241,6 +245,9 @@ export class Publisher {
241
245
  if (!job) throw new Error("Unknown job " + id);
242
246
  return job;
243
247
  }
248
+ revision(id: string) {
249
+ return this.revisions.get(id) ?? null;
250
+ }
244
251
  current(name: string) {
245
252
  return this.app.store.one<Job>(
246
253
  "SELECT * FROM jobs WHERE task=? ORDER BY created DESC,rowid DESC LIMIT 1",
@@ -276,6 +283,10 @@ export class Publisher {
276
283
  stop() {
277
284
  this.stopping = true;
278
285
  }
286
+ private phase(job: Pick<Job, "id">, phase: string) {
287
+ if (this.stopping) throw new Error("Server stopped during publication.");
288
+ this.update(job.id, { phase });
289
+ }
279
290
  async join() {
280
291
  await Promise.resolve();
281
292
  while (this.running.size || this.pruning)
@@ -313,6 +324,9 @@ export class Publisher {
313
324
  }
314
325
  this.update(job.id, { status: "done", result, finished: now() });
315
326
  } catch (error) {
327
+ // A build the server stopped stays unfinished: the next server marks it
328
+ // as interrupted by a restart, and the app builds it again.
329
+ if (this.stopping) return;
316
330
  const result: JobResult =
317
331
  error instanceof PublishError
318
332
  ? {
@@ -324,6 +338,7 @@ export class Publisher {
324
338
  : { error: (error as Error).message, next: "Publish again." };
325
339
  this.update(job.id, { status: "failed", result, finished: now() });
326
340
  } finally {
341
+ this.revisions.delete(job.id);
327
342
  this.running.delete(job.task);
328
343
  if (this.pending.get(job.task) === job.id) this.pending.delete(job.task);
329
344
  else if (!this.stopping) this.queue.push(this.pending.get(job.task)!);
@@ -343,16 +358,17 @@ export class Publisher {
343
358
  [],
344
359
  "Run `finish --force`, then start a new task.",
345
360
  );
346
- this.update(job.id, { phase: "snapshot" });
347
- const after = app.tasks.snapshot(task);
348
- task = app.tasks.get(name);
349
- const before = task.accepted,
350
- latest = app.review.latestRound(name),
351
- decided = app.review.lastChange(name),
352
- violations = scopeViolations(app.repo, task.base, after, task.scope);
361
+ this.phase(job, "snapshot");
362
+ // The worktree and the decisions are read together, between writes.
363
+ const { after, before, latest, decided } = await app.exclusive(async () => {
364
+ const after = await app.tasks.snapshotAsync(task);
365
+ task = app.tasks.get(name);
366
+ return { after, before: task.accepted, latest: app.review.latestRound(name), decided: app.review.lastChange(name) };
367
+ });
368
+ const violations = await scopeViolationsAsync(app.repo, task.base, after, task.scope);
353
369
  const reconciliation = app.reconciliation.get(name);
354
370
  if (reconciliation) {
355
- for (const file of app.repo.changedFiles(task.base, after))
371
+ for (const file of await app.repo.changedFilesAsync(task.base, after))
356
372
  if (!reconciliation.files.includes(file) && !violations.includes(file)) violations.push(file);
357
373
  }
358
374
  if (violations.length)
@@ -371,7 +387,7 @@ export class Publisher {
371
387
  if (
372
388
  latest &&
373
389
  latest.before === before &&
374
- app.repo.treeId(latest.after) === app.repo.treeId(after) &&
390
+ (await app.repo.treeIdAsync(latest.after)) === (await app.repo.treeIdAsync(after)) &&
375
391
  latest.summary.renderer === codeVersion()
376
392
  )
377
393
  return {
@@ -379,45 +395,35 @@ export class Publisher {
379
395
  round: latest.number,
380
396
  changes: latest.units.length,
381
397
  };
382
- const files = app.repo.changedFiles(before, after);
398
+ const files = await app.repo.changedFilesAsync(before, after);
383
399
  if (!files.length) return { outcome: "empty", round: null, changes: 0 };
384
- const units = diffUnits(app.repo, before, after, (f) =>
400
+ const units = await diffUnitsAsync(app.repo, before, after, (f) =>
385
401
  isFigure(project, f),
386
402
  );
387
- this.update(job.id, { phase: "build" });
403
+ this.phase(job, "build");
388
404
  const builder = new Builder(project, app.repo),
389
- targets = builder.plan(before, after, files);
390
- let seed = null,
391
- old,
392
- next;
393
- try {
394
- seed = targets[0] !== "full" ? await builder.build(task.base) : null;
395
- old = await builder.build(before, targets, seed);
396
- } catch (error) {
397
- if (error instanceof BuildError)
398
- throw new PublishError(
399
- "The comparison baseline could not be compiled.",
400
- error.details,
401
- "Check the compiler diagnostics, fix the document or build environment, then publish again.",
402
- error.log,
403
- );
404
- throw error;
405
- }
406
- try {
407
- next = await builder.build(after, targets, seed);
408
- } catch (error) {
405
+ targets = await builder.planAsync(before, after, files);
406
+ if (targets[0] !== "full") this.revisions.set(job.id, after);
407
+ const failed = (what: string) => (error: unknown) => {
409
408
  if (error instanceof BuildError)
410
409
  throw new PublishError(
411
- "The revised document could not be compiled.",
410
+ what,
412
411
  error.details,
413
412
  "Check the compiler diagnostics, fix the document or build environment, then publish again.",
414
413
  error.log,
415
414
  );
416
415
  throw error;
417
- }
416
+ };
417
+ const seed = targets[0] !== "full" ? await builder.build(task.base).catch(failed("The comparison baseline could not be compiled.")) : null;
418
+ // Both versions compile at once: each needs only the seed.
419
+ const baseline = builder.build(before, targets, seed).catch(failed("The comparison baseline could not be compiled.")),
420
+ revised = builder.build(after, targets, seed).catch(failed("The revised document could not be compiled."));
421
+ // A failure of either is reported once both have settled, the baseline's first.
422
+ await Promise.allSettled([baseline, revised]);
423
+ const old = await baseline, next = await revised;
418
424
  if (app.review.lastChange(name) !== decided) throw new Stale();
419
425
  const warnings = next.warnings.filter((w) => !old.warnings.includes(w));
420
- this.update(job.id, { phase: "render" });
426
+ this.phase(job, "render");
421
427
  const number = (latest?.number ?? 0) + 1,
422
428
  rounds = path.join(app.state, "rounds", name),
423
429
  final = path.join(rounds, String(number).padStart(3, "0")),
@@ -447,20 +453,23 @@ export class Publisher {
447
453
  seed,
448
454
  ),
449
455
  );
450
- if (app.review.lastChange(name) !== decided) throw new Stale();
451
- fs.rmSync(final, { recursive: true, force: true });
452
- fs.renameSync(output, final);
453
- app.store.execute(
454
- "INSERT INTO rounds(task,number,before,after,units,summary,created) VALUES(?,?,?,?,?,?,?)",
455
- name,
456
- number,
457
- before,
458
- after,
459
- JSON.stringify(units),
460
- JSON.stringify(summary),
461
- now(),
462
- );
463
- app.repo.updateRef(`refs/latex-review/${name}/round-${number}`, after);
456
+ // No decision may come between this check and the new round.
457
+ await app.exclusive(async () => {
458
+ if (app.review.lastChange(name) !== decided) throw new Stale();
459
+ await fs.promises.rm(final, { recursive: true, force: true });
460
+ await fs.promises.rename(output, final);
461
+ app.store.execute(
462
+ "INSERT INTO rounds(task,number,before,after,units,summary,created) VALUES(?,?,?,?,?,?,?)",
463
+ name,
464
+ number,
465
+ before,
466
+ after,
467
+ JSON.stringify(units),
468
+ JSON.stringify(summary),
469
+ now(),
470
+ );
471
+ await app.repo.updateRefAsync(`refs/latex-review/${name}/round-${number}`, after);
472
+ });
464
473
  } finally {
465
474
  fs.rmSync(output, { recursive: true, force: true });
466
475
  }
@@ -1,9 +1,9 @@
1
1
  import { randomUUID } from "node:crypto";
2
2
  import type { App } from "../server.ts";
3
- import { merge3 } from "./git.ts";
3
+ import { merge3Async } from "./git.ts";
4
4
  import { readBytes, same, writeOrDelete } from "./files.ts";
5
5
  import { safePath } from "./review.ts";
6
- import { GENERATED, revisionReader } from "./tasks.ts";
6
+ import { GENERATED, revisionReaderAsync } from "./tasks.ts";
7
7
  import { buildOutline, resolveSections, selectedRanges } from "./outline.ts";
8
8
 
9
9
  export interface Reconciliation {
@@ -34,45 +34,46 @@ export class Reconciliations {
34
34
  clear(name: string) {
35
35
  this.app.store.execute("DELETE FROM reconciliations WHERE task=?", name);
36
36
  }
37
- start(name: string, force = false, intent?: Reconciliation["intent"]) {
37
+ // Callers hold the engine's write turn.
38
+ async start(name: string, force = false, intent?: Reconciliation["intent"]) {
38
39
  const app = this.app, task = app.tasks.get(name), previous = this.get(name);
39
40
  if (previous && (!previous.ready || (!force && (app.review.latestRound(name)?.number ?? 0) <= previous.round)))
40
41
  return this.prepare(name, previous);
41
- const worktreeBefore = app.tasks.snapshot(task);
42
- if (app.repo.changedFiles(task.accepted, worktreeBefore).length)
42
+ const worktreeBefore = await app.tasks.snapshotAsync(task);
43
+ if ((await app.repo.changedFilesAsync(task.accepted, worktreeBefore)).length)
43
44
  throw new Error("Review the latest task edits before applying the manuscript changes.");
44
45
  const delivered = app.delivery.get(name);
45
46
  const sourceBase = previous?.base ?? (delivered && delivered.mode !== "branch" ? delivered.accepted : task.base);
46
- const base = app.repo.snapshot([app.project.source_dir, ...task.scope.paths], GENERATED);
47
- const changed = app.repo.changedFiles(sourceBase, task.accepted);
48
- const old = app.repo.blobs(sourceBase, changed), accepted = app.repo.blobs(task.accepted, changed), current = app.repo.blobs(base, changed);
47
+ const base = await app.repo.snapshotAsync([app.project.source_dir, ...task.scope.paths], GENERATED);
48
+ const changed = await app.repo.changedFilesAsync(sourceBase, task.accepted);
49
+ const [old, accepted, current] = await Promise.all([sourceBase, task.accepted, base].map((rev) => app.repo.blobsAsync(rev, changed)));
49
50
  const files: string[] = [], contents: Record<string, Buffer | null> = {};
50
51
  for (const file of changed) {
51
- const [merged, conflict] = merge3(current[file], old[file], accepted[file]);
52
+ const [merged, conflict] = await merge3Async(current[file], old[file], accepted[file]);
52
53
  if (!conflict) contents[file] = merged;
53
54
  else {
54
55
  files.push(file);
55
56
  // Keep clean hunks even in a file with conflicts. At overlapping
56
57
  // hunks keep the latest manuscript as the baseline for human review.
57
- const [kept, failed] = merge3(current[file], old[file], accepted[file], true);
58
+ const [kept, failed] = await merge3Async(current[file], old[file], accepted[file], true);
58
59
  contents[file] = failed ? current[file] : kept;
59
60
  }
60
61
  }
61
62
  if (!files.length) return null; // The author may have resolved it meanwhile.
62
- const reviewBase = app.repo.commitWith(base, contents, "latex-review: prepare merge review");
63
+ const reviewBase = await app.repo.commitWithAsync(base, contents, "latex-review: prepare merge review");
63
64
  const data: Reconciliation = { id: randomUUID(), round: app.review.latestRound(name)?.number ?? 0, files, sourceBase, sourceAccepted: task.accepted, base, reviewBase, worktreeBefore, ready: false, ...(intent ? { intent } : {}) };
64
- for (const file of app.repo.changedFiles(worktreeBefore, reviewBase)) safePath(task.worktree, file);
65
+ for (const file of await app.repo.changedFilesAsync(worktreeBefore, reviewBase)) safePath(task.worktree, file);
65
66
  // Keep the three versions available to the agent and across Git GC.
66
67
  for (const [kind, rev] of Object.entries({ original: sourceBase, proposed: task.accepted, current: base, before: worktreeBefore, review: reviewBase }))
67
- app.repo.updateRef(`refs/latex-review/${name}/merge/${data.id}/${kind}`, rev);
68
+ await app.repo.updateRefAsync(`refs/latex-review/${name}/merge/${data.id}/${kind}`, rev);
68
69
  app.store.execute("INSERT OR REPLACE INTO reconciliations(task,data) VALUES(?,?)", name, JSON.stringify(data));
69
70
  return this.prepare(name, data);
70
71
  }
71
- prepare(name: string, data: Reconciliation) {
72
+ async prepare(name: string, data: Reconciliation) {
72
73
  if (data.ready) return data;
73
74
  const app = this.app, task = app.tasks.get(name);
74
- const files = app.repo.changedFiles(data.worktreeBefore, data.reviewBase);
75
- const before = app.repo.blobs(data.worktreeBefore, files), after = app.repo.blobs(data.reviewBase, files);
75
+ const files = await app.repo.changedFilesAsync(data.worktreeBefore, data.reviewBase);
76
+ const [before, after] = await Promise.all([app.repo.blobsAsync(data.worktreeBefore, files), app.repo.blobsAsync(data.reviewBase, files)]);
76
77
  const writes = files.map(file => ({ file, path: safePath(task.worktree, file) }));
77
78
  for (const write of writes) {
78
79
  const current = readBytes(write.path);
@@ -81,21 +82,22 @@ export class Reconciliations {
81
82
  }
82
83
  let scope = task.scope;
83
84
  if (scope.sections.length) {
84
- const outline = buildOutline(revisionReader(app.repo, data.reviewBase, app.project.doc_dir), app.project.main);
85
+ const outline = buildOutline(await revisionReaderAsync(app.repo, data.reviewBase, app.project.doc_dir), app.project.main);
85
86
  const [ranges, sections] = selectedRanges(outline.nodes, resolveSections(outline.nodes, scope.sections.map(section => section.id.replace(/^label:/, ""))));
86
87
  scope = { ranges, sections, paths: Object.keys(ranges).sort() };
87
88
  }
88
89
  for (const write of writes) writeOrDelete(write.path, after[write.file]);
89
- app.repo.updateRef(`refs/latex-review/${name}/base`, data.reviewBase);
90
- app.repo.updateRef(`refs/latex-review/${name}/accepted`, data.reviewBase);
90
+ await app.repo.updateRefAsync(`refs/latex-review/${name}/base`, data.reviewBase);
91
+ await app.repo.updateRefAsync(`refs/latex-review/${name}/accepted`, data.reviewBase);
91
92
  app.store.transaction(() => {
92
93
  app.store.execute("UPDATE tasks SET base=?,accepted=?,scope=? WHERE name=?", data.reviewBase, data.reviewBase, JSON.stringify(scope), name);
93
94
  app.store.execute("UPDATE reconciliations SET data=? WHERE task=?", JSON.stringify({ ...data, ready: true }), name);
94
95
  });
95
96
  return { ...data, ready: true };
96
97
  }
97
- recover() {
98
+ // At start, before any other write.
99
+ async recover() {
98
100
  for (const row of this.app.store.all<{ task: string; data: Reconciliation }>("SELECT * FROM reconciliations"))
99
- if (!row.data.ready) try { this.prepare(row.task, row.data); } catch { /* Retry without overwriting intervening edits. */ }
101
+ if (!row.data.ready) try { await this.prepare(row.task, row.data); } catch { /* Retry without overwriting intervening edits. */ }
100
102
  }
101
103
  }
@@ -3,7 +3,7 @@ import path from "node:path";
3
3
  import { randomUUID } from "node:crypto";
4
4
  import { applyUnits, revertUnits } from "./changes.ts";
5
5
  import { now, readBytes, readJson, same, writeOrDelete } from "./files.ts";
6
- import { cleanPath, merge3 } from "./git.ts";
6
+ import { cleanPath, merge3Async } from "./git.ts";
7
7
  import type { App } from "../server.ts";
8
8
  import type {
9
9
  Action,
@@ -293,6 +293,9 @@ export class Review {
293
293
  loose: render.loose,
294
294
  warnings: round.summary.warnings ?? [],
295
295
  files: `/files/${name}/${number}/`,
296
+ ...(round.summary.builds?.length
297
+ ? { build: { key: round.summary.builds.at(-1)!, targets: round.summary.targets ?? ["full"] } }
298
+ : {}),
296
299
  ...(render.references ? { references: render.references } : {}),
297
300
  };
298
301
  }
@@ -386,6 +389,8 @@ export class Review {
386
389
  warnings: this.latestRound(name)?.summary.warnings ?? [],
387
390
  };
388
391
  }
392
+ /** Accepts, rejects or comments on changes of the latest round. Runs after
393
+ * every other write of the review engine, never beside one. */
389
394
  decide(
390
395
  name: string,
391
396
  data: {
@@ -395,6 +400,18 @@ export class Review {
395
400
  round: number;
396
401
  body?: string;
397
402
  },
403
+ ) {
404
+ return this.app.exclusive(() => this.decideNow(name, data));
405
+ }
406
+ private async decideNow(
407
+ name: string,
408
+ data: {
409
+ action: ActionKind;
410
+ id?: string;
411
+ changes: string[];
412
+ round: number;
413
+ body?: string;
414
+ },
398
415
  ) {
399
416
  const task = this.app.tasks.get(name),
400
417
  kind = data.action;
@@ -443,8 +460,8 @@ export class Review {
443
460
  if (body.length > 20000)
444
461
  throw new Error("Comments are limited to 20,000 characters.");
445
462
  const selected = chosen.map((c) => latest.units.find((u) => u.id === c)!);
446
- if (kind === "accept") this.accept(task, latest, identity, selected);
447
- else if (kind === "reject") this.reject(task, latest, identity, selected);
463
+ if (kind === "accept") await this.accept(task, latest, identity, selected);
464
+ else if (kind === "reject") await this.reject(task, latest, identity, selected);
448
465
  else
449
466
  this.app.store.transaction(() => {
450
467
  this.record(
@@ -470,22 +487,20 @@ export class Review {
470
487
  for (const unit of units) (groups[unit.file] ??= []).push(unit);
471
488
  return groups;
472
489
  }
473
- accept(task: Task, round: Round, identity: string, units: Unit[]) {
490
+ private async accept(task: Task, round: Round, identity: string, units: Unit[]) {
474
491
  const app = this.app,
475
492
  writes: [string, Buffer | null, Buffer | null][] = [],
476
493
  accepted: Record<string, Buffer | null> = {},
477
494
  conflicts: string[] = [],
478
495
  groups = this.byFile(units),
479
496
  files = Object.keys(groups),
480
- before = app.repo.blobs(round.before, files),
481
- after = app.repo.blobs(round.after, files),
482
- records = app.repo.blobs(task.accepted, files);
497
+ [before, after, records] = await Promise.all([round.before, round.after, task.accepted].map((rev) => app.repo.blobsAsync(rev, files)));
483
498
  for (const [file, group] of Object.entries(groups)) {
484
499
  const proposed = applyUnits(before[file], after[file], group),
485
500
  target = task.deferred ? "" : safePath(app.root, file),
486
501
  current = task.deferred ? records[file] : readBytes(target),
487
- [merged, conflict] = merge3(current, before[file], proposed),
488
- [recorded, recordedConflict] = merge3(
502
+ [merged, conflict] = await merge3Async(current, before[file], proposed),
503
+ [recorded, recordedConflict] = await merge3Async(
489
504
  records[file],
490
505
  before[file],
491
506
  proposed,
@@ -498,7 +513,7 @@ export class Review {
498
513
  throw new Conflict(
499
514
  `${units.map((u) => u.id).join(", ")} can't be applied: ${conflicts.join(", ")} changed in your manuscript since this round was published. Nothing was written. Comment on the change to have the agent redo it.`,
500
515
  );
501
- const afterCommit = app.repo.commitWith(
516
+ const afterCommit = await app.repo.commitWithAsync(
502
517
  task.accepted,
503
518
  accepted,
504
519
  "latex-review: accept " + units.map((u) => u.id).join(", "),
@@ -513,20 +528,19 @@ export class Review {
513
528
  { accepted_before: task.accepted, accepted_after: afterCommit },
514
529
  writes,
515
530
  );
516
- this.carryOut(identity, "apply");
531
+ await this.carryOut(identity, "apply");
517
532
  }
518
- reject(task: Task, round: Round, identity: string, units: Unit[]) {
533
+ private async reject(task: Task, round: Round, identity: string, units: Unit[]) {
519
534
  const writes: [string, Buffer | null, Buffer | null][] = [],
520
535
  kept: string[] = [],
521
536
  groups = this.byFile(units),
522
537
  files = Object.keys(groups),
523
- before = this.app.repo.blobs(round.before, files),
524
- after = this.app.repo.blobs(round.after, files);
538
+ [before, after] = await Promise.all([this.app.repo.blobsAsync(round.before, files), this.app.repo.blobsAsync(round.after, files)]);
525
539
  for (const [file, group] of Object.entries(groups)) {
526
540
  const reverted = revertUnits(before[file], after[file], group),
527
541
  target = safePath(task.worktree, file),
528
542
  current = readBytes(target),
529
- [merged, conflict] = merge3(current, after[file], reverted);
543
+ [merged, conflict] = await merge3Async(current, after[file], reverted);
530
544
  if (conflict) kept.push(file);
531
545
  else if (!same(merged, current)) writes.push([target, current, merged]);
532
546
  }
@@ -540,7 +554,7 @@ export class Review {
540
554
  { not_reverted: kept },
541
555
  writes,
542
556
  );
543
- this.carryOut(identity, "apply");
557
+ await this.carryOut(identity, "apply");
544
558
  }
545
559
  record(
546
560
  identity: string,
@@ -601,7 +615,7 @@ export class Review {
601
615
  if (!same(current.get(row.path)!, row.target))
602
616
  writeOrDelete(row.path, row.target);
603
617
  }
604
- carryOut(identity: string, phase: Write["phase"]) {
618
+ private async carryOut(identity: string, phase: Write["phase"]) {
605
619
  try {
606
620
  this.perform(identity, phase);
607
621
  } catch (error) {
@@ -629,9 +643,9 @@ export class Review {
629
643
  });
630
644
  throw error;
631
645
  }
632
- this.complete(identity);
646
+ await this.complete(identity);
633
647
  }
634
- complete(identity: string) {
648
+ private async complete(identity: string) {
635
649
  const app = this.app,
636
650
  action = app.store.one<Action>(
637
651
  "SELECT * FROM actions WHERE id=?",
@@ -674,7 +688,7 @@ export class Review {
674
688
  }
675
689
  });
676
690
  if (action.kind === "accept")
677
- app.repo.updateRef(
691
+ await app.repo.updateRefAsync(
678
692
  `refs/latex-review/${action.task}/accepted`,
679
693
  accepted!,
680
694
  );
@@ -713,7 +727,11 @@ export class Review {
713
727
  );
714
728
  return this.pageState(name);
715
729
  }
730
+ /** Undoes the latest decision, after every other write of the engine. */
716
731
  undo(name: string, identity: string) {
732
+ return this.app.exclusive(() => this.undoNow(name, identity));
733
+ }
734
+ private async undoNow(name: string, identity: string) {
717
735
  this.app.delivery.assertReady(name);
718
736
  const app = this.app,
719
737
  task = app.tasks.get(name),
@@ -734,7 +752,7 @@ export class Review {
734
752
  identity,
735
753
  )) {
736
754
  const current = readBytes(write.path),
737
- [restored, conflict] = merge3(current, write.target, write.expected);
755
+ [restored, conflict] = await merge3Async(current, write.target, write.expected);
738
756
  if (conflict) conflicts.push(write.path);
739
757
  else if (!same(restored, current))
740
758
  restores.push([write.path, current, restored]);
@@ -757,15 +775,16 @@ export class Review {
757
775
  target,
758
776
  );
759
777
  });
760
- this.carryOut(identity, "undo");
778
+ await this.carryOut(identity, "undo");
761
779
  return { ...this.pageState(name), restored: action.changes };
762
780
  }
763
- recover() {
781
+ // At start, before any other write.
782
+ async recover() {
764
783
  for (const action of this.app.store.all<Action>(
765
784
  "SELECT * FROM actions WHERE status IN ('applying','undoing') ORDER BY seq",
766
785
  )) {
767
786
  try {
768
- this.carryOut(
787
+ await this.carryOut(
769
788
  action.id,
770
789
  action.status === "applying" ? "apply" : "undo",
771
790
  );