papilot 0.8.3 → 0.8.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/assets/index-DSWizoaI.js +267 -0
- package/dist/assets/index-PFQDdu53.css +1 -0
- package/dist/assets/{math-ibBpTslk.js → math-DHvVou9A.js} +1 -1
- package/dist/assets/pdf-view-AYTS7kdA.js +598 -0
- package/dist/index.html +2 -2
- package/engine/dist/web/assets/{FullPaper-DUgLb01O.js → FullPaper-DlgZ1MOz.js} +1 -1
- package/engine/dist/web/assets/index-BCgVChtV.js +19 -0
- package/engine/dist/web/index.html +1 -1
- package/engine/src/client.ts +11 -4
- package/engine/src/core/publish.ts +17 -3
- package/engine/src/core/store.ts +1 -0
- package/engine/src/server.ts +3 -2
- package/engine/src/web/review.tsx +39 -13
- package/engine/src/web/view-preferences.ts +8 -6
- package/package.json +2 -2
- package/src/server/agents.ts +10 -1
- package/src/server/app.ts +8 -2
- package/src/server/cursor.ts +20 -1
- package/src/server/git-overview.ts +9 -5
- package/src/server/imports.ts +40 -18
- package/src/server/manuscript.ts +21 -2
- package/src/server/review.ts +8 -3
- package/src/server/start.ts +10 -3
- package/src/server/update-install.ts +57 -0
- package/src/server/updates.ts +95 -27
- package/src/server/workspace.ts +76 -15
- package/src/shared.ts +8 -3
- package/dist/assets/index-0siHHtfz.js +0 -267
- package/dist/assets/index-CxdYm81a.css +0 -1
- package/dist/assets/pdf-view-CD1kNKYH.js +0 -598
- package/engine/dist/web/assets/index-DJywe0wU.js +0 -19
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
rel="icon"
|
|
10
10
|
href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Crect width='16' height='16' rx='3' fill='%232b6e5c'/%3E%3Cpath d='M4 8.5l2.5 2.5L12 5' stroke='white' stroke-width='2' fill='none'/%3E%3C/svg%3E"
|
|
11
11
|
/>
|
|
12
|
-
<script type="module" crossorigin src="/assets/index-
|
|
12
|
+
<script type="module" crossorigin src="/assets/index-BCgVChtV.js"></script>
|
|
13
13
|
<link rel="stylesheet" crossorigin href="/assets/index-D3_ZFdT8.css">
|
|
14
14
|
</head>
|
|
15
15
|
<body>
|
package/engine/src/client.ts
CHANGED
|
@@ -157,6 +157,15 @@ export async function connect(
|
|
|
157
157
|
// Match the canonical repository path reported by the server (e.g. macOS /var).
|
|
158
158
|
root = fs.realpathSync(root);
|
|
159
159
|
const home = serverHome(root);
|
|
160
|
+
// A reader neither starts nor upgrades the server. Do not wait behind an
|
|
161
|
+
// upgrader holding start.lock while the job we need to inspect is building.
|
|
162
|
+
if (!start) {
|
|
163
|
+
const last = readJson<{ port: number } | null>(path.join(home, "last.json"), null);
|
|
164
|
+
const info = readJson<Connection | null>(path.join(home, "connection.json"), null)
|
|
165
|
+
?? (last ? { url: `http://127.0.0.1:${last.port}`, port: last.port, pid: 0 } : null);
|
|
166
|
+
const answer = await probe(root, info);
|
|
167
|
+
return answer ? new Client(info!.url, answer.csrf) : null;
|
|
168
|
+
}
|
|
160
169
|
return withLock(path.join(home, "start.lock"), async () => {
|
|
161
170
|
const last = readJson<{ port: number } | null>(
|
|
162
171
|
path.join(home, "last.json"),
|
|
@@ -171,12 +180,10 @@ export async function connect(
|
|
|
171
180
|
let answer = await probe(root, info);
|
|
172
181
|
if (
|
|
173
182
|
answer &&
|
|
174
|
-
(
|
|
175
|
-
|
|
176
|
-
answer.environment === environmentVersion()))
|
|
183
|
+
answer.version === codeVersion() &&
|
|
184
|
+
answer.environment === environmentVersion()
|
|
177
185
|
)
|
|
178
186
|
return new Client(info!.url, answer.csrf);
|
|
179
|
-
if (!start) return null;
|
|
180
187
|
if (answer && info) {
|
|
181
188
|
const deadline = Date.now() + 900000;
|
|
182
189
|
while (answer?.busy && Date.now() < deadline) {
|
|
@@ -199,8 +199,22 @@ export class Publisher {
|
|
|
199
199
|
}),
|
|
200
200
|
);
|
|
201
201
|
}
|
|
202
|
-
submit(name: string) {
|
|
202
|
+
submit(name: string, requestId?: string) {
|
|
203
203
|
this.app.tasks.get(name);
|
|
204
|
+
return this.app.store.transaction(() => this.enqueue(name, requestId));
|
|
205
|
+
}
|
|
206
|
+
private enqueue(name: string, requestId?: string) {
|
|
207
|
+
if (requestId) {
|
|
208
|
+
const existing = this.app.store.one<Job>("SELECT jobs.* FROM jobs JOIN build_requests ON jobs.id=build_requests.job WHERE build_requests.id=?", requestId);
|
|
209
|
+
if (existing) {
|
|
210
|
+
if (existing.task !== name) throw new Error("This build request belongs to another task.");
|
|
211
|
+
return existing;
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
const remember = (job: Job) => {
|
|
215
|
+
if (requestId) this.app.store.execute("INSERT INTO build_requests(id,task,job) VALUES(?,?,?)", requestId, name, job.id);
|
|
216
|
+
return job;
|
|
217
|
+
};
|
|
204
218
|
if (this.stopping)
|
|
205
219
|
throw new Error(
|
|
206
220
|
"The review server is stopping. Publish again after it restarts.",
|
|
@@ -208,7 +222,7 @@ export class Publisher {
|
|
|
208
222
|
const current = this.pending.get(name);
|
|
209
223
|
if (current) {
|
|
210
224
|
const job = this.job(current);
|
|
211
|
-
if (job.status === "queued") return job;
|
|
225
|
+
if (job.status === "queued") return remember(job);
|
|
212
226
|
}
|
|
213
227
|
const id = randomBytes(8).toString("hex");
|
|
214
228
|
this.app.store.execute(
|
|
@@ -220,7 +234,7 @@ export class Publisher {
|
|
|
220
234
|
this.pending.set(name, id);
|
|
221
235
|
if (!this.running.has(name)) this.queue.push(id);
|
|
222
236
|
queueMicrotask(() => this.drain());
|
|
223
|
-
return this.job(id);
|
|
237
|
+
return remember(this.job(id));
|
|
224
238
|
}
|
|
225
239
|
job(id: string) {
|
|
226
240
|
const job = this.app.store.one<Job>("SELECT * FROM jobs WHERE id=?", id);
|
package/engine/src/core/store.ts
CHANGED
|
@@ -10,6 +10,7 @@ CREATE TABLE IF NOT EXISTS actions(seq INTEGER PRIMARY KEY AUTOINCREMENT,id TEXT
|
|
|
10
10
|
CREATE TABLE IF NOT EXISTS writes(action TEXT NOT NULL,phase TEXT NOT NULL,path TEXT NOT NULL,expected BLOB,target BLOB,PRIMARY KEY(action,phase,path));
|
|
11
11
|
CREATE TABLE IF NOT EXISTS messages(id INTEGER PRIMARY KEY AUTOINCREMENT,task TEXT NOT NULL,round INTEGER,kind TEXT NOT NULL,action TEXT,body TEXT NOT NULL DEFAULT '',data TEXT NOT NULL DEFAULT '{}',created REAL NOT NULL);
|
|
12
12
|
CREATE TABLE IF NOT EXISTS jobs(id TEXT PRIMARY KEY,task TEXT NOT NULL,status TEXT NOT NULL,phase TEXT NOT NULL DEFAULT '',result TEXT NOT NULL DEFAULT '{}',created REAL NOT NULL,finished REAL);
|
|
13
|
+
CREATE TABLE IF NOT EXISTS build_requests(id TEXT PRIMARY KEY,task TEXT NOT NULL,job TEXT NOT NULL);
|
|
13
14
|
CREATE INDEX IF NOT EXISTS actions_by_task ON actions(task,round);
|
|
14
15
|
CREATE INDEX IF NOT EXISTS messages_by_task ON messages(task,id);
|
|
15
16
|
CREATE INDEX IF NOT EXISTS jobs_by_task ON jobs(task,created);`;
|
package/engine/src/server.ts
CHANGED
|
@@ -331,11 +331,12 @@ export function createServer(app: App) {
|
|
|
331
331
|
);
|
|
332
332
|
},
|
|
333
333
|
);
|
|
334
|
-
server.post<{ Params: { name: string } }>(
|
|
334
|
+
server.post<{ Params: { name: string }; Body: { requestId?: string } }>(
|
|
335
335
|
"/api/tasks/:name/publish",
|
|
336
|
+
{ schema: { body: { ...objectSchema, properties: { requestId: { type: "string", pattern: "^[a-zA-Z0-9-]{1,80}$" } } } } },
|
|
336
337
|
(request) => {
|
|
337
338
|
app.delivery.assertReady(request.params.name);
|
|
338
|
-
const job = app.publisher.submit(request.params.name);
|
|
339
|
+
const job = app.publisher.submit(request.params.name, request.body?.requestId);
|
|
339
340
|
return { job: job.id, status: job.status };
|
|
340
341
|
},
|
|
341
342
|
);
|
|
@@ -171,11 +171,16 @@ export function ReviewApp({
|
|
|
171
171
|
const paper = useRef<HTMLElement>(null),
|
|
172
172
|
epoch = useRef(0),
|
|
173
173
|
scrollUntil = useRef(0),
|
|
174
|
+
// How far into the current change the author had read, restored once
|
|
175
|
+
// when the review reopens on it in the same view.
|
|
176
|
+
returning = useRef<{ index: number; offset: number; paper: boolean } | null>(null),
|
|
174
177
|
snapshot = useRef({ state, detail, busy, composer, current });
|
|
178
|
+
// Where each review was left; a host's uncommitted changes are kept per paper.
|
|
179
|
+
const placeKey = `latex-review:${changes ? `changes:${location.pathname}` : task}`;
|
|
175
180
|
const [shortcuts, setShortcuts] = useState(readShortcuts);
|
|
176
181
|
// The whole paper with the changes in place, or the changed sections only.
|
|
177
182
|
// A host may fix the view in the address (Papilot's manuscript chooses its
|
|
178
|
-
// PDF with its own switch); otherwise it
|
|
183
|
+
// PDF with its own switch); otherwise it opens on the stored default.
|
|
179
184
|
const [pinnedView] = useState<ReviewView | null>(() => {
|
|
180
185
|
const value = new URLSearchParams(location.search).get("view");
|
|
181
186
|
return value === "paper" || value === "changes" ? value : null;
|
|
@@ -339,8 +344,9 @@ export function ReviewApp({
|
|
|
339
344
|
}
|
|
340
345
|
function select(index: number, scroll = true) {
|
|
341
346
|
if (index < 0 || index >= stops.length) return;
|
|
347
|
+
if (scroll) returning.current = null;
|
|
342
348
|
setCurrent(index);
|
|
343
|
-
store(
|
|
349
|
+
store(placeKey, {
|
|
344
350
|
round: detail?.round,
|
|
345
351
|
stop: stops[index].key,
|
|
346
352
|
});
|
|
@@ -355,15 +361,34 @@ export function ReviewApp({
|
|
|
355
361
|
});
|
|
356
362
|
}
|
|
357
363
|
}
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
364
|
+
// How far the paper is scrolled past the top of a change; 0 when revealed.
|
|
365
|
+
function past(article: HTMLElement) {
|
|
366
|
+
const root = paper.current!;
|
|
361
367
|
// scrollIntoView also scrolls ancestors across the embedding iframe.
|
|
362
368
|
// Navigation belongs to the paper, independently of the host's Chat.
|
|
363
369
|
const padding = parseFloat(getComputedStyle(root).scrollPaddingTop) || 0;
|
|
364
370
|
const margin = parseFloat(getComputedStyle(article).scrollMarginTop) || 0;
|
|
365
|
-
|
|
371
|
+
return root.getBoundingClientRect().top + padding + margin - article.getBoundingClientRect().top;
|
|
366
372
|
}
|
|
373
|
+
function reveal(article: HTMLElement, behavior: ScrollBehavior = "instant") {
|
|
374
|
+
const root = paper.current;
|
|
375
|
+
if (!root) return;
|
|
376
|
+
const index = Number(article.dataset.stop), back = returning.current;
|
|
377
|
+
let offset = 0;
|
|
378
|
+
if (back && back.index === index && back.paper === paperView) { offset = back.offset; returning.current = null; }
|
|
379
|
+
root.scrollTo({ top: root.scrollTop - past(article) + offset, behavior });
|
|
380
|
+
}
|
|
381
|
+
// The place within the current change is kept as the author reads on.
|
|
382
|
+
function remember(index: number) {
|
|
383
|
+
const article = paper.current?.querySelector<HTMLElement>(`[data-stop="${index}"]`);
|
|
384
|
+
if (!article || !stops[index]) return;
|
|
385
|
+
store(placeKey, { round: detail?.round, stop: stops[index].key, offset: Math.round(past(article)), paper: paperView });
|
|
386
|
+
}
|
|
387
|
+
// Reading into the next change makes it current, which resizes its header;
|
|
388
|
+
// measure the place again once it has, unless a reveal is on its way.
|
|
389
|
+
useEffect(() => {
|
|
390
|
+
if (current >= 0 && !returning.current && Date.now() >= scrollUntil.current && paper.current?.scrollTop) remember(current);
|
|
391
|
+
}, [current]);
|
|
367
392
|
useEffect(() => {
|
|
368
393
|
let cancelled = false,
|
|
369
394
|
timer: ReturnType<typeof setTimeout>;
|
|
@@ -401,8 +426,8 @@ export function ReviewApp({
|
|
|
401
426
|
)
|
|
402
427
|
return;
|
|
403
428
|
const nextStops = buildStops(loaded),
|
|
404
|
-
saved = readStorage<{ round?: number; stop?: string }>(
|
|
405
|
-
|
|
429
|
+
saved = readStorage<{ round?: number; stop?: string; offset?: number; paper?: boolean }>(
|
|
430
|
+
placeKey,
|
|
406
431
|
{},
|
|
407
432
|
);
|
|
408
433
|
let index =
|
|
@@ -416,6 +441,10 @@ export function ReviewApp({
|
|
|
416
441
|
index = nextPending(nextStops, loadedState.decisions, index);
|
|
417
442
|
if (linkedChange >= 0 && nextStops[linkedChange]) index = linkedChange;
|
|
418
443
|
if (index < 0) index = pinned && nextStops.length ? 0 : -1;
|
|
444
|
+
returning.current =
|
|
445
|
+
!previous.detail && index >= 0 && saved.round === target && nextStops[index].key === saved.stop && saved.offset
|
|
446
|
+
? { index, offset: saved.offset, paper: Boolean(saved.paper) }
|
|
447
|
+
: null;
|
|
419
448
|
if (
|
|
420
449
|
previous.detail &&
|
|
421
450
|
loaded.round !== previous.detail.round &&
|
|
@@ -676,6 +705,7 @@ export function ReviewApp({
|
|
|
676
705
|
});
|
|
677
706
|
const index = visibleStop(regions, current, box.top + 60, box.bottom - 100);
|
|
678
707
|
if (index !== current) select(index, false);
|
|
708
|
+
if (index >= 0) remember(index);
|
|
679
709
|
}
|
|
680
710
|
const actionBar = (index: number) => {
|
|
681
711
|
if (index !== current || !state) return null;
|
|
@@ -1144,13 +1174,9 @@ export function ReviewApp({
|
|
|
1144
1174
|
if (article) reveal(article);
|
|
1145
1175
|
});
|
|
1146
1176
|
}, [paperView]);
|
|
1177
|
+
// The switch changes this review only; the default is a Papilot setting.
|
|
1147
1178
|
function chooseView(next: ReviewView) {
|
|
1148
1179
|
setView(next);
|
|
1149
|
-
try {
|
|
1150
|
-
localStorage.setItem(VIEW_STORAGE, next);
|
|
1151
|
-
} catch {
|
|
1152
|
-
/* The choice lasts for this page. */
|
|
1153
|
-
}
|
|
1154
1180
|
}
|
|
1155
1181
|
let index = 0;
|
|
1156
1182
|
return (
|
|
@@ -1,13 +1,15 @@
|
|
|
1
|
-
// Whole paper or changed sections only.
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
|
|
1
|
+
// Whole paper ("paper") or changed sections only ("changes"). Reviews open on
|
|
2
|
+
// the stored default: the changed sections, which show fastest, unless the
|
|
3
|
+
// whole paper was chosen in Papilot's settings. Switching views lasts for the
|
|
4
|
+
// open review only. A host such as Papilot shows its own switch and tells this
|
|
5
|
+
// frame; storage events bring a new default to reviews already open.
|
|
6
|
+
export const VIEW_STORAGE = "latex-review:default-view";
|
|
5
7
|
export const VIEW_EVENT = "latex-review:view-changed";
|
|
6
8
|
export type ReviewView = "paper" | "changes";
|
|
7
9
|
export function readView(): ReviewView {
|
|
8
10
|
try {
|
|
9
|
-
return localStorage.getItem(VIEW_STORAGE) === "
|
|
11
|
+
return localStorage.getItem(VIEW_STORAGE) === "paper" ? "paper" : "changes";
|
|
10
12
|
} catch {
|
|
11
|
-
return "
|
|
13
|
+
return "changes";
|
|
12
14
|
}
|
|
13
15
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "papilot",
|
|
3
|
-
"version": "0.8.
|
|
3
|
+
"version": "0.8.5",
|
|
4
4
|
"description": "A Cursor-like workspace for your papers: annotate and review PDFs directly without ever touching LaTeX.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"latex",
|
|
@@ -71,7 +71,7 @@
|
|
|
71
71
|
"doctor": "node bin/papilot.mjs doctor",
|
|
72
72
|
"service:install": "tsx scripts/service.ts install",
|
|
73
73
|
"service:stop": "tsx scripts/service.ts stop",
|
|
74
|
-
"test:ui": "npm run build && node scripts/check-ui.mjs && node scripts/check-task-model.mjs && node scripts/check-appearance.mjs && node scripts/check-updates.mjs && node scripts/check-papers.mjs && node scripts/check-imports.mjs && node scripts/check-compilation.mjs && node scripts/check-build-problems.mjs && node scripts/check-review-notes.mjs && node --import tsx scripts/check-note-groups.mjs && node scripts/check-note-selection.mjs && node scripts/check-ai-review.mjs && node --import tsx scripts/check-note-results.mjs && node --import tsx scripts/check-review-flow.mjs && node scripts/check-skills.mjs && node --import tsx scripts/check-skill-library.mjs && node scripts/check-attachments.mjs && node --import tsx scripts/check-accounts.mjs",
|
|
74
|
+
"test:ui": "npm run build && node scripts/check-ui.mjs && node scripts/check-task-model.mjs && node scripts/check-appearance.mjs && node scripts/check-updates.mjs && node scripts/check-papers.mjs && node scripts/check-imports.mjs && node scripts/check-compilation.mjs && node scripts/check-build-problems.mjs && node scripts/check-review-notes.mjs && node --import tsx scripts/check-note-groups.mjs && node scripts/check-note-selection.mjs && node scripts/check-ai-review.mjs && node --import tsx scripts/check-note-results.mjs && node --import tsx scripts/check-review-flow.mjs && node scripts/check-skills.mjs && node --import tsx scripts/check-skill-library.mjs && node scripts/check-attachments.mjs && node --import tsx scripts/check-accounts.mjs && node --import tsx scripts/check-places.mjs && node scripts/check-pdf-words.mjs",
|
|
75
75
|
"site": "node scripts/build-site.mjs",
|
|
76
76
|
"test:site": "npm run site && node scripts/check-site.mjs",
|
|
77
77
|
"prepack": "npm run build && npm run setup:review",
|
package/src/server/agents.ts
CHANGED
|
@@ -126,10 +126,11 @@ export function claudeConfigured(env: NodeJS.ProcessEnv = process.env) {
|
|
|
126
126
|
// Agent SDKs carry large native parts, so an installed Papilot fetches one
|
|
127
127
|
// into the data folder the first time its agent can be used. ready() runs
|
|
128
128
|
// before the download and throws when the agent is not configured.
|
|
129
|
+
const PACKAGES = path.join(DATA, 'agents');
|
|
129
130
|
export async function loadPackage(name: string, version: string, label: string, ready: () => void = () => {}): Promise<any> {
|
|
130
131
|
try { return await import(name); }
|
|
131
132
|
catch (error) { if ((error as NodeJS.ErrnoException).code !== 'ERR_MODULE_NOT_FOUND') throw error; }
|
|
132
|
-
const prefix =
|
|
133
|
+
const prefix = PACKAGES, resolve = () => createRequire(path.join(prefix, 'package.json')).resolve(name);
|
|
133
134
|
let entry: string;
|
|
134
135
|
try { entry = resolve(); } catch {
|
|
135
136
|
ready();
|
|
@@ -139,6 +140,14 @@ export async function loadPackage(name: string, version: string, label: string,
|
|
|
139
140
|
}
|
|
140
141
|
return import(pathToFileURL(entry).href);
|
|
141
142
|
}
|
|
143
|
+
// The file a package loaded from: Papilot's own dependencies, or the copy
|
|
144
|
+
// fetched on first use.
|
|
145
|
+
export function packageFile(name: string) {
|
|
146
|
+
for (const from of [import.meta.url, path.join(PACKAGES, 'package.json')]) {
|
|
147
|
+
try { return createRequire(from).resolve(name); } catch { /* Not installed there. */ }
|
|
148
|
+
}
|
|
149
|
+
return null;
|
|
150
|
+
}
|
|
142
151
|
|
|
143
152
|
let claude: Promise<typeof import('@anthropic-ai/claude-agent-sdk')['query']> | null = null;
|
|
144
153
|
// The Claude Agent SDK carries Claude Code. GLM runs on it too.
|
package/src/server/app.ts
CHANGED
|
@@ -12,7 +12,7 @@ import { reviewClient, allowedReviewRoute, rewriteReviewHtml, rewriteReviewJson,
|
|
|
12
12
|
import { fullPaperPdf } from './pdf.ts';
|
|
13
13
|
import { type ChangeAction, Changes } from './changes.ts';
|
|
14
14
|
import { bibliography, confirmEntry } from './references.ts';
|
|
15
|
-
import { type Manuscripts, annotationTask, gitAbortMerge, gitCommit, gitDiff, gitFinishMerge, gitIgnore, gitRemote, gitStage, gitStatus } from './manuscript.ts';
|
|
15
|
+
import { type Manuscripts, annotationTask, gitAbortMerge, gitCommit, gitDiff, gitDiscard, gitFinishMerge, gitIgnore, gitRemote, gitStage, gitStatus } from './manuscript.ts';
|
|
16
16
|
import { gitOverview } from './git-overview.ts';
|
|
17
17
|
import { findSkill, listedSkill, loadSkills } from './skills.ts';
|
|
18
18
|
import { registerSkillRoutes } from './skill-routes.ts';
|
|
@@ -25,7 +25,7 @@ import type { Updates } from './updates.ts';
|
|
|
25
25
|
import { Diagnostics } from './diagnostics.ts';
|
|
26
26
|
import { attachmentIds } from './attachments.ts';
|
|
27
27
|
import { MAX_ATTACHMENT_BYTES } from '../shared.ts';
|
|
28
|
-
import { PaperImports } from './imports.ts';
|
|
28
|
+
import { PaperImports, openFolder } from './imports.ts';
|
|
29
29
|
import { DATA } from './paths.ts';
|
|
30
30
|
import { registerAiReviewRoutes } from './ai-review-routes.ts';
|
|
31
31
|
import { detectLatex } from './latex-installation.ts';
|
|
@@ -174,6 +174,7 @@ export async function buildApp(options: AppOptions) {
|
|
|
174
174
|
const string = (value: unknown, name: string, max = 20000) => { if (typeof value !== 'string' || !value.trim() || value.length > max) throw new Error(`${name} is required and must be no longer than ${max} characters.`); return value.trim(); };
|
|
175
175
|
// Adding a paper names a folder on this host, so only the owner can.
|
|
176
176
|
app.post('/api/projects', owner, async request => { const b = request.body as any; return workspace.addProject(string(b.path, 'Repository path', 4000), b.name ? string(b.name, 'Paper name', 120) : undefined); });
|
|
177
|
+
app.post('/api/projects/open', owner, request => openFolder((request.body || {}) as any, root => store.projectByPath(root), (root, name) => workspace.addProject(root, name)));
|
|
177
178
|
app.post('/api/imports', { ...owner, bodyLimit: 90 * 1024 * 1024 }, request => imports!.create((request.body || {}) as any));
|
|
178
179
|
app.get<{ Params: { id: string } }>('/api/imports/:id', owner, request => imports!.get(request.params.id));
|
|
179
180
|
app.post<{ Params: { id: string } }>('/api/imports/:id/build', owner, request => imports!.build(request.params.id, (request.body || {}) as any));
|
|
@@ -314,6 +315,11 @@ export async function buildApp(options: AppOptions) {
|
|
|
314
315
|
const project = store.project(request.params.id), b = request.body as any;
|
|
315
316
|
return serialGit(project.id, async () => ({ git: await gitStage(project.path, paths(b.paths), b.stage !== false) }));
|
|
316
317
|
});
|
|
318
|
+
// The Git panel confirms before discarding; this cannot be undone.
|
|
319
|
+
app.post<{ Params: { id: string } }>('/api/projects/:id/git/discard', project(), request => {
|
|
320
|
+
const project = store.project(request.params.id), b = request.body as any;
|
|
321
|
+
return serialGit(project.id, async () => ({ git: await gitDiscard(project.path, paths(b.paths)) }));
|
|
322
|
+
});
|
|
317
323
|
// An agent writes a commit message for the chosen files, or a fresh one
|
|
318
324
|
// instead of the message it already wrote for them; nothing is written.
|
|
319
325
|
app.post<{ Params: { id: string } }>('/api/projects/:id/git/message', project(), request => {
|
package/src/server/cursor.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import fs from 'node:fs';
|
|
2
2
|
import os from 'node:os';
|
|
3
3
|
import path from 'node:path';
|
|
4
|
-
import { Adapter, loadPackage, toolItem, type Item, type Thread, type Turn } from './agents.ts';
|
|
4
|
+
import { Adapter, loadPackage, packageFile, toolItem, type Item, type Thread, type Turn } from './agents.ts';
|
|
5
5
|
import { imageType } from './attachments.ts';
|
|
6
6
|
import { DATA } from './paths.ts';
|
|
7
7
|
|
|
@@ -27,10 +27,29 @@ export function cursorConfigured(env: NodeJS.ProcessEnv = process.env) {
|
|
|
27
27
|
let cursor: Promise<CursorSdk> | null = null;
|
|
28
28
|
export function loadCursor() {
|
|
29
29
|
cursor ??= loadPackage('@cursor/sdk', CURSOR_SDK_VERSION, 'the Cursor SDK for Cursor tasks', () => { if (!cursorConfigured()) throw new Error(CURSOR_KEY); })
|
|
30
|
+
.then(sdk => { const file = packageFile('@cursor/sdk'); return file ? helpersBesideSdk(sdk, file) : sdk; })
|
|
30
31
|
.catch(error => { cursor = null; throw error; });
|
|
31
32
|
return cursor;
|
|
32
33
|
}
|
|
33
34
|
|
|
35
|
+
// The SDK looks for its platform helpers (the cursorsandbox binary, rg and the
|
|
36
|
+
// shell parser) in node_modules folders above the running script, not beside
|
|
37
|
+
// itself, and loads them as a run starts. The copy Papilot fetches on first
|
|
38
|
+
// use sits in the data folder, outside that search, so every sandboxed run
|
|
39
|
+
// failed as "not supported in this environment". While a run starts, the
|
|
40
|
+
// search begins at the SDK's own file.
|
|
41
|
+
let starting = 0, script: string | undefined;
|
|
42
|
+
export function helpersBesideSdk(sdk: CursorSdk, file: string): CursorSdk {
|
|
43
|
+
const fromSdk = async <T>(start: () => Promise<T>) => {
|
|
44
|
+
if (starting++ === 0) { script = process.argv[1]; process.argv[1] = file; }
|
|
45
|
+
try { return await start(); } finally { if (--starting === 0) process.argv[1] = script as string; }
|
|
46
|
+
};
|
|
47
|
+
const wrap = (agent: CursorAgentHandle): CursorAgentHandle => ({
|
|
48
|
+
agentId: agent.agentId, close: () => agent.close(), send: (message, options) => fromSdk(() => agent.send(message, options)),
|
|
49
|
+
});
|
|
50
|
+
return { Cursor: sdk.Cursor, Agent: { create: async options => wrap(await sdk.Agent.create(options)), resume: async (id, options) => wrap(await sdk.Agent.resume(id, options)) } };
|
|
51
|
+
}
|
|
52
|
+
|
|
34
53
|
// Effort is a model parameter in Cursor's catalog.
|
|
35
54
|
const effortParameter = (model?: CursorModel) => model?.parameters?.find(parameter => /effort|reasoning/i.test(parameter.id));
|
|
36
55
|
|
|
@@ -50,11 +50,15 @@ export async function gitOverview(root: string, tasks: Pick<Task, 'id' | 'worktr
|
|
|
50
50
|
}
|
|
51
51
|
}));
|
|
52
52
|
worktrees.sort((a, b) => Number(b.current) - Number(a.current));
|
|
53
|
-
const refs = new Map<string, string[]>();
|
|
54
|
-
const refOutput = await git(root, ['for-each-ref', '--format=%(objectname)%00%(refname:short)%00%(*objectname)', 'refs/heads', 'refs/remotes', 'refs/tags']);
|
|
53
|
+
const refs = new Map<string, { refs: string[]; remotes: string[]; tags: string[] }>();
|
|
54
|
+
const refOutput = await git(root, ['for-each-ref', '--format=%(objectname)%00%(refname)%00%(refname:short)%00%(*objectname)', 'refs/heads', 'refs/remotes', 'refs/tags']);
|
|
55
55
|
for (const line of refOutput.trim().split('\n').filter(Boolean)) {
|
|
56
|
-
const [object, name, peeled] = line.split('\0'), hash = peeled || object;
|
|
57
|
-
|
|
56
|
+
const [object, full, name, peeled] = line.split('\0'), hash = peeled || object;
|
|
57
|
+
// A remote's HEAD only names its default branch, which is listed anyway.
|
|
58
|
+
if (full.startsWith('refs/remotes/') && full.endsWith('/HEAD')) continue;
|
|
59
|
+
const entry = refs.get(hash) || { refs: [], remotes: [], tags: [] };
|
|
60
|
+
(full.startsWith('refs/heads/') ? entry.refs : full.startsWith('refs/remotes/') ? entry.remotes : entry.tags).push(name);
|
|
61
|
+
refs.set(hash, entry);
|
|
58
62
|
}
|
|
59
63
|
// Include detached worktree tips, which --branches alone would miss.
|
|
60
64
|
const heads = [...new Set(worktrees.flatMap(tree => tree.head ? [tree.head] : []))];
|
|
@@ -63,7 +67,7 @@ export async function gitOverview(root: string, tasks: Pick<Task, 'id' | 'worktr
|
|
|
63
67
|
const log = await git(root, ['log', '--topo-order', `-${limit + 1}`, '--format=%H%x00%P%x00%s%x00%an%x00%ct', '--branches', '--remotes', '--tags', ...heads, '--']);
|
|
64
68
|
const commits: GitGraphCommit[] = log.trim().split('\n').filter(Boolean).map(line => {
|
|
65
69
|
const [hash, parents, subject, author, date] = line.split('\0');
|
|
66
|
-
return { hash, parents: parents ? parents.split(' ') : [], subject, author, date: Number(date) * 1000, refs: refs.get(hash)
|
|
70
|
+
return { hash, parents: parents ? parents.split(' ') : [], subject, author, date: Number(date) * 1000, refs: [], remotes: [], tags: [], ...refs.get(hash) };
|
|
67
71
|
});
|
|
68
72
|
return { worktrees, commits: commits.slice(0, limit), truncated: commits.length > limit };
|
|
69
73
|
}
|
package/src/server/imports.ts
CHANGED
|
@@ -88,18 +88,25 @@ export async function inspectPaper(root: string) {
|
|
|
88
88
|
const found = new Set(roots);
|
|
89
89
|
candidates = [...roots, ...files.filter(file => !found.has(file))];
|
|
90
90
|
}
|
|
91
|
-
try {
|
|
92
|
-
// Engine detection also works before Git initialization by reading the source hints.
|
|
93
|
-
if (main) {
|
|
94
|
-
const text = sourceHead(path.join(root, main));
|
|
95
|
-
engine = typeof settings.engine === 'string' ? settings.engine : '';
|
|
96
|
-
const rc = [path.join(path.dirname(main), '.latexmkrc'), path.join(path.dirname(main), 'latexmkrc'), '.latexmkrc', 'latexmkrc'].find(file => fs.existsSync(path.join(root, file)));
|
|
97
|
-
const mode = rc ? fs.readFileSync(path.join(root, rc), 'utf8').match(/^\s*\$pdf_mode\s*=\s*(\d)/m)?.[1] : undefined;
|
|
98
|
-
if (!engine) engine = text.match(/^%\s*!\s*TEX\s+(?:TS-)?program\s*=\s*(pdflatex|xelatex|lualatex)/im)?.[1].toLowerCase() || ({ '1': 'pdflatex', '4': 'lualatex', '5': 'xelatex' } as Record<string, string>)[mode || ''] || (/\\(?:usepackage(?:\[[^\]]*\])?\{[^}]*\b(?:fontspec|xeCJK|unicode-math|polyglossia)\b|documentclass(?:\[[^\]]*\])?\{ctex)/.test(text) ? 'xelatex' : 'pdflatex');
|
|
99
|
-
}
|
|
100
|
-
} catch (failure) { error = (failure as Error).message; }
|
|
91
|
+
try { if (main) engine = engineFor(root, main, settings); } catch (failure) { error = (failure as Error).message; }
|
|
101
92
|
return { candidates, main, engine: engine || 'pdflatex', error };
|
|
102
93
|
}
|
|
94
|
+
// Engine detection also works before Git initialization by reading the source hints.
|
|
95
|
+
function engineFor(root: string, main: string, settings: Record<string, unknown>) {
|
|
96
|
+
if (typeof settings.engine === 'string' && settings.engine) return settings.engine;
|
|
97
|
+
const text = sourceHead(path.join(root, main));
|
|
98
|
+
const rc = [path.join(path.dirname(main), '.latexmkrc'), path.join(path.dirname(main), 'latexmkrc'), '.latexmkrc', 'latexmkrc'].find(file => fs.existsSync(path.join(root, file)));
|
|
99
|
+
const mode = rc ? fs.readFileSync(path.join(root, rc), 'utf8').match(/^\s*\$pdf_mode\s*=\s*(\d)/m)?.[1] : undefined;
|
|
100
|
+
return text.match(/^%\s*!\s*TEX\s+(?:TS-)?program\s*=\s*(pdflatex|xelatex|lualatex)/im)?.[1].toLowerCase() || ({ '1': 'pdflatex', '4': 'lualatex', '5': 'xelatex' } as Record<string, string>)[mode || ''] || (/\\(?:usepackage(?:\[[^\]]*\])?\{[^}]*\b(?:fontspec|xeCJK|unicode-math|polyglossia)\b|documentclass(?:\[[^\]]*\])?\{ctex)/.test(text) ? 'xelatex' : 'pdflatex');
|
|
101
|
+
}
|
|
102
|
+
// Keep the author's settings, including a source folder that still contains
|
|
103
|
+
// the main file; without one, the whole folder compiles.
|
|
104
|
+
function rememberMain(root: string, main: string, engine: string) {
|
|
105
|
+
const settings = config(root), folder = typeof settings.source_dir === 'string' ? settings.source_dir.replace(/\/+$/, '') : null;
|
|
106
|
+
const contains = folder === '' || (folder !== null && main.startsWith(folder + '/'));
|
|
107
|
+
const next = { ...settings, main, engine, ...(contains ? {} : { source_dir: '' }) };
|
|
108
|
+
if (JSON.stringify(next) !== JSON.stringify(settings)) fs.writeFileSync(path.join(root, CONFIG), JSON.stringify(next, null, 2) + '\n');
|
|
109
|
+
}
|
|
103
110
|
async function repositoryRoot(root: string) {
|
|
104
111
|
let top: string | undefined;
|
|
105
112
|
try { top = await git(root, 'rev-parse', '--show-toplevel'); } catch { /* A plain folder needs Git. */ }
|
|
@@ -115,6 +122,28 @@ export async function prepareRepository(root: string) {
|
|
|
115
122
|
await git(root, 'add', '--all');
|
|
116
123
|
await git(root, '-c', 'user.name=Papilot', '-c', 'user.email=papilot@localhost', '-c', 'commit.gpgsign=false', '-c', 'core.hooksPath=/dev/null', 'commit', '-qm', 'Import paper into Papilot');
|
|
117
124
|
}
|
|
125
|
+
// The desktop app opens a folder the way an editor does. A paper already in
|
|
126
|
+
// Papilot opens again; any other folder with a clear main file is added as it
|
|
127
|
+
// is and compiles in its own view. Only a folder with several possible main
|
|
128
|
+
// files returns them, so the author can pick one.
|
|
129
|
+
export async function openFolder(input: { path?: unknown; main?: unknown }, known: (root: string) => Project | undefined, register: (root: string, name: string) => Promise<Project>): Promise<{ project: Project } | { name: string; candidates: string[] }> {
|
|
130
|
+
if (typeof input.path !== 'string' || !input.path.trim()) throw new Error('Enter a folder path.');
|
|
131
|
+
const root = fs.realpathSync(input.path);
|
|
132
|
+
if (!fs.statSync(root).isDirectory()) throw new Error('Select a folder containing the paper.');
|
|
133
|
+
const project = known(root);
|
|
134
|
+
if (project) return { project };
|
|
135
|
+
await repositoryRoot(root);
|
|
136
|
+
const detected = await inspectPaper(root);
|
|
137
|
+
if (detected.error) throw new Error(detected.error);
|
|
138
|
+
if (input.main === undefined && !detected.main) {
|
|
139
|
+
if (!detected.candidates.length) throw new Error('Select a folder containing the paper.');
|
|
140
|
+
return { name: path.basename(root), candidates: detected.candidates };
|
|
141
|
+
}
|
|
142
|
+
const main = input.main ?? detected.main;
|
|
143
|
+
if (!existingTex(root, main)) throw new Error('Choose an existing TeX main file.');
|
|
144
|
+
rememberMain(root, main, engineFor(root, main, config(root)));
|
|
145
|
+
return { project: await register(root, path.basename(root)) };
|
|
146
|
+
}
|
|
118
147
|
export async function extractZip(buffer: Buffer, root: string) {
|
|
119
148
|
if (buffer.length > MAX_BYTES) throw new Error('Choose a ZIP smaller than 64 MB.');
|
|
120
149
|
const zip = await new Promise<yauzl.ZipFile>((resolve, reject) => yauzl.fromBuffer(buffer, { lazyEntries: true, strictFileNames: true, validateEntrySizes: true }, (error, value) => error ? reject(error) : resolve(value)));
|
|
@@ -214,14 +243,7 @@ export class PaperImports {
|
|
|
214
243
|
void (async () => {
|
|
215
244
|
try {
|
|
216
245
|
await repositoryRoot(value.path);
|
|
217
|
-
if (!repair)
|
|
218
|
-
// Keep the author's settings, including a source folder that still
|
|
219
|
-
// contains the main file; without one, the whole folder compiles.
|
|
220
|
-
const settings = config(value.path), folder = typeof settings.source_dir === 'string' ? settings.source_dir.replace(/\/+$/, '') : null;
|
|
221
|
-
const contains = folder === '' || (folder !== null && (main as string).startsWith(folder + '/'));
|
|
222
|
-
const next = { ...settings, main, engine, ...(contains ? {} : { source_dir: '' }) };
|
|
223
|
-
if (JSON.stringify(next) !== JSON.stringify(settings)) fs.writeFileSync(path.join(value.path, CONFIG), JSON.stringify(next, null, 2) + '\n');
|
|
224
|
-
}
|
|
246
|
+
if (!repair) rememberMain(value.path, main as string, engine as string);
|
|
225
247
|
await prepareRepository(value.path); value.versioned = true;
|
|
226
248
|
if (repair) { value.summary = await repair(value.path, previousError); Object.assign(value, await inspectPaper(value.path)); }
|
|
227
249
|
value.phase = 'building'; this.save(value);
|
package/src/server/manuscript.ts
CHANGED
|
@@ -47,8 +47,9 @@ export function parseStatus(output: string, numstat: string): Omit<GitStatus, 'h
|
|
|
47
47
|
// A rename or copy is followed by its original path.
|
|
48
48
|
const from = kind === '2' ? fields[++i] : undefined;
|
|
49
49
|
const code = kind === 'u' ? 'U' : xy[1] !== '.' ? xy[1] : xy[0];
|
|
50
|
-
// X is the index against HEAD, Y the working tree against the index.
|
|
51
|
-
|
|
50
|
+
// X is the index against HEAD, Y the working tree against the index. The
|
|
51
|
+
// staged list shows X where it differs, such as a new file edited again.
|
|
52
|
+
status.files.push({ path: file, status: code, staged: xy[0] !== '.', unstaged: xy[1] !== '.', lines: lines.get(file), ...(from ? { from } : {}), ...(xy[0] !== '.' && xy[0] !== code ? { index: xy[0] } : {}) });
|
|
52
53
|
}
|
|
53
54
|
}
|
|
54
55
|
return status;
|
|
@@ -147,6 +148,24 @@ export async function gitStage(root: string, paths: string[], stage: boolean) {
|
|
|
147
148
|
return gitStatus(root);
|
|
148
149
|
}
|
|
149
150
|
|
|
151
|
+
// Discard what the Changes list shows for these files, as an editor's source
|
|
152
|
+
// control does: a tracked file returns to its staged version, or the last
|
|
153
|
+
// commit, and an untracked file is deleted. Staged changes stay.
|
|
154
|
+
export async function gitDiscard(root: string, paths: string[]) {
|
|
155
|
+
const status = await gitStatus(root);
|
|
156
|
+
if (status.merging) throw new Error('Finish or abort the merge before discarding changes.');
|
|
157
|
+
const files = status.files.filter(item => paths.includes(item.path) && item.unstaged);
|
|
158
|
+
if (!files.length) throw new Error('Select at least one changed file.');
|
|
159
|
+
const tracked = files.filter(item => item.status !== '?').map(item => item.path);
|
|
160
|
+
if (tracked.length) await gitAction(root, ['checkout', '--', ...tracked]);
|
|
161
|
+
for (const item of files.filter(item => item.status === '?')) {
|
|
162
|
+
const file = path.resolve(root, item.path);
|
|
163
|
+
if (!file.startsWith(root + path.sep)) throw new Error('Invalid file path.');
|
|
164
|
+
fs.rmSync(file, { force: true });
|
|
165
|
+
}
|
|
166
|
+
return gitStatus(root);
|
|
167
|
+
}
|
|
168
|
+
|
|
150
169
|
// Commit what is staged, or with paths, exactly those files as they are on
|
|
151
170
|
// disk; changes staged for other files then stay staged and out of the commit.
|
|
152
171
|
export async function gitCommit(root: string, message: string, paths: string[] | null) {
|
package/src/server/review.ts
CHANGED
|
@@ -4,12 +4,17 @@ import { ENGINE } from './paths.ts';
|
|
|
4
4
|
import type { Task } from '../shared.ts';
|
|
5
5
|
|
|
6
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
|
-
let modulePromise: Promise<{ connect: (root: string) => Promise<ReviewClient> }> | null = null;
|
|
8
|
-
export function reviewClient(root: string): Promise<ReviewClient> {
|
|
7
|
+
let modulePromise: Promise<{ connect: (root: string, start?: boolean) => Promise<ReviewClient | null> }> | null = null;
|
|
8
|
+
export function reviewClient(root: string, reuse = false): Promise<ReviewClient> {
|
|
9
9
|
modulePromise ??= import(pathToFileURL(path.join(ENGINE, 'src/client.ts')).href);
|
|
10
|
-
|
|
10
|
+
// Polling an existing job must not replace a healthy server of another version.
|
|
11
|
+
return modulePromise!.then(async module => (reuse && await module.connect(root, false)) || (await module.connect(root))!);
|
|
11
12
|
}
|
|
12
13
|
export const taskRoute = (name: string) => `/api/tasks/${encodeURIComponent(name)}`;
|
|
14
|
+
export function reviewConnectionError(error: unknown): boolean {
|
|
15
|
+
if (!(error instanceof Error)) return false;
|
|
16
|
+
return ['AbortError', 'TimeoutError'].includes(error.name) || /fetch failed|ECONNREFUSED|ECONNRESET|socket hang up|The review server failed to start/.test(error.message);
|
|
17
|
+
}
|
|
13
18
|
|
|
14
19
|
export function confirmedReviewResponse(status: number, value: unknown, task: string): boolean {
|
|
15
20
|
if (!value || typeof value !== 'object') return false;
|
package/src/server/start.ts
CHANGED
|
@@ -21,8 +21,13 @@ const accounts = new Accounts(store, ACCOUNT_ORIGIN || null);
|
|
|
21
21
|
const manuscripts = new Manuscripts(), changes = new Changes();
|
|
22
22
|
const desktop = process.env.PAPILOT_DESKTOP === '1';
|
|
23
23
|
if (desktop) await desktopDemo(ROOT, DATA, store, workspace, manuscripts).catch(error => console.error('Could not prepare the demo paper:', error));
|
|
24
|
-
//
|
|
25
|
-
|
|
24
|
+
// A packaged desktop app downloads only Papilot's own code built for its
|
|
25
|
+
// installer (see desktop/runtime.ts) and asks the app to switch to it. npm
|
|
26
|
+
// updates belong to the web server.
|
|
27
|
+
const bundleRoot = desktop && process.send ? process.env.PAPILOT_DESKTOP_BUNDLE : undefined, fingerprint = process.env.PAPILOT_DESKTOP_FINGERPRINT || '';
|
|
28
|
+
const bundle = bundleRoot && fingerprint ? { root: bundleRoot, version: readJson(path.join(bundleRoot, 'package.json'))?.version || '', fingerprint } : undefined;
|
|
29
|
+
const updates = new Updates({ root: ROOT, data: DATA, version: VERSION, port: PORT, desktop, bundle,
|
|
30
|
+
...(bundle ? { restart: async (root: string, version: string) => { process.send!({ type: 'papilot:activate', root, version }); } } : {}),
|
|
26
31
|
busy: () => workspace.busyForUpdate || manuscripts.busyForUpdate || changes.busyForUpdate, shutdown: () => shutdown() });
|
|
27
32
|
const activation = desktop ? null : readJson(path.join(DATA, 'updates/active.json'));
|
|
28
33
|
const updateInstance = typeof activation?.root === 'string' && fs.existsSync(activation.root) && fs.realpathSync(activation.root) === fs.realpathSync(ROOT) ? activation.id : undefined;
|
|
@@ -46,7 +51,9 @@ console.log(`Papilot is running at ${localUrl}${PUBLIC_ORIGIN ? `\nRemote: ${PUB
|
|
|
46
51
|
if (!process.env.PAPILOT_UPDATE_PROBE) { void workspace.connect(); updates.start(); }
|
|
47
52
|
// Recompute cached feedback counts, including decisions from before this restart.
|
|
48
53
|
for (const task of store.tasks()) {
|
|
49
|
-
if (task.status === 'building') {
|
|
54
|
+
if (task.pendingBuild || (task.review?.job?.id && (task.status === 'building' || (task.status === 'failed' && task.error === 'fetch failed')))) {
|
|
55
|
+
void workspace.resumeBuild(task.id);
|
|
56
|
+
} else if (task.status === 'building') {
|
|
50
57
|
store.updateTask(task.id, { status: 'interrupted', error: 'The server restarted during the PDF build. Check the review and rebuild it if needed.' });
|
|
51
58
|
void workspace.refresh(task.id);
|
|
52
59
|
} else if (task.review?.round && task.status !== 'deleting') void workspace.refresh(task.id);
|