Unified · Split
| @@ -47,6 +47,12 @@ v2 changes the default: · 46 unmodified lines | ||
| 47 | 47 | - `cloud` and `self-hosted` are not separate architecture branches. |
| 48 | 48 | - They are both **remote Convex targets**; only URL/auth/deployment ownership differs. |
| 49 | 49 | |
| 50 | +### Runtime targets (adapter-based) | |
| 51 | + | |
| 52 | +- **`deno-subprocess`**: local sandbox runtime (current local secure target in v2). | |
| 53 | +- **`cloudflare-worker-loader`**: production cloud runtime target. | |
| 54 | +- **`node:vm`**: explicit unsafe/dev fallback only, not default. | |
| 55 | + | |
| 50 | 56 | --- |
| 51 | 57 | |
| 52 | 58 | ## 4) Control + execution model |
| @@ -391,27 +397,36 @@ Implemented in `v2` so far: · 338 unmodified lines | ||
| 391 | 397 | - monorepo app/package skeleton |
| 392 | 398 | - `confect` imported from quickhub |
| 393 | 399 | - schema package scaffold with Effect models and event envelope |
| 400 | +- schema-driven runtime tool models (canonical descriptor + OpenAPI invocation/manifest) | |
| 394 | 401 | - domain ID groundwork in `schema/src/ids.ts` |
| 395 | 402 | - Convex table ID groundwork in `persistence-convex/src/convex-ids.ts` |
| 396 | 403 | - shared persistence ports for `SourceStore` and `ToolArtifactStore` |
| 397 | 404 | - local persistence adapters for source/tool artifacts |
| 398 | 405 | - local-only snapshot/WAL contract and persistence implementation |
| 399 | 406 | - OpenAPI extraction + artifact refresh/reuse flow |
| 400 | −- service-first wiring (`Context.Tag` + Layer) in source manager and local persistence | |
| 407 | +- provider registry contracts and routing in `engine` | |
| 408 | +- OpenAPI provider invocation + in-memory provider path through registry | |
| 409 | +- local in-process JS runner with `tools.*` proxy dispatch | |
| 410 | +- Deno subprocess runtime path with IPC tool-call proxying and tests | |
| 411 | +- runtime adapter contract + registry scaffold in `engine` with mapped adapters (`local-inproc`, `deno-subprocess`, `cloudflare-worker-loader`) | |
| 412 | +- minimal PM execute lifecycle wired through MCP `executor.execute` -> runtime adapter registry (single run, no persisted run state yet) | |
| 413 | +- vertical test: OpenAPI spec -> manifest -> execute code -> HTTP tool call | |
| 414 | +- service-first wiring (`Context.Tag` + Layer) across source manager and persistence/local runtime orchestration | |
| 401 | 415 | |
| 402 | 416 | Not implemented yet: |
| 403 | −- provider registry and canonical tool descriptor routing in engine/runtime | |
| 404 | −- execute lifecycle through PM beyond stub MCP tool | |
| 405 | −- MCP/GraphQL providers and in-memory tool registration/invocation path | |
| 417 | +- `tools.executor.sources.add/list/remove` end-to-end PM + MCP gateway wiring | |
| 418 | +- approval adapter/state machine integration (pending/resume/deny persistence) in v2 runtime path | |
| 419 | +- provider/runtime conformance suite beyond current baseline tests (timeouts, cancellation, pending approval) | |
| 420 | +- Cloudflare worker loader adapter execution integration (current adapter is explicit scaffold/not-implemented) | |
| 421 | +- MCP/GraphQL provider implementations | |
| 406 | 422 | - full rpc/sdk adapter wiring and sync engine |
| 407 | 423 | |
| 408 | 424 | --- |
| 409 | 425 | |
| 410 | 426 | ## 19) Immediate next steps |
| 411 | 427 | |
| 412 | −1. Define canonical provider tool descriptor + provider service contracts (`discover` / `invoke`). | |
| 413 | −2. Implement engine-side provider registry and routing for tool invocation. | |
| 414 | −3. Wire minimal `apps/pm` execute flow (single run lifecycle) using provider routing. | |
| 415 | −4. Implement first end-to-end providers: `openapi` (network) and `in_memory` (AI-SDK style). | |
| 416 | −5. Add first `tools.executor.sources.add/list/remove` control operations in PM gateway. | |
| 417 | −6. Add provider conformance tests and one vertical test: OpenAPI spec -> manifest -> execute tool call. | |
| 428 | +1. Add first `tools.executor.sources.add/list/remove` control operations end-to-end in PM + MCP gateway. | |
| 429 | +2. Implement approval adapter contract with persisted pending/resume/deny flow for runtime tool calls. | |
| 430 | +3. Add provider/runtime conformance tests (invoke success/failure, timeout, pending approval, cancel). | |
| 431 | +4. Add Cloudflare worker loader adapter integration against the runtime adapter contract. | |
| 432 | +5. Extend PM execute lifecycle from single-run scaffold to persisted run state + streaming status. | |
| @@ -0,0 +1 @@ | ||
| 1 | +export {}; | |
| @@ -0,0 +1 @@ | ||
| 1 | +export {}; | |
| @@ -5,4 +5,5 @@ Local Process Manager app scaffold for Executor v2. · 4 unmodified lines | ||
| 5 | 5 | Current scaffold includes: |
| 6 | 6 | - local MCP endpoint at `POST /mcp` |
| 7 | 7 | - health endpoint at `GET /healthz` |
| 8 | −- MCP stub tool routing via `@executor-v2/mcp-gateway` | |
| 8 | +- MCP tool routing via `@executor-v2/mcp-gateway` | |
| 9 | +- `executor.execute` wired to engine runtime adapters (`local-inproc` default, `deno-subprocess` optional) | |
| @@ -8,6 +8,8 @@ · 7 unmodified lines | ||
| 8 | 8 | "typecheck": "bunx tsc --noEmit -p tsconfig.json" |
| 9 | 9 | }, |
| 10 | 10 | "dependencies": { |
| 11 | − "@executor-v2/mcp-gateway": "workspace:*" | |
| 11 | + "@executor-v2/engine": "workspace:*", | |
| 12 | + "@executor-v2/mcp-gateway": "workspace:*", | |
| 13 | + "effect": "^3.19.8" | |
| 12 | 14 | } |
| 13 | 15 | } |
| @@ -1,12 +1,78 @@ | ||
| 1 | −import { handleMcpHttpRequest } from "@executor-v2/mcp-gateway"; | |
| 1 | +import { | |
| 2 | + makeCloudflareWorkerLoaderRuntimeAdapter, | |
| 3 | + makeDenoSubprocessRuntimeAdapter, | |
| 4 | + makeLocalInProcessRuntimeAdapter, | |
| 5 | + makeRuntimeAdapterRegistry, | |
| 6 | + makeToolProviderRegistry, | |
| 7 | + ToolProviderRegistryService, | |
| 8 | +} from "@executor-v2/engine"; | |
| 9 | +import { | |
| 10 | + handleMcpHttpRequest, | |
| 11 | + type ExecuteToolInput, | |
| 12 | + type ExecuteToolResult, | |
| 13 | +} from "@executor-v2/mcp-gateway"; | |
| 14 | +import * as Effect from "effect/Effect"; | |
| 15 | +import * as Either from "effect/Either"; | |
| 2 | 16 | |
| 3 | 17 | const port = Number(Bun.env.PORT ?? 8787); |
| 4 | 18 | |
| 19 | +const runtimeRegistry = makeRuntimeAdapterRegistry([ | |
| 20 | + makeLocalInProcessRuntimeAdapter(), | |
| 21 | + makeDenoSubprocessRuntimeAdapter(), | |
| 22 | + makeCloudflareWorkerLoaderRuntimeAdapter(), | |
| 23 | +]); | |
| 24 | + | |
| 25 | +const toolRegistry = makeToolProviderRegistry([]); | |
| 26 | + | |
| 27 | +const errorToText = (error: unknown): string => { | |
| 28 | + if (error instanceof Error) { | |
| 29 | + return error.message; | |
| 30 | + } | |
| 31 | + | |
| 32 | + if ( | |
| 33 | + typeof error === "object" && | |
| 34 | + error !== null && | |
| 35 | + "message" in error && | |
| 36 | + typeof error.message === "string" | |
| 37 | + ) { | |
| 38 | + return error.message; | |
| 39 | + } | |
| 40 | + | |
| 41 | + return String(error); | |
| 42 | +}; | |
| 43 | + | |
| 44 | +const executeTool = async (input: ExecuteToolInput): Promise<ExecuteToolResult> => { | |
| 45 | + const result = await Effect.runPromise( | |
| 46 | + Effect.either( | |
| 47 | + runtimeRegistry | |
| 48 | + .execute({ | |
| 49 | + runtimeKind: input.runtimeKind ?? "local-inproc", | |
| 50 | + code: input.code, | |
| 51 | + tools: [], | |
| 52 | + }) | |
| 53 | + .pipe(Effect.provideService(ToolProviderRegistryService, toolRegistry)), | |
| 54 | + ), | |
| 55 | + ); | |
| 56 | + | |
| 57 | + if (Either.isLeft(result)) { | |
| 58 | + return { | |
| 59 | + isError: true, | |
| 60 | + error: errorToText(result.left), | |
| 61 | + }; | |
| 62 | + } | |
| 63 | + | |
| 64 | + return { | |
| 65 | + isError: false, | |
| 66 | + output: result.right, | |
| 67 | + }; | |
| 68 | +}; | |
| 69 | + | |
| 5 | 70 | const handleMcp = async (request: Request): Promise<Response> => |
| 6 | 71 | handleMcpHttpRequest(request, { |
| 7 | 72 | target: "local", |
| 8 | 73 | serverName: "executor-v2-pm", |
| 9 | 74 | serverVersion: "0.0.0", |
| 75 | + execute: executeTool, | |
| 10 | 76 | }); |
| 11 | 77 | |
| 12 | 78 | const server = Bun.serve({ |
| @@ -0,0 +1 @@ | ||
| 1 | +export {}; | |
| @@ -25,7 +25,9 @@ · 24 unmodified lines | ||
| 25 | 25 | "apps/pm": { |
| 26 | 26 | "name": "@executor-v2/app-pm", |
| 27 | 27 | "dependencies": { |
| 28 | + "@executor-v2/engine": "workspace:*", | |
| 28 | 29 | "@executor-v2/mcp-gateway": "workspace:*", |
| 30 | + "effect": "^3.19.8", | |
| 29 | 31 | }, |
| 30 | 32 | }, |
| 31 | 33 | "apps/web": { |
| @@ -50,7 +52,6 @@ · 18 unmodified lines | ||
| 50 | 52 | "next": "^16.1.6", |
| 51 | 53 | }, |
| 52 | 54 | "devDependencies": { |
| 53 | − "@effect/vitest": "^0.27.0", | |
| 54 | 55 | "@types/node": "^20.19.9", |
| 55 | 56 | "typescript": "^5.9.2", |
| 56 | 57 | "vitest": "^3.2.4", |
| @@ -67,7 +68,9 @@ · 10 unmodified lines | ||
| 67 | 68 | "effect": "^3.19.8", |
| 68 | 69 | }, |
| 69 | 70 | "devDependencies": { |
| 71 | + "@effect/vitest": "^0.27.0", | |
| 70 | 72 | "@executor-v2/source-manager": "workspace:*", |
| 73 | + "vitest": "^3.2.4", | |
| 71 | 74 | }, |
| 72 | 75 | }, |
| 73 | 76 | "packages/mcp-gateway": { |
| @@ -102,6 +105,10 @@ · 28 unmodified lines | ||
| 102 | 105 | "@executor-v2/schema": "workspace:*", |
| 103 | 106 | "effect": "^3.19.8", |
| 104 | 107 | }, |
| 108 | + "devDependencies": { | |
| 109 | + "@effect/vitest": "^0.27.0", | |
| 110 | + "vitest": "^3.2.4", | |
| 111 | + }, | |
| 105 | 112 | }, |
| 106 | 113 | "packages/state-contracts": { |
| 107 | 114 | "name": "@executor-v2/persistence-ports", |
| @@ -0,0 +1,3 @@ | ||
| 1 | +{ | |
| 2 | + "$schema": "https://unpkg.com/knip@latest/schema.json" | |
| 3 | +} | |
| @@ -9,7 +9,8 @@ · 8 unmodified lines | ||
| 9 | 9 | ], |
| 10 | 10 | "scripts": { |
| 11 | 11 | "prepare": "effect-language-service patch", |
| 12 | − "typecheck": "tsc --noEmit" | |
| 12 | + "typecheck": "tsc --noEmit", | |
| 13 | + "knip": "bunx knip --config knip.json" | |
| 13 | 14 | }, |
| 14 | 15 | "devDependencies": { |
| 15 | 16 | "@effect/language-service": "^0.76.0", |
| @@ -0,0 +1 @@ | ||
| 1 | +export {}; | |
| @@ -0,0 +1 @@ | ||
| 1 | +export {}; | |
| @@ -27,7 +27,6 @@ · 26 unmodified lines | ||
| 27 | 27 | "next": "^16.1.6" |
| 28 | 28 | }, |
| 29 | 29 | "devDependencies": { |
| 30 | − "@effect/vitest": "^0.27.0", | |
| 31 | 30 | "@types/node": "^20.19.9", |
| 32 | 31 | "typescript": "^5.9.2", |
| 33 | 32 | "vitest": "^3.2.4" |
| @@ -44,7 +44,6 @@ export { Rpc, RpcGroup, RpcMiddleware }; · 43 unmodified lines | ||
| 44 | 44 | |
| 45 | 45 | export { |
| 46 | 46 | WrapperTypeId, |
| 47 | − type WrapperTypeId as WrapperTypeIdType, | |
| 48 | 47 | type Wrapper, |
| 49 | 48 | isWrapper, |
| 50 | 49 | wrap, |
| @@ -9,7 +9,7 @@ export const convexRequestIdHeader = "convex-request-id"; · 8 unmodified lines | ||
| 9 | 9 | |
| 10 | 10 | export type RpcClientKind = "query" | "mutation" | "action"; |
| 11 | 11 | |
| 12 | −export interface RpcTelemetryContext { | |
| 12 | +interface RpcTelemetryContext { | |
| 13 | 13 | readonly traceparent: string; |
| 14 | 14 | readonly b3?: string; |
| 15 | 15 | readonly tracestate?: string; |
| @@ -0,0 +1 @@ | ||
| 1 | +export {}; | |
| @@ -8,4 +8,5 @@ Current scaffold includes: · 7 unmodified lines | ||
| 8 | 8 | - OpenAPI provider invocation and manifest-to-descriptor conversion helpers |
| 9 | 9 | - minimal in-process JavaScript runner with `tools.*` proxy dispatch into provider registry |
| 10 | 10 | - Deno subprocess runner with line-delimited JSON IPC for proxied `tools.*` calls |
| 11 | +- runtime adapter contract + registry (`local-inproc`, `deno-subprocess`, `cloudflare-worker-loader` scaffold) | |
| 11 | 12 | - vertical integration test covering OpenAPI extraction -> descriptor conversion -> code execution -> HTTP call |
| @@ -6,7 +6,7 @@ · 5 unmodified lines | ||
| 6 | 6 | ".": "./src/index.ts" |
| 7 | 7 | }, |
| 8 | 8 | "scripts": { |
| 9 | − "test": "bun test src/*.test.ts", | |
| 9 | + "test": "bunx vitest run --no-watch src/*.test.ts", | |
| 10 | 10 | "typecheck": "bunx tsc --noEmit -p tsconfig.json" |
| 11 | 11 | }, |
| 12 | 12 | "dependencies": { |
| @@ -15,6 +15,8 @@ · 2 unmodified lines | ||
| 15 | 15 | "effect": "^3.19.8" |
| 16 | 16 | }, |
| 17 | 17 | "devDependencies": { |
| 18 | − "@executor-v2/source-manager": "workspace:*" | |
| 18 | + "@effect/vitest": "^0.27.0", | |
| 19 | + "@executor-v2/source-manager": "workspace:*", | |
| 20 | + "vitest": "^3.2.4" | |
| 19 | 21 | } |
| 20 | 22 | } |
| @@ -1,9 +1,10 @@ | ||
| 1 | +import { describe, expect, it } from "@effect/vitest"; | |
| 1 | 2 | import { |
| 2 | 3 | CanonicalToolDescriptorSchema, |
| 3 | 4 | type CanonicalToolDescriptor, |
| 4 | 5 | } from "@executor-v2/schema"; |
| 5 | −import { describe, expect, test } from "bun:test"; | |
| 6 | 6 | import * as Effect from "effect/Effect"; |
| 7 | +import * as Either from "effect/Either"; | |
| 7 | 8 | import * as Schema from "effect/Schema"; |
| 8 | 9 | |
| 9 | 10 | import { |
| @@ -33,30 +34,30 @@ const sumToolDescriptor: CanonicalToolDescriptor = decodeCanonicalToolDescriptor · 23 unmodified lines | ||
| 33 | 34 | }); |
| 34 | 35 | |
| 35 | 36 | describe("executeJavaScriptInDenoSubprocess", () => { |
| 36 | − test("runs code in Deno subprocess and proxies tool calls", async () => { | |
| 37 | − const sumProvider: ToolProvider = { | |
| 38 | − kind: "in_memory", | |
| 39 | − invoke: (input) => | |
| 40 | − Effect.gen(function* () { | |
| 41 | − if (input.tool.toolId !== "sum") { | |
| 37 | + it.effect("runs code in Deno subprocess and proxies tool calls", () => | |
| 38 | + Effect.gen(function* () { | |
| 39 | + const sumProvider: ToolProvider = { | |
| 40 | + kind: "in_memory", | |
| 41 | + invoke: (input) => | |
| 42 | + Effect.gen(function* () { | |
| 43 | + if (input.tool.toolId !== "sum") { | |
| 44 | + return { | |
| 45 | + output: `unknown tool: ${input.tool.toolId}`, | |
| 46 | + isError: true, | |
| 47 | + } as const; | |
| 48 | + } | |
| 49 | + | |
| 50 | + const args = input.args as { a: number; b: number }; | |
| 42 | 51 | return { |
| 43 | − output: `unknown tool: ${input.tool.toolId}`, | |
| 44 | − isError: true, | |
| 52 | + output: args.a + args.b, | |
| 53 | + isError: false, | |
| 45 | 54 | } as const; |
| 46 | − } | |
| 47 | − | |
| 48 | − const args = input.args as { a: number; b: number }; | |
| 49 | − return { | |
| 50 | − output: args.a + args.b, | |
| 51 | − isError: false, | |
| 52 | − } as const; | |
| 53 | − }), | |
| 54 | − }; | |
| 55 | + }), | |
| 56 | + }; | |
| 55 | 57 | |
| 56 | − const registry = makeToolProviderRegistry([sumProvider]); | |
| 58 | + const registry = makeToolProviderRegistry([sumProvider]); | |
| 57 | 59 | |
| 58 | − const result = await Effect.runPromise( | |
| 59 | − executeJavaScriptInDenoSubprocess({ | |
| 60 | + const result = yield* executeJavaScriptInDenoSubprocess({ | |
| 60 | 61 | code: "console.log('hello'); return await tools.sum({ a: 2, b: 3 });", |
| 61 | 62 | tools: [ |
| 62 | 63 | { |
| @@ -65,31 +66,30 @@ describe("executeJavaScriptInDenoSubprocess", () => { · 2 unmodified lines | ||
| 65 | 66 | }, |
| 66 | 67 | ], |
| 67 | 68 | timeoutMs: 10_000, |
| 68 | − }).pipe(Effect.provideService(ToolProviderRegistryService, registry)), | |
| 69 | − ); | |
| 69 | + }).pipe(Effect.provideService(ToolProviderRegistryService, registry)); | |
| 70 | 70 | |
| 71 | − expect(result).toBe(5); | |
| 72 | − }); | |
| 71 | + expect(result).toBe(5); | |
| 72 | + }), | |
| 73 | + ); | |
| 73 | 74 | |
| 74 | − test("returns a typed error when Deno executable is missing", async () => { | |
| 75 | − const registry = makeToolProviderRegistry([]); | |
| 75 | + it.effect("returns a typed error when Deno executable is missing", () => | |
| 76 | + Effect.gen(function* () { | |
| 77 | + const registry = makeToolProviderRegistry([]); | |
| 76 | 78 | |
| 77 | − const result = await Effect.runPromise( | |
| 78 | − Effect.either( | |
| 79 | + const result = yield* Effect.either( | |
| 79 | 80 | executeJavaScriptInDenoSubprocess({ |
| 80 | 81 | code: "return 1;", |
| 81 | 82 | tools: [], |
| 82 | 83 | denoExecutable: "/definitely-missing-deno-binary", |
| 83 | 84 | timeoutMs: 1_000, |
| 84 | 85 | }).pipe(Effect.provideService(ToolProviderRegistryService, registry)), |
| 85 | − ), | |
| 86 | − ); | |
| 87 | − | |
| 88 | − if (result._tag === "Right") { | |
| 89 | − throw new Error("expected missing Deno executable to fail"); | |
| 90 | − } | |
| 86 | + ); | |
| 91 | 87 | |
| 92 | − expect(result.left).toBeInstanceOf(DenoSubprocessRunnerError); | |
| 93 | − expect(result.left.operation).toBe("spawn"); | |
| 94 | − }); | |
| 88 | + expect(Either.isLeft(result)).toBe(true); | |
| 89 | + if (Either.isLeft(result)) { | |
| 90 | + expect(result.left).toBeInstanceOf(DenoSubprocessRunnerError); | |
| 91 | + expect(result.left.operation).toBe("spawn"); | |
| 92 | + } | |
| 93 | + }), | |
| 94 | + ); | |
| 95 | 95 | }); |
| @@ -11,6 +11,22 @@ export { · 10 unmodified lines | ||
| 11 | 11 | type ExecuteJavaScriptInput, |
| 12 | 12 | type RunnableTool, |
| 13 | 13 | } from "./local-runner"; |
| 14 | +export { | |
| 15 | + RuntimeAdapterRegistryLive, | |
| 16 | + RuntimeAdapterRegistryError, | |
| 17 | + RuntimeAdapterRegistryService, | |
| 18 | + RuntimeAdapterError, | |
| 19 | + makeCloudflareWorkerLoaderRuntimeAdapter, | |
| 20 | + makeDenoSubprocessRuntimeAdapter, | |
| 21 | + makeLocalInProcessRuntimeAdapter, | |
| 22 | + makeRuntimeAdapterRegistry, | |
| 23 | + type RuntimeAdapter, | |
| 24 | + type RuntimeAdapterKind, | |
| 25 | + type RuntimeAdapterRegistry, | |
| 26 | + type RuntimeExecuteError, | |
| 27 | + type RuntimeExecuteInput, | |
| 28 | + type RuntimeRunnableTool, | |
| 29 | +} from "./runtime-adapters"; | |
| 14 | 30 | export { |
| 15 | 31 | makeOpenApiToolProvider, |
| 16 | 32 | openApiToolDescriptorsFromManifest, |
| @@ -1,9 +1,10 @@ | ||
| 1 | −import { | |
| 2 | − type ToolArtifactStore, | |
| 3 | −} from "@executor-v2/persistence-ports"; | |
| 1 | +import { createServer, type IncomingMessage, type ServerResponse } from "node:http"; | |
| 2 | + | |
| 3 | +import { describe, expect, it } from "@effect/vitest"; | |
| 4 | +import { type ToolArtifactStore } from "@executor-v2/persistence-ports"; | |
| 4 | 5 | import { type Source, SourceSchema, type ToolArtifact } from "@executor-v2/schema"; |
| 5 | 6 | import { makeSourceManagerService } from "@executor-v2/source-manager"; |
| 6 | −import { describe, expect, test } from "bun:test"; | |
| 7 | +import * as Data from "effect/Data"; | |
| 7 | 8 | import * as Effect from "effect/Effect"; |
| 8 | 9 | import * as Option from "effect/Option"; |
| 9 | 10 | import * as Schema from "effect/Schema"; |
| @@ -27,51 +28,104 @@ type TestServer = { · 17 unmodified lines | ||
| 27 | 28 | query: string; |
| 28 | 29 | apiKey: string | null; |
| 29 | 30 | }>; |
| 30 | − server: Bun.Server<unknown>; | |
| 31 | + close: () => Promise<void>; | |
| 31 | 32 | }; |
| 32 | 33 | |
| 33 | −const makeTestServer = Effect.acquireRelease( | |
| 34 | − Effect.sync<TestServer>(() => { | |
| 35 | − const requests: TestServer["requests"] = []; | |
| 36 | − | |
| 37 | − const server = Bun.serve({ | |
| 38 | − port: 0, | |
| 39 | − fetch: async (request) => { | |
| 40 | − const url = new URL(request.url); | |
| 41 | − | |
| 42 | − if (url.pathname === "/users/u123") { | |
| 43 | − requests.push({ | |
| 44 | − path: url.pathname, | |
| 45 | − query: url.search, | |
| 46 | − apiKey: request.headers.get("x-api-key"), | |
| 47 | − }); | |
| 34 | +class TestServerReleaseError extends Data.TaggedError("TestServerReleaseError")<{ | |
| 35 | + message: string; | |
| 36 | +}> {} | |
| 37 | + | |
| 38 | +const jsonResponse = (res: ServerResponse, statusCode: number, body: unknown): void => { | |
| 39 | + res.statusCode = statusCode; | |
| 40 | + res.setHeader("content-type", "application/json"); | |
| 41 | + res.end(JSON.stringify(body)); | |
| 42 | +}; | |
| 43 | + | |
| 44 | +const getHeaderValue = ( | |
| 45 | + req: IncomingMessage, | |
| 46 | + key: string, | |
| 47 | +): string | null => { | |
| 48 | + const value = req.headers[key]; | |
| 49 | + if (typeof value === "string") { | |
| 50 | + return value; | |
| 51 | + } | |
| 48 | 52 | |
| 49 | − return Response.json({ | |
| 50 | − id: "u123", | |
| 51 | − verbose: url.searchParams.get("verbose") === "true", | |
| 52 | − apiKey: request.headers.get("x-api-key"), | |
| 53 | + if (Array.isArray(value)) { | |
| 54 | + return value[0] ?? null; | |
| 55 | + } | |
| 56 | + | |
| 57 | + return null; | |
| 58 | +}; | |
| 59 | + | |
| 60 | +const makeTestServer = Effect.acquireRelease( | |
| 61 | + Effect.promise<TestServer>( | |
| 62 | + () => | |
| 63 | + new Promise<TestServer>((resolve, reject) => { | |
| 64 | + const requests: TestServer["requests"] = []; | |
| 65 | + | |
| 66 | + const server = createServer((req, res) => { | |
| 67 | + const host = getHeaderValue(req, "host") ?? "127.0.0.1"; | |
| 68 | + const url = new URL(req.url ?? "/", `http://${host}`); | |
| 69 | + | |
| 70 | + if (url.pathname === "/users/u123") { | |
| 71 | + requests.push({ | |
| 72 | + path: url.pathname, | |
| 73 | + query: url.search, | |
| 74 | + apiKey: getHeaderValue(req, "x-api-key"), | |
| 75 | + }); | |
| 76 | + | |
| 77 | + jsonResponse(res, 200, { | |
| 78 | + id: "u123", | |
| 79 | + verbose: url.searchParams.get("verbose") === "true", | |
| 80 | + apiKey: getHeaderValue(req, "x-api-key"), | |
| 81 | + }); | |
| 82 | + return; | |
| 83 | + } | |
| 84 | + | |
| 85 | + jsonResponse(res, 404, { error: "not found" }); | |
| 86 | + }); | |
| 87 | + | |
| 88 | + server.once("error", (error) => { | |
| 89 | + reject(error); | |
| 90 | + }); | |
| 91 | + | |
| 92 | + server.listen(0, "127.0.0.1", () => { | |
| 93 | + const address = server.address(); | |
| 94 | + if (!address || typeof address === "string") { | |
| 95 | + reject(new Error("failed to resolve test server address")); | |
| 96 | + return; | |
| 97 | + } | |
| 98 | + | |
| 99 | + resolve({ | |
| 100 | + baseUrl: `http://127.0.0.1:${address.port}`, | |
| 101 | + requests, | |
| 102 | + close: () => | |
| 103 | + new Promise<void>((closeResolve, closeReject) => { | |
| 104 | + server.close((error) => { | |
| 105 | + if (error) { | |
| 106 | + closeReject(error); | |
| 107 | + return; | |
| 108 | + } | |
| 109 | + closeResolve(); | |
| 110 | + }); | |
| 111 | + }), | |
| 53 | 112 | }); |
| 54 | − } | |
| 55 | − | |
| 56 | − return Response.json({ error: "not found" }, { status: 404 }); | |
| 57 | − }, | |
| 58 | − }); | |
| 59 | − | |
| 60 | − return { | |
| 61 | − baseUrl: `http://127.0.0.1:${server.port}`, | |
| 62 | − requests, | |
| 63 | − server, | |
| 64 | − }; | |
| 65 | − }), | |
| 66 | − ({ server }) => | |
| 67 | − Effect.sync(() => { | |
| 68 | − server.stop(true); | |
| 69 | − }), | |
| 113 | + }); | |
| 114 | + }) | |
| 115 | + ), | |
| 116 | + (testServer) => | |
| 117 | + Effect.tryPromise({ | |
| 118 | + try: () => testServer.close(), | |
| 119 | + catch: (cause) => | |
| 120 | + new TestServerReleaseError({ | |
| 121 | + message: cause instanceof Error ? cause.message : String(cause), | |
| 122 | + }), | |
| 123 | + }).pipe(Effect.orDie), | |
| 70 | 124 | ); |
| 71 | 125 | |
| 72 | 126 | describe("OpenAPI execution vertical slice", () => { |
| 73 | − test("extracts OpenAPI tools and executes code against provider", async () => { | |
| 74 | − const program = Effect.gen(function* () { | |
| 127 | + it.scoped("extracts OpenAPI tools and executes code against provider", () => | |
| 128 | + Effect.gen(function* () { | |
| 75 | 129 | const testServer = yield* makeTestServer; |
| 76 | 130 | |
| 77 | 131 | const openApiSpec = { |
| @@ -185,8 +239,6 @@ return await tools.getUser({ · 107 unmodified lines | ||
| 185 | 239 | expect(testServer.requests[0]?.path).toBe("/users/u123"); |
| 186 | 240 | expect(testServer.requests[0]?.query).toBe("?verbose=true"); |
| 187 | 241 | expect(testServer.requests[0]?.apiKey).toBe("sk_test"); |
| 188 | − }); | |
| 189 | − | |
| 190 | − await Effect.runPromise(program.pipe(Effect.scoped)); | |
| 191 | − }); | |
| 242 | + }), | |
| 243 | + ); | |
| 192 | 244 | }); |
| @@ -0,0 +1,96 @@ | ||
| 1 | +import { describe, expect, it } from "@effect/vitest"; | |
| 2 | +import { | |
| 3 | + CanonicalToolDescriptorSchema, | |
| 4 | + type CanonicalToolDescriptor, | |
| 5 | +} from "@executor-v2/schema"; | |
| 6 | +import * as Effect from "effect/Effect"; | |
| 7 | +import * as Either from "effect/Either"; | |
| 8 | +import * as Schema from "effect/Schema"; | |
| 9 | + | |
| 10 | +import { | |
| 11 | + makeCloudflareWorkerLoaderRuntimeAdapter, | |
| 12 | + makeLocalInProcessRuntimeAdapter, | |
| 13 | + makeRuntimeAdapterRegistry, | |
| 14 | + RuntimeAdapterError, | |
| 15 | +} from "./runtime-adapters"; | |
| 16 | +import { | |
| 17 | + makeToolProviderRegistry, | |
| 18 | + ToolProviderRegistryService, | |
| 19 | + type ToolProvider, | |
| 20 | +} from "./tool-providers"; | |
| 21 | + | |
| 22 | +const decodeCanonicalToolDescriptor = Schema.decodeUnknownSync( | |
| 23 | + CanonicalToolDescriptorSchema, | |
| 24 | +); | |
| 25 | + | |
| 26 | +const sumToolDescriptor: CanonicalToolDescriptor = decodeCanonicalToolDescriptor({ | |
| 27 | + providerKind: "in_memory", | |
| 28 | + sourceId: null, | |
| 29 | + workspaceId: null, | |
| 30 | + toolId: "sum", | |
| 31 | + name: "sum", | |
| 32 | + description: null, | |
| 33 | + invocationMode: "in_memory", | |
| 34 | + availability: "local_only", | |
| 35 | + providerPayload: null, | |
| 36 | +}); | |
| 37 | + | |
| 38 | +describe("runtime adapters", () => { | |
| 39 | + it.effect("executes with local-inproc adapter via runtime registry", () => | |
| 40 | + Effect.gen(function* () { | |
| 41 | + const sumProvider: ToolProvider = { | |
| 42 | + kind: "in_memory", | |
| 43 | + invoke: (input) => | |
| 44 | + Effect.gen(function* () { | |
| 45 | + const args = input.args as { a: number; b: number }; | |
| 46 | + return { | |
| 47 | + output: args.a + args.b, | |
| 48 | + isError: false, | |
| 49 | + } as const; | |
| 50 | + }), | |
| 51 | + }; | |
| 52 | + | |
| 53 | + const toolRegistry = makeToolProviderRegistry([sumProvider]); | |
| 54 | + const runtimeRegistry = makeRuntimeAdapterRegistry([ | |
| 55 | + makeLocalInProcessRuntimeAdapter(), | |
| 56 | + ]); | |
| 57 | + | |
| 58 | + const result = yield* runtimeRegistry | |
| 59 | + .execute({ | |
| 60 | + runtimeKind: "local-inproc", | |
| 61 | + code: "return await tools.sum({ a: 2, b: 4 });", | |
| 62 | + tools: [{ descriptor: sumToolDescriptor, source: null }], | |
| 63 | + }) | |
| 64 | + .pipe(Effect.provideService(ToolProviderRegistryService, toolRegistry)); | |
| 65 | + | |
| 66 | + expect(result).toBe(6); | |
| 67 | + }), | |
| 68 | + ); | |
| 69 | + | |
| 70 | + it.effect("returns typed not-implemented error for cloudflare adapter", () => | |
| 71 | + Effect.gen(function* () { | |
| 72 | + const toolRegistry = makeToolProviderRegistry([]); | |
| 73 | + const runtimeRegistry = makeRuntimeAdapterRegistry([ | |
| 74 | + makeCloudflareWorkerLoaderRuntimeAdapter(), | |
| 75 | + ]); | |
| 76 | + | |
| 77 | + const result = yield* Effect.either( | |
| 78 | + runtimeRegistry | |
| 79 | + .execute({ | |
| 80 | + runtimeKind: "cloudflare-worker-loader", | |
| 81 | + code: "return 1;", | |
| 82 | + tools: [], | |
| 83 | + }) | |
| 84 | + .pipe(Effect.provideService(ToolProviderRegistryService, toolRegistry)), | |
| 85 | + ); | |
| 86 | + | |
| 87 | + expect(Either.isLeft(result)).toBe(true); | |
| 88 | + if (Either.isLeft(result)) { | |
| 89 | + expect(result.left).toBeInstanceOf(RuntimeAdapterError); | |
| 90 | + if (result.left instanceof RuntimeAdapterError) { | |
| 91 | + expect(result.left.runtimeKind).toBe("cloudflare-worker-loader"); | |
| 92 | + } | |
| 93 | + } | |
| 94 | + }), | |
| 95 | + ); | |
| 96 | +}); | |
| @@ -0,0 +1,212 @@ | ||
| 1 | +import type { Source } from "@executor-v2/schema"; | |
| 2 | +import * as Context from "effect/Context"; | |
| 3 | +import * as Data from "effect/Data"; | |
| 4 | +import * as Effect from "effect/Effect"; | |
| 5 | +import * as Layer from "effect/Layer"; | |
| 6 | + | |
| 7 | +import { | |
| 8 | + DenoSubprocessRunnerError, | |
| 9 | + executeJavaScriptInDenoSubprocess, | |
| 10 | +} from "./deno-subprocess-runner"; | |
| 11 | +import { | |
| 12 | + executeJavaScriptWithTools, | |
| 13 | + LocalCodeRunnerError, | |
| 14 | +} from "./local-runner"; | |
| 15 | +import { | |
| 16 | + ToolProviderError, | |
| 17 | + ToolProviderRegistryError, | |
| 18 | + type CanonicalToolDescriptor, | |
| 19 | + type ToolProviderRegistryService, | |
| 20 | +} from "./tool-providers"; | |
| 21 | + | |
| 22 | +export type RuntimeAdapterKind = | |
| 23 | + | "local-inproc" | |
| 24 | + | "deno-subprocess" | |
| 25 | + | "cloudflare-worker-loader"; | |
| 26 | + | |
| 27 | +export type RuntimeRunnableTool = { | |
| 28 | + descriptor: CanonicalToolDescriptor; | |
| 29 | + source: Source | null; | |
| 30 | +}; | |
| 31 | + | |
| 32 | +export type RuntimeExecuteInput = { | |
| 33 | + code: string; | |
| 34 | + tools: ReadonlyArray<RuntimeRunnableTool>; | |
| 35 | + timeoutMs?: number; | |
| 36 | +}; | |
| 37 | + | |
| 38 | +export class RuntimeAdapterError extends Data.TaggedError("RuntimeAdapterError")<{ | |
| 39 | + operation: string; | |
| 40 | + runtimeKind: RuntimeAdapterKind; | |
| 41 | + message: string; | |
| 42 | + details: string | null; | |
| 43 | +}> {} | |
| 44 | + | |
| 45 | +export type RuntimeExecuteError = | |
| 46 | + | RuntimeAdapterError | |
| 47 | + | LocalCodeRunnerError | |
| 48 | + | DenoSubprocessRunnerError | |
| 49 | + | ToolProviderRegistryError | |
| 50 | + | ToolProviderError; | |
| 51 | + | |
| 52 | +export interface RuntimeAdapter { | |
| 53 | + readonly kind: RuntimeAdapterKind; | |
| 54 | + readonly isAvailable: () => Effect.Effect<boolean>; | |
| 55 | + readonly execute: ( | |
| 56 | + input: RuntimeExecuteInput, | |
| 57 | + ) => Effect.Effect<unknown, RuntimeExecuteError, ToolProviderRegistryService>; | |
| 58 | +} | |
| 59 | + | |
| 60 | +export class RuntimeAdapterRegistryError extends Data.TaggedError( | |
| 61 | + "RuntimeAdapterRegistryError", | |
| 62 | +)<{ | |
| 63 | + operation: string; | |
| 64 | + runtimeKind: RuntimeAdapterKind; | |
| 65 | + message: string; | |
| 66 | +}> {} | |
| 67 | + | |
| 68 | +export interface RuntimeAdapterRegistry { | |
| 69 | + readonly register: ( | |
| 70 | + adapter: RuntimeAdapter, | |
| 71 | + ) => Effect.Effect<void, RuntimeAdapterRegistryError>; | |
| 72 | + | |
| 73 | + readonly registerAll: ( | |
| 74 | + adapters: ReadonlyArray<RuntimeAdapter>, | |
| 75 | + ) => Effect.Effect<void, RuntimeAdapterRegistryError>; | |
| 76 | + | |
| 77 | + readonly get: ( | |
| 78 | + runtimeKind: RuntimeAdapterKind, | |
| 79 | + ) => Effect.Effect<RuntimeAdapter, RuntimeAdapterRegistryError>; | |
| 80 | + | |
| 81 | + readonly execute: ( | |
| 82 | + input: RuntimeExecuteInput & { runtimeKind: RuntimeAdapterKind }, | |
| 83 | + ) => Effect.Effect<unknown, RuntimeExecuteError | RuntimeAdapterRegistryError, ToolProviderRegistryService>; | |
| 84 | +} | |
| 85 | + | |
| 86 | +export class RuntimeAdapterRegistryService extends Context.Tag( | |
| 87 | + "@executor-v2/engine/RuntimeAdapterRegistryService", | |
| 88 | +)<RuntimeAdapterRegistryService, RuntimeAdapterRegistry>() {} | |
| 89 | + | |
| 90 | +type DenoSubprocessRuntimeAdapterOptions = { | |
| 91 | + denoExecutable?: string; | |
| 92 | + defaultTimeoutMs?: number; | |
| 93 | +}; | |
| 94 | + | |
| 95 | +const duplicateRuntimeAdapterError = ( | |
| 96 | + runtimeKind: RuntimeAdapterKind, | |
| 97 | +): RuntimeAdapterRegistryError => | |
| 98 | + new RuntimeAdapterRegistryError({ | |
| 99 | + operation: "register", | |
| 100 | + runtimeKind, | |
| 101 | + message: `Runtime adapter already registered: ${runtimeKind}`, | |
| 102 | + }); | |
| 103 | + | |
| 104 | +const runtimeAdapterNotFoundError = ( | |
| 105 | + operation: string, | |
| 106 | + runtimeKind: RuntimeAdapterKind, | |
| 107 | +): RuntimeAdapterRegistryError => | |
| 108 | + new RuntimeAdapterRegistryError({ | |
| 109 | + operation, | |
| 110 | + runtimeKind, | |
| 111 | + message: `No runtime adapter registered for kind: ${runtimeKind}`, | |
| 112 | + }); | |
| 113 | + | |
| 114 | +export const makeLocalInProcessRuntimeAdapter = (): RuntimeAdapter => ({ | |
| 115 | + kind: "local-inproc", | |
| 116 | + isAvailable: () => Effect.succeed(true), | |
| 117 | + execute: (input) => | |
| 118 | + executeJavaScriptWithTools({ | |
| 119 | + code: input.code, | |
| 120 | + tools: input.tools, | |
| 121 | + }), | |
| 122 | +}); | |
| 123 | + | |
| 124 | +export const makeDenoSubprocessRuntimeAdapter = ( | |
| 125 | + options: DenoSubprocessRuntimeAdapterOptions = {}, | |
| 126 | +): RuntimeAdapter => ({ | |
| 127 | + kind: "deno-subprocess", | |
| 128 | + isAvailable: () => Effect.succeed(true), | |
| 129 | + execute: (input) => | |
| 130 | + executeJavaScriptInDenoSubprocess({ | |
| 131 | + code: input.code, | |
| 132 | + tools: input.tools, | |
| 133 | + timeoutMs: input.timeoutMs ?? options.defaultTimeoutMs, | |
| 134 | + denoExecutable: options.denoExecutable, | |
| 135 | + }), | |
| 136 | +}); | |
| 137 | + | |
| 138 | +export const makeCloudflareWorkerLoaderRuntimeAdapter = (): RuntimeAdapter => ({ | |
| 139 | + kind: "cloudflare-worker-loader", | |
| 140 | + isAvailable: () => Effect.succeed(false), | |
| 141 | + execute: () => | |
| 142 | + new RuntimeAdapterError({ | |
| 143 | + operation: "execute", | |
| 144 | + runtimeKind: "cloudflare-worker-loader", | |
| 145 | + message: "Cloudflare worker loader runtime adapter is not implemented yet", | |
| 146 | + details: null, | |
| 147 | + }), | |
| 148 | +}); | |
| 149 | + | |
| 150 | +export const makeRuntimeAdapterRegistry = ( | |
| 151 | + initialAdapters: ReadonlyArray<RuntimeAdapter> = [], | |
| 152 | +): RuntimeAdapterRegistry => { | |
| 153 | + const adaptersByKind = new Map<RuntimeAdapterKind, RuntimeAdapter>(); | |
| 154 | + | |
| 155 | + const register = ( | |
| 156 | + adapter: RuntimeAdapter, | |
| 157 | + ): Effect.Effect<void, RuntimeAdapterRegistryError> => | |
| 158 | + Effect.gen(function* () { | |
| 159 | + if (adaptersByKind.has(adapter.kind)) { | |
| 160 | + return yield* duplicateRuntimeAdapterError(adapter.kind); | |
| 161 | + } | |
| 162 | + | |
| 163 | + adaptersByKind.set(adapter.kind, adapter); | |
| 164 | + }); | |
| 165 | + | |
| 166 | + const registerAll = ( | |
| 167 | + adapters: ReadonlyArray<RuntimeAdapter>, | |
| 168 | + ): Effect.Effect<void, RuntimeAdapterRegistryError> => | |
| 169 | + Effect.forEach(adapters, register, { discard: true }); | |
| 170 | + | |
| 171 | + const get = ( | |
| 172 | + runtimeKind: RuntimeAdapterKind, | |
| 173 | + ): Effect.Effect<RuntimeAdapter, RuntimeAdapterRegistryError> => | |
| 174 | + Effect.gen(function* () { | |
| 175 | + const adapter = adaptersByKind.get(runtimeKind); | |
| 176 | + if (!adapter) { | |
| 177 | + return yield* runtimeAdapterNotFoundError("get", runtimeKind); | |
| 178 | + } | |
| 179 | + | |
| 180 | + return adapter; | |
| 181 | + }); | |
| 182 | + | |
| 183 | + const execute = ( | |
| 184 | + input: RuntimeExecuteInput & { runtimeKind: RuntimeAdapterKind }, | |
| 185 | + ): Effect.Effect< | |
| 186 | + unknown, | |
| 187 | + RuntimeExecuteError | RuntimeAdapterRegistryError, | |
| 188 | + ToolProviderRegistryService | |
| 189 | + > => | |
| 190 | + Effect.gen(function* () { | |
| 191 | + const adapter = yield* get(input.runtimeKind); | |
| 192 | + return yield* adapter.execute(input); | |
| 193 | + }); | |
| 194 | + | |
| 195 | + for (const adapter of initialAdapters) { | |
| 196 | + if (!adaptersByKind.has(adapter.kind)) { | |
| 197 | + adaptersByKind.set(adapter.kind, adapter); | |
| 198 | + } | |
| 199 | + } | |
| 200 | + | |
| 201 | + return { | |
| 202 | + register, | |
| 203 | + registerAll, | |
| 204 | + get, | |
| 205 | + execute, | |
| 206 | + }; | |
| 207 | +}; | |
| 208 | + | |
| 209 | +export const RuntimeAdapterRegistryLive = ( | |
| 210 | + initialAdapters: ReadonlyArray<RuntimeAdapter> = [], | |
| 211 | +): Layer.Layer<RuntimeAdapterRegistryService> => | |
| 212 | + Layer.succeed(RuntimeAdapterRegistryService, makeRuntimeAdapterRegistry(initialAdapters)); | |
| @@ -1,6 +1,7 @@ | ||
| 1 | −import { describe, expect, test } from "bun:test"; | |
| 1 | +import { describe, expect, it } from "@effect/vitest"; | |
| 2 | 2 | import { type Source, SourceSchema } from "@executor-v2/schema"; |
| 3 | 3 | import * as Effect from "effect/Effect"; |
| 4 | +import * as Either from "effect/Either"; | |
| 4 | 5 | import * as Schema from "effect/Schema"; |
| 5 | 6 | |
| 6 | 7 | import { |
| @@ -29,103 +30,99 @@ const createOpenApiSource = (): Source => · 22 unmodified lines | ||
| 29 | 30 | }); |
| 30 | 31 | |
| 31 | 32 | describe("makeToolProviderRegistry", () => { |
| 32 | − test("routes discovery and invocation through provider kind", async () => { | |
| 33 | − const openApiProvider: ToolProvider = { | |
| 34 | − kind: "openapi", | |
| 35 | − discoverFromSource: (source) => | |
| 36 | − Effect.succeed({ | |
| 37 | − sourceHash: "hash_1", | |
| 38 | − tools: [ | |
| 39 | − { | |
| 40 | − providerKind: "openapi", | |
| 41 | − sourceId: source.id, | |
| 42 | − workspaceId: source.workspaceId, | |
| 43 | − toolId: "getHealth", | |
| 44 | − name: "Get health", | |
| 45 | − description: null, | |
| 46 | − invocationMode: "http", | |
| 47 | − availability: "remote_capable", | |
| 48 | − providerPayload: { | |
| 49 | − method: "get", | |
| 50 | − pathTemplate: "/healthz", | |
| 51 | − parameters: [], | |
| 52 | − requestBody: null, | |
| 33 | + it.effect("routes discovery and invocation through provider kind", () => | |
| 34 | + Effect.gen(function* () { | |
| 35 | + const openApiProvider: ToolProvider = { | |
| 36 | + kind: "openapi", | |
| 37 | + discoverFromSource: (source) => | |
| 38 | + Effect.succeed({ | |
| 39 | + sourceHash: "hash_1", | |
| 40 | + tools: [ | |
| 41 | + { | |
| 42 | + providerKind: "openapi", | |
| 43 | + sourceId: source.id, | |
| 44 | + workspaceId: source.workspaceId, | |
| 45 | + toolId: "getHealth", | |
| 46 | + name: "Get health", | |
| 47 | + description: null, | |
| 48 | + invocationMode: "http", | |
| 49 | + availability: "remote_capable", | |
| 50 | + providerPayload: { | |
| 51 | + method: "get", | |
| 52 | + pathTemplate: "/healthz", | |
| 53 | + parameters: [], | |
| 54 | + requestBody: null, | |
| 55 | + }, | |
| 53 | 56 | }, |
| 54 | − }, | |
| 55 | − ], | |
| 56 | − }), | |
| 57 | − invoke: () => | |
| 58 | − Effect.succeed({ | |
| 59 | − output: { ok: true }, | |
| 60 | − isError: false, | |
| 61 | − }), | |
| 62 | − }; | |
| 63 | − | |
| 64 | − const registry = makeToolProviderRegistry([openApiProvider]); | |
| 65 | − | |
| 66 | − const source = createOpenApiSource(); | |
| 67 | − const discovery = await Effect.runPromise(registry.discoverFromSource(source)); | |
| 68 | − | |
| 69 | − expect(discovery.tools).toHaveLength(1); | |
| 70 | − expect(discovery.tools[0]?.toolId).toBe("getHealth"); | |
| 71 | − | |
| 72 | − const result = await Effect.runPromise( | |
| 73 | − registry.invoke({ | |
| 74 | − source, | |
| 75 | − tool: discovery.tools[0]!, | |
| 76 | − args: { verbose: true }, | |
| 77 | − }), | |
| 78 | − ); | |
| 79 | − | |
| 80 | − expect(result.isError).toBe(false); | |
| 81 | − expect(result.output).toEqual({ ok: true }); | |
| 82 | − }); | |
| 83 | − | |
| 84 | − test("fails on duplicate provider registration", async () => { | |
| 85 | − const openApiProvider: ToolProvider = { | |
| 86 | − kind: "openapi", | |
| 87 | − invoke: () => Effect.succeed({ output: null, isError: false }), | |
| 88 | − }; | |
| 57 | + ], | |
| 58 | + }), | |
| 59 | + invoke: () => | |
| 60 | + Effect.succeed({ | |
| 61 | + output: { ok: true }, | |
| 62 | + isError: false, | |
| 63 | + }), | |
| 64 | + }; | |
| 89 | 65 | |
| 90 | − const registry = makeToolProviderRegistry([openApiProvider]); | |
| 91 | − const result = await Effect.runPromise( | |
| 92 | − Effect.either(registry.register(openApiProvider)), | |
| 93 | − ); | |
| 66 | + const registry = makeToolProviderRegistry([openApiProvider]); | |
| 94 | 67 | |
| 95 | − if (result._tag === "Right") { | |
| 96 | − throw new Error("expected duplicate provider registration to fail"); | |
| 97 | − } | |
| 68 | + const source = createOpenApiSource(); | |
| 69 | + const discovery = yield* registry.discoverFromSource(source); | |
| 98 | 70 | |
| 99 | − expect(result.left).toBeInstanceOf(ToolProviderRegistryError); | |
| 100 | − expect(result.left.message).toContain("Provider already registered"); | |
| 101 | − }); | |
| 71 | + expect(discovery.tools).toHaveLength(1); | |
| 72 | + expect(discovery.tools[0]?.toolId).toBe("getHealth"); | |
| 102 | 73 | |
| 103 | − test("fails when provider cannot discover from source", async () => { | |
| 104 | − const mcpProvider: ToolProvider = { | |
| 105 | − kind: "mcp", | |
| 106 | − invoke: () => | |
| 107 | − Effect.fail( | |
| 74 | + const result = yield* registry.invoke({ | |
| 75 | + source, | |
| 76 | + tool: discovery.tools[0]!, | |
| 77 | + args: { verbose: true }, | |
| 78 | + }); | |
| 79 | + | |
| 80 | + expect(result.isError).toBe(false); | |
| 81 | + expect(result.output).toEqual({ ok: true }); | |
| 82 | + }), | |
| 83 | + ); | |
| 84 | + | |
| 85 | + it.effect("fails on duplicate provider registration", () => | |
| 86 | + Effect.gen(function* () { | |
| 87 | + const openApiProvider: ToolProvider = { | |
| 88 | + kind: "openapi", | |
| 89 | + invoke: () => Effect.succeed({ output: null, isError: false }), | |
| 90 | + }; | |
| 91 | + | |
| 92 | + const registry = makeToolProviderRegistry([openApiProvider]); | |
| 93 | + const result = yield* Effect.either(registry.register(openApiProvider)); | |
| 94 | + | |
| 95 | + expect(Either.isLeft(result)).toBe(true); | |
| 96 | + if (Either.isLeft(result)) { | |
| 97 | + expect(result.left).toBeInstanceOf(ToolProviderRegistryError); | |
| 98 | + expect(result.left.message).toContain("Provider already registered"); | |
| 99 | + } | |
| 100 | + }), | |
| 101 | + ); | |
| 102 | + | |
| 103 | + it.effect("fails when provider cannot discover from source", () => | |
| 104 | + Effect.gen(function* () { | |
| 105 | + const mcpProvider: ToolProvider = { | |
| 106 | + kind: "mcp", | |
| 107 | + invoke: () => | |
| 108 | 108 | new ToolProviderError({ |
| 109 | 109 | operation: "invoke", |
| 110 | 110 | providerKind: "mcp", |
| 111 | 111 | message: "not implemented", |
| 112 | 112 | details: null, |
| 113 | 113 | }), |
| 114 | − ), | |
| 115 | − }; | |
| 116 | − | |
| 117 | − const registry = makeToolProviderRegistry([mcpProvider]); | |
| 118 | − const source = createOpenApiSource(); | |
| 114 | + }; | |
| 119 | 115 | |
| 120 | − const result = await Effect.runPromise( | |
| 121 | − Effect.either(registry.discoverFromSource(source)), | |
| 122 | − ); | |
| 116 | + const registry = makeToolProviderRegistry([mcpProvider]); | |
| 117 | + const source = createOpenApiSource(); | |
| 123 | 118 | |
| 124 | − if (result._tag === "Right") { | |
| 125 | − throw new Error("expected discovery to fail without provider"); | |
| 126 | − } | |
| 119 | + const result = yield* Effect.either(registry.discoverFromSource(source)); | |
| 127 | 120 | |
| 128 | − expect(result.left).toBeInstanceOf(ToolProviderRegistryError); | |
| 129 | − expect(result.left.message).toContain("No provider registered"); | |
| 130 | − }); | |
| 121 | + expect(Either.isLeft(result)).toBe(true); | |
| 122 | + if (Either.isLeft(result)) { | |
| 123 | + expect(result.left).toBeInstanceOf(ToolProviderRegistryError); | |
| 124 | + expect(result.left.message).toContain("No provider registered"); | |
| 125 | + } | |
| 126 | + }), | |
| 127 | + ); | |
| 131 | 128 | }); |
| @@ -5,4 +5,4 @@ Bare-minimum MCP gateway for Executor v2. · 4 unmodified lines | ||
| 5 | 5 | Current scaffold includes: |
| 6 | 6 | - MCP server built with `@modelcontextprotocol/sdk` in `src/server.ts` |
| 7 | 7 | - streamable HTTP request handling (`handleMcpHttpRequest`) |
| 8 | −- one stub tool: `executor.ping` | |
| 8 | +- tools: `executor.ping` and `executor.execute` (the execute tool is host-wired via callback) | |
| @@ -1,5 +1,8 @@ | ||
| 1 | 1 | export { |
| 2 | 2 | handleMcpHttpRequest, |
| 3 | + type ExecuteToolInput, | |
| 4 | + type ExecuteToolResult, | |
| 5 | + type GatewayRuntimeKind, | |
| 3 | 6 | type GatewayTarget, |
| 4 | 7 | type McpGatewayOptions, |
| 5 | 8 | } from "./server"; |
| @@ -4,20 +4,57 @@ import { z } from "zod"; · 3 unmodified lines | ||
| 4 | 4 | |
| 5 | 5 | export type GatewayTarget = "local" | "remote"; |
| 6 | 6 | |
| 7 | +export type GatewayRuntimeKind = | |
| 8 | + | "local-inproc" | |
| 9 | + | "deno-subprocess" | |
| 10 | + | "cloudflare-worker-loader"; | |
| 11 | + | |
| 12 | +export type ExecuteToolInput = { | |
| 13 | + code: string; | |
| 14 | + runtimeKind?: GatewayRuntimeKind; | |
| 15 | +}; | |
| 16 | + | |
| 17 | +export type ExecuteToolResult = { | |
| 18 | + output?: unknown; | |
| 19 | + error?: string; | |
| 20 | + isError: boolean; | |
| 21 | +}; | |
| 22 | + | |
| 7 | 23 | export type McpGatewayOptions = { |
| 8 | 24 | target: GatewayTarget; |
| 9 | 25 | serverName?: string; |
| 10 | 26 | serverVersion?: string; |
| 27 | + execute?: (input: ExecuteToolInput) => Promise<ExecuteToolResult>; | |
| 11 | 28 | }; |
| 12 | 29 | |
| 13 | 30 | const DEFAULT_SERVER_NAME = "executor-v2"; |
| 14 | 31 | const DEFAULT_SERVER_VERSION = "0.0.0"; |
| 15 | 32 | const STUB_TOOL_NAME = "executor.ping"; |
| 33 | +const EXECUTE_TOOL_NAME = "executor.execute"; | |
| 16 | 34 | |
| 17 | 35 | const PingToolInput = z.object({ |
| 18 | 36 | message: z.string().optional(), |
| 19 | 37 | }); |
| 20 | 38 | |
| 39 | +const ExecuteToolInputSchema = z.object({ | |
| 40 | + code: z.string(), | |
| 41 | + runtimeKind: z | |
| 42 | + .enum(["local-inproc", "deno-subprocess", "cloudflare-worker-loader"]) | |
| 43 | + .optional(), | |
| 44 | +}); | |
| 45 | + | |
| 46 | +const contentText = (value: unknown): string => { | |
| 47 | + if (typeof value === "string") { | |
| 48 | + return value; | |
| 49 | + } | |
| 50 | + | |
| 51 | + try { | |
| 52 | + return JSON.stringify(value, null, 2); | |
| 53 | + } catch { | |
| 54 | + return String(value); | |
| 55 | + } | |
| 56 | +}; | |
| 57 | + | |
| 21 | 58 | const createStubMcpServer = (options: McpGatewayOptions): McpServer => { |
| 22 | 59 | const mcp = new McpServer({ |
| 23 | 60 | name: options.serverName ?? DEFAULT_SERVER_NAME, |
| @@ -47,6 +84,52 @@ const createStubMcpServer = (options: McpGatewayOptions): McpServer => { · 23 unmodified lines | ||
| 47 | 84 | }, |
| 48 | 85 | ); |
| 49 | 86 | |
| 87 | + mcp.registerTool( | |
| 88 | + EXECUTE_TOOL_NAME, | |
| 89 | + { | |
| 90 | + description: "Execute JavaScript against configured runtime adapter", | |
| 91 | + inputSchema: ExecuteToolInputSchema, | |
| 92 | + }, | |
| 93 | + async (input: ExecuteToolInput) => { | |
| 94 | + if (!options.execute) { | |
| 95 | + return { | |
| 96 | + content: [ | |
| 97 | + { | |
| 98 | + type: "text" as const, | |
| 99 | + text: "executor.execute is not configured for this gateway target", | |
| 100 | + }, | |
| 101 | + ], | |
| 102 | + isError: true, | |
| 103 | + }; | |
| 104 | + } | |
| 105 | + | |
| 106 | + try { | |
| 107 | + const result = await options.execute(input); | |
| 108 | + return { | |
| 109 | + content: [ | |
| 110 | + { | |
| 111 | + type: "text" as const, | |
| 112 | + text: result.isError | |
| 113 | + ? result.error ?? "Execution failed" | |
| 114 | + : contentText(result.output), | |
| 115 | + }, | |
| 116 | + ], | |
| 117 | + isError: result.isError, | |
| 118 | + }; | |
| 119 | + } catch (cause) { | |
| 120 | + return { | |
| 121 | + content: [ | |
| 122 | + { | |
| 123 | + type: "text" as const, | |
| 124 | + text: cause instanceof Error ? cause.message : String(cause), | |
| 125 | + }, | |
| 126 | + ], | |
| 127 | + isError: true, | |
| 128 | + }; | |
| 129 | + } | |
| 130 | + }, | |
| 131 | + ); | |
| 132 | + | |
| 50 | 133 | return mcp; |
| 51 | 134 | }; |
| 52 | 135 | |
| @@ -0,0 +1 @@ | ||
| 1 | +export {}; | |
| @@ -0,0 +1 @@ | ||
| 1 | +export {}; | |
| @@ -0,0 +1 @@ | ||
| 1 | +export {}; | |
| @@ -0,0 +1 @@ | ||
| 1 | +export {}; | |
| @@ -6,11 +6,16 @@ · 5 unmodified lines | ||
| 6 | 6 | ".": "./src/index.ts" |
| 7 | 7 | }, |
| 8 | 8 | "scripts": { |
| 9 | + "test": "bunx vitest run --no-watch src/*.test.ts", | |
| 9 | 10 | "typecheck": "bunx tsc --noEmit -p tsconfig.json" |
| 10 | 11 | }, |
| 11 | 12 | "dependencies": { |
| 12 | 13 | "@executor-v2/persistence-ports": "workspace:*", |
| 13 | 14 | "@executor-v2/schema": "workspace:*", |
| 14 | 15 | "effect": "^3.19.8" |
| 16 | + }, | |
| 17 | + "devDependencies": { | |
| 18 | + "@effect/vitest": "^0.27.0", | |
| 19 | + "vitest": "^3.2.4" | |
| 15 | 20 | } |
| 16 | 21 | } |
| @@ -1,73 +1,73 @@ | ||
| 1 | −import { describe, expect, test } from "bun:test"; | |
| 1 | +import { describe, expect, it } from "@effect/vitest"; | |
| 2 | 2 | import * as Effect from "effect/Effect"; |
| 3 | 3 | |
| 4 | 4 | import { extractOpenApiManifest } from "./openapi-extraction"; |
| 5 | 5 | |
| 6 | 6 | describe("extractOpenApiManifest invocation metadata", () => { |
| 7 | − test("extracts parameter and request body metadata", async () => { | |
| 8 | − const openApiSpec = { | |
| 9 | − openapi: "3.1.0", | |
| 10 | − paths: { | |
| 11 | − "/users/{userId}": { | |
| 12 | − parameters: [ | |
| 13 | − { name: "userId", in: "path" }, | |
| 14 | − { name: "x-org-id", in: "header", required: true }, | |
| 15 | − ], | |
| 16 | − get: { | |
| 17 | − operationId: "getUser", | |
| 18 | − summary: "Get user", | |
| 7 | + it.effect("extracts parameter and request body metadata", () => | |
| 8 | + Effect.gen(function* () { | |
| 9 | + const openApiSpec = { | |
| 10 | + openapi: "3.1.0", | |
| 11 | + paths: { | |
| 12 | + "/users/{userId}": { | |
| 19 | 13 | parameters: [ |
| 20 | − { name: "include", in: "query" }, | |
| 21 | − { name: "x-org-id", in: "header", required: false }, | |
| 14 | + { name: "userId", in: "path" }, | |
| 15 | + { name: "x-org-id", in: "header", required: true }, | |
| 22 | 16 | ], |
| 23 | − }, | |
| 24 | − post: { | |
| 25 | − summary: "Update user", | |
| 26 | − requestBody: { | |
| 27 | − required: true, | |
| 28 | − content: { | |
| 29 | − "application/merge-patch+json": {}, | |
| 30 | − "application/json": {}, | |
| 17 | + get: { | |
| 18 | + operationId: "getUser", | |
| 19 | + summary: "Get user", | |
| 20 | + parameters: [ | |
| 21 | + { name: "include", in: "query" }, | |
| 22 | + { name: "x-org-id", in: "header", required: false }, | |
| 23 | + ], | |
| 24 | + }, | |
| 25 | + post: { | |
| 26 | + summary: "Update user", | |
| 27 | + requestBody: { | |
| 28 | + required: true, | |
| 29 | + content: { | |
| 30 | + "application/merge-patch+json": {}, | |
| 31 | + "application/json": {}, | |
| 32 | + }, | |
| 31 | 33 | }, |
| 32 | 34 | }, |
| 33 | 35 | }, |
| 34 | 36 | }, |
| 35 | − }, | |
| 36 | − }; | |
| 37 | + }; | |
| 37 | 38 | |
| 38 | − const manifest = await Effect.runPromise( | |
| 39 | − extractOpenApiManifest("test-source", openApiSpec), | |
| 40 | − ); | |
| 39 | + const manifest = yield* extractOpenApiManifest("test-source", openApiSpec); | |
| 41 | 40 | |
| 42 | − const getTool = manifest.tools.find((tool) => tool.toolId === "getUser"); | |
| 43 | − expect(getTool).toBeDefined(); | |
| 44 | − if (!getTool) { | |
| 45 | − throw new Error("expected getUser tool"); | |
| 46 | − } | |
| 41 | + const getTool = manifest.tools.find((tool) => tool.toolId === "getUser"); | |
| 42 | + expect(getTool).toBeDefined(); | |
| 43 | + if (!getTool) { | |
| 44 | + throw new Error("expected getUser tool"); | |
| 45 | + } | |
| 47 | 46 | |
| 48 | − expect(getTool.invocation.method).toBe("get"); | |
| 49 | − expect(getTool.invocation.pathTemplate).toBe("/users/{userId}"); | |
| 50 | − expect(getTool.invocation.requestBody).toBeNull(); | |
| 47 | + expect(getTool.invocation.method).toBe("get"); | |
| 48 | + expect(getTool.invocation.pathTemplate).toBe("/users/{userId}"); | |
| 49 | + expect(getTool.invocation.requestBody).toBeNull(); | |
| 51 | 50 | |
| 52 | − expect(getTool.invocation.parameters).toEqual( | |
| 53 | − expect.arrayContaining([ | |
| 54 | − { name: "userId", location: "path", required: true }, | |
| 55 | − { name: "x-org-id", location: "header", required: false }, | |
| 56 | − { name: "include", location: "query", required: false }, | |
| 57 | − ]), | |
| 58 | − ); | |
| 51 | + expect(getTool.invocation.parameters).toEqual( | |
| 52 | + expect.arrayContaining([ | |
| 53 | + { name: "userId", location: "path", required: true }, | |
| 54 | + { name: "x-org-id", location: "header", required: false }, | |
| 55 | + { name: "include", location: "query", required: false }, | |
| 56 | + ]), | |
| 57 | + ); | |
| 59 | 58 | |
| 60 | − const postTool = manifest.tools.find( | |
| 61 | − (tool) => tool.toolId === "post_users_userid", | |
| 62 | − ); | |
| 63 | − expect(postTool).toBeDefined(); | |
| 64 | − if (!postTool) { | |
| 65 | − throw new Error("expected post tool"); | |
| 66 | − } | |
| 59 | + const postTool = manifest.tools.find( | |
| 60 | + (tool) => tool.toolId === "post_users_userid", | |
| 61 | + ); | |
| 62 | + expect(postTool).toBeDefined(); | |
| 63 | + if (!postTool) { | |
| 64 | + throw new Error("expected post tool"); | |
| 65 | + } | |
| 67 | 66 | |
| 68 | − expect(postTool.invocation.requestBody).toEqual({ | |
| 69 | − required: true, | |
| 70 | − contentTypes: ["application/json", "application/merge-patch+json"], | |
| 71 | − }); | |
| 72 | − }); | |
| 67 | + expect(postTool.invocation.requestBody).toEqual({ | |
| 68 | + required: true, | |
| 69 | + contentTypes: ["application/json", "application/merge-patch+json"], | |
| 70 | + }); | |
| 71 | + }), | |
| 72 | + ); | |
| 73 | 73 | }); |
| @@ -45,17 +45,17 @@ export class OpenApiExtractionError extends Data.TaggedError("OpenApiExtractionE · 44 unmodified lines | ||
| 45 | 45 | details: string | null; |
| 46 | 46 | }> {} |
| 47 | 47 | |
| 48 | −export const ExtractedToolParameterSchema = OpenApiToolParameterSchema; | |
| 49 | −export const ExtractedToolRequestBodySchema = OpenApiToolRequestBodySchema; | |
| 50 | −export const ExtractedToolInvocationSchema = OpenApiInvocationPayloadSchema; | |
| 51 | −export const ExtractedToolSchema = OpenApiExtractedToolSchema; | |
| 52 | −export const ToolManifestSchema = OpenApiToolManifestSchema; | |
| 53 | − | |
| 54 | −export type ExtractedToolParameter = OpenApiToolParameter; | |
| 55 | −export type ExtractedToolRequestBody = OpenApiToolRequestBody; | |
| 56 | −export type ExtractedToolInvocation = OpenApiInvocationPayload; | |
| 57 | −export type ExtractedTool = OpenApiExtractedTool; | |
| 58 | −export type ToolManifest = OpenApiToolManifest; | |
| 48 | +const ExtractedToolParameterSchema = OpenApiToolParameterSchema; | |
| 49 | +const ExtractedToolRequestBodySchema = OpenApiToolRequestBodySchema; | |
| 50 | +const ExtractedToolInvocationSchema = OpenApiInvocationPayloadSchema; | |
| 51 | +const ExtractedToolSchema = OpenApiExtractedToolSchema; | |
| 52 | +const ToolManifestSchema = OpenApiToolManifestSchema; | |
| 53 | + | |
| 54 | +type ExtractedToolParameter = OpenApiToolParameter; | |
| 55 | +type ExtractedToolRequestBody = OpenApiToolRequestBody; | |
| 56 | +type ExtractedToolInvocation = OpenApiInvocationPayload; | |
| 57 | +type ExtractedTool = OpenApiExtractedTool; | |
| 58 | +type ToolManifest = OpenApiToolManifest; | |
| 59 | 59 | |
| 60 | 60 | const ToolManifestFromJsonSchema = Schema.parseJson(ToolManifestSchema); |
| 61 | 61 | const encodeManifestToJson = Schema.encode(ToolManifestFromJsonSchema); |
| @@ -75,7 +75,7 @@ export type RefreshOpenApiArtifactResult = { · 13 unmodified lines | ||
| 75 | 75 | reused: boolean; |
| 76 | 76 | }; |
| 77 | 77 | |
| 78 | −export type RefreshOpenApiArtifactInput = { | |
| 78 | +type RefreshOpenApiArtifactInput = { | |
| 79 | 79 | source: Source; |
| 80 | 80 | openApiSpec: unknown; |
| 81 | 81 | artifactStore: ToolArtifactStore; |
| @@ -0,0 +1 @@ | ||
| 1 | +export {}; | |