maestro/src/bridge/local-tasks-comments-api.ts
oss-sync 77ee3bc426
Some checks are pending
CI / build-and-test (push) Waiting to run
sync: update from private repo (ddadfd71)
2026-07-08 23:35:00 +00:00

231 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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' });
}
});
}