feat: initial public release (MAESTRO)

This commit is contained in:
oss-sync
2026-06-03 05:08:00 +00:00
commit f5c7666f6b
823 changed files with 184150 additions and 0 deletions
+670
View File
@@ -0,0 +1,670 @@
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).
*/
pieceExists?: (name: 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;
}
export function mountLocalTasksApi(app: Application, opts: LocalTasksApiOptions): void {
const { repo, worktreeDir, sessRepo } = opts;
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,
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)) {
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' });
}
});
}