maestro/src/bridge/local-files-api.ts
2026-06-03 05:08:00 +00:00

177 lines
7.3 KiB
TypeScript

import express, { type Application, type Request, type Response } from 'express';
import { mkdirSync, readdirSync, statSync, readFileSync, writeFileSync } from 'fs';
import { join, extname } from 'path';
import { Repository, localTaskRepoName } from '../db/repository.js';
import { logger } from '../logger.js';
import { parseTaskId } from './validation.js';
import { ensurePathWithin, serializeLocalFileEntry, checkTaskOwnership, canViewTask } from './local-api-helpers.js';
export function mountLocalFilesApi(app: Application, repo: Repository): void {
app.get('/api/local/tasks/:taskId/files', async (req: Request, res: Response) => {
try {
const taskId = parseTaskId(req.params.taskId);
if (taskId === null) {
res.status(400).json({ error: 'Invalid task ID' });
return;
}
const viewer = req.user as Express.User | undefined;
const task = await repo.getLocalTask(taskId, viewer ? { viewer } : undefined);
if (!canViewTask(req, res, task)) return;
if (!task?.workspacePath) {
res.status(404).json({ error: 'Workspace not found' });
return;
}
const section = String(req.query.section ?? 'input');
if (!['workspace', 'input', 'output', 'logs'].includes(section)) {
res.status(400).json({ error: 'section must be workspace, input, output, or logs' });
return;
}
const relativeDir = String(req.query.path ?? '').replace(/^\/+/, '').replace(/\/+$/, '');
const rootDir = section === 'workspace' ? task.workspacePath : join(task.workspacePath, section);
mkdirSync(rootDir, { recursive: true });
const dirPath = ensurePathWithin(rootDir, relativeDir);
const entries = readdirSync(dirPath, { withFileTypes: true }).map((entry) => {
const stat = statSync(join(dirPath, entry.name));
return serializeLocalFileEntry(relativeDir, entry.name, entry.isDirectory(), stat.size, stat.mtime);
});
res.json({ basePath: section, path: relativeDir, entries });
} catch (err) {
logger.error(`Local files list API error: ${err}`);
res.status(500).json({ error: 'Failed to list files' });
}
});
app.get('/api/local/tasks/:taskId/files/content', async (req: Request, res: Response) => {
try {
const taskId = parseTaskId(req.params.taskId);
if (taskId === null) {
res.status(400).json({ error: 'Invalid task ID' });
return;
}
const viewer = req.user as Express.User | undefined;
const task = await repo.getLocalTask(taskId, viewer ? { viewer } : undefined);
if (!canViewTask(req, res, task)) return;
if (!task?.workspacePath) {
res.status(404).json({ error: 'Workspace not found' });
return;
}
const section = String(req.query.section ?? 'input');
if (!['workspace', 'input', 'output', 'logs'].includes(section)) {
res.status(400).json({ error: 'section must be workspace, input, output, or logs' });
return;
}
const relativePath = String(req.query.path ?? '').replace(/^\/+/, '');
if (!relativePath) {
res.status(400).json({ error: 'path is required' });
return;
}
const rootDir = section === 'workspace' ? task.workspacePath : join(task.workspacePath, section);
const filePath = ensurePathWithin(rootDir, relativePath);
const stat = statSync(filePath);
if (!stat.isFile()) {
res.status(400).json({ error: 'path must point to a file' });
return;
}
res.setHeader('Content-Type', 'text/plain; charset=utf-8');
res.send(readFileSync(filePath, 'utf-8'));
} catch (err) {
logger.error(`Local file content API error: ${err}`);
res.status(500).json({ error: 'Failed to read file' });
}
});
app.get('/api/local/tasks/:taskId/files/raw', async (req: Request, res: Response) => {
try {
const taskId = parseTaskId(req.params.taskId);
if (taskId === null) {
res.status(400).json({ error: 'Invalid task ID' });
return;
}
const viewer = req.user as Express.User | undefined;
const task = await repo.getLocalTask(taskId, viewer ? { viewer } : undefined);
if (!canViewTask(req, res, task)) return;
if (!task?.workspacePath) {
res.status(404).json({ error: 'Workspace not found' });
return;
}
const section = String(req.query.section ?? 'input');
if (!['workspace', 'input', 'output', 'logs'].includes(section)) {
res.status(400).json({ error: 'section must be workspace, input, output, or logs' });
return;
}
const relativePath = String(req.query.path ?? '').replace(/^\/+/, '');
if (!relativePath) {
res.status(400).json({ error: 'path is required' });
return;
}
const rootDir = section === 'workspace' ? task.workspacePath : join(task.workspacePath, section);
const filePath = ensurePathWithin(rootDir, relativePath);
const stat = statSync(filePath);
if (!stat.isFile()) {
res.status(400).json({ error: 'path must point to a file' });
return;
}
res.type(extname(filePath) || 'application/octet-stream');
res.send(readFileSync(filePath));
} catch (err) {
logger.error(`Local file raw API error: ${err}`);
res.status(500).json({ error: 'Failed to read raw file' });
}
});
app.put('/api/local/tasks/:taskId/files/content', express.json(), async (req: Request, res: Response) => {
try {
const taskId = parseTaskId(req.params.taskId);
if (taskId === null) {
res.status(400).json({ error: 'Invalid task ID' });
return;
}
const viewer = req.user as Express.User | undefined;
const task = await repo.getLocalTask(taskId, viewer ? { viewer } : undefined);
if (!checkTaskOwnership(req, res, task)) return;
if (!task?.workspacePath) {
res.status(404).json({ error: 'Workspace not found' });
return;
}
const latestJob = await repo.getLatestJobForIssue(localTaskRepoName(taskId), taskId);
if (latestJob && ['running', 'dispatching'].includes(latestJob.status)) {
res.status(409).json({ error: 'Cannot edit files while job is running' });
return;
}
const section = String(req.body?.section ?? '');
if (section !== 'output') {
res.status(400).json({ error: 'Only output files can be edited' });
return;
}
const relativePath = String(req.body?.path ?? '').replace(/^\/+/, '');
if (!relativePath) {
res.status(400).json({ error: 'path is required' });
return;
}
const content = req.body?.content;
if (typeof content !== 'string') {
res.status(400).json({ error: 'content is required' });
return;
}
// PUT (inline edit) is output-only; section is narrowed to 'output' above.
const rootDir = join(task.workspacePath, section);
const filePath = ensurePathWithin(rootDir, relativePath);
writeFileSync(filePath, content, 'utf-8');
res.json({ ok: true });
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
if (message === 'Path escapes workspace') {
res.status(400).json({ error: message });
return;
}
logger.error(`Local file update API error: ${err}`);
res.status(500).json({ error: 'Failed to update file' });
}
});
}