History

callback

Rhys Sullivan committed 8 months ago

Commit 8ec79cf1 parent 354b0694 files · +202 −11Browse files

Unified · Split

4 files
  1. apps/web/app/v1/runtime/tool-call/route.ts +9 −0
  2. apps/web/lib/control-plane/server.ts +181 −11
  3. apps/web/lib/env/server.ts +5 −0
  4. turbo.json +7 −0
apps/web/app/v1/runtime/tool-call/route.ts+9−0
@@ -0,0 +1,9 @@
1import { getControlPlaneRuntime } from "../../../../lib/control-plane/server";
2
3
4const handler = async (request: Request): Promise<Response> => {
5 const controlPlaneRuntime = await getControlPlaneRuntime();
6 return controlPlaneRuntime.handleRuntimeToolCall(request);
7};
8
9export const POST = handler;
apps/web/lib/control-plane/server.ts+181−11
@@ -12,6 +12,7 @@ import { · 11 unmodified lines
12 createRunExecutor,12 createRunExecutor,
13 createSourceToolRegistry,13 createSourceToolRegistry,
14 defaultExecuteToolExposureMode,14 defaultExecuteToolExposureMode,
15 invokeRuntimeToolCallResult,
15 makeGraphqlToolProvider,16 makeGraphqlToolProvider,
16 makeMcpToolProvider,17 makeMcpToolProvider,
17 makeOpenApiToolProvider,18 makeOpenApiToolProvider,
@@ -106,6 +107,7 @@ type ControlPlaneRuntime = { · 88 unmodified lines
106 fetchOpenApiDocument: typeof fetchOpenApiDocument;107 fetchOpenApiDocument: typeof fetchOpenApiDocument;
107 handleControlPlane: (request: Request) => Promise<Response>;108 handleControlPlane: (request: Request) => Promise<Response>;
108 handleMcp: (request: Request, workspaceId: string) => Promise<Response>;109 handleMcp: (request: Request, workspaceId: string) => Promise<Response>;
110 handleRuntimeToolCall: (request: Request) => Promise<Response>;
109 dispose: () => Promise<void>;111 dispose: () => Promise<void>;
110};112};
111 113
@@ -119,6 +121,36 @@ type ControlPlanePrincipal = { · 7 unmodified lines
119 workspaceId: string;121 workspaceId: string;
120};122};
121 123
124type RuntimeToolCallRequest = {
125 runId: string;
126 callId: string;
127 toolPath: string;
128 input?: Record<string, unknown>;
129};
130
131type RuntimeToolCallResult =
132 | {
133 ok: true;
134 value: unknown;
135 }
136 | {
137 ok: false;
138 kind: "pending";
139 approvalId: string;
140 retryAfterMs: number;
141 error?: string;
142 }
143 | {
144 ok: false;
145 kind: "denied";
146 error: string;
147 }
148 | {
149 ok: false;
150 kind: "failed";
151 error: string;
152 };
153
122const normalizeIdPart = (value: string): string =>154const normalizeIdPart = (value: string): string =>
123 value.replace(/[^a-zA-Z0-9_-]/g, "_");155 value.replace(/[^a-zA-Z0-9_-]/g, "_");
124 156
@@ -144,6 +176,7 @@ const resolveStateRootDir = (): string => · 19 unmodified lines
144 defaultControlPlaneStateRootDir;176 defaultControlPlaneStateRootDir;
145 177
146const openApiSyncRetryDelayMs = 300;178const openApiSyncRetryDelayMs = 300;
179const runtimeToolCallRetentionMs = 15 * 60 * 1000;
147 180
148const formatCause = (cause: unknown): string => {181const formatCause = (cause: unknown): string => {
149 if (cause && typeof cause === "object") {182 if (cause && typeof cause === "object") {
@@ -181,6 +214,54 @@ const formatCause = (cause: unknown): string => { · 31 unmodified lines
181 }214 }
182};215};
183 216
217const toFailedRuntimeToolCallResult = (error: string): RuntimeToolCallResult => ({
218 ok: false,
219 kind: "failed",
220 error,
221});
222
223const runtimeToolCallErrorResponse = (status: number, error: string): Response =>
224 Response.json(toFailedRuntimeToolCallResult(error), { status });
225
226const normalizeRuntimeToolCallInput = (
227 value: unknown,
228): Record<string, unknown> | undefined => {
229 if (value === undefined) {
230 return undefined;
231 }
232
233 if (value && typeof value === "object" && !Array.isArray(value)) {
234 return value as Record<string, unknown>;
235 }
236
237 return {};
238};
239
240const parseRuntimeToolCallRequest = async (
241 request: Request,
242): Promise<RuntimeToolCallRequest | null> => {
243 const payload = await request.json().catch(() => null);
244 if (!payload || typeof payload !== "object" || Array.isArray(payload)) {
245 return null;
246 }
247
248 const record = payload as Record<string, unknown>;
249 if (
250 typeof record.runId !== "string"
251 || typeof record.callId !== "string"
252 || typeof record.toolPath !== "string"
253 ) {
254 return null;
255 }
256
257 return {
258 runId: record.runId,
259 callId: record.callId,
260 toolPath: record.toolPath,
261 input: normalizeRuntimeToolCallInput(record.input),
262 };
263};
264
184const ensurePrincipalProvisioned = (265const ensurePrincipalProvisioned = (
185 persistence: SqlControlPlanePersistence,266 persistence: SqlControlPlanePersistence,
186 principal: ControlPlanePrincipal,267 principal: ControlPlanePrincipal,
@@ -297,10 +378,12 @@ const createControlPlaneRuntime = async (): Promise<ControlPlaneRuntime> => { · 110 unmodified lines
297 requireApprovals: requireToolApprovals,378 requireApprovals: requireToolApprovals,
298 },379 },
299 );380 );
300 const mcpHandlers = new Map<381 const mcpSessions = new Map<string, {
301 string,382 handler: (request: Request) => Promise<Response>;
302 (request: Request) => Promise<Response>383 toolRegistry: ReturnType<typeof createSourceToolRegistry>;
303 >();384 }>();
385 const workspaceByRunId = new Map<string, string>();
386 const runCleanupTimers = new Map<string, ReturnType<typeof setTimeout>>();
304 387
305 const sourceCatalog = makeSourceCatalogService(sourceStore);388 const sourceCatalog = makeSourceCatalogService(sourceStore);
306 const sourceManager = makeSourceManagerService(toolArtifactStore);389 const sourceManager = makeSourceManagerService(toolArtifactStore);
@@ -424,8 +507,27 @@ const createControlPlaneRuntime = async (): Promise<ControlPlaneRuntime> => { · 117 unmodified lines
424 PmActorLive(persistence.rows),507 PmActorLive(persistence.rows),
425 );508 );
426 509
427 const resolveMcpHandler = (workspaceId: string): ((request: Request) => Promise<Response>) => {510 const rememberRunWorkspace = (runId: string, workspaceId: string): void => {
428 const existing = mcpHandlers.get(workspaceId);511 workspaceByRunId.set(runId, workspaceId);
512
513 const existingTimer = runCleanupTimers.get(runId);
514 if (existingTimer) {
515 clearTimeout(existingTimer);
516 }
517
518 const timer = setTimeout(() => {
519 workspaceByRunId.delete(runId);
520 runCleanupTimers.delete(runId);
521 }, runtimeToolCallRetentionMs);
522
523 runCleanupTimers.set(runId, timer);
524 };
525
526 const resolveMcpSession = (workspaceId: string): {
527 handler: (request: Request) => Promise<Response>;
528 toolRegistry: ReturnType<typeof createSourceToolRegistry>;
529 } => {
530 const existing = mcpSessions.get(workspaceId);
429 if (existing) {531 if (existing) {
430 return existing;532 return existing;
431 }533 }
@@ -442,14 +544,25 @@ const createControlPlaneRuntime = async (): Promise<ControlPlaneRuntime> => { · 10 unmodified lines
442 runtimeAdapters,544 runtimeAdapters,
443 toolRegistry,545 toolRegistry,
444 });546 });
445 const runExecutor = createRunExecutor(executeRuntimeRun);547 const runExecutor = createRunExecutor(executeRuntimeRun, {
548 makeRunId: () => {
549 const runId = `run_${crypto.randomUUID()}`;
550 rememberRunWorkspace(runId, workspaceId);
551 return runId;
552 },
553 });
446 const next = createPmMcpHandler(runExecutor.executeRun, {554 const next = createPmMcpHandler(runExecutor.executeRun, {
447 toolRegistry,555 toolRegistry,
448 defaultToolExposureMode,556 defaultToolExposureMode,
449 });557 });
450 558
451 mcpHandlers.set(workspaceId, next);559 const session = {
452 return next;560 handler: next,
561 toolRegistry,
562 };
563
564 mcpSessions.set(workspaceId, session);
565 return session;
453 };566 };
454 567
455 return {568 return {
@@ -459,10 +572,67 @@ const createControlPlaneRuntime = async (): Promise<ControlPlaneRuntime> => { · 3 unmodified lines
459 fetchOpenApiDocument,572 fetchOpenApiDocument,
460 handleControlPlane: controlPlaneWebHandler.handler,573 handleControlPlane: controlPlaneWebHandler.handler,
461 handleMcp: async (request, workspaceId) => {574 handleMcp: async (request, workspaceId) => {
462 const handler = resolveMcpHandler(workspaceId);575 const session = resolveMcpSession(workspaceId);
463 return handler(request);576 return session.handler(request);
577 },
578 handleRuntimeToolCall: async (request) => {
579 if (request.method.toUpperCase() !== "POST") {
580 return runtimeToolCallErrorResponse(405, "Method not allowed. Expected POST");
581 }
582
583 const expectedSecret = webServerEnvironment.cloudflareSandboxCallbackSecret;
584 if (!expectedSecret) {
585 return runtimeToolCallErrorResponse(503, "Runtime callback secret is not configured");
586 }
587
588 const providedSecret = request.headers.get("x-internal-secret")?.trim();
589 if (!providedSecret) {
590 return runtimeToolCallErrorResponse(401, "Runtime callback authentication is required");
591 }
592
593 if (providedSecret !== expectedSecret) {
594 return runtimeToolCallErrorResponse(403, "Runtime callback authentication failed");
595 }
596
597 const input = await parseRuntimeToolCallRequest(request);
598 if (!input) {
599 return runtimeToolCallErrorResponse(400, "Runtime callback request body is invalid");
600 }
601
602 const workspaceId = workspaceByRunId.get(input.runId);
603 if (!workspaceId) {
604 return runtimeToolCallErrorResponse(404, `Unknown runtime callback run id: ${input.runId}`);
605 }
606
607 const session = resolveMcpSession(workspaceId);
608
609 const result = await Effect.runPromise(
610 invokeRuntimeToolCallResult(session.toolRegistry, input).pipe(
611 Effect.catchTag("RuntimeAdapterError", (error) =>
612 Effect.succeed<RuntimeToolCallResult>(
613 toFailedRuntimeToolCallResult(
614 error.details ? `${error.message}: ${error.details}` : error.message,
615 ),
616 )
617 ),
618 Effect.catchAll((cause) =>
619 Effect.succeed<RuntimeToolCallResult>(
620 toFailedRuntimeToolCallResult(formatCause(cause)),
621 )
622 ),
623 ),
624 );
625
626 return Response.json(result, { status: 200 });
464 },627 },
465 dispose: async () => {628 dispose: async () => {
629 for (const timer of runCleanupTimers.values()) {
630 clearTimeout(timer);
631 }
632 runCleanupTimers.clear();
633 workspaceByRunId.clear();
634 mcpSessions.clear();
635
466 await controlPlaneWebHandler.dispose();636 await controlPlaneWebHandler.dispose();
467 await persistence.close();637 await persistence.close();
468 },638 },
apps/web/lib/env/server.ts+5−0
@@ -11,6 +11,10 @@ const webServerEnvConfig = configSchema("WebServerEnvironment", { · 10 unmodified lines
11 pmRuntimeKind: server({ env: "PM_RUNTIME_KIND", optional: true }),11 pmRuntimeKind: server({ env: "PM_RUNTIME_KIND", optional: true }),
12 pmRequireToolApprovals: server({ env: "PM_REQUIRE_TOOL_APPROVALS", optional: true }),12 pmRequireToolApprovals: server({ env: "PM_REQUIRE_TOOL_APPROVALS", optional: true }),
13 pmToolExposureMode: server({ env: "PM_TOOL_EXPOSURE_MODE", optional: true }),13 pmToolExposureMode: server({ env: "PM_TOOL_EXPOSURE_MODE", optional: true }),
14 cloudflareSandboxCallbackSecret: server({
15 env: "CLOUDFLARE_SANDBOX_CALLBACK_SECRET",
16 optional: true,
17 }),
14 executorPublicOrigin: server({ env: "EXECUTOR_PUBLIC_ORIGIN", optional: true }),18 executorPublicOrigin: server({ env: "EXECUTOR_PUBLIC_ORIGIN", optional: true }),
15 vercelProjectProductionUrl: server({ env: "VERCEL_PROJECT_PRODUCTION_URL", optional: true }),19 vercelProjectProductionUrl: server({ env: "VERCEL_PROJECT_PRODUCTION_URL", optional: true }),
16 vercelUrl: server({ env: "VERCEL_URL", optional: true }),20 vercelUrl: server({ env: "VERCEL_URL", optional: true }),
@@ -36,6 +40,7 @@ export const webServerEnvironment = { · 19 unmodified lines
36 pmRuntimeKind: trim(env.pmRuntimeKind),40 pmRuntimeKind: trim(env.pmRuntimeKind),
37 pmRequireToolApprovals: isTruthy(env.pmRequireToolApprovals),41 pmRequireToolApprovals: isTruthy(env.pmRequireToolApprovals),
38 pmToolExposureMode: trim(env.pmToolExposureMode),42 pmToolExposureMode: trim(env.pmToolExposureMode),
43 cloudflareSandboxCallbackSecret: trim(env.cloudflareSandboxCallbackSecret),
39 executorPublicOrigin: trim(env.executorPublicOrigin),44 executorPublicOrigin: trim(env.executorPublicOrigin),
40 vercelProjectProductionUrl: trim(env.vercelProjectProductionUrl),45 vercelProjectProductionUrl: trim(env.vercelProjectProductionUrl),
41 vercelUrl: trim(env.vercelUrl),46 vercelUrl: trim(env.vercelUrl),
turbo.json+7−0
@@ -11,8 +11,15 @@ · 10 unmodified lines
11 "build": {11 "build": {
12 "dependsOn": ["^build"],12 "dependsOn": ["^build"],
13 "env": [13 "env": [
14 "CLOUDFLARE_SANDBOX_AUTH_TOKEN",
15 "CLOUDFLARE_SANDBOX_CALLBACK_SECRET",
16 "CLOUDFLARE_SANDBOX_CALLBACK_URL",
17 "CLOUDFLARE_SANDBOX_RUN_URL",
14 "CONVEX_URL",18 "CONVEX_URL",
19 "CONTROL_PLANE_POSTGRES_MAX_CONNECTIONS",
15 "DATABASE_URL",20 "DATABASE_URL",
21 "MCP_AUTHORIZATION_SERVER",
22 "PM_RUNTIME_KIND",
16 "VITE_CONVEX_URL",23 "VITE_CONVEX_URL",
17 "WORKOS_API_KEY",24 "WORKOS_API_KEY",
18 "WORKOS_CLIENT_ID",25 "WORKOS_CLIENT_ID",