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
55 "": {
66 "name": "executor",
77 "dependencies": {
8+ "better-result": "^2.7.0",
89 "convex": "^1.31.7",
910 "graphql": "^16.12.0",
1011 },
1112 },
1213 },
1314 "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+
1419 "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.27.0", "", { "os": "aix", "cpu": "ppc64" }, "sha512-KuZrd2hRjz01y5JK9mEBSD3Vj3mbCvemhT466rSuJYeE/hjuBrHfjjcjMdTm/sz7au+++sdbJZJmuBwQLuw68A=="],
1520
1621 "@esbuild/android-arm": ["@esbuild/android-arm@0.27.0", "", { "os": "android", "cpu": "arm" }, "sha512-j67aezrPNYWJEOHUNLPj9maeJte7uSMM6gMoxfPC9hOg8N02JuQi/T7ewumf4tNvJadFkvLZMlAq73b9uwdMyQ=="],
@@ -63,12 +68,18 @@ · 46 unmodified lines
6368
6469 "@esbuild/win32-x64": ["@esbuild/win32-x64@0.27.0", "", { "os": "win32", "cpu": "x64" }, "sha512-aIitBcjQeyOhMTImhLZmtxfdOcuNRpwlPNmlFKPcHQYPhEssw75Cl1TSXJXpMkzaua9FUetx/4OQKq7eJul5Cg=="],
6570
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+
6673 "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=="],
6774
6875 "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=="],
6976
7077 "graphql": ["graphql@16.12.0", "", {}, "sha512-DKKrynuQRne0PNpEbzuEdHlYOMksHSUI8Zc9Unei5gTsMNA2/vMpoMz/yKba50pejK56qj98qM0SjYxAKi13gQ=="],
7178
79+ "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
80+
7281 "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=="],
7384 }
7485 }
executor/lib/runtimes/cloudflare_worker_loader_runtime.ts+66−59
@@ -1,5 +1,6 @@
11 "use node";
22
3+import { Result } from "better-result";
34 import type { SandboxExecutionRequest, SandboxExecutionResult } from "../types";
45 import { getCloudflareWorkerLoaderConfig } from "./runtime_catalog";
56 import { transpileForRuntime } from "./transpile";
@@ -38,19 +39,30 @@ export async function runCodeWithCloudflareWorkerLoader( · 32 unmodified lines
3839 const config = getCloudflareWorkerLoaderConfig();
3940 const startedAt = Date.now();
4041
41− const controller = new AbortController();
42− const timeout = setTimeout(
43− () => controller.abort(),
44− config.requestTimeoutMs,
45− );
42+ const mkResult = (
43+ status: SandboxExecutionResult["status"],
44+ opts?: { stdout?: string; stderr?: string; error?: string; exitCode?: number },
45+ ): SandboxExecutionResult => ({
46+ status,
47+ stdout: opts?.stdout ?? "",
48+ stderr: opts?.stderr ?? "",
49+ exitCode: opts?.exitCode,
50+ error: opts?.error,
51+ durationMs: Date.now() - startedAt,
52+ });
4653
47− try {
48− // Transpile TS → JS on the Convex side before sending to the CF isolate.
49− // The dynamic isolate runs the code as plain JS (harness.js), so any
50− // TypeScript syntax must be stripped first.
51− const code = await transpileForRuntime(request.code);
54+ // ── Transpile TS → JS on the Convex side ─────────────────────────────
55+ const transpiled = transpileForRuntime(request.code);
56+ if (transpiled.isErr()) {
57+ return mkResult("failed", { error: transpiled.error.message });
58+ }
5259
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, {
5466 method: "POST",
5567 headers: {
5668 "Content-Type": "application/json",
@@ -58,71 +70,66 @@ export async function runCodeWithCloudflareWorkerLoader( · 1 unmodified line
5870 },
5971 body: JSON.stringify({
6072 taskId: request.taskId,
61− code,
73+ code: transpiled.value,
6274 timeoutMs: request.timeoutMs,
63− // The host Worker needs these to call back to Convex for tool
64− // invocations and output streaming.
6575 callback: {
6676 baseUrl: config.callbackBaseUrl,
6777 authToken: config.callbackAuthToken,
6878 },
6979 }),
7080 signal: controller.signal,
71− });
81+ }),
82+ );
7283
73− if (!response.ok) {
74− const text = await response.text().catch(() => response.statusText);
75− return {
76− status: "failed",
77− stdout: "",
78− stderr: text,
79− error: `Cloudflare sandbox returned ${response.status}: ${text}`,
80− durationMs: Date.now() - startedAt,
81− };
84+ clearTimeout(timeout);
85+
86+ if (response.isErr()) {
87+ const cause = response.error.cause;
88+ const isAbort = cause instanceof DOMException && cause.name === "AbortError";
89+ if (isAbort) {
90+ return mkResult("timed_out", {
91+ error: `Cloudflare sandbox request timed out after ${config.requestTimeoutMs}ms`,
92+ });
8293 }
94+ const message = cause instanceof Error ? cause.message : String(cause);
95+ return mkResult("failed", {
96+ error: `Cloudflare sandbox request failed: ${message}`,
97+ });
98+ }
8399
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<{
85113 status?: string;
86114 stdout?: string;
87115 stderr?: string;
88116 error?: string;
89117 exitCode?: number;
90− };
91−
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− }
118+ }>,
119+ );
115120
116− return {
117− status: "failed",
118− stdout: "",
119− stderr: "",
120− error: `Cloudflare sandbox request failed: ${message}`,
121− durationMs: Date.now() - startedAt,
122− };
123− } finally {
124− clearTimeout(timeout);
121+ if (body.isErr()) {
122+ return mkResult("failed", {
123+ error: `Cloudflare sandbox returned invalid JSON`,
124+ });
125125 }
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+ });
126133 }
127134
128135 function mapStatus(
executor/lib/runtimes/runtime_core.ts+72−60
@@ -5,6 +5,7 @@ · 4 unmodified lines
55 // the core helpers here (formatArgs, createToolsProxy, console proxy, execution
66 // loop, result mapping) should be mirrored there.
77 import { APPROVAL_DENIED_PREFIX, TASK_TIMEOUT_MARKER } from "../execution_constants";
8+import { Result } from "better-result";
89 import { Script, createContext } from "node:vm";
910 import type {
1011 ExecutionAdapter,
@@ -17,11 +18,7 @@ function formatArgs(args: unknown[]): string { · 6 unmodified lines
1718 return args
1819 .map((value) => {
1920 if (typeof value === "string") return value;
20− try {
21− return JSON.stringify(value);
22− } catch {
23− return String(value);
24− }
21+ return Result.try(() => JSON.stringify(value)).unwrapOr(String(value));
2522 })
2623 .join(" ");
2724 }
@@ -71,6 +68,30 @@ function createToolsProxy( · 43 unmodified lines
7168 });
7269 }
7370
71+/** Classify a caught error from VM execution into a result status. */
72+function 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+
7495 export async function runCodeWithAdapter(
7596 request: SandboxExecutionRequest,
7697 adapter: ExecutionAdapter,
@@ -103,6 +124,26 @@ export async function runCodeWithAdapter( · 26 unmodified lines
103124 );
104125 };
105126
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 ──────────────────────────────────────────────────────
106147 const tools = createToolsProxy(adapter, request.taskId);
107148 const consoleProxy = {
108149 log: (...args: unknown[]) => appendStdout(formatArgs(args)),
@@ -118,15 +159,14 @@ export async function runCodeWithAdapter( · 9 unmodified lines
118159 clearTimeout,
119160 });
120161 const context = createContext(sandbox, {
121− codeGeneration: {
122− strings: false,
123− wasm: false,
124− },
162+ codeGeneration: { strings: false, wasm: false },
125163 });
126164
127− const executableCode = await transpileForRuntime(request.code);
128− const runnerScript = new Script(`(async () => {\n"use strict";\n${executableCode}\n})()`);
165+ const runnerScript = new Script(
166+ `(async () => {\n"use strict";\n${transpiled.value}\n})()`,
167+ );
129168
169+ // ── Execute with timeout ───────────────────────────────────────────────
130170 let timeoutHandle: ReturnType<typeof setTimeout> | undefined;
131171 const timeoutPromise = new Promise<never>((_resolve, reject) => {
132172 timeoutHandle = setTimeout(() => {
@@ -134,60 +174,32 @@ export async function runCodeWithAdapter( · 1 unmodified line
134174 }, request.timeoutMs);
135175 });
136176
137− try {
177+ const execution = await Result.tryPromise(async () => {
138178 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+ ),
140184 timeoutPromise,
141185 ]);
186+ return value;
187+ });
142188
143− if (value !== undefined) {
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− }
189+ if (timeoutHandle) clearTimeout(timeoutHandle);
179190
191+ if (execution.isErr()) {
192+ const { status, message } = classifyExecutionError(
193+ execution.error.cause,
194+ request,
195+ );
180196 appendStderr(message);
181− return {
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− }
197+ return mkResult(status, { error: message });
192198 }
199+
200+ if (execution.value !== undefined) {
201+ appendStdout(`result: ${formatArgs([execution.value])}`);
202+ }
203+
204+ return mkResult("completed", { exitCode: 0 });
193205 }
executor/lib/runtimes/transpile.ts+61−21
@@ -1,38 +1,78 @@
11 "use node";
22
3+import { Result, TaggedError } from "better-result";
4+
5+// ---------------------------------------------------------------------------
6+// Errors
7+// ---------------------------------------------------------------------------
8+
9+export class TranspileError extends TaggedError("TranspileError")<{
10+ message: string;
11+}>() {}
12+
13+// ---------------------------------------------------------------------------
14+// TypeScript module loader
15+// ---------------------------------------------------------------------------
16+
17+let cachedTypeScript: typeof import("typescript") | null | undefined;
18+
19+function 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+
331 /**
432 * Transpile TypeScript code to JavaScript using the `typescript` module.
533 *
6− * If the `typescript` module is not available, the code is returned as-is
34+ * 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
736 * (graceful fallback for environments where TS isn't installed).
837 *
938 * Targets ES2022/ESNext so the output can run in modern runtimes (node:vm,
1039 * Cloudflare Workers isolates, etc.) without further downlevelling.
1140 */
12−export async function transpileForRuntime(code: string): Promise<string> {
13− let ts: typeof import("typescript");
14− try {
15− ts = require("typescript");
16− } catch {
17− return code;
18− }
41+export function transpileForRuntime(
42+ code: string,
43+): Result<string, TranspileError> {
44+ const ts = getTypeScriptModule();
45+ if (!ts) return Result.ok(code);
1946
2047 const target = ts.ScriptTarget?.ES2022 ?? ts.ScriptTarget?.ESNext;
2148 const moduleKind = ts.ModuleKind?.ESNext;
2249
23− const result = ts.transpileModule(code, {
24− compilerOptions: {
25− ...(target !== undefined ? { target } : {}),
26− ...(moduleKind !== undefined ? { module: moduleKind } : {}),
27− },
28− reportDiagnostics: true,
29− });
50+ return Result.try({
51+ try: () => {
52+ const result = ts.transpileModule(code, {
53+ compilerOptions: {
54+ ...(target !== undefined ? { target } : {}),
55+ ...(moduleKind !== undefined ? { module: moduleKind } : {}),
56+ },
57+ reportDiagnostics: true,
58+ });
3059
31− if (result.diagnostics && result.diagnostics.length > 0) {
32− const first = result.diagnostics[0];
33− const message = ts.flattenDiagnosticMessageText(first.messageText, "\n");
34− throw new Error(`TypeScript transpile error: ${message}`);
35− }
60+ if (result.diagnostics && result.diagnostics.length > 0) {
61+ const first = result.diagnostics[0];
62+ const message = ts.flattenDiagnosticMessageText(
63+ first.messageText,
64+ "\n",
65+ );
66+ throw new TranspileError({ message: `TypeScript transpile error: ${message}` });
67+ }
3668
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+ });
3878 }
executor/lib/typechecker.ts+139−98
@@ -13,6 +13,7 @@ · 12 unmodified lines
1313 * without needing their own TypeScript setup.
1414 */
1515
16+import { Result } from "better-result";
1617 import type { ToolDescriptor } from "./types";
1718
1819 // ---------------------------------------------------------------------------
@@ -23,11 +24,8 @@ let cachedTypeScript: typeof import("typescript") | null | undefined; · 4 unmodified lines
2324
2425 function getTypeScriptModule(): typeof import("typescript") | null {
2526 if (cachedTypeScript === undefined) {
26− try {
27− cachedTypeScript = require("typescript");
28− } catch {
29− cachedTypeScript = null;
30− }
27+ const loaded = Result.try(() => require("typescript") as typeof import("typescript"));
28+ cachedTypeScript = loaded.isOk() ? loaded.value : null;
3129 }
3230 return cachedTypeScript ?? null;
3331 }
@@ -39,7 +37,7 @@ function isValidTypeExpression(typeExpression: string): boolean { · 5 unmodified lines
3937 return !/[\r\n`]/.test(typeExpression);
4038 }
4139
42− try {
40+ return Result.try(() => {
4341 const sourceFile = ts.createSourceFile(
4442 "_type_expr_check.ts",
4543 `type __T = ${typeExpression};`,
@@ -51,9 +49,7 @@ function isValidTypeExpression(typeExpression: string): boolean { · 5 unmodified lines
5149 sourceFile as unknown as { parseDiagnostics?: import("typescript").Diagnostic[] }
5250 ).parseDiagnostics ?? [];
5351 return diagnostics.length === 0;
54− } catch {
55− return false;
56− }
52+ }).unwrapOr(false);
5753 }
5854
5955 function safeTypeExpression(raw: string | undefined, fallback: string): string {
@@ -63,20 +59,62 @@ function safeTypeExpression(raw: string | undefined, fallback: string): string { · 3 unmodified lines
6359 }
6460
6561 const OPENAPI_HELPER_TYPES = `
66−type _OrEmpty<T> = [T] extends [never] ? {} : T;
62+type _Normalize<T> = Exclude<T, undefined | null>;
63+type _OrEmpty<T> = [_Normalize<T>] extends [never] ? {} : _Normalize<T>;
6764 type _Simplify<T> = { [K in keyof T]: T[K] } & {};
65+type _ParamsOf<Op> =
66+ Op extends { parameters: infer P } ? P :
67+ Op extends { parameters?: infer P } ? P :
68+ never;
69+type _ParamAt<Op, K extends "query" | "path" | "header" | "cookie"> =
70+ _ParamsOf<Op> extends { [P in K]?: infer V } ? V : never;
71+type _BodyOf<Op> =
72+ Op extends { requestBody?: infer B } ? B :
73+ Op extends { requestBody: infer B } ? B :
74+ never;
75+type _BodyContent<B> =
76+ B extends { content: infer C }
77+ ? C extends Record<string, infer V> ? V : never
78+ : never;
6879 type ToolInput<Op> = _Simplify<
69− _OrEmpty<Op extends { parameters: { query: infer Q } } ? { [K in keyof Q]: Q[K] } : never> &
70− _OrEmpty<Op extends { parameters: { path: infer P } } ? { [K in keyof P]: P[K] } : never> &
71− _OrEmpty<Op extends { requestBody: { content: { "application/json": infer B } } } ? B : never>
80+ _OrEmpty<_ParamAt<Op, "query">> &
81+ _OrEmpty<_ParamAt<Op, "path">> &
82+ _OrEmpty<_ParamAt<Op, "header">> &
83+ _OrEmpty<_ParamAt<Op, "cookie">> &
84+ _OrEmpty<_BodyContent<_BodyOf<Op>>>
7285 >;
73−type ToolOutput<Op> =
74− Op extends { responses: { 200: { content: { "application/json": infer R } } } } ? R :
75− Op extends { responses: { 201: { content: { "application/json": infer R } } } } ? R :
76− Op extends { responses: { 202: { content: { "application/json": infer R } } } } ? R :
77− Op extends { responses: { 204: unknown } } ? void :
78− Op extends { responses: { 205: unknown } } ? void :
79− unknown;
86+type _ResponsesOf<Op> = Op extends { responses: infer R } ? R : never;
87+type _RespAt<Op, Code extends PropertyKey> =
88+ _ResponsesOf<Op> extends { [K in Code]?: infer R } ? R : never;
89+type _ResponsePayload<R> =
90+ [R] extends [never] ? never :
91+ R extends { content: infer C }
92+ ? C extends Record<string, infer V> ? V : unknown
93+ : R extends { schema: infer S } ? S : unknown;
94+type _HasStatus<Op, Code extends PropertyKey> =
95+ [_RespAt<Op, Code>] extends [never] ? false : true;
96+type _PayloadAt<Op, Code extends PropertyKey> =
97+ Code extends 204 | 205
98+ ? (_HasStatus<Op, Code> extends true ? void : never)
99+ : _ResponsePayload<_RespAt<Op, Code>>;
100+type _FirstKnown<T extends readonly unknown[]> =
101+ T extends readonly [infer H, ...infer Rest]
102+ ? [H] extends [never] ? _FirstKnown<Rest> : H
103+ : unknown;
104+type 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+]>;
80118 `;
81119
82120 function stripExportKeywordsForTypechecker(dts: string): string {
@@ -233,6 +271,8 @@ export interface TypecheckResult { · 150 unmodified lines
233271 readonly errors: readonly string[];
234272 }
235273
274+const TYPECHECK_OK: TypecheckResult = { ok: true, errors: [] };
275+
236276 let warnedMissingCompilerHostSupport = false;
237277 let warnedSemanticFallback = false;
238278
@@ -245,7 +285,7 @@ function runSyntaxOnlyTypecheck( · 6 unmodified lines
245285 headerLineCount: number,
246286 ) => string,
247287 ): TypecheckResult {
248− try {
288+ return Result.try(() => {
249289 const sourceFile = ts.createSourceFile(
250290 "generated.ts",
251291 wrappedCode,
@@ -256,16 +296,72 @@ function runSyntaxOnlyTypecheck( · 4 unmodified lines
256296 const diagnostics = (
257297 sourceFile as unknown as { parseDiagnostics?: import("typescript").Diagnostic[] }
258298 ).parseDiagnostics ?? [];
259− if (diagnostics.length === 0) {
260− return { ok: true, errors: [] };
261− }
299+ if (diagnostics.length === 0) return TYPECHECK_OK;
262300 return {
263− ok: false,
264− errors: diagnostics.map((d: import("typescript").Diagnostic) => formatError(d, headerLineCount)),
301+ ok: false as const,
302+ errors: diagnostics.map((d) => formatError(d, headerLineCount)),
265303 };
266− } catch {
267− return { ok: true, errors: [] };
268− }
304+ }).unwrapOr(TYPECHECK_OK);
305+}
306+
307+function 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+ });
269365 }
270366
271367 /**
@@ -278,13 +374,8 @@ export function typecheckCode( · 6 unmodified lines
278374 code: string,
279375 toolDeclarations: string,
280376 ): TypecheckResult {
281− let ts: typeof import("typescript");
282− try {
283− ts = require("typescript");
284− } catch {
285− // TypeScript not available — skip typechecking
286− return { ok: true, errors: [] };
287− }
377+ const ts = getTypeScriptModule();
378+ if (!ts) return TYPECHECK_OK;
288379
289380 // Wrap the code in an async function with the tools declaration.
290381 // We declare sandbox globals (console, setTimeout, etc.) ourselves since
@@ -301,12 +392,12 @@ export function typecheckCode( · 10 unmodified lines
301392
302393 const formatError = (
303394 diagnostic: import("typescript").Diagnostic,
304− headerLineCount: number,
395+ hdrLineCount: number,
305396 ): string => {
306397 const message = ts.flattenDiagnosticMessageText(diagnostic.messageText, "\n");
307398 if (diagnostic.start !== undefined && diagnostic.file) {
308399 const { line } = diagnostic.file.getLineAndCharacterOfPosition(diagnostic.start);
309− const adjustedLine = line + 1 - headerLineCount;
400+ const adjustedLine = line + 1 - hdrLineCount;
310401 if (adjustedLine > 0) {
311402 return `Line ${adjustedLine}: ${message}`;
312403 }
@@ -328,66 +419,16 @@ export function typecheckCode( · 15 unmodified lines
328419 return runSyntaxOnlyTypecheck(ts, wrappedCode, headerLineCount, formatError);
329420 }
330421
331− try {
332− const sourceFile = ts.createSourceFile(
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− });
422+ const semantic = runSemanticTypecheck(ts, wrappedCode, headerLineCount, formatError);
423+ if (semantic.isOk()) return semantic.value;
371424
372− if (userErrors.length === 0) {
373− return { ok: true, errors: [] };
374− }
375−
376− return {
377− ok: false,
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);
425+ // Semantic typechecking failed to initialize — fall back to syntax-only.
426+ if (!warnedSemanticFallback) {
427+ warnedSemanticFallback = true;
428+ console.warn(
429+ `[executor] TypeScript semantic typecheck unavailable, falling back to syntax-only checks: ${semantic.error.message}`,
430+ );
392431 }
432+
433+ return runSyntaxOnlyTypecheck(ts, wrappedCode, headerLineCount, formatError);
393434 }
executor/package.json+1−0
@@ -14,6 +14,7 @@ · 13 unmodified lines
1414 "build:release": "bun run scripts/build-release.ts"
1515 },
1616 "dependencies": {
17+ "better-result": "^2.7.0",
1718 "convex": "^1.31.7",
1819 "graphql": "^16.12.0"
1920 }