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
Unified · Split
| @@ -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 | } |
| @@ -1,5 +1,6 @@ | |||
| 1 | "use node"; | 1 | "use node"; |
| 2 | 2 | ||
| 3 | import { Result } from "better-result"; | ||
| 3 | import type { SandboxExecutionRequest, SandboxExecutionResult } from "../types"; | 4 | import type { SandboxExecutionRequest, SandboxExecutionResult } from "../types"; |
| 4 | import { getCloudflareWorkerLoaderConfig } from "./runtime_catalog"; | 5 | import { getCloudflareWorkerLoaderConfig } from "./runtime_catalog"; |
| 5 | import { transpileForRuntime } from "./transpile"; | 6 | import { 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 any | 56 | 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 | ||
| 128 | function mapStatus( | 135 | function mapStatus( |
| @@ -5,6 +5,7 @@ · 4 unmodified lines | |||
| 5 | // the core helpers here (formatArgs, createToolsProxy, console proxy, execution | 5 | // 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. |
| 7 | import { APPROVAL_DENIED_PREFIX, TASK_TIMEOUT_MARKER } from "../execution_constants"; | 7 | import { APPROVAL_DENIED_PREFIX, TASK_TIMEOUT_MARKER } from "../execution_constants"; |
| 8 | import { Result } from "better-result"; | ||
| 8 | import { Script, createContext } from "node:vm"; | 9 | import { Script, createContext } from "node:vm"; |
| 9 | import type { | 10 | import type { |
| 10 | ExecutionAdapter, | 11 | ExecutionAdapter, |
| @@ -17,11 +18,7 @@ function formatArgs(args: unknown[]): string { · 6 unmodified lines | |||
| 17 | return args | 18 | 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. */ | ||
| 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 | |||
| 74 | export async function runCodeWithAdapter( | 95 | export 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 | } |
| @@ -1,38 +1,78 @@ | |||
| 1 | "use node"; | 1 | "use node"; |
| 2 | 2 | ||
| 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 | |||
| 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-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 | ||
| 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 | */ |
| 12 | export async function transpileForRuntime(code: string): Promise<string> { | 41 | export 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 | } |
| @@ -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 | ||
| 16 | import { Result } from "better-result"; | ||
| 16 | import type { ToolDescriptor } from "./types"; | 17 | import type { ToolDescriptor } from "./types"; |
| 17 | 18 | ||
| 18 | // --------------------------------------------------------------------------- | 19 | // --------------------------------------------------------------------------- |
| @@ -23,11 +24,8 @@ let cachedTypeScript: typeof import("typescript") | null | undefined; · 4 unmodified lines | |||
| 23 | 24 | ||
| 24 | function getTypeScriptModule(): typeof import("typescript") | null { | 25 | function 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 | ||
| 59 | function safeTypeExpression(raw: string | undefined, fallback: string): string { | 55 | function 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 | ||
| 65 | const OPENAPI_HELPER_TYPES = ` | 61 | 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>; | ||
| 67 | type _Simplify<T> = { [K in keyof T]: T[K] } & {}; | 64 | 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; | ||
| 68 | type ToolInput<Op> = _Simplify< | 79 | type 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 | >; |
| 73 | type ToolOutput<Op> = | 86 | type _ResponsesOf<Op> = Op extends { responses: infer R } ? R : never; |
| 74 | Op extends { responses: { 200: { content: { "application/json": infer R } } } } ? R : | 87 | type _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 : | 89 | type _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; | ||
| 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 | ]>; | ||
| 80 | `; | 118 | `; |
| 81 | 119 | ||
| 82 | function stripExportKeywordsForTypechecker(dts: string): string { | 120 | function 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 | ||
| 274 | const TYPECHECK_OK: TypecheckResult = { ok: true, errors: [] }; | ||
| 275 | |||
| 236 | let warnedMissingCompilerHostSupport = false; | 276 | let warnedMissingCompilerHostSupport = false; |
| 237 | let warnedSemanticFallback = false; | 277 | let 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 | |
| 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 | }); | ||
| 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 since | 381 | // 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 | } |
| @@ -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 | } |