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.
- package/dist/assets/index-BIc9Mnim.css +1 -0
- package/dist/assets/index-MvQvvv3I.js +267 -0
- package/dist/assets/{math-C7ENJr-a.js → math-CBzEH7Y2.js} +1 -1
- package/dist/assets/{pdf-view-B2xQuHmI.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 +357 -93
- 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/workspace.ts +18 -0
- package/dist/assets/index-Dm7AYM9e.js +0 -267
- package/dist/assets/index-PFQDdu53.css +0 -1
package/src/server/pdf.ts
CHANGED
|
@@ -1,27 +1,180 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
1
2
|
import path from 'node:path';
|
|
2
3
|
import { pathToFileURL } from 'node:url';
|
|
3
4
|
import { ENGINE } from './paths.ts';
|
|
4
|
-
import {
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
5
|
+
import { taskRoute } from './review.ts';
|
|
6
|
+
|
|
7
|
+
type Connect = (root: string) => Promise<{ get<T>(route: string): Promise<T> }>;
|
|
8
|
+
|
|
9
|
+
type Priority = 'foreground' | 'normal' | 'background';
|
|
10
|
+
const RANK: Record<Priority, number> = { background: 0, normal: 1, foreground: 2 };
|
|
11
|
+
|
|
12
|
+
// The complete PDF of one version of a paper, for the review's full paper. A
|
|
13
|
+
// round may compile only the changed chapters; this compiles them all, once.
|
|
14
|
+
// It can start before anyone asks, as soon as a round's new version exists;
|
|
15
|
+
// whoever then waits for it has it compile first and sees each TeX pass.
|
|
16
|
+
interface Paper {
|
|
17
|
+
priority: Priority;
|
|
18
|
+
following: boolean;
|
|
19
|
+
// The passes shown so far, by number, while the PDF compiled.
|
|
20
|
+
passes: Map<number, string>;
|
|
21
|
+
pdf: string | null;
|
|
22
|
+
failure: Error | null;
|
|
23
|
+
done: Promise<void>;
|
|
24
|
+
changed: Set<() => void>;
|
|
25
|
+
}
|
|
26
|
+
const papers = new Map<string, Paper>();
|
|
27
|
+
// Compiled papers are remembered for the passes they showed, a few at a time.
|
|
28
|
+
const REMEMBERED = 32;
|
|
29
|
+
|
|
30
|
+
async function builder(root: string) {
|
|
31
|
+
const [{ Builder }, { Repo }, { loadProject }] = await Promise.all([
|
|
32
|
+
import(pathToFileURL(path.join(ENGINE, 'src/core/build.ts')).href),
|
|
33
|
+
import(pathToFileURL(path.join(ENGINE, 'src/core/git.ts')).href),
|
|
34
|
+
import(pathToFileURL(path.join(ENGINE, 'src/core/config.ts')).href),
|
|
35
|
+
]);
|
|
36
|
+
return new Builder(loadProject(root), new Repo(root));
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const notFound = () => Object.assign(new Error('Review resource not found.'), { statusCode: 404 });
|
|
40
|
+
const changed = (paper: Paper) => { for (const notify of [...paper.changed]) notify(); };
|
|
41
|
+
|
|
42
|
+
/** Starts compiling the complete PDF of `revision`, or has the build already
|
|
43
|
+
* under way run at `priority` and, with `follow`, show its passes. */
|
|
44
|
+
export function preparePaper(root: string, revision: string, priority: Priority = 'normal', follow = false): Paper {
|
|
45
|
+
const key = JSON.stringify([root, revision]);
|
|
46
|
+
let paper = papers.get(key);
|
|
47
|
+
const compile = (target: Paper, wanted: Priority, watch: boolean) => builder(root).then(b => b.build(revision, ['full'], null, {
|
|
48
|
+
priority: wanted,
|
|
49
|
+
...(watch ? {
|
|
50
|
+
pass: (preview: { pass: number; pdf: string }) => {
|
|
51
|
+
if (target.pdf || target.passes.has(preview.pass)) return;
|
|
52
|
+
target.passes.set(preview.pass, preview.pdf);
|
|
53
|
+
changed(target);
|
|
54
|
+
},
|
|
55
|
+
} : {}),
|
|
56
|
+
})) as Promise<{ pdf: string }>;
|
|
57
|
+
if (paper) {
|
|
58
|
+
papers.delete(key);
|
|
59
|
+
papers.set(key, paper);
|
|
60
|
+
if (!paper.pdf && !paper.failure && (RANK[priority] > RANK[paper.priority] || (follow && !paper.following))) {
|
|
61
|
+
if (RANK[priority] > RANK[paper.priority]) paper.priority = priority;
|
|
62
|
+
paper.following ||= follow;
|
|
63
|
+
void compile(paper, paper.priority, follow).catch(() => undefined);
|
|
64
|
+
}
|
|
65
|
+
return paper;
|
|
66
|
+
}
|
|
67
|
+
const created: Paper = { priority, following: follow, passes: new Map(), pdf: null, failure: null, done: Promise.resolve(), changed: new Set() };
|
|
68
|
+
created.done = compile(created, priority, follow).then(
|
|
69
|
+
result => { created.pdf = result.pdf; },
|
|
70
|
+
error => {
|
|
71
|
+
created.failure = error instanceof Error ? error : new Error(String(error));
|
|
72
|
+
// A later request compiles it again.
|
|
73
|
+
if (papers.get(key) === created) papers.delete(key);
|
|
74
|
+
},
|
|
75
|
+
).finally(() => changed(created));
|
|
76
|
+
papers.set(key, created);
|
|
77
|
+
for (const old of papers.keys()) {
|
|
78
|
+
if (papers.size <= REMEMBERED) break;
|
|
79
|
+
if (papers.get(old)!.pdf) papers.delete(old);
|
|
80
|
+
}
|
|
81
|
+
return created;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// A round does not change; what the full paper needs of it is looked up once.
|
|
85
|
+
interface RoundInfo { before: string; after: string; build?: { key: string; targets: string[] } }
|
|
86
|
+
const rounds = new Map<string, Promise<RoundInfo>>();
|
|
87
|
+
function roundInfo(connect: Connect, root: string, name: string, round: number) {
|
|
88
|
+
const key = JSON.stringify([root, name, round]);
|
|
89
|
+
let found = rounds.get(key);
|
|
90
|
+
if (!found) {
|
|
91
|
+
found = connect(root)
|
|
92
|
+
.then(client => client.get<RoundInfo>(`${taskRoute(name)}/rounds/${round}`))
|
|
93
|
+
.then(detail => {
|
|
94
|
+
if (typeof detail?.before !== 'string' || typeof detail.after !== 'string') throw notFound();
|
|
95
|
+
return { before: detail.before, after: detail.after, ...(detail.build ? { build: detail.build } : {}) };
|
|
96
|
+
});
|
|
97
|
+
rounds.set(key, found);
|
|
98
|
+
found.catch(() => rounds.delete(key));
|
|
99
|
+
if (rounds.size > 256) rounds.delete(rounds.keys().next().value!);
|
|
100
|
+
}
|
|
101
|
+
return found;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// The round's own PDF when it compiled only the changed chapters: their pages
|
|
105
|
+
// with the changes, exactly where its strips come from, at once.
|
|
106
|
+
async function chaptersPdf(root: string, info: RoundInfo) {
|
|
107
|
+
if (!info.build || !/^[0-9a-f]{32}$/.test(info.build.key) || JSON.stringify(info.build.targets) === '["full"]') return null;
|
|
108
|
+
const { loadProject } = await import(pathToFileURL(path.join(ENGINE, 'src/core/config.ts')).href);
|
|
109
|
+
try {
|
|
110
|
+
const result = JSON.parse(await fs.promises.readFile(path.join(loadProject(root).state, 'cache/builds', info.build.key, 'result.json'), 'utf8')) as { pdf?: unknown };
|
|
111
|
+
if (typeof result.pdf !== 'string') return null;
|
|
112
|
+
await fs.promises.access(result.pdf);
|
|
113
|
+
return result.pdf;
|
|
114
|
+
} catch { return null; }
|
|
27
115
|
}
|
|
116
|
+
|
|
117
|
+
async function ready(root: string, revision: string, follow = false) {
|
|
118
|
+
for (let attempt = 0; ; attempt++) {
|
|
119
|
+
const paper = preparePaper(root, revision, 'foreground', follow);
|
|
120
|
+
await paper.done;
|
|
121
|
+
if (paper.failure) throw paper.failure;
|
|
122
|
+
// The build cache may have removed an old PDF meanwhile.
|
|
123
|
+
if (attempt || await fs.promises.access(paper.pdf!).then(() => true, () => false)) return paper.pdf!;
|
|
124
|
+
papers.delete(JSON.stringify([root, revision]));
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** The complete PDF of a round's version, once it has compiled. */
|
|
129
|
+
export async function fullPaperPdf(connect: Connect, root: string, name: string, round: number, side: 'before' | 'after'): Promise<string> {
|
|
130
|
+
return ready(root, (await roundInfo(connect, root, name, round))[side]);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** What the review's full paper shows next, after version `after`: a later
|
|
134
|
+
* version, `null` once the complete PDF is ready, or nothing when `wait`
|
|
135
|
+
* ends first. Until then it shows the round's changed chapters (version 1
|
|
136
|
+
* when the round compiled only those), then each TeX pass of the whole. */
|
|
137
|
+
export async function nextPaperPass(connect: Connect, root: string, name: string, round: number, after: number, wait = 20000): Promise<{ pass?: number | null }> {
|
|
138
|
+
const info = await roundInfo(connect, root, name, round), chapters = await chaptersPdf(root, info), offset = chapters ? 1 : 0;
|
|
139
|
+
if (chapters && after < 1) {
|
|
140
|
+
// A complete PDF that is already there, or nearly, comes first.
|
|
141
|
+
const paper = preparePaper(root, info.after, 'foreground', true);
|
|
142
|
+
await Promise.race([paper.done, new Promise(resolve => setTimeout(resolve, 300))]);
|
|
143
|
+
if (paper.pdf) return { pass: null };
|
|
144
|
+
return { pass: 1 };
|
|
145
|
+
}
|
|
146
|
+
const next = await nextPass(root, info.after, Math.max(0, after - offset), wait);
|
|
147
|
+
return typeof next.pass === 'number' ? { pass: next.pass + offset } : next;
|
|
148
|
+
}
|
|
149
|
+
export async function nextPass(root: string, revision: string, after: number, wait = 20000): Promise<{ pass?: number | null }> {
|
|
150
|
+
const paper = preparePaper(root, revision, 'foreground', true);
|
|
151
|
+
const deadline = Date.now() + wait;
|
|
152
|
+
for (;;) {
|
|
153
|
+
if (paper.failure) throw paper.failure;
|
|
154
|
+
if (paper.pdf) return { pass: null };
|
|
155
|
+
const newest = Math.max(0, ...paper.passes.keys());
|
|
156
|
+
if (newest > after) return { pass: newest };
|
|
157
|
+
const left = deadline - Date.now();
|
|
158
|
+
if (left <= 0) return {};
|
|
159
|
+
await new Promise<void>(resolve => {
|
|
160
|
+
const done = () => { clearTimeout(timer); paper.changed.delete(done); resolve(); };
|
|
161
|
+
const timer = setTimeout(done, left);
|
|
162
|
+
paper.changed.add(done);
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** The PDF of a version that `nextPaperPass` named. */
|
|
168
|
+
export async function paperPassPdf(connect: Connect, root: string, name: string, round: number, pass: number) {
|
|
169
|
+
const info = await roundInfo(connect, root, name, round), chapters = await chaptersPdf(root, info);
|
|
170
|
+
if (chapters && pass === 1) return chapters;
|
|
171
|
+
return passPdf(root, info.after, pass - (chapters ? 1 : 0));
|
|
172
|
+
}
|
|
173
|
+
export function passPdf(root: string, revision: string, pass: number) {
|
|
174
|
+
const pdf = papers.get(JSON.stringify([root, revision]))?.passes.get(pass);
|
|
175
|
+
if (!pdf) throw notFound();
|
|
176
|
+
return pdf;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** The complete PDF of `revision`, once it has compiled. */
|
|
180
|
+
export const paperPdf = ready;
|
package/src/server/references.ts
CHANGED
|
@@ -20,7 +20,7 @@ interface Engine {
|
|
|
20
20
|
entryInputs(entries: unknown[]): Input[];
|
|
21
21
|
ReferenceChecker: new (file: string) => Checker;
|
|
22
22
|
Confirmations: new (file: string) => Confirmations;
|
|
23
|
-
Repo: new (root: string) => { gitDir(): string };
|
|
23
|
+
Repo: new (root: string) => { gitDir(): string; textAsync(args: string[]): Promise<string> };
|
|
24
24
|
}
|
|
25
25
|
let loading: Promise<Engine> | undefined;
|
|
26
26
|
export function engine(): Promise<Engine> {
|
|
@@ -38,9 +38,17 @@ export async function checker(file = path.join(DATA, 'references.json')) {
|
|
|
38
38
|
}
|
|
39
39
|
// Confirmations live beside the review engine's state in the repository, so
|
|
40
40
|
// a task's review, the uncommitted changes and this panel agree.
|
|
41
|
+
const gitDirs = new Map<string, Promise<string>>();
|
|
41
42
|
export async function confirmations(root: string) {
|
|
42
43
|
const { Confirmations, Repo } = await engine();
|
|
43
|
-
|
|
44
|
+
// The panel polls while a check runs; the Git directory is looked up once.
|
|
45
|
+
let dir = gitDirs.get(root);
|
|
46
|
+
if (!dir) {
|
|
47
|
+
const found = new Repo(root).textAsync(['rev-parse', '--absolute-git-dir']).then(folder => fs.promises.realpath(folder));
|
|
48
|
+
found.catch(() => gitDirs.delete(root));
|
|
49
|
+
gitDirs.set(root, dir = found);
|
|
50
|
+
}
|
|
51
|
+
return new Confirmations(path.join(await dir, 'latex-review', 'references-confirmed.json'));
|
|
44
52
|
}
|
|
45
53
|
|
|
46
54
|
// A file of the paper, read only from inside its folder.
|
package/src/server/review.ts
CHANGED
|
@@ -4,11 +4,15 @@ import { ENGINE } from './paths.ts';
|
|
|
4
4
|
import type { Task } from '../shared.ts';
|
|
5
5
|
|
|
6
6
|
export interface ReviewClient { url: string; token: string; get<T = any>(route: string): Promise<T>; post<T = any>(route: string, body?: unknown, timeout?: number): Promise<T> }
|
|
7
|
-
let modulePromise: Promise<{ connect: (root: string, start?: boolean) => Promise<ReviewClient | null> }> | null = null;
|
|
7
|
+
let modulePromise: Promise<{ connect: (root: string, start?: boolean, options?: { owned?: boolean }) => Promise<ReviewClient | null> }> | null = null;
|
|
8
|
+
// The desktop app owns the review servers it starts, so they stop with it,
|
|
9
|
+
// even after a crash. The web server leaves them running to finish builds
|
|
10
|
+
// across a restart.
|
|
11
|
+
const owned = process.env.PAPILOT_DESKTOP === '1';
|
|
8
12
|
export function reviewClient(root: string, reuse = false): Promise<ReviewClient> {
|
|
9
13
|
modulePromise ??= import(pathToFileURL(path.join(ENGINE, 'src/client.ts')).href);
|
|
10
14
|
// Polling an existing job must not replace a healthy server of another version.
|
|
11
|
-
return modulePromise!.then(async module => (reuse && await module.connect(root, false)) || (await module.connect(root))!);
|
|
15
|
+
return modulePromise!.then(async module => (reuse && await module.connect(root, false)) || (await module.connect(root, true, { owned }))!);
|
|
12
16
|
}
|
|
13
17
|
export const taskRoute = (name: string) => `/api/tasks/${encodeURIComponent(name)}`;
|
|
14
18
|
export function reviewConnectionError(error: unknown): boolean {
|
package/src/server/start.ts
CHANGED
|
@@ -39,6 +39,9 @@ const localListener = { kind: 'local' as const, port: PORT, publicOrigin: PUBLIC
|
|
|
39
39
|
const apps = [await buildApp({ ...shared, listener: localListener })];
|
|
40
40
|
if (ACCOUNT_ORIGIN) apps.push(await buildApp({ ...shared, listener: { kind: 'account', origin: ACCOUNT_ORIGIN, hsts: accountHsts(ACCOUNT_ORIGIN, PUBLIC_ORIGIN) } }));
|
|
41
41
|
|
|
42
|
+
// Agent SDKs and the PDF engine load before the first request, not in the
|
|
43
|
+
// middle of one.
|
|
44
|
+
if (!process.env.PAPILOT_UPDATE_PROBE) await Promise.race([Promise.all([workspace.preload(), manuscripts.warm()]), new Promise(resolve => setTimeout(resolve, 5000).unref())]);
|
|
42
45
|
const localUrl = await apps[0].listen({ host: '127.0.0.1', port: PORT }).catch(error => {
|
|
43
46
|
if (!desktop || error.code !== 'EADDRINUSE') throw error;
|
|
44
47
|
return apps[0].listen({ host: '127.0.0.1', port: 0 });
|
|
@@ -62,7 +65,7 @@ for (const task of store.tasks()) {
|
|
|
62
65
|
setInterval(() => accounts.purge(), 3600000).unref();
|
|
63
66
|
let shuttingDown = false;
|
|
64
67
|
function shutdown() {
|
|
65
|
-
if (shuttingDown) return; shuttingDown = true; updates.close(); workspace.close();
|
|
68
|
+
if (shuttingDown) return; shuttingDown = true; updates.close(); workspace.close(); void manuscripts.stop().catch(() => undefined);
|
|
66
69
|
void Promise.all(apps.map(app => app.close())).finally(() => { store.close(); process.exit(0); });
|
|
67
70
|
setTimeout(() => process.exit(0), 2500).unref();
|
|
68
71
|
}
|
package/src/server/workspace.ts
CHANGED
|
@@ -161,6 +161,16 @@ export class Workspace extends EventEmitter {
|
|
|
161
161
|
this.emit('change');
|
|
162
162
|
}
|
|
163
163
|
snapshot(): Snapshot { return { version: VERSION, projects: this.store.projects(), tasks: this.store.tasks(), requests: [...this.requests.values()].map(({ rpcId: _, agent: __, ...r }) => r), runtime: this.runtime, runtimes: this.runtimes }; }
|
|
164
|
+
/** Loads the SDKs of the agents this host has before the server answers
|
|
165
|
+
* requests. Evaluating one blocks for up to a few hundred milliseconds,
|
|
166
|
+
* which would otherwise hold up whatever the app asked for while the
|
|
167
|
+
* agents connect. SDKs not installed yet are fetched when first used. */
|
|
168
|
+
async preload() {
|
|
169
|
+
await Promise.all(AGENTS.map(async id => {
|
|
170
|
+
const agent = this.agents[id];
|
|
171
|
+
try { if (agent.preload && (!agent.detect || agent.detect())) await agent.preload(); } catch { /* Connecting reports it. */ }
|
|
172
|
+
}));
|
|
173
|
+
}
|
|
164
174
|
async connect() {
|
|
165
175
|
if (this.connecting) return this.connecting;
|
|
166
176
|
this.connecting = Promise.all(AGENTS.map(id => this.connectAgent(id))).then(() => { this.emit('change'); this.schedule(); }).finally(() => { this.connecting = null; });
|
|
@@ -780,6 +790,7 @@ ${this.agentOf(task) === 'antigravity' ? 'Shell commands are not available to yo
|
|
|
780
790
|
let pending = this.store.task(taskId).pendingBuild!;
|
|
781
791
|
let client: ReviewClient | undefined;
|
|
782
792
|
let reuse = resume;
|
|
793
|
+
let compiling: string | undefined;
|
|
783
794
|
// Only connection failures are retried. Every submission has a persisted key,
|
|
784
795
|
// so losing its response cannot submit a second build.
|
|
785
796
|
const request = async <T>(work: (client: ReviewClient) => Promise<T>, submitting = false): Promise<T> => {
|
|
@@ -810,6 +821,11 @@ ${this.agentOf(task) === 'antigravity' ? 'Shell commands are not available to yo
|
|
|
810
821
|
this.store.updateTask(taskId, { pendingBuild: pending });
|
|
811
822
|
}
|
|
812
823
|
const current = await request(client => client.get(`/api/jobs/${pending.job}`));
|
|
824
|
+
// The round's new version exists; it compiles only some chapters here.
|
|
825
|
+
if (typeof current.revision === 'string' && current.revision !== compiling) {
|
|
826
|
+
compiling = current.revision;
|
|
827
|
+
this.emit('publishing', taskId, compiling);
|
|
828
|
+
}
|
|
813
829
|
const summary = await request(client => client.get(taskRoute(task.name)));
|
|
814
830
|
this.store.updateTask(taskId, { review: { ...this.store.task(taskId).review, state: summary.state, round: summary.round, counts: summary.counts, job: { ...current, id: pending.job } } });
|
|
815
831
|
if (current.status === 'done' || current.status === 'completed' || current.status === 'succeeded') {
|
|
@@ -838,6 +854,8 @@ ${this.agentOf(task) === 'antigravity' ? 'Shell commands are not available to yo
|
|
|
838
854
|
await delay(1500);
|
|
839
855
|
}
|
|
840
856
|
await this.refresh(taskId);
|
|
857
|
+
// What applying the new round will write can now be compiled ahead.
|
|
858
|
+
this.emit('reviewed', taskId);
|
|
841
859
|
} catch (e) {
|
|
842
860
|
if (this.closing) return;
|
|
843
861
|
if (reviewConnectionError(e)) {
|