## Motivation `McpSessionDO.init` span on the dashboard showed a single 2.77s root with no children. Sub-spans inside the engine (postgres queries, MCP server registration) never appeared as children because every public engine method was a Promise wrapping `Effect.runPromise` internally — each call spawned a new root trace and orphaned the work done underneath. Also: `CodeExecutor.execute` returned `Effect<ExecuteResult, unknown>`, which forced `cause instanceof Error ? cause.message : String(cause)` boilerplate at every error boundary and made Sentry / dashboard filtering impossible to type. ## What changed `@executor/execution`'s `engine.ts` is now Effect-native: - `ExecutionEngine<E extends Cause.YieldableError = CodeExecutionError>` — methods return `Effect<..., E>` not `Promise<...>`. - `createExecutionEngine` propagates the runtime's typed error through. - `InternalPausedExecution<E>` follows. `@executor/execution/promise` becomes the sole home for `Effect.runPromise`: - `createExecutionEngine` (Promise API) unchanged externally — same signatures callers were using. - New `toPromiseExecutionEngine(effectEngine)` lets Effect-aware hosts that need a Promise adapter (e.g. the `ExecutionEngineService` consumers) convert at a single edge. `@executor/codemode-core`'s `CodeExecutor`: - Now `CodeExecutor<E extends Cause.YieldableError = CodeExecutionError>`. - Default error is the new `CodeExecutionError` tagged error. - Every runtime parameterizes with its own tagged error: - `runtime-dynamic-worker` → `CodeExecutor<DynamicWorkerExecutionError>` - `runtime-quickjs` → `CodeExecutor<QuickJsExecutionError>` - `runtime-secure-exec` / `runtime-deno-subprocess` → `CodeExecutor<never>` (both already catch internally and fold errors into `ExecuteResult`). `host-mcp` stays Promise-at-the-boundary — MCP SDK callbacks are async — but now imports from `@executor/execution` (Effect) and calls `Effect.runPromise` only at the SDK callback boundary, where it belongs. Factory is generic in `E` so callers flow their runtime-specific error through unchanged. `@executor/api`'s `ExecutionEngineService` is Effect-typed (`ExecutionEngine<Cause.YieldableError>`); its `handlers/executions.ts` drops every `Effect.promise(() => engine.x(...))` wrapper in favour of `yield* engine.x(...)`. ## Apps/cloud fallout - `services/execution-stack.ts`: returns a single Effect engine; no Promise wrapper path. - `api/execution-usage.ts`: `withExecutionUsageTracking<E>` is Effect- native and generic — `Effect.tap` to record usage, no `async` wrappers, no `Promise<...>` plumbing. - `mcp-session.ts`: `createConnectedRuntimeEffect` now yields `buildExecuteDescription(executor)` directly, so the two postgres queries inside (`executor.sources.list`, `executor.tools.list`) land as children of `McpSessionDO.createRuntime` in the trace. - `api/protected.ts` + test harnesses: pass Effect engines to `ExecutionEngineService` directly. ## Verified in prod Init trace tree after deploy (sample trace `16b2e150e986d703b3b23adad6ae3816`): ``` McpSessionDO.init 2.51s ├── resolveSessionMeta 231ms └── createRuntime 2.28s ├── makeExecutionStack → createScopedExecutor 0ms ├── buildExecuteDescription 2.28s │ ├── executor.sources.list 167ms │ └── executor.tools.list 2.11s ← real bottleneck ├── createExecutorMcpServer 0ms └── transport.connect 0ms ``` `executor.tools.list` is now visible as the 2.1s postgres scan — the actual optimization target. Before this refactor it was hidden under an opaque 2.77s `init` span.
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), |
| @@ -11,6 +11,7 @@ import { drizzle } from "drizzle-orm/postgres-js"; · 10 unmodified lines | ||
| 11 | 11 | import postgres from "postgres"; |
| 12 | 12 | |
| 13 | 13 | import { createExecutorMcpServer } from "@executor/host-mcp"; |
| 14 | +import { buildExecuteDescription } from "@executor/execution"; | |
| 14 | 15 | import type { DrizzleDb, DbServiceShape } from "./services/db"; |
| 15 | 16 | |
| 16 | 17 | // Import directly from core-shared-services, NOT from ./api/layers.ts. |
| @@ -176,12 +177,18 @@ export class McpSessionDO extends DurableObject { · 159 unmodified lines | ||
| 176 | 177 | ) { |
| 177 | 178 | const self = this; |
| 178 | 179 | return Effect.gen(function* () { |
| 179 | − const { engine } = yield* makeExecutionStack( | |
| 180 | + const { executor, engine } = yield* makeExecutionStack( | |
| 180 | 181 | sessionMeta.organizationId, |
| 181 | 182 | sessionMeta.organizationName, |
| 182 | 183 | ); |
| 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); | |
| 183 | 190 | const mcpServer = yield* Effect.promise(() => |
| 184 | − createExecutorMcpServer({ engine }), | |
| 191 | + createExecutorMcpServer({ engine, description }), | |
| 185 | 192 | ).pipe(Effect.withSpan("McpSessionDO.createExecutorMcpServer")); |
| 186 | 193 | const transport = new WorkerTransport({ |
| 187 | 194 | sessionIdGenerator: () => self.ctx.id.toString(), |
| @@ -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 | }); |