diff --git a/apps/backend/src/modules/chat.ts b/apps/backend/src/modules/chat.ts index a2ae96b..dee8d17 100644 --- a/apps/backend/src/modules/chat.ts +++ b/apps/backend/src/modules/chat.ts @@ -6,7 +6,8 @@ import { prisma } from "../prisma"; import { redis } from "./redis"; import { logger } from "./utils"; import { runQueue } from "./worker"; -import type { OrchestratorEvent } from "../../../../packages/agents"; +import { badRequest, conflict, notFound, ok, requireStrings, serverError } from "./http"; +import { isRunSettlingEvent, type OrchestratorEvent } from "../../../../packages/agents"; import type { Answers } from "../../../../packages/agents/types/agentTypes"; const chatRouter = Router() @@ -23,6 +24,15 @@ GET /chat/:projectId/history → all past runs' events, for reload */ +// Newest persisted event of a given type for a run, already JSON-parsed. +async function latestEventContent(runId: string, type: string): Promise { + const event = await prisma.runEvent.findFirst({ + where: { runId, type }, + orderBy: { createdAt: 'desc' }, + }) + return event?.content ? JSON.parse(event.content) : null +} + chatRouter.post('/', auth, createRun) chatRouter.post('/:projectId', auth, createRun) @@ -33,7 +43,7 @@ async function createRun(req: Request, res: Response){ const existingSandboxId = req.body?.sandboxId if(typeof userId !== 'string' || typeof userPrompt !== 'string'){ - return res.status(400).json({success: false, message: `Invalid userid or userPrompt`}) + return badRequest(res, `Invalid userid or userPrompt`) } if(!projectId){ const project = await prisma.project.create({data: { @@ -46,7 +56,7 @@ async function createRun(req: Request, res: Response){ logger.info(`Project id: ${projectId}`) if(typeof projectId !== 'string'){ - return res.status(400).json({}) + return badRequest(res) } // const sandbox = await E2BSandbox.StartSandbox(userId, projectId, existingSandboxId ) @@ -63,7 +73,7 @@ async function createRun(req: Request, res: Response){ // activate after testing, #POST-TESTING if(!user){ - return res.status(404).json({success: false, message: `User not found :(`}) + return notFound(res, `User not found :(`) } try{ @@ -78,7 +88,7 @@ async function createRun(req: Request, res: Response){ logger.info(`Added to run queue`) } catch(e){ logger.error(`Failed to enqueue run ${run.id}: ${e}`) - return res.status(500).json({success: false, message: `Failed to start run`}) + return serverError(res, `Failed to start run`) } return res.status(200).json({ @@ -92,46 +102,22 @@ async function createRun(req: Request, res: Response){ // URL (e.g. /w/:runId after a page refresh) — RunProvider's state otherwise // only lives in memory for the current tab. chatRouter.get('/:runId/state', auth, async (req: Request, res: Response) => { - const { runId } = req.params - if(typeof runId !== 'string'){ - return res.status(400).json({success: false, message: `Invalid runId type`}) - } + const params = requireStrings(res, { runId: req.params.runId }, `Invalid runId type`) + if(!params) return; + const { runId } = params const run = await prisma.run.findUnique({where: {id: runId}}) if(!run){ - return res.status(404).json({success: false, message: `Run not found`}) - } - - let pauseEvent: unknown = null - if(run.status === 'CLARIFICATION_NEEDED' || run.status === 'AWAITING_DESIGN_SELECTION'){ - const event = await prisma.runEvent.findFirst({ - where: { runId, type: run.status === 'CLARIFICATION_NEEDED' ? 'clarification_needed' : 'select_design' }, - orderBy: { createdAt: 'desc' }, - }) - pauseEvent = event?.content ? JSON.parse(event.content) : null - } - - let completedEvent: unknown = null - if(run.status === 'COMPLETED'){ - const event = await prisma.runEvent.findFirst({ - where: { runId, type: 'run_completed' }, - orderBy: { createdAt: 'desc' }, - }) - completedEvent = event?.content ? JSON.parse(event.content) : null + return notFound(res, `Run not found`) } - let failedEvent: unknown = null - if(run.status === 'FAILED'){ - const event = await prisma.runEvent.findFirst({ - where: { runId, type: 'run_failed' }, - orderBy: { createdAt: 'desc' }, - }) - failedEvent = event?.content ? JSON.parse(event.content) : null - } + const pauseEvent = run.status === 'CLARIFICATION_NEEDED' || run.status === 'AWAITING_DESIGN_SELECTION' + ? await latestEventContent(runId, run.status === 'CLARIFICATION_NEEDED' ? 'clarification_needed' : 'select_design') + : null + const completedEvent = run.status === 'COMPLETED' ? await latestEventContent(runId, 'run_completed') : null + const failedEvent = run.status === 'FAILED' ? await latestEventContent(runId, 'run_failed') : null - return res.status(200).json({ - success: true, - data: { + return ok(res, { runId: run.id, projectId: run.projectId, userPrompt: run.userPrompt, @@ -139,34 +125,36 @@ chatRouter.get('/:runId/state', auth, async (req: Request, res: Response) => { pauseEvent, completedEvent, failedEvent, - }, }) }) chatRouter.post('/:projectId/:runId/continue', auth, async (req: Request, res: Response) => { - const userId = req.headers.userid - const { projectId, runId } = req.params const answers: Answers[] = req.body?.answers ?? [] const selectedDesignId: string | undefined = req.body?.selectedDesignId - if(typeof userId !== 'string' || typeof projectId !== 'string' || typeof runId !== 'string'){ - return res.status(400).json({success: false, message: `Invalid params`}) - } + const params = requireStrings(res, { + userId: req.headers.userid, + projectId: req.params.projectId, + runId: req.params.runId, + }, `Invalid params`) + if(!params) return; + const { userId, projectId, runId } = params + if(!Array.isArray(answers)){ - return res.status(400).json({success: false, message: `answers must be an array (send [] if none)`}) + return badRequest(res, `answers must be an array (send [] if none)`) } const run = await prisma.run.findFirst({where: {id: runId, projectId}}) if(!run){ - return res.status(404).json({success: false, message: `Run not found`}) + return notFound(res, `Run not found`) } if(run.status !== 'CLARIFICATION_NEEDED' && run.status !== 'AWAITING_DESIGN_SELECTION'){ - return res.status(409).json({success: false, message: `Run ${runId} isn't awaiting input`}) + return conflict(res, `Run ${runId} isn't awaiting input`) } const user = await prisma.user.findUnique({where: {id: userId}}) if(!user){ - return res.status(404).json({success: false, message: `User not found :(`}) + return notFound(res, `User not found :(`) } await prisma.run.update({where: {id: runId}, data: {status: 'IN_PROGRESS'}}) @@ -185,7 +173,7 @@ chatRouter.post('/:projectId/:runId/continue', auth, async (req: Request, res: R logger.info(`Continuing run ${run.id}`) } catch(e){ logger.error(`Failed to re-enqueue run ${run.id}: ${e}`) - return res.status(500).json({success: false, message: `Failed to continue run`}) + return serverError(res, `Failed to continue run`) } return res.status(200).json({ @@ -197,10 +185,9 @@ chatRouter.post('/:projectId/:runId/continue', auth, async (req: Request, res: R // SSE frontend --> Backend chatRouter.get('/:runId/stream', auth, async (req: Request, res: Response) =>{ - const {runId} = req.params - if(typeof runId !== 'string'){ - return res.status(400).json({message: 'runId should be of string type'}) - } + const params = requireStrings(res, {runId: req.params.runId}, 'runId should be of string type') + if(!params) return; + const {runId} = params // Validate + fetch before committing to SSE headers, so a bad/missing // runId (e.g. a stale reconnect after the run was deleted) gets a clean @@ -209,7 +196,7 @@ chatRouter.get('/:runId/stream', auth, async (req: Request, res: Response) =>{ // rejection that takes the whole process down mid-reconnect. const run = await prisma.run.findUnique({where: {id: runId}}) if(!run){ - return res.status(404).json({message: `Run not found`}) + return notFound(res, `Run not found`) } res.setHeader("content-type", "text/event-stream") @@ -249,7 +236,7 @@ chatRouter.get('/:runId/stream', auth, async (req: Request, res: Response) =>{ // which createBackendEmitter hits unconditionally — this redis path is // only live if a browser happens to be connected, so it must not be the // only place the DB gets updated. This just closes the stream. - if(event.type === 'run_completed' || event.type === 'run_failed' || event.type === 'clarification_needed' || event.type === 'select_design'){ + if(isRunSettlingEvent(event)){ res.end() } } @@ -270,18 +257,14 @@ chatRouter.get('/:runId/stream', auth, async (req: Request, res: Response) =>{ chatRouter.get('/:projectId/history', auth, async (req: Request, res: Response) =>{ - const {projectId} = req.params - if(typeof projectId !== 'string'){ - return res.status(400).json({message: `Invalid projectId type`}) - } + const params = requireStrings(res, {projectId: req.params.projectId}, `Invalid projectId type`) + if(!params) return; + const runs = await prisma.run.findMany({ - where: { projectId: projectId }, + where: { projectId: params.projectId }, orderBy: { startedAt: 'desc' }, }) - return res.status(200).json({ - success: true, - data: runs - }) + return ok(res, runs) }) export default chatRouter diff --git a/apps/backend/src/modules/design.ts b/apps/backend/src/modules/design.ts index 51e22cb..92f6182 100644 --- a/apps/backend/src/modules/design.ts +++ b/apps/backend/src/modules/design.ts @@ -5,9 +5,23 @@ import type { Request, Response } from "express"; import { randomUUID } from "bullmq"; import { randomUUIDv5, randomUUIDv7 } from "bun"; import { logger } from "./utils"; +import { created, notFound, ok, requireStrings, serverError } from "./http"; const designRouter = Router(); +// Exactly one design per project may be selected, so selecting always means +// clearing the project's flags first. +async function markDesignSelected(projectId: string, designId: string) { + await prisma.design.updateMany({ + where: { projectId }, + data: { isSelected: false }, + }); + return prisma.design.update({ + where: { id: designId }, + data: { isSelected: true }, + }); +} + /*Routes: GET /projects/:projectId/designs → list Design rows GET /projects/:projectId/designs/selected → the isSelected=true Design @@ -18,15 +32,12 @@ POST /projects/:projectId/assets → upload reference files/ima */ // save all designs to the db designRouter.post("/:projectId", internalAuth, async( req: Request, res: Response) =>{ - const {projectId} = req.params; const {designs} = req.body - if (typeof projectId !== "string") { - return res.status(400).json({ - success: false, - message: "Invalid projectId", - }); - } + const params = requireStrings(res, { projectId: req.params.projectId }); + if (!params) return; + const { projectId } = params; + let result try{ console.log(`Saving designs to the db`) @@ -43,22 +54,17 @@ designRouter.post("/:projectId", internalAuth, async( req: Request, res: Respons )) } catch(e){ logger.error(`Error occurred while saving design ${e}`) - return res.status(500).json({message: `Internal server error`}) + return serverError(res) } // Echo the created rows back (with ids) so callers can hand a design id // to the frontend instead of routing full htmlContent through every hop. - return res.status(201).json({success: true, data: result}) + return created(res, result) }) // get all designs designRouter.get("/:projectId/getDesigns", auth, async (req: Request, res: Response) => { - const projectId = req.params.projectId; - - if (typeof projectId !== "string") { - return res.status(400).json({ - success: false, - message: "Invalid projectId", - }); - } + const params = requireStrings(res, { projectId: req.params.projectId }); + if (!params) return; + const { projectId } = params; try { const designs = await prisma.design.findMany({ @@ -67,28 +73,17 @@ designRouter.get("/:projectId/getDesigns", auth, async (req: Request, res: Respo }, }); logger.info(`Designs are: ${designs}`) - return res.status(200).json({ - success: true, - data: designs, - }); + return ok(res, designs); } catch (e) { - return res.status(500).json({ - success: false, - message: "Internal server error", - }); + return serverError(res); } }); // get selected design designRouter.get("/:projectId/selectedDesign", auth, async (req: Request, res: Response) => { - const projectId = req.params.projectId; - - if (typeof projectId !== "string") { - return res.status(400).json({ - success: false, - message: "Invalid projectId", - }); - } + const params = requireStrings(res, { projectId: req.params.projectId }); + if (!params) return; + const { projectId } = params; try { const design = await prisma.design.findFirst({ @@ -99,37 +94,20 @@ designRouter.get("/:projectId/selectedDesign", auth, async (req: Request, res: R }); if (!design) { - return res.status(404).json({ - success: false, - message: "Selected design not found", - }); + return notFound(res, "Selected design not found"); } - return res.status(200).json({ - success: true, - data: design, - }); + return ok(res, design); } catch (e) { - return res.status(500).json({ - success: false, - message: "Internal server error", - }); + return serverError(res); } }); designRouter.patch("/:projectId/designs/:designId", auth, async (req: Request, res: Response) => { - const { projectId, designId } = req.params; - - if ( - typeof projectId !== "string" || - typeof designId !== "string" - ) { - return res.status(400).json({ - success: false, - message: "Invalid params", - }); - } + const params = requireStrings(res, { projectId: req.params.projectId, designId: req.params.designId }); + if (!params) return; + const { projectId, designId } = params; try { const design = await prisma.design.findFirst({ @@ -140,53 +118,23 @@ designRouter.patch("/:projectId/designs/:designId", auth, async (req: Request, r }); if (!design) { - return res.status(404).json({ - success: false, - message: "Design not found", - }); + return notFound(res, "Design not found"); } - await prisma.design.updateMany({ - where: { - projectId: projectId, - }, - data: { - isSelected: false, - }, - }); - - const selectedDesign = await prisma.design.update({ - where: { - id: designId, - }, - data: { - isSelected: true, - }, - }); + const selectedDesign = await markDesignSelected(projectId, designId); - return res.status(200).json({ - success: true, - message: "Design selected", - data: selectedDesign, - }); + return ok(res, selectedDesign, "Design selected"); } catch (e) { - return res.status(500).json({ - success: false, - message: "Internal server error", - }); + return serverError(res); } }); designRouter.post("/:projectId/selectDesign", auth, async (req: Request, res: Response) => { - const { projectId } = req.params; const { htmlContent } = req.body; - if (typeof projectId !== "string" || typeof htmlContent !== "string") { - return res.status(400).json({ - success: false, - message: "Invalid params", - }); - } + const params = requireStrings(res, { projectId: req.params.projectId, htmlContent }); + if (!params) return; + const { projectId } = params; try { const design = await prisma.design.findFirst({ @@ -197,33 +145,15 @@ designRouter.post("/:projectId/selectDesign", auth, async (req: Request, res: Re }); if (!design) { - return res.status(404).json({ - success: false, - message: "Design not found", - }); + return notFound(res, "Design not found"); } - await prisma.design.updateMany({ - where: { projectId }, - data: { isSelected: false }, - }); + const selectedDesign = await markDesignSelected(projectId, design.id); - const selectedDesign = await prisma.design.update({ - where: { id: design.id }, - data: { isSelected: true }, - }); - - return res.status(200).json({ - success: true, - message: "Design selected", - data: selectedDesign, - }); + return ok(res, selectedDesign, "Design selected"); } catch (e) { logger.error(`Error occurred while selecting design ${e}`) - return res.status(500).json({ - success: false, - message: "Internal server error", - }); + return serverError(res); } }); diff --git a/apps/backend/src/modules/http.ts b/apps/backend/src/modules/http.ts new file mode 100644 index 0000000..7a76d63 --- /dev/null +++ b/apps/backend/src/modules/http.ts @@ -0,0 +1,47 @@ +import type { Response } from "express"; + +/* +Shared JSON envelope + param validation used by every router. Every handler +returns {success, message?, data?}, so the shape lives here instead of being +re-spelled at each call site. +*/ + +export function ok(res: Response, data?: T, message?: string) { + return res.status(200).json({ success: true, ...(message !== undefined ? { message } : {}), ...(data !== undefined ? { data } : {}) }); +} + +export function created(res: Response, data?: T, message?: string) { + return res.status(201).json({ success: true, ...(message !== undefined ? { message } : {}), ...(data !== undefined ? { data } : {}) }); +} + +export function fail(res: Response, status: number, message: string) { + return res.status(status).json({ success: false, message }); +} + +export const badRequest = (res: Response, message = "Invalid params") => fail(res, 400, message); +export const unauthorized = (res: Response, message = "Unauthorized") => fail(res, 401, message); +export const forbidden = (res: Response, message = "Forbidden") => fail(res, 403, message); +export const notFound = (res: Response, message = "Not found") => fail(res, 404, message); +export const conflict = (res: Response, message: string) => fail(res, 409, message); +export const serverError = (res: Response, message = "Internal server error") => fail(res, 500, message); + +/* +Validates that every value is a string and hands back the narrowed record, or +responds 400 and returns null: + + const p = requireStrings(res, { projectId, runId }); + if (!p) return; + // p.projectId / p.runId are strings here +*/ +export function requireStrings( + res: Response, + values: Record, + message?: string, +): Record | null { + const invalid = (Object.keys(values) as K[]).filter((key) => typeof values[key] !== "string"); + if (invalid.length > 0) { + badRequest(res, message ?? (invalid.length === 1 ? `Invalid ${invalid[0]}` : "Invalid params")); + return null; + } + return values as Record; +} diff --git a/apps/backend/src/modules/project.ts b/apps/backend/src/modules/project.ts index 20d22db..0eb2968 100644 --- a/apps/backend/src/modules/project.ts +++ b/apps/backend/src/modules/project.ts @@ -5,6 +5,7 @@ import type { Request, Response } from "express"; import { randomUUID } from "node:crypto"; import { R2 } from "../../../../packages/agents/agent/services/file-storage/fileStorage"; import { logger } from "./utils"; +import { created, forbidden, notFound, ok, requireStrings, serverError, unauthorized } from "./http"; /*Routes: GET /projects → list projects for authed user POST /projects → create project @@ -19,16 +20,14 @@ const r2 = new R2(); projectRouter.get("/", auth, async (req: AuthRequest, res: Response) => { const userId = req.user.id if(!userId){ - - res.status(401).json({success: false, message: `UserId not given`}) + return unauthorized(res, `UserId not given`) } - await prisma.project.findMany({where: {userId: userId}}) const projects = await prisma.project.findMany({where: {userId: userId}}) if(!projects){ - res.status(404).json({success: false, message: `Projects not found`}) + return notFound(res, `Projects not found`) } - res.status(200).json({success: true, data: projects}) + return ok(res, projects) }); projectRouter.post("/", async (req: Request, res: Response) => { @@ -49,31 +48,22 @@ projectRouter.post("/", async (req: Request, res: Response) => { } }) if(!saveIntoDB){ - return res.status(500).json({success: false, message: `Failed to save into db`}) + return serverError(res, `Failed to save into db`) } - return res.status(201).json({success: true, message: `project created`, data: saveIntoDB}) + return created(res, saveIntoDB, `project created`) }catch(e){ - return res.json(500).json({message: `Internal server error`}) + return serverError(res) } }) projectRouter.get("/:projectId", auth, async (req: Request, res: Response) => { - const projectId = req.params.projectId - if(!projectId){ - - res.status(401).json({success: false, message: `UserId not given`}) - } - if (typeof projectId !== "string") { - return res.status(400).json({ - success: false, - message: "Invalid projectId", - }); - } - const projects = await prisma.project.findUniqueOrThrow({where: {id: projectId}}) + const params = requireStrings(res, { projectId: req.params.projectId }) + if (!params) return; + + const projects = await prisma.project.findUniqueOrThrow({where: {id: params.projectId}}) - res.status(200).json({success: true, data: projects}) + return ok(res, projects) }); projectRouter.patch('/:projectId', auth, async (req: Request, res: Response) =>{ - const projectId = req.params.projectId const { name, archived, starred, isComplex } = req.body; const data: { @@ -99,62 +89,51 @@ projectRouter.patch('/:projectId', auth, async (req: Request, res: Response) =>{ data.isComplex = isComplex; } - if (typeof projectId !== "string") { - return res.status(400).json({ - success: false, - message: "Invalid projectId", - }); - } + const params = requireStrings(res, { projectId: req.params.projectId }) + if (!params) return; + const { projectId } = params + try{ const project = await prisma.project.findUniqueOrThrow({where: {id: projectId}}) if(!project){ - return res.status(404).json({message: `Project not found`}) + return notFound(res, `Project not found`) } const dbUpdate = await prisma.project.update({where: {id: projectId}, data: data}) - return res.status(200).json({success: true, data: dbUpdate}) + return ok(res, dbUpdate) } catch(e){ - return res.status(500).json({message: `Internal Server error`}) + return serverError(res) } }) projectRouter.delete('/:projectId', auth, async (req: Request, res: Response) =>{ - const projectId = req.params.projectId - if(!projectId){ - - res.status(401).json({success: false, message: `UserId not given`}) - } - if (typeof projectId !== "string") { - return res.status(400).json({ - success: false, - message: "Invalid projectId", - }); - } - const dbUpdate = await prisma.project.delete({where: {id: projectId}}) + const params = requireStrings(res, { projectId: req.params.projectId }) + if (!params) return; + + const dbUpdate = await prisma.project.delete({where: {id: params.projectId}}) if(!dbUpdate){ - return res.send(500).json({success: false, message: `Failed to update DB`}) + return serverError(res, `Failed to update DB`) } - res.status(200).json({success: true}) + return ok(res) }); // Files are synced sandbox -> R2 by E2BSandbox.SyncR2() during the run, so this // reads the durable copy rather than reconnecting to a possibly-dead sandbox. projectRouter.get("/:projectId/files", auth, async (req: AuthRequest, res: Response) => { - const { projectId } = req.params; const userId = req.user.id; - if (typeof projectId !== "string") { - return res.status(400).json({ success: false, message: "Invalid projectId" }); - } + const params = requireStrings(res, { projectId: req.params.projectId }); + if (!params) return; + const { projectId } = params; try { const project = await prisma.project.findUnique({ where: { id: projectId } }); if (!project) { - return res.status(404).json({ success: false, message: "Project not found" }); + return notFound(res, "Project not found"); } if (project.userId !== userId) { - return res.status(403).json({ success: false, message: "Not your project" }); + return forbidden(res, "Not your project"); } const prefix = r2.filesPrefix(project.userId, projectId); @@ -172,10 +151,10 @@ projectRouter.get("/:projectId/files", auth, async (req: AuthRequest, res: Respo files.push(...batchFiles); } - return res.status(200).json({ success: true, data: files }); + return ok(res, files); } catch (e) { logger.error(`Failed to list files for project ${projectId}: ${e}`); - return res.status(500).json({ success: false, message: "Internal server error" }); + return serverError(res); } }); diff --git a/apps/backend/src/modules/question.ts b/apps/backend/src/modules/question.ts index 9f07038..e3d6c59 100644 --- a/apps/backend/src/modules/question.ts +++ b/apps/backend/src/modules/question.ts @@ -7,41 +7,35 @@ import { auth, internalAuth } from "./middleware"; import type { Request, Response } from "express"; import { randomUUIDv7 } from "bun"; import { logger } from "./utils"; +import { badRequest, created, ok, requireStrings, serverError } from "./http"; type IncomingQuestion = {question: string, option: string[]} export const questionRouter = Router(); questionRouter.get('/:projectId/getQuestions', auth, async (req: Request, res: Response) =>{ - const projectId = req.params.projectId - if(typeof projectId !== 'string'){ - return res.status(400).json({ - success: false, - message: "Invalid projectId", - }); - } + const params = requireStrings(res, { projectId: req.params.projectId }) + if(!params) return; + const { projectId } = params try{ logger.info(`Fetching questions`) const questions = await prisma.question.findMany({where: {projectId, clarification: null}}) - return res.status(200).json({success: true, data: questions}) + return ok(res, questions) } catch(e){ - return res.status(500).json({ - success: false, - message: "Internal server error", - }); + return serverError(res) } }) questionRouter.post('/:projectId/:runId', internalAuth, async (req: Request, res: Response) =>{ - const {projectId, runId} = req.params const {questionsObj} = req.body - if(typeof projectId !== 'string' || typeof runId !== 'string'){ - return res.status(400).json({success: false, message: `Bad types of the params`}) - } + const params = requireStrings(res, {projectId: req.params.projectId, runId: req.params.runId}, `Bad types of the params`) + if(!params) return; + const {projectId, runId} = params + const result = await Promise.all( questionsObj.map((question: IncomingQuestion) => prisma.question.create({ @@ -56,21 +50,18 @@ questionRouter.post('/:projectId/:runId', internalAuth, async (req: Request, res }) ) ); - return res.status(201).json({ - success: true, - data: result - }); + return created(res, result); }) questionRouter.post('/:projectId/:runId/answers', auth, async (req: Request, res: Response) =>{ - const {projectId, runId} = req.params const {answers} = req.body as {answers: {questionId: string, answer: string}[]} - if(typeof projectId !== 'string' || typeof runId !== 'string'){ - return res.status(400).json({success: false, message: `Bad types of the params`}) - } + const params = requireStrings(res, {projectId: req.params.projectId, runId: req.params.runId}, `Bad types of the params`) + if(!params) return; + const {projectId, runId} = params + if(!Array.isArray(answers) || answers.length === 0){ - return res.status(400).json({success: false, message: `answers must be a non-empty array`}) + return badRequest(res, `answers must be a non-empty array`) } try{ @@ -97,8 +88,8 @@ questionRouter.post('/:projectId/:runId/answers', auth, async (req: Request, res })) } catch(e){ logger.error(`Error occurred while saving answers ${e}`) - return res.status(500).json({success: false, message: `Internal server error`}) + return serverError(res) } - return res.status(201).json({success: true}) + return created(res) }) \ No newline at end of file diff --git a/apps/backend/src/modules/run.ts b/apps/backend/src/modules/run.ts index e65fd3a..c27bcd0 100644 --- a/apps/backend/src/modules/run.ts +++ b/apps/backend/src/modules/run.ts @@ -4,6 +4,7 @@ import { auth, internalAuth } from "./middleware"; import type { Request, Response } from "express"; import { randomUUIDv7 } from "bun"; import { logger } from "./utils"; +import { badRequest, created, notFound, ok, requireStrings, serverError } from "./http"; import type { AgentType } from "../../generated/prisma/enums"; const runRouter = Router(); @@ -19,19 +20,13 @@ POST /projects/:projectId/:runId/todos/:taskId/summary → mark a Todo complet */ runRouter.get("/:projectId/runs", auth, async (req: Request, res: Response) => { - const projectId = req.params.projectId; - - if (typeof projectId !== "string") { - return res.status(400).json({ - success: false, - message: "Invalid projectId", - }); - } + const params = requireStrings(res, { projectId: req.params.projectId }); + if (!params) return; try { const runs = await prisma.run.findMany({ where: { - projectId: projectId, + projectId: params.projectId, }, select: { id: true, @@ -44,31 +39,17 @@ runRouter.get("/:projectId/runs", auth, async (req: Request, res: Response) => { }, }); - return res.status(200).json({ - success: true, - data: runs, - }); + return ok(res, runs); } catch (e) { - return res.status(500).json({ - success: false, - message: "Internal server error", - }); + return serverError(res); } }); runRouter.get("/:projectId/runs/:runId", auth, async (req: Request, res: Response) => { - const { projectId, runId } = req.params; - - if ( - typeof projectId !== "string" || - typeof runId !== "string" - ) { - return res.status(400).json({ - success: false, - message: "Invalid params", - }); - } + const params = requireStrings(res, { projectId: req.params.projectId, runId: req.params.runId }); + if (!params) return; + const { projectId, runId } = params; try { const run = await prisma.run.findFirst({ @@ -79,36 +60,20 @@ runRouter.get("/:projectId/runs/:runId", auth, async (req: Request, res: Respons }); if (!run) { - return res.status(404).json({ - success: false, - message: "Run not found", - }); + return notFound(res, "Run not found"); } - return res.status(200).json({ - success: true, - data: run, - }); + return ok(res, run); } catch (e) { - return res.status(500).json({ - success: false, - message: "Internal server error", - }); + return serverError(res); } }); runRouter.get("/:projectId/:runId/todos", auth, async (req: Request, res: Response) => { - const { projectId, runId } = req.params; - if ( - typeof projectId !== "string" || - typeof runId !== "string" - ) { - return res.status(400).json({ - success: false, - message: "Invalid params", - }); - } + const params = requireStrings(res, { projectId: req.params.projectId, runId: req.params.runId }); + if (!params) return; + const { projectId, runId } = params; try { const run = await prisma.run.findFirst({ @@ -119,10 +84,7 @@ runRouter.get("/:projectId/:runId/todos", auth, async (req: Request, res: Respon }); if (!run) { - return res.status(404).json({ - success: false, - message: "Run not found", - }); + return notFound(res, "Run not found"); } const todos = await prisma.todo.findMany({ @@ -137,31 +99,17 @@ runRouter.get("/:projectId/:runId/todos", auth, async (req: Request, res: Respon }, }); - return res.status(200).json({ - success: true, - data: todos, - }); + return ok(res, todos); } catch (e) { - return res.status(500).json({ - success: false, - message: "Internal server error", - }); + return serverError(res); } }); runRouter.get("/:projectId/:runId/summaries", auth, async (req: Request, res: Response) => { - const { projectId, runId } = req.params; - - if ( - typeof projectId !== "string" || - typeof runId !== "string" - ) { - return res.status(400).json({ - success: false, - message: "Invalid params", - }); - } + const params = requireStrings(res, { projectId: req.params.projectId, runId: req.params.runId }); + if (!params) return; + const { projectId, runId } = params; try { const run = await prisma.run.findFirst({ @@ -172,10 +120,7 @@ runRouter.get("/:projectId/:runId/summaries", auth, async (req: Request, res: Re }); if (!run) { - return res.status(404).json({ - success: false, - message: "Run not found", - }); + return notFound(res, "Run not found"); } const summaries = await prisma.taskSummary.findMany({ @@ -199,15 +144,9 @@ runRouter.get("/:projectId/:runId/summaries", auth, async (req: Request, res: Re }, }); - return res.status(200).json({ - success: true, - data: summaries, - }); + return ok(res, summaries); } catch (e) { - return res.status(500).json({ - success: false, - message: "Internal server error", - }); + return serverError(res); } }); @@ -215,28 +154,22 @@ runRouter.get("/:projectId/:runId/summaries", auth, async (req: Request, res: Re runRouter.post("/:projectId/:runId/todos", internalAuth, async (req: Request, res: Response) => { logger.info(`Saving todos to the db`) - const { projectId, runId } = req.params; const { todos } = req.body as { todos: {id: number, task: string, agent: AgentType, status: "pending" | "completed", dependency: number[], designNeeded?: boolean}[] }; - if (typeof projectId !== "string" || typeof runId !== "string") { - return res.status(400).json({ - success: false, - message: "Invalid params", - }); - } + const params = requireStrings(res, { projectId: req.params.projectId, runId: req.params.runId }); + if (!params) return; + const { runId } = params; + if (!Array.isArray(todos) || todos.length === 0) { - return res.status(400).json({ - success: false, - message: "todos must be a non-empty array", - }); + return badRequest(res, "todos must be a non-empty array"); } try { logger.info(`calling promises to save todos`) - const created = await Promise.all(todos.map((t) => + const createdTodos = await Promise.all(todos.map((t) => prisma.todo.create({ data: { id: randomUUIDv7(), @@ -251,35 +184,27 @@ runRouter.post("/:projectId/:runId/todos", internalAuth, async (req: Request, re }) )); - return res.status(201).json({ - success: true, - data: created, - }); + return created(res, createdTodos); } catch (e) { logger.error(`Error occurred while saving todos ${e}`); - return res.status(500).json({ - success: false, - message: "Internal server error", - }); + return serverError(res); } }); runRouter.post("/:projectId/:runId/todos/:taskId/summary", internalAuth, async (req: Request, res: Response) => { - const { projectId, runId, taskId } = req.params; const { summary } = req.body as { summary: string }; - if (typeof projectId !== "string" || typeof runId !== "string" || typeof taskId !== "string") { - return res.status(400).json({ - success: false, - message: "Invalid params", - }); - } + const params = requireStrings(res, { + projectId: req.params.projectId, + runId: req.params.runId, + taskId: req.params.taskId, + }); + if (!params) return; + const { runId, taskId } = params; + if (typeof summary !== "string") { - return res.status(400).json({ - success: false, - message: "summary must be a string", - }); + return badRequest(res, "summary must be a string"); } try { @@ -287,10 +212,7 @@ runRouter.post("/:projectId/:runId/todos/:taskId/summary", internalAuth, async ( where: { runId, taskId: Number(taskId) }, }); if (!todo) { - return res.status(404).json({ - success: false, - message: `Todo ${taskId} not found for run ${runId}`, - }); + return notFound(res, `Todo ${taskId} not found for run ${runId}`); } await prisma.todo.update({ @@ -303,17 +225,11 @@ runRouter.post("/:projectId/:runId/todos/:taskId/summary", internalAuth, async ( update: { summary }, }); - return res.status(201).json({ - success: true, - data: saved, - }); + return created(res, saved); } catch (e) { logger.error(`Error occurred while saving task summary ${e}`); - return res.status(500).json({ - success: false, - message: "Internal server error", - }); + return serverError(res); } }); -export default runRouter; \ No newline at end of file +export default runRouter; diff --git a/apps/backend/src/modules/sessions.ts b/apps/backend/src/modules/sessions.ts index 609d051..52f245d 100644 --- a/apps/backend/src/modules/sessions.ts +++ b/apps/backend/src/modules/sessions.ts @@ -2,8 +2,9 @@ import { Router, type Request, type Response } from "express"; import { internalAuth } from "./middleware"; import { prisma } from "../prisma"; import { randomUUIDv7 } from "bun"; -import type { OrchestratorEvent } from "../../../../packages/agents"; +import { isRunSettlingEvent, type OrchestratorEvent } from "../../../../packages/agents"; import { logger } from "./utils"; +import { ok, requireStrings, serverError } from "./http"; /* POST /internal/sessions/:runId/events @@ -13,12 +14,11 @@ const sessionRouter = Router(); sessionRouter.post('/:runId/events', internalAuth, async (req: Request, res: Response) =>{ - const {runId} = req.params const event: OrchestratorEvent = req.body - if(typeof runId !== 'string'){ - return res.status(400).json({success: false, message: `Invalid runId type`}) - } + const params = requireStrings(res, {runId: req.params.runId}, `Invalid runId type`) + if(!params) return; + const {runId} = params try{ await prisma.runEvent.create({data: { @@ -33,7 +33,7 @@ sessionRouter.post('/:runId/events', internalAuth, async (req: Request, res: Res // by createBackendEmitter, unlike the redis pub/sub path in chat.ts's SSE // handler, which only updates status if a browser happens to be // connected at the exact moment the event is published. - if(event.type === 'run_completed' || event.type === 'run_failed' || event.type === 'clarification_needed' || event.type === 'select_design'){ + if(isRunSettlingEvent(event)){ const status = event.type === 'clarification_needed' ? 'CLARIFICATION_NEEDED' : event.type === 'select_design' ? 'AWAITING_DESIGN_SELECTION' : @@ -44,10 +44,10 @@ sessionRouter.post('/:runId/events', internalAuth, async (req: Request, res: Res }) } - return res.status(200).json({success: true, message: `event saved`}) + return ok(res, undefined, `event saved`) } catch(e){ logger.error(`Failed to save event for run ${runId}: ${e}`) - return res.status(500).json({success: false, message: `Internal server error`}) + return serverError(res) } }) @@ -57,14 +57,13 @@ sessionRouter.post('/:runId/events', internalAuth, async (req: Request, res: Res // must arrive JSON.stringify'd; we don't re-stringify here since that'd // double-encode whatever the caller already sent. sessionRouter.post('/:runId/state', internalAuth, async (req: Request, res: Response) =>{ - const {runId} = req.params const {context_snapshot, session_snapshot, iteration} = req.body as { context_snapshot?: string, session_snapshot?: string, iteration?: number } - if(typeof runId !== 'string'){ - return res.status(400).json({success: false, message: `Invalid runId type`}) - } + const params = requireStrings(res, {runId: req.params.runId}, `Invalid runId type`) + if(!params) return; + const {runId} = params try{ await prisma.run.update({ @@ -75,10 +74,10 @@ sessionRouter.post('/:runId/state', internalAuth, async (req: Request, res: Resp currentStep: iteration !== undefined ? String(iteration) : undefined, } }) - return res.status(200).json({success: true, message: `session and context state saved`}) + return ok(res, undefined, `session and context state saved`) } catch(e){ logger.error(`Failed to save session state for run ${runId}: ${e}`) - return res.status(500).json({success: false, message: `Internal server error`}) + return serverError(res) } }) diff --git a/apps/backend/src/modules/user.ts b/apps/backend/src/modules/user.ts index e3bdfdc..48af777 100644 --- a/apps/backend/src/modules/user.ts +++ b/apps/backend/src/modules/user.ts @@ -5,6 +5,7 @@ import { auth, type AuthRequest } from "./middleware"; import {isValidEmail, isValidPassword, signUserToken,toPublicUser} from "./user.helpers"; import { verifyGoogleIdToken } from "./google"; import { logger } from "./utils"; +import { badRequest, conflict, created, notFound, ok, serverError, unauthorized } from "./http"; /*Routes: POST /users/signup → email/password signup POST /users/login → email/password login @@ -21,21 +22,18 @@ userRouter.post("/signup", async (req: Request, res: Response) => { if (!isValidEmail(email)) { console.error(`Invalid email: ${email}`); - return res.status(400).json({ success: false, message: "Invalid email" }); + return badRequest(res, "Invalid email"); } if (!isValidPassword(password)) { logger.error(`Password is not valid: ${password}`); - return res.status(400).json({ - success: false, - message: "Password must be at least 8 characters", - }); + return badRequest(res, "Password must be at least 8 characters"); } try { const existing = await prisma.user.findUnique({ where: { email } }); if (existing) { console.error(`Email already registered: ${email}`); - return res.status(409).json({ success: false, message: "Email already registered" }); + return conflict(res, "Email already registered"); } const passwordHash = await Bun.password.hash(password); const user = await prisma.user.create({ @@ -48,9 +46,9 @@ userRouter.post("/signup", async (req: Request, res: Response) => { const token = signUserToken(user); console.log("Token created"); - return res.status(201).json({ success: true, data: { token, user: toPublicUser(user) } }); + return created(res, { token, user: toPublicUser(user) }); } catch (e) { - return res.status(500).json({ success: false, message: `Internal server error: ${e}` }); + return serverError(res, `Internal server error: ${e}`); } }); @@ -58,34 +56,24 @@ userRouter.post("/login", async (req: Request, res: Response) => { const { email, password } = req.body ?? {}; if (!isValidEmail(email) || typeof password !== "string") { - return res - .status(400) - .json({ success: false, message: "Invalid email or password" }); + return badRequest(res, "Invalid email or password"); } try { const user = await prisma.user.findUnique({ where: { email } }); if (!user || !user.password) { - return res - .status(401) - .json({ success: false, message: "Invalid email or password" }); + return unauthorized(res, "Invalid email or password"); } const valid = await Bun.password.verify(password, user.password); if (!valid) { - return res - .status(401) - .json({ success: false, message: "Invalid email or password" }); + return unauthorized(res, "Invalid email or password"); } const token = signUserToken(user); - return res - .status(200) - .json({ success: true, data: { token, user: toPublicUser(user) } }); + return ok(res, { token, user: toPublicUser(user) }); } catch (e) { - return res - .status(500) - .json({ success: false, message: "Internal server error" }); + return serverError(res); } }); @@ -93,9 +81,7 @@ userRouter.post("/google", async (req: Request, res: Response) => { const { idToken } = req.body ?? {}; if (typeof idToken !== "string" || !idToken) { - return res - .status(400) - .json({ success: false, message: "Missing idToken" }); + return badRequest(res, "Missing idToken"); } try { @@ -128,14 +114,10 @@ userRouter.post("/google", async (req: Request, res: Response) => { } const token = signUserToken(user); - return res - .status(200) - .json({ success: true, data: { token, user: toPublicUser(user) } }); + return ok(res, { token, user: toPublicUser(user) }); } catch (e) { console.error("Google sign-in failed:", e); - return res - .status(401) - .json({ success: false, message: "Invalid Google token" }); + return unauthorized(res, "Invalid Google token"); } }); @@ -147,23 +129,19 @@ userRouter.get("/me", auth, async (req: AuthRequest, res: Response) => { where: { id: req.user!.id }, }); if (!user) { - return res - .status(404) - .json({ success: false, message: "User not found" }); + return notFound(res, "User not found"); } console.log("User found"); console.log(toPublicUser(user)); - return res.status(200).json({ success: true, data: toPublicUser(user) }); + return ok(res, toPublicUser(user)); } catch (e) { console.error(`Error getting user: ${e}`); - return res - .status(500) - .json({ success: false, message: "Internal server error" }); + return serverError(res); } }); userRouter.post( "/logout",auth, async (req: AuthRequest, res: Response) => { - return res.status(200).json({ success: true, message: "Logged out" }); + return ok(res, undefined, "Logged out"); } ); diff --git a/packages/agents/agent/events.ts b/packages/agents/agent/events.ts index 9f6533c..3f3f033 100644 --- a/packages/agents/agent/events.ts +++ b/packages/agents/agent/events.ts @@ -17,6 +17,15 @@ export type OrchestratorEvent = MainAgentEvents | type MainAgentEvents = | {type : 'main_agent_success'} | {type: 'main_agent_tool_call', step: number, toolName: string} +// Events that settle a run: after one of these the run is no longer +// in progress (it either finished or is waiting on the user), so both the +// durable status write and the SSE stream key off the same list. +const RUN_SETTLING_EVENTS = ['run_completed', 'run_failed', 'clarification_needed', 'select_design'] as const + +export function isRunSettlingEvent(event: OrchestratorEvent): event is Extract { + return (RUN_SETTLING_EVENTS as readonly string[]).includes(event.type) +} + export interface EventEmitter { emit(event: OrchestratorEvent): Promise; } diff --git a/packages/agents/agent/subagents/coder.ts b/packages/agents/agent/subagents/coder.ts index c53e5cf..209426f 100644 --- a/packages/agents/agent/subagents/coder.ts +++ b/packages/agents/agent/subagents/coder.ts @@ -2,8 +2,8 @@ import { CODER_PROMPT } from "../config/sysPrompts"; import {b, type Abort, type CoderContext, type DeleteFile, type Done, type EditFile, type FetchDocs, type GetSkill, type Message, type ReadFile, type Research, type ResearcherResponse, type RunCommand, type ToolResult, type WriteFile} from '../../baml_client' import { Researcher } from "./researcher"; import { E2BSandbox } from "../utils/sandbox"; -import { fetchDocs } from "../MCPs/context7"; import { BaseAgent } from "./baseAgent"; +import { runResearch } from "../utils/research"; import type { CoderTaskInput } from "../../types/subAgentsTypes"; import { SkillStore } from "../skills"; @@ -49,19 +49,7 @@ export class CoderAgent extends BaseAgent { + if (searchType.type === 'webSearch') { + return await researcher.WebSearch(searchType.query, searchType.maxResults) + } + if (searchType.type === 'webScrape') { + return await researcher.WebScrape(searchType.urls, searchType.maxPages) + } + if (searchType.type === 'docsSearch') { + return await fetchDocs(searchType.library, searchType.query) + } + throw new Error("Invalid research type") +}