History

refactor(execution): flip engine to Effect-native, propagate typed errors

## 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.

Rhys Sullivan committed 6 months ago

Commit 2fa41671 parent ce44a5918 files · +256 −191Browse files

Unified · Split

18 files
  1. apps/cloud/src/api/execution-usage.ts +16 −18
  2. apps/cloud/src/api/protected.test.ts +22 −26
  3. apps/cloud/src/api/protected.ts +0 −3
  4. apps/cloud/src/mcp-session.ts +9 −2
  5. packages/core/api/src/handlers/executions.ts +5 −7
  6. packages/core/api/src/services.ts +7 −1
  7. packages/core/execution/src/description.ts +7 −3
  8. packages/core/execution/src/engine.ts +35 −30
  9. packages/core/execution/src/errors.ts +5 −0
  10. packages/core/execution/src/promise.ts +45 −30
  11. packages/core/execution/src/tool-invoker.test.ts +39 −51
  12. packages/hosts/mcp/src/server.ts +28 −13
  13. packages/kernel/core/src/effect-errors.ts +14 −0
  14. packages/kernel/core/src/types.ts +14 −3
  15. packages/kernel/runtime-deno-subprocess/src/index.ts +1 −1
  16. packages/kernel/runtime-dynamic-worker/src/executor.ts +3 −1
  17. packages/kernel/runtime-quickjs/src/index.ts +3 −1
  18. packages/kernel/runtime-secure-exec/src/index.ts +3 −1
