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

103 lines
4.1 KiB
TypeScript

import { type Application, type Request, type Response } from 'express';
import { existsSync, readdirSync, statSync } from 'fs';
import { resolve, sep } from 'path';
import { Repository } from '../db/repository.js';
import { logger } from '../logger.js';
import { parseTaskId } from './validation.js';
import { canViewTask } from './local-api-helpers.js';
export function mountSubtaskFilesApi(app: Application, repo: Repository): void {
// NOTE: listing MUST be registered before the wildcard route
app.get('/api/local/tasks/:id/subtasks/:jobId/files', async (req: Request, res: Response) => {
try {
const taskId = parseTaskId(req.params.id);
if (taskId === null) {
res.status(400).json({ error: 'Invalid task ID' });
return;
}
const jobId = req.params.jobId;
const viewer = (req.user as Express.User | undefined) ?? undefined;
const task = await repo.getLocalTask(taskId, viewer ? { viewer } : undefined);
if (!canViewTask(req, res, task)) return;
// jobId で直接取得(孫タスクにも対応)
const subJob = await repo.getJob(jobId, viewer ? { viewer } : undefined);
if (!subJob || !subJob.worktreePath) {
res.status(404).json({ error: 'Subtask not found' }); return;
}
// タスクのワークスペース配下であることを確認
if (task!.workspacePath && !subJob.worktreePath.startsWith(task!.workspacePath)) {
res.status(404).json({ error: 'Subtask not found' }); return;
}
const basePath = resolve(subJob.worktreePath);
const categories: Record<string, string[]> = {};
for (const dir of ['output', 'logs', 'input']) {
const dirPath = resolve(basePath, dir);
if (!existsSync(dirPath)) continue;
const dirFiles = readdirSync(dirPath, { recursive: true })
.map(f => String(f))
.filter(f => !statSync(resolve(dirPath, f)).isDirectory());
if (dirFiles.length > 0) categories[dir] = dirFiles;
}
// 後方互換: files は output/ のファイル一覧
res.json({ files: categories['output'] ?? [], categories });
} catch (err) {
logger.error(`Subtask file list API error: ${err}`);
res.status(500).json({ error: 'Failed to list subtask files' });
}
});
app.get('/api/local/tasks/:id/subtasks/:jobId/files/*', async (req: Request, res: Response) => {
try {
const taskId = parseTaskId(req.params.id);
if (taskId === null) {
res.status(400).json({ error: 'Invalid task ID' });
return;
}
const jobId = req.params.jobId;
const filePath = req.params[0];
const viewer = (req.user as Express.User | undefined) ?? undefined;
const task = await repo.getLocalTask(taskId, viewer ? { viewer } : undefined);
if (!canViewTask(req, res, task)) return;
// jobId で直接取得(孫タスクにも対応)
const subJob = await repo.getJob(jobId, viewer ? { viewer } : undefined);
if (!subJob || !subJob.worktreePath) {
res.status(404).json({ error: 'Subtask not found' }); return;
}
// タスクのワークスペース配下であることを確認
if (task!.workspacePath && !subJob.worktreePath.startsWith(task!.workspacePath)) {
res.status(404).json({ error: 'Subtask not found' }); return;
}
const base = resolve(subJob.worktreePath);
const resolved = resolve(base, filePath);
// Require the trailing separator so a sibling like `<base>-x` cannot pass
// the prefix check; allow the base dir itself.
if (resolved !== base && !resolved.startsWith(base + sep)) {
res.status(403).json({ error: 'Access denied' }); return;
}
if (!existsSync(resolved)) { res.status(404).json({ error: 'File not found' }); return; }
const stat = statSync(resolved);
if (stat.isDirectory()) {
const dirFiles = readdirSync(resolved);
res.json({ files: dirFiles }); return;
}
res.sendFile(resolved);
} catch (err) {
logger.error(`Subtask files API error: ${err}`);
res.status(500).json({ error: 'Failed to fetch subtask file' });
}
});
}