papilot 0.6.0 → 0.8.0

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.
Files changed (85) hide show
  1. package/README.md +58 -30
  2. package/README.zh-CN.md +58 -30
  3. package/bin/doctor.mjs +53 -6
  4. package/bin/installation.d.mts +8 -0
  5. package/bin/installation.mjs +54 -0
  6. package/bin/papilot.mjs +6 -2
  7. package/dist/assets/_virtual_papilot-licenses-D7ro6ecN.js +7597 -0
  8. package/dist/assets/index-Cc-awdNn.css +1 -0
  9. package/dist/assets/index-D_VX51no.js +265 -0
  10. package/dist/assets/latex-BarqJFtQ.svg +1 -0
  11. package/dist/assets/{math-DY-H5SfX.js → math-V9uUaUzA.js} +1 -1
  12. package/dist/assets/pdf-view-Bnb2uIBE.js +598 -0
  13. package/dist/index.html +2 -2
  14. package/examples/papilot-paper/main.tex +1 -1
  15. package/examples/papilot-paper/sections/02-using.tex +3 -1
  16. package/examples/papilot-paper/sections/09-agents.tex +8 -5
  17. package/package.json +15 -7
  18. package/scripts/service.ts +1 -0
  19. package/skills/ai-review.md +18 -0
  20. package/skills/latex-warnings.md +1 -1
  21. package/src/conversation.ts +6 -0
  22. package/src/note-groups.ts +30 -0
  23. package/src/notes.ts +34 -10
  24. package/src/server/acp.ts +354 -0
  25. package/src/server/agents.ts +237 -47
  26. package/src/server/ai-review-routes.ts +72 -0
  27. package/src/server/ai-review.ts +38 -0
  28. package/src/server/annotation-placement.ts +99 -0
  29. package/src/server/app.ts +216 -21
  30. package/src/server/attachments.ts +128 -0
  31. package/src/server/build-problems.ts +329 -0
  32. package/src/server/changes.ts +28 -2
  33. package/src/server/codex.ts +2 -1
  34. package/src/server/cursor.ts +153 -0
  35. package/src/server/diagnostics.ts +45 -0
  36. package/src/server/git-overview.ts +69 -0
  37. package/src/server/imports.ts +205 -0
  38. package/src/server/latex-installation.ts +43 -0
  39. package/src/server/main.ts +8 -42
  40. package/src/server/manuscript.ts +212 -12
  41. package/src/server/note-results.ts +65 -0
  42. package/src/server/paths.ts +1 -1
  43. package/src/server/references.ts +70 -0
  44. package/src/server/review.ts +6 -5
  45. package/src/server/skill-library.ts +113 -0
  46. package/src/server/skill-routes.ts +19 -0
  47. package/src/server/skills.ts +29 -19
  48. package/src/server/start.ts +51 -0
  49. package/src/server/store.ts +44 -4
  50. package/src/server/update-install.ts +124 -0
  51. package/src/server/update-worker.ts +78 -0
  52. package/src/server/updates.ts +152 -0
  53. package/src/server/workspace.ts +450 -60
  54. package/src/shared.ts +120 -10
  55. package/src/skill-icons.ts +18 -0
  56. package/src/skill-metadata.ts +45 -0
  57. package/src/task-caption.ts +20 -0
  58. package/vendor/latex-review/dist/web/assets/index-7VKAte5x.js +19 -0
  59. package/vendor/latex-review/dist/web/assets/index-DEz-LsGl.css +1 -0
  60. package/vendor/latex-review/dist/web/index.html +2 -2
  61. package/vendor/latex-review/src/core/bib.ts +692 -0
  62. package/vendor/latex-review/src/core/build.ts +5 -1
  63. package/vendor/latex-review/src/core/changes.ts +39 -0
  64. package/vendor/latex-review/src/core/delivery.ts +109 -0
  65. package/vendor/latex-review/src/core/review.ts +12 -4
  66. package/vendor/latex-review/src/core/store.ts +3 -0
  67. package/vendor/latex-review/src/core/tasks.ts +5 -2
  68. package/vendor/latex-review/src/core/verify.ts +784 -0
  69. package/vendor/latex-review/src/core/view.ts +204 -0
  70. package/vendor/latex-review/src/render/round.ts +11 -0
  71. package/vendor/latex-review/src/server.ts +81 -1
  72. package/vendor/latex-review/src/types.ts +141 -0
  73. package/vendor/latex-review/src/web/effect-preferences.ts +10 -0
  74. package/vendor/latex-review/src/web/i18n.ts +1 -1
  75. package/vendor/latex-review/src/web/overview.tsx +1 -5
  76. package/vendor/latex-review/src/web/references.tsx +499 -0
  77. package/vendor/latex-review/src/web/review.tsx +381 -230
  78. package/vendor/latex-review/src/web/shortcuts.ts +53 -0
  79. package/vendor/latex-review/src/web/style.css +295 -0
  80. package/vendor/latex-review/src/web/zh-CN.ts +95 -26
  81. package/dist/assets/index-BNGqA0_L.css +0 -1
  82. package/dist/assets/index-Bt9UXsGX.js +0 -222
  83. package/dist/assets/pdf-view-BGp3W-RG.js +0 -598
  84. package/vendor/latex-review/dist/web/assets/index-AFBj4gQ9.js +0 -19
  85. package/vendor/latex-review/dist/web/assets/index-DfC0GURl.css +0 -1
