Unified · Split
| @@ -1,24 +1,22 @@ | ||
| 1 | +import { Effect } from "effect"; | |
| 2 | +import type * as Cause from "effect/Cause"; | |
| 3 | + | |
| 1 | 4 | import type { ExecutionEngine } from "@executor/execution"; |
| 2 | 5 | |
| 3 | −export const withExecutionUsageTracking = ( | |
| 6 | +export const withExecutionUsageTracking = <E extends Cause.YieldableError>( | |
| 4 | 7 | organizationId: string, |
| 5 | − engine: ExecutionEngine, | |
| 8 | + engine: ExecutionEngine<E>, | |
| 6 | 9 | trackUsage: (organizationId: string) => void, |
| 7 | −): ExecutionEngine => ({ | |
| 8 | − execute: async (code, options) => { | |
| 9 | − const result = await engine.execute(code, options); | |
| 10 | − trackUsage(organizationId); | |
| 11 | − return result; | |
| 12 | − }, | |
| 13 | − executeWithPause: async (code) => { | |
| 14 | − const result = await engine.executeWithPause(code); | |
| 15 | − trackUsage(organizationId); | |
| 16 | − return result; | |
| 17 | − }, | |
| 18 | − resume: async (executionId, response) => { | |
| 19 | − const result = await engine.resume(executionId, response); | |
| 20 | − // resume doesn't count as usage | |
| 21 | − return result; | |
| 22 | − }, | |
| 10 | +): ExecutionEngine<E> => ({ | |
| 11 | + execute: (code, options) => | |
| 12 | + engine | |
| 13 | + .execute(code, options) | |
| 14 | + .pipe(Effect.tap(() => Effect.sync(() => trackUsage(organizationId)))), | |
| 15 | + executeWithPause: (code) => | |
| 16 | + engine | |
| 17 | + .executeWithPause(code) | |
| 18 | + .pipe(Effect.tap(() => Effect.sync(() => trackUsage(organizationId)))), | |
| 19 | + // resume doesn't count as usage | |
| 20 | + resume: (executionId, response) => engine.resume(executionId, response), | |
| 23 | 21 | getDescription: engine.getDescription, |
| 24 | 22 | }); |
| @@ -5,16 +5,18 @@ import { withExecutionUsageTracking } from "./execution-usage"; · 4 unmodified lines | ||
| 5 | 5 | |
| 6 | 6 | const makeBaseEngine = (): ExecutionEngine => |
| 7 | 7 | ({ |
| 8 | − execute: async () => ({ result: "ok", logs: [] }), | |
| 9 | − executeWithPause: async () => ({ | |
| 10 | − status: "completed", | |
| 11 | − result: { result: "ok", logs: [] }, | |
| 12 | − }), | |
| 13 | − resume: async () => ({ | |
| 14 | − status: "completed", | |
| 15 | − result: { result: "ok", logs: [] }, | |
| 16 | − }), | |
| 17 | − getDescription: async () => "desc", | |
| 8 | + execute: () => Effect.succeed({ result: "ok", logs: [] }), | |
| 9 | + executeWithPause: () => | |
| 10 | + Effect.succeed({ | |
| 11 | + status: "completed", | |
| 12 | + result: { result: "ok", logs: [] }, | |
| 13 | + }), | |
| 14 | + resume: () => | |
| 15 | + Effect.succeed({ | |
| 16 | + status: "completed", | |
| 17 | + result: { result: "ok", logs: [] }, | |
| 18 | + }), | |
| 19 | + getDescription: Effect.succeed("desc"), | |
| 18 | 20 | }) as ExecutionEngine; |
| 19 | 21 | |
| 20 | 22 | describe("withExecutionUsageTracking", () => { |
| @@ -25,10 +27,8 @@ describe("withExecutionUsageTracking", () => { · 4 unmodified lines | ||
| 25 | 27 | tracked.push(orgId); |
| 26 | 28 | }); |
| 27 | 29 | |
| 28 | − yield* Effect.promise(() => | |
| 29 | − engine.execute("1+1", { onElicitation: (() => Effect.die("unused")) as never }), | |
| 30 | − ); | |
| 31 | − yield* Effect.promise(() => engine.executeWithPause("2+2")); | |
| 30 | + yield* engine.execute("1+1", { onElicitation: (() => Effect.die("unused")) as never }); | |
| 31 | + yield* engine.executeWithPause("2+2"); | |
| 32 | 32 | |
| 33 | 33 | expect(tracked).toEqual(["org_1", "org_1"]); |
| 34 | 34 | }), |
| @@ -44,8 +44,8 @@ describe("withExecutionUsageTracking", () => { · 9 unmodified lines | ||
| 44 | 44 | "org_2", |
| 45 | 45 | { |
| 46 | 46 | ...base, |
| 47 | − resume: async (...args) => { | |
| 48 | − if (shouldReturnNull) return null; | |
| 47 | + resume: (...args) => { | |
| 48 | + if (shouldReturnNull) return Effect.succeed(null); | |
| 49 | 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(() => | |
| 58 | − engine.resume("exec_1", { | |
| 59 | − action: "accept", | |
| 60 | − }), | |
| 61 | − ); | |
| 57 | + yield* engine.resume("exec_1", { | |
| 58 | + action: "accept", | |
| 59 | + }); | |
| 62 | 60 | shouldReturnNull = true; |
| 63 | − yield* Effect.promise(() => | |
| 64 | − engine.resume("missing", { | |
| 65 | − action: "accept", | |
| 66 | − }), | |
| 67 | − ); | |
| 61 | + yield* engine.resume("missing", { | |
| 62 | + action: "accept", | |
| 63 | + }); | |
| 68 | 64 | |
| 69 | 65 | expect(tracked).toEqual([]); |
| 70 | 66 | }), |
| @@ -34,9 +34,6 @@ const createProtectedApp = (organizationId: string, organizationName: string) => · 33 unmodified lines | ||
| 34 | 34 | Effect.gen(function* () { |
| 35 | 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 | 37 | const requestServices = Layer.mergeAll( |
| 41 | 38 | Layer.succeed(ExecutorService, executor), |
| 42 | 39 | Layer.succeed(ExecutionEngineService, engine), |
| @@ -4,12 +4,14 @@ · 3 unmodified lines | ||
| 4 | 4 | |
| 5 | 5 | import { DurableObject, env } from "cloudflare:workers"; |
| 6 | 6 | import { Data, Effect, Layer } from "effect"; |
| 7 | +import * as Sentry from "@sentry/cloudflare"; | |
| 7 | 8 | import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; |
| 8 | 9 | import { WorkerTransport, type TransportState } from "agents/mcp"; |
| 9 | 10 | import { drizzle } from "drizzle-orm/postgres-js"; |
| 10 | 11 | import postgres from "postgres"; |
| 11 | 12 | |
| 12 | 13 | import { createExecutorMcpServer } from "@executor/host-mcp"; |
| 14 | +import { buildExecuteDescription } from "@executor/execution"; | |
| 13 | 15 | import type { DrizzleDb, DbServiceShape } from "./services/db"; |
| 14 | 16 | |
| 15 | 17 | // Import directly from core-shared-services, NOT from ./api/layers.ts. |
| @@ -100,8 +102,13 @@ const makeResolveOrganizationServices = (dbHandle: DbHandle) => { · 84 unmodified lines | ||
| 100 | 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 | 110 | const makeSessionServices = (dbHandle: DbHandle) => |
| 104 | − Layer.mergeAll(makeResolveOrganizationServices(dbHandle), DoTelemetryLive); | |
| 111 | + makeResolveOrganizationServices(dbHandle); | |
| 105 | 112 | |
| 106 | 113 | const resolveSessionMeta = Effect.fn("McpSessionDO.resolveSessionMeta")(function* ( |
| 107 | 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 | 175 | sessionMeta: SessionMeta, |
| 169 | − options: { | |
| 170 | − readonly dbHandle: DbHandle; | |
| 171 | − readonly enableJsonResponse?: boolean; | |
| 172 | − }, | |
| 173 | − ): Promise<{ mcpServer: McpServer; transport: WorkerTransport }> { | |
| 174 | − const program = Effect.gen(function* () { | |
| 175 | − const { engine } = yield* makeExecutionStack( | |
| 176 | + options: { readonly dbHandle: DbHandle; readonly enableJsonResponse?: boolean }, | |
| 177 | + ) { | |
| 178 | + const self = this; | |
| 179 | + return Effect.gen(function* () { | |
| 180 | + const { executor, engine } = yield* makeExecutionStack( | |
| 176 | 181 | sessionMeta.organizationId, |
| 177 | 182 | sessionMeta.organizationName, |
| 178 | 183 | ); |
| 179 | − return yield* Effect.promise(() => createExecutorMcpServer({ engine })); | |
| 180 | − }).pipe(Effect.withSpan("McpSessionDO.createRuntime"), Effect.provide(makeSessionServices(options.dbHandle))); | |
| 181 | − | |
| 182 | − const mcpServer = await Effect.runPromise(program); | |
| 183 | − const transport = new WorkerTransport({ | |
| 184 | − sessionIdGenerator: () => this.ctx.id.toString(), | |
| 185 | − storage: this.makeStorage(), | |
| 186 | − enableJsonResponse: options.enableJsonResponse, | |
| 187 | − }); | |
| 188 | − | |
| 189 | − await mcpServer.connect(transport); | |
| 190 | − return { mcpServer, transport }; | |
| 184 | + // Build the description here so the two postgres queries it runs | |
| 185 | + // (`executor.sources.list` + `executor.tools.list`) land as | |
| 186 | + // children of `McpSessionDO.createRuntime`. host-mcp would | |
| 187 | + // otherwise call `Effect.runPromise(engine.getDescription)` at | |
| 188 | + // its async MCP-SDK boundary and orphan those sub-spans. | |
| 189 | + const description = yield* buildExecuteDescription(executor); | |
| 190 | + const mcpServer = yield* Effect.promise(() => | |
| 191 | + createExecutorMcpServer({ engine, description }), | |
| 192 | + ).pipe(Effect.withSpan("McpSessionDO.createExecutorMcpServer")); | |
| 193 | + const transport = new WorkerTransport({ | |
| 194 | + sessionIdGenerator: () => self.ctx.id.toString(), | |
| 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> { | |
| 194 | − const dbHandle = makeRequestScopedDb(); | |
| 195 | − try { | |
| 196 | − const sessionMeta = await Effect.runPromise( | |
| 197 | − resolveSessionMeta(token.organizationId).pipe( | |
| 208 | + private resolveAndStoreSessionMetaEffect(token: McpSessionInit) { | |
| 209 | + const self = this; | |
| 210 | + return Effect.gen(function* () { | |
| 211 | + const dbHandle = makeRequestScopedDb(); | |
| 212 | + try { | |
| 213 | + const sessionMeta = yield* resolveSessionMeta(token.organizationId).pipe( | |
| 198 | 214 | Effect.provide(makeResolveOrganizationServices(dbHandle)), |
| 199 | − ), | |
| 200 | − ); | |
| 201 | − await this.saveSessionMeta(sessionMeta); | |
| 202 | − return sessionMeta; | |
| 203 | − } finally { | |
| 204 | − await dbHandle.end(); | |
| 205 | − } | |
| 215 | + ); | |
| 216 | + yield* Effect.promise(() => self.saveSessionMeta(sessionMeta)); | |
| 217 | + return sessionMeta; | |
| 218 | + } finally { | |
| 219 | + yield* Effect.promise(() => dbHandle.end()); | |
| 220 | + } | |
| 221 | + }); | |
| 206 | 222 | } |
| 207 | 223 | |
| 208 | 224 | async init(token: McpSessionInit): Promise<void> { |
| 209 | 225 | if (this.initialized) return; |
| 210 | − // Outer `McpSessionDO.init` span wraps the full session-bootstrap cost | |
| 211 | − // (resolveSessionMeta + createRuntime + alarm setup) so the MCP DO | |
| 212 | − // dashboard's "new sessions" / "init p95" panels have one uniform span | |
| 213 | − // to filter on, regardless of whether the DO is running the long-lived | |
| 214 | − // or request-scoped variant. | |
| 215 | − const program = Effect.promise(() => this.doInit(token)).pipe( | |
| 216 | − Effect.withSpan("McpSessionDO.init", { | |
| 217 | − attributes: { | |
| 218 | − "mcp.auth.organization_id": token.organizationId, | |
| 219 | − }, | |
| 220 | − }), | |
| 221 | − Effect.provide(DoTelemetryLive), | |
| 226 | + return Effect.runPromise( | |
| 227 | + this.doInitEffect(token).pipe( | |
| 228 | + Effect.withSpan("McpSessionDO.init", { | |
| 229 | + attributes: { "mcp.auth.organization_id": token.organizationId }, | |
| 230 | + }), | |
| 231 | + Effect.provide(DoTelemetryLive), | |
| 232 | + ), | |
| 222 | 233 | ); |
| 223 | − return Effect.runPromise(program); | |
| 224 | 234 | } |
| 225 | 235 | |
| 226 | − private async doInit(token: McpSessionInit): Promise<void> { | |
| 227 | − try { | |
| 228 | − const sessionMeta = await this.resolveAndStoreSessionMeta(token); | |
| 236 | + private doInitEffect(token: McpSessionInit) { | |
| 237 | + const self = this; | |
| 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 | 248 | if (!requestScopedRuntimeEnabled) { |
| 231 | − this.dbHandle = makeLongLivedDb(); | |
| 232 | − const runtime = await this.createConnectedRuntime(sessionMeta, { | |
| 233 | − dbHandle: this.dbHandle, | |
| 249 | + self.dbHandle = makeLongLivedDb(); | |
| 250 | + const runtime = yield* self.createConnectedRuntimeEffect(sessionMeta, { | |
| 251 | + dbHandle: self.dbHandle, | |
| 234 | 252 | }); |
| 235 | − this.mcpServer = runtime.mcpServer; | |
| 236 | − this.transport = runtime.transport; | |
| 253 | + self.mcpServer = runtime.mcpServer; | |
| 254 | + self.transport = runtime.transport; | |
| 237 | 255 | } |
| 238 | 256 | |
| 239 | − this.initialized = true; | |
| 240 | − this.lastActivityMs = Date.now(); | |
| 257 | + self.initialized = true; | |
| 258 | + self.lastActivityMs = Date.now(); | |
| 241 | 259 | |
| 242 | − await this.ctx.storage.setAlarm(Date.now() + HEARTBEAT_MS); | |
| 243 | − } catch (err) { | |
| 244 | − // Partial init leaves dangling resources (DB socket, maybe mcpServer). | |
| 245 | − // Clean up before rethrowing so the DO isn't stuck in a half-built state. | |
| 246 | − console.error("[mcp-session] init failed:", err instanceof Error ? err.stack : err); | |
| 247 | − await this.cleanup(); | |
| 248 | − throw err; | |
| 249 | − } | |
| 260 | + yield* Effect.promise(() => self.ctx.storage.setAlarm(Date.now() + HEARTBEAT_MS)).pipe( | |
| 261 | + Effect.withSpan("McpSessionDO.setAlarm"), | |
| 262 | + ); | |
| 263 | + }).pipe( | |
| 264 | + Effect.tapErrorCause((cause) => | |
| 265 | + Effect.sync(() => { | |
| 266 | + console.error("[mcp-session] init failed:", cause); | |
| 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 | 279 | private async handleRequestWithRequestScopedRuntime(request: Request): Promise<Response> { |
| @@ -264,10 +291,12 @@ export class McpSessionDO extends DurableObject { · 11 unmodified lines | ||
| 264 | 291 | |
| 265 | 292 | try { |
| 266 | 293 | dbHandle = makeRequestScopedDb(); |
| 267 | − const runtime = await this.createConnectedRuntime(sessionMeta, { | |
| 268 | − dbHandle, | |
| 269 | − enableJsonResponse: request.method !== "GET", | |
| 270 | − }); | |
| 294 | + const runtime = await Effect.runPromise( | |
| 295 | + this.createConnectedRuntimeEffect(sessionMeta, { | |
| 296 | + dbHandle, | |
| 297 | + enableJsonResponse: request.method !== "GET", | |
| 298 | + }).pipe(Effect.provide(DoTelemetryLive)), | |
| 299 | + ); | |
| 271 | 300 | mcpServer = runtime.mcpServer; |
| 272 | 301 | transport = runtime.transport; |
| 273 | 302 | |
| @@ -281,6 +310,7 @@ export class McpSessionDO extends DurableObject { · 7 unmodified lines | ||
| 281 | 310 | "[mcp-session] request-scoped handleRequest error:", |
| 282 | 311 | err instanceof Error ? err.stack : err, |
| 283 | 312 | ); |
| 313 | + Sentry.captureException(err); | |
| 284 | 314 | return jsonRpcError(500, -32603, "Internal error"); |
| 285 | 315 | } finally { |
| 286 | 316 | await transport?.close().catch(() => undefined); |
| @@ -331,6 +361,7 @@ export class McpSessionDO extends DurableObject { · 44 unmodified lines | ||
| 331 | 361 | return response; |
| 332 | 362 | } catch (err) { |
| 333 | 363 | console.error("[mcp-session] handleRequest error:", err instanceof Error ? err.stack : err); |
| 364 | + Sentry.captureException(err); | |
| 334 | 365 | return jsonRpcError(500, -32603, "Internal error"); |
| 335 | 366 | } |
| 336 | 367 | } |
| @@ -180,9 +180,16 @@ const JsonRpcEnvelope = Schema.Struct({ · 179 unmodified lines | ||
| 180 | 180 | method: Schema.optional(Schema.String), |
| 181 | 181 | id: Schema.optional(Schema.Union(Schema.String, Schema.Number, Schema.Null)), |
| 182 | 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 | 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 | 193 | const InitializeParams = Schema.Struct({ |
| 187 | 194 | protocolVersion: Schema.optional(Schema.String), |
| 188 | 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 | 265 | const rpcAttrs = (envelope: Option.Option<JsonRpcEnvelope>): Record<string, unknown> => |
| 251 | 266 | Option.match(envelope, { |
| 252 | 267 | onNone: () => ({}), |
| @@ -254,6 +269,7 @@ const rpcAttrs = (envelope: Option.Option<JsonRpcEnvelope>): Record<string, unkn · 1 unmodified line | ||
| 254 | 269 | ...(e.method && { "mcp.rpc.method": e.method }), |
| 255 | 270 | ...(e.id !== undefined && e.id !== null && { "mcp.rpc.id": String(e.id) }), |
| 256 | 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 | 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 | 355 | type JsonRpcErrorBody = { |
| 335 | 356 | readonly jsonrpc?: string; |
| 336 | 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 | 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 | 397 | if (payload.result?.isError === true) { |
| 374 | 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 | 408 | return attrs; |
| 377 | 409 | }; |
| 378 | 410 | |
| @@ -385,6 +417,16 @@ const peekAndAnnotate = (response: Response): Effect.Effect<Response> => · 6 unmodified lines | ||
| 385 | 417 | if (Object.keys(attrs).length > 0) { |
| 386 | 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 | 430 | return new Response(text, { |
| 389 | 431 | status: response.status, |
| 390 | 432 | statusText: response.statusText, |
| @@ -2,6 +2,7 @@ · 1 unmodified line | ||
| 2 | 2 | // Autumn billing service — wraps the autumn-js SDK with Effect |
| 3 | 3 | // --------------------------------------------------------------------------- |
| 4 | 4 | |
| 5 | +import * as Sentry from "@sentry/cloudflare"; | |
| 5 | 6 | import { Autumn } from "autumn-js"; |
| 6 | 7 | import { Context, Data, Effect, Layer } from "effect"; |
| 7 | 8 | |
| @@ -56,16 +57,21 @@ const make = Effect.sync(() => { · 48 unmodified lines | ||
| 56 | 57 | }).pipe(Effect.withSpan(`autumn.${fn.name ?? "use"}`)); |
| 57 | 58 | |
| 58 | 59 | const trackExecution = (organizationId: string) => |
| 59 | − use((c) => | |
| 60 | − c.track({ customerId: organizationId, featureId: "executions", value: 1 }), | |
| 61 | − ).pipe( | |
| 62 | − Effect.asVoid, | |
| 63 | − Effect.tapError((err) => | |
| 64 | − Effect.sync(() => console.error("[billing] track failed:", err)), | |
| 65 | − ), | |
| 66 | − Effect.orElse(() => Effect.void), | |
| 67 | − Effect.withSpan("autumn.trackExecution"), | |
| 68 | − ); | |
| 60 | + Effect.gen(function* () { | |
| 61 | + yield* Effect.annotateCurrentSpan({ "autumn.customer.id": organizationId }); | |
| 62 | + const outcome = yield* Effect.either( | |
| 63 | + use((c) => | |
| 64 | + c.track({ customerId: organizationId, featureId: "executions", value: 1 }), | |
| 65 | + ), | |
| 66 | + ); | |
| 67 | + if (outcome._tag === "Left") { | |
| 68 | + // Silent billing data loss is worth paging on — autumn.trackExecution | |
| 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 | 76 | return { use, trackExecution } satisfies IAutumnService; |
| 71 | 77 | }); |
| @@ -16,7 +16,9 @@ import { createScopedExecutor } from "./executor"; · 15 unmodified lines | ||
| 16 | 16 | |
| 17 | 17 | export const makeExecutionStack = (organizationId: string, organizationName: string) => |
| 18 | 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 | 22 | const codeExecutor = makeDynamicWorkerExecutor({ loader: env.LOADER }); |
| 21 | 23 | const autumn = yield* AutumnService; |
| 22 | 24 | const engine = withExecutionUsageTracking( |
| @@ -25,4 +27,4 @@ export const makeExecutionStack = (organizationId: string, organizationName: str · 2 unmodified lines | ||
| 25 | 27 | (orgId) => Effect.runFork(autumn.trackExecution(orgId)), |
| 26 | 28 | ); |
| 27 | 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 | 11 | .handle("execute", ({ payload }) => |
| 12 | 12 | capture(Effect.gen(function* () { |
| 13 | 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 | 16 | if (outcome.status === "completed") { |
| 17 | 17 | const formatted = formatExecuteResult(outcome.result); |
| @@ -34,12 +34,10 @@ export const ExecutionsHandlers = HttpApiBuilder.group(ExecutorApi, "executions" · 16 unmodified lines | ||
| 34 | 34 | .handle("resume", ({ path, payload }) => |
| 35 | 35 | capture(Effect.gen(function* () { |
| 36 | 36 | const engine = yield* ExecutionEngineService; |
| 37 | − const result = yield* Effect.promise(() => | |
| 38 | − engine.resume(path.executionId, { | |
| 39 | − action: payload.action, | |
| 40 | − content: payload.content as Record<string, unknown> | undefined, | |
| 41 | − }), | |
| 42 | − ); | |
| 37 | + const result = yield* engine.resume(path.executionId, { | |
| 38 | + action: payload.action, | |
| 39 | + content: payload.content as Record<string, unknown> | undefined, | |
| 40 | + }); | |
| 43 | 41 | |
| 44 | 42 | if (!result) { |
| 45 | 43 | return yield* Effect.fail({ |
| @@ -1,4 +1,5 @@ | ||
| 1 | 1 | import { Context } from "effect"; |
| 2 | +import type * as Cause from "effect/Cause"; | |
| 2 | 3 | import type { Executor } from "@executor/sdk"; |
| 3 | 4 | import type { ExecutionEngine } from "@executor/execution"; |
| 4 | 5 | |
| @@ -7,7 +8,12 @@ export class ExecutorService extends Context.Tag("ExecutorService")< · 2 unmodified lines | ||
| 7 | 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 | 16 | export class ExecutionEngineService extends Context.Tag("ExecutionEngineService")< |
| 11 | 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 | 11 | export const buildExecuteDescription = (executor: Executor): Effect.Effect<string> => |
| 12 | 12 | Effect.gen(function* () { |
| 13 | − const sources: readonly Source[] = yield* executor.sources.list().pipe(Effect.orDie); | |
| 14 | − const tools: readonly Tool[] = yield* executor.tools.list().pipe(Effect.orDie); | |
| 13 | + const sources: readonly Source[] = yield* executor.sources | |
| 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 | 20 | const namespaces = new Set<string>(); |
| 17 | 21 | for (const tool of tools) namespaces.add(tool.sourceId); |
| 18 | 22 | |
| 19 | 23 | return formatDescription([...namespaces], sources); |
| 20 | − }); | |
| 24 | + }).pipe(Effect.withSpan("buildExecuteDescription")); | |
| 21 | 25 | |
| 22 | 26 | const formatDescription = (namespaces: readonly string[], sources: readonly Source[]): string => { |
| 23 | 27 | const lines: string[] = [ |
| @@ -1,4 +1,5 @@ | ||
| 1 | 1 | import { Deferred, Effect, Fiber, Ref } from "effect"; |
| 2 | +import type * as Cause from "effect/Cause"; | |
| 2 | 3 | |
| 3 | 4 | import type { |
| 4 | 5 | Executor, |
| @@ -7,6 +8,7 @@ import type { · 2 unmodified lines | ||
| 7 | 8 | ElicitationHandler, |
| 8 | 9 | ElicitationContext, |
| 9 | 10 | } from "@executor/sdk"; |
| 11 | +import { CodeExecutionError } from "@executor/codemode-core"; | |
| 10 | 12 | import type { CodeExecutor, ExecuteResult, SandboxToolInvoker } from "@executor/codemode-core"; |
| 11 | 13 | |
| 12 | 14 | import { |
| @@ -22,9 +24,11 @@ import { buildExecuteDescription } from "./description"; · 9 unmodified lines | ||
| 22 | 24 | // Types |
| 23 | 25 | // --------------------------------------------------------------------------- |
| 24 | 26 | |
| 25 | −export type ExecutionEngineConfig = { | |
| 27 | +export type ExecutionEngineConfig< | |
| 28 | + E extends Cause.YieldableError = CodeExecutionError, | |
| 29 | +> = { | |
| 26 | 30 | readonly executor: Executor; |
| 27 | − readonly codeExecutor: CodeExecutor; | |
| 31 | + readonly codeExecutor: CodeExecutor<E>; | |
| 28 | 32 | }; |
| 29 | 33 | |
| 30 | 34 | export type ExecutionResult = |
| @@ -37,10 +41,10 @@ export type PausedExecution = { · 6 unmodified lines | ||
| 37 | 41 | }; |
| 38 | 42 | |
| 39 | 43 | /** Internal representation with Effect runtime state for pause/resume. */ |
| 40 | −type InternalPausedExecution = PausedExecution & { | |
| 44 | +type InternalPausedExecution<E> = PausedExecution & { | |
| 41 | 45 | readonly response: Deferred.Deferred<typeof ElicitationResponse.Type>; |
| 42 | − readonly fiber: Fiber.Fiber<ExecuteResult, unknown>; | |
| 43 | − readonly pauseSignalRef: Ref.Ref<Deferred.Deferred<InternalPausedExecution>>; | |
| 46 | + readonly fiber: Fiber.Fiber<ExecuteResult, E>; | |
| 47 | + readonly pauseSignalRef: Ref.Ref<Deferred.Deferred<InternalPausedExecution<E>>>; | |
| 44 | 48 | }; |
| 45 | 49 | |
| 46 | 50 | export type ResumeResponse = { |
| @@ -255,22 +259,26 @@ const makeFullInvoker = (executor: Executor, invokeOptions: InvokeOptions): Sand · 208 unmodified lines | ||
| 255 | 259 | // Execution Engine |
| 256 | 260 | // --------------------------------------------------------------------------- |
| 257 | 261 | |
| 258 | −export type ExecutionEngine = { | |
| 262 | +export type ExecutionEngine<E extends Cause.YieldableError = CodeExecutionError> = { | |
| 259 | 263 | /** |
| 260 | 264 | * Execute code with elicitation handled inline by the provided handler. |
| 261 | 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 | 271 | readonly execute: ( |
| 264 | 272 | code: string, |
| 265 | 273 | options: { readonly onElicitation: ElicitationHandler }, |
| 266 | − ) => Promise<ExecuteResult>; | |
| 274 | + ) => Effect.Effect<ExecuteResult, E>; | |
| 267 | 275 | |
| 268 | 276 | /** |
| 269 | 277 | * Execute code, intercepting the first elicitation as a pause point. |
| 270 | 278 | * Use this when the host doesn't support inline elicitation. |
| 271 | 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 | 284 | * Resume a paused execution. Returns a completed result, a new pause, or |
| @@ -279,20 +287,21 @@ export type ExecutionEngine = { · 2 unmodified lines | ||
| 279 | 287 | readonly resume: ( |
| 280 | 288 | executionId: string, |
| 281 | 289 | response: ResumeResponse, |
| 282 | − ) => Promise<ExecutionResult | null>; | |
| 290 | + ) => Effect.Effect<ExecutionResult | null, E>; | |
| 283 | 291 | |
| 284 | 292 | /** |
| 285 | 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> => | |
| 291 | − Effect.runPromise(effect as Effect.Effect<A, never>); | |
| 292 | − | |
| 293 | −export const createExecutionEngine = (config: ExecutionEngineConfig): ExecutionEngine => { | |
| 298 | +export const createExecutionEngine = < | |
| 299 | + E extends Cause.YieldableError = CodeExecutionError, | |
| 300 | +>( | |
| 301 | + config: ExecutionEngineConfig<E>, | |
| 302 | +): ExecutionEngine<E> => { | |
| 294 | 303 | const { executor, codeExecutor } = config; |
| 295 | − const pausedExecutions = new Map<string, InternalPausedExecution>(); | |
| 304 | + const pausedExecutions = new Map<string, InternalPausedExecution<E>>(); | |
| 296 | 305 | let nextId = 0; |
| 297 | 306 | |
| 298 | 307 | /** |
| @@ -301,12 +310,11 @@ export const createExecutionEngine = (config: ExecutionEngineConfig): ExecutionE · 2 unmodified lines | ||
| 301 | 310 | * comes first). Re-used by both executeWithPause and resume. |
| 302 | 311 | */ |
| 303 | 312 | const awaitCompletionOrPause = ( |
| 304 | − fiber: Fiber.Fiber<ExecuteResult, unknown>, | |
| 305 | − pauseSignal: Deferred.Deferred<InternalPausedExecution>, | |
| 306 | − ): Effect.Effect<ExecutionResult> => | |
| 313 | + fiber: Fiber.Fiber<ExecuteResult, E>, | |
| 314 | + pauseSignal: Deferred.Deferred<InternalPausedExecution<E>>, | |
| 315 | + ): Effect.Effect<ExecutionResult, E> => | |
| 307 | 316 | Effect.race( |
| 308 | 317 | Fiber.join(fiber).pipe( |
| 309 | − Effect.orDie, | |
| 310 | 318 | Effect.map((result): ExecutionResult => ({ status: "completed", result })), |
| 311 | 319 | ), |
| 312 | 320 | Deferred.await(pauseSignal).pipe( |
| @@ -329,17 +337,17 @@ export const createExecutionEngine = (config: ExecutionEngineConfig): ExecutionE · 16 unmodified lines | ||
| 329 | 337 | // Ref holds the current pause signal. The elicitation handler reads |
| 330 | 338 | // it each time it fires, so resume() can swap in a fresh Deferred |
| 331 | 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 | 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 | 345 | const elicitationHandler: ElicitationHandler = (ctx) => |
| 338 | 346 | Effect.gen(function* () { |
| 339 | 347 | const responseDeferred = yield* Deferred.make<typeof ElicitationResponse.Type>(); |
| 340 | 348 | const id = `exec_${++nextId}`; |
| 341 | 349 | |
| 342 | − const paused: InternalPausedExecution = { | |
| 350 | + const paused: InternalPausedExecution<E> = { | |
| 343 | 351 | id, |
| 344 | 352 | elicitationContext: ctx, |
| 345 | 353 | response: responseDeferred, |
| @@ -383,7 +391,7 @@ export const createExecutionEngine = (config: ExecutionEngineConfig): ExecutionE · 37 unmodified lines | ||
| 383 | 391 | |
| 384 | 392 | // Swap in a fresh pause signal BEFORE unblocking the fiber, so the |
| 385 | 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 | 395 | yield* Ref.set(paused.pauseSignalRef, nextSignal); |
| 388 | 396 | |
| 389 | 397 | yield* Deferred.succeed(paused.response, { |
| @@ -415,12 +423,9 @@ export const createExecutionEngine = (config: ExecutionEngineConfig): ExecutionE · 25 unmodified lines | ||
| 415 | 423 | }); |
| 416 | 424 | |
| 417 | 425 | return { |
| 418 | − execute: (code, options) => runEffect(runInlineExecution(code, options)), | |
| 419 | − | |
| 420 | − executeWithPause: (code) => runEffect(startPausableExecution(code)), | |
| 421 | − | |
| 422 | − resume: (executionId, response) => runEffect(resumeExecution(executionId, response)), | |
| 423 | − | |
| 424 | − getDescription: () => runEffect(buildExecuteDescription(executor)), | |
| 426 | + execute: runInlineExecution, | |
| 427 | + executeWithPause: startPausableExecution, | |
| 428 | + resume: resumeExecution, | |
| 429 | + getDescription: buildExecuteDescription(executor), | |
| 425 | 430 | }; |
| 426 | 431 | }; |
| @@ -4,3 +4,8 @@ export class ExecutionToolError extends Data.TaggedError("ExecutionToolError")<{ · 3 unmodified lines | ||
| 4 | 4 | readonly message: string; |
| 5 | 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 | 2 | // @executor/execution/promise — Promise-native surface for the execution |
| 3 | −// engine. Accepts a Promise-style Executor (from @executor/sdk) and an | |
| 4 | −// async `onElicitation` handler — no Effect imports required by callers. | |
| 3 | +// engine. | |
| 4 | +// --------------------------------------------------------------------------- | |
| 5 | 5 | // |
| 6 | −// Under the hood the engine is Effect-based, so we wrap the incoming | |
| 7 | −// Promise executor into the minimal Effect shape the engine consumes, | |
| 8 | −// and bridge the elicitation handler via `Effect.tryPromise`. | |
| 6 | +// `engine.ts` is Effect-native; this module runs each method with | |
| 7 | +// `Effect.runPromise` at the boundary so hosts that can't compose Effects | |
| 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 | 13 | import { Effect } from "effect"; |
| 14 | +import type * as Cause from "effect/Cause"; | |
| 12 | 15 | |
| 13 | 16 | import type { |
| 14 | 17 | ElicitationContext, |
| 15 | 18 | ElicitationResponse, |
| 16 | 19 | Executor as PromiseExecutor, |
| 17 | 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 | 23 | import { |
| 21 | 24 | createExecutionEngine as createEffectExecutionEngine, |
| 25 | + type ExecutionEngine as EffectExecutionEngine, | |
| 22 | 26 | type ExecutionResult, |
| 23 | 27 | type PausedExecution, |
| 24 | 28 | type ResumeResponse, |
| @@ -28,9 +32,11 @@ export type ElicitationHandler = ( · 3 unmodified lines | ||
| 28 | 32 | ctx: ElicitationContext, |
| 29 | 33 | ) => Promise<ElicitationResponse>; |
| 30 | 34 | |
| 31 | −export type ExecutionEngineConfig = { | |
| 35 | +export type ExecutionEngineConfig< | |
| 36 | + E extends Cause.YieldableError = CodeExecutionError, | |
| 37 | +> = { | |
| 32 | 38 | readonly executor: PromiseExecutor; |
| 33 | − readonly codeExecutor: CodeExecutor; | |
| 39 | + readonly codeExecutor: CodeExecutor<E>; | |
| 34 | 40 | }; |
| 35 | 41 | |
| 36 | 42 | export type ExecutionEngine = { |
| @@ -49,7 +55,7 @@ export type ExecutionEngine = { · 12 unmodified lines | ||
| 49 | 55 | /** |
| 50 | 56 | * Wrap a Promise-style executor into the Effect shape the engine consumes. |
| 51 | 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 | 60 | const wrapPromiseExecutor = (pe: PromiseExecutor): any => ({ |
| 55 | 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 = ( | |
| 88 | − config: ExecutionEngineConfig, | |
| 89 | −): ExecutionEngine => { | |
| 90 | − const engine = createEffectExecutionEngine({ | |
| 91 | − executor: wrapPromiseExecutor(config.executor), | |
| 92 | − codeExecutor: config.codeExecutor, | |
| 93 | − }); | |
| 94 | − return { | |
| 95 | − execute: (code, options) => | |
| 93 | +/** | |
| 94 | + * Promise-wrap an Effect-native `ExecutionEngine` (from `./engine`). | |
| 95 | + * Exposed separately so callers that already hold an Effect engine | |
| 96 | + * (apps/cloud's execution-stack composes both) can convert it for hosts | |
| 97 | + * that need the Promise surface (host-mcp). | |
| 98 | + */ | |
| 99 | +export const toPromiseExecutionEngine = <E extends Cause.YieldableError>( | |
| 100 | + engine: EffectExecutionEngine<E>, | |
| 101 | +): ExecutionEngine => ({ | |
| 102 | + execute: (code, options) => | |
| 103 | + Effect.runPromise( | |
| 96 | 104 | engine.execute(code, { |
| 97 | 105 | onElicitation: (ctx) => |
| 98 | − Effect.tryPromise(() => options.onElicitation(ctx)).pipe( | |
| 99 | − Effect.orDie, | |
| 100 | − ), | |
| 106 | + Effect.tryPromise(() => options.onElicitation(ctx)).pipe(Effect.orDie), | |
| 101 | 107 | }), |
| 102 | − executeWithPause: (code) => engine.executeWithPause(code), | |
| 103 | − resume: (executionId, response) => engine.resume(executionId, response), | |
| 104 | − getDescription: () => engine.getDescription(), | |
| 105 | − }; | |
| 106 | −}; | |
| 108 | + ), | |
| 109 | + executeWithPause: (code) => Effect.runPromise(engine.executeWithPause(code)), | |
| 110 | + resume: (executionId, response) => Effect.runPromise(engine.resume(executionId, response)), | |
| 111 | + getDescription: () => Effect.runPromise(engine.getDescription), | |
| 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 | 127 | // Re-exports — plain types/helpers that don't carry Effect signatures. |
| 110 | 128 | // --------------------------------------------------------------------------- |
| 111 | 129 | |
| 112 | −export { | |
| 113 | − formatExecuteResult, | |
| 114 | − formatPausedExecution, | |
| 115 | −} from "./engine"; | |
| 130 | +export { formatExecuteResult, formatPausedExecution } from "./engine"; | |
| 116 | 131 | |
| 117 | 132 | export type { ExecutionResult, PausedExecution, ResumeResponse }; |
| 118 | 133 | |
| @@ -150,11 +150,9 @@ describe("tool discovery", () => { · 149 unmodified lines | ||
| 150 | 150 | }); |
| 151 | 151 | expect(crmOnly.map((match) => match.path)).toEqual(["crm.listContacts"]); |
| 152 | 152 | |
| 153 | − const sandboxResult = yield* Effect.promise(() => | |
| 154 | − createExecutionEngine({ executor, codeExecutor }).execute( | |
| 155 | − 'return await tools.search({ namespace: "crm", query: "create contact", limit: 5 });', | |
| 156 | − { onElicitation: acceptAll }, | |
| 157 | − ), | |
| 153 | + const sandboxResult = yield* createExecutionEngine({ executor, codeExecutor }).execute( | |
| 154 | + 'return await tools.search({ namespace: "crm", query: "create contact", limit: 5 });', | |
| 155 | + { onElicitation: acceptAll }, | |
| 158 | 156 | ); |
| 159 | 157 | expect(sandboxResult.error).toBeUndefined(); |
| 160 | 158 | expect(sandboxResult.result).toEqual([ |
| @@ -167,10 +165,9 @@ describe("tool discovery", () => { · 6 unmodified lines | ||
| 167 | 165 | Effect.gen(function* () { |
| 168 | 166 | const executor = yield* makeSearchExecutor(); |
| 169 | 167 | |
| 170 | − const listed = yield* Effect.promise(() => | |
| 171 | − createExecutionEngine({ executor, codeExecutor }).execute("return await tools.executor.sources.list();", { | |
| 172 | − onElicitation: acceptAll, | |
| 173 | − }), | |
| 168 | + const listed = yield* createExecutionEngine({ executor, codeExecutor }).execute( | |
| 169 | + "return await tools.executor.sources.list();", | |
| 170 | + { onElicitation: acceptAll }, | |
| 174 | 171 | ); |
| 175 | 172 | expect(listed.error).toBeUndefined(); |
| 176 | 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(() => | |
| 184 | − createExecutionEngine({ executor, codeExecutor }).execute( | |
| 185 | − 'return await tools.search({ query: "list contacts", namespace: "crm", limit: 5 });', | |
| 186 | − { onElicitation: acceptAll }, | |
| 187 | − ), | |
| 180 | + const searched = yield* createExecutionEngine({ executor, codeExecutor }).execute( | |
| 181 | + 'return await tools.search({ query: "list contacts", namespace: "crm", limit: 5 });', | |
| 182 | + { onElicitation: acceptAll }, | |
| 188 | 183 | ); |
| 189 | 184 | expect(searched.error).toBeUndefined(); |
| 190 | 185 | expect(searched.result).toEqual([expect.objectContaining({ path: "crm.listContacts" })]); |
| @@ -210,55 +205,48 @@ describe("tool discovery", () => { · 19 unmodified lines | ||
| 210 | 205 | const executor = yield* makeSearchExecutor(); |
| 211 | 206 | const engine = createExecutionEngine({ executor, codeExecutor }); |
| 212 | 207 | |
| 213 | − const invalid = yield* Effect.promise(() => | |
| 214 | − engine.execute( | |
| 215 | − [ | |
| 216 | − "try {", | |
| 217 | − ' await tools.search("github issues");', | |
| 218 | − ' return "unexpected";', | |
| 219 | − "} catch (error) {", | |
| 220 | − " return error instanceof Error ? error.message : String(error);", | |
| 221 | − "}", | |
| 222 | − ].join("\n"), | |
| 223 | − { onElicitation: acceptAll }, | |
| 224 | − ), | |
| 208 | + const invalid = yield* engine.execute( | |
| 209 | + [ | |
| 210 | + "try {", | |
| 211 | + ' await tools.search("github issues");', | |
| 212 | + ' return "unexpected";', | |
| 213 | + "} catch (error) {", | |
| 214 | + " return error instanceof Error ? error.message : String(error);", | |
| 215 | + "}", | |
| 216 | + ].join("\n"), | |
| 217 | + { onElicitation: acceptAll }, | |
| 225 | 218 | ); |
| 226 | 219 | expect(invalid.error).toBeUndefined(); |
| 227 | 220 | expect(String(invalid.result)).toContain( |
| 228 | 221 | "tools.search expects an object: { query?: string; namespace?: string; limit?: number }", |
| 229 | 222 | ); |
| 230 | 223 | |
| 231 | − const emptyQuery = yield* Effect.promise(() => | |
| 232 | − engine.execute('return await tools.search({ query: "", limit: 5 });', { | |
| 233 | − onElicitation: acceptAll, | |
| 234 | − }), | |
| 224 | + const emptyQuery = yield* engine.execute( | |
| 225 | + 'return await tools.search({ query: "", limit: 5 });', | |
| 226 | + { onElicitation: acceptAll }, | |
| 235 | 227 | ); |
| 236 | 228 | expect(emptyQuery.error).toBeUndefined(); |
| 237 | 229 | expect(emptyQuery.result).toEqual([]); |
| 238 | 230 | |
| 239 | − const invalidDescribe = yield* Effect.promise(() => | |
| 240 | − engine.execute( | |
| 241 | − [ | |
| 242 | − "try {", | |
| 243 | − ' await tools.describe.tool({ path: "github.listRepositoryIssues", includeSchemas: true });', | |
| 244 | − ' return "unexpected";', | |
| 245 | − "} catch (error) {", | |
| 246 | − " return error instanceof Error ? error.message : String(error);", | |
| 247 | − "}", | |
| 248 | − ].join("\n"), | |
| 249 | − { onElicitation: acceptAll }, | |
| 250 | − ), | |
| 231 | + const invalidDescribe = yield* engine.execute( | |
| 232 | + [ | |
| 233 | + "try {", | |
| 234 | + ' await tools.describe.tool({ path: "github.listRepositoryIssues", includeSchemas: true });', | |
| 235 | + ' return "unexpected";', | |
| 236 | + "} catch (error) {", | |
| 237 | + " return error instanceof Error ? error.message : String(error);", | |
| 238 | + "}", | |
| 239 | + ].join("\n"), | |
| 240 | + { onElicitation: acceptAll }, | |
| 251 | 241 | ); |
| 252 | 242 | expect(invalidDescribe.error).toBeUndefined(); |
| 253 | 243 | expect(String(invalidDescribe.result)).toContain( |
| 254 | 244 | "tools.describe.tool no longer accepts includeSchemas", |
| 255 | 245 | ); |
| 256 | 246 | |
| 257 | − const invalidSearch = yield* Effect.promise(() => | |
| 258 | − engine.execute( | |
| 259 | − 'try { return await tools.search("crm"); } catch (error) { return error instanceof Error ? error.message : String(error); }', | |
| 260 | − { onElicitation: acceptAll }, | |
| 261 | − ), | |
| 247 | + const invalidSearch = yield* engine.execute( | |
| 248 | + 'try { return await tools.search("crm"); } catch (error) { return error instanceof Error ? error.message : String(error); }', | |
| 249 | + { onElicitation: acceptAll }, | |
| 262 | 250 | ); |
| 263 | 251 | expect(invalidSearch.error).toBeUndefined(); |
| 264 | 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 | 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 | 326 | expect(outcome1.status).toBe("paused"); |
| 339 | 327 | const paused1 = outcome1 as Extract<typeof outcome1, { status: "paused" }>; |
| 340 | 328 | expect(paused1.execution.elicitationContext.request.message).toBe("First approval"); |
| @@ -344,7 +332,7 @@ describe("pause/resume with multiple elicitations", () => { · 3 unmodified lines | ||
| 344 | 332 | // result or the completion). |
| 345 | 333 | const outcome2 = yield* Effect.promise(() => |
| 346 | 334 | Promise.race([ |
| 347 | − engine.resume(paused1.execution.id, { action: "accept" }), | |
| 335 | + Effect.runPromise(engine.resume(paused1.execution.id, { action: "accept" })), | |
| 348 | 336 | new Promise<never>((_, reject) => |
| 349 | 337 | setTimeout( |
| 350 | 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 | 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 | 360 | expect(outcome1.status).toBe("paused"); |
| 373 | 361 | const paused1 = outcome1 as Extract<typeof outcome1, { status: "paused" }>; |
| 374 | 362 | expect(paused1.execution.elicitationContext.request.message).toBe("Only approval"); |
| @@ -389,7 +377,7 @@ describe("pause/resume with multiple elicitations", () => { · 14 unmodified lines | ||
| 389 | 377 | expect(exitProbe).toBe("still-running"); |
| 390 | 378 | |
| 391 | 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 | 381 | new Promise<never>((_, reject) => |
| 394 | 382 | setTimeout(() => reject(new Error("resume hung across runPromise boundaries")), 2000), |
| 395 | 383 | ), |
| @@ -14,6 +14,7 @@ import type { · 13 unmodified lines | ||
| 14 | 14 | ElicitationContext, |
| 15 | 15 | ElicitationRequest, |
| 16 | 16 | } from "@executor/sdk"; |
| 17 | +import type * as Cause from "effect/Cause"; | |
| 17 | 18 | import { |
| 18 | 19 | createExecutionEngine, |
| 19 | 20 | formatExecuteResult, |
| @@ -44,11 +45,23 @@ class CfWorkerJsonSchemaValidator implements jsonSchemaValidator { · 24 unmodified lines | ||
| 44 | 45 | // Config |
| 45 | 46 | // --------------------------------------------------------------------------- |
| 46 | 47 | |
| 47 | −export type ExecutorMcpServerConfig = | |
| 48 | − | ExecutionEngineConfig | |
| 49 | − | { readonly engine: ExecutionEngine } | |
| 50 | − | (ExecutionEngineConfig & { readonly stateless: true }) | |
| 51 | − | { readonly engine: ExecutionEngine; readonly stateless: true }; | |
| 48 | +type SharedMcpServerConfig = { | |
| 49 | + /** | |
| 50 | + * Pre-built `execute` tool description. When provided, the factory skips | |
| 51 | + * its internal `engine.getDescription()` call. Useful when the caller | |
| 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 | 67 | // Elicitation bridge |
| @@ -153,11 +166,11 @@ const toMcpPausedResult = (formatted: ReturnType<typeof formatPausedExecution>): · 98 unmodified lines | ||
| 153 | 166 | // Server factory |
| 154 | 167 | // --------------------------------------------------------------------------- |
| 155 | 168 | |
| 156 | −export const createExecutorMcpServer = async ( | |
| 157 | − config: ExecutorMcpServerConfig, | |
| 169 | +export const createExecutorMcpServer = async <E extends Cause.YieldableError>( | |
| 170 | + config: ExecutorMcpServerConfig<E>, | |
| 158 | 171 | ): Promise<McpServer> => { |
| 159 | 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 | 175 | const server = new McpServer( |
| 163 | 176 | { name: "executor", version: "1.0.0" }, |
| @@ -166,13 +179,15 @@ export const createExecutorMcpServer = async ( · 2 unmodified lines | ||
| 166 | 179 | |
| 167 | 180 | const executeCode = async (code: string): Promise<McpToolResult> => { |
| 168 | 181 | if (supportsManagedElicitation(server)) { |
| 169 | − const result = await engine.execute(code, { | |
| 170 | − onElicitation: makeMcpElicitationHandler(server), | |
| 171 | − }); | |
| 182 | + const result = await Effect.runPromise( | |
| 183 | + engine.execute(code, { | |
| 184 | + onElicitation: makeMcpElicitationHandler(server), | |
| 185 | + }), | |
| 186 | + ); | |
| 172 | 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 | 191 | return outcome.status === "completed" |
| 177 | 192 | ? toMcpResult(formatExecuteResult(outcome.result)) |
| 178 | 193 | : toMcpPausedResult(formatPausedExecution(outcome.execution)); |
| @@ -222,7 +237,7 @@ export const createExecutorMcpServer = async ( · 43 unmodified lines | ||
| 222 | 237 | }, |
| 223 | 238 | async ({ executionId, action, content: rawContent }) => { |
| 224 | 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 | 242 | if (!outcome) { |
| 228 | 243 | return { |
| @@ -7,3 +7,17 @@ export class KernelCoreEffectError extends Data.TaggedError("KernelCoreEffectErr · 6 unmodified lines | ||
| 7 | 7 | |
| 8 | 8 | export const kernelCoreEffectError = (module: string, message: string) => |
| 9 | 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 | 1 | import type { StandardSchemaV1 } from "@standard-schema/spec"; |
| 2 | +import type * as Cause from "effect/Cause"; | |
| 2 | 3 | import type * as Effect from "effect/Effect"; |
| 3 | 4 | |
| 5 | +import type { CodeExecutionError } from "./effect-errors"; | |
| 6 | + | |
| 4 | 7 | /** Branded tool path */ |
| 5 | 8 | export type ToolPath = string & { readonly __toolPath: unique symbol }; |
| 6 | 9 | |
| @@ -30,9 +33,17 @@ export type ExecuteResult = { · 23 unmodified lines | ||
| 30 | 33 | logs?: string[]; |
| 31 | 34 | }; |
| 32 | 35 | |
| 33 | −/** Executes code in a sandboxed runtime with tool access */ | |
| 34 | −export interface CodeExecutor { | |
| 35 | − execute(code: string, toolInvoker: SandboxToolInvoker): Effect.Effect<ExecuteResult, unknown>; | |
| 36 | +/** | |
| 37 | + * Executes code in a sandboxed runtime with tool access. | |
| 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 | 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 | 331 | export const makeDenoSubprocessExecutor = ( |
| 332 | 332 | options: DenoSubprocessExecutorOptions = {}, |
| 333 | −): CodeExecutor => ({ | |
| 333 | +): CodeExecutor<never> => ({ | |
| 334 | 334 | execute: (code: string, toolInvoker: SandboxToolInvoker) => |
| 335 | 335 | executeInDeno(code, toolInvoker, options), |
| 336 | 336 | }); |
| @@ -323,7 +323,9 @@ const runInDynamicWorker = ( · 322 unmodified lines | ||
| 323 | 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 | 329 | execute: (code: string, toolInvoker: SandboxToolInvoker) => |
| 328 | 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 | 402 | execute: (code: string, toolInvoker: SandboxToolInvoker) => |
| 401 | 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 | 447 | execute: (code: string, toolInvoker: SandboxToolInvoker) => |
| 446 | 448 | evaluateInSecureExec(options, code, toolInvoker), |
| 447 | 449 | }); |