History

Add TypeScript native preview support and enhance task management

- Introduced `@typescript/native-preview` dependency for improved TypeScript handling. - Updated package.json to include new TypeScript native preview dependency. - Enhanced task management by adding `scheduleAfterCreate` option in task creation, allowing for more flexible task scheduling. - Implemented caching for typecheck declarations to optimize performance and reduce redundant processing. - Refactored task runner to handle terminal task results directly, improving response handling in Cloudflare Workers. - Added tests for new typecheck functionalities and tool reference analysis to ensure reliability.

Rhys Sullivan committed 9 months ago

Commit c0afbbd1 parent 8e4b94e21 files · +1294 −167Browse files

Unified · Split

21 files
  1. executor/bun.lock +17 −0
  2. executor/package.json +7 −6
  3. executor/packages/convex/_generated/api.d.ts +27 −0
  4. executor/packages/convex/accounts.ts +9 −0
  5. executor/packages/convex/executor.ts +7 −3
  6. executor/packages/convex/executor_node/task_runner.ts +26 −3
  7. executor/packages/convex/http/mcp_service.ts +4 −0
  8. executor/packages/convex/mcpNode.ts +247 −9
  9. executor/packages/convex/schema.ts +10 −0
  10. executor/packages/convex/typecheckDeclarationCache.test.ts +188 −0
  11. executor/packages/convex/typecheckDeclarationCache.ts +76 −0
  12. executor/packages/core/src/mcp-gateway-oauth-sdk.test.ts +1 −1
  13. executor/packages/core/src/mcp-server.test.ts +1 −1
  14. executor/packages/core/src/mcp-server.ts +56 −39
  15. executor/packages/core/src/runtimes/cloudflare-worker-loader-runtime.test.ts +9 −9
  16. executor/packages/core/src/runtimes/cloudflare-worker-loader-runtime.ts +26 −18
  17. executor/packages/core/src/runtimes/runtime-catalog.ts +1 −1
  18. executor/packages/core/src/runtimes/runtime-core.ts +19 −1
  19. executor/packages/core/src/typechecker.test.ts +95 −1
  20. executor/packages/core/src/typechecker.ts +457 −8
  21. executor/packages/runner-sandbox-host/src/index.ts +11 −67