@@ -1,47 +1,57 @@
1
1
  import fs from 'node:fs';
2
2
  import path from 'node:path';
3
- import { DATA, ROOT } from './paths.ts';
3
+ import os from 'node:os';
4
+ import { ROOT } from './paths.ts';
4
5
  import type { Skill } from '../shared.ts';
6
+ import { isSkillIcon } from '../skill-icons.ts';
7
+ import { skillMetadata, skillInstructions } from '../skill-metadata.ts';
5
8
 
6
9
  // Skills are Markdown instructions an author runs with "/" in the composer.
7
- // Papilot ships some in skills/; the data folder's skills/ adds the author's
10
+ // Papilot ships some in skills/; ~/.papilot/skills/ adds the author's
8
11
  // own for every paper, and a paper's .papilot/skills/ its own. A later source
9
12
  // replaces an earlier skill with the same name. Each skill is <name>.md, or a
10
13
  // folder with a SKILL.md (the Agent Skills layout) whose other files the
11
14
  // agent may read. Front matter gives its name, description, composer hint
12
15
  // and order.
13
16
  export interface SkillFile extends Skill { body: string; folder?: string; order: number }
14
- const NAME = /^[a-z0-9][a-z0-9-]{0,47}$/;
17
+ export const SKILL_NAME = /^[a-z0-9][a-z0-9-]{0,47}$/;
18
+ // Global authoring tools belong to the user, independently of the database or
19
+ // a temporary demo's PAPILOT_DATA directory.
20
+ export const skillRoots = { builtin: path.join(ROOT, 'skills'), user: path.join(os.homedir(), '.papilot', 'skills') };
21
+
22
+ // Both the composer and the library address the same files, including legacy
23
+ // <name>.md files. New skills use the portable <name>/SKILL.md layout.
24
+ export function skillFiles(dir: string): { id: string; file: string; folder?: string }[] {
25
+ let entries: fs.Dirent[];
26
+ try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return []; }
27
+ return entries.sort((a, b) => a.name.localeCompare(b.name)).flatMap(entry => {
28
+ const id = entry.name.replace(/\.md$/i, '').toLowerCase();
29
+ if (!SKILL_NAME.test(id)) return [];
30
+ const folder = path.join(dir, entry.name);
31
+ return entry.isDirectory() ? [{ id, file: path.join(folder, 'SKILL.md'), folder }]
32
+ : entry.isFile() && /\.md$/i.test(entry.name) ? [{ id, file: folder }] : [];
33
+ });
34
+ }
15
35
 
