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.
@@ -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
- // npm can be published before desktop builds finish. Only advertise releases
35
- // with an uploaded installer for this OS and CPU, and link to that exact release.
36
- export async function latestDesktopRelease(platform = process.platform, arch = process.arch, fetcher = fetch) {
37
- const releases = await releaseInfo('https://api.github.com/repos/PapilotAI/papilot/releases?per_page=100', fetcher, 4 * 1024 * 1024);
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 latest;
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
- latestDesktop?: () => Promise<{ version: string; downloadUrl: string } | null>;
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 downloadUrl = DESKTOP_RELEASES;
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
- this.file = path.join(options.data, options.desktop ? 'updates/desktop/status.json' : 'updates/status.json'); this.lock = path.join(options.data, 'updates/install.lock');
76
- const preference = readJson(path.join(options.data, 'updates/channel.json'))?.channel;
77
- this.channel = options.desktop ? 'stable' : preference === 'dev' || preference === 'stable' ? preference : devRevision(options.version) ? 'dev' : 'stable';
78
- let supported = options.desktop ? false : options.supported ?? true;
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, channel: 'stable', canUpdate: false, downloadUrl: this.downloadUrl };
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(path.join(this.options.data, 'updates/channel.json'), { channel });
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 ? (this.options.latestDesktop || latestDesktopRelease)() : (this.options.latest || latestManifest)(channel);
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) this.downloadUrl = manifest!.downloadUrl;
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
- void (this.options.install || installRelease)(this.options.data, version).then(root => {
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();
@@ -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.updateTask(taskId, { status: 'building', error: null });
756
- return this.buildReview(task, Boolean(after && !after.requestKey.startsWith(BUILD_FIX)), after?.id).finally(() => { this.publishing.delete(taskId); this.schedule(); });
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
- private async buildReview(task: Task, sendBack: boolean, after?: string) {
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
- const unlinked = this.store.runs().filter(run => run.taskId === taskId && run.status === 'completed' && (run.reviewRound === 0 || (run.reviewRound === undefined && run.id === after))).map(run => run.id);
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
- const current = await client.get(`/api/jobs/${job.job}`);
767
- const summary = await client.get(taskRoute(task.name));
768
- this.store.updateTask(taskId, { review: { ...this.store.task(taskId).review, state: summary.state, round: summary.round, counts: summary.counts, job: current } });
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
- for (const run of this.store.runs().filter(run => unlinked.includes(run.id))) this.store.saveRun({ ...run, reviewRound: current.outcome === 'empty' || current.outcome === 'unchanged' ? null : current.round ?? summary.round ?? null });
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 + job.job, false, { build: failure }).then(() => true, () => false)) {
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) { this.store.updateTask(taskId, { status: 'failed', error: errorText(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
- export interface GitFile { path: string; status: string; staged: boolean; unstaged: boolean; lines?: [number, number]; from?: string }
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
- export interface GitGraphCommit { hash: string; parents: string[]; subject: string; author: string; date: number; refs: string[] }
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.