papilot 0.8.6 → 0.8.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/dist/assets/index-BIc9Mnim.css +1 -0
  2. package/dist/assets/index-MvQvvv3I.js +267 -0
  3. package/dist/assets/{math-C7ENJr-a.js → math-CBzEH7Y2.js} +1 -1
  4. package/dist/assets/{pdf-view-B2xQuHmI.js → pdf-view-ByftRBhD.js} +2 -2
  5. package/dist/index.html +2 -2
  6. package/engine/dist/web/assets/{FullPaper-DlgZ1MOz.js → FullPaper-DWwiT43p.js} +2 -2
  7. package/engine/dist/web/assets/{index-BCgVChtV.js → index-DBYJK8Uy.js} +4 -4
  8. package/engine/dist/web/assets/{index-D3_ZFdT8.css → index-DGkq8xQx.css} +1 -1
  9. package/engine/dist/web/index.html +2 -2
  10. package/engine/src/cli.ts +3 -0
  11. package/engine/src/client.ts +24 -7
  12. package/engine/src/core/build.ts +357 -93
  13. package/engine/src/core/changes.ts +39 -10
  14. package/engine/src/core/config.ts +2 -2
  15. package/engine/src/core/current-commit.ts +34 -34
  16. package/engine/src/core/delivery.ts +83 -30
  17. package/engine/src/core/files.ts +19 -0
  18. package/engine/src/core/git.ts +283 -92
  19. package/engine/src/core/process.ts +124 -11
  20. package/engine/src/core/publish.ts +59 -50
  21. package/engine/src/core/reconcile.ts +23 -21
  22. package/engine/src/core/review.ts +43 -24
  23. package/engine/src/core/tasks.ts +85 -28
  24. package/engine/src/core/view.ts +8 -8
  25. package/engine/src/server.ts +62 -6
  26. package/engine/src/types.ts +8 -0
  27. package/engine/src/web/FullPaper.tsx +122 -34
  28. package/engine/src/web/review.tsx +4 -1
  29. package/engine/src/web/style.css +24 -0
  30. package/package.json +1 -1
  31. package/src/server/agents.ts +3 -0
  32. package/src/server/annotated-pdf.ts +392 -0
  33. package/src/server/annotation-placement.ts +18 -3
  34. package/src/server/app.ts +68 -10
  35. package/src/server/changes.ts +12 -7
  36. package/src/server/cursor.ts +1 -0
  37. package/src/server/manuscript.ts +218 -56
  38. package/src/server/pdf.ts +176 -23
  39. package/src/server/references.ts +10 -2
  40. package/src/server/review.ts +6 -2
  41. package/src/server/start.ts +4 -1
  42. package/src/server/workspace.ts +18 -0
  43. package/dist/assets/index-Dm7AYM9e.js +0 -267
  44. package/dist/assets/index-PFQDdu53.css +0 -1