16
36
  export function parseSkill(text: string, id: string, source: Skill['source'], folder?: string): SkillFile | null {
17
37
  text = text.replace(/^\uFEFF/, '').replace(/\r\n?/g, '\n');
18
- const header = /^---\n([\s\S]*?)\n---(?:\n|$)/.exec(text), fields: Record<string, string> = {};
19
- for (const line of header?.[1].split('\n') || []) {
20
- const field = /^([A-Za-z-]+):\s*(.*)$/.exec(line);
21
- if (field) fields[field[1].toLowerCase()] = field[2].trim().replace(/^(['"])(.*)\1$/, '$2');
22
- }
23
- 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);
24
39
  if (!body) return null;
25
- 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 };
26
41
  }
27
42
 
28
43
  function read(dir: string, source: Skill['source']): SkillFile[] {
29
- let entries: fs.Dirent[];
30
- try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return []; }
31
44
  const found: SkillFile[] = [];
32
- for (const entry of entries) {
33
- const id = entry.name.replace(/\.md$/i, '').toLowerCase();
34
- if (!NAME.test(id)) continue;
45
+ for (const { id, file, folder } of skillFiles(dir)) {
35
46
  try {
36
- const skill = entry.isDirectory() ? parseSkill(fs.readFileSync(path.join(dir, entry.name, 'SKILL.md'), 'utf8'), id, source, path.join(dir, entry.name))
37
- : entry.isFile() && /\.md$/i.test(entry.name) ? parseSkill(fs.readFileSync(path.join(dir, entry.name), 'utf8'), id, source) : null;
47
+ const skill = parseSkill(fs.readFileSync(file, 'utf8'), id, source, folder);
38
48
  if (skill) found.push(skill);
39
49
  } catch { /* A folder without a readable SKILL.md is not a skill. */ }
40
50
  }
41
51
  return found;
42
52
  }
43
53
 
44
- export function loadSkills(paper?: string, roots = { builtin: path.join(ROOT, 'skills'), user: path.join(DATA, 'skills') }): SkillFile[] {
54
+ export function loadSkills(paper?: string, roots = skillRoots): SkillFile[] {
45
55
  const skills = new Map<string, SkillFile>();
46
56
  const sources: [string, Skill['source']][] = [[roots.builtin, 'builtin'], [roots.user, 'user'], ...(paper ? [[path.join(paper, '.papilot', 'skills'), 'paper'] as [string, Skill['source']]] : [])];
47
57
  for (const [dir, source] of sources) for (const skill of read(dir, source)) skills.set(skill.id, skill);
@@ -54,4 +64,4 @@ export function findSkill(paper: string, id: string) {
54
64
  return skill;
55
65
  }
56
66
 
57
- 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 } : {}) });
@@ -0,0 +1,51 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { randomBytes } from 'node:crypto';
4
+ import { Store } from './store.ts';
5
+ import { Workspace } from './workspace.ts';
6
+ import { Accounts } from './accounts.ts';
7
+ import { ROOT, DATA, PORT, PUBLIC_ORIGIN, OWNER, VERSION, ACCOUNT_ORIGIN, ACCOUNT_PORT } from './paths.ts';
8
+ import { accountHsts, accountProblem } from './security.ts';
9
+ import { Manuscripts } from './manuscript.ts';
10
+ import { Changes } from './changes.ts';
11
+ import { buildApp, gitQueue } from './app.ts';
12
+ import { Updates } from './updates.ts';
13
+ import { readJson } from '../../bin/installation.mjs';
14
+
15
+ const problem = accountProblem(ACCOUNT_ORIGIN, ACCOUNT_PORT, PORT, PUBLIC_ORIGIN);
16
+ if (problem) { console.error(problem); process.exit(1); }
17
+ const store = new Store(path.join(DATA, 'papilot.sqlite'));
18
+ const workspace = new Workspace(store);
19
+ const accounts = new Accounts(store, ACCOUNT_ORIGIN || null);
20
+ const manuscripts = new Manuscripts(), changes = new Changes();
21
+ const updates = new Updates({ root: ROOT, data: DATA, version: VERSION, port: PORT, busy: () => workspace.busyForUpdate || manuscripts.busyForUpdate || changes.busyForUpdate, shutdown: () => shutdown() });
22
+ const activation = readJson(path.join(DATA, 'updates/active.json'));
23
+ const updateInstance = typeof activation?.root === 'string' && fs.existsSync(activation.root) && fs.realpathSync(activation.root) === fs.realpathSync(ROOT) ? activation.id : undefined;
24
+ const shared = {
25
+ store, workspace, accounts, manuscripts, changes, updates, updateInstance, csrf: randomBytes(32).toString('base64url'), serialGit: gitQueue(),
26
+ dist: fs.existsSync(path.join(ROOT, 'dist/index.html')) ? path.join(ROOT, 'dist') : null, version: VERSION,
27
+ };
28
+ const apps = [await buildApp({ ...shared, listener: { kind: 'local', port: PORT, publicOrigin: PUBLIC_ORIGIN, owner: OWNER } })];
29
+ if (ACCOUNT_ORIGIN) apps.push(await buildApp({ ...shared, listener: { kind: 'account', origin: ACCOUNT_ORIGIN, hsts: accountHsts(ACCOUNT_ORIGIN, PUBLIC_ORIGIN) } }));
30
+
31
+ await apps[0].listen({ host: '127.0.0.1', port: PORT });
32
+ if (apps[1]) await apps[1].listen({ host: '127.0.0.1', port: ACCOUNT_PORT });
33
+ store.recover();
34
+ console.log(`Papilot is running at http://127.0.0.1:${PORT}${PUBLIC_ORIGIN ? `\nRemote: ${PUBLIC_ORIGIN}` : ''}${ACCOUNT_ORIGIN ? `\nSign-in for invited people: ${ACCOUNT_ORIGIN} (forwarded to 127.0.0.1:${ACCOUNT_PORT})` : ''}`);
35
+ if (!process.env.PAPILOT_UPDATE_PROBE) { void workspace.connect(); updates.start(); }
36
+ // Recompute cached feedback counts, including decisions from before this restart.
37
+ for (const task of store.tasks()) {
38
+ if (task.status === 'building') {
39
+ store.updateTask(task.id, { status: 'interrupted', error: 'The server restarted during the PDF build. Check the review and rebuild it if needed.' });
40
+ void workspace.refresh(task.id);
41
+ } else if (task.review?.round && task.status !== 'deleting') void workspace.refresh(task.id);
42
+ }
43
+ // Expired invites and sessions are removed as they lapse.
44
+ setInterval(() => accounts.purge(), 3600000).unref();
45
+ let shuttingDown = false;
46
+ function shutdown() {
47
+ if (shuttingDown) return; shuttingDown = true; updates.close(); workspace.close();
48
+ void Promise.all(apps.map(app => app.close())).finally(() => { store.close(); process.exit(0); });
49
+ setTimeout(() => process.exit(0), 2500).unref();
50
+ }
51
+ for (const signal of ['SIGINT', 'SIGTERM']) process.on(signal, shutdown);
@@ -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';
@@ -6,7 +7,7 @@ import type { Annotation, Project, Task, Run, TimelineItem } from '../shared.ts'
6
7
 