apps/cloud/src/api/execution-usage.ts+16−18
@@ -1,24 +1,22 @@
1import { Effect } from "effect";
2import type * as Cause from "effect/Cause";
3
1import type { ExecutionEngine } from "@executor/execution";4import type { ExecutionEngine } from "@executor/execution";
2 5
3export const withExecutionUsageTracking = (6export const withExecutionUsageTracking = <E extends Cause.YieldableError>(
4 organizationId: string,7 organizationId: string,
5 engine: ExecutionEngine,8 engine: ExecutionEngine<E>,
6 trackUsage: (organizationId: string) => void,9 trackUsage: (organizationId: string) => void,
7): ExecutionEngine => ({10): ExecutionEngine<E> => ({
8 execute: async (code, options) => {11 execute: (code, options) =>
9 const result = await engine.execute(code, options);12 engine
10 trackUsage(organizationId);13 .execute(code, options)
11 return result;14 .pipe(Effect.tap(() => Effect.sync(() => trackUsage(organizationId)))),
12 },15 executeWithPause: (code) =>
13 executeWithPause: async (code) => {16 engine
14 const result = await engine.executeWithPause(code);17 .executeWithPause(code)
15 trackUsage(organizationId);18 .pipe(Effect.tap(() => Effect.sync(() => trackUsage(organizationId)))),
16 return result;19 // resume doesn't count as usage
17 },20 resume: (executionId, response) => engine.resume(executionId, response),
18 resume: async (executionId, response) => {
19 const result = await engine.resume(executionId, response);
20 // resume doesn't count as usage
21 return result;
22 },
23 getDescription: engine.getDescription,21 getDescription: engine.getDescription,
24});22});
apps/cloud/src/api/protected.test.ts+22−26
@@ -5,16 +5,18 @@ import { withExecutionUsageTracking } from "./execution-usage"; · 4 unmodified lines
5 5
6const makeBaseEngine = (): ExecutionEngine =>6const makeBaseEngine = (): ExecutionEngine =>
7 ({7 ({
8 execute: async () => ({ result: "ok", logs: [] }),8 execute: () => Effect.succeed({ result: "ok", logs: [] }),
9 executeWithPause: async () => ({9 executeWithPause: () =>
10 status: "completed",10 Effect.succeed({
11 result: { result: "ok", logs: [] },11 status: "completed",
12 }),12 result: { result: "ok", logs: [] },
13 resume: async () => ({13 }),
14 status: "completed",14 resume: () =>
15 result: { result: "ok", logs: [] },15 Effect.succeed({
16 }),16 status: "completed",
17 getDescription: async () => "desc",17 result: { result: "ok", logs: [] },
18 }),
19 getDescription: Effect.succeed("desc"),
18 }) as ExecutionEngine;20 }) as ExecutionEngine;
19 21
20describe("withExecutionUsageTracking", () => {22describe("withExecutionUsageTracking", () => {
@@ -25,10 +27,8 @@ describe("withExecutionUsageTracking", () => { · 4 unmodified lines
25 tracked.push(orgId);27 tracked.push(orgId);
26 });28 });
27 29
28 yield* Effect.promise(() =>30 yield* engine.execute("1+1", { onElicitation: (() => Effect.die("unused")) as never });
29 engine.execute("1+1", { onElicitation: (() => Effect.die("unused")) as never }),31 yield* engine.executeWithPause("2+2");
30 );
31 yield* Effect.promise(() => engine.executeWithPause("2+2"));
32 32
33 expect(tracked).toEqual(["org_1", "org_1"]);33 expect(tracked).toEqual(["org_1", "org_1"]);
34 }),34 }),
@@ -44,8 +44,8 @@ describe("withExecutionUsageTracking", () => { · 9 unmodified lines
44 "org_2",44 "org_2",
45 {45 {
46 ...base,46 ...base,
47 resume: async (...args) => {47 resume: (...args) => {
48 if (shouldReturnNull) return null;48 if (shouldReturnNull) return Effect.succeed(null);
49 return base.resume(...args);49 return base.resume(...args);
50 },50 },
51 },51 },
@@ -54,17 +54,13 @@ describe("withExecutionUsageTracking", () => { · 2 unmodified lines
54 },54 },
55 );55 );
56 56
57 yield* Effect.promise(() =>57 yield* engine.resume("exec_1", {
58 engine.resume("exec_1", {58 action: "accept",
59 action: "accept",59 });
60 }),
61 );
62 shouldReturnNull = true;60 shouldReturnNull = true;
63 yield* Effect.promise(() =>61 yield* engine.resume("missing", {
64 engine.resume("missing", {62 action: "accept",
65 action: "accept",63 });
66 }),
67 );
68 64
69 expect(tracked).toEqual([]);65 expect(tracked).toEqual([]);
70 }),66 }),
apps/cloud/src/api/protected.ts+0−3
@@ -34,9 +34,6 @@ const createProtectedApp = (organizationId: string, organizationName: string) => · 33 unmodified lines
34 Effect.gen(function* () {34 Effect.gen(function* () {
35 const { executor, engine } = yield* makeExecutionStack(organizationId, organizationName);35 const { executor, engine } = yield* makeExecutionStack(organizationId, organizationName);
36 36
37 // Handlers wrap their own bodies with `capture(...)` — the edge
38 // translation lives per-handler, not at service construction. See
39 // notes/error-handling.md.
40 const requestServices = Layer.mergeAll(37 const requestServices = Layer.mergeAll(
41 Layer.succeed(ExecutorService, executor),38 Layer.succeed(ExecutorService, executor),
42 Layer.succeed(ExecutionEngineService, engine),39 Layer.succeed(ExecutionEngineService, engine),
apps/cloud/src/mcp-session.ts+9−2
@@ -11,6 +11,7 @@ import { drizzle } from "drizzle-orm/postgres-js"; · 10 unmodified lines
11import postgres from "postgres";11import postgres from "postgres";
12 12
13import { createExecutorMcpServer } from "@executor/host-mcp";13import { createExecutorMcpServer } from "@executor/host-mcp";
14import { buildExecuteDescription } from "@executor/execution";
14import type { DrizzleDb, DbServiceShape } from "./services/db";15import type { DrizzleDb, DbServiceShape } from "./services/db";
15 16
16// Import directly from core-shared-services, NOT from ./api/layers.ts.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 const self = this;178 const self = this;
178 return Effect.gen(function* () {179 return Effect.gen(function* () {
179 const { engine } = yield* makeExecutionStack(180 const { executor, engine } = yield* makeExecutionStack(
180 sessionMeta.organizationId,181 sessionMeta.organizationId,
181 sessionMeta.organizationName,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 const mcpServer = yield* Effect.promise(() =>190 const mcpServer = yield* Effect.promise(() =>
184 createExecutorMcpServer({ engine }),191 createExecutorMcpServer({ engine, description }),
185 ).pipe(Effect.withSpan("McpSessionDO.createExecutorMcpServer"));192 ).pipe(Effect.withSpan("McpSessionDO.createExecutorMcpServer"));
186 const transport = new WorkerTransport({193 const transport = new WorkerTransport({
187 sessionIdGenerator: () => self.ctx.id.toString(),194 sessionIdGenerator: () => self.ctx.id.toString(),
packages/core/api/src/handlers/executions.ts+5−7
@@ -11,7 +11,7 @@ export const ExecutionsHandlers = HttpApiBuilder.group(ExecutorApi, "executions" · 10 unmodified lines
11 .handle("execute", ({ payload }) =>11 .handle("execute", ({ payload }) =>
12 capture(Effect.gen(function* () {12 capture(Effect.gen(function* () {
13 const engine = yield* ExecutionEngineService;13 const engine = yield* ExecutionEngineService;
14 const outcome = yield* Effect.promise(() => engine.executeWithPause(payload.code));14 const outcome = yield* engine.executeWithPause(payload.code);
15 15
16 if (outcome.status === "completed") {16 if (outcome.status === "completed") {
17 const formatted = formatExecuteResult(outcome.result);17 const formatted = formatExecuteResult(outcome.result);
@@ -34,12 +34,10 @@ export const ExecutionsHandlers = HttpApiBuilder.group(ExecutorApi, "executions" · 16 unmodified lines
34 .handle("resume", ({ path, payload }) =>34 .handle("resume", ({ path, payload }) =>
35 capture(Effect.gen(function* () {35 capture(Effect.gen(function* () {
36 const engine = yield* ExecutionEngineService;36 const engine = yield* ExecutionEngineService;
37 const result = yield* Effect.promise(() =>37 const result = yield* engine.resume(path.executionId, {
38 engine.resume(path.executionId, {38 action: payload.action,
39 action: payload.action,39 content: payload.content as Record<string, unknown> | undefined,
40 content: payload.content as Record<string, unknown> | undefined,40 });
41 }),
42 );
43 41
44 if (!result) {42 if (!result) {
45 return yield* Effect.fail({43 return yield* Effect.fail({
packages/core/api/src/services.ts+7−1
@@ -1,4 +1,5 @@
1import { Context } from "effect";1import { Context } from "effect";
2import type * as Cause from "effect/Cause";
2import type { Executor } from "@executor/sdk";3import type { Executor } from "@executor/sdk";
3import type { ExecutionEngine } from "@executor/execution";4import type { ExecutionEngine } from "@executor/execution";
4 5
@@ -7,7 +8,12 @@ export class ExecutorService extends Context.Tag("ExecutorService")< · 2 unmodified lines
7 Executor8 Executor
8>() {}9>() {}
9 10
11// Error channel widened to `Cause.YieldableError` so callers that plug
12// in a runtime-specific tagged error (e.g.
13// `ExecutionEngine<DynamicWorkerExecutionError>`) assign structurally.
14// Handlers yield directly; defects flow through `Effect.catchAllCause`
15// at the edge.
10export class ExecutionEngineService extends Context.Tag("ExecutionEngineService")<16export class ExecutionEngineService extends Context.Tag("ExecutionEngineService")<
11 ExecutionEngineService,17 ExecutionEngineService,
12 ExecutionEngine18 ExecutionEngine<Cause.YieldableError>
13>() {}19>() {}
packages/core/execution/src/description.ts+7−3
@@ -10,14 +10,18 @@ import type { Executor, Tool, Source } from "@executor/sdk"; · 9 unmodified lines
10 */10 */
11export const buildExecuteDescription = (executor: Executor): Effect.Effect<string> =>11export const buildExecuteDescription = (executor: Executor): Effect.Effect<string> =>
12 Effect.gen(function* () {12 Effect.gen(function* () {
13 const sources: readonly Source[] = yield* executor.sources.list().pipe(Effect.orDie);13 const sources: readonly Source[] = yield* executor.sources
14 const tools: readonly Tool[] = yield* executor.tools.list().pipe(Effect.orDie);14 .list()
15 .pipe(Effect.orDie, Effect.withSpan("executor.sources.list"));
16 const tools: readonly Tool[] = yield* executor.tools
17 .list()
18 .pipe(Effect.orDie, Effect.withSpan("executor.tools.list"));
15 19
16 const namespaces = new Set<string>();20 const namespaces = new Set<string>();
17 for (const tool of tools) namespaces.add(tool.sourceId);21 for (const tool of tools) namespaces.add(tool.sourceId);
18 22
19 return formatDescription([...namespaces], sources);23 return formatDescription([...namespaces], sources);
20 });24 }).pipe(Effect.withSpan("buildExecuteDescription"));
21 25
22const formatDescription = (namespaces: readonly string[], sources: readonly Source[]): string => {26const formatDescription = (namespaces: readonly string[], sources: readonly Source[]): string => {
23 const lines: string[] = [27 const lines: string[] = [
packages/core/execution/src/engine.ts+35−30
@@ -1,4 +1,5 @@
1import { Deferred, Effect, Fiber, Ref } from "effect";1import { Deferred, Effect, Fiber, Ref } from "effect";
2import type * as Cause from "effect/Cause";
2 3
3import type {4import type {
4 Executor,5 Executor,
@@ -7,6 +8,7 @@ import type { · 2 unmodified lines
7 ElicitationHandler,8 ElicitationHandler,
8 ElicitationContext,9 ElicitationContext,
9} from "@executor/sdk";10} from "@executor/sdk";
11import { CodeExecutionError } from "@executor/codemode-core";
10import type { CodeExecutor, ExecuteResult, SandboxToolInvoker } from "@executor/codemode-core";12import type { CodeExecutor, ExecuteResult, SandboxToolInvoker } from "@executor/codemode-core";
11 13
12import {14import {
@@ -22,9 +24,11 @@ import { buildExecuteDescription } from "./description"; · 9 unmodified lines
22// Types24// Types
23// ---------------------------------------------------------------------------25// ---------------------------------------------------------------------------
24 26
25export type ExecutionEngineConfig = {27export type ExecutionEngineConfig<
28 E extends Cause.YieldableError = CodeExecutionError,
29> = {
26 readonly executor: Executor;30 readonly executor: Executor;
27 readonly codeExecutor: CodeExecutor;31 readonly codeExecutor: CodeExecutor<E>;
28};32};
29 33
30export type ExecutionResult =34export type ExecutionResult =
@@ -37,10 +41,10 @@ export type PausedExecution = { · 6 unmodified lines
37};41};
38 42
39/** Internal representation with Effect runtime state for pause/resume. */43/** Internal representation with Effect runtime state for pause/resume. */
40type InternalPausedExecution = PausedExecution & {44type InternalPausedExecution<E> = PausedExecution & {
41 readonly response: Deferred.Deferred<typeof ElicitationResponse.Type>;45 readonly response: Deferred.Deferred<typeof ElicitationResponse.Type>;
42 readonly fiber: Fiber.Fiber<ExecuteResult, unknown>;46 readonly fiber: Fiber.Fiber<ExecuteResult, E>;
43 readonly pauseSignalRef: Ref.Ref<Deferred.Deferred<InternalPausedExecution>>;47 readonly pauseSignalRef: Ref.Ref<Deferred.Deferred<InternalPausedExecution<E>>>;
44};48};
45 49
46export type ResumeResponse = {50export type ResumeResponse = {
@@ -255,22 +259,26 @@ const makeFullInvoker = (executor: Executor, invokeOptions: InvokeOptions): Sand · 208 unmodified lines
255// Execution Engine259// Execution Engine
256// ---------------------------------------------------------------------------260// ---------------------------------------------------------------------------
257 261
258export type ExecutionEngine = {262export type ExecutionEngine<E extends Cause.YieldableError = CodeExecutionError> = {
259 /**263 /**
260 * Execute code with elicitation handled inline by the provided handler.264 * Execute code with elicitation handled inline by the provided handler.
261 * Use this when the host supports elicitation (e.g. MCP with elicitation capability).265 * Use this when the host supports elicitation (e.g. MCP with elicitation capability).
266 *
267 * Fails with the code executor's typed error `E` (defaults to
268 * `CodeExecutionError`). Runtimes surface their own `Data.TaggedError`
269 * subclass, which flows through here unchanged.
262 */270 */
263 readonly execute: (271 readonly execute: (
264 code: string,272 code: string,
265 options: { readonly onElicitation: ElicitationHandler },273 options: { readonly onElicitation: ElicitationHandler },
266 ) => Promise<ExecuteResult>;274 ) => Effect.Effect<ExecuteResult, E>;
267 275
268 /**276 /**
269 * Execute code, intercepting the first elicitation as a pause point.277 * Execute code, intercepting the first elicitation as a pause point.
270 * Use this when the host doesn't support inline elicitation.278 * Use this when the host doesn't support inline elicitation.
271 * Returns either a completed result or a paused execution that can be resumed.279 * Returns either a completed result or a paused execution that can be resumed.
272 */280 */
273 readonly executeWithPause: (code: string) => Promise<ExecutionResult>;281 readonly executeWithPause: (code: string) => Effect.Effect<ExecutionResult, E>;
274 282
275 /**283 /**
276 * Resume a paused execution. Returns a completed result, a new pause, or284 * Resume a paused execution. Returns a completed result, a new pause, or
@@ -279,20 +287,21 @@ export type ExecutionEngine = { · 2 unmodified lines
279 readonly resume: (287 readonly resume: (
280 executionId: string,288 executionId: string,
281 response: ResumeResponse,289 response: ResumeResponse,
282 ) => Promise<ExecutionResult | null>;290 ) => Effect.Effect<ExecutionResult | null, E>;
283 291
284 /**292 /**
285 * Get the dynamic tool description (workflow + namespaces).293 * Get the dynamic tool description (workflow + namespaces).
286 */294 */
287 readonly getDescription: () => Promise<string>;295 readonly getDescription: Effect.Effect<string>;
288};296};
289 297
290const runEffect = <A>(effect: Effect.Effect<A, unknown>): Promise<A> =>298export const createExecutionEngine = <
291 Effect.runPromise(effect as Effect.Effect<A, never>);299 E extends Cause.YieldableError = CodeExecutionError,
292 300>(
293export const createExecutionEngine = (config: ExecutionEngineConfig): ExecutionEngine => {301 config: ExecutionEngineConfig<E>,
302): ExecutionEngine<E> => {
294 const { executor, codeExecutor } = config;303 const { executor, codeExecutor } = config;
295 const pausedExecutions = new Map<string, InternalPausedExecution>();304 const pausedExecutions = new Map<string, InternalPausedExecution<E>>();
296 let nextId = 0;305 let nextId = 0;
297 306
298 /**307 /**
@@ -301,12 +310,11 @@ export const createExecutionEngine = (config: ExecutionEngineConfig): ExecutionE · 2 unmodified lines
301 * comes first). Re-used by both executeWithPause and resume.310 * comes first). Re-used by both executeWithPause and resume.
302 */311 */
303 const awaitCompletionOrPause = (312 const awaitCompletionOrPause = (
304 fiber: Fiber.Fiber<ExecuteResult, unknown>,313 fiber: Fiber.Fiber<ExecuteResult, E>,
305 pauseSignal: Deferred.Deferred<InternalPausedExecution>,314 pauseSignal: Deferred.Deferred<InternalPausedExecution<E>>,
306 ): Effect.Effect<ExecutionResult> =>315 ): Effect.Effect<ExecutionResult, E> =>
307 Effect.race(316 Effect.race(
308 Fiber.join(fiber).pipe(317 Fiber.join(fiber).pipe(
309 Effect.orDie,
310 Effect.map((result): ExecutionResult => ({ status: "completed", result })),318 Effect.map((result): ExecutionResult => ({ status: "completed", result })),
311 ),319 ),
312 Deferred.await(pauseSignal).pipe(320 Deferred.await(pauseSignal).pipe(
@@ -329,17 +337,17 @@ export const createExecutionEngine = (config: ExecutionEngineConfig): ExecutionE · 16 unmodified lines
329 // Ref holds the current pause signal. The elicitation handler reads337 // Ref holds the current pause signal. The elicitation handler reads
330 // it each time it fires, so resume() can swap in a fresh Deferred338 // it each time it fires, so resume() can swap in a fresh Deferred
331 // before unblocking the fiber.339 // before unblocking the fiber.
332 const pauseSignalRef = yield* Ref.make(yield* Deferred.make<InternalPausedExecution>());340 const pauseSignalRef = yield* Ref.make(yield* Deferred.make<InternalPausedExecution<E>>());
333 341
334 // Will be set once the fiber is forked.342 // Will be set once the fiber is forked.
335 let fiber: Fiber.Fiber<ExecuteResult, unknown>;343 let fiber: Fiber.Fiber<ExecuteResult, E>;
336 344
337 const elicitationHandler: ElicitationHandler = (ctx) =>345 const elicitationHandler: ElicitationHandler = (ctx) =>
338 Effect.gen(function* () {346 Effect.gen(function* () {
339 const responseDeferred = yield* Deferred.make<typeof ElicitationResponse.Type>();347 const responseDeferred = yield* Deferred.make<typeof ElicitationResponse.Type>();
340 const id = `exec_${++nextId}`;348 const id = `exec_${++nextId}`;
341 349
342 const paused: InternalPausedExecution = {350 const paused: InternalPausedExecution<E> = {
343 id,351 id,
344 elicitationContext: ctx,352 elicitationContext: ctx,
345 response: responseDeferred,353 response: responseDeferred,
@@ -383,7 +391,7 @@ export const createExecutionEngine = (config: ExecutionEngineConfig): ExecutionE · 37 unmodified lines
383 391
384 // Swap in a fresh pause signal BEFORE unblocking the fiber, so the392 // Swap in a fresh pause signal BEFORE unblocking the fiber, so the
385 // next elicitation handler call signals this new Deferred.393 // next elicitation handler call signals this new Deferred.
386 const nextSignal = yield* Deferred.make<InternalPausedExecution>();394 const nextSignal = yield* Deferred.make<InternalPausedExecution<E>>();
387 yield* Ref.set(paused.pauseSignalRef, nextSignal);395 yield* Ref.set(paused.pauseSignalRef, nextSignal);
388 396
389 yield* Deferred.succeed(paused.response, {397 yield* Deferred.succeed(paused.response, {
@@ -415,12 +423,9 @@ export const createExecutionEngine = (config: ExecutionEngineConfig): ExecutionE · 25 unmodified lines
415 });423 });
416 424
417 return {425 return {
418 execute: (code, options) => runEffect(runInlineExecution(code, options)),426 execute: runInlineExecution,
419 427 executeWithPause: startPausableExecution,
420 executeWithPause: (code) => runEffect(startPausableExecution(code)),428 resume: resumeExecution,
421 429 getDescription: buildExecuteDescription(executor),
422 resume: (executionId, response) => runEffect(resumeExecution(executionId, response)),
423
424 getDescription: () => runEffect(buildExecuteDescription(executor)),
425 };430 };
426};431};
packages/core/execution/src/errors.ts+5−0
@@ -4,3 +4,8 @@ export class ExecutionToolError extends Data.TaggedError("ExecutionToolError")<{ · 3 unmodified lines
4 readonly message: string;4 readonly message: string;
5 readonly cause?: unknown;5 readonly cause?: unknown;
6}> {}6}> {}
7
8// `CodeExecutionError` lives in `@executor/codemode-core` — the `CodeExecutor`
9// interface uses it as the default error channel, so the runtime packages
10// can import the same class directly.
11export { CodeExecutionError } from "@executor/codemode-core";
packages/core/execution/src/promise.ts+45−30
@@ -1,24 +1,28 @@
1// ---------------------------------------------------------------------------1// ---------------------------------------------------------------------------
2// @executor/execution/promise — Promise-native surface for the execution2// @executor/execution/promise — Promise-native surface for the execution
3// engine. Accepts a Promise-style Executor (from @executor/sdk) and an3// engine.
4// async `onElicitation` handler — no Effect imports required by callers.4// ---------------------------------------------------------------------------
5//5//
6// Under the hood the engine is Effect-based, so we wrap the incoming6// `engine.ts` is Effect-native; this module runs each method with
7// Promise executor into the minimal Effect shape the engine consumes,7// `Effect.runPromise` at the boundary so hosts that can't compose Effects
8// and bridge the elicitation handler via `Effect.tryPromise`.8// (the MCP SDK tool handlers, plain async call sites) can still use the
9// engine. Callers already inside an Effect context should import directly
10// from `@executor/execution` to keep trace context intact.
9// ---------------------------------------------------------------------------11// ---------------------------------------------------------------------------
10 12
11import { Effect } from "effect";13import { Effect } from "effect";
14import type * as Cause from "effect/Cause";
12 15
13import type {16import type {
14 ElicitationContext,17 ElicitationContext,
15 ElicitationResponse,18 ElicitationResponse,
16 Executor as PromiseExecutor,19 Executor as PromiseExecutor,
17} from "@executor/sdk";20} from "@executor/sdk";
18import type { CodeExecutor, ExecuteResult } from "@executor/codemode-core";21import type { CodeExecutionError, CodeExecutor, ExecuteResult } from "@executor/codemode-core";
19 22
20import {23import {
21 createExecutionEngine as createEffectExecutionEngine,24 createExecutionEngine as createEffectExecutionEngine,
25 type ExecutionEngine as EffectExecutionEngine,
22 type ExecutionResult,26 type ExecutionResult,
23 type PausedExecution,27 type PausedExecution,
24 type ResumeResponse,28 type ResumeResponse,
@@ -28,9 +32,11 @@ export type ElicitationHandler = ( · 3 unmodified lines
28 ctx: ElicitationContext,32 ctx: ElicitationContext,
29) => Promise<ElicitationResponse>;33) => Promise<ElicitationResponse>;
30 34
31export type ExecutionEngineConfig = {35export type ExecutionEngineConfig<
36 E extends Cause.YieldableError = CodeExecutionError,
37> = {
32 readonly executor: PromiseExecutor;38 readonly executor: PromiseExecutor;
33 readonly codeExecutor: CodeExecutor;39 readonly codeExecutor: CodeExecutor<E>;
34};40};
35 41
36export type ExecutionEngine = {42export type ExecutionEngine = {
@@ -49,7 +55,7 @@ export type ExecutionEngine = { · 12 unmodified lines
49/**55/**
50 * Wrap a Promise-style executor into the Effect shape the engine consumes.56 * Wrap a Promise-style executor into the Effect shape the engine consumes.
51 * Only the four method families the engine actually touches need wrapping:57 * Only the four method families the engine actually touches need wrapping:
52 * `tools.{invoke,list,schema}` and `sources.list`.58 * `tools.{invoke,list,schema,definitions}` and `sources.list`.
53 */59 */
54const wrapPromiseExecutor = (pe: PromiseExecutor): any => ({60const wrapPromiseExecutor = (pe: PromiseExecutor): any => ({
55 scope: (pe as any).scope,61 scope: (pe as any).scope,
@@ -84,35 +90,44 @@ const wrapPromiseExecutor = (pe: PromiseExecutor): any => ({ · 28 unmodified lines
84 },90 },
85});91});
86 92
87export const createExecutionEngine = (93/**
88 config: ExecutionEngineConfig,94 * Promise-wrap an Effect-native `ExecutionEngine` (from `./engine`).
89): ExecutionEngine => {95 * Exposed separately so callers that already hold an Effect engine
90 const engine = createEffectExecutionEngine({96 * (apps/cloud's execution-stack composes both) can convert it for hosts
91 executor: wrapPromiseExecutor(config.executor),97 * that need the Promise surface (host-mcp).
92 codeExecutor: config.codeExecutor,98 */
93 });99export const toPromiseExecutionEngine = <E extends Cause.YieldableError>(
94 return {100 engine: EffectExecutionEngine<E>,
95 execute: (code, options) =>101): ExecutionEngine => ({
102 execute: (code, options) =>
103 Effect.runPromise(
96 engine.execute(code, {104 engine.execute(code, {
97 onElicitation: (ctx) =>105 onElicitation: (ctx) =>
98 Effect.tryPromise(() => options.onElicitation(ctx)).pipe(106 Effect.tryPromise(() => options.onElicitation(ctx)).pipe(Effect.orDie),
99 Effect.orDie,
100 ),
101 }),107 }),
102 executeWithPause: (code) => engine.executeWithPause(code),108 ),
103 resume: (executionId, response) => engine.resume(executionId, response),109 executeWithPause: (code) => Effect.runPromise(engine.executeWithPause(code)),
104 getDescription: () => engine.getDescription(),110 resume: (executionId, response) => Effect.runPromise(engine.resume(executionId, response)),
105 };111 getDescription: () => Effect.runPromise(engine.getDescription),
106};112});
113
114export const createExecutionEngine = <
115 E extends Cause.YieldableError = CodeExecutionError,
116>(
117 config: ExecutionEngineConfig<E>,
118): ExecutionEngine =>
119 toPromiseExecutionEngine(
120 createEffectExecutionEngine({
121 executor: wrapPromiseExecutor(config.executor),
122 codeExecutor: config.codeExecutor,
123 }),
124 );
107 125
108// ---------------------------------------------------------------------------126// ---------------------------------------------------------------------------
109// Re-exports — plain types/helpers that don't carry Effect signatures.127// Re-exports — plain types/helpers that don't carry Effect signatures.
110// ---------------------------------------------------------------------------128// ---------------------------------------------------------------------------
111 129
112export {130export { formatExecuteResult, formatPausedExecution } from "./engine";
113 formatExecuteResult,
114 formatPausedExecution,
115} from "./engine";
116 131
117export type { ExecutionResult, PausedExecution, ResumeResponse };132export type { ExecutionResult, PausedExecution, ResumeResponse };
118 133
packages/core/execution/src/tool-invoker.test.ts+39−51
@@ -150,11 +150,9 @@ describe("tool discovery", () => { · 149 unmodified lines
150 });150 });
151 expect(crmOnly.map((match) => match.path)).toEqual(["crm.listContacts"]);151 expect(crmOnly.map((match) => match.path)).toEqual(["crm.listContacts"]);
152 152
153 const sandboxResult = yield* Effect.promise(() =>153 const sandboxResult = yield* createExecutionEngine({ executor, codeExecutor }).execute(
154 createExecutionEngine({ executor, codeExecutor }).execute(154 'return await tools.search({ namespace: "crm", query: "create contact", limit: 5 });',
155 'return await tools.search({ namespace: "crm", query: "create contact", limit: 5 });',155 { onElicitation: acceptAll },
156 { onElicitation: acceptAll },
157 ),
158 );156 );
159 expect(sandboxResult.error).toBeUndefined();157 expect(sandboxResult.error).toBeUndefined();
160 expect(sandboxResult.result).toEqual([158 expect(sandboxResult.result).toEqual([
@@ -167,10 +165,9 @@ describe("tool discovery", () => { · 6 unmodified lines
167 Effect.gen(function* () {165 Effect.gen(function* () {
168 const executor = yield* makeSearchExecutor();166 const executor = yield* makeSearchExecutor();
169 167
170 const listed = yield* Effect.promise(() =>168 const listed = yield* createExecutionEngine({ executor, codeExecutor }).execute(
171 createExecutionEngine({ executor, codeExecutor }).execute("return await tools.executor.sources.list();", {169 "return await tools.executor.sources.list();",
172 onElicitation: acceptAll,170 { onElicitation: acceptAll },
173 }),
174 );171 );
175 expect(listed.error).toBeUndefined();172 expect(listed.error).toBeUndefined();
176 expect(listed.result).toEqual(173 expect(listed.result).toEqual(
@@ -180,11 +177,9 @@ describe("tool discovery", () => { · 3 unmodified lines
180 ]),177 ]),
181 );178 );
182 179
183 const searched = yield* Effect.promise(() =>180 const searched = yield* createExecutionEngine({ executor, codeExecutor }).execute(
184 createExecutionEngine({ executor, codeExecutor }).execute(181 'return await tools.search({ query: "list contacts", namespace: "crm", limit: 5 });',
185 'return await tools.search({ query: "list contacts", namespace: "crm", limit: 5 });',182 { onElicitation: acceptAll },
186 { onElicitation: acceptAll },
187 ),
188 );183 );
189 expect(searched.error).toBeUndefined();184 expect(searched.error).toBeUndefined();
190 expect(searched.result).toEqual([expect.objectContaining({ path: "crm.listContacts" })]);185 expect(searched.result).toEqual([expect.objectContaining({ path: "crm.listContacts" })]);
@@ -210,55 +205,48 @@ describe("tool discovery", () => { · 19 unmodified lines
210 const executor = yield* makeSearchExecutor();205 const executor = yield* makeSearchExecutor();
211 const engine = createExecutionEngine({ executor, codeExecutor });206 const engine = createExecutionEngine({ executor, codeExecutor });
212 207
213 const invalid = yield* Effect.promise(() =>208 const invalid = yield* engine.execute(
214 engine.execute(209 [
215 [210 "try {",
216 "try {",211 ' await tools.search("github issues");',
217 ' await tools.search("github issues");',212 ' return "unexpected";',
218 ' return "unexpected";',213 "} catch (error) {",
219 "} catch (error) {",214 " return error instanceof Error ? error.message : String(error);",
220 " return error instanceof Error ? error.message : String(error);",215 "}",
221 "}",216 ].join("\n"),
222 ].join("\n"),217 { onElicitation: acceptAll },
223 { onElicitation: acceptAll },
224 ),
225 );218 );
226 expect(invalid.error).toBeUndefined();219 expect(invalid.error).toBeUndefined();
227 expect(String(invalid.result)).toContain(220 expect(String(invalid.result)).toContain(
228 "tools.search expects an object: { query?: string; namespace?: string; limit?: number }",221 "tools.search expects an object: { query?: string; namespace?: string; limit?: number }",
229 );222 );
230 223
231 const emptyQuery = yield* Effect.promise(() =>224 const emptyQuery = yield* engine.execute(
232 engine.execute('return await tools.search({ query: "", limit: 5 });', {225 'return await tools.search({ query: "", limit: 5 });',
233 onElicitation: acceptAll,226 { onElicitation: acceptAll },
234 }),
235 );227 );
236 expect(emptyQuery.error).toBeUndefined();228 expect(emptyQuery.error).toBeUndefined();
237 expect(emptyQuery.result).toEqual([]);229 expect(emptyQuery.result).toEqual([]);
238 230
239 const invalidDescribe = yield* Effect.promise(() =>231 const invalidDescribe = yield* engine.execute(
240 engine.execute(232 [
241 [233 "try {",
242 "try {",234 ' await tools.describe.tool({ path: "github.listRepositoryIssues", includeSchemas: true });',
243 ' await tools.describe.tool({ path: "github.listRepositoryIssues", includeSchemas: true });',235 ' return "unexpected";',
244 ' return "unexpected";',236 "} catch (error) {",
245 "} catch (error) {",237 " return error instanceof Error ? error.message : String(error);",
246 " return error instanceof Error ? error.message : String(error);",238 "}",
247 "}",239 ].join("\n"),
248 ].join("\n"),240 { onElicitation: acceptAll },
249 { onElicitation: acceptAll },
250 ),
251 );241 );
252 expect(invalidDescribe.error).toBeUndefined();242 expect(invalidDescribe.error).toBeUndefined();
253 expect(String(invalidDescribe.result)).toContain(243 expect(String(invalidDescribe.result)).toContain(
254 "tools.describe.tool no longer accepts includeSchemas",244 "tools.describe.tool no longer accepts includeSchemas",
255 );245 );
256 246
257 const invalidSearch = yield* Effect.promise(() =>247 const invalidSearch = yield* engine.execute(
258 engine.execute(248 'try { return await tools.search("crm"); } catch (error) { return error instanceof Error ? error.message : String(error); }',
259 'try { return await tools.search("crm"); } catch (error) { return error instanceof Error ? error.message : String(error); }',249 { onElicitation: acceptAll },
260 { onElicitation: acceptAll },
261 ),
262 );250 );
263 expect(invalidSearch.error).toBeUndefined();251 expect(invalidSearch.error).toBeUndefined();
264 expect(String(invalidSearch.result)).toContain("tools.search expects an object");252 expect(String(invalidSearch.result)).toContain("tools.search expects an object");
@@ -334,7 +322,7 @@ describe("pause/resume with multiple elicitations", () => { · 69 unmodified lines
334 322
335 const code = "return await tools.api.multiApproval({});";323 const code = "return await tools.api.multiApproval({});";
336 324
337 const outcome1 = yield* Effect.promise(() => engine.executeWithPause(code));325 const outcome1 = yield* engine.executeWithPause(code);
338 expect(outcome1.status).toBe("paused");326 expect(outcome1.status).toBe("paused");
339 const paused1 = outcome1 as Extract<typeof outcome1, { status: "paused" }>;327 const paused1 = outcome1 as Extract<typeof outcome1, { status: "paused" }>;
340 expect(paused1.execution.elicitationContext.request.message).toBe("First approval");328 expect(paused1.execution.elicitationContext.request.message).toBe("First approval");
@@ -344,7 +332,7 @@ describe("pause/resume with multiple elicitations", () => { · 3 unmodified lines
344 // result or the completion).332 // result or the completion).
345 const outcome2 = yield* Effect.promise(() =>333 const outcome2 = yield* Effect.promise(() =>
346 Promise.race([334 Promise.race([
347 engine.resume(paused1.execution.id, { action: "accept" }),335 Effect.runPromise(engine.resume(paused1.execution.id, { action: "accept" })),
348 new Promise<never>((_, reject) =>336 new Promise<never>((_, reject) =>
349 setTimeout(337 setTimeout(
350 () => reject(new Error("resume hung — second elicitation not surfaced")),338 () => reject(new Error("resume hung — second elicitation not surfaced")),
@@ -368,7 +356,7 @@ describe("pause/resume with multiple elicitations", () => { · 17 unmodified lines
368 356
369 const code = "return await tools.api.singleApproval({});";357 const code = "return await tools.api.singleApproval({});";
370 358
371 const outcome1 = await engine.executeWithPause(code);359 const outcome1 = await Effect.runPromise(engine.executeWithPause(code));
372 expect(outcome1.status).toBe("paused");360 expect(outcome1.status).toBe("paused");
373 const paused1 = outcome1 as Extract<typeof outcome1, { status: "paused" }>;361 const paused1 = outcome1 as Extract<typeof outcome1, { status: "paused" }>;
374 expect(paused1.execution.elicitationContext.request.message).toBe("Only approval");362 expect(paused1.execution.elicitationContext.request.message).toBe("Only approval");
@@ -389,7 +377,7 @@ describe("pause/resume with multiple elicitations", () => { · 14 unmodified lines
389 expect(exitProbe).toBe("still-running");377 expect(exitProbe).toBe("still-running");
390 378
391 const outcome2 = await Promise.race([379 const outcome2 = await Promise.race([
392 engine.resume(paused1.execution.id, { action: "accept" }),380 Effect.runPromise(engine.resume(paused1.execution.id, { action: "accept" })),
393 new Promise<never>((_, reject) =>381 new Promise<never>((_, reject) =>
394 setTimeout(() => reject(new Error("resume hung across runPromise boundaries")), 2000),382 setTimeout(() => reject(new Error("resume hung across runPromise boundaries")), 2000),
395 ),383 ),
packages/hosts/mcp/src/server.ts+28−13
@@ -14,6 +14,7 @@ import type { · 13 unmodified lines
14 ElicitationContext,14 ElicitationContext,
15 ElicitationRequest,15 ElicitationRequest,
16} from "@executor/sdk";16} from "@executor/sdk";
17import type * as Cause from "effect/Cause";
17import {18import {
18 createExecutionEngine,19 createExecutionEngine,
19 formatExecuteResult,20 formatExecuteResult,
@@ -44,11 +45,23 @@ class CfWorkerJsonSchemaValidator implements jsonSchemaValidator { · 24 unmodified lines
44// Config45// Config
45// ---------------------------------------------------------------------------46// ---------------------------------------------------------------------------
46 47
47export type ExecutorMcpServerConfig =48type SharedMcpServerConfig = {
48 | ExecutionEngineConfig49 /**
49 | { readonly engine: ExecutionEngine }50 * Pre-built `execute` tool description. When provided, the factory skips
50 | (ExecutionEngineConfig & { readonly stateless: true })51 * its internal `engine.getDescription()` call. Useful when the caller
51 | { readonly engine: ExecutionEngine; readonly stateless: true };52 * wants to compute the description inside its own Effect tracer context
53 * so sub-spans (`executor.sources.list`, `executor.tools.list`) nest as
54 * children of the caller's root span instead of being orphaned by the
55 * `Effect.runPromise` that `engine.getDescription()` runs internally.
56 */
57 readonly description?: string;
58};
59
60export type ExecutorMcpServerConfig<E extends Cause.YieldableError = Cause.YieldableError> =
61 | (ExecutionEngineConfig<E> & SharedMcpServerConfig)
62 | ({ readonly engine: ExecutionEngine<E> } & SharedMcpServerConfig)
63 | (ExecutionEngineConfig<E> & SharedMcpServerConfig & { readonly stateless: true })
64 | ({ readonly engine: ExecutionEngine<E>; readonly stateless: true } & SharedMcpServerConfig);
52 65
53// ---------------------------------------------------------------------------66// ---------------------------------------------------------------------------
54// Elicitation bridge67// Elicitation bridge
@@ -153,11 +166,11 @@ const toMcpPausedResult = (formatted: ReturnType<typeof formatPausedExecution>): · 98 unmodified lines
153// Server factory166// Server factory
154// ---------------------------------------------------------------------------167// ---------------------------------------------------------------------------
155 168
156export const createExecutorMcpServer = async (169export const createExecutorMcpServer = async <E extends Cause.YieldableError>(
157 config: ExecutorMcpServerConfig,170 config: ExecutorMcpServerConfig<E>,
158): Promise<McpServer> => {171): Promise<McpServer> => {
159 const engine = "engine" in config ? config.engine : createExecutionEngine(config);172 const engine = "engine" in config ? config.engine : createExecutionEngine(config);
160 const description = await engine.getDescription();173 const description = config.description ?? (await Effect.runPromise(engine.getDescription));
161 174
162 const server = new McpServer(175 const server = new McpServer(
163 { name: "executor", version: "1.0.0" },176 { name: "executor", version: "1.0.0" },
@@ -166,13 +179,15 @@ export const createExecutorMcpServer = async ( · 2 unmodified lines
166 179
167 const executeCode = async (code: string): Promise<McpToolResult> => {180 const executeCode = async (code: string): Promise<McpToolResult> => {
168 if (supportsManagedElicitation(server)) {181 if (supportsManagedElicitation(server)) {
169 const result = await engine.execute(code, {182 const result = await Effect.runPromise(
170 onElicitation: makeMcpElicitationHandler(server),183 engine.execute(code, {
171 });184 onElicitation: makeMcpElicitationHandler(server),
185 }),
186 );
172 return toMcpResult(formatExecuteResult(result));187 return toMcpResult(formatExecuteResult(result));
173 }188 }
174 189
175 const outcome = await engine.executeWithPause(code);190 const outcome = await Effect.runPromise(engine.executeWithPause(code));
176 return outcome.status === "completed"191 return outcome.status === "completed"
177 ? toMcpResult(formatExecuteResult(outcome.result))192 ? toMcpResult(formatExecuteResult(outcome.result))
178 : toMcpPausedResult(formatPausedExecution(outcome.execution));193 : toMcpPausedResult(formatPausedExecution(outcome.execution));
@@ -222,7 +237,7 @@ export const createExecutorMcpServer = async ( · 43 unmodified lines
222 },237 },
223 async ({ executionId, action, content: rawContent }) => {238 async ({ executionId, action, content: rawContent }) => {
224 const content = parseJsonContent(rawContent);239 const content = parseJsonContent(rawContent);
225 const outcome = await engine.resume(executionId, { action, content });240 const outcome = await Effect.runPromise(engine.resume(executionId, { action, content }));
226 241
227 if (!outcome) {242 if (!outcome) {
228 return {243 return {
packages/kernel/core/src/effect-errors.ts+14−0
@@ -7,3 +7,17 @@ export class KernelCoreEffectError extends Data.TaggedError("KernelCoreEffectErr · 6 unmodified lines
7 7
8export const kernelCoreEffectError = (module: string, message: string) =>8export const kernelCoreEffectError = (module: string, message: string) =>
9 new KernelCoreEffectError({ module, message });9 new KernelCoreEffectError({ module, message });
10
11/**
12 * Default failure type for any `CodeExecutor.execute` implementation —
13 * surfaces sandbox-level defects (isolate crash, module load failure,
14 * worker loader error) as a typed error so callers can handle them
15 * structurally instead of untyped `unknown`. Runtimes that want a
16 * narrower error shape can define their own `Data.TaggedError` subclass
17 * and parameterize `CodeExecutor<MyError>`.
18 */
19export class CodeExecutionError extends Data.TaggedError("CodeExecutionError")<{
20 readonly runtime: string;
21 readonly message: string;
22 readonly cause?: unknown;
23}> {}
packages/kernel/core/src/types.ts+14−3
@@ -1,6 +1,9 @@
1import type { StandardSchemaV1 } from "@standard-schema/spec";1import type { StandardSchemaV1 } from "@standard-schema/spec";
2import type * as Cause from "effect/Cause";
2import type * as Effect from "effect/Effect";3import type * as Effect from "effect/Effect";
3 4
5import type { CodeExecutionError } from "./effect-errors";
6
4/** Branded tool path */7/** Branded tool path */
5export type ToolPath = string & { readonly __toolPath: unique symbol };8export type ToolPath = string & { readonly __toolPath: unique symbol };
6 9
@@ -30,9 +33,17 @@ export type ExecuteResult = { · 23 unmodified lines
30 logs?: string[];33 logs?: string[];
31};34};
32 35
33/** Executes code in a sandboxed runtime with tool access */36/**
34export interface CodeExecutor {37 * Executes code in a sandboxed runtime with tool access.
35 execute(code: string, toolInvoker: SandboxToolInvoker): Effect.Effect<ExecuteResult, unknown>;38 *
39 * Error channel is constrained to Effect's `YieldableError` (the base
40 * shape `Data.TaggedError(...)` produces) so callers always get a
41 * structurally tagged error, never untyped `unknown`. Defaults to
42 * `CodeExecutionError`; runtimes can parameterize with their own
43 * `Data.TaggedError` subclass — e.g. `CodeExecutor<WorkerLoaderError>`.
44 */
45export interface CodeExecutor<E extends Cause.YieldableError = CodeExecutionError> {
46 execute(code: string, toolInvoker: SandboxToolInvoker): Effect.Effect<ExecuteResult, E>;
36}47}
37 48
38/** Accept-anything schema for tools with no input validation */49/** Accept-anything schema for tools with no input validation */
packages/kernel/runtime-deno-subprocess/src/index.ts+1−1
@@ -330,7 +330,7 @@ export const isDenoAvailable = (executable: string = defaultDenoExecutable()): b · 329 unmodified lines
330 330
331export const makeDenoSubprocessExecutor = (331export const makeDenoSubprocessExecutor = (
332 options: DenoSubprocessExecutorOptions = {},332 options: DenoSubprocessExecutorOptions = {},
333): CodeExecutor => ({333): CodeExecutor<never> => ({
334 execute: (code: string, toolInvoker: SandboxToolInvoker) =>334 execute: (code: string, toolInvoker: SandboxToolInvoker) =>
335 executeInDeno(code, toolInvoker, options),335 executeInDeno(code, toolInvoker, options),
336});336});
packages/kernel/runtime-dynamic-worker/src/executor.ts+3−1
@@ -323,7 +323,9 @@ const runInDynamicWorker = ( · 322 unmodified lines
323// Public API323// Public API
324// ---------------------------------------------------------------------------324// ---------------------------------------------------------------------------
325 325
326export const makeDynamicWorkerExecutor = (options: DynamicWorkerExecutorOptions): CodeExecutor => ({326export const makeDynamicWorkerExecutor = (
327 options: DynamicWorkerExecutorOptions,
328): CodeExecutor<DynamicWorkerExecutionError> => ({
327 execute: (code: string, toolInvoker: SandboxToolInvoker) =>329 execute: (code: string, toolInvoker: SandboxToolInvoker) =>
328 runInDynamicWorker(options, code, toolInvoker),330 runInDynamicWorker(options, code, toolInvoker),
329});331});
packages/kernel/runtime-quickjs/src/index.ts+3−1
@@ -396,7 +396,9 @@ const runInQuickJs = ( · 395 unmodified lines
396 }),396 }),
397 );397 );
398 398
399export const makeQuickJsExecutor = (options: QuickJsExecutorOptions = {}): CodeExecutor => ({399export const makeQuickJsExecutor = (
400 options: QuickJsExecutorOptions = {},
401): CodeExecutor<QuickJsExecutionError> => ({
400 execute: (code: string, toolInvoker: SandboxToolInvoker) =>402 execute: (code: string, toolInvoker: SandboxToolInvoker) =>
401 runInQuickJs(options, code, toolInvoker),403 runInQuickJs(options, code, toolInvoker),
402});404});
packages/kernel/runtime-secure-exec/src/index.ts+3−1
@@ -441,7 +441,9 @@ const evaluateInSecureExec = ( · 440 unmodified lines
441 );441 );
442};442};
443 443
444export const makeSecureExecExecutor = (options: SecureExecExecutorOptions = {}): CodeExecutor => ({444export const makeSecureExecExecutor = (
445 options: SecureExecExecutorOptions = {},
446): CodeExecutor<never> => ({
445 execute: (code: string, toolInvoker: SandboxToolInvoker) =>447 execute: (code: string, toolInvoker: SandboxToolInvoker) =>
446 evaluateInSecureExec(options, code, toolInvoker),448 evaluateInSecureExec(options, code, toolInvoker),
447});449});