Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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

Expand Down
31 changes: 19 additions & 12 deletions apps/server/src/compiler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -67,16 +68,21 @@ export class Compiler {
);
}

async request(project: ProjectRecord, actor: ActorIdentity): Promise<CompileJob> {
async request(project: ProjectRecord, actor: ActorIdentity, filePath = project.entry_file): Promise<CompileJob> {
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);
Expand All @@ -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;
Expand All @@ -102,7 +108,7 @@ export class Compiler {
}
}

private async admit(project: ProjectRecord, actor: ActorIdentity, sourceHash: string, bytes: number): Promise<CompileJob> {
private async admit(project: ProjectRecord, actor: ActorIdentity, entryFile: string, sourceHash: string, bytes: number): Promise<CompileJob> {
if (!Number.isSafeInteger(bytes) || bytes > this.maxSnapshotBytes) {
throw new HttpError(413, 'Project sources exceed the compiler snapshot size limit', 'compile_snapshot_too_large');
}
Expand Down Expand Up @@ -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;
}
Expand All @@ -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<CompileJob>(
`SELECT * FROM compile_jobs
Expand Down Expand Up @@ -205,6 +211,7 @@ export class Compiler {

private insertJob(
project: ProjectRecord,
entryFile: string,
sourceHash: string,
actor: ActorIdentity,
status: CompileJob['status'],
Expand All @@ -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,
Expand Down Expand Up @@ -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 {
Expand All @@ -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}`);
Expand Down
3 changes: 2 additions & 1 deletion apps/server/src/openapi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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')
}
Expand Down
33 changes: 24 additions & 9 deletions apps/server/src/project-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -213,22 +214,19 @@ export class ProjectStore {
now,
file.project_id
);
this.repairEntryFile(file.project_id);
});
return { ...file, path: newPath, revision_hash: hash, updated_at: now };
}

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);
this.touch(file.project_id);
this.repairEntryFile(file.project_id);
});
}

compileFileMetadata(projectId: string): Array<Pick<FileRecord, 'path' | 'kind' | 'revision_hash'> & { byte_length: number }> {
Expand Down Expand Up @@ -316,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<Pick<ProjectRecord, 'entry_file'>>('SELECT entry_file FROM projects WHERE id = ?', projectId)!;
const eligible = "kind = 'text' AND lower(path) LIKE '%.tex'";
const current = this.db.get<Pick<FileRecord, 'path'>>(
`SELECT path FROM files WHERE project_id = ? AND path = ? AND ${eligible}`,
projectId, project.entry_file
);
if (current) return;
const fallback = this.db.get<Pick<FileRecord, 'path'>>(
`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);
}
Expand Down
3 changes: 0 additions & 3 deletions apps/server/src/routes/file-routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
Expand Down
6 changes: 4 additions & 2 deletions apps/server/src/routes/project-routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,12 +72,14 @@ 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);
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);
}
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) };
Expand Down
5 changes: 3 additions & 2 deletions apps/server/src/routes/runtime-routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) });
});

Expand Down
89 changes: 88 additions & 1 deletion apps/server/tests/api.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -184,6 +184,93 @@ 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('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);
Expand Down
Loading
Loading