188 lines
6.0 KiB
TypeScript
188 lines
6.0 KiB
TypeScript
import { createServer } from 'node:http';
|
|
import type { IncomingMessage, ServerResponse } from 'node:http';
|
|
|
|
import type { Id } from '@spoon/backend/convex/_generated/dataModel.js';
|
|
|
|
import { env } from './env';
|
|
import {
|
|
abortWorkspaceAgent,
|
|
cleanupOrphanedWorkspaces,
|
|
getWorkerHealth,
|
|
getWorkspaceAgentStatus,
|
|
getWorkspaceDiff,
|
|
listWorkspaceTree,
|
|
openWorkspacePullRequest,
|
|
readWorkspaceFile,
|
|
replyToInteraction,
|
|
runWorkspaceCommand,
|
|
sendWorkspaceMessage,
|
|
stopWorkspace,
|
|
writeWorkspaceFile,
|
|
} from './worker';
|
|
|
|
const sendJson = (response: ServerResponse, status: number, body: unknown) => {
|
|
response.writeHead(status, { 'content-type': 'application/json' });
|
|
response.end(JSON.stringify(body));
|
|
};
|
|
|
|
const readBody = async (request: IncomingMessage) =>
|
|
await new Promise<string>((resolve, reject) => {
|
|
let body = '';
|
|
request.on('data', (chunk: Buffer) => {
|
|
body += chunk.toString('utf8');
|
|
});
|
|
request.on('end', () => resolve(body));
|
|
request.on('error', reject);
|
|
});
|
|
|
|
const parseJson = async <T>(request: IncomingMessage) => {
|
|
const body = await readBody(request);
|
|
if (!body.trim()) return {} as T;
|
|
return JSON.parse(body) as T;
|
|
};
|
|
|
|
const requireAuth = (request: IncomingMessage) => {
|
|
const header = request.headers.authorization;
|
|
const token = header?.startsWith('Bearer ') ? header.slice(7) : '';
|
|
if (!env.internalToken || token !== env.internalToken) {
|
|
throw new Error('Unauthorized');
|
|
}
|
|
};
|
|
|
|
const jobRoute = (pathname: string) => {
|
|
const match = /^\/jobs\/([^/]+)\/(.+)$/.exec(pathname);
|
|
if (!match?.[1] || !match[2]) return null;
|
|
return { jobId: decodeURIComponent(match[1]), action: match[2] };
|
|
};
|
|
|
|
export const startWorkerServer = () => {
|
|
const server = createServer((request, response) => {
|
|
void (async () => {
|
|
try {
|
|
requireAuth(request);
|
|
const url = new URL(
|
|
request.url ?? '/',
|
|
`http://localhost:${env.httpPort}`,
|
|
);
|
|
if (url.pathname === '/health' && request.method === 'GET') {
|
|
sendJson(response, 200, await getWorkerHealth());
|
|
return;
|
|
}
|
|
if (url.pathname === '/cleanup' && request.method === 'POST') {
|
|
sendJson(response, 200, await cleanupOrphanedWorkspaces());
|
|
return;
|
|
}
|
|
const route = jobRoute(url.pathname);
|
|
if (!route) {
|
|
sendJson(response, 404, { error: 'Not found' });
|
|
return;
|
|
}
|
|
|
|
if (request.method === 'GET' && route.action === 'tree') {
|
|
sendJson(response, 200, {
|
|
tree: await listWorkspaceTree(route.jobId),
|
|
});
|
|
return;
|
|
}
|
|
if (request.method === 'GET' && route.action === 'file') {
|
|
const filePath = url.searchParams.get('path') ?? '';
|
|
sendJson(response, 200, {
|
|
path: filePath,
|
|
content: await readWorkspaceFile(route.jobId, filePath),
|
|
});
|
|
return;
|
|
}
|
|
if (request.method === 'PUT' && route.action === 'file') {
|
|
const body = await parseJson<{ path?: string; content?: string }>(
|
|
request,
|
|
);
|
|
sendJson(
|
|
response,
|
|
200,
|
|
await writeWorkspaceFile(
|
|
route.jobId,
|
|
body.path ?? '',
|
|
body.content ?? '',
|
|
),
|
|
);
|
|
return;
|
|
}
|
|
if (request.method === 'GET' && route.action === 'diff') {
|
|
sendJson(response, 200, {
|
|
diff: await getWorkspaceDiff(route.jobId),
|
|
});
|
|
return;
|
|
}
|
|
if (request.method === 'POST' && route.action === 'message') {
|
|
const body = await parseJson<{ content?: string }>(request);
|
|
await sendWorkspaceMessage(route.jobId, body.content ?? '');
|
|
sendJson(response, 200, { success: true });
|
|
return;
|
|
}
|
|
if (request.method === 'GET' && route.action === 'agent/status') {
|
|
sendJson(response, 200, getWorkspaceAgentStatus(route.jobId));
|
|
return;
|
|
}
|
|
if (request.method === 'POST' && route.action === 'agent/abort') {
|
|
sendJson(response, 200, await abortWorkspaceAgent(route.jobId));
|
|
return;
|
|
}
|
|
const interactionMatch =
|
|
/^interactions\/([^/]+)\/reply$/.exec(route.action);
|
|
if (request.method === 'POST' && interactionMatch?.[1]) {
|
|
const body = await parseJson<{
|
|
externalRequestId?: string;
|
|
response?: string;
|
|
}>(request);
|
|
sendJson(
|
|
response,
|
|
200,
|
|
await replyToInteraction(route.jobId, {
|
|
interactionId: decodeURIComponent(
|
|
interactionMatch[1],
|
|
) as Id<'agentInteractionRequests'>,
|
|
externalRequestId: body.externalRequestId ?? '',
|
|
response: body.response ?? 'once',
|
|
}),
|
|
);
|
|
return;
|
|
}
|
|
if (request.method === 'POST' && route.action === 'run-command') {
|
|
const body = await parseJson<{ command?: string }>(request);
|
|
sendJson(
|
|
response,
|
|
200,
|
|
await runWorkspaceCommand(route.jobId, body.command ?? ''),
|
|
);
|
|
return;
|
|
}
|
|
if (request.method === 'POST' && route.action === 'open-pr') {
|
|
sendJson(response, 200, await openWorkspacePullRequest(route.jobId));
|
|
return;
|
|
}
|
|
if (request.method === 'POST' && route.action === 'stop') {
|
|
sendJson(response, 200, await stopWorkspace(route.jobId));
|
|
return;
|
|
}
|
|
sendJson(response, 404, { error: 'Not found' });
|
|
} catch (error) {
|
|
const message = error instanceof Error ? error.message : String(error);
|
|
const status =
|
|
message === 'Unauthorized'
|
|
? 401
|
|
: message.includes('not supported')
|
|
? 409
|
|
: 500;
|
|
sendJson(response, status, {
|
|
error: message,
|
|
});
|
|
}
|
|
})();
|
|
});
|
|
server.listen(env.httpPort, () => {
|
|
console.log(
|
|
`Spoon agent worker HTTP server listening on port ${env.httpPort}`,
|
|
);
|
|
});
|
|
};
|