231 lines
11 KiB
TypeScript
231 lines
11 KiB
TypeScript
import express, { type Application, type Request, type Response } from 'express';
|
||
import { mkdirSync, writeFileSync } from 'fs';
|
||
import { join } from 'path';
|
||
import { localTaskRepoName, MISSION_BRIEF_FIELDS } from '../db/repository.js';
|
||
import { logger } from '../logger.js';
|
||
import { parseTaskId, validateCommentBody, validateFeedbackBody } from './validation.js';
|
||
import { checkTaskOwnership, canViewTask, resolveSpaceAccess, reserveAttachmentName } from './local-api-helpers.js';
|
||
import { type LocalTasksDeps, makeDynamicJson, resolveTaskWriteGate } from './local-tasks-shared.js';
|
||
|
||
/**
|
||
* Conversation + per-task state: feedback / mission brief / comments (read &
|
||
* post, including interjection and tool-request gating).
|
||
*/
|
||
export function registerLocalTaskCommentsRoutes(app: Application, deps: LocalTasksDeps): void {
|
||
const { repo } = deps;
|
||
const dynamicJson = makeDynamicJson(deps.opts.getMaxUploadMb);
|
||
|
||
app.put('/api/local/tasks/:taskId/feedback', 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 validation = validateFeedbackBody(req.body);
|
||
if (!validation.valid) {
|
||
res.status(400).json({ error: validation.error });
|
||
return;
|
||
}
|
||
const viewer = req.user as Express.User | undefined;
|
||
const task = await repo.getLocalTask(taskId, viewer ? { viewer } : undefined);
|
||
if (!checkTaskOwnership(req, res, task)) return;
|
||
await repo.updateFeedback(taskId, validation.data);
|
||
const updated = await repo.getLocalTask(taskId);
|
||
res.json({ task: updated });
|
||
} catch (err) {
|
||
logger.error(`Local task feedback API error: ${err}`);
|
||
res.status(500).json({ error: 'Failed to update feedback' });
|
||
}
|
||
});
|
||
|
||
app.put('/api/local/tasks/:taskId/mission', 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;
|
||
|
||
// Partial-replace: only string fields are written. Anything else
|
||
// (null, undefined, non-string) is treated as "leave unchanged".
|
||
// To clear a field, send an empty string.
|
||
const body = (req.body ?? {}) as Record<string, unknown>;
|
||
const patch: Record<string, string> = {};
|
||
for (const key of MISSION_BRIEF_FIELDS) {
|
||
const v = body[key];
|
||
if (typeof v === 'string') patch[key] = v;
|
||
}
|
||
if (Object.keys(patch).length === 0) {
|
||
res.status(400).json({ error: `No mission fields provided. Send ${MISSION_BRIEF_FIELDS.join(', ')} as strings.` });
|
||
return;
|
||
}
|
||
const merged = await repo.updateMissionBrief(taskId, patch);
|
||
res.json({ missionBrief: merged });
|
||
} catch (err) {
|
||
logger.error(`Local task mission API error: ${err}`);
|
||
res.status(500).json({ error: 'Failed to update mission brief' });
|
||
}
|
||
});
|
||
|
||
app.get('/api/local/tasks/:taskId/comments', 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, resolveSpaceAccess(repo, task, viewer))) return;
|
||
const comments = await repo.listLocalTaskComments(taskId);
|
||
res.json({ comments });
|
||
} catch (err) {
|
||
logger.error(`Local task comments API error: ${err}`);
|
||
res.status(500).json({ error: 'Failed to fetch local task comments' });
|
||
}
|
||
});
|
||
|
||
app.post('/api/local/tasks/:taskId/comments', dynamicJson, 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 commentValidation = validateCommentBody(req.body);
|
||
if (!commentValidation.valid) {
|
||
res.status(400).json({ error: commentValidation.error });
|
||
return;
|
||
}
|
||
const { body, author, attachments } = commentValidation;
|
||
const viewer = req.user as Express.User | undefined;
|
||
const task = await repo.getLocalTask(taskId, viewer ? { viewer } : undefined);
|
||
// 共有ワークスペースのタスクには editor 以上のメンバーもコメント可能。
|
||
// owner/admin/editor+ → 投稿可、閲覧のみ(viewer ロール)→ 明確な 403、
|
||
// 閲覧すらできない(非メンバー)→ 404。
|
||
const writeGate = await resolveTaskWriteGate(repo, viewer, task);
|
||
if (writeGate === 'deny') {
|
||
res.status(404).json({ error: 'Task not found' });
|
||
return;
|
||
}
|
||
if (writeGate === 'view-only') {
|
||
res.status(403).json({ error: '閲覧のみのため投稿できません' });
|
||
return;
|
||
}
|
||
|
||
// Tool-request mechanism: a job parked for tool approval must be resumed
|
||
// via approve/deny (POST .../tool-requests/:id/decide), NOT by a normal
|
||
// message — that would spawn a SECOND resume job running in parallel with
|
||
// the one the approval re-queues (duplicate execution). The UI locks the
|
||
// composer; guard here too so it's race-proof regardless of UI polling.
|
||
// Checked TWICE: once now (before writing attachments, so a rejected post
|
||
// leaves no orphan files in input/) and again after attachments (below)
|
||
// to close the TOCTOU where the job flips to tool_request mid-upload.
|
||
const earlyJob = await repo.getLatestJobForIssue(localTaskRepoName(taskId), taskId);
|
||
if (earlyJob && earlyJob.status === 'waiting_human'
|
||
&& (earlyJob.waitReason === 'tool_request' || earlyJob.waitReason === 'package_request')) {
|
||
res.status(409).json({ error: '保留中の承認要求があります。先に許可または拒否してください。' });
|
||
return;
|
||
}
|
||
|
||
// Compute the (sanitized) attachment filenames now — they go into the
|
||
// comment and the instruction — but DEFER the actual file writes until
|
||
// after the tool_request gate passes (below), so a rejected post never
|
||
// leaves orphan files in input/.
|
||
// 実書き込みはゲート通過まで遅延するが、確定ファイル名(同名は `名前 (2).ext`
|
||
// に避ける)は instruction / コメントに先に載せる必要があるため、ここで予測して
|
||
// 確定させる。既存 input/ ファイルは上書きしない。
|
||
const inputDir = task?.workspacePath ? join(task.workspacePath, 'input') : null;
|
||
const reservedNames = new Set<string>();
|
||
const writePlan: Array<{ name: string; buf: Buffer }> = [];
|
||
for (const att of attachments ?? []) {
|
||
if (!att.name || !att.contentBase64 || !inputDir) continue;
|
||
const safeName = att.name.replace(/[\\/]/g, '_');
|
||
const finalName = reserveAttachmentName(inputDir, safeName, reservedNames);
|
||
writePlan.push({ name: finalName, buf: Buffer.from(att.contentBase64, 'base64') });
|
||
}
|
||
const savedFileNames = writePlan.map(p => p.name);
|
||
const writeAttachments = () => {
|
||
if (writePlan.length > 0 && inputDir) {
|
||
mkdirSync(inputDir, { recursive: true });
|
||
for (const p of writePlan) {
|
||
writeFileSync(join(inputDir, p.name), p.buf);
|
||
}
|
||
}
|
||
};
|
||
|
||
// Re-fetch the latest job AFTER saving attachments for the interjection
|
||
// check below. The authoritative tool_request gate is enforced atomically
|
||
// inside createJobIfNoPending (same transaction as the insert) — see the
|
||
// blockOnToolRequestPause handling below — so there is no check-then-act
|
||
// race with the engine parking the job.
|
||
const prevJob = await repo.getLatestJobForIssue(localTaskRepoName(taskId), taskId);
|
||
|
||
// A job actively running injects the comment at its next iteration — save
|
||
// it as an interjection and don't touch the job queue.
|
||
const isRunning = prevJob && (prevJob.status === 'running' || prevJob.status === 'dispatching' || prevJob.status === 'waiting_subtasks');
|
||
|
||
if (isRunning) {
|
||
// Interjection: injected into the running job at its next iteration.
|
||
writeAttachments();
|
||
const comment = await repo.addLocalTaskComment(taskId, author, body, 'interjection', savedFileNames);
|
||
logger.info(`[local-tasks-api] interjection: comment ${comment.id} saved for ${prevJob!.status} job ${prevJob!.id} on task ${taskId}`);
|
||
res.status(201).json({ comment, jobId: prevJob!.id, interjection: true });
|
||
return;
|
||
}
|
||
|
||
const askCount = prevJob?.status === 'waiting_human' ? prevJob.askCount : 0;
|
||
const resumeMovement = prevJob?.status === 'waiting_human' ? prevJob.resumeMovement : null;
|
||
|
||
// Build instruction with attachment info (savedFileNames computed above)
|
||
const instruction = savedFileNames.length > 0
|
||
? `${body}\n\n添付ファイル(input/ に保存済み): ${savedFileNames.join(', ')}`
|
||
: body;
|
||
|
||
// Atomically reuse a still-pending job (e.g. a queued job from a comment
|
||
// sent a moment earlier) or create one. Without this, rapid/concurrent
|
||
// comments each spawned a job; the newest (queued) became latestJob and the
|
||
// task showed "Inbox" while an older job ran in the background.
|
||
// The tool_request gate is enforced inside the same transaction.
|
||
const { job, created, blockedByToolRequest } = repo.createJobIfNoPending({
|
||
repo: localTaskRepoName(taskId),
|
||
issueNumber: taskId,
|
||
instruction,
|
||
pieceName: task!.pieceName,
|
||
askCount,
|
||
resumeMovement,
|
||
role: prevJob?.requiredRole,
|
||
ownerId: task!.ownerId,
|
||
visibility: task!.visibility,
|
||
visibilityScopeOrgId: task!.visibilityScopeOrgId,
|
||
browserSessionProfileId: task!.browserSessionProfileId ?? null,
|
||
spaceId: task!.spaceId ?? null,
|
||
}, { blockOnToolRequestPause: true });
|
||
// Blocked by a tool/package-approval pause → return BEFORE persisting the
|
||
// comment so a rejected post leaves no orphan comment tied to no job.
|
||
if (blockedByToolRequest) {
|
||
res.status(409).json({ error: '保留中の承認要求があります。先に許可または拒否してください。' });
|
||
return;
|
||
}
|
||
|
||
// Persist attachments + the comment only after the job decision succeeds.
|
||
writeAttachments();
|
||
const comment = await repo.addLocalTaskComment(taskId, author, body, 'comment', savedFileNames);
|
||
if (created) {
|
||
await repo.addAuditLog(job.id, 'job_queued_local_comment', author, { taskId });
|
||
} else {
|
||
logger.info(`[local-tasks-api] comment ${comment.id} appended to pending job ${job.id} (${job.status}) on task ${taskId}; no duplicate job created`);
|
||
}
|
||
|
||
res.status(201).json({ comment, jobId: job.id, reusedPending: !created });
|
||
} catch (err) {
|
||
logger.error(`Local task comment create API error: ${err}`);
|
||
res.status(500).json({ error: 'Failed to post local task comment' });
|
||
}
|
||
});
|
||
}
|