@@ -20,10 +20,27 @@ export function diffUnits(
20
20
  after: string,
21
21
  isFigure: (p: string) => boolean = () => false,
22
22
  ): Unit[] {
23
- const files = repo.changedFiles(before, after),
24
- oldBlobs = repo.blobs(before, files),
25
- newBlobs = repo.blobs(after, files),
26
- units: Unit[] = [];
23
+ const files = repo.changedFiles(before, after);
24
+ return unitsOf(files, repo.blobs(before, files), repo.blobs(after, files), isFigure);
25
+ }
26
+ /** `diffUnits` for servers, which keep answering requests meanwhile. */
27
+ export async function diffUnitsAsync(
28
+ repo: Repo,
29
+ before: string,
30
+ after: string,
31
+ isFigure: (p: string) => boolean = () => false,
32
+ ): Promise<Unit[]> {
33
+ const files = await repo.changedFilesAsync(before, after),
34
+ [old, next] = await Promise.all([repo.blobsAsync(before, files), repo.blobsAsync(after, files)]);
35
+ return unitsOf(files, old, next, isFigure);
36
+ }
37
+ function unitsOf(
38
+ files: string[],
39
+ oldBlobs: Record<string, Buffer | null>,
40
+ newBlobs: Record<string, Buffer | null>,
41
+ isFigure: (p: string) => boolean,
42
+ ): Unit[] {
43
+ const units: Unit[] = [];
27
44
  for (const file of files) {
28
45
  const old = oldBlobs[file],
29
46
  next = newBlobs[file],
@@ -159,15 +176,27 @@ export function scopeViolations(
159
176
  scope: Scope,
160
177
  ) {
161
178
  const files = repo.changedFiles(base, revision);
162
- if (scope.ranges === null)
163
- return files.filter((f) => !within(f, scope.paths));
164
- const old = repo.blobs(base, files),
165
- next = repo.blobs(revision, files),
166
- violations: string[] = [];
179
+ if (scope.ranges === null) return files.filter((f) => !within(f, scope.paths));
180
+ return violationsOf(files, repo.blobs(base, files), repo.blobs(revision, files), scope);
181
+ }
182
+ /** `scopeViolations` for servers, which keep answering requests meanwhile. */
183
+ export async function scopeViolationsAsync(repo: Repo, base: string, revision: string, scope: Scope) {
184
+ const files = await repo.changedFilesAsync(base, revision);
185
+ if (scope.ranges === null) return files.filter((f) => !within(f, scope.paths));
186
+ const [old, next] = await Promise.all([repo.blobsAsync(base, files), repo.blobsAsync(revision, files)]);
187
+ return violationsOf(files, old, next, scope);
188
+ }
189
+ function violationsOf(
190
+ files: string[],
191
+ old: Record<string, Buffer | null>,
192
+ next: Record<string, Buffer | null>,
193
+ scope: Scope,
194
+ ) {
195
+ const violations: string[] = [];
167
196
  for (const file of files) {
168
197
  const a = textOf(old[file]),
169
198
  b = textOf(next[file]),
170
- allowed = scope.ranges[file];
199
+ allowed = scope.ranges?.[file];
171
200
  if (!allowed || old[file] === null || a === null || b === null) {
172
201
  violations.push(file);
173
202
  continue;
@@ -1,6 +1,6 @@
1
1
  import fs from "node:fs";
2
2
  import path from "node:path";
3
- import { Repo, cleanPath, within } from "./git.ts";
3
+ import { Repo, cleanPath, gitDirOf, within } from "./git.ts";
4
4
  export const CONFIG_NAME = ".latex-review.json";
5
5
  export const ENGINES = ["pdflatex", "xelatex", "lualatex"] as const;
6
6
  type Engine = (typeof ENGINES)[number];
@@ -244,7 +244,7 @@ export function loadProject(root: string): Project {
244
244
  throw new Error('"build_workers" must be an integer between 1 and 8.');
245
245
  return {
246
246
  root,
247
- state: path.join(new Repo(root).gitDir(), "latex-review"),
247
+ state: path.join(gitDirOf(root), "latex-review"),
248
248
  main,
249
249
  doc_dir,
250
250
  engine: engine as Engine,
@@ -3,7 +3,7 @@ import { createHash } from "node:crypto";
3
3
  import os from "node:os";
4
4
  import path from "node:path";
5
5
  import type { Repo } from "./git.ts";
6
- import { merge3 } from "./git.ts";
6
+ import { merge3Async } from "./git.ts";
7
7
  import type { Delivery } from "../types.ts";
8
8
 
9
9
  export interface CombinedCommitReview { token: string; files: string[]; diff: string }
@@ -13,35 +13,35 @@ export class CommitNeedsReview extends Error {
13
13
 
14
14
  // Project only the task delta onto HEAD. The worktree and staging area are
15
15
  // separate destinations: neither is used as the content of the commit.
16
- export function planCurrentCommit(repo: Repo, data: Delivery, before: string, files: string[], message: string, confirmation?: string) {
17
- const ref = repo.text(["symbolic-ref", "-q", "HEAD"], { check: false });
16
+ export async function planCurrentCommit(repo: Repo, data: Delivery, before: string, files: string[], message: string, confirmation?: string) {
17
+ const ref = await repo.textAsync(["symbolic-ref", "-q", "HEAD"], { check: false });
18
18
  if (!ref.startsWith("refs/heads/")) throw new Error("Open a branch to commit this task, or apply it without committing.");
19
- const parent = repo.rev("HEAD"), index = repo.text(["write-tree"]);
20
- const base = repo.blobs(before, files), accepted = repo.blobs(data.accepted, files), head = repo.blobs(parent, files), staged = repo.blobs(index, files);
19
+ const [parent, index] = await Promise.all([repo.revAsync("HEAD"), repo.textAsync(["write-tree"])]);
20
+ const [base, accepted, head, staged] = await Promise.all([before, data.accepted, parent, index].map((rev) => repo.blobsAsync(rev, files)));
21
21
  const committed: Record<string, Buffer | null> = {}, nextIndex: Record<string, Buffer | null> = {};
22
22
  const dependencies: string[] = [];
23
23
  for (const file of files) {
24
- let [content, conflict] = merge3(head[file], base[file], accepted[file]);
24
+ let [content, conflict] = await merge3Async(head[file], base[file], accepted[file]);
25
25
  if (conflict) {
26
26
  // Include only the overlapping earlier passages, never the whole dirty
27
27
  // file. This is a proposal until the author confirms its exact diff.
28
- [content, conflict] = merge3(accepted[file], base[file], head[file], true);
28
+ [content, conflict] = await merge3Async(accepted[file], base[file], head[file], true);
29
29
  if (conflict) throw new Error(`The earlier changes in ${file} cannot be included automatically. Apply to workspace to save your reviewed result without a commit.`);
30
30
  dependencies.push(file);
31
31
  }
32
- let [combined, indexConflict] = merge3(staged[file], head[file], content);
33
- if (indexConflict && dependencies.includes(file) && !merge3(staged[file], base[file], accepted[file])[1])
34
- [combined, indexConflict] = merge3(content, head[file], staged[file], true);
32
+ let [combined, indexConflict] = await merge3Async(staged[file], head[file], content);
33
+ if (indexConflict && dependencies.includes(file) && !(await merge3Async(staged[file], base[file], accepted[file]))[1])
34
+ [combined, indexConflict] = await merge3Async(content, head[file], staged[file], true);
35
35
  if (indexConflict) throw new Error(`A different version of ${file} is already selected for another commit. Apply to workspace to save this result without changing that selection.`);
36
36
  committed[file] = content;
37
37
  nextIndex[file] = combined;
38
38
  }
39
- const tree = repo.treeWith(parent, committed);
40
- if (tree === repo.treeId(parent)) throw new Error("These changes are already in the current commit. Apply without committing to finish this task.");
39
+ const tree = await repo.treeWithAsync(parent, committed);
40
+ if (tree === await repo.treeIdAsync(parent)) throw new Error("These changes are already in the current commit. Apply without committing to finish this task.");
41
41
  if (dependencies.length || confirmation) {
42
42
  const token = createHash("sha256").update(JSON.stringify([ref, parent, index, before, data.accepted, tree, data.writes])).digest("hex");
43
43
  if (confirmation !== token) {
44
- const diff = repo.text(["diff", "--no-ext-diff", "--no-textconv", parent, tree, "--"]);
44
+ const diff = await repo.textAsync(["diff", "--no-ext-diff", "--no-textconv", parent, tree, "--"]);
45
45
  if (diff.length > 500000) throw new Error("The combined changes are too large to preview here. Apply to workspace and review them in Git before committing.");
46
46
  throw new CommitNeedsReview({ token, files: dependencies, diff });
47
47
  }
@@ -49,56 +49,56 @@ export function planCurrentCommit(repo: Repo, data: Delivery, before: string, fi
49
49
  data.branch = ref.slice("refs/heads/".length);
50
50
  data.parent = parent;
51
51
  data.indexBefore = index;
52
- data.indexAfter = repo.treeWith(index, nextIndex);
53
- data.commit = repo.text(["commit-tree", tree, "-p", parent, "-m", message.trim()]);
52
+ data.indexAfter = await repo.treeWithAsync(index, nextIndex);
53
+ data.commit = await repo.textAsync(["commit-tree", tree, "-p", parent, "-m", message.trim()]);
54
54
  }
55
55
 
56
56
  // Hold Git's index lock while checking and installing the recorded result.
57
57
  // CAS on the branch prevents updating the wrong or concurrently moved HEAD.
58
58
  // A retry recognizes our commit and completes its files/index without a
59
59
  // duplicate commit. Unrelated staged entries (including their flags) survive.
60
- export function finishCurrentCommit(repo: Repo, data: Delivery, validateFiles: () => void, writeFiles: () => void) {
61
- const index = path.resolve(repo.root, repo.text(["rev-parse", "--git-path", "index"]));
60
+ export async function finishCurrentCommit(repo: Repo, data: Delivery, validateFiles: () => void, writeFiles: () => void) {
61
+ const index = path.resolve(repo.root, await repo.textAsync(["rev-parse", "--git-path", "index"]));
62
62
  const lock = index + ".lock";
63
- let fd: number;
64
- try { fd = fs.openSync(lock, "wx", fs.existsSync(index) ? fs.statSync(index).mode & 0o777 : 0o644); }
63
+ let handle: fs.promises.FileHandle;
64
+ try { handle = await fs.promises.open(lock, "wx", fs.existsSync(index) ? fs.statSync(index).mode & 0o777 : 0o644); }
65
65
  catch (error) {
66
66
  if ((error as NodeJS.ErrnoException).code === "EEXIST") throw new Error("Another Git operation is using the staging area. Wait for it to finish, then retry.");
67
67
  throw error;
68
68
  }
69
69
  let temp: string | undefined;
70
70
  try {
71
- temp = fs.mkdtempSync(path.join(os.tmpdir(), "papilot-task-index-"));
71
+ temp = await fs.promises.mkdtemp(path.join(os.tmpdir(), "papilot-task-index-"));
72
72
  const ref = `refs/heads/${data.branch}`;
73
- if (repo.text(["symbolic-ref", "-q", "HEAD"], { check: false }) !== ref)
73
+ if (await repo.textAsync(["symbolic-ref", "-q", "HEAD"], { check: false }) !== ref)
74
74
  throw new Error("The current branch changed. Return to the original branch before retrying this task commit.");
75
- const head = repo.rev("HEAD");
75
+ const head = await repo.revAsync("HEAD");
76
76
  if (head !== data.parent && head !== data.commit)
77
77
  throw new Error("The current branch moved during this task commit. Nothing else was changed; inspect the Git history before retrying.");
78
78
  const target = path.join(temp, "index");
79
- fs.copyFileSync(index, target);
79
+ await fs.promises.copyFile(index, target);
80
80
  const env = { ...process.env, GIT_INDEX_FILE: target };
81
- const currentIndex = repo.text(["write-tree"], { env });
81
+ const currentIndex = await repo.textAsync(["write-tree"], { env });
82
82
  if (currentIndex !== data.indexBefore && currentIndex !== data.indexAfter)
83
83
  throw new Error("The staged changes changed during this task commit. Nothing else was changed; restore the previous staging selection before retrying.");
84
84
  validateFiles();
85
85
  if (currentIndex !== data.indexAfter) {
86
- const changed = repo.changedFiles(data.indexBefore!, data.indexAfter!);
86
+ const changed = await repo.changedFilesAsync(data.indexBefore!, data.indexAfter!);
87
87
  for (const file of changed) {
88
- const blob = repo.blob(data.indexAfter!, file);
89
- if (blob === null) repo.run(["update-index", "--force-remove", "--", file], { env });
90
- else repo.run(["update-index", "--add", "--cacheinfo", `${repo.mode(data.indexAfter!, file)},${repo.hashObject(blob)},${file}`], { env });
88
+ const blob = await repo.blobAsync(data.indexAfter!, file);
89
+ if (blob === null) await repo.runAsync(["update-index", "--force-remove", "--", file], { env });
90
+ else await repo.runAsync(["update-index", "--add", "--cacheinfo", `${await repo.modeAsync(data.indexAfter!, file)},${await repo.hashObjectAsync(blob)},${file}`], { env });
91
91
  }
92
92
  }
93
- if (repo.text(["write-tree"], { env }) !== data.indexAfter) throw new Error("Could not prepare the task's staging result.");
94
- fs.writeFileSync(fd, fs.readFileSync(target));
95
- fs.fsyncSync(fd);
96
- if (head !== data.commit) repo.run(["update-ref", "-m", "Papilot: commit reviewed task", ref, data.commit!, data.parent!]);
93
+ if (await repo.textAsync(["write-tree"], { env }) !== data.indexAfter) throw new Error("Could not prepare the task's staging result.");
94
+ await handle.writeFile(await fs.promises.readFile(target));
95
+ await handle.sync();
96
+ if (head !== data.commit) await repo.runAsync(["update-ref", "-m", "Papilot: commit reviewed task", ref, data.commit!, data.parent!]);
97
97
  writeFiles();
98
98
  fs.renameSync(lock, index);
99
99
  } finally {
100
- fs.closeSync(fd);
100
+ await handle.close();
101
101
  fs.rmSync(lock, { force: true });
102
- if (temp) fs.rmSync(temp, { recursive: true, force: true });
102
+ if (temp) await fs.promises.rm(temp, { recursive: true, force: true });
103
103
  }
104
104
  }
@@ -2,7 +2,8 @@ import fs from "node:fs";
2
2
  import path from "node:path";
3
3
  import type { App } from "../server.ts";
4
4
  import type { Delivery } from "../types.ts";
5
- import { merge3 } from "./git.ts";
5
+ import { merge3Async } from "./git.ts";
6
+ import { applyUnits } from "./changes.ts";
6
7
  import { readBytes, same, writeOrDelete } from "./files.ts";
7
8
  import { planCurrentCommit, finishCurrentCommit, CommitNeedsReview } from "./current-commit.ts";
8
9
  import { safePath } from "./review.ts";
@@ -23,11 +24,57 @@ export class Deliveries {
23
24
  save(name: string, data: Delivery) {
24
25
  this.app.store.execute("INSERT OR REPLACE INTO deliveries(task,data) VALUES(?,?)", name, JSON.stringify(data));
25
26
  }
27
+ /** What applying this task to the manuscript would write, if every change
28
+ * still pending in its latest round were accepted first: the new contents
29
+ * of each file it changes, merged with the files on disk exactly as an
30
+ * application merges them. Null while the result cannot be foreseen: a
31
+ * delivery or merge review is in progress, or a file would conflict. */
32
+ async preview(name: string): Promise<Record<string, Buffer | null> | null> {
33
+ const app = this.app, task = app.tasks.get(name), last = this.get(name), reconciliation = app.reconciliation.get(name);
34
+ if (!task.deferred || (last && !last.done) || (reconciliation && !reconciliation.ready) || app.publisher.busy(name)) return null;
35
+ const before = reconciliation?.base ?? (last && last.mode !== "branch" ? last.accepted : task.base);
36
+ // The latest round's pending changes, accepted onto the accepted text.
37
+ let accepted = task.accepted;
38
+ const round = app.review.latestRound(name);
39
+ if (round) {
40
+ const decisions = app.review.decisions(name, round), groups: Record<string, typeof round.units> = {};
41
+ for (const unit of round.units) if (decisions[unit.id] === "pending") (groups[unit.file] ??= []).push(unit);
42
+ const files = Object.keys(groups);
43
+ if (files.length) {
44
+ const [old, next, records] = await Promise.all([round.before, round.after, task.accepted].map((rev) => app.repo.blobsAsync(rev, files)));
45
+ const contents: Record<string, Buffer | null> = {};
46
+ for (const file of files) {
47
+ const [merged, conflict] = await merge3Async(records[file], old[file], applyUnits(old[file], next[file], groups[file]));
48
+ if (conflict) return null;
49
+ contents[file] = merged;
50
+ }
51
+ accepted = await app.repo.treeWithAsync(task.accepted, contents);
52
+ }
53
+ }
54
+ const files = await app.repo.changedFilesAsync(before, accepted);
55
+ const [base, result] = await Promise.all([app.repo.blobsAsync(before, files), app.repo.blobsAsync(accepted, files)]);
56
+ const writes: Record<string, Buffer | null> = {};
57
+ for (const file of files) {
58
+ const current = await fs.promises.readFile(safePath(app.root, file)).catch((error) => {
59
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return null;
60
+ throw error;
61
+ });
62
+ const [merged, conflict] = await merge3Async(current, base[file], result[file]);
63
+ if (conflict) return null;
64
+ writes[file] = merged;
65
+ }
66
+ return writes;
67
+ }
26
68
  assertReady(name: string) {
27
69
  const last = this.get(name);
28
70
  if (last && !last.done) throw new Error("Finish the pending delivery before changing this review. Retry the same destination.");
29
71
  }
72
+ /** Applies the task to the manuscript, with or without a commit. Runs
73
+ * after every other write of the review engine, never beside one. */
30
74
  apply(name: string, keepCurrent = false, mode?: "workspace" | "commit", message?: string, confirmation?: string) {
75
+ return this.app.exclusive(() => this.applyNow(name, keepCurrent, mode, message, confirmation));
76
+ }
77
+ private async applyNow(name: string, keepCurrent = false, mode?: "workspace" | "commit", message?: string, confirmation?: string) {
31
78
  const active = this.app.reconciliation.get(name);
32
79
  mode ??= active?.intent?.mode ?? "workspace";
33
80
  message ??= active?.intent?.message ?? "";
@@ -35,27 +82,31 @@ export class Deliveries {
35
82
  if (mode === "commit" && (!message.trim() || message.length > 5000)) throw new Error("Write a commit message (at most 5,000 characters).");
36
83
  const last = this.get(name);
37
84
  if (last && !last.done) {
38
- const delivery = this.finish(name, last);
85
+ const delivery = await this.finish(name, last);
39
86
  if (last.mode !== "branch") return { delivery };
40
87
  }
41
88
  if (keepCurrent) {
42
89
  const task = this.app.tasks.get(name);
43
- if (!active?.ready || task.accepted !== active.reviewBase || this.app.repo.changedFiles(task.accepted, this.app.tasks.snapshot(task)).length)
90
+ if (!active?.ready || task.accepted !== active.reviewBase || (await this.app.repo.changedFilesAsync(task.accepted, await this.app.tasks.snapshotAsync(task))).length)
44
91
  throw new Error("Review the merge suggestion before applying it, or ask the agent to keep the latest wording.");
45
92
  }
46
93
  if (active) this.app.reconciliation.intent(name, { mode, message });
47
94
  if (!keepCurrent && active && (!active.ready || (this.app.review.latestRound(name)?.number ?? 0) <= active.round))
48
- return { reconciliation: this.app.reconciliation.prepare(name, this.app.reconciliation.get(name)!) };
49
- try { return { delivery: this.deliver(name, mode, message, keepCurrent, confirmation) }; }
95
+ return { reconciliation: await this.app.reconciliation.prepare(name, this.app.reconciliation.get(name)!) };
96
+ try { return { delivery: await this.deliverNow(name, mode, message, keepCurrent, confirmation) }; }
50
97
  catch (error) {
51
98
  if (error instanceof CommitNeedsReview) return { commitReview: error.review };
52
99
  if (!(error instanceof DeliveryConflict)) throw error;
53
- const reconciliation = this.app.reconciliation.start(name, keepCurrent, { mode, message });
100
+ const reconciliation = await this.app.reconciliation.start(name, keepCurrent, { mode, message });
54
101
  if (reconciliation) return { reconciliation };
55
- return { delivery: this.deliver(name, mode, message, keepCurrent, confirmation) };
102
+ return { delivery: await this.deliverNow(name, mode, message, keepCurrent, confirmation) };
56
103
  }
57
104
  }
105
+ /** Delivers the accepted changes to the working directory or a task branch. */
58
106
  deliver(name: string, mode: Delivery["mode"], message = "", keepCurrent = false, confirmation?: string) {
107
+ return this.app.exclusive(() => this.deliverNow(name, mode, message, keepCurrent, confirmation));
108
+ }
109
+ private async deliverNow(name: string, mode: Delivery["mode"], message = "", keepCurrent = false, confirmation?: string) {
59
110
  const app = this.app, task = app.tasks.get(name), last = this.get(name);
60
111
  if (!task.deferred) throw new Error("This task already applies accepted changes to the manuscript.");
61
112
  if (mode !== "workspace" && mode !== "commit" && mode !== "branch") throw new Error("Choose a working directory or task branch.");
@@ -69,9 +120,9 @@ export class Deliveries {
69
120
  if (last && last.mode === mode && (!last.done || (!reconciliation && last.accepted === task.accepted))) return this.finish(name, last);
70
121
  if (app.publisher.busy(name) || app.review.counts(name).pending) throw new Error("Finish reviewing every change before choosing a destination.");
71
122
  const before = reconciliation?.base ?? (last && last.mode !== "branch" ? last.accepted : task.base);
72
- const files = app.repo.changedFiles(before, task.accepted);
123
+ const files = await app.repo.changedFilesAsync(before, task.accepted);
73
124
  if (!files.length && !reconciliation) throw new Error("This task has no accepted changes left to deliver.");
74
- const base = app.repo.blobs(before, files), accepted = app.repo.blobs(task.accepted, files);
125
+ const [base, accepted] = await Promise.all([app.repo.blobsAsync(before, files), app.repo.blobsAsync(task.accepted, files)]);
75
126
  const data: Delivery = { mode, accepted: task.accepted, seq: app.store.one<{ seq: number }>("SELECT MAX(seq) AS seq FROM actions WHERE task=?", name)!.seq, done: false, writes: [] };
76
127
  const contents: Record<string, Buffer | null> = {};
77
128
  let current: Record<string, Buffer | null>;
@@ -81,50 +132,51 @@ export class Deliveries {
81
132
  data.previous = last?.commit;
82
133
  // The base snapshot may include the author's uncommitted edits. Apply
83
134
  // only the accepted delta onto its original HEAD, never commit those edits.
84
- data.parent = last?.commit ?? app.repo.rev(`${task.base}^`);
85
- current = app.repo.blobs(data.parent, files);
86
- const ref = app.repo.text(["show-ref", "--verify", "--hash", `refs/heads/${data.branch}`], { check: false });
135
+ data.parent = last?.commit ?? await app.repo.revAsync(`${task.base}^`);
136
+ current = await app.repo.blobsAsync(data.parent, files);
137
+ const ref = await app.repo.textAsync(["show-ref", "--verify", "--hash", `refs/heads/${data.branch}`], { check: false });
87
138
  if (ref !== (data.previous ?? "")) throw new Error("The task branch was changed outside Papilot. Nothing was committed.");
88
- this.assertBranchAvailable(data.branch);
139
+ await this.assertBranchAvailable(data.branch);
89
140
  } else {
90
- this.assertNoMerge();
141
+ await this.assertNoMerge();
91
142
  current = Object.fromEntries(files.map(file => [file, readBytes(safePath(app.root, file))]));
92
143
  }
93
144
  for (const file of files) {
94
- const [merged, conflict] = merge3(current[file], base[file], accepted[file]);
145
+ const [merged, conflict] = await merge3Async(current[file], base[file], accepted[file]);
95
146
  if (conflict) throw new DeliveryConflict(`Cannot deliver ${file}: it conflicts with the destination. Nothing was written. Revise the task and try again.`);
96
147
  contents[file] = merged;
97
148
  if (mode !== "branch" && !same(current[file], merged)) data.writes.push({ file, before: encode(current[file]), after: encode(merged) });
98
149
  }
99
150
  if (mode === "branch") {
100
- const tree = app.repo.treeWith(data.parent!, contents);
101
- data.commit = app.repo.text(["commit-tree", tree, "-p", data.parent!, "-m", message.trim()]);
151
+ const tree = await app.repo.treeWithAsync(data.parent!, contents);
152
+ data.commit = await app.repo.textAsync(["commit-tree", tree, "-p", data.parent!, "-m", message.trim()]);
102
153
  }
103
154
  if (mode === "commit" && (!message.trim() || message.length > 5000)) throw new Error("Write a commit message (at most 5,000 characters).");
104
- if (mode === "commit") planCurrentCommit(app.repo, data, before, files, message, confirmation);
155
+ if (mode === "commit") await planCurrentCommit(app.repo, data, before, files, message, confirmation);
105
156
  this.save(name, data);
106
157
  return this.finish(name, data);
107
158
  }
108
- private assertBranchAvailable(branch: string) {
109
- if (this.app.repo.text(["worktree", "list", "--porcelain"]).split("\n").includes(`branch refs/heads/${branch}`))
159
+ private async assertBranchAvailable(branch: string) {
160
+ if ((await this.app.repo.textAsync(["worktree", "list", "--porcelain"])).split("\n").includes(`branch refs/heads/${branch}`))
110
161
  throw new Error("The task branch is checked out. Switch that worktree to another branch before committing here.");
111
162
  }
112
- private assertNoMerge() {
113
- if (fs.existsSync(path.resolve(this.app.root, this.app.repo.text(["rev-parse", "--git-path", "MERGE_HEAD"]))))
163
+ private async assertNoMerge() {
164
+ if (fs.existsSync(path.resolve(this.app.root, await this.app.repo.textAsync(["rev-parse", "--git-path", "MERGE_HEAD"]))))
114
165
  throw new Error("Finish or abort the merge before applying changes.");
115
166
  }
116
- finish(name: string, data: Delivery) {
167
+ // Completes a saved delivery. Callers hold the engine's write turn.
168
+ private async finish(name: string, data: Delivery) {
117
169
  if (data.done) { if (data.mode !== "branch") this.app.reconciliation.clear(name); return this.result(data); }
118
170
  const app = this.app;
119
171
  if (data.mode === "branch") {
120
172
  const ref = `refs/heads/${data.branch}`;
121
- const current = app.repo.text(["show-ref", "--verify", "--hash", ref], { check: false });
173
+ const current = await app.repo.textAsync(["show-ref", "--verify", "--hash", ref], { check: false });
122
174
  if (current !== data.commit) {
123
- this.assertBranchAvailable(data.branch!);
124
- app.repo.run(["update-ref", ref, data.commit!, data.previous ?? "0".repeat(data.commit!.length)]);
175
+ await this.assertBranchAvailable(data.branch!);
176
+ await app.repo.runAsync(["update-ref", ref, data.commit!, data.previous ?? "0".repeat(data.commit!.length)]);
125
177
  }
126
178
  } else {
127
- this.assertNoMerge();
179
+ await this.assertNoMerge();
128
180
  const writes = data.writes.map(write => ({ ...write, target: safePath(app.root, write.file) }));
129
181
  const validate = () => {
130
182
  for (const write of writes) {
@@ -134,7 +186,7 @@ export class Deliveries {
134
186
  }
135
187
  };
136
188
  const applyFiles = () => { for (const write of writes) writeOrDelete(write.target, decode(write.after)); };
137
- if (data.mode === "commit") finishCurrentCommit(app.repo, data, validate, applyFiles);
189
+ if (data.mode === "commit") await finishCurrentCommit(app.repo, data, validate, applyFiles);
138
190
  else { validate(); applyFiles(); }
139
191
  }
140
192
  this.save(name, { ...data, done: true, writes: [] });
@@ -145,11 +197,12 @@ export class Deliveries {
145
197
  const { mode, accepted, branch, commit } = data;
146
198
  return { mode, accepted, ...(branch ? { branch, commit } : {}) };
147
199
  }
148
- recover() {
200
+ // At start, before any other write.
201
+ async recover() {
149
202
  for (const row of this.app.store.all<{ task: string; data: Delivery }>("SELECT * FROM deliveries")) {
150
203
  if (row.data.done && row.data.mode !== "branch" && this.app.reconciliation.get(row.task) && this.app.tasks.get(row.task).accepted === row.data.accepted)
151
204
  this.app.reconciliation.clear(row.task);
152
- else if (!row.data.done) try { this.finish(row.task, row.data); } catch { /* Retain the journal for an explicit retry. */ }
205
+ else if (!row.data.done) try { await this.finish(row.task, row.data); } catch { /* Retain the journal for an explicit retry. */ }
153
206
  }
154
207
  }
155
208
  }
@@ -29,6 +29,25 @@ export function atomicWrite(
29
29
  fs.rmSync(temporary, { force: true });
30
30
  }
31
31
  }
32
+ /** `atomicWrite` without blocking the event loop on the disk. */
33
+ export async function atomicWriteAsync(file: string, data: string | Uint8Array) {
34
+ await fs.promises.mkdir(path.dirname(file), { recursive: true });
35
+ const temporary = path.join(path.dirname(file), `.${path.basename(file)}.${randomUUID()}.tmp`);
36
+ try {
37
+ const handle = await fs.promises.open(temporary, "w");
38
+ try {
39
+ await handle.writeFile(data);
40
+ await handle.sync();
41
+ } finally {
42
+ await handle.close();
43
+ }
44
+ await fs.promises.rename(temporary, file);
45
+ } finally {
46
+ await fs.promises.rm(temporary, { force: true });
47
+ }
48
+ }
49
+ export const writeJsonAsync = (file: string, value: unknown) =>
50
+ atomicWriteAsync(file, JSON.stringify(value, null, 2) + "\n");
32
51
  export function readBytes(file: string): Buffer | null {
33
52
  try {
34
53
  return fs.readFileSync(file);