papilot 0.7.0 → 0.8.1
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/README.md +53 -100
- package/README.zh-CN.md +52 -97
- package/bin/doctor.mjs +55 -6
- package/bin/installation.d.mts +2 -0
- package/bin/installation.mjs +7 -1
- package/dist/assets/{_virtual_papilot-licenses-BWIsfrQh.js → _virtual_papilot-licenses-CMfCtEYQ.js} +54 -3
- package/dist/assets/index-BlLyIqT9.js +265 -0
- package/dist/assets/index-DVZtyZvu.css +1 -0
- package/dist/assets/latex-BarqJFtQ.svg +1 -0
- package/dist/assets/{math-BsEl3u6P.js → math-Bu3xEyrC.js} +1 -1
- package/dist/assets/pdf-view-PbkCuTZ-.js +598 -0
- package/dist/index.html +2 -2
- package/examples/papilot-paper/main.tex +1 -1
- package/examples/papilot-paper/preview/main.pdf +0 -0
- package/examples/papilot-paper/preview/main.synctex.gz +0 -0
- package/examples/papilot-paper/sections/02-using.tex +3 -1
- package/examples/papilot-paper/sections/09-agents.tex +8 -5
- package/package.json +18 -8
- package/skills/ai-review.md +18 -0
- package/skills/latex-warnings.md +1 -1
- package/src/conversation.ts +6 -0
- package/src/server/acp.ts +387 -0
- package/src/server/agents.ts +220 -43
- package/src/server/ai-review-routes.ts +72 -0
- package/src/server/ai-review.ts +38 -0
- package/src/server/app.ts +56 -14
- package/src/server/build-problems.ts +329 -0
- package/src/server/changes.ts +25 -2
- package/src/server/codex.ts +2 -1
- package/src/server/cursor.ts +153 -0
- package/src/server/desktop-demo.ts +38 -0
- package/src/server/latex-installation.ts +43 -0
- package/src/server/manuscript.ts +183 -23
- package/src/server/paths.ts +1 -1
- package/src/server/reconcile.ts +20 -0
- package/src/server/references.ts +70 -0
- package/src/server/review.ts +6 -5
- package/src/server/skills.ts +5 -8
- package/src/server/start.ts +19 -4
- package/src/server/store.ts +20 -2
- package/src/server/update-install.ts +53 -2
- package/src/server/update-worker.ts +6 -6
- package/src/server/updates.ts +49 -14
- package/src/server/workspace.ts +236 -41
- package/src/shared.ts +88 -9
- package/src/skill-icons.ts +18 -0
- package/src/skill-metadata.ts +45 -0
- package/vendor/latex-review/dist/web/assets/index-CXdnUjct.js +19 -0
- package/vendor/latex-review/dist/web/assets/index-DEz-LsGl.css +1 -0
- package/vendor/latex-review/dist/web/index.html +2 -2
- package/vendor/latex-review/src/core/bib.ts +692 -0
- package/vendor/latex-review/src/core/changes.ts +39 -0
- package/vendor/latex-review/src/core/current-commit.ts +104 -0
- package/vendor/latex-review/src/core/delivery.ts +62 -16
- package/vendor/latex-review/src/core/git.ts +2 -1
- package/vendor/latex-review/src/core/publish.ts +7 -1
- package/vendor/latex-review/src/core/reconcile.ts +101 -0
- package/vendor/latex-review/src/core/review.ts +6 -2
- package/vendor/latex-review/src/core/store.ts +1 -0
- package/vendor/latex-review/src/core/tasks.ts +1 -1
- package/vendor/latex-review/src/core/verify.ts +784 -0
- package/vendor/latex-review/src/core/view.ts +204 -0
- package/vendor/latex-review/src/render/round.ts +11 -0
- package/vendor/latex-review/src/server.ts +77 -1
- package/vendor/latex-review/src/types.ts +130 -2
- package/vendor/latex-review/src/web/effect-preferences.ts +10 -0
- package/vendor/latex-review/src/web/references.tsx +499 -0
- package/vendor/latex-review/src/web/review.tsx +263 -133
- package/vendor/latex-review/src/web/style.css +285 -0
- package/vendor/latex-review/src/web/zh-CN.ts +79 -2
- package/dist/assets/index-CIJEYlP7.js +0 -252
- package/dist/assets/index-hOJEruFc.css +0 -1
- package/dist/assets/pdf-view-Cz9_vK5U.js +0 -598
- package/vendor/latex-review/dist/web/assets/index-Be9CtgcZ.css +0 -1
- package/vendor/latex-review/dist/web/assets/index-TvMWVSaw.js +0 -19
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { Repo, within } from "./git.ts";
|
|
2
2
|
import { groups, opcodes } from "./diff.ts";
|
|
3
|
+
import { entrySpans } from "./bib.ts";
|
|
3
4
|
import type { Scope, Unit, Op } from "../types.ts";
|
|
4
5
|
export const EXCERPT = 6000;
|
|
5
6
|
export function textOf(data: Buffer | null): string | null {
|
|
@@ -34,6 +35,8 @@ export function diffUnits(
|
|
|
34
35
|
const left = binary ? [] : splitLines(a!),
|
|
35
36
|
right = binary ? [] : splitLines(b!);
|
|
36
37
|
let hunks = binary ? [] : groups(opcodes(left, right));
|
|
38
|
+
if (!binary && file.endsWith(".bib"))
|
|
39
|
+
hunks = byEntry(hunks, entrySpans(a!), entrySpans(b!));
|
|
37
40
|
const whole =
|
|
38
41
|
binary || status !== "modified" || isFigure(file) || !hunks.length;
|
|
39
42
|
if (whole) hunks = [[["replace", 0, left.length, 0, right.length]]];
|
|
@@ -61,6 +64,42 @@ export function diffUnits(
|
|
|
61
64
|
}
|
|
62
65
|
return units;
|
|
63
66
|
}
|
|
67
|
+
// A bibliography entry is decided whole: hunks that touch the same entry,
|
|
68
|
+
// before or after, or the same key on either side, become one change, so
|
|
69
|
+
// accepting part of an entry cannot mix its old and new fields.
|
|
70
|
+
export function byEntry(
|
|
71
|
+
hunks: Op[][],
|
|
72
|
+
before: { key: string; span: [number, number] }[],
|
|
73
|
+
after: { key: string; span: [number, number] }[],
|
|
74
|
+
) {
|
|
75
|
+
// An empty range touches an entry it falls strictly inside.
|
|
76
|
+
const touches = ([a, b]: [number, number], [s, e]: [number, number]) =>
|
|
77
|
+
a === b ? s < a && a < e : a < e && s < b;
|
|
78
|
+
const root = hunks.map((_, i) => i),
|
|
79
|
+
find = (i: number): number => (root[i] === i ? i : (root[i] = find(root[i]))),
|
|
80
|
+
owner = new Map<string, number>();
|
|
81
|
+
hunks.forEach((hunk, i) => {
|
|
82
|
+
const old: [number, number] = [hunk[0][1], hunk.at(-1)![2]],
|
|
83
|
+
next: [number, number] = [hunk[0][3], hunk.at(-1)![4]],
|
|
84
|
+
keys = [
|
|
85
|
+
...before.filter((e) => touches(old, e.span)).map((e) => e.key),
|
|
86
|
+
...after.filter((e) => touches(next, e.span)).map((e) => e.key),
|
|
87
|
+
];
|
|
88
|
+
for (const key of keys) {
|
|
89
|
+
const j = owner.get(key);
|
|
90
|
+
if (j === undefined) owner.set(key, i);
|
|
91
|
+
else root[find(i)] = find(j);
|
|
92
|
+
}
|
|
93
|
+
});
|
|
94
|
+
const merged = new Map<number, Op[]>();
|
|
95
|
+
hunks.forEach((hunk, i) => {
|
|
96
|
+
const r = find(i);
|
|
97
|
+
merged.set(r, [...(merged.get(r) ?? []), ...hunk]);
|
|
98
|
+
});
|
|
99
|
+
return [...merged.values()].map((ops) =>
|
|
100
|
+
ops.sort((p, q) => p[1] - q[1] || p[3] - q[3]),
|
|
101
|
+
);
|
|
102
|
+
}
|
|
64
103
|
function edits(units: Unit[], index: 1 | 3): Op[] {
|
|
65
104
|
return units.flatMap((u) => u.ops).sort((a, b) => b[index] - a[index]);
|
|
66
105
|
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import { createHash } from "node:crypto";
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import type { Repo } from "./git.ts";
|
|
6
|
+
import { merge3 } from "./git.ts";
|
|
7
|
+
import type { Delivery } from "../types.ts";
|
|
8
|
+
|
|
9
|
+
export interface CombinedCommitReview { token: string; files: string[]; diff: string }
|
|
10
|
+
export class CommitNeedsReview extends Error {
|
|
11
|
+
constructor(public review: CombinedCommitReview) { super("Earlier edits to the same passages need to be included. Review the combined commit or apply without committing."); }
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
// Project only the task delta onto HEAD. The worktree and staging area are
|
|
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 });
|
|
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);
|
|
21
|
+
const committed: Record<string, Buffer | null> = {}, nextIndex: Record<string, Buffer | null> = {};
|
|
22
|
+
const dependencies: string[] = [];
|
|
23
|
+
for (const file of files) {
|
|
24
|
+
let [content, conflict] = merge3(head[file], base[file], accepted[file]);
|
|
25
|
+
if (conflict) {
|
|
26
|
+
// Include only the overlapping earlier passages, never the whole dirty
|
|
27
|
+
// file. This is a proposal until the author confirms its exact diff.
|
|
28
|
+
[content, conflict] = merge3(accepted[file], base[file], head[file], true);
|
|
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
|
+
dependencies.push(file);
|
|
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);
|
|
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
|
+
committed[file] = content;
|
|
37
|
+
nextIndex[file] = combined;
|
|
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.");
|
|
41
|
+
if (dependencies.length || confirmation) {
|
|
42
|
+
const token = createHash("sha256").update(JSON.stringify([ref, parent, index, before, data.accepted, tree, data.writes])).digest("hex");
|
|
43
|
+
if (confirmation !== token) {
|
|
44
|
+
const diff = repo.text(["diff", "--no-ext-diff", "--no-textconv", parent, tree, "--"]);
|
|
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
|
+
throw new CommitNeedsReview({ token, files: dependencies, diff });
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
data.branch = ref.slice("refs/heads/".length);
|
|
50
|
+
data.parent = parent;
|
|
51
|
+
data.indexBefore = index;
|
|
52
|
+
data.indexAfter = repo.treeWith(index, nextIndex);
|
|
53
|
+
data.commit = repo.text(["commit-tree", tree, "-p", parent, "-m", message.trim()]);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Hold Git's index lock while checking and installing the recorded result.
|
|
57
|
+
// CAS on the branch prevents updating the wrong or concurrently moved HEAD.
|
|
58
|
+
// A retry recognizes our commit and completes its files/index without a
|
|
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"]));
|
|
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); }
|
|
65
|
+
catch (error) {
|
|
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
|
+
throw error;
|
|
68
|
+
}
|
|
69
|
+
let temp: string | undefined;
|
|
70
|
+
try {
|
|
71
|
+
temp = fs.mkdtempSync(path.join(os.tmpdir(), "papilot-task-index-"));
|
|
72
|
+
const ref = `refs/heads/${data.branch}`;
|
|
73
|
+
if (repo.text(["symbolic-ref", "-q", "HEAD"], { check: false }) !== ref)
|
|
74
|
+
throw new Error("The current branch changed. Return to the original branch before retrying this task commit.");
|
|
75
|
+
const head = repo.rev("HEAD");
|
|
76
|
+
if (head !== data.parent && head !== data.commit)
|
|
77
|
+
throw new Error("The current branch moved during this task commit. Nothing else was changed; inspect the Git history before retrying.");
|
|
78
|
+
const target = path.join(temp, "index");
|
|
79
|
+
fs.copyFileSync(index, target);
|
|
80
|
+
const env = { ...process.env, GIT_INDEX_FILE: target };
|
|
81
|
+
const currentIndex = repo.text(["write-tree"], { env });
|
|
82
|
+
if (currentIndex !== data.indexBefore && currentIndex !== data.indexAfter)
|
|
83
|
+
throw new Error("The staged changes changed during this task commit. Nothing else was changed; restore the previous staging selection before retrying.");
|
|
84
|
+
validateFiles();
|
|
85
|
+
if (currentIndex !== data.indexAfter) {
|
|
86
|
+
const changed = repo.changedFiles(data.indexBefore!, data.indexAfter!);
|
|
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 });
|
|
91
|
+
}
|
|
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!]);
|
|
97
|
+
writeFiles();
|
|
98
|
+
fs.renameSync(lock, index);
|
|
99
|
+
} finally {
|
|
100
|
+
fs.closeSync(fd);
|
|
101
|
+
fs.rmSync(lock, { force: true });
|
|
102
|
+
if (temp) fs.rmSync(temp, { recursive: true, force: true });
|
|
103
|
+
}
|
|
104
|
+
}
|
|
@@ -4,8 +4,11 @@ import type { App } from "../server.ts";
|
|
|
4
4
|
import type { Delivery } from "../types.ts";
|
|
5
5
|
import { merge3 } from "./git.ts";
|
|
6
6
|
import { readBytes, same, writeOrDelete } from "./files.ts";
|
|
7
|
+
import { planCurrentCommit, finishCurrentCommit, CommitNeedsReview } from "./current-commit.ts";
|
|
7
8
|
import { safePath } from "./review.ts";
|
|
8
9
|
|
|
10
|
+
class DeliveryConflict extends Error {}
|
|
11
|
+
|
|
9
12
|
const encode = (data: Buffer | null) => data?.toString("base64") ?? null;
|
|
10
13
|
const decode = (data: string | null) => data === null ? null : Buffer.from(data, "base64");
|
|
11
14
|
|
|
@@ -24,16 +27,50 @@ export class Deliveries {
|
|
|
24
27
|
const last = this.get(name);
|
|
25
28
|
if (last && !last.done) throw new Error("Finish the pending delivery before changing this review. Retry the same destination.");
|
|
26
29
|
}
|
|
27
|
-
|
|
30
|
+
apply(name: string, keepCurrent = false, mode?: "workspace" | "commit", message?: string, confirmation?: string) {
|
|
31
|
+
const active = this.app.reconciliation.get(name);
|
|
32
|
+
mode ??= active?.intent?.mode ?? "workspace";
|
|
33
|
+
message ??= active?.intent?.message ?? "";
|
|
34
|
+
if (mode !== "workspace" && mode !== "commit") throw new Error("Choose a task commit or apply without committing.");
|
|
35
|
+
if (mode === "commit" && (!message.trim() || message.length > 5000)) throw new Error("Write a commit message (at most 5,000 characters).");
|
|
36
|
+
const last = this.get(name);
|
|
37
|
+
if (last && !last.done) {
|
|
38
|
+
const delivery = this.finish(name, last);
|
|
39
|
+
if (last.mode !== "branch") return { delivery };
|
|
40
|
+
}
|
|
41
|
+
if (keepCurrent) {
|
|
42
|
+
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)
|
|
44
|
+
throw new Error("Review the merge suggestion before applying it, or ask the agent to keep the latest wording.");
|
|
45
|
+
}
|
|
46
|
+
if (active) this.app.reconciliation.intent(name, { mode, message });
|
|
47
|
+
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) }; }
|
|
50
|
+
catch (error) {
|
|
51
|
+
if (error instanceof CommitNeedsReview) return { commitReview: error.review };
|
|
52
|
+
if (!(error instanceof DeliveryConflict)) throw error;
|
|
53
|
+
const reconciliation = this.app.reconciliation.start(name, keepCurrent, { mode, message });
|
|
54
|
+
if (reconciliation) return { reconciliation };
|
|
55
|
+
return { delivery: this.deliver(name, mode, message, keepCurrent, confirmation) };
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
deliver(name: string, mode: Delivery["mode"], message = "", keepCurrent = false, confirmation?: string) {
|
|
28
59
|
const app = this.app, task = app.tasks.get(name), last = this.get(name);
|
|
29
60
|
if (!task.deferred) throw new Error("This task already applies accepted changes to the manuscript.");
|
|
30
|
-
if (mode !== "workspace" && mode !== "branch") throw new Error("Choose a working directory or task branch.");
|
|
31
|
-
|
|
32
|
-
if (
|
|
61
|
+
if (mode !== "workspace" && mode !== "commit" && mode !== "branch") throw new Error("Choose a working directory or task branch.");
|
|
62
|
+
const reconciliation = app.reconciliation.get(name);
|
|
63
|
+
if (reconciliation && (!reconciliation.ready || mode === "branch" || (!keepCurrent && (app.review.latestRound(name)?.number ?? 0) <= reconciliation.round)))
|
|
64
|
+
throw new Error("Review the merge suggestion before applying it to the manuscript.");
|
|
65
|
+
// Older task-branch commits remain available, but can now be applied
|
|
66
|
+
// through the same manuscript action. Never move or merge those branches.
|
|
67
|
+
if (last && last.mode !== mode && (!last.done || mode === "branch"))
|
|
68
|
+
throw new Error("Finish the pending delivery before applying to the manuscript.");
|
|
69
|
+
if (last && last.mode === mode && (!last.done || (!reconciliation && last.accepted === task.accepted))) return this.finish(name, last);
|
|
33
70
|
if (app.publisher.busy(name) || app.review.counts(name).pending) throw new Error("Finish reviewing every change before choosing a destination.");
|
|
34
|
-
const before =
|
|
71
|
+
const before = reconciliation?.base ?? (last && last.mode !== "branch" ? last.accepted : task.base);
|
|
35
72
|
const files = app.repo.changedFiles(before, task.accepted);
|
|
36
|
-
if (!files.length) throw new Error("This task has no accepted changes left to deliver.");
|
|
73
|
+
if (!files.length && !reconciliation) throw new Error("This task has no accepted changes left to deliver.");
|
|
37
74
|
const base = app.repo.blobs(before, files), accepted = app.repo.blobs(task.accepted, files);
|
|
38
75
|
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: [] };
|
|
39
76
|
const contents: Record<string, Buffer | null> = {};
|
|
@@ -55,14 +92,16 @@ export class Deliveries {
|
|
|
55
92
|
}
|
|
56
93
|
for (const file of files) {
|
|
57
94
|
const [merged, conflict] = merge3(current[file], base[file], accepted[file]);
|
|
58
|
-
if (conflict) throw new
|
|
95
|
+
if (conflict) throw new DeliveryConflict(`Cannot deliver ${file}: it conflicts with the destination. Nothing was written. Revise the task and try again.`);
|
|
59
96
|
contents[file] = merged;
|
|
60
|
-
if (mode
|
|
97
|
+
if (mode !== "branch" && !same(current[file], merged)) data.writes.push({ file, before: encode(current[file]), after: encode(merged) });
|
|
61
98
|
}
|
|
62
99
|
if (mode === "branch") {
|
|
63
100
|
const tree = app.repo.treeWith(data.parent!, contents);
|
|
64
101
|
data.commit = app.repo.text(["commit-tree", tree, "-p", data.parent!, "-m", message.trim()]);
|
|
65
102
|
}
|
|
103
|
+
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);
|
|
66
105
|
this.save(name, data);
|
|
67
106
|
return this.finish(name, data);
|
|
68
107
|
}
|
|
@@ -75,7 +114,7 @@ export class Deliveries {
|
|
|
75
114
|
throw new Error("Finish or abort the merge before applying changes.");
|
|
76
115
|
}
|
|
77
116
|
finish(name: string, data: Delivery) {
|
|
78
|
-
if (data.done) return this.result(data);
|
|
117
|
+
if (data.done) { if (data.mode !== "branch") this.app.reconciliation.clear(name); return this.result(data); }
|
|
79
118
|
const app = this.app;
|
|
80
119
|
if (data.mode === "branch") {
|
|
81
120
|
const ref = `refs/heads/${data.branch}`;
|
|
@@ -87,14 +126,19 @@ export class Deliveries {
|
|
|
87
126
|
} else {
|
|
88
127
|
this.assertNoMerge();
|
|
89
128
|
const writes = data.writes.map(write => ({ ...write, target: safePath(app.root, write.file) }));
|
|
90
|
-
|
|
91
|
-
const
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
129
|
+
const validate = () => {
|
|
130
|
+
for (const write of writes) {
|
|
131
|
+
const current = readBytes(write.target);
|
|
132
|
+
if (!same(current, decode(write.before)) && !same(current, decode(write.after)))
|
|
133
|
+
throw new Error(`Cannot finish delivery: ${write.file} changed. Restore it to its state before delivery and retry.`);
|
|
134
|
+
}
|
|
135
|
+
};
|
|
136
|
+
const applyFiles = () => { for (const write of writes) writeOrDelete(write.target, decode(write.after)); };
|
|
137
|
+
if (data.mode === "commit") finishCurrentCommit(app.repo, data, validate, applyFiles);
|
|
138
|
+
else { validate(); applyFiles(); }
|
|
96
139
|
}
|
|
97
140
|
this.save(name, { ...data, done: true, writes: [] });
|
|
141
|
+
if (data.mode !== "branch") this.app.reconciliation.clear(name);
|
|
98
142
|
return this.result(data);
|
|
99
143
|
}
|
|
100
144
|
result(data: Delivery) {
|
|
@@ -103,7 +147,9 @@ export class Deliveries {
|
|
|
103
147
|
}
|
|
104
148
|
recover() {
|
|
105
149
|
for (const row of this.app.store.all<{ task: string; data: Delivery }>("SELECT * FROM deliveries")) {
|
|
106
|
-
if (
|
|
150
|
+
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
|
+
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. */ }
|
|
107
153
|
}
|
|
108
154
|
}
|
|
109
155
|
}
|
|
@@ -297,6 +297,7 @@ export function merge3(
|
|
|
297
297
|
ours: Buffer | null,
|
|
298
298
|
base: Buffer | null,
|
|
299
299
|
theirs: Buffer | null,
|
|
300
|
+
favorCurrent = false,
|
|
300
301
|
): [Buffer | null, boolean] {
|
|
301
302
|
if (same(ours, base) || same(ours, theirs)) return [theirs, false];
|
|
302
303
|
if (same(theirs, base)) return [ours, false];
|
|
@@ -312,7 +313,7 @@ export function merge3(
|
|
|
312
313
|
path.join(folder, name),
|
|
313
314
|
);
|
|
314
315
|
[ours, base, theirs].forEach((data, i) => fs.writeFileSync(files[i], data));
|
|
315
|
-
const result = run(gitBinary(), ["merge-file", "-p", ...files], {
|
|
316
|
+
const result = run(gitBinary(), ["merge-file", "-p", ...(favorCurrent ? ["--ours"] : []), ...files], {
|
|
316
317
|
check: false,
|
|
317
318
|
});
|
|
318
319
|
return [result.stdout, result.status !== 0];
|
|
@@ -321,6 +321,7 @@ export class Publisher {
|
|
|
321
321
|
const app = this.app,
|
|
322
322
|
name = job.task,
|
|
323
323
|
project = app.project;
|
|
324
|
+
if (app.reconciliation.get(name)?.ready === false) throw new Error("Retry applying to finish preparing the merge review.");
|
|
324
325
|
let task = app.tasks.get(name);
|
|
325
326
|
if (!fs.existsSync(task.worktree))
|
|
326
327
|
throw new PublishError(
|
|
@@ -335,13 +336,18 @@ export class Publisher {
|
|
|
335
336
|
latest = app.review.latestRound(name),
|
|
336
337
|
decided = app.review.lastChange(name),
|
|
337
338
|
violations = scopeViolations(app.repo, task.base, after, task.scope);
|
|
339
|
+
const reconciliation = app.reconciliation.get(name);
|
|
340
|
+
if (reconciliation) {
|
|
341
|
+
for (const file of app.repo.changedFiles(task.base, after))
|
|
342
|
+
if (!reconciliation.files.includes(file) && !violations.includes(file)) violations.push(file);
|
|
343
|
+
}
|
|
338
344
|
if (violations.length)
|
|
339
345
|
throw new PublishError(
|
|
340
346
|
"Some edits are outside the task scope: " +
|
|
341
347
|
violations.slice(0, 10).join(", "),
|
|
342
348
|
[
|
|
343
349
|
"Allowed: " +
|
|
344
|
-
(task.scope.sections.length
|
|
350
|
+
(reconciliation ? reconciliation.files : task.scope.sections.length
|
|
345
351
|
? task.scope.sections.map((s) => `${s.number} ${s.title}`)
|
|
346
352
|
: task.scope.paths
|
|
347
353
|
).join(", "),
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import type { App } from "../server.ts";
|
|
3
|
+
import { merge3 } from "./git.ts";
|
|
4
|
+
import { readBytes, same, writeOrDelete } from "./files.ts";
|
|
5
|
+
import { safePath } from "./review.ts";
|
|
6
|
+
import { GENERATED, revisionReader } from "./tasks.ts";
|
|
7
|
+
import { buildOutline, resolveSections, selectedRanges } from "./outline.ts";
|
|
8
|
+
|
|
9
|
+
export interface Reconciliation {
|
|
10
|
+
id: string;
|
|
11
|
+
round: number;
|
|
12
|
+
files: string[];
|
|
13
|
+
sourceBase: string;
|
|
14
|
+
sourceAccepted: string;
|
|
15
|
+
base: string;
|
|
16
|
+
reviewBase: string;
|
|
17
|
+
worktreeBefore: string;
|
|
18
|
+
ready: boolean;
|
|
19
|
+
intent?: { mode: "workspace" | "commit"; message: string };
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// A conflict is another proposal in the same task. The manuscript and its
|
|
23
|
+
// index stay untouched; only the private proposal is rebased for PDF review.
|
|
24
|
+
export class Reconciliations {
|
|
25
|
+
constructor(public app: App) {}
|
|
26
|
+
get(name: string) {
|
|
27
|
+
return this.app.store.one<{ data: Reconciliation }>("SELECT data FROM reconciliations WHERE task=?", name)?.data;
|
|
28
|
+
}
|
|
29
|
+
intent(name: string, intent: NonNullable<Reconciliation["intent"]>) {
|
|
30
|
+
const data = { ...this.get(name)!, intent };
|
|
31
|
+
this.app.store.execute("UPDATE reconciliations SET data=? WHERE task=?", JSON.stringify(data), name);
|
|
32
|
+
return data;
|
|
33
|
+
}
|
|
34
|
+
clear(name: string) {
|
|
35
|
+
this.app.store.execute("DELETE FROM reconciliations WHERE task=?", name);
|
|
36
|
+
}
|
|
37
|
+
start(name: string, force = false, intent?: Reconciliation["intent"]) {
|
|
38
|
+
const app = this.app, task = app.tasks.get(name), previous = this.get(name);
|
|
39
|
+
if (previous && (!previous.ready || (!force && (app.review.latestRound(name)?.number ?? 0) <= previous.round)))
|
|
40
|
+
return this.prepare(name, previous);
|
|
41
|
+
const worktreeBefore = app.tasks.snapshot(task);
|
|
42
|
+
if (app.repo.changedFiles(task.accepted, worktreeBefore).length)
|
|
43
|
+
throw new Error("Review the latest task edits before applying the manuscript changes.");
|
|
44
|
+
const delivered = app.delivery.get(name);
|
|
45
|
+
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);
|
|
49
|
+
const files: string[] = [], contents: Record<string, Buffer | null> = {};
|
|
50
|
+
for (const file of changed) {
|
|
51
|
+
const [merged, conflict] = merge3(current[file], old[file], accepted[file]);
|
|
52
|
+
if (!conflict) contents[file] = merged;
|
|
53
|
+
else {
|
|
54
|
+
files.push(file);
|
|
55
|
+
// Keep clean hunks even in a file with conflicts. At overlapping
|
|
56
|
+
// hunks keep the latest manuscript as the baseline for human review.
|
|
57
|
+
const [kept, failed] = merge3(current[file], old[file], accepted[file], true);
|
|
58
|
+
contents[file] = failed ? current[file] : kept;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
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 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
|
+
// Keep the three versions available to the agent and across Git GC.
|
|
66
|
+
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
|
+
app.store.execute("INSERT OR REPLACE INTO reconciliations(task,data) VALUES(?,?)", name, JSON.stringify(data));
|
|
69
|
+
return this.prepare(name, data);
|
|
70
|
+
}
|
|
71
|
+
prepare(name: string, data: Reconciliation) {
|
|
72
|
+
if (data.ready) return data;
|
|
73
|
+
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);
|
|
76
|
+
const writes = files.map(file => ({ file, path: safePath(task.worktree, file) }));
|
|
77
|
+
for (const write of writes) {
|
|
78
|
+
const current = readBytes(write.path);
|
|
79
|
+
if (!same(current, before[write.file]) && !same(current, after[write.file]))
|
|
80
|
+
throw new Error("The task changed while preparing its merge review. Keep those edits and retry after reviewing them.");
|
|
81
|
+
}
|
|
82
|
+
let scope = task.scope;
|
|
83
|
+
if (scope.sections.length) {
|
|
84
|
+
const outline = buildOutline(revisionReader(app.repo, data.reviewBase, app.project.doc_dir), app.project.main);
|
|
85
|
+
const [ranges, sections] = selectedRanges(outline.nodes, resolveSections(outline.nodes, scope.sections.map(section => section.id.replace(/^label:/, ""))));
|
|
86
|
+
scope = { ranges, sections, paths: Object.keys(ranges).sort() };
|
|
87
|
+
}
|
|
88
|
+
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);
|
|
91
|
+
app.store.transaction(() => {
|
|
92
|
+
app.store.execute("UPDATE tasks SET base=?,accepted=?,scope=? WHERE name=?", data.reviewBase, data.reviewBase, JSON.stringify(scope), name);
|
|
93
|
+
app.store.execute("UPDATE reconciliations SET data=? WHERE task=?", JSON.stringify({ ...data, ready: true }), name);
|
|
94
|
+
});
|
|
95
|
+
return { ...data, ready: true };
|
|
96
|
+
}
|
|
97
|
+
recover() {
|
|
98
|
+
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. */ }
|
|
100
|
+
}
|
|
101
|
+
}
|
|
@@ -121,8 +121,8 @@ export class Review {
|
|
|
121
121
|
"SELECT * FROM actions WHERE task=? AND status='applied' ORDER BY seq DESC LIMIT 1",
|
|
122
122
|
name,
|
|
123
123
|
);
|
|
124
|
-
const delivered = this.app.delivery.get(name);
|
|
125
|
-
return action && latest && action.round === latest.number && (!delivered || action.seq > delivered.seq) ? action : null;
|
|
124
|
+
const delivered = this.app.delivery.get(name), reconciliation = this.app.reconciliation.get(name);
|
|
125
|
+
return action && latest && (!reconciliation || action.round > reconciliation.round) && action.round === latest.number && (!delivered || action.seq > delivered.seq) ? action : null;
|
|
126
126
|
}
|
|
127
127
|
summary(name: string): Summary {
|
|
128
128
|
const task = this.app.tasks.get(name),
|
|
@@ -261,6 +261,7 @@ export class Review {
|
|
|
261
261
|
loose: render.loose,
|
|
262
262
|
warnings: round.summary.warnings ?? [],
|
|
263
263
|
files: `/files/${name}/${number}/`,
|
|
264
|
+
...(render.references ? { references: render.references } : {}),
|
|
264
265
|
};
|
|
265
266
|
}
|
|
266
267
|
history(name: string, round: Round): Record<string, History[]> {
|
|
@@ -366,6 +367,9 @@ export class Review {
|
|
|
366
367
|
const task = this.app.tasks.get(name),
|
|
367
368
|
kind = data.action;
|
|
368
369
|
this.app.delivery.assertReady(name);
|
|
370
|
+
const reconciliation = this.app.reconciliation.get(name);
|
|
371
|
+
if (reconciliation && (!reconciliation.ready || data.round <= reconciliation.round))
|
|
372
|
+
throw new Error("Review the new merge suggestion before changing decisions.");
|
|
369
373
|
if (!Object.hasOwn(DECIDED, kind))
|
|
370
374
|
throw new Error("The action must be accept, reject or comment.");
|
|
371
375
|
const identity = data.id || randomUUID();
|
|
@@ -3,6 +3,7 @@ import fs from "node:fs";
|
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
const SCHEMA = `
|
|
5
5
|
CREATE TABLE IF NOT EXISTS tasks(name TEXT PRIMARY KEY,title TEXT NOT NULL,worktree TEXT NOT NULL,base TEXT NOT NULL,accepted TEXT NOT NULL,scope TEXT NOT NULL,created REAL NOT NULL,closed REAL);
|
|
6
|
+
CREATE TABLE IF NOT EXISTS reconciliations(task TEXT PRIMARY KEY,data TEXT NOT NULL);
|
|
6
7
|
CREATE TABLE IF NOT EXISTS deliveries(task TEXT PRIMARY KEY,data TEXT NOT NULL);
|
|
7
8
|
CREATE TABLE IF NOT EXISTS rounds(task TEXT NOT NULL,number INTEGER NOT NULL,before TEXT NOT NULL,after TEXT NOT NULL,units TEXT NOT NULL,summary TEXT NOT NULL,created REAL NOT NULL,PRIMARY KEY(task,number));
|
|
8
9
|
CREATE TABLE IF NOT EXISTS actions(seq INTEGER PRIMARY KEY AUTOINCREMENT,id TEXT NOT NULL UNIQUE,task TEXT NOT NULL,round INTEGER NOT NULL,kind TEXT NOT NULL,changes TEXT NOT NULL,body TEXT NOT NULL DEFAULT '',status TEXT NOT NULL,detail TEXT NOT NULL DEFAULT '{}',created REAL NOT NULL);
|
|
@@ -155,7 +155,7 @@ export class Tasks {
|
|
|
155
155
|
name,
|
|
156
156
|
);
|
|
157
157
|
app.store.execute("DELETE FROM tasks WHERE name=?", name);
|
|
158
|
-
for (const table of ["rounds", "actions", "messages", "jobs", "deliveries"])
|
|
158
|
+
for (const table of ["rounds", "actions", "messages", "jobs", "deliveries", "reconciliations"])
|
|
159
159
|
app.store.execute(`DELETE FROM ${table} WHERE task=?`, name);
|
|
160
160
|
app.store.execute(
|
|
161
161
|
"INSERT INTO tasks(name,title,worktree,base,accepted,scope,created,deferred) VALUES(?,?,?,?,?,?,?,?)",
|