papilot 0.8.4 → 0.8.5
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-DSWizoaI.js +267 -0
- package/dist/assets/index-PFQDdu53.css +1 -0
- package/dist/assets/{math-UelUOI-R.js → math-DHvVou9A.js} +1 -1
- package/dist/assets/pdf-view-AYTS7kdA.js +598 -0
- package/dist/index.html +2 -2
- package/engine/dist/web/assets/{FullPaper-DbT25OhQ.js → FullPaper-DlgZ1MOz.js} +1 -1
- package/engine/dist/web/assets/index-BCgVChtV.js +19 -0
- package/engine/dist/web/index.html +1 -1
- package/engine/src/client.ts +11 -4
- package/engine/src/core/publish.ts +17 -3
- package/engine/src/core/store.ts +1 -0
- package/engine/src/server.ts +3 -2
- package/engine/src/web/review.tsx +37 -7
- package/package.json +2 -2
- package/src/server/agents.ts +10 -1
- package/src/server/app.ts +6 -1
- package/src/server/cursor.ts +20 -1
- package/src/server/git-overview.ts +9 -5
- package/src/server/manuscript.ts +21 -2
- package/src/server/review.ts +8 -3
- package/src/server/start.ts +10 -3
- package/src/server/update-install.ts +57 -0
- package/src/server/updates.ts +95 -27
- package/src/server/workspace.ts +76 -15
- package/src/shared.ts +8 -3
- package/dist/assets/index-CwFvCo6r.css +0 -1
- package/dist/assets/index-dByEATYE.js +0 -267
- package/dist/assets/pdf-view-ChJvfsD8.js +0 -598
- package/engine/dist/web/assets/index-C_KwLmXQ.js +0 -19
package/src/server/updates.ts
CHANGED
|
@@ -3,13 +3,14 @@ import path from 'node:path';
|
|
|
3
3
|
import { spawn } from 'node:child_process';
|
|
4
4
|
import { randomUUID } from 'node:crypto';
|
|
5
5
|
import { devRevision, newerVersion, readJson, stableVersion, writeJson } from '../../bin/installation.mjs';
|
|
6
|
-
import { installRelease, loaderFor, npmCli } from './update-install.ts';
|
|
6
|
+
import { download, installLayer, installRelease, loaderFor, npmCli, type DesktopLayer } from './update-install.ts';
|
|
7
7
|
import type { UpdateChannel, UpdateStatus } from '../shared.ts';
|
|
8
8
|
|
|
9
9
|
const activePhases = new Set(['installing', 'waiting', 'restarting']);
|
|
10
10
|
export const alive = (pid: number) => { try { process.kill(pid, 0); return true; } catch { return false; } };
|
|
11
|
-
async function releaseInfo(url: string, fetcher: typeof fetch, limit = 256000) {
|
|
11
|
+
async function releaseInfo(url: string, fetcher: typeof fetch, limit = 256000, missing?: null) {
|
|
12
12
|
const response = await fetcher(url, { signal: AbortSignal.timeout(10000), redirect: 'error', headers: { Accept: 'application/json', 'User-Agent': 'Papilot' } });
|
|
13
|
+
if (response.status === 404 && missing === null) return null;
|
|
13
14
|
if (!response.ok) throw new Error('Could not check for updates.');
|
|
14
15
|
const text = await response.text();
|
|
15
16
|
if (text.length > limit) throw new Error('Invalid release information.');
|
|
@@ -31,28 +32,65 @@ export async function latestManifest(channel: UpdateChannel = 'stable', fetcher
|
|
|
31
32
|
}
|
|
32
33
|
export const latestRelease = async (channel: UpdateChannel = 'stable', fetcher = fetch) => (await latestManifest(channel, fetcher)).version as string;
|
|
33
34
|
export const DESKTOP_RELEASES = 'https://github.com/PapilotAI/papilot/releases';
|
|
34
|
-
//
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
35
|
+
// Nightly builds of main replace the assets of this one prerelease.
|
|
36
|
+
export const DESKTOP_DEV_TAG = 'desktop-dev';
|
|
37
|
+
const DESKTOP_API = 'https://api.github.com/repos/PapilotAI/papilot';
|
|
38
|
+
// The smoke test serves releases locally.
|
|
39
|
+
const desktopApi = () => process.env.PAPILOT_DESKTOP_UPDATE_SOURCE ? process.env.PAPILOT_DESKTOP_UPDATE_SOURCE.replace(/\/+$/, '') + '/repos/PapilotAI/papilot' : DESKTOP_API;
|
|
40
|
+
export interface DesktopRelease { version: string; downloadUrl: string; layer?: DesktopLayer }
|
|
41
|
+
const asset = (release: any, name: unknown) => release.assets.find((asset: any) => asset.name === name && asset.state === 'uploaded' && asset.size > 0 && typeof asset.browser_download_url === 'string');
|
|
42
|
+
// desktop.json (written by scripts/desktop-layer.mjs) names the release's
|
|
43
|
+
// version, its update layer and the installer shell that layer runs on.
|
|
44
|
+
async function desktopManifest(release: any, fetcher: typeof fetch) {
|
|
45
|
+
const listed = asset(release, 'desktop.json');
|
|
46
|
+
if (!listed) return null;
|
|
47
|
+
try { return JSON.parse((await download(listed.browser_download_url, fetcher, 64 * 1024)).toString('utf8')); }
|
|
48
|
+
catch { throw new Error('Could not check for updates.'); }
|
|
49
|
+
}
|
|
50
|
+
function desktopLayer(release: any, manifest: any, version: string, fingerprint: string): DesktopLayer | undefined {
|
|
51
|
+
const layer = manifest?.layer, file = layer && asset(release, layer.name);
|
|
52
|
+
if (!fingerprint || manifest?.format !== 1 || manifest.version !== version || manifest.fingerprint !== fingerprint || !file || file.size !== layer.size || !/^[a-f0-9]{64}$/.test(layer.sha256)) return undefined;
|
|
53
|
+
return { url: file.browser_download_url, size: layer.size, sha256: layer.sha256 };
|
|
54
|
+
}
|
|
55
|
+
// Stable: npm can be published before desktop builds finish. Only advertise
|
|
56
|
+
// releases with an uploaded installer for this OS and CPU, and link to that
|
|
57
|
+
// exact release. Dev: the latest nightly build of main. Either includes the update layer
|
|
58
|
+
// when it was built for this installer's shell (fingerprint).
|
|
59
|
+
export async function latestDesktopRelease(platform = process.platform, arch = process.arch, fetcher = fetch, channel: UpdateChannel = 'stable', fingerprint = ''): Promise<DesktopRelease | null> {
|
|
60
|
+
if (channel === 'dev') {
|
|
61
|
+
const release = await releaseInfo(`${desktopApi()}/releases/tags/${DESKTOP_DEV_TAG}`, fetcher, 4 * 1024 * 1024, null);
|
|
62
|
+
if (!release) return null;
|
|
63
|
+
if (!Array.isArray(release.assets) || release.draft !== false) throw new Error('Invalid release information.');
|
|
64
|
+
const manifest = await desktopManifest(release, fetcher);
|
|
65
|
+
if (!manifest) return null;
|
|
66
|
+
if (!devRevision(manifest.version)) throw new Error('Invalid release information.');
|
|
67
|
+
const layer = desktopLayer(release, manifest, manifest.version, fingerprint);
|
|
68
|
+
return { version: manifest.version, downloadUrl: `${DESKTOP_RELEASES}/tag/${DESKTOP_DEV_TAG}`, ...(layer ? { layer } : {}) };
|
|
69
|
+
}
|
|
70
|
+
const releases = await releaseInfo(`${desktopApi()}/releases?per_page=100`, fetcher, 4 * 1024 * 1024);
|
|
38
71
|
if (!Array.isArray(releases)) throw new Error('Invalid release information.');
|
|
39
|
-
let latest: { version: string; downloadUrl: string } | null = null;
|
|
72
|
+
let latest: { version: string; downloadUrl: string; release: any } | null = null;
|
|
40
73
|
for (const release of releases) {
|
|
41
74
|
const version = typeof release.tag_name === 'string' ? release.tag_name.replace(/^v/, '') : '';
|
|
42
75
|
if (release.draft !== false || release.prerelease !== false || !stableVersion(version) || !Array.isArray(release.assets)) continue;
|
|
43
76
|
const names = platform === 'darwin' && ['arm64', 'x64'].includes(arch) ? [`Papilot-${version}-mac-${arch}.dmg`, `Papilot-${version}-mac-${arch}.zip`]
|
|
44
77
|
: platform === 'linux' && ['arm64', 'x64'].includes(arch) ? [`Papilot-${version}-linux-${arch === 'x64' ? 'x86_64' : arch}.AppImage`, `Papilot-${version}-linux-${arch === 'x64' ? 'amd64' : arch}.deb`] : [];
|
|
45
78
|
if (!release.assets.some((asset: any) => asset.state === 'uploaded' && asset.size > 0 && names.includes(asset.name))) continue;
|
|
46
|
-
if (!latest || newerVersion(version, latest.version)) latest = { version, downloadUrl: `${DESKTOP_RELEASES}/tag/${release.tag_name}
|
|
79
|
+
if (!latest || newerVersion(version, latest.version)) latest = { version, downloadUrl: `${DESKTOP_RELEASES}/tag/${release.tag_name}`, release };
|
|
47
80
|
}
|
|
48
|
-
return
|
|
81
|
+
if (!latest) return null;
|
|
82
|
+
const layer = fingerprint ? desktopLayer(latest.release, await desktopManifest(latest.release, fetcher), latest.version, fingerprint) : undefined;
|
|
83
|
+
return { version: latest.version, downloadUrl: latest.downloadUrl, ...(layer ? { layer } : {}) };
|
|
49
84
|
}
|
|
50
85
|
const validTarget = (version: unknown, channel: UpdateChannel): version is string => channel === 'dev' ? Boolean(devRevision(version)) : stableVersion(version);
|
|
51
86
|
const available = (next: string, current: string, channel: UpdateChannel) => validTarget(next, channel)
|
|
52
87
|
&& (channel === 'dev' || devRevision(current) ? next !== current : newerVersion(next, current));
|
|
53
88
|
interface Options {
|
|
54
89
|
desktop?: boolean;
|
|
55
|
-
|
|
90
|
+
// A packaged desktop app: its bundled server and shell fingerprint.
|
|
91
|
+
bundle?: { root: string; version: string; fingerprint: string };
|
|
92
|
+
latestDesktop?: (channel: UpdateChannel) => Promise<DesktopRelease | null>;
|
|
93
|
+
installLayer?: (data: string, version: string, layer: DesktopLayer, bundle: { root: string; fingerprint: string }) => Promise<string>;
|
|
56
94
|
root: string; data: string; version: string; port: number;
|
|
57
95
|
busy: () => boolean; shutdown: () => void;
|
|
58
96
|
latest?: (channel: UpdateChannel) => Promise<string | Record<string, any>>; install?: (data: string, version: string) => Promise<string>;
|
|
@@ -70,15 +108,23 @@ export class Updates {
|
|
|
70
108
|
private file: string;
|
|
71
109
|
private lock: string;
|
|
72
110
|
private channel: UpdateChannel;
|
|
73
|
-
private
|
|
111
|
+
private channelFile: string;
|
|
112
|
+
// Desktop: the release page when the update needs a new installer.
|
|
113
|
+
private downloadUrl?: string;
|
|
114
|
+
private target: DesktopRelease | null = null;
|
|
115
|
+
// Only packaged desktop apps install layers; the development app links to releases.
|
|
116
|
+
private layers: boolean;
|
|
74
117
|
constructor(private options: Options) {
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
this.
|
|
78
|
-
|
|
118
|
+
const folder = path.join(options.data, options.desktop ? 'updates/desktop' : 'updates');
|
|
119
|
+
this.file = path.join(folder, 'status.json'); this.lock = path.join(folder, 'install.lock'); this.channelFile = path.join(folder, 'channel.json');
|
|
120
|
+
this.layers = Boolean(options.desktop && options.bundle?.fingerprint);
|
|
121
|
+
if (options.desktop && !this.layers) this.downloadUrl = DESKTOP_RELEASES;
|
|
122
|
+
const preference = readJson(this.channelFile)?.channel;
|
|
123
|
+
this.channel = options.desktop && !this.layers ? 'stable' : preference === 'dev' || preference === 'stable' ? preference : devRevision(options.version) ? 'dev' : 'stable';
|
|
124
|
+
let supported = options.desktop ? this.layers : options.supported ?? true;
|
|
79
125
|
if (!options.desktop && options.supported === undefined) try { npmCli(); } catch { supported = false; }
|
|
80
126
|
this.state = { currentVersion: options.version, latestVersion: null, phase: 'idle', checkedAt: null, error: null, canUpdate: supported };
|
|
81
|
-
const saved = options.desktop ? null : readJson(this.file);
|
|
127
|
+
const saved = options.desktop && !this.layers ? null : readJson(this.file);
|
|
82
128
|
if (saved && ['failed', 'updated', 'installing', 'waiting', 'restarting'].includes(saved.phase)) this.state = { ...this.state, ...saved, currentVersion: options.version, canUpdate: supported };
|
|
83
129
|
if (saved && activePhases.has(saved.phase) && !alive(saved.pid)) this.change({ phase: 'failed', checkedAt: Date.now(), error: 'The update was interrupted. Your current installation is still available; retry the update.' });
|
|
84
130
|
}
|
|
@@ -86,8 +132,8 @@ export class Updates {
|
|
|
86
132
|
const saved = readJson(this.file);
|
|
87
133
|
if (saved && activePhases.has(this.state.phase) && ['updated', 'failed'].includes(saved.phase)) this.state = { ...this.state, ...saved, currentVersion: this.options.version };
|
|
88
134
|
const { currentVersion, latestVersion, phase, checkedAt, error, canUpdate } = this.state, desktop = this.options.desktop;
|
|
89
|
-
if (desktop) return { currentVersion, latestVersion, phase, checkedAt, error,
|
|
90
|
-
return { currentVersion, latestVersion, phase, checkedAt, error, canUpdate, channel: this.channel };
|
|
135
|
+
if (desktop && !this.layers) return { currentVersion, latestVersion, phase, checkedAt, error, canUpdate: false, downloadUrl: this.downloadUrl };
|
|
136
|
+
return { currentVersion, latestVersion, phase, checkedAt, error, canUpdate, channel: this.channel, ...(this.downloadUrl ? { downloadUrl: this.downloadUrl } : {}) };
|
|
91
137
|
}
|
|
92
138
|
get restarting() { return this.snapshot().phase === 'restarting'; }
|
|
93
139
|
enter() { this.inFlight++; let released = false; return () => { if (!released) { released = true; this.inFlight--; } }; }
|
|
@@ -104,13 +150,14 @@ export class Updates {
|
|
|
104
150
|
writeJson(this.file, { ...this.state, pid: process.pid });
|
|
105
151
|
}
|
|
106
152
|
setChannel(channel: unknown) {
|
|
107
|
-
if (this.options.desktop) throw new Error('Download desktop updates from GitHub Releases.');
|
|
153
|
+
if (this.options.desktop && !this.layers) throw new Error('Download desktop updates from GitHub Releases.');
|
|
108
154
|
if (channel !== 'stable' && channel !== 'dev') throw new Error('Choose a valid update channel.');
|
|
109
155
|
if (activePhases.has(this.state.phase)) throw new Error('Wait for the current update to finish before changing channels.');
|
|
110
156
|
if (channel === this.channel) return this.check();
|
|
111
157
|
this.channel = channel;
|
|
112
|
-
writeJson(
|
|
113
|
-
this.checking = null;
|
|
158
|
+
writeJson(this.channelFile, { channel });
|
|
159
|
+
this.checking = null; this.target = null;
|
|
160
|
+
if (this.layers) this.downloadUrl = undefined;
|
|
114
161
|
this.change({ latestVersion: null, checkedAt: null, phase: 'idle', error: null });
|
|
115
162
|
return this.check();
|
|
116
163
|
}
|
|
@@ -119,12 +166,12 @@ export class Updates {
|
|
|
119
166
|
if (activePhases.has(this.state.phase)) return Promise.resolve(this.snapshot());
|
|
120
167
|
this.change({ phase: 'checking' });
|
|
121
168
|
const channel = this.channel;
|
|
122
|
-
const discovery = this.options.desktop ?
|
|
169
|
+
const discovery = this.options.desktop ? this.desktopRelease(channel) : (this.options.latest || latestManifest)(channel);
|
|
123
170
|
const checking = discovery.then(found => {
|
|
124
171
|
const manifest = typeof found === 'string' ? { version: found } : found;
|
|
125
172
|
if (!manifest && this.options.desktop) {
|
|
126
|
-
if (!this.closed && this.checking === checking) {
|
|
127
|
-
this.downloadUrl = DESKTOP_RELEASES;
|
|
173
|
+
if (!this.closed && this.checking === checking && !activePhases.has(this.state.phase)) {
|
|
174
|
+
this.target = null; this.downloadUrl = this.layers ? undefined : DESKTOP_RELEASES;
|
|
128
175
|
this.change({ latestVersion: null, checkedAt: Date.now(), error: null, phase: 'idle' });
|
|
129
176
|
}
|
|
130
177
|
return this.snapshot();
|
|
@@ -133,7 +180,12 @@ export class Updates {
|
|
|
133
180
|
if (!validTarget(version, channel)) throw new Error('Invalid release version.');
|
|
134
181
|
if (!this.closed && this.checking === checking && !activePhases.has(this.state.phase)) {
|
|
135
182
|
const newer = available(version, this.options.version, channel);
|
|
136
|
-
if (this.options.desktop)
|
|
183
|
+
if (this.options.desktop) {
|
|
184
|
+
const release = manifest as DesktopRelease, bundled = version === this.options.bundle?.version;
|
|
185
|
+
// A layer for this shell, or the installer's own server, installs in place.
|
|
186
|
+
this.target = this.layers && newer && (release.layer || bundled) ? release : null;
|
|
187
|
+
this.downloadUrl = this.layers ? newer && !this.target ? release.downloadUrl : undefined : release.downloadUrl;
|
|
188
|
+
}
|
|
137
189
|
this.change({ latestVersion: version, checkedAt: Date.now(), error: null, phase: newer ? 'available' : 'idle' });
|
|
138
190
|
}
|
|
139
191
|
return this.snapshot();
|
|
@@ -144,9 +196,23 @@ export class Updates {
|
|
|
144
196
|
this.checking = checking;
|
|
145
197
|
return this.checking;
|
|
146
198
|
}
|
|
199
|
+
private desktopRelease(channel: UpdateChannel) {
|
|
200
|
+
return (this.options.latestDesktop || (channel => latestDesktopRelease(process.platform, process.arch, fetch, channel, this.options.bundle?.fingerprint)))(channel);
|
|
201
|
+
}
|
|
202
|
+
// A retry after a restart finds the release again; the check that offered it
|
|
203
|
+
// happened in the previous process.
|
|
204
|
+
private async desktopLayer(version: string) {
|
|
205
|
+
const bundle = this.options.bundle!;
|
|
206
|
+
if (version === bundle.version) return bundle.root;
|
|
207
|
+
let release = this.target;
|
|
208
|
+
if (release?.version !== version) release = await this.desktopRelease(this.channel);
|
|
209
|
+
if (release?.version !== version || !release.layer) throw new Error('Check for a newer release before updating.');
|
|
210
|
+
return (this.options.installLayer || installLayer)(this.options.data, version, release.layer, bundle);
|
|
211
|
+
}
|
|
147
212
|
install(version: unknown) {
|
|
148
|
-
if (this.options.desktop) throw new Error('Download desktop updates from GitHub Releases.');
|
|
213
|
+
if (this.options.desktop && !this.layers) throw new Error('Download desktop updates from GitHub Releases.');
|
|
149
214
|
if (activePhases.has(this.state.phase)) return this.snapshot();
|
|
215
|
+
if (this.options.desktop && this.downloadUrl) throw new Error('Download desktop updates from GitHub Releases.');
|
|
150
216
|
if (!this.state.canUpdate) throw new Error('npm is unavailable to Papilot.');
|
|
151
217
|
if (!validTarget(version, this.channel) || version !== this.state.latestVersion || !available(version, this.options.version, this.channel)) throw new Error('Check for a newer release before updating.');
|
|
152
218
|
fs.mkdirSync(path.dirname(this.lock), { recursive: true, mode: 0o700 });
|
|
@@ -155,7 +221,9 @@ export class Updates {
|
|
|
155
221
|
if (previous) fs.rmSync(this.lock);
|
|
156
222
|
const fd = fs.openSync(this.lock, 'wx', 0o600); fs.writeFileSync(fd, JSON.stringify({ pid: process.pid })); fs.closeSync(fd);
|
|
157
223
|
this.change({ phase: 'installing', error: null });
|
|
158
|
-
|
|
224
|
+
// Returning to the installer's own version needs no download.
|
|
225
|
+
const installing = this.options.desktop ? this.desktopLayer(version) : (this.options.install || installRelease)(this.options.data, version);
|
|
226
|
+
void installing.then(root => {
|
|
159
227
|
if (this.closed) return;
|
|
160
228
|
this.prepared = root; this.change({ phase: 'waiting' });
|
|
161
229
|
return this.activate();
|
package/src/server/workspace.ts
CHANGED
|
@@ -12,7 +12,7 @@ import { Codex, RpcResponseError } from './codex.ts';
|
|
|
12
12
|
import { AGENT_NAMES, AntigravityAgent, ClaudeAgent, GlmAgent, type AgentRuntime } from './agents.ts';
|
|
13
13
|
import { ACP_PROFILES, AcpAgent } from './acp.ts';
|
|
14
14
|
import { CursorAgent } from './cursor.ts';
|
|
15
|
-
import { reviewClient, taskRoute, confirmedReviewResponse, type ReviewClient } from './review.ts';
|
|
15
|
+
import { reviewClient, reviewConnectionError, taskRoute, confirmedReviewResponse, type ReviewClient } from './review.ts';
|
|
16
16
|
import { VERSION } from './paths.ts';
|
|
17
17
|
import { prepareRepository } from './imports.ts';
|
|
18
18
|
import { findSkill, type SkillFile } from './skills.ts';
|
|
@@ -668,6 +668,7 @@ ${this.agentOf(task) === 'antigravity' ? 'Shell commands are not available to yo
|
|
|
668
668
|
async reconcile(taskId: string) {
|
|
669
669
|
const task = this.store.task(taskId);
|
|
670
670
|
if (task.status === 'deleting') throw new Error('Retry deletion to confirm this task was removed.');
|
|
671
|
+
if (task.pendingBuild && !task.pendingReview) { void this.resumeBuild(taskId); return this.store.task(taskId); }
|
|
671
672
|
if (task.status !== 'uncertain') return task;
|
|
672
673
|
if (task.pendingReview) return this.reconcileReview(task);
|
|
673
674
|
const run = this.active.get(taskId) || this.store.runs().find(r => r.id === task.pendingRunId && r.taskId === taskId);
|
|
@@ -752,40 +753,100 @@ ${this.agentOf(task) === 'antigravity' ? 'Shell commands are not available to yo
|
|
|
752
753
|
if (this.reviewWrites.has(taskId)) throw new Error('Wait for the current review action before building a review.');
|
|
753
754
|
if (this.publishing.has(taskId) || (!automatic && (BUSY.has(task.status) || task.status === 'queued'))) throw new Error('This task is busy. Wait for it to finish or pause it first.');
|
|
754
755
|
this.publishing.add(taskId);
|
|
755
|
-
this.store.
|
|
756
|
-
|
|
756
|
+
const runs = this.store.runs().filter(run => run.taskId === taskId && run.status === 'completed' && (run.reviewRound === 0 || (run.reviewRound === undefined && run.id === after?.id))).map(run => run.id);
|
|
757
|
+
this.store.updateTask(taskId, { status: 'building', error: null, pendingBuild: { requestId: randomUUID(), runs, sendBack: Boolean(after && !after.requestKey.startsWith(BUILD_FIX)), restarts: 0 } });
|
|
758
|
+
return this.buildReview(task).finally(() => { this.publishing.delete(taskId); this.schedule(); });
|
|
757
759
|
}
|
|
758
|
-
|
|
760
|
+
// Resume a persisted job, including jobs saved by versions before pendingBuild.
|
|
761
|
+
async resumeBuild(taskId: string) {
|
|
762
|
+
const task = this.store.task(taskId);
|
|
763
|
+
if (this.closing || this.publishing.has(taskId) || task.mode === 'review' || task.pendingReview || this.active.has(taskId)
|
|
764
|
+
|| !['building', 'failed', 'interrupted', 'uncertain'].includes(task.status)) return false;
|
|
765
|
+
let pending = task.pendingBuild;
|
|
766
|
+
if (!pending) {
|
|
767
|
+
const job = task.review?.job;
|
|
768
|
+
if (!job?.id || !['running', 'queued'].includes(job.status)
|
|
769
|
+
|| (task.status !== 'building' && !/fetch failed|server restarted during the PDF build/i.test(task.error || ''))) return false;
|
|
770
|
+
pending = { requestId: randomUUID(), job: job.id, runs: this.store.runs().filter(run => run.taskId === taskId && run.status === 'completed' && run.reviewRound === 0).map(run => run.id), sendBack: false, restarts: 0 };
|
|
771
|
+
}
|
|
772
|
+
this.publishing.add(taskId);
|
|
773
|
+
this.store.updateTask(taskId, { status: 'building', error: null, pendingBuild: pending });
|
|
774
|
+
try { await this.buildReview(task, true); }
|
|
775
|
+
finally { this.publishing.delete(taskId); this.schedule(); }
|
|
776
|
+
return true;
|
|
777
|
+
}
|
|
778
|
+
private async buildReview(task: Task, resume = false) {
|
|
759
779
|
const taskId = task.id;
|
|
760
|
-
|
|
780
|
+
let pending = this.store.task(taskId).pendingBuild!;
|
|
781
|
+
let client: ReviewClient | undefined;
|
|
782
|
+
let reuse = resume;
|
|
783
|
+
// Only connection failures are retried. Every submission has a persisted key,
|
|
784
|
+
// so losing its response cannot submit a second build.
|
|
785
|
+
const request = async <T>(work: (client: ReviewClient) => Promise<T>, submitting = false): Promise<T> => {
|
|
786
|
+
for (let attempt = 0; ; attempt++) {
|
|
787
|
+
if (this.closing) throw new Error();
|
|
788
|
+
try {
|
|
789
|
+
client ??= await this.connectReview(this.store.project(task.projectId).path, reuse);
|
|
790
|
+
const value = await work(client);
|
|
791
|
+
if (this.closing) throw new Error();
|
|
792
|
+
return value;
|
|
793
|
+
} catch (error) {
|
|
794
|
+
if (this.closing || !reviewConnectionError(error) || attempt >= 3) throw error;
|
|
795
|
+
console.warn('PDF comparison connection interrupted:', task.name, pending.job || pending.requestId, error);
|
|
796
|
+
client = undefined; reuse = !submitting;
|
|
797
|
+
await delay(1500 * 2 ** attempt);
|
|
798
|
+
}
|
|
799
|
+
}
|
|
800
|
+
};
|
|
761
801
|
try {
|
|
762
|
-
const client = await this.connectReview(this.store.project(task.projectId).path);
|
|
763
|
-
const job = await client.post(`${taskRoute(task.name)}/publish`, {});
|
|
764
802
|
for (;;) {
|
|
765
803
|
if (this.closing) return;
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
804
|
+
if (!pending.job) {
|
|
805
|
+
// New writes require our engine version, which understands request IDs.
|
|
806
|
+
// Read-only recovery may have connected to an older installed engine.
|
|
807
|
+
client = undefined; reuse = false;
|
|
808
|
+
const job = await request(client => client.post<{ job: string }>(`${taskRoute(task.name)}/publish`, { requestId: pending.requestId }), true);
|
|
809
|
+
pending = { ...pending, job: job.job };
|
|
810
|
+
this.store.updateTask(taskId, { pendingBuild: pending });
|
|
811
|
+
}
|
|
812
|
+
const current = await request(client => client.get(`/api/jobs/${pending.job}`));
|
|
813
|
+
const summary = await request(client => client.get(taskRoute(task.name)));
|
|
814
|
+
this.store.updateTask(taskId, { review: { ...this.store.task(taskId).review, state: summary.state, round: summary.round, counts: summary.counts, job: { ...current, id: pending.job } } });
|
|
769
815
|
if (current.status === 'done' || current.status === 'completed' || current.status === 'succeeded') {
|
|
770
816
|
// Freeze the result round for these instructions. Later follow-ups
|
|
771
817
|
// have their own results and cannot silently replace these links.
|
|
772
|
-
|
|
818
|
+
this.store.transaction(() => {
|
|
819
|
+
for (const run of this.store.runs().filter(run => pending.runs.includes(run.id))) this.store.saveRun({ ...run, reviewRound: current.outcome === 'empty' || current.outcome === 'unchanged' ? null : current.round ?? summary.round ?? null });
|
|
820
|
+
this.store.updateTask(taskId, { status: this.store.runs().some(r => r.taskId === taskId && r.status === 'queued') ? 'queued' : 'reviewing', error: null, pendingBuild: null });
|
|
821
|
+
});
|
|
773
822
|
break;
|
|
774
823
|
}
|
|
775
824
|
if (current.status === 'failed') {
|
|
825
|
+
if (current.error === 'The review server restarted during the build.' && pending.restarts < 1) {
|
|
826
|
+
pending = { ...pending, job: undefined, requestId: randomUUID(), restarts: pending.restarts + 1 };
|
|
827
|
+
this.store.updateTask(taskId, { pendingBuild: pending });
|
|
828
|
+
continue;
|
|
829
|
+
}
|
|
776
830
|
const failure: BuildFailure = { error: current.error || 'PDF build failed.', details: current.details || [], ...(current.log ? { log: current.log } : {}) };
|
|
777
831
|
// Instructions the author queued meanwhile go first; the build after them sends any error that remains.
|
|
778
|
-
if (sendBack && FIXABLE.test(failure.error) && await this.enqueue(taskId, '', BUILD_FIX +
|
|
779
|
-
this.store.updateTask(taskId, { status: 'queued', error: null });
|
|
832
|
+
if (pending.sendBack && FIXABLE.test(failure.error) && await this.enqueue(taskId, '', BUILD_FIX + pending.job, false, { build: failure }).then(() => true, () => false)) {
|
|
833
|
+
this.store.updateTask(taskId, { status: 'queued', error: null, pendingBuild: null });
|
|
780
834
|
return;
|
|
781
835
|
}
|
|
782
836
|
throw new Error([failure.error, ...failure.details].join('\n'));
|
|
783
837
|
}
|
|
784
838
|
await delay(1500);
|
|
785
839
|
}
|
|
786
|
-
this.store.updateTask(taskId, { status: this.store.runs().some(r => r.taskId === taskId && r.status === 'queued') ? 'queued' : 'reviewing', error: null });
|
|
787
840
|
await this.refresh(taskId);
|
|
788
|
-
} catch (e) {
|
|
841
|
+
} catch (e) {
|
|
842
|
+
if (this.closing) return;
|
|
843
|
+
if (reviewConnectionError(e)) {
|
|
844
|
+
console.warn('PDF comparison recovery pending:', task.name, pending.job || pending.requestId, e);
|
|
845
|
+
this.store.updateTask(taskId, { status: 'uncertain', pendingBuild: pending,
|
|
846
|
+
error: 'The PDF comparison service could not be reached. Your edits are saved. Select Check status to recover the existing build.' });
|
|
847
|
+
}
|
|
848
|
+
else this.store.updateTask(taskId, { status: 'failed', pendingBuild: null, error: errorText(e) });
|
|
849
|
+
}
|
|
789
850
|
}
|
|
790
851
|
private finishAiReview(task: Task, run: Run, turnId: string) {
|
|
791
852
|
try {
|
package/src/shared.ts
CHANGED
|
@@ -19,7 +19,8 @@ export interface UpdateStatus {
|
|
|
19
19
|
// unsent counts review updates the agent has not received; comments are the
|
|
20
20
|
// ones that ask for a revision. files are the manuscript files this task's
|
|
21
21
|
// accepted changes touched since its last commit, relative to the repository root.
|
|
22
|
-
export interface ReviewSummary { state: string; round: number | null; counts: Record<string, number>; unsent?: number; comments?: number; files?: string[]; next?: string; job?: { phase: string; status: string; error?: string; details?: string[] } | null }
|
|
22
|
+
export interface ReviewSummary { state: string; round: number | null; counts: Record<string, number>; unsent?: number; comments?: number; files?: string[]; next?: string; job?: { id?: string; phase: string; status: string; error?: string; details?: string[] } | null }
|
|
23
|
+
export interface PendingBuild { requestId: string; job?: string; runs: string[]; sendBack: boolean; restarts: number }
|
|
23
24
|
export interface PendingReview { route: string; body: Record<string, unknown>; previousStatus: TaskStatus }
|
|
24
25
|
export interface TaskCommitReview { token: string; files: string[]; diff: string }
|
|
25
26
|
export interface TaskReconciliation {
|
|
@@ -44,6 +45,8 @@ export interface Task {
|
|
|
44
45
|
error: string | null; review: ReviewSummary | null;
|
|
45
46
|
pauseVersion?: number; pendingRunId?: string | null;
|
|
46
47
|
pendingReview?: PendingReview | null;
|
|
48
|
+
// Persist before submission so reconnects resume the same build and run links.
|
|
49
|
+
pendingBuild?: PendingBuild | null;
|
|
47
50
|
// The task's last commit from its review, and the accepted text it included.
|
|
48
51
|
committed?: { hash: string; accepted: string; at: number; pushed?: boolean } | null;
|
|
49
52
|
}
|
|
@@ -113,7 +116,8 @@ export interface Person { id: string; name: string; role: 'owner' | 'member'; jo
|
|
|
113
116
|
export interface Device { id: string; kind: 'passkey' | 'session'; label: string; createdAt: number; lastUsedAt: number | null }
|
|
114
117
|
export interface People { origin: string | null; people: Person[] }
|
|
115
118
|
// staged: the index has changes to this file; unstaged: the working tree has more.
|
|
116
|
-
|
|
119
|
+
// index is the staged status where it differs from status, which is the working tree's when it has changes.
|
|
120
|
+
export interface GitFile { path: string; status: string; staged: boolean; unstaged: boolean; lines?: [number, number]; from?: string; index?: string }
|
|
117
121
|
// merging is a merge in progress, with the files whose conflict markers remain.
|
|
118
122
|
export interface GitStatus { branch: string | null; upstream: string | null; ahead: number; behind: number; files: GitFile[]; head: { hash: string; subject: string; date: number } | null; remote?: boolean; merging?: { message: string; conflicts: string[] } | null }
|
|
119
123
|
export interface GitDiff { path: string; diff: string; truncated: boolean }
|
|
@@ -122,7 +126,8 @@ export interface GitWorktree {
|
|
|
122
126
|
locked?: string; prunable?: string; status: GitStatus | null; error?: string;
|
|
123
127
|
taskId?: string;
|
|
124
128
|
}
|
|
125
|
-
|
|
129
|
+
// refs are local branches; remote branches and tags are listed apart, as an editor's graph shows them.
|
|
130
|
+
export interface GitGraphCommit { hash: string; parents: string[]; subject: string; author: string; date: number; refs: string[]; remotes?: string[]; tags?: string[] }
|
|
126
131
|
export interface GitOverview { worktrees: GitWorktree[]; commits: GitGraphCommit[]; truncated: boolean }
|
|
127
132
|
// A running compilation, from latexmk's output: its step, the TeX pass, and
|
|
128
133
|
// the pages typeset in that pass against the last known page count.
|