Unified · Split
| @@ -652,18 +652,16 @@ const stopServer = (baseUrl: string) => · 651 unmodified lines | ||
| 652 | 652 | const pid = typeof pidRecord?.pid === "number" ? pidRecord.pid : null; |
| 653 | 653 | |
| 654 | 654 | if (pid === null) { |
| 655 | − yield* Effect.tryPromise({ | |
| 656 | − try: () => rm(DEFAULT_SERVER_PID_FILE, { force: true }), | |
| 657 | − catch: () => undefined, | |
| 658 | − }); | |
| 655 | + yield* Effect.tryPromise(() => rm(DEFAULT_SERVER_PID_FILE, { force: true })).pipe( | |
| 656 | + Effect.ignore, | |
| 657 | + ); | |
| 659 | 658 | return false; |
| 660 | 659 | } |
| 661 | 660 | |
| 662 | 661 | if (!isPidRunning(pid)) { |
| 663 | − yield* Effect.tryPromise({ | |
| 664 | − try: () => rm(DEFAULT_SERVER_PID_FILE, { force: true }), | |
| 665 | − catch: () => undefined, | |
| 666 | − }); | |
| 662 | + yield* Effect.tryPromise(() => rm(DEFAULT_SERVER_PID_FILE, { force: true })).pipe( | |
| 663 | + Effect.ignore, | |
| 664 | + ); | |
| 667 | 665 | return false; |
| 668 | 666 | } |
| 669 | 667 | |
| @@ -674,10 +672,10 @@ const stopServer = (baseUrl: string) => · 4 unmodified lines | ||
| 674 | 672 | |
| 675 | 673 | yield* waitForReachability(baseUrl, false).pipe( |
| 676 | 674 | Effect.catchAll(() => |
| 677 | − Effect.tryPromise({ | |
| 678 | − try: () => rm(DEFAULT_SERVER_PID_FILE, { force: true }), | |
| 679 | − catch: () => undefined, | |
| 680 | − }).pipe(Effect.zipRight(Effect.fail(new Error(`Timed out stopping local executor server pid ${pid}`)))), | |
| 675 | + Effect.tryPromise(() => rm(DEFAULT_SERVER_PID_FILE, { force: true })).pipe( | |
| 676 | + Effect.ignore, | |
| 677 | + Effect.zipRight(Effect.fail(new Error(`Timed out stopping local executor server pid ${pid}`))), | |
| 678 | + ), | |
| 681 | 679 | ), |
| 682 | 680 | ); |
| 683 | 681 | |
| @@ -176,7 +176,8 @@ export const LocalDistributionHarnessLive = Layer.scoped( · 175 unmodified lines | ||
| 176 | 176 | }), |
| 177 | 177 | (path) => Effect.tryPromise({ |
| 178 | 178 | try: () => rm(path, { recursive: true, force: true }), |
| 179 | − catch: () => undefined, | |
| 179 | + catch: (cause) => | |
| 180 | + cause instanceof Error ? cause : new Error(String(cause ?? "temp dir cleanup failed")), | |
| 180 | 181 | }).pipe(Effect.orDie), |
| 181 | 182 | ); |
| 182 | 183 | |
| @@ -647,12 +647,13 @@ describe("local-executor-server", () => { · 646 unmodified lines | ||
| 647 | 647 | }); |
| 648 | 648 | |
| 649 | 649 | const listed = yield* Effect.promise( |
| 650 | − () => mcp.client.listTools() as Promise<{ tools: Array<{ name: string }> }>, | |
| 650 | + () => mcp.client.listTools() as Promise<{ tools: Array<{ name: string; description?: string }> }>, | |
| 651 | 651 | ); |
| 652 | 652 | expect(listed.tools.map((tool) => tool.name)).toEqual(["execute"]); |
| 653 | − expect(mcp.client.getInstructions()).toContain( | |
| 654 | − `npx add-mcp \"${server.baseUrl}/mcp\" --transport http --name \"executor\"`, | |
| 655 | − ); | |
| 653 | + expect(listed.tools[0]?.description).toContain("Workflow:"); | |
| 654 | + expect(listed.tools[0]?.description).toContain("tools.discover"); | |
| 655 | + expect(listed.tools[0]?.description).toContain("tools.executor.sources.add"); | |
| 656 | + | |
| 656 | 657 | |
| 657 | 658 | const executed = yield* Effect.promise( |
| 658 | 659 | () => |
| @@ -686,12 +687,12 @@ describe("local-executor-server", () => { · 27 unmodified lines | ||
| 686 | 687 | }); |
| 687 | 688 | |
| 688 | 689 | const listed = yield* Effect.promise( |
| 689 | − () => mcp.client.listTools() as Promise<{ tools: Array<{ name: string }> }>, | |
| 690 | + () => mcp.client.listTools() as Promise<{ tools: Array<{ name: string; description?: string }> }>, | |
| 690 | 691 | ); |
| 691 | 692 | expect(listed.tools.map((tool) => tool.name).sort()).toEqual(["execute", "resume"]); |
| 692 | − expect(mcp.client.getInstructions()).toContain( | |
| 693 | − `npx add-mcp \"${server.baseUrl}/mcp\" --transport http --name \"executor\"`, | |
| 694 | − ); | |
| 693 | + expect(listed.tools.find((tool) => tool.name === "execute")?.description).toContain("Workflow:"); | |
| 694 | + expect(listed.tools.find((tool) => tool.name === "execute")?.description).toContain("tools.discover"); | |
| 695 | + | |
| 695 | 696 | |
| 696 | 697 | const executed = yield* Effect.promise( |
| 697 | 698 | () => |
| @@ -0,0 +1,37 @@ | ||
| 1 | +import { useEffect, useMemo, useState } from "react"; | |
| 2 | +import { CodeBlock } from "./code-block"; | |
| 3 | + | |
| 4 | +export function LocalMcpInstallCard(props: { | |
| 5 | + title?: string; | |
| 6 | + description?: string; | |
| 7 | + className?: string; | |
| 8 | +}) { | |
| 9 | + const [origin, setOrigin] = useState<string | null>(null); | |
| 10 | + | |
| 11 | + useEffect(() => { | |
| 12 | + setOrigin(window.location.origin); | |
| 13 | + }, []); | |
| 14 | + | |
| 15 | + const command = useMemo( | |
| 16 | + () => | |
| 17 | + origin | |
| 18 | + ? `npx add-mcp "${origin}/mcp" --transport http --name "executor"` | |
| 19 | + : 'npx add-mcp "<this-server>/mcp" --transport http --name "executor"', | |
| 20 | + [origin], | |
| 21 | + ); | |
| 22 | + | |
| 23 | + return ( | |
| 24 | + <section className={props.className ?? "rounded-2xl border border-border bg-card/80 p-5"}> | |
| 25 | + <div className="mb-3 space-y-1"> | |
| 26 | + <h2 className="text-sm font-semibold text-foreground"> | |
| 27 | + {props.title ?? "Install local MCP"} | |
| 28 | + </h2> | |
| 29 | + <p className="text-[13px] text-muted-foreground"> | |
| 30 | + {props.description | |
| 31 | + ?? "Add this local executor server to an MCP client with one command. The URL uses the same origin as this web app."} | |
| 32 | + </p> | |
| 33 | + </div> | |
| 34 | + <CodeBlock code={command} lang="bash" className="rounded-xl border border-border bg-background/70" /> | |
| 35 | + </section> | |
| 36 | + ); | |
| 37 | +} | |
| @@ -5,7 +5,10 @@ · 4 unmodified lines | ||
| 5 | 5 | * control-plane handler. Everything else (frontend assets, HMR) is |
| 6 | 6 | * handled by Vite itself. |
| 7 | 7 | */ |
| 8 | +import * as Cause from "effect/Cause"; | |
| 8 | 9 | import * as Effect from "effect/Effect"; |
| 10 | +import * as Exit from "effect/Exit"; | |
| 11 | +import * as Runtime from "effect/Runtime"; | |
| 9 | 12 | import * as Scope from "effect/Scope"; |
| 10 | 13 | import { createLocalExecutorRequestHandler } from "@executor-v3/server"; |
| 11 | 14 | |
| @@ -16,28 +19,53 @@ const truncateForLog = (value: string): string => · 4 unmodified lines | ||
| 16 | 19 | ? `${value.slice(0, MAX_LOGGED_ERROR_BODY_LENGTH)}... [truncated]` |
| 17 | 20 | : value; |
| 18 | 21 | |
| 19 | −const formatErrorForLog = (error: unknown) => | |
| 20 | − error instanceof Error | |
| 21 | − ? { | |
| 22 | − name: error.name, | |
| 23 | − message: error.message, | |
| 24 | − stack: error.stack, | |
| 25 | − } | |
| 26 | − : error; | |
| 22 | +/** | |
| 23 | + * Extract a detailed, human-readable description from an error. | |
| 24 | + * | |
| 25 | + * When Effect rejects via `runPromise`, the thrown value is a | |
| 26 | + * `FiberFailure` wrapping a `Cause`. `Cause.pretty` renders the full | |
| 27 | + * failure tree (defects, interrupts, tagged errors, etc.) rather than the | |
| 28 | + * opaque fallback message ("An error has occurred"). | |
| 29 | + */ | |
| 30 | +const formatErrorForLog = (error: unknown): string => { | |
| 31 | + if (Runtime.isFiberFailure(error)) { | |
| 32 | + const cause = error[Runtime.FiberFailureCauseId]; | |
| 33 | + return Cause.pretty(cause); | |
| 34 | + } | |
| 35 | + | |
| 36 | + if (error instanceof Error) { | |
| 37 | + return error.stack ?? error.message; | |
| 38 | + } | |
| 39 | + | |
| 40 | + return String(error); | |
| 41 | +}; | |
| 27 | 42 | |
| 28 | 43 | // Create a long-lived scope that stays open for the lifetime of the process. |
| 29 | −const handlerPromise = Effect.runPromise( | |
| 30 | − Effect.gen(function* () { | |
| 31 | − const scope = yield* Scope.make(); | |
| 32 | − const handler = yield* createLocalExecutorRequestHandler().pipe( | |
| 33 | − Effect.provideService(Scope.Scope, scope), | |
| 34 | − ); | |
| 35 | − return handler; | |
| 36 | − }), | |
| 37 | −).catch((error) => { | |
| 38 | − console.error("[executor dev api] failed to initialize request handler", formatErrorForLog(error)); | |
| 39 | − throw error; | |
| 40 | −}); | |
| 44 | +const handlerPromise = (async () => { | |
| 45 | + const exit = await Effect.runPromiseExit( | |
| 46 | + Effect.gen(function* () { | |
| 47 | + const scope = yield* Scope.make(); | |
| 48 | + const handler = yield* createLocalExecutorRequestHandler().pipe( | |
| 49 | + Effect.provideService(Scope.Scope, scope), | |
| 50 | + ); | |
| 51 | + return handler; | |
| 52 | + }), | |
| 53 | + ); | |
| 54 | + | |
| 55 | + if (Exit.isSuccess(exit)) { | |
| 56 | + return exit.value; | |
| 57 | + } | |
| 58 | + | |
| 59 | + // Log the full Cause tree — this captures typed errors, defects (uncaught | |
| 60 | + // throws), and interrupts with their original stack traces. | |
| 61 | + console.error( | |
| 62 | + "[executor dev api] failed to initialize request handler\n\n" + | |
| 63 | + Cause.pretty(exit.cause), | |
| 64 | + ); | |
| 65 | + | |
| 66 | + // Re-throw so every subsequent request sees the init failure. | |
| 67 | + throw Cause.squash(exit.cause); | |
| 68 | +})(); | |
| 41 | 69 | |
| 42 | 70 | export default { |
| 43 | 71 | async fetch(request: Request) { |
| @@ -18,6 +18,7 @@ import { · 17 unmodified lines | ||
| 18 | 18 | } from "@executor-v3/react"; |
| 19 | 19 | import { Badge } from "../components/ui/badge"; |
| 20 | 20 | import { Button } from "../components/ui/button"; |
| 21 | +import { LocalMcpInstallCard } from "../components/local-mcp-install-card"; | |
| 21 | 22 | import { SourceFavicon } from "../components/source-favicon"; |
| 22 | 23 | import { |
| 23 | 24 | IconArrowLeft, |
| @@ -737,6 +738,14 @@ export function AddSourcePage() { · 713 unmodified lines | ||
| 737 | 738 | |
| 738 | 739 | {statusBanner && <StatusBanner state={statusBanner} className="mb-6" />} |
| 739 | 740 | |
| 741 | + | |
| 742 | + {(phase === "idle" || phase === "discovering") && ( | |
| 743 | + <LocalMcpInstallCard | |
| 744 | + className="mb-6 rounded-xl border border-border bg-card/80 p-5" | |
| 745 | + title="Install this executor as MCP" | |
| 746 | + description="Prefer a one-command setup? Install this local executor server into your MCP client, or add an external MCP source below." | |
| 747 | + /> | |
| 748 | + )} | |
| 740 | 749 | {/* Step 1: Discovery */} |
| 741 | 750 | {(phase === "idle" || phase === "discovering") && ( |
| 742 | 751 | <div className="space-y-6"> |
| @@ -1,6 +1,7 @@ | ||
| 1 | 1 | import { Link } from "@tanstack/react-router"; |
| 2 | 2 | import { useSources, type Source } from "@executor-v3/react"; |
| 3 | 3 | import { LoadableBlock } from "../components/loadable"; |
| 4 | +import { LocalMcpInstallCard } from "../components/local-mcp-install-card"; | |
| 4 | 5 | import { SourceFavicon } from "../components/source-favicon"; |
| 5 | 6 | import { Badge } from "../components/ui/badge"; |
| 6 | 7 | import { Button } from "../components/ui/button"; |
| @@ -30,6 +31,8 @@ export function HomePage() { · 23 unmodified lines | ||
| 30 | 31 | </Link> |
| 31 | 32 | </div> |
| 32 | 33 | |
| 34 | + <LocalMcpInstallCard className="mb-8" /> | |
| 35 | + | |
| 33 | 36 | {/* Source list */} |
| 34 | 37 | <LoadableBlock loadable={sources} loading="Loading sources..."> |
| 35 | 38 | {(items) => |
| @@ -11,8 +11,8 @@ export default defineConfig({ · 10 unmodified lines | ||
| 11 | 11 | devServer({ |
| 12 | 12 | entry: "src/dev.ts", |
| 13 | 13 | exclude: [ |
| 14 | − // Only let /v1 requests reach the API handler | |
| 15 | − /^\/(?!v1(\/|$))/, | |
| 14 | + // Only let /v1 and /mcp requests reach the API handler | |
| 15 | + /^\/(?!(v1|mcp)(\/|$))/, | |
| 16 | 16 | /^\/(src|node_modules|@vite|@id|@react-refresh)/, |
| 17 | 17 | /\.(css|ts|tsx|js|jsx|svg|png|jpg|gif|ico|woff2?|json|map)(\?.*)?$/, |
| 18 | 18 | ], |
| @@ -2,6 +2,8 @@ import { ElicitRequestSchema } from "@modelcontextprotocol/sdk/types.js"; · 1 unmodified line | ||
| 2 | 2 | import * as Data from "effect/Data"; |
| 3 | 3 | import * as Either from "effect/Either"; |
| 4 | 4 | import * as Effect from "effect/Effect"; |
| 5 | +import * as Cause from "effect/Cause"; | |
| 6 | +import * as Exit from "effect/Exit"; | |
| 5 | 7 | import * as PartitionedSemaphore from "effect/PartitionedSemaphore"; |
| 6 | 8 | |
| 7 | 9 | import { |
| @@ -88,10 +90,11 @@ const inputSchemaFromManifest = (inputSchemaJson: string | undefined) => { · 80 unmodified lines | ||
| 88 | 90 | const closeConnection = (connection: McpConnection): Effect.Effect<void> => |
| 89 | 91 | Effect.tryPromise({ |
| 90 | 92 | try: () => connection.close?.() ?? Promise.resolve(), |
| 91 | − catch: () => undefined, | |
| 93 | + catch: (cause) => | |
| 94 | + cause instanceof Error ? cause : new Error(String(cause ?? "mcp connection close failed")), | |
| 92 | 95 | }).pipe( |
| 93 | 96 | Effect.asVoid, |
| 94 | − Effect.catchAll(() => Effect.succeed(undefined)), | |
| 97 | + Effect.catchAll(() => Effect.void), | |
| 95 | 98 | ); |
| 96 | 99 | |
| 97 | 100 | const withConnectionEffect = <A, E>(input: { |
| @@ -192,7 +195,13 @@ const installMcpElicitationHandler = (input: { · 94 unmodified lines | ||
| 192 | 195 | }), |
| 193 | 196 | ), |
| 194 | 197 | Effect.map(toMcpElicitationResponse), |
| 195 | − Effect.catchAll(() => Effect.succeed({ action: "cancel" as const })), | |
| 198 | + Effect.catchAll((error) => { | |
| 199 | + console.error( | |
| 200 | + `[mcp-tools] elicitation failed for ${input.toolName}, treating as cancel:`, | |
| 201 | + error instanceof Error ? error.message : String(error), | |
| 202 | + ); | |
| 203 | + return Effect.succeed({ action: "cancel" as const }); | |
| 204 | + }), | |
| 196 | 205 | ), |
| 197 | 206 | ); |
| 198 | 207 | }); |
| @@ -427,8 +436,8 @@ export const createMcpToolsFromManifest = (input: { · 228 unmodified lines | ||
| 427 | 436 | tool: { |
| 428 | 437 | description: entry.description ?? `MCP tool: ${entry.toolName}`, |
| 429 | 438 | inputSchema: inputSchemaFromManifest(entry.inputSchemaJson), |
| 430 | − execute: (args: unknown, executionContext?: ToolExecutionContext) => | |
| 431 | − Effect.runPromise( | |
| 439 | + execute: async (args: unknown, executionContext?: ToolExecutionContext) => { | |
| 440 | + const exit = await Effect.runPromiseExit( | |
| 432 | 441 | withConnectionEffect({ |
| 433 | 442 | connect: input.connect, |
| 434 | 443 | onConnectError: (cause) => |
| @@ -453,7 +462,10 @@ export const createMcpToolsFromManifest = (input: { · 18 unmodified lines | ||
| 453 | 462 | : callEffect; |
| 454 | 463 | }, |
| 455 | 464 | }), |
| 456 | − ), | |
| 465 | + ); | |
| 466 | + if (Exit.isSuccess(exit)) return exit.value; | |
| 467 | + throw Cause.squash(exit.cause); | |
| 468 | + }, | |
| 457 | 469 | }, |
| 458 | 470 | metadata: { |
| 459 | 471 | sourceKey, |
| @@ -113,7 +113,8 @@ const runMigrationsEffect = ( · 112 unmodified lines | ||
| 113 | 113 | const closeRuntimeEffect = (runtime: SqlRuntime) => |
| 114 | 114 | Effect.tryPromise({ |
| 115 | 115 | try: () => runtime.close(), |
| 116 | − catch: () => undefined, | |
| 116 | + catch: (cause) => | |
| 117 | + cause instanceof Error ? cause : new Error(String(cause ?? "unknown close error")), | |
| 117 | 118 | }).pipe(Effect.orDie); |
| 118 | 119 | |
| 119 | 120 | export const createSqlControlPlanePersistence = ( |
| @@ -150,7 +151,8 @@ export const SqlControlPlanePersistenceLive = ( · 30 unmodified lines | ||
| 150 | 151 | (persistence) => |
| 151 | 152 | Effect.tryPromise({ |
| 152 | 153 | try: () => persistence.close(), |
| 153 | − catch: () => undefined, | |
| 154 | + catch: (cause) => | |
| 155 | + cause instanceof Error ? cause : new Error(String(cause ?? "unknown close error")), | |
| 154 | 156 | }).pipe(Effect.orDie), |
| 155 | 157 | ), |
| 156 | 158 | ); |
| @@ -3,8 +3,8 @@ import { drizzle as drizzlePGlite } from "drizzle-orm/pglite"; · 2 unmodified lines | ||
| 3 | 3 | import { migrate as migratePGlite } from "drizzle-orm/pglite/migrator"; |
| 4 | 4 | import { drizzle as drizzlePostgres } from "drizzle-orm/postgres-js"; |
| 5 | 5 | import { migrate as migratePostgres } from "drizzle-orm/postgres-js/migrator"; |
| 6 | −import { existsSync, readdirSync } from "node:fs"; | |
| 7 | −import { mkdir } from "node:fs/promises"; | |
| 6 | +import { existsSync, readdirSync, readFileSync } from "node:fs"; | |
| 7 | +import { mkdir, unlink } from "node:fs/promises"; | |
| 8 | 8 | import * as path from "node:path"; |
| 9 | 9 | import { fileURLToPath } from "node:url"; |
| 10 | 10 | import postgres from "postgres"; |
| @@ -44,6 +44,46 @@ const trim = (value: string | undefined): string | undefined => { · 33 unmodified lines | ||
| 44 | 44 | const isPostgresUrl = (value: string): boolean => |
| 45 | 45 | value.startsWith("postgres://") || value.startsWith("postgresql://"); |
| 46 | 46 | |
| 47 | +/** | |
| 48 | + * Remove a stale PGlite `postmaster.pid` lock file if present. | |
| 49 | + * | |
| 50 | + * PGlite writes this file on open and removes it on close. When a process | |
| 51 | + * exits without calling `PGlite.close()` (e.g. the Vite dev server is | |
| 52 | + * killed with SIGKILL, or the terminal is closed), the lock file is left | |
| 53 | + * behind and prevents subsequent PGlite instances from opening the | |
| 54 | + * database. PGlite uses a synthetic PID (e.g. `-42`) that is never a real | |
| 55 | + * OS process, so we cannot use it to detect a live owner. Instead we rely | |
| 56 | + * on the fact that in the local single-user context only one process | |
| 57 | + * should ever own this database at a time. | |
| 58 | + */ | |
| 59 | +const cleanupStalePGliteLock = async (dataDir: string): Promise<void> => { | |
| 60 | + const lockPath = path.join(dataDir, "postmaster.pid"); | |
| 61 | + if (!existsSync(lockPath)) { | |
| 62 | + return; | |
| 63 | + } | |
| 64 | + | |
| 65 | + // PGlite's synthetic PID is always negative (e.g. -42). A real Postgres | |
| 66 | + // postmaster would write a positive PID. If we ever see a positive PID | |
| 67 | + // we leave the file alone. | |
| 68 | + try { | |
| 69 | + const content = readFileSync(lockPath, "utf-8"); | |
| 70 | + const firstLine = content.split("\n")[0]?.trim(); | |
| 71 | + const pid = Number(firstLine); | |
| 72 | + if (!Number.isNaN(pid) && pid > 0) { | |
| 73 | + // Looks like a real Postgres PID — don't touch it. | |
| 74 | + return; | |
| 75 | + } | |
| 76 | + } catch { | |
| 77 | + // If we can't read the file, try to remove it anyway. | |
| 78 | + } | |
| 79 | + | |
| 80 | + try { | |
| 81 | + await unlink(lockPath); | |
| 82 | + } catch { | |
| 83 | + // Best-effort cleanup; PGlite will report the real error if this fails. | |
| 84 | + } | |
| 85 | +}; | |
| 86 | + | |
| 47 | 87 | const createPGliteRuntime = async (localDataDir: string): Promise<SqlRuntime> => { |
| 48 | 88 | const normalized = trim(localDataDir) ?? ".executor-v3/control-plane-pgdata"; |
| 49 | 89 | |
| @@ -55,6 +95,7 @@ const createPGliteRuntime = async (localDataDir: string): Promise<SqlRuntime> => · 5 unmodified lines | ||
| 55 | 95 | if (!existsSync(resolvedDataDir)) { |
| 56 | 96 | await mkdir(resolvedDataDir, { recursive: true }); |
| 57 | 97 | } |
| 98 | + await cleanupStalePGliteLock(resolvedDataDir); | |
| 58 | 99 | client = new PGlite(resolvedDataDir); |
| 59 | 100 | } |
| 60 | 101 | |
| @@ -15,8 +15,17 @@ import { · 14 unmodified lines | ||
| 15 | 15 | type WorkspaceId, |
| 16 | 16 | } from "#schema"; |
| 17 | 17 | import * as Effect from "effect/Effect"; |
| 18 | +import * as Cause from "effect/Cause"; | |
| 19 | +import * as Exit from "effect/Exit"; | |
| 18 | 20 | import * as Schema from "effect/Schema"; |
| 19 | 21 | |
| 22 | +/** Run an Effect as a Promise, preserving the original error (not FiberFailure). */ | |
| 23 | +const runEffect = async <A>(effect: Effect.Effect<A, unknown, never>): Promise<A> => { | |
| 24 | + const exit = await Effect.runPromiseExit(effect); | |
| 25 | + if (Exit.isSuccess(exit)) return exit.value; | |
| 26 | + throw Cause.squash(exit.cause); | |
| 27 | +}; | |
| 28 | + | |
| 20 | 29 | import { |
| 21 | 30 | type ExecutorAddSourceInput, |
| 22 | 31 | type ExecutorHttpSourceAuthInput, |
| @@ -277,7 +286,7 @@ export const createExecutorToolMap = (input: { · 254 unmodified lines | ||
| 277 | 286 | executionId, |
| 278 | 287 | interactionId, |
| 279 | 288 | }; |
| 280 | − const result = await Effect.runPromise( | |
| 289 | + const result = await runEffect( | |
| 281 | 290 | input.sourceAuthService.addExecutorSource( |
| 282 | 291 | preparedArgs, |
| 283 | 292 | context?.onElicitation |
| @@ -304,7 +313,7 @@ export const createExecutorToolMap = (input: { · 20 unmodified lines | ||
| 304 | 313 | ExecutorAddSourceInput, |
| 305 | 314 | { kind: "openapi" | "graphql" } |
| 306 | 315 | >; |
| 307 | − const selectedAuth = await Effect.runPromise( | |
| 316 | + const selectedAuth = await runEffect( | |
| 308 | 317 | promptForSourceCredentialSelection({ |
| 309 | 318 | args: { |
| 310 | 319 | ...preparedHttpArgs, |
| @@ -323,7 +332,7 @@ export const createExecutorToolMap = (input: { · 12 unmodified lines | ||
| 323 | 332 | }), |
| 324 | 333 | ); |
| 325 | 334 | |
| 326 | − const completed = await Effect.runPromise( | |
| 335 | + const completed = await runEffect( | |
| 327 | 336 | input.sourceAuthService.addExecutorSource( |
| 328 | 337 | { |
| 329 | 338 | ...preparedHttpArgs, |
| @@ -355,7 +364,7 @@ export const createExecutorToolMap = (input: { · 25 unmodified lines | ||
| 355 | 364 | throw new Error("executor.sources.add requires an elicitation-capable host"); |
| 356 | 365 | } |
| 357 | 366 | |
| 358 | − const response: ElicitationResponse = await Effect.runPromise( | |
| 367 | + const response: ElicitationResponse = await runEffect( | |
| 359 | 368 | context.onElicitation({ |
| 360 | 369 | interactionId, |
| 361 | 370 | path: context.path ?? asToolPath("executor.sources.add"), |
| @@ -376,7 +385,7 @@ export const createExecutorToolMap = (input: { · 14 unmodified lines | ||
| 376 | 385 | throw new Error(`Source add was not completed for ${result.source.id}`); |
| 377 | 386 | } |
| 378 | 387 | |
| 379 | − return await Effect.runPromise( | |
| 388 | + return await runEffect( | |
| 380 | 389 | input.sourceAuthService.getSourceById({ |
| 381 | 390 | workspaceId: input.workspaceId, |
| 382 | 391 | sourceId: result.source.id, |
| @@ -192,6 +192,6 @@ export const createSqlControlPlaneRuntime = ( · 191 unmodified lines | ||
| 192 | 192 | persistence, |
| 193 | 193 | localInstallation, |
| 194 | 194 | runtimeLayer: concreteRuntimeLayer, |
| 195 | − close: () => Effect.runPromise(Scope.close(scope, Exit.void)), | |
| 195 | + close: () => Effect.runPromise(Scope.close(scope, Exit.void)).catch(() => {}), | |
| 196 | 196 | }; |
| 197 | 197 | }); |
| @@ -4,11 +4,16 @@ import type { · 3 unmodified lines | ||
| 4 | 4 | SqlControlPlaneRuntime, |
| 5 | 5 | } from "@executor-v3/control-plane"; |
| 6 | 6 | import { |
| 7 | + EXECUTOR_SOURCES_ADD_HELP_LINES, | |
| 8 | + ExecutionIdSchema, | |
| 9 | + RuntimeExecutionResolverService, | |
| 7 | 10 | createExecution, |
| 8 | 11 | getExecution, |
| 9 | 12 | resumeExecution, |
| 10 | 13 | } from "@executor-v3/control-plane"; |
| 11 | 14 | import * as Effect from "effect/Effect"; |
| 15 | +import * as Cause from "effect/Cause"; | |
| 16 | +import * as Exit from "effect/Exit"; | |
| 12 | 17 | import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; |
| 13 | 18 | import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js"; |
| 14 | 19 | import { z } from "zod/v4"; |
| @@ -110,14 +115,27 @@ const parseInteractionPayload = ( · 95 unmodified lines | ||
| 110 | 115 | } |
| 111 | 116 | }; |
| 112 | 117 | |
| 113 | −const runControlPlane = <A, E, R>( | |
| 118 | +const runControlPlane = async <A, E, R>( | |
| 114 | 119 | runtime: SqlControlPlaneRuntime, |
| 115 | 120 | effect: Effect.Effect<A, E, R>, |
| 116 | −): Promise<A> => | |
| 117 | − Effect.runPromise( | |
| 121 | +): Promise<A> => { | |
| 122 | + const exit = await Effect.runPromiseExit( | |
| 118 | 123 | effect.pipe(Effect.provide(runtime.runtimeLayer)) as Effect.Effect<A, E, never>, |
| 119 | 124 | ); |
| 120 | 125 | |
| 126 | + if (Exit.isSuccess(exit)) { | |
| 127 | + return exit.value; | |
| 128 | + } | |
| 129 | + | |
| 130 | + const error = Cause.squash(exit.cause); | |
| 131 | + if (error instanceof Error) { | |
| 132 | + // Preserve the original error with its stack trace and message rather | |
| 133 | + // than wrapping it in an opaque FiberFailure. | |
| 134 | + throw error; | |
| 135 | + } | |
| 136 | + throw error; | |
| 137 | +}; | |
| 138 | + | |
| 121 | 139 | const sleep = (ms: number) => |
| 122 | 140 | new Promise<void>((resolve) => { |
| 123 | 141 | setTimeout(resolve, ms); |
| @@ -128,6 +146,63 @@ const supportsManagedElicitation = (server: McpServer): boolean => { · 4 unmodified lines | ||
| 128 | 146 | return Boolean(capabilities?.elicitation?.form) && Boolean(capabilities?.elicitation?.url); |
| 129 | 147 | }; |
| 130 | 148 | |
| 149 | +type CatalogLike = { | |
| 150 | + listNamespaces: (input: { limit: number }) => Effect.Effect< | |
| 151 | + ReadonlyArray<{ namespace: string; displayName?: string }>, | |
| 152 | + unknown | |
| 153 | + >; | |
| 154 | +}; | |
| 155 | + | |
| 156 | +const buildExecuteWorkflowText = (namespaces: readonly string[] = []): string => | |
| 157 | + [ | |
| 158 | + "Execute TypeScript in sandbox; call tools via discovery workflow.", | |
| 159 | + ...(namespaces.length > 0 | |
| 160 | + ? [ | |
| 161 | + "Available namespaces:", | |
| 162 | + ...namespaces.map((namespace) => `- ${namespace}`), | |
| 163 | + ] | |
| 164 | + : []), | |
| 165 | + "Workflow:", | |
| 166 | + '1) const matches = await tools.discover({ query: "<intent>", limit: 12 });', | |
| 167 | + "2) const details = await tools.describe.tool({ path, includeSchemas: true });", | |
| 168 | + "3) Call selected tools.<path>(input).", | |
| 169 | + '4) To connect a source, call tools.executor.sources.add(...) for MCP, OpenAPI, or GraphQL APIs.', | |
| 170 | + ...EXECUTOR_SOURCES_ADD_HELP_LINES, | |
| 171 | + "5) If execution pauses for interaction, resume it with the returned resumePayload or the available resume flow.", | |
| 172 | + "Do not use fetch; use tools.* only.", | |
| 173 | + ].join("\n"); | |
| 174 | + | |
| 175 | +const defaultExecuteDescription = buildExecuteWorkflowText(); | |
| 176 | + | |
| 177 | +const loadExecuteDescription = (runtime: SqlControlPlaneRuntime): Promise<string> => | |
| 178 | + runControlPlane( | |
| 179 | + runtime, | |
| 180 | + Effect.gen(function* () { | |
| 181 | + const resolveExecutionEnvironment = yield* RuntimeExecutionResolverService; | |
| 182 | + const environment = yield* resolveExecutionEnvironment({ | |
| 183 | + workspaceId: runtime.localInstallation.workspaceId, | |
| 184 | + accountId: runtime.localInstallation.accountId, | |
| 185 | + executionId: ExecutionIdSchema.make("exec_mcp_help"), | |
| 186 | + }); | |
| 187 | + | |
| 188 | + const catalog = environment.catalog as CatalogLike | undefined; | |
| 189 | + if (!catalog) { | |
| 190 | + return defaultExecuteDescription; | |
| 191 | + } | |
| 192 | + | |
| 193 | + const namespaces = yield* catalog.listNamespaces({ limit: 200 }).pipe( | |
| 194 | + Effect.map((items) => | |
| 195 | + items.length > 0 | |
| 196 | + ? items.map((item) => item.displayName ?? item.namespace) | |
| 197 | + : ["none discovered yet"], | |
| 198 | + ), | |
| 199 | + Effect.catchAll(() => Effect.succeed(["none discovered yet"])), | |
| 200 | + ); | |
| 201 | + | |
| 202 | + return buildExecuteWorkflowText(namespaces); | |
| 203 | + }).pipe(Effect.catchAll(() => Effect.succeed(defaultExecuteDescription))), | |
| 204 | + ); | |
| 205 | + | |
| 131 | 206 | const summarizeExecution = (execution: ExecutionEnvelope["execution"]): string => { |
| 132 | 207 | switch (execution.status) { |
| 133 | 208 | case "completed": |
| @@ -348,26 +423,16 @@ const driveExecutionWithoutElicitation = async (input: { · 214 unmodified lines | ||
| 348 | 423 | return current; |
| 349 | 424 | }; |
| 350 | 425 | |
| 351 | −const buildInstallInstructions = (baseUrl: string): string => { | |
| 352 | − const mcpUrl = new URL("/mcp", baseUrl).toString(); | |
| 353 | − | |
| 354 | − return [ | |
| 355 | − "Run once to install for supported MCP clients (via add-mcp):", | |
| 356 | − `npx add-mcp \"${mcpUrl}\" --transport http --name \"executor\"`, | |
| 357 | − ].join("\n"); | |
| 358 | −}; | |
| 359 | − | |
| 360 | −const createExecutorMcpServer = (config: { | |
| 426 | +const createExecutorMcpServer = async (config: { | |
| 361 | 427 | runtime: SqlControlPlaneRuntime; |
| 362 | − baseUrl: string; | |
| 363 | −}): McpServer => { | |
| 428 | +}): Promise<McpServer> => { | |
| 429 | + const executeDescription = await loadExecuteDescription(config.runtime); | |
| 364 | 430 | const server = new McpServer( |
| 365 | 431 | { name: "executor", version: "1.0.0" }, |
| 366 | 432 | { |
| 367 | 433 | capabilities: { |
| 368 | 434 | tools: {}, |
| 369 | 435 | }, |
| 370 | − instructions: buildInstallInstructions(config.baseUrl), | |
| 371 | 436 | }, |
| 372 | 437 | ); |
| 373 | 438 | |
| @@ -377,10 +442,7 @@ const createExecutorMcpServer = (config: { · 3 unmodified lines | ||
| 377 | 442 | const executeTool = server.registerTool( |
| 378 | 443 | "execute", |
| 379 | 444 | { |
| 380 | − description: [ | |
| 381 | − "Execute TypeScript against the local executor runtime.", | |
| 382 | − "Use the in-sandbox tool catalog via tools.* rather than direct fetch calls.", | |
| 383 | − ].join("\n"), | |
| 445 | + description: executeDescription, | |
| 384 | 446 | inputSchema: executeInputSchema, |
| 385 | 447 | }, |
| 386 | 448 | async ({ code }: { code: string }) => { |
| @@ -431,7 +493,6 @@ const createExecutorMcpServer = (config: { · 44 unmodified lines | ||
| 431 | 493 | }, |
| 432 | 494 | ); |
| 433 | 495 | |
| 434 | − | |
| 435 | 496 | const syncToolAvailability = () => { |
| 436 | 497 | if (supportsManagedElicitation(server)) { |
| 437 | 498 | executeTool.enable(); |
| @@ -524,9 +585,8 @@ export const createExecutorMcpRequestHandler = ( · 86 unmodified lines | ||
| 524 | 585 | }; |
| 525 | 586 | |
| 526 | 587 | try { |
| 527 | − createdServer = createExecutorMcpServer({ | |
| 588 | + createdServer = await createExecutorMcpServer({ | |
| 528 | 589 | runtime, |
| 529 | − baseUrl: new URL(request.url).origin, | |
| 530 | 590 | }); |
| 531 | 591 | await createdServer.connect(transport); |
| 532 | 592 | const response = await transport.handleRequest(request); |
| @@ -28,6 +28,8 @@ import type { · 27 unmodified lines | ||
| 28 | 28 | } from "@executor-v3/control-plane"; |
| 29 | 29 | import * as Cause from "effect/Cause"; |
| 30 | 30 | import * as Effect from "effect/Effect"; |
| 31 | +import * as Exit from "effect/Exit"; | |
| 32 | +import * as Runtime from "effect/Runtime"; | |
| 31 | 33 | import * as React from "react"; |
| 32 | 34 | |
| 33 | 35 | const DEFAULT_EXECUTOR_API_BASE_URL = "http://127.0.0.1:8788"; |
| @@ -177,6 +179,15 @@ const shouldLogExecutorDevErrors = (): boolean => { · 143 unmodified lines | ||
| 177 | 179 | }; |
| 178 | 180 | |
| 179 | 181 | const describeExecutorDevError = (cause: unknown): Record<string, unknown> => { |
| 182 | + if (Runtime.isFiberFailure(cause)) { | |
| 183 | + const inner = cause[Runtime.FiberFailureCauseId]; | |
| 184 | + return { | |
| 185 | + name: cause.name, | |
| 186 | + message: cause.message, | |
| 187 | + cause: Cause.pretty(inner), | |
| 188 | + }; | |
| 189 | + } | |
| 190 | + | |
| 180 | 191 | if (cause instanceof Error) { |
| 181 | 192 | return { |
| 182 | 193 | name: cause.name, |
| @@ -198,26 +209,31 @@ const logExecutorDevError = (label: string, details: Record<string, unknown>): v · 15 unmodified lines | ||
| 198 | 209 | console.error(`[executor react] ${label}`, details); |
| 199 | 210 | }; |
| 200 | 211 | |
| 201 | −const runControlPlane = <A>(input: { | |
| 212 | +const runControlPlane = async <A>(input: { | |
| 202 | 213 | accountId?: string; |
| 203 | 214 | execute: (client: ControlPlaneClient) => Effect.Effect<A, unknown, never>; |
| 204 | 215 | }): Promise<A> => { |
| 205 | 216 | const accountId = input.accountId; |
| 206 | 217 | |
| 207 | − return Effect.runPromise( | |
| 218 | + const exit = await Effect.runPromiseExit( | |
| 208 | 219 | createControlPlaneClient({ |
| 209 | 220 | baseUrl: apiBaseUrl, |
| 210 | 221 | ...(accountId !== undefined ? { accountId } : {}), |
| 211 | 222 | }).pipe(Effect.flatMap(input.execute)), |
| 212 | − ).catch((cause) => { | |
| 213 | − logExecutorDevError("control-plane request failed", { | |
| 214 | − baseUrl: apiBaseUrl, | |
| 215 | − accountId, | |
| 216 | − error: describeExecutorDevError(cause), | |
| 217 | − cause, | |
| 218 | − }); | |
| 219 | − throw cause; | |
| 223 | + ); | |
| 224 | + | |
| 225 | + if (Exit.isSuccess(exit)) { | |
| 226 | + return exit.value; | |
| 227 | + } | |
| 228 | + | |
| 229 | + const error = Cause.squash(exit.cause); | |
| 230 | + logExecutorDevError("control-plane request failed", { | |
| 231 | + baseUrl: apiBaseUrl, | |
| 232 | + accountId, | |
| 233 | + error: describeExecutorDevError(error), | |
| 234 | + cause: Cause.pretty(exit.cause), | |
| 220 | 235 | }); |
| 236 | + throw error; | |
| 221 | 237 | }; |
| 222 | 238 | |
| 223 | 239 | const controlPlaneRequest = <A>(input: { |
| @@ -80,7 +80,11 @@ export type LocalExecutorRequestHandler = { · 79 unmodified lines | ||
| 80 | 80 | }; |
| 81 | 81 | |
| 82 | 82 | const disposeRuntime = (runtime: SqlControlPlaneRuntime) => |
| 83 | − Effect.promise(() => runtime.close()).pipe(Effect.orDie); | |
| 83 | + Effect.tryPromise({ | |
| 84 | + try: () => runtime.close(), | |
| 85 | + catch: (cause) => | |
| 86 | + cause instanceof Error ? cause : new Error(String(cause ?? "runtime close failed")), | |
| 87 | + }).pipe(Effect.orDie); | |
| 84 | 88 | |
| 85 | 89 | const createControlPlaneWebHandler = (runtime: SqlControlPlaneRuntime) => |
| 86 | 90 | Effect.acquireRelease( |
| @@ -92,7 +96,7 @@ const createControlPlaneWebHandler = (runtime: SqlControlPlaneRuntime) => · 5 unmodified lines | ||
| 92 | 96 | ), |
| 93 | 97 | ), |
| 94 | 98 | ), |
| 95 | − (handler) => Effect.promise(() => handler.dispose()).pipe(Effect.orDie), | |
| 99 | + (handler) => Effect.tryPromise({ try: () => handler.dispose(), catch: (cause) => cause instanceof Error ? cause : new Error(String(cause ?? "web handler dispose failed")) }).pipe(Effect.orDie), | |
| 96 | 100 | ); |
| 97 | 101 | |
| 98 | 102 | const safeFilePath = (assetsDir: string, pathname: string): string | null => { |
| @@ -280,7 +284,7 @@ export const createLocalExecutorRequestHandler = ( · 181 unmodified lines | ||
| 280 | 284 | const apiHandler = yield* createControlPlaneWebHandler(runtime); |
| 281 | 285 | const mcpHandler = yield* Effect.acquireRelease( |
| 282 | 286 | Effect.sync(() => createExecutorMcpRequestHandler(runtime)), |
| 283 | − (handler) => Effect.promise(() => handler.close()).pipe(Effect.orDie), | |
| 287 | + (handler) => Effect.tryPromise({ try: () => handler.close(), catch: (cause) => cause instanceof Error ? cause : new Error(String(cause ?? "mcp handler close failed")) }).pipe(Effect.orDie), | |
| 284 | 288 | ); |
| 285 | 289 | |
| 286 | 290 | return { |
| @@ -389,7 +393,8 @@ export const runLocalExecutorServer = ( · 102 unmodified lines | ||
| 389 | 393 | }), |
| 390 | 394 | () => Effect.tryPromise({ |
| 391 | 395 | try: () => rm(pidFile, { force: true }), |
| 392 | − catch: () => undefined, | |
| 396 | + catch: (cause) => | |
| 397 | + cause instanceof Error ? cause : new Error(String(cause ?? "pid file cleanup failed")), | |
| 393 | 398 | }).pipe(Effect.orDie), |
| 394 | 399 | ); |
| 395 | 400 | |