papilot 0.7.0 → 0.8.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +53 -100
- package/README.zh-CN.md +52 -97
- package/bin/doctor.mjs +55 -6
- package/bin/installation.d.mts +2 -0
- package/bin/installation.mjs +7 -1
- package/dist/assets/{_virtual_papilot-licenses-BWIsfrQh.js → _virtual_papilot-licenses-CMfCtEYQ.js} +54 -3
- package/dist/assets/index-BlLyIqT9.js +265 -0
- package/dist/assets/index-DVZtyZvu.css +1 -0
- package/dist/assets/latex-BarqJFtQ.svg +1 -0
- package/dist/assets/{math-BsEl3u6P.js → math-Bu3xEyrC.js} +1 -1
- package/dist/assets/pdf-view-PbkCuTZ-.js +598 -0
- package/dist/index.html +2 -2
- package/examples/papilot-paper/main.tex +1 -1
- package/examples/papilot-paper/preview/main.pdf +0 -0
- package/examples/papilot-paper/preview/main.synctex.gz +0 -0
- package/examples/papilot-paper/sections/02-using.tex +3 -1
- package/examples/papilot-paper/sections/09-agents.tex +8 -5
- package/package.json +18 -8
- package/skills/ai-review.md +18 -0
- package/skills/latex-warnings.md +1 -1
- package/src/conversation.ts +6 -0
- package/src/server/acp.ts +387 -0
- package/src/server/agents.ts +220 -43
- package/src/server/ai-review-routes.ts +72 -0
- package/src/server/ai-review.ts +38 -0
- package/src/server/app.ts +56 -14
- package/src/server/build-problems.ts +329 -0
- package/src/server/changes.ts +25 -2
- package/src/server/codex.ts +2 -1
- package/src/server/cursor.ts +153 -0
- package/src/server/desktop-demo.ts +38 -0
- package/src/server/latex-installation.ts +43 -0
- package/src/server/manuscript.ts +183 -23
- package/src/server/paths.ts +1 -1
- package/src/server/reconcile.ts +20 -0
- package/src/server/references.ts +70 -0
- package/src/server/review.ts +6 -5
- package/src/server/skills.ts +5 -8
- package/src/server/start.ts +19 -4
- package/src/server/store.ts +20 -2
- package/src/server/update-install.ts +53 -2
- package/src/server/update-worker.ts +6 -6
- package/src/server/updates.ts +49 -14
- package/src/server/workspace.ts +236 -41
- package/src/shared.ts +88 -9
- package/src/skill-icons.ts +18 -0
- package/src/skill-metadata.ts +45 -0
- package/vendor/latex-review/dist/web/assets/index-CXdnUjct.js +19 -0
- package/vendor/latex-review/dist/web/assets/index-DEz-LsGl.css +1 -0
- package/vendor/latex-review/dist/web/index.html +2 -2
- package/vendor/latex-review/src/core/bib.ts +692 -0
- package/vendor/latex-review/src/core/changes.ts +39 -0
- package/vendor/latex-review/src/core/current-commit.ts +104 -0
- package/vendor/latex-review/src/core/delivery.ts +62 -16
- package/vendor/latex-review/src/core/git.ts +2 -1
- package/vendor/latex-review/src/core/publish.ts +7 -1
- package/vendor/latex-review/src/core/reconcile.ts +101 -0
- package/vendor/latex-review/src/core/review.ts +6 -2
- package/vendor/latex-review/src/core/store.ts +1 -0
- package/vendor/latex-review/src/core/tasks.ts +1 -1
- package/vendor/latex-review/src/core/verify.ts +784 -0
- package/vendor/latex-review/src/core/view.ts +204 -0
- package/vendor/latex-review/src/render/round.ts +11 -0
- package/vendor/latex-review/src/server.ts +77 -1
- package/vendor/latex-review/src/types.ts +130 -2
- package/vendor/latex-review/src/web/effect-preferences.ts +10 -0
- package/vendor/latex-review/src/web/references.tsx +499 -0
- package/vendor/latex-review/src/web/review.tsx +263 -133
- package/vendor/latex-review/src/web/style.css +285 -0
- package/vendor/latex-review/src/web/zh-CN.ts +79 -2
- package/dist/assets/index-CIJEYlP7.js +0 -252
- package/dist/assets/index-hOJEruFc.css +0 -1
- package/dist/assets/pdf-view-Cz9_vK5U.js +0 -598
- package/vendor/latex-review/dist/web/assets/index-Be9CtgcZ.css +0 -1
- package/vendor/latex-review/dist/web/assets/index-TvMWVSaw.js +0 -19
package/src/server/review.ts
CHANGED
|
@@ -3,7 +3,7 @@ import path from 'node:path';
|
|
|
3
3
|
import { ENGINE } from './paths.ts';
|
|
4
4
|
import type { Task } from '../shared.ts';
|
|
5
5
|
|
|
6
|
-
export interface ReviewClient { url: string; token: string; get<T = any>(route: string): Promise<T>; post<T = any>(route: string, body?: unknown): Promise<T> }
|
|
6
|
+
export interface ReviewClient { url: string; token: string; get<T = any>(route: string): Promise<T>; post<T = any>(route: string, body?: unknown, timeout?: number): Promise<T> }
|
|
7
7
|
let modulePromise: Promise<{ connect: (root: string) => Promise<ReviewClient> }> | null = null;
|
|
8
8
|
export function reviewClient(root: string): Promise<ReviewClient> {
|
|
9
9
|
modulePromise ??= import(pathToFileURL(path.join(ENGINE, 'src/client.ts')).href);
|
|
@@ -34,15 +34,16 @@ export function annotateReviewState(value: unknown, tasks: readonly Task[]): unk
|
|
|
34
34
|
else if (task.status === 'building') decisionLock = 'Wait for the PDF build before accepting, rejecting, or undoing changes. You can still add comments.';
|
|
35
35
|
else if (['running', 'waiting_input', 'queued'].includes(task.status)) decisionLock = 'Pause this task in Papilot before accepting, rejecting, or undoing changes. You can still add comments.';
|
|
36
36
|
else if (task.pendingReview) decisionLock = 'Wait for the current review action before accepting, rejecting, or undoing changes. You can still add comments.';
|
|
37
|
+
else if (task.reconciliation && (!task.reconciliation.ready || Number(state.round) <= task.reconciliation.round)) decisionLock = 'Wait for the merge suggestion before changing review decisions.';
|
|
37
38
|
else if (task.delivery?.pending) decisionLock = 'Retry the pending delivery before changing review decisions.';
|
|
38
|
-
//
|
|
39
|
-
return { ...state, decisionLock, commentLock, ...(task ? { workspace: { status: task.status, unsent: task.review?.unsent ?? 0, comments: task.review?.comments ?? 0, accepted: task.review?.files?.length || (task.committed || task.delivery?.pending ? 1 : 0), ...(task.deferred ? { deferred: true, destination: task.delivery?.pending ? undefined : task.delivery?.mode } : {}) } } : {}) };
|
|
39
|
+
// Task-branch commits from older versions still need manuscript application.
|
|
40
|
+
return { ...state, decisionLock, commentLock, ...(task ? { workspace: { ...(task.reconciliation ? { merging: true } : {}), status: task.status, unsent: task.review?.unsent ?? 0, comments: task.review?.comments ?? 0, accepted: task.review?.files?.length || (task.committed || task.delivery?.pending || task.delivery?.mode === 'branch' ? 1 : 0), ...(task.deferred ? { deferred: true, destination: task.delivery?.pending ? undefined : task.delivery?.mode } : {}) } } : {}) };
|
|
40
41
|
}
|
|
41
42
|
|
|
42
43
|
export function allowedReviewRoute(route: string, method: string): boolean {
|
|
43
44
|
if (route.includes('..') || route.includes('\\')) return false;
|
|
44
|
-
if (method === 'GET') return route === '/' || /^\/(assets|fonts)\/[\w./-]+$/.test(route) || /^\/files\/[a-z0-9-]+\/\d+\/[\w.-]+\.(svg|png)$/.test(route) || /^\/api\/tasks(?:\/[a-z0-9-]+(?:\/(?:state|rounds\/\d+(?:\/effects\/c\d
|
|
45
|
-
return method === 'POST' && /^\/api\/tasks\/[a-z0-9-]+\/(decide|undo|note)$/.test(route);
|
|
45
|
+
if (method === 'GET') return route === '/' || /^\/(assets|fonts)\/[\w./-]+$/.test(route) || /^\/files\/[a-z0-9-]+\/\d+\/[\w.-]+\.(svg|png)$/.test(route) || /^\/api\/tasks(?:\/[a-z0-9-]+(?:\/(?:state|rounds\/\d+(?:\/effects\/c\d+|\/references)?))?)?$/.test(route);
|
|
46
|
+
return method === 'POST' && /^\/api\/tasks\/[a-z0-9-]+\/(decide|undo|note|rounds\/\d+\/references\/confirm)$/.test(route);
|
|
46
47
|
}
|
|
47
48
|
export function rewriteReviewJson(value: any, prefix: string): any {
|
|
48
49
|
if (Array.isArray(value)) return value.map(v => rewriteReviewJson(v, prefix));
|
package/src/server/skills.ts
CHANGED
|
@@ -3,6 +3,8 @@ import path from 'node:path';
|
|
|
3
3
|
import os from 'node:os';
|
|
4
4
|
import { ROOT } from './paths.ts';
|
|
5
5
|
import type { Skill } from '../shared.ts';
|
|
6
|
+
import { isSkillIcon } from '../skill-icons.ts';
|
|
7
|
+
import { skillMetadata, skillInstructions } from '../skill-metadata.ts';
|
|
6
8
|
|
|
7
9
|
// Skills are Markdown instructions an author runs with "/" in the composer.
|
|
8
10
|
// Papilot ships some in skills/; ~/.papilot/skills/ adds the author's
|
|
@@ -33,14 +35,9 @@ export function skillFiles(dir: string): { id: string; file: string; folder?: st
|
|
|
33
35
|
|
|
34
36
|
export function parseSkill(text: string, id: string, source: Skill['source'], folder?: string): SkillFile | null {
|
|
35
37
|
text = text.replace(/^\uFEFF/, '').replace(/\r\n?/g, '\n');
|
|
36
|
-
const
|
|
37
|
-
for (const line of header?.[1].split('\n') || []) {
|
|
38
|
-
const field = /^([A-Za-z-]+):\s*(.*)$/.exec(line);
|
|
39
|
-
if (field) fields[field[1].toLowerCase()] = field[2].trim().replace(/^(['"])(.*)\1$/, '$2');
|
|
40
|
-
}
|
|
41
|
-
const body = text.slice(header ? header[0].length : 0).trim().slice(0, 20000);
|
|
38
|
+
const fields = skillMetadata(text), body = skillInstructions(text).trim().slice(0, 20000);
|
|
42
39
|
if (!body) return null;
|
|
43
|
-
return { id, name: (fields.name || id).slice(0, 60), description: (fields.description || '').slice(0, 300), hint: (fields.hint || '').slice(0, 200), source, body, ...(folder ? { folder } : {}), order: fields.order && Number.isFinite(Number(fields.order)) ? Number(fields.order) : 1000 };
|
|
40
|
+
return { id, name: (fields.name || id).slice(0, 60), description: (fields.description || '').slice(0, 300), hint: (fields.hint || '').slice(0, 200), ...(isSkillIcon(fields.icon) ? { icon: fields.icon } : {}), source, body, ...(fields.output === 'annotations' ? { output: 'annotations' as const } : {}), ...(folder ? { folder } : {}), order: fields.order && Number.isFinite(Number(fields.order)) ? Number(fields.order) : 1000 };
|
|
44
41
|
}
|
|
45
42
|
|
|
46
43
|
function read(dir: string, source: Skill['source']): SkillFile[] {
|
|
@@ -67,4 +64,4 @@ export function findSkill(paper: string, id: string) {
|
|
|
67
64
|
return skill;
|
|
68
65
|
}
|
|
69
66
|
|
|
70
|
-
export const listedSkill = ({ id, name, description, hint, source }: SkillFile): Skill => ({ id, name, description, hint, source });
|
|
67
|
+
export const listedSkill = ({ id, name, description, hint, source, icon, output }: SkillFile): Skill => ({ id, name, description, hint, source, ...(icon ? { icon } : {}), ...(output ? { output } : {}) });
|
package/src/server/start.ts
CHANGED
|
@@ -11,6 +11,7 @@ import { Changes } from './changes.ts';
|
|
|
11
11
|
import { buildApp, gitQueue } from './app.ts';
|
|
12
12
|
import { Updates } from './updates.ts';
|
|
13
13
|
import { readJson } from '../../bin/installation.mjs';
|
|
14
|
+
import { desktopDemo } from './desktop-demo.ts';
|
|
14
15
|
|
|
15
16
|
const problem = accountProblem(ACCOUNT_ORIGIN, ACCOUNT_PORT, PORT, PUBLIC_ORIGIN);
|
|
16
17
|
if (problem) { console.error(problem); process.exit(1); }
|
|
@@ -18,20 +19,30 @@ const store = new Store(path.join(DATA, 'papilot.sqlite'));
|
|
|
18
19
|
const workspace = new Workspace(store);
|
|
19
20
|
const accounts = new Accounts(store, ACCOUNT_ORIGIN || null);
|
|
20
21
|
const manuscripts = new Manuscripts(), changes = new Changes();
|
|
21
|
-
const
|
|
22
|
+
const desktop = process.env.PAPILOT_DESKTOP === '1';
|
|
23
|
+
if (desktop) await desktopDemo(ROOT, DATA, store, workspace, manuscripts).catch(error => console.error('Could not prepare the demo paper:', error));
|
|
24
|
+
const updates = new Updates({ root: ROOT, data: DATA, version: VERSION, port: PORT,
|
|
25
|
+
...(desktop ? { supported: false, downloadUrl: 'https://github.com/PapilotAI/papilot/releases/latest' } : {}),
|
|
26
|
+
busy: () => workspace.busyForUpdate || manuscripts.busyForUpdate || changes.busyForUpdate, shutdown: () => shutdown() });
|
|
22
27
|
const activation = readJson(path.join(DATA, 'updates/active.json'));
|
|
23
28
|
const updateInstance = typeof activation?.root === 'string' && fs.existsSync(activation.root) && fs.realpathSync(activation.root) === fs.realpathSync(ROOT) ? activation.id : undefined;
|
|
24
29
|
const shared = {
|
|
25
30
|
store, workspace, accounts, manuscripts, changes, updates, updateInstance, csrf: randomBytes(32).toString('base64url'), serialGit: gitQueue(),
|
|
26
31
|
dist: fs.existsSync(path.join(ROOT, 'dist/index.html')) ? path.join(ROOT, 'dist') : null, version: VERSION,
|
|
27
32
|
};
|
|
28
|
-
const
|
|
33
|
+
const localListener = { kind: 'local' as const, port: PORT, publicOrigin: PUBLIC_ORIGIN, owner: OWNER };
|
|
34
|
+
const apps = [await buildApp({ ...shared, listener: localListener })];
|
|
29
35
|
if (ACCOUNT_ORIGIN) apps.push(await buildApp({ ...shared, listener: { kind: 'account', origin: ACCOUNT_ORIGIN, hsts: accountHsts(ACCOUNT_ORIGIN, PUBLIC_ORIGIN) } }));
|
|
30
36
|
|
|
31
|
-
await apps[0].listen({ host: '127.0.0.1', port: PORT })
|
|
37
|
+
const localUrl = await apps[0].listen({ host: '127.0.0.1', port: PORT }).catch(error => {
|
|
38
|
+
if (!desktop || error.code !== 'EADDRINUSE') throw error;
|
|
39
|
+
return apps[0].listen({ host: '127.0.0.1', port: 0 });
|
|
40
|
+
});
|
|
41
|
+
// Desktop requests an OS-assigned port. Keep Host/Origin checks exact after bind.
|
|
42
|
+
localListener.port = Number(new URL(localUrl).port);
|
|
32
43
|
if (apps[1]) await apps[1].listen({ host: '127.0.0.1', port: ACCOUNT_PORT });
|
|
33
44
|
store.recover();
|
|
34
|
-
console.log(`Papilot is running at
|
|
45
|
+
console.log(`Papilot is running at ${localUrl}${PUBLIC_ORIGIN ? `\nRemote: ${PUBLIC_ORIGIN}` : ''}${ACCOUNT_ORIGIN ? `\nSign-in for invited people: ${ACCOUNT_ORIGIN} (forwarded to 127.0.0.1:${ACCOUNT_PORT})` : ''}`);
|
|
35
46
|
if (!process.env.PAPILOT_UPDATE_PROBE) { void workspace.connect(); updates.start(); }
|
|
36
47
|
// Recompute cached feedback counts, including decisions from before this restart.
|
|
37
48
|
for (const task of store.tasks()) {
|
|
@@ -49,3 +60,7 @@ function shutdown() {
|
|
|
49
60
|
setTimeout(() => process.exit(0), 2500).unref();
|
|
50
61
|
}
|
|
51
62
|
for (const signal of ['SIGINT', 'SIGTERM']) process.on(signal, shutdown);
|
|
63
|
+
if (desktop && process.send) {
|
|
64
|
+
process.on('disconnect', shutdown);
|
|
65
|
+
process.send({ type: 'papilot:ready', url: localUrl });
|
|
66
|
+
}
|
package/src/server/store.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { ReviewSnapshot } from './ai-review.ts';
|
|
1
2
|
import { DatabaseSync } from 'node:sqlite';
|
|
2
3
|
import { EventEmitter } from 'node:events';
|
|
3
4
|
import fs from 'node:fs';
|
|
@@ -16,6 +17,7 @@ export class Store extends EventEmitter {
|
|
|
16
17
|
CREATE TABLE IF NOT EXISTS runs(id TEXT PRIMARY KEY, task_id TEXT NOT NULL, request_key TEXT UNIQUE NOT NULL, status TEXT NOT NULL, data TEXT NOT NULL);
|
|
17
18
|
CREATE TABLE IF NOT EXISTS items(id TEXT PRIMARY KEY, task_id TEXT NOT NULL, created INTEGER NOT NULL, data TEXT NOT NULL);
|
|
18
19
|
CREATE TABLE IF NOT EXISTS deleted_tasks(id TEXT PRIMARY KEY, deleted INTEGER NOT NULL);
|
|
20
|
+
CREATE TABLE IF NOT EXISTS ai_review_inputs(task_id TEXT PRIMARY KEY, data TEXT NOT NULL);
|
|
19
21
|
CREATE TABLE IF NOT EXISTS annotations(id TEXT PRIMARY KEY, project_id TEXT NOT NULL, data TEXT NOT NULL);
|
|
20
22
|
CREATE TABLE IF NOT EXISTS attachments(id TEXT PRIMARY KEY, project_id TEXT NOT NULL, data TEXT NOT NULL);
|
|
21
23
|
CREATE TABLE IF NOT EXISTS users(id TEXT PRIMARY KEY, data TEXT NOT NULL);
|
|
@@ -23,6 +25,7 @@ export class Store extends EventEmitter {
|
|
|
23
25
|
CREATE TABLE IF NOT EXISTS invites(token_hash TEXT PRIMARY KEY, user_id TEXT NOT NULL, expires INTEGER NOT NULL, data TEXT NOT NULL);
|
|
24
26
|
CREATE TABLE IF NOT EXISTS sessions(token_hash TEXT PRIMARY KEY, id TEXT UNIQUE NOT NULL, user_id TEXT NOT NULL, expires INTEGER NOT NULL, data TEXT NOT NULL);
|
|
25
27
|
CREATE TABLE IF NOT EXISTS members(project_id TEXT NOT NULL, user_id TEXT NOT NULL, PRIMARY KEY(project_id, user_id));
|
|
28
|
+
CREATE TABLE IF NOT EXISTS migrations(key TEXT PRIMARY KEY, done INTEGER NOT NULL);
|
|
26
29
|
CREATE INDEX IF NOT EXISTS task_items ON items(task_id,created);
|
|
27
30
|
CREATE INDEX IF NOT EXISTS task_runs ON runs(task_id,status);`);
|
|
28
31
|
}
|
|
@@ -39,7 +42,7 @@ export class Store extends EventEmitter {
|
|
|
39
42
|
this.transaction(() => {
|
|
40
43
|
// Only Papilot records are removed. Never finish review tasks or touch their files.
|
|
41
44
|
this.db.prepare('INSERT OR IGNORE INTO deleted_tasks SELECT id, ? FROM tasks WHERE project_id=?').run(Date.now(), id);
|
|
42
|
-
for (const table of ['items', 'runs']) this.db.prepare(`DELETE FROM ${table} WHERE task_id IN (SELECT id FROM tasks WHERE project_id=?)`).run(id);
|
|
45
|
+
for (const table of ['items', 'runs', 'ai_review_inputs']) this.db.prepare(`DELETE FROM ${table} WHERE task_id IN (SELECT id FROM tasks WHERE project_id=?)`).run(id);
|
|
43
46
|
for (const table of ['tasks', 'annotations', 'members']) this.db.prepare(`DELETE FROM ${table} WHERE project_id=?`).run(id);
|
|
44
47
|
this.db.prepare('DELETE FROM projects WHERE id=?').run(id);
|
|
45
48
|
});
|
|
@@ -51,6 +54,12 @@ export class Store extends EventEmitter {
|
|
|
51
54
|
runByKey(key: string) { const row = this.db.prepare('SELECT data FROM runs WHERE request_key=?').get(key) as { data: string } | undefined; return row ? JSON.parse(row.data) as Run : undefined; }
|
|
52
55
|
items(taskId: string) { return (this.db.prepare('SELECT data FROM items WHERE task_id=? ORDER BY created,rowid').all(taskId) as { data: string }[]).map(r => JSON.parse(r.data) as TimelineItem); }
|
|
53
56
|
saveItem(item: TimelineItem) { this.db.prepare('INSERT INTO items VALUES(?,?,?,?) ON CONFLICT(id) DO UPDATE SET data=excluded.data').run(item.id, item.taskId, item.createdAt, JSON.stringify(item)); this.emit('change'); }
|
|
57
|
+
saveReviewInput(taskId: string, snapshot: ReviewSnapshot) { this.db.prepare('INSERT INTO ai_review_inputs VALUES(?,?) ON CONFLICT(task_id) DO UPDATE SET data=excluded.data').run(taskId, JSON.stringify(snapshot)); }
|
|
58
|
+
reviewInput(taskId: string): ReviewSnapshot {
|
|
59
|
+
const row = this.db.prepare('SELECT data FROM ai_review_inputs WHERE task_id=?').get(taskId) as { data: string } | undefined;
|
|
60
|
+
if (!row) throw new Error('The PDF snapshot for this review is unavailable. Start a new AI review.');
|
|
61
|
+
return JSON.parse(row.data);
|
|
62
|
+
}
|
|
54
63
|
annotations(projectId: string) { return (this.db.prepare('SELECT data FROM annotations WHERE project_id=? ORDER BY rowid').all(projectId) as { data: string }[]).map(r => JSON.parse(r.data) as Annotation).filter(note => note.deletedAt === undefined); }
|
|
55
64
|
annotation(projectId: string, id: string) {
|
|
56
65
|
const row = this.db.prepare('SELECT data FROM annotations WHERE id=? AND project_id=?').get(id, projectId) as { data: string } | undefined;
|
|
@@ -69,10 +78,19 @@ export class Store extends EventEmitter {
|
|
|
69
78
|
this.db.prepare('INSERT INTO deleted_tasks VALUES(?,?) ON CONFLICT(id) DO NOTHING').run(id, Date.now());
|
|
70
79
|
this.db.prepare('DELETE FROM items WHERE task_id=?').run(id);
|
|
71
80
|
this.db.prepare('DELETE FROM runs WHERE task_id=?').run(id);
|
|
81
|
+
this.db.prepare('DELETE FROM ai_review_inputs WHERE task_id=?').run(id);
|
|
72
82
|
this.db.prepare('DELETE FROM tasks WHERE id=?').run(id);
|
|
73
83
|
});
|
|
74
84
|
this.emit('change');
|
|
75
85
|
}
|
|
86
|
+
// Runs a one-off change the first time this database sees its key.
|
|
87
|
+
migrate(key: string, fn: () => void) {
|
|
88
|
+
this.transaction(() => {
|
|
89
|
+
if (this.db.prepare('SELECT 1 FROM migrations WHERE key=?').get(key)) return;
|
|
90
|
+
fn();
|
|
91
|
+
this.db.prepare('INSERT INTO migrations VALUES(?,?)').run(key, Date.now());
|
|
92
|
+
});
|
|
93
|
+
}
|
|
76
94
|
transaction<T>(fn: () => T): T { this.db.exec('BEGIN IMMEDIATE'); try { const result = fn(); this.db.exec('COMMIT'); return result; } catch (e) { this.db.exec('ROLLBACK'); throw e; } }
|
|
77
95
|
recover() {
|
|
78
96
|
this.transaction(() => {
|
|
@@ -80,7 +98,7 @@ export class Store extends EventEmitter {
|
|
|
80
98
|
for (const t of this.tasks().filter(t => t.status === 'deleting')) this.updateTask(t.id, { error: 'The server restarted during deletion. Retry deletion to confirm cleanup.' });
|
|
81
99
|
for (const t of this.tasks().filter(t => t.pendingReview)) this.updateTask(t.id, { status: 'uncertain', error: 'The server restarted before a review action was confirmed. Select Check status before continuing.' });
|
|
82
100
|
for (const r of this.runs().filter(r => r.status === 'running')) this.saveRun({ ...r, status: 'interrupted', finishedAt: Date.now() });
|
|
83
|
-
for (const t of this.tasks().filter(t => ['running', 'waiting_input'].includes(t.status))) this.updateTask(t.id, { status: 'interrupted', turnId: null, error: 'The server restarted and interrupted the previous turn. Continue in the same conversation.' });
|
|
101
|
+
for (const t of this.tasks().filter(t => ['running', 'waiting_input'].includes(t.status) || (t.mode === 'review' && t.status === 'building'))) this.updateTask(t.id, { status: 'interrupted', turnId: null, error: 'The server restarted and interrupted the previous turn. Continue in the same conversation.' });
|
|
84
102
|
});
|
|
85
103
|
}
|
|
86
104
|
close() { this.db.close(); }
|
|
@@ -1,9 +1,13 @@
|
|
|
1
1
|
import fs from 'node:fs';
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import net from 'node:net';
|
|
4
|
-
import { spawn } from 'node:child_process';
|
|
4
|
+
import { execFile, spawn } from 'node:child_process';
|
|
5
|
+
import { promisify } from 'node:util';
|
|
5
6
|
import { createRequire } from 'node:module';
|
|
6
|
-
import { readJson, stableVersion } from '../../bin/installation.mjs';
|
|
7
|
+
import { devRevision, readJson, stableVersion } from '../../bin/installation.mjs';
|
|
8
|
+
|
|
9
|
+
export const DEV_REPOSITORY = 'https://github.com/PapilotAI/papilot.git';
|
|
10
|
+
const git = async (args: string[], cwd: string) => (await promisify(execFile)('git', args, { cwd, timeout: 120000, maxBuffer: 1024 * 1024, env: { ...process.env, GIT_TERMINAL_PROMPT: '0' } })).stdout.trim();
|
|
7
11
|
|
|
8
12
|
export function npmCli(env = process.env, executable = process.execPath) {
|
|
9
13
|
const candidates = [env.npm_execpath, path.join(path.dirname(executable), '../lib/node_modules/npm/bin/npm-cli.js'), path.join(path.dirname(executable), 'node_modules/npm/bin/npm-cli.js'),
|
|
@@ -52,7 +56,54 @@ export async function probeInstallation(root: string, version: string, stage: st
|
|
|
52
56
|
}
|
|
53
57
|
}
|
|
54
58
|
}
|
|
59
|
+
// Resolve main during discovery, then fetch that exact commit. Building in a
|
|
60
|
+
// managed directory keeps the running installation and any source checkout intact.
|
|
61
|
+
export async function prepareDev(root: string, version: string, log: number, run = runInstall, checkout = git) {
|
|
62
|
+
const revision = devRevision(version);
|
|
63
|
+
if (!revision) throw new Error('Invalid development version.');
|
|
64
|
+
await checkout(['init', '--quiet'], root);
|
|
65
|
+
await checkout(['-c', 'core.hooksPath=/dev/null', 'fetch', '--depth=1', DEV_REPOSITORY, revision], root);
|
|
66
|
+
await checkout(['-c', 'core.hooksPath=/dev/null', 'checkout', '--detach', 'FETCH_HEAD'], root);
|
|
67
|
+
if (await checkout(['rev-parse', 'HEAD'], root) !== revision) throw new Error('The development checkout does not match the requested commit.');
|
|
68
|
+
const pkg = readJson(path.join(root, 'package.json'));
|
|
69
|
+
if (pkg?.name !== 'papilot' || !stableVersion(pkg.version) || `${pkg.version}-dev.${revision}` !== version) throw new Error('The development checkout does not match the requested version.');
|
|
70
|
+
const flags = ['--ignore-scripts', '--no-audit', '--no-fund'];
|
|
71
|
+
// Test-only tools can exclude a Node version that supports the application.
|
|
72
|
+
// Build first, then enforce engine compatibility for the retained runtime.
|
|
73
|
+
await run(['ci', '--include=dev', '--engine-strict=false', ...flags], root, log);
|
|
74
|
+
const engine = path.join(root, 'vendor/latex-review');
|
|
75
|
+
await run(['ci', '--include=dev', '--engine-strict=false', ...flags], engine, log);
|
|
76
|
+
// Only this installed snapshot gets a dev version; the repository stays unchanged.
|
|
77
|
+
fs.writeFileSync(path.join(root, 'package.json'), JSON.stringify({ ...pkg, version }, null, 2) + '\n');
|
|
78
|
+
await run(['run', 'build'], engine, log);
|
|
79
|
+
await run(['run', 'build'], root, log);
|
|
80
|
+
for (const cwd of [root, engine]) {
|
|
81
|
+
// npm 10 checks even omitted dev dependencies under --engine-strict, so they
|
|
82
|
+
// are hidden while pruning. The manifest is then restored: the launcher and
|
|
83
|
+
// the agents read the tested Codex and Claude SDK versions from it.
|
|
84
|
+
const manifest = path.join(cwd, 'package.json'), original = fs.readFileSync(manifest, 'utf8');
|
|
85
|
+
const { devDependencies: _, ...runtime } = JSON.parse(original);
|
|
86
|
+
fs.writeFileSync(manifest, JSON.stringify(runtime, null, 2) + '\n');
|
|
87
|
+
try { await run(['prune', '--omit=dev', '--engine-strict', ...flags], cwd, log); }
|
|
88
|
+
finally { fs.writeFileSync(manifest, original); }
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
async function installDev(data: string, version: string) {
|
|
92
|
+
const releases = path.join(data, 'updates/releases'); fs.mkdirSync(releases, { recursive: true, mode: 0o700 });
|
|
93
|
+
const space = fs.statfsSync(releases);
|
|
94
|
+
if (space.bavail * space.bsize < 2 * 1024 ** 3) throw new Error('The dev build needs at least 2 GB of free disk space. Free some space and retry.');
|
|
95
|
+
const stage = fs.mkdtempSync(path.join(releases, 'dev-')), root = path.join(stage, 'papilot');
|
|
96
|
+
fs.mkdirSync(root);
|
|
97
|
+
const log = fs.openSync(path.join(data, 'updates/install.log'), 'a', 0o600);
|
|
98
|
+
try {
|
|
99
|
+
await prepareDev(root, version, log);
|
|
100
|
+
await probeInstallation(root, version, stage, log);
|
|
101
|
+
return root;
|
|
102
|
+
} catch (error) { fs.rmSync(stage, { recursive: true, force: true }); throw error; }
|
|
103
|
+
finally { fs.closeSync(log); }
|
|
104
|
+
}
|
|
55
105
|
export async function installRelease(data: string, version: string) {
|
|
106
|
+
if (devRevision(version)) return installDev(data, version);
|
|
56
107
|
if (!stableVersion(version)) throw new Error('Invalid release version.');
|
|
57
108
|
const releases = path.join(data, 'updates/releases'); fs.mkdirSync(releases, { recursive: true, mode: 0o700 });
|
|
58
109
|
const stage = fs.mkdtempSync(path.join(releases, version + '-')), root = path.join(stage, 'node_modules/papilot');
|
|
@@ -5,10 +5,10 @@ import path from 'node:path';
|
|
|
5
5
|
import os from 'node:os';
|
|
6
6
|
import { fileURLToPath } from 'node:url';
|
|
7
7
|
import { spawn, spawnSync, type ChildProcess } from 'node:child_process';
|
|
8
|
-
import { readJson,
|
|
8
|
+
import { devRevision, readJson, releaseVersion, writeJson } from '../../bin/installation.mjs';
|
|
9
9
|
import { health, loaderFor, sleep } from './update-install.ts';
|
|
10
10
|
|
|
11
|
-
export async function restartRelease(job: { id: string; root: string; version: string; previousRoot: string; previousVersion: string; data: string; port: number; parent: number; service: boolean }, ready = () => process.send?.('ready'), attempts = 100) {
|
|
11
|
+
export async function restartRelease(job: { id: string; root: string; version: string; channel?: 'stable' | 'dev'; previousRoot: string; previousVersion: string; data: string; port: number; parent: number; service: boolean }, ready = () => process.send?.('ready'), attempts = 100) {
|
|
12
12
|
const folder = path.join(job.data, 'updates'), activeFile = path.join(folder, 'active.json'), statusFile = path.join(folder, 'status.json'), lock = path.join(folder, 'install.lock');
|
|
13
13
|
const previous = readJson(activeFile), status = readJson(statusFile) || {};
|
|
14
14
|
const database = path.join(job.data, 'papilot.sqlite'), backup = path.join(folder, 'before-update.sqlite');
|
|
@@ -32,10 +32,10 @@ export async function restartRelease(job: { id: string; root: string; version: s
|
|
|
32
32
|
}
|
|
33
33
|
};
|
|
34
34
|
try {
|
|
35
|
-
if (!
|
|
35
|
+
if (!releaseVersion(job.version) || (job.channel && (job.channel === 'dev') !== Boolean(devRevision(job.version))) || !fs.realpathSync(job.root).startsWith(fs.realpathSync(path.join(folder, 'releases')) + path.sep) || readJson(path.join(job.root, 'package.json'))?.version !== job.version) throw new Error('Invalid update activation.');
|
|
36
36
|
writeJson(lock, { pid: process.pid });
|
|
37
37
|
writeJson(statusFile, { ...status, phase: 'restarting', pid: process.pid });
|
|
38
|
-
writeJson(activeFile, { root: job.root, version: job.version, id: job.id, phase: 'pending', pid: process.pid, previous });
|
|
38
|
+
writeJson(activeFile, { root: job.root, version: job.version, channel: job.channel, id: job.id, phase: 'pending', pid: process.pid, previous });
|
|
39
39
|
ready();
|
|
40
40
|
for (let i = 0; i < 150 && alive(job.parent); i++) await sleep(100);
|
|
41
41
|
if (alive(job.parent)) throw new Error('Papilot did not finish shutting down.');
|
|
@@ -47,11 +47,11 @@ export async function restartRelease(job: { id: string; root: string; version: s
|
|
|
47
47
|
if (fs.existsSync(database + '-wal')) fs.copyFileSync(database + '-wal', backup + '-wal');
|
|
48
48
|
else fs.rmSync(backup + '-wal', { force: true });
|
|
49
49
|
}
|
|
50
|
-
writeJson(activeFile, { root: job.root, version: job.version, id: job.id, phase: 'starting', pid: process.pid, previous });
|
|
50
|
+
writeJson(activeFile, { root: job.root, version: job.version, channel: job.channel, id: job.id, phase: 'starting', pid: process.pid, previous });
|
|
51
51
|
launch(job.root);
|
|
52
52
|
for (let i = 0; i < attempts; i++) {
|
|
53
53
|
if (await health(job.port, job.version, job.id)) {
|
|
54
|
-
writeJson(activeFile, { root: job.root, version: job.version, id: job.id, phase: 'active' });
|
|
54
|
+
writeJson(activeFile, { root: job.root, version: job.version, channel: job.channel, id: job.id, phase: 'active' });
|
|
55
55
|
writeJson(statusFile, { ...status, phase: 'updated', currentVersion: job.version, error: null, pid: process.pid });
|
|
56
56
|
return;
|
|
57
57
|
}
|
package/src/server/updates.ts
CHANGED
|
@@ -2,25 +2,40 @@ import fs from 'node:fs';
|
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import { spawn } from 'node:child_process';
|
|
4
4
|
import { randomUUID } from 'node:crypto';
|
|
5
|
-
import { newerVersion, readJson, stableVersion, writeJson } from '../../bin/installation.mjs';
|
|
5
|
+
import { devRevision, newerVersion, readJson, stableVersion, writeJson } from '../../bin/installation.mjs';
|
|
6
6
|
import { installRelease, loaderFor, npmCli } from './update-install.ts';
|
|
7
|
-
import type { UpdateStatus } from '../shared.ts';
|
|
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
|
-
|
|
12
|
-
const response = await
|
|
11
|
+
async function releaseInfo(url: string, fetcher: typeof fetch) {
|
|
12
|
+
const response = await fetcher(url, { signal: AbortSignal.timeout(10000), redirect: 'error', headers: { Accept: 'application/json', 'User-Agent': 'Papilot' } });
|
|
13
13
|
if (!response.ok) throw new Error('Could not check for updates.');
|
|
14
14
|
const text = await response.text();
|
|
15
15
|
if (text.length > 256000) throw new Error('Invalid release information.');
|
|
16
|
-
|
|
16
|
+
return JSON.parse(text);
|
|
17
|
+
}
|
|
18
|
+
export async function latestRelease(channel: UpdateChannel = 'stable', fetcher = fetch): Promise<string> {
|
|
19
|
+
if (channel === 'dev') {
|
|
20
|
+
const commit = await releaseInfo('https://api.github.com/repos/PapilotAI/papilot/git/ref/heads/main', fetcher);
|
|
21
|
+
const revision = commit.object?.sha;
|
|
22
|
+
if (!/^[a-f0-9]{40}$/.test(revision)) throw new Error('Invalid development revision.');
|
|
23
|
+
const pkg = await releaseInfo(`https://raw.githubusercontent.com/PapilotAI/papilot/${revision}/package.json`, fetcher);
|
|
24
|
+
if (pkg.name !== 'papilot' || !stableVersion(pkg.version)) throw new Error('Invalid development version.');
|
|
25
|
+
return `${pkg.version}-dev.${revision}`;
|
|
26
|
+
}
|
|
27
|
+
const pkg = await releaseInfo('https://registry.npmjs.org/papilot/latest', fetcher);
|
|
17
28
|
if (pkg.name !== 'papilot' || !stableVersion(pkg.version)) throw new Error('Invalid release information.');
|
|
18
29
|
return pkg.version;
|
|
19
30
|
}
|
|
31
|
+
const validTarget = (version: unknown, channel: UpdateChannel): version is string => channel === 'dev' ? Boolean(devRevision(version)) : stableVersion(version);
|
|
32
|
+
const available = (next: string, current: string, channel: UpdateChannel) => validTarget(next, channel)
|
|
33
|
+
&& (channel === 'dev' || devRevision(current) ? next !== current : newerVersion(next, current));
|
|
20
34
|
interface Options {
|
|
35
|
+
downloadUrl?: string;
|
|
21
36
|
root: string; data: string; version: string; port: number;
|
|
22
37
|
busy: () => boolean; shutdown: () => void;
|
|
23
|
-
latest?: () => Promise<string>; install?: (data: string, version: string) => Promise<string>;
|
|
38
|
+
latest?: (channel: UpdateChannel) => Promise<string>; install?: (data: string, version: string) => Promise<string>;
|
|
24
39
|
restart?: (root: string, version: string) => Promise<void>; supported?: boolean;
|
|
25
40
|
}
|
|
26
41
|
// Both HTTP listeners share one updater. Downloads do not interrupt editing;
|
|
@@ -35,8 +50,11 @@ export class Updates {
|
|
|
35
50
|
private closed = false;
|
|
36
51
|
private file: string;
|
|
37
52
|
private lock: string;
|
|
53
|
+
private channel: UpdateChannel;
|
|
38
54
|
constructor(private options: Options) {
|
|
39
55
|
this.file = path.join(options.data, 'updates/status.json'); this.lock = path.join(options.data, 'updates/install.lock');
|
|
56
|
+
const preference = readJson(path.join(options.data, 'updates/channel.json'))?.channel;
|
|
57
|
+
this.channel = options.downloadUrl ? 'stable' : preference === 'dev' || preference === 'stable' ? preference : devRevision(options.version) ? 'dev' : 'stable';
|
|
40
58
|
let supported = options.supported ?? true;
|
|
41
59
|
if (options.supported === undefined) try { npmCli(); } catch { supported = false; }
|
|
42
60
|
this.state = { currentVersion: options.version, latestVersion: null, phase: 'idle', checkedAt: null, error: null, canUpdate: supported };
|
|
@@ -45,14 +63,16 @@ export class Updates {
|
|
|
45
63
|
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.' });
|
|
46
64
|
}
|
|
47
65
|
snapshot(): UpdateStatus {
|
|
66
|
+
if (this.options.downloadUrl) return { currentVersion: this.options.version, latestVersion: null, phase: 'idle', checkedAt: null, error: null, canUpdate: false, channel: 'stable', downloadUrl: this.options.downloadUrl };
|
|
48
67
|
const saved = readJson(this.file);
|
|
49
68
|
if (saved && activePhases.has(this.state.phase) && ['updated', 'failed'].includes(saved.phase)) this.state = { ...this.state, ...saved, currentVersion: this.options.version };
|
|
50
69
|
const { currentVersion, latestVersion, phase, checkedAt, error, canUpdate } = this.state;
|
|
51
|
-
return { currentVersion, latestVersion, phase, checkedAt, error, canUpdate };
|
|
70
|
+
return { currentVersion, latestVersion, phase, checkedAt, error, canUpdate, channel: this.channel };
|
|
52
71
|
}
|
|
53
72
|
get restarting() { return this.snapshot().phase === 'restarting'; }
|
|
54
73
|
enter() { this.inFlight++; let released = false; return () => { if (!released) { released = true; this.inFlight--; } }; }
|
|
55
74
|
start() {
|
|
75
|
+
if (this.options.downloadUrl) return;
|
|
56
76
|
if (this.state.phase !== 'failed') void this.check();
|
|
57
77
|
this.timer = setInterval(() => {
|
|
58
78
|
if (this.state.phase === 'waiting') void this.activate();
|
|
@@ -64,24 +84,39 @@ export class Updates {
|
|
|
64
84
|
this.state = { ...this.state, ...patch };
|
|
65
85
|
writeJson(this.file, { ...this.state, pid: process.pid });
|
|
66
86
|
}
|
|
87
|
+
setChannel(channel: unknown) {
|
|
88
|
+
if (this.options.downloadUrl) throw new Error('Download desktop updates from GitHub Releases.');
|
|
89
|
+
if (channel !== 'stable' && channel !== 'dev') throw new Error('Choose a valid update channel.');
|
|
90
|
+
if (activePhases.has(this.state.phase)) throw new Error('Wait for the current update to finish before changing channels.');
|
|
91
|
+
if (channel === this.channel) return this.check();
|
|
92
|
+
this.channel = channel;
|
|
93
|
+
writeJson(path.join(this.options.data, 'updates/channel.json'), { channel });
|
|
94
|
+
this.checking = null;
|
|
95
|
+
this.change({ latestVersion: null, checkedAt: null, phase: 'idle', error: null });
|
|
96
|
+
return this.check();
|
|
97
|
+
}
|
|
67
98
|
check(): Promise<UpdateStatus> {
|
|
99
|
+
if (this.options.downloadUrl) return Promise.resolve(this.snapshot());
|
|
68
100
|
if (this.checking) return this.checking;
|
|
69
101
|
if (activePhases.has(this.state.phase)) return Promise.resolve(this.snapshot());
|
|
70
102
|
this.change({ phase: 'checking' });
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
if (!
|
|
103
|
+
const channel = this.channel;
|
|
104
|
+
const checking = (this.options.latest || latestRelease)(channel).then(version => {
|
|
105
|
+
if (!validTarget(version, channel)) throw new Error('Invalid release version.');
|
|
106
|
+
if (!this.closed && this.checking === checking && !activePhases.has(this.state.phase)) this.change({ latestVersion: version, checkedAt: Date.now(), error: null, phase: available(version, this.options.version, channel) ? 'available' : 'idle' });
|
|
74
107
|
return this.snapshot();
|
|
75
108
|
}).catch(() => {
|
|
76
|
-
if (!this.closed && !activePhases.has(this.state.phase)) this.change({ phase: this.state.latestVersion &&
|
|
109
|
+
if (!this.closed && this.checking === checking && !activePhases.has(this.state.phase)) this.change({ phase: this.state.latestVersion && available(this.state.latestVersion, this.options.version, channel) ? 'available' : 'idle', checkedAt: Date.now(), error: 'Could not check for updates. Try again when connected.' });
|
|
77
110
|
return this.snapshot();
|
|
78
|
-
}).finally(() => { this.checking = null; });
|
|
111
|
+
}).finally(() => { if (this.checking === checking) this.checking = null; });
|
|
112
|
+
this.checking = checking;
|
|
79
113
|
return this.checking;
|
|
80
114
|
}
|
|
81
115
|
install(version: unknown) {
|
|
116
|
+
if (this.options.downloadUrl) throw new Error('Download desktop updates from GitHub Releases.');
|
|
82
117
|
if (activePhases.has(this.state.phase)) return this.snapshot();
|
|
83
118
|
if (!this.state.canUpdate) throw new Error('npm is unavailable to Papilot.');
|
|
84
|
-
if (!
|
|
119
|
+
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.');
|
|
85
120
|
fs.mkdirSync(path.dirname(this.lock), { recursive: true, mode: 0o700 });
|
|
86
121
|
const previous = readJson(this.lock);
|
|
87
122
|
if (previous && alive(previous.pid)) throw new Error('Another Papilot instance is already updating.');
|
|
@@ -105,7 +140,7 @@ export class Updates {
|
|
|
105
140
|
private release() { const lock = readJson(this.lock); if (lock?.pid === process.pid) fs.rmSync(this.lock, { force: true }); }
|
|
106
141
|
private restart(root: string, version: string) {
|
|
107
142
|
const folder = path.dirname(this.file), job = path.join(folder, 'restart.json');
|
|
108
|
-
writeJson(job, { id: randomUUID(), root, version, previousRoot: this.options.root, previousVersion: this.options.version, data: this.options.data, port: this.options.port, parent: process.pid,
|
|
143
|
+
writeJson(job, { id: randomUUID(), root, version, channel: this.channel, previousRoot: this.options.root, previousVersion: this.options.version, data: this.options.data, port: this.options.port, parent: process.pid,
|
|
109
144
|
service: process.env.PAPILOT_SERVICE_MANAGER === 'launchd' || process.env.XPC_SERVICE_NAME === 'local.papilot' });
|
|
110
145
|
const log = fs.openSync(path.join(folder, 'restart.log'), 'a', 0o600);
|
|
111
146
|
const child = spawn(process.execPath, ['--import', loaderFor(this.options.root), path.join(this.options.root, 'src/server/update-worker.ts'), job], { cwd: this.options.root, env: process.env, detached: true, stdio: ['ignore', log, log, 'ipc'] });
|