688 lines
29 KiB
TypeScript
688 lines
29 KiB
TypeScript
import express, { type Application, type Request, type Response } from 'express';
|
|
import { mkdirSync, writeFileSync } from 'fs';
|
|
import { join } from 'path';
|
|
import { Repository, localTaskRepoName } from '../db/repository.js';
|
|
import type { BrowserSessionRepo } from '../db/browser-session-repo.js';
|
|
import { logger } from '../logger.js';
|
|
import { resolveJobScheduling } from '../scheduling.js';
|
|
import { parseTaskId, validateCreateTaskBody, validateCommentBody, validateFeedbackBody } from './validation.js';
|
|
import { getLocalWorkspacePath, checkTaskOwnership, canViewTask } from './local-api-helpers.js';
|
|
import { jobEventBus, type JobStreamEvent } from './job-events.js';
|
|
|
|
export interface LocalTasksApiOptions {
|
|
repo: Repository;
|
|
worktreeDir?: string;
|
|
generateTitle?: (body: string) => Promise<string>;
|
|
selectPiece?: (body: string, fileNames: string[], userId?: string) => Promise<string>;
|
|
/**
|
|
* Server-side validator for piece names accepted by the
|
|
* /continue endpoint. Returns true if the piece is loadable.
|
|
* When unset, /continue rejects all requests with 500 (misconfiguration).
|
|
*
|
|
* `ownerId` is the task owner's user id; when provided the implementation
|
|
* MUST also check the owner's per-user piece dir so that user-custom pieces
|
|
* are accepted (matching the resolution order the worker uses at run time).
|
|
*/
|
|
pieceExists?: (name: string, ownerId?: string) => boolean;
|
|
/**
|
|
* Optional. When set, accepting browserSessionProfileId on task create
|
|
* verifies the profile belongs to the requesting user. Without it, the
|
|
* field is silently dropped (legacy / no-auth deployments).
|
|
*/
|
|
sessRepo?: BrowserSessionRepo;
|
|
/**
|
|
* Optional. Returns the current upload size limit (MB) for task creation
|
|
* and comment posting. Called per request so config changes take effect
|
|
* without a server restart. Clamped to [1, 1000] MB. Default: 50.
|
|
*/
|
|
getMaxUploadMb?: () => number;
|
|
/**
|
|
* Whether the auth subsystem is wired. When `false` (no-auth single-user
|
|
* deployment) requests carry no `req.user`, so new tasks/jobs are registered
|
|
* under the stable `local` owner instead of NULL — keeping per-task
|
|
* reflection / visibility lookups (which key on owner_id) consistent with the
|
|
* rest of the no-auth path. Defaults to `true` (owner stays req.user.id).
|
|
*/
|
|
authActive?: boolean;
|
|
}
|
|
|
|
export function mountLocalTasksApi(app: Application, opts: LocalTasksApiOptions): void {
|
|
const { repo, worktreeDir, sessRepo } = opts;
|
|
// No-auth single-user mode: register new tasks/jobs under the 'local' owner
|
|
// (the same namespace pieces/memory/reflection use) rather than letting the
|
|
// missing req.user fall through to a NULL owner. In auth mode this is
|
|
// undefined, so `req.user.id ?? noAuthOwner` keeps the real owner.
|
|
const noAuthOwner: string | undefined = (opts.authActive ?? true) ? undefined : 'local';
|
|
|
|
const resolveUploadLimit = (): string => {
|
|
const raw = opts.getMaxUploadMb?.() ?? 50;
|
|
const mb = Number.isFinite(raw) ? Math.max(1, Math.min(1000, Math.floor(raw))) : 50;
|
|
return `${mb}mb`;
|
|
};
|
|
const dynamicJson = () => (req: Request, res: Response, next: express.NextFunction) =>
|
|
express.json({ limit: resolveUploadLimit() })(req, res, next);
|
|
|
|
app.get('/api/local/tasks', async (req: Request, res: Response) => {
|
|
try {
|
|
const viewer = req.user as Express.User | undefined;
|
|
const tasks = await repo.listLocalTasks(viewer ? { viewer } : {});
|
|
res.json({ tasks });
|
|
} catch (err) {
|
|
logger.error(`Local tasks list API error: ${err}`);
|
|
res.status(500).json({ error: 'Failed to fetch local tasks' });
|
|
}
|
|
});
|
|
|
|
app.post('/api/local/tasks', dynamicJson(), async (req: Request, res: Response) => {
|
|
try {
|
|
const validation = validateCreateTaskBody(req.body);
|
|
if (!validation.valid) {
|
|
res.status(400).json({ error: validation.error });
|
|
return;
|
|
}
|
|
const body = validation.data;
|
|
|
|
// Visibility extraction + validation
|
|
const rawVisibility = req.body?.visibility ?? 'private';
|
|
if (!['private', 'org', 'public'].includes(rawVisibility)) {
|
|
res.status(400).json({ error: 'invalid visibility' });
|
|
return;
|
|
}
|
|
const visibility = rawVisibility as 'private' | 'org' | 'public';
|
|
const rawScopeOrgId = req.body?.visibilityScopeOrgId;
|
|
const visibilityScopeOrgId: string | null =
|
|
typeof rawScopeOrgId === 'string' && rawScopeOrgId.length > 0 ? rawScopeOrgId : null;
|
|
if (visibility === 'org') {
|
|
const orgIds = (req.user as Express.User | undefined)?.orgIds ?? [];
|
|
if (!visibilityScopeOrgId || !orgIds.includes(visibilityScopeOrgId)) {
|
|
res.status(400).json({ error: 'visibility_scope_org_id must be one of your orgs' });
|
|
return;
|
|
}
|
|
}
|
|
|
|
// Optional browser session profile binding. Owner-scoped check
|
|
// (sessRepo.getProfileById enforces owner_id = req.user.id) prevents
|
|
// user A from binding user B's profile to their task.
|
|
let browserSessionProfileId: number | null = null;
|
|
const rawProfileId = req.body?.browserSessionProfileId;
|
|
if (rawProfileId !== undefined && rawProfileId !== null && rawProfileId !== '') {
|
|
const n = Number(rawProfileId);
|
|
if (!Number.isInteger(n) || n <= 0) {
|
|
res.status(400).json({ error: 'browserSessionProfileId must be a positive integer' });
|
|
return;
|
|
}
|
|
if (sessRepo) {
|
|
const userId = (req.user as Express.User | undefined)?.id;
|
|
if (!userId) {
|
|
res.status(400).json({ error: 'browserSessionProfileId requires an authenticated user' });
|
|
return;
|
|
}
|
|
const owned = sessRepo.getProfileById(n, userId);
|
|
if (!owned) {
|
|
res.status(400).json({ error: 'browser session profile not found or not owned by you' });
|
|
return;
|
|
}
|
|
}
|
|
browserSessionProfileId = n;
|
|
}
|
|
|
|
let taskTitle = (body.title ?? '').trim();
|
|
const rawPiece = (body.piece ?? 'auto').trim();
|
|
const attachmentNames = (body.attachments ?? []).map((a: { name?: string }) => a.name).filter(Boolean) as string[];
|
|
|
|
// タイトル生成と piece 分類を並列実行
|
|
const [generatedTitle, autoSelectedPiece] = await Promise.all([
|
|
// タイトル生成
|
|
(!taskTitle && opts.generateTitle)
|
|
? Promise.race([
|
|
opts.generateTitle(body.body.trim()),
|
|
new Promise<string>((_, reject) => setTimeout(() => reject(new Error('timeout')), 8000)),
|
|
]).catch((e: unknown) => { logger.warn(`Title generation failed: ${e}`); return ''; })
|
|
: Promise.resolve(''),
|
|
// piece 分類('auto' の場合のみ); userId を渡し per-user カタログを使用
|
|
(rawPiece === 'auto' && opts.selectPiece)
|
|
? opts.selectPiece(body.body.trim(), attachmentNames, (req.user as Express.User | undefined)?.id).catch((e: unknown) => { logger.warn(`Piece classification failed: ${e}`); return 'chat'; })
|
|
: Promise.resolve(rawPiece),
|
|
]);
|
|
|
|
if (!taskTitle) {
|
|
taskTitle = generatedTitle || body.body.trim().slice(0, 40).replace(/\n/g, ' ');
|
|
}
|
|
const piece = autoSelectedPiece;
|
|
const profile = body.profile ?? 'auto';
|
|
const outputFormat = body.outputFormat ?? 'markdown';
|
|
const askPolicy = body.askPolicy ?? 'low';
|
|
const priority = body.priority ?? 'medium';
|
|
const scheduling = resolveJobScheduling({
|
|
role: profile,
|
|
pieceName: piece,
|
|
instruction: body.body.trim(),
|
|
});
|
|
|
|
// Per-task options (e.g. { mcpDisabled, skillsDisabled })
|
|
const rawOptions = req.body?.options;
|
|
const taskOptions: Record<string, unknown> =
|
|
rawOptions && typeof rawOptions === 'object' && !Array.isArray(rawOptions)
|
|
? rawOptions as Record<string, unknown>
|
|
: {};
|
|
|
|
const task = await repo.createLocalTask({
|
|
title: taskTitle,
|
|
body: body.body.trim(),
|
|
pieceName: piece,
|
|
profile,
|
|
outputFormat,
|
|
askPolicy,
|
|
priority,
|
|
ownerId: req.user?.id ?? noAuthOwner,
|
|
visibility,
|
|
visibilityScopeOrgId: visibility === 'org' ? visibilityScopeOrgId : null,
|
|
browserSessionProfileId,
|
|
options: taskOptions,
|
|
});
|
|
|
|
const workspacePath = getLocalWorkspacePath(worktreeDir, task.id);
|
|
mkdirSync(join(workspacePath, 'input'), { recursive: true });
|
|
mkdirSync(join(workspacePath, 'output'), { recursive: true });
|
|
mkdirSync(join(workspacePath, 'logs'), { recursive: true });
|
|
await repo.updateLocalTask(task.id, { workspacePath });
|
|
|
|
for (const att of body.attachments ?? []) {
|
|
if (!att.name || !att.contentBase64) continue;
|
|
const safeName = att.name.replace(/[\\/]/g, '_');
|
|
writeFileSync(join(workspacePath, 'input', safeName), Buffer.from(att.contentBase64, 'base64'));
|
|
}
|
|
|
|
await repo.addLocalTaskComment(task.id, 'user', body.body.trim(), 'request');
|
|
|
|
const metadataBlock = [
|
|
'---',
|
|
`ui_profile: ${scheduling.role}`,
|
|
`ui_output_format: ${outputFormat}`,
|
|
`ui_ask_policy: ${askPolicy}`,
|
|
`ui_priority: ${priority}`,
|
|
'---',
|
|
].join('\n');
|
|
const instruction = `${taskTitle}\n\n${body.body.trim()}\n\n${metadataBlock}`.trim();
|
|
// Merge task options into job payload so the worker can read them at runtime.
|
|
const hasOptions = Object.keys(taskOptions).length > 0;
|
|
const job = await repo.createJob({
|
|
repo: localTaskRepoName(task.id),
|
|
issueNumber: task.id,
|
|
instruction,
|
|
pieceName: piece,
|
|
role: scheduling.role,
|
|
ownerId: task.ownerId,
|
|
visibility: task.visibility,
|
|
visibilityScopeOrgId: task.visibilityScopeOrgId,
|
|
browserSessionProfileId: task.browserSessionProfileId ?? null,
|
|
payload: hasOptions ? JSON.stringify({ options: taskOptions }) : undefined,
|
|
});
|
|
await repo.addAuditLog(job.id, 'job_queued_local_create', 'local-ui', { taskId: task.id });
|
|
|
|
if (rawPiece === 'auto') {
|
|
await repo.addAuditLog(job.id, 'piece_auto_selected', 'piece-classifier', {
|
|
selectedPiece: piece,
|
|
});
|
|
}
|
|
|
|
const created = await repo.getLocalTask(task.id);
|
|
res.status(201).json({ task: created, jobId: job.id });
|
|
} catch (err) {
|
|
logger.error(`Create local task API error: ${err}`);
|
|
res.status(500).json({ error: 'Failed to create local task' });
|
|
}
|
|
});
|
|
|
|
app.get('/api/local/tasks/:taskId', 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;
|
|
res.json({ task });
|
|
} catch (err) {
|
|
logger.error(`Local task detail API error: ${err}`);
|
|
res.status(500).json({ error: 'Failed to fetch local task' });
|
|
}
|
|
});
|
|
|
|
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 ['goal', 'done', 'open', 'clarifications'] as const) {
|
|
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 goal, done, open, or clarifications 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)) 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);
|
|
if (!checkTaskOwnership(req, res, task)) return;
|
|
|
|
// Save attachments to input/
|
|
if (attachments && attachments.length > 0 && task?.workspacePath) {
|
|
const inputDir = join(task.workspacePath, 'input');
|
|
mkdirSync(inputDir, { recursive: true });
|
|
for (const att of attachments) {
|
|
if (!att.name || !att.contentBase64) continue;
|
|
const safeName = att.name.replace(/[\\/]/g, '_');
|
|
writeFileSync(join(inputDir, safeName), Buffer.from(att.contentBase64, 'base64'));
|
|
}
|
|
}
|
|
|
|
const prevJob = await repo.getLatestJobForIssue(localTaskRepoName(taskId), taskId);
|
|
|
|
// running / dispatching / waiting_subtasks 中: コメント保存のみ(agent-loop が注入する)
|
|
const isActive = prevJob && (prevJob.status === 'running' || prevJob.status === 'dispatching' || prevJob.status === 'waiting_subtasks');
|
|
const commentKind = isActive ? 'interjection' : 'comment';
|
|
const comment = await repo.addLocalTaskComment(taskId, author, body, commentKind);
|
|
|
|
if (isActive) {
|
|
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
|
|
const savedFileNames = (attachments ?? [])
|
|
.filter(att => att.name && att.contentBase64)
|
|
.map(att => att.name.replace(/[\\/]/g, '_'));
|
|
const instruction = savedFileNames.length > 0
|
|
? `${body}\n\n添付ファイル(input/ に保存済み): ${savedFileNames.join(', ')}`
|
|
: body;
|
|
|
|
const job = await repo.createJob({
|
|
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,
|
|
});
|
|
await repo.addAuditLog(job.id, 'job_queued_local_comment', author, { taskId });
|
|
|
|
res.status(201).json({ comment, jobId: job.id });
|
|
} catch (err) {
|
|
logger.error(`Local task comment create API error: ${err}`);
|
|
res.status(500).json({ error: 'Failed to post local task comment' });
|
|
}
|
|
});
|
|
|
|
app.patch('/api/local/tasks/:taskId', 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 task = await repo.getLocalTask(taskId, { viewer: req.user as Express.User | undefined });
|
|
if (!checkTaskOwnership(req, res, task)) return;
|
|
|
|
const updates: { visibility?: 'private' | 'org' | 'public'; visibilityScopeOrgId?: string | null } = {};
|
|
if (req.body.visibility !== undefined) {
|
|
const v = req.body.visibility;
|
|
if (!['private', 'org', 'public'].includes(v)) {
|
|
res.status(400).json({ error: 'invalid visibility' }); return;
|
|
}
|
|
updates.visibility = v;
|
|
}
|
|
if (req.body.visibilityScopeOrgId !== undefined) {
|
|
updates.visibilityScopeOrgId = req.body.visibilityScopeOrgId ?? null;
|
|
}
|
|
if (updates.visibility === 'org') {
|
|
const orgIds = (req.user as Express.User | undefined)?.orgIds ?? [];
|
|
const scopeId = updates.visibilityScopeOrgId ?? task!.visibilityScopeOrgId ?? null;
|
|
if (!scopeId || !orgIds.includes(scopeId)) {
|
|
res.status(400).json({ error: 'visibility_scope_org_id must be one of your orgs' }); return;
|
|
}
|
|
updates.visibilityScopeOrgId = scopeId;
|
|
}
|
|
if (updates.visibility && updates.visibility !== 'org') {
|
|
updates.visibilityScopeOrgId = null;
|
|
}
|
|
await repo.updateLocalTask(taskId, updates);
|
|
const refreshed = await repo.getLocalTask(taskId, { viewer: req.user as Express.User | undefined });
|
|
if ((updates.visibility !== undefined || updates.visibilityScopeOrgId !== undefined) && refreshed) {
|
|
await repo.updateJobsVisibilityForTask(taskId, {
|
|
visibility: refreshed.visibility ?? 'private',
|
|
visibilityScopeOrgId: refreshed.visibilityScopeOrgId ?? null,
|
|
});
|
|
}
|
|
res.json({ task: refreshed });
|
|
} catch (err) {
|
|
logger.error(`Patch local task API error: ${err}`);
|
|
res.status(500).json({ error: 'Failed to update task' });
|
|
}
|
|
});
|
|
|
|
app.delete('/api/local/tasks/:taskId', 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 task = await repo.getLocalTask(taskId, { viewer: req.user as Express.User | undefined });
|
|
if (!checkTaskOwnership(req, res, task)) return;
|
|
await repo.deleteLocalTask(taskId);
|
|
res.json({ ok: true });
|
|
} catch (err) {
|
|
const message = err instanceof Error ? err.message : String(err);
|
|
if (message.includes('has an active job')) {
|
|
res.status(409).json({ error: 'Cannot delete task with running jobs' });
|
|
return;
|
|
}
|
|
logger.error(`Delete local task API error: ${err}`);
|
|
res.status(500).json({ error: 'Failed to delete local task' });
|
|
}
|
|
});
|
|
|
|
app.post('/api/local/tasks/:taskId/cancel', 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;
|
|
const latestJob = await repo.getLatestJobForIssue(localTaskRepoName(taskId), taskId);
|
|
if (!latestJob || !['running', 'dispatching'].includes(latestJob.status)) {
|
|
res.status(404).json({ error: 'No running job found' });
|
|
return;
|
|
}
|
|
const cancelled = repo.requestJobCancel(latestJob.id);
|
|
if (!cancelled) {
|
|
res.status(409).json({ error: 'Job is no longer running' });
|
|
return;
|
|
}
|
|
await repo.addAuditLog(latestJob.id, 'job_cancel_requested', 'local-ui', { taskId });
|
|
logger.info(`Cancel requested for job ${latestJob.id} (task ${taskId})`);
|
|
res.json({ ok: true, jobId: latestJob.id });
|
|
} catch (err) {
|
|
logger.error(`Cancel local task API error: ${err}`);
|
|
res.status(500).json({ error: 'Failed to cancel task' });
|
|
}
|
|
});
|
|
|
|
app.post('/api/local/tasks/:taskId/continue', 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 piece = typeof req.body?.piece === 'string' ? req.body.piece.trim() : '';
|
|
const instruction = typeof req.body?.instruction === 'string' ? req.body.instruction : '';
|
|
if (!piece) {
|
|
res.status(400).json({ error: 'piece_required' });
|
|
return;
|
|
}
|
|
if (!instruction.trim()) {
|
|
res.status(400).json({ error: 'instruction_required' });
|
|
return;
|
|
}
|
|
|
|
const viewer = req.user as Express.User | undefined;
|
|
const task = await repo.getLocalTask(taskId, viewer ? { viewer } : undefined);
|
|
if (!checkTaskOwnership(req, res, task)) return;
|
|
|
|
// Piece existence check (server-side; UI dropdown is best-effort).
|
|
if (!opts.pieceExists) {
|
|
logger.error('[local-tasks-api] /continue invoked but pieceExists option not configured');
|
|
res.status(500).json({ error: 'piece_validation_unavailable' });
|
|
return;
|
|
}
|
|
if (!opts.pieceExists(piece, task?.ownerId ?? undefined)) {
|
|
res.status(400).json({ error: 'piece_not_found', piece });
|
|
return;
|
|
}
|
|
|
|
const prevJob = await repo.getLatestJobForIssue(localTaskRepoName(taskId), taskId);
|
|
if (!prevJob) {
|
|
res.status(409).json({ error: 'no_previous_job' });
|
|
return;
|
|
}
|
|
// jobs.status CHECK には 'aborted' が無い (worker が abort 結果を 'failed' に集約するため)。
|
|
// 'waiting_subtasks' は子 job 待機の中間状態で、そこから別 piece に切り替えると孤立するので除外。
|
|
const TERMINAL: ReadonlyArray<string> = ['succeeded', 'failed', 'waiting_human', 'cancelled'];
|
|
if (!TERMINAL.includes(prevJob.status)) {
|
|
res.status(409).json({ error: 'job_in_progress', currentStatus: prevJob.status });
|
|
return;
|
|
}
|
|
|
|
const job = await repo.createJob({
|
|
repo: localTaskRepoName(taskId),
|
|
issueNumber: taskId,
|
|
instruction: instruction.trim(),
|
|
pieceName: piece,
|
|
continuedFromJobId: prevJob.id,
|
|
ownerId: task!.ownerId,
|
|
role: prevJob.requiredRole,
|
|
visibility: task!.visibility,
|
|
visibilityScopeOrgId: task!.visibilityScopeOrgId,
|
|
browserSessionProfileId: task!.browserSessionProfileId ?? null,
|
|
});
|
|
|
|
await repo.updateLocalTask(taskId, { pieceName: piece });
|
|
|
|
// Surface the handoff in the timeline so the user (and the LLM, when
|
|
// it later inspects task comments) can see when piece switches happened.
|
|
await repo.addLocalTaskComment(
|
|
taskId,
|
|
'system',
|
|
`🔄 Continued: piece="${prevJob.pieceName}" → piece="${piece}"`,
|
|
'handoff',
|
|
);
|
|
|
|
await repo.addAuditLog(job.id, 'job_queued_local_continue', 'local-ui', {
|
|
taskId,
|
|
fromPiece: prevJob.pieceName,
|
|
toPiece: piece,
|
|
prevJobId: prevJob.id,
|
|
});
|
|
|
|
res.status(201).json({ jobId: job.id });
|
|
} catch (err) {
|
|
logger.error(`Local task continue API error: ${err}`);
|
|
res.status(500).json({ error: 'Failed to continue task' });
|
|
}
|
|
});
|
|
|
|
// ── SSE stream: real-time job events ──────────────────────────────────────
|
|
app.get('/api/local/tasks/:taskId/stream', async (req: Request, res: Response) => {
|
|
const taskId = parseTaskId(req.params.taskId);
|
|
if (taskId === null) { res.status(400).json({ error: 'invalid taskId' }); return; }
|
|
|
|
try {
|
|
const viewer = req.user as Express.User | undefined;
|
|
const task = await repo.getLocalTask(taskId, viewer ? { viewer } : {});
|
|
if (!task) { res.status(404).json({ error: 'task not found' }); return; }
|
|
|
|
const runningJob = task.latestJob;
|
|
if (!runningJob || (runningJob.status !== 'running' && runningJob.status !== 'dispatching')) {
|
|
res.status(204).end();
|
|
return;
|
|
}
|
|
const jobId = runningJob.id;
|
|
|
|
res.setHeader('Content-Type', 'text/event-stream');
|
|
res.setHeader('Cache-Control', 'no-store');
|
|
res.setHeader('Connection', 'keep-alive');
|
|
res.setHeader('X-Accel-Buffering', 'no');
|
|
res.flushHeaders();
|
|
|
|
// Text delta batching (50ms flush)
|
|
let textBuf = '';
|
|
let flushTimer: ReturnType<typeof setTimeout> | null = null;
|
|
const TEXT_FLUSH_MS = 50;
|
|
|
|
// Tool-call argument delta batching, keyed by callId (50ms flush).
|
|
const toolBuf = new Map<string, { name: string; chunk: string }>();
|
|
let toolFlushTimer: ReturnType<typeof setTimeout> | null = null;
|
|
|
|
const flushText = () => {
|
|
if (textBuf) {
|
|
const data = JSON.stringify({ type: 'text_delta', text: textBuf });
|
|
res.write(`data: ${data}\n\n`);
|
|
textBuf = '';
|
|
}
|
|
flushTimer = null;
|
|
};
|
|
|
|
const flushToolDeltas = () => {
|
|
for (const [callId, { name, chunk }] of toolBuf) {
|
|
if (res.writableEnded) break;
|
|
res.write(`data: ${JSON.stringify({ type: 'tool_use_delta', callId, name, chunk })}\n\n`);
|
|
}
|
|
toolBuf.clear();
|
|
toolFlushTimer = null;
|
|
};
|
|
|
|
const handler = (event: JobStreamEvent) => {
|
|
if (res.writableEnded) return;
|
|
if (event.type === 'text') {
|
|
textBuf += event.text ?? '';
|
|
if (!flushTimer) flushTimer = setTimeout(flushText, TEXT_FLUSH_MS);
|
|
return;
|
|
}
|
|
if (event.type === 'tool_use_delta') {
|
|
const callId = event.callId ?? '';
|
|
// chunk is a full snapshot of args-so-far; keep the LATEST per
|
|
// callId (replace, not append) so each flush sends the newest
|
|
// complete prefix. Coalesces many snapshots into one per 50ms.
|
|
toolBuf.set(callId, {
|
|
name: event.name ?? toolBuf.get(callId)?.name ?? '',
|
|
chunk: event.chunk ?? '',
|
|
});
|
|
if (!toolFlushTimer) toolFlushTimer = setTimeout(flushToolDeltas, TEXT_FLUSH_MS);
|
|
return;
|
|
}
|
|
// Flush pending text + tool deltas before non-streaming events
|
|
if (textBuf) flushText();
|
|
if (toolBuf.size) flushToolDeltas();
|
|
if (event.type === 'prompt_progress') {
|
|
const effective = (event.processed ?? 0) - (event.cache ?? 0);
|
|
const effectiveTotal = (event.total ?? 0) - (event.cache ?? 0);
|
|
const percent = effectiveTotal > 0 ? Math.round(effective / effectiveTotal * 100) : 0;
|
|
res.write(`data: ${JSON.stringify({ type: 'prompt_progress', percent, processed: event.processed, total: event.total, cache: event.cache, timeMs: event.timeMs })}\n\n`);
|
|
} else if (event.type === 'done') {
|
|
res.write(`data: ${JSON.stringify({ type: 'done' })}\n\n`);
|
|
cleanup();
|
|
res.end();
|
|
} else {
|
|
res.write(`data: ${JSON.stringify(event)}\n\n`);
|
|
}
|
|
};
|
|
|
|
// Heartbeat to keep connection alive
|
|
const heartbeat = setInterval(() => {
|
|
if (!res.writableEnded) res.write(': heartbeat\n\n');
|
|
}, 15_000);
|
|
|
|
const cleanup = () => {
|
|
jobEventBus.offJob(jobId, handler);
|
|
clearInterval(heartbeat);
|
|
if (flushTimer) { clearTimeout(flushTimer); flushText(); }
|
|
if (toolFlushTimer) { clearTimeout(toolFlushTimer); flushToolDeltas(); }
|
|
};
|
|
|
|
jobEventBus.onJob(jobId, handler);
|
|
req.on('close', cleanup);
|
|
} catch (err) {
|
|
logger.error(`Local task stream API error: ${err}`);
|
|
if (!res.headersSent) res.status(500).json({ error: 'stream failed' });
|
|
}
|
|
});
|
|
}
|