History

Merge pull request #281 from RhysSullivan/rs/mcp-do-shared-layer

Rhys Sullivan committed 7 months ago

Commit b5741222 parents 599ae33 8177e398 files · +176 −95Browse files

Unified · Split

8 files
  1. apps/cloud/src/api/error-response.ts +6 −1
  2. apps/cloud/src/api/layers.ts +12 −2
  3. apps/cloud/src/api/protected.ts +2 −15
  4. apps/cloud/src/mcp-session.ts +37 −42
  5. apps/cloud/src/mcp.ts +2 −0
  6. apps/cloud/src/server.ts +52 −29
  7. apps/cloud/src/services/execution-stack.ts +29 −0
  8. apps/cloud/src/services/telemetry.ts +36 −6
apps/cloud/src/api/error-response.ts+6−1
@@ -1,3 +1,4 @@
1import * as Sentry from "@sentry/cloudflare";
1import { Data } from "effect";2import { Data } from "effect";
2import { HttpServerResponse } from "@effect/platform";3import { HttpServerResponse } from "@effect/platform";
3 4
@@ -20,12 +21,16 @@ export const isServerError = (error: unknown): boolean => toHttpResponseError(er · 16 unmodified lines
20 21
21export const toErrorResponse = (error: unknown): Response => {22export const toErrorResponse = (error: unknown): Response => {
22 const mapped = toHttpResponseError(error);23 const mapped = toHttpResponseError(error);
24 if (mapped.status >= 500) Sentry.captureException(error);
23 return Response.json({ error: mapped.message, code: mapped.code }, { status: mapped.status });25 return Response.json({ error: mapped.message, code: mapped.code }, { status: mapped.status });
24};26};
25 27
26export const toErrorServerResponse = (error: unknown): HttpServerResponse.HttpServerResponse => {28export const toErrorServerResponse = (error: unknown): HttpServerResponse.HttpServerResponse => {
27 console.error("[api] toErrorServerResponse error:", error instanceof Error ? error.stack : error);
28 const mapped = toHttpResponseError(error);29 const mapped = toHttpResponseError(error);
30 if (mapped.status >= 500) {
31 console.error("[api] toErrorServerResponse error:", error instanceof Error ? error.stack : error);
32 Sentry.captureException(error);
33 }
29 return HttpServerResponse.unsafeJson(34 return HttpServerResponse.unsafeJson(
30 { error: mapped.message, code: mapped.code },35 { error: mapped.message, code: mapped.code },
31 { status: mapped.status },36 { status: mapped.status },
apps/cloud/src/api/layers.ts+12−2
@@ -30,11 +30,21 @@ const ProtectedCloudApi = CoreExecutorApi.add(OpenApiGroup) · 29 unmodified lines
30const DbLive = DbService.Live;30const DbLive = DbService.Live;
31const UserStoreLive = UserStoreService.Live.pipe(Layer.provide(DbLive));31const UserStoreLive = UserStoreService.Live.pipe(Layer.provide(DbLive));
32 32
33/**
34 * Services that are independent of how the DB or tracer is provisioned —
35 * both the stateless HTTP path (per-request DB via Hyperdrive) and the MCP
36 * session DO (long-lived DB + isolate-local tracer SDK) merge this with
37 * their own `DbLive` + `UserStoreLive` + telemetry layer.
38 */
39export const CoreSharedServices = Layer.mergeAll(
40 WorkOSAuth.Default,
41 AutumnService.Default,
42);
43
33export const SharedServices = Layer.mergeAll(44export const SharedServices = Layer.mergeAll(
34 DbLive,45 DbLive,
35 UserStoreLive,46 UserStoreLive,
36 WorkOSAuth.Default,47 CoreSharedServices,
37 AutumnService.Default,
38 HttpServer.layerContext,48 HttpServer.layerContext,
39 TelemetryLive,49 TelemetryLive,
40);50);
apps/cloud/src/api/protected.ts+2−15
@@ -1,21 +1,15 @@
1import { env } from "cloudflare:workers";
2import { HttpApiBuilder, HttpApiSwagger, HttpServerRequest } from "@effect/platform";1import { HttpApiBuilder, HttpApiSwagger, HttpServerRequest } from "@effect/platform";
3import { Effect, Layer } from "effect";2import { Effect, Layer } from "effect";
4 3
5import { ExecutorService, ExecutionEngineService } from "@executor/api/server";4import { ExecutorService, ExecutionEngineService } from "@executor/api/server";
6import { createExecutionEngine } from "@executor/execution";
7import { makeDynamicWorkerExecutor } from "@executor/runtime-dynamic-worker";
8import { OpenApiExtensionService } from "@executor/plugin-openapi/api";5import { OpenApiExtensionService } from "@executor/plugin-openapi/api";
9import { McpExtensionService } from "@executor/plugin-mcp/api";6import { McpExtensionService } from "@executor/plugin-mcp/api";
10import { GraphqlExtensionService } from "@executor/plugin-graphql/api";7import { GraphqlExtensionService } from "@executor/plugin-graphql/api";
11 8
12import { authorizeOrganization } from "../auth/authorize-organization";9import { authorizeOrganization } from "../auth/authorize-organization";
13import { WorkOSAuth } from "../auth/workos";10import { WorkOSAuth } from "../auth/workos";
14import { AutumnService } from "../services/autumn";11import { makeExecutionStack } from "../services/execution-stack";
15import { createScopedExecutor } from "../services/executor";
16import { makeTrackExecutionUsage } from "./autumn";
17import { HttpResponseError, isServerError, toErrorServerResponse } from "./error-response";12import { HttpResponseError, isServerError, toErrorServerResponse } from "./error-response";
18import { withExecutionUsageTracking } from "./execution-usage";
19import { ProtectedCloudApiLive, RouterConfig, SharedServices } from "./layers";13import { ProtectedCloudApiLive, RouterConfig, SharedServices } from "./layers";
20 14
21const lookupOrgForRequest = (request: HttpServerRequest.HttpServerRequest) =>15const lookupOrgForRequest = (request: HttpServerRequest.HttpServerRequest) =>
@@ -38,14 +32,7 @@ const lookupOrgForRequest = (request: HttpServerRequest.HttpServerRequest) => · 16 unmodified lines
38 32
39const createProtectedApp = (organizationId: string, organizationName: string) =>33const createProtectedApp = (organizationId: string, organizationName: string) =>
40 Effect.gen(function* () {34 Effect.gen(function* () {
41 const executor = yield* createScopedExecutor(organizationId, organizationName);35 const { executor, engine } = yield* makeExecutionStack(organizationId, organizationName);
42 const codeExecutor = makeDynamicWorkerExecutor({ loader: env.LOADER });
43 const autumn = yield* AutumnService;
44 const engine = withExecutionUsageTracking(
45 organizationId,
46 createExecutionEngine({ executor, codeExecutor }),
47 makeTrackExecutionUsage(autumn),
48 );
49 36
50 const requestServices = Layer.mergeAll(37 const requestServices = Layer.mergeAll(
51 Layer.succeed(ExecutorService, executor),38 Layer.succeed(ExecutorService, executor),
apps/cloud/src/mcp-session.ts+37−42
@@ -10,19 +10,15 @@ import { drizzle } from "drizzle-orm/postgres-js"; · 9 unmodified lines
10import postgres from "postgres";10import postgres from "postgres";
11 11
12import { createExecutorMcpServer } from "@executor/host-mcp";12import { createExecutorMcpServer } from "@executor/host-mcp";
13import { createExecutionEngine } from "@executor/execution";
14import { makeDynamicWorkerExecutor } from "@executor/runtime-dynamic-worker";
15import type { DrizzleDb, DbServiceShape } from "./services/db";13import type { DrizzleDb, DbServiceShape } from "./services/db";
16 14
17import { makeTrackExecutionUsage } from "./api/autumn";15import { CoreSharedServices } from "./api/layers";
18import { withExecutionUsageTracking } from "./api/execution-usage";
19import { UserStoreService } from "./auth/context";16import { UserStoreService } from "./auth/context";
20import { resolveOrganization } from "./auth/resolve-organization";17import { resolveOrganization } from "./auth/resolve-organization";
21import { WorkOSAuth } from "./auth/workos";
22import { server } from "./env";18import { server } from "./env";
23import { AutumnService } from "./services/autumn";
24import { createScopedExecutor } from "./services/executor";
25import { DbService, combinedSchema } from "./services/db";19import { DbService, combinedSchema } from "./services/db";
20import { makeExecutionStack } from "./services/execution-stack";
21import { DoTelemetryLive } from "./services/telemetry";
26 22
27// ---------------------------------------------------------------------------23// ---------------------------------------------------------------------------
28// Types24// Types
@@ -105,47 +101,46 @@ export class McpSessionDO extends DurableObject { · 76 unmodified lines
105 async init(token: McpSessionInit): Promise<void> {101 async init(token: McpSessionInit): Promise<void> {
106 if (this.initialized) return;102 if (this.initialized) return;
107 103
108 // Create a long-lived DB connection for the DO's lifetime
109 this.dbHandle = makeLongLivedDb();104 this.dbHandle = makeLongLivedDb();
110 105 try {
111 const { sql, db } = this.dbHandle;106 const { sql, db } = this.dbHandle;
112 const DbLive = Layer.succeed(DbService, { sql, db });107 const DbLive = Layer.succeed(DbService, { sql, db });
113 const UserStoreLive = UserStoreService.Live.pipe(Layer.provide(DbLive));108 const UserStoreLive = UserStoreService.Live.pipe(Layer.provide(DbLive));
114 const Services = Layer.mergeAll(109 const Services = Layer.mergeAll(
115 DbLive,110 DbLive,
116 UserStoreLive,111 UserStoreLive,
117 WorkOSAuth.Default,112 CoreSharedServices,
118 AutumnService.Default,113 DoTelemetryLive,
119 );
120
121 const program = Effect.gen(function* () {
122 const org = yield* resolveOrganization(token.organizationId);
123 if (!org)
124 return yield* new OrganizationNotFoundError({ organizationId: token.organizationId });
125
126 const executor = yield* createScopedExecutor(org.id, org.name);
127 const codeExecutor = makeDynamicWorkerExecutor({ loader: env.LOADER });
128 const autumn = yield* AutumnService;
129 const engine = withExecutionUsageTracking(
130 org.id,
131 createExecutionEngine({ executor, codeExecutor }),
132 makeTrackExecutionUsage(autumn),
133 );114 );
134 return yield* Effect.promise(() => createExecutorMcpServer({ engine }));
135 }).pipe(Effect.provide(Services));
136 115
137 this.mcpServer = await Effect.runPromise(program);116 const program = Effect.gen(function* () {
117 const org = yield* resolveOrganization(token.organizationId);
118 if (!org)
119 return yield* new OrganizationNotFoundError({ organizationId: token.organizationId });
138 120
139 this.transport = new WorkerTransport({121 const { engine } = yield* makeExecutionStack(org.id, org.name);
140 sessionIdGenerator: () => this.ctx.id.toString(),122 return yield* Effect.promise(() => createExecutorMcpServer({ engine }));
141 storage: this.makeStorage(),123 }).pipe(Effect.withSpan("McpSessionDO.init"), Effect.provide(Services));
142 });
143 124
144 await this.mcpServer.connect(this.transport);125 this.mcpServer = await Effect.runPromise(program);
145 this.initialized = true;
146 this.lastActivityMs = Date.now();
147 126
148 await this.ctx.storage.setAlarm(Date.now() + HEARTBEAT_MS);127 this.transport = new WorkerTransport({
128 sessionIdGenerator: () => this.ctx.id.toString(),
129 storage: this.makeStorage(),
130 });
131
132 await this.mcpServer.connect(this.transport);
133 this.initialized = true;
134 this.lastActivityMs = Date.now();
135
136 await this.ctx.storage.setAlarm(Date.now() + HEARTBEAT_MS);
137 } catch (err) {
138 // Partial init leaves dangling resources (DB socket, maybe mcpServer).
139 // Clean up before rethrowing so the DO isn't stuck in a half-built state.
140 console.error("[mcp-session] init failed:", err instanceof Error ? err.stack : err);
141 await this.cleanup();
142 throw err;
143 }
149 }144 }
150 145
151 async handleRequest(request: Request): Promise<Response> {146 async handleRequest(request: Request): Promise<Response> {
apps/cloud/src/mcp.ts+2−0
@@ -3,6 +3,7 @@ · 2 unmodified lines
3// ---------------------------------------------------------------------------3// ---------------------------------------------------------------------------
4 4
5import { env } from "cloudflare:workers";5import { env } from "cloudflare:workers";
6import * as Sentry from "@sentry/cloudflare";
6import { createRemoteJWKSet, jwtVerify } from "jose";7import { createRemoteJWKSet, jwtVerify } from "jose";
7 8
8import { server } from "./env";9import { server } from "./env";
@@ -130,6 +131,7 @@ const handleMcpRequest_POST = async (request: Request, token: VerifiedToken): Pr · 121 unmodified lines
130 return await stub.handleRequest(request);131 return await stub.handleRequest(request);
131 } catch (err) {132 } catch (err) {
132 console.error("[mcp] POST handler error:", err instanceof Error ? err.stack : err);133 console.error("[mcp] POST handler error:", err instanceof Error ? err.stack : err);
134 Sentry.captureException(err);
133 return jsonRpcError(500, -32603, "Internal server error");135 return jsonRpcError(500, -32603, "Internal server error");
134 }136 }
135};137};
apps/cloud/src/server.ts+52−29
@@ -1,45 +1,68 @@
1import * as Sentry from "@sentry/cloudflare";1import * as Sentry from "@sentry/cloudflare";
2import handler from "@tanstack/react-start/server-entry";2import handler from "@tanstack/react-start/server-entry";
3import { instrument, type ResolveConfigFn } from "@microlabs/otel-cf-workers";3import { instrument, type TraceConfig } from "@microlabs/otel-cf-workers";
4 4
5// Export Durable Objects as named exports5import { McpSessionDO as McpSessionDOBase } from "./mcp-session";
6export { McpSessionDO } from "./mcp-session";6import { server } from "./env";
7 7
8// ---------------------------------------------------------------------------8// ---------------------------------------------------------------------------
9// OTEL config — `otel-cf-workers` owns the global TracerProvider and flushes9// OTEL config for the main fetch handler — `otel-cf-workers` owns the global
10// via `ctx.waitUntil` at the end of each request. `TelemetryLive` in10// TracerProvider and flushes via `ctx.waitUntil` at the end of each request.
11// `services/telemetry.ts` plugs Effect's tracer into that same provider.11// The DO runs in a separate isolate and uses its own self-contained WebSdk
12// (see `services/telemetry.ts#DoTelemetryLive`); `instrumentDO` from
13// otel-cf-workers is NOT used because it breaks `this` binding on
14// `WorkerTransport`'s stream primitives and crashes every MCP request with
15// DOMException "Illegal invocation".
12// ---------------------------------------------------------------------------16// ---------------------------------------------------------------------------
13 17
14type OtelEnv = {18const otelConfig: TraceConfig = {
15 AXIOM_TOKEN?: string;
16 AXIOM_DATASET?: string;
17};
18
19const resolveOtelConfig: ResolveConfigFn<OtelEnv> = (env) => ({
20 service: { name: "executor-cloud", version: "1.0.0" },19 service: { name: "executor-cloud", version: "1.0.0" },
21 exporter: {20 exporter: {
22 url: "https://api.axiom.co/v1/traces",21 url: "https://api.axiom.co/v1/traces",
23 headers: {22 headers: {
24 Authorization: `Bearer ${env.AXIOM_TOKEN ?? ""}`,23 Authorization: `Bearer ${server.AXIOM_TOKEN}`,
25 "X-Axiom-Dataset": env.AXIOM_DATASET ?? "executor-cloud",24 "X-Axiom-Dataset": server.AXIOM_DATASET,
26 },25 },
27 },26 },
27};
28
29// otel-cf-workers owns the global TracerProvider. Sentry's OTEL compat shim
30// registers a ProxyTracerProvider of its own, which prevents otel-cf-workers
31// from finding its WorkerTracer and breaks the whole request path with
32// "global tracer is not of type WorkerTracer".
33//
34// The `_env` parameter is unused — `server` from `./env` already gives us
35// typed access to every secret. It's only in the signature so Sentry's
36// generics infer the DO's `Env` type correctly.
37const sentryOptions = (_env: Env) => ({
38 dsn: server.SENTRY_DSN,
39 tracesSampleRate: 0,
40 enableLogs: true,
41 sendDefaultPii: true,
42 skipOpenTelemetrySetup: true,
43 // Our DO methods (init/handleRequest/alarm) live on the prototype, not on
44 // the instance. Sentry's default DO auto-wrap only visits own properties,
45 // which misses prototype methods — so errors thrown inside init() never
46 // reach Sentry. This flag opts into prototype-method instrumentation.
47 instrumentPrototypeMethods: true,
28});48});
29 49
30const instrumentedHandler = instrument({ fetch: handler.fetch }, resolveOtelConfig);50// ---------------------------------------------------------------------------
31 51// Durable Object — wrapped with Sentry so DO errors land in Sentry (inits the
32export default Sentry.withSentry(52// client inside the DO isolate, which plain `Sentry.captureException` cannot
33 (env: Record<string, string>) => ({53// do on its own). We deliberately do NOT wrap with otel-cf-workers'
34 dsn: env.SENTRY_DSN,54// `instrumentDO` (see note above).
35 tracesSampleRate: 0,55// ---------------------------------------------------------------------------
36 enableLogs: true,56
37 sendDefaultPii: true,57export const McpSessionDO = Sentry.instrumentDurableObjectWithSentry(
38 // otel-cf-workers owns the global TracerProvider. Sentry's OTEL compat58 sentryOptions,
39 // shim registers a ProxyTracerProvider of its own, which prevents59 McpSessionDOBase,
40 // otel-cf-workers from finding its WorkerTracer and breaks the whole
41 // request path with "global tracer is not of type WorkerTracer".
42 skipOpenTelemetrySetup: true,
43 }),
44 instrumentedHandler,
45);60);
61
62// ---------------------------------------------------------------------------
63// Worker fetch handler
64// ---------------------------------------------------------------------------
65
66const instrumentedHandler = instrument({ fetch: handler.fetch }, otelConfig);
67
68export default Sentry.withSentry(sentryOptions, instrumentedHandler);
apps/cloud/src/services/execution-stack.ts+29−0
@@ -0,0 +1,29 @@
1// ---------------------------------------------------------------------------
2// Shared execution stack — the wiring that turns an organization into a
3// runnable executor + engine. Used by the protected HTTP API (per-request)
4// and the MCP session DO (per-session) so changes to the stack flow to both.
5// ---------------------------------------------------------------------------
6
7import { env } from "cloudflare:workers";
8import { Effect } from "effect";
9
10import { createExecutionEngine } from "@executor/execution";
11import { makeDynamicWorkerExecutor } from "@executor/runtime-dynamic-worker";
12
13import { makeTrackExecutionUsage } from "../api/autumn";
14import { withExecutionUsageTracking } from "../api/execution-usage";
15import { AutumnService } from "./autumn";
16import { createScopedExecutor } from "./executor";
17
18export const makeExecutionStack = (organizationId: string, organizationName: string) =>
19 Effect.gen(function* () {
20 const executor = yield* createScopedExecutor(organizationId, organizationName);
21 const codeExecutor = makeDynamicWorkerExecutor({ loader: env.LOADER });
22 const autumn = yield* AutumnService;
23 const engine = withExecutionUsageTracking(
24 organizationId,
25 createExecutionEngine({ executor, codeExecutor }),
26 makeTrackExecutionUsage(autumn),
27 );
28 return { executor, engine };
29 });
apps/cloud/src/services/telemetry.ts+36−6
@@ -1,15 +1,45 @@
1// ---------------------------------------------------------------------------1// ---------------------------------------------------------------------------
2// Effect → global OTEL bridge2// Effect → OTEL → Axiom bridge
3// ---------------------------------------------------------------------------3// ---------------------------------------------------------------------------
4// The global TracerProvider is owned by `@microlabs/otel-cf-workers` (see4//
5// `server.ts`). This layer plugs Effect's tracer into that provider so every5// Two callers, two setups:
6// `Effect.withSpan(...)` becomes a real OTLP span exported to Axiom, with6//
7// flushing handled reliably by the instrument() wrapper via ctx.waitUntil.7// - `TelemetryLive` (fetch path): reads the global `TracerProvider` that
8// `@microlabs/otel-cf-workers`' `instrument(...)` installs in `server.ts`.
9// Flushing is handled by `instrument()` via `ctx.waitUntil` at request end.
10//
11// - `DoTelemetryLive` (Durable Object path): provisions its own
12// `WebSdk`-backed tracer via `Effect`. The DO runs in a separate isolate
13// and we deliberately avoid `instrumentDO` (it wraps DO methods in a way
14// that breaks `this` binding on `WorkerTransport`'s stream primitives —
15// every MCP request 500s with "Illegal invocation"). The DO uses a
16// `SimpleSpanProcessor` so spans export immediately; there's no
17// `ctx.waitUntil` to rely on for batching.
8// ---------------------------------------------------------------------------18// ---------------------------------------------------------------------------
9 19
10import { Resource, Tracer as OtelTracer } from "@effect/opentelemetry";20import { Resource, Tracer as OtelTracer, WebSdk } from "@effect/opentelemetry";
21import { OTLPTraceExporter } from "@opentelemetry/exporter-trace-otlp-http";
22import { SimpleSpanProcessor } from "@opentelemetry/sdk-trace-base";
11import { Layer } from "effect";23import { Layer } from "effect";
12 24
25import { server } from "../env";
26
13export const TelemetryLive: Layer.Layer<never> = OtelTracer.layerGlobal.pipe(27export const TelemetryLive: Layer.Layer<never> = OtelTracer.layerGlobal.pipe(
14 Layer.provide(Resource.layer({ serviceName: "executor-cloud", serviceVersion: "1.0.0" })),28 Layer.provide(Resource.layer({ serviceName: "executor-cloud", serviceVersion: "1.0.0" })),
15);29);
30
31const makeDoOtelExporter = () =>
32 new OTLPTraceExporter({
33 url: "https://api.axiom.co/v1/traces",
34 headers: {
35 Authorization: `Bearer ${server.AXIOM_TOKEN}`,
36 "X-Axiom-Dataset": server.AXIOM_DATASET,
37 },
38 });
39
40export const DoTelemetryLive: Layer.Layer<never> = server.AXIOM_TOKEN
41 ? WebSdk.layer(() => ({
42 resource: { serviceName: "executor-cloud", serviceVersion: "1.0.0" },
43 spanProcessor: new SimpleSpanProcessor(makeDoOtelExporter()),
44 }))
45 : Layer.empty;