papilot 0.8.4 → 0.8.6

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,7 +3,7 @@ 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']);
@@ -31,28 +31,41 @@ export async function latestManifest(channel: UpdateChannel = 'stable', fetcher
31
31
  }
32
32
  export const latestRelease = async (channel: UpdateChannel = 'stable', fetcher = fetch) => (await latestManifest(channel, fetcher)).version as string;
33
33
  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);
38
- if (!Array.isArray(releases)) throw new Error('Invalid release information.');
39
- let latest: { version: string; downloadUrl: string } | null = null;
40
- for (const release of releases) {
41
- const version = typeof release.tag_name === 'string' ? release.tag_name.replace(/^v/, '') : '';
42
- if (release.draft !== false || release.prerelease !== false || !stableVersion(version) || !Array.isArray(release.assets)) continue;
43
- const names = platform === 'darwin' && ['arm64', 'x64'].includes(arch) ? [`Papilot-${version}-mac-${arch}.dmg`, `Papilot-${version}-mac-${arch}.zip`]
44
- : platform === 'linux' && ['arm64', 'x64'].includes(arch) ? [`Papilot-${version}-linux-${arch === 'x64' ? 'x86_64' : arch}.AppImage`, `Papilot-${version}-linux-${arch === 'x64' ? 'amd64' : arch}.deb`] : [];
45
- 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}` };
47
- }
48
- return latest;
34
+ // Nightly builds of main replace the assets of this one prerelease.
35
+ export const DESKTOP_DEV_TAG = 'desktop-dev';
36
+ // Release downloads, not the rate-limited REST API. The smoke test serves
37
+ // them locally.
38
+ const desktopDownloads = () => (process.env.PAPILOT_DESKTOP_UPDATE_SOURCE ? process.env.PAPILOT_DESKTOP_UPDATE_SOURCE.replace(/\/+$/, '') + '/PapilotAI/papilot/releases' : DESKTOP_RELEASES);
39
+ export interface DesktopRelease { version: string; downloadUrl: string; layer?: DesktopLayer }
40
+ // desktop.json (written by scripts/desktop-layer.mjs, uploaded after every
41
+ // other asset of its release) names the version, its update layer and the
42
+ // installer shell that layer runs on. Stable: the latest release. Dev: the
43
+ // latest nightly build of main. The layer is offered only to that shell.
44
+ export async function latestDesktopRelease(fetcher = fetch, channel: UpdateChannel = 'stable', fingerprint = ''): Promise<DesktopRelease | null> {
45
+ const folder = channel === 'dev' ? `download/${DESKTOP_DEV_TAG}` : 'latest/download';
46
+ let manifest: any;
47
+ try {
48
+ const bytes = await download(`${desktopDownloads()}/${folder}/desktop.json`, fetcher, 64 * 1024, true);
49
+ if (!bytes) return null;
50
+ manifest = JSON.parse(bytes.toString('utf8'));
51
+ } catch { throw new Error('Could not check for updates.'); }
52
+ const version = manifest?.version;
53
+ if (manifest?.format !== 1 || (channel === 'dev' ? !devRevision(version) : !stableVersion(version))) throw new Error('Invalid release information.');
54
+ const tag = channel === 'dev' ? DESKTOP_DEV_TAG : `v${version}`, layer = manifest.layer;
55
+ const usable = fingerprint && manifest.fingerprint === fingerprint && typeof layer?.name === 'string' && /^papilot-app-[\w.-]+\.tgz$/.test(layer.name)
56
+ && Number.isSafeInteger(layer.size) && layer.size > 0 && /^[a-f0-9]{64}$/.test(layer.sha256);
57
+ return { version, downloadUrl: `${DESKTOP_RELEASES}/tag/${tag}`,
58
+ ...(usable ? { layer: { url: `${desktopDownloads()}/download/${tag}/${layer.name}`, size: layer.size, sha256: layer.sha256 } } : {}) };
49
59
  }
50
60
  const validTarget = (version: unknown, channel: UpdateChannel): version is string => channel === 'dev' ? Boolean(devRevision(version)) : stableVersion(version);
51
61
  const available = (next: string, current: string, channel: UpdateChannel) => validTarget(next, channel)
52
62
  && (channel === 'dev' || devRevision(current) ? next !== current : newerVersion(next, current));
53
63
  interface Options {
54
64
  desktop?: boolean;
55
- latestDesktop?: () => Promise<{ version: string; downloadUrl: string } | null>;
65
+ // A packaged desktop app: its bundled server and shell fingerprint.
66
+ bundle?: { root: string; version: string; fingerprint: string };
67
+ latestDesktop?: (channel: UpdateChannel) => Promise<DesktopRelease | null>;
68
+ installLayer?: (data: string, version: string, layer: DesktopLayer, bundle: { root: string; fingerprint: string }) => Promise<string>;
56
69
  root: string; data: string; version: string; port: number;
57
70
  busy: () => boolean; shutdown: () => void;
58
71
  latest?: (channel: UpdateChannel) => Promise<string | Record<string, any>>; install?: (data: string, version: string) => Promise<string>;
@@ -70,15 +83,23 @@ export class Updates {
70
83
  private file: string;
71
84
  private lock: string;
72
85
  private channel: UpdateChannel;
73
- private downloadUrl = DESKTOP_RELEASES;
86
+ private channelFile: string;
87
+ // Desktop: the release page when the update needs a new installer.
88
+ private downloadUrl?: string;
89
+ private target: DesktopRelease | null = null;
90
+ // Only packaged desktop apps install layers; the development app links to releases.
91
+ private layers: boolean;
74
92
  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;
93
+ const folder = path.join(options.data, options.desktop ? 'updates/desktop' : 'updates');
94
+ this.file = path.join(folder, 'status.json'); this.lock = path.join(folder, 'install.lock'); this.channelFile = path.join(folder, 'channel.json');
95
+ this.layers = Boolean(options.desktop && options.bundle?.fingerprint);
96
+ if (options.desktop && !this.layers) this.downloadUrl = DESKTOP_RELEASES;
97
+ const preference = readJson(this.channelFile)?.channel;
98
+ this.channel = options.desktop && !this.layers ? 'stable' : preference === 'dev' || preference === 'stable' ? preference : devRevision(options.version) ? 'dev' : 'stable';
99
+ let supported = options.desktop ? this.layers : options.supported ?? true;
79
100
  if (!options.desktop && options.supported === undefined) try { npmCli(); } catch { supported = false; }
80
101
  this.state = { currentVersion: options.version, latestVersion: null, phase: 'idle', checkedAt: null, error: null, canUpdate: supported };
81
- const saved = options.desktop ? null : readJson(this.file);
102
+ const saved = options.desktop && !this.layers ? null : readJson(this.file);
82
103
  if (saved && ['failed', 'updated', 'installing', 'waiting', 'restarting'].includes(saved.phase)) this.state = { ...this.state, ...saved, currentVersion: options.version, canUpdate: supported };
83
104
  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
105
  }
@@ -86,8 +107,8 @@ export class Updates {
86
107
  const saved = readJson(this.file);
87
108
  if (saved && activePhases.has(this.state.phase) && ['updated', 'failed'].includes(saved.phase)) this.state = { ...this.state, ...saved, currentVersion: this.options.version };
88
109
  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 };
110
+ if (desktop && !this.layers) return { currentVersion, latestVersion, phase, checkedAt, error, canUpdate: false, downloadUrl: this.downloadUrl };
111
+ return { currentVersion, latestVersion, phase, checkedAt, error, canUpdate, channel: this.channel, ...(this.downloadUrl ? { downloadUrl: this.downloadUrl } : {}) };
91
112
  }
92
113
  get restarting() { return this.snapshot().phase === 'restarting'; }
93
114
  enter() { this.inFlight++; let released = false; return () => { if (!released) { released = true; this.inFlight--; } }; }
@@ -104,13 +125,14 @@ export class Updates {
104
125
  writeJson(this.file, { ...this.state, pid: process.pid });
105
126
  }
106
127
  setChannel(channel: unknown) {
107
- if (this.options.desktop) throw new Error('Download desktop updates from GitHub Releases.');
128
+ if (this.options.desktop && !this.layers) throw new Error('Download desktop updates from GitHub Releases.');
108
129
  if (channel !== 'stable' && channel !== 'dev') throw new Error('Choose a valid update channel.');
109
130
  if (activePhases.has(this.state.phase)) throw new Error('Wait for the current update to finish before changing channels.');
110
131
  if (channel === this.channel) return this.check();
111
132
  this.channel = channel;
112
- writeJson(path.join(this.options.data, 'updates/channel.json'), { channel });
113
- this.checking = null;
133
+ writeJson(this.channelFile, { channel });
134
+ this.checking = null; this.target = null;
135
+ if (this.layers) this.downloadUrl = undefined;
114
136
  this.change({ latestVersion: null, checkedAt: null, phase: 'idle', error: null });
115
137
  return this.check();
116
138
  }
@@ -119,12 +141,12 @@ export class Updates {
119
141
  if (activePhases.has(this.state.phase)) return Promise.resolve(this.snapshot());
120
142
  this.change({ phase: 'checking' });
121
143
  const channel = this.channel;
122
- const discovery = this.options.desktop ? (this.options.latestDesktop || latestDesktopRelease)() : (this.options.latest || latestManifest)(channel);
144
+ const discovery = this.options.desktop ? this.desktopRelease(channel) : (this.options.latest || latestManifest)(channel);
123
145
  const checking = discovery.then(found => {
124
146
  const manifest = typeof found === 'string' ? { version: found } : found;
125
147
  if (!manifest && this.options.desktop) {
126
- if (!this.closed && this.checking === checking) {
127
- this.downloadUrl = DESKTOP_RELEASES;
148
+ if (!this.closed && this.checking === checking && !activePhases.has(this.state.phase)) {
149
+ this.target = null; this.downloadUrl = this.layers ? undefined : DESKTOP_RELEASES;
128
150
  this.change({ latestVersion: null, checkedAt: Date.now(), error: null, phase: 'idle' });
129
151
  }
130
152
  return this.snapshot();
@@ -133,7 +155,12 @@ export class Updates {
133
155
  if (!validTarget(version, channel)) throw new Error('Invalid release version.');
134
156
  if (!this.closed && this.checking === checking && !activePhases.has(this.state.phase)) {
135
157
  const newer = available(version, this.options.version, channel);
136
- if (this.options.desktop) this.downloadUrl = manifest!.downloadUrl;
158
+ if (this.options.desktop) {
159
+ const release = manifest as DesktopRelease, bundled = version === this.options.bundle?.version;
160
+ // A layer for this shell, or the installer's own server, installs in place.
161
+ this.target = this.layers && newer && (release.layer || bundled) ? release : null;
162
+ this.downloadUrl = this.layers ? newer && !this.target ? release.downloadUrl : undefined : release.downloadUrl;
163
+ }
137
164
  this.change({ latestVersion: version, checkedAt: Date.now(), error: null, phase: newer ? 'available' : 'idle' });
138
165
  }
139
166
  return this.snapshot();
@@ -144,9 +171,23 @@ export class Updates {
144
171
  this.checking = checking;
145
172
  return this.checking;
146
173
  }
174
+ private desktopRelease(channel: UpdateChannel) {
175
+ return (this.options.latestDesktop || (channel => latestDesktopRelease(fetch, channel, this.options.bundle?.fingerprint)))(channel);
176
+ }
177
+ // A retry after a restart finds the release again; the check that offered it
178
+ // happened in the previous process.
179
+ private async desktopLayer(version: string) {
180
+ const bundle = this.options.bundle!;
181
+ if (version === bundle.version) return bundle.root;
182
+ let release = this.target;
183
+ if (release?.version !== version) release = await this.desktopRelease(this.channel);
184
+ if (release?.version !== version || !release.layer) throw new Error('Check for a newer release before updating.');
185
+ return (this.options.installLayer || installLayer)(this.options.data, version, release.layer, bundle);
186
+ }
147
187
  install(version: unknown) {
148
- if (this.options.desktop) throw new Error('Download desktop updates from GitHub Releases.');
188
+ if (this.options.desktop && !this.layers) throw new Error('Download desktop updates from GitHub Releases.');
149
189
  if (activePhases.has(this.state.phase)) return this.snapshot();
190
+ if (this.options.desktop && this.downloadUrl) throw new Error('Download desktop updates from GitHub Releases.');
150
191
  if (!this.state.canUpdate) throw new Error('npm is unavailable to Papilot.');
151
192
  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
193
  fs.mkdirSync(path.dirname(this.lock), { recursive: true, mode: 0o700 });
@@ -155,7 +196,9 @@ export class Updates {
155
196
  if (previous) fs.rmSync(this.lock);
156
197
  const fd = fs.openSync(this.lock, 'wx', 0o600); fs.writeFileSync(fd, JSON.stringify({ pid: process.pid })); fs.closeSync(fd);
157
198
  this.change({ phase: 'installing', error: null });
158
- void (this.options.install || installRelease)(this.options.data, version).then(root => {
199
+ // Returning to the installer's own version needs no download.
200
+ const installing = this.options.desktop ? this.desktopLayer(version) : (this.options.install || installRelease)(this.options.data, version);
201
+ void installing.then(root => {
159
202
  if (this.closed) return;
160
203
  this.prepared = root; this.change({ phase: 'waiting' });
161
204
  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.