- Introduced a new method to resolve multiple control base URLs, allowing for better handling of API requests. - Implemented retryable error handling for control API requests, improving robustness against transient failures. - Updated provisioning and runtime state retrieval functions to utilize the new fallback logic. - Enhanced tests to validate the new behavior, ensuring proper functionality under various scenarios. - Added utility functions for managing retryable errors and control API state, improving code clarity and maintainability.
239 lines
7.7 KiB
TypeScript
239 lines
7.7 KiB
TypeScript
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<TeamLaunchRequest, 'teamName'>;
|
|
|
|
const EFFORT_LEVELS = new Set<EffortLevel>(['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<string, unknown>) : {};
|
|
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) });
|
|
}
|
|
});
|
|
}
|