import { validateTeamName } from '@main/ipc/guards'; import { getErrorMessage } from '@shared/utils/errorHandling'; import { createLogger } from '@shared/utils/logger'; import { isAbsolute } from 'path'; import type { HttpServices } from './index'; import type { EffortLevel, TeamLaunchRequest } from '@shared/types/team'; import type { FastifyInstance } from 'fastify'; const logger = createLogger('HTTP:teams'); type LaunchBody = Omit; const EFFORT_LEVELS = new Set(['low', 'medium', 'high']); class HttpBadRequestError extends Error {} class HttpFeatureUnavailableError extends Error {} function getTeamProvisioningService(services: HttpServices) { if (!services.teamProvisioningService) { throw new HttpFeatureUnavailableError('Team runtime control is not available in this mode'); } return services.teamProvisioningService; } function getStatusCode(error: unknown, fallback: number = 500): number { if (error instanceof HttpBadRequestError) { return 400; } if (error instanceof HttpFeatureUnavailableError) { return 501; } return fallback; } function shouldLogError(error: unknown): boolean { return !(error instanceof HttpBadRequestError) && !(error instanceof HttpFeatureUnavailableError); } function assertAbsoluteCwd(cwd: unknown): string { if (typeof cwd !== 'string' || cwd.trim().length === 0) { throw new HttpBadRequestError('cwd must be a non-empty string'); } const normalized = cwd.trim(); if (!isAbsolute(normalized)) { throw new HttpBadRequestError('cwd must be an absolute path'); } return normalized; } function assertOptionalString(value: unknown, fieldName: string): string | undefined { if (value == null) { return undefined; } if (typeof value !== 'string') { throw new HttpBadRequestError(`${fieldName} must be a string`); } const normalized = value.trim(); return normalized.length > 0 ? normalized : undefined; } function assertOptionalBoolean(value: unknown, fieldName: string): boolean | undefined { if (value == null) { return undefined; } if (typeof value !== 'boolean') { throw new HttpBadRequestError(`${fieldName} must be a boolean`); } return value; } function assertOptionalEffort(value: unknown): EffortLevel | undefined { if (value == null) { return undefined; } if (typeof value !== 'string' || !EFFORT_LEVELS.has(value as EffortLevel)) { throw new HttpBadRequestError('effort must be one of: low, medium, high'); } return value as EffortLevel; } function parseLaunchRequest(teamName: string, body: unknown): TeamLaunchRequest { const payload = body && typeof body === 'object' ? (body as Record) : {}; const prompt = assertOptionalString(payload.prompt, 'prompt'); const model = assertOptionalString(payload.model, 'model'); const effort = assertOptionalEffort(payload.effort); const clearContext = assertOptionalBoolean(payload.clearContext, 'clearContext'); const skipPermissions = assertOptionalBoolean(payload.skipPermissions, 'skipPermissions'); const worktree = assertOptionalString(payload.worktree, 'worktree'); const extraCliArgs = assertOptionalString(payload.extraCliArgs, 'extraCliArgs'); return { teamName, cwd: assertAbsoluteCwd(payload.cwd), ...(prompt && { prompt, }), ...(model && { model, }), ...(effort && { effort, }), ...(clearContext !== undefined && { clearContext, }), ...(skipPermissions !== undefined && { skipPermissions, }), ...(worktree && { worktree, }), ...(extraCliArgs && { extraCliArgs, }), }; } export function registerTeamRoutes(app: FastifyInstance, services: HttpServices): void { app.post<{ Params: { teamName: string }; Body: LaunchBody }>( '/api/teams/:teamName/launch', async (request, reply) => { try { const validatedTeamName = validateTeamName(request.params.teamName); if (!validatedTeamName.valid) { return reply.status(400).send({ error: validatedTeamName.error }); } const launchRequest = parseLaunchRequest(validatedTeamName.value!, request.body); const response = await getTeamProvisioningService(services).launchTeam( launchRequest, () => undefined ); return reply.send(response); } catch (error) { const statusCode = getStatusCode(error); if (shouldLogError(error)) { logger.error( `Error in POST /api/teams/${request.params.teamName}/launch:`, getErrorMessage(error) ); } return reply.status(statusCode).send({ error: getErrorMessage(error) }); } } ); app.post<{ Params: { teamName: string } }>( '/api/teams/:teamName/stop', async (request, reply) => { try { const validatedTeamName = validateTeamName(request.params.teamName); if (!validatedTeamName.valid) { return reply.status(400).send({ error: validatedTeamName.error }); } const teamProvisioningService = getTeamProvisioningService(services); teamProvisioningService.stopTeam(validatedTeamName.value!); return reply.send(teamProvisioningService.getRuntimeState(validatedTeamName.value!)); } catch (error) { if (shouldLogError(error)) { logger.error( `Error in POST /api/teams/${request.params.teamName}/stop:`, getErrorMessage(error) ); } return reply.status(getStatusCode(error)).send({ error: getErrorMessage(error) }); } } ); app.get<{ Params: { teamName: string } }>( '/api/teams/:teamName/runtime', async (request, reply) => { try { const validatedTeamName = validateTeamName(request.params.teamName); if (!validatedTeamName.valid) { return reply.status(400).send({ error: validatedTeamName.error }); } return reply.send( getTeamProvisioningService(services).getRuntimeState(validatedTeamName.value!) ); } catch (error) { if (shouldLogError(error)) { logger.error( `Error in GET /api/teams/${request.params.teamName}/runtime:`, getErrorMessage(error) ); } return reply.status(getStatusCode(error)).send({ error: getErrorMessage(error) }); } } ); app.get<{ Params: { runId: string } }>( '/api/teams/provisioning/:runId', async (request, reply) => { try { const runId = request.params.runId?.trim(); if (!runId) { return reply.status(400).send({ error: 'runId is required' }); } return reply.send(await getTeamProvisioningService(services).getProvisioningStatus(runId)); } catch (error) { const message = getErrorMessage(error); const statusCode = message === 'Unknown runId' ? 404 : getStatusCode(error); if (shouldLogError(error) && statusCode !== 404) { logger.error(`Error in GET /api/teams/provisioning/${request.params.runId}:`, message); } return reply.status(statusCode).send({ error: message }); } } ); app.get('/api/teams/runtime/alive', async (_request, reply) => { try { const teamProvisioningService = getTeamProvisioningService(services); const runtimeStates = teamProvisioningService .getAliveTeams() .map((teamName) => teamProvisioningService.getRuntimeState(teamName)); return reply.send(runtimeStates); } catch (error) { if (shouldLogError(error)) { logger.error('Error in GET /api/teams/runtime/alive:', getErrorMessage(error)); } return reply.status(getStatusCode(error)).send({ error: getErrorMessage(error) }); } }); }