executor/bun.lock+17−0
@@ -5,6 +5,7 @@ · 4 unmodified lines
5 "": {5 "": {
6 "name": "executor",6 "name": "executor",
7 "dependencies": {7 "dependencies": {
8 "@typescript/native-preview": "^7.0.0-dev.20260212.1",
8 "better-result": "^2.7.0",9 "better-result": "^2.7.0",
9 "convex": "^1.31.7",10 "convex": "^1.31.7",
10 "graphql": "^16.12.0",11 "graphql": "^16.12.0",
@@ -68,6 +69,22 @@ · 57 unmodified lines
68 69
69 "@esbuild/win32-x64": ["@esbuild/win32-x64@0.27.0", "", { "os": "win32", "cpu": "x64" }, "sha512-aIitBcjQeyOhMTImhLZmtxfdOcuNRpwlPNmlFKPcHQYPhEssw75Cl1TSXJXpMkzaua9FUetx/4OQKq7eJul5Cg=="],70 "@esbuild/win32-x64": ["@esbuild/win32-x64@0.27.0", "", { "os": "win32", "cpu": "x64" }, "sha512-aIitBcjQeyOhMTImhLZmtxfdOcuNRpwlPNmlFKPcHQYPhEssw75Cl1TSXJXpMkzaua9FUetx/4OQKq7eJul5Cg=="],
70 71
72 "@typescript/native-preview": ["@typescript/native-preview@7.0.0-dev.20260212.1", "", { "optionalDependencies": { "@typescript/native-preview-darwin-arm64": "7.0.0-dev.20260212.1", "@typescript/native-preview-darwin-x64": "7.0.0-dev.20260212.1", "@typescript/native-preview-linux-arm": "7.0.0-dev.20260212.1", "@typescript/native-preview-linux-arm64": "7.0.0-dev.20260212.1", "@typescript/native-preview-linux-x64": "7.0.0-dev.20260212.1", "@typescript/native-preview-win32-arm64": "7.0.0-dev.20260212.1", "@typescript/native-preview-win32-x64": "7.0.0-dev.20260212.1" }, "bin": { "tsgo": "bin/tsgo.js" } }, "sha512-VHAVbp8d2VGm90EK//brKIYvT3iPrLXMq4/LApCdkKww/Hfn33zPRVmig4rswNaJiVu8XhcdHld5yfMw6d5A9Q=="],
73
74 "@typescript/native-preview-darwin-arm64": ["@typescript/native-preview-darwin-arm64@7.0.0-dev.20260212.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-HH4bOVbNW6ITv00VSaE3aZjCuU2d+amgFZKdhbq7NpcJDxFvxyy9GT9gkKV0D1DXz5qoxZIcyBEIbwrhABb9vg=="],
75
76 "@typescript/native-preview-darwin-x64": ["@typescript/native-preview-darwin-x64@7.0.0-dev.20260212.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-vnQ2xRJscbtyS/jHO5QY2xAZ3c11Yn1ZAor/XODDrxd7N7jIrm0Vtc2CIwsi51oncLS1SZtUd9cHZmJg5zUJrQ=="],
77
78 "@typescript/native-preview-linux-arm": ["@typescript/native-preview-linux-arm@7.0.0-dev.20260212.1", "", { "os": "linux", "cpu": "arm" }, "sha512-T8sF3YtYtODhWnFNhVuL/GABCHpKJs6ZxTtSC1LtXoM/CE0Ai06k5WKOxJG5rJrBtLIW+Dempk7qKPfhNliDTA=="],
79
80 "@typescript/native-preview-linux-arm64": ["@typescript/native-preview-linux-arm64@7.0.0-dev.20260212.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-suA5OryrhL/tE7AiQXiNNV88XwKEOfO0sypJQj+cfg/fpQ2trFyDZcsdMLYVZ7J0zirDai6H3TDETYYoNFE1/g=="],
81
82 "@typescript/native-preview-linux-x64": ["@typescript/native-preview-linux-x64@7.0.0-dev.20260212.1", "", { "os": "linux", "cpu": "x64" }, "sha512-w687rpZKJM0Lev0ya0GYJlnFCITTUmN8jDpwLXn60jrNEZzL2J4F7biA6papr2sMdKRfWvRklhjB1TKHbJ6FKA=="],
83
84 "@typescript/native-preview-win32-arm64": ["@typescript/native-preview-win32-arm64@7.0.0-dev.20260212.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-NhCXPQF6OTNEZl8iwRE1ef/zHiqit5p3m7hdT2vfAOi1iA2eoazX0zTSdhgjX83o9cLjen3V1R7nbSYehFHaqw=="],
85
86 "@typescript/native-preview-win32-x64": ["@typescript/native-preview-win32-x64@7.0.0-dev.20260212.1", "", { "os": "win32", "cpu": "x64" }, "sha512-0yqSBlASRx9rqM12QvaWc227w+bIsuI2EwAiNsoB1ybRbCXoXMah1RQlfjjTpD02eWCe/029vwrNhq+FLn7Z8A=="],
87
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=="],88 "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 89
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=="],90 "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=="],
executor/package.json+7−6
@@ -1,7 +1,12 @@
1{1{
2 "name": "executor",2 "name": "executor",
3 "dependencies": {
4 "@typescript/native-preview": "^7.0.0-dev.20260212.1",
5 "better-result": "^2.7.0",
6 "convex": "^1.31.7",
7 "graphql": "^16.12.0"
8 },
3 "private": true,9 "private": true,
4 "type": "module",
5 "scripts": {10 "scripts": {
6 "doctor": "bun run executor.ts doctor",11 "doctor": "bun run executor.ts doctor",
7 "doctor:prod": "bun run scripts/doctor-prod.ts",12 "doctor:prod": "bun run scripts/doctor-prod.ts",
@@ -22,9 +27,5 @@ · 14 unmodified lines
22 "build:binary": "bun build executor.ts --compile --outfile dist/executor",27 "build:binary": "bun build executor.ts --compile --outfile dist/executor",
23 "build:release": "bun run scripts/build-release.ts"28 "build:release": "bun run scripts/build-release.ts"
24 },29 },
25 "dependencies": {30 "type": "module"
26 "better-result": "^2.7.0",
27 "convex": "^1.31.7",
28 "graphql": "^16.12.0"
29 }
30}31}
executor/packages/convex/_generated/api.d.ts+27−0
@@ -332,6 +332,12 @@ export declare const api: { · 331 unmodified lines
332 },332 },
333 any333 any
334 >;334 >;
335 getApprovalStatus: FunctionReference<
336 "query",
337 "public",
338 { approvalId: string; internalSecret: string; runId: string },
339 any
340 >;
335 handleToolCall: FunctionReference<341 handleToolCall: FunctionReference<
336 "action",342 "action",
337 "public",343 "public",
@@ -871,6 +877,7 @@ export declare const internal: { · 533 unmodified lines
871 code: string;877 code: string;
872 metadata?: any;878 metadata?: any;
873 runtimeId?: string;879 runtimeId?: string;
880 scheduleAfterCreate?: boolean;
874 timeoutMs?: number;881 timeoutMs?: number;
875 workspaceId: Id<"workspaces">;882 workspaceId: Id<"workspaces">;
876 },883 },
@@ -1105,6 +1112,26 @@ export declare const internal: { · 228 unmodified lines
1105 any1112 any
1106 >;1113 >;
1107 };1114 };
1115 typecheckDeclarationCache: {
1116 getEntry: FunctionReference<
1117 "query",
1118 "internal",
1119 { cacheKey: string; maxAgeMs: number; workspaceId: Id<"workspaces"> },
1120 any
1121 >;
1122 putEntry: FunctionReference<
1123 "mutation",
1124 "internal",
1125 {
1126 cacheKey: string;
1127 maxEntriesPerWorkspace?: number;
1128 sizeBytes: number;
1129 storageId: Id<"_storage">;
1130 workspaceId: Id<"workspaces">;
1131 },
1132 any
1133 >;
1134 };
1108 workspaceAuthInternal: {1135 workspaceAuthInternal: {
1109 getWorkspaceAccessForRequest: FunctionReference<1136 getWorkspaceAccessForRequest: FunctionReference<
1110 "query",1137 "query",
executor/packages/convex/accounts.ts+9−0
@@ -72,6 +72,15 @@ async function deleteWorkspaceData( · 71 unmodified lines
72 await ctx.db.delete(cacheEntry._id);72 await ctx.db.delete(cacheEntry._id);
73 }73 }
74 74
75 const cachedDeclarations = await ctx.db
76 .query("typecheckDeclarationCache")
77 .withIndex("by_workspace_created", (q) => q.eq("workspaceId", workspaceId))
78 .collect();
79 for (const cacheEntry of cachedDeclarations) {
80 await ctx.storage.delete(cacheEntry.storageId).catch(() => {});
81 await ctx.db.delete(cacheEntry._id);
82 }
83
75 const workspaceMembers = await ctx.db84 const workspaceMembers = await ctx.db
76 .query("workspaceMembers")85 .query("workspaceMembers")
77 .withIndex("by_workspace", (q) => q.eq("workspaceId", workspaceId))86 .withIndex("by_workspace", (q) => q.eq("workspaceId", workspaceId))
executor/packages/convex/executor.ts+7−3
@@ -40,6 +40,7 @@ async function createTaskRecord( · 39 unmodified lines
40 workspaceId: Id<"workspaces">;40 workspaceId: Id<"workspaces">;
41 actorId: string;41 actorId: string;
42 clientId?: string;42 clientId?: string;
43 scheduleAfterCreate?: boolean;
43 },44 },
44): Promise<{ task: TaskRecord }> {45): Promise<{ task: TaskRecord }> {
45 if (!args.code.trim()) {46 if (!args.code.trim()) {
@@ -89,9 +90,11 @@ async function createTaskRecord( · 43 unmodified lines
89 },90 },
90 });91 });
91 92
92 await ctx.scheduler.runAfter(1, internal.executorNode.runTask, {93 if (args.scheduleAfterCreate ?? true) {
93 taskId,94 await ctx.scheduler.runAfter(1, internal.executorNode.runTask, {
94 });95 taskId,
96 });
97 }
95 98
96 return { task };99 return { task };
97}100}
@@ -182,6 +185,7 @@ export const createTaskInternal = internalMutation({ · 84 unmodified lines
182 workspaceId: v.id("workspaces"),185 workspaceId: v.id("workspaces"),
183 actorId: v.string(),186 actorId: v.string(),
184 clientId: v.optional(v.string()),187 clientId: v.optional(v.string()),
188 scheduleAfterCreate: v.optional(v.boolean()),
185 },189 },
186 handler: async (ctx, args): Promise<{ task: TaskRecord }> => {190 handler: async (ctx, args): Promise<{ task: TaskRecord }> => {
187 return await createTaskRecord(ctx, args);191 return await createTaskRecord(ctx, args);
executor/packages/convex/executor_node/task_runner.ts+26−3
@@ -98,11 +98,34 @@ export async function runQueuedTask( · 97 unmodified lines
98 return null;98 return null;
99 }99 }
100 100
101 await publishTaskEvent(ctx, args.taskId, "task", "task.dispatched", {101 const finished = await ctx.runMutation(internal.database.markTaskFinished as any, {
102 taskId: args.taskId,102 taskId: args.taskId,
103 runtimeId: running.runtimeId,103 status: dispatchResult.status,
104 dispatchId: dispatchResult.dispatchId,104 result: dispatchResult.result,
105 exitCode: dispatchResult.exitCode,
106 error: dispatchResult.error,
107 });
108
109 if (!finished) {
110 return null;
111 }
112
113 const terminalEvent =
114 dispatchResult.status === "completed"
115 ? "task.completed"
116 : dispatchResult.status === "timed_out"
117 ? "task.timed_out"
118 : dispatchResult.status === "denied"
119 ? "task.denied"
120 : "task.failed";
121
122 await publishTaskEvent(ctx, args.taskId, "task", terminalEvent, {
123 taskId: args.taskId,
124 status: finished.status,
125 exitCode: finished.exitCode,
105 durationMs: dispatchResult.durationMs,126 durationMs: dispatchResult.durationMs,
127 error: finished.error,
128 completedAt: finished.completedAt,
106 });129 });
107 return null;130 return null;
108 }131 }
executor/packages/convex/http/mcp_service.ts+4−0
@@ -21,8 +21,12 @@ export function createMcpExecutorService(ctx: ActionCtx) { · 20 unmodified lines
21 }) => {21 }) => {
22 return (await ctx.runMutation(internal.executor.createTaskInternal, {22 return (await ctx.runMutation(internal.executor.createTaskInternal, {
23 ...input,23 ...input,
24 scheduleAfterCreate: false,
24 })) as { task: TaskRecord };25 })) as { task: TaskRecord };
25 },26 },
27 runTaskNow: async (taskId: string) => {
28 return (await ctx.runAction(internal.executorNode.runTask, { taskId })) as null;
29 },
26 getTask: async (taskId: string, workspaceId?: Id<"workspaces">) => {30 getTask: async (taskId: string, workspaceId?: Id<"workspaces">) => {
27 if (workspaceId) {31 if (workspaceId) {
28 return (await ctx.runQuery(internal.database.getTaskInWorkspace, { taskId, workspaceId })) as TaskRecord | null;32 return (await ctx.runQuery(internal.database.getTaskInWorkspace, { taskId, workspaceId })) as TaskRecord | null;
executor/packages/convex/mcpNode.ts+247−9
@@ -1,11 +1,145 @@
1"use node";1"use node";
2 2
3import { createHash } from "node:crypto";
4import { createRequire } from "node:module";
3import { v } from "convex/values";5import { v } from "convex/values";
4import { internal } from "./_generated/api";6import { internal } from "./_generated/api";
5import { internalAction } from "./_generated/server";7import { internalAction } from "./_generated/server";
6import { loadSourceDtsByUrl } from "../core/src/dts-loader";8import { loadSourceDtsByUrl } from "../core/src/dts-loader";
7import { generateToolDeclarations, typecheckCode } from "../core/src/typechecker";9import {
10 analyzeToolReferences,
11 generateToolDeclarations,
12 sliceOpenApiOperationsDts,
13 typecheckCode,
14} from "../core/src/typechecker";
8import type { ToolDescriptor } from "../core/src/types";15import type { ToolDescriptor } from "../core/src/types";
16import { sourceSignature } from "./executor_node/tool_source_loading";
17
18if (typeof (globalThis as { require?: unknown }).require !== "function") {
19 (globalThis as { require?: unknown }).require = createRequire(import.meta.url);
20}
21
22const DECLARATION_CACHE_VERSION = "v1";
23const DECLARATION_CACHE_TTL_MS = 24 * 60 * 60_000;
24const DECLARATION_CACHE_MAX_ENTRIES_PER_WORKSPACE = 96;
25
26function hashString(input: string): string {
27 return createHash("sha256").update(input).digest("hex");
28}
29
30function normalizeEnginePreference(): string {
31 const value = process.env.EXECUTOR_TYPECHECK_ENGINE?.trim().toLowerCase();
32 if (!value) return "auto";
33 if (value === "typescript") return "typescript";
34 if (value === "tsgo") return "tsgo";
35 return value;
36}
37
38function toolFingerprint(tools: ToolDescriptor[]): string {
39 const compact = tools
40 .map((tool) => ({
41 path: tool.path,
42 approval: tool.approval,
43 source: tool.source ?? "",
44 argsType: tool.argsType ?? "",
45 returnsType: tool.returnsType ?? "",
46 strictArgsType: tool.strictArgsType ?? "",
47 strictReturnsType: tool.strictReturnsType ?? "",
48 operationId: tool.operationId ?? "",
49 argPreviewKeys: Array.isArray(tool.argPreviewKeys) ? [...tool.argPreviewKeys].sort() : [],
50 }))
51 .sort((a, b) => a.path.localeCompare(b.path));
52 return hashString(JSON.stringify(compact));
53}
54
55function declarationSelectionFingerprint(
56 selectiveMode: boolean,
57 operationIdsBySource: Map<string, Set<string>>,
58 sourcesRequiringFullDts: Set<string>,
59): string {
60 if (!selectiveMode) {
61 return "mode:full";
62 }
63
64 const parts: string[] = [];
65 for (const source of [...sourcesRequiringFullDts].sort((a, b) => a.localeCompare(b))) {
66 parts.push(`${source}:*`);
67 }
68 for (const [source, operationIds] of [...operationIdsBySource.entries()].sort(([a], [b]) => a.localeCompare(b))) {
69 parts.push(`${source}:${[...operationIds].sort((a, b) => a.localeCompare(b)).join(",")}`);
70 }
71
72 return parts.length > 0 ? parts.join("|") : "mode:selective:none";
73}
74
75function declarationCacheKey(input: {
76 workspaceSignature: string;
77 toolsFingerprint: string;
78 selectionFingerprint: string;
79 enginePreference: string;
80 hasDynamicToolAccess: boolean;
81 hasNonCallToolAccess: boolean;
82}): string {
83 const payload = [
84 DECLARATION_CACHE_VERSION,
85 input.workspaceSignature,
86 input.toolsFingerprint,
87 input.selectionFingerprint,
88 input.enginePreference,
89 input.hasDynamicToolAccess ? "dynamic:1" : "dynamic:0",
90 input.hasNonCallToolAccess ? "noncall:1" : "noncall:0",
91 ].join("|");
92 return `${DECLARATION_CACHE_VERSION}:${hashString(payload)}`;
93}
94
95function shouldUseSelectiveOpenApiDts(analysis: ReturnType<typeof analyzeToolReferences>): boolean {
96 return !analysis.hasDynamicToolAccess && !analysis.hasNonCallToolAccess;
97}
98
99function mapOperationIdsBySource(
100 tools: ToolDescriptor[],
101 callPaths: readonly string[],
102 availableDtsSources: ReadonlySet<string>,
103): {
104 operationIdsBySource: Map<string, Set<string>>;
105 sourcesRequiringFullDts: Set<string>;
106} {
107 const toolByPath = new Map<string, ToolDescriptor>(tools.map((tool) => [tool.path, tool]));
108 const operationIdsBySource = new Map<string, Set<string>>();
109 const sourcesRequiringFullDts = new Set<string>();
110
111 for (const path of callPaths) {
112 const descriptor = toolByPath.get(path);
113 if (!descriptor?.source || !availableDtsSources.has(descriptor.source)) continue;
114
115 if (!descriptor.operationId) {
116 sourcesRequiringFullDts.add(descriptor.source);
117 continue;
118 }
119
120 const operationIds = operationIdsBySource.get(descriptor.source) ?? new Set<string>();
121 operationIds.add(descriptor.operationId);
122 operationIdsBySource.set(descriptor.source, operationIds);
123 }
124
125 return { operationIdsBySource, sourcesRequiringFullDts };
126}
127
128function selectToolsForTypecheck(
129 tools: ToolDescriptor[],
130 analysis: ReturnType<typeof analyzeToolReferences>,
131): ToolDescriptor[] {
132 if (!shouldUseSelectiveOpenApiDts(analysis)) {
133 return tools;
134 }
135
136 if (analysis.callPaths.length === 0) {
137 return [];
138 }
139
140 const wanted = new Set(analysis.callPaths);
141 return tools.filter((tool) => wanted.has(tool.path));
142}
9 143
10export const typecheckRunCodeInternal = internalAction({144export const typecheckRunCodeInternal = internalAction({
11 args: {145 args: {
@@ -21,16 +155,120 @@ export const typecheckRunCodeInternal = internalAction({ · 9 unmodified lines
21 clientId: args.clientId,155 clientId: args.clientId,
22 };156 };
23 157
24 const result = await ctx.runAction(internal.executorNode.listToolsWithWarningsInternal, toolContext) as {158 const [result, sources] = await Promise.all([
25 tools: ToolDescriptor[];159 ctx.runAction(internal.executorNode.listToolsWithWarningsInternal, toolContext) as Promise<{
26 dtsUrls?: Record<string, string>;160 tools: ToolDescriptor[];
27 };161 dtsUrls?: Record<string, string>;
162 }>,
163 ctx.runQuery(internal.database.listToolSources, { workspaceId: args.workspaceId }) as Promise<Array<{
164 id: string;
165 updatedAt: number;
166 enabled: boolean;
167 }>>,
168 ]);
169
170 const enabledSources = sources.filter((source) => source.enabled);
171 const workspaceSignature = sourceSignature(args.workspaceId, enabledSources);
172
173 const dtsUrls = result.dtsUrls ?? {};
174 const dtsSources = new Set(Object.keys(dtsUrls));
175 const analysis = analyzeToolReferences(args.code);
176 const selectiveMode = shouldUseSelectiveOpenApiDts(analysis);
177 const toolsForTypecheck = selectToolsForTypecheck(result.tools, analysis);
178
179 let dtsUrlsToLoad: Record<string, string> = {};
180 let operationIdsBySource = new Map<string, Set<string>>();
181 let sourcesRequiringFullDts = new Set<string>();
182
183 if (selectiveMode) {
184 const mapped = mapOperationIdsBySource(result.tools, analysis.callPaths, dtsSources);
185 operationIdsBySource = mapped.operationIdsBySource;
186 sourcesRequiringFullDts = mapped.sourcesRequiringFullDts;
187
188 for (const source of mapped.sourcesRequiringFullDts) {
189 const url = dtsUrls[source];
190 if (url) {
191 dtsUrlsToLoad[source] = url;
192 }
193 }
194 for (const [source] of operationIdsBySource) {
195 const url = dtsUrls[source];
196 if (url) {
197 dtsUrlsToLoad[source] = url;
198 }
199 }
200 } else {
201 dtsUrlsToLoad = dtsUrls;
202 }
28 203
29 const sourceDtsBySource = await loadSourceDtsByUrl(result.dtsUrls ?? {});204 const cacheKey = declarationCacheKey({
30 const declarations = generateToolDeclarations(result.tools, {205 workspaceSignature,
31 sourceDtsBySource,206 toolsFingerprint: toolFingerprint(toolsForTypecheck),
207 selectionFingerprint: declarationSelectionFingerprint(
208 selectiveMode,
209 operationIdsBySource,
210 sourcesRequiringFullDts,
211 ),
212 enginePreference: normalizeEnginePreference(),
213 hasDynamicToolAccess: analysis.hasDynamicToolAccess,
214 hasNonCallToolAccess: analysis.hasNonCallToolAccess,
32 });215 });
33 const typecheck = typecheckCode(args.code, declarations);216
217 let declarations: string | null = null;
218
219 try {
220 const cacheEntry = await ctx.runQuery(internal.typecheckDeclarationCache.getEntry, {
221 workspaceId: args.workspaceId,
222 cacheKey,
223 maxAgeMs: DECLARATION_CACHE_TTL_MS,
224 });
225 if (cacheEntry) {
226 const blob = await ctx.storage.get(cacheEntry.storageId);
227 if (blob) {
228 declarations = await blob.text();
229 }
230 }
231 } catch (error) {
232 const message = error instanceof Error ? error.message : String(error);
233 console.warn(`[executor] declaration cache read failed for '${args.workspaceId}': ${message}`);
234 }
235
236 if (!declarations) {
237 const sourceDtsBySource = await loadSourceDtsByUrl(dtsUrlsToLoad);
238
239 if (selectiveMode) {
240 for (const [source, operationIds] of operationIdsBySource.entries()) {
241 const fullDts = sourceDtsBySource[source];
242 if (!fullDts) continue;
243 const sliced = sliceOpenApiOperationsDts(fullDts, operationIds);
244 if (sliced) {
245 sourceDtsBySource[source] = sliced;
246 }
247 }
248 }
249
250 declarations = generateToolDeclarations(toolsForTypecheck, {
251 sourceDtsBySource,
252 });
253
254 try {
255 const blob = new Blob([declarations], { type: "text/plain" });
256 const storageId = await ctx.storage.store(blob);
257 await ctx.runMutation(internal.typecheckDeclarationCache.putEntry, {
258 workspaceId: args.workspaceId,
259 cacheKey,
260 storageId,
261 sizeBytes: declarations.length,
262 maxEntriesPerWorkspace: DECLARATION_CACHE_MAX_ENTRIES_PER_WORKSPACE,
263 });
264 } catch (error) {
265 const message = error instanceof Error ? error.message : String(error);
266 console.warn(`[executor] declaration cache write failed for '${args.workspaceId}': ${message}`);
267 }
268 }
269
270 const declarationText = declarations ?? "declare const tools: {};";
271 const typecheck = typecheckCode(args.code, declarationText);
34 272
35 return {273 return {
36 ok: typecheck.ok,274 ok: typecheck.ok,
executor/packages/convex/schema.ts+10−0
@@ -313,6 +313,16 @@ export default defineSchema({ · 312 unmodified lines
313 })313 })
314 .index("by_workspace", ["workspaceId"]),314 .index("by_workspace", ["workspaceId"]),
315 315
316 typecheckDeclarationCache: defineTable({
317 workspaceId: v.id("workspaces"),
318 cacheKey: v.string(),
319 storageId: v.id("_storage"),
320 sizeBytes: v.number(),
321 createdAt: v.number(),
322 })
323 .index("by_workspace_cache_key", ["workspaceId", "cacheKey"])
324 .index("by_workspace_created", ["workspaceId", "createdAt"]),
325
316 anonymousSessions: defineTable({326 anonymousSessions: defineTable({
317 sessionId: v.string(), // domain ID: anon_session_<uuid> or mcp_<uuid>327 sessionId: v.string(), // domain ID: anon_session_<uuid> or mcp_<uuid>
318 workspaceId: v.id("workspaces"),328 workspaceId: v.id("workspaces"),
executor/packages/convex/typecheckDeclarationCache.test.ts+188−0
@@ -0,0 +1,188 @@
1import { describe, expect, test } from "bun:test";
2import { convexTest } from "convex-test";
3import { internal } from "./_generated/api";
4import schema from "./schema";
5
6function setup() {
7 return convexTest(schema, {
8 "./typecheckDeclarationCache.ts": () => import("./typecheckDeclarationCache"),
9 "./_generated/api.js": () => import("./_generated/api.js"),
10 });
11}
12
13describe("typecheckDeclarationCache table operations", () => {
14 test("getEntry returns null for empty cache", async () => {
15 const t = setup();
16 const workspaceId = await t.run(async (ctx) => {
17 const orgId = await ctx.db.insert("organizations", {
18 slug: "org",
19 name: "Org",
20 status: "active",
21 createdAt: Date.now(),
22 updatedAt: Date.now(),
23 });
24 return await ctx.db.insert("workspaces", {
25 organizationId: orgId,
26 slug: "ws",
27 name: "Workspace",
28 createdAt: Date.now(),
29 updatedAt: Date.now(),
30 });
31 });
32
33 const result = await t.query(internal.typecheckDeclarationCache.getEntry, {
34 workspaceId,
35 cacheKey: "cache:a",
36 maxAgeMs: 10_000,
37 });
38
39 expect(result).toBeNull();
40 });
41
42 test("putEntry stores and getEntry retrieves", async () => {
43 const t = setup();
44 const workspaceId = await t.run(async (ctx) => {
45 const orgId = await ctx.db.insert("organizations", {
46 slug: "org2",
47 name: "Org 2",
48 status: "active",
49 createdAt: Date.now(),
50 updatedAt: Date.now(),
51 });
52 return await ctx.db.insert("workspaces", {
53 organizationId: orgId,
54 slug: "ws2",
55 name: "Workspace 2",
56 createdAt: Date.now(),
57 updatedAt: Date.now(),
58 });
59 });
60
61 const storageId = await t.run(async (ctx) => {
62 return await ctx.storage.store(new Blob(["declare const tools: {};"], { type: "text/plain" }));
63 });
64
65 await t.mutation(internal.typecheckDeclarationCache.putEntry, {
66 workspaceId,
67 cacheKey: "cache:b",
68 storageId,
69 sizeBytes: 24,
70 });
71
72 const entry = await t.query(internal.typecheckDeclarationCache.getEntry, {
73 workspaceId,
74 cacheKey: "cache:b",
75 maxAgeMs: 10_000,
76 });
77
78 expect(entry).not.toBeNull();
79 expect(entry!.storageId).toBe(storageId);
80 expect(entry!.sizeBytes).toBe(24);
81 });
82
83 test("putEntry replaces existing key and deletes stale blob", async () => {
84 const t = setup();
85 const workspaceId = await t.run(async (ctx) => {
86 const orgId = await ctx.db.insert("organizations", {
87 slug: "org3",
88 name: "Org 3",
89 status: "active",
90 createdAt: Date.now(),
91 updatedAt: Date.now(),
92 });
93 return await ctx.db.insert("workspaces", {
94 organizationId: orgId,
95 slug: "ws3",
96 name: "Workspace 3",
97 createdAt: Date.now(),
98 updatedAt: Date.now(),
99 });
100 });
101
102 const firstStorageId = await t.run(async (ctx) => {
103 return await ctx.storage.store(new Blob(["first"], { type: "text/plain" }));
104 });
105
106 await t.mutation(internal.typecheckDeclarationCache.putEntry, {
107 workspaceId,
108 cacheKey: "cache:c",
109 storageId: firstStorageId,
110 sizeBytes: 5,
111 });
112
113 const secondStorageId = await t.run(async (ctx) => {
114 return await ctx.storage.store(new Blob(["second"], { type: "text/plain" }));
115 });
116
117 await t.mutation(internal.typecheckDeclarationCache.putEntry, {
118 workspaceId,
119 cacheKey: "cache:c",
120 storageId: secondStorageId,
121 sizeBytes: 6,
122 });
123
124 const entry = await t.query(internal.typecheckDeclarationCache.getEntry, {
125 workspaceId,
126 cacheKey: "cache:c",
127 maxAgeMs: 10_000,
128 });
129 expect(entry!.storageId).toBe(secondStorageId);
130
131 const staleBlob = await t.run(async (ctx) => await ctx.storage.get(firstStorageId));
132 expect(staleBlob).toBeNull();
133 });
134
135 test("putEntry prunes old rows when maxEntriesPerWorkspace is set", async () => {
136 const t = setup();
137 const workspaceId = await t.run(async (ctx) => {
138 const orgId = await ctx.db.insert("organizations", {
139 slug: "org4",
140 name: "Org 4",
141 status: "active",
142 createdAt: Date.now(),
143 updatedAt: Date.now(),
144 });
145 return await ctx.db.insert("workspaces", {
146 organizationId: orgId,
147 slug: "ws4",
148 name: "Workspace 4",
149 createdAt: Date.now(),
150 updatedAt: Date.now(),
151 });
152 });
153
154 for (const key of ["cache:1", "cache:2", "cache:3"]) {
155 const storageId = await t.run(async (ctx) => {
156 return await ctx.storage.store(new Blob([key], { type: "text/plain" }));
157 });
158 await t.mutation(internal.typecheckDeclarationCache.putEntry, {
159 workspaceId,
160 cacheKey: key,
161 storageId,
162 sizeBytes: key.length,
163 maxEntriesPerWorkspace: 2,
164 });
165 await new Promise((resolve) => setTimeout(resolve, 2));
166 }
167
168 const oldest = await t.query(internal.typecheckDeclarationCache.getEntry, {
169 workspaceId,
170 cacheKey: "cache:1",
171 maxAgeMs: 10_000,
172 });
173 const newestA = await t.query(internal.typecheckDeclarationCache.getEntry, {
174 workspaceId,
175 cacheKey: "cache:2",
176 maxAgeMs: 10_000,
177 });
178 const newestB = await t.query(internal.typecheckDeclarationCache.getEntry, {
179 workspaceId,
180 cacheKey: "cache:3",
181 maxAgeMs: 10_000,
182 });
183
184 expect(oldest).toBeNull();
185 expect(newestA).not.toBeNull();
186 expect(newestB).not.toBeNull();
187 });
188});
executor/packages/convex/typecheckDeclarationCache.ts+76−0
@@ -0,0 +1,76 @@
1import { v } from "convex/values";
2import { internalMutation, internalQuery } from "./_generated/server";
3
4export const getEntry = internalQuery({
5 args: {
6 workspaceId: v.id("workspaces"),
7 cacheKey: v.string(),
8 maxAgeMs: v.number(),
9 },
10 handler: async (ctx, args) => {
11 const entry = await ctx.db
12 .query("typecheckDeclarationCache")
13 .withIndex("by_workspace_cache_key", (q) =>
14 q.eq("workspaceId", args.workspaceId).eq("cacheKey", args.cacheKey),
15 )
16 .unique();
17
18 if (!entry) return null;
19
20 const ageMs = Date.now() - entry.createdAt;
21 if (ageMs > args.maxAgeMs) return null;
22
23 return {
24 storageId: entry.storageId,
25 sizeBytes: entry.sizeBytes,
26 createdAt: entry.createdAt,
27 };
28 },
29});
30
31export const putEntry = internalMutation({
32 args: {
33 workspaceId: v.id("workspaces"),
34 cacheKey: v.string(),
35 storageId: v.id("_storage"),
36 sizeBytes: v.number(),
37 maxEntriesPerWorkspace: v.optional(v.number()),
38 },
39 handler: async (ctx, args) => {
40 const existing = await ctx.db
41 .query("typecheckDeclarationCache")
42 .withIndex("by_workspace_cache_key", (q) =>
43 q.eq("workspaceId", args.workspaceId).eq("cacheKey", args.cacheKey),
44 )
45 .unique();
46
47 if (existing) {
48 await ctx.storage.delete(existing.storageId).catch(() => {});
49 await ctx.db.delete(existing._id);
50 }
51
52 await ctx.db.insert("typecheckDeclarationCache", {
53 workspaceId: args.workspaceId,
54 cacheKey: args.cacheKey,
55 storageId: args.storageId,
56 sizeBytes: args.sizeBytes,
57 createdAt: Date.now(),
58 });
59
60 const maxEntries = Math.max(1, Math.floor(args.maxEntriesPerWorkspace ?? 64));
61 const entries = await ctx.db
62 .query("typecheckDeclarationCache")
63 .withIndex("by_workspace_created", (q) => q.eq("workspaceId", args.workspaceId))
64 .order("desc")
65 .collect();
66
67 if (entries.length <= maxEntries) {
68 return;
69 }
70
71 for (const stale of entries.slice(maxEntries)) {
72 await ctx.storage.delete(stale.storageId).catch(() => {});
73 await ctx.db.delete(stale._id);
74 }
75 },
76});
executor/packages/core/src/mcp-gateway-oauth-sdk.test.ts+1−1
@@ -93,7 +93,7 @@ class FakeMcpService { · 92 unmodified lines
93 return context;93 return context;
94 }94 }
95 95
96 subscribe(taskId: string, listener: (event: LiveTaskEvent) => void): () => void {96 subscribe(taskId: string, _workspaceId: Id<"workspaces">, listener: (event: LiveTaskEvent) => void): () => void {
97 const set = this.listeners.get(taskId) ?? new Set();97 const set = this.listeners.get(taskId) ?? new Set();
98 set.add(listener);98 set.add(listener);
99 this.listeners.set(taskId, set);99 this.listeners.set(taskId, set);
executor/packages/core/src/mcp-server.test.ts+1−1
@@ -87,7 +87,7 @@ class FakeMcpService { · 86 unmodified lines
87 return context;87 return context;
88 }88 }
89 89
90 subscribe(taskId: string, listener: (event: LiveTaskEvent) => void): () => void {90 subscribe(taskId: string, _workspaceId: Id<"workspaces">, listener: (event: LiveTaskEvent) => void): () => void {
91 const set = this.listeners.get(taskId) ?? new Set();91 const set = this.listeners.get(taskId) ?? new Set();
92 set.add(listener);92 set.add(listener);
93 this.listeners.set(taskId, set);93 this.listeners.set(taskId, set);
executor/packages/core/src/mcp-server.ts+56−39
@@ -24,8 +24,9 @@ function getTaskTerminalState(status: string): boolean { · 23 unmodified lines
24 24
25interface McpExecutorService {25interface McpExecutorService {
26 createTask(input: CreateTaskInput): Promise<{ task: TaskRecord }>;26 createTask(input: CreateTaskInput): Promise<{ task: TaskRecord }>;
27 runTaskNow?(taskId: string): Promise<null>;
27 getTask(taskId: string, workspaceId?: Id<"workspaces">): Promise<TaskRecord | null>;28 getTask(taskId: string, workspaceId?: Id<"workspaces">): Promise<TaskRecord | null>;
28 subscribe(taskId: string, listener: (event: LiveTaskEvent) => void): () => void;29 subscribe(taskId: string, workspaceId: Id<"workspaces">, listener: (event: LiveTaskEvent) => void): () => void;
29 bootstrapAnonymousContext(sessionId?: string): Promise<AnonymousContext>;30 bootstrapAnonymousContext(sessionId?: string): Promise<AnonymousContext>;
30 listTools(context?: { workspaceId: Id<"workspaces">; actorId?: string; clientId?: string }): Promise<ToolDescriptor[]>;31 listTools(context?: { workspaceId: Id<"workspaces">; actorId?: string; clientId?: string }): Promise<ToolDescriptor[]>;
31 listToolsForTypecheck?(32 listToolsForTypecheck?(
@@ -135,7 +136,6 @@ function waitForTerminalTask( · 103 unmodified lines
135): Promise<TaskRecord | null> {136): Promise<TaskRecord | null> {
136 return new Promise((resolve) => {137 return new Promise((resolve) => {
137 let settled = false;138 let settled = false;
138 let checking = false;
139 let elicitationEnabled = Boolean(139 let elicitationEnabled = Boolean(
140 onApprovalPrompt140 onApprovalPrompt
141 && approvalContext141 && approvalContext
@@ -145,7 +145,6 @@ function waitForTerminalTask( · 3 unmodified lines
145 let loggedElicitationFallback = false;145 let loggedElicitationFallback = false;
146 const seenApprovalIds = new Set<string>();146 const seenApprovalIds = new Set<string>();
147 let unsubscribe: (() => void) | undefined;147 let unsubscribe: (() => void) | undefined;
148 let poll: ReturnType<typeof setInterval> | undefined;
149 148
150 const logElicitationFallback = (reason: string) => {149 const logElicitationFallback = (reason: string) => {
151 if (loggedElicitationFallback) return;150 if (loggedElicitationFallback) return;
@@ -156,7 +155,7 @@ function waitForTerminalTask( · 4 unmodified lines
156 const done = async () => {155 const done = async () => {
157 if (settled) return;156 if (settled) return;
158 settled = true;157 settled = true;
159 if (poll) clearInterval(poll);158 clearTimeout(timeout);
160 unsubscribe?.();159 unsubscribe?.();
161 resolve(await service.getTask(taskId, workspaceId));160 resolve(await service.getTask(taskId, workspaceId));
162 };161 };
@@ -202,44 +201,34 @@ function waitForTerminalTask( · 39 unmodified lines
202 }201 }
203 };202 };
204 203
205 const checkTask = async () => {204 unsubscribe = service.subscribe(taskId, workspaceId, (event) => {
206 if (settled || checking) return;205 const payload = typeof event.payload === "object" && event.payload
207 checking = true;206 ? event.payload as Record<string, unknown>
208 try {207 : {};
209 const task = await service.getTask(taskId, workspaceId);208 const type = typeof payload.status === "string" ? payload.status : undefined;
210 if (task && getTaskTerminalState(task.status)) {209 const pendingApprovalCount = typeof payload.pendingApprovalCount === "number"
211 clearTimeout(timeout);210 ? payload.pendingApprovalCount
212 await done();211 : 0;
213 return;212
214 }213 if (typeof type === "string" && getTaskTerminalState(type)) {
214 void done();
215 return;
216 }
215 217
216 await maybeHandleApprovals();218 if (pendingApprovalCount > 0) {
217 } finally {219 void maybeHandleApprovals().catch(() => {});
218 checking = false;
219 }220 }
220 };221 });
221 222
222 poll = setInterval(() => {223 // Prompt immediately if there are already pending approvals.
223 void checkTask().catch(() => {});224 void maybeHandleApprovals().catch(() => {});
224 }, 400);225
225 226 // Handle tasks that were already terminal before subscribe connected.
226 // Check if already terminal before subscribing (race condition guard)227 void service.getTask(taskId, workspaceId).then((task) => {
227 void checkTask().catch(() => {});228 if (task && getTaskTerminalState(task.status)) {
228 229 void done();
229 // Subscribe for live events when available; polling remains as fallback.230 }
230 try {231 }).catch(() => {});
231 unsubscribe = service.subscribe(taskId, (event) => {
232 const type = typeof event.payload === "object" && event.payload
233 ? (event.payload as Record<string, unknown>).status
234 : undefined;
235 if (typeof type === "string" && getTaskTerminalState(type)) {
236 clearTimeout(timeout);
237 void done();
238 }
239 });
240 } catch {
241 // Ignore subscription errors and rely on polling.
242 }
243 });232 });
244}233}
245 234
@@ -493,6 +482,34 @@ function createRunCodeTool( · 247 unmodified lines
493 };482 };
494 }483 }
495 484
485 if (service.runTaskNow) {
486 await service.runTaskNow(created.task.id);
487 const task = await service.getTask(created.task.id, context.workspaceId);
488 if (!task) {
489 return {
490 content: [textContent(`Task ${created.task.id} not found after execution`)],
491 isError: true,
492 };
493 }
494
495 const isError = task.status !== "completed";
496 return {
497 content: [textContent(summarizeTask(task))],
498 structuredContent: {
499 taskId: task.id,
500 status: task.status,
501 runtimeId: task.runtimeId,
502 exitCode: task.exitCode,
503 error: task.error,
504 result: task.result,
505 workspaceId: context.workspaceId,
506 actorId: context.actorId,
507 sessionId: context.sessionId,
508 },
509 ...(isError ? { isError: true } : {}),
510 };
511 }
512
496 const waitTimeoutMs = input.resultTimeoutMs ?? Math.max(requestedTimeoutMs + 30_000, 120_000);513 const waitTimeoutMs = input.resultTimeoutMs ?? Math.max(requestedTimeoutMs + 30_000, 120_000);
497 const task = await waitForTerminalTask(514 const task = await waitForTerminalTask(
498 service,515 service,
executor/packages/core/src/runtimes/cloudflare-worker-loader-runtime.test.ts+9−9
@@ -7,8 +7,8 @@ let fakeCallbackServer: ReturnType<typeof Bun.serve>; · 6 unmodified lines
7const AUTH_TOKEN = "test-sandbox-token";7const AUTH_TOKEN = "test-sandbox-token";
8const CALLBACK_TOKEN = "test-callback-token";8const CALLBACK_TOKEN = "test-callback-token";
9 9
10let hostResponseStatus = 202;10let hostResponseStatus = 200;
11let hostResponseBody: Record<string, unknown> = { accepted: true, dispatchId: "dispatch_test" };11let hostResponseBody: Record<string, unknown> = { status: "completed", result: { ok: true }, exitCode: 0 };
12type HostRequestBody = {12type HostRequestBody = {
13 taskId: string;13 taskId: string;
14 code: string;14 code: string;
@@ -60,9 +60,9 @@ afterAll(() => { · 45 unmodified lines
60});60});
61 61
62describe("cloudflare worker loader dispatch", () => {62describe("cloudflare worker loader dispatch", () => {
63 test("dispatches run request and returns accepted response", async () => {63 test("dispatches run request and returns terminal response", async () => {
64 hostResponseStatus = 202;64 hostResponseStatus = 200;
65 hostResponseBody = { accepted: true, dispatchId: "dispatch_abc" };65 hostResponseBody = { status: "completed", result: { ok: true }, exitCode: 0 };
66 lastHostRequestBody = null;66 lastHostRequestBody = null;
67 67
68 const result = await dispatchCodeWithCloudflareWorkerLoader({68 const result = await dispatchCodeWithCloudflareWorkerLoader({
@@ -73,8 +73,8 @@ describe("cloudflare worker loader dispatch", () => { · 4 unmodified lines
73 73
74 expect(result.ok).toBe(true);74 expect(result.ok).toBe(true);
75 if (result.ok) {75 if (result.ok) {
76 expect(result.accepted).toBe(true);76 expect(result.status).toBe("completed");
77 expect(result.dispatchId).toBe("dispatch_abc");77 expect(result.exitCode).toBe(0);
78 }78 }
79 79
80 expect(lastHostRequestBody).not.toBeNull();80 expect(lastHostRequestBody).not.toBeNull();
@@ -84,8 +84,8 @@ describe("cloudflare worker loader dispatch", () => { · 3 unmodified lines
84 });84 });
85 85
86 test("transpiles TypeScript before dispatching", async () => {86 test("transpiles TypeScript before dispatching", async () => {
87 hostResponseStatus = 202;87 hostResponseStatus = 200;
88 hostResponseBody = { accepted: true, dispatchId: "dispatch_ts" };88 hostResponseBody = { status: "completed", result: { ok: true }, exitCode: 0 };
89 lastHostRequestBody = null;89 lastHostRequestBody = null;
90 90
91 const result = await dispatchCodeWithCloudflareWorkerLoader({91 const result = await dispatchCodeWithCloudflareWorkerLoader({
executor/packages/core/src/runtimes/cloudflare-worker-loader-runtime.ts+26−18
@@ -24,17 +24,20 @@ import { transpileForRuntime } from "./transpile"; · 23 unmodified lines
24 * 4. Only explicit `return` values from the isolate are included in callback24 * 4. Only explicit `return` values from the isolate are included in callback
25 * completion payloads.25 * completion payloads.
26 *26 *
27 * 5. The host Worker accepts the run immediately and finishes execution27 * 5. The host Worker executes the run inline and returns the terminal result
28 * asynchronously, reporting terminal results back through callback RPC.28 * directly in the HTTP response.
29 *29 *
30 * ## Callback authentication30 * ## Callback authentication
31 *31 *
32 * The host Worker authenticates callback RPCs using `EXECUTOR_INTERNAL_TOKEN`.32 * The host Worker authenticates tool-callback RPCs using
33 * `EXECUTOR_INTERNAL_TOKEN`.
33 */34 */
34export interface CloudflareDispatchResult {35export interface CloudflareDispatchResult {
35 ok: true;36 ok: true;
36 accepted: true;37 status: "completed" | "failed" | "timed_out" | "denied";
37 dispatchId: string;38 result?: unknown;
39 error?: string;
40 exitCode?: number;
38 durationMs: number;41 durationMs: number;
39}42}
40 43
@@ -64,7 +67,8 @@ export async function dispatchCodeWithCloudflareWorkerLoader( · 23 unmodified lines
64 67
65 // ── POST to CF host worker ────────────────────────────────────────────68 // ── POST to CF host worker ────────────────────────────────────────────
66 const controller = new AbortController();69 const controller = new AbortController();
67 const timeout = setTimeout(() => controller.abort(), config.requestTimeoutMs);70 const waitTimeoutMs = Math.max(config.requestTimeoutMs, request.timeoutMs + 30_000);
71 const timeout = setTimeout(() => controller.abort(), waitTimeoutMs);
68 72
69 const response = await Result.tryPromise(() =>73 const response = await Result.tryPromise(() =>
70 fetch(config.runUrl, {74 fetch(config.runUrl, {
@@ -92,39 +96,43 @@ export async function dispatchCodeWithCloudflareWorkerLoader( · 21 unmodified lines
92 const cause = response.error.cause;96 const cause = response.error.cause;
93 const isAbort = cause instanceof DOMException && cause.name === "AbortError";97 const isAbort = cause instanceof DOMException && cause.name === "AbortError";
94 if (isAbort) {98 if (isAbort) {
95 return mkError(`Cloudflare sandbox dispatch timed out after ${config.requestTimeoutMs}ms`);99 return mkError(`Cloudflare sandbox execution timed out after ${waitTimeoutMs}ms`);
96 }100 }
97 const message = cause instanceof Error ? cause.message : String(cause);101 const message = cause instanceof Error ? cause.message : String(cause);
98 return mkError(`Cloudflare sandbox dispatch failed: ${message}`);102 return mkError(`Cloudflare sandbox dispatch failed: ${message}`);
99 }103 }
100 104
101 // ── Handle non-accepted HTTP status ───────────────────────────────────105 // ── Handle non-success HTTP status ────────────────────────────────────
102 if (response.value.status !== 202) {106 if (response.value.status !== 200) {
103 const text = await Result.tryPromise(() => response.value.text());107 const text = await Result.tryPromise(() => response.value.text());
104 const body = text.unwrapOr(response.value.statusText);108 const body = text.unwrapOr(response.value.statusText);
105 return mkError(`Cloudflare sandbox dispatch returned ${response.value.status}: ${body}`);109 return mkError(`Cloudflare sandbox execution returned ${response.value.status}: ${body}`);
106 }110 }
107 111
108 // ── Parse accepted response JSON ──────────────────────────────────────112 // ── Parse terminal response JSON ──────────────────────────────────────
109 const body = await Result.tryPromise(() =>113 const body = await Result.tryPromise(() =>
110 response.value.json() as Promise<{114 response.value.json() as Promise<{
111 accepted?: boolean;115 status?: "completed" | "failed" | "timed_out" | "denied";
112 dispatchId?: string;116 result?: unknown;
117 error?: string;
118 exitCode?: number;
113 }>,119 }>,
114 );120 );
115 121
116 if (body.isErr()) {122 if (body.isErr()) {
117 return mkError("Cloudflare sandbox dispatch returned invalid JSON");123 return mkError("Cloudflare sandbox execution returned invalid JSON");
118 }124 }
119 125
120 if (!body.value.accepted || !body.value.dispatchId) {126 if (!body.value.status) {
121 return mkError("Cloudflare sandbox dispatch response missing accepted/dispatchId");127 return mkError("Cloudflare sandbox execution response missing status");
122 }128 }
123 129
124 return {130 return {
125 ok: true,131 ok: true,
126 accepted: true,132 status: body.value.status,
127 dispatchId: body.value.dispatchId,133 result: body.value.result,
134 error: body.value.error,
135 exitCode: body.value.exitCode,
128 durationMs: Date.now() - startedAt,136 durationMs: Date.now() - startedAt,
129 };137 };
130}138}
executor/packages/core/src/runtimes/runtime-catalog.ts+1−1
@@ -19,7 +19,7 @@ export interface CloudflareWorkerLoaderConfig { · 18 unmodified lines
19 runUrl: string;19 runUrl: string;
20 /** Shared-secret bearer token for authenticating with the host worker. */20 /** Shared-secret bearer token for authenticating with the host worker. */
21 authToken: string;21 authToken: string;
22 /** Dispatch request timeout in ms (how long we wait for /v1/runs accepted response). */22 /** Execution request timeout in ms (how long we wait for /v1/runs terminal response). */
23 requestTimeoutMs: number;23 requestTimeoutMs: number;
24 /** Convex deployment URL used for runtime callback RPC invocations. */24 /** Convex deployment URL used for runtime callback RPC invocations. */
25 callbackConvexUrl: string;25 callbackConvexUrl: string;
executor/packages/core/src/runtimes/runtime-core.ts+19−1
@@ -18,6 +18,21 @@ async function sleep(ms: number): Promise<void> { · 17 unmodified lines
18 await new Promise((resolve) => setTimeout(resolve, ms));18 await new Promise((resolve) => setTimeout(resolve, ms));
19}19}
20 20
21const MIN_RETRY_DELAY_MS = 200;
22const MAX_RETRY_DELAY_MS = 5_000;
23const RETRY_BACKOFF_MULTIPLIER = 1.6;
24
25function clampRetryDelayMs(ms: number): number {
26 return Math.min(MAX_RETRY_DELAY_MS, Math.max(MIN_RETRY_DELAY_MS, Math.round(ms)));
27}
28
29function withJitter(ms: number): number {
30 const spread = Math.round(ms * 0.2);
31 const min = Math.max(50, ms - spread);
32 const max = ms + spread;
33 return min + Math.floor(Math.random() * (max - min + 1));
34}
35
21function sanitizeExecutionResult(value: unknown): unknown {36function sanitizeExecutionResult(value: unknown): unknown {
22 if (value === undefined) {37 if (value === undefined) {
23 return undefined;38 return undefined;
@@ -55,6 +70,7 @@ function createToolsProxy( · 31 unmodified lines
55 70
56 const input = args.length > 0 ? args[0] : {};71 const input = args.length > 0 ? args[0] : {};
57 const callId = `call_${crypto.randomUUID()}`;72 const callId = `call_${crypto.randomUUID()}`;
73 let retryDelayMs = MIN_RETRY_DELAY_MS;
58 74
59 while (true) {75 while (true) {
60 const result = await adapter.invokeTool({76 const result = await adapter.invokeTool({
@@ -70,7 +86,9 @@ function createToolsProxy( · 9 unmodified lines
70 86
71 switch (result.kind) {87 switch (result.kind) {
72 case "pending":88 case "pending":
73 await sleep(Math.max(50, result.retryAfterMs ?? 500));89 retryDelayMs = clampRetryDelayMs(Math.max(result.retryAfterMs ?? 0, retryDelayMs));
90 await sleep(withJitter(retryDelayMs));
91 retryDelayMs = clampRetryDelayMs(retryDelayMs * RETRY_BACKOFF_MULTIPLIER);
74 continue;92 continue;
75 case "denied":93 case "denied":
76 throw new Error(`${APPROVAL_DENIED_PREFIX}${result.error}`);94 throw new Error(`${APPROVAL_DENIED_PREFIX}${result.error}`);
executor/packages/core/src/typechecker.test.ts+95−1
@@ -1,5 +1,12 @@
1import { test, expect, describe } from "bun:test";1import { test, expect, describe } from "bun:test";
2import { generateToolDeclarations, generateToolInventory, typecheckCode, type TypecheckResult } from "./typechecker";2import {
3 analyzeToolReferences,
4 generateToolDeclarations,
5 generateToolInventory,
6 parseTsgoDiagnostics,
7 sliceOpenApiOperationsDts,
8 typecheckCode,
9} from "./typechecker";
3import { prepareOpenApiSpec, buildOpenApiToolsFromPrepared } from "./tool-sources";10import { prepareOpenApiSpec, buildOpenApiToolsFromPrepared } from "./tool-sources";
4import type { ToolDescriptor } from "./types";11import type { ToolDescriptor } from "./types";
5 12
@@ -33,6 +40,93 @@ const FLAT_TOOL: ToolDescriptor = { · 27 unmodified lines
33 40
34const ALL_TOOLS = [MATH_TOOL, ADMIN_TOOL, FLAT_TOOL];41const ALL_TOOLS = [MATH_TOOL, ADMIN_TOOL, FLAT_TOOL];
35 42
43describe("analyzeToolReferences", () => {
44 test("extracts static tool call paths", () => {
45 const analysis = analyzeToolReferences(`
46 await tools.github.issues.list_for_repo({ owner: "a", repo: "b" });
47 await tools["vercel"]["dns"].get_records({ domain: "executor.sh" });
48 `);
49
50 expect(analysis.callPaths).toEqual([
51 "github.issues.list_for_repo",
52 "vercel.dns.get_records",
53 ]);
54 expect(analysis.hasDynamicToolAccess).toBe(false);
55 expect(analysis.hasNonCallToolAccess).toBe(false);
56 });
57
58 test("marks dynamic and non-call tool access", () => {
59 const analysis = analyzeToolReferences(`
60 const dns = tools.vercel.dns;
61 await tools[sourceName].records.get({});
62 return dns;
63 `);
64
65 expect(analysis.callPaths).toEqual([]);
66 expect(analysis.hasDynamicToolAccess).toBe(true);
67 expect(analysis.hasNonCallToolAccess).toBe(true);
68 });
69});
70
71describe("sliceOpenApiOperationsDts", () => {
72 test("keeps only requested operations", () => {
73 const dts = `
74export interface operations {
75 "issues/list-for-repo": {
76 parameters: { path: { owner: string; repo: string } };
77 };
78 createRecord: {
79 parameters: { path: { domain: string } };
80 };
81}
82
83export interface components {
84 schemas: {
85 Example: { ok: boolean };
86 };
87}
88`;
89
90 const sliced = sliceOpenApiOperationsDts(dts, ["createRecord"]);
91 expect(sliced).toBeDefined();
92 expect(sliced).toContain("createRecord");
93 expect(sliced).not.toContain("issues/list-for-repo");
94 expect(sliced).not.toContain("interface components");
95 });
96
97 test("returns null when operation cannot be found", () => {
98 const dts = `export interface operations { ping: { parameters: {} }; }`;
99 const sliced = sliceOpenApiOperationsDts(dts, ["missing"]);
100 expect(sliced).toBeNull();
101 });
102});
103
104describe("parseTsgoDiagnostics", () => {
105 test("extracts and line-adjusts generated.ts diagnostics", () => {
106 const output = [
107 "generated.ts(21,14): error TS2339: Property 'name' does not exist on type '{}'.",
108 "generated.ts(22,3): error TS2345: Argument of type 'number' is not assignable to parameter of type 'string'.",
109 "Some unrelated line",
110 ].join("\n");
111
112 const parsed = parseTsgoDiagnostics(output, 20);
113 expect(parsed).toEqual([
114 "Line 1: Property 'name' does not exist on type '{}'.",
115 "Line 2: Argument of type 'number' is not assignable to parameter of type 'string'.",
116 ]);
117 });
118
119 test("ignores header diagnostics and non-matching lines", () => {
120 const output = [
121 "generated.ts(5,1): error TS1005: ';' expected.",
122 "generated.ts(6,1): error TS1005: ';' expected.",
123 "",
124 ].join("\n");
125
126 expect(parseTsgoDiagnostics(output, 6)).toEqual([]);
127 });
128});
129
36// ---------------------------------------------------------------------------130// ---------------------------------------------------------------------------
37// generateToolDeclarations131// generateToolDeclarations
38// ---------------------------------------------------------------------------132// ---------------------------------------------------------------------------
executor/packages/core/src/typechecker.ts+457−8
@@ -53,6 +53,228 @@ function safeTypeExpression(raw: string | undefined, fallback: string): string { · 52 unmodified lines
53 return isValidTypeExpression(typeExpression) ? typeExpression : fallback;53 return isValidTypeExpression(typeExpression) ? typeExpression : fallback;
54}54}
55 55
56export interface ToolReferenceAnalysis {
57 callPaths: string[];
58 hasDynamicToolAccess: boolean;
59 hasNonCallToolAccess: boolean;
60}
61
62type StaticToolPathResult = {
63 segments: string[] | null;
64 dynamic: boolean;
65};
66
67function unwrapToolAccessExpression(
68 expression: import("typescript").Expression,
69 ts: typeof import("typescript"),
70): import("typescript").Expression {
71 let current = expression;
72 while (
73 ts.isParenthesizedExpression(current)
74 || ts.isAsExpression(current)
75 || ts.isTypeAssertionExpression(current)
76 || ts.isNonNullExpression(current)
77 || ts.isPartiallyEmittedExpression(current)
78 || ts.isSatisfiesExpression(current)
79 ) {
80 current = current.expression;
81 }
82 return current;
83}
84
85function parseStaticToolPath(
86 expression: import("typescript").Expression,
87 ts: typeof import("typescript"),
88): StaticToolPathResult {
89 const unwrapped = unwrapToolAccessExpression(expression, ts);
90
91 if (ts.isIdentifier(unwrapped)) {
92 if (unwrapped.text === "tools") {
93 return { segments: [], dynamic: false };
94 }
95 return { segments: null, dynamic: false };
96 }
97
98 if (ts.isPropertyAccessExpression(unwrapped)) {
99 const base = parseStaticToolPath(unwrapped.expression, ts);
100 if (!base.segments) return base;
101 return {
102 segments: [...base.segments, unwrapped.name.text],
103 dynamic: base.dynamic,
104 };
105 }
106
107 if (ts.isElementAccessExpression(unwrapped)) {
108 const base = parseStaticToolPath(unwrapped.expression, ts);
109 if (!base.segments) return base;
110
111 const argument = unwrapped.argumentExpression
112 ? unwrapToolAccessExpression(unwrapped.argumentExpression, ts)
113 : null;
114
115 if (argument && (ts.isStringLiteral(argument) || ts.isNoSubstitutionTemplateLiteral(argument))) {
116 return {
117 segments: [...base.segments, argument.text],
118 dynamic: base.dynamic,
119 };
120 }
121
122 return {
123 segments: base.segments,
124 dynamic: true,
125 };
126 }
127
128 return { segments: null, dynamic: false };
129}
130
131/**
132 * Analyze user code and extract static tool call paths like
133 * `tools.github.issues.list_for_repo(...)`.
134 */
135export function analyzeToolReferences(code: string): ToolReferenceAnalysis {
136 const ts = getTypeScriptModule();
137 if (!ts) {
138 return {
139 callPaths: [],
140 hasDynamicToolAccess: true,
141 hasNonCallToolAccess: true,
142 };
143 }
144
145 const sourceFile = ts.createSourceFile(
146 "generated_user_code.ts",
147 code,
148 ts.ScriptTarget.ESNext,
149 true,
150 ts.ScriptKind.TS,
151 );
152
153 const callPaths = new Set<string>();
154 let hasDynamicToolAccess = false;
155 let hasNonCallToolAccess = false;
156
157 const isInCallTargetChain = (node: import("typescript").Node): boolean => {
158 let current = node;
159 while (
160 (ts.isPropertyAccessExpression(current.parent) || ts.isElementAccessExpression(current.parent))
161 && current.parent.expression === current
162 ) {
163 current = current.parent;
164 }
165 return ts.isCallExpression(current.parent) && current.parent.expression === current;
166 };
167
168 const visit = (node: import("typescript").Node): void => {
169 if (ts.isCallExpression(node)) {
170 const parsed = parseStaticToolPath(node.expression, ts);
171 if (parsed.segments && parsed.segments.length > 0 && !parsed.dynamic) {
172 callPaths.add(parsed.segments.join("."));
173 }
174 if (parsed.dynamic) {
175 hasDynamicToolAccess = true;
176 }
177 }
178
179 if (ts.isPropertyAccessExpression(node) || ts.isElementAccessExpression(node)) {
180 const parsed = parseStaticToolPath(node, ts);
181 if (parsed.segments) {
182 if (!isInCallTargetChain(node)) {
183 hasNonCallToolAccess = true;
184 }
185 if (parsed.dynamic) {
186 hasDynamicToolAccess = true;
187 }
188 }
189 }
190
191 ts.forEachChild(node, visit);
192 };
193
194 visit(sourceFile);
195
196 return {
197 callPaths: [...callPaths].sort((a, b) => a.localeCompare(b)),
198 hasDynamicToolAccess,
199 hasNonCallToolAccess,
200 };
201}
202
203function propertyNameText(
204 name: import("typescript").PropertyName,
205 ts: typeof import("typescript"),
206): string | null {
207 if (ts.isIdentifier(name)) return name.text;
208 if (ts.isStringLiteral(name) || ts.isNoSubstitutionTemplateLiteral(name)) return name.text;
209 if (ts.isNumericLiteral(name)) return name.text;
210 if (ts.isComputedPropertyName(name)) {
211 const expression = unwrapToolAccessExpression(name.expression, ts);
212 if (ts.isStringLiteral(expression) || ts.isNoSubstitutionTemplateLiteral(expression) || ts.isNumericLiteral(expression)) {
213 return expression.text;
214 }
215 }
216 return null;
217}
218
219function indentBlock(text: string, indent = " "): string {
220 return text
221 .split("\n")
222 .map((line) => (line.trim().length === 0 ? "" : `${indent}${line}`))
223 .join("\n");
224}
225
226/**
227 * Build a minimal OpenAPI .d.ts containing only selected `operations` members.
228 * Returns null when slicing is not possible and callers should use full .d.ts.
229 */
230export function sliceOpenApiOperationsDts(
231 dts: string,
232 operationIds: Iterable<string>,
233): string | null {
234 const ts = getTypeScriptModule();
235 if (!ts) return null;
236
237 const wanted = new Set([...operationIds].filter((value) => value.trim().length > 0));
238 if (wanted.size === 0) return null;
239
240 const sourceFile = ts.createSourceFile(
241 "openapi-source.d.ts",
242 dts,
243 ts.ScriptTarget.ESNext,
244 true,
245 ts.ScriptKind.TS,
246 );
247
248 const operationsInterface = sourceFile.statements.find((statement) =>
249 ts.isInterfaceDeclaration(statement)
250 && statement.name.text === "operations",
251 );
252
253 if (!operationsInterface || !ts.isInterfaceDeclaration(operationsInterface)) {
254 return null;
255 }
256
257 const selectedMembers: string[] = [];
258 for (const member of operationsInterface.members) {
259 if (!ts.isPropertySignature(member) || !member.name) continue;
260 const key = propertyNameText(member.name, ts);
261 if (!key || !wanted.has(key)) continue;
262
263 const start = member.getFullStart();
264 const raw = dts.slice(start, member.end).trim();
265 if (raw.length > 0) {
266 selectedMembers.push(raw);
267 }
268 }
269
270 if (selectedMembers.length === 0) {
271 return null;
272 }
273
274 const body = selectedMembers.map((member) => indentBlock(member)).join("\n");
275 return `export interface operations {\n${body}\n}`;
276}
277
56const OPENAPI_HELPER_TYPES = `278const OPENAPI_HELPER_TYPES = `
57type _Normalize<T> = Exclude<T, undefined | null>;279type _Normalize<T> = Exclude<T, undefined | null>;
58type _OrEmpty<T> = [_Normalize<T>] extends [never] ? {} : _Normalize<T>;280type _OrEmpty<T> = [_Normalize<T>] extends [never] ? {} : _Normalize<T>;
@@ -175,7 +397,12 @@ export function generateToolDeclarations( · 116 unmodified lines
175 const inputType = strictArgsType !== "{}"397 const inputType = strictArgsType !== "{}"
176 ? `(${openApiInputType}) & (${strictArgsType})`398 ? `(${openApiInputType}) & (${strictArgsType})`
177 : openApiInputType;399 : openApiInputType;
178 lines.push(`${pad}${key}(input: ${inputType}): Promise<ToolOutput<operations[${opKey}]>>;`);400 const openApiOutputType = `ToolOutput<operations[${opKey}]>`;
401 const strictReturnsType = safeTypeExpression(tool.strictReturnsType, "unknown");
402 const outputType = strictReturnsType !== "unknown"
403 ? `(${openApiOutputType}) & (${strictReturnsType})`
404 : openApiOutputType;
405 lines.push(`${pad}${key}(input: ${inputType}): Promise<${outputType}>;`);
179 } else {406 } else {
180 const strictArgsType = tool.strictArgsType?.trim();407 const strictArgsType = tool.strictArgsType?.trim();
181 const strictReturnsType = tool.strictReturnsType?.trim();408 const strictReturnsType = tool.strictReturnsType?.trim();
@@ -275,6 +502,221 @@ const TYPECHECK_OK: TypecheckResult = { ok: true, errors: [] }; · 93 unmodified lines
275 502
276let warnedMissingCompilerHostSupport = false;503let warnedMissingCompilerHostSupport = false;
277let warnedSemanticFallback = false;504let warnedSemanticFallback = false;
505let warnedTsgoUnavailable = false;
506let warnedTsgoFallback = false;
507let resolvedTsgoExecutablePath: string | null | undefined;
508
509function getNodeProcess(): { env?: Record<string, string | undefined>; platform?: string; arch?: string } | null {
510 const candidate = (globalThis as { process?: unknown }).process;
511 return candidate && typeof candidate === "object"
512 ? (candidate as { env?: Record<string, string | undefined>; platform?: string; arch?: string })
513 : null;
514}
515
516function getNodeRequire(): ((id: string) => any) | null {
517 return Result.try(() => {
518 const candidate = Function("return typeof require === 'function' ? require : null;")() as unknown;
519 return typeof candidate === "function" ? (candidate as (id: string) => any) : null;
520 }).unwrapOr(null);
521}
522
523function wantsTsgoTypecheckEngine(): boolean {
524 const configured = getNodeProcess()?.env?.EXECUTOR_TYPECHECK_ENGINE?.trim().toLowerCase();
525 if (!configured || configured === "auto") return true;
526 if (configured === "typescript") return false;
527 return configured === "tsgo";
528}
529
530function resolveTsgoExecutablePath(opts?: { silentIfMissing?: boolean }): string | null {
531 if (resolvedTsgoExecutablePath !== undefined) {
532 return resolvedTsgoExecutablePath;
533 }
534
535 const result = Result.try(() => {
536 const processRef = getNodeProcess();
537 const platform = processRef?.platform;
538 const arch = processRef?.arch;
539 if (!platform || !arch) {
540 throw new Error("Node platform information unavailable");
541 }
542
543 const requireFn = getNodeRequire();
544 if (!requireFn) {
545 throw new Error("Node require() unavailable");
546 }
547
548 const fs = requireFn("fs");
549 const path = requireFn("path");
550 const platformPackageName = `@typescript/native-preview-${platform}-${arch}`;
551 const packageJsonPath = (requireFn as { resolve?: (id: string) => string }).resolve?.(`${platformPackageName}/package.json`);
552 if (!packageJsonPath) {
553 throw new Error(`Unable to resolve ${platformPackageName}/package.json`);
554 }
555 const executableName = platform === "win32" ? "tsgo.exe" : "tsgo";
556 const executablePath = path.join(path.dirname(packageJsonPath), "lib", executableName);
557 if (!fs.existsSync(executablePath)) {
558 throw new Error(`Executable not found at ${executablePath}`);
559 }
560 return executablePath;
561 });
562
563 if (result.isOk()) {
564 resolvedTsgoExecutablePath = result.value;
565 return result.value;
566 }
567
568 resolvedTsgoExecutablePath = null;
569 if (!opts?.silentIfMissing && !warnedTsgoUnavailable) {
570 warnedTsgoUnavailable = true;
571 console.warn(
572 `[executor] tsgo requested but unavailable, falling back to TypeScript compiler API: ${result.error.message}`,
573 );
574 }
575 return null;
576}
577
578function parseTsgoDiagnosticsInternal(output: string, headerLineCount: number): {
579 userErrors: string[];
580 matchedDiagnostics: number;
581} {
582 const lines = output
583 .split(/\r?\n/)
584 .map((line) => line.trim())
585 .filter((line) => line.length > 0);
586
587 const errors: string[] = [];
588 let matchedDiagnostics = 0;
589 const pattern = /(?:^|[\\/])generated\.ts\((\d+),(\d+)\):\s*error\s+TS\d+:\s*(.+)$/;
590
591 for (const line of lines) {
592 const match = pattern.exec(line);
593 if (!match) continue;
594 matchedDiagnostics += 1;
595 const absoluteLine = Number.parseInt(match[1] ?? "", 10);
596 const message = (match[3] ?? "Type error").trim();
597 if (!Number.isFinite(absoluteLine) || absoluteLine <= headerLineCount) {
598 continue;
599 }
600 errors.push(`Line ${absoluteLine - headerLineCount}: ${message}`);
601 }
602
603 return {
604 userErrors: errors,
605 matchedDiagnostics,
606 };
607}
608
609export function parseTsgoDiagnostics(output: string, headerLineCount: number): string[] {
610 return parseTsgoDiagnosticsInternal(output, headerLineCount).userErrors;
611}
612
613function runTsgoTypecheck(
614 wrappedCode: string,
615 headerLineCount: number,
616): TypecheckResult | null {
617 const configured = getNodeProcess()?.env?.EXECUTOR_TYPECHECK_ENGINE?.trim().toLowerCase();
618 const explicitTsgo = configured === "tsgo";
619 const executablePath = resolveTsgoExecutablePath({ silentIfMissing: !explicitTsgo });
620 if (!executablePath) {
621 return null;
622 }
623
624 const result = Result.try(() => {
625 const requireFn = getNodeRequire();
626 if (!requireFn) {
627 throw new Error("Node require() unavailable");
628 }
629
630 const fs = requireFn("fs");
631 const os = requireFn("os");
632 const path = requireFn("path");
633 const childProcess = requireFn("child_process");
634 const spawnSync = childProcess?.spawnSync;
635 if (typeof spawnSync !== "function") {
636 throw new Error("child_process.spawnSync unavailable");
637 }
638
639 const tempDirectory = fs.mkdtempSync(path.join(os.tmpdir(), "executor-tsgo-"));
640 const sourcePath = path.join(tempDirectory, "generated.ts");
641 const tsconfigPath = path.join(tempDirectory, "tsconfig.json");
642
643 try {
644 fs.writeFileSync(sourcePath, wrappedCode, "utf8");
645 fs.writeFileSync(
646 tsconfigPath,
647 JSON.stringify({
648 compilerOptions: {
649 target: "ESNext",
650 module: "ESNext",
651 strict: true,
652 noEmit: true,
653 lib: ["es2022"],
654 types: [],
655 },
656 files: ["generated.ts"],
657 }),
658 "utf8",
659 );
660
661 const command = spawnSync(
662 executablePath,
663 ["--pretty", "false", "--project", tsconfigPath],
664 {
665 cwd: tempDirectory,
666 encoding: "utf8",
667 stdio: ["ignore", "pipe", "pipe"],
668 },
669 );
670
671 if (command.error) {
672 throw command.error;
673 }
674
675 if ((command.status ?? 1) === 0) {
676 return TYPECHECK_OK;
677 }
678
679 const output = `${command.stdout ?? ""}\n${command.stderr ?? ""}`;
680 const diagnostics = parseTsgoDiagnosticsInternal(output, headerLineCount);
681 if (diagnostics.userErrors.length > 0) {
682 return {
683 ok: false as const,
684 errors: diagnostics.userErrors,
685 };
686 }
687
688 if (diagnostics.matchedDiagnostics > 0) {
689 return TYPECHECK_OK;
690 }
691
692 const fallbackMessage = output
693 .split(/\r?\n/)
694 .map((line) => line.trim())
695 .filter((line) => line.length > 0)
696 .slice(0, 3)
697 .join(" | ");
698
699 return {
700 ok: false as const,
701 errors: [fallbackMessage.length > 0 ? fallbackMessage : "tsgo typecheck failed"],
702 };
703 } finally {
704 fs.rmSync(tempDirectory, { recursive: true, force: true });
705 }
706 });
707
708 if (result.isOk()) {
709 return result.value;
710 }
711
712 if (!warnedTsgoFallback) {
713 warnedTsgoFallback = true;
714 console.warn(
715 `[executor] tsgo typecheck failed, falling back to TypeScript compiler API: ${result.error.message}`,
716 );
717 }
718 return null;
719}
278 720
279function runSyntaxOnlyTypecheck(721function runSyntaxOnlyTypecheck(
280 ts: typeof import("typescript"),722 ts: typeof import("typescript"),
@@ -374,9 +816,6 @@ export function typecheckCode( · 93 unmodified lines
374 code: string,816 code: string,
375 toolDeclarations: string,817 toolDeclarations: string,
376): TypecheckResult {818): TypecheckResult {
377 const ts = getTypeScriptModule();
378 if (!ts) return TYPECHECK_OK;
379
380 // Wrap the code in an async function with the tools declaration.819 // Wrap the code in an async function with the tools declaration.
381 // We declare sandbox globals (console, setTimeout, etc.) ourselves since820 // We declare sandbox globals (console, setTimeout, etc.) ourselves since
382 // `types: []` prevents @types/node from loading.821 // `types: []` prevents @types/node from loading.
@@ -390,6 +829,20 @@ export function typecheckCode( · 7 unmodified lines
390 "}",829 "}",
391 ].join("\n");830 ].join("\n");
392 831
832 // Count header lines so we can adjust line numbers
833 const headerLineCount =
834 toolDeclarations.split("\n").length + 4; // +4 for console, setTimeout, clearTimeout, function header
835
836 if (wantsTsgoTypecheckEngine()) {
837 const tsgoResult = runTsgoTypecheck(wrappedCode, headerLineCount);
838 if (tsgoResult) {
839 return tsgoResult;
840 }
841 }
842
843 const ts = getTypeScriptModule();
844 if (!ts) return TYPECHECK_OK;
845
393 const formatError = (846 const formatError = (
394 diagnostic: import("typescript").Diagnostic,847 diagnostic: import("typescript").Diagnostic,
395 hdrLineCount: number,848 hdrLineCount: number,
@@ -405,10 +858,6 @@ export function typecheckCode( · 9 unmodified lines
405 return message;858 return message;
406 };859 };
407 860
408 // Count header lines so we can adjust line numbers
409 const headerLineCount =
410 toolDeclarations.split("\n").length + 4; // +4 for console, setTimeout, clearTimeout, function header
411
412 if (!ts.sys || typeof ts.sys.useCaseSensitiveFileNames !== "boolean") {861 if (!ts.sys || typeof ts.sys.useCaseSensitiveFileNames !== "boolean") {
413 if (!warnedMissingCompilerHostSupport) {862 if (!warnedMissingCompilerHostSupport) {
414 warnedMissingCompilerHostSupport = true;863 warnedMissingCompilerHostSupport = true;
executor/packages/runner-sandbox-host/src/index.ts+11−67
@@ -20,8 +20,8 @@ · 19 unmodified lines
20 * 4. Console output is intentionally discarded. Only explicit `return` values20 * 4. Console output is intentionally discarded. Only explicit `return` values
21 * are included in terminal run results.21 * are included in terminal run results.
22 *22 *
23 * 5. `/v1/runs` returns an accepted dispatch response immediately. Terminal23 * 5. `/v1/runs` waits for execution to finish and returns the terminal result
24 * result status is reported back to Convex through callback RPC.24 * directly to the caller.
25 *25 *
26 * ## Code isolation26 * ## Code isolation
27 *27 *
@@ -93,11 +93,6 @@ interface RunResult { · 65 unmodified lines
93 exitCode?: number;93 exitCode?: number;
94}94}
95 95
96interface RunDispatchResponse {
97 accepted: true;
98 dispatchId: string;
99}
100
101interface ToolCallResult {96interface ToolCallResult {
102 ok: true | false;97 ok: true | false;
103 value?: unknown;98 value?: unknown;
@@ -146,10 +141,6 @@ const failedResult = (error: string): RunResult => ({ · 42 unmodified lines
146 error,141 error,
147});142});
148 143
149async function sleep(ms: number): Promise<void> {
150 await new Promise((resolve) => setTimeout(resolve, ms));
151}
152
153// ── Tool Bridge Entrypoint ───────────────────────────────────────────────────144// ── Tool Bridge Entrypoint ───────────────────────────────────────────────────
154//145//
155// This class is exposed as a named entrypoint on the host Worker. A loopback146// This class is exposed as a named entrypoint on the host Worker. A loopback
@@ -351,41 +342,6 @@ async function executeSandboxRun(request: RunRequest, ctx: ExecutionContext, env · 195 unmodified lines
351 return body.value;342 return body.value;
352}343}
353 344
354async function reportRunCompletion(request: RunRequest, result: RunResult, durationMs: number): Promise<void> {
355 const convex = new ConvexHttpClient(request.callback.convexUrl, {
356 skipConvexDeploymentUrlCheck: true,
357 });
358 let lastError: unknown = null;
359
360 for (let attempt = 1; attempt <= 3; attempt += 1) {
361 const response = await Result.tryPromise(async () => {
362 return await convex.mutation(api.runtimeCallbacks.completeRun, {
363 internalSecret: request.callback.internalSecret,
364 runId: request.taskId,
365 status: result.status,
366 result: result.result,
367 exitCode: result.exitCode,
368 error: result.error,
369 durationMs,
370 });
371 });
372
373 if (response.isOk()) {
374 return;
375 }
376
377 lastError = response.error.cause;
378 if (attempt < 3) {
379 await sleep(200 * attempt);
380 }
381 }
382
383 console.error("Failed to report run completion", {
384 taskId: request.taskId,
385 error: lastError instanceof Error ? lastError.message : String(lastError),
386 });
387}
388
389// ── Main Handler ─────────────────────────────────────────────────────────────345// ── Main Handler ─────────────────────────────────────────────────────────────
390 346
391export default {347export default {
@@ -424,27 +380,15 @@ export default { · 32 unmodified lines
424 );380 );
425 }381 }
426 382
427 const startedAt = Date.now();383 const runResult = await Result.tryPromise(() => executeSandboxRun(body, ctx, env));
428 const dispatchId = `dispatch_${body.taskId}_${startedAt}`;384 const finalResult = runResult.isOk()
429 385 ? runResult.value
430 ctx.waitUntil((async () => {386 : failedResult(
431 const runResult = await Result.tryPromise(() => executeSandboxRun(body, ctx, env));387 `Sandbox host error: ${runResult.error.cause instanceof Error
432 const finalResult = runResult.isOk()388 ? runResult.error.cause.message
433 ? runResult.value389 : String(runResult.error.cause)}`,
434 : failedResult(390 );
435 `Sandbox host error: ${runResult.error.cause instanceof Error
436 ? runResult.error.cause.message
437 : String(runResult.error.cause)}`,
438 );
439
440 await reportRunCompletion(body, finalResult, Date.now() - startedAt);
441 })());
442
443 const response: RunDispatchResponse = {
444 accepted: true,
445 dispatchId,
446 };
447 391
448 return Response.json(response, { status: 202 });392 return Response.json(finalResult, { status: 200 });
449 },393 },
450};394};