diff --git a/src/components/frontend/src/index.ts b/src/components/frontend/src/index.ts index 1c3a605a..02dc9393 100644 --- a/src/components/frontend/src/index.ts +++ b/src/components/frontend/src/index.ts @@ -707,6 +707,7 @@ async function proxyCodeQueueDirect(req: Request, url: URL): Promise { } const overviewCacheKey = `${suffix}${url.search}`; const canUseOverviewCache = req.method === "GET" && suffix === "/api/tasks/overview"; + const expectsJsonResponse = suffix === "/health" || suffix.startsWith("/api/"); if (req.method !== "GET" && req.method !== "HEAD") invalidateCodeQueueOverviewCache(); if (canUseOverviewCache) { const cached = cachedCodeQueueOverview(overviewCacheKey); @@ -736,8 +737,28 @@ async function proxyCodeQueueDirect(req: Request, url: URL): Promise { const isJsonResponse = (upstreamContentType ?? "").toLowerCase().includes("json"); let parsedJson: unknown = null; let jsonText = ""; - if (canUseOverviewCache && upstream.ok && isJsonResponse) { + if (expectsJsonResponse) { jsonText = new TextDecoder().decode(upstreamBody); + if (!isJsonResponse) { + logger("warn", "code_queue_direct_proxy_non_json", { + path: suffix, + upstreamUrl: upstreamUrl.toString(), + status: upstream.status, + contentType: upstreamContentType, + bodyBytes: upstreamBody.byteLength, + preview: safePreview(jsonText), + }); + return jsonResponse({ + ok: false, + error: { + message: "code-queue upstream returned non-JSON response", + status: upstream.status, + contentType: upstreamContentType, + bodyBytes: upstreamBody.byteLength, + preview: safePreview(jsonText), + }, + }, 502); + } try { parsedJson = jsonText ? JSON.parse(jsonText) as unknown : null; } catch (error) { @@ -757,14 +778,15 @@ async function proxyCodeQueueDirect(req: Request, url: URL): Promise { detail, status: upstream.status, bodyBytes: upstreamBody.byteLength, + contentType: upstreamContentType, preview: safePreview(jsonText), }, }, 502); } } - if (canUseOverviewCache && upstream.ok && isJsonResponse) { + if (expectsJsonResponse) { const text = jsonText; - if (typeof parsedJson === "object" && parsedJson !== null) { + if (canUseOverviewCache && upstream.ok && typeof parsedJson === "object" && parsedJson !== null) { codeQueueOverviewCache.set(codeQueueOverviewCacheKey(overviewCacheKey), { at: Date.now(), payload: parsedJson as JsonValue, text }); } return new Response(text, { status: upstream.status, headers: responseHeaders }); diff --git a/src/components/microservices/code-queue/src/index.ts b/src/components/microservices/code-queue/src/index.ts index 98b08596..c94bba89 100644 --- a/src/components/microservices/code-queue/src/index.ts +++ b/src/components/microservices/code-queue/src/index.ts @@ -108,7 +108,7 @@ import { taskForListResponse, transcriptChunkResponse, } from "./queue-api"; -import { configureReferences, injectReferencedTaskContext, taskReferenceIds } from "./references"; +import { ReferenceTaskLookupError, configureReferences, injectReferencedTaskContext, taskReferenceIds } from "./references"; import { configureSelfTests, runJudgeInfraSelfTest, runQueueOrderingSelfTest, runReferenceInjectionSelfTest, runTracePortSelfTest } from "./self-tests"; import { configureTaskView, @@ -2686,6 +2686,34 @@ async function readJson(req: Request): Promise { return JSON.parse(text) as unknown; } +function requestErrorResponse(error: unknown): Response | null { + if (error instanceof ReferenceTaskLookupError) { + return jsonResponse({ + ok: false, + error: error.message, + missingReferenceTaskIds: error.missingIds, + }, 400); + } + if (error instanceof SyntaxError && /json/iu.test(error.message)) { + return jsonResponse({ + ok: false, + error: "invalid JSON request body", + detail: error.message, + }, 400); + } + if (error instanceof Error && ( + error.message === "request body must be an object" + || error.message === "prompt is required" + || error.message === "a task cannot reference itself while editing prompt" + || error.message.startsWith("referenceTaskIds supports at most ") + || error.message.startsWith("queueId must match ") + || error.message.startsWith("queue name must be ") + )) { + return jsonResponse({ ok: false, error: error.message }, 400); + } + return null; +} + function findTask(id: string): QueueTask | null { return state.tasks.find((task) => task.id === id) ?? null; } @@ -3033,10 +3061,10 @@ async function route(req: Request): Promise { }); } const devContainerStartMatch = url.pathname.match(/^\/api\/dev-containers(?:\/([^/]+))?\/start$/u); - if (devContainerStartMatch !== null && req.method === "POST") return startDevContainer(req, devContainerStartMatch[1] === undefined ? null : decodeURIComponent(devContainerStartMatch[1])); + if (devContainerStartMatch !== null && req.method === "POST") return await startDevContainer(req, devContainerStartMatch[1] === undefined ? null : decodeURIComponent(devContainerStartMatch[1])); const devContainerStatusMatch = url.pathname.match(/^\/api\/dev-containers(?:\/([^/]+))?\/status$/u); - if (devContainerStatusMatch !== null && req.method === "GET") return devContainerStatus(devContainerStatusMatch[1] === undefined ? null : decodeURIComponent(devContainerStatusMatch[1])); - if (url.pathname === "/api/judge/probe" && (req.method === "GET" || req.method === "POST")) return runJudgeProbe(); + if (devContainerStatusMatch !== null && req.method === "GET") return await devContainerStatus(devContainerStatusMatch[1] === undefined ? null : decodeURIComponent(devContainerStatusMatch[1])); + if (url.pathname === "/api/judge/probe" && (req.method === "GET" || req.method === "POST")) return await runJudgeProbe(); if (url.pathname === "/api/judge/self-test" && (req.method === "GET" || req.method === "POST")) return jsonResponse(runJudgeInfraSelfTest()); if (url.pathname === "/api/queue-order/self-test" && (req.method === "GET" || req.method === "POST")) return jsonResponse(runQueueOrderingSelfTest()); if (url.pathname === "/api/reference-injection/self-test" && (req.method === "GET" || req.method === "POST")) return jsonResponse(runReferenceInjectionSelfTest()); @@ -3062,17 +3090,17 @@ async function route(req: Request): Promise { const tasks = await loadAllTasksForRead(); return jsonResponse({ ok: true, queues: perQueueSummaries(tasks), queue: queueSummary(false, tasks) }); } - if (url.pathname === "/api/queues" && req.method === "POST") return createQueue(req); + if (url.pathname === "/api/queues" && req.method === "POST") return await createQueue(req); const queueMatch = url.pathname.match(/^\/api\/queues\/([^/]+)$/u); - if (queueMatch !== null && (req.method === "PATCH" || req.method === "PUT" || req.method === "POST")) return updateQueue(decodeURIComponent(queueMatch[1] ?? ""), req); - if (url.pathname === "/api/tasks/read-all" && req.method === "POST") return markTerminalTasksRead(url); + if (queueMatch !== null && (req.method === "PATCH" || req.method === "PUT" || req.method === "POST")) return await updateQueue(decodeURIComponent(queueMatch[1] ?? ""), req); + if (url.pathname === "/api/tasks/read-all" && req.method === "POST") return await markTerminalTasksRead(url); if (url.pathname === "/api/tasks/stats" && req.method === "GET") { const queueId = url.searchParams.get("queueId"); const allTasks = await loadAllTasksForRead(); const statsTasks = queueId === null ? allTasks : allTasks.filter((task) => queueIdOf(task) === safeQueueId(queueId)); return jsonResponse({ ok: true, statistics: taskStatisticsSummary(statsTasks, statsDaysFromUrl(url)), queue: queueSummary(false, allTasks) }); } - if (url.pathname === "/api/tasks/overview" && req.method === "GET") return tasksOverviewResponse(url); + if (url.pathname === "/api/tasks/overview" && req.method === "GET") return await tasksOverviewResponse(url); if (url.pathname === "/api/tasks" && req.method === "GET") { const status = url.searchParams.get("status"); const queueId = url.searchParams.get("queueId"); @@ -3104,29 +3132,29 @@ async function route(req: Request): Promise { }, }); } - if ((url.pathname === "/api/tasks" || url.pathname === "/api/tasks/batch") && req.method === "POST") return createTasks(req); + if ((url.pathname === "/api/tasks" || url.pathname === "/api/tasks/batch") && req.method === "POST") return await createTasks(req); const outputMatch = url.pathname.match(/^\/api\/tasks\/([^/]+)\/output$/u); if (outputMatch !== null && req.method === "GET") { const task = await findTaskForRead(decodeURIComponent(outputMatch[1] ?? "")); if (task === null) return jsonResponse({ ok: false, error: "task not found" }, 404); - return outputChunkResponse(task, url); + return await outputChunkResponse(task, url); } const transcriptMatch = url.pathname.match(/^\/api\/tasks\/([^/]+)\/transcript$/u); if (transcriptMatch !== null && req.method === "GET") { const task = await findTaskForRead(decodeURIComponent(transcriptMatch[1] ?? "")); if (task === null) return jsonResponse({ ok: false, error: "task not found" }, 404); - return transcriptChunkResponse(task, url); + return await transcriptChunkResponse(task, url); } const promptMatch = url.pathname.match(/^\/api\/tasks\/([^/]+)\/prompt$/u); if (promptMatch !== null && req.method === "GET") { const task = await findTaskForRead(decodeURIComponent(promptMatch[1] ?? "")); if (task === null) return jsonResponse({ ok: false, error: "task not found" }, 404); - return taskPromptDetailResponse(task, url); + return await taskPromptDetailResponse(task, url); } if (promptMatch !== null && req.method === "PATCH") { const task = await findTaskForMutation(decodeURIComponent(promptMatch[1] ?? "")); if (task === null) return jsonResponse({ ok: false, error: "task not found" }, 404); - return editQueuedTaskPrompt(task, req); + return await editQueuedTaskPrompt(task, req); } const traceSummaryMatch = url.pathname.match(/^\/api\/tasks\/([^/]+)\/trace-summary$/u); if (traceSummaryMatch !== null && req.method === "GET") { @@ -3138,13 +3166,13 @@ async function route(req: Request): Promise { if (traceStepsMatch !== null && req.method === "GET") { const task = await findTaskForRead(decodeURIComponent(traceStepsMatch[1] ?? "")); if (task === null) return jsonResponse({ ok: false, error: "task not found" }, 404); - return taskTraceStepsResponse(task, url); + return await taskTraceStepsResponse(task, url); } const traceStepMatch = url.pathname.match(/^\/api\/tasks\/([^/]+)\/trace-step$/u); if (traceStepMatch !== null && req.method === "GET") { const task = await findTaskForRead(decodeURIComponent(traceStepMatch[1] ?? "")); if (task === null) return jsonResponse({ ok: false, error: "task not found" }, 404); - return taskTraceStepDetailResponse(task, url); + return await taskTraceStepDetailResponse(task, url); } const summaryMatch = url.pathname.match(/^\/api\/tasks\/([^/]+)\/summary$/u); if (summaryMatch !== null && req.method === "GET") { @@ -3156,27 +3184,32 @@ async function route(req: Request): Promise { if (match !== null) { const action = match[2]; const taskId = decodeURIComponent(match[1] ?? ""); - if (action === "read" && req.method === "POST") return markTaskReadById(taskId); + if (action === "read" && req.method === "POST") return await markTaskReadById(taskId); const task = action === undefined && req.method === "GET" ? await findTaskForRead(taskId) : await findTaskForMutation(taskId); if (task === null) return jsonResponse({ ok: false, error: "task not found" }, 404); - if (action === "retry" && req.method === "POST") return manualRetry(task, req); - if (action === "steer" && req.method === "POST") return steerTask(task, req); - if (action === "interrupt" && req.method === "POST") return interruptTask(task); - if (action === "move" && req.method === "POST") return moveTaskToQueue(task, req); - if (action === "edit" && (req.method === "POST" || req.method === "PATCH")) return editQueuedTaskPrompt(task, req); + if (action === "retry" && req.method === "POST") return await manualRetry(task, req); + if (action === "steer" && req.method === "POST") return await steerTask(task, req); + if (action === "interrupt" && req.method === "POST") return await interruptTask(task); + if (action === "move" && req.method === "POST") return await moveTaskToQueue(task, req); + if (action === "edit" && (req.method === "POST" || req.method === "PATCH")) return await editQueuedTaskPrompt(task, req); if (action !== undefined) return jsonResponse({ ok: false, error: "not found" }, 404); if (req.method === "GET") { if (url.searchParams.get("meta") === "1") return jsonResponse({ ok: true, task: taskForMetaResponse(task) }); const includeRaw = url.searchParams.get("raw") === "1" || url.searchParams.get("full") === "1"; return jsonResponse({ ok: true, task: taskForResponse(task, true, includeRaw) }); } - if (req.method === "DELETE") return interruptTask(task); + if (req.method === "DELETE") return await interruptTask(task); return jsonResponse({ ok: false, error: "method not allowed" }, 405); } return jsonResponse({ ok: false, error: "not found", path: url.pathname }, 404); } catch (error) { + const requestError = requestErrorResponse(error); + if (requestError !== null) { + logger("warn", "request_rejected", { path: url.pathname, error: errorToJson(error) }); + return requestError; + } logger("error", "request_failed", { path: url.pathname, error: error instanceof Error ? error.stack ?? error.message : String(error) }); return jsonResponse({ ok: false, error: error instanceof Error ? error.message : String(error) }, 500); } diff --git a/src/components/microservices/code-queue/src/references.ts b/src/components/microservices/code-queue/src/references.ts index 89256439..e68aa4bd 100644 --- a/src/components/microservices/code-queue/src/references.ts +++ b/src/components/microservices/code-queue/src/references.ts @@ -4,6 +4,16 @@ import { lastAssistantMessage } from "./task-view"; import { resolvedReferenceContextTitle, userPromptForDisplay } from "./prompts"; import type { QueueTask, QueueTaskRequest, ReferenceInjectionRecord, ReferenceInjectionSummaryItem } from "./types"; +export class ReferenceTaskLookupError extends Error { + missingIds: string[]; + + constructor(missingIds: string[]) { + super(`referenced Code Queue task not found: ${missingIds.join(", ")}`); + this.name = "ReferenceTaskLookupError"; + this.missingIds = [...missingIds]; + } +} + export interface ReferencesContext { addUniqueTaskId: (ids: string[], value: string) => void; findTask: (id: string) => QueueTask | null; @@ -142,7 +152,7 @@ function injectReferencedTaskContext(request: QueueTaskRequest, finder: (id: str if (ids.length > 5) throw new Error(`referenceTaskIds supports at most 5 task ids, got ${ids.length}`); const referencedTasks = ids.map((id) => finder(id)); const missing = ids.filter((_id, index) => referencedTasks[index] === null); - if (missing.length > 0) throw new Error(`referenced Code Queue task not found: ${missing.join(", ")}`); + if (missing.length > 0) throw new ReferenceTaskLookupError(missing); const userPrompt = request.basePrompt ?? userPromptForDisplay(request.prompt); const graph = collectReferenceGraph(ids, ctx().referenceInjectionMaxRounds, finder); const injection: ReferenceInjectionRecord = {