History

Merge pull request #318 from RhysSullivan/rs/mcp-o11y-v2

Rhys Sullivan committed 6 months ago

Commit ea64d582 parents ab2a1a9 2fa416721 files · +409 −270Browse files

Unified · Split

21 files
  1. apps/cloud/src/api/execution-usage.ts +16 −18
  2. apps/cloud/src/api/protected.test.ts +22 −26
  3. apps/cloud/src/api/protected.ts +0 −3
  4. apps/cloud/src/mcp-session.ts +99 −68
  5. apps/cloud/src/mcp.ts +43 −1
  6. apps/cloud/src/services/autumn.ts +16 −10
  7. apps/cloud/src/services/execution-stack.ts +4 −2
  8. packages/core/api/src/handlers/executions.ts +5 −7
  9. packages/core/api/src/services.ts +7 −1
  10. packages/core/execution/src/description.ts +7 −3
  11. packages/core/execution/src/engine.ts +35 −30
  12. packages/core/execution/src/errors.ts +5 −0
  13. packages/core/execution/src/promise.ts +45 −30
  14. packages/core/execution/src/tool-invoker.test.ts +39 −51
  15. packages/hosts/mcp/src/server.ts +28 −13
  16. packages/kernel/core/src/effect-errors.ts +14 −0
  17. packages/kernel/core/src/types.ts +14 −3
  18. packages/kernel/runtime-deno-subprocess/src/index.ts +1 −1
  19. packages/kernel/runtime-dynamic-worker/src/executor.ts +3 −1
  20. packages/kernel/runtime-quickjs/src/index.ts +3 −1
  21. packages/kernel/runtime-secure-exec/src/index.ts +3 −1
