Unified · Split
| @@ -1,24 +1,22 @@ | |||
| 1 | import { Effect } from "effect"; | ||
| 2 | import type * as Cause from "effect/Cause"; | ||
| 3 | |||
| 1 | import type { ExecutionEngine } from "@executor/execution"; | 4 | import type { ExecutionEngine } from "@executor/execution"; |
| 2 | 5 | ||
| 3 | export const withExecutionUsageTracking = ( | 6 | export 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 | }); |
| @@ -5,16 +5,18 @@ import { withExecutionUsageTracking } from "./execution-usage"; · 4 unmodified lines | |||
| 5 | 5 | ||
| 6 | const makeBaseEngine = (): ExecutionEngine => | 6 | const 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 | ||
| 20 | describe("withExecutionUsageTracking", () => { | 22 | describe("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 | }), |
| @@ -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), |
| @@ -4,12 +4,14 @@ · 3 unmodified lines | |||
| 4 | 4 | ||
| 5 | import { DurableObject, env } from "cloudflare:workers"; | 5 | import { DurableObject, env } from "cloudflare:workers"; |
| 6 | import { Data, Effect, Layer } from "effect"; | 6 | import { Data, Effect, Layer } from "effect"; |
| 7 | import * as Sentry from "@sentry/cloudflare"; | ||
| 7 | import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; | 8 | import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; |
| 8 | import { WorkerTransport, type TransportState } from "agents/mcp"; | 9 | import { WorkerTransport, type TransportState } from "agents/mcp"; |
| 9 | import { drizzle } from "drizzle-orm/postgres-js"; | 10 | import { drizzle } from "drizzle-orm/postgres-js"; |
| 10 | import postgres from "postgres"; | 11 | import postgres from "postgres"; |
| 11 | 12 | ||
| 12 | import { createExecutorMcpServer } from "@executor/host-mcp"; | 13 | import { createExecutorMcpServer } from "@executor/host-mcp"; |
| 14 | import { buildExecuteDescription } from "@executor/execution"; | ||
| 13 | import type { DrizzleDb, DbServiceShape } from "./services/db"; | 15 | import 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. | ||
| 103 | const makeSessionServices = (dbHandle: DbHandle) => | 110 | const makeSessionServices = (dbHandle: DbHandle) => |
| 104 | Layer.mergeAll(makeResolveOrganizationServices(dbHandle), DoTelemetryLive); | 111 | makeResolveOrganizationServices(dbHandle); |
| 105 | 112 | ||
| 106 | const resolveSessionMeta = Effect.fn("McpSessionDO.resolveSessionMeta")(function* ( | 113 | const 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 cost | 226 | return Effect.runPromise( |
| 211 | // (resolveSessionMeta + createRuntime + alarm setup) so the MCP DO | 227 | this.doInitEffect(token).pipe( |
| 212 | // dashboard's "new sessions" / "init p95" panels have one uniform span | 228 | Effect.withSpan("McpSessionDO.init", { |
| 213 | // to filter on, regardless of whether the DO is running the long-lived | 229 | 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 | } |
| @@ -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 | }); |
| 184 | type JsonRpcEnvelope = typeof JsonRpcEnvelope.Type; | 187 | type JsonRpcEnvelope = typeof JsonRpcEnvelope.Type; |
| 185 | 188 | ||
| 189 | const ElicitationReplyResult = Schema.Struct({ | ||
| 190 | action: Schema.optional(Schema.Literal("accept", "decline", "cancel")), | ||
| 191 | }); | ||
| 192 | |||
| 186 | const InitializeParams = Schema.Struct({ | 193 | const 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 | ||
| 257 | const 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 | |||
| 250 | const rpcAttrs = (envelope: Option.Option<JsonRpcEnvelope>): Record<string, unknown> => | 265 | const 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 | ||
| 350 | type SandboxOutcome = { | ||
| 351 | readonly status?: string; | ||
| 352 | readonly error?: { readonly kind?: string; readonly message?: string }; | ||
| 353 | }; | ||
| 354 | |||
| 334 | type JsonRpcErrorBody = { | 355 | type 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 | ||
| 340 | const parseFirstJsonRpc = (contentType: string, body: string): JsonRpcErrorBody | null => { | 364 | const 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, |
| @@ -2,6 +2,7 @@ · 1 unmodified line | |||
| 2 | // Autumn billing service — wraps the autumn-js SDK with Effect | 2 | // Autumn billing service — wraps the autumn-js SDK with Effect |
| 3 | // --------------------------------------------------------------------------- | 3 | // --------------------------------------------------------------------------- |
| 4 | 4 | ||
| 5 | import * as Sentry from "@sentry/cloudflare"; | ||
| 5 | import { Autumn } from "autumn-js"; | 6 | import { Autumn } from "autumn-js"; |
| 6 | import { Context, Data, Effect, Layer } from "effect"; | 7 | import { 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 | }); |
| @@ -16,7 +16,9 @@ import { createScopedExecutor } from "./executor"; · 15 unmodified lines | |||
| 16 | 16 | ||
| 17 | export const makeExecutionStack = (organizationId: string, organizationName: string) => | 17 | export 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")); |
| @@ -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({ |
| @@ -1,4 +1,5 @@ | |||
| 1 | import { Context } from "effect"; | 1 | import { Context } from "effect"; |
| 2 | import type * as Cause from "effect/Cause"; | ||
| 2 | import type { Executor } from "@executor/sdk"; | 3 | import type { Executor } from "@executor/sdk"; |
| 3 | import type { ExecutionEngine } from "@executor/execution"; | 4 | import type { ExecutionEngine } from "@executor/execution"; |
| 4 | 5 | ||
| @@ -7,7 +8,12 @@ export class ExecutorService extends Context.Tag("ExecutorService")< · 2 unmodified lines | |||
| 7 | Executor | 8 | 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. | ||
| 10 | export class ExecutionEngineService extends Context.Tag("ExecutionEngineService")< | 16 | export class ExecutionEngineService extends Context.Tag("ExecutionEngineService")< |
| 11 | ExecutionEngineService, | 17 | ExecutionEngineService, |
| 12 | ExecutionEngine | 18 | ExecutionEngine<Cause.YieldableError> |
| 13 | >() {} | 19 | >() {} |
| @@ -10,14 +10,18 @@ import type { Executor, Tool, Source } from "@executor/sdk"; · 9 unmodified lines | |||
| 10 | */ | 10 | */ |
| 11 | export const buildExecuteDescription = (executor: Executor): Effect.Effect<string> => | 11 | export 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 | ||
| 22 | const formatDescription = (namespaces: readonly string[], sources: readonly Source[]): string => { | 26 | const formatDescription = (namespaces: readonly string[], sources: readonly Source[]): string => { |
| 23 | const lines: string[] = [ | 27 | const lines: string[] = [ |
| @@ -1,4 +1,5 @@ | |||
| 1 | import { Deferred, Effect, Fiber, Ref } from "effect"; | 1 | import { Deferred, Effect, Fiber, Ref } from "effect"; |
| 2 | import type * as Cause from "effect/Cause"; | ||
| 2 | 3 | ||
| 3 | import type { | 4 | import 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"; |
| 11 | import { CodeExecutionError } from "@executor/codemode-core"; | ||
| 10 | import type { CodeExecutor, ExecuteResult, SandboxToolInvoker } from "@executor/codemode-core"; | 12 | import type { CodeExecutor, ExecuteResult, SandboxToolInvoker } from "@executor/codemode-core"; |
| 11 | 13 | ||
| 12 | import { | 14 | import { |
| @@ -22,9 +24,11 @@ import { buildExecuteDescription } from "./description"; · 9 unmodified lines | |||
| 22 | // Types | 24 | // Types |
| 23 | // --------------------------------------------------------------------------- | 25 | // --------------------------------------------------------------------------- |
| 24 | 26 | ||
| 25 | export type ExecutionEngineConfig = { | 27 | export 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 | ||
| 30 | export type ExecutionResult = | 34 | export 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. */ |
| 40 | type InternalPausedExecution = PausedExecution & { | 44 | type 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 | ||
| 46 | export type ResumeResponse = { | 50 | export type ResumeResponse = { |
| @@ -255,22 +259,26 @@ const makeFullInvoker = (executor: Executor, invokeOptions: InvokeOptions): Sand · 208 unmodified lines | |||
| 255 | // Execution Engine | 259 | // Execution Engine |
| 256 | // --------------------------------------------------------------------------- | 260 | // --------------------------------------------------------------------------- |
| 257 | 261 | ||
| 258 | export type ExecutionEngine = { | 262 | export 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, or | 284 | * 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 | ||
| 290 | const runEffect = <A>(effect: Effect.Effect<A, unknown>): Promise<A> => | 298 | export const createExecutionEngine = < |
| 291 | Effect.runPromise(effect as Effect.Effect<A, never>); | 299 | E extends Cause.YieldableError = CodeExecutionError, |
| 292 | 300 | >( | |
| 293 | export 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 reads | 337 | // Ref holds the current pause signal. The elicitation handler reads |
| 330 | // it each time it fires, so resume() can swap in a fresh Deferred | 338 | // 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 the | 392 | // 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 | }; |
| @@ -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. | ||
| 11 | export { CodeExecutionError } from "@executor/codemode-core"; | ||
| @@ -1,24 +1,28 @@ | |||
| 1 | // --------------------------------------------------------------------------- | 1 | // --------------------------------------------------------------------------- |
| 2 | // @executor/execution/promise — Promise-native surface for the execution | 2 | // @executor/execution/promise — Promise-native surface for the execution |
| 3 | // engine. Accepts a Promise-style Executor (from @executor/sdk) and an | 3 | // 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 incoming | 6 | // `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 | ||
| 11 | import { Effect } from "effect"; | 13 | import { Effect } from "effect"; |
| 14 | import type * as Cause from "effect/Cause"; | ||
| 12 | 15 | ||
| 13 | import type { | 16 | import 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"; |
| 18 | import type { CodeExecutor, ExecuteResult } from "@executor/codemode-core"; | 21 | import type { CodeExecutionError, CodeExecutor, ExecuteResult } from "@executor/codemode-core"; |
| 19 | 22 | ||
| 20 | import { | 23 | import { |
| 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 | ||
| 31 | export type ExecutionEngineConfig = { | 35 | export 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 | ||
| 36 | export type ExecutionEngine = { | 42 | export 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 | */ |
| 54 | const wrapPromiseExecutor = (pe: PromiseExecutor): any => ({ | 60 | const 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 | ||
| 87 | export 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 | }); | 99 | export 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 | |||
| 114 | export 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 | ||
| 112 | export { | 130 | export { formatExecuteResult, formatPausedExecution } from "./engine"; |
| 113 | formatExecuteResult, | ||
| 114 | formatPausedExecution, | ||
| 115 | } from "./engine"; | ||
| 116 | 131 | ||
| 117 | export type { ExecutionResult, PausedExecution, ResumeResponse }; | 132 | export type { ExecutionResult, PausedExecution, ResumeResponse }; |
| 118 | 133 | ||
| @@ -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 | ), |
| @@ -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"; |
| 17 | import type * as Cause from "effect/Cause"; | ||
| 17 | import { | 18 | import { |
| 18 | createExecutionEngine, | 19 | createExecutionEngine, |
| 19 | formatExecuteResult, | 20 | formatExecuteResult, |
| @@ -44,11 +45,23 @@ class CfWorkerJsonSchemaValidator implements jsonSchemaValidator { · 24 unmodified lines | |||
| 44 | // Config | 45 | // Config |
| 45 | // --------------------------------------------------------------------------- | 46 | // --------------------------------------------------------------------------- |
| 46 | 47 | ||
| 47 | export type ExecutorMcpServerConfig = | 48 | type SharedMcpServerConfig = { |
| 48 | | ExecutionEngineConfig | 49 | /** |
| 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 | |||
| 60 | export 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 bridge | 67 | // Elicitation bridge |
| @@ -153,11 +166,11 @@ const toMcpPausedResult = (formatted: ReturnType<typeof formatPausedExecution>): · 98 unmodified lines | |||
| 153 | // Server factory | 166 | // Server factory |
| 154 | // --------------------------------------------------------------------------- | 167 | // --------------------------------------------------------------------------- |
| 155 | 168 | ||
| 156 | export const createExecutorMcpServer = async ( | 169 | export 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 { |
| @@ -7,3 +7,17 @@ export class KernelCoreEffectError extends Data.TaggedError("KernelCoreEffectErr · 6 unmodified lines | |||
| 7 | 7 | ||
| 8 | export const kernelCoreEffectError = (module: string, message: string) => | 8 | export 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 | */ | ||
| 19 | export class CodeExecutionError extends Data.TaggedError("CodeExecutionError")<{ | ||
| 20 | readonly runtime: string; | ||
| 21 | readonly message: string; | ||
| 22 | readonly cause?: unknown; | ||
| 23 | }> {} | ||
| @@ -1,6 +1,9 @@ | |||
| 1 | import type { StandardSchemaV1 } from "@standard-schema/spec"; | 1 | import type { StandardSchemaV1 } from "@standard-schema/spec"; |
| 2 | import type * as Cause from "effect/Cause"; | ||
| 2 | import type * as Effect from "effect/Effect"; | 3 | import type * as Effect from "effect/Effect"; |
| 3 | 4 | ||
| 5 | import type { CodeExecutionError } from "./effect-errors"; | ||
| 6 | |||
| 4 | /** Branded tool path */ | 7 | /** Branded tool path */ |
| 5 | export type ToolPath = string & { readonly __toolPath: unique symbol }; | 8 | export 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 | /** |
| 34 | export 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 | */ | ||
| 45 | export 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 */ |
| @@ -330,7 +330,7 @@ export const isDenoAvailable = (executable: string = defaultDenoExecutable()): b · 329 unmodified lines | |||
| 330 | 330 | ||
| 331 | export const makeDenoSubprocessExecutor = ( | 331 | export 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 | }); |
| @@ -323,7 +323,9 @@ const runInDynamicWorker = ( · 322 unmodified lines | |||
| 323 | // Public API | 323 | // Public API |
| 324 | // --------------------------------------------------------------------------- | 324 | // --------------------------------------------------------------------------- |
| 325 | 325 | ||
| 326 | export const makeDynamicWorkerExecutor = (options: DynamicWorkerExecutorOptions): CodeExecutor => ({ | 326 | export 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 | }); |
| @@ -396,7 +396,9 @@ const runInQuickJs = ( · 395 unmodified lines | |||
| 396 | }), | 396 | }), |
| 397 | ); | 397 | ); |
| 398 | 398 | ||
| 399 | export const makeQuickJsExecutor = (options: QuickJsExecutorOptions = {}): CodeExecutor => ({ | 399 | export 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 | }); |
| @@ -441,7 +441,9 @@ const evaluateInSecureExec = ( · 440 unmodified lines | |||
| 441 | ); | 441 | ); |
| 442 | }; | 442 | }; |
| 443 | 443 | ||
| 444 | export const makeSecureExecExecutor = (options: SecureExecExecutorOptions = {}): CodeExecutor => ({ | 444 | export 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 | }); |