From 03489edd3e566cbb406c91291dea3a9565521e41 Mon Sep 17 00:00:00 2001 From: Vasil Vasilev <89039078+SookX@users.noreply.github.com> Date: Thu, 22 Oct 2026 15:05:41 +0300 Subject: [PATCH 1/2] Compile the opened source file instead of a project main file --- README.md | 4 +-- apps/server/src/compiler.ts | 31 +++++++++------- apps/server/src/openapi.ts | 3 +- apps/server/src/project-store.ts | 20 ++++++----- apps/server/src/routes/file-routes.ts | 3 -- apps/server/src/routes/project-routes.ts | 3 +- apps/server/src/routes/runtime-routes.ts | 5 +-- apps/server/tests/api.test.ts | 35 ++++++++++++++++++- apps/web/public/agent-guide.md | 2 +- apps/web/src/components/FileTree.tsx | 10 ++---- apps/web/src/components/WorkspaceDialogs.tsx | 21 +++++------ apps/web/src/lib/api.ts | 6 ++-- apps/web/src/pages/DashboardPage.tsx | 2 +- apps/web/src/pages/WorkspacePage.pdf.test.tsx | 3 +- apps/web/src/pages/WorkspacePage.test.tsx | 13 ++++++- apps/web/src/pages/WorkspacePage.tsx | 19 ++++++---- 16 files changed, 115 insertions(+), 65 deletions(-) diff --git a/README.md b/README.md index 64746dd..0c08ed1 100644 --- a/README.md +++ b/README.md @@ -75,7 +75,7 @@ the individual source editor for changing includes, file structure, bibliography or other project files. Switching views and compiling first synchronize all open modules. The cursor's original file and line drive PDF highlighting and History. -The view expands literal braced includes from the compilation root, including +The view expands literal braced includes from the open source file, including nested inputs. It does not evaluate TeX macros or conditional execution. Comments, verbatim examples and inputs inside macro arguments are not expanded. Missing, repeated and cyclic inputs display a warning; repeated files appear once for @@ -84,7 +84,7 @@ exports always use the original project files. ### 3. Compile and review -Select **Compile** to flush the current collaborative editor state and run Tectonic. Successful output appears in the PDF pane for every connected collaborator. A completely empty document body has no printable page, so add content between `\begin{document}` and `\end{document}` before compiling a blank template. +Open a `.tex` file and select **Compile** to flush the current collaborative editor state and run Tectonic on that file. Every editor can compile the file they opened; no owner-selected main file is needed. Successful output appears in the PDF pane for every connected collaborator. A completely empty document body has no printable page, so add content between `\begin{document}` and `\end{document}` before compiling a blank template. ## External agent access diff --git a/apps/server/src/compiler.ts b/apps/server/src/compiler.ts index f666f60..b21989a 100644 --- a/apps/server/src/compiler.ts +++ b/apps/server/src/compiler.ts @@ -10,6 +10,7 @@ import type { ProjectStore } from './project-store.js'; import type { ActorIdentity, ProjectRecord } from './types.js'; import { stageCompileSources, type CompileSource } from './compile-staging.js'; import { hasPdfEnvelope } from './compile-artifacts.js'; +import { normalizeProjectPath } from './paths.js'; export interface CompileJob { id: string; @@ -67,16 +68,21 @@ export class Compiler { ); } - async request(project: ProjectRecord, actor: ActorIdentity): Promise { + async request(project: ProjectRecord, actor: ActorIdentity, filePath = project.entry_file): Promise { if (this.closing) throw new HttpError(503, 'The compiler is shutting down', 'compiler_unavailable'); await this.collaboration.flushProject(project.id); if (this.closing) throw new HttpError(503, 'The compiler is shutting down', 'compiler_unavailable'); project = { ...project }; + let entryFile: string; + try { entryFile = normalizeProjectPath(filePath); } + catch { throw new HttpError(400, 'Invalid compile source path', 'invalid_compile_file'); } const metadata = this.store.compileFileMetadata(project.id); - const entry = metadata.find(file => file.path === project.entry_file); - if (!entry || entry.kind !== 'text') throw new HttpError(400, 'The project entry file does not exist', 'entry_file_missing'); + const entry = metadata.find(file => file.path === entryFile); + if (!entry || entry.kind !== 'text' || !entryFile.toLowerCase().endsWith('.tex')) { + throw new HttpError(400, 'Choose an existing .tex source file to compile', 'invalid_compile_file'); + } const sourceHash = this.store.sourceHash(project.id, [ - project.entry_file, + entryFile, config.fakeCompiler ? 'fake-compiler-v1' : config.dockerImage, 'tectonic-v3:untrusted:synctex:snapshot' ], metadata); @@ -93,7 +99,7 @@ export class Compiler { const pending = this.pendingRequests.get(key); if (pending) return pending; const bytes = metadata.reduce((total, file) => total + file.byte_length, 0); - const requested = this.admit(project, actor, sourceHash, bytes); + const requested = this.admit(project, actor, entryFile, sourceHash, bytes); this.pendingRequests.set(key, requested); try { return await requested; @@ -102,7 +108,7 @@ export class Compiler { } } - private async admit(project: ProjectRecord, actor: ActorIdentity, sourceHash: string, bytes: number): Promise { + private async admit(project: ProjectRecord, actor: ActorIdentity, entryFile: string, sourceHash: string, bytes: number): Promise { if (!Number.isSafeInteger(bytes) || bytes > this.maxSnapshotBytes) { throw new HttpError(413, 'Project sources exceed the compiler snapshot size limit', 'compile_snapshot_too_large'); } @@ -131,7 +137,7 @@ export class Compiler { const cachedSynctex = join(this.cacheDir, `${sourceHash}.synctex.gz`); const synctexPath = await fileExists(cachedSynctex) ? cachedSynctex : null; const now = new Date().toISOString(); - const job = this.insertJob(project, sourceHash, actor, 'succeeded', now, now, cachedPdf, await readOptional(cachedLog), null, synctexPath); + const job = this.insertJob(project, entryFile, sourceHash, actor, 'succeeded', now, now, cachedPdf, await readOptional(cachedLog), null, synctexPath); this.events.emit(project.id, { type: 'compile.ready', job: publicCompileJob(job), cached: true }); return job; } @@ -140,7 +146,7 @@ export class Compiler { let job: CompileJob; try { - job = this.insertJob(project, sourceHash, actor, 'queued'); + job = this.insertJob(project, entryFile, sourceHash, actor, 'queued'); } catch (error) { const concurrent = this.db.get( `SELECT * FROM compile_jobs @@ -205,6 +211,7 @@ export class Compiler { private insertJob( project: ProjectRecord, + entryFile: string, sourceHash: string, actor: ActorIdentity, status: CompileJob['status'], @@ -220,7 +227,7 @@ export class Compiler { project_id: project.id, source_hash: sourceHash, status, - entry_file: project.entry_file, + entry_file: entryFile, pdf_path: pdfPath, synctex_path: synctexPath, log, @@ -269,7 +276,7 @@ export class Compiler { let output: CommandResult; if (config.fakeCompiler) { - const pdfName = `${basename(project.entry_file, extname(project.entry_file))}.pdf`; + const pdfName = `${basename(job.entry_file, extname(job.entry_file))}.pdf`; await writeFile(join(outputDir, pdfName), createMinimalPdf(project.name)); output = { code: 0, stdout: 'Fake compiler completed', stderr: '' }; } else { @@ -295,14 +302,14 @@ export class Compiler { '--outdir', '.overroot-output', '--', - project.entry_file + job.entry_file ], (config.compileTimeoutSeconds + 10) * 1000 ); } const log = trimLog([output.stdout, output.stderr].filter(Boolean).join('\n')); - const outputBase = basename(project.entry_file, extname(project.entry_file)); + const outputBase = basename(job.entry_file, extname(job.entry_file)); const generatedPdf = join(outputDir, `${outputBase}.pdf`); if (output.code !== 0 || !(await fileExists(generatedPdf))) { throw new CompileFailure(log || `Tectonic exited with status ${output.code}`); diff --git a/apps/server/src/openapi.ts b/apps/server/src/openapi.ts index d738f6b..237e668 100644 --- a/apps/server/src/openapi.ts +++ b/apps/server/src/openapi.ts @@ -122,10 +122,11 @@ export const openapi = { '/api/v1/projects/{projectHash}/compile': { post: { operationId: 'startCompile', tags: ['Compile'], parameters: [projectHash], + requestBody: { description: 'Source file to compile; omit for legacy project default.', content: { 'application/json': { schema: { type: 'object', properties: { filePath: { type: 'string', description: 'Existing project .tex path' } } } } } }, responses: { '200': response('Cached result', 'CompileEnvelope'), '202': response('Compile queued', 'CompileEnvelope'), - '400': error('Entry file missing'), + '400': error('Compile source missing or not a .tex file'), '413': error('Project exceeds the compiler snapshot byte limit'), '429': error('Compiler queue capacity is full; retry after a pending job finishes') } diff --git a/apps/server/src/project-store.ts b/apps/server/src/project-store.ts index 812ac79..5d7ff7b 100644 --- a/apps/server/src/project-store.ts +++ b/apps/server/src/project-store.ts @@ -220,15 +220,17 @@ export class ProjectStore { deleteFile(fileId: string): void { const file = this.getFileById(fileId); if (!file) throw new HttpError(404, 'File not found', 'file_not_found'); - this.db.run('DELETE FROM files WHERE id = ?', fileId); - this.touch(file.project_id); - } - - setEntryFile(projectId: string, pathInput: string): void { - const path = normalizeProjectPath(pathInput); - const file = this.getFile(projectId, path); - if (!file || file.kind !== 'text') throw new HttpError(400, 'Entry file must be an existing text file', 'invalid_entry_file'); - this.db.run('UPDATE projects SET entry_file = ?, updated_at = ? WHERE id = ?', path, new Date().toISOString(), projectId); + this.db.transaction(() => { + this.db.run('DELETE FROM files WHERE id = ?', fileId); + const fallback = this.db.get>( + "SELECT path FROM files WHERE project_id = ? AND kind = 'text' ORDER BY path COLLATE NOCASE LIMIT 1", + file.project_id + ); + this.db.run( + 'UPDATE projects SET entry_file = CASE WHEN entry_file = ? THEN ? ELSE entry_file END, updated_at = ? WHERE id = ?', + file.path, fallback?.path ?? '', new Date().toISOString(), file.project_id + ); + }); } compileFileMetadata(projectId: string): Array & { byte_length: number }> { diff --git a/apps/server/src/routes/file-routes.ts b/apps/server/src/routes/file-routes.ts index e1139ed..7334145 100644 --- a/apps/server/src/routes/file-routes.ts +++ b/apps/server/src/routes/file-routes.ts @@ -103,9 +103,6 @@ export function registerFileRoutes(app: FastifyInstance, context: AppContext): v const current = context.collaboration.current(fileId); assertFileProject(current.file, access.project.id); requireAgentRevision(actor, request, current.revision); - if (current.file.path === access.project.entry_file) { - throw new HttpError(400, 'Choose another entry file before deleting this one', 'entry_file_required'); - } await context.collaboration.remove(fileId); context.projects.deleteFile(fileId); audit(context.db, access.project.id, actorIdentity(actor), 'file.deleted', { path: current.file.path }); diff --git a/apps/server/src/routes/project-routes.ts b/apps/server/src/routes/project-routes.ts index ed2761f..4def15d 100644 --- a/apps/server/src/routes/project-routes.ts +++ b/apps/server/src/routes/project-routes.ts @@ -72,12 +72,11 @@ export function registerProjectRoutes(app: FastifyInstance, context: AppContext) const { hash } = z.object({ hash: z.string() }).parse(request.params); const access = projectAccess(context.db, actor, hash); requireOwner(access); - const input = z.object({ name: z.string().min(1).max(100).optional(), entryFile: z.string().optional() }).parse(request.body); + const input = z.object({ name: z.string().min(1).max(100).optional() }).parse(request.body); if (input.name) { const name = input.name.trim(); context.db.run('UPDATE projects SET name = ?, updated_at = ? WHERE id = ?', name, new Date().toISOString(), access.project.id); } - if (input.entryFile) context.projects.setEntryFile(access.project.id, input.entryFile); audit(context.db, access.project.id, actorIdentity(actor), 'project.updated', input); context.events.emit(access.project.id, { type: 'project.updated' }); return { project: projectDetail(context, hash, actor) }; diff --git a/apps/server/src/routes/runtime-routes.ts b/apps/server/src/routes/runtime-routes.ts index 6fb70f7..4a37849 100644 --- a/apps/server/src/routes/runtime-routes.ts +++ b/apps/server/src/routes/runtime-routes.ts @@ -16,8 +16,9 @@ export function registerRuntimeRoutes(app: FastifyInstance, context: AppContext) const { hash } = z.object({ hash: z.string() }).parse(request.params); const access = projectAccess(context.db, actor, hash); requireWrite(access); - const job = await context.compiler.request(access.project, actorIdentity(actor)); - audit(context.db, access.project.id, actorIdentity(actor), 'compile.requested', { jobId: job.id, sourceHash: job.source_hash }); + const { filePath } = z.object({ filePath: z.string().min(1).optional() }).parse(request.body ?? {}); + const job = await context.compiler.request(access.project, actorIdentity(actor), filePath); + audit(context.db, access.project.id, actorIdentity(actor), 'compile.requested', { jobId: job.id, sourceHash: job.source_hash, filePath: job.entry_file }); return reply.code(job.status === 'succeeded' ? 200 : 202).send({ job: publicCompileJob(job) }); }); diff --git a/apps/server/tests/api.test.ts b/apps/server/tests/api.test.ts index f21268d..c8a3e96 100644 --- a/apps/server/tests/api.test.ts +++ b/apps/server/tests/api.test.ts @@ -4,7 +4,7 @@ import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { gzipSync } from 'node:zlib'; import type { FastifyInstance } from 'fastify'; -import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { buildApp } from './helpers.js'; import type { AppContext } from '../src/context.js'; import { config } from '../src/config.js'; @@ -184,6 +184,39 @@ describe('Overroot API', () => { expect(restoreWithoutRevision.statusCode).toBe(428); }); + it('lets editors compile their chosen source without changing a project default', async () => { + const { app, context, adminToken } = harness!; + const editor = seedUser(context.db, 'compile-editor', false); + const editorToken = login(context.auth, editor); + const project = await createProject(app, adminToken); + const base = `/api/v1/projects/${project.hash}`; + expect((await app.inject({ method: 'POST', url: `${base}/members`, headers: bearer(adminToken), payload: { username: 'compile-editor', role: 'editor' } })).statusCode).toBe(201); + const source = await app.inject({ method: 'POST', url: `${base}/files`, headers: bearer(editorToken), payload: { path: 'chapters/other.tex', content: '\\documentclass{article}\\begin{document}Other\\end{document}' } }); + expect(source.statusCode).toBe(201); + const compile = (filePath: string) => app.inject({ method: 'POST', url: `${base}/compile`, headers: bearer(editorToken), payload: { filePath } }); + const first = await compile('chapters/other.tex'); + expect(first.statusCode).toBe(202); + expect(first.json().job.entryFile).toBe('chapters/other.tex'); + await vi.waitFor(() => expect(context.compiler.get(first.json().job.id, context.projects.getProjectByHash(project.hash)!.id)?.status).toBe('succeeded')); + const cached = await compile('chapters/other.tex'); + expect(cached.statusCode).toBe(200); + expect(cached.json().job.sourceHash).toBe(first.json().job.sourceHash); + const original = await compile('main.tex'); + expect(original.statusCode).toBe(202); + expect(original.json().job.sourceHash).not.toBe(first.json().job.sourceHash); + expect((await app.inject({ method: 'GET', url: base, headers: bearer(editorToken) })).json().project.entryFile).toBe('main.tex'); + for (const filePath of ['missing.tex', '../outside.tex', 'chapters/other.pdf']) { + const invalid = await compile(filePath); + expect(invalid.statusCode).toBe(400); + } + const deleted = await app.inject({ method: 'DELETE', url: `${base}/files/${source.json().file.id}`, headers: bearer(editorToken) }); + expect(deleted.statusCode).toBe(204); + expect((await compile('chapters/other.tex')).statusCode).toBe(400); + const tree = await app.inject({ method: 'GET', url: `${base}/files`, headers: bearer(editorToken) }); + const main = tree.json().files.find((file: any) => file.path === 'main.tex'); + expect((await app.inject({ method: 'DELETE', url: `${base}/files/${main.id}`, headers: bearer(editorToken) })).statusCode).toBe(204); + }); + it('compiles, caches, and serves authenticated PDFs', async () => { const { app, adminToken, context } = harness!; const project = await createProject(app, adminToken); diff --git a/apps/web/public/agent-guide.md b/apps/web/public/agent-guide.md index 67f27d3..6c2edc7 100644 --- a/apps/web/public/agent-guide.md +++ b/apps/web/public/agent-guide.md @@ -27,6 +27,6 @@ If-Match: A `409 revision_conflict` means a person or another agent edited the file. Fetch it again, merge deliberately, and retry. Never retry by blindly overwriting. Creating a new path does not need `If-Match`. -Direct file edits and compilation results are broadcast immediately to connected collaborators. Use `POST /api/v1/projects/{projectHash}/compile`, then inspect `/compile/latest` or the job endpoint. The OpenAPI document is authoritative for request and response fields. +Direct file edits and compilation results are broadcast immediately to connected collaborators. Use `POST /api/v1/projects/{projectHash}/compile` with JSON body `{ "filePath": "paper.tex" }` to compile that source file (omit the body for legacy project default), then inspect `/compile/latest` or the job endpoint. The OpenAPI document is authoritative for request and response fields. Human sign-in uses site-owned accounts with per-service admission and host-only browser cookies. Agents continue to use their independent project-scoped Bearer credentials, including during account-provider outages. Never request a human password or copy a browser cookie into an agent workflow. Human APIs and sockets require the live `overroot` permission `use`; service roles never widen project permissions. Human sockets revalidate central grants on a bounded interval; agents should continue using the REST API and existing project scope. diff --git a/apps/web/src/components/FileTree.tsx b/apps/web/src/components/FileTree.tsx index 084213e..4015131 100644 --- a/apps/web/src/components/FileTree.tsx +++ b/apps/web/src/components/FileTree.tsx @@ -12,7 +12,6 @@ interface TreeNode { export function FileTree({ files, selectedId, - entryFile, canWrite, onSelect, onCreate, @@ -21,7 +20,6 @@ export function FileTree({ }: { files: ProjectFile[]; selectedId: string | null; - entryFile: string; canWrite: boolean; onSelect: (file: ProjectFile) => void; onCreate: () => void; @@ -44,7 +42,7 @@ export function FileTree({ } ); @@ -54,7 +52,6 @@ function TreeLevel({ node, depth, selectedId, - entryFile, canWrite, onSelect, onFileMenu @@ -62,18 +59,17 @@ function TreeLevel({ node: TreeNode; depth: number; selectedId: string | null; - entryFile: string; canWrite: boolean; onSelect: (file: ProjectFile) => void; onFileMenu: (file: ProjectFile) => void; }) { return <> - {node.folders.map((folder) => ) } + {node.folders.map((folder) => ) } {node.files.map((file) => { const Icon = iconFor(file); return
{canWrite && }
; diff --git a/apps/web/src/components/WorkspaceDialogs.tsx b/apps/web/src/components/WorkspaceDialogs.tsx index 683e974..0c1a860 100644 --- a/apps/web/src/components/WorkspaceDialogs.tsx +++ b/apps/web/src/components/WorkspaceDialogs.tsx @@ -1,4 +1,4 @@ -import { Bot, Check, Download, Eye, FileClock, KeyRound, Pencil, Plus, RotateCcw, Trash2, UserPlus } from 'lucide-react'; +import { Bot, Download, Eye, FileClock, KeyRound, Pencil, Plus, RotateCcw, Trash2, UserPlus } from 'lucide-react'; import { useEffect, useState, type FormEvent } from 'react'; import type { ApiClient } from '../lib/api'; import { fileSize, relativeTime } from '../lib/format'; @@ -21,10 +21,10 @@ export function NewFileDialog({ open, onClose, onCreate }: { open: boolean; onCl } export function FileActionsDialog({ - open, file, entryFile, canSetEntry, onClose, onRename, onDelete, onSetEntry + open, file, onClose, onRename, onDelete }: { - open: boolean; file: ProjectFile | null; entryFile: string; canSetEntry: boolean; onClose: () => void; - onRename: (path: string) => Promise; onDelete: () => Promise; onSetEntry: () => Promise; + open: boolean; file: ProjectFile | null; onClose: () => void; + onRename: (path: string) => Promise; onDelete: () => Promise; }) { const [path, setPath] = useState(''); const [error, setError] = useState(null); const [busy, setBusy] = useState(false); useEffect(() => { setPath(file?.path ?? ''); setError(null); }, [file, open]); @@ -35,10 +35,8 @@ export function FileActionsDialog({
- {canSetEntry && file.kind === 'text' && file.path !== entryFile && } - +
- {file.path === entryFile &&

The main file cannot be deleted. Choose another main file first.

} ; } @@ -84,15 +82,14 @@ export function AgentDialog({ open, onClose, api, project }: { open: boolean; on function Credential({ label, value, secret = false }: { label: string; value: string; secret?: boolean }) { return
{label}{value}
; } -export function ProjectSettingsDialog({ open, onClose, api, project, files, onChanged, onDeleted }: { open: boolean; onClose: () => void; api: ApiClient; project: ProjectDetail; files: ProjectFile[]; onChanged: (project: ProjectDetail) => void; onDeleted: () => void }) { - const [name, setName] = useState(project.name); const [entryFile, setEntryFile] = useState(project.entryFile); const [error, setError] = useState(null); const [busy, setBusy] = useState(false); - useEffect(() => { setName(project.name); setEntryFile(project.entryFile); setError(null); }, [project, open]); - async function save(event: FormEvent) { event.preventDefault(); setBusy(true); try { const response = await api.updateProject(project.hash, { name, entryFile }); onChanged(response.project); onClose(); } catch (reason) { setError(message(reason)); } finally { setBusy(false); } } +export function ProjectSettingsDialog({ open, onClose, api, project, onChanged, onDeleted }: { open: boolean; onClose: () => void; api: ApiClient; project: ProjectDetail; onChanged: (project: ProjectDetail) => void; onDeleted: () => void }) { + const [name, setName] = useState(project.name); const [error, setError] = useState(null); const [busy, setBusy] = useState(false); + useEffect(() => { setName(project.name); setError(null); }, [project, open]); + async function save(event: FormEvent) { event.preventDefault(); setBusy(true); try { const response = await api.updateProject(project.hash, { name }); onChanged(response.project); onClose(); } catch (reason) { setError(message(reason)); } finally { setBusy(false); } } async function exportZip() { const result = await api.exportProject(project.hash); downloadBytes(result.data, result.filename ?? `${project.name}.zip`, 'application/zip'); } async function remove() { if (!confirm(`Permanently delete “${project.name}”?`)) return; await api.deleteProject(project.hash); onDeleted(); } return
-
Delete projectRemoves files, history, sharing, and agent access.
; diff --git a/apps/web/src/lib/api.ts b/apps/web/src/lib/api.ts index a752800..fd4465e 100644 --- a/apps/web/src/lib/api.ts +++ b/apps/web/src/lib/api.ts @@ -109,7 +109,7 @@ export class ApiClient { return this.request<{ project: ProjectDetail }>(`/api/v1/projects/${encodeURIComponent(hash)}`); } - updateProject(hash: string, change: { name?: string; entryFile?: string }) { + updateProject(hash: string, change: { name?: string }) { return this.request<{ project: ProjectDetail }>(`/api/v1/projects/${encodeURIComponent(hash)}`, { method: 'PATCH', body: JSON.stringify(change) @@ -169,8 +169,8 @@ export class ApiClient { ); } - compile(hash: string) { - return this.request<{ job: CompileJob }>(`/api/v1/projects/${encodeURIComponent(hash)}/compile`, { method: 'POST' }); + compile(hash: string, filePath: string) { + return this.request<{ job: CompileJob }>(`/api/v1/projects/${encodeURIComponent(hash)}/compile`, { method: 'POST', body: JSON.stringify({ filePath }) }); } latestCompile(hash: string) { diff --git a/apps/web/src/pages/DashboardPage.tsx b/apps/web/src/pages/DashboardPage.tsx index 80c4656..80ff5e7 100644 --- a/apps/web/src/pages/DashboardPage.tsx +++ b/apps/web/src/pages/DashboardPage.tsx @@ -105,7 +105,7 @@ export function DashboardPage() { {projects.length === 0 &&

No projects yet.

Begin with a clean template or import an existing ZIP archive.

} {projects.map((project) => -
{project.name}{project.role.toUpperCase()} · {project.entryFile}
+
{project.name}{project.role.toUpperCase()}
{presence.slice(0, 4).map((person) => {initials(person.name)})}{presence.length > 4 && +{presence.length - 4}}
{activity &&
{activity}
}
@@ -416,7 +421,7 @@ function ProjectWorkspace({ hash }: { hash: string }) {
- void selectFile(file)} onCreate={() => setNewFileOpen(true)} onUpload={(items) => void uploadFiles(items)} onFileMenu={setFileActions}/> + void selectFile(file)} onCreate={() => setNewFileOpen(true)} onUpload={(items) => void uploadFiles(items)} onFileMenu={setFileActions}/>
@@ -428,7 +433,7 @@ function ProjectWorkspace({ hash }: { hash: string }) {
}>{editorMode === 'manuscript' - ? + ? : }
@@ -442,10 +447,10 @@ function ProjectWorkspace({ hash }: { hash: string }) { setNewFileOpen(false)} onCreate={createFile}/> - setFileActions(null)} onRename={renameFile} onDelete={async () => { const id = fileActions!.id; await api.deleteFile(hash, id); if (selected?.id === id) setSelected(null); await loadFiles(); }} onSetEntry={async () => { const response = await api.updateProject(hash, { entryFile: fileActions!.path }); setProject(response.project); }}/> + setFileActions(null)} onRename={renameFile} onDelete={async () => { const id = fileActions!.id; await api.deleteFile(hash, id); if (selected?.id === id) setSelected(null); await loadFiles(); }} /> setShareOpen(false)} api={api} project={project}/> {project.canManage && setAgentOpen(false)} api={api} project={project}/>} - {project.canManage && setSettingsOpen(false)} api={api} project={project} files={files} onChanged={setProject} onDeleted={() => navigate('/')}/>} + {project.canManage && setSettingsOpen(false)} api={api} project={project} onChanged={setProject} onDeleted={() => navigate('/')}/>} setHistoryOpen(false)} api={api} projectHash={hash} file={editorMode === 'manuscript' ? files.find((file) => file.path === sourceLocation?.path) ?? selected : selected} canWrite={project.canWrite} refreshKey={historyKey}/> setLogOpen(false)} status={job?.status ?? 'Not compiled'} log={job?.log ?? ''} error={job?.error ?? null}/> setMobileMenuOpen(false)} title={project.name} eyebrow="Workspace actions" size="small">
@@ -453,7 +458,7 @@ function ProjectWorkspace({ hash }: { hash: string }) { {project.canManage && } - {project.canManage && } + {project.canManage && }
AppearanceLight, system, or dark
From 7e2d8341bba597cb7754c6307fafd1e29042f361 Mon Sep 17 00:00:00 2001 From: Vasil Vasilev <89039078+SookX@users.noreply.github.com> Date: Thu, 22 Oct 2026 15:30:35 +0300 Subject: [PATCH 2/2] Repair legacy compile default and reject retired entry updates --- apps/server/src/project-store.ts | 29 +++++++++---- apps/server/src/routes/project-routes.ts | 5 ++- apps/server/tests/api.test.ts | 54 ++++++++++++++++++++++++ 3 files changed, 79 insertions(+), 9 deletions(-) diff --git a/apps/server/src/project-store.ts b/apps/server/src/project-store.ts index 5d7ff7b..aaed6a1 100644 --- a/apps/server/src/project-store.ts +++ b/apps/server/src/project-store.ts @@ -147,6 +147,7 @@ export class ProjectStore { const file = this.db.transaction(() => { const created = this.insertFile(projectId, path, content, mimeType, actor, now); this.touch(projectId, now); + this.repairEntryFile(projectId); return created; }); return file; @@ -213,6 +214,7 @@ export class ProjectStore { now, file.project_id ); + this.repairEntryFile(file.project_id); }); return { ...file, path: newPath, revision_hash: hash, updated_at: now }; } @@ -222,14 +224,8 @@ export class ProjectStore { if (!file) throw new HttpError(404, 'File not found', 'file_not_found'); this.db.transaction(() => { this.db.run('DELETE FROM files WHERE id = ?', fileId); - const fallback = this.db.get>( - "SELECT path FROM files WHERE project_id = ? AND kind = 'text' ORDER BY path COLLATE NOCASE LIMIT 1", - file.project_id - ); - this.db.run( - 'UPDATE projects SET entry_file = CASE WHEN entry_file = ? THEN ? ELSE entry_file END, updated_at = ? WHERE id = ?', - file.path, fallback?.path ?? '', new Date().toISOString(), file.project_id - ); + this.touch(file.project_id); + this.repairEntryFile(file.project_id); }); } @@ -318,6 +314,23 @@ export class ProjectStore { ); } + // The stored path is only a legacy compile/manuscript default, not an owner-controlled root. + // Keep it compilable as files are added, renamed or deleted (including an empty project). + private repairEntryFile(projectId: string): void { + const project = this.db.get>('SELECT entry_file FROM projects WHERE id = ?', projectId)!; + const eligible = "kind = 'text' AND lower(path) LIKE '%.tex'"; + const current = this.db.get>( + `SELECT path FROM files WHERE project_id = ? AND path = ? AND ${eligible}`, + projectId, project.entry_file + ); + if (current) return; + const fallback = this.db.get>( + `SELECT path FROM files WHERE project_id = ? AND ${eligible} ORDER BY path COLLATE NOCASE LIMIT 1`, + projectId + ); + this.db.run('UPDATE projects SET entry_file = ? WHERE id = ?', fallback?.path ?? '', projectId); + } + private touch(projectId: string, now = new Date().toISOString()): void { this.db.run('UPDATE projects SET updated_at = ? WHERE id = ?', now, projectId); } diff --git a/apps/server/src/routes/project-routes.ts b/apps/server/src/routes/project-routes.ts index 4def15d..4b7f38d 100644 --- a/apps/server/src/routes/project-routes.ts +++ b/apps/server/src/routes/project-routes.ts @@ -72,7 +72,10 @@ export function registerProjectRoutes(app: FastifyInstance, context: AppContext) const { hash } = z.object({ hash: z.string() }).parse(request.params); const access = projectAccess(context.db, actor, hash); requireOwner(access); - const input = z.object({ name: z.string().min(1).max(100).optional() }).parse(request.body); + if (request.body && typeof request.body === 'object' && Object.hasOwn(request.body, 'entryFile')) { + throw new HttpError(400, 'Project main file selection is retired; specify filePath when compiling', 'entry_file_retired'); + } + const input = z.object({ name: z.string().min(1).max(100).optional() }).strict().parse(request.body); if (input.name) { const name = input.name.trim(); context.db.run('UPDATE projects SET name = ?, updated_at = ? WHERE id = ?', name, new Date().toISOString(), access.project.id); diff --git a/apps/server/tests/api.test.ts b/apps/server/tests/api.test.ts index c8a3e96..94edee5 100644 --- a/apps/server/tests/api.test.ts +++ b/apps/server/tests/api.test.ts @@ -217,6 +217,60 @@ describe('Overroot API', () => { expect((await app.inject({ method: 'DELETE', url: `${base}/files/${main.id}`, headers: bearer(editorToken) })).statusCode).toBe(204); }); + it('keeps the legacy compile default on an eligible .tex across deletion and recreation', async () => { + const { app, adminToken } = harness!; + const project = await createProject(app, adminToken); + const base = `/api/v1/projects/${project.hash}`; + const headers = bearer(adminToken); + const add = async (path: string) => { + const result = await app.inject({ method: 'POST', url: `${base}/files`, headers, payload: { path, content: path } }); + expect(result.statusCode).toBe(201); + return result.json().file.id as string; + }; + const remove = async (id: string) => { + expect((await app.inject({ method: 'DELETE', url: `${base}/files/${id}`, headers })).statusCode).toBe(204); + }; + const defaultPath = async () => (await app.inject({ method: 'GET', url: base, headers })).json().project.entryFile as string; + const compileDefault = () => app.inject({ method: 'POST', url: `${base}/compile`, headers }); + const tree = await app.inject({ method: 'GET', url: `${base}/files`, headers }); + const mainId = tree.json().files.find((file: any) => file.path === 'main.tex').id as string; + await add('references.bib'); + await add('notes.md'); + const alternateId = await add('standalone.tex'); + await remove(mainId); + expect(await defaultPath()).toBe('standalone.tex'); + const alternate = await compileDefault(); + expect(alternate.statusCode).toBe(202); + expect(alternate.json().job.entryFile).toBe('standalone.tex'); + await remove(alternateId); + expect(await defaultPath()).toBe(''); + expect((await compileDefault()).statusCode).toBe(400); + const freshId = await add('fresh.tex'); + expect(await defaultPath()).toBe('fresh.tex'); + const fresh = await compileDefault(); + expect(fresh.statusCode).toBe(202); + expect(fresh.json().job.entryFile).toBe('fresh.tex'); + const renamed = await app.inject({ method: 'PATCH', url: `${base}/files/${freshId}`, headers, payload: { path: 'fresh.bib' } }); + expect(renamed.statusCode).toBe(200); + expect(await defaultPath()).toBe(''); + await add('new-source.tex'); + expect(await defaultPath()).toBe('new-source.tex'); + }); + + it('rejects retired project entryFile updates rather than silently accepting them', async () => { + const { app, adminToken } = harness!; + const project = await createProject(app, adminToken); + const base = `/api/v1/projects/${project.hash}`; + const headers = bearer(adminToken); + const source = await app.inject({ method: 'POST', url: `${base}/files`, headers, payload: { path: 'other.tex', content: 'Other' } }); + expect(source.statusCode).toBe(201); + const retired = await app.inject({ method: 'PATCH', url: base, headers, payload: { name: 'Should not change', entryFile: 'other.tex' } }); + expect(retired.statusCode).toBe(400); + expect(retired.json().error.code).toBe('entry_file_retired'); + const unchanged = await app.inject({ method: 'GET', url: base, headers }); + expect(unchanged.json().project).toMatchObject({ name: project.name, entryFile: 'main.tex' }); + }); + it('compiles, caches, and serves authenticated PDFs', async () => { const { app, adminToken, context } = harness!; const project = await createProject(app, adminToken);