7
8
  export class Store extends EventEmitter {
8
9
  db: DatabaseSync;
9
- constructor(file: string) {
10
+ constructor(public readonly file: string) {
10
11
  super();
11
12
  fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
12
13
  this.db = new DatabaseSync(file);
@@ -16,12 +17,15 @@ 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);
22
+ CREATE TABLE IF NOT EXISTS attachments(id TEXT PRIMARY KEY, project_id TEXT NOT NULL, data TEXT NOT NULL);
20
23
  CREATE TABLE IF NOT EXISTS users(id TEXT PRIMARY KEY, data TEXT NOT NULL);
21
24
  CREATE TABLE IF NOT EXISTS credentials(id TEXT PRIMARY KEY, user_id TEXT NOT NULL, data TEXT NOT NULL);
22
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);
23
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);
24
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);
25
29
  CREATE INDEX IF NOT EXISTS task_items ON items(task_id,created);
26
30
  CREATE INDEX IF NOT EXISTS task_runs ON runs(task_id,status);`);
27
31
  }
@@ -33,32 +37,68 @@ export class Store extends EventEmitter {
33
37
  task(id: string) { const row = this.db.prepare('SELECT data FROM tasks WHERE id=?').get(id) as { data: string } | undefined; if (!row) throw new Error('Task not found.'); return JSON.parse(row.data) as Task; }
34
38
  projectByPath(p: string) { return this.projects().find(project => project.path === p); }
35
39
  saveProject(p: Project) { this.db.prepare('INSERT INTO projects VALUES(?,?,?) ON CONFLICT(id) DO UPDATE SET path=excluded.path,data=excluded.data').run(p.id, p.path, JSON.stringify(p)); this.emit('change'); }
40
+ removeProject(id: string) {
41
+ this.project(id);
42
+ this.transaction(() => {
43
+ // Only Papilot records are removed. Never finish review tasks or touch their files.
44
+ this.db.prepare('INSERT OR IGNORE INTO deleted_tasks SELECT id, ? FROM tasks WHERE project_id=?').run(Date.now(), 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);
46
+ for (const table of ['tasks', 'annotations', 'members']) this.db.prepare(`DELETE FROM ${table} WHERE project_id=?`).run(id);
47
+ this.db.prepare('DELETE FROM projects WHERE id=?').run(id);
48
+ });
49
+ this.emit('change');
50
+ }
36
51
  saveTask(t: Task) { t.updatedAt = Date.now(); this.db.prepare('INSERT INTO tasks VALUES(?,?,?) ON CONFLICT(id) DO UPDATE SET data=excluded.data').run(t.id, t.projectId, JSON.stringify(t)); this.emit('change'); return t; }
37
52
  updateTask(id: string, update: Partial<Task>) { return this.saveTask({ ...this.task(id), ...update }); }
38
53
  saveRun(r: Run) { this.db.prepare('INSERT INTO runs VALUES(?,?,?,?,?) ON CONFLICT(id) DO UPDATE SET status=excluded.status,data=excluded.data').run(r.id, r.taskId, r.requestKey, r.status, JSON.stringify(r)); this.emit('change'); }
39
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; }
40
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); }
41
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'); }
42
- 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); }
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
+ }
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); }
64
+ annotation(projectId: string, id: string) {
65
+ const row = this.db.prepare('SELECT data FROM annotations WHERE id=? AND project_id=?').get(id, projectId) as { data: string } | undefined;
66
+ if (!row) throw new Error('Note not found.');
67
+ return JSON.parse(row.data) as Annotation;
68
+ }
43
69
  saveAnnotation(a: Annotation) { this.db.prepare('INSERT INTO annotations VALUES(?,?,?) ON CONFLICT(id) DO UPDATE SET data=excluded.data').run(a.id, a.projectId, JSON.stringify(a)); this.emit('change'); return a; }
44
- deleteAnnotation(projectId: string, id: string) { this.db.prepare('DELETE FROM annotations WHERE id=? AND project_id=?').run(id, projectId); this.emit('change'); }
70
+ deleteAnnotation(projectId: string, id: string) {
71
+ const note = this.annotation(projectId, id);
72
+ if (note.taskId) throw new Error('This note has already been sent.');
73
+ this.saveAnnotation({ ...note, deletedAt: note.deletedAt ?? Date.now() });
74
+ }
45
75
  taskDeleted(id: string) { return Boolean(this.db.prepare('SELECT 1 FROM deleted_tasks WHERE id=?').get(id)); }
46
76
  deleteTask(id: string) {
47
77
  this.transaction(() => {
48
78
  this.db.prepare('INSERT INTO deleted_tasks VALUES(?,?) ON CONFLICT(id) DO NOTHING').run(id, Date.now());
49
79
  this.db.prepare('DELETE FROM items WHERE task_id=?').run(id);
50
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);
51
82
  this.db.prepare('DELETE FROM tasks WHERE id=?').run(id);
52
83
  });
53
84
  this.emit('change');
54
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
+ }
55
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; } }
56
95
  recover() {
57
96
  this.transaction(() => {
97
+ for (const t of this.tasks().filter(t => t.titleGeneration === 'pending')) this.updateTask(t.id, { titleGeneration: 'failed' });
58
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.' });
59
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.' });
60
100
  for (const r of this.runs().filter(r => r.status === 'running')) this.saveRun({ ...r, status: 'interrupted', finishedAt: Date.now() });
61
- 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.' });
62
102
  });
63
103
  }
64
104
  close() { this.db.close(); }
@@ -0,0 +1,124 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import net from 'node:net';
4
+ import { execFile, spawn } from 'node:child_process';
5
+ import { promisify } from 'node:util';
6
+ import { createRequire } from 'node:module';
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();
11
+
12
+ export function npmCli(env = process.env, executable = process.execPath) {
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'),
14
+ ...(env.PATH || '').split(path.delimiter).map(dir => path.join(dir, 'npm'))];
15
+ for (const candidate of candidates) {
16
+ if (!candidate) continue;
17
+ try { const real = fs.realpathSync(candidate); if (real.endsWith('npm-cli.js')) return real; } catch { /* next installation */ }
18
+ }
19
+ throw new Error('npm is unavailable to Papilot.');
20
+ }
21
+ export const loaderFor = (root: string) => createRequire(path.join(root, 'package.json')).resolve('tsx/esm');
22
+ export function runInstall(args: string[], cwd: string, log: number, timeout = 600000) {
23
+ return new Promise<void>((resolve, reject) => {
24
+ const child = spawn(process.execPath, [npmCli(), ...args], { cwd, env: { ...process.env, PATH: path.dirname(process.execPath) + path.delimiter + (process.env.PATH || '') }, stdio: ['ignore', log, log] });
25
+ const timer = setTimeout(() => { child.kill('SIGKILL'); reject(new Error('The update download timed out.')); }, timeout);
26
+ child.once('error', error => { clearTimeout(timer); reject(error); });
27
+ child.once('exit', code => { clearTimeout(timer); code === 0 ? resolve() : reject(new Error('The new version could not be installed. Check the connection and available disk space, then retry.')); });
28
+ });
29
+ }
30
+ export async function health(port: number, version: string, instance?: string) {
31
+ try {
32
+ const response = await fetch(`http://127.0.0.1:${port}/api/health`, { signal: AbortSignal.timeout(1500), redirect: 'error' });
33
+ const value = await response.json();
34
+ return response.ok && value.name === 'Papilot' && value.version === version && (!instance || value.updateInstance === instance);
35
+ } catch { return false; }
36
+ }
37
+ export const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms));
38
+ export async function probeInstallation(root: string, version: string, stage: string, log: number) {
39
+ const server = net.createServer();
40
+ await new Promise<void>((resolve, reject) => { server.once('error', reject); server.listen(0, '127.0.0.1', resolve); });
41
+ const port = (server.address() as net.AddressInfo).port;
42
+ await new Promise<void>(resolve => server.close(() => resolve()));
43
+ const child = spawn(process.execPath, ['--import', loaderFor(root), path.join(root, 'src/server/main.ts')], {
44
+ cwd: root, env: { ...process.env, PAPILOT_DATA: path.join(stage, 'probe'), PAPILOT_PORT: String(port), PAPILOT_PUBLIC_ORIGIN: '', PAPILOT_ACCOUNT_ORIGIN: '', PAPILOT_UPDATE_PROBE: '1' }, stdio: ['ignore', log, log],
45
+ });
46
+ let ended = false, error: Error | undefined;
47
+ child.once('error', e => { error = e; ended = true; }); child.once('exit', () => { ended = true; });
48
+ try {
49
+ for (let i = 0; i < 40 && !ended; i++) { if (await health(port, version)) return; await sleep(250); }
50
+ throw error || new Error('The new version did not pass its startup check. Your current version is still running.');
51
+ } finally {
52
+ if (!ended) {
53
+ child.kill('SIGTERM');
54
+ for (let i = 0; i < 30 && !ended; i++) await sleep(100);
55
+ if (!ended) child.kill('SIGKILL');
56
+ }
57
+ }
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
+ }
105
+ export async function installRelease(data: string, version: string) {
106
+ if (devRevision(version)) return installDev(data, version);
107
+ if (!stableVersion(version)) throw new Error('Invalid release version.');
108
+ const releases = path.join(data, 'updates/releases'); fs.mkdirSync(releases, { recursive: true, mode: 0o700 });
109
+ const stage = fs.mkdtempSync(path.join(releases, version + '-')), root = path.join(stage, 'node_modules/papilot');
110
+ const log = fs.openSync(path.join(data, 'updates/install.log'), 'a', 0o600);
111
+ try {
112
+ const flags = ['--registry=https://registry.npmjs.org', '--omit=dev', '--ignore-scripts', '--no-audit', '--no-fund', '--engine-strict'];
113
+ await runInstall(['install', '--prefix', stage, '--save-exact', ...flags, `papilot@${version}`], stage, log);
114
+ const pkg = readJson(path.join(root, 'package.json'));
115
+ if (pkg?.name !== 'papilot' || pkg.version !== version) throw new Error('The downloaded package does not match the requested release.');
116
+ for (const file of ['dist/index.html', 'src/server/start.ts', 'src/server/main.ts', 'bin/papilot.mjs', 'vendor/latex-review/package-lock.json']) {
117
+ if (!fs.existsSync(path.join(root, file))) throw new Error('The downloaded release is incomplete.');
118
+ }
119
+ await runInstall(['ci', '--prefix', path.join(root, 'vendor/latex-review'), ...flags], stage, log);
120
+ await probeInstallation(root, version, stage, log);
121
+ return root;
122
+ } catch (error) { fs.rmSync(stage, { recursive: true, force: true }); throw error; }
123
+ finally { fs.closeSync(log); }
124
+ }
@@ -0,0 +1,78 @@
1
+ // Detached before the old server stops. This file stays in the old release,
2
+ // so a broken download cannot remove the code needed to roll back.
3
+ import fs from 'node:fs';
4
+ import path from 'node:path';
5
+ import os from 'node:os';
6
+ import { fileURLToPath } from 'node:url';
7
+ import { spawn, spawnSync, type ChildProcess } from 'node:child_process';
8
+ import { devRevision, readJson, releaseVersion, writeJson } from '../../bin/installation.mjs';
9
+ import { health, loaderFor, sleep } from './update-install.ts';
10
+
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
+ 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
+ const previous = readJson(activeFile), status = readJson(statusFile) || {};
14
+ const database = path.join(job.data, 'papilot.sqlite'), backup = path.join(folder, 'before-update.sqlite');
15
+ const service = `gui/${process.getuid?.()}/local.papilot`, domain = `gui/${process.getuid?.()}`;
16
+ const plist = path.join(os.homedir(), 'Library/LaunchAgents/local.papilot.plist');
17
+ const alive = (pid: number) => { try { process.kill(pid, 0); return true; } catch { return false; } };
18
+ const launchctl = (...args: string[]) => spawnSync('launchctl', args, { stdio: 'ignore', timeout: 15000 });
19
+ let child: ChildProcess | undefined, parentStopped = false, backedUp = false;
20
+ const launch = (root: string) => {
21
+ if (job.service) { if (launchctl('bootstrap', domain, plist).status !== 0) throw new Error('Could not restart the background service.'); return; }
22
+ const log = fs.openSync(path.join(folder, 'server.log'), 'a', 0o600);
23
+ child = spawn(process.execPath, ['--import', loaderFor(root), path.join(root, 'src/server/main.ts')], { cwd: root, detached: true, env: { ...process.env, PAPILOT_DATA: job.data, PAPILOT_PORT: String(job.port) }, stdio: ['ignore', log, log] });
24
+ child.on('error', () => {}); child.unref(); fs.closeSync(log);
25
+ };
26
+ const stopCandidate = async () => {
27
+ if (job.service) launchctl('bootout', service);
28
+ else if (child?.pid) {
29
+ child.kill('SIGTERM');
30
+ for (let i = 0; i < 40 && alive(child.pid); i++) await sleep(100);
31
+ if (alive(child.pid)) child.kill('SIGKILL');
32
+ }
33
+ };
34
+ try {
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
+ writeJson(lock, { pid: process.pid });
37
+ writeJson(statusFile, { ...status, phase: 'restarting', pid: process.pid });
38
+ writeJson(activeFile, { root: job.root, version: job.version, channel: job.channel, id: job.id, phase: 'pending', pid: process.pid, previous });
39
+ ready();
40
+ for (let i = 0; i < 150 && alive(job.parent); i++) await sleep(100);
41
+ if (alive(job.parent)) throw new Error('Papilot did not finish shutting down.');
42
+ parentStopped = true;
43
+ if (job.service) launchctl('bootout', service);
44
+ if (fs.existsSync(database)) {
45
+ fs.copyFileSync(database, backup); backedUp = true;
46
+ // Forced shutdown may leave committed SQLite pages in the WAL.
47
+ if (fs.existsSync(database + '-wal')) fs.copyFileSync(database + '-wal', backup + '-wal');
48
+ else fs.rmSync(backup + '-wal', { force: true });
49
+ }
50
+ writeJson(activeFile, { root: job.root, version: job.version, channel: job.channel, id: job.id, phase: 'starting', pid: process.pid, previous });
51
+ launch(job.root);
52
+ for (let i = 0; i < attempts; i++) {
53
+ if (await health(job.port, job.version, job.id)) {
54
+ writeJson(activeFile, { root: job.root, version: job.version, channel: job.channel, id: job.id, phase: 'active' });
55
+ writeJson(statusFile, { ...status, phase: 'updated', currentVersion: job.version, error: null, pid: process.pid });
56
+ return;
57
+ }
58
+ await sleep(300);
59
+ }
60
+ throw new Error('The new version did not restart successfully.');
61
+ } catch (error) {
62
+ if (parentStopped) await stopCandidate();
63
+ if (previous) writeJson(activeFile, previous); else fs.rmSync(activeFile, { force: true });
64
+ if (parentStopped && backedUp) {
65
+ for (const suffix of ['-wal', '-shm']) fs.rmSync(database + suffix, { force: true });
66
+ fs.copyFileSync(backup, database);
67
+ if (fs.existsSync(backup + '-wal')) fs.copyFileSync(backup + '-wal', database + '-wal');
68
+ }
69
+ writeJson(statusFile, { ...status, phase: 'failed', checkedAt: Date.now(), error: `${error instanceof Error ? error.message : 'The update failed.'} The previous version has been restored.`, pid: process.pid });
70
+ if (parentStopped) launch(job.previousRoot);
71
+ } finally { fs.rmSync(lock, { force: true }); }
72
+ }
73
+
74
+ if (process.argv[1] && fs.existsSync(process.argv[1]) && fs.realpathSync(process.argv[1]) === fs.realpathSync(fileURLToPath(import.meta.url))) {
75
+ const job = readJson(process.argv[2]);
76
+ if (!job) throw new Error('Missing update restart job.');
77
+ await restartRelease(job);
78
+ }
@@ -0,0 +1,152 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { spawn } from 'node:child_process';
4
+ import { randomUUID } from 'node:crypto';
5
+ import { devRevision, newerVersion, readJson, stableVersion, writeJson } from '../../bin/installation.mjs';
6
+ import { installRelease, loaderFor, npmCli } from './update-install.ts';
7
+ import type { UpdateChannel, UpdateStatus } from '../shared.ts';
8
+
9
+ const activePhases = new Set(['installing', 'waiting', 'restarting']);
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) {
12
+ const response = await fetcher(url, { signal: AbortSignal.timeout(10000), redirect: 'error', headers: { Accept: 'application/json', 'User-Agent': 'Papilot' } });
13
+ if (!response.ok) throw new Error('Could not check for updates.');
14
+ const text = await response.text();
15
+ if (text.length > 256000) throw new Error('Invalid release information.');
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);
28
+ if (pkg.name !== 'papilot' || !stableVersion(pkg.version)) throw new Error('Invalid release information.');
29
+ return pkg.version;
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));
34
+ interface Options {
35
+ root: string; data: string; version: string; port: number;
36
+ busy: () => boolean; shutdown: () => void;
37
+ latest?: (channel: UpdateChannel) => Promise<string>; install?: (data: string, version: string) => Promise<string>;
38
+ restart?: (root: string, version: string) => Promise<void>; supported?: boolean;
39
+ }
40
+ // Both HTTP listeners share one updater. Downloads do not interrupt editing;
41
+ // only the short activation window refuses new work and waits for quiescence.
42
+ export class Updates {
43
+ private state: UpdateStatus;
44
+ private checking: Promise<UpdateStatus> | null = null;
45
+ private operation: Promise<void> | null = null;
46
+ private prepared = '';
47
+ private inFlight = 0;
48
+ private timer?: NodeJS.Timeout;
49
+ private closed = false;
50
+ private file: string;
51
+ private lock: string;
52
+ private channel: UpdateChannel;
53
+ constructor(private options: Options) {
54
+ this.file = path.join(options.data, 'updates/status.json'); this.lock = path.join(options.data, 'updates/install.lock');
55
+ const preference = readJson(path.join(options.data, 'updates/channel.json'))?.channel;
56
+ this.channel = preference === 'dev' || preference === 'stable' ? preference : devRevision(options.version) ? 'dev' : 'stable';
57
+ let supported = options.supported ?? true;
58
+ if (options.supported === undefined) try { npmCli(); } catch { supported = false; }
59
+ this.state = { currentVersion: options.version, latestVersion: null, phase: 'idle', checkedAt: null, error: null, canUpdate: supported };
60
+ const saved = readJson(this.file);
61
+ if (saved && ['failed', 'updated', 'installing', 'waiting', 'restarting'].includes(saved.phase)) this.state = { ...this.state, ...saved, currentVersion: options.version, canUpdate: supported };
62
+ 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.' });
63
+ }
64
+ snapshot(): UpdateStatus {
65
+ const saved = readJson(this.file);
66
+ if (saved && activePhases.has(this.state.phase) && ['updated', 'failed'].includes(saved.phase)) this.state = { ...this.state, ...saved, currentVersion: this.options.version };
67
+ const { currentVersion, latestVersion, phase, checkedAt, error, canUpdate } = this.state;
68
+ return { currentVersion, latestVersion, phase, checkedAt, error, canUpdate, channel: this.channel };
69
+ }
70
+ get restarting() { return this.snapshot().phase === 'restarting'; }
71
+ enter() { this.inFlight++; let released = false; return () => { if (!released) { released = true; this.inFlight--; } }; }
72
+ start() {
73
+ if (this.state.phase !== 'failed') void this.check();
74
+ this.timer = setInterval(() => {
75
+ if (this.state.phase === 'waiting') void this.activate();
76
+ else if (!activePhases.has(this.state.phase) && Date.now() - (this.state.checkedAt || 0) > 6 * 3600000) void this.check();
77
+ }, 2000); this.timer.unref();
78
+ }
79
+ close() { this.closed = true; clearInterval(this.timer); }
80
+ private change(patch: Partial<UpdateStatus>) {
81
+ this.state = { ...this.state, ...patch };
82
+ writeJson(this.file, { ...this.state, pid: process.pid });
83
+ }
84
+ setChannel(channel: unknown) {
85
+ if (channel !== 'stable' && channel !== 'dev') throw new Error('Choose a valid update channel.');
86
+ if (activePhases.has(this.state.phase)) throw new Error('Wait for the current update to finish before changing channels.');
87
+ if (channel === this.channel) return this.check();
88
+ this.channel = channel;
89
+ writeJson(path.join(this.options.data, 'updates/channel.json'), { channel });
90
+ this.checking = null;
91
+ this.change({ latestVersion: null, checkedAt: null, phase: 'idle', error: null });
92
+ return this.check();
93
+ }
94
+ check(): Promise<UpdateStatus> {
95
+ if (this.checking) return this.checking;
96
+ if (activePhases.has(this.state.phase)) return Promise.resolve(this.snapshot());
97
+ this.change({ phase: 'checking' });
98
+ const channel = this.channel;
99
+ const checking = (this.options.latest || latestRelease)(channel).then(version => {
100
+ if (!validTarget(version, channel)) throw new Error('Invalid release version.');
101
+ 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' });
102
+ return this.snapshot();
103
+ }).catch(() => {
104
+ 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.' });
105
+ return this.snapshot();
106
+ }).finally(() => { if (this.checking === checking) this.checking = null; });
107
+ this.checking = checking;
108
+ return this.checking;
109
+ }
110
+ install(version: unknown) {
111
+ if (activePhases.has(this.state.phase)) return this.snapshot();
112
+ if (!this.state.canUpdate) throw new Error('npm is unavailable to Papilot.');
113
+ 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.');
114
+ fs.mkdirSync(path.dirname(this.lock), { recursive: true, mode: 0o700 });
115
+ const previous = readJson(this.lock);
116
+ if (previous && alive(previous.pid)) throw new Error('Another Papilot instance is already updating.');
117
+ if (previous) fs.rmSync(this.lock);
118
+ const fd = fs.openSync(this.lock, 'wx', 0o600); fs.writeFileSync(fd, JSON.stringify({ pid: process.pid })); fs.closeSync(fd);
119
+ this.change({ phase: 'installing', error: null });
120
+ this.operation = (this.options.install || installRelease)(this.options.data, version).then(root => {
121
+ if (this.closed) return;
122
+ this.prepared = root; this.change({ phase: 'waiting' });
123
+ return this.activate();
124
+ }).catch(error => { this.change({ phase: 'failed', error: error instanceof Error ? error.message : 'The update failed.' }); this.release(); }).finally(() => { this.operation = null; });
125
+ return this.snapshot();
126
+ }
127
+ // Public for deterministic tests; the periodic tick calls this in production.
128
+ async activate() {
129
+ if (this.closed || this.state.phase !== 'waiting' || !this.prepared || this.inFlight || this.options.busy()) return;
130
+ this.change({ phase: 'restarting' });
131
+ try { await (this.options.restart || this.restart.bind(this))(this.prepared, this.state.latestVersion!); }
132
+ catch (error) { this.change({ phase: 'failed', error: error instanceof Error ? error.message : 'Could not restart Papilot.' }); this.release(); }
133
+ }
134
+ private release() { const lock = readJson(this.lock); if (lock?.pid === process.pid) fs.rmSync(this.lock, { force: true }); }
135
+ private restart(root: string, version: string) {
136
+ const folder = path.dirname(this.file), job = path.join(folder, 'restart.json');
137
+ 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,
138
+ service: process.env.PAPILOT_SERVICE_MANAGER === 'launchd' || process.env.XPC_SERVICE_NAME === 'local.papilot' });
139
+ const log = fs.openSync(path.join(folder, 'restart.log'), 'a', 0o600);
140
+ 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'] });
141
+ fs.closeSync(log);
142
+ return new Promise<void>((resolve, reject) => {
143
+ const timer = setTimeout(() => { child.kill(); reject(new Error('The update restart helper did not start.')); }, 10000);
144
+ child.once('error', error => { clearTimeout(timer); reject(error); });
145
+ child.once('exit', code => { clearTimeout(timer); reject(new Error(`The update restart helper stopped (${code}).`)); });
146
+ child.once('message', message => {
147
+ if (message !== 'ready') return;
148
+ clearTimeout(timer); child.disconnect(); child.unref(); resolve(); this.options.shutdown();
149
+ });
150
+ });
151
+ }
152
+ }