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; const patch: Record = {}; 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(); 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' }); } }); }