History

Refactor runtime and typechecker to use better-result instead of try/catch

Replace nested try/catch patterns with Result types from better-result: - transpileForRuntime() returns Result<string, TranspileError> (sync) - runtime_core uses Result.tryPromise() for VM execution - CF worker loader uses Result.tryPromise() for fetch + response parsing - typechecker uses Result.try() for TS module loading and semantic checks - Add TranspileError as a TaggedError for typed error discrimination

Rhys Sullivan committed 9 months ago

Commit ca46a9f1 parent 64903696 files · +350 −238Browse files

Unified · Split

6 files
  1. executor/bun.lock +11 −0
  2. executor/lib/runtimes/cloudflare_worker_loader_runtime.ts +66 −59
  3. executor/lib/runtimes/runtime_core.ts +72 −60
  4. executor/lib/runtimes/transpile.ts +61 −21
  5. executor/lib/typechecker.ts +139 −98
  6. executor/package.json +1 −0
executor/bun.lock+11−0
@@ -5,12 +5,17 @@ · 4 unmodified lines
5 "": {5 "": {
6 "name": "executor",6 "name": "executor",
7 "dependencies": {7 "dependencies": {
8 "better-result": "^2.7.0",
8 "convex": "^1.31.7",9 "convex": "^1.31.7",
9 "graphql": "^16.12.0",10 "graphql": "^16.12.0",
10 },11 },
11 },12 },
12 },13 },
13 "packages": {14 "packages": {
15 "@clack/core": ["@clack/core@0.5.0", "", { "dependencies": { "picocolors": "^1.0.0", "sisteransi": "^1.0.5" } }, "sha512-p3y0FIOwaYRUPRcMO7+dlmLh8PSRcrjuTndsiA0WAFbWES0mLZlrjVoBRZ9DzkPFJZG6KGkJmoEAY0ZcVWTkow=="],
16
17 "@clack/prompts": ["@clack/prompts@0.11.0", "", { "dependencies": { "@clack/core": "0.5.0", "picocolors": "^1.0.0", "sisteransi": "^1.0.5" } }, "sha512-pMN5FcrEw9hUkZA4f+zLlzivQSeQf5dRGJjSUbvVYDLvpKCdQx5OaknvKzgbtXOizhP+SJJJjqEbOe55uKKfAw=="],
18
14 "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.27.0", "", { "os": "aix", "cpu": "ppc64" }, "sha512-KuZrd2hRjz01y5JK9mEBSD3Vj3mbCvemhT466rSuJYeE/hjuBrHfjjcjMdTm/sz7au+++sdbJZJmuBwQLuw68A=="],19 "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.27.0", "", { "os": "aix", "cpu": "ppc64" }, "sha512-KuZrd2hRjz01y5JK9mEBSD3Vj3mbCvemhT466rSuJYeE/hjuBrHfjjcjMdTm/sz7au+++sdbJZJmuBwQLuw68A=="],
15 20
16 "@esbuild/android-arm": ["@esbuild/android-arm@0.27.0", "", { "os": "android", "cpu": "arm" }, "sha512-j67aezrPNYWJEOHUNLPj9maeJte7uSMM6gMoxfPC9hOg8N02JuQi/T7ewumf4tNvJadFkvLZMlAq73b9uwdMyQ=="],21 "@esbuild/android-arm": ["@esbuild/android-arm@0.27.0", "", { "os": "android", "cpu": "arm" }, "sha512-j67aezrPNYWJEOHUNLPj9maeJte7uSMM6gMoxfPC9hOg8N02JuQi/T7ewumf4tNvJadFkvLZMlAq73b9uwdMyQ=="],
@@ -63,12 +68,18 @@ · 46 unmodified lines
63 68
64 "@esbuild/win32-x64": ["@esbuild/win32-x64@0.27.0", "", { "os": "win32", "cpu": "x64" }, "sha512-aIitBcjQeyOhMTImhLZmtxfdOcuNRpwlPNmlFKPcHQYPhEssw75Cl1TSXJXpMkzaua9FUetx/4OQKq7eJul5Cg=="],69 "@esbuild/win32-x64": ["@esbuild/win32-x64@0.27.0", "", { "os": "win32", "cpu": "x64" }, "sha512-aIitBcjQeyOhMTImhLZmtxfdOcuNRpwlPNmlFKPcHQYPhEssw75Cl1TSXJXpMkzaua9FUetx/4OQKq7eJul5Cg=="],
65 70
71 "better-result": ["better-result@2.7.0", "", { "dependencies": { "@clack/prompts": "^0.11.0" }, "bin": { "better-result": "bin/cli.mjs" } }, "sha512-7zrmXjAK8u8Z6SOe4R65XObOR5X+Y2I/VVku3t5cPOGQ8/WsBcfFmfnIPiEl5EBMDOzPHRwbiPbMtQBKYdw7RA=="],
72
66 "convex": ["convex@1.31.7", "", { "dependencies": { "esbuild": "0.27.0", "prettier": "^3.0.0" }, "peerDependencies": { "@auth0/auth0-react": "^2.0.1", "@clerk/clerk-react": "^4.12.8 || ^5.0.0", "react": "^18.0.0 || ^19.0.0-0 || ^19.0.0" }, "optionalPeers": ["@auth0/auth0-react", "@clerk/clerk-react", "react"], "bin": { "convex": "bin/main.js" } }, "sha512-PtNMe1mAIOvA8Yz100QTOaIdgt2rIuWqencVXrb4McdhxBHZ8IJ1eXTnrgCC9HydyilGT1pOn+KNqT14mqn9fQ=="],73 "convex": ["convex@1.31.7", "", { "dependencies": { "esbuild": "0.27.0", "prettier": "^3.0.0" }, "peerDependencies": { "@auth0/auth0-react": "^2.0.1", "@clerk/clerk-react": "^4.12.8 || ^5.0.0", "react": "^18.0.0 || ^19.0.0-0 || ^19.0.0" }, "optionalPeers": ["@auth0/auth0-react", "@clerk/clerk-react", "react"], "bin": { "convex": "bin/main.js" } }, "sha512-PtNMe1mAIOvA8Yz100QTOaIdgt2rIuWqencVXrb4McdhxBHZ8IJ1eXTnrgCC9HydyilGT1pOn+KNqT14mqn9fQ=="],
67 74
68 "esbuild": ["esbuild@0.27.0", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.0", "@esbuild/android-arm": "0.27.0", "@esbuild/android-arm64": "0.27.0", "@esbuild/android-x64": "0.27.0", "@esbuild/darwin-arm64": "0.27.0", "@esbuild/darwin-x64": "0.27.0", "@esbuild/freebsd-arm64": "0.27.0", "@esbuild/freebsd-x64": "0.27.0", "@esbuild/linux-arm": "0.27.0", "@esbuild/linux-arm64": "0.27.0", "@esbuild/linux-ia32": "0.27.0", "@esbuild/linux-loong64": "0.27.0", "@esbuild/linux-mips64el": "0.27.0", "@esbuild/linux-ppc64": "0.27.0", "@esbuild/linux-riscv64": "0.27.0", "@esbuild/linux-s390x": "0.27.0", "@esbuild/linux-x64": "0.27.0", "@esbuild/netbsd-arm64": "0.27.0", "@esbuild/netbsd-x64": "0.27.0", "@esbuild/openbsd-arm64": "0.27.0", "@esbuild/openbsd-x64": "0.27.0", "@esbuild/openharmony-arm64": "0.27.0", "@esbuild/sunos-x64": "0.27.0", "@esbuild/win32-arm64": "0.27.0", "@esbuild/win32-ia32": "0.27.0", "@esbuild/win32-x64": "0.27.0" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-jd0f4NHbD6cALCyGElNpGAOtWxSq46l9X/sWB0Nzd5er4Kz2YTm+Vl0qKFT9KUJvD8+fiO8AvoHhFvEatfVixA=="],75 "esbuild": ["esbuild@0.27.0", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.0", "@esbuild/android-arm": "0.27.0", "@esbuild/android-arm64": "0.27.0", "@esbuild/android-x64": "0.27.0", "@esbuild/darwin-arm64": "0.27.0", "@esbuild/darwin-x64": "0.27.0", "@esbuild/freebsd-arm64": "0.27.0", "@esbuild/freebsd-x64": "0.27.0", "@esbuild/linux-arm": "0.27.0", "@esbuild/linux-arm64": "0.27.0", "@esbuild/linux-ia32": "0.27.0", "@esbuild/linux-loong64": "0.27.0", "@esbuild/linux-mips64el": "0.27.0", "@esbuild/linux-ppc64": "0.27.0", "@esbuild/linux-riscv64": "0.27.0", "@esbuild/linux-s390x": "0.27.0", "@esbuild/linux-x64": "0.27.0", "@esbuild/netbsd-arm64": "0.27.0", "@esbuild/netbsd-x64": "0.27.0", "@esbuild/openbsd-arm64": "0.27.0", "@esbuild/openbsd-x64": "0.27.0", "@esbuild/openharmony-arm64": "0.27.0", "@esbuild/sunos-x64": "0.27.0", "@esbuild/win32-arm64": "0.27.0", "@esbuild/win32-ia32": "0.27.0", "@esbuild/win32-x64": "0.27.0" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-jd0f4NHbD6cALCyGElNpGAOtWxSq46l9X/sWB0Nzd5er4Kz2YTm+Vl0qKFT9KUJvD8+fiO8AvoHhFvEatfVixA=="],
69 76
70 "graphql": ["graphql@16.12.0", "", {}, "sha512-DKKrynuQRne0PNpEbzuEdHlYOMksHSUI8Zc9Unei5gTsMNA2/vMpoMz/yKba50pejK56qj98qM0SjYxAKi13gQ=="],77 "graphql": ["graphql@16.12.0", "", {}, "sha512-DKKrynuQRne0PNpEbzuEdHlYOMksHSUI8Zc9Unei5gTsMNA2/vMpoMz/yKba50pejK56qj98qM0SjYxAKi13gQ=="],
71 78
79 "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
80
72 "prettier": ["prettier@3.8.1", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg=="],81 "prettier": ["prettier@3.8.1", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg=="],
82
83 "sisteransi": ["sisteransi@1.0.5", "", {}, "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg=="],
73 }84 }
74}85}
executor/lib/runtimes/cloudflare_worker_loader_runtime.ts+66−59
@@ -1,5 +1,6 @@
1"use node";1"use node";
2 2
3import { Result } from "better-result";
3import type { SandboxExecutionRequest, SandboxExecutionResult } from "../types";4import type { SandboxExecutionRequest, SandboxExecutionResult } from "../types";
4import { getCloudflareWorkerLoaderConfig } from "./runtime_catalog";5import { getCloudflareWorkerLoaderConfig } from "./runtime_catalog";
5import { transpileForRuntime } from "./transpile";6import { transpileForRuntime } from "./transpile";
@@ -38,19 +39,30 @@ export async function runCodeWithCloudflareWorkerLoader( · 32 unmodified lines
38 const config = getCloudflareWorkerLoaderConfig();39 const config = getCloudflareWorkerLoaderConfig();
39 const startedAt = Date.now();40 const startedAt = Date.now();
40 41
41 const controller = new AbortController();42 const mkResult = (
42 const timeout = setTimeout(43 status: SandboxExecutionResult["status"],
43 () => controller.abort(),44 opts?: { stdout?: string; stderr?: string; error?: string; exitCode?: number },
44 config.requestTimeoutMs,45 ): SandboxExecutionResult => ({
45 );46 status,
47 stdout: opts?.stdout ?? "",
48 stderr: opts?.stderr ?? "",
49 exitCode: opts?.exitCode,
50 error: opts?.error,
51 durationMs: Date.now() - startedAt,
52 });
46 53
47 try {54 // ── Transpile TS → JS on the Convex side ─────────────────────────────
48 // Transpile TS → JS on the Convex side before sending to the CF isolate.55 const transpiled = transpileForRuntime(request.code);
49 // The dynamic isolate runs the code as plain JS (harness.js), so any56 if (transpiled.isErr()) {
50 // TypeScript syntax must be stripped first.57 return mkResult("failed", { error: transpiled.error.message });
51 const code = await transpileForRuntime(request.code);58 }
52 59
53 const response = await fetch(config.runUrl, {60 // ── POST to CF host worker ────────────────────────────────────────────
61 const controller = new AbortController();
62 const timeout = setTimeout(() => controller.abort(), config.requestTimeoutMs);
63
64 const response = await Result.tryPromise(() =>
65 fetch(config.runUrl, {
54 method: "POST",66 method: "POST",
55 headers: {67 headers: {
56 "Content-Type": "application/json",68 "Content-Type": "application/json",
@@ -58,71 +70,66 @@ export async function runCodeWithCloudflareWorkerLoader( · 1 unmodified line
58 },70 },
59 body: JSON.stringify({71 body: JSON.stringify({
60 taskId: request.taskId,72 taskId: request.taskId,
61 code,73 code: transpiled.value,
62 timeoutMs: request.timeoutMs,74 timeoutMs: request.timeoutMs,
63 // The host Worker needs these to call back to Convex for tool
64 // invocations and output streaming.
65 callback: {75 callback: {
66 baseUrl: config.callbackBaseUrl,76 baseUrl: config.callbackBaseUrl,
67 authToken: config.callbackAuthToken,77 authToken: config.callbackAuthToken,
68 },78 },
69 }),79 }),
70 signal: controller.signal,80 signal: controller.signal,
71 });81 }),
82 );
72 83
73 if (!response.ok) {84 clearTimeout(timeout);
74 const text = await response.text().catch(() => response.statusText);85
75 return {86 if (response.isErr()) {
76 status: "failed",87 const cause = response.error.cause;
77 stdout: "",88 const isAbort = cause instanceof DOMException && cause.name === "AbortError";
78 stderr: text,89 if (isAbort) {
79 error: `Cloudflare sandbox returned ${response.status}: ${text}`,90 return mkResult("timed_out", {
80 durationMs: Date.now() - startedAt,91 error: `Cloudflare sandbox request timed out after ${config.requestTimeoutMs}ms`,
81 };92 });
82 }93 }
94 const message = cause instanceof Error ? cause.message : String(cause);
95 return mkResult("failed", {
96 error: `Cloudflare sandbox request failed: ${message}`,
97 });
98 }
83 99
84 const result = (await response.json()) as {100 // ── Handle non-OK HTTP status ─────────────────────────────────────────
101 if (!response.value.ok) {
102 const text = await Result.tryPromise(() => response.value.text());
103 const body = text.unwrapOr(response.value.statusText);
104 return mkResult("failed", {
105 stderr: body,
106 error: `Cloudflare sandbox returned ${response.value.status}: ${body}`,
107 });
108 }
109
110 // ── Parse JSON response ───────────────────────────────────────────────
111 const body = await Result.tryPromise(() =>
112 response.value.json() as Promise<{
85 status?: string;113 status?: string;
86 stdout?: string;114 stdout?: string;
87 stderr?: string;115 stderr?: string;
88 error?: string;116 error?: string;
89 exitCode?: number;117 exitCode?: number;
90 };118 }>,
91 119 );
92 const status = mapStatus(result.status);
93
94 return {
95 status,
96 stdout: result.stdout ?? "",
97 stderr: result.stderr ?? "",
98 exitCode: result.exitCode,
99 error: result.error,
100 durationMs: Date.now() - startedAt,
101 };
102 } catch (error) {
103 const message = error instanceof Error ? error.message : String(error);
104 const isAbort = error instanceof DOMException && error.name === "AbortError";
105
106 if (isAbort) {
107 return {
108 status: "timed_out",
109 stdout: "",
110 stderr: "",
111 error: `Cloudflare sandbox request timed out after ${config.requestTimeoutMs}ms`,
112 durationMs: Date.now() - startedAt,
113 };
114 }
115 120
116 return {121 if (body.isErr()) {
117 status: "failed",122 return mkResult("failed", {
118 stdout: "",123 error: `Cloudflare sandbox returned invalid JSON`,
119 stderr: "",124 });
120 error: `Cloudflare sandbox request failed: ${message}`,
121 durationMs: Date.now() - startedAt,
122 };
123 } finally {
124 clearTimeout(timeout);
125 }125 }
126
127 return mkResult(mapStatus(body.value.status), {
128 stdout: body.value.stdout,
129 stderr: body.value.stderr,
130 exitCode: body.value.exitCode,
131 error: body.value.error,
132 });
126}133}
127 134
128function mapStatus(135function mapStatus(
executor/lib/runtimes/runtime_core.ts+72−60
@@ -5,6 +5,7 @@ · 4 unmodified lines
5// the core helpers here (formatArgs, createToolsProxy, console proxy, execution5// the core helpers here (formatArgs, createToolsProxy, console proxy, execution
6// loop, result mapping) should be mirrored there.6// loop, result mapping) should be mirrored there.
7import { APPROVAL_DENIED_PREFIX, TASK_TIMEOUT_MARKER } from "../execution_constants";7import { APPROVAL_DENIED_PREFIX, TASK_TIMEOUT_MARKER } from "../execution_constants";
8import { Result } from "better-result";
8import { Script, createContext } from "node:vm";9import { Script, createContext } from "node:vm";
9import type {10import type {
10 ExecutionAdapter,11 ExecutionAdapter,
@@ -17,11 +18,7 @@ function formatArgs(args: unknown[]): string { · 6 unmodified lines
17 return args18 return args
18 .map((value) => {19 .map((value) => {
19 if (typeof value === "string") return value;20 if (typeof value === "string") return value;
20 try {21 return Result.try(() => JSON.stringify(value)).unwrapOr(String(value));
21 return JSON.stringify(value);
22 } catch {
23 return String(value);
24 }
25 })22 })
26 .join(" ");23 .join(" ");
27}24}
@@ -71,6 +68,30 @@ function createToolsProxy( · 43 unmodified lines
71 });68 });
72}69}
73 70
71/** Classify a caught error from VM execution into a result status. */
72function classifyExecutionError(
73 error: unknown,
74 request: SandboxExecutionRequest,
75): { status: SandboxExecutionResult["status"]; message: string } {
76 const message = error instanceof Error ? error.message : String(error);
77
78 if (message === TASK_TIMEOUT_MARKER || message.includes("Script execution timed out")) {
79 return {
80 status: "timed_out",
81 message: `Execution timed out after ${request.timeoutMs}ms`,
82 };
83 }
84
85 if (message.startsWith(APPROVAL_DENIED_PREFIX)) {
86 return {
87 status: "denied",
88 message: message.replace(APPROVAL_DENIED_PREFIX, "").trim(),
89 };
90 }
91
92 return { status: "failed", message };
93}
94
74export async function runCodeWithAdapter(95export async function runCodeWithAdapter(
75 request: SandboxExecutionRequest,96 request: SandboxExecutionRequest,
76 adapter: ExecutionAdapter,97 adapter: ExecutionAdapter,
@@ -103,6 +124,26 @@ export async function runCodeWithAdapter( · 26 unmodified lines
103 );124 );
104 };125 };
105 126
127 const mkResult = (
128 status: SandboxExecutionResult["status"],
129 opts?: { error?: string; exitCode?: number },
130 ): SandboxExecutionResult => ({
131 status,
132 stdout: stdoutLines.join("\n"),
133 stderr: stderrLines.join("\n"),
134 exitCode: opts?.exitCode,
135 error: opts?.error,
136 durationMs: Date.now() - startedAt,
137 });
138
139 // ── Transpile ──────────────────────────────────────────────────────────
140 const transpiled = transpileForRuntime(request.code);
141 if (transpiled.isErr()) {
142 appendStderr(transpiled.error.message);
143 return mkResult("failed", { error: transpiled.error.message });
144 }
145
146 // ── Sandbox setup ──────────────────────────────────────────────────────
106 const tools = createToolsProxy(adapter, request.taskId);147 const tools = createToolsProxy(adapter, request.taskId);
107 const consoleProxy = {148 const consoleProxy = {
108 log: (...args: unknown[]) => appendStdout(formatArgs(args)),149 log: (...args: unknown[]) => appendStdout(formatArgs(args)),
@@ -118,15 +159,14 @@ export async function runCodeWithAdapter( · 9 unmodified lines
118 clearTimeout,159 clearTimeout,
119 });160 });
120 const context = createContext(sandbox, {161 const context = createContext(sandbox, {
121 codeGeneration: {162 codeGeneration: { strings: false, wasm: false },
122 strings: false,
123 wasm: false,
124 },
125 });163 });
126 164
127 const executableCode = await transpileForRuntime(request.code);165 const runnerScript = new Script(
128 const runnerScript = new Script(`(async () => {\n"use strict";\n${executableCode}\n})()`);166 `(async () => {\n"use strict";\n${transpiled.value}\n})()`,
167 );
129 168
169 // ── Execute with timeout ───────────────────────────────────────────────
130 let timeoutHandle: ReturnType<typeof setTimeout> | undefined;170 let timeoutHandle: ReturnType<typeof setTimeout> | undefined;
131 const timeoutPromise = new Promise<never>((_resolve, reject) => {171 const timeoutPromise = new Promise<never>((_resolve, reject) => {
132 timeoutHandle = setTimeout(() => {172 timeoutHandle = setTimeout(() => {
@@ -134,60 +174,32 @@ export async function runCodeWithAdapter( · 1 unmodified line
134 }, request.timeoutMs);174 }, request.timeoutMs);
135 });175 });
136 176
137 try {177 const execution = await Result.tryPromise(async () => {
138 const value = await Promise.race([178 const value = await Promise.race([
139 Promise.resolve(runnerScript.runInContext(context, { timeout: Math.max(1, request.timeoutMs) })),179 Promise.resolve(
180 runnerScript.runInContext(context, {
181 timeout: Math.max(1, request.timeoutMs),
182 }),
183 ),
140 timeoutPromise,184 timeoutPromise,
141 ]);185 ]);
186 return value;
187 });
142 188
143 if (value !== undefined) {189 if (timeoutHandle) clearTimeout(timeoutHandle);
144 appendStdout(`result: ${formatArgs([value])}`);
145 }
146
147 return {
148 status: "completed",
149 stdout: stdoutLines.join("\n"),
150 stderr: stderrLines.join("\n"),
151 exitCode: 0,
152 durationMs: Date.now() - startedAt,
153 };
154 } catch (error) {
155 const message = error instanceof Error ? error.message : String(error);
156 if (message === TASK_TIMEOUT_MARKER || message.includes("Script execution timed out")) {
157 const timeoutMessage = `Execution timed out after ${request.timeoutMs}ms`;
158 appendStderr(timeoutMessage);
159 return {
160 status: "timed_out",
161 stdout: stdoutLines.join("\n"),
162 stderr: stderrLines.join("\n"),
163 error: timeoutMessage,
164 durationMs: Date.now() - startedAt,
165 };
166 }
167
168 if (message.startsWith(APPROVAL_DENIED_PREFIX)) {
169 const deniedMessage = message.replace(APPROVAL_DENIED_PREFIX, "").trim();
170 appendStderr(deniedMessage);
171 return {
172 status: "denied",
173 stdout: stdoutLines.join("\n"),
174 stderr: stderrLines.join("\n"),
175 error: deniedMessage,
176 durationMs: Date.now() - startedAt,
177 };
178 }
179 190
191 if (execution.isErr()) {
192 const { status, message } = classifyExecutionError(
193 execution.error.cause,
194 request,
195 );
180 appendStderr(message);196 appendStderr(message);
181 return {197 return mkResult(status, { error: message });
182 status: "failed",
183 stdout: stdoutLines.join("\n"),
184 stderr: stderrLines.join("\n"),
185 error: message,
186 durationMs: Date.now() - startedAt,
187 };
188 } finally {
189 if (timeoutHandle) {
190 clearTimeout(timeoutHandle);
191 }
192 }198 }
199
200 if (execution.value !== undefined) {
201 appendStdout(`result: ${formatArgs([execution.value])}`);
202 }
203
204 return mkResult("completed", { exitCode: 0 });
193}205}
executor/lib/runtimes/transpile.ts+61−21
@@ -1,38 +1,78 @@
1"use node";1"use node";
2 2
3import { Result, TaggedError } from "better-result";
4
5// ---------------------------------------------------------------------------
6// Errors
7// ---------------------------------------------------------------------------
8
9export class TranspileError extends TaggedError("TranspileError")<{
10 message: string;
11}>() {}
12
13// ---------------------------------------------------------------------------
14// TypeScript module loader
15// ---------------------------------------------------------------------------
16
17let cachedTypeScript: typeof import("typescript") | null | undefined;
18
19function getTypeScriptModule(): typeof import("typescript") | null {
20 if (cachedTypeScript === undefined) {
21 const loaded = Result.try(() => require("typescript") as typeof import("typescript"));
22 cachedTypeScript = loaded.isOk() ? loaded.value : null;
23 }
24 return cachedTypeScript ?? null;
25}
26
27// ---------------------------------------------------------------------------
28// Public API
29// ---------------------------------------------------------------------------
30
3/**31/**
4 * Transpile TypeScript code to JavaScript using the `typescript` module.32 * Transpile TypeScript code to JavaScript using the `typescript` module.
5 *33 *
6 * If the `typescript` module is not available, the code is returned as-is34 * Returns a Result — either the transpiled JS string or a TranspileError.
35 * If the TypeScript module is not available, the code is returned as-is
7 * (graceful fallback for environments where TS isn't installed).36 * (graceful fallback for environments where TS isn't installed).
8 *37 *
9 * Targets ES2022/ESNext so the output can run in modern runtimes (node:vm,38 * Targets ES2022/ESNext so the output can run in modern runtimes (node:vm,
10 * Cloudflare Workers isolates, etc.) without further downlevelling.39 * Cloudflare Workers isolates, etc.) without further downlevelling.
11 */40 */
12export async function transpileForRuntime(code: string): Promise<string> {41export function transpileForRuntime(
13 let ts: typeof import("typescript");42 code: string,
14 try {43): Result<string, TranspileError> {
15 ts = require("typescript");44 const ts = getTypeScriptModule();
16 } catch {45 if (!ts) return Result.ok(code);
17 return code;
18 }
19 46
20 const target = ts.ScriptTarget?.ES2022 ?? ts.ScriptTarget?.ESNext;47 const target = ts.ScriptTarget?.ES2022 ?? ts.ScriptTarget?.ESNext;
21 const moduleKind = ts.ModuleKind?.ESNext;48 const moduleKind = ts.ModuleKind?.ESNext;
22 49
23 const result = ts.transpileModule(code, {50 return Result.try({
24 compilerOptions: {51 try: () => {
25 ...(target !== undefined ? { target } : {}),52 const result = ts.transpileModule(code, {
26 ...(moduleKind !== undefined ? { module: moduleKind } : {}),53 compilerOptions: {
27 },54 ...(target !== undefined ? { target } : {}),
28 reportDiagnostics: true,55 ...(moduleKind !== undefined ? { module: moduleKind } : {}),
29 });56 },
57 reportDiagnostics: true,
58 });
30 59
31 if (result.diagnostics && result.diagnostics.length > 0) {60 if (result.diagnostics && result.diagnostics.length > 0) {
32 const first = result.diagnostics[0];61 const first = result.diagnostics[0];
33 const message = ts.flattenDiagnosticMessageText(first.messageText, "\n");62 const message = ts.flattenDiagnosticMessageText(
34 throw new Error(`TypeScript transpile error: ${message}`);63 first.messageText,
35 }64 "\n",
65 );
66 throw new TranspileError({ message: `TypeScript transpile error: ${message}` });
67 }
36 68
37 return result.outputText || code;69 return result.outputText || code;
70 },
71 catch: (e) =>
72 e instanceof TranspileError
73 ? e
74 : new TranspileError({
75 message: e instanceof Error ? e.message : String(e),
76 }),
77 });
38}78}
executor/lib/typechecker.ts+139−98
@@ -13,6 +13,7 @@ · 12 unmodified lines
13 * without needing their own TypeScript setup.13 * without needing their own TypeScript setup.
14 */14 */
15 15
16import { Result } from "better-result";
16import type { ToolDescriptor } from "./types";17import type { ToolDescriptor } from "./types";
17 18
18// ---------------------------------------------------------------------------19// ---------------------------------------------------------------------------
@@ -23,11 +24,8 @@ let cachedTypeScript: typeof import("typescript") | null | undefined; · 4 unmodified lines
23 24
24function getTypeScriptModule(): typeof import("typescript") | null {25function getTypeScriptModule(): typeof import("typescript") | null {
25 if (cachedTypeScript === undefined) {26 if (cachedTypeScript === undefined) {
26 try {27 const loaded = Result.try(() => require("typescript") as typeof import("typescript"));
27 cachedTypeScript = require("typescript");28 cachedTypeScript = loaded.isOk() ? loaded.value : null;
28 } catch {
29 cachedTypeScript = null;
30 }
31 }29 }
32 return cachedTypeScript ?? null;30 return cachedTypeScript ?? null;
33}31}
@@ -39,7 +37,7 @@ function isValidTypeExpression(typeExpression: string): boolean { · 5 unmodified lines
39 return !/[\r\n`]/.test(typeExpression);37 return !/[\r\n`]/.test(typeExpression);
40 }38 }
41 39
42 try {40 return Result.try(() => {
43 const sourceFile = ts.createSourceFile(41 const sourceFile = ts.createSourceFile(
44 "_type_expr_check.ts",42 "_type_expr_check.ts",
45 `type __T = ${typeExpression};`,43 `type __T = ${typeExpression};`,
@@ -51,9 +49,7 @@ function isValidTypeExpression(typeExpression: string): boolean { · 5 unmodified lines
51 sourceFile as unknown as { parseDiagnostics?: import("typescript").Diagnostic[] }49 sourceFile as unknown as { parseDiagnostics?: import("typescript").Diagnostic[] }
52 ).parseDiagnostics ?? [];50 ).parseDiagnostics ?? [];
53 return diagnostics.length === 0;51 return diagnostics.length === 0;
54 } catch {52 }).unwrapOr(false);
55 return false;
56 }
57}53}
58 54
59function safeTypeExpression(raw: string | undefined, fallback: string): string {55function safeTypeExpression(raw: string | undefined, fallback: string): string {
@@ -63,20 +59,62 @@ function safeTypeExpression(raw: string | undefined, fallback: string): string { · 3 unmodified lines
63}59}
64 60
65const OPENAPI_HELPER_TYPES = `61const OPENAPI_HELPER_TYPES = `
66type _OrEmpty<T> = [T] extends [never] ? {} : T;62type _Normalize<T> = Exclude<T, undefined | null>;
63type _OrEmpty<T> = [_Normalize<T>] extends [never] ? {} : _Normalize<T>;
67type _Simplify<T> = { [K in keyof T]: T[K] } & {};64type _Simplify<T> = { [K in keyof T]: T[K] } & {};
65type _ParamsOf<Op> =
66 Op extends { parameters: infer P } ? P :
67 Op extends { parameters?: infer P } ? P :
68 never;
69type _ParamAt<Op, K extends "query" | "path" | "header" | "cookie"> =
70 _ParamsOf<Op> extends { [P in K]?: infer V } ? V : never;
71type _BodyOf<Op> =
72 Op extends { requestBody?: infer B } ? B :
73 Op extends { requestBody: infer B } ? B :
74 never;
75type _BodyContent<B> =
76 B extends { content: infer C }
77 ? C extends Record<string, infer V> ? V : never
78 : never;
68type ToolInput<Op> = _Simplify<79type ToolInput<Op> = _Simplify<
69 _OrEmpty<Op extends { parameters: { query: infer Q } } ? { [K in keyof Q]: Q[K] } : never> &80 _OrEmpty<_ParamAt<Op, "query">> &
70 _OrEmpty<Op extends { parameters: { path: infer P } } ? { [K in keyof P]: P[K] } : never> &81 _OrEmpty<_ParamAt<Op, "path">> &
71 _OrEmpty<Op extends { requestBody: { content: { "application/json": infer B } } } ? B : never>82 _OrEmpty<_ParamAt<Op, "header">> &
83 _OrEmpty<_ParamAt<Op, "cookie">> &
84 _OrEmpty<_BodyContent<_BodyOf<Op>>>
72>;85>;
73type ToolOutput<Op> =86type _ResponsesOf<Op> = Op extends { responses: infer R } ? R : never;
74 Op extends { responses: { 200: { content: { "application/json": infer R } } } } ? R :87type _RespAt<Op, Code extends PropertyKey> =
75 Op extends { responses: { 201: { content: { "application/json": infer R } } } } ? R :88 _ResponsesOf<Op> extends { [K in Code]?: infer R } ? R : never;
76 Op extends { responses: { 202: { content: { "application/json": infer R } } } } ? R :89type _ResponsePayload<R> =
77 Op extends { responses: { 204: unknown } } ? void :90 [R] extends [never] ? never :
78 Op extends { responses: { 205: unknown } } ? void :91 R extends { content: infer C }
79 unknown;92 ? C extends Record<string, infer V> ? V : unknown
93 : R extends { schema: infer S } ? S : unknown;
94type _HasStatus<Op, Code extends PropertyKey> =
95 [_RespAt<Op, Code>] extends [never] ? false : true;
96type _PayloadAt<Op, Code extends PropertyKey> =
97 Code extends 204 | 205
98 ? (_HasStatus<Op, Code> extends true ? void : never)
99 : _ResponsePayload<_RespAt<Op, Code>>;
100type _FirstKnown<T extends readonly unknown[]> =
101 T extends readonly [infer H, ...infer Rest]
102 ? [H] extends [never] ? _FirstKnown<Rest> : H
103 : unknown;
104type ToolOutput<Op> = _FirstKnown<[
105 _PayloadAt<Op, 200>,
106 _PayloadAt<Op, 201>,
107 _PayloadAt<Op, 202>,
108 _PayloadAt<Op, 203>,
109 _PayloadAt<Op, 204>,
110 _PayloadAt<Op, 205>,
111 _PayloadAt<Op, 206>,
112 _PayloadAt<Op, 207>,
113 _PayloadAt<Op, 208>,
114 _PayloadAt<Op, 226>,
115 _PayloadAt<Op, "default">,
116 unknown
117]>;
80`;118`;
81 119
82function stripExportKeywordsForTypechecker(dts: string): string {120function stripExportKeywordsForTypechecker(dts: string): string {
@@ -233,6 +271,8 @@ export interface TypecheckResult { · 150 unmodified lines
233 readonly errors: readonly string[];271 readonly errors: readonly string[];
234}272}
235 273
274const TYPECHECK_OK: TypecheckResult = { ok: true, errors: [] };
275
236let warnedMissingCompilerHostSupport = false;276let warnedMissingCompilerHostSupport = false;
237let warnedSemanticFallback = false;277let warnedSemanticFallback = false;
238 278
@@ -245,7 +285,7 @@ function runSyntaxOnlyTypecheck( · 6 unmodified lines
245 headerLineCount: number,285 headerLineCount: number,
246 ) => string,286 ) => string,
247): TypecheckResult {287): TypecheckResult {
248 try {288 return Result.try(() => {
249 const sourceFile = ts.createSourceFile(289 const sourceFile = ts.createSourceFile(
250 "generated.ts",290 "generated.ts",
251 wrappedCode,291 wrappedCode,
@@ -256,16 +296,72 @@ function runSyntaxOnlyTypecheck( · 4 unmodified lines
256 const diagnostics = (296 const diagnostics = (
257 sourceFile as unknown as { parseDiagnostics?: import("typescript").Diagnostic[] }297 sourceFile as unknown as { parseDiagnostics?: import("typescript").Diagnostic[] }
258 ).parseDiagnostics ?? [];298 ).parseDiagnostics ?? [];
259 if (diagnostics.length === 0) {299 if (diagnostics.length === 0) return TYPECHECK_OK;
260 return { ok: true, errors: [] };
261 }
262 return {300 return {
263 ok: false,301 ok: false as const,
264 errors: diagnostics.map((d: import("typescript").Diagnostic) => formatError(d, headerLineCount)),302 errors: diagnostics.map((d) => formatError(d, headerLineCount)),
265 };303 };
266 } catch {304 }).unwrapOr(TYPECHECK_OK);
267 return { ok: true, errors: [] };305}
268 }306
307function runSemanticTypecheck(
308 ts: typeof import("typescript"),
309 wrappedCode: string,
310 headerLineCount: number,
311 formatError: (
312 diagnostic: import("typescript").Diagnostic,
313 headerLineCount: number,
314 ) => string,
315): Result<TypecheckResult, Error> {
316 return Result.try({
317 try: () => {
318 const sourceFile = ts.createSourceFile(
319 "generated.ts",
320 wrappedCode,
321 ts.ScriptTarget.ESNext,
322 true,
323 ts.ScriptKind.TS,
324 );
325
326 const compilerOptions: import("typescript").CompilerOptions = {
327 target: ts.ScriptTarget.ESNext,
328 module: ts.ModuleKind.ESNext,
329 strict: true,
330 noEmit: true,
331 lib: ["lib.es2022.d.ts"],
332 types: [], // prevent automatic @types/* from conflicting with sandbox declarations
333 };
334
335 const host = ts.createCompilerHost(compilerOptions);
336 const originalGetSourceFile = host.getSourceFile.bind(host);
337 host.getSourceFile = (fileName, languageVersion) => {
338 if (fileName === "generated.ts") return sourceFile;
339 return originalGetSourceFile(fileName, languageVersion);
340 };
341
342 const program = ts.createProgram(["generated.ts"], compilerOptions, host);
343 const diagnostics = program.getSemanticDiagnostics(sourceFile);
344
345 if (diagnostics.length === 0) return TYPECHECK_OK;
346
347 // Filter out errors from the .d.ts header — only report user code errors
348 const userErrors = diagnostics.filter((d) => {
349 if (d.start !== undefined && d.file) {
350 const { line } = d.file.getLineAndCharacterOfPosition(d.start);
351 return line + 1 > headerLineCount;
352 }
353 return false;
354 });
355
356 if (userErrors.length === 0) return TYPECHECK_OK;
357
358 return {
359 ok: false as const,
360 errors: userErrors.map((d) => formatError(d, headerLineCount)),
361 };
362 },
363 catch: (e) => (e instanceof Error ? e : new Error(String(e))),
364 });
269}365}
270 366
271/**367/**
@@ -278,13 +374,8 @@ export function typecheckCode( · 6 unmodified lines
278 code: string,374 code: string,
279 toolDeclarations: string,375 toolDeclarations: string,
280): TypecheckResult {376): TypecheckResult {
281 let ts: typeof import("typescript");377 const ts = getTypeScriptModule();
282 try {378 if (!ts) return TYPECHECK_OK;
283 ts = require("typescript");
284 } catch {
285 // TypeScript not available — skip typechecking
286 return { ok: true, errors: [] };
287 }
288 379
289 // Wrap the code in an async function with the tools declaration.380 // Wrap the code in an async function with the tools declaration.
290 // We declare sandbox globals (console, setTimeout, etc.) ourselves since381 // We declare sandbox globals (console, setTimeout, etc.) ourselves since
@@ -301,12 +392,12 @@ export function typecheckCode( · 10 unmodified lines
301 392
302 const formatError = (393 const formatError = (
303 diagnostic: import("typescript").Diagnostic,394 diagnostic: import("typescript").Diagnostic,
304 headerLineCount: number,395 hdrLineCount: number,
305 ): string => {396 ): string => {
306 const message = ts.flattenDiagnosticMessageText(diagnostic.messageText, "\n");397 const message = ts.flattenDiagnosticMessageText(diagnostic.messageText, "\n");
307 if (diagnostic.start !== undefined && diagnostic.file) {398 if (diagnostic.start !== undefined && diagnostic.file) {
308 const { line } = diagnostic.file.getLineAndCharacterOfPosition(diagnostic.start);399 const { line } = diagnostic.file.getLineAndCharacterOfPosition(diagnostic.start);
309 const adjustedLine = line + 1 - headerLineCount;400 const adjustedLine = line + 1 - hdrLineCount;
310 if (adjustedLine > 0) {401 if (adjustedLine > 0) {
311 return `Line ${adjustedLine}: ${message}`;402 return `Line ${adjustedLine}: ${message}`;
312 }403 }
@@ -328,66 +419,16 @@ export function typecheckCode( · 15 unmodified lines
328 return runSyntaxOnlyTypecheck(ts, wrappedCode, headerLineCount, formatError);419 return runSyntaxOnlyTypecheck(ts, wrappedCode, headerLineCount, formatError);
329 }420 }
330 421
331 try {422 const semantic = runSemanticTypecheck(ts, wrappedCode, headerLineCount, formatError);
332 const sourceFile = ts.createSourceFile(423 if (semantic.isOk()) return semantic.value;
333 "generated.ts",
334 wrappedCode,
335 ts.ScriptTarget.ESNext,
336 true,
337 ts.ScriptKind.TS,
338 );
339
340 const compilerOptions: import("typescript").CompilerOptions = {
341 target: ts.ScriptTarget.ESNext,
342 module: ts.ModuleKind.ESNext,
343 strict: true,
344 noEmit: true,
345 lib: ["lib.es2022.d.ts"],
346 types: [], // prevent automatic @types/* (e.g. @types/node) from conflicting with our sandbox declarations
347 };
348
349 const host = ts.createCompilerHost(compilerOptions);
350 const originalGetSourceFile = host.getSourceFile.bind(host);
351 host.getSourceFile = (fileName, languageVersion) => {
352 if (fileName === "generated.ts") return sourceFile;
353 return originalGetSourceFile(fileName, languageVersion);
354 };
355
356 const program = ts.createProgram(["generated.ts"], compilerOptions, host);
357 const diagnostics = program.getSemanticDiagnostics(sourceFile);
358
359 if (diagnostics.length === 0) {
360 return { ok: true, errors: [] };
361 }
362
363 // Filter out errors from the .d.ts header (circular refs, etc.) — only report user code errors
364 const userErrors = diagnostics.filter((d) => {
365 if (d.start !== undefined && d.file) {
366 const { line } = d.file.getLineAndCharacterOfPosition(d.start);
367 return line + 1 > headerLineCount;
368 }
369 return false;
370 });
371 424
372 if (userErrors.length === 0) {425 // Semantic typechecking failed to initialize — fall back to syntax-only.
373 return { ok: true, errors: [] };426 if (!warnedSemanticFallback) {
374 }427 warnedSemanticFallback = true;
375 428 console.warn(
376 return {429 `[executor] TypeScript semantic typecheck unavailable, falling back to syntax-only checks: ${semantic.error.message}`,
377 ok: false,430 );
378 errors: userErrors.map((d) => formatError(d, headerLineCount)),
379 };
380 } catch (error) {
381 // Some runtimes (e.g. Convex action isolates) can lack the full Node-backed
382 // TypeScript host environment. If semantic typechecking cannot initialize,
383 // fall back to syntax-only parsing instead of failing the MCP call.
384 if (!warnedSemanticFallback) {
385 warnedSemanticFallback = true;
386 console.warn(
387 `[executor] TypeScript semantic typecheck unavailable, falling back to syntax-only checks: ${error instanceof Error ? error.message : String(error)}`,
388 );
389 }
390
391 return runSyntaxOnlyTypecheck(ts, wrappedCode, headerLineCount, formatError);
392 }431 }
432
433 return runSyntaxOnlyTypecheck(ts, wrappedCode, headerLineCount, formatError);
393}434}
executor/package.json+1−0
@@ -14,6 +14,7 @@ · 13 unmodified lines
14 "build:release": "bun run scripts/build-release.ts"14 "build:release": "bun run scripts/build-release.ts"
15 },15 },
16 "dependencies": {16 "dependencies": {
17 "better-result": "^2.7.0",
17 "convex": "^1.31.7",18 "convex": "^1.31.7",
18 "graphql": "^16.12.0"19 "graphql": "^16.12.0"
19 }20 }