Unified · Split
| @@ -1 +1,3 @@ | ||
| 1 | 1 | .reference/ |
| 2 | +.vercel | |
| 3 | +.env*.local | |
| @@ -3,5 +3,7 @@ · 2 unmodified lines | ||
| 3 | 3 | Convex remote server app scaffold for Executor v2. |
| 4 | 4 | |
| 5 | 5 | Current scaffold includes: |
| 6 | −- Convex schema wiring at `convex/schema.ts`, sourced from `@executor-v2/persistence-convex` | |
| 7 | −- bare-minimum MCP HTTP endpoint with a stub tool at `convex/http.ts` and `convex/mcp.ts` | |
| 6 | + Convex schema wiring at `convex/schema.ts`, sourced from `@executor-v2/persistence-convex` | |
| 7 | + MCP HTTP endpoint at `convex/http.ts` and `convex/mcp.ts`, wired through a run client | |
| 8 | + runtime callback endpoints at `POST /runtime/tool-call` and `POST /v1/runtime/tool-call` | |
| 9 | + action-level callback handler scaffold in `convex/runtimeCallbacks.ts` | |
| @@ -8,8 +8,13 @@ · 7 unmodified lines | ||
| 8 | 8 | * @module |
| 9 | 9 | */ |
| 10 | 10 | |
| 11 | +import type * as executor from "../executor.js"; | |
| 11 | 12 | import type * as http from "../http.js"; |
| 12 | 13 | import type * as mcp from "../mcp.js"; |
| 14 | +import type * as rpc_exit from "../rpc_exit.js"; | |
| 15 | +import type * as run_executor from "../run_executor.js"; | |
| 16 | +import type * as runtimeCallbacks from "../runtimeCallbacks.js"; | |
| 17 | +import type * as runtime_adapter from "../runtime_adapter.js"; | |
| 13 | 18 | |
| 14 | 19 | import type { |
| 15 | 20 | ApiFromModules, |
| @@ -18,8 +23,13 @@ import type { · 2 unmodified lines | ||
| 18 | 23 | } from "convex/server"; |
| 19 | 24 | |
| 20 | 25 | declare const fullApi: ApiFromModules<{ |
| 26 | + executor: typeof executor; | |
| 21 | 27 | http: typeof http; |
| 22 | 28 | mcp: typeof mcp; |
| 29 | + rpc_exit: typeof rpc_exit; | |
| 30 | + run_executor: typeof run_executor; | |
| 31 | + runtimeCallbacks: typeof runtimeCallbacks; | |
| 32 | + runtime_adapter: typeof runtime_adapter; | |
| 23 | 33 | }>; |
| 24 | 34 | |
| 25 | 35 | /** |
| @@ -0,0 +1,65 @@ | ||
| 1 | +import { describe, expect, it } from "@effect/vitest"; | |
| 2 | +import { convexTest } from "convex-test"; | |
| 3 | +import * as Effect from "effect/Effect"; | |
| 4 | + | |
| 5 | +import { api } from "./_generated/api"; | |
| 6 | +import { unwrapRpcSuccess } from "./rpc_exit"; | |
| 7 | +import schema from "./schema"; | |
| 8 | + | |
| 9 | +const setup = () => | |
| 10 | + convexTest(schema, { | |
| 11 | + "./http.ts": () => import("./http"), | |
| 12 | + "./mcp.ts": () => import("./mcp"), | |
| 13 | + "./executor.ts": () => import("./executor"), | |
| 14 | + "./runtimeCallbacks.ts": () => import("./runtimeCallbacks"), | |
| 15 | + "./_generated/api.js": () => import("./_generated/api.js"), | |
| 16 | + }); | |
| 17 | + | |
| 18 | +describe("Convex executor actions", () => { | |
| 19 | + it.effect("executes code via executeRun action", () => | |
| 20 | + Effect.gen(function* () { | |
| 21 | + const t = setup(); | |
| 22 | + | |
| 23 | + const result = unwrapRpcSuccess<{ | |
| 24 | + status: string; | |
| 25 | + result?: unknown; | |
| 26 | + }>( | |
| 27 | + yield* Effect.tryPromise(() => | |
| 28 | + t.action(api.executor.executeRun, { | |
| 29 | + code: "return 6 * 7;", | |
| 30 | + }), | |
| 31 | + ), | |
| 32 | + "executor.executeRun", | |
| 33 | + ); | |
| 34 | + | |
| 35 | + expect(result.status).toBe("completed"); | |
| 36 | + expect(result.result).toBe(42); | |
| 37 | + }), | |
| 38 | + ); | |
| 39 | + | |
| 40 | + it.effect("returns failed callback result from runtime callback action", () => | |
| 41 | + Effect.gen(function* () { | |
| 42 | + const t = setup(); | |
| 43 | + | |
| 44 | + const result = unwrapRpcSuccess<{ | |
| 45 | + ok: boolean; | |
| 46 | + kind?: string; | |
| 47 | + error?: string; | |
| 48 | + }>( | |
| 49 | + yield* Effect.tryPromise(() => | |
| 50 | + t.action(api.runtimeCallbacks.handleToolCall, { | |
| 51 | + runId: "run_1", | |
| 52 | + callId: "call_1", | |
| 53 | + toolPath: "tools.example.lookup", | |
| 54 | + input: { query: "ping" }, | |
| 55 | + }), | |
| 56 | + ), | |
| 57 | + "runtimeCallbacks.handleToolCall", | |
| 58 | + ); | |
| 59 | + | |
| 60 | + expect(result.ok).toBe(false); | |
| 61 | + expect(result.kind).toBe("failed"); | |
| 62 | + expect(result.error).toContain("tools.example.lookup"); | |
| 63 | + }), | |
| 64 | + ); | |
| 65 | +}); | |
| @@ -0,0 +1,64 @@ | ||
| 1 | +import { createRpcFactory, makeRpcModule } from "@executor-v2/confect/rpc"; | |
| 2 | +import { executorConfectSchema } from "@executor-v2/persistence-convex"; | |
| 3 | +import type { ExecuteRunInput } from "@executor-v2/sdk"; | |
| 4 | +import * as Effect from "effect/Effect"; | |
| 5 | +import * as Layer from "effect/Layer"; | |
| 6 | +import * as Schema from "effect/Schema"; | |
| 7 | + | |
| 8 | +import { | |
| 9 | + ConvexRunExecutor, | |
| 10 | + ConvexRunExecutorLive, | |
| 11 | + ConvexToolProviderRegistryLive, | |
| 12 | +} from "./run_executor"; | |
| 13 | + | |
| 14 | +const executeRunResultSchema = Schema.Struct({ | |
| 15 | + runId: Schema.String, | |
| 16 | + status: Schema.Union( | |
| 17 | + Schema.Literal("completed"), | |
| 18 | + Schema.Literal("failed"), | |
| 19 | + Schema.Literal("timed_out"), | |
| 20 | + Schema.Literal("denied"), | |
| 21 | + ), | |
| 22 | + result: Schema.optional(Schema.Unknown), | |
| 23 | + error: Schema.optional(Schema.String), | |
| 24 | + exitCode: Schema.optional(Schema.Number), | |
| 25 | + durationMs: Schema.optional(Schema.Number), | |
| 26 | +}); | |
| 27 | + | |
| 28 | +const factory = createRpcFactory({ | |
| 29 | + schema: executorConfectSchema, | |
| 30 | +}); | |
| 31 | + | |
| 32 | +const ConvexExecuteDependenciesLive = Layer.merge( | |
| 33 | + ConvexRunExecutorLive, | |
| 34 | + ConvexToolProviderRegistryLive, | |
| 35 | +); | |
| 36 | + | |
| 37 | +export const executeRunImpl = ( | |
| 38 | + input: ExecuteRunInput, | |
| 39 | +): Effect.Effect<typeof executeRunResultSchema.Type> => | |
| 40 | + Effect.gen(function* () { | |
| 41 | + const runExecutor = yield* ConvexRunExecutor; | |
| 42 | + return yield* runExecutor.executeRun(input); | |
| 43 | + }).pipe(Effect.provide(ConvexExecuteDependenciesLive)); | |
| 44 | + | |
| 45 | +const executeRunEndpoint = factory.action({ | |
| 46 | + payload: { | |
| 47 | + code: Schema.String, | |
| 48 | + timeoutMs: Schema.optional(Schema.Number), | |
| 49 | + }, | |
| 50 | + success: executeRunResultSchema, | |
| 51 | +}); | |
| 52 | + | |
| 53 | +executeRunEndpoint.implement((payload) => | |
| 54 | + executeRunImpl({ | |
| 55 | + code: payload.code, | |
| 56 | + timeoutMs: payload.timeoutMs, | |
| 57 | + }), | |
| 58 | +); | |
| 59 | + | |
| 60 | +const executorRpc = makeRpcModule({ | |
| 61 | + executeRun: executeRunEndpoint, | |
| 62 | +}); | |
| 63 | + | |
| 64 | +export const executeRun = executorRpc.handlers.executeRun; | |
| @@ -1,5 +1,6 @@ | ||
| 1 | 1 | import { httpRouter } from "convex/server"; |
| 2 | 2 | import { mcpHandler } from "./mcp"; |
| 3 | +import { handleToolCallHttp } from "./runtimeCallbacks"; | |
| 3 | 4 | |
| 4 | 5 | const http = httpRouter(); |
| 5 | 6 | |
| @@ -9,5 +10,15 @@ http.route({ path: "/mcp", method: "DELETE", handler: mcpHandler }); · 3 unmodified lines | ||
| 9 | 10 | http.route({ path: "/v1/mcp", method: "POST", handler: mcpHandler }); |
| 10 | 11 | http.route({ path: "/v1/mcp", method: "GET", handler: mcpHandler }); |
| 11 | 12 | http.route({ path: "/v1/mcp", method: "DELETE", handler: mcpHandler }); |
| 13 | +http.route({ | |
| 14 | + path: "/runtime/tool-call", | |
| 15 | + method: "POST", | |
| 16 | + handler: handleToolCallHttp, | |
| 17 | +}); | |
| 18 | +http.route({ | |
| 19 | + path: "/v1/runtime/tool-call", | |
| 20 | + method: "POST", | |
| 21 | + handler: handleToolCallHttp, | |
| 22 | +}); | |
| 12 | 23 | |
| 13 | 24 | export default http; |
| @@ -1,15 +1,19 @@ | ||
| 1 | 1 | import { handleMcpHttpRequest } from "@executor-v2/mcp-gateway"; |
| 2 | 2 | import { createExecutorRunClient } from "@executor-v2/sdk"; |
| 3 | +import { api } from "./_generated/api"; | |
| 3 | 4 | import { httpAction } from "./_generated/server"; |
| 4 | −const runClient = createExecutorRunClient(async () => ({ | |
| 5 | − runId: `run_${Date.now()}`, | |
| 6 | − status: "failed" as const, | |
| 7 | − error: "Convex run client is not wired yet", | |
| 8 | −})); | |
| 9 | −export const mcpHandler = httpAction(async (_ctx, request) => | |
| 10 | − handleMcpHttpRequest(request, { | |
| 5 | +import { unwrapRpcSuccess } from "./rpc_exit"; | |
| 6 | +export const mcpHandler = httpAction(async (ctx, request) => { | |
| 7 | + const runClient = createExecutorRunClient((input) => | |
| 8 | + ctx | |
| 9 | + .runAction(api.executor.executeRun, input) | |
| 10 | + .then((result) => unwrapRpcSuccess(result, "executor.executeRun")), | |
| 11 | + ); | |
| 12 | + | |
| 13 | + return handleMcpHttpRequest(request, { | |
| 11 | 14 | target: "remote", |
| 12 | 15 | serverName: "executor-v2-convex", |
| 13 | 16 | serverVersion: "0.0.0", |
| 14 | 17 | runClient, |
| 15 | − })); | |
| 18 | + }); | |
| 19 | +}); | |
| @@ -0,0 +1,50 @@ | ||
| 1 | +type RpcExitSuccess<T> = { | |
| 2 | + _tag: "Success"; | |
| 3 | + value: T; | |
| 4 | +}; | |
| 5 | + | |
| 6 | +type RpcExitFailure = { | |
| 7 | + _tag: "Failure"; | |
| 8 | + cause: unknown; | |
| 9 | +}; | |
| 10 | + | |
| 11 | +const isRpcExitSuccess = <T>(value: unknown): value is RpcExitSuccess<T> => | |
| 12 | + typeof value === "object" && | |
| 13 | + value !== null && | |
| 14 | + "_tag" in value && | |
| 15 | + value._tag === "Success" && | |
| 16 | + "value" in value; | |
| 17 | + | |
| 18 | +const isRpcExitFailure = (value: unknown): value is RpcExitFailure => | |
| 19 | + typeof value === "object" && | |
| 20 | + value !== null && | |
| 21 | + "_tag" in value && | |
| 22 | + value._tag === "Failure"; | |
| 23 | + | |
| 24 | +const renderUnknown = (value: unknown): string => { | |
| 25 | + if (value instanceof Error) { | |
| 26 | + return value.message; | |
| 27 | + } | |
| 28 | + | |
| 29 | + try { | |
| 30 | + return JSON.stringify(value); | |
| 31 | + } catch { | |
| 32 | + return String(value); | |
| 33 | + } | |
| 34 | +}; | |
| 35 | + | |
| 36 | +export const unwrapRpcSuccess = <T>(value: unknown, endpoint: string): T => { | |
| 37 | + if (isRpcExitSuccess<T>(value)) { | |
| 38 | + return value.value; | |
| 39 | + } | |
| 40 | + | |
| 41 | + if (isRpcExitFailure(value)) { | |
| 42 | + throw new Error( | |
| 43 | + `RPC endpoint '${endpoint}' returned failure exit: ${renderUnknown(value.cause)}`, | |
| 44 | + ); | |
| 45 | + } | |
| 46 | + | |
| 47 | + throw new Error( | |
| 48 | + `RPC endpoint '${endpoint}' returned invalid exit payload: ${renderUnknown(value)}`, | |
| 49 | + ); | |
| 50 | +}; | |
| @@ -0,0 +1,89 @@ | ||
| 1 | +import { | |
| 2 | + makeToolProviderRegistry, | |
| 3 | + ToolProviderRegistryService, | |
| 4 | +} from "@executor-v2/engine/tool-providers"; | |
| 5 | +import type { ExecuteRunInput, ExecuteRunResult } from "@executor-v2/sdk"; | |
| 6 | +import * as Context from "effect/Context"; | |
| 7 | +import * as Effect from "effect/Effect"; | |
| 8 | +import * as Layer from "effect/Layer"; | |
| 9 | + | |
| 10 | +import { makeLocalInProcessRuntimeAdapter } from "./runtime_adapter"; | |
| 11 | + | |
| 12 | +export type ConvexRunExecutorService = { | |
| 13 | + executeRun: ( | |
| 14 | + input: ExecuteRunInput, | |
| 15 | + ) => Effect.Effect<ExecuteRunResult, never, ToolProviderRegistryService>; | |
| 16 | +}; | |
| 17 | + | |
| 18 | +export class ConvexRunExecutor extends Context.Tag( | |
| 19 | + "@executor-v2/app-convex/ConvexRunExecutor", | |
| 20 | +)<ConvexRunExecutor, ConvexRunExecutorService>() {} | |
| 21 | + | |
| 22 | +const runtimeAdapter = makeLocalInProcessRuntimeAdapter(); | |
| 23 | + | |
| 24 | +const errorToText = (error: unknown): string => { | |
| 25 | + if (error instanceof Error) { | |
| 26 | + return error.message; | |
| 27 | + } | |
| 28 | + | |
| 29 | + if ( | |
| 30 | + typeof error === "object" && | |
| 31 | + error !== null && | |
| 32 | + "message" in error && | |
| 33 | + typeof error.message === "string" | |
| 34 | + ) { | |
| 35 | + return error.message; | |
| 36 | + } | |
| 37 | + | |
| 38 | + return String(error); | |
| 39 | +}; | |
| 40 | + | |
| 41 | +const executeRun = Effect.fn( | |
| 42 | + "@executor-v2/app-convex/run-executor.executeRun", | |
| 43 | +)(function* (input: ExecuteRunInput) { | |
| 44 | + const runId = `run_${crypto.randomUUID()}`; | |
| 45 | + | |
| 46 | + const isAvailable = yield* runtimeAdapter.isAvailable(); | |
| 47 | + if (!isAvailable) { | |
| 48 | + return { | |
| 49 | + runId, | |
| 50 | + status: "failed", | |
| 51 | + error: `Runtime '${runtimeAdapter.kind}' is not available in this Convex process.`, | |
| 52 | + } satisfies ExecuteRunResult; | |
| 53 | + } | |
| 54 | + | |
| 55 | + return yield* runtimeAdapter | |
| 56 | + .execute({ | |
| 57 | + code: input.code, | |
| 58 | + timeoutMs: input.timeoutMs, | |
| 59 | + tools: [], | |
| 60 | + }) | |
| 61 | + .pipe( | |
| 62 | + Effect.map( | |
| 63 | + (result): ExecuteRunResult => ({ | |
| 64 | + runId, | |
| 65 | + status: "completed", | |
| 66 | + result, | |
| 67 | + }), | |
| 68 | + ), | |
| 69 | + Effect.catchAll((error) => | |
| 70 | + Effect.succeed({ | |
| 71 | + runId, | |
| 72 | + status: "failed", | |
| 73 | + error: errorToText(error), | |
| 74 | + } satisfies ExecuteRunResult), | |
| 75 | + ), | |
| 76 | + ); | |
| 77 | +}); | |
| 78 | + | |
| 79 | +export const ConvexRunExecutorLive = Layer.succeed( | |
| 80 | + ConvexRunExecutor, | |
| 81 | + ConvexRunExecutor.of({ | |
| 82 | + executeRun, | |
| 83 | + }), | |
| 84 | +); | |
| 85 | + | |
| 86 | +export const ConvexToolProviderRegistryLive = Layer.succeed( | |
| 87 | + ToolProviderRegistryService, | |
| 88 | + makeToolProviderRegistry([]), | |
| 89 | +); | |
| @@ -0,0 +1,119 @@ | ||
| 1 | +import { createRpcFactory, makeRpcModule } from "@executor-v2/confect/rpc"; | |
| 2 | +import { executorConfectSchema } from "@executor-v2/persistence-convex"; | |
| 3 | +import type { | |
| 4 | + RuntimeToolCallRequest, | |
| 5 | + RuntimeToolCallResult, | |
| 6 | +} from "@executor-v2/sdk"; | |
| 7 | +import * as Effect from "effect/Effect"; | |
| 8 | +import * as Schema from "effect/Schema"; | |
| 9 | +import { api } from "./_generated/api"; | |
| 10 | +import { httpAction } from "./_generated/server"; | |
| 11 | +import { unwrapRpcSuccess } from "./rpc_exit"; | |
| 12 | + | |
| 13 | +const runtimeToolCallResultSchema = Schema.Struct({ | |
| 14 | + ok: Schema.Boolean, | |
| 15 | + kind: Schema.optional(Schema.String), | |
| 16 | + error: Schema.optional(Schema.String), | |
| 17 | + value: Schema.optional(Schema.Unknown), | |
| 18 | + approvalId: Schema.optional(Schema.String), | |
| 19 | + retryAfterMs: Schema.optional(Schema.Number), | |
| 20 | +}); | |
| 21 | + | |
| 22 | +const factory = createRpcFactory({ | |
| 23 | + schema: executorConfectSchema, | |
| 24 | +}); | |
| 25 | + | |
| 26 | +export const handleToolCallImpl = ( | |
| 27 | + input: RuntimeToolCallRequest, | |
| 28 | +): Effect.Effect<RuntimeToolCallResult> => | |
| 29 | + Effect.succeed({ | |
| 30 | + ok: false, | |
| 31 | + kind: "failed", | |
| 32 | + error: `Convex runtime callback received tool '${input.toolPath}', but callback invocation is not wired yet.`, | |
| 33 | + }); | |
| 34 | + | |
| 35 | +const handleToolCallEndpoint = factory.action({ | |
| 36 | + payload: { | |
| 37 | + runId: Schema.String, | |
| 38 | + callId: Schema.String, | |
| 39 | + toolPath: Schema.String, | |
| 40 | + input: Schema.optional( | |
| 41 | + Schema.Record({ | |
| 42 | + key: Schema.String, | |
| 43 | + value: Schema.Unknown, | |
| 44 | + }), | |
| 45 | + ), | |
| 46 | + }, | |
| 47 | + success: runtimeToolCallResultSchema, | |
| 48 | +}); | |
| 49 | + | |
| 50 | +handleToolCallEndpoint.implement((payload) => | |
| 51 | + handleToolCallImpl({ | |
| 52 | + runId: payload.runId, | |
| 53 | + callId: payload.callId, | |
| 54 | + toolPath: payload.toolPath, | |
| 55 | + input: payload.input, | |
| 56 | + }), | |
| 57 | +); | |
| 58 | + | |
| 59 | +const runtimeCallbacksRpc = makeRpcModule({ | |
| 60 | + handleToolCall: handleToolCallEndpoint, | |
| 61 | +}); | |
| 62 | + | |
| 63 | +export const handleToolCall = runtimeCallbacksRpc.handlers.handleToolCall; | |
| 64 | + | |
| 65 | +const badRequest = (message: string): Response => | |
| 66 | + Response.json( | |
| 67 | + { | |
| 68 | + ok: false, | |
| 69 | + kind: "failed", | |
| 70 | + error: message, | |
| 71 | + } satisfies RuntimeToolCallResult, | |
| 72 | + { status: 400 }, | |
| 73 | + ); | |
| 74 | + | |
| 75 | +export const handleToolCallHttp = httpAction(async (ctx, request) => { | |
| 76 | + let body: unknown; | |
| 77 | + try { | |
| 78 | + body = await request.json(); | |
| 79 | + } catch (cause) { | |
| 80 | + return badRequest( | |
| 81 | + `Invalid runtime callback request body: ${ | |
| 82 | + cause instanceof Error ? cause.message : String(cause) | |
| 83 | + }`, | |
| 84 | + ); | |
| 85 | + } | |
| 86 | + | |
| 87 | + if (typeof body !== "object" || body === null) { | |
| 88 | + return badRequest("Runtime callback request body must be an object"); | |
| 89 | + } | |
| 90 | + | |
| 91 | + const payload = body as Partial<RuntimeToolCallRequest>; | |
| 92 | + | |
| 93 | + if ( | |
| 94 | + typeof payload.runId !== "string" || | |
| 95 | + typeof payload.callId !== "string" || | |
| 96 | + typeof payload.toolPath !== "string" | |
| 97 | + ) { | |
| 98 | + return badRequest("Runtime callback request body is missing required fields"); | |
| 99 | + } | |
| 100 | + | |
| 101 | + let result: RuntimeToolCallResult; | |
| 102 | + try { | |
| 103 | + result = unwrapRpcSuccess( | |
| 104 | + await ctx.runAction(api.runtimeCallbacks.handleToolCall, { | |
| 105 | + runId: payload.runId, | |
| 106 | + callId: payload.callId, | |
| 107 | + toolPath: payload.toolPath, | |
| 108 | + input: | |
| 109 | + payload.input && typeof payload.input === "object" | |
| 110 | + ? (payload.input as Record<string, unknown>) | |
| 111 | + : undefined, | |
| 112 | + }), | |
| 113 | + "runtimeCallbacks.handleToolCall", | |
| 114 | + ); | |
| 115 | + } catch (cause) { | |
| 116 | + return badRequest(cause instanceof Error ? cause.message : String(cause)); | |
| 117 | + } | |
| 118 | + return Response.json(result, { status: 200 }); | |
| 119 | +}); | |
| @@ -0,0 +1,36 @@ | ||
| 1 | +import { executeJavaScriptWithTools } from "@executor-v2/engine/local-runner"; | |
| 2 | +import type { | |
| 3 | + CanonicalToolDescriptor, | |
| 4 | + ToolProviderRegistryService, | |
| 5 | +} from "@executor-v2/engine/tool-providers"; | |
| 6 | +import type { Source } from "@executor-v2/schema"; | |
| 7 | +import * as Effect from "effect/Effect"; | |
| 8 | + | |
| 9 | +export type RuntimeRunnableTool = { | |
| 10 | + descriptor: CanonicalToolDescriptor; | |
| 11 | + source: Source | null; | |
| 12 | +}; | |
| 13 | + | |
| 14 | +export type RuntimeExecuteInput = { | |
| 15 | + code: string; | |
| 16 | + tools: ReadonlyArray<RuntimeRunnableTool>; | |
| 17 | + timeoutMs?: number; | |
| 18 | +}; | |
| 19 | + | |
| 20 | +export type RuntimeAdapter = { | |
| 21 | + kind: "local-inproc"; | |
| 22 | + isAvailable: () => Effect.Effect<boolean>; | |
| 23 | + execute: ( | |
| 24 | + input: RuntimeExecuteInput, | |
| 25 | + ) => Effect.Effect<unknown, unknown, ToolProviderRegistryService>; | |
| 26 | +}; | |
| 27 | + | |
| 28 | +export const makeLocalInProcessRuntimeAdapter = (): RuntimeAdapter => ({ | |
| 29 | + kind: "local-inproc", | |
| 30 | + isAvailable: () => Effect.succeed(true), | |
| 31 | + execute: (input) => | |
| 32 | + executeJavaScriptWithTools({ | |
| 33 | + code: input.code, | |
| 34 | + tools: input.tools, | |
| 35 | + }), | |
| 36 | +}); | |
| @@ -4,13 +4,22 @@ · 3 unmodified lines | ||
| 4 | 4 | "type": "module", |
| 5 | 5 | "scripts": { |
| 6 | 6 | "dev": "bunx convex dev", |
| 7 | + "test": "bunx vitest run --no-watch convex/*.test.ts", | |
| 7 | 8 | "typecheck": "bunx tsc --noEmit -p tsconfig.json", |
| 8 | 9 | "convex:codegen": "bunx convex codegen" |
| 9 | 10 | }, |
| 10 | 11 | "dependencies": { |
| 12 | + "@executor-v2/confect": "workspace:*", | |
| 13 | + "@executor-v2/engine": "workspace:*", | |
| 11 | 14 | "@executor-v2/mcp-gateway": "workspace:*", |
| 12 | 15 | "@executor-v2/persistence-convex": "workspace:*", |
| 13 | 16 | "@executor-v2/sdk": "workspace:*", |
| 14 | − "convex": "catalog:" | |
| 17 | + "convex": "catalog:", | |
| 18 | + "effect": "catalog:" | |
| 19 | + }, | |
| 20 | + "devDependencies": { | |
| 21 | + "@effect/vitest": "catalog:", | |
| 22 | + "convex-test": "^0.0.41", | |
| 23 | + "vitest": "catalog:" | |
| 15 | 24 | } |
| 16 | 25 | } |
| @@ -7,6 +7,7 @@ Current scaffold includes: · 6 unmodified lines | ||
| 7 | 7 | - health endpoint at `GET /healthz` |
| 8 | 8 | - MCP tool routing via `@executor-v2/mcp-gateway` |
| 9 | 9 | - local runtime adapter injection (`makeLocalInProcessRuntimeAdapter()` in entry wiring) |
| 10 | + runtime callback endpoint at `POST /runtime/tool-call` | |
| 10 | 11 | |
| 11 | 12 | App wiring is now split by responsibility: |
| 12 | 13 | - `src/config.ts`: Effect config service (`PORT`) |
| @@ -5,6 +5,7 @@ · 4 unmodified lines | ||
| 5 | 5 | "scripts": { |
| 6 | 6 | "dev": "bun --watch src/main.ts", |
| 7 | 7 | "start": "bun src/main.ts", |
| 8 | + "test": "bunx vitest run --no-watch src/*.test.ts", | |
| 8 | 9 | "typecheck": "bunx tsc --noEmit -p tsconfig.json" |
| 9 | 10 | }, |
| 10 | 11 | "dependencies": { |
| @@ -12,5 +13,9 @@ · 1 unmodified line | ||
| 12 | 13 | "@executor-v2/mcp-gateway": "workspace:*", |
| 13 | 14 | "@executor-v2/sdk": "workspace:*", |
| 14 | 15 | "effect": "catalog:" |
| 16 | + }, | |
| 17 | + "devDependencies": { | |
| 18 | + "@effect/vitest": "catalog:", | |
| 19 | + "vitest": "catalog:" | |
| 15 | 20 | } |
| 16 | 21 | } |
| @@ -0,0 +1,167 @@ | ||
| 1 | +import { spawn } from "node:child_process"; | |
| 2 | +import { createServer } from "node:net"; | |
| 3 | +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; | |
| 4 | +import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; | |
| 5 | +import { describe, expect, it } from "@effect/vitest"; | |
| 6 | +import * as Data from "effect/Data"; | |
| 7 | +import * as Effect from "effect/Effect"; | |
| 8 | + | |
| 9 | +const pmAppDir = new URL("..", import.meta.url).pathname; | |
| 10 | + | |
| 11 | +type McpCallToolResult = { | |
| 12 | + isError?: boolean; | |
| 13 | + content?: Array<{ type?: string; text?: string }>; | |
| 14 | +}; | |
| 15 | + | |
| 16 | +class ExecuteE2eError extends Data.TaggedError("ExecuteE2eError")<{ | |
| 17 | + message: string; | |
| 18 | + details: string | null; | |
| 19 | +}> {} | |
| 20 | + | |
| 21 | +const toExecuteE2eError = (message: string, cause: unknown): ExecuteE2eError => | |
| 22 | + new ExecuteE2eError({ | |
| 23 | + message, | |
| 24 | + details: cause instanceof Error ? cause.message : String(cause), | |
| 25 | + }); | |
| 26 | + | |
| 27 | +const findFreePort = Effect.async<number, ExecuteE2eError, never>((resume) => { | |
| 28 | + const server = createServer(); | |
| 29 | + | |
| 30 | + server.once("error", (cause) => { | |
| 31 | + resume(Effect.fail(toExecuteE2eError("Failed to allocate free TCP port", cause))); | |
| 32 | + }); | |
| 33 | + | |
| 34 | + server.listen(0, "127.0.0.1", () => { | |
| 35 | + const address = server.address(); | |
| 36 | + const port = typeof address === "object" && address ? address.port : null; | |
| 37 | + | |
| 38 | + server.close((cause) => { | |
| 39 | + if (cause) { | |
| 40 | + resume(Effect.fail(toExecuteE2eError("Failed closing port probe server", cause))); | |
| 41 | + return; | |
| 42 | + } | |
| 43 | + | |
| 44 | + if (!port) { | |
| 45 | + resume( | |
| 46 | + Effect.fail( | |
| 47 | + new ExecuteE2eError({ | |
| 48 | + message: "Failed to allocate free TCP port", | |
| 49 | + details: "No port returned from node:net server", | |
| 50 | + }), | |
| 51 | + ), | |
| 52 | + ); | |
| 53 | + return; | |
| 54 | + } | |
| 55 | + | |
| 56 | + resume(Effect.succeed(port)); | |
| 57 | + }); | |
| 58 | + }); | |
| 59 | +}); | |
| 60 | + | |
| 61 | +const waitForHealth = (port: number) => | |
| 62 | + Effect.gen(function* () { | |
| 63 | + for (let attempt = 0; attempt < 80; attempt += 1) { | |
| 64 | + const healthy = yield* Effect.tryPromise({ | |
| 65 | + try: async () => { | |
| 66 | + const signal = AbortSignal.timeout(250); | |
| 67 | + const response = await fetch(`http://127.0.0.1:${port}/healthz`, { signal }); | |
| 68 | + return response.status === 200; | |
| 69 | + }, | |
| 70 | + catch: (cause) => | |
| 71 | + toExecuteE2eError("Failed polling PM health endpoint", cause), | |
| 72 | + }).pipe(Effect.catchAll(() => Effect.succeed(false))); | |
| 73 | + | |
| 74 | + if (healthy) { | |
| 75 | + return; | |
| 76 | + } | |
| 77 | + | |
| 78 | + yield* Effect.sleep("100 millis"); | |
| 79 | + } | |
| 80 | + | |
| 81 | + return yield* new ExecuteE2eError({ | |
| 82 | + message: "PM server did not become healthy in time", | |
| 83 | + details: `http://127.0.0.1:${port}/healthz`, | |
| 84 | + }); | |
| 85 | + }); | |
| 86 | + | |
| 87 | +const withPmProcess = (port: number) => | |
| 88 | + Effect.acquireRelease( | |
| 89 | + Effect.sync(() => | |
| 90 | + spawn("bun", ["src/main.ts"], { | |
| 91 | + cwd: pmAppDir, | |
| 92 | + env: { | |
| 93 | + ...process.env, | |
| 94 | + PORT: String(port), | |
| 95 | + }, | |
| 96 | + stdio: "pipe", | |
| 97 | + }), | |
| 98 | + ), | |
| 99 | + (child) => | |
| 100 | + Effect.sync(() => { | |
| 101 | + if (!child.killed) { | |
| 102 | + child.kill("SIGTERM"); | |
| 103 | + } | |
| 104 | + }), | |
| 105 | + ); | |
| 106 | + | |
| 107 | +const withMcpClient = (port: number) => | |
| 108 | + Effect.acquireRelease( | |
| 109 | + Effect.tryPromise({ | |
| 110 | + try: async () => { | |
| 111 | + const transport = new StreamableHTTPClientTransport( | |
| 112 | + new URL(`http://127.0.0.1:${port}/mcp`), | |
| 113 | + ); | |
| 114 | + const client = new Client( | |
| 115 | + { name: "executor-v2-pm-e2e", version: "0.0.0" }, | |
| 116 | + { capabilities: {} }, | |
| 117 | + ); | |
| 118 | + | |
| 119 | + await client.connect(transport); | |
| 120 | + return { client, transport }; | |
| 121 | + }, | |
| 122 | + catch: (cause) => | |
| 123 | + toExecuteE2eError("Failed to initialize MCP client transport", cause), | |
| 124 | + }), | |
| 125 | + ({ client, transport }) => | |
| 126 | + Effect.tryPromise({ | |
| 127 | + try: async () => { | |
| 128 | + await transport.close().catch(() => undefined); | |
| 129 | + await client.close().catch(() => undefined); | |
| 130 | + }, | |
| 131 | + catch: (cause) => | |
| 132 | + toExecuteE2eError("Failed closing MCP client transport", cause), | |
| 133 | + }).pipe(Effect.orDie), | |
| 134 | + ); | |
| 135 | + | |
| 136 | +describe("PM execute E2E", () => { | |
| 137 | + it.live( | |
| 138 | + "runs executor.execute code through MCP", | |
| 139 | + () => | |
| 140 | + Effect.scoped( | |
| 141 | + Effect.gen(function* () { | |
| 142 | + const port = yield* findFreePort; | |
| 143 | + yield* withPmProcess(port); | |
| 144 | + yield* waitForHealth(port); | |
| 145 | + | |
| 146 | + const { client } = yield* withMcpClient(port); | |
| 147 | + | |
| 148 | + const result = (yield* Effect.tryPromise({ | |
| 149 | + try: () => | |
| 150 | + client.callTool({ | |
| 151 | + name: "executor.execute", | |
| 152 | + arguments: { | |
| 153 | + code: "return 2 + 3;", | |
| 154 | + }, | |
| 155 | + }), | |
| 156 | + catch: (cause) => | |
| 157 | + toExecuteE2eError("Failed calling executor.execute over MCP", cause), | |
| 158 | + })) as McpCallToolResult; | |
| 159 | + | |
| 160 | + expect(result.isError).toBe(false); | |
| 161 | + const textPart = result.content?.find((part) => part.type === "text"); | |
| 162 | + expect(textPart?.text).toBe("5"); | |
| 163 | + }), | |
| 164 | + ), | |
| 165 | + 30_000, | |
| 166 | + ); | |
| 167 | +}); | |
| @@ -2,10 +2,12 @@ import * as Effect from "effect/Effect"; · 1 unmodified line | ||
| 2 | 2 | |
| 3 | 3 | import { PmConfig } from "./config"; |
| 4 | 4 | import { PmMcpHandler } from "./mcp-handler"; |
| 5 | +import { PmToolCallHttpHandler } from "./tool-call-handler"; | |
| 5 | 6 | |
| 6 | 7 | export const startPmHttpServer = Effect.fn("@executor-v2/app-pm/http.start")(function* () { |
| 7 | 8 | const { port } = yield* PmConfig; |
| 8 | 9 | const { handleMcp } = yield* PmMcpHandler; |
| 10 | + const { handleToolCallHttp } = yield* PmToolCallHttpHandler; | |
| 9 | 11 | |
| 10 | 12 | const server = Bun.serve({ |
| 11 | 13 | port, |
| @@ -23,6 +25,12 @@ export const startPmHttpServer = Effect.fn("@executor-v2/app-pm/http.start")(fun · 11 unmodified lines | ||
| 23 | 25 | POST: handleMcp, |
| 24 | 26 | DELETE: handleMcp, |
| 25 | 27 | }, |
| 28 | + "/runtime/tool-call": { | |
| 29 | + POST: handleToolCallHttp, | |
| 30 | + }, | |
| 31 | + "/v1/runtime/tool-call": { | |
| 32 | + POST: handleToolCallHttp, | |
| 33 | + }, | |
| 26 | 34 | }, |
| 27 | 35 | }); |
| 28 | 36 | |
| @@ -10,6 +10,10 @@ import { · 9 unmodified lines | ||
| 10 | 10 | PmRunExecutorLive, |
| 11 | 11 | PmToolProviderRegistryLive, |
| 12 | 12 | } from "./run-executor"; |
| 13 | +import { | |
| 14 | + PmToolCallHandlerLive, | |
| 15 | + PmToolCallHttpHandlerLive, | |
| 16 | +} from "./tool-call-handler"; | |
| 13 | 17 | |
| 14 | 18 | const runtimeAdapter = makeLocalInProcessRuntimeAdapter(); |
| 15 | 19 | const PmMcpDependenciesLive = Layer.merge( |
| @@ -17,9 +21,14 @@ const PmMcpDependenciesLive = Layer.merge( · 1 unmodified line | ||
| 17 | 21 | PmToolProviderRegistryLive, |
| 18 | 22 | ); |
| 19 | 23 | |
| 20 | −const PmAppLive = Layer.merge( | |
| 24 | +const PmToolCallDependenciesLive = PmToolCallHttpHandlerLive.pipe( | |
| 25 | + Layer.provide(PmToolCallHandlerLive), | |
| 26 | +); | |
| 27 | + | |
| 28 | +const PmAppLive = Layer.mergeAll( | |
| 21 | 29 | PmConfigLive, |
| 22 | 30 | PmMcpHandlerLive.pipe(Layer.provide(PmMcpDependenciesLive)), |
| 31 | + PmToolCallDependenciesLive, | |
| 23 | 32 | ); |
| 24 | 33 | |
| 25 | 34 | const program = startPmHttpServer().pipe(Effect.provide(PmAppLive)); |
| @@ -0,0 +1,68 @@ | ||
| 1 | +import { describe, expect, it } from "@effect/vitest"; | |
| 2 | +import * as Effect from "effect/Effect"; | |
| 3 | +import * as Layer from "effect/Layer"; | |
| 4 | + | |
| 5 | +import { | |
| 6 | + PmToolCallHandler, | |
| 7 | + PmToolCallHandlerLive, | |
| 8 | + PmToolCallHttpHandler, | |
| 9 | + PmToolCallHttpHandlerLive, | |
| 10 | +} from "./tool-call-handler"; | |
| 11 | + | |
| 12 | +describe("PM runtime tool-call handling", () => { | |
| 13 | + it.effect("returns failed result while callback invocation is unwired", () => | |
| 14 | + Effect.gen(function* () { | |
| 15 | + const handler = yield* PmToolCallHandler; | |
| 16 | + | |
| 17 | + const result = yield* handler.handleToolCall({ | |
| 18 | + runId: "run_1", | |
| 19 | + callId: "call_1", | |
| 20 | + toolPath: "tools.example.lookup", | |
| 21 | + input: { query: "ping" }, | |
| 22 | + }); | |
| 23 | + | |
| 24 | + expect(result.ok).toBe(false); | |
| 25 | + if (!result.ok) { | |
| 26 | + expect(result.kind).toBe("failed"); | |
| 27 | + expect(result.error).toContain("tools.example.lookup"); | |
| 28 | + } | |
| 29 | + }).pipe(Effect.provide(PmToolCallHandlerLive)), | |
| 30 | + ); | |
| 31 | + | |
| 32 | + it.effect("decodes callback request payload and returns JSON result", () => | |
| 33 | + Effect.gen(function* () { | |
| 34 | + const httpHandler = yield* PmToolCallHttpHandler; | |
| 35 | + | |
| 36 | + const response = yield* Effect.tryPromise(() => | |
| 37 | + httpHandler.handleToolCallHttp( | |
| 38 | + new Request("http://127.0.0.1/runtime/tool-call", { | |
| 39 | + method: "POST", | |
| 40 | + headers: { "content-type": "application/json" }, | |
| 41 | + body: JSON.stringify({ | |
| 42 | + runId: "run_2", | |
| 43 | + callId: "call_2", | |
| 44 | + toolPath: "tools.example.weather", | |
| 45 | + input: { city: "London" }, | |
| 46 | + }), | |
| 47 | + }), | |
| 48 | + ), | |
| 49 | + ); | |
| 50 | + | |
| 51 | + expect(response.status).toBe(200); | |
| 52 | + | |
| 53 | + const payload = (yield* Effect.tryPromise(() => response.json())) as { | |
| 54 | + ok: boolean; | |
| 55 | + kind?: string; | |
| 56 | + error?: string; | |
| 57 | + }; | |
| 58 | + | |
| 59 | + expect(payload.ok).toBe(false); | |
| 60 | + expect(payload.kind).toBe("failed"); | |
| 61 | + expect(payload.error).toContain("tools.example.weather"); | |
| 62 | + }).pipe( | |
| 63 | + Effect.provide( | |
| 64 | + PmToolCallHttpHandlerLive.pipe(Layer.provide(PmToolCallHandlerLive)), | |
| 65 | + ), | |
| 66 | + ), | |
| 67 | + ); | |
| 68 | +}); | |
| @@ -0,0 +1,124 @@ | ||
| 1 | +import type { | |
| 2 | + RuntimeToolCallRequest, | |
| 3 | + RuntimeToolCallResult, | |
| 4 | +} from "@executor-v2/sdk"; | |
| 5 | +import * as Context from "effect/Context"; | |
| 6 | +import * as Data from "effect/Data"; | |
| 7 | +import * as Effect from "effect/Effect"; | |
| 8 | +import * as Layer from "effect/Layer"; | |
| 9 | +import * as Runtime from "effect/Runtime"; | |
| 10 | +import * as Schema from "effect/Schema"; | |
| 11 | + | |
| 12 | +export type PmToolCallHandlerService = { | |
| 13 | + handleToolCall: (input: RuntimeToolCallRequest) => Effect.Effect<RuntimeToolCallResult>; | |
| 14 | +}; | |
| 15 | + | |
| 16 | +export class PmToolCallHandler extends Context.Tag("@executor-v2/app-pm/PmToolCallHandler")< | |
| 17 | + PmToolCallHandler, | |
| 18 | + PmToolCallHandlerService | |
| 19 | +>() {} | |
| 20 | + | |
| 21 | +class PmToolCallHttpRequestError extends Data.TaggedError( | |
| 22 | + "PmToolCallHttpRequestError", | |
| 23 | +)<{ | |
| 24 | + message: string; | |
| 25 | + details: string | null; | |
| 26 | +}> {} | |
| 27 | + | |
| 28 | +const RuntimeToolCallRequestSchema = Schema.Struct({ | |
| 29 | + runId: Schema.String, | |
| 30 | + callId: Schema.String, | |
| 31 | + toolPath: Schema.String, | |
| 32 | + input: Schema.optional(Schema.Record({ | |
| 33 | + key: Schema.String, | |
| 34 | + value: Schema.Unknown, | |
| 35 | + })), | |
| 36 | +}); | |
| 37 | + | |
| 38 | +const decodeRuntimeToolCallRequest = Schema.decodeUnknown(RuntimeToolCallRequestSchema); | |
| 39 | + | |
| 40 | +const errorToText = (error: unknown): string => { | |
| 41 | + if (error instanceof Error) { | |
| 42 | + return error.message; | |
| 43 | + } | |
| 44 | + | |
| 45 | + if ( | |
| 46 | + typeof error === "object" && | |
| 47 | + error !== null && | |
| 48 | + "message" in error && | |
| 49 | + typeof error.message === "string" | |
| 50 | + ) { | |
| 51 | + return error.message; | |
| 52 | + } | |
| 53 | + | |
| 54 | + return String(error); | |
| 55 | +}; | |
| 56 | + | |
| 57 | +const decodeRequestBodyError = (cause: unknown): PmToolCallHttpRequestError => | |
| 58 | + new PmToolCallHttpRequestError({ | |
| 59 | + message: "Invalid runtime callback request body", | |
| 60 | + details: cause instanceof Error ? cause.message : String(cause), | |
| 61 | + }); | |
| 62 | + | |
| 63 | +const handleToolCall = Effect.fn("@executor-v2/app-pm/tool-call.handle")(function* ( | |
| 64 | + input: RuntimeToolCallRequest, | |
| 65 | +) { | |
| 66 | + return { | |
| 67 | + ok: false, | |
| 68 | + kind: "failed", | |
| 69 | + error: `PM runtime callback received tool '${input.toolPath}', but callback invocation is not wired yet.`, | |
| 70 | + } satisfies RuntimeToolCallResult; | |
| 71 | +}); | |
| 72 | + | |
| 73 | +export const PmToolCallHandlerLive = Layer.succeed( | |
| 74 | + PmToolCallHandler, | |
| 75 | + PmToolCallHandler.of({ | |
| 76 | + handleToolCall, | |
| 77 | + }), | |
| 78 | +); | |
| 79 | + | |
| 80 | +export type PmToolCallHttpHandlerService = { | |
| 81 | + handleToolCallHttp: (request: Request) => Promise<Response>; | |
| 82 | +}; | |
| 83 | + | |
| 84 | +export class PmToolCallHttpHandler extends Context.Tag("@executor-v2/app-pm/PmToolCallHttpHandler")< | |
| 85 | + PmToolCallHttpHandler, | |
| 86 | + PmToolCallHttpHandlerService | |
| 87 | +>() {} | |
| 88 | + | |
| 89 | +export const PmToolCallHttpHandlerLive = Layer.effect( | |
| 90 | + PmToolCallHttpHandler, | |
| 91 | + Effect.gen(function* () { | |
| 92 | + const handler = yield* PmToolCallHandler; | |
| 93 | + const runtime = yield* Effect.runtime<never>(); | |
| 94 | + const runPromise = Runtime.runPromise(runtime); | |
| 95 | + | |
| 96 | + const handleToolCallHttp = (request: Request): Promise<Response> => | |
| 97 | + runPromise( | |
| 98 | + Effect.tryPromise({ | |
| 99 | + try: () => request.json(), | |
| 100 | + catch: decodeRequestBodyError, | |
| 101 | + }).pipe( | |
| 102 | + Effect.flatMap((body) => decodeRuntimeToolCallRequest(body)), | |
| 103 | + Effect.flatMap((input) => handler.handleToolCall(input)), | |
| 104 | + Effect.map((result) => Response.json(result, { status: 200 })), | |
| 105 | + Effect.catchAll((error) => | |
| 106 | + Effect.succeed( | |
| 107 | + Response.json( | |
| 108 | + { | |
| 109 | + ok: false, | |
| 110 | + kind: "failed", | |
| 111 | + error: errorToText(error), | |
| 112 | + } satisfies RuntimeToolCallResult, | |
| 113 | + { status: 400 }, | |
| 114 | + ), | |
| 115 | + ), | |
| 116 | + ), | |
| 117 | + ), | |
| 118 | + ); | |
| 119 | + | |
| 120 | + return PmToolCallHttpHandler.of({ | |
| 121 | + handleToolCallHttp, | |
| 122 | + }); | |
| 123 | + }), | |
| 124 | +); | |
| @@ -20,10 +20,18 @@ · 19 unmodified lines | ||
| 20 | 20 | "apps/convex": { |
| 21 | 21 | "name": "@executor-v2/app-convex", |
| 22 | 22 | "dependencies": { |
| 23 | + "@executor-v2/confect": "workspace:*", | |
| 24 | + "@executor-v2/engine": "workspace:*", | |
| 23 | 25 | "@executor-v2/mcp-gateway": "workspace:*", |
| 24 | 26 | "@executor-v2/persistence-convex": "workspace:*", |
| 25 | 27 | "@executor-v2/sdk": "workspace:*", |
| 26 | 28 | "convex": "catalog:", |
| 29 | + "effect": "catalog:", | |
| 30 | + }, | |
| 31 | + "devDependencies": { | |
| 32 | + "@effect/vitest": "catalog:", | |
| 33 | + "convex-test": "^0.0.41", | |
| 34 | + "vitest": "catalog:", | |
| 27 | 35 | }, |
| 28 | 36 | }, |
| 29 | 37 | "apps/pm": { |
| @@ -766,6 +774,8 @@ · 736 unmodified lines | ||
| 766 | 774 | |
| 767 | 775 | "convex": ["convex@1.32.0", "", { "dependencies": { "esbuild": "0.27.0", "prettier": "^3.0.0", "ws": "8.18.0" }, "peerDependencies": { "@auth0/auth0-react": "^2.0.1", "@clerk/clerk-react": "^4.12.8 || ^5.0.0", "react": "^18.0.0 || ^19.0.0-0 || ^19.0.0" }, "optionalPeers": ["@auth0/auth0-react", "@clerk/clerk-react", "react"], "bin": { "convex": "bin/main.js" } }, "sha512-5FlajdLpW75pdLS+/CgGH5H6yeRuA+ru50AKJEYbJpmyILUS+7fdTvsdTaQ7ZFXMv0gE8mX4S+S3AtJ94k0mfw=="], |
| 768 | 776 | |
| 777 | + "convex-test": ["convex-test@0.0.41", "", { "peerDependencies": { "convex": "^1.16.4" } }, "sha512-GPHeYFOi70n7UtW0eCEQFVhzl/+m8PvbWkDCbKpHLybI1MrScf4sVpGeM0cC2qmtxiduxa2nLPbehPalhh9oyQ=="], | |
| 778 | + | |
| 769 | 779 | "cookie": ["cookie@0.7.2", "", {}, "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w=="], |
| 770 | 780 | |
| 771 | 781 | "cookie-es": ["cookie-es@2.0.0", "", {}, "sha512-RAj4E421UYRgqokKUmotqAwuplYw15qtdXfY+hGzgCJ/MBjCVZcSoHK/kH9kocfjRjcDME7IiDWR/1WX1TM2Pg=="], |
| @@ -26,5 +26,8 @@ · 25 unmodified lines | ||
| 26 | 26 | "@types/bun": "^1.2.22", |
| 27 | 27 | "@types/node": "catalog:", |
| 28 | 28 | "typescript": "catalog:" |
| 29 | + }, | |
| 30 | + "dependencies": { | |
| 31 | + "vercel": "^50.23.2" | |
| 29 | 32 | } |
| 30 | 33 | } |
| @@ -15,10 +15,10 @@ · 14 unmodified lines | ||
| 15 | 15 | "zod": "^4.3.6" |
| 16 | 16 | }, |
| 17 | 17 | "devDependencies": { |
| 18 | − "ai": "^6.0.0", | |
| 19 | − "effect": "catalog:", | |
| 20 | 18 | "@effect/vitest": "catalog:", |
| 21 | 19 | "@executor-v2/engine": "workspace:*", |
| 20 | + "ai": "^6.0.0", | |
| 21 | + "effect": "catalog:", | |
| 22 | 22 | "vitest": "catalog:" |
| 23 | 23 | } |
| 24 | 24 | } |
| @@ -1,14 +1,17 @@ | ||
| 1 | 1 | import { describe, expect, it } from "@effect/vitest"; |
| 2 | 2 | import { |
| 3 | + createInMemoryRuntimeRunClient, | |
| 4 | + createRuntimeRunClient, | |
| 3 | 5 | makeDenoSubprocessRuntimeAdapter, |
| 6 | + makeLocalInProcessRuntimeAdapter, | |
| 4 | 7 | makeToolProviderRegistry, |
| 5 | − ToolProviderRegistryService, | |
| 6 | 8 | } from "@executor-v2/engine"; |
| 7 | 9 | import { createExecutorRunClient } from "@executor-v2/sdk"; |
| 8 | 10 | import type { ExecuteRunResult } from "@executor-v2/sdk"; |
| 9 | 11 | import { createGateway, generateText, stepCountIs, tool } from "ai"; |
| 10 | 12 | import * as Effect from "effect/Effect"; |
| 11 | −import * as Runtime from "effect/Runtime"; | |
| 13 | + | |
| 14 | +import { z } from "zod"; | |
| 12 | 15 | |
| 13 | 16 | import { toAiSdkTools } from "./index"; |
| 14 | 17 | |
| @@ -50,15 +53,11 @@ describe("toAiSdkTools", () => { · 35 unmodified lines | ||
| 50 | 53 | }), |
| 51 | 54 | ); |
| 52 | 55 | |
| 53 | − // Our mock ExecutorRunClient should have been invoked | |
| 54 | 56 | expect(executionLog.length).toBeGreaterThanOrEqual(1); |
| 55 | 57 | expect(executionLog[0]!.code).toBeTypeOf("string"); |
| 56 | 58 | expect(executionLog[0]!.code.length).toBeGreaterThan(0); |
| 57 | − | |
| 58 | − // timeoutMs should be set — either by the model or by the default | |
| 59 | 59 | expect(executionLog[0]!.timeoutMs).toBeTypeOf("number"); |
| 60 | 60 | |
| 61 | − // Check the steps contain a tool call | |
| 62 | 61 | const toolCallSteps = result.steps.filter( |
| 63 | 62 | (step) => step.toolCalls.length > 0, |
| 64 | 63 | ); |
| @@ -68,7 +67,6 @@ describe("toAiSdkTools", () => { · 3 unmodified lines | ||
| 68 | 67 | expect(firstToolCall.toolName).toBe("execute"); |
| 69 | 68 | expect(firstToolCall.input).toHaveProperty("code"); |
| 70 | 69 | |
| 71 | − // Tool results should contain our mock response | |
| 72 | 70 | const toolResultSteps = result.steps.filter( |
| 73 | 71 | (step) => step.toolResults.length > 0, |
| 74 | 72 | ); |
| @@ -77,12 +75,64 @@ describe("toAiSdkTools", () => { · 2 unmodified lines | ||
| 77 | 75 | expect(toolResult.toolName).toBe("execute"); |
| 78 | 76 | expect(toolResult.output).toMatchObject(mockResult); |
| 79 | 77 | |
| 80 | − // The model should have produced a final text response | |
| 81 | 78 | expect(result.text).toBeTypeOf("string"); |
| 82 | 79 | }), |
| 83 | 80 | { timeout: 30_000 }, |
| 84 | 81 | ); |
| 85 | 82 | |
| 83 | + it.effect( | |
| 84 | + "calls a normal AI SDK tool from the execute sandbox", | |
| 85 | + () => | |
| 86 | + Effect.gen(function* () { | |
| 87 | + const normalToolCalls: Array<{ query: string }> = []; | |
| 88 | + | |
| 89 | + const searchDocsTool = tool({ | |
| 90 | + description: "Search docs by query", | |
| 91 | + inputSchema: z.object({ | |
| 92 | + query: z.string(), | |
| 93 | + }), | |
| 94 | + execute: async (input: { query: string }) => { | |
| 95 | + normalToolCalls.push(input); | |
| 96 | + return { | |
| 97 | + hits: [`match:${input.query}`], | |
| 98 | + }; | |
| 99 | + }, | |
| 100 | + }); | |
| 101 | + | |
| 102 | + const runClient = createInMemoryRuntimeRunClient({ | |
| 103 | + runtimeAdapter: makeLocalInProcessRuntimeAdapter(), | |
| 104 | + tools: { | |
| 105 | + search_docs: searchDocsTool, | |
| 106 | + }, | |
| 107 | + defaults: { | |
| 108 | + timeoutMs: 30_000, | |
| 109 | + }, | |
| 110 | + }); | |
| 111 | + | |
| 112 | + const tools = toAiSdkTools({ | |
| 113 | + runClient, | |
| 114 | + makeTool: (def) => def, | |
| 115 | + }); | |
| 116 | + | |
| 117 | + const result = yield* Effect.tryPromise(() => | |
| 118 | + tools.execute.execute({ | |
| 119 | + code: "return await tools.search_docs({ query: 'codemode adapter integration' });", | |
| 120 | + }), | |
| 121 | + ); | |
| 122 | + | |
| 123 | + expect(result.status).toBe("completed"); | |
| 124 | + expect(result.result).toEqual({ | |
| 125 | + hits: ["match:codemode adapter integration"], | |
| 126 | + }); | |
| 127 | + expect(normalToolCalls).toEqual([ | |
| 128 | + { | |
| 129 | + query: "codemode adapter integration", | |
| 130 | + }, | |
| 131 | + ]); | |
| 132 | + }), | |
| 133 | + { timeout: 30_000 }, | |
| 134 | + ); | |
| 135 | + | |
| 86 | 136 | it.effect( |
| 87 | 137 | "executes code in a real Deno subprocess via generateText", |
| 88 | 138 | () => |
| @@ -93,37 +143,9 @@ describe("toAiSdkTools", () => { · 4 unmodified lines | ||
| 93 | 143 | |
| 94 | 144 | const toolProviderRegistry = makeToolProviderRegistry([]); |
| 95 | 145 | |
| 96 | − // Build an Effect runtime that has the ToolProviderRegistryService | |
| 97 | − const effectRuntime = yield* Effect.runtime<never>(); | |
| 98 | − const runPromise = Runtime.runPromise(effectRuntime); | |
| 99 | − | |
| 100 | − const runClient = createExecutorRunClient(async (input) => { | |
| 101 | − const runId = `run_${crypto.randomUUID()}`; | |
| 102 | − | |
| 103 | − try { | |
| 104 | − const result = await runPromise( | |
| 105 | − runtimeAdapter | |
| 106 | − .execute({ | |
| 107 | − code: input.code, | |
| 108 | − tools: [], | |
| 109 | − timeoutMs: input.timeoutMs, | |
| 110 | − }) | |
| 111 | − .pipe( | |
| 112 | − Effect.provideService( | |
| 113 | − ToolProviderRegistryService, | |
| 114 | − toolProviderRegistry, | |
| 115 | − ), | |
| 116 | − ), | |
| 117 | − ); | |
| 118 | − | |
| 119 | − return { runId, status: "completed", result } satisfies ExecuteRunResult; | |
| 120 | − } catch (error) { | |
| 121 | − return { | |
| 122 | − runId, | |
| 123 | − status: "failed", | |
| 124 | − error: error instanceof Error ? error.message : String(error), | |
| 125 | − } satisfies ExecuteRunResult; | |
| 126 | − } | |
| 146 | + const runClient = createRuntimeRunClient({ | |
| 147 | + runtimeAdapter, | |
| 148 | + toolProviderRegistry, | |
| 127 | 149 | }); |
| 128 | 150 | |
| 129 | 151 | const tools = toAiSdkTools({ |
| @@ -145,7 +167,6 @@ describe("toAiSdkTools", () => { · 15 unmodified lines | ||
| 145 | 167 | }), |
| 146 | 168 | ); |
| 147 | 169 | |
| 148 | − // Check the steps contain a tool call | |
| 149 | 170 | const toolCallSteps = result.steps.filter( |
| 150 | 171 | (step) => step.toolCalls.length > 0, |
| 151 | 172 | ); |
| @@ -154,7 +175,6 @@ describe("toAiSdkTools", () => { · 2 unmodified lines | ||
| 154 | 175 | const firstToolCall = toolCallSteps[0]!.toolCalls[0]!; |
| 155 | 176 | expect(firstToolCall.toolName).toBe("execute"); |
| 156 | 177 | |
| 157 | − // Tool results should show the code was executed successfully in Deno | |
| 158 | 178 | const toolResultSteps = result.steps.filter( |
| 159 | 179 | (step) => step.toolResults.length > 0, |
| 160 | 180 | ); |
| @@ -167,7 +187,6 @@ describe("toAiSdkTools", () => { · 6 unmodified lines | ||
| 167 | 187 | result: 5, |
| 168 | 188 | }); |
| 169 | 189 | |
| 170 | − // The model should have produced a final text response | |
| 171 | 190 | expect(result.text).toBeTypeOf("string"); |
| 172 | 191 | }), |
| 173 | 192 | { timeout: 30_000 }, |
| @@ -27,6 +27,7 @@ export type ToAiSdkToolsOptions<TTool> = { · 26 unmodified lines | ||
| 27 | 27 | timeoutMs?: number; |
| 28 | 28 | }; |
| 29 | 29 | }; |
| 30 | + | |
| 30 | 31 | const withDefaults = ( |
| 31 | 32 | input: ExecuteToolInput, |
| 32 | 33 | defaults?: { |
| @@ -40,7 +40,7 @@ listEndpoint.implement(() => · 39 unmodified lines | ||
| 40 | 40 | const getEndpoint = factory.query({ |
| 41 | 41 | payload: { id: Schema.String }, |
| 42 | 42 | success: Schema.Struct({ author: Schema.String, message: Schema.String }), |
| 43 | − error: Schema.Struct({ _tag: Schema.Literal("NotFound") }), | |
| 43 | + error: Schema.TaggedStruct("NotFound", {}), | |
| 44 | 44 | }); |
| 45 | 45 | getEndpoint.implement((payload) => |
| 46 | 46 | Effect.gen(function* () { |
| @@ -3,7 +3,9 @@ · 2 unmodified lines | ||
| 3 | 3 | "private": true, |
| 4 | 4 | "type": "module", |
| 5 | 5 | "exports": { |
| 6 | − ".": "./src/index.ts" | |
| 6 | + ".": "./src/index.ts", | |
| 7 | + "./local-runner": "./src/local-runner.ts", | |
| 8 | + "./tool-providers": "./src/tool-providers.ts" | |
| 7 | 9 | }, |
| 8 | 10 | "scripts": { |
| 9 | 11 | "test": "bunx vitest run --no-watch src/*.test.ts", |
| @@ -27,6 +27,14 @@ export { · 26 unmodified lines | ||
| 27 | 27 | type RuntimeExecuteInput, |
| 28 | 28 | type RuntimeRunnableTool, |
| 29 | 29 | } from "./runtime-adapters"; |
| 30 | +export { | |
| 31 | + createInMemoryRuntimeRunClient, | |
| 32 | + createRuntimeRunClient, | |
| 33 | + type CreateInMemoryRuntimeRunClientOptions, | |
| 34 | + type CreateRuntimeRunClientOptions, | |
| 35 | + type InMemorySandboxTool, | |
| 36 | + type InMemorySandboxToolMap, | |
| 37 | +} from "./run-client"; | |
| 30 | 38 | export { |
| 31 | 39 | makeOpenApiToolProvider, |
| 32 | 40 | openApiToolDescriptorsFromManifest, |
| @@ -14,7 +14,8 @@ export class LocalCodeRunnerError extends Data.TaggedError("LocalCodeRunnerError · 13 unmodified lines | ||
| 14 | 14 | operation: string; |
| 15 | 15 | message: string; |
| 16 | 16 | details: string | null; |
| 17 | −}> {} | |
| 17 | +}> { | |
| 18 | +} | |
| 18 | 19 | |
| 19 | 20 | export type RunnableTool = { |
| 20 | 21 | descriptor: CanonicalToolDescriptor; |
| @@ -63,7 +64,7 @@ const buildToolBindings = ( · 42 unmodified lines | ||
| 63 | 64 | |
| 64 | 65 | return byToolId; |
| 65 | 66 | }); |
| 66 | − | |
| 67 | +// todo: run in sandbox | |
| 67 | 68 | const runJavaScript = ( |
| 68 | 69 | code: string, |
| 69 | 70 | toolsObject: Record<string, (args: unknown) => Promise<unknown>>, |
| @@ -74,7 +75,7 @@ const runJavaScript = ( · 4 unmodified lines | ||
| 74 | 75 | "tools", |
| 75 | 76 | `"use strict"; return (async () => {\n${code}\n})();`, |
| 76 | 77 | ) as (tools: Record<string, (args: unknown) => Promise<unknown>>) => |
| 77 | − Promise<unknown>; | |
| 78 | + Promise<unknown>; | |
| 78 | 79 | |
| 79 | 80 | return await execute(toolsObject); |
| 80 | 81 | }, |
| @@ -0,0 +1,215 @@ | ||
| 1 | +import * as Effect from "effect/Effect"; | |
| 2 | +import * as Either from "effect/Either"; | |
| 3 | + | |
| 4 | +import { | |
| 5 | + ToolProviderError, | |
| 6 | + ToolProviderRegistryService, | |
| 7 | + makeToolProviderRegistry, | |
| 8 | + type CanonicalToolDescriptor, | |
| 9 | + type ToolProvider, | |
| 10 | + type ToolProviderRegistry, | |
| 11 | +} from "./tool-providers"; | |
| 12 | +import type { RuntimeAdapter, RuntimeRunnableTool } from "./runtime-adapters"; | |
| 13 | + | |
| 14 | +export type RuntimeRunClientExecuteInput = { | |
| 15 | + code: string; | |
| 16 | + timeoutMs?: number; | |
| 17 | +}; | |
| 18 | + | |
| 19 | +export type RuntimeRunClientExecuteResult = { | |
| 20 | + runId: string; | |
| 21 | + status: "completed" | "failed" | "timed_out" | "denied"; | |
| 22 | + result?: unknown; | |
| 23 | + error?: string; | |
| 24 | + exitCode?: number; | |
| 25 | + durationMs?: number; | |
| 26 | +}; | |
| 27 | + | |
| 28 | +export type RuntimeRunClient = { | |
| 29 | + execute: ( | |
| 30 | + input: RuntimeRunClientExecuteInput, | |
| 31 | + ) => Promise<RuntimeRunClientExecuteResult>; | |
| 32 | +}; | |
| 33 | + | |
| 34 | +export type InMemorySandboxTool = { | |
| 35 | + description?: string | null; | |
| 36 | + execute?: (...args: Array<any>) => Promise<any> | any; | |
| 37 | +}; | |
| 38 | + | |
| 39 | +export type InMemorySandboxToolMap = Record<string, InMemorySandboxTool>; | |
| 40 | + | |
| 41 | +export type CreateRuntimeRunClientOptions = { | |
| 42 | + runtimeAdapter: RuntimeAdapter; | |
| 43 | + tools?: ReadonlyArray<RuntimeRunnableTool>; | |
| 44 | + toolProviderRegistry: ToolProviderRegistry; | |
| 45 | + defaults?: { | |
| 46 | + timeoutMs?: number; | |
| 47 | + }; | |
| 48 | + makeRunId?: () => string; | |
| 49 | +}; | |
| 50 | + | |
| 51 | +export type CreateInMemoryRuntimeRunClientOptions = { | |
| 52 | + runtimeAdapter: RuntimeAdapter; | |
| 53 | + tools: InMemorySandboxToolMap; | |
| 54 | + defaults?: { | |
| 55 | + timeoutMs?: number; | |
| 56 | + }; | |
| 57 | + makeRunId?: () => string; | |
| 58 | +}; | |
| 59 | + | |
| 60 | +const errorToText = (error: unknown): string => { | |
| 61 | + if (error instanceof Error) { | |
| 62 | + return error.message; | |
| 63 | + } | |
| 64 | + | |
| 65 | + if ( | |
| 66 | + typeof error === "object" && | |
| 67 | + error !== null && | |
| 68 | + "message" in error && | |
| 69 | + typeof error.message === "string" | |
| 70 | + ) { | |
| 71 | + return error.message; | |
| 72 | + } | |
| 73 | + | |
| 74 | + return String(error); | |
| 75 | +}; | |
| 76 | + | |
| 77 | +const toInMemoryDescriptor = ( | |
| 78 | + toolName: string, | |
| 79 | + tool: InMemorySandboxTool, | |
| 80 | +): CanonicalToolDescriptor => ({ | |
| 81 | + providerKind: "in_memory", | |
| 82 | + sourceId: null, | |
| 83 | + workspaceId: null, | |
| 84 | + toolId: toolName, | |
| 85 | + name: toolName, | |
| 86 | + description: tool.description ?? null, | |
| 87 | + invocationMode: "in_memory", | |
| 88 | + availability: "local_only", | |
| 89 | + providerPayload: null, | |
| 90 | +}); | |
| 91 | + | |
| 92 | +const makeInMemoryRuntimeTools = ( | |
| 93 | + tools: InMemorySandboxToolMap, | |
| 94 | +): ReadonlyArray<RuntimeRunnableTool> => | |
| 95 | + Object.entries(tools).map(([toolName, tool]) => ({ | |
| 96 | + descriptor: toInMemoryDescriptor(toolName, tool), | |
| 97 | + source: null, | |
| 98 | + })); | |
| 99 | + | |
| 100 | +const makeInMemoryToolProvider = ( | |
| 101 | + tools: InMemorySandboxToolMap, | |
| 102 | +): ToolProvider => ({ | |
| 103 | + kind: "in_memory", | |
| 104 | + invoke: (input) => { | |
| 105 | + const implementation = tools[input.tool.toolId]; | |
| 106 | + | |
| 107 | + if (!implementation) { | |
| 108 | + return Effect.succeed({ | |
| 109 | + output: `Unknown in-memory tool: ${input.tool.toolId}`, | |
| 110 | + isError: true, | |
| 111 | + }); | |
| 112 | + } | |
| 113 | + | |
| 114 | + return Effect.tryPromise({ | |
| 115 | + try: async () => { | |
| 116 | + if (!implementation.execute) { | |
| 117 | + throw new Error(`In-memory tool '${input.tool.toolId}' has no execute function`); | |
| 118 | + } | |
| 119 | + | |
| 120 | + return { | |
| 121 | + output: await implementation.execute(input.args, undefined), | |
| 122 | + isError: false, | |
| 123 | + }; | |
| 124 | + }, | |
| 125 | + catch: (cause) => | |
| 126 | + new ToolProviderError({ | |
| 127 | + operation: "invoke", | |
| 128 | + providerKind: "in_memory", | |
| 129 | + message: `In-memory tool invocation failed: ${input.tool.toolId}`, | |
| 130 | + details: cause instanceof Error ? cause.message : String(cause), | |
| 131 | + }), | |
| 132 | + }); | |
| 133 | + }, | |
| 134 | +}); | |
| 135 | + | |
| 136 | +export const createRuntimeRunClient = ( | |
| 137 | + options: CreateRuntimeRunClientOptions, | |
| 138 | +): RuntimeRunClient => { | |
| 139 | + const runIdFactory = options.makeRunId ?? (() => `run_${crypto.randomUUID()}`); | |
| 140 | + | |
| 141 | + return { | |
| 142 | + execute: async ( | |
| 143 | + input: RuntimeRunClientExecuteInput, | |
| 144 | + ): Promise<RuntimeRunClientExecuteResult> => { | |
| 145 | + const runId = runIdFactory(); | |
| 146 | + | |
| 147 | + const availabilityResult = await Effect.runPromise( | |
| 148 | + Effect.either(options.runtimeAdapter.isAvailable()), | |
| 149 | + ); | |
| 150 | + | |
| 151 | + if (Either.isLeft(availabilityResult)) { | |
| 152 | + return { | |
| 153 | + runId, | |
| 154 | + status: "failed", | |
| 155 | + error: errorToText(availabilityResult.left), | |
| 156 | + }; | |
| 157 | + } | |
| 158 | + | |
| 159 | + if (!availabilityResult.right) { | |
| 160 | + return { | |
| 161 | + runId, | |
| 162 | + status: "failed", | |
| 163 | + error: `Runtime '${options.runtimeAdapter.kind}' is not available`, | |
| 164 | + }; | |
| 165 | + } | |
| 166 | + | |
| 167 | + const executionResult = await Effect.runPromise( | |
| 168 | + Effect.either( | |
| 169 | + options.runtimeAdapter | |
| 170 | + .execute({ | |
| 171 | + code: input.code, | |
| 172 | + timeoutMs: input.timeoutMs ?? options.defaults?.timeoutMs, | |
| 173 | + tools: options.tools ?? [], | |
| 174 | + }) | |
| 175 | + .pipe( | |
| 176 | + Effect.provideService( | |
| 177 | + ToolProviderRegistryService, | |
| 178 | + options.toolProviderRegistry, | |
| 179 | + ), | |
| 180 | + ), | |
| 181 | + ), | |
| 182 | + ); | |
| 183 | + | |
| 184 | + if (Either.isLeft(executionResult)) { | |
| 185 | + return { | |
| 186 | + runId, | |
| 187 | + status: "failed", | |
| 188 | + error: errorToText(executionResult.left), | |
| 189 | + }; | |
| 190 | + } | |
| 191 | + | |
| 192 | + return { | |
| 193 | + runId, | |
| 194 | + status: "completed", | |
| 195 | + result: executionResult.right, | |
| 196 | + }; | |
| 197 | + }, | |
| 198 | + }; | |
| 199 | +}; | |
| 200 | + | |
| 201 | +export const createInMemoryRuntimeRunClient = ( | |
| 202 | + options: CreateInMemoryRuntimeRunClientOptions, | |
| 203 | +): RuntimeRunClient => { | |
| 204 | + const toolProviderRegistry = makeToolProviderRegistry([ | |
| 205 | + makeInMemoryToolProvider(options.tools), | |
| 206 | + ]); | |
| 207 | + | |
| 208 | + return createRuntimeRunClient({ | |
| 209 | + runtimeAdapter: options.runtimeAdapter, | |
| 210 | + tools: makeInMemoryRuntimeTools(options.tools), | |
| 211 | + toolProviderRegistry, | |
| 212 | + defaults: options.defaults, | |
| 213 | + makeRunId: options.makeRunId, | |
| 214 | + }); | |
| 215 | +}; | |