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.
- package/dist/assets/index-BIc9Mnim.css +1 -0
- package/dist/assets/index-MvQvvv3I.js +267 -0
- package/dist/assets/{math-DHvVou9A.js → math-CBzEH7Y2.js} +1 -1
- package/dist/assets/{pdf-view-AYTS7kdA.js → pdf-view-ByftRBhD.js} +2 -2
- package/dist/index.html +2 -2
- package/engine/dist/web/assets/{FullPaper-DlgZ1MOz.js → FullPaper-DWwiT43p.js} +2 -2
- package/engine/dist/web/assets/{index-BCgVChtV.js → index-DBYJK8Uy.js} +4 -4
- package/engine/dist/web/assets/{index-D3_ZFdT8.css → index-DGkq8xQx.css} +1 -1
- package/engine/dist/web/index.html +2 -2
- package/engine/src/cli.ts +3 -0
- package/engine/src/client.ts +24 -7
- package/engine/src/core/build.ts +367 -92
- package/engine/src/core/changes.ts +39 -10
- package/engine/src/core/config.ts +2 -2
- package/engine/src/core/current-commit.ts +34 -34
- package/engine/src/core/delivery.ts +83 -30
- package/engine/src/core/files.ts +19 -0
- package/engine/src/core/git.ts +283 -92
- package/engine/src/core/process.ts +124 -11
- package/engine/src/core/publish.ts +59 -50
- package/engine/src/core/reconcile.ts +23 -21
- package/engine/src/core/review.ts +43 -24
- package/engine/src/core/tasks.ts +85 -28
- package/engine/src/core/view.ts +8 -8
- package/engine/src/server.ts +62 -6
- package/engine/src/types.ts +8 -0
- package/engine/src/web/FullPaper.tsx +122 -34
- package/engine/src/web/review.tsx +4 -1
- package/engine/src/web/style.css +24 -0
- package/package.json +1 -1
- package/src/server/agents.ts +3 -0
- package/src/server/annotated-pdf.ts +392 -0
- package/src/server/annotation-placement.ts +18 -3
- package/src/server/app.ts +68 -10
- package/src/server/changes.ts +12 -7
- package/src/server/cursor.ts +1 -0
- package/src/server/manuscript.ts +218 -56
- package/src/server/pdf.ts +176 -23
- package/src/server/references.ts +10 -2
- package/src/server/review.ts +6 -2
- package/src/server/start.ts +4 -1
- package/src/server/update-install.ts +5 -1
- package/src/server/updates.ts +24 -49
- package/src/server/workspace.ts +18 -0
- package/dist/assets/index-DSWizoaI.js +0 -267
- 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<
|
|
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)
|
|
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 =
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
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 {
|
|
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.
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
398
|
+
const files = await app.repo.changedFilesAsync(before, after);
|
|
383
399
|
if (!files.length) return { outcome: "empty", round: null, changes: 0 };
|
|
384
|
-
const units =
|
|
400
|
+
const units = await diffUnitsAsync(app.repo, before, after, (f) =>
|
|
385
401
|
isFigure(project, f),
|
|
386
402
|
);
|
|
387
|
-
this.
|
|
403
|
+
this.phase(job, "build");
|
|
388
404
|
const builder = new Builder(project, app.repo),
|
|
389
|
-
targets = builder.
|
|
390
|
-
|
|
391
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
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 {
|
|
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,
|
|
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
|
-
|
|
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.
|
|
42
|
-
if (app.repo.
|
|
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.
|
|
47
|
-
const changed = app.repo.
|
|
48
|
-
const old
|
|
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] =
|
|
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] =
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
75
|
-
const before = app.repo.
|
|
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(
|
|
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.
|
|
90
|
-
app.repo.
|
|
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
|
-
|
|
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,
|
|
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 =
|
|
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] =
|
|
488
|
-
[recorded, recordedConflict] =
|
|
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.
|
|
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.
|
|
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] =
|
|
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.
|
|
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] =
|
|
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
|
-
|
|
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
|
);
|