apps/cloud/src/api/execution-usage.ts+16−18
@@ -1,24 +1,22 @@
1import { Effect } from "effect";
2import type * as Cause from "effect/Cause";
3
1import type { ExecutionEngine } from "@executor/execution";4import type { ExecutionEngine } from "@executor/execution";
2 5
3export const withExecutionUsageTracking = (6export const withExecutionUsageTracking = <E extends Cause.YieldableError>(
4 organizationId: string,7 organizationId: string,
5 engine: ExecutionEngine,8 engine: ExecutionEngine<E>,
6 trackUsage: (organizationId: string) => void,9 trackUsage: (organizationId: string) => void,
7): ExecutionEngine => ({10): ExecutionEngine<E> => ({
8 execute: async (code, options) => {11 execute: (code, options) =>
9 const result = await engine.execute(code, options);12 engine
10 trackUsage(organizationId);13 .execute(code, options)
11 return result;14 .pipe(Effect.tap(() => Effect.sync(() => trackUsage(organizationId)))),
12 },15 executeWithPause: (code) =>
13 executeWithPause: async (code) => {16 engine
14 const result = await engine.executeWithPause(code);17 .executeWithPause(code)
15 trackUsage(organizationId);18 .pipe(Effect.tap(() => Effect.sync(() => trackUsage(organizationId)))),
16 return result;19 // resume doesn't count as usage
17 },20 resume: (executionId, response) => engine.resume(executionId, response),
18 resume: async (executionId, response) => {
19 const result = await engine.resume(executionId, response);
20 // resume doesn't count as usage
21 return result;
22 },
23 getDescription: engine.getDescription,21 getDescription: engine.getDescription,
24});22});
apps/cloud/src/api/protected.test.ts+22−26
@@ -5,16 +5,18 @@ import { withExecutionUsageTracking } from "./execution-usage"; · 4 unmodified lines
5 5
6const makeBaseEngine = (): ExecutionEngine =>6const makeBaseEngine = (): ExecutionEngine =>
7 ({7 ({
8 execute: async () => ({ result: "ok", logs: [] }),8 execute: () => Effect.succeed({ result: "ok", logs: [] }),
9 executeWithPause: async () => ({9 executeWithPause: () =>
10 status: "completed",10 Effect.succeed({
11 result: { result: "ok", logs: [] },11 status: "completed",
12 }),12 result: { result: "ok", logs: [] },
13 resume: async () => ({13 }),
14 status: "completed",14 resume: () =>
15 result: { result: "ok", logs: [] },15 Effect.succeed({
16 }),16 status: "completed",
17 getDescription: async () => "desc",17 result: { result: "ok", logs: [] },
18 }),
19 getDescription: Effect.succeed("desc"),
18 }) as ExecutionEngine;20 }) as ExecutionEngine;
19 21
20describe("withExecutionUsageTracking", () => {22describe("withExecutionUsageTracking", () => {
@@ -25,10 +27,8 @@ describe("withExecutionUsageTracking", () => { · 4 unmodified lines
25 tracked.push(orgId);27 tracked.push(orgId);
26 });28 });
27 29
28 yield* Effect.promise(() =>30 yield* engine.execute("1+1", { onElicitation: (() => Effect.die("unused")) as never });
29 engine.execute("1+1", { onElicitation: (() => Effect.die("unused")) as never }),31 yield* engine.executeWithPause("2+2");
30 );
31 yield* Effect.promise(() => engine.executeWithPause("2+2"));
32 32
33 expect(tracked).toEqual(["org_1", "org_1"]);33 expect(tracked).toEqual(["org_1", "org_1"]);
34 }),34 }),
@@ -44,8 +44,8 @@ describe("withExecutionUsageTracking", () => { · 9 unmodified lines
44 "org_2",44 "org_2",
45 {45 {
46 ...base,46 ...base,
47 resume: async (...args) => {47 resume: (...args) => {
48 if (shouldReturnNull) return null;48 if (shouldReturnNull) return Effect.succeed(null);
49 return base.resume(...args);49 return base.resume(...args);
50 },50 },
51 },51 },
@@ -54,17 +54,13 @@ describe("withExecutionUsageTracking", () => { · 2 unmodified lines
54 },54 },
55 );55 );
56 56
57 yield* Effect.promise(() =>57 yield* engine.resume("exec_1", {
58 engine.resume("exec_1", {58 action: "accept",
59 action: "accept",59 });
60 }),
61 );
62 shouldReturnNull = true;60 shouldReturnNull = true;
63 yield* Effect.promise(() =>61 yield* engine.resume("missing", {
64 engine.resume("missing", {62 action: "accept",
65 action: "accept",63 });
66 }),
67 );
68 64
69 expect(tracked).toEqual([]);65 expect(tracked).toEqual([]);
70 }),66 }),
apps/cloud/src/api/protected.ts+0−3
@@ -34,9 +34,6 @@ const createProtectedApp = (organizationId: string, organizationName: string) => · 33 unmodified lines
34 Effect.gen(function* () {34 Effect.gen(function* () {
35 const { executor, engine } = yield* makeExecutionStack(organizationId, organizationName);35 const { executor, engine } = yield* makeExecutionStack(organizationId, organizationName);
36 36
37 // Handlers wrap their own bodies with `capture(...)` — the edge
38 // translation lives per-handler, not at service construction. See
39 // notes/error-handling.md.
40 const requestServices = Layer.mergeAll(37 const requestServices = Layer.mergeAll(
41 Layer.succeed(ExecutorService, executor),38 Layer.succeed(ExecutorService, executor),
42 Layer.succeed(ExecutionEngineService, engine),39 Layer.succeed(ExecutionEngineService, engine),
apps/cloud/src/mcp-session.ts+99−68
@@ -4,12 +4,14 @@ · 3 unmodified lines
4 4
5import { DurableObject, env } from "cloudflare:workers";5import { DurableObject, env } from "cloudflare:workers";
6import { Data, Effect, Layer } from "effect";6import { Data, Effect, Layer } from "effect";
7import * as Sentry from "@sentry/cloudflare";
7import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";8import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
8import { WorkerTransport, type TransportState } from "agents/mcp";9import { WorkerTransport, type TransportState } from "agents/mcp";
9import { drizzle } from "drizzle-orm/postgres-js";10import { drizzle } from "drizzle-orm/postgres-js";
10import postgres from "postgres";11import postgres from "postgres";
11 12
12import { createExecutorMcpServer } from "@executor/host-mcp";13import { createExecutorMcpServer } from "@executor/host-mcp";
14import { buildExecuteDescription } from "@executor/execution";
13import type { DrizzleDb, DbServiceShape } from "./services/db";15import type { DrizzleDb, DbServiceShape } from "./services/db";
14 16
15// Import directly from core-shared-services, NOT from ./api/layers.ts.17// Import directly from core-shared-services, NOT from ./api/layers.ts.
@@ -100,8 +102,13 @@ const makeResolveOrganizationServices = (dbHandle: DbHandle) => { · 84 unmodified lines
100 return Layer.mergeAll(DbLive, UserStoreLive, CoreSharedServices);102 return Layer.mergeAll(DbLive, UserStoreLive, CoreSharedServices);
101};103};
102 104
105// Session services DON'T re-provide `DoTelemetryLive` — that would install a
106// second WebSdk tracer in the nested Effect scope, disconnecting every
107// child span from the outer `McpSessionDO.init` / `McpSessionDO.handleRequest`
108// trace. Tracer comes from the outermost `Effect.provide(DoTelemetryLive)`
109// at the DO method boundary.
103const makeSessionServices = (dbHandle: DbHandle) =>110const makeSessionServices = (dbHandle: DbHandle) =>
104 Layer.mergeAll(makeResolveOrganizationServices(dbHandle), DoTelemetryLive);111 makeResolveOrganizationServices(dbHandle);
105 112
106const resolveSessionMeta = Effect.fn("McpSessionDO.resolveSessionMeta")(function* (113const resolveSessionMeta = Effect.fn("McpSessionDO.resolveSessionMeta")(function* (
107 organizationId: string,114 organizationId: string,
@@ -164,89 +171,109 @@ export class McpSessionDO extends DurableObject { · 56 unmodified lines
164 ]);171 ]);
165 }172 }
166 173
167 private async createConnectedRuntime(174 private createConnectedRuntimeEffect(
168 sessionMeta: SessionMeta,175 sessionMeta: SessionMeta,
169 options: {176 options: { readonly dbHandle: DbHandle; readonly enableJsonResponse?: boolean },
170 readonly dbHandle: DbHandle;177 ) {
171 readonly enableJsonResponse?: boolean;178 const self = this;
172 },179 return Effect.gen(function* () {
173 ): Promise<{ mcpServer: McpServer; transport: WorkerTransport }> {180 const { executor, engine } = yield* makeExecutionStack(
174 const program = Effect.gen(function* () {
175 const { engine } = yield* makeExecutionStack(
176 sessionMeta.organizationId,181 sessionMeta.organizationId,
177 sessionMeta.organizationName,182 sessionMeta.organizationName,
178 );183 );
179 return yield* Effect.promise(() => createExecutorMcpServer({ engine }));184 // Build the description here so the two postgres queries it runs
180 }).pipe(Effect.withSpan("McpSessionDO.createRuntime"), Effect.provide(makeSessionServices(options.dbHandle)));185 // (`executor.sources.list` + `executor.tools.list`) land as
181 186 // children of `McpSessionDO.createRuntime`. host-mcp would
182 const mcpServer = await Effect.runPromise(program);187 // otherwise call `Effect.runPromise(engine.getDescription)` at
183 const transport = new WorkerTransport({188 // its async MCP-SDK boundary and orphan those sub-spans.
184 sessionIdGenerator: () => this.ctx.id.toString(),189 const description = yield* buildExecuteDescription(executor);
185 storage: this.makeStorage(),190 const mcpServer = yield* Effect.promise(() =>
186 enableJsonResponse: options.enableJsonResponse,191 createExecutorMcpServer({ engine, description }),
187 });192 ).pipe(Effect.withSpan("McpSessionDO.createExecutorMcpServer"));
188 193 const transport = new WorkerTransport({
189 await mcpServer.connect(transport);194 sessionIdGenerator: () => self.ctx.id.toString(),
190 return { mcpServer, transport };195 storage: self.makeStorage(),
196 enableJsonResponse: options.enableJsonResponse,
197 });
198 yield* Effect.promise(() => mcpServer.connect(transport)).pipe(
199 Effect.withSpan("McpSessionDO.transport.connect"),
200 );
201 return { mcpServer, transport };
202 }).pipe(
203 Effect.withSpan("McpSessionDO.createRuntime"),
204 Effect.provide(makeSessionServices(options.dbHandle)),
205 );
191 }206 }
192 207
193 private async resolveAndStoreSessionMeta(token: McpSessionInit): Promise<SessionMeta> {208 private resolveAndStoreSessionMetaEffect(token: McpSessionInit) {
194 const dbHandle = makeRequestScopedDb();209 const self = this;
195 try {210 return Effect.gen(function* () {
196 const sessionMeta = await Effect.runPromise(211 const dbHandle = makeRequestScopedDb();
197 resolveSessionMeta(token.organizationId).pipe(212 try {
213 const sessionMeta = yield* resolveSessionMeta(token.organizationId).pipe(
198 Effect.provide(makeResolveOrganizationServices(dbHandle)),214 Effect.provide(makeResolveOrganizationServices(dbHandle)),
199 ),215 );
200 );216 yield* Effect.promise(() => self.saveSessionMeta(sessionMeta));
201 await this.saveSessionMeta(sessionMeta);217 return sessionMeta;
202 return sessionMeta;218 } finally {
203 } finally {219 yield* Effect.promise(() => dbHandle.end());
204 await dbHandle.end();220 }
205 }221 });
206 }222 }
207 223
208 async init(token: McpSessionInit): Promise<void> {224 async init(token: McpSessionInit): Promise<void> {
209 if (this.initialized) return;225 if (this.initialized) return;
210 // Outer `McpSessionDO.init` span wraps the full session-bootstrap cost226 return Effect.runPromise(
211 // (resolveSessionMeta + createRuntime + alarm setup) so the MCP DO227 this.doInitEffect(token).pipe(
212 // dashboard's "new sessions" / "init p95" panels have one uniform span228 Effect.withSpan("McpSessionDO.init", {
213 // to filter on, regardless of whether the DO is running the long-lived229 attributes: { "mcp.auth.organization_id": token.organizationId },
214 // or request-scoped variant.230 }),
215 const program = Effect.promise(() => this.doInit(token)).pipe(231 Effect.provide(DoTelemetryLive),
216 Effect.withSpan("McpSessionDO.init", {232 ),
217 attributes: {
218 "mcp.auth.organization_id": token.organizationId,
219 },
220 }),
221 Effect.provide(DoTelemetryLive),
222 );233 );
223 return Effect.runPromise(program);
224 }234 }
225 235
226 private async doInit(token: McpSessionInit): Promise<void> {236 private doInitEffect(token: McpSessionInit) {
227 try {237 const self = this;
228 const sessionMeta = await this.resolveAndStoreSessionMeta(token);238 // Single Effect chain so every sub-span (resolveSessionMeta,
239 // createRuntime, createScopedExecutor, createExecutorMcpServer,
240 // transport.connect, storage.setAlarm) lands as a child of
241 // `McpSessionDO.init`. The prior implementation called
242 // `Effect.runPromise` nested inside an async function, which orphaned
243 // each sub-span into its own root trace and made init opaque —
244 // dashboard saw one 2.77s span with nothing under it.
245 return Effect.gen(function* () {
246 const sessionMeta = yield* self.resolveAndStoreSessionMetaEffect(token);
229 247
230 if (!requestScopedRuntimeEnabled) {248 if (!requestScopedRuntimeEnabled) {
231 this.dbHandle = makeLongLivedDb();249 self.dbHandle = makeLongLivedDb();
232 const runtime = await this.createConnectedRuntime(sessionMeta, {250 const runtime = yield* self.createConnectedRuntimeEffect(sessionMeta, {
233 dbHandle: this.dbHandle,251 dbHandle: self.dbHandle,
234 });252 });
235 this.mcpServer = runtime.mcpServer;253 self.mcpServer = runtime.mcpServer;
236 this.transport = runtime.transport;254 self.transport = runtime.transport;
237 }255 }
238 256
239 this.initialized = true;257 self.initialized = true;
240 this.lastActivityMs = Date.now();258 self.lastActivityMs = Date.now();
241 259
242 await this.ctx.storage.setAlarm(Date.now() + HEARTBEAT_MS);260 yield* Effect.promise(() => self.ctx.storage.setAlarm(Date.now() + HEARTBEAT_MS)).pipe(
243 } catch (err) {261 Effect.withSpan("McpSessionDO.setAlarm"),
244 // Partial init leaves dangling resources (DB socket, maybe mcpServer).262 );
245 // Clean up before rethrowing so the DO isn't stuck in a half-built state.263 }).pipe(
246 console.error("[mcp-session] init failed:", err instanceof Error ? err.stack : err);264 Effect.tapErrorCause((cause) =>
247 await this.cleanup();265 Effect.sync(() => {
248 throw err;266 console.error("[mcp-session] init failed:", cause);
249 }267 }),
268 ),
269 Effect.catchAllCause((cause) =>
270 Effect.gen(function* () {
271 yield* Effect.promise(() => self.cleanup());
272 return yield* Effect.failCause(cause);
273 }),
274 ),
275 Effect.orDie,
276 );
250 }277 }
251 278
252 private async handleRequestWithRequestScopedRuntime(request: Request): Promise<Response> {279 private async handleRequestWithRequestScopedRuntime(request: Request): Promise<Response> {
@@ -264,10 +291,12 @@ export class McpSessionDO extends DurableObject { · 11 unmodified lines
264 291
265 try {292 try {
266 dbHandle = makeRequestScopedDb();293 dbHandle = makeRequestScopedDb();
267 const runtime = await this.createConnectedRuntime(sessionMeta, {294 const runtime = await Effect.runPromise(
268 dbHandle,295 this.createConnectedRuntimeEffect(sessionMeta, {
269 enableJsonResponse: request.method !== "GET",296 dbHandle,
270 });297 enableJsonResponse: request.method !== "GET",
298 }).pipe(Effect.provide(DoTelemetryLive)),
299 );
271 mcpServer = runtime.mcpServer;300 mcpServer = runtime.mcpServer;
272 transport = runtime.transport;301 transport = runtime.transport;
273 302
@@ -281,6 +310,7 @@ export class McpSessionDO extends DurableObject { · 7 unmodified lines
281 "[mcp-session] request-scoped handleRequest error:",310 "[mcp-session] request-scoped handleRequest error:",
282 err instanceof Error ? err.stack : err,311 err instanceof Error ? err.stack : err,
283 );312 );
313 Sentry.captureException(err);
284 return jsonRpcError(500, -32603, "Internal error");314 return jsonRpcError(500, -32603, "Internal error");
285 } finally {315 } finally {
286 await transport?.close().catch(() => undefined);316 await transport?.close().catch(() => undefined);
@@ -331,6 +361,7 @@ export class McpSessionDO extends DurableObject { · 44 unmodified lines
331 return response;361 return response;
332 } catch (err) {362 } catch (err) {
333 console.error("[mcp-session] handleRequest error:", err instanceof Error ? err.stack : err);363 console.error("[mcp-session] handleRequest error:", err instanceof Error ? err.stack : err);
364 Sentry.captureException(err);
334 return jsonRpcError(500, -32603, "Internal error");365 return jsonRpcError(500, -32603, "Internal error");
335 }366 }
336 }367 }
apps/cloud/src/mcp.ts+43−1
@@ -180,9 +180,16 @@ const JsonRpcEnvelope = Schema.Struct({ · 179 unmodified lines
180 method: Schema.optional(Schema.String),180 method: Schema.optional(Schema.String),
181 id: Schema.optional(Schema.Union(Schema.String, Schema.Number, Schema.Null)),181 id: Schema.optional(Schema.Union(Schema.String, Schema.Number, Schema.Null)),
182 params: Schema.optional(UnknownRecord),182 params: Schema.optional(UnknownRecord),
183 // Responses to server-initiated requests arrive as POST bodies too —
184 // notably elicitation replies (`result.action = "accept" | "decline" | "cancel"`).
185 result: Schema.optional(UnknownRecord),
183});186});
184type JsonRpcEnvelope = typeof JsonRpcEnvelope.Type;187type JsonRpcEnvelope = typeof JsonRpcEnvelope.Type;
185 188
189const ElicitationReplyResult = Schema.Struct({
190 action: Schema.optional(Schema.Literal("accept", "decline", "cancel")),
191});
192
186const InitializeParams = Schema.Struct({193const InitializeParams = Schema.Struct({
187 protocolVersion: Schema.optional(Schema.String),194 protocolVersion: Schema.optional(Schema.String),
188 clientInfo: Schema.optional(195 clientInfo: Schema.optional(
@@ -247,6 +254,14 @@ const methodAttrs = (envelope: JsonRpcEnvelope): Record<string, unknown> => { · 58 unmodified lines
247 }254 }
248};255};
249 256
257const replyAttrs = (envelope: JsonRpcEnvelope): Record<string, unknown> => {
258 if (!envelope.result || envelope.method) return {};
259 return Option.match(decode(ElicitationReplyResult, envelope.result), {
260 onNone: () => ({}),
261 onSome: ({ action }) => (action ? { "mcp.elicitation.action": action } : {}),
262 });
263};
264
250const rpcAttrs = (envelope: Option.Option<JsonRpcEnvelope>): Record<string, unknown> =>265const rpcAttrs = (envelope: Option.Option<JsonRpcEnvelope>): Record<string, unknown> =>
251 Option.match(envelope, {266 Option.match(envelope, {
252 onNone: () => ({}),267 onNone: () => ({}),
@@ -254,6 +269,7 @@ const rpcAttrs = (envelope: Option.Option<JsonRpcEnvelope>): Record<string, unkn · 1 unmodified line
254 ...(e.method && { "mcp.rpc.method": e.method }),269 ...(e.method && { "mcp.rpc.method": e.method }),
255 ...(e.id !== undefined && e.id !== null && { "mcp.rpc.id": String(e.id) }),270 ...(e.id !== undefined && e.id !== null && { "mcp.rpc.id": String(e.id) }),
256 ...methodAttrs(e),271 ...methodAttrs(e),
272 ...replyAttrs(e),
257 }),273 }),
258 });274 });
259 275
@@ -331,10 +347,18 @@ const authorizationServerMetadata = Effect.promise(async () => { · 71 unmodified lines
331// onto a long-lived SSE channel we don't want to consume.347// onto a long-lived SSE channel we don't want to consume.
332// ---------------------------------------------------------------------------348// ---------------------------------------------------------------------------
333 349
350type SandboxOutcome = {
351 readonly status?: string;
352 readonly error?: { readonly kind?: string; readonly message?: string };
353};
354
334type JsonRpcErrorBody = {355type JsonRpcErrorBody = {
335 readonly jsonrpc?: string;356 readonly jsonrpc?: string;
336 readonly error?: { readonly code?: number; readonly message?: string };357 readonly error?: { readonly code?: number; readonly message?: string };
337 readonly result?: { readonly isError?: boolean };358 readonly result?: {
359 readonly isError?: boolean;
360 readonly structuredContent?: SandboxOutcome;
361 };
338};362};
339 363
340const parseFirstJsonRpc = (contentType: string, body: string): JsonRpcErrorBody | null => {364const parseFirstJsonRpc = (contentType: string, body: string): JsonRpcErrorBody | null => {
@@ -373,6 +397,14 @@ const rpcResponseAttrs = (payload: JsonRpcErrorBody | null): Record<string, unkn · 32 unmodified lines
373 if (payload.result?.isError === true) {397 if (payload.result?.isError === true) {
374 attrs["mcp.tool.result.is_error"] = true;398 attrs["mcp.tool.result.is_error"] = true;
375 }399 }
400 const sc = payload.result?.structuredContent;
401 if (sc && typeof sc.status === "string") {
402 attrs["mcp.tool.sandbox.status"] = sc.status;
403 if (sc.error?.kind) attrs["mcp.tool.sandbox.error.kind"] = sc.error.kind;
404 if (typeof sc.error?.message === "string") {
405 attrs["mcp.tool.sandbox.error.message"] = sc.error.message.slice(0, 500);
406 }
407 }
376 return attrs;408 return attrs;
377};409};
378 410
@@ -385,6 +417,16 @@ const peekAndAnnotate = (response: Response): Effect.Effect<Response> => · 6 unmodified lines
385 if (Object.keys(attrs).length > 0) {417 if (Object.keys(attrs).length > 0) {
386 yield* Effect.annotateCurrentSpan(attrs);418 yield* Effect.annotateCurrentSpan(attrs);
387 }419 }
420 // Internal-error code -32603 means our server failed handling a
421 // structurally valid request. Unlike -32601 / -32602 ("the client
422 // fucked up"), this is a real bug in our code — route to Sentry so
423 // we get alerted. Protocol-level client errors stay in Axiom.
424 if (payload?.error?.code === -32603) {
425 yield* Effect.sync(() => {
426 const msg = payload.error?.message ?? "unknown";
427 Sentry.captureException(new Error(`MCP internal error (-32603): ${msg}`));
428 });
429 }
388 return new Response(text, {430 return new Response(text, {
389 status: response.status,431 status: response.status,
390 statusText: response.statusText,432 statusText: response.statusText,
apps/cloud/src/services/autumn.ts+16−10
@@ -2,6 +2,7 @@ · 1 unmodified line
2// Autumn billing service — wraps the autumn-js SDK with Effect2// Autumn billing service — wraps the autumn-js SDK with Effect
3// ---------------------------------------------------------------------------3// ---------------------------------------------------------------------------
4 4
5import * as Sentry from "@sentry/cloudflare";
5import { Autumn } from "autumn-js";6import { Autumn } from "autumn-js";
6import { Context, Data, Effect, Layer } from "effect";7import { Context, Data, Effect, Layer } from "effect";
7 8
@@ -56,16 +57,21 @@ const make = Effect.sync(() => { · 48 unmodified lines
56 }).pipe(Effect.withSpan(`autumn.${fn.name ?? "use"}`));57 }).pipe(Effect.withSpan(`autumn.${fn.name ?? "use"}`));
57 58
58 const trackExecution = (organizationId: string) =>59 const trackExecution = (organizationId: string) =>
59 use((c) =>60 Effect.gen(function* () {
60 c.track({ customerId: organizationId, featureId: "executions", value: 1 }),61 yield* Effect.annotateCurrentSpan({ "autumn.customer.id": organizationId });
61 ).pipe(62 const outcome = yield* Effect.either(
62 Effect.asVoid,63 use((c) =>
63 Effect.tapError((err) =>64 c.track({ customerId: organizationId, featureId: "executions", value: 1 }),
64 Effect.sync(() => console.error("[billing] track failed:", err)),65 ),
65 ),66 );
66 Effect.orElse(() => Effect.void),67 if (outcome._tag === "Left") {
67 Effect.withSpan("autumn.trackExecution"),68 // Silent billing data loss is worth paging on — autumn.trackExecution
68 );69 // is fire-and-forget so the caller doesn't handle it themselves.
70 console.error("[billing] track failed:", outcome.left);
71 Sentry.captureException(outcome.left);
72 yield* Effect.annotateCurrentSpan({ "autumn.track.failed": true });
73 }
74 }).pipe(Effect.withSpan("autumn.trackExecution"));
69 75
70 return { use, trackExecution } satisfies IAutumnService;76 return { use, trackExecution } satisfies IAutumnService;
71});77});
apps/cloud/src/services/execution-stack.ts+4−2
@@ -16,7 +16,9 @@ import { createScopedExecutor } from "./executor"; · 15 unmodified lines
16 16
17export const makeExecutionStack = (organizationId: string, organizationName: string) =>17export const makeExecutionStack = (organizationId: string, organizationName: string) =>
18 Effect.gen(function* () {18 Effect.gen(function* () {
19 const executor = yield* createScopedExecutor(organizationId, organizationName);19 const executor = yield* createScopedExecutor(organizationId, organizationName).pipe(
20 Effect.withSpan("McpSessionDO.createScopedExecutor"),
21 );
20 const codeExecutor = makeDynamicWorkerExecutor({ loader: env.LOADER });22 const codeExecutor = makeDynamicWorkerExecutor({ loader: env.LOADER });
21 const autumn = yield* AutumnService;23 const autumn = yield* AutumnService;
22 const engine = withExecutionUsageTracking(24 const engine = withExecutionUsageTracking(
@@ -25,4 +27,4 @@ export const makeExecutionStack = (organizationId: string, organizationName: str · 2 unmodified lines
25 (orgId) => Effect.runFork(autumn.trackExecution(orgId)),27 (orgId) => Effect.runFork(autumn.trackExecution(orgId)),
26 );28 );
27 return { executor, engine };29 return { executor, engine };
28 });30 }).pipe(Effect.withSpan("McpSessionDO.makeExecutionStack"));
packages/core/api/src/handlers/executions.ts+5−7
@@ -11,7 +11,7 @@ export const ExecutionsHandlers = HttpApiBuilder.group(ExecutorApi, "executions" · 10 unmodified lines
11 .handle("execute", ({ payload }) =>11 .handle("execute", ({ payload }) =>
12 capture(Effect.gen(function* () {12 capture(Effect.gen(function* () {
13 const engine = yield* ExecutionEngineService;13 const engine = yield* ExecutionEngineService;
14 const outcome = yield* Effect.promise(() => engine.executeWithPause(payload.code));14 const outcome = yield* engine.executeWithPause(payload.code);
15 15
16 if (outcome.status === "completed") {16 if (outcome.status === "completed") {
17 const formatted = formatExecuteResult(outcome.result);17 const formatted = formatExecuteResult(outcome.result);
@@ -34,12 +34,10 @@ export const ExecutionsHandlers = HttpApiBuilder.group(ExecutorApi, "executions" · 16 unmodified lines
34 .handle("resume", ({ path, payload }) =>34 .handle("resume", ({ path, payload }) =>
35 capture(Effect.gen(function* () {35 capture(Effect.gen(function* () {
36 const engine = yield* ExecutionEngineService;36 const engine = yield* ExecutionEngineService;
37 const result = yield* Effect.promise(() =>37 const result = yield* engine.resume(path.executionId, {
38 engine.resume(path.executionId, {38 action: payload.action,
39 action: payload.action,39 content: payload.content as Record<string, unknown> | undefined,
40 content: payload.content as Record<string, unknown> | undefined,40 });
41 }),
42 );
43 41
44 if (!result) {42 if (!result) {
45 return yield* Effect.fail({43 return yield* Effect.fail({
packages/core/api/src/services.ts+7−1
@@ -1,4 +1,5 @@
1import { Context } from "effect";1import { Context } from "effect";
2import type * as Cause from "effect/Cause";
2import type { Executor } from "@executor/sdk";3import type { Executor } from "@executor/sdk";
3import type { ExecutionEngine } from "@executor/execution";4import type { ExecutionEngine } from "@executor/execution";
4 5
@@ -7,7 +8,12 @@ export class ExecutorService extends Context.Tag("ExecutorService")< · 2 unmodified lines
7 Executor8 Executor
8>() {}9>() {}
9 10
11// Error channel widened to `Cause.YieldableError` so callers that plug
12// in a runtime-specific tagged error (e.g.
13// `ExecutionEngine<DynamicWorkerExecutionError>`) assign structurally.
14// Handlers yield directly; defects flow through `Effect.catchAllCause`
15// at the edge.
10export class ExecutionEngineService extends Context.Tag("ExecutionEngineService")<16export class ExecutionEngineService extends Context.Tag("ExecutionEngineService")<
11 ExecutionEngineService,17 ExecutionEngineService,
12 ExecutionEngine18 ExecutionEngine<Cause.YieldableError>
13>() {}19>() {}
packages/core/execution/src/description.ts+7−3
@@ -10,14 +10,18 @@ import type { Executor, Tool, Source } from "@executor/sdk"; · 9 unmodified lines
10 */10 */
11export const buildExecuteDescription = (executor: Executor): Effect.Effect<string> =>11export const buildExecuteDescription = (executor: Executor): Effect.Effect<string> =>
12 Effect.gen(function* () {12 Effect.gen(function* () {
13 const sources: readonly Source[] = yield* executor.sources.list().pipe(Effect.orDie);13 const sources: readonly Source[] = yield* executor.sources
14 const tools: readonly Tool[] = yield* executor.tools.list().pipe(Effect.orDie);14 .list()
15 .pipe(Effect.orDie, Effect.withSpan("executor.sources.list"));
16 const tools: readonly Tool[] = yield* executor.tools
17 .list()
18 .pipe(Effect.orDie, Effect.withSpan("executor.tools.list"));
15 19
16 const namespaces = new Set<string>();20 const namespaces = new Set<string>();
17 for (const tool of tools) namespaces.add(tool.sourceId);21 for (const tool of tools) namespaces.add(tool.sourceId);
18 22
19 return formatDescription([...namespaces], sources);23 return formatDescription([...namespaces], sources);
20 });24 }).pipe(Effect.withSpan("buildExecuteDescription"));
21 25
22const formatDescription = (namespaces: readonly string[], sources: readonly Source[]): string => {26const formatDescription = (namespaces: readonly string[], sources: readonly Source[]): string => {
23 const lines: string[] = [27 const lines: string[] = [
packages/core/execution/src/engine.ts+35−30
@@ -1,4 +1,5 @@
1import { Deferred, Effect, Fiber, Ref } from "effect";1import { Deferred, Effect, Fiber, Ref } from "effect";
2import type * as Cause from "effect/Cause";
2 3
3import type {4import type {
4 Executor,5 Executor,
@@ -7,6 +8,7 @@ import type { · 2 unmodified lines
7 ElicitationHandler,8 ElicitationHandler,
8 ElicitationContext,9 ElicitationContext,
9} from "@executor/sdk";10} from "@executor/sdk";
11import { CodeExecutionError } from "@executor/codemode-core";
10import type { CodeExecutor, ExecuteResult, SandboxToolInvoker } from "@executor/codemode-core";12import type { CodeExecutor, ExecuteResult, SandboxToolInvoker } from "@executor/codemode-core";
11 13
12import {14import {
@@ -22,9 +24,11 @@ import { buildExecuteDescription } from "./description"; · 9 unmodified lines
22// Types24// Types
23// ---------------------------------------------------------------------------25// ---------------------------------------------------------------------------
24 26
25export type ExecutionEngineConfig = {27export type ExecutionEngineConfig<
28 E extends Cause.YieldableError = CodeExecutionError,
29> = {
26 readonly executor: Executor;30 readonly executor: Executor;
27 readonly codeExecutor: CodeExecutor;31 readonly codeExecutor: CodeExecutor<E>;
28};32};
29 33
30export type ExecutionResult =34export type ExecutionResult =
@@ -37,10 +41,10 @@ export type PausedExecution = { · 6 unmodified lines
37};41};
38 42
39/** Internal representation with Effect runtime state for pause/resume. */43/** Internal representation with Effect runtime state for pause/resume. */
40type InternalPausedExecution = PausedExecution & {44type InternalPausedExecution<E> = PausedExecution & {
41 readonly response: Deferred.Deferred<typeof ElicitationResponse.Type>;45 readonly response: Deferred.Deferred<typeof ElicitationResponse.Type>;
42 readonly fiber: Fiber.Fiber<ExecuteResult, unknown>;46 readonly fiber: Fiber.Fiber<ExecuteResult, E>;
43 readonly pauseSignalRef: Ref.Ref<Deferred.Deferred<InternalPausedExecution>>;47 readonly pauseSignalRef: Ref.Ref<Deferred.Deferred<InternalPausedExecution<E>>>;
44};48};
45 49
46export type ResumeResponse = {50export type ResumeResponse = {
@@ -255,22 +259,26 @@ const makeFullInvoker = (executor: Executor, invokeOptions: InvokeOptions): Sand · 208 unmodified lines
255// Execution Engine259// Execution Engine
256// ---------------------------------------------------------------------------260// ---------------------------------------------------------------------------
257 261
258export type ExecutionEngine = {262export type ExecutionEngine<E extends Cause.YieldableError = CodeExecutionError> = {
259 /**263 /**
260 * Execute code with elicitation handled inline by the provided handler.264 * Execute code with elicitation handled inline by the provided handler.
261 * Use this when the host supports elicitation (e.g. MCP with elicitation capability).265 * Use this when the host supports elicitation (e.g. MCP with elicitation capability).
266 *
267 * Fails with the code executor's typed error `E` (defaults to
268 * `CodeExecutionError`). Runtimes surface their own `Data.TaggedError`
269 * subclass, which flows through here unchanged.
262 */270 */
263 readonly execute: (271 readonly execute: (
264 code: string,272 code: string,
265 options: { readonly onElicitation: ElicitationHandler },273 options: { readonly onElicitation: ElicitationHandler },
266 ) => Promise<ExecuteResult>;274 ) => Effect.Effect<ExecuteResult, E>;
267 275
268 /**276 /**
269 * Execute code, intercepting the first elicitation as a pause point.277 * Execute code, intercepting the first elicitation as a pause point.
270 * Use this when the host doesn't support inline elicitation.278 * Use this when the host doesn't support inline elicitation.
271 * Returns either a completed result or a paused execution that can be resumed.279 * Returns either a completed result or a paused execution that can be resumed.
272 */280 */
273 readonly executeWithPause: (code: string) => Promise<ExecutionResult>;281 readonly executeWithPause: (code: string) => Effect.Effect<ExecutionResult, E>;
274 282
275 /**283 /**
276 * Resume a paused execution. Returns a completed result, a new pause, or284 * Resume a paused execution. Returns a completed result, a new pause, or
@@ -279,20 +287,21 @@ export type ExecutionEngine = { · 2 unmodified lines
279 readonly resume: (287 readonly resume: (
280 executionId: string,288 executionId: string,
281 response: ResumeResponse,289 response: ResumeResponse,
282 ) => Promise<ExecutionResult | null>;290 ) => Effect.Effect<ExecutionResult | null, E>;
283 291
284 /**292 /**
285 * Get the dynamic tool description (workflow + namespaces).293 * Get the dynamic tool description (workflow + namespaces).
286 */294 */
287 readonly getDescription: () => Promise<string>;295 readonly getDescription: Effect.Effect<string>;
288};296};
289 297
290const runEffect = <A>(effect: Effect.Effect<A, unknown>): Promise<A> =>298export const createExecutionEngine = <
291 Effect.runPromise(effect as Effect.Effect<A, never>);299 E extends Cause.YieldableError = CodeExecutionError,
292 300>(
293export const createExecutionEngine = (config: ExecutionEngineConfig): ExecutionEngine => {301 config: ExecutionEngineConfig<E>,
302): ExecutionEngine<E> => {
294 const { executor, codeExecutor } = config;303 const { executor, codeExecutor } = config;
295 const pausedExecutions = new Map<string, InternalPausedExecution>();304 const pausedExecutions = new Map<string, InternalPausedExecution<E>>();
296 let nextId = 0;305 let nextId = 0;
297 306
298 /**307 /**
@@ -301,12 +310,11 @@ export const createExecutionEngine = (config: ExecutionEngineConfig): ExecutionE · 2 unmodified lines
301 * comes first). Re-used by both executeWithPause and resume.310 * comes first). Re-used by both executeWithPause and resume.
302 */311 */
303 const awaitCompletionOrPause = (312 const awaitCompletionOrPause = (
304 fiber: Fiber.Fiber<ExecuteResult, unknown>,313 fiber: Fiber.Fiber<ExecuteResult, E>,
305 pauseSignal: Deferred.Deferred<InternalPausedExecution>,314 pauseSignal: Deferred.Deferred<InternalPausedExecution<E>>,
306 ): Effect.Effect<ExecutionResult> =>315 ): Effect.Effect<ExecutionResult, E> =>
307 Effect.race(316 Effect.race(
308 Fiber.join(fiber).pipe(317 Fiber.join(fiber).pipe(
309 Effect.orDie,
310 Effect.map((result): ExecutionResult => ({ status: "completed", result })),318 Effect.map((result): ExecutionResult => ({ status: "completed", result })),
311 ),319 ),
312 Deferred.await(pauseSignal).pipe(320 Deferred.await(pauseSignal).pipe(
@@ -329,17 +337,17 @@ export const createExecutionEngine = (config: ExecutionEngineConfig): ExecutionE · 16 unmodified lines
329 // Ref holds the current pause signal. The elicitation handler reads337 // Ref holds the current pause signal. The elicitation handler reads
330 // it each time it fires, so resume() can swap in a fresh Deferred338 // it each time it fires, so resume() can swap in a fresh Deferred
331 // before unblocking the fiber.339 // before unblocking the fiber.
332 const pauseSignalRef = yield* Ref.make(yield* Deferred.make<InternalPausedExecution>());340 const pauseSignalRef = yield* Ref.make(yield* Deferred.make<InternalPausedExecution<E>>());
333 341
334 // Will be set once the fiber is forked.342 // Will be set once the fiber is forked.
335 let fiber: Fiber.Fiber<ExecuteResult, unknown>;343 let fiber: Fiber.Fiber<ExecuteResult, E>;
336 344
337 const elicitationHandler: ElicitationHandler = (ctx) =>345 const elicitationHandler: ElicitationHandler = (ctx) =>
338 Effect.gen(function* () {346 Effect.gen(function* () {
339 const responseDeferred = yield* Deferred.make<typeof ElicitationResponse.Type>();347 const responseDeferred = yield* Deferred.make<typeof ElicitationResponse.Type>();
340 const id = `exec_${++nextId}`;348 const id = `exec_${++nextId}`;
341 349
342 const paused: InternalPausedExecution = {350 const paused: InternalPausedExecution<E> = {
343 id,351 id,
344 elicitationContext: ctx,352 elicitationContext: ctx,
345 response: responseDeferred,353 response: responseDeferred,
@@ -383,7 +391,7 @@ export const createExecutionEngine = (config: ExecutionEngineConfig): ExecutionE · 37 unmodified lines
383 391
384 // Swap in a fresh pause signal BEFORE unblocking the fiber, so the392 // Swap in a fresh pause signal BEFORE unblocking the fiber, so the
385 // next elicitation handler call signals this new Deferred.393 // next elicitation handler call signals this new Deferred.
386 const nextSignal = yield* Deferred.make<InternalPausedExecution>();394 const nextSignal = yield* Deferred.make<InternalPausedExecution<E>>();
387 yield* Ref.set(paused.pauseSignalRef, nextSignal);395 yield* Ref.set(paused.pauseSignalRef, nextSignal);
388 396
389 yield* Deferred.succeed(paused.response, {397 yield* Deferred.succeed(paused.response, {
@@ -415,12 +423,9 @@ export const createExecutionEngine = (config: ExecutionEngineConfig): ExecutionE · 25 unmodified lines
415 });423 });
416 424
417 return {425 return {
418 execute: (code, options) => runEffect(runInlineExecution(code, options)),426 execute: runInlineExecution,
419 427 executeWithPause: startPausableExecution,
420 executeWithPause: (code) => runEffect(startPausableExecution(code)),428 resume: resumeExecution,
421 429 getDescription: buildExecuteDescription(executor),
422 resume: (executionId, response) => runEffect(resumeExecution(executionId, response)),
423
424 getDescription: () => runEffect(buildExecuteDescription(executor)),
425 };430 };
426};431};
packages/core/execution/src/errors.ts+5−0
@@ -4,3 +4,8 @@ export class ExecutionToolError extends Data.TaggedError("ExecutionToolError")<{ · 3 unmodified lines
4 readonly message: string;4 readonly message: string;
5 readonly cause?: unknown;5 readonly cause?: unknown;
6}> {}6}> {}
7
8// `CodeExecutionError` lives in `@executor/codemode-core` — the `CodeExecutor`
9// interface uses it as the default error channel, so the runtime packages
10// can import the same class directly.
11export { CodeExecutionError } from "@executor/codemode-core";
packages/core/execution/src/promise.ts+45−30
@@ -1,24 +1,28 @@
1// ---------------------------------------------------------------------------1// ---------------------------------------------------------------------------
2// @executor/execution/promise — Promise-native surface for the execution2// @executor/execution/promise — Promise-native surface for the execution
3// engine. Accepts a Promise-style Executor (from @executor/sdk) and an3// engine.
4// async `onElicitation` handler — no Effect imports required by callers.4// ---------------------------------------------------------------------------
5//5//
6// Under the hood the engine is Effect-based, so we wrap the incoming6// `engine.ts` is Effect-native; this module runs each method with
7// Promise executor into the minimal Effect shape the engine consumes,7// `Effect.runPromise` at the boundary so hosts that can't compose Effects
8// and bridge the elicitation handler via `Effect.tryPromise`.8// (the MCP SDK tool handlers, plain async call sites) can still use the
9// engine. Callers already inside an Effect context should import directly
10// from `@executor/execution` to keep trace context intact.
9// ---------------------------------------------------------------------------11// ---------------------------------------------------------------------------
10 12
11import { Effect } from "effect";13import { Effect } from "effect";
14import type * as Cause from "effect/Cause";
12 15
13import type {16import type {
14 ElicitationContext,17 ElicitationContext,
15 ElicitationResponse,18 ElicitationResponse,
16 Executor as PromiseExecutor,19 Executor as PromiseExecutor,
17} from "@executor/sdk";20} from "@executor/sdk";
18import type { CodeExecutor, ExecuteResult } from "@executor/codemode-core";21import type { CodeExecutionError, CodeExecutor, ExecuteResult } from "@executor/codemode-core";
19 22
20import {23import {
21 createExecutionEngine as createEffectExecutionEngine,24 createExecutionEngine as createEffectExecutionEngine,
25 type ExecutionEngine as EffectExecutionEngine,
22 type ExecutionResult,26 type ExecutionResult,
23 type PausedExecution,27 type PausedExecution,
24 type ResumeResponse,28 type ResumeResponse,
@@ -28,9 +32,11 @@ export type ElicitationHandler = ( · 3 unmodified lines
28 ctx: ElicitationContext,32 ctx: ElicitationContext,
29) => Promise<ElicitationResponse>;33) => Promise<ElicitationResponse>;
30 34
31export type ExecutionEngineConfig = {35export type ExecutionEngineConfig<
36 E extends Cause.YieldableError = CodeExecutionError,
37> = {
32 readonly executor: PromiseExecutor;38 readonly executor: PromiseExecutor;
33 readonly codeExecutor: CodeExecutor;39 readonly codeExecutor: CodeExecutor<E>;
34};40};
35 41
36export type ExecutionEngine = {42export type ExecutionEngine = {
@@ -49,7 +55,7 @@ export type ExecutionEngine = { · 12 unmodified lines
49/**55/**
50 * Wrap a Promise-style executor into the Effect shape the engine consumes.56 * Wrap a Promise-style executor into the Effect shape the engine consumes.
51 * Only the four method families the engine actually touches need wrapping:57 * Only the four method families the engine actually touches need wrapping:
52 * `tools.{invoke,list,schema}` and `sources.list`.58 * `tools.{invoke,list,schema,definitions}` and `sources.list`.
53 */59 */
54const wrapPromiseExecutor = (pe: PromiseExecutor): any => ({60const wrapPromiseExecutor = (pe: PromiseExecutor): any => ({
55 scope: (pe as any).scope,61 scope: (pe as any).scope,
@@ -84,35 +90,44 @@ const wrapPromiseExecutor = (pe: PromiseExecutor): any => ({ · 28 unmodified lines
84 },90 },
85});91});
86 92
87export const createExecutionEngine = (93/**
88 config: ExecutionEngineConfig,94 * Promise-wrap an Effect-native `ExecutionEngine` (from `./engine`).
89): ExecutionEngine => {95 * Exposed separately so callers that already hold an Effect engine
90 const engine = createEffectExecutionEngine({96 * (apps/cloud's execution-stack composes both) can convert it for hosts
91 executor: wrapPromiseExecutor(config.executor),97 * that need the Promise surface (host-mcp).
92 codeExecutor: config.codeExecutor,98 */
93 });99export const toPromiseExecutionEngine = <E extends Cause.YieldableError>(
94 return {100 engine: EffectExecutionEngine<E>,
95 execute: (code, options) =>101): ExecutionEngine => ({
102 execute: (code, options) =>
103 Effect.runPromise(
96 engine.execute(code, {104 engine.execute(code, {
97 onElicitation: (ctx) =>105 onElicitation: (ctx) =>
98 Effect.tryPromise(() => options.onElicitation(ctx)).pipe(106 Effect.tryPromise(() => options.onElicitation(ctx)).pipe(Effect.orDie),
99 Effect.orDie,
100 ),
101 }),107 }),
102 executeWithPause: (code) => engine.executeWithPause(code),108 ),
103 resume: (executionId, response) => engine.resume(executionId, response),109 executeWithPause: (code) => Effect.runPromise(engine.executeWithPause(code)),
104 getDescription: () => engine.getDescription(),110 resume: (executionId, response) => Effect.runPromise(engine.resume(executionId, response)),
105 };111 getDescription: () => Effect.runPromise(engine.getDescription),
106};112});
113
114export const createExecutionEngine = <
115 E extends Cause.YieldableError = CodeExecutionError,
116>(
117 config: ExecutionEngineConfig<E>,
118): ExecutionEngine =>
119 toPromiseExecutionEngine(
120 createEffectExecutionEngine({
121 executor: wrapPromiseExecutor(config.executor),
122 codeExecutor: config.codeExecutor,
123 }),
124 );
107 125
108// ---------------------------------------------------------------------------126// ---------------------------------------------------------------------------
109// Re-exports — plain types/helpers that don't carry Effect signatures.127// Re-exports — plain types/helpers that don't carry Effect signatures.
110// ---------------------------------------------------------------------------128// ---------------------------------------------------------------------------
111 129
112export {130export { formatExecuteResult, formatPausedExecution } from "./engine";
113 formatExecuteResult,
114 formatPausedExecution,
115} from "./engine";
116 131
117export type { ExecutionResult, PausedExecution, ResumeResponse };132export type { ExecutionResult, PausedExecution, ResumeResponse };
118 133
packages/core/execution/src/tool-invoker.test.ts+39−51
@@ -150,11 +150,9 @@ describe("tool discovery", () => { · 149 unmodified lines
150 });150 });
151 expect(crmOnly.map((match) => match.path)).toEqual(["crm.listContacts"]);151 expect(crmOnly.map((match) => match.path)).toEqual(["crm.listContacts"]);
152 152
153 const sandboxResult = yield* Effect.promise(() =>153 const sandboxResult = yield* createExecutionEngine({ executor, codeExecutor }).execute(
154 createExecutionEngine({ executor, codeExecutor }).execute(154 'return await tools.search({ namespace: "crm", query: "create contact", limit: 5 });',
155 'return await tools.search({ namespace: "crm", query: "create contact", limit: 5 });',155 { onElicitation: acceptAll },
156 { onElicitation: acceptAll },
157 ),
158 );156 );
159 expect(sandboxResult.error).toBeUndefined();157 expect(sandboxResult.error).toBeUndefined();
160 expect(sandboxResult.result).toEqual([158 expect(sandboxResult.result).toEqual([
@@ -167,10 +165,9 @@ describe("tool discovery", () => { · 6 unmodified lines
167 Effect.gen(function* () {165 Effect.gen(function* () {
168 const executor = yield* makeSearchExecutor();166 const executor = yield* makeSearchExecutor();
169 167
170 const listed = yield* Effect.promise(() =>168 const listed = yield* createExecutionEngine({ executor, codeExecutor }).execute(
171 createExecutionEngine({ executor, codeExecutor }).execute("return await tools.executor.sources.list();", {169 "return await tools.executor.sources.list();",
172 onElicitation: acceptAll,170 { onElicitation: acceptAll },
173 }),
174 );171 );
175 expect(listed.error).toBeUndefined();172 expect(listed.error).toBeUndefined();
176 expect(listed.result).toEqual(173 expect(listed.result).toEqual(
@@ -180,11 +177,9 @@ describe("tool discovery", () => { · 3 unmodified lines
180 ]),177 ]),
181 );178 );
182 179
183 const searched = yield* Effect.promise(() =>180 const searched = yield* createExecutionEngine({ executor, codeExecutor }).execute(
184 createExecutionEngine({ executor, codeExecutor }).execute(181 'return await tools.search({ query: "list contacts", namespace: "crm", limit: 5 });',
185 'return await tools.search({ query: "list contacts", namespace: "crm", limit: 5 });',182 { onElicitation: acceptAll },
186 { onElicitation: acceptAll },
187 ),
188 );183 );
189 expect(searched.error).toBeUndefined();184 expect(searched.error).toBeUndefined();
190 expect(searched.result).toEqual([expect.objectContaining({ path: "crm.listContacts" })]);185 expect(searched.result).toEqual([expect.objectContaining({ path: "crm.listContacts" })]);
@@ -210,55 +205,48 @@ describe("tool discovery", () => { · 19 unmodified lines
210 const executor = yield* makeSearchExecutor();205 const executor = yield* makeSearchExecutor();
211 const engine = createExecutionEngine({ executor, codeExecutor });206 const engine = createExecutionEngine({ executor, codeExecutor });
212 207
213 const invalid = yield* Effect.promise(() =>208 const invalid = yield* engine.execute(
214 engine.execute(209 [
215 [210 "try {",
216 "try {",211 ' await tools.search("github issues");',
217 ' await tools.search("github issues");',212 ' return "unexpected";',
218 ' return "unexpected";',213 "} catch (error) {",
219 "} catch (error) {",214 " return error instanceof Error ? error.message : String(error);",
220 " return error instanceof Error ? error.message : String(error);",215 "}",
221 "}",216 ].join("\n"),
222 ].join("\n"),217 { onElicitation: acceptAll },
223 { onElicitation: acceptAll },
224 ),
225 );218 );
226 expect(invalid.error).toBeUndefined();219 expect(invalid.error).toBeUndefined();
227 expect(String(invalid.result)).toContain(220 expect(String(invalid.result)).toContain(
228 "tools.search expects an object: { query?: string; namespace?: string; limit?: number }",221 "tools.search expects an object: { query?: string; namespace?: string; limit?: number }",
229 );222 );
230 223
231 const emptyQuery = yield* Effect.promise(() =>224 const emptyQuery = yield* engine.execute(
232 engine.execute('return await tools.search({ query: "", limit: 5 });', {225 'return await tools.search({ query: "", limit: 5 });',
233 onElicitation: acceptAll,226 { onElicitation: acceptAll },
234 }),
235 );227 );
236 expect(emptyQuery.error).toBeUndefined();228 expect(emptyQuery.error).toBeUndefined();
237 expect(emptyQuery.result).toEqual([]);229 expect(emptyQuery.result).toEqual([]);
238 230
239 const invalidDescribe = yield* Effect.promise(() =>231 const invalidDescribe = yield* engine.execute(
240 engine.execute(232 [
241 [233 "try {",
242 "try {",234 ' await tools.describe.tool({ path: "github.listRepositoryIssues", includeSchemas: true });',
243 ' await tools.describe.tool({ path: "github.listRepositoryIssues", includeSchemas: true });',235 ' return "unexpected";',
244 ' return "unexpected";',236 "} catch (error) {",
245 "} catch (error) {",237 " return error instanceof Error ? error.message : String(error);",
246 " return error instanceof Error ? error.message : String(error);",238 "}",
247 "}",239 ].join("\n"),
248 ].join("\n"),240 { onElicitation: acceptAll },
249 { onElicitation: acceptAll },
250 ),
251 );241 );
252 expect(invalidDescribe.error).toBeUndefined();242 expect(invalidDescribe.error).toBeUndefined();
253 expect(String(invalidDescribe.result)).toContain(243 expect(String(invalidDescribe.result)).toContain(
254 "tools.describe.tool no longer accepts includeSchemas",244 "tools.describe.tool no longer accepts includeSchemas",
255 );245 );
256 246
257 const invalidSearch = yield* Effect.promise(() =>247 const invalidSearch = yield* engine.execute(
258 engine.execute(248 'try { return await tools.search("crm"); } catch (error) { return error instanceof Error ? error.message : String(error); }',
259 'try { return await tools.search("crm"); } catch (error) { return error instanceof Error ? error.message : String(error); }',249 { onElicitation: acceptAll },
260 { onElicitation: acceptAll },
261 ),
262 );250 );
263 expect(invalidSearch.error).toBeUndefined();251 expect(invalidSearch.error).toBeUndefined();
264 expect(String(invalidSearch.result)).toContain("tools.search expects an object");252 expect(String(invalidSearch.result)).toContain("tools.search expects an object");
@@ -334,7 +322,7 @@ describe("pause/resume with multiple elicitations", () => { · 69 unmodified lines
334 322
335 const code = "return await tools.api.multiApproval({});";323 const code = "return await tools.api.multiApproval({});";
336 324
337 const outcome1 = yield* Effect.promise(() => engine.executeWithPause(code));325 const outcome1 = yield* engine.executeWithPause(code);
338 expect(outcome1.status).toBe("paused");326 expect(outcome1.status).toBe("paused");
339 const paused1 = outcome1 as Extract<typeof outcome1, { status: "paused" }>;327 const paused1 = outcome1 as Extract<typeof outcome1, { status: "paused" }>;
340 expect(paused1.execution.elicitationContext.request.message).toBe("First approval");328 expect(paused1.execution.elicitationContext.request.message).toBe("First approval");
@@ -344,7 +332,7 @@ describe("pause/resume with multiple elicitations", () => { · 3 unmodified lines
344 // result or the completion).332 // result or the completion).
345 const outcome2 = yield* Effect.promise(() =>333 const outcome2 = yield* Effect.promise(() =>
346 Promise.race([334 Promise.race([
347 engine.resume(paused1.execution.id, { action: "accept" }),335 Effect.runPromise(engine.resume(paused1.execution.id, { action: "accept" })),
348 new Promise<never>((_, reject) =>336 new Promise<never>((_, reject) =>
349 setTimeout(337 setTimeout(
350 () => reject(new Error("resume hung — second elicitation not surfaced")),338 () => reject(new Error("resume hung — second elicitation not surfaced")),
@@ -368,7 +356,7 @@ describe("pause/resume with multiple elicitations", () => { · 17 unmodified lines
368 356
369 const code = "return await tools.api.singleApproval({});";357 const code = "return await tools.api.singleApproval({});";
370 358
371 const outcome1 = await engine.executeWithPause(code);359 const outcome1 = await Effect.runPromise(engine.executeWithPause(code));
372 expect(outcome1.status).toBe("paused");360 expect(outcome1.status).toBe("paused");
373 const paused1 = outcome1 as Extract<typeof outcome1, { status: "paused" }>;361 const paused1 = outcome1 as Extract<typeof outcome1, { status: "paused" }>;
374 expect(paused1.execution.elicitationContext.request.message).toBe("Only approval");362 expect(paused1.execution.elicitationContext.request.message).toBe("Only approval");
@@ -389,7 +377,7 @@ describe("pause/resume with multiple elicitations", () => { · 14 unmodified lines
389 expect(exitProbe).toBe("still-running");377 expect(exitProbe).toBe("still-running");
390 378
391 const outcome2 = await Promise.race([379 const outcome2 = await Promise.race([
392 engine.resume(paused1.execution.id, { action: "accept" }),380 Effect.runPromise(engine.resume(paused1.execution.id, { action: "accept" })),
393 new Promise<never>((_, reject) =>381 new Promise<never>((_, reject) =>
394 setTimeout(() => reject(new Error("resume hung across runPromise boundaries")), 2000),382 setTimeout(() => reject(new Error("resume hung across runPromise boundaries")), 2000),
395 ),383 ),
packages/hosts/mcp/src/server.ts+28−13
@@ -14,6 +14,7 @@ import type { · 13 unmodified lines
14 ElicitationContext,14 ElicitationContext,
15 ElicitationRequest,15 ElicitationRequest,
16} from "@executor/sdk";16} from "@executor/sdk";
17import type * as Cause from "effect/Cause";
17import {18import {
18 createExecutionEngine,19 createExecutionEngine,
19 formatExecuteResult,20 formatExecuteResult,
@@ -44,11 +45,23 @@ class CfWorkerJsonSchemaValidator implements jsonSchemaValidator { · 24 unmodified lines
44// Config45// Config
45// ---------------------------------------------------------------------------46// ---------------------------------------------------------------------------
46 47
47export type ExecutorMcpServerConfig =48type SharedMcpServerConfig = {
48 | ExecutionEngineConfig49 /**
49 | { readonly engine: ExecutionEngine }50 * Pre-built `execute` tool description. When provided, the factory skips
50 | (ExecutionEngineConfig & { readonly stateless: true })51 * its internal `engine.getDescription()` call. Useful when the caller
51 | { readonly engine: ExecutionEngine; readonly stateless: true };52 * wants to compute the description inside its own Effect tracer context
53 * so sub-spans (`executor.sources.list`, `executor.tools.list`) nest as
54 * children of the caller's root span instead of being orphaned by the
55 * `Effect.runPromise` that `engine.getDescription()` runs internally.
56 */
57 readonly description?: string;
58};
59
60export type ExecutorMcpServerConfig<E extends Cause.YieldableError = Cause.YieldableError> =
61 | (ExecutionEngineConfig<E> & SharedMcpServerConfig)
62 | ({ readonly engine: ExecutionEngine<E> } & SharedMcpServerConfig)
63 | (ExecutionEngineConfig<E> & SharedMcpServerConfig & { readonly stateless: true })
64 | ({ readonly engine: ExecutionEngine<E>; readonly stateless: true } & SharedMcpServerConfig);
52 65
53// ---------------------------------------------------------------------------66// ---------------------------------------------------------------------------
54// Elicitation bridge67// Elicitation bridge
@@ -153,11 +166,11 @@ const toMcpPausedResult = (formatted: ReturnType<typeof formatPausedExecution>): · 98 unmodified lines
153// Server factory166// Server factory
154// ---------------------------------------------------------------------------167// ---------------------------------------------------------------------------
155 168
156export const createExecutorMcpServer = async (169export const createExecutorMcpServer = async <E extends Cause.YieldableError>(
157 config: ExecutorMcpServerConfig,170 config: ExecutorMcpServerConfig<E>,
158): Promise<McpServer> => {171): Promise<McpServer> => {
159 const engine = "engine" in config ? config.engine : createExecutionEngine(config);172 const engine = "engine" in config ? config.engine : createExecutionEngine(config);
160 const description = await engine.getDescription();173 const description = config.description ?? (await Effect.runPromise(engine.getDescription));
161 174
162 const server = new McpServer(175 const server = new McpServer(
163 { name: "executor", version: "1.0.0" },176 { name: "executor", version: "1.0.0" },
@@ -166,13 +179,15 @@ export const createExecutorMcpServer = async ( · 2 unmodified lines
166 179
167 const executeCode = async (code: string): Promise<McpToolResult> => {180 const executeCode = async (code: string): Promise<McpToolResult> => {
168 if (supportsManagedElicitation(server)) {181 if (supportsManagedElicitation(server)) {
169 const result = await engine.execute(code, {182 const result = await Effect.runPromise(
170 onElicitation: makeMcpElicitationHandler(server),183 engine.execute(code, {
171 });184 onElicitation: makeMcpElicitationHandler(server),
185 }),
186 );
172 return toMcpResult(formatExecuteResult(result));187 return toMcpResult(formatExecuteResult(result));
173 }188 }
174 189
175 const outcome = await engine.executeWithPause(code);190 const outcome = await Effect.runPromise(engine.executeWithPause(code));
176 return outcome.status === "completed"191 return outcome.status === "completed"
177 ? toMcpResult(formatExecuteResult(outcome.result))192 ? toMcpResult(formatExecuteResult(outcome.result))
178 : toMcpPausedResult(formatPausedExecution(outcome.execution));193 : toMcpPausedResult(formatPausedExecution(outcome.execution));
@@ -222,7 +237,7 @@ export const createExecutorMcpServer = async ( · 43 unmodified lines
222 },237 },
223 async ({ executionId, action, content: rawContent }) => {238 async ({ executionId, action, content: rawContent }) => {
224 const content = parseJsonContent(rawContent);239 const content = parseJsonContent(rawContent);
225 const outcome = await engine.resume(executionId, { action, content });240 const outcome = await Effect.runPromise(engine.resume(executionId, { action, content }));
226 241
227 if (!outcome) {242 if (!outcome) {
228 return {243 return {
packages/kernel/core/src/effect-errors.ts+14−0
@@ -7,3 +7,17 @@ export class KernelCoreEffectError extends Data.TaggedError("KernelCoreEffectErr · 6 unmodified lines
7 7
8export const kernelCoreEffectError = (module: string, message: string) =>8export const kernelCoreEffectError = (module: string, message: string) =>
9 new KernelCoreEffectError({ module, message });9 new KernelCoreEffectError({ module, message });
10
11/**
12 * Default failure type for any `CodeExecutor.execute` implementation —
13 * surfaces sandbox-level defects (isolate crash, module load failure,
14 * worker loader error) as a typed error so callers can handle them
15 * structurally instead of untyped `unknown`. Runtimes that want a
16 * narrower error shape can define their own `Data.TaggedError` subclass
17 * and parameterize `CodeExecutor<MyError>`.
18 */
19export class CodeExecutionError extends Data.TaggedError("CodeExecutionError")<{
20 readonly runtime: string;
21 readonly message: string;
22 readonly cause?: unknown;
23}> {}
packages/kernel/core/src/types.ts+14−3
@@ -1,6 +1,9 @@
1import type { StandardSchemaV1 } from "@standard-schema/spec";1import type { StandardSchemaV1 } from "@standard-schema/spec";
2import type * as Cause from "effect/Cause";
2import type * as Effect from "effect/Effect";3import type * as Effect from "effect/Effect";
3 4
5import type { CodeExecutionError } from "./effect-errors";
6
4/** Branded tool path */7/** Branded tool path */
5export type ToolPath = string & { readonly __toolPath: unique symbol };8export type ToolPath = string & { readonly __toolPath: unique symbol };
6 9
@@ -30,9 +33,17 @@ export type ExecuteResult = { · 23 unmodified lines
30 logs?: string[];33 logs?: string[];
31};34};
32 35
33/** Executes code in a sandboxed runtime with tool access */36/**
34export interface CodeExecutor {37 * Executes code in a sandboxed runtime with tool access.
35 execute(code: string, toolInvoker: SandboxToolInvoker): Effect.Effect<ExecuteResult, unknown>;38 *
39 * Error channel is constrained to Effect's `YieldableError` (the base
40 * shape `Data.TaggedError(...)` produces) so callers always get a
41 * structurally tagged error, never untyped `unknown`. Defaults to
42 * `CodeExecutionError`; runtimes can parameterize with their own
43 * `Data.TaggedError` subclass — e.g. `CodeExecutor<WorkerLoaderError>`.
44 */
45export interface CodeExecutor<E extends Cause.YieldableError = CodeExecutionError> {
46 execute(code: string, toolInvoker: SandboxToolInvoker): Effect.Effect<ExecuteResult, E>;
36}47}
37 48
38/** Accept-anything schema for tools with no input validation */49/** Accept-anything schema for tools with no input validation */
packages/kernel/runtime-deno-subprocess/src/index.ts+1−1
@@ -330,7 +330,7 @@ export const isDenoAvailable = (executable: string = defaultDenoExecutable()): b · 329 unmodified lines
330 330
331export const makeDenoSubprocessExecutor = (331export const makeDenoSubprocessExecutor = (
332 options: DenoSubprocessExecutorOptions = {},332 options: DenoSubprocessExecutorOptions = {},
333): CodeExecutor => ({333): CodeExecutor<never> => ({
334 execute: (code: string, toolInvoker: SandboxToolInvoker) =>334 execute: (code: string, toolInvoker: SandboxToolInvoker) =>
335 executeInDeno(code, toolInvoker, options),335 executeInDeno(code, toolInvoker, options),
336});336});
packages/kernel/runtime-dynamic-worker/src/executor.ts+3−1
@@ -323,7 +323,9 @@ const runInDynamicWorker = ( · 322 unmodified lines
323// Public API323// Public API
324// ---------------------------------------------------------------------------324// ---------------------------------------------------------------------------
325 325
326export const makeDynamicWorkerExecutor = (options: DynamicWorkerExecutorOptions): CodeExecutor => ({326export const makeDynamicWorkerExecutor = (
327 options: DynamicWorkerExecutorOptions,
328): CodeExecutor<DynamicWorkerExecutionError> => ({
327 execute: (code: string, toolInvoker: SandboxToolInvoker) =>329 execute: (code: string, toolInvoker: SandboxToolInvoker) =>
328 runInDynamicWorker(options, code, toolInvoker),330 runInDynamicWorker(options, code, toolInvoker),
329});331});
packages/kernel/runtime-quickjs/src/index.ts+3−1
@@ -396,7 +396,9 @@ const runInQuickJs = ( · 395 unmodified lines
396 }),396 }),
397 );397 );
398 398
399export const makeQuickJsExecutor = (options: QuickJsExecutorOptions = {}): CodeExecutor => ({399export const makeQuickJsExecutor = (
400 options: QuickJsExecutorOptions = {},
401): CodeExecutor<QuickJsExecutionError> => ({
400 execute: (code: string, toolInvoker: SandboxToolInvoker) =>402 execute: (code: string, toolInvoker: SandboxToolInvoker) =>
401 runInQuickJs(options, code, toolInvoker),403 runInQuickJs(options, code, toolInvoker),
402});404});
packages/kernel/runtime-secure-exec/src/index.ts+3−1
@@ -441,7 +441,9 @@ const evaluateInSecureExec = ( · 440 unmodified lines
441 );441 );
442};442};
443 443
444export const makeSecureExecExecutor = (options: SecureExecExecutorOptions = {}): CodeExecutor => ({444export const makeSecureExecExecutor = (
445 options: SecureExecExecutorOptions = {},
446): CodeExecutor<never> => ({
445 execute: (code: string, toolInvoker: SandboxToolInvoker) =>447 execute: (code: string, toolInvoker: SandboxToolInvoker) =>
446 evaluateInSecureExec(options, code, toolInvoker),448 evaluateInSecureExec(options, code, toolInvoker),
447});449});